Git revert 从逆向补丁到合并主线
先定位当前任务,再对照操作前提、结果与容易混淆的边界。
章节速览8 节 · 展开选择
差异审查让我们看清一次提交改了什么,bisect帮助定位行为改变的位置。找到问题提交之后,还有一步:怎样撤销它的影响,同时保留后来已经完成的其他工作?
git revert 的思路是向前增加一份逆向修改。理解它时,要同时看内容和祖先关系:内容可以被反向修改,旧提交仍留在历史里。合并提交有多个父版本,这个区别尤其重要。
明确目标提交相对哪个父版本引入了变化。
逆向修改应用到现在,独立的后续工作可以保留。
合并的内容被撤销,合并关系仍然存在。
先把提交和快照分开
在普通单父提交中,设 B 的父提交为 A。B 引入的变化来自 A 与 B 的比较;撤销 B 时,Git 尝试在当前内容上应用相反方向的变化,成功后通常记录一个新提交 R。这一行为由 git-revert 官方手册定义。
“相反方向”是理解差异的模型,不是把每一行当作独立加减数。当前版本可能已经移动、修改了相关内容,因此应用时可能需要合并,也可能产生冲突。即便没有文本冲突,后续代码仍可能在语义上依赖被撤销的功能。
本文只讨论已经提交的、明确选中的单个目标,练习文件都是已有普通文本文件。开始前先审查工作区与暂存区,确认干净,并检查目标涉及的全部路径与变更类型;若目标超出项目允许修改的范围,应先按项目规则处理。
两个文件的手算:后来的说明保留下来
初始提交 A 中,settings.txt 内容是 feature=off,note.txt 内容是 note=old。B 只把开关改为 on,C 只把说明改为 new。现在位于 C,选择撤销 B。
| 提交 | 父提交 | settings.txt | note.txt | 这一步的作用 |
|---|---|---|---|---|
| A | 无 | feature=off | note=old | 初始快照 |
| B | A | feature=on | note=old | 打开功能 |
| C | B | feature=on | note=new | 更新独立说明 |
| R | C | feature=off | note=new | 撤销 B 的影响 |
| RR | R | feature=on | note=new | 再撤销 R 的影响 |
R 和 A 都关闭功能,却有不同的说明。R 的父提交是 C,A、B、C 仍可沿祖先关系到达。即便某个例子的文件快照恰好与旧版本相同,新提交的身份与祖先也不因此相同。
最后一行 RR 是本例的“撤销撤销”:R 关闭了功能,撤销 R 就重新打开它。这里只因为改动彼此独立,结果可以直接手算;真实历史中间若有重叠修改,仍需处理冲突和行为依赖。
flowchart LR A[A 功能关 旧说明] --> B[B 功能开 旧说明] B --> C[C 功能开 新说明] C --> R[R 功能关 新说明] B -. 反向应用B的变化 .-> R R --> RR[RR 功能开 新说明]
一次单提交撤销的审查顺序
下面的 $target 是 PowerShell 变量,须替换为已经核对的完整提交 ID。A、B、C、R 是本文图中的标签,并非要求仓库里存在这些分支。先用只读命令确认目标;单父场景中的 ^ 表示第一父版本,语法见 gitrevisions 官方文档。
1 | $target = '替换为已核对的完整提交ID' |
本例的目标只涉及 settings.txt;其他目标应先查看它实际涉及的全部文件。确认父版本、内容与权限范围,再针对这一个提交执行:
1 | git revert --no-edit $target |
--no-edit 采用默认撤销说明,不启动提交说明编辑器。它没有跳过冲突处理、内容审查或测试。命令成功之后,应确认新提交的差异只表达预期撤销,并运行与被改行为相关的测试。若需要解释撤销原因,可以使用编辑提交说明的正常流程。
冲突之后,继续与中止分别做什么
把例子改成 A 的开关为 off、B 改为 on、C 又把同一行改为 experimental。此时撤销 B 需要把 on 反向改成 off,当前却已经是 experimental,不能假定 Git 会自动保留你想要的语义。
遇到冲突后,先确认当前进行的是这一次 revert,再检查未合并路径与差异:
1 | git status |
若决定完成撤销,通过编辑器解决 settings.txt 的冲突,明确保留哪部分当前行为。完成行为测试后,只暂存这个已审查的文件,再核对暂存差异:
1 | git add -- settings.txt |
--continue 继续正在进行的操作;它不会替你证明解法正确。若决定取消本次尚未完成的撤销,则使用 git revert --abort,回到本次操作开始前的状态。本次冲突处理期间的编辑也会随之撤回,需要保留的解法应事先另行记录。--quit 只是忘记进行中的操作状态,不能当作恢复开始状态的同义词。三者的区别见上述 revert 手册的 sequencer 小节。
自检:没有冲突,就能保证后续工作不受影响吗?
不能。两个文件的文本修改可以顺利合并,但后来的调用可能依赖被关闭的功能。无冲突只说明 Git 得到了可应用的文本结果;仍应检查行为、配置兼容性和相关测试。
合并提交为什么必须选择父版本
考虑一个两父合并 M:P1 更新说明,P2 打开功能,合并后两者都在。这里 P1、P2 是 M 的父列表顺序,不是分支名的排序,也不是图中位置决定的。
| 比较基线 | 基线内容 | M 相对它的变化 | 紧接 M 撤销后的内容 |
|---|---|---|---|
| 第一父 P1 | 关、新说明 | 功能关 → 开 | 关、新说明 |
| 第二父 P2 | 开、旧说明 | 说明旧 → 新 | 开、旧说明 |
git revert -m 1 M 选择第一父为比较基线,反向应用 P1→M;-m 2 选择第二父,反向应用 P2→M。这里的数字从 1 开始。选择的是保留哪条父基线的含义,不是“删除第几个父提交”。 结果仍有 M 作为祖先。Pro Git 的合并章节给出了同类解释。
实际操作先检查父列表与两份比较;以下仍是 PowerShell 的单个提交变量:
1 | $merge = '替换为已核对的合并提交ID' |
通常在某分支上执行普通合并时,第一父是合并前该分支的 tip,但不应仅凭分支名猜测。确认第一父确实是要保留的比较基线后,才针对这个合并执行 git revert --no-edit -m 1 $merge。不要把 -m 1 当成所有合并都适用的固定答案。
撤销合并之后,再合并为什么没有恢复旧功能
本例在 M 后创建 R,撤销 P1→M 的功能变化。P2 仍是 R 的祖先,因此再合并同一个 P2,不会把它当作从未合并的新历史。
如果 P2 后面新增 P3,只添加独立的 fix.txt,再次合并会带来这份新改动,旧功能不一定因此恢复。本例结果是“功能关、新说明、新修补文件”:修补文件到了,旧开关仍然关闭。祖先判断与内容撤销各自都在发挥作用。
flowchart TD
A[已合并 M 后创建撤销提交 R] --> B{准备合并的分支历史}
B -- 仍是已到达的 P2 --> C[没有新的祖先需要合入]
B -- 在 P2 上追加 P3 --> D[计算 P2 之后的新变化]
D --> E[新增修补可进入 旧内容不会自动补回]
B -- 需求是恢复被撤销内容 --> F[先检查是否应撤销 R]
F --> G[审查恢复结果 再合并后续修复]
当分支是在原有提交上追加修复,而且确实要恢复 R 撤掉的内容时,可以先审查并撤销 R 这个普通提交,然后再合并后续修复。此时通常不需要对 R 传 -m:R 撤销的是合并,但 R 自己只有一个父提交。
如果分支已经基于撤销后的主线重新实现功能,恢复 R 可能重复引入旧实现并造成冲突。这是另一种历史,应重新比较合并基线与实际差异。Git 项目自身的 revert-a-faulty-merge 说明专门区分了追加修复与重做历史,不能机械套用“再撤销一次”。
用真实 Git 核验教学历史
本文的验证位于 tests/verify-git-revert-article.cjs。它在新建的独立教学仓库中,用 Git 对象命令构造上述快照与父关系,再实际运行单提交 revert、两种主线撤销、冲突继续/中止和再次合并。所有实验目录均保留,不接触博客的部署仓库,不清理文件或缓存。
核验分别比较文件内容、新提交的父 ID、祖先可达性和工作区状态。随机案例只改变合成文件中的内容,用于检查独立后续修改的保留;它们不构成对所有合并策略、重命名、二进制文件或真实业务兼容性的保证。本文所有图都依据这些教学状态绘制,不把它们称为生产项目的恢复记录。
自检:合并的第一父是 P1,撤销时写 -m 1,是删除 P1 吗?
不是。它把 P1 作为比较基线,反向应用 P1 到 M 的变化。紧接 M 的简单例子中,结果内容等于 P1,但 M、P1、P2 都仍在历史中。若以后需要恢复原合入内容,还要同时考虑这份撤销提交与后续分支的祖先关系。
接着读
- Git 差异审查:先区分工作区、暂存区与提交,检查这次准备记录什么。
- Git bisect:先定位并核实问题提交,再决定修复或撤销的粒度。
- 开发工具分类与资源索引:按现有路线继续阅读。