git diff --name-only 預設偵測改名,只印出目的地那一個路徑。來源的刪除因此被漏掉—— 留在 index 裡沒被提交,而腳本還回報成功,要等下一次跑才會發現工作區不乾淨。加 --no-renames。 某一批提交失敗時,錯誤現在會列出前面已經建立的那幾顆 commit。不回捲它們:那會動到使用者的 歷史,而那幾顆本身是好的;但一定要說出做到哪裡,否則重跑前得自己去翻 git log。 類型對照表補上目標專案常見的測試擺法:tests/、spec/、__tests__/,以及放在被測檔案旁邊的 user.test.js。先前只認 test/,目標專案的測試會被併進 feat 那一批。
395 lines
17 KiB
JavaScript
395 lines
17 KiB
JavaScript
/**
|
||
* 把變更分批 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, '');
|
||
});
|