GitHub Pull Request 的三种合并方式
对比 GitHub PR 的 Create a merge commit、Squash and merge、Rebase and merge 三种集成策略,重点解释提交图、SHA、历史可读性、回滚与分支删除行为。
[!info] related notes
- 所属 MOC: Git MOC
- 前置概念: Git中的merge和rebase, GitHub
- 易混淆概念: Git 中的 fast-forward merge
- 相关流程: Bug 修复完整工作流
GitHub Pull Request 的三种合并方式
覆盖范围
这篇笔记只讨论 GitHub Pull Request 最终进入目标分支时的三种标准合并策略:
Create a merge commitSquash and mergeRebase and merge
它们解决的是同一个问题:
PR 已经通过 Review / CI 后,GitHub 应该以什么形态把 feature branch 的变化放进
main?
它们和日常在本地执行的 git merge、git rebase 有直接关系,但并不完全等价。Git 本身的基础差异见 Git中的merge和rebase。
[!important] “默认合并”不是固定规则 GitHub UI 中最常被叫作“普通合并”的选项正式名称是
Create a merge commit。仓库管理员可以在 Repository Settings 中启用或禁用三种方式,因此实际默认显示哪一种取决于仓库配置,而不是 GitHub 永远固定一种。
为什么三种方式值得单独理解
三种方式都能把最终代码内容放进 main,真正不同的是它们如何处理 Git 历史:
同一个 PR
↓
代码结果可能相同
↓
但 commit graph、SHA、回滚方式、历史可读性可能完全不同
因此选择合并方式,本质上是在决定:
- feature branch 的原始 commit 是否保留;
main是否保留一个明确的“PR 合并节点”;- 一个 PR 最终对应一个 commit 还是多个 commit;
- 历史是否保持线性;
- 后续
revert、bisect、删除分支时 Git 如何理解 ancestry。
先建立一个示例提交图
假设 main 当前是:
A---B---C main
开发者从 C 创建 feature branch,并提交三次:
A---B---C main
\
D---E---F feature
PR 的内容就是 D + E + F 相对于 C 的变化。
下面三种方式,最终得到的历史完全不同。
1. Create a merge commit
GitHub UI:
Merge pull request
→ Create a merge commit
GitHub CLI:
gh pr merge <pr-number> --merge
提交图
合并后通常变成:
A---B---C-------M main
\ /
D---E---F
M 是一个新的 merge commit,同时有两个 parent:
parent 1 = C
parent 2 = F
因此 Git 能明确看到:
D → E → F是一条 feature branch,并且在M这个节点整体进入了 main。
原始 commit SHA 会怎样
D、E、F 原封不动保留。
也就是说:
feature branch 上的 D
=
main history 里的 D
没有重写 SHA。
这也是它和另外两种方法最重要的差异之一。
优点
- 完整保留 feature branch 的真实历史;
- PR 的分支拓扑清晰;
- 所有原始 commit SHA 都还在;
- 多人共享、长期 feature branch 比较安全;
- 可以清楚看到“哪个 merge commit 对应哪个 PR”。
缺点
如果团队每个 PR 都有很多琐碎 commit:
fix typo
try again
fix test
address review
format
它们都会永久进入 main。
大量 PR 后,历史可能变成:
--M------M---------M----M---
/ / / /
... ... ... ...
commit graph 信息丰富,但阅读起来不一定简洁。
适合什么情况
优先考虑:
- feature branch 本身的每个 commit 都有历史价值;
- 多人共享同一分支;
- 长周期 feature / release branch;
- 团队希望保留真实分支拓扑;
- 不希望重写任何已推送 commit。
分支删除行为
因为 main 的 merge commit M 把 D/E/F 纳入 ancestry:
D ∈ main history
E ∈ main history
F ∈ main history
所以合并后运行:
git switch main
git pull --ff-only origin main
git branch -d feature
Git 通常能够直接确认:
feature branch 已经 merged into HEAD。
2. Squash and merge
GitHub UI:
Merge pull request
→ Squash and merge
GitHub CLI:
gh pr merge <pr-number> --squash
这是短生命周期 feature / bugfix PR 中非常常见的选择。
提交图
feature branch 原来有:
D---E---F
GitHub 会把它们的最终 diff压成一个新的 commit,例如 S:
A---B---C---S main
\
D---E---F feature
从代码内容来看:
S ≈ D + E + F 的最终效果
但从 Git 历史来看:
S != D
S != E
S != F
S 是新 commit,有自己的新 SHA。
核心心智模型
feature branch 的“开发过程”
D → E → F
squash
↓
main 只保留“最终结果”
S
这是一种非常典型的:
feature branch 可以 messy,main history 必须 clean。
优点
- 一个 PR 最终通常对应一个 commit;
main非常容易阅读;- 临时修复 commit 不污染永久历史;
- 单个 PR 很容易整体 revert;
- 对短生命周期 Bug / feature branch 非常合适。
例如 main 最终可以保持:
A
↓
feat(web): add onboarding
↓
fix(api): reject stale revision
↓
fix(web): scope temporal actions by fact semantics
↓
feat(ai): add diagnosis replay
而不是把开发过程中几十个 WIP commit 全带进去。
缺点
- feature branch 原始 commit 不进入
main; - 单个 commit 的精细历史被压掉;
- 如果 feature branch 每个 commit 都经过精心设计并有独立意义,squash 会丢失这种结构;
- Git 的 ancestry 不会认为原 feature commit 已经成为 main 的祖先。
BodySense 的真实例子
Bug 修复分支:
fix/144-body-state-temporal-actions
feature branch 原 commit:
303cb509
fix(web): scope temporal actions by fact semantics
PR #145 使用 Squash Merge 后,main 上得到的是一个新的 commit:
653aaa5f
fix(web): scope temporal actions by fact semantics (#145)
因此:
303cb509 != 653aaa5f
但两个节点对应的是同一项 PR 修复的开发版本与最终集成版本。
为什么 Squash Merge 后删除分支可能出现 warning
这次实际执行:
git branch -d fix/144-body-state-temporal-actions
Git 提示:
warning: deleting branch 'fix/144-body-state-temporal-actions'
that has been merged to
'refs/remotes/origin/fix/144-body-state-temporal-actions',
but not yet merged to HEAD
原因不是代码没有进入 main,而是 Git 的安全判断基于 commit ancestry。
Squash 后:
main:
A---B---C---653aaa5f
feature:
\
303cb509
main 中没有 303cb509 这个原始 SHA,因此:
303cb509 ∉ ancestors(main)
Git 无法只通过 commit graph 判断:
“653aaa5f 的代码其实已经包含了 303cb509 的最终效果。”
所以会出现 warning。
真正应该先验证的是:
git switch main
git pull --ff-only origin main
git log -5 --oneline
以及 GitHub PR 是否已经是 Merged。
CLI 也可以验证:
gh pr view <pr-number>
确认 PR 已合并后,再删除 feature branch 即可。
[!warning] 不要仅因为
git branch -d/-D能执行,就把它当成“代码已经进入 main”的证明。对于 squash / rebase 类型的集成,先确认 PR merge 和 main 上的最终 commit。
适合什么情况
非常适合:
- GitHub Flow;
- Bugfix branch;
- 小到中型 feature PR;
- 一个 PR 希望对应一个业务变化;
- 开发过程中有 WIP / review-fix / test-fix commit;
- 希望 main 保持简单、线性、易回滚。
这也是很多个人项目、应用项目和 PR 驱动仓库的常用默认选择。
3. Rebase and merge
GitHub UI:
Merge pull request
→ Rebase and merge
GitHub CLI:
gh pr merge <pr-number> --rebase
提交图
假设 feature 分支上有:
D---E---F
GitHub 会把这些 commit 逐个重新应用到当前 base branch 顶部。
最终类似:
A---B---C---D'---E'---F' main
注意:
D' != D
E' != E
F' != F
逻辑 commit 被保留下来了,但因为 parent 改了,每个 commit 的 SHA 都会重新计算。
为什么 SHA 一定会变
Git commit 的 SHA 不只是文件内容的 hash,它还包含:
- tree;
- parent commit;
- author / committer metadata;
- commit message 等。
Rebase 改变了 parent:
原来:D parent = C
重放后:D' parent = 最新 base
所以会产生新 commit 对象和新 SHA。
优点
main完全线性;- 不产生 merge commit;
- 不像 squash 那样把所有 commit 合成一个;
- 可以保留 feature branch 中“每个 commit 的逻辑边界”。
最终历史像:
A---B---C---D'---E'---F'---G---H
非常适合希望:
main 是线性的,同时每个 commit 本身也有意义。
缺点
- 原始 feature commit SHA 不保留;
- 多个 commit 都会进入 main,因此 feature branch 的 commit 质量必须高;
- 如果 feature branch 里有
fix typo、try again、address CI,这些也会成为永久历史; - 对审计“原始远端 commit SHA”不友好;
- 删除原 feature branch 时,同样可能遇到 ancestry 相关提示。
适合什么情况
优先考虑:
- 团队强烈要求线性 main;
- feature branch 的 commit 已经精心整理;
- 每个 commit 都应该可以独立理解 / review / bisect;
- 不希望有 merge commit;
- 但又不想 squash 掉 commit 边界。
三种方式直接对比
| 维度 | Create a merge commit | Squash and merge | Rebase and merge |
|---|---|---|---|
| main 是否产生 merge commit | 是 | 否 | 否 |
| feature 原始 commit 数量是否保留 | 是 | 否,压成 1 个 | 逻辑上保留 |
| feature 原始 SHA 是否保留 | 是 | 否 | 否 |
| main 是否线性 | 否,保留分支图 | 是 | 是 |
| 一个 PR 最终通常对应几个 commit | N + 1 个 merge commit | 1 个 | N 个 |
| 是否保留 feature 分支拓扑 | 是 | 否 | 否 |
| 对 WIP commit 的容忍度 | 中等 | 高 | 低 |
| 整体 revert 一个 PR | 较方便,但 merge revert 有 parent 语义 | 最简单 | 多 commit 时更麻烦 |
| main 历史阅读体验 | 信息完整但可能复杂 | 最简洁 | 线性且细粒度 |
| 合并后原 branch SHA 属于 main ancestry | 是 | 否 | 否 |
三种提交图放在一起看
Merge commit
A---B---C-------M
\ /
D---E---F
保留:
D E F + branch topology + M
Squash
A---B---C---S
保留:
D + E + F 的最终结果
但不保留:
D / E / F 的原始 commit identity
Rebase
A---B---C---D'---E'---F'
保留:
D / E / F 的逻辑拆分
但每个都是新 commit。
Rebase and merge 和“先 rebase feature 到 main”不是一回事
这是一个很容易混淆的地方。
开发过程中你可能执行:
git fetch origin
git rebase origin/main
这是在做:
同步 / 整理 feature branch。
而 GitHub PR 中选择:
Rebase and merge
是在做:
最终把 PR 集成进 base branch 的策略。
因此完全可能有:
feature 先 rebase 到最新 main
↓
PR 最终仍然 Squash and merge
这两件事不冲突。
同理:fast-forward merge 也不是 GitHub PR UI 中单独列出的第四种标准按钮。GitHub 的 Rebase and merge 虽然得到线性历史,但它通过新的 rebased commits 形成最终历史,不应简单理解成“把原 feature branch 原封不动 fast-forward 进 main”。
如何选择
可以用一个简单决策树:
这个 PR 的每个 commit 都值得永久保留吗?
│
├─ 否
│ ↓
│ Squash and merge
│
└─ 是
│
├─ 是否需要保留真实 branch topology / 原始 SHA?
│ ↓
│ 是 → Create a merge commit
│
└─ 否,只想要线性历史
↓
Rebase and merge
对于典型 GitHub Flow 应用仓库,可以进一步简化:
Bugfix / 普通 feature
→ Squash and merge
精心设计的一组独立 commits
→ Rebase and merge
长期共享分支 / 需要保留分支历史
→ Create a merge commit
对短生命周期 Bug 分支的推荐
一次标准 Bug 修复通常经历:
Issue
→ fix branch
→ 测试
→ commit
→ PR
→ CI / Review
→ merge
→ 删除 branch
这类分支的目标不是永久保存开发过程,而是永久保存修复本身。
因此常见推荐是:
Squash and merge
最终 main 会留下一个容易理解的节点:
fix(web): scope temporal actions by fact semantics (#145)
详细闭环见 Bug 修复完整工作流。
Merge 策略不会替代 CI / Branch Protection
无论最终选择哪一种:
Create a merge commit
Squash and merge
Rebase and merge
都不应该绕过:
- required status checks;
- branch protection / rulesets;
- Review requirement;
- merge queue(如果仓库启用);
- commit policy。
Merge Strategy 解决的是:
已经允许合并之后,历史怎么落进 main。
它不负责证明代码本身已经安全。
Issue 自动关闭和 Merge Strategy 无关
PR body 中写:
Fixes #144
当 PR 合并进入默认分支后,GitHub 可以自动关闭对应 Issue。
这和选择:
merge / squash / rebase
没有本质关系。
合并后的标准收尾
无论使用哪种方式,都建议:
git switch main
git pull --ff-only origin main
先确认 main 已经拿到最终 PR 结果。
再检查:
git log --oneline -5
然后删除本地 feature branch:
git branch -d <feature-branch>
如果是 squash / rebase,并且 Git 因 ancestry 无法证明已合并,不要马上使用 -D。
先确认:
GitHub PR = MERGED
main = 最新 origin/main
main 上存在本 PR 的最终 commit / diff
确认无误后再进行清理。
远端分支不再需要时:
git push origin --delete <feature-branch>
git fetch --prune
最短记忆模型
Merge commit
= 保留整个分支历史
Squash
= 保留 PR 的最终结果
Rebase
= 保留 commit 逻辑,但重写历史
再加一条 SHA 规则:
Merge commit → 原 feature SHA 保留
Squash → 原 feature SHA 不进入 main
Rebase → 原 feature SHA 被重写
理解这两组规则,基本就能解释 GitHub PR 三种合并方式的大多数行为。