Git:关于合并算法、冲突格式以及与合并工具的相互作用的困惑

Posted

技术标签:

【中文标题】Git:关于合并算法、冲突格式以及与合并工具的相互作用的困惑【英文标题】:Git: Confusion about merge algorithm, conflict format, and interplay with mergetools 【发布时间】:2017-10-28 00:01:22 【问题描述】:

不知道具体情况,但据我了解合并和冲突解决的过程是这样的(假设仓库只有一个文件,在两个分支中修改):

    用户发出git merge 命令。 Git 应用了一些 git-specific 算法 来自动合并两个修改过的文件。为此,它会创建文件的 BASE、LOCAL、OTHER 和 BACKUP 版本。 然后它将合并结果写入原始跟踪文件(称为 MERGED)。 假设存在冲突。 Git 使用某种格式来表示冲突(<<<<<<<|||||||=======>>>>>>> 标记)。然后它将其状态设置为“正在合并”或类似的状态。 如果用户随后发出 git mergetool ... 配置的外部合并工具打开,参数指向 BASE、LOCAL、OTHER,当然还有 MERGED。

有几点我很困惑:

该工具是否总是能理解 Git 的冲突格式?是否标准化? diff3 选项呢?外部工具也普遍理解吗? 该工具是否会应用自己的(可能不同)合并算法并完全丢弃 Git 的输出? 当 Git 需要执行 递归合并(由于多个合并基础)并且中间合并会产生冲突时,它是否会将内部冲突标记视为纯文本,就像任何其他非冲突文本一样?还是冲突格式本身是递归的?

我找不到任何能真正说明整个故事的解释。

【问题讨论】:

【参考方案1】:

完整的答案很复杂。 Edward Thomson 涵盖了大部分内容。这里有更多的细节。

不过,让我们开始吧:git mergetool 运行——我应该说,运行之后git merge 的所有其余部分都完成了。在git merge 完成之前,您的合并工具甚至不会进入图片(并且由于冲突而失败)。这极大地改变了您对这些问题的看法。

(递归和解析)合并的工作原理

用户发出git merge 命令。

到目前为止一切顺利。

Git 应用了一些 git-specific 算法来自动合并两个修改过的文件。

哎呀,不,我们已经出轨了,火车可能要冲下悬崖了。 :-)

此时的第一步是选择一个合并策略。让我们选择默认的 (-s recursive) 策略。如果我们选择其他策略,下一步可能会有所不同(-s ours 完全不同,-s octopus 则有些不同,但无论如何现在这些都不是有趣的)。

下一步是找到所有的合并基。运气好的话,只有一个。我们稍后会回到递归问题。不过,可能有 no 合并基础。旧版本的 Git 使用空树作为假合并基础。较新的版本(2.9 或更高版本)要求您在此处添加 --allow-unrelated-histories(然后以相同的方式继续)。使用空树时,会在两个非基础提交中添加每个文件。

如果有一个合并基,它可能与任何一个分支提示相同。如果是这样,则没有要执行的合并。不过,这里也有两个子案例。可能没有要合并的内容,因为合并基础是另一个提交,而另一个提交在当前提交的“后面”(是其祖先)。在这种情况下,Git 总是什么都不做。或者,另一个提交可能在当前提交之前(的后代)。在这种情况下,Git 通常会执行 fast-forward 操作,除非您指定 --no-ff。在这两种情况下(快进或--no-ff),都不会发生实际的合并。相反,提取了更进一步的提交。它要么成为当前提交(快进合并:无论您在哪个分支上,它现在都指向更远的提交),或者 Git 使用该提交的树进行新的提交,并且新的提交成为当前提交。

真正的合并:将一个合并基础与两个提交合并

我们现在处于一个合并基础提交B和两个提交L(本地或左侧,--ours)和R(远程或右侧,--theirs)。现在,两个正常的(-s recursive-s resolve)策略在启用重命名检测的情况下执行一对git diff --name-status 操作,查看B-to- 中是否有文件L 改即改名,如果有文件在B-to-R 改即改名。这还可以查明 LR 中是否有新添加的文件,以及 LR 中是否删除了文件。所有这些信息被组合以产生 文件标识,以便 Git 知道要组合哪些更改集。这里可能存在冲突:一个文件,其路径在基础中是 PB,但现在都是 PLPR,例如存在重命名/重命名冲突。

此时的任何冲突——我称之为高级冲突——都在文件级合并范围之外:它们会让 Git 以冲突,不管发生什么其他事情。但与此同时,正如我上面所说的,我们最终得到了“已识别文件”,但没有完全定义它。粗略地说,这意味着仅仅因为某些路径 P 发生了变化,并不意味着它是一个 new 文件。如果在基本提交 B 中有一个文件 base,现在它在 L 中称为 renamed,但在 R 中仍称为 base em>,Git 将使用新名称,但将 B:baseL:renamedB:baseR 进行比较: base 当 Git 去合并文件级别的更改时。

换句话说,我们在这个阶段计算的文件标识告诉我们(和Git)B中的哪些文件与L中的哪些文件匹配> 和/或 R。此标识不一定是路径名。这只是通常所有三个路径都匹配的情况。

您可以在第一个diff 阶段插入一些小调整:

重新规范化 (merge.renormalize):您可以让 Git 应用来自 .gitattributes 和/或 core.eol 设置的文本转换。 .gitattributes 设置包括 ident 过滤器以及任何涂抹和清洁过滤器(尽管此处仅适用涂抹方向)。

(我认为 Git 很早就这样做了,因为它可能会影响重命名检测。不过,我还没有实际测试过,我只是查看了 Git 源代码,似乎没有在这个阶段。所以也许merge.renormalize 在这里不适用,即使涂抹过滤器可以彻底重写文件。例如,考虑一个加密和解密的过滤器对。这可能是一个错误,虽然很小。幸运的是,EOL 转换对相似性指数值完全没有影响。)

您可以设置 Git 何时考虑重命名文件的相似性索引,或完全禁用重命名检测。这是-X find-renames=<em>n</em> 扩展策略选项,以前称为重命名阈值。与git diff -M--find-renames 选项相同。

Git 目前无法像 git diff -B 那样设置“中断”阈值。这也会影响文件身份计算,但如果你不能设置它,那也没关系。 (您可能应该能够设置它:另一个小 buglet。)

合并单个文件

现在我们已经确定了我们的文件并确定了哪些文件与其他文件匹配,我们最终进入文件合并级别。请注意,如果您使用的是内置的合并驱动程序,那么剩余的可设置差异选项将开始变得重要。

让我再次引用这段话,因为它是相关的:

Git 应用了一些 ... 算法来自动合并两个修改过的文件。为此,它会创建文件的 BASE、LOCAL、OTHER 和 BACKUP 版本。

此时三个(不是四个)文件,但 Git 没有创建其中任何一个。它们是来自 BLR 的文件。这三个文件作为 blob 对象 存在于存储库中。 (如果 Git 正在重新规范化文件,它确实此时必须将重新规范化的文件创建为 blob 对象,但随后它们存在于存储库中,Git 只是假装它们在原始提交中。 )

下一步非常关键,这就是索引出现的地方。这三个 blob 对象的哈希 ID 是 HB、HL 和 HR。 Git 准备将这三个哈希分别放入索引中的插槽 1、2 和 3,但现在使用the git read-tree documentation under the 3-Way Merge section 中描述的规则:

如果所有三个哈希值都相等,则文件已经合并并且没有任何反应:哈希值进入零槽。即使只有第二个和第三个哈希值相等,文件 仍然 已经合并:LR 使 same 相对于 B 改变。新的哈希进入零槽,文件合并完成。 如果 HB = HL 且 HB ≠ HR,则右侧(远程/ other/--theirs) 文件应该是结果。此哈希进入插槽 0,文件合并完成。 如果 HB ≠ HL 且 HB = HR,则左侧(局部/ --ours) 文件应该是结果。此哈希进入插槽 0,文件合并完成。 这仅留下所有三个哈希值不同的情况。现在文件确实需要合并。 Git 将所有三个哈希值放入三个索引槽中。

此时可以应用一些特殊情况,所有这些都与更高级别的冲突有关。对于某些路径名,可能会有一个或两个索引槽为空,因为索引是经过精心管理的,以使其与工作树保持同步(以便它可以发挥其作为缓存 em> 大大加快了 Git 的速度)。但原则上,特别是当我们关注合并驱动程序时,我们可以将其视为“所有三个插槽”——在重命名文件的情况下,它们可能只是分布在多个名称中的三个插槽。

调用合并驱动程序 (.gitattributes)

此时,我们需要执行一个实际的文件级合并。我们有三个 input 文件。它们的实际内容作为 blob 对象存储在存储库中。它们的 hash IDs 存储在索引中的插槽 1 到 3 中(通常是单个索引条目,但在重命名的情况下,可能使用多个索引条目)。我们现在可以:

使用 git 的内置文件合并(也可作为外部命令使用,git merge-file)。

内置文件合并直接从索引工作(尽管如果我们想通过git merge-file 运行它,我们必须将 blob 提取到文件系统中)。它提取文件,合并它们,并且根据扩展策略选项-X ours-X theirs 可选地写入冲突标记。它将最终结果放入工作树中,无论 Git 选择什么路径名作为最终路径名,然后就完成了。

使用合并驱动程序(通过.gitattributes)。合并驱动程序是run with arguments。但是,这些参数是通过让 Git 将三个 blob 对象提取到三个临时文件来构造的。

参数扩展自我们输入的 %O%A%B%L%P。这些参数字母与我们一直使用的不太匹配:%Obase 文件的名称,%A 是左侧/本地/--ours 版本的名称,%B是右侧/其他/远程/--theirs版本的名称,%Lconflict-marker-size设置(默认7),%P是Git要用来保存的路径工作树中的最终结果。

请注意,%O%A%B 都是 Git 创建的 临时 文件的名称(用于保存 blob 内容)。它们都不匹配%P。 Git 希望合并驱动程序将合并结果留在路径 %A 中(然后 Git 将自行重命名为 %P)。

此时,在所有情况下,合并的文件都会进入工作树。如果合并顺利,索引中编号较高的插槽将被清除:Git 实际上在工作树文件上运行git add,将数据作为 blob 对象写入存储库,并获取哈希 ID进入零槽。如果合并因冲突而失败,则编号较高的插槽保持原位;零槽为空。

所有这一切的最终结果是工作树保存合并的文件,可能带有冲突标记,而索引保存合并的结果,可能带有应该解决的冲突。

使用git mergetool

这与合并驱动程序的工作方式大致相同。但是,除了仅在 之后运行合并,其结果在索引和工作树中,主要区别是:

git mergetool 将制作额外的文件副本(.orig 文件)。 它确切知道如何运行每个已知工具,即传递哪些参数以使该工具执行有用的操作。例如,没有等效于驱动程序 %O 占位符。 它可以对所有某个目录中尚未合并的文件运行命令。

事实上,git mergetool 是一个很大的 shell 脚本:它使用git ls-files -u 查找未合并的索引条目,并使用git checkout-index 从索引中提取每个阶段。它甚至有更高级别冲突的特殊情况,例如添加/添加或重命名/删除。

每个已知工具都有一个额外的驱动程序外壳脚本片段:查看

$ ls $(git --exec-path)/mergetools

查看所有单独的工具驱动程序。这些都传递了一个标志$base_present,用于处理添加/添加冲突。 (它们是有源的,即使用. "$MERGE_TOOLS_DIR/$tool" 运行,因此它们可以覆盖脚本中定义的shell 函数。)

对于 unknown 工具,您使用 shell 的变量名称 $BASE$LOCAL$REMOTE 来了解脚本将提取的三个文件放在哪里索引,然后您编写你的结果到$MERGED(实际上是文件的工作树名称)。该脚本执行此操作:

setup_user_tool () 
        merge_tool_cmd=$(get_merge_tool_cmd "$tool")
        test -n "$merge_tool_cmd" || return 1

        diff_cmd () 
                ( eval $merge_tool_cmd )
        

        merge_cmd () 
                ( eval $merge_tool_cmd )
        

即,evals 您在子 shell 中的工具命令,这样您就不能像已知工具那样覆盖事物。

递归合并

当 Git 需要执行递归合并 ...

在这一点上,这个问题的大部分内容都没有实际意义。合并工具根本看不到这种情况,因为git mergetool Git 本身完成递归合并并将结果留在索引和工作树中之后被调用。但是,合并 驱动程序 在这里确实有发言权。

-s recursive 合并策略 合并合并基础以进行新的“虚拟提交”时,它会调用另一个git merge——嗯,更准确地说,只是递归地调用自己——在合并基本提交(但见下文)。这个内部git merge 知道它正在被递归调用,因此当它要应用.gitattributes 合并驱动程序时,它会检查那里的recursive = 设置。这确定是再次使用合并驱动程序,还是使用其他合并驱动程序进行内部合并。对于内置的合并驱动,Git 关闭了扩展策略选项,即-X ours-X theirs 都不起作用。

当内部合并完成时,其结果(如果这不是内部递归合并,所有将留在工作树中的文件)实际上会保存为 真正的 提交。即使存在未解决的冲突也是如此。这些未解决的冲突甚至可能包含冲突标记。尽管如此,这是新的“虚拟合并基础”提交,而且是真正的提交;它只是没有外部名称,您可以通过它找到它的提交哈希。

如果在此特定级别有三个或更多合并基础,而不仅仅是两个合并基础,则此新的虚拟合并基础现在将与下一个剩余合并基础迭代合并。从逻辑上讲,Git 可以在这里使用分而治之的策略:如果最初有 32 个合并基,它可以一次合并两个以产生 16 个提交,一次合并这两个以产生 8 个,依此类推。但是,除了进行 ceil(log2(N)) 合并而不是 N-1 合并之外,还不清楚这是否会带来很多好处:N > 1 已经非常罕见了。

【讨论】:

当然,+1。在索引及其舞台上,您还写了***.com/a/29707465/6309 @VonC:是的,但另一个答案是关于正常的、非合并的索引条目。 不用说,这是一个很好的答案!正是我正在寻找的详细程度。所以非常感谢你的努力!我还有一些悬而未决的问题:冲突标记的 格式 是否以某种方式标准化?并且:外部合并工具是否使用合并文件中已经生成的(由 Git)标记?据我了解,他们仅使用$MERGED 作为写入目标。并且只是为了确认:内部合并冲突标记因此被视为“正常”文件内容,对吧? 外部合并工具是否会使用合并文件中已经生成的(由 Git 生成的)标记?我对此表示怀疑,尽管这是可能的(因为每个工具都有自己的脚本,并且可以为所欲为)。 冲突标记的格式是否以某种方式标准化? Git 本身只写一种,但 长度 会有所不同,并且它具有 mergediff3 冲突样式设置. 因此,内部合并冲突标记被视为“正常”文件内容,对吗? 它们成为作为下一个输入的新提交的一部分,所以,是的;但我怀疑他们彼此打得很好,所以这个[续] ...所以这似乎是未来改进的候选者,如果未来虚拟基地经常发生冲突(我认为这可能)。跨度> 【参考方案2】:

合并工具不会解析工作目录中带有冲突标记的文件。他们读取 git mergetool 从索引中创建的祖先、我们和他们的文件,并为他们放置在磁盘上。

他们将使用自己的逻辑来产生合并结果,并将覆盖 Git 创建的文件。

【讨论】:

他们如何阅读索引?然后他们需要了解 Git 内部结构或在后台发出 Git 命令。他们甚至知道 Git吗?为什么 Git 然后会在磁盘上创建所有这些文件版本(如 LOCAL)? 不,他们对 Git 一无所知。 git mergetool 命令为它们创建所有这些文件版本。

以上是关于Git:关于合并算法、冲突格式以及与合并工具的相互作用的困惑的主要内容,如果未能解决你的问题,请参考以下文章

如果存在合并冲突,如何让 git 自动打开合并工具?

git merge原理(递归三路合并算法)

成功自动解决合并冲突后,使用 KDiff 审查合并操作

Git 合并工具性能

用于解决 git 中的合并冲突的控制台 UI 工具......就像 vimdiff 但“更容易”

如何使用我们的 Git 工作流解决合并策略冲突