几乎所有的版本控制系统都以某种形式支持分支。 使用分支意味着你可以把你的工作从开发主线上分离开来,以免影响开发主线。 在很多版本控制系统中,这是一个略微低效的过程——常常需要完全创建一个源代码目录的副本。对于大项目来说,这样的过程会耗费很多时间。
有人把 Git 的分支模型称为它的`‘必杀技特性’’,也正因为这一特性,使得 Git 从众多版本控制系统中脱颖而出。 为何 Git 的分支模型如此出众呢? Git 处理分支的方式可谓是难以置信的轻量,创建新分支这一操作几乎能在瞬间完成,并且在不同分支之间的切换操作也是一样便捷。 与许多其它版本控制系统不同,Git 鼓励在工作流程中频繁地使用分支与合并,哪怕一天之内进行许多次。 理解和精通这一特性,你便会意识到 Git 是如此的强大而又独特,并且从此真正改变你的开发方式。
分支简介
首先我们知道,Git保存的不是文件的变化或者差异,而是一系列不同时刻的文件快照。
在进行提交操作时,Git会保存一个提交对象(commit object)。这就是Git保存数据的方式,我们可以很自然的想到–该提交对象会包含一个指向暂存内容快照的指针。但不仅仅是这样,该提交对象还包含了作者的姓名和邮箱、提交时输入的信息以及指向它的父对象的指针。首次提交产生的提交对象没有父对象,普通提交的操作产生的提交对象有一个父对象,而由多个分支合并产生的提交对象有多个父对象。
例如,假设我们现在有一个工作目录,里面包含了三个将要被暂存和提交的文件。暂存操作会为每一个文件计算校验和,然后会把当前版本的文件快照保存到Git仓库中(Git 使用blob对象来保存它们),最终将校验和加入到暂存区域等待提交:
git add *
然后当我们使用git commit
进行提交操作时,Git会先计算每一个子目录(本例子中只有根目录)的校验和,然后再Git仓库中将这些校验和保存为树对象。随后,Git便会创建一个提交对象,它除了包含上面提到的那些信息外,还包含指向这个树对象(项目的根目录)的指针,如此一来,Git就可以在需要的时候重现此次保存的快照。
现在,Git仓库中有五个对象:三个blob对象(文件快照,因为我们这个工作目录只有三个文件)、一个树对象(记录着目录结构和blob对象索引)以及一个提交对象(包含着指向当前数对象的指针和所有提交信息)。
在我们做了一些修改之后,那么这次产生的提交对象会包含一个指向上次的提交对象(父对象)的指针。
Git的分支,其实本质上仅仅是指向提交对象的可变指针。Git的默认分支名字是master
。在多次提交之后,我们其实已经有一个指向最后那个提交对象的master
分支。它会在每次的提交操作中自动向前移动。
注意: Git的master
分支并不是一个特殊的分支。它就跟其他分支完全没有区别。之所以几乎每一个仓库都有一个master
分支,是因为git init
命令会默认创建它,并且大多数人都难得去改动它。
分支创建
语法格式:git branch [name]
git branch testing
这样我们就创建了一个testing
的分支,实质上创建了一个可以移动的新的指针指向当前提交的对象。
那么,Git是怎样知道当前我们在哪一个分支上呢?很简单,Git拥有一个HEAD
的特殊指针。
他是一个指针,指向当前所在的本地分支(可以想象为当前分支的别名)。在我们这个例子中,我们仍然在master
分支上。因为git branch
仅仅创建一个分支,并不会切换到新的分支中国去。
我们可以使用git log
命令查看各个分支当前所指的对象。提供这一功能的参数是--decorate
。
git log --oneline --decorate
我们就会看到以下信息:
f30ab (HEAD, master, testing) add feature
98ca9 initial commit of my project
我们可以看到master
和testing
分支均指向校验和为f30ab
开头的提交对象。
分支切换
当我们要切换到一个已经存在的分支是,我们只需要是使用git checkout
命令。我们现在切换到新创建的testing
分支去:
git checkout testing
这样HEAD
就指向了testing
分支了。
那么,我们这样做了有什么好处呢?现在我们修改一些信息,然后在提交一次:
vim test.py
git add *
git commit -m "changed branch first commit"
这个时候我们的仓库就变成了下面这样了
如图所示,我们的testing
分支向前移动了,但是master
分支却没有,这是因为我们的当前分支是testing
分支,那么我们做的所有改动都会被记录在testing
上。这个时候我们在切换回master
分支上。
git checkout master
然后我们在去看一下我们刚才修改了的文件。
vim test.py
我么可以看到,我们刚才对这个文件的修改变没了。也就是git checkout master
命令做了两件事,一是使HEAD指向master
分支,二是将工作目录恢复成master
分支指向的快照内容。也就是说,我们现在再做修改的话,项目将始于一个较旧的版本。本质上来讲,这就是忽略testing
分支所做的修改,以便于向另一个方向进行开发。
注意: 分支切换会改变你工作目录中的文件,在切换分支时,一定要注意工作目录里面的文件会被改变。如果是切换到一个较旧的分支,你的工作目录会恢复到该分支最后一次提交的样子。如果Git不能干净利落的完成这个任务,他将禁止切换分支。
这个时候我们再对文件做一些修改并提交:
vim test.py
git commit -a -m "testing branch switch to master branch change file"
现在,这个项目的提交历史已经产生了分叉,因为我们刚才创建了一个新的分支testing
,并切换过去做了一些工作,然后我们有切换回来master
分支进行了另外一些工作,上述两次改动针对的是不同的分支:我们可以在不同的分支间不断的来回切换和工作,并在时机成熟把他们合并起来。而所有这些工作,我们需要的命令只有branch
、checkout
、commit
。
现在我们的项目结构应该为这样的,我们可以运行git log
命令查看分叉历史。运行git log --oneline --decorate --graph --all
,它会输出我们的额提交历史、各个分支的指向以及项目的分叉情况。
git log --oneline --decorate --graph --all
由于 Git 的分支实质上仅是包含所指对象校验和(长度为 40 的 SHA-1 值字符串)的文件,所以它的创建和销毁都异常高效。 创建一个新分支就相当于往一个文件中写入 41 个字节(40 个字符和 1 个换行符),所以Git是一个高效的版本控制系统。
这与过去大多数版本控制系统形成了鲜明的对比,它们在创建分支时,将所有的项目文件都复制一遍,并保存到一个特定的目录。 完成这样繁琐的过程通常需要好几秒钟,有时甚至需要好几分钟。所需时间的长短,完全取决于项目的规模。而在 Git 中,任何规模的项目都能在瞬间创建新分支。 同时,由于每次提交都会记录父对象,所以寻找恰当的合并基础(即共同祖先)也是同样的简单和高效。 这些高效的特性使得 Git 鼓励开发人员频繁地创建和使用分支。
分支的新建与合并
我们来看一个简单的分支新建与合并的例子,实际工作中我们也会用到类似的工作流。我们将经历以下步骤:
- 开发某个网站
- 为实现某个新的需求,创建一个分支。
- 在这个分支上进行开发工作。
在这个时候,我们的线上网站突然有一个严重的问题急需修补。我们就可以按照以下的方式来处理:
4. 切换到我们的线上分支。
5. 为这个紧急任务新建一个分支,并在其中修复它。
6. 在测试通过之后,我们又切换回线上分支,然后合并修补这个分支,最后将改动推送到线上分支。
7. 切换回我们最初的工作的分支上,继续工作。
首先,假设我们已经拥有一个项目,并且已经做了一些提交。假设它是下面这样的结构。
首先我们得新建一个分支来解决我们得问题
git checkout -b iss53
上面的命令是下面两条命令的简写:
git branch iss53
git checkout iss53
这条命令可以一次性执行创建分支和切换分支,这里我们创建了一个issue
的分支,并且切换到这个分支上去了。
现在我们对当前的项目做了一些修改,然后我们还没有解决完这个问题。这个时候我们接到了一个电话,线上的项目有一个紧急的问题需要我们解决。我们只有先放弃对当前问题的解决,去解决那个紧急的问题。我们先将我们当前的修改的代码进行提交:
git commit -a -m "solve iss53's issue"
然后我们的项目结构就是下面这样的了。
但是,在我们这么做之前,要留意你的工作目录和暂存区域里面那些还没有被提交的修改,它可能会和你即将检出的分支产生冲突从而阻止Git切换到该分支。最好的方法就是在我们切换分支之前,保持一个干净的状态。我们也有一些方法可以绕过这个问题,即保存进度(stashing)和修补提交(commit amending)。这个命令将会在Git的储藏与清理
介绍。现在,假设我们已经把我们所做的修改全部提交了,这个时候我们就可以切换至master
分支了。
git checkout master
这个时候,我们的工作目录和我们在哎开始修改iss53
问题之前是一模一样的,现在我们就回到了修改iss53
问题之前的分支了。请牢记:当我们切换分支的时候,Git会重置我们的工作目录,使其看起来像是回到了我们在那个分支上最后一次提交的样子。Git会自动添加、删除、修改文件以确保此时我们的工作目录和这个分支最后一次提交时的样子一模一样。
然后我们又新建了一个分支来解决这个紧急的问题,直到在该分支上解决该问题为止:
git checkout -b hotfix
'''do something to solve this hot issue'''
git commit -a -m "fixed this issue"
这个时候我们的项目的结构为:
这个时候,我们想要合并master
分支和hotfix
分支,然后部署到线上,我们可以使用git merge
命令来达到上述目的:
git checkout master # 切换回master分支
git merge hotfix # 将hotfix分支合并到master分支上
在合并的时候,我们可以看到"快进(fast-forward)"这个词。 由于当前 master 分支所指向的提交是你当前提交(有关 hotfix 的提交)的直接上游,所以 Git 只是简单的将指针向前移动。 换句话说,当你试图合并两个分支时,如果顺着一个分支走下去能够到达另一个分支,那么 Git 在合并两者的时候,只会简单的将指针向前推进(指针右移),因为这种情况下的合并操作没有需要解决的分歧——这就叫做 “快进(fast-forward)”。
这个时候,我们已经解决了我们的紧急问题,那么hotfix
分支也没有用了,我们不在需要它了,所以我们可以删除这个分支:
git branch -d hotfix
现在,我们可以切换回iss53
分支,对刚才那个没有解决完的问题继续进行修改。
git checkout iss53
'''do something to solve this issue'''
git commit -a -m "finish solve this issue"
但是,我们在hotfix
分支上所做的工作并没有包含到iss53
分支中。如果这个时候我们需要hotfix
分支所做的修改,我们可以使用git merge master
命令将master
分支合并到iss53
分支。如果我们不需要hotfix
分支中所做的修改,那么我们也可以等我们在iss53
分支将问题全部解决完成之后再合并回master
分支。
分支的合并
假设我们已经修正了iss53
问题,并且打算将我们的工作合并入master
分支,这和我们之前合并hotfix
分支所做的工作差不多,我们只需要检出我们想要合并的分支,然后运行git merge
命令就可以了:
git checkout master # 首先切换回master分支
git merge iss53 # 然后合并iss53这个分支到master分支上去
注意: 注意,当我们两次修改的内容有冲突的时候,我们的合并可能会失败,这个时候急需要我们手动解决这个冲突了才能开始合并了。
我们这次的合并和之前的hotfix
分支合并的时候看起来有一点不一样。在这种情况下,我们的开发历史从一个更早的地方分叉开来。因为master
分支所在的提交并不是iss53
分支所在分支所提交的直接祖先,Git不得不做一些额外的工作。出现这个情况的时候,Git会使用两个分支的末端所指的快照(c4和c5)以及这两个分支的工作祖先(c2),做一个简单的三方合并。
这和之前的合并并不一样,前面的知识简单的将指针向前推进,而现在这次合并的结果是做了一个新的快照并且自动创建一个新的提交对象指向了它。这个被称作合并提交。他的特别之处在于它不知一个父提交。
需要说明的是,Git会自动决定选取哪一个提交作为最优共同祖先,并以此作为合并的基础;这就和其他的一些版本控制系统不同了,而不用自己选择一个最佳的合并基础 ,而是有Git自己给我们选择。所以Git合并操作也比其它版本控制系统简答很多。
现在我们已经修复了iss53
问题,所以我么已经不再需要这个分支了,我们就可以将这个分支删除。
git branch -d iss53
注意: 删除分支的时候我们不能在这个分支上,否则会删除不了。
遇到冲突时的分支合并
有时候合并操作并不会很顺利。如果我们在两个不同的分支中,对同一个文件得同一个部分进行了不同的修改,Git就没有办法干净的合并他们。
当我们执行合并命令的时候,可能就会出现以下提示:
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会暂停下来,等待我们去解决合并时产生的冲突。我们可以使用git status
命令来查看哪些包含合并冲突而处于未合并(unmerged)状态的文件:
git status
On branch master
You have unmerged paths.
(fix conflicts and run "git commit")
Unmerged paths:
(use "git add <file>..." to mark resolution)
both modified: index.html
no changes added to commit (use "git add" and/or "git commit -a")
任何因包含合并冲突而有待解决的文件,都会以未合并状态标识出来。 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分支,因为我们切换到master分支执行的合并命令)在这个区段的上半部分(======
的上半部分,)而iss53
分支所指示的版本在======
的下半部分,为了解决冲突,我们必须选择由======
分割中的两部分中的一个,也就是是我们打开文件,替换这段冲突内容为:
<div id="footer">
please contact us at email.support@github.com
</div>
这样我们就手动解决了这个冲突,然后我们使用git add
命令将这个文件添加至暂存区(也就是标记了冲突已解决)
这个时候我们已经手动的解决我们的冲突,并且提交至了暂存区,所以我们就可以将其提交至我们的仓库中了
git commit
接下来我们输入一些对此次提交内容的描述就可以了。
分支管理
现在我们已经知道了如何创建,合并,删除分支,接下来我么看一下一些常用的分支管理工具
git branch
命令不只是可以创建爱你与删除分支。如果不加任何参数运行它,我们就会得到当前所有分支的一个列表:
git branch
* master
testing
注意master
分支之前的*
字符:他代表现HEAD指针指向的当前分支(也就是当前我们所在的分支)。
git branch -v
,命令可以查看每一个分支的最后一次提交,并且能看到提交的描述信息
git branch -v
* master 533657f [ahead 7] dont` know change file
testing dc469de changed branch first commit
--merged
与--no-merged
这两个有用的选项可以过滤这个列表中已经合并或尚未合并到当前分支的分支,
例如,我们需要查看哪些分支已经合并到当前分支:
git branch --merged
因这条命令显示的分支是已经合并过的,所以我们可以运行git branch -d <branch-name>
命令来删除这些分支。
查看哪些分支没有合并当前分支:
git branch --no-merged
这条命令显示的分支是没有合并到当前的分支的,所以我们尝试删除这个分支的时候,就会失败。并且提示我们这个分支还没有合并,如果我们想要强制删除这个分支并丢弃掉这部分工作,我们可以使用-D
选项来强制删除它。
git branch -D <branch-name>