了解冲突合并算法

Understanging Conflicts Merging Algorithm

我看到一个看起来一团糟的合并标记。为了给你这个情况让我们有这个:

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

现在进行合并(我使用 SourceTree 进行拉取)。 标记看起来像这样:

<<<<<<<<< HEAD
    try {
      doSomething();
    }
    catch(Exception e) {
      doSomethingElse();
    }
============================
private void methodB() {
    doOtherStuff();
>>>>>>>> 9832432984384398949873ab
}

因此,拉取提交所做的是完全删除 methodA 并添加 methodB。

但是您注意到有些行完全丢失了。

根据我对流程的了解,Git 正在尝试所谓的自动合并,如果失败并且检测到冲突,则完整合并由标有 '<<<* HEAD 的部分表示' + before + '====' + after + '>>>* CommitID' 并准备手动解决冲突。

那么为什么它会漏掉一些行。对我来说它看起来更像是一个错误。

我用的是Windows7,安装的git版本是2.6.2.windows.1。虽然最新版本是 2.9,但我想知道是否知道 git 版本有如此严重的合并问题?这不是我第一次遇到这样的事情......

在合并中,仅标记包含冲突的更改。

Rev A 的变化和Rev B 的不同变化,直接合并进来。只有Rev A 和Rev B 在同一地方的变化被标记为冲突。通知用户文件中存在冲突,需要解决。

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

您的担心是正确的:Git 对语言一无所知,其内置合并算法严格基于逐行比较。 您不必使用这种内置的合并算法,但大多数人都会这样做,因为 (a) 它大部分都能正常工作,并且 (b) 没有那么多替代方案。

请注意,这取决于您的合并策略-s参数);以下文字适用于默认的 recursive 策略。 resolve 策略与 recursive 非常相似; octopus 策略不仅仅适用于两次提交; ours 策略完全不同(与 -X ours 完全不同)。您还可以使用 .gitattributes 和 "merge drivers" 为特定文件 select 替代策略或算法。并且,none 适用于 Git 决定认为是 "binary" 的文件:对于这些,它甚至不会尝试合并。 (我不打算在这里介绍任何内容,只是默认 recursive 策略如何处理文件。)

git merge 的工作原理(使用默认 -s recursive 时)

  • 合并从两个提交开始:当前一个(也称为 "ours"、"local" 和 HEAD)和一些 "other" 一个(也称为 "theirs" 和 "remote")
  • Merge 找到这些提交之间的合并基础
    • 通常这只是另一个提交:在隐含分支的第一个点1 连接
    • 在某些特殊情况下(多个合并基础候选),Git必须发明一个"virtual merge base"(但我们将在这里忽略这些情况)
  • 合并 运行 两个差异:git diff base localgit diff base other
    • 这些已打开重命名检测
    • 您可以自己运行 这些相同的差异,看看合并后会看到什么

您可以将这两个差异视为 "what we did" 和 "what they did"。合并的目标合并"what we did"和"what they did"。差异是基于行的,来自最小编辑距离算法,2 并且实际上只是 Git 的 guess 关于我们做了什么,以及他们做了什么做了。

first diff(base-vs-local)的输出告诉Git哪些基本文件对应于哪些本地文件,即如何遵循来自当前提交回基地。 Git 然后可以使用基本名称来发现其他提交中的重命名或删除。在大多数情况下,我们可以忽略重命名和删除问题,以及新文件创建问题。请注意,Git 版本 2.9 默认为 所有 差异打开重命名检测,而不仅仅是合并差异。 (在早期的 Git 版本中,您可以通过将 diff.renames 配置为 true 来自行开启此功能;另请参阅 diff.renameLimitgit config 设置。)

如果一个文件在只有一侧(base-to-local,或base-to-other)发生了变化,Git 只会接受这些变化。 Git 仅当文件在 两边都发生更改时才需要进行三向合并。

要执行三向合并,Git 本质上要遍历两个差异(base-to-local 和 base-to-other),一个"diff hunk"一次,比较变化的区域。如果每个 hunk 影响原始基础文件的 不同部分 ,则 Git 只接受那个 hunk。如果某些 hunk(s) 影响基本文件的 same 部分,Git 会尝试获取一份副本,无论该更改是什么。

例如,如果本地更改为 "add a close brace line" 而远程更改为 "add (the same place, same indentation) close brace line",则 Git 将只复制一个右括号。如果两者都说 "delete a close brace line" Git 只会删除该行一次。

只有当两个差异 冲突 时——例如,一个说 "add a close brace line indented 12 spaces" 而另一个说 "add a close brace line indented 11 spaces" 将 Git 声明冲突。默认情况下,Git 将冲突写入文件,显示两组更改——并且,如果将 merge.conflictstyle 设置为 diff3 也会 显示来自文件 .

合并基础版本的代码

任何不冲突的 diff hunks,Git 适用。如果存在冲突,Git 通常会使文件处于 "conflicted merge" 状态。但是,两个 -X 参数(-X ours-X theirs)对此进行了修改:with -X ours Git 在冲突中选择了 "our" diff hunk,并且将更改放入,忽略 "their" 更改。使用 -X theirs Git 选择 "their" diff hunk 并将更改放入,忽略 "our" 更改。这两个 -X 参数保证 Git 最终不会声明冲突。

如果 Git 能够自行解决此文件的所有问题,它会这样做:您获得基本文件,加上您的本地更改,以及他们在工作树和中的其他更改index/staging-area.

如果 Git 无法自行解决所有问题,它会使用三个特殊的非零索引槽将文件的基本版本、其他版本和本地版本放入 index/staging-area。工作树版本总是 "what Git was able to resolve, plus the conflict markers as directed by various configurable items."

每个索引条目有四个槽

foo.java 等文件通常在槽零中暂存。这意味着它现在已准备好进行新的提交。根据定义,其他三个槽是空的,因为有一个槽零条目。

在冲突合并期间,槽 0 留空,槽 1-3 用于保存合并基础版本,"local" 或 --ours 版本,另一个或 --theirs 版本。工作树包含正在进行的合并。

您可以使用 git checkout 提取这些版本中的任何一个,或使用 git checkout -m 重新创建合并冲突。所有成功的 git checkout 命令都会更新文件的工作树版本。

一些git checkout命令不影响各个插槽。一些 git checkout 命令写入槽 0,清除槽 1-3 中的条目,以便文件准备好提交。 (要知道哪些是做什么的,你只需要记住它们。我把它们记错了很长一段时间。)

您不能运行 git commit,直到所有未合并的插槽都被清除。您可以使用 git ls-files --unmerged 查看未合并的插槽,或使用 git status 以获得更人性化的版本。 (提示:使用 git status。经常使用!)

合并成功不代表代码好

即使git merge成功自动合并所有内容,也不代表结果是正确的!当然,当它因冲突而停止时,这也意味着Git 无法自动合并所有内容,而不是它自己自动合并的内容是正确的。我喜欢将 merge.conflictstyle 设置为 diff3,这样我就可以看到 Git 在 base 替换 "base" 之前的想法合并两侧的代码。经常发生冲突是因为 diff 选择了错误的基数——例如一些匹配的大括号 and/or 空行——而不是因为必须存在实际冲突。

使用 "patience" diff 可以在碱基选择不当的情况下保持不变,至少在理论上是这样。我自己没有试验过这个。 The new "compaction heuristic" in Git 2.9 很有前途,但我也没有试验过。

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

您还可以使用 --no-commit 抑制合并的自动提交。在实践中,我发现很少需要这样做:大多数合并大多只是工作,并且快速观察 git show -m and/or "it compiles and passes unit tests" 会发现问题。但是,在冲突或 --no-commit 合并期间,一个简单的 git diff 会给你一个组合差异(与 git show 没有 -m 得到的相同类型,在你提交合并后),这可能会有帮助,但也可能更令人困惑。您可以 运行 更具体的 git diff 命令 and/or 检查三个(基本、本地、其他)插槽条目,如 .

看到什么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 之间的合并基础—"merge base" 是可交换的,所以它们应该与其他方式相同,比如 7+2和 2+7 都是 9——但是这次将合并基础与提交 foo.1

进行比较

(对于其他命令——不是 git diff 的东西——三点语法产生一个 对称差异 :在任一分支上的所有提交的集合, 但不是在两个分支上。对于具有单个合并基础提交的分支,这是 "every commit after the merge base, on each branch":换句话说,两个分支的并集,不包括合并基础本身和任何更早的提交。对于具有多个合并的分支基础,这减去 all 合并基础。对于 git diff 我们假设只有一个合并基础,而不是减去它和它的祖先,我们将它用作diff 的左侧或 "before" 侧。)


1在 Git 中,分支 name 标识一个特定的提交,即 tip 的分支。事实上,分支实际上是这样工作的:一个分支名称命名一个特定的提交,并且为了向该分支添加另一个提交——branch 这里的意思是 的链commits—Git 创建一个新的提交,其父节点是当前的分支提示,然后将分支名称指向新的提交。单词 "branch" 可以指代分支名称,也可以指代整个提交链;我们应该根据上下文找出哪一个。

在任何时候,我们都可以命名一个特定的提交,并将其视为一个分支,方法是获取该提交及其所有祖先:其父项,其父项的父项,以及很快。当我们命中一个合并提交时——一个有两个或更多父项的提交——在这个过程中,我们采用 all 父项提交,以及他们父项的父项,等等。

2这个算法其实是select可以的。默认 myers 基于 Eugene Myers 的算法,但 Git 有一些其他选项。