一、问题背景:我们是怎么把master搞成一锅粥的
先说结论:小团队最容易犯的错,不是不会用Git,而是“把Git当网盘用”。我们团队9个人,后端6、前端2、测试1,2023年上半年之前的工作流基本是这样的:
- 所有人本地
git checkout master && git pull,然后直接改代码; - 改完
git commit -m "fix"、git push origin master; - 谁push得快谁先上,冲突了就在本地merge一把再push;
- 上线靠“谁记得谁手动打tag”。
结果很典型:master上经常出现半成品代码;一次发布前发现某个同事把调试用的 console.log 和临时开关一起推上去了;回滚只能靠 git revert 一串commit,越滚越乱。最夸张的一周,我们统计了一下:master分支上直接push的commit有47个,其中带“fix”“update”“临时”字样的占63%,合并冲突平均每周6.2次,每次平均处理35分钟。
真正让我们下决心改的是一次线上事故:一个未完成的功能被误合并进发布分支,导致订单接口返回500持续了22分钟。事后复盘,问题不在代码本身,而在于“没有分支边界、没有评审、没有流水线拦截”。
所以这篇文章不是讲Git命令大全,而是讲我们怎么用分支策略 + Code Review + CI/CD把流程卡住,并且给出能直接抄的配置。
二、环境与版本
先把家底报清楚,避免“抄了配置跑不起来”:
- Git:2.40.1(服务端GitLab 16.4 / 客户端macOS + Windows混用)
- 代码托管:GitHub Enterprise Cloud(也兼容GitLab,差异点我会标注)
- CI/CD:GitHub Actions(runner:ubuntu-22.04,2C4G)
- 代码质量:SonarQube 9.9 LTS + sonar-scanner 5.0
- 分支保护:GitHub Branch Protection Rules + Rulesets
- 通知:Slack Incoming Webhook
选择Git 2.40的原因很简单:git switch/git restore已经稳定,merge.conflictStyle=zdiff3在2.35+可用,能显著减少冲突阅读成本。我们统一在 ~/.gitconfig 里加了:
[init]
defaultBranch = main
[merge]
conflictStyle = zdiff3
[pull]
rebase = true
[core]
autocrlf = input
这里 pull.rebase=true 是个态度问题:我们要求开发在同步主干时用rebase保持线性历史,避免“merge commit满天飞”。当然,公共分支之间的合并仍然用merge,这个后面讲。
三、方案设计:分支模型与保护规则
我们最终采用的是“简化版Git Flow + 主干保护”,不是原教旨Git Flow(那套develop/release/hotfix对9人团队太重)。具体分支:
main:唯一长期分支,永远可发布,受保护,禁止直接push;feature/*:功能分支,从main切出,命名feature/ORD-123-order-timeout;fix/*:普通缺陷修复,从main切出;hotfix/*:线上紧急修复,从main切出,合并回main后立即打tag;release/*:可选,仅当一次发布包含多个feature且需要冻结时创建,生命周期不超过3天。
规则上我们定了三条硬约束:
- 任何进入main的代码必须经过PR,且至少1个Approve(核心模块2个);
- PR必须通过CI的4个必检项:lint、unit test、build、sonar quality gate;
- main分支要求“Require branches to be up to date before merging”,即合并前必须与main同步。
这里有个细节值得说:我们没有强制“squash merge”,而是允许“Squash and merge”和“Rebase and merge”,禁止“Merge commit”。原因是小团队PR通常只有1-3个commit,squash能保持main线性;但如果一个PR本身是多人协作,rebase更合适。禁止merge commit是为了让 git log --oneline main 可读。
分支保护的具体配置(GitHub Ruleset JSON,可直接导入):
{
"name": "protect-main",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": {
"include": ["refs/heads/main"],
"exclude": []
}
},
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{
"type": "pull_request",
"parameters": {
"required_approving_review_count": 1,
"dismiss_stale_reviews_on_push": true,
"require_code_owner_review": true,
"require_last_push_approval": true,
"required_review_thread_resolution": true
}
},
{
"type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": true,
"required_status_checks": [
{ "context": "ci/lint" },
{ "context": "ci/test" },
{ "context": "ci/build" },
{ "context": "sonar/quality-gate" }
]
}
}
]
}
其中 dismiss_stale_reviews_on_push=true 和 require_last_push_approval=true 是关键:前者保证有人push新commit后旧Approve失效,后者防止“自己approve自己最后一下push”。这两个参数救过我们一次——有个同事在Approve后偷偷加了一行 if (env === 'prod') return true,被规则拦下来了。
四、核心实现:PR流程、CODEOWNERS与CI配置
4.1 PR模板与CODEOWNERS
PR模板不是形式主义,它决定了评审者能不能在30秒内看懂你要干什么。我们的 .github/pull_request_template.md:
## 变更类型
- [ ] feature - [ ] fix - [ ] hotfix - [ ] refactor - [ ] docs
## 关联单据
JIRA: ORD-___
## 改动说明
## 自测情况
- [ ] 本地单测通过
- [ ] 已在测试环境验证(附截图/日志)
## 风险与回滚
CODEOWNERS 用来强制核心模块的评审人:
# .github/CODEOWNERS
* @team/backend
/src/payment/ @alice @bob
/src/auth/ @carol
/.github/workflows/ @team/devops
注意 require_code_owner_review=true 时,CODEOWNERS里匹配到的文件必须由对应人Approve,否则PR不能合并。我们踩过的坑是:一开始把 * 指给了整个team,结果任何PR都要全员Approve,评审积压。后来改成“默认team + 核心目录指定到人”。
4.2 GitHub Actions流水线
流水线拆成两个workflow:ci.yml(PR触发)和 release.yml(tag触发)。先看ci.yml,这是每天跑几十次的那个:
name: ci
on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened, ready_for_review]
push:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
if: github.event.pull_request.draft == false
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: actions/setup-node@v4
with: { node-version: '20.11.1', cache: 'npm' }
- run: npm ci --prefer-offline --no-audit
- run: npm run lint -- --max-warnings=0
test:
if: github.event.pull_request.draft == false
runs-on: ubuntu-22.04
services:
postgres:
image: postgres:15.5
env:
POSTGRES_PASSWORD: test
ports: ['5432:5432']
options: >-
--health-cmd pg_isready --health-interval 10s
--health-timeout 5s --health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20.11.1', cache: 'npm' }
- run: npm ci --prefer-offline --no-audit
- run: npm run test:ci -- --coverage --coverageThreshold='{"global":{"lines":80}}'
env:
DATABASE_URL: postgres://postgres:test@localhost:5432/postgres
build:
if: github.event.pull_request.draft == false
runs-on: ubuntu-22.04
needs: [lint]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20.11.1', cache: 'npm' }
- run: npm ci --prefer-offline --no-audit
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
retention-days: 3
sonar:
if: github.event.pull_request.draft == false
runs-on: ubuntu-22.04
needs: [test]
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: SonarSource/sonarcloud-github-action@v2.1.1
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
with:
args: >
-Dsonar.projectKey=order-service
-Dsonar.qualitygate.wait=true
-Dsonar.qualitygate.timeout=300
-Dsonar.coverage.exclusions=**/*.spec.ts
几个参数值得解释:
concurrency.cancel-in-progress=true:同一PR连续push时取消旧运行,省runner分钟数,我们每月省了约18%的Actions额度;fetch-depth: 0:Sonar需要完整历史做blame和新代码判定;--max-warnings=0:lint警告视为失败,避免“先合了再说”;qualitygate.wait=true+timeout=300:让job真的等质量门结果,而不是“提交完就绿”;coverageThreshold卡80%行覆盖,低于直接失败。
release.yml 则简单很多:打 v* tag触发,构建镜像推registry,然后跑一遍冒烟。这里不展开,重点是PR阶段把问题拦住。
4.3 Code Review流程
流程上我们定了“24小时首响”的软SLA,并把它做进了Slack机器人:PR创建后超过24小时无人评审,机器人@对应CODEOWNER。评审清单只有4条:
- 是否有测试覆盖新增逻辑(看diff里的test文件);
- 是否有硬编码配置/密钥;
- 错误处理是否完整(尤其是外部调用);
- 是否有N+1查询或明显的性能问题。
另外我们要求“Reviewer只提两类意见:必须改(blocking)和建议(non-blocking)”,并在评论里用 [must] / [nit] 前缀。这个小约定把“评审变成吵架”的概率降了很多。
五、踩坑与优化
坑一:rebase导致的“force push覆盖评审”。开发用 git push --force 更新feature分支后,GitHub上旧评论还在,但代码已经变了。我们的解法是强制 push --force-with-lease,并在分支保护里开启 require_last_push_approval。同时给团队装了 git config --global alias.pushf "push --force-with-lease"。
坑二:CI在draft PR上跑浪费额度。一开始没加 if: draft == false,结果草稿PR每次push都跑全套,一个月runner分钟数超了预算。加上draft判断后,CI分钟数从每月4200降到2900左右。
坑三:Sonar质量门超时。9.9 LTS默认质量门等待是300秒,我们项目大,扫描+分析经常到280秒,偶发超时。后来把 sonar.qualitygate.timeout 调到600,并把 sonar.exclusions 加上 **/generated/**,扫描时间从平均4分10秒降到2分40秒。
坑四:hotfix分支合并回main后忘记打tag。我们加了一个workflow:当 hotfix/* 合并进main时,自动创建 v-hotfix. tag并通知。虽然土,但再没漏过。
优化方面,最有效的是“把CI拆成必检和可选”:lint/test/build/sonar是必检,e2e和性能测试挂在 workflow_dispatch 上手动触发。PR平均等待时间从11分钟降到6分钟左右。
六、效果数据与总结
改造从2023年8月上线,跑了5个月,对比改造前(2023年3-7月)的数据:
- 合并冲突:每周6.2次 → 0.8次;
- PR平均首轮评审时长:18小时 → 3.5小时;
- main分支构建成功率:82% → 97.6%(失败主要是flaky test,已单独治理);
- 线上因“未完成代码误合”导致的事故:3次 → 0次;
- 回滚耗时:平均42分钟 → 9分钟(因为main始终可发布,回滚就是revert一个squash commit)。
总结几句大实话:
- 分支策略不是越复杂越好。9人团队用简化Git Flow足够,develop分支我们试过两周就废弃了,纯增加合并次数。
- Code Review能不能落地,取决于“评审是否被工具卡住”。没有branch protection和required checks,评审就是靠自觉,靠自觉就是没有。
- CI的第一目标是“快且可信”,不是“全”。必检项控制在5分钟内出结果,开发者才愿意等;等不起的检查放手动触发。
- 配置要进版本库。CODEOWNERS、PR模板、workflow yaml全部走PR评审,流程本身也要被流程管。
最后贴一个我们最常用的命令组合,给还在手动merge的同行:
git switch main && git pull --rebase
git switch -c feature/ORD-123-order-timeout
# ... 开发 ...
git add -A && git commit -m "feat(order): add timeout config for payment callback"
git fetch origin && git rebase origin/main
git push -u origin HEAD
# 然后在GitHub开PR,等CI和Review
如果这篇文章只能带走一句话:让机器守门,让人做判断。分支保护、CI、CODEOWNERS是机器能做的;代码好不好、设计合不合理,才是评审该操心的。