在一个二十年历史、上百万次提交的仓库里敲 git branch feature-x,回车之后光标立刻就回来了。同一台机器上复制一份工作目录要等几十秒,而 Git 建分支的耗时与仓库大小完全无关。
这不是优化出来的,是数据模型的直接推论。要看懂它,得先看清 .git/objects 里到底躺着什么。
破除误解
误解一:Git 存的是每次改动的差异(diff)。
这大概来自 git log -p 那一屏一屏的加号减号。但 Git 的提交对象里根本没有 diff——每个提交指向一整棵目录树,树里每个文件都是一份完整内容。你看到的差异是 Git 在你敲下命令的那一刻,拿两棵树现场比出来的查询结果,不是存储格式。真正把增量当存储单位的是 RCS、CVS、Subversion 那一系。
误解二:建分支是把代码复制了一份。
分支是 .git/refs/heads/ 下的一个普通文本文件,内容为 40 个十六进制字符加一个换行符,共 41 字节。可以自己数:
$ git branch feature-x
$ wc -c .git/refs/heads/feature-x
41 .git/refs/heads/feature-x
```仓库是 1 MB 还是 30 GB,这个文件都是 41 字节。删分支也只是删掉这个文件,一个提交对象都不会动。
误解三:SHA-1 被攻破了,所以 Git 的历史不再可信。
要把两件事分开。2017 年 2 月 Google 与 CWI 公布了 SHAttered,第一个真实的 SHA-1 碰撞——两份内容不同的 PDF 拥有相同哈希。但 Git 用哈希做的是内容寻址与完整性校验,不是身份认证;想用碰撞去污染别人的仓库,攻击者先得把恶意对象推进去,而那一步本身就需要写权限。Git 2.13.0(2017 年)默认启用了马克·斯蒂文斯与丹·舒莫的 sha1dc 实现,它能识别碰撞攻击特有的差分模式并直接报错。SHA-256 仓库从 Git 2.29(2020 年 10 月)起以实验形式提供,2.42(2023 年)摘掉实验标签,2.45(2024 年)才有初步的 SHA-1/SHA-256 互操作。难点从来不是算法本身,而是迁移:仓库里每个对象都用哈希引用别的对象,换哈希函数等于换掉每一个 ID。
Git 只认识四种对象
整个版本库归结为四类不可变对象,全都躺在同一个键值存储里:
| 对象 | 存的是什么 | 指向谁 | 日常对应物 |
|---|---|---|---|
| blob | 一段字节流,不含文件名 | 谁都不指 | 文件的内容 |
| tree | 一张条目表:权限、名字、子对象哈希 | blob 与其他 tree | 一个目录 |
| commit | 一个 tree 哈希、若干父提交、作者、提交者、留言 | tree 与父 commit | 一次快照 |
| tag(附注标签) | 被标注对象的哈希、类型、标注者、说明 | 任意对象 | 一个带签名的书签 |
blob 里没有文件名,这一点常被忽略。文件名住在 tree 里:
$ git cat-file -p HEAD^{tree}
100644 blob ce013625030ba8dba906f756967f9e9ca394464a a.txt
```100644 是普通文件,100755 是可执行文件,120000 是符号链接,040000 是子目录,160000 是子模块。Git 只记这五种权限——它不打算当文件系统。
对象的哈希怎么来的?把类型、空格、字节长度、一个 \0、然后内容拼起来,取 SHA-1:
$ printf 'blob 6\0hello\n' | shasum
ce013625030ba8dba906f756967f9e9ca394464a
```这正是 git hash-object 会给出的值。存盘时再用 zlib 压缩,落到 .git/objects/ce/013625…——前两位当目录名,纯粹是为了避免单个目录塞进几十万个文件。
内容寻址:名字由内容决定
这就是 Git 的地基:对象的名字不是人起的,是内容算出来的。同样的内容在任何机器上、任何年份、任何仓库里,哈希都一样。空 blob 永远是 e69de29b…,空目录树永远是 4b825dc6…,这两个常量在全世界所有 Git 仓库中通用。
后果一是去重免费。把一个 10 MB 的文件复制到十个目录,仓库只多存一份 blob,十条 tree 条目各自引用它。一个分支和 main 有 99% 的文件相同,那 99% 的 blob 与子树是同一批对象,不是副本。
后果二是确定性必须被强制。tree 里的条目按名字排序,权限位只允许那五种,作者时间戳带时区——只要序列化留下任何自由度,同样的内容就会算出不同的哈希,去重立刻失效。
后果三是几个恼人的限制。空目录无法被追踪,因为 tree 只由条目构成,没有条目的目录压根不会被任何东西引用(于是有了 .gitkeep 这种民间约定)。重命名也不被记录:改个文件名,Git 看到的是旧 tree 条目消失、新条目出现,blob 哈希不变。git log --follow 里的重命名是事后猜的——按内容相似度匹配,默认相似度阈值 50%,diff.renames 自 Git 2.9(2016 年)起默认开启。改名同时大改内容,猜测就会失手。
哈希链:改得掉,藏不住
commit 对象长这样:
tree 2e81171448eb9f2ee3821e3d447aa6b2fe3ddba1
parent aff4c357c87a35da1a35aa7426e95211d7043f70
author Alice <a@example.com> 1785931475 -0700
committer Alice <a@example.com> 1785931475 -0700修复解析器越界 ```
提交的哈希是对这整段文本算出来的,其中既包含目录树哈希,也包含父提交哈希。于是三年前一个字节的改动,会改变那次提交的树哈希,进而改变那次提交的哈希,进而改变它所有后代提交的哈希——一路传到分支尖端。这就是默克尔树(1979 年由拉尔夫·默克尔提出的哈希树)的性质,Git 里是它的有向无环图版本。
所以"不可篡改"要说得准确一点:Git 拦不住任何人改历史,它保证的是改完之后无法冒充没改过。只要对方手里有一个旧的提交哈希,他就能一句话判定你给的历史是不是同一份。
分支:一个 41 字节的文件
refs/heads/main 里就是一行哈希。HEAD 更朴素,是一行 ref: refs/heads/main——一个指向指针的指针。提交时 Git 做三件事:写新的 tree 与 commit 对象、把 refs/heads/main 的内容覆写成新的哈希、往 reflog 追加一行。分支"前进"就是这个文件被重写。
于是分支的成本与历史长度、文件数量、仓库体积统统无关。对比一下别的系统怎么做同一件事:
| 系统 | 分支是什么 | 成本 | 能离线做吗 |
|---|---|---|---|
| CVS | 给每个文件打一个分支标签 | 与文件数成正比 | 不能,要连服务器 |
| Subversion | 版本库里的一次"廉价拷贝"(写时复制目录) | 服务端 O(1),但要一次网络往返与提交 | 不能 |
| Perforce(经典分支) | 在 depot 里建立分支映射并派生文件版本 | 与文件数相关,需服务器参与 | 不能 |
| Git | 一个 41 字节的本地文件 | 与仓库规模无关 | 能,完全本地 |
值得注意的是 Subversion 的分支在存储上其实也不贵——它同样是写时复制。Git 真正拉开差距的地方在于:分支是本地的、无需许可、无需网络,而且创建它不需要往版本库里写入任何新对象。这才是"廉价"的完整含义:不是省了磁盘,是省了协调。
分支多了,refs/heads/ 下几万个小文件也会成为负担,于是有了 packed-refs(把所有引用塞进一个文本文件)和 Git 2.45(2024 年)引入的 reftable 后端。SHA-256 仓库里这个文件是 65 字节——64 个十六进制字符加换行。
merge 与 rebase:在同一张图上做两件不同的事
两者的差别不在结果好不好看,而在对象图被怎么改动。
git merge 先算出两条分支的最近公共祖先(git merge-base),拿它做基准做三方合并,然后写一个带两个 parent 的 commit。原有的提交对象一个都没变,图上多出一个菱形。历史因此忠实:谁在什么时候基于什么状态写了什么,全部保留,代价是图会越来越像地铁线路图。
git rebase 走的是另一条路。它取出你分支上每个提交的差异,逐个重放到新的基点上,每次重放都产出一个全新的 commit 对象——新的 tree、新的 parent、新的哈希。原来的提交并没有被修改(对象不可变,改不了),只是不再被任何引用指到,成了不可达对象。它们会在 reflog 里再活一段时间(gc.reflogExpire 默认 90 天),随后被 git gc 清掉(不可达松散对象默认有两周宽限期)。
| merge | rebase | |
|---|---|---|
| 对象图变化 | 新增一个多父提交 | 逐个新建提交,旧提交变为不可达 |
| 提交哈希 | 全部保留 | 全部改变 |
| 历史形状 | 有分叉与汇合 | 线性 |
| 冲突解决次数 | 一次 | 每个被重放的提交可能各来一次 |
| 记录的是 | 实际发生的开发过程 | 一段被整理过的叙述 |
这也解释了"不要 rebase 已推送的分支"这条纪律的机制:别人本地的引用指向的是旧提交,而你的强制推送让远端指向了一批新对象。旧对象在服务端一旦被回收,别人的引用就悬空了——问题不在于礼貌,在于对象图的连通性被你单方面拆掉了。
存快照会不会撑爆磁盘?
如果每次提交都完整存下所有文件,一个改了一行的提交岂不是要复制整个项目?不会——因为去重发生在对象层,压缩发生在另一层。
没改的文件哈希不变,tree 条目直接复用旧 blob,一次提交真正新增的往往只有一个 blob、若干条路径上的 tree、一个 commit。真正会累积的是同一个文件的历次版本,各存一份完整内容。这一层由 packfile 收拾:git gc 把成千上万个松散对象打成一个 .pack 加一个 .idx 索引(索引里有 256 项的扇出表,用于按首字节快速定位)。在包内,相似对象之间用增量编码,只存"从这个基准对象出发怎么改",两种增量类型分别按包内偏移和按哈希引用基准对象。
关键在于 Git 不按文件历史选基准。打包时它把对象按类型、路径名、大小排序,在一个滑动窗口里试着互相求增量:pack.window 默认 10(每个对象和邻近的 10 个候选比),pack.depth 默认 50(增量链最长 50 环)。git gc --aggressive 把窗口开到 250。所以两个从没有过版本关系、但内容相近的文件也可能互为增量基准;而 Pro Git 指出,Git 倾向把较新的版本完整存下、旧版本存成相对它的增量,因为你最常访问的是新版本。
于是模型层与存储层被干净地切开了,这是整套设计里最值钱的一刀:
- 语义上,任一版本都是一份完整快照,
git checkout任何一个古老提交都是"查表取对象",代价与它距今多远无关; - 物理上,磁盘占用由增量压缩兜底,而压缩策略随时可以换(改打包参数不会改变任何一个对象哈希)。
对照 RCS 那一系:取某个旧版本要从某个基准开始重放一长串增量,历史越深越慢,而且增量链的形状被历史结构写死。Git 把这两件事解耦,代价是增量链最长 50 环意味着最坏情况要解开 50 层——一个有上界、可调节的代价,而不是随历史增长的代价。
这套设计在哪里失效
大二进制文件。 已压缩的 PSD、MP4、模型权重之间求不出有意义的增量,每次改动都等于新增一份完整拷贝;而克隆默认拉全部历史,于是所有人都要下载所有版本。Git LFS(2015 年)的做法是把大文件挪到仓库外,提交里只留一个指针文件——这等于承认对象模型不适合这类数据。
历史删不掉。 误提交了密钥或个人信息,要真正抹掉就得重写那个提交及其全部后代,所有下游克隆随之失效。不可篡改与可删除在结构上就是对立的。
全仓库粒度。 对象图不支持"只克隆一个子目录"。微软 2017 年把 Windows 源码搬上 Git 时遇到的正是这个:约 350 万个文件、300 GB,git status 要跑十分钟,checkout 要几小时。他们先做了 VFS for Git(虚拟文件系统,按需下载对象),后来这套思路收敛成 Scalar 并逐步并入 Git 上游。今天的通用解法是部分克隆(git clone --filter=blob:none,Git 2.19 起)加稀疏检出(git sparse-checkout 的 cone 模式,Git 2.25,2020 年 1 月)——都是在补对象模型没提供的"按需"能力。
打包与迁移的成本。 大仓库的 git gc 是一次昂贵的全局操作;而换哈希函数之所以拖了这么多年,正是因为每个对象都以哈希引用其他对象,SHA-1 与 SHA-256 仓库之间的互通必须靠一张双向翻译表。内容寻址给了不可篡改与免费去重,也把"改变标识方式"变成了一次全局手术。
跨域连接
- 概率论:内容寻址的整个前提是"两段不同内容撞上同一个哈希"可以忽略不计——而这是一个概率判断,不是逻辑保证。SHA-1 有 160 位,按生日界,随机碰撞要在 2 的 80 次方量级的对象数上才变得可期待,任何真实仓库都够不着;真正把风险从"概率"变成"工程问题"的是构造性攻击,它绕开生日界直接算出碰撞,所以 Git 的防线必须从"位数够多"改成"检测攻击特征"。同一套概率直觉也解释了缩写哈希的长度:日常写 7 位够用,而 Linux 内核那种量级的仓库要求引用提交时写满 12 位,长度是按仓库对象数反推出来的碰撞预算。
- 数字权利与隐私:哈希链带来的"不可篡改"在合规语境里会掉个头变成负债。有人误提交了含个人信息的文件,删掉它意味着重写那个提交、进而重写其后每一个提交的哈希,所有基于旧历史的克隆与引用全部作废;"被遗忘权"要求的可删除性,与内容寻址要求的可验证性在结构上就是对立的,这不是实现偷懒,而是同一份设计一次性买下的两个后果。因此现实中的补救(历史重写工具、强制推送、通知全部下游重新克隆)本质上是社会协调,而不是技术操作。
- 公共资源治理:廉价分支改变的不只是操作耗时,还有开源协作的权力结构。奥斯特罗姆研究公共池资源时反复强调,退出成本决定成员在议价中的位置;当"另开一支自己维护"只需要 41 字节且无需任何许可,维护者对贡献者的支配力就被结构性削弱,分叉从核选项变成日常姿态。代价是碎片化,于是社区把治理成本从"控制写权限"整体转移到"控制合并权"——代码审查、持续集成门禁、维护者制度,都是在补回被分支廉价性拆掉的那道闸。
- 什么是真理:Git 保证的是一致性,不是真实性。哈希能证明"你手里的历史和我手里的是同一份",却证明不了这份历史描述过任何真实发生过的事;
rebase产出的线性序列往往是一段从未存在过的施工过程——它按"应该怎么讲"重排,而不是按"实际怎么做"记录。团队在 merge 与 rebase 之间的偏好,因此本质上是在选择历史忠于事实还是忠于可读性,这与史学里编年与叙事之争同构。 - 供应链:软件供应链的溯源锚点正是内容寻址——锁文件里写死的哈希,让"我构建的和你审计的是同一份代码"变成可验证命题,这是纯用版本号做不到的。但哈希只能建立同一性,不能建立可信性:它证明不了这份内容出自谁之手,也证明不了上游账号没有被接管。所以真实的供应链防护必须在对象模型之上再叠一层签名与凭证(提交签名、构件签名、来源证明),Git 的哈希是这条链的地基,而不是这条链本身。
参考文献
- Git Project. gitformat-pack(5) 与 gitrepository-layout(5), Git 官方手册页, 2005 年至今. (对象库布局与 packfile 格式的规范定义)
- Chacon, S. & Straub, B. Pro Git, 2nd ed. Apress, 2014. 第 10 章「Git 内部原理」. (对象、引用与打包机制的标准阐述)
- Stevens, M., Bursztein, E., Karpman, P., Albertini, A. & Markov, Y. The First Collision for Full SHA-1. CRYPTO, 2017. (SHAttered 攻击原始论文)
- Merkle, R. C. A Certified Digital Signature. CRYPTO, 1989(1979 年博士工作). (哈希树的原始构造)
- Git Project. Git 2.29 Release Notes, 2020-10-19. (
--object-format=sha256的实验性支持) - Harry, B. The Largest Git Repo on the Planet. Microsoft DevBlogs, 2017. (Windows 仓库约 350 万文件 / 300 GB 的规模问题与 VFS for Git)
延伸阅读
- Wiegley, J. Git from the Bottom Up. (从对象与引用出发讲 Git,而不是从命令出发)
- Coglan, J. Building Git. 自出版, 2019. (从零实现一个 Git,逐章重建对象库、索引与打包)
- Git Project. gitcore-tutorial(7). (官方底层命令教程,全程只用 plumbing 命令走一遍提交流程)