From d6b44e0ba844a0546ff0d66f9a222ad4ca97f3a7 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 08:23:21 +0000 Subject: [PATCH] =?UTF-8?q?test(=E5=88=86=E6=89=B9=E6=8F=90=E4=BA=A4):=20?= =?UTF-8?q?=E6=8A=8A=E8=AE=8A=E6=9B=B4=E4=BE=9D=E9=A1=9E=E5=9E=8B=E5=88=86?= =?UTF-8?q?=E6=89=B9=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一個 commit 只裝一種類型:程式碼、測試、文件、雜項各自成批,reviewer 一次只看一件事, 日後 git log 也讀得懂。全部混成一顆「完成工作包」的巨大 commit,等於沒有歷史。 類型多半看得出來——測試檔就是 test、README 就是 docs——但 scripts/ 底下的改動是新功能 還是修 bug,只有做的人知道,所以那一批由 --type 指定。這張對照表是純字串規則,表格驅動。 scope 單檔用檔名(claim.test.js 的 scope 是 claim,不是 claim.test),多檔用 --scope 的 功能名。描述要有中文:日後回顧時看得懂的是中文,而夾雜英文的專有名詞本來就該保留原文。 --files 讓一次變更橫跨兩個功能時能分兩次跑;--body 讓工具產出的歷史與本 repo 既有的 commit 一樣說明得出「為什麼」。 列變更檔案刻意不用 git status --porcelain:它的前兩欄是狀態碼,而 runGit 會 trim 掉 輸出的前導空白,未 staged 的修改會少掉檔名的第一個字元。 --- test/commit-split.test.js | 354 ++++++++++++++++++++++++++++++++++++++ test/helpers/temp-repo.js | 5 +- 2 files changed, 357 insertions(+), 2 deletions(-) create mode 100644 test/commit-split.test.js diff --git a/test/commit-split.test.js b/test/commit-split.test.js new file mode 100644 index 0000000..31de5be --- /dev/null +++ b/test/commit-split.test.js @@ -0,0 +1,354 @@ +/** + * 把變更分批 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, ''); +}); diff --git a/test/helpers/temp-repo.js b/test/helpers/temp-repo.js index e7235ca..a5933a6 100644 --- a/test/helpers/temp-repo.js +++ b/test/helpers/temp-repo.js @@ -8,7 +8,8 @@ import { join } from 'node:path'; import { tmpRoot } from './run-script.js'; /** - * @returns {{dir: string, cleanup: Function}} dir 為已有一顆 commit 的 git repo + * @returns {{dir: string, git: Function, cleanup: Function}} dir 為已有一顆 commit 的 git repo, + * git 為綁在它身上的執行器(與 makeTempRepoWithRemote 對稱) */ export function makeTempRepo() { mkdirSync(tmpRoot, { recursive: true }); @@ -18,7 +19,7 @@ export function makeTempRepo() { git('init', '-q', '-b', 'master'); seed(git, dir, 'tester', 'tester@example.com'); - return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; + return { dir, git, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; } /**