| name | stack-changes |
| description | Splits a large change into small, single-purpose units of review — PRs, diffs, or CLs — each making one argument (one diff, one thesis) and each buildable and testable on its own. Use when a change is too big to review, when a feature builds on existing code, when working in a large monorepo with stacked diffs, or when you need to land dependent changes and make the ordering clear. Triggers on "split this PR/diff/CL", "break up this change", "stack these", "one diff one thesis". |
Splitting Changes Into PRs
Overview
One diff, one thesis. Each change should make exactly one argument — and that argument should
be buildable and testable by itself. "One thing" isn't a size rule; it's a communication rule —
if a reviewer can't state the change's thesis in one sentence, it's carrying more than one. Size
follows from that.
This skill is about decomposition: given a change that already exists (or is fully designed),
how do you carve it into a stack where every change stands on its own and the ordering is
obvious to a reviewer. The core move is finding the natural seams between theses — the lines
along which one change honestly becomes two. The most common and highest-leverage seam, when a
feature builds on existing code, is refactor-first: the reshaping ships before the feature,
as its own changes, so the feature itself lands as a small, obvious diff. It's the most useful
technique here — but it is a technique in service of the principle, not the principle itself. A
change that doesn't sit on existing code splits along other seams entirely.
Why small single-thesis changes matter — empathy for the reviewer, plus review latency, CI cost,
and clean rollback/bisection at org scale: reference/why.md.
Procedure
Run this top to bottom; the rest of the document is the reference for each step.
- Detect the review system — run
scripts/detect-review-system.sh.
If you can't run it (no shell / sandbox / permissions), ask the fallback questions in
Detecting Your Review System instead of guessing.
- Inspect local context — the change shape, recent history + merge base, ownership/conventions,
and build/test hints. See Inspect Local Context.
- Emit a Split Plan — the proposed stack, ordered
refactor-first — and persist it to
STACK_PLAN.md so a multi-session stack survives interruptions.
- Confirm the plan with the user before touching git. Revise until they agree on the shape.
- Execute the mechanics for the detected system (Stacking in Your Review System).
Confirm before any history rewrite or force-push (see the Safety note there).
- Verify — run the per-revision loop (checkout → build → test each node) and confirm the Verification checklist.
- Communicate the dependency in every title and description.
A Note on Terminology
This skill says "change" for the unit of review — a PR (GitHub), diff
(Phabricator/Sapling), or CL (Critique/Gerrit); a stack is the dependent series. Full
mapping table: reference/terminology.md.
When to Use
- A change is over ~400 lines, or touches many files for more than one reason
- A new feature requires changing or reshaping existing code first
- A change mixes concerns: refactor + feature, behavior change + formatting, two features
- You need to land dependent (stacked) changes and want the dependency to be unambiguous
- A monorepo change spans multiple ownership boundaries (OWNERS / CODEOWNERS)
- A change would make presubmit build/test a huge set of targets
- Anything that makes a reviewer say "this is a lot — where do I start?"
When NOT to use: A genuinely atomic, single-purpose change. Don't split a 30-line bug
fix into a three-change stack — that's review theater. The goal is one thing per change,
not minimum lines per change.
The One-Thing Rule (One Diff, One Thesis)
A change carries exactly one thesis when it is the smallest chunk that can be understood,
tested, and rolled back on its own. Concretely, it passes these acceptance tests:
- Statable in one sentence — you can write its thesis without "and." If the honest title is
"Add caching and refactor the client," that's two theses → two changes.
- Understandable alone — a reviewer gets the point from this diff, without reading the
changes above or below it in the stack.
- Buildable alone — checked out on its own, the revision compiles. No references to code
that only exists in a sibling change.
- Testable alone — the change ships the tests for its own behavior, and the suite is green
at that change's revision (and passes presubmit/CI independently, at its position in the stack).
- Revertable alone — it can be rolled back without dragging unrelated work with it.
A change that builds but has no way to prove it works is not done — "buildable and testable by
itself" is one requirement, not two optional ones.
Atomic commits are not atomic reviews. The unit of review is the diff a reviewer opens —
the whole PR/diff/CL. Tidy 80-line commits inside one 900-line PR still ask the reviewer to
evaluate 900 lines at once. Split the reviewable unit: open a stack of small changes.
Right-Sizing: Don't Over-Split
"One thing" is a single concern, not a minimum line count. Over-splitting has real costs — every
change is a review round-trip, a CI run, and a rebase when its parent lands — so a stack of twenty
interdependent tiny pieces is worse than one well-organized change. Aim for the fewest changes
that each still do one thing and stand on their own:
- Don't split a single concern to hit a line target. If change 1 can't be understood, tested,
or motivated without change 2, fold them.
- Each change must be motivatable alone — its description justifies landing it on its own
terms, not "setup for the next one." No independent value and no test of its own = a fragment;
merge it upward.
- Batch trivial, same-concern, same-owner edits — one "prep" refactor, not one change per call
site. (Thousands of call sites = an LSC; see At Scale.)
- Keep stacks shallow — roughly 3–7 deep. Deeper usually means some changes are independent
(base on trunk, review in parallel), or the work is really a codemod/LSC.
Over-split litmus: if you can't write a one-line motivation for a change without referencing
a later change in the stack, it's too small. Merge it upward.
The Decomposition Process
1. Map the end state → what does the final diff actually touch?
2. Find the seams → split by thesis; refactor-vs-behavior is the most common seam
3. Order by dependency → topological sort: what must land before what (refactors first)
4. Size & scope each change → ~200–400 lines; one owner set; small blast radius
1. Map the end state
List every file the finished change touches and why each is touched. Group the "whys" —
each distinct why is a candidate change.
2. Find the seams (refactor vs behavior is the most common one)
A seam is a line along which one change honestly becomes two. Look for these, in rough order of
how often they pay off:
- Refactor vs behavior — the most common and highest-leverage seam (detailed below).
- Independent feature vs independent feature — two things that don't depend on each other
base on trunk and review in parallel; don't stack what isn't dependent.
- Behavior change vs behavior change — two distinct behaviors are two theses, even if they
touch the same file.
- Ownership boundary — a change spanning several OWNERS/CODEOWNERS sets splits so each piece
has one coherent owner set (see step 4).
- Risky vs safe — isolate the risky, hard-to-review part so the rest lands trivially.
The refactor/behavior seam. A pure refactor changes structure but not behavior: existing
tests pass unchanged. A behavior change adds or alters what the code does: it comes with new
or modified tests.
Never mix them in one change. A reviewer reading a mixed change can't tell which diff lines are
"safe restructuring" and which lines actually change what the system does — so they have to
treat all of it as risky. Split, and each half becomes easy:
- The refactor change: "assertions unchanged and still green → behavior is preserved."
- The feature change: a small diff against already-prepared code → the new behavior is the whole story.
Split by thesis, not by layer. "PR1: models, PR2: utils, PR3: tests, PR4: wire it up" is the
classic bad split — each piece has no independent value and nothing to prove until the last. Every
change carries its own thesis and its proof. (Worked example: reference/why.md.)
3. Order by dependency
Sort the candidate changes so each one only depends on changes before it. Refactors that
prepare the ground come first; the feature that uses them comes last.
4. Size & scope each change
Google's eng-practices put numbers on it: ~100 lines is a reasonable CL, ~1000 is usually too
large. And distribution matters as much as the count — 200 lines in one file can be fine,
but 200 lines spread across 50 files is usually too large. Treat one cohesive thesis of a few
hundred lines as the target, but never split a single concern below the point where it can stand
alone (see Right-Sizing). Two scale-aware criteria sharpen the
split:
- Split along ownership boundaries. Carve changes so each one touches a coherent
OWNERS / CODEOWNERS set. A change needing six teams' approval blocks on the slowest of six;
three changes of two owners each clear in parallel.
- Mind the blast radius. Keep each change's affected build targets / test impact small
(Bazel/Buck reverse-dependency sets). A small change triggers a cheap, fast presubmit; a
sprawling one re-runs half the repo.
See git-workflow-and-versioning for sizing heuristics and atomic-commit discipline within
each change.
Inspect Local Context (before you plan)
A Split Plan is only as good as what you know about the repo. Gather this first — guessing
produces a plausible-but-wrong plan. Run only the commands that apply (skip what isn't present).
- Current change shape — what you're actually splitting:
git status --short
git diff --stat ; git diff --name-only
git diff --cached --stat
- Recent history & base — where the stack roots and what's idiomatic here:
git log --oneline -n 10
git branch --show-current
trunk=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null)
trunk=${trunk:-origin/main}
git merge-base HEAD "$trunk"
- Review system — run
scripts/detect-review-system.sh
(or the fallback questions).
- Ownership & conventions — read if present:
CODEOWNERS / OWNERS (split along owner
boundaries), .github/pull_request_template.md (match the PR body), CONTRIBUTING.md,
CLAUDE.md (house rules, commit/PR conventions).
- Build/test hints — so each change's Test proof is real, not aspirational. Inspect the
manifest(s) —
package.json, pnpm-lock.yaml, pom.xml, build.gradle, Cargo.toml,
go.mod, pyproject.toml, BUCK / BUILD, etc. — and identify the test command (and, if you
can, the affected targets) for each proposed change.
Feed it into the plan: change shape → the proposed stack · merge base → the Base ·
CODEOWNERS/OWNERS → how you carve and order · manifests → the Test proof column ·
conventions/template → the description template.
The Split Plan (the artifact to produce)
Before touching git, emit a Split Plan — a predictable artifact the author (or a reviewer)
can sign off on before any branches exist. Always produce it in this shape:
## Split Plan
### Final goal
<one sentence: what the whole change accomplishes>
### Concerns currently mixed together
1. <concern> — refactor | behavior
2. ...
### Proposed stack
| # | Title | Type | Depends on | Files | Test proof | Risk |
|---|-------|------|------------|-------|------------|------|
| 1 | refactor: extract … seam | refactor | — | a.ts, b.ts | existing tests unchanged | low |
| 2 | feat: add … | feature | #1 | c.ts | new unit test | med |
### Parallelizable changes
<which changes are independent (base on trunk, review in parallel) vs strictly stacked>
### Land order
<bottom-up order; what must merge before what>
### PR/diff/CL description template
Stack (land in order):
#1 [1/N] … ← this change
#2 [2/N] …
Base: <branch> Depends on: <#prev or none> Do not land before: <…>
Produce this first, get agreement on the shape, then execute the mechanics below. The plan is
cheap to revise; a half-built stack of branches is not.
Persist it — one on-disk source of truth. Write the plan to STACK_PLAN.md at the repo root,
and add STACK_PLAN.md to .gitignore yourself (it's working state — don't leave an
uncommitted artifact behind). For a deep stack worked across more than one session this is
the anchor: on resume, read STACK_PLAN.md, re-run the
per-revision loop to find the first unbuilt/red node, and
continue from there — instead of re-deriving the plan from scratch and risking drift. Keep the
file current as the plan changes (check off landed nodes).
The Refactor-First Pattern
When you add a feature on top of existing code, the first changes are pure refactors that reshape
the existing code so the feature becomes a small diff. The refactors land first, each proving it
changed nothing (existing tests pass unchanged); by the time the feature change lands, it's a
tiny diff against already-prepared code. When a change doesn't sit on existing code, there's nothing
to refactor first — split along the other
seams.
(This repo's validation/demo/ carries this further — an expense-report monolith carved into a
refactor-first stack of small PRs.)
Dependency Types & Stacking
Independent changes Dependent (stacked) changes
trunk trunk
├── A (off trunk) └── 1 (off trunk)
├── B (off trunk) └── 2 (off 1)
└── C (off trunk) └── 3 (off 2)
Land in any order; Land bottom-up, in order;
review in parallel. each builds on the one below.
- Independent changes — base each on trunk. They don't touch each other; review and land
in any order. Prefer this whenever the pieces are truly independent — parallel review is faster.
- Dependent (stacked) changes — each is based on the previous change, because it needs that
code to build. Land strictly bottom-up.
Trunk-based + feature-gated is how partial stacks land safely. You don't have to wait for
the whole stack to be done before landing the bottom. Land each completed change to trunk with
the incomplete feature gated behind a flag (an experiment/config system, or a simple env
flag) so trunk stays releasable and you avoid a long-lived branch. See
git-workflow-and-versioning.
Detecting Your Review System
Before you stack, know what you are stacking on — the mechanics differ, and only some systems
offer commit-per-change. scripts/detect-review-system.sh
classifies the current repo and tells you which path below to use:
| It prints | Use the mechanics for | Commit-per-change? |
|---|
sapling | Sapling | native |
gerrit | Gerrit | native |
phabricator | Phabricator | native |
github-stacked | GitHub + stacking tool | via tooling (branch-per-PR underneath) |
git-local | local commits, no remote yet | yes — locally, until you push |
github-plain | Plain branch-per-PR — any remote we can't classify (GitHub/GHE, GitLab/Bitbucket, self-hosted) | no — branch-per-PR only |
It exits 0 when commit-per-change is available, 1 for branch-per-PR (github-plain), and 2
for an unknown / non-repo dir. Its full fixture matrix — every row above plus adversarial cases —
lives beside it in scripts/detect-review-system.test.sh and runs in CI on every push.
The detector is a heuristic — treat the output as a hint and override it when it's wrong.
Known failure modes:
gerrit fires only on strong signals — .gitreview or the commit-msg hook. A bare
Change-Id: in history counts only when there's no GitHub remote, so a github.com repo that
imported a Gerrit patch stays github-plain (an adversarial fixture pins this). Residual: a
non-GitHub repo with a stray Change-Id and no .gitreview/hook can still read as gerrit —
if you don't push to refs/for/*, you're not on Gerrit.
- Graphite needs its config — it looks for
.git/.graphite_repo_config; a Graphite user who
hasn't gt init-ed degrades to github-plain (a fixture documents this). If you use gt,
treat it as github-stacked.
- Any unclassified remote →
github-plain. GitLab/Bitbucket (cloud or self-hosted),
GitHub Enterprise on a custom domain (github.acme.com, git.acme.com — we can't fingerprint
these from the URL), Gitea, etc. all route to github-plain (branch-per-PR), not git-local.
git-local (exit 0, commit-per-change) now means no remote configured yet — a true local
commit stack. If the detector is wrong about a custom host's exact tool, the mechanics are still
branch-per-PR; only the CLI differs (glab/web instead of gh).
If you can't run the script, don't guess — ask the user three questions:
- Where do code reviews happen — GitHub PRs, Phabricator/Sapling diffs, Gerrit CLs, or just
local commits for now?
- Do you use a stacking tool (Graphite
gt, spr, ghstack)?
- Is there a remote yet, or is this local-only?
Then pick the matching mechanics below.
It works before any remote exists. A git-local repo (commits, no remote) is the normal
starting point. Split your local commit stack now — git rebase -i to split/reorder/squash
commits, or git reset to uncommit and re-stage in single-thesis pieces — and bind each change
to a PR/diff/CL when you push. The decomposition is the same work whether the review tool is
chosen yet or not.
Detect and adapt — don't refuse. The result picks the plumbing, not whether to split. The
decomposition (one diff one thesis, refactor-first, communicate the dependency) is identical
everywhere; only the stacking commands change.
Stacking in Your Review System
Principle: let the tool track the stack; you choose the decomposition. Modern review
systems make stacked changes first-class and auto-retarget descendants for you — the manual
rebase dance below is only needed when you have no stacking tool.
Strongly prefer a stacking tool (or a native commit-per-change system) over the manual path.
The plain-git git rebase --onto fallback is the riskiest thing this skill does — a wrong
base silently corrupts the stack, and an agent driving it can't always see the corruption. Use it
only as a last resort; rebase one branch at a time, and re-run the per-revision verify
loop after each rebase so a bad base fails loudly instead of
rotting the stack.
⚠ Safety — history rewrite & force-push. Several mechanics below run git rebase / git reset
(which rewrite history) and git push --force-with-lease. Confirm with the user before
rewriting history or force-pushing, and never force-push a shared or protected branch
(main/master, a release branch, or anyone else's branch). Force-push only your own stack
branches. These are hard-to-reverse, outward-facing actions — pause and check first.
Tool specifics drift. The behaviors below (Sapling auto-restack, Graphite auto-retarget on
merge, the exact gt / spr / ghstack flags) are current as of writing. If an invocation has
changed, the principle still holds — confirm the exact command against the tool's own docs.
Sapling (Meta-style stacked diffs)
Stacks are the default. Each commit is a change; the stack is just your commit stack.
sl commit -m "refactor: extract Report core seam"
sl commit -m "refactor: introduce Formatter registry"
sl pr submit
Amend a lower change (sl amend) and Sapling auto-restacks the descendants — no manual
rebase. The UI shows the stack graph; reviewers land bottom-up.
Phabricator (arc)
One commit per change; arc diff turns your commit stack into a diff stack.
arc diff
The dependency is recorded structurally — reviewers see "Depends on D123" and the stack view.
Gerrit
One commit = one change, keyed by a Change-Id trailer; consecutive commits form a
relation chain.
git commit
git push origin HEAD:refs/for/main
Submit bottom-up; Gerrit shows the relation chain and blocks out-of-order submits.
GitHub with a stacking tool
GitHub has no native commit-per-change model. Its reviewable unit is always a
branch-to-branch diff (a PR = head branch vs base branch): you can't push a stack of commits
and have GitHub turn each commit into its own PR, there is no Change-Id / relation chain, and
approval and merge happen at the PR level — never per commit. So on GitHub a stack is always
branches underneath; the only question is whether you manage them or a tool does.
A stacking tool (Graphite gt, spr, ghstack) gives you the Sapling/Gerrit-style
commit-per-change authoring experience on top of that branch-per-PR substrate: you keep a
local stack of plain commits, and the tool generates and retargets one branch + one PR per
commit for you.
gt create -m "refactor: extract seam"
gt create -m "refactor: registry"
gt submit
On merge of the bottom PR, the tool retargets the next onto trunk automatically.
Plain git + GitHub (fallback — last resort, no stacking tool; the riskiest path)
Manage bases and rebases by hand — verify after every rebase (see the warning above):
git switch -c stack/1-seam main
gh pr create --base main --title "[1/4] refactor: extract seam"
git switch -c stack/2-registry stack/1-seam
gh pr create --base stack/1-seam --title "[2/4] refactor: registry (needs #<1>)"
gh pr edit <2> --base main
git rebase --onto main stack/1-seam stack/2-registry
git push --force-with-lease
If a rebase reports a conflict — stop. This is the corruption point. Do not resolve
conflicts you don't fully understand, and never git rebase --continue by guessing — a wrong
resolution silently rewrites what a "refactor" did (the exact failure the warning above is about):
git rebase --abort
git status
Then surface it to the user: report which branch failed to rebase onto which base and the
conflicting files, and ask how to proceed (resolve it together, re-order the stack, or switch to a
stacking tool). After any successful rebase, re-run the
per-revision verify loop for that branch and every branch stacked
above it — a wrong base corrupts everything on top, so one green node is not proof.
Communicating the Dependency
Even when the tool tracks the stack structurally, the title should still tell a human
skimming a review queue where this change sits and what it needs first.
Title conventions:
- Prefix the stack position:
[2/4].
- Name the prerequisite explicitly:
(needs #123) / — requires D123.
- Lead with the change type so the refactor/feature split is visible:
refactor: / feat:.
[1/4] refactor: extract Report core into a seam
[2/4] refactor: introduce Formatter registry (needs #1)
Description template (put this in every change in the stack so any entry point is self-explanatory):
Stack (land in order):
1 [1/4] refactor: extract seam ← this change
2 [2/4] refactor: Formatter registry
3 [3/4] feat: CsvFormatter behind flag
4 [4/4] feat: enable CSV export
Base: trunk
Depends on: (none — bottom of stack)
Do not land before: (none)
Phabricator, Gerrit, Sapling, and Graphite all render the stack graph natively — so the title
convention is belt-and-suspenders. Keep it anyway: it's what a reviewer reads first in a list
of fifty changes.
At Scale
Splitting is load-bearing in a large org: small-change culture (Google's small-CL guidance),
presubmit economics, ownership-aligned splits, feature-gating over long branches, shallow stacks
for land queues, and the LSC/codemod escape hatch for repo-wide sweeps (don't hand-split a
3,000-file rename — generate it). Full notes: reference/at-scale.md.
Verify the Stack (per-revision)
Don't assert that each change builds — observe it. The skill doesn't need to understand
Bazel vs Cargo vs npm; it runs the project's own build and test command and reads the exit
code. That portability is the whole point.
- Find the build + test command — from the manifest you read in
Inspect Local Context, or ask the user. Default to
whatever the repo's CI runs. Examples:
npm run build && npm test · cargo build && cargo test · go build ./... && go test ./... ·
mvn -q verify · ./gradlew test · pytest · bazel test //....
- Walk the stack bottom-up. For each change, in order:
git checkout <change-ref>
<build command>
<test command>
Record green/red for that node.
- Stop at the first red. A red node means the stack is invalid there — a change references
code introduced by a later one, or a "refactor" actually changed behavior. Report which node
failed and why; do not present an unverified stack as done. Fix the decomposition, re-verify.
A refactor change has a sharper check: build + test green and the test assertions and
expected outputs are unchanged from the parent. Byte-identical test files (git diff <parent> -- <test paths> is empty) is the strongest form of that proof — but it's sufficient, not
necessary. A legitimate pure refactor can still touch test files mechanically: a rename updates
call sites, a file move updates import paths. So the real invariant is "no asserted value or
expected output changed"; when a refactor must edit tests, confirm the diff is purely mechanical
(names/paths), with every assertion intact.
When done, restore the user's working state (git checkout - / their branch) — don't leave
them on a detached revision. And per the Safety note, confirm before any rebase/force-push.
Verification
Before submitting, confirm every change passes the
One-Thing Rule acceptance tests (statable in one
sentence, understandable / buildable / testable / revertable alone, not a fragment) — and, at the
stack level:
Related Skills
git-workflow-and-versioning — atomic commits, trunk-based development, feature flags, change sizing
incremental-implementation — building each change in thin, tested slices
code-review-and-quality — what a reviewable change looks like from the reviewer's side
planning-and-task-breakdown — deciding the split at plan time, before code exists
deprecation-and-migration — the Large-Scale-Change / codemod playbook for repo-wide sweeps
Further Reading