Git 是怎么工作的

Background

最近一时兴起了想开发一个与 Git 相关的游戏,于是去读了一下 Git 的源码,才发现自己对这个的理解还是不是很到位,于是想记录一下 Git 的原理和一些底层机制。

oh-my-git-game

Git 的数据结构抽象

总的来说,Git 是一个附加了提交信息的文件系统。

存储是一个哈希表

首先,Git 的存储可以看作一个内容寻址的 哈希表

# object:type + " " + length + "\0" + content
# hash:sha1(object)
def store(type, content):
    obj = f"{type} {len(content)}\0{content}"
    hash = sha1(obj)
    map[hash] = zlib.compress(obj)
    return hash

def load(hash):
    return zlib.decompress(map[hash])

这样,hash 值可以充当内容的指针,这对于 Git 核心的跳转功能至关重要。

抽象出的四种对象

Git 有四种对象类型。

首先,作为一个文件系统,它抽象出了 blob文件内容[1]tree文件结构),通过这两个对象,我们就很容易地复现一个时刻的文件系统。

blob {
    data: bytes
}

tree {
    entries: [
        { mode: str, name: str, hash: str },
        ...
    ]
}

为了能实现 Git 的 commit 功能,我们需要一个记录相关信息的数据结构,命名当然就是 commit

commit {
    tree: hash,          
    parents: [hash, ...],
    author: str,
    committer: str,
    message: str
}

Git 的另一大特色是其能在各个 commit 间跳转。你可能熟知的有:branchHEAD 等等。抽象地来看,它们都是 ref引用)的一种。

最后还有一种特殊的对象 —— 附注标签(annotated tag)。它本质上也是指向某个 commit,但会额外保存标签名、创建者、备注等信息。轻量标签则只是一个 ref,不生成对象。

tag {
    object: hash,
    type: str,
    tag: str,
    tagger: str,
    message: str
}

至此,我们大致就有了如下的建模方式。

ref -> commit -> tree -> (tree | blob)

值得注意的是,在这四种对象中,只有 blob 真正存储数据treecommittag 存储的都是指向其他对象的 hash(再加上一些元信息)。ref 则位于对象层之外,是一个命名指针。

blob 和 tree 构成了文件系统结构;commit 保存提交历史与元信息;ref 是指向某个 commit 的指针。

这就是 Git 对版本管理做出的抽象。

历史是有向无环图

不难想到,所有的 commit 通过 parents 指针连接在一起,会形成一张有向无环图。它具有这些性质:

  • 普通提交有一个父提交
  • 合并提交(merge)可以有多个父提交
  • 分支和 HEAD 都是指向某个 commit 的引用
  • 因为 commit 不可变,图中的节点不会被修改,只会不断新增

从这个模型出发,几个常用操作的本质就很清晰了:

  1. 提交:创建一个新的 commit,让它的 parents 指向当前 HEAD,然后让当前分支(ref)向前移动指向这个新的 commit。

  2. 分支:创建一个新的 ref,指向当前 commit。分支本身不复制任何历史,只是给 DAG 中某个节点起了一个新的名字。

  3. 撤销:让当前 ref/HEAD 重新指向另一个 commit。旧的 commit 仍然留在 DAG 中,只是当前分支不再指向它。

内容寻址的天然去重

如果每个版本都存一份完整文件,仓库体积很快就会爆炸。但 Git 通过内容寻址天然避免了这个问题:只要文件内容相同,无论它出现在哪个 commit、哪个目录、叫什么名字,都对应同一个 blob 哈希,在对象数据库中只存一份。

与各大 IDE 的视觉效果所显示的不同,Git commit 的不是相对于上一个版本的 diff,而是当前文件系统的完整快照。但由于 blob 是去重的,没有改动的文件并不会被重复存储。

因此,Git 每次提交都存储全量快照,但仓库体积的增长其实远小于直觉估计。

这里有一个层次需要区分清楚:

  • 对象层:每个 commit 记录的是完整快照,不存在跨版本的 diff。这是 Git 的逻辑模型。
  • 存储层:松散对象是逐个独立存储的;而打包对象 [2] 为了压缩体积,会在多个相似对象之间做压缩。这是一种透明的物理优化,不影响对象层的语义。

换句话说,当你通过 git cat-file 读取一个 blob 时,无论它底层是松散对象还是 打包对象,你拿到的永远是完整的内容。delta 压缩只是节省磁盘空间的手段,Git 的对象模型本身并不感知它的存在。

这里有一个常见的误解需要澄清:Git 并不是 "擅长存文本文件,不能存二进制文件"。实际上 Git 对所有文件都一视同仁,把它们都当作完整的 blob 存储。无论修改一个文本文件还是修改一个 docx,都会生成一个完整的新 blob。

两者的区别不在于 Git 的存储方式,而在于:

  • 文本文件本身小,生成的 blob 也小。
  • docx 本身大而且是压缩二进制格式,生成的 blob 几乎一样大。

因为文本文件天然小、适合压缩、diff 对人类可读、merge 能自动进行,所以 Git 更适合文本文件,不是因为文本文件被 Git 进行了特殊处理。

其实这种说法也不准确,详见 3.4 节

三个工作区域

Git 在工作流上抽象出三个区域:

  1. 工作区(Working Directory):永远只有一份文件,就是现在看到的切实存储在磁盘上的实际文件。
  2. 暂存区(Index / Stage):下一次提交要包含的文件快照。
  3. 仓库(Repository).git 目录中保存的所有对象和引用

git add 是把工作区文件放入暂存区,git commit 是把暂存区内容打包成一个新的 commit 对象放入仓库。

引用系统

ref 是一个抽象概念,表示指向某个 commit(或其他对象)的命名指针。Git 中的引用非常轻量:

  • 分支(branch):如 mainfeature-x,会随提交自动前进
  • 标签(tag):如 v1.0,通常固定指向某个 commit
  • HEAD:符号引用,指向当前所在的分支或 commit
  • 远程跟踪分支:如 origin/main,记录最后一次看到的远程状态

分支并不是历史的一部分,它只是一个动态移动的标签。Ref 的具体实现我们会在后面的实现部分讲到。

Git 做出的实现

当我们讲完了抽象,我们可以具体看看这些思想在 .git 目录中的落地。

下面的脚本在目录下创建了一个简单的 Git 仓库,并用 Git 命令完成了一次提交:

#!/bin/bash
set -e
mkdir myrepo && cd myrepo
git init
touch foo.txt
echo "hello from foo" > foo.txt
git add .
git commit -m "first commit"

跑完这个脚本,我们可以观察到 .git 下出现了这样的目录结构(已做简化)。

├── HEAD              ← 当前分支指向
├── index             ← 暂存区
├── objects/
│   ├── 19/6f02f...   ← foo.txt 的 blob
│   ├── 18/24eb6...   ← tree 对象
│   ├── 38/e220d...   ← commit 对象
│   └── pack/
└── refs/
    └── heads/
        └── master    ← master 分支指针

对象存储

path: .git/objects/

前面我们抽象出了 blobtreecommittag 四种对象,现在来看看它们在磁盘上到底长什么样。所有对象都存放在 .git/objects/ 目录下,Git 把每个对象单独存储为一个文件,称为松散对象,每个对象都有一个 40 位的哈希值:

文件路径为 .git/objects/aa/bbccddeeff...,其中前两位 aa 是目录名,后面 38 位是文件名。

文件内容的格式和我们之前伪代码里写的一样:

# 原始内容
obj = f"{type} {len(content)}\0{content}"

先是对象类型(blob/tree/commit/tag),加一个空格,然后是内容长度,加一个 \0 分隔符,最后是实际内容。写入磁盘时,整个对象经过 zlib 压缩,通过 file 可以验证这一点:

file .git/objects/19/6f02*

由于用 git cat-file 有点自证的意味,我们可以借助 python 来帮助我们试着解码一下三个文件的内容。

#!/usr/bin/env python3
import zlib
import sys

for path in sys.argv[1:]:
    with open(path, "rb") as f:
        raw = f.read()
    decompressed = zlib.decompress(raw)
    header, content = decompressed.split(b"\0", 1)
    sys.stdout.buffer.write(content)

git-object-decode

不难验证可读性,我们还可以观察到 tree 文件中存储了对应 blob 文件的哈希(196f02…)

索引文件

path:.git/index

前面讲过 Git 有三个工作区域:工作区、暂存区、仓库。

暂存区在磁盘上的对应物就是 .git/index 这个二进制文件。其记录着文件和哈希的联系,也记录着文件的元信息(修改时间,文件模式,文件路径等等)。这也是它叫 index 的原因。

可以用 git ls-files --stage 查看 index 的内容,其记录了文件模式,哈希值,文件状态 [3],文件名等信息:

git ls-files --stage
# 100644 196f02f0*   0        foo.txt
# [mode] [blob hash] [stage]  [name]

至此,我们就大概能猜到 git status 的判断逻辑了:

  1. 比较工作区.git/index:文件有没有改?
  2. 比较 .git/index 和当前 commit 的 tree:有没有暂存?

引用存储

前面在抽象层说过,ref 是指向某个对象的命名指针。现在来看看它们在磁盘上是什么样的。

ref 本质上就是一个个纯文本文件,里面只存了一个 40 位的 hash。比如:

cat .git/refs/heads/master
# 38e220d858dd396559239871ec9bcc6ac4d31552

就是这么简单 —— 一个文件名是 master,内容是某个 commit 的 hash。

Git 的引用分散在 .git 目录中:

路径 作用
.git/refs/heads/ 本地分支
.git/refs/tags/ 标签
.git/refs/remotes/ 远程跟踪分支
.git/HEAD 当前所在分支

.git/HEAD 比较特殊,它存的不是 hash,而是一个符号引用:

cat .git/HEAD
# ref: refs/heads/master

意思是 " 当前 HEAD 指向 master 分支 "。当你 git checkout feature 时,Git 只需要把这个文件改成 ref: refs/heads/feature 就完成了分支切换。

可以用 git show-ref 查看所有引用:

git show-ref
# 38e220d... refs/heads/master

由此观之,分支的 "自动前进" 也不神秘,只是改了一个文件里的一行文本。

打包文件

path:.git/objects/pack/

前面介绍的对象存储方式是松散对象(loose object)—— 每个对象单独一个文件,各自用 zlib 压缩。这种格式写入简单,但当仓库中有成千上万个对象时,会带来两个问题:

  1. 磁盘开销大:每个文件至少占一个 inode,即使对象很小,文件系统元数据的开销也不可忽略。
  2. 压缩率低:zlib 对单个小文件的压缩效果有限,而很多对象之间其实内容非常相似(比如同一文件的不同版本)。

Git 的解决方案是 打包文件 (packfile):把多个松散对象打包进一个文件,并利用 delta 压缩进一步减小体积。

打包过程

Git 会执行以下步骤:

  1. 收集所有可达的对象(从 ref 出发能追溯到的所有 commit、tree、blob)
  2. 按对象类型和大小排序,将相似的对象放在一起
  3. 对每个对象,尝试找到一个 "基础对象",只存储两者之间的差异(delta)
  4. 将所有对象写入一个 .pack 文件,同时生成一个 .idx 索引文件用于快速查找

打包后的文件存放在 .git/objects/pack/ 下:

.git/objects/pack/
├── pack-abc123...-.pack   ← 包含所有对象数据
└── pack-abc123...-.idx    ← 哈希到 pack 内偏移量的索引

Delta 压缩

Delta 压缩是 packfile 节省空间的核心。假设你修改了一个 1000 行的文件,只改了 1 行:

  • 松散对象方式:存两个完整的 1000 行 blob
  • Delta 压缩:存原始 blob + 一个 diff 差异

Git 会选择最近的版本或者大小最接近的对象作为 delta 的基础。

松散对象与 Packfile 的关系

两种格式共存于同一个仓库中。新创建的对象总是先以松散对象写入,当数量达到阈值,Git 会自动触发打包。打包完成后,原有的松散对象会被删除。

读取对象时,Git 会先查松散对象,再查 packfile。对使用者来说,这两种存储方式完全透明,不影响任何操作。

可以通过 git count-objects -v 查看当前仓库的存储状况:

git count-objects -v
# count: 0          ← 松散对象数量
# size: 0           ← 松散对象总大小(KB)
# in-pack: 42       ← pack 中的对象数量
# packs: 1          ← pack 文件数量
# size-pack: 16     ← pack 文件大小(KB)

如果 count 很大而 in-pack 很小,说明仓库中有大量松散对象尚未打包,可以手动运行 git gc 进行整理。

垃圾回收

对象一旦创建就不会被修改,但会被 "遗弃"。当一个对象不再被任何 ref(分支、标签、HEAD)指向时,它就变成了孤立对象,仍然留在 .git/objects/ 里,不会立即删除。

常见的遗弃场景:

  • git commit --amend:旧 commit 被新 commit 替代
  • git branch -D feature:删除分支后,该分支独有的 commit 可能变成孤立节点
  • git rebase:变基会创建新的 commit 链,旧链上的 commit 可能失去 ref 指向

可以用 git fsck(file system check)查看孤立对象:

git fsck
# dangling commit abc123...
# dangling blob def456...

dangling 就是 "悬空的",即没有被任何 ref 指向。

真正的清理由 git gc(garbage collection)完成,它会:

  1. 把松散对象打包成 packfile(详见上一节),压缩体积
  2. 删除所有不可达的孤立对象

一般来说不需要手动运行 git gc,Git 在某些操作(如 pushmerge)后会自动触发。这也意味着几乎所有的操作都可以被撤销 —— 误删的文件、误删的分支,只要 gc 还没跑,就能用 git fsck 找回。

结语

重新看 Git,它的实现其实非常地简洁,在搞清楚原理后,其基本上就等价于一个 BTC[4] 管理系统。然而,其对于文件寻址哈希的巧妙设计和整体的得当设计着实令人折服。

这也是我在读完 Git 源码后最大的感受:好的系统不是把功能堆上去,而是在最底层就把问题建模对了。一旦模型对了,后面的一切 —— 分支、合并、变基 —— 都只是在这个数据结构上的不同遍历方式而已。


  1. 顺便一提,blob 的全称是 Binary Large Object。 ↩︎

  2. 关于打包对象,见 3.4 节 ↩︎

  3. stage 用于处理合并冲突。正常情况下所有文件都是 stage 0。 ↩︎

  4. 笑点解析:B for blob,T for tree,C for commit,BTC for bitcoin。 ↩︎