Files
tea-sdlc/scripts/commit-split.js
jiantw83 f99adab245 fix(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:23 +00:00

222 lines
9.1 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.
#!/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 { basename } from 'node:path';
import { ScriptError, main, openGitRepo, parseFlags } from './lib.js';
/** commit 訊息的類型。與既有 git 歷史一致,不另立新詞。 */
const TYPES = ['feat', 'fix', 'refactor', 'test', 'docs', 'chore', 'perf', 'style'];
/**
* 從檔案路徑看得出來的類型。由上往下比對,第一個命中的為準。
*
* 只列「看路徑就能確定」的那幾種。`scripts/`、`prompts/`、`references/`、`templates/`
* 都是產品本身,是新增還是修正得由做的人說,所以不在這張表裡——它們吃 `--type`。
*/
const BY_PATH = [
{
// 目標專案的測試未必放在 test/:tests/、spec/、__tests__/ 都常見,
// 也常見把 user.test.js 放在被測檔案旁邊
type: 'test',
match: (path) =>
/(^|\/)(tests?|spec|__tests__)\//.test(path) || /\.(test|spec)\.[^./]+$/.test(path),
},
{ 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);
const git = openGitRepo(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 };
}
const done = [];
for (const { message, files } of commits) {
try {
// 只有未追蹤的檔案需要先 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);
done.push(message);
} catch (cause) {
// 不回捲已經建立的 commit:那會動到使用者的歷史,而這幾顆本身是好的。
// 但一定要說出做到哪裡,否則重跑前得自己去翻 git log。
throw new ScriptError(
'COMMIT_FAILED',
`這一批提交失敗:${message.split('\n')[0]}(${cause.message})。` +
(done.length > 0
? `在此之前已經建立:${done.map((m) => m.split('\n')[0]).join('、')};` +
'修掉原因之後重跑即可,已建立的那幾顆不會重複。'
: '還沒有任何 commit 被建立。'),
);
}
}
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) {
// --no-renames 是必要的:git 預設偵測改名,只印出目的地那一個路徑,
// 來源的刪除就會被漏掉——留在 index 裡沒被提交,而腳本還回報成功
const tracked = git('diff', '--name-only', '--no-renames', '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;
}