一、问题背景:当5人团队变成30人
2023年初我们团队只有5个人,用最朴素的Git Flow:master + develop + feature/* + release/* + hotfix/*。那时候一周发一次版,冲突靠吼,合并靠手,没人觉得有问题。
到2023年Q4,团队扩到30人,同时跑4条产品线。问题集中爆发:
develop分支长期落后master,每次release合并要解几十个冲突,最长一次解了整整两天- feature分支生命周期平均11天,最长的拖了3周,rebase一次等于重写一遍
- 没有强制的Code Review,有人直接push到
develop,线上出过两次P0 - CI只在合并后跑,测试环境经常被半成品代码污染
数据很直白:发布周期中位数从3天涨到14天,合并冲突率(有冲突的MR占比)37%,线上事故从每月0.5次涨到2.3次。
我们决定重构Git工作流。目标:发布周期回到3天内,冲突率降到10%以下,所有合并必须过CI+Review。
二、环境与版本
先交代工具链,避免配置对不上:
- Git:2.43.0(用到了
git switch、git restore、merge.conflictStyle=zdiff3) - GitLab:16.9.1(自建,CE版)
- GitLab Runner:16.9.1,Docker executor
- GitHub Actions:仅用于开源仓库的镜像同步
- 预提交工具:pre-commit 3.6.2
- 代码检查:ESLint 8.57.0、golangci-lint 1.56.2(多语言团队)
三、方案设计:主干开发 + 短生命周期分支
我们最终选的不是纯Trunk-Based,也不是纯Git Flow,而是一个折中模型,内部叫“Trunk + Release Branch”:
main ──●──●──●──●──●──●──●──●──●──●──●──●── (始终可发布)
\ \ \
\ \ release/2.3 ──●──● (仅cherry-pick hotfix)
\ \
feat/A feat/B (生命周期≤3天)
核心规则:
main永远可发布。任何合并进main的提交都必须通过完整CI。- feature分支生命周期≤3天。超过3天必须拆分或转为spike分支(不合并)。
- release分支只用于hotfix。正常发布直接从
main打tag,不再维护长期develop。 - 所有合并走Merge Request(MR),禁止直接push到
main。 - 至少1个Code Owner approve + CI全绿才能合并。
分支命名规范:
feat/-,如feat/PROJ-123-add-oauthfix/-hotfix/-,从release分支切出
四、核心实现:配置与代码
4.1 分支保护与CODEOWNERS
GitLab的Protected Branch配置(Settings → Repository → Protected branches):
main:Allowed to merge = Maintainers,Allowed to push = No onerelease/*:Allowed to merge = Maintainers,Allowed to push = No one
CODEOWNERS文件放在仓库根目录:
# .gitlab/CODEOWNERS
# 默认owner
* @backend-lead @frontend-lead
# 支付模块必须支付组review
/src/payment/ @payment-team
# CI配置变更必须SRE review
/.gitlab-ci.yml @sre-team
/ci/ @sre-team
# 数据库迁移必须DBA review
/migrations/ @dba-team
这样只要MR碰到/src/payment/,@payment-team自动被加为reviewer,且他们的approve是必须的。
4.2 GitLab CI多环境流水线
这是我们的.gitlab-ci.yml,跑在GitLab 16.9上,用到了rules、needs、environment:
# .gitlab-ci.yml
stages:
- lint
- test
- build
- deploy-staging
- deploy-prod
variables:
GO_VERSION: "1.22.1"
NODE_VERSION: "20.11.1"
DOCKER_DRIVER: overlay2
default:
image: golang:${GO_VERSION}
cache:
key:
files:
- go.sum
paths:
- .go-cache/
before_script:
- export GOPATH=$CI_PROJECT_DIR/.go-cache
- export PATH=$GOPATH/bin:$PATH
lint:
stage: lint
script:
- go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.56.2
- golangci-lint run --timeout=5m ./...
rules:
- if: $CI_MERGE_REQUEST_ID
- if: $CI_COMMIT_BRANCH == "main"
test:
stage: test
script:
- go test -race -coverprofile=coverage.out -covermode=atomic ./...
- go tool cover -func=coverage.out | tail -1
coverage: '/total:\s+\(statements\)\s+(\d+\.\d+)%/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
rules:
- if: $CI_MERGE_REQUEST_ID
- if: $CI_COMMIT_BRANCH == "main"
build:
stage: build
image: docker:24.0.5
services:
- docker:24.0.5-dind
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
rules:
- if: $CI_COMMIT_BRANCH == "main"
deploy-staging:
stage: deploy-staging
image: bitnami/kubectl:1.29
environment:
name: staging
url: https://staging.example.com
script:
- kubectl set image deployment/app app=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA -n staging
- kubectl rollout status deployment/app -n staging --timeout=120s
rules:
- if: $CI_COMMIT_BRANCH == "main"
needs: ["build"]
deploy-prod:
stage: deploy-prod
image: bitnami/kubectl:1.29
environment:
name: production
url: https://app.example.com
script:
- kubectl set image deployment/app app=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA -n prod
- kubectl rollout status deployment/app -n prod --timeout=180s
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
when: manual
needs: ["build"]
关键点:
- MR阶段只跑
lint和test,不build不deploy,节省Runner资源 main分支跑完整流水线,自动部署staging- 生产部署由tag触发(
v1.2.3格式),且when: manual需要人工点确认 - 用
needs做DAG,deploy-staging不用等lint重跑
4.3 pre-commit钩子:把问题挡在本地
.pre-commit-config.yaml:
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-merge-conflict
- id: check-yaml
- id: detect-private-key
- repo: https://github.com/golangci/golangci-lint
rev: v1.56.2
hooks:
- id: golangci-lint
args: [--timeout=3m, --fast]
- repo: local
hooks:
- id: branch-name-check
name: Check branch naming
entry: bash -c 'git rev-parse --abbrev-ref HEAD | grep -E "^(feat|fix|hotfix|chore|release)/" || (echo "分支名必须符合 feat/ fix/ hotfix/ chore/ release/ 前缀" && exit 1)'
language: system
pass_filenames: false
这个branch-name-check是我们自己加的,防止有人图省事直接叫test、tmp。
五、踩坑与优化
坑1:rebase丢提交
早期我们要求feature分支每天git rebase main保持同步。有次一个同事在rebase时误用了--skip,把一个关键提交跳过了,push时又用了--force,结果那个提交彻底消失,直到上线才发现支付回调少了逻辑。
解决:禁止在共享分支上--force。feature分支可以用--force-with-lease,且rebase前必须git log --oneline main..HEAD确认提交列表。后来我们干脆改成默认用merge main而不是rebase,虽然历史难看点,但不会丢东西。
坑2:tag错位
有次生产部署触发了v2.3.1的tag,但实际部署的镜像SHA是v2.3.0的。原因是打tag的人在有未合并提交的分支上打了tag,CI拿到的是旧SHA。
解决:tag只能由CI在main分支上自动打,禁止人工打tag。在流水线里加:
create-tag:
stage: build
script:
- |
if [ "$CI_COMMIT_BRANCH" = "main" ] && [ -f VERSION ]; then
VERSION=$(cat VERSION)
if ! git ls-remote --tags origin "v$VERSION" | grep -q "v$VERSION"; then
git tag "v$VERSION" $CI_COMMIT_SHA
git push origin "v$VERSION" -o ci.skip
fi
fi
rules:
- if: $CI_COMMIT_BRANCH == "main"
坑3:CI缓存污染
go.sum没变但go.mod变了的时候,cache key没变,导致拉到了旧依赖。后来cache key改成同时hashgo.mod和go.sum,问题消失。
优化:MR模板强制填写
.gitlab/merge_request_templates/default.md:
## 变更类型
- [ ] feature
- [ ] bugfix
- [ ] hotfix
- [ ] refactor
## 关联Ticket
PROJ-
## 测试情况
- [ ] 单测已补充
- [ ] 本地验证通过
- [ ] 需要QA介入
## 风险点
配合CI里的检查:MR描述为空或没勾选测试情况,流水线直接fail。
六、效果数据
迁移后跑了3个月(2024年1月-3月),对比之前3个月:
| 指标 | 迁移前 | 迁移后 |
|---|---|---|
| 发布周期中位数 | 14天 | 2.5天 |
| 合并冲突率 | 37% | 6% |
| MR平均Review时长 | 18小时 | 4.2小时 |
| 线上事故/月 | 2.3次 | 0.7次 |
| CI平均耗时(MR) | 11分钟 | 4.5分钟 |
| feature分支平均生命周期 | 11天 | 2.1天 |
几个关键数字的来源:发布周期取的是从第一个feature提交到tag的日历天中位数;冲突率是GitLab API统计的有conflict的MR占比;CI耗时是Runner日志里的p50。
总结
这套工作流的核心不是某个工具,而是三条铁律:
main永远可发布,所有质量门禁卡在合并前,不是合并后。- 分支生命周期要短,超过3天的feature一定出问题,要么拆要么砍。
- 自动化能做的事不要靠人,tag、部署、reviewer分配全部交给配置。
如果你团队还在用Git Flow且人数超过15,建议认真考虑迁移。迁移成本主要在习惯,不在工具——我们真正花时间的是让所有人接受“不rebase、不force push、不直接推main”这三件事。工具配置一天就写完了。
最后贴一下我们用的git config,减少日常操作噪音:
git config --global merge.conflictStyle zdiff3
git config --global rebase.autoStash true
git config --global pull.rebase false
git config --global push.default current
git config --global alias.lg "log --oneline --graph --decorate --all"
git config --global alias.undo "reset --soft HEAD~1"
pull.rebase false是故意的,我们鼓励merge而不是rebase,避免新人踩坑。