第 8 章 揭开面纱

我们揭开Git神秘面纱,往里瞧瞧它是如何创造奇迹的。我会跳过细节,若要更深入的了解Git 工作原理,可参见 用 户手册

大象无形

Git怎么这么谦逊寡言呢?除了偶尔提交和合并外,你可以如常工作,就像不知道版本控 制系统存在一样。那就是,直到你需要它,并且感到时间合适的时候以外,Git都只是默 默在后台看顾着你。

其他版本控制系统强迫你与繁文缛节和官僚主义不断斗争。文件的权限可能是只读的, 除非你明确地告诉中心服务器哪些文件你打算编辑。即使最基本的命令,随着用户数目 的增多,也会慢的像爬一样。中心服务器可能正跟踪什么人,什么时候check out了什么 代码。当网络连接断了的时候,你就遭殃了。开发人员不断地与这些版本控制系统的种 种限制作斗争。一旦网络或中心服务器瘫痪,工作就嘎然而止。

与之相反,Git简单地在你工作目录下的`.git`目录保存你项目的历史。这是你自己的历 史拷贝,因此你可以保持离线,直到你想和他人沟通为止。你拥有你的文件命运完全的 控制权,因为Git可以轻易在任何时候从`.git`重建一个曾经保存过的状态。

数据完整性

很多人把加密和保持信息机密关联起来,但一个同等重要的目标是保证信息安全。合理 使用哈希加密功能可以防止无意或有意的数据损坏行为。

一个SHA1哈希值可被认为是一个唯一的160位ID数,用它可以唯一标识你一生中遇到的每 个字节串。 实际上不止如此:每个字节串可供任何人用好多辈子。

对一个文件而言,其整体内容的哈希值可以被看作这个文件的唯一标识ID数。

因为一个SHA1哈希值本身也是一个字节串,我们可以哈希包括其他哈希值的字节串。这 个简单的观察出奇地有用:查看“哈希链”。我们之后会看Git如何利用这一点来高效地 保证数据完整性。

简言之,Git把数据保存在`.git/objects`子目录,那里看不到正常文件名,相反你只 看到ID。通过用ID作为文件名,加上一些文件锁和时间戳技巧,Git把任意一个原始的文 件系统转化为一个高效而稳定的数据库。

智能

Git是如何知道你重命名了一个文件,即使你从来没有明确提及这个事实?当然,你或许 是运行了 git mv ,但这个命令和 git add 紧随 git rm 是完全一样的。

Git启发式地找出相连版本之间的重命名和拷贝。实际上,它能检测文件之间代码块的移 动或拷贝!尽管它不能覆盖所有的情况,但它已经做的很好了,并且这个功能也总在改 进中。如果它在你那儿不工作的话,可以尝试打开开销更高的拷贝检测选项,并考虑升 级。

索引

对每个加入库中管理的文件,Git都会在一个名为“index”的文件里记录统计信息,诸如 大小,创建时间和最后修改时间。为了确定文件是否被更改,Git会将当前统计信息同那 些在索引里的统计信息对比。如果一致,那Git就跳过该文件。

因为统计信息的调用比读文件内容快的很多,如果你仅仅编辑了少数几个文件,Git几乎 不需要什么时间就能更新他们的统计信息。

我们前面讲过索引是一个中转区。为什么一堆文件的统计数据是一个中转区?因为添加 命令将文件放到Git的数据库并更新它们的统计信息,而无参数的提交命令将只基于统计 信息和已经在数据库里的文件来创建一个全新的提交。

Git的源起

这个 Linux内核邮件列表帖子 描述了导致 Git诞生的一系列事件。对Git史学家而言,整个讨论线是一个令人着迷的历史探究过程。

对象数据库

你数据的每个版本都保存在“对象数据库”里,其位于子目录`.git/objects`内;其他位 于`.git/`的较少数据:索引,分支名,标签,配置选项,日志,头提交的当前位置等。 对象数据库朴素而优雅,是Git的力量之源。

`.git/objects`里的每个文件是一个对象。有3种对象跟我们有关:“blob”对象, “tree”对象,和“commit”对象。

Blob对象

首先来一个小把戏。选择一个文件名,任意文件名。在一个空目录:

$ echo sweet > YOUR_FILENAME
$ git init
$ git add .
$ find .git/objects -type f

你将看到 .git/objects/aa/823728ea7d592acc69b36875a482cdf3fd5c8d

我如何在不知道文件名的情况下知道这个?这是因为以下内容的SHA1哈希值:

"blob" SP "6" NUL "sweet" LF

是 aa823728ea7d592acc69b36875a482cdf3fd5c8d,这里SP是一个空格,NUL是一 个0字节,LF是一个换行符。你可以验证这一点,键入:

$ printf "blob 6\000sweet\n" | sha1sum

Git基于“内容寻址”:文件并不按它们的文件名存储,而是按它们包含内容的哈希值, 在一个叫“blob对象”的文件里。我们可以把文件内容的哈希值看作一个唯一ID,这样 在某种意义上我们通过他们内容放置文件。开始的“blob 6”只是一个包含对象类型与 其长度的头;它简化了内部存储。

这样我可以轻易预言你所看到的输出:文件名是无关的:只有里面的内容被用作构 建blob对象。

你可能想知道对相同的文件会发生什么。试图填加一个你文件的拷贝,什么文件名都行。 在 .git/objects 的内容保持不变,不管你加了多少。Git都只存储一次数据。

顺便说一句,在 .git/objects 里的文件用zlib压缩,因此你不应该直接查看他们。 可以通过http://www.zlib.net/zpipe.c[zpipe -d] 管道, 或者键入:

$ git cat-file -p aa823728ea7d592acc69b36875a482cdf3fd5c8d

这样可以漂亮地打印出给定的对象。注意,上面的cat-file命令中,aa是目录名。

Tree对象

但文件名在哪?它们必定在某个阶段保存在某个地方。Git在提交时得到文件名:

$ git commit  # 输入一些信息。
$ find .git/objects -type f

你应看到3个对象。这次我不能告诉你这两个新文件是什么,因为它部分依赖你选择的文 件名。我继续进行,假设你选了`‘rose’'。如果你没有,你可以重写历史以让它看起来 像似你做了:

$ git filter-branch --tree-filter 'mv YOUR_FILENAME rose'
$ find .git/objects -type f

现在你应看到文件 .git/objects/05/b217bb859794d08bb9e4f7f04cbda4b207fbe9 ,因 为这是以下内容的SHA1哈希值:

"tree" SP "32" NUL "100644 rose" NUL 0xaa823728ea7d592acc69b36875a482cdf3fd5c8d

通过键入以下命令来检查这个文件真的包含上面内容:

$ echo 05b217bb859794d08bb9e4f7f04cbda4b207fbe9 | git cat-file --batch

使用zpipe,验证哈希值是容易的:

$ zpipe -d < .git/objects/05/b217bb859794d08bb9e4f7f04cbda4b207fbe9 | sha1sum

与查看文件相比,哈希值验证更轻巧一些,因为其输出不包含原始未压缩文件。

这里的输出是一个“tree”对象:一组包含文件类型,文件名和哈希值的数据。在我们的例 子里,文件类型是100644,这意味着“rose”是一个一般文件,并且哈希值指blob对象, 包含“rose”的内容。其他可能文件类型有可执行,链接或者目录。在最后一个例子里, 哈希值指向一个tree对象。

在一些过渡性的分支,你会有一些你不再需要的老的对象,尽管在宽限过期之后,它们 会被自动清除,现在我们还是将其删除,以使我们比较容易跟上这个示范的例子。

$ rm -r .git/refs/original
$ git reflog expire --expire=now --all
$ git prune

在真实项目里你通常应该避免像这样的命令,因为你在破坏备份。如果你期望一个干净 的仓库,通常最好做一个新的克隆。还有,直接操作 .git 时一定要小心:如果 Git命令同时也在运行会怎样,或者突然停电?一般,引用应由 git update-ref -d 删除,尽管通常手工删除 refs/original 也是安全的。

Commit对象

我们已经解释了三个对象中的两个。第三个是“commit”对象。其内容依赖于提交信息 以及其创建的日期和时间。为满足这里我们所需的,我们不得不调整一下:

$ git commit --amend -m Shakespeare  # 改提交信息
$ git filter-branch --env-filter 'export
    GIT_AUTHOR_DATE="Fri 13 Feb 2009 15:31:30 -0800"
    GIT_AUTHOR_NAME="Alice"
    GIT_AUTHOR_EMAIL="alice@example.com"
    GIT_COMMITTER_DATE="Fri, 13 Feb 2009 15:31:30 -0800"
    GIT_COMMITTER_NAME="Bob"
    GIT_COMMITTER_EMAIL="bob@example.com"'  # Rig timestamps and authors.
$ find .git/objects -type f

你现在应看到 .git/objects/49/993fe130c4b3bf24857a15d7969c396b7bc187 是下列 内容的SHA1哈希值:

"commit 158" NUL
"tree 05b217bb859794d08bb9e4f7f04cbda4b207fbe9" LF
"author Alice <alice@example.com> 1234567890 -0800" LF
"committer Bob <bob@example.com> 1234567890 -0800" LF
LF
"Shakespeare" LF

和前面一样,你可以运行zpipe或者cat-file来自己看。

这是第一个提交,因此没有父提交,但之后的提交将总有至少一行,指定一个父提交。

没那么神

Git的秘密似乎太简单。看起来似乎你可以整合几个shell脚本,加几行C代码来弄起来, 也就几个小时的事:一个基本文件操作和SHA1哈希化的混杂,用锁文件装饰一下,文件 同步保证健壮性。实际上,这准确描述了Git的最早期版本。尽管如此,除了巧妙地打包 以节省空间,巧妙地索引以省时间,我们现在知道Git如何灵巧地改造文件系统,使其成 为一个完美的版本控制数据库。

例如,如果对象数据库里的任何一个文件由于硬盘错误损毁,那么其哈希值将不再匹配, 这个错误会报告给我们。通过哈希化其他对象的哈希值,我们在所有层面维护数据完整 性。Commit对象是原子性的,也就是说,一个提交永远不会部分地记录变更:在我们已经 存储所有关于tree对象,blob对象和父commit对象之后,我们才可以计算提交的的哈希 值并将其存储在数据库,对象数据库不受诸如停电之类的意外中断影响。

我们打败了即使是最狡猾的对手。假设有人试图悄悄修改一个项目里一个远古版本文件的 内容,为使对象据库看起来健康,他们也必须修改相应blob对象的哈希值,既然它现在 是一个不同的字节串。这意味着他们将不得不引用这个文件的tree对象的哈希值,并反 过来改变所有与这个tree相关的commit对象的哈希值,还要加上这些提交所有后裔的哈 希值。这暗示官方head的哈希值与这个坏仓库不同。通过跟踪不匹配哈希值线索,我 们可以查明残缺文件,以及第一个被破坏的提交。

总之,只要20个字节代表最后一次的提交是安全的,我们就将不可能篡改一个Git仓库。

那么Git的著名功能怎样实现的呢?分支?合并?标签?这些都是单纯的细节。当前head保 存在文件+.git /HEAD+ ,其中包含了一个commit对象的哈希值。该哈希值在运行提交 以及其他命令时更新。分支几乎一样:它们是保存在 .git/refs/heads 的文件。标签 也是:它们住在 .git/refs/tags ,但它们由一套不同的命令更新。