test(分批提交): 把變更依類型分批 commit

一個 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 的修改會少掉檔名的第一個字元。
This commit is contained in:
2026-09-17 08:23:21 +00:00
parent bb886457fd
commit d6b44e0ba8
2 changed files with 357 additions and 2 deletions
+354
View File
@@ -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, '');
});