一、问题背景:三个人也能把Git用成一锅粥

先说结论:团队小不代表Git工作流可以省。我们团队三个人,后端服务用Spring Boot 3.2 + JDK 21,仓库托管在GitHub,2024年初的状态是这样的:

  • 所有人直接在develop上git commit,然后git push,谁先push谁赢;
  • 没有PR,没有Review,代码质量靠自觉;
  • 发布靠手动mvn package然后scp到服务器;
  • 每周平均4~5次合并冲突,最惨的一次是两个人同时改了同一个Mapper XML,冲突解决了半小时,还漏了一个字段;
  • 线上回滚过一次,从发现问题到回滚完成47分钟,因为没人记得上次的tag是哪个commit。

真正让我下决心改的是一次事故:A同学在本地跑测试时把application-dev.yml里的数据库连接改成了自己的本地地址,直接push到develop,CI没跑(当时就没CI),第二天B同学发布时把这个配置带上了预发环境,排查了2小时。

所以这套工作流的核心目标很明确:隔离开发、强制审查、自动化验证。

二、环境与版本

先列清楚环境,避免抄配置时对不上:

组件 版本/配置
Git 2.43.0
托管平台 GitHub(Team Plan)
CI/CD GitHub Actions
JDK Temurin 21.0.2
构建工具 Maven 3.9.6
分支保护 GitHub Branch Protection Rules
代码规范检查 Checkstyle 10.12.5 + SpotBugs 4.8.3

三、方案设计:分支模型与合并策略

我们最终采用的是简化版Git Flow,别被"简化"两个字骗了,规则其实很硬:

分支定义:

  • main:生产分支,只接受来自release/*和hotfix/*的合并,每次合并打tag(v1.4.2这种格式);
  • develop:集成分支,只接受PR合并,禁止直接push;
  • feature/*:功能分支,命名feature/ORD-1234-order-split,关联Jira单号,生命周期不超过5天;
  • release/*:发布分支,从develop切出,只修bug不加功能;
  • hotfix/*:从main切出,修完同时合回main和develop。

关键决策:合并策略统一用Squash Merge。 理由是feature分支上的"fix typo"、"再改一下"这类commit对主干毫无价值,Squash之后develop上每个commit对应一个PR,git log一眼能看懂。代价是丢失分支内的细粒度历史,但我们认为值得。

分支保护规则(main和develop都开):

  • 禁止直接push(Require a pull request before merging);
  • 至少1个Approve(Required approvals: 1);
  • 要求状态检查通过(Require status checks to pass),勾选CI里的build和test两个job;
  • 要求分支是最新的(Require branches to be up to date before merging)——这条会逼开发者先rebase,冲突在本地解决而不是在合并时;
  • 禁止force push,禁止删除分支。

最后一条"要求分支最新"是多花了点等待时间,但把冲突发现的时间点从"合并那一刻"提前到了"push那一刻",这是冲突率下降的主要原因之一。

四、核心实现:CI/CD配置与Review流程

4.1 GitHub Actions 流水线

仓库根目录 .github/workflows/ci.yml,这是实际在跑的版本(去掉了一些内部通知的步骤):

name: CI

on:
  pull_request:
    branches: [develop, main]
  push:
    branches: [develop, main]

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  build:
    name: build
    runs-on: ubuntu-22.04
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '21'
          cache: maven

      - name: Compile & Checkstyle
        run: mvn -B -q compile checkstyle:check -Dcheckstyle.config.location=config/checkstyle.xml

      - name: Unit Tests
        run: mvn -B test -Dspring.profiles.active=test

      - name: Upload test report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: surefire-reports
          path: target/surefire-reports/
          retention-days: 7

  integration-test:
    name: test
    needs: build
    runs-on: ubuntu-22.04
    timeout-minutes: 25
    services:
      mysql:
        image: mysql:8.0.36
        env:
          MYSQL_ROOT_PASSWORD: root
          MYSQL_DATABASE: order_test
        ports: ['3306:3306']
        options: >-
          --health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -proot"
          --health-interval=5s
          --health-timeout=3s
          --health-retries=10
    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '21'
          cache: maven

      - name: Integration Tests
        env:
          SPRING_DATASOURCE_URL: jdbc:mysql://127.0.0.1:3306/order_test?useSSL=false
          SPRING_DATASOURCE_USERNAME: root
          SPRING_DATASOURCE_PASSWORD: root
        run: mvn -B verify -DskipUnitTests=false -Pit

      - name: Package
        if: github.event_name == 'push' && github.ref == 'refs/heads/develop'
        run: mvn -B -DskipTests package

      - name: Build Docker Image
        if: github.event_name == 'push' && github.ref == 'refs/heads/develop'
        run: |
          docker build -t order-service:${{ github.sha }} .
          echo "Image built: order-service:${{ github.sha }}"

几个刻意的设计点:

  1. concurrency + cancel-in-progress:同一个PR连续push时,取消旧的运行。之前有次一个PR推了6次,6条流水线并行跑,白烧了20多分钟runner时间。
  2. build和test拆成两个job:build只跑编译+Checkstyle+单测,5分钟内出结果,让开发者快速拿到反馈;test跑集成测试,慢但必要。Branch Protection里两个都要求通过。
  3. fetch-depth: 0:后面如果要用git diff做增量检查(比如只对改动文件跑SpotBugs)需要完整历史,先留着。
  4. 缓存Maven:cache: maven是setup-java自带的,配合~/.m2,冷启动构建从4分12秒降到1分38秒。

4.2 本地pre-push钩子:把冲突挡在push之前

CI再快也是push之后的事,我们在本地加了pre-push钩子,强制rebase到最新develop:

#!/bin/sh
# .git/hooks/pre-push
remote="$1"
branch=$(git rev-parse --abbrev-ref HEAD)

case "$branch" in
  feature/*|hotfix/*)
    echo ">>> 检查是否落后于 origin/develop ..."
    git fetch origin develop --quiet
    behind=$(git rev-list --count HEAD..origin/develop)
    if [ "$behind" -gt 0 ]; then
      echo "!! 你的分支落后 origin/develop $behind 个commit,请先执行:"
      echo "   git rebase origin/develop"
      exit 1
    fi
    ;;
esac
exit 0

这个钩子用git config core.hooksPath .githooks统一管理,新同事clone完跑一条命令就装好。效果很直接:以前是合并时才发现冲突,现在是push前就被拦下来。

4.3 Code Review流程

规则不多,但每条都执行:

  • PR必须关联Jira单号,标题格式[ORD-1234] 订单拆单逻辑,模板里强制填写"改动说明"和"测试方式";
  • 单PR改动不超过400行(用danger之类的工具可以自动检查,我们暂时靠人盯),超过就拆。400行是我拍的数字,实践下来Review时长能控制在30分钟内;
  • Reviewer 24小时内必须响应,三个人轮流值班,当天值班的人负责第一轮;
  • Approve之后不允许再push,GitHub的分支保护里开了Dismiss stale pull request approvals when new commits are pushed,避免"审完又偷偷改";
  • 合并前必须rebase,Squash Merge时GitHub会自动处理,但我们要求本地先rebase一遍,减少服务端冲突。

Review关注什么?我们约定只看三件事:正确性(边界条件、并发)、可读性(命名、过长的函数)、是否有测试覆盖。风格问题交给Checkstyle,别在PR里吵缩进。

五、踩坑与优化

踩过的坑比配置本身更值得写:

坑1:Squash Merge导致git blame找不到人。 Squash之后commit author是PR发起人,但具体某行是谁写的看不出来了。解决:Squash时的commit message里保留PR号,配合GitHub的blame视图能跳回PR,勉强够用。如果团队更看重追溯性,建议用Rebase Merge。

坑2:Require branches to be up to date让合并变慢。 develop一天合并七八个PR,每个PR合并后其他PR都变"落后",得重新rebase。我们的缓解办法是把合并集中在每天上午11点和下午5点两个时间窗口,减少反复rebase。代价是发布节奏变慢,但换来的是冲突在本地解决,值得。

坑3:集成测试偶发失败。 MySQL service容器启动偶尔超时,health-retries: 10还是不够。后来把health-interval从10s改成5s、retries提到10,失败率从大约8%降到1%以下。另一个是测试之间的数据污染,加了@Transactional + @Rollback之后稳定了。

坑4:CI runner时间超预算。 一开始每个PR都跑全量集成测试,一个月跑了1200多分钟,超出免费额度。优化:paths-ignore跳过纯文档改动,concurrency取消旧运行,Maven缓存。现在平均每个PR消耗约6分钟,月均450分钟。

坑5:hotfix绕过流程。 有次线上紧急问题,直接push了main。后来加了规则:hotfix分支也必须走PR,但允许Required approvals: 0(通过hotfix/*的规则例外),保留CI检查。紧急不等于不检查。

六、效果数据

改造前后对比(统计周期各3个月):

指标 改造前 改造后
周均合并冲突次数 4.6 1.1
主分支构建成功率 82% 99.3%
PR平均Review时长 无Review 3.2小时
平均回滚耗时 47分钟 8分钟(有tag+镜像)
线上配置类事故 2次/季 0次
CI月均runner分钟 0(无CI) 450分钟

合并冲突下降约76%,构建成功率提升17个百分点。最有价值的其实不是数字,而是"改坏了CI会拦"这件事带来的心理安全感——现在没人怕push了。

七、总结

这套工作流没什么高深的东西,核心就三条:分支短命、合并前必须绿、Review必须有人看。三个人团队最容易犯的错是觉得"人少不用流程",但恰恰是人少,一次事故的恢复成本更高。

如果你们团队也在类似规模,建议按这个顺序落地:先开分支保护+强制PR(成本最低、收益最大),再加CI(build job先上,集成测试后补),最后加本地钩子和Squash策略。别一上来就搞全套,流程太重没人遵守反而更糟。

配置都在上面了,ci.yml可以直接抄,pre-push钩子改改分支名就能用。有问题欢迎评论区聊。