git 毫无争议是当下软件技术的基石之一,随着 gitops 的诞生,git 也成为无数版本控制系统的参考对象。
坊间传闻 Linus 仅用了两周便完成了 git 的早期版本,一直感叹于骨灰级大神的战斗力,在初步了解了 git 工作原理后,震惊于 git 实现之精巧。果然功在于巧而不在于多。
存储
一个 git init
后的新仓库,会包含 git 数据目录,这个目录便是名为 .git
的隐藏文件,其中主要包含:
.git/objects/
: 对象存储,存储 git 对象本身.git/refs/
: 引用存储,它包含两个子目录,heads 和 tags。.git/HEAD
: 当前 HEAD 的引用.git/config
: 仓库的配置文件
如果把 git 作为一个存储系统,那么可以把 git 分为三个主要部分:
- 对象存储:提供最基础的存储、取回能力
- 对象:git 中的面向功能的数据结构,包括 blob、commit、tree、tag 等
- 引用:git 中最简单的特殊对象结构,可以指向另一个引用或者对象
git 是通过文件系统实现了一个简单的对象存储能力,文件的路径便是 key,文件内容便是 value。但是和通常的 KV 存储不同,git 对象的 key 是基于 value 的值进行计算出来的(一种增强版的 SHA-1),更像是一种 VV 存储。
为了避免但目录文件数过多,对象的 hash 值会先截断前两位作为二级目录,将后续的部分作为文件名,比如:
hash: d645695673349e3947e8e5ae42332d0ac3164cd7
path: .git/objects/d6/45695673349e3947e8e5ae42332d0ac3164cd7
一个基本的 git 对象会被 zlib 压缩后放在对应的文件中,文件内容包含 3 个部分:头部,内容大小、对象,后文会给出具体的例子。
对象
git 在存储系统上引入了几种对象,来实现仓库的版本控制,其中最重要的是 blob、commit、tree 三种。
Blob 对象
Blob 对象用作存储仓库中的用户数据,是 git 中最重要但却最简单的,因为它们没有实际的格式,直接存储了文件内容。
我们可以在一个空的仓库中提交一个文件,并找到对应的 blob。
$ echo "hello world" > file1
$ git commit -m "first commit"
我们对 blob 对象文件进行 zlib 解压缩后查看内容:
$ zlib-flate -uncompress < .git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad | HexDump -C
00000000 62 6c 6f 62 20 31 32 00 68 65 6c 6c 6f 20 77 6f |blob 12.hello wo|
00000010 72 6c 64 0a |rld.|
00000014
可以清楚的看到描述对象类型的头部、一个空格(0x20)、内容大小(12)、空分隔符(0x00)、具体的文件内容。
Commit 对象
我们是怎么找到这个具体的文件 blob 的呢,一般情况下,我们和 git 打交道比较多的是 commit,通过 add 命令加入暂存区,提交后产生一个新的 commit。
$ git log
commit d0cc63109120f35dadbafeaf538144baabc49e30 (HEAD -> main)
Author: Hypo <***@ihypo.net>
Date: Wed Nov 6 14:14:05 2024 +0800
first commit
我们可以直接查看对象存储中具体的 commit 内容:
zlib-flate -uncompress < .git/objects/d0/cc63109120f35dadbafeaf538144baabc49e30
commit 165tree c8f5514375be193ba3c4716a89d91d8f393cba2e
author Hypo <***@ihypo.net> 1730873645 +0800
committer Hypo <***@ihypo.net> 1730873645 +0800
first commit
当我们尝试产生第二个 commit,看下有没有什么变化:
$ mkdir dir1
$ echo "new file in a dir" > dir1/file1
$ git add .
$ git commit -m "second commit"
通过 cat-file,可以更简单的查看 git 对象内容,帮我们省略头部等信息:
$ git cat-file commit ceede7c29a7d59b5ed6dfb20981af12242c3ca5f
tree a9cbd3a4ea1e6978fa2c6dbae13fbe3dc08e0218
parent d0cc63109120f35dadbafeaf538144baabc49e30
author Hypo <***@ihypo.net> 1730875602 +0800
committer Hypo <***@ihypo.net> 1730875602 +0800
second commit
从这两个 commit 可以看出一个 commit 包含了这几个信息:
- tree id
- parent,上一个 commit 的 id(在 merge 时,会有两个 parent)
- author/committer 信息
- commit message
虽然我们的版本控制时基于 commit 展开,但是 commit 对象本身并没有存储更改,而是通过关联一个 tree 对象完成的,blob 信息存储在 tree 中。
Tree 对象
tree 对象,像一个目录树一样,是一个文件的集合。可以通过 ls-tree 命令查看 tree 的内容。
$ git ls-tree a9cbd3a4ea1e6978fa2c6dbae13fbe3dc08e0218
040000 tree 29ef443a51e8ea380a60ad7e5eced466a9522b15 dir1
100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad file1
$ git ls-tree 29ef443a51e8ea380a60ad7e5eced466a9522b15
100644 blob b46ffdb034ffb03e3a831ae2c80df5d6e0c8ec85 file1 # ./dir1/file1
在上面的例子中可以看到,一个 tree 对象包含若干 blob 对象,即关联一个文件,也可以包含其他的 tree 对象,用来关联一个目录。
每一行表示一个条目,每个条目有 4 个部分组成:
含义 | 内容 |
---|---|
文件模式 | 100644 |
对象类型 | blob |
对象 Hash | 3b18e512dba79e4c8300dd08aeb37f8e728b8dad |
相对路径 | file1 |
引用
git 通过引用对象实现了 branch、tag 的能力。
分支和 HEAD
我们常说的 HEAD 是一个特殊的引用,可以看到,当前是引用在 main 分支
$ cat .git/HEAD
ref: refs/heads/main
引用对象存储在 .git/refs
中,可以在该目录下找到 main 分支对象的具体内容,即是当前最新的 commit:
$ cat .git/refs/heads/main
ceede7c29a7d59b5ed6dfb20981af12242c3ca5f
当我们通过 checkout 创建一个新的分支时,.git/refs/heads
也会创建一个新的引用对象文件,而 HEAD 会指向对应的分支:
$ git checkout -b branch-1
Switched to a new branch 'branch-1'
$ ls .git/refs/heads
branch-1 main
$ cat .git/HEAD
ref: refs/heads/branch-1
Tag
tag 是一种比较特殊的对象,默认情况下,tag 和分支一样,是一种引用对象,只包含引用的内容。
$ git tag tag-1
$ cat .git/refs/tags/tag-1
ceede7c29a7d59b5ed6dfb20981af12242c3ca5f
但由于 tag 可以带上 message 信息,这时 tag 的逻辑会有些变化:
$ git tag -a tag-2 -m "some message"
$ cat .git/refs/tags/tag-2
e3ec108df7a752db0d8452ea3de0168897211aae
除了在 refs/tags
创建一个引用外,还会在 objects
中创建一个实体 tag 对象,用于带上 message 等信息,引用对象会指向该实体对象。
git cat-file tag e3ec108df7a752db0d8452ea3de0168897211aae
object ceede7c29a7d59b5ed6dfb20981af12242c3ca5f
type commit
tag tag-2
tagger Hypo <***@ihypo.net> 1730877516 +0800
some message
版本控制
通过对象和引用,就已经实现了基础的版本控制能力,分支引用最新的 commit,commit 关联了一个 tree 对象,而 tree 对象关联具体的 blob。
由于每个 commit/tree 都是一个完整的仓库,进而达到了对一个版本进行快照的效果,换句话说,每个 commit 本质上是一个快照。通过当前暂存区中的文件构建新的 commit,或者利用老的 commit 中关联的文件重建当前工作目录,便完成了新版本创建和老版本回退。
暂存区
在提交 commit 之前,git 需要先通过 add 命令将文件加入到暂存区,git 的暂存区并没有完全基于上述的对象机制实现,而是单独引入了一个 .git/index
文件。
index 文件内容面向内存数据结构(可以通过 mmap
转为 C 结构体),但可以通过 ls-files 命令查看部分内容:
$ echo "hello world" > file2
$ git ls-files
dir1/file1
file1
$ git add file2
$ git ls-files
dir1/file1
file1
file2
index 记录了仓库中所有需要提交的文件和文件的基本信息,比如各种 time、hash 等,一方面,通过修改时间、hash 信息的判断,可以很方便的判断文件是否在加入暂存区后再次发生变化,另一方面,也可以在 commit 时,基于 index 的内容,生成一个完整的 tree。
基本工作流程:
操作 | index | objects |
---|---|---|
touch file3 |
无操作 | 无操作 |
git add |
新增 file3 条目 | file3 创建 blob |
git commit |
无操作 | 基于 index 中的 blob 创建 tree 和 commit |
参考: