计算机 · 2026 年 11 月 11 日

Git 的三种对象与一次 commit 的真实结构

git cat-file -p HEAD 打印的到底是什么?从 blob、tree、commit 的字节结构讲起,手工造一个 commit,理解哈希链和引用。

git cat-file -p HEAD 会打印出一段很像配置文件的内容:tree、parent、author、committer,空行之后是提交信息。看着像 Git 的私有格式,其实就是一个纯文本对象,前面加了一行类型和长度,整体 zlib 压缩后按内容哈希命名。

理解这一层,很多现象就不用背了:为什么 amend 会改变哈希、为什么 rebase 后所有 commit id 都变了、为什么 .git/objects 里是两位目录加 38 位文件名。

四种对象,一种存法#

Git 仓库里只有四类对象:blob 存文件内容,tree 存目录结构,commit 存一次提交,tag 存附注标签。它们的存储方式完全一致:<type> <content-length>\0<content>。

这段字节做 SHA-1(新仓库可选 SHA-256),得到 40 位十六进制,前两位当目录名,后 38 位当文件名,落在 .git/objects/ab/cdef...。内容用 zlib 压缩,所以直接 cat 是乱码,必须走 git cat-file。

一个能自己验证的例子:空树的哈希是 4b825dc642cb6eb9a060e54bf8d69288fbee4904,全世界所有仓库都一样。用 git hash-object -t tree /dev/null 算一遍,得到同样的值——哈希只由内容决定,没有随机成分。

注意 blob 只存内容,不存文件名和权限——这些在 tree 里。所以同一份内容出现在十个路径下,仓库里仍然只有一个 blob。

手搓一个 commit#

在临时仓库里跑,能看到每一步的中间产物:

bash
mkdir /tmp/git-lab && cd /tmp/git-lab && git init -q
blob=$(printf 'hello\n' | git hash-object -w --stdin)
git cat-file -t "$blob"            # blob
git cat-file -p "$blob"            # hello

tree=$(printf '100644 blob %s\thello.txt\n' "$blob" | git mktree)
commit=$(git commit-tree "$tree" -m 'manual commit')
git cat-file -p "$commit"

git hash-object -w 写入对象并返回哈希;git mktree 从 ls-tree 格式的文本造出 tree 对象;git commit-tree 用当前 user.name、user.email 和当前时间生成 commit。所以同样的内容在不同时间跑,哈希不同。

commit 对象的正文由五个部分组成:

text
tree 7a8b9c...(指向包含 hello.txt 的那棵 tree)
parent 8f3a1c...(首个 commit 没有这一行)
author 张三 <zhangsan@example.com> 1731200000 +0800
committer 张三 <zhangsan@example.com> 1731200000 +0800

manual commit

时间是 Unix 秒加时区偏移。同一个提交在不同时区生成,字节不同,哈希也不同——这就是为什么 GIT_AUTHOR_DATE 能用来复现历史提交。

另外注意 author 和 committer 是两个字段。前者是原始作者和写作时间,后者是最后一次应用这个改动的人和时间。rebase、cherry-pick、amend 都会保留 author,只更新 committer。用 git log --format='%an %ad / %cn %cd' 能看出哪些提交被人动过。

tree 里存了什么#

git ls-tree HEAD 的输出格式和 mktree 的输入一致:

text
100644 blob a1b2c3...    README.md
100755 blob d4e5f6...    deploy.sh
040000 tree 7a8b9c...    src

第一列是 mode:100644 普通文件,100755 可执行,040000 子目录,120000 符号链接,160000 是 submodule 的 commit 引用。子目录是一个嵌套的 tree,所以一个 commit 的 tree 是一棵完整的目录树。

文件名不参与 blob 的哈希,这带来一个副作用:重命名在 Git 看来是「删一个 blob 引用、加一个 blob 引用」,git log --follow 只能靠内容相似度猜,猜错很正常。

tag 也是一个对象#

附注标签(git tag -a v1.0 -m 'release')会真正写出一个 tag 对象。git cat-file -p v1.0 输出 object、type、tag、tagger 和说明文字,object 指向被标记的 commit。

轻量标签(git tag v1.0)不生成对象,只是往 refs/tags/ 写一个指向 commit 的文件。两者在 git describe、GPG 签名和发布记录上有区别,正式发版应该用附注标签。

index 是第三棵树#

工作区、暂存区(index)、HEAD 常被叫做 Git 的三棵树。index 本身不是对象,而是一个二进制文件 .git/index,记录每个路径的 mode、对象哈希和 stage。

git ls-files -s 的输出格式和 ls-tree 完全一致,形如 100644 a1b2c3... 0 README.md。第三列是 stage,冲突时同一路径会出现 1、2、3 三个条目。git add 做的事就是写 blob、更新 index;git commit 再把 index 序列化成 tree 并写 commit。所谓提交,本质是 index 的一次快照。

冲突时 index 里有什么#

合并冲突时,git ls-files -u 会列出同一路径的三个 stage:1 是共同祖先,2 是当前分支(ours),3 是对方(theirs)。三份 blob 哈希同时存在 index 里,工作区文件则被写入冲突标记。

解决冲突就是选一份或者手改,然后 git add。它会把三个条目合并回一个 stage 0,冲突才算结束。git checkout --ours <file> 只是把工作区文件换成自己那份,不会自动 add,很多人以为执行完就完事了。

理解 stage,就不会被 git status 里那句 both modified 绕晕:它只是在说这个路径有三个索引条目,还没归一。

哈希链和引用#

commit 之间靠 parent 字段串成链。每个 commit 的哈希包含父提交的哈希,所以改动历史中任何一个字节,它之后所有 commit id 都会变。这就是 rebase 后必须 force push 的原因——不是 Git 想为难你,是哈希真的全变了。

HEAD^ 是父提交,HEAD~3 是往上三代,HEAD^{tree} 直接取出 commit 指向的 tree。这些语法本质都是在对象图上走边。

引用只是文件:.git/refs/heads/main 里存着 40 位哈希,HEAD 里通常写 ref: refs/heads/main。detached HEAD 时,HEAD 直接存哈希。

删除分支只是删掉这个文件,commit 对象还在,靠 reflog 能找回来。先用 git reflog 找到位置,再用 git cat-file -p HEAD@{3} 看内容;实在找不到,用 git fsck --lost-found 扫一遍不可达对象。

reflog 默认保留 90 天,过期后 git gc 才会真正清理不可达对象。

packfile:从松散对象到压缩包#

git gc 之后,松散对象会被打包进 .git/objects/pack/*.pack,并在其中用 delta 存储——相似的文件只存差异。这时候单个对象不再有独立文件,git cat-file 会去 pack 里查。

git count-objects -vH 能看松散对象的数量和体积;git verify-pack -v .git/objects/pack/*.idx | sort -k3 -n | tail -5 按大小列出 pack 里的对象。第三列是字节数,按它排序就是最大的几个对象,仓库体积异常时这是最快的入口。后面的 depth 列是 delta 链深度,链太长会拖慢读取,git repack -adf 可以重建打包。

需要批量查对象时用 git cat-file --batch,一次进程处理多个哈希。脚本里循环调用 git cat-file -p 的写法,在几万个对象上会慢一个数量级,瓶颈全在反复 fork。

不知道对象大小时,git cat-file -s <hash> 直接给出解压后的字节数,比 -p 打出来再数省事。

除了松散对象和 packfile,还会看到一个 commit-graph 文件。它是提交图的索引,git commit-graph write 生成,能让 git log、git rev-list 在大仓库上快很多。它不存内容,只是一份派生索引,删掉也能重建。

想统计对象构成,用 git cat-file --batch-all-objects --batch-check='%(objecttype)' | sort | uniq -c,一眼就能看出这个仓库是 blob 多还是 tree 多。

排查大仓库#

bash
git rev-list --objects --all \
  | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \
  | sort -k3 -n | tail -10

这条会把每个对象的类型、哈希、大小和路径打出来,通常是某个历史版本里的大二进制文件。注意它只报告,不删除。清理历史要用 git filter-repo,而且必须先在副本上验证、通知所有协作者重新克隆——历史改写后,旧克隆推上来会把垃圾带回来。

对象 存什么 关键字段
blob 文件内容 无,纯字节
tree 一层目录 mode、类型、哈希、文件名
commit 一次提交 tree、parent、author、committer、message
tag 附注标签 object、type、tag、tagger、message

结论#

  • Git 对象就是「类型 + 长度 + 内容」做哈希,内容寻址决定了去重和不可变性。
  • blob 不带文件名,目录结构全在 tree 里,权限也记在 tree 的 mode 上。
  • commit 的哈希包含父提交,改历史必然改掉后续所有 id。
  • 分支和 HEAD 只是文本引用,删分支不等于删数据,reflog 还能救。
  • 排查仓库体积先用 rev-list --objects 排序,别直接 git gc 了事。

完

#Git#版本控制#底层原理
回到顶部 ↑