Commit History Rewrite
重寫現有儲存庫的提交歷史,使其符合 Conventional Commits 規範。分析提交、依類型分類、識別需要壓縮的候選提交,並在完整的安全檢查機制下執行重寫。
概述
歷史即文件。 master 分支上的每則提交訊息,都是未來工程師(以及 AI 代理)用來了解做了什麼以及為什麼這麼做的永久記錄。像「fix」、「update」、「wip」這類訊息會破壞這份文件。Release-please 會解析提交訊息來決定版本號的變動——不符合規範的訊息會導致錯誤的發佈或根本無法發佈。
程式碼是負債。 Hyrum 定律同樣適用於 Git 歷史:當有太多工具依賴提交訊息(例如變更日誌、發佈自動化、git bisect、git blame),這些訊息的格式就成了一種契約。在採用自動化工具之前清理歷史,可以避免舊訊息污染所有下游工具。
此技能重寫的是訊息,而非程式碼。前後的檔案樹必須完全相同——只有提交的中繼資料會改變。
使用時機
- 在已有不符合規範歷史的儲存庫中導入 Conventional Commits
- 提交日誌被 WIP、fixup、合併或無意義的訊息污染
- 準備讓儲存庫搭配 release-please(需要 Conventional Commits 才能運作)
- 開源或轉移儲存庫前進行清理
請勿使用
- 儲存庫已使用 Conventional Commits 發佈(無需重寫)
- 共用分支上有活躍的合作者,且尚未通知他們
- 只需要修正最後幾個提交(直接使用
git rebase -i HEAD~N) - 儲存庫是上游控制提交格式的分支(fork)
流程
步驟 1:評估儲存庫
# 計算總提交數
git rev-list --count HEAD
# 顯示提交訊息摘要
git log --oneline --no-merges
# 檢查合作者(作者多樣性)
git shortlog -sne --no-merges
# 識別合併提交密度
git log --merges --oneline | wc -l
決策點: 如果提交數 < 50,使用互動式 rebase。如果 >= 50,使用 git-filter-repo。
步驟 2:分類現有提交
使用兩種訊號分析每個提交:訊息文字與變更的檔案。當訊息不明確時(例如「update stuff」),diff 是真相的來源。
訊號 1:訊息內容
| 訊息中的訊號 | 指派類型 |
|---|---|
| 「add」、「new」、「implement」、「create」、「introduce」、「support」 | feat |
| 「fix」、「bug」、「patch」、「resolve」、「correct」、「handle error」 | fix |
| 「refactor」、「restructure」、「reorganize」、「simplify」、「extract」、「move」 | refactor |
| 「update dep」、「bump」、「upgrade」、「version」、「config」、「setup」 | chore |
| 「test」、「spec」、「coverage」、「assert」 | test |
| 「doc」、「readme」、「comment」、「typo in doc」 | docs |
| 「ci」、「workflow」、「pipeline」、「action」、「deploy」 | ci |
訊號 2:變更的檔案(當訊息不明確時)
git show --stat --format="" <hash>
| 檔案模式 | 指派類型 |
|---|---|
僅 *.md、docs/** | docs |
僅 *test*、*spec*、__tests__/** | test |
僅 .github/workflows/**、.gitlab-ci.yml、Jenkinsfile | ci |
僅 package.json、*.lock、requirements.txt、Cargo.toml | chore |
僅設定檔(.eslintrc、tsconfig.json、.prettierrc) | chore |
壓縮候選提交
| 訊號 | 動作 |
|---|---|
| 訊息為「WIP」、「wip」、「temp」、「save」、「checkpoint」、單字或空白 | 壓入最近的語意化提交 |
| 訊息為「fixup」、「fix typo」、「oops」、「lint」、「formatting」 | 壓入前一個提交 |
| 合併提交(來自非 squash 合併) | 壓入候選 — 評估內容是否已在 master 分支上 |
對於不明確的提交,檢查完整 diff(git show <hash>)以根據內容決定類型。
步驟 3:撰寫高品質訊息
重寫後的訊息必須遵循提交訊息標準:
<type>[optional scope]: <short imperative description>
[optional body explaining WHY, not what]
好的改寫範例:
"initial setup" → "chore: initial project setup"
"added login" → "feat: add user login"
"fix bug" → "fix: resolve login redirect loop"
"Update README.md" → "docs: update README with setup instructions"
"updated deps" → "chore(deps): update dependencies"
壞的改寫範例(不要這樣做):
"stuff" → "chore: stuff" ← 仍然無意義
"changes" → "feat: changes" ← 沒有描述
"fix" → "fix: fix" ← 冗餘
當原始訊息無意義時,閱讀 diff 來撰寫合適的描述。如果 diff 太大或不明確,詢問使用者該提交的內容。
步驟 4:呈現重寫計畫
在任何破壞性操作之前,向使用者顯示完整的計畫:
重寫計畫
============
總提交數:47
需要重寫的提交:32
需要壓縮的提交:8
已符合規範的提交:7
保留的作者:3 位(Alice <alice@co>、Bob <bob@co>、CI Bot <bot@co>)
建議的變更:
abc1234 "initial setup" → "chore: initial project setup"
def5678 "added login" → "feat: add user login"
ghi9012 "wip" → [壓入 def5678]
jkl3456 "fix bug" → "fix: resolve login redirect loop"
mno7890 "Update README.md" → "docs: update README with setup instructions"
pqr1234 "feat: add search" → [保留 —— 已符合規範]
...
安全措施:
✓ 備份分支:backup/pre-rewrite-<timestamp>
⚠ 重寫後需要強制推送
⚠ 所有合作者必須在推送後重新複製或重置
不變的項目:
- 檔案樹(零內容變更)
- 每個提交的作者姓名、電子郵件與日期
- 提交順序和父子關係(壓縮的提交除外)
在進行之前等待使用者明確同意。 不要因為「聽起來不錯」就繼續——需要使用者輸入「yes」或同等確認。
步驟 5:建立備份
# 在目前 HEAD 建立備份分支
git branch "backup/pre-rewrite-$(date +%Y%m%d-%H%M%S)"
# 確認備份存在
git branch -l 'backup/*'
# 選擇性地將備份推送到遠端以增加安全性
git push origin "backup/pre-rewrite-$(date +%Y%m%d-%H%M%S)"
步驟 6:執行重寫
選項 A:互動式 Rebase(< 50 個提交)
# 從根提交開始 rebase
git rebase -i --root
在編輯器中:
- 將壓縮候選提交標記為
squash或fixup - 將需要改寫訊息的提交標記為
reword - 保留提交順序
作者保留: 互動式 rebase 預設會保留作者姓名、電子郵件和日期。除非使用者特別要求,否則不要使用 --reset-author。除非明確要求,否則不要使用 --committer-date-is-author-date。
選項 B:git-filter-repo(>= 50 個提交)
建立 Python 回呼腳本(rewrite-messages.py),將舊訊息對應到新訊息:
import re
REWRITES = {
b'old hash prefix': b'new message',
# ... 從計畫產生
}
def rewrite(commit, metadata):
# 依原始訊息或 hash 查詢
if commit.original_id in REWRITES:
commit.message = REWRITES[commit.original_id]
使用以下指令套用:
git filter-repo --commit-callback "$(cat rewrite-messages.py)" --force
作者保留: git-filter-repo 預設會保留作者身份。除非使用者明確要求,否則不要使用 --mailmap 或作者重寫旗標。
壓縮提交
對於壓縮候選提交,將其合併到最近的語意化提交中。結果提交:
- 使用語意化提交的 Conventional Commit 訊息
- 保留語意化提交的原始作者(而非壓縮目標)
- 合併壓縮範圍內的所有變更
步驟 7:驗證
執行所有驗證檢查。每個檢查都必須通過才能繼續推送。
# 1. 檢視重寫後的日誌
git log --oneline --no-merges
# 2. 驗證所有訊息都符合 Conventional Commits 格式
FAILURES=$(git log --format="%s" --no-merges | grep -cvE "^(feat|fix|docs|chore|refactor|test|ci)(\(.+\))?!?: .+")
if [[ "$FAILURES" -gt 0 ]]; then
echo "失敗:發現 $FAILURES 個不符合規範的提交:"
git log --format="%h %s" --no-merges | grep -vE "^[a-f0-9]+ (feat|fix|docs|chore|refactor|test|ci)(\(.+\))?!?: .+"
exit 1
fi
echo "通過:所有提交均符合規範"
# 3. 驗證零內容變更(關鍵——這證明只有訊息改變了)
DIFF=$(git diff "backup/pre-rewrite-"*..HEAD --stat)
if [[ -n "$DIFF" ]]; then
echo "失敗:檢測到內容差異——重寫改變了程式碼,而不只是訊息"
echo "$DIFF"
exit 1
fi
echo "通過:檔案樹完全相同"
# 4. 驗證作者保留
echo "=== 重寫前的作者 ==="
git -C . log backup/pre-rewrite-* --format="%an <%ae>" --no-merges | sort -u
echo "=== 重寫後的作者 ==="
git log --format="%an <%ae>" --no-merges | sort -u
# 這兩個清單必須完全相同
如果任一檢查失敗,請勿推送。 重置到備份分支並調查:
git reset --hard backup/pre-rewrite-*
步驟 8:推送(需經確認)
這對遠端協作者來說是破壞性操作。 與使用者確認:
⚠ 強制推送警告
分支:main
遠端:origin
動作:git push --force-with-lease origin main
推送後,所有協作者必須執行:
git fetch origin
git reset --hard origin/main
請輸入「yes」以繼續:
git push --force-with-lease origin main
使用 --force-with-lease(而非 --force)以防止覆蓋並發的遠端變更。如果 --force-with-lease 被拒絕,表示有人在你重寫後推送到了該分支——在重試前先調查。
重寫後清理
在成功推送並通知協作者後:
# 確認遠端與本地一致
git log --oneline -5
git log --oneline -5 origin/main
# 兩者應相同
# 保留備份分支至少兩週,之後再刪除
# git branch -D backup/pre-rewrite-*
# git push origin --delete backup/pre-rewrite-*
理由說明
| 合理化 | 現實 |
|---|---|
| 「舊的歷史不重要」 | Release-please 會解析提交訊息來決定版本。不符合規範的歷史會導致錯誤或無法發佈。git bisect、git blame 和 git log --grep 都依賴有意義的訊息。 |
| 「我從這個提交開始重新來過就好了」 | 你會失去 blame、作者資訊和脈絡。--orphan 分支會破壞開發敘事。重寫會保留一切,除了壞訊息。 |
| 「重寫歷史太冒險了」 | 備份分支讓這個過程完全可逆。驗證 diff 顯示零內容變更。如果出錯:git reset --hard backup/pre-rewrite-*。 |
| 「等以後需要時再重寫」 | 歷史重寫會隨著時間推移,更多人複製和分支而變得越來越困難。立即執行,在下游相依性出現之前。 |
| 「強制推送很危險」 | --force-with-lease 可以防止覆蓋他人的工作。搭配備份分支,這是安全的。 |
| 「訊息差不多就行了」 | 「差不多」代表 release-please 無法解析它們。Conventional Commits 是一種嚴格的格式——訊息要嘛符合,要嘛不符合。 |
| 「我手動標記版本發佈就好了」 | 手動標記會偏移、被遺忘,而且缺少變更日誌整合。重點是全自動化——但自動化需要可解析的輸入。 |
紅旗標誌
- 跳過建立備份分支
- 使用
--force而非--force-with-lease - 在沒有通知的情況下重寫其他人正在積極開發的分支
- 步驟 7 的 diff 中出現內容差異(表示重寫改變了程式碼,而不只是訊息)
- 重寫後作者數量改變(表示作者身份未被保留)
- 在未經使用者批准重寫計畫的情況下繼續操作
- 重寫後的訊息仍然無意義(例如「chore: stuff」)
- 未經討論就壓縮跨不同作者的提交
- 重寫後未保留備份分支至少兩週