
<!--more-->

当一个功能同时包含基础代码、接口、测试和上层行为时，直接在一个分支上开发，最后得到的 PR 往往很大。评审者需要一次理解所有层次，任何一处修改都可能让整份 diff 重新变得难以阅读。

Stacked PR（堆叠式 Pull Request）把这些相互依赖的改动拆成一条分支链。每个分支仍然可以独立提交、运行 CI 和评审，但它的 base branch 指向前一层分支，而不是都指向主干。这样既保留了开发顺序，也让每个 PR 的 diff 聚焦在一个逻辑单元上。

本文以一次实际的多层开发过程为背景，将项目名称和业务细节抽象掉，只介绍 stacked PR 的原理、创建方式和日常维护方法。

## GitHub 官方定义与功能边界

GitHub 官方把 stacked PR 定义为：同一个仓库中的两个或更多 PR，底层 PR 指向 Stack 的 trunk，后续每个 PR 指向紧邻的下层分支。每个 PR 都应该是一个可独立评审的逻辑改动，而不是把一个大 PR 按文件随意切开。详细定义见 [About stacked pull requests](https://docs.github.com/en/pull-requests/get-started/about-stacked-prs) 和 [Stacked pull requests reference](https://docs.github.com/en/pull-requests/reference/stacked-pull-requests)。

这个定义也明确了一个重要边界：官方 Stack 是一条有序的依赖链，不是任意的依赖 DAG。也就是说，下面这种关系可以用普通 Git 分支和 PR 的 base branch 表达：

```text
1
├── 2 ── 5
└── 3 ── 4
```

但它不能作为一条官方 Stack 管理，因为 1 同时有两个下层分支。[`gh-stack` 官方说明](https://github.com/github/gh-stack/blob/main/skills/gh-stack/SKILL.md)也将 Stack 限定为线性结构：一个分支最多只有一个父分支和一个子分支。需要这种分叉关系时，应当手动维护多个普通 PR，或者重新调整边界，让它们变成一条线性链。

官方目前将 stacked PR 标记为 Public Preview，功能和界面仍可能变化。它可以在 GitHub 网页、GitHub CLI、GitHub Mobile 以及 API/Webhook 中使用；分支必须位于同一个仓库，GitHub Desktop 不支持 stacked PR。

官方还规定，Stack 中每个 PR 都按照 Stack trunk 的保护规则和必需检查进行评估，而不是只检查它直接指向的下层分支。以 trunk 为目标的 GitHub Actions 也会为 Stack 中的每个 PR 触发。因此，拆分 PR 不会绕过分支保护、CODEOWNERS 或必需 CI，只是改变了评审 diff 的范围。

合并时必须从底层向上进行。可以一次合并整个 Stack，也可以只合并底层的一部分；合并中间层时，它下面的 PR 一起合并，上面的 PR 保留并自动重新指向 Stack 的 trunk。完整规则见 [Stacked pull requests](https://docs.github.com/en/pull-requests/reference/stacked-pull-requests)。

## 先看分支和 PR 的关系

假设主干分支为 `main`，一次功能被拆成三层：

```text
main
  └── feature/foundation  → PR 1，base: main
        └── feature/api    → PR 2，base: feature/foundation
              └── feature/behavior → PR 3，base: feature/api
```

这里的「堆叠」不是把三个 PR 的内容复制三份，而是让分支依次从前一个分支创建。提交关系可以简化为：

```text
T──A1──A2──B1──B2──C1
    │       │       │
    │       │       └── feature/behavior
    │       └────────── feature/api
    └────────────────── feature/foundation
```

- `feature/foundation` 包含 `A1`、`A2`。
- `feature/api` 在此基础上增加 `B1`、`B2`。
- `feature/behavior` 再增加 `C1`。

GitHub PR 的关键是 base branch。PR 2 比较 `feature/api` 和 `feature/foundation`，所以评审者主要看到 `B1`、`B2`；PR 3 同理只展示 `C1`。如果三个 PR 都以 `main` 为 base，后面的 PR 会重复显示前面已经评审过的改动，堆叠就失去了意义。

因此，stacked PR 由三部分组成：

1. Git 分支的父子关系：后一个分支从前一个分支创建。
2. PR 的目标关系：每个 PR 指向栈中紧邻的下层分支。
3. 更新机制：下层分支变化后，上层分支需要级联 rebase，并更新远端 PR 的 base。

前两部分是普通 Git 和 GitHub PR 已有的能力，`gh stack` 主要负责第三部分，以及分支、PR 和 Stack 元数据的同步。

## 为什么要拆成一条栈

一次实际开发中，我先把不同依赖层拆开：底层通用改动放在较低层，接口入口放在中间层，独立修复单独成层，最后把依赖这些改动的主功能放在最上层。这样的顺序有两个直接收益：

- 底层 PR 可以先被单独评审和合并，上层 PR 不必等待一份巨型 diff 完成。
- 上层实现可以直接使用下层代码，同时 PR 页面只显示本层新增内容。

这也会迫使拆分边界变得清楚：如果一个修改既不是底层基础，也不是当前层逻辑，就应该重新判断它属于哪一层，而不是顺手塞进当前 PR。

但 stacked PR 不是把所有工作都串起来。只有存在明确依赖、并且最终要作为同一个功能演进的改动，才适合放在同一条栈中。完全无关的修复应当创建独立分支或另一条栈。

## 使用 `gh stack` 创建新栈

### 准备工具

需要安装 GitHub CLI 和 `gh-stack` 扩展：

```bash
gh extension install github/gh-stack
gh auth status
```

如果本地有多个远端，先设置默认推送远端，避免命令进入交互选择：

```bash
git config remote.pushDefault origin
git config rerere.enabled true
```

所有分支必须位于同一个远端仓库中，跨 fork 的分支不能组成 GitHub stacked PR。

### 初始化第一层

从主干创建第一层分支：

```bash
gh stack init --base main feature/foundation
```

编辑并提交第一层：

```bash
git add internal/foundation.go internal/foundation_test.go
git commit -m "add foundation layer"
```

这里使用明确的文件路径，是为了控制每一层的改动范围。并不建议直接使用 `git add .`，因为它容易把还没有决定归属的文件一起带入当前 PR。

### 增加后续层

完成第一层后，在当前分支上创建下一层：

```bash
gh stack add feature/api
git add internal/api.go internal/api_test.go
git commit -m "add api layer"
```

继续创建最上层：

```bash
gh stack add feature/behavior
git add internal/behavior.go internal/behavior_test.go
git commit -m "add behavior layer"
```

`gh stack add` 创建的新分支会以当前分支为父分支，因此命令执行顺序就是栈的自下而上顺序。也可以一次采用已有分支初始化栈：

```bash
gh stack init --base main feature/foundation feature/api feature/behavior
```

参数顺序必须是从靠近主干的分支到最上层分支。

### 接入已有分支或 PR

如果分支和 PR 已经存在，不必重新创建。可以用 `gh stack link` 按从下到上的顺序建立栈：

```bash
gh stack link 101 102 103 --remote origin
```

参数可以是已有 PR 编号，也可以是分支名：

```bash
gh stack link feature/foundation feature/api feature/behavior --remote origin
```

如果要向已有 Stack 的顶部追加分支，可以把 Stack 编号作为第一个参数：

```bash
gh stack link 7 feature/behavior --remote origin
```

当本地跟踪关系已经过期或与远端拓扑不一致时，先移除本地跟踪，再按正确的父子顺序重新初始化；确认远端 PR 已存在后，再使用 `link` 建立远端关系：

```bash
gh stack unstack --local
gh stack init --base main feature/foundation feature/api
gh stack link feature/foundation feature/api --remote origin
```

`unstack --local` 只清理本地元数据，不会删除分支或远端 PR。重新建立关系后，用 `gh stack view --json` 检查每一层的 base branch。

### 推送并创建 PR

把所有分支推送并创建或更新 PR：

```bash
gh stack submit --auto
```

`--auto` 会跳过标题编辑器；新建的 PR 默认是 Draft。如果已经准备好评审，可以使用：

```bash
gh stack submit --auto --open
```

查看当前栈时使用 JSON 输出，便于确认分支顺序、每个 PR 的 base 和是否需要 rebase：

```bash
gh stack view --json
```

`gh stack push` 只负责推送分支，不会创建 PR；`gh stack submit` 才会创建或更新 PR，并把它们链接成一条 Stack。

## 日常开发：从中间层修改

堆叠开发最常见的变化是：正在最上层工作时，发现某个接口或基础逻辑应该属于下层。这时不要直接把它提交到最上层，否则下层 PR 不包含真正的基础改动，上层 PR 也会混入不属于自己的内容。

应该先切换到正确的下层分支：

```bash
gh stack checkout feature/api

# 修改接口并提交
git add internal/api.go
git commit -m "adjust api contract"
```

然后把这次修改级联到所有上层分支：

```bash
gh stack rebase --upstack
gh stack push
gh stack view --json
```

`--upstack` 表示从当前分支向远离主干的方向更新。完成后再切回原来的工作层：

```bash
gh stack top
```

如果当前就在最上层，也可以用 `gh stack down` 逐层向下移动，用 `gh stack bottom` 直接回到最底层。

### 为什么必须级联 rebase

假设 `feature/api` 新增了 `B3`：

```text
更新前：main → foundation(A) → api(B) → behavior(C)
更新后：main → foundation(A) → api(B+B3) → behavior(C)
```

`feature/behavior` 原先基于旧的 `feature/api`。如果只修改 `feature/api` 而不重放 `feature/behavior`，上层分支的提交历史就没有包含 `B3`，远端 PR 的比较基线也会变旧。级联 rebase 会按顺序把上层分支重新放到最新父分支上，保持整条链的一致性。

真实使用中，父层在远端继续前进、顶部 PR 仍基于旧父层，是最容易出现冲突的场景。处理前先查看栈拓扑和 `needsRebase` 状态，再决定从哪一层开始 rebase，通常比直接对顶部分支执行普通 rebase 更容易定位问题。

## 同步主干和远端状态

日常同步可以使用：

```bash
gh stack sync
```

它会获取远端更新、协调远端 Stack 状态、更新主干、级联 rebase、推送分支并同步 PR 状态。已经合并的本地分支可以在确认后清理：

```bash
gh stack sync --prune
```

如果只想手动控制步骤，可以拆开执行：

```bash
git fetch origin
gh stack rebase
gh stack push
gh stack view --json
```

远端只增加了栈中的新分支时，`sync` 可以自动补齐本地关系；如果本地和远端分别对同一条栈做了不同调整，二者已经发生分叉，则应先确认哪一侧是正确的，再重建栈关系，不要盲目覆盖远端状态。

## 冲突处理

级联 rebase 可能在某一层产生冲突。处理流程和普通 Git rebase 类似，但要保持当前栈操作处于同一个状态：

```bash
gh stack rebase

# 查看冲突文件，编辑并保留正确内容
git status
git add path/to/conflicted-file
gh stack rebase --continue
```

如果确认这次 rebase 方向不对，可以中止：

```bash
gh stack rebase --abort
```

冲突解决后不要只推送发生冲突的一个分支。先用 `gh stack view --json` 确认每层的父子关系，再运行 `gh stack push`，因为上层分支通常也已经被重新生成，需要一起更新。

## 合并方式

评审时建议从底层向上层阅读：先确认基础层，再确认依赖它的接口层，最后检查最上层行为。每个 PR 的 diff 都是本层相对父层的增量，但完整理解仍然需要按栈的顺序阅读。

准备合并时，可以使用 `gh stack merge`：

```bash
gh stack merge --yes --squash
```

它会按栈的顺序处理当前栈。也可以指定某个 PR，只合并到该层；更高层会继续保留。合并底层后，剩余 PR 的 base 和分支会重新整理，必要时仍应检查 CI、PR 状态和本地分支。

如果只需要保留普通分支而不再使用本地栈跟踪，可以使用：

```bash
gh stack unstack --local
```

这只移除本地跟踪信息，不删除 Git 分支，也不改变远端 PR。

## 常见误区

### 把依赖关系当成提交顺序

先提交并不等于已经正确堆叠。只有后一个分支从前一个分支创建，并且 PR base 指向前一个分支，评审页面才会只显示本层 diff。

### 在错误的层修改

如果接口变化属于接口层，就切到接口层提交，再向上 rebase。不要因为当前人在顶部而把所有修改都提交到顶部。

### 父层更新后继续直接开发顶部

父层更新后，顶部 PR 可能显示需要 rebase。继续在旧基线开发会让冲突累积，也会让 PR diff 与实际依赖关系脱节。先同步栈，再继续开发。

### 把无关修复放进同一条栈

一条栈应当讲述一个连续的故事。无关 bug、独立重构和临时实验混入后，底层 PR 的合并会被不必要地绑定，评审顺序也会变得不清楚。

### 只看顶部 PR

顶部 PR 通常能看到完整代码结果，但它不是完整的评审入口。顶部 diff 依赖所有下层分支，应该从底层开始逐个确认，最后再检查合并后的整体行为。

## 什么时候适合使用

可以用下面的判断快速决定：

- 改动可以拆成两个或更多相互依赖、边界清晰的逻辑单元。
- 下层改动具有独立的评审价值，上层改动依赖它。
- 希望尽早让部分改动进入评审或合并，而不想等待整个功能完成。
- 团队能够接受分支 rebase、PR base 调整和级联 CI 的维护成本。

如果改动很小，一个普通 PR 更简单。如果各部分没有依赖，使用独立 PR 更合适。如果底层接口仍然频繁变化，过早堆叠会制造重复 rebase 和冲突，此时可以先稳定边界，再建立上层分支。

## 总结

Stacked PR 的核心不是多创建几个 PR，而是把一个有依赖关系的改动组织成一条可追踪的分支链：

```text
主干 → 基础层 → 接口层 → 行为层
```

每一层只承担一个清晰的逻辑单元，PR base 指向紧邻的下层分支；下层发生变化时，沿着依赖方向级联 rebase 并推送。`gh stack` 将这些分支关系、PR base、远端 Stack 和同步操作统一管理，降低了手工维护的出错概率。

它的价值是让大型改动可以更早、更小、更有顺序地被评审；代价是开发者必须认真维护层次边界，并把 rebase 和同步作为日常工作的一部分。

## 参考资料

- [About stacked pull requests](https://docs.github.com/en/pull-requests/get-started/about-stacked-prs)
- [Creating stacked pull requests](https://docs.github.com/en/pull-requests/how-tos/create-pull-requests/creating-stacked-pull-requests)
- [GitHub Stacked PRs 与 `gh stack` 扩展](https://github.com/github/gh-stack)

