为什么 git pull origin develop --rebase 会导致冲突而 git pull origin develop 不会?

Why does git pull origin develop --rebase cause conflict when git pull origin develop doesn't?

现在我通常使用

git pull origin develop

从开发分支获取最新更新。最近,我的团队一直在过渡到使用 rebase 而不是合并,所以我对某些东西有点困惑。在我的工作流程非常简单之前。我会先结帐到开发分支并使用

git checkout -b feature/foo

然后我会进行更改、提交然后推送它们。通常 develop 分支会因此进行一些更改,我会使用

 git pull origin develop

获取最新的更改,只有当其他人修改同一个文件时才会发生冲突。但是,当我使用

git pull origin develop --rebase

我注意到我会与我自己的分支发生冲突,即使我是唯一修改它的人。这有什么特别的原因吗?有没有办法避免我与自己的分支之间的这些合并冲突?

首先,我们要注意git pull主要由运行两个Git命令组成。这意味着它是一种方便的操作,让您键入 git pull 而不是 git fetch 输入 git .....。第一个命令始终是 git fetch,第二个是您的选择:默认为 git merge,但您可以选择 git rebase。当你想变基时,执行一个命令和执行两个命令几乎一样多,所以它毕竟不是很方便,我建议使用单独的 git fetch 和第二个命令,至少直到你'我们非常熟悉 Git.1

所以你的问题真的解决了一个更简单的问题:为什么 rebase 有时会发生合并没有的冲突? 对此有一个答案,实际上相当简单: Rebase主要就是重复cherry-picking,cherry-picking是合并的一种形式。因此,当您合并时,您有 一个 可能发生冲突的地方。如果你 rebase 十次提交,你有 ten 个地方可能会发生冲突。冲突本身也可能不同,但机会的规模是这里的主要因素。


1在带有子模块的存储库中,git pull 可以递归到子模块中,在这种情况下,它是两个以上的命令,其便利性方面变得很重要。你也可以默认配置git pull到运行git rebase,即使没有子模块也能重新出现便利。不过,我仍然鼓励新用户使用两个单独的命令——git pull 的语法有点奇怪,与几乎所有其他 Git 的东西有点不同,而且它很容易混淆。分配给 pull 的魔法太多了,而实际上 all 魔法来自第二个命令——你需要学习合并才能理解 rebase。


正在合并

尽管实现充满了棘手的小曲折,但合并背后的 idea 很简单。当我们要求 Git 合并时,我们有 "our work" 和 "their work"。 Git 需要弄清楚 我们 改变了什么,他们 改变了什么,并将这些改变结合起来。

为了做到这一点,Git 需要找到一个共同的起点。提交根本不是 一组更改 :它实际上是一个快照。 Git 可以将这些快照之一显示为其前身的不同之处,即提取两个快照并查看有什么不同。因此,如果我们从一些具有哈希 ID B 的提交开始,并且 他们 也从那个 same 提交开始:

          C--D   <-- our-branch (HEAD)
         /
...--A--B
         \
          E--F   <-- their-branch

然后 Git 可以将 B 中的快照与我们的最新快照 D 以及他们的最新快照 F 进行比较。 B-vs-D 的不同之处在于 we 发生了变化。 B-vs-F 的不同之处在于 他们 发生了变化。 Git 然后合并更改,将 合并的 更改应用到来自合并基础 B 的快照,并提交结果,将它连接到 两位前辈:

          C--D
         /    \
...--A--B      G   <-- our-branch (HEAD)
         \    /
          E--F   <-- their-branch

要到达那里,Git 必须 运行:

  • git diff --find-renames <em>hash-of-B</em> <em>hash-of-D</em>(我们改变了什么)
  • git diff --find-renames <em>hash-of-B</em> <em>hash-of-F</em>(他们改变了什么)

当 Git 将这两个差异组合在一起时,我们和他们可能会在某些地方更改 相同的行 相同的文件。如果我们没有对这些行进行相同的 更改,Git 将声明冲突并在中间停止合并,而不是提交 G yet,迫使我们收拾残局并完成合并以创建 G.

摘樱桃

cherry-pick 背后的想法是复制 一个提交。要复制提交,我们可以 Git 将其转换为一组更改:

  • git diff --find-renames <em>hash-of-parent</em> <em>hash-of-commit</em>

然后我们可以将这些更改手动应用到其他地方,即其他提交。例如,如果我们有:

          C--D   <-- our-branch (HEAD)
         /
...--A--B
         \
          E--F   <-- their-branch

我们喜欢他们在 F 中所做的事情,但还不想 E 本身,我们可以区分 EF,看看他们做了什么做过。我们可以使用它来尝试对 D 中的快照进行相同的更改。然后我们给自己做一个新的提交——我们称它为 F' 表示 copy of F:

          C--D--F'  <-- our-branch (HEAD)
         /
...--A--B
         \
          E--F   <-- their-branch

但是如果我们在 C 中进行了重大更改,或者他们在 E 中进行了重大更改,则可能很难从 E-到-F 与我们在 D 中的快照对齐。为了 Git 帮助我们,并自动执行此复制 ,Git 想知道:E 之间有什么不同D? 也就是说,Git 想要 运行:

  • git diff --find-renames <em>hash-of-E</em> <em>hash-of-D</em>(我们在 CE 中有什么)
  • git diff --find-renames <em>hash-of-E</em> <em>hash-of-F</em>(他们在 F 中更改了什么)

但是等等,我们刚刚在 git merge 期间看到了上面相同的模式!事实上,这正是 Git 在这里所做的:它使用 git merge 相同的代码 ,它只是强制合并基础——这将是 B 用于常规合并 - 提交 E,我们正在挑选的提交 F 的父级。 Git 现在将我们的更改与他们的更改结合起来,将组合的更改集应用到基础中的快照(在 E 中)并自行进行最终的 F' 提交,但是这次作为常规提交。

新提交也重新使用提交F本身的提交消息,因此新提交F'(它有一些新的散列ID,与 F 不同)很像 Fgit show 可能显示相同或非常相似的差异列表,当然还有相同的提交日志消息。

git merge 一样,这个合并过程——我喜欢将其称为 合并动词 ——可能会出错。如果确实出错,Git 会抱怨合并冲突,并在合并未完成时停止,并让您清理混乱并提交。当你提交时,Git 知道你正在完成一个 git cherry-pick 并在那个时候为你复制提交消息,使 F'.

Rebase 重复 cherry-picking

做一个 git 变基 <em>target</em>, Git:

  • 列出您在您的分支上的提交,这些提交不是 可访问的 (一个技术术语:请参阅 Think Like (a) Git 来自 target ;
  • 在适当的情况下修剪此列表——见下文;
  • 检出提交 target 作为 "detached HEAD";
  • 重复,一次一个提交,使用 git cherry-pick 复制列表中的每个提交。2

成功复制所有要复制的提交后,Git 将分支名称移动到复制列表的末尾。

假设我们从与之前类似的设置开始,但我将在此处列出更多提交:

          C--D--E--F   <-- our-branch (HEAD)
         /
...--A--B
         \
          G--H   <-- their-branch

我们运行 git rebase their-branch,所以Git 列出要复制的提交:C-D-E-F,按顺序。然后 Git 检出提交 H 作为 "detached HEAD":

          C--D--E--F   <-- our-branch
         /
...--A--B
         \
          G--H   <-- their-branch, HEAD

现在 Git 将挑选 C 进行复制。如果一切顺利:

          C--D--E--F   <-- our-branch
         /
...--A--B
         \
          G--H   <-- their-branch
              \
               C'  <-- HEAD

Git 重复 DEF。完成后 DE 我们处于这种状态:

          C--D--E--F   <-- our-branch
         /
...--A--B
         \
          G--H   <-- their-branch
              \
               C'-D'-E'  <-- HEAD

在Git完成将F复制到F'之后,rebase的最后一步是将名称our-branch拉到指向最终复制的提交,并且重新附加 HEAD 到它:

          C--D--E--F   [abandoned]
         /
...--A--B
         \
          G--H   <-- their-branch
              \
               C'-D'-E'-F'  <-- our-branch (HEAD)

每个 cherry-pick 执行一个三向合并,操作的合并基础是被复制的提交的父级,"ours" 提交是分离的 HEAD —请注意,最初是 他们的 提交 H,随着我们的进步,它会随着时间的推移变成 "their commit H plus our work"。 "theirs" 提交每次都是我们自己的提交。每个 cherry-pick 都可能有所有常见的合并冲突,但在大多数情况下,大多数没有。

有两个案例特别糟糕。其中之一,可能是最常见的,是当你自己的任何提交,例如在列表 C-D-E-F 中,本身是 G-H 链中的某些东西的精选(这通常是长于两次提交)——反之亦然,例如,也许 H 本质上是 D'.

如果您或他们能够更早地轻松进行挑选,没有冲突,您的副本可能看起来几乎完全一样,甚至 100% 完全像 G-H 链中的一个。如果是这样,Git 可以识别它 这样的副本,并将其从 "to be copied" 列表中删除。在我们这里的例子中,如果 H 真的是 D',并且 Git 可以看到,Git 将从待复制列表中删除 D,并且只复制 C-E-F。但如果不是——例如,如果他们不得不改变他们的D副本以制作H——那么Git尝试复制D并且这些更改几乎肯定与他们修改的H.

冲突

如果您合并而不是复制,您将比较 BH(他们的)和 BF(您的)并且发生冲突的可能性是也许减少了。即使存在冲突,它们也可能更明显并且更容易解决。如果冲突是因为不必要的副本,根据我的经验,它们往往看起来更棘手。

另一个常见的问题案例是,在您的 C-D-E-F 链中,您最后几次提交是您专门为使合并更容易而做的事情。也就是说,有人可能会这样说:我们更改了 foo 子系统,现在您需要第三个参数 并且您在挑选更改后在 F 中添加了第三个参数在 E。复制 CD 时会发生冲突。您可能会跳过复制 E,因为它 一个精选,然后在您解决了 D 中的冲突后,就不需要复制 FE,但这是需要修复的两份副本,一份自动删除,一份需要您自己手动删除。

所以,最后,git merge 进行了一次合并,但是 git rebase 进行了多次挑选,每一次都是——在内部——一次合并,每一次都可以导致合并冲突。变基得到更多冲突也就不足为奇了!


2从技术上讲,普通(非交互式)git rebase 通常 不会 使用 git cherry-pick .相反,它实际上使用 git format-patch ... | git am ...。使用 git rebase -i 总是使用 git cherry-pick,而 git rebase -m 强制非交互式 git rebase 使用 git cherry-pick。简单的 rebase 避免它的事实主要只是古代(可能是 2008 年左右之前)Git,在 cherry-pick 被教导进行适当的三向合并之前。

git am步骤使用-3,因此如果补丁失败,Git将"fall back"进行三向合并。结果通常是相同的,但是 format-patch-pipe-to-am 方法永远找不到重命名的文件。这使得格式补丁样式更快,但不是那么好。