一、问题背景:当仓库变成“垃圾场”

我们团队当时是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可建

关键约束有三条,写进了团队规范:

  1. 特性分支存活不超过3个工作日。超过就拆,拆不了说明需求粒度有问题。
  2. 每天至少 rebase 一次 main。用 git pull --rebase,禁止在特性分支上 merge main。
  3. 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分钟清理超期分支,比什么都管用。