0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

巨大 PR を作らずに「依存する機能追加」を小さく出す — Stacked PR という技術

0
Posted at

「まとめて1本の PR」が生んだコンフリクト

チーム開発をしていると、必ずこの生き物に出会います。

CONFLICT (content): Merge conflict in src/...

原因を探ってみると、実は「隣の人」のせいではなく、自分の PR が大きすぎて、レビューに時間がかかりすぎたことが原因、というケースは意外とよくあります。

ありがちなパターンはこうです。

  • 検索 API を作った(機能 A)
  • ついでに、A の上に乗るフィルタ・ソート機能(機能 B)も同じ PR に詰め込んだ
  • 「A と B はセットだから」と 1 本の巨大 PR として出す
  • レビューに1週間かかっている間に main が先へ進む
  • いざマージしようとしたらコンフリクトの山

「機能 B は機能 A の上に乗るんだから、A がマージされるまで待つか、まとめて 1 本の PR にするしかない」——多くの人がこの二択で悩み、結局まとめてしまいます。
実はここに第三の道があります。それが Stacked PR です。

先に断っておくと、この記事が扱うのは 「自分(または自分のチーム)が把握している依存関係のある変更」をどう分割するか という問題です。
見ず知らずの誰かが、示し合わせもなく同じファイルを同時に触っていた、という類の事故は Stacked PR では防げません。この違いは後の章で改めて整理します。

なぜ「依存関係のある機能追加」は巨大 PR になりがちか

シンプルな悪循環です。

  1. 機能 B が機能 A に依存しているので、A と B をまとめて 1 本の PR にする(変更ファイル数、うん十個)
  2. レビュアーが「うっ」となって後回しにする
  3. レビュー待ちの間に main が先へ進む
  4. 差分がどんどん本流から乖離していく
  5. いざレビューが終わって取り込む頃には、あちこちでコンフリクト
  6. 疲弊したレビュアーは、次から大きい PR を見るたびにさらに後回しにする(1 に戻る)

PR を小さくすればレビューは速くなる、というのは誰でも知っています。問題は「小さくすると、後続の変更が前の変更に依存してしまう」ことです。機能 A の上に機能 B を作りたいのに、A がまだ main に入っていない。しかたなく A と B をまとめて 1 本の巨大 PR にする——多くのチームがこのジレンマで巨大 PR に逆戻りします。

この「未マージの PR の上に、次の PR を積む」を正面から扱う技術が Stacked PR です。

Stacked PR という考え方

考え方自体はシンプルです。

main
 └─ PR#1 (feat/A)         ← まず A だけレビューしてもらう
     └─ PR#2 (feat/A-B)   ← A の上に B を積む。B の diff は B の分だけ

PR#2 の base ブランチを main ではなく feat/A にするだけです。レビュアーは PR#2 を開いても、A の変更には煩わされず、B の差分だけ を見られます。
1 本 1000 行の PR を出すのではなく、200 行の PR を 5 本、順番にレビューしてもらう。1 本あたりのレビューコストが激減するので、待ち時間も短くなり、結果としてコンフリクトの発生確率そのものが下がります。

「それ、機能ブランチを枝分かれさせてるだけじゃない?」というのはその通りです。
Stacked PR は魔法ではなく、GitHub の base ブランチ機能を、mainだけでなく他の feature ブランチに対しても律儀に使う、ただそれだけの話です。ただ、これが地味に強い。

Stacked PR が解決すること、しないこと

ここではっきりさせておきます。Stacked PR が効くのは、依存関係がある変更を、自分(またはチーム内で調整済みのメンバー)があらかじめ把握している場合だけです。

  • 解決できること: 「機能 B は機能 A の上に乗る」と最初からわかっている場合に、1本の巨大 PR を作らず、A・B を別々の小さい PR として順番にレビューしてもらえる。レビューが速く終わる分、main との乖離、つまり後で起きるコンフリクトの芽も小さく抑えられる
  • 解決できないこと: 自分がスタックを組んでいるかどうかに関係なく、見ず知らずの誰かが、示し合わせもなく同じファイルを同時に触っていたというパターン。相手のブランチの存在を知らなければ、そもそも「その上に積む」という選択肢自体が生まれません。これは trunk-based development で変更を細かく速くマージする、変更のスコープをファイル単位まで絞る、コードオーナーシップを分ける、着手前に一声かける、といった別の施策で対処すべき領域です
  • 解決できないこと(その2): PR#1(土台)がレビューで指摘を食らって差分が育つと、PR#2(積み上げ側)はそのたびに rebase で追従する必要があり、そこでコンフリクトが起きることもあります。Stacked PR は「スタック内部の依存関係に起因するコンフリクト」まで消してくれるわけではありません

つまり Stacked PR は「コンフリクト全般の特効薬」ではなく、「自分で作ってしまいがちな、依存関係のある巨大 PR」というかなり具体的な原因ひとつに効く薬、というのが正確な理解です。逆に言えば、この原因に心当たりがあるなら、今日からでも効きます。

実際にやってみた

理屈だけだと味気ないので、よくある EC サイトの機能追加を例にシミュレーションしてみます。

「検索機能」を強化するタスクが2つありました。

  • PR #1: 検索 API そのものを新設(feat/search-api)。商品名でキーワード検索できるようにする、土台となる変更
  • PR #2: 検索結果に「価格順」「新着順」などのフィルタ・ソートを追加(feat/search-filters)。当然、PR #1 の API がないと実装できない

PR #2 は PR #1 のコードに乗っかる形だったので、PR #1 がまだ未マージの段階で、そのブランチの上に feat/search-filters を積んで作業を始めました。

# 前提: feat/search-api は push 済み、PR#1 として既にレビュー依頼中(未マージ)

git checkout feat/search-api          # ローカルに feat/search-api を持ってくる
git checkout -b feat/search-filters
# ここで実装・コミット
git push -u origin feat/search-filters
gh pr create --base feat/search-api   # base を main ではなく PR#1 のブランチに

これで PR 画面には「フィルタ・ソート機能の差分だけ」が表示されます。PR #1 の検索 API 本体(1000 行近い変更)に埋もれることなく、レビュアーは新規追加分だけを見ればいい状態。

しばらくして PR #1 がマージされました。ここで面白いことが起きます。
自分では何もしていないのに、PR#2 の base 表示が feat/search-api から自動的に main へ切り替わっていました。

これは GitHub の Pull Request Retargeting という機能です。
base ブランチ(feat/search-api)がマージされて削除されると、それを base にしていた PR の base を自動的に付け替えてくれます。
地味ですが 2020 年からある機能で、これのおかげで「PR が、もう存在しないブランチを指したまま迷子になる」事故を避けられます。

squash マージや rebase マージの場合は、履歴が変わるため rebase --onto が必要です(merge commit なら不要です)。

体感として、1000 行級の PR を 1 本レビューしてもらうのを待つより、200 行の PR を「これ単体でレビューお願いします」と出す方が圧倒的にレビューが早く返ってきますし、待っている間に事故る確率もぐっと下がります。

GitHub 側もこの問題に本気を出し始めた

面白いのは、ここ数年で GitHub 自身がこの「PR を積む」ワークフローに正面から投資し始めていることです。

  • 2020 年 5 月: Pull Request Retargeting — base ブランチがマージ・削除されたとき、依存する PR の base を自動的に付け替える機能
  • 2026 年 4 月: GitHub Stacked PRsgh stack という専用 CLI と、PR 画面にスタック全体を見せる UI(スタックマップ、レビュー時の階層ナビゲーション)がプライベートプレビューとして登場。公式ドキュメントによれば、スタック内の各 PR は直接の base ではなくスタックの最終着地点(通常 main)を対象に branch protection や required status checks が評価される。スタック上位の PR をマージするには、下位の PR も含めてすべて要件を満たしている必要があるとのこと

まとめ

  • 「機能 B は機能 A に依存する」というよくある状況が、まとめて 1 本の巨大 PR を生み、それがレビュー遅延とコンフリクトの元になる
  • Stacked PR は、未マージの PR の上に次の PR を積むことで、依存関係を保ったままレビュー単位を小さく保てる
  • ただし効くのは「自分が把握している依存関係」に対してだけ。見ず知らずの誰かとの偶発的な衝突や、土台 PR がレビューで育ってしまうケースまでは解決しない
  • 専用ツールを待たなくても、gh pr create --base <未マージのブランチ> を使うだけで今日から始められる

「機能追加のたびに気づいたら巨大 PR になっている」という心当たりがあるなら、次はひとつだけ、土台の PR の上にもうひとつ PR を積んでみてください。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?