From 0154cf59d4e9fdab6a4b070723e66aa7a70cae6d Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 15:59:48 +0800 Subject: [PATCH] =?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) => {