Git 实践指南(三)
九、Git 内部原理
随着这本书接近尾声,我将用几页的篇幅讲述 Git 的一些内部知识,以帮助巩固心智模型并揭开 Git 的秘密。本章的目的不是详尽无遗,也不允许你成为 Git 的贡献者,尽管我鼓励每个人考虑为开源做贡献。我们将打开 Git 的盖子,看看一些组件是如何连接在一起的,这样我们就可以更好地推理发生了什么,如果最坏的情况发生,我们可以深入挖掘。
Git 图
在基本层面上,Git 是一个带有标签的提交图。这个图就是所谓的有向无环图,有一些有趣的性质。图论是计算机科学中一个成熟且被广泛研究的领域。Git 的许多基础算法都来自图论领域。
一个图被定义为一组顶点和它们之间的边。在 Git 中,顶点被实现为提交,边被表示为提交中的父指针。图 9-1 显示了一个带有边和顶点的 Git 图。图是有向的意味着每条边都有一个方向,因此可以认为是一个箭头。非循环意味着提交图中没有循环。因此,不可能通过跟随父指针出站来返回提交。这对 Git 使用的算法的有效性有影响。我们不打算深入研究图论背景,但在相关的地方,我会在本章介绍这个术语。
图 9-1
Git 提交图,其中包含父指针。添加了两个分支和头
承诺
在前面的章节中,我们已经将提交视为基本的原子单位,Git 最基本的部分。但是就像一个原子可以分解成中子、质子和电子一样,我们可以把一个承诺分解成它的复合元素。由于我们已经对工作区进行了版本控制,并讨论了这些版本如何与另一个版本相关联,所以提交是一个适当的抽象级别。提交也是我们在软件开发期间正常 Git 操作中使用的抽象层次。
提交由元数据组成,如 ID、作者、消息、时间戳和父指针。提交还包含一个指向树的指针,树是 Git 用来存储工作目录状态的数据结构。提交数据结构如图 9-2 所示。
图 9-2
提交中包含的数据
提交数据结构的一个关键部分是 ID,即给定提交的唯一标识符。Git 根据提交的内容确定性地生成这些惟一的 id。Git 通过散列函数来实现这一点。散列函数具有这样的特性:如果它们的输入发生变化,输出会变成什么样是不可预测的。我们可以有把握地假设,如果两个提交具有相同的 ID,则它们具有相同的内容,因此是相同的提交。Git 将.git/objects/中的对象存储在一个名为 ID 前两个字符的文件夹中,一个名为 ID 最后 38 个字符的文件中。提交c70be832f3c02582ed3b587b282aa1c034f5dc1b因此存在于文件夹中。文件0be832f3c02582ed3b587b282aa1c034f5dc1b中的 git/objects/c7/获取.git/objects/c7/0be832f3c02582ed3b587b282aa1c034f5dc1b的完整路径。
图 9-3
将内容散列到文件系统地址
前面的属性是 Git 有时被称为内容可寻址的原因。内容定义了 ID 以及存储内容的地址。正如我们在下一节中所讨论的,树也有一个由其内容决定的 ID,这意味着提交的 ID 是由其目录内容和元数据唯一决定的。请注意,即使在一个存储库中,也可能存在多个目录内容相同但元数据不同的提交。
提交包含所有这些信息。我们可以使用命令show来研究头部的提交。我们可以给它传递一个 ref,如果没有,它将默认为 HEAD。Show 还附加了 diff,展示了清单 9-1 中的变更集。
$ git log -1
commit 1135048cd36443eee6e28b472aa203b61997087b (HEAD -> master, origin/master, origin/HEAD)
Author: Johan Abildskov <randomsort@gmail.com>
Date: Thu May 21 13:35:18 2020 +0200
Add the Practical Git Bio
$ git show
commit 1135048cd36443eee6e28b472aa203b61997087b (HEAD -> master, origin/master, origin/HEAD)
Author: Johan Abildskov <randomsort@gmail.com>
Date: Thu May 21 13:35:18 2020 +0200
Add the Practical Git Bio
diff --git a/the-practical-git.md b/the-practical-git.md
new file mode 100644
index 0000000..7e8aac9
--- /dev/null
+++ b/the-practical-git.md
@@ -0,0 +1,11 @@
+# The Practical Git
+
+Hi,
+I am the first to submit a pull request to this repository and I am so happy to do it!
+
+I represent the book, so I am a part of an exercise, how exciting is this?!?
+
+Other than that, I hope you enjoy the book and contribute your bio to this repository!
+
+Cheers,
+The Practical Git
Listing 9-1Using git show to display information about a commit
输出包含有价值的信息,但是有些信息,比如 diff,是计算出来的。在我们想要研究原始内容的场景中,我们使用命令git cat-file。Cat-file 允许我们直接输出 Git 对象,如果我们以人类可读的格式而不是二进制格式使用标志-p。我只遇到过两种使用cat-file的方式:使用-p调查内容和使用-s检查大小。在清单 9-2 中,我展示了运行cat-file -p和-s来查看提交的大小和存储在磁盘上的内容。当钩子和过滤器可能会干扰一个简单的工作空间调查时,像这样深入挖掘是有用的。
$ git cat-file -p 11350
tree e119db480900fac506e721d6560fce9ffcc0765f
parent ce866b9f738529476f87347a76b0ba69e5ff0960
author Johan Abildskov <randomsort@gmail.com> 1590060918 +0200
committer Johan Abildskov <randomsort@gmail.com> 1590060918 +0200
Add the Practical Git Bio
$ git cat-file -s 113504
250
Listing 9-2Using cat-file -p and -s to investigate a commit
在清单 9-2 中,我们看到了我们之前提到的树引用。这个树对象包含我们正在版本化的工作目录的根目录上的数据。清单 9-2 中包含的数据唯一地确定了提交 ID,目录内容唯一地确定了树 ID。
树
虽然我们在抽象层次上有一个 Git 提交图,在提交之间有指针,但在更具体的层次上,我们对工作目录的演变以及它们之间的关系感兴趣。树对象跟踪文件系统中的目录。
树包含路径列表以及需要恢复到该路径的对象的类型和引用。由于树也可以引用树,这允许 Git 恢复一个完整的嵌套文件夹的工作目录。路径可以引用树、blob 或提交。树表示嵌套文件夹、blobs 文件内容,并提交要在该路径实例化的子模块。清单 9-3 中显示了一个示例树清单。
$ git cat-file -p 4f66
100644 blob f5b7a1a105b79d9b0bd889c4ba9c3feba88687fc README.md
100644 blob 9b2c04de2d845c775fa98f86fcf2bed7f0bf1549 setup.ps1
100755 blob 20cbad89573a7f1472e9bd2bcafd8441eedfecef setup.sh
040000 tree 85d92a502a5fa0297480932721ccd07c91bb9ef6 utils
Listing 9-3A tree listing
在下一节中,我们将展示 blobs 是如何工作的,这将允许我们绘制 Git 内部数据表示的完整图像。这里有趣的一点是,blob 只包含要在给定路径上恢复的文件的内容。这意味着树列表中的名称单独负责给定文件在目录结构中的名称。这也是 Git 对文件进行重复数据消除的方式。因为相同的内容将以相同的散列 ID 结束,所以相同文件的多个副本不会占用存储库中的空间。树也驻留在.git/objects。
一滴
Blobs 是文件内容存储。我们的直觉告诉我们,一个文件由一个包含名称的路径和一些内容组成。在 Git 中,处理路径和文件名信息的是树或文件夹抽象,所以留给 blob 的唯一责任是管理文件内容。Git 中的 id 是使用 sha1 或 sha256 算法通过散列内容生成的。如前所述,Git 被认为是内容可寻址的。当讨论 blobs 的地址或文件路径直接从文件内容中计算出来时,这一点可能最为明显。下面的代码显示了稍微更改一个文件会如何不可预知地更改 blob ID:
$ ls -alh
total 24K
drwxr-xr-x 1 rando 197609 0 aug 10 14:30 ./
drwxr-xr-x 1 rando 197609 0 aug 10 14:29 ../
drwxr-xr-x 1 rando 197609 0 aug 10 14:31 .git/
-rw-r--r-- 1 rando 197609 8,1K aug 10 14:31 file.txt
$ git cat-file -p HEAD
tree b8041d12e65e591d4921bc3edfc9cabc23f9565a
author Johan Abildskov <randomsort@gmail.com> 1597062696 +0200
committer Johan Abildskov <randomsort@gmail.com> 1597062696 +0200
First commit
$ git cat-file -p b8041d12e65e591d4921bc3edfc9cabc23f9565a
100644 blob 02454bc2cea1cdbce18a1cdcc39d94fad5a9777f file.txt
$ echo " " >> file.txt
$ git add .
$ git commit -m "update file"
[master da9a6db] update file
1 file changed, 1 insertion(+), 1 deletion(-)
$ git cat-file -p HEAD
tree 243983d2cef9f535fd2a6d728958e0b09398bf72
parent 41e1a39ebc9c3720d60945c95bd4bd7152dbc907
author Johan Abildskov <randomsort@gmail.com> 1597062777 +0200
committer Johan Abildskov <randomsort@gmail.com> 1597062777 +0200
update file
$ git cat-file -p 243983d2cef9f535fd2a6d728958e0b09398bf72
100644 blob 2f7720fb6a49470af72fbc2b56061e1871320c93 file.txt
在前面的代码中,添加一个空白字符会将 ID 从02454bc2cea1cdbce18a1cdcc39d94fad5a9777f更改为243983d2cef9f535fd2a6d728958e0b09398bf72,这两个字符串没有明显的联系。这是哈希函数的一个属性。哈希函数的另一个特性是,冲突发生的可能性很小,实际上不会发生。冲突是指两个不同的输入产生相同的输出。Git 从 sha1 迁移到 sha256 的一个原因是,面对现代计算能力,很难产生冲突。其结果是,即使文件名不同,也不可能有重复的文件内容。用不同的内容覆盖一个文件实际上也是不可能的,因为那需要一个冲突。
参考
在 Git 中,我们有三种引用类型:分支、标签和远程。除了带注释的标签,引用是轻量级的。轻量级意味着没有附加额外的信息,但它是一个简单的指针。
分支驻留在.git/refs/heads中,而标签驻留在。git/refs/tags。Remotes 出现在.git/refs/remotes中,从我们的角度来看,它可以被视为只读分支。这是因为更新它们应该总是来自从远程获取信息的操作,而不是在本地操作它们。正如我们在前面讨论分支时所讨论的,引用打破了我们对分支外观和行为的直觉和心理模型。在 Git 中,引用是标记特定提交的标签,因此比直接通过 ID 检索更容易。标签可以被认为是不移动的分支。
HEAD 是一个特殊的指针,它指向当前签出的内容。HEAD 既可以指向本地分支,也可以指向 commit。如果我们试图切换到一个远程分支或一个标签,我们将结束在分离头状态。在这种情况下,我们可能会丢失我们的工作,因为新提交在默认情况下没有对它们的引用,所以过一段时间后,它们将被垃圾收集。
下面的代码清单显示了分离的 HEAD 场景:
$ git log --oneline --decorate
da9a6db (HEAD -> master, tag: test) update file
41e1a39 First commit
$ git switch test
fatal: a branch is expected, got tag 'test'
$ git checkout test
Note: switching to 'test'.
You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by switching back to a branch.
If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -c with the switch command. Example:
git switch -c <new-branch-name>
Or undo this operation with:
git switch -
Turn off this advice by setting config variable advice.detachedHead to false
HEAD is now at da9a6db update file
这里的解决方案是创建一个新的指针,或者签出一个指向相关提交的现有指针。
虽然我们的分支本身不包含任何信息,但是它们包含的元信息对于可追溯性是很重要的——并且询问这样的问题,比如在我进行硬重置之前主分支在哪里。为此,我们可以使用 git reflog。如果我们传递一个对 git reflog 的引用,我们会得到一个指针如何改变的列表。然后,我们可以使用那里的引用,根据引用所在的位置来检查提交。最常见的是,我们使用像master@{1}这样的索引,这意味着主引用是在一个变更之前。还有更多抽象引用,如master@{yesterday}或master@{upstream}来检查主设备的跟踪分支指向哪里。下面的屏幕截图显示了在一个简单的线性示例中使用 reflog。真正有趣的地方是更复杂的历史。
$ git reflog
da9a6db (HEAD -> master, tag: test) HEAD@{0}: checkout: moving from 41e1a39ebc9c3720d60945c95bd4bd7152dbc907 to master
41e1a39 HEAD@{1}: checkout: moving from master to master@{1}
da9a6db (HEAD -> master, tag: test) HEAD@{2}: checkout: moving from da9a6dbe39f09e98520f208e2b94ec610af1af4f to master
da9a6db (HEAD -> master, tag: test) HEAD@{3}: checkout: moving from master to test
da9a6db (HEAD -> master, tag: test) HEAD@{4}: commit: update file
41e1a39 HEAD@{5}: commit (initial): First commit
$ git checkout master@{1}
Note: switching to 'master@{1}'.
You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this state without impacting any branches by switching back to a branch.
If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -c with the switch command. Example:
git switch -c <new-branch-name>
Or undo this operation with:
git switch -
Turn off this advice by setting config variable advice.detachedHead to false
HEAD is now at 41e1a39 First commit
Listing 9-4Using git reflog
to investigate where a pointer has been
请注意,reflog 是一个纯粹的本地概念,不能在同一个存储库的多个克隆之间共享。
使用树进行版本控制
我们已经讨论了所有的组成部分,所以我们有了讨论 Git 中版本控制如何工作的框架。我们有指向分支、指向提交、指向树、而树指向 blobs 和树头。虽然前面的句子是我们需要知道的全部,但它也以一种实际上可理解的格式显示在图 9-4 中。
图 9-4
底层对象的单次初始提交
在图 9-4 中,显示了具有相应 Git 对象结构的单次提交。作为文件系统的一对一映射,这看起来很了不起。当我们添加更多的提交时,真正有趣的部分发生了。Git 将尽可能多地重用已经存在的提交。鉴于 Git 的内容可寻址特性,这是免费的。这意味着只需要创建包含更改的树,因为已经存在的树将被重用,因为它们具有正确的地址。在提交操作期间,不会删除任何 blob 对象;这是一个附加的过程,只创建表示当前工作目录所需的 blobs。这种重用就是为什么 Git 没有贪婪地把你的硬盘和你周围所有不同的版本打包在一起。图 9-5 显示了改变一个文件如何创建新的树和 blob 对象,同时重用未改变的对象。
图 9-5
创建提交会重用对象和树
Note
如果工作目录中的任何文件由于被添加、删除或修改而发生变化,这将最终导致根树的变化。如果文件室树不变,目录内容也不变。Git 通常不会接受这一点,但是您可以在提交时使用标志- allow-empty 强制它这样做。
Git Internals
在这个练习中,我们从一个空的存储库开始,随着我们添加内容,慢慢地研究存储库中显示了哪些部分。本练习首先在本地创建一个空的存储库,因此您可以从有命令行的地方开始。请注意,所有的 id 都与您在练习中看到的不同,所以如果您天真地复制粘贴命令,它们不太可能成功。
我们从初始化一个空的存储库开始,四处看看我们能找到什么。
$ git init pg-internals
Initialized empty Git repository in /user/randomsort/pg-internals/.git/
$ cd pg-internals/
$ ls -al .git
total 24
drwxr-xr-x. 4 randomsort users 4096 Aug 11 13:43 .
drwxr-xr-x. 3 randomsort users 4096 Aug 11 13:43 ..
-rw-r--r--. 1 randomsort users 92 Aug 11 13:43 config
-rw-r--r--. 1 randomsort users 23 Aug 11 13:43 HEAD
drwxr-xr-x. 4 randomsort users 4096 Aug 11 13:43 objects
drwxr-xr-x. 4 randomsort users 4096 Aug 11 13:43 refs
$ ls .git/objects
info pack
要注意的第一个有趣的部分是,除了 info 和 pack 文件夹,object 文件夹是空的。这些是用于 Git 压缩的文件夹。它们在新初始化的存储库中是空的。
$ ls .git/refs
heads tags
$ ls .git/refs/heads
我们有 refs 文件夹,但是我们可以看到没有分支。
$ cat .git/HEAD
ref: refs/heads/master
HEAD 仍然指向主分支,即使它不存在。这是一种只需要处理棘手问题的情况。要么头不存在,要么它指向的分支不存在,要么分支指向的对象丢失。从下面的 status 命令中,我们可以看到 Git 清楚地意识到了这种情况,但是如果我们尝试签出主分支,就会得到一个错误。
$ git status
On branch master
No commits yet
nothing to commit (create/copy files and use "git add" to track)
$ git checkout master
error: pathspec 'master' did not match any file(s) known to git
让我们创建第一个提交。
$ echo "# First data" > README.md
$ git add README.md
$ git commit -m "Initial Commit"
[master (root-commit) 2fb5c2e] Initial Commit
1 file changed, 1 insertion(+)
create mode 100644 README.md
在创建了第一个提交之后,我们期望在.git/objects文件夹中看到三个对象:一个用于提交,一个用于树,一个用于 blob。
$ ls .git/objects
2f bf d4 info pack
$ ls .git/objects/2f
b5c2e86d6f21d52d2f05b07ed524669f10d07f
不深入,我们可以看到我们有三个对象。我们可以在这里使用cat-file来检查任何给定对象的内容。我们注意到对象的 ID 是文件夹名(2f)与文件名(b5c2e86d6f21d52d2f05b07ed524669f10d07f连接在一起。类似于我们引用提交时,我们可以使用完整 ID 的唯一前缀。
$ git cat-file -p 2fb5c
tree bfd4eb4e8767678f4abfe229f7ee701ca9ee0b69
author Johan Abildskov <randomsort@gmail.com> 1597146899 +0200
committer Johan Abildskov <randomsort@gmail.com> 1597146899 +0200
Initial Commit
因此,在这个场景中,我们似乎遇到了提交对象。
现在我们用一个文件创建一个子目录,看看它是如何改变我们的对象的。
$ mkdir subdir
$ echo "important content" > subdir/file.txt
$ git add subdir/file.txt
$ git commit -m "Add important content"
[master 62b545d] Add important content
1 file changed, 1 insertion(+)
create mode 100644 subdir/file.txt
$ ls .git/objects
24 2f 3a 62 bf c9 d4 info pack
在这里,我们看到,与初始提交时的三个对象相比,我们最终获得了七个对象。我们以此结束,因为我们创建了一个新的 commit、一个新的根树对象、一个用于 subdir 的树对象和一个用于 file 的 blob 对象——再加上仍然存在的最初的三个对象。我们没有为 README.md 创建新的 blob,因为它没有改变,将被重用。我们可以再次使用 cat-file 来查看新提交的内容,注意我们有了一个新的树对象。
$ git cat-file -p HEAD
tree 3a7a21c251d2e8c05a6c1c7c2c866c4c3821e97e
parent 2fb5c2e86d6f21d52d2f05b07ed524669f10d07f
author Johan Abildskov <randomsort@gmail.com> 1597147088 +0200
committer Johan Abildskov <randomsort@gmail.com> 1597147088 +0200
Add important content
Git 过滤器和驱动程序会导致工作目录中的 Git 存储库内容与存储库中的不同。对于 Git LFS 来说是这样,但是也可能是本地配置,比如行尾。
在我们想知道 Git 中真正存储了什么的情况下,我们可以使用git ls-tree来查找给定路径在给定时间点的 blob 是什么。在下面一行中,我们告诉 Git 递归地遍历树,在修订头中搜索 subdir/file.txt。代替 HEAD,我们可以提供一个任意树或提交对象。
$ git ls-tree -r HEAD subdir/file.txt
100644 blob 24013f7d4de4b5143b03c76db8656625c00798d2 subdir/file.txt
现在我们已经修补了对象,让我们操纵一些分支。首先,我们来看看有哪些分支。
$ git branch
* master
$ ls .git/refs/heads
master
We have the master branch, and we can see it is now also present in refs/heads. We can conclude that the file representing the branch was created when we made the first commit.
Under normal circumstances we use Git to create branches, but it is trivial to do so manually.
$ cp .git/refs/heads/master .git/refs/heads/practical-git
$ git branch
* master
practical-git
通过复制命令的魔力,我们创建了一个新的分支。这样做的结果是创建一个分支的成本非常低,因为文件包含的只是提交的 sha。HEAD 指向 master,所以让我们检查一下我们的另一个分支。
$ echo "ref: refs/heads/practical-git" > .git/HEAD
$ git status
On branch practical-git
nothing to commit, working tree clean
如我们所见,我们成功地手动切换了分支。当然有一个警告,如果分支没有指向相同的提交,我们的状态可能会非常不同。
卡塔斯
为了支持本章的学习目标,我建议您完成以下表格:
-
调查
-
重置(你之前做过这个练习,但是现在你应该有一个更好的基础来推理正在发生的事情。记得用 reflog!)
摘要
本章已经带你浏览了 Git 的一些内部结构,以进一步加深你对 Git 工作原理的理解。除了参考日志,你不应该再挖这么深,但我希望你喜欢这次旅行!