Git 合并代码的不同方式 - Merge Commit、Squash and merge、Cherry-pick、Rebase and merge

shabbywu大约 9 分钟基础技术

前言

我们在日常开发中经常使用 Git 管理代码, 每个人在各自的分支开发代码, 开发完毕后在 Gitlab/Github 上提交 MR/PR, 最后点击 merge 按钮即将代码合并至主分支...

在稍微学习 Git 相关的知识后, 我们会发现 Git 代码合并的方法绝不仅此一种, 不同的代码合并方式之间有什么差异?各自又适用于什么样的场景?我将在这篇文章为大家展开聊聊这些话题。

不同的代码合并方式

1. Merge

在常见的 Git 工作流中, 我们会有 2 个长期存在并且不会被删除的分支: master 和 develop。而在日常开发流程中, 我们使用的是特性分支,也叫功能分支。当需要开发一个新的功能的时候,可以新建一个 feature-xxx 的分支,在里边开发新功能,开发完成后,将之并入 develop 分支中,如下图:

                        H---I---J feature-xxx
                        /       \
                E---F---G---K----L develop
                /
    A---B---C---D master

其中 L 这个提交是由 Git 自动生成的合并提交节点。

注意, 如果 git 可以通过移动指针完成合并, 那么默认情况下将不会创建提交节点, 这个优化又被称之为 fast-forward(ff) , 如需关闭该优化项, 可添加参数 --no-ff 要求 git 创建提交节点。

2. Squash Merge

在日常的 MR/PR 过程中, 我们会发现合并时有个选项叫 squash commits 。 顾名思义, Squash 意味着会将多个 commit(提交) 合并到一个。与 Merge 类似的是, 使用 Squash Merge 将会在该分支末尾追加一个提交记录, 如下拓扑结构:

                        H---I---J feature-xxx
                        / 
                E---F---G---K----L' develop (where L' == (H + I +J)
                /
    A---B---C---D master

但是, 与普通的 Merge 不同的是, Squash Merge丢弃原来分支 (feature-xxx) 上的所有提交记录, 并生成一个包含原来提交的所有内容的提交节点。
基于以上特性, 如果 Squash Merge 后继续在 feature-xxx 分支开发, 那么下次合并后将大概率出现冲突,这时候就需要用到 cherry-pick

3. Cherry-pick

根据 git-book 中的介绍, cherry-pick 提供了从另一分支中 挑选(pick) 单个或数个提交并应用到当前的开发分支中的能力。 我们以 Squash Merge 后意外地在原分支中继续开发为例, 介绍 cherry-pick 的操作流程, 如下拓扑结构:

                        H---I---J---M---N feature-xxx
                        /           `    `
                E---F---G---K----L'---M'---N' develop (where M', N' is chery pick from M, N)
                /
    A---B---C---D master

除了修复 Sqaush Merge 引来的意外冲突以外, cherry-pick 还常用于从不稳定的开发分支(不具备合并到主分支的条件)挑选个别需要紧急发布的安全修复到稳定分支中, 这种场景合并没有意义, 因为合并反而会引入更多不需要的变更。

4. Rebase

最后一种常用的, 也是最强大(复杂)的合并方式是 Rebase。顾名思义, Rebase(变基) 即变更当前分支的根节点, 我们以如下拓扑结构为例介绍 Rebase 的流程:

        E---F---G feature-xxx
        /
    A---B---C---D develop

当我们开发的基础分支已经落后于原分支时, 我们在提交代码前就应该使用 rebase :

➜ git rebase develop feature-xxx

执行以上操作后, 拓扑结构将调整为如下所示:

                E'---F'---G' feature-xxx
                /
    A---B---C---D develop

其中, E', F', G' 与原来的 E, F, G 内容完全一致, 本质上是在另一个根节点后重新应用原来的提交。

值得注意的是, rebase 后的分支是必然符合 fast-forward 的优化条件的, 这意味着 rebase merge 可以不创建无意义的合并节点, 有利于保持代码分支的可读性。

交互式 Rebase

Rebase 本质上是在另一个根节点上 重放 你的代码提交记录, 因此 rebase 不仅仅具备变更根节点的能力, 还能压缩代码提交记录(squash), 修改代码提交信息(edit) 甚至可删除部分提交(drop)。我们可以通过启动一个交互式的 Rebase 会话来做到上述功能:

➜ git rebase -i HEAD~2

执行上述指令后, Git 将打开一个编辑器, 依据指引操作即可:

pick 6b2e82f 2
pick a95710b 4

## 变基 7244a00..a95710b 到 7244a00(2 个提交)
#
## 命令:
## p, pick <提交> = 使用提交
## r, reword <提交> = 使用提交,但修改提交说明
## e, edit <提交> = 使用提交,进入 shell 以便进行提交修补
## s, squash <提交> = 使用提交,但融合到前一个提交
## f, fixup <提交> = 类似于 "squash",但丢弃提交说明日志
## x, exec <命令> = 使用 shell 运行命令(此行剩余部分)
## b, break = 在此处停止(使用 'git rebase --continue' 继续变基)
## d, drop <提交> = 删除提交
## l, label <label> = 为当前 HEAD 打上标记
## t, reset <label> = 重置 HEAD 到该标记
## m, merge [-C <commit> | -c <commit>] <label> [## <oneline>]
## .       创建一个合并提交,并使用原始的合并提交说明(如果没有指定
## .       原始提交,使用注释部分的 oneline 作为提交说明)。使用
## .       -c <提交> 可以编辑提交说明。
#
## 可以对这些行重新排序,将从上至下执行。
#
## 如果您在这里删除一行,对应的提交将会丢失。
#
## 然而,如果您删除全部内容,变基操作将会终止。
#
## 注意空提交已被注释掉

如何区分不同的合并方式?

一般情况下, 我们选择不同的合并方式应该基于同一个准则: 维护一份干净且可用的代码提交历史。为此, 我们需要区分不同的场景使用以上不同的合并方式。

1. Merge

毋庸置疑, 合并是最通用的代码合并方式。当你需要将来自一个分支的整个功能完全合并到另一个分支时, 使用 merge 可以将代码提交历史完整地保存下来, 为代码溯源(git blame)提供最有价值的技术指导。

以 Git 工作流为例, 当需要发布 develop 至稳定的环境时, 就应当将 develop 分支 mergemaster 分支。

2. Squash Merge

如前所述, Squash Merge 会将代码提交记录压缩合并为 1个, 并且操作不当容易引发代码冲突。不过仍然有些情况是建议将提交记录进行压缩的:

以功能开发为例, 当我们开发一个功能分支时, 可能会产生很多意义不大的提交记录(例如可能 commit 后才发现有 typo, 于是又多了个修复 typo 的 commit)。

一般情况下, 是否使用 Squash Merge 是一个团队偏好问题:

  1. 如果你觉得意义不大的提交记录污染了主分支的代码历史, 那么你将代码合并到主分支前就应当合并你的代码提交历史, 而 Squash Merge 则是其中一种合并提交记录的方式。
  2. 如果你觉得所有提交都应该被追踪(例如某些团队以提交记录作为工作凭证?), 那么你的所有提交就不应该被任何人"篡改"!

一些团队可能认为使用 Squash Merge 有助于保持主分支的整洁, 但是并不能说这就是绝对正确的事情,所以这主要还是一个偏好问题。

而且, 为什么不使用 rebase 调整代码记录后再进行代码合并呢!

3. Cherry-pick

Cherry-pick 用于从某个分支挑选个别提交记录合并至指定分支, 因此 cherry-pick 常用的场景即是从开发分支中 挑选(pick) 安全修复至稳定分支(如, master)。除此之外, 在日常开发中如需从其他开发分支中摘取部分代码时, 亦可使用 cherry-pick

4. Rebase

Rebase 是 Git 常用命令中最强大的命令之一, 使用场景亦是最广泛的, 包括:

  1. 当你的开发分支是基于过时的分支时:

这是团队开发中最为常见的场景: 当其他人将代码合并至远程的 develop 分支后, 你的开发分支将落后于 develop 分支。

为了保证开发的功能不被其他人破坏, 本地测试时应当保证本地代码是最新的。在这种情况下, 我们可以将 develop 分支逆向合并至本地开发分支, 但是这会产生不必要的代码提交记录。使用 Rebase 即可更优雅地解决这个 "噪音" 问题。

  1. 当你希望不产生额外的代码合并记录时:

正如前言, rebase 后的分支是必然符合 fast-forward 的优化条件的, 这意味着 rebase merge 可以不创建无意义的合并节点, 有利于保持代码分支的可读性。

  1. 当你需要清理或调整某些提交记录时:

这种情况在现实开发中也是经常发生的: 例如当你在代码提交后意外发现代码中(或者 commit message 中)存在错别字, 但是这份代码又并未合并到主分支时。

我们期望维护一份干净而可用的代码提交历史,不希望某些意义不大或存在歧义的提交记录污染主分支的代码提交历史, 此时我们就应该使用可交互式的 Rebase 压缩或调整代码提交记录。

总结

Git 提供了多种合并代码的方式, 日常开发使用普通的 Merge 即可。如非团队开发约定, 尽量少用 Squash Merge 。如需压缩代码提交记录, 可于本地使用 Rebase 调整代码提交历史后, 再合并至主分支。而对于安全修复等紧急发布, 可使用 cherry-pick 摘取提交记录合并至主分支。