假设在分支 A 的中午你创建了一个文件:
A
在 1 处,从 A 创建一个分支 B,并将文件更改为:
A
B
C
在 2 处,其他人从 A 创建 C,并将文件更改为:
A
B
C
D
在 3 处,他们将 C 合并为 A,所以 A 现在是:
A
B
C
D
在4 时,有人在 A 上创建了一个分支 D,并将文件更改为:
A
在 5 处,创建分支的原始人将 B 合并为 A,这意味着在 A 上文件是:
A
B
C
最后,在 6 点,分支 D 合并到 A 中,留下,作为最终结果(?):
A
我的问题是 Git 知道如何将其视为合并冲突吗? 它看时间吗? 或者只是与共同祖先的 3 向差异,如果它在词汇上"去",它就会去?
在此示例中,分支 D 正在破坏 B,即使它是在 B 提交后从 A 创建的(在 B 中,但在合并回 A 之前)。 如果是这种情况,如果最后两个提交不是 B->A 然后是 D->A,而是相反(首先是 D->A,然后是 B->A),您将获得不同的结果。
对于 Git 来说,时间并不重要。 只有 生活 图表才重要。 (当然,图表本身是随着时间的推移而建立的,所以人们可以争辩说时间在某种程度上确实很重要,但请继续阅读。
假设在分支 A 的中午,您创建了一个文件 [包含一行读取
A
]
分支也不重要,不是你想象的意义上。 分支是否重要取决于您所说的分支是什么意思(请参阅我们所说的">分支"到底是什么意思? 请记住,每个提交(由其唯一的哈希 ID 标识)都具有:
- 所有源文件的完整快照;
- 作者姓名、电子邮件和时间戳;
- 提交者姓名、电子邮件和时间戳;
- 父提交的哈希 ID;以及
- 日志消息。
上面的粗体项(提交父级的哈希 ID)在这里很重要,因为这构成了图形。 作者和提交者这两个时间戳本质上是任意的:您可以在提交时覆盖其中一个或两个时间戳。 (提交后,它们就是提交数据的一部分,无法更改。 任何提交的任何部分都不能更改:最多,您可以复制该提交,更改某些项目,并获得具有新的和不同的唯一哈希ID的新提交,并说服每个人开始使用新提交代替旧提交。
让我们给每个提交一个唯一的C<number>
ID(我通常使用大写字母,但你在这里称分支为A
、B
、C
和D
,所以让我们用C1
到C7
来提交)。
同时,分支名称(请参阅"分支"到底是什么意思?)只是一个指向一个特定提交的指针,具有一个特殊的属性,即当我们git checkout
该分支并进行新提交时,Git 会更新名称以指向我们刚刚进行的新提交。 同时,我们刚刚进行的新提交存储了上一次提交的哈希 ID,作为其父级。 所以,假设我们有一个名为A
的分支,指向一些现有的提交C1
,它有一些我们不打算绘制的父级:
... <-C1 <-- A
分支名称A
包含C1
的哈希 ID。 您git checkout A
,以便C1
成为当前提交,A
成为当前分支:
...--C1 <-- A (HEAD)
(将连接器从提交绘制到它们的父级作为箭头即将成为一个问题,所以我在这里切换到--
)。 然后创建一个新文件,git add
它,并创建一个新的提交C2
。 这使得名称A
标识提交C2
,其父级为C1
:
...--C1--C2 <-- A (HEAD)
在 1 处,您从 A 创建分支 B ...
(例如,通过:git checkout -b B A
。
现在您有:
...--C1--C2 <-- A, B (HEAD)
。并将文件更改为 [有三行读取
A
B
和C
]
假设你运行git add
和git commit
,你现在有一个新的提交,B
标识它;新提交的父级是C2
:
...--C1--C2 <-- A
C3 <-- B (HEAD)
在 2 处,其他人从 A 创建 C ...
(例如,git checkout -b C A
)。 所以让我们画出来:
...--C1--C2 <-- A, C (HEAD)
C3 <-- B
并将文件更改为 [四行,
A
B
C
和D
,按此顺序]
假设通常的添加和提交,我们得到 commitC4
,C
现在指向:
C4 <-- C (HEAD)
/
...--C1--C2 <-- A
C3 <-- B
在 3 处,他们将 C 合并为 A
在这一点上,准确找出他们使用哪些命令来执行此操作变得很重要,因为提交C4
严格领先于提交C2
。 这意味着:
git checkout A
git merge C
将执行快进合并,结果为:
C4 <-- A (HEAD), C
/
...--C1--C2
C3 <-- B
但是,如果他们使用git merge --no-ff C
,Git 将执行真正的合并(这仍然是微不足道的),导致:
C4 <-- C
/
...--C1--C2----C5 <-- A (HEAD)
C3 <-- B
其中存储在C5
C2
中的新文件的内容与C4
中同名文件的内容匹配。 一个微不足道的合并只是通过我们稍后会看到的一般规则的明显简化来获取"图形明智"提交的内容。
无论哪种方式,名称A
都指向文件具有三行形式的提交,但一种方式A
指向与C
不同的提交。
4 时,有人在 A 上创建了一个分支 D
也就是说,git checkout -b D A
. 为了吸引它,我们需要知道再次从两个图表中的哪一个开始。 不过暂时没关系,所以现在,我将选择具有快进合并的那个:
C4 <-- A, C, D (HEAD)
/
...--C1--C2
C3 <-- B
并将文件更改为 [再次
A
一行]
$ git checkout -b D A
Switched to a new branch 'D'
$ echo A > file
$ git add file
$ git commit -m c7
[D 5724954] c7
1 file changed, 3 deletions(-)
我打电话给这个C7
留出一些空间,跳过一些提交ID,因为我们即将停止绘制它一段时间;但现在我们有:
C7 <-- D (HEAD)
/
C4 <-- A, C
/
...--C1--C2
C3 <-- B
5时,创建分支的原始人将B合并为A
这一次,无论输入是哪种提交图结构,都无法进行快进合并。 我们得到了一个真正的合并,这不是一个微不足道的合并。
现在让我们探索实际的合并算法
此时,我将稍微倒带我的演示存储库并重做快进合并,以便回到第一种情况:
$ git checkout A
Switched to branch 'A'
$ git reset --hard 6626cd2
HEAD is now at 6626cd2 c2
$ git merge C
Updating 6626cd2..7af3a02
Fast-forward
file | 3 +++
1 file changed, 3 insertions(+)
$ git log --all --decorate --oneline --graph
* 5724954 (D) c7
* 7af3a02 (HEAD -> A, C) c4
| * 5915b1d (B) c3
|/
* 6626cd2 c2
* 80e22c8 (master) initial
或以我的首选格式:
C7 <-- D
/
C4 <-- A (HEAD), C
/
...--C1--C2
C3 <-- B
我们在索引和工作树中提交了C4
,我们的HEAD
附加到名称A
上。 我们现在运行git merge B
或git merge <hash of C3>
- 哪个并不重要。 Git 查看此图以查找最低共同祖先节点,在这种情况下显然是提交C2
。
实际上,合并算法现在运行两个git diff
命令:
git diff --find-renames <hash-of-C2> <hash-of-C4> # what we changed
git diff --find-renames <hash-of-C2> <hash-of-C3> # what they changed
假设"我们"(C2
-vs-C4
)只更改了一个文件,则此处的更改将是:在文件末尾添加三行,B
C
D
。同样,为"他们的"工作找到的更改是在文件末尾添加两行,B
和C
。
Git 的工作是将这两个变化结合起来。 但它们是冲突的:不可能只添加两行,同时添加三行。 所以 Git 以合并冲突停止:
$ git checkout A
Already on 'A'
$ git merge B
Auto-merging file
CONFLICT (content): Merge conflict in file
Automatic merge failed; fix conflicts and then commit the result.
因此,您的下一个陈述是一个问题:
这意味着在 A 上文件是:
A B C
因为正如我们所看到的,在我们修复合并冲突之前,这不是文件中的内容:
$ cat file
A
<<<<<<< HEAD
B
C
D
||||||| merged common ancestors
=======
B
C
>>>>>>> B
(我merge.conflictStyle
设置为diff3
,在这里生成||||||| merged common ancestors
部分,尽管在这种情况下它无论如何都是空的)。
当然,执行合并的人可以按照您建议的方式设置内容,或者使用-X ours
或-X theirs
选择两个更改之一来优先。 但默认值是合并冲突,在这种情况下,由您(人类)正确解决它,无论在您的情况下正确定义是什么。 让我们在这里选择A
B
C
:
$ git checkout --theirs file
$ cat file
A
B
C
$ git add file
$ git commit -m c5
[A eec968d] c5
图形现在如下所示:
C4 <-- C
/
...--C1--C2 C5 <-- A (HEAD)
/
C3 <-- B
提交C5
中file
的内容是我们选择的内容。
如果我们强制进行微不足道的合并怎么办? 为什么琐碎的合并如此微不足道?
我们可以再次重置条件并返回到允许A
快进之前:
$ git reset --hard HEAD~2
HEAD is now at 6626cd2 c2
$ git log --all --decorate --oneline --graph
* 5724954 (D) c7
* 7af3a02 (C) c4
| * 5915b1d (B) c3
|/
* 6626cd2 (HEAD -> A) c2
* 80e22c8 (master) initial
以我的方式重新绘制此图,我们又回到了:
C7 <-- D
/
C4 <-- C
/
...--C1--C2 <-- A (HEAD)
C3 <-- B
这次让我们使用git merge --no-ff C
,并探索通常的算法的作用。 我们找到两个提示提交,C2
和C4
,并找到它们的合并基础 — 最不常见的祖先,即C2
。 然后我们做两个差异:
git diff --find-renames C2 C2 # what we changed (nothing!)
git diff --find-renames C2 C4 # what they changed
然后,我们将"无"与它们改变的任何内容结合起来。 这种结合的结果当然只是它们的变化;这些应用于C2
,结果是一个提交,其内容与C4
的内容匹配:
$ git merge --no-ff C -m c5
Merge made by the 'recursive' strategy.
file | 3 +++
1 file changed, 3 insertions(+)
$ git log --all --decorate --oneline --graph
* b47cf02 (HEAD -> A) c5
|
| | * 5724954 (D) c7
| |/
| * 7af3a02 (C) c4
|/
| * 5915b1d (B) c3
|/
* 6626cd2 c2
* 80e22c8 (master) initial
请注意,C4
和C5
的内容匹配:
$ git diff 7af3a02 b47cf02
$
并且图表现在符合预测(尽管 Git 的绘图很难阅读):
C7 <-- D
/
C4 <-- C
/
...--C1--C2----C5 <-- A (HEAD)
C3 <-- B
如果我们现在运行git merge B
,我们必然要求真正的合并——没有办法将名称A
"向前"滑动到C3
——但再次出现冲突:
$ git merge B
Auto-merging file
CONFLICT (content): Merge conflict in file
Automatic merge failed; fix conflicts and then commit the result.
$ cat file
$ cat file
A
<<<<<<< HEAD
B
C
D
||||||| merged common ancestors
=======
B
C
>>>>>>> B
合并冲突与之前的冲突相同,因为三个输入内容也相同。
让我们再次按照您建议的方式解决此问题,使用三行--theirs
版本(我将使用一个快捷方式作弊,无需git add
它):
$ git checkout MERGE_HEAD -- file
$ git commit -m c6
[A 2e66e76] c6
$ cat file
A
B
C
("作弊"是git checkout MERGE_HEAD
从提交C3
中提取文件,B
指向该文件,而不是从索引槽 3 中提取文件。 这将清除三个冲突的索引槽条目,将它们替换为已解析的零槽条目,以便我们准备好提交结果。
现在我们有这个图:
C7 <-- D
/
C4 <-- C
/
...--C1--C2----C5--C6 <-- A (HEAD)
/
/
/
C3 <-- B
返回到正在使用的命令
最后,在 6 点,分支 D 合并为 A ...
为此,我们必须将HEAD
附加到A
- 它已经是 - 并且我们运行git merge D
或git merge <hash of C7>
。 让我们通过找到提交C6
和C7
的合并基础来预测会发生什么,跟随图连接向后到最佳("最低")共同祖先。 这一次,这是提交C4
。C4
又有什么file
? 让我们查看它,使用名称C
指向它的事实:
$ git show C:file
A
B
C
D
$
所以 Git 会将<hash-of-C4>:file
的内容与<hash-of-C6>:file
的内容进行比较,看看我们更改了什么——我们不会为重命名检测或其他文件而烦恼,因为没有要检测的重命名,也没有对其他文件的更改:
$ git diff C:file A:file
diff --git a/file b/file
index 8422d40..b1e6722 100644
--- a/file
+++ b/file
@@ -1,4 +1,3 @@
A
B
C
-D
所以我们改变的是删除最后的D
.
另外,Git 会将<hash-of-C4>:file
的内容与<hash-of-C7>:file
的内容进行比较,看看它们更改了什么,因此:
$ git diff C:file D:file
$ git diff C:file D:file
diff --git a/file b/file
index 8422d40..f70f10e 100644
--- a/file
+++ b/file
@@ -1,4 +1 @@
A
-B
-C
-D
他们删除了三行。 这些更改应该相互冲突。 让我们看看我们是否正确:
$ git merge D
Auto-merging file
CONFLICT (content): Merge conflict in file
Automatic merge failed; fix conflicts and then commit the result.
$ cat file
A
<<<<<<< HEAD
B
C
||||||| merged common ancestors
B
C
D
=======
>>>>>>> D
我们确实是正确的:共同祖先(保存在工作树中,因为我diff3
作为我的冲突风格设置)有三行,而HEAD
版本保留了其中两行,但他们(D
)版本删除了所有三行。
(我们可以用快进产生的稍微简单的图形进行相同的练习,但效果是相同的:我们在同一组线上得到合并冲突。 关键是找到合并基和两个提示提交,并将基础与每个分支提示进行比较。 在这种情况下,最终合并的输入具有以下图:
C7 <-- D
/
C4 <-- C
/
...--C1--C2 C5 <-- A (HEAD)
/
C3 <-- B
其中C5
具有与更复杂的图形中C6
相同的手动构建内容,并且C4
和C7
相同,合并基础仍然是C4
。
递归合并
在对iBug的回答的评论中,您询问了递归合并。 当有多个最低共同祖先时,就会发生这种情况。 在简单的树形数据结构中,只有一个 LCA,但在有向图中,可能有多个。 参见迈克尔·本德、马丁·法拉赫-科尔顿、吉里达尔·彭马萨尼、史蒂文·斯基纳和帕维尔·苏马津。树和有向无环图中最低的共同祖先。算法学报, 57(2):75–94, 2005.两个正式定义;但一般来说,这些发生在图形导向的版本控制系统(如 Git 和 Mercurial)中。 当您进行"纵横交错"合并时。 例如,假设我们从这个图开始:
o--A <-- branch1
/
...--o--*
o--B <-- branch2
我们现在git checkout branch1 && git merge branch2
,然后当成功时,我们有:
o--A---M1 <-- branch1
/ /
...--o--* /
/
o--B <-- branch2
我们立即运行git checkout branch2 && git merge branch1~1
(或等效的,例如git merge <hash of commit A>
)来产生:
o--A---M1 <-- branch1
/ /
...--o--* X
/
o--B---M2 <-- branch2
如果我们现在在两个分支上进行更多的提交,我们可能会有,例如:
o--A---M1--C <-- branch1
/ /
...--o--* X
/
o--B---M2--D <-- branch2
我们现在问:哪个提交是或两个分支的尖端的最低共同祖先,提交C
和D
? 从C
开始并向后工作,我们发现提交B
通过M1
,可以从D
到M2
访问,所以它是一个LCA。 但是我们也在直线路径上发现了提交A
,并且可以通过M2
的另一个父级从D
访问它。
使用他们的定义之一,或者我喜欢的更简单的定义,涉及计数跃点(但这在有很多输入时不如诱导子图方法有效),我们发现提交A
和B
都是提交C
和D
的 LCA。 这就是 Git-s resolve
和-s recursive
策略的不同之处。
在-s resolve
下,Git 只是(显然)随机选择一个祖先,并将其用作两个差异的合并基础。 在-s recursive
下,Git 找到所有的LCA 并使用它们作为合并基础。 为此,Git 合并所有 LCA,就像您运行了git merge <lca1> <lca2>
一样,然后 - 如果有更多的 LCA—git merge <resulting commit> <lca3>
,依此类推。 即使存在冲突,也会进行每次提交:Git 只是获取冲突合并,并完成冲突标记,并从中进行提交。
(合并任何一对 LCA 本身可能需要合并多个 LCA。 如果是这样,Git 会递归合并它们:因此得名。
最终提交是临时的,因为它没有名称来保留它,用作与分支提示进行比较的合并基础。 当内部合并发生冲突时,会产生非常混乱的结果和/或合并冲突。 不过,在大多数情况下,它运行良好。 在大多数情况下,它给出的结果与-s resolve
相似,对于结果不同的少数情况,它们往往更好。