From 5b740c236b2f90da0637e511a2db2b14829b4275 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 15:54:27 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(branch-prep):=20=E4=B8=80=E5=BE=8B?= =?UTF-8?q?=E5=9C=A8=E7=8D=A8=E7=AB=8B=E7=9A=84=E5=B7=A5=E4=BD=9C=E6=A8=B9?= =?UTF-8?q?=E4=B8=8A=E9=96=8B=E5=B7=A5=EF=BC=8C=E4=B8=8D=E5=9C=A8=E5=8E=9F?= =?UTF-8?q?=E5=9C=B0=E5=88=87=E6=8F=9B=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同一份 clone 上同時持有多顆工作包時,原地切分支有三種損耗,一種比一種難查: 未提交的變更擋路、建置產物跨分支混淆,以及 agent 讀到不屬於它那顆工作包的 程式碼——agent 是非同步的,它可能在分支已經被切走之後才去讀檔,而且不會察覺, 產出看起來完全合理,只是接錯了上下文。前兩種人會當場發現,第三種不會, 所以工作樹一律建立,不是「有衝突才用」。 建不起來就中止,不退回原地切分支:靜默降級會讓使用者以為自己在隔離環境裡, 其實在原地改。 分支與工作樹合併為一個原子動作(fetch 後一次 worktree add),並補上回滾—— git 在 worktree add 失敗時仍會把分支留下來,那是最難查的半成品:下一次重跑 會走到「目標分支已存在」那條路,起點從此不再是遠端的來源分支。 起點一律取自 origin/{來源分支},遠端沒有就中止,不退回本機同名分支; 本機分支可能落後好幾天,而這件事從輸出上完全看不出來。原「來源分支在遠端 已存在時 pull 而非重建」那條,用更強的方式達成同一個目的:根本不碰本機分支, 就沒有覆蓋他人進度的可能。 不設 upstream:此刻遠端還沒有這個新分支,--track 會把 upstream 指到來源分支, 之後 git pull 會把來源分支的提交拉進來。留給第一次 push -u 自然建立。 路徑由 owner/repo/分支名 正規化後取雜湊推導(lib 的 worktreePath),不查表、 不寫狀態檔,換機器算出來一樣。取雜湊而不是把斜線攤平成 -,是因為攤平會讓 feat/a-b/main 與 feat/a/b/main 撞成同一個目錄,而現行的分支命名規則恰好讓 這種形狀有機會出現。 議題 #40 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/branch-prep.js | 300 ++++++++++++++++++++------------ scripts/lib.js | 76 ++++++++ test/branch-prep.test.js | 348 ++++++++++++++++++++++++++----------- test/worktree-path.test.js | 131 ++++++++++++++ 4 files changed, 647 insertions(+), 208 deletions(-) create mode 100644 test/worktree-path.test.js diff --git a/scripts/branch-prep.js b/scripts/branch-prep.js index 398c756..c9370db 100644 --- a/scripts/branch-prep.js +++ b/scripts/branch-prep.js @@ -1,6 +1,14 @@ #!/usr/bin/env node /** - * 備妥開工的分支。 + * 備妥開工的分支與工作樹。 + * + * 每顆工作包在一棵屬於自己的工作樹上開工,不在原地切換分支。這樣同時持有幾顆工作包 + * 都互不干擾:各自的未提交變更、各自的建置產物,而 agent 無論什麼時候去讀檔, + * 都只會讀到它該讀的那份程式碼——agent 是非同步的,它不會察覺自己讀到的是別顆工作包 + * 的內容,產出看起來完全合理,只是接錯了上下文。 + * + * 工作樹一律建立,沒有例外。建不起來就明確中止,不默默退回原地切分支:靜默降級會讓 + * 使用者以為自己在隔離環境裡,其實在原地改。 * * 命名規則(議題 #1 的正本): * - 從開發分支長出 → `{類型}/{英文-kebab-需求描述}/main` @@ -10,20 +18,29 @@ * 需求描述由議題標題翻譯——那是 agent 的事,不是腳本的事,所以這裡只收 `--slug` * 並驗格式:英文 kebab、≤40 字元。中文分支名會讓 CI 與 URL 出問題,擋在建立之前。 * - * 三處「不弄丟別人的東西」: - * - 工作區不乾淨就不動手,免得把不相干的改動帶進這顆工作包的分支。 - * - 來源分支在遠端已存在時 pull 而不是重建。 - * - 目標分支已存在時接上去而不是從來源蓋掉。 - * 三種情況都在任何 git 寫入之前判斷完:試跑印得出漂亮的計畫、實跑卻中途炸掉, - * 是最難查的那種落差。 + * 建分支與建工作樹是同一個原子動作:先 `git fetch` 更新遠端引用,再以一次 + * `git worktree add` 完成。起點一律取自 `origin/{來源分支}`,遠端沒有就中止, + * 不退回本機同名分支——那是靜默降級,而且後果隱蔽:使用者以為自己從最新的遠端狀態 + * 開工,實際上起點可能落後好幾天。 * + * **不設 upstream**。此刻遠端還沒有這個新分支,`--track` 會把 upstream 指到*來源分支*, + * 之後 `git pull` 會把來源分支的提交拉進來,幾乎一定不是使用者要的。 + * upstream 留給第一次 `push -u` 自然建立。 + * + * 兩處「不弄丟別人的東西」:目標分支已存在時接上去而不是從來源蓋掉;推導出的路徑被 + * 別的東西佔住時中止而不是硬蓋過去。兩者都在任何 git 寫入之前判斷完:試跑印得出 + * 漂亮的計畫、實跑卻中途炸掉,是最難查的那種落差。 + * + * 工作樹路徑由 `owner/repo/分支名` 純函式推導(見 lib 的 worktreePath), * 不寫任何本機狀態檔:換機器或換 agent 都能接手,進度只從 Gitea 與 git 本身推導。 * * 用法: - * node scripts/branch-prep.js --source <來源分支> --slug <英文-kebab> + * node scripts/branch-prep.js --repo --source <來源分支> --slug <英文-kebab> * [--type feat] [--path <目標專案>] [--dry-run] */ -import { ScriptError, main, openGitRepo, parseFlags } from './lib.js'; +import { existsSync, realpathSync } from 'node:fs'; +import { join } from 'node:path'; +import { ScriptError, main, openGitRepo, parseFlags, parseRepo, worktreePath } from './lib.js'; /** 需求描述的長度上限。超過就換一個短的說法,不要靠截斷。 */ const SLUG_MAX = 40; @@ -31,63 +48,63 @@ const SLUG_MAX = 40; /** 功能分支長這樣:三段、前兩段非空。開發分支(master/main/develop)不合這個樣式。 */ const FEATURE_BRANCH = /^([a-z]+)\/([^/]+)\/([^/]+)$/; +/** + * 專案檔 → 把依賴裝起來的指令。 + * 工作樹是乾淨的,這份對照表只用來提示使用者該跑什麼,腳本自己不執行安裝—— + * 在別人的機器上裝東西應該是他自己的決定。 + */ +const INSTALL_HINTS = [ + ['package.json', 'npm install'], + ['composer.json', 'composer install'], + ['requirements.txt', 'pip install -r requirements.txt'], + ['go.mod', 'go mod download'], + ['Gemfile', 'bundle install'], + ['Cargo.toml', 'cargo fetch'], +]; + +const CLEAN_WORKTREE = + '這是一棵乾淨的工作樹:沒有安裝依賴,也沒有任何建置產物。' + + '.env 這類機密檔案一律不自動複製,需要的話請自己放一份。'; + main(async () => { const flags = parseFlags(process.argv.slice(2), { - required: ['source', 'slug'], + required: ['repo', 'source', 'slug'], optional: ['type', 'path'], booleans: ['dry-run'], }); + const repo = parseRepo(flags.repo); const path = flags.path ?? process.cwd(); const source = flags.source; const branch = buildBranchName(source, flags.slug, flags.type); + const worktree = worktreePath(repo, branch); const git = openGitRepo(path); - checkClean(git, path); - - const hasOrigin = git('remote').split('\n').includes('origin'); - /** - * 比對用全名 `refs/heads/`:`ls-remote --heads origin main` 的樣式比對吃的是 - * ref 的尾段,而本 repo 的命名慣例讓每一支分支都以 `/main` 結尾——用短名比對, - * 拿 main 當開發分支的專案會整個誤判成「遠端已經有這一支」。 - */ - const onRemote = (ref) => - hasOrigin && git('ls-remote', '--heads', 'origin', `refs/heads/${ref}`).trim() !== ''; - const onLocal = (ref) => git('branch', '--list', ref).trim() !== ''; - - const sourcePlan = syncPlan(source, onRemote(source), onLocal(source), { - missing: () => { - throw new ScriptError( - 'SOURCE_NOT_FOUND', - `來源分支 ${source} 在本地與遠端都不存在;請確認分支名,或先把它推上遠端`, - ); - }, - }); - const branchPlan = syncPlan(branch, onRemote(branch), onLocal(branch), { - // 目標分支不存在是常態:這就是開一支新分支 - missing: () => ({ commands: [['checkout', '-b', branch]], 動作: '從來源建立' }), - onRemote: '接上遠端既有', - onLocal: '切換到本地既有', - }); - - const commands = [...sourcePlan.commands, ...branchPlan.commands]; - const 來源 = { 位置: sourcePlan.位置, 動作: sourcePlan.動作 }; - const 分支 = { 動作: branchPlan.動作 }; - - if (flags['dry-run']) { - return { - dryRun: true, - path, - source, - branch, - 來源, - 分支, - commands: commands.map((args) => `git ${args.join(' ')}`), - }; + if (!git('remote').split('\n').includes('origin')) { + throw new ScriptError( + 'NO_ORIGIN', + `${path} 沒有 origin 遠端;工作樹的起點一律取自 origin/{來源分支},請先設定 origin`, + ); } - for (const args of commands) runGitStep(git, args, source); + const plan = planWorktree(git, { repo, source, branch, worktree }); + const 報告 = { path, repo, source, branch, worktree, 分支: { 動作: plan.動作 } }; - return { path, source, branch, 來源, 分支 }; + if (flags['dry-run']) { + 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; + } + } + + return { ...報告, 提示: { 訊息: CLEAN_WORKTREE, 安裝指令: installHints(worktree) } }; }); @@ -153,72 +170,137 @@ function isKebab(value) { } /** - * 工作區必須乾淨才開工。 - * - * 未提交的改動與未追蹤的檔案都會跟著 checkout 走到新分支上,混進這顆工作包的 commit - * 裡;而目標分支已存在時,git 還會在 checkout 那一步才拒絕,屆時 fetch 與 merge - * 都已經跑掉了,留下做到一半的狀態。寧可一開始就擋。 - */ -function checkClean(git, path) { - const dirty = git('status', '--porcelain'); - if (dirty !== '') { - const files = dirty - .split('\n') - .map((line) => line.slice(3)) - .join('、'); - throw new ScriptError( - 'DIRTY_WORKTREE', - `${path} 的工作區還有未處理的變更(${files});` + - '請先提交、暫存(git stash)或清掉,再來開分支', - ); - } -} - -/** - * 算出要把一支 ref 弄到手需要哪幾個 git 指令。 - * - * 來源分支與目標分支的處理是同一個形狀——遠端有就 pull、只有本地就切過去—— - * 差別只在「兩邊都沒有」時怎麼辦,所以那一段由呼叫端給。 + * 算出要把這棵工作樹弄到手需要哪幾個 git 指令。 * * 分成「算」與「做」兩段,`--dry-run` 才能印出真正將執行的 git 指令, - * 而不是另外維護一份描述——兩邊分開寫就會走鐘。 + * 而不是另外維護一份描述——兩邊分開寫就會走鐘。會擋的判斷全在這一段裡完成, + * 所以試跑與實跑在同一個地方被擋下來。 * - * @param {(ref: string) => {commands: string[][], 動作: string}} handlers.missing 兩邊都沒有時 + * @returns {{commands: string[][], 動作: string}} commands 為空代表工作樹已經在了 */ -function syncPlan(ref, onRemote, onLocal, handlers) { - if (onRemote) { - // 遠端已經有了就 pull,不重建:別人推上去的進度要帶進來 - const commands = [['fetch', 'origin', ref]]; - commands.push(onLocal ? ['checkout', ref] : ['checkout', '-b', ref, `origin/${ref}`]); - if (onLocal) commands.push(['merge', '--ff-only', `origin/${ref}`]); - return { commands, 位置: '遠端', 動作: handlers.onRemote ?? 'pull' }; +function planWorktree(git, { repo, 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 (onLocal) { - return { - commands: [['checkout', ref]], - 位置: '本地', - 動作: handlers.onLocal ?? '用本地既有', - }; + if (!既有 && 目錄還在) { + throw new ScriptError( + 'WORKTREE_PATH_TAKEN', + `${worktree} 已經有東西了,但它不是這個 repo 的工作樹(可能是別的 clone 留下的);` + + '請確認裡面沒有還沒保存的東西之後移除它,再重跑', + ); } - return handlers.missing(ref); + + // 起點一律取自遠端:本機同名分支可能落後好幾天,靜默拿它當起點的後果太隱蔽 + 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, 動作: '從來源建立' }; } /** - * 跑一個 git 步驟,並把已知會發生的失敗換成看得懂的錯誤碼。 - * `merge --ff-only` 失敗幾乎都是同一件事:本地有沒推上去的 commit,而遠端也往前走了。 - * 原始的 git 訊息說得不夠白,使用者需要知道下一步是 rebase 還是先推。 + * 這個 repo 目前有哪幾棵工作樹。 + * `--porcelain` 的輸出是以空行分隔的區塊,每塊第一行是 `worktree <路徑>`, + * 分支則是 `branch refs/heads/<名字>`;detached 的工作樹沒有 branch 那一行。 */ -function runGitStep(git, args, source) { +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 || resolve(a) === resolve(b); +} + +/** 解析得出真實路徑就用它,路徑還不存在時退回原字串 */ +function resolve(path) { try { - return git(...args); - } catch (error) { - if (args[0] === 'merge') { - throw new ScriptError( - 'SOURCE_DIVERGED', - `${source} 的本地與遠端已經分歧,無法直接快轉;` + - '請先把本地的 commit 推上去或 rebase 到遠端之後,再執行一次', - ); - } - throw error; + 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:失敗了也不能蓋掉真正的錯誤訊息,那才是使用者要看的東西。 */ +function quietly(git, args) { + try { + git(...args); + } catch { + // 清不掉就算了:原本的錯誤比清理的錯誤重要 + } +} + +/** + * 這棵工作樹要怎麼把依賴裝起來。 + * 偵測不到就回空陣列,不亂猜——猜錯的指令比沒有指令更浪費時間。 + */ +function installHints(worktree) { + return INSTALL_HINTS.filter(([file]) => existsSync(join(worktree, file))).map(([, command]) => command); +} diff --git a/scripts/lib.js b/scripts/lib.js index 5b3f3cd..e670526 100644 --- a/scripts/lib.js +++ b/scripts/lib.js @@ -12,6 +12,7 @@ * 外部相依集中在 giteaRequest 與 runGit 兩個函式,測試才有地方替身。 */ import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { accessSync, constants, existsSync, readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -51,6 +52,50 @@ export function promptsDir() { return join(pluginRoot(), 'prompts'); } +/** + * tea-sdlc 在使用者家目錄底下的家。工作樹集中放在這裡,清理時只有一個地方要看。 + * `TEA_SDLC_HOME` 只是測試與 CI 的覆寫出口,正常使用不必設。 + */ +export function teaSdlcHome() { + return process.env.TEA_SDLC_HOME?.trim() || join(homedir(), '.tea-sdlc'); +} + +/** 所有工作樹的集中處 */ +export function worktreesRoot() { + return join(teaSdlcHome(), 'worktrees'); +} + +/** + * 由「哪顆工作包」純函式推導出「它的工作樹在哪」。 + * + * 不查表、不讀狀態檔:任何流程(開工、處理留言、清理)都要算得出同一條路徑, + * 換一台機器或換一個 agent 也一樣,不存在就重建。 + * + * 目錄名取雜湊而不是把分支名的斜線攤平成 `-`:攤平會讓 `feat/a-b/main` 與 + * `feat/a/b/main` 撞成同一個目錄,而本專案的分支命名規則恰好讓這種形狀有機會出現。 + * 可讀性的缺口由 `git worktree list` 補上——它本來就會把分支名印在路徑旁邊。 + * + * 推導前先正規化:沒有它,同一棵工作樹會因為輸入多一個空白或大小寫不同而被推導成兩條路徑。 + * + * @param {string} repo owner/name + * @param {string} branch 分支名,可含斜線 + * @returns {string} ~/.tea-sdlc/worktrees/{sha256 前 12 碼} + */ +export function worktreePath(repo, branch) { + const key = `${normalizeRef(repo)}/${normalizeRef(branch)}`; + const hash = createHash('sha256').update(key).digest('hex').slice(0, 12); + return join(worktreesRoot(), hash); +} + +/** 逐段修掉空白再轉小寫:`Plugins / Tea-SDLC` 與 `plugins/tea-sdlc` 是同一個東西。 */ +function normalizeRef(value) { + return value + .split('/') + .map((segment) => segment.trim()) + .join('/') + .toLowerCase(); +} + // ── 套件 manifest ───────────────────────────────────────────────── /** @@ -609,6 +654,37 @@ export async function countUnmergedComments(login, repo, index) { return unmerged; } +// ── 碼錶 ─────────────────────────────────────────────────────────── + +/** + * 目前跑在自己身上的碼錶。 + * + * Gitea 只讓人讀自己的碼錶,看不到別人的——所以這份清單的語意永遠是「**我**的錶」, + * 它用來發現自己忘了停上一顆,不是用來判斷別人有沒有在做(那看 assignee)。 + * @param {{base: string, token: string}} login + * @returns {Promise} + */ +export async function listStopwatches(login) { + const path = '/user/stopwatches'; + return expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? []; +} + +/** + * 這些碼錶裡,跑在指定議題上的那一顆。 + * 比對要連 repo 一起看:不同 repo 的同號議題是兩件事。 + * @param {object[]} watches listStopwatches 的結果 + * @param {string} repo owner/name + * @param {number} index + * @returns {object|null} + */ +export function stopwatchOnIssue(watches, repo, index) { + return ( + watches.find( + (watch) => `${watch.repo_owner_name}/${watch.repo_name}` === repo && watch.issue_index === index, + ) ?? null + ); +} + // ── 標籤 ─────────────────────────────────────────────────────────── /** diff --git a/test/branch-prep.test.js b/test/branch-prep.test.js index 6ae8fa0..76f956c 100644 --- a/test/branch-prep.test.js +++ b/test/branch-prep.test.js @@ -1,33 +1,52 @@ /** - * 備妥開工的分支。 + * 備妥開工的工作樹。 * - * 兩件事各自要驗: + * 三件事各自要驗: * 1. **分支命名**是純字串規則,表格驅動,成本最低、回歸價值最高。 * 規則錯了會一路帶到 PR 標題與 CI,事後改名很痛。 - * 2. **不覆蓋他人進度**。來源分支在遠端已存在時要 pull 而不是重建, - * 目標分支已存在時要切過去而不是從來源蓋掉。這兩件事沒有真的遠端就驗不出來, - * 所以測試在臨時 repo 上跑真的 git。 + * 2. **一律在獨立的工作樹上開工**。主工作區一個字都不該被動到——包含它未提交的變更, + * 以及它現在停在哪一支分支上。 + * 3. **原子性與不覆蓋他人進度**。分支與工作樹是同一個動作,失敗時不留半成品; + * 起點一律取自遠端,目標分支已存在時接上去而不是蓋掉。 + * + * git 不做替身:在臨時 repo 上跑真的 git,以本機裸 repo 充當遠端,不需網路。 + * 工作樹則以 TEA_SDLC_HOME 改指到測試暫存,不落到開發者真正的家目錄。 */ import test from 'node:test'; import assert from 'node:assert/strict'; -import { existsSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { writeFileSync } from 'node:fs'; import { runScript, tmpRoot } from './helpers/run-script.js'; import { makeTempRepoWithRemote } from './helpers/temp-repo.js'; -/** 開一個有遠端的臨時 repo,並登記在測試結束時清掉 */ +const REPO = 'plugins/tea-sdlc'; + +/** 開一個有遠端的臨時 repo 與一個空的工作樹家,並登記在測試結束時清掉 */ function withRepo(t) { const repo = makeTempRepoWithRemote(); t.after(() => repo.cleanup()); - return repo; + mkdirSync(tmpRoot, { recursive: true }); + const home = mkdtempSync(join(tmpRoot, 'home-')); + t.after(() => { + // 工作樹裡有 .git 檔指回主 repo,直接刪目錄即可;主 repo 隨後也會被刪掉 + rmSync(home, { recursive: true, force: true }); + }); + return { ...repo, home }; } -const run = (repo, args) => runScript('branch-prep.js', ['--path', repo.dir, ...args]); +const run = (repo, args) => + runScript('branch-prep.js', ['--repo', REPO, '--path', repo.dir, ...args], { + env: { TEA_SDLC_HOME: repo.home }, + }); -/** 目前 checkout 在哪一支 */ +/** 主工作區目前停在哪一支 */ const currentBranch = (repo) => repo.git('rev-parse', '--abbrev-ref', 'HEAD'); +/** 工作樹裡 checkout 出來的是哪一支 */ +const branchIn = (dir) => + execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim(); + // ── 分支命名規則 ─────────────────────────────────────────────────── const NAMING = [ @@ -66,13 +85,14 @@ const NAMING = [ for (const { name, source, args, expected } of NAMING) { test(`命名:${name}`, async (t) => { const repo = withRepo(t); - if (source !== 'master') repo.git('checkout', '-q', '-B', source); + // 來源分支一律取自遠端,所以先讓遠端有這一支 + if (source !== 'master') repo.pushFromElsewhere(source, `${expected.replace(/\//g, '-')}.txt`, '來源\n'); const { code, json } = await run(repo, ['--source', source, ...args]); assert.equal(code, 0, json.error?.message); assert.equal(json.data.branch, expected); - assert.equal(currentBranch(repo), expected); + assert.equal(branchIn(json.data.worktree), expected, '工作樹裡 checkout 出來的要是新分支'); }); } @@ -128,7 +148,7 @@ test('從開發分支長出卻沒給 --type 時,指名缺的是哪一個', asy test('從功能分支長出時給 --type 會被擋,避免子分支跑到別棵樹下', async (t) => { const repo = withRepo(t); - repo.git('checkout', '-q', '-B', 'feat/wp-extract-contract/main'); + repo.pushFromElsewhere('feat/wp-extract-contract/main', 'src.txt', '來源\n'); const { json } = await run(repo, [ '--source', 'feat/wp-extract-contract/main', '--type', 'fix', '--slug', 'crlf', @@ -138,41 +158,72 @@ test('從功能分支長出時給 --type 會被擋,避免子分支跑到別棵 assert.match(json.error.message, /feat/); }); -// ── 來源分支:pull 而不是重建 ───────────────────────────────────── +// ── 一律建立工作樹 ───────────────────────────────────────────────── -test('來源分支在遠端已存在時執行 pull,帶進別人的進度', async (t) => { +test('分支與工作樹一起建立,主工作區完全不被動到', async (t) => { + const repo = withRepo(t); + + const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + + assert.equal(code, 0, json.error?.message); + assert.equal(existsSync(json.data.worktree), true, '工作樹要真的在磁碟上'); + assert.equal(branchIn(json.data.worktree), 'feat/mine/main'); + assert.equal(currentBranch(repo), 'master', '主工作區不該被切走:agent 會在那裡讀到不屬於它的程式碼'); +}); + +test('主工作區有未提交的變更時照樣開得了工,那正是工作樹要解決的事', async (t) => { + const repo = withRepo(t); + writeFileSync(join(repo.dir, 'README.md'), '改到一半的東西\n'); + writeFileSync(join(repo.dir, 'stray.txt'), '不相干的檔案\n'); + + const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + + assert.equal(code, 0, json.error?.message); + assert.equal( + repo.git('status', '--porcelain').includes('stray.txt'), + true, + '未提交的變更要原封不動留在主工作區,不被帶到新分支上', + ); + assert.equal(existsSync(join(json.data.worktree, 'stray.txt')), false, '也不該跟到工作樹裡'); +}); + +test('工作樹路徑在集中的家底下,不長在目標專案裡也不長在它的兄弟目錄', async (t) => { + const repo = withRepo(t); + + const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + + assert.equal(json.data.worktree.startsWith(join(repo.home, 'worktrees')), true); + assert.equal(repo.git('status', '--porcelain'), '', '工作樹不該出現在目標專案的 git status 裡'); +}); + +// ── 起點一律取自遠端 ─────────────────────────────────────────────── + +test('本機落後時,工作樹仍從遠端的最新狀態長出', async (t) => { const repo = withRepo(t); repo.pushFromElsewhere('master', 'theirs.txt', '別人的進度\n'); const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); assert.equal(code, 0, json.error?.message); - assert.ok(existsSync(join(repo.dir, 'theirs.txt')), '別人推上去的檔案要被帶進來'); - assert.equal(json.data.來源.動作, 'pull'); + assert.equal(existsSync(join(json.data.worktree, 'theirs.txt')), true, '起點要是遠端的最新狀態'); + assert.equal(existsSync(join(repo.dir, 'theirs.txt')), false, '主工作區不必被順便更新'); }); -test('來源分支只在本地時照樣可用,不會因為遠端沒有就報錯', async (t) => { +test('遠端沒有來源分支時明確中止,不退回本機同名分支', async (t) => { const repo = withRepo(t); - repo.git('checkout', '-q', '-B', 'feat/local-only/main'); - repo.git('checkout', '-q', 'master'); + repo.git('branch', 'feat/local-only/main'); - const { code, json } = await run(repo, [ - '--source', 'feat/local-only/main', '--slug', 'sub-feature', - ]); - - assert.equal(code, 0, json.error?.message); - assert.equal(json.data.來源.動作, '用本地既有'); -}); - -test('來源分支本地與遠端都沒有時,回可區分的錯誤碼', async (t) => { - const repo = withRepo(t); - - const { code, json } = await run(repo, [ - '--source', 'feat/不存在/main', '--slug', 'whatever', - ]); + const { code, json } = await run(repo, ['--source', 'feat/local-only/main', '--slug', 'sub-feature']); assert.equal(code, 1); assert.equal(json.error.code, 'SOURCE_NOT_FOUND'); + assert.match(json.error.message, /推/, '要指出下一步是把來源分支推上去'); + assert.match(json.error.message, /來源/, '或改指定一個已存在的來源分支'); + assert.equal( + repo.git('branch', '--list', 'feat/local-only/sub-feature'), + '', + '擋下來就不該已經建好分支', + ); }); test('遠端分支的比對是全名,不是尾段', async (t) => { @@ -188,95 +239,150 @@ test('遠端分支的比對是全名,不是尾段', async (t) => { assert.equal(json.error.code, 'SOURCE_NOT_FOUND', '遠端沒有 main 這一支,不該被 feat/x/main 冒名頂替'); }); -// ── 開工前的工作區必須乾淨 ───────────────────────────────────────── - -test('工作區有未提交的改動時擋下,不把它們帶進新分支', async (t) => { +test('不為新分支設定 upstream:此刻遠端還沒有這一支,設了會誤指到來源分支', async (t) => { const repo = withRepo(t); - writeFileSync(join(repo.dir, 'README.md'), '改到一半的東西\n'); - - const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); - - assert.equal(code, 1); - assert.equal(json.error.code, 'DIRTY_WORKTREE'); - assert.equal(currentBranch(repo), 'master', '擋下來就不該已經切過分支'); - assert.equal(repo.git('branch', '--list', 'feat/mine/main'), '', '也不該已經建好分支'); -}); - -test('未追蹤的檔案同樣算不乾淨:它會跟著被帶到新分支上', async (t) => { - const repo = withRepo(t); - writeFileSync(join(repo.dir, 'stray.txt'), '不相干的檔案\n'); const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); - assert.equal(json.error.code, 'DIRTY_WORKTREE'); - assert.match(json.error.message, /stray\.txt/, '要指名是哪些檔案擋住了'); + assert.equal( + repo.git('for-each-ref', '--format=%(upstream)', 'refs/heads/feat/mine/main'), + '', + 'upstream 指到來源分支的話,之後 git pull 會把來源分支的提交拉進來', + ); + assert.equal(json.data.branch, 'feat/mine/main'); }); -test('工作區不乾淨時,--dry-run 也要照樣說出來', async (t) => { - // 試跑印得出漂亮的計畫、實跑卻中途炸掉,是最難查的那種落差 +// ── 原子性:失敗不留半成品 ───────────────────────────────────────── + +test('工作樹建不起來時,不留下那一支已經建好的分支', async (t) => { + // git worktree add 失敗時仍會把分支留下來,那是最難查的半成品: + // 下一次重跑會走到「目標分支已存在」那條路,起點從此不是遠端的來源分支 const repo = withRepo(t); - writeFileSync(join(repo.dir, 'stray.txt'), '不相干的檔案\n'); + const blocked = join(repo.home, 'blocker'); + writeFileSync(blocked, '這是一個檔案,不是目錄\n'); - const { json } = await run(repo, [ - '--source', 'master', '--type', 'feat', '--slug', 'mine', '--dry-run', - ]); - - assert.equal(json.error.code, 'DIRTY_WORKTREE'); -}); - -// ── 來源分支與遠端分歧 ───────────────────────────────────────────── - -test('本地來源分支與遠端分歧時,回可區分的錯誤碼而不是 git 的原始訊息', async (t) => { - const repo = withRepo(t); - // 本地有一顆沒推的 commit,遠端也往前走了一顆 - writeFileSync(join(repo.dir, 'mine.txt'), '我的\n'); - repo.git('add', '-A'); - repo.git('commit', '-qm', '本地未推的 commit'); - repo.pushFromElsewhere('master', 'theirs.txt', '別人的\n'); - - const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + const { code, json } = await runScript( + 'branch-prep.js', + ['--repo', REPO, '--path', repo.dir, '--source', 'master', '--type', 'feat', '--slug', 'mine'], + { env: { TEA_SDLC_HOME: blocked } }, + ); assert.equal(code, 1); - assert.equal(json.error.code, 'SOURCE_DIVERGED'); - assert.match(json.error.message, /master/); - assert.equal(currentBranch(repo), 'master', '不該留在半途的狀態'); + assert.equal(json.error.code, 'GIT_FAILED'); + assert.equal(repo.git('branch', '--list', 'feat/mine/main'), '', '分支不該留下來'); + assert.equal( + repo.git('worktree', 'list', '--porcelain').includes('feat/mine/main'), + false, + '也不該留下工作樹的中繼資料', + ); }); -// ── 目標分支:已存在就切過去,不覆蓋 ─────────────────────────────── +// ── 目標分支已存在:接上去,不覆蓋 ───────────────────────────────── -test('目標分支已在遠端時切過去,保留上面已有的進度', async (t) => { +test('目標分支已在遠端時接上去,保留上面已有的進度', async (t) => { const repo = withRepo(t); repo.pushFromElsewhere('feat/mine/main', 'progress.txt', '已經做了一半\n'); const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); assert.equal(code, 0, json.error?.message); - assert.equal(currentBranch(repo), 'feat/mine/main'); - assert.ok( - existsSync(join(repo.dir, 'progress.txt')), + assert.equal(json.data.分支.動作, '接上遠端既有'); + assert.equal( + existsSync(join(json.data.worktree, 'progress.txt')), + true, '遠端已有的分支要接上去,不是從來源重建一個空的蓋掉', ); - assert.equal(json.data.分支.動作, '接上遠端既有'); }); -test('目標分支只在本地時切過去,不重建', async (t) => { +test('目標分支只在本地時接上去,不重建', async (t) => { const repo = withRepo(t); - repo.git('checkout', '-q', '-b', 'feat/mine/main'); - repo.git('checkout', '-q', 'master'); + repo.git('branch', 'feat/mine/main'); const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); - assert.equal(json.data.分支.動作, '切換到本地既有'); - assert.equal(currentBranch(repo), 'feat/mine/main'); + assert.equal(json.data.分支.動作, '接上本地既有'); + assert.equal(branchIn(json.data.worktree), 'feat/mine/main'); }); -test('目標分支不存在時從來源建立', async (t) => { +test('目標分支不存在時從遠端的來源分支建立', async (t) => { const repo = withRepo(t); const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'brand-new']); assert.equal(json.data.分支.動作, '從來源建立'); - assert.equal(currentBranch(repo), 'feat/brand-new/main'); +}); + +// ── 冪等:工作樹已經在了 ─────────────────────────────────────────── + +test('工作樹已經在了就沿用,不動裡面還沒提交的東西', async (t) => { + const repo = withRepo(t); + const first = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + writeFileSync(join(first.json.data.worktree, 'wip.txt'), '做到一半\n'); + + const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + + assert.equal(code, 0, json.error?.message); + assert.equal(json.data.worktree, first.json.data.worktree, '推導出來的是同一條路徑'); + assert.equal(json.data.分支.動作, '沿用既有工作樹'); + assert.equal(existsSync(join(json.data.worktree, 'wip.txt')), true, '重跑不該把做到一半的東西刷掉'); +}); + +test('推導出來的路徑被別的東西佔住時明確中止,不硬蓋過去', async (t) => { + const repo = withRepo(t); + const { json: first } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + // 造出「別的 clone 留下的目錄」:本 repo 的 git 已經不認得它了,但路徑還在 + repo.git('worktree', 'remove', '--force', first.data.worktree); + mkdirSync(first.data.worktree, { recursive: true }); + writeFileSync(join(first.data.worktree, '別人的東西.txt'), 'x\n'); + + const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + + assert.equal(code, 1); + assert.equal(json.error.code, 'WORKTREE_PATH_TAKEN'); + assert.match(json.error.message, new RegExp(first.data.worktree), '要指名是哪一條路徑被佔住'); + assert.equal(existsSync(join(first.data.worktree, '別人的東西.txt')), true, '不得動到裡面的東西'); +}); + +// ── 乾淨的工作樹 ─────────────────────────────────────────────────── + +test('建好後說明這是一棵乾淨的工作樹,並依專案檔給出安裝指令', async (t) => { + const repo = withRepo(t); + repo.pushFromElsewhere('master', 'package.json', '{"name":"demo"}\n'); + + const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + + assert.deepEqual(json.data.提示.安裝指令, ['npm install']); + assert.match(json.data.提示.訊息, /乾淨/); +}); + +test('偵測不到專案檔時不亂猜指令,只說明它是乾淨的', async (t) => { + const repo = withRepo(t); + + const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + + assert.deepEqual(json.data.提示.安裝指令, []); + assert.match(json.data.提示.訊息, /乾淨/); +}); + +test('多種語言的專案檔都偵測得到,各給各的指令', async (t) => { + const repo = withRepo(t); + repo.pushFromElsewhere('master', 'composer.json', '{}\n'); + + const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + + assert.deepEqual(json.data.提示.安裝指令, ['composer install']); +}); + +test('不複製也不連結依賴、建置產物與機密檔案', async (t) => { + const repo = withRepo(t); + mkdirSync(join(repo.dir, 'node_modules', 'left-pad'), { recursive: true }); + writeFileSync(join(repo.dir, 'node_modules', 'left-pad', 'index.js'), '\n'); + writeFileSync(join(repo.dir, '.env'), 'TOKEN=秘密\n'); + + const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + + assert.equal(existsSync(join(json.data.worktree, 'node_modules')), false, 'symlink 會把隔離接回去'); + assert.equal(existsSync(join(json.data.worktree, '.env')), false, '機密一律由使用者自己放'); }); // ── 不留本機狀態檔 ───────────────────────────────────────────────── @@ -286,12 +392,11 @@ test('跑完不在目標專案裡留下任何狀態檔', async (t) => { await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'no-state']); - const status = repo.git('status', '--porcelain'); - assert.equal(status, '', '工作區要是乾淨的:進度只從 Gitea 推導,不寫本機狀態檔'); + assert.equal(repo.git('status', '--porcelain'), '', '進度只從 Gitea 與 git 本身推導,不寫本機狀態檔'); }); test('git 自己的進度訊息不漏到 stderr', async (t) => { - // checkout 與 fetch 的訊息 git 一律寫在 stderr。腳本的輸出契約是「stdout 一行 JSON、 + // fetch 與 worktree add 的訊息 git 一律寫在 stderr。腳本的輸出契約是「stdout 一行 JSON、 // stderr 乾淨」,漏出去的話呼叫端就得去分辨哪幾行是雜訊。 const repo = withRepo(t); @@ -300,22 +405,43 @@ test('git 自己的進度訊息不漏到 stderr', async (t) => { assert.equal(stderr, ''); }); -// ── 路徑 ─────────────────────────────────────────────────────────── +// ── 路徑與 flag ─────────────────────────────────────────────────── test('--path 指向的不是 git repo 時,回可區分的錯誤碼', async (t) => { const { json } = await runScript('branch-prep.js', [ - '--path', tmpRoot, '--source', 'master', '--type', 'feat', '--slug', 'whatever', + '--repo', REPO, '--path', tmpRoot, '--source', 'master', '--type', 'feat', '--slug', 'whatever', ]); assert.equal(json.error.code, 'NOT_A_GIT_REPO'); }); +test('缺 --repo 時擋下:沒有它推導不出工作樹在哪', async (t) => { + const repo = withRepo(t); + + const { json } = await runScript('branch-prep.js', [ + '--path', repo.dir, '--source', 'master', '--type', 'feat', '--slug', 'mine', + ], { env: { TEA_SDLC_HOME: repo.home } }); + + assert.equal(json.error.code, 'MISSING_FLAG'); + assert.match(json.error.message, /--repo/); +}); + +test('目標專案沒有 origin 時擋下:起點一律取自遠端', async (t) => { + const repo = withRepo(t); + repo.git('remote', 'remove', 'origin'); + + const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + + assert.equal(json.error.code, 'NO_ORIGIN'); +}); + test('回報實際動到的是哪一個目錄,讓人確認沒搞錯專案', async (t) => { const repo = withRepo(t); const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); assert.equal(json.data.path, repo.dir); + assert.equal(json.data.repo, REPO); }); // ── --dry-run ───────────────────────────────────────────────────── @@ -331,23 +457,47 @@ test('--dry-run 印出將執行的 git 指令,且完全不動 repo', async (t) assert.equal(code, 0); assert.equal(json.data.dryRun, true); assert.equal(json.data.branch, 'feat/mine/main'); - assert.ok(json.data.commands.length > 0); assert.ok( json.data.commands.every((c) => c.startsWith('git ')), '預覽的是 git 指令本身,不是自創的描述', ); - assert.equal(currentBranch(repo), 'master', '試跑不該切分支'); assert.equal(repo.git('rev-parse', 'HEAD'), before); assert.equal(repo.git('branch', '--list', 'feat/mine/main'), '', '試跑不該建分支'); + assert.equal(existsSync(json.data.worktree), false, '試跑不該建工作樹'); }); -test('--dry-run 連分支命名都先算出來,看得到才叫預覽', async (t) => { +test('--dry-run 把 fetch 與建立工作樹兩步都印出來,順序不顛倒', async (t) => { const repo = withRepo(t); - repo.git('checkout', '-q', '-B', 'feat/wp-extract-contract/main'); + + const { json } = await run(repo, [ + '--source', 'master', '--type', 'feat', '--slug', 'mine', '--dry-run', + ]); + + assert.deepEqual(json.data.commands, [ + 'git fetch origin', + `git worktree add --no-track -b feat/mine/main ${json.data.worktree} origin/master`, + ], '建分支與建工作樹是同一個指令,不拆成兩步'); +}); + +test('--dry-run 連工作樹路徑都先算出來,看得到才叫預覽', async (t) => { + const repo = withRepo(t); + repo.pushFromElsewhere('feat/wp-extract-contract/main', 'src.txt', '來源\n'); const { json } = await run(repo, [ '--source', 'feat/wp-extract-contract/main', '--slug', 'crlf-fix', '--dry-run', ]); assert.equal(json.data.branch, 'feat/wp-extract-contract/crlf-fix'); + assert.match(json.data.worktree, /worktrees\/[0-9a-f]{12}$/); +}); + +test('遠端沒有來源分支時,--dry-run 也要照樣說出來', async (t) => { + // 試跑印得出漂亮的計畫、實跑卻中途炸掉,是最難查的那種落差 + const repo = withRepo(t); + + const { json } = await run(repo, [ + '--source', 'feat/沒有這支/main', '--slug', 'mine', '--dry-run', + ]); + + assert.equal(json.error.code, 'SOURCE_NOT_FOUND'); }); diff --git a/test/worktree-path.test.js b/test/worktree-path.test.js new file mode 100644 index 0000000..8377bb1 --- /dev/null +++ b/test/worktree-path.test.js @@ -0,0 +1,131 @@ +/** + * 工作樹路徑的推導。 + * + * 這是一個純函式,而且是整套工作樹機制的地基:任何流程都要能從「這是哪顆工作包」 + * 算出「它的工作樹在哪」,不查表、不讀狀態檔,換一台機器算出來也要一樣。 + * 算錯的代價是找不到既有的工作樹而重建一棵,或兩顆工作包撞進同一個目錄。 + * + * 這一支直接 import lib,是本專案第二個這麼做的測試:大小寫與空白的變體沒辦法 + * 穿過 CLI 的 kebab 驗證送進去,而表格驅動正是這種純規則最划算的驗法。 + * 最後一則測試把它與 CLI 實際印出的路徑釘在一起,避免兩邊各自為政。 + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { worktreePath } from '../scripts/lib.js'; +import { runScript, tmpRoot } from './helpers/run-script.js'; +import { makeTempRepoWithRemote } from './helpers/temp-repo.js'; + +/** 推導只看輸入,不看環境;家目錄則由 TEA_SDLC_HOME 決定,測試固定一個值好比對 */ +const HOME = '/home/tester/.tea-sdlc'; +const derive = (repo, branch) => { + process.env.TEA_SDLC_HOME = HOME; + try { + return worktreePath(repo, branch); + } finally { + delete process.env.TEA_SDLC_HOME; + } +}; + +test('路徑長在固定的家底下,目錄名是 12 碼十六進位', () => { + const path = derive('plugins/tea-sdlc', 'feat/worktree-branch-prep/main'); + + assert.match(path, new RegExp(`^${HOME}/worktrees/[0-9a-f]{12}$`)); +}); + +// ── 同一顆工作包只能推導出一條路徑 ───────────────────────────────── + +const SAME = [ + { + name: '大小寫不同', + a: ['plugins/tea-sdlc', 'feat/mine/main'], + b: ['Plugins/Tea-SDLC', 'Feat/Mine/Main'], + }, + { + name: '前後有空白', + a: ['plugins/tea-sdlc', 'feat/mine/main'], + b: [' plugins/tea-sdlc ', ' feat/mine/main '], + }, + { + name: '斜線兩側有空白', + a: ['plugins/tea-sdlc', 'feat/mine/main'], + b: ['plugins / tea-sdlc', 'feat / mine / main'], + }, +]; + +for (const { name, a, b } of SAME) { + test(`同一顆工作包:${name}的變體推導出同一條路徑`, () => { + assert.equal(derive(...a), derive(...b), '正規化沒做,同一棵工作樹會被推導成兩個目錄'); + }); +} + +// ── 不同的工作包不能撞在一起 ─────────────────────────────────────── + +const DIFFERENT = [ + { + name: '分支名的斜線位置不同:feat/a-b/main 與 feat/a/b/main', + a: ['plugins/tea-sdlc', 'feat/a-b/main'], + b: ['plugins/tea-sdlc', 'feat/a/b/main'], + }, + { + name: '同名分支在不同的 repo 上', + a: ['plugins/tea-sdlc', 'feat/mine/main'], + b: ['plugins/別的專案', 'feat/mine/main'], + }, + { + name: '同名 repo 在不同的 owner 底下', + a: ['plugins/tea-sdlc', 'feat/mine/main'], + b: ['someone/tea-sdlc', 'feat/mine/main'], + }, + { + name: '同一個 repo 的不同分支', + a: ['plugins/tea-sdlc', 'feat/mine/main'], + b: ['plugins/tea-sdlc', 'feat/yours/main'], + }, +]; + +for (const { name, a, b } of DIFFERENT) { + test(`不同的工作包:${name},不得撞名`, () => { + assert.notEqual(derive(...a), derive(...b)); + }); +} + +test('分支名含斜線時路徑仍是單層目錄,不會長出巢狀結構', () => { + const path = derive('plugins/tea-sdlc', 'feat/a/b/c/main'); + + assert.equal(path.startsWith(`${HOME}/worktrees/`), true); + assert.equal(path.slice(`${HOME}/worktrees/`.length).includes('/'), false); +}); + +test('換一台機器只有家目錄會變,目錄名不變', () => { + process.env.TEA_SDLC_HOME = '/somewhere/else/.tea-sdlc'; + const elsewhere = worktreePath('plugins/tea-sdlc', 'feat/mine/main'); + delete process.env.TEA_SDLC_HOME; + + const here = derive('plugins/tea-sdlc', 'feat/mine/main'); + + assert.equal(elsewhere.split('/').at(-1), here.split('/').at(-1)); +}); + +// ── 與 CLI 釘在一起 ─────────────────────────────────────────────── + +test('branch-prep 印出的工作樹路徑就是這個函式算出來的那一條', async (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 })); + + const { json } = await runScript( + 'branch-prep.js', + ['--repo', 'plugins/tea-sdlc', '--path', repo.dir, '--source', 'master', + '--type', 'feat', '--slug', 'mine', '--dry-run'], + { env: { TEA_SDLC_HOME: home } }, + ); + + process.env.TEA_SDLC_HOME = home; + const expected = worktreePath('plugins/tea-sdlc', 'feat/mine/main'); + delete process.env.TEA_SDLC_HOME; + assert.equal(json.data.worktree, expected); +}); From 74b4ca130e4092b38f8e1132eb0907a4c2bbf001 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 15:54:27 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(timer):=20=E7=A2=BC=E9=8C=B6=E7=A7=BB?= =?UTF-8?q?=E5=87=BA=E9=A0=98=E5=8F=96=EF=BC=8C=E7=AD=89=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=A8=B9=E5=BB=BA=E6=88=90=E4=B9=8B=E5=BE=8C=E6=89=8D=E8=B5=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原本的順序是「放行 → 設 assignee 與標籤 → 起錶 → 處理分支」,而工作樹建立 失敗會中止整個領取——錶已經起了才失敗,使用者會被計一段什麼都沒做的時間, 而工時要準正是工時報表的立足點。 claim 只留領取鎖的兩件事(assignee 與標籤),起錶交給新的 timer.js,由流程 正本排在 branch-prep 之後。timer 已經跑在這顆議題上時什麼都不做:中斷後重跑 是它最常見的處境,重新起錶會把已經累積的時間切成兩段;跑在別顆上則照舊擋下, 不代勞停錶。 三支腳本讀碼錶的那段各留一份,趁這次收進 lib。 議題 #40 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/claim.js | 21 +++--- scripts/timer.js | 78 +++++++++++++++++++++++ scripts/wp-extract.js | 11 +--- test/claim.test.js | 24 +++---- test/timer.test.js | 144 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 248 insertions(+), 30 deletions(-) create mode 100644 scripts/timer.js create mode 100644 test/timer.test.js diff --git a/scripts/claim.js b/scripts/claim.js index 8d58f6a..8b36844 100644 --- a/scripts/claim.js +++ b/scripts/claim.js @@ -1,10 +1,13 @@ #!/usr/bin/env node /** - * 領取一顆工作包:上鎖、貼標籤、起錶。 + * 領取一顆工作包:上鎖、貼標籤。 * * 鎖用 assignee 加標籤,不用碼錶——Gitea 只讓人讀自己的碼錶(`/user/stopwatches`), * 看不到別人的錶,拿它當鎖會漏判。碼錶在這裡只有一個用途:發現自己忘了停掉上一顆。 * + * **錶不在這一步起**。它等工作樹建好之後才由 timer.js 起動(見 branch-prep.js): + * 工作樹建立失敗會中止整個領取,錶要是先起了,使用者就被計了一段什麼都沒做的時間。 + * * 四種狀態的處置: * - 他人已認領 → 擋。不會兩個人做同一件事。 * - 自己的錶跑在本議題 → 擋。這顆你已經在做了,別重複起錶。 @@ -24,12 +27,14 @@ import { fetchIssue, giteaRequest, listLabels, + listStopwatches, main, parseFlags, parseIndex, parseRepo, preflight, resolveLogin, + stopwatchOnIssue, } from './lib.js'; /** 領取鎖的另一半。本 plugin 不自動建立標籤,這個名字要在 repo 上先存在。 */ @@ -84,9 +89,6 @@ main(async () => { }); labels.push(IN_PROGRESS); } - // 錶最後才起:前面任一步失敗時,不該留下一顆還在跑的碼錶 - planned.push({ method: 'POST', path: `${issuePath}/stopwatch/start`, body: {} }); - if (dryRun) { return { dryRun: true, repo, index, title: issue.title, requests: planned, 已認領過 }; } @@ -102,7 +104,8 @@ main(async () => { url: issue.html_url, assignee: me, labels, - 碼錶中: true, + // 鎖上好了,錶還沒起:它等工作樹建好之後才由 timer.js 起動 + 碼錶中: false, 已認領過, }; }); @@ -128,14 +131,10 @@ function checkClaimable(assignees, me, index) { * 跑在別的議題是「你忘了停掉那一顆」。 */ async function checkNoStopwatch(login, repo, index) { - const path = '/user/stopwatches'; - const watches = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? []; + const watches = await listStopwatches(login); if (watches.length === 0) return; - const here = watches.find( - (watch) => `${watch.repo_owner_name}/${watch.repo_name}` === repo && watch.issue_index === index, - ); - if (here) { + if (stopwatchOnIssue(watches, repo, index)) { throw new ScriptError( 'STOPWATCH_ON_THIS_ISSUE', `你的碼錶已經跑在議題 #${index} 上,這顆你正在做;` + diff --git a/scripts/timer.js b/scripts/timer.js new file mode 100644 index 0000000..245dded --- /dev/null +++ b/scripts/timer.js @@ -0,0 +1,78 @@ +#!/usr/bin/env node +/** + * 起錶。 + * + * 錶與領取鎖是兩件事:鎖用 assignee 加標籤(見 claim.js),錶只管工時。分開的理由是 + * 時機不同——鎖要在開工之前就上好,錶則要等到**工作樹真的建好之後**才起。工作樹建立 + * 失敗會中止整個領取,錶要是先起了,使用者就被計了一段什麼都沒做的時間, + * 而工時要準正是工時報表的立足點。 + * + * 自己的錶跑在別顆議題上時擋下,不代勞停錶:那一段時間該記在哪顆議題上只有人知道, + * 腳本自作主張會把工時記錯地方。錶已經跑在本議題上則什麼都不做——重新起錶會把已經 + * 累積的時間切成兩段,而中斷後重跑正是這支腳本最常見的處境。 + * + * 用法: + * node scripts/timer.js --repo owner/name --index 40 [--host <網址>] [--dry-run] + */ +import { + ScriptError, + expectOk, + fetchIssue, + giteaRequest, + listStopwatches, + main, + parseFlags, + parseIndex, + parseRepo, + preflight, + resolveLogin, + stopwatchOnIssue, +} from './lib.js'; + +main(async () => { + const flags = parseFlags(process.argv.slice(2), { + required: ['repo', 'index'], + optional: ['host'], + booleans: ['dry-run'], + }); + const repo = parseRepo(flags.repo); + const index = parseIndex(flags.index); + const issuePath = `/repos/${repo}/issues/${index}`; + const dryRun = flags['dry-run'] === true; + + const login = resolveLogin({ host: flags.host }); + // 試跑照樣讀現況:手寫一份固定的清單會跟實作走鐘,也說不出「這顆已經在計時了」 + if (!dryRun) await preflight(login, repo); + const issue = await fetchIssue(login, repo, index); + + const watches = await listStopwatches(login); + const 已在計時 = stopwatchOnIssue(watches, repo, index) !== null; + if (!已在計時 && watches.length > 0) { + const elsewhere = watches[0]; + throw new ScriptError( + 'STOPWATCH_ON_OTHER_ISSUE', + `你的碼錶正跑在 ${elsewhere.repo_owner_name}/${elsewhere.repo_name} 的議題 ` + + `#${elsewhere.issue_index} 上,起錶前請先手動停錶,否則工時會記到那一顆去`, + ); + } + + // 已經在跑就不重起:重新起錶會把已經累積的時間切成兩段 + const planned = 已在計時 ? [] : [{ method: 'POST', path: `${issuePath}/stopwatch/start`, body: {} }]; + + if (dryRun) { + return { dryRun: true, repo, index, title: issue.title, requests: planned, 已在計時 }; + } + + for (const { method, path, body } of planned) { + expectOk(await giteaRequest(login, method, path, { body }), `${method} ${path}`); + } + + return { + repo, + index: issue.number, + title: issue.title, + url: issue.html_url, + 碼錶中: true, + 已在計時, + }; +}); diff --git a/scripts/wp-extract.js b/scripts/wp-extract.js index eb081a8..eb31ee1 100644 --- a/scripts/wp-extract.js +++ b/scripts/wp-extract.js @@ -14,9 +14,8 @@ import { UNMERGED_COMMENT_NOTE, countUnmergedComments, - expectOk, fetchIssue, - giteaRequest, + listStopwatches, main, pages, parseFlags, @@ -24,6 +23,7 @@ import { parseRepo, preflight, resolveLogin, + stopwatchOnIssue, } from './lib.js'; import { checklistInSection, @@ -118,10 +118,5 @@ async function fetchLinked(login, path, kind) { * ——領取鎖看的是 assignee。 */ async function hasRunningStopwatch(login, repo, index) { - const path = '/user/stopwatches'; - const watches = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? []; - - return watches.some( - (watch) => `${watch.repo_owner_name}/${watch.repo_name}` === repo && watch.issue_index === index, - ); + return stopwatchOnIssue(await listStopwatches(login), repo, index) !== null; } diff --git a/test/claim.test.js b/test/claim.test.js index af8cf33..b3445ae 100644 --- a/test/claim.test.js +++ b/test/claim.test.js @@ -1,6 +1,10 @@ /** * 領取工作包的鎖。 * + * 錶不在這一支起——它等工作樹建好之後才由 timer.js 起動,所以這裡連帶要驗 + * 「一發起錶請求都沒有」:領取失敗或工作樹建不起來時,使用者不該被計一段 + * 什麼都沒做的時間。 + * * 這一支的價值全在「什麼時候擋下來」:放行的路徑只有一條,擋的理由有四種, * 而擋錯的代價是兩個人做同一件事、或是工時記到別顆議題上。所以決策表的四種狀態 * 各有測試,而且每一種都要驗「一個字都沒寫進 Gitea」——擋下來卻已經改了一半, @@ -68,7 +72,7 @@ const writes = (stub) => // ── 決策表:無鎖 ─────────────────────────────────────────────────── -test('沒有鎖時放行:設 assignee、貼進行中、起錶', async (t) => { +test('沒有鎖時放行:設 assignee、貼進行中', async (t) => { const stub = await withStub(t, {}, { repoLabels: ['ready-for-agent', '進行中'] }); const { code, json } = await run([], stub); @@ -76,11 +80,11 @@ test('沒有鎖時放行:設 assignee、貼進行中、起錶', async (t) => { assert.equal(code, 0); assert.equal(json.data.assignee, ME); assert.deepEqual(json.data.labels, ['進行中']); - assert.equal(json.data.碼錶中, true); + assert.equal(json.data.碼錶中, false, '鎖上好了,錶還沒起'); assert.equal(json.data.已認領過, false); }); -test('放行時三個寫入請求都發出,且順序為先上鎖再起錶', async (t) => { +test('放行時只寫入鎖的那兩件事,一發起錶請求都沒有', async (t) => { const stub = await withStub(t, {}, { repoLabels: ['進行中'] }); await run([], stub); @@ -90,9 +94,8 @@ test('放行時三個寫入請求都發出,且順序為先上鎖再起錶', as [ `PATCH /api/v1/repos/${REPO}/issues/${INDEX}`, `POST /api/v1/repos/${REPO}/issues/${INDEX}/labels`, - `POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/start`, ], - '錶要最後才起:前面任一步失敗時,不該留下一顆還在跑的碼錶', + '錶等工作樹建好之後才由 timer.js 起動:建不起來就中止,不該已經計了時間', ); }); @@ -180,7 +183,7 @@ test('自己已認領但沒有錶時放行,並如實說這顆本來就是自 assert.equal(code, 0); assert.equal(json.data.已認領過, true); - assert.equal(json.data.碼錶中, true, '錶還是要起,中斷重跑就是為了接上這件事'); + assert.equal(json.data.碼錶中, false, '重跑時鎖照樣補齊,錶則仍舊留到工作樹建好之後'); }); test('進行中標籤已經在議題上時不重複貼', async (t) => { @@ -251,7 +254,6 @@ test('--dry-run 印出將發出的寫入,但一個字都不寫進去', async ( [ `PATCH /repos/${REPO}/issues/${INDEX}`, `POST /repos/${REPO}/issues/${INDEX}/labels`, - `POST /repos/${REPO}/issues/${INDEX}/stopwatch/start`, ], ); assert.deepEqual(writes(stub), [], '預覽不得真的寫入'); @@ -270,7 +272,7 @@ test('--dry-run 會先讀現況:預覽出來的是這一顆實際的處境', a assert.ok(reads.includes(`/api/v1/repos/${REPO}/labels`)); }); -test('--dry-run 略過已經做好的部分,不謊報將發出的請求', async (t) => { +test('--dry-run 略過已經做好的部分:鎖都在了就什麼都不必寫', async (t) => { const stub = await withStub(t, {}, { assignees: [ME], labels: ['進行中'], @@ -280,9 +282,9 @@ test('--dry-run 略過已經做好的部分,不謊報將發出的請求', asyn const { json } = await run(['--dry-run'], stub); assert.deepEqual( - json.data.requests.map((r) => `${r.method} ${r.path}`), - [`POST /repos/${REPO}/issues/${INDEX}/stopwatch/start`], - 'assignee 與標籤都已經到位,只差起錶', + json.data.requests, + [], + 'assignee 與標籤都已經到位,中斷重跑就是走到這裡;手寫一份固定的清單會謊報', ); }); diff --git a/test/timer.test.js b/test/timer.test.js new file mode 100644 index 0000000..ffd7930 --- /dev/null +++ b/test/timer.test.js @@ -0,0 +1,144 @@ +/** + * 起錶。 + * + * 錶是工時報表的唯一來源,所以這一支的價值全在「什麼時候不該起」:工作樹還沒建好 + * 不該起(那由流程的順序保證),自己的錶已經跑在別顆議題上更不該起——那會把兩顆 + * 工作包的時間攪在一起。停錶一律由使用者自己來,這裡不提供。 + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { runScript } from './helpers/run-script.js'; +import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js'; + +const REPO = 'plugins/tea-sdlc'; +const INDEX = 40; + +/** 議題上跑著的碼錶長什麼樣 */ +const stopwatchOn = (index, repo = REPO) => ({ + issue_index: index, + repo_owner_name: repo.split('/')[0], + repo_name: repo.split('/')[1], +}); + +function routes(overrides = {}, { stopwatches = [] } = {}) { + return healthyRoutes(REPO, { + [`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: { + status: 200, + body: { + number: INDEX, + title: '以 worktree 建立工作包分支並備妥隔離環境', + html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/${INDEX}`, + }, + }, + 'GET /api/v1/user/stopwatches': { status: 200, body: stopwatches }, + [`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/start`]: { status: 201, body: {} }, + ...overrides, + }); +} + +const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options)); + +const run = (args, stub) => + runScript('timer.js', ['--repo', REPO, '--index', String(INDEX), ...args], { env: envFor(stub) }); + +/** 會改動 Gitea 的請求;前置檢查打在 issues/0 的探針不算(見 lib 的 checkIssueWrite) */ +const writes = (stub) => + stub.requests.filter((r) => r.method !== 'GET').filter((r) => !r.path.endsWith('/issues/0')); + +test('沒有錶在跑時起錶,並回報起在哪一顆上', async (t) => { + const stub = await withStub(t); + + const { code, json } = await run([], stub); + + assert.equal(code, 0, json.error?.message); + assert.equal(json.data.碼錶中, true); + assert.equal(json.data.index, INDEX); + assert.deepEqual( + writes(stub).map((r) => `${r.method} ${r.path}`), + [`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/start`], + ); +}); + +test('錶已經跑在這顆議題上時什麼都不做,重跑不會把計時打斷', async (t) => { + const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(INDEX)] }); + + const { code, json } = await run([], stub); + + assert.equal(code, 0, json.error?.message); + assert.equal(json.data.碼錶中, true); + assert.equal(json.data.已在計時, true, '要如實說這顆本來就在計時,不要假裝是這次起的'); + assert.deepEqual(writes(stub), [], '重新起錶會把已經累積的時間切成兩段'); +}); + +test('錶跑在別顆議題上時擋下,並指出是哪一顆', async (t) => { + const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(7)] }); + + const { code, json } = await run([], stub); + + assert.equal(code, 1); + assert.equal(json.error.code, 'STOPWATCH_ON_OTHER_ISSUE'); + assert.match(json.error.message, /#7/, '忘了停掉的是哪一顆,要指名'); + assert.deepEqual(writes(stub), [], '擋下來就不該寫進任何東西'); +}); + +test('別的 repo 上的同號碼錶也算自己有錶在跑', async (t) => { + const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(INDEX, 'plugins/別的專案')] }); + + const { json } = await run([], stub); + + assert.equal(json.error.code, 'STOPWATCH_ON_OTHER_ISSUE'); + assert.match(json.error.message, /別的專案/); +}); + +test('不代替使用者停錶:訊息要說清楚下一步是他自己去停', async (t) => { + const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(7)] }); + + const { json } = await run([], stub); + + assert.match(json.error.message, /停/); +}); + +test('議題不存在時回可區分的錯誤碼', async (t) => { + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 404, body: { message: 'not found' } }, + }); + + const { json } = await run([], stub); + + assert.equal(json.error.code, 'ISSUE_NOT_FOUND'); + assert.deepEqual(writes(stub), []); +}); + +test('--index 不是正整數時擋在打 Gitea 之前', async (t) => { + const stub = await withStub(t); + + const { json } = await runScript('timer.js', ['--repo', REPO, '--index', '0'], { + env: envFor(stub), + }); + + assert.equal(json.error.code, 'BAD_INDEX'); + assert.equal(stub.requests.length, 0); +}); + +test('--dry-run 印出將發出的寫入,但一個字都不寫進去', async (t) => { + const stub = await withStub(t); + + const { code, json } = await run(['--dry-run'], stub); + + assert.equal(code, 0); + assert.equal(json.data.dryRun, true); + assert.deepEqual( + json.data.requests.map((r) => `${r.method} ${r.path}`), + [`POST /repos/${REPO}/issues/${INDEX}/stopwatch/start`], + ); + assert.deepEqual(writes(stub), [], '預覽不得真的寫入'); +}); + +test('--dry-run 會先讀現況:已經在計時時預覽出來就是什麼都不做', async (t) => { + const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(INDEX)] }); + + const { json } = await run(['--dry-run'], stub); + + assert.deepEqual(json.data.requests, [], '手寫一份固定的清單會跟實作走鐘'); + assert.equal(json.data.已在計時, true); +}); From 4583a5f2100cc2b7109596d58fa12d2008a4cc33 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 15:54:27 +0800 Subject: [PATCH 3/5] =?UTF-8?q?docs(sdlc-feat):=20=E7=AC=AC=E4=B8=80?= =?UTF-8?q?=E6=AE=B5=E6=94=B9=E6=88=90=E9=A0=98=E5=8F=96=E3=80=81=E5=82=99?= =?UTF-8?q?=E5=A6=A5=E5=B7=A5=E4=BD=9C=E6=A8=B9=E3=80=81=E6=9C=80=E5=BE=8C?= =?UTF-8?q?=E8=B5=B7=E9=8C=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 正本跟著實作走:分支那一步改成工作樹,新增起錶那一步排在它後面, 並把三處「不要自己決定」寫明——來源分支不存在時不自己換一支、路徑被佔住時 不自己刪、工作樹建不起來時不退回原地切分支。工作區不乾淨的處置整段拿掉: 工作樹本來就是為了讓未提交的變更不再擋路。 AGENTS.md 補上兩條邊界。git worktree add 一定會在目標 repo 的 .git/worktrees/ 底下寫中繼資料,這是 git 的機制,無法避免——「不改目標專案」指的是專案的內容檔, 把這件事明說,免得下一個人以為實作違規。 議題 #40 Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 6 +++ prompts/sdlc-feat.md | 91 ++++++++++++++++++++++++++--------- test/sdlc-feat-assets.test.js | 59 ++++++++++++++++++----- 3 files changed, 120 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e819d79..02aefc4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,4 +30,10 @@ - **契約以議題為正本**:腳本的 flag 介面、JSON 輸出形狀、前置檢查與路徑定位規則,正本在[議題 #1](https://gitea.jsc.idv.tw/plugins/tea-sdlc/issues/1),實作時以該處為準;本檔不複寫,以免兩邊走鐘。 - **測試**:`npm test`(等同 `node --test`)。測試產生的暫存一律寫到 `.tmp/`,該目錄已被 git 忽略,也不會被測試探索掃到。 - **不改目標專案**:本 plugin 只讀目標專案的程式碼,不寫入目標專案的 `CLAUDE.md` 或任何設定檔。 + 唯一的例外是 git 自己的內部中繼資料——`git worktree add` 一定會在目標 repo 的 + `.git/worktrees/` 底下寫東西,那是 git 的機制,無法避免,也不是專案的內容檔。 +- **工作樹集中在家目錄**:每顆工作包的工作樹開在 `~/.tea-sdlc/worktrees/{hash}`, + 路徑由 `owner/repo/分支名` 純函式推導(`scripts/lib.js` 的 `worktreePath`), + 不查表也不寫狀態檔。不開在目標專案裡(會出現在它的 `git status`), + 也不開在它的兄弟目錄(那個目錄結構屬於使用者)。 - **不自動觸發**:所有指令僅由使用者明確叫用;skill/command 的 `description` 統一以「僅由 /sdlc-xxx 指令叫用。」起頭。 diff --git a/prompts/sdlc-feat.md b/prompts/sdlc-feat.md index d29a7ae..f715a1f 100644 --- a/prompts/sdlc-feat.md +++ b/prompts/sdlc-feat.md @@ -5,7 +5,7 @@ description: 僅由 /sdlc-feat 指令叫用。領取一顆工作包、起錶、 拿一顆工作包,從領取到開出 PR。 -第一段**領取與開工準備**:把工作包安全地認領下來,開始計時,備妥開工的分支。 +第一段**領取與開工準備**:把工作包安全地認領下來,備妥一棵屬於它的工作樹,然後開始計時。 這一段不改任何一行程式碼——它只負責讓後面的實作有個乾淨的起點。 第二段**逐項實作**:一項一項把待辦做完並即時勾選,讓議題頁的進度條隨時反映真實狀態。 @@ -45,8 +45,9 @@ node scripts/wp-extract.js --repo --index <編號> node scripts/claim.js --repo --index <編號> --dry-run ``` -確認無誤後拿掉旗標再跑一次。放行時它會設 assignee、貼「進行中」標籤、起錶——三件事 -一起構成領取鎖,錶則是工時的來源。 +確認無誤後拿掉旗標再跑一次。放行時它會設 assignee、貼「進行中」標籤——這兩件事一起 +構成領取鎖。**錶不在這一步起**:它等工作樹建好之後才起(第 6 步)。工作樹建立失敗會 +中止整個領取,錶要是先起了,使用者就被計了一段什麼都沒做的時間。 領取鎖有四種狀態,三種擋、一種放行。被擋下來時**不要繞過去**,照著錯誤碼告訴使用者 發生什麼事、下一步是什麼: @@ -72,6 +73,8 @@ node scripts/claim.js --repo --index <編號> --dry-run 但若這顆工作包明顯是某個既有功能分支的一部分,就建議那一支,並說明為什麼。 - **手動輸入** — 讓使用者自己填分支名。 +不論哪一種,來源分支都必須**已經在遠端上**:工作樹的起點一律取自 `origin/{來源分支}`。 + ### 4. 把議題標題翻成英文 分支名的中段要用英文,中文會讓 CI 與 URL 出問題。把工作包議題的標題翻成 @@ -79,41 +82,71 @@ node scripts/claim.js --repo --index <編號> --dry-run 翻譯要保留原意而不是逐字直譯,寧可用一個更短的說法,也不要把長句截斷成看不懂的字串。 -### 5. 備妥分支 +### 5. 備妥工作樹 -分支開在**工作包的 `repos` 列出的那些 repo** 上,不是開在本 plugin 的目錄裡。 +工作樹開在**工作包的 `repos` 列出的那些 repo** 上,不是開在本 plugin 的目錄裡。 `repos` 只有一顆就用那一顆;**有多顆時逐一確認**要在哪幾個開分支, 再對每一個各跑一次 `branch-prep`,分支名在每個 repo 都相同。 ``` -node scripts/branch-prep.js --path <目標專案路徑> --source <來源分支> \ - --slug <英文-kebab> [--type feat] --dry-run +node scripts/branch-prep.js --repo --path <目標專案路徑> \ + --source <來源分支> --slug <英文-kebab> [--type feat] --dry-run ``` `--type` 只在來源是開發分支時要給(`feat`/`fix`/`chore`…);從功能分支長出時, 類型與需求描述沿用來源,不必也不能再指定。 -試跑會印出將執行的 git 指令與算出來的分支名。確認無誤後拿掉旗標再跑一次。 +試跑會印出將執行的 git 指令、算出來的分支名與工作樹路徑。確認無誤後拿掉旗標再跑一次。 -它保證三件事,都是為了不弄丟別人的東西:工作區不乾淨時**先擋下來**,免得把不相干的 -改動帶進這顆工作包的分支;來源分支在遠端已存在時是 **pull 而不是重建**;目標分支已經 -存在時是**接上去而不是蓋掉**。 +**不在原地切換分支,一律開一棵獨立的工作樹。** 每顆工作包有自己的目錄、自己的建置 +產物、自己的未提交變更,彼此看不見對方。這件事對 agent 特別重要:它是非同步的, +可能在分支已經被切走之後才去讀檔,而它**不會察覺**自己讀到的是別顆工作包的內容—— +產出看起來完全合理,只是接錯了上下文。 -工作區不乾淨(`DIRTY_WORKTREE`)時,把 git 回報的檔案念給使用者聽,讓他決定要提交、 -`git stash` 還是丟掉——**不要自己選**。 +**工作樹一律建立,沒有例外。** 建不起來就照實中止,**不要改成在原地切分支**: +使用者會以為自己在隔離環境裡,其實在原地改。 -### 6. 回報 +工作樹路徑由 `owner/repo/分支名` 推導而得,印在輸出的 `worktree` 欄位。 +**後面幾段的實作、測試與提交都在那棵工作樹裡做**,不要回到主工作區動手。 + +它保證三件事: + +- **起點一律是遠端的來源分支**(`origin/{來源分支}`),不是本機同名分支——後者可能 + 落後好幾天。遠端沒有那一支時得到 `SOURCE_NOT_FOUND`,把訊息念給使用者,讓他決定 + 是先把來源分支推上去,還是改指定一個別的來源——**不要自己換一個**。 +- **目標分支已經存在時接上去而不是蓋掉**;工作樹已經在了就沿用,不動裡面還沒提交的東西。 +- **失敗時不留半成品**:不會出現有分支沒工作樹、或有工作樹沒分支的狀態。 + +推導出的路徑被別的東西佔住時(`WORKTREE_PATH_TAKEN`,多半是別的 clone 留下的), +把路徑念給使用者,請他確認裡面沒有還沒保存的東西再移除——**不要自己刪**。 + +工作樹是乾淨的:**沒有安裝依賴,也沒有任何建置產物**,`.env` 這類機密檔案更不會被 +複製過去。把輸出的 `提示.安裝指令` 念給使用者,機密檔案請他自己放一份。 + +### 6. 起錶 + +工作樹建好之後才起錶: + +``` +node scripts/timer.js --repo --index <編號> --dry-run +``` + +確認無誤後拿掉旗標再跑一次。錶已經跑在這顆議題上時它什麼都不做——那正是中斷後重跑 +的情形,重新起錶會把已經累積的時間切成兩段。 + +### 7. 回報 印出一份開工前的現況,不寫回議題: - 工作包標題與網址、這一顆有幾項待辦 - 認領結果(是否本來就是自己的)、碼錶已起 - 來源分支、新分支名、分支是新建還是接上既有 +- 工作樹路徑,以及它是乾淨的、要先跑哪一行安裝指令 - 未處理留言數與未關閉的先決議題(若有) ## 第二段:逐項實作 -### 7. 認出語言,讀規則正本 +### 8. 認出語言,讀規則正本 改任何一個檔案之前,先依專案檔認出這是什麼語言,再讀兩份規則正本: @@ -127,7 +160,10 @@ node scripts/branch-prep.js --path <目標專案路徑> --source <來源分支> 屬性的資料範例**優先從 MCP 取得**;取不到就以邏輯推理,並照 `comment-styles.md` 的寫法 在註解裡註明「由邏輯推理、未經驗證」。這句註明不能省,否則後面的人會照著沒對過的格式寫解析。 -### 8. 一項一項做 +### 9. 一項一項做 + +**改的是工作樹裡的檔案**,路徑就是 `branch-prep` 印出來的 `worktree`,不是主工作區—— +主工作區可能停在別的分支上,在那裡動手會把改動落到別顆工作包的分支去。 依 `wp-extract` 給的 `待辦` 順序做。每一項的做法: @@ -141,7 +177,7 @@ node scripts/branch-prep.js --path <目標專案路徑> --source <來源分支> 真正需要停下來問的只有三種:語言認不出來、待辦的意思有歧義、做下去會超出工作包的 `範圍邊界`。除此之外一路做完。 -### 9. 做完一項就勾一項 +### 10. 做完一項就勾一項 ``` node scripts/issue-update.js --repo --index <編號> \ @@ -166,7 +202,7 @@ node scripts/issue-update.js --repo --index <編號> \ **不要為了勾選在議題上留留言。** 勾選改的是 body,進度條自己會動;逐項留言會把議題洗版, reviewer 得從一堆「已完成第 N 項」裡找真正的討論。 -### 10. 中斷後重跑 +### 11. 中斷後重跑 進度完全由 Gitea 上的勾選狀態推導,**不看任何本機檔案**。重跑這一段時: @@ -175,7 +211,7 @@ reviewer 得從一堆「已完成第 N 項」裡找真正的討論。 3. 已經勾過的項目再 `--tick` 一次是安靜的 no-op(回傳 `已經勾過: true`,不發 PATCH), 所以不確定某一項有沒有勾到時,直接再勾一次即可,不必先查。 -### 11. 回報 +### 12. 回報 全部待辦完成後印一份小結,不寫回議題: @@ -187,15 +223,17 @@ reviewer 得從一堆「已完成第 N 項」裡找真正的討論。 ## 第三段:提交與開立 PR -### 12. 分批提交 +### 13. 分批提交 全部待辦都勾完之後才進這一段。變更依類型分批: ``` -node scripts/commit-split.js --path <目標專案路徑> --type feat \ +node scripts/commit-split.js --path <工作樹路徑> --type feat \ --subject '<繁中描述>' [--scope <功能名>] --dry-run ``` +`--path` 給的是第一段建出來的那棵**工作樹**——commit 要落在它的分支上。 + `--type` 是**這次程式碼變更**的類型(`feat`/`fix`/`refactor`…);測試、文件與設定檔 由腳本自己認出來,各自成批,不必也不能指定。`--body` 寫「為什麼這樣做」,那一段會接在 每一顆 commit 的首行之後——本 repo 的歷史靠它讀得懂。 @@ -217,7 +255,7 @@ node scripts/commit-split.js ... --files scripts/claim.js,test/claim.test.js 一顆 commit 的描述只說得清楚一件事,硬湊在一起就失去了分批的意義。 -### 13. 寫 PR 描述 +### 14. 寫 PR 描述 固定八個段落,順序不能換——reviewer 每次都在同一個位置找到要找的資訊: @@ -237,7 +275,7 @@ node scripts/commit-split.js ... --files scripts/claim.js,test/claim.test.js `pr-create` 會擋下缺段落、順序不對、以及測試結果只有空話的描述。被擋下來時**補真的內容**, 不要為了通過而拼湊。 -### 14. 開 PR 並停錶 +### 15. 開 PR 並停錶 ``` node scripts/pr-create.js --repo <目標專案 owner/name> --head <分支名> \ @@ -258,7 +296,7 @@ repo**(錶停在那裡)。兩者常常不是同一個——議題在需求 重跑不會開出第二顆 PR:同一個 head 已經有開著的 PR 就回傳它(`created` 為 `false`), 然後照樣停錶——那一步可能正是上次中斷的地方。 -### 15. 回報 +### 16. 回報 - PR 的網址與編號、標題(等同分支名),以及它是這次新開的還是接上既有的 - 建立了哪幾顆 commit @@ -270,6 +308,11 @@ repo**(錶停在那裡)。兩者常常不是同一個——議題在需求 - 第一段**不改任何一行程式碼**、不勾待辦、不提交、不開 PR——那些是後面幾段的事。 - 第二段只實作與勾選。**不提交、不開 PR、不停錶**——那是第三段的事。 - 第三段不改任何一行程式碼。到這裡實作已經結束,要改就回第二段改完再來。 +- **不在主工作區動手。** 第二段與第三段的每一個動作都在 `branch-prep` 建出來的那棵 + 工作樹裡進行,包含跑測試與 `--path`。 +- 不把依賴、建置產物或 `.env` 這類機密檔案複製到工作樹裡,也不做連結—— + 兩棵工作樹共用同一份依賴,正好把工作樹要隔離的東西又接回去。 +- 工作樹建不起來時中止,**不退回原地切分支**。 - 不把「已測試通過」這種空話寫進 PR 描述,也不為了通過檢查而拼湊內容。 - 不代替使用者決定 commit 的類型與描述;`--type` 與 `--subject` 都要是這次真的做了什麼。 - 不把實作規範或註解格式寫進目標專案的任何檔案。 diff --git a/test/sdlc-feat-assets.test.js b/test/sdlc-feat-assets.test.js index b21771a..8c4a6da 100644 --- a/test/sdlc-feat-assets.test.js +++ b/test/sdlc-feat-assets.test.js @@ -18,11 +18,37 @@ test('正本平台中立,description 前綴正確', () => { assertNeutralPrompt(prompt, 'sdlc-feat'); }); -test('第一段指名三支腳本,順序為先讀再領再備分支', () => { - const order = ['wp-extract.js', 'claim.js', 'branch-prep.js']; +test('第一段指名四支腳本,順序為先讀再領、備妥工作樹、最後起錶', () => { + const order = ['wp-extract.js', 'claim.js', 'branch-prep.js', 'timer.js']; const positions = order.map((name) => phase1.indexOf(name)); - assert.equal(positions.every((p) => p >= 0), true, '三支腳本都要被指名'); - assert.deepEqual([...positions].sort((a, b) => a - b), positions, '領取之前要先讀得懂這顆在做什麼'); + assert.equal(positions.every((p) => p >= 0), true, '四支腳本都要被指名'); + assert.deepEqual( + [...positions].sort((a, b) => a - b), + positions, + '領取之前要先讀得懂這顆在做什麼;錶則要等工作樹建好之後才起', + ); +}); + +test('錶等工作樹建好之後才起,並說明為什麼', () => { + assert.match(phase1, /錶不在這一步起/, '領取那一步要明講錶還沒起'); + assert.match(phase1, /什麼都沒做的時間/, '要說明為什麼後移:失敗的領取不該留下憑空的工時'); +}); + +test('工作樹一律建立,建不起來就中止而不是退回原地切分支', () => { + assert.match(phase1, /一律建立,沒有例外/); + assert.match(phase1, /不要改成在原地切分支/); + assert.match(phase1, /以為自己在隔離環境裡/, '要說明靜默降級的後果'); +}); + +test('實作要在工作樹裡做,路徑從輸出取得', () => { + assert.match(phase1, /worktree/, '工作樹路徑印在哪個欄位要講'); + assert.match(phase1, /都在那棵工作樹裡做/); +}); + +test('工作樹是乾淨的,且機密檔案不會被複製過去', () => { + assert.match(phase1, /沒有安裝依賴/); + assert.match(phase1, /安裝指令/); + assert.match(phase1, /\.env/); }); test('未處理留言不是 0 時要先停下來提示整併', () => { @@ -50,9 +76,11 @@ test('工作包跨多個 repo 時怎麼開分支,有交代', () => { assert.match(phase1, /有多顆時逐一確認/); }); -test('工作區不乾淨時的處置寫明了,且不替使用者決定', () => { - assert.match(phase1, /DIRTY_WORKTREE/); - assert.match(phase1, /不要自己選/); +test('兩種擋下來的情境各自寫明處置,且都不替使用者決定', () => { + assert.match(phase1, /SOURCE_NOT_FOUND/); + assert.match(phase1, /不要自己換一個/, '來源分支是使用者的決定,不要自己改指定別支'); + assert.match(phase1, /WORKTREE_PATH_TAKEN/); + assert.match(phase1, /不要自己刪/, '路徑上的東西可能還沒保存,不該由 agent 決定刪掉'); }); test('碼錶只由使用者自己停,並說明為什麼不代勞', () => { @@ -79,13 +107,13 @@ test('--type 什麼時候要給、什麼時候不能給,寫清楚了', () => { }); test('兩處「不覆蓋他人進度」的保證都有寫出來', () => { - assert.match(phase1, /pull 而不是重建/); + assert.match(phase1, /起點一律是遠端的來源分支/, '根本不碰本機分支,就沒有覆蓋的可能'); assert.match(phase1, /接上去而不是蓋掉/); }); -test('三支腳本的寫入都要求先試跑', () => { +test('三支寫入型腳本都要求先試跑', () => { const dryRuns = phase1.match(/--dry-run/g) ?? []; - assert.ok(dryRuns.length >= 2, `兩支寫入型腳本各要先試跑,只找到 ${dryRuns.length} 處`); + assert.ok(dryRuns.length >= 3, `三支寫入型腳本各要先試跑,只找到 ${dryRuns.length} 處`); }); test('邊界把第一段不做的事分開列,且明講不寫本機狀態檔', () => { @@ -94,17 +122,24 @@ test('邊界把第一段不做的事分開列,且明講不寫本機狀態檔', assert.match(boundary, /不勾待辦/); assert.match(boundary, /不開 PR/); assert.match(boundary, /不自行建立標籤/); + assert.match(boundary, /不在主工作區動手/); + assert.match(boundary, /不退回原地切分支/); assert.match(boundary, /不寫任何本機狀態檔/); assert.match(boundary, /換一台機器或換一個 agent/, '要說明為什麼不留狀態檔'); }); // ── 第二段:逐項實作 ─────────────────────────────────────────────── +test('第二段明講改的是工作樹裡的檔案,不是主工作區', () => { + assert.match(phase2, /改的是工作樹裡的檔案/); + assert.match(phase2, /落到別顆工作包的分支/, '要說明在主工作區動手的後果'); +}); + test('第二段指名兩份規則正本,且在改檔之前就要讀', () => { assert.match(phase2, /references\/coding-standards\.md/); assert.match(phase2, /references\/comment-styles\.md/); const readAt = phase2.indexOf('coding-standards.md'); - const implementAt = phase2.indexOf('### 8.'); + const implementAt = phase2.indexOf('### 9.'); assert.ok(readAt < implementAt, '讀規則要排在動手實作之前'); }); @@ -128,7 +163,7 @@ test('資料範例優先取自 MCP,取不到要註明未經驗證', () => { }); test('回報要點出哪些範例是推理來的', () => { - const report = phase2.slice(phase2.indexOf('### 11.')); + const report = phase2.slice(phase2.indexOf('### 12.')); assert.match(report, /哪些資料範例是推理來的/); }); From 0154cf59d4e9fdab6a4b070723e66aa7a70cae6d Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 15:59:48 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix(claim):=20=E8=A2=AB=E7=A2=BC=E9=8C=B6?= =?UTF-8?q?=E6=93=8B=E4=B8=8B=E6=99=82=E6=98=8E=E8=AA=AA=E5=81=9C=E9=8C=B6?= =?UTF-8?q?=E4=B8=8D=E6=9C=83=E5=8B=95=E5=88=B0=E6=97=A2=E6=9C=89=E7=9A=84?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=A8=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 議題 #38 的使用者故事第 30 條:使用者常以為停錶等於放棄那顆工作包,於是寧可 不停——工時就記到別顆議題去了。碼錶只管時間、工作樹只管檔案,兩者互不相干, 這件事要在擋下來的當下就講,不能指望使用者自己推論。 領取與起錶會撞到同一個擋路理由,訊息收進 lib 只寫一份。順手收掉 review 指出的 三處:planWorktree 沒用到的 repo 參數、與 path.resolve 同名而誤導的區域函式、 以及只有 lib 自己用得到卻對外 export 的兩支路徑函式。 回滾補上最後一道:git 清不掉時把目錄本身也刪掉。那條路徑在這次執行之前不存在 (不存在正是建立的前提),裡面不可能有使用者的東西,而留著它下一次重跑會直接 撞上 WORKTREE_PATH_TAKEN——一次失敗的建立不該讓人從此開不了工。 議題 #40 Co-Authored-By: Claude Opus 5 (1M context) --- prompts/sdlc-feat.md | 3 +++ scripts/branch-prep.js | 19 +++++++++++++------ scripts/claim.js | 11 ++++------- scripts/lib.js | 32 +++++++++++++++++++++++--------- scripts/timer.js | 11 ++--------- test/claim.test.js | 6 ++++++ test/lib-exports.test.js | 9 ++++----- test/sdlc-feat-assets.test.js | 5 +++++ test/timer.test.js | 5 +++++ 9 files changed, 65 insertions(+), 36 deletions(-) diff --git a/prompts/sdlc-feat.md b/prompts/sdlc-feat.md index f715a1f..797f453 100644 --- a/prompts/sdlc-feat.md +++ b/prompts/sdlc-feat.md @@ -57,6 +57,9 @@ node scripts/claim.js --repo --index <編號> --dry-run | 別人已經認領這顆 | `CLAIMED_BY_OTHER` | 改領別顆,或先跟對方確認 | | 你的錶已經跑在這顆上 | `STOPWATCH_ON_THIS_ISSUE` | 這顆你正在做;要重新計時請先手動停錶 | | 你的錶跑在別的議題上 | `STOPWATCH_ON_OTHER_ISSUE` | 多半是忘了停上一顆;先去停掉再回來 | + +被錶擋下來時**要順帶說明停錶不會動到既有的工作樹**:碼錶只管時間、工作樹只管檔案。 +不講清楚,使用者會以為停錶等於放棄那顆工作包,於是寧可不停——工時就記到別顆去了。 | 沒有鎖 | —— | 放行。自己已認領但沒起錶也算沒有鎖,那正是中斷後重跑的情形 | 碼錶一律由使用者自己停。哪一段時間該記在哪顆議題上只有他知道,代勞會把工時記錯地方。 diff --git a/scripts/branch-prep.js b/scripts/branch-prep.js index c9370db..f6d100b 100644 --- a/scripts/branch-prep.js +++ b/scripts/branch-prep.js @@ -38,7 +38,7 @@ * node scripts/branch-prep.js --repo --source <來源分支> --slug <英文-kebab> * [--type feat] [--path <目標專案>] [--dry-run] */ -import { existsSync, realpathSync } from 'node:fs'; +import { existsSync, realpathSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { ScriptError, main, openGitRepo, parseFlags, parseRepo, worktreePath } from './lib.js'; @@ -51,7 +51,7 @@ const FEATURE_BRANCH = /^([a-z]+)\/([^/]+)\/([^/]+)$/; /** * 專案檔 → 把依賴裝起來的指令。 * 工作樹是乾淨的,這份對照表只用來提示使用者該跑什麼,腳本自己不執行安裝—— - * 在別人的機器上裝東西應該是他自己的決定。 + * 在別人的機器上裝東西應該是他自己的決定。偵測不到就不提,猜錯的指令比沒有更浪費時間。 */ const INSTALL_HINTS = [ ['package.json', 'npm install'], @@ -86,7 +86,7 @@ main(async () => { ); } - const plan = planWorktree(git, { repo, source, branch, worktree }); + const plan = planWorktree(git, { source, branch, worktree }); const 報告 = { path, repo, source, branch, worktree, 分支: { 動作: plan.動作 } }; if (flags['dry-run']) { @@ -178,7 +178,7 @@ function isKebab(value) { * * @returns {{commands: string[][], 動作: string}} commands 為空代表工作樹已經在了 */ -function planWorktree(git, { repo, source, branch, worktree }) { +function planWorktree(git, { source, branch, worktree }) { const 既有 = listWorktrees(git).find((entry) => samePath(entry.path, worktree)); const 目錄還在 = existsSync(worktree); @@ -248,11 +248,11 @@ function listWorktrees(git) { * (家目錄本身就常是一條連結),逐字比對會把同一棵工作樹判成兩棵。 */ function samePath(a, b) { - return a === b || resolve(a) === resolve(b); + return a === b || realOrSelf(a) === realOrSelf(b); } /** 解析得出真實路徑就用它,路徑還不存在時退回原字串 */ -function resolve(path) { +function realOrSelf(path) { try { return realpathSync(path); } catch { @@ -286,6 +286,13 @@ 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:失敗了也不能蓋掉真正的錯誤訊息,那才是使用者要看的東西。 */ diff --git a/scripts/claim.js b/scripts/claim.js index 8b36844..9bc6632 100644 --- a/scripts/claim.js +++ b/scripts/claim.js @@ -34,6 +34,7 @@ import { parseRepo, preflight, resolveLogin, + stopwatchElsewhere, stopwatchOnIssue, } from './lib.js'; @@ -138,14 +139,10 @@ async function checkNoStopwatch(login, repo, index) { throw new ScriptError( 'STOPWATCH_ON_THIS_ISSUE', `你的碼錶已經跑在議題 #${index} 上,這顆你正在做;` + - '若要重新計時,請先在 Gitea 上手動停錶再執行一次', + '若要重新計時,請先在 Gitea 上手動停錶再執行一次——' + + '停錶只停計時,不會動到你既有的工作樹', ); } - const elsewhere = watches[0]; - throw new ScriptError( - 'STOPWATCH_ON_OTHER_ISSUE', - `你的碼錶正跑在 ${elsewhere.repo_owner_name}/${elsewhere.repo_name} 的議題 ` + - `#${elsewhere.issue_index} 上,領取前請先手動停錶,否則工時會記到那一顆去`, - ); + throw stopwatchElsewhere(watches[0], '領取'); } diff --git a/scripts/lib.js b/scripts/lib.js index e670526..914c4d0 100644 --- a/scripts/lib.js +++ b/scripts/lib.js @@ -53,16 +53,13 @@ export function promptsDir() { } /** - * tea-sdlc 在使用者家目錄底下的家。工作樹集中放在這裡,清理時只有一個地方要看。 - * `TEA_SDLC_HOME` 只是測試與 CI 的覆寫出口,正常使用不必設。 + * 所有工作樹的集中處:`~/.tea-sdlc/worktrees`。 + * 集中在一個地方,清理時只有一處要看。`TEA_SDLC_HOME` 只是測試與 CI 的覆寫出口, + * 正常使用不必設。 */ -export function teaSdlcHome() { - return process.env.TEA_SDLC_HOME?.trim() || join(homedir(), '.tea-sdlc'); -} - -/** 所有工作樹的集中處 */ -export function worktreesRoot() { - return join(teaSdlcHome(), 'worktrees'); +function worktreesRoot() { + const home = process.env.TEA_SDLC_HOME?.trim() || join(homedir(), '.tea-sdlc'); + return join(home, 'worktrees'); } /** @@ -669,6 +666,23 @@ export async function listStopwatches(login) { return expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? []; } +/** + * 「你的錶正跑在別顆議題上」的擋路錯誤。領取與起錶都會撞到它,訊息只寫一份。 + * + * 一定要明說停錶不會動到工作樹:使用者常以為停錶等於放棄那顆工作包,於是寧可不停, + * 而工時就記到別顆議題去了。碼錶只管時間,工作樹只管檔案,兩者互不相干。 + * @param {object} watch listStopwatches 裡的一顆錶 + * @param {string} 動作 擋在哪件事之前,例如「領取」「起錶」 + */ +export function stopwatchElsewhere(watch, 動作) { + return new ScriptError( + 'STOPWATCH_ON_OTHER_ISSUE', + `你的碼錶正跑在 ${watch.repo_owner_name}/${watch.repo_name} 的議題 ` + + `#${watch.issue_index} 上,${動作}前請先手動停錶,否則工時會記到那一顆去;` + + '停錶只停計時,不會動到任何既有的工作樹', + ); +} + /** * 這些碼錶裡,跑在指定議題上的那一顆。 * 比對要連 repo 一起看:不同 repo 的同號議題是兩件事。 diff --git a/scripts/timer.js b/scripts/timer.js index 245dded..7fe5cb5 100644 --- a/scripts/timer.js +++ b/scripts/timer.js @@ -15,7 +15,6 @@ * node scripts/timer.js --repo owner/name --index 40 [--host <網址>] [--dry-run] */ import { - ScriptError, expectOk, fetchIssue, giteaRequest, @@ -26,6 +25,7 @@ import { parseRepo, preflight, resolveLogin, + stopwatchElsewhere, stopwatchOnIssue, } from './lib.js'; @@ -47,14 +47,7 @@ main(async () => { const watches = await listStopwatches(login); const 已在計時 = stopwatchOnIssue(watches, repo, index) !== null; - if (!已在計時 && watches.length > 0) { - const elsewhere = watches[0]; - throw new ScriptError( - 'STOPWATCH_ON_OTHER_ISSUE', - `你的碼錶正跑在 ${elsewhere.repo_owner_name}/${elsewhere.repo_name} 的議題 ` + - `#${elsewhere.issue_index} 上,起錶前請先手動停錶,否則工時會記到那一顆去`, - ); - } + if (!已在計時 && watches.length > 0) throw stopwatchElsewhere(watches[0], '起錶'); // 已經在跑就不重起:重新起錶會把已經累積的時間切成兩段 const planned = 已在計時 ? [] : [{ method: 'POST', path: `${issuePath}/stopwatch/start`, body: {} }]; diff --git a/test/claim.test.js b/test/claim.test.js index b3445ae..72a37e1 100644 --- a/test/claim.test.js +++ b/test/claim.test.js @@ -145,6 +145,7 @@ test('自己碼錶跑在本議題時擋下,要求先手動停錶', async (t) = assert.equal(code, 1); assert.equal(json.error.code, 'STOPWATCH_ON_THIS_ISSUE'); assert.match(json.error.message, /停/, '要說清楚下一步是手動停錶'); + assert.match(json.error.message, /工作樹/, '要明說停錶不會動到既有的工作樹'); assert.deepEqual(writes(stub), []); }); @@ -159,6 +160,11 @@ test('自己碼錶跑在別的議題時擋下,並指出是哪一顆', async (t assert.equal(code, 1); assert.equal(json.error.code, 'STOPWATCH_ON_OTHER_ISSUE'); assert.match(json.error.message, /#7/, '忘了停掉的是哪一顆,要指名'); + assert.match( + json.error.message, + /停錶只停計時,不會動到任何既有的工作樹/, + '以為停錶等於放棄那顆工作包的人會寧可不停,工時就記到別顆去了', + ); assert.deepEqual(writes(stub), []); }); diff --git a/test/lib-exports.test.js b/test/lib-exports.test.js index 2907af0..f1e08ca 100644 --- a/test/lib-exports.test.js +++ b/test/lib-exports.test.js @@ -1,10 +1,9 @@ /** - * 這一支是唯一直接 import lib 的測試,其餘一律走子行程的 CLI 邊界。 + * 直接 import lib 的兩支測試之一(另一支是 worktree-path),其餘一律走子行程的 CLI 邊界。 * - * 理由:冪等查重與 git 執行點是 lib 對「其他腳本」公開的契約,但本工作包只交付 - * labels-list(讀取型、不碰 git、不需查重),CLI 邊界上還沒有消費者。等 #4 的 - * issue-create 與 #10 的 branch-prep 落地後,它們的 CLI 測試才是這兩件事的主場, - * 屆時這支可以縮小或移除。在那之前直接測 export,好過讓契約完全沒有測試。 + * 理由:冪等查重與 git 執行點是 lib 對「其他腳本」公開的契約,而 issue-create 與 + * branch-prep 落地之後,它們的 CLI 測試才是這兩件事的主場——這一支已經是備位的, + * 留著是因為直接測 export 仍比讓契約完全沒有測試好。 */ import test from 'node:test'; import assert from 'node:assert/strict'; diff --git a/test/sdlc-feat-assets.test.js b/test/sdlc-feat-assets.test.js index 8c4a6da..3df97a5 100644 --- a/test/sdlc-feat-assets.test.js +++ b/test/sdlc-feat-assets.test.js @@ -83,6 +83,11 @@ test('兩種擋下來的情境各自寫明處置,且都不替使用者決定', assert.match(phase1, /不要自己刪/, '路徑上的東西可能還沒保存,不該由 agent 決定刪掉'); }); +test('被碼錶擋下時要說明停錶不會動到工作樹', () => { + assert.match(phase1, /停錶不會動到既有的工作樹/); + assert.match(phase1, /以為停錶等於放棄那顆工作包/, '要說明不講清楚的後果'); +}); + test('碼錶只由使用者自己停,並說明為什麼不代勞', () => { assert.match(phase1, /由使用者自己停/); assert.match(phase1, /工時記錯地方/); diff --git a/test/timer.test.js b/test/timer.test.js index ffd7930..324ff21 100644 --- a/test/timer.test.js +++ b/test/timer.test.js @@ -96,6 +96,11 @@ test('不代替使用者停錶:訊息要說清楚下一步是他自己去停', const { json } = await run([], stub); assert.match(json.error.message, /停/); + assert.match( + json.error.message, + /不會動到任何既有的工作樹/, + '碼錶只管時間、工作樹只管檔案;不講清楚,使用者會以為停錶等於放棄那顆工作包', + ); }); test('議題不存在時回可區分的錯誤碼', async (t) => { From d3ce3647277ee0e40bf674eb728681cf948c7e03 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 16:30:00 +0800 Subject: [PATCH 5/5] =?UTF-8?q?docs(sdlc-feat):=20description=20=E8=B7=9F?= =?UTF-8?q?=E8=91=97=E6=94=B9=E6=88=90=E3=80=8C=E5=82=99=E5=A6=A5=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=A8=B9=E3=80=81=E8=B5=B7=E9=8C=B6=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 轉接檔的一行說明是從這裡取的,順序寫錯會讓人以為錶還是在領取那一步起。 議題 #40 Co-Authored-By: Claude Opus 5 (1M context) --- prompts/sdlc-feat.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prompts/sdlc-feat.md b/prompts/sdlc-feat.md index 797f453..5a20526 100644 --- a/prompts/sdlc-feat.md +++ b/prompts/sdlc-feat.md @@ -1,5 +1,5 @@ name: sdlc-feat -description: 僅由 /sdlc-feat 指令叫用。領取一顆工作包、起錶、備妥分支,逐項實作並勾選待辦,最後分批提交並開立 PR。 +description: 僅由 /sdlc-feat 指令叫用。領取一顆工作包、備妥工作樹、起錶,逐項實作並勾選待辦,最後分批提交並開立 PR。 # sdlc-feat