关于
译者注:本文档翻译自 Git Internals,仅用于学习交流。 |
本 PWA 应用的内容是关于 Git 的内部结构的。
本应用在 github.com/HarshKapadia2/git_internals 上开源。
该网站的内容也以讲座的形式呈现,欢迎 观看 Git Internals 讲座。 |
前置准备
事先了解一下 Git 基础会对学习本站内容很有帮助。
-
观看 Git Basics 讲座.
-
浏览 git_basics PWA。
.git
目录
简介
在一个目录中执行 git init
命令 时,Git 会在这个目录下创建一个隐藏目录 .git
。.git
目录包含了 Git 可以执行其版本控制功能的项目的所有历史数据。此外,它还包含了用于配置 Git 如何处理该特定 仓库 的相关事宜的配置文件。
.git
目录的内容
.git
├───addp-hunk-edit.diff
├───COMMIT_EDITMSG
├───config
├───description
├───FETCH_HEAD
├───HEAD
├───hooks
│ └───<*.sample>
├───index
├───info
│ └───exclude
├───lfs
│ ├───cache
│ │ └───locks
│ │ └───refs
│ │ └───heads
│ │ └───<branch_names>
│ │ └───verifiable
│ ├───objects
│ │ └───<first_2_SHA-256_characters>
│ │ └───<next_2_SHA-256_characters>
│ │ └───<entire_64_character_SHA-256_hash>
│ └───tmp
├───logs
│ ├───HEAD
│ └───refs
│ ├───heads
│ │ └───<branch_names>
│ ├───remotes
│ │ └───<remote_aliases>
│ │ └───<branch_names>
│ └───stash
├───MERGE_HEAD
├───MERGE_MODE
├───MERGE_MSG
├───objects
│ ├───<first_2_SHA-1_characters>
│ │ └───<remaining_38_SHA-1_characters>
│ ├───info
│ │ ├───commit-graph
│ │ └───packs
│ └───pack
│ ├───multi-pack-index
│ ├───<*.idx>
│ ├───<*.pack>
│ └───<*.rev>
├───ORIG_HEAD
├───packed-refs
├───rebase-merge
│ ├───git-rebase-todo
│ ├───git-rebase-todo.backup
│ ├───head-name
│ ├───interactive
│ ├───no-reschedule-failed-exec
│ ├───onto
│ └───orig-head
└───refs
├───heads
│ └───<branch_names>
├───remotes
│ └───<remote_aliases>
│ └───<branch_names>
├───stash
└───tags
└───<tag_names>
index
文件
-
该文件包含了 已暂存(已添加) 文件的全部细节,它就是 Git 仓库的暂存区。
|
-
该文件是在第一次添加文件时创建的,以后每次执行
git add
命令 时都会更新它。
-
该文件是一个二进制文件,只是使用
cat .git/index
查看其内容时会导致乱码. 想要查看其内容可以使用git ls-files --stage
[底层命令]。
-
上图中的内容解释
-
100644
表示文件的模式,它是一个八进制数。Octal: 100644 Binary: 001000 000 110100100
-
接下来的 40 个字符的十六进制字符串表示的是文件的 SHA-1 哈希。
-
下一个数字是暂存号/槽,它在处理合并冲突时很有用。
-
0
表示正常无冲突的文件。 -
1
表示文件的原始版本。 -
2
表示“我们的”版本,即 HEAD 指向的版本,包括了冲突双方的更改。 -
3
表示“他们的”版本,即被合并的版本的文件。
-
-
最后的字符串是所引用文件的名称。
-
有关 index 文件的更多内容可参阅 资源 一节。
|
HEAD
文件
-
它用于引用当前分支中的最新提交。
-
HEAD 文件通常不使用这个最新 commit 的 SHA-1 哈希,而是记录了一个在 refs 目录 下的文件(以当前分支的名称命名)路径,这个文件存储了在它这个分支下最后一个 commit 的 SHA-1 哈希。
-
当 一个特定的 commit 或 tag 被检出 时,该文件才会包含提交的 SHA-1 哈希。(分离头(Detached HEAD)状态。)
-
Eg:
# in the 'main' branch $ cat .git/HEAD ref: refs/heads/main $ git switch test_branch Switched to branch 'test_branch' $ cat .git/HEAD ref: refs/heads/test_branch
refs
目录
.git
├───...
└───refs
├───heads
│ └───<branch_name(s)>
├───remotes
│ └───<remote_alias(es)>
│ └───<branch_name(s)>
├───stash
└───tags
└───<tag_name(s)>
不要把 .git/refs 目录和 .git/logs/refs 目录 搞混了,他们的用处不同。
|
packed-refs
文件
-
Git 会在
refs
目录 下的为每个分支和标签创建一个文件。 -
在拥有很多分支和标签的仓库中,便会存在很多引用(refs),其中很少会被主动使用或更改的。
-
这些引用占用了大量的存储空间,以致出现性能问题。
-
git pack-refs
就是用来解决上面👆🏻这个问题的。它将所有的引用存储在这个名为“packed-refs”的文件中。git gc
命令也可以用来执行此操作。
-
执行上述命令打包后,如果某个引用在
refs
目录中缺失了,Git 就会在此文件中查找。 -
对已打包 ref 的分支进行后续更新(新提交、拉取或推送更改)时,会像往常一样在
refs
目录中创建一个带有该分支名称的新文件,但它不会将该分支在packed-refs
文件中的哈希值更新为最新的哈希值。(为此必须生成一个新的packed-refs
文件。)
logs
文件夹
.git
├───...
└───logs
├───HEAD
└───refs
├───heads
│ └───<branch_name(s)>
├───remotes
│ └───<remote_alias(es)>
│ └───<branch_name(s)>
└───stash
-
该目录包含了按顺序排列的所有提交的历史记录。
不要把 .git/logs/refs 目录 and the .git/refs 目录 搞混了,他们的用处不同。
|
FETCH_HEAD
文件
-
它包含了取回的远程分支的最新提交。
-
它对应的分支是:
-
上次取回时 检出 的分支。
-
从上图可以看到,只有一个分支不带
not-for-merge
文本描述。这个奇怪的分支(本例中是main
分支)就是远程取回时检出的分支。
-
-
使用
git fetch <remote_repo_alias> <branch_name>
command 命令时明确指定的分支。
-
COMMIT_EDITMSG
文件
-
提交信息就是写入此文件中的。
-
当执行
git commit
command 命令时,该文件会被编辑器打开。 -
该文件内容包含
git status
command 命令输出中由该字符#
注释掉的内容。 -
如果之前有过提交,那么这个文件将会显示上次提交的信息以及本次提交之前的
git status
的输出。
objects
目录
.git
├───...
└───objects
├───<first_2_SHA-1_characters>
│ └───<remaining_38_SHA-1_characters>
├───info
│ ├───commit-graph
│ └───packs
└───pack
├───multi-pack-index
├───<*.idx>
└───<*.pack>
-
.git
目录下最重要的目录了。 -
它保存着仓库中所有的 commit, tree and blob 对象 的数据(SHA-1 哈希)。
-
为了减少访问时间,对象被放置在存储桶(目录)中,其 SHA-1 哈希值的前两个字符作为存储桶的名称,其余 38 个字符用于命名对象的文件。
不要将这个目录 (.git/objects/info ) 和 .git/info 目录 搞混了,它们的用处不同。
|
info
目录
.git
├───...
└───info
└───exclude
-
它含有一个
exclude
文件,其行为类似于.gitignore
文件,但用于在本地忽略文件而不修改.gitignore
。
不要将这个目录 (.git/info ) 和 .git/objects/info 目录 搞混了,它们的用处不同。
|
config
文件
-
这个文件包含了本地 Git 仓库的配置。
-
使用
git config --local
命令 可以修改它。
addp-hunk-edit.diff
文件
-
当在
git add --patch
命令 中指定了-e
(edit) 选项时创建该文件。 -
该文件的创建使得你可以手动编辑将要 暂存 的文件 hunk。
ORIG_HEAD
文件
-
它包含了一个 commit 的 SHA-1 哈希。
-
它是 HEAD 的前一个状态,但不一定是紧邻的前一个状态。
-
它是由某些具有破坏性/危险行为的命令设置的,因此它通常指向具有破坏性更改的最新提交。
-
由于 [
git reflog
命令] 的存在,它现在不是那么有用,git reflog
更容易回退/重置到一个特定的 commit。
description
文件
-
仓库的描述文件。
-
该文件由 GitWeb 使用,目前几乎没有人使用它,因此可以不用管。
Git 对象
简介
Git 有两种数据结构, 一种是可变的(mutable) index,用于缓存有关工作区和下次要提交的修订的信息。另一种是不变的(immutable)、只能追加的对象数据库 (repository),它包含四类对象:
-
Blob 对象
-
Commit 对象
-
Tree 对象
-
Tag 对象
Git 使用这些对象来存储数据来以进行版本控制,通过理解这些对象可以理解 Git 的内部工作原理。
本节将通过一个示例来探讨 Git 内部工作的部分内容。
接下来,请跟随我们的脚步随时使用 Git Graph 创建图解。 |
运行 git init`
在 inside_git
目录(根目录)中初始化一个空 仓库,Git 在根目录中会创建一个隐藏目录 .git
。
命令 du -c
用于列出 inside_git
的子目录及其占用的磁盘大小(以 kbs 为单位)。
blob
对象
Blob 对象存储了文件的内容。 |
在根目录下创建一个文件。
现在,工作树(根目录)包含了 .git
目录和新文件 master_file_1.txt
。
使用 git add .
命令将新文件添加到 暂存区,然后再次运行 du -c
。
注意一个新目录 e6
被添加到了 .git/objects
目录下。
使用 dir
(或 ls
) 命令来看下 .git/objects/e6
目录下的文件。
文件名 9de29bb2d1d6434b8b29ae775ad8c2e48c5391
38 个字符长。将其附加到文件夹名称 (e6
) 后,就变成了 40 个字符的字符串 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
,这是一个 SHA-1 哈希。Git 使用 SHA-1 算法对文件内容(以及其他相关数据)进行哈希处理,生成 40 个字符的十六进制字符串。对于每一个 暂存,[commit] 和 [tag] 都会生成自己唯一的 SHA-1 哈希值。(作为 40 个字符的字符串,哈希冲突非常罕见。)哈希值的前两个字符用于将哈希值归类到文件夹中,以减少访问时间。为了方便起见,Git 有时只使用对象哈希值的 4 到 8 个字符。
如前所述,Git 会对文件内容以及其他细节进行散列处理,生成一个 40 个字符的 SHA-1 哈希值。为了验证这一点,我们需要在文件中添加一些内容,然后再次把文件添加到暂存区。(这将产生另一个哈希)。
从上图中的最后一条命令可以推断,一个新的哈希值 1a3851c172420a2198cf8ca6f2b776589d955cc5
生成了。使用 cat 命令检查其内容:
可以看到其输出是乱码,因为 Git 使用 zlib 库压缩文件内容(以及一些附加数据),然后将其存储在文件中。因此,为了弄懂这些乱码是什么意思,我们需要解压缩文件的内容。
可以看到,这个哈希文件的内容是 blob 16\0Git is amazing!\n
。(\0
和 \n
不可见,下文会对其解释。)
将这段内容拆开来看:
-
blob
是文件的对象类型,它是 'Binary Large OBject' 的缩写。这种对象 (文件) 存储了文件的内容。 -
16
是文件的尺寸(长度)。Git is amazing!
由 15 个字符组成, 然而echo
命令 在文本的末尾添加了一个换行符 (\n
),因此其长度是 16。 -
就像在输出中看不到
\n
字符一样,在长度和文件内容之间有一个 NULL 字符 (\0
)。 -
Git is amazing!\n
是文件内容 (\n
不可见)。
如果 |
综上,Git 使用 <object_type> <content_length>\0<file_content>
这个字符串格式来生成文件的哈希值,然后将其压缩后存在文件中。 (文件名是生成的 40 个字符哈希值的最后 38 个字符,前两个字符则用于存储它的文件夹名。)
Blob 对象不存储文件的差异化内容( diff/delta),而是存储文件的完整内容。 |
使用 将使用的 git cat-file 命令的变体 接下来要使用
|
commit
对象
Commit 对象将若干个 tree 对象链接成一个历史记录,它包含了一个 tree 对象(顶级源目录)的名称,一个时间戳,一条日志信息以及 0 个或多个父 commit 对象的名称。 |
提交 master_file_1.txt
,然后再次运行 du -c
。
从上图可以看到,有两个新目录 .git/objects/1b
和 .git/objects/d5
被创建了。而且,文件被提交后,Git 输出了这个提交的哈希值的前 7 个字符。
以这 7 个字符为参,用 git cat-file -t
命令检查文件类型:
文件类型正是 commit
,从而可知该文件是通过一个提交生成的。
使用 git cat-file -p
命令输出这个 commit 对象的内容如下:
Commit 对象的内容解析:
-
tree 1b2190cdc2801ec3df6505dc351dee878ac7f2fc
是生成的另一个 SHA-1 哈希值(还记得提交文件时在.git/objects
生成的两个目录吧),其对象类型是tree
,它就是当前仓库状态的 [快照(snapshot)]。 -
父提交的 SHA-1 哈希(此处不存在,下面会解释。)
-
下一行是作者的详细信息 (写代码的人):
-
姓名
-
邮箱 ID
-
时间戳
-
-
下一行是提交者的详细信息 (提交代码的人):
-
姓名
-
邮箱 ID
-
时间戳
-
-
提交信息
-
提交描述 (如果提供的话,这里没有。)
tree
对象
一个树对象就相当于一个(子)目录:它包含一个文件名列表,每个文件名都有一些类型位和一个 blob 或 tree 对象的名称,即这个文件、符号链接或目录的内容。这种对象描述了源目录树的一个快照。 |
让我们看一下在 commit 对象(文件)中列出的 tree 文件的内容:
Tree 对象类型的文件包含了本地仓库在一个快照(当前状态)中的文件和目录条目。每行的格式是相同的。
Tree 对象的内容格式如下:
-
100644
表示文件的模式,是一个八进制数。Octal: 100644 Binary: 001000 000 110100100
-
blob
标识对象类型 (也可以是tree
对象 下文会解释。)
-
1a3851c172420a2198cf8ca6f2b776589d955cc5
是文件 SHA-1 哈希值。 -
文件名称。
综上,每个 commit 对象都指向一个 tree 对象,每个 tree 对象都指向若干 blob 以及/或 tree 对象,分别对应具体的文件和子目录。
目前,commit、tree 和 blob 文件的连接关系如下所示:(HEAD
只是一个指向最新提交的指针。)
|
父提交
再新建一个文件 (master_file_2.txt
),暂存并提交。
看下 commit 对象类型文件的内容(使用上图中的部分哈希 8282663
):
可以看到一个新行 parent d5b8f77ce1dc1a37b29885026055c8656c3e0b65
出现了。记着,这是前一个提交的哈希。Git 因此又创建了一个关系图,准确地说,应该是有向无环图。(图在下面)
而且,HEAD
会自动指向这个最新的提交 82826
,而不是父提交(前一个-d5b8f
)。看下 HEAD
的指向来验证下:
它确实指向最新提交 (82826
)。
现在再看下最新提交指向的 tree 对象文件的内容:
Commit 对象,tree 对象以及 HEAD 的最新关系连接图如下所示:
该图可以使用 Git Graph 创建。 |
新建目录
在 (dir_1
) 目录下新建一个文件 (master_dir_1_file_3.txt
),暂存并提交,然后看下 commit 对象类型文件的内容:
该文件内功的格式与 上文介绍的相同。
Tree 文件(上图中的哈希 f6a65
)的内容如下:
令人惊讶的是,树 f6a65
指向另一棵树 abecf
!新树的名称是 dir_1
。
再看下 dir_1
树的内容:
它指向 dir_1
目录下的文件 (master_dir_1_file_3.txt
)。
来看下树 f6a65
是如何与其他树和 blob 建立连接的:
目前仓库的关系图是:
该图可以使用 Git Graph 创建。 |
重命名文件
将 master_file_1.txt
重命名为 the_master_file.txt
来看下 Git 内部是如何处理它的。
提交文件时,Git 会识别出文件已重命名,而不是新建一个文件,如上图最后一行所示。之所以能识别到文件重命名,是因为文件的 SHA-1 哈希值没有变化(因为文件内容没有变化)。
Check the contents of the commit and tree files.
从最后一行可以看出,哈希 1a385
与原始文件相同(文件名是 master_file_1.txt
)。Git 只是在 commit 指向的树文件中记录了已经变更后的文件名称,而不是新建一个 blob 文件。这就是 Git 高效的空间管理!
当前仓库的结构如下所示:
该图可以使用 Git Graph 创建。 |
修改大文件
添加图像并将其提交到 Git。图像的大小为 1.374Mb(或 1374kb),因此与其他文件(约 1 kb/文件)相比,它是一个相对较大的文件。
稍微修改下图片文件的内容,然后再次暂存并提交。
master_image_1.png
在最新树 6d2d2
和先前的树 27666
中的 SHA-1 哈希并不相同,因此 Git 为这个示例文件先后创建了两个不同的 blob 对象(ca893
和 1f7af
),即便它们之间只存在细微的差别。
现在运行 du -c
:
从上图可以看到,这两个目录 (.git/objects/1f
和 .git/objects/ca
) 的大小一样 (1376 kb)。
目录内容的大小 (1376 kb) 大于图像大小 (1374 kb),因为 Git 将文件类型和大小(长度)添加到 blob 文件后才对其进行哈希处理。 |
pack
目录
.git
├───...
└───objects
├───...
└───pack
├───multi-pack-index
├───<*.idx>
└───<*.pack>
每次执行 clone
、push
或 pull
,或者运行垃圾收集 (git gc
) 时,都会执行增量压缩(Delta compression)。
增量压缩会在 .git/objects/pack
目录下创建两种类型的文件。
-
若干 Pack (
.pack
) 文件 -
若干 Index (
.idx
) 文件
|
仓库的当前状态:
注意上图中 .git/objects/pack
目录的大小是 0kb。
垃圾回收(git gc
)可以用来执行增量压缩,之后用 du -c
来观察变化。
请注意,上图中 .git/objects/pack
的大小从 0kb 变成了 1380kb,.git/objects
中的很多文件也消失了,只留下了 .git/objects/e6
。
.git 目录的总大小从 4220kb(见本小节的第一个 du -c 图像)减少到 2838kb(如上图所示),这使得本地仓库的大小减少了 32.75%!
|
The contents of .git/objects/pack
As mentioned above, two types of files (a pack .pack
file and an index .idx
file) are created in .git/objects/pack
.
Check the contents of the Packfile using the plumbing command git verify-pack -v path/to/pack/file/<file_name>.pack
(-v
= verbose).
From the above image, it can be understood that the Packfile contains all the Git objects. The Pack file is a file that contains all the Git Objects (along with their content) stored in it in a particular format. All the objects stored in the Packfile are removed from the .git/objects
directory.
From the above image, it can also be understood that the size of the newly modified image (hash 1f7af
) is very large in comparison to the original image (hash ca893
). The blob of the original image (hash ca893
) also has the hash of the modified image (1f7af
) mentioned after it, indicating that its parent is the newly modified image file (hash 1f7af
). Thus Git stored the entire new file and only a diff/delta for the older file with a pointer to the newer file, rather than storing the entire file again, making it space efficient.
The newer file (hash 1f7af ) will usually be accessed more than the older one (hash ca893 ), so storing the entirety of the newer file and a delta/diff for the older one makes more sense than storing the entirety of the old file and a delta/diff for the new one. As the newer file will usually be accessed more, it would be inefficient to apply the delta/diff of the newer file to the entirety of the older file to generate the newer file every time. It is cheaper to apply the delta/diff of the older file to the entirety of the newer file, as the older file won’t be accessed as frequently.
|
|
On running aggressive Garbage Collection (git gc --aggressive
), Git got rid of all the files in .git/objects
that were referenced in a tree and added them to the Pack file. The .git/objects/e6
directory did not get removed as it was not referenced (listed) in any Tree Object.
As mentioned at the start of this sub-section, these Packfiles and Index files are created every time a clone, push or pull is executed, or if Garbage Collection (git gc
) is run. Why is this so? Network bandwidth and clone/push/pull command execution time are the main reasons. Applying Delta compression and putting in all objects into one file makes it simpler and faster to transfer data over the Network and also saves storage space (~32% space was saved through packing in this case).
Take a look at the log of the repository.
Further reading on Packfiles can be found in the Resources section. |
空提交
git commit
^ 的 --allow-empty
选项 允许创建不含有任何文件变更的提交记录。
为了说明这一点,请使用以下命令设置仓库:
$ git init
$ touch file_1.txt
$ git add .
$ git commit -m "Add file_1.txt"
$ git commit --allow-empty -m "Empty commit #1"
$ git commit --allow-empty -m "Empty commit #2"
# Now run
$ git log --oneline --graph
* 208cead (HEAD -> main) Empty commit #2
* 64cf914 Empty commit #1
* be0c1ec Add file_1.txt
Use the git cat-file -p <hash>
command as done in previous sub-sections to create the graph.
上面仓库的图解如下:
|
资源
工具
-
Git Graph: Visualize Commit, Tree and Blob objects (by Harsh Kapadia)