|
|
发表于 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完全掌控的,区别只在于提交代码的人操作起来是简单还是麻烦。 |
|