一个清晰的,有意义的版本变更信息
一个GIT用户的非常重要的技能是他们必须能够维护一个清晰的语义化的暴露给大众的变更历史。为了达到这个目的,他们必须依赖于四个主要的工具:
- git commit --amend
- git merge外加或不外加--no-ff参数
- git rebase,特别是git reabase -i和git rebase -p
- git cherry-pick(实际上这个命令和rebase是紧密绑定在一起的)
我经常看到人们将merge和rebase都堆放到一个篮子里,这里说明人们存在的普遍的误解:”获取别的branch的commits到我的branch上“。
但是实际上,这两个命令实际上并没有什么共通之处。他们有着完全不同的目的,并且实际上应用他们的原因也是完全不同的!
我将不仅要highlight出来他们各自的role,而且给你足够的知识和最佳实践技能以便你暴露给公众的历史信息不仅是表意的,而且是语义化的(通过查看版本变更历史图就应该能够明显地反映出团队的不同的开发的目的)。而这个组织良好的历史信息对于团队的价值也是明显的:比如有新的团队成员加入,或者过一段时间再回来维护项目,或者对于项目的管理,code review等等。。
什么时候我应该用git merge?
正如merge的名字所隐含的意思:merge执行一个合并,或者说一个融合。我们希望在当前分支上往前走,所以我们需要融合合并其他分支的工作,从而放弃其他的分支。
你需要问你自己的问题是:这个其他分支到底代表了什么??
它是不是仅仅是一个local的,临时性的分支,创建它的目的仅仅是为了在开发它的同时又能防止master变得不稳定?
如果答案是yes,那么, it is not only useless but downright counter-productive for this branch to remain visible in the history graph, as an identifiable “railroad switch.”
如果merge的目标分支(比如说master分支)在这个分支创建后又往前走了,也就是说master分支(头tip)已经不再是这个临时local分支的直接祖先了,我们会认为我们这个local分支too old了,所以我们往往需要使用git rebase命令来在master的tip上重新运行我们local分支上的commit以便保持一个线性的历史。但是如果master分支在我们创建local分支之后一直没有改变,那么一个fast-forward merge就是足够的了。
结论:如果是对local 私有的临时性质的分支,则直接git rebase -i master(梳理历史信息比如合并成一个commit)+git merge产生一个fast forward,最终以一个commit展示在master分支上;
它是一个well-kown的branch,被团队清晰了解或者仅仅是我的工作schedule来定义需要的?
在这种情况下,我们的这个分支可能代表了一个sprint或者说user story的实现过程,或者说代表了我们的一个bug fix过程。
Is is then preferable, perhaps even mandatory, that the entire extent of our branch remain visible in the history graph. This would be the default result if the receiving branch (say master) had moved ahead since we branched out, but if it remained untouched, we will need to prevent Git from using its fast-forward trick. In both these cases, we will always use merge, never rebase.
结论:如果是一个特别活动的跟踪,比如feature分支,bugfix分支那么永远不要使用rebase,而是git merge --no-ff,这样该分支历史永远存续在主分支上
什么时候我应该使用rebase?
正如他的名字所隐含的意思:rebase存在的价值是:对一个分支做“变基”操作,这意味着改变这个branch的初始commit(我们知道commits本身组成了一颗树)。它会在新的base上一个一个地运行这个分支上的所有commits.
这通常在当本地的工作(由一些列的commits组成)被认为是在一个过时的base基础上做的工作的时候才需要用它。这可能每天都会有几次这样的场景出现,比如当你试图将local commits push到一个remote时而因为tracking branch(比如说origin/master)过于陈旧而被拒绝时(原因是自从我们上次和origin同步(通过git pull)后别的同事已经做了很多工作并且也push到了origin/master上):这种情况下,如果我们强行将我们的代码push过去将会覆盖我们其他同事的并行工作成果。而这,往往是不允许的,所以push总会给出提示。
一个merge动作(往往pull就会内置执行这个merge动作)在这种情况下并不是很好的应用场景,因为merge会产生一些杂乱的历史遗迹。
另外一个对rebase的需求可能是:很久以前你曾经启动过一个并行的工作(比如做一些实验,做一些r&d工作),但是一直没有时间就耽搁了下来,现在又有了时间来做这件事情的时候,而这个时候你的R&D工作的base可能已经非常落后了。当你再次来继续这个工作时,你一定希望你的工作是在一个新的bas基础上来进行,以便你可以从已经解决的bugfix或者其他新的ready功能中获益。
最后还有一种场景:实际上是更频繁的场景:实际上并不是变基,而是为了清理你的分支上commits。
在使用git时,我们通常非常频繁地向repo中做commit,但是我们的commit本身往往是零散的不连续的,比如:
- 我在不同的topic之间来回切换,这样会导致我的历史中不同topic互相交叉,逻辑上组织混乱;
- 我们可能需要多个连续的commit来解决一个bug;
- 我可能会在commit中写了错别字,后来又做修改;
- 甚至我们在一次提交时纯粹就是因为懒惰的原因,我可能吧很多的变更都放在一个commit中做了提交。
上面的各种行为只要是保留在local repo中,这是没有问题的,也是正常的,但是如果为了尊重别人同时也为了自己将来能够返回来我绝对避免将这些杂乱的历史信息push到remote上去。在我push之前,我会使用git rebase -i的命令来清理一下历史。
rebase黄金定律
永远不要rebase一个已经分享的分支(到非remote分支,比如rebase到master,develop,release分支上),也就是说永远不要rebase一个已经在中央库中存在的分支.只能rebase你自己使用的私有分支
上面这个例子中展示了已经在中央库存在的feature分支,两个开发人员做了对feature分支针对master做rebase操作后,再次push并且同步工作带来的灾难:历史混乱,并且merge后存在多个完全相同的changeset。
在执行git rebase之前,总是多问问你自己:“有没有其他人也需要这个分支来工作?”,如果答案是yes,那么你就需要思考必须使用一种非破坏性的方式来完成rebase一样的工作(就是需要合入别人的工作成果),比如使用git revert命令。否则,如果这个branch没有别人来使用,那么很好,你可以非常安全地随心所欲地re-write history(注意rebase往往会重写历史,所有已经存在的commits虽然内容没有改变,但是commit本身的hash都会改变!!!)
但是我们要注意,即使对于上面的这个已经分享的feature分支,Bob和Anna也可以互相rebase对方的feature分支,这并不违反上面强调的rebase黄金定律,下面用图例再说明一下:
假如你和你的同事John都工作在一个feature开发上,你和他分别做了一些commit,随后你fetch了John的feature分支(或者已经被John分享到中央库的feature分支),那么你的repo的版本历史可能已经是下面的样子了:
这时你希望集成John的feature开发工作,你也有两个选择,要么merge,要么rebase,
记住在这个场景中,你rebase到John/feature分支的操作并不违反rebase的黄金定律,因为:
只有你的local本地私有(还未push的) feature commits被移动和重写历史了,而你的本地commit之前的所有commit都未做改变。这就像是说“把我的改动放到John的工作之上”。在大多数情况下,这种rebase比用merge要好很多
结论:只要你的分支上需要rebase的所有commits历史还没有被push过(比如上例中rebase时从分叉处开始有两个commit历史会被重写),就可以安全地使用git rebase来操作。
上述结论可能还需要修正:对于不再有子分支的branch,并且因为rebase而会被重写的commits都还没有push分享过,可以比较安全地做rebase
我们在rebase自己的私有分支后希望push到中央库中,但是却会由于rebase改写了历史,因此push时肯定会存在冲突,从而git拒绝你的push,这时,你可以安全地使用-f参数来覆盖中央库的历史(同时其他对这个feature也使用的人员可以git pull):
git push --force
快速sum up: 核心工作流原则和心法
下面的几个心法是你在使用git时必须磨砺在心的,在本文的后面,我们将具体说明哪些命令来负责执行这些心法:
1. 当我需要merge一个临时的本地branch时。。。我确保这个branch不会在版本变更历史图谱中显示,我总是使用一个fast-forward merge策略来merge这类branch,而这往往需要在merge之前做一个rebase;
2.当我需要merge一个项目组都知道的local branch时。。。我得确保这个branch的信息会在历史图谱中一直展示,我总是执行一个true merge;
3.当我准备push我的本地工作时。。。我得首先清理我的本地历史信息以便我总是push一些清晰易读有用的功能;
4.当我的push由于和别人已经发布的工作相冲突而被拒绝时,我总是rebase更新到最新的remote branch以避免用一些无意义的micro-merge来污染历史图谱
聪明地merge一个branch
前面讲过,你只有在需要合并融入一个分支所提供的所有feature时才做merge。在这时,你需要问你的核心的问题是:这个分支需要在历史图谱中展示吗?
当这个分支代表了一个团队都熟知的一块工作时(比如在项目管理系统中的一个task,一个关联到一个ticket的bugfix,一个user story或者use case的实现,一个项目文档工作等),那么在这种情况下,我们应该将branch的信息永远留存在产品历史图谱中,甚至即使branch本身已经被删除。
否则,如果不代表一个well-known body of work,那么branch本身仅仅是一个技术意义上的实体,我们没有理由将它呈现在产品历史图谱中。我们得使用一个rebase+fast-forward merge来完成merge。
我们来看看上面两种场景分别长什么样:
通过"true merge"来保留历史信息
我们假设我们一个乘坐oauth-signin的feature branch,该branch的merge 目标是master.
如果master分支在oauth-signin分支从master创建后又往前走了一些commits(这可能是由于其他的branch已经merge到了master,或者在master上直接做了commit,或者有人在master上cherry-picked了一些commits),那么这时在master和oauth-signin之间就产生了分叉(也就是说master不可能在不会退的情况下直接到oauth-signin)。在这种情况下,git将会自动地产生一个"true merge"
这是我们要的也是我们希望的,并不需要任何额外工作。
然而,如果master在oauth-signin创建后并未向前走,后者就是master的直接后代(无分叉),这时GIT默认地在merge时是执行一个fast-forward的merge策略,git并不会创建一个merge commit而是简单地把master分支标签移动到oauth-signin分支tip所指向的commit。这时oauth-sigin分支就变成了一个"透明"的分支了:在历史图谱中无法得知oauth-signin分支的起始位置在哪里,并且一旦这个branch被删除,那么从历史上我们再也无法看到任何关于这个开发分支曾经存在的历史渊源。
这不是我们所想要的,所以我们通过强制git产生一个真正的merge---通过使用--no-ff参数(no fast forward的意思)。