学习git rebase

神奇的git rebase。

写在前面

前段时间老大新教了一个技能,使用git rebase master -i 来修改或者合并commit,用了之后,顿时有种发现新世界的感觉,好工具有必要花时间了解下。

过了下pro git,梳理下git rebase的基本使用以及“偷天换日”的神技。

正文

  • 基本用法

    pro git中,对于basic rebase是这么说的:

    With the rebase command, you can take all the changes that were committed on one branch and replay them on another one.

    简单来说,就是相当于把一个分支A嫁接到另一个分支上B。

    书中举了这么一个例子:

    现在需要将master 和experiment这两个分支合并。

    有两种方式:

    • merge
    • rebase

    先看merge,很简单,终端执行:

    git checkout master ## 切到分支master
    git merge experiment  ## 将experiment merge 到master
    

    用three-merge 【我理解为三路合并】的方式来将两个最新的分支快照(C3和C4)和它们最近的共同祖先(C2)之间进行合并,创建了一个新的快照 C5。

    效果:

    rebase的方式,终端执行:

    git checkout experiment ## 切到分支experiment
    git rebase master ## 将experiment嫁接到master上,即ancestor由原来的C2,变成了C3,生成了新的snapshot C4‘
    git checkout master ## 切回到master
    git merge experiment ## 将experiment merge进 master
    

    效果:

    这里你估计要嘀咕了,git rebase好像多走了两步啊。对,相比merge,它是多走了两步,但是这两步,让整个整合的过程变得很清晰,不同于three-merge的方式,而是直线式,先嫁接再简单的合并。而且你还可以选择,只rebase,不merge,什么意思呢?看下面一个很常见的场景:

    你在master上开了新的分支B1,同时其他人也开了分支B2,随后B2被merge进了 master,此时,你将分支push到远端代码仓库,对分支进行git push -u origin B1时,会显示有冲突,因为此时B1是基于本地的master来开发的,而远端origin因为已经merge了B2, 跟本地的master已经不同,怎么处理?

    思路也很清晰,先把远端最新的代码pull下来,让本地的master与origin/master同步,然后将B1 rebase 到同步后的master上即可。

    终端执行:

    git checkout master ## 切换到master
    git pull origin master ## 拉下origin/master,让本地master与origin/master同步
    git checkout B1 ## 切换到分支B1
    git rebase master ## 将B1嫁接到master上,这样B1就是在最新的master上进行开发了,随后你push B1时,项目的maintainer就可以直接merge你的分支了
    

    当然, rebase master的时候,可能会出现冲突问题,比如B2分支和你的B1同时修改了同一个地方,此时需要手动修正冲突,修改后,git add, 不用commit,然后git rebase —continue,直到没有冲突,完成rebase。

    如果rebase仅仅只是这样,好像也没什么神奇的,下面才是重头戏。

  • 偷天换日,篡改历史

    执行如下命令,可以进入git rebase的interactive 交互模式 :

    git rebase -i xxx
    

    其中XXX指的是你要在哪个commit的基础上进行修改,也就是最后一个修改的commit的父commit,比如你现在在分支B上,想要rebase 分支B的最后3个,那就写:

    git rebase -i HEAD~3
    

    这样rebase的时候,重新生成的commit history,就会是从HEAD, HEAD1到HEAD2的快照,而不会影响其它的snapshot。

    同样,想要rebase 分支B的所有commit,假定B是从master主分支上开发的,则可以直接:

    git rebase -i master
    

    进入到interactive 模式后, 可以看到目前有7个命令可以使用:

    简单过一下上面的命令列表:

    • p, pick = 要这条 commit ,什麼都不改
    • r, reword = 要这条 commit ,但要改 commit message
    • e, edit = 要这条 commit,stop for amending,不仅仅是edit message
    • s, squash = 要這條 commit,但要跟前面那条合并,合并后的message可以自己定义
    • f, fixup = 类似squash,但是不要这条 message,直接用前面的那条
    • e, exec = 使用shell执行指令
    • d, drop = 移除这条commit

    编辑好后,退出即可。

    光说不练没什么感觉,找个项目练练。

    假定现在有一个rails的项目movie,目前的commit history长这样:

    我们把最后6个拎出来练练。

    终端执行:

    git rebase -i HEAD~6
    

    使用默认编辑器打开了git-rebase-todo:

    我们修改一下commit “ implement like & dislike movie “ ,把 & 换成 and, 使用reword 命令试试。

    保存后退出,会打开COMMIT_EDITMSG, 我们将&改成了and:

    保存后退出,打开fork,这时会发现最后6个commit的short SHA-1已经变了,生成了新的short SHA-1:

    看看edit:

    同样的步骤,我们这次动 41e6c73 implement my favorite movies, 把my 改成 users‘:

    保存后退出,终端会输出如下的内容:

    Stopped at 41e6c73... implement my favorite movies
    You can amend the commit now, with
    
        git commit --amend
    
    Once you are satisfied with your changes, run
    
        git rebase --continue
    

    按照指示来,执行git commit --amend, 在打开的编辑器中修改:

    保存后退出,执行git rebase --continue , 完成, 此时查看short SHA-1, 会发现,从刚刚修改的那条commit开始,又重新生成了新的short SHA-1。

    这样看,edit 和reword好像没啥区别,额,你这可就小看edit了,修改message只是amending的一种哦,我们还可以使用edit来拆分commit。

    比如我们来拆分 9d1e108 implement like and dislike movie

    还是一样的,在它前面添加e:

    同样,保存后退出,回到命令行,我们reset下这个commit,将所有的文件从history里面捞出来,变成unstaged状态,然后再创建多个commit。终端执行:

    git reset HEAD~
    git add app/controllers/movies_controller.rb
    git commit -m "add like and dislike actions"
    git add app/views/movies/show.html.erb
    git commit -m "add like and dislike button on movie's show page"
    git add config/routes.rb
    git commit -m "update routes for like and dislike actions"
    git rebase --continue
    

    此时,一个commit被拆成了三个,short SHA-1也都更新了。

    好,下一个,squash。

    刚刚我们用edit拆了一个commit,现在我们使用squash把它们合并回来。

    终端执行:

    git rebase -i HEAD~8
    

    bf9a37c66c0d7e前面的pick改成s:

    保存后退出,会打开COMMIT_EDITMSG:

    它会列出三个commit,如果你什么都不做,直接退出,它会自动将第2,3的commit合并到第一条,最后的commit message 变成:

    add method of like/dislike to movie

    add like and dislike actions

    add like and add like and dislike button on movie’s show page

    当然你也可以自己修改message,这里我们直接退出。

    打开fork,会发现已经合并了,三个add 变成了一个add。【换行的缘故,没有显示全部的message】

    额,我发现改错了,刚刚拆出来的不是这三条……擦,终端git rebase -i HEAD~9 了,把第9个commit给添加进来了……

    正好,我们再来一次,把0d92196 add method of like/dislike to movie , **c2795c3 ** update routes for like and dislike actions 合并, 这次换成自己定义新的commit message。

    按照前面的方式再走一遍,在打开的COMMIT_EDITMSG中:

    修改成:

    保存后退出,查看fork, 合并完成!

    fixup类似squash,只是它是直接用前面一条commit,舍弃这一条,我们来试试。

    b4062de use self delimit helper 进行fixup:

    保存后退出,rebase完成,查看fork:

    刚刚的 b4062de use self delimit helper 已经被合并进了implement my reviews里面了,导致从implement my reviews 开始后的三个commit 的short SHA-1都更新了。【箭头画偏了……】

    exec,运行命令:

    在pro git中没有看到exec相关的例子,不过在这篇文章git rebase –exec: make sure your tests pass at each commit! (and other rebase goodies)里面看到相关的操作, 照着试试。

    选择9996f59 render partial with collection, 我们添加上exec。

    终端执行:

    git rebase -i --exec "pwd" 9996f59
    

    会在9996f59后执行 pwd 命令,可以看到已经执行了pwd命令:

    额,Google上exec相关的内容还是较少的,少有的几篇说到了测试与重置authors的:

    d,移除commit:

    终端执行:

    git rebase -i HEAD-6
    

    删除最后一条 bac0634

    保存后退出, 查看fork:

    最后一条commit已经被移除了。

  • Rebase的危险性

    rebase也不是想rebase就rebase的,这么强大的权利,用的时候,要注意的。

    用书中的一句话就是:Do not rebase commits that exist outside your repository.

    换句话说,在你的分支还没有push到远端代码仓库时,你想怎么rebase都可以,但是如果你的代码已经push到远端了,而且你的同事是在你的分支的基础上开发的,那么请不要使用rebase来刷你的存在感。因为你rebase后,他们需要重新pull下你提交的最新代码,此时你之前的commit history和rebase后的commit history会让他们生生抓狂的。

    看个场景理解下:【偷懒,直接用书中的例子】

    此时,远端的分支master指向snapshot C1。

    本地你在teamone/master上开发后,commit了两次,history中有snapshot C2, C3。

    随后,你的同事A merge了C4, C5,生成了新的snapshot C6,然后git push,提交了新的代码,将master指向了C6。

    那很显然,你需要把teamone/master的代码fetch下来,merge后继续开发。同样,按照three-merge方式,这时git会将C3,C6和C1三路合并,生成新的snapshot C7。

    不幸的是,你的同事针对之前提交的分支,进行了rebase的操作,将C4嫁接到C5上,生成了新的snapshot C4‘, 然后git push —force 强制push到了远端,等同于刷新了之前的commit history。然后你继续fetch,再merge。

    此时,git merge了C4‘, C7和C1,重新生成快照C8,这时C8中来自于C4‘中的commit history,会跟C7中的commit history有重合的部分,相当于存在两个C4,导致出现两个一模一样的commit。

    书中在rebase your rebase 部分中,提到一个针对这种情况的解决方法,思路就是分辨出所有的snapshot中,哪些是你做的修改,哪些是你的同事做的,值得庆幸的是,git 帮我们处理了这种情况。

    终端执行:

    git fetch
    git rebase teamone/master
    

    会得到这样的效果:

    相当于将C2,C3直接嫁接到C4‘上,生成了新的commit,厉害!不过这种方法的前提是C4与C4’需要是一样的snapshot。至于原因,书中这样说的:

    Otherwise the rebase won’t be able to tell that it’s a duplicate and will add another C4-like patch (which will probably fail to apply cleanly, since the changes would already be at least somewhat there).

    如果C4与C4’不一样的话,rebase 无法分辨出C4‘是不是一个复制的commit snapshot?额,这里暂时没有理解透。

    不过个人看完的感觉就是,push后的分支,应当尽量避免使用rebase。

  • rebase VS merge

    个人的理解,两者还是不一样的,merge类似把两个东西融为一体,而rebase 则好比进行重新嫁接。书中提到commit history的一个意义就在于记录下发生了什么,使用merge会真实的记录下所有发生的事情,而rebase,诚如上面做过的那些commands,有着篡改历史的嫌疑,但也有好处,可以把杂乱的commit history整理的清清爽爽,谁喜欢一个分支上一堆没什么用的commits呢。

    借书中一段话作为结尾:

    In general the way to get the best of both worlds is to rebase local changes you’ve made but haven’t shared yet before you push them in order to clean up your story, but never rebase anything you’ve pushed somewhere.

参考

pro git

git rebase –exec: make sure your tests pass at each commit! (and other rebase goodies)

Execute a shell command on every commit in rebase

推荐阅读:git rebase, 写的很详细,类比也很形象。