git分支合并为啥会发生冲突

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了git分支合并为啥会发生冲突相关的知识,希望对你有一定的参考价值。

在解决git merge的冲突时,有时我总忍不住吐槽git实在太不智能了,明明仅仅是往代码里面插入几行,没想到合并就失败了,只能手工去一个个确认。真不知道git的合并冲突是怎么判定的。

在一次解决了涉及几十个文件的合并冲突后(整整花了我一个晚上和一个早上的时间!),我终于下定决心,去看一下git
merge代码里面冲突判定的具体实现。正所谓冤有头债有主,至少下次遇到同样的问题时就可以知道自己栽在谁的手里了。于是就有了这样一篇文章,讲讲
git merge内部的冲突判定机制。

recursive three-way merge和ancestor

git的源码
先用merge作关键字搜索,看看涉及的相关代码。
找了一段时间,找到了git merge的时候,比较待合并文件的函数入口:ll_merge。另外还有一份文档,它也指出ll_merge正是合并实现的入口。

从函数签名可以看到,mmfile_t应该就代表了待合并的文件。有趣的是,这里待合并的文件并不是两份,而是三份。

int ll_merge(mmbuffer_t *result_buf,
const char *path,
mmfile_t *ancestor, const char *ancestor_label,
mmfile_t *ours, const char *our_label,
mmfile_t *theirs, const char *their_label,
const struct ll_merge_options *opts)

看过git help merge的读者应该知道,ours表示当前分支,theirs表示待合并分支。看得出来,这个函数就是把某个文件在不同分支上的版本合并在一起。那么ancestor又是位于哪个分支呢?倒过来从调用方开始阅读代码,可以看出大体的流程是这样的,git merge会找出三个commit,然后对每个待合并的文件调用ll_merge,生成最终的合并结果。按注释的说法,ancestor是后面两个commit(ours和theirs)的公共祖先(ancestor)。另外前面提到的文档也说明,git合并的时候使用的是recursive three-way merge。

关于recursive three-way merge, wikipedia上有个相关的介绍#Recursive_three-
way_merge)。就是在合并的时候,将ours,theirs和ancestor三个版本的文件进行比较,获取ours和ancestor的
diff,以及theirs和ancestor的diff,这样做能够发现两个不同的分支到底做了哪些改动。毕竟后面git需要判定冲突的内容,如果没有
原初版本的信息,只是简单地比较两个文件,是做不到的。

鉴于我的目标是发掘git判定冲突的机制,所以没有去看git里面查找ancestor的实现。不过只需肉眼在图形化界面里瞅上一眼,就可以找到ancestor commit。(比如在gitlab的network界面中,回溯两个分支的commit线,一直到岔路口)

有一点需要注意的是,revert一个commit不会改变它的ancestor。所谓的revert,只是在当前commit的上面添加了新的
undo
commit,并没有改变“岔路口”的位置。不要想当然地认为,revert之后ancestor就变成上一个commit的ancestor了。尤其是
在revert merge commit的时候,总是容易忘掉这个事实。假如你revert了一个merge
commit,在重新merge的时候,git所参照的ancestor将不是merge之前的ancestor,而是revert之后的
ancestor。于是就掉到坑里去了。建议所有读者都看一下git官方对于revert merge commit潜在后果的说法:
结论是,如果一个merge commit引入的bug容易修复,请不要轻易revert一个merge commit。

剖析xdiff

从ll_merge往下追,可以看到后面出了一条旁路:ll_binary_merge。这个函数专门处理bin类型文件的合并。它的实现简单粗暴,如果你没有指定合并策略(theris或ours),直接报Cannot merge binary files错误。看来在git看来,二进制文件并没有diff的价值。

主路径从ll_xdl_merge到xdl_merge,进到一个叫xdiff的库中。终于找到git merge的具体实现了。

平心而论,xdiff的代码风格十分糟糕,不仅注释太少,而且结构体成员变量居然使用类似i1、i2这样的命名,看得我头昏脑胀、心烦意燥。

吐槽结束,先讲下xdl_merge的流程。xdl_merge做了下面四件事:

由xdl_do_diff完成two-way diff(ours和ancestor,theirs和ancestor),生成修改记录,存储到xdfenv_t中。
xdl_change_compact压缩相邻的修改记录,再用xdl_build_script建立xdchange_t链表,记录双方修改。xdchange_t主要包括了修改的起始行号和修改范围。
这时候分三种情况,其中两种是只有一方有修改(只有ours或theirs一条链表),直接退出。最后一种是双方都有修改,需要合并修改记
录。由于修改记录是按行号有序排列的,所以直接合并两个链表。修改记录如果没有重叠部分,按先后顺序标记为我方修改/他方修改。如果发生了重叠,就表示发
生了冲突。之后会重新过一遍两个待合并链表,对于那些标记为冲突的部分,比较它们是否相等的,如果是,标记为双方修改。
由xdl_fill_merge_buffer输出合并结果。如果有冲突,调用fill_conflict_hunk输出冲突情况。如果没有冲突(标记为我方修改/他方修改/双方修改),则合并ancestor的原内容和修改记录,按标记的类型取修改后的内容,并输出。

输出冲突情况的代码位于fill_conflict_hunk中。它的实现很简单,毕竟此时我们已经有了双方修改的内容,现在只需要同时输出冲突内容,供用户取舍。(这便是那次花了一个晚上和一个早上改掉的冲突的源头,凶手就是你,哼)。

输出格式恐怕大家都很熟悉。该函数会先打印若干个。这个就是折磨人的合并冲突了:

<<<<<<< HEAD
3
=======
2
>>>>>>> branch1

总结

git merge的冲突判定机制如下:先寻找两个commit的公共祖先,比较同一个文件分别在ours和theirs下对于公共祖先的差异,然后合并这两组差异。如果双方同时修改了一处地方且修改内容不同,就判定为合并冲突,依次输出双方修改的内容。
参考技术A git branch
没有参数,显示本地版本库中所有的本地分支名称。
当前检出分支的前面会有星号。
git branch newname
在当前检出分支上新建分支,名叫newname。
git checkout newname
检出分支,即切换到名叫newname的分支。本回答被提问者采纳
参考技术B git branch
没有参数,显示本地版本库中所有的本地分支名称。
当前检出分支的前面会有星号。
git branch newname
在当前检出分支上新建分支,名叫newname。
git checkout newname
检出分支,即切换到名叫newname的分支。

究竟是什么导致了git中的合并冲突?

1)有一个'master'分支,其中包含一个文件

1

2

3

4

5

2)A从'master'获取分支并编辑

1

2

100

3

4

5

3)B从'master'获取分支并编辑

1

2

3

4

200

5

4)现在推动变成主人。然后B试图推动。

B会发生什么?任何合并冲突或没有合并冲突?原因是什么?

答案

最简单的答案是:如果两个开发人员在同一个文件(和同一个分支)中更改了相同的代码行,git将假定存在冲突。

无论是谁先提出并推动,都是“胜利者”。第二个必须处理冲突。他不能推动,直到他从原点撤下所有变化并解决冲突。

另一答案

你的问题意味着事情并非如此。

具体来说,git push只推动现有的提交。它没有合并任何东西,事实上,它甚至从未试图合并任何东西。

在您的问题中,涉及三个实体(人员和存储库)。 A人(让我们称她为Alice),B人(让他们称他为Bob)和C人(让他们称他为Central-Server,他实际上只是一台机器,而不是一个人,虽然这并不重要)。

Alice和Bob都是从Central-Server获取某个存储库的副本(克隆)开始的。它们得到完全相同的克隆(这就是为什么它们被称为“克隆”):Alice的克隆匹配Bob的克隆匹配Central-Server上的克隆。他们通过运行git clone <url>来执行此操作,其中<url>指向中央服务器(github或其他任何东西),并且他们的git将名称origin保存下来(我们很快会再次看到此名称)。

让我们绘制一下所有三个实体现在拥有的git提交图(的一部分):

... - C7 - C8   <-- master

现在爱丽丝和鲍勃都做出了改变,但他们做了不同的改变。爱丽丝承诺改变:

... - C7 - C8 - A   <-- master

然后Alice运行git push origin将她的工作推回到Central-Server。 Central-Server查看了她的请求,其中说“在A将链接C8添加到链的末尾,并使master指向A”。此操作会向链中添加新提交,因此允许,因此Central-Server向Alice回复“OK”,并且她已全部完成。 Central-Server的存储库现在看起来和Alice的相同,因为它们都在旧提交A之后有新的提交C8master指向提交A(并且提交A指向旧的C8)。

同时Bob已做出改变并添加了一个新的提交,他的提交图现在看起来像这样:

... - C7 - C8 - B   <-- master

Bob不知道Alice已经提交了A,也没有成功将它推送到Central-Server。他去了git push origin,但这次Central-Server得到一个请求,说“在C8链接的末尾添加提交B,然后让master指向B”。如果Central-Server这样做,效果将是:

                A
              /
... - C7 - C8 - B   <-- master

也就是说,提交A将保持浮动,没有任何指向它。 (分支master将指向BB将指向C8,没有任何指向A。)这通常是一种糟糕的状态,git拒绝它,所以Central-Server告诉Bob:

rejected (non-fast-forward)

请注意,没有合并,也没有变基。

现在鲍勃的工作是进行合并(或改组)。他应该这样做:

  1. 从拥有它的人那里获取更新。谁拥有它?爱丽丝拥有它,但Central-Server也是如此。他刚刚要求推送到Central-Server,后者告诉Bob“不”,所以他不妨从Central-Server获取它。
  2. 合并(或rebase),解决任何冲突。
  3. 重试推送。

如果Bob选择“合并”并做适当的合并工作,这是他的新提交图:

... - C7 - C8 - A - M   <-- master
                 /
                B

请注意新的合并提交M。现在Bob可以重新尝试推送到Central-Server,后者目前拥有以A结尾的链。这次中央服务器将看到使master指向M的请求,并且由于M指向A,该请求将被允许(现在它是“快进”)。

当然,如果Alice(或Dave或Emily或Frank)通过在A之后添加新提交并将其发送回Central-Server而击败Bob,那么Bob将不得不再次合并(或重新绑定),然后再试一次。

How does Bob manage all this?

这是Bob的选择是合并还是变基。无论哪种方式,Bob都必须解决任何合并冲突 - 无论他使用哪种方法,他都会得到相同的合并冲突。并且,在任何一种情况下,他应该开始运行:

git fetch origin

(或只是git fetch,它将自动使用origin)。

让我们看一下在变基或合并之前Bob的提交图:

                A   <-- origin/master
              /
... - C7 - C8
              
                B   <-- master

请注意,Bob的master指向提交B,Bob有另外一件事 - 这个origin/master指向Alice的提交A。这就是git fetch所做的:它从Central-Server中带来最新版本(或者如果Bob直接从Alice获取,将其从她身上带过来,因为她有相同的提交),然后使一些标签指向该提交。标签以origin/...开头,因为这是我们允许git clone使用的名称:它只是将origin/粘贴在另一个名称前面(在这种情况下为master),以便我们可以区分它们。

如果鲍勃选择改变而不是合并,他将把他的git复制他的提交B到新的提交B'

                A       <-- origin/master
              /   
... - C7 - C8       B'  <-- master
              
                B

鲍勃的原始B会发生什么?答案是:它被废弃了。它保留在存储库中一段时间​​(默认30天),以防Bob需要它,保存在Bob的reflog中,但除非你(或Bob)明确要求git查看,否则你看不到这些提交,所以它们似乎消失了。

如果Bob选择合并,他会得到:

                A       <-- origin/master
              /   
... - C7 - C8       M   <-- master
                 /
                B

这是我们在上面绘制的图,我们刚刚提升了A节点,以便我们可以指向它的箭头(标记为origin/master)。

在任何一种情况下,Bob现在可以尝试推送,因为他的新提交 - B'M-points返回提交A,因此他只要求Central-Server添加新提交而不是忘记或放弃提交A

What about the merge itself?

Git将尝试通过比较Alice所做的更改(添加100的一行)与Bob所做的更改(添加一行200)来帮助Bob。如果git决定这些更改不会相互冲突,它将保留这两个更改。如果它确定这两个更改会影响文件的同一部分,则会给Bob一个合并冲突,标记文件的更改区域,并让Bob决定如何组合它们。

Bob可以使用他喜欢的任何东西来实现组合结果。 Bob应该确保结果是正确的,然后Bob应该告诉他的git到git add文件的最终版本和git commit来提交更改。如果他用来组合这些更改的命令是git merge,这个将进行合并提交。如果它是git rebase,这将使新的副本B'


1如果鲍勃选择git rebase,他可以使用git rebase --continue,它将为他做出承诺。虽然鲍勃首先做git commit然后做git rebase --continue是安全的(并且在git 1.5左右的时候,在继续变换之前,必须手动执行提交部分)。

A final note about git pull

我鼓励新的git用户从git fetch开始,然后做自己的git mergegit rebase。许多文件告诉你从git pull开始,但我认为这是一个错误。 git pull命令是一个方便:它运行git fetch,然后运行git mergegit rebase,但这有几个缺陷。没有一个是非常严重的,但这些对新用户不利:

  1. 在您甚至可以查看更改之前,它会选择合并与rebase。
  2. 默认是合并,这通常是新用户的错误答案,他们通常可能应该进行rebase。您可以更改默认值,但新用户不知道提前执行此操作。你可以添加--rebase告诉它改变,但你可以忘记包含这个标志。
  3. 它所做的合并是一个"foxtrot merge":它让父母提交错误的方向。
  4. 与手动合并相比,这些论点令人困惑:git pull origin <branch> vs git merge origin/<branch>。 (当然,如果你想避免foxtrot合并,你也不能使用后者,但你可能应该重新定位。)
  5. 如果你提供了太多的参数(git pull origin master develop),它会使章鱼合并,这不是新用户应该考虑的事情。 :-)
  6. 它曾经有许多破坏工作的bug案例。我相信它们都是固定的,但使用git fetch后跟一个单独的git mergegit rebase一直避免这些错误。
  7. 最后,这里发生了太多的魔法。它应该是方便的(对于那些使用git的老手,它很方便)但它只是模糊不清。
另一答案

当B尝试推送时,会有错误返回,因为A已被推送更改为master。那你应该这样做,

git fetch
git rebase origin/master

在执行rebase步骤时,不会发生冲突,因为A更改了文件的第3行,B更改了文件的第5行。那你可以做到,

git commit -m ""
git push
另一答案

当git不知道如何处理代码的相同部分中的两个更改(未按顺序进行)时,会发生合并冲突。在您的情况下,已更改的行是不同的,因此git将能够合并提交而不会发生冲突。但是,一旦A中的提交被推送,B中的提交就不能在没有在A之上重新定位的情况下被推送。

在这种情况下你不能推B:

----M-----A
     ----B

如果你去B并输入并使用:

git rebase A

您的代码在本地存储库中将如下所示:

----M-----A-----B

你也可以推送提交B.

以上是关于git分支合并为啥会发生冲突的主要内容,如果未能解决你的问题,请参考以下文章

究竟是什么导致了git中的合并冲突?

合并分支,从当地分支机构挑选樱桃,没有任何冲突

git合并分支冲突解决

在Git的PR(Pull Request)提示冲突无法merge合并的解决方案

git分支的创建与合并

git合并分支