安全地重写历史
每一次重写——commit --amend、reset、rebase、filter-repo——都会生成新的提交,并将分支指针从旧提交上移开。当没有其他人拥有旧提交时,这是无害的;但一旦有人拥有,它就会造成破坏:他们的克隆中仍然存在旧历史,下次他们推送或拉取时,历史会以重复或冲突的形式回来。因此,首要问题从来不是“如何做”,而是“谁还有这些提交?”
1. 决定:重写还是还原
git fetch --prune
git status --short --branch # 查看 ahead/behind 和脏状态
git branch -r --contains <commit> # 检查哪些远程分支已经包含此提交
git log --oneline @{u}..HEAD # 仅存在于本地的提交
| 提交所在位置 | 操作 |
|---|---|
| 仅本地,从未推送 | 自由重写。 |
| 推送到只有当前用户操作的分支(例如其 PR 分支) | 重写,然后使用 lease 推送(步骤 5)。 |
在共享或受保护的分支上:main、master、develop、release/* 或任何其他人在其上叠加了提交的分支 | 不要重写。 使用 git revert。 |
| 已发布或部署 | git revert。重写无法撤销部署。 |
当用户说“从 main 中删除那个提交”时,他们通常希望其效果消失,而不是其 SHA。git revert <sha> 可以做到这一点,保持历史诚实,并且只需要一次普通推送。对于合并提交,使用 git revert -m 1 <merge-sha>。说明你为什么选择还原,如果用户坚持从共享分支中擦除它,请说明这会影响到谁,并先获得明确的同意。
当后续提交编辑了相同行时,还原会产生冲突——这正是提交建立在糟糕提交之上的情况。像处理任何冲突一样解决它(请参阅 git-conflict-resolution):删除糟糕的更改,保留后续工作,然后执行 git revert --continue。
一个还原陷阱:在还原合并之后,再次合并同一个分支不会带回任何东西,因为 Git 认为这些提交已经合并过。要重新引入这些工作,请先还原之前的还原。
2. 在处理任何内容之前,先设置一个恢复点
git rev-parse HEAD # 记下来
git branch backup/<branch>-pre-rewrite # 一个真正的引用,而不仅仅是 reflog
Reflog 也会记录分支当时的位置,但它仅本地存在于这个克隆中,会过期(可达条目 90 天,不可达条目默认 30 天),并且 reflog expire 后的 git gc 会结束它。一个命名的备份分支不花什么成本。在用户确认结果之前,保留它。
3. 选择破坏性最小的命令
| 目标 | 命令 | 备注 |
|---|---|---|
| 修复最后一次提交的信息或添加遗漏的文件 | git commit --amend | -m "..." 或 --no-edit 避免打开编辑器。 |
| 取消提交但保留已暂存的更改 | git reset --soft HEAD~1 | |
| 取消提交并取消暂存,保留文件 | git reset --mixed HEAD~1(默认) | |
| 将分支向后移动,拒绝丢失本地编辑 | git reset --keep <commit> | 如果会覆盖未提交的更改则中止。优先于 --hard。 |
| 同时也丢弃未提交的工作 | git reset --hard | 未提交的工作不在 reflog 中且无法恢复。 仅在确认那里没有任何想要的东西后执行;如果不确定,先执行 git stash -u。 |
| 丢弃对一个文件的更改 | git restore <file> | |
| 将修复压缩或折叠到早期的提交中 | git commit --fixup=<sha> 然后 autosquash | 见下文。 |
| 删除或重新排序提交 | rebase -i 并提供自定义的 todo | 见下文。 |
失败的预提交钩子意味着没有提交被创建。 运行 git commit --amend 来“重试”会修改前一个提交,将新更改错误地合并到其中。钩子失败后,修复问题,重新暂存,并创建一个正常的普通新提交。
无需交互式编辑器的交互式变基
git rebase -i 会打开一个编辑器,这在非交互式 shell 中会挂起。不要依赖它。两种可行的方法:
# 将修复提交折叠到它们的目标中——接受 Git 生成的 todo:
git commit --fixup=<target-sha>
GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash <base>
# 删除或重新排序:自己编写 todo。
GIT_SEQUENCE_EDITOR="sed -i '/^pick <short-sha> /d'" git rebase -i <base>
<base> 是你想要更改的第一个提交之前的那个提交。如果变基在冲突处停止,解决它,执行 git add,然后执行 GIT_EDITOR=true git rebase --continue。如果出错了,git rebase --abort 会返回到开始的地方。
4. 验证重写是否按预期更改了内容
信息编辑、重新排序或压缩应该使最终的树相同:
git diff backup/<branch>-pre-rewrite HEAD --stat # 空 = 内容相同
git range-diff backup/<branch>-pre-rewrite...HEAD # 逐提交比较
如果你打算删除内容,diff 应该恰好显示这些内容,没有别的。任何意外都意味着从备份恢复并重做。
5. 发布——仅在用户同意后进行
推送重写是面向外部、难以逆转的步骤。除非用户已经要求推送,否则先确认。
git push --force-with-lease=<branch>:<expected-remote-sha> --force-if-includes origin <branch>
- 永远不要使用裸的
--force。 它会覆盖远程上的任何内容,包括你未看到的队友的推送。 - 指定预期的 SHA。 单纯的
--force-with-lease会与你的远程跟踪 ref 进行比较,而 IDE 的后台git fetch会悄悄更新该 ref,因此 lease 实际上保护不了什么。使用你在步骤 1 中看到的 SHA(git rev-parse origin/<branch>)。 --force-if-includes(Git 2.30+)额外拒绝在远程移动到你的本地分支从未集成过的内容时执行推送。- 永远不要强制推送
main/master/develop/release/*。 如果托管平台的规则阻止了它,那这条规则就是正确的;不要绕过它。
推送后,告诉用户任何签出了旧分支的人都必须重置到新分支,而不是拉取,并且打开的 PR 的审查评论可能会失去其锚点。
安全护栏
- 签名提交在重写后会丢失签名; 使用
-S重新签名或告知用户。 - 不要重写他人创作的提交,即使是在功能分支上,未经询问也不行。
- 被重写分支上方的堆叠分支现在已过时。 使用
git-stacked-pr-rebase重新堆叠它们,而不是手动逐个变基。 - 已提交的机密无法通过重写修复。 首先轮换凭据;假设它已被泄露。
- 如实报告: 旧的 tip 是什么,新的 tip 是什么,备份 ref 在哪里,以及是否有任何未推送的内容。如果还原是正确的选择,说明你已经这样做了。