卷1:第12章 Mercurial

作者:Dirkjan Ochtman

译者:谢路云

状态:完成

原文链接:http://www.aosabook.org/en/mercurial.html

Mercurial是一个现代分布式版本控制系统(VCS),主要由Python语言编写,以及一小部分C代码,以提高性能。在本章中,我会讨论Mercurial设计上的一些关于算法和数据结构的决策。首先,请允许我简短的回顾一下版本控制系统的历史,介绍一些必要的背景知识。

12.1.版本控制简史

虽然这一章的主要内容关于Mercurial的体系结构,但其中的许多思想和其他版本控制系统是共通的。为了更好的讨论,我想先说明一些存在于不同版本控制系统中的概念和行为。为了恰当的说明这一切,我还将简单的介绍一下这个领域的历史。

版本控制系统的发明是为了帮助软件系统的开发人员并行的工作,而不必相互传递文件的拷贝并人工的记录文件的修改历史。我们可以将软件的源代码文件扩展到任意文件树。版本控制的主要功能之一就是传递树的变化。工作流程的基本循环是这样的:

  1. 从其他人处获取最新的文件树
  2. 对这个版本的树进行一系列修改
  3. 发布并使其他人可以获得这些修改

第一个动作,也就是获取一份本地的文件树,被称为“检出”(checkout)。我们获取和发布所有修改的地方叫做“版本库”(repository),而检出得到的目录则被称为“工作目录”、“工作树”或是“工作拷贝”。用版本库中的最新文件更新工作拷贝的动作就叫做“更新”(update)。有时候这还会涉及到“合并”(merge),也就是组合不同用户对同一个文件作出的修改。diff命令使我们能够查看树或文件在两个版本之间的变化,它最常见的用途是检查你的工作目录中的本地(未发布的)修改。修改的发布是通过一个“提交”(commit)命令完成的,它会将工作目录的改变保存到版本库中去。

12.1.1.集中式版本控制

史上第一个版本控制系统叫做“源代码控制系统”(Source Code Control System, SCCS),出现于1975年。它的主要功能是将差异保存在文件中,这比保存一个文件的多个版本的完整拷贝更经济,但它无法将这些差异传播给其他人。它的继任是1982年出现的“修订控制系统”(Revsion Control System, RCS)。RCS是SCCS更加先进并且免费的替代品(它至今仍在GNU项目的维护之下)。

RCS之后是“并行版本系统”(Concurrent Versioning System, CVS),发布于1986年。它最初是一组批量处理RCS修订文件的脚本。CVS最大的创新是实现了多个用户同时编辑相同的文件并在最后合并所有的修改(并行编辑)的模式。这也产生了“编辑冲突”的概念。开发者提交的新版本文件必须基于版本库的最新版本之上。如果版本库和我的工作目录都对文件作出了修改,那么我必须解决这些修改所产生的所有冲突(修改了同一行的情况)。

CVS也开创了“分支”(branch)以及“标签”(tag)的概念。分支使得开发者能够并行的工作在不同的任务之上,标签则可以用来标记版本库的一个快照以便引用。虽然CVS一开始是通过将版本库放在共享文件系统上来传递差异的,但随后CVS实现了C/S架构以适应大型网络中的应用(例如因特网)。

在2000年,三位开发者为了纠正CVS中的设计缺陷一同完成了一个新的版本控制系统,叫做Subversion。Subversion最主要的特点将工作树作为一个整体对待。也就是说,每个修订所作出的变更都应该具有原子性、一致性、隔离性和持久性。Subversion能够在工作目录中记录工作拷贝的检出版本,这样常用的diff操作(比较本地的工作树和检出的版本)就能更快的在本地进行。

Subversion的一个有趣的概念是标签和分支都是项目树的一部分。一个Subversion项目通常分为三个部分:tagsbranchestrunk。事实证明这种设计对于不熟悉版本控制系统的用户非常直观,尽管这种设计的天然灵活性也为各种版本库转换工具带来了大量问题,大部分是因为在其他版本控制系统中,tagsbranches有更加结构化的表示方法。

以上提到的都是“集中式”的版本控制系统。也就是说,尽管它们知道如何交换变更(从CVS开始),但是它们仍然依赖于另外一台计算机来记录版本库的历史。而“分布式”的版本控制系统则会在存在工作拷贝的每台计算机上都保存版本库的完整或是部分历史。

12.1.2.分布式版本控制

尽管Subversion相比CVS有了明显的进步,但它仍有许多缺点。首先,在所有的集中式版本控制系统中,由于版本库的历史集中在同一个地方,变更集的提交和发布实际上是一回事,这也意味着在没有网络的情况下是无法提交变更的。其次,在集中式版本控制系统中,访问版本库总是需要一次或者多次的网络请求,比分布式版本控制系统中的本地访问要慢的多。再次,以上提到的所有版本控制系统都不擅长于记录合并(随着系统的改进,有些能够支持)。在有许多人并行工作的大型团队之中,版本控制系统必须能够记录每个新的修订版本都包含了哪些变更,这样才能保证不丢失任何工作,并且后续的合并也能使用这些信息。第四,传统版本控制系统的集中特性有时非常别扭,它强迫你只能在一个地方进行集成。分布式版本控制的提倡者认为,使用分布式系统的团队的组织更加自然,开发者们能够根据项目的需要在任何推送和集成变更。

为了满足这些需求,已经出现了许多新的工具。我(从开源世界的角度)认为,2011年中最重要的三个当属Git、Mercurial和Bazaar。Git和Mercurial都始于2005年,当时Linux内核的开发者们决定不再继续使用专有系统BitKeeper。两者都是由Linux内核开发者发起的(分别是Linus Torvalds和Matt Mackall),以满足对能够处理上万文件的成百上千个变更集进行管理的版本控制系统的需求。Matt和Linus都深受另一个版本控制系统Monotone的影响。同一时期的Bazaar的开发则相对独立,但在被Canonical采纳为所有项目的版本控制系统之后也得到了很广泛的使用。

构建一个分布式的版本控制系统显然会遇到许多挑战,其中一部分是所有分布式系统所固有的。例如,在集中式系统中源代码控制服务器总是保存着一份统一的版本历史,但在分布式系统中是不存在统一的版本历史的。分布式系统允许并行的提交变更,这使得在任何版本库中按照时间将修订历史排序都是不可能的。

几乎所有的系统都采用了有向无环图(DAG)而非线性的变更集方式组织来解决这个问题( 图12.1 )。也就是说,新提交的变更是其基础版本的子版本,且不可能有任何版本的基础是自己或是自己的子嗣。在这个方案中,我们有三种特殊类型的修订版本: 没有父版本的“根版本”(root revision),一个版本库可以有多个根版本),有一个以上父版本的“合并版本”(merge revision),和没有子版本“头版本”(head revision)。每个版本库都是从一个空的根版本开始不断产生一系列变更集,最后得到一个或者多个头版本。当两个用户分别独立的提交了变更且其中一个人希望从另一个人那里拉取(pull)变更集时,他必须明确的将另一个人的变更合并从而得到一个新的版本,这个版本的提交将得到一个合并版本。

修订版本的有向无环图

图12.1:修订版本的有向无环图

需要注意的是,DAG模型有助于解决一些在集中式版本控制系统中很难解决的问题:合并修订的作用是记录DAG中被合并的分支的信息。合并的结果图也能很好的展示出大量并行的分支是如何通过合并缩减并最终得到一根特殊的“主干”的。

这种设计要求系统记录变更集之间的谱系关系。为了简化变更集数据的交换,这一般通过由每个变更集记录它们的父变更集完成。要做到这一点,每个变更集显然还需要某种标识符。部分版本控制系统使用UUID或是某种类似的机制,Git和Mercurial使用的则是变更集的内容的SHA1哈希值。这样做的额外收获是能够用变更集的ID和内容相互验证。事实上,由于父节点的信息也被包含在哈希值中,任意版本的所有历史信息都能够由它的哈希值验证。作者姓名、提交信息、时间戳和其他变更集的元数据也和每个新版本的实际文件变更内容一样进行了哈希运算,所以它们也是可被验证的。而且,由于时间戳是在提交时记录的,所以它们在版本库中完全可以是非线性的。

所有这一切对于只有过集中式版本控制系统经验的人来说都难以适应:只有一个40个字符的十六进制字符串而非一个友好的整数来全局标识一个修订版本。此外,所有人之间的版本不再是统一有序的了,只有本地的版本是有序的。统一有序的表示只存在于一张有向无环图,而不再是一条直线。如果你已经习惯了集中式系统在当你向一个已经拥有子版本的版本进行提交时给出警告,那么在分布式系统中这么做所意外产生的一个新的头版本可能会让你感到困惑。

幸运的是,有一些工具可以帮助我们将版本树的次序可视化,Mercurial可以将变更集的哈希值变为一个无歧义的简短版本并提供一个本地的线性数字版本号来帮助用户识别版本信息。整数版本号是单调递增的,它标识的是本地版本库中变更集的产生顺序。由于这个顺序在不同的本地版本库之间是不同的,因此不能依赖它进行非本地的操作。

12.2.数据结构

现在,在对DAG的概念有了一定的了解后,我们来看看Mercurial是如何存储DAG的。DAG模型是Mercurial内部的核心。实际上我们在版本库的磁盘存储(以及运行在内存中的代码结构)中使用了多个不同的DAG。本节将说明它们分别起什么作用以及它们是如何组合在一起的。

12.2.1.面临的挑战

在我们深入实际的数据结构之前,我想先介绍一下Mercurial成长的大环境。Matt Mackall在2005年4月20日发送到Linux内核邮件列表的一封邮件中第一次提到了Mercurial。这是在大家决定不再使用BitKeeper进行内核开发之后不久的事情。Matt在他的邮件中描述了Mercurial的几大目标:简单、高效和可扩展。

在[Mac06]中,Matt认为一个现代的版本控制系统必须能够处理百万级别的文件和版本,并能够容纳数千用户在几十年中不断的提交新的版本。在明确了目标之后,他评估了一些可能成为瓶颈的技术因素:

  • 速度:CPU
  • 容量:磁盘和内存
  • 带宽:内存,局域网,磁盘和广域网
  • 磁盘定位操作的频率(seek操作)

磁盘定位操作的频率和广域网带宽在今天仍然是性能的限制因素,应该为它们进行优化。文章继续说明了一些用于在文件层面评估系统性能的常见的场景或标准:

  • 存储压缩:最适合将文件历史保存在磁盘上的压缩方法是什么?或者说,哪种算法在不占满CPU的前提下能够将I/O性能最大化?
  • 获取任意文件版本:许多版本控制系统保存修订方式使得它们必须要读取大量旧的版本信息方能重现一个新版本(也就是仅保存差异)。我们希望这种情况得到控制并保证快速获取旧的修订版本。
  • 新增文件版本:每天都会有新的版本产生。我们不希望在添加新版本时需要重写旧版本,因为在有很多版本时这将非常慢。
  • 显示文件的版本历史:我们希望能够检查和任意文件相关的所有变更集的历史。这也使得我们可以实现“注释”(annotate)功能(它在CVS中被称为blame,但在其他一些版本控制系统中被更名为annotate以消除其暗示的负面含义):检查当前文件中的每一行来自于哪个变更集。

文章随后继续在项目层面解释了一些类似的场景。在这个层面,基本的操作包括检出某个版本,提交一个新版本,以及检查工作目录中的所有修改。特别是最后这个操作,在文件树很大时会比较慢(例如Mozilla和NetBeans的项目,它们都使用了Mercurial进行版本控制)。

12.2.2.版本的快速保存:revlog

Matt为Mercurial想出的解决方案叫做revlog(版本记录(revision log)的简写)。revlog是一种保存多个版本的文件内容的高效方法。基于上一节所描述的场景,它需要同时保证访问时间(为磁盘读写做优化)和存储空间的效率。要做到这一点,revlog实际上由磁盘上的两个文件组成:一个索引文件和相应的数据文件。

6个字节 块偏移量
2个字节 标志
4个字节 块长度
4个字节 未压缩的长度
4个字节 基准版本
4个字节 链接版本
4个字节 第一父节点版本
4个字节 第二父节点版本
32字节 哈希值

表12.1:Mercurial的记录格式

索引文件的内容由定长的记录组成,格式见表12.1 。定长记录意味着可以用本地版本号直接(常数时间内)访问到该版本:只需要直接读取索引文件的正确位置(索引文件总长度 - 记录长度 * 版本号)就可以获取记录数据。将数据和索引分离也意味着我们无需遍历磁盘上的数据文件就能够快速的读取索引数据。

“块偏移量”和“块长度”指定了数据文件中对应于该版本的压缩数据。要获得原始数据,我们需要首先读取基准版本,然后根据多个差异得到该版本。这里的困难在于应该在何时存储一个基准版本。这取决于多个差异的总大小和该版本在压缩之前的数据大小之比(为了节省磁盘空间数据会用zlib进行压缩)。通过用这种方式限制差异链的长度,我们可以确保重建指定版本的数据无需读取和使用大量的差异数据。

链接版本的作用是指向该revlog的所依赖的最高层级的revlog(我们之后会详细说明这一点(译者注:作者后来真的忘记说了……)),父版本字段保存的是本地的数字版本号。同样,这使得在相关revlog中查找它们的数据很简单。哈希值字段保存的是该变更集的唯一标识符。我们为它分配了32个而非SHA1所需的20个字节,以满足未来的扩展需求。

12.2.3.三种revlog

以通用的revlog结构保存历史数据作为基础,我们可以构造文件树的数据模型。它由三种revlog组成:“变更记录”(changelog)、“声明记录”(manifest)和“文件记录”(filelog)。变更记录含有每个版本的元数据以及一个指向声明记录(即声明记录中的相应版本的节点id)的指针。相应的,声明记录文件含有一列文件名以及每个文件的节点id,这个id指向该文件在文件记录中的相应版本。在代码中,变更记录、声明记录和文件记录都是通用的revlog类的子类,这样可以清晰而有层次的表现这些概念。

各种记录的结构

图12.2:各种记录的结构

一条变更记录看起来是这样的:

0a773e3480fe58d62dcc67bd9f7380d6403e26fa
Dirkjan Ochtman <dirkjan@ochtman.nl>
1276097267 -7200
mercurial/discovery.py
discovery: fix description line

这就是你从revlog层得到的内容,变更记录层会将它变为一列值。第一行是声明记录的哈希,然后是作者名、时间和日期(按Unix时间戳格式,加上时区偏移量)、一列涉及到的文件,最后是描述信息。这里没有说明的是,变更记录中的元数据可以是任何东西,为了保持向后兼容性这些内容可以添加在时间戳之后。

下面是声明记录:

.hgignore\x006d2dc16e96ab48b2fcca44f7e9f4b8c3289cb701
.hgsigs\x00de81f258b33189c609d299fd605e6c72182d7359
.hgtags\x00b174a4a4813ddd89c1d2f88878e05acc58263efa
CONTRIBUTORS\x007c8afb9501740a450c549b4b1f002c803c45193a
COPYING\x005ac863e17c7035f1d11828d848fb2ca450d89794
...

这是变更集0a773e指向的版本声明记录(Mercurial的命令允许我们将版本标识符缩短为任意长度的无歧义前缀字符串)它是树中所有文件的一张简单列表,每行一个文件,文件名后面是一个NULL字符,然后是一个十六进制编码的的节点id,指向该文件的文件记录。树中的目录不会被单独列出,而是通过文件路径中的斜杠推测出来。请记住,存储的声明记录和其他revlog一样会被比较差异,因此这个结构使得revlog层能够方便的仅保存任意版本中被修改过的文件和它们的新哈希值。声明记录在Mercurial的Python代码中一般用类似哈希表的结构表示,其中键是文件名,值是节点。

第三种类型的revlog是文件记录。文件记录保存在Mercurial内部的store目录中,记录名和它们所追踪的文件名几乎一样,只是经过了一些编码以确保能够工作在所有主流的操作系统上。例如,我们需要处理Windows和Mac OS X的文件系统的大小写冲突、Windows下的非法文件名,以及各种文件系统所使用的不同字符编码。你可以想像,保证跨操作系统的可靠性是相当痛苦的。相比之下,文件记录中的每个版本的内容就没那么有意思了,它们只是文件的内容加上一些非必要的元数据前缀而已。

这个数据模型使我们能够访问Mercurial版本库中的所有数据,但它并不总是那么好用。尽管模型是垂直的(每个文件记录对应一个文件),但Mercurial的开发者们经常希望能够由变更记录中的一个变更集直接获取一个版本的所有信息,包括声明记录和文件记录。后来,他们在各种revlog之上新增了一组类来做到了这一点。它们叫做contexts

使用多钟独立的revlog的协同工作的好处是之一它们的次序。在添加一个版本时,按照次序首先添加的是文件记录,其次是声明记录,最后是变更记录,这样版本库的状态能够总是保持一致。任何进程在读取变更记录的时候都无需担心指向其他revlog的指针是无效的,这能够预防许多问题。尽管如此,Mercurial仍然会用锁来确保不会有两个进程同时添加revlog记录。

12.2.4.工作目录

我们要讲的最后一个重要的数据结构叫做“目录状态”(dirstate)。“目录状态”表示的是工作目录在任意时刻的内容。最重要的是,它记录的是被检出的版本。这个版本是statusdiff命令比较的基准,也是下一个提交的变更集的父版本。在使用merge命令之后,目录状态将拥有两个父版本,并会将一个变更集中的差异合并到另一个中去。

由于statusdiff是很常见的操作(它们能够帮助你查看最近作出的改动),Mercurial会用一个缓存保存上次遍历工作目录所得到的状态。在状态中记录文件的最近修改时间和大小可以加快遍历目录树的速度。我们还需要记录文件的状态,即文件在工作目录中是被添加、删除还是合并了。这也可以加快遍历工作目录的速度,这些信息还可以供提交命令使用。

12.3.版本策略

现在我们已经熟悉了Mercurial底层的数据模型和代码的结构,可以在高一些的层次来学习Mercurial是如何在上一节所描述的基础设施之上实现版本控制的概念了。

12.3.1.分支

分支通常用于隔离不同的开发任务,并会在之后被合并。使用分支可能是因为人们希望在尝试新的实现方式的同时保证开发主线总是可发布的状态(即功能分支),或是为了快速发布老版本的修正(即维护分支)。这两种方式都很常用,所有的现代版本控制系统也都支持它们。在基于DAG的版本控制系统中,隐式分支很常见,但命名分支(分支名称保存在变更集的元数据中)却有所不同。

最初,Mercurial不支持对分支命名。分支的方式就是克隆(clone)并分别发布它们。这种方式既有效又简单易懂,因为代价很小,所以特别适合于功能分支。但是,在大型项目中,克隆的成本是很高的。尽管在大多数文件系统中可以使用硬链接复制版本库,但创建一棵单独的工作树既缓慢又消耗空间。

鉴于这些缺点,Mercurial添加了第二种分支的方式:在变更集的元数据中保存一个分支的名称。我们添加了一个branch命令用来设置当前工作目录的分支名称,这个分支名会被用于下一次提交。update命令可以用来更新一个分支的名称,而在一个分支上提交的变更集将永远属于这个分支。这种方式被称为命名分支 。但是Mercurial在好几个版本之后才添加了关闭分支的功能(关闭分支意味着在分支列表中不再可见)。关闭分支的实现方式是在变更集的元数据中新增一个域来标记该变更集关闭了这个分支。如果一个分支包含多个头版本,那么只有在它们全部关闭之后分支才会从版本库的分支列表中消失。

当然,实现命名分支的方法很多。Git命名分支的方式就不一样,它使用的是“引用”(reference)。引用是指向Git版本历史中的一个对象的名字,这个对象通常是变更集。这意味着Git的分支是临时的。只要你删去了引用,就没法再证明这个分支曾经存在过。这和在Mercurial中使用克隆然后再将修改合并回来的效果是类似的。它使得在本地的分支操作非常简单轻便,也避免了分支列表的混乱。

事实证明这种分支方式非常受欢迎,比Mercurial中的命名空间或克隆式分支都要流行的多。于是产生了Mercurial的bookmarks(书签)扩展,它在未来可能会被合并进入Mercurial。它简单的使用了一个无版本的文件来记录所有引用。Mercurial使用的传输协议(wire protocol)也相应的进行了扩展,使之能够传递bookmark。

12.3.2.标签

乍一看,Mercurial实现标签的方式有些令人费解。当你第一次添加一个标签的时候(使用tag命令),版本库中会添加并提交一个叫做.hgtags的文件。该文件的每一行都包含了一个变更集的节点id和其对应的标签名。因此,这个标签文件和版本库中的其他文件并没有区别。

之所以这么做原因有三。首先,标签应该是可修改的:意外是不可避免的,所以应该有办法修正或者删除错误的标签。其次,标签应该是变更历史的一部分:标签的被创造的日期、作者、起因,包括标签的变更,都是有价值的信息。第三,应该允许为一个过往的变更集打标签。例如,有些项目会从版本控制系统中导出一个版本,并在发布之前对它进行详尽的测试。

.hgtags的设计具备所有这些性质。尽管有些用户对工作目录中存在的.hgtags感到不解,但是它使得标签机制和Mercurial的其他部分(例如和其他克隆版本库的同步)的集成变得非常简单。如果标签存在于代码树之外(例如Git的做法),我们就需要其他的方式来检查标签的来源并处理(并行添加的)重复标签所造成的冲突。即便后者很罕见,但能从设计上消灭问题出现的可能性则更好。

为了做到这些,Mercurial只会向.hgtags文件追加内容。当标签是同时创建于不同的克隆版本库中时,这也有利于合并标签文件。任何标签的最新节点id的优先级总是最高的,添加一个空的节点id(表示的是存在于所有版本库的根版本)的效果是删除相应的标签。Mercurial也会考虑版本库中的所有分支上的标签,并用新旧程度来计算它们的优先级。

12.4.代码结构

Mercurial几乎全部是用Python写成的,只有一小部分是C,因为它们是整个应用程序性能的关键。Python更合适的原因是使用这样的动态语言来表达高层次的概念更简单。由于大部分代码的性能并不重要,所以我们不介意在大多数情况下用速度换取编写代码的舒适性。

一个Python模块对应着一个代码文件。模块中所包含的代码没有限制,所以它是一种组织代码的重要方式。模块可以通过导入其他模块使用各种数据类型和调用各种函数。含有__init__.py模块的目录叫做包,包中所包含的所有模块和包都是可以被其他Python代码引用的。

在默认情况下Mercurial会在Python的系统路径下安装两个包:mercurialhgextmercurial中包含了Mercurial运行所需的核心代码,而hgext则包含了一些我们觉得应该和核心代码一并交付的有用的扩展。但是,仍然需要手动编辑配置文件才能在需要的时候启用它们(稍后讨论)。

需要明确的是,Mercurial是一个命令行应用程序。这意味着我们的用户界面很简单,即调用hg脚本和一个子命令。子命令(例如logdiff或是commit)可能接受一些选项和参数,也有一些选项是适用于所有命令的。用户界面的可能出现三种不同的情况:

  • hg会输出用户所请求的结果,或者显示状态信息
  • hg会通过命令行请求更多的输入
  • hg可能会启动一个外部程序(如一个编写提交信息的编辑器或是用于帮助合并代码冲突的程序)

代码导入图

图12.3:代码导入图

这个过程的开始可以通过图12.3的代码导入图中清楚地观察到。所有的命令行参数都会被传递给dispatch(分发)模块中的一个函数。这个函数首先会实例化一个ui对象。这个ui对象会先在一些已知的地方(例如你的家目录下)寻找几个配置文件,并将配置选项保存在ui对象中。这些配置文件可能会包含一些指向扩展的路径,这些扩展在此时也会被加载。通过命令行传递过来的所有全局选项也会在此时被保存在ui对象中。

完成这些之后,我们需要判断是否创建一个repository对象。虽然大多数命令操作的都是本地的版本库(用localrepo模块的localrepo类表示),有些命令可以操作远程版本库(可以是HTTP、SSH或是其他实现了的方式),而一些命令则无需操作任何版本库。最后这类包括比如说init命令,它用于初始化一个新的版本库。

所有的核心命令都表示在commands模块的一个函数中,这样我们很容易找到任意命令的实现代码。Commands模块还包含一张将命令名和函数对应起来的哈希表,它描述了每个命令所接受的选项。这样,命令之间就可以共享选项(例如,许多命令都有和log命令类似的选项)。Dispatch模块可以使用选项的描述来检查任意命令所接受的参数,并将接受到的参数值转换为命令所需的类型。几乎所有的函数都会用到ui对象和repository对象。

12.5.可扩展性

Mercurial的强大特性之一就是能够为它编写扩展。因为Python是一个相对容易上手的语言,而Mercurial的API大部分设计的很好(尽管文档不全),许多人都是因为想扩展Mercurial而第一次学习Python。

12.5.1.编写扩展

要启用扩展,必须在Mercurial启动时所读取的任意配置文件之一中加上一行,指定一个键和扩展Python模块的路径。有几种方法来为Mercurial添砖加瓦:

  • 加入新的命令;
  • 封装现有的命令;
  • 封装所使用的版本库;
  • 封装Mercurial中的任意函数;
  • 添加新的版本库类型。

添加新的命令只需要在扩展模块中添加一张名为cmdtable的哈希表即可。扩展加载器会读取它,并将新的命令加入分发时的候选命令列表中。同样,扩展可以定义名为uisetupreposetup的函数,分发代码会在UI和repository对象被实例化之后调用它们。一种常见的方式是在扩展中使用reposetup函数自定义一个子类封装repository。这使得扩展可以修改版本库的各种基本行为。例如,我编写的一个扩展使用uisetup来根据环境配置中的SSH验证信息设置ui.username这一属性。

更强大的扩展还可以添加版本库的种类。例如, hgsubversion项目(没有被包含进Mercurial)为Subversion的版本库注册了一个新的版本库类型。这使得它几乎可以像克隆一个Mercurial版本库一样克隆一个Subversion的版本库。它甚至可以向Subversion版本库推送代码,不过由于两个系统的巨大差异,并不是所有情况下都能成功,但用户界面则是完全透明的。

对于那些希望从根本上改变Mercurial的人,在动态语言的世界里有一种叫做“monkeypatching”的技巧。因为扩展和Mercurial运行在相同的地址空间中,而且Python语言灵活的反射机制非常强大,Mercurial所定义的任何函数或者类都是可以(甚至是很简单的)被修改的。尽管这么做并不优雅,但它仍然不失为一种非常强力的技巧。例如, hgext中的highlight扩展修改了内置的Web服务器,为版本库的文件内容页面加上了语法高亮。

还有另一种简单得多的扩展Mercurial的方法,那就是aliases (别名)。所有配置文件都可以为已知的命令定义别名。这使得我们能够为其他命令定义较短的简写。最新版本的Mercurial还允许为shell命令定义别名,这样只用shell脚本你就能够设计出复杂的命令了。

12.5.2.钩子

所有版本控制系统长期以来都通过“钩子”这种方式在事件中和外界交互。常见的用法包括向持续集成系统发送通知或是更新Web服务器上的工作目录来发布修改。当然,Mercurial还包括一个调用此类钩子的子系统。 事实上,它也含有两个变种。一种和其他版本控制系统中传统的钩子类似,调用的是shell脚本。另一种则更有趣,因为它调用的钩子是用户指定的Python模块中的指定函数。由于运行在同一进程中,这种方式不仅更快,而且还能使用repo和ui对象,这意味着和版本控制系统的内部进行更复杂的交互变得更简单。

Mercurial的钩子可以分为“pre-command”(命令前执行)、“post-command”(命令后执行)、“controlling”(控制)和“miscellaneous”(其他)类。前两种钩子的定义只需要在配置文件的hooks小节中指定一个pre-command或是post-command键即可。另外两类则包含一组预定义的事件。controlling钩子的不同之处在于它们运行于事件即将发生的时候,并且可以阻止事件的继续。这常用于在中央服务器上用某些规则验证变更集的有效性。由于Mercurial的分布性,在提交时是无法进行这种检查的。例如,Python项目就使用了一个钩子来确保某些方面的代码风格一致性——如果一个变更集所添加的代码不合要求,它会被中央版本库所拒绝。

钩子的另一个有趣的用法是Mozilla和其他一些公司所使用的"推送日志"(pushlog)。推送日志会记录每次推送的内容(因为一次推送中可能含有任意数量的变更集)、发起者和时间,这也是审核版本库变更的一种方式。

12.6.经验教训

Matt在开始开发Mercurial时作出的决定之一就是使用Python。Python有强的可扩展性(通过扩展和钩子),编写代码也很容易。Python还节省了大量的跨平台兼容性工作,使得Mercurial达到在三个主流操作系统上都良好工作的目标相对简单。另一方面,Python和许多其他(编译型)的语言相比运行较慢,特别是解释器的启动较慢,这对于会被频繁调用而非长期运行的工具(例如版本控制系统)来说特别糟糕。

早期的一个选择使得现在Mercurial很难修改提交之后的变更集,因为修改版本号就必然会修改标识这个版本的哈希值。想要“召回”已经发布到互联网上的变更集是痛苦的,而Mercurial让这一切变得更困难。但是,修改尚未被发布的版本一般没有问题,而且Mercurial社区在第一个版本发布之后就一直在努力使之变得更简单。有一些扩展尝试着解决这个问题,但使用它们需要一些学习,而且步骤对于已经简单使用过Mercurial的人来说并不直观。

三种revlog能够很好的降低磁盘定位操作的频率,而且变更记录、声明记录和文件记录的三层结构工作的非常好。提交速度很快,每个版本所需的磁盘空间也较少。但是有一些用例的效率就不是很高,比如重命名文件,因为每个文件的版本都是分别存储的。我们会修正这个问题,但可能会打破分层的设计。同样,每个文件用来辅助文件记录存储的DAG图在实践中并不常用,所以用于管理这些数据的代码其实是个负担。

Mercurial关注的另一个焦点是易用性。我们尽量用较少的一组命令来完成绝大多数功能,且命令之间的选项是一致的。我们希望学习Mercurial的过程是渐进的,特别是对于那些曾经使用过其他版本控制系统的人。这种设计哲学的延伸使得Mercurial可以通过扩展来适应某些特别的用例。因此,开发者们保持了用户界面和其他版本控制系统的一致性,特别是Subversion。同样,开发小组也通过应用程序本身提供了优质的文档,不同的主题和命令之间都可以交叉引用。我们还尽力给出有用的错误信息,包括在操作失败时提示其他尝试的可能。

对于新用户来说,一些小地方可能会让他们很惊讶。例如,很多用户一开始并不喜欢用工作目录中的一个单独的文件来处理标签(如前文所述),但这种方式有它的优势(当然也有它的缺陷)。同样的,其他的版本控制系统在检出时只会发送指定的变更集和它的所有父版本,但Mercurial则会发送所有远程版本库中所没有的版本。两种方式都有各自的道理,最佳的选择只能根据你的开发方式来决定。

和在任何软件项目一样,这其中有许多取舍。我认为Mercurial作出了许多正确的选择,当然在事后从追求完美的角度来说,其他一些选择可能更好。从历史角度来说,Mercurial已经是第一代成熟的通用分布式版本控制系统之一了。我期待着下一代版本控制系统的出现。