首页 > 解决方案 > Git 是否会考虑时间来避免分支“竞赛”?还是合并顺序很重要?

问题描述

假设中午在分支 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 知道如何将其视为合并冲突吗?它看时间吗?或者只是具有共同祖先的三向差异,如果它在词汇上“去”它去?

在此示例中,分支 D 正在破坏 B,即使它是在 B 提交之后从 A 创建的(在 B 中,但在合并回 A 之前)。如果是这种情况,如果最后两次提交不是 B->A 然后 D->A,而是相反(首先是 D->A,然后是 B->A),您会得到不同的结果。

标签: git

解决方案


对于 Git,时间并不重要。只有 生命 图是重要的。 (当然,图表本身是随着时间构建的,所以人们可以争辩说时间在某种程度上确实很重要,但请继续阅读。)

假设中午在分支 A 上创建一个文件 [包含一行阅读A]

分支也并不重要,不是你想象的那样。分支是否重要取决于您所说的分支是什么意思(请参阅我们所说的“分支”到底是什么意思?)。请记住,每个提交都由其唯一的哈希 ID 标识,具有:

  • 所有源文件的完整快照;
  • 作者姓名、电子邮件和时间戳;
  • 提交者姓名、电子邮件和时间戳;
  • 提交的哈希 ID;和
  • 日志消息。

这是上面的粗体项——提交父项的哈希 ID——在这里很重要,因为它形成了图表。作者和提交者这两个时间戳本质上是任意的:您可以在提交时覆盖一个或两个。(一旦提交,它们就是提交数据的一部分,不能更改。任何提交的任何部分都不能更改:最多,您可以制作该提交的副本,更改某些项目,并获得一个的提交,具有一个新的和不同的唯一哈希 ID,并说服每个人开始使用新的提交来代替旧的提交。)

让我们给每个提交一个唯一的C<number>ID(我通常使用大写字母,但您在这里调用了分支、、、A和,B所以让我们继续进行提交)。CDC1C7

同时,一个分支名称——请参阅我们所说的“分支”到底是什么意思?再次——只是指向一个特定提交的指针,具有特殊属性,当我们git checkout分支并进行提交时,Git 会更新名称以指向我们刚刚进行的新提交。同时,我们刚刚创建的新提交作为其父提交存储了先前提交的哈希 ID。所以,假设我们有一个名为 的分支A,指向一些现有的 commit C1,它有一些我们不会引入的父级:

...  <-C1   <-- A

分支名称A包含 的哈希 ID C1。您git checkout A因此C1成为当前提交并A成为当前分支:

...--C1   <-- A (HEAD)

(将连接器从提交给他们的父母绘制为箭头即将成为一个问题,所以我--改用这里)。然后,您创建一个新文件,git add它,并进行新的 commit C2。这使得名称A标识 commit C2,其父级是C1

...--C1--C2   <-- A (HEAD)

在 1,您从 A 创建一个分支 B ...

(通过,例如:git checkout -b B A。)

现在你有:

...--C1--C2   <-- A, B (HEAD)

...并将文件更改为[读取三行A BC]

假设你运行git addand 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,按此顺序]

假设通常的添加和提交,我们得到 commit C4C现在指向:

            C4   <-- C (HEAD)
           /
...--C1--C2   <-- A
           \
            C3   <-- B

在 3 处,他们将 C 合并到 A

此时,准确找出他们使用哪些命令来执行此操作变得很重要,因为 commitC4严格在 commit 之前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

其中新文件的内容(C2存储在 commitC5中)与 中的同名文件的内容匹配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附加到 name A。我们现在运行git merge Bgit merge <hash of C3>- 哪个并不重要。Git 查看此图以找到最低公共祖先节点,在这种情况下显然是 commit 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

filein commit的内容C5就是我们选择的。

如果我们强制进行微不足道的合并怎么办?为什么微不足道的合并如此微不足道?

我们可以再次重置我们的条件并回到我们允许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这段时间,探索一下常用算法的作用。我们找到两个提示提交,C2C4,并找到它们的合并基础——最不共同的祖先,即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

注意C4C5匹配的内容:

$ 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从 commit 中提取文件C3,指向哪个B点,而不是从索引槽 3 中提取它。这会清除三个冲突的索引槽条目,用已解决的零槽条目替换它们,这样我们就是准备提交结果。)

现在我们有这个图表:

              C7   <-- D
             /
            C4   <-- C
           /  \
...--C1--C2----C5--C6   <-- A (HEAD)
           \      /
            \    /
             \  /
              C3   <-- B

回到正在使用的命令

最后,在 6 点,分支 D 被合并到 A ...

要做到这一点,我们必须HEAD附加到A——它已经是——并且我们运行git merge Dor git merge <hash of C7>。让我们通过找到提交的合并基础来预测会发生什么,C6C7沿着图形连接向后追溯到最佳(“最低”)共同祖先。这一次,是 commit C4file里面又是什么C4C让我们使用名称指向它的事实来查看它:

$ 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

所以我们改变的是删除 final 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

where与更复杂的图中C5具有相同的手动构建内容,并且和是相同的,合并基础仍然是.)C6C4C7C4

递归合并

在对iBug 的回答的评论中,您询问了递归合并。当有多个最低共同祖先时,就会发生这些情况。在简单的树数据结构中,只有一个 LCA,但在有向图中,可能不止一个。参见Michael A Bender、Martín Farach-Colton、Giridhar Pemmasani、Steven Skiena 和 Pavel Sumazin。树和有向无环图中的最低共同祖先。Journal of Algorithms, 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

我们现在要问:哪个提交是或者是两个分支的提示的最低共同祖先,提交CD?从 开始C并向后工作,我们发现 commit Bthrough M1,它可以从Dthrough到达M2,所以它是一个 LCA。但是我们也可以在直接路径上找到提交并且A可以D通过.M2

使用他们的任何一个定义,或者我喜欢的更简单的定义,包括计算跳数(但是当有很多输入时,这不像诱导子图方法那样有效),我们发现提交AB都是提交LCACD. 这就是 Git-s resolve-s recursive策略不同的地方。

在 下-s resolve,Git 简单地(显然)随机选择一个祖先,并将其用作两个差异的合并基础。在 下-s recursive,Git 找到所有LCA 并将它们全部用作合并基础。为此,Git 会合并所有 LCA,就好像你已经运行过一样git merge <lca1> <lca2>,然后——如果有更多 LCA——<code>git merge <resulting commit> <lca3>,以此类推。即使存在冲突,也会进行每次提交:Git 只是简单地获取冲突的合并,加上冲突标记,然后从中进行提交。

(合并任何一对 LCA 本身可能需要合并多个 LCA。如果是这样,Git 会递归地合并它们:因此得名。)

最终提交是临时的,因为它没有名称来保留它,它被用作比较分支提示的合并基础。当内部合并有冲突时,这会产生非常混乱的结果和/或合并冲突。不过,在大多数情况下,它运作良好。它给出的结果与大多数情况相似-s resolve,而对于结果不同的少数情况,它们往往更好。


推荐阅读