Git分支合并

Git分支合并

之前用Git做版本管理的时候,总是始终在一个main分支上提交,commit message混乱,也不写单元测试,给后续的查看和修改带来了很多困难。这次同步写了单元测试,也尝试为不同的模块建了不同的分支来工作,又带来了分支过多以及合并引起的新的混乱。不过不论如何,开始一个工作的关键就是不管好坏先做起来..

不搞争论,是我的一个发明。不争论,是为了争取时间干。一争论就复杂了,把时间都争掉了,什么也干不成。


在大型项目中,多个开发人员通常需要同时进行不同的任务。通过创建分支,每个人可以在独立的分支上进行工作,而不会影响彼此的代码,这样可以实现并行开发,提高开发效率。即使是一个人开发,当进行一些新特性开发或者实验性更改的时候,可能会引入一些错误。在独立的分支上进行这些更改,可以保证主分支的代码库保持稳定。当一个新特性在分支上开发完了,我们需要把这些改动合并到主分支上,就需要用到git merge

首先需要理解一下Git是怎么进行版本管理的。每当我们开发完一个子功能,比如在原有的代码库上修改了一些文件增加了几个函数,我们就可以提交(commit)这次代码变更。在 Git 中,一个提交表示了一次代码变更的快照,包括了文件的内容和目录结构的状态。每个提交有一个唯一的哈希值,就像Mac地址一样,使我们能够定位到这次提交的位置。每个提交就像一个节点一样,我们回退到某个节点,就可以获得这个节点时的代码库,并且查看这次提交时我们撰写的提交信息,比如这次提交增加了哪些功能。这些提交的节点网络最终构成了我们项目的版本历史,我们可以清楚地看到整个代码的发展过程,追踪功能的添加和修改。

image-20230902163739862

所以当我们建立了对Git版本管理——提交节点作为基本单元的图像之后,就可以更加轻松地理解Git的各种命令了。Git的各种操作,如分支(branch)、合并(merge)、重置(reset)、变基(rebase)等,实际上都是在处理提交节点。在底层,Git以提交作为基本单位来管理和跟踪代码的变化。当我们想理解一个命令的时候,只要理解了这个命令对节点做了些什么,整个过程就会变得比较清楚。所以从这个角度来看,一个分支实际上就是一个节点串,当我们在一个分支合并另一个分支的时候,只需要看看合并之后这个分支的节点情况发生了什么变动,就可以清楚地理解合并操作到底在做什么了。我们可以以两个分支master分支和A分支为例,分情况讨论一下。

merge

快进(Fast-forward)

如果 master 分支自从 feature 分支创建以来没有新的提交,Git 默认会进行快进操作。这种情况下,master 分支简单地“快进”到 A 分支的最新提交节点,不会创建新的 merge 提交节点。

我们首先初始化git,然后在master分支上增加一个root.txt文件并提交。接下来,我们在这个提交节点上新建A分支,并新建A-1.txt,进行一次提交。继续新建A-2.txt,往里面写入A分支写的A-2再进行一次提交。此时,我们在A分支的提交记录是这样的:

1
2
3
c62cd21 (HEAD -> A, origin/A) add A-2.txt
126f271 add A-1.txt
5dec4cf (origin/master, master) add root.txt

每一行代表一个提交节点,第一列是这次提交哈希值的前几位,作为这次提交的代号。第二个的括号里表示这次提交所属的本地分支和远程分支,HEAD是一个指针,它指向当前所在分支的最新提交节点,在这里就指向了我们新建A-2.txt这个提交节点。在A分支里现在共有三个提交节点,最早的就是master分支的初始提交节点,我们从这个节点引出来了A分支,之后又在A分支进行了两次提交。

master分支现在只有最初的提交节点:

1
5dec4cf (HEAD -> master, origin/master) add root.txt

现在我们在主分支上合并特性分支git merge A

1
2
3
4
5
6
7
Updating 5dec4cf..c62cd21
Fast-forward
A-1.txt | 0
A-2.txt | 0
2 files changed, 0 insertions(+), 0 deletions(-)
create mode 100644 A-1.txt
create mode 100644 A-2.txt

可以发现Git自动进行了快进合并,此时master分支合并了A分支的所有提交,最新提交节点和A分支的提交一样了,哈希值都是c62cd21

1
2
3
c62cd21 (HEAD -> master, origin/A, A) add A-2.txt
126f271 add A-1.txt
5dec4cf (origin/master) add root.txt

递归(Recursive)

如果master主分支提交了更改,而特性分支A也提交了更改,合并特性分支Amaster 分支时,Git 会在合并时会分别向前查找这两个分支的提交节点,找到它们相同的共同祖先节点,并合并两个分支的更改。这种情况下,递归策略会被使用来执行合并操作,确保主分支与特性分支的变更被合理地整合在一起。此时主分支不光拥有A分支的所有提交节点,还会额外产生一个新的合并提交节点。这也是非常容易理解的,因为合并后的节点和现有的任何节点都不一样。

我们在上面例子中的master分支执行git reset --hard 5dec4cfbdd16aa84de90305b2da91bf53233283d,回退到主分支还没有合并的情况。之后,我们主分支上新建一个root-1.txt文件,并进行一次提交,这时我们的主分支和特性分支都有了各自不一样的提交。

主分支:

1
2
69e69bc (HEAD -> master) add root-1.txt
5dec4cf (origin/master) add root.txt

特性分支:

1
2
3
c62cd21 (HEAD -> A, origin/A) add A-2.txt
126f271 add A-1.txt
5dec4cf (origin/master) add root.txt

这时候我们在主分支上合并A分支git merge A,输出为

1
2
3
4
5
6
Merge made by the 'recursive' strategy.
A-1.txt | 0
A-2.txt | 0
2 files changed, 0 insertions(+), 0 deletions(-)
create mode 100644 A-1.txt
create mode 100644 A-2.txt

合并后主分支包含的结点变成了:

1
2
3
4
5
d1d44dc (HEAD -> master) Merge branch 'A'
69e69bc add root-1.txt
c62cd21 (origin/A, A) add A-2.txt
126f271 add A-1.txt
5dec4cf (origin/master) add root.txt

此时主分支拥有按时间顺序排列的所有的主分支提交结点和A分支提交结点,同时还多了一个最新的提交结点。我们在主分支上也可以回退到A分支的结点,比如如果回退到126f271结点,这时代码库内容就其实和A分支的对应提交结点时一样的。

如果我们的69e69bc次提交不光增加了root-1.txt,还增加了A-2.txt,并在里面写入master分支写的A-2,这时我们我们在尝试git merge A的时候,Git就会提醒我们出现了A-2.txt中合并冲突:

1
2
3
CONFLICT (add/add): Merge conflict in A-2.txt
Auto-merging A-2.txt
Automatic merge failed; fix conflicts and then commit the result.

此时A-2.txt里面的内容变成了:

1
2
3
4
5
<<<<<<< HEAD
主分支写的A-2
=======
A分支写的A-2
>>>>>>> A

其中

  • <<<<<<< HEAD:这个标记之后的部分是当前分支(主分支,即 HEAD 所指的分支)的更改内容。在这个标记之后的部分是主分支上的内容,表示你在主分支上对同一部分进行了修改。
  • =======:这个标记表示冲突的分隔线,分隔了主分支和其他分支(在这里是特性分支 A)的更改内容。
  • >>>>>>> A:这个标记之后的部分是其他分支(在这里是特性分支 A)的更改内容。

这时候需要我们手动解决冲突,删除冲突标记之后,决定我们想留在最新合并节点内部A-2.txt内部的内容。比如我们把A-2.txt的内容改成:

1
主分支和A分支写的A-2

之后,我们使用 git add 命令将编辑后的文件标记为已解决冲突,并将其添加到暂存区。然后使用 git commit 命令提交解决冲突的文件。

1
2
git add A-2.txt
git commit -m "解决冲突:合并主分支和特性分支的更改"

这时候master分支所拥有的结点变成了5个,包括主分支的两个提交结点,A分支的两个提交结点,以及主分支上解决冲突之后的合并结点。

1
2
3
4
5
2da14f7 (HEAD -> master) 解决冲突:合并主分支和特性分支的更改
1622745 add root-1 and A-2
2d0e1c6 (A) add A-2.txt
126f271 add A-1.txt
5dec4cf (origin/master) add root.txt

实际上这里的解决冲突的意思并不是同时保留两个分支的内容,而是只是决定这个提交结点内的冲突应该如何解决,我们也可以直接删除掉A-2.txt文件,如果它对程序的功能没有什么影响。

1
2
3
4
5
8cf1cb1 (HEAD -> master) 直接删除A-2.txt文件来解决冲突
1622745 add root-1 and A-2
2d0e1c6 (A) add A-2.txt
126f271 add A-1.txt
5dec4cf (origin/master) add root.txt

压缩(squash)

在上面的merge操作中,我们发现主分支合并子分支之后,拥有了子分支的全部历史提交结点,这就像是两条贪吃蛇,主分支把另一条蛇合并进来以后,另一条蛇的每一段都长在了自己的身上。现在我们想将子分支的多个提交合并成一个单一的提交,合并到主分支来。这种方式通常用于保持主分支的提交历史整洁和易于理解,尤其是当你在特性分支上进行多次提交时,希望将这些提交合并成一个有意义的整体提交。我们可以在merge上加上--squash参数,

1
git merge --squash A

这时主分支上就并不会合并出来A分支的历史结点,而只会创建一个新的合并结点:

1
2
3
1381f29 (HEAD -> master) squash 合并并解决A-2.txt中的冲突
1622745 add root-1 and A-2
5dec4cf (origin/master) add root.txt

但不幸的是,假如我们这次采用--squash合并并解决了A-2.txt中的冲突,如果我们再次执行正常合并git merge A,整个合并过程还会再来一次,而且A-2.txt还是会和A分支中的最新提交产生冲突,我们还需要再次解决冲突,这时这个1381f29其实就不被理解成为合并了,而是master分支上一次单独的提交。

1
2
3
4
5
6
93ab45a (HEAD -> master) 又合并一次...
1381f29 squash 合并
1622745 add root-1 and A-2
2d0e1c6 (A) add A-2.txt
126f271 add A-1.txt
5dec4cf (origin/master) add root.txt

merge与rebase

所以从上面的讨论可以看到,git merge 是一个安全的操作。它不会改写历史提交结点,而只会在当前分支里加入另一个分支的历史结点,或者在当前分支最新结点之前再增加一个新的合并结点。用图来描述merge的过程,假如我们的初始提交结点状态是

1
2
3
4
A---B---C  (main)
\
D---E (feature)

现在我们merge之后,所有的历史结点都不会发生改变,只是会在main分支最上面增加一个合并结点,

1
2
3
4
A---B---C------F  (main)
\ /
D---E (feature)

但我们如果在feature分支上使用rebase操作,整个历史提交记录会变成,

1
2
3
4
A---B---C  (main)
\
D'--E' (feature)

此时Git 会尝试将 feature 分支上从分叉点(这里是 B)开始的所有提交(D, E), 移至main 分支的最末端,这个操作会更改我们的历史提交结点,此时我们需要为所有提交结点(D, E)解决他们可能与main分支上的C结点发生的冲突。这是个相对危险的操作,但也会让这个分支的提交记录显得更加简洁,好像feature分支是从C结点开始分叉的。此后,可以通过快进合并安全地将 feature 分支的更改合并到 main 分支。

1
A---B---C---D'--E'  (main)