揭开.git的面纱——Git内部原理
你每天都在用
git add、git commit、git push,但有没有想过,当你执行这些命令时,Git 在背后到底做了什么?那个隐藏在项目根目录下的.git文件夹里,藏着怎样的世界?今天我们掀开它的盖子,看看 Git 这台精密机器内部的齿轮是怎么转动的。
📋 开篇自测:你已经知道多少?
.git目录里有哪些关键的子目录和文件?它们各自负责什么?- Git 存储文件内容时,是保存每次的差异(diff),还是保存完整的快照(snapshot)?
- 两个内容完全相同但文件名不同的文件,Git 会存储几份副本?
git hash-object和git cat-file这两个底层命令分别用来做什么?- Git 的分支(branch)在底层究竟是什么?
一、把.git想象成一座图书馆档案系统
理解 Git 内部原理的最好方式,是把 .git 目录想象成一座图书馆的档案管理系统。
在这座图书馆里:
objects/目录是档案室,存放着所有文档的原始内容——每一份手稿、每一本目录册、每一张入库记录卡refs/目录是索引柜,里面放着各种标签和书签,帮你快速定位到某份档案HEAD文件是你桌上的**“当前正在阅读”指示牌**,告诉大家你现在翻到了哪一页config文件是图书馆的规章制度手册,记录着各种配置规则hooks/目录是自动化工作台,放着各种在特定时刻自动触发的脚本程序
让我们先打开一个真实的 .git 目录,看看里面到底有什么:
# 创建一个新仓库并查看 .git 目录结构
$ mkdir git-lab && cd git-lab
$ git init
$ ls -la .git/
-rw-r--r-- HEAD
-rw-r--r-- config
-rw-r--r-- description
drwxr-xr-x hooks/
drwxr-xr-x info/
drwxr-xr-x objects/
drwxr-xr-x refs/
.git/
├── HEAD ← 指向当前所在分支
├── config ← 仓库级别的配置
├── description ← GitWeb 程序使用的描述文件
├── hooks/ ← 钩子脚本(pre-commit、post-merge等)
│ ├── pre-commit.sample
│ ├── commit-msg.sample
│ └── ...
├── info/ ← 额外信息(如 exclude 排除规则)
├── objects/ ← 所有 Git 对象的存储仓库
│ ├── info/
│ └── pack/
└── refs/ ← 引用(分支、标签、远程跟踪分支)
├── heads/ ← 本地分支
└── tags/ ← 标签
别看只有这么几个文件和目录,整个 Git 的版本控制能力就建立在它们之上。接下来我们逐一深入。
二、Git对象模型——档案室里的四种文件
Git 的核心是一个内容寻址的文件系统。这句话的意思是:Git 根据文件的内容来存储和检索数据,而不是根据文件名。具体来说,Git 会对内容进行 SHA-1 哈希运算,生成一个 40 位的十六进制字符串作为唯一标识。
💡 趋势提示:Git 正在从 SHA-1 向 SHA-256 迁移(详见第1章的介绍)。本章以当前默认的 SHA-1 为例讲解,但底层原理完全相同——只是哈希值将从 40 位变为 64 位十六进制字符串。
这就好比图书馆不按书名编目录,而是给每本书的内容做”指纹采集”——不管你把书名改成什么,只要内容一样,指纹就一样,就被视为同一本书。
Git 的档案室里只有四种类型的档案:
1. blob(文件内容对象)——一页手稿
blob 是最基本的对象,它保存的就是文件的原始内容,不包含文件名、权限或任何元数据。一个 blob 就像档案室里的一页手稿——上面只有正文内容,没有标题也没有作者。
# 手动创建一个 blob 对象
$ echo "Hello, Git internals!" | git hash-object -w --stdin
d670460b4b4aece5915caf5c68d12f560a9fe3e4
# -w 表示写入 objects 数据库
# --stdin 表示从标准输入读取内容
执行这条命令后,Git 做了两件事:
- 计算内容的 SHA-1 哈希值 →
d670460b... - 把内容以压缩格式存入
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4
注意文件路径——Git 用哈希值的前两位作为目录名,剩余38位作为文件名。这种分层策略避免了单个目录下文件过多导致性能下降。
# 用 cat-file 查看这个对象
$ git cat-file -t d670460 # 查看类型
blob
$ git cat-file -p d670460 # 查看内容
Hello, Git internals!
$ git cat-file -s d670460 # 查看大小(字节)
22
🤔 想一想 如果你创建两个文件
a.txt和b.txt,它们的内容完全相同,Git 会在objects/中存几个 blob 对象?
答案是一个。因为内容相同意味着 SHA-1 哈希相同,指向同一个 blob 对象。文件名的区别由 tree 对象来记录。这就是 Git 存储高效的秘密之一。
2. tree(目录结构对象)——目录册
如果 blob 是一页页手稿,那 tree 就是把这些手稿组织起来的目录册。tree 对象记录了一个目录下有哪些文件(blob)和子目录(其他 tree),以及它们的文件名和权限。
tree 对象的内容结构:
┌──────────┬───────────────────┬──────────────────────────┐
│ 文件模式 │ 对象类型 + 文件名 │ 指向的对象SHA-1 │
├──────────┼───────────────────┼──────────────────────────┤
│ 100644 │ blob README.md │ a1b2c3d4... │
│ 100644 │ blob main.py │ e5f6a7b8... │
│ 040000 │ tree src/ │ c9d0e1f2... │
└──────────┴───────────────────┴──────────────────────────┘
# 查看某个 tree 对象的内容
$ git cat-file -p main^{tree}
100644 blob a1b2c3d4e5f6a7b8... README.md
100644 blob e5f6a7b8c9d0e1f2... main.py
040000 tree c9d0e1f2a3b4c5d6... src
文件模式 100644 表示普通文件,100755 表示可执行文件,040000 表示子目录。tree 对象通过嵌套引用其他 tree 对象来表示多级目录结构。
3. commit(提交对象)——入库记录卡
commit 对象是一张入库记录卡,它不存储文件内容本身,而是记录:
- 指向哪个 tree 对象(这次提交的完整目录快照)
- 父提交是谁(上一次提交的 SHA-1)
- 作者和提交者信息
- 提交时间戳
- 提交说明
# 查看一个 commit 对象的原始内容
$ git cat-file -p HEAD
tree 8fa3c9b0e7d2f1a4b5c6d7e8f9a0b1c2d3e4f5a6
parent 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b
author 张三 <zhangsan@example.com> 1709251200 +0800
committer 张三 <zhangsan@example.com> 1709251200 +0800
实现用户登录功能
多个 commit 通过 parent 字段串成一条链,这就是你在 git log 中看到的提交历史。第一个提交没有 parent(它是”开天辟地”的那一次)。合并提交则有两个或多个 parent。
4. tag(标签对象)——收藏印章
tag 对象是一枚收藏印章,盖在某个特定的 commit 上,标记”这个版本值得纪念”。它记录了被标记对象的 SHA-1、标签名、打标签的人以及附注信息。
注意:只有附注标签(annotated tag)才会创建 tag 对象。轻量标签(lightweight tag)只是一个指向 commit 的引用文件,不会在 objects 中创建新对象。
# 创建一个附注标签
$ git tag -a v1.0 -m "第一个正式发布版本"
# 查看这个 tag 对象
$ git cat-file -p v1.0
object 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b
type commit
tag v1.0
tagger 张三 <zhangsan@example.com> 1709251200 +0800
第一个正式发布版本
四种对象的关系全景图
把四种对象组合在一起,就构成了 Git 存储版本历史的完整模型:
tag v1.0
│
▼
commit c3 commit c2 commit c1
┌──────────┐ ┌──────────┐ ┌──────────┐
│tree: t3 │───────→│tree: t2 │───────→│tree: t1 │
│parent: c2│ │parent: c1│ │(无parent) │
│msg: ... │ │msg: ... │ │msg: ... │
└──────────┘ └──────────┘ └──────────┘
│ │ │
▼ ▼ ▼
tree t3 tree t2 tree t1
┌────────────┐ ┌────────────┐ ┌────────────┐
│README (b3) │ │README (b2) │ │README (b1) │
│main.py (b5)│ │main.py (b4)│ │main.py (b4)│
│src/ (t3s) │ │src/ (t2s) │ └────────────┘
└────────────┘ └────────────┘
仔细观察:commit c2 和 commit c1 中的 main.py 指向同一个 blob b4——如果文件没有被修改,Git 不会重复存储,只是在 tree 中重复引用同一个 blob 的哈希值。这就是 Git 存储模型高效的根本原因。
⚠️ 常见误区 误区:Git 每次提交只保存文件的差异(diff)。 事实上,Git 每次提交保存的是项目的完整快照。每个 commit 指向一个 tree 对象,tree 对象指向一组 blob 对象,每个 blob 保存文件的完整内容。未修改的文件不会产生新的 blob,tree 只是重复引用已有的 blob 哈希。差异(diff)是 Git 在展示时实时计算出来的,而不是它的存储方式。
三、引用系统——索引柜里的标签和书签
40 位的 SHA-1 哈希值虽然唯一,但没人记得住 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b 这样的字符串。Git 的引用系统就是为了解决这个问题——用人类友好的名字来指代那些难记的哈希值。
引用就是 refs/ 目录里的一些文本文件,每个文件的内容只有一行:一个 SHA-1 哈希值。
refs/heads/——本地分支
# 查看本地分支的引用文件
$ cat .git/refs/heads/main
1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b
$ cat .git/refs/heads/feature-login
9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e
现在你明白了——分支不是一条线,而是一个指针。所谓”创建分支”,不过是在 refs/heads/ 里新建了一个 41 字节的小文件(40位哈希 + 1个换行符)。这就是为什么 Git 的分支操作如此轻量和迅速。
refs/tags/——标签
# 轻量标签:直接存储 commit 的哈希
$ cat .git/refs/tags/v0.9-beta
1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b
# 附注标签:存储 tag 对象的哈希(tag 对象再指向 commit)
$ cat .git/refs/tags/v1.0
5e4d3c2b1a0f9e8d7c6b5a4f3e2d1c0b9a8f7e6d
refs/remotes/——远程跟踪分支
$ cat .git/refs/remotes/origin/main
3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d
远程跟踪分支记录的是:上次跟远程仓库同步时,远程分支所指向的 commit。它是”只读”的快照,你不应该直接修改它。
HEAD——特殊的引用
HEAD 通常不直接存储 commit 的哈希,而是存储一个符号引用——指向某个分支:
$ cat .git/HEAD
ref: refs/heads/main
这行内容的意思是:“我当前在 main 分支上”。当你执行 git checkout feature 切换分支时,Git 只需把 HEAD 文件的内容改成 ref: refs/heads/feature。
当 HEAD 直接存储一个 commit 哈希而非指向分支时,就进入了”分离 HEAD”状态(detached HEAD)——这意味着你不在任何分支上,此时的新提交不属于任何分支,切走后可能会丢失。
正常状态: 分离HEAD状态:
HEAD → refs/heads/main → c3 HEAD → c3
(不经过任何分支)
四、packfile与对象压缩——档案室的整理术
随着项目的增长,objects 目录中会积累成千上万个松散对象文件(loose objects)。每个文件单独存储、单独压缩,这样做有两个问题:
- 文件系统压力:大量小文件占用过多的 inode 和目录索引
- 冗余存储:如果一个大文件只做了微小修改,Git 会存两个几乎完全相同的 blob
Git 的解决方案是 packfile(打包文件)。你可以把它理解成档案室的”年度整理”——管理员定期把散落在各处的手稿归档成册,标注编号,建好索引,腾出空间。
gc 机制
git gc(garbage collection)是这个整理过程的入口:
# 手动触发打包和垃圾回收
$ git gc
# 查看打包后的文件
$ ls .git/objects/pack/
pack-1a2b3c4d5e6f.idx # 索引文件(记录每个对象在pack中的偏移位置)
pack-1a2b3c4d5e6f.pack # 数据文件(所有对象打包在一起)
打包时 Git 做了一个聪明的优化——增量压缩(delta compression)。它会识别出内容相似的对象,只完整保存最新版本,而把旧版本存储为与新版本之间的差异。
打包前(松散对象): 打包后(packfile):
objects/a1/b2c3... (100KB) pack文件:
objects/d4/e5f6... (100.5KB) ┌────────────────────────────┐
objects/g7/h8i9... (101KB) │ g7h8i9 完整内容 (101KB) │
│ d4e5f6 增量: 基于g7 (-500B)│
│ a1b2c3 增量: 基于d4 (-500B)│
└────────────────────────────┘
+ idx索引文件
注意一个反直觉的细节:Git 完整保存的是最新版本,把旧版本存为增量。这是因为你最常访问的是最新版本(比如 git checkout、git diff),完整保存它可以避免需要层层回溯来还原内容。
gc 什么时候会自动运行?
Git 在以下时机会自动触发 gc:
- 松散对象数量超过约 6700 个
- packfile 数量超过 50 个
- 执行
git push时(部分服务端配置)
你也可以通过配置控制这些阈值:
$ git config gc.auto 256 # 松散对象超过256个时自动gc
$ git config gc.autoPackLimit 10 # packfile超过10个时自动gc
🤔 想一想 前面说 Git 以”快照”方式存储,每个 blob 保存文件的完整内容。但 packfile 中又使用了增量压缩。这两个说法矛盾吗?提示:想一想”逻辑模型”和”物理存储”的区别。
五、快照 vs 差异——Git的存储哲学
市面上的版本控制系统大致分两派:
差异存储派(如 SVN):只记录每个版本与上一版之间的变化。
版本1: 文件A(完整) 文件B(完整)
版本2: 文件A(Δ2) 文件B(不变)
版本3: 文件A(Δ3) 文件B(Δ3)
版本4: 文件A(不变) 文件B(Δ4)
要还原版本4的文件A = 版本1 + Δ2 + Δ3
快照存储派(Git):每个版本保存完整的文件状态,未修改的文件只存储引用。
版本1: 文件A → blob-a1 文件B → blob-b1
版本2: 文件A → blob-a2 文件B → blob-b1 (引用不变)
版本3: 文件A → blob-a3 文件B → blob-b3
版本4: 文件A → blob-a3 文件B → blob-b4
(引用不变)
要还原版本4的文件A = 直接读取 blob-a3
快照模型的优势在于:切换到任何一个历史版本,Git 都可以直接构造出该版本的完整目录树,不需要从某个基础版本开始逐步应用一连串的差异补丁。这就是 git checkout 和 git switch 能够瞬间完成的秘密。
当然,前面提到的 packfile 中的增量压缩是一种物理层面的优化,它不影响 Git 的逻辑模型——在逻辑上,每个 commit 指向的 tree 仍然引用完整的 blob 内容,只不过在磁盘上,相似的 blob 之间的冗余被压缩掉了。
六、实战:用底层命令手动创建一次提交
现在让我们抛开 git add 和 git commit,用 Git 的底层管道命令(plumbing commands)来手动完成一次提交,深入理解”一次提交到底发生了什么”。
# 第一步:确保你在一个干净的测试仓库中
$ mkdir plumbing-lab && cd plumbing-lab
$ git init
# 第二步:创建一个 blob 对象(存储文件内容)
$ echo "print('Hello from plumbing!')" | git hash-object -w --stdin
# 假设返回: 5d3b1f04a3c2e8b7d6c5a4f3e2d1c0b9a8f7e6d5
# 第三步:创建 tree 对象(定义目录结构)
# 首先需要把信息写入暂存区(index),再从中生成 tree
$ git update-index --add --cacheinfo 100644 \
5d3b1f04a3c2e8b7d6c5a4f3e2d1c0b9a8f7e6d5 hello.py
# 从暂存区生成 tree 对象
$ git write-tree
# 假设返回: 8fa3c9b0e7d2f1a4b5c6d7e8f9a0b1c2d3e4f5a6
# 验证一下这个 tree 的内容
$ git cat-file -p 8fa3c9b0
100644 blob 5d3b1f04a3c2e8b7d6c5a4f3e2d1c0b9a8f7e6d5 hello.py
# 第四步:创建 commit 对象
$ echo "我的第一次手动提交" | git commit-tree 8fa3c9b0
# 假设返回: 2e7f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f
# 验证 commit 对象
$ git cat-file -p 2e7f1a0b
tree 8fa3c9b0e7d2f1a4b5c6d7e8f9a0b1c2d3e4f5a6
author 张三 <zhangsan@example.com> 1709251200 +0800
committer 张三 <zhangsan@example.com> 1709251200 +0800
我的第一次手动提交
# 第五步:把分支引用指向这个 commit
$ git update-ref refs/heads/main 2e7f1a0b
# 第六步:把 HEAD 指向 main 分支
$ git symbolic-ref HEAD refs/heads/main
# 大功告成!用 git log 验证
$ git log --oneline
2e7f1a0 我的第一次手动提交
让我们回顾一下这个过程,对比高层命令:
底层操作 等效的高层命令
───────────────────────────── ──────────────
git hash-object -w ┐
git update-index --add ├→ git add
git write-tree ┘
git commit-tree ┐
git update-ref ├→ git commit
git symbolic-ref ┘
你平时执行的一个 git add + git commit,背后就是这六步操作的封装。Git 的高层命令(porcelain commands)是友好的外衣,底层管道命令(plumbing commands)才是真正干活的齿轮。
⚠️ 常见误区 误区:暂存区(staging area / index)是一个存放文件副本的临时目录。 实际上,暂存区是一个二进制文件
.git/index,它记录的是一张清单——每个被暂存的文件对应的 blob 哈希值、文件路径和权限。文件内容本身已经作为 blob 对象存储在objects/中了。git add的本质操作是:把文件内容写入 blob 对象,然后在 index 文件中登记这个 blob 的哈希。
七、从全局视角看 .git
最后,让我们把所有知识串联起来,用一张全景图回顾 .git 的架构:
.git/
│
├── HEAD 当前分支指针
│ └── "ref: refs/heads/main"
│
├── index 暂存区(二进制文件)
│ └── [路径 → blob哈希] 的映射表
│
├── objects/ 对象数据库
│ ├── 5d/3b1f04... blob对象(文件内容)
│ ├── 8f/a3c9b0... tree对象(目录快照)
│ ├── 2e/7f1a0b... commit对象(提交记录)
│ ├── info/
│ └── pack/ 打包后的对象
│ ├── pack-xxx.pack 打包数据
│ └── pack-xxx.idx 打包索引
│
├── refs/ 引用
│ ├── heads/ 本地分支
│ │ ├── main → commit哈希
│ │ └── feature-x → commit哈希
│ ├── tags/ 标签
│ │ └── v1.0 → tag/commit哈希
│ └── remotes/ 远程跟踪分支
│ └── origin/
│ └── main → commit哈希
│
├── config 仓库配置
├── hooks/ 钩子脚本
│ ├── pre-commit
│ └── commit-msg
└── logs/ 引用变更日志(reflog)
├── HEAD
└── refs/heads/main
当你理解了这张图,很多看似神秘的 Git 行为就变得清晰了:
git branch feature= 在refs/heads/下创建一个新文件git checkout main= 修改 HEAD 文件的内容git commit= 创建 blob + tree + commit 对象,更新分支引用git tag v1.0= 在refs/tags/下创建一个新文件git clone= 下载 objects 数据库 + 创建 refs/remotes + 设置 config
📝 掌握度自测
- Git 的四种对象类型分别是什么?各自存储了哪些信息?
- 为什么说 Git 的分支是”轻量级”的?从底层存储的角度解释。
git cat-file -p <hash>和git cat-file -t <hash>分别用来做什么?- packfile 的增量压缩与 Git 的”快照存储”模型是否矛盾?请解释。
- 用底层命令手动创建一次提交,至少需要哪几个步骤?请按顺序列出。
💡 自我评估
- 答对5题:你已经深入理解了 Git 的内部机制,遇到任何诡异的 Git 问题都能从原理层面思考
- 答对3-4题:核心概念掌握扎实,建议动手执行一遍”手动创建提交”的实验加深理解
- 答对0-2题:Git 内部原理比较抽象,建议边操作边阅读,创建一个测试仓库亲手探索
.git目录
购买课程解锁全部内容
版本控制不翻车:Git 从基础到团队协作
¥29.90