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 的修改會少掉檔名的第一個字元。
This commit is contained in:
@@ -0,0 +1,202 @@
|
|||||||
|
#!/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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user