前言
上篇我就Git协议讲了两大点,Git的起步,Git的基础操作,其中详细的小伙伴可以先去阅读我上一篇文章 https://my.oschina.net/qiangdada/blog/800093 (一入前端深似海,从此红尘是路人系列第十弹之如何合理利用Git进行团队协作(一))。接下来直接和大家分享有关Git分支以及如何操作远程仓库进行团队协作。
要查看当前配置有哪些远程仓库,可以用 git remote
命令,它会列出每个远程库的简短名字。在克隆完某个项目后,至少可以看到一个名为 origin 的远程库,Git 默认使用这个名字来标识你所克隆的原始仓库
# step 1 $ git clone https://github.com/xuqiang521/data-visualization.git #Cloning into 'data-visualization'... #remote: Counting objects: 38, done. #remote: Compressing objects: 100% (29/29), done. #remote: Total 38 (delta 7), reused 38 (delta 7), pack-reused 0 #Unpacking objects: 100% (38/38), done. # step 2 $ cd data-visualization # step 3 $ git remote #origin
也可以加上 -v
选项(译注:此为 --verbose
的简写,取首字母),显示对应的克隆地址:
$ git remote -v #origin https://github.com/xuqiang521/data-visualization.git (fetch) #origin https://github.com/xuqiang521/data-visualization.git (push)
如果有多个远程仓库,此命令也可以将其全部列出。这样一来,我就可以非常轻松地从这些用户的仓库中,拉取他们的提交到本地。
要添加一个新的远程仓库,可以指定一个简单的名字,以便将来引用,运行 git remote add [shortname] [url]
:
# step 1 $ git remote #origin # step 2 $ git remote add pb git://github.com/paulboone/ticgit.git # step 3 $ git remote -v #origin git://github.com/schacon/ticgit.git #pb git://github.com/paulboone/ticgit.git
现在可以用字符串 pb
指代对应的仓库地址了。比如说,要抓取所有 Paul 有的,但本地仓库没有的信息,可以运行 git fetch pb
:
$ git fetch pb #remote: Counting objects: 58, done. #remote: Compressing objects: 100% (41/41), done. #remote: Total 44 (delta 24), reused 1 (delta 0) #Unpacking objects: 100% (44/44), done. #From git://github.com/paulboone/ticgit #* [new branch] master -> pb/master #* [new branch] ticgit -> pb/ticgit
现在,Paul 的主干分支(master)已经完全可以在本地访问了,对应的名字是 pb/master
,你可以将它合并到自己的某个分支,或者切换到这个分支,看看有些什么有趣的更新。
正如之前所看到的,可以用下面的命令从远程仓库抓取数据到本地:
$ git fetch [remote-name]
此命令会到远程仓库中拉取所有你本地仓库中还没有的数据。运行完成后,你就可以在本地访问该远程仓库中的所有分支,将其中某个分支合并到本地,或者只是取出某个分支,一探究竟。
如果是克隆了一个仓库,此命令会自动将远程仓库归于 origin 名下。所以, git fetch origin
会抓取从你上次克隆以来别人上传到此远程仓库中的所有更新(或是上次 fetch 以来别人提交的更新)。有一点很重要,需要记住,fetch 命令只是将远端的数据拉到本地仓库,并不自动合并到当前工作分支,只有当你确实准备好了,才能手工合并。
如果设置了某个分支用于跟踪某个远端仓库的分支,可以使用 git pull
命令自动抓取数据下来,然后将远端分支自动合并到本地仓库中当前分支。在日常工作中我们经常这么用,既快且好。实际上,默认情况下 git clone
命令本质上就是自动创建了本地的 master 分支用于跟踪远程仓库中的 master 分支(假设远程仓库确实有 master 分支)。所以一般我们运行 git pull
,目的都是要从原始克隆的远端仓库中抓取数据后,合并到工作目录中的当前分支。
项目进行到一个阶段,要同别人分享目前的成果,可以将本地仓库中的数据推送到远程仓库。实现这个任务的命令很简单: git push [remote-name] [branch-name]
。如果要把本地的 master 分支推送到 origin
服务器上(再次说明下,克隆操作会自动使用默认的 master 和 origin 名字),可以运行下面的命令:
$ git push origin master
只有在所克隆的服务器上有写权限,或者同一时刻没有其他人在推数据,这条命令才会如期完成任务。如果在你推数据前,已经有其他人推送了若干更新,那你的推送操作就会被驳回。你必须先把他们的更新抓取到本地,合并到自己的项目中,然后才可以再次推送。
我们可以通过命令 git remote show [remote-name]
查看某个远程仓库的详细信息,比如要看所克隆的 origin
仓库,可以运行:
$ git remote show origin #* remote origin #Fetch URL: https://github.com/xuqiang521/data-visualization.git #Push URL: https://github.com/xuqiang521/data-visualization.git #HEAD branch: master #Remote branch: # master tracked #Local branch configured for 'git pull': # master merges with remote master #Local ref configured for 'git push': # master pushes to master (up to date)
除了对应的克隆地址外,它还给出了许多额外的信息。它友善地告诉你如果是在 master 分支,就可以用 git pull
命令抓取数据合并到本地。另外还列出了所有处于跟踪状态中的远端分支。
上面的例子非常简单,而随着使用 Git 的深入, git remote show
给出的信息可能会像这样:
$ git remote show origin #* remote origin #URL: git@github.com:defunkt/github.git #Remote branch merged with 'git pull' while on branch issues #issues #Remote branch merged with 'git pull' while on branch master #master #New remote branches (next fetch will store in remotes/origin) #caching #Stale tracking branches (use 'git remote prune') #libwalker #walker2 #Tracked remote branches #acl #apiv2 #dashboard2 #issues #master #postgres #Local branch pushed with 'git push' #master:master
它告诉我们,运行 git push
时缺省推送的分支是什么(译注:最后两行)。它还显示了有哪些远端分支还没有同步到本地(译注:第六行的 caching
分支),哪些已同步到本地的远端分支在远端服务器上已被删除(译注: Stale tracking branches
下面的两个分支),以及运行 git pull
时将自动合并哪些分支(译注:前四行中列出的 issues
和 master
分支)。
在新版 Git 中可以用 git remote rename
命令修改某个远程仓库在本地的简称,比如想把 pb
改成 paul
,可以这么运行:
# step 1 $ git remote rename pb paul # step 2 $ git remote #origin #paul
注意,对远程仓库的重命名,也会使对应的分支名称发生变化,原来的 pb/master
分支现在成了 paul/master
。
碰到远端仓库服务器迁移,或者原来的克隆镜像不再使用,又或者某个参与者不再贡献代码,那么需要移除对应的远端仓库,可以运行 git remote rm
命令:
# step 1 $ git remote rm paul # step 2 $ git remote #origin
为了理解 Git 分支的实现方式,我们需要回顾一下 Git 是如何储存数据的。看过我上一篇博客的朋友应该知道,Git 保存的不是文件差异或者变化量,而只是一系列文件快照。
在 Git 中提交时,会保存一个提交(commit)对象,该对象包含一个指向暂存内容快照的指针,包含本次提交的作者等相关附属信息,包含零个或多个指向该提交对象的父对象指针:首次提交是没有直接祖先的,普通提交有一个祖先,由两个或多个分支合并产生的提交则有多个祖先。
为直观起见,我们假设在工作目录中有三个文件,准备将它们暂存后提交。暂存操作会对每一个文件计算校验和,然后把当前版本的文件快照保存到 Git 仓库中(Git 使用 blob 类型的对象存储这些快照),并将校验和加入暂存区域:
# step 1 $ git add README test.rb LICENSE # step 2 $ git commit -m 'initial commit of my project'
当使用 git commit
新建一个提交对象前,Git 会先计算每一个子目录的校验和,然后在 Git 仓库中将这些目录保存为树(tree)对象。之后 Git 创建的提交对象,除了包含相关提交信息以外,还包含着指向这个树对象(项目根目录)的指针,如此它就可以在将来需要的时候,重现此次快照的内容了。
现在,Git 仓库中有五个对象:三个表示文件快照内容的 blob 对象;一个记录着目录树内容及其中各个文件对应 blob 对象索引的 tree 对象;以及一个包含指向 tree 对象(根目录)的索引和其他提交信息元数据的 commit 对象。概念上来说,仓库中的各个对象保存的数据和相互关系看起来如图2-1所示:
图 2-1. 单个提交对象在仓库中的数据结构
作些修改后再次提交,那么这次的提交对象会包含一个指向上次提交对象的指针(译注:即下图中的 parent 对象)。两次提交后,仓库历史会变成图2-2的样子:
图 2-2. 多个提交对象之间的链接关系
现在来谈分支。Git 中的分支,其实本质上仅仅是个指向 commit 对象的可变指针。Git 会使用 master 作为分支的默认名字。在若干次提交后,你其实已经有了一个指向最后一次提交对象的 master 分支,它在每次提交的时候都会自动向前移动。
图2-3.分支其实就是从某个提交对象往回看的历史
那么,Git 又是如何创建一个新的分支的呢?答案很简单,创建一个新的分支指针。比如新建一个 testing 分支,可以使用 git branch
命令:
$ git branch testing
这会在当前 commit 对象上新建一个分支指针(见图2-4)。
图 2-4. 多个分支指向提交数据的历史
那么,Git 是如何知道你当前在哪个分支上工作的呢?其实答案也很简单,它保存着一个名为 HEAD 的特别指针。请注意它和你熟知的许多其他版本控制系统(比如 Subversion 或 CVS)里的 HEAD 概念大不相同。在 Git 中,它是一个指向你正在工作中的本地分支的指针。运行 git branch
命令,仅仅是建立了一个新的分支,但不会自动切换到这个分支中去,所以在这个例子中,我们依然还在 master 分支里工作(参考图 2-5)
图 2-5. HEAD 指向当前所在的分支
要切换到其他分支,可以执行 git checkout
命令。我们现在转换到新建的 testing 分支:
$ git checkout testing
这样 HEAD 就指向了 testing 分支(见图2-6)。
图 3-6. HEAD 在你转换分支时指向新的分支
这样的实现方式会给我们带来什么好处呢?好吧,现在不妨再提交一次:
# step 1 $ vim test.rb # step 2 $ git commit -a -m 'made a change'
图 2-7 展示了提交后的结果
图 2-7. 每次提交后 HEAD 随着分支一起向前移动
非常有趣,现在 testing 分支向前移动了一格,而 master 分支仍然指向原先 git checkout
时所在的 commit 对象。现在我们回到 master 分支看看:
$ git checkout master
图 2-8 显示了结果。
图 2-8. HEAD 在一次 checkout 之后移动到了另一个分支
这条命令做了两件事。它把 HEAD 指针移回到 master 分支,并把工作目录中的文件换成了 master 分支所指向的快照内容。也就是说,现在开始所做的改动,将始于本项目中一个较老的版本。它的主要作用是将 testing 分支里作出的修改暂时取消,这样你就可以向另一个方向进行开发。
我们作些修改后再次提交:
# step 1 $ vim test.js # step 2 $ git commit -a -m 'made other changes'
现在我们的项目提交历史产生了分叉(如图 2-9 所示),因为刚才我们创建了一个分支,转换到其中进行了一些工作,然后又回到原来的主分支进行了另外一些工作。这些改变分别孤立在不同的分支里:我们可以在不同分支里反复切换,并在时机成熟时把它们合并到一起。而所有这些工作,仅仅需要 branch
和 checkout
这两条命令就可以完成。
图 2-9. 不同流向的分支历史
现在让我们来看一个简单的分支与合并的例子,实际工作中大体也会用到这样的工作流程:
假设此时,你突然接到一个电话说有个很严重的问题需要紧急修补,那么可以按照下面的方式处理:
a.分支的新建与切换
首先,我们假设你正在项目中愉快地工作,并且已经提交了几次更新(见图 2-10)。
图 2-10. 一个简短的提交历史
现在,你决定要修补问题追踪系统上的 #53 问题。顺带说明下,Git 并不同任何特定的问题追踪系统打交道。这里为了说明要解决的问题,才把新建的分支取名为 iss53。要新建并切换到该分支,运行 git checkout
并加上 -b
参数:
$ git checkout -b iss53 #Switched to a new branch "iss53"
这相当于执行下面这两条命令:
# step 1 $ git branch iss53 # step 2 $ git checkout iss53
图 2-11 示意该命令的执行结果。
图 2-11. 创建了一个新分支的指针
接着你开始尝试修复问题,在提交了若干次更新后, iss53
分支的指针也会随着向前推进,因为它就是当前分支(换句话说,当前的 HEAD
指针正指向 iss53
,见图 2-12):
# step 1 $ vim index.html # step 2 $ git commit -a -m 'added a new footer [issue 53]'
图 2-12. iss53 分支随工作进展向前推进
现在你就接到了那个网站问题的紧急电话,需要马上修补。有了 Git ,我们就不需要同时发布这个补丁和 iss53
里作出的修改,也不需要在创建和发布该补丁到服务器之前花费大力气来复原这些修改。唯一需要的仅仅是切换回 master
分支。
不过在此之前,留心你的暂存区或者工作目录里,那些还没有提交的修改,它会和你即将检出的分支产生冲突从而阻止 Git 为你切换分支。切换分支的时候最好保持一个清洁的工作区域。稍后会介绍几个绕过这种问题的办法(分别叫做 stashing 和 commit amending)。目前已经提交了所有的修改,所以接下来可以正常转换到 master
分支:
$ git checkout master #Switched to branch "master"
此时工作目录中的内容和你在解决问题 #53 之前一模一样,你可以集中精力进行紧急修补。这一点值得牢记:Git 会把工作目录的内容恢复为检出某分支时它所指向的那个提交对象的快照。它会自动添加、删除和修改文件以确保目录的内容和你当时提交时完全一样。
接下来,你得进行紧急修补。我们创建一个紧急修补分支 hotfix
来开展工作,直到搞定(见图 2-13):
# step 1 $ git checkout -b 'hotfix' #Switched to a new branch "hotfix" # step 2 $ vim index.html # step 3 $ git commit -a -m 'fixed the broken email address' #[hotfix]: created 3a0874c: "fixed the broken email address" #1 files changed, 0 insertions(+), 1 deletions(-)
图 2-13. hotfix 分支是从 master 分支所在点分化出来的
有必要作些测试,确保修补是成功的,然后回到 master
分支并把它合并进来,然后发布到生产服务器。用 git merge
命令来进行合并:
# step 1 $ git checkout master # step 2 $ 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-14)。
图 2-14. 合并之后,master 分支和 hotfix 分支指向同一位置。
在那个超级重要的修补发布以后,你想要回到被打扰之前的工作。由于当前 hotfix
分支和 master
都指向相同的提交对象,所以 hotfix
已经完成了历史使命,可以删掉了。使用 git branch
的 -d
选项执行删除操作:
$ git branch -d hotfix #Deleted branch hotfix (3a0874c).
现在回到之前未完成的 #53 问题修复分支上继续工作(图 2-15):
# step 1 $ git checkout iss53 #Switched to branch "iss53" # step 2 $ vim index.html # step 3 $ git commit -a -m 'finished the new footer [issue 53]' #[iss53]: created ad82d7a: "finished the new footer [issue 53]" #1 files changed, 1 insertions(+), 0 deletions(-)
图 2-15. iss53 分支可以不受影响继续推进。
不用担心之前 hotfix
分支的修改内容尚未包含到 iss53
中来。如果确实需要纳入此次修补,可以用 git merge master
把 master 分支合并到 iss53
;或者等 iss53
完成之后,再将 iss53
分支中的更新并入 master
。
b.分支的合并
在问题 #53 相关的工作完成之后,可以合并回 master
分支。实际操作同前面合并 hotfix
分支差不多,只需回到 master
分支,运行 git merge
命令指定要合并进来的分支:
# step 1 $ git checkout master # step 2 $ git merge iss53 #Merge made by recursive. #README | 1 + #1 files changed, 1 insertions(+), 0 deletions(-)
请注意,这次合并操作的底层实现,并不同于之前 hotfix
的并入方式。因为这次你的开发历史是从更早的地方开始分叉的。由于当前 master
分支所指向的提交对象(C4)并不是 iss53
分支的直接祖先,Git 不得不进行一些额外处理。就此例而言,Git 会用两个分支的末端(C4 和 C5)以及它们的共同祖先(C2)进行一次简单的三方合并计算。图 2-16 用红框标出了 Git 用于合并的三个提交对象:
图 2-16. Git 为分支合并自动识别出最佳的同源合并点。
这次,Git 没有简单地把分支指针右移,而是对三方合并后的结果重新做一个新的快照,并自动创建一个指向它的提交对象(C6)(见图 2-17)。这个提交对象比较特殊,它有两个祖先(C4 和 C5)。
值得一提的是 Git 可以自己裁决哪个共同祖先才是最佳合并基础;这和 CVS 或 Subversion(1.5 以后的版本)不同,它们需要开发者手工指定合并基础。所以此特性让 Git 的合并操作比其他系统都要简单不少。
图 2-17. Git 自动创建了一个包含了合并结果的提交对象。
既然之前的工作成果已经合并到 master
了,那么 iss53
也就没用了。你可以就此删除它,并在问题追踪系统里关闭该问题。
$ git branch -d iss53
c.遇到冲突时的分支合并
有时候合并操作并不会如此顺利。如果在不同的分支中都修改了同一个文件的同一部分,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 <file>..." to update what will be committed) # (use "git checkout -- <file>..." to discard changes in working directory) # # unmerged: index.html #
任何包含未解决冲突的文件都会以未合并(unmerged)的状态列出。Git 会在有冲突的文件里加入标准的冲突解决标记,可以通过它们来手工定位并解决这些冲突。可以看到此文件包含类似下面这样的部分:
<<<<<<< HEAD:index.html <div id="footer">contact : email.support@github.com</div> ======= <div id="footer"> please contact us at support@github.com </div> >>>>>>> iss53:index.html
可以看到 =======
隔开的上半部分,是 HEAD
(即 master
分支,在运行 merge
命令时所切换到的分支)中的内容,下半部分是在 iss53
分支中的内容。解决冲突的办法无非是二者选其一或者由你亲自整合到一起。比如你可以通过把这段内容替换为下面这样来解决:
<div id="footer"> please contact us at email.support@github.com </div>
这个解决方案各采纳了两个分支中的一部分内容,而且我还删除了 <<<<<<<
, =======
和 >>>>>>>
这些行。在解决了所有文件里的所有冲突后,运行 git add
将把它们标记为已解决状态(实际上就是来一次快照保存到暂存区域)。因为一旦暂存,就表示冲突已经解决。
再运行一次 git status
来确认所有冲突都已解决:
$ git status # On branch master # Changes to be committed: # (use "git reset HEAD <file>..." 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 qiangdada to the author list in the readmes
要从该清单中筛选出你已经(或尚未)与当前分支合并的分支,可以用 --merge
和 --no-merged
选项(Git 1.5.6 以上版本)。比如用 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
强制执行,就像上面提示信息中给出的那样。
a.长期分支
由于 Git 使用简单的三方合并,所以就算在较长一段时间内,反复多次把某个分支合并到另一分支,也不是什么难事。也就是说,你可以同时拥有多个开放的分支,每个分支用于完成特定的任务,随着开发的推进,你可以随时把某个特性分支的成果并到其他分支中。
许多使用 Git 的开发者都喜欢用这种方式来开展工作,比如仅在 master
分支中保留完全稳定的代码,即已经发布或即将发布的代码。与此同时,他们还有一个名为 develop
或 next
的平行分支,专门用于后续的开发,或仅用于稳定性测试 — 当然并不是说一定要绝对稳定,不过一旦进入某种稳定状态,便可以把它合并到 master
里。这样,在确保这些已完成的特性分支(短期分支,比如之前的 iss53
分支)能够通过所有测试,并且不会引入更多错误之后,就可以并到主干分支中,等待下一次的发布。
本质上我们刚才谈论的,是随着提交对象不断右移的指针。稳定分支的指针总是在提交历史中落后一大截,而前沿分支总是比较靠前(见图 2-18)。
图 2-18. 稳定分支总是比较老旧。
或者把它们想象成工作流水线,或许更好理解一些,经过测试的提交对象集合被遴选到更稳定的流水线(见图 2-19)。
图 2-19. 想象成流水线可能会容易点。
你可以用这招维护不同层次的稳定性。某些大项目还会有个 proposed
(建议)或 pu
(proposed updates,建议更新)分支,它包含着那些可能还没有成熟到进入 next
或 master
的内容。这么做的目的是拥有不同层次的稳定性:当这些分支进入到更稳定的水平时,再把它们合并到更高层分支中去。再次说明下,使用多个长期分支的做法并非必需,不过一般来说,对于特大型项目或特复杂的项目,这么做确实更容易管理。
b.特性分支
在任何规模的项目中都可以使用特性(Topic)分支。一个特性分支是指一个短期的,用来实现单一特性或与其相关工作的分支。可能你在以前的版本控制系统里从未做过类似这样的事情,因为通常创建与合并分支消耗太大。然而在 Git 中,一天之内建立、使用、合并再删除多个分支是常见的事。
现在我们来看一个实际的例子。请看图 2-20,由下往上,起先我们在 master
工作到 C1,然后开始一个新分支 iss91
尝试修复 91 号缺陷,提交到 C6 的时候,又冒出一个解决该问题的新办法,于是从之前 C4 的地方又分出一个分支 iss91v2
,干到 C8 的时候,又回到主干 master
中提交了 C9 和 C10,再回到 iss91v2
继续工作,提交 C11,接着,又冒出个不太确定的想法,从 master
的最新提交 C10 处开了个新的分支 dumbidea
做些试验。
图 2-20. 拥有多个特性分支的提交历史。
现在,假定两件事情:我们最终决定使用第二个解决方案,即 iss91v2
中的办法;另外,我们把 dumbidea
分支拿给同事们看了以后,发现它竟然是个天才之作。所以接下来,我们准备抛弃原来的 iss91
分支(实际上会丢弃 C5 和 C6),直接在主干中并入另外两个分支。最终的提交历史将变成图 2-21 这样:
图 2-21. 合并了 dumbidea 和 iss91v2 后的分支历史 。
请务必牢记这些分支全部都是本地分支,这一点很重要。当你在使用分支及合并的时候,一切都是在你自己的 Git 仓库中进行的 — 完全不涉及与服务器的交互。
远程分支(remote branch)是对远程仓库中的分支的索引。它们是一些无法移动的本地分支;只有在 Git 进行网络交互时才会更新。远程分支就像是书签,提醒着你上次连接远程仓库时上面各分支的位置。
我们用 (远程仓库名)/(分支名)
这样的形式表示远程分支。比如我们想看看上次同 origin
仓库通讯时 master
分支的样子,就应该查看 origin/master
分支。如果你和同伴一起修复某个问题,但他们先推送了一个 iss53
分支到远程仓库,虽然你可能也有一个本地的 iss53
分支,但指向服务器上最新更新的却应该是 origin/iss53
分支。
可能有点乱,我们不妨举例说明。假设你们团队有个地址为 git.ourcompany.com
的 Git 服务器。如果你从这里克隆,Git 会自动为你将此远程仓库命名为 origin
,并下载其中所有的数据,建立一个指向它的 master
分支的指针,在本地命名为 origin/master
,但你无法在本地更改其数据。接着,Git 建立一个属于你自己的本地 master
分支,始于 origin
上 master
分支相同的位置,你可以就此开始工作(见图 2-22):
图 2-22. 一次 Git 克隆会建立你自己的本地分支 master 和远程分支 origin/master,并且将它们都指向 origin
上的 master
分支。
如果你在本地 master
分支做了些改动,与此同时,其他人向 git.ourcompany.com
推送了他们的更新,那么服务器上的 master
分支就会向前推进,而于此同时,你在本地的提交历史正朝向不同方向发展。不过只要你不和服务器通讯,你的 origin/master
指针仍然保持原位不会移动(见图 2-23)。
图 2-23. 在本地工作的同时有人向远程仓库推送内容会让提交历史开始分流。
可以运行 git fetch origin
来同步远程服务器上的数据到本地。该命令首先找到 origin
是哪个服务器,从上面获取你尚未拥有的数据,更新你本地的数据库,然后把 origin/master
的指针移到它最新的位置上(见图 2-24)
图 3-24. git fetch 命令会更新 remote 索引。
为了演示拥有多个远程分支(在不同的远程服务器上)的项目是如何工作的,我们假设你还有另一个仅供你的敏捷开发小组使用的内部服务器 git.team1.ourcompany.com
。可以用我上面提到的 git remote add
命令把它加为当前项目的远程分支之一。我们把它命名为 teamone
,以便代替完整的 Git URL 以方便使用(见图 2-25)
图 3-25. 把另一个服务器加为远程仓库
现在你可以用 git fetch teamone
来获取小组服务器上你还没有的数据了。由于当前该服务器上的内容是你 origin
服务器上的子集,Git 不会下载任何数据,而只是简单地创建一个名为 teamone/master
的远程分支,指向 teamone
服务器上 master
分支所在的提交对象 31b8e
(见图 2-26)。
图 2-26. 你在本地有了一个指向 teamone 服务器上 master 分支的索引。
要想和其他人分享某个本地分支,你需要把它推送到一个你拥有写权限的远程仓库。你创建的本地分支不会因为你的写入操作而被自动同步到你引入的远程服务器上,你需要明确地执行推送分支的操作。换句话说,对于无意分享的分支,你尽管保留为私人分支好了,而只推送那些协同工作要用到的特性分支。
如果你有个叫 serverfix
的分支需要和他人一起开发,可以运行 git push (远程仓库名) (分支名)
:
$ git push origin serverfix # Counting objects: 20, done. # Compressing objects: 100% (14/14), done. # Writing objects: 100% (15/15), 1.74 KiB, done. # Total 15 (delta 5), reused 0 (delta 0) # To git@github.com:schacon/simplegit.git # * [new branch] serverfix -> serverfix
这里Git 会自动把 serverfix
分支名扩展为 refs/heads/serverfix:refs/heads/serverfix
,意为“取出我在本地的 serverfix 分支,推送到远程仓库的 serverfix 分支中去”。也可以运行 git push origin serverfix:serverfix
来实现相同的效果,它的意思是“上传我本地的 serverfix 分支到远程仓库中去,仍旧称它为 serverfix 分支”。通过此语法,你可以把本地分支推送到某个命名不同的远程分支:若想把远程分支叫作 awesomebranch
,可以用 git push origin serverfix:awesomebranch
来推送数据。
接下来,当你的协作者再次从服务器上获取数据时,他们将得到一个新的远程分支 origin/serverfix
,并指向服务器上 serverfix
所指向的版本:
$ git fetch origin # remote: Counting objects: 20, done. # remote: Compressing objects: 100% (14/14), done. # remote: Total 15 (delta 5), reused 0 (delta 0) # Unpacking objects: 100% (15/15), done. # From git@github.com:schacon/simplegit # * [new branch] serverfix -> origin/serverfix
值得注意的是,在 fetch
操作下载好新的远程分支之后,你仍然无法在本地编辑该远程仓库中的分支。换句话说,在本例中,你不会有一个新的 serverfix
分支,有的只是一个你无法移动的 origin/serverfix
指针。
如果要把该远程分支的内容合并到当前分支,可以运行 git merge origin/serverfix
。如果想要一份自己的 serverfix
来开发,可以在远程分支的基础上分化出一个新的分支来:
$ git checkout -b serverfix origin/serverfix # Branch serverfix set up to track remote branch refs/remotes/origin/serverfix. # Switched to a new branch "serverfix"
这会切换到新建的 serverfix
本地分支,其内容同远程分支 origin/serverfix
一致,这样你就可以在里面继续开发了。
从远程分支 checkout
出来的本地分支,称为 跟踪分支 (tracking branch)。跟踪分支是一种和某个远程分支有直接联系的本地分支。在跟踪分支里输入 git push
,Git 会自行推断应该向哪个服务器的哪个分支推送数据。同样,在这些分支里运行 git pull
会获取所有远程索引,并把它们的数据都合并到本地分支中来。
在克隆仓库时,Git 通常会自动创建一个名为 master
的分支来跟踪 origin/master
。这正是 git push
和 git pull
一开始就能正常工作的原因。当然,你可以随心所欲地设定为其它跟踪分支,比如 origin
上除了 master
之外的其它分支。刚才我们已经看到了这样的一个例子: git checkout -b [分支名] [远程名]/[分支名]
。如果你有 1.6.2 以上版本的 Git,还可以用 --track
选项简化:
$ git checkout --track origin/serverfix # Branch serverfix set up to track remote branch refs/remotes/origin/serverfix. # Switched to a new branch "serverfix"
要为本地分支设定不同于远程分支的名字,只需在第一个版本的命令里换个名字:
$ git checkout -b sf origin/serverfix # Branch sf set up to track remote branch refs/remotes/origin/serverfix. # Switched to a new branch "sf"
现在你的本地分支 sf
会自动将推送和抓取数据的位置定位到 origin/serverfix
了。
如果不再需要某个远程分支了,比如搞定了某个特性并把它合并进了远程的 master
分支(或任何其他存放稳定代码的分支),可以用这个非常无厘头的语法来删除它: git push [远程名] :[分支名]
。如果想在服务器上删除 serverfix
分支,运行下面的命令:
$ git push origin :serverfix # To git@github.com:schacon/simplegit.git # - [deleted] serverfix
咚!服务器上的分支没了。你最好特别留心这一页,因为你一定会用到那个命令,而且你很可能会忘掉它的语法。有种方便记忆这条命令的方法:记住我们不久前见过的 git push [远程名] [本地分支]:[远程分支]
语法,如果省略 [本地分支]
,那就等于是在说“在这里提取空白然后把它变成 [远程分支]
”。
把一个分支中的修改整合到另一个分支的办法有两种: merge
和 rebase
。
a.基本的衍合操作
请回顾我上面讲到的合并(见图 2-27),你会看到开发进程分叉到两个不同分支,又各自提交了更新。
图 2-27. 最初分叉的提交历史。
前面介绍过,最容易的整合分支的方法是 merge
命令,它会把两个分支最新的快照(C3 和 C4)以及二者最新的共同祖先(C2)进行三方合并,合并的结果是产生一个新的提交对象(C5)。如图 2-28 所示:
图 2-28. 通过合并一个分支来整合分叉了的历史。
其实,还有另外一个选择:你可以把在 C3 里产生的变化补丁在 C4 的基础上重新打一遍。在 Git 里,这种操作叫做 衍合(rebase) 。有了 rebase
命令,就可以把在一个分支里提交的改变移到另一个分支里重放一遍。
在上面这个例子中,运行:
$ git checkout experiment # $ git rebase master # First, rewinding head to replay your work on top of it... # Applying: added staged command
它的原理是回到两个分支最近的共同祖先,根据当前分支(也就是要进行衍合的分支 experiment
)后续的历次提交对象(这里只有一个 C3),生成一系列文件补丁,然后以基底分支(也就是主干分支 master
)最后一个提交对象(C4)为新的出发点,逐个应用之前准备好的补丁文件,最后会生成一个新的合并提交对象(C3'),从而改写 experiment
的提交历史,使它成为 master
分支的直接下游,如图 2-29 所示:
图 2-29. 把 C3 里产生的改变到 C4 上重演一遍。
现在回到 master
分支,进行一次快进合并(见图 2-30):
图 2-30. master 分支的快进。
现在的 C3' 对应的快照,其实和普通的三方合并,即上个例子中的 C5 对应的快照内容一模一样了。虽然最后整合得到的结果没有任何区别,但衍合能产生一个更为整洁的提交历史。如果视察一个衍合过的分支的历史记录,看起来会更清楚:仿佛所有修改都是在一根线上先后进行的,尽管实际上它们原本是同时并行发生的。
一般我们使用衍合的目的,是想要得到一个能在远程分支上干净应用的补丁 — 比如某些项目你不是维护者,但想帮点忙的话,最好用衍合:先在自己的一个分支里进行开发,当准备向主项目提交补丁的时候,根据最新的 origin/master
进行一次衍合操作然后再提交,这样维护者就不需要做任何整合工作(实际上是把解决分支补丁同最新主干代码之间冲突的责任,化转为由提交补丁的人来解决),只需根据你提供的仓库地址作一次快进合并,或者直接采纳你提交的补丁。
请注意,合并结果中最后一次提交所指向的快照,无论是通过衍合,还是三方合并,都会得到相同的快照内容,只不过提交历史不同罢了。衍合是按照每行的修改次序重演一遍修改,而合并是把最终结果合在一起。
b.有趣的衍合
衍合也可以放到其他分支进行,并不一定非得根据分化之前的分支。以图 2-31 的历史为例,我们为了给服务器端代码添加一些功能而创建了特性分支 server
,然后提交 C3 和 C4。然后又从 C3 的地方再增加一个 client
分支来对客户端代码进行一些相应修改,所以提交了 C8 和 C9。最后,又回到 server
分支提交了 C10。
图 2-31. 从一个特性分支里再分出一个特性分支的历史。
假设在接下来的一次软件发布中,我们决定先把客户端的修改并到主线中,而暂缓并入服务端软件的修改(因为还需要进一步测试)。这个时候,我们就可以把基于 server
分支而非 master
分支的改变(即 C8 和 C9),跳过 server
直接放到 master
分支中重演一遍,但这需要用 git rebase
的 --onto
选项指定新的基底分支 master
:
$ git rebase --onto master server client
这好比在说:“取出 client
分支,找出 client
分支和 server
分支的共同祖先之后的变化,然后把它们在 master
上重演一遍”。它的结果如图 2-32 所示(译注:虽然 client
里的 C8, C9 在 C3 之后,但这仅表明时间上的先后,而非在 C3 修改的基础上进一步改动,因为 server
和 client
这两个分支对应的代码应该是两套文件,虽然这么说不是很严格,但应理解为在 C3 时间点之后,对另外的文件所做的 C8,C9 修改,放到主干重演。):
图 2-32. 将特性分支上的另一个特性分支衍合到其他分支。
现在可以快进 master
分支了(见图 2-33):
# step 1 $ git checkout master # step 2 $ git merge client
图 2-33. 快进 master 分支,使之包含 client 分支的变化。
现在我们决定把 server
分支的变化也包含进来。我们可以直接把 server
分支衍合到 master
,而不用手工切换到 server
分支后再执行衍合操作 — git rebase [主分支] [特性分支]
命令会先取出特性分支 server
,然后在主分支 master
上重演:
$ git rebase master server
于是, server
的进度应用到 master
的基础上,如图 2-34 所示:
图 2-34. 在 master 分支上衍合 server 分支。
然后就可以快进主干分支 master
了:
# step 1 $ git checkout master # step 2 $ git merge server
现在 client
和 server
分支的变化都已经集成到主干分支来了,可以删掉它们了。最终我们的提交历史会变成图 2-35 的样子:
# step 1 $ git branch -d client # step 2 $ git branch -d server
图 2-35. 最终的提交历史
这里需要强调一点的是,使用衍合的时候必须遵守一条准则:
三、小结
读到这里,首先我表示对你的耐心感到赞许,而这个时候的你应该已经学会了如何创建分支并切换到新分支,在不同分支间转换,合并本地分支,把分支推送到共享服务器上,使用共享分支与他人协作,以及在分享之前进行衍合。