Files
tea-sdlc/test/branch-prep.test.js
T
jiantw83andClaude Opus 5 865561c63b feat(branch-prep): 一律在獨立的工作樹上開工,不在原地切換分支
同一份 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) <noreply@anthropic.com>
2026-09-17 15:54:27 +08:00

504 lines
21 KiB
JavaScript

/**
* 備妥開工的工作樹。
*
* 三件事各自要驗:
* 1. **分支命名**是純字串規則,表格驅動,成本最低、回歸價值最高。
* 規則錯了會一路帶到 PR 標題與 CI,事後改名很痛。
* 2. **一律在獨立的工作樹上開工**。主工作區一個字都不該被動到——包含它未提交的變更,
* 以及它現在停在哪一支分支上。
* 3. **原子性與不覆蓋他人進度**。分支與工作樹是同一個動作,失敗時不留半成品;
* 起點一律取自遠端,目標分支已存在時接上去而不是蓋掉。
*
* git 不做替身:在臨時 repo 上跑真的 git,以本機裸 repo 充當遠端,不需網路。
* 工作樹則以 TEA_SDLC_HOME 改指到測試暫存,不落到開發者真正的家目錄。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { runScript, tmpRoot } from './helpers/run-script.js';
import { makeTempRepoWithRemote } from './helpers/temp-repo.js';
const REPO = 'plugins/tea-sdlc';
/** 開一個有遠端的臨時 repo 與一個空的工作樹家,並登記在測試結束時清掉 */
function withRepo(t) {
const repo = makeTempRepoWithRemote();
t.after(() => repo.cleanup());
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', ['--repo', REPO, '--path', repo.dir, ...args], {
env: { TEA_SDLC_HOME: repo.home },
});
/** 主工作區目前停在哪一支 */
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 = [
{
name: '從開發分支長出:{類型}/{需求描述}/main',
source: 'master',
args: ['--type', 'feat', '--slug', 'claim-and-branch-prep'],
expected: 'feat/claim-and-branch-prep/main',
},
{
name: '開發分支叫 main 時同樣成立',
source: 'main',
args: ['--type', 'fix', '--slug', 'crlf-parsing'],
expected: 'fix/crlf-parsing/main',
},
{
name: '開發分支叫 develop 時同樣成立',
source: 'develop',
args: ['--type', 'chore', '--slug', 'bump-node'],
expected: 'chore/bump-node/main',
},
{
name: '從功能分支長出:類型與需求描述沿用來源,第三段換成這次的描述',
source: 'feat/wp-extract-contract/main',
args: ['--slug', 'nested-todo-parser'],
expected: 'feat/wp-extract-contract/nested-todo-parser',
},
{
name: '從功能分支的子分支長出時,仍掛在同一個需求描述底下',
source: 'feat/wp-extract-contract/nested-todo-parser',
args: ['--slug', 'crlf-fix'],
expected: 'feat/wp-extract-contract/crlf-fix',
},
];
for (const { name, source, args, expected } of NAMING) {
test(`命名:${name}`, async (t) => {
const repo = withRepo(t);
// 來源分支一律取自遠端,所以先讓遠端有這一支
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(branchIn(json.data.worktree), expected, '工作樹裡 checkout 出來的要是新分支');
});
}
// ── 命名規則的輸入驗證 ─────────────────────────────────────────────
test('需求描述含中文時擋下:CI 與 URL 會出問題', async (t) => {
const repo = withRepo(t);
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', '領取工作包']);
assert.equal(code, 1);
assert.equal(json.error.code, 'BAD_SLUG');
assert.match(json.error.message, /英文/);
});
test('需求描述超過 40 字元時擋下', async (t) => {
const repo = withRepo(t);
const long = 'a'.repeat(41);
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', long]);
assert.equal(json.error.code, 'BAD_SLUG');
assert.match(json.error.message, /40/);
});
test('剛好 40 字元是可以的', async (t) => {
const repo = withRepo(t);
const exactly = 'a'.repeat(40);
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', exactly]);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.branch, `feat/${exactly}/main`);
});
test('大寫、底線、連續連字號都不是 kebab', async (t) => {
const repo = withRepo(t);
for (const slug of ['Claim-Work', 'claim_work', 'claim--work', '-claim', 'claim-']) {
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', slug]);
assert.equal(json.error?.code, 'BAD_SLUG', `${slug} 應該被擋下`);
}
});
test('從開發分支長出卻沒給 --type 時,指名缺的是哪一個', async (t) => {
const repo = withRepo(t);
const { json } = await run(repo, ['--source', 'master', '--slug', 'claim-work']);
assert.equal(json.error.code, 'MISSING_FLAG');
assert.match(json.error.message, /--type/);
});
test('從功能分支長出時給 --type 會被擋,避免子分支跑到別棵樹下', 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', '--type', 'fix', '--slug', 'crlf',
]);
assert.equal(json.error.code, 'TYPE_FROM_SOURCE');
assert.match(json.error.message, /feat/);
});
// ── 一律建立工作樹 ─────────────────────────────────────────────────
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.equal(existsSync(join(json.data.worktree, 'theirs.txt')), true, '起點要是遠端的最新狀態');
assert.equal(existsSync(join(repo.dir, 'theirs.txt')), false, '主工作區不必被順便更新');
});
test('遠端沒有來源分支時明確中止,不退回本機同名分支', async (t) => {
const repo = withRepo(t);
repo.git('branch', 'feat/local-only/main');
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) => {
// git ls-remote --heads origin main 會匹配到 refs/heads/feat/x/main ——
// 而本 repo 的命名慣例讓每一支分支都以 /main 結尾,拿 main 當開發分支的專案
// 會整個誤判成「遠端已經有這一支」。
const repo = withRepo(t);
repo.pushFromElsewhere('feat/x/main', 'theirs.txt', '別人的分支\n');
const { code, json } = await run(repo, ['--source', 'main', '--type', 'feat', '--slug', 'mine']);
assert.equal(code, 1);
assert.equal(json.error.code, 'SOURCE_NOT_FOUND', '遠端沒有 main 這一支,不該被 feat/x/main 冒名頂替');
});
test('不為新分支設定 upstream:此刻遠端還沒有這一支,設了會誤指到來源分支', async (t) => {
const repo = withRepo(t);
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
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('工作樹建不起來時,不留下那一支已經建好的分支', async (t) => {
// git worktree add 失敗時仍會把分支留下來,那是最難查的半成品:
// 下一次重跑會走到「目標分支已存在」那條路,起點從此不是遠端的來源分支
const repo = withRepo(t);
const blocked = join(repo.home, 'blocker');
writeFileSync(blocked, '這是一個檔案,不是目錄\n');
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, '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) => {
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(json.data.分支.動作, '接上遠端既有');
assert.equal(
existsSync(join(json.data.worktree, 'progress.txt')),
true,
'遠端已有的分支要接上去,不是從來源重建一個空的蓋掉',
);
});
test('目標分支只在本地時接上去,不重建', async (t) => {
const repo = withRepo(t);
repo.git('branch', 'feat/mine/main');
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
assert.equal(json.data.分支.動作, '接上本地既有');
assert.equal(branchIn(json.data.worktree), 'feat/mine/main');
});
test('目標分支不存在時從遠端的來源分支建立', async (t) => {
const repo = withRepo(t);
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'brand-new']);
assert.equal(json.data.分支.動作, '從來源建立');
});
// ── 冪等:工作樹已經在了 ───────────────────────────────────────────
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, '機密一律由使用者自己放');
});
// ── 不留本機狀態檔 ─────────────────────────────────────────────────
test('跑完不在目標專案裡留下任何狀態檔', async (t) => {
const repo = withRepo(t);
await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'no-state']);
assert.equal(repo.git('status', '--porcelain'), '', '進度只從 Gitea 與 git 本身推導,不寫本機狀態檔');
});
test('git 自己的進度訊息不漏到 stderr', async (t) => {
// fetch 與 worktree add 的訊息 git 一律寫在 stderr。腳本的輸出契約是「stdout 一行 JSON、
// stderr 乾淨」,漏出去的話呼叫端就得去分辨哪幾行是雜訊。
const repo = withRepo(t);
const { stderr } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'quiet']);
assert.equal(stderr, '');
});
// ── 路徑與 flag ───────────────────────────────────────────────────
test('--path 指向的不是 git repo 時,回可區分的錯誤碼', async (t) => {
const { json } = await runScript('branch-prep.js', [
'--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 ─────────────────────────────────────────────────────
test('--dry-run 印出將執行的 git 指令,且完全不動 repo', async (t) => {
const repo = withRepo(t);
const before = repo.git('rev-parse', 'HEAD');
const { code, json } = await run(repo, [
'--source', 'master', '--type', 'feat', '--slug', 'mine', '--dry-run',
]);
assert.equal(code, 0);
assert.equal(json.data.dryRun, true);
assert.equal(json.data.branch, 'feat/mine/main');
assert.ok(
json.data.commands.every((c) => c.startsWith('git ')),
'預覽的是 git 指令本身,不是自創的描述',
);
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 把 fetch 與建立工作樹兩步都印出來,順序不顛倒', async (t) => {
const repo = withRepo(t);
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');
});