2026-08-21
話題のGitHubのStacked PRを実際に使ってみた
7月30日にGitHubのStacked PRがパブリックプレビューになりました。
ドキュメントを読んだだけでは掴みにくい部分が多そうだったので、使い捨てのprivateリポジトリを作って、3層のPRスタックを実際に組んでみました。
Stacked PRとは
複数のPRを前のPRのブランチを土台にする形で鎖状につなげる機能です。
一番下(トランク寄り)のPRはmainのようなデフォルトブランチを土台にし、その上のPRは1つ下のPRのブランチを土台にします。
大きな変更を1つの巨大PRにせず、レビュー可能な単位に割った状態で、それぞれ独立してレビュー・承認しつつ、最終的にはまとめてmainに取り込めるようにする仕組みです。
作り方はCLIとWeb UIの2通りあり、CLIはghの拡張機能gh-stackを使います。
gh extension install github/gh-stack
結論: 積み上げたPRをいい感じにマージしてくれる
下から順にPR#1→PR#2→PR#3と積んだスタックで、真ん中のPR#2だけをマージした結果です。
Stacked PRの場合
PR#2をマージしただけで、これだけのことが起きました。
- 下にあったPR#1も同時に
MERGEDになる - 上にあったPR#3は
mainへ自動でretargetされ、ブランチ自体もmainを土台にリベースされる(force-update)
普通のPRの場合
比較として、ブランチの組み方自体はStacked PRと同じまま、PR#2のbaseだけをmainにマージした場合の図も並べます。
PR#2はPR#1を土台にしているので、PR#2をmainにマージすると、PR#1の変更ごとmainに取り込まれます。
mainだけ見ればStacked PRとほぼ同じ結果に見えるんですが、副作用が2つ残ります。
- PR#1自体は内容がすでに
mainに入っているのに、GitHub上はOpenのままになる(自分でマージしたのはPR#2であってPR#1ではないため) - PR#3は相変わらずマージ前のPR#2にぶら下がったままで、baseもretargetされない
結局、PR#1をクローズする後始末と、PR#3のbase付け替えは別途手動でやることになります。
普通のPRなら、PR#1をマージ→PR#2のbaseを手動でmainに付け替え→rebase・force push→PR#2をマージ→PR#3でも同じことを繰り返す、という手順が要ります。
それをまとめて肩代わりしてくれるのが、Stacked PRというわけです。
ここから先は、実際どのような見え方になるか、具体例をみていきます。
3層のスタックを組む
題材はgreetという、名前から挨拶文を組み立てるだけの関数にしました。
以下の手順でスタックプルリクエストを作成します。
1層目: 基本実装
1層目(PR#1)で実装するsrc/greet.jsです。
export function greet(name) {
return `こんにちは、${name}さん`;
}
gh stack init feat/greet
これでスタックの1層目のブランチが切られ、そのブランチにチェックアウトされた状態になります。
あとは普通に実装してコミットするだけです。
git add -A
git commit -m "feat: greetの基本実装"
2層目: 前後の空白を取り除く
2層目(PR#2)は同じファイルに手を入れて、前後の空白を取り除くようにします。
- return `こんにちは、${name}さん`;
+ return `こんにちは、${name.trim()}さん`;
2層目からはgh stack addで新しいブランチを1つ上に積む操作になります。
gh stack add feat/greet-trim -Am "feat: greetで前後の空白を取り除く"
3層目: 空文字ケースに対応
3層目(PR#3)は、空文字が渡された場合に「さん」なしで挨拶するようにします。
export function greet(name) {
+ if (!name.trim()) {
+ return "こんにちは";
+ }
return `こんにちは、${name.trim()}さん`;
}
3層目も同じ要領です。
gh stack add feat/greet-empty -Am "fix: greetの空文字ケースに対応"
PR作成
積み終えると、gh stack viewはこう表示されます。
● feat/greet-empty (current)
│ d8571cb · 1 second ago
│ fix: greetの空文字ケースに対応
│
○ feat/greet-trim
│ 264cb4c · 2 seconds ago
│ feat: greetで前後の空白を取り除く
│
○ feat/greet
│ fe6eda6 · 3 seconds ago
│ feat: greetの基本実装
│
└ main
gh stack submit --auto --openでpushとPR作成をまとめて実行すると、3つのPRが一括で作られます。
✓ Created PR #1 for feat/greet
✓ Created PR #2 for feat/greet-trim
✓ Created PR #3 for feat/greet-empty
✓ Stack created on GitHub with 3 PRs (stack #4)
実際のbase branchをgh pr viewで見ると、狙い通り鎖になっています。
| PR | head | base |
|---|---|---|
| #1 | feat/greet | main |
| #2 | feat/greet-trim | feat/greet |
| #3 | feat/greet-empty | feat/greet-trim |
Web UI側では、PRのタイトル横に3/3のようなレイヤー番号バッジが付きます。
下にはスタックとしてマージ可能というボックスが出て、まだマージしていない下位PRの一覧と各PRの状態(Ready)が表示されます。

スタックの途中のPRを開くと、ボタンがMerge pull requestではなくMerge stackという表記になります。
下にぶら下がっているPRの数もバッジで併記されます。

部分マージで何が起きるか
ここからは、冒頭の結論を実際のAPIレスポンスとログで裏付けていきます。
ドキュメントには途中のPRをマージすると、下のPRも一緒にマージされ、上のPRは自動でretargetされるという説明がありました。
実際に、PR#2をマージしただけなのにPR#1も同時にMERGEDになっていました。
mergedAtを見ると、ほぼ同じタイミングでした。
{"number": 1, "state": "MERGED", "mergedAt": "2026-08-20T08:28:50Z"}
{"number": 2, "state": "MERGED", "mergedAt": "2026-08-20T08:28:51Z"}
{"number": 3, "state": "OPEN", "mergedAt": null}
PR#1の画面を開くと、タイトル横のバッジが1/3のままMergedになっていて、下部には#2がマージ済みとして表示されています。
自分でマージ操作をしたのはPR#2だけなのに、PR#1側にもその結果が反映されている形です。
なお、まだ手を付けていないPR#3は、この時点ではちゃんとReadyのままでした。

PR#3のbaseRefNameもfeat/greet-trimからmainに自動で書き換わっていました。
しかもブランチ自体もサーバー側でリベースされていて、git fetchのログにforce-updateの記録が残っていました。
+ d8571cb...ce20894 feat/greet-empty -> origin/feat/greet-empty (forced update)
手元でgit rebaseしてforce-pushするのと同じ結果を、GitHubが勝手にやってくれていたわけです。地味にありがたい話だと思います。
同じ3層構成を、Stacked PRを使わずにただのPRとして積んでいたらどうなっていたかを比べてみます。
Stacked PRの場合
ただのPRの場合
操作の回数そのものより、retarget・rebaseを手でやるかGitHubに任せるかが実際の差です。
触ってみての感想
大きな変更を小さいPRに割るというやりたいことに対して、ブランチのbaseを手で付け替えたりrebaseしたりする面倒がなくなっているのは素直に便利でした。
特に部分マージ時の自動retarget・rebaseは、これまで手動でやっていた一番面倒な部分をそのまま肩代わりしてくれています。
パブリックプレビューということもあり、フォークをまたいだスタックは非対応、GitHub Desktopでは扱えない、といった制約もまだ残っています。
しばらくはCLI・API側から触る前提で使うのが現実的そうです。