一、问题背景:三个人也能把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 }}"
几个刻意的设计点:
concurrency+cancel-in-progress:同一个PR连续push时,取消旧的运行。之前有次一个PR推了6次,6条流水线并行跑,白烧了20多分钟runner时间。- build和test拆成两个job:build只跑编译+Checkstyle+单测,5分钟内出结果,让开发者快速拿到反馈;test跑集成测试,慢但必要。Branch Protection里两个都要求通过。
fetch-depth: 0:后面如果要用git diff做增量检查(比如只对改动文件跑SpotBugs)需要完整历史,先留着。- 缓存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钩子改改分支名就能用。有问题欢迎评论区聊。