一、背景:代码合并像打仗,发布像赌博

先说说我们团队的情况。12个人,3个产品线,维护着同一个微服务仓库(Java Spring Boot + Vue前端),每天平均产生15-20个MR/PR。在2023年Q3之前,我们的Git使用基本靠“自觉”——有人直接在master上改,有人开feature分支但一开就是一个月不合并,Code Review形同虚设,CI只在master上跑,每次发版都是手动挑commit然后打tag。

最崩溃的一次是2023年8月,两个同事各自在master上改了同一个配置文件的同一行,结果第三个同事pull代码时直接冲突,为了解这个冲突花了整整一个下午,最后还引入了线上BUG。那次事情之后,我们决定彻底重构Git工作流。

二、环境与版本基线

首先明确我们的技术环境,版本号很重要,因为不同版本的Git和GitLab在功能上有差异:

  • Git版本:2.39.2(支持git switchgit restore命令,必须2.23+)
  • GitLab:15.11.3(支持Merge Request Approval Rules,即审批规则)
  • Jenkins:2.414.2(Pipeline插件版本 2.9.3)
  • 仓库规模:约8000+ commits,2.3GB(含历史大文件,已用BFG清理过)
  • 团队成员:12人,前端4人,后端6人,测试2人

我们的核心诉求是:保证master永远可部署,同时不牺牲开发效率

三、分支策略:双轨制设计

我们最终没有选择纯Git Flow或纯Trunk Based,而是结合两者设计了“双轨制”。

轨道A(日常开发):Trunk Based为主

所有日常开发基于main分支(我们习惯叫master,但实际是main)。每个功能/修复必须从最新的master切出短生命周期分支(不超过3天),命名规范为:

feat/[需求编号]-[简短描述]      如 feat/JIRA-1234-user-login
fix/[BUG编号]-[简短描述]        如 fix/BUG-5678-npe-error
refactor/[描述]                如 refactor/optimize-query

分支生命周期:从master切出 -> 开发 -> 提交MR -> 通过CI + Code Review -> 合并回master -> 删除分支。

轨道B(版本发布):Git Flow的release分支

每两周一个发布周期。发版前从master切出release/分支(如release/2.4.0),在release分支上只做bug修复,不允许新功能。修复后merge回master,同时打上tag(如v2.4.0)。

对于紧急线上hotfix,从对应tag切出hotfix/分支,修复后merge回master和当前release分支。

graph TD
    A[master] -->|切分支| B[feat/xxx]
    B -->|MR合并| A
    A -->|发版前| C[release/2.4.0]
    C -->|修复| C
    C -->|合并| A
    C -->|打tag| D[v2.4.0]
    D -->|紧急修复| E[hotfix/xxx]
    E -->|合并| A
    E -->|合并| C

这个策略的核心思想是:master永远是可部署的,release分支是短暂的(最多存在2周),feature分支是脆弱的(随时可以被删除重建)。

四、核心实现:分支保护 + MR流程 + CI/CD集成

4.1 分支保护规则(GitLab设置)

在GitLab的Settings -> Repository -> Protected Branches中配置:

  • master:允许合并(Merge)但不允许直接Push。允许开发者角色Push,但会被拒绝。只有Maintainer可以Push(实际上我们强制所有变更走MR)。
  • release/*:允许Maintainer Push,合并需要至少1个Approval。
  • hotfix/*:允许Maintainer Push,需要至少2个Approval(因为涉及线上修复)。

4.2 MR流程与Code Review

我们强制要求:所有合并到master的MR必须通过以下检查

  1. CI流水线通过(包含编译、单元测试、代码扫描)
  2. 至少2个Approval(其中至少1个来自非本模块的同事)
  3. 无冲突(GitLab会检测,有冲突必须先解决)
  4. 提交信息符合规范(我们通过Commitlint检查)

在GitLab的Merge Request Approval Settings中配置:

Approval rules:
- 规则1:默认规则,需要2个Approval
- 规则2:如果涉及`src/main/resources/`下的配置文件,额外需要1个运维同事的Approval

4.3 CI/CD集成:Jenkins Pipeline

这是我们的.gitlab-ci.yml核心配置(我们使用GitLab CI Runner,但逻辑和Jenkins类似,这里展示GitLab CI的写法,因为更团队友好):

# .gitlab-ci.yml
stages:
  - build
  - test
  - scan
  - deploy

variables:
  MAVEN_OPTS: "-Xmx2048m"
  DOCKER_REGISTRY: "registry.example.com:5000"

# 构建阶段
build:
  stage: build
  image: maven:3.8.7-eclipse-temurin-17
  script:
    - mvn clean compile -DskipTests -q
  artifacts:
    paths:
      - target/*.jar
    expire_in: 1 day
  only:
    - master
    - release/*
    - /^feat\/.*$/

# 单元测试(带覆盖率门禁)
test:
  stage: test
  image: maven:3.8.7-eclipse-temurin-17
  script:
    - mvn test
    - mvn jacoco:report
  coverage: '/Total.*?([0-9]{1,3})%/'
  after_script:
    - echo "覆盖率报告已生成"
  only:
    - master
    - release/*
    - /^feat\/.*$/

# 代码质量扫描(SonarQube)
sonar:
  stage: scan
  image: sonarsource/sonar-scanner-cli:5.0.1
  script:
    - sonar-scanner
      -Dsonar.projectKey=my-service
      -Dsonar.sources=src/main/java
      -Dsonar.host.url=http://sonar.internal:9000
      -Dsonar.login=$SONAR_TOKEN
      -Dsonar.qualitygate.wait=true
      -Dsonar.qualitygate.timeout=300
  only:
    - master
    - release/*

# 部署到测试环境(仅master分支)
deploy_test:
  stage: deploy
  image: docker:24.0.5
  services:
    - docker:24.0.5-dind
  script:
    - docker build -t $DOCKER_REGISTRY/my-service:latest .
    - docker push $DOCKER_REGISTRY/my-service:latest
    - kubectl rollout restart deployment/my-service -n test
  environment:
    name: test
  only:
    - master
  when: manual  # 手动触发,避免每次合并都部署

这里有个关键点:only规则必须严格匹配我们的分支命名规范。releas分支走部署到预发,master走测试环境,feature分支只跑build和test(不部署,节省资源)。

4.4 提交信息规范(Commitlint + husky)

我们在每个开发者本地装husky + @commitlint/config-conventional,在package.json中配置:

{
  "husky": {
    "hooks": {
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS",
      "pre-push": "npm run test:unit"
    }
  },
  "commitlint": {
    "extends": ["@commitlint/config-conventional"],
    "rules": {
      "type-enum": [2, "always", ["feat", "fix", "refactor", "chore", "test"]],
      "subject-min-length": [2, "always", 10],
      "subject-max-length": [2, "always", 50]
    }
  }
}

这样强制每个commit的message都是形如feat(JIRA-123): add user login API的格式,方便后续自动生成CHANGELOG。

五、踩坑与优化:那些文档没告诉你的

坑1:CI在feature分支上跑全量测试太慢

一开始我们对所有分支都跑全量测试,结果一个feature分支的MR等待时间长达15分钟。优化方案:区分测试级别。

# 只跑受影响的测试
test_feature:
  stage: test
  script:
    - mvn test -Dtest.affected=only  # 通过maven-surefire-plugin的增量测试插件
  only:
    - /^feat\/.*$/

坑2:pre-commit钩子被绕过

有些同事用--no-verify跳过husky检查。我们的解决方案是在GitLab服务端加了一个Custom Hook(利用GitLab的Server Hooks功能),在pre-receive阶段校验commit message格式,不合法直接reject。

坑3:release分支的合并冲突

每个release分支从master切出后,master上可能有新commit(其他feature提前合并了)。我们固定:release分支冻结后,master上只允许合并bugfix,不允许merge新feature。这需要PM和TL的协调。我们通过一个简单的脚本检查是否违反了规则:

# check_release_conflict.sh
#!/bin/bash
# 检查release分支与master的差异是否只包含fix类型
release_branch=$1
master_branch="master"
diff_commits=$(git log --oneline $master_branch..$release_branch --grep="^feat" --all-match)
if [ -n "$diff_commits" ]; then
  echo "ERROR: release分支包含feat提交,不允许"
  exit 1
fi

六、效果数据:用数字说话

改造后运行了3个月,我们统计了以下数据:

  • 代码冲突:从每周平均8次降至每周1次以内(主要发生在配置文件)
  • MR等待时长:从平均4小时降至40分钟(因为CI快了,且Review规则更明确)
  • 发布周期:从每两周一次(且经常延期)变为每周五固定发布,且当天完成
  • 线上BUG:从每月5-6个降至1-2个(这和Code Review质量提升直接相关)
  • CI资源消耗:通过增量测试,从每月消耗120小时CPU降至45小时

七、总结与建议

这套双轨制Git工作流我们已经跑了3个季度,最大的感受是:流程不是为了限制开发,而是为了把人的精力从机械冲突中解放出来

如果你的团队在10-20人规模,维护1-3个生产仓库,这套方案可以直接复制。如果团队更小(5人以下),可以砍掉release分支,直接用Trunk Based + tag发布。如果更大(30+人),可能需要引入更细粒度的模块拆分。

最后给几个关键建议:

  1. 分支保护必须强制,不要相信自觉
  2. CI要快,超过10分钟的流水线没人愿意等,宁愿省资源也不要慢
  3. Review规则要具体,不要只写“需要2人审批”,要写“哪些文件变更需要谁审批”
  4. 工具链要统一,全组必须用同一个Git客户端(我们统一用IntelliJ IDEA内置Git,避免命令行和GUI混用导致的问题)

Git工作流不是银弹,但它绝对是最廉价的工程效率投资。现在每次合并代码都变成了一件“无感”的事情,这才是我们想要的状态。