区分 底层(plumbing) 和 上层(poreclain) 两类 git 命令会对你很有帮助。这两个术语的应用奇怪地来自于马桶(没错,就是🚽)。马桶通常是用陶瓷(porcelain)做的,它的基本结构是管道(plumbing,上水道和下水道)。
我们可以说上层命令为底层命令提供了一个用户友好的接口。大多数人只会涉及到上层命令。然而,当事情变得(非常)糟糕时,有人可能就会想知道为什么,他们会卷起袖子去检查底层命令。(注意:这些术语并不是我发明的,它们在 git 中的使用非常广泛)。
译者注:读者若想更好的理解这两个术语,建议阅读 Git 内部原理 - 底层命令与上层命令。
git 使用这些术语进行类比,从而将用户不常使用的底层命令(plumbing)和那些更友好的高层(porcelain)命令区分开。
目前,我们已经接触过上层命令——git init、git add 和 git commit。接下来,我们转到底层命令。
CREATE REPOSITORY
我们用 git init repo_1 初始化一个新的 仓库,然后用 cd repo_1 切换到仓库所在目录。借助 tree /f .git 命令,我们可以看到运行 git init 之后 .git 目录下面出现了很多子目录(/f 表示在 tree 的输出中包含文件)。
让我们在 repo_1 目录中创建一个文件吧:
这个文件已经在我们的 工作目录 中了。目前,我们还没有将它添加到 暂存区,所以它是 未跟踪 状态。让我们用 git status 验证一下:
因为我们没有将新的文件添加到暂存区,所以它还是未跟踪状态,它也没有在之前的提交中
我们现在用 git add new_file.txt 将这个文件添加到 暂存区,再用 git status 验证一下它是否已经被暂存了:
我们可现在可以用 git commit 创建一个 提交:
.git 目录有变化吗?我们用 tree /f .git 检查一下:
很明显,很多东西都变了。是时候深入 .git 的结构,理解执行 git init、git add 或 git commit 之后发生的什么事情了。
ADVANCED
为了深入理解 git 是如何工作的,我们将从零开始创建一个 仓库。
我们不会使用 git init、git add 或 git commit,这会让我们更好地理解这个过程。
How To Set Up .GIT Folder
先创建一个新目录,然后在里面运行 git status:
好吧,因为我们没有 .git 文件夹,git 好像不怎么高兴。我们先把这个目录创建出来:
很明显,只创建一个 .git 目录还不够。我们需要往这个目录添加一些东西。
一个 git 仓库有两个主要组成部分:
- 一组对象——blob、树对象 和 提交对象。
- 一个命名这些对象的方式——称为 引用。
译者注:引用是 Git 中的一个重要概念,读者可以进一步阅读 Git 引用。
一个 仓库 可能还包含一些其它的东西,比如 git 钩子(hooks)。不过,仓库至少必须要有对象和引用。
让我们分别为对象和引用(简称:refs)各创建一个目录,Windows 下的两个目录分别为 .git\objects 和 .git\refs(UNIX 下的两个目录分别为 .git/objects 和 .git/refs)。
分支 是引用的一种,git 内部将 分支 称为 heads,所以我们会为它们创建一个目录 git\refs\heads。
然而 git status 的输出还是纹丝不动。
在寻找 仓库 中的 提交 时,git 怎么知道该从何开始呢?我之前解释过,它会寻找 HEAD,而 HEAD 指向着活动分支。
所以,我们需要创建 HEAD,它是一个位于 .git\HEAD 的文件。我们可以这么做:
Windows:
> echo ref: refs/heads/master > .git\HEAD
UNIX:
$ echo "ref: refs/heads/master" > .git/HEAD
所以我们现在知道 HEAD 是如何实现的了——它只是一个文件,文件内容描述了它所指向的分支。
执行上面的命令以后,git status 似乎改变它的主意了:
注意:虽然我们还没有创建 master 分支,但是 git 相信我们就在这个分支上。之前有讲过,master只是一个名字。如果我们想的话,也可以让 git 认为我们在 banana 分支上。
我们已经准备好了 git 目录,现在继续往下,来一次 提交(同样地,不使用 git add 或 git commit)。
How to Create Objects
让我们从创建对象并将其写入 git 的对象数据库开始吧,git 的对象数据库位于 .git\objects 中。第一条底层命令 git hash-object 会让我们将找到 blob 对象 的 SHA-1 哈希值。方式如下:
Windows:
> echo git is awesome | git hash-object --stdin
UNIX:
$ echo "git is awesome" | git hash-object --stdin
我们使用 --stdin 告知 git hash-object 从标准输入(standard input)获取输入内容,这将给我们提供相应的哈希值。
为了真的将该 blob 对象 写入 git 的对象数据库,我们可以简单地给 git hash-object 加一个 -w 开关。然后,检查 .git 目录中的内容,看看它们有没有改变。
我们现在可以看到,这个 blob 的哈希值为 54f6...36, .git\objects 下也多出来了一个名为 54 的目录,目录内有一个名为 f6..36 的文件。
所以,git 实际上是使用 SHA-1 哈希值的前两个字符作为目录的名字,剩余字符用作 blob 所在文件的文件名。
为什么要这样呢?考虑一个非常大的仓库,仓库的数据库内存有三十万个对象(blob 对象、树对象 和 提交对象)。从这三十万个哈希值中找出一个值会花些时间,因此,git 将这个问题划分成了 256 份。
为了查找上面的那个哈希值,git 会先寻找 .git\objects 目录下名为 54 的目录,然后搜索那个目录,这进一步缩小了搜索范围。.git\objects 目录下最多可能会有 256 个子目录(从 00 到 FF)。
回到生成 提交对象 的过程中来,现在我们已经创建了一个对象,它的类型是什么呢?我们可以通过另一个底层命令 git cat-file -t (-t 代表“type”)瞧一瞧:
不出所料,这个对象是一个 blob。我们还可以使用 git cat-file -p (-p 代表“pretty-print”)查看它的内容:
创建 blob 这个过程通常发生在我们将一些东西添加到 暂存区 的时候——也就是我们使用 git add 的时候。
记住:git 是为 整个 暂存的文件创建 blob。即使文件中只有修改或添加了一个字符(如同我们在之前的例子红添加 ! 一样),该文件也会有一个新的 blob,这个 blob 有着新的哈希值。
git status 会有任何改变吗?
显然没有。向 git 的内部数据库中添加一个 blob 对象并不会改变状态,因为 git 在这个阶段是不知道任何已跟踪或未跟踪文件的。
我们需要跟踪这个文件——把它添加到 暂存区。为此,我们可以使用底层命令 git update-index,例如:git update-index --add --cacheinfo 100644 <blob-hash> <filename>。
注意:cacheinfo 是一个git 存储的十六位的文件模式,这个模式遵循POSIX 类型和模式的布局。这超出了本文讨论的范围。
运行上述命令会改变 .git 目录的内容:
你能发现变化吗?多了一个名为 index 的新文件。这就是著名的 索引 (或 暂存区),它基本上是一个位于 .git\index 中的文件。
既然 blob 已经被添加到了 索引,我们希望 git status 看起来会有所不同,像这样:
真有趣!这里发生了两件事。
第一件事,我们可以在 changes to be committed 中看到绿色的 my_file.txt。这是因为 索引 中有了 my_file.txt,它正等着被提交。
第二件事,我们可以看到红色的 my_file.txt——因为 git 相信 my_file.txt 这个 文件 已经被删除了,并且它没有被暂存。
这发生在我们将内容为 git is awesome 的 blob 添加到对象数据库中的时候,我们告诉 索引 ,那个 blob 的内容在文件 my_file.txt 中,但是我们从未创建过那个文件。
通过将那个 blob 的内容写入我们文件系统中名为 my_file.txt 的文件,我们可以很容易地解决这个问题:
执行 git status 后,它将不再出现在红色内容中:
现在是时候从我们的 暂存区 创建一个 提交 对象了。如上所述,一个 提交 对象引用着一个 树对象,所以我们需要创建一个 树对象。
我们可以用 git write-tree 做这件事,它会在一个 树对象 中记录 索引 的内容。当然,我们可以使用 git cat-file -t 进行确认:
我们还可以用 git cat-file -p 查看它的内容:
太棒了!我们创建了一个 树对象,现在我们需要创建一个引用这个 树对象 的 提交 对象。为此,我们可以使用 git commit-tree <tree-hash> -m <commit message>:
你现在应该对查看对象类型和打印对象内容的命令感到得心应手了:
注意这个 提交 并没有 父节点,因为它是第一个 提交。当我们添加另一个 提交 时,我们就得声明它的 父节点了——我们稍后会做这个。
我们刚得到的哈希值(80e...8f)是一个 提交对象 的哈希值。实际上我们非常习惯使用这些哈希值——我们一直都在看它们。注意这个 提交对象 拥有一个 树对象,树对象有自己的哈希值,不过我们几乎不会显式地指定这个哈希值。
git status 会有所变化吗?
并没有。🤔
为什么呢?git 需要知道最近一次 提交,才能知道文件已经被提交。那么 git 是怎么做的呢?它会去找 HEAD:
HEAD 指向 master,但是 master 是什么呢?我们还没有创建它呢。
如同我们在前面解释的那样,分支只是 提交对象 的命名引用。这时,我们想要让 master 指向哈希值为 80e8ed4fb0bfc3e7ba88ec417ecf2f6e6324998f 的 提交对象。
这实现起来很简单,在 \refs\heads\master 创建一个文件,文件内容为这个哈希值。像这样:
⭐ 总而言之,分支 只是 .git\refs\heads 中的一个文件,文件内容为该分支所指向的 提交对象 的哈希值。
现在,git status 和 git log 终于欣赏我们的付出了:
我们已经成功创建出了一个 提交,全程没有使用上层命令!是不是很酷?🎉
Working With Git Branch
就像我们不借助 git init、git add 或 git commit 创建 仓库 和 提交 一样,我们将要创建 分支,在不同 分支 间来回切换,整个过程也不使用上层命令(git branch 或 git checkout)。
如果你很兴奋,这是完全可以理解的。我也很兴奋 🙂
咱们开始吧:
目前我们只有一个名为 master 的分支。要创建另一个名为 test 的分支(等价于执行 git branch test),我需要在 .git\refs\heads 下创建一个名为 test 的文件,文件的内容应该和 master 分支指向的那个 提交 的哈希值一致。
如果我们使用 git log,就可以看到 master 和 test 确实是指向同一个 提交:
我们也切换到新创建的分支吧(等价于执行 git branch test)。为此,我们需要改变 HEAD 的指向,让它指向我们的新分支:
我们可以看到:git status 和 git log 都确认 HEAD 现在指向的是 test 分支(活动分支)。
我们现在可以使用之前的命令去创建另一个文件,然后将它添加到索引:
我们用上面的命令创建了一个名为 test.txt 的文件,文件内容为 Testing。我们还创建了相应的 blob,将它添加了到 索引。我们还创建了代表这个 索引 的 树对象。
现在是时候创建引用这个 树对象 的 提交 了。这一次,我们还应该声明这个提交的 父提交,也就是之前的那次 提交。我们用 git commit-tree 命令的 -p 开关声明父节点:
可以看到,我们刚刚创建了一个 提交,还有它的 树对象 和父节点:
git log 会展示我们的新 提交 吗?
可以看到:git log 并没有展示任何新的东西。为什么呢?🤔 还记得 git log 会跟踪 分支 ,查找要展示的相关提交吗?它现在给我们展示了 test 和它指向的那个 提交,还展示了指向同一个提交的 master。
没错,我们需要让 test 指向我们的新 提交。我们只需要稍微改变一下 .git\refs\heads\test 的内容:
成功了! 🎉🥂
git log 找到 HEAD,HEAD 告诉它去 test 分支,test 分支指向着 提交 465...5e,这个提交又链接到它的父 提交 80e...8f。
尽情欣赏美吧,we git you。 😊
CONCLUSION
本文向你介绍了 git 的内部原理,我们一开始讲了基本对象——blob、树对象 和 提交对象。
我们了解到 blob 持有文件的内容,树对象 是一个包含 blob 对象 和 子树对象 的目录列表,提交对象 是工作目录的一个快照,包含了一些像时间或提交信息这样的元数据。
我们接着讨论了 分支,它们不过是 提交对象 的命名引用。
我们继续描述了 工作目录,它是一个目录,有着相应的仓库。暂存区(索引) 为下一个 提交对象 持有对应的 树对象,而仓库就是一个 提交对象 的集合。
我们阐明了这些术语与 git init、git add 和 git commit 之间的关系,我们用这几条著名的命令创建新仓库、提交文件。
然后,我们大胆地深入 git 内部,停止使用上层命令,转而使用底层命令。
借助 echo 和 git bash-object 这类的底层命令,我们创建了 blob,把它添加到 索引,创建了 索引 的 树对象,以及指向这个 树对象 的 提交对象。
我们还创建了 分支,在 分支 间来回切换。为你们中那些亲身尝试这个过程的人鼓个掌!👏
希望你在跟着本文操作一遍之后,对使用 git 过程中背后发生的事情有了更深入的理解。