git 从不记录你改了什么:diff 格式深读
先来个三连问,画面都是你天天见的。这行 hunk 头取自一次真实的 git diff:
1
@@ -2,7 +2,7 @@ l1
- 那个
7数的是哪些行?上下文行算不算?被删的行算不算? - 第二行
index 46584d4..c77cd3f 100644里那串十六进制,和 commit hash 是一回事吗? - 拿一份行号早已对不上的旧补丁去打一个文件,为什么经常照样能打上?
读 diff 是 code review 的日常,但多数人对格式本身的态度是”跳过标头,直接看红绿”。这三个问题答不全,说明 diff 对你是个黑盒——用得越熟的黑盒,越值得拆一次。照旧分三层:L1 逐行解剖格式,L2 看它怎么被生成和应用,L3 想透 diff 到底”是”什么。本文所有实验都在本地跑过(git 2.43.0),输出原样照贴。
L1:会读——一张解剖图
你现在在第一层:把 diff 输出的每一行都认全。标本是一份最小改动,给三行的文件加了两行,git diff 的完整输出如下:
1
2
3
4
5
6
7
8
9
10
diff --git a/demo.txt b/demo.txt
index 46584d4..c77cd3f 100644
--- a/demo.txt
+++ b/demo.txt
@@ -1,3 +1,5 @@
第一行:标题
第二行
+第二点五行
第三行
+第四行:结尾
逐块解剖:
1
2
3
4
5
6
7
8
9
10
diff --git a/demo.txt b/demo.txt ┐ 扩展头:比较对象与元信息(git 专属)
index 46584d4..c77cd3f 100644 ┘ 旧/新 blob 哈希缩写 + 文件模式
--- a/demo.txt ┐ unified 正文:旧/新文件名(古今通用)
+++ b/demo.txt ┘
@@ -1,3 +1,5 @@ ← hunk 头:旧侧第 1 行起共 3 行;新侧第 1 行起共 5 行
第一行:标题 ┐ hunk 体:
第二行 │ 空格开头 = 上下文(两边都有)
+第二点五行 │ + 开头 = 新侧多出的行
第三行 │ - 开头 = 旧侧少掉的行
+第四行:结尾 ┘
前两行是 git 的扩展头:diff --git a/… b/… 声明比较对象,a/、b/ 是”改动前/后”的前缀约定(这也是 patch -p1 砍掉一层前缀的由来);index 行后面讲。文件权限变化不会挤在 index 行里,而是另起两行:
1
2
old mode 100644
new mode 100755
从 ---/+++ 起是标准 unified 格式的地盘,与 diff -u 的产物同构——这个”超集”关系 L2 会用到。
数那一对数字
hunk 头 @@ -1,3 +1,5 @@ 的规则一句话:旧侧计数 = 空格行 + 减号行;新侧计数 = 空格行 + 加号行。上下文行两边都算。拿一个带删有加的标本验证(10 行文件改第 5 行):
1
2
3
4
5
6
7
8
9
@@ -2,7 +2,7 @@ l1
l2
l3
l4
-l5
+L5
l6
l7
l8
数一遍:空格行 6 行 + 减号行 1 行 = 7,对上旧侧的 7;空格 6 + 加号 1 = 7,对上新侧。改动行 -l5 旧侧算数、新侧不算;+L5 正好相反——7 和 7 里各有一半的真相。两个特例:计数为 1 时省略”,1”(你会看到 @@ -1 +1 @@);新文件旧侧是零,写成 @@ -0,0 +1 @@。
hunk 头的尾巴,和三个”怪东西”
@@ 行末尾那截 l1 不是正文,是 git 顺手标注的函数上下文:往改动的上方找最近一行”像函数声明”的行贴过来,review 时帮你定位改动落在哪个函数里。对 C 类代码它会显示 @@ -3,7 +3,7 @@ int process(int x) { 这样。
三个常见怪东西,见到不慌:
\ No newline at end of file:标注紧邻的那行末尾没有换行符。改了这种行会看到-行和+行各跟一条。Binary files a/x and b/x differ:git 对二进制内容不做行级 diff,只报差异存在。new file mode 100644+--- /dev/null:新文件。对应地,删除文件是deleted file mode …++++ /dev/null;重命名则是similarity index 95%加rename from/rename to两行。
上下文默认前后各 3 行(-U<n> 可调)。为什么格式要这么设计——正文里混着 6 行”没改的行”?这个问题 L2 回答。
到这里你已经能逐行读 diff 了。但这解释不了一个现象:行号明明全变了,补丁却常常照样打得上。git 是在蒙吗?
L2:懂原理——两个哈希,一套指纹
你现在在第二层:看这份格式背后 git 实际在做什么。
index 行的两个 blob 哈希
index 46584d4..c77cd3f 里那串十六进制不是 commit hash,是 blob 哈希——两份文件内容各自的指纹。验证只需要 git hash-object:
1
2
3
4
5
$ git show HEAD:no-newline.txt | git hash-object --stdin
b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0
$ printf 'hello world' | git hash-object --stdin
95d09f2b10159347eece71399a7e2e907ea3df4f
对照这次改动的 diff 头:index b6fc4c6..95d09f2 100644——严丝合缝。这暴露了 diff 的真实身份:git diff 比较的是两份完整快照,不是一份”改动记录”。改动是 git diff 命令运行的那一刻现算出来的,仓库里本来没有这个东西。
@@ 的行号只是建议,上下文才是钥匙
回到开头的问题 3。做一个实验:给 10 行文件的 l6 处生成补丁(hunk 头声称 @@ -3,7,从第 3 行开始),然后往目标文件最前面塞 12 行,hunk 的真实落点从第 3 行漂到第 15 行:
1
2
3
4
$ git apply -v my.patch
Checking patch f.txt...
Hunk #1 succeeded at 15 (offset 12 lines).
Applied patch f.txt cleanly.
行号全错,补丁照样贴上。因为 git apply 的找法不是”跳到第 6 行”,而是拿 hunk 里的上下文行当指纹,全文件搜,搜到了再报一句偏移量;往文件前部删行导致往前漂(offset -1 lines)同理。你甚至可以把这理解成一种设计哲学:hunk 头的行号是路牌,上下文指纹才是钥匙。
类比的失效边界:现实中门牌号是权威,钥匙只开自己那扇门;diff 里恰好反过来——门牌号可以错,指纹才是唯一权威。这也回答了 L1 留下的问题:上下文行不是给你看的注释,是给 git apply 用的定位凭据。
但有两处位置是焊死的。hunk 的上下文若覆盖旧文件的开头或结尾,git 就把它当作边界凭据,不再允许漂移。三连实验(10 行文件,改 l2 处生成 @@ -1,5 的头部 hunk):
1
2
3
4
5
6
7
8
9
$ # 头部 hunk,往前面加一行
$ sed -i '1i NEW' f.txt && git apply -v p.diff
error: while searching for:
l1
l2
l3
l4
l5
error: patch does not apply
尾部 hunk(@@ -7,4,覆盖到末行)往后面追加内容,同样报 patch does not apply;而同样的补丁往前插行就没问题(尾部锚点没被破坏,照样 offset 1 line 成功)。合理的解释是:上下文都盖到文件边界了,说明”文件从这里开始/结束”本身就是这份补丁认账的前提——目标文件若在边界处对不上,继续搜下去反而容易贴错地方。
超集关系:一件两边通吃的嫁妆
git 的输出 = 扩展头 + 标准 unified 正文。这个超集关系是双向实用的:git diff 的产物剥掉前两行,GNU patch 也能吃;反过来,GNU diff -u 的产物 git apply 也认——拿真实 diff -u 输出试过,git apply -v 干净打上。这就是为什么上世纪邮寄补丁的工作流,和今天 git am 的工作流,中间不需要翻译。
到这里你知道 diff 怎么生成、怎么应用了。但还有个更根本的问题没回答:这堆加减号描述的,是”你做过的事”吗?——不是。git 从头到尾不知道你做过什么。
L3:想得透——diff 是编剧,不是账本
你现在在最后一层:想透 diff 的本体。
两个事实打底。其一,git 的对象库里只有快照(blob),没有 diff——L2 验证过了,diff 是每次命令现算的。(诚实的脚注:packfile 打包存储时会做增量压缩,但那是存储层的优化,概念模型是纯快照。)其二,两个快照之间的”编辑脚本”不唯一。这两条合起来,结论很扎心:diff 不是从你身上记录下来的账本,是从两张快照上事后重建的剧本。
拿一个 6 行的最小对看。旧文件是 a a a a a b,新文件是 a a a b a a——先预测一下:git diff 会怎么说这个改动?
git 默认算法(Myers)的答案是:”b 挪到了中间”:
1
2
3
4
5
6
7
a
a
a
+b
a
a
-b
而 git diff --patience 的答案是:”两个 a 挪到了 b 后面”:
1
2
3
4
5
6
7
8
a
a
a
-a
-a
b
+a
+a
同一对文件,两份都完全合法、都能干净应用的 diff,讲的故事却不同。你实际做的是哪种?可能是真删了两行又敲了两行,也可能是剪切粘贴——git 永远无从知晓。它只拿到两张照片,然后挑一个算法编一个”看起来合理”的过程。Myers(Eugene Myers 1986 年的 O(ND) 算法)挑计算上高效的;patience(BitTorrent 作者 Bram Cohen 提出)锚定唯一行,对代码块的移动更贴近人的直觉;histogram 是它在 JGit 里的工程化近亲。git config diff.algorithm 可换。评价这些算法的标准从来不是真或假——都真——而是人读起来顺不顺。
所以,去掉所有术语,diff 的本质是:两个状态之间可行编辑路线中的一条,由算法按”人类觉得合理”的标准挑选。它是编剧,不是记账员。
最后一层追问:那为什么 git 干脆不记操作日志,让 diff 天生忠实?因为忠实于”过程”在分布式世界里是个幻觉:rebase、merge、cherry-pick 之后,”你当初做了什么”本身就不再成立;而任意两个 commit 之间的对比(diff、blame、bisect 全靠这个)要求任何两个快照都能直接对,与它们怎么来的无关。快照模型换来的是这个任意组合的自由,代价就是 diff 永远只是”一种讲法”。四年前我在Git 笔记里抄过一句”直接记录快照,而非差异”,当时是抄来了,这篇算是真正还账。
顺带补一段家谱:diff 诞生于 1970 年代贝尔实验室的 McIlroy 之手,算的是最长公共子序列;1980 年代中期 Larry Wall 写出 patch,”用邮件寄 diff”从此成为开源协作的标准姿势;unified 格式随 GNU diffutils 流行,如今是事实标准——POSIX 钦定名单里甚至只有更老的 context 格式(diff -c)。git 站在这条四十年的流水线上,加了两行扩展头。
收尾地图
一张速查表,diff 输出里每行的身份:
| 行 | 身份 |
|---|---|
diff --git a/x b/x | 扩展头:比较对象 |
index 旧..新 模式 | 扩展头:两侧 blob 哈希缩写 |
old mode / new mode | 权限变更 |
new file mode / deleted file mode / rename from/to | 文件的生卒与改名 |
--- a/x / +++ b/x | unified 正文开始,/dev/null 表示无中生有 |
@@ -起,数 +起,数 @@ 函数名 | hunk 头:行号是路牌,函数名是路标 |
空格 / - / + 行 | 上下文(两边都算数)/ 旧侧 / 新侧 |
\ No newline at end of file | 紧邻行末尾无换行符 |
两个自测,能答出来就真懂了:
- hunk 头是
@@ -12,7 +12,8 @@,上下文共 4 行——这次改动删了几行、加了几行?(答案在 L1 的数法里:4 + 删 = 7,4 + 增 = 8。) - 为什么贴着文件末尾的改动生成的补丁,对一个”末尾又追加过内容”的文件死活打不上?(答案在 L2 的锚定实验里。)
下一站:把这套”快照 + 现算”的地基记牢,再看 git log -S"某字符串"(pickaxe)和 git blame -L 会突然通明——它们全是在这块地基上盖的楼。还有一个方言先记着:merge 冲突时你见过的 ++/-- 双列怪 diff,叫 combined diff,是 unified 的又一层变体——下回拆。