Stop cherry-picking, start merging,第2部分:合并冲突从未发生

147 阅读7分钟

停止cherry-picking,开始合并,第2部分:本应发生但从未发生的合并冲突

原文链接: Stop cherry-picking, start merging, Part 2: The merge conflict that never happened (but should have)

原文作者:Raymond Chen,发布于2018年3月13日

上次,我们看到了如何编辑受cherry-pick影响的代码会创建一个潜在的合并冲突,这个冲突直到原始提交和其cherry-picked副本在某处合并时才会显现出来,而这个地方可能远离包含原始提交和其cherry-pick的分支。

但你知道什么比合并冲突更糟糕吗?

没有合并冲突。

让我们设置与上次相同的情况:

%%{init: { 'gitGraph': {'showBranches': true, 'showCommitLabel':true, 'mainBranchName': 'master'}} }%%
gitGraph
   commit id: "A (apple)"
   branch feature
   checkout feature
   commit id: "F1 (apple)"
   commit id: "F2 (berry)"
   checkout master
   commit id: "M1 (apple)"
   commit id: "M2 (berry)" type: REVERSE

假设这个feature分支已经存在一段时间,当它达到稳定里程碑时会将其更改合并回master分支。我们的图表从最近一次合并回master分支后的时间点开始,feature分支已经开始了下一个里程碑功能的工作。

假设包含单词apple的那一行在控制feature的配置文件中。master分支和feature分支都进行了提交(分别是M1和F1),这些提交与配置文件无关。

假设你现在发现feature中有一个严重问题,导致它失控。为了立即停止这个问题,你对feature分支进行了提交F2,将配置文件设置为berry,这样做的效果是关闭了该feature。

(在实际情况中,这种更改更像是将

#define IS_FEATURE_ENABLED 1

改为

#define IS_FEATURE_ENABLED 0

但我坚持使用appleberry,这样与昨天的例子更一致。)

好的,你在feature分支中禁用了feature,验证它没有任何意外的副作用,并将修复cherry-pick到master分支。呼,这停止了出血,给你时间来弄清楚发生了什么并提出修复方案。

(如果你的工作流是将修复应用到master分支,然后cherry-pick到feature分支,那也很好,按照你的方式做。故事是一样的。)

在你调查问题的同时,master分支的工作继续进行。后来,你在feature分支中找到了真正的修复方案,这涉及重新启用feature(通过将该行设置回apple)并修复其他地方的根本原因。提交图现在看起来像这样:

%%{init: { 'gitGraph': {'showBranches': true, 'showCommitLabel':true, 'mainBranchName': 'master'}} }%%
gitGraph
   commit id: "A (apple)"
   branch feature
   checkout feature
   commit id: "F1 (apple)"
   commit id: "F2 (berry)"
   commit id: "F3 (apple)"
   checkout master
   commit id: "M1 (apple)"
   commit id: "M2 (berry)" type: REVERSE
   commit id: "M3 (berry)"

在master分支上,在M2之上添加了一个额外的无关提交M3。在feature分支上,在F2之上添加了一个额外的提交F3,F3将berry改回apple,并修复了问题的根本原因。

好的,现在你想将feature分支合并到master分支,这样临时修复可以被真正的修复所替代。但当你进行合并时,发生了这种情况:

%%{init: { 'gitGraph': {'showBranches': true, 'showCommitLabel':true, 'mainBranchName': 'master'}} }%%
gitGraph
   commit id: "A (apple)"
   branch feature
   checkout feature
   commit id: "F1 (apple)"
   commit id: "F2 (berry)"
   commit id: "F3 (apple)"
   checkout master
   commit id: "M1 (apple)"
   commit id: "M2 (berry)" type: REVERSE
   commit id: "M3 (berry)"
   merge feature id: "M4 (berry) ⚠️"

master分支从feature分支合并,产生了提交M4,但在提交M4中,该行仍然是berry!临时修复仍然存在于master分支中。实际上,情况更糟:临时修复的berry部分存在于master分支中,但提交F3中其他部分的永久修复也存在!这两个部分修复可能彼此不能很好地交互,在这种情况下,你处于更糟糕的位置,即feature在master分支中是损坏的,但在feature分支中却正常工作。

今天,我们将调查发生了什么。下次,我们将研究如何防止这种情况在未来发生。

让我们回到尝试将feature分支合并到master分支之前的仓库状态:

%%{init: { 'gitGraph': {'showBranches': true, 'showCommitLabel':true, 'mainBranchName': 'master'}} }%%
gitGraph
   commit id: "A (apple)"
   branch feature
   checkout feature
   commit id: "F1 (apple)"
   commit id: "F2 (berry)"
   commit id: "F3 (apple)"
   checkout master
   commit id: "M1 (apple)"
   commit id: "M2 (berry)" type: REVERSE
   commit id: "M3 (berry)"

现在我们执行合并。Git寻找一个合并基础,即提交A,这是两个分支之间最近的共同祖先。Git然后使用A作为基础,M3作为HEAD,F3作为传入的更改执行三向合并。现在重要的只是基础和两个终端提交之间的差异,所以让我们从图表中删除不相关的提交。

%%{init: { 'gitGraph': {'showBranches': true, 'showCommitLabel':true, 'mainBranchName': 'master'}} }%%
gitGraph
   commit id: "A (apple)"
   branch feature
   checkout feature
   commit id: "F3 (apple)"
   checkout master
   commit id: "M3 (berry)"

在简化的图表中,我们仍然有共同的合并基础,即提交A(我们以apple开始的地方),但我们只看到master分支中的提交M3(我们有berry)和feature分支中的提交F3(我们有apple)。

将基础与master分支的头部进行比较,我们看到apple变成了berry。将基础与feature分支的头部进行比较,我们看到apple根本没有变化。由于该行在feature分支中没有更改,这意味着来自feature分支的合并也不会更改该行。结果是该行保持不变,所以它保持在master分支中的当前值berry

情况更糟:如果随后你从master分支合并到feature分支,错误的行会传播到feature分支。

%%{init: { 'gitGraph': {'showBranches': true, 'showCommitLabel':true, 'mainBranchName': 'master'}} }%%
gitGraph
   commit id: "A (apple)"
   branch feature
   checkout feature
   commit id: "F1 (apple)"
   commit id: "F2 (berry)"
   commit id: "F3 (apple)"
   checkout master
   commit id: "M1 (apple)"
   commit id: "M2 (berry)" type: REVERSE
   commit id: "M3 (berry)"
   merge feature id: "M4 (berry)"
   checkout feature
   merge master id: "F4 (berry) ⚠️⚠️"

对于从master分支到feature分支的合并,共同的合并基础是提交F3,也是feature分支的头部。在提交F3中,该行是apple。在master分支的头部,它是berry,这一变化传播到feature分支。结果,在feature分支的新提交F4中,该行现在是berry。(我选择使用非快进合并,但如果是快进合并,你会看到相同的情况。)

大多数人认为cherry-pick是"预期部分合并",即你想将源分支的一部分合并到目标分支中。预期是,如果你以后决定将源分支的其余部分合并到目标分支中,它将只合并新的部分。

如果你在两边最终合并之前小心不要触碰受cherry-pick影响的行,那就会发生这种情况,因为合并会看到两边以相同方式修改了文件,两个提交会合并在一起。

但如果你对任一分支中受影响的行进行额外的更改,那么两个更改会被叠加,而不是合并。如果你对受影响行的额外更改具有取消cherry-picked更改的效果,那么你甚至不会得到合并冲突来通知你发生了奇怪的事情。(在我们团队内部,我们称这为ABA问题,因为行以A开始,变为B,B被cherry-pick走了,然后在合并回master分支之前,行又变回A。)

master分支应用了一个更改,feature分支应用了该更改,然后feature分支撤销了该更改。从数学上讲,你执行了两个更改和一个撤销,所以净效果仍然是+1支持该更改。

好的,问题是我们想从feature分支部分合并回master分支。可惜没有部分合并这回事。

或者有吗?

下次,我们将展示如何执行部分合并。

额外内容:通常,合并两次产生的结果与合并一次相同,只是有更多的合并冲突(因为你必须在每次合并时解决一次冲突)。但在这种情况下,我们得到了不同的结果,而且两者都没有提出合并冲突。如果我们从feature分支到master分支执行两次合并,首先合并提交F2,然后再次合并提交F3,那么我们会有两个干净的合并,但结果会不同:

%%{init: { 'gitGraph': {'showBranches': true, 'showCommitLabel':true, 'mainBranchName': 'master'}} }%%
gitGraph
   commit id: "A (apple)"
   branch feature
   checkout feature
   commit id: "F1 (apple)"
   commit id: "F2 (berry)"
   commit id: "F3 (apple)"
   checkout master
   commit id: "M1 (apple)"
   commit id: "M2 (berry)" type: REVERSE
   commit id: "M3 (berry)"
   merge feature id: "M4.1 (berry)"
   merge feature id: "M4.2 (apple)"

这令人不安,因为这意味着改变你合并的频率策略可能导致不同的最终结果,而git不会发出任何警告。

更多额外内容:请注意,"撤销"不一定是实际的撤销。它可能只是碰巧类似于撤销。例如,假设你开始时有

char* predefined_items[4] = {
  "armoire",
  "bed",
  "credenza",
  "desk",
};

你决定你需要第五个项目,所以你添加第五个项目并增加数组大小:

char* predefined_items[5] = {
  "armoire",
  "bed",
  "credenza",
  "desk",
  "end table",
};

另一个分支cherry-pick了这个更改,因为它需要end table。同时,你意识到你不再需要bed,所以你删除它并将数组大小减少到四。

char* predefined_items[4] = {
  "armoire",
  "credenza",
  "desk",
  "end table",
};

当这两个更改合并时,结果将是

char* predefined_items[5] = {
  "armoire",
  "credenza",
  "desk",
  "end table",
};

注意,predefined_items数组的长度是五,尽管其中只有四个条目。

导航