什么是合并冲突(merge conflict)

想象你和小王都是同一个开源项目的贡献者,这一天你拉取了远程仓库的最新版本,但是git却报出了一个错误:

自动合并 xxx
冲突(内容):合并冲突于xxx
自动合并失败,修正冲突然后提交修正的结果。

你仔细一看,原来是你上一次commit时的“utils.java”的实现被小王重构并且优化了一下,并且小王也将这个“utils.java”给push到了远程仓库。这样,远程仓库的“utils.java”的内容,和你自己本地分支最新commit的“utils.java”的内容出现了不一致。git的merge能自行解决一部分差异,但另一部分相同位置出现的不同实现就无法被git的自动merge解决了,因为这个时候git就不知道,你是想要保留自己的,还是要保留小王的版本,于是就这样git报出了合并冲突的错误。

所谓“**合并冲突(merge conflict)**”,便是git在合并两个分支的时候,出现了对同一处位置的不同内容。大部分时候git都能自动完成merge工作,但一旦出现上述的同一位置的不同内容的情况,git就会“犯难”,它并不知道该保留哪一处分支的内容,此时就会出现“分支冲突”的错误,并把冲突的解决交给用户。


一个简单的实例

现在我们来看一个简单的合并冲突的实例。

比如我现在想要布置一个“实现斐波那契数列第n项求解”的任务,我在一开始就初始化了一个git仓库,并且在默认的分支master上给出了代码的基本框架并且提交了一个commit:

  1. 新建工作目录,初始化git仓库

    1
    2
    3
    mkdir merge-conflict-demo # 在当前目录新建一个名为“merge-conflict-demo”的目录
    cd merge-conflict-demo # 进入这个目录
    git init. # 在这个进入的目录下初始化一个git仓库
  2. 给出“求解斐波那契数列第n项”的基本代码框架

    1
    touch fibonacci.cpp # 新建一个cpp源文件用来求解斐波那契数列的第n项
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // 求解斐波那契数列第n项的代码框架
    #include <iostream>

    long long fib(int n) {
    // To be implemented.
    }

    int main() {
    std::cout << "请输入一个正整数:";
    int n;
    std::cin >> n;
    std::cout << "Fibonacci(" << n << ") = " << fib(n) << std::endl;
    return 0;
    }
  3. 提交代码

    1
    2
    git add fibonacci.cpp
    git commit -m "Provide skeleton of Fibonacci series evaluation by cpp"

这样子git的仓库就有包含代码框架的commit了。现在比方说有两个学生小王和小李,他们接到的任务就是函数long long fib(int n)的具体实现。

小王说,这还不简单,我根据定义写个递归就行,于是他:

  1. 基于初始commit创建了自己的分支,并且进行了fib的递归实现

    1
    git checkout -b wang-implementation
    1
    2
    3
    4
    5
    6
    7
    long long fib(int n) {
    // 小王的递归实现
    if (n == 0 || n == 1)
    return 1;
    else
    return fib(n - 1) + fib(n - 2);
    }
  2. 简单测试并通过了样例以后,小王提交了代码

    1
    2
    git add fibonacci.cpp
    git commit -m "Implement Fibonacci series evaluation recursively"

小李一开始也是这么想的,但小李发现这么写会产生很多的重复计算,比如计算“fib(n)”的时候,按递归定义计算了“f(n-2)”;但计算“fib(n-1)”的时候按递归定义又要重新把“fib(n-2)”算一遍,会导致更多不必要的开销。深思熟虑以后,小李决定:

  1. 基于初始commit创建了自己的分支,并完成了fib的迭代实现

    1
    git checkout -b li-implementation
    1
    2
    3
    4
    5
    6
    7
    long long fib(int n) {
    // 小李的迭代实现
    int a = 1, b = 1;
    for (int i = 2; i <= n; i++)
    b = b + a, a = b - a;
    return b;
    }
  2. 通过了测试并提交了自己的代码

    1
    2
    git add fibonacci.cpp
    git commit -m "Implement Fibonacci series evaluation iteratively"

于是我现在首先merge了小王的代码,然后git成功把我的“fibonacci.cpp”更新成了小王的递归版(记得我自己默认的分支最开始就是master):

成功的merge

当我看完小王的实现后,觉得没毛病,所以我现在想看小李的实现。于是我把小李的实现merge了进来,git就报出了合并冲突的bug:

冲突的merge

造成合并冲突的原因在第一部分已经有讲过了,要合并分支的commit和当前分支所记录的commit里存在对同一位置的不同内容,这个时候git会报出合并冲突并把选择权交给用户。反映到这里,就是小王和小李在“fibonacci.cpp”文件的long long fib(int n)这个函数的实现里选择了不同的方式,让git在合并时感到了“为难”。


合并冲突问题的解决

还是第二部分的例子——在合并冲突的文件“fibonacci.cpp”中,我们可以看到git为它生成了一些额外的信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
long long fib(int n) {
<<<<<<< HEAD
// 小王的递归实现
if (n == 0 || n == 1)
return 1;
else
return fib(n - 1) + fib(n - 2);
=======
// 小李的迭代实现
int a = 1, b = 1;
for (int i = 2; i <= n; i++)
b = b + a, a = b - a;
return b;
>>>>>>> li-implementation
}

我们对这些信息进行一点解释说明

  • <<<<<<< HEAD

    表示当前分支记录的内容,在这里就是因为一开始commit的就是小王的递归版本,所以“<<<<<<< HEAD”下面保存的就是小王的实现。

  • =======

    分隔符,用来区分当前分支的内容与merge进来的分支的内容。

  • >>>>>>> [merge进来的分支名]

    从“=======”到“>>>>>>> [merge进来的分支名]”之间的内容,表示merge进来的分支所记录的内容,在这里就是小李所实现的基于迭代的fib函数。

要解决分支冲突,我们需要对保留当前还是接受merge的内容进行取舍。因为显然小李的实现更加高效,所以我们选择保留小李的实现,并且删去小王的实现以及git生成的助记符,然后进行add与commit

1
2
3
4
5
6
7
long long fib(int n) {
// 小李的迭代实现
int a = 1, b = 1;
for (int i = 2; i <= n; i++)
b = b + a, a = b - a;
return b;
}

解决合并冲突

如图所示,经过上述的步骤后,我们已经成功解决了合并冲突,并且HEAD指针指向master分支,最近的一次commit显示我们把小李的分支merge进了master分支。

综上所述,进行分支冲突修复的办法就是:

  1. 找到发生冲突的文件以及冲突产生的具体位置
  2. 选择需要保留的版本,并删去不需要的内容和额外生成的信息
  3. 重新add那些发生冲突的文件,然后commit

后记

看到这里,我想有些细心的小伙伴可能会问,还记得最开始有一步——先merge小王的递归版本进master么,喏,你看:

一开始的fib函数

1
2
3
long long fib(int n) {
// To be implemented.
}

merge进来的小王的fib函数

1
2
3
4
5
6
7
long long fib(int n) {
// 小王的递归实现
if (n == 0 || n == 1)
return 1;
else
return fib(n - 1) + fib(n - 2);
}

那不是也符合我们之前说的“同一位置的不同内容”,那为什么这个没有造成git的合并冲突呢?那这里我们说,这是因为git的合并冲突是牢牢基于“分支”这个结构的,也就是说,不同的分支要存在并行的关系,才有可能出现“分支冲突”。

我们说小王和小李的代码,逻辑上是呈“并行”结构的——他们都是基于最开始的框架代码所在的master分支checkout出来的,所以合并起来会产生冲突。但也正是这个

基于最开始的框架代码所在的master分支checkout出来的

这个条件,使得小王的递归代码在一开始和master进行merge的时候,它们二者呈现的是一个先后的“顺序”关系而不是一种并行的关系,所以git认为——这是你用新版本去覆盖老版本啊,完全没有问题。所以这样一种先后的串行关系并不会造成“合并冲突”,只有“并行”的分支关系才有可能造成合并冲突。

注意图片里所显示的“串行”与“并行”关系