From 9e049580bbded70be753ac0bac118978ce177109 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 16:59:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(worktree-remove):=20=E6=89=8B=E5=8B=95?= =?UTF-8?q?=E6=B8=85=E6=8E=89=E4=B8=80=E6=A3=B5=E5=B7=A5=E4=BD=9C=E6=A8=B9?= =?UTF-8?q?=EF=BC=8C=E7=B5=95=E4=B8=8D=20--force?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scripts/lib.js | 53 +++++++++++ scripts/worktree-remove.js | 91 +++++++++++++++++++ test/worktree-remove.test.js | 166 +++++++++++++++++++++++++++++++++++ 3 files changed, 310 insertions(+) create mode 100644 scripts/worktree-remove.js create mode 100644 test/worktree-remove.test.js diff --git a/scripts/lib.js b/scripts/lib.js index 914c4d0..74d3964 100644 --- a/scripts/lib.js +++ b/scripts/lib.js @@ -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' }; +} + // ── 四層前置檢查 ─────────────────────────────────────────────────── /** diff --git a/scripts/worktree-remove.js b/scripts/worktree-remove.js new file mode 100644 index 0000000..cc2b59c --- /dev/null +++ b/scripts/worktree-remove.js @@ -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 留下的);` + + '請自己確認裡面沒有還沒保存的東西之後移除它', + ); + } +} diff --git a/test/worktree-remove.test.js b/test/worktree-remove.test.js new file mode 100644 index 0000000..45def73 --- /dev/null +++ b/test/worktree-remove.test.js @@ -0,0 +1,166 @@ +/** + * 手動清理工作樹。 + * + * 這一支的重點全在**守門**:清理會被 pr-watch 自動執行,而自動執行的東西只能做 + * 可逆的事。工作樹裡還有沒提交的東西就中止,絕不 `--force`——刪掉的檔案救不回來。 + * 移除只動工作樹,本機分支留著,使用者還能回頭看那段歷史。 + * + * 路徑由 `owner/repo/分支名` 推導,不查表也不讀狀態檔,所以這裡連帶驗它與 + * branch-prep 算出來的是同一條。 + * + * git 不做替身:在臨時 git repo 上跑真的 git,以本機裸 repo 充當遠端,不需網路。 + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { runScript, tmpRoot } from './helpers/run-script.js'; +import { makeTempRepoWithRemote } from './helpers/temp-repo.js'; + +const REPO = 'plugins/tea-sdlc'; + +/** + * 備好一棵真的工作樹:路徑由 branch-prep 建,測試不自己算, + * 兩支腳本推導出同一條路徑這件事本身就是要驗的東西。 + */ +async function withWorktree(t, slug = 'mine') { + const repo = makeTempRepoWithRemote(); + t.after(() => repo.cleanup()); + mkdirSync(tmpRoot, { recursive: true }); + const home = mkdtempSync(join(tmpRoot, 'home-')); + t.after(() => rmSync(home, { recursive: true, force: true })); + + const { json } = await runScript( + 'branch-prep.js', + ['--repo', REPO, '--path', repo.dir, '--source', 'master', '--type', 'feat', '--slug', slug], + { env: { TEA_SDLC_HOME: home } }, + ); + assert.equal(json.ok, true, json.error?.message); + return { repo, home, branch: json.data.branch, worktree: json.data.worktree }; +} + +const run = (home, args) => runScript('worktree-remove.js', args, { env: { TEA_SDLC_HOME: home } }); + +// ── 乾淨時移除 ───────────────────────────────────────────────────── + +test('工作樹乾淨時移除它,本機分支留著', async (t) => { + const { repo, home, branch, worktree } = await withWorktree(t); + + const { code, json } = await run(home, ['--repo', REPO, '--branch', branch]); + + assert.equal(code, 0, json.error?.message); + assert.equal(json.data.removed, true); + assert.equal(existsSync(worktree), false, '工作樹要真的不見了'); + assert.equal( + repo.git('branch', '--list', branch).trim().replace(/^\*?\s*/, ''), + branch, + '本機分支要留著:不佔什麼空間,而使用者還會回頭看那段歷史', + ); +}); + +test('回報的路徑就是 branch-prep 建的那一條', async (t) => { + const { home, branch, worktree } = await withWorktree(t); + + const { json } = await run(home, ['--repo', REPO, '--branch', branch]); + + assert.equal(json.data.worktree, worktree, '兩支腳本要從同一個輸入推導出同一條路徑'); +}); + +// ── 守門:有未提交變更就中止 ─────────────────────────────────────── + +test('工作樹裡有未提交的變更時中止,並報出路徑', async (t) => { + const { home, branch, worktree } = await withWorktree(t); + writeFileSync(join(worktree, 'README.md'), '改到一半的東西\n'); + + const { code, json } = await run(home, ['--repo', REPO, '--branch', branch]); + + assert.equal(code, 1); + assert.equal(json.error.code, 'WORKTREE_DIRTY'); + assert.match(json.error.message, new RegExp(worktree), '要指名是哪一條路徑,人才找得到它'); + assert.match(json.error.message, /README\.md/, '也要說出是哪些檔案擋住了'); + assert.equal(existsSync(worktree), true, '擋下來就不該已經刪掉'); + assert.equal(existsSync(join(worktree, 'README.md')), true); +}); + +test('未追蹤的檔案同樣算未提交:它一樣會被刪掉', async (t) => { + const { home, branch, worktree } = await withWorktree(t); + writeFileSync(join(worktree, 'notes.txt'), '還沒加進版控的筆記\n'); + + const { json } = await run(home, ['--repo', REPO, '--branch', branch]); + + assert.equal(json.error.code, 'WORKTREE_DIRTY'); + assert.match(json.error.message, /notes\.txt/); +}); + +// ── 冪等 ─────────────────────────────────────────────────────────── + +test('工作樹本來就不在時安靜地成功,重跑不會失敗', async (t) => { + const { home, branch } = await withWorktree(t); + await run(home, ['--repo', REPO, '--branch', branch]); + + const { code, json } = await run(home, ['--repo', REPO, '--branch', branch]); + + assert.equal(code, 0, json.error?.message); + assert.equal(json.data.removed, false); + assert.equal(json.data.已經不在, true, '要說清楚是本來就不在,不是這次刪的'); +}); + +// ── --dry-run ───────────────────────────────────────────────────── + +test('--dry-run 印出將執行的 git 指令,且不移除任何東西', async (t) => { + const { home, branch, worktree } = await withWorktree(t); + + const { code, json } = await run(home, ['--repo', REPO, '--branch', branch, '--dry-run']); + + assert.equal(code, 0); + assert.equal(json.data.dryRun, true); + assert.deepEqual(json.data.commands, [`git worktree remove ${worktree}`]); + assert.equal( + json.data.commands.some((c) => c.includes('--force')), + false, + '絕不 --force:自動執行的東西只能做可逆的事', + ); + assert.equal(existsSync(worktree), true, '試跑不該真的刪掉'); +}); + +test('--dry-run 也照樣把守門的結果說出來', async (t) => { + // 試跑印得出漂亮的計畫、實跑卻被擋下來,是最難查的那種落差 + const { home, branch, worktree } = await withWorktree(t); + writeFileSync(join(worktree, 'wip.txt'), '做到一半\n'); + + const { json } = await run(home, ['--repo', REPO, '--branch', branch, '--dry-run']); + + assert.equal(json.error.code, 'WORKTREE_DIRTY'); +}); + +// ── flag ────────────────────────────────────────────────────────── + +test('缺 --branch 時指名缺的是哪一個', async (t) => { + const { home } = await withWorktree(t); + + const { json } = await run(home, ['--repo', REPO]); + + assert.equal(json.error.code, 'MISSING_FLAG'); + assert.match(json.error.message, /--branch/); +}); + +test('--repo 格式不是 owner/name 時失敗', async (t) => { + const { home } = await withWorktree(t); + + const { json } = await run(home, ['--repo', 'tea-sdlc', '--branch', 'feat/mine/main']); + + assert.equal(json.error.code, 'BAD_REPO'); +}); + +test('推導出來的路徑上是別人的東西時不碰它', async (t) => { + const { repo, home, branch, worktree } = await withWorktree(t); + repo.git('worktree', 'remove', worktree); + mkdirSync(worktree, { recursive: true }); + writeFileSync(join(worktree, '別人的東西.txt'), 'x\n'); + + const { code, json } = await run(home, ['--repo', REPO, '--branch', branch]); + + assert.equal(code, 1); + assert.equal(json.error.code, 'NOT_A_WORKTREE'); + assert.equal(existsSync(join(worktree, '別人的東西.txt')), true); +});