bigywater.tech
Git · 5

手工解开 .git/objects 里的 blob、tree、commit:原始字节长什么样

.git/objects 里那些名字像乱码的文件到底是什么?这篇用 zlib 把松散对象逐个解压,看原始字节:blob 的头部与内容、tree 里没有分隔符的二进制条目、commit 的纯文本结构,最后不用 git 命令,自己从 HEAD 走到每个文件。

8 个
两次提交共 8 个对象:2 个 commit、3 个 tree、3 个 blob
20 字节
tree 条目里每个 hash 是 20 字节二进制,而不是 40 位十六进制文本
完全一致
只用 zlib 手工解析 HEAD→tree→blob,与 git ls-tree -r 的输出逐行相同

松散对象的路径

实测 · 本机执行输出
  $ git log --oneline
  f9b1362 改一下 README
  4591541 第一次提交
  $ find .git/objects -type f | sort
  .git/objects/01/52b8f966255cffca1c083a341f7c0d5809730b
  .git/objects/25/7d8da2938464704dc06cfd2408017a1a607b66
  .git/objects/37/a4b05693857e190d1ecbe45b4451bc36078274
  .git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad
  .git/objects/45/91541c3541d409dc8b0a6c156d7566e88fd194
  .git/objects/b8/0e3222ab264bd7cafb376749bd18814fd66776
  .git/objects/f0/e9ea9cdcd1c7d022373365088a65e94c5ab13e
  .git/objects/f9/b1362295e9b51a81b4adc680753b8f7e640a0f
  $ find .git/objects -type f | wc -l
         8

每个对象是 .git/objects/ 下的一个文件,路径由 hash 决定:前 2 位十六进制当目录名,后 38 位当文件名。内容相同的对象 hash 相同,所以只存一份——第二次提交没碰 src/,它的 tree 与 blob 就直接复用,对象总数才只有 8 个。

blob:头部加原始字节

实测 · 本机执行输出
  src/main.py 的 blob = b80e3222ab264bd7cafb376749bd18814fd66776
  文件 .git/objects/b8/0e3222ab264bd7cafb376749bd18814fd66776 的大小: 28 字节(压缩后);解压后 20 字节:
  0000  62 6c 6f 62 20 31 32 00 70 72 69 6e 74 28 22 68  blob 12.print("h
  0010  69 22 29 0a                                      i").
  repr: b'blob 12\x00print("hi")\n'
  $ git cat-file -t b80e3222ab264bd7cafb376749bd18814fd66776; git cat-file -s b80e3222ab264bd7cafb376749bd18814fd66776; git cat-file -p b80e3222ab264bd7cafb376749bd18814fd66776
  blob
  12
  print("hi")

松散对象是 zlib 压缩的。解压后固定是“类型 空格 长度 NUL 内容”,blob 的内容就是文件的原始字节,不带文件名,也不带权限。很小的文件经过头部和压缩的固定开销,磁盘上的文件可以比内容还大。

tree:没有分隔符的二进制

实测 · 本机执行输出
  根 tree = 0152b8f966255cffca1c083a341f7c0d5809730b,解压后 75 字节:
  0000  74 72 65 65 20 36 37 00 31 30 30 36 34 34 20 52  tree 67.100644 R
  0010  45 41 44 4d 45 2e 6d 64 00 f0 e9 ea 9c dc d1 c7  EADME.md........
  0020  d0 22 37 33 65 08 8a 65 e9 4c 5a b1 3e 34 30 30  ."73e..e.LZ.>400
  0030  30 30 20 73 72 63 00 37 a4 b0 56 93 85 7e 19 0d  00 src.7..V..~..
  0040  1e cb e4 5b 44 51 bc 36 07 82 74                 ...[DQ.6..t
  头部: tree 67
  条目 1: 模式=100644 名字=README.md  20 字节=f0e9ea9cdcd1c7d022373365088a65e94c5ab13e
  条目 2: 模式=40000  名字=src        20 字节=37a4b05693857e190d1ecbe45b4451bc36078274
  $ git ls-tree 0152b8f966255cffca1c083a341f7c0d5809730b
  100644 blob f0e9ea9cdcd1c7d022373365088a65e94c5ab13e	README.md
  040000 tree 37a4b05693857e190d1ecbe45b4451bc36078274	src

tree 对象把目录里每个条目写成“模式 空格 名字 NUL 20 字节二进制 hash”,条目首尾相接。目录模式在这里是 40000,git ls-tree 显示时才补成 040000。因为 hash 是原始字节,直接用文本编辑器看 tree 对象会是乱码。

commit:纯文本

实测 · 本机执行输出
  HEAD = f9b1362295e9b51a81b4adc680753b8f7e640a0f,解压后 226 字节:
  头部: commit 215
  | tree 0152b8f966255cffca1c083a341f7c0d5809730b
  | parent 4591541c3541d409dc8b0a6c156d7566e88fd194
  | author Alice <alice@example.com> 1767261900 +0000
  | committer Alice <alice@example.com> 1767261900 +0000
  | 
  | 改一下 README
  | 
  $ git cat-file -p HEAD
  tree 0152b8f966255cffca1c083a341f7c0d5809730b
  parent 4591541c3541d409dc8b0a6c156d7566e88fd194
  author Alice <alice@example.com> 1767261900 +0000
  committer Alice <alice@example.com> 1767261900 +0000
  
  改一下 README

commit 对象是几行头部加空行加提交说明:tree、parent(可以有零个、一个或多个)、author、committer。所谓“提交”就是这样一段文本,它指向某个目录快照和上一个提交。

从 HEAD 走到文件

实测 · 本机执行输出
  .git/HEAD: ref: refs/heads/main
  refs/heads/main: f9b1362295e9b51a81b4adc680753b8f7e640a0f
  commit 的 tree 行: 0152b8f966255cffca1c083a341f7c0d5809730b
  Python 走出来的:
    100644 blob f0e9ea9cdcd1c7d022373365088a65e94c5ab13e	README.md
    100644 blob b80e3222ab264bd7cafb376749bd18814fd66776	src/main.py
  与 git ls-tree -r HEAD 完全一致: True

HEAD 是一个符号引用,指向 refs/heads/main 这个文件,文件里是 commit 的 hash;commit 里的 tree 行、tree 里的条目一层层往下,就能走到每个 blob。整个 git 的读取就是这条链。

分步演示

手工解开对象
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / git 2.54.0(Apple Git-157);每个实验在临时目录里新建仓库,HOME 和全局配置指向实验专用的空目录(不读用户自己的 git 配置),提交的作者与时间固定以便复现;需要网络协议的实验用本机的 file:// 或 git daemon 回环端口(9800 以上),不碰任何远程仓库;计时用 time.perf_counter,数字每次运行会有小幅抖动。第五节的 Python 解析只用 zlib 与字节切分,没有调用 git;所有 hash 因作者、时间固定而可复现
    • 没有验证:对象存在 packfile 里时的读取方式(见后面 packfile 的文章);SHA-256 仓库里的 hash 长度(32 字节,后面单独一篇);带符号链接、子模块的 tree 条目(下一篇有四种模式);GPG 签名提交多出来的 gpgsig 头
    京ICP备2026067502号-1京公网安备11010502063487号