Files
tea-sdlc/scripts/commit-split.js
T
jiantw83 bb886457fd feat(commit-split): 把變更依類型分批 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:20 +00:00

203 lines
8.4 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.
#!/usr/bin/env node
/**
* 把工作區的變更依類型分批 commit。
*
* 一個 commit 只裝一種類型:程式碼、測試、文件、雜項各自成批,reviewer 一次只看一件事,
* 日後 `git log` 也讀得懂。全部混成一顆「完成工作包」的巨大 commit,等於沒有歷史。
*
* 類型多半看得出來——測試檔就是 test、README 就是 docs——但 `scripts/` 底下的改動
* 是新功能還是修 bug,只有做的人知道,所以那一批由 `--type` 指定。
*
* 訊息格式 `{類型}({scope}): {繁中描述}`,`--body` 接在首行之後說明「為什麼這樣做」。
* scope 單檔用檔名、多檔用 `--scope` 的功能名。
* 描述要用繁體中文——日後回顧時看得懂的是中文,不是當初隨手寫的英文。
*
* 一次變更橫跨兩個不相干的功能時用 `--files` 分兩次跑:一顆 commit 的描述只說得清楚
* 一件事,硬湊在一起就失去了分批的意義。
*
* 用法:
* node scripts/commit-split.js --type feat --subject '<繁中描述>'
* [--scope <功能名>] [--body '<為什麼>'] [--files a.js,b.js]
* [--path <目標專案>] [--dry-run]
*/
import { existsSync } from 'node:fs';
import { basename, join } from 'node:path';
import { ScriptError, main, parseFlags, runGit } from './lib.js';
/** commit 訊息的類型。與既有 git 歷史一致,不另立新詞。 */
const TYPES = ['feat', 'fix', 'refactor', 'test', 'docs', 'chore', 'perf', 'style'];
/**
* 從檔案路徑看得出來的類型。由上往下比對,第一個命中的為準。
*
* 只列「看路徑就能確定」的那幾種。`scripts/`、`prompts/`、`references/`、`templates/`
* 都是產品本身,是新增還是修正得由做的人說,所以不在這張表裡——它們吃 `--type`。
*/
const BY_PATH = [
{ type: 'test', match: (path) => path.startsWith('test/') },
{ type: 'docs', match: (path) => /^[^/]+\.md$/.test(path) || path.startsWith('docs/') },
{
type: 'chore',
match: (path) =>
/^[^/]+$/.test(path) && !/\.md$/.test(path) && /^[.]|\.(json|ya?ml|toml|lock)$/.test(path),
},
{ type: 'chore', match: (path) => path.startsWith('.github/') || path.startsWith('.gitea/') },
];
/** 分批的順序:先程式碼,再測試,最後周邊。git log 由新到舊讀起來才是「做了什麼、怎麼驗的」 */
const ORDER = ['feat', 'fix', 'refactor', 'perf', 'style', 'test', 'docs', 'chore'];
main(async () => {
const flags = parseFlags(process.argv.slice(2), {
required: ['type', 'subject'],
optional: ['scope', 'path', 'files', 'body'],
booleans: ['dry-run'],
});
const path = flags.path ?? process.cwd();
const type = parseType(flags.type);
const subject = parseSubject(flags.subject);
if (!existsSync(join(path, '.git'))) {
throw new ScriptError('NOT_A_GIT_REPO', `${path} 不是 git repo;請用 --path 指向目標專案的根目錄`);
}
const git = (...args) => runGit(args, { cwd: path });
const { changed, untracked } = changedFiles(git);
if (changed.length === 0) {
throw new ScriptError('NOTHING_TO_COMMIT', `${path} 的工作區是乾淨的,沒有東西可以提交`);
}
const commits = plan(selectFiles(changed, flags.files), type, flags.scope, subject, flags.body);
if (flags['dry-run']) {
return { dryRun: true, path, commits };
}
for (const { message, files } of commits) {
// 只有未追蹤的檔案需要先 add:commit 帶 pathspec 不會把新檔案收進來,
// 但已追蹤的修改與刪除它自己處理得了。對已經被 git rm 掉的檔案再 add 一次只會報
// 「找不到這個路徑」——那個檔案本來就已經不在工作區也不在 index 裡了。
const toAdd = files.filter((file) => untracked.has(file));
if (toAdd.length > 0) git('add', '--', ...toAdd);
git('commit', '-m', message, '--', ...files);
}
return { path, commits: commits.map(({ message, files }) => ({ message, files })) };
});
/**
* 列出工作區的變更檔案,含未追蹤與已刪除的。
*
* 刻意不用 `git status --porcelain`:它每一行的前兩欄是狀態碼,未 staged 的修改是
* 「空格 M」開頭,而 runGit 會 trim 掉輸出的前導空白——第一行的狀態欄會少一格,
* 切出來的檔名就少了第一個字元。改用兩個只印檔名的指令,不受 trim 影響。
*
* 未追蹤的那一份要單獨留著:提交時只有它們需要先 add。
* @returns {{changed: string[], untracked: Set<string>}}
*/
function changedFiles(git) {
// 已追蹤的改動:staged 與未 staged 都算,刪除與改名(列為一刪一增)也在內
const tracked = git('diff', '--name-only', 'HEAD').split('\n').filter((file) => file !== '');
const untracked = git('ls-files', '--others', '--exclude-standard')
.split('\n')
.filter((file) => file !== '');
return {
changed: [...new Set([...tracked, ...untracked])].sort(),
untracked: new Set(untracked),
};
}
/**
* 挑出這一次要處理的檔案。沒給 `--files` 就是全部。
* 指到沒有變更的檔案時報錯而不是略過——那多半是路徑打錯,默默少做一個檔案,
* 要等 PR 開出去才會有人發現。
*/
function selectFiles(changed, files) {
if (files === undefined) return changed;
const wanted = files.split(',').map((file) => file.trim()).filter((file) => file !== '');
const missing = wanted.filter((file) => !changed.includes(file));
if (missing.length > 0) {
throw new ScriptError(
'FILE_NOT_CHANGED',
`--files 指到的這幾個檔案沒有變更:${missing.join('、')};請確認路徑(相對於 repo 根)`,
);
}
return wanted.sort();
}
/** 把變更分成幾批,每批一個 commit */
function plan(changed, type, scope, subject, body) {
const batches = new Map();
for (const file of changed) {
const batchType = classify(file) ?? type;
if (!batches.has(batchType)) batches.set(batchType, []);
batches.get(batchType).push(file);
}
return ORDER.filter((batchType) => batches.has(batchType)).map((batchType) => {
const files = batches.get(batchType);
const first = `${batchType}(${scopeOf(files, scope)}): ${subject}`;
// 同一次變更的每一批共用同一段說明:它們是同一件事的不同面向
return { message: body === undefined ? first : `${first}\n\n${body.trim()}\n`, files };
});
}
/** 看路徑就能確定的類型;看不出來時回 null,由 --type 決定 */
function classify(file) {
return BY_PATH.find((rule) => rule.match(file))?.type ?? null;
}
/**
* 這一批的 scope。單檔時用檔名本身——它已經說明了改的是什麼;
* 多檔時檔名沒有共同答案,得由呼叫端給一個功能名。
*/
function scopeOf(files, scope) {
if (files.length === 1) return stemOf(files[0]);
if (scope === undefined) {
throw new ScriptError(
'SCOPE_REQUIRED',
`有一批是多檔(${files.join('、')}),scope 沒有辦法從檔名推得,請用 --scope 給一個功能名`,
);
}
return scope;
}
/**
* 檔名去掉所有副檔名。`claim.test.js` 的 scope 是 `claim` 而不是 `claim.test`——
* 既有歷史裡測試的 scope 就是它測的那個東西的名字。
* 隱藏檔(`.gitignore`)的開頭那一點是名字的一部分,不是副檔名。
*/
function stemOf(file) {
const name = basename(file);
const stem = name.startsWith('.') ? name.slice(1) : name;
return stem.split('.')[0] || stem;
}
function parseType(value) {
if (!TYPES.includes(value)) {
throw new ScriptError('BAD_TYPE', `--type 需為 ${TYPES.join('/')} 其中一個,收到的是 ${value}`);
}
return value;
}
/**
* 描述要有中文。這條規則擋的是「隨手寫一句英文」——日後回顧時看得懂的是中文,
* 而混用英文名詞(函式名、旗標名)本來就該保留原文,所以只要求含有中文,不是全中文。
*/
function parseSubject(value) {
const subject = value.trim();
if (subject === '') {
throw new ScriptError('BAD_SUBJECT', '--subject 不能是空的');
}
if (!/[一-鿿]/.test(subject)) {
throw new ScriptError(
'SUBJECT_NOT_CHINESE',
`--subject 要用繁體中文描述這次改了什麼,收到的是「${subject}」;` +
'夾雜英文的專有名詞沒問題,但整句英文日後回顧時讀起來最吃力',
);
}
return subject;
}