历史是一张图
git log 给你看的是一列自上而下的提交,所以人很容易以为历史是一条线。它不是。它是一张有向无环图,而且所有箭头都指向过去。这个不对称能解释很多事——包括为什么 git bisect 能在一千个提交里十次定位问题。
那条线是个显示效果
第 4 章讲过,提交对象里有 parent 字段,而且可以有多个:
- 0 个 —— 根提交(仓库的第一个)
- 1 个 —— 普通提交
- 2 个及以上 —— 合并提交
而多个提交可以共用同一个父提交——那就是分叉。
把这两件事放一起,得到的结构就不是线,是图:
C (main)
/
A ← B
\
D ← E (feat)
它有个正式名字:有向无环图(DAG,Directed Acyclic Graph)。
- 有向:每条边有方向(子 → 父)
- 无环:不可能绕回来。因为提交的哈希包含了父提交的哈希(第 4 章),一个提交没法成为自己的祖先——那需要它在被创建之前就知道自己的哈希。
Git 不需要写代码去检测环。环在这个系统里根本构造不出来。
要让 A 成为 B 的祖先、B 又成为 A 的祖先,你得先知道 B 的哈希才能造 A,又得先知道 A 的哈希才能造 B。这是个死循环,物理上做不到(除非你能找到 SHA-1 的一个特定碰撞,那属于另一个话题)。
这又是「内容寻址」白送的一个性质,和第 5 章的「天然去重」是同一类:不是实现出来的功能,是模型的必然结果。
「历史」的精确定义
点任意一个圈,亮起来的就是它的祖先集合。
先点 C,再点 E。注意一件事:C 和 E 谁也不在对方的亮区里。
git log X = 从 X 出发,顺着 parent 能走到的全部提交。
不多不少,就是这个集合。
所以「历史」在 Git 里不是一个列表,是一个可达集合。你看到的那条时间线,只是把这个集合拍扁成一列的显示效果——而「拍扁」的顺序是可以选的:
$ git log --topo-order # 拓扑序:保证父提交一定排在子提交后面(默认倾向) $ git log --date-order # 按时间,但仍不破坏拓扑关系 $ git log --reverse # 倒过来,从老到新
为什么默认不是纯时间序?因为提交时间可以伪造、可以错乱(第 4 章:时钟不同步、跨时区、rebase 保留旧作者时间)。DAG 里的 parent 关系才是唯一可靠的顺序。
箭头只指向过去
这是本章最重要的一点,而它带来的后果贯穿整个 Git。
一个提交对象里写着 parent。所以:
- 一个提交知道自己的爹是谁。
- 一个提交永远不知道自己有没有儿子。
它不可能知道——因为儿子是后来才被创建的,而提交一旦创建就不可修改(第 5 章)。要让父提交记住儿子,就得修改父提交,那会改变它的哈希,于是它就不是原来那个提交了。
为什么必须从贴纸出发?
因为你只能顺着箭头往回走。要遍历历史,必须先有一个起点——而起点就是贴纸(分支、标签、HEAD、reflog)。这就是第 5 章「可达性」的由来。
为什么删掉分支,提交就「找不到」了?
因为没有任何东西从上往下指着它们。它们还在库里,但你没有起点能走到它们(第 9 章的 reflog 就是在补一个起点)。
为什么 git log 看不到「未来」?
你 checkout 到一个老提交(detached HEAD,第 7 章),git log 只显示它之前的历史,后面那些提交一个都看不到——尽管它们就在库里。因为从这里出发,箭头不通往那个方向。
为什么「这个提交在哪些分支上」要遍历所有分支?
git branch --contains X 必须拿每一个分支当起点各走一遍,看谁能走到 X。这是个反向查询,而图上没有反向边——所以只能穷举。仓库大了它就明显变慢,原因就在这儿。
DAG 上的几种常见形状
后面几章会反复用到这几个词,这里先对上号:
| 形状 | 图 | 意思 |
|---|---|---|
| 线性 | A ← B ← C | 没有分叉。最好处理的情况 |
| 快进 | A 是 C 的祖先 | 把贴纸从 A 挪到 C 不会让任何提交失去引用(第 16 章) |
| 分叉 | C 和 E 谁也不是谁的祖先 | 需要调和。merge 或 rebase 的场合 |
| 合并提交 | M 有两个 parent | 把两条线重新汇成一条 |
| 孤儿 | 没有任何贴纸能走到 | rebase / reset 之后的残留(第 18 章) |
「分叉」的精确定义值得单独记一下,因为它是 Git 判断「要不要合并」的唯一依据:
两个提交分叉了 ⟺ 谁也不是谁的祖先。
没有别的判据。跟时间无关,跟分支名无关,跟谁先谁后无关——纯粹是图上的可达性问题。
用 Git 自带的命令直接查图的性质:
# X 是 Y 的祖先吗?(不输出任何东西,看返回码:0 = 是) $ git merge-base --is-ancestor X Y && echo "是祖先" || echo "不是" # 两边各自独有多少提交(左边 | 右边) $ git rev-list --left-right --count main...feat 3 5 # 画出 ASCII 图 $ git log --graph --oneline --all
* 1bada99 (HEAD -> main) second | * a3f21c9 (feat) 新功能 | * 7f2e8b1 修了个 bug |/ * 06b431f first
那个 |/ 就是分叉点。这张 ASCII 图是从 DAG 现算出来的,不是存的。
再看一个很能说明问题的对比:
$ git log --oneline main # main 能走到的 $ git log --oneline feat # feat 能走到的 $ git log --oneline main..feat # feat 有而 main 没有的 $ git log --oneline main...feat # 两边各自独有的(对称差)
三个点和两个点的区别,就是「单向差」和「对称差」。下一章讲 merge-base 时会看到,三个点的定义正是「从共同祖先分开之后,两边各走的路」。
bisect:DAG 上的二分查找
既然历史是一张图,那图算法就能用上。git bisect 是其中最实用的一个。
场景:某个版本还好好的,现在坏了,中间有 800 个提交。是哪个提交引入的?
手工一个个试要 800 次。bisect 用二分:
$ git bisect start $ git bisect bad # 当前这个是坏的 $ git bisect good v1.2 # 这个版本是好的 # Git 自动 checkout 到中间那个提交 # 你测一下,然后告诉它结果: $ git bisect good # 或 git bisect bad # 它继续二分…… $ git bisect reset # 结束,回到原来的位置
800 个提交,log₂(800) ≈ 10 次就能定位。
二分查找要求「单调性」:前面全是好的,后面全是坏的,中间有一个分界点。
Git 的历史满足这个吗?在 DAG 上,这个性质有个精确的表述:
如果提交 X 是坏的,那么所有能走到 X 的提交(X 的后代)也都是坏的(因为它们包含了 X 的改动)。如果 X 是好的,那么X 的所有祖先也都是好的。
所以 bisect 每问一次,就能把候选集合砍掉一半——它砍的不是「一段区间」,而是祖先集合或后代集合。这就是为什么它在有分支、有合并的复杂图上依然成立,而不只是在直线历史上。
顺带一提,这也是「每个提交都应该是可编译、可运行的」这条建议的实际价值所在——如果历史里有一半提交是坏的半成品,bisect 就废了。第 10 章那个 git add -p 的建议,在这里得到了回报。
而且它能自动化。写个脚本,返回 0 表示好、非 0 表示坏:
$ git bisect start HEAD v1.2 $ git bisect run ./test.sh # 喝杯咖啡回来,它已经告诉你是哪个提交了
这大概是 Git 里投入产出比最高的一个命令。很多人从没用过,但它能把「找了一下午」变成「跑了十分钟」。
HEAD~ 和 HEAD^(这次用图)第 7 章提过,这里用图再说一次,因为在 DAG 上它才真正清楚:
C ← M (main) M 是合并提交
/
D ← E ─
HEAD = M
HEAD^1 = C 第一个父提交 ——「你执行 merge 时所在的那条线」
HEAD^2 = E 第二个父提交 ——「被合进来的那条线」
HEAD~1 = C 往上一代(沿第一个父提交)
HEAD~2 = C 的第一个父提交
~ 是竖着走(往上数几代,永远沿第一个父提交),^ 是横着挑(选第几个爹)。
「第一个父提交」有个很实用的含义:它是你当时站着的那条线。所以:
$ git log --first-parent
只走第一个父提交,会把所有合并进来的分支压平成主干的一条直线。在一个天天合并 PR 的仓库里,这条命令能让你看到「主干上依次发生了什么」,而不是几百个功能分支的内部细节。做发布说明时非常好用。
「线上出了个 bug,两周前的版本是好的,中间有 300 多个提交。」
这就是 bisect 的主场。但有几个实战要点:
一、先写一个能自动判定的脚本。哪怕很糙:
#!/bin/sh npm install --silent >/dev/null 2>&1 || exit 125 # 125 = 「这个提交没法测,跳过」 npm test --silent -- -t "那个坏掉的用例"
返回码 125 是特殊的:它告诉 bisect「这个提交编译不过/装不上依赖,跳过它」,而不是把它判成坏的。这个细节能救很多场合。
二、缩小范围。如果你知道问题出在某个目录,可以只在改动过那里的提交之间二分:
$ git bisect start -- src/payment/
三、bisect 期间你处在 detached HEAD(第 7 章)——这是正常的,别慌。git bisect reset 会把你送回原来的分支。
四、找到之后看一眼那个提交。如果它是一个 500 行的巨型提交,你只是把「哪个提交」变成了「这 500 行里的哪一行」,帮助有限。这是「小提交」这个习惯真正兑现价值的时刻。
这一章的一句话
Git 的历史是一张有向无环图,「无环」是内容寻址数学上保证的。所有箭头指向过去——提交知道自己的爹,永远不知道自己的儿子;所以遍历历史必须从贴纸出发,而删掉贴纸就等于失去了起点。「分叉」的唯一定义是「谁也不是谁的祖先」,而这正是 Git 判断要不要合并的全部依据。
下一章:既然分叉了就要调和,那调和的参照物是什么?答案是 merge-base——两条线「最后一次还在一起」的那个点。我们会真的跑一遍 LCA 算法,还会看到一个病态但真实存在的情况:两个 merge-base。