本节摘要:tree 对象是目录快照,commit 对象是"快照加元数据加父指针"的卷宗。本节动手解剖一个真实提交,把 commit → tree → blob 的哈希链条完整走一遍,并解释 Git 历史为什么是"快照链"而不是"差异链"——这是 Git 与前代版本控制系统最深的分野。
git ls-tree 与 git cat-file -p 逐层解剖提交接着上一节的实验仓库,制造一个带子目录的提交:
printf "readme v1\n" > README.md mkdir src printf "print('hi')\n" > src/app.py git add . git commit -m "first case" # [master(根提交) a1b2c3d] first case
提交后立刻取证。git rev-parse HEAD 拿到当前提交哈希,然后验尸:
git cat-file -p HEAD # tree 4f2a9c1d8e3b7f6a5d4c3b2a1908f7e6d5c4b3a2 # author 侦探 <det@example.com> 1724000000 +0800 # committer 侦探 <det@example.com> 1724000000 +0800 # # first case
这就是 commit 对象的全部真身:一个 tree 指针、作者与提交者信息、提交说明;首个提交没有 parent 字段,后续提交会多出一行 parent <父提交哈希>。字段虽少,信息量极大——注意里面没有 diff、没有"改了哪些行",只有一个 tree 哈希。
顺着 tree 指针往下挖:
git ls-tree HEAD # 100644 blob 2b3c4d5... README.md # 040000 tree 9e8f7a6... src git ls-tree HEAD src # 100644 blob 7d6c5b4... app.py
ls-tree 列出每条登记的模式(100644 普通文件、100755 可执行、040000 子目录、120000 符号链接)、对象类型、哈希和名字。至此链条闭合:commit 指向根 tree,tree 里的条目要么是 blob(文件)要么是子 tree(目录),一整棵树就是项目在该时刻的完整快照。文件名在哪?在 tree 里——上一节留下的"blob 不记名字"的缺口在这里补上了。
CVS、SVN 等前代系统存的是"相对上一个版本的差异",要看某个旧版本,得从起点一路重放补丁。Git 每次提交都存整棵树,但你不必担心膨胀:树里未变更的文件条目仍然指向旧 blob,真正新写入的只有变更过内容的 blob 和路径变化过的 tree。做个实验最直观:
printf "readme v2\n" > README.md git commit -am "second case" git cat-file -p HEAD^{tree} # 100644 blob 5a4b3c2... README.md <- 新 blob # 040000 tree 9e8f7a6... src <- 哈希没变,复用旧 tree
src 目录没动,新的根 tree 直接复用了旧子树的哈希。这个设计把"全量快照的语义"和"增量的存储成本"同时拿到了,是 Git 性能的根基。也解释了一个日常现象:git checkout <旧提交> 后文件"瞬间"变回旧样子——因为根本不需要重放历史,直接按旧 tree 的登记把对象库里的 blob 取出来写入工作区即可,一千年前的快照和一秒前的快照取用成本相同。
commit 对象里 author 与 committer 分开,常被误认为是冗余。实际用意在于协作场景:当你用 git rebase 或 git cherry-pick 把别人的提交搬到新位置时,内容与原作者信息保留,但"执行搬运的人"记在 committer 字段里,时间戳也更新为搬运时刻。审计时这条线索很有用——一条提交链上 author 时间早于 committer 时间好几分钟,多半经历过变基或拣选。
第一个 parent 之外,merge commit 会有两个 parent(第 2 章 2.2 节会用到这个证据);tag 对象则是指向 commit 的带签名注解,放在第 4 章发布流程里细说。
哈希即地址,链即历史。 每个 commit 通过 parent 指针指向前一个,形成单向链表;两条链在 merge commit 处汇合,就是分支拓扑。Git 的所有历史操作——log、diff、merge、rebase——都只是在这些链上做图遍历。没有中心数据库给提交编号,哈希本身就是全局唯一地址,这就是分布式协作不需要"先到服务器申请版本号"的原因。

为什么提交哈希只有 7 位也够用? 完整哈希 40 位,显示时截短只要在当前对象库内无歧义即可,git rev-parse 接受任何不冲突的前缀。
改写历史会破坏链条吗? 会生成新对象新链条,旧链条仍在(第 2 章详述)。对象不可变是铁律,所有"修改"都是新建。
空提交存在吗? git commit --allow-empty 会创建一个 tree 与父提交完全相同、仅说明不同的提交,常用作标记节点(比如发布标记占位)。
cat-file -p 提交 → ls-tree 根树 → 逐层到 blob,是本教程反复使用的勘查动作。下一节把镜头拉远:这些对象在"工作区 → 暂存区 → 仓库"三区之间如何流转。