fix(claim): 被碼錶擋下時明說停錶不會動到既有的工作樹
議題 #38 的使用者故事第 30 條:使用者常以為停錶等於放棄那顆工作包,於是寧可 不停——工時就記到別顆議題去了。碼錶只管時間、工作樹只管檔案,兩者互不相干, 這件事要在擋下來的當下就講,不能指望使用者自己推論。 領取與起錶會撞到同一個擋路理由,訊息收進 lib 只寫一份。順手收掉 review 指出的 三處:planWorktree 沒用到的 repo 參數、與 path.resolve 同名而誤導的區域函式、 以及只有 lib 自己用得到卻對外 export 的兩支路徑函式。 回滾補上最後一道:git 清不掉時把目錄本身也刪掉。那條路徑在這次執行之前不存在 (不存在正是建立的前提),裡面不可能有使用者的東西,而留著它下一次重跑會直接 撞上 WORKTREE_PATH_TAKEN——一次失敗的建立不該讓人從此開不了工。 議題 #40 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -57,6 +57,9 @@ node scripts/claim.js --repo <owner/name> --index <編號> --dry-run
|
||||
| 別人已經認領這顆 | `CLAIMED_BY_OTHER` | 改領別顆,或先跟對方確認 |
|
||||
| 你的錶已經跑在這顆上 | `STOPWATCH_ON_THIS_ISSUE` | 這顆你正在做;要重新計時請先手動停錶 |
|
||||
| 你的錶跑在別的議題上 | `STOPWATCH_ON_OTHER_ISSUE` | 多半是忘了停上一顆;先去停掉再回來 |
|
||||
|
||||
被錶擋下來時**要順帶說明停錶不會動到既有的工作樹**:碼錶只管時間、工作樹只管檔案。
|
||||
不講清楚,使用者會以為停錶等於放棄那顆工作包,於是寧可不停——工時就記到別顆去了。
|
||||
| 沒有鎖 | —— | 放行。自己已認領但沒起錶也算沒有鎖,那正是中斷後重跑的情形 |
|
||||
|
||||
碼錶一律由使用者自己停。哪一段時間該記在哪顆議題上只有他知道,代勞會把工時記錯地方。
|
||||
|
||||
+13
-6
@@ -38,7 +38,7 @@
|
||||
* node scripts/branch-prep.js --repo <owner/name> --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:失敗了也不能蓋掉真正的錯誤訊息,那才是使用者要看的東西。 */
|
||||
|
||||
+4
-7
@@ -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], '領取');
|
||||
}
|
||||
|
||||
+23
-9
@@ -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 的同號議題是兩件事。
|
||||
|
||||
+2
-9
@@ -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: {} }];
|
||||
|
||||
@@ -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), []);
|
||||
});
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -83,6 +83,11 @@ test('兩種擋下來的情境各自寫明處置,且都不替使用者決定',
|
||||
assert.match(phase1, /不要自己刪/, '路徑上的東西可能還沒保存,不該由 agent 決定刪掉');
|
||||
});
|
||||
|
||||
test('被碼錶擋下時要說明停錶不會動到工作樹', () => {
|
||||
assert.match(phase1, /停錶不會動到既有的工作樹/);
|
||||
assert.match(phase1, /以為停錶等於放棄那顆工作包/, '要說明不講清楚的後果');
|
||||
});
|
||||
|
||||
test('碼錶只由使用者自己停,並說明為什麼不代勞', () => {
|
||||
assert.match(phase1, /由使用者自己停/);
|
||||
assert.match(phase1, /工時記錯地方/);
|
||||
|
||||
@@ -96,6 +96,11 @@ test('不代替使用者停錶:訊息要說清楚下一步是他自己去停',
|
||||
const { json } = await run([], stub);
|
||||
|
||||
assert.match(json.error.message, /停/);
|
||||
assert.match(
|
||||
json.error.message,
|
||||
/不會動到任何既有的工作樹/,
|
||||
'碼錶只管時間、工作樹只管檔案;不講清楚,使用者會以為停錶等於放棄那顆工作包',
|
||||
);
|
||||
});
|
||||
|
||||
test('議題不存在時回可區分的錯誤碼', async (t) => {
|
||||
|
||||
Reference in New Issue
Block a user