Rebasing and merging in kernel repositories
By Jonathan Corbet
June 18, 2019
本文是Jonathan在过去一个月撰写的一份kernel document,旨在merge window(合并窗口)期间减少子系统维护者所碰到的问题。如果一切顺利,会放置在Linux 5.3版本的Documentation/maintainer/rebasing-and-merging.txt。还有一个维护者手册在这里(https://www.kernel.org/doc/html/latest/maintainer/index.html ),因为有一些可能会感兴趣的读者可能没有关注这个网站,所以Jonathan将此文也发布到LWN上了。
维护一个内核子系统,通常来说首先需要熟悉Git代这个源码管理系统。Git是一个拥有众多功能的强大工具,对于这种(功能众多并且强大的)工具的各个功能来说,会有正确的用法,也会有一些常见的错误用法。本文主要专注于rebase和merge这两个方面。维护者如果使用工具不当的话,一定会碰到麻烦,不过其实避免这些问题并不困难。
有一个通用规则首先要知道,也是与其他许多项目所不同的一点,kernel community通常不怕在开发历史里面出现merge commit。其实,因为Linux kernel的规模太大,基本不可能避免merge操作。有些代码维护者想避免merge操作,因此碰到错误。也有一些问题是因为merge得过于频繁。
Rebasing
"Rebasing"是个会改变仓库中一系列commit的历史的操作。有两类操作都会被称为“rebasing",因为它们都用到了git rebase命令,不过两者之间其实区别很大:
改变一系列commit所基于的parent (starting) commit。例如,可以用rebase操作来把基于此前kernel发布版本的patch set来拿到当前最新kernel release版本之上。这个操作下面称之为“reparenting"。
对一系列commit进行修改、删除操作,或者增加patch,在commit的changelog上增加tag,修改这几个patch的合入顺序等等。在下文中,这类操作被称为“history modification"。
我们所说的"rebasing"指的就是上述两种操作。正确使用的话,rebasing可以让开发历史信息更加干净清晰;用得不对的话,就会让代码历史信息混乱,或者引入bug。
有一些基本规则可以帮助开发者避免rebasing引入的最坏情况:
已经从你个人本地的仓库发布出去的历史信息,通常不应该再改变了。其他人可能已经从你的git tree来pull了一份代码,并且基于这个版本进行一些开发了。此时如果修改你的代码树会让他们很麻烦。如果你觉得手头的工作可能需要做一些rebasing操作,那就意味着这些改动还不具备发布到公共代码库(public repository)的条件。
不过还是有例外情况的。有些代码分支(linux-next就是一个最明显的例子)上频繁出现rebase操作,开发者们知道不应该基于这个分支来做开发工作。有时候开发者还会把自己的一个unstable branch(不稳定分支)提供给其他人进行测试,或者发布给自动测试系统。假如你确有需要发布这样的一个不稳定分支,请确保潜在用户们都知道不要基于这个分支做开发工作。
不要对包含其他人工作的branch进行rebase操作。例如你是从其他开发者的代码仓库pull的改动,那么你就是他们代码历史的保管人,不能去改变他们的东西。也有少数例外情况,例如,这个代码分支里面有个错误的commit,那么就需要专门用git revert操作来回退,而不是简单的通过history modification的操作来让它消失。
不要在没有正当理由的情况下对代码仓库进行reparent操作。“想使用最新的代码”或者“避免使用merge”操作,这都不是一个正当理由。
如果必须要对代码库进行reparent操作,那么不能随意选择一个kernel commit来作为新的base版本。在两次正式release发布点之间,kernel通常稳定性不是在最好的情况,因此随意选择一个base版本的话会有更大概率碰到意外的bug。因此当你需要把一系列patch都更改到新的base版本上去的时候,需要选择一个稳定的节点(例如某个-rc release)作为新的base版本。
要意识到reparenting一系列patch(或者对代码历史进行大量改动),事实上都改变了这些patch的工作环境(与之前开发时候的版本不同了),也意味着之前做的大量测试可能都无效了,需要再次测试。也就是说reparent过的patch set通常来说就要当做是一组新的patch set,来从头开始进行测试。
在merge-window里出现问题的话,有一个常见的原因,就是刚把一组patch给reparent到随便一个commit上,马上就发pull request给Linus Torvalds了。这类patch通常都没有经过完善的测试,同样,这种pull request通常也不会被接受。
相反,假如只对私有的代码仓库做rebasing操作,所有的commit都是基于一个仔细选择过的稳定版本,并且经过完善的测试,那么导致问题的可能性就很低了。
Merging
Merging是kernel开发流程里面的一个常见操作。在5.1版本的开发周期里,有1126个merge commit,约占所有commit数量的9%。Kernel的开发进展都是分别在100多个不同子系统的代码库里进行积累开发的,每个子系统的代码库都可能有多个topic branch(话题分支,指专用于开发某个功能的分支);每个branch的开发工作通常跟其他branch都是独立的。所以,每个branch要想合入upstream repository(上游仓库),自然都会至少有一次merge操作。
很多项目的开发规范都是要求每个pull request要求合入的branch都是要基于当前的主干分支上最新commit的,这样合入时代码历史中就不会有merge commit出现。而Linux kernel不是这样管理的,如果为了避免merge操作,而对branch代码采取rebase操作,多数情况下都会导致问题。
子系统的维护者意识到他们通常得做两种类型的merge操作:从更下一级子系统的代码仓库里merge代码,或者从其他(或者是平级别仓库,或者是mainline主线)来merge代码。这两种情况下的最佳操作方式也有不同。
Merging from lower-level trees
比较大的子系统可能会有多级维护者,其中下一级的维护者会发pull request给上一级别的维护者。对这种pull request的合入操作通常都会生成一个merge commit,这个行为本身设计如此。实际上,子系统的维护者应该用 --no-ff 这个Git参数来强制生成merge commit,这样确保在某些少见的不会自动生成merge commit的情况下也能生成merge commit,并且记录下这次merge操作的缘由。这笔merge操作——其实任何merge操作都一样——都应该在changelog里面记录下为什么要做这个merge动作。对于低一级别的代码仓库,这里要记录的原因通常是这笔pull操作会引入的改动的总结列表。
各级维护者对他们发出的pull request都应该加个signed tag,这样上游仓库的维护者需要在pull这个branch的时候验证这个tag。如果不做这个操作的话,会引入kernel开发流程的安全性问题。
基于上面介绍的规则,每次你合入别人的代码改动到自己的代码仓库后,就不应该再进行rebase操作了,哪怕你有权限做这个操作也要避免。
Merging from sibling or upstream trees
其实从downstream(下游分支)merge代码是比较常见的操作,没有太多好说的。不过从其他代码仓库merge代码就需要在推送到upstream上游分支的时候提高警惕了。这样的合入通常都需要仔细想清楚,并且提供充足理由。否则的话对upstream发出的pull request很可能会被拒绝。
很多人都想把master branch(主干分支)的代码merge到本地仓库里。这种类型的merge通常被称为“back merge”。Back merge有助于确认在并行开发过程中是否有冲突,保持跟master branch的最新版本同步通常也会给开发者一个很安心、舒服的感觉。不过这种倾向绝大多数情况下都需要严格避免。
为什么呢?因为back merge会弄乱你自己branch的开发历史。这通常会显著增加你碰到community里其他人改动引入bug的概率,也会很难确定你自己负责的这部分改动是否稳定、能否达到推送给upstream的状态。
频繁进行merge操作也会让你的代码仓库中的开发流程变得晦涩难懂,这会让跟其他代码仓库的交互merge操作(这种操作在一个管理规范的仓库中通常不应该发生)信息变得不够显眼。
其实,有些极少情况下,back merge还是有用的。每次进行这种操作的时候,都请确保在commit message中记录好为什么需要进行back merge。并且每次一定要避免随便选择一个commit,而是要基于一个已知稳定的版本进行merge。哪怕是确有需要,你也绝不应该直接把这些改动给back merge到你的upstream代码版本上,而是应该提醒upstream仓库的维护者来先进行这个back merge操作。
有一种最常见的merge操作相关的问题,就是在维护者想要发送pull request之前,为了确保解决merge conflict(合并时产生的冲突),先和upstream代码进行merge操作。确实,这种想法是很可以理解的,不过绝对是要避免的。特别是对最后给Torvalds发送的pull request来说,格外要注意这点,因为Torvalds明确的表示他宁可看到merge conflict,也不愿意大家进行不必要的back merge。每次看到有conflict代码冲突,就能知道哪里可能有潜在问题。他会进行非常多的merge操作(5.1版本开发周期中他有382次merge),比起普通开发者,也更擅长处理代码conflict的情况。
那么如果maintainer确实看到他们的子系统分支和mainline分支有conflict,那该做什么呢?最重要的一个步骤就是要在pull request里面提醒Torvalds这里可能会有conflict,至少这样会表明你是意识到你的branch跟upstream整体配合的情况的。
对一些特别困难的conflict,可以创建一个单独演示分支,在此解决掉这些conflict,然后在pull request里面附带提一下这个演示分支,不过pull request本身还是得用那个没有进行merge的代码版本的。
就算没有已知的merge conflict,在发出pull request之前做一次test merge也是一个好主意。可能可以提醒你注意到一些linux-next branch上没有看到的问题,帮助你理清你需要让upstream这边做什么操作。
从upstream或者其他子系统的代码库进行merge的另一个常见原因,是解决dependency依赖关系。这种依赖关系问题时不时会发生,确实有时候从另外一个代码库进行cross-merge(相互merge)是最好的解决办法。同样,在这种情况下,这个merge commit的描述信息里需要解释为什么要做这个merge操作。请花些时间把它写好写完整,其他开发者会仔细读这个changelog的。
不过,通常依赖关系问题也意味着需要改变一些实现方式。从另一个子系统的代码库做merge操作来解决依赖问题,会有更大风险引入其他bug,还是应该尽量避免这些操作。假设那个子系统的代码库改动没能被pull到upstream上去,不管是出于什么原因,都会导致你的代码库也无法合入upstream。比较好的解决方法,可能是跟相关的子系统维护者商量好,把有关联的改动都放到一个代码仓库里(而不是分散在两边),或者专门创建一个topic branch来进行公共部分改动的开发,这样后面这个topic branch可以被merge到两个代码仓库里去。假设这个依赖关系是源自一个核心代码框架的改动,那么正确解决方法可能是等这些依赖的commit完成一个development cycle(开发周期)的验证,能确保这些改动在mainline上有较长的时间来稳定下来。
Finally
还有个常见操作是在每个development cycle的开始的时间点来把mainline的改动merge到自己的代码库,从而能拿到kernel里其他子系统的改动以及fix。同样,这种merge操作也需要挑选一个已知的发布节点,不能随便挑一个节点。如果你的跟upstream绑定的branch已经在上次merge window里面把所有本地改动都合入mainline了,那么你可以使用类似下面命令来pull mainline的代码:
这里的^0 会让git进行fast-forward merge(这种情况下应该会成功),这样避免引入一个不该出现的虚假merge commit。
上述列出的这些原则,仅仅是一些原则。肯定会有一些情况需要用到其他解决方案,这些guideline也并不是禁止开发者做什么操作,在有需要的时候肯定应该选择最合适的方案。不过开发者需要三思,是否真的有需要打破上述原则,是的话就需要解释清楚为什么要进行这些非常规操作。
全文完
LWN文章遵循CC BY-SA 4.0许可协议。
极度欢迎将文章分享到朋友圈热烈欢迎转载以及基于现有协议上的修改再创作~
长按下面二维码关注:Linux News搬运工,希望每周的深度文章以及开源社区的各种新近言论,能够让大家满意~