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>
This commit is contained in:
2026-09-17 16:27:43 +08:00
co-authored by Claude Opus 5
parent 03c368b6de
commit 5b740c236b
4 changed files with 647 additions and 208 deletions
+249 -99
View File
@@ -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');
});
+131
View File
@@ -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);
});