原文标题:Problems with Pull Requests and How to Fix Them

原文地址:https://gregoryszorc.com/blog/2020/01/07/problems-with-pull-requests-and-how-to-fix-them/

作者:Gregory Szorc

关于作者:

在Microsoft、Xobni、Mozilla和Airbnb(当前)工作过。近年的主要工作是Firefox Sync、Firefox Health Report、改进了Firefox的编译系统和Mozilla的自动化。当前在Airbnb的工作重心是支持源码控制和编译系统基础设施。

译者:卿培(微信公众号:qpsays)

译者注:

原文有两种格式表示强调,斜体加粗。但是中文排版中,斜体的可读性不佳,所以我用加粗替代原文斜体,用高亮替代原文加粗

少量git术语翻译后影响理解,不翻。如rebase等。

我在2020年2月初看到这篇文章,深受启发。获得原作者email授权后,花了些时间,将本文翻译成中文,希望能让更多中文开发者关注这个话题,找到提交模型和工作流的改进方向。

本文很长,26000+字,通读可能需要一小时。

你可能已经用过或者至少听过拉取请求(pull request,也有人译作合并请求1):拉取请求是类似GitHub、GitLab、Bitbucket等代码协作平台上实践并被广泛接受的对项目做出贡献的工作流程。一个人创建一个派生项目(fork),提交一些修改,推送到一个分支,然后创建一个拉取请求来跟踪将这些修改集成到目标项目的目标分支的过程。这个拉取请求接下来就被用作代码审核、跟踪自动化检查和讨论的载体,直到这些修改已经达到可以被集成的状态。集成操作通常是被项目的维护者执行的,往往是在拉取请求的网页上点击一个合并按钮。

值得一提的是,拉取请求这个术语并不是普遍使用的:比如GitLab就将其称为合并请求(merge requests)。此外,我认为这两个术语拉取请求合并请求的命名都不好,因为会与版本控制工具的术语混淆(例如git pullgit merge)。实现拉取合并请求可能不会真正执行拉取合并(你也可以对一个拉取/合并请求做变基(rebase),而没人会将称其为变基请求)。现代的拉取请求包含的内容远远超出了版本控制工具里的一个操作,更别说是简单的请求拉取合并一个分支了——它是监控变更集成过程的纽带,贯穿拟定变更在集成之前、之中、之后的全过程。但是能奈之如何呢。因为GitHub创造了这个术语,它也是当前实现了该功能的最流行的协作平台,我在本文后续部分都将把这个在GitHub、GitLab、Bitbucket和其他平台都实现了的通用工作流程称为拉取请求

拉取请求以当前的形式存在已经超过十年了。核心工作流程几乎没有变化。改变的是添加了增值功能,比如集成状态检查,例如持续集成结果,可以变基或压平(squash)一些提交来替代合并(merge),代码审核工具有所改善,UI也有大量的优化。GitLab在此值得单独拿出来说一下,它们的合并请求(merge requests)功能实现了比其他工具丰富得多的监控能力。(这是GitLab比竞品更多的内置功能带来的附带作用。)当GitHub这家公司前几年碌碌无为时,GitLab对拉取请求添加了新功能,我要赞扬这一点。(缺少一位对产品/公司有明确领导力的CEO,的确体现了后果。)幸运的是,这两家公司和其他的同行现在都在大力推广新颖实用的功能,令整个行业都受益匪浅。

尽管我没有证据,但我怀疑拉取请求(以及服务商们用来实现它的派生模型)是有人在思考我如何基于Git的新颖的分布式特效和分支功能设计一个协作网站的问题时出现的。他们想到这个问题,然后去发明了派生拉取请求。毕竟GitHub早期实现的拉取请求,就是对常见Git工作流的一个包装——克隆一个项目、创建一个分支、将它发送到某个地方。如果没有GitHub,你会运行git clonegit branch和其他什么像是[git request-pull](https://git-scm.com/docs/git-request-pull)之类的指令来生成和发送你的分支到别处。在GitHub上,类似的步骤大致是创建一个派生项目、创建一个分支、提交一个拉取请求。今时今日,以上这一系列操作你都可以直接在网页界面里完成操作,完全无需直接运行git命令。这意味着GitHub从概念上可以被视为Git功能分支工作流的一个纯服务端抽象/实现。

从本质上讲,拉取请求实际上就是围绕通用Git功能分支工作流程而构建的一层漂亮UI和功能封装。它最初可能被当作这个原来在客户端做的工作流程(在服务端上)的完善和增值。并且拉取请求的这个早期核心属性被Bitbucket、GitLab等其他供应商照抄了(在Bitbucket的案例中,这个功能是为Mercurial而非Git实现的,因为Bitbucket最初仅支持Mercurial)。

十年在计算机行业里简直就是永恒了。有种说法是不进则退。我认为行业现在应当仔细研究一下拉取请求模型,并把它进化成某种更好的模型了。

我知道你在想什么:你认为拉取请求现在运行得挺好,而且很受欢迎,因为它比其他之前存在的模型都更出色。除了一些细微的问题之外,这些论述都是正确的。但是,如果你处在版本控制领域(像我一样)或者你的工作就是向开发者们交付工具和工作流程来提高生产率和代码/产品质量(我就是这样),那么拉取请求工作流程和GitHub、GitLab、Bitbucket等供应商对其的实现中存在的瑕疵就很明显了,就算不被整个换掉,也需要一次大规模的检修。

所以系好安全带吧,你已经开始了一个万字长文的历险,在此你将探索关于拉取请求的一切,虽然你从来没想到过要了解这些。

拉取请求的问题

为了建立一个更好的工作流程,我们首先要理解拉取请求哪里做错了或者没有做到最优。

我认为,拉取请求的首要目标是促进期待的高质量变更被集成到目标仓库,并且在此过程中对提交者、集成者和所有期间参与者只带来最小的额外开销和复杂度。拉取请求为了实现这一目标,促进大家协同讨论变更(包括代码审核)、监控对变更的自动化检查结果、添加对相关问题的链接等等。换句话说,我所见到的是特定供应商的拉取请求实现仅仅是其实现细节。和所有实现细节一样,它们应当被频繁地全面检查,必要时还得做变更。

剖析拉取请求的问题,可以先聚焦在审核单元的大小这一点上。Google2、Microsoft34和其他组织的研究表明,审核单元大小和缺陷率存在负相关关系。Google是这样写的(高亮是我加的):

变更的大小分布是影响代码审核过程质量的重要因素。此前的研究发现,随着变更的大小增长,有用的评论数量减少、审核延迟增加。变更的大小也影响开发者对代码审核过程的认知。一项对Mozilla贡献者的调查发现,开发者感到影响审核延迟的最主要因素就是变更大小。Google的文章确认了变更大小和代码审核质量正相关,强烈建议开发人员进行小的增量变更(大量删除和自动化的重构除外)。这些研究发现和我们的研究都支持小规模变更评审的价值,也需要相应的研究和工具来帮助开发中创建这样小规模、自洽的代码变更以提交评审。

简而言之,更大的变更会导致审核期间的有用评论减少(这意味着质量下降),并使审核花费的时间更长(这意味着效率下降)。划重点:如果你关心缺陷率、质量和/或速度,则应该编写和审核更多、更小的变更,而不是更少、更大的变更。

我更同意 Google 在此问题上的观点,并衷心支持编写更多、更小的变更。我实践过这两种形式的变更,我可以毫无疑问地说,更多、较小的变更是更好的:对于作者、代码审核者和以后查看代码仓库历史记录的人来说都更优越。这个模型的主要缺陷是,参与者需要对版本控制工具有更多的了解才能执行。还需要相应的工具能很好地适应这种变更编写模型,在过程中引入尽可能少的阻力。因为随着变更大小的减小、速度的增加以及等待被集成的变更单元的增多,参与者与工具的交互次数都将增加。

最后一点很重要,并且与本文密切相关,因为当今拉取请求的通用实现并不十分兼容大量小规模变更的工作流程。我想说,当前拉取请求的实现在积极地给大量小规模变更的工作流程设置障碍。而且,由于较小的变更会带来更高的质量和更快的审核,当前拉取请求的实现其实是在损害质量和速度。

我不是要针对谁,但是由于GitHub是最受欢迎的,并且正是它让拉取请求流行开来,所以我们不妨使用 GitHub 的拉取请求实现来阐述我的观点。

我认为,为了让我们创造更多更小的变更,我们必须要么 a)创建更多更小的拉取请求,要么 b)使拉取请求的审核重点放在单个提交上(而不是整个待合并的差异)。我们挨个分析。

如果我们要编写更多更小的拉取请求,那么为了保持速度,似乎需要在拉取请求之间建立依赖关系。拉取请求之间的依赖性增加的开销可能会阻碍整个过程的进行。让我解释一下。我们不想牺牲作者和维护者能够集成变更请求的总体速度。如果我们将现有的变更请求拆分为更多、更小的拉取请求,那么我们的拉取请求数量就会比现在多得多。当没有描述他们之间依赖关系的功能时,作者可以等待前一个拉取请求被集成再发送下一个请求。这将导致作者与集成者之间的沟通往返次数增加,并且几乎可以肯定会减慢整个过程。那是不可取的。明显的缓解方法是允许同时进行多个相关的拉取请求。但这需要发明新功能来引入和跟踪拉取请求之间的依赖关系,确保一个拉取请求不会先于它依赖的拉取请求被集成。这在技术上当然是可行的。但是,它本身会带来相当大的开销。你如何定义依赖关系?是否根据提交历史的DAG自动检测或更新依赖?如果是,那么当你强制推送(force push)时,一个提交到底是逻辑上的新提交还是上一个提交的后继是很难判断的,这时候怎么处理?如果不是,你是否真的要把这些麻烦事推给提交者,让他们定义每个拉取请求之间的依赖关系?在极端情况下,如果每个提交都发起一个拉取请求,你是否要让作者提交20次之后,创建的拉取请求中去标注19个依赖项呢?这样太二了!

还有一个更实际的问题:Git分支和拉取请求之间的相互作用。GitHub的实现汇总,一个拉取请求就对应一个Git分支来跟踪。如果我们有N个相互独立的拉取请求,就有N个Git分支。在最坏情况下,我们对每一个Git提交都有一个Git分支。同时管理N个在发展过程中的Git分支是很荒谬的做法。这样做会给拉取请求的提交者增加非常可观的额外开销。这样能完美地凸显Git的分支管理中引用游戏5的低效,我两年前的博客里就写过。(简言之,一旦你习惯了像Mercurial那样的工作流,不需要你为提交或分支命名,你就能感到Git强制给分支命名并且在所有命令中要求提供分支名称的设计整体上低效,且带来了巨大的额外开销。)当然可以实现一些工具来更高效地发起拉取请求。(例子参见 ghstack6)。但我认为Git分支和GitHub拉取请求之间的相互作用复杂到工具和流程已经无法解决问题了,除非是最浅显的或最好情况的场景。请注意,对于这个问题,任何足够好用的解决方案都涉及到对git rebase的改进,让分支能够在重写祖先提交后移动,而不是将分支留在重写前的旧版提交上。(说真的,这个功能应该有人来实现一下:将其作为本地分支的默认行为都说得过去。)换句话说,我不认为可以在不增加参与者过多负担的条件下实现一个稳定的多拉取请求并发模型,除非从根本上改变拉取请求基于Git分支的前提。(如果这个想法被证明是错的,我会很高兴。)

因此,我认为通用的 GitHub 模型如果不能将拉取请求等价于Git分支这个定义有效地分开(稍后会详细介绍),就无法简单地用多个拉取请求的模式来践行更多小规模变更的工作流程。而且,我也不是要暗示拉取请求之间的依赖关系无法实现:它们可以实现,GitLab 就是证据7。但是,GitLab的实现方式有些简单粗暴(我猜可能是因为比之更复杂的实现太过困难)。

因此,在不从根本上改变拉取请求与Git分支之间的关系的情况下,我们的选择只剩下在拉取请求中更加注重单个提交的变更而不是合并差异(merge diff)。我们现在来讨论这一点。

拉取请求有史以来都将重点放在合并差异上。也就是说,GitHub(或其他提供者)在后台将你提交的 Git 分支对目标分支运行 git merge,并将差异作为提议的修改单元,显示在审核界面的正中间:如果开始审核的时候单击的是“修改的文件”标签页,你就会看到这个整体合并差异。你可以单击 “提交” 标签页,然后选择一个单独的提交以仅审核该提交。或者,你可以使用 “修改的文件”标签页的下拉列表来选择单个提交以进行审核。这些(相对较新的)功能是非常可喜的改进,确实有助于执行单个提交的审核,而这正是实现更多小规模变更工作流必要条件。不幸的是,这还远不是完整实现该工作流程全部好处的充分条件。

默认值很重要,而 GitHub 的默认值是在进行代码审核时显示合并差异。(我敢打赌,很大一部分用户甚至不知道可以对单个提交进行审核。)由于更大的变更会导致更高的缺陷率和更慢的审核,GitHub 默认显示合并差异就意味着 GitHub 的默认选择了更低质量和更长时间的审核。(我认为这对网站的用户活跃度指标是有好处的,这会导致当前使用率的上升,也会因为带来的缺陷而推动用户后续的长期使用。但是我衷心希望产品经理并没有想着——让我们设计一款会破坏质量的产品来促进参与度吧。)

不幸的是,哪怕仅仅是做个极小的改动,把默认显示从合并差异改成单独的提交,都不那么简单。因为项目和代码作者并没有按完整提交的写法来执行,一个单独的提交并不都能被独立出来审核。

(提交写法的一种分类方式是判断:一系列提交中的每一个都是自洽的好变更;还是只有整个一系列提交全部应用之后才产生效果。少数成熟的项目,比如Linux内核、Firefox、Chrome、Git和Mercurial,是按一系列单独成型的提交模型来执行的,我称之为以提交为中心的工作流。我打赌GitHub和类似平台上的大多数项目执行的模型则是,我们只关心一系列提交的最终结果。验证后一种模型的试金石是,看拉取请求里是不是包含修复某某这样的提交,或看对拉取请求的修订是在追加提交还是修补之前的提交。我强烈主张,撰写一个整洁的提交历史,最终代码仓库历史里的每一个提交单独来看都是站得住的。但我倾向于更成熟的软件开发时间,我本人就是个版本控制专家。如果要在这个主题上继续辩论,就要另写一篇文章了。)

如果GitHub或其他平台不改变拉取请求和Git分支的关系,而仅将拉取请求的默认值改为对每个提交的审核,将会迫使大量经验不足的用户去熟悉如何改写Git历史。这会给拉取请求作者施加可观的痛苦,从而让平台用户不满、降低参与度等等。所以我认为这个全局默认值的修改不可行。倘若Git中修改历史的用户体验好一些,或者我们不需要去改变一个已经做了十年的行为,这个问题会更好处理一些。但是拉取请求的实现并不需要做全局变化:平台们想要纠正方向,只需要给执行完整提交实践的项目增加一个选项,允许将审核默认值从合并差异改为强调单独的提交即可。这将在鼓励编写及审核单独的提交方向上迈出一大步,对审核速度和代码质量有积极的影响。

即使这些平台真的在拉取请求的审核中默认强调单个提交,仍然存在一些重大缺陷,这些缺陷会破坏我们想要做到的更多小规模变更的工作流。

尽管可以审核单个提交,但所有审核注释仍会聚集在拉取请求的活动时间线视图里。如果提交者和审核者努力编写和审核各个提交,收获却是对离散变更单元的所有反馈被汇总为对整个拉取请求的一个巨大的反馈集合。这种聚合成堆的反馈(当前正是如此)让人很难识别一个评论是针对哪个提交的,也帮不了代码作者确定哪个或哪些提交需要修改来解决反馈的问题。这破坏了以提交为中心的工作流的价值,并促使提交作者采取修复某某的提交风格。为了有效地执行单个提交的审核,审核评论和讨论需要在每个提交下分别展示,而不是汇总到拉取请求的时间线里。这是对拉取请求用户界面的重大更改,而且工作量巨大,因此可以理解为什么这尚未发生。而且,这样的工作还需要解决一些微妙的问题,例如在面对强制推送(force pushes)时如何保留评论。如今,当发生强制推送时,GitHub 的评论可能会丢失上下文。情况已经比以前好了,曾经这些留在单个提交上的审核注释会被删除(是的:GitHub 确实在好几年的时间里都丢失过代码审核评论。)但是,即使在工具方面有所改进,问题仍然存在。假如我们要采用提交级别的审核跟踪,就需要解决这些技术问题,才能满足采取这种工作流的用户。

即使 GitHub(或其他平台)实现了稳定的拉取请求单个提交审核的功能,速度仍然存在问题。问题是,如果拉取请求是集成(或者说:合并)单元,那么你必须等到每个提交都经过审核后才能进行集成。这也许听起来可以忍受(毕竟,这正是我们现在的做法)。但是我认为,与变更一旦准备好就能立即被集成,而不必等待其后的其他变更相比,这并不是最佳选择。身为作者和维护者,如果我看到可以集成的变更,则希望尽快将其集成。一个可以被集成的变更等待时间越长,就越容易发生问题(该变更会由于系统中的其他变更而失效)。尽早集成通过审核的变更也可以缩短获得有意义反馈的时间:如果一系列变更中的前几个就有根本性问题在集成之前未发现,则尽早集成较早的变更而不等后续变更,可以更快地暴露问题。这样可以最大程度地减少系统变更的增量(通常使回溯问题变得更容易),在真的发生问题时将破坏半径最小化,并允许变更作者有更充分的时间,在更少的压力下修改尚未集成的后续提交。除此之外,更频繁地进行集成本身就让人感觉更好。渐进原则8指出,人们在持续进步时会感觉更好,工作表现更好。但是挫折不仅仅抵消了小胜利的力量。虽然我不知道在这方面有没有明确的研究,但我对编写变更和维护项目的渐进原则的解释是(该解释被我观察到的一些轶事所支持),源源不断的集成变更给人的感觉比在一个巨大的变更审核中徘徊不前要好得多,后者通常看起来像是永远结束不了的。尽管你需要意识到不能将变动与有意义的改善混为一谈,但我认为 “渐进原则” 真正有用,我们应该力求一旦变更准备就绪,就立即集成。版本控制和代码审核中应用时,这意味着当作者、审核者和我们的机器状态检查报告都同意提交,就可以集成,而不必等待更大的工作单元(例如拉取请求)。简而言之,只要条件允许,就向前推进!

更快集成的希望对拉取请求有重大意义。在GitHub的拉取请求实现中,我还是看不到当前的拉取请求怎样才能不做结构上的大改就满足这一愿望。对于初学者来说,审核必须增加对每个提交的状态监控,否则集成一个没有完整内容的单个提交几乎无意义。但这会带来我前文描述过的复杂性。还有用Git分支能否有效定义拉取请求的问题。如果一个拉取请求的部分提交已经被集成,作者又把已经在拉取请求的里的提交和本地分支做了变基或合并,会发生什么?这有时行得通。但当行不通的时候,作者就会处于合并冲突陷阱里,一个又一个提交无法被自动应用,本来精心编写的一系列提交很快就变成了前进的包袱和阻力。(顺便一提,Mercurial版本控制工具有个概念叫变更集合演化,它能监控提交——Mercurial中叫变更集——被重写成其他提交然后像变基那样将其处理好。例如,有提交XYX已经通过变基被集成为X',用hg rebaseY变基到X'时,工具会发现X已经被重写为X',跳过X的变基不做,因为这已经做过了。这可以避开很多重写历史的问题——比如合并冲突——并且可以让终端用户体验变得更好。)在拉取请求工作流中,尽管变更一准备就绪就集成是可能做到的,我还是认为这个过程很尴尬,因为做了那么多适应工作流的改动后,它和我们熟知的拉取请求工作流已经没什么相似之处了,那就是个完全不同的工作流。

上面的论点过分强调了这样一个假设,即较小的更改对质量和/或速度更有利,我们也应该围绕此假设设计工作流程。 虽然我坚信更小变更单元的优点,但其他人可能会不同意。(如果你不同意,你应该问自己是否相信相反的观点:较大的变更单元更有利于质量和速度。我怀疑大多数人无法解释这一点。但是,我确实相信这样的论点,较小的变更单元会产生额外的每单元成本,或者产生二阶效应,从而达不到其声称的质量或速度优势。)

但是,即使你不接受变更大小的说法,仍然有站得住脚的理由来在当前实现之上的层次思考拉取请求:工具的可扩展性。

当前拉取请求的实现与Git的默认工作方式紧密耦合。在推到远端仓库的一个Git分支上发起拉取请求。创建拉取请求后,服务端会创建一个Git分支/引用,引用该拉取请求的head提交。在GitHub上,这些引用被命名9pull/ID/head(您可以从远端Git库中获取它们,但它们默认不会被获取)。同样,在创建或更新拉取请求时,会执行git merge来产生差异以供审核。在GitHub上,生成的合并提交被保存下来,并在开启的拉取请求中通过引用pull/ID/merge指向它,这也是可以被获取到本地的。(当拉取请求关闭时,合并引用将被删除。)

这里存在我们的可扩展性问题:Git ref的无限增长和项目变更的频率不断提高。每个 Git 引用都会增加图遍历操作和数据交换的开销。尽管所涉及的操作不断被优化(通常是用更高级的数据结构或算法来达到),但这种无限制的增长存在着内生的扩展性挑战,作为版本控制工具的维护者,我不想陷入其中。技术解决方案是否可以扩展到数百万个Git引用?算是吧。但这需要诸如JGit的Reftable10那样的高效率解决方案,该解决方案大约经过90轮审核,历时约4个月,才落地。而且这还是因为早在2017年7月11就首次提出该功能的设计。请不要误会,Reftable的存在令我高兴吗?是的。这是解决实际问题的绝佳解决方案,理解其原理可能会使您成为更好的工程师。但与此同时,它解决的问题本身没必要存在。将图数据结构和算法扩展到数百万甚至数十亿个节点、边和路径的空间存在,但您的版本控制工具不应该处于其中。数以百万计的提交和文件,没问题。但是,鉴于在工具的各个部分增加了太多复杂性,引入数百万个DAG头来扩展遍历该图的不同路径的数量简直要疯。我认为,这样的方案需要超过合理预期的大量投资才能在大规模下正常运作。作为一名工程师,面对此类问题时,我倾向于一开始就去避免它们。最容易解决的问题就是不曾遇到的问题。

不幸的是,拉取请求与Git分支/引用的紧密耦合引入了无限制的增长以及与之相关的众多问题。大多数项目可能无法发展到遇到这些问题的规模。但是,作为在多家公司都具有该问题经验的人,我可以告诉您这个问题是非常实际的,一旦达到一定规模,它所产生的性能和可扩展性问题就会降低使用当前拉取请求实现的可行性。由于我们大概能够解决Git潜在的扩展性问题,我认为Git引用的爆炸式增长不会成为扩展拉取请求的长期阻碍。但是目前它是个障碍,直到Git及其基础之上的工具得到改进之前,都会保持这个状况。

总结一下,拉取请求的一些高层次问题如下:

  • 默认情况下,对合并差异进行审核会鼓励使用较大的审核单元,这会降低质量和速度。
  • 无法将拉取请求中的提交进行增量集成,这会减慢速度,推迟有意义的评论出现的时间,并降低士气。
  • 拉取请求与 Git 分支的紧密耦合增加了工作流程的刚性,从而使工作流程变得不够灵活且不那么理想。
  • Git 用户体验中的缺陷,尤其是在进行重写(包括变基)时,大大限制了能通过拉取请求安全执行的工作流程。
  • 拉取请求与 Git 分支的紧密耦合会导致大规模系统的性能问题。

反言之,我们就能得到一组更理想的结果:

  • 审核体验对单个提交而非合并差异优化,使评审单元变小,质量和速度改善。
  • 能从一个大集合中将准备好的提交单独进行增量集成,改善速度,缩短获得有意义评论的时间,提高士气。
  • 您采用的Git分支模型对拉取请求处理不会产生什么重大限制。
  • 你可以用你想用的版本控制工具,不用担心工作流被拉取请求的工作方式束缚住。
  • 拉取请求服务端可以相对简单地扩展到最苛刻的使用场景。

让我们谈谈如何实现这些更理想的结果。

探索替代模型

拉取请求仅仅是集成提议的变更的普遍问题的一种实现模式。其他工具使用其他模式。在描述它们之前,我想创造一个术语 集成请求(integration request) 来指代要求将某些更改集成到其他地方的通用概念。例如,GitHub拉取请求和GitLab合并请求都是对集成请求的实现。

我不想详述替代工具,而是概述不同工具与拉取请求不同的关键点,并评估不同方法的优缺点。

使用版本控制系统进行数据交换

可以通过如何利用版本控制工具来对集成请求的实现进行分类。

在Git和GitHub出现之前,你可能正在运行一个集中式的版本控制工具,它不支持离线提交或功能分支(如CVS或Subversion)。在这个世界上,常见的集成请求机制是通过各种媒体交换差异或补丁,如电子邮件、发布到代码审核工具的 Web 服务等。你的版本控制工具并没有直接与VCS服务器对话来发起集成请求。相反,你会运行一个命令,导出一个基于文本的变更表示,然后将其发送到某个地方。

今天,我们可以根据集成请求是否使用版本控制工具的原生协议来交换数据,或者是否通过其他机制交换补丁,对集成请求进行分类。拉取请求使用版本控制系统的原生协议。像 Review Board 和 Phabricator 这样的工具通过自定义 HTTP Web 服务交换补丁。通常情况下,使用非原生交换的工具需要额外的客户端配置,包括可能需要安装一个自定义工具(例如Review Board的RBTools或Phabricator的Arcanist)。不过现代的版本控制工具有时会内置这种功能。例如,Git和Mercurial满足了Zawinski定律12,而Mercurial在其官方发行版中也有Phabricator扩展。

一个有趣的例外是Gerrit,它通过git push来接收集成请求。(参见文档13) 但是Gerrit通过git push接收请求的方式与拉取请求的工作方式有本质上的区别。在拉取请求中,你将本地分支推送到远程分支,然后围绕着这个远程分支建立一个拉取请求。使用Gerrit,你的推送命令是像这样的 git push gerrit HEAD:refs/for/master。对于非大师级用户来说,head:refs/for/master语法的意思是,HEAD提交(实际上是当前工作目录对应的提交)推送到gerrit远端上的refs/for/master 引用SOURCE:DEST语法指定了本地版本标识符与远端引用的映射)。这里幕后的向导是Gerrit运行一个特殊的Git服务器,它为refs/for/* 引用实现了非标准行为。当你向 refs/for/master 推送时,Gerrit 会像普通的 Git 服务器一样接收你的 Git 推送。但它并没有编写一个名为 refs/for/master 的引用,而是将传入的提交数据消化成一个代码审核请求。Gerrit 会为推送的提交创建 Git 引用。但它主要是为了内部跟踪(Gerrit将所有的数据都存储在Git中——从Git数据移植到审核注释)。如果这个功能对你来说还不够神奇的话,你也可以通过引用名传递参数给 Gerrit!比如 git push gerrit HEAD refs/for/master%private 会创建一个需要特殊权限才能看到的私有审核请求。(对于普通用户来说,为了增加功能而重载引用名是否是一个好的用户体验,这一点值得商榷。但毋庸置疑这是一个很酷的做法!)

从表面上看,使用版本控制工具的原生数据交换似乎是一种更优越的工作流程,因为它更原生、更现代化。(通过电子邮件发送补丁是如此的老套。)。再也不需要配置客户端工具来导出和提交补丁了。取而代之的是,你运行git push,你的变更可以自动或点击几下鼠标就变成一个集成请求。而且从技术层面上看,这种交换方法可能更安全,因为在不丢失数据的情况下将变更表示为纯文本再转换回来是非常难办到的。(例如JSON缺少无损二进制数据交换能力,除非先编码为base64那样的形式,这往往意味着许多服务交换基于文本的补丁是有损的,特别是在存在不符合UTF-8的内容时,这在测试中可能是很常见的。要是告诉你有多少工具在将版本控制提交或差异转换为文本时会丢失数据,你肯定会惊讶。我跑题了,按下不表。)与交换文本补丁相比,用 Git 的传输协议交换二进制数据更安全,而且可能更容易使用,因为它不需要任何额外的客户端配置。

但是,尽管通过版本控制工具进行交换的方式更原生、更现代,而且可以说更稳健,这不见得就更好

对初学者来说,使用版本控制工具的原生传输协议可以避免在客户端上任意使用其他各种版本控制工具。当你的集成请求需要使用版本控制工具的传输协议时,客户端很可能需要运行该版本控制工具。使用其他方法,如交换基于文本的补丁,客户端可以运行任何理想的软件:只要它能以服务器所需的格式输出补丁或API请求,就可以创建一个集成请求!这意味着,版本控制工具的选择权被锁定可能性更小。只要人们愿意,都可以在自己的机器上使用自己的工具,而且(但愿)他们不必把自己的选择强加给别人。举个例子,大部分的 Firefox 开发者使用 Mercurial——经典版本库的版本控制工具,但更多人在客户端使用 Git。因为Firefox使用Phabricator(更早前是Review Board和Bugzilla)进行代码审核,而且Phabricator会接受基于文本的补丁,所以客户端上的版本控制工具的选择并不重要,而服务器上的版本控制系统的选择也不会因为迫使开发者使用自己不喜欢的版本控制工具而引发圣战。诚然,有很好的理由(组织开销是其中之一)去使用统一的工具,有时对工具使用的强制要求是合理的。但在很多情况下(比如偶尔一个对开源项目的贡献),这可能并不重要,也不应该备受关注。而在Git和Mercurial这样的情况下,像神奇的git-cinnabar14这样的工具可以在没有数据丢失和可接受的开销的情况下轻松地在版本库之间进行转换。采用版本控制工具的原生传输协议可能会排斥或抑制贡献者的生产力,因为这会强制人们使用特定的、不想用的工具。

使用版本控制工具的传输协议的另一个问题是,它往往会强迫或鼓励你以某种特定方式工作。以 GitHub 的拉取请求为例。拉取请求是围绕着你git push的远程Git分支来定义的。如果你想更新那个分支,你需要知道它的名字。所以这需要一些额外开销,要么创建和跟踪分支,要么更新时候找到分支的名称。这与Gerrit不同,在Gerrit里没有一个显性的远程分支,你只需在git push gerrit HEAD:refs/for/master,它就会把后面的事情办妥(这一点稍后会有更多的说明)。使用Gerrit时,我不需要创建一个本地的Git分支来发起集成请求。而使用拉取请求时,我不得不这样做。而这会迫使我采用效率较低的工作流程,降低我的工作效率。

我们最后进行比较的方面是可扩展性。当你把版本控制工具的传输协议当作集成请求的一部分时,你就引入了扩展版本控制服务器的问题。我曾经做过多个涉及扩展版本控制服务器的工作,并且对Git和Mercurial的传输协议的底层细节非常了解的人,听我一句:别跟扩展版本控制服务器的工作扯上关系。Git 和 Mercurial 的传输协议都是在很早以前,并且也不是由网络协议专家设计的。它们从传输协议层面的根基上来看就很难扩展。我听说曾经有一段时间,Google最昂贵的单台服务器是他们的Perforce或Perforce衍生的服务器(这是几年前的事了,后来Google已经转向了更好的架构)。

网络协议不佳的版本控制工具有很多副作用,包括服务器不能使用分布式存储,至少极为困难。因此,为了横向扩展计算容量,你需要投资昂贵的网络存储解决方案,或者设计一个复制和同步策略。我身为一个在三家公司从事数据同步产品(源代码控制之外的领域),我只想说:这个问题你别想着自己去解决。数据同步本质上是困难重重的,而且充满了艰难的权衡。如果你有选择的余地,最好选择避开这个问题。

如果创建 Git 引用是创建集成请求的一部分,那么你就引入了 Git 引用数量的扩展性挑战。这些 Git 引用是否会永远存在?当你有成千上万的开发人员,可能都在同一个版本库中工作,并且每年的引用或引用变化的数量增长到几十万或几百万时,会发生什么?

你的版本控制服务器能以合适的性能来处理每一两秒一次的推送吗?除非你是Google、Facebook,或者我所知道的其他少数几家公司,否则它不能。在你大喊我所说的问题只困扰着0.01%的公司之前,我可以举出几个规模不到这些巨头10%的公司,而他们遇到了这个问题。而且我也敢保证,很多人根本没有客户端指标来衡量他们的git pushP99时间或可靠性,甚至都没有意识到问题的存在!扩展版本控制可能不是你公司的核心业务。不幸的是,因为设计或使用不当的工具,公司常常被迫在这方面分配资源来解决。

把用原生版本控制服务器来扩展集成请求的挑战与只交换补丁做个对比。使用更原始的方法,你可能是通过HTTP将补丁发送到一个Web服务。而使用Phabricator和Review Board等工具,该补丁会被转化为关系型数据库中的一行行记录。我敢断定扩展一个架在关系型数据库前的HTTP Web服务会比扩展你的版本控制服务器更容易。别的不说,它总应该更容易管理和调试,因为这些领域的专家比版本控制服务器领域的专家要多得多!

确实,很多人都不会碰到版本控制服务器的扩展性限制。而一些巧妙的扩展性解决方案确实存在。但是,如果集成请求不那么倚重于版本控制工具的默认操作模式,那么这个问题空间的很大一部分是完全可以避免的,包括版本控制工具维护者不得不在他们的工具中支持巨大的扩展向量。不幸的是,像GitHub的拉取请求和Gerrit使用Git 引用来存储一切的解决方案,都给版本控制服务器的扩展带来了很大的压力,一旦达到一定的规模,就会成为一个非常现实的问题。

希望上面的段落能给你带来一些启发,让你了解到数据交换机制的选择对集成请求的影响!下面我们再来看另一个对比的方面。

跟踪提交

人们可以根据在集成生命周期中如何跟踪提交的方式来对集成请求的具体实现进行分类。我是指,集成请求如何跟住一个逻辑变更的演变过程。例如,如果你做了一个提交,然后修改它,系统如何能知道提交X演化成了X'

拉取请求并不直接跟踪提交。相反,提交是 Git 分支的一部分,而这个分支是被跟踪的实体,拉取请求围绕这个分支建立。审核界面将合并差异显示在正中间。它可以查看单个提交。但据我所知,现在这些工具中没有一个能明确跟踪或映射多次变化前后的各个提交。相反,它们仅仅假设提交顺序不变。如果在现有的一系列提交中有重新排序、添加或删除提交,那么工具很容易混淆。(在 GitHub 中,有过这样的情况,在提交上留下的审核注释完全消失了。这个行为已经被修正了,现在如果GitHub不知道在哪里显示之前提交中的注释,就会在拉取请求的时间线视图中显示这条评论。)

如果你只熟悉拉取请求,你可能想不到还有其他的提交跟踪方式!事实上,最常见的替代方法(不是什么都不做)完全早于拉取请求就出现了,现在仍然被各种工具所采用。

Gerrit、Phabricator和Review Board的工作方式是,提交消息中包含唯一的令牌,用于识别该提交的集成请求。例如,Phabricator审核的提交消息包含一行 Differential Revision: https://phab.mercurial-scm.org/D7543。Gerrit的提交包含这样的信息Change-Id: Id9bfca21f7697ebf85d6a6fa7bac7de4358d7a43

这个注解在提交消息中出现的方式因工具而异。Gerrit的web UI宣传了一个shell单行脚本来克隆版本库,它不仅可以执行git clone,还会用curl从Gerrit服务器上下载一个shell脚本,将其作为Git的commit-msg钩子安装到新克隆的版本库中。这个 Git 钩子将确保任何新创建的提交都有一个 Change-ID: XXX 行,其中包含一个随机生成的、被认为是唯一的标识符。Phabricator 和 Review Board 利用客户端工具在提交到各自的工具后重写提交消息,使提交消息中包含代码审核的 URL。哪种方法更好仍有争议,它们各有优点和缺点。幸运的是,这个争论与本篇文章无关,所以我们在这里就不讨论了。

重要的是如何使用提交消息中的这个元数据。

当提交消息元数据被录入到集成请求中时,提交消息元数据就会发挥作用。如果一个提交报文缺少元数据或者引用了一个不存在的实体,接收系统会认为它是新的。如果元数据与文件中的实体相匹配,那么传入的提交往往会自动匹配到现有的提交,即使它的Git SHA不一样!

这种在提交信息中插入跟踪标识符的方法,对于跟踪提交的演变过程非常有效。即使你修改、重新排序、插入或删除了提交,工具也能找出与之前提交的提交匹配的内容,并对状态进行相应的调整。不同工具对这项功能的支持不尽相同。Mercurial 的提交到 Phabricator 的扩展足够智能,可以参考本地提交的 DAG,在 Phabricator 中改变审核单元的依赖关系来反映新的 DAG 形状

更简单、更现代的拉取请求功能常常不如之前的解决方案好用的另一处地方,是追踪提交。是的,在提交消息中插入标识符感觉太像个hack,有时会很脆弱(有些工具没有很好地实现提交重写,这可能会导致用户体验不佳)。但你无法反驳它取得的成效:使用显式的、稳定的标识符来跟踪提交,比起拉取请求所依赖的启发式算法要稳健得多。假阴性/阳性率要低得多。(我从第一手经验中了解到这一点,因为在部署Phabricator之前,我们曾尝试为Mozilla的一个代码审核工具实现了提交跟踪启发式算法,结果发现有很多我们无法正确处理的边界问题。而这还是在使用了Mercurial的过时标记的情况下,它给我们提供了由版本控制工具直接生成的提交演化!如果这样都做不好,很难想象还有什么启发式的方法可以做好。我们最终放弃了,改为在提交消息中使用稳定的标识符,这修复了大部分恼人的边界问题。)

使用显式提交跟踪标识符可能看起来并没有什么大不了,但它的影响是深远的。

跟踪标识符的明显好处是,它们允许重写提交,而不会将集成请求工具搅乱这意味着,人们可以进行高级历史重写,而几乎不会影响到集成请求。我是一个重写历史的重度用户。我喜欢策划一系列单独的高质量的提交,这些提交可以各自独立存在。当我把这样的系列提交到GitHub的拉取请求中,并收到了一些我需要修改的反馈,当我颁布这些修改的时候,我不得不思考,我在这里重写历史会不会增加重审的难度?(我尽量理解审核人,并尽可能让他们的工作省心一些。我会自问,如果我在审核别人的修改时,我希望他做什么,然后我自己就这样做。)对于 GitHub 的拉取请求,如果我在一个系列的中间重新排序提交或添加、删除一个提交,我发现到这可能会使那些提交上留下的审核意见很难被找到,因为 GitHub 无法理解历史重写。而且这可能意味着这些审核意见会丢失,最终没有被执行,导致错误或其他有缺陷的变更。这是一个关于工具缺陷导致了次优工作流程和结果的教科书式案例:由于拉取请求没有明确跟踪提交,我不得不采用非理想的工作流程,或者牺牲一些东西比如提交质量,以最大限度地降低风险,避免审核工具搞混淆。总的来说,工具不应该把这类成本或权衡因素暴露给用户:它们应当开箱即用,并且为公认的立项结果来优化。

追踪标识符的另一个好处是,它们使单个提交的审核变得可行。一旦你可以跟踪单个提交的逻辑演变,你就有足够的信心将审核评论与单个提交关联起来。使用(当前实现的)拉取请求,你可以尝试将评论与提交关联起来。但是,由于你无法以可接受的高成功率来跟踪跨重写的提交,所以重写的提交经常会掉队,从而导致像审核评论这样的数据与它针对的提交孤立开。数据丢失是很糟糕的,所以你需要一个地方来收集这些孤立数据。拉取请求的活动时间线就可以实现这个功能。

但是一旦你能可靠地跟踪提交(Gerrit和Phabricator这样的工具证明了这一点是可能的),你就不会有这种严重的数据丢失问题,因此不需要担心找地方收集孤立数据的问题了!于是您可以创建单个提交的审核单元,每个单元都可以与其他提交松散地耦合在一起,整个一系列提交反映了你想达到的结果。

有趣的是,我们可以注意到不同工具采取的不同方法。更有趣的是,我们可以注意到审核工具能做到什么,而它们默认状态下做了些什么!

让我们来看看Phabricator。Phabricator的审核单元是Differential修订。(Differential是Phabricator中的代码审核工具的名称,它实际上是一个功能套件,类似GitLab,但功能并不完善。)一个Differential修订代表了一个单独的差异(diff)。Differential修订之间可以有父子关系。在Phabricator的术语中,这样关联的多个Differential修订形成了一个概念上的。请访问 https://phab.mercurial-scm.org/D4414,搜索栈(stack)来查看它的实际情况。(这个名字有点误导性,因为父子关系实际上形成了一个DAG,Phabricator能够在其图形界面中渲染出有多个子节点的情况。) Phabricator的官方客户端提交工具——Arcanist或arc——默认的行为是将所有的Git提交折叠成一个Differential修订

Phabricator 可以保存来自于单个提交的元数据(它至少可以在Web 界面中呈现提交信息,这样你就可以看到 Differential修订的来源)。换句话说,默认情况下,Arcanist会为每一个提交构造多个Differential修订,因此不会为它们构造父子关系。所以这里没有出现。说实话,我也不确定Arcanist的新版本是否支持这样做。我知道Mercurial和Mozilla都开发了定制的客户端工具,用于提交给Phabricator,以解决Arcanist中这样的缺陷。Mozilla的定制工具不一定适合Mozilla以外的用户,对此我不确定。

Phabricator的另一个有趣的方面是,Phabricator没有一个总体提交序列的概念。相反,每个Differential修订都是独立存在的。它们可以形成父子关系并构成一个。但是并没有主用户界面或API(我上次看了一下)。这可能看起来很激进。你可能会问一些问题,比如我如何跟踪一系列提交的整体状态,或者我如何传达与整个提交序列相关的信息。这些都是很好的问题。但是不必深究这些问题,答案听起来很激进,不需要将一些列Differential修订的整体作为跟踪对象,现在的方案就已经能用了。而且,Mercurial项目使用这个工作流程已经有几年了,我敢说我并没觉得我想要这个功能。

Gerrit也是值得研究的。和Phabricator一样,Gerrit在提交消息中使用标识符来跟踪提交。但Phabricator在首次上传到服务端时重写提交消息,以加入上传过程中创建的URL,而Gerrit在创建提交时就往提交消息中加入唯一的标识符。服务器维护提交标识符到评审单元的映射。除了实现细节之外,最终的结果是相似的:可以更容易地跟踪单个提交。

Gerrit 与 Phabricator 不同的是,Gerrit 对多个提交有更强的分组功能。Gerrit会追踪提交在一起的提交,并且会自动呈现关系链一起上传的提交列表。尽管它缺乏Phabricator中的视觉美感,但它很有效,并且在界面中默认显示,而Phabricator默认不展示这些。

与Phabricator的另一个不同之处是,Gerrit默认使用了单个提交审核。Phabricator需要非官方客户端来把一系列提交构成一个链,而Gerrit默认就是这样。而且据我所知,无法让Gerrit将本地提交压缩到单个diff来审核:如果你想一并审核,你必须先在本地压缩提交,然后推送被压缩的提交。(更多关于这个话题的内容将在后文介绍。)

单个提交审核的另一个好处是,这种模式可以实现增量集成工作流,此时,一个系列或一组提交中的某些提交可以比其他提交先集成,而不需要等待整个批次完成审核。提交的增量集成可以大大加快某些工作流的速度,因为提交一旦准备好了,就可以立即集成,而不是等待更长时间。这种模式的好处不可估量。但实际部署这种工作流可能会很棘手。一个问题是,当你对部分落地的状态进行变基或合并时,你的版本控制工具可能会混淆。另一个问题是它可能会增加版本库的整体变更率,这可能会使从版本控制到持续集成再到部署系统的压力都会增加。还有一个潜在的问题涉及到如何把审核签发集成签发的区别表达出来。许多工具/工作流程混淆了我签发这个变更我签发落地这个变更。虽然在很多情况下,它们实际上是等价的,但也有一些真实的情况下,你想把它们分开跟踪。而采用一个提交可以逐步集成的工作流会暴露出这些边界情况。所以在你走这条路之前,你要考虑清楚是谁来集成提交,以及何时集成提交。(无论如何你都应该考虑这个问题,因为这很重要。)

设计一个更好的集成请求

在描述了拉取请求的一些问题和解决集成请求的一般问题的替代方法之后,是时候回答这个价值百万美元的问题了:设计一个更好的集成请求。(当你考虑到人们在拉取请求中花费的时间,以及由于现有工具设计导致的缺陷、低质量修改的成本,改善全行业的集成请求,将比100万美元的价值要高得多)。

需要提醒大家的是,拉取请求从根本上来说是围绕通用Git功能分支工作流程构建的漂亮的界面和功能集合。这个属性从2007-2008年最早的拉取请求时就被保留了下来,之后的几年里也被Bitbucket和GitLab等厂商复制了。在我看来,拉取请求应该已经成熟了,可以进行大修了。

把派生换掉

我想对拉取请求做的第一个改变是不再把派生(forks)作为工作流程的必要部分。这可能看起来很激进。其实不然!

像 GitHub 这样的服务上的派生是一个完整的项目,就像它的派生的原始项目一样。它有自己的问题、百科、发布、拉取请求等等。现在来投个票:你经常在派生上使用这些功能吗?我也没有。在绝大多数情况下,派生的存在只是作为启动一个针对被派生的版本库的拉取请求的载体。它几乎没有什么额外的有意义的功能。我现在并不是说派生没用,它们当然有用!但如果有人想对一个仓库提出修改,派生并不是必需的,它的存在是由当前的拉取请求实现强加给我们的。

我在上一句中说强加,是因为派生会带来开销和混乱。派生的存在可能会让人迷惑,不知道一个原始项目在哪里。派生也会增加版本控制工具的开销。它们的存在迫使用户不得不管理一个额外的 Git 远程和分支。这迫使人们必须记住在派生上保持分支的同步。好像记得保持本地版本库的同步还不够难一样。如果要推送到派生项目,你需要重新推送已经推送到原始仓库的数据,哪怕这些数据已经存在于服务器上(只是在不同的 Git 仓库中)。(我相信Git正在进行传输协议的改进,以缓解这个问题。)

如果仅仅是作为发起集成请求的工具,我不认为派生能提供足够的价值来证明它应当存在。派生是否应该存在:是的。是否应该强迫人们使用派生来贡献变更,不。(派生的有效用例是对一个项目进行社区拆分,或是创建一个独立的实体来更好地保证数据的可用性和完整性。)

派生本质上是服务器端git clone之上的一个装饰面板。而之所以要使用独立的 Git 仓库,可能是因为 GitHub 最早的版本不过是一堆抽象的 git 命令。这个服务一炮而红,其他人几乎是原样复制了它的功能,没有人回过头来想,当初我们为什么要这样做

要回答我们会用什么来代替派生,我们必须回到第一原则,问问我们想要做什么。答案是针对现有的项目提出一个修改单元。而对于版本控制工具来说,你只需要提出一个补丁/提交就可以了。所以,要取代派生,我们需要一个替代机制来向现有项目提交补丁/提交。

我倾向于用直接向原始仓库做git push的方式来替代派生。这可以像Gerrit的实现那样,将其推送到一个特殊的引用,例如git push origin HEAD:refs/for/master。或者,采用我更喜欢的解决方案,版本控制服务器可以对推送的处理逻辑变得更智能一些,尤其是当运行在特定模式下,甚至可以改变git push命令执行的动作。

一个想法是让Git服务器根据认证用户的不同暴露不同的引用命名空间。例如,我在GitHub上使用的是indygreg。如果我想对一个项目提出修改,比如说python/cpython,我就会用git clone [email protected]:python/cpython。接着我创建一个分支,比如 indygreg/proposed-change。然后执行git push origin indygreg/proposed-change,由于分支的前缀与我的用户名相匹配,服务器会放行。这样,我就可以打开一个拉取请求而不需要派生!(使用分支前缀不是很理想,但在服务器上实现起来应该是比较容易的。更好的方法是重映射 Git 引用名。但这在当前版本的 Git 中可能需要进行更多的配置,用户会嫌麻烦。一个更好的解决方案是让 Git 增加一些功能来简化这个过程,例如 git push --workspace origin proposed-change 将会把 proposed-change 推送到 origin 远端的工作区,而Git自己知道如何将其转化为一个正确的远端引用更新。)

另一个想法是让版本控制服务器发明一个交换提交的新概念,基于提交集合而不是 DAG 同步。从本质上说,服务器无需复杂的搜寻过程将提交与底层 Git 仓库进行同步,而是接收到提交后,在目标仓库一旁(不是在仓库内部)存储和展示的提交集合。这样一来,你就不会把版本库的 DAG 扩展到无数多个HEAD引用,那是个很难解决的问题。一个具体的实现方法可以是在客户端运行一个 git push --workspace origin proposed-change 来告诉远程服务器将你的proposed-change分支存储在你的个人工作区中(很抱歉重复使用了上一段的术语)。Git 服务器会接收到你的提交,生成一个独立的blob来存储这些提交,然后将这个blob保存到一个像S3这样的键值存储中,然后在别处的一个也许是关系型数据库中,更新哪个提交/分支在哪个blob中的映射关系。这将有效地将核心项目数据与更多的瞬时性分支数据分离开来,保持核心存储库的干净和纯粹。它可以让服务器架设基于更容易处理的数据存储库,如键值blob存储和关系数据库,而不是版本控制工具。我知道这个想法是可行的,因为Facebook为Mercurial实现了这个想法。infinitepush扩展本质上是在推送时,将Mercurial的bundle(持有提交数据的独立文件)抽出到blob存储中。在hg pull时,如果请求的这个修订不在仓库中,服务器会询问数据库中的 blob 索引,该修订是否存在。如果存在的话,这个blob/bundle就会被获取,然后在内存里动态地叠加到版本库上,再送达给客户端。虽然Mercurial官方项目中的infinitepush扩展有些不足(这不是Facebook的错),但其核心思想还是很扎实的,我希望有人能花点时间多花点时间来打理一下这个设计,因为它真的可以在逻辑上的将仓库扩展到无限的DAG头,而避免了实际去扩展DAG算法、仓库存储和版本控制工具算法来支持无限HEAD的复杂度。回到集成请求的主题,可以想象,有一个工作区推送的目标。例如,git push --workspace=review origin就会推送到review工作区,从而自动发起代码审核。

本博客精明的读者可能会发现这些想法很熟悉。几年前,我在 High-level Problems with Git and How to Fix Them 一文中提出了用户命名空间的建议。所以,你在那里阅读更多关于去掉派生的影响。

能否取消提交拉取请求过程中对派生的要求?可以!Gerrit 的 git push origin head:refs/for/master 机制证明了这一点。对于普通用户来说,Gerrit的方法是否太神奇或者太烧脑?我不太清楚。Git 能否增加一些功能,让用户的体验更好,这样用户就不需要太过复杂和神奇,只需运行 git submit --for review 这样的命令就可以了?当然可以!

将关注从分支转移到单个提交

我理想中的集成请求围绕着单个提交,而不是分支。虽然客户端可以提交一个分支来发起或更新一个集成请求,但集成请求是由一组松散耦合的提交组成的,其中父子关系可以用来表达提交之间的依赖关系。每个提交都被单独评估。有些情况下可能需要检查多个提交,以全面理解提议的变更。UI方面需要支持对一组相关的提交进行批量操作(例如大量删除被放弃的提交)。

在这样的环境下,分支并不重要。相反,提交才是王道。因为我们放弃用分支名称来跟踪集成请求,我们需要一些东西来代替它,否则我们无法知道如何更新现有的集成请求!我们应该像Phabricator、Gerrit和Review Board这样的工具一样,在提交中添加一个持久化标识符,哪怕重写历史都能保持不变。(基于分支的拉取请求其实也应该这样做,这样重写历史就不会令审核工具混淆,也不会导致上文提到的注释被遗弃的情况)。

值得注意的是,如果人们没有编写和审核一系列更小规模的提交的话,以提交为中心的集成请求模式就一文不值!虽然行业巨头和我都极力鼓励撰写较小的提交,但以提交为中心的集成请求本质上并无法强迫你这样做。因为以提交为中心的集成请求并不强迫你改变你的本地工作流程。如果你是那种不想整理一大堆又小、又隔离得很好的提交的人(毕竟这确实需要更多的努力),没有人会强迫你这么做。相反,如果这是你的提交撰写风格,那么在提交提议的变更时,把这些提交压缩(squash)在一起,这个过程中可以选择重写你的本地历史。如果你想在你的本地历史记录中保留几十个简单修复的提交(fixup commits),没问题:只需让工具在提交时将它们压在一起即可。虽然我不认为这些简单修复提交有什么价值,也不应该被审核者看到,但如果我们愿意,我们可以让工具继续上传这些提交,并让它们可见(就像今天的GitHub的拉取请求一样)。但它们不会成为审核的关注点(就像今天的GitHub的拉取请求一样)。集成请求以提交为中心,并不会强迫人们采用不同的提交工作流程。但它确实可以让那些希望采用更成熟的提交方式的项目也可以得偿所愿。话虽如此,工具的实现方式会带来一些限制。但这并不是说以提交为中心的审核从根本上禁止在本地工作流中使用简单修复提交

我应该创建一个专门的帖子来宣扬以提交为中心的工作流的优点,但我还是借别人之口来说明我的观点吧,因为有些项目不使用现代的拉取请求,是因为以提交为中心的工作流不可行。当我在 Mozilla 工作的时候,转到 GitHub 的障碍之一就是拉取请求审核工具不符合我们的世界观,即审核单元应该是小的。(Google、Facebook 和一些著名的开源项目等都认同这个观点)。出于这篇文章前面提到的原因,我认为,只要拉取请求围绕着分支/合并差异,并且面对历史重写(由于缺乏健壮的提交跟踪)时不健壮,那些坚持更精细化的做法的项目就会继续回避拉取请求。再重申一下,审核规模和质量之间的联系已经明确建立起来了。而更高的质量,以及由于更少的缺陷而降低开发成本的长期成效,会把天平压向更有利的一方。即使用GitHub、GitLab或Bitbucket这样的产品时有很多好处,人们也会因质量和长期收益而选择以提交为中心的工作流。

现存工具中的好选择

更好的集成请求的一些特性在现有的工具中已经存在。很可惜很多功能并没有在GitHub、GitLab、Bitbucket 等拉取请求中实现。因此,为了改善拉取请求,这些产品需要借鉴其他工具的想法。

不用Git分支来构建集成请求(Gerrit、Phabricattor、Review Board等),而在提交消息中使用标识符来跟踪该提交。这有助于在变化前后跟踪提交。该模型具有明显优势。稳定的提交跟踪是以提交为中心的工作流程的必要条件。而且它还可以改善基于分支的拉取请求的一些功能。设计良好的集成请求需要一个稳定的提交跟踪机制。

Gerrit具有以提交为中心的工作流的同类最佳体验。这是我所知道的唯一流行的支持并默认采用该工作流的集成请求实现。实际上,这个默认选择根本无法更改!(在某些情况下,这样对用户不利,因为它迫使用户学习如何重写提交,而这在Git中通常很危险。如果Gerrit可以在服务端把提交压成一个审核单元就太好了。但是我理解不愿意实施此功能的理由,因为在跟踪提交方面存在着一系列挑战,我就不赘述了。)Gerrit在查看提议的变更时还在醒目处显示了相关的提交。

除了Gerrit之外,Phabricator是唯一一个我确定能实现相对好用的以提交为中心的工作流,而不会引起本文前面提到的被孤立评论,上下文过载等问题。但这需要配合非标准的提交工具,一系列的提交在网页界面也不突出。因此,Phabricator的实现不如Gerrit那么靠谱。

另一个值得称赞的Gerrit功能是提交机制。您只需git push到一个特殊引用。就这么简单。无需创建派生项目。无需创建Git分支。无需在推送后再创建一个拉取请求。只需要Gerrit收到推送的提交,然后将其转化为审核请求。完全不需要任何额外的客户端工具开发!

与其他工具相比,使用一个通用的git命令提交和更新集成请求更简单,更直观。Gerrit的提交方式完美吗?不。git push origin HEADrefs/for/master的语法不直观。用编码引用名称的URL来重载提交选项,是虽然有效,但投机取巧的做法。不过用户可能很快就能学会用一行代码搞定或创建更直观的别名来操作。

Gerrit通过仅使用git push就能发起集成请求的优雅方案自成一派。如果全世界的GitHub都降低了提交拉取请求的复杂性,简单到克隆原始代码库,创建一些提交并运行一个git命令就完成,我会欣喜若狂。希望将来提交集成请求的方式更像Gerrit,而不是其他。

需要做什么

更好的集成请求的某些特性尚不存在,或者需要大量工作才能达到我认为可行的标准。

对于利用原生版本控制工具进行提交的工具(例如,通过git push),需要进行一些工作来支持通过更普适的 HTTP 节点进行提交。我完全接受利用git push作为提交机制,因为这让终端用户的体验变得很便捷。但这是唯一的提交机制就不太好了。对此有一些论据支撑:例如我相信您可以通过GitHub的API从头开始形成一个拉取请求。这并不像向节点提交一个补丁那样简单,但它本应是那么简单的。即使是Gerrit健壮的HTTP API15,似乎也不允许通过该API创建新的提交/差异。无论如何,此限制不仅将已有的非Git工具排除在使用这些工具的使用场景之外,还限制了在不使用Git的情况下编写其他工具来进行提交。例如,您可能想写一个发起自动变更的机器人,生成差异比调用git要容易得多,因为前者不需要文件系统(在无服务器环境中这很重要)。

许多现有实现中存在的更大问题是服务端存储对Git的过度依赖。这在Gerrit中最为明显,在Gerrit中,不仅您的git push存储在Gerrit服务器上的Git仓库中,每个代码审核评论和回复也都存储在Git中!Git是一个通用的键值存储,如果正确操作的话,可以在其中存储所需的任何数据。可以通过git clone复制所有Gerrit数据确实很酷——这几乎避免了我们选用一个去中心化工具并通过GitHub将其变得中心化的争议。但是,如果把这种将一切都存储在Git的方式大规模应用,则意味着运行大规模的Git服务器。不单是一个Git服务器那么简单——而是一个写入负载很重的Git服务器!而且,如果有成千上万的开发人员在同一个存储库中工作,那可能要面对每年数百万个新的Git引用。尽管Git,Gerrit和JGit的开发人员在扩展性方面做了大量出色的工作,但我感觉放弃将Git扩展到支持无限推送和引用问题,而使用更具扩展性的方法(例如提供HTTP接收节点,将收到的数据写到键值存储或关系型数据库)会好得多。换言之,非要用版本控制工具来服务大规模集成请求是搬起石头砸自己的脚,完全可以避免。

结论

祝贺你坚持读完了我的大脑转储(brain dump)!尽管用了如此长的篇幅,仍然有不少话题是我应该涵盖但没有纳入的。这包括更具体的代码审核话题和涉及到的各种功能。我还很大程度上忽略了一些一般话题,例如集成请求在整个开发生命周期中发挥作用的价值:集成请求不仅仅是代码审核——它们充当了跟踪变更演变全程的纽带。

希望本文能使你对拉取请求和集成请求存在的一些结构性问题有所了解。如果你的岗位是设计或实现更好的集成请求或围绕它们的工具(包括版本控制工具本身),则希望它为你提供了一些好主意或下一步工作方向。


1 Pull request 翻译问题 · Issue #234 · progit/progit2-zh, https://github.com/progit/progit2-zh/issues/234
6 ezyang/ghstack: Submit stacked diffs to GitHub on the command line, https://github.com/ezyang/ghstack
10 Change I1837f268: file: implement FileReftableDatabase, https://git.eclipse.org/r/#/c/146568/
14 glandium/git-cinnabar: git remote helper to interact with mercurial repositories, https://github.com/glandium/git-cinnabar