Git学习笔记:Git 底层存储设计 — blob、tree、commit、ref 如何组成一个版本控制系统
📝本文首发于 栏轩·阁
欢迎访问阅读原文,获取更好的阅读体验。
核心设计思想
Git 不是传统意义上的"版本控制系统",它的设计理念和 SVN、CVS 完全不同。
传统 VCS:基于 diff
SVN 等系统记录的是"文件的变更"——第一次提交是完整文件,后续只保存每次修改的差异。要还原版本,需要从初始状态起逐个叠加 diff。
Git:基于快照
Git 每次提交保存的是完整的文件快照,不是差异。如果文件没变,就复用一个引用。这种设计的优势:
| 特性 | 效果 |
|---|---|
| 完整性 | 每个版本都是完整的,不会因为 diff 链断裂而丢失数据 |
| 速度 | 切换分支/版本只需替换工作目录文件,不用逐个计算 diff |
| 分布式 | 每个克隆都是完整仓库,不依赖中央服务器 |
内容寻址
Git 最核心的设计:所有对象通过其内容的 SHA-1 哈希值来寻址。
内容 → SHA-1 哈希 → 文件名(存在 .git/objects 目录下)这意味着:
- 同样的内容永远产生同样的 SHA(在不同仓库、不同机器上)
- 内容变了,SHA 就变 —— 所以对象一旦创建就不可修改
- 修改 = 创建新对象,原对象仍然存在
四类底层对象
Git 只有四类底层对象,所有功能都建立在这四类之上:
Blob(文件内容) → Tree(目录结构) → Commit(快照) → Ref(指针)1. Blob — 文件内容
Blob 是最底层的对象,只存文件内容,不存文件名。
┌─────────────┐ │ Blob │ │ size: 11 │ │ content: │ │ "hello" │ │ SHA: abc12 │ └─────────────┘内容 → SHA → 存进
.git/objects/ab/c123...
关键理解:Blob 不关心文件名。文件名属于 tree 的职责。所以把一个文件改名后提交,Git 存储的 blob 不变,只是 tree 里的路径变了。
2. Tree — 目录结构
Tree 记录一个目录里所有文件和子目录的布局:
┌──────────────────────────┐ │ Tree │ │ blob abc12 README.md │ │ blob def34 main.go │ │ tree ghi56 src/ │ │ SHA: xyz78 │ └──────────────────────────┘每个条目包含:
- mode:文件模式(普通文件、可执行文件、目录、符号链接)
- type:blob(文件)或 tree(子目录)
- sha:指向的 blob 或 tree 的哈希
- path:文件名或目录名
关键理解:Tree 是整个目录的完整快照。更新一个文件 = 创建新 blob → 新 tree(指向新 blob)→ 新 commit。
3. Commit — 提交快照
Commit 将 tree 固定为一个历史版本:
┌────────────────────────┐ │ Commit │ │ tree xyz78 │ ← 当时完整的目录快照 │ parent older123 │ ← 前一个版本 │ author "You" │ │ msg "feat: add x" │ │ SHA: commit456 │ └────────────────────────┘关键理解:Commit 本质上是指向 tree 的指针,加上时间戳和作者信息。不存 diff,不存变更内容——就是一张完整的目录照片。
4. Ref — 分支 / 标签
Ref 是指向 commit 的指针,是 Git 里唯一"可变"的东西:
refs/heads/main → commit456 refs/heads/dev → commit789 refs/tags/v1.0 → commit123关键理解:分支就是 ref。创建分支 = 创建指向某个 commit 的 ref。切换分支 = 把工作目录还原到 ref 指向的 commit 的 tree。
.git 目录结构
以上四类对象在磁盘上是怎么存的?初始化一个仓库看看.git目录:
gitinit myrepo tree .git.git/ ├── HEAD # 当前分支指针(内容:ref: refs/heads/main) ├── config # 仓库配置(用户信息、远程地址等) ├── description # 仓库描述 ├── hooks/ # 钩子脚本(pre-commit、post-receive 等) ├── info/ │ └── exclude # 本地排除规则(类似 .gitignore,不提交) ├── objects/ # ★ 对象库 —— 核心 │ ├── 22/ # SHA 前两位 = 目录 │ │ └── 596363b3de... # 后 38 位 = 文件名(压缩后的对象) │ ├── ab/ │ │ └── c123def456... # 一个文件 = 一个对象 │ ├── info/ # 对象库的索引信息 │ └── pack/ # 压缩后的打包文件(git gc 后产生) └── refs/ # ★ 指针 —— 分支和标签 ├── heads/ │ ├── main # refs/heads/main 内容是一个 SHA │ └── dev # refs/heads/dev 也是 40 位 SHA └── tags/ └── v1.0 # refs/tags/v1.0 指向某个 commit关键目录详解
objects/ — 对象库
所有 blob、tree、commit 都存这里。存储规则:
SHA = 22596363b3de40b06f981fb85dac12e096651f52 ↑ ↑ 前 2 位 = 目录名 后 38 位 = 文件名路径:.git/objects/22/596363b3de40b06f981fb85dac12e096651f52
文件内容是经过zlib 压缩的对象数据,不是明文。可以用git cat-file查看:
# 查看 blob 内容gitcat-file-p22596363b3de40b06f981fb85dac12e096651f52# → "hello\n"# 查看对象类型gitcat-file-t22596363b3de40b06f981fb85dac12e096651f52# → blobblob、tree、commit 都在一个目录?
对,全部混在一起。没有按类型分文件夹:
.git/objects/22/5963... ← 可能是 blob .git/objects/ab/cdef... ← 可能是 commit .git/objects/12/3456... ← 可能是 treeGit 怎么区分?靠文件内部的头部信息。每个对象存的是:
对象类型 内容长度\0原始数据所以磁盘上同样的22596363...文件,实际内容可能是:
blob 6\0hello\n而不是只有hello\n。Git 读取时先解析blob 6\0这个头部,就知道:
- 类型是
blob - 内容长度 6 字节
- 之后
hello\n才是真正的内容
用git cat-file -t只看类型,-p解压并显示纯内容,自动跳过头部。
refs/ — 指针目录
分支和标签在这里只是普通文本文件,文件内容就是目标 commit 的 40 位 SHA:
cat.git/refs/heads/main# → 22596363b3de40b06f981fb85dac12e096651f52这就是为什么"创建分支成本为零"——只是写一个 40 字节的文件。
HEAD — 当前在哪
cat.git/HEAD# → ref: refs/heads/mainHEAD 指向一个 ref,ref 指向一个 commit,commit 指向一个 tree,tree 指向 blob——一条链就到了文件内容。
磁盘上的完整链条
.git/HEAD → ref: refs/heads/main ↓ .git/refs/heads/main → commit SHA ↓ .git/objects/ab/cdef... (commit 对象) ↓ 包含 tree 的 SHA .git/objects/12/3456... (tree 对象) ↓ 包含 blob 的 SHA .git/objects/22/5963... (blob 对象 = 文件内容)你平时执行git log、git checkout main、git diff等命令,Git 就是在遍历这条链。
工作区 / 暂存区 / 仓库
Git 的三棵树(Three Trees)概念:
工作区(Working Directory) ← 你眼睛看到的文件 │ │ git add ↓ 暂存区(Staging Area / Index) ← .git/index,准备提交的内容 │ │ git commit ↓ 仓库(Repository / .git) ← 已提交的历史版本工作区
就是你电脑上看到的文件目录。你在这里新建、编辑、删除文件——这些操作Git 不会主动记录。
暂存区(Index)
.git/index文件,存的是下次要提交的文件清单。就是git add后的状态:
# 暂存区里到底存了什么?gitls-files--stage# → 100644 22596363b3de40b06f981fb85dac12e096651f52 0 README.md# ↑mode ↑blob SHA ↑文件名暂存区不存文件内容,只存blob SHA → 文件路径的映射。文件内容已经进了.git/objects/。
仓库(Repository)
就是.git/目录,已提交的所有历史版本都在这里。
三棵树的工作流程
文件在手里(工作区) │ git add → 内容写入 objects/,路径写入 index ↓ 文件在暂存区(.git/index) │ git commit → 创建 tree(当前 index 的快照)→ 创建 commit ↓ 文件在仓库(.git/objects/ + refs/)所以git commit的本质是:
把 .git/index(暂存区)拍一张快照 → 存成 tree → 创建 commit 指向它这就是为什么 Git 说"先 add 再 commit"——add 把内容存进
objects/,commit 把目录结构存成 tree。
链条:从文件到版本
一次完整的提交链条:
文件内容 → SHA → Blob ↘ Tree(目录结构) ↙ Commit(快照) │ ↓ Ref(指针:分支/标签)具体流程:
1. 你在本地:echo "hello" > README.md ↓ 2. Git 计算 "hello\n" 的 SHA → 存为 blob ↓ 3. Git 创建一个 tree,记录 README.md → blob 的 SHA ↓ 4. Git 创建一个 commit,指向这个 tree,记录时间/作者 ↓ 5. 分支 refs/heads/main 指向这个 commit同一个内容的文件,只存一份
如果你在两个仓库里都创建了内容为hello\n的文件,它们的 blob SHA完全一样。因为 SHA 只依赖内容:
# 在任何机器上运行,结果都一样echo"hello"|githash-object--stdin# → 22596363b3de40b06f981fb85dac12e096651f52这就是内容寻址的优势:同样的内容,全球共享一个 SHA,Git 不需要重复存储。
为什么这些设计重要
1. 不可变性 = 数据完整性
对象创建后不可修改。如果有人篡改了仓库的某个版本,它的 SHA 会变,后续所有 commit 的链条都会断裂——篡改无处遁形。
2. 分支便宜得像白菜
分支就是一个文件(ref),存的是 40 位 SHA。创建一百个分支只需要写一百个小文件,和仓库大小无关。
3. 分布式不需要中央服务器
每个克隆的git clone复制的是完整的对象库。你本地就有全部历史,离线也能提交、查日志、对比版本。
4. 快照对比 diff 的优势
| 基于 diff | 基于快照(Git) | |
|---|---|---|
| 还原 v100 | 应用 99 个 diff | 直接取出第 100 个 tree |
| 切换分支 | 逐个计算差异 | 直接替换工作目录文件 |
| 仓库损坏 | diff 链断裂 → 全损 | 每个快照独立 |
用 curl 验证这些概念
以下示例用 GitHub 的 Git Data API 验证上面的理论。不需要 Go,curl就够了。
1. 创建一个 blob
# 文件内容 "hello" → blobcurl-s-XPOST https://api.github.com/repos/cloud-drive-01/t/git/blobs\-H"Authorization: Bearer$token"\-H"Content-Type: application/json"\-d'{"content":"aGVsbG8K","encoding":"base64"}'# 返回:{"sha":"22596363b3de40b06f981fb85dac12e096651f52",...}
aGVsbG8K是"hello\n"的 base64 编码。这个 SHA 在任何 Git 仓库中,只要内容是hello\n,就一模一样。
2. 创建一个 tree
# blob → tree(把文件组织到目录里)curl-s-XPOST https://api.github.com/repos/cloud-drive-01/t/git/trees\-H"Authorization: Bearer$token"\-H"Content-Type: application/json"\-d'{"tree":[{"path":"README.md","mode":"100644","type":"blob","sha":"22596363b3de40b06f981fb85dac12e096651f52"}]}'3. 创建一个 commit
# tree → commit(拍一张快照)curl-s-XPOST https://api.github.com/repos/cloud-drive-01/t/git/commits\-H"Authorization: Bearer$token"\-H"Content-Type: application/json"\-d'{"message":"first commit","tree":"<tree的SHA>","parents":[]}'4. 更新 ref
# commit → ref(让分支指向这个版本)curl-s-XPATCH https://api.github.com/repos/cloud-drive-01/t/git/refs/heads/main\-H"Authorization: Bearer$token"\-H"Content-Type: application/json"\-d'{"sha":"<commit的SHA>","force":true}'这四步就是 Git 提交的完整链。你平时敲git add、git commit、git push时,Git 就在背后做这些事。
验证一下
# 查看当前仓库的根 treecurl-shttps://api.github.com/repos/cloud-drive-01/t/git/trees/<tree的SHA>\-H"Authorization: Bearer$token"# 返回的 tree 里每个条目就是一个文件或子目录总结
四类对象的职责
| 对象 | 职责 | 可变 | 存什么 |
|---|---|---|---|
| Blob | 文件内容 | ❌ 不可变 | 原始二进制内容 |
| Tree | 目录结构 | ❌ 不可变 | 文件名 → SHA 的映射 |
| Commit | 版本快照 | ❌ 不可变 | 指向 tree + 时间 + 作者 |
| Ref | 指针 | ✅ 可变 | 指向 commit 的 SHA |
数据流
文件内容 ↓ (SHA-1) Blob ↓ (加入 tree) Tree(目录结构的快照) ↓ (创建提交) Commit(历史版本) ↓ (指向) Ref(分支/标签)Git 设计哲学
- 内容寻址:一切通过内容哈希定位,同样的内容只存一次
- 不可变对象:只创建,不修改,保证历史完整性
- 快照代替 diff:每次提交是完整目录快照,切换/还原极快
- 指针代替副本:分支只是一个文件(40 字节),创建成本为零
- 完整本地仓库:每个 clone 包含全部对象,不依赖网络
理解 Git 的底层设计后,再看 Git Data API 和 Contents API——它们不是"附加功能",而是 Git 核心设计的外露接口。Blob、tree、commit、ref 不是 GitHub 的概念,是 Git 本身的概念。你日常敲的每一行
git命令,最终都在操作这四类对象。