Git 教程(二) git 的基本构成及原理
本教程描述 Git 架构的两个基础部分——对象数据库和索引文件。本文将为读者理解 Git 其余文档奠定基础。
1、Git 对象数据库
我们从一个实例开始。创建一个新项目和一些修改历史形成不同版本:
$ mkdir test-project
$ cd test-project
$ git init
Initialized empty Git repository in .git/
$ echo 'hello world' > file.txt
$ git add .
$ git commit -a -m "initial commit"
[master (root-commit) 54196cc] initial commit
1 file changed, 1 insertion(+)
create mode 100644 file.txt
$ echo 'hello world!' >file.txt
$ git commit -a -m "add emphasis"
[master c4d59f3] add emphasis
1 file changed, 1 insertion(+), 1 deletion(-)
上述命令中 Git 回显的 7 位十六进制数是什么呢?
回忆一下我们在教程一中见过的变更提交 commits 有类似的名称,那里指出 Git 的历史记录库中存储的所有对象均以 40 位十六进制数命名。实际上,这个名称是所存储对象内容的 SHA-1 散列值;通过以散列值命名这种机制,确保 Git 永远不会吧相同的内容保存两次(因为 SHA-1 散列算法保证了同样的内容均产生同样的 SHA-1 名称),而且这也确保某个名称所对应的 Git 对象不会被改变(因为内容一变,则名称也会跟着变)。这里的 7 位数是 40 位十六进制数的缩略写法(取了前面若干位),只要不导致混淆,7 位数和 40 位数一样可以用来标识对象。这就是 Git所玩的魔术。
你运行上述示例时会生成不同名字的对象,原因是变更提交(commit)对象中还同时记录了变更提交的时间和执行变更提交的用户信息。
可以通过 cat-file 命令来向 git 询问关于特定对象的信息,注意下面的例子中应该使用你自己产生的名称而不是照抄上面示例中名称,你可以视情使用略写形式名称(不用把 40 个数字全写出来)。
$ git cat-file -t 54196cc # 查询对象类型
commit
$ git cat-file commit 54196cc2 # 查看对象内容
tree 92b8b694ffb1675e5975148e1121810081dbdffe
author J. Bruce Fields <bfields@puzzle.fieldses.org> 1143414668 -0500
committer J. Bruce Fields <bfields@puzzle.fieldses.org> 1143414668 -0500
initial commit
tree 对象可指向(包含)一个或多个 blob 对象,每一个 blob 对象对应一个文件。此外,一个 tree 对象还可以指向(包含)其他 tree 对象,这样便构成了树状的目录层级。通过 ls-tree 命令来检查其中的内容:
$ git ls-tree 92b8b694
100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad file.txt
可以看到这个 tree 中有一个文件,同样也有一个 SHA-1 散列值名称指向该文件的数据
$ git cat-file -t 3b18e512
blob
查看这个 SHA-1 散列值名称的类型,可以获知它是个 blob 对象,一个 blob 对象就是一个文件的数据,可以通过 cat-file 命令查看其中的内容:
$ git cat-file blob 3b18e512
hello world
注意到这个文件是旧版本,可以看出 Git 在其中记录的内容是首次提交后项目目录状态的快照。
Git 中所有对象均用它们的 SHA-1 散列值作为名称保存在 Git 目录下。
$ find .git/objects/
.git/objects/
.git/objects/pack
.git/objects/info
.git/objects/3b
.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad
.git/objects/92
.git/objects/92/b8b694ffb1675e5975148e1121810081dbdffe
.git/objects/54
.git/objects/54/196cc2703dc165cbd373a65a4dcf22d50ae7f7
.git/objects/a0
.git/objects/a0/423896973644771497bdc03eb99d5281615b51
.git/objects/d0
.git/objects/d0/492b368b66bdabf2ac1fd8c92b39d3db916e59
.git/objects/c4
.git/objects/c4/d59f390b9cfd4318117afde11d601c1085f241
其中每一个文件其实都是压缩过的文件加一个文件头信息组成的,其中明确了它们各自的长度及类型。对象类型包括 blob,tree,commit 或 tag。
最好找的变更提交对象(commit)是 HEAD,它在 .git/HEAD 处:
$ cat .git/HEAD
ref: refs/heads/master
正如你所见,它指出了用户当前使用的分支,它告诉我们的是一个文件的位置,该文件包含了一个指向 commit 对象的 SHA-1 名称,可以用 cat-file 命令查看这个 commit 对象的内容。
$ cat .git/refs/heads/master
c4d59f390b9cfd4318117afde11d601c1085f241
$ git cat-file -t c4d59f39
commit
$ git cat-file commit c4d59f39
tree d0492b368b66bdabf2ac1fd8c92b39d3db916e59
parent 54196cc2703dc165cbd373a65a4dcf22d50ae7f7
author J. Bruce Fields <bfields@puzzle.fieldses.org> 1143418702 -0500
committer J. Bruce Fields <bfields@puzzle.fieldses.org> 1143418702 -0500
add emphasis
这个地方的 tree 对象给出了该树状目录结构的最新状态:
$ git ls-tree d0492b36
100644 blob a0423896973644771497bdc03eb99d5281615b51 file.txt
$ git cat-file blob a0423896
hello world!
而这里的 parent 对象(实际是个 commit 对象)给出了记录前一版本变更提交(commit)对象的名称:
$ git cat-file commit 54196cc2
tree 92b8b694ffb1675e5975148e1121810081dbdffe
author J. Bruce Fields <bfields@puzzle.fieldses.org> 1143414668 -0500
committer J. Bruce Fields <bfields@puzzle.fieldses.org> 1143414668 -0500
initial commit
这个 tree 对象就是我们在前面讨论部分见过的那个 tree 对象,它保存了初次提交时的目录状态,比较特殊,没有 parent 对象。
大多数的变更提交对象(commit)都只有一个 parent,不过拥有多个 parent 的 commit 对象也十分常见。这种情况下,该 commit 意味着进行过一次合并操作(merge),它因而同时将合并前的各个分支的末端作为 parent 对象了。
除了 blob、tree 和 commit 对象外,还剩下一种 tag 对象,这里不做讨论,详情请参考 git-tag 的 map page 页面。
到现在为止,我们知道了 Git 如何使用对象数据库来表示项目历史记录了:
- commit 对象负责描述不同版本间 tree 对象之间的关联关系,它包含的 tree 对象相当于历史记录中某个时间点项目目录树状结构的快照,同时它的 parent 对象指向前一版本的 commit 对象,从而构建了前后版本 commit 对象之间的继承关系。
- tree 对象负责描述单个目录在某个时间点的状态,把某时间点该目录中文件的 blob 对象和子目录 tree 对象组成集合,用 SHA-1 散列值名称记录下来。
- blob 对象最简单,它仅仅包含某个版本的文件内容,不包含任何其他结构。
- 指向每个分支末端的 commit 对象,保存在 .git/refs/heads/. 中的文件里。
- 当前分支的名称保存在 .git/HEAD 文件中。
注意:很多 Git 命令以 tree 对象作为参数。但是正如我们前面所看到的,一个 tree 对象可以通过多种不同方式指代——通过该 tree 对象 SHA-1 名称;通过指向该 tree 对象的 commit对象的名称;通过其末端指向该 tree 对象的分支的名称;等等。这些命令大部分都能接受以上任意一种名称。在命令语法说明中,有时候会用 tree-ish 来表示这样一个参数。
2、索引文件
我们用来创建 commit 对象的主要工具是 git -commit -a 命令。这个命令创建一个包含了所有你在工作目录下所作变更的 commit 对象。那么你如果只想提交某些特定文件的变更,甚至你只想提交特定文件的特定变更,又该怎么操作呢?
通过深入了解 Git 创建 commit 对象时的幕后工作,我们可以学习到一些比 git -commit -a灵活得多的方法来创建 commit 对象。
继续在前面的 test-project 项目上进行,这里把 file.txt 再进行一次修改:
$ echo "hello world, again" >> file.txt
这次不着急创建 commit 对象(提交变更),我们先来完成一些中间步骤,通过 diff 查看一下历史修订轨迹,看看都做过哪些修改:
$ git diff # 当前工作目录和索引的比较
--- a/file.txt
+++ b/file.txt
@@ -1 +1,2 @@
hello world!
+hello world, again
$ git add file.txt
$ git diff # 当前工作目录和索引的比较
你会发现最后一个 diff 命令将返回空值,可是到目前为止还没有提交 commit 呢,分支的末端依然不包括新增加的那行:
$ git diff HEAD # 当前工作目录和前一版 commit 对象的比较
diff --git a/file.txt b/file.txt
index a042389..513feba 100644
--- a/file.txt
+++ b/file.txt
@@ -1 +1,2 @@
hello world!
+hello world, again # 可以看出 HEAD 里面仍然没有把新加的行合进来
那么我们可以由此推断,git diff (不带 HEAD 参数)肯定与其他什么东西进行了比较。实际上,这个参与比较的东西就是以二进制格式保存在 .git/index 中的索引文件,这个文件的内容可以使用 ls-files 命令查看:
$ git ls-files --stage
100644 513feba2e53ebbd2532419ded848ba19de88ba00 0 file.txt
$ git cat-file -t 513feba2
blob
$ git cat-file blob 513feba2
hello world!
hello world, again
那么由此可以看出 git add 竟在幕后偷偷保存了一个新的 blob 对象,并在索引文件中存储了一个指向这个 blob 对象指针。如果再次修改这个文件,我们将会看到新的更改又反映到 git diff 的输出中了:
$ echo 'again?' >>file.txt
$ git diff # 当前工作目录与索引的比较
index 513feba..ba3da7b 100644
--- a/file.txt
+++ b/file.txt
@@ -1,2 +1,3 @@
hello world!
hello world, again
+again?
给定不同的参数时,git diff 命令能够分别显示当前工作目录内容与最后一次提交的区别,或者是索引内容与最后一次提交的区别:
$ git diff HEAD # 当前工作目录与前一版 commit 对象的比较,提示新增了2行
diff --git a/file.txt b/file.txt
index a042389..ba3da7b 100644
--- a/file.txt
+++ b/file.txt
@@ -1 +1,3 @@
hello world!
+hello world, again
+again?
$ git diff --cached # 索引与前一版 commit 对象的比较,仍然只增加了1行
diff --git a/file.txt b/file.txt
index a042389..513feba 100644
--- a/file.txt
+++ b/file.txt
@@ -1 +1,2 @@
hello world!
+hello world, again
任何时候,我们都可以使用 git commit 来创建一个新的 commit 对象(不使用 -a 选项),可以验证所提交的状态仅仅包括保存在索引文件中的变化,而当前工作目录中那些还没有加入索引的修改不会提交:
$ git commit -m "repeat" # 提交一下 不带 -a 选项
$ git diff HEAD # 查看当前目录与前一版 commit 的区别
diff --git a/file.txt b/file.txt
index 513feba..ba3da7b 100644
--- a/file.txt
+++ b/file.txt
@@ -1,2 +1,3 @@
hello world!
hello world, again
+again? # 最后新增的一行仍然作为区别保留在提交版本之外
由此可见,git commit 默认使用索引文件来创建 commit 对象,而不是当前工作目录;而 -a选项则告诉 git commit 命令首先将当前工作目录中所有的变化更新到所谓文件,然后再执行提交动作。
最后,命令 gid add 对索引文件的影响也值得研究一下:
$ echo "goodbye, world" >closing.txt
$ git add closing.txt
实际上,git add 命令在索引文件中增加了一个新的入口:
$ git ls-files --stage
100644 8b9743b20d4b15be3955fc8d5cd2b09cd2336138 0 closing.txt
100644 513feba2e53ebbd2532419ded848ba19de88ba00 0 file.txt
通过 cat-file 命令可以看到,这个新入口指向了新增文件的当前内容:
$ git cat-file blob 8b9743b2
goodbye, world
Git 的 status 命令是查看此种状况的简要说明的有用办法:
$ git status
On branch master
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)
new file: closing.txt
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working directory)
modified: file.txt
由于 closing.txt 目前已经缓存到索引文件,因此它被列为 『Changes to be committed』;而 file.txt 在工作目录里被修改过但还没有反映到索引文件中,所以被标记为『changed but not updated』。这时候运行 git commit 命令,将会创建一个提交对象,其中包含了对 closing.txt 文件新增内容的修改,但不会改变 file.txt 文件。
同样地, 不带参数的 git diff (表示当前目录与索引文件的区别)将显示对 file.txt 的改变,而不会显示新增加的 closing.txt 文件,因为索引中 closing.txt 文件的版本与当前工作目录中该文件的版本完全一致。
除了作为新 commit 动作的工作范围区域外,在切换到某个分支时,相应的索引文件也将从对象数据库中被迁移出来,并在进行 merge 操作时用于缓存相关的 tree 对象。要获取详细信息,请查阅 gitcore-tutorial(7) 和相关 man pages 页面。
3、后续学习
此时此刻,你已经掌握了阅读任何 git 命令相应 man page 页面所需的基础背景知识。从 giteverydata(7) 中提及的那些命令开始是个不错的选择。你可以从 gitglossary(7) 中获得关于那些不明白的术语的解释。
Git User's Manual[1] 提供了对 Git 更为全面的介绍。
gitcvs-migration(7) 详细解释了如何将一个 CVS 代码库导入到 Git 中,并展示了如何以 CVS-方式 来使用 Git。
关于使用 Git 的一些有趣的范例,可以在 howtos[2] 中找到。
对于 Git 开发者而言,gitcore-tutorial(7) 为深入了解 Git 涉及的底层机制——比如说创建一个新的 commit 对象——提供了一个好的去处。