01 分支的实现原理
Git的分支特性常常被称为“必杀技特性”,因为分支给团队开发提供了很大的便利,而且在Git中的分支实现非常轻量,创建分支,切换分支等复杂操作在Git中只是改变指针指向而已。
在学习分支之前,我们先来回顾一下一次提交的形成过程。Git中有三个存放文件的区域,工作区,暂存区,上一次的提交对象,使用Git的过程其实是操作这三个区域的过程。工作区就是我们在电脑上看到的目录(不包含.git目录,.git目录默认是隐藏的),我们的新建,删除,修改操作都在这里进行。
我们现在的工作流程是这样的。在对工作区改动过后,Git发现工作区里的内容和暂存区里的内容不一样了,就会发出提示说有未暂存的改动(即git status
命令显示出的Changes unstaged for commit
和 Untracked files
),然后我们把需要的改动更新到暂存区里,Git又发现暂存区里的内容和上一次提交的内容不一样了,就发出提示说有待提交的改动(即git status
命令显示出的Changes to be commited
中的内容),最后我们用git commit
进行提交,Git会把暂存区保存成一个文件,然后把指向这个文件的指针与提交者的用户名和邮箱,提交时间等内容,放到一个新建的commit对象里,并让这个commit对象链接到上一个对象,当然commit对象也会被保存起来,而且会计算出一个SHA1校验和让我们来找到这个commit对象,最后把上一个提交对象替换为刚生成的commit对象(其实就是把HEAD指针的指向从上一个commit对象改为了新建的commit对象),一次提交就这么结束了。按上述步骤一直工作下去,我们的commit对象可能会链接成下面这样子。
然后我们发现现在这个功能的实现方式并不好,我们要回退到这个功能开发前的那一个版本重新开发,于是我们用git reset --hard 提交2
指令跳回去了,随着这个指令的执行,Git把HEAD指针的指向改成了提交2,并且把暂存区内容更新为了提交2中的内容,因为加了--hard
参数,Git会接着将工作区的内容更新为暂存区中的内容。 注意Git并不会把提交删掉,有同学会想,既然没删掉,那为什么我用git log
看不到他们了,因为git log
是从HEAD指向的那个提交开始,依次打log的。
然后我们接着开发,中途提交了两次,commit对象就变成了这样。
之后,你可能又觉得不好,又从头来一遍,或者你觉得还是第一次那种好,就用git reflog
查出了那次提交4的commitID,用git reset --hard commitID
跳回去了。其实这样我们已经创建了两个分支了,在这个例子中他们分别代表了我们功能的两种实现方法。之后你可能又会开发很多功能,每个功能里,可能还会有很多小功能,……最后你的commit对象就连接成了这个样子。
额。总之就变成了一棵树的形状,有很多的分支。Commit对象的树形结构为分支特性提供了天然的支持,剩下要做的就只是把分支标志出来而已,于是Git给了每个分支一个指向提交的指针,从这个指针一直遍历到结尾就是这个分支的提交历史。而且对分支的操作,都只是改变指针指向而已。
以前我们说过,HEAD表示了当前版本,其实HEAD是指向分支的,它标志了当前分支,而当前分支的分支指针确定了当前的版本。在每次提交的时候,Git只是在新建commit对象的基础上,把当前分支的分支指针指向新的提交而已。而以前我们所说的git reset
操作只是把当前分支的分支指针指向对应提交,然后替换暂存区而已,如果有--hard
参数那么再替换工作区。分支间的切换也非常简单,如果要从分支1切换到分支2上去开发,只要把HEAD指针指向分支2,然后把暂存区和工作区的内容用分支2的分支指针对应的Commit对象中的内容替换即可。
像我们之前看到过的master
,是Git仓库初始化时默认生成的分支,我们之前的操作都是在master
分支上进行的。
上面的例子用Git中的分支模型表示的话就像下面这样。
在实际开发中,通常会在主分支外,给各个功能的开发新建一个分支,并在功能开发完了之后用分支的合并操作,将分支合并到主分支上去。就像是一个汽车工厂,分开建造各种零件,在零件造好了之后合并到汽车架子上去。
02 分支的作用
分支间互不影响的特点可以在团队开发中发挥很大的作用。两个人可以各建一条分支来开发新功能,只要在开发完后将代码合并到主分支即可,而不必频繁地合并代码,提高了效率,同时也保持了主分支的整洁。
在开发中,经常会遇到线上版本出现Bug,不得不放下手头新功能的开发去修复bug的情况。在Git中,我们通常会建两条分支,一条用来发布新版本,一条用来开发新版本,在新版本开发完后再把这个版本合并回主分支。如果出现了bug,只要在主分支上修改即可,不会和新功能的开发混合起来。有的同学可能会说,那我一条分支时,出现了bug,也只要修复代码就好了呀!注意,向外发布的是稳定的版本,肯定不能把还没开发好的新功能混在一起发布出去啊,那你还得把新功能的代码删掉,在发布后又要把新功能的代码恢复回来继续开发。
另外,用好分支可以保持主分支的整洁,也能让整个项目的开发历史清晰明了。为了更好的利用分支,还演化出了各种各样的工作流,这里不多介绍,大家可以通过文章顶部的链接自行了解。
03 分支操作
常用的分支操作有创建分支,分支间切换,分支的合并(merge)和变基(rebase)。接下来会依次介绍。
分支操作一:创建分支
分支的创建可以用git checkout -b <branch_name>
来完成,branch_name
为分支名。如果该分支不存在,就会创建这个分支并切换到该分支上。其实,Git只是创建了一个指针,并且把HEAD指向了这个指针而已。
当前我们的提交历史如下,只有一个master
分支,origin/master
是远程分支,对应远程库中的master
分支。
用以下指令在当前位置创建两个新的分支,名为produceWheel
和produceEngine
。
$ git checkout -b produceWheel
$ git checkout -b produceEngine
可以看到多了两个标签,它代表了我们刚创建的两个分支。
现在我们在当前分支上新建一个提交试试,我们修改activity_main.xml文件,在最后添加一行produceEngine,然后提交。
$ git commit -am "EngineCompleted"
可以看到,HEAD标签和produceEngine标签前进了一个提交。Git在新建提交对象之后,把produceEngine分支的分支指针指向了新的提交对象,而HEAD标签随着当前分支的移动而移动。
可以用git branch -D <branch_name>
来删除对应分支。
Android Studio中的相应操作
分支的创建操作如下,选中对应的提交,鼠标右键,在弹出的菜单中按下New Branch
,在弹出的对话框中填写分支名即可。如果要删除分支,也是选中分支所在的提交,然后鼠标右键,在弹出的菜单中按如下路径找到删除操作,删除即可(注意不能删除当前分支,删除当前分支前,先切换到别的分支)。
分支操作二:分支间切换
分支间的切换可以用git checkout <branch_name>
来完成。要注意它和git reset [--hard] <commitID>
的区别。他们的本质区别是git checkout <branch_name>
切换的是HEAD指针的指向来改变当前版本的,准确的说是切换当前分支,而git reset [--hard] <commitID>
是改变当前分支的分支指针的只想来改变当前版本的。
这里演示一下他们的区别。当前提交历史如下,当前分支为produceEngine。
我们切换分支到produceWheel上。
$ git checkout produceWheel
再查看提交历史,只是HEAD标签换了个指向,指向了produceWheel,从而改变了当前版本。
将分支切换回去,我们用git reset [--hard] <commitID>
回退到同一个版本看一下效果。
$ git reset --hard HEAD^
再查看提交历史,发现除了HEAD以外,produceEngine也一起回到了上一个版本。其实HEAD的内容没变,变的是produceEngine,只是因为HEAD是指向produceEngine的,所以连带的回到了上一个版本。
当然,除了指针指向的切换以外,git checkout <branch_name>
还会把暂存区和工作区的内容替换为切换后的提交中的内容。这一点和git reset --hard <commitID>
操作一样,不过git checkout <branch_name>
在发现有还未提交的改动时,它会报错并提醒用户将这些改动贮藏起来(用git stash
操作可以保存当前所有改动,到需要的时候再恢复进工作区),或者删掉(git reset --hard HEAD
)他们,等到清理完了这些改动后才会允许用户进行git checkout <branch_name>
操作,但其实Git是很机智的,他会先尝试着将改动合并到目标分支的工作区里,如果有冲突才会报错。
Android Studio中的相应操作
先选中分支所在提交,鼠标右键,按如下路径进行操作即可。
分支操作三:分支的合并
可以用git merge <branch_name>
来合并分支,它能将branch_name
所指的分支和并到当前分支上来,合并的时候注意一下是谁合并到谁上。合并其实可以看成是一种特殊的提交,因为它把两条分支上的改动合并到一起,在当前分支上生成一个新的提交,与普通提交不同的是,合并的改动来自两个提交,所有它会连接到两个父提交对象上。
Git中的合并通常被叫做三方合并。合并时会确定三个提交,当前分支对应的提交,被合并分支对应的提交,和两条分支的共同祖先提交。Git会把其他两个提交相对于共同祖先提交的改动提取出来,如果这两份改动里对同一个文件进行了改动,那么Git就会提示自动合并失败,让我们手动修改冲突的文件,在修改完后,使用git commit
把所有改动提交,如果没有对同一个文件进行改动,Git就会自动帮我们添加所有改动到新提交中。
下面合并操作的演示。我们当前的提交历史如下。
我们试着将分支produceEngine
合并到主分支上。
$ git checkout master // 先切换到master分支上
$ git merge produceEngine -m "引擎生产完成" // 把produceEngine分支合并到master分支上
结果如图所示。当前分支是master
(因为这个从图里看不出来,再截一张图又太累赘,我就直接跟你们讲了),合并之后,master
从添加了忽略规则
前进到了EngineCompleted
,这时同学们又要问了,前面不是说master
分支会新建一个提交,然后这个提交会同时连接两个分支的吗!这是个特殊情况,从两个分支的共同祖先出发,master
分支压根就没有改动,只有produceEngine
分支上有改动,那还合并什么,produceEngine
分支的当前提交就是我master
分支要的结果呀!于是Git就偷了个懒,直接把master
分支的分支指针指向produceEngine
分支的当前提交了。这种简化的合并模式叫做——快进(fast-forward)。
有的时候我们不想要用快进模式来合并,我们想看保留我们的工作轨迹——在哪开始开发这个功能,在哪这个工作完成,中间有哪几步。我们可以用--no-ff
来关闭快进模式。让我们用--no-ff
重新来一遍。
$ git reset --hard HEAD^ // 撤销上次合并
$ git merge --no-ff produceEngine -m "引擎生产完成"
不用快进模式时的提交历史如下。跟我们之前设想的一样。
现在我们转去生产轮胎。先切换到produceWheel
分支上。
$ git checkout produceWheel
再在produceWheel
分支上进行开发,这边我们在activity_main.xml
的最后加上一行ProduceWheel
,然后提交。
$ git commit -am "WheelCompleted"
开发完后,我们切换回master
分支把改动合并回来。
$ git merge produceWheel -m "轮子生产完成"
结果,额。报错了。
Git提示说它尝试了自动合并,但是失败了。因为在master
分支上我们改了activity_main.xml
文件,而在produceWheel
分支上我们也改了activity_main.xml
文件,Git不知道要怎么处理这些两个改动。于是Git提示我们,让我们解决冲突后,提交结果。
我们打开冲突文件,Git已经帮我们标记出了各分支的修改。
我们手动修改这个文件,修改结果如下。
然后提交。
$ git commit -m "轮胎生产完成"
提交之后,合并就完成了,现在我们的提交历史是下面这个样子的。
Android Studio中的相应操作
选择要合并进当前分支的分支,鼠标右键,按如图所示路径操作。如果有冲突存在,Android Studio会弹出
Files Merged with Confilcts
对话框,右边有三个选项,分别是,采用自己的改动,采用其他分支的改动,合并改动,我们选择合并改动。然后会弹出合并改动的窗口,左边是当前分支的改动,右边是其他分支的改动(要合并进来的那个分支) ,中间是冲突合并的结果。选择箭头可以采用改动到结果中,而选择叉号会忽略这个修改。
这里我们两边的改动都采用了。
冲突解决完成后,点击
Apply
按钮即可完成合并。上述方式虽然方便,但是没有办法自己写提交信息。如果要写提交信息,可以用
VCS->Git->Merge Changes
下的合并操作,窗口如下,在Commit Message栏里可以填写提交信息。用这种方法合并,在解决冲突后需要自己提交一下。
分支操作四:变基
除了合并之外,变基也可以将两条分支的内容整合到一起。使用变基操作时,Git会先确定两条分支的共同祖先,然后会依次当前分支中各个提交的修改提取出来,并且结合另一个分支上的修改,形成新的提交,一个一个拼接到指定分支之后。
变基可以使提交历史整洁,但是它会修改已有的提交历史。
对已经推送到中央仓库的分支,要慎重考虑能不能用变基。变基会修改已有的提交历史,而且会删去原提交历史上的提交(内容没删,只是不再连接到原有的树形结构上了), 但是对中央仓库的fetch操作只能拿取本地分支没有的提交,而不会删除中央仓库没有但本地存在的提交。如果其他人拉取过变基前的分支,那他可能要手动把它删除,如果他不仅拉取过,而且在之上进行了一些开发,进行过提交,合并等等操作,那他可能会打你一顿。
变基可以用命令git rebase <branch_name>
完成,它会将当前分支整合到指定分支上。举个栗子,我们当前的提交历史如下。
我们将produceWheel分支rebase
到master
上。
$ git rebase master
运行后,Git提示我们它正在把WheelCompleted
这个提交应用到master分支上,但是在合并activity_main.xml
文件时,发生了冲突,让我们解决冲突后用git rebase --continue
继续rebase
。
用冲突解决工具,显示如下,左边是提交WheelCompleted
的内容,右边是master
分支上提交引擎生产完成
的内容,它们均在祖先提交添加了忽略规则上
,添加了一行。
我们把改动都添加上去。冲突解决后输入命令git rebase --continue
继续变基。在这之后master
分支上就生成了一个新的WheelCompleted
提交。
接着,Git又告诉我们它正在把BetterWheelCompleted
这个提交应用到master
分支上,但是在合并activity_main.xml
文件时,发生了冲突,让我们解决冲突后用git rebase --continue
继续rebase
。
再次打开冲突解决工具,显示如下,左边是提交BetterWheelCompleted
的内容,右边是master
分支上新WheelCompleted
提交的内容,他们均在老WheelCompleted
提交的基础上添加了一行。
老样子,我们把改动都添加上,然后输入命令git rebase --continue
继续变基。然后,master分支上就又生成了新的BetterWheelCompleted
提交,至此提交已经全部转移完成了,Git将produceWheel
分支的分支指针指向新的BetterWheelCompleted
提交,变基至此就结束了。完成后的提交历史如下,这时只要再进行一次简单地快进合并就把produceWheel
分支的内容整合到master
分支上了。
大家可能会对rebase
过程中的各个新节点的产生过程感到迷惑。其实新节点可以看做是由旧节点和上一个提交的新节点以上一个提交的旧节点为祖先节点合并产生,举个栗子,新BetterWheelCompleted
节点可以看做是由旧BetterWheelCompleted
节点和新WheelCompleted
节点以旧WheelCompleted
节点为祖先合并产生的。
Android Studio中的相应操作
首先,切换到要变基的分支,然后在如下路径中进行操作,因为解决冲突等操作与合并中相同,就不再多说了。也可以使用
VCS->Git->Rebase
路径中的操作。