在分布式版本控制系统(DVCS)的早期,合并请求可能仅仅是通过电子邮件发送的。例如,Alice可能会通过邮件请求从她的代码库中拉取一些变更。如果认为这个变更是个好主意,可以通过几个命令将这些变更合并到master分支中:
$ git remote add alice git://bitbucket.org/alice/bleak.git
$ git checkout master
$ git pull alice master
当然,随意地将Alice的变更合并到master分支并不是一个好主意。master分支代表了打算交付给客户的代码,因此通常需要密切关注合并进来的内容。更好的做法是将变更拉取到一个单独的分支中,并在合并之前检查这些变更:
$ git fetch alice
$ git diff master...alice/master
使用git diff的“三点点”语法可以展示alice/master分支的尖端与本地master分支的合并基础(或共同祖先)之间的差异。这实际上展示了Alice希望拉取的所有变更。
git diff master...alice/master等同于git diff A B。乍一看,这似乎是一个合理的方式来审查合并请求中涉及的变更。实际上,在撰写本文时,这似乎是大多数git托管工具实现合并请求差异算法的方式。
然而,使用“三点点”差异方法生成合并请求的差异存在一些问题。在实际项目中,master分支将会显著地偏离任何给定的特性分支。其他开发者也会在他们自己的分支上工作,并将它们合并到master中。一旦master分支进展,一个简单的git diff从特性分支尖端回溯到其合并基础就不再足够展示两个分支之间的真实差异。只能看到分支尖端和master的某个旧版本的差别。
“三点点”git diff master...alice/master没有考虑到master的变化。为什么在合并请求差异中看不到这些变化是一个问题?有两个原因。
首先,可能经常遇到的问题:合并冲突。如果在特性分支上修改了一个文件,而该文件在master上也被修改了,git diff仍然只会展示在特性分支上所做的变更。另一方面,git merge则会抛出错误,并在工作副本中散布冲突标记,表明分支存在不可调和的差异。或者至少是超出了git复杂合并策略的能力。
没有人喜欢解决合并冲突,但它们是所有版本控制系统的现实问题。至少,那些不支持文件级锁定的版本控制系统是这样的,而文件级锁定也有它自己的问题。
但是,合并冲突远远不如使用“三点点”git diff进行合并请求时可能遇到的第二个问题:一种特殊类型的逻辑冲突,它会干净地合并,但可能会将微妙的错误引入代码库。
如果开发者在不同的分支上修改了同一个文件的不同部分,可能会遇到一些麻烦。在某些情况下,不同的变更在独立工作时看起来可以愉快地合并而没有冲突,但实际上当它们组合在一起时可能会创建逻辑错误。
这种情况可能会以几种不同的方式发生,但一个常见的方式是当两个或更多的开发者在两个不同的分支上偶然注意到并修复了同一个bug。考虑以下用于计算机票价格的javascript代码:
var customsFee = 5.5;
var immigrationFee = 7;
var federalTransportTax = 0.025;
function calculateAirfare(baseFare) {
var fare = baseFare;
fare += immigrationFee;
fare *= (1 + federalTransportTax);
return fare;
}
这里有一个明显的错误——作者忽略了在计算中包含海关费用!
现在想象两个不同的开发者,Alice和Bob,每个人都独立地在两个不同的分支上注意到了这个bug并修复了它。
Alice在immigrationFee之前添加了customsFee:
function calculateAirfare(baseFare) {
var fare = baseFare;
+++ fare += customsFee;
// Fixed it! Phew. Glad we didn't ship that! - Alice
fare += immigrationFee;
fare *= (1 + federalTransportTax);
return fare;
}
Bob进行了类似的修复,但是在immigrationFee之后的那一行:
function calculateAirfare(baseFare) {
var fare = baseFare;
fare += immigrationFee;
+++ fare += customsFee;
// Fixed it! Gee, lucky I caught that one. - Bob
fare *= (1 + federalTransportTax);
return fare;
}
因为每个分支都修改了不同的行,这两个分支都将干净地合并到master中,一个接一个。然而,master最终将包含这两行。而且,一个严重的错误将导致客户被双重收取海关费用:
function calculateAirfare(baseFare) {
var fare = baseFare;
fare += customsFee;
// Fixed it! Phew. Glad we didn't ship that! - Alice
fare += immigrationFee;
fare += customsFee;
// Fixed it! Gee, lucky I caught that one. - Bob
fare *= (1 + federalTransportTax);
return fare;
}
(这显然是一个牵强的例子,但重复的代码或逻辑可能会导致相当严重的问题:goto fail; 有人吗?)
假设首先将Alice的合并请求合并到master中,如果使用“三点点”git diff从分支尖端到共同祖先,Bob的合并请求看起来会是这样的:
function calculateAirfare(baseFare) {
var fare = baseFare;
fare += immigrationFee;
+++ fare += customsFee;
// Fixed it! Gee, lucky I caught that one. - Bob
fare *= (1 + federalTransportTax);
return fare;
}
因为是在对祖先进行审查,所以没有警告即将发生的灾难,当点击合并按钮时。
真正想在合并请求中看到的是当合并Bob的分支时master将如何变化:
function calculateAirfare(baseFare) {
var fare = baseFare;
fare += customsFee;
// Fixed it! Phew. Glad we didn't ship that! - Alice
fare += immigrationFee;
+++ fare += customsFee;
// Fixed it! Gee, lucky I caught that one. - Bob
fare *= (1 + federalTransportTax);
return fare;
}
这个差异清楚地展示了问题。一个合并请求审查者会注意到重复的行(希望如此),并让Bob知道代码需要一些重构,从而防止一个严重的错误达到master并最终进入生产环境。
这就是在Bitbucket和Stash中决定实现合并请求差异的方式。当查看一个合并请求时,看到的是实际的合并提交将是什么样子。通过在后台实际创建一个合并提交,并向展示它和目标分支尖端之间的差异来做到这一点:
git diff C D where D is a merge commit shows all differences between the two branches
如果好奇,已经将同一个代码库推送到了几个不同的托管提供商,所以可以看到不同的差异算法在行动: 一个带有GitHub的“三点点”差异的合并请求 一个带有Bitbucket的“合并提交”差异的合并请求 一个带有GitLab的“三点点”差异的合并请求
在Bitbucket和Stash中使用的“合并提交”差异显示了当合并时将实际应用的所有变化。问题是它更难以实现,并且执行起来更昂贵。
首先,合并提交D实际上还不存在,创建一个合并提交是一个相对昂贵的过程。第二个问题是不能简单地创建D就完事了。B和C,合并提交的父母,可能随时会改变。称这些父母的一个变化为重新调整合并请求,因为它有效地改变了当合并请求被合并时将应用的差异。如果合并请求是针对像master这样的繁忙分支,合并请求可能会非常频繁地被重新调整。
合并提交是在任一分支变化时创建的。事实上,每次有人推送到master或将分支合并到master或特性分支时,Bitbucket或Stash可能需要计算一个新的合并,以便向展示一个准确的差异。
处理合并冲突的另一个问题是,偶尔,将不得不处理一个合并冲突。由于git服务器是非交互式运行的,没有人会在场来解决它们。这使得事情变得更加复杂,但实际上却是一个优势。在Bitbucket和Stash中,实际上将冲突标记作为合并提交D的一部分提交,然后在差异中标记它们,向展示合并请求是如何冲突的: 在Bitbucket和Stash的差异中:绿色线是添加的,红色线是删除的,橙色线是冲突的。