很多人知道 git 存的是"快照"不是"diff",但很少有人真的扒开 .git/objects 看过——那个 40 位哈希组成的文件名,到底是随便分配的编号,还是真的跟内容本身绑死?真实建了几个文件、真实解压对象文件、手动重算了一遍哈希,一步步验证。
.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.txtone.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:
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 对象跟 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 对象:
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 对象的数量往往跟文件数量同一个量级,这个"省一半"在真实项目里不是小数目。
内容寻址的另一面:哪怕只改一个字符,新对象跟旧对象在哈希上完全没有相似之处,而且改动会沿着目录树往上传导。
只加一个感叹号 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 历史存储的增量只跟"变了多少不同的内容"挂钩,而不是跟"改了多少次"或者"仓库里总共有多少文件"挂钩的原因。