本节书摘来自异步社区《Git版本控制管理(第2版)》一书中的第4章,第4.3节,作者:【美】Jon Loeliger , Matthew McCullough著,更多章节内容可以访问云栖社区“异步社区”公众号查看
4.3 Git在工作时的概念
带着一些原则,来看看所有这些概念和组件是如何在版本库里结合在一起的。让我们创建一个新的版本库,并更详细地检查内部文件和对象库。
4.3.1 进入.git目录
首先,使用git init来初始化一个空的版本库,然后运行find来看看都创建了什么文件。
$ mkdir /tmp/hello
$ cd /tmp/hello
$ git init
Initialized empty Git repository in /tmp/hello/.git/
# 列出当前目录中的所有文件
$ find .
.
./.git
./.git/hooks
./.git/hooks/commit-msg.sample
./.git/hooks/applypatch-msg.sample
./.git/hooks/pre-applypatch.sample
./.git/hooks/post-commit.sample
./.git/hooks/pre-rebase.sample
./.git/hooks/post-receive.sample
./.git/hooks/prepare-commit-msg.sample
./.git/hooks/post-update.sample
./.git/hooks/pre-commit.sample
./.git/hooks/update.sample
./.git/refs
./.git/refs/heads
./.git/refs/tags
./.git/config
./.git/objects
./.git/objects/pack
./.git/objects/info
./.git/description
./.git/HEAD
./.git/branches
./.git/info
./.git/info/exclude
可以看到,.git目录包含很多内容。这些文件是基于模板目录显示的,根据需要可以进行调整。根据使用的Git的版本,实际列表可能看起来会有一点不同。例如,旧版本的Git不对.git/hooks文件使用.sample后缀。
在一般情况下,不需要查看或者操作.git目录下的文件。认为这些“隐藏”的文件是Git底层(plumbing)或者配置的一部分。Git有一小部分底层命令来处理这些隐藏的文件,但是你很少会用到它们。
最初,除了几个占位符之外,.git/objects目录(用来存放所有Git对象的目录)是空的。
$ find .git/objects
.git/objects
.git/objects/pack
.git/objects/info
现在,让我们来小心地创建一个简单的对象。
$ echo "hello world" > hello.txt
$ git add hello.txt
如果输入的“hello world”跟这里一样(没有改变间距和大小写),那么objects目录应该如下所示:
$ find .git/objects
.git/objects
.git/objects/pack
.git/objects/3b
.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad
.git/objects/info
所有这一切看起来很神秘。其实不然,下面各节会慢慢解释原因。
4.3.2 对象、散列和blob
当为hello.txt创建一个对象的时候,Git并不关心hello.txt的文件名。Git只关心文件里面的内容:表示“hello world”的12个字节和换行符(跟之前创建的blob一样)。Git对这个blob执行一些操作,计算它的SHA1散列值,把散列值的十六进制表示作为文件名它放进对象库中。
如何知道一个SHA1散列值是唯一的?
两个不同blob产生相同SHA1散列值的机会十分渺茫。当这种情况发生的时候,称为一次碰撞。然而,一次SHA1碰撞的可能性太低,你可以放心地认为它不会干扰我们对Git的使用。
SHA1是“安全散列加密”算法。直到现在,没有任何已知的方法(除了运气之外)可以让一个用户刻意造成一次碰撞。但是碰撞会随机发生吗?让我们来看看。
对于160位数,你有2160或者大约1048(1后面跟48个0)种可能的SHA1散列值。这个数是极其巨大的。即使你雇用一万亿人来每秒产生一万亿个新的唯一blob对象,持续一万亿年,你也只有1043个blob对象。
如果你散列了280个随机blob,可能会发生一次碰撞。
不相信我们的话,就去读读Bruce Schneier的书吧②。
在这种情况下散列值是3b18e512dba79e4c8300dd08aeb37f8e728b8dad。160位的SHA1散列值对应20个字节,这需要40个字节的十六进制来显示,因此这内容另存为.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad。Git在前两个数字后面插入一个“/”以提高文件系统效率(如果你把太多的文件放在同一个目录中,一些文件系统会变慢;使SHA1的第一个字节成为一个目录是一个很简单的办法,可以为所有均匀分布的可能对象创建一个固定的、256路分区的命名空间)。
为了展示Git真的没有对文件的内容做很多事情(它还是同样的内容“hello world”),可以在任何时间使用散列值把它从对象库里提取出来。
$ git cat-file -p 3b18e512dba79e4c8300dd08aeb37f8e728b8dad
hello world
Git也知道手动输入40个字符是很不切实际的,因此它提供了一个命令通过对象的唯一前缀来查找对象的散列值。
$ git rev-parse 3b18e512d
3b18e512dba79e4c8300dd08aeb37f8e728b8dad
4.3.3 文件和树
既然“hello world”那个blob已经安置在对象库里了,那么它的文件名又发生了什么事呢?如果不能通过文件名找到文件Git就太没用了。
正如前面提到的,Git通过另一种叫做目录树(tree)的对象来跟踪文件的路径名。当使用git add命令时,Git会给添加的每个文件的内容创建一个对象,但它并不会马上为树创建一个对象。相反,索引更新了。索引位于.git/index中,它跟踪文件的路径名和相应的blob。每次执行命令(比如,git add、git rm或者git mv)的时候,Git会用新的路径名和blob信息来更新索引。
任何时候,都可以从当前索引创建一个树对象,只要通过底层的git write-tree命令来捕获索引当前信息的快照就可以了。
目前,该索引只包含一个文件,hello.txt.
$ git ls-files -s
100644 3b18e512dba79e4c8300dd08aeb37f8e728b8dad 0 hello.txt
在这里你可以看到文件的关联,hello.txt与3b18e4...的blob。
接下来,让我们捕获索引状态并把它保存到一个树对象里。
$ git write-tree
68aba62e560c0ebc3396e8ae9335232cd93a3f60
$ find .git/objects
.git/objects
.git/objects/68
.git/objects/68/aba62e560c0ebc3396e8ae9335232cd93a3f60
.git/objects/pack
.git/objects/3b
.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad
.git/objects/info
现在有两个对象:3b18e5的“hello world”对象和一个新的68aba6树对象。可以看到,SHA1对象名完全对应.git/objects下的子目录和文件名。
但是树是什么样子的呢?因为它是一个对象,就像blob一样,所以可以用底层命令来查看它。
$ git cat-file -p 68aba6
100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad hello.txt
对象的内容应该很容易解释。第一个数100644,是对象的文件属性的八进制表示,用过UNIX的chmod命令的人应该对这个很熟悉了。这里,3b18e5是hello world的blob的对象名,hello.txt是与该blob关联的名字。
当执行git ls-file -s的时候,很容易就可以看到树对象已经捕获了索引中的信息。
4.3.4 对Git使用SHA1的一点说明
在更详细地讲解树对象的内容之前,让我们先来看看SHA1散列的一个重要特性。
$ git write-tree
68aba62e560c0ebc3396e8ae9335232cd93a3f60
$ git write-tree
68aba62e560c0ebc3396e8ae9335232cd93a3f60
$ git write-tree
68aba62e560c0ebc3396e8ae9335232cd93a3f60
每次对相同的索引计算一个树对象,它们的SHA1散列值仍是完全一样的。Git并不需要重新创建一个新的树对象。如果你在计算机前按照这些步骤操作,你应该看到完全一样的SHA1散列值,跟本书所刊印的一样。
这样看来,散列函数在数学意义上是一个真正的函数:对于一个给定的输入,它总产生相同的输出。这样的散列函数有时也称为摘要,用来强调它就像散列对象的摘要一样。当然,任何散列函数(即使是低级的奇偶校验位)也有这个属性。
这是非常重要的。例如,如果你创建了跟其他开发人员相同的内容,无论你俩在何时何地工作,相同的散列值就足以证明全部内容是一致的。事实上,Git确实将它们视为一致的。
但是等一下——SHA1散列是唯一的吗?难道万亿人每秒产生的万亿个blob永远不会产生一次碰撞吗?这在Git新手中是一个常见的疑惑。因此,请仔细阅读,因为如果你能理解这种区别,那么本章的其他内容就很简单了。
在这种情况下,相同的SHA1散列值并不算碰撞。只有两个不同的对象产生一个相同的散列值时才算碰撞。在这里,你创建了相同内容的两个单独实例,相同的内容始终有相同的散列值。
Git依赖于SHA1散列函数的另一个后果:你是如何得到称为68aba62e560c0ebc3396 e8ae9335232cd93a3f60的树的并不重要。如果你得到了它,你就可以非常有信心地说,它跟本书的另一个读者的树对象是一样的。Bob通过合并Jennie的提交A、提交B和Sergey的提交C来创建这个树,而你是从Sue得到提交A,然后从Lakshmi那里更新提交B和提交C的合并。结果都是一样的,这有利于分布式开发。
如果要求你查看对象68aba62e560c0ebc3396e8ae9335232cd93a3f60,并且你能找到这样的一个对象,同时因为SHA1是一个加密散列算法,因此你就可以确信你找的对象跟散列创建时的那个对象的数据是相同的。
反过来也是如此:如果你在你的对象库里没找到具有特定散列值的对象,那么你就可以肯定你没有持有那个对象的副本。总之,你可以判断你的对象库是否有一个特定的的对象,即使你对它(可能非常大)的内容一无所知。因此,散列就好似对象的可靠标签或名称。
但是Git也依赖于比那个结论更强的东西。考虑最近的一次提交(或者它关联的树对象)。因为它包含其父提交以及树的散列,反过来又通过递归整个数据结构包含其所有子树和blob的散列,因此可归结为它通过原始提交的散列值唯一标识整个数据结构在提交时的状态。
最后,我们在上一段中的声明可以推出散列函数的强大应用:它提供了一种有效的方法来比较两个对象,甚至是两个非常大而复杂的数据结构③,而且并不需要完全传输。
4.3.5 树层次结构
只有单个文件的信息是很好管理的,就像上一节所讲的一样,但项目包含复杂而且深层嵌套的目录结构,并且会随着时间的推移而重构和移动。通过创建一个新的子目录,该目录包含hello.txt的一个完全相同的副本,让我们看看Git是如何处理这个问题的。
$ pwd
/tmp/hello
$ mkdir subdir
$ cp hello.txt subdir/
$ git add subdir/hello.txt
$ git write-tree
492413269336d21fac079d4a4672e55d5d2147ac
$ git cat-file -p 4924132693
100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad hello.txt
040000 tree 68aba62e560c0ebc3396e8ae9335232cd93a3f60 subdir
新的*树包含两个条目:原始的hello.txt以及新的 子目录 ,子目录是 树 而不是blob。
注意到不寻常之处了吗?仔细看subdir的对象名。是你的老朋友,68aba62e560c0 ebc3396e8ae9335232cd93a3f60!
刚刚发生了什么?subdir的新树只包含一个文件hello.txt,该文件跟旧的“hello world”内容相同。所以subdir树跟以前的*树是完全一样的!当然它就有跟之前一样的SHA1对象名了。
让我们来看看.git/objects目录,看看最近的更改有哪些影响。
$ find .git/objects
.git/objects
.git/objects/49
.git/objects/49/2413269336d21fac079d4a4672e55d5d2147ac
.git/objects/68
.git/objects/68/aba62e560c0ebc3396e8ae9335232cd93a3f60
.git/objects/pack
.git/objects/3b
.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad
.git/objects/info
这只有三个唯一的对象:一个包含“hello world”的blob;一棵包含hello.txt的树,文件里是“hello world”加一个换行;还有第一棵树旁边包含hello.txt的另一个索引的另一棵树。
4.3.6 提交
讨论的下一主题是提交(commit)。现在hello.txt已经通过git add命令添加了,树对象也通过git write-tree命令生成了,可以像这样用底层命令那样创建提交对象。
$ echo -n "Commit a file that says hello\n" \
| git commit-tree 492413269336d21fac079d4a4672e55d5d2147ac
3ede4622cc241bcb09683af36360e7413b9ddf6c
结果如下所示。
$ git cat-file -p 3ede462
tree 492413269336d21fac079d4a4672e55d5d2147ac
author Jon Loeliger <jdl@example.com> 1220233277 -0500
committer Jon Loeliger <jdl@example.com> 1220233277 -0500
Commit a file that says hello
如果你在计算机上按步骤操作,你可能会发现你生成的提交对象跟书上的名字不一样。如果你已经理解了目前为止的一切内容,那原因就很明显了:这是不同的提交。提交包含你的名字和创建提交的时间,尽管这区别很微小,但依然是不同的。另一方面,你的提交确实有相同的树。这就是提交对象与它们的树对象分开的原因:不同的提交经常指向同一棵树。当这种情况发生时,Git能足够聪明地只传输新的提交对象,这是非常小的,而不是很可能很大的树和blob对象。
在实际生活中,你可以(并且应该)跳过底层的git write-tree和git commit-tree步骤,并只使用git commit命令。成为一个完全快乐的Git用户,你不需要记住那些底层命令。
一个基本的提交对象是相当简单的,这是成为一个真正的RCS需要的最后组成部分。提交对象可能是最简单的一个,包含:
标识关联文件的树对象的名称;
创作新版本的人(作者)的名字和创作的时间;
把新版本放到版本库的人(提交者)的名字和提交的时间;
对本次修订原因的说明(提交消息)。
默认情况下,作者和提交者是同一个人,也有一些情况下,他们是不同的。
可以使用git show --pretty=fuller命令来查看给定提交的其他细节。
尽管提交对象跟树对象用的结构是完全不同的,但是它也存储在图结构中。当你做一个新提交时,你可以给它一个或多个父提交。通过继承链来回溯,可以查看项目历史。第6章会给出关于提交和提交图的更详细描述。
4.3.7 标签
最后,Git还管理的一个对象就是标签。尽管Git只实现了一种标签对象,但是有两种基本的标签类型,通常称为轻量级的(lightweight)和带附注的(annotated)。
轻量级标签只是一个提交对象的引用,通常被版本库视为是私有的。这些标签并不在版本库里创建永久对象。带标注的标签则更加充实,并且会创建一个对象。它包含你提供的一条消息,并且可以根据RFC 4880来使用GnuPG密钥进行数字签名。
Git在命名一个提交的时候对轻量级的标签和带标注的标签同等对待。不过,默认情况下,很多Git命令只对带标注的标签起作用,因为它们被认为是“永久”的对象。
可以通过git tag命令来创建一个带有提交信息、带附注且未签名的标签:
$ git tag -m "Tag version 1.0" V1.0 3ede462
可以通过git cat-file -p命令来查看标签对象,但是标签对象的SHA1值是什么呢?为了找到它,使用4.3.2节的提示。
$ git rev-parse V1.0
6b608c1093943939ae78348117dd18b1ba151c6a
$ git cat-file -p 6b608c
object 3ede4622cc241bcb09683af36360e7413b9ddf6c
type commit
tag V1.0
tagger Jon Loeliger <jdl@example.com> Sun Oct 26 17:07:15 2008 -0500
Tag version 1.0
除了日志消息和作者信息之外,标签指向提交对象3ede462。通常情况下,Git通过某些分支来给特定的提交命名标签。请注意,这种行为跟其他VCS有明显的不同。
Git通常给指向树对象的提交对象打标签,这个树对象包含版本库中文件和目录的整个层次结构的总状态。
回想一下图4-1,V1.0标签指向提交1492——依次指向跨越多个文件的树(8675309)。因此,这个标签同时适用于该树的所有文件。
这跟CVS不同,例如,对每个单独的文件应用标签,然后依赖所有打过标签的文件来重建一个完整的标记修订。并且CVS允许你移动单独文件的标签,而Git则需要在标签移动到的地方做一个新的提交,囊括该文件的状态变化。