diff --git a/scripts/commit-split.js b/scripts/commit-split.js new file mode 100644 index 0000000..7d1ad15 --- /dev/null +++ b/scripts/commit-split.js @@ -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}} + */ +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; +}