bigywater.tech
Git 内部原理 · 开篇

Git 是怎么给内容去重的:两个路径不同、内容相同的文件,.git 里真的只存一份

很多人知道 git 存的是"快照"不是"diff",但很少有人真的扒开 .git/objects 看过——那个 40 位哈希组成的文件名,到底是随便分配的编号,还是真的跟内容本身绑死?真实建了几个文件、真实解压对象文件、手动重算了一遍哈希,一步步验证。

3 个文件 → 2 份 blob
两个路径不同、内容相同的文件真实提交后,.git/objects 里只多了一份内容对象
改 1 个字节 → 全新哈希
真实只多打一个感叹号,新旧两个哈希完全不沾边,牵连了 3 层父目录对象跟着变

思路:git 存的是内容的哈希,不是文件的路径

.git/objects 下面那些看起来随机的文件名,其实是内容本身算出来的。

Git 的底层存储只有四种对象:blob(文件内容)、tree(目录结构)、commit(一次提交)、tag。每个对象都按同一个规则存放:先拼一个头部 "<类型> <字节长度>\0",后面接上真正的内容,整个拼出来的字节串算一次 SHA-1,哈希值本身就是这个对象在 .git/objects/ 下的存放路径(前 2 位做子目录名,剩下 38 位做文件名)。这意味着:只要两段内容完全一样,不管它们出现在哪个文件名、哪个目录下,算出来的哈希必然一样——git 天然就是内容寻址存储(content-addressable storage),不需要额外写一套去重逻辑。

真实验证:两个内容相同、路径不同的文件,只存一份

建三个文件,其中两个内容完全一样但路径不同,看 git 到底存几份。

造数据 mkdir -p src/a src/b echo "hello git internals" > src/a/one.txt # 内容 A echo "hello git internals" > src/b/two.txt # 内容跟 one.txt 完全一样,路径不同 echo "this file is different" > src/a/three.txt # 内容不一样 git add -A && git commit -m "first commit"
真实实测
$ git ls-tree -r HEAD
100644 blob b00f56f95b66e87d8d2f3baff7ee67f101c7bfc6	src/a/one.txt
100644 blob f6eeccd6a3eb28bc383121454a0bb41cd59114ab	src/a/three.txt
100644 blob b00f56f95b66e87d8d2f3baff7ee67f101c7bfc6	src/b/two.txt
one.txt 和 two.txt 是同一个 blob hash(b00f56f9...),尽管它们是两个完全独立的文件

git ls-tree 已经能看出苗头,再数一下 .git/objects 目录里真实有几个对象文件坐实这件事:

真实实测
$ find .git/objects -type f | wc -l
7

3 个文件,只对应 2 份 blob——剩下 7 个对象里,2 个是 blob(one.txt/two.txt 共用一份、three.txt 单独一份)、4 个是 tree(根目录、src/、src/a/、src/b/ 各一个)、1 个是 commit,加起来正好 7。two.txt 完全没有产生新的对象,它在磁盘上"存在"这件事,只是 src/b 这个 tree 对象里的一行记录,指向跟 one.txt 一模一样的那份 blob。

真实验证:文件名真的是内容算出来的,不是随便分配的

不信 git,自己动手解压 + 重新算一遍哈希,看跟文件名对不对得上。

.git/objects 里的文件是 zlib 压缩过的,手动解压看看原始字节长什么样,再按 git 的规则重新算一遍 SHA-1:

真实实测(Python)
import zlib, hashlib

sha = "b00f56f95b66e87d8d2f3baff7ee67f101c7bfc6"
path = f".git/objects/{sha[:2]}/{sha[2:]}"
data = zlib.decompress(open(path, "rb").read())

print(data)
# b'blob 20\x00hello git internals\n'

recomputed = hashlib.sha1(data).hexdigest()
print(recomputed == sha)
原始字节: b'blob 20\x00hello git internals\n' 手动重算的 sha1: b00f56f95b66e87d8d2f3baff7ee67f101c7bfc6 —— 跟文件名完全一致: True

解压出来的内容是 "blob 20\0hello git internals\n"——blob 是类型,20 是内容的字节长度(不含头部),\0 之后才是真正的文件内容。把这整段字节丢给 hashlib.sha1,算出来的值跟文件路径(.git/objects/b0/0f56f9...)一字不差——这不是巧合,git 存对象时用的就是这个规则,文件名不是分配出来的编号,是内容本身的指纹。

真实验证:手动解析 tree 对象的二进制格式

tree 对象跟 blob 不太一样,是纯二进制,每条记录里嵌了 20 字节原始哈希,不是 40 位的十六进制字符串。

真实实测
$ git cat-file -p HEAD
tree 3c04a9f3f85dc0776bbf877276b8d374b0ea4d37
author demo <demo@example.com> 1790056070 +0800
committer demo <demo@example.com> 1790056070 +0800

first commit

$ git cat-file -p 3c04a9f3
040000 tree 4ff7ebc800c4dabb02bd189d1e032882f2ba139f	src

用 git cat-file -p 看到的是格式化过的文本,真实按字节手动解析一遍根 tree 对象:

真实实测(Python)
data = zlib.decompress(open(".git/objects/3c/04a9f3...", "rb").read())
# header: b'tree 30'
# body 里每条记录: " <文件名>\0<20字节原始哈希>",没有分隔符,
# 靠固定的 20 字节长度切割,读完一条紧接着读下一条

mode=40000 name='src' sha1(20原始字节转成hex)=4ff7ebc800c4dabb02bd189d1e032882f2ba139f
手动从二进制里抠出来的 20 字节,转成十六进制之后,跟 git cat-file -p 显示的哈希完全一致

这里能看出 tree 对象为什么是二进制格式而不是纯文本:如果每条记录都写成 40 位十六进制字符串,一个哈希就要占 40 字节;直接存 20 字节原始二进制,体积正好减半。一个仓库里 tree 对象的数量往往跟文件数量同一个量级,这个"省一半"在真实项目里不是小数目。

真实验证:改 1 个字节,牵连了几层目录

内容寻址的另一面:哪怕只改一个字符,新对象跟旧对象在哈希上完全没有相似之处,而且改动会沿着目录树往上传导。

只加一个感叹号 echo "hello git internals!" > src/a/one.txt # 原来是 "hello git internals"(没有!) git add -A && git commit -m "second commit: tweak one.txt"
真实实测
改之前 one.txt 的 blob: b00f56f95b66e87d8d2f3baff7ee67f101c7bfc6
改之后 one.txt 的 blob: 8abfcf6b257b08b0ae5f7dfac5198818b0c390f3

$ find .git/objects -type f | wc -l
12   # 第一次提交后是 7,这次提交完变成 12,多了 5 个
只多了一个感叹号,新哈希(8abfcf6b...)跟旧哈希(b00f56f9...)在字面上毫无相似之处——SHA-1 的雪崩效应,输入差一点,输出面目全非

多出来的 5 个对象是:one.txt 新内容的 blob(1 个)+ 从 src/a/ 到根目录一路上跟着变的 tree 对象(src/a、src、根目录,3 个)+ 这次的 commit 对象(1 个)。src/b/two.txt 完全没被改动,它对应的 blob 和 src/b 这个 tree 都原样复用第一次提交时的对象,没有产生任何新文件。改一个深层文件会连带产生几层新的父目录 tree 对象,但没改的兄弟文件/子目录不受任何影响,继续复用旧对象——这也是 git 历史存储的增量只跟"变了多少不同的内容"挂钩,而不是跟"改了多少次"或者"仓库里总共有多少文件"挂钩的原因。

为什么不直接用文件名当 key?如果按文件名存,src/a/one.txt 和 src/b/two.txt 就是两条独立记录,哪怕内容一模一样也得存两份;git 按内容的哈希存,内容相同天然共享同一份对象,这个去重效果是存储方式本身带来的副作用,不是额外写了什么去重算法。

参考与说明

  • 本文全部对象哈希、字节数、对象数量均在本机真实 git init 后用真实命令(git ls-tree/git cat-file)和 Python 标准库(zlib/hashlib)现场跑出,没有手写编造任何一个哈希值。
  • 没有涉及:git 的 pack 文件(仓库跑 git gc 之后,松散对象会被打包压缩成 delta,存储结构会不一样)、以及 commit/tag 对象更完整的字段格式——这些留给系列后面的文章。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电
京ICP备2026067502号-1京公网安备11010502063487号