Matrixzk’s Blog

keep moving

一个完美的 Git 分支管理模型 (Git工作流)

Nov 4th, 2014

众所周知,Git 是目前最优秀的版本控制工具。但很多团队在使用Git进行协作开发的过程中,并没有形成一个清晰规范的流程。本文的作者 Vincent Driessen 向我们介绍了一个相对比较完美的分支管理策略,依照这个策略基本可以保证团队开发和版本发布有条不紊得进行。Vincent Driessen还据此实现了一个 Git 扩展集 git-flow ,它为本文所介绍的分支管理模型提供了一些较高层次的repository operations(库操作)。其本质是对下文所述的相关Git命令集进行进一步封装,并提供一些友好的命令行提示,具体可参考 git-flow 备忘清单。本文译自 A successful Git branching model ,推荐原汁原味的阅读。

本文我将给大家展示一个开发模式,一年前我就已经把它应用到了我所有的项目中(包括公司的和私人的),结果证明非常成功。很早我就想写篇文章介绍一下它,但直到现在才抽出空来。本文主要介绍分支创建策略(branching strategy)和发版管理(release management),不涉及相关项目的任何细节。

这里以Git作为源代码的版本控制工具。

为什么选Git (Why Git?)

关于Git集中式源代码控制系统之间优劣的深入探讨,可以看这里这里。那里充满了浓重的火药味。作为一个开发者,我认为Git是现有的同类工具中最好的,没有之一。Git很大得改变了开发者对于合并(merging)文件和创建分支(branching)的思考方式。在我所经历过的经典的CVS/Subversion(集中式版本控制系统)时代,merging/branching(开分支与合并分支)常常让人提心吊胆(“小心合并冲突,否则会很蛋疼!”)但有时又不得不做。

但是使用Git,这些操作就非常得轻量且简单了,并且被视为日常工作流程的核心部分之一,真的是这样。例如,在介绍CVS/Subversion中,第一次提及branching(分支)和merging(合并)通常是在比较靠后的章节(针对高级读者),然而几乎对于每本介绍Git的,这些内容往往在前三章(即基础部分)就被涵盖了。

正是由于Git的简单易用,创建分支与合并分支变得不那么令人恐怖了。版本控制工具更多得被认为了是对branching/merging(创建分支与合并分支)的一种辅助而非别的。

介绍了这些工具之后,下面我们来开始讲这个开发模式。我将要在这里为大家展示的这个模式,本质上说只是一套流程,团队的每个成员都应该遵守这套流程以确保完成一个可控的软件开发过程。

分散而集中 (Decentralized but centralized)

在我们为配合这个分支管理模型很好的工作而做的仓库配置中,有一个名义上的中央仓库(central repo)。注意,这个仓库仅仅是被视为中央仓库,由于Git是一个DVCS(分布式版本控制Distributive Version Control System),因此在技术层面上没有中央仓库这一概念。我们姑且把这个仓库(repo)称为origin,因为所有的Git用户都很熟悉这一称谓。

每个开发者都对origin执行pull(拉取代码)和push(推送代码)操作。但是在这种中央式的push-pull关系背后,每个开发者可能还会从其他同事那里pull一些修改(changes)而组成子团队。比如说,当有两个及以上开发者要合作完成一个较大的新功能,并且尚未将该新功能全部完成并推往origin仓库时,适用于这一场景。在上图中,就有Alice和Bob,Alice和David,以及Clair和David这样三个子团队。

从技术角度来讲,这里对于Alice来说所做的仅仅是定义(defined)了一个远程库(remote),命名为bob,并将它指向Bob的仓库,反之亦然。

主要分支 (The main branches)

本文所介绍的开发模式的核心部分很大得受到了现有模型的启发。在中央仓库中有两个生命期无限长的主要分支:

  • Master
  • Developer

originmaster分支已被所有的Git用户所熟知。平行于master分支存在着另一个被称为develop的分支。

我们默认主要分支origin/masterHEAD所指向的源码总是处于可发布(production-ready)的状态。

我们默认主要分支origin/developHEAD所指向的源码总是处于为下次发版所做的最近的一次修改提交的状态。也有人把它称作”集成分支(integration branch)”,每晚(nightly)所做的自动编译(automatic builds)通常来自该分支。也就是说这是一个日常开发所在的分支(译者注)。

develop分支上的代码达到了一个稳定的点并且准备进行发版时,在该分支上所做的所有修改都应该被合并(merged)回master分支,并且打一个带有发布版本号的标签(tag)。具体做法等下会进行详细讲解。

因此,每次把修改合并回master分支时,这都将被定义为一次新的产品发版。在这点上我们往往是非常严格的,由此在理论上,我们可以写一个Git钩子脚本(hook script),在每有一个针对master分支的commit(提交)时都进行自动编译并且生成一个可发布的软件产品。

辅助分支 (Supporting branches)

伴随着主要分支masterdevelop,我们的开发模式还用了一些辅助分支来协助团队成员间的平行开发,使对功能的追踪变得轻便(ease tracking of features),并且协助产品的发布,以及进行线上版本bug的快速修复。

我们所用到这些的不同类型的辅助分支包括:

  • Feature branches (功能分支)
  • Release branches (预发布分支)
  • Hotfix branches (热修复分支)

上述每种分支都有特定的用途,并且对于他们各自的起始分支和将要合并到的目标分支都有严格的规则限制。等下我们会详述。

从技术角度来讲,这些分支并没有本质区别,我们是根据使用场景去给它们归类的。它们本质上只是普通的Git分支而已。

功能分支 (Feature branches)

分支创建自:develop;必须合并回:develop

分支命名约定:除master, develop, release-*, 或hotfix-*以外的任何名字。

功能分支(有时亦称主题分支(topic branch))是用来给一个可预见的未来即将要发布的版本开发新功能的。当开始一个新功能的开发时,我们可能并不知道该新功能将被合并入哪个发布版本。事实上一个功能分支将伴随该功能的开发过程一直存在,并最终被合并回develop分支(已确定将要把该新功能加入到即将发布的新版本中)或者丢弃掉(假如是一个失败的尝试)。

功能分支通常只存在于开发者的本地仓库中,并不包含在远程库origin中。

创建功能分支

当要开发一个新功能时,从develop分支中切出(branch off)一个新分支:

$ git checkout -b myfeature develop
Switched to a new branch "myfeature"

将已完成的新功能合并到develop分支

如果确定要把已完成的新功能加入到即将发布的新版中,将它合并到develop分支:

$ git checkout develop
Switched to branch 'develop'

$ git merge --no-ff myfeature
Updating ea1b82a..05e9557
(Summary of changes)

$ git branch -d myfeature
Deleted branch myfeature (was 05e9557).

$ git push origin develop

上边的--no-ff标记的作用是使当前的合并操作总是创建一个新的commit对象,即使该合并被执行为快进式(fast-forward)合并。这样可以避免丢失掉该功能分支的历史存在信息,并且将针对该功能的所有提交都集中到一起。来对比一下:

对于后者的情形,想要从Git的历史信息中识别出哪些commit对象是针对该新功能所做的,简直不可能,除非去阅读所有的log信息。而且如果你想要回退到做整个新功能(比如一组提交)之前的状态,对于后者来说简直是件非常让人头疼的事,但是如果使用了--no-ff标记这将很容易实现。

是的,这将创建一些多余的(空的)提交(commit)对象,但是收益远远大于开销。

不幸的是,目前为止我还没有找到一个可以将--no-ff作为git merge默认行为的方法,但它确实应该是。

预发布分支 (Release branches)

分支创建自:develop;必须合并回:developmaster;分支命名约定:release-*

预发布分支主要用来协助一个新版本发布的准备工作。它允许对预发布版本做最后的打点。此外,它也允许做一些较小的bug修复并且准备一些发版的元数据(版本号和编译数据等)。在预发布分支做上述这些工作的同时,develop分支已经可以开始放心得为下一次发版进行新功能的开发了。

develop分支切出预发布分支的关键时机是当前的开发工作已经(几乎)达到了可以发版的预期状态。所有针对该次发版所做的功能开发都要在这个时间点及时地全部合并到develop分支。为以后的发版所做的功能都必须等到该预发布分支创建出来(branched off)之后才能合并到develop分支上来。

必须准确得在一个新创建的预发布分支的起始处为即将到来的发版分配版本号,不能提前。因为在那之前,虽然在develop分支上所做的修改都是针对该“下次发版”,但是还并没决定该“下次发版”的版本号究竟是要定为0.3还是1.0,直到预发布分支创建出来时才能确定。这个决定是在预发布分支的起始处做的,并且遵从项目关于版本号变更的规则。

创建预发布分支

预发布分支创建自develop分支。比如说,当前的线上版本是1.1.5,我们即将有一个大的发版。develop分支当前已经达到了该“下次发版”的预期状态,并且我们已经决定了该次发版的版本号将定为1.2(而不是1.1.6或者2.0)。藉此我们切出一个预发布分支,并给它一个可反映出新版本号的名字:

$ git checkout -b release-1.2 develop
Switched to a new branch "release-1.2"

$ ./bump-version.sh 1.2
Files modified successfully, version bumped to 1.2.

$ git commit -a -m "Bumped version number to 1.2"
[release-1.2 74d9424] Bumped version number to 1.2
1 files changed, 1 insertions(+), 1 deletions(-)

创建并切换到新建的分支后,我们先修改版本号。这里的bump-version.sh是一个虚构的shell脚本,我们用它来修改当前工作副本(working copy)中的相关文件以反映出新的版本号(当然你也可以手动修改)。然后,提交修改后的版本号。

这个新建的预发布分支会保留一段时间,直到确定已完成本次版本发布。在这段时间,一些bug的修复工作应该在该分支上来做(而不是在develop分支上)。但是决不能在该分支上增加较大的新功能。如果此时要新增较大的新功能,决不能添加到该分支,而应该将它们合并到develop分支以等待下次大的发版。

结束预发布分支

当预发布分支已经达到可发布状态时,我们需要进行以下操作。首先,把该预发布分支合并到master分支(因为每个针对master分支的commit都是一次已确定的新的发版,切记)。然后,必须给针对master分支的该commit打一个标签(tag),以方便以后参考该历史版本。最后,在该预发布分支上所做的修改要合并回develop分支,这样以后的版本就也包含了在该预发布分支上所做的bug修复。

上述前两步操作如下:

$ git checkout master
Switched to branch 'master'

$ git merge --no-ff release-1.2
Merge made by recursive.
(Summary of changes)

$ git tag -a 1.2

发版结束,并且打上标签供以后参考。另外,如果需要的话可以使用-s或者-u参数为你所打的标签进行加密处理。

为保存对该预发布分支所做的修改,我们需要将它合并回develop分支:

$ git checkout develop
Switched to branch 'develop'

$ git merge --no-ff release-1.2
Merge made by recursive.
(Summary of changes)

这步操作可能会导致合并冲突(merge conflict)(只是可能,因为我们修改了版本号)。如果真的出现了合并冲突,解决它并再次提交。

OK,至此操作结束,可以删掉该预发布分支了,因为我们已经不再需要它:

$ git branch -d release-1.2
Deleted branch release-1.2 (was ff452fe).

热修复分支 (Hotfix branches)

分支创建自:master;必须合并回:developmaster;分支命名约定:hotfix-*

热修复分支也是用来协助新版本发布的,在这点上和预发布分支相似,但该分支不是必须存在的。该分支的创建主要是用来及时应对线上版本所出现的意外情况。当线上版本出现一个需要立刻修复的严重bug时,我们可以从master分支上标记为当前线上版本号的tag处切出一个热修复分支。

这样做的好处是,在抽出一两个人来应对线上版本紧急bug修复的同时,团队其他成员依然可以继续在develop分支上进行日常开发工作。

创建热修复分支

热修复分支创建自master分支。比如,当前线上产品版本号是1.2,突然出现了一个急需修复的严重bug。但此时在develop分支上的开发还没有达到一个可发布状态。这时我们就需要切出一个热修复分支来修复该bug:

$ git checkout -b hotfix-1.2.1 master
Switched to a new branch "hotfix-1.2.1"

$ ./bump-version.sh 1.2.1
Files modified successfully, version bumped to 1.2.1.

$ git commit -a -m "Bumped version number to 1.2.1"
[hotfix-1.2.1 41e61bb] Bumped version number to 1.2.1
1 files changed, 1 insertions(+), 1 deletions(-)

切出分支后不要忘了先修改版本号!

然后,修复bug,并将修复工作进行一或多次单独提交。

$ git commit -m "Fixed severe production problem"
[hotfix-1.2.1 abbe5d6] Fixed severe production problem
5 files changed, 32 insertions(+), 17 deletions(-)

结束热修复分支

当完成了紧急bug的修复时,要将该热修复分支合并回master分支,并且同时也要将其合并回develop分支,以确保对该bug的修复也同时包含在下一次发版中。这里的操作和结束预发布分支完全类似。

首先,将bug修复工作合并回master分支,并且打上一个标示发版号的标签:

$ git checkout master
Switched to branch 'master'

$ git merge --no-ff hotfix-1.2.1
Merge made by recursive.
(Summary of changes)

$ git tag -a 1.2.1

如果需要的话可以使用-s或者-u参数为你所打的标签进行加密处理。

然后,必须将bug修复工作也合并回develop分支:

$ git checkout develop
Switched to branch 'develop'

$ git merge --no-ff hotfix-1.2.1
Merge made by recursive.
(Summary of changes)

对于这里的规则有一个例外,即如果此时同时存在一个预发布分支,那么要把该热修复分支合并回该预发布分支,而不是develop分支。因为当该预发布分支完成之后是要合并回develop分支的,所以从热修复分支合并到预发布分支上的bug修复工作,最终也同样会被合并回develop分支。(如果在develop分支上的开发工作已经等不及该预发布分支的完成就迫切的需要对该bug的修复,此时你同样可以放心得将已经在热修复分支上做的bug修复工作合并到develop分支上去)。

最后,删除该临时分支:

$ git branch -d hotfix-1.2.1
Deleted branch hotfix-1.2.1 (was abbe5d6).

总结

虽然在我所介绍的这个分支管理模型中没有涉及到什么特别新的东西,但事实证明文章开头所展示给大家的模型图确实对我们的项目帮助极大。它形成了一种优雅的思维模式,非常容易理解,并且可让团队成员达成一个对创建分支和发版流程的共同理解。

这里提供了一个高质量的PDF格式的分支管理模型图。

返回顶部