变基与合并

通常来说,维护一个子系统需要熟悉 Git 源代码管理系统。Git 是一个功能强大的工具,拥有许多特性;正如这类工具常见的情况一样,使用这些特性也有正确和错误的方式。本文将特别关注变基(rebasing)和合并(merging)的用法。维护者在使用这些工具不当时常常会遇到麻烦,但避免问题实际上并不困难。

总的来说,需要注意的一点是,与许多其他项目不同,内核社区并不害怕在其开发历史中看到合并提交。事实上,鉴于项目的规模,避免合并几乎是不可能的。维护者遇到的一些问题源于避免合并的愿望,而另一些则源于合并过于频繁。

变基

“变基”是更改仓库中一系列提交历史的过程。有两种不同类型的操作被称为变基,因为它们都通过 git rebase 命令完成,但它们之间存在显著差异

  • 更改构建一系列补丁所基于的父(起始)提交。例如,变基操作可以将基于上一个内核版本构建的补丁集,转而基于当前版本。在下面的讨论中,我们将此操作称为“重新定基(reparenting)”。

  • 通过修复(或删除)损坏的提交、添加补丁、为提交更新日志添加标签,或更改提交应用的顺序来改变一组补丁的历史。在下面的文本中,此类操作将被称为“历史修改(history modification)”。

术语“变基”将用于指代上述两种操作。如果使用得当,变基可以产生更干净、更清晰的开发历史;如果使用不当,它可能会混淆历史并引入错误。

有一些经验法则可以帮助开发者避免变基最严重的危险

  • 通常不应更改已暴露给私有系统之外的历史记录。其他人可能已经拉取了你的代码树并在此基础上进行构建;修改你的代码树会给他们带来麻烦。如果工作需要变基,这通常表明它尚未准备好提交到公共仓库。

    话虽如此,总有例外。一些代码树(例如 linux-next 就是一个重要的例子)由于其性质会频繁变基,开发者也知道不应在此基础上进行开发。开发者有时会公开一个不稳定的分支供他人测试或用于自动化测试服务。如果你以这种方式公开了一个可能不稳定的分支,请确保潜在用户知道不要在此基础上进行开发。

  • 不要对包含他人创建的历史记录的分支进行变基。如果你已从其他开发者的仓库中拉取了更改,你现在就是他们历史记录的保管者。你不应该更改它。除了少数例外情况,例如,这种代码树中的损坏提交应该明确地回滚,而不是通过历史修改使其消失。

  • 没有充分理由不要重新定基(reparent)代码树。仅仅是为了使用更新的基础或避免与上游仓库合并,通常不是一个好的理由。

  • 如果你必须重新定基(reparent)一个仓库,不要选择某个随机的内核提交作为新的基础。内核在发布点之间通常处于相对不稳定的状态;将开发基于这些点之一会增加遇到意外错误的几率。当一个补丁系列必须移动到一个新的基础时,请选择一个稳定的点(例如某个 -rc 版本)进行移动。

  • 请注意,重新定基一个补丁系列(或进行重大的历史修改)会改变其开发环境,并很可能使之前的大部分测试失效。一般来说,重新定基后的补丁系列应被视为新代码,并从头开始重新测试。

合并窗口期间常见的问题是,当 Linus 收到一个明显被重新定基的补丁系列(通常是基于一个随机提交),而且是在发送拉取请求前不久才完成时。这类系列经过充分测试的可能性相对较低——同样,该拉取请求被处理的可能性也较低。

相反,如果变基仅限于私有代码树,提交基于一个众所周知的起始点,并且经过充分测试,那么出现问题的可能性就会很低。

合并

合并是内核开发过程中常见的操作;5.1 开发周期包含了 1,126 次合并提交——几乎占总数的 9%。内核工作积累在 100 多个不同的子系统代码树中,每个代码树可能包含多个主题分支;每个分支通常独立于其他分支进行开发。因此,在任何给定分支进入上游仓库之前,自然至少需要一次合并。

许多项目要求拉取请求中的分支基于当前的 trunk(主干),以便历史记录中不出现合并提交。内核并非这样的项目;任何为了避免合并而对分支进行的变基操作,很可能会导致麻烦。

子系统维护者会发现自己必须进行两种类型的合并:从较低级别的子系统代码树合并,以及从其他代码树(无论是兄弟代码树还是主线)合并。在这两种情况下,应遵循的最佳实践有所不同。

从较低级别代码树合并

较大的子系统往往有多个级别的维护者,较低级别的维护者向较高级别发送拉取请求。处理此类拉取请求几乎肯定会生成一个合并提交;这正是应该如此的。事实上,在极少数情况下,当合并提交通常不会被创建时,子系统维护者可能希望使用 --no-ff 标志强制添加一个合并提交,以便记录合并的原因。任何类型的合并,其更新日志都应说明合并的原因。对于较低级别的代码树,“原因”通常是该拉取请求所带来的更改的摘要。

所有级别的维护者都应在他们的拉取请求上使用签名标签,上游维护者在拉取分支时应验证这些标签。未能这样做将威胁到整个开发过程的安全性。

根据上述规则,一旦你将他人的历史合并到你的代码树中,你就不能对该分支进行变基,即使在其他情况下你可能能够这样做。

从兄弟或上游代码树合并

虽然来自下游的合并是常见且不足为奇的,但当需要将分支推送到上游时,来自其他代码树的合并往往是一个危险信号。此类合并需要仔细考虑并充分证明其合理性,否则后续的拉取请求很有可能会被拒绝。

将主分支合并到仓库中是很自然的愿望;这种合并通常被称为“回溯合并(back merge)”。回溯合并有助于确保与并行开发没有冲突,并且通常会给人一种及时更新的舒适感。但几乎所有时候都应该避免这种诱惑。

为什么会这样?回溯合并会混淆你自己的分支的开发历史。它们会显著增加你遇到来自社区其他地方的错误的几率,并使你难以确保你管理的工作是稳定的并已准备好提交到上游。频繁的合并还可能掩盖你的代码树中开发过程的问题;它们可能会隐藏与其他代码树的交互,而这些交互在一个管理良好的分支中不应(经常)发生。

话虽如此,回溯合并偶尔也是必需的;当这种情况发生时,请务必在提交信息中说明其原因。一如既往,合并到一个众所周知的稳定点,而不是某个随机提交。即使如此,你也不应该回溯合并到你直接上游代码树之上的代码树;如果确实需要更高级别的回溯合并,上游代码树应首先进行。

与合并相关的问题最常见的原因之一是,维护者在发送拉取请求之前与上游合并以解决合并冲突。再次强调,这种诱惑很容易理解,但它绝对应该避免。对于最终的拉取请求尤其如此:Linus 坚决表示,他宁愿看到合并冲突,也不愿看到不必要的回溯合并。看到冲突让他知道潜在的问题区域在哪里。他进行了大量的合并(在 5.1 开发周期中进行了 382 次),并且在冲突解决方面做得相当好——通常比涉及的开发者做得更好。

那么,当维护者的子系统分支与主线之间存在冲突时,他们应该怎么做?最重要的一步是在拉取请求中警告 Linus 将会发生冲突;至少,这表明你了解自己的分支如何融入整个项目。对于特别困难的冲突,创建一个并推送一个单独的分支来展示你将如何解决问题。在你的拉取请求中提及该分支,但拉取请求本身应针对未合并的分支。

即使没有已知的冲突,在发送拉取请求之前进行一次测试合并也是一个好主意。它可能会提醒你一些你从 linux-next 中未发现的问题,并帮助你准确理解你要求上游做什么。

进行上游或其他子系统代码树合并的另一个原因是解决依赖关系。这些依赖问题有时会发生,有时与其他代码树进行交叉合并是解决它们的最佳方式;一如既往,在这种情况下,合并提交应解释合并的原因。花点时间正确地完成它;人们会阅读那些更新日志。

然而,依赖问题通常表明需要改变方法。合并另一个子系统代码树来解决依赖关系会冒引入其他错误的风险,并且几乎不应该这样做。如果该子系统代码树未能被拉取到上游,它存在的任何问题也会阻碍你的代码树的合并。更优的选择包括与维护者协商,在其中一个代码树中承载两组更改,或者创建一个专门用于先决提交的主题分支,该分支可以合并到两个代码树中。如果依赖关系与重大的基础设施更改有关,正确的解决方案可能是将依赖提交推迟一个开发周期,以便这些更改有时间在主线中稳定下来。

最后

在开发周期开始时,为了吸收代码树中其他地方所做的更改和修复,与主线合并是相对常见的做法。一如既往,这样的合并应选择一个众所周知的发布点,而不是某个随机位置。如果你的上游绑定分支在合并窗口期间已完全合并到主线中,你可以使用类似以下命令将其向前拉取:

git merge --ff-only v5.2-rc1

上述准则仅是:准则。总会有需要不同解决方案的情况,这些准则不应阻止开发者在需要时做正确的事情。但是,人们应该始终思考是否真的出现了这种需求,并准备好解释为什么需要采取异常措施。