Files
tea-sdlc/test/commit-split.test.js
jiantw83 8a6145ea14 test(commit-split): 改名時別漏掉舊檔的刪除,失敗時說出做到哪裡
git diff --name-only 預設偵測改名,只印出目的地那一個路徑。來源的刪除因此被漏掉——
留在 index 裡沒被提交,而腳本還回報成功,要等下一次跑才會發現工作區不乾淨。加 --no-renames。

某一批提交失敗時,錯誤現在會列出前面已經建立的那幾顆 commit。不回捲它們:那會動到使用者的
歷史,而那幾顆本身是好的;但一定要說出做到哪裡,否則重跑前得自己去翻 git log。

類型對照表補上目標專案常見的測試擺法:tests/、spec/、__tests__/,以及放在被測檔案旁邊的
user.test.js。先前只認 test/,目標專案的測試會被併進 feat 那一批。
2026-09-17 08:23:24 +00:00

395 lines
17 KiB
JavaScript
Raw Permalink 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: 'tests/user_test.py', type: 'test', why: '目標專案未必叫 test/' },
{ path: 'spec/user_spec.rb', type: 'test', why: '同上' },
{ path: '__tests__/user.js', type: 'test', why: '同上' },
{ path: 'src/user.test.js', type: 'test', why: '測試與程式碼放在一起也很常見' },
{ path: 'src/User.spec.ts', type: 'test', why: '同上' },
{ 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'), '');
});
test('改名時舊檔的刪除也要進 commit,不能只提交新檔', async (t) => {
// git diff --name-only 預設偵測改名,只印目的地那一個路徑。漏掉來源等於把刪除留在
// index 裡,而腳本還回報成功——下一次跑才會發現工作區不乾淨。
const repo = withRepo(t);
write(repo, 'scripts/old.js');
repo.git('add', '-A');
repo.git('commit', '-qm', '先有這個檔案');
repo.git('mv', 'scripts/old.js', 'scripts/new.js');
const { code, json } = await run(repo, ['--type', 'refactor', '--scope', '改名', '--subject', '換個名字']);
assert.equal(code, 0, JSON.stringify(json));
assert.deepEqual(json.data.commits[0].files, ['scripts/new.js', 'scripts/old.js']);
assert.equal(repo.git('status', '--porcelain'), '', '改名的兩邊都要進同一顆 commit');
});
// ── 中途失敗 ───────────────────────────────────────────────────────
test('某一批提交失敗時,錯誤要說出前面已經建立了哪幾顆 commit', async (t) => {
// 沒說的話,使用者不知道做到哪裡,重跑前得自己去翻 git log
const repo = withRepo(t);
write(repo, 'scripts/one.js', 'test/one.test.js');
// 用 pre-commit hook 擋掉測試那一批
const hook = join(repo.dir, '.git', 'hooks', 'pre-commit');
mkdirSync(dirname(hook), { recursive: true });
writeFileSync(hook, '#!/bin/sh\ngit diff --cached --name-only | grep -q "^test/" && exit 1\nexit 0\n', { mode: 0o755 });
const { code, json } = await run(repo, ['--type', 'feat', '--subject', '做一件事']);
assert.equal(code, 1);
assert.equal(json.error.code, 'COMMIT_FAILED');
assert.match(json.error.message, /feat\(one\): 做一件事/, '要指名已經建立的那一顆');
assert.match(json.error.message, /test\(one\)/, '也要指名是哪一批失敗的');
});
// ── --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, '');
});