From 7196385c5e8c77f22bce6f2d3b8143fa9e719ff9 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 17:26:26 +0800 Subject: [PATCH 1/4] =?UTF-8?q?refactor(branch-prep):=20=E5=BB=BA=E7=AB=8B?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=A8=B9=E7=9A=84=E3=80=8C=E7=AE=97=E3=80=8D?= =?UTF-8?q?=E8=88=87=E3=80=8C=E5=81=9A=E3=80=8D=E6=94=B6=E9=80=B2=20lib?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重建工作樹(下一個 commit 的 worktree-ensure)要走的是與開工時完全同一套:一樣先 git fetch 更新遠端引用,一樣不設 upstream,分支已經存在就接上去而不是長一棵空的。 兩邊各寫一份,遲早會在「起點取自哪裡」這種地方分岔——而那種分岔要等到有人的進度 不見了才會被發現。 planWorktree 多接受一種用法:不給來源分支就是「重建既有分支的工作樹」,沒有「從來源 長一支新的」那條路,走到那裡就是 BRANCH_NOT_FOUND。branch-prep 的行為完全不變。 議題 #42 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/branch-prep.js | 162 ++++----------------------------------- scripts/lib.js | 170 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 184 insertions(+), 148 deletions(-) diff --git a/scripts/branch-prep.js b/scripts/branch-prep.js index f6d100b..fca6390 100644 --- a/scripts/branch-prep.js +++ b/scripts/branch-prep.js @@ -31,6 +31,9 @@ * 別的東西佔住時中止而不是硬蓋過去。兩者都在任何 git 寫入之前判斷完:試跑印得出 * 漂亮的計畫、實跑卻中途炸掉,是最難查的那種落差。 * + * 算指令、跑指令與失敗回滾都在 lib 的 `planWorktree`/`createWorktree`——重建工作樹 + * (`worktree-ensure`)走的是同一套,兩邊各寫一份遲早會在「起點取自哪裡」這種地方分岔。 + * * 工作樹路徑由 `owner/repo/分支名` 純函式推導(見 lib 的 worktreePath), * 不寫任何本機狀態檔:換機器或換 agent 都能接手,進度只從 Gitea 與 git 本身推導。 * @@ -38,9 +41,18 @@ * node scripts/branch-prep.js --repo --source <來源分支> --slug <英文-kebab> * [--type feat] [--path <目標專案>] [--dry-run] */ -import { existsSync, realpathSync, rmSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import { join } from 'node:path'; -import { ScriptError, main, openGitRepo, parseFlags, parseRepo, worktreePath } from './lib.js'; +import { + ScriptError, + createWorktree, + main, + openGitRepo, + parseFlags, + parseRepo, + planWorktree, + worktreePath, +} from './lib.js'; /** 需求描述的長度上限。超過就換一個短的說法,不要靠截斷。 */ const SLUG_MAX = 40; @@ -93,16 +105,7 @@ main(async () => { return { dryRun: true, ...報告, commands: plan.commands.map((args) => `git ${args.join(' ')}`) }; } - if (plan.commands.length > 0) { - // 既有的本地分支不是這次建的,回滾時不能連它一起刪掉 - const 分支本來就在 = onLocal(git, branch); - try { - for (const args of plan.commands) git(...args); - } catch (error) { - rollback(git, { worktree, branch, 保留分支: 分支本來就在 }); - throw error; - } - } + createWorktree(git, plan, { worktree, branch }); return { ...報告, 提示: { 訊息: CLEAN_WORKTREE, 安裝指令: installHints(worktree) } }; }); @@ -169,141 +172,6 @@ function isKebab(value) { return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(value); } -/** - * 算出要把這棵工作樹弄到手需要哪幾個 git 指令。 - * - * 分成「算」與「做」兩段,`--dry-run` 才能印出真正將執行的 git 指令, - * 而不是另外維護一份描述——兩邊分開寫就會走鐘。會擋的判斷全在這一段裡完成, - * 所以試跑與實跑在同一個地方被擋下來。 - * - * @returns {{commands: string[][], 動作: string}} commands 為空代表工作樹已經在了 - */ -function planWorktree(git, { source, branch, worktree }) { - const 既有 = listWorktrees(git).find((entry) => samePath(entry.path, worktree)); - const 目錄還在 = existsSync(worktree); - - if (既有 && 目錄還在) { - if (既有.branch !== branch) { - throw new ScriptError( - 'WORKTREE_PATH_TAKEN', - `${worktree} 已經是 ${既有.branch} 的工作樹;請先 git worktree remove 它再重跑`, - ); - } - // 冪等:中斷重跑時接上既有那一棵,不碰裡面還沒提交的東西 - return { commands: [], 動作: '沿用既有工作樹' }; - } - if (!既有 && 目錄還在) { - throw new ScriptError( - 'WORKTREE_PATH_TAKEN', - `${worktree} 已經有東西了,但它不是這個 repo 的工作樹(可能是別的 clone 留下的);` + - '請確認裡面沒有還沒保存的東西之後移除它,再重跑', - ); - } - - // 起點一律取自遠端:本機同名分支可能落後好幾天,靜默拿它當起點的後果太隱蔽 - if (!onRemote(git, source)) { - throw new ScriptError( - 'SOURCE_NOT_FOUND', - `遠端沒有來源分支 ${source};請先把它推上去(git push origin ${source}),` + - '或改指定一個已經存在於遠端的來源分支', - ); - } - - // 目錄被刪掉但中繼資料還在時先清乾淨,否則 git 會說這條路徑已經註冊過 - const commands = 既有 ? [['worktree', 'prune']] : []; - commands.push(['fetch', 'origin']); - - if (onLocal(git, branch)) { - // 已經有的分支接上去,不從來源蓋掉:上面可能有做到一半的進度 - commands.push(['worktree', 'add', worktree, branch]); - return { commands, 動作: '接上本地既有' }; - } - if (onRemote(git, branch)) { - commands.push(['worktree', 'add', '--no-track', '-b', branch, worktree, `origin/${branch}`]); - return { commands, 動作: '接上遠端既有' }; - } - commands.push(['worktree', 'add', '--no-track', '-b', branch, worktree, `origin/${source}`]); - return { commands, 動作: '從來源建立' }; -} - -/** - * 這個 repo 目前有哪幾棵工作樹。 - * `--porcelain` 的輸出是以空行分隔的區塊,每塊第一行是 `worktree <路徑>`, - * 分支則是 `branch refs/heads/<名字>`;detached 的工作樹沒有 branch 那一行。 - */ -function listWorktrees(git) { - return git('worktree', 'list', '--porcelain') - .split('\n\n') - .map((block) => { - const path = block.match(/^worktree (.+)$/m)?.[1]; - const branch = block.match(/^branch refs\/heads\/(.+)$/m)?.[1] ?? null; - return path ? { path, branch } : null; - }) - .filter(Boolean); -} - -/** - * 兩條路徑指的是不是同一個地方。 - * git 印出來的是解析過符號連結的真實路徑,而推導出來的那一條可能經過連結 - * (家目錄本身就常是一條連結),逐字比對會把同一棵工作樹判成兩棵。 - */ -function samePath(a, b) { - return a === b || realOrSelf(a) === realOrSelf(b); -} - -/** 解析得出真實路徑就用它,路徑還不存在時退回原字串 */ -function realOrSelf(path) { - try { - return realpathSync(path); - } catch { - return path; - } -} - -/** - * 遠端有沒有這一支分支。 - * - * 比對用全名 `refs/heads/`:`ls-remote --heads origin main` 的樣式比對吃的是 - * ref 的尾段,而本 repo 的命名慣例讓每一支分支都以 `/main` 結尾——用短名比對, - * 拿 main 當開發分支的專案會整個誤判成「遠端已經有這一支」。 - */ -function onRemote(git, ref) { - return git('ls-remote', '--heads', 'origin', `refs/heads/${ref}`).trim() !== ''; -} - -function onLocal(git, ref) { - return git('branch', '--list', ref).trim() !== ''; -} - -/** - * 建立失敗時把半成品清掉。 - * - * `git worktree add` 失敗時仍會把新分支留下來,而那是最難查的半成品:下一次重跑會走到 - * 「目標分支已存在」那條路,起點從此不再是遠端的來源分支。本來就存在的分支不能碰—— - * 上面可能有別人的進度。 - */ -function rollback(git, { worktree, branch, 保留分支 }) { - quietly(git, ['worktree', 'remove', '--force', worktree]); - quietly(git, ['worktree', 'prune']); - if (!保留分支) quietly(git, ['branch', '-D', branch]); - // git 清不乾淨時把目錄本身也清掉:這條路徑在這次執行之前不存在(不存在是建立的前提), - // 裡面不可能有使用者的東西;留著它下一次重跑會直接撞上 WORKTREE_PATH_TAKEN - try { - rmSync(worktree, { recursive: true, force: true }); - } catch { - // 連目錄都刪不掉就只能留著:原本的錯誤比清理的錯誤重要 - } -} - -/** 清理用的 git:失敗了也不能蓋掉真正的錯誤訊息,那才是使用者要看的東西。 */ -function quietly(git, args) { - try { - git(...args); - } catch { - // 清不掉就算了:原本的錯誤比清理的錯誤重要 - } -} - /** * 這棵工作樹要怎麼把依賴裝起來。 * 偵測不到就回空陣列,不亂猜——猜錯的指令比沒有指令更浪費時間。 diff --git a/scripts/lib.js b/scripts/lib.js index bc97400..6647e02 100644 --- a/scripts/lib.js +++ b/scripts/lib.js @@ -13,7 +13,7 @@ */ import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { accessSync, constants, existsSync, readFileSync, statSync } from 'node:fs'; +import { accessSync, constants, existsSync, readFileSync, realpathSync, statSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -518,6 +518,174 @@ export function removeWorktree(worktree) { return { ...state, removed: true, reason: 'removed' }; } +/** + * 算出要把這棵工作樹弄到手需要哪幾個 git 指令。 + * + * 建立與**重建**共用這一份:換一台機器接手時工作樹本來就不存在,而重建若另寫一套, + * 兩邊遲早會在「起點取自哪裡」「要不要設 upstream」這種地方分岔。 + * + * 分成「算」與「做」兩段,`--dry-run` 才能印出真正將執行的 git 指令, + * 而不是另外維護一份描述——兩邊分開寫就會走鐘。會擋的判斷全在這一段裡完成, + * 所以試跑與實跑在同一個地方被擋下來。 + * + * @param {(...args: string[]) => string} git 綁在目標專案上的 git 執行器 + * @param {{source?: string, branch: string, worktree: string}} 目標 + * 不給 `source` 就是「重建既有分支的工作樹」:分支必須已經存在, + * 沒有「從來源長一支新的」這條路。 + * @returns {{commands: string[][], 動作: string}} commands 為空代表工作樹已經在了 + */ +export function planWorktree(git, { source, branch, worktree }) { + const 既有 = listWorktrees(git).find((entry) => samePath(entry.path, worktree)); + const 目錄還在 = existsSync(worktree); + + if (既有 && 目錄還在) { + if (既有.branch !== branch) { + throw new ScriptError( + 'WORKTREE_PATH_TAKEN', + `${worktree} 已經是 ${既有.branch} 的工作樹;請先 git worktree remove 它再重跑`, + ); + } + // 冪等:中斷重跑時接上既有那一棵,不碰裡面還沒提交的東西 + return { commands: [], 動作: '沿用既有工作樹' }; + } + if (!既有 && 目錄還在) { + throw new ScriptError( + 'WORKTREE_PATH_TAKEN', + `${worktree} 已經有東西了,但它不是這個 repo 的工作樹(可能是別的 clone 留下的);` + + '請確認裡面沒有還沒保存的東西之後移除它,再重跑', + ); + } + + // 起點一律取自遠端:本機同名分支可能落後好幾天,靜默拿它當起點的後果太隱蔽 + if (source !== undefined && !onRemote(git, source)) { + throw new ScriptError( + 'SOURCE_NOT_FOUND', + `遠端沒有來源分支 ${source};請先把它推上去(git push origin ${source}),` + + '或改指定一個已經存在於遠端的來源分支', + ); + } + + // 目錄被刪掉但中繼資料還在時先清乾淨,否則 git 會說這條路徑已經註冊過 + const commands = 既有 ? [['worktree', 'prune']] : []; + commands.push(['fetch', 'origin']); + + if (onLocal(git, branch)) { + // 已經有的分支接上去,不從來源蓋掉:上面可能有做到一半的進度 + commands.push(['worktree', 'add', worktree, branch]); + return { commands, 動作: '接上本地既有' }; + } + if (onRemote(git, branch)) { + commands.push(['worktree', 'add', '--no-track', '-b', branch, worktree, `origin/${branch}`]); + return { commands, 動作: '接上遠端既有' }; + } + if (source === undefined) { + // 重建的路走到這裡代表那一支分支已經不見了——憑空長一棵空的只會讓人以為進度還在 + throw new ScriptError( + 'BRANCH_NOT_FOUND', + `分支 ${branch} 在本機與遠端都不存在,重建不出工作樹;` + + '請確認分支名,或先把它推上遠端', + ); + } + commands.push(['worktree', 'add', '--no-track', '-b', branch, worktree, `origin/${source}`]); + return { commands, 動作: '從來源建立' }; +} + +/** + * 照計畫把工作樹建起來,失敗時回到原狀。 + * 與 `planWorktree` 成對:算歸算、做歸做,而試跑只跑前半段。 + * @param {{commands: string[][]}} plan planWorktree 的結果 + */ +export function createWorktree(git, plan, { worktree, branch }) { + if (plan.commands.length === 0) return; + + // 既有的本地分支不是這次建的,回滾時不能連它一起刪掉 + const 分支本來就在 = onLocal(git, branch); + try { + for (const args of plan.commands) git(...args); + } catch (error) { + rollback(git, { worktree, branch, 保留分支: 分支本來就在 }); + throw error; + } +} + +/** + * 這個 repo 目前有哪幾棵工作樹。 + * `--porcelain` 的輸出是以空行分隔的區塊,每塊第一行是 `worktree <路徑>`, + * 分支則是 `branch refs/heads/<名字>`;detached 的工作樹沒有 branch 那一行。 + */ +function listWorktrees(git) { + return git('worktree', 'list', '--porcelain') + .split('\n\n') + .map((block) => { + const path = block.match(/^worktree (.+)$/m)?.[1]; + const branch = block.match(/^branch refs\/heads\/(.+)$/m)?.[1] ?? null; + return path ? { path, branch } : null; + }) + .filter(Boolean); +} + +/** + * 兩條路徑指的是不是同一個地方。 + * git 印出來的是解析過符號連結的真實路徑,而推導出來的那一條可能經過連結 + * (家目錄本身就常是一條連結),逐字比對會把同一棵工作樹判成兩棵。 + */ +function samePath(a, b) { + return a === b || realOrSelf(a) === realOrSelf(b); +} + +/** 解析得出真實路徑就用它,路徑還不存在時退回原字串 */ +function realOrSelf(path) { + try { + return realpathSync(path); + } catch { + return path; + } +} + +/** + * 遠端有沒有這一支分支。 + * + * 比對用全名 `refs/heads/`:`ls-remote --heads origin main` 的樣式比對吃的是 + * ref 的尾段,而本 repo 的命名慣例讓每一支分支都以 `/main` 結尾——用短名比對, + * 拿 main 當開發分支的專案會整個誤判成「遠端已經有這一支」。 + */ +function onRemote(git, ref) { + return git('ls-remote', '--heads', 'origin', `refs/heads/${ref}`).trim() !== ''; +} + +function onLocal(git, ref) { + return git('branch', '--list', ref).trim() !== ''; +} + +/** + * 建立失敗時把半成品清掉。 + * + * `git worktree add` 失敗時仍會把新分支留下來,而那是最難查的半成品:下一次重跑會走到 + * 「目標分支已存在」那條路,起點從此不再是遠端的來源分支。本來就存在的分支不能碰—— + * 上面可能有別人的進度。 + */ +function rollback(git, { worktree, branch, 保留分支 }) { + quietly(git, ['worktree', 'remove', '--force', worktree]); + quietly(git, ['worktree', 'prune']); + if (!保留分支) quietly(git, ['branch', '-D', branch]); + // git 清不乾淨時把目錄本身也清掉:這條路徑在這次執行之前不存在(不存在是建立的前提), + // 裡面不可能有使用者的東西;留著它下一次重跑會直接撞上 WORKTREE_PATH_TAKEN + try { + rmSync(worktree, { recursive: true, force: true }); + } catch { + // 連目錄都刪不掉就只能留著:原本的錯誤比清理的錯誤重要 + } +} + +/** 清理用的 git:失敗了也不能蓋掉真正的錯誤訊息,那才是使用者要看的東西。 */ +function quietly(git, args) { + try { + git(...args); + } catch { + // 清不掉就算了:原本的錯誤比清理的錯誤重要 + } +} + // ── 四層前置檢查 ─────────────────────────────────────────────────── /** From 2562555058c884b917676751658064dda6f73757 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 17:26:26 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(worktree-ensure):=20=E5=AE=9A=E4=BD=8D?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8C=85=E7=9A=84=E5=B7=A5=E4=BD=9C=E6=A8=B9?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=9C=A8=E5=B0=B1=E9=87=8D=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 處理 PR 留言的人不必先手動 cd 到正確的目錄:路徑由 owner/repo/分支名 純函式推導, 問這一支就知道該在哪裡動手。 「不在就重建」是常態不是防禦性程式設計。進度完全不寫在本機——換一台機器或換一個 agent 接手時,工作樹本來就不存在,而重建的成本就是一次 git worktree add。 兩種情況明確中止而不是硬幹:分支在本機與遠端都不見時報 BRANCH_NOT_FOUND(憑空長一棵 空的工作樹只會讓人以為進度還在),推導出的路徑上是別的東西時報 WORKTREE_PATH_TAKEN。 議題 #42 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/worktree-ensure.js | 65 ++++++++++++ test/worktree-ensure.test.js | 196 +++++++++++++++++++++++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 scripts/worktree-ensure.js create mode 100644 test/worktree-ensure.test.js diff --git a/scripts/worktree-ensure.js b/scripts/worktree-ensure.js new file mode 100644 index 0000000..9679bc7 --- /dev/null +++ b/scripts/worktree-ensure.js @@ -0,0 +1,65 @@ +#!/usr/bin/env node +/** + * 定位一顆工作包的工作樹,不在就重建。 + * + * 處理 PR 留言的人不必先手動 `cd` 到正確的目錄:路徑由 `owner/repo/分支名` 純函式推導 + * (見 lib 的 `worktreePath`),問這一支就知道該在哪裡動手。 + * + * **「不在就重建」是常態,不是防禦性程式設計。** 進度完全不寫在本機——換一台機器或 + * 換一個 agent 接手時,工作樹本來就不存在,而重建的成本就是一次 `git worktree add`。 + * + * 重建與建立走同一套(lib 的 `planWorktree`/`createWorktree`):一樣先 `git fetch` + * 更新遠端引用,一樣不設 upstream,分支已經存在就接上去而不是長一棵空的。兩邊各寫 + * 一份,遲早會在「起點取自哪裡」這種地方分岔,而那種分岔要等到有人的進度不見了 + * 才會被發現。 + * + * 分支在本機與遠端都不存在時明確中止:憑空長一棵空的工作樹,只會讓人以為進度還在。 + * 推導出的路徑上是別的東西時也中止,不盲目拿來用。 + * + * 用法: + * node scripts/worktree-ensure.js --repo owner/name --branch <分支名> + * [--path <目標專案>] [--dry-run] + */ +import { + ScriptError, + createWorktree, + main, + openGitRepo, + parseFlags, + parseRepo, + planWorktree, + worktreePath, +} from './lib.js'; + +main(async () => { + const flags = parseFlags(process.argv.slice(2), { + required: ['repo', 'branch'], + optional: ['path'], + booleans: ['dry-run'], + }); + const repo = parseRepo(flags.repo); + const branch = flags.branch; + const path = flags.path ?? process.cwd(); + const worktree = worktreePath(repo, branch); + + const git = openGitRepo(path); + if (!git('remote').split('\n').includes('origin')) { + throw new ScriptError( + 'NO_ORIGIN', + `${path} 沒有 origin 遠端;重建工作樹要先能讀到 origin/${branch},請先設定 origin`, + ); + } + + // 不給 source:這裡只重建既有分支的工作樹,沒有「從來源長一支新的」那條路, + // 那是 branch-prep 的事——在這裡憑空開一支新分支,等於把 PR 的進度扔掉 + const plan = planWorktree(git, { branch, worktree }); + const 報告 = { path, repo, branch, worktree, 動作: plan.動作 }; + + if (flags['dry-run']) { + return { dryRun: true, ...報告, commands: plan.commands.map((args) => `git ${args.join(' ')}`) }; + } + + createWorktree(git, plan, { worktree, branch }); + + return { ...報告, 重建: plan.commands.length > 0 }; +}); diff --git a/test/worktree-ensure.test.js b/test/worktree-ensure.test.js new file mode 100644 index 0000000..df2594d --- /dev/null +++ b/test/worktree-ensure.test.js @@ -0,0 +1,196 @@ +/** + * 定位一顆工作包的工作樹,不在就重建。 + * + * 「不在就重建」不是防禦性程式設計,而是最常見的情境:進度完全不寫在本機,換一台機器 + * 或換一個 agent 接手時,工作樹本來就不存在。重建的成本就是一次 `git worktree add`。 + * + * 重建走的是與 branch-prep 同一套推導與同一套建立方式(`lib` 的 `worktreePath` 與 + * `planWorktree`),所以這裡連帶驗兩件事:算出來的是同一條路徑,而且分支上已經有的 + * 進度會被接上,不是從頭長一棵空的。 + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +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'; +const SLUG = 'mine'; +const BRANCH = `feat/${SLUG}/main`; + +/** 一個有遠端的臨時 repo 與一個空的工作樹家 */ +function withRepo(t) { + 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 })); + return { ...repo, home }; +} + +/** 照正規流程開一棵工作樹,回傳它的路徑——測試不自己算路徑 */ +async function prep(repo) { + const { json } = await runScript( + 'branch-prep.js', + ['--repo', REPO, '--path', repo.dir, '--source', 'master', '--type', 'feat', '--slug', SLUG], + { env: { TEA_SDLC_HOME: repo.home } }, + ); + assert.equal(json.ok, true, json.error?.message); + return json.data.worktree; +} + +const run = (repo, args = []) => + runScript('worktree-ensure.js', ['--repo', REPO, '--path', repo.dir, '--branch', BRANCH, ...args], { + env: { TEA_SDLC_HOME: repo.home }, + }); + +const branchIn = (dir) => + execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim(); + +// ── 已經在了就沿用 ───────────────────────────────────────────────── + +test('工作樹已經在了就沿用,不碰裡面還沒提交的東西', async (t) => { + const repo = withRepo(t); + const worktree = await prep(repo); + writeFileSync(join(worktree, 'wip.txt'), '做到一半\n'); + + const { code, json } = await run(repo); + + assert.equal(code, 0, json.error?.message); + assert.equal(json.data.worktree, worktree, '推導出的要是 branch-prep 建的那一條'); + assert.equal(json.data.動作, '沿用既有工作樹'); + assert.equal(existsSync(join(worktree, 'wip.txt')), true); +}); + +// ── 不在就重建 ───────────────────────────────────────────────────── + +test('工作樹不在時重建它,分支上的進度跟著回來', async (t) => { + // 換一台機器接手就是這個情形:進度不寫在本機,工作樹本來就不存在 + const repo = withRepo(t); + const worktree = await prep(repo); + writeFileSync(join(worktree, 'done.txt'), '已經提交的進度\n'); + execFileSync('git', ['add', '-A'], { cwd: worktree }); + execFileSync('git', ['commit', '-qm', '分支上的進度'], { cwd: worktree }); + repo.git('worktree', 'remove', worktree); + + const { code, json } = await run(repo); + + assert.equal(code, 0, json.error?.message); + assert.equal(json.data.worktree, worktree, '同一顆工作包算出來的永遠是同一條路徑'); + assert.equal(json.data.動作, '接上本地既有'); + assert.equal(branchIn(worktree), BRANCH); + assert.equal(existsSync(join(worktree, 'done.txt')), true, '接上既有分支,不是長一棵空的'); +}); + +test('本機連分支都沒有時,從遠端那一支重建', async (t) => { + // 真正的新機器:clone 完什麼都沒有,分支只在遠端上 + const repo = withRepo(t); + repo.pushFromElsewhere(BRANCH, 'theirs.txt', '推上去的進度\n'); + + const { code, json } = await run(repo); + + assert.equal(code, 0, json.error?.message); + assert.equal(json.data.動作, '接上遠端既有'); + assert.equal(existsSync(join(json.data.worktree, 'theirs.txt')), true); + assert.equal( + repo.git('for-each-ref', '--format=%(upstream)', `refs/heads/${BRANCH}`), + '', + '重建與建立走同一套:一樣不設 upstream', + ); +}); + +test('中繼資料還在但目錄被砍掉時照樣重建', async (t) => { + const repo = withRepo(t); + const worktree = await prep(repo); + rmSync(worktree, { recursive: true, force: true }); + + const { code, json } = await run(repo); + + assert.equal(code, 0, json.error?.message); + assert.equal(existsSync(worktree), true); + assert.equal(branchIn(worktree), BRANCH); +}); + +// ── 擋下來的情況 ─────────────────────────────────────────────────── + +test('分支在本機與遠端都不存在時明確報錯,不憑空長一棵', async (t) => { + const repo = withRepo(t); + + const { code, json } = await run(repo); + + assert.equal(code, 1); + assert.equal(json.error.code, 'BRANCH_NOT_FOUND'); + assert.match(json.error.message, new RegExp(BRANCH)); + assert.equal(existsSync(json.data?.worktree ?? '/nonexistent'), false); +}); + +test('推導出的路徑上是別的東西時明確報錯,不盲目拿來用', async (t) => { + const repo = withRepo(t); + const worktree = await prep(repo); + repo.git('worktree', 'remove', worktree); + mkdirSync(worktree, { recursive: true }); + writeFileSync(join(worktree, '別人的東西.txt'), 'x\n'); + + const { code, json } = await run(repo); + + assert.equal(code, 1); + assert.equal(json.error.code, 'WORKTREE_PATH_TAKEN'); + assert.equal(existsSync(join(worktree, '別人的東西.txt')), true, '不是我們建的東西就不碰'); +}); + +test('--path 指向的不是 git repo 時,回可區分的錯誤碼', async (t) => { + const { json } = await runScript('worktree-ensure.js', [ + '--repo', REPO, '--path', tmpRoot, '--branch', BRANCH, + ]); + + assert.equal(json.error.code, 'NOT_A_GIT_REPO'); +}); + +test('缺 --branch 時指名缺的是哪一個', async (t) => { + const repo = withRepo(t); + + const { json } = await runScript('worktree-ensure.js', ['--repo', REPO, '--path', repo.dir], { + env: { TEA_SDLC_HOME: repo.home }, + }); + + assert.equal(json.error.code, 'MISSING_FLAG'); + assert.match(json.error.message, /--branch/); +}); + +// ── --dry-run ───────────────────────────────────────────────────── + +test('--dry-run 印出路徑與將執行的 git 指令,且不建任何東西', async (t) => { + const repo = withRepo(t); + repo.pushFromElsewhere(BRANCH, 'theirs.txt', '推上去的進度\n'); + + const { code, json } = await run(repo, ['--dry-run']); + + assert.equal(code, 0); + assert.equal(json.data.dryRun, true); + assert.match(json.data.worktree, /worktrees\/[0-9a-f]{12}$/); + assert.deepEqual(json.data.commands, [ + 'git fetch origin', + `git worktree add --no-track -b ${BRANCH} ${json.data.worktree} origin/${BRANCH}`, + ]); + assert.equal(existsSync(json.data.worktree), false, '試跑不該真的建'); +}); + +test('--dry-run 在工作樹已經在時說沒事要做', async (t) => { + const repo = withRepo(t); + await prep(repo); + + const { json } = await run(repo, ['--dry-run']); + + assert.equal(json.data.動作, '沿用既有工作樹'); + assert.deepEqual(json.data.commands, []); +}); + +test('--dry-run 也照樣把擋下來的情況說出來', async (t) => { + const repo = withRepo(t); + + const { json } = await run(repo, ['--dry-run']); + + assert.equal(json.error.code, 'BRANCH_NOT_FOUND'); +}); From 0dd8d7b7bbc8ae414adb63f74edef7a31d185171 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 17:26:26 +0800 Subject: [PATCH 3/4] =?UTF-8?q?docs(sdlc-fix):=20=E7=AC=AC=E4=B8=80?= =?UTF-8?q?=E6=AD=A5=E5=85=88=E7=9C=8B=20PR=20=E7=8F=BE=E6=B3=81=EF=BC=8C?= =?UTF-8?q?=E5=86=8D=E5=AE=9A=E4=BD=8D=E5=B7=A5=E4=BD=9C=E6=A8=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 補上 #14 沒有的工作樹概念:它預設在當前目錄處理留言,而留言要改的程式碼在那顆工作包 自己的工作樹裡。新的第一步先問 pr-watch 現況——PR 已經合併或關閉就停下來,在一棵該被 清掉的工作樹上處理留言是白做工——再用 worktree-ensure 定位,不在就重建。 邊界同時擋住兩件事:不回主工作區處理留言(它可能停在別的分支上),以及推導路徑上有 別的東西時不自己刪。原有的五個步驟整體往後移一號,內容不變。 議題 #42 Co-Authored-By: Claude Opus 5 (1M context) --- prompts/sdlc-fix.md | 47 +++++++++++++++++++++++++++++++----- test/sdlc-fix-assets.test.js | 37 ++++++++++++++++++++++++++-- 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/prompts/sdlc-fix.md b/prompts/sdlc-fix.md index 9e9905e..9f0104d 100644 --- a/prompts/sdlc-fix.md +++ b/prompts/sdlc-fix.md @@ -1,5 +1,5 @@ name: sdlc-fix -description: 僅由 /sdlc-fix 指令叫用。讀取 PR 上的三類留言,逐條處理並回覆,最後輸出修正摘要。 +description: 僅由 /sdlc-fix 指令叫用。定位工作包的工作樹,讀取 PR 上的三類留言,逐條處理並回覆,最後輸出修正摘要。 # sdlc-fix @@ -11,7 +11,40 @@ reviewer 留完意見,跑這一段,意見被逐條處理並回覆,不漏 一個 PR 編號。 -## 1. 讀留言 +## 1. 看 PR 現況,並定位工作樹 + +先問一次現況,再決定要不要動手: + +``` +node scripts/pr-watch.js --repo --index +``` + +**`terminal` 為 `true`(PR 已合併或已關閉)時就停下來。** 那顆工作包已經結束,在一棵 +該被清掉的工作樹上處理留言是白做工。把 `suggestedAction` 的意思講給使用者聽: +`nothing-to-do` 是工作樹也清掉了;`blocked-dirty` 是那裡還有沒提交的東西,要他自己處理。 +使用者堅持要繼續就繼續,但要說清楚 PR 已經結束了。 + +PR 還開著就定位工作樹。輸入是 `pr-watch` 給的 `branch`——路徑由「哪顆工作包」推導, +不必也不該由使用者自己去記那串雜湊目錄名: + +``` +node scripts/worktree-ensure.js --repo --branch --dry-run +``` + +試跑會印出推導出的路徑與將執行的 git 指令;確認無誤後拿掉旗標再跑一次。 + +**推導出的路徑不存在時它會重建,那是常態不是例外**:進度完全不寫在本機,換一台機器 +或換一個 agent 接手時工作樹本來就不在,重建的成本就是一次 `git worktree add`。 +重建走的是與開工時同一套建立方式,分支上已經有的進度會被接上,不是長一棵空的。 + +兩種會被擋下來的情況照實說,不要繞過去:`BRANCH_NOT_FOUND` 是那一支分支在本機與遠端 +都不見了(多半是 PR 已經合併而分支被刪,回頭確認 PR 狀態);`WORKTREE_PATH_TAKEN` 是 +推導出的路徑上有別的東西,請使用者自己確認後移除——**不要自己刪**。 + +**後面每一步都在那棵工作樹裡做**,不要回到主工作區:它可能停在別的分支上,在那裡改 +會把改動落到別顆工作包的分支去。 + +## 2. 讀留言 ``` node scripts/pr-comments.js --repo --index @@ -31,7 +64,7 @@ resolve。`已處理` 認的是**自己打的** `+1`——reviewer 對留言按 偶爾會遇到 `可標記` 是 `false` 的總評(在 timeline 上對不到它在 issue comment 表裡的 那一份)。那一則回覆照發,但沒有記號留得下來,**要在修正摘要裡單獨點出來**。 -## 2. 分類:必改還是建議 +## 3. 分類:必改還是建議 逐則判斷,**reviewer 不必逐則說明**。判斷依據是內容本身: @@ -42,7 +75,7 @@ resolve。`已處理` 認的是**自己打的** `+1`——reviewer 對留言按 分類錯的代價不對稱——把必改當成建議會漏掉真的問題,所以拿不準時歸到必改那一邊, 並在下一步問清楚。 -## 3. 不確定就問 +## 4. 不確定就問 **一次問一題。** 下列情況不要自作主張: @@ -57,7 +90,7 @@ resolve。`已處理` 認的是**自己打的** `+1`——reviewer 對留言按 **不確定卻硬改,比多問一題貴得多。** 改壞的地方 reviewer 下一輪才會看到。 -## 4. 逐則處理並回覆 +## 5. 逐則處理並回覆 一則一則來:先改,改完立刻回覆那一則,再處理下一則。**不要全部改完才一起回**—— 中途斷掉的話,沒有人知道哪幾則已經處理過。 @@ -81,7 +114,7 @@ node scripts/pr-reply.js --repo --index \ 不必自己去翻 diff。決定不改的也要回,並說明理由——建議類的留言常常合理地不採納, 但沉默會讓 reviewer 以為被忽略了。 -## 5. 修正摘要 +## 6. 修正摘要 全部處理完後印一則摘要,讓 reviewer 不必逐串點開: @@ -102,3 +135,5 @@ node scripts/pr-reply.js --repo --index \ - 不在回覆沒成功時標記已處理。 - **不自動張貼修正摘要**,也不自動關閉或合併 PR。 - 不動 PR 的 review 狀態:回覆一則意見不該順手把整個 PR 標成通過或要求變更。 +- **不在主工作區處理留言**,一律在 `worktree-ensure` 定位出來的那棵工作樹裡。 +- PR 已經合併或關閉時不繼續處理留言,也不自己去刪推導路徑上的東西。 diff --git a/test/sdlc-fix-assets.test.js b/test/sdlc-fix-assets.test.js index a23866a..5707b7c 100644 --- a/test/sdlc-fix-assets.test.js +++ b/test/sdlc-fix-assets.test.js @@ -11,6 +11,39 @@ import { assertNeutralPrompt, readPrompt } from './helpers/prompt-doc.js'; const prompt = readPrompt('sdlc-fix'); const steps = prompt.slice(prompt.indexOf('## 1.'), prompt.indexOf('## 邊界')); +/** 定位那一步,與後面處理留言的步驟分開看 */ +const locate = steps.slice(0, steps.indexOf('## 2.')); + +test('第一步先看 PR 現況,終止狀態就停下來不白做工', () => { + assert.match(locate, /pr-watch\.js/); + assert.match(locate, /terminal/); + assert.match(locate, /白做工/, '要說明為什麼停:那棵工作樹已經該被清掉了'); +}); + +test('工作樹由工作包推導,不要求使用者自己切目錄', () => { + assert.match(locate, /worktree-ensure\.js/); + assert.match(locate, /branch/, '輸入是 pr-watch 給的分支名'); + assert.match(locate, /不必也不該由使用者自己去記/); + assert.match(locate, /都在那棵工作樹裡做/); +}); + +test('不存在就重建,並說明那是常態不是例外', () => { + assert.match(locate, /常態不是例外/); + assert.match(locate, /換一台機器/, '要說明為什麼工作樹常常不在'); + assert.match(locate, /同一套建立方式/, '重建不另寫一套'); +}); + +test('兩種擋下來的情況各自寫明處置,且不替使用者決定', () => { + assert.match(locate, /BRANCH_NOT_FOUND/); + assert.match(locate, /WORKTREE_PATH_TAKEN/); + assert.match(locate, /不要自己刪/); +}); + +test('邊界擋住「回主工作區處理留言」與「PR 結束了還繼續改」', () => { + const boundary = prompt.slice(prompt.indexOf('## 邊界')); + assert.match(boundary, /不在主工作區處理留言/); + assert.match(boundary, /已經合併或關閉時不繼續處理留言/); +}); test('正本平台中立,description 前綴正確', () => { assertNeutralPrompt(prompt, 'sdlc-fix'); @@ -71,7 +104,7 @@ test('一次問一題,選項含手動輸入', () => { }); test('該問的情況有列舉,不是一句「不確定就問」', () => { - const section = steps.slice(steps.indexOf('## 3.'), steps.indexOf('## 4.')); + const section = steps.slice(steps.indexOf('## 4.'), steps.indexOf('## 5.')); const bullets = section.match(/^- /gm) ?? []; assert.ok(bullets.length >= 3, `該問的情況要列得出來,只找到 ${bullets.length} 條`); assert.match(section, /推了新 commit/, '位置對不上是最常見的一種,要點名'); @@ -112,7 +145,7 @@ test('寫入前要求先試跑', () => { // ── 摘要 ─────────────────────────────────────────────────────────── test('摘要要逐則列出,並單獨點出沒有記號的那幾則', () => { - const section = steps.slice(steps.indexOf('## 5.')); + const section = steps.slice(steps.indexOf('## 6.')); assert.match(section, /逐則一行/); assert.match(section, /沒有留下記號的那幾則/); assert.match(section, /以為它們被跳過/, '要說出為什麼得單獨列'); From 66064a751ed098b3895d700aaf1306230c5ca353 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 17:32:01 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(lib):=20=E8=A3=9C=E5=9B=9E=E6=90=AC?= =?UTF-8?q?=E5=AE=B6=E6=99=82=E6=8E=89=E4=BA=86=E7=9A=84=20rmSync=EF=BC=8C?= =?UTF-8?q?=E4=B8=A6=E8=AE=93=20origin=20=E6=AA=A2=E6=9F=A5=E5=8F=AA?= =?UTF-8?q?=E6=9C=89=E4=B8=80=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review 抓到一個沉默的失效:rollback 最後那一手「git 清不乾淨時把目錄本身也清掉」呼叫 rmSync,但把這段從 branch-prep 搬進 lib 時,import 留在了原處。那一行落在自己的 try/catch 裡,所以 ReferenceError 被吞掉——註解承諾的事從來沒發生過,而且測試全綠。 下一次重跑會撞上 WORKTREE_PATH_TAKEN,人看到的是一句與真正原因無關的錯誤。 順手收掉兩支腳本各寫一次的 origin 檢查(只有「為什麼需要它」那一句不同,由呼叫端給), 並把 lib 檔頭「負責六件事」改成七件——工作樹的一生現在整個住在這裡。 正本三處跟著改: - 查現況那一行補上 --dry-run。pr-watch 在 PR 已終止時會順手清掉工作樹,而「我想看一下 留言」不該把清理順便做掉——清不清理是使用者的決定。 - 拿掉「使用者堅持要繼續就繼續」:它與同一份正本的邊界(已合併或關閉時不繼續處理留言) 直接矛盾,而在一棵該被清掉的工作樹上改東西,那些改動不會進到任何 PR 裡。 - worktree-ensure 的指令補上 --path:目標專案多半不是當前目錄,而「不必先手動 cd」 正是這一步要解決的問題。 議題 #42 Co-Authored-By: Claude Opus 5 (1M context) --- prompts/sdlc-fix.md | 24 ++++++++++++++++++------ scripts/branch-prep.js | 8 ++------ scripts/lib.js | 31 ++++++++++++++++++++++++++----- scripts/worktree-ensure.js | 9 ++------- test/branch-prep.test.js | 1 + test/sdlc-fix-assets.test.js | 14 ++++++++++++++ 6 files changed, 63 insertions(+), 24 deletions(-) diff --git a/prompts/sdlc-fix.md b/prompts/sdlc-fix.md index 9f0104d..a4cf06c 100644 --- a/prompts/sdlc-fix.md +++ b/prompts/sdlc-fix.md @@ -16,21 +16,33 @@ reviewer 留完意見,跑這一段,意見被逐條處理並回覆,不漏 先問一次現況,再決定要不要動手: ``` -node scripts/pr-watch.js --repo --index +node scripts/pr-watch.js --repo --index --dry-run ``` -**`terminal` 為 `true`(PR 已合併或已關閉)時就停下來。** 那顆工作包已經結束,在一棵 -該被清掉的工作樹上處理留言是白做工。把 `suggestedAction` 的意思講給使用者聽: -`nothing-to-do` 是工作樹也清掉了;`blocked-dirty` 是那裡還有沒提交的東西,要他自己處理。 -使用者堅持要繼續就繼續,但要說清楚 PR 已經結束了。 +**這裡要帶 `--dry-run`**:`pr-watch` 在 PR 已終止時會順手清掉工作樹,而這一步只是要 +知道現況——清不清理是使用者的決定,不該由「我想看一下留言」這個動作順便做掉。 + +**`terminal` 為 `true`(PR 已合併或已關閉)時就停下來,不要繼續處理留言。** 那顆工作包 +已經結束,在一棵該被清掉的工作樹上改東西是白做工,而且那些改動不會進到任何 PR 裡。 +把 `suggestedAction` 的意思講給使用者聽,讓他決定下一步: + +| 值 | 意思 | +| --- | --- | +| `nothing-to-do` | 沒事了;工作樹不在或已經清掉 | +| `cleanup` | 工作樹還在,可以用 `worktree-remove` 清掉 | +| `blocked-dirty` | 那棵工作樹裡還有沒提交的東西,要他自己處理 | PR 還開著就定位工作樹。輸入是 `pr-watch` 給的 `branch`——路徑由「哪顆工作包」推導, 不必也不該由使用者自己去記那串雜湊目錄名: ``` -node scripts/worktree-ensure.js --repo --branch --dry-run +node scripts/worktree-ensure.js --repo --path <目標專案路徑> \ + --branch --dry-run ``` +`--path` 是目標專案在本機的位置(工作包 `repos` 列的那一顆),不給就用當前目錄—— +而當前目錄多半不是它,這正是這一步要解決的問題。 + 試跑會印出推導出的路徑與將執行的 git 指令;確認無誤後拿掉旗標再跑一次。 **推導出的路徑不存在時它會重建,那是常態不是例外**:進度完全不寫在本機,換一台機器 diff --git a/scripts/branch-prep.js b/scripts/branch-prep.js index fca6390..f765b90 100644 --- a/scripts/branch-prep.js +++ b/scripts/branch-prep.js @@ -51,6 +51,7 @@ import { parseFlags, parseRepo, planWorktree, + requireOrigin, worktreePath, } from './lib.js'; @@ -91,12 +92,7 @@ main(async () => { const worktree = worktreePath(repo, branch); const git = openGitRepo(path); - if (!git('remote').split('\n').includes('origin')) { - throw new ScriptError( - 'NO_ORIGIN', - `${path} 沒有 origin 遠端;工作樹的起點一律取自 origin/{來源分支},請先設定 origin`, - ); - } + requireOrigin(git, path, '工作樹的起點一律取自 origin/{來源分支}'); const plan = planWorktree(git, { source, branch, worktree }); const 報告 = { path, repo, source, branch, worktree, 分支: { 動作: plan.動作 } }; diff --git a/scripts/lib.js b/scripts/lib.js index 6647e02..4cd363f 100644 --- a/scripts/lib.js +++ b/scripts/lib.js @@ -1,19 +1,28 @@ /** * tea-sdlc 所有腳本的共用地基。 * - * 這一層負責六件事,其餘腳本只寫自己的業務: + * 這一層負責七件事,其餘腳本只寫自己的業務: * 1. 具名 flag 解析與單行 JSON 輸出({ok, data, error:{code, message}}) * 2. Gitea API 呼叫 —— 全專案唯一的 HTTP 出口 * 3. git 執行 —— 全專案唯一的子行程出口 - * 4. 四層前置檢查 - * 5. 冪等查重 - * 6. 兩支抽取腳本共用的議題讀取 + * 4. 工作樹的一生 —— 路徑推導、建立(含重建)、現況、移除 + * 5. 四層前置檢查 + * 6. 冪等查重 + * 7. 兩支抽取腳本共用的議題讀取 * * 外部相依集中在 giteaRequest 與 runGit 兩個函式,測試才有地方替身。 */ import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { accessSync, constants, existsSync, readFileSync, realpathSync, statSync } from 'node:fs'; +import { + accessSync, + constants, + existsSync, + readFileSync, + realpathSync, + rmSync, + statSync, +} from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -430,6 +439,18 @@ export function runGit(args, { cwd } = {}) { } } +/** + * 要求目標專案有 origin 遠端。 + * 工作樹的起點一律取自遠端,沒有 origin 就什麼都做不了——兩支腳本擋的是同一件事, + * 只有「為什麼需要它」那一句不同,所以那一句由呼叫端給。 + * @param {string} 用途 出現在訊息裡的理由,例如「工作樹的起點一律取自 origin/{來源分支}」 + */ +export function requireOrigin(git, path, 用途) { + if (!git('remote').split('\n').includes('origin')) { + throw new ScriptError('NO_ORIGIN', `${path} 沒有 origin 遠端;${用途},請先設定 origin`); + } +} + /** * 開一個目標專案的 git repo,回傳綁在它身上的執行器。 * diff --git a/scripts/worktree-ensure.js b/scripts/worktree-ensure.js index 9679bc7..91be0e1 100644 --- a/scripts/worktree-ensure.js +++ b/scripts/worktree-ensure.js @@ -21,13 +21,13 @@ * [--path <目標專案>] [--dry-run] */ import { - ScriptError, createWorktree, main, openGitRepo, parseFlags, parseRepo, planWorktree, + requireOrigin, worktreePath, } from './lib.js'; @@ -43,12 +43,7 @@ main(async () => { const worktree = worktreePath(repo, branch); const git = openGitRepo(path); - if (!git('remote').split('\n').includes('origin')) { - throw new ScriptError( - 'NO_ORIGIN', - `${path} 沒有 origin 遠端;重建工作樹要先能讀到 origin/${branch},請先設定 origin`, - ); - } + requireOrigin(git, path, `重建工作樹要先能讀到 origin/${branch}`); // 不給 source:這裡只重建既有分支的工作樹,沒有「從來源長一支新的」那條路, // 那是 branch-prep 的事——在這裡憑空開一支新分支,等於把 PR 的進度扔掉 diff --git a/test/branch-prep.test.js b/test/branch-prep.test.js index 76f956c..a9c0549 100644 --- a/test/branch-prep.test.js +++ b/test/branch-prep.test.js @@ -275,6 +275,7 @@ test('工作樹建不起來時,不留下那一支已經建好的分支', async false, '也不該留下工作樹的中繼資料', ); + assert.equal(existsSync(join(blocked, 'worktrees')), false, '半途建出來的目錄也不該留下來'); }); // ── 目標分支已存在:接上去,不覆蓋 ───────────────────────────────── diff --git a/test/sdlc-fix-assets.test.js b/test/sdlc-fix-assets.test.js index 5707b7c..33fcb98 100644 --- a/test/sdlc-fix-assets.test.js +++ b/test/sdlc-fix-assets.test.js @@ -17,11 +17,25 @@ const locate = steps.slice(0, steps.indexOf('## 2.')); test('第一步先看 PR 現況,終止狀態就停下來不白做工', () => { assert.match(locate, /pr-watch\.js/); assert.match(locate, /terminal/); + assert.match(locate, /不要繼續處理留言/); assert.match(locate, /白做工/, '要說明為什麼停:那棵工作樹已經該被清掉了'); }); +test('查現況帶 --dry-run,看一下留言不該順手清掉工作樹', () => { + const 指令 = locate.slice(locate.indexOf('pr-watch.js')); + assert.match(指令.slice(0, 120), /--dry-run/); + assert.match(locate, /清不清理是使用者的決定/); +}); + +test('三個建議動作的意思都交代了,不留一個讓 agent 自由發揮', () => { + for (const 值 of ['nothing-to-do', 'cleanup', 'blocked-dirty']) { + assert.match(locate, new RegExp(值)); + } +}); + test('工作樹由工作包推導,不要求使用者自己切目錄', () => { assert.match(locate, /worktree-ensure\.js/); + assert.match(locate, /--path/, '目標專案不是當前目錄時要指得出來,否則會找錯 repo'); assert.match(locate, /branch/, '輸入是 pr-watch 給的分支名'); assert.match(locate, /不必也不該由使用者自己去記/); assert.match(locate, /都在那棵工作樹裡做/);