大多数开发者每天都在使用 git add、git commit、git push,但对 Git 内部如何存储数据却知之甚少。当你真正理解了 Git 的对象模型,很多看似“魔法”的行为——比如为什么切换分支如此之快、为什么 Git 能精确追踪文件重命名、为什么 reset 有三种模式——都会变得理所当然。本文将从底层数据结构出发,系统讲解 Git 的四种核心对象:blob、tree、commit 和 ref。
Git 是一个内容寻址文件系统
Git 的核心是一个键值数据库。你往里面存入任意内容,它会返回一个唯一的键,之后你可以用这个键取回内容。这个键就是内容的 SHA-1 哈希值(Git 正在逐步迁移到 SHA-256)。
Git 将数据存储在 .git/objects 目录下。每个对象经过 zlib 压缩后存放,文件路径由哈希值的前两个字符作为子目录、后 38 个字符作为文件名。例如哈希 a1b2c3... 对应的路径是 .git/objects/a1/b2c3...。
关键点在于:相同的输入永远产生相同的哈希。这意味着如果两个文件内容完全一样,Git 只会存储一份。这也是 Git 仓库通常比 SVN 等系统更紧凑的原因之一。
Git 有四种对象类型:blob、tree、commit 和 tag。加上 ref 作为引用机制,构成了完整的版本控制模型。
blob:存储文件内容
blob(Binary Large Object)是 Git 中最基本的对象,它只存储文件的内容,不包含文件名、权限或任何元数据。
创建一个 blob 非常简单:
echo "hello world" | git hash-object -w --stdin
# 输出: 3b18e512dba79e4c8300dd08aeb37f8e728b8dad
你可以用 git cat-file -p 3b18e5 取回内容。注意,blob 的哈希只取决于内容本身——两个不同路径下内容相同的文件,共享同一个 blob 对象。
blob 的存储格式为:blob <内容长度>\0<内容>。头部信息也参与哈希计算,这防止了不同对象类型之间的哈希碰撞。
tree:目录结构的快照
单个 blob 只能表示一个文件。要表示目录结构,Git 使用 tree 对象。一个 tree 包含若干条目,每个条目指向一个 blob(文件)或另一个 tree(子目录)。
用 git cat-file -p 查看一个 tree 的典型输出:
100644 blob a1b2c3d4... README.md
100755 blob e5f6a7b8... build.sh
040000 tree 9c8d7e6f... src
每行包含三部分:
- 模式:
100644表示普通文件,100755表示可执行文件,040000表示子目录,120000表示符号链接。 - 对象类型和哈希:指向具体的 blob 或 tree。
- 名称:该条目在目录中的文件名。
tree 对象完美地表示了某一时刻的目录快照。由于 tree 可以嵌套,整个项目的目录结构就形成了一棵有向无环图(DAG)。修改一个文件只会产生新的 blob 和从根到该文件路径上的新 tree,其余 tree 对象完全复用——这就是 Git 高效存储的秘诀。
commit:时间线上的节点
commit 对象将一棵 tree(代表项目某个时刻的完整快照)与历史记录关联起来。一个 commit 包含以下信息:
tree 9c8d7e6f...
parent 1a2b3c4d...
author Alice <alice@example.com> 1700000000 +0800
committer Alice <alice@example.com> 1700000000 +0800
feat: add user authentication
关键字段解读:
- tree:指向本次提交对应的根 tree 对象,代表项目在该时刻的完整状态。
- parent:指向父 commit。普通提交有一个父节点,合并提交有两个或多个父节点,初始提交没有父节点。
- author / committer:作者和提交者信息,包括时间戳和时区。注意这两者可以不同——比如你 rebase 别人的提交时,author 不变但 committer 变成你。
- 提交信息:描述本次变更的文本。
commit 的哈希由其全部内容计算得出,包括 tree 哈希、parent 哈希、作者信息和提交信息。这意味着任何微小的改动都会产生完全不同的 commit 哈希。这也解释了为什么修改历史(如 rebase)会导致所有后续 commit 的哈希全部改变。
通过 parent 指针,commit 形成了一条链(或更准确地说,一个有向无环图)。git log 本质上就是从某个 commit 出发,沿着 parent 指针不断回溯的过程。
ref:人类可读的指针
如果每次操作都要输入 40 位的 SHA-1 哈希,Git 就没法用了。ref(引用)就是指向 commit 的指针,用人类可读的名称代替哈希值。
常见的 ref 类型包括:
- 分支:
.git/refs/heads/main是一个文本文件,内容就是该分支最新 commit 的哈希。 - 标签:
.git/refs/tags/v1.0指向标签对应的 commit(或 tag 对象)。 - 远程跟踪分支:
.git/refs/remotes/origin/main。 - HEAD:
.git/HEAD是一个特殊文件,通常内容是ref: refs/heads/main,表示当前所在分支。
分支的本质就是一个包含 commit 哈希的文件。创建分支就是写入一个 41 字节的文件(40 字符哈希加换行符),这就是为什么 Git 创建分支几乎瞬间完成——它只是写了一个文件而已。
HEAD 的间接引用机制也很优雅:当 HEAD 指向 refs/heads/main 时,提交新 commit 会自动更新 main 的哈希值。而当你 git checkout 某个具体 commit 时,HEAD 会直接包含哈希值,进入“分离头指针”状态。
四者如何协同工作
让我们用一个完整流程串联这些概念:
- 你编辑了
src/app.js,运行git add src/app.js。Git 对文件内容计算哈希,创建 blob 对象存入.git/objects。 - 运行
git commit。Git 根据暂存区的状态创建新的 tree 对象(包括src/子 tree 和根 tree),每个 tree 指向对应的 blob。 - Git 创建一个 commit 对象,指向根 tree,parent 设为当前 HEAD 指向的 commit。
- Git 更新当前分支 ref(如
refs/heads/main),使其指向新 commit 的哈希。
整个过程没有任何文件被“复制”或“移动”——只有新对象的创建和指针的更新。
理解对象模型的实际价值
掌握这些底层原理后,很多日常操作会变得清晰:
git reset --softvs--mixedvs--hard:三者的区别在于移动 HEAD/分支指针后,是否更新暂存区(index)和工作目录。对象本身从不被删除,只是引用关系变了。git gc和悬空对象:当对象不再被任何 ref 或 tree 引用时,它会成为“悬空对象”,最终被垃圾回收。git reflog保留的引用能防止对象被过早回收。- 浅克隆为什么快:
--depth=1只拉取最新的 commit 及其 tree/blob,跳过了历史 commit 对象。 git cat-file和git ls-tree:这些底层命令让你直接查看对象内容,是调试仓库问题的利器。
Git 的对象模型设计精巧而简洁:blob 存内容,tree 存结构,commit 存历史,ref 存入口。四者各司其职,共同构建出一个高效、可靠、分布式的版本控制系统。下次当你执行 git commit 时,不妨想想背后那些正在 .git/objects 中诞生的新对象——你已经在用底层思维理解 Git 了。
未经允许不得转载:任鹏个人博客 » 理解 Git 对象模型:blob、tree、commit 与 ref 的底层原理


朋友圈点赞图在线生成源码