Git Internals
共同祖先与历史祖先关系教程
解释 Git 如何基于共同祖先判断分支差异、合并基础和历史可达性。
- 想建立稳定 Git 心智模型的学习者
- 经常遇到历史、引用、恢复问题的开发者
- 会看基础命令输出
- 知道提交、分支、HEAD 这些名词
- 只背底层术语却不连接到实际命令
- 把对象、引用、工作区混成一层理解
很多分支操作之所以让人困惑,是因为眼睛看到的是“两个分支现在不一样了”,但 Git 真正在计算的是:“它们共同从哪里分出来?哪些提交是这边独有的?哪些提交那边已经有了?”
先抓住一个核心概念
当 Git 要比较两个分支、执行一次 merge、判断能不能 fast-forward,或者决定 rebase 应该从哪里开始搬提交时,它通常都要先找一个 共同祖先。
这个共同祖先就是常说的 merge base。
你可以把它理解成:
main和feature现在分别站在两个位置- Git 要先找到它们最近一次“还在同一条线上”的地方
- 然后再从这个基点往两边看,才知道各自改了什么
如果没有这个基点,Git 很难判断“新增了哪些改动”和“哪些只是历史上本来就共有的内容”。
什么叫祖先关系
Git 的提交历史本质上是一张由提交对象组成的有向图。
- 普通提交通常有一个父提交
- merge 提交会有两个或更多父提交
- 从某个提交沿着 parent 指针一直往回走,能走到的提交,都是它的祖先
所以当我们说:
- A 是 B 的祖先
意思是从 B 一路沿着父提交往回走,最终能走到 A - A 不是 B 的祖先
说明它们虽然可能有关联,但 B 的历史链条里没有 A
这件事直接决定了很多命令的行为。
merge-base 到底在解决什么问题
假设:
main已经往前走了几次feature也在继续开发- 你现在想把
feature合并回main
Git 不会只看“当前两个分支头指向什么内容”,而是会先问:
- 这两个分支最近的共同祖先是谁?
- 从共同祖先到
main这一边,发生了什么? - 从共同祖先到
feature这一边,又发生了什么?
只有把这三部分拆开,Git 才能做出经典的三方比较:
- base:共同祖先
- ours:当前分支
- theirs:待合并分支
这就是为什么 merge-base 不只是一个“高级命令”的概念,而是 merge 判断的底层前提。
为什么它会影响 merge
fast-forward 的本质
如果 main 是 feature 的祖先,那么把 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 会觉得很抽象,但它和共同祖先密切相关。
在很多上下文里,三点语法的核心意思是:
- 找出
A和B的共同祖先 - 再以这个共同祖先作为比较基点
这比简单地直接拿两个分支头做比较更稳定,因为它更接近“分叉之后双方各改了什么”这个问题本身。
用例 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..feature和main...feature含义不同 - 为什么某些提交看起来“已经在历史里了”
- 为什么冲突判断是基于三方关系,而不是只看两个结果
建议连着看
建议和这些内容一起看:
git mergegit rebasegit cherry-pickgit log --graphgit merge-base