理解冲突合并算法

3

我看到了一个合并标记,它看起来非常混乱。为了让你更好地理解情况,请看下面的示例:

public void methodA() {
    prepare();
    try {
      doSomething();
    }
    catch(Exception e) {
      doSomethingElse();
    }
}

现在进行一次合并操作(我使用SourceTree进行拉取)。 标记看起来像这样:
<<<<<<<<< HEAD
    try {
      doSomething();
    }
    catch(Exception e) {
      doSomethingElse();
    }
============================
private void methodB() {
    doOtherStuff();
>>>>>>>> 9832432984384398949873ab
}

所以,被撤销的提交删除了方法A,并添加了代替它的方法B。
但是你会发现有一些行完全丢失了。
据我所了解的过程,Git正在尝试所谓的自动合并,如果失败并检测到冲突,则整个合并由标记为“<<<* HEAD”的部分+ before +“====”+ after +' >>>* CommitID'组成,并准备进行手动冲突解决。
那么为什么它会省略一些行呢?在我看来,这更像是一个 bug。
我使用的是 Windows7,安装的git版本为2.6.2.windows.1。虽然最新版本是2.9,但我想知道是否有关于一个git版本具有这种规模的合并问题的任何信息?这不是我第一次遇到这样的问题...。

你有查看过这个三向合并的冲突吗?它有助于弄清楚发生了什么。 - everton
你需要知道标记的作用。最好阅读此链接:https://help.github.com/articles/resolving-a-merge-conflict-from-the-command-line/ - pratZ
@pratZ 我知道标记的作用。我的观点是,当文件中仍存在无法解决的冲突时,git进行了合并并从唯一的本地副本中删除了代码行,而没有询问我。 - Martin Kersten
@EvertonAgner 发生的是三向合并。这就是我的担忧。看起来 git 使用的算法似乎决定只有某些方法的行进入手动解决模式,但它剪切了 git 认为可以自动解决的行。由于 Git 似乎不知道 Java 语言,我更愿意认为 git 在存在单个冲突时将文件保持不变的方式工作。 - Martin Kersten
我的意思是从三方合并的角度来看待冲突:在任何更改之前,代码的样子是怎样的,在您的更改和他们的更改后它又是什么样子,以此来弄清楚为什么Git会以这种方式放置标记。Git不知道任何编程语言,它只跟踪文本文件中文本行的更改。 - everton
2个回答

13
您的担忧是正确的:Git对语言一无所知,其内置合并算法严格基于逐行比较。但您不必使用这个内置的合并算法,大多数人使用它是因为它通常可以正常工作,并且没有太多的替代方案。
请注意,这取决于您的合并策略(-s参数);下面的文本是针对默认的recursive策略。 resolve策略与recursive非常相似;octopus策略适用于不止两个提交;ours策略则完全不同(与-X ours也不一样)。您还可以使用.gitattributes和“合并驱动程序”为特定文件选择替代策略或算法。对于Git已经决定信任的“二进制”文件,所有这些都不适用:对于这些文件,它甚至不尝试合并。(我不会在这里涵盖任何内容,只介绍默认的recursive策略如何处理文件。) < h3 >git merge如何工作(使用默认的-s recursive时)
  • 合并以两个提交开始:当前提交(也称为“ours”,“local”和HEAD),以及另一个提交(也称为“theirs”和“remote”)
  • 合并查找这些提交之间的合并基础
    • 通常只有一个其他提交:在隐含分支1连接的第一个点处的提交
    • 在某些特殊情况下(多个合并基础候选项),Git 必须发明一个“虚拟合并基础”(但我们将在此忽略这些情况)
  • 合并运行两个差异:git diff base localgit diff base other
    • 这些都打开了重命名检测
    • 您可以自己运行这些相同的差异,以查看合并将看到什么
你可以把这两个差异看作是“我们做了什么”和“他们做了什么”。合并的目标是将“我们做的”和“他们做的”结合起来。这些差异是基于行的,来自最小编辑距离算法,实际上只是Git关于我们做了什么和他们做了什么的猜测。第一个差异(基础与本地)的输出告诉Git哪些基础文件对应哪些本地文件,即如何从当前提交回到基础。然后Git可以使用基础名称来发现其他提交中的重命名或删除。在大多数情况下,我们可以忽略重命名和删除问题,以及新文件创建问题。请注意,Git版本2.9默认为所有差异打开重命名检测功能,而不仅仅是合并差异。 (在早期的Git版本中,您可以通过配置diff.renames为true来自己打开此功能;还可以查看git config设置diff.renameLimit。)
如果文件仅在一个侧面(基地到本地或基地到其他)更改,则Git只需采取这些更改。当文件在两个侧面上都更改时,Git才必须执行三方合并。
要执行三方合并,Git基本上会遍历两个差异(基地到本地和基地到其他),一次“差异块”比较一个,比较已更改的区域。如果每个块影响原始基础文件的不同部分,则Git只需要采取该块。如果某些块影响基础文件的相同部分,则Git尝试采取该更改的一个副本。
例如,如果本地更改说“添加关闭括号行”,而远程更改说“添加(相同位置,相同缩进)关闭括号行”,则Git将只采取一个关闭括号的副本。如果两者都说“删除关闭括号行”,Git将只删除该行一次。
只有当两个差异冲突时 - 例如,一个说“添加一个缩进12个空格的关闭括号行”,另一个说“添加一个缩进11个空格的关闭括号行” - Git才会宣布冲突。默认情况下,Git将冲突写入文件,显示两组更改 - 如果您将merge.conflictstyle设置为diff3,还会显示来自文件合并基础版本的代码 任何非冲突的差异块都会被应用。如果存在冲突,则Git通常会将文件保留在“冲突合并”状态中。但是,两个-X参数(-X ours-X theirs)会修改此行为:使用-X ours,Git选择在冲突中“我们”的差异块,并将其更改放入其中,忽略“他们”的更改。使用-X theirs,Git选择“他们”的差异块,并将其更改放入其中,忽略“我们”的更改。这两个-X参数确保Git最终不会声明冲突。
如果Git能够自己解决文件的所有内容,那么它会在工作树和索引/暂存区中返回基础文件、本地更改和其他更改。如果Git无法自行解决所有内容,则会将文件的基础版本、其他版本和本地版本放入索引/暂存区,并使用三个特殊的非零索引槽。工作树版本始终是“Git能够解决的内容加上各种可配置项指定的冲突标记”。每个索引条目都有四个插槽,通常情况下,像foo.java这样的文件被暂存在零号插槽中,这意味着它现在可以进入一个新的提交。另外三个插槽是空的,因为已经有了零号插槽条目。在冲突合并期间,零号插槽保持为空,插槽1-3用于保存合并基础版本、本地或--ours版本和其他或--theirs版本。工作树中保存正在进行的合并。
你可以使用git checkout提取其中任何一个版本,或者使用git checkout -m重新创建合并冲突。所有成功的git checkout命令都会更新文件的工作区版本。
一些git checkout命令会保留不同的插槽。一些git checkout命令写入slot 0,擦除slots 1-3中的条目,以便文件准备提交。(要知道哪些做什么,你只需要记住它们。我曾经很长一段时间在脑海中弄错了。)
在清除所有未合并插槽之前,无法运行git commit。你可以使用git ls-files --unmerged查看未合并插槽,或者使用更人性化的版本git status。(提示:经常使用git status.)
成功合并并不意味着良好的代码。

即使 git merge 成功自动合并了所有内容,这并不意味着结果是正确的! 当然,当它停止并出现冲突时,这也意味着 Git 无法自动合并所有内容,而不是它自己自动合并的内容是正确的。我喜欢将 merge.conflictstyle 设置为 diff3,以便我可以看到 Git 认为的基础是什么,在用两个合并方案替换"基础"代码之前。通常,冲突发生是因为 diff 选择了错误的基础,比如一些匹配的大括号和/或空行,而不是因为必须有一个实际的冲突。

使用“patience”diff 理论上能够帮助解决糟糕的基础选择。但我自己没有尝试过。Git 2.9 中的新“紧缩启发式算法” 很有前途,但我也没有尝试过。

您必须始终检查和/或测试合并的结果。 如果合并已经提交,您可以编辑文件,构建和测试,git add更正版本,并使用git commit --amend将以前(不正确的)合并提交推到一边,并放入具有相同父项的不同提交。 (git commit --amend中的--amend部分是虚假广告。它不会更改当前提交本身,因为它不能; 相反,它创建一个新的提交,其父ID与当前提交相同,而不是使用当前提交的ID作为新提交的父项的常规方法。)

您也可以使用--no-commit来取消合并的自动提交。实际上,我发现很少需要这样做:大多数合并都能正常工作,通过快速查看git show -m和/或“它编译并通过单元测试”就能发现问题。然而,在冲突或--no-commit合并期间,简单的git diff将为您提供一个组合差异(与在没有-m的情况下使用git show提交合并后获得的相同类型),这可能会有所帮助,也可能更加混乱。您可以运行更具体的git diff命令和/或检查三个(基本、本地、其他)插槽条目,如Gregg在评论中指出

查看 Git 将要查看的内容

除了将diff3用作您的merge.conflictstyle之外,您还可以查看git merge将看到的差异。您只需要运行两个git diff命令-与git merge运行的相同两个命令。
要执行这些操作,您必须找到或至少告诉git diff找到合并基础。 您可以使用git merge-base,它会查找(或所有)合并基础并将其打印出来:
$ git merge-base --all HEAD foo
4fb3b9e0570d2fb875a24a037e39bdb2df6c1114

这段话表示当前分支和分支foo之间的合并基础是提交4fb3b9e...(且只有一个这样的合并基础)。我可以运行git diff 4fb3b9e HEADgit diff 4fb3b9e foo。但是,如果我能够假设只有一个合并基础,那么就有一种更简单的方法:
$ git diff foo...HEAD   # note: three dots

这段话告诉我们,git diff(仅限于git diff)需要找到fooHEAD之间的合并基础,然后将该提交-合并基础与提交HEAD进行比较。
$ git diff HEAD...foo   # again, three dots

这个命令的作用是找到HEADfoo之间的合并基础,"合并基础"是可交换的,所以这应该与另一种方式相同,就像7+2和2+7都是9一样,但这次是将合并基础与提交的foo进行差异比较。1 (对于其他命令 - 不是git diff的东西 - 三个点的语法产生一个对称差异:所有在任一分支上但不在两个分支上的提交的集合。对于具有单个合并基础提交的分支,这是“合并基础之后的每个提交,在每个分支上”:换句话说,是两个分支的并集,不包括合并基础本身和任何早期提交。对于具有多个合并基础的分支,这会减去所有合并基础。对于git diff,我们只假设有一个合并基础,而不是将其及其祖先减去,我们将其用作差异的左侧或“before”部分。)

1在Git中,分支名字标识了一个特定的提交,即分支的tip。实际上,这就是分支的工作原理:分支名称命名了一个特定的提交,并且为了将另一个提交添加到该分支 - 这里的“分支”指的是提交链 - Git会创建一个新的提交,其父提交是当前分支tip,然后将分支名称指向新提交。单词“分支”可以指分支名称或整个提交链; 我们应该通过上下文来确定具体指哪一个。

在任何时候,我们都可以命名一个特定的提交,并将其视为分支,方法是采用该提交及其所有祖先:其父级,其父级的父级等。在此过程中,当我们遇到合并提交 - 具有两个或多个父级的提交 - 我们将采取所有父提交及其父级的父级等。

2这个算法实际上是可选择的。默认的myers是基于Eugene Myers提出的算法,但Git还有其他几个选项。


那是一个非常好的回答,Torek!是否有任何开关可以让我在至少有一个冲突的情况下,使git不自动合并任何行? - Martin Kersten
没有这样的开关。您可以编写一个自定义合并驱动程序来执行此操作(通过调用“git merge-file”命令并检查冲突,然后检出基本版本或 “--ours” 版本(如果有)),然后将其定义为用于所有文件的合并。 或者,可能更实际的是,您可以运行常规的 “git merge”,如果有冲突,只需简单地git checkout --ours每个有冲突的文件,这将使其处于冲突状态。 如果要获取路径的基本版本,稍微麻烦一些:对于路径 P,您必须请求:1:P(请参阅 gitrevisions 文档)。 - torek
我查看了如何提供自定义合并策略,它归结为提供一个单一的应用程序名称,该名称由git使用定义良好的参数列表执行。所以感谢所有的帮助。看起来我的问题得到了解决。 - Martin Kersten

2

在合并过程中,只有包含冲突的更改才会被标记。

Rev A中的更改和Rev B中的不同更改直接合并。只有在Rev A和Rev B相同位置的更改被标记为冲突。用户将收到通知,文件中存在冲突需要解决。

当您解决冲突时,合并后的文件已经包含了Rev A和Rev B的独立更改,并且包含了冲突部分的冲突标记。


这是我的担忧。由于Git对Java一无所知,如果在单行中存在无法自动解决的冲突,它将如何知道要删除什么和不删除什么。十多年来,我记得Git一直是这样做的:要么一切都可以自动解决,要么就必须手动解决所有问题。关于这个问题有什么变化吗? - Martin Kersten
这是来自合并文档的内容。它似乎表明冲突是以一个 hunk 为单位处理的。在合并过程中,工作树文件将被更新以反映合并的结果。在对共同祖先版本进行更改时,其中非重叠部分(也就是说,您更改了文件的某个区域,而另一方则保持该区域不变,或者反之)会直接并入最终结果。然而,当双方都对同一区域进行更改时,Git 无法随意选择一方而不选择另一方,并要求您通过保留双方对该区域所做的更改来解决它。 - Gregg
标记为冲突的文件有四个相关版本可供使用。部分合并和标记冲突的文件在工作树中。要完整查看更改,请使用“git show”进行三方差异比较,其中“:1:filename”显示共同祖先,“:2:filename”显示未合并,“:3:filename”显示其他。 - Gregg
Greeg,我接受了torek的答案,因为它更全面。你也基本上是对的,所以你也得到了一个赞。感谢你的帮助! - Martin Kersten

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