一、背景:代码合并像打仗,发布像赌博
先说说我们团队的情况。12个人,3个产品线,维护着同一个微服务仓库(Java Spring Boot + Vue前端),每天平均产生15-20个MR/PR。在2023年Q3之前,我们的Git使用基本靠“自觉”——有人直接在master上改,有人开feature分支但一开就是一个月不合并,Code Review形同虚设,CI只在master上跑,每次发版都是手动挑commit然后打tag。
最崩溃的一次是2023年8月,两个同事各自在master上改了同一个配置文件的同一行,结果第三个同事pull代码时直接冲突,为了解这个冲突花了整整一个下午,最后还引入了线上BUG。那次事情之后,我们决定彻底重构Git工作流。
二、环境与版本基线
首先明确我们的技术环境,版本号很重要,因为不同版本的Git和GitLab在功能上有差异:
- Git版本:2.39.2(支持
git switch和git restore命令,必须2.23+) - GitLab:15.11.3(支持Merge Request Approval Rules,即审批规则)
- Jenkins:2.414.2(Pipeline插件版本 2.9.3)
- 仓库规模:约8000+ commits,2.3GB(含历史大文件,已用BFG清理过)
- 团队成员:12人,前端4人,后端6人,测试2人
我们的核心诉求是:保证master永远可部署,同时不牺牲开发效率。
三、分支策略:双轨制设计
我们最终没有选择纯Git Flow或纯Trunk Based,而是结合两者设计了“双轨制”。
轨道A(日常开发):Trunk Based为主
所有日常开发基于main分支(我们习惯叫master,但实际是main)。每个功能/修复必须从最新的master切出短生命周期分支(不超过3天),命名规范为:
feat/[需求编号]-[简短描述] 如 feat/JIRA-1234-user-login
fix/[BUG编号]-[简短描述] 如 fix/BUG-5678-npe-error
refactor/[描述] 如 refactor/optimize-query
分支生命周期:从master切出 -> 开发 -> 提交MR -> 通过CI + Code Review -> 合并回master -> 删除分支。
轨道B(版本发布):Git Flow的release分支
每两周一个发布周期。发版前从master切出release/分支(如release/2.4.0),在release分支上只做bug修复,不允许新功能。修复后merge回master,同时打上tag(如v2.4.0)。
对于紧急线上hotfix,从对应tag切出hotfix/分支,修复后merge回master和当前release分支。
graph TD
A[master] -->|切分支| B[feat/xxx]
B -->|MR合并| A
A -->|发版前| C[release/2.4.0]
C -->|修复| C
C -->|合并| A
C -->|打tag| D[v2.4.0]
D -->|紧急修复| E[hotfix/xxx]
E -->|合并| A
E -->|合并| C
这个策略的核心思想是:master永远是可部署的,release分支是短暂的(最多存在2周),feature分支是脆弱的(随时可以被删除重建)。
四、核心实现:分支保护 + MR流程 + CI/CD集成
4.1 分支保护规则(GitLab设置)
在GitLab的Settings -> Repository -> Protected Branches中配置:
master:允许合并(Merge)但不允许直接Push。允许开发者角色Push,但会被拒绝。只有Maintainer可以Push(实际上我们强制所有变更走MR)。release/*:允许Maintainer Push,合并需要至少1个Approval。hotfix/*:允许Maintainer Push,需要至少2个Approval(因为涉及线上修复)。
4.2 MR流程与Code Review
我们强制要求:所有合并到master的MR必须通过以下检查:
- CI流水线通过(包含编译、单元测试、代码扫描)
- 至少2个Approval(其中至少1个来自非本模块的同事)
- 无冲突(GitLab会检测,有冲突必须先解决)
- 提交信息符合规范(我们通过Commitlint检查)
在GitLab的Merge Request Approval Settings中配置:
Approval rules:
- 规则1:默认规则,需要2个Approval
- 规则2:如果涉及`src/main/resources/`下的配置文件,额外需要1个运维同事的Approval
4.3 CI/CD集成:Jenkins Pipeline
这是我们的.gitlab-ci.yml核心配置(我们使用GitLab CI Runner,但逻辑和Jenkins类似,这里展示GitLab CI的写法,因为更团队友好):
# .gitlab-ci.yml
stages:
- build
- test
- scan
- deploy
variables:
MAVEN_OPTS: "-Xmx2048m"
DOCKER_REGISTRY: "registry.example.com:5000"
# 构建阶段
build:
stage: build
image: maven:3.8.7-eclipse-temurin-17
script:
- mvn clean compile -DskipTests -q
artifacts:
paths:
- target/*.jar
expire_in: 1 day
only:
- master
- release/*
- /^feat\/.*$/
# 单元测试(带覆盖率门禁)
test:
stage: test
image: maven:3.8.7-eclipse-temurin-17
script:
- mvn test
- mvn jacoco:report
coverage: '/Total.*?([0-9]{1,3})%/'
after_script:
- echo "覆盖率报告已生成"
only:
- master
- release/*
- /^feat\/.*$/
# 代码质量扫描(SonarQube)
sonar:
stage: scan
image: sonarsource/sonar-scanner-cli:5.0.1
script:
- sonar-scanner
-Dsonar.projectKey=my-service
-Dsonar.sources=src/main/java
-Dsonar.host.url=http://sonar.internal:9000
-Dsonar.login=$SONAR_TOKEN
-Dsonar.qualitygate.wait=true
-Dsonar.qualitygate.timeout=300
only:
- master
- release/*
# 部署到测试环境(仅master分支)
deploy_test:
stage: deploy
image: docker:24.0.5
services:
- docker:24.0.5-dind
script:
- docker build -t $DOCKER_REGISTRY/my-service:latest .
- docker push $DOCKER_REGISTRY/my-service:latest
- kubectl rollout restart deployment/my-service -n test
environment:
name: test
only:
- master
when: manual # 手动触发,避免每次合并都部署
这里有个关键点:only规则必须严格匹配我们的分支命名规范。releas分支走部署到预发,master走测试环境,feature分支只跑build和test(不部署,节省资源)。
4.4 提交信息规范(Commitlint + husky)
我们在每个开发者本地装husky + @commitlint/config-conventional,在package.json中配置:
{
"husky": {
"hooks": {
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS",
"pre-push": "npm run test:unit"
}
},
"commitlint": {
"extends": ["@commitlint/config-conventional"],
"rules": {
"type-enum": [2, "always", ["feat", "fix", "refactor", "chore", "test"]],
"subject-min-length": [2, "always", 10],
"subject-max-length": [2, "always", 50]
}
}
}
这样强制每个commit的message都是形如feat(JIRA-123): add user login API的格式,方便后续自动生成CHANGELOG。
五、踩坑与优化:那些文档没告诉你的
坑1:CI在feature分支上跑全量测试太慢
一开始我们对所有分支都跑全量测试,结果一个feature分支的MR等待时间长达15分钟。优化方案:区分测试级别。
# 只跑受影响的测试
test_feature:
stage: test
script:
- mvn test -Dtest.affected=only # 通过maven-surefire-plugin的增量测试插件
only:
- /^feat\/.*$/
坑2:pre-commit钩子被绕过
有些同事用--no-verify跳过husky检查。我们的解决方案是在GitLab服务端加了一个Custom Hook(利用GitLab的Server Hooks功能),在pre-receive阶段校验commit message格式,不合法直接reject。
坑3:release分支的合并冲突
每个release分支从master切出后,master上可能有新commit(其他feature提前合并了)。我们固定:release分支冻结后,master上只允许合并bugfix,不允许merge新feature。这需要PM和TL的协调。我们通过一个简单的脚本检查是否违反了规则:
# check_release_conflict.sh
#!/bin/bash
# 检查release分支与master的差异是否只包含fix类型
release_branch=$1
master_branch="master"
diff_commits=$(git log --oneline $master_branch..$release_branch --grep="^feat" --all-match)
if [ -n "$diff_commits" ]; then
echo "ERROR: release分支包含feat提交,不允许"
exit 1
fi
六、效果数据:用数字说话
改造后运行了3个月,我们统计了以下数据:
- 代码冲突:从每周平均8次降至每周1次以内(主要发生在配置文件)
- MR等待时长:从平均4小时降至40分钟(因为CI快了,且Review规则更明确)
- 发布周期:从每两周一次(且经常延期)变为每周五固定发布,且当天完成
- 线上BUG:从每月5-6个降至1-2个(这和Code Review质量提升直接相关)
- CI资源消耗:通过增量测试,从每月消耗120小时CPU降至45小时
七、总结与建议
这套双轨制Git工作流我们已经跑了3个季度,最大的感受是:流程不是为了限制开发,而是为了把人的精力从机械冲突中解放出来。
如果你的团队在10-20人规模,维护1-3个生产仓库,这套方案可以直接复制。如果团队更小(5人以下),可以砍掉release分支,直接用Trunk Based + tag发布。如果更大(30+人),可能需要引入更细粒度的模块拆分。
最后给几个关键建议:
- 分支保护必须强制,不要相信自觉
- CI要快,超过10分钟的流水线没人愿意等,宁愿省资源也不要慢
- Review规则要具体,不要只写“需要2人审批”,要写“哪些文件变更需要谁审批”
- 工具链要统一,全组必须用同一个Git客户端(我们统一用IntelliJ IDEA内置Git,避免命令行和GUI混用导致的问题)
Git工作流不是银弹,但它绝对是最廉价的工程效率投资。现在每次合并代码都变成了一件“无感”的事情,这才是我们想要的状态。