提交历史重写
重写现有仓库的提交历史,使其符合约定式提交规范。分析提交,按类型分类,识别压缩候选,并在完全安全门控下执行重写。
概述
历史即文档。 main 分支上的每一条提交消息都是一份永久记录,未来工程师(以及 AI 智能体)会用它来理解什么发生了变化以及为什么发生。像 "fix"、"update"、"wip" 这样的消息会破坏这份文档。Release-please 通过解析提交消息来确定版本号——不符合规范的消息会导致错误的版本或不产生任何发布。
代码是负债。 Hyrum 定律同样适用于 Git 历史:当有足够多的工具依赖提交消息(变更日志、发布自动化、git bisect、git blame)时,这些消息的格式就成了一种契约。在采用自动化之前清理历史,可以防止旧消息污染所有下游工具。
本技能重写的是消息,而非代码。重写前后的文件树必须完全一致——只有提交元数据会发生变化。
何时使用
- 在一个存在非规范历史的仓库中采用约定式提交
- 提交日志被 WIP、fixup、合并或无意义消息污染
- 为启用 release-please 做准备(它需要约定式提交才能正常工作)
- 在开源或转移仓库之前进行清理
何时不使用
- 仓库已经使用了约定式提交(无需重写)
- 共享分支上有活跃协作者且尚未通知他们
- 只需要修复最后几个提交(直接使用
git rebase -i HEAD~N) - 仓库是上游控制提交格式的分支
流程
第 1 步:评估仓库
# 统计总提交数
git rev-list --count HEAD
# 显示提交消息摘要
git log --oneline --no-merges
# 检查协作者(作者多样性)
git shortlog -sne --no-merges
# 确定合并提交密度
git log --merges --oneline | wc -l
决策点: 如果提交数 < 50,使用交互式变基。如果 >= 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="" <哈希值>
| 文件模式 | 分配的类型 |
|---|---|
仅 *.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" | 压缩到前一个提交中 |
| 合并提交(来自非压缩合并) | 压缩候选——评估变更是否已在 main 分支上 |
对于模糊的提交,查看完整 diff(git show <哈希值>)以根据内容确定类型。
第 3 步:编写高质量消息
重写的消息必须遵循提交消息标准:
<类型>[可选范围]: <简短祈使句描述>
[可选正文,解释为什么,而非是什么]
好的重写:
"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-<时间戳>
⚠ 重写后需要强制推送
⚠ 所有协作者在推送后必须重新克隆或重置
未更改的内容:
- 文件树(零内容变更)
- 每个提交的作者、邮箱和日期
- 提交顺序和父子关系(除压缩的提交外)
在得到用户明确批准后再进行。 不要因为“听起来不错”就继续——需要明确的“是”或等同的确认。
第 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:交互式变基(提交数 < 50)
# 从根开始变基
git rebase -i --root
在编辑器中:
- 将压缩候选标记为
squash或fixup - 将需要重写消息的提交标记为
reword - 保持提交顺序
作者保留: 交互式变基默认保留作者姓名、邮箱和日期。不要使用 --reset-author。除非用户特别要求,否则不要使用 --committer-date-is-author-date。
选项 B:git-filter-repo(提交数 >= 50)
创建一个 Python 回调脚本(rewrite-messages.py),将旧消息映射到新消息:
import re
REWRITES = {
b'旧哈希前缀': b'新消息',
# ... 根据计划生成
}
def rewrite(commit, metadata):
# 根据原始消息或哈希查找
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 或作者重写标志。
压缩提交
对于压缩候选,将它们合并到最近的有意义的提交中。最终提交:
- 使用有意义提交的约定式提交消息
- 保留有意义提交的原始作者(而非压缩目标)
- 合并压缩范围内的所有变更
第 7 步:验证
运行所有验证检查。每一项检查都必须通过才能继续推送。
# 1. 查看重写后的日志
git log --oneline --no-merges
# 2. 验证所有消息是否符合约定式提交
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
# 这些应该完全相同
# 至少保留备份分支 2 周,之后删除
# git branch -D backup/pre-rewrite-*
# git push origin --delete backup/pre-rewrite-*
常见疑虑与事实
| 常见疑虑 | 事实 |
|---|---|
| "旧历史不重要" | Release-please 解析提交消息以确定版本。非规范的历史会导致错误的版本或不产生发布。git bisect、git blame 和 git log --grep 都依赖于有意义的消息。 |
| "我从这个提交开始重新开始" | 你会丢失出处、作者信息和上下文。--orphan 分支会破坏开发叙事。重写能保留除坏消息之外的所有内容。 |
| "重写风险太大" | 备份分支使这个过程完全可逆。验证 diff 显示内容零变更。如果出问题:git reset --hard backup/pre-rewrite-*。 |
| "等真正需要的时候再重写" | 随着时间的推移,历史重写会越来越难,因为更多人克隆并拉出了分支。立即执行,在产生更多下游依赖之前。 |
| "强制推送很危险" | --force-with-lease 保护你不会覆盖他人的工作。结合备份分支,这是安全的。 |
| "消息差不多就行了" | "差不多"意味着 release-please 无法解析它们。约定式提交是严格的格式——要么消息符合规范,要么不符合。 |
| "我可以手动标记版本" | 手动标记会漂移、被遗忘,并且缺少变更日志集成。整个意义在于自动化——但自动化需要可解析的输入。 |
危险信号
- 跳过创建备份分支
- 使用
--force而不是--force-with-lease - 重写其他人正在积极工作的分支且未通知
- 第 7 步的 diff 中显示内容差异(意味着重写更改了代码,而不仅仅是消息)
- 重写后作者数量发生变化(意味着作者身份未被保留)
- 未获得用户批准重写计划就继续执行
- 重写后的消息仍然无意义(例如
"chore: stuff") - 跨不同作者压缩提交前未进行讨论
- 重写后未保留备份分支至少 2 周