Git 协作规范

前置说明

本文默认读者已经理解 Git 的基本原理,包括 commit、branch、HEAD、merge、rebase、远程分支和冲突解决

因此,本文不再解释 Git 是什么,也不重复讲对象模型和分支原理。重点放在团队协作中如何写 commit、整理历史,以及选择合适的合并方式

为什么需要 Git 规范

Git 提交历史不只是代码变更记录,更是团队协作的知识库。它真正的读者通常不是当前作者,而是 reviewer、排障者和未来维护者

但好的提交历史不会自然产生。它需要两个层面的规范:

  1. Commit 规范 - 如何写好单个提交:规范的 message 格式、清晰的拆分粒度、合理的组织方式
  2. Merge 规范 - 如何将分支合入主干:选择合适的合并策略,保持历史清晰可维护

Git 规范不是为了追求格式整齐,而是为了降低协作成本。好的提交历史应该像一本技术日志,让任何人都能:

  • 在 5 分钟内理解一个 PR 的改动范围和意图
  • 通过 git bisect 快速定位引入问题的提交
  • 在半年后仍能理解当时的决策背景和权衡
  • 安全地回滚某个功能而不影响其他改动

规范化的 commit 还可以服务自动化流程。例如生成 changelog、判断语义化版本升级类型、触发 CI/CD、关联 issue 或 PR

Commit Message 规范

格式规范

Commit Message 建议采用 Conventional Commits 1.0.0

<type>[optional scope]: <description>

[optional body]

[optional footer(s)]

最常见的提交如下:

feat(auth): add password reset flow

其中 type 表示变更类型,scope 表示影响范围,description 简短说明这个提交做了什么

常用类型包括:

feat: 新增功能
fix: 修复问题
docs: 文档变更
test: 测试变更
refactor: 重构,不改变外部行为
style: 格式调整,不影响逻辑
chore: 构建、依赖、脚手架等杂项
ci: CI 配置变更
build: 构建系统或外部依赖变更

feat 通常对应 SemVer 的 MINOR 版本升级,fix 通常对应 PATCH 版本升级

破坏性变更需要显式标记。可以在 type 或 scope 后加 !

feat(api)!: remove legacy user endpoint

也可以在 footer 中写:

BREAKING CHANGE: remove legacy user endpoint

写作规则

Conventional Commits 解决的是结构问题,Chris Beams 的 七条规则 解决的是可读性问题

团队可以采用以下规则:

  1. subject 和 body 之间空一行
  2. subject 尽量简短,英文建议 50 字符附近
  3. subject 不以句号结尾
  4. subject 使用动作表达,描述“应用这个提交会发生什么”
  5. body 按合适宽度换行,英文建议 72 字符
  6. body 解释 what 和 why,不重复解释 how
  7. issue、PR、BREAKING CHANGE 等元信息放在 footer

一个需要 body 的提交可以这样写:

fix(order): preserve payment state after retry

The previous retry flow recreated the order payment state, which could
drop the provider transaction id after a transient network failure.

This commit keeps the existing payment state and only updates retry
metadata.

Refs: #123

如果改动非常简单,一行 subject 就够了:

docs: fix typo in deployment guide

body 的价值不在于复述 diff。diff 已经说明代码怎么变了,body 应该解释为什么要变、之前的问题是什么、这个方案有什么取舍

对比两个常见写法:

fix bug
update auth
change config
fix(auth): handle expired refresh token
chore(config): align staging API base URL

坏 subject 的问题不是短,而是缺少边界和意图。好的 subject 应该让 reviewer 在展开 diff 前,就能大致判断改动影响哪里、解决什么问题

Commit 组织策略

本地 Commit 与 PR Commit

本地 commit 和 PR commit 的目标不同。本地 commit 服务开发过程,PR commit 服务代码审查、问题排查和长期维护

本地 commit 服务开发者
PR commit 服务 reviewer、维护者、bisect 和未来历史

本地开发时,可以使用小步提交保存上下文:

wip: try new auth callback
debug: log token refresh response
fix: handle missing refresh token
style: adjust login form spacing

这些提交对开发者有价值,因为它们能帮助回滚、对比实验、保存中间状态

但它们通常不适合作为 PR 历史。PR commit 应该整理成清晰的逻辑序列:

feat(auth): add refresh token flow
fix(auth): handle missing refresh token
test(auth): cover token refresh failure

如何拆分 Commit

一个好的 commit 应该是一个完整、独立、可理解的逻辑变更

如果一个 subject 无法准确概括整个 diff,通常说明这个 commit 太大,应该拆分

建议拆分的情况:

  • 重构和行为变更分开
  • 测试和无关实现分开
  • UI、API、数据模型、迁移、文档按可 review 单元分开
  • 格式化调整不要混在业务变更里

例如,不建议一个 commit 同时做这些事:

refactor user service
add password reset
format all files
update docs

更好的方式是拆成:

refactor(user): isolate password service
feat(auth): add password reset flow
docs(auth): document password reset behavior

拆分与合并 Commit 的方法

使用 Interactive Rebase

git rebase -i 是整理提交历史最强大的工具,可以重新排序、合并、拆分、修改或删除提交

# 整理最近 3 个提交
git rebase -i HEAD~3

# 整理从某个 commit 之后的所有提交
git rebase -i <commit-hash>

# 整理当前分支相对于 main 的所有提交
git rebase -i main

执行后会打开编辑器,显示待整理的提交列表:

pick a1b2c3d feat(auth): add login form
pick d4e5f6g fix: button state
pick h7i8j9k docs: update auth guide

# Commands:
# p, pick = use commit
# r, reword = use commit, but edit the commit message
# e, edit = use commit, but stop for amending
# s, squash = use commit, but meld into previous commit
# f, fixup = like "squash" but discard this commit's log message
# d, drop = remove commit

常用操作:

  • 合并提交:将 pick 改为 squashfixup,会合并到上一个 commit
  • 拆分提交:将 pick 改为 edit,rebase 会在该 commit 处暂停
  • 删除提交:将 pick 改为 drop 或直接删除该行
  • 重新排序:直接调整行的顺序
  • 修改 message:将 pick 改为 reword

拆分单个 Commit

如果一个 commit 包含多个逻辑变更,可以用 edit 拆分:

# 1. 启动 interactive rebase
git rebase -i HEAD~3

# 2. 将要拆分的 commit 标记为 edit
# 保存退出后,rebase 会在该 commit 处暂停

# 3. 撤销该 commit,但保留改动
git reset HEAD^

# 4. 分批暂存并提交
git add src/auth/login.ts
git commit -m "feat(auth): add login form"

git add src/auth/validation.ts
git commit -m "feat(auth): add input validation"

# 5. 继续 rebase
git rebase --continue

使用 Reset 重新组织

git reset 可以撤销提交但保留改动,适合快速重新组织最近的提交:

# 撤销最近 2 个 commit,改动回到暂存区
git reset --soft HEAD~2

# 撤销最近 2 个 commit,改动回到工作区
git reset HEAD~2
# 或
git reset --mixed HEAD~2

# 撤销最近 2 个 commit,丢弃所有改动(危险)
git reset --hard HEAD~2

--soft 适合重新组织 commit message 或合并提交,--mixed 适合重新选择暂存内容

部分暂存

使用 git add -p 可以交互式地选择文件中的部分改动暂存,实现更细粒度的 commit 拆分:

git add -p src/auth/service.ts

# Git 会逐个展示改动块(hunk),询问是否暂存
# y = 暂存这个块
# n = 不暂存
# s = 拆分成更小的块
# e = 手动编辑这个块
# q = 退出

修改最近一次 Commit

如果只需要修改最近一次提交,可以用 --amend

# 修改 commit message
git commit --amend -m "feat(auth): add login form with validation"

# 追加改动到最近一次 commit
git add forgotten-file.ts
git commit --amend --no-edit

注意:--amend 会改写历史,如果 commit 已推送到远程,需要 git push --force-with-lease

整理历史的最佳实践

  1. 在本地整理:只整理未推送的提交,避免改写已共享的历史
  2. 先备份:整理前创建备份分支 git branch backup-feature
  3. 小步验证:每次 rebase 后运行测试,确保功能正常
  4. 保持原子性:拆分后的每个 commit 应该能独立通过测试
  5. 使用 —force-with-lease:如果必须改写远程历史,用 --force-with-lease 而非 --force

Git 合并策略

merge

理解了 commit 的组织方式后,接下来看如何将分支合入主干。合并策略决定的是主干历史的形状:线性、保留分支边界,还是压缩成单个结果

策略历史形态适用场景优点缺点
Fast-forward线性分支短、无分叉历史简洁易读丢失分支边界
—ff-only线性(强制)严格线性历史要求保护线性历史需要频繁 rebase
Three-way merge非线性两分支都有新提交保留真实拓扑历史复杂
—no-ff非线性(强制)保留功能边界可整体回滚增加 merge commit
Squash merge线性过程杂乱、结果单一主干简洁丢失演进过程

选择时先看三个问题:主干是否必须线性、分支里的 commit 是否有长期价值、是否需要保留功能边界

场景推荐策略命令示例
分支短、历史干净—ff-onlygit merge --ff-only feature/foo
团队要求线性历史rebase + —ff-onlygit rebase origin/main && git merge --ff-only
功能分支有多个有意义 commit保留 commit + —no-ffgit merge --no-ff feature/foo
过程杂乱但最终改动单一squash merge平台 Squash Merge 或 git merge --squash
需要 bisect 和保留演进过程避免 squashgit merge --no-ff feature/foo

Fast-forward

目标分支直接移动指针到源分支最新 commit,不产生 merge commit

适用场景:从 main 拉出分支后,main 没有新增提交,或当前分支已 rebase 到最新 main

git checkout main
git merge feature/login  # 自动 fast-forward

代价:历史非常简洁,但看不出哪些 commit 曾经属于同一个功能分支

—ff-only

git merge --ff-only 只允许 fast-forward,无法快进时直接失败,强制保护线性历史

适用场景:团队希望主干始终保持线性,并且要求开发分支在合并前主动 rebase 到最新主干

git checkout main
git merge --ff-only feature/login
# 如果失败,说明 main 已分叉,需要先 rebase

代价:开发者需要更频繁地同步主干。分支存在冲突时,必须先解决 rebase 或重新整理历史

Three-way Merge

当两个分支都有新提交时,Git 基于共同祖先、当前分支 tip、待合并分支 tip 三个点计算合并结果,生成 merge commit

适用场景:团队接受非线性历史,并希望保留分支真实演进过程

代价:历史拓扑更接近真实协作过程,但阅读主干时会出现更多分叉和 merge commit

—no-ff

即使可以 fast-forward,也强制生成 merge commit,保留「分支作为整体被合入」的语义

适用场景:功能分支包含多个有意义 commit,希望保留功能边界,方便整体回滚

git checkout main
git merge --no-ff feature/payment

代价是历史更复杂。如果每个小改动都强制 merge commit,主干会变得臃肿

Squash Merge

把源分支所有改动压成一个新 commit 合入目标分支,不保留原分支 commit 拓扑

适用场景:PR 过程里有大量 wip、debug、fix lint 等过程提交,但最终改动可以被一个 subject 清楚概括

什么时候 Squash

Squash 的目标不是减少 commit 数量,而是删除没有长期价值的过程噪音

适合 squash 的提交:

wip: add login form
fix: button state
debug: print response
fix typo in previous commit
fix lint
remove temp log

合入主干时 squash 成:

feat(auth): add login form

不应该 Squash 的情况

如果多个 commit 分别代表清晰的设计步骤、可回滚单元或 bisect 边界,它们应该保留。不要为了「看起来干净」而 squash 掉有意义的历史

如何选择合并策略

没有一种策略适合所有团队。策略选择应该服务历史价值,而不是机械追求线性或 commit 数量少

工作流示例

偏线性历史的团队

git fetch origin
git rebase origin/main
git checkout main
git merge --ff-only feature/foo

保留功能边界的团队

git checkout main
git merge --no-ff feature/foo

以 PR 为主、希望主干简洁的团队

使用平台提供的 Squash Merge 功能

推荐团队工作流

可以把团队协作拆成四个阶段,每个阶段只关注一个目标

开发中

本地小步提交。不要为了追求一开始就完美,而牺牲开发过程中的安全 checkpoint

PR 前

整理历史。删除 debug、wip、临时日志和无意义 fixup,把提交整理成 reviewer 能理解的逻辑序列

如果分支历史杂乱,但最终改动单一,可以 squash。如果分支历史清晰,并且每个 commit 都有独立价值,应该保留

合并前

合并前,同步最新主干:

git fetch origin
git rebase origin/main

如果分支已经推送到远程,rebase 后更新远程分支时优先使用:

git push --force-with-lease

--force-with-lease--force 更安全,因为它会避免覆盖别人已经推送到远程的更新

合并时

根据团队规则选择合并策略。短分支优先保持线性,过程杂乱但结果单一时使用 squash,有清晰演进价值的功能分支保留 commit 和分支边界

PR 前检查清单

提交 PR 前,建议按以下分组检查:

Commit 质量

  • subject 清晰且符合 Conventional Commits
  • 去掉 debug、wip、临时日志
  • 每个 commit 是一个逻辑变更
  • 复杂变更写了 body,解释 why 而非 how

代码组织

  • 没有混入无关格式化
  • 测试、文档、实现放在合适的 commit 中
  • 重构和行为变更分开

合并准备

  • 分支基于最新 main
  • 选择了合适的合并策略
  • 如需 rebase,已使用 --force-with-lease 更新远程

历史价值检验

好的 Git 历史应该能回答三个问题:

  • 这个变更做了什么?
  • 为什么要这样做?
  • 它是如何进入主干的?

最终目标不是让历史「看起来少」,而是让历史有用

参考资料