/** * 備妥開工的工作樹。 * * 三件事各自要驗: * 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, '也不該留下工作樹的中繼資料', ); assert.equal(existsSync(join(blocked, 'worktrees')), 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'); });