多人开发中的合并冲突是我们使用Git时常常会遇到的情况,小小合并门道大,讲述合并的那些事儿,晴耕 · 白话之“Git合并那些事”系列​持续连载中……

注: 本文的部分写作灵感来自于“Pro Git book”。感谢原作者的精彩分享。 本文采用知识共享署名-相同方式共享 4.0 国际许可协议进行许可。知识共享许可协议

制造冲突

这篇文章所要讨论的,全部都是和我们进行分支合并时产生的合并冲突有关。为了通过实验加深理解,首先需要人为地制造出一个合并冲突来。下面我们就一步一步地来准备实验环境。

首先,我们新建一个本地Git库,叫做test-merge-conflict:

$ git init test-merge-conflict
Initialized empty Git repository in /root/test-merge-conflict/.git/
$ cd test-merge-conflict/

在master分支上新建README文件:

$ vi README
$ cat README 
When conflict happens...
* Cancel the merge

并建立提交记录c0:

$ git add .
$ git commit -m c0
[master (root-commit) 12de8e7] c0
 1 file changed, 2 insertions(+)
 create mode 100644 README

然后,创建dev分支:

$ git branch dev

并继续在master分支上修改README文件:

$ vi README
$ cat README 
When conflict happens...
* Cancel the merge
* Understand the conflict

形成新的提交记录c1:

$ git commit -am c1
[master fc3fedf] c1
 1 file changed, 1 insertion(+)

然后,切换到dev分支:

$ git checkout dev
Switched to branch 'dev'

继续修改README文件:

$ vi README 
$ cat README
When conflict happens...
* Prepare
* Cancel the merge
* ...

并建立提交记录c2:

$ git commit -am c2
[dev f6cd947] c2
 1 file changed, 2 insertions(+)

同时,再增加一个新文件.gitignore:

$ touch .gitignore
$ git add .gitignore

并建立提交记录c3:

$ git commit -am c3
[dev 225fb79] c3
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 .gitignore

现在,让我们重新回到master分支:

$ git checkout master
Switched to branch 'master'

然后,把dev分支合并到master分支上来:

$ git merge dev
Auto-merging README
CONFLICT (content): Merge conflict in README
Automatic merge failed; fix conflicts and then commit the result.

这个时候,我们发现README在合并时产生了冲突。如果查看README的内容,还可以看到Git为我们自动添加的合并冲突标记:

$ cat README 
When conflict happens...
* Prepare
* Cancel the merge
<<<<<<< HEAD
* Understand the conflict
=======
* ...
>>>>>>> dev

冲突背后

三份拷贝

当合并出现冲突的时候,Git会在它的index(或者说是“暂存区”)里,针对产生冲突的文件,记录下该文件的三份拷贝。分别对应于:代表“共同祖先”的提交记录(common ancestor commit,也叫merge-base commit),用数字1标识;代表当前分支上参与合并的提交记录(也叫ours commit),用数字2标识;代表被合并分支上参与合并的提交记录(也叫theris commit),用数字3标识。

可以通过git ls-files -u命令对这三份拷贝进行查看:

$ git ls-files -u
100644 45e560ae8081887efcfa21e0f06e8490b59297de 1	README
100644 75e3ffd958d236a08543a5ab0149c5ae262c7b32 2	README
100644 9612c0564c52f87357ff29a92b52947a7b0057d3 3	README

其中,第一列代表的是mode值,100644对应的是普通文件。关于mode值更详细的介绍,大家可以在“Git解密——Tree对象和Commit对象”一文里找到。第二列代表的是这些文件在.git目录下保存成Git对象所对应的唯一键。关于Git对象,可以在“Git解密——认识Git对象”一文里找到更多的信息。第三列就是三份拷贝各自对应的数字标识。

我们还可以用git show命令,结合特殊的语法,查看具体的文件内容。

比如这是“共同祖先”的版本:

$ git show :1:README > README.common
$ cat README.common
When conflict happens...
* Cancel the merge

这是ours的版本:

$ git show :2:README > README.ours
$ cat README.ours
When conflict happens...
* Cancel the merge
* Understand the conflict

这是theirs的版本:

$ git show :3:README > README.theirs
$ cat README.theirs
When conflict happens...
* Prepare
* Cancel the merge
* ...

不仅如此,我们还可以利用这三个版本,结合git merge-file命令,显式地对README文件进行一次Three-Way Merge:

$ git merge-file -p README.ours README.common README.theirs > README.merged

这其中,参数-p是为了让Git把合并结果输出到标准输出设备,然后我们再利用重定向,把结果写入README.merged文件。默认情况下,合并后的结果会直接覆盖README.ours的内容。

当然,因为我们的合并存在冲突,所以还需要做进一步的手工编辑。当冲突解决后,我们可以使用git clean,清除所有手工合并时产生的多余临时文件:

$ git clean -f
Removing README.common
Removing README.merged
Removing README.ours
Removing README.theirs

使用git diff

当冲突发生的时候,Git为我们提供了很多工具,可以帮助我们进一步了解冲突的具体情况。比如,我们可以利用git diff,对当前工作目录下等待提交的内容(也就是自动合并后的结果)和三份拷贝进行比较:

$ git diff
diff --cc README
index 75e3ffd,9612c05..0000000
--- a/README
+++ b/README
@@@ -1,3 -1,4 +1,8 @@@
  When conflict happens...
+ * Prepare
  * Cancel the merge
++<<<<<<< HEAD
 +* Understand the conflict
++=======
+ * ...
++>>>>>>> dev

执行git diff时,Git会把能够成功完成自动合并的部分略去,只列出当前遇到冲突的部分。对于冲突部分的表示方法,Git采用的是一种被称为“Combined Diff”的格式。其中,涉及冲突的每一行最前面会多出两列来。第一列告诉我们,当前工作目录里的内容是否和ours版本有差异;第二列告诉我们,当前工作目录里的内容是否和theirs版本有差异。如果是新增的,就用“+”表示;如果是删除的,就用“-”表示。

所以,在上面的例子里:* Prepare* ...都是来自theirs版本,相对于ours版本而言新增的内容;* Understand the conflict则刚好相反;<<<<<<< HEAD=======>>>>>>> dev都是Git自动为我们加入的合并冲突标记,对于ours和theirs而言都是新增的内容。

另外,如果我们只想和当前分支合并前的内容进行比较,也可以使用git diff --ours;和被合并分支上的内容进行比较,可以使用git diff --theirs;和作为两个分支的共同祖先进行比较,则可以使用git diff --base

使用git log

为了快速掌握合并冲突是由那些改动引起的,我们也可以利用git log查看提交历史。它可以帮助我们把参与合并的两个分支上,所有和当前这次合并相关的提交记录都列出来:

$ git log --oneline --left-right HEAD...MERGE_HEAD
> 225fb79 (dev) c3
> f6cd947 c2
< fc3fedf (HEAD -> master) c1

这里,MERGE_HEAD指向的是我们的被合并分支。和它相对应的是ORIG_HEAD,指向的是当前分支。因为在我们的例子里它等价于HEAD,所以我们就直接使用了HEAD。对于HEAD...MERGE_HEAD这种写法,我们用到了一种被称为“Triple Dot”的特殊语法。它指定的是,两个分支上的所有提交记录,但不包含这两个分支的共同祖先。例如,在我们的例子里,c1,c2,c3都是满足条件的。而c0则不满足条件,因为它是两个分支的共同祖先:

上面的命令里,我们还使用了--left-right参数。它可以让Git标记出,影响合并结果的那些提交记录分别来自于哪个分支(左边还是右边)。来自左侧的提交记录会标记为<,在我们的例子里就是HEAD所指向的master分支;来自右侧的提交记录则会标记为>,在我们的例子里就是MERGE_HEAD所指向的被合并分支dev。

另外,如果我们把参数换成--merge,那么Git只会显示参与合并的两个分支上,直接造成当前文件冲突的那些提交记录:

$ git log --oneline --left-right --merge
> f6cd947 c2
< fc3fedf (HEAD -> master) c1

这里我们并没有看到提交记录c3,因为c3没有涉及对README文件的修改。

使用git checkout

如果我们把存在冲突的文件改乱了,想重新开始;或者,如果我们只想从其他分支上合并个别文件,我们可以使用git checkout。结合--conflict参数,它可以把存在冲突的文件,或者我们希望合并的文件重新checkout出来,并重新加上代表合并冲突的标记:

$ git checkout --conflict=diff3 README

参数--conflict的取值可以是merge或者diff3。默认情况下使用merge,Git会在文件里发生冲突的地方把代表ours和theirs的内容都显示出来。如果使用了diff3,Git还会把代表merge base的部分也显示出来:

$ cat README
When conflict happens...
* Prepare
* Cancel the merge
<<<<<<< ours
* Understand the conflict
||||||| base
=======
* ...
>>>>>>> theirs

其中,从|||||||开始到=======结束的部分就是代表merge base的部分。

另外,git checkout也可以带--ours--theirs参数。这对于处理二进制文件的合并冲突很有用,因为二进制文件做文本比较没有意义,我们在合并时通常只要简单地选择其中一个分支上的内容就可以了。Git会为我们快速地选择参与合并的两个分支中的其中一方,而根本不需要做对比。

留下评论

您的电子邮箱地址并不会被展示。请填写标记为必须的字段。 *

正在加载...