Git分支合并
Git分支合并
之前用Git做版本管理的时候,总是始终在一个main分支上提交,commit message混乱,也不写单元测试,给后续的查看和修改带来了很多困难。这次同步写了单元测试,也尝试为不同的模块建了不同的分支来工作,又带来了分支过多以及合并引起的新的混乱。不过不论如何,开始一个工作的关键就是不管好坏先做起来..
不搞争论,是我的一个发明。不争论,是为了争取时间干。一争论就复杂了,把时间都争掉了,什么也干不成。
在大型项目中,多个开发人员通常需要同时进行不同的任务。通过创建分支,每个人可以在独立的分支上进行工作,而不会影响彼此的代码,这样可以实现并行开发,提高开发效率。即使是一个人开发,当进行一些新特性开发或者实验性更改的时候,可能会引入一些错误。在独立的分支上进行这些更改,可以保证主分支的代码库保持稳定。当一个新特性在分支上开发完了,我们需要把这些改动合并到主分支上,就需要用到git merge
。
首先需要理解一下Git是怎么进行版本管理的。每当我们开发完一个子功能,比如在原有的代码库上修改了一些文件增加了几个函数,我们就可以提交(commit)这次代码变更。在 Git 中,一个提交表示了一次代码变更的快照,包括了文件的内容和目录结构的状态。每个提交有一个唯一的哈希值,就像Mac地址一样,使我们能够定位到这次提交的位置。每个提交就像一个节点一样,我们回退到某个节点,就可以获得这个节点时的代码库,并且查看这次提交时我们撰写的提交信息,比如这次提交增加了哪些功能。这些提交的节点网络最终构成了我们项目的版本历史,我们可以清楚地看到整个代码的发展过程,追踪功能的添加和修改。
所以当我们建立了对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 | c62cd21 (HEAD -> A, origin/A) add A-2.txt |
每一行代表一个提交节点,第一列是这次提交哈希值的前几位,作为这次提交的代号。第二个的括号里表示这次提交所属的本地分支和远程分支,HEAD
是一个指针,它指向当前所在分支的最新提交节点,在这里就指向了我们新建A-2.txt
这个提交节点。在A
分支里现在共有三个提交节点,最早的就是master
分支的初始提交节点,我们从这个节点引出来了A
分支,之后又在A
分支进行了两次提交。
master
分支现在只有最初的提交节点:
1 | 5dec4cf (HEAD -> master, origin/master) add root.txt |
现在我们在主分支上合并特性分支git merge A
,
1 | Updating 5dec4cf..c62cd21 |
可以发现Git自动进行了快进合并,此时master
分支合并了A
分支的所有提交,最新提交节点和A
分支的提交一样了,哈希值都是c62cd21
。
1 | c62cd21 (HEAD -> master, origin/A, A) add A-2.txt |
递归(Recursive)
如果master
主分支提交了更改,而特性分支A
也提交了更改,合并特性分支A
到 master
分支时,Git 会在合并时会分别向前查找这两个分支的提交节点,找到它们相同的共同祖先节点,并合并两个分支的更改。这种情况下,递归策略会被使用来执行合并操作,确保主分支与特性分支的变更被合理地整合在一起。此时主分支不光拥有A
分支的所有提交节点,还会额外产生一个新的合并提交节点。这也是非常容易理解的,因为合并后的节点和现有的任何节点都不一样。
我们在上面例子中的master
分支执行git reset --hard 5dec4cfbdd16aa84de90305b2da91bf53233283d
,回退到主分支还没有合并的情况。之后,我们主分支上新建一个root-1.txt
文件,并进行一次提交,这时我们的主分支和特性分支都有了各自不一样的提交。
主分支:
1 | 69e69bc (HEAD -> master) add root-1.txt |
特性分支:
1 | c62cd21 (HEAD -> A, origin/A) add A-2.txt |
这时候我们在主分支上合并A
分支git merge A
,输出为
1 | Merge made by the 'recursive' strategy. |
合并后主分支包含的结点变成了:
1 | d1d44dc (HEAD -> master) Merge branch 'A' |
此时主分支拥有按时间顺序排列的所有的主分支提交结点和A
分支提交结点,同时还多了一个最新的提交结点。我们在主分支上也可以回退到A
分支的结点,比如如果回退到126f271
结点,这时代码库内容就其实和A
分支的对应提交结点时一样的。
如果我们的69e69bc
次提交不光增加了root-1.txt
,还增加了A-2.txt
,并在里面写入master分支写的A-2
,这时我们我们在尝试git merge A
的时候,Git就会提醒我们出现了A-2.txt
中合并冲突:
1 | CONFLICT (add/add): Merge conflict in A-2.txt |
此时A-2.txt
里面的内容变成了:
1 | <<<<<<< HEAD |
其中
<<<<<<< HEAD
:这个标记之后的部分是当前分支(主分支,即HEAD
所指的分支)的更改内容。在这个标记之后的部分是主分支上的内容,表示你在主分支上对同一部分进行了修改。=======
:这个标记表示冲突的分隔线,分隔了主分支和其他分支(在这里是特性分支 A)的更改内容。>>>>>>> A
:这个标记之后的部分是其他分支(在这里是特性分支 A)的更改内容。
这时候需要我们手动解决冲突,删除冲突标记之后,决定我们想留在最新合并节点内部A-2.txt
内部的内容。比如我们把A-2.txt
的内容改成:
1 | 主分支和A分支写的A-2 |
之后,我们使用 git add
命令将编辑后的文件标记为已解决冲突,并将其添加到暂存区。然后使用 git commit
命令提交解决冲突的文件。
1 | git add A-2.txt |
这时候master
分支所拥有的结点变成了5个,包括主分支的两个提交结点,A
分支的两个提交结点,以及主分支上解决冲突之后的合并结点。
1 | 2da14f7 (HEAD -> master) 解决冲突:合并主分支和特性分支的更改 |
实际上这里的解决冲突的意思并不是同时保留两个分支的内容,而是只是决定这个提交结点内的冲突应该如何解决,我们也可以直接删除掉A-2.txt
文件,如果它对程序的功能没有什么影响。
1 | 8cf1cb1 (HEAD -> master) 直接删除A-2.txt文件来解决冲突 |
压缩(squash)
在上面的merge
操作中,我们发现主分支合并子分支之后,拥有了子分支的全部历史提交结点,这就像是两条贪吃蛇,主分支把另一条蛇合并进来以后,另一条蛇的每一段都长在了自己的身上。现在我们想将子分支的多个提交合并成一个单一的提交,合并到主分支来。这种方式通常用于保持主分支的提交历史整洁和易于理解,尤其是当你在特性分支上进行多次提交时,希望将这些提交合并成一个有意义的整体提交。我们可以在merge
上加上--squash
参数,
1 | git merge --squash A |
这时主分支上就并不会合并出来A
分支的历史结点,而只会创建一个新的合并结点:
1 | 1381f29 (HEAD -> master) squash 合并并解决A-2.txt中的冲突 |
但不幸的是,假如我们这次采用--squash
合并并解决了A-2.txt
中的冲突,如果我们再次执行正常合并git merge A
,整个合并过程还会再来一次,而且A-2.txt
还是会和A
分支中的最新提交产生冲突,我们还需要再次解决冲突,这时这个1381f29
其实就不被理解成为合并了,而是master
分支上一次单独的提交。
1 | 93ab45a (HEAD -> master) 又合并一次... |
merge与rebase
所以从上面的讨论可以看到,git merge
是一个安全的操作。它不会改写历史提交结点,而只会在当前分支里加入另一个分支的历史结点,或者在当前分支最新结点之前再增加一个新的合并结点。用图来描述merge
的过程,假如我们的初始提交结点状态是
1 | A---B---C (main) |
现在我们merge
之后,所有的历史结点都不会发生改变,只是会在main
分支最上面增加一个合并结点,
1 | A---B---C------F (main) |
但我们如果在feature
分支上使用rebase
操作,整个历史提交记录会变成,
1 | A---B---C (main) |
此时Git 会尝试将 feature
分支上从分叉点(这里是 B
)开始的所有提交(D
, E
), 移至main
分支的最末端,这个操作会更改我们的历史提交结点,此时我们需要为所有提交结点(D
, E
)解决他们可能与main
分支上的C
结点发生的冲突。这是个相对危险的操作,但也会让这个分支的提交记录显得更加简洁,好像feature
分支是从C
结点开始分叉的。此后,可以通过快进合并安全地将 feature
分支的更改合并到 main
分支。
1 | A---B---C---D'--E' (main) |