Git Internals

共同祖先与历史祖先关系教程

解释 Git 如何基于共同祖先判断分支差异、合并基础和历史可达性。

适合谁看
  • 想建立稳定 Git 心智模型的学习者
  • 经常遇到历史、引用、恢复问题的开发者
前置知识
  • 会看基础命令输出
  • 知道提交、分支、HEAD 这些名词
常见风险
  • 只背底层术语却不连接到实际命令
  • 把对象、引用、工作区混成一层理解

很多分支操作之所以让人困惑,是因为眼睛看到的是“两个分支现在不一样了”,但 Git 真正在计算的是:“它们共同从哪里分出来?哪些提交是这边独有的?哪些提交那边已经有了?”

先抓住一个核心概念

Merge Base 与分支分叉当两个分支从同一个基点分叉后,Git 通过找到最近的共同祖先(merge base)来进行三方比较,判断各自的独立修改。
分叉前(共同祖先)
main
ABC
feature
BDE
分叉后(各自独立历史)
main
ABCM
feature
BDEM

当 Git 要比较两个分支、执行一次 merge、判断能不能 fast-forward,或者决定 rebase 应该从哪里开始搬提交时,它通常都要先找一个 共同祖先

这个共同祖先就是常说的 merge base

你可以把它理解成:

  • mainfeature 现在分别站在两个位置
  • Git 要先找到它们最近一次“还在同一条线上”的地方
  • 然后再从这个基点往两边看,才知道各自改了什么

如果没有这个基点,Git 很难判断“新增了哪些改动”和“哪些只是历史上本来就共有的内容”。

什么叫祖先关系

Git 的提交历史本质上是一张由提交对象组成的有向图。

  • 普通提交通常有一个父提交
  • merge 提交会有两个或更多父提交
  • 从某个提交沿着 parent 指针一直往回走,能走到的提交,都是它的祖先

所以当我们说:

  • A 是 B 的祖先
    意思是从 B 一路沿着父提交往回走,最终能走到 A
  • A 不是 B 的祖先
    说明它们虽然可能有关联,但 B 的历史链条里没有 A

这件事直接决定了很多命令的行为。

merge-base 到底在解决什么问题

假设:

  • main 已经往前走了几次
  • feature 也在继续开发
  • 你现在想把 feature 合并回 main

Git 不会只看“当前两个分支头指向什么内容”,而是会先问:

  1. 这两个分支最近的共同祖先是谁?
  2. 从共同祖先到 main 这一边,发生了什么?
  3. 从共同祖先到 feature 这一边,又发生了什么?

只有把这三部分拆开,Git 才能做出经典的三方比较:

  • base:共同祖先
  • ours:当前分支
  • theirs:待合并分支

这就是为什么 merge-base 不只是一个“高级命令”的概念,而是 merge 判断的底层前提。

为什么它会影响 merge

fast-forward 的本质

如果 mainfeature 的祖先,那么把 main 更新到 feature,本质上只是把引用往前挪,不需要制造新的 merge commit。

也就是说:

  • Git 先检查祖先关系
  • 如果当前分支只是落后,没有分叉
  • 就可以 fast-forward

这不是“Git 特别聪明”,而是图结构上确实还保持在同一条链上。

真正需要 merge commit 的情况

如果两个分支都在共同祖先之后继续产生了自己的提交,那么它们就分叉了。

这时 Git 不能只移动一个引用,而必须:

  • 找共同祖先
  • 比较两边各自的改动
  • 尝试合并结果
  • 必要时生成一个新的 merge commit

为什么它也会影响 rebase

git rebase 的常见理解是“把我的提交挪到新的基线上重新放一遍”。

这里的关键问题是:哪些提交算“我的提交”?

Git 也是通过共同祖先来判断的。

大致可以理解为:

  • 找到当前分支和目标分支的共同祖先
  • 把“共同祖先之后、当前分支独有的提交”拿出来
  • 以目标分支的新位置为基础,重新应用这些提交

所以如果你看不懂 rebase 为什么只搬了一部分提交,或者为什么某些提交被认为“已经存在”,背后通常就是 merge-base 和祖先关系在起作用。

用例 1:看懂 main...feature

很多人第一次看到三点语法 A...B 会觉得很抽象,但它和共同祖先密切相关。

在很多上下文里,三点语法的核心意思是:

  • 找出 AB 的共同祖先
  • 再以这个共同祖先作为比较基点

这比简单地直接拿两个分支头做比较更稳定,因为它更接近“分叉之后双方各改了什么”这个问题本身。

用例 2:判断一个提交是不是已经在目标分支里

团队协作里常见一个判断:

  • 这个修复是不是已经进了 main
  • 这个 feature 分支还要不要继续 rebase?

如果某个提交已经在目标分支的祖先链里,那么从历史关系上看,它已经被包含进去了。
这也是为什么一些命令会基于祖先关系来决定:

  • 是否需要再次应用
  • 是否可以 fast-forward
  • 是否可以跳过某些提交

用例 3:为什么冲突有时和你想的不一样

有时你会说:“这两边明明只改了一点点,为什么 merge 还是冲突?”

因为 Git 不是只看最终两个版本,而是看:

  • 共同祖先里是什么
  • 你的这一边改成了什么
  • 另一边改成了什么

如果两边都是在同一个 base 基础上改了同一位置,即使最终你觉得“看起来不多”,Git 也可能把它视为冲突点。

特殊情况:merge-base 不一定只有一个

在复杂历史里,尤其是交错合并很多次的仓库里,可能出现多个候选共同祖先。

这说明历史图已经不是简单的单一路径,而是存在更复杂的交汇关系。
对大多数日常使用者来说,不需要手动处理这些细节,但要知道:

  • Git 的历史判断是图算法,不只是线性时间轴
  • 一些“为什么这次 merge 和上次不一样”的现象,可能就来自祖先结构不同

特殊情况:祖先关系影响 --is-ancestor 这类判断

如果你需要做自动化、发版门禁、提交策略检查,常见需求是:

  • 判断某个提交是否已经包含在另一个分支里
  • 判断某个分支是否落后于主分支

这类检查本质上都和祖先关系有关,而不是和文件内容表面上像不像有关。

常见误解

“Git 比较两个分支时,就是直接比较两个最新快照”

不完全对。
在 merge、rebase、范围比较这些场景里,Git 经常会先找共同祖先,再从共同祖先出发理解变化。

“只要分支名字不一样,Git 就会把它们当成完全不同的历史”

也不对。
Git 关心的是提交图和 parent 关系,不是分支名本身。
分支名只是指向某个提交的引用。

“冲突多少只取决于当前文件差异大小”

也不对。
冲突还取决于共同祖先在哪里、两边分别从 base 改了什么。

这篇原理对命令理解有什么帮助

理解共同祖先之后,你会更容易理解这些行为:

  • 为什么有时能 fast-forward,有时必须 merge
  • 为什么 rebase 只搬一部分提交
  • 为什么 main..featuremain...feature 含义不同
  • 为什么某些提交看起来“已经在历史里了”
  • 为什么冲突判断是基于三方关系,而不是只看两个结果

建议连着看

建议和这些内容一起看:

  • git merge
  • git rebase
  • git cherry-pick
  • git log --graph
  • git merge-base