TL;DR
- 「PR を分割しろ」で止まると何も動きません。必要なのはどこで切るかと、切り方が正しいことをどう確かめるかの 2 つです。
- 自リポジトリの直近 140 PR を測ったところ、変更行数は p50=117 行に対し p90=723 行、最大 1,641 行でした(
社内データ)。分布は長い右裾を持ちます。 - 400 行を超えた 24 本のうち 19 本(79%)が 2 つ以上のトップレベル領域に跨っていました。分割線は最初から差分の中にあります。引き直すのではなく、見つけて順序を付ける作業です。
- 実在する最大の PR(28 ファイル / 1,641 行)を contract / tooling / wiring の 3 層に分解したところ、各層で
pnpm testが exit 0 になりました。層の順序を誤ると exit 1 で落ちます。 - ただし テストが green でも順序が正しいとは限りません。wiring 層を先頭に積むとテストは exit 0 のまま、未存在パスへの参照が 12 箇所残りました。順序の検証には別のチェックが要ります。
はじめに:「分割しろ」の次が無い
AI コーディングエージェントに機能を任せると、成果物は 1 本の PR にまとまって出てきます。指示していなくても、エージェントは「タスクを完了させる」ことを目的に動くので、仕様も実装もテストも配線も一度に触ります。結果として 1,000 行を超える PR が日常的に発生します。
なぜそれがレビューを止めるのかは、AI生成PRでレビューが詰まる本当の理由 で診断済みです。そこでは根因を 意図の復元・影響範囲の推定・証拠の確認 の 3 つに分解しました。本記事はその続きで、3 つのうち 「影響範囲の推定コスト」を構造的に下げる手段を扱います。
手段は Stacked PR です。1 本の大きな PR を、互いに依存する順序付きの小さな PR の連鎖に置き換えます。親が main、子が親のブランチ、孫が子のブランチを base に持つ形です。各 PR は前の PR を前提にしてよいので、独立した小 PR 群のように「無理に依存を断ち切る」必要がありません。
小さい単位でレビューすべきという主張自体は新しくありません。この記事の中身は、自分のリポジトリで実際に 1,641 行の PR を分解し、各層が本当に成立するかをコマンドで確かめた記録です。計測日はすべて 2026-09-06、対象は本ブログのリポジトリです。
1. まず、自分のリポジトリの PR サイズを測る
議論の前に分布を見ます。GitHub API を叩かなくても、squash merge を使っているリポジトリなら git log だけで測れます。
以降のコマンドの前提を先に書きます。git(origin を git fetch 済み)、bash、awk があれば第 1 節は動きます。第 3 節以降は本リポジトリのテストを回すので、加えて Node.js 24.x / pnpm(pnpm install 済み)/ python3 と、作業ツリーがクリーンであることが必要です。読者のリポジトリで再現する場合は「自分のテストコマンド」に読み替えてください。
# 直近 400 コミットのうち、subject が "(#123)" で終わるもの = squash merge された PR
git log origin/main -n 400 --format='C|%h|%s' --numstat > /tmp/ns.txt
awk -F'|' '
/^C\|/{if(sha!="" && ispr) print lines; sha=$2; ispr=($3 ~ /\(#[0-9]+\)$/); lines=0; next}
{if($0=="")next; split($0,f,"\t"); if(f[1]!="-") lines+=f[1]+f[2]}
END{if(ispr) print lines}' /tmp/ns.txt | sort -n | \
awk '{n++;s[n]=$1} END{print "n="n, "p50="s[int(n*0.5)], "p90="s[int(n*0.9)], "max="s[n]}'
# => n=140 p50=117 p90=723 max=1641
# ↑ 本稿執筆時点(base = origin/main の 097a70b、2026-09-06)の出力例。
# 対象は「直近400コミット中の squash merge 140本」なので、コミットが進むと値は動きます。
本リポジトリの結果です(社内データ、n=140、基準 origin/main = 097a70b、2026-09-06 時点のスナップショット)。このコマンドは移動する origin/main の直近 400 コミットを見るので、日が進めば値は変わります。実際、翌日に同じコマンドを流すと p90 は 794 に動きました。比較したいときは基準コミットを固定してください。
なお int(n*0.9) による分位点はサンプル数が小さいと精度が出ません(n=1 だと空欄になります)。PR が数十本しかないリポジトリでは、分位点ではなく上位 5 件を直接並べるほうが実用的です。
| 指標 | 変更行数 |
|---|---|
| p50(中央値) | 117 |
| p75 | 257 |
| p90 | 723 |
| p95 | 1,030 |
| max | 1,641 |
| サイズ帯 | 本数 |
|---|---|
| 100 行以下 | 59 |
| 101〜400 行 | 57 |
| 401〜1,000 行 | 16 |
| 1,000 行超 | 8 |
読み方は「平均は小さいが、裾が長い」です。**8 割強の PR は問題になりません。**問題は残りの 2 割で、そこにレビュー時間が集中します。Google のエンジニアリング実践ガイドも CL(変更単位)の適正サイズを「1 つの self-contained な変更」と定義したうえで、"100 lines is usually a reasonable size for a CL, and 1000 lines is usually too large, but it's up to the judgment of your reviewer" と書いています(公式値、Small CLs、確認日 2026-09-06)。数値は目安で、判断はレビュアーに委ねられるという留保付きです。だからこそ、他所の数字ではなく自分の p90 と max を知るのが出発点になります。
2. 分割線は「引く」のではなく「見つける」
巨大 PR を前にして手が止まるのは、分割を設計作業だと思うからです。実際には多くの場合、線はすでに差分の中にあります。
先ほどの 140 本を、変更されたトップレベルディレクトリの数でクロス集計しました。
| 条件 | 値 |
|---|---|
| 400 行超の PR | 24 本(全体の 17%) |
| うち 2 領域以上に跨るもの | 19 本(79%) |
社内データ(2026-09-06、n=140、基準 097a70b)。ここでの「領域」はトップレベルディレクトリ(content/ spec/ scripts/ .agents/ など)で、行数はバイナリファイルを除いた numstat の追加+削除です。領域の分類定義を変えると内訳の数字は数 % 動くので、自分のリポジトリで測るときは分類ルールを先に固定してください。大きい PR は、そもそも複数の関心事を同時に触っているから大きい、という当たり前の事実が数字で出ます。逆に言えば、400 行超のうち 8 割は追加の設計なしに 2 本以上へ割れます。
割る順序は次の 3 軸で決めます。
| 軸 | 問い | 具体例 |
|---|---|---|
| 依存 | A が無いと B は動かないか | スクリプト本体 → それを呼ぶテスト → CI 設定 |
| レイヤー | レビュアーが変わるか | 仕様・契約 → 実装 → 設定・配線 |
| リスク | 壊れたときの影響半径が違うか | 追加のみの新規ファイル → 既存ファイルの書き換え → 破壊的変更 |

3 軸が競合したら 依存 > リスク > レイヤー の順で優先します。依存は物理的に順序が決まってしまうので選択の余地がなく、リスクは「先に安全なものを通して差分を減らす」ために早く出したいからです。
3. 実演:1,641 行の PR を 3 層に分解する
抽象論で終わらせないために、本リポジトリで実際にマージ済みの最大 PR を分解しました。対象は PR #429(commit b34539f、28 ファイル / 1,641 行)です。内容は「記事のファクトチェックを Claim Ledger という仕組みに一般化する」変更で、仕様・スクリプト・テスト・エージェント定義が同時に入っています。
前節の 3 軸を当てると、線は自然に 3 本に決まりました。
| 層 | 中身 | ファイル | 行 | レビュアーが見るもの |
|---|---|---|---|---|
| L1 contract | spec/ | 6 | 350 | 仕様として妥当か |
| L2 tooling | scripts/ tests/ package.json | 3 | 950 | 実装が仕様どおりか |
| L3 wiring | .agents/ .claude/ .specify/ | 19 | 341 | 既存フローに正しく繋がるか |
1,641 行を一度に読む代わりに、350 行 → 950 行 → 341 行の 3 回に分かれます。行数の合計は変わりませんが、1 回あたりの読解に必要な文脈は大きく減ります。L1 を読むときにスクリプトの実装は不要で、L3 を読むときには L1・L2 が「すでに承認された前提」になっているからです。これが影響範囲の推定コストを下げる仕組みです。
なお L2 が 950 行と一番厚いままなのは正直に書いておきます。内訳はスクリプト本体 446 行とテスト 499 行で、テストは並べて読める性質のものなので実質の読解量はもっと小さい、という判断でここは分けませんでした。機械的に等分するのが目的ではありません。
4. Stacked PR の各段が green かを確かめる
Stacked PR の各段は「その時点でマージしても壊れない」必要があります。検証すべきは各層を単独で切り出したときではなく、その段までを積み上げたスタックの先端が green かどうかです。積み上げながら実際に確かめました。手順はマージ済みコミットからの再構成です。
# 分解対象の親コミットから始める
git checkout -b tmp-stack-base b34539f^
pnpm test # exit 0(ベースライン, 8.5 秒)
# L1: contract のみ
git checkout -b tmp-stack-l1
git checkout b34539f -- spec/
pnpm test # exit 0(157 tests)
# L2: あえて依存順を誤らせてみる(テストだけ先に置く)
git checkout -b tmp-stack-l2bad
git checkout b34539f -- tests/ package.json
pnpm test # exit 1
# ModuleNotFoundError: No module named 'scripts.validate_claim_ledger'
# Ran 158 tests ... FAILED (errors=1) ← 157 + 収集に失敗したテスト1件
# L2: スクリプト本体を足して正順にする
git checkout b34539f -- scripts/validate_claim_ledger.py
pnpm test # exit 0(174 tests, OK)
# L3: wiring
git add -A && git commit -m "tmp L2"
git checkout -b tmp-stack-l3
git checkout b34539f -- .agents/ .claude/ .specify/
pnpm test # exit 0(Skills validation passed: 89 skills)
3 層すべてが green になりました。 なお次節の実験に移る前に、作業ツリーを必ず戻してください。ここまでで tmp ブランチが 4 本と未コミットの差分が残っています。
git reset --hard b34539f^ && git clean -fd
git checkout <元のブランチ>
git branch -D tmp-stack-base tmp-stack-l1 tmp-stack-l2bad tmp-stack-l3
結果(社内データ、2026-09-06)。
| ステップ | exit code | 内容 |
|---|---|---|
| base | 0 | 8.5 秒 |
| L1 contract | 0 | 157 tests |
L2(誤順・scripts/ 欠落) | 1 | ModuleNotFoundError |
| L2(正順) | 0 | 174 tests, OK |
| L3 wiring | 0 | 89 skills PASS |
つまりこの PR は最初から 3 本の PR として出せたということです。そして依存順を誤ったケースは、CI が 8.5 秒で教えてくれました。ここは自動化の効く領域です。CI 側の設計は AIコードのCI品質ゲート設計、実行時間そのものの短縮は CI/CD高速化 を参照してください。
5. テストが通っても、順序が正しいとは限らない
ここが今回いちばん重要な発見です。「各層で CI が green」は、順序が正しいことの証明になりません。
同じ PR で、wiring 層(.agents/ など)を最初に積んでみました。エージェント定義やスキル定義は Markdown なので、テストスイートは何も文句を言いません。
git checkout -b tmp-stack-l3first b34539f^
git checkout b34539f -- .agents/ .claude/ .specify/
pnpm test # exit 0(157 tests, Skills / Agent index も PASS)
# しかし、置いたばかりの定義は「まだ存在しないファイル」を参照している
grep -rhoE '(spec/article_claim_ledger\.md|scripts/validate_claim_ledger\.py)' \
.agents/ .claude/commands/ .specify/ | sort | uniq -c
# 6 scripts/validate_claim_ledger.py
# 6 spec/article_claim_ledger.md
ls spec/article_claim_ledger.md scripts/validate_claim_ledger.py
# ls: No such file or directory(両方)
テストは exit 0。しかし未存在パスへの参照が 12 箇所残っています(社内データ、2026-09-06)。この状態でマージすると、リポジトリは「動くが説明が嘘になっている」状態になります。エージェントやドキュメントは実行されないので、テストでは検出できません。この種の負債の蓄積は AI開発のコンテキスト負債 と同じ構図です。
対策は、テストとは別に 参照の実在チェックを各段に入れることです。20 行ほどで書けます。
#!/usr/bin/env bash
# stack-refcheck.sh — この段で追加/変更したファイルが参照するリポジトリ内パスが実在するか
# BASE には「1 つ下の段のブランチ」を渡す。スタックの最下段だけ origin/main。
set -euo pipefail
BASE="${BASE:-origin/main}"
ALLOWLIST='content/posts/(my-new-article|slug)/' # テンプレの例示パスは除外する
missing=0 scanned=0
while IFS= read -r f; do
[ -f "$f" ] || continue
case "$f" in *.md|*.MD|*.json|*.yml|*.yaml) ;; *) continue ;; esac
scanned=$((scanned+1))
while IFS= read -r ref; do
[ -n "$ref" ] || continue
[[ "$ref" =~ $ALLOWLIST ]] && continue
[ -e "$ref" ] || { echo "DANGLING: $f -> $ref"; missing=$((missing+1)); }
done < <(grep -ohE '(spec|scripts|tests|content|\.agents)/[A-Za-z0-9._/-]+\.[A-Za-z]+' "$f" | sort -u)
done < <(git diff --name-only "$BASE"...HEAD)
echo "scanned=$scanned missing=$missing"
[ "$missing" -eq 0 ] || exit 1
BASE を可変にしているのは、Stacked PR では各段の比較対象が main ではなく「1 つ下の段」だからです。既定のまま最下段以外で回すと、下の段の差分まで検査対象に入ります。また scanned=0 で終わったときは検査対象が 0 件だった、つまり何も見ずに PASS したという意味なので、CI では scanned を必ずログに出してください。
導入するときの注意が 2 つあります。
- 既存の負債で初回から red になります。 本リポジトリの
.agents/配下 239 ファイルに同じロジックを掛けたところ、11 件の dangling が出ました。うち 3 件はテンプレートの例示パス(上のALLOWLISTで除外)、残り 8 件は過去に消えたスクリプトへの参照です。まず現状をベースラインとして記録し、新規の増加だけを落とす運用から始めてください。 - 正規表現ベースなので誤検知はゼロになりません。 例示・将来のパス・コード断片の中のパスも拾います。allowlist をリポジトリ規約として置くのが前提です。
それでも、これを各段の CI に足すと順序違反が green を通り抜けにくくなります。Stacked PR で本当に守るべき不変条件は「テストが通ること」ではなく、**「各段でリポジトリが自己整合していること」**です。
6. Stacked PR の運用コストと、やらない判断
Stacked PR にはコストがあります。採用する前に見積もってください。
| コスト | 内容 | 緩和策 |
|---|---|---|
| rebase の連鎖 | 親を修正すると子・孫を全て積み直す | 親には「レビュー指摘が入りにくい層」(契約・仕様)を置く |
| CI 実行回数 | 1 PR が n 本になると CI も n 倍 | 層ごとに必要なジョブだけ動かす。本リポジトリは全体で 8.5 秒なので問題にならない |
| base の付け替え | 親がマージされたら子の base を main に変える | GitHub は open な PR の base branch を後から変更できる(公式値、Changing the base branch、確認日 2026-09-06)。ただし付け替えで一部コミットがタイムラインから外れ、レビューコメントが陳腐化する点は公式が明記している |
| マージ順序の管理 | 順序を守らないと壊れる | merge queue は「キュー内の先行 PR の変更 + 最新の base」に対して必須チェックを通す(公式値、Managing a merge queue、確認日 2026-09-06) |
やらないほうがいい場合もあります。
- 差分が 400 行未満で、かつ単一領域に収まっている。分割の管理コストが利得を超えます。
- 層に切ると各層が意味を成さない。たとえば 1 つの関数のリファクタリングを「前半・後半」で切るのは、レビュー単位としては悪化します。
- レビュアーが 1 人しかおらず、連鎖の待ち時間がそのまま全体のリードタイムになる。この場合はまず レビューワークフローの設計 から見直すほうが効きます。
なお「コミット列そのものをレビュー単位にする」ツールは以前から存在します。Gerrit では git commit は change の patch set として扱われ、Change-Id を保ったまま amend / rebase すると同じ change の新しい patch set になります(公式値、Gerrit intro-user、確認日 2026-09-06)。Sapling は "first-class support for editing and manipulating stacks of commits" を掲げています(公式値、Sapling — Stacks、確認日 2026-09-06)。GitHub の PR モデルでこれを再現するのが Stacked PR で、git-town は "Stacked changes let you implement and review complex work as a series of smaller, focused feature branches that build on top of each other" とほぼ同じ設計を提供します(公式値、Git Town — Stacked changes、確認日 2026-09-06)。ツール導入は後回しでよく、まず 3 軸で線を見つけ、各段で refcheck を回すところから始まります。
効果の測り方は 開発生産性メトリクス の枠組みに乗せてください。見るべきは PR 数ではなく、1 PR あたりのレビュー待ち時間と差し戻し回数です。生成量そのものを指標にすると AIでコードは増えるのに価値が伸びない 状態に戻ります。DORA は小さなバッチで進める能力について "working in small batches amplifies the positive impact of AI adoption on product performance" と述べています(DORA — Working in small batches、確認日 2026-09-06)。AI を入れたうえでバッチを大きいまま運用すると、増幅されるのは逆側です。
FAQ
何行を超えたら分割すべきですか
行数だけで決めないでください。本リポジトリでは p90 が 723 行なので、そこを「例外として扱う閾値」に置いています。よく引かれる「一度に 200〜400 行まで」という数字は、SmartBear が Cisco Systems の開発チームを対象に行った調査の知見であって業界標準ではありません(Best Practices for Code Review、確認日 2026-09-06)。重要なのはその PR が触っている関心事が 1 つかどうかです。300 行でも 3 つの関心事があれば分割対象、800 行でも 1 つのファイル生成だけなら分割不要です。
AI に「最初から分割して出して」と指示すれば済みませんか
部分的には済みます。ただしエージェントはタスク完了を目的に動くため、分割の順序制約(依存の向き)まで正しく守るとは限りません。本記事の実験でも、テストは通るのに参照が壊れている状態が作れました。指示ではなく検証で担保するのが安全です。分割を指示したうえで、各段に refcheck を掛ける組み合わせを推奨します。
親 PR にレビュー指摘が入ったらどうしますか
親を直して、子以降を rebase します。連鎖が長いほどこの再作業が重くなるので、指摘が入りにくい層を親に置くのが設計上のコツです。仕様や契約の層は先にレビューを終えれば動きにくいため、親に向いています。実装の細部は末端に置きます。
3 層より細かく割るべきですか
割る回数ではなく「各段が単独でマージできるか」で決めます。本記事の例では L2 が 950 行と厚いままですが、スクリプト本体とそのテストを分けると、テストだけの段が意味を失います(テスト対象が存在しないか、実装だけでテストが無い段ができる)。単独でマージしても自己整合する最小単位が層の粒度です。
CI が長いリポジトリでも成立しますか
CI 時間 × 段数がそのままコストになるので、段ごとに必要なジョブだけ動かす設計が前提になります。本リポジトリは全体で 8.5 秒なので制約になりませんでした。CI が数十分かかる場合は、先に実行時間を削るほうが投資対効果は高いです。
まとめ:今日やる 3 つ
Stacked PR は新しい概念ではありませんが、AI が巨大 PR を量産する環境では前提条件が変わりました。分割は例外対応ではなく既定の作業になります。
- 自リポの PR サイズ分布を測る(10 分)。 第 1 節の
git log+awkをそのまま実行してください。p90 と max が出れば、どこから例外扱いにするかが決まります。 - 直近の 400 行超 PR を 1 本選び、3 軸で線を引いてみる(30 分)。 本リポジトリでは 400 行超の 79% が最初から複数領域に跨っていました。おそらく引くまでもなく線は見えます。
- refcheck を CI に足す(30 分)。 テストが green でも順序違反は通ります。第 5 節のスクリプトが最小の出発点です。
分割の前提として「モデルを更新したときに何が壊れるか」を測る話は Coding Agentのモデル更新と回帰テスト に続きます。診断側は AI生成PRでレビューが詰まる本当の理由 に戻ってください。
References
- Google Engineering Practices — Small CLs(確認日 2026-09-06。"The right size for a CL is one self-contained change." および 100 行 / 1000 行の目安と、判断をレビュアーに委ねる留保)
- SmartBear — Best Practices for Code Review(確認日 2026-09-06。Cisco Systems の開発チームを対象とした調査に基づく 200〜400 行の知見)
- Gerrit — Working with Gerrit: An Example(確認日 2026-09-06。git commit が change の patch set として扱われ、
Change-Idで同一 change に紐づくモデル) - Sapling SCM — Stacks(確認日 2026-09-06。"first-class support for editing and manipulating stacks of commits")
- GitHub Docs — Changing the base branch of a pull request(確認日 2026-09-06。open な PR の base 変更機能と、コメントが陳腐化しうる旨)
- GitHub Docs — Managing a merge queue(確認日 2026-09-06。
merge_groupで最新 base と先行 PR を含めて必須チェックを通す仕組み) - Git Town — Stacked changes(確認日 2026-09-06。stacked changes の定義とブランチ連鎖の自動管理)
- DORA — Working in small batches(確認日 2026-09-06。小さなバッチが AI 導入の効果を増幅するという記述)
