变基与合并

作为一项一般规则,维护一个子系统需要熟悉 Git 源代码管理系统。Git 是一个功能强大的工具,拥有许多特性;正如这类工具常见的情况一样,使用这些特性有正确和错误的方法。本文档特别关注变基和合并的使用。维护者在使用这些工具不当经常会陷入麻烦,但避免这些问题其实并不算太难。

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

变基

“变基(Rebasing)”是更改仓库中一系列提交的历史记录的过程。由于这两种操作都是通过 git rebase 命令完成的,因此它们都被称为变基,但这两种操作之间存在显著差异

  • 更改构建一系列补丁所依据的父(起始)提交。例如,变基操作可以获取构建在上一版本内核上的补丁集,转而将其建立在当前版本上。我们在下面的讨论中将此操作称为“更换父节点(reparenting)”。

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

术语“变基”将用于指代上述两种操作。正确使用变基可以产生更干净、更清晰的开发历史;使用不当则会模糊历史并引入漏洞。

有一些经验法则可以帮助开发人员避免变基带来的严重危险

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

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

  • 不要对包含由他人创建的历史的分支进行变基。如果你已经从其他开发人员的仓库中拉取了更改,你现在就是他们历史的保管人。你不应该改变它。除了极少数例外(例如,此类代码树中的损坏提交应该被显式回滚,而不是通过历史修改使其消失)。

  • 不要无缘无故地更换代码树的父节点。仅仅是为了处于更新的基底上或避免与上游仓库进行合并,通常算不上好理由。

  • 如果你必须更换代码树的父节点,请不要挑选某个随机的内核提交作为新的基底。内核在发布点之间通常处于相对不稳定的状态;将开发建立在这些点之一上会增加遇到意料之外的漏洞的几率。当补丁系列必须移动到新的基底时,请选择一个稳定点(例如某个 -rc 版本)进行迁移。

  • 必须认识到,更换补丁系列的父节点(或进行大量的历史修改)会改变其开发的环境,并且很可能会使已经完成的大部分测试失效。作为一项一般规则,更换了父节点的补丁系列应被视为新代码,并从头开始重新测试。

合并窗口期间出现麻烦的一个常见原因是,Linus 收到了一个补丁系列,该系列在发送 pull request 之前不久被明显地更换了父节点(通常是更换到一个随机提交)。这样一个系列经过充分测试的几率相对较低——pull request 被采纳的几率也是如此。

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

合并

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

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

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

从低级代码树合并

较大的子系统往往有多级维护者,低级维护者向高级别发送 pull request。处理这样的 pull request 几乎肯定会产生一个合并提交;理应如此。事实上,在极少数情况下通常不会创建合并提交时,子系统维护者可能希望使用 --no-ff 标志来强制添加合并提交,以便记录合并的原因。对于任何类型的合并,合并的变更日志都应该说明为什么要进行合并。对于低级代码树,“为什么”通常是该 pull 将带来的更改的总结。

所有级别的维护者都应在其 pull request 上使用带签名的标签,并且上游维护者在拉取分支时应验证这些标签。如果不这样做,将威胁到整个开发过程的安全性。

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

从同级或上游代码树合并

虽然来自下游的合并很常见且不出奇,但在准备将分支推送到上游时,来自其他代码树的合并往往是一个危险信号。此类合并需要经过深思熟虑并有充分的理由,否则随后的 pull request 很有可能会被拒绝。

人们自然会想要将 master 分支合并到仓库中;这种类型的合并通常被称为“反向合并”。反向合并可以帮助确保与并行开发没有冲突,并且通常会给人一种跟上了最新进展的温馨、舒适的感觉。但这种诱惑几乎在所有时候都应该避免。

这是为什么呢?反向合并会弄乱你自己的分支的开发历史。它们会显著增加你遇到来自社区其他地方的漏洞的机会,并使你难以确保你所管理的工作是稳定的并已准备好推送到上游。频繁的合并还会掩盖你的代码树中开发过程的问题;它们可能会掩盖与其他代码树之间不应该(经常)在一个管理良好的分支中发生的交互。

话虽如此,反向合并偶尔也是需要的;当发生这种情况时,请务必在提交信息中记录为什么需要它。与往常一样,合并到一个众所周知的稳定点,而不是某个随机提交。即便如此,你也不应该反向合并高于你直接上游代码树的代码树;如果确实需要更高层的反向合并,则应该由上游代码树先生这样做。

与合并相关的麻烦最常见的原因之一是,维护者在发送 pull request 之前为了解决合并冲突而与上游进行合并。同样,这种诱惑很容易理解,但绝对应该避免。对于最后的 pull request 尤其如此:Linus 坚持认为,他宁愿看到合并冲突,也不愿看到不必要的反向合并。看到冲突能让他知道潜在的问题区域在哪里。他做了大量的合并(在 5.1 开发周期中进行了 382 次),并且在解决冲突方面变得非常擅长——通常比相关开发人员还要好。

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

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

合向上游或其他子系统代码树进行合并的另一个原因是解决依赖关系。这些依赖问题确实有时会发生,有时与其他代码树进行交叉合并是解决它们的最佳方法;与往常一样,在这种情况下,合并提交应该解释为什么进行了合并。花点时间把它做好;人们会阅读这些变更日志的。

然而,通常情况下,依赖问题表明需要改变方法。合并另一个子系统代码树来解决依赖关系会冒引入其他漏洞的风险,几乎绝不应该这样做。如果该子系统代码树未能被上游拉取,它所具有的任何问题也将阻塞你的代码树的合并。更可取的替代方案包括:与维护者达成一致,在其中一个代码树中承载两套更改;或者创建一个专门用于前置提交的主题分支,该分支可以合并到两个代码树中。如果该依赖关系与重大基础设施更改有关,正确的解决方案可能是将依赖的提交保留一个开发周期,以便这些更改有时间在主线中稳定下来。

最后

在开发周期之初与主线进行合并相对常见,以便获取代码树其他地方进行的更改和修复。与往常一样,此类合并应该选择一个众所周知的发布点,而不是某个随机地点。如果你的发往上游的分支在合并窗口期间已经完全清空并合并到了主线中,你可以使用类似以下的命令将其向前拉取

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

上面列出的准则就是准则:仅此而已。总会有需要不同解决方案的情况,这些准则不应阻止开发人员在需要时做出正确的事情。但人们应该始终思考是否真的产生了这种需求,并准备好解释为什么需要做一些不寻常的事情。