从 git 看版本控制系统设计

2024/11/06 20:28 pm posted in  Linux

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 包含了这几个信息:

  1. tree id
  2. parent,上一个 commit 的 id(在 merge 时,会有两个 parent)
  3. author/committer 信息
  4. 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

参考: