一、问题背景:我们为什么把 Git Flow 拆了

先说结论:不是 Git Flow 本身有问题,是我们团队的规模和发布节奏不匹配它。

2023 年下半年,我们团队从 12 人扩到 30 人,后端 14 人、前端 10 人、测试 4 人、运维 2 人。当时用的是标准 Git Flow:master、develop、feature/*、release/*、hotfix/* 五类分支齐全。听起来很规范,实际跑起来是这样的:

  • develop 分支长期领先 master 200+ 个提交,release/* 分支拉出来后还要再合回 develop,一周内必然出现两次「反向合并冲突」,每次解决要 1-2 小时;
  • 一个 feature 分支平均存活 11 天,最长的挂了 3 周,合并时冲突文件数中位数是 8 个;
  • 没有强制 CI,测试同学拿到的是「本地能跑」的包,回归测试阶段平均每天发现 5 个环境相关 bug;
  • Code Review 靠群里 @ 人,MR 平均挂 19 小时才有人看,最长的一个挂了 4 天。

统计了一个月的数据:合并冲突率 27%(有冲突的 MR 占比),平均 MR 生命周期 19.3 小时,生产回滚 3.4 次/月。

问题的根子不在工具,在于「长分支 + 弱门禁」。所以我们决定换模型。

二、环境与版本

先把环境交代清楚,避免配置对不上:

  • GitLab CE 16.9.1(自建,Docker 部署)
  • GitLab Runner 16.9.1,注册了 6 个 executor,其中 2 个带 docker tag 跑构建,4 个 shell tag 跑部署
  • Node.js 20.11.1 / pnpm 9.1.0(前端)
  • JDK 17.0.10 + Maven 3.9.6(后端)
  • husky 9.0.11 + commitlint 19.0.3 + lint-staged 15.2.2
  • SonarQube 10.4(社区版)
  • 部署目标:K8s 1.28,镜像仓库 Harbor 2.10

三、方案设计:Trunk-Based 的裁剪版

我们没照搬纯 Trunk-Based(一天多次合主干对 30 人团队还是激进),而是取了中间形态,核心三条:

  1. 主干唯一且随时可发布:main 是唯一长期分支,任何时刻 main 的 HEAD 都必须能通过完整流水线并可直接部署到预发。
  2. 短分支 + 强制短生命周期:所有开发走 feat/、fix/、chore/ 前缀分支,从 main 切出,存活不超过 3 个工作日,超过就拆。分支命名带 Jira 号:feat/PROJ-1234-order-export。
  3. 发布靠 Tag 不靠分支:发布时在 main 上打 v2.8.0 语义化 tag,流水线监听 tag 触发生产部署。彻底消灭 release/* 和 hotfix/* 分支——线上紧急修复也是从 main 切 fix/ 分支,走同样流程,只是加了 urgent label 走快速通道(跳过部分非阻塞检查)。

分支保护规则(GitLab 侧配置):

  • main:禁止直接 push,必须 MR;至少 2 个 approve;流水线必须通过;要求分支为最新(Squash 合并开启);允许 Maintainer 强制推送(仅限事故处理)。
  • feat/* 等:允许开发者 push,MR 目标只能是 main。

Code Review 流程定死四条:

  • MR 描述必须填「变更点 / 影响面 / 自测方式 / 回滚方案」四段模板;
  • 每 MR 改动行数软上限 400 行(CI 里用脚本统计 diff,超了打 warning 不阻塞,但 review 时会被打回拆分);
  • 提出 review 后 4 小时内必须有第一次响应(用 GitLab 的 MR 超时提醒 + 每日站会通报);
  • 涉及数据库变更、公共 API、鉴权逻辑的 MR,必须由对应 owner(在 CODEOWNERS 里配)approve。

四、核心实现:配置与代码

4.1 提交约束:commitlint + husky

提交信息不统一是 review 效率低的隐形杀手。我们用 commitlint 强制 Conventional Commits,husky 做 pre-commit 钩子。

package.json 关键片段:

{
  "scripts": {
    "prepare": "husky",
    "lint:staged": "lint-staged"
  },
  "lint-staged": {
    "*.{js,ts,vue}": ["eslint --fix --max-warnings=0", "prettier --write"],
    "*.{json,md,yml,yaml}": ["prettier --write"]
  },
  "devDependencies": {
    "husky": "9.0.11",
    "commitlint": "19.0.3",
    "@commitlint/cli": "19.0.3",
    "@commitlint/config-conventional": "19.0.3",
    "lint-staged": "15.2.2"
  }
}

commitlint.config.cjs:

module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [2, 'always', [
      'feat', 'fix', 'docs', 'style', 'refactor',
      'perf', 'test', 'build', 'ci', 'chore', 'revert'
    ]],
    'subject-case': [0],
    'header-max-length': [2, 'always', 100],
    // 强制带 Jira 号,格式如 feat(PROJ-1234): xxx
    'scope-empty': [2, 'never']
  }
};

husky 9 的钩子文件放在 .husky/ 下,commit-msg 内容:

#!/usr/bin/env sh
npx --no -- commitlint --edit "$1"

pre-commit:

#!/usr/bin/env sh
npx --no -- lint-staged

这里有个真实的坑:husky 9 改变了 prepare 的写法(不再是 husky install),老教程照抄会失败;另外 CI 环境里 prepare 也会跑,导致 Runner 里没有 .git 目录时报错,所以我们在 CI 里用 HUSKY=0 环境变量跳过。

4.2 GitLab CI 流水线

.gitlab-ci.yml 全量配置(已脱敏),分四阶段:validate → build → test → deploy,用 rules 控制触发条件。

stages:
  - validate
  - build
  - test
  - deploy

variables:
  PNPM_VERSION: "9.1.0"
  MAVEN_OPTS: "-Dmaven.repo.local=.m2/repository"
  HUSKY: "0"

default:
  interruptible: true
  retry:
    max: 1
    when: [runner_system_failure, stuck_or_timeout_failure]

# ---------- 校验阶段 ----------
commitlint:
  stage: validate
  image: node:20.11.1-alpine
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
  script:
    - npm i -g @commitlint/cli@19.0.3 @commitlint/config-conventional@19.0.3
    - npx commitlint --from "$CI_MERGE_REQUEST_DIFF_BASE_SHA" --to "$CI_COMMIT_SHA" --verbose

mr-diff-size:
  stage: validate
  image: alpine:3.19
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
  script:
    - apk add --no-cache git curl jq
    - |
      LINES=$(git diff --numstat "$CI_MERGE_REQUEST_DIFF_BASE_SHA" "$CI_COMMIT_SHA" | awk '{a+=$1; d+=$2} END {print a+d}')
      echo "MR diff lines: $LINES"
      if [ "$LINES" -gt 400 ]; then
        curl -s -X POST -H "PRIVATE-TOKEN: $GITLAB_API_TOKEN" \
          "$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes" \
          -d "body=⚠️ 本次 MR 改动 ${LINES} 行,超过 400 行软上限,请考虑拆分后 review。"
      fi

# ---------- 构建阶段 ----------
build-backend:
  stage: build
  image: maven:3.9.6-eclipse-temurin-17
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == "main"'
    - if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/'
  cache:
    key: "maven-$CI_COMMIT_REF_SLUG"
    paths: [.m2/repository]
  script:
    - mvn -B -DskipTests package
  artifacts:
    paths: [target/*.jar]
    expire_in: 3 days

build-frontend:
  stage: build
  image: node:20.11.1-alpine
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == "main"'
    - if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/'
  cache:
    key:
      files: [pnpm-lock.yaml]
    paths: [.pnpm-store]
  script:
    - corepack enable && corepack prepare pnpm@$PNPM_VERSION --activate
    - pnpm config set store-dir .pnpm-store
    - pnpm install --frozen-lockfile
    - pnpm build
  artifacts:
    paths: [dist/]
    expire_in: 3 days

# ---------- 测试阶段 ----------
unit-test-backend:
  stage: test
  image: maven:3.9.6-eclipse-temurin-17
  needs: [build-backend]
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == "main"'
  script:
    - mvn -B test
  coverage: '/Total.*?([0-9]{1,3})%/'
  artifacts:
    when: always
    reports:
      junit: target/surefire-reports/TEST-*.xml

sonarqube:
  stage: test
  image: sonarsource/sonar-scanner-cli:5.0
  needs: [unit-test-backend]
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
  variables:
    SONAR_HOST_URL: "https://sonar.internal.example.com"
  script:
    - sonar-scanner
      -Dsonar.projectKey=$CI_PROJECT_PATH_SLUG
      -Dsonar.qualitygate.wait=true
      -Dsonar.qualitygate.timeout=300
  allow_failure: false

# ---------- 部署阶段 ----------
deploy-staging:
  stage: deploy
  image: bitnami/kubectl:1.28
  needs: [build-backend, build-frontend, unit-test-backend]
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
  environment:
    name: staging
    url: https://staging.example.com
  script:
    - echo "$KUBE_CONFIG_STAGING" | base64 -d > /tmp/kubeconfig
    - export KUBECONFIG=/tmp/kubeconfig
    - kubectl -n app set image deployment/backend backend=registry.example.com/app/backend:$CI_COMMIT_SHORT_SHA --record
    - kubectl -n app rollout status deployment/backend --timeout=180s

deploy-prod:
  stage: deploy
  image: bitnami/kubectl:1.28
  needs: [build-backend, build-frontend]
  rules:
    - if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/'
      when: manual
  environment:
    name: production
    url: https://app.example.com
  script:
    - echo "$KUBE_CONFIG_PROD" | base64 -d > /tmp/kubeconfig
    - export KUBECONFIG=/tmp/kubeconfig
    - kubectl -n app set image deployment/backend backend=registry.example.com/app/backend:$CI_COMMIT_TAG --record
    - kubectl -n app rollout status deployment/backend --timeout=300s

几个设计取舍值得说:

  • MR 流水线只跑 validate/build/test,不自动部署,避免半成品污染预发;main 合并后自动部署 staging,给人一个稳定的联调环境。
  • 生产部署是 manual + tag 触发,保留人工卡点。when: manual 配合 tag 规则,点一下就能发,但必须有人点。
  • qualitygate.wait=true 让 Sonar 质量门禁阻塞流水线,超时 300 秒。这条上了之后,新增代码的重复率从 8.3% 降到 2.1%。
  • interruptible: true 让同一分支的新流水线自动取消旧的,Runner 排队时间从平均 4 分钟降到 1 分钟内。

五、踩坑与优化

上线第一周踩了四个坑,都挺典型:

坑 1:rules 和 only/except 混用导致 MR 流水线不触发。 老配置里残留了 only: [merge_requests],和新的 rules 冲突,GitLab 16.x 会静默忽略部分规则。解决:全量清理 only/except,统一 rules,并用 CI_PIPELINE_SOURCE 而不是 CI_MERGE_REQUEST_ID 判断(后者在合并后流水线里为空)。

坑 2:needs 跨阶段导致 artifacts 找不到。 sonarqube 需要 unit-test-backend 的覆盖率报告,但两者都在 test 阶段,needs 指向同阶段任务时 GitLab 16.9 要求显式声明 artifacts: true(默认行为在 16.x 有变更)。最后我们直接让 sonar-scanner 自己重跑一遍覆盖率采集,用 -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml,多花 40 秒但省了调试时间。

坑 3:Maven cache 在 MR 和 main 之间不共享。 key: "maven-$CI_COMMIT_REF_SLUG" 导致每个分支一份 cache,Runner 磁盘一周涨了 60GB。改成 key: { files: [pom.xml] } 后,按依赖文件哈希分 cache,磁盘占用回落到 8GB,构建时间从 3 分 20 秒降到 1 分 50 秒。

坑 4:husky 在 CI 里报 not a git repository。 前面提过,HUSKY=0 解决;另外 Docker executor 拉代码用的是 GIT_STRATEGY: fetch,.git 目录是浅克隆,commitlint --from 拿不到 base SHA 时会失败,改成 GIT_DEPTH: 50 后稳定。

优化上还有两条经验:

  • 分支存活时间用机器人盯:写了个每天 9:30 跑的定时流水线,调 GitLab API 查所有 feat/* 分支的最后提交时间,超过 3 天就在群里 @ 作者并列出 MR 链接。这招比制度管用。
  • review 响应时长做成看板:用 GitLab API 拉 MR 的 created_at 和第一条 note 的 created_at,算差值,按人聚合展示。数据一公开,中位数从 19 小时掉到 4.2 小时只用了两周。

六