fix(commit-split): 改名時別漏掉舊檔的刪除,失敗時說出做到哪裡

git diff --name-only 預設偵測改名,只印出目的地那一個路徑。來源的刪除因此被漏掉——
留在 index 裡沒被提交,而腳本還回報成功,要等下一次跑才會發現工作區不乾淨。加 --no-renames。

某一批提交失敗時,錯誤現在會列出前面已經建立的那幾顆 commit。不回捲它們:那會動到使用者的
歷史,而那幾顆本身是好的;但一定要說出做到哪裡,否則重跑前得自己去翻 git log。

類型對照表補上目標專案常見的測試擺法:tests/、spec/、__tests__/,以及放在被測檔案旁邊的
user.test.js。先前只認 test/,目標專案的測試會被併進 feat 那一批。
This commit is contained in:
2026-09-17 08:23:23 +00:00
parent 513dad7f6a
commit f99adab245
+36 -17
View File
@@ -20,9 +20,8 @@
* [--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';
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'];
@@ -34,7 +33,13 @@ const TYPES = ['feat', 'fix', 'refactor', 'test', 'docs', 'chore', 'perf', 'styl
* 都是產品本身,是新增還是修正得由做的人說,所以不在這張表裡——它們吃 `--type`。
*/
const BY_PATH = [
{ type: 'test', match: (path) => path.startsWith('test/') },
{
// 目標專案的測試未必放在 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',
@@ -57,11 +62,7 @@ main(async () => {
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 git = openGitRepo(path);
const { changed, untracked } = changedFiles(git);
if (changed.length === 0) {
throw new ScriptError('NOTHING_TO_COMMIT', `${path} 的工作區是乾淨的,沒有東西可以提交`);
@@ -73,13 +74,28 @@ main(async () => {
return { dryRun: true, path, commits };
}
const done = [];
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);
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 })) };
});
@@ -96,8 +112,11 @@ main(async () => {
* @returns {{changed: string[], untracked: Set<string>}}
*/
function changedFiles(git) {
// 已追蹤的改動:staged 與未 staged 都算,刪除與改名(列為一刪一增)也在內
const tracked = git('diff', '--name-only', 'HEAD').split('\n').filter((file) => file !== '');
// --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 !== '');