第一性原理拆解 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-16 | R1 | 违反 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 的字节 | 哈希 |
|---|---|---|
| 空 blob | blob 0\0 | e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 |
| 空 tree | tree 0\0 | 4b825dc642cb6eb9a060e54bf8d69288fbee4904 |
这两个值全世界所有仓库都相同——因为它们不依赖任何仓库状态,只依赖那几个字节。这就是"无需中心分配标识"的具体含义。
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 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 → 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 | 恰好一个,指向本次快照的根目录 |
parent | 0 个(根提交)/ 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 B | v1 | 共同起点 |
| main F | v1 | 相同 → main 没改 |
| feat E | v2 | 不同 → 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 committed | index ↔ 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 / ino | stat 缓存:与磁盘文件比对,一致就跳过读文件和算哈希 | git status 在大仓库仍然快;touch 所有文件后第一次 status 变慢 |
| SHA-1 | 指向已入库的 blob | git add 当场就把内容写进了对象库——所以 add 过的内容即使工作区被删也能救回 |
| TREE 扩展 | 缓存未改动目录的 tree 哈希 | git commit 只需为改动路径重算 tree(第四章 4.2 的落地实现) |
| stage(标志位) | 0=正常,1=base,2=ours,3=theirs | 冲突时同一路径有 3 条记录(第八章) |
7.4 命令 → 三棵树的影响
| 命令 | HEAD | index | 工作区 | 危险度 |
|---|---|---|---|---|
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 已经推送、且别人可能基于它工作的分支。
| 维度 | merge | rebase | cherry-pick |
|---|---|---|---|
| 原有 commit 哈希 | 不变 | 全变 | 不变(但新造了副本) |
| 新增对象 | 1 个合并 commit | N 个重写 commit | 1 个 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 的不对称
fetch | push | |
|---|---|---|
| 方向 | 远端 → 本地 | 本地 → 远端 |
| 改哪些 ref | refs/remotes/origin/* | 远端的 refs/heads/* |
| 是否动工作区 | 否——所以 fetch 永远安全 | 不适用 |
| 默认安全检查 | 无需 | 拒绝非快进:新值必须以旧值为祖先 |
| 覆盖手段 | — | --force(危险)/ --force-with-lease(推荐) |
"拒绝非快进"是唯一保护别人提交的机制:非快进意味着远端现有的某些 commit 将不再可达。--force-with-lease 之所以更安全,是因为它附带断言"远端当前值必须等于我上次看到的值",如果期间有人推过,断言失败即中止;裸 --force 则无条件覆盖。
| 命令 | 实际做了什么 |
|---|---|
git fetch | ①②③ 协商 + 收 pack + 更新 refs/remotes/*。本地分支纹丝不动 |
git pull | fetch + 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 的现实处境是:构造碰撞可行,但把它变成攻击还需要额外条件(攻击者要能让你接受它的两份对象之一,且对象格式、长度头都要吻合)。已采取的缓解措施:
| 措施 | 做法 |
|---|---|
| sha1dc | Git 自 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-parse | add 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 存盘 | — |
| blob | blob <n>\0 + 原始字节 | 文件内容原样,不含文件名 |
| tree | tree <n>\0 + 条目拼接 | 每条:<mode> <name>\0<20 字节二进制 SHA>,按名排序 |
| commit | commit <n>\0 + 文本 | tree / parent* / author / committer / 空行 / 信息 |
| tag | tag <n>\0 + 文本 | object / type / tag / tagger / 空行 / 信息 / 签名 |
| 常量 | 值 |
|---|---|
| 空 blob | e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 |
| 空 tree | 4b825dc642cb6eb9a060e54bf8d69288fbee4904 |
| tree mode | 100644 普通 / 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 对象模型
| 缩写/术语 | 英文全称 | 中文与解读 |
|---|---|---|
| blob | Binary Large Object | 数据块对象。存文件内容本身,不含文件名与路径 |
| tree | Tree Object | 树对象。一个目录的快照:名字→哈希的有序列表 |
| commit | Commit Object | 提交对象。一个根 tree + 若干 parent + 作者/提交者 + 信息 |
| tag | Tag Object | 附注标签对象。带签名者信息的不可变标记,可被 GPG 签名 |
| SHA-1 | Secure Hash Algorithm 1 | 安全散列算法 1。160 位(40 个十六进制字符)摘要,Git 的默认对象名 |
| CAS | Content-Addressable Storage | 内容寻址存储。地址由内容算出,而非人为分配——全文的地基 |
| DAG | Directed Acyclic Graph | 有向无环图。提交历史的形状;无环性是内容寻址的数学后果 |
| LCA | Lowest Common Ancestor | 最近公共祖先。即 merge base,三方合并的起点 |
| Merkle DAG | Merkle Directed Acyclic Graph | 默克尔有向无环图。父节点哈希覆盖子节点哈希,改一字节则根哈希必变 |
| gitlink | — | 子模块链接。mode 为 160000 的 tree 条目,指向另一仓库的 commit |
A.3.2 引用与状态
| 缩写/术语 | 英文全称 | 中文与解读 |
|---|---|---|
| ref | Reference | 引用。名字→哈希的映射,文件内容仅 41 字节 |
| HEAD | — | 当前位置指针。通常间接指向一个分支 ref |
| detached HEAD | Detached HEAD | 分离头指针。HEAD 直接存哈希,此时提交无分支跟随 |
| index | Index / Staging Area / Cache | 索引/暂存区。三个名字同指 .git/index,本质是"施工中的下一棵 tree" |
| DIRC | DirCache | 目录缓存。.git/index 的文件签名(前四字节) |
| reflog | Reference Log | 引用日志。ref 每次变动的流水账;纯本地,不随 clone 传播 |
| stage | Stage Number | 暂存槽位。0 正常 / 1 base / 2 ours / 3 theirs;冲突时同路径有三条 |
| packed-refs | Packed References | 打包引用。大量 ref 合并成一个文件;松散 ref 优先级更高 |
| upstream | Upstream Branch | 上游分支。记在 .git/config,决定 ahead/behind 怎么算 |
| ORIG_HEAD | Original HEAD | 操作前的 HEAD。merge/rebase/reset 前自动记录,是快捷的后悔药 |
A.3.3 合并与历史
| 缩写/术语 | 英文全称 | 中文与解读 |
|---|---|---|
| merge base | Merge Base | 合并基。两分支的最近公共祖先,三方合并的参照点 |
| three-way merge | Three-Way Merge | 三方合并。用 base/ours/theirs 三个状态判定结果,见 8.1 真值表 |
| ff | Fast-Forward | 快进。目标已是当前的后代,只挪 ref,不造合并提交 |
| ours / theirs | Ours / Theirs | 我方/对方。当前分支侧与被合入侧;rebase 时两者语义互换 |
| ort | Ostensibly Recursive's Twin | 默认合并策略。Git 2.34+ 取代 recursive,更快且处理重命名更好 |
| diff3 / zdiff3 | Diff3 Conflict Style | 三段式冲突样式。冲突标记里额外显示 base,强烈建议开启 |
| rebase | Rebase | 变基。逐个重放提交到新基底,产生全新对象,需 force-push |
| cherry-pick | Cherry-Pick | 拣选。以被选提交的父提交为 base 的一次三方合并 |
| revert | Revert | 反做。base 与 theirs 互换(ours 始终是当前 HEAD)的三方合并;新增提交,不删历史 |
| octopus merge | Octopus Merge | 章鱼合并。一次合并多于两个分支,commit 有 ≥3 个 parent |
| bisect | Bisect | 二分查找。在 DAG 上二分定位引入问题的提交 |
A.3.4 存储与传输
| 缩写/术语 | 英文全称 | 中文与解读 |
|---|---|---|
| loose object | Loose Object | 松散对象。一个对象一个文件,zlib 压缩,写入快 |
| packfile | Pack File | 包文件。大量对象合并压缩,跨对象做 delta,体积小一到两个数量级 |
| delta | Delta Encoding | 差量编码。只存在于存储层,不影响对象语义与哈希 |
| OFS_DELTA | Offset Delta | 偏移量差量。以 pack 内相对偏移引用 base,更紧凑 |
| REF_DELTA | Reference Delta | 哈希差量。以 20 字节哈希引用 base,base 可在包外 |
| idx | Pack Index | 包索引。fanout 表 + 排序哈希 + 偏移量,支持 O(log n) 定位 |
| fanout | Fanout Table | 扇出表。256 项前缀计数,一次查表把搜索范围缩到 1/256 |
| thin pack | Thin Pack | 瘦包。允许 delta 的 base 不在包内(接收方已有),省传输量 |
| gc | Garbage Collection | 垃圾回收。打包 + 删除不可达且过宽限期的对象 |
| dangling | Dangling Object | 悬空对象。任何 ref 都到不了的对象,gc 前仍可救 |
| shallow clone | Shallow Clone | 浅克隆。人为截断 DAG,主动破坏"父提交必然存在"这一不变式 |
| partial clone | Partial Clone | 部分克隆。--filter=blob:none,blob 按需下载 |
| want / have | Want / Have | 协商原语。客户端报所需与已有,服务端据 DAG 算差集 |
A.3.5 安全与工程
| 缩写/术语 | 英文全称 | 中文与解读 |
|---|---|---|
| SHAttered | SHA-1 Shattered Attack | SHA-1 构造碰撞攻击。2017 年实证两份不同 PDF 同哈希 |
| sha1dc | SHA-1 with Collision Detection | 带碰撞检测的 SHA-1。Git 2.13+ 默认,检出攻击特征即报错退出 |
| preimage attack | Preimage Attack | 原像攻击。由哈希反推内容;至今不可行,是 Git 仍可运转的关键 |
| GPG | GNU Privacy Guard | GNU 隐私卫士。签名 commit/tag,提供 Git 自身不具备的身份认证 |
| plumbing | Plumbing Command | 管道命令。面向脚本,输出稳定,才是真正的 Git |
| porcelain | Porcelain Command | 瓷器命令。面向人的便利封装,可整层替换 |
| CAS | Compare-And-Swap(通用并发原语,非 Git 术语) | 比较并交换。可类比 --force-with-lease 的语义:远端现值须等于我上次所见,否则中止 |
| filter-repo | Git Filter-Repo | 历史重写工具。官方推荐,取代已不建议使用的 filter-branch |
| LFS | Large File Storage | 大文件存储。把大文件换成指针 blob,真身存外部——绕开而非解决 9.4 的问题 |
本文从"要解决什么问题"出发推导 Git 的设计,而非从命令出发记忆 Git 的用法。blob 的哈希均可用 sha1sum 手工复现;commit / tree 的哈希为示意值。全部结论均可用附录 A.2 的管道命令当场验证。若某处与你的 Git 版本行为不符,以 git cat-file 的实际输出为准。

浙公网安备 33010602011771号