Files
tea-sdlc/test/worktree-ensure.test.js
T
jiantw83andClaude Opus 5 2562555058 feat(worktree-ensure): 定位工作包的工作樹,不在就重建
處理 PR 留言的人不必先手動 cd 到正確的目錄:路徑由 owner/repo/分支名 純函式推導,
問這一支就知道該在哪裡動手。

「不在就重建」是常態不是防禦性程式設計。進度完全不寫在本機——換一台機器或換一個
agent 接手時,工作樹本來就不存在,而重建的成本就是一次 git worktree add。

兩種情況明確中止而不是硬幹:分支在本機與遠端都不見時報 BRANCH_NOT_FOUND(憑空長一棵
空的工作樹只會讓人以為進度還在),推導出的路徑上是別的東西時報 WORKTREE_PATH_TAKEN。

議題 #42

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 17:33:44 +08:00

197 lines
7.8 KiB
JavaScript

/**
* 定位一顆工作包的工作樹,不在就重建。
*
* 「不在就重建」不是防禦性程式設計,而是最常見的情境:進度完全不寫在本機,換一台機器
* 或換一個 agent 接手時,工作樹本來就不存在。重建的成本就是一次 `git worktree add`。
*
* 重建走的是與 branch-prep 同一套推導與同一套建立方式(`lib` 的 `worktreePath` 與
* `planWorktree`),所以這裡連帶驗兩件事:算出來的是同一條路徑,而且分支上已經有的
* 進度會被接上,不是從頭長一棵空的。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
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';
const SLUG = 'mine';
const BRANCH = `feat/${SLUG}/main`;
/** 一個有遠端的臨時 repo 與一個空的工作樹家 */
function withRepo(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 }));
return { ...repo, home };
}
/** 照正規流程開一棵工作樹,回傳它的路徑——測試不自己算路徑 */
async function prep(repo) {
const { json } = await runScript(
'branch-prep.js',
['--repo', REPO, '--path', repo.dir, '--source', 'master', '--type', 'feat', '--slug', SLUG],
{ env: { TEA_SDLC_HOME: repo.home } },
);
assert.equal(json.ok, true, json.error?.message);
return json.data.worktree;
}
const run = (repo, args = []) =>
runScript('worktree-ensure.js', ['--repo', REPO, '--path', repo.dir, '--branch', BRANCH, ...args], {
env: { TEA_SDLC_HOME: repo.home },
});
const branchIn = (dir) =>
execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim();
// ── 已經在了就沿用 ─────────────────────────────────────────────────
test('工作樹已經在了就沿用,不碰裡面還沒提交的東西', async (t) => {
const repo = withRepo(t);
const worktree = await prep(repo);
writeFileSync(join(worktree, 'wip.txt'), '做到一半\n');
const { code, json } = await run(repo);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.worktree, worktree, '推導出的要是 branch-prep 建的那一條');
assert.equal(json.data.動作, '沿用既有工作樹');
assert.equal(existsSync(join(worktree, 'wip.txt')), true);
});
// ── 不在就重建 ─────────────────────────────────────────────────────
test('工作樹不在時重建它,分支上的進度跟著回來', async (t) => {
// 換一台機器接手就是這個情形:進度不寫在本機,工作樹本來就不存在
const repo = withRepo(t);
const worktree = await prep(repo);
writeFileSync(join(worktree, 'done.txt'), '已經提交的進度\n');
execFileSync('git', ['add', '-A'], { cwd: worktree });
execFileSync('git', ['commit', '-qm', '分支上的進度'], { cwd: worktree });
repo.git('worktree', 'remove', worktree);
const { code, json } = await run(repo);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.worktree, worktree, '同一顆工作包算出來的永遠是同一條路徑');
assert.equal(json.data.動作, '接上本地既有');
assert.equal(branchIn(worktree), BRANCH);
assert.equal(existsSync(join(worktree, 'done.txt')), true, '接上既有分支,不是長一棵空的');
});
test('本機連分支都沒有時,從遠端那一支重建', async (t) => {
// 真正的新機器:clone 完什麼都沒有,分支只在遠端上
const repo = withRepo(t);
repo.pushFromElsewhere(BRANCH, 'theirs.txt', '推上去的進度\n');
const { code, json } = await run(repo);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.動作, '接上遠端既有');
assert.equal(existsSync(join(json.data.worktree, 'theirs.txt')), true);
assert.equal(
repo.git('for-each-ref', '--format=%(upstream)', `refs/heads/${BRANCH}`),
'',
'重建與建立走同一套:一樣不設 upstream',
);
});
test('中繼資料還在但目錄被砍掉時照樣重建', async (t) => {
const repo = withRepo(t);
const worktree = await prep(repo);
rmSync(worktree, { recursive: true, force: true });
const { code, json } = await run(repo);
assert.equal(code, 0, json.error?.message);
assert.equal(existsSync(worktree), true);
assert.equal(branchIn(worktree), BRANCH);
});
// ── 擋下來的情況 ───────────────────────────────────────────────────
test('分支在本機與遠端都不存在時明確報錯,不憑空長一棵', async (t) => {
const repo = withRepo(t);
const { code, json } = await run(repo);
assert.equal(code, 1);
assert.equal(json.error.code, 'BRANCH_NOT_FOUND');
assert.match(json.error.message, new RegExp(BRANCH));
assert.equal(existsSync(json.data?.worktree ?? '/nonexistent'), false);
});
test('推導出的路徑上是別的東西時明確報錯,不盲目拿來用', async (t) => {
const repo = withRepo(t);
const worktree = await prep(repo);
repo.git('worktree', 'remove', worktree);
mkdirSync(worktree, { recursive: true });
writeFileSync(join(worktree, '別人的東西.txt'), 'x\n');
const { code, json } = await run(repo);
assert.equal(code, 1);
assert.equal(json.error.code, 'WORKTREE_PATH_TAKEN');
assert.equal(existsSync(join(worktree, '別人的東西.txt')), true, '不是我們建的東西就不碰');
});
test('--path 指向的不是 git repo 時,回可區分的錯誤碼', async (t) => {
const { json } = await runScript('worktree-ensure.js', [
'--repo', REPO, '--path', tmpRoot, '--branch', BRANCH,
]);
assert.equal(json.error.code, 'NOT_A_GIT_REPO');
});
test('缺 --branch 時指名缺的是哪一個', async (t) => {
const repo = withRepo(t);
const { json } = await runScript('worktree-ensure.js', ['--repo', REPO, '--path', repo.dir], {
env: { TEA_SDLC_HOME: repo.home },
});
assert.equal(json.error.code, 'MISSING_FLAG');
assert.match(json.error.message, /--branch/);
});
// ── --dry-run ─────────────────────────────────────────────────────
test('--dry-run 印出路徑與將執行的 git 指令,且不建任何東西', async (t) => {
const repo = withRepo(t);
repo.pushFromElsewhere(BRANCH, 'theirs.txt', '推上去的進度\n');
const { code, json } = await run(repo, ['--dry-run']);
assert.equal(code, 0);
assert.equal(json.data.dryRun, true);
assert.match(json.data.worktree, /worktrees\/[0-9a-f]{12}$/);
assert.deepEqual(json.data.commands, [
'git fetch origin',
`git worktree add --no-track -b ${BRANCH} ${json.data.worktree} origin/${BRANCH}`,
]);
assert.equal(existsSync(json.data.worktree), false, '試跑不該真的建');
});
test('--dry-run 在工作樹已經在時說沒事要做', async (t) => {
const repo = withRepo(t);
await prep(repo);
const { json } = await run(repo, ['--dry-run']);
assert.equal(json.data.動作, '沿用既有工作樹');
assert.deepEqual(json.data.commands, []);
});
test('--dry-run 也照樣把擋下來的情況說出來', async (t) => {
const repo = withRepo(t);
const { json } = await run(repo, ['--dry-run']);
assert.equal(json.error.code, 'BRANCH_NOT_FOUND');
});