为什么使用git cherry-pick比git rebase产生更少的冲突?

13

我经常变基。有时候,rebase会特别棘手(有很多合并冲突),在这种情况下,我的解决方案是将个别提交cherry-pick到主分支上。我之所以这样做,是因为几乎每次这样做,冲突的数量都要少得多。

我的问题是为什么这样做情况下会有更少的合并冲突。

为什么当我cherry-pick时会比rebase时有较少的合并冲突?

在我的心智模型中,rebasecherry-pick正在做同样的事情。

变基示例

A-B-C (master)
   \
    D-E (next)

git checkout next
git rebase master

产生

A-B-C (master)
     \
      D`-E` (next)

然后

git checkout master
git merge next

产生

A-B-C-D`-E` (master)

挑刺例子

A-B-C (master)
   \
    D-E (next)

git checkout master 
git cherry-pick D E

产生

A-B-C-D`-E` (master)

据我的理解,最终结果是相同的。(D和E现在已经在主分支上,并具有干净的提交历史记录。)

为什么后者(cherry picking)会比前者(rebasing)产生更少的合并冲突?

更新 更新 更新

我终于能够重现这个问题了,现在我意识到可能我之前举的例子过于简单了。以下是我如何复现它的过程...

假设我有以下内容(请注意多出来的分支):

A-B-C (master)
   \
    D-E (next)
       \
        F-G (other-next)

然后我按照以下步骤进行

git checkout next
git rebase master
git checkout master
git merge next

我最终得到了以下结果

A-B-C-D`-E` (master)
   \ \
    \ D`-E` (next)
     \
      D-E
         \
          F-G (other-next)

从这里开始,我要么变基,要么挑选特定提交集

变基示例

git checkout other-next
git rebase master 

生成

A-B-C-D`-E`-F`-G` (master)

挑选例子

git checkout master
git cherry-pick F G

产生相同的结果

A-B-C-D`-E`-F`-G` (master)

但与变基策略相比,合并冲突要少得多。

最终我终于成功复制了一个类似的例子,我认为我明白为什么变基会比挑选更容易产生合并冲突,但是我将把这个问题留给其他人来回答(他们可能能够做得更好(和更准确)比我)。


是的,提供一个复现案例会有助于理解,因为 git-rebase 在底层使用 git-cherry-pick。 (无论是显式地还是在补丁应用失败时作为后备方案。) - Edward Thomson
1
虽然我认为准备好最小可复现代码后,你很可能已经找到了自己问题的答案,并能够与我们分享在不同场景下重新定位和挑选的有用指南。 - Leon
1
@EdwardThomson - 非常有趣...这就是我一直在思考rebase和cherry-pick的方式... - sfletche
@torek:没错,但我的想法是这将相当于“cherry-pick”(暂且忽略重命名),不过我想我可能会漏掉一些微妙的东西... - Edward Thomson
@Leon - 编辑了问题并更新了一些内容,希望能够更清晰明了。 - sfletche
显示剩余5条评论
1个回答

12

更新的回答(请参见问题中的更新)

我认为这里发生的事情与选择要复制的提交有关。

让我们注意一下,然后搁置一下这个事实:git rebase 可能会使用 git cherry-pickgit format-patchgit am 来复制一些提交。在大多数情况下,git cherry-pickgit am 应该可以实现相同的结果。(git rebase 文档 特别指出了上游文件重命名作为使用 cherry-pick 方法的问题,而默认的基于 git am 的方法用于非交互式 rebase。另请参见下面原始答案中的各种括号内备注和评论。)

这里需要考虑的主要问题是要复制哪些提交。在手动方法中,您首先手动将提交DE复制到D'E',然后手动将FG复制到F'G'。这是我们想要的最少工作量,唯一的缺点是我们必须进行所有手动提交标识。
当您使用以下命令:
git checkout <branch> && git rebase <upstream>

您可以让Git自动化查找要复制的提交过程。这很好,当Git做对的时候,但如果Git做错了,就不是那么好了。
那么Git是如何选择这些提交的呢?简单但有些错误的答案在这句话中(来自相同的文档):
“当前分支中提交所做的所有更改但不在中的提交都保存在临时区域中。这是与git log ..HEAD或git log 'fork_point'..HEAD显示的提交集相同的提交集,如果--fork-point已激活(请参见下面关于--fork-point的描述),则为激活状态;或者通过git log HEAD,如果指定了--root选项。”
--fork-point的复杂性是比较新的,自git 2.x以来,但在这种情况下它并没有被“激活”,因为您指定了参数并且没有指定--fork-point。实际上的在两次操作中都是master。
现在,如果您实际运行每个git log(使用--oneline使其更好看):
git checkout next && git log --oneline master..HEAD

并且:

git checkout other-next && git log --oneline master..HEAD

你会发现第一个列表列出了提交DE,很好!但是第二个列表列出了DEFG。哦,DE出现了两次!
问题是,这种方法有时候有效。嗯,我上面说的“有点不对”,就在前面引用的两段话中解释了:
请注意,任何在HEAD中引入与HEAD..<upstream>中提交相同文本更改的提交都将被省略(即已使用不同提交消息或时间戳接受上游的补丁将被跳过)。
请注意,这里的HEAD..<upstream>git log命令中我们刚刚运行的<upstream>..HEAD的反向,其中我们看到了DG
对于第一次rebase,git log HEAD..master中没有提交,因此不会跳过任何提交。这很好,因为我们没有要跳过的提交:我们只是将EF复制到E'F'中,这正是我们想要的。
对于第二次rebase,在第一次rebase完成后,git log HEAD..master会显示E'F'两个提交的副本。这些提交被认为是潜在要跳过的提交:它们是要考虑跳过的候选提交。
“潜在要跳过的”并不等同于“真正要跳过的”。
那么 Git 如何决定哪些提交真正应该被跳过呢?答案在于 git patch-id,尽管它实际上是直接在 git rev-list 中实现的,这是一个非常花哨和复杂的命令。但是,这两个命令都没有很好地描述它,部分原因是因为它很难描述。不过,这里是我的尝试。 :-)
Git在这里所做的是查看差异,去掉标识行号,以防补丁在不同位置(由于先前的补丁将行上下移动)而略有不同。它使用与文件相同的技巧 - 将文件内容转换为唯一哈希 - 将每个提交转换为“补丁ID”。提交ID是一个唯一的哈希,用于标识一个特定的提交,并始终标识相同的提交。补丁ID是另一个(但仍然是唯一的某些内容)哈希ID,它始终标识“相同”的补丁,即删除和添加相同的差异块,即使从不同位置删除和添加它们。
Git为每个提交计算了补丁ID,然后可以说:“啊哈,提交D和提交D'有相同的补丁ID!我应该跳过复制D,因为D'可能是复制D的结果。”它也可以对E与E'进行同样的操作。这通常有效,但在从D到D'的复制需要手动干预(修复合并冲突)时,它会失败,并且在从E到E'的复制需要手动干预时也会失败。
需要的是一种“智能变基”,它可以预先查看一系列分支并计算要复制哪些提交,一次性为所有待变基的分支完成复制。然后,在完成所有复制之后,“智能变基”将调整所有分支名称。
在这种特定情况下-复制D到G-实际上很容易,您可以手动执行此操作:
$ git checkout -q other-next && git rebase master
[here rebase copies D, E, F, and G, perhaps with your assistance]

接着是:

$ git checkout next
[here git checks out "next", so that HEAD is ref: refs/heads/next
 and refs/heads/next points to original commit E]
$ git reset --hard other-next~2

这能够起作用是因为other-next指向了提交G',它的父提交是F',而F'的父提交则是E',这正是我们希望next指向的位置。由于HEAD引用了分支next,所以git reset会调整refs/heads/next来指向提交E',然后我们就完成了。
在更复杂的情况下,需要仅复制一次的提交并不都是线性的:
                A1-A2-A3  <-- featureA
               /
...--o--o--o--o--o--o--o   <-- master
         \
          *--*--B3-B4-B5   <-- featureB
              \
               C3-C4       <-- featureC

如果我们想要对这三个特性进行“多次变基”,我们可以独立于其他两个功能重新定义featureA - 除了早期的A提交之外,这三个A提交都不依赖于任何“非主”内容。但是,为了复制这五个B提交和这四个C提交,我们必须复制那两个*提交,它们既是B又是C,但只复制一次,然后将其余的三个和两个提交(分别)复制到已复制提交的顶部。 (实现这样一个“智能变基”是可能的,但将其整合到Git中并使得git status真正理解它则相当困难。)

原始回答

我希望看到一个可重现的例子。在大多数情况下,你的“头脑中”模型应该是有效的。但有一种特殊情况。

交互式变基(interactive rebase)或在普通的git rebase命令中添加-m--merge选项实际上使用了git cherry-pick,而默认的非交互式变基则使用git format-patchgit am。后者对于重命名检测不够好。特别是,如果在上游进行了文件重命名,那么可以预期交互式或--merge变基将表现得不同(通常更好)。

(另外,请注意两种重新设置基础的方式——基于补丁和基于 cherry-pick 的版本——都会跳过与已经存在于上游的提交相同的提交,通过使用 git rev-list --left-only --cherry-pick HEAD...<upstream> 或等效命令。请参阅 git rev-list 的文档,特别是关于 --cherry-mark--left-right 的部分,我认为这使得这一点更加易于理解。不过,对于两种重新设置基础的方式来说,这应该是相同的;如果您手动进行 cherry-pick,那么您需要自己决定是否要这样做。)
更精确地说,git diff --find-renames 需要“相信”那里有一个重命名。通常情况下,如果有一个重命名,它会相信这一点,但由于它是通过比较树来“检测”它们的,所以这并不完美。

@Leon: 哦,那很有道理。我现在会把它留着,如果以后需要的话再进行编辑... - torek
@Leon:你是正确的(在rebase之后合并)。抱歉给torek带来了困惑。我编辑了我的问题,希望能澄清这一点。 - sfletche
感谢 @torek 的答案!我在一天后重新审视了我的问题,并进行了一些编辑,这些编辑可能有助于澄清为什么在 rebase 和 cherry-pick 时存在合并冲突数量差异。如果您有兴趣,我很欢迎您的跟进答案。 - sfletche
1
是的,重新回答了。 :-) 我实际上有一个(不是很好的,非常粗略的尝试)“智能变基”脚本,我开始并且已经勉强运行起来了,我正在考虑它可能会成为我的停滞不前的关于Git和Mercurial项目的一部分“编写”的好程序,在那里我需要在一个示例项目中进行样本提交... - torek
今天我学到了一个新技巧,原来可以在分支A上进行交互式变基,将其合并到分支B上。这个方法解决了所有的问题。 - undefined

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接