卷 IV · 网CH 15深度 15/24

历史是一张图

git log 给你看的是一列自上而下的提交,所以人很容易以为历史是一条线。它不是。它是一张有向无环图,而且所有箭头都指向过去。这个不对称能解释很多事——包括为什么 git bisect 能在一千个提交里十次定位问题。

DAG祖先集合箭头指向过去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 章的「天然去重」是同一类:不是实现出来的功能,是模型的必然结果。

「历史」的精确定义

▶ 动手 · DAG 演算器

点任意一个圈,亮起来的就是它的祖先集合。

先点 C,再点 E。注意一件事:C 和 E 谁也不在对方的亮区里。

◆ git log 到底列了什么

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 看一眼

用 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。