Skip to content

Git 内部原理

· 16 min
TL;DR

本文将深入剖析 Git 的内部工作原理,通过实际示例和命令详细讲解 Git 对象数据库的核心数据结构。文章从最基础的 blob 对象开始,演示了如何使用底层命令如 git hash-object 和 git cat-file 来理解 Git 如何存储文件内容,并逐步解析 Git 的内容寻址文件系统原理。通过亲手操作和分析 Git 对象的压缩存储格式,读者可以全面理解 Git 的对象模型,掌握 Git 命令背后的工作机制,从而更高效地使用 Git 进行版本控制。

普通 Git 仓库分为:

裸仓就是不包含工作区的仓库,一般服务端的仓库 (即远程库) 就是裸仓。

仓库名结尾一般使用 .git 为了与正常仓库区分,但不是强制。

创建裸仓的方式有两种:

Terminal window
$ git init --bare <bare-repo>
$ git clone --bare source-repo <bare-repo>

我们新建一个空的裸仓,命名为 remote.git:

Terminal window
$ git init --bare remote.git

然后再创建两个副本,后续的实验就是在 repo_a, repo_b 中进行:

Terminal window
$ git clone remote.git repo_a
$ git clone remote.git repo_b

进入刚克隆的一个仓库 repo_a 中,只有 .git 目录,.git 目录中的所有文件,即是裸仓中的所有文件,但作为一个正常仓库,目前它工作区还是为空。好在我们可以手动创建一些内容。

认识 blob object#

git hash-object:将数据保存到对象数据库中

Terminal window
$ echo -n 'Hello, Git' > README
$ git hash-object -w README
6fe402b35d6e80a187adc393f36ce10e4fdd259f

选项-n 为了避免 echo 在输出字符串时自动添加换行符。

选项 -w 表示写入,如果不加这个选项,则仅是计算文件的 hash 值,不会保存。

这里将内容 Hello, Git 写入文件 README,再对该文件运行了 git hash-objects 命令,并返回了一串值。这串值会以某种形式下保存下来。

git count-objects 命令得知,此时仓库已经有了第一个对象:

Terminal window
$ git count-objects
$ 1 objects, 4 kilobytes

可以通过 tree 命令查看下 .git/objects 目录:

Terminal window
$ tree .git/objects
.git/objects
├── 6f
└── e402b35d6e80a187adc393f36ce10e4fdd259f
├── info
└── pack
3 directories, 1 file

我们把 .git/objects 目录称作对象数据库 (object database),新增的对象都会存到这个目录下。

里面还是熟悉的那串数字,我们对 Git 的认识就从这一串数字开始。

git cat-file :剖析 git 数据对象

通过 git cat-file 命令查看新生成的对象:

Terminal window
$ git cat-file -p 6fe402b35d6e80a187adc393f36ce10e4fdd259f
Hello, Git

顺便还可以看一下对象的类型:

Terminal window
$git cat-file -t 6fe402b35d6e80a187adc393f36ce10e4fdd259f
blob

这里,我们认识到了 git 对象中的第一种类型 blob object, 称之为数据对象。

我们创建了一个文件,内容是 Hello, Git, 通过 git hash-object 命令将内容写入到 .git/objects 目录中,并且返回指向该数据对象的唯一的键,它是一个 40-bit 的 hash 值,通过这串值即可寻找出它对应的文件的内容,因此 blob 数据对象可表示如下:

image-20220213174201053

这也是 Git 的内容可寻址的文件系统 (Content-Addressable Filesystem) 的含义。

同时我们也知道了 Git 会将这 40-bit 的 hash 值的前 2 位作为 .git/objects 目录下的子目录,后 38 位作为子目录下的一个文件的文件名。

通过 file 命令我们可以知道这是一个 zlib 压缩文件:

Terminal window
$ file .git/objects/6f/e402b35d6e80a187adc393f36ce10e4fdd259f
.git/objects/6f/e402b35d6e80a187adc393f36ce10e4fdd259f: zlib compressed data

既然是被压缩过的,那么里面的内容就是不可读的。

为了查看里面的内容,先安装一个解压缩工具:

Terminal window
$ apt-get update
$ apt-get install pigz

然后运行:

Terminal window
$ pigz -d < .git/objects/6f/e402b35d6e80a187adc393f36ce10e4fdd259f
blob 10Hello, Git

-d 表示解压

可以看到在进行 hash 时,git 内部是按照特定格式进行的:

image-20220213174240793

切到 repo_b,我们可以手动验证这个过程:

Terminal window
$ echo -ne 'blob 10\0Hello, Git' | sha1sum
6fe402b35d6e80a187adc393f36ce10e4fdd259f -

选项-e 为了让 echo 能够识别反斜杠转义符(即字符串中的”)

再次使用 git hash-objects 验证这条数据:

Terminal window
$ echo -n 'Hello, Git' | git hash-object --stdin
6fe402b35d6e80a187adc393f36ce10e4fdd259f

结果是一致的,说明 git hash-objects 在生成 hash 时,是严格按照特定格式进行的。

此时并没有创建 .git/objects/6f 目录,因为没有加 -w 选项,我们可以手动完成。

我们知道 Git 会对数据使用 zlib 的 deflate 算法进行压缩,我们也手动验证一下:

创建一个目录和文件:

Terminal window
$ mkdir .git/objects/6f
$ touch .git/objects/6f/e402b35d6e80a187adc393f36ce10e4fdd259f

接着,使用 pigz 工具压缩数据,存放到 e402b35d6e80a187adc393f36ce10e4fdd259f 文件中:

Terminal window
$ echo -ne 'blob 10\0Hello, Git' | pigz -cz > .git/objects/6f/e402b35d6e80a187adc393f36ce10e4fdd259f

-c 表示输出,-z 表示采用 zlib 的 deflate 算法

此时我们才可以用 git cat-file 查看一下里面的内容:

Terminal window
$ git cat-file -p 6fe402b35d6e80a187adc393f36ce10e4fdd259f
Hello, Git

同样我们可以 解压,看看压缩文件里面的内容:

Terminal window
$ pigz -d < .git/objects/6f/e402b35d6e80a187adc393f36ce10e4fdd259f
blob 10Hello, Git

结果是与前面一致的。


回到 repo_a 仓库继续实验其它内容。

再对 README 文件进行修改:

Terminal window
改写文件
$ echo -n "Hello, Gitee" > README
# 写入对象数据库
$ git hash-object -w README
216ef921a90b782fed1ca37223c3141ed7d5de32
# 查看内容
$ git cat-file -p 216ef921a90b782fed1ca37223c3141ed7d5de32
Hello, Gitee

再次查看一下 .git/object 目录:

Terminal window
$ tree .git/objects
.git/objects
├── 21
└── 6ef921a90b782fed1ca37223c3141ed7d5de32
├── 6f
└── e402b35d6e80a187adc393f36ce10e4fdd259f
├── info
└── pack
4 directories, 2 files

里面有两个 Git 对象了,它们分别代表 README 的两个版本。

我们写入了 blob 对象,通过 git status 查看工作区状态,可以看到 README 仍是 Untracked files 状态,也就是暂存区还是空的,此时 .git/ 目录中还不存在 index 文件。

我们可以先更新一下暂存区,需要用到新的命令:

git update-index: 将工作区的文件内容更新到 index 区

Terminal window
$ git update-index --add --cacheinfo 100644 6fe402b35d6e80a187adc393f36ce10e4fdd259f README

—add 表示加到 Index 中

—cacheinfo 表示是从 git 数据库.git/object 中添加文件

100644 表示普通文件

再次 git status 查看状态,就不一样了:

Terminal window
$ git status
On branch master
No commits yet
Changes to be committed:
(use "git rm --cached <file>..." to unstage)
new file: README #version 1 index 区
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: README #version 2 工作区

同时我们也可以看到生成了文件 .git/index

以上过程相当于 git add REAME

所以 git add file 就是将工作区的文件内容更新到暂存区。


认识 tree object#

前面认识的 blob 对象只是文件的内容本身,以及它的 hash 值,并没有涉及到文件名,要保存文件名需要 tree 对象。

git write-tree: 从当前 index 区创建树对象

Terminal window
$ git write-tree
16ab25f42fdb4563f1acb0ff8b978493bfd2bc1c # tree 1

又生成了一个 hash 值。查看一下:

Terminal window
$ git cat-file -t 16ab25f42fdb4563f1acb0ff8b978493bfd2bc1c
tree
$ git cat-file -p 16ab25f42fdb4563f1acb0ff8b978493bfd2bc1c
100644 blob 6fe402b35d6e80a187adc393f36ce10e4fdd259f README

内容与之前暂存区的内容一致,说明确实是从暂存区到了数据库。

这样我们也就认识到了 git 对象中的第二种类型 tree object,即树对象。

tree 对象不仅可以保存文件名,还可以保存多个文件的内容及其唯一键,而且它还允许嵌套子树,这相当于文件目录。

我们先再建一棵树:

Terminal window
$ echo '1.0' > VERSION
$ git update-index --add VERSION
$ git update-index --add README # version 2
$ git status
On branch master
No commits yet
Changes to be committed:
(use "git rm --cached <file>..." to unstage)
new file: README
new file: VERSION
$ git write-tree
33ad99f76f295411d5c198cb58c5c95e5d0b3c91 # tree 2
$ git cat-file -p 33ad99f76f295411d5c198cb58c5c95e5d0b3c91
100644 blob 216ef921a90b782fed1ca37223c3141ed7d5de32 README
100644 blob d3827e75a5cadb9fe4a27e1cb9b6d192e7323120 VERSION

这个目录树包含两个文件对象。

其实还可以包含子树,我们可以利用 git read-tree 把第一棵树整个读入暂存区,然后再写入 Git 的数据库。

git read-tree: 将树信息读到暂存区

Terminal window
第一棵树读到 index 区,并放到 bak 下面
$ git read-tree --prefix=bak 16ab25f42fdb4563f1acb0ff8b978493bfd2bc1c
# 将当前整个状态写入新的树,放回数据库
$ git write-tree
77e9ad8de018dab58d76e0667507378b3cfe4808 # tree 3

但是此时并没有创建正真的 bak 目录,至少在工作区是没有的 (ls 查看不到),它目前只存在于暂存区中

Terminal window
$ git status
On branch master
No commits yet
Changes to be committed:
(use "git rm --cached <file>..." to unstage)
new file: README
new file: VERSION
new file: bak/README
Changes not staged for commit:
(use "git add/rm <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
deleted: bak/README

此时只需要使用 git checkout 命令将它从暂存区恢复到工作区就行:

Terminal window
$ git checkout -- bak/README
$ git status
# 此时可以观察到确实有这个目录了
$ ls
README VERSION bak

同时别忘了 git cat-file 查看新树:

Terminal window
$ git cat-file -p 77e9ad8de018dab58d76e0667507378b3cfe4808
100644 blob 216ef921a90b782fed1ca37223c3141ed7d5de32 README
100644 blob d3827e75a5cadb9fe4a27e1cb9b6d192e7323120 VERSION
040000 tree 16ab25f42fdb4563f1acb0ff8b978493bfd2bc1c bak

100644 表示普通文件,040000 表示目录

用一张图表示:

image-20220213174314167

认识 commit object#

git commit-tree:创建 commit 对象

目前我们有三棵树:16ab233ad977e9a

现在可以根据树来创建 commit

Terminal window
$ echo 'first commit' | git commit-tree 16ab2
3aa1317953001375c744a8a12f59a37cc1640fdb
$ git log 3aa13
commit 3aa1317953001375c744a8a12f59a37cc1640fdb
Author: Li Linchao <lilinchao@oschina.cn>
Date: Mon Aug 16 16:16:31 2021 +0800
first commit
(END)
Terminal window
$ git cat-file -t 3aa1317953001375c744a8a12f59a37cc1640fdb
commit
$ git cat-file -p 3aa1317953001375c744a8a12f59a37cc1640fdb
tree 16ab25f42fdb4563f1acb0ff8b978493bfd2bc1c
author Li Linchao <lilinchao@oschina.cn> 1629101791 +0800
committer Li Linchao <lilinchao@oschina.cn> 1629101791 +0800
first commit

这个过程相当于 git commit -m "message"

以上就创建了 git 对象中的第三种类型 commit object,即提交对象

其它的 commit 时按照 tree 生成的顺序来:

Terminal window
$ echo 'second commit' | git commit-tree 33ad9 -p 3aa13
2aa80fc99a89a808fc0342972c5a3514d41fa5f7
$ git log 2aa8
commit 2aa80fc99a89a808fc0342972c5a3514d41fa5f7
Author: Li Linchao <lilinchao@oschina.cn>
Date: Mon Aug 16 16:21:42 2021 +0800
second commit
commit 3aa1317953001375c744a8a12f59a37cc1640fdb
Author: Li Linchao <lilinchao@oschina.cn>
Date: Mon Aug 16 16:16:31 2021 +0800
first commit
(END)
$ echo 'third commit' | git commit-tree 77e9a -p 2aa80
bdc5642cd9e8a62767710d1d9761b056f91f094c
$ git log bdc56
commit bdc5642cd9e8a62767710d1d9761b056f91f094c
Author: Li Linchao <lilinchao@oschina.cn>
Date: Mon Aug 16 16:23:00 2021 +0800
third commit
commit 2aa80fc99a89a808fc0342972c5a3514d41fa5f7
Author: Li Linchao <lilinchao@oschina.cn>
Date: Mon Aug 16 16:21:42 2021 +0800
second commit
commit 3aa1317953001375c744a8a12f59a37cc1640fdb
Author: Li Linchao <lilinchao@oschina.cn>
Date: Mon Aug 16 16:16:31 2021 +0800
first commit
(END)

不管是数据对象 (blob object), 树对象 (tree object), 提交对象 (commit object),所有的对象都会放到对象数据库中:

Terminal window
$ tree .git/objects
.git/objects
├── 16
└── ab25f42fdb4563f1acb0ff8b978493bfd2bc1c
├── 21
└── 6ef921a90b782fed1ca37223c3141ed7d5de32
├── 2a
└── a80fc99a89a808fc0342972c5a3514d41fa5f7
├── 33
└── ad99f76f295411d5c198cb58c5c95e5d0b3c91
├── 3a
└── a1317953001375c744a8a12f59a37cc1640fdb
├── 6f
└── e402b35d6e80a187adc393f36ce10e4fdd259f
├── 77
└── e9ad8de018dab58d76e0667507378b3cfe4808
├── bd
└── c5642cd9e8a62767710d1d9761b056f91f094c
├── d3
└── 827e75a5cadb9fe4a27e1cb9b6d192e7323120
├── info
└── pack
11 directories, 9 files

创建成功 commit 对象后,很快我们就会发现一个问题:

Terminal window
$ git log
fatal: your current branch 'master' does not have any commits yet

前面查看 log 时指定了 commit ID 才有结果,但是如果不指定 commit,系统就提示当前分支没有 commit,这是怎么回事?我们平时都是没指定也能正常使用对吧。

原因是还有些工作没有完成,这就涉及到 Git 引用 (Git Reference)。

local_a 仓库中,目前我们还没有创建任何引用,所以 .git/refs 下面还是空的:

Terminal window
$ find .git/refs
.git/refs
.git/refs/tags
.git/refs/heads
$ find .git/refs -type f

我们还是继续手动创建。

首先我们需要一个指向当前分支(默认 master)最新提交 (bdc56) 的引用:

Terminal window
$ echo bdc5642cd9e8a62767710d1d9761b056f91f094c > .git/refs/heads/master

此时,再次运行 git log,不需要指定 commit ID 就能看到全部提交历史了。

实际上 git 有专门的命令完成引用设置:git-update-ref

Terminal window
$ git update-ref refs/heads/master bdc5642cd9e8a62767710d1d9761b056f91f094c

基于引用,于是就有了分支的实现。实际上每个分支都有一个 head 指针,指向该分支的最新提交。

假设我们在第二次提交 (2aa80fc99a89a808fc0342972c5a3514d41fa5f7) 上切出一个分支,实际上就是加一个引用名,如 dev:

Terminal window
$ git update-ref refs/heads/dev 2aa80

git log 可以看到第二次提交上有个 dev 的标签,运行 git branch 也可以看到有 dev 分支。

通过 git log 可以看到有个特殊的指针 HEAD,它是指向引用的引用,它永远指向当前分支。

当前分支在 master 分支上,所以 HEAD 指向 master,当使用 git checkout dev 后可以看到 HEAD 指向 dev, 表示当前切换到了 dev 分支。

HEAD 是一种符号引用 (symbolic reference)

git 有个专门的命令用来读取,修改,删除符号引用:git symbolic-ref

Terminal window
$ git symbolic-ref HEAD
refs/heads/master

表示当前分支是 master

Terminal window
$ git symbolic-ref HEAD refs/heads/dev

改变 HEAD 的指向,也意味着切换分支


认识 tag object#

其实还有一种比较少用到的 Git 数据对象是 tag object,它和 commit 对象有点类似,创建方式是:

Terminal window
$ git tag -a tag-name -m "tag message"

比如我们创建一个 v1.0tag:

Terminal window
$ git tag -a v1.0 -m "version 1.0"
$ cat .git/refs/tags/v1.0
05f749dc5667010dbe07ee181b3607143b84b14f
$ git cat-file -t 05f749dc5667010dbe07ee181b3607143b84b14f
tag
$ git cat-file -p 05f749dc5667010dbe07ee181b3607143b84b14f
object bdc5642cd9e8a62767710d1d9761b056f91f094c
type commit
tag v1.0
tagger Li Linchao <lilinchao@oschina.cn> 1629103432 +0800
version 1.0
# 或者
$ git cat-file -t v1.0
tag
$ git cat-file -p v1.0
object bdc5642cd9e8a62767710d1d9761b056f91f094c
type commit
tag v1.0
tagger Li Linchao <lilinchao@oschina.cn> 1629103432 +0800
version 1.0

git tag 的 hash 值在 .git/refs/tags/v1.0 文件里面。v1.0 是文件名,也是 tag 名称,文件内容是 tag 的 hash 值,它们一一对应,v1.0 就是 db1cc3710e8b284f44286400b61974a8f1e633d4 的指针。

我们可以借助 git-draw 工具生成一张图来说明此时的仓库各个数据之间的关系:

Terminal window
$ ../git-draw -i --hide-index --hide-legend --hide-reflogs --hide-refs --image-filename output.png

工具地址:https://github.com/sensorflo/git-draw

或者手动绘制各种数据的关系,如下:

http://assets.processon.com/chart_image/60a12b2fe0b34d34ca5ef5e7.png

参考#

Git 内部原理 - Git 对象

Git 内部原理 - Git 引用

认识 Git 对象

认识 Git 引用