Decompose large PRs into a DAG of small, reviewable PRs
- Stacked PR Mode — every PR with a single parent branches from and targets its parent's branch, so each sub-PR compiles and passes CI on its own. Pass
--stackto also register chains as native GitHub stacks via thegh-stackextension. - GitHub Action — add pr-split to any repo as a CI check. Scores every PR and posts a split plan comment when it's too large. No API key needed.
- Smart LOC Bounds — set
--min-locand--max-locto control sub-PR size across all three backends (LLM, graph, CP-SAT). Undersized groups get merged, oversized groups get penalised.
Vibe coding with AI assistants can produce massive PRs that no one wants to review. A 2,000 line PR with changes across dozens of files is a review bottleneck: teammates skim it, rubber stamp it, or just ignore it. pr-split turns that monolith into a set of focused, bite-sized PRs your team can actually review with confidence. Each sub-PR has a clear purpose, minimal scope, and explicit dependencies, so reviewers know exactly what changed and why.
pr-split takes a large pull request (local branch, PR number, or user:branch), sends the diff to an LLM for analysis, and produces a split plan: a set of smaller, focused PRs arranged in a dependency DAG. Each sub-PR gets its own branch, commit, and GitHub PR targeting the correct base.
pr-split is not published on PyPI; install it from this repository.
# With uv (recommended)
uv tool install "git+https://github.com/vitali87/pr-split"
# With pip
pip install "git+https://github.com/vitali87/pr-split"
# With the optional CP-SAT partitioning backend
uv tool install "pr-split[cp-sat] @ git+https://github.com/vitali87/pr-split"- Python 3.12+
- GitHub CLI (
gh) authenticated viagh auth login gh-stackextension (gh extension install github/gh-stack) when using--stackANTHROPIC_API_KEYorOPENAI_API_KEYenvironment variable set when using thellmpartition backend, unless you plan with a local model (no key needed). With neither and no--partition-strategy,splituses thegraphbackend, which needs no model, and says so
pr-split split feature-branch --base mainWorks for same-repo and fork PRs: the PR's head (refs/pull/<N>/head) is fetched from the repository gh resolves, through origin when that is the same repository, and its base branch is used.
pr-split split '#42' --base mainA bare number (with or without #) is always treated as a PR number, even if a
local branch or abbreviated commit hash with the same digits exists. To split a
local branch literally named like a number, pass its full ref:
pr-split split refs/heads/1006 --base mainpr-split split someuser:feature-branch --base mainpr-split split feature-branch --base main --dry-run| Flag | Default | Description |
|---|---|---|
--base |
main |
Base branch for the diff |
--min-loc |
unset | Minimum target diff lines per sub-PR |
--max-loc |
400 |
Maximum target diff lines per sub-PR |
--strict-loc-bounds |
false |
Fail if the final plan violates configured LOC bounds |
--max-refinement-iterations |
0 |
Maximum LLM refinement iterations to fix LOC bound violations (0 = disabled) |
--priority |
orthogonal |
Grouping priority (orthogonal or logical) |
--chunk-strategy |
dynamic_programming |
Large-diff chunking strategy (dynamic_programming or greedy) |
--partition-strategy |
llm |
Hunk-to-PR partition backend (llm, graph, or cp_sat). graph is deterministic; llm and cp_sat can return a different plan on each run, so treat the saved plan file as the artifact of record |
--cp-sat-timeout |
15.0 |
Maximum seconds to spend in the CP-SAT solver |
--stack |
false |
Register dependent PR chains as native GitHub stacks (a PR with one parent always branches from and targets it) |
--draft |
false |
Open every sub-PR as a draft |
--dry-run |
false |
Preview plan and save to .pr-split/plans/<dev-branch>.json (the branch name percent-encoded, so feat/big is feat%2Fbig.json) without creating branches or PRs |
pr-split split feature-branch --base main --stackA group with a single parent has its branch cut from the parent group's branch, carries the parent's hunks for shared files, and targets the parent's branch, with or without --stack. Its PR shows only its own diff and compiles standalone. When pr-split merge reaches such a child it retargets the PR at the base branch (gh pr edit --base) right before merging it, since GitHub only does that itself for native stacks or when the parent's head branch is deleted (which gh pr merge --auto skips). A group with several parents is cut from the merge base, carries every ancestor's changes, and targets the base branch, so its diff also includes those ancestors' changes (its PR description lists them). Groups with no dependencies are cut from the merge base and target the base branch. --stack additionally registers the chains as native GitHub stacks.
A new file larger than --max-loc cannot fit in any one sub-PR. With --stack it is cut into pieces between top-level definitions: Python files at top-level statements, and other languages at unindented lines after a blank line, outside brackets, strings and comments. Each piece is appended by a PR stacked on the one holding the piece before it. Every layer holds a valid prefix of the file, and the top of the chain holds the whole file. Without --stack a new file is never cut.
Linear chains in the plan are registered as native GitHub stacks via the gh-stack extension, which is required for --stack: install it with gh extension install github/gh-stack. pr-split checks for it up front and refuses to run a stacked split (or execute a stacked plan) without it; a --dry-run does not need it. If linking fails after the PRs are created, the command exits with an error — the plan state is already saved, so pr-split clean can undo the split. Groups that depend on more than one group target the base branch directly, since native stacks are strictly linear; their branch carries every ancestor's changes so it still builds standalone, and those extra changes drop out of the diff as the ancestor PRs merge.
pr-split statusShows a table with each sub-PR's ID, title, branch, PR number, live state (OPEN/CLOSED/MERGED), and review decision (Approved, Changes Requested, etc.) queried directly from GitHub. A PR whose state could not be fetched is shown as UNKNOWN with a warning, never as a stale OPEN.
pr-split mergeWalks the dependency DAG and merges each PR in topological order. Each child PR that targets its parent's branch is retargeted at the base branch right before it is merged (native-stack PRs are left to GitHub). Skips already-merged, closed, draft, review-required, or changes-requested PRs, and every PR whose dependency was not merged in this run (independent subtrees still proceed). Stops if a merge fails. Exits 1 whenever a merge failed, any PR was left blocked, or a PR's state could not be fetched from GitHub (check gh auth status), so re-run once the cause is resolved.
Use --auto to queue merges behind CI checks (uses gh pr merge --auto):
pr-split merge --auto--auto is not fire-and-forget: after queueing a batch, merge waits for every PR in it to reach MERGED before moving on to the dependent batch, polling GitHub every 10 seconds for up to 10 minutes per batch. If a PR is still unmerged when the timeout expires, the command stops before the dependent batch and exits 1 (webhook exit_reason: incomplete_batch). A PR closed while waiting is listed as <id> (CLOSED) among the skipped PRs, its dependants are skipped as blocked, and the run exits 1 (webhook exit_reason: pr_closed). Either way, re-run pr-split merge --auto once CI has caught up to continue from where it left off.
Use --notify to POST merge results to an http(s) webhook URL (e.g. Slack, Discord); other schemes are rejected up front, and a delivery failure (timeout, non-2xx) is logged as a warning without changing the exit code:
pr-split merge --notify https://hooks.slack.com/...Each split's plan is saved per dev branch under .pr-split/plans/, so splitting a second branch does not overwrite the first. execute, status, merge and clean use the only saved plan, or the one you name with the global --branch option when several are saved:
pr-split --branch feat/big status.pr-split/ is added to the repository's .git/info/exclude, so a new plan (which holds the whole diff) stays out of git status and git add -A. The exclude does not apply to a plan file git already tracks; remove it from the index with git rm --cached once. A plan saved by an older version at .pr-split/plan.json is moved to its branch's file the first time it is used.
pr-split executeCreates branches and PRs from a previously saved --dry-run plan. Uses the saved diff and merge base for consistency — safe even if the dev branch has changed since the dry run. Pass --stack or --draft to stack the PRs or open them as drafts even when the plan was saved without those flags. Pass --yes (-y) to skip the confirmation prompt, e.g. from a script or CI job.
pr-split adopt test/allowlist test/derive --base mainRegisters existing branches, bottom first, as a native GitHub stack on --base, opening a PR for any branch that has none, and saves them as a plan so status and merge work on them. Each branch must contain the one below it (the first must contain --base); otherwise it is refused. Pass --yes to skip the confirmation. The branches and PRs stay yours: pr-split clean forgets an adopted stack without closing its PRs or deleting its branches.
pr-split retarget # onto the repository's default branch
pr-split retarget --to developWhen a split was made with --base <feature-branch> and that branch later merges, its root PRs still target the merged branch, and GitHub refuses to change the base of a PR that is in a stack. retarget unstacks the plan's native stacks, points the root PRs at the new base, links the stacks again on it and updates the saved plan. status warns and merge refuses while the plan's base branch has already merged.
After the plan is displayed, an interactive editor lets you adjust the plan before confirming:
edit> show pr-1 # inspect a group's assignments
edit> move src/foo.py:2 pr-1 pr-2 # move a hunk between groups
edit> movefile src/foo.py pr-1 pr-2 # move every hunk of a file
edit> dep pr-3 pr-2 # make pr-3 depend on pr-2 (undep removes the edge)
edit> title pr-3 feat: add the parser # set the PR title
edit> desc pr-3 Adds the parser and its tests. # set the PR body text
edit> new pr-9 # create an empty group to move hunks into
edit> merge pr-1 pr-2 # fold pr-2 (hunks, parents, dependants) into pr-1
edit> plan # redisplay the plan table
edit> help # list the commands again
edit> done # proceed (default — just press Enter)
edit> abort # cancel
Groups you empty are dropped (their dependants inherit their dependencies); the plan is then re-validated for coverage gaps and LOC bounds.
Create .pr-split/template.md to customize the body of each generated PR using placeholders:
{description}
### Files
{files}
**Stats:** +{added}/-{removed} ({loc} lines)
{dag}Available placeholders: {description}, {files}, {added}, {removed}, {loc}, {dependencies}, {dag}, {id}, {title}.
pr-split restack # or: pr-split restack --dry-runAfter review feedback is fixed with a new commit on a lower layer's branch (locally or on GitHub), the layers above it lack the fix, and their diffs show it reversed. restack fetches the stack's branches, rebases each layer onto its parent's current head in plan order, and pushes the rewritten branches with --force-with-lease. It stops at the first conflicting layer, names it, and leaves that layer and everything above it unchanged. pr-split status warns about any layer that lacks its parent's head.
When the base branch itself moves on (another PR lands on main and touches a line your bottom layer also touches), run pr-split restack --onto-base. It fetches the base and rebases the layers that target it onto its current head, then restacks every layer above them, with the same conflict handling.
pr-split move docs/flags.md:1 --from pr-1 --to pr-3When review shows a change belongs in a higher layer, move takes it out of the source layer, carries that removal up through the layers in between, and applies it on the target layer, then restacks the layers above. Each step patches the layer's current content, so changes that arrived by other routes (a base merge, a review fix) are kept. Only changed branches are force-pushed, so every PR stays open with its threads, and the saved plan is updated. The hunk index is the one the plan editor's show prints.
pr-split recover feat/x # --base main if its PRs target more than one branchThe plan lives in .pr-split/plan.json of the checkout where split ran. If that checkout is gone (a removed worktree, a fresh clone), recover rebuilds the plan from the stack itself: it lists the PRs whose head branch is pr-split/<dev branch>/… and fetches those branches. Each layer depends on the layer its PR targets, or on the groups its PR body's "depends on" line names once a merged parent has been retargeted. status, merge, restack and clean then work as before. A recovered plan holds no diff, so execute and move cannot use it. recover refuses to replace an existing plan unless you pass --force.
Some repositories require every PR to carry files unique to it, such as a version bump above its base and a release-notes file for that version. With --stack each layer's base is its parent's branch, so each layer needs its own. Configure a command in .pr-split.toml at the repository root:
[per_group]
run = "scripts/bump-version.sh"
commit_message = "chore: release notes for {title}" # {id}, {title}, {index}execute, and split without --dry-run, run it in each sub-PR's worktree after that group's commit and before anything is pushed, in stack order. Whatever it changes is committed to that layer, and children are cut from the layer after it ran. The command gets PR_SPLIT_GROUP_ID, PR_SPLIT_GROUP_TITLE, PR_SPLIT_GROUP_INDEX, PR_SPLIT_PR_BASE (the branch the PR targets) and PR_SPLIT_PARENT_REF (the commit the layer was cut from, e.g. git show "$PR_SPLIT_PARENT_REF:Cargo.toml") as environment variables, never spliced into the command line. A failing command stops the run and names the group. PR_SPLIT_PER_GROUP_RUN and PR_SPLIT_PER_GROUP_COMMIT_MESSAGE override the file.
--base names the branch the sub-PRs are opened against. When it tracks a remote branch, split fetches it and diffs against the remote copy (for example origin/main), so commits that landed upstream after your local main was last updated are not split as branch work. A warning says when the local branch is behind or ahead of its upstream.
Running split again when a plan already exists will prompt you to clean up existing branches and PRs before re-planning. Dry-run plans are silently overwritten. With --dry-run, split never closes PRs or deletes branches: if the saved plan has already been executed it exits 1 and asks you to run pr-split clean first.
pr-split cleanCloses all split PRs, deletes their branches (local and remote), and removes the plan file.
Settings can be set via environment variables with the PR_SPLIT_ prefix:
| Variable | Default | Description |
|---|---|---|
PR_SPLIT_PROVIDER |
anthropic |
LLM provider: anthropic, openai, claude-cli (the local Claude Code CLI, claude -p, using its own login; no API key needed), or local (any OpenAI-compatible server such as Ollama, llama.cpp, vLLM or LM Studio; no API key) |
ANTHROPIC_API_KEY |
(required for Anthropic) | Anthropic API key |
OPENAI_API_KEY |
(required for OpenAI) | OpenAI API key |
PR_SPLIT_MODEL |
auto per provider | Model name (defaults to best available model for the chosen provider; required for local) |
PR_SPLIT_LOCAL_BASE_URL |
http://localhost:11434/v1 |
Base URL of the local server (the default is Ollama) |
PR_SPLIT_LOCAL_CONTEXT_TOKENS |
32768 |
Context window the local server was started with; larger diffs are chunked to fit |
PR_SPLIT_LOCAL_MAX_OUTPUT_TOKENS |
8192 |
Tokens reserved for the plan in each local reply |
PR_SPLIT_LOCAL_API_KEY |
(none) | Only for a local server started with an API key |
PR_SPLIT_MIN_LOC |
unset | Minimum target diff lines per sub-PR |
PR_SPLIT_MAX_LOC |
400 |
Default maximum target diff lines |
PR_SPLIT_STRICT_LOC_BOUNDS |
false |
Fail if the final plan violates configured LOC bounds |
PR_SPLIT_MAX_REFINEMENT_ITERATIONS |
0 |
Maximum LLM refinement iterations to fix LOC bound violations (0 = disabled) |
PR_SPLIT_PRIORITY |
orthogonal |
Default grouping priority |
PR_SPLIT_CHUNK_STRATEGY |
dynamic_programming |
Large-diff chunking strategy |
PR_SPLIT_PARTITION_STRATEGY |
llm if its provider is configured, else graph |
Hunk-to-PR partition backend |
PR_SPLIT_CP_SAT_TIMEOUT |
15.0 |
Maximum seconds to spend in the CP-SAT solver |
PR_SPLIT_STACK |
false |
Register dependent PR chains as native GitHub stacks |
PR_SPLIT_DRAFT |
false |
Open every sub-PR as a draft |
PR_SPLIT_WEBHOOK_URL |
(none) | Webhook URL for merge notifications |
Add pr-split as a CI check that scores every PR and posts a split plan when it's too large. Uses the graph backend by default — no API key needed.
# .github/workflows/split-score.yml
name: PR Split Score
on:
pull_request:
branches: [main]
permissions:
contents: read
pull-requests: write
jobs:
score:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: vitali87/pr-split@v1.0.1
with:
max-loc: "400"
partition-strategy: "graph"
threshold-groups: "2"| Input | Default | Description |
|---|---|---|
max-loc |
400 |
Maximum target diff lines per sub-PR |
min-loc |
(unset) | Minimum target diff lines per sub-PR |
partition-strategy |
graph |
Backend for partitioning (graph or cp_sat). cp_sat installs ortools automatically from v1.0.1 |
priority |
orthogonal |
Grouping priority (orthogonal or logical) |
threshold-groups |
2 |
Minimum suggested groups before posting the split plan |
python-version |
3.12 |
Python version to use |
post-comment |
true |
Whether to post a PR comment with the results |
| Output | Description |
|---|---|
total-loc |
Total lines of code in the PR diff |
total-groups |
Number of suggested groups |
objective |
Simplified plan score, overflow × 1000 + file_scatter × 50 + groups (lower is better; 0 when no split plan was scored; not the full score_plan objective described in METHODOLOGY.md) |
should-split |
Whether the PR should be split (true/false) |
pr-split now separates two optimization layers:
- Chunking: for diffs that exceed the model context window,
dynamic_programmingchooses chunk boundaries to avoid splitting the same file when possible.greedykeeps the previous first-fit behavior. - Partitioning:
llmpreserves the original semantic planner,graphuses deterministic affinity-based grouping, andcp_satuses an optimization model to balance group count, LOC, and cohesion.
The cp_sat backend requires the optional ortools package. Install it via the cp-sat extra: uv tool install "pr-split[cp-sat] @ git+https://github.com/vitali87/pr-split".
The llm backend can run entirely on your machine. Any server exposing the OpenAI-compatible /v1/chat/completions endpoint with tool calling works, and no API key is needed. With the default PR_SPLIT_LOCAL_BASE_URL (localhost) the diff never leaves the machine. If you point it at another host, the diff is sent there, and pr-split warns once that it is:
ollama pull qwen2.5-coder:14b
PR_SPLIT_PROVIDER=local PR_SPLIT_MODEL=qwen2.5-coder:14b \
pr-split split feature-branch --base main --partition-strategy llm --dry-runFor llama.cpp (llama-server --jinja -c 32768), vLLM or LM Studio, set PR_SPLIT_LOCAL_BASE_URL to the server's /v1 URL. Set PR_SPLIT_LOCAL_CONTEXT_TOKENS to the context size the server was started with, because Ollama's own default window is small. Requests use temperature 0, so the same diff gives the same plan. A model that answers with the plan as JSON text instead of a tool call is accepted too.
For no model at all, use --partition-strategy graph or cp_sat. Without an API key or a local provider, split picks graph on its own.
For a deeper explanation of the planning model, optimization methods, scoring, and research directions, see METHODOLOGY.md.
- Extracts the merge-base diff between your branch and the base (same view as GitHub's PR page)
- Sends the diff to the configured backend (LLM, graph, or CP-SAT), which groups hunks into logical sub-PRs with dependency ordering
- Makes each sub-PR work on its own, whichever backend planned it (see Standalone sub-PRs)
- Validates the plan: full coverage (every hunk assigned exactly once), no cycles, no merge conflicts between independent groups
- Shows you the plan (table + dependency tree) with an optional interactive editor to move hunks between groups
- Creates branches, commits, pushes, and opens GitHub PRs — materialization and push/PR creation run in parallel using git worktrees. Use
--dry-runto save the plan for later execution withexecute - For diffs exceeding the model's context window, uses the configured chunking strategy and processes chunks sequentially while carrying forward the group catalog across chunks
status,merge, andcleancommands are available to track progress, merge PRs in dependency order, or clean up branches and PRs
A backend groups hunks by size and affinity, which can leave a sub-PR needing code another sub-PR adds; its CI would then fail on its own. After planning, pr-split finds what each group needs and fixes the plan:
- Dependencies it uses. A group depends on every group whose new functions, classes, constants or fields it uses. It also depends on the group holding an added import it relies on, on the code that fills a new field it reads, and on the group that adds a name its
mock.patch("pkg.mod.name")targets. - Tests after the code they test. Tests are taken out of groups of unrelated code. A test group depends on every changed module its tests import (followed through unchanged modules), on the files it loads by path, and on its
conftest.py. Where the size limit allows, a test rejoins the group whose code it tests. - Test updates with the change they follow. An edit to an existing test, a conftest change, or a new autouse fixture moves into the group whose code change it adapts to. Otherwise the old test would fail in that group's own PR.
- Pieces that only work together. Hunks of one function or its decorators stay in one group, as does a lockfile with its manifest (
uv.lockandpyproject.toml,package-lock.jsonandpackage.json, …).
Groups that end up needing each other are combined, and each combination is logged with the reason. The combined PR may exceed --max-loc; with --strict-loc-bounds that is an error, as for any oversized group. Import analysis is for Python; for other languages tests are paired with the file they are named after. The analysis is static, so behaviour a test only exercises indirectly can still slip through: check the sub-PRs' CI.

