#!/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}} */ 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; }