《Pro Git》笔记3:Git分支基本操作
分支使多线开发和合并非常容易。Git的分支就是一个指向提交对象的可变指针,极其轻量。Git的默认分支为master。
1.Git数据存储结构和分支
git提交时会将暂存文件的内容,暂存的目录结构,提交对象,含附注标签对象都以包含信息头的二进制文件形式存储到版本库中(.git/objects目录),存储的对象以其自身SHA1值作为唯一标识,SHA1前两位为存储对象所在目录名,SHA1后38位为存储对象的文件名。存储的数据对象类型有:
- blob(文件内容)
- tree(目录对象或者说是目录快照对象,其内容为目录下所有文件名和文件内容blob对象SHA1的映射,以及子目录名和子目录tree SHA1的映射)
- commit(提交对象,内容包含父提交对象SHA1,顶层tree对象SHA1,作者提交者信息,时间戳,空行,提交说明)
- tag(含附注标签对象)
分支是指向提交对象的指针(refs,分支本质上是存放在.git/refs目录的文件,其中保存了指向的提交对象的SHA1值)。可见git分支代价微乎其微,可以随意使用。本地分支是可变指针,远程分支和轻量级标签是不可变指针无法直接改变指向。删除分支也就是删除指针而已。
HEAD指针:记录git当前正在哪个分支上工作,是指向分支的指针,切换分支时变更HEAD指向。该文件位置为.git/HEAD。其内容是当前所在的分支的全名,如ref: refs/heads/master。
2.使用和切换分支
管理分支使用git branch命令。
git branch #不带任何参数时,列出当前所有的分支
git branch -v #列出所有分支时还,显示每个分支的最后一次提交SHA1和提交说明
git branch --mered | --no--merged #显示已经合并了的分支或还未合并的分支。
git branch <分支名> #从当前所在分支创建一个新分支
git checkout <分支名> #切换到指定分支(变更HEAD指针并切换工作目录内容),切换分支前应保持工作目录干净,以免检出时产生冲突。
git checkout -b <分支名> #从当前分支创建新分支并切换到新分支,等效于前两条命令的组合。
git checkout -b <分支名> [分支|标签|SHA1] #从特定版本创建并切换到新分支
a.git默认存在一个叫master的分支指向最后一次提交,默认分支上多次提交后如图3.3。
b.在当前提交上创建新分支后(调用git branch testing),实际上只是增加了一个指向当前提交的指针(图3.4)。
c.切换分支(调用git checkout testing),git做了两件事:一是修改HEAD指针使其指新的向当前分支(图3.5,3.6),二是将新当前分支指向提交的快照换进工作目录。
d.在新分支上继续开发,提交新的版本时(git commit -am "made a change" ),当前分支(testing)会自动移动指向新的提交。非当前分支(master)不移动,如图3.7。
e.切换到master分支,HEAD指针指向切换到的分支,并且工作目录换入切换到的分支的版本(f30ab)(图3.8)。
f.在master分支上继续提交其他修改,项目并行向不同方向开发(图3.9),随时可以在不同线路上切换,互不影响。
3.分支合并(merge)
合并的本质就是在分支间复制变更。要将分支A合并到分支B,就是将A中有而B中没有的差异复制到B中。合并时以当前所在分支B为合并的基础,接收差异并被更新。合并命令中指定了差异来源分支A,合并结果就是来源分支A中独有的修改复制到了当前分支B,如果B中的内容在纳入A的修改后发生了变化,还会创建一个新的合并提交(变更来自两个父提交)将合并结果记录到版本库。
基本合并主要分为几种情况:
- 快进合并(一个分支指向的提交是另一个分支指向提交的祖先)。
- 基于共同祖先的三方合并(两个分支在一个共同祖先之后走向了分叉的两边)。
- 两方合并(两个分支指向的提交无共同祖先,是孤立的,也无法参考共同祖先自动合并)
(1)快进合并
示例项目中,master为主干分支用于发布稳定版本。首先在master分支基础上创建了iss53分支来开发新功能,提交了C3,开发过程中老版本出现重要bug,于是又重在master分支的基础上创建了hotfix分支来修复bug,提交了C4,就形成多线并行开发的两个分支。如下图。
解决bug后提交C4并测试无误,就可以将包含bug修复内容的hotfix合并到master中了。master指向的提交C2是hotfix指向提交C4的祖先,那么直接移动master指针使其指向hotfix所在的提交,masert分支自然也就包含了hotfix的全部变更。只需要移动指针,不需要再创建新的快照和合并提交,这就是快进合并。合并结果如下图:
git merge <变更来源分支名> #将指定分支合并到当前分支,也就是将指定分支中特有的变更都并入到当前分支里,不同情况下有不同的合并策略,如快进,三方合并。
hotfix并入master后,master分支就可以再次发布了。而hotfix也没有什么作用了,可以直接删除(分支就是指针,删除分支仅仅是删除这个指针)。调用git branch -d hotfix删除hotfix分支后如下图。
git branch -d <分支名> #删除已经合并了的分支,未合并过的分支会失败
git branch -D <分支名> #不管分支有没有被合并,都删除
解决了bug后就可以继续在iss53上开发新功能了并提交了C5。bug是在C4中解决的,iss53分支的版本C3和C5中不包含修改bug的变更,因此iss53分支上bug仍然存在。可以立即将master(包含bug修复内容)并入iss53,也可以等iss53完成后并入master。
(2)三方合并
iss53完成后,并入master分支。同样是先检出master分支(git checkout mater),再调用git merge iss53。但是master指向的C4和iss53指向的C5没有直接的祖先后代关系,而是在共同的祖先C2之后分叉了,合并时先要找到iss53对于master来说特有的变更(即C3和C5的变更),并将这些变更并入master指向的C4,C4的内容被更新了,然后还必须要为C4内容更新后的新状态作快照并记录到版本库中(即新的提交C6)。在这个合并的过程中,iss53对于master来说特有的变更又是如何被找到的呢?Git会自动找到C5和C4的合并起来最佳的共同祖先C2,那么C5相对于C2的变更内容就是C4中没有的。差异的计算过程中涉及C4,C5和共同祖先C2三个提交,所以这个合并是一次三方合并。三方合并中自动创建的提交C6内容来自两个分支,其祖先不止一个,这类提交为合并提交。
合并后master分支中就包含iss53中开发的新功能了,master可以发布,iss53使命也已经完成,可以删除掉了。
回顾下三方合并的特点:直接在两个分支最后一次提交快照(分支发展的最终结果)的基础上操作,不考虑分支发展的中间过程,多个分支发展的中间过程以及所有的提交全部都保留下来了。
(3)解决合并冲突
如果两个分支都修改了同一个文件的同一部分,合并(快进合并不会冲突)这两个分支时,Git就无法确定到底以哪个分支中的改动为准了(这种情况只能由人来做决定),这时Git就会将这个文件中的这些区域插入冲突标记(<<<<<< (当前分支版本) ==== (修改的 来源分支版本)>>>>>>)等着事后人工处理,然后继续合并其他文件,合并出现冲突时也不会自动创建新的合并提交了,而是停留在冲突状态,等待人工解决冲突。
合并中的冲突其实把合并过程中断了,需要手动做两件事才能完成整个合并操作:手动解决冲突 和 提交解决冲突后的合并结果。
(A)查看冲突情况。冲突时,会给出如下提示。使用git status命令,也会列出冲突的文件(unmerged状态)。
$ git merge iss53
Auto-merging index.html
CONFLICT (content): Merge conflict in index.html
Automatic merge failed; fix conflicts and then commit the result.
[master*]$ git status
index.html: needs merge
# On branch master
# Changes not staged for commit: (已修改还未暂存的项目)
# (use "git add <file>..." to update what will be committed)
# (use "git checkout -- <file>..." to discard changes in working directory)
# unmerged: index.html
存在冲突的文件中插入的冲突标记示例。
<<<<<<< HEAD:index.html (当前分支版本)
<div id="footer">contact : [email protected]</div>
=======
<div id="footer">
please contact us at [email protected]
</div>
>>>>>>> iss53:index.html (修改的来源分支版本)
(B)手动解决冲突。也是分两步:第一,就是编辑这些冲突部分的内容,去掉冲突标记,最终应该是什么样子就编辑成什么样子保存。第二,运行 git add <PATH> 暂存文件,表明冲突已经解决。解决冲突也可以使用其他文件合并工具(git mergetool)。
再总结下git add的三个功能(实质上都是将对象加入暂存区):添加新追踪对象(暂存还未追踪的对象),暂存已修改的追踪对象,标记对象冲突已解决(还是暂存已修改的追踪对象)。
(C)提交。解决冲突后,当前工作目录同时包含了两个分支的内容,但只是临时的,一切换分支或版本就都丢失了,还必须创建一个合并提交,将合并结果记录到版本库里。
4.分支衍合(rebase)
衍合(rebase)也是一种在分支间复制变更的方式,效果也是把指定分支的变更并入当前分支。
衍合这个词不太好理解这种操作的作用,直接按英文rebase(更新分支起点)更容易理解。因为这个操作的本质就是提取一个分支中每个提交的变更,以rebase命令中指定的新分支为起点,将这些变更逐个重做一次。从最近的共同祖先开始经过了多少个提交,重做后就会生成多少个新的提交,当前分支原来指向的那些提交被丢弃,然后重新指向最新生成的提交。
它的原理是回到两个分支(你所在的分支和你想要衍合进去的分支)的共同祖先,提取你所在分支每次提交时产生的差异(diff),把这些差异分别保存到临时文件里,然后从当前分支转换到你需要衍合入的分支,依序施用每一个差异补丁文件。
git rebase <新分支起点> #将当前分支的变更,在新分支起点上重做
git rebase <新分支起点> <特性分支> #检出特性分支,将特性分支的变更,在新分支起点上重做
git rebase --onto <新分支起点> <特性分支1> <特性分支2> #检出特性分支2,找出特性分支2和特性分支1的共同祖先之后的变化,然后把它们在新分支起点上重演
举个栗子,将分支experiment以master为新的分支起点进行衍合(将experiment中的变更以master指向的C4为起点重做一次)。衍合前的状态如下图
调用下面的衍合命令后。提取出experiment分支中C3的变更,以C4为基础重演,产生了新提交C3‘,原来的C3被丢弃,experiment分支指向新的提交C3‘。命令的效果就是experiiment的起点从C2移动到了C4,experiment分支中已经包含了master的变更。
$ git checkout experiment
$ git rebase master
First, rewinding head to replay your work on top of it...
Applying: C3 commit comment
比较下合并和衍合的结果,衍合历史更为清晰,但是会变动已有的提交(如丢弃了c3)。
另一个衍合的例子:原来分支A中所有修改都是以共同祖先(C2)为起点的。衍合(更新起点)以后,新的分支A中的所有提交就都以分支B为起点了,不同分支也在向一条主线并拢,分支A已经包含了分支B中的所有变更。
衍合的好处是:衍合操作会将不同分支上并行的提交慢慢并到一条主开发线上(并入的不是原提交本身,但提交中的变更是一样的)。衍合不会产生一个额外的合并提交。衍合后的提交历史非常清晰。
衍合的缺陷:衍合以后最初起点之后的那些提交就没有用了,会被丢弃掉,如果这些提交已经被同步到远程库中,并且其他人引用了这些提交,最后就会陷入混乱。所以衍合仅仅应该用在不会推送到远端的本地分支上来获得清晰的历史线。
5.衍合冲突解决
只要是复制变更,就会出现潜在的冲突,衍合也不例外,而且情况更为复杂。衍合的过程是以一个新的提交为起点,一个提交一个提交的重新应用变更,那么每个提交的变更都有可能出现冲突。因为衍合是一个跨越多个提交的过程,应用每个提交时发生了冲突,就解决当前这些冲突,然后继续(git rebase --continue)应用下一个提交,发生冲突再解决,直到所有提交都重新应用了。衍合中某个提交出现冲突,也可以跳过而不应用这个提交(git rebase --skip),也可以撤销衍合过程(git rebase --abort),恢复到衍合命令之前的状态。
假如test分支和master分支共同祖先提交为C1,test有C2,C3, master有C4,C5
$git checkout test
$git rebase master
First, rewinding head to replay your work on top of it... (当前分支HEAD切换到master)
Applying: commit2 in test branch (应用test分支中C2的变更)
Using index info to reconstruct a base tree...
M README
Falling back to patching base and 3-way merge... (采用三方合并,三个提交为C1,C2,C5)
Auto-merging README
CONFLICT (content): Merge conflict in README (合并产生冲突)
Failed to merge in the changes.
Patch failed at 0001 commit2 in test branch (test分支中的第一个提交C2在C5上应用失败)
The copy of the patch that failed is found in:
c:/Users/Administrator/git-test/.git/rebase-apply/patch (C2的变更内容保存该文件中)
When you have resolved this problem, run "git rebase --continue". (手动解决了冲突后,运行git rebase --continue完成应用第一个提交中的变更,继续应用下一个提交中的变更)
If you prefer to skip this patch, run "git rebase --skip" instead. (跳过这个提交,这个提交中的变更不重新应用到C5上,衍合后的test分支中不再包含这个提交中的变更)
To check out the original branch and stop rebasing, run "git rebase --abort". (取消衍合,之前成功应用的变更都撤销)
发生了冲突。此时调用git status可以看到
rebase in progress; onto 4fc0159 (衍合进行中)
You are currently rebasing branch ‘test‘ on ‘4fc0159‘.
(fix conflicts and then run "git rebase --continue")
(use "git rebase --skip" to skip this patch)
(use "git rebase --abort" to check out the original branch)
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)
new file: ccc.txt
Unmerged paths:
(use "git reset HEAD <file>..." to unstage)
(use "git add <file>..." to mark resolution)
both modified: README
手动编辑冲突的文件README,改为正确内容后后运行git add 移除冲突状态,再运行git rebase --continue继续衍合流程。
$git rebase --continue
Applying: commit1 in test branch
Applying: commit2 in test branch
衍合结束后,test分支丢弃了原来的提交C2,C3,包含了最新的提交C2‘,C3‘
6.远程分支和同步
git远程版本库和本地版本库是版本数据互相备份的不同存放位置。从远端克隆一个项目时,两者完全相同。当本地不断添加变更内容并提交版本时,慢慢的本地库和远端库不再同步了。分支是指向提交对象的指针,本地提交新版本时,本地分支会不断移动到最新提交。git还提供了一些额外的指针标记了最后一次同步时远端库中各个分支指向的位置。这些指针就是远程分支,它们固定的标记远端库的分支状态,所有只有同远端同步的命令才会更新远程分支。本地库可以同时和很多个远程库协作,可以同时包含多个远程库的多个远程分支。同步命令有以下几条:
git clone
git pull #将远程库数据同步到本地库,并按照分支对象关系尝试自动合并到本地分支
git push
git fetch <远程库名> #将远程库数据同步到本地库,不会合并
(1)本地分支直接用<分支名>表示。远程分支用<远程仓库名>/<分支名>表示。克隆远程仓库(git clone)时会自动获取远程分支。
(2)同步分支
如果有人先在远程库中推送了提交,我们将这些提交更新(git pull或git fetch)到本地后会与本地的提交形成不同的历史线路。使用git fetch获取到新的远程分支数据后并不会自动创建对应的本地分支,可以使用(git checkout -b [本地分支名] [远程库名]/[远程分支名])或git branch命令手动创建。
本地库可以同时与多个远程库的多个分支协同工作。
(3).推送分支
本地分支默认都是私有的,只在本地库可见。只有那些确实需要与他人协作的分支才有必要推送到远程库中。
git push <远程库名> <分支名>
git push <远程库名> <本地分支名>:<远程分支名>
(4)追踪远程分支
从某个远程分支检出一个新的本地分支后,这个本地分支就是跟踪分支。跟踪分支记录了与那个远程分支关联,git pull,git push就知道当前分支默认与哪个服务的那个分支同步了,可以不用额外的参数。手动指定追踪关系命令为:
$ git checkout --track origin/serverfix
Branch serverfix set up to track remote branch refs/remotes/origin/serverfix.
Switched to a new branch "serverfix"
(5)删除远程分支
删除远程分支命令没有单独的命令,而是在普通推送命令中将本地分支参数设为空即可。
git push <远程库名> :<远程分支名> #删除远程分支,可以理解为在这里提取空白然后把它变成[远程分支]
参考:
《Pro Git》
http://gitbook.liuhui998.com/index.html