/** * 把變更分批 commit。 * * 兩件事各自要驗: * 1. **類型分類**是純字串規則,表格驅動——它決定 git 歷史讀不讀得懂, * 而錯了之後要改歷史才修得回來。 * 2. **分批的界線**:一個 commit 只裝一種類型,程式碼與測試不混在一起, * reviewer 才能一次只看一件事。 * * git 不做 mock:在臨時 repo 上跑真的 git 比假的 git 可信,成本也低。 */ import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdirSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { runScript } from './helpers/run-script.js'; import { makeTempRepo } from './helpers/temp-repo.js'; function withRepo(t) { const repo = makeTempRepo(); t.after(() => repo.cleanup()); return repo; } /** 在 repo 裡寫幾個檔案(含目錄),模擬一次實作留下的變更 */ function write(repo, ...paths) { for (const path of paths) { const full = join(repo.dir, path); mkdirSync(dirname(full), { recursive: true }); writeFileSync(full, `// ${path}\n`); } } const run = (repo, args) => runScript('commit-split.js', ['--path', repo.dir, ...args]); /** 初始 commit 之後新增的 commit 訊息首行,由舊到新 */ const subjects = (repo) => repo.git('log', '--format=%s', '--reverse').split('\n').filter((line) => line !== '').slice(1); // ── 類型分類:表格驅動 ───────────────────────────────────────────── const CLASSIFY = [ { path: 'test/claim.test.js', type: 'test', why: '測試檔' }, { path: 'test/helpers/stub-gitea.js', type: 'test', why: '測試用的 helper 也算測試' }, { path: 'README.md', type: 'docs', why: '根目錄的說明文件' }, { path: 'AGENTS.md', type: 'docs', why: '同上' }, { path: 'package.json', type: 'chore', why: '專案設定' }, { path: '.gitignore', type: 'chore', why: '同上' }, { path: 'scripts/claim.js', type: null, why: '看不出是新功能還是修 bug,要由呼叫端指定' }, { path: 'prompts/sdlc-feat.md', type: null, why: '流程正本是產品的一部分,同上' }, { path: 'references/coding-standards.md', type: null, why: '規則正本同上' }, { path: 'templates/work-package-issue.md', type: null, why: '輸出模板同上' }, ]; for (const { path, type, why } of CLASSIFY) { test(`分類:${path} → ${type ?? '由 --type 決定'}(${why})`, async (t) => { const repo = withRepo(t); write(repo, path); const { code, json } = await run(repo, ['--type', 'feat', '--subject', '做了一件事']); assert.equal(code, 0, JSON.stringify(json)); assert.equal(json.data.commits.length, 1); assert.match(json.data.commits[0].message, new RegExp(`^${type ?? 'feat'}\\(`)); }); } // ── 分批:一個 commit 只裝一種類型 ───────────────────────────────── test('程式碼與測試分成兩個 commit,不混在一起', async (t) => { const repo = withRepo(t); write(repo, 'scripts/claim.js', 'test/claim.test.js'); const { code, json } = await run(repo, ['--type', 'feat', '--subject', '領取工作包']); assert.equal(code, 0, JSON.stringify(json)); assert.deepEqual(subjects(repo), [ 'feat(claim): 領取工作包', 'test(claim): 領取工作包', ]); }); test('四種類型都出現時分成四個 commit,順序為先程式碼後周邊', async (t) => { const repo = withRepo(t); write(repo, 'scripts/claim.js', 'test/claim.test.js', 'README.md', 'package.json'); const { json } = await run(repo, ['--type', 'feat', '--scope', '領取', '--subject', '領取工作包']); assert.deepEqual(json.data.commits.map((c) => c.message), [ 'feat(claim): 領取工作包', 'test(claim): 領取工作包', 'docs(README): 領取工作包', 'chore(package): 領取工作包', ]); }); test('每個 commit 只含它自己那一批檔案', async (t) => { const repo = withRepo(t); write(repo, 'scripts/claim.js', 'test/claim.test.js'); await run(repo, ['--type', 'feat', '--subject', '領取工作包']); const firstFiles = repo.git('show', '--name-only', '--format=', 'HEAD~1').split('\n').filter(Boolean); const secondFiles = repo.git('show', '--name-only', '--format=', 'HEAD').split('\n').filter(Boolean); assert.deepEqual(firstFiles, ['scripts/claim.js']); assert.deepEqual(secondFiles, ['test/claim.test.js']); }); test('工作區在跑完之後是乾淨的:沒有檔案被漏掉', async (t) => { const repo = withRepo(t); write(repo, 'scripts/claim.js', 'test/claim.test.js', 'README.md', 'package.json'); await run(repo, ['--type', 'feat', '--scope', '領取', '--subject', '領取工作包']); assert.equal(repo.git('status', '--porcelain'), ''); }); // ── scope:單檔用檔名,多檔用功能名 ─────────────────────────────── test('一批只有一個檔案時,scope 是那個檔名(去掉目錄與副檔名)', async (t) => { const repo = withRepo(t); write(repo, 'scripts/branch-prep.js'); const { json } = await run(repo, ['--type', 'feat', '--subject', '備妥分支']); assert.equal(json.data.commits[0].message, 'feat(branch-prep): 備妥分支'); }); test('一批有多個檔案時,scope 是 --scope 給的功能名', async (t) => { const repo = withRepo(t); write(repo, 'scripts/claim.js', 'scripts/branch-prep.js'); const { json } = await run(repo, ['--type', 'feat', '--scope', '領取與分支', '--subject', '備妥開工']); assert.equal(json.data.commits[0].message, 'feat(領取與分支): 備妥開工'); }); test('多檔卻沒給 --scope 時擋下,並說明什麼時候要給', async (t) => { const repo = withRepo(t); write(repo, 'scripts/claim.js', 'scripts/branch-prep.js'); const { code, json } = await run(repo, ['--type', 'feat', '--subject', '備妥開工']); assert.equal(code, 1); assert.equal(json.error.code, 'SCOPE_REQUIRED'); assert.match(json.error.message, /多檔/); assert.equal(repo.git('status', '--porcelain') === '', false, '擋下來就不該已經提交掉'); }); test('--scope 只在多檔那幾批生效,單檔那批仍用檔名', async (t) => { const repo = withRepo(t); write(repo, 'scripts/claim.js', 'scripts/branch-prep.js', 'test/claim.test.js'); const { json } = await run(repo, ['--type', 'feat', '--scope', '領取與分支', '--subject', '備妥開工']); assert.deepEqual(json.data.commits.map((c) => c.message), [ 'feat(領取與分支): 備妥開工', 'test(claim): 備妥開工', ]); }); // ── --files:一次只處理一個功能 ─────────────────────────────────── test('--files 只提交指定的那幾個檔案,其餘原封不動留著', async (t) => { // 正本要求「一次變更橫跨兩個不相干的功能時分兩次跑」,那就得有辦法只處理一部分 const repo = withRepo(t); write(repo, 'scripts/claim.js', 'scripts/branch-prep.js'); const { code, json } = await run(repo, [ '--type', 'feat', '--subject', '領取工作包', '--files', 'scripts/claim.js', ]); assert.equal(code, 0, JSON.stringify(json)); assert.deepEqual(json.data.commits.map((c) => c.message), ['feat(claim): 領取工作包']); assert.equal( repo.git('status', '--porcelain').includes('branch-prep.js'), true, '沒被指定的檔案要留在工作區', ); }); test('--files 指定多個檔案時照樣依類型分批', async (t) => { const repo = withRepo(t); write(repo, 'scripts/claim.js', 'test/claim.test.js', 'scripts/branch-prep.js'); const { json } = await run(repo, [ '--type', 'feat', '--subject', '領取工作包', '--files', 'scripts/claim.js,test/claim.test.js', ]); assert.deepEqual(json.data.commits.map((c) => c.message), [ 'feat(claim): 領取工作包', 'test(claim): 領取工作包', ]); }); test('--files 指到沒有變更的檔案時擋下,不默默少做', async (t) => { const repo = withRepo(t); write(repo, 'scripts/claim.js'); const { code, json } = await run(repo, [ '--type', 'feat', '--subject', '領取工作包', '--files', 'scripts/claim.js,scripts/沒改過.js', ]); assert.equal(code, 1); assert.equal(json.error.code, 'FILE_NOT_CHANGED'); assert.match(json.error.message, /沒改過/); }); // ── 訊息格式 ─────────────────────────────────────────────────────── test('描述要用繁體中文,純英文的描述會被擋下', async (t) => { const repo = withRepo(t); write(repo, 'scripts/claim.js'); const { code, json } = await run(repo, ['--type', 'feat', '--subject', 'claim the work package']); assert.equal(code, 1); assert.equal(json.error.code, 'SUBJECT_NOT_CHINESE'); assert.match(json.error.message, /繁體中文|中文/); }); test('描述夾雜英文是可以的,只要有中文', async (t) => { const repo = withRepo(t); write(repo, 'scripts/claim.js'); const { code, json } = await run(repo, ['--type', 'feat', '--subject', '讓 claim 擋住他人已認領的工作包']); assert.equal(code, 0, JSON.stringify(json)); assert.equal(json.data.commits[0].message, 'feat(claim): 讓 claim 擋住他人已認領的工作包'); }); test('--type 不是既定分類時擋下,並列出可用的', async (t) => { const repo = withRepo(t); write(repo, 'scripts/claim.js'); const { json } = await run(repo, ['--type', 'feature', '--subject', '做了一件事']); assert.equal(json.error.code, 'BAD_TYPE'); assert.match(json.error.message, /feat/); }); // ── 訊息本體 ─────────────────────────────────────────────────────── test('--body 接在首行之後,中間空一行', async (t) => { // 本 repo 的每一顆 commit 都說明「為什麼這樣做」,工具產出的歷史不該只有首行 const repo = withRepo(t); write(repo, 'scripts/claim.js'); const { code, json } = await run(repo, [ '--type', 'feat', '--subject', '領取工作包', '--body', '鎖用 assignee 加標籤,不用碼錶——Gitea 只讀得到自己的錶。', ]); assert.equal(code, 0, JSON.stringify(json)); assert.equal( repo.git('log', '-1', '--format=%B').trim(), 'feat(claim): 領取工作包\n\n鎖用 assignee 加標籤,不用碼錶——Gitea 只讀得到自己的錶。', ); }); test('同一批變更的每一顆 commit 共用同一段說明', async (t) => { const repo = withRepo(t); write(repo, 'scripts/claim.js', 'test/claim.test.js'); await run(repo, ['--type', 'feat', '--subject', '領取工作包', '--body', '說明為什麼。']); for (const ref of ['HEAD', 'HEAD~1']) { assert.match(repo.git('log', '-1', '--format=%b', ref), /說明為什麼。/); } }); test('沒給 --body 時訊息就只有首行,不補空行', async (t) => { const repo = withRepo(t); write(repo, 'scripts/claim.js'); await run(repo, ['--type', 'feat', '--subject', '領取工作包']); assert.equal(repo.git('log', '-1', '--format=%B').trim(), 'feat(claim): 領取工作包'); }); // ── 沒有東西可提交 ───────────────────────────────────────────────── test('工作區乾淨時回可區分的錯誤碼,不做出一顆空 commit', async (t) => { const repo = withRepo(t); const { code, json } = await run(repo, ['--type', 'feat', '--subject', '什麼都沒改']); assert.equal(code, 1); assert.equal(json.error.code, 'NOTHING_TO_COMMIT'); }); // ── 刪除與改名 ───────────────────────────────────────────────────── test('被刪掉的檔案也照樣分類、照樣進 commit', async (t) => { const repo = withRepo(t); write(repo, 'scripts/old.js'); repo.git('add', '-A'); repo.git('commit', '-qm', '先有這個檔案'); repo.git('rm', '-q', 'scripts/old.js'); const { code, json } = await run(repo, ['--type', 'refactor', '--subject', '移除不再使用的腳本']); assert.equal(code, 0, JSON.stringify(json)); assert.equal(json.data.commits[0].message, 'refactor(old): 移除不再使用的腳本'); assert.equal(repo.git('status', '--porcelain'), ''); }); // ── --dry-run ───────────────────────────────────────────────────── test('--dry-run 印出將建立的 commit 與各自的檔案,但不提交', async (t) => { const repo = withRepo(t); write(repo, 'scripts/claim.js', 'test/claim.test.js'); const before = repo.git('rev-parse', 'HEAD'); const { code, json } = await run(repo, ['--type', 'feat', '--subject', '領取工作包', '--dry-run']); assert.equal(code, 0); assert.equal(json.data.dryRun, true); assert.deepEqual(json.data.commits, [ { message: 'feat(claim): 領取工作包', files: ['scripts/claim.js'] }, { message: 'test(claim): 領取工作包', files: ['test/claim.test.js'] }, ]); assert.equal(repo.git('rev-parse', 'HEAD'), before, '試跑不該產生 commit'); assert.equal(repo.git('status', '--porcelain') === '', false, '變更要原封不動留著'); }); test('--dry-run 在多檔缺 --scope 時一樣報錯,不會等到實跑才發現', async (t) => { const repo = withRepo(t); write(repo, 'scripts/claim.js', 'scripts/branch-prep.js'); const { json } = await run(repo, ['--type', 'feat', '--subject', '備妥開工', '--dry-run']); assert.equal(json.error.code, 'SCOPE_REQUIRED'); }); // ── 路徑 ─────────────────────────────────────────────────────────── test('--path 不是 git repo 時回可區分的錯誤碼', async (t) => { const { json } = await runScript('commit-split.js', [ '--path', '/', '--type', 'feat', '--subject', '做了一件事', ]); assert.equal(json.error.code, 'NOT_A_GIT_REPO'); }); test('git 自己的訊息不漏到 stderr', async (t) => { const repo = withRepo(t); write(repo, 'scripts/claim.js'); const { stderr } = await run(repo, ['--type', 'feat', '--subject', '領取工作包']); assert.equal(stderr, ''); });