/** * 備妥開工的分支。 * * 兩件事各自要驗: * 1. **分支命名**是純字串規則,表格驅動,成本最低、回歸價值最高。 * 規則錯了會一路帶到 PR 標題與 CI,事後改名很痛。 * 2. **不覆蓋他人進度**。來源分支在遠端已存在時要 pull 而不是重建, * 目標分支已存在時要切過去而不是從來源蓋掉。這兩件事沒有真的遠端就驗不出來, * 所以測試在臨時 repo 上跑真的 git。 */ import test from 'node:test'; import assert from 'node:assert/strict'; import { existsSync } 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,並登記在測試結束時清掉 */ function withRepo(t) { const repo = makeTempRepoWithRemote(); t.after(() => repo.cleanup()); return repo; } const run = (repo, args) => runScript('branch-prep.js', ['--path', repo.dir, ...args]); /** 目前 checkout 在哪一支 */ const currentBranch = (repo) => repo.git('rev-parse', '--abbrev-ref', 'HEAD'); // ── 分支命名規則 ─────────────────────────────────────────────────── 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.git('checkout', '-q', '-B', source); 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); }); } // ── 命名規則的輸入驗證 ───────────────────────────────────────────── 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.git('checkout', '-q', '-B', 'feat/wp-extract-contract/main'); 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/); }); // ── 來源分支:pull 而不是重建 ───────────────────────────────────── test('來源分支在遠端已存在時執行 pull,帶進別人的進度', 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'); }); test('來源分支只在本地時照樣可用,不會因為遠端沒有就報錯', async (t) => { const repo = withRepo(t); repo.git('checkout', '-q', '-B', 'feat/local-only/main'); repo.git('checkout', '-q', 'master'); 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', ]); assert.equal(code, 1); assert.equal(json.error.code, 'SOURCE_NOT_FOUND'); }); 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('工作區有未提交的改動時擋下,不把它們帶進新分支', 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/, '要指名是哪些檔案擋住了'); }); test('工作區不乾淨時,--dry-run 也要照樣說出來', async (t) => { // 試跑印得出漂亮的計畫、實跑卻中途炸掉,是最難查的那種落差 const repo = withRepo(t); writeFileSync(join(repo.dir, 'stray.txt'), '不相干的檔案\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']); assert.equal(code, 1); assert.equal(json.error.code, 'SOURCE_DIVERGED'); assert.match(json.error.message, /master/); assert.equal(currentBranch(repo), 'master', '不該留在半途的狀態'); }); // ── 目標分支:已存在就切過去,不覆蓋 ─────────────────────────────── 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.分支.動作, '接上遠端既有'); }); test('目標分支只在本地時切過去,不重建', async (t) => { const repo = withRepo(t); repo.git('checkout', '-q', '-b', 'feat/mine/main'); repo.git('checkout', '-q', 'master'); const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); assert.equal(json.data.分支.動作, '切換到本地既有'); assert.equal(currentBranch(repo), '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.分支.動作, '從來源建立'); assert.equal(currentBranch(repo), 'feat/brand-new/main'); }); // ── 不留本機狀態檔 ───────────────────────────────────────────────── test('跑完不在目標專案裡留下任何狀態檔', async (t) => { const repo = withRepo(t); await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'no-state']); const status = repo.git('status', '--porcelain'); assert.equal(status, '', '工作區要是乾淨的:進度只從 Gitea 推導,不寫本機狀態檔'); }); test('git 自己的進度訊息不漏到 stderr', async (t) => { // checkout 與 fetch 的訊息 git 一律寫在 stderr。腳本的輸出契約是「stdout 一行 JSON、 // stderr 乾淨」,漏出去的話呼叫端就得去分辨哪幾行是雜訊。 const repo = withRepo(t); const { stderr } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'quiet']); assert.equal(stderr, ''); }); // ── 路徑 ─────────────────────────────────────────────────────────── test('--path 指向的不是 git repo 時,回可區分的錯誤碼', async (t) => { const { json } = await runScript('branch-prep.js', [ '--path', tmpRoot, '--source', 'master', '--type', 'feat', '--slug', 'whatever', ]); assert.equal(json.error.code, 'NOT_A_GIT_REPO'); }); 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); }); // ── --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.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'), '', '試跑不該建分支'); }); test('--dry-run 連分支命名都先算出來,看得到才叫預覽', async (t) => { const repo = withRepo(t); repo.git('checkout', '-q', '-B', 'feat/wp-extract-contract/main'); 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'); });