文章目录
- 前言
- Pull Request 为什么会是这样?
- Pull Request = Branch的差异 ?
- Two Dot Diff和Three Dot Diff
- 老生常谈: Merge 和 Rebase
- git merge
- git rebase
- Revert Main分支中的一个Merge Commit
- 现象描述
- 解决方案:
- Revert Feature分支中的一个Merge Commit
- 背景介绍
- PR中出现反向修改的原因
- 尝试解决
- 引用
前言
决定我们对一件事物的认真深度(insight),不取决于我们和它接触时间的长短(或者叫做熟悉的程度,familiarity),而取决于我们的观察(observation)和思考(thinking)。
比如,没有软件工程师不熟悉git的基本用法,比如简单的revert/merge/rebase子命令,因为在大多数情况下,正常而简单的使用已经足够。当我们发现我们的代码版本管理因为merge/rebase/revert/reset等变得不可控和无法理解的时候,我们往往可以无脑地手动从一个新的分支通过拷贝代码开始,而不会花几个小时的专门时间用正确而专业的方式彻底解决问题。
苏格拉底说过: 未经审视的人生不值得度过。很多时候,我们对事物的认知如果从没经过深入的思考,那么总有一天,这件事物呈现给我们的陌生感,会让我们感觉震惊。
相信创建Pull Request、Review代码然后合并分支是每个软件工程师每天经历的事情,但是最近我才发现,我居然连Pull Request的真正含义都没有正确理解。
本文以我自己最近在使用git rebase/revert/merge等操作过程中发现的问题为出发点,讲一下这几个子命令在使用过程中的真正含义。
Pull Request 为什么会是这样?
Pull Request = Branch的差异 ?
在Github官网上,关于Pull Request的定义 About Pull Request中是这样描述的:
About Pull Request: A pull request is a proposal to merge a set of changes from one branch into another. In a pull request, collaborators can review and discuss the proposed set of changes before they integrate the changes into the main codebase. Pull requests display the differences, or diffs, between the content in the source branch and the content in the target branch.
即:
Pull Request 是一种Proposal(提案),将一组Changes(变更)从一个branch合并到另一个branch。在Pull Request中,协作者可以在将更改整合到主代码库之前review(审查)和discuss(讨论)提议的一组更改。Pull request 显示源分支和目标分支内容之间的差异,即 diffs.。
假设我们有main branch的提交如下图所示:
commit a 和 commit b分别在main 分支创建了file_a.txt和file_b.txt:
此时,我们基于main branch,创建feature branch,创建了文件file_d.txt,commit d提交到feature branch:
然后我们基于base branch(main)和topic branch(feature)创建PR,这个PR很简单,File Changes中显示的是我们在commit d的修改:
可是,随后,main分支中有了新的提交commit c,用来添加一个文件file_c.txt:
所以,当前,两个分支之间的依赖关系如下图所示:
这时候,main分支的文件状态和feature分支的文件状态分别如下所示:
所以,以main branch为base,feature branch的diff应该是commit d中增加的文件d.txt 和main branch中 commit c(增加文件c.txt)的反向操作删除文件c.txt。但是,很显然,我们在Pull Request中的File Changes看到的依然只有commit d中增加文件d.txt,并没有看到commit c(增加文件c.txt)的反向操作。
这显然与GitHub官网上关于Pull Request的定义不符合:Pull request 显示源分支和目标分支内容之间的差异,即 diffs.
原因是什么?
Two Dot Diff和Three Dot Diff
看起来,Pull Request中的diff所表达的不是两个分支的最新提交的差异比较,而是feature branch相对于从feature branch与main branch共同祖先以后的feature branch的变更,而main branch在此以后的文件变更并不显示在Pull Request中。
终于,我们在About comparing branches in pull requests文档中找到了原因。
Git 的 diff 命令有两种比较方法:两点比较(git diff A…B)和三点比较(git diff A…B)。GitHub 上的 Pull Request 页面显示的是三点 diff。
Three-Dot Diff:三点比较显示的是两个分支的最新公共提交(merge base)和Feature分支的最新版本之间的差异。
- 在我们的例子中,feature branch和main branch的最新公共提交是commit b,因此,我们在GitHub的Pull Request页面看到的diff,是commit b和feature branch的commit d之间的差异,显然,这个差异就是commit d的变更,增加文件d.
我们在创建Pull Request的Compare页面(https://github.com/VicoWu/test-git/compare/main…feature)的URL中可以看到其Diff方式三点Diff:
基于Three Point Diff的Pull Request给用户的感受是,Pull Request中体现的是feature分支引入的变更,而对于main分支引入的变更是不关心的。这种特性是符合直觉的。
比如,我们创建了PR以后,main分支的任何变更都不会再体现在PR中。
Two-Dot Diff:两点比较显示的是基准分支(例如,main)的最新状态与主题分支的最新版本之间的差异。
在Pull Request页面不显示Two-Dot Diff,但是,在我们创建分支的Compare页面,我们可以将URL中的三点改为两点(https://github.com/VicoWu/test-git/compare/main…feature),此时显示的就是两点Diff:
可以看到,在两点diff的状态下,main分支的提交commit c的反向修改就显示在了diff中。但是,已经说过,PR页面的diff只是三点diff。
如果我们想在 Pull Request 中模拟两点 diff 比较,并查看每个分支的最新版本之间的比较,可以将基准分支(main branch)合并到我们的主题分支(feature branch)中,这样,这个merge commit就成为了main branch和feature branch的最近的公共祖先,此时,Pull Request的三点比较就和两点比较等价了。
两点 diff 直接比较两个 Git committish 引用(如 SHA 或 OID)。在 GitHub 上,两点 diff 比较中的 Git committish 引用必须推送到同一个仓库或其分叉中。
当我们使用两点比较时,即使我们没有对feature分支进行任何更改,main分支更新后 diff 也会发生变化。此外,两点diff侧重于基准分支,这意味着我们在feature分钟中添加的内容都会显示为基准分支中缺少的内容(比如文件d.txt),好像它是一个删除,相反也一样,我们在main分支中添加的内容都会显示为feature分支中缺少的内容(比如文件c.txt),好像它是一个删除一样。
因此,在两点分支的比较方式下,我们的feature branch引入的更改变得模糊不清。为了让我们的Pull Request显示的Diff更加偏向于我们的Feature Branch引入的修改,而不偏向于main branch引入的修改,GitHub页面使用三点diff。
有关比较更改的 Git 命令的更多信息,请参阅 Pro Git 书籍网站上的“Git diff 选项”。
https://github.com/VicoWu/test-git/compare/2bcdb20…f4aa8f9
老生常谈: Merge 和 Rebase
关于 git rebase,首先要了解的是它解决的问题与 git merge 相同。这两个命令都旨在将一个分支的更改集成到另一个分支中,只是它们实现的方式截然不同。
考虑一下当我们开始在自己的feature分支中开发新功能时会发生什么,然后另一个团队成员用新的commit更新main branch。这会导致分叉历史记录(diverge),任何使用过 Git 作为协作工具的人都应该熟悉这一点。
比如,分叉形成了如下图所示的分支结构:
git merge
对于管理员,当一个PR approve以后,最简单的选择是使用类似下面的命令将feature分支合并到main分支中:
git checkout main
git merge feature
或者,我们可以将其压缩为一行:
git merge main feature
或者,我们可以在我们的Pull Request页面,选择以Merge的方式将feature branch合并到main分支:
这会在main分支中创建一个新的“merge commit”,将两个分支的历史记录联系在一起,形成如下所示的分支结构:
可以看到,git merge操作不会破坏main branch的提交历史(而git rebase会),而是在main branch的提交历史的末尾追加(append)一个特殊的merge commit。可以看到,这个merge commit的parent是feature branch 的 commit d 和main branch的commit c。
另一方面,这也意味着每次需要合并上游更改时,main分支都会有一个多余的合并提交。如果feature分支非常活跃,这可能会严重污染main分支的历史记录。虽然可以使用高级 git log 选项缓解此问题,但它会使其他开发人员难以了解项目的历史记录。
git rebase
作为merge的替代方法,我们可以选择Rebase and Merge
的方式进行合并。这里其实有两层意思(两个步骤),1) 先将main分支rebase到feature分支,然后,2) 将rebase完成的feature分支merge到main分支:
-
首先,将main分支rebase到feature分支的下面(main分支作为feature分支的base):
git checkout feature git rebase main
这会将整个main分支移动到feature分支与main分支的分叉位置,从而有效地将main主分支中的所有新提交合并到feature分支。但是,rebase不是使用合并提交(merge commit),而是通过为feature分支中的每个提交(严格来讲,是和main分支diverge以后的所有commit,那些公共的commit不会变化)创建全新的提交来整个重写项目历史记录。
如下图所示: 两个分支分叉(diverge)的部分commit c(本示例中只有一个commit,实际使用中不一定只有一个)被插入到了feature branch 中作为feature branch的base,然后基于这个新的base,重新去应用 feature branch中分叉的部分的每一个commit。由于是重新apply,因此所有的commit id都发生了替换,原始的每一个commit id都丢失了。
-
然后,我们将经过rebase的feature branch通过merge的方式合并进入main branch:
cwu@cwu-mbp-1 test-git % git merge --ff-only feature-test-rebase
在这里,我们依然使用了merge,但是,由于我们已经将main branch给merge到了feature branch,即main branch已经是feature branch的base了,此时,我们可以通过fast-farward(
--ff-only
)的方式,快速将feature branch比main branch多的那些commit直接append到main branch。如果不适用fast-farward的方式,而是使用普通的merge,则会在main分支上创建一个merge commit,尽管main分支已经是feature分支的parent了。这也是我们在Pull Request 页面上选择Rebase and Merge进行分支合并的时候,在合并完成的main分支上看不到merge commit的原因。
经过Rebase and Merge,我们看到合并前后的分支状态如下所示:
rebase的主要好处是我们可以获得更清晰的项目历史记录。
- 首先,它消除了 git merge 所需的不必要的合并提交。
- 其次,如上图所示,变基还可以产生完美的线性项目历史记录 。我们可以将feature branch的提交历史从头到尾看一遍,没有任何分支,看起来就好像feature branch历史上一直在线性提交、没有引入任何外部分支的提交一样。但是,很显然,这种表面的线性的提交历史与事实不符,它隐藏(修改)了背后的合并过程,好让提交历史看起来简单而完美。
原始提交历史记录可能难看,但是给我们提供了两个好处:安全性和可追溯性。
- 安全性:如果我们不遵循rebase的黄金法则(golden rules),重写项目历史记录可能会对我们的协作工作流程造成灾难性的后果。这里的黄金法则就是,不要对一个公共分支(比如,main branch)进行rebase操作,比如,main分支是public branch,feature 分支只有开发者一个人维护,为private branch。因此,只应该将main 分支rebase到feature分支,而不应该将feature 分支 rebase到main分支下面,一旦如此,rebase以后的main分支将会和所有其他开发者的main分支diverge,造成非常难以理解的提交历史。
- 可追溯性:不那么重要的是,rebase会丢失合并提交提供的上下文,我们无法看到main分支的修改是什么时候被纳入feature分支的。
由于本地的commit id全部被改写,此时feature分支的本地和远程已经完全diverge了,因此如果push到远程,需要加-f
参数。显然,如果feature分支是多人共同开发维护,这是不安全的。
Revert Main分支中的一个Merge Commit
现象描述
我们从main分支clone出了feature branch,并在feature branch上开发了commit d,经过review & approve,管理员采用merge的方式将feature branch合并到main branch:
cwu@cwu-mbp-1 test-git % git merge feature
合并以后,我的feature 分支和main 分支如下所示:
merge以后,我们查看两个分支的公共parent,可以看到,这个公共的parent是commit d:
cwu@cwu-mbp-1 test-git % git merge-base main feature
f4aa8f93ad82f1c586f2a84b24ba5cee6685311f
在此期间,main分支上又有了来自其他开发者的commit x(增加文件x.txt):
在有了commit x以后,feature branch的开发者突然报告说之前的commit d存在某些不足,必须从main 分支下线。因此,admin选择将merge commit m从main分支中revert掉。
由于commit m存在两个parent,我们上面说过,需要通过-m
参数指定需要保留哪条主线:
git merge -m 1
代表以27b1bee
为基准,将另外一个parent(f4aa8f9
)取消掉;即,取消掉feature分支中的修改,而保留main分支的修改。这显然是我们想要的。git merge -m 2
代表以f4aa8f9
为基准,将另外一个parent(27b1bee
)取消掉;即,取消掉main分支中的修改,而保留feature分支的修改。这并不是我们想要的。
cwu@cwu-mbp-1 test-git % git revert -m 1 2ab4dff
[main 22bc9af] Revert "Merge branch 'feature'"1 file changed, 1 deletion(-)delete mode 100644 d.txt
Revert 以后(commit w是commit m的revert commit,因为m翻转过来时w)的提交关系如下图所示:
revert以后,我们查看两个分支的公共parent发现,这个公共parent依然还是commit d:
cwu@cwu-mbp-1 test-git % git merge-base main feature
f4aa8f93ad82f1c586f2a84b24ba5cee6685311f
这时候,我们的疑问是,既然merge已经被revert了,为什么公共parent依然还是commit d?看起来两个分支还是存在交集。后面会解答。
此时,我们看到,在main分支下面,commit d对应的文件d.txt的确已经被删除:
cwu@cwu-mbp-1 test-git % ls -lh
total 40
-rw-r--r-- 1 cwu wheel 11B Jul 26 14:17 README.md
-rw-r--r-- 1 cwu wheel 29B Jul 26 14:19 a.txt
-rw-r--r-- 1 cwu wheel 29B Jul 26 14:20 b.txt
-rw-r--r--@ 1 cwu wheel 29B Jul 30 15:59 c.txt
-rw-r--r--@ 1 cwu wheel 29B Jul 30 16:00 x.txt
随后,我们等待feature分支的开发者提供一个新的、有bugfix的feature分支。等待期间,main分支又有其他开发者的commit提交进来,我们假设这个commit是commit y,增加了一个文件y.txt:
随后,feature分支的开发者通过commit f 修正了commit d中的bug,假设commit f是创建了一个bugfix文件f.txt,如下图所示(注意,commit f是对commit d的修正,因此两个commit同时存在才完整):
随后,我们重新将feature分支merge到main分支:
此时,我们再次查看两个分支的公共parent,可以看到,由于第二次merge的发生,公共parent已经由d变为f了:
cwu@cwu-mbp-1 test-git % git merge-base main feature
8a0237b880fc19958b191edcb9dcbbdfc90cec95
我们预想,重新merge以后,main分支中应该包含完成的feature分支的文件,文件d.txt和文件f.txt,但是,我们发现,重新merge以后,文件d.txt似乎丢了,并没有在main分支中:
cwu@cwu-mbp-1 test-git % ls -lh
total 56
-rw-r--r-- 1 cwu wheel 11B Jul 26 14:17 README.md
-rw-r--r-- 1 cwu wheel 29B Jul 26 14:19 a.txt
-rw-r--r-- 1 cwu wheel 29B Jul 26 14:20 b.txt
-rw-r--r--@ 1 cwu wheel 29B Jul 30 16:16 c.txt
-rw-r--r--@ 1 cwu wheel 57B Jul 30 16:18 f.txt
-rw-r--r--@ 1 cwu wheel 29B Jul 30 16:16 x.txt
-rw-r--r--@ 1 cwu wheel 29B Jul 30 16:18 y.txt
文件d.txt的丢失肯定与Revert操作有关。我们理解Revert a Merge操作,有两种可能的选择:
- Revert a Merge是把这个Merge Commit操作从拓扑图中删除,就好像这个Merge节点在拓扑图中不存在一样;
- Revert a Merge只是把Merge操作带来的数据进行了undo操作,但是不改变Merge Commit在提交历史的拓扑图的位置;
从我们的观察来看,第二种推测是正确的。
-
我们看到,在merge以后,
git merge-base main feature
的结果会变成feature分支的最近一次提交,这个是合理的,因为经过merge,feature分支的最近一次提交同时存在于main 和 feature分支。但是,在我们将merge commit给revert掉以后,git merge-base main feature
的结果保持不变。这说明 Revert a Merge只是数据Undo,不会将Merge Commit从Commit History中删除。 -
在上面,我们看到,我们提交了bug fix(commit f),然后重新merge,之前的commit d在main分支中不存在了。如果 Revert a Merge(commit w)会将Merge Commit给Cancel掉,那么,我们二次merge(commit m’)的时候,应该是重新merge了整个feature branch,因此文件d.txt应该出现在main branch中。文件d.txt没有出现在main branch中,是因为Revert a Merge(commit w)只是做文件d.txt的undo操作,即,将d.txt 删除,但是Merge Commit(commit m)依然有效,因此,当我们进行二次merge(commit m’)的时候,git会认为feature分支中的commit d已经merge了,因此会被忽略(已经在commit m中被merge了),只会处理bug fix(commit f)。
解决方案:
我们的解决方案是,现将Revert a Merge(commit w)给revert掉(revert the revert commit),这样,基于我们上面关于Revert a Merge的分析,对文件d.txt的删除操作就被取消了,然后,我们再merge feature branch:
我们首先将commit w给revert掉,我们看到,revert了以后,文件d.txt恢复了:
cwu@cwu-mbp-1 test-git % git revert 22bc9af
[main 3c6eeae] Revert "Revert "Merge branch 'feature'""1 file changed, 1 insertion(+)create mode 100644 d.txt
随后,我们再merge feature branch,可以看到,merge以后,文件f.txt也恢复了:
cwu@cwu-mbp-1 test-git % git merge feature
Merge made by the 'ort' strategy.f.txt | 1 +1 file changed, 1 insertion(+)create mode 100644 f.txt
Revert Feature分支中的一个Merge Commit
如果我们需要revert 的 merge commit在feature分支,整个原理和上文讲到的revert main分支中的merge commit的原理是一模一样的。
但是,我们上文在讲到Github页面中PR基于Three Point Diff的基本原理,PR中的差异是基于对feature分支的变化的跟踪,而不是基于对main分支的变更的跟踪。所以,在我们对feature分支中的一些merge进行revert的时候,在Pull Request页面会看到一些不寻常的、似乎反直觉的东西。我们稍微介绍一下。
背景介绍
我们开发了feature 分支,在创建PR以前,我们习惯性的将main分支merge到feature分支,然后创建PR。很显然,此时feature分支最新的commit是我们的merge commit。
但是我们随后发现之前做merge的时候main branch的commit log有问题,所以,在解决了main 分支的问题以后,我们想要重新merge。于是,我们的操作是先将merge commit给revert掉。
但是,我们发现,在revert了merge commit以后,PR中的file change不仅仅包含了 feature branch 的修改,还包含了main branch的修改。
第一次merge的时候,两个分支的关系如下图所示:
merge以后,我们继续进行开发,并且有一个新的commit x,这个commit创建了一个文件x:
显然,此时如果我们运行git merge-base main feature
命令查看两个分支的公共parent,应该是commit c
我们创建PR, 按照我们上文讲过的Three Dot Diff,PR中仅仅显式了feature branch中的变更,包括增加了文件d.txt和x.txt:
随后,我们在feature 分支上把 merge commit revert掉,并push到remote。由于我们需要保留的是feature分支中的commit,因此使用参数-m 1
保留f4aa8f9
(来自feature branch 的 commit d),revert掉27b1bee
(来自main branch的commit c):
cwu@cwu-mbp-1 test-git % git revert -m 1 74a18ff
[feature 12ddeaa] Revert "Merge branch 'main' into feature"1 file changed, 1 deletion(-)delete mode 100644 c.txt
revert以后的提交关系图如下所示(commit m是merge,commit w是commit m的revert commit):
我们预期的结果是,PR中应该还是只有feature branch中的修改(文件d.txt和文件x.txt),不会有什么问题。但是当我们打开PR,发现main branch中的修改(增加文件c.txt
)的“反向修改(删除文件c.txt
)”也进入了PR:
如果我们没有上文关于Two Dot Diff 和Three Dot Diff的讲解,这个结果非常令人费解。尤其是,假如我们的project是一个巨大的project,比如hadoop这种project,main分支的提交是非常频繁的,这时候,我们revert 一个 merge commit,然后刷新我们的PR Page,main branch中的各种浩若烟海的commit全部出现在我们的PR中(我们甚至无法看出这是反向commit),就会手足无措。
问题是,为什么我们revert 了一个merge以后,main branch的修改的反向修改会出现在PR中?
PR中出现反向修改的原因
基于上文中对PR是Three Point Diff的理解,假如我们在commit w中revert了commit m中main branch的commit c,此时,feature branch在commit w的状态和feature branch在commit b时的状态差异应该只是增加了文件d,而不应该main branch中的修改(增加文件c.txt
)的“反向修改(删除文件c.txt
)”,因为在两个状态(commit b的状态和commit w的状态中文件c都不存在)。但是我们在PR中却看到了main branch中的修改(增加文件c.txt
)的“反向修改(删除文件c.txt
)”。
我们通过运行git merge-base命令查看两个branch的最优公共祖先,看到两个branch的公共祖先是commit c,即merge操作将两个branch的最优公共祖先从commit b更行到了commit c。这是合理的,merge操作是将main branch的commit c merge到了feature branch,因此commit c成为了两个branch共有的commit。
cwu@cwu-mbp-1 test-git % git merge-base main feature
27b1bee6d8a488ac0571858530a381a6b4123e93
由于公共祖先是commit c,因此基于上文中对PR是Three Point Diff的理解,此时PR中显示的是commit w和commit c之间的diff。显然,这个diff是:
- commit w中包含了文件d,来源于commit d,而commit c中没有,因此PR中包含了文件d.txt;
- commit w中没有文件c,因为在revert的时候已经删掉了,而commit c中有,因此PR中包含了对文件c.txt的删除操作。
尝试解决
我们的revert操作仅仅只是对数据本身的revert,其实无法将merge操作从整个commit history topology中删除。
在我们进行了revert操作以后,PR中显示了对文件c.txt的删除,是因为merge操作将main和feature 两个分支的公共parent更新成为了c,基于Three Dot Diffs原理,在revert了以后,PR中会显示基于commit c以后的diff。所以,尽管PR看起来很奇怪,但是其实我们revert以后,feature branch的状态是没问题的,文件c.txt
不存在,只存在文件a.txt
, b.txt
, d.txt
, x.txt
:
cwu@cwu-mbp-1 test-git % git rev-parse --abbrev-ref HEAD
feature
cwu@cwu-mbp-1 test-git % ls
README.md a.txt b.txt d.txt x.txt
引用
- 《git撤销merge,彻底学会git revert的用法》
- 《Merging vs. rebasing》
- 《Three Dot and Two Dot Diff Comparision》
- 《How to Revert a Faulty Merge》