Git版本控制管理——合并

之前提到了分支,既然有分,就一定有合。

在Git中,当一个分支中的修改与另一个分支中的修改不发生冲突的时候,Git会计算合并结果,并创建一个新提交来代表新的统一状态。但是当分支冲突时,Git并不解决冲突,而将该文件在索引中标记为未合并的(unmerged),交给用户处理,用户处理完冲突后,才能够进行最终提交。

合并示例

没有冲突的分支

首先执行下列代码初始化版本库:

git init
echo abc > file1
git add file1
git commit -m "commit file1"
echo abcd > file2
git add file2
git commit -m "commit file2"
git branch other HEAD^
git checkout other
git branch
echo abcde > file3
git add file3
git commit -m "commit file3"
echo abcdef > file4
git add file4
git commit -m "commit file4"
git checkout master
git branch

在上面的代码中,在master分支外创建了other分支,并进行了两次提交,然后如果想要将other分支合并到master分支上的话,就需要使用到git merge命令。

git merge操作区分上下文,当前分支始终是目标分支,其它一个或多个分支始终合并到当前分支,因此此时需要检出mster分支,然后执行:

$ git merge other
Merge made by the 'recursive' strategy.
 file3 | 1 +
 file4 | 1 +
 2 files changed, 2 insertions(+)
 create mode 100644 file3
 create mode 100644 file4

此时可以看下发生了什么:

$ git log --graph --pretty=oneline --abbrev-commit
*   c434f5c (HEAD -> master) Merge branch 'other'
|\
| * 9a52c7a (other) commit file4
| * ed1d141 commit file3
* | a2c5b9d commit file2
|/
* e3dea4c commit file1

从上面可以看出,git merge会产生一个合并后的提交并添加到当前分支中,而另一个分支不受影响。

有冲突的合并

而如果在分支other上新建file2,那么此时的合并操作为:

$ git checkout other
git add file2
git commit -m "commit file2 again"
git checkout master
Switched to branch 'other'

$ echo 1111 > file2

$ git add file2
warning: LF will be replaced by CRLF in file2.
The file will have its original line endings in your working directory

$ git commit -m "commit file2 again"
[other 7105a3a] commit file2 again
 1 file changed, 1 insertion(+)
 create mode 100644 file2

$ git checkout master
Switched to branch 'master'

$ git merge other
CONFLICT (add/add): Merge conflict in file2
Auto-merging file2
Automatic merge failed; fix conflicts and then commit the result.

此时提示冲突,而file2文件的内容就改变为:

$ cat file2
<<<<<<< HEAD
abcd
=======
1111
>>>>>>> other

=======之前的部分表示活动分支该文件的内容,之后的部分表示other分支该文件的内容。用户可以自己抉择如何修改。

若选择留下other分支的内容,就可以删除掉其它内容:

$ cat file2
1111

此时可以使用git add将该文件暂存,然后使用git commit来提交合并。

$ git add file2

$ git commit -m "merge other to master"
[master 741c781] merge other to master

$ git log --graph --pretty=oneline --abbrev-commit
*   741c781 (HEAD -> master) merge other to master
|\
| * 7105a3a (other) commit file2 again
| * 854da83 commit file4
| * 54bd89c commit file3
* | 4e6d232 commit file2
|/
* 32ae382 commit file1

这样也就解决了冲突,并完成了合并。

处理合并冲突

上面的例子中提供了冲突的例子,但实际开发中可能有多个冲突文件,冲突的部分可能也有很多,此时就需要进行其它处理。

定位冲突的文件

Git可以对有问题的文件进行跟踪,并在索引中将之标记为冲突的(conflicted)或者未合并的(unmerged),因此可以使用git status来显示:

$ git status
On branch master
You have unmerged paths.
  (fix conflicts and run "git commit")
  (use "git merge --abort" to abort the merge)

Changes to be committed:
        new file:   file3
        new file:   file4

Unmerged paths:
  (use "git add ..." to mark resolution)
        both added:      file2

这里便将file2标记为unmerged,说明该文件是存在冲突的。

也可以使用git ls-files -u命令来显示工作目录中未合并的一组文件:

$ git ls-files -u
100644 acbe86c7c89586e0912a0a851bacf309c595c308 2       file2
100644 5f2f16bfff90e6620509c0cf442e7a3586dad8fb 3       file2

检查冲突

当冲突出现时,通过三方比较或合并标记可以对冲突进行检查:

$ cat file2
<<<<<<< HEAD
abcd
=======
1111
>>>>>>> other

也可以使用git dif命令:

$ git diff
diff --cc file2
index acbe86c,5f2f16b..0000000
--- a/file2
+++ b/file2
@@@ -1,1 -1,1 +1,5 @@@
++<<<<<<< HEAD
 +abcd
++=======
+ 1111
++>>>>>>> other

git diff是比较工作目录与索引的差异,但这里的比较好像并不是如此,因为其打印结果跟之前似乎有所不同。

这里的git diff命令是特殊的,特定于合并的变体,以同时显示针对两个父版本做的修改。上半部分对应HEAD的父版本,后半部分对应other分支的父版本。Git也给第二个父版本命名为MERGE_HEAD。

可以将工作目录和HEAD及MERGE_HEAD版本库进行比较:

$ git diff HEAD file2
diff --git a/file2 b/file2
index acbe86c..0f4a467 100644
--- a/file2
+++ b/file2
@@ -1 +1,5 @@
+<<<<<<< HEAD
 abcd
+=======
+1111
+>>>>>>> other

$ git diff MERGE_HEAD file2
diff --git a/file2 b/file2
index 5f2f16b..0f4a467 100644
--- a/file2
+++ b/file2
@@ -1 +1,5 @@
+<<<<<<< HEAD
+abcd
+=======
 1111
+>>>>>>> other

这样就清楚多了,用户只需要对上述的file2内容做修改,然后暂存,提交即可。

而git diff HEAD还有一种写法:

$ git diff --ours
* Unmerged path file2
diff --git a/file2 b/file2
index acbe86c..0f4a467 100644
--- a/file2
+++ b/file2
@@ -1 +1,5 @@
+<<<<<<< HEAD
 abcd
+=======
+1111
+>>>>>>> other

同时git diff MERGE_HEAD也还有一种写法:

$ git diff --theirs
* Unmerged path file2
diff --git a/file2 b/file2
index 5f2f16b..0f4a467 100644
--- a/file2
+++ b/file2
@@ -1 +1,5 @@
+<<<<<<< HEAD
+abcd
+=======
 1111
+>>>>>>> other

git diff命令的打印结果中,标记部分有两列,第一列表示活动分支,第二列表示other分支,++表示两个分支中都存在,+表示只有一个分支中存在。也就是说,git diff命令只显示仍然有冲突的部分。

而在解决冲突的过程中,可以使用一些特殊的git log选项来找出变更的确切来源和原因:

$ git log --merge --left-right -p
commit > cb56d13a485bf70d1104749d1261c70c7c4282e0 (other)
Author: wood_glb 
Date:   Sun Jun 26 19:49:36 2022 +0800

    commit file2 again

diff --git a/file2 b/file2
new file mode 100644
index 0000000..5f2f16b
--- /dev/null
+++ b/file2
@@ -0,0 +1 @@
+1111

commit < 097862d886a284294ffcd6bccf25d293f6dbf618 (HEAD -> master)
Author: wood_glb 
Date:   Sun Jun 26 19:49:28 2022 +0800

    commit file2

diff --git a/file2 b/file2
new file mode 100644
index 0000000..acbe86c
--- /dev/null
+++ b/file2
@@ -0,0 +1 @@
+abcd

上面的打印结果说明了冲突的来源,分别是other和master分支的某一次提交。在合并中两个分支都影响冲突的文件,上面的命令将会显示这两部分历史中的所有提交,并显示每次提交引入的实际变更。

上面命令的各个选项含义为:

  • --merge:只显示和产生冲突的文件相关的提交
  • --left-right:如果提交来自合并的活动分支就显示<,如果提交来自合并的other分支就显示>
  • -p:显示提交消息和每个提交相关联的补丁

git是如何追踪冲突的

git对冲突的追踪主要分为以下几个部分:

  • .git/MERGE_HEAD:包含合并进来的提交的散列值,任何时候提到MERGE_HEAD,Git都知道去查看哪个文件
  • .git/MERGE_MSG包含当解决冲突后执行git commit命令时用到的默认合并消息
  • Git的索引包含每个冲突文件的三个副本:合并基础,活动分支版本和other分支版本,并给三个版本分配了各自的编号1,2,3
  • 冲突的版本(合并标记和所有内容)不存储在索引中,相反存储在工作命令中的文件中

之前提到可以使用git ls-files来定位冲突的文件:

$ git ls-files -s
100644 8baef1b4abc478178b004d62031cf7fe6db6f903 0       file1
100644 acbe86c7c89586e0912a0a851bacf309c595c308 2       file2
100644 5f2f16bfff90e6620509c0cf442e7a3586dad8fb 3       file2
100644 00dedf6bd5f3e493ce8b03c889912f47b01297d4 0       file3
100644 0373d9336f8c8ee90faff225de842888e884a48b 0       file4

$ git ls-files -u
100644 acbe86c7c89586e0912a0a851bacf309c595c308 2       file2
100644 5f2f16bfff90e6620509c0cf442e7a3586dad8fb 3       file2

$ git cat-file -p acbe86c
abcd

$ git cat-file -p 5f2f16bf
1111

git ls_files的-s参数显示所有文件的各个阶段,-u参数则只关注冲突文件。而使用git cat-file命令则可以查看变体文件的内容。

结束解决冲突

如果冲突已经完全解决,并使用git add暂存,git commit提交后,该合并就结束了。

此时再使用git ls-files来查看文件是如何存储的:

$ git ls-files -s
100644 8baef1b4abc478178b004d62031cf7fe6db6f903 0       file1
100644 d1d06ad36a5841e8c06a90bd29707290d979cc2b 0       file2
100644 00dedf6bd5f3e493ce8b03c889912f47b01297d4 0       file3
100644 0373d9336f8c8ee90faff225de842888e884a48b 0       file4

 上面的打印中file2的暂存编号也为0,表示无冲突。

同时在git commit后,使用git show来查看这次合并提交:

$ git show
commit 9fb3688d8542f3b386e4352b1077993f523a953e (HEAD -> master)
Merge: 097862d cb56d13
Author: wood_glb 
Date:   Sun Jun 26 20:42:30 2022 +0800

    merge other to master

diff --cc file2
index acbe86c,5f2f16b..d1d06ad
--- a/file2
+++ b/file2
@@@ -1,1 -1,1 +1,1 @@@
- abcd
 -1111
++1111

当查看该合并提交时,应注意到:

  • 该提交第二行为Merge: 097862d cb56d13:通常在git log或git show中不显示父提交,因为一般只有一个父提交,并且一般都正好在日志中显示在后边,但是合并提交通常有多个父提交,因此会显示每个父提交的散列值
  • 自动生成的提交日志消息标注冲突的文件列表
  • 合并提交的差异不是一般的差异,它始终处于组合差异或者冲突合并的格式,合并提交的内容中只显示与合并分支不同的地方,而不是全部的区别

中止或重新启动合并

如果开始合并操作后,而由于某些原因需要中止操作,在合并提交执行最后的git commit命令前,使用如下命令:

$ git reset --hard HEAD
HEAD is now at a521623 commit file2

该命令可以将工作目录和索引都还原到git merge命令之前。

如果要中止或在其已经结束后放弃,使用以下命令:

$ git reset --hard ORIG_HEAD
HEAD is now at a521623 commit file2

在开始合并操作前,Git把活动分支的HEAD保存在ORIG_HEAD,这样上述命令就是合理的。

合并策略

这之后的内容对于实际开发可能用的不多,只是了解即可。

在复杂的项目开发过程中,如果合并的操作不合理,比如同一修改在不同分支之间来回合并,就会出现交叉合并的情况。因此为了应对某些复杂的情况,开发人员将问题普遍化,参数化并提出了可替代,可配置的合并策略来处理不同的情况。

退化合并

有两种导致合并的常见退化情况:

  • 已经是最新的(already up-to-date):当来自其它分支(HEAD)的所有提交都存在于目标分支上时,即使目标分支存在新提交,目标分支还是already up-to-date的,因此,没有新的提交添加到分支上。比如,在合并操作后,重复进行该合并请求,此时就会提示分支是already up-to-date的。
  • 快进的(fast-forward):当分支HEAD已经在其它分支中完全存在时,就会发生快进合并,由于HEAD已经存在于其它分支了,Git会简单把其它分支的新提交合并到HEAD上,然后Git移动分支HEAD来指向最终的新提交,并更新索引和工作目录

这两种情况下执行git merge都不会引入合并提交,以免出现新的问题。

常规合并

常规合并中的合并策略会产生一个最终提交,并添加到当前分支,表示合并的组合状态:

  • 解决(resolve):解决策略只操作两个分支,定位共同的祖先作为合并基础,然后执行一个直接的三方合并,通过对当前分支施加从合并基础到其它分支HEAD的变化。
  • 递归(recursive):这跟resolve策略相似,一次只能处理两个分支。然后该策略能够处理在两个分支间有多个合并基础的情况,此时Git会生成一个临时合并来包含所有相同的合并基础,然后以此为基础,通过一个普通的三方合并算法导出两个给定分支的最终合并,然后丢掉临时合并基础,并将最终合并状态提交到目标分支。
  • 章鱼(octputs):该策略专用于合并两个以上分支而设计,通过内部多次调用recursive策略来执行合并。但该策略并不能处理需要用户交互解决的冲突,此时需要进行一系列常规合并,一次解决一个冲突

比如之前的合并操作,就会显示合并策略。

$ git merge -m "merge other to master" master other
Merge made by the 'recursive' strategy.
 file3 | 1 +
 file4 | 1 +
 2 files changed, 2 insertions(+)
 create mode 100644 file3
 create mode 100644 file4

特殊提交

有两个特殊的策略:

  • 我们的(ours):该策略合并任何数量的其它分支,但其实际上丢弃其它分支的修改,而只使用当前分支的文件,合并结果和HEAD是相同的,但是任何其它分支也会记为父提交
  • 子树(subtree):子树策略合并到另一个分支,但是那个分支的一切会合并到当前树的一棵特定子树,不需要用户指定哪一棵子树,Git会自动决定

这两种策略都会产生一个最终提交,添加到当前分支中,代表合并的组合状态。

应用合并策略

  • 首先Git会尝试使用尽可能简单的算法,因此如果可以解决问题,Git会首先使用已经是最新的和快进策略来消除简单的情况。
  • 如果指定了多个其它分支合并到当前分支中,Git只能尝试章鱼策略,因为只有该策略能够一次合并多个分支
  • 如果上述尝试都不能解决问题,那么Git就会使用默认策略,最初resolve策略是Git使用的默认策略,而现在resursive策略成为了Git使用的默认策略

用户如果想要明确指定使用的合并策略,可以使用git merge的-s选项,不过一般情况下,如果对Git没有深刻的理解的话,不建议如此操作。

合并驱动程序

这里提到的每个合并策略都使用相关的合并驱动程序来解决和合并每个单独文件。一个合并驱动程序接受三个临时文件来表示共同祖先,目标分支版本和其它分支版本。而驱动程序通过修改目标分支得到合并结果。

存在几种内置的合并驱动程序:

  • 文本(text)合并驱动程序会留下三方合并的标记<<<<<<<<,========,>>>>>>>>
  • 二进制(binary)合并驱动程序简单地保留文件的目标分支版本,在索引中把文件标记为冲突的
  • 联合(union)合并驱动程序则简单地把两个版本的所有行留在合并后的文件中

大多数文本文件被text驱动程序处理,大多数二进制文件被binary驱动程序处理,特殊需求则可以指定自定义合并驱动程序。

压制合并

如果分支other包含了很多提交,在某些系统中,将other合并到master分支会产生一个差异,并将其当作一个单独的patch打到master,然后在历史中创建一个新元素,这就是所谓的压制提交。

压制提交会将所有的单独提交压制为一个大修改,然后应用到目标分支上,这样就只需关心目标分支的历史记录,而other分支的历史记录将会丢失。

在Git中,可以根据需要进行压制提交,这需要借助git merge的--squash选项。但是使用该选项要考虑到可能出现的后果,将所有提交进行压制意味着以后如果需要分离之前的提交就会变得非常麻烦,因为提交历史记录消失了。

你可能感兴趣的:(Git,git,github,git,merge,conflict,unmerged)