卷2:第6章 Git

6.1. Git简述

Git实现了合作者们使用点对点网络的资料库来维护工作的数据主体(通常,但不仅限于,代码)。它支持分布式工作流,允许工作主体既能够最终合并,也能够临时分支。

这个章节将展示隐藏着的使Git运转的各个方面是如何做到这些的,以及它与其他版本控制系统(VCSs)的不同。

6.2. Git的起源

更好地了解Git的设计理念是有助于了解始于Linux内核社区的Git项目的情况的。

就当时大多数商业软件项目而言,Linux内核是不寻常的,这是因为有大量的提交者和频繁变化的贡献者参与,以及现有的代码库的知识。这个内核必须通过延续数年的tar包和补丁来维持,同时核心开发社区也努力寻找一个可以满足他们大部分需求的VCS。

在这些需求和困扰的促进之下,开源项目Git在2005年诞生了。当时Linux代码库是通过不同的核心开发人员来维护,分别由BitKeeper和CVS这两个VCS来管理。此时的BitKeeper提供了与流行开源VCSs不同的VCS历史记录系谱视图。

在BitKeeper的创造者BitMover宣布撤销一些Linux内核核心开发人员的许可证后几天,Linus Torvalds非常仓促地开始开发一个替代系统,他的工作最后演变成了Git。他首先写了一些脚本,来方便自己管理邮件上的补丁,并支持逐个应用这些补丁。这些最初的脚本的目的在于支持迅速地终止合并,以便维护人员可以编辑代码库中的补丁来进行手工合并,然后再继续合并后来的补丁。

从一开始,Torvalds的哲学目标就是反CVS,另外,他还有三个功能设计目标:

  • 支持类似BitKeeper的分布式工作流
  • 防止内容受损的安全措施
  • 高性能

在一定程度上,这些设计目标已经完成并维持下来。下面我将从多个方面来阐述这一点,包括Git基于有向无环图(DAGs)的存储,分支头的引用指针,对象模型的表达,远程协议,以及最后Git是如何追踪树的合并的。

尽管原始设计受到BitKeeper影响,但Git的实现方式是完全不同的,并且支持更多的分布式和纯本地工作流的功能,这是BitKeeper无法做到的。Git的早期开发过程中很有可能受到另外一个开源分布式VCS系统的启发,那就是始于2003年的Monotone

分布式版本控制系统给工作流程提供了极大的灵活性,但牺牲了一定的简洁性。分布式模型具体的好处包括:

  • 提供合作者们离线工作和增量提交的能力。
  • 允许合作者来决定何时准备分享他/她的工作。
  • 提供合作者离线访问仓库历史记录。
  • 允许被管理的工作发布到多个仓库,并潜在地支持不同的分支或者可见变化的粒度。

在Git项目差不多开始的时候,另外三个开源的分布式VCS项目也启动了。(其中一个,Mercurial,在《开源软件架构》的卷1中讨论。)所有这些dVCS工具都提供几乎同样的方法来实现高度灵活的工作流程,这是此前集中式VCSs无法直接处理的。注:Subversion有一个扩展应用叫做SVK,是由不同的开发人员所维护,可以支持服务器到服务器的同步。

现在,比较流行并被积极维护的开源dVCS项目包括Bazaar, Darcs, Fossil, Git, Mercurial, 和Veracity。

6.3. 版本控制系统设计

现在,我们不如后退一步,考虑Git有没有其它的替代VCS方案。理解不同设计之间的差别,可以让我们探索一下开发Git时所面临的架构选择难题:

一个版本控制系统通常有三个核心功能需求,即:

  • 内容存储
  • 内容变更跟踪(历史记录,包括合并元数据)
  • 向合作者分发内容和历史记录

注:第三项不是VCS必需的功能。

内容存储
在VCS世界里内容存储的最常见的设计选择是基于增量的变更,或者用有向无环图(DAG)的内容描述。

基于增量的变更封装了两个版本的内容之间的差异,并记录了一些元数据。使用有向无环图描述内容需要将对象组织成一个层次结构,将内容的目录树映射成一次提交的快照(尽可能地重用树中的未修改对象)。Git采用有向无环图和不同的对象类型来存储内容。稍后本章中的“对象数据库”一节会描述这些在Git仓库内构成有向无环图(DAG)的不同的对象类型。

** 提交和合并的历史记录 **
大多数VCS软件在历史记录和变更跟踪方面会使用如下方法之一:

  • 线性的历史记录
  • 历史记录的有向无环图

Git再次使用了有向无环图(DAG),只不过这次是用来存储历史记录。每次提交都包含有关提交前内容的元数据;在Git里的一交提交可以有零个或者多个(理论上没有上限)的父提交。举例来说,第一次在Git仓库里的提交有零个父提交,三个分支合并的结果会有三个父提交。

与Subversion及其线性历史记录相比,Git的另一个主要不同在于直接支持分支,并记录大部分合并历史场景。

enter image description here

图6.1:Git中DAG描述的例子

使用有向无环图存储内容,Git支持完整的分支功能。一个文件的历史记录向上延路径关联到根目录(通过节点表示目录),根目录再被关联到一个提交节点。这个提交节点又可能有一个或多个父提交。相比于源自RCS的VCS家族,Git支持以更确定的方法来推断历史记录和内容,因为Git获益于上述有向图结构所带来的两个特性,即:

  • 当在图中的内容节点(也就是,文件或目录 )在不同的提交中有相同的引用特征(Git中的SHA)时,这两个节点被确保包含了相同的内容,因此Git可充分利用这种冗余性来提高存储效率。
  • 当合并两个分支时,我们在DAG中合并两个节点的内容。DAG支持Git“高效地”(相对于RCS的VCS家族)决定公共的祖先节点。

分发
VCS使用如下三种方式之一来向项目合作者分发工作副本:

  • 纯本地:针对不包括前面所说的第三个功能需求的VCS解决方案。
  • 集中式服务器: 所有仓库变更必须在一个特定的仓库中处理,以便在那里存储所有的历史记录。
  • 分布式模型: 通常有公共的仓库供合作者“推送”内容,不过提交可以在本地进行并且可稍后再推送到公共节点,因此支持离线工作。

为了展示这每一种主要设计方案的优越点,我将比较具有同样内容(比如,Git默认分支的HEAD与Subversion中trunk的最新版本的内容相同)的一个Subversion仓库和一个Git仓库(在一个服务器上)。 一个名叫Alex的开发人员,有一个Subversion仓库的本地签出和Git仓库的本地克隆。

让我们假定Alex对Subversion签出中对一个1M大小的文件做了变更,然后提交这个变更。在本地,这个文件的签出模拟了最近的变更,同时本地元数据被更新。在Alex向集中式Subversion仓库的提交过程中,一个差异(diff)在变更前的文件快照与新的变更之间产生,然后这个差异被存储到仓库中。

与这相对比,再看看Git是如何工作的。当Alex对本地Git克隆里对应的文件进行同样的编辑时,这个变更将首先被记录在本地,接着Alex可以将这个本地待处理的提交的“推送”到一个公共仓库,然后他的工作就能和项目 中的其他合作者们分享了。这次内容的变更被在所有的Git仓库中都是唯一可识别的。在本地提交时(最简单情形),本地Git仓库将为此修改后文件创建一个新的文件描述对象(包含了文件的所有内容)。在此被修改文件之上的每个目录(加上仓库的根目录),都会被创建一个新的树对象和标识符。从新创建的树根对象到叶子节点上的blob,构成了一个新的有向无环图(DAG) (如果blob对应的文件没有被修改,那此blob会被重用) ,此DAG引用的是替代旧blob的新的blob(blob指的是文件在仓库中存储形式)。

此时,这个提交仍然位于Alex的本地设备的本地Git克隆上。当Alex“推送”这个提交到一个公共可访问的Git仓库中时,这个提交才真正地被存储到那个仓库中。在这个公共仓库验证这个提交可应用到这个分支后,同样的这些对象(在本地创建)被存储到了公共仓库中。

在Git的场景中,有更多的移动环节,部分隐藏起来部分面向用户。用户需要显式地表达自己的意图,来与远程仓库共享内容的修改,而共享过程与本地的变更跟踪过程是分离的。虽然增加了复杂性,但是,正如前面“Git起源”一节中所描述的那样,这种设计显著增强了项目团队在工作流和发布能力方面的灵活性。

在Subversion场景中,当项目合作人员准备让他人查看更改时,他们并不是非要记得推送到远程公共仓库。如果对一个大文件作的一点小的修改被发送到中心版本控制仓库,显然,与存储每个版本的完整文件相比,增量地存储可获得更高的存储效率。然而,接下来我们会看到,Git在某些场合中可以改善这个问题。

6.4 工具包

现在,Git的生态系统中包含许多命令行和UI工具,它支持众多的操作系统(包括Windows, 原来并不支持)。大部分这些工具主要是基于Git的核心工具包。

由于Git最初由Linux编写,并启动于Linux社区,因此它的工具包的设计哲学与Unix传统命令行工具非常类似。

Git工具包可分为两个部分:plumbing和porcelain。plumbing包含低级命令,负责基本的内容跟踪和有向无环图的操纵。porcelain是git一个较小的命令行子集,支持Git终端用户操纵仓库和仓库间的协作通信这些常用功能。

虽然工具包的设计为细粒度使用Git功能提供了足够的命令,满足了脚本使用者的需求,但是应用开发者却在抱怨缺乏可链接的Git程序库。因为Git的二进制程序调用了die(),这是不可再进入的,因此GUI程序,网页界面程序或长时运行的服务必须重复(fork/exec)对Git二进制程序的调用,这自然很慢。

应用开发者所面对的这种情况已经得到改善,详情请参见"现在和将来工作"这一节的内容。

6.5 仓库,索引和工作区

让我们练练手,在本地使用Git,只求能够理解几个基本的概念。

首先,在本地文件系统上创建一个新的Git仓库(使用一个类Unix操作系统),我们可以执行:

$ mkdir testgit
$ cd testgit
$ git init

现在,我们有了一个空的初始化了的Git仓库,位于testgit目录中。我们可以建立分支,提交,生成标签,甚至与其它本地或远程的Git仓库通信。只需要使用少数git命令,你甚至可以与其它类型的VCS仓库通信。

git init命令在testgit中创建了一个.git子目录。让我们看一下里面有什么:

tree .git/
.git/
|-- HEAD
|-- config
|-- description
|-- hooks
|   |-- applypatch-msg.sample
|   |-- commit-msg.sample
|   |-- post-commit.sample
|   |-- post-receive.sample
|   |-- post-update.sample
|   |-- pre-applypatch.sample
|   |-- pre-commit.sample
|   |-- pre-rebase.sample
|   |-- prepare-commit-msg.sample
|   |-- update.sample
|-- info
|   |-- exclude
|-- objects
|   |-- info
|   |-- pack
|-- refs
|-- heads
|-- tags

上面的.git目录默认是根工作目录(testgit)的一个子目录,它包含了一些不同类型的文件和目录:

  • 配置(Configuration): .git/config, .git/description.git/info/exclude文件是基本的配置本地仓库的配置文件。
  • 勾子(Hooks): .git/hooks目录包含了可以运行在仓库生命周期事件中的一些脚本。
  • 缓存区域(Staging Area): .git/index文件(没有在上面列出的目录树中列出来)为工作目录提供了一个缓存区域。
  • 对象数据库(Object Database): .git/objects目录是默认的Git对象数据库, 包含了所有的内容或本地内容的指针。所有对象一旦生成了就不可修改。
  • 引用(References): .git/refs目录是存储引用指针的默认位置,引用指针包括本地或远程分支(branch),标签(tag)和头(head)的引用。 引用(reference)是指向一个对象的指针,这个对象通常是标签(tag)或提交(commit)类型。引用在对象数据库(Object Database)之外管理,以支持在仓库进化时对它们的引用位置进行修改。还有一种特殊的引用是对其它引用的引用,比如HEAD

.git目录是事实上的仓库。包含了工作文件的目录是工作目录,通常是.git目录(或仓库)的父目录。如果你创建一个Git远程仓库,其中并没有工作目录,你可以用git init --bare命令来初始化。这只会在根目录中创建简化版的仓库文件,而不会在工作树中创建一个作为仓库的子目录。

另一个关于Git索引(index)的重要文件是:.git/index。它提供了本地工作目录和本地仓库之间的缓存区域(staging area)。索引用来缓存(stage)一个文件(或多个)中的特定修改,可以打包一起提交。即使你做了各种各样的修改,提交可以让它们看起来像一起生成的修改,从而可以在提交信息中以更有逻辑的方式来描述。为了选择性地缓存(stage)一个(或多个)文件中的特定修改,你可以使用git add -p命令。

Git索引(index)默认被存储于单个文件中,存于仓库目录之中。这三个区域的路径可以使用一些环境变量来定制。

理解这三个区域(仓库,索引和工作区)之间的交互对于执行一些核心的Git命令是很有好处的。

  • git checkout [branch] 此命令将本地仓库的HEAD引用指向一个分支引用路径(比如refs/heads/master),在索引(index)中填写head数据,并刷新工作目录以表达那个头(head)的树。

  • git add [files] 这些文件在Git索引(index)被指定了相应的条目,此命令将交叉引用这些文件的校验和,看缓存文件的索引是否需要更新到工作目录的版本。在Git目录(或仓库)中不会发生任何变化。

让我们检查一下.git目录(或仓库)中的内容,分析一下这条命令的具体含义。

$ GIT_DIR=$PWD/.git
$ cat $GIT_DIR/HEAD
ref: refs/heads/master
$ MY_CURRENT_BRANCH=$(cat .git/HEAD | sed 's/ref: //g')
$ cat $GIT_DIR/$MY_CURRENT_BRANCH
cat: .git/refs/heads/master: No such file or directory

我们得到了一个错误,因为,在向仓库作出任何提交之前,除了Git中的默认分支(master,主分支),并没有其它分支存在(主分支也可能不存在)。

现在,如果我们作出新的提交,会默认创建主分支(master branch)。让我们开始吧(在同一个shell中继续执行,以保持之前的历史和上下文):

$ git commit -m "Initial empty commit" --allow-empty
$ git branch
* master
$ cat $GIT_DIR/$MY_CURRENT_BRANCH
3bce5b130b17b7ce2f98d17b2998e32b1bc29d68
$ git cat-file -p $(cat $GIT_DIR/$MY_CURRENT_BRANCH)

这里,我们开始看到的是Git的对象数据库中的内容的表达形式。

6.6 对象数据库

图6.2 Git对象类图

Git有四种基本对象,来表达本地仓库中的每一种内容类型。每一种对象类型都有如下属性:类型,大小和内容。基本对象类型包括:

  • 树(Tree): 树中的一个元素可以是另一棵树或一个blob,树表示的是一个内容目录。
  • Blob: 一个blob表达存储于仓库中的一个文件。
  • 提交(Commit): 一次提交指向一棵树,该树表示此次提交(以及父提交和一些标准属性)的顶层目录。
  • 标签(Tag): 一个标签有一个名称,并指向仓库历史中标签所表示时间的一次提交。

所有基本对象都用一个SHA值来引用,这是一个40个数字的对象标识符,它有如下属性:

  • 如果两个对象完全一样,则拥有同样的SHA。
  • 如果两个对象不相同,它们拥有不同的SHA。
  • 如果一个对象只是部分拷贝或数据发生损坏,重新计算的SHA值会表现出不同。

SHA的头两个属性用于唯一标识对象,这在Git的分布式模型(Git的第2个目标)中最为有用。最后一个属性可支持对内容损坏的检测(Git的第3个目标)。

尽管基于DAG的存储方案在内容存储和合并历史方面的巨大的吸引力,但是它的松散对象的存储效率明显不如基于增量存储的其它版本管理系统。

6.7 存储和压缩技术

Git将对象打包压缩从而处理存储空间的问题,然后使用索引来定位对象在打包文件中的位置。

图 6.3: 附带相应索引文件的打包文件示意图

我们可以数出松散对象(未打包)在本地Git仓库中的个数,命令是git count-objects。现在,我们可以让Git在对象数据库中将这些松散对象打包,移除已经打包过的对象,必要时还可以通过Git的plumbing命令找到冗余的打包文件。

现在的打包文件格式已有一些变化。最初的打包文件存储的是打包文件和索引文件的CRC校验和,但这可能会出现不能检测到的压缩文件的内容损坏,因为重新打包过程并没有再次接受检查。第2版的打包文件格式克服了这个问题,方案是将每个被压缩对象的CRC校验和存于索引文件中。第2版还支持大于4G比特大小的打包文件,而原来的格式并不支持。为了快速检测到打包文件是否损坏,在打包文件末尾包含了一个20比特的SHA1值,它是此文件中所有SHA码的有序列表的检验和。新打包文件格式的主要重点在于帮助实现Git的第2个功能设计目标,即防止内容受损。

在远程通信中,Git计算出需要发送的提交和内容,然后通过网络将其同步到仓库中(或一个分支),然后立即产生打包文件格式,并使用所需的客户端协议将其发送回来。

6.8 合并历史

正如前面提到的,Git在合并历史记录的方法上和RCS族VCS有着本质的不同。比如,Subversion使用线性进度来表示文件或目录树的历史记录,任何高版本的记录都会取代前面的记录。它不直接支持分支功能,只能在仓库中使用一种非强制性的目录结构。

图 6.4: 线性融合历史示意图

让我们先用一个例子来提示这种方法在维护多个分支时会出现哪些问题。然后,我们会在一个场景中表现出其局限性。

当我们在Subversion的一个典型的branches/branch-name的"分支"上工作时,我们其实是在trunk(典型的活跃代码或主要代码的开发场所)临近的一个子目录树上工作。假设这个分支是与trunk目录树并行开发。

比如,我们正在重写一个代码库,以便使用一个不同的数据库。重写过程中,我们希望从另外一个上游的分支子树(不是trunk)中合并一些修改。我们就合并了这些修改,必要时使用手动的方式,然后继续重写过程。后来,我们完成了branches/branch-name的数据库生产商的代码移植,并将我们作出的修改合并到trunk中去。Subversion使用的是线性历史记录,因此,从它的历史记录中无法看出将修改从另一个分支合并到trunk中的这个过程。

基于DAG的合并历史记录VCS系统,比如Git,可以很合理地处理这种情况。假设另外那个分支并不包含还没有合并到我们的数据库生产商移植分支(比如,Git仓库中的db-migration)中的那些提交,从提交的父子关系中,我们可以确定db-migration分支上的一个提交包含了这个上游分支的tip(或HEAD)。注意,一个提交对象可以有0个或多个父提交。因此,db-migration分支上的合并提交通过父亲提交们的SHA哈希值可以知道它合并了当前分支的当前HEAD,以及另一个上游分支的HEAD。主(master)分支(Git的master对应于Subversion中的trunk)中的合并提交同样可以做到这一点。

使用基于DAG(以及线性方法)的合并历史记录方法都难以解决的一个问题是确定每个分支都包含了哪些提交。比如,在上面的场景中,我们假定将两个分支中的所有的修改都合并到了每个分支中。而实际情况可能并非如此。

对于更简单的场合中,Git能够优选(cherry pick)出来自其它分支中的提交,然后合并到当前分支中(假定提交可以容易地应用到分支上)。

6.9 下一步?

前面提到了,Git核心是基于Unix的工具包设计哲学的,这对于脚本非常方便,但是对于嵌入到长时运行的应用或服务却难以胜任。虽然Git现在已经得到众多流行集成开发环境(IDE)的支持,但是相比于其它提供了跨平台动态链接库的VCS相比,这种支持和维护明显更具有挑战性。

为了解决这个问题,Shawn Pearce(来自Google的开源项目办公室)首先创建一个可链接的Git库,而且使用许可相对自由。这被称为是libgit2。然而,直到去年,一个名为Vincent Marti的Google暑期学校学生选择使用之后,这个库才开始被人重视。自此以后,Vincent和Github的工程师们不断地为libgit2项目做出贡献,并为之添加了大量的其它语言的绑定,比如Ruby, Python, PHP, .NET, Lua,以及Objective-C。

Shawn Pearce另外还启动了一个基于BSD许可证的称为JGit的纯Java库,它支持许多对Git仓库的常用操作。现在,这个项目由Eclipse基金会维护,并被整合到Eclipse IDE。

在Git核心项目之外,还有一些有意思的和实验性的开源项目,它们使用了不同的后台数据存储。这些软件包括:

  • jgit_cassandra, 使用Apache Cassandra来保存Git对象,在BigTable列数据模型环境下使用Dynamo风格的分布式混合存储。
  • jgit_hbase, 支持对存储在HBase(一种分布式数据库)的Git对象进行读写操作。
  • libgit2-backends,从libgit2出发,为不同的流行数据库提供GIt对象的后台数据支持。支持的数据库包括Memcached, Redis, SQLite和MySQL。

所有这些开源项目都在Git核心项目之外维护。

你已经看到了,现在使用Git格式的方式是很多的。Git的面貌已经焕然一新,不仅仅只是命令行,而变成一种仓库格式和仓库间共享的协议。

到撰写此文为止,据它们的开发者所说,大部分这些项目都还没发布稳定的版本,因此这个领域里需要做的工作还有很多,但Git的前途无疑是光明的。

6.10 经验与教训

在软件领域,每个设计决定最后都变成了一种权衡。作为一个Git的资深用户,以及围绕Git对象数据库模型开发过软件的开发者,我对于Git当前的形式非常着迷。因此,这里所说的教训更多的是反映Git核心开发者关注重点和设计决策导致的一些常见的抱怨。

来自开发者和管理人员的一个最常见的抱怨是Git缺乏其它VCS工具所具备的IDE的整合支持。相对于其它现代VCS和相关工具,Git的工具包设计使得整合到IDE中更为困难。

早期的Git中的一些命令甚至是用shell脚本实现的。这些shell脚本命令降低了Git的可移植性,特别是对于Windows平台。我确信Git的核心开发者不会因为这个问题失眠,但是这会让Git因为早期的可移植性问题而在大型组织中失去吸引力。现在,一个称为Git for Windows的项目已经由志愿者启动,以便让Git可以即时地移植到Windows平台上。

Git围绕工具包设计的一个非直接后果是Git中大量的管道(plumbing)命令会让新用户很快迷失方向。他们会对所有的子命令迷惑不已,因为一个低级管道(plumbing)任务失败了,用户就可能无法理解其错误信息,这种让新用户抓狂的地方太多了。因此这让一些开发团队难以采用Git。

即便有这些关于Git的抱怨,我仍然对Git核心项目未来的发展前景以及所有这些相关的项目感到高兴。