merge-base:共同祖先怎么找
要合并两条线,光知道两个终点是不够的。你必须知道它们从哪儿分开的——不然分不清「这一行是我改的」还是「本来就长这样」。这一章找出那个点,顺便给「快进」下一个精确定义。
为什么两个终点不够
假设要合并 main 和 feat,某个文件在两边分别是:
main 这边: port = 3000 feat 这边: port = 8080
该取哪个?光看这两行没法回答。因为有两种完全不同的可能:
| 如果分开时是…… | 那么真相是 | 该取 |
|---|---|---|
port = 3000 | main 没动,feat 改成了 8080 | 8080(feat 的改动) |
port = 8080 | feat 没动,main 改成了 3000 | 3000(main 的改动) |
port = 80 | 两边都改了,改得还不一样 | 冲突,Git 不猜 |
合并需要三个输入,不是两个:
- ours —— 你这边现在的样子
- theirs —— 对方现在的样子
- base —— 你们分开时的样子
没有 base,你只能问「这两行哪个对」——那是个无解的问题。
有了 base,你问的是「谁动了它」——这是个有答案的问题。
而 base 从哪来?就是 merge-base。这一章找它,下一章用它。
找那个点
直觉上很简单:两条线最后一次还在一起的地方。
但要写成算法,得说得更严谨一点:
merge-base(A, B) = 一个提交 X,满足:
- X 是 A 的祖先,并且是 B 的祖先(是共同祖先)
- 没有别的共同祖先是 X 的后代(是「最近的」)
第 2 条是关键。共同祖先通常有很多个——在上面那张图里,A 和 B 都是共同祖先。但只有最靠下的那个才是我们要的,因为只有它才代表「最后一次还在一起」。
在图论里这叫 LCA(Lowest Common Ancestor,最近公共祖先)。
四种图景,切着看。这台引擎跑的是真的 LCA 算法(求两边祖先集合的交集,再排除掉那些「是别的共同祖先的祖先」的)。
第二个「快进」和第四个「交叉合并」是重点,它们各自揭示一件事。
快进的精确定义
点第二个场景时你应该看到了:merge-base 就是 main 自己。
这说明 main 没有任何 feat 不知道的提交——feat 完全「包含」了 main。
把贴纸从 A 挪到 B 是「快进」,当且仅当 A 是 B 的祖先。
等价的说法:merge-base(A, B) == A。
这时候「合并」根本不需要动脑子——没有任何需要调和的东西。B 已经包含了 A 的全部历史,把贴纸往前挪就行了。
所以快进合并不产生新提交,不需要三路合并,不可能有冲突。它就是一次贴纸移动(第 6 章:写 41 个字节)。
这个定义能一口气解释三件事:
一、为什么 push 会被拒绝
第 8 章那个 non-fast-forward:服务器只接受快进的推送。
因为如果不是快进,把贴纸挪过去就会让服务器上那些提交失去引用——而它们可能是别人推上去的(第 15 章:箭头只指向过去,没人指着就找不到了)。
二、--no-ff 是干什么的
$ git merge --no-ff feat
它强制造一个合并提交,哪怕本来可以快进。
为什么要这样?因为快进之后,「这里曾经有过一个分支」这个信息就消失了——历史变成一条直线,看不出哪几个提交属于同一个功能。
--no-ff 保留了这个结构。很多团队在合并 PR 时强制用它,就是为了让 git log --first-parent(第 15 章)能清晰地列出「主干上依次合入了哪些功能」。
三、git pull --rebase 为什么能让历史变干净
默认的 git pull 在你本地有提交时会产生一个合并提交(「Merge branch 'main' of ...」)。这类提交没有任何信息量,纯粹是噪音。
--rebase 则把你的提交重放到对方后面(第 18 章),于是变成快进,不产生合并提交。
$ git config --global pull.rebase true # 一次设好
(前提是你的提交还没推出去。这条规矩第 18 章会讲透。)
那个病态的情况:两个 merge-base
点第四个场景,你会看到一个奇怪的图:A 和 B 都被标成了 merge-base。
A ─┬─→ X (main) │ ╳ B ─┴─→ Y (feat) X 的父提交是 A 和 B Y 的父提交也是 A 和 B
A 和 B 都是共同祖先,而且谁也不是谁的祖先——没法分出「最近」。这叫交叉合并(criss-cross merge)。
怎么发生的?两个人各自把对方的分支合进了自己的分支,然后又要合并。在活跃的多人仓库里并不罕见。
Git 默认的合并策略叫 ort(Ostensibly Recursive's Twin,2021 年起取代了老的 recursive)。遇到多个 merge-base 时,它的做法是:
先把这些 base 两两递归合并,得到一个「虚拟 base」,再拿它去做三路合并。
这就是策略名里那个 recursive 的由来——合并的过程中可能递归地做合并。
这个设计很聪明,但它解释了一件常让人困惑的怪事:
为什么有时候合并会冒出你从没见过的冲突?
因为参照物是一个算出来的、历史上从未真实存在过的版本。你拿它跟两边比,得到的结论可能跟你的直觉对不上。
好消息是:你多半这辈子都不用主动处理它。但下次撞见一个莫名其妙的冲突时,记得跑一句 git merge-base --all main feat——如果它吐出不止一行,你就知道原因了。
直接查这些图性质:
# 找 merge-base $ git merge-base main feat 06b431f5f556d4b4d9b2f2fa4240e4d12fc6c975 # 如果有多个,全列出来 —— 输出超过一行就是交叉合并 $ git merge-base --all main feat # X 是 Y 的祖先吗(能不能快进) $ git merge-base --is-ancestor main feat && echo "能快进" # 两边各自独有几个提交 $ git rev-list --left-right --count main...feat 2 3
最后那个 ... 值得说清楚,因为它的定义正是基于 merge-base:
main..feat = feat 能走到、但 main 走不到的
= 从 merge-base 到 feat 这一段
main...feat = 两边各自独有的(对称差)
= 从 merge-base 分开之后,两边各走的路
所以 git log main...feat 显示的就是「分开之后各自干了什么」——review 一个分支时最该看的东西。
顺带一个实用的:
# 看看我这个分支相对主干做了什么(不受主干后续提交干扰) $ git diff $(git merge-base main HEAD) HEAD # 有简写 $ git diff main...HEAD
git diff A...B(三个点)= 「从 merge-base 到 B 的改动」,而不是「A 和 B 的差别」。这正是 GitHub 的 PR 页面显示的东西——所以你在 PR 里看到的 diff,不包含主干在你开分支之后的改动。
.. 和 ... 在 diff 和 log 里的含义是反的这是 Git 最坑的一处不一致,务必记住:
A..B | A...B | |
|---|---|---|
git log | B 有 A 没有的提交 | 对称差(两边各自独有) |
git diff | A 和 B 两点之间的差别 | merge-base 到 B 的差别 |
注意 log 的三点是「两边」,而 diff 的三点是「单边(从 base 到 B)」。它们不是一回事。
记忆的办法:diff 只能有两个端点(它要输出一份补丁),所以 A...B 只能理解成「base 到 B」;而 log 输出的是一个集合,可以是两边的并集。
「我的分支开出来两周了,主干动了 200 个提交。我该 merge 还是 rebase?」
先用 merge-base 看清楚状况:
# 我落后多少,领先多少 $ git rev-list --left-right --count main...HEAD 200 7 # 我这个分支到底改了什么(不含主干的 200 个) $ git diff main...HEAD --stat # 分歧点在哪儿 $ git log -1 $(git merge-base main HEAD)
拿到这三个数字之后再决定,判据只有一条(第 18 章会展开):
- 这个分支只有你一个人在用、还没推给别人 →
rebase,历史干净。 - 已经推出去了、别人可能基于它工作 →
merge,别改写历史。
顺带一个很多人不知道的实用命令,专治「我 rebase 之后不确定有没有改坏东西」:
$ git range-diff main old-branch new-branch
它比较的是两组补丁而不是两个版本——能告诉你「rebase 前后,每个提交的内容有没有变」。这是验证一次 rebase 是否干净的最好工具。
这一章的一句话
merge-base 是两条线「最后一次还在一起」的那个点,也就是图论里的 LCA——合并必须有它当参照物,否则分不清「谁动了这一行」。「快进」的精确定义是 merge-base 等于其中一方,这时候合并退化成一次贴纸移动。交叉合并会产生多个 merge-base,Git 会递归地造一个虚拟 base 来应付,而那正是某些莫名其妙的冲突的来源。
下一章:参照物有了,把三个版本摆在一起,冲突到底是怎么算出来的?我们会跑一台真的 diff3 引擎,三个输入框随便改——你会看到那些 <<<<<<< 标记当场被生成出来,并且明白它们为什么只是普通的文本行。