cd ~/blog/github-stacked-pull-requests

2026-08-21

話題のGitHubのStacked PRを実際に使ってみた

GitHubGitPull Request

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を使います。

Bash
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です。

JS
export function greet(name) {
  return `こんにちは、${name}さん`;
}
Bash
gh stack init feat/greet

これでスタックの1層目のブランチが切られ、そのブランチにチェックアウトされた状態になります。

あとは普通に実装してコミットするだけです。

Bash
git add -A
git commit -m "feat: greetの基本実装"

2層目: 前後の空白を取り除く

2層目(PR#2)は同じファイルに手を入れて、前後の空白を取り除くようにします。

DIFF
-  return `こんにちは、${name}さん`;
+  return `こんにちは、${name.trim()}さん`;

2層目からはgh stack add新しいブランチを1つ上に積む操作になります。

Bash
gh stack add feat/greet-trim -Am "feat: greetで前後の空白を取り除く"

3層目: 空文字ケースに対応

3層目(PR#3)は、空文字が渡された場合に「さん」なしで挨拶するようにします。

DIFF
 export function greet(name) {
+  if (!name.trim()) {
+    return "こんにちは";
+  }
   return `こんにちは、${name.trim()}さん`;
 }

3層目も同じ要領です。

Bash
gh stack add feat/greet-empty -Am "fix: greetの空文字ケースに対応"

PR作成

積み終えると、gh stack viewはこう表示されます。

TEXT
● 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が一括で作られます。

TEXT
✓ 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で見ると、狙い通り鎖になっています。

PRheadbase
#1feat/greetmain
#2feat/greet-trimfeat/greet
#3feat/greet-emptyfeat/greet-trim

Web UI側では、PRのタイトル横に3/3のようなレイヤー番号バッジが付きます。

下にはスタックとしてマージ可能というボックスが出て、まだマージしていない下位PRの一覧と各PRの状態(Ready)が表示されます。

GitHub上でのStacked PRの画面。タイトル横に3/3のレイヤー番号バッジがあり、下に「Able to merge as a stack」ボックスと、下位2PRの一覧(Ready)が表示されている

スタックの途中のPRを開くと、ボタンがMerge pull requestではなくMerge stackという表記になります。

下にぶら下がっているPRの数もバッジで併記されます。

GitHub Web UIの「Merge stack」ボタン。ボタンの右側に「2」と表示され、下にある2件のPRもまとめてマージされることを示している

部分マージで何が起きるか

ここからは、冒頭の結論を実際のAPIレスポンスとログで裏付けていきます。

ドキュメントには途中のPRをマージすると、下のPRも一緒にマージされ、上のPRは自動でretargetされるという説明がありました。

実際に、PR#2をマージしただけなのにPR#1も同時にMERGEDになっていました。

mergedAtを見ると、ほぼ同じタイミングでした。

JSON
{"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のままでした。

Merged状態のPR画面。タイトル横に1/3のバッジとMergedラベルがあり、下部に「Pull request successfully merged and closed」の表示と、PR#2がMerged・PR#3はまだReadyとして一覧表示されている

PR#3のbaseRefNamefeat/greet-trimからmainに自動で書き換わっていました。

しかもブランチ自体もサーバー側でリベースされていて、git fetchのログにforce-updateの記録が残っていました。

TEXT
+ 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側から触る前提で使うのが現実的そうです。

参照