Git详解之九 Git内部原理
2014-08-16 12:26
232 查看
本文转自:《Git详解之九Git内部原理》
不管你是从前面的章节直接跳到了本章,还是读完了其余各章一直到这,你都将在本章见识Git的内部工作原理和实现方式。我个人发现学习这些内容对于理解Git的用处和强大是非常重要的,不过也有人认为这些内容对于初学者来说可能难以理解且过于复杂。正因如此我把这部分内容放在最后一章,你在学习过程中可以先阅读这部分,也可以晚点阅读这部分,这完全取决于你自己。
既然已经读到这了,就让我们开始吧。首先要弄明白一点,从根本上来讲Git是一套内容寻址(content-addressable)文件系统,在此之上提供了一个VCS用户界面。马上你就会学到这意味着什么。
早期的Git(主要是1.5之前版本)的用户界面要比现在复杂得多,这是因为它更侧重于成为文件系统而不是一套更精致的VCS。最近几年改进了UI从而使它跟其他任何系统一样清晰易用。即便如此,还是经常会有一些陈腔滥调提到早期Git的UI复杂又难学。
内容寻址文件系统这一层相当酷,在本章中我会先讲解这部分。随后你会学到传输机制和最终要使用的各种库管理任务。
本书讲解了使用
30个Git命令。然而由于Git一开始被设计成供VCS使用的工具集而不是一整套用户友好的VCS,它还包含了许多底层命令,这些命令用于以UNIX风格使用或由脚本调用。这些命令一般被称为“plumbing”命令(底层命令),其他的更友好的命令则被称为“porcelain”命令(高层命令)。
本书前八章主要专门讨论高层命令。本章将主要讨论底层命令以理解Git的内部工作机制、演示Git如何及为何要以这种方式工作。这些命令主要不是用来从命令行手工使用的,更多的是用来为其他工具和自定义脚本服务的。
当你在一个新目录或已有目录内执行
Git存储和操作的内容都位于该目录下。如果你要备份或复制一个库,基本上将这一目录拷贝至其他地方就可以了。本章基本上都讨论该目录下的内容。该目录结构如下:
该目录下有可能还有其他文件,但这是一个全新的
GitWeb程序使用,所以不用关心这些内容。
.gitignore文件中管理的忽略模式(ignoredpatterns)的全局可执行文件。
另外还有四个重要的文件或目录:
Git的核心部分。
(分支)的提交对象的指针,
Git是如何操纵这些内容的。
Git是一套内容寻址文件系统。很不错。不过这是什么意思呢?这种说法的意思是,从内部来看,Git是简单的key-value数据存储。它允许插入任意类型的内容,并会返回一个键值,通过该键值可以在任何时候再取出该内容。可以通过底层命令
Git仓库并确认
Git初始化了
Git数据库里存储一些文本:
参数
(数据)对象,若不指定这个参数该命令仅仅返回键值。
Git已经存储了数据:
可以在
(保存至子目录下)。
通过
可以往Git中添加更多内容并取回了。也可以直接添加文件。比方说可以对一个文件进行简单的版本控制。首先,创建一个新文件,并把文件内容存储到数据库中:
接着往该文件中写入一些新内容并再次保存:
数据库中已经将文件的两个新版本连同一开始的内容保存下来了:
再将文件恢复到第一个版本:
或恢复到第二个版本:
需要记住的是几个版本的文件SHA-1值可能与实际的值不同,其次,存储的并不是文件名而仅仅是文件内容。这种对象类型称为blob。通过传递SHA-1值给
接下去来看tree对象,tree对象可以存储文件名,同时也允许存储一组文件。Git以一种类似UNIX文件系统但更简单的方式来存储内容。所有内容以tree或blob对象存储,其中tree对象对应于UNIX中的目录,blob对象则大致对应于inodes或文件内容。一个单独的tree对象包含一条或多条tree记录,每一条记录含有一个指向blob或子tree对象的SHA-1指针,并附有该对象的权限模式(mode)、类型和文件名信息。以simplegit项目为例,最新的
tree可能是这个样子:
tree对象。请注意
从概念上来讲,Git保存的数据如图9-1所示。
图9-1.Git对象模型的简化版
你可以自己创建tree。通常Git根据你的暂存区域或index来创建并写入一个tree。因此要创建一个tree对象的话首先要通过将一些文件暂存从而创建一个index。可以使用plumbing命令
──test.txt文件的第一个版本──创建一个index。通过该命令人为的将test.txt文件的首个版本加入到了一个新的暂存区域中。由于该文件原先并不在暂存区域中(甚至就连暂存区域也还没被创建出来呢),必须传入
值和文件名:
在本例中,指定了文件模式为
UNIX文件模式中参考来的,但是没有那么灵活──上述三种模式仅对Git中的文件(blobs)有效(虽然也有其他模式用于目录和子模块)。
现在可以用
──如果目标tree不存在,调用
可以这样验证这确实是一个tree对象:
再根据test.txt的第二个版本以及一个新文件创建一个新tree对象:
这时暂存区域中包含了test.txt的新版本及一个新文件new.txt。创建(写)该tree对象(将暂存区域或index状态写入到一个tree对象),然后瞧瞧它的样子:
请注意该tree对象包含了两个文件记录,且test.txt的SHA值是早先值的“第二版”(
tree对象读到暂存区域中去。在这时,通过传一个
tree对象作为一个子tree读到暂存区域中:
如果从刚写入的新tree对象创建一个工作目录,将得到位于工作目录顶级的两个文件和一个名为
图9-2.当前Git数据的内容结构
你现在有三个tree对象,它们指向了你要跟踪的项目的不同快照,可是先前的问题依然存在:必须记往三个SHA-1值以获得这些快照。你也没有关于谁、何时以及为何保存了这些快照的信息。commit对象为你保存了这些基本信息。
要创建一个commit对象,使用
通过
commit对象有格式很简单:指明了该时间点项目快照的顶层树对象、作者/提交者信息(从Git设理发店的
接着再写入另外两个commit对象,每一个都指定其之前的那个commit对象:
每一个commit对象都指向了你创建的树对象快照。出乎意料的是,现在已经有了真实的Git历史了,所以如果运行
真棒。你刚刚通过使用低级操作而不是那些普通命令创建了一个Git历史。这基本上就是运行
如果你按照以上描述进行了操作,可以得到如图9-3所示的对象图。
图9-3.Git目录下的所有对象
之前我提到当存储数据内容时,同时会有一个文件头被存储起来。我们花些时间来看看Git是如何存储对象的。你将看来如何通过Ruby脚本语言存储一个blob对象(这里以字符串“whatisup,doc?”为例)。使用
Ruby交互式模式:
Git以对象类型为起始内容构造一个文件头,本例中是一个blob。然后添加一个空格,接着是数据内容的长度,最后是一个空字节(nullbyte):
Git将文件头与原始数据内容拼接起来,并计算拼接后的新内容的SHA-1校验和。可以在Ruby中使用
SHA-1值:
Git用zlib对数据内容进行压缩,在Ruby中可以用zlib库来实现。首先需要导入该库,然后用
最后将用zlib压缩后的内容写入磁盘。需要指定保存对象的路径(SHA-1值的头两个字符作为子目录名称,剩余38个字符作为文件名保存至该子目录中)。在Ruby中,如果子目录不存在可以用
这就行了──你已经创建了一个正确的blob对象。所有的Git对象都以这种方式存储,惟一的区别是类型不同──除了字符串blob,文件头起始内容还可以是commit或tree。不过虽然blob几乎可以是任意内容,commit和tree的数据却是有固定格式的。
你可以执行像
SHA-1值,这样你就可以用这些指针而不是原来的SHA-1值去检索了。
在Git中,我们称之为“引用”(references或者refs,译者注)。你可以在
如果想要创建一个新的引用帮助你记住最后一次提交,技术上你可以这样做:
现在,你就可以在Git命令中使用你刚才创建的引用而不是SHA-1值:
当然,我们并不鼓励你直接修改这些引用文件。如果你确实需要更新一个引用,Git提供了一个安全的命令
基本上Git中的一个分支其实就是一个指向某个工作版本一条HEAD记录的指针或引用。你可以用这条命令创建一个指向第二次提交的分支:
这样你的分支将会只包含那次提交以及之前的工作:
现在,你的Git数据库应该看起来像图9-4一样。
图9-4.包含分支引用的Git目录对象
每当你执行
SHA-1值,添加到你要创建的分支的引用。
现在的问题是,当你执行
SHA-1值,而是一个指向另外一个引用的指针。如果你看一下这个文件,通常你将会看到这样的内容:
如果你执行
当你再执行
你也可以手动编辑这个文件,但是同样有一个更安全的方法可以这样做:
你也可以设置HEAD的值:
但是你不能设置成refs以外的形式:
你刚刚已经重温过了Git的三个主要对象类型,现在这是第四种。Tag对象非常像一个commit对象——包含一个标签,一组数据,一个消息和一个指针。最主要的区别就是Tag对象指向一个commit而不是一个tree。它就像是一个分支引用,但是不会变化——永远指向同一个commit,仅仅是提供一个更加友好的名字。
正如我们在第二章所讨论的,Tag有两种类型:annotated和lightweight。你可以类似下面这样的命令建立一个lightweighttag:
这就是lightweighttag的全部——一个永远不会发生变化的分支。annotatedtag要更复杂一点。如果你创建一个annotatedtag,Git会创建一个tag对象,然后写入一个指向指向它而不是直接指向commit的reference。你可以这样创建一个annotatedtag(
annotatedtag):
这是所创建对象的SHA-1值:
现在你可以运行
值得注意的是这个对象指向你所标记的commit对象的SHA-1值。同时需要注意的是它并不是必须要指向一个commit对象;你可以标记任何Git对象。例如,在Git的源代码里,管理者添加了一个GPG公钥(这是一个blob对象)对它做了一个标签。你就可以运行:
来查看Git源代码仓库中的公钥.Linuxkernel也有一个不是指向commit对象的tag——第一个tag是在导入源代码的时候创建的,它指向初始tree(initialtree,译者注)。
你将会看到的第四种reference是remotereference(远程引用,译者注)。如果你添加了一个remote然后推送代码过去,Git会把你最后一次推送到这个remote的每个分支的值都记录在
remote然后把你的
然后查看
中的
Remote应用和分支主要区别在于他们是不能被checkout的。Git把他们当作是标记这些了这些分支在服务器上最后状态的一种书签。
我们再来看一下testGit仓库。目前为止,有11个对象──4个blob,3个tree,3个commit以及一个tag:
Git用zlib压缩文件内容,因此这些文件并没有占用太多空间,所有文件加起来总共仅用了925字节。接下去你会添加一些大文件以演示Git的一个很有意思的功能。将你之前用到过的Grit库中的repo.rb文件加进去──这个源代码文件大小约为12K:
如果查看一下生成的tree,可以看到repo.rb文件的blob对象的SHA-1值:
然后可以用
稍微修改一下些文件,看会发生些什么:
查看这个commit生成的tree,可以看到一些有趣的东西:
blob对象与之前的已经不同了。这说明虽然只是往一个400行的文件最后加入了一行内容,Git却用一个全新的对象来保存新的文件内容:
你的磁盘上有了两个几乎完全相同的12K的对象。如果Git只完整保存其中一个,并保存另一个对象的差异内容,岂不更好?
事实上Git可以那样做。Git往磁盘保存对象时默认使用的格式叫松散对象(looseobject)格式。Git时不时地将这些对象打包至一个叫packfile的二进制文件以节省空间并提高效率。当仓库中有太多的松散对象,或是手工调用
查看一下objects目录,会发现大部分对象都不在了,与此同时出现了两个新文件:
仍保留着的几个对象是未被任何commit引用的blob──在此例中是你之前创建的“whatisup,doc?”和“testcontent”这两个示例blob。你从没将他们添加至任何commit,所以Git认为它们是“悬空”的,不会将它们打包进packfile。
剩下的文件是新创建的packfile以及一个索引。packfile文件包含了刚才从文件系统中移除的所有对象。索引文件包含了packfile的偏移信息,这样就可以快速定位任意一个指定对象。有意思的是运行
12K,而这个新生成的packfile仅为6K大小。通过打包对象减少了一半磁盘使用空间。
Git是如何做到这点的?Git打包对象时,会查找命名及尺寸相近的文件,并只保存文件不同版本之间的差异内容。可以查看一下packfile,观察它是如何节省空间的。
如果你还记得的话,
blob,即该文件的第二个版本。命令输出内容的第三列显示的是对象大小,可以看到
7字节。非常有趣的是第二个版本才是完整保存文件内容的对象,而第一个版本是以差异方式保存的──这是因为大部分情况下需要快速访问文件的最新版本。
最妙的是可以随时进行重新打包。Git自动定期对仓库进行重新打包以节省空间。当然也可以手工运行
这本书读到这里,你已经使用过一些简单的远程分支到本地引用的映射方式了,这种映射可以更为复杂。假设你像这样添加了一项远程仓库:
它在你的
远程仓库的URL地址,和用于获取操作的Refspec:
Refspec的格式是一个可选的
Git在即使不能快速演进的情况下,也去强制更新它。
缺省情况下refspec会被
所以,如果远端上有一个
它们全是等价的,因为Git把它们都扩展成
如果你想让Git每次只拉取远程的
这是
你也可以在命令行上指定多个refspec.像这样可以一次获取远程的多个分支:
在这个例子中,
你也可以在配置文件中指定多个refspec.如你想在每次获取时都获取
但是这里不能使用部分通配符,像这样就是不合法的:
但无论如何,你可以使用命名空间来达到这个目的。如你有一个QA组,他们推送一系列分支,你想每次获取
如果你的工作流很复杂,有QA组推送的分支、开发人员推送的分支、和集***员推送的分支,并且他们在远程分支上协作,你可以采用这种方式为他们创建各自的命名空间。
采用命名空间的方式确实很棒,但QA组成员第1次是如何将他们的分支推送到
如果QA组成员想把他们的
如果他们想让Git每次运行
这样,就会让
你也可以使用refspec来删除远程的引用,是通过运行这样的命令:
因为refspec的格式是
Git可以以两种主要的方式跨越两个仓库传输数据:基于HTTP协议之上,和
和
Git基于HTTP之上传输通常被称为哑协议,这是因为它在服务端不需要有针对Git特有的代码。这个获取过程仅仅是一系列GET请求,客户端可以假定服务端的Git仓库中的布局。让我们以simplegit库来看看
Git内部原理
不管你是从前面的章节直接跳到了本章,还是读完了其余各章一直到这,你都将在本章见识Git的内部工作原理和实现方式。我个人发现学习这些内容对于理解Git的用处和强大是非常重要的,不过也有人认为这些内容对于初学者来说可能难以理解且过于复杂。正因如此我把这部分内容放在最后一章,你在学习过程中可以先阅读这部分,也可以晚点阅读这部分,这完全取决于你自己。既然已经读到这了,就让我们开始吧。首先要弄明白一点,从根本上来讲Git是一套内容寻址(content-addressable)文件系统,在此之上提供了一个VCS用户界面。马上你就会学到这意味着什么。
早期的Git(主要是1.5之前版本)的用户界面要比现在复杂得多,这是因为它更侧重于成为文件系统而不是一套更精致的VCS。最近几年改进了UI从而使它跟其他任何系统一样清晰易用。即便如此,还是经常会有一些陈腔滥调提到早期Git的UI复杂又难学。
内容寻址文件系统这一层相当酷,在本章中我会先讲解这部分。随后你会学到传输机制和最终要使用的各种库管理任务。
9.1底层命令(Plumbing)和高层命令(Porcelain)
本书讲解了使用checkout,
branch,
remote等共约
30个Git命令。然而由于Git一开始被设计成供VCS使用的工具集而不是一整套用户友好的VCS,它还包含了许多底层命令,这些命令用于以UNIX风格使用或由脚本调用。这些命令一般被称为“plumbing”命令(底层命令),其他的更友好的命令则被称为“porcelain”命令(高层命令)。
本书前八章主要专门讨论高层命令。本章将主要讨论底层命令以理解Git的内部工作机制、演示Git如何及为何要以这种方式工作。这些命令主要不是用来从命令行手工使用的,更多的是用来为其他工具和自定义脚本服务的。
当你在一个新目录或已有目录内执行
gitinit时,Git会创建一个
.git目录,几乎所有
Git存储和操作的内容都位于该目录下。如果你要备份或复制一个库,基本上将这一目录拷贝至其他地方就可以了。本章基本上都讨论该目录下的内容。该目录结构如下:
$lsHEADbranches/configdescriptionhooks/indexinfo/objects/refs/
该目录下有可能还有其他文件,但这是一个全新的
gitinit生成的库,所以默认情况下这些就是你能看到的结构。新版本的Git不再使用
branches目录,
description文件仅供
GitWeb程序使用,所以不用关心这些内容。
config文件包含了项目特有的配置选项,
info目录保存了一份不希望在
.gitignore文件中管理的忽略模式(ignoredpatterns)的全局可执行文件。
hooks目录包住了第六章详细介绍了的客户端或服务端钩子脚本。
另外还有四个重要的文件或目录:
HEAD及
index文件,
objects及
refs目录。这些是
Git的核心部分。
objects目录存储所有数据内容,
refs目录存储指向数据
(分支)的提交对象的指针,
HEAD文件指向当前分支,
index文件保存了暂存区域信息。马上你将详细了解
Git是如何操纵这些内容的。
9.2Git对象
Git是一套内容寻址文件系统。很不错。不过这是什么意思呢?这种说法的意思是,从内部来看,Git是简单的key-value数据存储。它允许插入任意类型的内容,并会返回一个键值,通过该键值可以在任何时候再取出该内容。可以通过底层命令hash-object来示范这点,传一些数据给该命令,它会将数据保存在
.git目录并返回表示这些数据的键值。首先初使化一个
Git仓库并确认
objects目录是空的:
$mkdirtest$cdtest$gitinitInitializedemptyGitrepositoryin/tmp/test/.git/$find.git/objects.git/objects.git/objects/info.git/objects/pack$find.git/objects-typef$
Git初始化了
objects目录,同时在该目录下创建了
pack和
info子目录,但是该目录下没有其他常规文件。我们往这个
Git数据库里存储一些文本:
$echo'testcontent'|githash-object-w--stdind670460b4b4aece5915caf5c68d12f560a9fe3e4
参数
-w指示
hash-object命令存储
(数据)对象,若不指定这个参数该命令仅仅返回键值。
--stdin指定从标准输入设备(stdin)来读取内容,若不指定这个参数则需指定一个要存储的文件的路径。该命令输出长度为40个字符的校验和。这是个SHA-1哈希值──其值为要存储的数据加上你马上会了解到的一种头信息的校验和。现在可以查看到
Git已经存储了数据:
$find.git/objects-typef.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4
可以在
objects目录下看到一个文件。这便是Git存储数据内容的方式──为每份内容生成一个文件,取得该内容与头信息的SHA-1校验和,创建以该校验和前两个字符为名称的子目录,并以(校验和)剩下38个字符为文件命名
(保存至子目录下)。
通过
cat-file命令可以将数据内容取回。该命令是查看Git对象的瑞士军刀。传入
-p参数可以让该命令输出数据内容的类型:
$gitcat-file-pd670460b4b4aece5915caf5c68d12f560a9fe3e4testcontent
可以往Git中添加更多内容并取回了。也可以直接添加文件。比方说可以对一个文件进行简单的版本控制。首先,创建一个新文件,并把文件内容存储到数据库中:
$echo'version1'>test.txt$githash-object-wtest.txt83baae61804e65cc73a7201a7252750c76066a30
接着往该文件中写入一些新内容并再次保存:
$echo'version2'>test.txt$githash-object-wtest.txt1f7a7a472abf3dd9643fd615f6da379c4acb3e3a
数据库中已经将文件的两个新版本连同一开始的内容保存下来了:
$find.git/objects-typef.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a.git/objects/83/baae61804e65cc73a7201a7252750c76066a30.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4
再将文件恢复到第一个版本:
$gitcat-file-p83baae61804e65cc73a7201a7252750c76066a30>test.txt$cattest.txtversion1
或恢复到第二个版本:
$gitcat-file-p1f7a7a472abf3dd9643fd615f6da379c4acb3e3a>test.txt$cattest.txtversion2
需要记住的是几个版本的文件SHA-1值可能与实际的值不同,其次,存储的并不是文件名而仅仅是文件内容。这种对象类型称为blob。通过传递SHA-1值给
cat-file-t命令可以让Git返回任何对象的类型:
$gitcat-file-t1f7a7a472abf3dd9643fd615f6da379c4acb3e3ablob
tree(树)对象
接下去来看tree对象,tree对象可以存储文件名,同时也允许存储一组文件。Git以一种类似UNIX文件系统但更简单的方式来存储内容。所有内容以tree或blob对象存储,其中tree对象对应于UNIX中的目录,blob对象则大致对应于inodes或文件内容。一个单独的tree对象包含一条或多条tree记录,每一条记录含有一个指向blob或子tree对象的SHA-1指针,并附有该对象的权限模式(mode)、类型和文件名信息。以simplegit项目为例,最新的tree可能是这个样子:
$gitcat-file-pmaster^{tree}100644bloba906cb2a4a904a152e80877d4088654daad0c859README100644blob8f94139338f9404f26296befa88755fc2598c289Rakefile040000tree99f1a6d12cb4b6f19c8655fca46c3ecf317074e0lib
master^{tree}表示
branch分支上最新提交指向的
tree对象。请注意
lib子目录并非一个blob对象,而是一个指向别一个tree对象的指针:
$gitcat-file-p99f1a6d12cb4b6f19c8655fca46c3ecf317074e0100644blob47c6340d6459e05787f644c2447d2595f5d3a54bsimplegit.rb
从概念上来讲,Git保存的数据如图9-1所示。
图9-1.Git对象模型的简化版
你可以自己创建tree。通常Git根据你的暂存区域或index来创建并写入一个tree。因此要创建一个tree对象的话首先要通过将一些文件暂存从而创建一个index。可以使用plumbing命令
update-index为一个单独文件
──test.txt文件的第一个版本──创建一个index。通过该命令人为的将test.txt文件的首个版本加入到了一个新的暂存区域中。由于该文件原先并不在暂存区域中(甚至就连暂存区域也还没被创建出来呢),必须传入
--add参数;由于要添加的文件并不在当前目录下而是在数据库中,必须传入
--cacheinfo参数。同时指定了文件模式,SHA-1
值和文件名:
$gitupdate-index--add--cacheinfo100644\83baae61804e65cc73a7201a7252750c76066a30test.txt
在本例中,指定了文件模式为
100644,表明这是一个普通文件。其他可用的模式有:
100755表示可执行文件,
120000表示符号链接。文件模式是从常规的
UNIX文件模式中参考来的,但是没有那么灵活──上述三种模式仅对Git中的文件(blobs)有效(虽然也有其他模式用于目录和子模块)。
现在可以用
write-tree命令将暂存区域的内容写到一个tree对象了。无需
-w参数
──如果目标tree不存在,调用
write-tree会自动根据index状态创建一个tree对象。
$gitwrite-treed8329fc1cc938780ffdd9f94e0d364e0ea74f579$gitcat-file-pd8329fc1cc938780ffdd9f94e0d364e0ea74f579100644blob83baae61804e65cc73a7201a7252750c76066a30test.txt
可以这样验证这确实是一个tree对象:
$gitcat-file-td8329fc1cc938780ffdd9f94e0d364e0ea74f579tree
再根据test.txt的第二个版本以及一个新文件创建一个新tree对象:
$echo'newfile'>new.txt$gitupdate-indextest.txt$gitupdate-index--addnew.txt
这时暂存区域中包含了test.txt的新版本及一个新文件new.txt。创建(写)该tree对象(将暂存区域或index状态写入到一个tree对象),然后瞧瞧它的样子:
$gitwrite-tree0155eb4229851634a0f03eb265b69f5a2d56f341$gitcat-file-p0155eb4229851634a0f03eb265b69f5a2d56f341100644blobfa49b077972391ad58037050f2a75f74e3671e92new.txt100644blob1f7a7a472abf3dd9643fd615f6da379c4acb3e3atest.txt
请注意该tree对象包含了两个文件记录,且test.txt的SHA值是早先值的“第二版”(
1f7a7a)。来点更有趣的,你将把第一个tree对象作为一个子目录加进该tree中。可以用
read-tree命令将
tree对象读到暂存区域中去。在这时,通过传一个
--prefix参数给
read-tree,将一个已有的
tree对象作为一个子tree读到暂存区域中:
$gitread-tree--prefix=bakd8329fc1cc938780ffdd9f94e0d364e0ea74f579$gitwrite-tree3c4e9cd789d88d8d89c1073707c3585e41b0e614$gitcat-file-p3c4e9cd789d88d8d89c1073707c3585e41b0e614040000treed8329fc1cc938780ffdd9f94e0d364e0ea74f579bak100644blobfa49b077972391ad58037050f2a75f74e3671e92new.txt100644blob1f7a7a472abf3dd9643fd615f6da379c4acb3e3atest.txt
如果从刚写入的新tree对象创建一个工作目录,将得到位于工作目录顶级的两个文件和一个名为
bak的子目录,该子目录包含了test.txt文件的第一个版本。可以将Git用来包含这些内容的数据想象成如图9-2所示的样子。
图9-2.当前Git数据的内容结构
commit(提交)对象
你现在有三个tree对象,它们指向了你要跟踪的项目的不同快照,可是先前的问题依然存在:必须记往三个SHA-1值以获得这些快照。你也没有关于谁、何时以及为何保存了这些快照的信息。commit对象为你保存了这些基本信息。要创建一个commit对象,使用
commit-tree命令,指定一个tree的SHA-1,如果有任何前继提交对象,也可以指定。从你写的第一个tree开始:
$echo'firstcommit'|gitcommit-treed8329ffdf4fc3344e67ab068f836878b6c4951e3b15f3d
通过
cat-file查看这个新commit对象:
$gitcat-file-pfdf4fc3treed8329fc1cc938780ffdd9f94e0d364e0ea74f579authorScottChacon1243040974-0700committerScottChacon1243040974-0700firstcommit
commit对象有格式很简单:指明了该时间点项目快照的顶层树对象、作者/提交者信息(从Git设理发店的
user.name和
user.email中获得)以及当前时间戳、一个空行,以及提交注释信息。
接着再写入另外两个commit对象,每一个都指定其之前的那个commit对象:
$echo'secondcommit'|gitcommit-tree0155eb-pfdf4fc3cac0cab538b970a37ea1e769cbbde608743bc96d$echo'thirdcommit'|gitcommit-tree3c4e9c-pcac0cab1a410efbd13591db07496601ebc7a059dd55cfe9
每一个commit对象都指向了你创建的树对象快照。出乎意料的是,现在已经有了真实的Git历史了,所以如果运行
gitlog命令并指定最后那个commit对象的SHA-1便可以查看历史:
$gitlog--stat1a410ecommit1a410efbd13591db07496601ebc7a059dd55cfe9Author:ScottChaconDate:FriMay2218:15:242009-0700thirdcommitbak/test.txt|1+1fileschanged,1insertions(+),0deletions(-)commitcac0cab538b970a37ea1e769cbbde608743bc96dAuthor:ScottChaconDate:FriMay2218:14:292009-0700secondcommitnew.txt|1+test.txt|2+-2fileschanged,2insertions(+),1deletions(-)commitfdf4fc3344e67ab068f836878b6c4951e3b15f3dAuthor:ScottChaconDate:FriMay2218:09:342009-0700firstcommittest.txt|1+1fileschanged,1insertions(+),0deletions(-)
真棒。你刚刚通过使用低级操作而不是那些普通命令创建了一个Git历史。这基本上就是运行
gitadd和
git commit命令时Git进行的工作──保存修改了的文件的blob,更新索引,创建tree对象,最后创建commit对象,这些commit对象指向了顶层tree对象以及先前的commit对象。这三类Git对象──blob,tree以及tree──都各自以文件的方式保存在
.git/objects目录下。以下所列是目前为止样例中的所有对象,每个对象后面的注释里标明了它们保存的内容:
$find.git/objects-typef.git/objects/01/55eb4229851634a0f03eb265b69f5a2d56f341#tree2.git/objects/1a/410efbd13591db07496601ebc7a059dd55cfe9#commit3.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a#test.txtv2.git/objects/3c/4e9cd789d88d8d89c1073707c3585e41b0e614#tree3.git/objects/83/baae61804e65cc73a7201a7252750c76066a30#test.txtv1.git/objects/ca/c0cab538b970a37ea1e769cbbde608743bc96d#commit2.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4#'testcontent'.git/objects/d8/329fc1cc938780ffdd9f94e0d364e0ea74f579#tree1.git/objects/fa/49b077972391ad58037050f2a75f74e3671e92#new.txt.git/objects/fd/f4fc3344e67ab068f836878b6c4951e3b15f3d#commit1
如果你按照以上描述进行了操作,可以得到如图9-3所示的对象图。
图9-3.Git目录下的所有对象
对象存储
之前我提到当存储数据内容时,同时会有一个文件头被存储起来。我们花些时间来看看Git是如何存储对象的。你将看来如何通过Ruby脚本语言存储一个blob对象(这里以字符串“whatisup,doc?”为例)。使用irb命令进入
Ruby交互式模式:
$irb>>content="whatisup,doc?"=>"whatisup,doc?"
Git以对象类型为起始内容构造一个文件头,本例中是一个blob。然后添加一个空格,接着是数据内容的长度,最后是一个空字节(nullbyte):
>>header="blob#{content.length}\0"=>"blob16\000"
Git将文件头与原始数据内容拼接起来,并计算拼接后的新内容的SHA-1校验和。可以在Ruby中使用
require语句导入SHA1digest库,然后调用
Digest::SHA1.hexdigest()方法计算字符串的
SHA-1值:
>>store=header+content=>"blob16\000whatisup,doc?">>require'digest/sha1'=>true>>sha1=Digest::SHA1.hexdigest(store)=>"bd9dbf5aae1a3862dd1526723246b20206e5fc37"
Git用zlib对数据内容进行压缩,在Ruby中可以用zlib库来实现。首先需要导入该库,然后用
Zlib::Deflate.deflate()对数据进行压缩:
>>require'zlib'=>true>>zlib_content=Zlib::Deflate.deflate(store)=>"x\234K\312\311OR04c(\317H,Q\310,V(-\320QH\311O\266\a\000_\034\a\235"
最后将用zlib压缩后的内容写入磁盘。需要指定保存对象的路径(SHA-1值的头两个字符作为子目录名称,剩余38个字符作为文件名保存至该子目录中)。在Ruby中,如果子目录不存在可以用
FileUtils.mkdir_p()函数创建它。接着用
File.open方法打开文件,并用
write()方法将之前压缩的内容写入该文件:
>>path='.git/objects/'+sha1[0,2]+'/'+sha1[2,38]=>".git/objects/bd/9dbf5aae1a3862dd1526723246b20206e5fc37">>require'fileutils'=>true>>FileUtils.mkdir_p(File.dirname(path))=>".git/objects/bd">>File.open(path,'w'){|f|f.writezlib_content}=>32
这就行了──你已经创建了一个正确的blob对象。所有的Git对象都以这种方式存储,惟一的区别是类型不同──除了字符串blob,文件头起始内容还可以是commit或tree。不过虽然blob几乎可以是任意内容,commit和tree的数据却是有固定格式的。
9.3GitReferences
你可以执行像gitlog1a410e这样的命令来查看完整的历史,但是这样你就要记得
1a410e是你最后一次提交,这样才能在提交历史中找到这些对象。你需要一个文件来用一个简单的名字来记录这些
SHA-1值,这样你就可以用这些指针而不是原来的SHA-1值去检索了。
在Git中,我们称之为“引用”(references或者refs,译者注)。你可以在
.git/refs目录下面找到这些包含SHA-1值的文件。在这个项目里,这个目录还没不包含任何文件,但是包含这样一个简单的结构:
$find.git/refs.git/refs.git/refs/heads.git/refs/tags$find.git/refs-typef$
如果想要创建一个新的引用帮助你记住最后一次提交,技术上你可以这样做:
$echo"1a410efbd13591db07496601ebc7a059dd55cfe9">.git/refs/heads/master
现在,你就可以在Git命令中使用你刚才创建的引用而不是SHA-1值:
$gitlog--pretty=onelinemaster1a410efbd13591db07496601ebc7a059dd55cfe9thirdcommitcac0cab538b970a37ea1e769cbbde608743bc96dsecondcommitfdf4fc3344e67ab068f836878b6c4951e3b15f3dfirstcommit
当然,我们并不鼓励你直接修改这些引用文件。如果你确实需要更新一个引用,Git提供了一个安全的命令
update-ref:
$gitupdate-refrefs/heads/master1a410efbd13591db07496601ebc7a059dd55cfe9
基本上Git中的一个分支其实就是一个指向某个工作版本一条HEAD记录的指针或引用。你可以用这条命令创建一个指向第二次提交的分支:
$gitupdate-refrefs/heads/testcac0ca
这样你的分支将会只包含那次提交以及之前的工作:
$gitlog--pretty=onelinetestcac0cab538b970a37ea1e769cbbde608743bc96dsecondcommitfdf4fc3344e67ab068f836878b6c4951e3b15f3dfirstcommit
现在,你的Git数据库应该看起来像图9-4一样。
图9-4.包含分支引用的Git目录对象
每当你执行
gitbranch(分支名称)这样的命令,Git基本上就是执行
update-ref命令,把你现在所在分支中最后一次提交的
SHA-1值,添加到你要创建的分支的引用。
HEAD标记
现在的问题是,当你执行gitbranch(分支名称)这条命令的时候,Git怎么知道最后一次提交的SHA-1值呢?答案就是HEAD文件。HEAD文件是一个指向你当前所在分支的引用标识符。这样的引用标识符——它看起来并不像一个普通的引用——其实并不包含
SHA-1值,而是一个指向另外一个引用的指针。如果你看一下这个文件,通常你将会看到这样的内容:
$cat.git/HEADref:refs/heads/master
如果你执行
gitcheckouttest,Git就会更新这个文件,看起来像这样:
$cat.git/HEADref:refs/heads/test
当你再执行
gitcommit命令,它就创建了一个commit对象,把这个commit对象的父级设置为HEAD指向的引用的SHA-1值。
你也可以手动编辑这个文件,但是同样有一个更安全的方法可以这样做:
symbolic-ref。你可以用下面这条命令读取HEAD的值:
$gitsymbolic-refHEADrefs/heads/master
你也可以设置HEAD的值:
$gitsymbolic-refHEADrefs/heads/test$cat.git/HEADref:refs/heads/test
但是你不能设置成refs以外的形式:
$gitsymbolic-refHEADtestfatal:RefusingtopointHEADoutsideofrefs/
Tags
你刚刚已经重温过了Git的三个主要对象类型,现在这是第四种。Tag对象非常像一个commit对象——包含一个标签,一组数据,一个消息和一个指针。最主要的区别就是Tag对象指向一个commit而不是一个tree。它就像是一个分支引用,但是不会变化——永远指向同一个commit,仅仅是提供一个更加友好的名字。正如我们在第二章所讨论的,Tag有两种类型:annotated和lightweight。你可以类似下面这样的命令建立一个lightweighttag:
$gitupdate-refrefs/tags/v1.0cac0cab538b970a37ea1e769cbbde608743bc96d
这就是lightweighttag的全部——一个永远不会发生变化的分支。annotatedtag要更复杂一点。如果你创建一个annotatedtag,Git会创建一个tag对象,然后写入一个指向指向它而不是直接指向commit的reference。你可以这样创建一个annotatedtag(
-a参数表明这是一个
annotatedtag):
$gittag-av1.11a410efbd13591db07496601ebc7a059dd55cfe9-m'testtag'
这是所创建对象的SHA-1值:
$cat.git/refs/tags/v1.19585191f37f7b0fb9444f35a9bf50de191beadc2
现在你可以运行
cat-file命令检查这个SHA-1值:
$gitcat-file-p9585191f37f7b0fb9444f35a9bf50de191beadc2object1a410efbd13591db07496601ebc7a059dd55cfe9typecommittagv1.1taggerScottChaconSatMay2316:48:582009-0700testtag
值得注意的是这个对象指向你所标记的commit对象的SHA-1值。同时需要注意的是它并不是必须要指向一个commit对象;你可以标记任何Git对象。例如,在Git的源代码里,管理者添加了一个GPG公钥(这是一个blob对象)对它做了一个标签。你就可以运行:
$gitcat-fileblobjunio-gpg-pub
来查看Git源代码仓库中的公钥.Linuxkernel也有一个不是指向commit对象的tag——第一个tag是在导入源代码的时候创建的,它指向初始tree(initialtree,译者注)。
Remotes
你将会看到的第四种reference是remotereference(远程引用,译者注)。如果你添加了一个remote然后推送代码过去,Git会把你最后一次推送到这个remote的每个分支的值都记录在refs/remotes目录下。例如,你可以添加一个叫做
origin的
remote然后把你的
master分支推送上去:
$gitremoteaddorigingit@github.com:schacon/simplegit-progit.git$gitpushoriginmasterCountingobjects:11,done.Compressingobjects:100%(5/5),done.Writingobjects:100%(7/7),716bytes,done.Total7(delta2),reused4(delta1)Togit@github.com:schacon/simplegit-progit.gita11bef0..ca82a6dmaster->master
然后查看
refs/remotes/origin/master这个文件,你就会发现
originremote
中的
master分支就是你最后一次和服务器的通信。
$cat.git/refs/remotes/origin/masterca82a6dff817ec66f44342007202690a93763949
Remote应用和分支主要区别在于他们是不能被checkout的。Git把他们当作是标记这些了这些分支在服务器上最后状态的一种书签。
9.4Packfiles
我们再来看一下testGit仓库。目前为止,有11个对象──4个blob,3个tree,3个commit以及一个tag:$find.git/objects-typef.git/objects/01/55eb4229851634a0f03eb265b69f5a2d56f341#tree2.git/objects/1a/410efbd13591db07496601ebc7a059dd55cfe9#commit3.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a#test.txtv2.git/objects/3c/4e9cd789d88d8d89c1073707c3585e41b0e614#tree3.git/objects/83/baae61804e65cc73a7201a7252750c76066a30#test.txtv1.git/objects/95/85191f37f7b0fb9444f35a9bf50de191beadc2#tag.git/objects/ca/c0cab538b970a37ea1e769cbbde608743bc96d#commit2.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4#'testcontent'.git/objects/d8/329fc1cc938780ffdd9f94e0d364e0ea74f579#tree1.git/objects/fa/49b077972391ad58037050f2a75f74e3671e92#new.txt.git/objects/fd/f4fc3344e67ab068f836878b6c4951e3b15f3d#commit1
Git用zlib压缩文件内容,因此这些文件并没有占用太多空间,所有文件加起来总共仅用了925字节。接下去你会添加一些大文件以演示Git的一个很有意思的功能。将你之前用到过的Grit库中的repo.rb文件加进去──这个源代码文件大小约为12K:
$curlhttp://github.com/mojombo/grit/raw/master/lib/grit/repo.rb>repo.rb$gitaddrepo.rb$gitcommit-m'addedrepo.rb'[master484a592]addedrepo.rb3fileschanged,459insertions(+),2deletions(-)deletemode100644bak/test.txtcreatemode100644repo.rbrewritetest.txt(100%)
如果查看一下生成的tree,可以看到repo.rb文件的blob对象的SHA-1值:
$gitcat-file-pmaster^{tree}100644blobfa49b077972391ad58037050f2a75f74e3671e92new.txt100644blob9bc1dc421dcd51b4ac296e3e5b6e2a99cf44391erepo.rb100644blobe3f094f522629ae358806b17daf78246c27c007btest.txt
然后可以用
gitcat-file命令查看这个对象有多大:
$gitcat-file-s9bc1dc421dcd51b4ac296e3e5b6e2a99cf44391e12898
稍微修改一下些文件,看会发生些什么:
$echo'#testing'>>repo.rb$gitcommit-am'modifiedrepoabit'[masterab1afef]modifiedrepoabit1fileschanged,1insertions(+),0deletions(-)
查看这个commit生成的tree,可以看到一些有趣的东西:
$gitcat-file-pmaster^{tree}100644blobfa49b077972391ad58037050f2a75f74e3671e92new.txt100644blob05408d195263d853f09dca71d55116663690c27crepo.rb100644blobe3f094f522629ae358806b17daf78246c27c007btest.txt
blob对象与之前的已经不同了。这说明虽然只是往一个400行的文件最后加入了一行内容,Git却用一个全新的对象来保存新的文件内容:
$gitcat-file-s05408d195263d853f09dca71d55116663690c27c12908
你的磁盘上有了两个几乎完全相同的12K的对象。如果Git只完整保存其中一个,并保存另一个对象的差异内容,岂不更好?
事实上Git可以那样做。Git往磁盘保存对象时默认使用的格式叫松散对象(looseobject)格式。Git时不时地将这些对象打包至一个叫packfile的二进制文件以节省空间并提高效率。当仓库中有太多的松散对象,或是手工调用
git gc命令,或推送至远程服务器时,Git都会这样做。手工调用
gitgc命令让Git将库中对象打包并看会发生些什么:
$gitgcCountingobjects:17,done.Deltacompressionusing2threads.Compressingobjects:100%(13/13),done.Writingobjects:100%(17/17),done.Total17(delta1),reused10(delta0)
查看一下objects目录,会发现大部分对象都不在了,与此同时出现了两个新文件:
$find.git/objects-typef.git/objects/71/08f7ecb345ee9d0084193f147cdad4d2998293.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4.git/objects/info/packs.git/objects/pack/pack-7a16e4488ae40c7d2bc56ea2bd43e25212a66c45.idx.git/objects/pack/pack-7a16e4488ae40c7d2bc56ea2bd43e25212a66c45.pack
仍保留着的几个对象是未被任何commit引用的blob──在此例中是你之前创建的“whatisup,doc?”和“testcontent”这两个示例blob。你从没将他们添加至任何commit,所以Git认为它们是“悬空”的,不会将它们打包进packfile。
剩下的文件是新创建的packfile以及一个索引。packfile文件包含了刚才从文件系统中移除的所有对象。索引文件包含了packfile的偏移信息,这样就可以快速定位任意一个指定对象。有意思的是运行
gc命令前磁盘上的对象大小约为
12K,而这个新生成的packfile仅为6K大小。通过打包对象减少了一半磁盘使用空间。
Git是如何做到这点的?Git打包对象时,会查找命名及尺寸相近的文件,并只保存文件不同版本之间的差异内容。可以查看一下packfile,观察它是如何节省空间的。
gitverify-pack命令用于显示已打包的内容:
$gitverify-pack-v\.git/objects/pack/pack-7a16e4488ae40c7d2bc56ea2bd43e25212a66c45.idx0155eb4229851634a0f03eb265b69f5a2d56f341tree7176540005408d195263d853f09dca71d55116663690c27cblob12908347887409f01cea547666f58d6a8d809583841a7c6f0130tree10610750861a410efbd13591db07496601ebc7a059dd55cfe9commit2251513221f7a7a472abf3dd9643fd615f6da379c4acb3e3ablob101953813c4e9cd789d88d8d89c1073707c3585e41b0e614tree1011055211484a59275031909e19aadb7c92262719cfcdf19acommit22615316983baae61804e65cc73a7201a7252750c76066a30blob101953629585191f37f7b0fb9444f35a9bf50de191beadc2tag13612754769bc1dc421dcd51b4ac296e3e5b6e2a99cf44391eblob7185193105408d195263d853f09dca71d55116663690c27c\ab1afef80fac8e34258ff41fc1b867c702daa24bcommit23215712cac0cab538b970a37ea1e769cbbde608743bc96dcommit226154473d8329fc1cc938780ffdd9f94e0d364e0ea74f579tree36465316e3f094f522629ae358806b17daf78246c27c007bblob14867344352f8f51d7d8a1760462eca26eebafde32087499533tree106107749fa49b077972391ad58037050f2a75f74e3671e92blob918856fdf4fc3344e67ab068f836878b6c4951e3b15f3dcommit177122627chainlength=1:1objectpack-7a16e4488ae40c7d2bc56ea2bd43e25212a66c45.pack:ok
如果你还记得的话,
9bc1d这个blob是repo.rb文件的第一个版本,这个blob引用了
05408这个
blob,即该文件的第二个版本。命令输出内容的第三列显示的是对象大小,可以看到
05408占用了12K空间,而
9bc1d仅为
7字节。非常有趣的是第二个版本才是完整保存文件内容的对象,而第一个版本是以差异方式保存的──这是因为大部分情况下需要快速访问文件的最新版本。
最妙的是可以随时进行重新打包。Git自动定期对仓库进行重新打包以节省空间。当然也可以手工运行
gitgc命令来这么做。
9.5TheRefspec
这本书读到这里,你已经使用过一些简单的远程分支到本地引用的映射方式了,这种映射可以更为复杂。假设你像这样添加了一项远程仓库:$gitremoteaddorigingit@github.com:schacon/simplegit-progit.git
它在你的
.git/config文件中添加了一节,指定了远程的名称(
origin),
远程仓库的URL地址,和用于获取操作的Refspec:
[remote"origin"]url=git@github.com:schacon/simplegit-progit.gitfetch=+refs/heads/*:refs/remotes/origin/*
Refspec的格式是一个可选的
+号,接着是
:的格式,这里是远端上的引用格式,是将要记录在本地的引用格式。可选的
+号告诉
Git在即使不能快速演进的情况下,也去强制更新它。
缺省情况下refspec会被
gitremoteadd命令所自动生成,Git会获取远端上
refs/heads/下面的所有引用,并将它写入到本地的
refs/remotes/origin/.
所以,如果远端上有一个
master分支,你在本地可以通过下面这种方式来访问它的历史记录:
$gitlogorigin/master$gitlogremotes/origin/master$gitlogrefs/remotes/origin/master
它们全是等价的,因为Git把它们都扩展成
refs/remotes/origin/master.
如果你想让Git每次只拉取远程的
master分支,而不是远程的所有分支,你可以把fetch这一行修改成这样:
fetch=+refs/heads/master:refs/remotes/origin/master
这是
gitfetch操作对这个远端的缺省refspec值。而如果你只想做一次该操作,也可以在命令行上指定这个refspec.如可以这样拉取远程的
master分支到本地的
origin/mymaster分支:
$gitfetchoriginmaster:refs/remotes/origin/mymaster
你也可以在命令行上指定多个refspec.像这样可以一次获取远程的多个分支:
$gitfetchoriginmaster:refs/remotes/origin/mymaster\topic:refs/remotes/origin/topicFromgit@github.com:schacon/simplegit![rejected]master->origin/mymaster(nonfastforward)*[newbranch]topic->origin/topic
在这个例子中,
master分支因为不是一个可以快速演进的引用而拉取操作被拒绝。你可以在refspec之前使用一个
+号来重载这种行为。
你也可以在配置文件中指定多个refspec.如你想在每次获取时都获取
master和
experiment分支,就添加两行:
[remote"origin"]url=git@github.com:schacon/simplegit-progit.gitfetch=+refs/heads/master:refs/remotes/origin/masterfetch=+refs/heads/experiment:refs/remotes/origin/experiment
但是这里不能使用部分通配符,像这样就是不合法的:
fetch=+refs/heads/qa*:refs/remotes/origin/qa*
但无论如何,你可以使用命名空间来达到这个目的。如你有一个QA组,他们推送一系列分支,你想每次获取
master分支和QA组的所有分支,你可以使用这样的配置段落:
[remote"origin"]url=git@github.com:schacon/simplegit-progit.gitfetch=+refs/heads/master:refs/remotes/origin/masterfetch=+refs/heads/qa/*:refs/remotes/origin/qa/*
如果你的工作流很复杂,有QA组推送的分支、开发人员推送的分支、和集***员推送的分支,并且他们在远程分支上协作,你可以采用这种方式为他们创建各自的命名空间。
推送Refspec
采用命名空间的方式确实很棒,但QA组成员第1次是如何将他们的分支推送到qa/空间里面的呢?答案是你可以使用refspec来推送。
如果QA组成员想把他们的
master分支推送到远程的
qa/master分支上,可以这样运行:
$gitpushoriginmaster:refs/heads/qa/master
如果他们想让Git每次运行
gitpushorigin时都这样自动推送,他们可以在配置文件中添加
push值:
[remote"origin"]url=git@github.com:schacon/simplegit-progit.gitfetch=+refs/heads/*:refs/remotes/origin/*push=refs/heads/master:refs/heads/qa/master
这样,就会让
gitpushorigin缺省就把本地的
master分支推送到远程的
qa/master分支上。
删除引用
你也可以使用refspec来删除远程的引用,是通过运行这样的命令:$gitpushorigin:topic
因为refspec的格式是
:,通过把部分留空的方式,这个意思是是把远程的
topic分支变成空,也就是删除它。
9.6传输协议
Git可以以两种主要的方式跨越两个仓库传输数据:基于HTTP协议之上,和file://,
ssh://,
和
git://等智能传输协议。这一节带你快速浏览这两种主要的协议操作过程。
哑协议
Git基于HTTP之上传输通常被称为哑协议,这是因为它在服务端不需要有针对Git特有的代码。这个获取过程仅仅是一系列GET请求,客户端可以假定服务端的Git仓库中的布局。让我们以simplegit库来看看http-fetch的过程:
$gitclone'target='_blank'>http://github.com/schacon/simplegit-progit.git[/code]
它做的第1件事情就是获取info/refs文件。这个文件是在服务端运行了update-server-info所生成的,这也解释了为什么在服务端要想使用HTTP传输,必须要开启post-receive钩子:=>GETinfo/refsca82a6dff817ec66f44342007202690a93763949refs/heads/master
现在你有一个远端引用和SHA值的列表。下一步是寻找HEAD引用,这样你就知道了在完成后,什么应该被检出到工作目录:=>GETHEADref:refs/heads/master
这说明在完成获取后,需要检出master分支。这时,已经可以开始漫游操作了。因为你的起点是在info/refs文件中所提到的ca82a6commit
对象,你的开始操作就是获取它:=>GETobjects/ca/82a6dff817ec66f44342007202690a93763949(179bytesofbinarydata)
然后你取回了这个对象-这在服务端是一个松散格式的对象,你使用的是静态的HTTPGET请求获取的。可以使用zlib解压缩它,去除其头部,查看它的commmit内容:$gitcat-file-pca82a6dff817ec66f44342007202690a93763949treecfda3bf379e4f8dba8717dee55aab78aef7f4dafparent085bb3bcb608e1e8451d4b2432f8ecbe6306e7e7authorScottChacon1205815931-0700committerScottChacon1240030591-0700changedtheversionnumber
这样,就得到了两个需要进一步获取的对象-cfda3b是这个commit对象所对应的tree对象,和085bb3是它的父对象;=>GETobjects/08/5bb3bcb608e1e8451d4b2432f8ecbe6306e7e7(179bytesofdata)
这样就取得了这它的下一步commit对象,再抓取tree对象:=>GETobjects/cf/da3bf379e4f8dba8717dee55aab78aef7f4daf(404-NotFound)
Oops-看起来这个tree对象在服务端并不以松散格式对象存在,所以得到了404响应,代表在HTTP服务端没有找到该对象。这有好几个原因-这个对象可能在替代仓库里面,或者在打包文件里面,Git会首先检查任何列出的替代仓库:=>GETobjects/info/http-alternates(emptyfile)
如果这返回了几个替代仓库列表,那么它会去那些地方检查松散格式对象和文件-这是一种在软件分叉之间共享对象以节省磁盘的好方法。然而,在这个例子中,没有替代仓库。所以你所需要的对象肯定在某个打包文件中。要检查服务端有哪些打包格式文件,你需要获取objects/info/packs文件,这里面包含有打包文件列表(是的,它也是被update-server-info所生成的);=>GETobjects/info/packsPpack-816a9b2334da9953e530f27bcac22082a9f5b835.pack
这里服务端只有一个打包文件,所以你要的对象显然就在里面。但是你可以先检查它的索引文件以确认。这在服务端有多个打包文件时也很有用,因为这样就可以先检查你所需要的对象空间是在哪一个打包文件里面了:=>GETobjects/pack/pack-816a9b2334da9953e530f27bcac22082a9f5b835.idx(4kofbinarydata)
现在你有了这个打包文件的索引,你可以看看你要的对象是否在里面-因为索引文件列出了这个打包文件所包含的所有对象的SHA值,和该对象存在于打包文件中的偏移量,所以你只需要简单地获取整个打包文件:=>GETobjects/pack/pack-816a9b2334da9953e530f27bcac22082a9f5b835.pack(13kofbinarydata)
现在你也有了这个tree对象,你可以继续在commit对象上漫游。它们全部都在这个你已经下载到的打包文件里面,所以你不用继续向服务端请求更多下载了。在这完成之后,由于下载开始时已探明HEAD引用是指向master分支,
Git会将它检出到工作目录。
整个过程看起来就像这样:$gitclonehttp://github.com/schacon/simplegit-progit.gitInitializedemptyGitrepositoryin/private/tmp/simplegit-progit/.git/gotca82a6dff817ec66f44342007202690a93763949walkca82a6dff817ec66f44342007202690a93763949got085bb3bcb608e1e8451d4b2432f8ecbe6306e7e7Gettingalternateslistfor http://github.com/schacon/simplegit-progit.gitGettingpacklistfor http://github.com/schacon/simplegit-progit.gitGettingindexforpack816a9b2334da9953e530f27bcac22082a9f5b835Gettingpack816a9b2334da9953e530f27bcac22082a9f5b835whichcontainscfda3bf379e4f8dba8717dee55aab78aef7f4dafwalk085bb3bcb608e1e8451d4b2432f8ecbe6306e7e7walka11bef06a3f659402fe7563abf99ad00de2209e6 这个HTTP方法是很简单但效率不是很高。使用智能协议是传送数据的更常用的方法。这些协议在远端都有Git智能型进程在服务-它可以读出本地数据并计算出客户端所需要的,并生成合适的数据给它,这有两类传输数据的进程:一对用于上传数据和一对用于下载。
智能协议为了上传数据至远端,Git使用
上传数据send-pack和receive-pack进程。这个send-pack进程运行在客户端上,它连接至远端运行的receive-pack进程。
举例来说,你在你的项目上运行了gitpushoriginmaster,并且origin被定义为一个使用SSH协议的URL。
Git会使用send-pack进程,它会启动一个基于SSH的连接到服务器。它尝试像这样透过SSH在服务端运行命令:$ssh-xgit@github.com"git-receive-pack'schacon/simplegit-progit.git'"005bca82a6dff817ec66f4437202690a93763949refs/heads/masterreport-statusdelete-refs003e085bb3bcb608e1e84b2432f8ecbe6306e7e7refs/heads/topic0000
这里的git-receive-pack命令会立即对它所拥有的每一个引用响应一行-在这个例子中,只有master分支和它的SHA值。这里第1行也包含了服务端的能力列表(这里是report-status和delete-refs)。
每一行以4字节的十六进制开始,用于指定整行的长度。你看到第1行以005b开始,这在十六进制中表示91,意味着第1行有91字节长。下一行以003e起始,表示有62字节长,所以需要读剩下的62字节。再下一行是0000开始,表示服务器已完成了引用列表过程。
现在它知道了服务端的状态,你的send-pack进程会判断哪些commit是它所拥有但服务端没有的。针对每个引用,这次推送都会告诉对端的receive-pack这个信息。举例说,如果你在更新master分支,并且增加experiment分支,这个send-pack将会是像这样:0085ca82a6dff817ec66f44342007202690a9376394915027957951b64cf874c3557a0f3547bd83b3ff6refs/heads/masterreport-status00670000000000000000000000000000000000000000cdfdb42577e2506715f8cfeacdbabc092bf63e8drefs/heads/experiment0000
这里的全’0’的SHA-1值表示之前没有过这个对象-因为你是在添加新的experiment引用。如果你在删除一个引用,你会看到相反的:就是右边是全’0’。
Git针对每个引用发送这样一行信息,就是旧的SHA值,新的SHA值,和将要更新的引用的名称。第1行还会包含有客户端的能力。下一步,客户端会发送一个所有那些服务端所没有的对象的一个打包文件。最后,服务端以成功(或者失败)来响应:000Aunpackok当你在下载数据时,
下载数据fetch-pack和upload-pack进程就起作用了。客户端启动fetch-pack进程,连接至远端的upload-pack进程,以协商后续数据传输过程。
在远端仓库有不同的方式启动upload-pack进程。你可以使用与receive-pack相同的透过SSH管道的方式,也可以通过
Git后台来启动这个进程,它默认监听在9418号端口上。这里fetch-pack进程在连接后像这样向后台发送数据:003fgit-upload-packschacon/simplegit-progit.git\0host=myserver.com\0
它也是以4字节指定后续字节长度的方式开始,然后是要运行的命令,和一个空字节,然后是服务端的主机名,再跟随一个最后的空字节。Git后台进程会检查这个命令是否可以运行,以及那个仓库是否存在,以及是否具有公开权限。如果所有检查都通过了,它会启动这个upload-pack进程并将客户端的请求移交给它。
如果你透过SSH使用获取功能,fetch-pack会像这样运行:$ssh-xgit@github.com"git-upload-pack'schacon/simplegit-progit.git'"
不管哪种方式,在fetch-pack连接之后,upload-pack都会以这种形式返回:0088ca82a6dff817ec66f44342007202690a93763949HEAD\0multi_ackthin-pack\side-bandside-band-64kofs-deltashallowno-progressinclude-tag003fca82a6dff817ec66f44342007202690a93763949refs/heads/master003e085bb3bcb608e1e8451d4b2432f8ecbe6306e7e7refs/heads/topic0000
这与receive-pack响应很类似,但是这里指的能力是不同的。而且它还会指出HEAD引用,让客户端可以检查是否是一份克隆。
在这里,fetch-pack进程检查它自己所拥有的对象和所有它需要的对象,通过发送“want”和所需对象的SHA值,发送“have”和所有它已拥有的对象的SHA值。在列表完成时,再发送“done”通知upload-pack进程开始发送所需对象的打包文件。这个过程看起来像这样:0054wantca82a6dff817ec66f44342007202690a93763949ofs-delta0032have085bb3bcb608e1e8451d4b2432f8ecbe6306e7e700000009done
这是传输协议的一个很基础的例子,在更复杂的例子中,客户端可能会支持multi_ack或者side-band能力;但是这个例子中展示了智能协议的基本交互过程。你时不时的需要进行一些清理工作──如减小一个仓库的大小,清理导入的库,或是恢复丢失的数据。本节将描述这类使用场景。
9.7维护及数据恢复Git会不定时地自动运行称为“autogc”的命令。大部分情况下该命令什么都不处理。不过要是存在太多松散对象(looseobject,不在packfile中的对象)或packfile,Git会进行调用
维护gitgc命令。gc指垃圾收集
(garbagecollect),此命令会做很多工作:收集所有松散对象并将它们存入packfile,合并这些packfile进一个大的packfile,然后将不被任何commit引用并且已存在一段时间(数月)的对象删除。
可以手工运行autogc命令:$gitgc--auto
再次强调,这个命令一般什么都不干。如果有7,000个左右的松散对象或是50个以上的packfile,Git才会真正调用gc命令。可能通过修改配置中的gc.auto和gc.autopacklimit来调整这两个阈值。gc还会将所有引用(references)并入一个单独文件。假设仓库中包含以下分支和标签:$find.git/refs-typef.git/refs/heads/experiment.git/refs/heads/master.git/refs/tags/v1.0.git/refs/tags/v1.1
这时如果运行gitgc,refs下的所有文件都会消失。Git
会将这些文件挪到.git/packed-refs文件中去以提高效率,该文件是这个样子的:$cat.git/packed-refs#pack-refswith:peeledcac0cab538b970a37ea1e769cbbde608743bc96drefs/heads/experimentab1afef80fac8e34258ff41fc1b867c702daa24brefs/heads/mastercac0cab538b970a37ea1e769cbbde608743bc96drefs/tags/v1.09585191f37f7b0fb9444f35a9bf50de191beadc2refs/tags/v1.1^1a410efbd13591db07496601ebc7a059dd55cfe9
当更新一个引用时,Git不会修改这个文件,而是在refs/heads下写入一个新文件。当查找一个引用的SHA时,Git首先在refs目录下查找,如果未找到则到packed-refs文件中去查找。因此如果在refs目录下找不到一个引用,该引用可能存到packed-refs文件中去了。
请留意文件最后以^开头的那一行。这表示该行上一行的那个标签是一个annotated标签,而该行正是那个标签所指向的commit。在使用Git的过程中,有时会不小心丢失commit信息。这一般出现在以下情况下:强制删除了一个分支而后又想重新使用这个分支,hard-reset了一个分支从而丢弃了分支的部分commit。如果这真的发生了,有什么办法把丢失的commit找回来呢?
数据恢复
下面的示例演示了对test仓库主分支进行hard-reset到一个老版本的commit的操作,然后恢复丢失的commit。首先查看一下当前的仓库状态:$gitlog--pretty=onelineab1afef80fac8e34258ff41fc1b867c702daa24bmodifiedrepoabit484a59275031909e19aadb7c92262719cfcdf19aaddedrepo.rb1a410efbd13591db07496601ebc7a059dd55cfe9thirdcommitcac0cab538b970a37ea1e769cbbde608743bc96dsecondcommitfdf4fc3344e67ab068f836878b6c4951e3b15f3dfirstcommit
接着将master分支移回至中间的一个commit:$gitreset--hard1a410efbd13591db07496601ebc7a059dd55cfe9HEADisnowat1a410efthirdcommit$gitlog--pretty=oneline1a410efbd13591db07496601ebc7a059dd55cfe9thirdcommitcac0cab538b970a37ea1e769cbbde608743bc96dsecondcommitfdf4fc3344e67ab068f836878b6c4951e3b15f3dfirstcommit
这样就丢弃了最新的两个commit──包含这两个commit的分支不存在了。现在要做的是找出最新的那个commit的SHA,然后添加一个指它它的分支。关键在于找出最新的commit的SHA──你不大可能记住了这个SHA,是吧?
通常最快捷的办法是使用gitreflog工具。当你(在一个仓库下)工作时,Git会在你每次修改了HEAD时悄悄地将改动记录下来。当你提交或修改分支时,reflog就会更新。git命令也可以更新reflog,这是在本章前面的“GitReferences”部分我们使用该命令而不是手工将SHA值写入ref文件的理由。任何时间运行
update-refgitreflog命令可以查看当前的状态:$gitreflog1a410efHEAD@{0}:1a410efbd13591db07496601ebc7a059dd55cfe9:updatingHEADab1afefHEAD@{1}:ab1afef80fac8e34258ff41fc1b867c702daa24b:updatingHEAD
可以看到我们签出的两个commit,但没有更多的相关信息。运行gitlog-g会输出reflog的正常日志,从而显示更多有用信息:$gitlog-gcommit1a410efbd13591db07496601ebc7a059dd55cfe9Reflog:HEAD@{0}(ScottChacon)Reflogmessage:updatingHEADAuthor:ScottChaconDate:FriMay2218:22:372009-0700thirdcommitcommitab1afef80fac8e34258ff41fc1b867c702daa24bReflog:HEAD@{1}(ScottChacon)Reflogmessage:updatingHEADAuthor:ScottChaconDate:FriMay2218:15:242009-0700modifiedrepoabit
看起来弄丢了的commit是底下那个,这样在那个commit上创建一个新分支就能把它恢复过来。比方说,可以在那个commit(ab1afef)上创建一个名为recover-branch的分支:$gitbranchrecover-branchab1afef$gitlog--pretty=onelinerecover-branchab1afef80fac8e34258ff41fc1b867c702daa24bmodifiedrepoabit484a59275031909e19aadb7c92262719cfcdf19aaddedrepo.rb1a410efbd13591db07496601ebc7a059dd55cfe9thirdcommitcac0cab538b970a37ea1e769cbbde608743bc96dsecondcommitfdf4fc3344e67ab068f836878b6c4951e3b15f3dfirstcommit
酷!这样有了一个跟原来master一样的recover-branch分支,最新的两个
commit又找回来了。接着,假设引起commit丢失的原因并没有记录在reflog中──可以通过删除recover-branch和reflog来模拟这种情况。这样最新的两个commit不会被任何东西引用到:$gitbranch-Drecover-branch$rm-Rf.git/logs/
因为reflog数据是保存在.git/logs/目录下的,这样就没有reflog了。现在要怎样恢复commit呢?办法之一是使用git工具,该工具会检查仓库的数据完整性。如果指定
fsck--ful选项,该命令显示所有未被其他对象引用(指向)的所有对象:$gitfsck--fulldanglingblobd670460b4b4aece5915caf5c68d12f560a9fe3e4danglingcommitab1afef80fac8e34258ff41fc1b867c702daa24bdanglingtreeaea790b9a58f6cf6f2804eeac9f0abbe9631e4c9danglingblob7108f7ecb345ee9d0084193f147cdad4d2998293
本例中,可以从danglingcommit找到丢失了的commit。用相同的方法就可以恢复它,即创建一个指向该SHA的分支。Git有许多过人之处,不过有一个功能有时却会带来问题:
移除对象gitclone会将包含每一个文件的所有历史版本的整个项目下载下来。如果项目包含的仅仅是源代码的话这并没有什么坏处,毕竟Git可以非常高效地压缩此类数据。不过如果有人在某个时刻往项目中添加了一个非常大的文件,那们即便他在后来的提交中将此文件删掉了,所有的签出都会下载这个
大文件。因为历史记录中引用了这个文件,它会一直存在着。
当你将Subversion或Perforce仓库转换导入至Git时这会成为一个很严重的问题。在此类系统中,(签出时)不会下载整个仓库历史,所以这种情形不大会有不良后果。如果你从其他系统导入了一个仓库,或是发觉一个仓库的尺寸远超出预计,可以用下面的方法找到并移除大(尺寸)对象。
警告:此方法会破坏提交历史。为了移除对一个大文件的引用,从最早包含该引用的tree对象开始之后的所有commit对象都会被重写。如果在刚导入一个仓库并在其他人在此基础上开始工作之前这么做,那没有什么问题──否则你不得不通知所有协作者(贡献者)去衍合你新修改的commit。
为了演示这点,往test仓库中加入一个大文件,然后在下次提交时将它删除,接着找到并将这个文件从仓库中永久删除。首先,加一个大文件进去:$curlhttp://kernel.org/pub/software/scm/git/git-1.6.3.1.tar.bz2>git.tbz2$gitaddgit.tbz2$gitcommit-am'addedgittarball'[master6df7640]addedgittarball1fileschanged,0insertions(+),0deletions(-)createmode100644git.tbz2
喔,你并不想往项目中加进一个这么大的tar包。最后还是去掉它:$gitrmgit.tbz2rm'git.tbz2'$gitcommit-m'oops-removedlargetarball'[masterda3f30d]oops-removedlargetarball1fileschanged,0insertions(+),0deletions(-)deletemode100644git.tbz2
对仓库进行gc操作,并查看占用了空间:$gitgcCountingobjects:21,done.Deltacompressionusing2threads.Compressingobjects:100%(16/16),done.Writingobjects:100%(21/21),done.Total21(delta3),reused15(delta1)
可以运行count-objects以查看使用了多少空间:$gitcount-objects-vcount:4size:16in-pack:21packs:1size-pack:2016prune-packable:0garbage:0size-pack是以千字节为单位表示的packfiles的大小,因此已经使用了2MB。而在这次提交之前仅用了2K左右──显然在这次提交时删除文件并没有真正将其从历史记录中删除。每当有人复制这个仓库去取得这个小项目时,都不得不复制所有
2MB数据,而这仅仅因为你曾经不小心加了个大文件。当我们来解决这个问题。
首先要找出这个文件。在本例中,你知道是哪个文件。假设你并不知道这一点,要如何找出哪个(些)文件占用了这么多的空间?如果运行gitgc,所有对象会存入一个packfile文件;运行另一个底层命令git以识别出大对象,对输出的第三列信息即文件大小进行排序,还可以将输出定向到
verify-packtail命令,因为你只关心排在最后的那几个最大的文件:$gitverify-pack-v.git/objects/pack/pack-3f8c0...bb.idx|sort-k3-n|tail-3e3f094f522629ae358806b17daf78246c27c007bblob1486734466705408d195263d853f09dca71d55116663690c27cblob12908347811897a9eb2fba2b1811321254ac360970fc169ba2330blob205671620568725401
最底下那个就是那个大文件:2MB。要查看这到底是哪个文件,可以使用第7章中已经简单使用过的rev-list命令。若给rev-list命令传入--objects选项,它会列出所有
commitSHA值,blobSHA值及相应的文件路径。可以这样查看blob的文件名:$gitrev-list--objects--all|grep7a9eb2fb7a9eb2fba2b1811321254ac360970fc169ba2330git.tbz2
接下来要将该文件从历史记录的所有tree中移除。很容易找出哪些commit修改了这个文件:$gitlog--pretty=oneline--git.tbz2da3f30d019005479c99eb4c3406225613985a1dboops-removedlargetarball6df764092f3e7c8f5f94cbe08ee5cf42e92a0289addedgittarball
必须重写从6df76开始的所有commit才能将文件从Git历史中完全移除。这么做需要用到第6章中用过的filter-branch命令:$gitfilter-branch--index-filter\'gitrm--cached--ignore-unmatchgit.tbz2'--6df7640^..Rewrite6df764092f3e7c8f5f94cbe08ee5cf42e92a0289(1/2)rm'git.tbz2'Rewriteda3f30d019005479c99eb4c3406225613985a1db(2/2)Ref'refs/heads/master'wasrewritten--index-filter选项类似于第6章中使用的--tree-filter选项,但这里不是传入一个命令去修改磁盘上签出的文件,而是修改暂存区域或索引。不能用rm命令来删除一个特定文件,而是必须用
filegitrm--cached来删除它──即从索引而不是磁盘删除它。这样做是出于速度考虑──由于Git在运行你的filter之前无需将所有版本签出到磁盘上,这个操作会快得多。也可以用--tree-filter来完成相同的操作。git的
rm--ignore-unmatch选项指定当你试图删除的内容并不存在时不显示错误。最后,因为你清楚问题是从哪个commit开始的,使用filter-branch重写自6df7640这个
commit开始的所有历史记录。不这么做的话会重写所有历史记录,花费不必要的更多时间。
现在历史记录中已经不包含对那个文件的引用了。不过reflog以及运行filter-branch时Git往.git/refs/original添加的一些
refs中仍有对它的引用,因此需要将这些引用删除并对仓库进行repack操作。在进行repack前需要将所有对这些commits的引用去除:$rm-Rf.git/refs/original$rm-Rf.git/logs/$gitgcCountingobjects:19,done.Deltacompressionusing2threads.Compressingobjects:100%(14/14),done.Writingobjects:100%(19/19),done.Total19(delta3),reused16(delta1)
看一下节省了多少空间。$gitcount-objects-vcount:8size:2040in-pack:19packs:1size-pack:7prune-packable:0garbage:0
repack后仓库的大小减小到了7K,远小于之前的2MB。从size值可以看出大文件对象还在松散对象中,其实并没有消失,不过这没有关系,重要的是在再进行推送或复制,这个对象不会再传送出去。如果真的要完全把这个对象删除,可以运行git命令。
prune--expire现在你应该对Git可以作什么相当了解了,并且在一定程度上也知道了Git是如何实现的。本章覆盖了许多plumbing命令──这些命令比较底层,且比你在本书其他部分学到的porcelain命令要来得简单。从底层了解Git的工作原理可以帮助你更好地理解为何Git实现了目前的这些功能,也使你能够针对你的工作流写出自己的工具和脚本。
9.8总结
Git作为一套content-addressable的文件系统,是一个非常强大的工具,而不仅仅只是一个VCS供人使用。希望借助于你新学到的Git内部原理的知识,你可以实现自己的有趣的应用,并以更高级便利的方式使用Git。
相关文章推荐
- Git详解之九:Git内部原理
- Git 详解之九 Git内部原理
- Git详解之九 Git内部原理
- Git详解之九 Git内部原理
- Git详解之九 Git内部原理
- Git详解之九 Git内部原理
- Git详解之九 Git内部原理
- 【转】Git详解之九 Git内部原理
- Git详解之九 Git内部原理
- Git详解之九 Git内部原理
- Git详解之九 Git内部原理
- Git详解之九 Git内部原理
- Git详解之九 Git内部原理
- Git详解之九 Git内部原理
- Git详解之九 Git内部原理
- Git详解之九 Git内部原理
- Git详解之九 Git内部原理
- Git详解之九 Git内部原理
- Git详解之九:Git内部原理
- Git详解之九 Git内部原理