底层命令之美-如何创建git仓库并提交?

175 阅读12分钟

区分 底层(plumbing)上层(poreclain) 两类 git 命令会对你很有帮助。这两个术语的应用奇怪地来自于马桶(没错,就是🚽)。马桶通常是用陶瓷(porcelain)做的,它的基本结构是管道(plumbing,上水道和下水道)。

我们可以说上层命令为底层命令提供了一个用户友好的接口。大多数人只会涉及到上层命令。然而,当事情变得(非常)糟糕时,有人可能就会想知道为什么,他们会卷起袖子去检查底层命令。(注意:这些术语并不是我发明的,它们在 git 中的使用非常广泛)。

译者注:读者若想更好的理解这两个术语,建议阅读 Git 内部原理 - 底层命令与上层命令

git 使用这些术语进行类比,从而将用户不常使用的底层命令(plumbing)和那些更友好的高层(porcelain)命令区分开。

目前,我们已经接触过上层命令——git initgit addgit 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 initgit addgit commit 之后发生的什么事情了。

ADVANCED

为了深入理解 git 是如何工作的,我们将从零开始创建一个 仓库

我们不会使用 git initgit addgit commit,这会让我们更好地理解这个过程。

How To Set Up .GIT Folder

先创建一个新目录,然后在里面运行 git status

好吧,因为我们没有 .git 文件夹,git 好像不怎么高兴。我们先把这个目录创建出来:

很明显,只创建一个 .git 目录还不够。我们需要往这个目录添加一些东西。

一个 git 仓库有两个主要组成部分:

  1. 一组对象——blob树对象提交对象
  2. 一个命名这些对象的方式——称为 引用

译者注:引用是 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 addgit 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 对象写入对象数据库

我们现在可以看到,这个 blob 的哈希值为 54f6...36.git\objects 下也多出来了一个名为 54 的目录,目录内有一个名为 f6..36 的文件。

所以,git 实际上是使用 SHA-1 哈希值的前两个字符作为目录的名字,剩余字符用作 blob 所在文件的文件名。

为什么要这样呢?考虑一个非常大的仓库,仓库的数据库内存有三十万个对象(blob 对象树对象提交对象)。从这三十万个哈希值中找出一个值会花些时间,因此,git 将这个问题划分成了 256 份。

为了查找上面的那个哈希值,git 会先寻找 .git\objects 目录下名为 54 的目录,然后搜索那个目录,这进一步缩小了搜索范围。.git\objects 目录下最多可能会有 256 个子目录(从 00FF)。

回到生成 提交对象 的过程中来,现在我们已经创建了一个对象,它的类型是什么呢?我们可以通过另一个底层命令 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 awesomeblob 添加到对象数据库中的时候,我们告诉 索引 ,那个 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 statusgit log 终于欣赏我们的付出了:

我们已经成功创建出了一个 提交,全程没有使用上层命令!是不是很酷?🎉

Working With Git Branch

就像我们不借助 git initgit addgit commit 创建 仓库提交 一样,我们将要创建 分支,在不同 分支 间来回切换,整个过程也不使用上层命令(git branchgit checkout)。

如果你很兴奋,这是完全可以理解的。我也很兴奋 🙂

咱们开始吧:

目前我们只有一个名为 master 的分支。要创建另一个名为 test 的分支(等价于执行 git branch test),我需要在 .git\refs\heads 下创建一个名为 test 的文件,文件的内容应该和 master 分支指向的那个 提交 的哈希值一致。

如果我们使用 git log,就可以看到 mastertest 确实是指向同一个 提交

我们也切换到新创建的分支吧(等价于执行 git branch test)。为此,我们需要改变 HEAD 的指向,让它指向我们的新分支:

通过修改  切换到  分支

我们可以看到:git statusgit 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 找到 HEADHEAD 告诉它去 test 分支,test 分支指向着 提交 465...5e,这个提交又链接到它的父 提交 80e...8f

尽情欣赏美吧,we git you。 😊

CONCLUSION

本文向你介绍了 git 的内部原理,我们一开始讲了基本对象——blob树对象 提交对象

我们了解到 blob 持有文件的内容,树对象 是一个包含 blob 对象子树对象 的目录列表,提交对象 是工作目录的一个快照,包含了一些像时间或提交信息这样的元数据。

我们接着讨论了 分支,它们不过是 提交对象 的命名引用。

我们继续描述了 工作目录,它是一个目录,有着相应的仓库。暂存区(索引) 为下一个 提交对象 持有对应的 树对象,而仓库就是一个 提交对象 的集合。

我们阐明了这些术语与 git initgit addgit commit 之间的关系,我们用这几条著名的命令创建新仓库、提交文件。

然后,我们大胆地深入 git 内部,停止使用上层命令,转而使用底层命令。

借助 echogit bash-object 这类的底层命令,我们创建了 blob,把它添加到 索引,创建了 索引树对象,以及指向这个 树对象提交对象

我们还创建了 分支,在 分支 间来回切换。为你们中那些亲身尝试这个过程的人鼓个掌!👏

希望你在跟着本文操作一遍之后,对使用 git 过程中背后发生的事情有了更深入的理解。