跳到主要内容
预计阅读 32 分钟

揭开.git的面纱——Git内部原理

你每天都在用 git addgit commitgit push,但有没有想过,当你执行这些命令时,Git 在背后到底做了什么?那个隐藏在项目根目录下的 .git 文件夹里,藏着怎样的世界?今天我们掀开它的盖子,看看 Git 这台精密机器内部的齿轮是怎么转动的。

📋 开篇自测:你已经知道多少?

  1. .git 目录里有哪些关键的子目录和文件?它们各自负责什么?
  2. Git 存储文件内容时,是保存每次的差异(diff),还是保存完整的快照(snapshot)?
  3. 两个内容完全相同但文件名不同的文件,Git 会存储几份副本?
  4. git hash-objectgit cat-file 这两个底层命令分别用来做什么?
  5. 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 做了两件事:

  1. 计算内容的 SHA-1 哈希值 → d670460b...
  2. 把内容以压缩格式存入 .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.txtb.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)。每个文件单独存储、单独压缩,这样做有两个问题:

  1. 文件系统压力:大量小文件占用过多的 inode 和目录索引
  2. 冗余存储:如果一个大文件只做了微小修改,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 checkoutgit 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 checkoutgit switch 能够瞬间完成的秘密。

当然,前面提到的 packfile 中的增量压缩是一种物理层面的优化,它不影响 Git 的逻辑模型——在逻辑上,每个 commit 指向的 tree 仍然引用完整的 blob 内容,只不过在磁盘上,相似的 blob 之间的冗余被压缩掉了。


六、实战:用底层命令手动创建一次提交

现在让我们抛开 git addgit 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

📝 掌握度自测

  1. Git 的四种对象类型分别是什么?各自存储了哪些信息?
  2. 为什么说 Git 的分支是”轻量级”的?从底层存储的角度解释。
  3. git cat-file -p <hash>git cat-file -t <hash> 分别用来做什么?
  4. packfile 的增量压缩与 Git 的”快照存储”模型是否矛盾?请解释。
  5. 用底层命令手动创建一次提交,至少需要哪几个步骤?请按顺序列出。

💡 自我评估

  • 答对5题:你已经深入理解了 Git 的内部机制,遇到任何诡异的 Git 问题都能从原理层面思考
  • 答对3-4题:核心概念掌握扎实,建议动手执行一遍”手动创建提交”的实验加深理解
  • 答对0-2题:Git 内部原理比较抽象,建议边操作边阅读,创建一个测试仓库亲手探索 .git 目录

购买课程解锁全部内容

版本控制不翻车:Git 从基础到团队协作

¥29.90