一、问题背景:当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 switchgit restoremerge.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天)

核心规则:

  1. main永远可发布。任何合并进main的提交都必须通过完整CI。
  2. feature分支生命周期≤3天。超过3天必须拆分或转为spike分支(不合并)。
  3. release分支只用于hotfix。正常发布直接从main打tag,不再维护长期develop
  4. 所有合并走Merge Request(MR),禁止直接push到main
  5. 至少1个Code Owner approve + CI全绿才能合并。

分支命名规范:

  • feat/-,如feat/PROJ-123-add-oauth
  • fix/-
  • hotfix/-,从release分支切出

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

4.1 分支保护与CODEOWNERS

GitLab的Protected Branch配置(Settings → Repository → Protected branches):

  • main:Allowed to merge = Maintainers,Allowed to push = No one
  • release/*: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上,用到了rulesneedsenvironment

# .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阶段只跑linttest,不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是我们自己加的,防止有人图省事直接叫testtmp

五、踩坑与优化

坑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.modgo.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。

总结

这套工作流的核心不是某个工具,而是三条铁律:

  1. main永远可发布,所有质量门禁卡在合并前,不是合并后。
  2. 分支生命周期要短,超过3天的feature一定出问题,要么拆要么砍。
  3. 自动化能做的事不要靠人,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,避免新人踩坑。