Stacked PRs:把大改动拆成一条可合并的依赖链

Stacked PRs:把大改动拆成一条可合并的依赖链

最近 GitHub 文档里出现了一个很值得注意的概念:Stacked PRs,中文可以叫“堆叠式拉取请求”。

它解决的是一个很常见的开发问题:一个功能很大,最好拆成多个小 PR 来评审;但这些小 PR 又彼此依赖,后面的工作必须建立在前面的工作之上。如果每个 PR 都必须等前一个合并到 main,开发节奏就会被卡住。

Stacked PRs 的做法是:让后一个 PR 直接以前一个 PR 的分支作为 base,形成一条依赖链。

它不是把一个巨大 PR 按文件随便切开,而是把一项工作按依赖关系切成多个、每个都能独立理解的变更层。

一、先看它长什么样

假设我们要做一个“用户登录”功能,可以按下面的顺序拆分:

main
└── feat/auth-layer → PR #1,base: main
└── feat/api-endpoints → PR #2,base: feat/auth-layer
└── feat/frontend → PR #3,base: feat/api-endpoints

这三个 PR 不是三个互不相关的分支,而是一条有方向的依赖链:

  1. feat/auth-layer 添加认证模型、共享类型或数据库结构。
  2. feat/api-endpoints 在认证层之上添加 API 路由。
  3. feat/frontend 再调用这些 API,添加页面和交互。

因此,PR #2 的改动里不会重复展示 PR #1 已经完成的内容;PR #3 也只展示相对于 PR #2 的新增内容。

这就是“stack”的含义:每一层都站在下面一层之上。

二、它和普通 PR 有什么不同?

普通的 PR 通常是这样:

feature ───────────────→ main

Stacked PRs 则是这样:

feature-1 ─────────────→ main
feature-2 ─────────────→ feature-1
feature-3 ─────────────→ feature-2

最重要的变化不是分支多了,而是 PR 的 base 不再全部指向 main。

如果三个分支都直接向 main 提 PR,那么后面的 PR 会把前面分支的代码也全部带进 diff,审阅者看到的可能是一个越来越大的混合变更。Stacked PRs 让每个 PR 只显示自己这一层的增量,因此评审者可以按层理解:

PR #1:底层能力是否正确?
PR #2:API 是否正确使用了底层能力?
PR #3:界面是否正确使用了 API?

三、它真正解决了什么问题?

1. 不必等前一个 PR 合并

最直接的好处是,前一个 PR 还在 review,你就可以继续开发后面的部分。

比如后端同事正在等待认证层的 review,前端同事不必把工作复制到另一个临时分支,也不必等认证层合并后再开始,而是直接基于 feat/auth-layer 创建 feat/api-endpoints 或 feat/frontend。

这样既保留了依赖关系,又不会让团队停下来排队。

2. 每个 PR 都更容易审查

一个 2000 行的 PR 很难认真 review。即使代码本身没有问题,审阅者也很容易因为范围太大而只做粗略检查。

拆成多个有明确主题的层之后,每个 PR 可以只关注一件事:

层 关注点 适合的评审问题
底层 数据结构、共享类型、核心逻辑 设计是否稳定?边界是否正确?
中层 API、服务、业务流程 是否正确调用底层能力?错误处理是否完整?
上层 UI、测试、文档、集成 用户行为是否正确?是否覆盖关键路径?

评审的粒度变小之后,反馈也会更具体:到底是数据模型的问题、API 的问题,还是页面集成的问题,不会全部混在同一个 diff 里。

3. 更适合持续交付和 AI 代理开发

大型功能通常天然存在先后顺序:先改 schema,再改后端,再改前端,最后补测试和文档。

这种顺序本身就很像一个 stack。尤其是使用 AI coding agent 时,每完成一个清晰的子任务就创建一个分支和 PR,可以把“代理做了哪些变更、这些变更之间是什么关系”记录下来,而不是把几十个不同目的的修改堆进一个分支。

四、实际开发流程

方式一:手动理解 stack

先用普通 Git 分支建立关系:

git switch main
git pull

git switch -c feat/auth-layer
# 完成认证层
git add . && git commit -m "Add auth layer"

git switch -c feat/api-endpoints
# 完成 API 层
git add . && git commit -m "Add auth API endpoints"

git switch -c feat/frontend
# 完成前端层
git add . && git commit -m "Add auth frontend"

创建 PR 时,base 要逐层指定:

PR #1: feat/auth-layer     → main
PR #2: feat/api-endpoints → feat/auth-layer
PR #3: feat/frontend → feat/api-endpoints

这样做能帮助你理解原理,但随着层数增加,手动维护会变得麻烦:底层 PR 合并后,上面的分支都要重新变基,相关 PR 的 base 也要同步更新。

方式二:使用 GitHub CLI 的 gh stack

GitHub 目前提供了 gh stack 扩展来管理本地的堆叠分支。先安装:

gh extension install github/gh-stack

常用命令大致可以这样记:

# 初始化一个 stack
gh stack init

# 在当前 stack 顶部创建下一层分支
gh stack add api-endpoints

# 查看当前 stack
gh stack view

# 创建或更新所有分支对应的 PR
gh stack submit

# 拉取远程更新,并同步整个 stack
gh stack sync

# 对整个 stack 做级联 rebase
gh stack rebase

其中最关键的是 submit、sync 和 rebase:它们把推送分支、创建或更新 PR、重新建立上下游关系这些容易出错的操作集中处理了。

五、底层 PR 合并后会发生什么?

假设现在的 stack 是:

main ← PR #1 ← PR #2 ← PR #3

当 PR #1 合并到 main 后,PR #2 不应该继续以已经合并的 feat/auth-layer 作为长期 base。系统需要把它重新定位成:

main ← PR #2 ← PR #3

这就是级联变基(cascading rebase)。PR #2 的独有提交会被重新应用到最新的 main 上,PR #3 再以 PR #2 的新位置为基础。

如果底层改动和上层改动没有冲突,这个过程通常很顺滑;如果底层 PR 的实现方式发生了变化,上层分支就可能出现冲突,需要人工解决。也就是说,Stacked PRs 减少了等待,但没有消除依赖关系带来的协调成本。

六、合并时必须遵守什么顺序?

通常应该从底部向上合并:

PR #1 → PR #2 → PR #3

因为上层 PR 依赖下层 PR。底层合并后,上层 PR 会自动重新定位到新的 base 分支。

GitHub 的堆叠 PR 也支持一次合并整个 stack,或者合并其中一段。但理解上仍然可以记住一句话:依赖先合并,被依赖的代码后合并。

另外,堆叠中的每一层仍然要满足仓库的规则和 CI 要求。分支保护、CODEOWNER 审批和 GitHub Actions 不应该因为某个 PR 不是直接面向 main 就被绕过。

七、什么时候适合用,什么时候不适合用?

适合使用的情况:

  • 一个大功能可以自然拆成多个有先后关系的主题。
  • 后面的开发确实依赖前面的代码,但不想等待前一个 PR 合并。
  • 团队希望小步 review,而不是等所有工作完成后一次性提交。
  • 代码库有较好的自动化测试、分支保护和 PR 管理习惯。

不适合使用的情况:

  • 各个改动其实互不依赖,只是为了“看起来像一个 stack”而强行串起来。
  • 每一层都很小,拆分后反而增加沟通和维护成本。
  • 团队不熟悉 rebase,或者没有明确谁负责维护整个 stack。
  • 分支会被很多人同时随意修改,导致 stack 经常发生分叉。

一个很实用的判断标准是:如果删除下面一层,上面一层仍然可以独立存在,那它们可能应该是普通的并行 PR;如果上面一层必须建立在下面一层之上,stack 才有意义。

八、最容易踩的坑

1. 把 stack 当成“超长分支”

Stack 的目的不是让一条分支永远叠很多东西,而是让每一层都保持可理解、可评审、可合并。如果某一层已经变成“顺手把所有剩余工作都放进去”,就失去了拆分的意义。

2. 上层 PR 混入下层未完成的内容

创建上层分支前,应该先把当前层的提交边界整理清楚。每一层最好代表一个稳定的逻辑单元,而不是半个功能加上一堆临时修改。

3. 变基后忘记同步远程

rebase 会改写提交历史。stack 重新变基后,通常需要使用安全的强制推送方式更新远程分支;不要在不了解影响的情况下直接覆盖其他人的分支。

4. 评审顺序混乱

最下面的 PR 发生重大变化时,上面的 diff 也可能跟着变化。审阅者最好从底层开始看,开发者也应该在底层设计变化后及时通知上层 PR 的审阅者。

5. 忘记它仍是预览功能

GitHub 文档目前将 Stacked PRs 标记为 Public Preview,功能和命令未来可能发生变化;同时,所有 stack 分支必须位于同一个仓库中,GitHub Desktop 目前也不支持这种工作流。

九、我的理解:Stacked PRs 是“依赖关系可视化”

Stacked PRs 最有价值的地方,不只是帮我们少敲几条 Git 命令,而是把代码之间的依赖关系显式化了。

普通的大 PR 往往把这些关系都压扁在一个 diff 里:数据库结构、后端服务、前端页面和测试同时出现,审阅者只能自己在脑子里重建顺序。

Stack 则把这个顺序写在分支和 PR 的 base 关系里:

基础能力 → 业务能力 → 用户界面 → 测试与收尾

因此,它更像一种协作结构,而不只是 Git 技巧。它要求我们在提交代码之前先回答一个问题:这项改动依赖什么,谁依赖它?

如果这个问题能回答清楚,Stacked PRs 就能让大型改动变得更容易并行、更容易评审,也更容易在中途停下来合并一部分成果。

参考资料

  • GitHub:关于堆叠式拉取请求
  • GitHub:堆叠式拉取请求 CLI 命令
  • GitHub:堆叠式拉取请求快速入门