每一种版本控制都以某种形式支持分支。
使用分支的好处就是你可以从开发主线上分离开来,在不影响主线的同时继续工作。
在之前的版本控制系统中,这个是奢侈昂贵的操作,经常需要创建一个源代码目录的完整副本,对大型项目来说花费大量时间。
有了Git分支模型,将Git从版本控制系统家族区分出来,它以难以置信的轻量级,新建操作几乎可以在瞬间完成,并且在不同分支见切换起来也超快。
Git在工作流程中频繁使用分支与合并,当你理解分支的概念并熟练运用后,你才会意识到为什么Git是一个强大独特的工具,并且会改变你的开发方式。
我们要想理解Git分支的实现方式,需要回顾一下Git是如何储存数据的。
Git保存的不是文件的差异或者变化量,而是一系列文件快照。
在Git提交时,会保存一个提交对象,该对象包含一个指向暂存内容快照的指针,包含本次提交的作者等相关附属信息,包含0个或n个指向该提交对象的父对象指针:首次提交是没有直接祖先的,普通提交有一个祖先,2个或n个分支合并产生的提交则有多个祖先。
在这里我们举个栗子:
假设工作目录有三个文件,准备将它们暂存后提交。
暂存操作会对每一个文件计算校验和(SHA-1哈希字串),然后把当前版本的文件快照保存到Git仓库中(使用blob类型的对象存储这些快照),并将校验和加入暂存区域:
先执行git add README test.rb LICENSE
然后执行git commit -m “initial commit of my project”
当git commit 新建一个提交对象前,Git会先计算每一个子目录(本栗子中就是项目根目录)的校验和,然后在Git仓库中将这些目录保存为树(tree)对象。
之后Git创建的提交对象,除了包含相关提交信息以外,还包含这个树对象(项目根目录)的指针,如此它就可以在将来需要的时候,重现此次快照的内容了。
不要蒙兄弟们,我们来分析一下:
现在Git仓库中有五个对象:
三个表示快照内容的blob对象(之前聊过,文件是由blob方式储存的);
一个记录着目录树内容以及各个文件对应blob对象索引的tree对象;
一个包含指向tree对象(根目录)的索引和其他提交信息元数据的commit对象。
图(1-1)——单个提交对象在仓库中的数据结构
做些修改再次提交,那么这次提交对象包含一个指向上次提交对象的指针(下图中的parent对象)。两次提交后,仓库历史会变成下图的样子:
现在来谈分支。
Git分支,本质上仅仅是指向commit对象的可变指针。GIt会使用master作为分支的默认名字。
在若干次提交后,我们其实已经有一个指向最后一次提交对象的master分支,它在每次提交的时候都会自动向前移动。
图(3-3)——某个提交对象往回看的历史
那么,Git又是如何创建一个新的分支的呢?答案很简单,创建一个新的分支指针。
比如新建一个testing分支,我们使用git branch
命令
$ git branch testing
这会在当前commit对象上新建一个分支指针:
图(1-4)——多个分支指向提交数据的历史
有个问题:Git是如何知道你当前在哪个分支上工作的呢?
答:它保存着一个名为HEAD的特别指针。
在Git中,它表示一个指向你正在工作中的本地分支的指针(理解为当前分支的别名就行)
我们之前仅仅是建立一个新的分支,但不会自动切换到这个分支上去,所以我们现在易燃还在master分支里工作:
如果我们要切换到其他分支,执行git checkout
命令:
这时候HEAD就指向了testing分支:
图(1-6)——HEAD转换分支时指向新的分支
可能你会问了 ,感觉有点麻烦,这样做带给我们什么好处?
我们再提交一次就可以发现里面的秘密:
$ vim test.rb
$ git commit -a -m 'made a change'
展示提交后的结果:
图(1-7)——每次提交后HEAD随着分支一起向前移动
所以你可以看到,testing向前移动了一格,而master仍然指向原先git checkout
时所在的commit
对象,现在我们回到master
分支看看:
$ git checkout master
图(1-8)——HEAD在一次checkout之后移动到了另一个分支
我们解读一下:这条命令做了两件事情,它把HEAD指针移动到了master分支,并且把工作目录中的文件换成了master分支所指向的快照内容。
也就是说,现在开始所做的改动,将始于本项目中较老的版本。
它的主要作用是将testing分支里作出的修改暂时取消,这样我们就可以向另一个方向进行开发。
我们作些修改后再次提交:
执行代码
$ vim test.rb
$ git commit -a -m 'made other changes'
现在我们的项目提交历史产生了分叉,因为刚才我们创建了 一个分支,转换到其中做了一些工作,然后又回到原来的主分支进行了另外一些工作。
这些改变分别孤立在不同的分支里:我们可以在不同分支里反复切换,并在时机成熟时把它们合并到一起。而所有这些工作,仅仅需要branch
和checkout
这两条命令就可以完成。
图(1-9)不同流向的分支历史
由于Git分支实际上仅仅是一个包含所指对象检验和(40个字符长度SHA-1字串)的文件,所以创建和销毁一个分支就变得非常廉价了。
我们举个例子来说:
1.开发一个网站。
2.实现某个需求,创建一个分支。
3.在这个分支上开展工作。
此时,突然接到一个电话说出了一个bug很严重需要紧急修补,那么我们可以按照下面的方式处理:
1.返回到原先已经发布到生产服务器上的分支。
2.为这次紧急修补建立一个新分支,并在其中修复问题。
3.通过测试后,回到生产服务器所在的分支,将修补分支合并起来,然后再推送到生产服务器上。
4.切换到之前实现新需求的分支,继续工作。
首先,假设你正在项目中工作,并且已经提交了几次更新:
图(2-1)——一个提交历史
现在,你需要去修补问题追踪系统上#53问题。
这里我们把新建的分支取名为iss53,要新建并切换该分支,运行git checkout
并加上- b
参数:
$ git checkout -b iss53
相当于执行下面两条命令:
$ git branch iss53
$ git checkout iss53
该命令执行结果:
图(2-2)——创建了一个新分支的指针。
接着你开始尝试修复问题,在提交了若干次更新后,iss53
分支的指针也会随着向前前进,因为它就是当前分支(换句话说,当前的HEAD指针正指向iss53):
//举个修改的例子
$ vim index.html
$ git commit -a -m 'added a new footer [issue 53]'
现在你接到了网站问题的紧急电话,需要马上修补。
有了Git,我们就不需要同时发布这个补丁和iss53里作出修改,也不需要再创建和发布该补丁到服务器之前大费力气来复原这些修改。
我们唯一需要做的是切换回master分支。(再次之前,留心你的暂存区或者工作目录里,那些还没有提交的修改,它会和你即将检出的分支产生冲突从而阻止Git切换分支)
切换master分支:
$ git checkout master
此时工作目录中的内容和你在解决问题#53之前一模一样,我们可以几种精力修补。
有一点需要牢记:Git会把工作目录的内容恢复为检出某分支时它所指向的那个提交对象的快照。它会自动添加,删除和修改文件以确保目录的内容和你当时提交的完全一样。
接下来,我们要紧急修补。我们创建一个紧急修补分支hotfix
来搞定:
$ git checkout -b 'hotfix'
$ vim index.html
$ git commit -a -m 'fixed the broken email address'
图(2-4)——hotfix分支是从master分支所在点分化出来的。
有必要做些测试,确保修补是成功的,然后回到master分支把它合并起来,然后发布到生产服务器,用git merge
命令来进行合并:
$ git checkout master
$ git merge hotfix
Updating f42c576..3a0874c
Fast forward
README | 1 -
1 files changed, 0 insertions(+), 1 deletions(-)
注意,合并出现了“Fast forward”的提示。由于当前master
分支所在的提交对象是要并入的hotfix
分支的直接上游,Git只需把master分支指针直接右移。换句话说,如果顺着一个分支走下去可以到达另一个分支的话,那么Git在合并两者时,只会简单地把指针右移,因为这种单线的历史分支不存在任何需要解决的分歧,所以这种合并过程可以称为快进(Fast forward)。
现在最新的修改已经在当前master分支所指向的提交对象中了,可以部署到生产服务器上去了。
图(2-5)——合并之后,master和hotfix分支指向同一位置。
在修补发布之后,你想要回到被打扰之前的工作。
由于当前hotfix分支和master分支都指向相同的提交对象,所以hotfix已经完成了历史使命,可以删掉了。
使用git branch -d
选项执行删除操作:
$ git branch -d hotfix
现在回到之前未完成#53问题修复分支上继续工作:
$ git checkout iss53
$ vim index.html
$ git commit -a -m 'finished the new footer'
[iss53]: created ad82d7a: "finished the new footer [issue 53]"
1 files changed, 1 insertions(+), 0 deletions(-)
图(2-5)——iss53分支可以不受影响继续推进
不用担心之前hotfix分支的修改内容尚未包含到iss53中来。
如果确实需要纳入此次修补,可以用git merge master
把master分支合并到iss53;
或者等iss53完成之后,再将iss53分支中的更新并入到master。
在问题#53先关的工作完成之后,可以合并回master分支。
实际操作同前面合并hotfix分支差不多,只需回到master分支,运行git merge命令指定要合并进来的分支:
$ git checkout master
$ git merge iss53
Merge made by recursive.
README | 1 +
1 files changed, 1 insertions(+), 0 deletions(-)
注意,这次合并操作的底层实现,并不同于之前hotfix的并入方式。因为这次你的开发历史是从更早的地方开始分叉的。见(下图3-1)
由于当前master分支所 指向的对象(C4)并不是iss53的直接祖先,Git不得不进行一些额外的处理。
就此例而言,Git会用两个分支的末端(C4和C5)以及它们的共同祖先进行一次简单的三方合并计算。
图(3-1)——Git分支合并自动识别出最佳的同源合并点。
这次Git没有简单地把分支指针右移,而是对三方合并后的结果重新做一个新的快照,并自动创建一个指向它的提交对象(C6)。
这个提交对象比较特殊,它有着两个祖先(C4和C5)。
值得一提的是Git可以自己裁决哪个共同祖先才是最佳合并基础;
图(3-2)——Git自动创建一个包含了合并结果的提交对象。
之前工作成功已经合并到了master了,那么iss53也就没用了。你就可以就此删除它,并在问题追踪系统里关闭该问题。
$ git branch -d iss53
有时候合并操作并不会如此顺利。
如果在不同的分支中都修改了同一个文件的统一部分,Git就无法干净地把两者合到一起。(逻辑上说,这种问题只能由人来裁决)
如果你在解决问题#53的过程中修改了hotfix中修改的部分,将得到类似下面的结果:
$ git merge iss53
Auto-merging index.html
CONFLICT (content): Merge conflict in index.html
Automatic merge failed; fix conflicts and then commit the result.
Git做了合并,但是没有提交,它会停下来等你解决冲突。要看看哪些文件在合并时发生冲突,可以使用git status
来查看:
[master*]$ git status
index.html: needs merge
# On branch master
# Changes not staged for commit:
# (use "git add ..." to update what will be committed)
# (use "git checkout -- ..." to discard changes in working directory)
#
# unmerged: index.html
#
任何包含未解决冲突的文件都会以未合并(unmergeed)的状态列出。
Git会在有冲突的文件里加入标准的冲突解决标记,可以通过手工定位并解决这些冲突。
可以看到文件包含类似下面这样的部分:
<<<<<<< HEAD:index.html
<div id="footer">contact : [email protected]div>
=======
<div id="footer">
please contact us at [email protected]
div>
>>>>>>> iss53:index.html
可以看到=======
隔开的上半部分,是HEAD(即master分支,在运行merge命令时所切换到的分支)中的内容,下半部分是在iss53分支中的内容。
解决冲突的办法无非是二者选其一或者我们亲自整合到一起。比如你可以通过这段内容替换为先这样来解决:
<div id="footer">
please contact us at [email protected]
div>
这个解决方案各采纳了两个分支的一部分,还删除了 <<<<<<<,======= 和 >>>>>>> 这些行。
在解决了所有文件里的所有冲突后,运行git add
将它们标记为已解决状态。(实际上就是来一次快照保存到暂存区域)
因为一旦暂存,就表示冲突已经解决。如果你想用一个有图形界面的工具来解决这些问题,不妨运行git mergetool
,它会调用一个可视化的合并工具并引导你解决所有冲突:
$ git mergetool
merge tool candidates: kdiff3 tkdiff xxdiff meld gvimdiff opendiff emerge vimdiff
Merging the files: index.html
Normal merge conflict for 'index.html':
{local}: modified
{remote}: modified
Hit return to start merge resolution tool (opendiff):
退出合并工具后,Git会询问你合并是否成功。如果回答是,它会为你把相关文件暂存起来,以表明状态为你解决。
再运行一次git status
来确认所有冲突都已解决:
$ git status
# On branch master
# Changes to be committed:
# (use "git reset HEAD ..." to unstage)
#
# modified: index.html
#
如果觉得满意了,并且所有冲突都已经解决,也就是进入了暂存区,就可以用git commit
来完成这次合并提交。提交的记录差不多是这样:
Merge branch 'iss53'
Conflicts:
index.html
#
# It looks like you may be committing a MERGE.
# If this is not correct, please remove the file
# .git/MERGE_HEAD
# and try again.
#
我们学习了创建,合并和删除分支。除此之外,还需要学习如何管理分支,日后的常规工作中会经常用到下面介绍的管理命令。
使用git branch
命令 不仅仅能创建爱你和删除分支,如果不加任何参数,它会给出当前所有分支的清单:
$ git branch
iss53
*master
testing
注意看master分支前的*字符,它表示当前所在的分支。也就是说,master分支将随着开发进度前移。
若要查看各个分支最后一个提交对象的信息,运行git branch -v
:
$ git branch -v
iss53 93b412c fix javascript issue
* master 7a98805 Merge branch 'iss53'
testing 782fd34 add scott to the author list in the readmes
要从该清单中筛选出你已经(或尚未)与当前分支合并的分支,可以用--merge
和--no-merged
选项。
比如git branch --merge
查看哪些分支已被并入当前分支(哪些分支是当前分支的直接上游):
$ git branch --merged
iss53
* master
之前我们已经合并了iss53,所以这里会看到它。
一般来说,列表中没有*的分支通常可以用git branch -d
来删掉。
原因很简单,既然已经把它们所包含的工作整合到了其他分支,删掉也不会损失什么。
另外可以用git branch --no-merged
查看尚未合并的工作:
$ git branch --no-merged
testing
它会显示还未合并进来的分支。由于这些分支中还包含着尚未合并进来的分支。由于这些分支还包含着尚未合并进来的工作成果,所以简单地用git branch -d
删除该分支会提示错误,因为那样会丢失数据:
$ git branch -d testing
error: The branch 'testing' is not an ancestor of your current HEAD.
If you are sure you want to delete it, run 'git branch -D testing'.
不过如果你确实想删除该分支上的改动,可以用大写的删除选项-D强制执行,就像上面提示信息给出的那样。