第一性原理拆解 Git:从内容寻址到分布式协作



目录

章主题从第一性原理回答的问题
第一章问题定义版本控制到底要解决什么?朴素方案在哪里崩溃?
第二章内容寻址如果地址由内容决定,会自然推导出什么?
第三章四种对象只用"不可变的内容块"如何表达文件、目录、历史?
第四章快照与 tree 递归为什么 Git 存快照而不是差量?
第五章commit 与 DAG为什么历史必须是有向无环图而不是链表?
第六章引用不可变对象之上,怎么表达"当前在哪"这种可变状态?
第七章三棵树暂存区不是多余设计,它是什么的缓冲区?
第八章分支与合并分支为什么廉价?冲突的本质是什么?
第九章存储层快照模型如何不把磁盘撑爆?
第十章分布式与传输两个仓库如何在不互信的前提下同步?
第十一章完整性Merkle 结构给了什么保证,没给什么保证?
第十二章命令重解常用命令在对象层究竟做了什么?
附录速查与术语对象格式、命令映射、英文缩写全称

本文的方法:不从命令讲起。先问"要解决的问题是什么",再看"最小的机制是什么",让 Git 的设计从约束中被推导出来,而不是被当作规定背下来。文中 blob 的哈希可用 sha1sum 手工复现(第 2.4 节给出完整算式);commit / tree 的哈希为示意值,需由 Git 管道命令构造(第十二章)。


第一章 问题定义

1.1 三个不可回避的需求

抛开所有现有工具,版本控制系统必须同时满足三件事。任何设计都是在这三条约束下的解。

#需求严格表述违反的后果
R1可复原任意历史时刻的完整项目状态都能被精确重建"能看到改了什么"但装不回去,等于没有备份
R2可验证能判断取回的内容是否与当初写入的一致,且能检测任何篡改历史可被悄悄改写,审计和追责失效
R3可并行多人同时修改,事后能合并,且合并结果有明确定义退化成串行加锁,协作效率崩塌

再加两条工程约束,否则理论上正确的方案在现实中不可用:

#约束说明
C1空间必须次线性增长改一个字符不能付出一份完整副本的代价
C2常用操作必须是本地的查历史、切分支、提交如果都要网络往返,工具会被绕开

1.2 朴素方案在哪里崩溃

方案做法满足崩溃点
目录复制cp -r proj proj.2026-09-16R1违反 C1;无法验证(R2);无合并语义(R3)
差量链只存每次的 diff,复原靠回放C1复原是 O(n),越老的版本越慢;链中一环损坏则后续全毁;diff 依赖上下文,重命名和二进制文件表达力差
中心化加锁改文件前先向服务器申请独占锁R3(以串行方式)违反 C2;把并行退化成串行;服务器是单点
中心版本号全局递增修订号(如 SVN 的 r1234)R1 R3版本号由中心分配,离线无法提交;号码与内容无关,无法验证(R2)

把崩溃点归并,剩下三个必须同时成立的性质:

复原必须是 O(1) 查找,不能是 O(n) 回放     ← 排除纯差量链
标识必须能验证内容,不能由中心分配         ← 排除递增版本号
存储必须去重,不能整份复制                 ← 排除目录复制

1.3 一个机制同时满足三条

用内容本身的哈希作为它的地址。这一个决定就把上面三条全部解决:

性质为什么自然成立
复原 O(1)地址即哈希,直接按哈希取出完整内容,无需回放
可验证取出后重算哈希,与地址比对即可。地址就是校验和
去重内容相同 → 哈希相同 → 天然是同一个对象,只存一份
无需中心哈希是纯函数,任何机器独立计算得到相同结果,不需要协调者分配标识
不可变改内容就改哈希,等于产生新对象。原地修改在这个模型里不可表达

最后一行是全文的关键。它不是 Git 的一条规定,而是内容寻址的数学推论:既然地址由内容决定,那么"修改一个对象"这个操作根本无法被表达——你只能创建另一个对象。Git 里几乎所有看似奇怪的行为,最终都能回溯到这一点。

1.4 全文的抽象栈

flowchart BT
  S["第九章 · 物理存储<br/>loose object(zlib)· packfile(delta)<br/>纯优化 · 可完全替换"]
  C["第二章 · ★ 内容寻址存储<br/>地址 = SHA(header + NUL + content)<br/>地基 · 决定其余一切"]
  O["第三、四、五章 · ★ 对象模型<br/>blob · tree · commit · tag<br/>核心 · 完全不可变"]
  L["第六、七章 · 管道层 + 可变状态<br/>refs · HEAD · index<br/>面向脚本 · 少量可变"]
  P["第十二章 · 瓷器层 Porcelain<br/>add · commit · merge · rebase · pull<br/>面向人 · 可替换"]
  S -.->|"只负责把字节存下来,不参与语义"| C
  C ==>|"依赖"| O
  O ==>|"依赖"| L
  L ==>|"依赖"| P
  class S plain
  class C accent
  class O blob
  class L tree
  class P commit
  classDef commit fill:#ffe6cc,stroke:#d79b00,color:#333333
  classDef tree   fill:#dae8fc,stroke:#6c8ebf,color:#333333
  classDef blob   fill:#d5e8d4,stroke:#82b366,color:#333333
  classDef tag    fill:#e1d5e7,stroke:#9673a6,color:#333333
  classDef new    fill:#f8cecc,stroke:#b85450,color:#333333
  classDef plain  fill:#f5f5f5,stroke:#999999,color:#333333
  classDef accent fill:#fff2cc,stroke:#d6b656,color:#333333

图 1 全文的抽象栈

箭头方向就是依赖方向。第九章整个换掉,上面四层的语义一个字都不用改; 第二章一改,上面四层同时失效 —— 这就是「地基」两个字的全部含义。

这张图决定了阅读顺序。多数人学 Git 从最上层的命令开始,结果是记住了一堆互相矛盾的规则。从地基往上走,上层的每个行为都会变成"本来就该这样"。


第二章 内容寻址

2.1 对象的字节格式

既然地址由内容算出,就要先定死"算哈希时喂进去的字节序列"。Git 的定义是:

flowchart LR
  subgraph H["header:可变长度的 ASCII 文本"]
    T["blob<br/>类型:blob / tree / commit / tag"]
    SP["␠<br/>一个空格"]
    N["6<br/>内容字节数"]
    Z["NUL(0x00)<br/>一个零字节"]
    T --- SP --- N --- Z
  end
  subgraph Y["payload:一个字节都不加工"]
    P["hello⏎<br/>内容原文,6 字节"]
  end
  Z --- P
  P ==>|"整串 13 个字节,送进 SHA-1"| S["ce013625030ba8dba906f756967f9e9ca394464a"]
  S ==> A["这就是对象的地址<br/>同时也是它在 .git/objects/ 下的文件名"]
  class T plain
  class SP plain
  class N plain
  class Z plain
  class P blob
  class S accent
  class A accent
  classDef commit fill:#ffe6cc,stroke:#d79b00,color:#333333
  classDef tree   fill:#dae8fc,stroke:#6c8ebf,color:#333333
  classDef blob   fill:#d5e8d4,stroke:#82b366,color:#333333
  classDef tag    fill:#e1d5e7,stroke:#9673a6,color:#333333
  classDef new    fill:#f8cecc,stroke:#b85450,color:#333333
  classDef plain  fill:#f5f5f5,stroke:#999999,color:#333333
  classDef accent fill:#fff2cc,stroke:#d6b656,color:#333333

图 2 对象的字节格式

长度是十进制的 ASCII 文本,不是二进制整数:12 字节的内容写作「12」,占两个字符。 改一个比特,整个地址就全变 —— 「内容寻址」四个字说的就是这件事。

三个细节值得注意,它们都不是随意选的:

设计为什么
header 里写类型内容完全相同的 blob 和 tag 会算出不同哈希。防止跨类型混淆攻击,也让对象自描述
header 里写长度解码时先读到长度,可一次性分配缓冲;也让"截断的对象"无法伪装成合法对象
用 \0 分隔长度是 ASCII 数字,用零字节做终止符可无歧义地找到 payload 起点

2.2 手工复现:从字节到哈希

这一节的所有数字都可以在任何一台装了 sha1sum 的机器上复现,不需要 Git。

$ printf 'blob 6\0hello\n' | sha1sum
ce013625030ba8dba906f756967f9e9ca394464a

$ printf 'hello\n' | git hash-object --stdin
ce013625030ba8dba906f756967f9e9ca394464a        ← 完全一致

两条自校验用的特殊值,任何 Git 仓库里都是同一个常量:

对象喂给 SHA-1 的字节哈希
空 blobblob 0\0e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
空 treetree 0\04b825dc642cb6eb9a060e54bf8d69288fbee4904

这两个值全世界所有仓库都相同——因为它们不依赖任何仓库状态,只依赖那几个字节。这就是"无需中心分配标识"的具体含义。

2.3 由不可变性推导出的后果

下面每一条都不是独立的规则,而是 2.1 的推论。理解了这张表,Git 的大半"怪行为"就不再需要记忆。

现象推导
--amend 不是"修改提交"改了内容就是新哈希 → 生成了一个全新的 commit 对象,旧的仍在磁盘上,只是没人引用了
rebase 后哈希全变父提交变了 → commit 的 payload 变了 → 哈希变了 → 其所有后代的 payload 也变了,连锁到分支末端
改历史必须强推本地重写产生的是另一条链,不是远端那条链的延长,所以不是快进
误删分支常可救回删的只是引用,commit 对象还在,reflog 里还记着哈希
同内容文件不占额外空间哈希相同即同一对象。一个文件在 1000 个提交里没变过,磁盘上只有一份
Git 不记录重命名对象层根本没有"重命名"这个概念,只有"某个哈希在新 tree 里换了名字"。重命名是 diff 时用相似度现场推断出来的,不是存下来的

最后一条常被误解。git log --follow 能追踪重命名,靠的是每次比较两个 tree 时按内容相似度做匹配(默认阈值 50%),而不是查询某个记录表。所以重命名同时大改内容,追踪就会断——这不是 bug,是模型里本就没有这个信息。

2.4 地址空间与碰撞

项值含义
SHA-1 输出160 bit = 20 字节 = 40 个十六进制字符地址空间 2160
缩写形式默认 7 字符起,按仓库规模自动加长只要在本仓库内唯一即可解析
随机碰撞概率生日界:约 280 个对象才有显著碰撞概率工程上可忽略
构造碰撞2017 年 SHAttered 已实际做到不可忽略,见第十一章

随机碰撞和构造碰撞是两个问题。前者概率低到不必考虑,后者是攻击者主动构造的,2017 年 Google 与 CWI 公布了两个 SHA-1 相同的 PDF。Git 的应对见 11.2。


第三章 四种对象

3.1 为什么恰好是四种

有了"不可变内容块"这一种原语,要表达一个项目的完整历史,需要哪些东西?逐个推导:

要表达的概念最小机制对象类型
一个文件的内容裸字节序列blob
一个目录「名字 → 哈希」的有序列表(可指向 blob 或 tree)tree
某一时刻的完整状态指向根 tree 的指针 + 元信息commit
状态之间的先后关系指向父 commit 的指针
带签名的永久标记指向任意对象 + 签名者信息tag

四种就够了,且缺一不可。注意 blob 不含文件名——文件名属于"它在某个目录里叫什么",是 tree 的职责。这个切分是去重能生效的前提:同样内容的文件在不同路径下仍是同一个 blob。

3.2 对象图

flowchart LR
  C1["commit 9a3f1c2<br/>tree&nbsp;&nbsp;&nbsp;aaa96ce<br/>parent 7d2e8b1"]
  C0["commit 7d2e8b1<br/>(上一次提交)"]
  T1["tree aaa96ce 根目录<br/>100644 hello.txt → blob ce01362<br/>100644 README.md → blob 8f1a3d4<br/>040000 src → tree 5c7b2e9"]
  T2["tree 5c7b2e9 src 子目录<br/>100644 main.c → blob 3e5f7a1<br/>100755 build&nbsp;&nbsp;→ blob 9c2d4b8"]
  B1["blob ce01362<br/>hello 的内容"]
  B2["blob 8f1a3d4<br/>README 的内容"]
  B3["blob 3e5f7a1<br/>main.c 的内容"]
  B4["blob 9c2d4b8<br/>build 的内容"]
  C1 -->|"tree"| T1
  C1 -->|"parent"| C0
  T1 --> B1
  T1 --> B2
  T1 -->|"040000 src"| T2
  T2 --> B3
  T2 --> B4
  class C1 commit
  class C0 commit
  class T1 tree
  class T2 tree
  class B1 blob
  class B2 blob
  class B3 blob
  class B4 blob
  classDef commit fill:#ffe6cc,stroke:#d79b00,color:#333333
  classDef tree   fill:#dae8fc,stroke:#6c8ebf,color:#333333
  classDef blob   fill:#d5e8d4,stroke:#82b366,color:#333333
  classDef tag    fill:#e1d5e7,stroke:#9673a6,color:#333333
  classDef new    fill:#f8cecc,stroke:#b85450,color:#333333
  classDef plain  fill:#f5f5f5,stroke:#999999,color:#333333
  classDef accent fill:#fff2cc,stroke:#d6b656,color:#333333

图 3 一次提交的对象图

箭头一律从新指向旧、从容器指向成员,永不反向 —— 因为被指向者的哈希必须先 确定下来,这是内容寻址的数学后果,不是 Git 的设计选择。另外注意:blob 只存内容,文件名 由父 tree 的 entry 携带,所以重命名一个大文件不会产生任何新 blob。

图里的箭头方向蕴含一条硬约束:一个对象只能引用创建时间不晚于自己的对象。要引用某个哈希,那个哈希就必须已经算出来,也就是内容已经定型。这条约束直接导出第五章的无环性,也是 11.1 防篡改的根据。

3.3 blob

项说明
payload文件的原始字节,一个字节都不加工
不包含文件名、路径、权限、时间戳、所有者
文本/二进制不区分。Git 只在 diff、合并、换行符转换时才去猜是不是文本
推论同内容文件无论在哪个路径、哪个分支、哪个提交,都只存一份

3.4 tree

tree 的 payload 是若干条目的直接拼接,条目之间没有分隔符——靠 \0 和固定 20 字节哈希就能无歧义地切分:

  单条 entry 的字节布局(无分隔符,紧密拼接):

  ┌──────────┬───┬────────────┬────┬────────────────────────┐
  │   mode   │空 │    name    │ \0 │   SHA-1(20 字节原始)  │
  │ "100644" │格 │ "hello.txt"│    │  0xce 0x01 0x36 ... ×20 │
  └──────────┴───┴────────────┴────┴────────────────────────┘
   ASCII 八进制   文件名          注意:是二进制,不是 40 字符十六进制

  上例 entry 长度 = 6 + 1 + 9 + 1 + 20 = 37 字节

  完整 tree 对象 = "tree 37\0" + 上面 37 字节
  → SHA-1 = aaa96ced2d9a1c8e72c56b253a0e2fe78393feb7
mode含义说明
100644普通文件最常见
100755可执行文件Git 只记录可执行位,不记录完整 Unix 权限
120000符号链接blob 内容就是链接目标路径字符串
040000子目录指向另一个 tree;实际字节里常写作 40000(无前导零)
160000子模块gitlink,指向另一个仓库里的 commit,该对象不在本仓库

条目必须按名字排序,且排序规则里目录名视同带尾部斜杠。这不是美观要求:排序不一致会导致同样的目录内容算出不同的 tree 哈希,去重和比较全部失效。确定性的序列化是内容寻址能成立的隐含前提。

还要注意 Git 记录的权限信息极少——只有一个可执行位。所以 chmod 600 和 chmod 644 在 Git 看来毫无区别,靠 Git 保存文件权限的想法不成立。

3.5 commit

commit 的 payload 是纯文本,结构极简:几行 键 空格 值,一个空行,然后是提交信息。

  tree aaa96ced2d9a1c8e72c56b253a0e2fe78393feb7
  parent 7d2e8b1...                      ← 0 个:根提交
                                            1 个:普通提交
                                            ≥2 个:合并提交
  author    A U Thor <a@example.com> 1700000000 +0000
  committer A U Thor <a@example.com> 1700000000 +0000
                                         ← 必须有这个空行
  initial

  ─────────────────────────────────────────────────────────
  上例(无 parent 行,payload 恰为 156 字节 = 46+49+52+1+8;哈希为示意值,复现需 author/committer 逐字节一致,见 12.1):
  "commit 156\0" + 上述文本
  → SHA-1 = 595fbe91b7b8dac6aefc26797f14c2cee37884ff
字段说明
tree恰好一个,指向本次快照的根目录
parent0 个(根提交)/ 1 个(普通)/ ≥2 个(合并)。章鱼合并可以有很多个
author写代码的人与原始时间。rebase、cherry-pick 时保持不变
committer放进历史的人与时间。rebase 会刷新它
时间格式Unix 时间戳 + 时区偏移,两者都参与哈希

author 与 committer 分离是为了表达"代码经他人之手进入历史"这件事:邮件列表协作里打补丁的人不是写补丁的人。这也解释了一个常见困惑:rebase 之后 git log 显示的日期没变(那是 author date),但提交顺序变了(按 committer date 重排)。

3.6 tag

种类是否为对象存储用途
轻量标签否refs/tags/x 里直接是一个哈希本地临时书签
附注标签是独立 tag 对象,ref 指向它发版;可 GPG 签名
  tag 对象 payload:

  object 9a3f1c2...          ← 可以指向任意类型对象,不限于 commit
  type commit
  tag v1.0.0
  tagger A U Thor <a@example.com> 1700000000 +0000

  Release 1.0.0
  -----BEGIN PGP SIGNATURE-----   ← 签名覆盖上面全部内容
  ...
  -----END PGP SIGNATURE-----

关键区别:轻量标签只是一个名字,可以被无声地挪到别处;附注标签是一个不可变对象,改它就是造了一个新对象。发版必须用附注标签,否则"v1.0.0 指的是哪个提交"这件事没有任何保证。


第四章 快照与 tree 递归

4.1 一个绕不开的选择题

记录"版本"有两条路:存差量(每个版本记录相对上一版改了什么)还是存快照(每个版本记录完整状态)。SVN、CVS 走前者,Git 走后者。这不是喜好问题,用第一章的三条要求去筛就只剩一条路。

维度差量模型快照模型(Git)
取出第 N 版从起点重放 N 次差量,O(N)直接读根 tree,O(1)
校验第 N 版必须先重建才能校验根 tree 哈希即校验和,直接可验
合并两条分支差量序列不可交换,重放顺序敏感两个状态 + 一个共同祖先,纯函数
某一版损坏后续所有版本全废只坏那一个对象
存储开销天然小看似巨大 —— 由 4.2 化解

差量模型违反 R2(可验证):你无法在不做 O(N) 计算的前提下确认第 N 版是什么。而 R2 是内容寻址的立身之本。所以快照是被第一章的要求逼出来的,不是选出来的。

4.2 "每次全量"为何不爆炸

快照模型的直觉反驳是:改一个字符就要复制整个项目?答案是不会,因为内容寻址让"复制"退化成"引用"。

flowchart TB
  subgraph BEF["改动前"]
    cA["commit A"] --> T1["tree T1 根"]
    T1 --> b1["blob b1 README.md"]
    T1 --> td["tree t_doc docs/"]
    T1 --> ts["tree t_src src/"]
    ts --> b2["blob b2 main.c"]
    ts --> b3["blob b3 util.c"]
    ts --> b4["blob b4 io.c"]
  end
  subgraph AFT["改动后:只改了 src/main.c 一个文件"]
    cB["commit B ★ 新"] --> T2["tree T2 根 ★ 新"]
    T2 --> b1r["blob b1 README.md(复用)"]
    T2 --> tdr["tree t_doc docs/(复用)"]
    T2 --> ts2["tree t_src2 src/ ★ 新"]
    ts2 --> b2n["blob b2new main.c ★ 新"]
    ts2 --> b3r["blob b3 util.c(复用)"]
    ts2 --> b4r["blob b4 io.c(复用)"]
  end
  class cA commit
  class T1 tree
  class ts tree
  class td tree
  class b1 blob
  class b2 blob
  class b3 blob
  class b4 blob
  class cB new
  class T2 new
  class ts2 new
  class b2n new
  class tdr tree
  class b1r blob
  class b3r blob
  class b4r blob
  classDef commit fill:#ffe6cc,stroke:#d79b00,color:#333333
  classDef tree   fill:#dae8fc,stroke:#6c8ebf,color:#333333
  classDef blob   fill:#d5e8d4,stroke:#82b366,color:#333333
  classDef tag    fill:#e1d5e7,stroke:#9673a6,color:#333333
  classDef new    fill:#f8cecc,stroke:#b85450,color:#333333
  classDef plain  fill:#f5f5f5,stroke:#999999,color:#333333
  classDef accent fill:#fff2cc,stroke:#d6b656,color:#333333

图 4 路径复制:为什么全量快照不会爆炸

新增的对象只有 4 个:b2new、t_src2、T2、commit B。docs/ 下就算躺着一万个文件, 也不新增一个字节。★ 新对象数 = 被改文件数 + 到根的路径深度 = O(改动量 × 树高),与仓库总大小无关。

这就是持久化数据结构(persistent data structure)的路径复制(path copying):修改一棵不可变树时,只重建从被改节点到根的那条路径,其余子树原样共享。函数式语言里的不可变 Map 用的是同一套办法。

再加上第二章那条"地址即内容"的推论:去重是免费的、全局的、自动的。同一个 LICENSE 文件出现在一百个提交、十个分支、三个目录里,磁盘上只有一份。

4.3 递归的边界条件

情形Git 的处理推论
空目录无法表示——tree 条目必须指向一个对象,而"空目录"没有内容要占位必须放 .gitkeep 之类的文件
空文件可以:空 blob e69de29b…空文件和空目录在 Git 眼里完全不同
空仓库的根空 tree 4b825dc6…该对象往往不落盘,但哈希恒定
子模块mode 160000 的条目,指向的 commit 不在本仓库递归到此为止;这是 Merkle 树上唯一的"悬空指针"
符号链接mode 120000,blob 内容是目标路径字符串不跟随,不递归

"Git 不能跟踪空目录"常被当成缺陷,其实是模型的必然结论:Git 跟踪的是内容,空目录没有内容。

4.4 由此坍塌的一批"特性"

一旦接受"commit 指向完整快照",很多看起来独立的 Git 行为就只剩一个解释:

现象快照模型下的解释
git checkout 切分支极快不重放任何差量,直接按目标 tree 铺文件
git log -p 却有点慢diff 是现场算出来的,不是存着的。Git 存的是状态,差异是查询结果
改文件名不占空间blob 复用,只有 tree 条目变了
Git 不记录重命名快照里根本没有"重命名"这个事件。git log --follow 是在 diff 时按 ≥50% 相似度猜出来的
大文件提交一次,永久占空间历史不可变,那个 blob 被祖先 tree 引用着,删不掉。只能改写历史
分支"几乎不占空间"分支只是一个 41 字节的文件(第六章)

第五章 commit 与 DAG

5.1 为什么是图,不是链表

tree 表达了"某一刻的状态",还缺"状态之间的关系"。最朴素的做法是线性链表——每个版本指向上一个版本。它撑不住一件事:两个人同时基于同一版本开工。

结构能否表达并行开发能否表达合并判决
版本号递增(1,2,3…)否,需全局协调否违反第一章 C2
单向链表(1 个 parent)可以,分叉即两个 commit 共享 parent不能,合并点需要两个 parent不够
有向无环图(≥0 个 parent)可以可以✅ 最小可行

于是 commit 的 parent 字段允许出现 0 次、1 次或多次。仅此一处放宽,就同时获得了分叉与合并的表达力。Git 的分支模型不是一个"功能",而是 parent 字段允许重复的副产品。

5.2 无环性是白送的

DAG 里的 A(acyclic,无环)从不需要检查。理由在第三章已埋好:要把某个哈希写进 commit 的 parent 字段,那个 commit 必须已经存在、内容已定型、哈希已算出。

flowchart TB
  subgraph R["① 先假设能成环"]
    C1["commit C1"]
    C2["commit C2"]
    C1 -->|"parent"| C2
    C2 -->|"parent"| C1
  end
  S1["创建 C1 的前提<br/>→ 已知 hash(C2)"]
  S2["已知 hash(C2) 的前提<br/>→ C2 的内容已定型"]
  S3["C2 的内容包含<br/>→ hash(C1)"]
  S4["已知 hash(C1) 的前提<br/>→ C1 的内容已定型"]
  R ==>|"② 那就顺着推"| S1
  S1 ==> S2 ==> S3 ==> S4
  S4 -.->|"与第一行的前提互相要求 —— 矛盾"| S1
  class C1 new
  class C2 new
  class S1 plain
  class S2 plain
  class S3 plain
  class S4 new
  classDef commit fill:#ffe6cc,stroke:#d79b00,color:#333333
  classDef tree   fill:#dae8fc,stroke:#6c8ebf,color:#333333
  classDef blob   fill:#d5e8d4,stroke:#82b366,color:#333333
  classDef tag    fill:#e1d5e7,stroke:#9673a6,color:#333333
  classDef new    fill:#f8cecc,stroke:#b85450,color:#333333
  classDef plain  fill:#f5f5f5,stroke:#999999,color:#333333
  classDef accent fill:#fff2cc,stroke:#d6b656,color:#333333

图 5 无环性是内容寻址白送的

成环 ⟺ 在算出 SHA-1 之前就已经知道 SHA-1 的结果,等于要求构造哈希函数的不动点。 所以无环性不是 Git 立的规矩,是内容寻址白送的。

5.3 三种形状

flowchart RL
  subgraph L["① 线性:每个 commit 恰有 1 个 parent"]
    lB["B"] --> lA["A"]
    lC["C ← main"] --> lB
  end
  subgraph F["② 分叉:B 有两个孩子"]
    fB["B"] --> fA["A"]
    fC["C ← main"] --> fB
    fD["D"] --> fB
    fE["E ← feat"] --> fD
  end
  subgraph G["③ 合并:M 有两个 parent"]
    gB["B"] --> gA["A"]
    gC["C"] --> gB
    gD["D"] --> gB
    gE["E"] --> gD
    gM["M ← main"] -->|"parent 1"| gC
    gM -->|"parent 2"| gE
  end
  class lA commit
  class lB commit
  class lC commit
  class fA commit
  class fB accent
  class fC commit
  class fD commit
  class fE commit
  class gA commit
  class gB commit
  class gC commit
  class gD commit
  class gE commit
  class gM new
  classDef commit fill:#ffe6cc,stroke:#d79b00,color:#333333
  classDef tree   fill:#dae8fc,stroke:#6c8ebf,color:#333333
  classDef blob   fill:#d5e8d4,stroke:#82b366,color:#333333
  classDef tag    fill:#e1d5e7,stroke:#9673a6,color:#333333
  classDef new    fill:#f8cecc,stroke:#b85450,color:#333333
  classDef plain  fill:#f5f5f5,stroke:#999999,color:#333333
  classDef accent fill:#fff2cc,stroke:#d6b656,color:#333333

图 6 历史 DAG 的三种形状

②里 C 和 D 各自仍然只有 1 个 parent —— 分叉是「图」的性质,不是 commit 的属性。 ③里 M 的 tree 是三方合并的结果。要点:commit 只知道父亲,不知道孩子,所以 git log 能一路反向遍历, 而 --children 必须把整个仓库扫一遍。

5.4 合并基(merge base)——图论进入日常

合并两条分支时唯一需要计算的东西,是它们的最近公共祖先(LCA,Lowest Common Ancestor),Git 里叫 merge base。它决定了"谁改了什么"。

flowchart RL
  B["B<br/>★ merge base"] --> A["A"]
  D["D"] --> B
  E["E ← feat"] --> D
  C["C"] --> B
  F["F ← main"] --> C
  class A commit
  class B accent
  class C commit
  class D commit
  class E tree
  class F commit
  classDef commit fill:#ffe6cc,stroke:#d79b00,color:#333333
  classDef tree   fill:#dae8fc,stroke:#6c8ebf,color:#333333
  classDef blob   fill:#d5e8d4,stroke:#82b366,color:#333333
  classDef tag    fill:#e1d5e7,stroke:#9673a6,color:#333333
  classDef new    fill:#f8cecc,stroke:#b85450,color:#333333
  classDef plain  fill:#f5f5f5,stroke:#999999,color:#333333
  classDef accent fill:#fff2cc,stroke:#d6b656,color:#333333
hello.txt内容与 base 比
base Bv1共同起点
main Fv1相同 → main 没改
feat Ev2不同 → feat 改了
★ 结论取 v2无冲突

图 7 合并基(merge base)

只有拿到 base,才能区分「这一边改了」和「那一边删了」。两边都从 v1 改成各自 不同的东西,才叫冲突 —— 判据始终是「跟 base 比有没有变」。

情形merge base 的位置Git 行为
base 就是 main 本身main 是 feat 的祖先fast-forward,不建合并提交,只挪 ref
base 是更早的共同点两边都有独有提交三方合并,建一个双 parent 的 commit
存在多个 LCA(交叉合并)祖先关系不唯一recursive / ort 策略:先把多个 base 合并成一个虚拟 base,再三方合并
无共同祖先两棵独立的 DAG拒绝,除非 --allow-unrelated-histories

5.5 由 DAG 直接导出的结论

结论推理
改任何一个历史提交,其后所有提交的哈希全变parent 字段参与哈希,改动沿 DAG 向后级联(第二章)
因此 rebase 后必须 force-push本地那串 commit 已是全新对象,远端的旧链不是它的祖先
"提交时间"不可信时间戳是写进去的字符串,不是被验证的事实,可任意伪造
git log 的顺序不等于真实时间顺序默认是按 DAG 逆拓扑 + 时间启发式排的,时钟不同步就会看着乱序
找不到的提交可能还在DAG 上无人引用的节点仍在对象库里,直到 gc。reflog 是救命索引(第六章)

第六章 引用

6.1 不可变世界里必须有一处可变

到第五章为止,整个系统完全不可变——而一个完全不可变的系统无法表达"最新"。新提交造出来了,但没有任何东西知道它存在。所以必须引入可变状态,问题只在于:最少需要多少可变状态?

方案可变状态量问题
对象本身可改全部推翻内容寻址,R2 崩塌
全局"最新指针"一个1无法表达多分支
命名指针,每个存一个哈希N 个 41 字节文件✅ 不可变的部分仍然不可变

Git 的答案是 ref(引用):一个名字到一个哈希的映射。文件内容就是 40 个十六进制字符加一个换行——41 字节。这就是"Git 分支很廉价"的字面意义。

6.2 ref 的全貌

flowchart LR
  subgraph M["可变状态:加起来不到几百字节"]
    H["HEAD<br/>ref: refs/heads/main<br/>★ 唯一的「我现在在哪」"]
    R1["refs/heads/main<br/>9a3f1c2… 本地分支,整个文件 41 字节"]
    R2["refs/heads/feature/login<br/>7d2e8b1… 目录分隔符就是斜杠本身"]
    R3["refs/tags/v0.9<br/>轻量标签:直接是 commit 哈希"]
    R4["refs/tags/v1.0<br/>附注标签:指向一个 tag 对象"]
    R5["refs/remotes/origin/main<br/>★ 上次通信时 origin 的样子,只读快照"]
    PR["packed-refs 大量 ref 打包进一个文件"]
    LG["logs/ reflog"]
    H ==>|"解引用"| R1
  end
  subgraph I["不可变对象库 objects/"]
    O["blob · tree · commit · tag<br/>第二 ~ 五章<br/>★ 只增不改,永不修改"]
  end
  M -.->|"每个 ref 文件装的,就是右边某个对象的 40 位十六进制哈希"| I
  class H accent
  class R1 commit
  class R2 commit
  class R3 tag
  class R4 tag
  class R5 plain
  class PR plain
  class LG plain
  class O blob
  classDef commit fill:#ffe6cc,stroke:#d79b00,color:#333333
  classDef tree   fill:#dae8fc,stroke:#6c8ebf,color:#333333
  classDef blob   fill:#d5e8d4,stroke:#82b366,color:#333333
  classDef tag    fill:#e1d5e7,stroke:#9673a6,color:#333333
  classDef new    fill:#f8cecc,stroke:#b85450,color:#333333
  classDef plain  fill:#f5f5f5,stroke:#999999,color:#333333
  classDef accent fill:#fff2cc,stroke:#d6b656,color:#333333

图 8 .git/ 的全貌:可变的只有 ref

HEAD 是间接引用 —— 它指向一个 ref 而不是哈希,只有 detached HEAD 时才直接写哈希。 「删分支」删掉的是 refs/ 下那个文本文件,commit 一个都没动 —— 所以只要还说得出哈希,历史就还在。

6.3 HEAD 与两种状态

状态HEAD 内容提交时发生什么风险
附着(正常)ref: refs/heads/main建 commit,然后把 main 挪到新 commit无
分离(detached)9a3f1c2…(裸哈希)建 commit,只改 HEAD 自己,没有任何分支跟着走切走后新提交无人引用,等 gc 回收

分离 HEAD 之所以危险,机制上一目了然:commit 造出来了、在对象库里、哈希有效,但没有任何 ref 指向它,于是从任何 ref 出发都遍历不到它。它不是"丢了",是"找不到了"——区别关键,因为 reflog 还记着。

6.4 分支的真身

  git branch feature          →  写一个文件:
                                 .git/refs/heads/feature  内容 = 当前 HEAD 指向的哈希
                                 ★ 41 字节。不复制任何对象,不遍历任何历史。O(1)。

  git commit (在 main 上)    →  ① 造 tree(第四章)
                                 ② 造 commit,parent = main 当前值
                                 ③ 把 refs/heads/main 覆写成新哈希   ← 唯一的写
                                 ④ 往 logs/refs/heads/main 追加一行  ← reflog

  git branch -d feature       →  删一个 41 字节的文件。
                                 ★ 对象一个都没删!它们还在 objects/ 里,
                                   只是从任何 ref 出发都走不到了(悬空 / dangling)。

  git reset --hard HEAD~3     →  把 refs/heads/main 改成三个 commit 之前的值。
                                 ★ 被"丢掉"的 3 个 commit 完好无损,
                                   只是无人引用。reflog 里还留着旧值 →
                                   git reset --hard <reflog 里的旧哈希> 即可完全复原。

6.5 reflog:可变状态的历史

ref 是可变的,而可变就意味着旧值会丢。Git 的补救是给每次 ref 变动记一条流水账:

  .git/logs/refs/heads/main 的一行:

  7d2e8b1... 9a3f1c2... A U Thor <a@example.com> 1700000000 +0000	commit: add login
  └─ 旧值 ──┘ └─ 新值 ──┘ └────── 谁 / 何时 ──────┘              └─ 动作说明 ─┘

  ★ reflog 是纯本地的,不随 clone / push / fetch 传播。
    别人的仓库救不了你的误操作。
  ★ 默认保留 90 天(可达对象)/ 30 天(不可达对象),过期后 gc 才真正删对象。
操作看似丢失的东西实际位置恢复手段
reset --hard若干 commit对象库,无人引用git reflog 找旧哈希,reset 回去
branch -D整条分支同上同上
rebase 出错原始提交链同上,reflog 有 rebase (start) 记录git reset --hard ORIG_HEAD
分离 HEAD 下提交后切走新提交同上git reflog 或 git fsck --lost-found
git clean -fd未跟踪文件不在对象库里❌ 无法恢复——从没进过 Git

最后一行是唯一真正的例外,也是判断"这次操作能不能救"的通用准绳:内容进过对象库,就还在;没进过,就没了。

6.6 packed-refs 与远程跟踪分支

机制为什么存在要点
packed-refs上万个 tag 就是上万个小文件,目录遍历慢打包成一个文本文件。松散 ref 优先级更高,覆盖打包值
refs/remotes/origin/main需要知道"上次见到远端时它在哪"本地只读缓存,只有 fetch/push 会更新它,不是实时的
上游(upstream)记在 .git/config,不是 ref决定 git status 里"ahead 2, behind 1"怎么算

"ahead / behind"的计算就是纯 DAG 运算:以 refs/heads/main 和 refs/remotes/origin/main 为端点求 merge base,两侧各自的独有提交数即两个数字。它反映的是上次 fetch 时的远端状态,不是此刻的远端状态——这是 git status 最常被误读的一行。


第七章 三棵树

7.1 为什么需要中间层

提交一次的本质是"造一棵 tree"。那么在 git commit 敲下去之前,这棵 tree 是从哪来的?

方案做法问题
提交时扫描工作区遍历全部文件、算全部哈希、造 tree① 大仓库每次提交都要全量 hash,慢
② 无法只提交一部分改动
提交时让用户列文件commit a.c b.c状态不可见,没法反复调整
维护一份"下一棵 tree"的草稿随时可增删,提交时直接定型✅ 既解决增量,又解决部分提交

这份草稿就是 index(索引),也叫 staging area(暂存区)、cache——三个名字指同一个文件 .git/index。它常被当作"Git 多出来的一道麻烦",但从模型看它是必需品:index 就是正在施工中的下一棵 tree。

7.2 三棵树的全景

flowchart LR
  W["工作区 Working Tree<br/>磁盘上的真实文件,可随意改<br/>★ 不在对象库里"]
  I["index 暂存区 / 草稿<br/>.git/index(二进制):路径 → 哈希 的列表<br/>★ 内容已经进了对象库"]
  H["HEAD 当前提交的 tree<br/>对象库里的一棵 tree<br/>★ 不可变"]
  O["新的 commit 对象"]
  W ==>|"git add"| I
  I ==>|"git commit:造一棵新 tree,再挪动 ref"| O
  H -->|"git reset(--mixed)"| I
  H -->|"git checkout / git reset --hard"| W
  class W plain
  class I accent
  class H blob
  class O commit
  classDef commit fill:#ffe6cc,stroke:#d79b00,color:#333333
  classDef tree   fill:#dae8fc,stroke:#6c8ebf,color:#333333
  classDef blob   fill:#d5e8d4,stroke:#82b366,color:#333333
  classDef tag    fill:#e1d5e7,stroke:#9673a6,color:#333333
  classDef new    fill:#f8cecc,stroke:#b85450,color:#333333
  classDef plain  fill:#f5f5f5,stroke:#999999,color:#333333
  classDef accent fill:#fff2cc,stroke:#d6b656,color:#333333
git status 的这一段其实是在比较
Changes not staged for commit工作区 ↔ index
Changes to be committedindex ↔ HEAD
Untracked files在工作区、不在 index

图 9 三棵树:工作区 · index · HEAD

只有 commit 会创造新对象;add 写 blob,但不产生 tree 和 commit。三棵树里只有 工作区不在对象库 —— 所以「没 add 就丢了」是唯一真正不可恢复的情况。

7.3 index 的真实内容

index 不是"一份改动清单",而是一份完整的路径→哈希全表——每个被跟踪的文件都有一条,不管有没有改。

  .git/index(二进制,大端序):

  ┌────────────────────────────────────────────┐
  │ 签名 "DIRC"(DirCache) │ 版本 │ 条目数     │  ← 12 字节头
  ├────────────────────────────────────────────┤
  │ 条目 1:                                    │
  │   ctime / mtime(秒 + 纳秒)                │  ← ★ 用于快速判断"文件变没变"
  │   dev / ino / mode / uid / gid / 文件大小    │
  │   SHA-1(20 字节)                          │  ← 内容哈希,对象已在库里
  │   标志位 + 路径名 "src/main.c"              │
  ├────────────────────────────────────────────┤
  │ 条目 2 … 条目 N(按路径名排序)              │
  ├────────────────────────────────────────────┤
  │ 扩展(TREE 缓存、REUC、UNTR…)              │  ← ★ TREE 扩展缓存了各级 tree 哈希
  ├────────────────────────────────────────────┤
  │ 整个文件的 SHA-1 校验和                     │
  └────────────────────────────────────────────┘
字段作用由此解释的现象
mtime / size / inostat 缓存:与磁盘文件比对,一致就跳过读文件和算哈希git status 在大仓库仍然快;touch 所有文件后第一次 status 变慢
SHA-1指向已入库的 blobgit add 当场就把内容写进了对象库——所以 add 过的内容即使工作区被删也能救回
TREE 扩展缓存未改动目录的 tree 哈希git commit 只需为改动路径重算 tree(第四章 4.2 的落地实现)
stage(标志位)0=正常,1=base,2=ours,3=theirs冲突时同一路径有 3 条记录(第八章)

7.4 命令 → 三棵树的影响

命令HEADindex工作区危险度
git add <f>—改(并写 blob 入库)—安全
git commit改(挪 ref)——安全
git reset --soft <c>改——安全,改动全留在暂存区
git reset [--mixed] <c>改改—安全,改动退回工作区
git reset --hard <c>改改改⚠ 工作区未提交改动永久丢失
git checkout <branch>改改改有冲突会拒绝,相对安全
git checkout -- <f>——改⚠ 该文件改动永久丢失
git restore --staged <f>—改—安全(= reset 的现代拼写)
git stash—改改安全,改动被存成真正的 commit 对象
git clean -fd——删❌ 未跟踪文件,无法恢复

危险度那一列有一条统一判据,与 6.5 完全一致:只动 HEAD 和 index 的操作都能救(内容已入对象库),动工作区的操作按"那份内容有没有进过对象库"来判。


第八章 分支与合并

8.1 合并的形式化定义

抛开命令,合并要解决的是一个纯粹的数学问题:已知三个状态,求第四个。

O(p) vs B(p) T(p) vs B(p) 结果 M(p)
相同相同 B(p) 两边都没动
相同不同 T(p) ★ 只有对方动了,收下
不同相同 O(p) ★ 只有我动了,保留
不同不同 ① 若 O(p) == T(p) → 取之(巧合同改)
② 否则 → ★ 冲突

B = merge base 的 tree(共同起点) · O = ours 的 tree(当前分支) · T = theirs 的 tree(被合入的分支)

图 10 三方合并的真值表

对每一条路径 p 逐格判定,没有第六种情况。★ 判据是「跟 base 比有没有变」, 不是「两边内容一不一样」。没有 base,就只能判断「两边不一样」,而无从知道是谁改的 —— 这就是两方合并 不可行、merge base 不可省的根本原因。所有关于「合并玄学」的困惑,都在上面这五行里。

8.2 冲突到底是什么

冲突不是 Git 出错,而是信息不足以裁决:两边都从同一起点改成了不同的东西,只有作者知道哪个对。Git 此刻做的事是把三份内容都摆到台面上,然后停手。

冲突类型成因index 里的表现
内容冲突同一文件同一区域两边都改该路径有 stage 1/2/3 三条记录
改/删冲突一边改内容,一边删文件缺其中一个 stage
重命名冲突两边改成不同的新名字靠相似度推断(第四章 4.4),可能推错
目录/文件冲突一边把 a 变成目录,一边把它当文件改路径本身冲突
语义冲突A 改了函数签名,B 在别处新增了旧签名的调用无表现 —— Git 自动合并成功,编译才报错

最后一行值得单列:Git 只在行的层面比较文本,对代码含义一无所知。"合并无冲突"从不等于"合并正确",这就是合并后必须跑 CI 的机制性理由。

  冲突文件里的标记,对应真值表的三个输入:

  <<<<<<< HEAD
  int add(int a, int b) { return a + b; }        ← O(ours,当前分支)
  ||||||| merge base                              ← ★ 仅 diff3 风格才有
  int add(int a, int b) { return 0; }            ← B(共同起点)
  =======
  long add(long a, long b) { return a + b; }     ← T(theirs,被合入)
  >>>>>>> feature/int64

  ★ 强烈建议开启 diff3:git config merge.conflictStyle zdiff3
    没有 base 那一段,你只能看到"两边不一样",
    看不到"各自相对起点改了什么" —— 而后者才是判断依据。

8.3 merge 与 rebase:两种不同的历史

flowchart RL
  subgraph S0["起点"]
    sB["B"] --> sA["A"]
    sD["D"] --> sB
    sE["E ← feature"] --> sD
    sC["C"] --> sB
    sF["F ← main"] --> sC
  end
  subgraph S1["① merge:只新增一个 M"]
    mB["B"] --> mA["A"]
    mD["D"] --> mB
    mE["E"] --> mD
    mC["C"] --> mB
    mF["F"] --> mC
    mM["M ← main"] -->|"parent 1"| mF
    mM -->|"parent 2"| mE
  end
  subgraph S2["② rebase:把 D E 重放到 F 后面"]
    rB["B"] --> rA["A"]
    rC["C"] --> rB
    rF["F ← main"] --> rC
    rD["D2 ★ 新哈希"] --> rF
    rE["E2 ← feature ★ 新哈希"] --> rD
    oD["D 悬空对象"] -.-> rB
    oE["E 悬空对象"] -.-> oD
  end
  class mM new
  class rD new
  class rE new
  class oD plain
  class oE plain
  classDef commit fill:#ffe6cc,stroke:#d79b00,color:#333333
  classDef tree   fill:#dae8fc,stroke:#6c8ebf,color:#333333
  classDef blob   fill:#d5e8d4,stroke:#82b366,color:#333333
  classDef tag    fill:#e1d5e7,stroke:#9673a6,color:#333333
  classDef new    fill:#f8cecc,stroke:#b85450,color:#333333
  classDef plain  fill:#f5f5f5,stroke:#999999,color:#333333
  classDef accent fill:#fff2cc,stroke:#d6b656,color:#333333

图 11 merge 与 rebase

merge 只造 1 个新对象,D E C F 的哈希全都不变,历史忠实,代价是图变密。 rebase 取 D 相对 B 的 diff 再应用到 F 上,D2 E2 的哈希与 D E 完全不同(parent 变了,哈希必变), 原来的 D E 变成悬空对象,只能靠 reflog 找回。黄金法则:不要 rebase 已经推送、且别人可能基于它工作的分支。

维度mergerebasecherry-pick
原有 commit 哈希不变全变不变(但新造了副本)
新增对象1 个合并 commitN 个重写 commit1 个 commit
历史形状保留分叉线性线性,内容重复
冲突次数最多 1 次最多 N 次最多 1 次
能否用于公共分支✅❌谨慎(易致重复提交)
本质求解真值表反复求解真值表 + 重放求解一次真值表

值得点明:rebase、cherry-pick、revert 三者内部都是三方合并,只是 base 选得不同。revert 令 base 与 theirs 互换(ours 始终是当前 HEAD)后合并,从而"反向应用"一个提交。理解了 8.1 的真值表,这三个命令就不需要单独记忆。

8.4 fast-forward:为什么它不造对象

flowchart RL
  subgraph P0["合并前:main 本身就是 feature 的祖先"]
    pB["B"] --> pA["A"]
    pC["C ← main"] --> pB
    pD["D"] --> pC
    pE["E ← feature"] --> pD
  end
  subgraph P1["fast-forward 之后"]
    qB["B"] --> qA["A"]
    qC["C"] --> qB
    qD["D"] --> qC
    qE["E ← main, feature"] --> qD
  end
  class pC accent
  class qE accent
  classDef commit fill:#ffe6cc,stroke:#d79b00,color:#333333
  classDef tree   fill:#dae8fc,stroke:#6c8ebf,color:#333333
  classDef blob   fill:#d5e8d4,stroke:#82b366,color:#333333
  classDef tag    fill:#e1d5e7,stroke:#9673a6,color:#333333
  classDef new    fill:#f8cecc,stroke:#b85450,color:#333333
  classDef plain  fill:#f5f5f5,stroke:#999999,color:#333333
  classDef accent fill:#fff2cc,stroke:#d6b656,color:#333333

图 12 fast-forward

merge base == main,这时候压根没有东西需要合。没有新 commit、没有新 tree、没有新 blob, 只是把 refs/heads/main 这个文件覆写成 E 的哈希 —— 一次 41 字节的写入。--no-ff 是拿线性去换一条 「这里曾经有过一个分支」的记录。


第九章 存储层

9.1 一个可以整层替换的层

前八章没有提过一次"文件怎么存在磁盘上",这不是疏漏——存储层对模型完全透明。只要能做到"给哈希,还内容",底下用什么实现都不影响上层的任何一条结论。这正是第一章分层图里把它标成"纯优化、可完全替换"的原因。

松散对象 loose object包文件 packfile
路径.git/objects/ce/013625…(前 2 位做目录).git/objects/pack/pack-*.pack + .idx
一个对象一个文件成千上万个对象在一个文件里
压缩zlib,各压各的zlib + 跨对象 delta
产生时机add / commit 即时写入gc / repack / clone / push
优势写入 O(1),读取直接定位体积小一到两个数量级,传输友好
劣势百万文件拖垮文件系统读单个对象要查索引 + 解 delta 链

为什么前两位十六进制拆成目录?纯粹是文件系统限制:一个目录下放几十万个文件,在多数文件系统上 readdir 和查找都会劣化。拆成 256 个子目录是一次廉价的散列分桶。

9.2 delta:把差量请回来,但请在正确的层

第四章论证了模型层必须用快照。但存储层没有那个约束——存储层只需保证"给哈希能还原内容",怎么还原是自由的。于是差量在这里回归:

flowchart TB
  subgraph PK["一个 .pack 文件从头到尾"]
    HD["文件头:PACK + 版本 2 + 对象数"]
    O1["对象 1 OBJ_BLOB<br/>完整内容,zlib 压缩 ← base"]
    O2["对象 2 OBJ_OFS_DELTA<br/>相对「往前 N 字节那个对象」的差量"]
    O3["对象 3 OBJ_OFS_DELTA<br/>相对对象 2 的差量"]
    OD["……"]
    ON["对象 N OBJ_REF_DELTA<br/>相对「哈希为 X 的那个对象」的差量"]
    CK["整个 pack 的 SHA-1 校验和 ← 传输完整性"]
    HD --- O1 --- O2 --- O3 --- OD --- ON --- CK
  end
  O3 -.->|"需要"| O2
  O2 -.->|"需要"| O1
  class HD plain
  class O1 blob
  class O2 tree
  class O3 tree
  class OD plain
  class ON tag
  class CK accent
  classDef commit fill:#ffe6cc,stroke:#d79b00,color:#333333
  classDef tree   fill:#dae8fc,stroke:#6c8ebf,color:#333333
  classDef blob   fill:#d5e8d4,stroke:#82b366,color:#333333
  classDef tag    fill:#e1d5e7,stroke:#9673a6,color:#333333
  classDef new    fill:#f8cecc,stroke:#b85450,color:#333333
  classDef plain  fill:#f5f5f5,stroke:#999999,color:#333333
  classDef accent fill:#fff2cc,stroke:#d6b656,color:#333333

图 13 packfile 与 delta

还原对象 3 的过程是:解压对象 1 → 打一次补丁得到对象 2 → 再打一次补丁得到对象 3。 delta 链深度默认上限 50(pack.depth)。delta 纯粹是存储层的事,对象模型对此一无所知 —— 这就是 第九章「纯优化、可完全替换」的含义。

要点说明
选谁作 base启发式:同名或同类型、大小相近的对象排在一起,然后在窗口内(默认 10)择优
delta 方向通常是"新版本作 base,老版本存 delta",因为取最新版最频繁,应当最快
OFS_DELTA用相对偏移引用 base,更紧凑,要求 base 在同一 pack 内且在前面
REF_DELTA用 20 字节哈希引用 base,base 可在别处(thin pack,第十章用它省流量)
delta 与文件无关可以跨文件、跨提交、跨分支做 delta。Git 不知道也不关心谁是谁的"历史版本"

9.3 idx 索引:O(log n) 定位

flowchart TB
  subgraph IDX["pack-*.idx 的结构"]
    MG["魔数(0xFF + tOc)+ 版本 2"]
    FO["fanout 表:256 个 uint32<br/>fanout(i) = 首字节 ≤ i 的对象总数<br/>★ 一次查表就把搜索范围缩到 1/256"]
    SH["全部对象的 SHA-1<br/>★ 按哈希升序排列 → 可二分查找"]
    CR["各对象的 CRC32"]
    OFS["各对象在 .pack 中的偏移量"]
    MG --- FO --- SH --- CR --- OFS
  end
  subgraph Q["查找「ce013625…」"]
    Q1["① 查 fanout(0xce) 与 fanout(0xcd)<br/>→ 得到候选区间"]
    Q2["② 在区间内对完整哈希二分查找"]
    Q3["③ 取同下标的偏移量 → seek 到 .pack"]
    Q4["④ 若是 delta,递归取 base,逐级打补丁"]
    Q1 ==> Q2 ==> Q3 ==> Q4
  end
  FO -.-> Q1
  SH -.-> Q2
  OFS -.-> Q3
  class MG plain
  class FO accent
  class SH tree
  class CR plain
  class OFS commit
  class Q1 plain
  class Q2 plain
  class Q3 plain
  class Q4 plain
  classDef commit fill:#ffe6cc,stroke:#d79b00,color:#333333
  classDef tree   fill:#dae8fc,stroke:#6c8ebf,color:#333333
  classDef blob   fill:#d5e8d4,stroke:#82b366,color:#333333
  classDef tag    fill:#e1d5e7,stroke:#9673a6,color:#333333
  classDef new    fill:#f8cecc,stroke:#b85450,color:#333333
  classDef plain  fill:#f5f5f5,stroke:#999999,color:#333333
  classDef accent fill:#fff2cc,stroke:#d6b656,color:#333333

图 14 pack-*.idx 索引

★ 这顺带解释了 git 为什么支持缩写哈希:哈希有序排列,二分查找天然支持前缀匹配。 缩写只要在本仓库里唯一就行,仓库变大时 Git 会自己把缩写加长(生日问题)。

9.4 gc 与对象生命周期

阶段发生什么
写入add/commit 写松散对象。先写临时文件再原子 rename,保证不出现半个对象
可达性分析从全部 ref + HEAD + reflog + index 出发遍历 DAG,标记可达对象
打包可达对象重排、算 delta、写新 pack 与 idx
删除不可达且超过宽限期(默认 2 周)的对象才被删
触发松散对象过多 / pack 过多时由日常命令自动触发 gc --auto

把 reflog 算作 GC 根,是第六章"误操作可恢复"的实现基础。也因此可以推出删除大文件的完整条件:改写历史 → 删相关 ref 和 reflog → 让宽限期失效 → 强制 gc,缺一步对象就还在。git filter-repo 做的正是这一整套。


第十章 分布式与传输

10.1 "分布式"是零成本的结论

Git 的分布式常被讲成一个设计目标,但从前九章看它更像一个推论:

已有性质推出的结论
对象名全局唯一且由内容决定(第二章)两台机器各自造对象,不会命名冲突,无需中央分配 ID
对象不可变(第二章)同步只需搬运缺失对象,永不需要"更新已有对象"
历史是 DAG(第五章)两个仓库的历史可以直接求并集,天然可合并
可变状态只有 ref(第六章)同步 = 传对象 + 改几个 41 字节文件

所以服务端并不特殊:GitHub 上的仓库和你本地的仓库是同一种东西。"中央仓库"完全是社会约定,不是技术设定。

10.2 协商:只传缺的

sequenceDiagram
  participant C as 客户端
  participant S as 服务端
  S->>C: ① ref 广播 refs/heads/main = 9a3f1c2… refs/heads/dev = 4b7e1d9…
  C->>S: ② want 9a3f1c2… (只点名自己没有的)
  C->>S: ③ have 7d2e8b1… have 3c9a5f2… (沿 DAG 从新到旧)
  S->>C: ④ ACK / NAK
  Note over C,S: 一旦确认某个 have 是 want 的祖先,该祖先以下全部不用传<br/>★ DAG 封闭性的直接兑现
  S->>C: ⑤ packfile 只含差集对象,可用 thin pack
  Note over C: ⑥ 落盘(客户端本地)<br/>收包 → 补全 thin pack → 建 .idx → 更新 refs/remotes/origin/*

图 15 fetch 的协商过程

整个协商靠一条公理:「哈希相同 ⟺ 内容相同」。协议 v2 把 ① 的全量 ref 广播换成了 ls-refs 按需查询 —— 在大仓库上,光这一条就省掉大半流量。

10.3 push 与 fetch 的不对称

fetchpush
方向远端 → 本地本地 → 远端
改哪些 refrefs/remotes/origin/*远端的 refs/heads/*
是否动工作区否——所以 fetch 永远安全不适用
默认安全检查无需拒绝非快进:新值必须以旧值为祖先
覆盖手段—--force(危险)/ --force-with-lease(推荐)

"拒绝非快进"是唯一保护别人提交的机制:非快进意味着远端现有的某些 commit 将不再可达。--force-with-lease 之所以更安全,是因为它附带断言"远端当前值必须等于我上次看到的值",如果期间有人推过,断言失败即中止;裸 --force 则无条件覆盖。

命令实际做了什么
git fetch①②③ 协商 + 收 pack + 更新 refs/remotes/*。本地分支纹丝不动
git pullfetch + merge(或 --rebase 时 rebase)。= 两步,冲突来自第二步
git clone建仓 + fetch 全部 + 建一个本地分支 + checkout 一次
clone --depth 1浅克隆:人为截断 DAG,在 .git/shallow 记录截断点,故意打破"父提交必然存在"这一不变式
--filter=blob:none部分克隆:先不取 blob,用到时按需下载。适合巨型仓库

浅克隆值得注意:它是 Git 里少数几处主动破坏模型不变式的地方,代价是很多操作(log 到底、任意 merge-base、bisect 到早期)会不完整或直接失败。CI 里用它省时间是对的,在它上面做历史分析则不对。


第十一章 完整性

11.1 Merkle DAG 保证了什么

  改动向上传播的必然性:

     改一个字节 → blob 哈希变
                  → 引用它的 tree 内容变 → tree 哈希变
                    → 上级 tree 变 …… → 根 tree 哈希变
                      → commit 内容变 → commit 哈希变
                        → 所有后代 commit 的 parent 变 → 哈希全变
                          → ★ 分支 ref 上的那个哈希不再匹配

  ★ 于是:验证一个 40 字符的哈希,等价于验证整个历史的每一个字节。
    这是 Merkle 树的核心性质,Git 只是它的一个实例
    (比特币、IPFS、Certificate Transparency 用的是同一个东西)。
能保证不能保证
内容未被意外损坏(磁盘坏道、传输错误)提交是谁写的——author 只是一串可任意填写的文本
已知哈希的历史未被悄悄改写你拿到的那个哈希本身是不是正确的那个
任何改写都会暴露为哈希不匹配提交时间真实——时间戳可伪造
两个仓库内容是否一致(比一个哈希即可)内容正确或安全——那是代码审查的事

第二列第一行是最常被误解的:Git 本身不提供任何身份认证。git config user.name 可以填任何人。真正的作者证明要靠 commit -S / 附注标签的 GPG 或 SSH 签名——签名覆盖 commit payload,而 payload 含根 tree 哈希,所以一个签名就锚定了那一刻的全部内容。

11.2 SHA-1 已被攻破,Git 为何还在跑

碰撞类型含义对 Git 的威胁
随机碰撞两个无关内容偶然同哈希280 量级,可以忽略——比硬件同时故障还罕见
构造碰撞攻击者刻意造出两份同哈希内容2017 年 SHAttered 已实证(两份不同 PDF,同 SHA-1)
原像攻击给定哈希反推内容至今不可行——这是 Git 尚可运转的关键

Git 的现实处境是:构造碰撞可行,但把它变成攻击还需要额外条件(攻击者要能让你接受它的两份对象之一,且对象格式、长度头都要吻合)。已采取的缓解措施:

措施做法
sha1dcGit 自 2.13 起默认使用带碰撞检测的 SHA-1 实现,检出攻击特征就直接报错退出,代价约几个百分点的性能
长度头第二章的 <type> <len>\0 让"内容长度必须吻合"成为额外约束,抬高构造门槛
签名GPG/SSH 签名独立于 SHA-1,提供第二道防线
SHA-256 仓库Git 2.29+ 支持 --object-format=sha256。但与 SHA-1 仓库不互通,生态迁移未完成,目前仍属实验性

这里有一个有价值的观察:哈希函数是 Git 的地基,而地基可以被换掉——因为前十章的所有推理只用到"哈希是内容的确定性唯一指纹"这一条性质,从未依赖 SHA-1 的任何具体特征。第一章那张分层图里,"内容寻址"是地基,而 SHA-1 只是地基的当前材料。

11.3 自检工具

命令做什么何时用
git fsck重算所有对象哈希并校验、检查 DAG 连通性怀疑仓库损坏;找悬空对象
git fsck --lost-found把不可达的 commit/blob 导出到 .git/lost-found/reflog 也救不回来时的最后手段
git cat-file -t / -p / -s查对象的类型 / 内容 / 大小手工验证本文每一条结论
git verify-pack -v列出 pack 内对象、delta 链深度与大小分析仓库为何膨胀
git count-objects -vH松散/打包对象数与体积判断是否该 gc

第十二章 命令重解

12.1 瓷器层只是壳

回到第一章那张分层图的最顶层。Git 的命令被官方明确分成两类,这个划分本身就说明了问题:

管道 plumbing瓷器 porcelain
面向脚本人
输出稳定、机器可读,承诺向后兼容随版本变化、可本地化
例子hash-object cat-file write-tree commit-tree update-ref rev-parseadd commit merge rebase pull log
地位真正的 Git便利封装,可以整层重写
  用管道命令手工完成一次提交 —— 全程不碰任何瓷器命令:

  $ echo 'hello' | git hash-object -w --stdin
  ce013625030ba8dba906f756967f9e9ca394464a        ← 写了一个 blob(第三章)

  $ git update-index --add --cacheinfo 100644 \
        ce013625030ba8dba906f756967f9e9ca394464a hello.txt
                                                  ← 改 index(第七章)

  $ git write-tree
  aaa96ced2d9a1c8e72c56b253a0e2fe78393feb7        ← index 定型成 tree(第四章)

  $ git commit-tree aaa96ced -m 'initial'
  595fbe91b7b8dac6aefc26797f14c2cee37884ff        ← 造 commit(第五章)
                     ★ 需 author/committer 与本文一致才能复现此哈希

  $ git update-ref refs/heads/main 595fbe91
                                                  ← 挪 ref(第六章)

  ★ git commit 做的就是这五步。没有第六步,也没有隐藏步骤。
  ★ 本文 blob 的哈希可用 sha1sum 手工复现(算式见第 2.4 节);commit / tree 的哈希为示意值。

12.2 常用命令 → 底层效果总表

命令动对象库动 ref动 index动工作区一句话本质
init建空库建 HEAD——铺目录结构
add写 blob—改—把内容送进对象库并登记到草稿 tree
commit写 tree+commit改——index 定型成 tree,造 commit,挪 ref
commit --amend写新 commit改——造新对象,旧的悬空(第二章)
branch x—新建——写一个 41 字节文件
checkout / switch—改 HEAD改改按目标 tree 铺文件
merge(ff)—改改改只挪 ref(第八章 8.4)
merge(真合并)写改改改三方真值表 + 双 parent commit
rebase写 N 个改改改逐个重放,全部重新哈希
cherry-pick写改改改以父提交为 base 的一次三方合并
revert写改改改base 与 theirs 互换(ours 始终是当前 HEAD)的三方合并,不删历史
reset --soft—改——只挪 ref
reset --hard—改改改三棵树对齐到目标 commit
stash写 commit改 refs/stash改改藏起来的东西是真提交,不是临时文件
tag -a写 tag 对象新建——造不可变标记
fetch收 pack改 remotes/*——只补对象,不碰本地分支
pull收 pack改改改fetch + merge/rebase,两步
push远端写远端改——传差集 + 请求对方挪 ref
gc重排/删———打包 + 回收不可达对象
clean -fd———删❌ 删从未入库的文件

12.3 用模型回答常见困惑

问题模型给出的答案
"我 reset --hard 了,代码没了"提交过的还在(reflog + 对象库,第六章);只 add 过未提交的可用 fsck --lost-found;从未入库的没了
"为什么改个文件名,历史就断了"Git 不记录重命名,--follow 靠相似度猜(第四章 4.4)。改名同时大改内容就猜不出来
"仓库为什么这么大"某个大 blob 被历史里的 tree 引用着,不可达才会删(第九章 9.4)。删它必须改写历史
"为什么必须 force-push"你改写了历史 = 造了新对象链,远端旧链不是它的祖先,快进检查不通过(第十章 10.3)
"合并没冲突,但编译挂了"三方合并只比文本行,不懂语义(第八章 8.2 末行)
"git log 时间顺序不对"时间戳是写进去的字符串,不是被验证的事实(第五章 5.5)
"status 说落后 2 个提交,但同事说他刚推了"remotes/origin/* 是上次 fetch 的缓存,不是实时值(第六章 6.6)
"空目录提交不上去"Git 跟踪内容,空目录没有内容(第四章 4.3)
"两个仓库内容是否完全一致"比一个根 commit 哈希即可(第十一章 11.1)

12.4 危险操作清单

操作危险来源可否挽回建议
reset --hard覆盖工作区未提交改动提交过的可;未提交的否先 git stash
checkout -- <f> / restore <f>同上,且无提示否确认 git diff 再执行
clean -fd删未跟踪文件否必先 clean -nd 预演
push --force无条件覆盖他人提交对方本地有则可,否则否一律改用 --force-with-lease
rebase 公共分支造新对象链,他人历史分叉可,但需全员配合遵守黄金法则(第八章 8.3)
branch -D删 ref可(reflog)不必紧张
gc --prune=now立即回收不可达对象否确认无待救数据再用
filter-repo / filter-branch全历史重写有备份则可先 git clone --mirror 备份

附录

A.1 对象格式速查

对象字节格式payload 结构
通用<type> <size>\0<payload> → SHA-1 → zlib 存盘—
blobblob <n>\0 + 原始字节文件内容原样,不含文件名
treetree <n>\0 + 条目拼接每条:<mode> <name>\0<20 字节二进制 SHA>,按名排序
commitcommit <n>\0 + 文本tree / parent* / author / committer / 空行 / 信息
tagtag <n>\0 + 文本object / type / tag / tagger / 空行 / 信息 / 签名
常量值
空 blobe69de29bb2d1d6434b8b29ae775ad8c2e48c5391
空 tree4b825dc642cb6eb9a060e54bf8d69288fbee4904
tree mode100644 普通 / 100755 可执行 / 120000 符号链接 / 40000 目录 / 160000 子模块

A.2 动手验证清单

  # 一、看对象(把本文第二、三章全部验一遍)
  git cat-file -t HEAD            # 类型:commit
  git cat-file -p HEAD            # 内容:tree / parent / author / ...
  git cat-file -p HEAD^{tree}     # 根 tree 的条目表
  git cat-file -s HEAD            # payload 字节数
  git rev-parse HEAD:src/main.c   # 某路径当前的 blob 哈希

  # 二、手算哈希(验证"地址 = 内容哈希")
  printf 'blob 6\0hello\n' | sha1sum
  echo 'hello' | git hash-object --stdin      # 两者必须一致

  # 三、看三棵树(第七章)
  git ls-files -s                 # index 全表:mode / 哈希 / stage / 路径
  git diff                        # 工作区 ↔ index
  git diff --cached               # index  ↔ HEAD
  git diff HEAD                   # 工作区 ↔ HEAD

  # 四、看 ref 与 reflog(第六章)
  cat .git/HEAD ; cat .git/refs/heads/main
  git for-each-ref                # 所有 ref 一览
  git reflog                      # HEAD 移动流水账
  git reflog show main            # 某分支的流水账

  # 五、看 DAG(第五章)
  git log --graph --oneline --all
  git merge-base main feature     # 合并基
  git rev-list --count main       # 提交数

  # 六、看存储(第九章)
  git count-objects -vH
  git verify-pack -v .git/objects/pack/pack-*.idx | sort -k3 -n | tail -5
                                  # 找出最大的几个对象

  # 七、查完整性(第十一章)
  git fsck --full
  git fsck --lost-found           # 捞不可达对象

A.3 术语表

A.3.1 对象模型

缩写/术语英文全称中文与解读
blobBinary Large Object数据块对象。存文件内容本身,不含文件名与路径
treeTree Object树对象。一个目录的快照:名字→哈希的有序列表
commitCommit Object提交对象。一个根 tree + 若干 parent + 作者/提交者 + 信息
tagTag Object附注标签对象。带签名者信息的不可变标记,可被 GPG 签名
SHA-1Secure Hash Algorithm 1安全散列算法 1。160 位(40 个十六进制字符)摘要,Git 的默认对象名
CASContent-Addressable Storage内容寻址存储。地址由内容算出,而非人为分配——全文的地基
DAGDirected Acyclic Graph有向无环图。提交历史的形状;无环性是内容寻址的数学后果
LCALowest Common Ancestor最近公共祖先。即 merge base,三方合并的起点
Merkle DAGMerkle Directed Acyclic Graph默克尔有向无环图。父节点哈希覆盖子节点哈希,改一字节则根哈希必变
gitlink—子模块链接。mode 为 160000 的 tree 条目,指向另一仓库的 commit

A.3.2 引用与状态

缩写/术语英文全称中文与解读
refReference引用。名字→哈希的映射,文件内容仅 41 字节
HEAD—当前位置指针。通常间接指向一个分支 ref
detached HEADDetached HEAD分离头指针。HEAD 直接存哈希,此时提交无分支跟随
indexIndex / Staging Area / Cache索引/暂存区。三个名字同指 .git/index,本质是"施工中的下一棵 tree"
DIRCDirCache目录缓存。.git/index 的文件签名(前四字节)
reflogReference Log引用日志。ref 每次变动的流水账;纯本地,不随 clone 传播
stageStage Number暂存槽位。0 正常 / 1 base / 2 ours / 3 theirs;冲突时同路径有三条
packed-refsPacked References打包引用。大量 ref 合并成一个文件;松散 ref 优先级更高
upstreamUpstream Branch上游分支。记在 .git/config,决定 ahead/behind 怎么算
ORIG_HEADOriginal HEAD操作前的 HEAD。merge/rebase/reset 前自动记录,是快捷的后悔药

A.3.3 合并与历史

缩写/术语英文全称中文与解读
merge baseMerge Base合并基。两分支的最近公共祖先,三方合并的参照点
three-way mergeThree-Way Merge三方合并。用 base/ours/theirs 三个状态判定结果,见 8.1 真值表
ffFast-Forward快进。目标已是当前的后代,只挪 ref,不造合并提交
ours / theirsOurs / Theirs我方/对方。当前分支侧与被合入侧;rebase 时两者语义互换
ortOstensibly Recursive's Twin默认合并策略。Git 2.34+ 取代 recursive,更快且处理重命名更好
diff3 / zdiff3Diff3 Conflict Style三段式冲突样式。冲突标记里额外显示 base,强烈建议开启
rebaseRebase变基。逐个重放提交到新基底,产生全新对象,需 force-push
cherry-pickCherry-Pick拣选。以被选提交的父提交为 base 的一次三方合并
revertRevert反做。base 与 theirs 互换(ours 始终是当前 HEAD)的三方合并;新增提交,不删历史
octopus mergeOctopus Merge章鱼合并。一次合并多于两个分支,commit 有 ≥3 个 parent
bisectBisect二分查找。在 DAG 上二分定位引入问题的提交

A.3.4 存储与传输

缩写/术语英文全称中文与解读
loose objectLoose Object松散对象。一个对象一个文件,zlib 压缩,写入快
packfilePack File包文件。大量对象合并压缩,跨对象做 delta,体积小一到两个数量级
deltaDelta Encoding差量编码。只存在于存储层,不影响对象语义与哈希
OFS_DELTAOffset Delta偏移量差量。以 pack 内相对偏移引用 base,更紧凑
REF_DELTAReference Delta哈希差量。以 20 字节哈希引用 base,base 可在包外
idxPack Index包索引。fanout 表 + 排序哈希 + 偏移量,支持 O(log n) 定位
fanoutFanout Table扇出表。256 项前缀计数,一次查表把搜索范围缩到 1/256
thin packThin Pack瘦包。允许 delta 的 base 不在包内(接收方已有),省传输量
gcGarbage Collection垃圾回收。打包 + 删除不可达且过宽限期的对象
danglingDangling Object悬空对象。任何 ref 都到不了的对象,gc 前仍可救
shallow cloneShallow Clone浅克隆。人为截断 DAG,主动破坏"父提交必然存在"这一不变式
partial clonePartial Clone部分克隆。--filter=blob:none,blob 按需下载
want / haveWant / Have协商原语。客户端报所需与已有,服务端据 DAG 算差集

A.3.5 安全与工程

缩写/术语英文全称中文与解读
SHAtteredSHA-1 Shattered AttackSHA-1 构造碰撞攻击。2017 年实证两份不同 PDF 同哈希
sha1dcSHA-1 with Collision Detection带碰撞检测的 SHA-1。Git 2.13+ 默认,检出攻击特征即报错退出
preimage attackPreimage Attack原像攻击。由哈希反推内容;至今不可行,是 Git 仍可运转的关键
GPGGNU Privacy GuardGNU 隐私卫士。签名 commit/tag,提供 Git 自身不具备的身份认证
plumbingPlumbing Command管道命令。面向脚本,输出稳定,才是真正的 Git
porcelainPorcelain Command瓷器命令。面向人的便利封装,可整层替换
CASCompare-And-Swap(通用并发原语,非 Git 术语)比较并交换。可类比 --force-with-lease 的语义:远端现值须等于我上次所见,否则中止
filter-repoGit Filter-Repo历史重写工具。官方推荐,取代已不建议使用的 filter-branch
LFSLarge File Storage大文件存储。把大文件换成指针 blob,真身存外部——绕开而非解决 9.4 的问题

本文从"要解决什么问题"出发推导 Git 的设计,而非从命令出发记忆 Git 的用法。blob 的哈希均可用 sha1sum 手工复现;commit / tree 的哈希为示意值。全部结论均可用附录 A.2 的管道命令当场验证。若某处与你的 Git 版本行为不符,以 git cat-file 的实际输出为准。

posted @ 2026-09-16 15:03  起飞的乌龟  阅读(20)  评论(0)    收藏  举报