第一次带团队时候,我以为 Git 就是 commit、push、pull 三板斧。直到有一次周五下午,三个人同时改了同一个文件同一块区域,然后我 merge 把另外两个人两天活给搞丢了—那一刻我才意识到,分支策略不是选修课,是生存技能。

分支策略该怎么选

Git Flow:规矩最多那条路

Git Flow 是 Vincent Driessen 在 2010 年提出来,核心思想是”不同目的用不同分支”。

main (生产)

├── develop (开发主线)
│   ├── feature/login
│   ├── feature/dashboard
│   └── release/1.2.0

└── hotfix/auth-bug

适合的场景:版本节奏固定、有正式发布周期项目。比如做 ToB 产品,每个月发一个版本,需要维护多个历史版本。

真实的痛点

  • 分支太多,切换来切换去,我经常在 feature 分支上直接 push 到 develop(然后被 CI 拒绝)
  • release 分支一开就是一周,期间 develop 上的新提交不敢合进去,怕引入不稳定因素
  • hotfix 从 main 拉出来,修完要同时合到 main 和 develop,经常忘记合回去

我待过一个团队严格奉行 Git Flow,结果每次发版前三天,release 分支就成了”所有人噩梦”—谁都不敢往 develop 提交,又怕 release 上的修复漏回 develop。后来我们干脆放弃了 release 分支,改用 tag 标记版本。

GitHub Flow:简单粗暴但够用

GitHub Flow 只有一条长期分支:main。所有新功能从 main 拉 feature 分支,完成后提 Pull Request,review 完就合回 main 然后部署。

main ────────────────────────────────────────
  │            │            │
  └─ feature/A ┘  feature/B ┘    feature/C

适合的场景:持续部署 Web 应用、SaaS 产品。只要你敢合回 main,就说明代码已经可以上线。

真实的体验

  • 简单,新同事钟就能上手
  • 对 CI/CD 要求高,main 必须时刻处于可部署状态
  • feature 分支生命周期短,冲突概率低

但有个前提:你的 main 合进去之后,要么立刻部署,要么有特性开关(feature flag)控制。不然一个没完成 feature 合进去,生产环境就炸了。

我现在团队用 GitHub Flow,但加了一条铁律:所有合并到 main 的 PR 必须过 CI + 至少一个人 review。这听起来理所当然,但我们确实经历过”随便合进去吧,反正周末不上线”然后周一早上叫起来修生产事故。

Trunk-based Development:高频提交极致

这个是 Google、Meta 这类大厂用模式。所有人几乎都往 main(他们叫 trunk)上提交,feature 分支存在时间不超过一天,甚至直接用特性开关控制未完成功能。

核心逻辑:小步快跑,冲突在本地解决,避免长期分支带来”合并地狱”。

门槛

  • 需要强大自动化测试覆盖,不然 trunk 分分钟红
  • feature flag 系统要完善,不然代码里全是 if (featureEnabled) { ... }
  • 对开发者本地测试能力要求高

我在一个创业公司试过 trunk-based,结果是:前两周大家很不适应,总觉得”代码还没写完怎么能推上去”。后来我们约定:推上去代码可以是不完整,但不能是能导致编译失败或核心流程崩溃。配合特性开关,慢慢地大家就习惯了。

我们怎么选

没有银弹。我们选了”改良版 GitHub Flow”:

  • main 保护起来,必须通过 CI 和 review
  • feature 分支可以长期存在,但每周至少要合一次(用 draft PR 标记未完成)
  • 发版用 tag,不用 release 分支
  • hotfix 直接从 main 拉,修完合回 main,再 cherry-pick 到需要历史版本

这个方案不适合你,但适合我们:小团队、迭代快、不需要维护多个历史版本。

日常开发流程:从 commit 到 merge

Commit 不是存档,是叙事

我年轻时候 commit message 写得跟游戏存档一样:fixupdate改了一下。后来翻 Git 历史的时候,想找某个功能是谁什么时候加,完全无从下手。

现在我习惯是:每个 commit 都是一个完整故事

# 不好的 commit message
git commit -m "fix bug"
git commit -m "update"
git commit -m "123"
# 好的 commit message(以英文为准,保持一致性)
git commit -m "fix: handle null user in auth middleware

The auth middleware crashed when req.user is null,
which happens when the token expires but the client
still sends requests. Added a null check and returns
401 with a clear error message.

Fixes #142"

Conventional Commits 规范值得参考:

  • feat: 新功能
  • fix: 修 bug
  • refactor: 重构(不改行为代码改动)
  • chore: 构建流程、依赖更新等

但别走火入魔。我见过有人每个 commit 都写三行正文,结果大部分是废话。我的原则是:一行写不清楚,再加正文;一行能写清楚,别啰嗦

Push 之前,先 pull —rebase

这是个习惯问题。很多人(包括几年前我)push 之前直接 pull,然后产生一堆毫无意义 merge commit:

Merge branch 'main' of https://github.com/xxx/xxx

这些 merge commit 不携带任何信息,只会让提交历史变得难以阅读。

正确的姿势

# 在 feature 分支上开发时
git fetch origin
git rebase origin/main

# 如果有冲突,解决后
git add .
git rebase --continue

# 最后 push(如果是第一次 push 这个分支,需要 -u)
git push -u origin feature/my-feature

rebase 的好处是保持提交历史线性,review 的时候能看清楚每个 commit 做了什么。坏处是改写了历史,所以不要对已经推送给别人 review 的分支做 rebase

PR/MR:Code Review 的主战场

PR(GitHub)或 MR(GitLab)不只是个合并入口,是团队协作核心环节。

提 PR 之前自己先检查一遍

  • 有没有遗留 console.log 或调试代码?
  • 测试覆盖到了吗?
  • 文档要不要更新?

我有个习惯:提完 PR 之后,自己先以 reviewer 的视角看一遍 diff。经常能发现一些提交时没注意到问题,比如变量命名不一致、注释过时了、或者某段逻辑可以简化。

Review 的时候,别只盯着代码

有一次我 review 一个 PR,代码写得完美,逻辑清晰,测试也全。但我注意到他改了一个公共函数签名,所有调用方都改了。我问他:这个改动会不会影响其他还没迁移插件?他说没考虑到。结果那个 PR 合进去之后,有个老插件崩了,因为我们忘了通知插件维护者。

所以现在 review 的时候,我会额外注意:

  • 这个改动影响范围有多大?
  • 有没有破坏向后兼容?
  • 需不需要发个 migration guide?

Merge 之后的收尾

很多人以为 merge 完就结束了。还有几件事要做:

  1. 删除 feature 分支(远程和本地)

    git push origin --delete feature/xxx
    git branch -d feature/xxx
  2. 更新本地 main

    git checkout main
    git pull origin main
  3. 确认 CI 通过且部署成功

我以前不删分支,结果 git branch -a 一拉出来几十个过期 feature 分支,看着就头疼。现在我们仓库设了规则:PR 合并后自动删除源分支。

冲突处理:从恐慌到淡定

冲突是怎么发生

简单来说:Git 合并两个分支时,如果发现同一个文件同一区域在两个分支都有改动,而且改动内容不一致,它就不知道该保留哪个,报冲突。

<<<<<<< HEAD
const timeout = 5000;
=======
const timeout = 10000;
>>>>>>> feature/new-config

上面这个冲突很好解决:选一个,或者合并两个意图。

但真实冲突往往比这个复杂。

场景一:配置文件冲突

这是最常见。两个人同时改了 package.json 或者 .eslintrc,加了新依赖或规则。

我的处理方式

# 先看对方改了什么
git log --oneline origin/main -- package.json

# 手动合并,保留双方的依赖
# 用 VS Code 的合并工具比较方便
code package.json

避免这类冲突方法:

  • 依赖更新单独提 PR,不要跟功能开发混在一起
  • package-lock.jsonyarn.lock 锁定版本(但 lock 文件本身也容易冲突)

场景二:重构导致冲突

这个最头疼。你在 feature 分支上重构了一个模块,改了接口签名;同时另一个人在 main 上基于旧接口加了新功能。合并的时候,冲突整个文件逻辑都对不上了。

真实的案例

我之前重构了一个 API client,把原来回调风格改成了 Promise。然后合并时候发现,main 上有三个新功能都用了旧接口。我有两个选择:

  1. 把那三个功能代码也重构了(在 merge commit 里改)
  2. 先保留旧接口做兼容层,合进去之后再重构

我选了方案 2。虽然代码里多了一些临时兼容代码,但合并过程是干净,没有隐藏逻辑改动。

教训:大规模重构之前,先跟团队同步,避免别人在旧接口上继续开发。如果无法避免,做好兼容层计划。

场景三:二进制文件冲突

图片、字体、编译后文件—这些 Git 没法合并,只能二选一。

我的建议:不要把编译后文件提交到 Git(除非是故意做版本快照)。在 .gitignore 里加上 dist/build/ 之类的目录。

如果真冲突了:

# 保留对方的版本
git checkout --theirs path/to/file

# 或者保留自己的版本
git checkout --ours path/to/file

但更好做法是:从源头避免。二进制文件用 Git LFS 管理,或者干脆不提交。

预防冲突习惯

  1. 小步提交,频繁合并:feature 分支每天至少 rebase 一次 main,早发现冲突早解决
  2. 分工明确:尽量避免两个人同时改同一个模块核心文件
  3. 改公共代码之前先沟通:比如改了基础组件接口,提前在群里说一声
  4. 用 editorconfig 统一代码风格:缩进、换行符这些差异导致”假冲突”很烦人

团队协作最佳实践

分支命名规范

没有统一规范时候,分支名千奇百怪:fix-bugfix_bugFixBughotfix-authadmin-fix-auth……

我们后来规范是:

  • 新功能:feature/功能简述(英文,用短横线连接)
  • 修 bug:fix/问题描述
  • 紧急修复:hotfix/问题描述
  • 重构:refactor/模块名

比如:

feature/user-dashboard
fix/login-timeout
hotfix/payment-callback
refactor/api-client

保护主分支

这个必须做。我们设置了:

  • main 分支不允许直接 push
  • 所有合并必须通过 PR + CI 通过 + 至少一人 review
  • 强制要求线性历史(不允许 merge commit,用 rebase 代替)

在 GitHub 上可以这样设置:

Repository Settings → Branches → Add rule
- Branch name pattern: main
- Require pull request reviews before merging
- Require status checks to pass before merging
- Require linear history

写好 .gitignore

.gitignore 是项目门卫,决定哪些文件该进 Git,哪些不该进。

常见的坑

  • node_modules/ 提交上去了(仓库瞬间变大几百 MB)
  • .env 提交上去了(泄露了数据库密码和 API key)
  • 把 IDE 配置文件提交上去了(.vscode/*.iml

我们的 .gitignore 模板:

# Dependencies
node_modules/
vendor/

# Environment
.env
.<prog>rc

# Build output
dist/
build/
*.log

# IDE
.vscode/
.idea/
*.swp

# OS
.DS_Store
Thumbs.db

提交前用 pre-commit hook 做检查

我们用了 husky + lint-staged,每次 commit 之前自动跑 linter 和 formatter:

// package.json
{
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged"
    }
  },
  "lint-staged": {
    "*.{js,ts}": ["eslint --fix", "prettier --write"],
    "*.{json,md}": ["prettier --write"]
  }
}

这样能保证提交到仓库代码风格是一致,review 的时候不会格式问题干扰。

但要注意:pre-commit hook 跑的时间不能太长,不然大家会受不了。我们有一次加了 type check,结果每次 commit 要等 30 秒,后来大家干脆 --no-verify 绕过了。所以 CI 能做的检查,不都要放 pre-commit 里。

一些有用小技巧

找回误删代码

# 查看所有操作历史
git reflog

# 找到对应的 commit hash,然后恢复
git checkout <hash> -- path/to/file

有次我不小心把一个月工作成果给 git reset --hard 了,幸好 reflog 里还能找到。从那以后,我对 git reset --hard 心存敬畏,每次用之前先 git status 三遍。

临时切换分支但不想 commit

# 暂存当前改动
git stash

# 切换到其他分支处理事情
git checkout main

# 回来之后恢复
git stash pop

git stash 像个抽屉,把当前工作区改动暂时收起来。但我一般不存太久,容易忘。如果超过一天不 pop,我就会忘了 stash 里有什么,然后莫名其妙地丢代码。

修改一个 commit

# 改 commit message
git commit --amend -m "new message"

# 补充文件到上一个 commit
git add forgotten-file.js
git commit --amend --no-edit

--no-edit 表示保留原来 commit message。这个命令很实用,但记住:不要对已经 push 过的 commit 用 amend,因为会改写历史。

二分查找定位 bug

# 开始二分查找
git bisect start

# 标记当前版本有 bug
git bisect bad

# 标记一个已知没 bug 的版本
git bisect good v1.2.0

# Git 会自动切换到中间版本,你测试后标记
git bisect good  # 或 git bisect bad

# 找到罪魁祸首后,结束
git bisect reset

这个技能在追查”什么时候引入 bug”时特别有用。我上次用它找出了一个在 47 个 commit 之前引入隐性 bug,如果手动一个个回退试,估计要花一下午。

总结

Git 工作流没有标准答案。小团队别学大厂复杂流程,大项目也别用太简化方案。关键是:

  • 选一个适合团队规模和发布节奏分支策略
  • 养成良好 commit 和 push 习惯
  • 把冲突看成正常现象,提前预防,发生了也别慌
  • 用工具(CI、pre-commit hook、branch protection)把流程固化下来

说一句:Git 是个工具,不是目的。如果某个流程让团队效率变低了,那就调整它。我见过一些团队把 Git 工作流搞得跟宗教仪式一样,每次提交都要填表审批,结果大家反而更愿意直接改生产—那就本末倒置了。

工具服务于人,不是人服务于工具。


如果你有 Git 相关的有趣事故或者最佳实践,欢迎留言分享。每一次 merge conflict,都是一次学习机会。

种下你的想法

在花园里留下一条评论,和这篇文章一起生长。

COMMENTS