这个问题有两个部分。相对容易的一部分是编写自定义合并驱动程序,就像你在步骤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
“指向”这三个提交中的最后一个。Git通过从分支名
master
读取其哈希ID来找到提交
C
,实际上,名称
master
有效地仅存储了此ID。
Git通过读取提交C
来找到提交B
。提交C
内部包含提交B
的哈希ID。我们说C
“指向”B
,因此是向后指的箭头。同样,B
“指向”A
。由于A
是第一个提交,因此它没有前一个提交,因此没有反向指针。
这些内部箭头告诉Git每个提交的“父提交”。大多数情况下,我们不关心它们都是向后的,因此可以更简单地绘制如下:
A
这让我们假装
C
在
B
之后很明显,即使在Git中实际上这很难。(与宣称“
B
在
C
之前”的说法相比,在Git中非常容易:因为内部箭头都是反向的,所以可以轻松地向后移动。)
现在让我们画一个实际的分支。假设我们从提交
B
开始创建一个新的分支,并进行第四次提交
D
(虽然不确定什么时候进行提交,但最终也无关紧要):
A
\
D <
现在,sidebr
指向提交D
,而master
指向提交C
。
Git的一个关键概念是提交B
同时存在于两个分支上。它既在master
上,也在sidebr
上。对于提交A
也是如此。在Git中,任何给定的提交都可以并且通常是同时存在于多个分支上的。
这里还隐藏着Git中与大多数其他版本控制系统截然不同的另一个关键概念,我只会简单提一下。实际上,分支本身是由提交组成的,并且分支名称在这里几乎没有任何意义或贡献。这些名称仅用于查找分支末端:在本例中是提交C
和D
。分支本身是通过绘制连线从新的(子)提交到旧的(父)提交得到的。
值得一提的是,这种奇怪的反向链接允许Git
永远不会更改任何提交的任何内容。请注意,
C
和
D
都是
B
的子节点,但我们在创建
B
时并不一定知道我们将同时创建
C
和
D
。但是,由于父级不“知道”它的子级,因此Git根本不必在
B
中存储
C
和
D
的ID。当创建
C
和
D
时,它只在每个
C
和
D
中存储
B
的ID,而
B
的ID则肯定已经存在。
我们制作的这些图表显示了(部分)提交图形。
合并基础
合并基础的正确定义太长了,这里不再赘述,但现在我们已经画出了图表,非正式定义非常容易,并且在视觉上也很明显。两个分支的合并基础是它们第一次相遇的点,就像Git一样向后工作。也就是说,它是第一个在两个分支上的提交。
因此,在以下示例中:
A
\
D <
合并基础是提交
B
。如果我们进行更多的提交:
A
\
D
合并基础仍然是提交
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
指向G
,G
指向E
等等,因此从sidebr
开始时,我们永远不会到达F
。但是,如果我们从master
到sidebr
进行下一次合并:
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
我们通过从分支分别到达
E
和
F
,然后执行
git checkout br1; git merge br2; git checkout br2; git merge br1
来创建
G
(
E
和
F
的合并,添加到
br1
),然后立即创建
H
(
F
和
E
的合并,添加到
br2
)。我们可以继续提交到两个分支,但最终,当我们再次合并时,我们遇到了一个问题:选择合并基础,因为
E
和
F
都是“最佳候选人”。
通常,即使这样“只是工作”,但有时候criss-cross合并会创建问题,Git 试图使用其默认的“递归”合并策略以花式处理它们。在这些(罕见的)情况下,您可能会看到一些看起来奇怪的合并冲突,特别是如果您设置了
merge.conflictstyle = diff3
(我通常建议这样做:它会显示冲突合并中的合并基础版本)。
你的合并驱动程序何时运行?
现在我们已经定义了合并基准并了解了哈希标识对象(包括文件)的方式,我们现在可以回答最初的问题。
当您运行
git merge 分支名称
时,Git会:
- 标识当前提交,也称为
HEAD
。这也被称为本地或 --ours
提交。
- 标识其他提交,即您通过
branch-name
给出的提交。那是另一个分支的尖端提交,有时也称为其他、--theirs
或远程提交(“远程”是一个非常糟糕的名称,因为 Git 也将该术语用于其他目的)。
- 标识合并基础。让我们称这个提交为“base”。字母
B
也很好,但使用合并驱动程序时,%A
和 %B
分别指代 --ours
和 --theirs
版本,而 %O
则指代基础。
- 实际上运行两个单独的
git diff
命令:git diff base ours
和 git 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,所以您可以做任何想做的事情,但您必须做很多工作。据我所知,没有使用此技术的预制解决方案。
git commit --amend
修改合并提交,在进行所需更改后。虽然不完美,但至少可以为您提供一个即时的解决方法,直到您解决脚本混乱问题。 - Tim Biegeleisen