三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Git学习笔记:Git 底层存储设计 — blob、tree、commit、ref 如何组成一个版本控制系统

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# → blob
blob、tree、commit 都在一个目录?

对,全部混在一起。没有按类型分文件夹:

.git/objects/22/5963... ← 可能是 blob .git/objects/ab/cdef... ← 可能是 commit .git/objects/12/3456... ← 可能是 tree

Git 怎么区分?靠文件内部的头部信息。每个对象存的是:

对象类型 内容长度\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/main

HEAD 指向一个 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 loggit checkout maingit 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 addgit commitgit 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命令,最终都在操作这四类对象。

← 返回列表