feat(worktree-remove): 手動清掉一棵工作樹,絕不 --force

pr-watch 會在 PR 合併或關閉時自動清理,但永遠不會被合併也不會被關閉的 PR 沒有出口
——沒有這一支,那些工作樹只能靠使用者自己記得去刪。兩條路共用 lib 的 removeWorktree,
不互相開子行程:守門的規則只有一份,自動的那條與手動的這條不該長出兩種行為。

**絕不 --force。** 清理會被自動執行,而自動執行的東西只能做可逆的事:工作樹重建得回來,
被刪掉的未提交變更救不回來。所以有東西沒提交就中止並報出路徑與檔名。只移除工作樹,
本機分支與遠端分支都留著。

輸入是 owner/repo 與分支名而不是一條路徑:要刪哪一棵由「哪顆工作包」決定,
使用者不必自己去記 12 碼的雜湊目錄名。

順手修掉一個測試抓出來的解析錯誤:git status --porcelain 的第一行前導空白會被 runGit
的 trim 修掉,固定切前三個字元會讓已修改檔案的檔名少一個字(README.md 變成 EADME.md),
人照著訊息去找會找不到。改成認「狀態欄一到兩個字元 + 空白」。

議題 #41

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 16:59:30 +08:00
co-authored by Claude Opus 5
parent 43764f457e
commit 9e049580bb
3 changed files with 310 additions and 0 deletions
+53
View File
@@ -446,6 +446,59 @@ export function openGitRepo(path) {
return (...args) => runGit(args, { cwd: path });
}
/**
* 看一棵工作樹現在是什麼狀況。
*
* 清理前要先知道三件事:它還在不在、裡面有沒有沒提交的東西、以及那條路徑上的東西
* 到底是不是一棵工作樹(別的 clone 也可能在同一條路徑上留下目錄——路徑由
* owner/repo/分支名 推導,不含本機 clone 的位置)。
*
* @param {string} worktree 推導出的工作樹路徑
* @returns {{path: string, exists: boolean, worktree: boolean, dirty: boolean, files: string[]}}
*/
export function inspectWorktree(worktree) {
const 空的 = { path: worktree, exists: false, worktree: false, dirty: false, files: [] };
if (!existsSync(worktree)) return 空的;
// 工作樹的 .git 是一個檔案(指回主 repo),不是目錄;沒有它就不是 git 認得的工作樹
if (!existsSync(join(worktree, '.git'))) {
return { ...空的, exists: true };
}
const files = runGit(['status', '--porcelain'], { cwd: worktree })
.split('\n')
.filter((line) => line !== '')
// 狀態欄是一到兩個字元,後面接空白才是檔名。不能固定切掉前三個字元——
// runGit 修掉了整段輸出的前後空白,第一行的「已修改」那個前導空白也跟著沒了,
// 切太多會讓檔名少一個字(README.md 變成 EADME.md),人照著去找會找不到。
.map((line) => line.replace(/^\s*\S{1,2}\s+/, ''));
return { path: worktree, exists: true, worktree: true, dirty: files.length > 0, files };
}
/**
* 移除一棵工作樹。自動清理與手動出口共用這一份實作,不互相開子行程。
*
* **絕不 `--force`。** 這件事會被 pr-watch 自動執行,而自動執行的東西只能做可逆的事:
* 工作樹重建得回來,被刪掉的未提交變更救不回來。所以有東西沒提交時就回報擋下的原因,
* 由呼叫端決定要報成錯誤(手動清理)還是一個待處理的建議(自動監看)。
*
* 只移除工作樹,**本機分支與遠端分支都保留**:本機分支不佔什麼空間,留著讓使用者
* 還能回頭看那段歷史;遠端分支要不要刪是 Gitea 合併時的選項,由使用者自己決定。
*
* @param {string} worktree 推導出的工作樹路徑
* @returns {{removed: boolean, reason: 'removed'|'missing'|'dirty'|'foreign', files: string[], path: string}}
*/
export function removeWorktree(worktree) {
const state = inspectWorktree(worktree);
if (!state.exists) return { ...state, removed: false, reason: 'missing' };
if (!state.worktree) return { ...state, removed: false, reason: 'foreign' };
if (state.dirty) return { ...state, removed: false, reason: 'dirty' };
// 在工作樹自己裡面執行:它的 .git 指得回主 repo,呼叫端因此不必知道主 clone 在哪
runGit(['worktree', 'remove', worktree], { cwd: worktree });
return { ...state, removed: true, reason: 'removed' };
}
// ── 四層前置檢查 ───────────────────────────────────────────────────
/**
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env node
/**
* 手動清掉一棵工作樹。
*
* pr-watch 會在 PR 合併或關閉時自動清理,這一支是給那些**永遠不會被合併也不會被關閉**
* 的 PR 用的出口——沒有它,那些工作樹只能靠使用者自己記得去刪。
*
* 兩條路共用 lib 的 `removeWorktree`,不互相開子行程:守門的規則只有一份,
* 自動的那條與手動的這條不該長出兩種行為。
*
* 只移除工作樹,本機分支與遠端分支都留著。工作樹裡還有沒提交的東西就中止並報出路徑,
* **絕不 `--force`**:工作樹重建得回來,被刪掉的未提交變更救不回來。
*
* 路徑由 `owner/repo/分支名` 推導,所以輸入是這兩個而不是一條路徑——要刪哪一棵由
* 「哪顆工作包」決定,使用者不必自己去記 12 碼的雜湊目錄名。
*
* 用法:
* node scripts/worktree-remove.js --repo owner/name --branch <分支名> [--dry-run]
*/
import {
ScriptError,
inspectWorktree,
main,
parseFlags,
parseRepo,
removeWorktree,
worktreePath,
} from './lib.js';
main(async () => {
const flags = parseFlags(process.argv.slice(2), {
required: ['repo', 'branch'],
booleans: ['dry-run'],
});
const repo = parseRepo(flags.repo);
const branch = flags.branch;
const worktree = worktreePath(repo, branch);
// 試跑與實跑走同一條守門:試跑印得出漂亮的計畫、實跑卻被擋下來,是最難查的那種落差
if (flags['dry-run']) {
const state = inspectWorktree(worktree);
checkRemovable({ ...state, reason: reasonOf(state) }, worktree);
return {
dryRun: true,
repo,
branch,
worktree,
已經不在: !state.exists,
commands: state.exists ? [`git worktree remove ${worktree}`] : [],
};
}
const result = removeWorktree(worktree);
checkRemovable(result, worktree);
return {
repo,
branch,
worktree,
removed: result.removed,
// 本來就不在不算失敗:重跑這一支是常態,而結果一樣是「那棵樹不在了」
已經不在: result.reason === 'missing',
};
});
/** inspectWorktree 的結果換算成 removeWorktree 用的同一組原因,讓試跑與實跑擋在同一處 */
function reasonOf(state) {
if (!state.exists) return 'missing';
if (!state.worktree) return 'foreign';
return state.dirty ? 'dirty' : 'removed';
}
/** 擋下來的兩種情況各有各的下一步,錯誤碼要分得開 */
function checkRemovable(result, worktree) {
if (result.reason === 'dirty') {
throw new ScriptError(
'WORKTREE_DIRTY',
`工作樹 ${worktree} 裡還有沒提交的東西(${result.files.join('、')});` +
'請先提交、暫存(git stash)或確認可以丟掉再自己刪除——' +
'本工具不會加 --force,刪掉的未提交變更救不回來',
);
}
if (result.reason === 'foreign') {
throw new ScriptError(
'NOT_A_WORKTREE',
`${worktree} 上有東西,但它不是一棵 git 工作樹(可能是別的 clone 留下的);` +
'請自己確認裡面沒有還沒保存的東西之後移除它',
);
}
}