cherry-pick 与 revert:同一台引擎
这一章几乎不需要新知识。你会发现 cherry-pick、revert、rebase 三个命令共用一台引擎——「算出一个提交带来的改动,在别处重新应用一遍」。差别只在挑几个、往哪儿放、正着用还是反着用。
那台引擎
第 18 章拆 rebase 时,核心那一步是:
算出提交 X 相对它父提交的 diff,把这个 diff 应用在当前位置,造一个新提交。
这句话原样搬过来,就是 cherry-pick 的定义。
| 命令 | 挑几个 | 方向 | 结束后动贴纸吗 |
|---|---|---|---|
cherry-pick X | 1 个 | 正向 | 当前分支往前长一个 |
cherry-pick A..B | 一串 | 正向 | 往前长一串 |
revert X | 1 个 | 反向 | 往前长一个 |
rebase | 一串 | 正向 | 把分支贴纸挪到重放结果上 |
所以:rebase ≈ 「先 cherry-pick 一串,最后把贴纸挪过去」。
这不是打比方——Git 内部的 rebase 实现(--merge 后端,现在是默认)真的就是循环调用 cherry-pick 的那套逻辑。
两个按钮切着看。注意上面那段补丁的加号减号方向——revert 那边是反的。
再看下面的图:两个操作都是「往前加一个新提交」,没有任何东西被删除。
cherry-pick:把一个改动搬到别处
$ git cherry-pick a3f21c9
典型场景:你在开发分支上修了个 bug,但这个修复得赶紧上线。而开发分支上还有一堆没做完的东西,不能整个合过去。
$ git switch hotfix-1.2 $ git cherry-pick a3f21c9 # 只把那个修复搬过来 $ git push
常用的几个参数:
$ git cherry-pick A..B # 搬一串(不含 A,含 B) $ git cherry-pick A^..B # 搬一串(含 A) $ git cherry-pick -n X # 只应用改动,不自动提交(让你先改改) $ git cherry-pick -x X # 在提交信息里注明「cherry picked from X」
-x 很值得养成习惯。它会在新提交的信息里加一行:
(cherry picked from commit a3f21c9e77b0d4c8135e6a9f02b4d7e8c1a5b3d6)
因为 cherry-pick 出来的提交在图上和原提交毫无关系(第 15 章:DAG 里没有边连着它们)。半年后有人问「这个改动是哪来的」,这一行就是唯一的线索。
你把 X cherry-pick 到 hotfix 分支,得到 X′。现在库里有两个内容相同、哈希不同的提交。
将来把 hotfix 合回主干时会发生什么?
通常没事。三路合并会发现「两边做了同样的改动」,按第 17 章的规则二,不冲突。
但如果之后有人又改了那块代码,两边的演化路径不同,就可能冒出一个看起来毫无道理的冲突——因为 base 里那个改动只出现了一次,而两边都有它的痕迹。
Git 有个命令能帮你查这类重复:
$ git cherry -v main hotfix # - 开头 = 这个改动主干已经有了(内容等价) # + 开头 = 主干还没有
它比的是补丁的内容(patch-id),不是哈希——所以能识别出 cherry-pick 过去的「同一个改动」。
revert:往前走一步来抵消
$ git revert a3f21c9
它算出 X 的 diff,反过来(加变删、删变加),应用在当前位置,造一个新提交。
reset: A ← B ← C 贴纸从 C 撕回 A
↑ B、C 变孤儿 —— 历史被改写
main → A
revert: A ← B ← C ← ~C 造新提交 ~C,内容是「把 C 反过来做」
↑ B、C 一个没动 —— 历史完整保留
main
reset 往后退,revert 往前走。
这个方向差异决定了适用场合:
- 提交还没推出去 → 两个都行,
reset更干净。 - 提交已经推出去了 → 只能
revert。
因为 revert 不改写任何已存在的历史——别人拉下来只是多一个提交,不会有任何冲突或混乱(第 18 章那个连锁反应完全不会发生)。
这是唯一一种对协作安全的「撤销」。
revert 一个合并提交
这是最容易出事的地方,值得单独讲。
合并提交有两个父提交(第 15 章)。那么「把它的改动反过来」——相对哪个父提交?
Git 不猜,要你明说:
$ git revert -m 1 <合并提交> # 相对第一个父提交(通常是主干那一侧)
-m 1 的意思是「保留第一个父提交那条线,撤销第二条线带来的东西」。
这是个很有名的坑,值得完整理解。
你 merge 了 feat,然后发现有问题,revert -m 1 撤销了。后来 feat 修好了,你想再合一次——Git 说「Already up to date」,什么都没合进来。
为什么?因为在 DAG 上,feat 的那些提交确实已经是主干的祖先了(那次 merge 建立了这个关系,而 revert 没有拆掉它)。三路合并一看:「这些改动 base 里就有,你这边没变,那就不用动」——于是什么都不做。
那个 revert 提交抵消了内容,但没有抵消图上的关系。
两种解法:
# 解法一:把那个 revert 再 revert 回来(推荐) $ git revert <那个 revert 提交> # 然后正常合并后续的新提交 # 解法二:重建分支,让它的提交变成「新的」(哈希不同) $ git rebase --onto main <老的分叉点> feat
这个坑的根源,还是那句主线:合并关系记录在 DAG 里,而 revert 只动内容不动图。Git 官方文档专门为它写过一篇长文(howto/revert-a-faulty-merge),可见坑了多少人。
验证「cherry-pick 出来的是全新对象」:
$ git switch hotfix $ git cherry-pick a3f21c9 # 新提交的哈希,和原来完全不同 $ git rev-parse HEAD $ git rev-parse a3f21c9 # 但它们带来的改动是一样的 —— 用 patch-id 验证 $ git show HEAD | git patch-id $ git show a3f21c9 | git patch-id
patch-id 是「补丁内容的哈希」,它忽略行号、上下文和提交元信息。所以内容等价的两个提交,patch-id 相同,哪怕提交哈希天差地别。
这正是 git cherry 和 rebase 用来判断「这个提交是不是已经在上游了」的机制——rebase 会自动跳过那些上游已经有等价改动的提交,你可能都没注意到。
操作进行中的状态文件:
$ ls .git/CHERRY_PICK_HEAD .git/REVERT_HEAD 2>/dev/null
和第 17 章的 MERGE_HEAD 一样,它们存在就表示「操作进行中」。三条出路也一样:
$ git cherry-pick --continue $ git cherry-pick --skip $ git cherry-pick --abort # 安全退回
「算出补丁、在别处重放」这套机制,Git 里还有几个直接的用户界面:
git format-patch/git am——把提交导出成邮件格式的补丁文件,别人am一下就应用了。Linux 内核几十年就是这么协作的:没有 PR,全靠邮件列表发补丁。这也是第 4 章 author 和 committer 分开的原因。git rebase --onto——「把这一段提交,从这个地基搬到那个地基」。用来把一个基于错误分支开出来的分支救回来:# feat 本该基于 main,却基于了 old。把它挪对 $ git rebase --onto main old feat
git apply——只应用补丁,不造提交。想手工调整之后再提交时用。
这几个命令看起来八竿子打不着,但底下都是同一件事:提交本身不重要,重要的是它带来的那个改动——而改动是可以脱离原来的位置、被重新应用到任何地方的。
理解了这一点,你会发现 Git 有两套并行的世界观:「快照的世界」(对象、树、DAG)和「补丁的世界」(diff、cherry-pick、patch-id)。前者是存储模型,后者是操作模型。而连接两者的桥梁,就是第 14 章那台差分引擎。
「线上出问题了,要紧急回滚昨天上线的那个功能(一共 6 个提交)。」
主干是共享的,所以只能 revert,不能 reset。做法:
# 如果那 6 个提交是通过一个 merge 提交合进来的(最常见) $ git revert -m 1 <那个 merge 提交> # 如果是 6 个独立的提交,倒序 revert(先撤最新的) $ git revert --no-commit HEAD~5..HEAD $ git commit -m "回滚 XX 功能:线上出现 YY 问题"
--no-commit 让 6 次撤销合成一个提交,历史上更清楚——「这是一次回滚」而不是「六次莫名其妙的改动」。
注意倒序。后面的提交可能依赖前面的,反着撤才不会打不上补丁。HEAD~5..HEAD 这个写法 Git 会自动按正确顺序处理。
然后立刻记一笔:如果这个功能之后要重新上线,务必记住上面那个坑——revert 了 merge 之后,直接再 merge 是合不进来的,得先 revert 那个 revert。把这句话写进回滚记录里,能省下未来某个人半天的困惑。
这一章的一句话
cherry-pick、revert、rebase 是同一台引擎的三个出口:算出一个提交带来的 diff,在别处重新应用。rebase 是「连续 cherry-pick 一串再挪贴纸」,revert 是「把补丁反过来用」。三者都只造新提交、不删旧的,所以 revert 是唯一对协作安全的撤销——但要小心 revert 合并提交,它抵消了内容却没抵消图上的关系。
下一章:卷 IV 收尾。把所有危险操作摆在一起,配上救援方法,并给出那条贯穿全书的判据——「它有没有被写成过对象?」