现代软件非常复杂,团队用来管理其开发的方法也反映了这种复杂性。尽管许多组织使用版本控制软件来跟踪和管理项目演进的复杂性,但关于如何明智地选择版本控制工具的话题却鲜有关注。直到最近,版本控制领域还处于停滞状态,因此在这个主题上没有什么可说的。
然而,在过去的五年里,版本控制软件领域出现了爆发式的创新,现在团队领导者面临着令人眼花缭乱的选择。
CVS(并发版本系统)在十多年来一直是占主导地位的开源版本控制系统。虽然它有许多严重的缺点,但它仍然作为遗留系统被广泛使用。Subversion 是为了取代 CVS 而编写的,在 2000 年代中期开始流行。(Perforce 是 Subversion 的一个著名的商业竞争对手。)Subversion 和 CVS 都遵循客户端-服务器模型:一个中央服务器托管项目的元数据,开发人员“检出”此数据的有限视图到他们工作的机器上。
在 2000 年代初期,一些项目开始远离集中式开发模型。在最初涌现的六个左右的项目中,今天最流行的是 Git 和 Mercurial。这些分布式工具的显著特点是它们以对等方式运行。项目的每个副本都包含项目的所有历史记录和元数据。开发人员可以根据自己的需要共享更改,而不是通过中央服务器。
无论是集中式还是分布式,版本控制系统都允许团队成员执行一些核心任务
每个版本控制工具都强调一种独特的工作和协作方法。反过来,这会影响团队的工作方式。因此,没有哪种版本控制工具适合每个团队:每种工具都带有一系列复杂的权衡,这些权衡甚至难以看到,更不用说评估了。
在一个大型项目中,管理并发开发是一个主要的难点。开发人员非常熟悉他们的功能进度因不相关模块中的错误而停滞的情况,因此他们更喜欢通过在隔离的分支中工作来管理这种风险。当分支被隔离太久时,会出现另一种风险:在不同分支中工作的团队对同一代码进行冲突的更改。
将一个分支的更改合并到另一个分支可能令人沮丧且危险——可能会悄悄地重新引入已修复的错误或产生全新的问题。这些风险可能以几种方式出现
由于合并引入了超出正常开发产生的风险,因此版本控制系统如何处理分支和合并都非常重要。在 Subversion 下,创建一个新分支就是复制现有分支,然后检出它的本地视图。尽管创建分支的成本相对较低,但 Subversion 允许几个开发人员在单个分支中同时工作。由于在单个分支中工作没有立即明显的成本,因此大多数团队维护的分支很少。
这种工作模式引入了一种新的风险。假设 Alice 和 Bob 同时在单个分支中的相同文件上工作。Subversion 将分支的历史记录视为线性的:修订版本 103 跟随修订版本 102,并先于修订版本 104。Alice 和 Bob 都从服务器上将分支的修订版本 105 的副本检出到他们自己的笔记本电脑上。这些工作副本包含他们未提交的工作,彼此隔离,直到其中一人提交了他的或她的更改。
如果 Alice 先提交她的工作,它将成为修订版本 106。在 Bob 将他的工作与 Alice 的修订版本 106 合并之前,Subversion 不允许 Bob 将他的工作提交为修订版本 107。由于 Bob 无法提交他的工作,如果他的合并出现问题会发生什么?他将没有他所做工作的永久记录,并面临一些可怕的可能性:他的工作可能会丢失或悄悄地损坏。由于 Subversion 提供在共享分支中工作作为阻力最小的路径,因此开发人员倾向于盲目地这样做,而不了解他们面临的风险。实际上,风险甚至更微妙:假设 Alice 的更改与 Bob 的更改在文本上没有冲突;她不会被迫在提交之前检出 Bob 的更改,因此她可以不受阻碍地将她的更改提交到服务器,从而导致没有人见过或测试过的新树状态。
Mercurial 和 Git 是分布式的,因此它们缺乏 Subversion 的单个中央服务器的概念,在中央服务器中托管元数据。存储库包含项目完整历史记录的独立副本,以及包含项目文件快照的工作目录。如果 Alice 和 Bob 一起在一个项目上工作,Alice 可能会克隆 Bob 的存储库的副本,或者她可以从某个服务器克隆副本。当她提交更改时,它会保留在她机器上的本地存储库中,直到她选择以某种方式共享它。她可以通过将其发布到服务器或让 Bob 直接从她那里拉取来做到这一点。
Mercurial 和 Git 都将获取远程更改与将它们与本地更改合并分离开来。如果 Bob 获取了 Alice 的修订版本,他仍然可以提交他的更改,而无需先与她的更改合并。当他之后合并时,他仍然会有他已提交更改的永久记录。如果合并遇到麻烦,他将能够恢复他早期的工作。
在版本控制的分布式视图下,每次提交都可能是其自身的分支。如果 Bob 和 Alice 从完全相同的历史视图开始,并且每个人都进行提交,那么他们已经在项目历史记录中创建了一个微小的匿名分支。在其中一人拉取另一人的更改之前,他们都不会知道这一点,此时他们将不得不与它们合并。
这些微小的分支和合并在 Mercurial 和 Git 中非常频繁,以至于这些工具的用户以与 Subversion 用户非常不同的方式看待分支和合并。项目开发的并行和分支性质在其历史记录中清晰可见,清楚地表明了谁在何时进行了哪些更改,以及他们的更改基于哪些其他更改。Mercurial 和 Git 都可以将名称与更长期的开发线关联起来(例如,“将成为版本 2.0 的代码”),因此值得命名的开发可以有一个名称。
看看 Subversion 和分布式工具在哪些方面给用户提供了自由度是很有启发意义的。Subversion 对它管理的文件的层次结构几乎没有施加任何结构。它缺乏分支的概念,除了它通过 svn copy
命令提供的概念之外。用户通过约定在层次结构的一部分中找到分支,人们同意分支应该存在于该部分中。按照约定,单个“主开发线”称为 /trunk,分支位于 /branches 下。
由于 Subversion 没有强制执行用于构建分支的策略,因此它有一些有趣的行为。要跨整个分支执行操作,您必须知道分支的根在命名空间中的位置。大多数 Subversion 命令仅对告知它们的命名空间部分进行操作。如果 Alice 检出了 /branches/myfeature 并在她的 /branches/myfeature/deep/sub/dir 工作副本中运行 svn commit
,她将仅提交分支的 deep/sub/dir 目录及其下方的更改。从错误的目录进行的粗心提交可能会让 Alice 认为一切正常,但会让她的同事留下不一致、损坏的树。
svn update
命令以相同的方式运行:工作副本的各个部分可以同步到分支历史记录的不同修订版本。这很容易导致工作副本看起来不一致,而实际上它是由分支历史记录中不同时间的片段意外组成的。
相比之下,分布式工具将存储库的整个内容视为要处理的单元。如果您在存储库内的任何目录中运行 git commit -a
,那么它将获取所有未完成更改的快照。对于 Mercurial,hg update
的操作类似,使整个工作目录与历史记录中的特定点保持同步。这两种工具都不可能意外地检出分支的不一致视图。如果您手动将文件或目录恢复到某个特定修订版本,则用户界面会通过将受影响的文件显示为已修改来明确这一点。
即使 Subversion 没有对使用分支的项目施加结构,它也建议了一种命名分支的约定。因此,通过中央服务器协作的 Subversion 用户可能很容易找到彼此的项目。Mercurial 和 Git 都使在服务器上发布只读存储库相当容易,但存储库的所有者必须告诉其他人存储库的位置:它可能在 Internet 上的任何位置,而不仅仅是单个服务器主机上的已知位置。此外,这两种系统都没有使读写发布特别容易。这是设计使然。
Subversion 的单服务器模型要求想要与其他人共享更改的协作者必须具有对共享存储库的写入权限,以便他们可以发布他们的更改。对于 Git 和 Mercurial,当然可以遵循这种集中式模型,但这是一种约定。用户通常将他们的存储库托管在他们自己的服务器上或托管提供商处。他们的协作者不是将他们的更改发布到共享服务器,而是从他们那里拉取更改,并在其他地方发布他们自己的修改。
Subversion 和分布式工具之间的主要区别在于:使用 Subversion,提交更改会隐式发布它,而使用分布式工具,两者是分离的。在所有参与者都具有对服务器的写入权限并且每个人始终连接到同一网络的环境中,将提交与发布结合起来很方便。分离两者增加了额外的发布步骤,但开启了离线工作和使用新颖发布技术的可能性。
例如,对于新颖的发布,Mercurial 支持使用其内置 Web 服务器在 LAN 上临时发布存储库,并且它支持使用 Bonjour 协议发现存储库。对于软件项目的冲刺等快速开发设置,这是一个强大的组合:只需打开您的笔记本电脑,共享您的存储库,您的 Wi-Fi 邻居就可以立即找到并拉取您的更改,无需任何服务器基础设施。
集中式和分布式发布方法都提供了权衡。对于一个始终有线的、紧密结合的小团队来说,提交即发布看起来是一个更容易的选择。在组织结构较为松散的环境中——例如,团队成员经常出差或在客户现场花费大量时间——将提交与发布分离可能更合适。
集中式工具可能非常适合高度结构化的“铁腕统治团队”管理模式。访问权限可以由管理者而不是同行控制。树的整个部分可以设置为仅供具有特定安全级别的员工写入或读取。去中心化系统目前在这方面没有提供太多,除了将敏感数据拆分到单独的存储库的能力,这有点笨拙。
许多团队开始使用分布式版本控制系统的方式与他们正在替换的集中式系统几乎完全相同。每个人都克隆几个中央存储库之一,并将更改推回。这种熟悉的模型非常适合让人们感到舒适,但它几乎没有触及可能的交互风格的表面。
由于分布式模型强调将更改拉取到本地存储库中,因此它自然非常适合有利于代码审查的开发模型。假设 Alice 管理将成为她的团队软件项目 2.4 版本的存储库。Bob 告诉她他有一些更改已准备好提交,并给了她她可以从中拉取他的更改的 URL。当她阅读他的更改时,她注意到他的代码没有正确处理错误条件,因此她要求他修改他的工作,然后她才会接受、合并和发布它。
当然,团队可以同意将“合并前审查”策略与集中式系统一起使用,但软件的默认行为更宽松。因此,团队必须采取明确的步骤来约束自己。
鉴于它们的背景,Mercurial 和 Git 在合并更改方面具有相似的方法并不奇怪,而 Subversion 的做法则不同。
由于合并在 Mercurial 和 Git 中如此频繁地发生,因此它们在这个领域具有精心设计的强大功能。在合并期间绊倒版本控制系统的典型情况是已重命名或删除的文件和目录。Mercurial 和 Git 都能干净利落地处理重命名。
Subversion 的合并机制复杂且脆弱。例如,曾经在合并中消失的文件。这个严重的错误已部分得到解决,因此文件现在已重命名,但它们可能包含错误的内容。目前尚不清楚这是否真的是向前迈进了一步。
文件命名的一个更微妙的问题经常困扰跨平台开发团队。Windows、OS X 和 Unix 系统在处理文件名的大小写方面有不同的约定(即,对于 FOO.TXT 是否与 foo.txt 同名的问题有不同的答案)。Mercurial 在这里胜过其竞争对手。它可以检测并安全地处理在默认情况下对大小写敏感的操作系统上使用的大小写不敏感的文件系统。
通常,开发人员对收到新的错误报告的第一个反应是查看项目的历史记录,看看最近发生了什么变化,或者注释源文件,看看是谁在何时修改了它们。这些操作在分布式工具中是即时的,因为所有数据都存储在开发人员的计算机上,但在针对远程或拥塞的 Subversion 服务器运行时,它们可能会很慢。由于人类是不耐烦的生物,额外的等待时间会降低这些有用的命令的运行频率。这是响应速度对人们如何使用他们的软件产生不成比例的影响的另一种方式。
虽然简单的历史记录显示很有用,但如果有一种自动查明错误来源的方法会更有趣。Git 引入了一种通过 bisect
命令来做到这一点的技术(事实证明它非常有用,Mercurial 也获得了自己的 bisect
命令)。这项技术很容易学习:您在已知没有错误的修订版本和已知有错误的修订版本上使用 bisect
命令。然后它检出一个修订版本并询问您该修订版本是否包含错误;它重复此操作,直到它识别出错误首次出现的位置的修订版本。
这对开发人员很有吸引力,部分原因在于它易于自动化。编写一个小的脚本来构建您的软件并测试是否存在错误;启动一个 bisect
;然后稍后回来并找出哪个修订版本引入了问题,而无需进一步的人工干预。bisect
具有吸引力的另一个原因是它以对数时间运行。告诉它搜索 1,000 个修订版本的范围,它只会问大约 10 个问题。将搜索范围扩大到 10,000 个修订版本,问题的数量仅增加到 14 个。
过度强调 bisect
的重要性是很难的。它不仅完全改变了您查找错误的方式,而且如果您经常使用脚本驱动它,您将有效地免费开发了回归测试。保存这些测试并使用它们!
狡猾的读者会注意到,使用 Subversion 搜索提交历史记录比使用分布式工具更容易,因为它的历史记录更线性。对此的反驳是,bisect
命令内置于其他工具中,因此更容易获得并且更易于可靠地自动化。
一旦您在一段软件中发现了一个错误,仅仅修复它通常是不够的。假设您的错误已有数年之久,并且您的软件在现场有三个版本需要修补。每个版本都可能有一个“维护”分支,其中累积了错误修复。问题在于,虽然将修复从一个分支复制到另一个分支的想法很简单,但实践起来并非那么简单。
Mercurial、Git 和 Subversion 都具有从一个分支拣选更改并将其应用于另一个分支的能力。拣选的麻烦在于它非常脆弱。更改不仅仅在空间中自由浮动:它有一个上下文——对围绕它的代码的依赖性。其中一些依赖关系是语义上的,并且会导致更改被干净地拣选,但稍后会失败。许多依赖关系只是文本上的:有人遍历并在目标分支中将每个 banana 单词更改为 orange,并且不再能干净地应用引用 bananas 的拣选更改。
当由于文本问题(可悲的是,常见的情况)导致拣选失败时,通常的方法是通过肉眼检查更改,然后在文本编辑器中手动重新输入它。分布式版本控制系统已经提出了一些强大的技术来处理此类问题。
也许最强大的方法是 Darcs 采用的方法,Darcs 是一种分布式版本控制系统,它在看待更改的方式上真正具有革命性。Darcs 没有简单的更改链或图,而是对更改如何相互依赖具有更强大的理论。这使得它在拣选更改方面比任何其他分布式版本控制系统都更加成功。那么为什么不是每个人都在使用 Darcs 呢?多年来,它存在严重的性能问题,使其完全不切实际。这些问题已得到解决,以至于它现在只是相当慢。它更根本的问题在于它的理论难以掌握,因此两个不沉浸在 Darcs 知识中的开发人员可能很难判断他们是否具有相同的更改。
让我们回到 Mercurial 和 Git 的阵营。由于这些工具提供了在任何修订版本之上进行提交的能力,从而产生一个微小的匿名分支,因此拣选的可行替代方案如下:使用 bisect
识别错误出现的修订版本;检出该修订版本;修复错误;并将修复提交为引入该错误的修订版本的子版本。这个新的更改可以很容易地合并到任何具有原始错误的分支中,而无需任何粗略的拣选行为。它使用版本控制工具的正常合并和冲突解决机制,因此它比拣选(其实现几乎总是一系列怪诞的黑客行为)可靠得多。
这种追溯历史修复错误,然后将修复合并到现代分支的技术被 Monotone(一个有影响力的分布式版本控制系统)的作者命名为“零散修复”。修复被称为零散,因为它们利用了项目的历史记录被构造为有向无环图或 dag。虽然这种方法可以与 Subversion 一起使用,但与分布式工具相比,它的分支是重量级的,这使得零散修复方法不太实用。这突出了工具的优势将告知其用户采用的技术的想法。
分布式工具难以匹敌其中央竞争对手的一个领域是二进制文件的管理,尤其是大型二进制文件的管理。尽管许多软件学科都有一项策略,即永远不要将二进制文件置于版本控制系统的管理之下,但在某些领域(例如游戏开发和 EDA(电子设计自动化))中,这样做很重要。例如,单个游戏项目对数十 GB 的纹理、骨骼、动画和声音进行版本控制是很常见的。二进制文件与文本文件的不同之处在于,它们通常难以压缩且无法合并。这些都带来了各自的挑战。
如果一个中等大小的二进制文件存储在版本控制下并被多次修改,则存储每个修订版本所需的空间可能很快就会大于所有文本文件所需的空间总和。在集中式系统中,这种开销只需支付一次,在中央服务器上。使用分布式系统,每台笔记本电脑上的每个存储库都将具有该文件历史记录的完整副本。这既会破坏性能,又会造成不可接受的存储成本。
当两个人修改二进制文件时,对于大多数文件格式,都无法分辨出他们各自版本的文件之间的差异,并且更罕见的是软件可以帮助解决他们各自修改之间的冲突。作为避免合并二进制文件的一种方式,集中式系统提供了锁定文件的能力,以便在任何给定时间只有一个可以编辑分支中的文件。分布式系统本质上不能提供锁定,因此它们必须依赖于社会规范(例如,团队策略,即只有一个人会修改某些类型的文件)。
相对于其分布式对应物,集中式工具将使分支的历史记录看起来更线性。这似乎是优势还是劣势取决于视角。更线性的历史记录更容易理解,因此对开发人员的版本控制专业知识要求更低。另一方面,包含大量小分支和合并的历史记录更准确地反映了项目的真实历史记录,并更清楚地表明了团队成员的代码在工作时基于哪个项目状态。对于喜欢保持项目历史记录整洁的团队,Git 和 Mercurial 都提供了 rebase
命令,可以将功能的混乱历史记录变成更整洁的逻辑更改集合,更适合最终合并到项目的主分支中。
集中式工具可以提供策略优势,这些优势更难以使用分布式工具实现。例如,可以配置一个预提交脚本,如果它引入了自动化测试套件失败,则拒绝尝试提交。使用分布式工具,这种检查可以放在共享中央服务器上,但这无法保护开发人员免受从一台笔记本电脑到另一台笔记本电脑水平共享无意中损坏的更改。
廉价的本地提交的可用性使得使用快速开发风格对分布式工具具有吸引力。假设 Alice 正在进行一项复杂的更改,并决定她想要推测性地重构一段代码。使用分布式工具,她可以按原样提交她的更改,而无需过多担心项目是否处于理智状态,并尝试她的推测性更改。如果该实验失败,她可以恢复它并继续她的工作,最终使用 rebase
命令来消除她在弄清楚她要做什么时所做的一些正在进行的提交。
虽然这种开发风格在使用 Subversion 时当然是可能的,但经验表明,它在分布式工具中更为常见。我的猜想是,开发人员笔记本电脑上分支的隐私性,加上分布式工具的即时响应性,以某种方式结合起来鼓励更积极和更普遍地使用版本控制。
我在合并方面观察到了类似的效果。由于它们是分布式工具中如此基础的活动,因此在许多项目中,它们的发生频率远高于其中央对应物。尽管所有合并都需要付出努力并带来风险,但当分支合并更频繁时,合并规模更小,风险也更小。询问任何经验丰富的开发人员关于在几个月的隔离工作后长期延迟的合并,并观看他或她的脸色变得苍白。
在版本控制系统的发展道路上,我们远未到达终点。该领域只受到了学术界的断断续续的关注。可以在其形式基础上做很多工作,这可以为开发人员协同工作带来更强大、更安全的方式。唉,在过去的十年里,我只知道一篇关于该主题的著名出版物。1 作为一个简单的例子,当合并可能冲突的更改时,几乎每个人都使用三向合并(已有数十年历史)或未发布的临时方法,在这些方法中,几乎没有理由感到自信。
更实际的是,在分布式工具处理具有深层历史记录的大型项目的方式方面,还有很多进步可以取得,对于这些项目,由于涉及的数据量,它们不太适合。对于对保证和安全性有敏感需求的组织,集中式工具比分布式工具做得稍好,但两者都可以大幅改进。
选择版本控制系统是一个绝对答案出奇地少的问题。要考虑的基本问题是您的团队使用哪种类型的数据,以及您希望您的团队成员如何交互。如果您有大量频繁编辑的二进制数据,分布式版本控制系统可能根本不适合您的需求。如果敏捷性、创新和远程工作对您很重要,那么分布式系统更有可能满足您的需求;相比之下,集中式系统可能会减慢您的团队的速度。
还有许多二阶考虑因素。例如,防火墙管理可能是一个问题:Mercurial 和 Subversion 在 HTTP 和 SSL(安全套接字层)上运行良好,但 Git 在 HTTP 上运行速度慢得无法使用。对于安全性,Subversion 提供对单个文件级别的访问控制,但 Mercurial 和 Git 不提供。为了易于学习和使用,Mercurial 和 Subversion 具有彼此相似的简单命令集(简化了从一种到另一种的过渡),而 Git 则暴露了可能令人难以承受的复杂性。在与构建工具、错误数据库等集成方面,这三者都易于编写脚本。许多软件开发工具已经支持或具有这些工具中的一个或多个的插件。
鉴于对可移植性、简单性和性能的需求,我通常为新项目选择 Mercurial,但具有不同需求或偏好的开发人员或团队可以合法地选择它们中的任何一个,并在长期内感到满意。我们很幸运,在这三个系统之间进行互操作很容易,因此对未知的实验很简单且无风险。
问
我要感谢 Bryan Cantrill、Eric Kow、Ben Collins-Sussman 和 Brendan Cully 对本文草稿的反馈。
1. L&ounl;h, A., Swierstra, W., Leijen, D. 2007. 一种版本控制的原则性方法; http://people.cs.uu.nl/andres/VersionControl.html。
喜欢它,讨厌它?请告诉我们
Bryan O'Sullivan 是一位爱尔兰黑客和作家,居住在旧金山。他的兴趣包括函数式编程、HPC(高性能计算)以及构建大型分布式系统。他是 Jolt 奖获奖作品Real World Haskell(2008 年)和Mercurial: The Definitive Guide(2009 年)的作者,这两本书均由 O'Reilly 出版社出版。
© 2009 1542-7730/09/0800 10.00 美元
最初发表于 Queue 杂志,第 7 卷,第 7 期—
在 数字图书馆 中评论这篇文章