Git - 如何强制手动合并,即使没有冲突

10
这是多年来经常被问到的一个问题,我找到了许多答案,特别是这个:Git - 如何在所选文件上强制合并冲突和手动合并 (@Dan Moulding)
该页面包含详细的设置合并驱动程序的指南,该驱动程序将始终返回失败,从而使手动合并成为可能。我尝试调整该解决方案以适用于Windows:
  1. 我将以下内容添加到%homepath%\.gitconfig中:

    [merge "verify"] name = merge and verify driver driver = %homepath%\\merge-and-verify-driver.bat %A %O %B

  2. 我将驱动程序更改为:

    cmd /K "echo Working > merge.log & git merge-file %1% %2% %3% & exit 1"

    (添加了echo Working > merge.log以检查是否调用了驱动程序)。

  3. 并且,在存储库的根目录下,创建了一个名为.gitattributes的文件,并添加了以下行:

    *.txt merge=verify

不幸的是,它不起作用。我尝试合并一个文件feature.txt,但非常遗憾,合并成功完成。似乎根本没有调用驱动程序,因为未创建merge.log文件。
我做错了什么吗?欢迎提供任何解决强制手动合并问题的解决方案。

顺便提一下,如果您无法使合并驱动程序正常工作,您始终可以通过 git commit --amend 修改合并提交,在进行所需更改后。虽然不完美,但至少可以为您提供一个即时的解决方法,直到您解决脚本混乱问题。 - Tim Biegeleisen
谢谢你,@Tim。不幸的是,它并不能满足我们的需求。我想能够启动一个三方合并,即使 Git 认为它不需要。我正在努力说服 ClearCase 用户转向 Git。在 ClearCase 中,只需要在开始合并之前勾选一个复选框,用户会惊讶地发现这样的选项不再可用。 - Jennifer Philips
你的同事们认为他们需要在合并时选择要应用哪些更改,这是真的吗?这种操作并不好,因为Git自己的簿记依赖于所有更改一次性合并的事实,你不能合并一些更改并将其余部分保留到以后。基本上,这是另一种恶意合并的情况,它会撤销一些更改。在某些情况下可能有意义,但如果使用不当,会导致“我的编辑去哪了”的问题。 - max630
2个回答

8
这个问题有两个部分。相对容易的一部分是编写自定义合并驱动程序,就像你在步骤1和2中所做的那样。困难的部分是如果Git认为不必要,它实际上不会运行自定义驱动程序。这就是你在步骤3中观察到的情况。
那么,什么时候Git才会运行你的合并驱动程序呢?答案相当复杂,为了得出答案,我们必须定义术语“合并基础”,我们稍后会讲到。您还需要知道Git通过其哈希ID标识文件-实际上是几乎所有内容:提交、文件、补丁等等。如果您已经知道了所有这些,可以直接跳转到最后一节。
哈希ID
哈希ID(有时称为对象ID或OID)是您在提交中看到的那些大而丑陋的名称:
$ git rev-parse HEAD
7f453578c70960158569e63d90374eee06104adc
$ git log
commit 7f453578c70960158569e63d90374eee06104adc
Author: ...

Git 存储的所有内容都有一个唯一的哈希 ID ,由对象(文件、提交或其他)的内容计算而来。

如果您将 相同的 文件存储 两次 或更多次,则会得到相同的哈希 ID 两次或更多次。由于每个提交最终都存储了该提交时每个文件的快照,因此每个提交都具有列出其哈希 ID 的每个文件的副本。实际上,您可以查看它们:

$ git ls-tree HEAD
100644 blob b22d69ec6378de44eacb9be8b61fdc59c4651453    README
100644 blob b92abd58c398714eb74cbe66671c7c3d5c030e2e    integer.txt
100644 blob 27dfc5306fbd27883ca227f08f06ee037cdcb9e2    lorem.txt

中间的三个丑陋的ID是三个哈希ID。这三个文件在那些ID下的HEAD提交中。我在几个提交中也有同样的三个文件,通常内容略有不同。
到达合并基础:DAG
DAG或有向无环图是绘制提交之间关系的一种方式。要真正正确地使用Git,您需要至少对DAG有一个模糊的概念。它也被称为提交图,从某些方面来说,这是一个更好的术语,因为它避免了专业的信息学术语。
在Git中,当我们创建分支时,我们可以以各种不同的方式绘制它们。我喜欢在这里(在文本上,在StackOverflow上)使用的方法是将早期提交放在左侧,将后期提交放在右侧,并使用单个大写字母标记每个提交。理想情况下,我们应该按Git保留它们的方式绘制它们,这相当反向。
A <- B <- C   <-- master

这里只有三个提交,全部在master分支上。分支名master“指向”这三个提交中的最后一个。Git通过从分支名master读取其哈希ID来找到提交C,实际上,名称master有效地仅存储了此ID。

Git通过读取提交C来找到提交B。提交C内部包含提交B的哈希ID。我们说C“指向”B,因此是向后指的箭头。同样,B“指向”A。由于A是第一个提交,因此它没有前一个提交,因此没有反向指针。

这些内部箭头告诉Git每个提交的“父提交”。大多数情况下,我们不关心它们都是向后的,因此可以更简单地绘制如下:

A--B--C   <-- master

这让我们假装 CB 之后很明显,即使在Git中实际上这很难。(与宣称“BC 之前”的说法相比,在Git中非常容易:因为内部箭头都是反向的,所以可以轻松地向后移动。)
现在让我们画一个实际的分支。假设我们从提交 B 开始创建一个新的分支,并进行第四次提交 D(虽然不确定什么时候进行提交,但最终也无关紧要):
A--B--C   <-- master
    \
     D   <-- sidebr

现在,sidebr指向提交D,而master指向提交C

Git的一个关键概念是提交B同时存在于两个分支上。它既在master上,也在sidebr上。对于提交A也是如此。在Git中,任何给定的提交都可以并且通常是同时存在于多个分支上的。

这里还隐藏着Git中与大多数其他版本控制系统截然不同的另一个关键概念,我只会简单提一下。实际上,分支本身是由提交组成的,并且分支名称在这里几乎没有任何意义或贡献。这些名称仅用于查找分支末端:在本例中是提交CD。分支本身是通过绘制连线从新的(子)提交到旧的(父)提交得到的。

值得一提的是,这种奇怪的反向链接允许Git 永远不会更改任何提交的任何内容。请注意,CD都是B的子节点,但我们在创建B时并不一定知道我们将同时创建CD。但是,由于父级不“知道”它的子级,因此Git根本不必在B中存储CD的ID。当创建CD时,它只在每个CD中存储B的ID,而B的ID则肯定已经存在。

我们制作的这些图表显示了(部分)提交图形

合并基础

合并基础的正确定义太长了,这里不再赘述,但现在我们已经画出了图表,非正式定义非常容易,并且在视觉上也很明显。两个分支的合并基础是它们第一次相遇的点,就像Git一样向后工作。也就是说,它是第一个在两个分支上的提交。

因此,在以下示例中:

A--B--C   <-- master
    \
     D   <-- sidebr

合并基础是提交B。如果我们进行更多的提交:
A--B--C--F   <-- master
    \
     D--E--G   <-- sidebr

合并基础仍然是提交B。如果我们成功地进行了合并,新的合并提交将有两个父提交,而不仅仅是一个:
A--B--C--F---H   <-- master
    \       /
     D--E--G   <-- sidebr

在这里,提交H是合并,在运行git merge sidebr后我们在master上进行了合并,它的两个父提交是F(曾经是master的最新提交)和G(仍然是sidebr的最新提交)。
如果现在我们继续做提交,并且稍后决定进行另一个合并,则G将成为新的合并基础:
A--B--C--F---H--I   <-- master
    \       /
     D--E--G--J   <-- sidebr

H有两个父节点,当我们向后查看时,我们(和Git)同时遵循这两个父节点。因此,如果我们进行另一个合并,提交G将是第一个在两个分支上的提交。

附:交叉合并

请注意,在这种情况下,F不在sidebr上:当我们遇到它们时,我们必须遵循父链接,因此J指向GG指向E等等,因此从sidebr开始时,我们永远不会到达F。但是,如果我们从mastersidebr进行下一次合并:

A--B--C--F---H--I   <-- master
    \       /    \
     D--E--G--J---K   <-- sidebr

现在提交 F 在两个分支上都存在。但实际上,提交 I 也存在于两个分支上,所以即使这使得合并双向进行,我们在这里也没有问题。我们可能会遇到所谓的“交错合并”问题,我将画一个来说明这个问题,但这里不讨论它:

A--B--C--E-G--I   <-- br1
    \     X
     D---F-H--J   <-- br2

我们通过从分支分别到达 EF,然后执行 git checkout br1; git merge br2; git checkout br2; git merge br1 来创建 GEF 的合并,添加到 br1),然后立即创建 HFE 的合并,添加到 br2)。我们可以继续提交到两个分支,但最终,当我们再次合并时,我们遇到了一个问题:选择合并基础,因为 EF 都是“最佳候选人”。
通常,即使这样“只是工作”,但有时候criss-cross合并会创建问题,Git 试图使用其默认的“递归”合并策略以花式处理它们。在这些(罕见的)情况下,您可能会看到一些看起来奇怪的合并冲突,特别是如果您设置了 merge.conflictstyle = diff3 (我通常建议这样做:它会显示冲突合并中的合并基础版本)。

你的合并驱动程序何时运行?

现在我们已经定义了合并基准并了解了哈希标识对象(包括文件)的方式,我们现在可以回答最初的问题。
当您运行git merge 分支名称时,Git会:
  1. 标识当前提交,也称为 HEAD。这也被称为本地或 --ours 提交。
  2. 标识其他提交,即您通过 branch-name 给出的提交。那是另一个分支的尖端提交,有时也称为其他、--theirs 或远程提交(“远程”是一个非常糟糕的名称,因为 Git 也将该术语用于其他目的)。
  3. 标识合并基础。让我们称这个提交为“base”。字母 B 也很好,但使用合并驱动程序时,%A%B 分别指代 --ours--theirs 版本,而 %O 则指代基础。
  4. 实际上运行两个单独的 git diff 命令:git diff base oursgit diff base theirs
这两个差异告诉Git "发生了什么事"。记住,Git的目标是合并"我们在自己的版本中做了什么"和"他们在他们的版本中做了什么"这两组更改。这就是两个git diffs所显示的内容:"base vs ours"是我们所做的,而"base vs theirs"是他们所做的。(这也是Git如何发现在base-to-ours和/或base-to-theirs中是否添加、删除和/或重命名任何文件的方式,但现在这是一个不必要的复杂问题,我们将忽略它。)
实际上,这些变化的组合机制会调用合并驱动程序,或者像我们的问题案例一样,不会调用。
请记住,Git通过其哈希ID对每个对象进行分类。每个ID都基于对象的内容是唯一的。这意味着它可以立即判断任何两个文件是否完全相同:只有当它们具有相同的哈希时,它们才完全相同。
这意味着,如果在基础对我们或者基础对他们的情况下,两个文件具有相同的哈希值,那么要么我们没有进行更改,要么他们没有进行更改。如果我们没有进行更改而他们进行了更改,那么显然将这些更改合并的结果是他们的文件。或者,如果他们没有进行更改而我们进行了更改,则结果是我们的文件。
同样地,如果我们和他们的哈希值相同,则我们都进行了相同的更改。在这种情况下,合并更改的结果是任何一个文件 - 它们是相同的,因此Git选择哪一个文件并不重要。
因此,在所有这些情况下,Git只需选择与基本版本具有不同哈希值(如果有)的任何新文件。这就是合并结果,没有合并冲突,Git完成了该文件的合并。它从未运行您的合并驱动程序,因为显然没有必要。
只有当三个文件具有三个不同的哈希值时,Git才需要进行真正的三方合并。如果您定义了自定义合并驱动程序,则会在此时运行它。

有一种方法可以解决这个问题,但它并不适合胆小的人。Git不仅提供自定义合并驱动程序,还提供自定义合并策略。有四种内置的合并策略,都是通过-s选项选择的:-s ours-s recursive-s resolve-s octopus。然而,你可以使用-s custom-strategy来调用自己的策略。

问题在于,要编写合并策略,必须确定合并基础,根据模糊的合并基础进行任何递归合并(如-s recursive),运行两个git diff,确定文件添加/删除/重命名操作,然后运行各种驱动程序。因为这需要处理整个megillah,所以您可以做任何想做的事情,但您必须做很多工作。据我所知,没有使用此技术的预制解决方案。


非常好的解释,@torek!从中学到了很多。 - Jennifer Philips
我想知道是否有其他方法可以在合并完成之前停止它(例如通过_git merge --no-commit_),并以某种方式获取_base_,_ours_和_theirs_的值。然后我们可以使用这些参数运行_git merge-file_。 - Jennifer Philips
如果您需要在没有冲突时检查更改,则需要使用difftool,而不是mergetool。您可以找到一些支持对整个树进行差异比较的工具。 - max630
@JenniferPhilips:我喜欢家谱的比喻(两个词有点绕口)。同时,如果自定义Git合并驱动程序和/或文件路径可以被标记为“始终运行,即使合并看起来微不足道,如果基础≠分支端点”,那就很好了。或者,也许,如果有一个自定义合并驱动程序,只需将其视为“始终运行”。 (对我来说不清楚,未标记时,如果ours = theirs但ours和theirs ≠ base,它是否应该运行。) - torek

1

简述:我尝试重复你描述的内容,似乎可以工作。与你的版本相比,有两个变化,但如果没有它们,合并就会失败(因为驱动程序基本上无法运行)。

我尝试了这个:

创建一个合并驱动程序$HOME/bin/errorout.bat

exit 1

创建一个合并类型的部分。
[merge "errorout"]
   name = errorout
   driver = ~/bin/errorout.bat %A %O %B

创建 .gitattributes 文件:
*.txt merge=errorout

之后,错误会按照你想要的方式进行报告:

 $ git merge a

 C:\...>exit 1
 Auto-merging f.txt
 CONFLICT (content): Merge conflict in f.txt
 Automatic merge failed; fix conflicts and then commit the result.

我有git版本2.11.0.rc1.windows.1。我无法成功运行您指定的复杂命令,它报告了一些语法错误。

哎呀,这个对我没用。根据@torek的解释,我认为合并驱动程序根本没有被调用。你是否尝试仅在一个分支上更改文件,以便显然没有冲突,然后进行合并? - Jennifer Philips
我理解不了你需要的是即使只在一侧进行更改也要失败。那么,merge driver将无法起到作用。 - max630
好的,现在我明白了。一切都正常工作,驱动程序被调用,但只有当我们的和他们的都与基础不同时才会被调用。这实际上解决了我的问题,因为只有在这种情况下开发人员才可能坚持手动合并。感谢max630和@torek的帮助。 - Jennifer Philips

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