Files
tea-sdlc/test/commit-split.test.js
T
jiantw83 d6b44e0ba8 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 的修改會少掉檔名的第一個字元。
2026-09-17 08:23:21 +00:00

355 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 把變更分批 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, '');
});