一、为什么我们放弃了“标准GitFlow”

团队在2023年初还是12人后端、5人前端,项目是部署在K8s上的Spring Cloud微服务(共7个服务),前端是Vue3+TypeScript。最初采用的是经典的git-flow(A successful Git branching model),但运行半年后出现了几个致命痛点:

  1. release分支生命周期过长:一个release分支常常存活2-3周,期间develop分支不断合入新功能,导致release分支与develop冲突频繁,Code Review(CR)时diff动辄上千行。
  2. 热修复流程繁琐:线上紧急bug需要从master拉hotfix,修复后合回master和develop,但若此时release分支存在,还需要手动合并到release,漏一次就出事故。
  3. CI构建效率低下:由于分支太多,GitLab CI的only: - developonly: - master规则导致每个MR(Pull Request)都要跑全量测试和构建,单次pipeline耗时22分钟,排队严重。

我们不是要否定GitFlow,而是它在“持续交付”场景下过于笨重。在参考了Trunk-Based Development和GitLab Flow之后,我们设计了一套混合方案。

二、环境与版本基线

  • Git版本:2.39.0 (强制要求,使用了git switchgit worktree特性)
  • GitLab:15.11.0 (Community Edition)
  • 代码托管:自建GitLab,仓库约1.2GB(含LFS对象)
  • CI Runner:Docker executor,image: maven:3.8.7-eclipse-temurin-17
  • 分支保护规则:mainrelease/*禁止直接push,需MR+2人approve

三、混合分支策略设计:Trunk-Based + Release Branch

我们的核心原则是:开发者永远基于main分支创建短期特性分支(存活不超过2天),release分支只用于版本固化,不允许长期存在。

main (Trunk)  ←—— feat/xxx (≤2天)  ←—— 直接合回
     │
     └── release/1.x.x (从main切出,只做bugfix,合回main)
     └── hotfix/xxx (从main切出,合回main和当前release)

3.1 分支命名与生命周期

分支类型 命名规则 生命周期 合回目标
特性分支 feat/{jira-id}-short-desc ≤2天 main
修复分支 fix/{jira-id}-short-desc ≤1天 main
发布分支 release/{version} 仅测试期(≤1周) main
热修复 hotfix/{jira-id}-short-desc ≤1天 main + 当前release

3.2 关键配置:Git别名与worktree

由于特性分支存活短,我们大量使用git worktree来并行处理多个任务,避免频繁切换分支导致构建缓存失效:

# ~/.gitconfig 核心配置
[alias]
    co = switch
    br = branch --format='%(HEAD) %(color:yellow)%(refname:short)%(color:reset) - %(contents:subject) %(color:green)(%(committerdate:relative))'
    lg = log --graph --abbrev-commit --decorate --format=format:'%C(bold blue)%h%C(reset) - %C(bold green)(%ar)%C(reset) %C(white)%s%C(reset) %C(dim white)- %an%C(reset)%C(bold yellow)%d%C(reset)' --all
    # 创建特性分支并推送远端
    new-feat = "!f() { git switch -c feat/$1 && git push -u origin feat/$1; }; f"
    # 合并main并清理本地分支
    sync-main = "!git fetch origin main && git rebase origin/main && git push --force-with-lease"

[core]
    # 避免文件权限变化导致误判冲突
    filemode = false
    # 解决中文文件名显示问题
    quotepath = false

[merge]
    # 默认使用rebase策略拉取远端更新,保持历史线性
    pull = rebase
    conflictstyle = zdiff3

核心实现文件:.git/hooks/pre-push (强制commit规范)

我们使用conventional-commits规范,但发现很多同事会忘记写类型。于是写了一个pre-push钩子,在推送前校验最近5个commit的message格式,不合法直接拒绝:

#!/bin/sh
# .git/hooks/pre-push
# 校验commit message是否符合Conventional Commits规范
remote="$1"
url="$2"
# 获取将要推送的commit范围
z40=0000000000000000000000000000000000000000
while read local_ref local_sha remote_ref remote_sha
do
    if [ "$local_sha" = $z40 ]; then
        # 删除分支操作,跳过
        continue
    fi
    if [ "$remote_sha" = $z40 ]; then
        # 新分支,检查所有commit
        range="$local_sha"
    else
        range="$remote_sha..$local_sha"
    fi
    for commit in $(git rev-list "$range")
    do
        msg=$(git log -1 --pretty=%s "$commit")
        # 正则匹配: type(scope): subject
        if ! echo "$msg" | grep -qE '^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?: .{1,100}$'; then
            echo "❌ ERROR: commit message不符合规范: $msg"
            echo "   正确格式: feat(scope): 描述 或 fix: 描述"
            exit 1
        fi
    done
done
exit 0

四、Code Review流程:从“事后审核”到“事前约束”

我们通过三条硬性规则将CR从“找茬”变成“护航”:

  1. MR必须小于400行变更 (通过GitLab的.gitlab/merge_request_template.md模板强制声明)
  2. 必须关联Jira Issue (通过MR描述中的Closes #123触发GitLab的issue关闭)
  3. CI必须全绿 (包括单测、集成测试、SonarQube质量门禁)

4.1 GitLab MR模板配置

在仓库根目录创建.gitlab/merge_request_template.md

## 变更描述
- **关联Issue**: Closes #(必填)
- **变更类型**: 🐛 Bug修复 / ✨ 新功能 / ♻️ 重构 / 📝 文档
- **影响范围**: 涉及的服务名、数据库变更、依赖升级

## 自测清单
- [ ] 本地已运行 `mvn verify` 通过(单测+集成测试)
- [ ] 已执行 `npm run lint` 无错误
- [ ] 已确认迁移脚本(如有)可回滚

## 测试说明
- 测试环境地址: http://staging.example.com
- 测试账号: 请在评论中索取

> ⚠️ 若变更超过400行,请注明原因并拆分MR

4.2 基于GitLab CI的自动审核

我们在.gitlab-ci.yml中配置了合并请求触发的流水线,且将test阶段设为allow_failure: false

# .gitlab-ci.yml 核心片段 (基于GitLab 15.11)
stages:
  - test
  - sonar
  - build

variables:
  MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository"
  SONAR_TOKEN: $SONAR_TOKEN

cache:
  key: "$CI_COMMIT_REF_SLUG"
  paths:
    - .m2/repository
    - node_modules/

# 仅MR事件触发,避免重复构建
test:
  stage: test
  image: maven:3.8.7-eclipse-temurin-17
  script:
    - mvn test -B -DskipITs=false -DtrimStackTrace=false
  artifacts:
    when: always
    reports:
      junit:
        - target/surefire-reports/TEST-*.xml
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'  # 核心:只跑MR
    - when: never

sonar:
  stage: sonar
  image: sonarsource/sonar-scanner-cli:5.0
  script:
    - sonar-scanner -Dsonar.qualitygate.wait=true -Dsonar.projectKey=$CI_PROJECT_KEY
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - when: never

# 构建镜像,仅在main分支合并后执行
build_image:
  stage: build
  image: docker:24.0.7
  services:
    - docker:24.0.7-dind
  script:
    - docker build -t registry.example.com/app:$CI_COMMIT_SHORT_SHA .
    - docker push registry.example.com/app:$CI_COMMIT_SHORT_SHA
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

关键配置解释rules中用了merge_request_event,这意味着push到特性分支不会触发CI,只有发起MR时才触发。这比传统的only: - merge_requests更精确,且避免了push流水线的资源浪费。但我们保留了手动触发play按钮用于调试。

五、踩坑与优化:三个真实教训

5.1 教训一:git push --force-with-lease 不是万能的

团队成员在rebase后强制推送,由于--force-with-lease特性,如果远端有更新会拒绝,但如果同事已经基于你的旧commit创建了MR,rebase后MR的diff会变得混乱。解决方案是:禁止对已创建MR的分支进行rebase。我们通过GitLab的CI检查$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME是否包含/,如果包含且存在MR,则rebase操作自动fail。

5.2 教训二:release分支的“孤儿化”

迁移初期,我们习惯从main切出release/1.5.0,测试一周后发现bug,直接在release分支修复并合回main。但忘记将修复同步到develop,导致下个迭代出现回归。后来我们引入了一个脚本sync-release-fix.sh

#!/bin/bash
# 同步release分支的修复到main
RELEASE_BRANCH=$1
if [ -z "$RELEASE_BRANCH" ]; then
    echo "用法: ./sync-release-fix.sh release/1.5.0"
    exit 1
fi
git switch main
git pull origin main
git merge --no-ff "$RELEASE_BRANCH" -m "chore: merge fix from $RELEASE_BRANCH to main"
git push origin main
echo "✅ 已同步 $RELEASE_BRANCH 到 main"

5.3 教训三:CI的cache策略导致构建依赖陈旧

最初我们设置了cache: untracked: true,结果某些同事本地修改了pom.xml版本号,但CI缓存了旧的.m2依赖,导致构建失败。优化:在缓存key中加入pom.xml的hash:

cache:
  key:
    files:
      - pom.xml  # 当pom.xml变更时自动失效缓存
  paths:
    - .m2/repository

六、效果数据与总结

经过以上改造,我们团队在2023年Q3的交付数据如下:

指标 改造前(GitFlow) 改造后(混合模式)
平均CR等待时间 4.2小时 35分钟
合并到生产环境的频率 每周1次 每天2-3次
CI构建成功率 87% 96.5%
因分支合并导致的冲突次数/周 12次 2次
发布回滚率 15% 4%

总结:Git工作流没有银弹。GitFlow适合低频发布、需要严格版本管理的传统项目;Trunk-Based适合追求持续交付的互联网团队。我们最终选择了“Trunk-Based为主,release分支为辅”的混合模式,并通过分支保护规则+commit规范钩子+MR触发CI这三板斧,把流程约束从“人治”变成了“法治”。

如果你也是中小团队,强烈建议先从“规范commit message”和“MR触发CI”开始,这两个改动成本最低、收益最明显。至于分支策略,不要迷信任何“最佳实践”,找到适合你团队发布频率和人员规模的模式才是关键。