卷 IV · 网CH 16深度 16/24

merge-base:共同祖先怎么找

要合并两条线,光知道两个终点是不够的。你必须知道它们从哪儿分开的——不然分不清「这一行是我改的」还是「本来就长这样」。这一章找出那个点,顺便给「快进」下一个精确定义。

merge-baseLCA快进的定义交叉合并

为什么两个终点不够

假设要合并 main 和 feat,某个文件在两边分别是:

main 这边:   port = 3000
feat 这边:   port = 8080

该取哪个?光看这两行没法回答。因为有两种完全不同的可能:

如果分开时是……那么真相是该取
port = 3000main 没动,feat 改成了 80808080(feat 的改动)
port = 8080feat 没动,main 改成了 30003000(main 的改动)
port = 80两边都改了,改得还不一样冲突,Git 不猜
◆ 这就是「三路」的第三路

合并需要三个输入,不是两个:

  • ours —— 你这边现在的样子
  • theirs —— 对方现在的样子
  • base —— 你们分开时的样子

没有 base,你只能问「这两行哪个对」——那是个无解的问题。

有了 base,你问的是「谁动了它」——这是个有答案的问题。

而 base 从哪来?就是 merge-base。这一章找它,下一章用它。

找那个点

直觉上很简单:两条线最后一次还在一起的地方。

但要写成算法,得说得更严谨一点:

◆ merge-base 的精确定义

merge-base(A, B) = 一个提交 X,满足:

  1. X 是 A 的祖先,并且是 B 的祖先(是共同祖先)
  2. 没有别的共同祖先是 X 的后代(是「最近的」)

第 2 条是关键。共同祖先通常有很多个——在上面那张图里,A 和 B 都是共同祖先。但只有最靠下的那个才是我们要的,因为只有它才代表「最后一次还在一起」。

在图论里这叫 LCA(Lowest Common Ancestor,最近公共祖先)

▶ 动手 · 真 merge-base

四种图景,切着看。这台引擎跑的是真的 LCA 算法(求两边祖先集合的交集,再排除掉那些「是别的共同祖先的祖先」的)。

第二个「快进」和第四个「交叉合并」是重点,它们各自揭示一件事。

快进的精确定义

点第二个场景时你应该看到了:merge-base 就是 main 自己。

这说明 main 没有任何 feat 不知道的提交——feat 完全「包含」了 main。

◆ 什么是快进(fast-forward)

把贴纸从 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 怎么处理:造一个虚拟的 base

Git 默认的合并策略叫 ort(Ostensibly Recursive's Twin,2021 年起取代了老的 recursive)。遇到多个 merge-base 时,它的做法是:

先把这些 base 两两递归合并,得到一个「虚拟 base」,再拿它去做三路合并。

这就是策略名里那个 recursive 的由来——合并的过程中可能递归地做合并

这个设计很聪明,但它解释了一件常让人困惑的怪事:

为什么有时候合并会冒出你从没见过的冲突?

因为参照物是一个算出来的、历史上从未真实存在过的版本。你拿它跟两边比,得到的结论可能跟你的直觉对不上。

好消息是:你多半这辈子都不用主动处理它。但下次撞见一个莫名其妙的冲突时,记得跑一句 git merge-base --all main feat——如果它吐出不止一行,你就知道原因了。

⌗ 掀开 .git 看一眼

直接查这些图性质:

# 找 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..BA...B
git logB 有 A 没有的提交对称差(两边各自独有)
git diffA 和 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 引擎,三个输入框随便改——你会看到那些 <<<<<<< 标记当场被生成出来,并且明白它们为什么只是普通的文本行。