商汇粹外网资源平台

搜索
查看: 1441|回复: 4

在开发过程中使用 git rebase 还是 git merge,优缺点分别是什么? ...

[复制链接]

该用户从未签到

5

主题

28

帖子

118

积分

注册会员

Rank: 2

积分
118
发表于 2022-10-22 09:49:26 | 显示全部楼层 |阅读模式
搞清楚这个问题首先要搞清楚merge和rebase背后的含义。
先看merge,官方文档给的说明是:
git-merge - Join two or more development histories together

顾名思义,当你想要两个分支交汇的时候应该使用merge。
根据官方文档给的例子,是master merge topic,如图:
                     A---B---C topic                    /         \               D---E---F---G---H master
然而在实践中,在H这个commit上的merge经常会出现merge conflict。为了避免解决冲突的时候引入一些不必要的问题,工程中一般都会规定no conflict merge。比如你在github上发pull request,如果有conflict就会禁止merge。
所以才会有题主问的问题:在当前的topic分支,想要引入master分支的F、G commit上的内容以避免merge conflict,方便最终合并到master。

这种情况下用merge当然是一个选项。用merge代表了topic分支与master分支交汇,并解决了所有合并冲突。然而merge的缺点是引入了一次不必要的history join。如图:
                     A--B--C-X topic                    /       / \               D---E---F---G---H master
其实仔细想一下就会发现,引入master分支的F、G commit这个问题上,我们并没有要求两个分支必须进行交汇(join),我们只是想避免最终的merge conflict而已。


rebase是另一个选项。rebase的含义是改变当前分支branch out的位置。这个时候进行rebase其实意味着,将topic分支branch out的位置从E改为G,如图:
                             A---B---C topic                            /                        D---E---F---G master
在这个过程中会解决引入F、G导致的冲突,同时没有多余的history join。但是rebase的缺点是,改变了当前分支branch out的节点。如果这个信息对你很重要的话,那么rebase应该不是你想要的。rebase过程中也会有多次解决同一个地方的冲突的问题,不过可以用squash之类的选项解决。个人并不认为这个是rebase的主要问题。


综上,其实选用merge还是rebase取决于你到底是以什么意图来避免merge conflict。实践上个人还是偏爱rebase。一个是因为branch out节点不能改变的情况实在太少。另外就是频繁从master merge导致的冗余的history join会提高所有人的认知成本。
回复

使用道具 举报

该用户从未签到

5

主题

12

帖子

51

积分

注册会员

Rank: 2

积分
51
发表于 2022-10-22 10:12:21 | 显示全部楼层
rebase 和 merge 不是二选一的关系,要协同使用,毕竟作者设计出两个命令不是让你挑一个来用的。
一个最简单的模型,从 master 分支 checkout 出几个本地 feature 分支,你或者你的团队在协同开发某个 feature-a 时,可能别人已把 feature-b 的代码 merge 回 master 了,所以应该及时将 master 的改动 rebase 到你的本地分支,顺便 fix conflicts。即:
$ git switch feature-a$ git rebase masterfix conflicts...$ git rebase --continue
当你开发完成 feature-a 时,应该将改动 merge 回 master。即:
$ git switch master$ git merge --no-ff -m "Merge branch 'feature-a'" feature-a
在本地分支中使用 rebase 来合并主分支的改动,是为了让你的本地提交记录清晰可读。(当然, rebase 不只用来合并 master 的改动,还可以在协同开发时 rebase 队友的改动。)
在主分支中使用 merge 来把 feature 分支的改动合并进来,是为了保留分支信息。
如果全使用 merge 就会导致提交历史繁复交叉,错综复杂。如果全使用 rebase 就会让你的 commits history 变成一条光秃秃的直线。
一个好的 commits history,应该是这样的:
*   e2e6451 (HEAD -> master) feture-c finished|\| * 516fc18 C.2| * 09112f5 C.1|/*   c6667ab feture-a finished|\| * e64c4b6 A.2| * 6058323 A.1|/*   2b24281 feture-b finished|\| * c354401 B.4| * 4bfefb8 B.3| * eb13f72 B.2| * c2c62b9 B.1|/* bbbba82 init
而不是这样的:
*   9f0c13b (HEAD -> master) feture-c finished|\| * 55be61c C.2| *   e18b5c5 merge master| |\| |/|/|* |   ee549c2 feture-a finished|\ \| * | 51f2126 A.3| * |   72118e2 merge master| |\ \| |/ /|/| |* | |   6cb16a0 feture-b finished|\ \ \| * | | 7b27b77 B.3| * | | 3aac8a2 B.2| * | | 2259a21 B.1|/ / /| * | 785fab7 A.2| * | 2b2b664 A.1|/ /| * bf9e77f C.1|/* 188abf9 init
也不是这样的:
* b8902ed (HEAD -> master) C.2* a4d4e33 C.1* 7e63b80 A.3* 760224c A.2* 84b2500 A.1* cb4c4cb B.3* 2ea8f0d B.2* df97f39 B.1* 838f514 init
就这么简单。
<hr>这个挺久之前的回答在最近几天收到了挺多赞,同时也有人在评论区提到了 trunk-based,所以我必须补充一下,当初写这个回答时确实有一定的局限性,如果你认同 TBD 这样的开发理念,那确实可以完全不使用 merge 命令,采用这种模型的人主要认为其适用于持续交付,我个人觉得其最大的优势还是简单吧,目前还没有使用过。
回复

使用道具 举报

该用户从未签到

0

主题

6

帖子

26

积分

新手上路

Rank: 1

积分
26
发表于 2022-10-22 10:35:16 | 显示全部楼层
编辑说明:这个答案目前还有人点赞,所以想要完善下,之前的答案有些问题。添加上图片,让说明更加直观。截图来自于网站Learn Git Branching。因为Git是分布式的并且本地仓库和远程仓库可以看作一个整体,所以将本地分支和服务器上的分支显示在同一张图上做说明。本地分支是master,服务器分支是server_ma。
不管用什么风格的操作流程来整合你的本地分支,最终,服务器上的目标分支是要和本地分支做一次类似于fast-forward merge的合并的。
通常情况下有这么两种情形:
1. 本地分支是C0-C1-C2-C3,服务器上是C0-C1。这时候直接push,服务器上也会变成C0-C1-C2-C3,这种是快进式合并。结束后,历史树是一条线。
push前如下图

push后如下图

2.本地分支C0-C1-C2-C3,服务器上是C0-C1-C4。这时候直接push会提示non-fast-forward而失败,需要先pull一下再push。git pull会先fetch然后执行git merge。优点:记录下合并动作;缺点:很多时候这种合并动作是垃圾信息,记录意义不大,反而把历史树搞得复杂不直观,会对实际工作带来负面作用。
push前如下图

pull后如下图

push后如下图

负面作用实例:某工在push完提交以后,想用当前分支最新的代码编译一个版本,于是就在C3上打了个tag,因为他错误地认为C3是最新的一笔提交,然后让编译任务根据这个tag去抓代码编译了,编完后同事跑来跟他反馈说,这版本不对呀,我改的C4好像没起效果嘛。我比你先push的,你说你取了最新的代码,怎么会没有我的改动呢?经过排查后才发现tag应该打在C5上。
现实中某工其实不大可能会打错tag,因为他自己先pull了,会看到C5在本地生成。但假如推送代码时不是直接推送的,而是类似于Github的pull request或者Gerrit的review那样先推送到审核分支再由审核人员将其合并到目标分支,那即使不是fast-forward形式,也是可以直接推送到pull request或者review分支的。这样C5会在服务器端合并操作的时候形成,而不是在本地生成以后再推送上去。这种情况下,某工很可能会错把C3当作最新的提交,因为他对C4的存在感不强烈,而且对C5毫无感知。
某工应该把tag打在C5上,才能取到当时最新的版本。他在取版本时不用tag而是用branch,那是可以避免这种情况的,因为branch一般指向的commit就是最新的。但也存在风险:编译任务去取分支最新代码时,分支上又被其他人上了新的提交,这个新提交很可能并不是他想要的,还可能带来不好的副作用。
这种有分叉的历史树还是有一个优点的,C3和C4分别代表了某工和同事各自的版本,C3上不包含C4的改动,C4上不包含C3和C2的改动。如果C1代码没问题,但C5却引入了bug,那可以分别验证C3和C4来初步定位是某工还是同事引入的。如果是某工引入的,那同事就不用耗费在这个问题上了。如果提交历史是一条线的话,那排查起来要把两个人都困在里面。
为了要让提交历史变成一条线的话,那某工在提交之前,如果先做一次git pull --rebase,本地分支就会被更新成C0-C1-C4-C2'-C3',把C2-C3的基从C1上变到C4上。这时再去push,那服务器上也变成C0-C1-C4-C2'-C3'. 那tag打在C3'上就行了。
pull rebase之前如下图

pull rebase之后如下图

push之后如下图

rebase和merge的使用得看具体的情形,并不存在谁比谁好。rebase最大的一个禁忌场景是:如果用了rebase以后,已经push并且合并到中央仓库分支的commit会被重写的话,不到万不得已的情况绝对不要用rebase!这也适用于git commit --amend命令、git reset --hard && git cherry-pick命令、git filter-branch等会重新生成commit的操作。重写已经发布的commit会需要用强制推送更新远程仓库的分支,这会需要所有已经下载了该分支的地方强制更新本地的分支,否则新旧commit历史会像两个分支一样糅合在一起,让分支历史变得很杂乱。可以想象,你强制push以后,其他对pull和rebase不熟悉的同学百分之一百会用pull(默认是merge操作)以后push的方式来处理直接push失败的场景,这样会把他本地的旧分支和你强制推送的新分支合并到一起。这种现象有被称为merging hell的。虽然最终代码可能是对的,但是看log的时候,那些只有sha1不一样,但commit log都一样的一对一对的commit会让你怀疑人生。
另外,按照我的感受和理解,如果是在同一个逻辑分支之间的合并,譬如本地master和服务器的master分支、还有我和你在同一条pull request分支或者feature分支上开发时,尽量用rebase的方式使其历史是一条线。而如果是逻辑上不同分支之间的合并,用non-fast-foward-merge保留下合并记录比较好,用git merge --no-ff。也有团队会用squash merge,这种merge方式的主分支和其他分支的形态相比是真地像一棵树。看起来只有分叉,没有合并,但实际上是合并了代码修改的,只是没有合并历史。此外,有些场景下,还会只用git cherry-pick来同步不同分支之间的修改。rebase和merge比起来,相对难以理解和使用一些。在学习rebase的过程中,可以把它分解成cherry-pick来做。
更新,2020-04-03
今天突然发现,我原来的回答陷入了一个小坑:某个目标既可以用rebase又可以用merge实现,哪个做法更好?其实还有些场景下是不能用merge解决的,有些用rebase是解决不了的。
譬如,将某两个commit调换一下先后顺序,或者将某个不是head的commit从历史中去掉。这两个需求用rebase来做是非常方便的,但用merge是没法实现的。
再譬如,把一个分支的历史合并到另一个分支上,但是不合并修改。这个用merge可以实现,用rebase是做不到的。
所以,具体用哪个git命令需要根据实际情况来分析,目标是解决问题并且最大程度地满足需求。
更新,2022-05-14
本地分支有多条commit,然后pull远程分支合并,将合并后的分支push到远程仓库。使用Gerrit review,每条commit需要管理员审核,而每审核通过一条commit之后都会出现一个合并分支的记录。例如有分支中有10条commit,新的远程分支中就会出现10条commit+10条合并的log,如何解决? -- by @遇见
Gerrit review是通过pending change实现的。其原理和github的pull request以及gitlab的 merge request一样,都是将这些待审核合并的提交推送到了一些特殊的内部分支上,等review后确认没问题了,系统会将将特殊分支merge到目标分支上。pending change的特殊分支名格式是refs/changes/xx/yyyxx/n。其中的yyyxx是每一个待审核change的id,这个id是自增长的正整数。xx是yyyxx除以100以后的余数。n是从1开始增长的自然数,对应change的第n个patchset,每个change默认有1个patchset。每个pending change推送到Gerrit以后,合入目标分支之前代码可能还是有问题的,所以会需要修改。虽然每次修改都可以创建一个新的change来处理,但这样不如将所有的修改版本关联在一个change里更好。所以在修改commit的时候,只要保证commit message里第一次自动生成的ChangeId不变,那在把新的commit推送到Gerrit上review时,Gerrit会知道这个commit是和某个已经存在且还是open状态的change是关联的,从而会将这个commit作为该change的最新的一个patchset,而不会给它新建一个change。假如推送新patchset时这个change状态不是open的,那推送会失败。
将pending change通过submit操作合入到目标分支上时,内部进行了一次merge操作。既然是merge,那就可能是true merge,也可能是fast-forward merge,也可能是rebase merge。采用哪种merge方式,是可以在Gerrit的仓库设置里更改的,默认的方式是merge if possible,能fast-forward merge时就fast-forward merge,不能的时候就true merge。true merge的时候,会产生一个merge commit,也就是问题中说的合并分支的纪录。
还是用图来演示下问题中的情况和处理方式,为了简化起见,只用3个新commit来演示,还是会将本地分支、pending change分支和目标分支合在一张图上表示,因为分支名字数限制的原因,pending change分支会用r/c/1/1/1这种形式表示。
push前如图,本地的main分支基于C0做了3个commit C1 C2 C3,此时服务器分支ser_main在C0上

通过命令git push origin HEAD:refs/for/main推送之后,在Gerrit上为3个新的commit创建了pending change分支

此时,如果直接去submit C2,Gerrit页面上的submit按钮时不可用的,因为C1还没有review过。要把r/c/2/2/1 merge到ser_main上去,必然会要把C1一同带过去。但C1还没有准备好,那C2必然是没法先submit的。同理,在C1和C2都准备好之前,也是没办法直接先submit C3的。
这里有两种选择,第一种是先submit C1,再submit C2,最后再submit C3;第二种是等C1,C2,C3都准备好以后,直接submit C3(submit按钮会变成Submit with parents),会把C1和C2也一起submit进去。
出现问题中提到的“新的远程分支中就会出现10条commit+10条合并的log”的情形,是选择了第一种做法。选择第二中做法的话只会出现1条合并的merge commit。但是就上图中这种情况,不管用第一还是第二种做法,都不会产生merge commit。最终结果都是这样,因为每次merge都是fast-forward merge,不会产生merge commit。

将分支A合入分支B的时候,会不会产生merge commit,不考虑通过参数强制更改默认行为的话,只要看两个分支的关系是分叉的还是线性的。上图种这种情况,里面任意挑2个指向不同commit的分支出来,它们都是线性关系的,所以submit后将pending change分支合入到ser_main上时都是fast-forward merge,不会产生merge commit。
而实际开发中,在我们review分支submit分支的同时,目标分支可能被其他人更新了新的commit进去。譬如现在ser_main分支上已经包含了其他人新提交的C4

那此时,3个pending change和ser_main分支就都是分叉的了,merge的话会发生true merge,生成一个merge commit。按照第二种方式merge C3的话,结果会是这样,ser_main上生成了C5这个merge commit。

假如按照第一种方式依次merge C1,C2,C3,那每次submit都会在ser_main上生成一个merge commit。因为每次merge时,当前的pending change分支和ser_main分支都是分叉的(C1和C4分叉,C2和C5分支,C3和C6分叉)。

从ser_main分支上最终的文件内容来看,两种方式是没有区别的,都是把C1,C2,C3合入到了ser_main分支上。但是明显前面的log图看起来要清爽得多。为了解决问题中的现象,通常采用直接submit最后一个commit的方式就行了,虽然会产生1个merge commit,但log也不是十分复杂。
但是有些强迫症犯了的同学可能会觉得,分叉来分叉去的看了难受,我就要把ser_main维护成线性的,一个merge commit都不能有。那也是可以的,通过rebase操作就可以了。这里的rebase操作可以在客户端做了再推送上来,也可以在change页面上通过rebase按钮直接操作。但这2种操作都需要时间,在点下submit之前,ser_main都有可能因为被其他人更新了而导致分叉。那不管,先看下rebase是什么样的情形
在本地通过git pull origin -r ser_main来rebase后,再git push origin HEAD:refs/for/ser_main推送。本地的main上将C1,C2,C3 rebase到ser_main后生成了C1',C2',C3'。推送后3个change都更新到了patchset 2上面。此时C1',C2',C3'和ser_main都是线性的,submit的话不会产生merge commit。

如果本地不管,直接在Gerrit页面上点rebase操作,依次rebase C1,C2,C3,这里顺序尽量不要乱,否则有可能会引起冲突等问题。本地的main分支不变,Gerrit上将C1,C2,C3 rebase到ser_main后生成了C1',C2',C3',3个change都更新到了patchset 2上面。此时C1',C2',C3'和ser_main都是线性的,submit的话不会产生merge commit。

上面两种做法看起来几乎一模一样,最终submit以后,ser_main都会更新到C3'上。但还是有一点不同的地方,那就是本地main分支和最后服务器端的ser_main分支之间的关系不一样。图7里面main和ser_main最终会是线性的同步的,而图8里面main和ser_main是分叉的不同步了。本地main和ser_main不同步的时候,很容易出问题:git pull orign ser_main的时候会把服务器上的ser_main和本地的main通过true merge合并到一起,线性的log又变成了面条。而为了强制同步,可以用git fetch origin ser_main && git reset FETCH_HEAD --hard。
如果仓库设置的merge方式改成rebase if possible,那就不需要手动rebase了,submit的时候Gerrit会自行按照类似图8的方式处理,而且不用考虑submit前ser_main被其他人更新的情况。坏处就是本地分支和服务器分支不同步。一般来说,图5的方式是折衷的,只对reviewer有操作要求。图7和图8的方式对提交代码的人有额外的操作要求。提交代码的人比较多,更容易出问题。但因为有review机制的存在,最终submit的方式掌控在reviewer手中,reviewer想把服务器分支维护成什么样都是reviewer完全掌控的,区别只在于提交代码的人操作起来是简单还是麻烦。
回复

使用道具 举报

该用户从未签到

6

主题

27

帖子

103

积分

注册会员

Rank: 2

积分
103
发表于 2022-10-22 10:58:11 | 显示全部楼层
多人协同工作应该用 merge,一个人写代码可以用 rebase。
merge会产生分支,然而从版本管理的角度,多人的工作本来就应该位于不同的分支。单纯为了线条好看而扭曲了实际开发过程中的事实,并不是可取的行为。
如果需要merge,本来就是因为在你提交之前有别人修改了代码,那么别人的代码事实上确实就是与你并行修改。从流程上讲,别人的代码与你并行修改,并且同时都基于某个早先的基线版本,那么这样的两组修改就确实应该位于不同的分支。分支归并正确的显示了多人协同开发过程中实际发生的客观事实。
因此显然应该选择merge,版本管理软件的职责就是准确的记录开发历史上发生过的所有事情,merge能确保你基于修改的基点忠实的反应了情况,这种情况下merge肯定是更准确的。
但如果是你自己一个人写的代码,多余出来的分支确实是不必要的,本来就应该把线整理成线性。那么确实可以考虑使用rebase。——这种情况下一般发生于自己一个人使用了多台电脑,多台电脑各有不同的未提交代码的情形,建议考虑rebase。


结论重复一下:归并目标是他人代码,用来解决两个不同开发者开发的代码冲突的时候,用merge,归并目标是自己代码,用来解决自己在两台不同电脑上修改代码的冲突的时候,用rebase。
回复

使用道具 举报

该用户从未签到

0

主题

5

帖子

53

积分

注册会员

Rank: 2

积分
53
发表于 2022-10-22 11:21:06 | 显示全部楼层
我反对 @pansz
,多人协作更应该用 rebase
二十个人在那翻来覆去 merge, 堪称地狱绘图, log 就完全没法看了, 基本报废
人多应该在主干上禁止 merge, 有个专门的策略叫线性历史(Linear History)
你可以在 Github 上看到这个选项, 可以禁止不小心 merge 到主干.

<hr>线性历史并不禁止 merge, 只是禁止 merge 到主干.
线性历史有两个主要分支, 一个叫 main, 一个叫 dev
所有其他的分支都从 main 分裂出去, 然后合并(merge/rebase/squash)到 dev
然后 dev 有专门的人 rebase 去掉所有的 merge 节点
(你实在不懂 rebase 那你直接 squash)
当 dev 稳定后, 打一个 tag
然后 main 分支执行 fast-forward 抵达这一稳定节点.
<hr>线性历史解决了 merge 盘丝洞的问题, log 清晰, 容易 revert
你说有个feature 需要长期游离在 main 和 dev 之外?
那这种情况你应该用 fork, 将当前的 repo 变成上游, 然后顺便改个名
建议 20 人以上的团队都试着开启 linear history, 你们会感谢这个风格的
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

快速回复 返回顶部 返回列表