一、问题背景:我们为什么把 Git Flow 拆了
先说结论:不是 Git Flow 本身有问题,是我们团队的规模和发布节奏不匹配它。
2023 年下半年,我们团队从 12 人扩到 30 人,后端 14 人、前端 10 人、测试 4 人、运维 2 人。当时用的是标准 Git Flow:master、develop、feature/*、release/*、hotfix/* 五类分支齐全。听起来很规范,实际跑起来是这样的:
develop分支长期领先master200+ 个提交,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 个带
dockertag 跑构建,4 个shelltag 跑部署 - 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 人团队还是激进),而是取了中间形态,核心三条:
- 主干唯一且随时可发布:
main是唯一长期分支,任何时刻main的 HEAD 都必须能通过完整流水线并可直接部署到预发。 - 短分支 + 强制短生命周期:所有开发走
feat/、fix/、chore/前缀分支,从main切出,存活不超过 3 个工作日,超过就拆。分支命名带 Jira 号:feat/PROJ-1234-order-export。 - 发布靠 Tag 不靠分支:发布时在
main上打v2.8.0语义化 tag,流水线监听 tag 触发生产部署。彻底消灭release/*和hotfix/*分支——线上紧急修复也是从main切fix/分支,走同样流程,只是加了urgentlabel 走快速通道(跳过部分非阻塞检查)。
分支保护规则(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 小时只用了两周。