GitLab Flow 介绍

GitLab Flow

Git 支持很多种分支策略和工作流(workflow)。正由于此,许多组织用户最终所使用的工作流太复杂,定义不清晰,或者缺少了议题追踪系统。因此提出了 GitLab 工作流,作为一个清晰明了的一套最佳实践。它结合了特性驱动开发和特性分支,并且包含了议题追踪。

一些组织以前采用其它的版本控制系统,在使用 Git 后往往发现很难开发一个高效的工作流。本文阐述了 GitLab 工作流,它集成了 Git 工作流与议题追踪系统,提供了一个透明且高效的使用 Git 来工作的方式:

graph LR subgraph Git workflow A[Working copy] --> |git add| B[Index] B --> |git commit| C[Local repository] C --> |git push| D[Remote repository] end

当从其它系统转换到 Git 时,您不得不习惯在将一个提交(commit)分享给同事前,需要三个步骤。 大部分版本控制系统只有一步:从工作区(working copy)直接提交到一个共享的服务器。 在 Git 里,首先要在工作区添加文件到暂存区(staging area)。然后,将这些文件提交到您的本地仓库(local repository)。最后,推送到远端仓库(remote repository)。 在熟悉这三个步骤之后,接下来的挑战便是分支模型。

很多初次接触 Git 的组织用户不了解 Git 的使用惯例,因而他们的代码仓库很快会变得杂乱无章。其中最大的问题是,很多持续维护的分支都只包含了部分更改。人们会很难搞清楚,哪个分支包含了最新的代码,或者哪个分支会部署到生产。 很多情况下,对于这个问题人们的反应是采用标准化的模式,例如Git flowGitHub flow。我们认为这些方法仍有改进空间。在本文中,我们会阐述一套实践,我们称之为 GitLab flow。

Git flow 以及它的问题

Git Flow timeline by Vincent Driessen, used with permission

Git flow 是最早使用 Git 分支的提案之一,并且受到了广泛的关注。 它建议使用一个 main 分支和一个单独的 develop 分支,当然也支持特性(feature),发布(release)和热修(hotfix)分支。开发活动最初在 develop 分支上进行,然后转移到 release 分支,并最终合并(merge)到 main 分支。

Git flow 定义了一套明确的标准,但是它的复杂性带来了两个问题。第一个问题是,开发者必须使用 develop 分支而不是 main 分支。main 分支是为部署到生产的代码所保留的分支。 我们习惯将默认的分支称为 main,大部分分支由这个分支发起,并最终合并到这个分支。大部分工具默认自动采用 main 分支,因而不得不切换到其他分支,这个操作很麻烦。

第二个 Git flow 的问题是由热修和发布分支带来的复杂性。 对于一些组织来说,这些分支是一个好的方法。但是对于大部分来说,这个是多余的。如今,大部分组织在实践持续交付(continuous delivery),这也就意味着默认分支是可以被部署的。 持续交付没有了 hotfix 和 relese 分支的需求。这些分支带来的繁琐流程也就一并移除了(比如,合并回 release 分支)。 尽管一些特殊的工具能解决这个问题,但它们的文档又额外增加了复杂性。 开发者经常会犯一些错,比方说将变更只合并到 main 却没有合并到 develop 分支。 这些问题的原因在于,Git flow 对于大部分使用场景来说太复杂了。对于一些项目来说,只需要做发布而不需要热修。

更为简洁的替代方案:GitHub flow

响应 Git flow,GitHub 创造了一个更简洁的替代方案。 GitHub flow 只有特性(feature)分支和一个 main 分支:

graph TD subgraph Feature branches in GitHub Flow A[main branch] ===>B[main branch] D[nav branch] --> |add navigation| B B ===> C[main branch] E[feature-branch] --> |add feature| C C ==> F[main branch] end

这个工作流相当简洁明了,并且很多组织非常成功地采用了它。 Atlassian 推荐了一个相似的策略,尽管他们采用的是将特性分支变基(rebase)的方式。将任何变更合并到 main 分支,并且频繁地部署,这意味着您能最少化未发布的代码地数量。这个方法与精益管理(lean)和持续交付的最佳实践相符合。然而,对于部署,环境,发布和议题管理的集成,这个工作流仍有许多问题需要解决。通过 GitLab flow,我们提供了对这些问题的额外指导。

GitLab flow 下的生产分支

GitHub flow 默认每当您合并一个特性分支时,能够部署到生产。 尽管在某些情况下这是有可能的,例如一些 SaaS 应用。但仍有许多情况做不到,比如:

  • 您不能控制发布的时间。比方说,一个 IOS 应用只有在通过 App Store 的验证后才能发布。
  • 您有部署的窗口。例如,在工作日的上午 10 点到下午 4 点运维团队在全力工作,然而您仍在其它时间合并代码。

在这些情况下,您可以创建一个生产环境的分支追踪被部署的代码。然后通过合并 main 到这个生产环境的分支的方式来部署一个新的版本:

graph TD subgraph Production branch in GitLab Flow A[development] ==>B[development] B ==> C[development] C ==> D[development] E[production] ====> F[production] C --> |deployment| F D ==> G[development] F ==> H[production] end

如果需要了解哪些代码部署到了生产,您可以查看生产的分支。 部署大致时间可以通过版本控制系统的合并历史看到。 如果您使用的是自动化的方式部署您的生产分支,这个时间会相当精准。 如果您需要更确切的时间,您可以让部署脚本在每次部署时打上一个标签(tag)。 这个流程避免了 Git flow 中会发生的发布,打标签以及合并带来的开销。

GitLab flow 下的环境分支

拥有一个自动更新到 staging 分支的环境可能是个好主意。 只是,在这种情况下,此环境的名称可能与分支名称不同。 假设您有一个 staging 环境、一个预生产环境和一个生产环境:

graph LR subgraph Environment branches in GitLab Flow A[staging] ==> B[staging] B ==> C[staging] C ==> D[staging] A --> |deploy to<br>pre-prod| G F[pre-prod] ==> G[pre-prod] G ==> H[pre-prod] H ==> I[pre-prod] C --> |deploy to<br>pre-prod| I J[production] ==> K[production] K ==> L[production] G --> |production <br>deployment| K end

在这种情况下,将 staging 分支部署到你的临时环境。 要部署到预生产,请创建从 staging 分支到 pre-prod 分支的合并请求。 通过将 pre-prod 分支合并到 production 分支来上线。此工作流仅向下游提交提交,可确保在所有环境中对所有内容进行测试。 如果您需要挑选带有修补程序的提交,通常会在功能分支上开发它并通过合并请求将其合并到 production 中。 在这种情况下,先不要删除功能分支。如果 production 通过自动测试,则将功能分支合并到其他分支。如果由于需要更多的手动测试而无法这样做,您可以将合并请求从功能分支发送到下游分支。

GitLab flow 下的发布分支

只有在需要向外界发布软件时才应该使用发布分支。 在这种情况下,每个分支都包含一个次要版本,例如 2.3-stable2.4-stable

graph LR A:::main ===> B((main)) B:::main ==> C((main)) C:::main ==> D((main)) D:::main ==> E((main)) A((main)) ----> F((2.3-stable)):::first F --> G((2.3-stable)):::first C -.-> |cherry-pick| G D --> H((2.4-stable)):::second classDef main fill:#f4f0ff,stroke:#7b58cf classDef first fill:#e9f3fc,stroke:#1f75cb classDef second fill:#ecf4ee,stroke:#108548

main 创建一个 stable 分支作为起点,并且这个分支尽可能的新。 这样,您能减少后续不得不将漏洞修复代码部署到多个分支的情况。声明了一个发布分支后,这个分支只会合并严重的漏洞修复更新。 尽可能先将漏洞修复的更新合并到 main,然后拣选(cherry-pick)到发布分支。 如果您先合并 到release 分支,您可能会忘记将它们拣选(cherry-pick)到 main,然后在后续的发布时候遇到相同的漏洞。 先合并到 main,然后拣选到发布分支,称为“上游优先(upstream first)”原则,也被GoogleRed Hat实践。 每次您将漏洞修复包含到一个 release 分支,通过设置新的标签的方式提升补丁(patch)版本(遵循语义化版本管理)。 有些项目也会有一个 stable 分支指向与最新的发布的分支相同的提交。 在这个工作流里,不推荐使用生产分支(或者 Git flow 里的 main 分支)。

GitLab flow 下的 Merge/pull 请求

Merge request with inline comments

合并请求(merge request,或者称为 pull request)是通过 Git 管理工具创建的。它们会请求被指派(assigned)的人去合并两个分支。 一工具些诸如GitHub和Bitbucket选择将其称为“pull request”,因为第一个手工操作是拉取特性分支。极狐GitLab 和其他的工具将其称为“合并请求”,因为最终的操作是合并该特性分支。本文采用“合并请求”的叫法。如果您在一个特性分支上工作了好几个小时了,需要将中间结果分享给您团队的其他成员查看。为了实现这个目的,创建一个合并请求,但不要将它指派给任何人,而是在 MR 的描述或者下面的评论中提到他们,例如 “/cc @mark @susan“。 这说明这个合并请求还没有准备好被合并,但是欢迎他们的反馈。您的组员可以对这个合并请求的整体做评价,也可以对特定的代码行做评价。 合并请求在这里的作用是作为一个代码审阅工具,而不需要其他单独的代码审阅工具。如果审阅发现了不足,任何人可以提交并推送一个修复。 通常,做这些事情的人是这个合并请求的创建者。每当新的提交被推送到待合并的分支中时,合并请求中的差异会自动更新。

当您的特性分支准备好被合并时,指派这个合并请求给最了解您更新的这个代码库的人。当然,还要在评论中提及一下您想要反馈的人。 当被指派的人觉得结果没有问题,他们可以合并这个分支。如果他们觉得不合适,他们可以要求更多修改,或者关闭这个合并请求而不合并。 在极狐GitLab,通常会保护持久分支,比如 main 分支,这样大部分开发者不能对其更改。 因此,如果您想合并到一个被保护的分支,将您的合并请求指派给拥有维护者角色的人。 在合并了一个特性分支之后,您应该将该分支从源码控制软件中移除。 在极狐GitLab,您可以在合并的时候就完成这件事。删除合并了的分支保证分支列表中只展现正在开发中的分支,也保证如果其他人重新开启这个议题(issue),他们可以使用相同的分支名称而不会出现问题。

note当重新开启议题的时候,您需要创建一个新的合并请求。

Remove checkbox for branch in merge requests

GitLab flow 下议题追踪

GitLab flow 的工作方式将代码和议题追踪之间的关系变得更加透明。

任何重大的代码变更需要由一个阐明目的的议题发起。 每个代码变更都有原因,可以帮助团队其他成员了解变更,并且保持特性分支的变更覆盖范围比较小。 在极狐GitLab,每次对代码库的变更都由一个议题追踪系统的议题发起。 如果还没有相关的议题,当变更的需要花费超过 1 小时的工时,为它创建一个议题。 在许多组织中,创建一个议题是开发流程的一部分,如果它们会用于敏捷计划会议上(sprint planning)。议题的标题应该描述对应系统想要达到的状态。比方说,这个用例的标题“作为一个管理员,我想移除用户而不收到任何报错”,会比”管理员不能移除用户”更好。

当您准备好去编写代码时,从 main 分支为这个议题创建一个分支。这个分支就是接下来做与之相关的变更的地方。

note分支的名字根据不同组织的标准会有特定的要求。

当您完成了代码编写,或者想要讨论这个代码,创建一个合并请求。合并请求是一个线上用于讨论变更和审阅代码的地方。

如果您创建了一个合并请求,但是没有指派给任何人,它是一个起草阶段的合并请求。 这些请求用于讨论提出的实施方案,但是还没有准备好包含到 main 分支。将 [Draft]Draft: 或者 (Draft)放在合并请求标题的开头,以避免它在准备好之前就被合并。

当您觉得代码可以了,指派这个合并请求给审阅者。如果他们认为代码可以包含到 main 分支中,他们就可以合并这些变更。在他们按下合并按钮时,极狐GitLab 会将代码合并,并且创建一个合并的提交,使得这次的活动在接下来可见。合并请求会单独生成一个合并的提交,即使分支可以在没有任何提交的情况下被合并。 这个合并策略在 Git 中被称为“非快进合并(no fast-forward)” 在合并之后,删除对应的特性分支,因为它不再需要了。在极狐GitLab 中,这个删除操作是合并过程中的一个选项。

假设一个分支被合并了但是有问题出现,导致相关的议题被重新开启。 在这个情况下,使用相同的分支名就不会出现问题,因为之前这个分支在合并之后被删除了。在任何时候,每个议题最多一个分支。有可能一个特性分支解决多个议题。

通过合并请求链接并关闭议题

可以通过在 Git 提交说明或者合并请求中提到相关议题的方式,来链接相关议题。例如:“Fixes #16”或者”更推荐使用鸭子类型。详见 #12。” 极狐GitLab 之后会创建链接到被提到的议题,并且会在议题下面添加评论来链接回该合并请求。

为了能自动关闭链接的议题,在提到它们的时候以 “fixes” 或者 “closes” 开头。比方说,“fixes #14“或者 “closes #67”。极狐GitLab 会在代码被合并到主分支的时候关闭这些议题。

如果您的议题横跨多个代码仓库,给每个仓库创建一个议题,并且将它们链接到一个父议题。

通过变基聚合多个提交

通过 Git,您可以使用交互的变基命令(rebase -i)将多个提交聚合为一个,或者对它们重新排序。 这个特性能帮助您以一个单独的提交取代多个小的提交,或者您想使这些提交的顺序更加合理:

pick c6ee4d3 add a new file to the repo
pick c3c130b change readme

# Rebase 168afa0..c3c130b onto 168afa0
#
# Commands:
# p, pick = use commit
# r, reword = use commit, but edit the commit message
# e, edit = use commit, but stop for amending
# s, squash = use commit, but meld into previous commit
# f, fixup = like "squash", but discard this commit's log message
# x, exec = run command (the rest of the line) using shell
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.
#
# Note that empty commits are commented out
~
~
~
"~/demo/gitlab-ce/.git/rebase-merge/git-rebase-todo" 20L, 673C

然而,如果有其他贡献者在同一个分支工作,您应当避免对推送到远程仓库的提交进行变基。 因为变基操作会对您的所有变更创建新的提交,这会导致困惑的发生,因为同一个变更可以出现多个标识。 这会导致,其他工作在同一个分支的贡献者的代码合并失败,因为他们的提交历史跟您的不同。这对于项目的作者和其他贡献者来说会非常麻烦。 此外,如果有人已经审阅了您的代码了,变基会导致在上次的代码审阅之后很难查看具体更改了什么。

除非得到允许,您不应该变基任何他人提交的代码。它不仅会改写变更历史,还会丢失作者信息。变基会将其他作者从贡献者和 git blame 中去除。

如果一个合并涉及到太多提交,撤销操作会变得更困难。可以考虑通过使用极狐GitLab 的 squash and merge 功能,在合并之前聚合所有的变更为一个提交的方式来解决这个问题。 幸运的是,您可以撤销一个合并和它所有的提交。实现的方法是还原(revert)这个合并提交。当您手动合并的时候,保证能够还原一次合并是采用“非快进合并”(--no-ff)策略的原因之一。

note当您在还原一个合并提交后改变心意,还原这个还原的提交来撤销合并。否则 Git 不允许再次合并这个代码。

减少在特性分支中合并提交

有太多合并提交会导致您的仓库历史混乱。因此,您应该避免在特性分支中有合并提交。通常,人们使用变基来重新排序,将他们的提交排到 main 上的最新提交的后面。通过这种方式,就避免了来自 main 的合并提交。 使用变基能防止合并 main 到您的特性分支时产生合并提交,并且能创建一个干净的线性提交历史。 然而,正如在变基部分讨论的一样,您应当避免在与其他人协同工作的特性分支中变基。

变基会造成更多的额外工作,因为每当变基时,您可能要解决相同的冲突。 有时候您可以复用冲突解决记录(rere),但是使用合并会更好,因为您只需要解决一次冲突。 在 Atlassian 的 blog 上,他们对合并和变基之间的折衷有更透彻的解释。

一个避免出现太多合并提交的方法是不要频繁合并 main 到特性分支。 从 main 中合并的原因有三个:采用新的代码,解决合冲突以及更新长期维护的分支。

如果在创建特性分支后,您需要采用一些 main 中采纳的新的代码,通常可以通过拣选(cherry-pick)一个提交来解决这个问题。

如果您的特性分支有合并冲突,一个标准的解决方法是创建一个合并提交。

note有时候您会使用 .gitattributes 来减少合并冲突。例如,您可以设置您的变更日志文件使用 union 合并要素,这样有多个条目的时候不会互相冲突。

最后一个创建合并提交的原因是保证长期维护的特性分支与项目最新的状态同步。解决方案是保持一般特性分支在一个短的生命周期。大多数特性分支所需花费的时间应该少于 1 天。如果您的特性分支经常要花费超过一天的时间,尝试将您的特性分解为更小的工作。

如果您需要保持一个特性分支在开启状态超过一天,有几个策略来保持同步。一个选择是,每天工作前使用持续集成工具(CI)来合并 main。 另一个选择是,只在明确定义的时间合并,比方说,一次打了标签的发布。 您也可以使用特性开关来隐藏未完成的特性,这样您仍能每天合并到 main

note不要将自动化分支测试与持续集成搞混。Martin Fowler 在一个关于特性分支的文章中做了区分:“有些人说他们在做 CI,因为他们(可能使用了一个CI服务器)在每个分支的每个提交时进行构建。这个是持续构建,当然这是个好东西,但是这里没有‘集成’,所以这不是持续集成。”

总之,您应该试图避免合并冲突,而不是消除它。您的代码库应当保持干净,但是提交历史要能反应真正发生了些什么。软件开发过程是在一个个微小的,混乱的步伐中前行的,而在您的提交历史中反映这些没什么大不了。 您可以使用一些工具来查看提交的网络图,并了解您写这些代码的混乱历史。如果您变基了代码,提交历史会不正确,而这没有任何补救措施,因为这些工具没有办法处理变更的提交标识符。

多推送,多提交,多多益善

另一个让您的开发工作变得更轻松的方法是经常性的提交变更。每当有一系列测试和代码,您应该做一次提交。将您的工作分解为一个个独立的提交,能给之后看您的代码的开发者一个清晰的上下文。 让提交更小,可以使得一个特性的开发脉络更加清晰。这能帮助您方便地回滚代码到一个特定的合适的时间,或者还原一段代码而不影响到其他不相关的变更。

及时提交也能帮助您分享您的工作进展,这很重要,因为所有人都能了解到您在做些什么。您应该经常性地推送您的特性分支,即使它还没有准备好审阅。通过分享您的工作在一个特性分支或者一个合并请求,能避免组员重复您的工作。 在完成以前分享您的工作也能得到与变更相关的讨论和反馈。这些反馈可以帮助您在代码审阅之前改善代码。

如何书写一个良好的提交说明

一个提交说明(commit message)应该反映您的意图,而不仅仅是这次提交的内容。您可以看到在一次提交中的变更,所以提交说明应该解释为什么要做这些变更。

# This commit message doesn't give enough information
git commit -m 'Improve XML generation'

# These commit messages clearly state the intent of the commit
git commit -m 'Properly escape special characters in XML generation'

一个好的提交说明的例子是:“结合使用模版来减少 user views 中的代码重复。” 这些词“更改(change)”,“改善(improve)”,“修复(fix)”以及“重构(refactor)”并不能为一个提交说明添加太多有效信息。

对于更多规范化提交说明的相关内容,请阅读这篇非常棒的 blog(作者Tim Pope)

为了能往一个提交信息中添加更多内容,可以考虑添加原变更相关的信息。例如,极狐GitLab 中的议题的链接,或者一个 Jira 议题的编号,为那些需要深入了解变更相关内容的人提供更多信息。

例子如下:

在 XML 代码生成中合理地转义特殊字符。

Issue: gitlab.com/gitlab-org/gitlab/-/issues/1

在合并之前进行测试

在过去的工作流中,持续集成(CI)服务器通常在 main 分支上进行测试。开发者不得不保证他们的代码不会破坏 main 分支的测试。当使用 GitLab flow,开发者可以从 main 分支创建自己的分支,所以 main 分支在CI中应该永远不会被打断。所以,每个合并请求在被采纳前必须被测试。 像 Travis CI 和极狐GitLab CI/CD 这些 CI 软件能在合并请求中显示构建的结果,从而简化了测试的流程。

但测试合并请求有一个弊端:CI服务器只测试这个特性分支,而不会测试合并的结果。 理想情况下,在每次变更后,CI服务器也能测试main分支。 然而,在main分支上重复测试每个提交要花费昂贵的计算资源,并且意味着您要更频繁地等待测试结果。 由于特性分支的周期很短,因此仅仅测试这个分支所带来的风险是可以接受的。 如果 main 中新的提交导致与特性分支出现合并冲突,合并 main 到这个特性分支,让 CI 服务器重新进行测试。 正如前面所说的,如果您经常有特性分支持续开发好几天,您应该将您的议题变小一些。

在特性分支下工作

当创建一个特性分支时,总是从最新的 main 分支创建。如果在开始之前您知道您的工作依赖另一个分支,您可以从那个分支创建。如果在开始后,您需要将另一个分支合并过来,要解释一下这个合并提交的原因。 如果您还没有将您的提交推送到一个公共的区域,您也可以将其他的变更通过变基 main 或者其他特性分支来包含进您的代码。如果您的代码能正常工作并且能干净地合并,而无需合并其他分支的代码,那么不要从上游合并代码。只有在需要的时候合并新的代码,可以避免在您的特性分支中创建合并提交,最终将 main 的提交历史搞乱。