git干货系列:(五)多人协同工作之分支管理

前言

分支就是科幻电影里面的平行宇宙,当你正在电脑前努力学习Git的时候,另一个你正在另一个平行宇宙里努力学习SVN。如果两个平行宇宙互不干扰,那对现在的你也没啥影响。不过,在某个时间点,两个平行宇宙合并了,结果,你既学会了Git又学会了SVN

正文

分支简介

为了真正理解 Git 处理分支的方式,我们需要回顾一下Git是如何保存数据的。
Git 保存的不是文件的变化或者差异,而是一系列不同时刻的文件快照。在进行提交操作时,Git会保存一个提交对象(commit object)。知道了Git保存数据的方式,我们可以很自然的想到——该提交对象会包含一个指向暂存内容快照的指针。 但不仅仅是这样,该提交对象还包含了作者的姓名和邮箱、提交时输入的信息以及指向它的父对象的指针。首次提交产生的提交对象没有父对象,普通提交操作产生的提交对象有一个父对象,而由多个分支合并产生的提交对象有多个父对象。

Git的分支,其实本质上仅仅是指向提交对象的可变指针。 Git的默认分支名字是 master。 在多次提交操作之后,你其实已经有一个指向最后那个提交对象的 master 分支。 它会在每次的提交操作中自动向前移动。

Git 的 “master” 分支并不是一个特殊分支。它就跟其它分支完全没有区别。 之所以几乎每一个仓库> 都有 master 分支,是因为 git init 命令默认创建它,并且大多数人都懒得去改动它。

分支在实际中有什么用呢?假设你准备开发一个新功能,但是需要两周才能完成,第一周你写了50%的代码,如果立刻提交,由于代码还没写完,不完整的代码库会导致别人不能干活了。如果等代码全部写完再一次提交,又存在丢失每天进度的巨大风险。
现在有了分支,就不用怕了。你创建了一个属于你自己的分支,别人看不到,还继续在原来的分支上正常工作,而你在自己的分支上干活,想提交就提交,直到开发完毕后,再一次性合并到原来的分支上,这样,既安全,又不影响别人工作。
其他版本控制系统如SVN等都有分支管理,但是用过之后你会发现,这些版本控制系统创建和切换分支比蜗牛还慢,简直让人无法忍受,结果分支功能成了摆设,大家都不去用。
Git的分支是与众不同的,无论创建、切换和删除分支,Git在1秒钟之内就能完成!无论你的版本库是1个文件还是1万个文件。

分支创建

Git是怎么创建新分支的呢? 很简单,它只是为你创建了一个可以移动的新的指针。 比如,创建一个 testing分支, 你需要使用 git branch 命令:

1
$ git branch testing

这会在当前所在的提交对象上创建一个指针。

两个指向相同提交历史的分支。

那么,Git又是怎么知道当前在哪一个分支上呢? 也很简单,它有一个名为 HEAD 的特殊指针。 请注意它和许多其它版本控制系统(如 Subversion 或 CVS)里的 HEAD 概念完全不同。 在 Git中,它是一个指针,指向当前所在的本地分支(译注:将 HEAD 想象为当前分支的别名)。 在本例中,你仍然在master 分支上。 因为 git branch 命令仅仅 创建 一个新分支,并不会自动切换到新分支中去。

HEAD 指向当前所在的分支.

你可以简单地使用 git log 命令查看各个分支当前所指的对象。 提供这一功能的参数是 --decorate

1
2
3
4
$ git log --oneline --decorate
f30ab (HEAD, master, testing) add feature #32 - ability to add new
34ac2 fixed bug #1328 - stack overflow under certain conditions
98ca9 initial commit of my project

正如你所见,当前 “master” 和 “testing” 分支均指向校验和以 f30ab 开头的提交对象。

分支切换

要切换到一个已存在的分支,你需要使用git checkout命令。 我们现在切换到新创建的 testing 分支去:

1
$ git checkout testing

这样 HEAD 就指向 testing 分支了。
HEAD 指向当前所在的分支.

上面的创建分支和切换分支命令可以合起来用下面这个命令来替代。

1
$ git checkout -b testing

那么,这样的实现方式会给我们带来什么好处呢? 现在不妨再提交一次:

1
2
$ vim test.rb
$ git commit -a -m 'made a change'

HEAD 分支随着提交操作自动向前移动.
如图所示,你的 testing 分支向前移动了,但是 master 分支却没有,它仍然指向运行 git checkout 时所指的对象。 这就有意思了,现在我们切换回 master 分支看看:

1
$ git checkout master

检出时 HEAD 随之移动.
这条命令做了两件事。 一是使 HEAD 指回 master 分支,二是将工作目录恢复成 master 分支所指向的快照内容。 也就是说,你现在做修改的话,项目将始于一个较旧的版本。 本质上来讲,这就是忽略testing 分支所做的修改,以便于向另一个方向进行开发。
可以使用 git branch命令查看当前分支,注意前面带*的表示当前分支


Note
分支切换会改变你工作目录中的文件
在切换分支时,一定要注意你工作目录里的文件会被改变。 如果是切换到一个较旧的分支,你的工作目> 录会恢复到该分支最后一次提交时的样子。 如果Git不能干净利落地完成这个任务,它将禁止切换分支。

合并分支(快速合并)

假如我们在testing上的工作完成了,就可以把testing合并到master上。Git怎么合并呢?最简单的方法,就是直接把master指向testing的当前提交,就完成了合并,这里你需要使用git merge命令

1
2
3
4
5
6
$ git merge testing
Updating 64ba18a..760118b
Fast-forward
hello.txt | 1 +
1 file changed, 1 insertion(+)
create mode 100644 hello.txt

git merge命令用于合并指定分支到当前分支。合并后,再查看内容,就可以看到,和testing分支的最新提交是完全一样的。
注意到上面的Fast-forward信息,Git告诉我们,这次合并是“快进模式”,也就是直接把master指向testing的当前提交,所以合并速度非常快。
当然,也不是每次合并都能Fast-forward,我们后面会讲其他方式的合并。

删除分支

合并完分支后,甚至可以删除dev分支。删除dev分支就是把dev指针给删掉,删掉后,我们就剩下了一条master分支,这里需要使用git branch -d命令来删除分支

1
2
$ git branch -d testing
Deleted branch testing (was 760118b).

分支合并冲突

人生不如意之事十之八九,合并分支往往也不是一帆风顺的。
准备新的dev分支,继续我们的新分支开发:

1
2
$ git checkout -b dev
Switched to a new branch 'dev'

修改README.md内容,添加一样内容”day day up~”,在dev分支上提交:

1
2
3
$ git commit -am "one commit"
[dev 6a6a08e] one commit
1 file changed, 1 insertion(+)

切换到master分支:

1
2
3
$ git checkout master
Switched to branch 'master'
Your branch is up-to-date with 'origin/master'.

Git还会自动提示我们当前master分支比远程的master分支要超前1个提交。
master分支上把README.md文件的最后改为 good good study,然后提价

1
2
3
$ git commit -am "two commit"
[master 75d6f25] two commit
1 file changed, 1 insertion(+)

现在,master分支和dev分支各自都分别有新的提交,变成了这样:

这种情况下,Git无法执行“快速合并”,只能试图把各自的修改合并起来,但这种合并就可能会有冲突,我们试试看:

1
2
3
4
$ git merge dev
Auto-merging README.md
CONFLICT (content): Merge conflict in README.md
Automatic merge failed; fix conflicts and then commit the result.

果然冲突了!Git告诉我们, README.md文件存在冲突,必须手动解决冲突后再提交。git status也可以告诉我们冲突的文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ git status
On branch master
Your branch is ahead of 'origin/master' by 1 commit.
(use "git push" to publish your local commits)
You have unmerged paths.
(fix conflicts and run "git commit")

Unmerged paths:
(use "git add <file>..." to mark resolution)

both modified: README.md

no changes added to commit (use "git add" and/or "git commit -a")

我们可以直接查看README.md的内容:

1
2
3
4
5
6
7
$ cat README.md
#gitLearn
<<<<<<< HEAD
good good study
=======
day day up
>>>>>>> dev

Git用<<<<<<<=======>>>>>>>标记出不同分支的内容,我们修改如下后保存:

1
2
3
#gitLearn
good good study
day day up

再提交:

1
2
$ git commit -am 'merge commit'
[master 9a4d00b] merge commit

现在,master分支和dev分支变成了下图所示:

用带参数的git log也可以看到分支的合并情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ git log --graph --pretty=oneline --abbrev-commit
* 9a4d00b merge commit
|\
| * 6a6a08e one commit
* | 75d6f25 two commit
|/
* ae06dcf 123
* 760118b test
* 64ba18a test
|\
| * 4392848 Accept Merge Request #1 test : (dev -> master)
| |\
| | * a430c4b update README.md
| |/
| * 88ec6d7 Initial commit
* 32d11c8 update README.md
* 8d5acc1 new file README
* e02f115 Initial commit

最后,删除feature1分支:

1
2
$ git branch -d dev
Deleted branch dev (was 6a6a08e).

合并分支(普通合并)

通常,合并分支时,如果可能,Git会用Fast forward模式,但这种模式下,删除分支后,会丢掉分支信息。
如果要强制禁用Fast forward模式,Git就会在merge时生成一个新的commit,这样,从分支历史上就可以看出分支信息。
下面我们实战一下--no-ff方式的git merge
首先,仍然创建并切换dev分支:

1
2
$ git checkout -b dev
Switched to a new branch 'dev'

修改README.md文件,并提交一个新的commit:

1
2
3
$ git commit -am 'submit'
[dev fee6025] submit
1 file changed, 1 insertion(+)

现在,我们切换回master

1
2
$ git checkout master
Switched to branch 'master'

目前来说流程图是这样:

准备合并dev分支,请注意--no-ff参数,表示禁用Fast forward

1
2
3
4
$ git merge --no-ff -m "merge with no-ff" dev
Merge made by the 'recursive' strategy.
README.md | 1 +
1 file changed, 1 insertion(+)

因为本次合并要创建一个新的commit,所以加上-m参数,把commit描述写进去。

合并后,我们用git log看看分支历史:

1
2
3
4
5
6
7
$ git log --graph --pretty=oneline --abbrev-commit
* b98f802 merge with no-ff
|\
| * fee6025 submit
|/
* 9a4d00b merge commit
...

可以看到,不使用Fast forward模式,merge后就像这样:

分支管理策略

实际公司开发的时候一般3个分支就可以了:

  1. mster 主分支用来发布
  2. dev 日常开发用的分支
  3. bug 修改bug用的分支

首先,master分支应该是非常稳定的,也就是仅用来发布新版本,平时不能在上面干活;
干活都在dev分支上,也就是说,dev分支是不稳定的,到某个时候,比如1.0版本发布时,再把dev分支合并到master上,在master分支发布1.0版本,你和你的小伙伴们每个人都在dev分支上干活,每个人都有自己的分支,时不时地往dev分支上合并就可以了;
bug分支用来处理日常bug,搞定后合到dev分支即可;

假设远程公共仓库,有一个master和一个dev分支,进行多人协作开发时候(每个人的公钥必须加入到远程账号下,否则无法push), 每个人都应该clone一份到本地。 但是clone的只是master,如果远程的masterdev一样,没关系;如果不一致,则需要clonedev分支 git checkout -b dev origin/dev 之后每个人在本地的dev分支上独自开发(最好不要在mast上开发), 开发完成之后push到远程dev, git push origin dev。 之后审核人再确定是否合并devmaster

团队多人开发协作

当你从远程仓库克隆时,实际上Git自动把本地的master分支和远程的master分支对应起来了,并且,远程仓库的默认名称是origin
要查看远程库的信息,用git remote

1
2
$ git remote
origin

或者,用git remote -v显示更详细的信息:

1
2
3
$ git remote -v
origin git@git.coding.net:tengj/gitLearn.git (fetch)
origin git@git.coding.net:tengj/gitLearn.git (push)

上面显示了可以抓取和推送的origin的地址。如果没有推送权限,就看不到push的地址。

推送分支

推送分支,就是把该分支上的所有本地提交推送到远程库。推送时,要指定本地分支,这样,Git就会把该分支推送到远程库对应的远程分支上:

1
$ git push origin master

如果要推送其他分支,比如dev,就改成:

1
$ git push origin dev

抓取分支

多人协作时,大家都会往masterdev分支上推送各自的修改。
现在,模拟一个你的小伙伴,可以在另一台电脑(注意要把SSH Key添加到GitHub)或者同一台电脑的另一个目录下克隆:

1
2
3
4
5
6
$ git clone git@git.coding.net:tengj/gitStudy.git
Cloning into 'gitStudy'...
remote: Counting objects: 3, done.
remote: Total 3 (delta 0), reused 0 (delta 0)
Receiving objects: 100% (3/3), done.
Checking connectivity... done.

当你的小伙伴从远程库clone时,默认情况下,你的小伙伴只能看到本地的master分支。不信可以用git branch命令看看:

1
2
$ git branch
* master

现在,你的小伙伴要在dev分支上开发,就必须创建远程origindev分支到本地,于是他用这个命令创建本地dev分支(程分支dev要先创建)。

1
2
$ git checkout -b dev
git

创建dev分之后,先同步远程服务器上的数据到本地

1
2
3
$ git fetch origin
From git.coding.net:tengj/gitStudy
* [new branch] dev -> origin/dev

现在,他就可以在dev上继续修改,然后,时不时地把dev分支push到远程:

1
2
3
4
5
6
7
8
9
10
11
$ git commit -am 'test'
[dev c120ad6] test
1 file changed, 1 insertion(+)
$ git push origin dev
Counting objects: 3, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (2/2), done.
Writing objects: 100% (3/3), 262 bytes | 0 bytes/s, done.
Total 3 (delta 0), reused 0 (delta 0)
To git@git.coding.net:tengj/gitStudy.git
65c05aa..c120ad6 dev -> dev

你的小伙伴已经向origin/dev分支推送了他的提交,而碰巧你也对同样的文件作了修改,并试图推送:

1
2
3
4
5
6
7
8
9
$ git push origin dev
To git@git.coding.net:tengj/gitStudy.git
! [rejected] dev -> dev (fetch first)
error: failed to push some refs to 'git@git.coding.net:tengj/gitStudy.git'
hint: Updates were rejected because the remote contains work that you do
hint: not have locally. This is usually caused by another repository pushing
hint: to the same ref. You may want to first integrate the remote changes
hint: (e.g., 'git pull ...') before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.

推送失败,因为你的小伙伴的最新提交和你试图推送的提交有冲突,解决办法也很简单,Git已经提示我们,先用git pull把最新的提交从origin/dev抓下来,然后,在本地合并,解决冲突,再推送:

1
2
3
4
5
6
7
8
9
10
11
$ git pull origin dev
remote: Counting objects: 3, done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 3 (delta 0), reused 0 (delta 0)
Unpacking objects: 100% (3/3), done.
From git.coding.net:tengj/gitStudy
* branch dev -> FETCH_HEAD
b7b87f4..f636337 dev -> origin/dev
Auto-merging a.txt
CONFLICT (content): Merge conflict in a.txt
Automatic merge failed; fix conflicts and then commit the result.

因此,多人协作的工作模式通常是这样:

  1. 首先,可以试图用git push origin branch-name推送自己的修改;
  2. 如果推送失败,则因为远程分支比你的本地更新,需要先用git pull试图合并;
  3. 如果合并有冲突,则解决冲突,并在本地提交;
  4. 没有冲突或者解决掉冲突后,再用git push origin branch-name推送就能成功!

如果git pull提示“no tracking information”,则说明本地分支和远程分支的链接关系没有创建,用命令git branch --set-upstream-to branch-name origin/branch-name
这就是多人协作的工作模式,一旦熟悉了,就非常简单。

总结

到此,Git分支管理就学完了,整理一下所学的命令,大体如下:

1
2
3
4
5
6
7
8
9
10
11
git branch           查看当前分支
git branch -v 查看每一个分支的最后一次提交
git branch -a 查看本地和远程分支的情况
git branch --merged 查看已经与当前分支合并的分支
git branch --no-merged 查看已经与当前分支未合并的分支
git branch -r 查看远程分支
git branch dev 创建分支 dev
git checkout dev 切换到分支dev
git checkout -b dev 创建并切换分支dev
git merge dev 名称为dev的分支与当前分支合并
git branch -d dev 删除分支dev


文章目录
  1. 1. 前言
  2. 2. 正文
  3. 3. 分支简介
  4. 4. 分支创建
  5. 5. 分支切换
  6. 6. 合并分支(快速合并)
  7. 7. 删除分支
  8. 8. 分支合并冲突
  9. 9. 合并分支(普通合并)
  10. 10. 分支管理策略
  11. 11. 团队多人开发协作
    1. 11.1. 推送分支
    2. 11.2. 抓取分支
  • 总结