补丁回溯与冲突解决

作者:

Vegard Nossum <vegard.nossum@oracle.com>

引言

有些开发者在日常工作中可能从未真正处理过补丁回溯、分支合并或冲突解决,因此当合并冲突突然出现时,可能会令人望而生畏。幸运的是,解决冲突和其他技能一样,并且有许多有用的技术可以使过程更顺畅,并增加您对结果的信心。

本文旨在成为一份关于补丁回溯和冲突解决的全面、分步指南。

将补丁应用于树

有时,您要回溯的补丁已经作为 git 提交存在,在这种情况下,您只需使用 git cherry-pick 直接将其拣选即可。但是,如果补丁来自电子邮件(Linux 内核经常如此),您将需要使用 git am 将其应用于树。

如果您曾使用过 git am,您可能已经知道它对补丁是否能完美应用于您的源代码树非常挑剔。事实上,您可能曾梦到 .rej 文件并试图编辑补丁以使其应用。

强烈建议您改为寻找一个能干净应用补丁的合适基础版本,然后将其拣选到您的目标树,因为这将使 git 输出冲突标记,并让您在 git 和任何其他您可能喜欢的冲突解决工具的帮助下解决冲突。例如,如果您想将 LKML 上刚收到的补丁应用于较旧的 stable 内核,您可以将其应用于最新的主线内核,然后将其拣选到您的旧 stable 分支。

通常最好使用与生成补丁完全相同的基础版本,但只要它能干净地应用并且与原始基础版本相距不远,这实际上并不重要。将补丁应用于“错误”基础的唯一问题是,在拣选到旧分支时,它可能会在差异上下文中引入更多不相关的更改。

选择 git cherry-pick 而非 git am 的一个重要原因是,git 知道现有提交的精确历史,因此它会知道代码何时移动和更改了行号;这反过来又降低了将补丁应用到错误位置的可能性(这可能导致无声的错误或混乱的冲突)。

如果您正在使用 b4,并且您直接从电子邮件应用补丁,您可以使用 b4 am 并带上选项 -g/--guess-base-3/--prep-3way 来自动完成其中一些操作(更多信息请参见 b4 演示)。然而,本文的其余部分将假定您正在进行普通的 git cherry-pick 操作。

一旦您在 git 中有了补丁,您就可以继续将其拣选到您的源代码树中。如果您希望有一个书面记录来记录补丁的来源,别忘了使用 -x 进行拣选!

请注意,如果您要为 stable 分支提交补丁,格式略有不同;主题行后的第一行需要是:

commit <upstream commit> upstream

或者

[ Upstream commit <upstream commit> ]

解决冲突

噢不;拣选失败,并出现了一个模糊的威胁性消息:

CONFLICT (content): Merge conflict

现在该怎么办?

一般来说,当补丁的上下文(即被更改的行和/或围绕更改的行)与您尝试应用补丁的树中的内容不匹配时,就会出现冲突。

对于回溯补丁,可能发生的情况是您回溯来源的分支包含了一些您回溯到的分支中没有的补丁。然而,反过来也可能发生。无论如何,结果都是需要解决的冲突。

如果您尝试的拣选因冲突而失败,git 会自动编辑文件以包含所谓的冲突标记,向您展示冲突的位置以及两个分支如何发生了分歧。解决冲突通常意味着以一种考虑这些其他提交的方式来编辑最终结果。

解决冲突可以通过在常规文本编辑器中手动完成,也可以使用专用的冲突解决工具。

许多人更喜欢使用他们的常规文本编辑器直接编辑冲突,因为这样可能更容易理解您正在做什么并控制最终结果。两种方法各有利弊,有时两者结合使用也很有价值。

除了提供一些您可能使用的各种工具的指引外,我们在此不会涵盖专用合并工具的使用:

要配置 git 以便与这些工具配合使用,请参阅 git mergetool --help 或官方的 git-mergetool 文档

前置补丁

大多数冲突的发生,是因为您回溯到的分支,相较于您回溯的分支,缺少了一些补丁。在更一般的情况下(例如合并两个独立分支),开发可能发生在任一分支上,或者分支只是简单地分叉了——也许您的旧分支应用了一些其他回溯补丁,这些补丁本身也需要冲突解决,导致了分歧。

识别导致冲突的一个或多个提交始终很重要,否则您无法对解决方案的正确性充满信心。作为额外的好处,特别是当补丁在您不熟悉的领域时,这些提交的变更日志通常会为您提供理解代码以及解决冲突时潜在问题或陷阱的上下文。

git log

一个好的第一步是查看冲突文件的 git log — 当文件没有太多补丁时,这通常就足够了,但如果文件很大并且经常打补丁,可能会让人感到困惑。您应该在当前检出的分支(HEAD)和您正在拣选的补丁的父提交(<commit>)之间的提交范围上运行 git log,即:

git log HEAD..<commit>^ -- <path>

更好的是,如果您想将此输出限制到单个函数(因为冲突出现在那里),您可以使用以下语法:

git log -L:'\<function\>':<path> HEAD..<commit>^

注意

函数名周围的 \<\> 确保匹配是锚定在单词边界上的。这很重要,因为这部分实际上是一个正则表达式,git 只会跟随第一个匹配项,所以如果您使用 -L:thread_stack:kernel/fork.c,它可能只为您提供函数 try_release_thread_stack_to_cache 的结果,尽管该文件中还有许多其他函数包含字符串 thread_stack 在它们的名称中。

另一个有用的 git log 选项是 -G,它允许您根据列表中提交的差异中出现的特定字符串进行过滤:

git log -G'regex' HEAD..<commit>^ -- <path>

这也可以是一种方便快捷的方式,可以快速查找某些内容(例如函数调用或变量)何时被更改、添加或删除。搜索字符串是一个正则表达式,这意味着您可能可以搜索更具体的内容,例如对特定结构成员的赋值:

git log -G'\->index\>.*='

git blame

查找前置提交的另一种方法(尽管只适用于给定冲突的最新提交)是运行 git blame。在这种情况下,您需要针对您正在拣选的补丁的父提交以及发生冲突的文件运行它,即:

git blame <commit>^ -- <path>

此命令也接受 -L 参数(用于将输出限制到单个函数),但在这种情况下,您像往常一样在命令末尾指定文件名:

git blame -L:'\<function\>' <commit>^ -- <path>

导航到冲突发生的地方。blame 输出的第一列是添加给定代码行的补丁的提交 ID。

最好 git show 这些提交,看看它们是否像是冲突的来源。有时会有多个这样的提交,这可能是因为多个提交更改了同一冲突区域的不同行,或者是因为多个后续补丁多次更改了同一行(或多行)。在后一种情况下,您可能需要再次运行 git blame 并指定要查看的文件的旧版本,以便更深入地追溯文件的历史。

前置补丁与偶然补丁

找到导致冲突的补丁后,您需要确定它是否是您正在回溯的补丁的前提条件,或者它只是偶然的,可以跳过。偶然补丁是指与您正在回溯的补丁接触相同的代码,但不会以任何实质性方式改变代码语义的补丁。例如,一个空白清理补丁是完全偶然的——同样,一个仅仅重命名函数或变量的补丁也可能是偶然的。另一方面,如果被更改的函数在您当前的分支中甚至不存在,那么这根本不是偶然的,您需要仔细考虑是否应该首先拣选添加该函数的补丁。

如果您发现存在必要的先决补丁,那么您需要停止并拣选该补丁。如果您已经解决了不同文件中的一些冲突,并且不想再做一遍,您可以创建该文件的临时副本。

要中止当前的拣选,请运行 git cherry-pick --abort,然后使用先决补丁的提交 ID 重新开始拣选过程。

理解冲突标记

合并差异(Combined diffs)

假设您已决定不拣选(或恢复)额外的补丁,只想解决冲突。Git 会在您的文件中插入冲突标记。开箱即用,这看起来会像这样:

<<<<<<< HEAD
this is what's in your current tree before cherry-picking
=======
this is what the patch wants it to be after cherry-picking
>>>>>>> <commit>... title

如果您在编辑器中打开文件,您会看到这样的内容。但是,如果您不带任何参数运行 git diff,输出将看起来像这样:

$ git diff
[...]
++<<<<<<<< HEAD
 +this is what's in your current tree before cherry-picking
++========
+ this is what the patch wants it to be after cherry-picking
++>>>>>>>> <commit>... title

当您解决冲突时,git diff 的行为与正常行为不同。注意,这里是两列差异标记,而不是通常的一列;这是一种所谓的“合并差异(combined diff)”,这里显示的是三方差异(或差异的差异)在以下两者之间:

  1. 当前分支(拣选前)和当前工作目录,以及

  2. 当前分支(拣选前)和应用原始补丁后的文件外观。

更好的差异(Better diffs)

三方合并差异包括在您的当前分支和您正在拣选的分支之间对文件发生的所有其他更改。虽然这对于发现您需要考虑的其他更改很有用,但这也使得 git diff 的输出有些令人望而生畏且难以阅读。您可能更喜欢运行 git diff HEAD(或 git diff --ours),它只显示当前分支在拣选之前与当前工作目录之间的差异。它看起来像这样:

$ git diff HEAD
[...]
+<<<<<<<< HEAD
 this is what's in your current tree before cherry-picking
+========
+this is what the patch wants it to be after cherry-picking
+>>>>>>>> <commit>... title

如您所见,这读起来就像任何其他差异一样,并清楚地表明了哪些行在当前分支中,以及哪些行是由于合并冲突或正在拣选的补丁而添加的。

合并风格与 diff3

上面显示的默认冲突标记样式称为 merge 样式。还有另一种可用的样式,称为 diff3 样式,它看起来像这样:

<<<<<<< HEAD
this is what is in your current tree before cherry-picking
||||||| parent of <commit> (title)
this is what the patch expected to find there
=======
this is what the patch wants it to be after being applied
>>>>>>> <commit> (title)

如您所见,这有 3 个部分而不是 2 个,并包含了 git 预期会找到但未找到的内容。强烈建议使用这种冲突样式,因为它能更清楚地显示补丁实际更改了什么;即,它允许您比较您正在拣选的提交的文件修改前后版本。这使您能够对如何解决冲突做出更好的决策。

要更改冲突标记样式,您可以使用以下命令:

git config merge.conflictStyle diff3

还有第三个选项,zdiff3,在 Git 2.35 中引入,它具有与 diff3 相同的 3 个部分,但共同的行已被删除,在某些情况下使冲突区域更小。

迭代解决冲突

任何冲突解决过程的第一步是理解您正在回溯的补丁。对于 Linux 内核来说,这尤为重要,因为不正确的更改可能导致整个系统崩溃——或者更糟,导致未被检测到的安全漏洞。

理解补丁的难易程度取决于补丁本身、变更日志以及您对被更改代码的熟悉程度。然而,对于每次更改(或补丁的每个代码块),一个很好的问题可能是:“为什么这个代码块会出现在补丁中?”这些问题的答案将指导您的冲突解决。

解决过程

有时最简单的方法是只保留冲突的第一部分,使文件基本保持不变,然后手动应用更改。也许补丁正在将函数调用参数从 0 更改为 1,而一个冲突的更改在参数列表的末尾添加了一个全新的(且不重要的)参数;在这种情况下,手动将参数从 0 更改为 1,并保留其他参数就足够简单了。这种手动应用更改的技术主要在冲突引入了大量您不需要关心的不相关上下文时有用。

对于带有许多冲突标记的特别棘手的冲突,您可以使用 git addgit add -i 来选择性地暂存您的解决方案,以将其移开;这还允许您使用 git diff HEAD 始终查看还有哪些需要解决,或使用 git diff --cached 查看您的补丁目前看起来如何。

处理文件重命名

在回溯补丁时,最令人恼火的事情之一是发现其中一个被修补的文件已被重命名,因为这通常意味着 git 甚至不会放置冲突标记,而只是摊手说(意译): “未合并的路径!你自己搞定吧……”

通常有几种方法可以解决这个问题。如果重命名文件的补丁很小,比如只有一行更改,最简单的办法就是直接手动应用更改并完成。另一方面,如果更改很大或很复杂,您肯定不想手动操作。

作为第一步,您可以尝试这样做,它会将重命名检测阈值降低到 30%(默认情况下,git 使用 50%,这意味着两个文件需要至少有 50% 的共同点,它才会将添加-删除对视为潜在的重命名):

git cherry-pick -strategy=recursive -Xrename-threshold=30

有时,正确的做法是也回溯执行重命名的补丁,但这肯定不是最常见的情况。相反,您可以做的是临时重命名您正在回溯到的分支中的文件(使用 git mv 并提交结果),重新尝试拣选补丁,再将文件重命名回来(再次使用 git mv 并再次提交),最后使用 git rebase -i(参见 rebase 教程)将结果压缩,这样在您完成时它显示为一个单一提交。

陷阱

函数参数

注意改变函数参数!很容易忽略细节,认为两行相同,但实际上它们在一些小细节上有所不同,比如传递的变量是哪个(特别是如果这两个变量都是一个看起来相同的单字符,比如 i 和 j)。

错误处理

如果您拣选的补丁包含 goto 语句(通常用于错误处理),那么绝对有必要仔细检查目标标签在您回溯到的分支中是否仍然正确。对于新增的 returnbreakcontinue 语句也是如此。

错误处理通常位于函数的底部,因此即使它可能已被其他补丁更改,它也可能不属于冲突。

确保您检查错误路径的一个好方法是始终在检查您的更改时使用 git diff -Wgit show -W(即 --function-context)。对于 C 代码,这会显示补丁中被更改的整个函数。在回溯过程中经常出错的一件事是,函数中的其他内容在您回溯的源分支或目标分支上发生了变化。通过在差异中包含整个函数,您可以获得更多上下文,并且更容易发现否则可能被忽视的问题。

重构的代码

经常发生的情况是,通过将常见的代码序列或模式“提取”到辅助函数中来重构代码。当回溯补丁到发生此类重构的区域时,您实际上需要在回溯时进行反向操作:对单个位置的补丁可能需要应用于回溯版本中的多个位置。(这种情况的一个线索是函数被重命名了——但这并非总是如此。)

为避免不完整的反向移植,值得尝试弄清楚该补丁是否修复了在多个地方出现的错误。一种方法是使用 git grep。(这实际上通常是个好主意,不只针对反向移植。)如果您确实发现在上游树中存在相同类型的修复会适用于其他地方,那么也值得看看这些地方是否存在于上游——如果不存在,则补丁可能需要调整。git log 是您的朋友,可以帮助您弄清楚这些区域发生了什么,因为 git blame 不会显示已删除的代码。

如果您在上游树中确实发现了相同模式的其他实例,并且不确定它是否也是一个错误,那么值得询问补丁作者。在回溯过程中发现新 bug 并不少见!

验证结果

colordiff

提交无冲突的新补丁后,您现在可以将您的补丁与原始补丁进行比较。强烈建议您使用 colordiff 等工具,它可以并排显示两个文件,并根据它们之间的更改进行着色:

colordiff -yw -W 200 <(git diff -W <upstream commit>^-) <(git diff -W HEAD^-) | less -SR

这里,-y 表示进行并排比较;-w 忽略空白,-W 200 设置输出宽度(否则默认使用 130,这通常有点太小)。

这里的 rev^- 语法是 rev^..rev 的一个方便的简写,本质上只为您提供该单个提交的差异;另请参阅官方的 git rev-parse 文档

再次注意 git diff 中包含 -W;这确保您将看到任何已更改函数的完整函数。

colordiff 做的一件极其重要的事情是突出显示不同的行。例如,如果原始补丁和回溯补丁之间的错误处理 goto 标签发生了变化,colordiff 会将它们并排显示,但用不同的颜色突出显示。因此,很容易看出这两个 goto 语句正在跳转到不同的标签。同样,未被任何补丁修改但在上下文中不同的行也会被突出显示,从而在手动检查时显得突出。

当然,这只是视觉检查;真正的测试是构建并运行打过补丁的内核(或程序)。

构建测试

我们在此不涵盖运行时测试,但作为快速的健全性检查,构建只被补丁触及的文件是个好主意。对于 Linux 内核,如果您正确设置了 .config 和构建环境,您可以像这样构建单个文件:

make path/to/file.o

请注意,这不会发现链接器错误,因此在验证单个文件编译后,您仍然应该进行完整构建。通过首先编译单个文件,您可以避免在更改的任何文件存在编译器错误时等待完整构建。

运行时测试

即使成功的构建或启动测试也不一定足以排除某处缺少依赖。尽管几率很小,但可能存在代码更改,其中对同一文件的两个独立更改导致没有冲突、没有编译时错误,并且仅在特殊情况下才出现运行时错误。

一个具体的例子是对系统调用入口代码的一对补丁,其中第一个补丁保存/恢复一个寄存器,而稍后的补丁在该序列的中间某个地方使用了相同的寄存器。由于更改之间没有重叠,所以可以拣选第二个补丁,没有冲突,并认为一切正常,而事实上代码现在正在覆盖一个未保存的寄存器。

尽管绝大多数错误将在编译期间或通过表面地运行代码时被捕获,但真正验证回溯补丁的唯一方法是像对待任何其他补丁一样,以相同程度的严格性审查最终补丁。拥有单元测试、回归测试或其他类型的自动化测试有助于增加对回溯补丁正确性的信心。

提交回溯补丁到 stable 分支

由于 stable 维护者会尝试将主线修复拣选到他们的 stable 内核上,当遇到冲突时,他们可能会发送电子邮件请求回溯补丁,例如参见 <https://lore.kernel.org/stable/2023101528-jawed-shelving-071a@gregkh/>。这些电子邮件通常会包含您将补丁拣选到正确分支并提交补丁所需的精确步骤。

需要确保的一点是,您的变更日志符合预期的格式:

<original patch title>

[ Upstream commit <mainline rev> ]

<rest of the original changelog>
[ <summary of the conflicts and their resolutions> ]
Signed-off-by: <your name and email>

“Upstream commit”一行有时会因 stable 版本而略有不同。旧版本使用这种格式:

commit <mainline rev> upstream.

最常见的是在电子邮件主题行中指明补丁适用的内核版本(例如使用 git send-email --subject-prefix='PATCH 6.1.y'),但您也可以将其放在 Signed-off-by: 区域或 --- 行下方。

stable 维护者期望为每个活跃的 stable 版本单独提交,并且每个提交也应该单独进行测试。

一些最后的建议

  1. 以谦逊的态度对待回溯过程。

  2. 理解您正在回溯的补丁;这意味着阅读变更日志和代码。

  3. 提交补丁时,对结果的信心要诚实。

  4. 向相关维护者请求明确的确认(acks)。

示例

上述内容大致展示了回溯补丁的理想化过程。如需更具体的示例,请参阅此视频教程,其中展示了将两个补丁从主线回溯到 stable 分支的過程:回溯 Linux 内核补丁