理解三路合并
Git 在分支合并时,采用三路合并的方法。不管合并的两个分支包含多少提交,Git 合并操作只关心代码的三个版本,即:合并双方的两个版本和一个基线版本。
看下我们的示例程序,使用 Go 语言开发,执行结果如下:
$ git clone https://github.com/jiangxin/git-merge-demo.git $ cd git-merge-demo $ git checkout demo-1/user-1 -- $ go build -o demo $ ./demo Topics: ♠ ♥ ♥ ♣
该示例程序输出一些符号,每行输出同一种符号,代表了某一个特性,符号个数代表该特性功能上的异同。
我们可以看到 “demo-1/user-1” 分支上,“demo” 程序有三个特性。特性 “♠”(一颗)、特性 “♥”(两颗)、特性 “♣”(一颗)。
同样我们在 “demo-1/user-2” 分支上会看到不同的特性输出:
♠ ♠ ♠ ♥ ♥ ♥ ♦ ♦
这两个分支各自包含三个特性。如果要将这两个分支合并在一起,那么 “demo-1/user-1” 分支上独有的 “♣” 特性应该出现在最终的合并结果中么?“demo-1/user-2” 分支上独有的 “♦” 特性应该出现在最终的合并结果中么?对于双方共同拥有的 “♠” 特性在合并结果中是出现一次,还是重复出现三次呢?
仅从上面这两个分支信息,我们很难推导出合理的合并结果。这时必须要考虑一个问题:这两个分支的特性是如何演变来的。我们要寻找这两个分支的共同的祖先提交,即寻找基线。
借助 git merge-base 命令,我们可以看到基线提交编号是 228ec07:
$ git merge-base --all demo-1/user-1 demo-1/user-2 228ec07e07ac83295b96be1ad55adfbd0c870f74
注意:上面命令中的 –all 参数会显示两个分支所有的基线提交,可能的结果有:
- 0 条基线。即两个分支没有重叠,没有公共的历史提交。
- 1 条基线。大多数合并场景两个分支存在一条基线。
- 多条基线。可能的场景有:1、两条分支都是集成分支,都接受来自各个特性分支的合入。2、特性分支之间存在依赖关系,复杂的特性分支和集成分支之间可能存在多条基线。
本例只有一条集成分支。从下面的 git-log 命令可以看出本例的两个分支提交历史还是很简单的,两个分支各自独有的提交标记为 “+” 和 “x”,共有的提交标记为星号。从这两个分支提交历史的重叠部分,我们很容易看出来重叠的顶端提交 228ec07 即是我们要找的基线提交。
$ git log --graph --oneline demo-1/user-1 demo-1/user-2 + 34b76a2 (demo-1/user-2) Topic 4: ♦ ♦ + dfdce61 Remove topic 3 + 68bc440 Topic 2: ♥ ♥ ♥ | x 55e002f (demo-1/user-1) Topic 1: ♠ |/ * 228ec07 (demo-1/base) Topic 3: ♣ * d1f0d8c Topic 2: ♥ ♥ * 8f19971 Topic 1: ♠ ♠ ♠ * bfdeca2 Demo for git 3-way merge
切换到基线提交 228ec07 上执行看看:
$ git checkout 228ec07 -- $ make Topics: ♠ ♠ ♠ ♥ ♥ ♣
下图综合了该程序三个版本,那么合并版本是不是呼之欲出了呢?
基线版本 User-1 版本 User-2 版本 合并版本 ======== ============= ============= =========== ♠ ♠ ♠ ♠ ♠ ♠ ♠ ? ♥ ♥ ♥ ♥ ♥ ♥ ♥ ? ♣ ♣ ? ♦ ♦ ?
对于特性 ♠ ,相比基线版本,User-1将其修改为一颗,User-2 没有修改;对于特性♥,相比基线,User-1没有修改,User-2将其修改为三颗;对于特性♣,User-1没有修改,User-2将其删除;而对于特性♦,是由 User-2 新引入的特性。
让我们执行命令来看一下真实的合并结果吧:
$ git checkout demo-1/user-1 $ git merge demo-1/user-2 $ make Topics: ♠ ♥ ♥ ♥ ♦ ♦
Revert 操作引发的“异常”合并结果
下面我们来看一个“异常”的合并。
在分支 “demo-2/topic-1” 运行,显示如下:
$ git checkout demo-2/topic-1 $ make ◉ ◉ ◉ ▲ ▲ △ △ △
其中特性 ◉ 是主干 “demo-2/master” 引入的特性。Topic-1 引入两个子特性: ▲ 和 △ 。
在分支 “demo-2/topic-2” 运行,显示如下:
$ git checkout demo-2/topic-2 $ make ◉ ◉ ◉ ♡ ♡ ♡
在主干分支 “demo-2/master” 运行,显示如下:
$ git checkout demo-2/master $ make ◉ ◉ ◉ ◉ ♡ ♡ ♡
我们可以看出主干分支的特性 ◉ 演进了,而且已经包含了 “demo-2/topic-2” 分支的特性。
现在将 “demo-2/topic-1” 分支合并到主干分支 “demo-2/master”,看看合并解决是否符合预期:
$ git checkout demo-2/master $ git merge demo-2/topic-1 $ make ◉ ◉ ◉ ◉ △ △ △ ♡ ♡ ♡
看出问题了么?“demo-2/topic-1” 分支的特性没有全部合入,只合入了特性 △ ,而丢失特性 ▲ !
让我们来分析一下:
- 先撤回刚刚的合并提交。
$ git reset --hard HEAD^
- 计算合并基线:
$ git merge-base --all demo-2/topic-1 demo-2/master 5db8ab7c5c1d2ede82096ae890446c290a664060
- 切换到这个基线版本:
$ git checkout 5db8ab7c5c1d2ede82096ae890446c290a664060 -- $ make ◉ ◉ ◉ ▲ ▲
- 基线版本有特性 ▲ ,分支 “demo-2/topic-1” 也有特性 ▲ ,但是主干分支 “demo-2/master” 上并没有特性 ▲。按照三路合并的原理分析,我们看出合并结果丢失特性 ▲ 是预料中的:
基线 5db8ab7 demo-2/master demo-2/topic-1 合并版本 ============ ============= ============== ======== ◉ ◉ ◉ ◉ ◉ ◉ ◉ ◉ ◉ ◉ ◉ ◉ ◉ ◉ ▲ ▲ ▲ ▲ △ △ △ △ △ △ ♡ ♡ ♡ ♡ ♡ ♡
- 下面命令用于查询基线 5db8ab7 之后主干分支的变更,看看哪个提交删除了特性 ▲ :
$ git log --stat --oneline 5db8ab7..demo-2/master .... .... e50fc0b Topic 1 is not complete, and is not ready for merge topic/topic-1.1.go | 20 -------------------- 1 file changed, 20 deletions(-) .... ....
从上面的日志输出,我们看到主干分支 “demo-2/master” 上的一个可疑提交 e50fc0b。这个提交删除了文件 “topic-1.1.go”。提交说明表明“因为当时 topic1 功能尚未完整,故撤回不完整的合并提交”。
如何才能将 “demo-2/topic-1” 特性完整地合入呢?采用如下操作:
- 对提交 e50fc0b 再次执行一次撤回操作。
$ git checkout demo-2/master -- $ git revert e50fc0b
- 然后再合并分支 “demo-2/topic-1”。
$ git merge demo-2/topic-1
- 执行程序,查看合并效果。
$ make ◉ ◉ ◉ ◉ ▲ ▲ △ △ △ ♡ ♡ ♡
操作完成,我们看到 “demo-2/topic-1” 分支完整的特性都合入了。
“脏合并”引发的“异常”
下面我们来看另外一个“异常”的合并。
在当前版本的开发分支 “demo-3/master” 运行,显示如下:
$ git checkout demo-3/master $ make ◉ ◉ ◉ ◉ ◉ ♠ ♠ ♠ ♠ ♦ ♦ ♦
其中特性 ◉ 是主干 “demo-3/master” 引入的特性。其余两个特性 ♠ 和 ♦ 是从相应的特性分支合入的。
接下来,在下一个版本的开发分支 “demo-3/next” 中运行,显示如下:
$ git checkout demo-3/next $ make ❍ ❍ ❍ ❍ ♠ ♠ ♠ ♠ ♥ ♥ ♦ ♦ ♦
其中特性 ❍ 是新版本主干 “demo-3/next” 引入的特性。其余三个特性 ♠、♥ 和 ♦ 是从相应的特性分支合入的。
现在要将当前版本 “demo-3/master” 合入到下一个新版本的开发分支 “demo-3/next” 中,运行如下:
$ git checkout demo-3/next $ git merge demo-3/master $ make ◉ ◉ ◉ ◉ ◉ ❍ ❍ ❍ ❍ ♠ ♠ ♠ ♠ ♦ ♦ ♦
我们看到合并后,“demo-3/master” 分支的特性 ◉ 被引入了,但是 “demo-3/next” 分支原有的特性 ♥ 被删除了。如果特性 ♥ 是需要的,那么合并后为何会丢失特性 ♥ 呢?
我们按照三路合并的原理来分析一下。
首先查看下分支 “demo-3/master” 以及分支 “demo-3/next” 合并前提交(即 “demo-3/next~1”)的基线:
$ git merge-base --all demo-3/master demo-3/next~1 e54a597938d7aaa20c8b3ce79d0b6c5bca8404e7 6b57153213757421f83523d1b03f7743aa654160 b2c844810a095a4e05763ffaff326101e61d444f
惊奇的发现,基线居然有三条!这实际上是三个特性分支分别向 “demo-3/master” 和 “demo-3/next” 分支合入后的结果。
我们使用命令 git log –graph –oneline demo-3/master demo-3/next~1 查看合并双方的提交。如果只显示两个分支重合的部分,如下图所示:
* 6b57153 (demo-3/topic-3) Topic 3: ♦ ♦ ♦ * f1dca57 Topic 3: ♦ / / | | * b2c8448 (demo-3/topic-2) Topic 2: ♥ ♥ | * 60f846d Topic 2: ♥ |/ | | * e54a597 (demo-3/topic-1) Topic 1: ♠ ♠ ♠ ♠ | * 3ccbd75 Topic 1: ♠ ♠ | * 18ea90f Topic 1: ♠ |/ | | * bfdeca2 (tag: v0) Demo for git 3-way merge
会看到有三个端点对应三条基线。
那么对于多基线场景,如何来分析呢?
Git 会将多条基线合并为一个提交,再将这个提交作为唯一的基线,参与到三路合并中。
$ git checkout -b demo-3/base e54a597 $ git merge 6b57153 b2c8448 $ make ♠ ♠ ♠ ♠ ♥ ♥ ♦ ♦ ♦
列出下列表格分析三路合并结果:
基线 base master 分支 next 分支 合并结果 ========= ============ ========== ========== ◉ ◉ ◉ ◉ ◉ ◉ ◉ ◉ ◉ ◉ ❍ ❍ ❍ ❍ ❍ ❍ ❍ ❍ ♠ ♠ ♠ ♠ ♠ ♠ ♠ ♠ ♠ ♠ ♠ ♠ ♠ ♠ ♠ ♠ ♥ ♥ ♥ ♥ ♦ ♦ ♦ ♦ ♦ ♦ ♦ ♦ ♦ ♦ ♦ ♦
从上面表格可以看出来,特性 ♥ 在基线中存在,在 “demo-3/next” 分支中也存在,但是被 “demo-3/master” 中删除了。所以导致合并结果中缺失了特性 ♥ 。
那么 “demo-3/master” 分支是如何在基线 “demo-3/base” 之后删除了特性 ♥ 的呢?
在一段提交历史中找出一个有问题的版本有很多办法,例如使用 git bisect 二分查找命令。不过这里使用 git log 命令就足够了。
使用如下命令查看这段历史中非合并提交:
$ git log --oneline --stat --no-merges demo-3/base..demo-3/master 05f2ec3 (origin/demo-3/master, demo-3/master) Topic master: ◉ ◉ ◉ ◉ ◉ topic/master.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 5daade4 Topic master: ◉ ◉ ◉ ◉ topic/master.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 6325a3f Topic master: ◉ ◉ ◉ topic/master.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+)
看到非合并提交都是在修改特性 ◉ 的,和特性 ♥ 无关。
那么问题一定出在合并提交中。不过使用下面命令看不出来合并提交包含了哪些修改,你知道为什么吗?
$ git log --oneline --stat --merges demo-3/base..demo-3/master 3a69a8a Merge branch 'demo-3/topic-3' into demo-3/master 37544d1 Merge branch 'demo-3/topic-2' into demo-3/master d872d8f Merge branch 'demo-3/topic-1' into demo-3/master
合并提交是将两个或多个提交合并在一起,因此合并提交有两个以上的父提交。合并的结果要么选择合并的某一方的版本,要么和任何一方版本都不一样,综合了各方版本的修改。
使用 git log -p 或者 git log –stat 显示提交差异,默认是不会显示合并提交差异的。Git 提供以下三个参数改变 git log 或 git diff 的行为,显示合并提交包含的差异。分别是:
- 参数 -m:分别显示合并提交和每一个父提交的差异。如果合并有两个父提交,则分别显示两个差异比较的结果。
- 参数 -c:将合并提交和各个父提交的差异合并在一起显示。如果和某个父提交相同,则不显示。
- 参数 –cc:类似 -c,进一步精简补丁的显示,忽略和某一方相同的补丁的显示。
对于本例,使用 -m 参数,执行结果如下:
$ git log --oneline -m --stat --merges demo-3/base..demo-3/master 3a69a8a (from 37544d1) Merge branch 'demo-3/topic-3' into demo-3/master topic/3.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 3a69a8a (from 6b57153) Merge branch 'demo-3/topic-3' into demo-3/master topic/1.go | 19 +++++++++++++++++++ topic/master.go | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) 37544d1 (from b2c8448) Merge branch 'demo-3/topic-2' into demo-3/master topic/1.go | 19 +++++++++++++++++++ topic/2.go | 19 ------------------- topic/master.go | 19 +++++++++++++++++++ 3 files changed, 38 insertions(+), 19 deletions(-) d872d8f (from 6325a3f) Merge branch 'demo-3/topic-1' into demo-3/master topic/1.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) d872d8f (from e54a597) Merge branch 'demo-3/topic-1' into demo-3/master topic/master.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+)
从输出中我们看到合并提交 37544d1 删除了 topic/2.go 文件。经查这个提交就是导致特性 ♥ 丢失的罪魁祸首。
$ git log -1 37544d1 commit 37544d18c2da92a265acd6a7a8145bed4ba51bd8 Merge: 5daade4 b2c8448 Author: Jiang Xin <zhiyou.jx@alibaba-inc.com> Date: Sun Mar 15 19:53:56 2020 +0800 Merge branch 'demo-3/topic-2' into demo-3/master * demo-3/topic-2: Topic 2: ♥ ♥ Topic 2: ♥ Signed-off-by: Jiang Xin <zhiyou.jx@alibaba-inc.com>
如何解决合并 demo-3/master 分支丢失特性 ♥ 的问题呢?
我们需要重新执行一次和提交 37544d1 类似的合并:
$ git checkout 5daade4 -- $ git merge b2c8448 $ make ◉ ◉ ◉ ◉ ♠ ♠ ♠ ♠ ♥ ♥
我们看合并的结果中出现了特性 ♥。
然后再和错误的提交 37544d1 做一次合并,并使用当前提交中的内容。
$ git merge -s ours 37544d1 $ make ◉ ◉ ◉ ◉ ♠ ♠ ♠ ♠ ♥ ♥
记下这个合并提交。
$ git tag -m "fixed merge" fixed-merge
切换到 “demo-3/next” 分支,合并这个刚刚创建好的正确的提交。
$ git checkout demo-3/next -- $ git merge fixed-merge $ make ◉ ◉ ◉ ◉ ◉ ❍ ❍ ❍ ❍ ♠ ♠ ♠ ♠ ♥ ♥ ♦ ♦ ♦
完美的合并结果。
建议
- 创建 pull request 代码评审时,不要在其中包含合并提交。因为合并提交的差异很难查看和分析,不要因此增加代码评审者的负担。开发者可以使用 git rebase 命令去掉不必要的合并提交。
- 如果做到了第一点,就不会出现多基线的情况。要避免多基线。多基线一方面增加了三路合并分析的复杂度,另外一方面导致 pull request 展示的代码差异是错的!因为绝大多数代码平台从执行效率考虑,都只会选择多条基线中的一条来和 pull request 的源分支进行比较,显示代码差异。读者可以翻到前面的示例,看看如果选择多条基线中的一条(而不是使用多条基线的合并结果)与要合并的代码进行代码比较,会是什么样的结果?
- 使用 git merge 命令完成分支合并,而不要使用目录比较工具去人为判断合并结果。因为人工选择合并结果可能造成“脏合并”,“脏合并”像是埋在提交记录中的一枚炸弹,会在未来随时引爆。
If not now, when? if not me, who?
如果你是一个懂代码,爱Git,有技术梦想的工程师,并想要和我们一起打造世界NO.1的代码服务和云产品,请联系我们吧!C/C++/Golang/Java 我们都要 💖
简历投递邮箱:
登录后评论
立即登录 注册