分支是我们的老朋友了,第2篇中的“第6章 Git对象库”、“第7章 Git重置”和“第8章 Git检出”等章节中,就已经从实现原理上理解了分支。您想必已经知道了分支master的存在方式无非就是在目录.git/refs/heads下的文件(或称引用)而已。也看到了分支master的指向如何随着提交而变化,如何通过git reset命令而重置,以及如何使用git checkout命令而检出。
之前的章节都只用到了一个分支:master分支,而在本章会接触到多个分支。会从应用的角度上介绍分支的几种不同类型:发布分支、特性分支和卖主分支。在本章可以学习到如何对多分支进行操作,如何创建分支,如何切换到其他分支,以及分支之间的合并、变基等。
分支是代码管理的利器。如果没有有效的分支管理,代码管理就适应不了复杂的开发过程和项目的需要。在实际的项目实践中,单一分支的单线开发模式还远远不够,因为:
为什么bug没完没了?
在2006年我接触到一个项目团队,使用Subversion做版本控制。最为困扰项目经理的是刚刚修正产品的一个bug,马上又会接二连三地发现新的bug。在访谈开发人员,询问开发人员是如何修正bug的时候,开发人员的回答让我大吃一惊:“当发现产品出现bug的时候,我要中断当前的工作,把我正在开发的新功能的代码注释掉,然后再去修改bug,修改好就生成一个war包(Java开发网站项目)给运维部门,扔到网站上去。”
于是我就画了下面的一个图(图18-1),大致描述了这个团队进行bug修正的过程,从中可以很容易地看出问题的端倪。这个图对于Git甚至其他版本库控制系统同样适用。
说明:
使用版本控制系统的分支功能,可以避免对已发布的软件版本进行bug修正时引入新功能的代码,或者因误删其他bug修正代码导致已修复问题重现。在这种情况下创建的分支有一个专有的名称:bugfix分支或发布分支(Release Branch)。之所以称为发布分支,是因为在软件新版本发布后经常使用此技术进行软件维护,发布升级版本。
图18-2演示了如何使用发布分支应对bug修正的问题。
说明:
关于如何基于一个历史提交创建分支,以及如何在分支之间进行合并,在本章后面的内容中会详细介绍。
为什么项目一再的拖延?
有这么一个软件项目,项目已经延期了可是还是看不到一点要完成的样子。最终老板变得有些不耐烦了,说道:“那么就砍掉一些功能吧”。项目经理听闻,一阵眩晕,因为项目经理知道自己负责的这个项目采用的是单一主线开发,要将一个功能从中撤销,工作量非常大,而且可能会牵涉到其他相关模块的变更。
图18-3就是这个项目的版本库示意图,显然这个项目的代码管理没有使用分支。
说明:
那么负责开发功能2的开发者干什么呢?或者放一个长假,或者在本地开发,与版本库隔离,即不向版本库提交,直到延期的项目终于发布之后再将代码提交。这两种方法都是不可取的,尤其是后一种隔离开发最危险,如果因为病毒感染、文件误删、磁盘损坏,就会导致全部工作损失殆尽。我的项目组就曾经遇到过这样的情况。
采用分支将某个功能或模块的开发与开发主线独立出来,是解决类似问题的办法,这种用途的分支被称为特性分支(Feature Branch)或主题分支(Topic Branch)。图18-4就展示了如何使用特性分支帮助纠正要延期的项目,协同多用户的开发。
说明:
那么在什么情况下使用特性分支呢?试验性、探索性的功能开发应该为其建立特性分支。功能复杂、开发周期长(有可能在本次发布中取消)的模块应该为其建立特性分支。会更改软件体系架构,破坏软件集成,或者容易导致冲突、影响他人开发进度的模块,应该为其建立特性分支。
在使用CVS或Subversion等版本控制系统建立分支时,或者因为太慢(CVS)或者因为授权原因需要找管理员进行操作,非常的不方便。Git的分支管理就方便多了,一是开发者可以在本地版本库中随心所欲地创建分支,二是管理员可以对共享版本库进行设置允许开发者创建特定名称的分支,这样开发者的本地分支可以推送到服务器实现数据的备份。关于Git服务器的分支授权参照本书第5篇的Gitolite服务器架设的相关章节。
有的项目要引用到第三方的代码模块并且需要对其进行定制,有的项目甚至整个就是基于某个开源项目进行的定制。如何有效地管理本地定制和第三方(上游)代码的变更就成为了一个难题。卖主分支(Vendor Branch)可以部分解决这个难题。
所谓卖主分支,就是在版本库中创建一个专门和上游代码进行同步的分支,一旦有上游代码发布就检入到卖主分支中。图18-5就是一个典型的卖主分支工作流程。
说明:
如果定制较少,使用卖主分支可以工作得很好,但是如果定制的内容非常多,在合并的时候就会遇到非常多的冲突。定制的代码越多,混杂的越厉害,冲突解决就越困难。
本章的内容尚不能针对复杂的定制开发给出满意的版本控制解决方案,本书第4篇的“第22章 Topgit协同模型”会介绍一个针对复杂定制开发的更好的解决方案。
在Git中分支管理使用命令git branch。该命令的主要用法如下:
用法1: git branch
用法2: git branch <branchname>
用法3: git branch <branchname> <start-point>
用法4: git branch -d <branchname>
用法5: git branch -D <branchname>
用法6: git branch -m <oldbranch> <newbranch>
用法7: git branch -M <oldbranch> <newbranch>
说明:
用法1用于显示本地分支列表。当前分支在输出中会显示为特别的颜色,并用星号 “*” 标识出来。
用法2和用法3用于创建分支。
用法2基于当前头指针(HEAD)指向的提交创建分支,新分支的分支名为<branchname>。
用法3基于提交<start-point>创建新分支,新分支的分支名为<branchname>。
用法4和用法5用于删除分支。
用法4在删除分支<branchname>时会检查所要删除的分支是否已经合并到其他分支中,否则拒绝删除。
用法5会强制删除分支<branchname>,即使该分支没有合并到任何一个分支中。
用法6和用法7用于重命名分支。
如果版本库中已经存在名为<newbranch>的分支,用法6拒绝执行重命名,而用法7会强制执行。
下面就通过hello-world项目演示Git的分支管理。
上一章从Github上检出的hello-world包含了一个C语言开发的应用,现在假设项目hello-world做产品发布,版本号定为1.0,则进行下面的里程碑操作。
为hello-world创建里程碑v1.0。
$ cd /path/to/user1/workspace/hello-world/
$ git tag -m "Release 1.0" v1.0
将新建的里程碑推送到远程共享版本库。
$ git push origin refs/tags/v1.0
Counting objects: 1, done.
Writing objects: 100% (1/1), 158 bytes, done.
Total 1 (delta 0), reused 0 (delta 0)
Unpacking objects: 100% (1/1), done.
To file:///path/to/repos/hello-world.git
* [new tag] v1.0 -> v1.0
到现在为止还没有运行hello-world程序呢,现在就在开发者user1的工作区中运行一下。
进入src目录,编译程序。
$ cd src
$ make
version.h.in => version.h
cc -c -o main.o main.c
cc -o hello main.o
使用参数--help运行hello程序,可以查看帮助信息。
说明:hello程序的帮助输出中有一个拼写错误,本应该是--help的地方写成了-help。这是有意为之。
$ ./hello --help
Hello world example v1.0
Copyright Jiang Xin <jiangxin AT ossxp DOT com>, 2009.
Usage:
hello
say hello to the world.
hello <username>
say hi to the user.
hello -h, -help
this help screen.
不带参数运行,向全世界问候。
说明:最后一行显示版本为“v1.0”,这显然是来自于新建立的里程碑“v1.0”。
$ ./hello
Hello world.
(version: v1.0)
执行命令的时候,后面添加用户名作为参数,则向该用户问候。
说明:下面在运行hello的时候,显然出现了一个bug,即用户名中间如果出现了空格,输出的欢迎信息只包含了部分的用户名。这个bug也是有意为之。
$ ./hello Jiang Xin
Hi, Jiang.
(version: v1.0)
新版本开发计划
既然1.0版本已经发布了,现在是时候制订下一个版本2.0的开发计划了。计划如下:
多语种支持。
为hello-world添加多语种支持,使得软件运行的时候能够使用中文或其他本地化语言进行问候。
用getopt进行命令行解析。
对命令行参数解析框架进行改造,以便实现更灵活、更易扩展的命令行处理。在1.0版本中,程序内部解析命令行参数使用了简单的字符串比较,非常不灵活。从源文件src/main.c中可以看到当前实现的简陋和局限。
$ git grep -n argv
main.c:20:main(int argc, char **argv)
main.c:24: } else if ( strcmp(argv[1],"-h") == 0 ||
main.c:25: strcmp(argv[1],"--help") == 0 ) {
main.c:28: printf ("Hi, %s.\n", argv[1]);
最终决定由开发者user2负责多语种支持的功能,由开发者user1负责用getopt进行命令行解析的功能。
有了前面“代码管理之殇”的铺垫,在领受任务之后,开发者user1和user2应该为自己负责的功能创建特性分支。
开发者user1负责用getopt进行命令行解析的功能,因为这个功能用到getopt函数,于是将这个分支命名为user1/getopt。开发者 user1 使用git branch命令创建该特性分支。
确保是在开发者user1的工作区中。
$ cd /path/to/user1/workspace/hello-world/
开发者user1基于当前HEAD创建分支user1/getopt。
$ git branch user1/getopt
使用git branch创建分支,并不会自动切换。查看当前分支可以看到仍然工作在master分支(用星号 “*” 标识)中。
$ git branch
* master
user1/getopt
执行git checkout命令切换到新分支上。
$ git checkout user1/getopt
Switched to branch 'user1/getopt'
再次查看分支列表,当前工作分支的标记符(星号)已经落在user1/getopt分支上。
$ git branch
master
* user1/getopt
分支的奥秘
分支实际上是创建在目录.git/refs/heads下的引用,版本库初始时创建的master分支就是在该目录下。在第2篇“Git重置”的章节中,已经介绍过master分支的实现,实际上这也是所有分支的实现方式。
查看一下目录.git/refs/heads目录下的引用。
可以在该目录下看到master文件,和一个user1目录。而在user1目录下是文件getopt。
$ ls -F .git/refs/heads/
master user1/
$ ls -F .git/refs/heads/user1/
getopt
引用文件.git/refs/heads/user1/getopt记录的是一个提交ID。
$ cat .git/refs/heads/user1/getopt
ebcf6d6b06545331df156687ca2940800a3c599d
因为分支user1/getopt是基于头指针HEAD创建的,因此当前该分支和master分支指向是一致的。
$ cat .git/refs/heads/master
ebcf6d6b06545331df156687ca2940800a3c599d
当前的工作分支为user1/getopt,记录在头指针文件.git/HEAD中。
切换分支命令git checkout对文件.git/HEAD的内容进行更新。可以参照第2篇“第8章 Git检出”的相关章节。
$ cat .git/HEAD
ref: refs/heads/user1/getopt
开发者user2要完成多语种支持的工作任务,于是决定将分支定名为user2/i18n。每一次创建分支通常都需要完成以下两个工作:
有没有简单的操作,在创建分支后立即切换到新分支上呢?有的,Git提供了这样一个命令,能够将上述两条命令所执行的操作一次性完成。用法如下:
用法: git checkout -b <new_branch> [<start_point>]
即检出命令git checkout通过参数-b <new_branch>实现了创建分支和切换分支两个动作的合二为一。下面开发者user2就使用git checkout命令来创建分支。
进入到开发者user2的工作目录,并和上游同步一次。
$ cd /path/to/user2/workspace/hello-world/
$ git pull
remote: Counting objects: 1, done.
remote: Total 1 (delta 0), reused 0 (delta 0)
Unpacking objects: 100% (1/1), done.
From file:///path/to/repos/hello-world
* [new tag] v1.0 -> v1.0
Already up-to-date.
执行git checkout -b命令,创建并切换到新分支user2/i18n上。
$ git checkout -b user2/i18n
Switched to a new branch 'user2/i18n'
查看本地分支列表,会看到已经切换到user2/i18n分支上了。
$ git branch
master
* user2/i18n
开发者user1开始在user1/getopt分支中工作,重构hello-world中的命令行参数解析的代码。重构时采用getopt_long函数。
您可以试着更改,不过在hello-world中已经保存了一份改好的代码,可以直接检出。
确保是在user1的工作区中。
$ cd /path/to/user1/workspace/hello-world/
执行下面的命令,用里程碑jx/v2.0标记的内容(已实现用getopt进行命令行解析的功能)替换暂存区和工作区。
下面的git checkout命令的最后是一个点“.”,因此检出只更改了暂存区和工作区,而没有修改头指针。
$ cd /path/to/user1/workspace/hello-world/
$ git checkout jx/v2.0 -- .
查看状态,会看到分支仍保持为user1/getopt,但文件src/main.c被修改了。
$ git status
# On branch user1/getopt
# Changes to be committed:
# (use "git reset HEAD <file>..." to unstage)
#
# modified: src/main.c
#
比较暂存区和HEAD的文件差异,可以看到为实现用getopt进行命令行解析功能而对代码的改动。
$ git diff --cached
diff --git a/src/main.c b/src/main.c
index 6ee936f..fa5244a 100644
--- a/src/main.c
+++ b/src/main.c
@@ -1,4 +1,6 @@
#include <stdio.h>
+#include <getopt.h>
+
#include "version.h"
int usage(int code)
@@ -19,15 +21,44 @@ int usage(int code)
int
main(int argc, char **argv)
{
- if (argc == 1) {
+ int c;
+ char *uname = NULL;
+
+ while (1) {
+ int option_index = 0;
+ static struct option long_options[] = {
+ {"help", 0, 0, 'h'},
+ {0, 0, 0, 0}
+ };
...
开发者user1提交代码,完成开发任务。
$ git commit -m "Refactor: use getopt_long for arguments parsing."
[user1/getopt 0881ca3] Refactor: use getopt_long for arguments parsing.
1 files changed, 36 insertions(+), 5 deletions(-)
提交完成之后,可以看到这时user1/getopt分支和master分支的指向不同了。
$ git rev-parse user1/getopt master
0881ca3f62ddadcddec08bd9f2f529a44d17cfbf
ebcf6d6b06545331df156687ca2940800a3c599d
编译运行hello-world。
注意输出中的版本号显示。
$ cd src
$ make clean
rm -f hello main.o version.h
$ make
version.h.in => version.h
cc -c -o main.o main.c
cc -o hello main.o
$ ./hello
Hello world.
(version: v1.0-1-g0881ca3)
既然开发者user1负责的功能开发完成了,那就合并到开发主线master上吧,这样测试团队(如果有的话)就可以基于开发主线master进行软件集成和测试了。
为将分支合并到主线,首先user1将工作区切换到主线,即master分支。
$ git checkout master
Switched to branch 'master'
然后执行git merge命令以合并user1/getopt分支。
$ git merge user1/getopt
Updating ebcf6d6..0881ca3
Fast-forward
src/main.c | 41 ++++++++++++++++++++++++++++++++++++-----
1 files changed, 36 insertions(+), 5 deletions(-)
本次合并非常的顺利,实际上合并后master分支和user1/getopt指向同一个提交。
这是因为合并前的master分支的提交就是usr1/getopt分支的父提交,所以此次合并相当于分支master重置到user1/getopt分支。
$ git rev-parse user1/getopt master
0881ca3f62ddadcddec08bd9f2f529a44d17cfbf
0881ca3f62ddadcddec08bd9f2f529a44d17cfbf
当前本地master分支比远程共享版本库的master分支领先一个提交。
可以从状态信息中看到本地分支和远程分支的跟踪关系。
$ git status
# On branch master
# Your branch is ahead of 'origin/master' by 1 commit.
#
nothing to commit (working directory clean)
执行推送操作,完成本地分支向远程分支的同步。
$ git push
Counting objects: 7, done.
Delta compression using up to 2 threads.
Compressing objects: 100% (4/4), done.
Writing objects: 100% (4/4), 689 bytes, done.
Total 4 (delta 3), reused 0 (delta 0)
Unpacking objects: 100% (4/4), done.
To file:///path/to/repos/hello-world.git
ebcf6d6..0881ca3 master -> master
删除user1/getopt分支。
既然特性分支user1/getopt已经合并到主线上了,那么该分支已经完成了历史使命,可以放心地将其删除。
$ git branch -d user1/getopt
Deleted branch user1/getopt (was 0881ca3).
开发者user2对多语种支持功能有些犯愁,需要多花些时间,那么就先不等他了。
用户在使用1.0版的hello-word过程中发现了两个错误,报告给项目组。
第一个问题是:帮助信息中出现文字错误。本应该写为“–help”却写成了“-help”。
第二个问题是:当执行hello-world的程序,提供带空格的用户名时,问候语中显示的是不完整的用户名。
例如执行./hello Jiang Xin,本应该输出“Hi, Jiang Xin.”,却只输出了“Hi, Jiang.”。
为了能够及时修正1.0版本中存在的这两个bug,将这两个bug的修正工作分别交给两个开发者user1和user2完成。
现在版本库中master分支相比1.0发布时添加了新功能代码,即开发者user1推送的用getopt进行命令行解析相关代码。如果基于master分支对用户报告的两个bug进行修改,就会引入尚未经过测试、可能不稳定的新功能的代码。在之前“代码管理之殇”中介绍的发布分支,恰恰适用于此场景。
要想解决在1.0版本中发现的bug,就需要基于1.0发行版的代码创建发布分支。
软件hello-world的1.0发布版在版本库中有一个里程碑相对应。
$ cd /path/to/user1/workspace/hello-world/
$ git tag -n1 -l v*
v1.0 Release 1.0
基于里程碑v1.0创建发布分支hello-1.x。
注:使用了git checkout命令创建分支,最后一个参数v1.0是新分支hello-1.x创建的基准点。如果没有里程碑,使用提交ID也是一样。
$ git checkout -b hello-1.x v1.0
Switched to a new branch 'hello-1.x'
用git rev-parse命令可以看到hello-1.x分支对应的提交ID和里程碑v1.0指向的提交一致,但是和master不一样。
提示:因为里程碑v1.0是一个包含提交说明的里程碑,因此为了显示其对应的提交ID,使用了特别的记法“v1.0^{}”。
$ git rev-parse hello-1.x v1.0^{} master
ebcf6d6b06545331df156687ca2940800a3c599d
ebcf6d6b06545331df156687ca2940800a3c599d
0881ca3f62ddadcddec08bd9f2f529a44d17cfbf
开发者user1将分支hello-1.x推送到远程共享版本库,因为开发者user2修改bug时也要用到该分支。
$ git push origin hello-1.x
Total 0 (delta 0), reused 0 (delta 0)
To file:///path/to/repos/hello-world.git
* [new branch] hello-1.x -> hello-1.x
开发者user2从远程共享版本库获取新的分支。
开发者user2执行git fetch命令,将远程共享版本库的新分支hello-1.x复制到本地引用origin/hello-1.x上。
$ cd /path/to/user2/workspace/hello-world/
$ git fetch
From file:///path/to/repos/hello-world
* [new branch] hello-1.x -> origin/hello-1.x
开发者user2切换到hello-1.x分支。
本地引用origin/hello-1.x称为远程分支,第19章将专题介绍。该远程分支不能直接检出,而是需要基于该远程分支创建本地分支。第19章会介绍一个更为简单的基于远程分支建立本地分支的方法,本例先用标准的方法建立分支。
$ git checkout -b hello-1.x origin/hello-1.x
Branch hello-1.x set up to track remote branch hello-1.x from origin.
Switched to a new branch 'hello-1.x'
开发者user1修改帮助信息中的文字错误。
编辑文件src/main.c,将“-help”字符串修改为“–help”。
$ cd /path/to/user1/workspace/hello-world/
$ vi src/main.c
...
开发者user1的改动可以从下面的差异比较中看到。
$ git diff
diff --git a/src/main.c b/src/main.c
index 6ee936f..e76f05e 100644
--- a/src/main.c
+++ b/src/main.c
@@ -11,7 +11,7 @@ int usage(int code)
" say hello to the world.\n\n"
" hello <username>\n"
" say hi to the user.\n\n"
- " hello -h, -help\n"
+ " hello -h, --help\n"
" this help screen.\n\n", _VERSION);
return code;
}
执行提交。
$ git add -u
$ git commit -m "Fix typo: -help to --help."
[hello-1.x b56bb51] Fix typo: -help to --help.
1 files changed, 1 insertions(+), 1 deletions(-)
推送到远程共享版本库。
$ git push
Counting objects: 7, done.
Delta compression using up to 2 threads.
Compressing objects: 100% (4/4), done.
Writing objects: 100% (4/4), 349 bytes, done.
Total 4 (delta 3), reused 0 (delta 0)
Unpacking objects: 100% (4/4), done.
To file:///path/to/repos/hello-world.git
ebcf6d6..b56bb51 hello-1.x -> hello-1.x
开发者user2针对问候时用户名显示不全的bug进行更改。
进入开发者user2的工作区,并确保工作在hello-1.x分支中。
$ cd /path/to/user2/workspace/hello-world/
$ git checkout hello-1.x
编辑文件src/main.c,修改代码中的bug。
$ vi src/main.c
实际上在hello-world版本库中包含了我的一份修改,可以看看和您的更改是否一致。
下面的命令将我对此bug的修改保存为一个补丁文件。
$ git format-patch jx/v1.1..jx/v1.2
0001-Bugfix-allow-spaces-in-username.patch
应用我对此bug的改动补丁。
如果您已经自己完成了修改,可以先执行git stash保存自己的修改进度,然后执行下面的命令应用补丁文件。当应用完补丁后,再执行git stash pop将您的改动合并到工作区。如果我们的改动一致(英雄所见略同),将不会有冲突。
$ patch -p1 < 0001-Bugfix-allow-spaces-in-username.patch
patching file src/main.c
看看代码的改动吧。
$ git diff
diff --git a/src/main.c b/src/main.c
index 6ee936f..f0f404b 100644
--- a/src/main.c
+++ b/src/main.c
@@ -19,13 +19,20 @@ int usage(int code)
int
main(int argc, char **argv)
{
+ char **p = NULL;
+
if (argc == 1) {
printf ("Hello world.\n");
} else if ( strcmp(argv[1],"-h") == 0 ||
strcmp(argv[1],"--help") == 0 ) {
return usage(0);
} else {
- printf ("Hi, %s.\n", argv[1]);
+ p = &argv[1];
+ printf ("Hi,");
+ do {
+ printf (" %s", *p);
+ } while (*(++p));
+ printf (".\n");
}
printf( "(version: %s)\n", _VERSION );
本地测试一下改进后的软件,看看bug是否已经被改正。如果运行结果能显示出完整的用户名,则bug成功修正。
$ cd src/
$ make
version.h.in => version.h
cc -c -o main.o main.c
cc -o hello main.o
$ ./hello Jiang Xin
Hi, Jiang Xin.
(version: v1.0-dirty)
提交代码。
$ git add -u
$ git commit -m "Bugfix: allow spaces in username."
[hello-1.x e64f3a2] Bugfix: allow spaces in username.
1 files changed, 8 insertions(+), 1 deletions(-)
开发者user2在本地版本库完成提交后,不要忘记向远程共享版本库进行推送。但在推送分支hello-1.x时开发者user2没有开发者user1那么幸运,因为此时远程共享版本库的hello-1.x分支已经被开发者user1推送过一次,因此开发者user2在推送过程中会遇到非快进式推送问题。
$ git push
To file:///path/to/repos/hello-world.git
! [rejected] hello-1.x -> hello-1.x (non-fast-forward)
error: failed to push some refs to 'file:///path/to/repos/hello-world.git'
To prevent you from losing history, non-fast-forward updates were rejected
Merge the remote changes (e.g. 'git pull') before pushing again. See the
'Note about fast-forwards' section of 'git push --help' for details.
就像在“第15章 Git协议和工作协同”一章中介绍的那样,开发者user2需要执行一个拉回操作,将远程共享服务器的改动获取到本地并和本地提交进行合并。
$ git pull
remote: Counting objects: 7, done.
remote: Compressing objects: 100% (4/4), done.
remote: Total 4 (delta 3), reused 0 (delta 0)
Unpacking objects: 100% (4/4), done.
From file:///path/to/repos/hello-world
ebcf6d6..b56bb51 hello-1.x -> origin/hello-1.x
Auto-merging src/main.c
Merge made by recursive.
src/main.c | 2 +-
1 files changed, 1 insertions(+), 1 deletions(-)
通过显示分支图的方式查看日志,可以看到在执行git pull操作后发生了合并。
$ git log --graph --oneline
* 8cffe5f Merge branch 'hello-1.x' of file:///path/to/repos/hello-world into hello-1.x
|\
| * b56bb51 Fix typo: -help to --help.
* | e64f3a2 Bugfix: allow spaces in username.
|/
* ebcf6d6 blank commit for GnuPG-signed tag test.
* 8a9f3d1 blank commit for annotated tag test.
* 60a2f4f blank commit.
* 3e6070e Show version.
* 75346b3 Hello world initialized.
现在开发者user2可以将合并后的本地版本库中的提交推送给远程共享版本库了。
$ git push
Counting objects: 14, done.
Delta compression using up to 2 threads.
Compressing objects: 100% (8/8), done.
Writing objects: 100% (8/8), 814 bytes, done.
Total 8 (delta 6), reused 0 (delta 0)
Unpacking objects: 100% (8/8), done.
To file:///path/to/repos/hello-world.git
b56bb51..8cffe5f hello-1.x -> hello-1.x
当开发者user1和user2都相继在hello-1.x分支将相应的bug修改完后,就可以从hello-1.x分支中编译新的软件产品交给客户使用了。接下来别忘了在主线master分支也做出同样的更改,因为在hello-1.x分支修改的bug同样也存在于主线master分支中。
使用Git提供的拣选命令,就可以直接将发布分支上进行的bug修正合并到主线上。下面就以开发者user2的身份进行操作。
进入user2工作区并切换到master分支。
$ cd /path/to/user2/workspace/hello-world/
$ git checkout master
从远程共享版本库同步master分支。
同步后本地master分支包含了开发者user1提交的命令行参数解析重构的代码。
$ git pull
remote: Counting objects: 7, done.
remote: Compressing objects: 100% (4/4), done.
remote: Total 4 (delta 3), reused 0 (delta 0)
Unpacking objects: 100% (4/4), done.
From file:///path/to/repos/hello-world
ebcf6d6..0881ca3 master -> origin/master
Updating ebcf6d6..0881ca3
Fast-forward
src/main.c | 41 ++++++++++++++++++++++++++++++++++++-----
1 files changed, 36 insertions(+), 5 deletions(-)
查看分支hello-1.x的日志,确认要拣选的提交ID。
从下面的日志可以看出分支hello-1.x的最新提交是一个合并提交,而要拣选的提交分别是其第一个父提交和第二个父提交,可以分别用hello-1.x^1和hello-1.x^2表示。
$ git log -3 --graph --oneline hello-1.x
* 8cffe5f Merge branch 'hello-1.x' of file:///path/to/repos/hello-world into hello-1.x
|\
| * b56bb51 Fix typo: -help to --help.
* | e64f3a2 Bugfix: allow spaces in username.
|/
执行拣选操作。先将开发者user2提交的修正代码拣选到当前分支(即主线)。
拣选操作遇到了冲突,见下面的命令输出。
$ git cherry-pick hello-1.x^1
Automatic cherry-pick failed. After resolving the conflicts,
mark the corrected paths with 'git add <paths>' or 'git rm <paths>'
and commit the result with:
git commit -c e64f3a216d346669b85807ffcfb23a21f9c5c187
拣选操作发生冲突,通过查看状态可以看到是在文件src/main.c上发生了冲突。
$ git status
# On branch master
# Unmerged paths:
# (use "git reset HEAD <file>..." to unstage)
# (use "git add/rm <file>..." as appropriate to mark resolution)
#
# both modified: src/main.c
#
no changes added to commit (use "git add" and/or "git commit -a")
冲突发生的原因
为什么发生了冲突呢?这是因为拣选hello-1.x分支上的一个提交到master分支时,因为两个甚至多个提交在重叠的位置更改代码所致。通过下面的命令可以看到到底是哪些提交引起的冲突。
$ git log master...hello-1.x^1
commit e64f3a216d346669b85807ffcfb23a21f9c5c187
Author: user2 <user2@moon.ossxp.com>
Date: Sun Jan 9 13:11:19 2011 +0800
Bugfix: allow spaces in username.
commit 0881ca3f62ddadcddec08bd9f2f529a44d17cfbf
Author: user1 <user1@sun.ossxp.com>
Date: Mon Jan 3 22:44:52 2011 +0800
Refactor: use getopt_long for arguments parsing.
可以看出引发冲突的提交一个是当前工作分支master上的最新提交,即开发者user1的重构命令行参数解析的提交,而另外一个引发冲突的是要拣选的提交,即开发者user2针对用户名显示不全所做的错误修正提交。一定是因为这两个提交的更改发生了重叠导致了冲突的发生。下面就来解决冲突。
冲突解决
冲突解决可以使用图形界面工具,不过对于本例直接编辑冲突文件,手工进行冲突解决也很方便。打开文件src/main.c就可以看到发生冲突的区域都用特有的标记符标识出来,参见表18-1中左侧一列中的内容。
表 18-1:冲突解决前后对照
冲突文件 src/main.c 标识出的冲突内容 | 冲突解决后的内容对照 |
---|---|
21 int
22 main(int argc, char **argv)
23 {
24 <<<<<<< HEAD
25 int c;
26 char *uname = NULL;
27
28 while (1) {
29 int option_index = 0;
30 static struct option long_options[] = {
31 {"help", 0, 0, 'h'},
32 {0, 0, 0, 0}
33 };
34
35 c = getopt_long(argc, argv, "h",
36 long_options, &option_index);
37 if (c == -1)
38 break;
39
40 switch (c) {
41 case 'h':
42 return usage(0);
43 default:
44 return usage(1);
45 }
46 }
47
48 if (optind < argc) {
49 uname = argv[optind];
50 }
51
52 if (uname == NULL) {
53 =======
54 char **p = NULL;
55
56 if (argc == 1) {
57 >>>>>>> e64f3a2... Bugfix: allow spaces in username.
58 printf ("Hello world.\n");
59 } else {
60 <<<<<<< HEAD
61 printf ("Hi, %s.\n", uname);
62 =======
63 p = &argv[1];
64 printf ("Hi,");
65 do {
66 printf (" %s", *p);
67 } while (*(++p));
68 printf (".\n");
69 >>>>>>> e64f3a2... Bugfix: allow spaces in username.
70 }
71
72 printf( "(version: %s)\n", _VERSION );
73 return 0;
74 }
|
21 int
22 main(int argc, char **argv)
23 {
24 int c;
25 char **p = NULL;
26
27 while (1) {
28 int option_index = 0;
29 static struct option long_options[] = {
30 {"help", 0, 0, 'h'},
31 {0, 0, 0, 0}
32 };
33
34 c = getopt_long(argc, argv, "h",
35 long_options, &option_index);
36 if (c == -1)
37 break;
38
39 switch (c) {
40 case 'h':
41 return usage(0);
42 default:
43 return usage(1);
44 }
45 }
46
47 if (optind < argc) {
48 p = &argv[optind];
49 }
50
51 if (p == NULL || *p == NULL) {
52 printf ("Hello world.\n");
53 } else {
54 printf ("Hi,");
55 do {
56 printf (" %s", *p);
57 } while (*(++p));
58 printf (".\n");
59 }
60
61 printf( "(version: %s)\n", _VERSION );
62 return 0;
63 }
|
在文件src/main.c冲突内容中,第25-52行及第61行是master分支中由开发者user1重构命令行解析时提交的内容,而第54-56行及第63-68行则是分支hello-1.x中由开发者user2提交的修正用户名显示不全的bug的相应代码。
表18-1右侧的一列则是冲突解决后的内容。为了和冲突前的内容相对照,重新进行了排版,并对差异内容进行加粗显示。您可以参照完成冲突解决。
将手动编辑完成的文件src/main.c添加到暂存区才真正地完成了冲突解决。
$ git add src/main.c
因为是拣选操作,提交时最好重用所拣选提交的提交说明和作者信息,而且也省下了自己写提交说明的麻烦。使用下面的命令完成提交操作。
$ git commit -C hello-1.x^1
[master 10765a7] Bugfix: allow spaces in username.
1 files changed, 8 insertions(+), 4 deletions(-)
接下来再将开发者 user1 在分支hello-1.x中的提交也拣选到当前分支。所拣选的提交非常简单,不过是修改了提交说明中的文字错误而已,拣选操作也不会引发异常,直接完成。
$ git cherry-pick hello-1.x^2
Finished one cherry-pick.
[master d81896e] Fix typo: -help to --help.
Author: user1 <user1@sun.ossxp.com>
1 files changed, 1 insertions(+), 1 deletions(-)
现在通过日志可以看到master分支已经完成了对已知bug的修复。
$ git log -3 --graph --oneline
* d81896e Fix typo: -help to --help.
* 10765a7 Bugfix: allow spaces in username.
* 0881ca3 Refactor: use getopt_long for arguments parsing.
查看状态可以看到当前的工作分支相对于远程服务器有两个新提交。
$ git status
# On branch master
# Your branch is ahead of 'origin/master' by 2 commits.
#
nothing to commit (working directory clean)
执行推送命令将本地master分支同步到远程共享版本库。
$ git push
Counting objects: 11, done.
Delta compression using up to 2 threads.
Compressing objects: 100% (8/8), done.
Writing objects: 100% (8/8), 802 bytes, done.
Total 8 (delta 6), reused 0 (delta 0)
Unpacking objects: 100% (8/8), done.
To file:///path/to/repos/hello-world.git
0881ca3..d81896e master -> master
开发者user2针对多语种开发的工作任务还没有介绍呢,在最后就借着“实现”这个稍微复杂的功能来学习一下Git分支的变基操作。
进入user2的工作区,并切换到user2/i18n分支。
$ cd /path/to/user2/workspace/hello-world/
$ git checkout user2/i18n
Switched to branch 'user2/i18n'
使用gettext为软件添加多语言支持。您可以尝试实现该功能。不过在hello-world中已经保存了一份实现该功能的代码(见里程碑jx/v1.0-i18n),可以直接拿过来用。
里程碑jx/v1.0-i18n最后的两个提交实现了多语言支持功能。
$ git log --oneline -2 --stat jx/v1.0-i18n
ade873c Translate for Chinese.
src/locale/zh_CN/LC_MESSAGES/helloworld.po | 30 +++++++++++++++++++++------
1 files changed, 23 insertions(+), 7 deletions(-)
0831248 Add I18N support.
src/Makefile | 21 +++++++++++-
src/locale/helloworld.pot | 46 ++++++++++++++++++++++++++++
src/locale/zh_CN/LC_MESSAGES/helloworld.po | 46 ++++++++++++++++++++++++++++
src/main.c | 18 ++++++++--
4 files changed, 125 insertions(+), 6 deletions(-)
可以通过拣选命令将这两个提交拣选到user2/i18n分支中,相当于在分支user2/i18n中实现了多语言支持的开发。
$ git cherry-pick jx/v1.0-i18n~1
...
$ git cherry-pick jx/v1.0-i18n
...
看看当前分拣选后的日志。
$ git log --oneline -2
7acb3e8 Translate for Chinese.
90d873b Add I18N support.
为了测试刚刚“开发”完成的多语言支持功能,先对源码执行编译。
$ cd src
$ make
version.h.in => version.h
cc -c -o main.o main.c
msgfmt -o locale/zh_CN/LC_MESSAGES/helloworld.mo locale/zh_CN/LC_MESSAGES/helloworld.po
cc -o hello main.o
查看帮助信息,会发现帮助信息已经本地化。
注意:帮助信息中仍然有文字错误,--help误写为-help。
$ ./hello --help
Hello world 示例 v1.0-2-g7acb3e8
版权所有 蒋鑫 <jiangxin AT ossxp DOT com>, 2009
用法:
hello
世界你好。
hello <username>
向用户问您好。
hello -h, -help
显示本帮助页。
不带用户名运行hello,也会输出中文。
$ ./hello
世界你好。
(version: v1.0-2-g7acb3e8)
带用户名运行hello,会向用户问候。
注意:程序仍然存在只显示部分用户名的问题。
$ ./hello Jiang Xin
您好, Jiang.
(version: v1.0-2-g7acb3e8)
推送分支user2/i18n到远程共享服务器。
推送该特性分支的目的并非是与他人在此分支上协同工作,主要只是为了进行数据备份。
$ git push origin user2/i18n
Counting objects: 21, done.
Delta compression using up to 2 threads.
Compressing objects: 100% (13/13), done.
Writing objects: 100% (17/17), 2.91 KiB, done.
Total 17 (delta 6), reused 1 (delta 0)
Unpacking objects: 100% (17/17), done.
To file:///path/to/repos/hello-world.git
* [new branch] user2/i18n -> user2/i18n
在测试刚刚完成的具有多语种支持功能的hello-world时,之前改正的两个bug又重现了。这并不奇怪,因为分支user2/i18n基于master分支创建的时候,这两个bug还没有发现呢,更不要说改正了。
在最早刚刚创建user2/i18n分支时,版本库的结构非常简单,如图18-6所示。
但是当前master分支中不但包含了对两个bug的修正,还包含了开发者user1调用getopt对命令行参数解析进行的代码重构。图18-7显示的是当前版本库master分支和user2/i18n分支的关系图。
开发者user2要将分支user2/i18n中的提交合并到主线master中,可以采用上一节介绍的分支合并操作。如果执行分支合并操作,版本库的状态将会如图18-8所示:
这样操作有利有弊。有利的一面是开发者在user2/i18n分支中的提交不会发生改变,这一点对于提交已经被他人共享时很重要。再有因为user2/i18n分支是基于v1.0创建的,这样可以很容易将多语言支持功能添加到1.0版本的hello-world中。不过这些对于本项目来说都不重要。至于不利的一面,就是这样的合并操作会产生三个提交(包括一个合并提交),对于要对提交进行审核的项目团队来说增加了代码审核的负担。因此很多项目在特性分支合并到开发主线的时候,都不推荐使用合并操作,而是使用变基操作。如果执行变基操作,版本库相关分支的关系图如图18-9所示。
很显然,采用变基操作的分支关系图要比采用合并操作的简单多了,看起来更像是集中式版本控制系统特有的顺序提交。因为减少了一个提交,也会减轻代码审核的负担。
下面开发者user2就通过变基操作将特性分支user2/i18n合并到主线。
首先确保开发者user2的工作区位于分支user2/i18n上。
$ cd /path/to/user2/workspace/hello-world/
$ git checkout user2/i18n
执行变基操作。
$ git rebase master
First, rewinding head to replay your work on top of it...
Applying: Add I18N support.
Using index info to reconstruct a base tree...
Falling back to patching base and 3-way merge...
Auto-merging src/main.c
CONFLICT (content): Merge conflict in src/main.c
Failed to merge in the changes.
Patch failed at 0001 Add I18N support.
When you have resolved this problem run "git rebase --continue".
If you would prefer to skip this patch, instead run "git rebase --skip".
To restore the original branch and stop rebasing run "git rebase --abort".
变基遇到了冲突,看来这回的麻烦可不小。冲突是在合并user2/i18n分支中的提交“Add I18N support”时遇到的。首先回顾一下变基的原理,参见第2篇“第12章 改变历史”相关章节。对于本例,在进行变基操作时会先切换到user2/i18n分支,并强制重置到master分支所指向的提交。然后再将原user2/i18n分支的提交一一拣选到新的user2/i18n分支上。运行下面的命令可以查看可能导致冲突的提交列表。
$ git rev-list --pretty=oneline user2/i18n^...master
d81896e60673771ef1873b27a33f52df75f70515 Fix typo: -help to --help.
10765a7ef46981a73d578466669f6e17b73ac7e3 Bugfix: allow spaces in username.
90d873bb93cd7577b7638f1f391bd2ece3141b7a Add I18N support.
0881ca3f62ddadcddec08bd9f2f529a44d17cfbf Refactor: use getopt_long for arguments parsing
刚刚发生的冲突是在拣选提交“Add I18N suppport”时出现的,所以在冲突文件中标识为他人版本的是user2添加多语种支持功能的提交,而冲突文件中标识为自己版本的是修正两个bug的提交及开发者user1提交的重构命令行参数解析的提交。下面的两个表格(表18-2和表18-3)是文件src/main.c发生冲突的两个主要区域,表格的左侧一列是冲突文件中的内容,右侧一列则是冲突解决后的内容。为了方便参照进行了适当排版。
表 18-2:变基冲突区域一解决前后对照
变基冲突区域一内容(文件 src/main.c) | 冲突解决后的内容对照 |
---|---|
12 int usage(int code)
13 {
14 printf(_("Hello world example %s\n"
15 "Copyright Jiang Xin <jiangxin AT ossxp ...\n"
16 "\n"
17 "Usage:\n"
18 " hello\n"
19 " say hello to the world.\n\n"
20 " hello <username>\n"
21 " say hi to the user.\n\n"
22 <<<<<<< HEAD
23 " hello -h, --help\n"
24 " this help screen.\n\n", _VERSION);
25 ||||||| merged common ancestors
26 " hello -h, -help\n"
27 " this help screen.\n\n", _VERSION);
28 =======
29 " hello -h, -help\n"
30 " this help screen.\n\n"), _VERSION);
31 >>>>>>> Add I18N support.
32 return code;
33 }
|
12 int usage(int code)
13 {
14 printf(_("Hello world example %s\n"
15 "Copyright Jiang Xin <jiangxin AT ossxp ...\n"
16 "\n"
17 "Usage:\n"
18 " hello\n"
19 " say hello to the world.\n\n"
20 " hello <username>\n"
21 " say hi to the user.\n\n"
22 " hello -h, --help\n"
23 " this help screen.\n\n"), _VERSION);
24 return code;
25 }
|
表 18-3:变基冲突区域二解决前后对照
变基冲突区域二内容(文件 src/main.c) | 冲突解决后的内容对照 |
---|---|
38 <<<<<<< HEAD
39 int c;
40 char **p = NULL;
41
42 while (1) {
43 int option_index = 0;
44 static struct option long_options[] = {
45 {"help", 0, 0, 'h'},
46 {0, 0, 0, 0}
47 };
48
49 c = getopt_long(argc, argv, "h",
50 long_options, &option_index);
51 if (c == -1)
52 break;
53
54 switch (c) {
55 case 'h':
56 return usage(0);
57 default:
58 return usage(1);
59 }
60 }
61
62 if (optind < argc) {
63 p = &argv[optind];
64 }
65
66 if (p == NULL || *p == NULL) {
67 printf ("Hello world.\n");
68 ||||||| merged common ancestors
69 if (argc == 1) {
70 printf ("Hello world.\n");
71 } else if ( strcmp(argv[1],"-h") == 0 ||
72 strcmp(argv[1],"--help") == 0 ) {
73 return usage(0);
74 =======
75 setlocale( LC_ALL, "" );
76 bindtextdomain("helloworld","locale");
77 textdomain("helloworld");
78
79 if (argc == 1) {
80 printf ( _("Hello world.\n") );
81 } else if ( strcmp(argv[1],"-h") == 0 ||
82 strcmp(argv[1],"--help") == 0 ) {
83 return usage(0);
84 >>>>>>> Add I18N support.
85 } else {
86 <<<<<<< HEAD
87 printf ("Hi,");
88 do {
89 printf (" %s", *p);
90 } while (*(++p));
91 printf (".\n");
92 ||||||| merged common ancestors
93 printf ("Hi, %s.\n", argv[1]);
94 =======
95 printf (_("Hi, %s.\n"), argv[1]);
96 >>>>>>> Add I18N support.
97 }
|
30 int c;
31 char **p = NULL;
32
33 setlocale( LC_ALL, "" );
34 bindtextdomain("helloworld","locale");
35 textdomain("helloworld");
36
37 while (1) {
38 int option_index = 0;
39 static struct option long_options[] = {
40 {"help", 0, 0, 'h'},
41 {0, 0, 0, 0}
42 };
43
44 c = getopt_long(argc, argv, "h",
45 long_options, &option_index);
46 if (c == -1)
47 break;
48
49 switch (c) {
50 case 'h':
51 return usage(0);
52 default:
53 return usage(1);
54 }
55 }
56
57 if (optind < argc) {
58 p = &argv[optind];
59 }
60
61 if (p == NULL || *p == NULL) {
62 printf ( _("Hello world.\n") );
63 } else {
64 printf (_("Hi,"));
65 do {
66 printf (" %s", *p);
67 } while (*(++p));
68 printf (".\n");
69 }
|
将完成冲突解决的文件src/main.c加入暂存区。
$ git add -u
查看工作区状态。
$ git status
# Not currently on any branch.
# Changes to be committed:
# (use "git reset HEAD <file>..." to unstage)
#
# modified: src/Makefile
# new file: src/locale/helloworld.pot
# new file: src/locale/zh_CN/LC_MESSAGES/helloworld.po
# modified: src/main.c
#
现在不要执行提交,而是继续变基操作。变基操作会自动完成对冲突解决的提交,并对分支中的其他提交继续执行变基,直至全部完成。
$ git rebase --continue
Applying: Add I18N support.
Applying: Translate for Chinese.
图18-10显示了版本库执行完变基后的状态。
现在需要将user2/i18n分支的提交合并到主线master中。实际上不需要在master分支上再执行繁琐的合并操作,而是可以直接用推送操作——用本地的user2/i18n分支直接更新远程版本库的master分支。
$ git push origin user2/i18n:master
Counting objects: 21, done.
Delta compression using up to 2 threads.
Compressing objects: 100% (13/13), done.
Writing objects: 100% (17/17), 2.91 KiB, done.
Total 17 (delta 6), reused 1 (delta 0)
Unpacking objects: 100% (17/17), done.
To file:///path/to/repos/hello-world.git
仔细看看上面运行的git push命令,终于看到了引用表达式中引号前后使用了不同名字的引用。含义是用本地的user2/i18n引用的内容(提交ID)更新远程共享版本库的master引用内容(提交ID)。
执行拉回操作,可以发现远程共享版本库的master分支的确被更新了。通过拉回操作本地的master分支也随之更新。
切换到master分支,会从提示信息中看到本地master分支落后远程共享版本库master分支两个提交。
$ git checkout master
Switched to branch 'master'
Your branch is behind 'origin/master' by 2 commits, and can be fast-forwarded.
执行拉回操作,将本地master分支同步到和远程共享版本库相同的状态。
$ git pull
Updating d81896e..c4acab2
Fast-forward
src/Makefile | 21 ++++++++-
src/locale/helloworld.pot | 46 ++++++++++++++++++++
src/locale/zh_CN/LC_MESSAGES/helloworld.po | 62 ++++++++++++++++++++++++++++
src/main.c | 18 ++++++--
4 files changed, 141 insertions(+), 6 deletions(-)
create mode 100644 src/locale/helloworld.pot
create mode 100644 src/locale/zh_CN/LC_MESSAGES/helloworld.po
特性分支user2/i18n也完成了历史使命,可以删除了。因为之前user2/i18n已经推送到远程共享版本库,如果想要删除分支不要忘了也将远程分支同时删除。
删除本地版本库的user2/i18n分支。
$ git branch -d user2/i18n
Deleted branch user2/i18n (was c4acab2).
删除远程共享版本库的user2/i18n分支。
$ git push origin :user2/i18n
To file:///path/to/repos/hello-world.git
- [deleted] user2/i18n
补充:实际上变基之后user2/i18n分支的本地化模板文件(helloworld.pot)和汉化文件(helloworld.po)都需要做出相应更新,否则hello-world的一些输出不能进行本地化。
具体的操作过程就不再赘述了。