Git 分支
对于任何一个文件,在Git内都只有三种状态:已提交(committed),已修改(modified)和已暂存(staged)。已提交表示该文件已经被安全地保存在本地数据库中了;已修改表示修改了某个文件,但还没有提交保存;已暂存表示把已修改的文件放在下次提交时要保存的清单中。
由此我们看到Git管理项目时,文件流转的三个工作区域:
Git的工作目录,暂存区域,以及本地仓库
基本的Git工作流程如下:
1.在工作目录中修改某些文件。
2.对修改后的文件进行快照,然后保存到暂存区域。
3.提交更新,将保存在暂存区域的文件快照永久转储到Git目录中。
所以,我们可以从文件所处的位置来判断状态:如果是Git目录中保存着的特定版本文件,就属于已提交状态;如果作了修改并已放入暂存区域,就属于已暂存状态;如果自上次取出后,作了修改但还没有放到暂存区域,就是已修改状态。
Git是如何储存数据:
在Git中提交时,会保存一个提交(commit)对象,该对象包含一个指向暂存内容快照的指针,包含本次提交的作者等相关附属信息,包含零个或多个指向该提交对象的父对象指针:首次提交是没有直接祖先的,普通提交有一个祖先,由两个或多个分支合并产生的提交则有多个祖先。
为直观起见,我们假设在工作目录中有三个文件,准备将它们暂存后提交。暂存操作会对每一个文件计算校验和(即第一章中提到的SHA-1哈希字串),然后把当前版本的文件快照保存到Git仓库中(Git使用blob类型的对象存储这些快照),并将校验和加入暂存区域:
$git add README test.rb LICENSE $ git commit -m 'initial commit of myproject'
当使用 gitcommit 新建一个提交对象前,Git会先计算每一个子目录(本例中就是项目根目录)的校验和,然后在Git仓库中将这些目录保存为树(tree)对象。之后Git创建的提交对象,除了包含相关提交信息以外,还包含着指向这个树对象(项目根目录)的指针,如此它就可以在将来需要的时候,重现此次快照的内容了。
现在,Git仓库中有五个对象:三个表示文件快照内容的blob对象;一个记录着目录树内容及其中各个文件对应blob对象索引的tree对象;以及一个包含指向tree对象(根目录)的索引和其他提交信息元数据的commit对象。
初次没有parent指针,第二次以后提交的对象会包含一个指向上次提交对象的指针(译注:即下图中的parent对象)。两次提交后
Git中的分支,其实本质上仅仅是个指向commit对象的可变指针。Git会使用
master作为分支的默认名字。在若干次提交后,你其实已经有了一个指向最后一次提交对象的master分支,它在每次提交的时候都会自动向前移动。
新建分支就是在当前commit对象上新建个指针
那么,Git是如何知道你当前在哪个分支上工作的呢?其实答案也很简单,它保存着一个名为HEAD的特别指针。请注意它和你熟知的许多其他版本控制系统(比如Subversion或CVS)里的HEAD概念大不相同。在Git中,它是一个指向你正在工作中的本地分支的指针(译注:将HEAD想象为当前分支的别名。)。运行git branch命令,仅仅是建立了一个新的分支,但不会自动切换到这个分支中去,所以在这个例子中,我们依然还在master分支
要切换到其他分支,可以执行 gitcheckout命令。我们现在转换到新建的testing分支:
$git checkout testing
这样HEAD就指向了testing
分支
这样的实现方式会给我们带来什么好处呢?好吧,现在不妨再提交一次:
$git commit -a -m 'made a change'
图展示了提交后的结果。每次提交后HEAD随着分支一起向前移动
这个时候切回master分子,它把HEAD指针移回到master分支,并把工作目录中的文件换成了master分支所指向的快照内容。也就是说,现在开始所做的改动,将始于本项目中一个较老的版本。它的主要作用是将testing分支里作出的修改暂时取消,这样你就可以向另一个方向进行开发
我们做些修改然后再提交,我们的项目提交历史产生了分叉,因为刚才我们创建了一个分支,转换到其中进行了一些工作,然后又回到原来的主分支进行了另外一些工作。这些改变分别孤立在不同的分支里:我们可以在不同分支里反复切换,并在时机成熟时把它们合并到一起。而所有这些工作,仅仅需要branch和 checkout这两条命令就可以完成
分支的合并
合并回 master分支。只需回到master分支,运行git merge命令指定要合并进来的分支:
$git merge iss53
请注意,由于当前 master分支所指向的提交对象(C4)并不是 iss53分支的直接祖先,Git不得不进行一些额外处理。就此例而言,Git会用两个分支的末端(C4和C5)以及它们的共同祖先(C2)进行一次简单的三方合并计算。用红框标出了Git用于合并的三个提交对象:
红色框为分支合并自动识别出最佳的同源合并点这次,Git没有简单地把分支指针右移,而是对三方合并后的结果重新做一个新的快照,并自动创建一个指向它的提交对象(C6)。这个提交对象比较特殊,它有两个祖先(
C4和C5)。值得一提的是Git可以自己裁决哪个共同祖先才是最佳合并基础;它们需要开发者手工指定合并基础。所以此特性让Git的合并操作比其他系统都要简单不少。
遇到冲突时的分支合并
有时候合并操作并不会如此顺利。如果在不同的分支中都修改了同一个文件的同一部分,Git就无法干净地把两者合到一起将得到类似下面的结果:
$git merge iss53 Auto-merging index.html CONFLICT (content): Mergeconflict in index.html Automatic merge failed; fix conflicts and thencommit the result.
Git作了合并,但没有提交,它会停下来等你解决冲突。要看看哪些文件在合并时发生冲突,可以用 gitstatus查阅:
[master*]
$git status
index.html: needs merge
# On branch master
# Changed butnot updated:
# (use "git add ..." to update what willbe committed)
# (use "git checkout -- ..." to discardchanges in working directory) # # unmerged: index.html #
任何包含未解决冲突的文件都会以未合并(unmerged)的状态列出。Git会在有冲突的文件里加入标准的冲突解决标记,可以通过它们来手工定位并解决这些冲突。可以看到此文件包含类似下面这样的部分:
<<<<<<<HEAD:index.html
contact: email.support@github.com
=======
pleasecontact us at support@github.com
>>>>>>>iss53:index.html
可以看到 =======隔开的上半部分,是 HEAD(即 master分支,在运行merge命令时所切换到的分支)中的内容,下半部分是在 iss53分支中的内容。解决冲突的办法无非是二者选其一或者由你亲自整合到一起。比如你可以通过把这段内容替换为下面这样来解决:
pleasecontact us at email.support@github.com
这个解决方案各采纳了两个分支中的一部分内容,而且我还删除了 <<<<<<<
,=======和 >>>>>>>这些行。在解决了所有文件里的所有冲突后,运行
gitadd将把它们标记为已解决状态(译注:实际上就是来一次快照保存到暂存区域。)。因为一旦暂存,就表示冲突已经解决。再运行一次 gitstatus来确认所有冲突都已解决:
$git status
# On branch master
# Changes to be committed:
# (use "gitreset HEAD ..." to unstage)
# # modified: index.html #
如果觉得满意了,并且确认所有冲突都已解决,也就是进入了暂存区,就可以用 git commit来完成这次合并提交。提交的记录差不多是这样:
Mergebranch 'iss53' Conflicts: index.html #
# It looks like you may becommitting 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分支将随着开发进度前移。若要查看各个分支最后一个提交对象的信息,运行$gitbranch -v:
$git branch -v
iss53 93b412c fix javascript issue * master 7a98805
Merge branch 'iss53' testing 782fd34 add scott to the author list inthe readmes
要从该清单中筛选出你已经(或尚未)与当前分支合并的分支,可以用
--merge和 --no-merged选项(Git1.5.6以上版本)。比如用gitbranch --merge 查看哪些分支已被并入当前分支(译注:也就是说哪些分支是当前分支的直接上游。):
$git branch --merged iss53 * master
之前我们已经合并了 iss53,所以在这里会看到它。一般来说,列表中没有*
的分支通常都可以用 gitbranch -d来删掉。原因很简单,既然已经把它们所包含的工作整合到了其他分支,删掉也不会损失什么。另外可以用 gitbranch --no-merged查看尚未合并的工作:
$git branch --no-merged testing
它会显示还未合并进来的分支。由于这些分支中还包含着尚未合并进来的工作成果,所以简单地用 gitbranch -d删除该分支会提示错误,因为那样做会丢失数据:
$git branch -d testing
error: The branch 'testing' is not an ancestorof your current HEAD.
If you are sure you want to delete it, run 'gitbranch -D testing'.
不过,如果你确实想要删除该分支上的改动,可以用大写的删除选项 -D强制执行,就像上面提示信息中给出的那样。
远程分支
远程分支(remotebranch)是对远程仓库中的分支的索引。它们是一些无法移动的本地分支;只有在Git进行网络交互时才会更新。远程分支就像是书签,提醒着你上次连接远程仓库时上面各分支的位置。我们用 (远程仓库名
)/(分支名)这样的形式表示远程分支。比如我们想看看上次同 origin仓库通讯时master的样子,就应该查看 origin/master分支。如果你和同伴一起修复某个问题,但他们先推送了一个iss53分支到远程仓库,虽然你可能也有一个本地的 iss53分支,但指向服务器上最新更新的却应该是 origin/iss53分支。
可能有点乱,我们不妨举例说明。假设你们团队有个地址为git.ourcompany.com的Git服务器。如果你从这里克隆,Git会自动为你将此远程仓库命名为origin,并下载其中所有的数据,建立一个指向它的 master分支的指针,在本地命名为 origin/master,但你无法在本地更改其数据。接着,Git建立一个属于你自己的本地master分支,始于 origin上 master分支相同的位置,你可以就此开始工作如果你在本地 master分支做了些改动,与此同时,其他人向 git.ourcompany.com推送了他们的更新,那么服务器上的master分支就会向前推进,而于此同时,你在本地的提交历史正朝向不同方向发展。不过只要你不和服务器通讯,你的 origin/master
可以运行 git fetch origin来同步远程服务器上的数据到本地。该命令首先找到
origin是哪个服务器(本例为git.ourcompany.com),从上面获取你尚未拥有的数据,更新你本地的数据库,然后把 origin/master的指针移到它最新的位置上
推送本地分支
要想和其他人分享某个本地分支,你需要把它推送到一个你拥有写权限的远程仓库。如果你有个叫 serverfix的分支需要和他人一起开发,可以运行gitpush (远程仓库名)(分支名):
$git push origin serverfix
这其实有点像条捷径。Git自动把 serverfix分支名扩展为refs/heads/serverfix:refs/heads/serverfix,意为“取出我在本地的
serverfix分支,推送到远程仓库的serverfix分支中去”。也可以运行
git push origin serverfix:serferfix
来实现相同的效果,它的意思是“上传我本地的serverfix分支到远程仓库中去,仍旧称它为serverfix分支”。通过此语法,你可以把本地分支推送到某个命名不同的远程分支:若想把远程分支叫作awesomebranch,可以用
git push origin serverfix:awesomebranch
来推送数据。
接下来,当你的协作者再次从服务器上获取数据时,他们将得到一个新的远程分支 origin/serverfix:
$git fetch origin
值得注意的是,在 fetch操作下载好新的远程分支之后,你仍然无法在本地编辑该远程仓库中的分支。换句话说,在本例中,你不会有一个新的serverfix分支,有的只是一个你无法移动的 origin/serverfix指针。如果要把该内容合并到当前分支,可以运行
git merge origin/serverfix
。如果想要一份自己的 serverfix来开发,可以在远程分支的基础上分化出一个新的分支来:
$git checkout -b serverfix origin/serverfix
Branch serverfix set up totrack remote branch refs/remotes/origin/serverfix. Switched to a newbranch "serverfix"
这会切换到新建的 serverfix 本地分支,其内容同远程分支 origin/serverfix一致,这样你就可以在里面继续开发了。跟踪远程分支从远程分支 checkout出来的本地分支,称为跟踪分支(trackingbranch)_。跟踪分支是一种和远程分支有直接联系的本地分支。在跟踪分支里输入gitpush,Git会自行推断应该向哪个服务器的哪个分支推送数据。反过来,在这些分支里运行 gitpull会获取所有远程索引,并把它们的数据都合并到本地分支中来。
在克隆仓库时,Git通常会自动创建一个名为 master的分支来跟踪origin/master。这正是gitpush和 gitpull一开始就能正常工作的原因。当然,你可以随心所欲地设定为其它跟踪分支,比如origin上除了 master之外的其它分支。刚才我们已经看到了这样的一个例子:
gitcheckout -b [分支名][远程名]/[分支名]。如果你有1.6.2以上版本的Git,还可以用--track选项简化:
$git checkout --track origin/serverfix
Branch serverfix set up totrack remote branch refs/remotes/origin/serverfix. Switched to a newbranch "serverfix"
要为本地分支设定不同于远程分支的名字,只需在前个版本的命令里换个名字:
$git checkout -b sf origin/serverfix
Branch sf set up to track remotebranch refs/remotes/origin/serverfix. Switched to a new branch "sf"
现在你的本地分支 sf会自动向 origin/serverfix推送和抓取数据了。
分支的衍合
把一个分支整合到另一个分支的办法有两种:merge和rebase(译注:rebase的翻译暂定为“衍合”,大家知道就可以了。)。在本章我们会学习什么是衍合,如何使用衍合,为什么衍合操作如此富有魅力,以及我们应该在什么情况下使用衍合。
最容易的整合分支的方法是 merge 命令,它会把两个分支最新的快照(C3
和C4)以及二者最新的共同祖先(C2)进行三方合并,合并的结果是产生一个新的提交对象(C5)
还有另外一个选择:你可以把在C3里产生的变化补丁在C4的基础上重新打一遍。在Git里,这种操作叫做衍合(rebase)。有了 rebase 命令,就可以把在一个分支里提交的改变移到另一个分支里重放一遍。
在上面这个例子中,运行:
$git checkout experiment
$git rebase master
First, rewinding head to replay your work on top ofit... Applying: added staged command
它的原理是回到两个分支最近的共同祖先,根据当前分支(也就是要进行衍合的分支 experiment)后续的历次提交对象(这里只有一个C3),生成一系列文件补丁,然后以基底分支(也就是主干分支master)最后一个提交对象(C4)为新的出发点,逐个应用之前准备好的补丁文件,最后会生成一个新的合并提交对象(C3’),从而改写 experiment 的提交历史,使它成为
master 分支的直接下游
把C3里产生的改变到C4上重演一遍。现在回到 master分支,进行一次快进合并
现在的C3’应的快照,其实和普通的三方合并,即上个例子中的C5对应的快照内容一模一样了。虽然最后整合得到的结果没有任何区别,但衍合能产生一个更为整洁的提交历史。如果视察一个衍合过的分支的历史记录,看起来会更清楚:仿佛所有修改都是在一根线上先后进行的,尽管实际上它们原本是同时并行发生的。
一般我们使用衍合的目的,是想要得到一个能在远程分支上干净应用的补丁—比如某些项目你不是维护者,但想帮点忙的话,最好用衍合:先在自己的一个分支里进行开发,当准备向主项目提交补丁的时候,根据最新的
origin/master
进行一次衍合操作然后再提交,这样维护者就不需要做任何整合工作(译注:实际上是把解决分支补丁同最新主干代码之间冲突的责任,化转为由提交补丁的人来解决。),只需根据你提供的仓库地址作一次快进合并,或者直接采纳你提交的补丁。
请注意,合并结果中最后一次提交所指向的快照,无论是通过衍合,还是三方合并,都会得到相同的快照内容,只不过提交历史不同罢了。衍合是按照每行的修改次序重演一遍修改,而合并是把最终结果合在一起。
有趣的衍合
衍合也可以放到其他分支进行,并不一定非得根据分化之前的分支。以图历史为例,我们为了给服务器端代码添加一些功能而创建了特性分支server,然后提交C3和C4。然后又从C3的地方再增加一个client分支来对客户端代码进行一些相应修改,所以提交了C8和C9。最后,又回到server分支提交了C10。
从一个特性分支里再分出一个特性分支的历史。
假设在接下来的一次软件发布中,我们决定先把客户端的修改并到主线中,而暂缓并入服务端软件的修改(因为还需要进一步测试)。这个时候,我们就可以把基于server分支而非master分支的改变(即C8和C9),跳过server直接放到master分支中重演一遍,但这需要用git rebase的--onto选项指定新的基底分支master:
$git rebase --onto master server client
这好比在说:“取出client分支,找出client分支和server分支的共同祖先之后的变化,然后把它们在master上重演一遍”。是不是有点复杂?不过它的结果如图所示,非常酷(译注:虽然client里的C8,C9在C3之后,但这仅表明时间上的先后,而非在C3修改的基础上进一步改动,因为server和client这两个分支对应的代码应该是两套文件,虽然这么说不是很严格,但应理解为在C3时间点之后,对另外的文件所做的C8,C9修改,放到主干重演。):
将特性分支上的另一个特性分支衍合到其他分支。
现在可以快进 master分支了
$git checkout master
$git merge client
master分支,使之包含client分支的变化。现在我们决定把server分支的变化也包含进来。我们可以直接把server分支衍合到master,而不用手工切换到server分支后再执行衍合操作—gitrebase [主分支][特性分支]命令会先取出特性分支server,然后在主分支master上重演:
$git rebase master server
于是,server的进度应用到master的基础上
在master分支上衍合server分支。然后就可以快进主干分支master了:
$git checkout master
$git merge server
现在client和Server分支的变化都已经集成到主干分支来了,可以删掉它们了。最终我们的提交历史会变成:
$git branch -d client
$git branch -d server
最终的提交历史衍合的风险呃,奇妙的衍合也并非完美无缺,要用它得遵守一条准则:
一旦分支中的提交对象发布到公共仓库,就千万不要对该分支进行衍合操作。
而在C8之后,你的提交历史里就会同时包含C4和C4’,两者有着不同的SHA-1校验值,如果用gitlog查看历史,会看到两个提交拥有相同的作者日期与说明,令人费解
网友评论