一、问题背景:当仓库变成“垃圾场”
我们团队当时是15个人,3条产品线共用一个Git仓库。刚接手的时候,主分支 master 上最后一次提交是三个月前,实际在跑的是某个同事本地的 dev-new 分支——对,就是那种只有他自己电脑上存在的分支。发布靠“谁打包谁负责”,回滚靠“把上次那个压缩包再传一遍”。
具体症状很典型:
- 分支数量峰值达到137个,其中89个超过30天无提交,没人敢删;
- 合并冲突平均每周3.2次,每次解决耗时40分钟以上;
- 线上事故回溯时,发现某个修复只存在于
hotfix-xxx分支,从未合回主干; - Code Review形同虚设,MR平均只有0.7条评论,多数是“+1”。
这不是工具问题,是流程问题。我们用了两周时间重新设计工作流,落地后跑了半年,数据后面会给。先说结论:分支策略要短、Review要有强制卡点、CI要快到让人愿意等。
二、环境与版本
先把家底交代清楚,避免配置对不上:
- Git:2.34.1(服务端 GitLab 14.9.5,自建)
- GitLab Runner:14.10.0,Docker executor
- Jenkins:2.346.2(仅用于需要物理机环境的iOS/Android打包)
- Node.js:16.14.2(前端),JDK 11(后端)
- 代码规范:ESLint 8.14.0 + Prettier 2.6.2,后端用 Checkstyle 9.3
- 分支保护:GitLab Protected Branches + Push Rules
选GitLab而不是GitHub,纯粹因为自建成本和内网隔离要求,配置逻辑两者是通的。
三、方案设计:主干开发 + 短分支 + 强制门禁
我们最终采用的是 Trunk-Based Development 的变体,不是标准的Git Flow。Git Flow的 develop 分支在我们这种持续部署节奏下纯属累赘——多一层合并就多一层冲突。
分支模型定义如下:
| 分支类型 | 命名规则 | 生命周期 | 保护级别 |
|---|---|---|---|
| 主干 | main |
永久 | 禁止直接push,需MR+2人approve |
| 特性 | feat/- |
≤3天 | 无 |
| 修复 | fix/- |
≤1天 | 无 |
| 紧急热修 | hotfix/ |
≤4小时 | 仅Tech Lead可建 |
关键约束有三条,写进了团队规范:
- 特性分支存活不超过3个工作日。超过就拆,拆不了说明需求粒度有问题。
- 每天至少 rebase 一次 main。用
git pull --rebase,禁止在特性分支上 merge main。 - main 永远可发布。任何合并进main的代码必须通过完整CI。
为什么用 rebase 而不是 merge?因为 merge 会在特性分支上制造大量无意义的合并提交,rebase 后历史是一条直线,git log --graph 看着舒服,出问题用 git bisect 也好定位。代价是 rebase 后要 push --force-with-lease,这个后面踩坑部分会讲。
Code Review 流程设计:
- 所有变更走 Merge Request,
main分支设置 Protected,关闭 "Allow force push",开启 "Require approval from 2 approvers"; - MR 描述模板强制填写变更类型、影响范围、回滚方案;
- 每个MR至少1个同模块owner + 1个跨模块reviewer;
- CI流水线中
lint和unit-test作为 required status check,不绿不能合。
CI/CD 分两段:GitLab CI 负责 lint/test/build/镜像构建,Jenkins 负责需要真机或特殊SDK的打包,通过 webhook 触发。
四、核心实现:可运行的配置
4.1 .gitlab-ci.yml
这是我们的主流水线,剪掉了业务相关的部署脚本,保留通用骨架:
stages:
- validate
- test
- build
- package
variables:
NODE_IMAGE: node:16.14.2-alpine
DOCKER_DRIVER: overlay2
GIT_DEPTH: "20"
# 缓存 node_modules,key 用 lock 文件哈希,避免缓存污染
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
- .eslintcache
lint:
stage: validate
image: $NODE_IMAGE
script:
- npm ci --prefer-offline --no-audit
- npx eslint . --ext .js,.ts --cache --max-warnings 0
- npx prettier --check "src/**/*.{js,ts,json,md}"
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main"'
unit-test:
stage: test
image: $NODE_IMAGE
script:
- npm ci --prefer-offline --no-audit
- npm run test:unit -- --coverage --ci
coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
artifacts:
when: always
reports:
junit: reports/junit.xml
cobertura: reports/coverage/cobertura-coverage.xml
expire_in: 7 days
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main"'
build-image:
stage: build
image: docker:20.10.16
services:
- docker:20.10.16-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin $CI_REGISTRY
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
# 合并进入 main 后触发 Jenkins 做真机打包
trigger-jenkins-package:
stage: package
image: curlimages/curl:7.83.1
script:
- >
curl -X POST "$JENKINS_URL/job/app-package/buildWithParameters"
--user "$JENKINS_USER:$JENKINS_TOKEN"
--data "GIT_COMMIT=$CI_COMMIT_SHA&BRANCH=main"
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: on_success
几个参数值得说明:
GIT_DEPTH: "20":浅克隆只拉20个提交,流水线拉代码从平均28秒降到6秒;--max-warnings 0:ESLint警告也当错误,避免“攒着以后修”;cache.key.files:用package-lock.json的哈希做缓存key,依赖一变自动失效,比按分支缓存干净得多;coverage正则:GitLab靠它提取覆盖率数字,写错就显示不出来。
4.2 本地 pre-push 钩子
CI卡得再严,也不如本地先拦一道。我们在 .githooks/pre-push 放了脚本,通过 git config core.hooksPath .githooks 启用(钩子文件进版本库,全组统一):
#!/bin/sh
# .githooks/pre-push
set -e
protected_branch="main"
current_branch=$(git symbolic-ref --short HEAD)
if [ "$current_branch" = "$protected_branch" ]; then
echo "✗ 禁止直接 push 到 $protected_branch,请走 Merge Request。"
exit 1
fi
echo "→ 检查分支落后情况..."
git fetch origin "$protected_branch" --quiet
behind=$(git rev-list --count HEAD..origin/$protected_branch)
if [ "$behind" -gt 0 ]; then
echo "⚠ 你的分支落后 origin/$protected_branch $behind 个提交,建议先 rebase:"
echo " git pull --rebase origin $protected_branch"
# 只警告不阻断,避免阻塞紧急提交
fi
echo "→ 本地 lint 快检..."
npx eslint --lint-staged 2>/dev/null || npx eslint src --ext .js,.ts --quiet
echo "✓ pre-push 检查通过"
这个脚本拦截了大概每周5~8次误操作,主要是新人直接往main推。注意落后检测只警告不阻断——阻断会让紧急修复很难受,实践下来警告已经足够。
4.3 MR 模板
.gitlab/merge_request_templates/default.md:
## 变更类型
- [ ] feature - [ ] bugfix - [ ] hotfix - [ ] refactor
## 关联工单
Closes #
## 变更说明
## 影响范围
## 回滚方案
## 自测清单
- [ ] 本地单测通过
- [ ] 已 rebase 最新 main
- [ ] 无调试代码残留(console.log/debugger)
模板看着形式主义,但“回滚方案”这一栏真的救过我们两次——有次上线后发现定时任务重复执行,因为MR里写了“关闭feature flag cron_v2 即可回滚”,5分钟就止血了。
五、踩坑与优化
坑1:--force-with-lease 也会翻车。 rebase 后强推是常态,但有同事在别人刚push后又rebase,--force-with-lease 检测到远程有变化直接拒绝,他改成 --force 覆盖了别人的提交。后来规定:特性分支只允许本人push,且push前必须 git fetch,同时在GitLab对该分支开启 "Prevent force push" 的白名单机制(仅创建者本人可强推)。
坑2:CI缓存把构建搞挂了。 早期用 cache:key: $CI_COMMIT_REF_SLUG,结果 node_modules 里混进了不同Node版本编译的原生模块,构建随机失败。改成按 package-lock.json 哈希后彻底解决。
坑3:覆盖率门禁太激进。 一开始设了“覆盖率低于80%阻断合并”,结果老代码模块根本达不到,大家开始写无意义的断言凑数。后来改成 增量覆盖率:只要求新增代码行覆盖率≥70%,用 diff-cover 实现,配合 git diff origin/main...HEAD。这个改动让MR通过率从58%回升到91%,且没人再凑数。
坑4:Jenkins触发重复打包。 GitLab CI 的 trigger-jenkins-package 在重试流水线时会重复触发。加了幂等判断:Jenkins 侧先查 $GIT_COMMIT 是否已构建过,用 builds/lastSuccessfulBuild 的 revision 比对,重复则直接跳过。
六、效果数据
跑满6个月后的对比(数据来自GitLab API统计 + 团队周会记录):
| 指标 | 改造前 | 改造后 | 变化 |
|---|---|---|---|
| 活跃分支数 | 137 | 11 | -92% |
| 周均合并冲突 | 3.2次 | 1.2次 | -62% |
| MR平均评审时长 | 18小时 | 4.5小时 | -75% |
| CI流水线平均耗时 | — | 3分42秒 | — |
| 因版本问题导致的线上事故 | 季度4起 | 季度1起 | -75% |
| 紧急回滚平均耗时 | 35分钟 | 8分钟 | -77% |
CI耗时3分42秒这个数字很关键——我们内部的目标是“5分钟内出结果”,因为超过5分钟开发者就会切走去干别的,评审反馈循环就断了。为此把单测从全量跑改成只跑受影响的包(用 jest --onlyChanged),lint加了 .eslintcache,这两个优化省了将近2分钟。
七、总结
回头看,这套工作流能落地,靠的不是配置多花哨,而是三条硬约束:分支活不过3天、main不可直推、CI不绿不合。配置只是把约束固化下来。真正难的是让团队接受“小步提交、频繁rebase”的习惯,前两周确实有人抱怨麻烦,但当一个MR从提交到合并只花20分钟、回滚只需要点一下feature flag的时候,抱怨就没了。
如果你们团队也在治理Git混乱,建议从“保护main + 强制MR”这两步开始,别一上来就搞全套CI/CD,先让流程跑通,再逐步加门禁。分支策略没有银弹,Trunk-Based适合我们这种发布频繁的团队,但如果是版本制交付(比如客户端SDK),Git Flow的release分支还是有它的价值。
最后一句:别让分支成为“不敢删”的技术债。每周五花10分钟清理超期分支,比什么都管用。