/** * 定位一顆工作包的工作樹,不在就重建。 * * 「不在就重建」不是防禦性程式設計,而是最常見的情境:進度完全不寫在本機,換一台機器 * 或換一個 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'); });