diff --git a/.github/workflows/split-score.yml b/.github/workflows/split-score.yml
index d304a85..73b3f72 100644
--- a/.github/workflows/split-score.yml
+++ b/.github/workflows/split-score.yml
@@ -17,7 +17,7 @@ jobs:
fetch-depth: 0
- name: pr-split score
- uses: vitali87/pr-split@v1.0.0
+ uses: vitali87/pr-split@95cdcd9748109351b2aa7129ede2269989d8f61c # v1.0.1
with:
max-loc: "400"
partition-strategy: "graph"
diff --git a/README.md b/README.md
index 01a0b24..124af7e 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
## Latest News 🔥
-- Stacked PR Mode — pass `--stack` and every dependent PR branches from and targets its parent's branch, so each sub-PR compiles and passes CI on its own. Chains are registered as native GitHub stacks via the `gh-stack` extension when it is installed.
+- 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 `--stack` to also register chains as native GitHub stacks via the `gh-stack` extension.
- 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-loc` and `--max-loc` to control sub-PR size across all three backends (LLM, graph, CP-SAT). Undersized groups get merged, oversized groups get penalised.
@@ -24,25 +24,31 @@ Vibe coding with AI assistants can produce massive PRs that no one wants to revi
## How it works
-`pr-split` takes a large pull request (local branch, fork 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` 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.
## Installation
+pr-split is not published on PyPI; install it from this repository.
+
```bash
# With uv (recommended)
-uv tool install pr-split
+uv tool install "git+https://github.com/vitali87/pr-split"
# With pip
-pip install pr-split
+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"
```
## Prerequisites
- Python 3.12+
- [GitHub CLI](https://cli.github.com/) (`gh`) authenticated via `gh auth login`
-- `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` environment variable set when using the `llm` partition backend
+- [`gh-stack` extension](https://github.com/github/gh-stack) (`gh extension install github/gh-stack`) when using `--stack`
+- `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` environment variable set when using the `llm` partition backend, unless you plan with a [local model](#local-models) (no key needed). With neither and no `--partition-strategy`, `split` uses the `graph` backend, which needs no model, and says so
## Usage
@@ -52,7 +58,9 @@ pip install pr-split
pr-split split feature-branch --base main
```
-### Split a fork PR by number
+### Split a PR by number
+
+Works for same-repo and fork PRs: the PR's head (`refs/pull//head`) is fetched from the repository `gh` resolves, through `origin` when that is the same repository, and its base branch is used.
```bash
pr-split split '#42' --base main
@@ -81,10 +89,11 @@ pr-split split feature-branch --base main --dry-run
| `--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`) |
-| `--stack` | `false` | Stack dependent PRs: each child branches from and targets its parent's branch |
+| `--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/plan.json` without creating branches or PRs |
+| `--dry-run` | `false` | Preview plan and save to `.pr-split/plans/.json` (the branch name percent-encoded, so `feat/big` is `feat%2Fbig.json`) without creating branches or PRs |
### Stack dependent PRs
@@ -92,9 +101,11 @@ pr-split split feature-branch --base main --dry-run
pr-split split feature-branch --base main --stack
```
-Without `--stack`, every sub-PR branch is cut from the merge base and targets the base branch, so a sub-PR that depends on code from another group only goes green once its dependency merges. With `--stack`, each dependent group's branch is cut from its parent group's branch and carries the parent's hunks for shared files, and its PR targets the parent's branch. Every PR shows only its own diff, compiles standalone, and GitHub retargets children automatically as parents merge.
+A 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, compiles standalone, and GitHub retargets it automatically as the parent merges. 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 also registered as [native GitHub stacks](https://github.blog/changelog/2026-07-30-stacked-pull-requests-are-now-in-public-preview/) via the [`gh-stack` extension](https://github.com/github/gh-stack) (`gh extension install github/gh-stack`). If the extension is missing the linking step is skipped with a warning — the PRs are already correctly chained without it. 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.
+Linear chains in the plan are registered as [native GitHub stacks](https://github.blog/changelog/2026-07-30-stacked-pull-requests-are-now-in-public-preview/) via the [`gh-stack` extension](https://github.com/github/gh-stack), 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.
### Check status of an existing split
@@ -102,7 +113,7 @@ Linear chains in the plan are also registered as [native GitHub stacks](https://
pr-split status
```
-Shows 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.
+Shows 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.
### Merge split PRs in dependency order
@@ -110,7 +121,7 @@ Shows a table with each sub-PR's ID, title, branch, PR number, live state (OPEN/
pr-split merge
```
-Walks the dependency DAG and merges each PR in topological order. Skips already-merged, closed, draft, review-required, or changes-requested PRs. Stops if a merge fails or a dependency wasn't merged to prevent out-of-order merges.
+Walks the dependency DAG and merges each PR in topological order. 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 or any PR was left blocked, so re-run once the blocking PRs are ready.
Use `--auto` to queue merges behind CI checks (uses `gh pr merge --auto`):
@@ -118,19 +129,48 @@ 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, or gets closed while waiting, the command stops before the dependent batch and exits 1 (webhook `exit_reason: incomplete_batch`); 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 a webhook URL (e.g. Slack, Discord):
```bash
pr-split merge --notify https://hooks.slack.com/...
```
+### Several splits in one checkout
+
+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:
+
+```bash
+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.
+
### Execute a saved dry-run plan
```bash
pr-split execute
```
-Creates 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.
+Creates 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.
+
+### Stack branches you already built
+
+```bash
+pr-split adopt test/allowlist test/derive --base main
+```
+
+Registers 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.
+
+### Move a split onto a new base
+
+```bash
+pr-split retarget # onto the repository's default branch
+pr-split retarget --to develop
+```
+
+When a split was made with `--base ` 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.
### Interactive plan editing
@@ -139,12 +179,18 @@ After the plan is displayed, an interactive editor lets you adjust the plan befo
```
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> done # proceed (default — just press Enter)
edit> abort # cancel
```
-The plan is re-validated after editing to catch empty groups or coverage gaps.
+Groups you empty are dropped (their dependants inherit their dependencies); the plan is then re-validated for coverage gaps and LOC bounds.
### Custom PR body templates
@@ -163,6 +209,48 @@ Create `.pr-split/template.md` to customize the body of each generated PR using
Available placeholders: `{description}`, `{files}`, `{added}`, `{removed}`, `{loc}`, `{dependencies}`, `{dag}`, `{id}`, `{title}`.
+### Carry a fix up a stack
+
+```bash
+pr-split restack # or: pr-split restack --dry-run
+```
+
+After 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.
+
+### Move a hunk up an executed stack
+
+```bash
+pr-split move docs/flags.md:1 --from pr-1 --to pr-3
+```
+
+When 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.
+
+### Recover a lost plan
+
+```bash
+pr-split recover feat/x # --base main if its PRs target more than one branch
+```
+
+The 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//…` 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`.
+
+### Per-PR release gates
+
+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:
+
+```toml
+[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.
+
+### Stale local base branch
+
+`--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.
+
### Re-split with different parameters
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.
@@ -181,18 +269,23 @@ Settings can be set via environment variables with the `PR_SPLIT_` prefix:
| Variable | Default | Description |
|----------|---------|-------------|
-| `PR_SPLIT_PROVIDER` | `anthropic` | LLM provider (`anthropic` or `openai`) |
+| `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) |
+| `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` | Hunk-to-PR partition backend |
-| `PR_SPLIT_STACK` | `false` | Stack dependent PRs on their parent's branch |
+| `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 |
@@ -219,7 +312,7 @@ jobs:
with:
fetch-depth: 0
- - uses: vitali87/pr-split@main
+ - uses: vitali87/pr-split@v1.0.1
with:
max-loc: "400"
partition-strategy: "graph"
@@ -232,7 +325,7 @@ jobs:
|-------|---------|-------------|
| `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`) |
+| `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 |
@@ -254,7 +347,21 @@ jobs:
- **Chunking**: for diffs that exceed the model context window, `dynamic_programming` chooses chunk boundaries to avoid splitting the same file when possible. `greedy` keeps the previous first-fit behavior.
- **Partitioning**: `llm` preserves the original semantic planner, `graph` uses deterministic affinity-based grouping, and `cp_sat` uses an optimization model to balance group count, LOC, and cohesion.
-The `cp_sat` backend requires the optional [`ortools`](https://developers.google.com/optimization) package to be installed in the runtime environment.
+The `cp_sat` backend requires the optional [`ortools`](https://developers.google.com/optimization) package. Install it via the `cp-sat` extra: `uv tool install "pr-split[cp-sat] @ git+https://github.com/vitali87/pr-split"`.
+
+### Local models
+
+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:
+
+```bash
+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-run
+```
+
+For 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](METHODOLOGY.md).
diff --git a/action.yml b/action.yml
index dc53a20..6ff063f 100644
--- a/action.yml
+++ b/action.yml
@@ -55,7 +55,14 @@ runs:
- name: Install pr-split
shell: bash
- run: uv tool install "${{ github.action_path }}"
+ env:
+ PARTITION_STRATEGY: ${{ inputs.partition-strategy }}
+ run: |
+ if [ "$PARTITION_STRATEGY" = "cp_sat" ]; then
+ uv tool install "${{ github.action_path }}[cp-sat]"
+ else
+ uv tool install "${{ github.action_path }}"
+ fi
- name: Score PR
id: score
diff --git a/pr_split/cli.py b/pr_split/cli.py
index 7121b06..ad690a4 100644
--- a/pr_split/cli.py
+++ b/pr_split/cli.py
@@ -5,7 +5,7 @@
import tempfile
import time
import urllib.request
-from collections.abc import Generator
+from collections.abc import Callable, Generator
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from threading import Lock, Semaphore
@@ -15,6 +15,7 @@
from loguru import logger
from pydantic import ValidationError
from rich.console import Console
+from rich.markup import escape
from rich.panel import Panel
from rich.table import Table
from rich.tree import Tree
@@ -28,10 +29,8 @@
DEFAULT_MAX_LOC,
DEFAULT_MAX_REFINEMENT_ITERATIONS,
DEFAULT_MIN_LOC,
- DEFAULT_PARTITION_STRATEGY,
DEFAULT_STRICT_LOC_BOUNDS,
- PLAN_DIR,
- PLAN_FILE,
+ NO_BACKEND_STRATEGY,
AssignmentType,
ChunkStrategy,
PartitionStrategy,
@@ -44,14 +43,23 @@
merge_chain_assignments,
parse_diff,
)
-from .exceptions import ErrorMsg, PlanValidationError, PRCreationError, PRSplitError
+from .exceptions import (
+ ErrorMsg,
+ GitOperationError,
+ PlanValidationError,
+ PRCreationError,
+ PRSplitError,
+)
from .git_ops import (
add_worktree,
+ adopt_remote_branch,
branch_exists,
check_gh_auth,
+ check_gh_stack,
commit_files_in_dir,
delete_branch,
derive_split_namespace,
+ diff_base_ref,
fetch_fork_branch,
fetch_fork_pr,
is_worktree_clean,
@@ -59,11 +67,31 @@
push_branch,
remove_worktree,
)
-from .git_ops.branches import run_git
-from .git_ops.prs import close_pr, create_pr, get_pr_state, link_stack, merge_pr
+from .git_ops.branches import commit_exists, run_git
+from .git_ops.prs import (
+ branch_has_merged,
+ close_pr,
+ create_pr,
+ default_branch,
+ find_open_pr,
+ get_pr_state,
+ link_stack,
+ merge_pr,
+ set_pr_base,
+ stack_numbers_for,
+ unstack,
+)
from .graph import PlanDAG
-from .plan_store import load_plan, plan_exists, save_plan
-from .planner import plan_split, validate_coverage, validate_plan
+from .per_group import PerGroupStep, load_per_group_step, run_per_group_step
+from .plan_store import load_plan, plan_dir, plan_exists, plan_path, save_plan, select_plan
+from .planner import plan_split, validate_coverage, validate_no_binary_files, validate_plan
+from .planner.chunker import recompute_estimated_loc
+from .planner.new_file_pieces import link_new_file_pieces
+from .planner.partitioning import refresh_generated_description
+from .planner.validator import validate_new_file_pieces
+from .recover import recover_plan
+from .restack import restack as restack_layers
+from .restack import stale_layers
from .schemas import (
BranchRecord,
GitState,
@@ -73,6 +101,7 @@
PRRecord,
SplitPlan,
)
+from .stack_move import move_hunk as move_stack_hunk
from .types_defs import ForkPRInfo
app = typer.Typer(
@@ -82,6 +111,21 @@
console = Console()
+@app.callback()
+def _choose_plan(
+ branch: Annotated[
+ str | None,
+ typer.Option(
+ "--branch",
+ envvar="PR_SPLIT_BRANCH",
+ help="Dev branch whose saved plan to use when several splits are in progress",
+ ),
+ ] = None,
+) -> None:
+ # Plans are saved per dev branch; without --branch the only saved plan is used.
+ select_plan(branch)
+
+
def _render_dag(groups: list[Group]) -> str:
roots = [g for g in groups if not g.depends_on]
tree = Tree("Split Plan")
@@ -125,19 +169,73 @@ def _add_children(parent_id: str, prefix: str) -> None:
return f"## Dependency graph\n\nMerge in this order:\n\n```\n{tree_block}\n```"
-def _validate_inputs(dev_branch: str, base: str, *, dry_run: bool = False) -> None:
+def _require_local_branch(base: str) -> None:
+ """Exit unless ``base`` names a local branch head.
+
+ A remote-tracking ref such as origin/main (or a tag or SHA) resolves
+ locally, so the split would run to completion -- branches created and
+ pushed -- and only fail when GitHub is asked to open PRs against a
+ branch it does not have.
+ """
+ if branch_exists(f"refs/heads/{base}"):
+ return
+ remote_prefix = "refs/remotes/" if base.startswith("refs/remotes/") else ""
+ stripped = base.removeprefix(remote_prefix)
+ suggestion = ""
+ if remote_prefix or "/" in stripped:
+ remote, _, name = stripped.partition("/")
+ if name and remote in _remote_names():
+ suggestion = f" (for example '{name}')"
+ console.print(
+ f"[red]{ErrorMsg.BASE_NOT_A_LOCAL_BRANCH(base=base, suggestion=suggestion)}[/red]"
+ )
+ raise typer.Exit(1)
+
+
+def _remote_names() -> set[str]:
+ try:
+ return set(run_git("remote").split())
+ except PRSplitError:
+ return set()
+
+
+def _require_gh_stack() -> None:
+ try:
+ installed = check_gh_stack()
+ except GitOperationError as exc:
+ console.print(f"[red]{exc}[/red]")
+ raise typer.Exit(1) from exc
+ if not installed:
+ console.print(f"[red]{ErrorMsg.GH_STACK_MISSING()}[/red]")
+ raise typer.Exit(1)
+
+
+def _validate_inputs(
+ dev_branch: str, base: str, *, dry_run: bool = False, stacked: bool = False
+) -> None:
if not branch_exists(dev_branch):
console.print(f"[red]{ErrorMsg.BRANCH_NOT_FOUND(branch=dev_branch)}[/red]")
raise typer.Exit(1)
if not branch_exists(base):
console.print(f"[red]{ErrorMsg.BRANCH_NOT_FOUND(branch=base)}[/red]")
raise typer.Exit(1)
+ _require_local_branch(base)
if not is_worktree_clean():
console.print(f"[red]{ErrorMsg.DIRTY_WORKTREE()}[/red]")
raise typer.Exit(1)
if not dry_run and not check_gh_auth():
console.print(f"[red]{ErrorMsg.GH_AUTH_FAILED()}[/red]")
raise typer.Exit(1)
+ if not dry_run and stacked:
+ _require_gh_stack()
+
+
+def _load_plan_or_exit() -> PlanFile:
+ try:
+ return load_plan()
+ except PRSplitError as exc:
+ console.print(f"[red]{exc}[/red]")
+ raise typer.Exit(1) from exc
def _handle_loc_bound_warnings(warnings: list[str], *, strict_loc_bounds: bool) -> None:
@@ -151,6 +249,58 @@ def _handle_loc_bound_warnings(warnings: list[str], *, strict_loc_bounds: bool)
logger.warning(warning)
+def _plan_provenance(plan: SplitPlan) -> str:
+ """One line naming how the plan was made; llm and cp_sat plans vary run to run."""
+ how = plan.partition_strategy or "unknown"
+ if plan.provider:
+ how += f" ({plan.provider}{' ' + plan.model if plan.model else ''})"
+ if plan.partition_strategy == NO_BACKEND_STRATEGY:
+ return "[dim]Kept as one group: the diff is within --max-loc, so no backend ran.[/dim]"
+ note = (
+ ""
+ if plan.partition_strategy == PartitionStrategy.GRAPH.value
+ else "; re-running may give a different plan, so keep the saved plan file"
+ )
+ return f"[dim]Planned with {escape(how)}{note}.[/dim]"
+
+
+def _oversized_group_ids(groups: list[Group], max_loc: int) -> list[str]:
+ return [g.id for g in groups if g.estimated_loc > max_loc]
+
+
+def _report_oversized_groups(
+ groups: list[Group], max_loc: int, hunk_counts: dict[str, int]
+) -> None:
+ """One visible line when groups miss the --max-loc target, so it is never silent.
+
+ ``hunk_counts`` maps each file to its parsed hunk count, so a WHOLE_FILE
+ assignment is counted by the hunks it covers, not the indices it lists.
+ """
+ oversized = [g for g in groups if g.estimated_loc > max_loc]
+ if not oversized:
+ return
+ largest = max(oversized, key=lambda g: g.estimated_loc)
+ # A group holding one hunk (e.g. a whole new file) cannot be split by any
+ # plan; say so rather than leave it looking like a planning miss.
+ single_hunk = [
+ g.id
+ for g in oversized
+ if sum(len(a.covered_indices(hunk_counts.get(a.file_path, 0))) for a in g.assignments) == 1
+ ]
+ irreducible = (
+ f" {', '.join(escape(gid) for gid in single_hunk)} hold a single hunk each and"
+ " cannot be split below the limit."
+ if single_hunk
+ else ""
+ )
+ console.print(
+ f"[yellow]{len(oversized)} of {len(groups)} groups exceed --max-loc {max_loc}"
+ f" (largest: {escape(largest.id)} at {largest.estimated_loc} LOC).{irreducible}"
+ " Use the editor, --max-refinement-iterations or --strict-loc-bounds to act on it."
+ "[/yellow]"
+ )
+
+
def _present_plan(groups: list[Group]) -> None:
table = Table(title="Split Plan")
table.add_column("ID")
@@ -180,6 +330,13 @@ def _present_plan(groups: list[Group]) -> None:
_worktree_ref_lock = Lock()
+def _discard_worktree(worktree_path: str) -> None:
+ try:
+ remove_worktree(worktree_path)
+ except PRSplitError as exc:
+ logger.warning(f"Failed to remove worktree {worktree_path}: {exc}")
+
+
def _create_single_branch_and_commit(
group: Group,
parsed_diff: ParsedDiff,
@@ -190,6 +347,7 @@ def _create_single_branch_and_commit(
*,
author: str | None = None,
start_point: str | None = None,
+ per_group: tuple[PerGroupStep, int] | None = None,
) -> BranchRecord:
branch_name = f"{BRANCH_PREFIX}{namespace}/{group.id}"
worktree_path = str(worktree_base / group.id)
@@ -203,7 +361,8 @@ def _create_single_branch_and_commit(
p = Path(worktree_path) / file_path
if content is not None:
p.parent.mkdir(parents=True, exist_ok=True)
- p.write_text(content, encoding="utf-8")
+ # newline="" keeps CRLF from the reconstructed content intact.
+ p.write_text(content, encoding="utf-8", errors="surrogateescape", newline="")
elif p.exists():
p.unlink()
@@ -214,11 +373,32 @@ def _create_single_branch_and_commit(
group.title,
author=author,
)
- finally:
+ if per_group is not None:
+ step, index = per_group
+ commit_sha = (
+ run_per_group_step(
+ step,
+ worktree_path,
+ group,
+ index=index,
+ pr_base=base_branch,
+ parent_ref=start_point or merge_base_ref,
+ author=author,
+ )
+ or commit_sha
+ )
+ except Exception:
+ # add_worktree succeeded, so this run created branch_name (a
+ # pre-existing branch of that name was already replaced). Delete it
+ # here, where that is known for certain: if add_worktree itself had
+ # failed it restores the previous branch and never reaches this path.
+ _discard_worktree(worktree_path)
try:
- remove_worktree(worktree_path)
+ delete_branch(branch_name)
except PRSplitError as exc:
- logger.warning(f"Failed to remove worktree {worktree_path}: {exc}")
+ logger.warning(f"Could not clean up branch {branch_name}: {exc}")
+ raise
+ _discard_worktree(worktree_path)
return BranchRecord(
group_id=group.id,
@@ -236,14 +416,20 @@ def _stacked_batch_args(
merge_base_ref: str,
hunk_counts: dict[str, int],
) -> Generator[list[tuple[Group, str, str]], None, None]:
- effective: dict[str, Group] = {}
for batch in dag.iter_ready():
batch_args: list[tuple[Group, str, str]] = []
for gid in batch:
group = groups_by_id[gid]
parents = dag.parents(gid)
if len(parents) == 1:
- merged = merge_chain_assignments(group, [effective[parents[0]]], hunk_counts)
+ # Files are rebuilt from the merge base, so a child must carry
+ # every ancestor's hunks for the files it touches - not only
+ # its direct parent's - or it silently reverts them.
+ merged = merge_chain_assignments(
+ group,
+ [groups_by_id[a] for a in sorted(dag.ancestors(gid))],
+ hunk_counts,
+ )
start_point = branch_names[parents[0]]
group_base = branch_names[parents[0]]
elif len(parents) > 1:
@@ -262,7 +448,6 @@ def _stacked_batch_args(
merged = group
start_point = merge_base_ref
group_base = base_branch
- effective[gid] = merged
batch_args.append((merged, group_base, start_point))
yield batch_args
@@ -276,10 +461,24 @@ def _create_branches_and_commits(
*,
author: str | None = None,
stacked: bool = False,
+ keep: dict[str, BranchRecord] | None = None,
) -> list[BranchRecord]:
- worktree_base = Path(tempfile.mkdtemp(prefix="pr-split-worktrees-"))
+ """Create a branch and commit per group; groups in ``keep`` reuse their record.
- if stacked:
+ ``keep`` holds groups whose PRs already exist from an earlier, partly
+ failed run: their branches are left as they are (recreating them would
+ rewrite the open PR's head).
+ """
+ kept = keep or {}
+ worktree_base = Path(tempfile.mkdtemp(prefix="pr-split-worktrees-"))
+ step = load_per_group_step()
+ order = {gid: i for i, gid in enumerate(PlanDAG(groups).topological_order(), start=1)}
+
+ # A plan with dependency edges is laid out along its DAG whether or not
+ # native stacking is on: each dependant builds on (and targets) its
+ # parent's branch, so it is reviewable and buildable against the code it
+ # depends on. ``stacked`` only adds native gh-stack registration on top.
+ if stacked or any(g.depends_on for g in groups):
dag = PlanDAG(groups)
groups_by_id = {g.id: g for g in groups}
branch_names = {g.id: f"{BRANCH_PREFIX}{namespace}/{g.id}" for g in groups}
@@ -291,9 +490,10 @@ def _create_branches_and_commits(
batches = iter([[(group, base_branch, merge_base_ref) for group in groups]])
try:
- results: dict[str, BranchRecord] = {}
+ results: dict[str, BranchRecord] = dict(kept)
errors: list[tuple[str, Exception]] = []
for batch_args in batches:
+ batch_args = [args for args in batch_args if args[0].id not in kept]
with ThreadPoolExecutor(max_workers=_WORKTREE_MAX_WORKERS) as executor:
future_to_group_id = {
executor.submit(
@@ -306,6 +506,7 @@ def _create_branches_and_commits(
worktree_base,
author=author,
start_point=start_point,
+ per_group=(step, order[group.id]) if step else None,
): group.id
for group, group_base, start_point in batch_args
}
@@ -320,7 +521,11 @@ def _create_branches_and_commits(
break
if errors:
- for record in results.values():
+ # Failed groups already removed their own branch inside the
+ # worker; only the successful ones remain to roll back.
+ for gid, record in results.items():
+ if gid in kept:
+ continue
try:
delete_branch(record.branch_name)
except PRSplitError as exc:
@@ -342,11 +547,13 @@ def _create_branches_and_commits(
_gh_semaphore = Semaphore(_GH_API_CONCURRENCY)
-_PR_TEMPLATE_PATH = Path(PLAN_DIR) / "template.md"
+def _pr_template_path() -> Path:
+ return plan_dir() / "template.md"
def _build_pr_body(group: Group, all_groups: list[Group]) -> str:
- if _PR_TEMPLATE_PATH.exists():
+ template_path = _pr_template_path()
+ if template_path.exists():
files = [a.file_path for a in group.assignments]
template_vars = {
"description": group.description,
@@ -360,19 +567,17 @@ def _build_pr_body(group: Group, all_groups: list[Group]) -> str:
"title": group.title,
}
try:
- template = _PR_TEMPLATE_PATH.read_text(encoding="utf-8")
+ template = template_path.read_text(encoding="utf-8")
return template.format(**template_vars)
except (KeyError, ValueError, IndexError) as exc:
available = ", ".join(f"{{{k}}}" for k in sorted(template_vars))
raise PRSplitError(
- f"Invalid PR template at {_PR_TEMPLATE_PATH}: {exc}. "
+ f"Invalid PR template at {template_path}: {exc}. "
f"Available placeholders: {available}. "
"Escape literal braces with {{ and }}."
) from exc
except OSError as exc:
- raise PRSplitError(
- f"Could not read PR template at {_PR_TEMPLATE_PATH}: {exc}"
- ) from exc
+ raise PRSplitError(f"Could not read PR template at {template_path}: {exc}") from exc
files = [a.file_path for a in group.assignments]
sections = [group.description]
@@ -387,7 +592,17 @@ def _build_pr_body(group: Group, all_groups: list[Group]) -> str:
)
if group.depends_on:
dep_list = ", ".join(f"`{d}`" for d in group.depends_on)
- sections.append(f"## Dependencies\n\nThis PR depends on: {dep_list}")
+ dependencies = f"## Dependencies\n\nThis PR depends on: {dep_list}"
+ if len(set(group.depends_on)) > 1:
+ # A merge node targets the base branch, so its diff also shows
+ # every ancestor's changes beyond the files listed above.
+ ancestors = sorted(PlanDAG(all_groups).ancestors(group.id))
+ carried = ", ".join(f"`{a}`" for a in ancestors)
+ dependencies += (
+ f"\n\nIt targets the base branch, so its diff also includes the changes of: "
+ f"{carried}."
+ )
+ sections.append(dependencies)
sections.append(_render_dag_markdown(all_groups, group.id))
return "\n\n".join(sections)
@@ -421,7 +636,10 @@ def _push_and_create_prs(
branch_records: list[BranchRecord],
*,
draft: bool = False,
+ existing_prs: dict[str, PRRecord] | None = None,
) -> list[PRRecord]:
+ """Push each group's branch and open its PR; groups in ``existing_prs`` are done."""
+ done = existing_prs or {}
record_map = {r.group_id: r for r in branch_records}
errors: list[tuple[str, Exception]] = []
@@ -430,8 +648,9 @@ def _push_and_create_prs(
push_futures = {
executor.submit(push_branch, record_map[group.id].branch_name): group.id
for group in groups
+ if group.id not in done
}
- pushed: set[str] = set()
+ pushed: set[str] = set(done)
for future in as_completed(push_futures):
group_id = push_futures[future]
try:
@@ -466,9 +685,9 @@ def _base_pushed(group: Group) -> bool:
_create_single_pr, group, record_map[group.id], groups, draft=draft
): group.id
for group in groups
- if group.id in pushed and _base_pushed(group)
+ if group.id not in done and group.id in pushed and _base_pushed(group)
}
- results: dict[str, PRRecord] = {}
+ results: dict[str, PRRecord] = dict(done)
for future in as_completed(future_to_group_id):
group_id = future_to_group_id[future]
try:
@@ -487,12 +706,15 @@ def _base_pushed(group: Group) -> bool:
return [results[g.id] for g in groups]
-def _link_stacks(dag: PlanDAG, pr_records: list[PRRecord]) -> None:
+def _link_stacks(
+ dag: PlanDAG, pr_records: list[PRRecord], branch_records: list[BranchRecord]
+) -> None:
pr_by_group = {r.group_id: r.pr_number for r in pr_records}
+ base_by_group = {r.group_id: r.base_branch for r in branch_records}
for chain in dag.linear_chains():
if len(chain) < 2:
continue
- link_stack([pr_by_group[gid] for gid in chain])
+ link_stack([pr_by_group[gid] for gid in chain], base=base_by_group[chain[0]])
def _move_assignment(
@@ -563,6 +785,8 @@ def _move_assignment(
)
)
+ refresh_generated_description(src)
+ refresh_generated_description(dst)
console.print(f"[green]Moved {file_path}:{hunk_index} from {from_id} to {to_id}[/green]")
return True
@@ -589,10 +813,275 @@ def _show_group_detail(groups: list[Group], group_id: str) -> None:
console.print()
+def _held_hunks(
+ groups: list[Group], hunk_counts: dict[str, int]
+) -> dict[str, set[tuple[str, int]]]:
+ """Map each group id to the (file, hunk index) pairs its assignments cover."""
+ return {
+ g.id: {
+ (a.file_path, idx)
+ for a in g.assignments
+ for idx in a.covered_indices(hunk_counts.get(a.file_path, 0))
+ }
+ for g in groups
+ }
+
+
+def _drop_empty_groups(
+ groups: list[Group],
+ held_before: dict[str, set[tuple[str, int]]],
+ hunk_counts: dict[str, int],
+) -> list[Group]:
+ """Remove groups the user emptied in the editor and unlink them from the DAG.
+
+ Moving every hunk out of a group is a legitimate way to dissolve it;
+ aborting the session there would throw away all the other edits. A
+ dropped group's dependants inherit its own dependencies and every kept
+ group that now holds one of its former hunks (``held_before`` is the
+ coverage snapshot taken before editing), so a stacked dependant still
+ builds on the code it was planned against. A move that cannot keep that
+ guarantee (the hunk went to a group that itself builds on the dependant)
+ is refused, since the dependant's stacked branch would lose the hunk.
+ """
+ dropped = {g.id: list(g.depends_on) for g in groups if not g.assignments}
+ if not dropped:
+ return groups
+
+ held_now = _held_hunks(groups, hunk_counts)
+ recipients = {
+ gid: [
+ g.id
+ for g in groups
+ if g.id not in dropped and held_before.get(gid, set()) & held_now[g.id]
+ ]
+ for gid in dropped
+ }
+
+ def _surviving(dep: str, seen: set[str]) -> tuple[list[str], list[tuple[str, str]]]:
+ # Walk through chains of dropped groups to the nearest kept ancestors,
+ # collecting the kept groups that took over the dropped groups' hunks.
+ if dep not in dropped:
+ return [dep], []
+ ancestors: list[str] = []
+ taken_over = [(recipient, dep) for recipient in recipients[dep]]
+ for parent in dropped[dep]:
+ if parent not in seen:
+ seen.add(parent)
+ more_ancestors, more_taken = _surviving(parent, seen)
+ ancestors.extend(more_ancestors)
+ taken_over.extend(more_taken)
+ return ancestors, taken_over
+
+ # Inherited ancestors first: they are transitive ancestors in the original
+ # acyclic plan, so rewiring to them cannot create a cycle.
+ deps: dict[str, list[str]] = {}
+ wanted: dict[str, list[tuple[str, str]]] = {}
+ for group in groups:
+ if group.id in dropped:
+ continue
+ deps[group.id] = []
+ wanted[group.id] = []
+ for dep in group.depends_on:
+ ancestors, taken_over = _surviving(dep, set())
+ for candidate in ancestors:
+ if candidate not in deps[group.id] and candidate != group.id:
+ deps[group.id].append(candidate)
+ wanted[group.id].extend(taken_over)
+
+ def _depends_on(start: str, target: str) -> bool:
+ stack, seen = [start], set()
+ while stack:
+ node = stack.pop()
+ if node == target:
+ return True
+ if node not in seen:
+ seen.add(node)
+ stack.extend(deps.get(node, []))
+ return False
+
+ # Then the groups that took over dropped hunks. One that already builds on
+ # the dependant cannot become its parent without a cycle, and leaving it out
+ # would build the dependant's branch without the moved hunk: refuse.
+ for gid, candidates in wanted.items():
+ for candidate, source in candidates:
+ # Already reachable through an inherited ancestor: a direct edge
+ # would only turn a linear stack into a multi-parent node.
+ if candidate == gid or _depends_on(gid, candidate):
+ continue
+ if _depends_on(candidate, gid):
+ console.print(
+ f"[red]Cannot drop emptied group '{source}': its hunks moved to"
+ f" '{candidate}', which builds on '{gid}', so '{gid}' would lose code"
+ " it was planned on. Move them to a group it can depend on.[/red]"
+ )
+ raise typer.Exit(1)
+ deps[gid].append(candidate)
+
+ kept = [g.model_copy(update={"depends_on": deps[g.id]}) for g in groups if g.id not in dropped]
+ console.print(f"[yellow]Dropped empty group(s) after editing: {', '.join(dropped)}[/yellow]")
+ return kept
+
+
+def _find_group(groups: list[Group], group_id: str) -> Group | None:
+ group = next((g for g in groups if g.id == group_id), None)
+ if group is None:
+ console.print(f"[red]Group '{group_id}' not found.[/red]")
+ return group
+
+
+def _creates_cycle(groups: list[Group]) -> bool:
+ try:
+ PlanDAG(groups).validate_acyclic()
+ except PlanValidationError:
+ return True
+ return False
+
+
+def _add_dependency(groups: list[Group], child_id: str, parent_id: str) -> bool:
+ child, parent = _find_group(groups, child_id), _find_group(groups, parent_id)
+ if child is None or parent is None:
+ return False
+ if child_id == parent_id:
+ console.print("[red]A group cannot depend on itself.[/red]")
+ return False
+ if parent_id in child.depends_on:
+ console.print(f"[yellow]{child_id} already depends on {parent_id}.[/yellow]")
+ return False
+ child.depends_on.append(parent_id)
+ if _creates_cycle(groups):
+ child.depends_on.remove(parent_id)
+ console.print(f"[red]{child_id} -> {parent_id} would create a dependency cycle.[/red]")
+ return False
+ console.print(f"[green]{child_id} now depends on {parent_id}[/green]")
+ return True
+
+
+def _remove_dependency(groups: list[Group], child_id: str, parent_id: str) -> bool:
+ child = _find_group(groups, child_id)
+ if child is None:
+ return False
+ if parent_id not in child.depends_on:
+ console.print(f"[yellow]{child_id} does not depend on {parent_id}.[/yellow]")
+ return False
+ child.depends_on.remove(parent_id)
+ console.print(f"[green]{child_id} no longer depends on {parent_id}[/green]")
+ return True
+
+
+def _move_file(
+ groups: list[Group], parsed_diff: ParsedDiff, file_path: str, from_id: str, to_id: str
+) -> bool:
+ """Move every hunk of ``file_path`` that ``from_id`` holds into ``to_id``."""
+ src, dst = _find_group(groups, from_id), _find_group(groups, to_id)
+ if src is None or dst is None:
+ return False
+ if from_id == to_id:
+ console.print("[yellow]Source and destination are the same. No move performed.[/yellow]")
+ return False
+ hunk_count = next((len(pf) for pf in parsed_diff.patch_set if pf.path == file_path), 0)
+ moving = sorted(
+ {
+ idx
+ for a in src.assignments
+ if a.file_path == file_path
+ for idx in a.covered_indices(hunk_count)
+ }
+ )
+ if not moving:
+ console.print(f"[red]{from_id} holds no hunks of {file_path}.[/red]")
+ return False
+ src.assignments = [a for a in src.assignments if a.file_path != file_path]
+ dst.assignments = _combine_assignments(
+ [
+ *dst.assignments,
+ GroupAssignment(
+ file_path=file_path,
+ assignment_type=AssignmentType.PARTIAL_HUNKS,
+ hunk_indices=moving,
+ ),
+ ],
+ {pf.path: len(pf) for pf in parsed_diff.patch_set},
+ )
+ console.print(
+ f"[green]Moved {len(moving)} hunk(s) of {file_path} from {from_id} to {to_id}[/green]"
+ )
+ return True
+
+
+def _new_group(groups: list[Group], group_id: str) -> bool:
+ if any(g.id == group_id for g in groups):
+ console.print(f"[red]Group '{group_id}' already exists.[/red]")
+ return False
+ groups.append(Group(id=group_id, title=group_id, description=""))
+ console.print(f"[green]Created empty group {group_id}[/green]")
+ return True
+
+
+def _combine_assignments(
+ assignments: list[GroupAssignment], hunk_counts: dict[str, int]
+) -> list[GroupAssignment]:
+ """One assignment per file covering the union of the given hunks."""
+ by_file: dict[str, set[int]] = {}
+ for a in assignments:
+ by_file.setdefault(a.file_path, set()).update(
+ a.covered_indices(hunk_counts.get(a.file_path, 0))
+ )
+ combined: list[GroupAssignment] = []
+ for path, indices in by_file.items():
+ whole = sorted(indices) == list(range(hunk_counts.get(path, 0)))
+ combined.append(
+ GroupAssignment(
+ file_path=path,
+ assignment_type=AssignmentType.WHOLE_FILE
+ if whole
+ else AssignmentType.PARTIAL_HUNKS,
+ hunk_indices=sorted(indices),
+ )
+ )
+ return combined
+
+
+def _merge_groups(
+ groups: list[Group], parsed_diff: ParsedDiff, keep_id: str, absorb_id: str
+) -> bool:
+ """Fold ``absorb_id`` into ``keep_id``: its hunks, its parents and its dependants."""
+ keep, absorb = _find_group(groups, keep_id), _find_group(groups, absorb_id)
+ if keep is None or absorb is None:
+ return False
+ if keep_id == absorb_id:
+ console.print("[yellow]Cannot merge a group into itself.[/yellow]")
+ return False
+ merged: list[Group] = []
+ for g in groups:
+ if g.id == absorb_id:
+ continue
+ deps = [keep_id if d == absorb_id else d for d in g.depends_on]
+ if g.id == keep_id:
+ deps += absorb.depends_on
+ deps = [d for i, d in enumerate(deps) if d != g.id and d not in deps[:i]]
+ assignments = list(g.assignments)
+ if g.id == keep_id:
+ hunk_counts = {pf.path: len(pf) for pf in parsed_diff.patch_set}
+ assignments = _combine_assignments(assignments + absorb.assignments, hunk_counts)
+ merged.append(g.model_copy(update={"depends_on": deps, "assignments": assignments}))
+ if _creates_cycle(merged):
+ console.print(
+ f"[red]Merging {absorb_id} into {keep_id} would create a dependency cycle.[/red]"
+ )
+ return False
+ groups[:] = merged
+ console.print(f"[green]Merged {absorb_id} into {keep_id}[/green]")
+ return True
+
+
def _interactive_edit(groups: list[Group], parsed_diff: ParsedDiff) -> list[Group]:
console.print(
"\n[cyan]Interactive editor. Commands:[/cyan]\n"
" [bold]move[/bold] : \n"
+ " [bold]movefile[/bold] \n"
+ " [bold]dep[/bold] / [bold]undep[/bold] \n"
+ " [bold]title[/bold] / [bold]desc[/bold] \n"
+ " [bold]new[/bold] / [bold]merge[/bold] \n"
" [bold]show[/bold] \n"
" [bold]plan[/bold] — redisplay the plan table\n"
" [bold]done[/bold] — proceed\n"
@@ -638,13 +1127,82 @@ def _interactive_edit(groups: list[Group], parsed_diff: ParsedDiff) -> list[Grou
if hunk_index < 0:
console.print("[red]Hunk index must be non-negative.[/red]")
continue
- _move_assignment(groups, parsed_diff, file_path, hunk_index, from_id, to_id)
+ if _move_assignment(groups, parsed_diff, file_path, hunk_index, from_id, to_id):
+ # Keep per-group LOC in step with the new assignments so the
+ # plan table, strict LOC bounds, plan.json and PR bodies are
+ # accurate.
+ recompute_estimated_loc(groups, parsed_diff)
+ elif action in ("dep", "undep"):
+ if len(parts) != 3:
+ console.print(f"[red]Usage: {action} [/red]")
+ continue
+ if action == "dep":
+ _add_dependency(groups, parts[1], parts[2])
+ else:
+ _remove_dependency(groups, parts[1], parts[2])
+ elif action in ("title", "desc"):
+ text_parts = cmd.strip().split(maxsplit=2)
+ if len(text_parts) != 3:
+ console.print(f"[red]Usage: {action} [/red]")
+ continue
+ group = _find_group(groups, text_parts[1])
+ if group is not None:
+ if action == "title":
+ group.title = text_parts[2]
+ else:
+ group.description = text_parts[2]
+ console.print(f"[green]Updated {action} of {group.id}[/green]")
+ elif action == "movefile":
+ if len(parts) != 4:
+ console.print("[red]Usage: movefile [/red]")
+ continue
+ if _move_file(groups, parsed_diff, parts[1], parts[2], parts[3]):
+ recompute_estimated_loc(groups, parsed_diff)
+ elif action == "new":
+ if len(parts) != 2:
+ console.print("[red]Usage: new [/red]")
+ continue
+ _new_group(groups, parts[1])
+ elif action == "merge":
+ if len(parts) != 3:
+ console.print("[red]Usage: merge [/red]")
+ continue
+ if _merge_groups(groups, parsed_diff, parts[1], parts[2]):
+ recompute_estimated_loc(groups, parsed_diff)
else:
console.print(
"[yellow]Unknown command. Type 'done' to proceed or 'abort' to cancel.[/yellow]"
)
+def _split_settings(
+ partition_strategy: PartitionStrategy | None,
+ build: Callable[[PartitionStrategy], Settings],
+) -> Settings:
+ """Settings for split; with no strategy chosen, llm if usable, else graph.
+
+ A machine without an API key (and no keyless provider) can still split
+ with the graph backend, which needs no model at all. An explicit
+ --partition-strategy llm keeps failing loudly when its key is missing.
+ """
+ if partition_strategy is not None:
+ return build(partition_strategy)
+ try:
+ return build(PartitionStrategy.LLM)
+ except (ValidationError, ValueError) as llm_error:
+ try:
+ settings = build(PartitionStrategy.GRAPH)
+ except (ValidationError, ValueError):
+ raise llm_error from None
+ reason = (
+ llm_error.errors()[0]["msg"].removeprefix("Value error, ")
+ if isinstance(llm_error, ValidationError)
+ else str(llm_error)
+ )
+ logger.warning(logs.LLM_UNAVAILABLE_USING_GRAPH.format(reason=reason))
+ return settings
+
+
def _resolve_fork_ref(dev_branch: str) -> ForkPRInfo | None:
cleaned = dev_branch.lstrip("#")
if cleaned.isdigit():
@@ -691,22 +1249,42 @@ def split(
help="Maximum LLM refinement iterations to fix LOC bound violations (0 = disabled)",
),
] = DEFAULT_MAX_REFINEMENT_ITERATIONS,
- priority: Annotated[Priority, typer.Option(help="Grouping priority")] = Priority.ORTHOGONAL,
+ priority: Annotated[
+ Priority,
+ typer.Option("--priority", envvar="PR_SPLIT_PRIORITY", help="Grouping priority"),
+ ] = Priority.ORTHOGONAL,
chunk_strategy: Annotated[
- ChunkStrategy, typer.Option(help="Chunking strategy for large diffs")
+ ChunkStrategy,
+ typer.Option(
+ "--chunk-strategy",
+ envvar="PR_SPLIT_CHUNK_STRATEGY",
+ help="Chunking strategy for large diffs",
+ ),
] = DEFAULT_CHUNK_STRATEGY,
partition_strategy: Annotated[
- PartitionStrategy, typer.Option(help="Backend for hunk-to-PR partitioning")
- ] = DEFAULT_PARTITION_STRATEGY,
+ PartitionStrategy | None,
+ typer.Option(
+ "--partition-strategy",
+ envvar="PR_SPLIT_PARTITION_STRATEGY",
+ help="Backend for hunk-to-PR partitioning"
+ " [default: llm when its provider is configured, else graph]",
+ show_default=False,
+ ),
+ ] = None,
cp_sat_timeout: Annotated[
- float, typer.Option(help="Maximum seconds to spend in the CP-SAT solver")
+ float,
+ typer.Option(
+ "--cp-sat-timeout",
+ envvar="PR_SPLIT_CP_SAT_TIMEOUT",
+ help="Maximum seconds to spend in the CP-SAT solver",
+ ),
] = DEFAULT_CP_SAT_TIMEOUT_SECONDS,
stack: Annotated[
bool,
typer.Option(
"--stack",
envvar="PR_SPLIT_STACK",
- help="Stack dependent PRs: each child branches from and targets its parent's branch",
+ help="Register dependent PR chains as native GitHub stacks",
),
] = False,
draft: Annotated[
@@ -724,6 +1302,13 @@ def split(
dev_branch_arg = dev_branch
author: str | None = None
fork_info: ForkPRInfo | None = None
+ select_plan(dev_branch_arg)
+
+ # A branch that exists only as origin/ (fresh clone or worktree)
+ # is adopted as a local branch, as `git checkout ` would.
+ for name in (dev_branch, base):
+ if not (name.lstrip("#").isdigit() or ":" in name):
+ adopt_remote_branch(name)
if not branch_exists(dev_branch):
if not check_gh_auth():
@@ -732,7 +1317,11 @@ def split(
if not is_worktree_clean():
console.print(f"[red]{ErrorMsg.DIRTY_WORKTREE()}[/red]")
raise typer.Exit(1)
- fork_info = _resolve_fork_ref(dev_branch)
+ try:
+ fork_info = _resolve_fork_ref(dev_branch)
+ except PRSplitError as exc:
+ console.print(f"[red]{exc}[/red]")
+ raise typer.Exit(1) from exc
if not fork_info:
console.print(f"[red]{ErrorMsg.BRANCH_NOT_FOUND(branch=dev_branch)}[/red]")
raise typer.Exit(1)
@@ -740,10 +1329,10 @@ def split(
base = fork_info["base_branch"]
author = fork_info["author"]
- _validate_inputs(dev_branch, base, dry_run=dry_run)
+ _validate_inputs(dev_branch, base, dry_run=dry_run, stacked=stack)
if plan_exists():
- existing = load_plan()
+ existing = _load_plan_or_exit()
has_git_state = existing.git_state.branches or existing.git_state.prs
if has_git_state:
console.print("[yellow]An existing split plan with branches/PRs was found.[/yellow]")
@@ -761,8 +1350,12 @@ def split(
else:
logger.info("Overwriting existing dry-run plan")
- raw_diff = extract_diff(dev_branch, base)
- parsed_diff = parse_diff(raw_diff)
+ # The sub-PRs target the remote's base, which a local base may lag behind.
+ diff_base = diff_base_ref(base)
+ raw_diff = extract_diff(dev_branch, diff_base)
+ # Only a stacked child builds on the PR holding a file's earlier pieces.
+ split_new_files_over = max_loc if stack else None
+ parsed_diff = parse_diff(raw_diff, split_new_files_over=split_new_files_over)
stats = parsed_diff.stats
logger.info(
logs.DIFF_STATS.format(
@@ -774,38 +1367,59 @@ def split(
)
try:
- settings = Settings(
- min_loc=min_loc,
- max_loc=max_loc,
- strict_loc_bounds=strict_loc_bounds,
- max_refinement_iterations=max_refinement_iterations,
- cp_sat_timeout=cp_sat_timeout,
- priority=priority,
- chunk_strategy=chunk_strategy,
- partition_strategy=partition_strategy,
+ settings = _split_settings(
+ partition_strategy,
+ lambda strategy: Settings(
+ min_loc=min_loc,
+ max_loc=max_loc,
+ strict_loc_bounds=strict_loc_bounds,
+ max_refinement_iterations=max_refinement_iterations,
+ cp_sat_timeout=cp_sat_timeout,
+ priority=priority,
+ chunk_strategy=chunk_strategy,
+ partition_strategy=strategy,
+ ),
)
except (ValidationError, ValueError) as exc:
console.print(f"[red]{exc}[/red]")
raise typer.Exit(1) from exc
- groups = plan_split(parsed_diff, settings)
+ try:
+ validate_no_binary_files(parsed_diff)
+ except PlanValidationError as exc:
+ console.print(f"[red]{exc}[/red]")
+ raise typer.Exit(1) from exc
+ try:
+ groups = plan_split(parsed_diff, settings)
+ link_new_file_pieces(groups, parsed_diff)
+ except PRSplitError as exc:
+ console.print(f"[red]{exc}[/red]")
+ raise typer.Exit(1) from exc
logger.info(logs.VALIDATING_PLAN)
- dag = PlanDAG(groups)
- warnings = validate_plan(groups, parsed_diff, dag, settings.max_loc, min_loc=settings.min_loc)
+ try:
+ dag = PlanDAG(groups)
+ warnings = validate_plan(
+ groups, parsed_diff, dag, settings.max_loc, min_loc=settings.min_loc
+ )
+ except PRSplitError as exc:
+ console.print(f"[red]{exc}[/red]")
+ raise typer.Exit(1) from exc
_handle_loc_bound_warnings(warnings, strict_loc_bounds=settings.strict_loc_bounds)
logger.success(logs.VALIDATION_PASSED)
logger.info(logs.PRESENTING_PLAN)
_present_plan(groups)
+ hunk_counts = {pf.path: len(pf) for pf in parsed_diff.patch_set}
+ held_before = _held_hunks(groups, hunk_counts)
groups = _interactive_edit(groups, parsed_diff)
# Re-validate after user edits
- empty_groups = [g for g in groups if not g.assignments]
- if empty_groups:
- empty_ids = [g.id for g in empty_groups]
- console.print(f"[red]Groups {empty_ids} are empty after editing.[/red]")
+ groups = _drop_empty_groups(groups, held_before, hunk_counts)
+ if not groups:
+ console.print("[red]Every group is empty after editing; nothing to split.[/red]")
raise typer.Exit(1)
+ link_new_file_pieces(groups, parsed_diff)
try:
dag = PlanDAG(groups)
warnings = validate_plan(
@@ -817,12 +1431,15 @@ def split(
)
_handle_loc_bound_warnings(warnings, strict_loc_bounds=settings.strict_loc_bounds)
logger.success("Edited plan validation passed")
+ _report_oversized_groups(groups, settings.max_loc, hunk_counts)
except PRSplitError as exc:
console.print(f"[red]Edited plan is invalid: {exc}[/red]")
raise typer.Exit(1) from exc
- merge_base_ref = merge_base(base, dev_branch)
+ merge_base_ref = merge_base(diff_base, dev_branch)
+ backend_ran = parsed_diff.stats["total_loc"] > settings.max_loc
+ llm_ran = backend_ran and settings.partition_strategy is PartitionStrategy.LLM
split_plan = SplitPlan(
dev_branch=dev_branch,
base_branch=base,
@@ -830,6 +1447,7 @@ def split(
max_loc=settings.max_loc,
strict_loc_bounds=settings.strict_loc_bounds,
stacked=stack,
+ split_new_files_over=split_new_files_over,
draft=draft,
priority=priority,
groups=groups,
@@ -837,19 +1455,32 @@ def split(
merge_base_sha=merge_base_ref,
dev_branch_arg=dev_branch_arg,
raw_diff=raw_diff,
+ # A diff within --max-loc is kept as one group without any backend.
+ partition_strategy=(
+ settings.partition_strategy.value if backend_ran else NO_BACKEND_STRATEGY
+ ),
+ chunk_strategy=settings.chunk_strategy.value,
+ provider=settings.provider.value if llm_ran else None,
+ model=settings.model if llm_ran else None,
+ oversized_groups=_oversized_group_ids(groups, settings.max_loc),
)
+ console.print(_plan_provenance(split_plan))
if dry_run:
save_plan(PlanFile(plan=split_plan, git_state=GitState(branches=[], prs=[])))
- logger.success(f"Dry run complete: plan with {len(groups)} groups saved to {PLAN_FILE}")
+ logger.success(f"Dry run complete: plan with {len(groups)} groups saved to {plan_path()}")
return
typer.confirm("Proceed with creating branches and PRs?", abort=True)
namespace = derive_split_namespace(dev_branch_arg)
- branch_records = _create_branches_and_commits(
- groups, parsed_diff, base, merge_base_ref, namespace, author=author, stacked=stack
- )
+ try:
+ branch_records = _create_branches_and_commits(
+ groups, parsed_diff, base, merge_base_ref, namespace, author=author, stacked=stack
+ )
+ except PRSplitError as exc:
+ console.print(f"[red]{exc}[/red]")
+ raise typer.Exit(1) from exc
try:
pr_records = _push_and_create_prs(groups, branch_records, draft=draft)
except PRCreationError as exc:
@@ -859,16 +1490,17 @@ def split(
git_state=GitState(branches=branch_records, prs=exc.pr_records),
)
)
- raise
- if stack:
- _link_stacks(dag, pr_records)
-
+ console.print(f"[red]{escape(str(exc))}[/red]")
+ console.print("[yellow]Created branches and PRs were saved to the plan file.[/yellow]")
+ raise typer.Exit(1) from exc
save_plan(
PlanFile(
plan=split_plan,
git_state=GitState(branches=branch_records, prs=pr_records),
)
)
+ if stack:
+ _link_stacks(dag, pr_records, branch_records)
logger.success(f"Split complete: {len(groups)} PRs created")
@@ -878,10 +1510,15 @@ def status() -> None:
console.print(ErrorMsg.NO_PLAN())
raise typer.Exit(0)
- plan_file = load_plan()
+ plan_file = _load_plan_or_exit()
plan = plan_file.plan
git_state = plan_file.git_state
+ if git_state.prs and _base_has_merged(plan.base_branch):
+ console.print(
+ f"[yellow]{logs.BASE_ALREADY_MERGED.format(base=escape(plan.base_branch))}[/yellow]"
+ )
+
branch_map = {r.group_id: r.branch_name for r in git_state.branches}
pr_map = {r.group_id: r for r in git_state.prs}
@@ -905,6 +1542,7 @@ def status() -> None:
table.add_column("State")
table.add_column("Review")
+ unverified: list[int] = []
for group in plan.groups:
branch_name = branch_map.get(group.id, "")
pr_record = pr_map.get(group.id)
@@ -912,54 +1550,315 @@ def status() -> None:
pr_state = ""
review = ""
if pr_record:
- live = live_states.get(pr_record.pr_number, {})
- pr_state = live.get("state", pr_record.state.value).upper()
- review = (live.get("reviewDecision") or "").replace("_", " ").title()
+ live = live_states.get(pr_record.pr_number) or {}
+ if live:
+ pr_state = str(live.get("state") or "").upper()
+ review = str(live.get("reviewDecision") or "").replace("_", " ").title()
+ else:
+ # The recorded state is never written back after merge/close,
+ # so showing it would claim OPEN for a PR that may be gone.
+ pr_state = "UNKNOWN"
+ unverified.append(pr_record.pr_number)
table.add_row(group.id, group.title, branch_name, pr_info, pr_state, review)
console.print(table)
+ for gid, branch, parent in stale_layers(plan_file):
+ behind = logs.LAYER_BEHIND_PARENT.format(group=gid, branch=branch, parent=parent)
+ console.print(f"[yellow]{behind}[/yellow]")
+ if unverified:
+ console.print(
+ f"[yellow]Could not fetch live state for {len(unverified)} PR(s): "
+ f"{', '.join(f'#{n}' for n in unverified)}. Check 'gh auth status'.[/yellow]"
+ )
def _cleanup_git_state(git_state: GitState) -> tuple[int, int]:
closed_prs = 0
for pr_record in git_state.prs:
+ if pr_record.adopted:
+ # The user's own PR, only registered by adopt: forget it, never close it.
+ logger.info(logs.ADOPTED_PR_KEPT.format(number=pr_record.pr_number))
+ closed_prs += 1
+ continue
+ # gh refuses to close a merged PR and silently succeeds on a closed
+ # one; neither needs a warning nor should count as newly closed, but
+ # both are "done" for the purpose of removing the plan.
+ state = str(get_pr_state(pr_record.pr_number).get("state") or "").upper()
+ if state in ("MERGED", "CLOSED"):
+ logger.info(
+ logs.PR_ALREADY_DONE.format(number=pr_record.pr_number, state=state.lower())
+ )
+ closed_prs += 1
+ continue
try:
close_pr(pr_record.pr_number)
closed_prs += 1
- except PRSplitError:
- logger.warning(f"Could not close PR #{pr_record.pr_number}")
+ except PRSplitError as exc:
+ logger.warning(f"Could not close PR #{pr_record.pr_number}: {exc}")
logger.info(logs.CLEANING_BRANCHES)
deleted_branches = 0
for branch_record in git_state.branches:
+ if branch_record.adopted:
+ logger.info(logs.ADOPTED_BRANCH_KEPT.format(branch=branch_record.branch_name))
+ deleted_branches += 1
+ continue
try:
delete_branch(branch_record.branch_name, remote=True)
deleted_branches += 1
except PRSplitError:
logger.warning(f"Could not delete branch {branch_record.branch_name}")
- plan_path = Path(PLAN_FILE)
- if plan_path.exists():
- plan_path.unlink()
+ complete = closed_prs == len(git_state.prs) and deleted_branches == len(git_state.branches)
+ saved_plan = plan_path()
+ if complete and saved_plan.exists():
+ saved_plan.unlink()
return closed_prs, deleted_branches
+# Nothing is saved until every step succeeds, so a failed adopt never blocks
+# a re-run; the stack itself may already be partly registered on GitHub.
+ADOPT_RERUN_HINT = (
+ "[yellow]The stack may be partly registered on GitHub; nothing was saved, so"
+ " re-run the same adopt command to finish it.[/yellow]"
+)
+
+
+@app.command(
+ help="Register branches you already built as a native stack (bottom to top) and "
+ "save them as a plan, so 'status' and 'merge' work on them."
+)
+def adopt(
+ branches: Annotated[
+ list[str], typer.Argument(help="Branches in stack order, bottom first (at least two)")
+ ],
+ base: Annotated[str, typer.Option(help="Branch the bottom of the stack targets")] = "main",
+ yes: Annotated[
+ bool, typer.Option("--yes", "-y", help="Register the stack without asking to confirm")
+ ] = False,
+) -> None:
+ if len(branches) < 2:
+ console.print("[red]adopt needs at least two branches, bottom of the stack first.[/red]")
+ raise typer.Exit(1)
+ if len(set(branches)) != len(branches):
+ console.print("[red]Each branch may appear only once.[/red]")
+ raise typer.Exit(1)
+ # The adopted stack's plan is filed under its top branch, like a split's dev branch.
+ select_plan(branches[-1])
+ if plan_exists():
+ existing = _load_plan_or_exit()
+ if existing.git_state.branches or existing.git_state.prs:
+ console.print(
+ "[red]A split plan with branches/PRs already exists."
+ " Run 'pr-split clean' (or merge it) first.[/red]"
+ )
+ raise typer.Exit(1)
+ for name in (base, *branches):
+ if not branch_exists(f"refs/heads/{name}"):
+ console.print(f"[red]{ErrorMsg.BRANCH_NOT_FOUND(branch=name)}[/red]")
+ raise typer.Exit(1)
+ # A stacked PR shows only its own diff on top of its parent, so each
+ # branch must actually contain the one below it.
+ for parent, child in zip((base, *branches), branches, strict=False):
+ try:
+ run_git("merge-base", "--is-ancestor", parent, child)
+ except GitOperationError as exc:
+ console.print(
+ f"[red]{child} does not contain {parent}; rebase it onto {parent} first.[/red]"
+ )
+ raise typer.Exit(1) from exc
+
+ console.print(f"Stack ({base}) <- " + " <- ".join(escape(b) for b in branches))
+ if not yes:
+ typer.confirm("Register these branches as a native stack?", abort=True)
+
+ try:
+ _require_gh_stack()
+ link_stack(list(branches), base=base)
+ prs = [find_open_pr(name) for name in branches]
+ except PRSplitError as exc:
+ console.print(f"[red]{escape(str(exc))}[/red]")
+ console.print(ADOPT_RERUN_HINT)
+ raise typer.Exit(1) from exc
+ missing = [name for name, pr in zip(branches, prs, strict=True) if pr is None]
+ if missing:
+ console.print(f"[red]No open PR found for: {', '.join(missing)}[/red]")
+ console.print(ADOPT_RERUN_HINT)
+ raise typer.Exit(1)
+
+ groups: list[Group] = []
+ branch_records: list[BranchRecord] = []
+ pr_records: list[PRRecord] = []
+ for i, (name, pr) in enumerate(zip(branches, prs, strict=True), start=1):
+ assert pr is not None
+ gid = f"pr-{i}"
+ parent = base if i == 1 else branches[i - 2]
+ groups.append(
+ Group(
+ id=gid,
+ title=name,
+ description=f"Adopted branch {name}",
+ depends_on=[] if i == 1 else [f"pr-{i - 1}"],
+ )
+ )
+ branch_records.append(
+ BranchRecord(
+ group_id=gid,
+ branch_name=name,
+ base_branch=parent,
+ commit_sha=run_git("rev-parse", name),
+ adopted=True,
+ )
+ )
+ pr_records.append(PRRecord(group_id=gid, pr_number=pr[0], pr_url=pr[1], adopted=True))
+ save_plan(
+ PlanFile(
+ plan=SplitPlan(
+ dev_branch=branches[-1],
+ base_branch=base,
+ max_loc=DEFAULT_MAX_LOC,
+ stacked=True,
+ priority=Priority.ORTHOGONAL,
+ groups=groups,
+ merge_base_sha=run_git("merge-base", base, branches[-1]),
+ ),
+ git_state=GitState(branches=branch_records, prs=pr_records),
+ )
+ )
+ logger.success(f"Adopted {len(branches)} branches as a stack on {base}")
+
+
+def _base_has_merged(base: str) -> bool:
+ """True when the plan's base is itself a merged feature branch."""
+ try:
+ return base != default_branch() and branch_has_merged(base)
+ except PRSplitError:
+ return False
+
+
+@app.command(
+ help="Move the split onto a new base, e.g. after its --base branch merged: "
+ "retargets the root PRs and relinks native stacks on the new base."
+)
+def retarget(
+ to: Annotated[
+ str | None,
+ typer.Option("--to", help="New base branch (default: the repository's default branch)"),
+ ] = None,
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Retarget without confirming")] = False,
+) -> None:
+ if not plan_exists():
+ console.print(ErrorMsg.NO_PLAN())
+ raise typer.Exit(1)
+ plan_file = _load_plan_or_exit()
+ plan, git_state = plan_file.plan, plan_file.git_state
+ if not git_state.prs:
+ console.print("[yellow]The plan has no PRs; nothing to retarget.[/yellow]")
+ raise typer.Exit(0)
+ try:
+ new_base = to or default_branch()
+ except PRSplitError as exc:
+ console.print(f"[red]{escape(str(exc))}[/red]")
+ raise typer.Exit(1) from exc
+ old_base = plan.base_branch
+ if new_base == old_base:
+ console.print(f"[yellow]The split already targets {escape(new_base)}.[/yellow]")
+ raise typer.Exit(0)
+
+ pr_by_group = {r.group_id: r.pr_number for r in git_state.prs}
+ roots = [r for r in git_state.branches if r.base_branch == old_base]
+ console.print(
+ f"Retarget {len(roots)} root PR(s) from {escape(old_base)} to {escape(new_base)}"
+ + (" and relink the native stacks" if plan.stacked else "")
+ )
+ if not yes:
+ typer.confirm("Proceed?", abort=True)
+
+ try:
+ # GitHub refuses to change the base of a PR that is in a stack, so
+ # unstack first, retarget the roots, then link the chains again.
+ if plan.stacked:
+ for number in sorted(stack_numbers_for(list(pr_by_group.values()))):
+ unstack(number)
+ for record in roots:
+ if record.group_id in pr_by_group:
+ set_pr_base(pr_by_group[record.group_id], new_base)
+ branches = [
+ r.model_copy(update={"base_branch": new_base}) if r.base_branch == old_base else r
+ for r in git_state.branches
+ ]
+ if plan.stacked:
+ _link_stacks(PlanDAG(plan.groups), git_state.prs, branches)
+ except PRSplitError as exc:
+ console.print(f"[red]{escape(str(exc))}[/red]")
+ raise typer.Exit(1) from exc
+
+ save_plan(
+ PlanFile(
+ plan=plan.model_copy(update={"base_branch": new_base}),
+ git_state=git_state.model_copy(update={"branches": branches}),
+ )
+ )
+ logger.success(f"Retargeted the split from {old_base} to {new_base}")
+
+
@app.command(help="Close all split PRs and delete their branches.")
def clean() -> None:
if not plan_exists():
console.print(ErrorMsg.NO_PLAN())
raise typer.Exit(0)
- plan_file = load_plan()
+ plan_file = _load_plan_or_exit()
git_state = plan_file.git_state
typer.confirm("Delete all pr-split branches and close PRs?", abort=True)
closed_prs, deleted_branches = _cleanup_git_state(git_state)
+ if closed_prs < len(git_state.prs) or deleted_branches < len(git_state.branches):
+ console.print(f"[yellow]{logs.CLEAN_INCOMPLETE}[/yellow]")
+ raise typer.Exit(1)
logger.success(logs.CLEAN_COMPLETE.format(branches=deleted_branches, prs=closed_prs))
+def _delete_stale_recorded_branches(
+ records: list[BranchRecord], groups: list[Group], namespace: str
+) -> None:
+ """Remove branches from a previous failed run that the plan no longer contains.
+
+ A retried execute recreates only the current groups and then overwrites
+ git_state; without this, a branch created before the plan was edited would
+ survive locally (and on origin, if its push succeeded) with no record left
+ for `pr-split clean` to delete.
+ """
+ expected = {f"{BRANCH_PREFIX}{namespace}/{group.id}" for group in groups}
+ for record in records:
+ if record.branch_name in expected:
+ continue
+ if branch_exists(record.branch_name):
+ try:
+ delete_branch(record.branch_name)
+ except GitOperationError as exc:
+ console.print(
+ f"[yellow]Could not delete stale branch"
+ f" {escape(record.branch_name)}: {escape(str(exc))}[/yellow]"
+ )
+ # The remote copy is handled independently: the local branch may be
+ # gone (manual prune, an earlier retry) while the pushed one survives,
+ # and delete_branch would raise before reaching the remote delete.
+ try:
+ run_git("ls-remote", "--exit-code", "origin", f"refs/heads/{record.branch_name}")
+ except GitOperationError:
+ continue # never pushed (or remote unreachable): nothing to delete
+ try:
+ run_git("push", "origin", "--delete", record.branch_name)
+ except GitOperationError as exc:
+ console.print(
+ f"[yellow]Could not delete stale remote branch"
+ f" {escape(record.branch_name)}: {escape(str(exc))}[/yellow]"
+ )
+
+
@app.command(
help="Execute a previously saved dry-run plan, creating branches and PRs.",
)
@@ -969,7 +1868,7 @@ def execute(
typer.Option(
"--stack",
envvar="PR_SPLIT_STACK",
- help="Stack dependent PRs even if the saved plan was not created with --stack",
+ help="Register native GitHub stacks even if the plan was saved without --stack",
),
] = False,
draft: Annotated[
@@ -980,21 +1879,49 @@ def execute(
help="Open every sub-PR as a draft even if the plan was not saved with --draft",
),
] = False,
+ yes: Annotated[
+ bool,
+ typer.Option("--yes", "-y", help="Create the branches and PRs without asking to confirm"),
+ ] = False,
) -> None:
if not plan_exists():
console.print(ErrorMsg.NO_PLAN())
raise typer.Exit(1)
- plan_file = load_plan()
+ plan_file = _load_plan_or_exit()
plan = plan_file.plan
if stack and not plan.stacked:
plan = plan.model_copy(update={"stacked": True})
if draft and not plan.draft:
plan = plan.model_copy(update={"draft": True})
- if plan_file.git_state.branches or plan_file.git_state.prs:
- console.print("[red]This plan already has branches/PRs. Use 'pr-split clean' first.[/red]")
+ existing_prs = {r.group_id: r for r in plan_file.git_state.prs}
+ recorded = {r.group_id: r for r in plan_file.git_state.branches}
+ kept_branches = {gid: recorded[gid] for gid in existing_prs if gid in recorded}
+ group_ids = {g.id for g in plan.groups}
+ if existing_prs and (
+ set(existing_prs) >= group_ids
+ or not set(existing_prs) <= group_ids
+ or set(kept_branches) != set(existing_prs)
+ ):
+ # Every group already has a PR, or the recorded PRs no longer match
+ # the plan's groups: there is nothing safe to resume.
+ console.print("[red]This plan already has PRs. Use 'pr-split clean' first.[/red]")
raise typer.Exit(1)
+ if existing_prs:
+ console.print(
+ f"[yellow]A previous run opened {len(existing_prs)} of {len(plan.groups)} PR(s)."
+ " Keeping those and creating the rest.[/yellow]"
+ )
+ elif plan_file.git_state.branches:
+ # A previous execute created branches but no PRs - a failed push or PR
+ # creation. Branch creation is idempotent (add_worktree recreates an
+ # existing branch), so retry instead of forcing 'clean' + a full
+ # re-plan, which for the LLM backend means paying for planning again.
+ console.print(
+ "[yellow]A previous run created branches but no PRs"
+ " (the push or PR creation failed). Recreating them and retrying.[/yellow]"
+ )
if not plan.raw_diff:
console.print(
@@ -1009,40 +1936,79 @@ def execute(
" Re-run 'pr-split split --dry-run' to regenerate.[/red]"
)
raise typer.Exit(1)
+ if not commit_exists(plan.merge_base_sha):
+ console.print(
+ f"[red]Plan's merge base {plan.merge_base_sha} is not in this repository "
+ "(plan copied from another checkout, or history rewritten). "
+ "Fetch it or re-run 'pr-split split --dry-run' to regenerate.[/red]"
+ )
+ raise typer.Exit(1)
if not branch_exists(plan.base_branch):
console.print(f"[red]{ErrorMsg.BRANCH_NOT_FOUND(branch=plan.base_branch)}[/red]")
raise typer.Exit(1)
+ # A plan saved by an older version may record a remote-tracking base.
+ _require_local_branch(plan.base_branch)
if not is_worktree_clean():
console.print(f"[red]{ErrorMsg.DIRTY_WORKTREE()}[/red]")
raise typer.Exit(1)
if not check_gh_auth():
console.print(f"[red]{ErrorMsg.GH_AUTH_FAILED()}[/red]")
raise typer.Exit(1)
+ if plan.stacked:
+ _require_gh_stack()
- parsed_diff = parse_diff(plan.raw_diff)
+ parsed_diff = parse_diff(plan.raw_diff, split_new_files_over=plan.split_new_files_over)
try:
+ validate_no_binary_files(parsed_diff)
+ # Building the DAG rejects unknown dependency ids; do it here so a
+ # malformed saved plan fails before any branch is created.
+ dag = PlanDAG(plan.groups)
+ dag.validate_acyclic()
validate_coverage(plan.groups, parsed_diff)
+ validate_new_file_pieces(plan.groups, parsed_diff, dag)
except PlanValidationError as exc:
console.print(f"[red]{exc}[/red]")
raise typer.Exit(1) from exc
+ # Any plan with dependency edges is laid out along its DAG (stacked or not),
+ # so a kept layer's branch sits on its ancestors' current commits; rebuilding
+ # an ancestor would rewrite the base under the kept layer's open PR, which
+ # would then show the ancestor's changes again. Their PRs are still opened.
+ if plan.stacked or any(g.depends_on for g in plan.groups):
+ for gid in list(kept_branches):
+ for ancestor in dag.ancestors(gid):
+ if ancestor in recorded:
+ kept_branches.setdefault(ancestor, recorded[ancestor])
+
_present_plan(plan.groups)
- typer.confirm("Proceed with creating branches and PRs?", abort=True)
+ _report_oversized_groups(
+ plan.groups, plan.max_loc, {pf.path: len(pf) for pf in parsed_diff.patch_set}
+ )
+ if not yes:
+ typer.confirm("Proceed with creating branches and PRs?", abort=True)
namespace = derive_split_namespace(plan.dev_branch_arg or plan.dev_branch)
- branch_records = _create_branches_and_commits(
- plan.groups,
- parsed_diff,
- plan.base_branch,
- plan.merge_base_sha,
- namespace,
- author=plan.author,
- stacked=plan.stacked,
- )
+ _delete_stale_recorded_branches(plan_file.git_state.branches, plan.groups, namespace)
+ try:
+ branch_records = _create_branches_and_commits(
+ plan.groups,
+ parsed_diff,
+ plan.base_branch,
+ plan.merge_base_sha,
+ namespace,
+ author=plan.author,
+ stacked=plan.stacked,
+ keep=kept_branches,
+ )
+ except PRSplitError as exc:
+ console.print(f"[red]{exc}[/red]")
+ raise typer.Exit(1) from exc
try:
- pr_records = _push_and_create_prs(plan.groups, branch_records, draft=plan.draft)
+ pr_records = _push_and_create_prs(
+ plan.groups, branch_records, draft=plan.draft, existing_prs=existing_prs
+ )
except PRCreationError as exc:
save_plan(
PlanFile(
@@ -1050,16 +2016,17 @@ def execute(
git_state=GitState(branches=branch_records, prs=exc.pr_records),
)
)
- raise
- if plan.stacked:
- _link_stacks(PlanDAG(plan.groups), pr_records)
-
+ console.print(f"[red]{escape(str(exc))}[/red]")
+ console.print("[yellow]Created branches and PRs were saved to the plan file.[/yellow]")
+ raise typer.Exit(1) from exc
save_plan(
PlanFile(
plan=plan,
git_state=GitState(branches=branch_records, prs=pr_records),
)
)
+ if plan.stacked:
+ _link_stacks(PlanDAG(plan.groups), pr_records, branch_records)
logger.success(f"Execute complete: {len(plan.groups)} PRs created from saved plan")
@@ -1110,7 +2077,14 @@ def _send_webhook(url: str, payload: dict[str, object]) -> None:
)
def merge_all(
auto: Annotated[
- bool, typer.Option("--auto", help="Queue merges to run after CI checks pass")
+ bool,
+ typer.Option(
+ "--auto",
+ help=(
+ "Queue merges to run after CI checks pass, waiting up to 10 minutes per "
+ "batch for them to land before merging dependent PRs"
+ ),
+ ),
] = False,
notify: Annotated[
str | None,
@@ -1125,7 +2099,7 @@ def merge_all(
console.print(ErrorMsg.NO_PLAN())
raise typer.Exit(0)
- plan_file = load_plan()
+ plan_file = _load_plan_or_exit()
plan = plan_file.plan
git_state = plan_file.git_state
pr_map = {r.group_id: r for r in git_state.prs}
@@ -1134,10 +2108,24 @@ def merge_all(
console.print("[yellow]No PRs found in plan. Nothing to merge.[/yellow]")
raise typer.Exit(0)
- dag = PlanDAG(plan.groups)
+ if _base_has_merged(plan.base_branch):
+ console.print(
+ f"[red]{logs.BASE_ALREADY_MERGED.format(base=escape(plan.base_branch))}[/red]"
+ )
+ raise typer.Exit(1)
+
+ # A hand-edited plan.json can carry unknown or cyclic dependencies;
+ # report that instead of a traceback from the DAG walk.
+ try:
+ dag = PlanDAG(plan.groups)
+ dag.validate_acyclic()
+ except PlanValidationError as exc:
+ console.print(f"[red]{exc}[/red]")
+ raise typer.Exit(1) from exc
merged: list[str] = []
skipped: list[str] = []
skipped_ids: set[str] = set()
+ blocked: list[str] = []
failed: list[str] = []
stopped = False
@@ -1166,6 +2154,18 @@ def merge_all(
merged.append(group_id)
continue
+ # Checked after the live state so a PR that already merged on GitHub
+ # (e.g. into a still-open parent branch) counts as merged rather
+ # than being reported as blocked.
+ unmerged_parents = [dep for dep in dag.parents(group_id) if dep not in merged]
+ if unmerged_parents:
+ deps = ", ".join(unmerged_parents)
+ logger.warning(f"{group_id} depends on unmerged {deps}, skipping")
+ skipped_ids.add(group_id)
+ blocked.append(group_id)
+ skipped.append(f"{group_id} (dependency {deps} not merged)")
+ continue
+
if state != "OPEN":
logger.warning(f"PR #{pr_record.pr_number} ({group_id}) is {state}, skipping")
skipped_ids.add(group_id)
@@ -1227,9 +2227,20 @@ def merge_all(
console.print(f"[yellow]Skipped ({len(skipped)}): {', '.join(skipped)}[/yellow]")
if failed:
console.print(f"[red]Failed ({len(failed)}): {', '.join(failed)}[/red]")
+ if blocked:
+ console.print(
+ f"[yellow]Blocked by unmerged dependencies ({len(blocked)}): "
+ f"{', '.join(blocked)}. Re-run once those PRs are merged.[/yellow]"
+ )
if notify:
exit_reason = (
- "merge_error" if stopped else "incomplete_batch" if exited_early else "success"
+ "merge_error"
+ if stopped
+ else "incomplete_batch"
+ if exited_early
+ else "unmerged_dependency"
+ if blocked
+ else "success"
)
skipped_structured = [{"id": s.split(" (")[0], "reason": s} for s in skipped]
_send_webhook(
@@ -1239,11 +2250,133 @@ def merge_all(
"merged": merged,
"skipped": skipped_structured,
"failed": failed,
- "success": not (failed or stopped or exited_early),
+ "success": not (failed or stopped or exited_early or blocked),
"exit_reason": exit_reason,
},
)
- if failed or stopped or exited_early:
+ if failed or stopped or exited_early or blocked:
raise typer.Exit(1)
logger.success(f"Merge complete: {len(merged)} PRs merged")
+
+
+@app.command(
+ help="Rebase every stacked layer onto its parent's current head and push it, so a fix on"
+ " a lower layer reaches the layers above it."
+)
+def restack(
+ dry_run: Annotated[
+ bool, typer.Option("--dry-run", help="Report the layers that need restacking only")
+ ] = False,
+ onto_base: Annotated[
+ bool,
+ typer.Option(
+ "--onto-base",
+ help="Also rebase the layers that target the base branch onto its current head,"
+ " so the whole stack catches up after the base moves",
+ ),
+ ] = False,
+) -> None:
+ if not plan_exists():
+ console.print(f"[red]{ErrorMsg.NO_PLAN()}[/red]")
+ raise typer.Exit(1)
+ try:
+ results = restack_layers(_load_plan_or_exit(), dry_run=dry_run, onto_base=onto_base)
+ except PRSplitError as exc:
+ console.print(f"[red]{exc}[/red]")
+ raise typer.Exit(1) from exc
+ table = Table(title="Restack")
+ table.add_column("ID")
+ table.add_column("Branch")
+ table.add_column("Result")
+ for result in results:
+ table.add_row(result.group_id, result.branch, result.action)
+ console.print(table)
+
+
+@app.command(
+ name="move",
+ help="Move one hunk from a layer of an executed stack up to a layer above it, keeping"
+ " every PR open. HUNK is :, as the plan editor's 'show' prints it.",
+)
+def move_hunk_command(
+ hunk: Annotated[str, typer.Argument(help=":")],
+ source: Annotated[str, typer.Option("--from", help="Group id that holds the hunk now")],
+ target: Annotated[str, typer.Option("--to", help="Group id to move it to")],
+) -> None:
+ path, _, index_text = hunk.rpartition(":")
+ if not path or not index_text.isdigit():
+ console.print(f"[red]Expected :, got '{hunk}'[/red]")
+ raise typer.Exit(1)
+ if not plan_exists():
+ console.print(f"[red]{ErrorMsg.NO_PLAN()}[/red]")
+ raise typer.Exit(1)
+ plan_file = _load_plan_or_exit()
+ try:
+ results = move_stack_hunk(plan_file, path, int(index_text), source, target)
+ except PRSplitError as exc:
+ console.print(f"[red]{exc}[/red]")
+ raise typer.Exit(1) from exc
+ save_plan(plan_file)
+ table = Table(title=f"Moved {hunk} from {source} to {target}")
+ table.add_column("ID")
+ table.add_column("Branch")
+ table.add_column("Result")
+ for result in results:
+ table.add_row(result.group_id, result.branch, result.action)
+ console.print(table)
+
+
+@app.command(
+ help="Rebuild a lost plan from the branches and PRs of a stack that still exists, so"
+ " status, merge, restack and clean work again. DEV_BRANCH is the branch the stack was"
+ " split from."
+)
+def recover(
+ dev_branch: Annotated[str, typer.Argument(help="Branch (or PR) the stack was split from")],
+ base: Annotated[
+ str | None,
+ typer.Option("--base", help="Base branch of the stack; read from its PRs by default"),
+ ] = None,
+ stack: Annotated[
+ bool,
+ typer.Option(
+ "--stack",
+ help="Mark the plan stacked even when no PR targets another layer any more",
+ ),
+ ] = False,
+ force: Annotated[bool, typer.Option("--force", help="Replace an existing plan")] = False,
+) -> None:
+ # The recovered plan is this dev branch's; other branches' plans are left alone.
+ select_plan(dev_branch)
+ if plan_exists() and not force:
+ console.print(f"[red]{ErrorMsg.RECOVER_PLAN_EXISTS(path=plan_path())}[/red]")
+ raise typer.Exit(1)
+ try:
+ plan_file = recover_plan(dev_branch, base=base, stacked=stack)
+ except PRSplitError as exc:
+ console.print(f"[red]{exc}[/red]")
+ raise typer.Exit(1) from exc
+ save_plan(plan_file)
+ git_state = plan_file.git_state
+ table = Table(title=f"Recovered plan for {dev_branch} onto {plan_file.plan.base_branch}")
+ table.add_column("ID")
+ table.add_column("Depends on")
+ table.add_column("Branch")
+ table.add_column("PR")
+ prs = {r.group_id: r for r in git_state.prs}
+ branches = {r.group_id: r.branch_name for r in git_state.branches}
+ for group in plan_file.plan.groups:
+ pr = prs.get(group.id)
+ table.add_row(
+ group.id,
+ ", ".join(group.depends_on),
+ branches.get(group.id, ""),
+ f"#{pr.pr_number} ({pr.state})" if pr else "",
+ )
+ console.print(table)
+ logger.success(
+ logs.RECOVERED_PLAN.format(
+ branches=len(git_state.branches), prs=len(git_state.prs), path=plan_path()
+ )
+ )
diff --git a/pr_split/config.py b/pr_split/config.py
index 9c4abca..dddd4fe 100644
--- a/pr_split/config.py
+++ b/pr_split/config.py
@@ -12,6 +12,10 @@
DEFAULT_MODEL,
DEFAULT_PARTITION_STRATEGY,
DEFAULT_STRICT_LOC_BOUNDS,
+ LOCAL_BASE_URL,
+ LOCAL_MAX_CONTEXT_TOKENS,
+ LOCAL_MAX_OUTPUT_TOKENS,
+ MAX_OUTPUT_TOKENS,
OPENAI_MAX_CONTEXT_TOKENS,
OPENAI_MODEL,
ChunkStrategy,
@@ -34,6 +38,10 @@ class Settings(BaseSettings):
validation_alias="OPENAI_API_KEY",
)
provider: Provider = Provider.ANTHROPIC
+ local_base_url: str = LOCAL_BASE_URL
+ local_api_key: str = ""
+ local_context_tokens: int = Field(default=LOCAL_MAX_CONTEXT_TOKENS, gt=0)
+ local_max_output_tokens: int = Field(default=LOCAL_MAX_OUTPUT_TOKENS, gt=0)
model: str = ""
min_loc: int | None = Field(default=DEFAULT_MIN_LOC, ge=1)
max_loc: int = Field(default=DEFAULT_MAX_LOC, gt=0)
@@ -52,6 +60,10 @@ def set_default_model(self):
self.model = DEFAULT_MODEL
case Provider.OPENAI:
self.model = OPENAI_MODEL
+ case Provider.CLAUDE_CLI:
+ pass # the CLI's own configured default model
+ case Provider.LOCAL:
+ pass # no sensible default; checked when the llm backend runs
case _:
raise NotImplementedError(f"No default model for provider '{self.provider}'")
return self
@@ -59,7 +71,9 @@ def set_default_model(self):
@model_validator(mode="after")
def auto_derive_min_loc(self):
if self.min_loc is None and self.max_refinement_iterations > 0:
- self.min_loc = self.max_loc // DEFAULT_MIN_LOC_RATIO
+ # Floor at 1 so a tiny max_loc cannot derive a min_loc that the
+ # explicit field constraint (ge=1) would have rejected.
+ self.min_loc = max(1, self.max_loc // DEFAULT_MIN_LOC_RATIO)
return self
@model_validator(mode="after")
@@ -81,6 +95,18 @@ def check_api_key_is_present(self):
case Provider.OPENAI:
if not self.openai_api_key:
raise ValueError("OPENAI_API_KEY must be set when provider is 'openai'")
+ case Provider.CLAUDE_CLI:
+ pass # authenticated through the CLI's own login
+ case Provider.LOCAL:
+ if not self.model:
+ raise ValueError(ErrorMsg.LOCAL_MODEL_REQUIRED())
+ if self.local_max_output_tokens >= self.local_context_tokens:
+ raise ValueError(
+ ErrorMsg.LOCAL_OUTPUT_EXCEEDS_CONTEXT(
+ output=self.local_max_output_tokens,
+ context=self.local_context_tokens,
+ )
+ )
case _:
raise NotImplementedError(
f"API key check not implemented for provider '{self.provider}'"
@@ -94,15 +120,31 @@ def api_key(self) -> str:
return self.anthropic_api_key
case Provider.OPENAI:
return self.openai_api_key
+ case Provider.CLAUDE_CLI:
+ return ""
+ case Provider.LOCAL:
+ # The OpenAI client refuses an empty key; local servers ignore it
+ # unless they were started with one.
+ return self.local_api_key or "local"
case _:
raise NotImplementedError(f"Provider '{self.provider}' is not supported")
@property
def max_context_tokens(self) -> int:
match self.provider:
- case Provider.ANTHROPIC:
+ case Provider.ANTHROPIC | Provider.CLAUDE_CLI:
return ANTHROPIC_MAX_CONTEXT_TOKENS
case Provider.OPENAI:
return OPENAI_MAX_CONTEXT_TOKENS
+ case Provider.LOCAL:
+ return self.local_context_tokens
case _:
raise NotImplementedError(f"Provider '{self.provider}' is not supported")
+
+ @property
+ def max_output_tokens(self) -> int:
+ match self.provider:
+ case Provider.LOCAL:
+ return self.local_max_output_tokens
+ case _:
+ return MAX_OUTPUT_TOKENS
diff --git a/pr_split/constants.py b/pr_split/constants.py
index 4e8ac43..597b0b3 100644
--- a/pr_split/constants.py
+++ b/pr_split/constants.py
@@ -22,6 +22,11 @@ class PartitionStrategy(StrEnum):
CP_SAT = "cp_sat"
+# Recorded as a plan's partition strategy when the diff fit within --max-loc
+# and was kept as one group without running any backend.
+NO_BACKEND_STRATEGY = "none"
+
+
class LocViolationType(StrEnum):
BELOW_MIN = "below_min"
ABOVE_MAX = "above_max"
@@ -36,6 +41,12 @@ class PRState(StrEnum):
class Provider(StrEnum):
ANTHROPIC = "anthropic"
OPENAI = "openai"
+ # The locally installed Claude Code CLI (`claude -p`), which uses its own
+ # login instead of an API key.
+ CLAUDE_CLI = "claude-cli"
+ # Any OpenAI-compatible chat-completions server on this machine or network
+ # (Ollama, llama.cpp, vLLM, LM Studio); no API key and no cloud call.
+ LOCAL = "local"
BRANCH_PREFIX = "pr-split/"
@@ -54,6 +65,9 @@ class Provider(StrEnum):
ANTHROPIC_MAX_CONTEXT_TOKENS = 1_000_000
OPENAI_MAX_CONTEXT_TOKENS = 1_050_000
MAX_OUTPUT_TOKENS = 128_000
+LOCAL_BASE_URL = "http://localhost:11434/v1"
+LOCAL_MAX_CONTEXT_TOKENS = 32_768
+LOCAL_MAX_OUTPUT_TOKENS = 8_192
CHUNK_TARGET_RATIO = 2 / 3
CHUNK_RETRY_LIMIT = 2
DEFAULT_MAX_REFINEMENT_ITERATIONS = 0
diff --git a/pr_split/diff_ops/new_file_split.py b/pr_split/diff_ops/new_file_split.py
new file mode 100644
index 0000000..aef894e
--- /dev/null
+++ b/pr_split/diff_ops/new_file_split.py
@@ -0,0 +1,157 @@
+"""Cut an oversized new file into several hunks at top-level boundaries.
+
+Git reports a new file as one hunk, so no plan can bring a new file larger
+than ``--max-loc`` under the limit. In a stacked split a later piece of the
+file is appended by a child PR on top of its parent, which already holds the
+earlier pieces, so the file can be cut anywhere its prefix is still a valid
+file: between top-level blocks.
+
+Python files are cut between top-level statements found by ``ast``, with
+their decorators and the comments directly above them. Other files are cut
+at an unindented line that follows a blank line, outside any bracket,
+multi-line string or block comment, which holds for Rust, Go, JavaScript,
+TypeScript, Java, C and most other languages.
+"""
+
+from __future__ import annotations
+
+import ast
+import re
+from itertools import pairwise
+from typing import TYPE_CHECKING
+
+from unidiff import Hunk
+
+if TYPE_CHECKING:
+ from unidiff import PatchedFile
+ from unidiff.patch import Line
+
+_NO_NEWLINE_MARKER = "\\"
+# A line starting with one of these continues the block above it.
+_CONTINUATIONS = ("}", ")", "]", "else", "elif", "except", "finally", "catch")
+_DOUBLE_QUOTED = re.compile(r'"(?:\\.|[^"\\])*"')
+_SINGLE_QUOTED = re.compile(r"'(?:\\.|[^'\\\n])*'")
+# Rust uses a lone quote for lifetimes ('a), so only char literals are quoted.
+_CHAR_LITERAL = re.compile(r"'(?:\\.[^']*|[^'\\\n])'")
+_LINE_COMMENT = re.compile(r"//.*")
+_OPENERS = "([{"
+_CLOSERS = ")]}"
+
+
+def _python_block_starts(values: list[str]) -> list[int] | None:
+ """Exact top-level statement starts, with their decorators and comments."""
+ try:
+ tree = ast.parse("".join(values))
+ except (SyntaxError, ValueError):
+ return None
+ starts: list[int] = []
+ for node in tree.body[1:]:
+ decorators = getattr(node, "decorator_list", [])
+ start = min([node.lineno, *(d.lineno for d in decorators)]) - 1
+ # Comment lines directly above a statement belong to it.
+ while start > 0 and values[start - 1].startswith("#"):
+ start -= 1
+ starts.append(start)
+ return starts
+
+
+def _strip_literals(code: str, *, rust: bool) -> str:
+ code = _DOUBLE_QUOTED.sub("", code)
+ code = (_CHAR_LITERAL if rust else _SINGLE_QUOTED).sub("", code)
+ return _LINE_COMMENT.sub("", code)
+
+
+def _generic_block_starts(values: list[str], *, rust: bool = False) -> list[int]:
+ """Unindented lines after a blank line, outside brackets, strings and comments."""
+ starts: list[int] = []
+ depth = 0
+ fence: str | None = None # the delimiter that closes an open string/comment
+ for i, line in enumerate(values):
+ if (
+ i > 0
+ and depth == 0
+ and fence is None
+ and not values[i - 1].strip()
+ and line.strip()
+ and line[0] not in " \t"
+ and not line.startswith(_CONTINUATIONS)
+ ):
+ starts.append(i)
+ rest = line
+ while rest:
+ if fence is not None:
+ end = rest.find(fence)
+ if end == -1:
+ break
+ rest, fence = rest[end + len(fence) :], None
+ continue
+ opener = min(
+ ((rest.find(tok), tok) for tok in ('"""', "'''", "/*", "`") if tok in rest),
+ default=None,
+ )
+ code = rest if opener is None else rest[: opener[0]]
+ code = _strip_literals(code, rust=rust)
+ depth = max(0, depth + sum(code.count(c) for c in _OPENERS))
+ depth = max(0, depth - sum(code.count(c) for c in _CLOSERS))
+ if opener is None:
+ break
+ index, token = opener
+ fence = "*/" if token == "/*" else token
+ rest = rest[index + len(token) :]
+ return starts
+
+
+def block_starts(values: list[str], path: str = "") -> list[int]:
+ """Line indices (after the first) where a top-level block starts."""
+ if path.endswith((".py", ".pyi")):
+ starts = _python_block_starts(values)
+ if starts is not None:
+ return starts
+ return _generic_block_starts(values, rust=path.endswith(".rs"))
+
+
+def cut_points(values: list[str], max_loc: int, path: str = "") -> list[int]:
+ """Line indices where a new piece starts, packing whole blocks up to max_loc.
+
+ A single block longer than max_loc stays whole: cutting inside it would
+ leave the parent PR with a file that does not parse.
+ """
+ starts = [s for s in block_starts(values, path) if s > 0]
+ cuts: list[int] = []
+ piece_start = 0
+ block_start = 0
+ for block_end in [*starts, len(values)]:
+ if block_end - piece_start > max_loc and block_start > piece_start:
+ cuts.append(block_start)
+ piece_start = block_start
+ block_start = block_end
+ return cuts
+
+
+def _piece(lines: list[Line], first_line: int) -> Hunk:
+ hunk = Hunk(src_start=0, src_len=0, tgt_start=first_line, tgt_len=len(lines))
+ hunk.extend(lines)
+ return hunk
+
+
+def split_new_file(patch_file: PatchedFile, max_loc: int) -> bool:
+ """Replace a new file's single hunk with pieces of at most max_loc lines.
+
+ Returns whether the file was split. Only a new file whose one hunk is
+ larger than max_loc is touched, and only where a top-level boundary exists.
+ """
+ if not patch_file.is_added_file or len(patch_file) != 1:
+ return False
+ hunk = patch_file[0]
+ if hunk.added <= max_loc:
+ return False
+ lines = [line for line in hunk if line.is_added]
+ cuts = cut_points([line.value for line in lines], max_loc, patch_file.path)
+ if not cuts:
+ return False
+ bounds = [0, *cuts, len(lines)]
+ pieces = [_piece(lines[a:b], a + 1) for a, b in pairwise(bounds)]
+ # "\ No newline at end of file" belongs after the file's last line.
+ pieces[-1].extend(line for line in hunk if line.line_type == _NO_NEWLINE_MARKER)
+ patch_file[:] = pieces
+ return True
diff --git a/pr_split/diff_ops/parser.py b/pr_split/diff_ops/parser.py
index 850ccdc..dbfbd28 100644
--- a/pr_split/diff_ops/parser.py
+++ b/pr_split/diff_ops/parser.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import re
import subprocess
from dataclasses import dataclass
from functools import cached_property
@@ -10,25 +11,147 @@
from .. import logs
from ..exceptions import DiffParseError, GitOperationError
from ..types_defs import DiffStats, FileSummary, HunkInfo
+from .new_file_split import split_new_file
+
+# Flags that pin the diff to the exact unified format the parser and
+# reconstructor expect, regardless of the user's git config:
+# --no-color / --no-ext-diff plain unified output, never an external tool
+# --no-textconv hunks must be raw blob content, since the
+# reconstructor applies them to git show output
+# --no-renames renames become delete + add; the reconstructor
+# resolves base content by path, so a rename
+# reported under its new name would fail
+# -U3 and fixed prefixes override diff.context and diff.mnemonicPrefix,
+# which would shift hunk positions and paths
+DIFF_ARGS: tuple[str, ...] = (
+ "--no-color",
+ "--no-ext-diff",
+ "--no-textconv",
+ "--no-renames",
+ "-U3",
+ "--src-prefix=a/",
+ "--dst-prefix=b/",
+)
def extract_diff(dev_branch: str, base_branch: str) -> str:
logger.info(logs.EXTRACTING_DIFF.format(base=base_branch, dev=dev_branch))
+ # Capture bytes: text mode applies universal-newline translation, which
+ # turns CRLF into LF and would make every sub-PR rewrite the file's
+ # line endings.
result = subprocess.run(
- ["git", "diff", f"{base_branch}...{dev_branch}"],
+ # core.quotePath=false: otherwise git octal-escapes and quotes any
+ # non-ASCII path ("b/caf\303\251.txt"), which unidiff cannot parse for
+ # new/deleted files and mis-reports as a literal quoted path otherwise.
+ ["git", "-c", "core.quotePath=false", "diff", *DIFF_ARGS, f"{base_branch}...{dev_branch}"],
capture_output=True,
- text=True,
)
if result.returncode != 0:
- raise GitOperationError(result.stderr.strip())
- return result.stdout
+ raise GitOperationError(result.stderr.decode("utf-8", errors="replace").strip())
+ # Git diffs any NUL-free file as text, so legacy latin-1 sources reach us
+ # too; surrogateescape keeps their bytes intact so they round-trip when
+ # the worker writes them back with the same error handler.
+ return result.stdout.decode("utf-8", errors="surrogateescape")
+
+
+_C_ESCAPES = {
+ "a": b"\a",
+ "b": b"\b",
+ "f": b"\f",
+ "n": b"\n",
+ "r": b"\r",
+ "t": b"\t",
+ "v": b"\v",
+ "\\": b"\\",
+ '"': b'"',
+}
+_C_ESCAPE_RE = re.compile(r"\\([0-7]{1,3}|.)", re.DOTALL)
+
+
+def unquote_git_path(path: str) -> str:
+ """Decode a path git printed in its C-quoted form, e.g. `"a/we\\"ird.py"`.
+
+ Even with core.quotePath=false git quotes any path containing `"`, `\\`
+ or a control character. unidiff keeps the quotes and escapes verbatim, so
+ without this the a/ b/ prefix is never stripped and the file would be
+ written under a literally quoted name.
+ """
+ if len(path) < 2 or path[0] != '"' or path[-1] != '"':
+ return path
+ # Literal spans may carry surrogates for undecodable filename bytes (see
+ # extract_diff); surrogateescape turns them back into those bytes.
+ out = bytearray()
+ pos = 0
+ body = path[1:-1]
+ for match in _C_ESCAPE_RE.finditer(body):
+ out += body[pos : match.start()].encode("utf-8", errors="surrogateescape")
+ escape = match.group(1)
+ if escape[0] in "01234567":
+ # Octal escapes are single bytes; consecutive ones form one
+ # multi-byte UTF-8 character, which the final decode reassembles.
+ out.append(int(escape, 8))
+ else:
+ out += _C_ESCAPES.get(escape, escape.encode("utf-8"))
+ pos = match.end()
+ out += body[pos:].encode("utf-8", errors="surrogateescape")
+ return bytes(out).decode("utf-8", errors="surrogateescape")
+
+
+_QUOTED_PATH_RE = re.compile(r'"(?:[^"\\]|\\.)*"')
+_HEADER_PREFIXES = ("diff --git ", "--- ", "+++ ")
+
+
+def _escape_spaces_in_quoted_paths(line: str) -> str:
+ return _QUOTED_PATH_RE.sub(lambda m: m.group(0).replace(" ", "\\040"), line)
-def parse_diff(raw_diff: str) -> ParsedDiff:
+def _normalize_quoted_headers(raw_diff: str) -> str:
+ """Make C-quoted paths containing spaces parseable by unidiff.
+
+ unidiff splits `diff --git ` on the last space, so a quoted
+ path with a space (`"a/my \\"notes\\".md"`) is cut in the wrong place and
+ no longer matches the `---`/`+++` lines: unidiff then keeps a phantom
+ 0-hunk file for a modification and raises "Target without source" for an
+ addition or deletion. Spaces inside quoted paths are rewritten as the
+ octal escape `\\040` -- still valid C-quoting, which unquote_git_path
+ decodes -- on the header lines only (between `diff --git` and the first
+ `@@`), so hunk content is never touched.
+ """
+ if '"' not in raw_diff:
+ return raw_diff
+ lines = raw_diff.split("\n")
+ in_header = False
+ for i, line in enumerate(lines):
+ if line.startswith("diff --git "):
+ in_header = True
+ elif line.startswith("@@"):
+ in_header = False
+ if in_header and '"' in line and line.startswith(_HEADER_PREFIXES):
+ lines[i] = _escape_spaces_in_quoted_paths(line)
+ return "\n".join(lines)
+
+
+def parse_diff(raw_diff: str, *, split_new_files_over: int | None = None) -> ParsedDiff:
+ """Parse a unified diff.
+
+ With ``split_new_files_over``, a new file larger than that many lines is
+ cut into several hunks at top-level boundaries (see new_file_split), so a
+ stacked split can spread it over a chain of PRs. The same value must be
+ passed wherever the plan's hunk indices are read back.
+ """
try:
- patch_set = PatchSet(raw_diff)
+ patch_set = PatchSet(_normalize_quoted_headers(raw_diff))
except Exception as exc:
raise DiffParseError(str(exc)) from exc
+ for patch_file in patch_set:
+ patch_file.source_file = unquote_git_path(patch_file.source_file)
+ patch_file.target_file = unquote_git_path(patch_file.target_file)
+ if split_new_files_over is not None and split_new_file(patch_file, split_new_files_over):
+ logger.info(
+ logs.NEW_FILE_SPLIT.format(
+ file=patch_file.path, loc=patch_file.added, pieces=len(patch_file)
+ )
+ )
return ParsedDiff(patch_set=patch_set, raw_diff=raw_diff)
@@ -37,6 +160,15 @@ class ParsedDiff:
patch_set: PatchSet
raw_diff: str
+ @property
+ def new_file_pieces(self) -> dict[str, int]:
+ """New files cut into several pieces, with their piece counts.
+
+ Git reports a new file as a single hunk, so a new file with more than
+ one is one that parse_diff split; piece k needs pieces 0..k-1 below it.
+ """
+ return {pf.path: len(pf) for pf in self.patch_set if pf.is_added_file and len(pf) > 1}
+
@property
def file_paths(self) -> list[str]:
return [pf.path for pf in self.patch_set]
diff --git a/pr_split/diff_ops/reconstructor.py b/pr_split/diff_ops/reconstructor.py
index 2132da3..b8df46c 100644
--- a/pr_split/diff_ops/reconstructor.py
+++ b/pr_split/diff_ops/reconstructor.py
@@ -3,7 +3,7 @@
import subprocess
from loguru import logger
-from unidiff import PatchedFile
+from unidiff import Hunk, PatchedFile
from .. import logs
from ..constants import AssignmentType
@@ -23,13 +23,9 @@ def merge_chain_assignments(
ancestor_hunks: dict[str, set[int]] = {}
for ancestor in ancestors:
for assignment in ancestor.assignments:
- # A WHOLE_FILE assignment covers every hunk even when its
- # hunk_indices list was left empty, so expand from the diff.
- if assignment.assignment_type is AssignmentType.WHOLE_FILE:
- covered = set(range(counts.get(assignment.file_path, 0)))
- covered.update(assignment.hunk_indices)
- else:
- covered = set(assignment.hunk_indices)
+ # Expand exactly like the validator does: a WHOLE_FILE assignment
+ # is every parsed hunk (stale stored indices are ignored).
+ covered = set(assignment.covered_indices(counts.get(assignment.file_path, 0)))
ancestor_hunks.setdefault(assignment.file_path, set()).update(covered)
merged = []
@@ -60,66 +56,108 @@ def merge_chain_assignments(
def _get_base_file_content(file_path: str, ref: str) -> str:
+ # Bytes, not text mode: universal newlines would strip the CR from CRLF
+ # files and the reconstructed file would come out with LF endings.
result = subprocess.run(
["git", "show", f"{ref}:{file_path}"],
capture_output=True,
- text=True,
)
if result.returncode != 0:
- raise GitOperationError(result.stderr.strip())
- return result.stdout
+ raise GitOperationError(result.stderr.decode("utf-8", errors="replace").strip())
+ return result.stdout.decode("utf-8", errors="surrogateescape")
+
+
+NO_NEWLINE_MARKER = "\\"
+
+
+def _hunk_target_lines(hunk: Hunk) -> list[str]:
+ """Return the post-image lines of a hunk with their exact line endings.
+
+ unidiff always attaches a newline to a line's value, and reports a
+ missing trailing newline as a separate ``\\ No newline at end of file``
+ marker following that line. Honour the marker so a file that ends
+ without a newline is reconstructed byte-for-byte.
+ """
+ target: list[str] = []
+ last_was_target = False
+ for line in hunk:
+ if line.is_added or line.is_context:
+ value = line.value
+ target.append(value if value.endswith("\n") else value + "\n")
+ last_was_target = True
+ elif line.line_type == NO_NEWLINE_MARKER:
+ if last_was_target:
+ target[-1] = target[-1].rstrip("\n")
+ last_was_target = False
+ else:
+ last_was_target = False
+ return target
+
+
+def split_git_lines(content: str) -> list[str]:
+ """Split file content into lines the way git counts them: on ``\\n`` only.
+
+ ``str.splitlines`` also breaks on form feed, vertical tab, ``\\x1c``-``\\x1e``,
+ ``\\x85``, ``\\u2028`` and ``\\u2029``, none of which git treats as a line
+ break, so every hunk after such a character would land at the wrong offset.
+ """
+ if not content:
+ return []
+ parts = content.split("\n")
+ lines = [part + "\n" for part in parts[:-1]]
+ if parts[-1]:
+ lines.append(parts[-1])
+ return lines
def apply_hunks(base_content: str, patch_file: PatchedFile, assigned_indices: list[int]) -> str:
- lines = base_content.splitlines(keepends=True)
+ lines = split_git_lines(base_content)
sorted_indices = sorted(assigned_indices, reverse=True)
for idx in sorted_indices:
hunk = patch_file[idx]
start = hunk.source_start - 1
end = start + hunk.source_length
- target_lines = [str(line)[1:] for line in hunk if line.is_added or line.is_context]
- target_with_endings = [ln if ln.endswith("\n") else ln + "\n" for ln in target_lines]
- lines[start:end] = target_with_endings
+ lines[start:end] = _hunk_target_lines(hunk)
return "".join(lines)
+def _assigned_hunk_indices(
+ patch_file: PatchedFile, assignments: list[GroupAssignment]
+) -> list[int]:
+ """Union of the hunks every assignment for one file claims."""
+ covered: set[int] = set()
+ for assignment in assignments:
+ if assignment.assignment_type is AssignmentType.WHOLE_FILE:
+ covered.update(range(len(patch_file)))
+ else:
+ covered.update(assignment.hunk_indices)
+ return sorted(covered)
+
+
def materialize_group_files(
parsed_diff: ParsedDiff, group: Group, ref: str
) -> dict[str, str | None]:
- logger.info(logs.MATERIALIZING_FILES.format(count=len(group.assignments), group=group.id))
pf_map = {pf.path: pf for pf in parsed_diff.patch_set}
- result: dict[str, str | None] = {}
+ # Several assignments may name the same file (e.g. merged across diff
+ # chunks); each file is written once from the union of their hunks.
+ assignments_by_path: dict[str, list[GroupAssignment]] = {}
for assignment in group.assignments:
- patch_file = pf_map.get(assignment.file_path)
+ assignments_by_path.setdefault(assignment.file_path, []).append(assignment)
+ logger.info(logs.MATERIALIZING_FILES.format(count=len(assignments_by_path), group=group.id))
+ result: dict[str, str | None] = {}
+ for file_path, assignments in assignments_by_path.items():
+ patch_file = pf_map.get(file_path)
if patch_file is None:
continue
if patch_file.is_removed_file:
- result[assignment.file_path] = None
+ result[file_path] = None
continue
+ indices = _assigned_hunk_indices(patch_file, assignments)
if patch_file.is_added_file:
- all_indices = list(range(len(patch_file)))
- match assignment.assignment_type:
- case AssignmentType.WHOLE_FILE:
- indices = all_indices
- case AssignmentType.PARTIAL_HUNKS:
- indices = assignment.hunk_indices
- target_lines = []
- for idx in indices:
- hunk = patch_file[idx]
- for line in hunk:
- if line.is_added or line.is_context:
- target_lines.append(str(line)[1:])
- # Lines from unidiff keep their trailing newline, so they are
- # concatenated as-is; joining on "\n" double-spaces the file.
- result[assignment.file_path] = "".join(
- ln if ln.endswith("\n") else ln + "\n" for ln in target_lines
+ result[file_path] = "".join(
+ "".join(_hunk_target_lines(patch_file[idx])) for idx in indices
)
continue
- base_content = _get_base_file_content(assignment.file_path, ref)
- match assignment.assignment_type:
- case AssignmentType.WHOLE_FILE:
- indices = list(range(len(patch_file)))
- case AssignmentType.PARTIAL_HUNKS:
- indices = assignment.hunk_indices
- result[assignment.file_path] = apply_hunks(base_content, patch_file, indices)
+ base_content = _get_base_file_content(file_path, ref)
+ result[file_path] = apply_hunks(base_content, patch_file, indices)
return result
diff --git a/pr_split/exceptions.py b/pr_split/exceptions.py
index f5a6958..55ac79c 100644
--- a/pr_split/exceptions.py
+++ b/pr_split/exceptions.py
@@ -9,24 +9,123 @@
class ErrorMsg(StrEnum):
BRANCH_NOT_FOUND = "Branch '{branch}' does not exist"
+ BASE_NOT_A_LOCAL_BRANCH = (
+ "Base '{base}' is not a local branch; sub-PRs are opened against it on GitHub, "
+ "so pass the branch name{suggestion}"
+ )
DIRTY_WORKTREE = "Working tree has uncommitted changes; commit or stash first"
GH_AUTH_FAILED = "GitHub CLI authentication failed; run 'gh auth login'"
CYCLE_DETECTED = "Dependency cycle detected in split plan"
COVERAGE_GAP = "Hunk {file}[{index}] not assigned to any group"
COVERAGE_OVERLAP = "Hunk {file}[{index}] assigned to multiple groups: {groups}"
+ UNKNOWN_HUNK = "Hunk {file}[{index}] assigned to group '{group}' does not exist in the diff"
+ UNKNOWN_FILE = "File '{file}' assigned to group '{group}' does not exist in the diff"
+ UNKNOWN_DEPENDENCY = "Group '{group}' depends on unknown group '{dep}'"
+ DUPLICATE_GROUP_ID = "Group id '{group}' is used more than once"
+ NEW_FILE_PIECE_ORDER = (
+ "Group '{group}' holds piece {piece} of new file '{file}' but does not build on"
+ " group '{parent}', which holds piece {previous}; its sub-PR would create the file"
+ " without its beginning"
+ )
LOC_MISMATCH = "Total LOC {actual} does not match diff LOC {expected}"
MERGE_CONFLICT = "Groups '{a}' and '{b}' modify overlapping regions in '{file}'"
NO_PLAN = "No split plan found; run 'pr-split split' first"
+ PLAN_AMBIGUOUS = (
+ "Several split plans are saved ({branches}); pick one with"
+ " 'pr-split --branch '"
+ )
+ PLAN_LOAD_FAILED = (
+ "Cannot load split plan from '{path}': {detail}; delete it and run 'pr-split split' again"
+ )
LLM_PARSE_ERROR = "Failed to parse LLM response: {detail}"
+ CLAUDE_CLI_NOT_FOUND = "Provider 'claude-cli' needs the Claude Code CLI ('claude') on PATH"
+ LOCAL_MODEL_REQUIRED = (
+ "PR_SPLIT_MODEL must be set when provider is 'local'"
+ " (the model name your local server serves, e.g. 'qwen2.5-coder:14b')"
+ )
+ LOCAL_OUTPUT_EXCEEDS_CONTEXT = (
+ "PR_SPLIT_LOCAL_MAX_OUTPUT_TOKENS ({output}) must be less than"
+ " PR_SPLIT_LOCAL_CONTEXT_TOKENS ({context})"
+ )
+ NO_DIFF_BUDGET = (
+ "No room for the diff in a chunk: {budget} tokens left after the output budget"
+ " ({output}) and prompt overhead ({overhead}) in a {context}-token window; raise"
+ " PR_SPLIT_LOCAL_CONTEXT_TOKENS or lower PR_SPLIT_LOCAL_MAX_OUTPUT_TOKENS"
+ )
+ LOCAL_SERVER_UNREACHABLE = (
+ "Cannot reach the local LLM server at {url}: {detail};"
+ " start it (e.g. 'ollama serve') or set PR_SPLIT_LOCAL_BASE_URL"
+ )
+ LLM_OUTPUT_TRUNCATED = (
+ "LLM response was cut off before the plan was complete ({detail});"
+ " the partial plan cannot be trusted"
+ )
+ RESTACK_NOT_STACKED = "restack only applies to a plan split with --stack"
+ RESTACK_NO_BRANCHES = "No branches recorded for this plan; run 'pr-split execute' first"
+ RESTACK_CHECKED_OUT = (
+ "Cannot restack branches checked out in a worktree: {branches};"
+ " switch to another branch first"
+ )
+ RESTACK_DIVERGED = (
+ "Branch '{branch}' and '{remote}/{branch}' both have commits the other lacks;"
+ " reconcile them, then run 'pr-split restack' again"
+ )
+ RESTACK_BASE_FETCH_FAILED = "Cannot fetch base branch '{base}' to restack onto it ({detail})"
+ RESTACK_CONFLICT = (
+ "Rebasing '{branch}' onto '{parent}' conflicts; every local branch was left as it was."
+ " Rebase it by hand, push it, then run 'pr-split restack' again ({detail})"
+ )
+ PER_GROUP_STEP_FAILED = "Per-group step failed for group '{group}' (exit {code}): {output}"
+ CONFIG_INVALID = "Cannot read '{path}': {detail}"
+ MOVE_NO_DIFF = "The saved plan has no diff; re-run 'pr-split split'"
+ MOVE_UNKNOWN_LAYER = "Group '{group}' is not an executed layer of this plan"
+ MOVE_NOT_UPWARD = (
+ "Group '{target}' does not build on '{source}'; a hunk can only move up the stack"
+ )
+ MOVE_NOT_A_CHAIN = (
+ "Group '{group}' has several parents, so the stack between the layers is not a chain"
+ )
+ MOVE_UNKNOWN_HUNK = "The plan's diff has no hunk {file}:{index}"
+ MOVE_UNSUPPORTED_FILE = (
+ "Moving a hunk of '{file}' is not supported: deleted files and pieces of a split"
+ " new file move with their whole file only"
+ )
+ MOVE_HUNK_NOT_IN_LAYER = "Group '{group}' does not hold hunk {file}:{index}"
+ MOVE_HUNK_DOES_NOT_APPLY = (
+ "The hunk no longer applies to '{branch}' ({detail}); nothing on that branch was changed"
+ )
+ RECOVER_PLAN_EXISTS = "A split plan already exists at '{path}'; pass --force to replace it"
+ RECOVER_NOTHING_FOUND = (
+ "No branch or PR under '{prefix}' was found; pass the dev branch the stack was split from"
+ )
+ RECOVER_BASE_UNKNOWN = (
+ "Cannot tell which base branch the stack under '{prefix}' targets ({bases}); pass --base"
+ )
+ RECOVER_PR_LIST_FAILED = "Cannot list PRs to rebuild the plan ({detail})"
+ LLM_TOKEN_COUNT_FAILED = "Could not count prompt tokens with the LLM API: {detail}"
+ LLM_KEY_REJECTED = (
+ "{variable} was rejected by the LLM API; set a valid key, or plan without one"
+ " using --partition-strategy graph"
+ )
BRANCH_CREATE_FAILED = "Failed to create branch '{branch}': {detail}"
PR_CREATE_FAILED = "Failed to create PR for group '{group}': {detail}"
MERGE_FAILED = "Merge of '{source}' into '{target}' failed: {detail}"
- PR_NOT_FOUND = "PR #{number} not found or is not from a fork"
- PR_FETCH_FAILED = "Failed to fetch fork branch for PR #{number}: {detail}"
+ PR_NOT_FOUND = "PR #{number} not found"
+ PR_RESPONSE_INVALID = "Unexpected response from GitHub for PR #{number}: {detail}"
+ PR_FETCH_FAILED = "Failed to fetch the head of PR #{number} from {source}: {detail}"
FORK_FETCH_FAILED = "Failed to fetch {user}:{branch}: {detail}"
HUNK_TOO_LARGE = "Hunk {file}[{index}] has ~{tokens} estimated tokens, exceeds budget {budget}"
MIN_LOC_GE_MAX_LOC = "min_loc {min_loc} must be less than max_loc {max_loc}"
LOC_BOUNDS_STRICT_FAILED = "Plan violates configured LOC bounds"
+ BINARY_FILES_UNSUPPORTED = (
+ "Diff contains binary files, which cannot be split into hunks: {files}."
+ " Commit them separately and re-run"
+ )
+ GH_STACK_MISSING = (
+ "The gh-stack extension is required for stacked PRs;"
+ " run 'gh extension install github/gh-stack'"
+ )
+ STACK_LINK_FAILED = "Failed to link stack for PRs {prs}: {detail}"
def __call__(self, **kwargs: object) -> str:
return self.value.format(**kwargs) if kwargs else self.value
diff --git a/pr_split/git_ops/__init__.py b/pr_split/git_ops/__init__.py
index 5cfddbd..2aae56d 100644
--- a/pr_split/git_ops/__init__.py
+++ b/pr_split/git_ops/__init__.py
@@ -2,26 +2,23 @@
add_worktree as add_worktree,
)
from .branches import (
- branch_exists as branch_exists,
-)
-from .branches import (
- checkout_branch as checkout_branch,
+ adopt_remote_branch as adopt_remote_branch,
)
from .branches import (
- commit_files as commit_files,
+ branch_exists as branch_exists,
)
from .branches import (
commit_files_in_dir as commit_files_in_dir,
)
-from .branches import (
- create_group_branch as create_group_branch,
-)
from .branches import (
delete_branch as delete_branch,
)
from .branches import (
derive_split_namespace as derive_split_namespace,
)
+from .branches import (
+ diff_base_ref as diff_base_ref,
+)
from .branches import (
is_worktree_clean as is_worktree_clean,
)
@@ -37,6 +34,9 @@
from .prs import (
check_gh_auth as check_gh_auth,
)
+from .prs import (
+ check_gh_stack as check_gh_stack,
+)
from .prs import (
close_pr as close_pr,
)
diff --git a/pr_split/git_ops/branches.py b/pr_split/git_ops/branches.py
index d6005c3..48825b0 100644
--- a/pr_split/git_ops/branches.py
+++ b/pr_split/git_ops/branches.py
@@ -1,20 +1,33 @@
from __future__ import annotations
+import atexit
+import functools
+import os
import re
+import shutil
import subprocess
+import tempfile
+import time
from loguru import logger
from .. import logs
-from ..constants import BRANCH_PREFIX
+from ..constants import PLAN_DIR
from ..exceptions import GitOperationError
+def _git_env() -> dict[str, str]:
+ # delete_branch inspects git's stderr; pin the UI language so those
+ # messages are stable on localised systems.
+ return {**os.environ, "LC_ALL": "C", "LANGUAGE": "C"}
+
+
def run_git(*args: str) -> str:
result = subprocess.run(
["git", *args],
capture_output=True,
text=True,
+ env=_git_env(),
)
if result.returncode != 0:
raise GitOperationError(result.stderr.strip())
@@ -27,12 +40,22 @@ def run_git_in_dir(cwd: str, *args: str) -> str:
capture_output=True,
text=True,
cwd=cwd,
+ env=_git_env(),
)
if result.returncode != 0:
raise GitOperationError(result.stderr.strip())
return result.stdout.strip()
+def commit_exists(ref: str) -> bool:
+ """True if ``ref`` resolves to a commit object in this repository."""
+ try:
+ run_git("cat-file", "-e", f"{ref}^{{commit}}")
+ except GitOperationError:
+ return False
+ return True
+
+
def branch_exists(branch: str) -> bool:
try:
run_git("rev-parse", "--verify", branch)
@@ -41,75 +64,207 @@ def branch_exists(branch: str) -> bool:
return True
-def is_worktree_clean() -> bool:
- output = run_git("status", "--porcelain")
- return all(line.startswith("??") for line in output.splitlines())
+def adopt_remote_branch(branch: str) -> bool:
+ """Create local ``branch`` from its remote-tracking ref when that is unambiguous.
+ A fresh clone or worktree has ``origin/`` but no local branch;
+ like ``git checkout ``, adopt the single remote that has it and
+ track it, so ``diff_base_ref`` keeps an adopted base fresh from that
+ remote. Returns True when a local branch was created.
+ """
+ if branch_exists(f"refs/heads/{branch}"):
+ return False
+ try:
+ listing = run_git("for-each-ref", "--format=%(refname:short)", f"refs/remotes/*/{branch}")
+ except GitOperationError:
+ return False
+ candidates = [ref for ref in listing.splitlines() if ref.split("/", 1)[1:] == [branch]]
+ if len(candidates) != 1:
+ return False
+ run_git("branch", "--track", branch, candidates[0])
+ logger.info(logs.ADOPTED_REMOTE_BRANCH.format(branch=branch, remote_ref=candidates[0]))
+ return True
-def checkout_new_branch(name: str, start_point: str) -> None:
- run_git("checkout", "-b", name, start_point)
+def is_worktree_clean() -> bool:
+ """True when nothing tracked is modified, ignoring pr-split's own plan directory.
+
+ `split`/`edit` write `.pr-split/plan.json`; a user who commits the plan
+ (to share or review it) then has a modified tracked file, and `execute`
+ would refuse to run on the very plan it was asked to execute. Everything
+ under the plan directory is therefore excluded from the check.
+ """
+ output = run_git("status", "--porcelain", "--", f":(top,exclude){PLAN_DIR}")
+ return all(line.startswith("??") for line in output.splitlines())
-def checkout_branch(name: str) -> None:
- run_git("checkout", name)
+# Server-side failures GitHub reports for a push that can succeed on retry.
+_TRANSIENT_PUSH_ERRORS = (
+ "fatal error in commit_refs",
+ "the remote end hung up unexpectedly",
+ "internal server error",
+ "http 500",
+ "http 502",
+ "http 503",
+ "connection reset",
+ "operation timed out",
+)
+_PUSH_ATTEMPTS = 3
+_PUSH_RETRY_DELAY = 2.0
-def commit_files(file_paths: list[str], message: str, *, author: str | None = None) -> str:
- run_git("add", "--", *file_paths)
- author_args = ("--author", author) if author else ()
+
+def _remote_has_local_head(branch: str) -> bool:
try:
- run_git("commit", "--no-verify", "-m", message, *author_args)
+ remote = run_git("ls-remote", "origin", f"refs/heads/{branch}").split()
+ return bool(remote) and remote[0] == run_git("rev-parse", branch)
except GitOperationError:
- run_git("add", "-u")
- run_git("commit", "--no-verify", "-m", message, *author_args)
- return run_git("rev-parse", "HEAD")
+ return False
def push_branch(branch: str) -> None:
logger.info(logs.PUSHING_BRANCH.format(branch=branch))
- run_git("push", "--force-with-lease", "-u", "origin", branch)
+ retried = False
+ for attempt in range(1, _PUSH_ATTEMPTS + 1):
+ try:
+ run_git("push", "--force-with-lease", "-u", "origin", branch)
+ return
+ except GitOperationError as exc:
+ transient = any(marker in str(exc).lower() for marker in _TRANSIENT_PUSH_ERRORS)
+ # A push reported as failed (hung-up remote, timeout) may still have
+ # landed; the retry is then rejected as a stale lease although the
+ # branch is already on the remote.
+ if retried and not transient and _remote_has_local_head(branch):
+ return
+ if not transient or attempt == _PUSH_ATTEMPTS:
+ raise
+ retried = True
+ logger.warning(
+ logs.PUSH_RETRY.format(branch=branch, attempt=attempt, error=str(exc).strip())
+ )
+ time.sleep(_PUSH_RETRY_DELAY * attempt)
+
+
+_LOCAL_BRANCH_MISSING = "not found"
+_REMOTE_REF_MISSING = "remote ref does not exist"
def delete_branch(branch: str, *, remote: bool = False) -> None:
- run_git("branch", "-D", branch)
- logger.info(logs.BRANCH_DELETED.format(branch=branch))
+ """Delete ``branch`` locally and, with ``remote``, on origin.
+
+ A branch that is already gone (``pr-split merge`` deletes the local and
+ remote branch as part of merging) counts as deleted: cleanup must be
+ re-runnable and must not report a completed merge as a failure.
+ """
+ local_error: GitOperationError | None = None
+ try:
+ run_git("branch", "-D", branch)
+ logger.info(logs.BRANCH_DELETED.format(branch=branch))
+ except GitOperationError as exc:
+ if _LOCAL_BRANCH_MISSING in str(exc):
+ logger.info(logs.BRANCH_ALREADY_GONE.format(branch=branch, where=" locally"))
+ elif not remote:
+ raise
+ else:
+ # The local branch may be checked out; still remove the remote
+ # branch so the cleanup is not left half done.
+ local_error = exc
if remote:
- run_git("push", "origin", "--delete", branch)
+ try:
+ run_git("push", "origin", "--delete", branch)
+ except GitOperationError as exc:
+ if _REMOTE_REF_MISSING not in str(exc):
+ raise
+ logger.info(logs.BRANCH_ALREADY_GONE.format(branch=branch, where=" on origin"))
+ if local_error is not None:
+ raise local_error
def merge_base(ref_a: str, ref_b: str) -> str:
return run_git("merge-base", ref_a, ref_b)
+def _branch_config(branch: str, key: str) -> str:
+ try:
+ return run_git("config", "--get", f"branch.{branch}.{key}")
+ except GitOperationError:
+ return ""
+
+
+def diff_base_ref(base: str) -> str:
+ """The ref to diff against for a split whose sub-PRs target ``base``.
+
+ Sub-PRs are opened against the remote's copy of ``base``, but the local
+ branch may lag behind it; diffing against the stale local ref would
+ present every upstream commit since as branch work. So when ``base``
+ tracks a remote branch, fetch it and return the remote-tracking ref
+ (e.g. ``origin/main``). A failed fetch (offline) falls back to the
+ tracking ref as last fetched; a branch with no upstream is used as is.
+ """
+ remote = _branch_config(base, "remote")
+ merge_ref = _branch_config(base, "merge")
+ if not remote or remote == "." or not merge_ref.startswith("refs/heads/"):
+ return base
+ tracking = f"refs/remotes/{remote}/{merge_ref.removeprefix('refs/heads/')}"
+ try:
+ run_git("fetch", "--quiet", remote, merge_ref)
+ except GitOperationError as exc:
+ logger.warning(logs.BASE_FETCH_FAILED.format(remote=remote, base=base, detail=exc))
+ if not commit_exists(tracking):
+ return base
+ upstream = tracking.removeprefix("refs/remotes/")
+ counts = run_git("rev-list", "--left-right", "--count", f"{base}...{tracking}").split()
+ ahead, behind = int(counts[0]), int(counts[1])
+ if behind:
+ logger.warning(logs.LOCAL_BASE_BEHIND.format(base=base, upstream=upstream, count=behind))
+ if ahead:
+ logger.warning(logs.LOCAL_BASE_AHEAD.format(base=base, upstream=upstream, count=ahead))
+ return upstream
+
+
def derive_split_namespace(dev_branch_arg: str) -> str:
raw = dev_branch_arg.split(":", 1)[1] if ":" in dev_branch_arg else dev_branch_arg.lstrip("#")
sanitized = re.sub(r"[^a-zA-Z0-9._-]", "-", raw)
return sanitized.strip("-")
-def create_group_branch(group_id: str, base: str, namespace: str) -> str:
- branch_name = f"{BRANCH_PREFIX}{namespace}/{group_id}"
- logger.info(logs.CREATING_BRANCH.format(branch=branch_name, base=base))
- if branch_exists(branch_name):
- checkout_branch(base)
- run_git("branch", "-D", branch_name)
- checkout_new_branch(branch_name, base)
- return branch_name
-
-
def add_worktree(path: str, branch_name: str, start_point: str) -> None:
prev_sha: str | None = None
if branch_exists(branch_name):
prev_sha = run_git("rev-parse", branch_name)
run_git("branch", "-D", branch_name)
try:
- run_git("worktree", "add", "-b", branch_name, path, start_point)
+ # A repository post-checkout hook (husky, lint-staged installers)
+ # runs inside the throwaway worktree, where it has no toolchain and
+ # can only fail; pointing hooksPath at an empty directory for this
+ # one command disables it.
+ run_git(
+ "-c",
+ f"core.hooksPath={_no_hooks_dir()}",
+ "worktree",
+ "add",
+ "-b",
+ branch_name,
+ path,
+ start_point,
+ )
except GitOperationError:
if prev_sha is not None:
run_git("branch", branch_name, prev_sha)
raise
+@functools.cache
+def _no_hooks_dir() -> str:
+ """An empty directory to use as core.hooksPath (no hooks run).
+
+ mkdtemp creates it private to this user, so nobody else can plant hooks
+ in it the way they could in a fixed, shared path under the temp dir.
+ """
+ path = tempfile.mkdtemp(prefix="pr-split-no-hooks-")
+ atexit.register(shutil.rmtree, path, ignore_errors=True)
+ return path
+
+
def remove_worktree(path: str) -> None:
run_git("worktree", "remove", "--force", path)
diff --git a/pr_split/git_ops/prs.py b/pr_split/git_ops/prs.py
index 9129796..f784173 100644
--- a/pr_split/git_ops/prs.py
+++ b/pr_split/git_ops/prs.py
@@ -1,7 +1,9 @@
from __future__ import annotations
import json
+import re
import subprocess
+from collections.abc import Sequence
from loguru import logger
@@ -30,6 +32,48 @@ def check_gh_auth() -> bool:
return True
+GH_STACK_EXTENSION = "github/gh-stack"
+
+
+def check_gh_stack() -> bool:
+ """Return whether the gh-stack extension is installed.
+
+ Raises GitOperationError if ``gh extension list`` itself fails, so an
+ operational problem (gh missing, auth rejected) is not mistaken for a
+ missing extension.
+ """
+ installed = _run_gh("extension", "list")
+ return any(GH_STACK_EXTENSION in line.split() for line in installed.splitlines())
+
+
+def find_open_pr(branch: str) -> tuple[int, str] | None:
+ """The open PR whose head is ``branch`` in this repository, if any.
+
+ ``gh pr list --head`` can also return PRs from forks and PRs whose head
+ merely starts with ``branch``, so only an exact, same-repository head counts.
+ """
+ raw = _run_gh(
+ "pr",
+ "list",
+ "--head",
+ branch,
+ "--state",
+ "open",
+ "--json",
+ "number,url,headRefName,isCrossRepository",
+ "--limit",
+ str(PR_LIST_LIMIT),
+ )
+ try:
+ prs = json.loads(raw or "[]")
+ except json.JSONDecodeError:
+ return None
+ for pr in prs:
+ if pr.get("headRefName") == branch and not pr.get("isCrossRepository"):
+ return int(pr["number"]), str(pr["url"])
+ return None
+
+
def create_pr(
head: str, base: str, title: str, body: str, *, draft: bool = False
) -> tuple[int, str]:
@@ -66,6 +110,36 @@ def get_pr_state(pr_number: int) -> dict[str, str | bool | None]:
return {}
+PR_LIST_LIMIT = 1000
+
+
+def list_prs_with_head_prefix(prefix: str) -> list[dict[str, object]]:
+ """Every PR, in any state, opened from this repository's branches under ``prefix``."""
+ raw = _run_gh(
+ "pr",
+ "list",
+ # Narrow on the server, so the limit applies to the stack's PRs only.
+ "--search",
+ f"head:{prefix}",
+ "--state",
+ "all",
+ "--limit",
+ str(PR_LIST_LIMIT),
+ "--json",
+ "number,url,state,headRefName,baseRefName,title,body,isCrossRepository",
+ )
+ try:
+ prs = json.loads(raw or "[]")
+ except json.JSONDecodeError as exc:
+ raise GitOperationError(f"gh pr list returned invalid JSON: {exc}") from exc
+ # A fork's branch of the same name is not one of the stack's branches.
+ return [
+ pr
+ for pr in prs
+ if str(pr.get("headRefName", "")).startswith(prefix) and not pr.get("isCrossRepository")
+ ]
+
+
def merge_pr(pr_number: int, *, auto: bool = False) -> None:
args = ["pr", "merge", str(pr_number), "--merge", "--delete-branch"]
if auto:
@@ -79,15 +153,30 @@ def close_pr(pr_number: int) -> None:
logger.info(logs.PR_CLOSED.format(number=pr_number))
-def link_stack(pr_numbers: list[int]) -> None:
+def link_stack(pr_numbers: Sequence[int | str], base: str) -> None:
+ """Link PRs (numbers, or branch names to open PRs for) into a stack, bottom first."""
+ # Without --base, gh stack link roots the stack on the repository default
+ # branch and retargets the bottom PR there.
try:
- _run_gh("stack", "link", *[str(n) for n in pr_numbers])
+ _run_gh("stack", "link", "--base", base, *[str(n) for n in pr_numbers])
except GitOperationError as exc:
- logger.warning(logs.STACK_LINK_FAILED.format(prs=pr_numbers, detail=exc))
- return
+ raise GitOperationError(ErrorMsg.STACK_LINK_FAILED(prs=pr_numbers, detail=exc)) from exc
logger.info(logs.STACK_LINKED.format(prs=pr_numbers))
+def _origin_is(full_name: str) -> bool:
+ """Whether the ``origin`` remote points at the GitHub repository ``full_name``."""
+ from .branches import run_git
+
+ try:
+ url = run_git("remote", "get-url", "origin")
+ except GitOperationError:
+ return False
+ path = url.strip().removesuffix("/").removesuffix(".git")
+ owner_repo = "/".join(re.split(r"[/:]", path)[-2:])
+ return owner_repo.lower() == full_name.lower()
+
+
def fetch_fork_pr(pr_number: int) -> ForkPRInfo:
from .branches import run_git
@@ -96,30 +185,51 @@ def fetch_fork_pr(pr_number: int) -> ForkPRInfo:
except GitOperationError as exc:
raise GitOperationError(ErrorMsg.PR_NOT_FOUND(number=pr_number)) from exc
- pr_data: dict[str, object] = json.loads(raw)
- head = pr_data["head"]
- base = pr_data["base"]
+ try:
+ pr_data = json.loads(raw)
+ head = pr_data["head"]
+ base = pr_data["base"]
+ except (json.JSONDecodeError, KeyError, TypeError) as exc:
+ raise GitOperationError(
+ ErrorMsg.PR_RESPONSE_INVALID(number=pr_number, detail=str(exc))
+ ) from exc
if not isinstance(head, dict) or not isinstance(base, dict):
raise GitOperationError(ErrorMsg.PR_NOT_FOUND(number=pr_number))
head_repo = head.get("repo")
- if not isinstance(head_repo, dict) or not head_repo.get("fork"):
+ if not isinstance(head_repo, dict):
+ # head.repo is null when the fork was deleted
raise GitOperationError(ErrorMsg.PR_NOT_FOUND(number=pr_number))
-
clone_url = str(head_repo["clone_url"])
head_ref = str(head["ref"])
base_ref = str(base["ref"])
fork_full_name = str(head_repo["full_name"])
local_ref = f"{PR_REF_PREFIX}{pr_number}"
- logger.info(logs.FETCHING_FORK_PR.format(number=pr_number, fork=fork_full_name))
+ # GitHub keeps every PR's head at refs/pull/N/head of the base repository,
+ # whether the branch lives there or in a fork, so fetching that ref from
+ # the repository gh resolved never picks up a same-named branch elsewhere.
+ base_repo = base.get("repo")
+ base_full_name = str(base_repo.get("full_name", "")) if isinstance(base_repo, dict) else ""
+ pull_ref = f"+refs/pull/{pr_number}/head:{local_ref}"
+ if base_full_name and _origin_is(base_full_name):
+ source, refspec = "origin", pull_ref
+ elif isinstance(base_repo, dict) and base_repo.get("clone_url"):
+ source, refspec = str(base_repo["clone_url"]), pull_ref
+ else:
+ # No base repository in the response: fall back to the head branch itself.
+ source, refspec = clone_url, f"+refs/heads/{head_ref}:{local_ref}"
+ if fork_full_name != base_full_name:
+ logger.info(logs.FETCHING_FORK_PR.format(number=pr_number, fork=fork_full_name))
+ else:
+ logger.info(logs.FETCHING_SAME_REPO_PR.format(number=pr_number, branch=head_ref))
try:
- run_git("fetch", clone_url, f"{head_ref}:{local_ref}")
+ run_git("fetch", source, refspec)
except GitOperationError as exc:
raise GitOperationError(
- ErrorMsg.PR_FETCH_FAILED(number=pr_number, detail=str(exc))
+ ErrorMsg.PR_FETCH_FAILED(number=pr_number, source=source, detail=str(exc))
) from exc
author = run_git("log", "-1", "--format=%aN <%aE>", local_ref)
@@ -172,3 +282,38 @@ def fetch_fork_branch(user: str, branch: str) -> ForkPRInfo:
author=author,
fork_full_name=fork_full_name,
)
+
+
+def default_branch() -> str:
+ """The repository's default branch on GitHub."""
+ return _run_gh("repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name")
+
+
+def branch_has_merged(branch: str) -> bool:
+ """True when a PR from ``branch`` has been merged (the branch itself landed)."""
+ raw = _run_gh(
+ "pr", "list", "--head", branch, "--state", "merged", "--json", "number", "--limit", "1"
+ )
+ try:
+ return bool(json.loads(raw or "[]"))
+ except json.JSONDecodeError:
+ return False
+
+
+def stack_numbers_for(pr_numbers: Sequence[int]) -> set[int]:
+ """Native stacks that contain any of ``pr_numbers``."""
+ stacks: set[int] = set()
+ for number in pr_numbers:
+ raw = _run_gh(
+ "api", f"repos/{{owner}}/{{repo}}/stacks?pull_request={number}", "--jq", ".[].number"
+ )
+ stacks.update(int(n) for n in raw.split())
+ return stacks
+
+
+def unstack(stack_number: int) -> None:
+ _run_gh("stack", "unstack", str(stack_number))
+
+
+def set_pr_base(pr_number: int, base: str) -> None:
+ _run_gh("pr", "edit", str(pr_number), "--base", base)
diff --git a/pr_split/graph.py b/pr_split/graph.py
index 447d863..df8d25c 100644
--- a/pr_split/graph.py
+++ b/pr_split/graph.py
@@ -10,11 +10,24 @@
class PlanDAG:
def __init__(self, groups: list[Group]) -> None:
- self._groups: dict[str, Group] = {g.id: g for g in groups}
+ self._groups: dict[str, Group] = {}
+ for g in groups:
+ # Silently collapsing duplicates would drop a group and later
+ # surface as a bogus merge conflict between a group and itself.
+ if g.id in self._groups:
+ raise PlanValidationError(ErrorMsg.DUPLICATE_GROUP_ID(group=g.id))
+ self._groups[g.id] = g
self._children: dict[str, list[str]] = {g.id: [] for g in groups}
- self._parents: dict[str, list[str]] = {g.id: list(g.depends_on) for g in groups}
+ # A dependency listed twice ("pr-1", "pr-1") is one edge: keeping the
+ # duplicate would make the group look like a merge node, so a stacked
+ # child would be built from the merge base and left out of the stack.
+ self._parents: dict[str, list[str]] = {
+ g.id: list(dict.fromkeys(g.depends_on)) for g in groups
+ }
for g in groups:
- for dep in g.depends_on:
+ for dep in self._parents[g.id]:
+ if dep not in self._groups:
+ raise PlanValidationError(ErrorMsg.UNKNOWN_DEPENDENCY(group=g.id, dep=dep))
self._children[dep].append(g.id)
def _build_sorter(self) -> TopologicalSorter[str]:
diff --git a/pr_split/logs.py b/pr_split/logs.py
index ec302ce..a338046 100644
--- a/pr_split/logs.py
+++ b/pr_split/logs.py
@@ -1,5 +1,12 @@
EXTRACTING_DIFF = "Extracting diff between {base} and {dev}"
DIFF_STATS = "Diff: {files} files, +{added}/-{removed} lines ({loc} LOC)"
+NEW_FILE_SPLIT = (
+ "New file '{file}' ({loc} lines) is over --max-loc; split it into {pieces} pieces"
+ " at top-level boundaries, each stacked on the one before"
+)
+NEW_FILE_PIECE_LINKED = (
+ "Group '{child}' holds a later piece of new file '{file}'; it now depends on '{parent}'"
+)
SENDING_TO_LLM = "Sending diff to LLM for analysis ({model})"
LLM_RESPONSE_RECEIVED = "Received split plan with {count} groups"
VALIDATING_PLAN = "Validating split plan"
@@ -12,20 +19,71 @@
MATERIALIZING_FILES = "Materializing {count} file(s) for group '{group}'"
COMMITTING_GROUP = "Committing group '{group}': {title}"
PUSHING_BRANCH = "Pushing {branch} to origin"
+MOVE_REVERTED = "Removed {file} hunk {index} from {branch}"
+MOVE_APPLIED = "Applied {file} hunk {index} to {branch}"
+RECOVER_FETCH_FAILED = "Could not fetch the stack's branches from {remote} ({detail})"
+RECOVERED_PLAN = (
+ "Rebuilt the plan from {branches} branch(es) and {prs} PR(s) and saved it to {path}"
+)
+LLM_UNAVAILABLE_USING_GRAPH = (
+ "LLM planner unavailable ({reason}); planning with the graph backend, which needs no model."
+ " Set the key, or PR_SPLIT_PROVIDER=local for a local model, to use the LLM planner"
+)
+REPAIR_UNKNOWN_FILE = (
+ "Plan repair: group '{group}' named '{file}', which is not in the diff; dropped"
+)
+REPAIR_UNKNOWN_HUNKS = (
+ "Plan repair: group '{group}' named hunks {indices} of '{file}', which do not exist; dropped"
+)
+REPAIR_DUPLICATE_HUNKS = (
+ "Plan repair: hunks {indices} of '{file}' were also in group '{owner}';"
+ " removed from group '{group}'"
+)
+REPAIR_UNKNOWN_DEPENDENCY = "Plan repair: group '{group}' depended on unknown ids {deps}; dropped"
+REPAIR_EMPTY_GROUP = "Plan repair: group '{group}' holds no hunks; dropped"
+PER_GROUP_STEP_RUNNING = "Running per-group step for {group}: {command}"
+RESTACKED_LAYER = "Rebased {branch} onto the current head of {parent}"
+RESTACK_FETCH_FAILED = "Could not fetch {branch} ({detail}); using the local branch"
+LAYER_BEHIND_PARENT = (
+ "{group} ({branch}) does not contain the current head of {parent};"
+ " run 'pr-split restack' to carry the parent's changes up"
+)
+BASE_FETCH_FAILED = (
+ "Could not fetch '{base}' from '{remote}' ({detail}); splitting against its last fetched state"
+)
+LOCAL_BASE_BEHIND = (
+ "Local '{base}' is {count} commit(s) behind '{upstream}'; splitting against '{upstream}',"
+ " which the sub-PRs target, so upstream commits are not split as branch work"
+)
+LOCAL_BASE_AHEAD = (
+ "Local '{base}' has {count} commit(s) not on '{upstream}'; the sub-PRs target '{upstream}',"
+ " so any of them on the dev branch are split as branch work. Push '{base}' first if that is"
+ " not intended"
+)
CREATING_PR = "Creating PR for group '{group}'"
PR_CREATED = "PR #{number} created: {url}"
SAVING_PLAN = "Saving plan to {path}"
PLAN_LOADED = "Loaded plan with {count} groups from {path}"
CLEANING_BRANCHES = "Cleaning up pr-split branches"
BRANCH_DELETED = "Deleted branch {branch}"
+BRANCH_ALREADY_GONE = "Branch {branch} was already deleted{where}"
PR_CLOSED = "Closed PR #{number}"
+PR_ALREADY_DONE = "PR #{number} is already {state}, nothing to close"
+ADOPTED_PR_KEPT = "PR #{number} was adopted, not opened by pr-split; leaving it open"
+ADOPTED_BRANCH_KEPT = "Branch {branch} was adopted, not created by pr-split; keeping it"
CLEAN_COMPLETE = "Cleanup complete: {branches} branches, {prs} PRs"
+CLEAN_INCOMPLETE = (
+ "Some PRs or branches could not be cleaned up; the plan file was kept"
+ " so 'pr-split clean' can be re-run"
+)
FETCHING_FORK_PR = "Fetching PR #{number} from fork {fork}"
+FETCHING_SAME_REPO_PR = "Fetching PR #{number} (same-repository branch {branch})"
FETCHING_FORK_BRANCH = "Fetching branch {branch} from fork {fork}"
AUTHOR_PRESERVED = "Preserving author: {author}"
COUNTING_TOKENS = "Counting input tokens ({model})"
TOKEN_COUNT = "Token count: {tokens} (limit: {limit})"
PLANNING_WITH_BACKEND = "Planning split with backend '{backend}'"
+DIFF_WITHIN_MAX_LOC = "Diff is {loc} LOC, within --max-loc {max_loc}; keeping it as a single PR"
CHUNK_STRATEGY_SELECTED = "Using chunking strategy '{strategy}'"
DIFF_TOO_LARGE = (
"Diff exceeds context window ({tokens} tokens > {limit} limit), switching to chunked mode"
@@ -40,6 +98,11 @@
LLM_OUTPUT_TRUNCATED = (
"LLM output truncated (stop_reason: {stop_reason}), keys in partial output: {keys}"
)
+LOCAL_SERVER_OFF_MACHINE = (
+ "PR_SPLIT_LOCAL_BASE_URL points at '{host}', which is not this machine;"
+ " the diff will be sent to that host"
+)
+LLM_OUTPUT_INCOMPLETE = "LLM output incomplete (status: {status}, reason: {reason})"
CHUNK_RETRY = "Chunk {index}/{total} failed (attempt {attempt}), retrying: {error}"
INVALID_HUNK_INDEX = (
"Group '{group}': invalid hunk index {index} for {file} (max: {max}), skipping"
@@ -59,13 +122,43 @@
REFINEMENT_EXHAUSTED = (
"Refinement iteration limit reached ({iterations}), {remaining} violation(s) remain"
)
+REFINEMENT_SKIPPED_CHUNKED = (
+ "Skipping LOC refinement: the diff exceeded the context window and was planned in"
+ " chunks, and the refinement prompt embeds the full diff; {remaining} violation(s)"
+ " remain. Adjust --min-loc/--max-loc or use the interactive editor"
+)
+REFINEMENT_REJECTED = (
+ "Refinement iteration {iteration} produced an invalid plan ({reason}); "
+ "keeping the current plan, {remaining} violation(s) remain"
+)
+REFINEMENT_NO_IMPROVEMENT = (
+ "Refinement iteration {iteration} did not reduce violations ({before} -> {after}); "
+ "keeping the current plan"
+)
STACK_LINKED = "Linked stack for PRs {prs}"
-STACK_LINK_FAILED = "Could not link stack for PRs {prs}: {detail}"
MERGE_NODE_NOT_STACKED = (
- "Group '{group}' depends on multiple groups; native stacks are linear, so its"
+ "Group '{group}' depends on multiple groups; a PR can target only one branch, so its"
" branch and PR target the base branch directly, carrying every ancestor's"
" changes until those PRs merge"
)
PR_SKIPPED_BASE_NOT_PUSHED = (
"Skipping PR for group '{group}': its base branch '{base}' was not pushed"
)
+CP_SAT_NOT_OPTIMAL = (
+ "CP-SAT stopped at the {timeout:g}s limit with a feasible but unproven-optimal plan "
+ "({units} units, {groups} groups); raise --cp-sat-timeout for a better partition"
+)
+PUSH_RETRY = "Push of {branch} failed (attempt {attempt}): {error}; retrying"
+BASE_ALREADY_MERGED = (
+ "The split's base branch {base} has already merged; run 'pr-split retarget' to move"
+ " the split onto the new base before merging"
+)
+SYMBOL_EDGE_SKIPPED = (
+ "Group '{user}' uses {names} from group '{definer}', but depending on it would"
+ " create a cycle; the edge was not added"
+)
+SYMBOL_ORDER_VIOLATION = (
+ "Group '{user}' uses {names} defined in group '{definer}', which it does not build on;"
+ " its sub-PR will not compile or import on its own"
+)
+ADOPTED_REMOTE_BRANCH = "Created local branch {branch} from {remote_ref}"
diff --git a/pr_split/per_group.py b/pr_split/per_group.py
new file mode 100644
index 0000000..3b5f999
--- /dev/null
+++ b/pr_split/per_group.py
@@ -0,0 +1,107 @@
+"""A repository-defined step run on every sub-PR branch before it is pushed.
+
+Some repositories gate each PR on files unique to it, e.g. a version bump
+above the PR's base and a release-notes file for that version. With
+``--stack`` a layer's base is its parent's branch, so every layer needs its
+own. The step runs in the layer's worktree after the layer's own commit; any
+changes it leaves are committed to that layer, and children are cut from the
+layer after it ran, so each one sees its parent's result.
+
+Configured in ``.pr-split.toml`` at the repository root::
+
+ [per_group]
+ run = "scripts/bump-version.sh"
+ commit_message = "chore: release notes for {title}"
+
+or with ``PR_SPLIT_PER_GROUP_RUN`` / ``PR_SPLIT_PER_GROUP_COMMIT_MESSAGE``.
+The group's details reach the command as environment variables, never
+spliced into the command line, so a title cannot inject shell syntax.
+"""
+
+from __future__ import annotations
+
+import os
+import subprocess
+import tomllib
+from dataclasses import dataclass
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from loguru import logger
+
+from . import logs
+from .exceptions import ErrorMsg, PRSplitError
+from .git_ops.branches import commit_files_in_dir, run_git, run_git_in_dir
+
+if TYPE_CHECKING:
+ from .schemas import Group
+
+CONFIG_FILE = ".pr-split.toml"
+DEFAULT_COMMIT_MESSAGE = "chore: per-group step for {title}"
+_OUTPUT_TAIL = 800
+
+
+@dataclass(frozen=True)
+class PerGroupStep:
+ run: str
+ commit_message: str = DEFAULT_COMMIT_MESSAGE
+
+
+def load_per_group_step() -> PerGroupStep | None:
+ """The configured step, environment first, then .pr-split.toml; None if unset."""
+ run = os.environ.get("PR_SPLIT_PER_GROUP_RUN", "")
+ message = os.environ.get("PR_SPLIT_PER_GROUP_COMMIT_MESSAGE", "")
+ config = Path(run_git("rev-parse", "--show-toplevel")) / CONFIG_FILE
+ if config.is_file():
+ try:
+ section = tomllib.loads(config.read_text(encoding="utf-8")).get("per_group", {})
+ except tomllib.TOMLDecodeError as exc:
+ raise PRSplitError(ErrorMsg.CONFIG_INVALID(path=config, detail=exc)) from exc
+ if not isinstance(section, dict):
+ raise PRSplitError(ErrorMsg.CONFIG_INVALID(path=config, detail="[per_group]"))
+ run = run or str(section.get("run", ""))
+ message = message or str(section.get("commit_message", ""))
+ if not run.strip():
+ return None
+ return PerGroupStep(run=run, commit_message=message or DEFAULT_COMMIT_MESSAGE)
+
+
+def run_per_group_step(
+ step: PerGroupStep,
+ worktree: str,
+ group: Group,
+ *,
+ index: int,
+ pr_base: str,
+ parent_ref: str,
+ author: str | None = None,
+) -> str | None:
+ """Run the step in the layer's worktree; return its commit, or None if it changed nothing."""
+ logger.info(logs.PER_GROUP_STEP_RUNNING.format(group=group.id, command=step.run))
+ env = {
+ **os.environ,
+ "PR_SPLIT_GROUP_ID": group.id,
+ "PR_SPLIT_GROUP_TITLE": group.title,
+ "PR_SPLIT_GROUP_INDEX": str(index),
+ # The branch the sub-PR is opened against, and the commit the layer
+ # was cut from (its parent's head, or the merge base for a root).
+ "PR_SPLIT_PR_BASE": pr_base,
+ "PR_SPLIT_PARENT_REF": parent_ref,
+ }
+ result = subprocess.run(
+ step.run, shell=True, cwd=worktree, env=env, capture_output=True, text=True
+ )
+ if result.returncode != 0:
+ output = (result.stderr or result.stdout).strip()[-_OUTPUT_TAIL:]
+ raise PRSplitError(
+ ErrorMsg.PER_GROUP_STEP_FAILED(group=group.id, code=result.returncode, output=output)
+ )
+ if not run_git_in_dir(worktree, "status", "--porcelain"):
+ return None
+ try:
+ message = step.commit_message.format(id=group.id, title=group.title, index=index)
+ except (KeyError, IndexError, ValueError) as exc:
+ raise PRSplitError(
+ ErrorMsg.CONFIG_INVALID(path=CONFIG_FILE, detail=f"commit_message: {exc!r}")
+ ) from exc
+ return commit_files_in_dir(worktree, ["."], message, author=author)
diff --git a/pr_split/plan_store.py b/pr_split/plan_store.py
index f7bfd99..89fb422 100644
--- a/pr_split/plan_store.py
+++ b/pr_split/plan_store.py
@@ -1,29 +1,181 @@
+import json
+import os
from pathlib import Path
+from urllib.parse import quote, unquote
from loguru import logger
+from pydantic import ValidationError
from . import logs
from .constants import PLAN_DIR, PLAN_FILE
-from .exceptions import ErrorMsg, PRSplitError
+from .exceptions import ErrorMsg, GitOperationError, PRSplitError
+from .git_ops.branches import run_git
from .schemas import PlanFile
+def repo_root() -> Path:
+ """The working tree's top level, or the cwd outside a git repository.
+
+ Every git call in the tool is cwd-independent, so the plan and template
+ must be too: a `split --dry-run` from `src/` and an `execute` from the
+ repository root have to see the same `.pr-split/plan.json`.
+ """
+ try:
+ return Path(run_git("rev-parse", "--show-toplevel"))
+ except GitOperationError:
+ return Path.cwd()
+
+
+# The plan the current command works on, keyed by its plan file's stem. Set
+# by `split` (its own branch) or the global --branch option; otherwise the only
+# saved plan is used. None with several plans saved means "ambiguous".
+_selected: str | None = None
+
+
+def plan_key(dev_branch: str) -> str:
+ """The plan file stem for a dev branch: the name percent-encoded, so it is
+ reversible and `feature/x` and `feature-x` do not share a file."""
+ return quote(dev_branch, safe="")
+
+
+def select_plan(dev_branch: str | None) -> None:
+ global _selected
+ _selected = plan_key(dev_branch) if dev_branch else None
+
+
+def plan_dir() -> Path:
+ return repo_root() / PLAN_DIR
+
+
+def _plans_dir() -> Path:
+ return plan_dir() / "plans"
+
+
+def _saved_plan_keys() -> list[str]:
+ folder = _plans_dir()
+ return sorted(p.stem for p in folder.glob("*.json")) if folder.is_dir() else []
+
+
+def saved_plan_branches() -> list[str]:
+ """The dev branches that have a saved plan."""
+ return [unquote(key) for key in _saved_plan_keys()]
+
+
+def _migrate_legacy_plan() -> bool:
+ """Move a pre-per-branch .pr-split/plan.json to plans/.json.
+
+ A plan that cannot be read stays where it is, so loading it still
+ reports the problem instead of silently ignoring it. Returns True only
+ when such an unreadable legacy plan is left behind.
+ """
+ legacy = repo_root() / PLAN_FILE
+ if not legacy.exists():
+ return False
+ try:
+ plan = json.loads(legacy.read_text()).get("plan", {})
+ branch = plan.get("dev_branch_arg") or plan["dev_branch"]
+ except (OSError, ValueError, KeyError, TypeError, AttributeError):
+ return True
+ target = _plans_dir() / f"{plan_key(str(branch))}.json"
+ if target.exists():
+ # That branch already has a per-branch plan; the legacy file is left
+ # alone and never used in its place.
+ return False
+ target.parent.mkdir(parents=True, exist_ok=True)
+ _exclude_plan_dir()
+ os.replace(legacy, target)
+ return False
+
+
+def plan_path() -> Path:
+ """Where the selected plan lives: per branch, or the legacy single file."""
+ legacy_unreadable = _migrate_legacy_plan()
+ legacy = repo_root() / PLAN_FILE
+ if _selected is not None:
+ selected = _plans_dir() / f"{_selected}.json"
+ # An unreadable legacy plan could not be migrated; keep using it so
+ # its error surfaces rather than being bypassed. A readable one belongs
+ # to one branch and must not stand in for another's.
+ return legacy if legacy_unreadable and not selected.exists() else selected
+ keys = _saved_plan_keys()
+ if len(keys) == 1:
+ return _plans_dir() / f"{keys[0]}.json"
+ if len(keys) > 1:
+ raise PRSplitError(ErrorMsg.PLAN_AMBIGUOUS(branches=", ".join(saved_plan_branches())))
+ return legacy
+
+
+def _exclude_plan_dir() -> None:
+ """Keep .pr-split/ out of `git status` and `git add -A` in the target repo.
+
+ The plan holds the whole raw diff; listing it in info/exclude (shared by
+ every worktree) stops it from being committed by accident.
+ """
+ try:
+ common = Path(run_git("rev-parse", "--git-common-dir"))
+ except GitOperationError:
+ return
+ if not common.is_absolute():
+ # rev-parse prints a relative path relative to the cwd, not the top level.
+ common = (Path.cwd() / common).resolve()
+ exclude = common / "info" / "exclude"
+ entry = f"/{PLAN_DIR}/"
+ try:
+ existing = exclude.read_text().splitlines() if exclude.exists() else []
+ if entry in existing:
+ return
+ exclude.parent.mkdir(parents=True, exist_ok=True)
+ with exclude.open("a") as fh:
+ prefix = "" if not existing or existing[-1] == "" else "\n"
+ fh.write(f"{prefix}{entry}\n")
+ except OSError:
+ return
+
+
def save_plan(plan_file: PlanFile) -> None:
- path = Path(PLAN_DIR)
- path.mkdir(parents=True, exist_ok=True)
- plan_path = Path(PLAN_FILE)
- plan_path.write_text(plan_file.model_dump_json(indent=2))
- logger.info(logs.SAVING_PLAN.format(path=plan_path))
+ """Atomically replace the plan file.
+
+ The most important save happens right after PRs are created; a crash or
+ full disk mid-write must not leave truncated JSON that destroys the only
+ record of those PRs and branches. The plan is therefore written to a
+ temporary file in the same directory and renamed over the target, which
+ is atomic on POSIX and Windows alike.
+ """
+ if _selected is None:
+ # No branch chosen for this command: file the plan under its own branch.
+ branch = plan_file.plan.dev_branch_arg or plan_file.plan.dev_branch
+ target = _plans_dir() / f"{plan_key(branch)}.json"
+ else:
+ target = plan_path()
+ target.parent.mkdir(parents=True, exist_ok=True)
+ _exclude_plan_dir()
+ tmp = target.with_name(target.name + ".tmp")
+ # The raw diff may carry surrogate-escaped bytes from non-UTF-8 files;
+ # json.dumps escapes those as \udcXX and loads them back losslessly,
+ # which pydantic's own JSON writer refuses to do.
+ payload = json.dumps(plan_file.model_dump(mode="json"), indent=2, ensure_ascii=True)
+ try:
+ tmp.write_text(payload, encoding="utf-8")
+ os.replace(tmp, target)
+ finally:
+ tmp.unlink(missing_ok=True)
+ logger.info(logs.SAVING_PLAN.format(path=target))
def load_plan() -> PlanFile:
- plan_path = Path(PLAN_FILE)
- if not plan_path.exists():
+ target = plan_path()
+ if not target.exists():
raise PRSplitError(ErrorMsg.NO_PLAN())
- plan_file = PlanFile.model_validate_json(plan_path.read_text())
- logger.info(logs.PLAN_LOADED.format(count=len(plan_file.plan.groups), path=plan_path))
+ try:
+ plan_file = PlanFile.model_validate(json.loads(target.read_text(encoding="utf-8")))
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValidationError) as exc:
+ raise PRSplitError(ErrorMsg.PLAN_LOAD_FAILED(path=target, detail=exc)) from exc
+ logger.info(logs.PLAN_LOADED.format(count=len(plan_file.plan.groups), path=target))
return plan_file
def plan_exists() -> bool:
- return Path(PLAN_FILE).exists()
+ try:
+ return plan_path().exists()
+ except PRSplitError:
+ return True # several plans: let load_plan report which to pick
diff --git a/pr_split/planner/__init__.py b/pr_split/planner/__init__.py
index c63ffde..85db7e5 100644
--- a/pr_split/planner/__init__.py
+++ b/pr_split/planner/__init__.py
@@ -1,5 +1,11 @@
from .client import plan_split
from .scoring import score_plan
-from .validator import validate_coverage, validate_plan
+from .validator import validate_coverage, validate_no_binary_files, validate_plan
-__all__ = ["plan_split", "score_plan", "validate_coverage", "validate_plan"]
+__all__ = [
+ "plan_split",
+ "score_plan",
+ "validate_coverage",
+ "validate_no_binary_files",
+ "validate_plan",
+]
diff --git a/pr_split/planner/chunker.py b/pr_split/planner/chunker.py
index 96e3d52..734f42a 100644
--- a/pr_split/planner/chunker.py
+++ b/pr_split/planner/chunker.py
@@ -7,7 +7,7 @@
from .. import logs
from ..constants import AssignmentType, ChunkStrategy
from ..diff_ops import ParsedDiff
-from ..exceptions import ErrorMsg
+from ..exceptions import ErrorMsg, PlanValidationError, PRSplitError
from ..schemas import Group, GroupAssignment
from ..types_defs import DiffStats, FileSummary, HunkRef
@@ -37,7 +37,7 @@ def chunk_hunks_greedy(hunk_sequence: list[HunkRef], token_budget: int) -> list[
for href in hunk_sequence:
if href.token_estimate > token_budget:
- raise ValueError(
+ raise PRSplitError(
ErrorMsg.HUNK_TOO_LARGE(
file=href.file_path,
index=href.hunk_index,
@@ -80,7 +80,7 @@ def chunk_hunks_dynamic_programming(
for href in hunk_sequence:
if href.token_estimate > token_budget:
- raise ValueError(
+ raise PRSplitError(
ErrorMsg.HUNK_TOO_LARGE(
file=href.file_path,
index=href.hunk_index,
@@ -121,7 +121,7 @@ def chunk_hunks_dynamic_programming(
while cursor > 0:
cut = previous_cut[cursor]
if cut < 0:
- raise ValueError("failed to reconstruct dynamic-programming chunk plan")
+ raise PRSplitError("failed to reconstruct dynamic-programming chunk plan")
chunks.append(hunk_sequence[cut:cursor])
cursor = cut
@@ -140,7 +140,7 @@ def chunk_hunks(
case ChunkStrategy.DYNAMIC_PROGRAMMING:
return chunk_hunks_dynamic_programming(hunk_sequence, token_budget)
case _:
- raise ValueError(f"Unknown chunk strategy '{strategy}'")
+ raise PRSplitError(f"Unknown chunk strategy '{strategy}'")
def build_chunk_diff_from_hunks(parsed_diff: ParsedDiff, hunk_refs: list[HunkRef]) -> str:
@@ -204,7 +204,7 @@ def recompute_estimated_loc(groups: list[Group], parsed_diff: ParsedDiff) -> Non
removed = 0
for assignment in group.assignments:
max_idx = file_hunk_counts.get(assignment.file_path, 0)
- for idx in assignment.hunk_indices:
+ for idx in assignment.covered_indices(max_idx):
if idx >= max_idx:
logger.warning(
logs.INVALID_HUNK_INDEX.format(
@@ -224,14 +224,21 @@ def recompute_estimated_loc(groups: list[Group], parsed_diff: ParsedDiff) -> Non
def assign_uncovered_hunks(groups: list[Group], parsed_diff: ParsedDiff) -> int:
+ hunk_counts = {pf.path: len(pf) for pf in parsed_diff.patch_set}
assigned = {
- (a.file_path, idx) for g in groups for a in g.assignments for idx in a.hunk_indices
+ (a.file_path, idx)
+ for g in groups
+ for a in g.assignments
+ for idx in a.covered_indices(hunk_counts.get(a.file_path, 0))
}
all_hunks = {(pf.path, i) for pf in parsed_diff.patch_set for i in range(len(pf))}
unassigned = sorted(all_hunks - assigned)
if not unassigned:
return 0
+ if not groups:
+ file_path, idx = unassigned[0]
+ raise PlanValidationError(ErrorMsg.COVERAGE_GAP(file=file_path, index=idx))
file_groups: dict[str, Group] = {}
for group in groups:
diff --git a/pr_split/planner/client.py b/pr_split/planner/client.py
index 0c933c8..0288b4d 100644
--- a/pr_split/planner/client.py
+++ b/pr_split/planner/client.py
@@ -1,13 +1,17 @@
from __future__ import annotations
+import ipaddress
import json
+import subprocess
from typing import TypedDict
+from urllib.parse import urlsplit
import anthropic
import openai
import tiktoken
from anthropic.types.beta import BetaToolUseBlock
from loguru import logger
+from openai.types.chat import ChatCompletionFunctionToolParam
from .. import logs
from ..config import Settings
@@ -20,7 +24,8 @@
Provider,
)
from ..diff_ops import ParsedDiff
-from ..exceptions import ErrorMsg, LLMError, PRSplitError
+from ..exceptions import ErrorMsg, LLMError, PlanValidationError, PRSplitError
+from ..graph import PlanDAG
from ..schemas import Group, GroupAssignment
from .chunker import (
assign_uncovered_hunks,
@@ -41,8 +46,9 @@
build_system_prompt,
build_user_prompt,
)
+from .repair import repair_plan
from .scoring import score_plan
-from .validator import detect_loc_bound_violations
+from .validator import detect_loc_bound_violations, validate_coverage, validate_no_conflicts
_ANTHROPIC_TOOL_DEF = anthropic.types.ToolParam(
name=SPLIT_TOOL_NAME,
@@ -50,6 +56,15 @@
input_schema=SPLIT_TOOL_SCHEMA,
)
+_CHAT_TOOL_DEF = ChatCompletionFunctionToolParam(
+ type="function",
+ function={
+ "name": SPLIT_TOOL_NAME,
+ "description": "Propose a plan to split the diff into groups",
+ "parameters": SPLIT_TOOL_SCHEMA,
+ },
+)
+
_OPENAI_TOOL_DEF = {
"type": "function",
"name": SPLIT_TOOL_NAME,
@@ -90,12 +105,20 @@ def _extract_raw_output(block_input: dict[str, object]) -> list[_RawGroup]:
def _count_tokens_anthropic(system: str, user: str, *, settings: Settings) -> int:
client = anthropic.Anthropic(api_key=settings.api_key)
- response = client.messages.count_tokens(
- model=settings.model,
- system=system,
- messages=[{"role": "user", "content": user}],
- tools=[_ANTHROPIC_TOOL_DEF],
- )
+ # This is the first API call of a run, so a bad key, a network blip or a
+ # rate limit surfaces here; it must be an LLMError like every other
+ # provider failure rather than an SDK traceback.
+ try:
+ response = client.messages.count_tokens(
+ model=settings.model,
+ system=system,
+ messages=[{"role": "user", "content": user}],
+ tools=[_ANTHROPIC_TOOL_DEF],
+ )
+ except anthropic.AuthenticationError as exc:
+ raise LLMError(ErrorMsg.LLM_KEY_REJECTED(variable="ANTHROPIC_API_KEY")) from exc
+ except anthropic.APIError as exc:
+ raise LLMError(ErrorMsg.LLM_TOKEN_COUNT_FAILED(detail=str(exc))) from exc
return response.input_tokens
@@ -107,12 +130,28 @@ def _count_tokens_openai(texts: list[str], *, model: str) -> int:
return sum(len(enc.encode(t)) for t in texts)
+def _utf8_safe(text: str) -> str:
+ """Replace surrogate-escaped bytes so the text can be sent as JSON.
+
+ Diff text keeps undecodable bytes as surrogates so files round-trip on
+ disk; the HTTP clients encode request bodies as strict UTF-8, so the
+ prompt copy gets U+FFFD instead.
+ """
+ return text.encode("utf-8", errors="surrogateescape").decode("utf-8", errors="replace")
+
+
def _count_tokens(system: str, user: str, *, settings: Settings) -> int:
+ system, user = _utf8_safe(system), _utf8_safe(user)
match settings.provider:
case Provider.ANTHROPIC:
return _count_tokens_anthropic(system, user, settings=settings)
case Provider.OPENAI:
return _count_tokens_openai([system, user], model=settings.model)
+ case Provider.CLAUDE_CLI | Provider.LOCAL:
+ # Neither the CLI nor a local server has a count endpoint, and
+ # tokenizers differ per model; this estimate only decides whether
+ # to chunk.
+ return _count_tokens_openai([system, user], model="")
def _call_anthropic(system: str, user: str, *, settings: Settings) -> RawToolOutput:
@@ -131,6 +170,11 @@ def _call_anthropic(system: str, user: str, *, settings: Settings) -> RawToolOut
except anthropic.APIError as exc:
raise LLMError(ErrorMsg.LLM_PARSE_ERROR(detail=str(exc))) from exc
if response.stop_reason != "tool_use":
+ # A max_tokens stop means the tool input was cut off mid-plan.
+ # Accepting it would silently drop groups and let
+ # assign_uncovered_hunks paper over the gap, so fail and let the
+ # chunk retry loop / caller handle it. Other stop reasons without a
+ # tool block are reported below as "no tool_use block".
stop_reason = getattr(response, "stop_reason", "unknown")
keys: list[str] = []
for block in getattr(response, "content", []):
@@ -138,6 +182,8 @@ def _call_anthropic(system: str, user: str, *, settings: Settings) -> RawToolOut
keys = list(block.input.keys())
break
logger.warning(logs.LLM_OUTPUT_TRUNCATED.format(stop_reason=stop_reason, keys=keys))
+ if stop_reason == "max_tokens":
+ raise LLMError(ErrorMsg.LLM_OUTPUT_TRUNCATED(detail=f"stop_reason={stop_reason}"))
for block in response.content:
if isinstance(block, BetaToolUseBlock) and block.name == SPLIT_TOOL_NAME:
return RawToolOutput(groups=_extract_raw_output(block.input))
@@ -156,6 +202,12 @@ def _call_openai(system: str, user: str, *, settings: Settings) -> RawToolOutput
)
except openai.APIError as exc:
raise LLMError(ErrorMsg.LLM_PARSE_ERROR(detail=str(exc))) from exc
+ status = getattr(response, "status", None)
+ if status == "incomplete":
+ details = getattr(response, "incomplete_details", None)
+ reason = getattr(details, "reason", None) or "unknown"
+ logger.warning(logs.LLM_OUTPUT_INCOMPLETE.format(status=status, reason=reason))
+ raise LLMError(ErrorMsg.LLM_OUTPUT_TRUNCATED(detail=f"status={status}, reason={reason}"))
for item in response.output:
if item.type == "function_call" and item.name == SPLIT_TOOL_NAME:
try:
@@ -168,12 +220,174 @@ def _call_openai(system: str, user: str, *, settings: Settings) -> RawToolOutput
raise LLMError(ErrorMsg.LLM_PARSE_ERROR(detail="no function_call in response output"))
+_CLAUDE_CLI_TIMEOUT_SECONDS = 1800
+
+
+def _call_claude_cli(system: str, user: str, *, settings: Settings) -> RawToolOutput:
+ """Plan through `claude -p`, which uses the CLI's login instead of an API key.
+
+ The schema-validated result comes back under ``structured_output``; the
+ diff goes in on stdin because it can exceed the argument-length limit.
+ """
+ cmd = [
+ "claude",
+ "-p",
+ "--output-format",
+ "json",
+ "--json-schema",
+ json.dumps(SPLIT_TOOL_SCHEMA),
+ "--system-prompt",
+ system,
+ "--no-session-persistence",
+ "--permission-mode",
+ "dontAsk",
+ "--allowedTools",
+ "",
+ ]
+ if settings.model:
+ cmd += ["--model", settings.model]
+ try:
+ result = subprocess.run(
+ cmd,
+ input=user,
+ capture_output=True,
+ text=True,
+ timeout=_CLAUDE_CLI_TIMEOUT_SECONDS,
+ )
+ except FileNotFoundError as exc:
+ raise LLMError(ErrorMsg.CLAUDE_CLI_NOT_FOUND()) from exc
+ except subprocess.TimeoutExpired as exc:
+ raise LLMError(
+ ErrorMsg.LLM_PARSE_ERROR(detail=f"claude -p timed out after {exc.timeout}s")
+ ) from exc
+ if result.returncode != 0:
+ detail = (result.stderr or result.stdout).strip()[:500]
+ raise LLMError(ErrorMsg.LLM_PARSE_ERROR(detail=f"claude -p failed: {detail}"))
+ try:
+ data = json.loads(result.stdout)
+ except json.JSONDecodeError as exc:
+ raise LLMError(ErrorMsg.LLM_PARSE_ERROR(detail=f"claude -p output: {exc}")) from exc
+ structured = data.get("structured_output") if isinstance(data, dict) else None
+ if not isinstance(structured, dict):
+ detail = str(data.get("result", ""))[:300] if isinstance(data, dict) else ""
+ raise LLMError(
+ ErrorMsg.LLM_PARSE_ERROR(detail=f"claude -p returned no structured output {detail}")
+ )
+ return RawToolOutput(groups=_extract_raw_output(structured))
+
+
+_LOCAL_TIMEOUT_SECONDS = 1800
+
+
+def _json_from_text(text: str) -> dict[str, object] | None:
+ """Pull the plan object out of a text reply, fenced or bare.
+
+ Small local models often answer with the tool arguments as plain JSON
+ instead of a tool call, even when the call is forced.
+ """
+ start, end = text.find("{"), text.rfind("}")
+ if start == -1 or end <= start:
+ return None
+ try:
+ parsed = json.loads(text[start : end + 1])
+ except json.JSONDecodeError:
+ return None
+ if not isinstance(parsed, dict):
+ return None
+ # Some models write the call itself: {"name": ..., "arguments": {...}}.
+ for key in ("arguments", "parameters"):
+ inner = parsed.get(key)
+ if "groups" not in parsed and isinstance(inner, dict):
+ return inner
+ return parsed
+
+
+_warned_remote_hosts: set[str] = set()
+
+
+def _is_loopback(host: str) -> bool:
+ if host == "localhost":
+ return True
+ try:
+ return ipaddress.ip_address(host).is_loopback
+ except ValueError:
+ return False
+
+
+def _warn_if_off_machine(base_url: str) -> None:
+ """The local provider promises the diff stays on this machine; say so when it will not."""
+ host = urlsplit(base_url).hostname or ""
+ if not _is_loopback(host) and host not in _warned_remote_hosts:
+ _warned_remote_hosts.add(host)
+ logger.warning(logs.LOCAL_SERVER_OFF_MACHINE.format(host=host or base_url))
+
+
+def _call_local(system: str, user: str, *, settings: Settings) -> RawToolOutput:
+ """Plan through an OpenAI-compatible chat-completions server (Ollama, llama.cpp, vLLM).
+
+ Temperature 0 keeps the plan repeatable for the same diff.
+ """
+ _warn_if_off_machine(settings.local_base_url)
+ client = openai.OpenAI(
+ base_url=settings.local_base_url,
+ api_key=settings.api_key,
+ timeout=_LOCAL_TIMEOUT_SECONDS,
+ )
+ try:
+ response = client.chat.completions.create(
+ model=settings.model,
+ messages=[
+ {"role": "system", "content": system},
+ {"role": "user", "content": user},
+ ],
+ tools=[_CHAT_TOOL_DEF],
+ tool_choice={"type": "function", "function": {"name": SPLIT_TOOL_NAME}},
+ temperature=0,
+ max_tokens=settings.max_output_tokens,
+ )
+ except openai.APIConnectionError as exc:
+ raise LLMError(
+ ErrorMsg.LOCAL_SERVER_UNREACHABLE(url=settings.local_base_url, detail=exc)
+ ) from exc
+ except openai.APIError as exc:
+ raise LLMError(ErrorMsg.LLM_PARSE_ERROR(detail=str(exc))) from exc
+ if not response.choices:
+ raise LLMError(ErrorMsg.LLM_PARSE_ERROR(detail="no choices in response"))
+ choice = response.choices[0]
+ if choice.finish_reason == "length":
+ logger.warning(
+ logs.LLM_OUTPUT_INCOMPLETE.format(status="incomplete", reason=choice.finish_reason)
+ )
+ raise LLMError(ErrorMsg.LLM_OUTPUT_TRUNCATED(detail="finish_reason=length"))
+ message = choice.message
+ for call in message.tool_calls or []:
+ function = getattr(call, "function", None)
+ if function is None or function.name != SPLIT_TOOL_NAME:
+ continue
+ try:
+ parsed = json.loads(function.arguments)
+ except json.JSONDecodeError as exc:
+ raise LLMError(
+ ErrorMsg.LLM_PARSE_ERROR(detail=f"failed to parse tool arguments: {exc}")
+ ) from exc
+ return RawToolOutput(groups=_extract_raw_output(parsed))
+ parsed_text = _json_from_text(message.content or "")
+ if parsed_text is not None:
+ return RawToolOutput(groups=_extract_raw_output(parsed_text))
+ raise LLMError(ErrorMsg.LLM_PARSE_ERROR(detail="no tool call or JSON plan in response"))
+
+
def _call_llm(system: str, user: str, *, settings: Settings) -> RawToolOutput:
+ system, user = _utf8_safe(system), _utf8_safe(user)
match settings.provider:
case Provider.ANTHROPIC:
return _call_anthropic(system, user, settings=settings)
case Provider.OPENAI:
return _call_openai(system, user, settings=settings)
+ case Provider.CLAUDE_CLI:
+ return _call_claude_cli(system, user, settings=settings)
+ case Provider.LOCAL:
+ return _call_local(system, user, settings=settings)
def _call_chunk_with_retry(
@@ -204,6 +418,16 @@ def _call_chunk_with_retry(
def _parse_groups(raw: RawToolOutput) -> list[Group]:
+ # Malformed shapes (missing keys, bad enum values, wrong types) must
+ # surface as LLMError so the chunk retry loop and the refinement
+ # fallback can handle them; pydantic's ValidationError is a ValueError.
+ try:
+ return _parse_groups_strict(raw)
+ except (KeyError, TypeError, ValueError) as exc:
+ raise LLMError(ErrorMsg.LLM_PARSE_ERROR(detail=f"{type(exc).__name__}: {exc}")) from exc
+
+
+def _parse_groups_strict(raw: RawToolOutput) -> list[Group]:
groups: list[Group] = []
for entry in raw["groups"]:
assignments = [
@@ -227,12 +451,38 @@ def _parse_groups(raw: RawToolOutput) -> list[Group]:
return groups
+def _merge_assignment(existing: GroupAssignment, incoming: GroupAssignment) -> GroupAssignment:
+ if (
+ existing.assignment_type is AssignmentType.WHOLE_FILE
+ or incoming.assignment_type is AssignmentType.WHOLE_FILE
+ ):
+ assignment_type = AssignmentType.WHOLE_FILE
+ else:
+ assignment_type = AssignmentType.PARTIAL_HUNKS
+ return GroupAssignment(
+ file_path=existing.file_path,
+ assignment_type=assignment_type,
+ hunk_indices=sorted(set(existing.hunk_indices) | set(incoming.hunk_indices)),
+ )
+
+
def _merge_chunk_groups(accumulated: list[Group], chunk_groups: list[Group]) -> list[Group]:
acc_map = {g.id: g for g in accumulated}
for cg in chunk_groups:
if cg.id in acc_map:
existing = acc_map[cg.id]
- existing.assignments.extend(cg.assignments)
+ # A later chunk may assign more hunks of a file this group already
+ # holds. Keep one assignment per path: materialization writes a
+ # file once per assignment, so duplicates would drop hunks.
+ by_path: dict[str, GroupAssignment] = {}
+ for assignment in [*existing.assignments, *cg.assignments]:
+ if assignment.file_path in by_path:
+ by_path[assignment.file_path] = _merge_assignment(
+ by_path[assignment.file_path], assignment
+ )
+ else:
+ by_path[assignment.file_path] = assignment
+ existing.assignments = list(by_path.values())
for dep in cg.depends_on:
if dep not in existing.depends_on:
existing.depends_on.append(dep)
@@ -249,7 +499,18 @@ def _plan_split_chunked(
) -> list[Group]:
overhead = _count_tokens(system, ".", settings=settings)
chunk_limit = int(settings.max_context_tokens * CHUNK_TARGET_RATIO)
- diff_budget = chunk_limit - overhead - MAX_OUTPUT_TOKENS
+ diff_budget = chunk_limit - overhead - settings.max_output_tokens
+ if diff_budget <= 0:
+ # Otherwise the chunker reports the first hunk as too large, which
+ # hides that the configured window leaves no room for any diff.
+ raise PRSplitError(
+ ErrorMsg.NO_DIFF_BUDGET(
+ budget=diff_budget,
+ context=settings.max_context_tokens,
+ output=settings.max_output_tokens,
+ overhead=overhead,
+ )
+ )
diff_chars = len(parsed_diff.raw_diff)
token_ratio = (full_token_count - overhead) / diff_chars if diff_chars > 0 else 0.25
@@ -315,8 +576,7 @@ def _plan_split_chunked(
auto_assigned = assign_uncovered_hunks(accumulated, parsed_diff)
if auto_assigned:
logger.warning(logs.UNCOVERED_HUNKS_FIXED.format(count=auto_assigned))
- recompute_estimated_loc(accumulated, parsed_diff)
- return accumulated
+ return repair_plan(accumulated, parsed_diff)
def _groups_to_raw_dicts(groups: list[Group]) -> list[dict[str, object]]:
@@ -369,6 +629,36 @@ def _refine_plan_with_llm(
raw = _call_llm(system=system, user=user, settings=settings)
refined = _parse_groups(raw)
recompute_estimated_loc(refined, parsed_diff)
+ # The incoming plan only had LOC warnings; never trade it for one
+ # that drops or duplicates hunks, has a broken dependency graph
+ # (duplicate ids, unknown parents, a cycle, overlapping independent
+ # groups), or that is no better.
+ try:
+ validate_coverage(refined, parsed_diff)
+ refined_dag = PlanDAG(refined)
+ refined_dag.validate_acyclic()
+ validate_no_conflicts(
+ refined, refined_dag, {pf.path: len(pf) for pf in parsed_diff.patch_set}
+ )
+ except PlanValidationError as exc:
+ logger.warning(
+ logs.REFINEMENT_REJECTED.format(
+ iteration=iteration, reason=exc, remaining=len(violations)
+ )
+ )
+ return groups
+ refined_violations = detect_loc_bound_violations(
+ refined, settings.max_loc, settings.min_loc
+ )
+ if len(refined_violations) >= len(violations):
+ logger.warning(
+ logs.REFINEMENT_NO_IMPROVEMENT.format(
+ iteration=iteration,
+ before=len(violations),
+ after=len(refined_violations),
+ )
+ )
+ return groups
groups = refined
except LLMError:
logger.warning(
@@ -400,35 +690,69 @@ def _plan_split_with_llm(
logger.info(logs.COUNTING_TOKENS.format(model=settings.model))
token_count = _count_tokens(system, user, settings=settings)
- effective_limit = settings.max_context_tokens - MAX_OUTPUT_TOKENS
+ effective_limit = settings.max_context_tokens - settings.max_output_tokens
logger.info(logs.TOKEN_COUNT.format(tokens=token_count, limit=effective_limit))
if token_count > effective_limit:
logger.warning(logs.DIFF_TOO_LARGE.format(tokens=token_count, limit=effective_limit))
groups = _plan_split_chunked(parsed_diff, settings, system, token_count)
logger.info(logs.LLM_RESPONSE_RECEIVED.format(count=len(groups)))
- return _refine_plan_with_llm(groups, parsed_diff, settings, system)
+ # The refinement prompt embeds the full labeled diff, which is exactly
+ # what did not fit; sending it would fail and be swallowed as an
+ # exhausted refinement. Say so instead of wasting the call.
+ if settings.max_refinement_iterations > 0:
+ violations = detect_loc_bound_violations(groups, settings.max_loc, settings.min_loc)
+ if violations:
+ logger.warning(logs.REFINEMENT_SKIPPED_CHUNKED.format(remaining=len(violations)))
+ return groups
logger.info(logs.SENDING_TO_LLM.format(model=settings.model))
raw = _call_llm(system=system, user=user, settings=settings)
- groups = _parse_groups(raw)
- recompute_estimated_loc(groups, parsed_diff)
+ groups = repair_plan(_parse_groups(raw), parsed_diff)
logger.info(logs.LLM_RESPONSE_RECEIVED.format(count=len(groups)))
return _refine_plan_with_llm(groups, parsed_diff, settings, system)
+def _single_group_plan(parsed_diff: ParsedDiff) -> list[Group]:
+ """One group holding every hunk: the plan for a diff that needs no split."""
+ group = Group(
+ id="pr-1",
+ title="review all changes",
+ description="The whole diff fits within --max-loc, so it is kept as a single PR.",
+ assignments=[
+ GroupAssignment(
+ file_path=pf.path,
+ assignment_type=AssignmentType.WHOLE_FILE,
+ hunk_indices=list(range(len(pf))),
+ )
+ for pf in parsed_diff.patch_set
+ ],
+ )
+ recompute_estimated_loc([group], parsed_diff)
+ return [group]
+
+
def plan_split(
parsed_diff: ParsedDiff,
settings: Settings,
) -> list[Group]:
+ if settings.partition_strategy not in (
+ PartitionStrategy.LLM,
+ PartitionStrategy.GRAPH,
+ PartitionStrategy.CP_SAT,
+ ):
+ raise PRSplitError(f"Unsupported partition strategy '{settings.partition_strategy}'")
+ total_loc = parsed_diff.stats["total_loc"]
+ if total_loc <= settings.max_loc:
+ # Nothing to split: no backend (and no LLM call) is needed.
+ logger.info(logs.DIFF_WITHIN_MAX_LOC.format(loc=total_loc, max_loc=settings.max_loc))
+ return _single_group_plan(parsed_diff)
+
logger.info(logs.PLANNING_WITH_BACKEND.format(backend=settings.partition_strategy))
- match settings.partition_strategy:
- case PartitionStrategy.LLM:
- groups = _plan_split_with_llm(parsed_diff, settings)
- case PartitionStrategy.GRAPH | PartitionStrategy.CP_SAT:
- groups = partition_diff(parsed_diff, settings)
- case _:
- raise PRSplitError(f"Unsupported partition strategy '{settings.partition_strategy}'")
+ if settings.partition_strategy is PartitionStrategy.LLM:
+ groups = _plan_split_with_llm(parsed_diff, settings)
+ else:
+ groups = partition_diff(parsed_diff, settings)
metrics = score_plan(groups, settings.max_loc, settings.min_loc)
logger.info(
diff --git a/pr_split/planner/new_file_pieces.py b/pr_split/planner/new_file_pieces.py
new file mode 100644
index 0000000..b95fd48
--- /dev/null
+++ b/pr_split/planner/new_file_pieces.py
@@ -0,0 +1,47 @@
+"""Keep the pieces of a split new file in stack order.
+
+A new file cut into pieces (see diff_ops.new_file_split) is rebuilt by
+concatenating the pieces a branch holds, and a stacked child carries its
+ancestors' pieces. So the group holding piece k must hold piece k-1 too, or
+build on the group that does.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from loguru import logger
+
+from .. import logs
+from ..exceptions import PRSplitError
+from ..graph import PlanDAG
+from .validator import new_file_pieces_out_of_order
+
+if TYPE_CHECKING:
+ from ..diff_ops import ParsedDiff
+ from ..schemas import Group
+
+
+def link_new_file_pieces(groups: list[Group], parsed_diff: ParsedDiff) -> None:
+ """Make each later piece's group depend on the previous piece's group.
+
+ An edge that would close a cycle is left out; validate_new_file_pieces
+ then reports the plan as invalid.
+ """
+ if not parsed_diff.new_file_pieces:
+ return
+ by_id = {g.id: g for g in groups}
+ for path, _piece, child, parent in new_file_pieces_out_of_order(
+ groups, parsed_diff, PlanDAG(groups)
+ ):
+ # An edge added earlier in this loop may already order the pair.
+ if parent in PlanDAG(groups).ancestors(child):
+ continue
+ group = by_id[child]
+ group.depends_on.append(parent)
+ try:
+ PlanDAG(groups).validate_acyclic()
+ except PRSplitError:
+ group.depends_on.remove(parent)
+ continue
+ logger.info(logs.NEW_FILE_PIECE_LINKED.format(child=child, file=path, parent=parent))
diff --git a/pr_split/planner/partitioning.py b/pr_split/planner/partitioning.py
index 166d207..d7bbc82 100644
--- a/pr_split/planner/partitioning.py
+++ b/pr_split/planner/partitioning.py
@@ -1,18 +1,28 @@
from __future__ import annotations
+import os
from collections import defaultdict
from dataclasses import dataclass
+from graphlib import CycleError, TopologicalSorter
from itertools import pairwise
from pathlib import PurePosixPath
from typing import TYPE_CHECKING
+from loguru import logger
+
+from .. import logs
from ..constants import AssignmentType, PartitionStrategy, Priority
from ..exceptions import PRSplitError
+from ..graph import PlanDAG
from ..schemas import Group, GroupAssignment
from .chunker import recompute_estimated_loc
+from .symbols import names_in, symbol_dependencies
if TYPE_CHECKING:
+ from collections.abc import Iterable, Sequence
+
from ortools.sat.python.cp_model import IntVar
+ from unidiff import PatchedFile
from ..config import Settings
from ..diff_ops import ParsedDiff
@@ -23,6 +33,8 @@
_AFFINITY_SAME_SUFFIX = 10
_AFFINITY_ORTHOGONAL_PENALTY = 20
_AFFINITY_LOGICAL_SHARED_DIR_BONUS = 10
+# One unit uses a name the other defines: the strongest cross-file signal.
+_AFFINITY_SHARED_SYMBOL = 120
_GRAPH_ORTHOGONAL_FILE_PENALTY = 25.0
_CP_SAT_OVERFLOW_WEIGHT = 1_000
_CP_SAT_UNDERFLOW_WEIGHT = 1_000
@@ -41,6 +53,26 @@ class PartitionUnit:
hunk_indices: tuple[int, ...]
loc: int
position: int
+ # Names the unit's added lines define, and names they use; a unit that
+ # uses what another defines belongs with (or after) it.
+ defines: frozenset[str] = frozenset()
+ uses: frozenset[str] = frozenset()
+
+
+def _unit(patch_file: PatchedFile, indices: list[int], loc: int, position: int) -> PartitionUnit:
+ added = [
+ line.value.rstrip("\n") for idx in indices for line in patch_file[idx] if line.is_added
+ ]
+ defines, uses = names_in(added)
+ return PartitionUnit(
+ id=f"{patch_file.path}:{indices[0]}-{indices[-1]}",
+ file_path=patch_file.path,
+ hunk_indices=tuple(indices),
+ loc=loc,
+ position=position,
+ defines=defines,
+ uses=uses,
+ )
def build_partition_units(parsed_diff: ParsedDiff, max_loc: int) -> list[PartitionUnit]:
@@ -53,15 +85,7 @@ def build_partition_units(parsed_diff: ParsedDiff, max_loc: int) -> list[Partiti
for hunk_index, hunk in enumerate(patch_file):
hunk_loc = hunk.added + hunk.removed
if current_indices and current_loc + hunk_loc > max_loc:
- units.append(
- PartitionUnit(
- id=f"{patch_file.path}:{current_indices[0]}-{current_indices[-1]}",
- file_path=patch_file.path,
- hunk_indices=tuple(current_indices),
- loc=current_loc,
- position=position,
- )
- )
+ units.append(_unit(patch_file, current_indices, current_loc, position))
position += 1
current_indices = []
current_loc = 0
@@ -70,15 +94,7 @@ def build_partition_units(parsed_diff: ParsedDiff, max_loc: int) -> list[Partiti
current_loc += hunk_loc
if current_indices:
- units.append(
- PartitionUnit(
- id=f"{patch_file.path}:{current_indices[0]}-{current_indices[-1]}",
- file_path=patch_file.path,
- hunk_indices=tuple(current_indices),
- loc=current_loc,
- position=position,
- )
- )
+ units.append(_unit(patch_file, current_indices, current_loc, position))
position += 1
return units
@@ -117,6 +133,8 @@ def _affinity_score(unit_a: PartitionUnit, unit_b: PartitionUnit, priority: Prio
if PurePosixPath(unit_a.file_path).suffix == PurePosixPath(unit_b.file_path).suffix:
score += _AFFINITY_SAME_SUFFIX
score += _test_pair_bonus(unit_a.file_path, unit_b.file_path)
+ if unit_a.defines & unit_b.uses or unit_b.defines & unit_a.uses:
+ score += _AFFINITY_SHARED_SYMBOL
if priority == Priority.ORTHOGONAL:
score = max(0, score - _AFFINITY_ORTHOGONAL_PENALTY)
@@ -126,11 +144,40 @@ def _affinity_score(unit_a: PartitionUnit, unit_b: PartitionUnit, priority: Prio
return score
+def _merge_order_is_acyclic(grouped_units: Iterable[Sequence[PartitionUnit]]) -> bool:
+ """Check that the merge-order dependencies implied by ``grouped_units`` form a DAG.
+
+ Mirrors ``_derive_merge_order_dependencies``: within each file, groups are ordered by
+ their earliest unit and each group depends on the one before it.
+ """
+ file_occurrences: dict[str, list[tuple[int, int]]] = defaultdict(list)
+ for group_idx, group_units in enumerate(grouped_units):
+ first_positions: dict[str, int] = {}
+ for unit in group_units:
+ previous = first_positions.get(unit.file_path)
+ if previous is None or unit.position < previous:
+ first_positions[unit.file_path] = unit.position
+ for file_path, position in first_positions.items():
+ file_occurrences[file_path].append((position, group_idx))
+
+ sorter: TopologicalSorter[int] = TopologicalSorter()
+ for occurrences in file_occurrences.values():
+ ordered_group_indices = [group_idx for _, group_idx in sorted(occurrences)]
+ for parent_idx, child_idx in pairwise(ordered_group_indices):
+ sorter.add(child_idx, parent_idx)
+ try:
+ sorter.prepare()
+ except CycleError:
+ return False
+ return True
+
+
def _group_units_graph(
units: list[PartitionUnit], *, settings: Settings
) -> list[list[PartitionUnit]]:
remaining = set(range(len(units)))
grouped_units: list[list[PartitionUnit]] = []
+ grouped_files: set[str] = set()
while remaining:
seed = max(remaining, key=lambda idx: (units[idx].loc, -units[idx].position))
@@ -146,6 +193,10 @@ def _group_units_graph(
candidate_unit = units[candidate]
if current_load and current_load + candidate_unit.loc > settings.max_loc:
continue
+ if candidate_unit.file_path in grouped_files and not _merge_order_is_acyclic(
+ [*grouped_units, [*(units[idx] for idx in current_group), candidate_unit]]
+ ):
+ continue
affinity = sum(
_affinity_score(candidate_unit, units[group_idx], settings.priority)
@@ -173,6 +224,7 @@ def _group_units_graph(
grouped_units.append(
sorted((units[idx] for idx in current_group), key=lambda unit: unit.position)
)
+ grouped_files.update(units[idx].file_path for idx in current_group)
return grouped_units
@@ -244,6 +296,8 @@ def _best_graph_merge_target(
continue
if not _shared_file_merge_is_contiguous(grouped_units, source_idx, target_idx):
continue
+ if not _merge_order_is_acyclic(_merge_group_units(grouped_units, source_idx, target_idx)):
+ continue
current_underflow = source_underflow + max(0, settings.min_loc - _group_load(target_group))
merged_underflow = max(0, settings.min_loc - merged_load)
@@ -325,7 +379,11 @@ def _group_units_cp_sat(
from ortools.sat.python import cp_model
except ImportError as exc:
raise PRSplitError(
- "CP-SAT partitioning requires the optional 'ortools' package to be installed"
+ "CP-SAT partitioning requires the optional 'ortools' package; "
+ "install it with "
+ "`uv tool install 'pr-split[cp-sat] @ git+https://github.com/vitali87/pr-split'` "
+ "for uv, or "
+ "`pip install 'pr-split[cp-sat] @ git+https://github.com/vitali87/pr-split'` for pip"
) from exc
if not units:
@@ -433,11 +491,21 @@ def _group_units_cp_sat(
assignments[group_idx].append(unit)
break
- return [
+ grouped = [
sorted(group_units, key=lambda unit: unit.position)
for _, group_units in sorted(assignments.items())
if group_units
]
+ if status == cp_model.FEASIBLE:
+ # The solver ran out of time before proving optimality; on larger
+ # diffs the plan it returns can be far from the best one (many
+ # singleton groups), so say so instead of presenting it as final.
+ logger.warning(
+ logs.CP_SAT_NOT_OPTIMAL.format(
+ timeout=settings.cp_sat_timeout, units=len(units), groups=len(grouped)
+ )
+ )
+ return grouped
def _build_group_title(group_index: int, units: list[PartitionUnit]) -> str:
@@ -445,13 +513,40 @@ def _build_group_title(group_index: int, units: list[PartitionUnit]) -> str:
if len(file_paths) == 1:
stem = PurePosixPath(file_paths[0]).stem.replace("_", "-")
return f"chore(split): review {stem}-{group_index}"
- return f"chore(split): review-slice-{group_index}"
+ # Name a multi-file group after the directory its files share, or after
+ # its largest file, so every group title says what it covers.
+ parents = {PurePosixPath(path).parent for path in file_paths}
+ common = PurePosixPath(os.path.commonpath([str(p) for p in parents])) if parents else None
+ if common is not None and str(common) not in ("", "."):
+ return f"chore(split): review {common.name}-{group_index}"
+ largest = max(units, key=lambda unit: unit.loc).file_path
+ stem = PurePosixPath(largest).stem.replace("_", "-")
+ return f"chore(split): review {stem} and {len(file_paths) - 1} more-{group_index}"
+
+
+def _describe_files(backend: str, file_paths: Iterable[str]) -> str:
+ paths = sorted(set(file_paths))
+ return f"{backend} partition over {len(paths)} file(s): {', '.join(paths)}"
def _build_group_description(backend: PartitionStrategy, units: list[PartitionUnit]) -> str:
- file_paths = sorted({unit.file_path for unit in units})
- files = ", ".join(file_paths)
- return f"{backend.value} partition over {len(file_paths)} file(s): {files}"
+ return _describe_files(backend.value, (unit.file_path for unit in units))
+
+
+def refresh_generated_description(group: Group) -> None:
+ """Re-list a group's files in its description if the backend wrote it.
+
+ A graph or cp_sat description names the group's files, so it goes stale
+ when the editor moves a hunk; it would then become the PR body. A
+ description written by the LLM or by hand is left alone.
+ """
+ backend, _, rest = group.description.partition(" partition over ")
+ count, _, files = rest.partition(" file(s): ")
+ if backend not in (PartitionStrategy.GRAPH, PartitionStrategy.CP_SAT) or not count.isdigit():
+ return
+ if len(files.split(", ") if files else []) != int(count):
+ return
+ group.description = _describe_files(backend, (a.file_path for a in group.assignments))
def _build_groups_from_units(
@@ -496,9 +591,46 @@ def _build_groups_from_units(
recompute_estimated_loc(groups, parsed_diff)
_derive_merge_order_dependencies(groups)
+ _add_symbol_dependencies(groups, parsed_diff)
return groups
+def _add_symbol_dependencies(groups: list[Group], parsed_diff: ParsedDiff) -> None:
+ """Make a group depend on every group whose newly defined names it uses.
+
+ File order alone misses a new test file that imports a new module, so the
+ test's sub-PR would target the base and fail on import. An edge that
+ would close a cycle is skipped with a warning.
+ """
+ by_id = {g.id: g for g in groups}
+ for user, definers in sorted(symbol_dependencies(groups, parsed_diff).items()):
+ for definer, names in sorted(definers.items()):
+ group = by_id[user]
+ if definer in group.depends_on:
+ continue
+ group.depends_on.append(definer)
+ try:
+ PlanDAG(groups).validate_acyclic()
+ except PRSplitError:
+ group.depends_on.remove(definer)
+ logger.warning(
+ logs.SYMBOL_EDGE_SKIPPED.format(
+ user=user, definer=definer, names=", ".join(sorted(names)[:5])
+ )
+ )
+ _reduce_transitive_dependencies(groups)
+
+
+def _reduce_transitive_dependencies(groups: list[Group]) -> None:
+ dep_map = {group.id: set(group.depends_on) for group in groups}
+ for group in groups:
+ reduced_deps = set(dep_map[group.id])
+ for dep in list(reduced_deps):
+ if _has_alternative_path(dep_map, group.id, dep):
+ reduced_deps.remove(dep)
+ group.depends_on = sorted(reduced_deps)
+
+
def _has_alternative_path(dep_map: dict[str, set[str]], source: str, target: str) -> bool:
stack = [source]
seen = {source}
@@ -532,11 +664,8 @@ def _derive_merge_order_dependencies(groups: list[Group]) -> None:
dep_map[child_id].add(parent_id)
for group in groups:
- reduced_deps = set(dep_map[group.id])
- for dep in list(reduced_deps):
- if _has_alternative_path(dep_map, group.id, dep):
- reduced_deps.remove(dep)
- group.depends_on = sorted(reduced_deps)
+ group.depends_on = sorted(dep_map[group.id])
+ _reduce_transitive_dependencies(groups)
def partition_diff(parsed_diff: ParsedDiff, settings: Settings) -> list[Group]:
@@ -553,8 +682,10 @@ def partition_diff(parsed_diff: ParsedDiff, settings: Settings) -> list[Group]:
case _:
raise PRSplitError(f"Unsupported partition strategy '{settings.partition_strategy}'")
- return _build_groups_from_units(
+ groups = _build_groups_from_units(
grouped_units,
parsed_diff,
backend=settings.partition_strategy,
)
+ PlanDAG(groups).validate_acyclic()
+ return groups
diff --git a/pr_split/planner/repair.py b/pr_split/planner/repair.py
new file mode 100644
index 0000000..7052178
--- /dev/null
+++ b/pr_split/planner/repair.py
@@ -0,0 +1,127 @@
+"""Mechanically repair an LLM plan before it is validated.
+
+Models, small local ones most of all, sometimes skip a hunk, claim one hunk
+in two groups, invent a file or hunk index, or depend on a group that does
+not exist. Each has one obvious fix, so it is applied with a warning rather
+than failing a whole planning call. A cycle or a duplicate group id has no
+single right fix and is left for validation to report.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from loguru import logger
+
+from .. import logs
+from ..constants import AssignmentType
+from .chunker import assign_uncovered_hunks, recompute_estimated_loc
+
+if TYPE_CHECKING:
+ from ..diff_ops import ParsedDiff
+ from ..schemas import Group
+
+
+def _drop_unknown_hunks(groups: list[Group], hunk_counts: dict[str, int]) -> None:
+ for group in groups:
+ kept = []
+ for assignment in group.assignments:
+ count = hunk_counts.get(assignment.file_path)
+ if count is None:
+ logger.warning(
+ logs.REPAIR_UNKNOWN_FILE.format(group=group.id, file=assignment.file_path)
+ )
+ continue
+ if assignment.assignment_type is AssignmentType.PARTIAL_HUNKS:
+ bad = [i for i in assignment.hunk_indices if not 0 <= i < count]
+ if bad:
+ logger.warning(
+ logs.REPAIR_UNKNOWN_HUNKS.format(
+ group=group.id, file=assignment.file_path, indices=bad
+ )
+ )
+ assignment.hunk_indices = [i for i in assignment.hunk_indices if i not in bad]
+ if not assignment.hunk_indices:
+ continue
+ kept.append(assignment)
+ group.assignments = kept
+
+
+def _drop_duplicate_claims(groups: list[Group], hunk_counts: dict[str, int]) -> None:
+ owner: dict[tuple[str, int], str] = {}
+ for group in groups:
+ kept = []
+ for assignment in group.assignments:
+ count = hunk_counts[assignment.file_path]
+ covered = assignment.covered_indices(count)
+ taken = [i for i in covered if (assignment.file_path, i) in owner]
+ if taken:
+ logger.warning(
+ logs.REPAIR_DUPLICATE_HUNKS.format(
+ group=group.id,
+ file=assignment.file_path,
+ indices=taken,
+ owner=owner[(assignment.file_path, taken[0])],
+ )
+ )
+ assignment.assignment_type = AssignmentType.PARTIAL_HUNKS
+ assignment.hunk_indices = [i for i in covered if i not in taken]
+ if not assignment.hunk_indices and assignment.assignment_type is not (
+ AssignmentType.WHOLE_FILE
+ ):
+ continue
+ for idx in assignment.covered_indices(count):
+ owner[(assignment.file_path, idx)] = group.id
+ kept.append(assignment)
+ group.assignments = kept
+
+
+def _drop_bad_dependencies(groups: list[Group]) -> None:
+ ids = {g.id for g in groups}
+ for group in groups:
+ bad = [d for d in group.depends_on if d not in ids or d == group.id]
+ if bad:
+ logger.warning(logs.REPAIR_UNKNOWN_DEPENDENCY.format(group=group.id, deps=bad))
+ group.depends_on = [d for d in group.depends_on if d not in bad]
+ # A repeated id is harmless but would show up twice in every PR body.
+ group.depends_on = list(dict.fromkeys(group.depends_on))
+
+
+def _drop_empty_groups(groups: list[Group]) -> list[Group]:
+ empty = {g.id for g in groups if not g.assignments}
+ if not empty:
+ return groups
+ by_id = {g.id: g for g in groups}
+ for gid in sorted(empty):
+ logger.warning(logs.REPAIR_EMPTY_GROUP.format(group=gid))
+ kept = [g for g in groups if g.id not in empty]
+ for group in kept:
+ # Keep the order an emptied group imposed by inheriting its parents.
+ deps: list[str] = []
+ pending = list(group.depends_on)
+ seen: set[str] = set()
+ while pending:
+ dep = pending.pop(0)
+ if dep in seen:
+ continue
+ seen.add(dep)
+ if dep in empty:
+ pending.extend(by_id[dep].depends_on)
+ elif dep != group.id:
+ deps.append(dep)
+ group.depends_on = deps
+ return kept
+
+
+def repair_plan(groups: list[Group], parsed_diff: ParsedDiff) -> list[Group]:
+ if not groups:
+ return groups # nothing to place hunks into; validation reports the gap
+ hunk_counts = {pf.path: len(pf) for pf in parsed_diff.patch_set}
+ _drop_unknown_hunks(groups, hunk_counts)
+ _drop_duplicate_claims(groups, hunk_counts)
+ _drop_bad_dependencies(groups)
+ if any(g.assignments for g in groups):
+ groups = _drop_empty_groups(groups)
+ assign_uncovered_hunks(groups, parsed_diff)
+ recompute_estimated_loc(groups, parsed_diff)
+ return groups
diff --git a/pr_split/planner/scoring.py b/pr_split/planner/scoring.py
index 6a09da6..20f03df 100644
--- a/pr_split/planner/scoring.py
+++ b/pr_split/planner/scoring.py
@@ -31,6 +31,7 @@ def _dag_depth(dag: PlanDAG) -> int:
def score_plan(groups: list[Group], max_loc: int, min_loc: int | None = None) -> PlanMetrics:
dag = PlanDAG(groups)
+ dag.validate_acyclic()
max_group_loc = max((group.estimated_loc for group in groups), default=0)
loc_underflow = (
@@ -39,7 +40,8 @@ def score_plan(groups: list[Group], max_loc: int, min_loc: int | None = None) ->
else 0
)
loc_overflow = sum(max(0, group.estimated_loc - max_loc) for group in groups)
- dependency_edges = sum(len(group.depends_on) for group in groups)
+ # Count edges as the DAG sees them (a dependency listed twice is one edge).
+ dependency_edges = sum(len(dag.parents(group.id)) for group in groups)
dag_width = max((len(batch) for batch in dag.iter_ready()), default=0)
dag_depth = _dag_depth(dag)
diff --git a/pr_split/planner/symbols.py b/pr_split/planner/symbols.py
new file mode 100644
index 0000000..cb0b1e1
--- /dev/null
+++ b/pr_split/planner/symbols.py
@@ -0,0 +1,104 @@
+"""Symbol-level dependencies between groups, read from the diff's added lines.
+
+A group that uses a name another group newly defines must build on that
+group, or its sub-PR cannot compile or import on its own. Shared files are
+not enough to see this: a new test file and the module it imports touch no
+common file. The analysis is lexical and language-agnostic: it recognises
+common definition forms and treats any later whole-word use as a reference.
+"""
+
+from __future__ import annotations
+
+import re
+from collections import defaultdict
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from ..diff_ops import ParsedDiff
+ from ..schemas import Group
+
+_DEFINITION_PATTERNS = (
+ # Python, Ruby: def/class, including async def.
+ re.compile(r"^\s*(?:async\s+)?(?:def|class)\s+([A-Za-z_]\w*)"),
+ # JavaScript/TypeScript, Go, Rust, Swift, Kotlin, PHP functions.
+ re.compile(
+ r"^\s*(?:export\s+)?(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?(?:function|fn|func|fun)\s+([A-Za-z_]\w*)"
+ ),
+ # Types across languages.
+ re.compile(
+ r"^\s*(?:export\s+)?(?:pub(?:\([^)]*\))?\s+)?(?:abstract\s+|final\s+|sealed\s+|data\s+)*"
+ r"(?:class|struct|enum|interface|trait|type|protocol|record)\s+([A-Za-z_]\w*)"
+ ),
+ # Top-level constants: UPPER_CASE = ... (Python) or const/let/var NAME = (JS/TS).
+ re.compile(r"^([A-Z][A-Z0-9_]{2,})\s*(?::[^=]+)?=(?!=)"),
+ re.compile(r"^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_]\w*)\s*[:=]"),
+)
+_IDENTIFIER = re.compile(r"[A-Za-z_]\w*")
+# Names too generic to tie two groups together.
+_MIN_NAME_LENGTH = 4
+_IGNORED = frozenset({"main", "self", "this", "init", "test", "setup", "teardown", "None", "True"})
+
+
+def _added_lines_by_group(groups: list[Group], parsed_diff: ParsedDiff) -> dict[str, list[str]]:
+ files = {pf.path: pf for pf in parsed_diff.patch_set}
+ added: dict[str, list[str]] = defaultdict(list)
+ for group in groups:
+ for assignment in group.assignments:
+ patch_file = files.get(assignment.file_path)
+ if patch_file is None:
+ continue
+ for idx in assignment.covered_indices(len(patch_file)):
+ if idx >= len(patch_file):
+ continue
+ added[group.id].extend(
+ line.value.rstrip("\n") for line in patch_file[idx] if line.is_added
+ )
+ return added
+
+
+def _definitions(lines: list[str]) -> set[str]:
+ names: set[str] = set()
+ for line in lines:
+ for pattern in _DEFINITION_PATTERNS:
+ match = pattern.match(line)
+ if match:
+ names.add(match.group(1))
+ return {n for n in names if len(n) >= _MIN_NAME_LENGTH and n not in _IGNORED}
+
+
+def symbol_dependencies(
+ groups: list[Group], parsed_diff: ParsedDiff
+) -> dict[str, dict[str, set[str]]]:
+ """Map user group -> definer group -> the names it uses from that definer.
+
+ Only names defined by exactly one group count: a name several groups
+ define (an overload, a common helper name) says nothing about order.
+ """
+ added = _added_lines_by_group(groups, parsed_diff)
+ defined_by: dict[str, list[str]] = defaultdict(list)
+ for group in groups:
+ for name in _definitions(added.get(group.id, [])):
+ defined_by[name].append(group.id)
+ owner = {name: gids[0] for name, gids in defined_by.items() if len(gids) == 1}
+
+ uses: dict[str, dict[str, set[str]]] = defaultdict(lambda: defaultdict(set))
+ for group in groups:
+ own = _definitions(added.get(group.id, []))
+ for line in added.get(group.id, []):
+ for name in _IDENTIFIER.findall(line):
+ definer = owner.get(name)
+ if definer is not None and definer != group.id and name not in own:
+ uses[group.id][definer].add(name)
+ return {user: dict(definers) for user, definers in uses.items()}
+
+
+def names_in(lines: list[str]) -> tuple[frozenset[str], frozenset[str]]:
+ """(names these lines define, identifiers they use), for unit-level affinity."""
+ defined = _definitions(lines)
+ used = {
+ name
+ for line in lines
+ for name in _IDENTIFIER.findall(line)
+ if len(name) >= _MIN_NAME_LENGTH and name not in _IGNORED
+ }
+ return frozenset(defined), frozenset(used - defined)
diff --git a/pr_split/planner/validator.py b/pr_split/planner/validator.py
index 0995af9..bd4c3b2 100644
--- a/pr_split/planner/validator.py
+++ b/pr_split/planner/validator.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+from loguru import logger
+
from .. import logs
from ..constants import AssignmentType, LocViolationType
from ..diff_ops import ParsedDiff
@@ -7,6 +9,19 @@
from ..graph import PlanDAG
from ..schemas import Group
from ..types_defs import LocBoundViolation
+from .symbols import symbol_dependencies
+
+
+def validate_no_binary_files(parsed_diff: ParsedDiff) -> None:
+ """Refuse diffs with binary files.
+
+ Binary patches carry no hunks, so coverage validation would pass
+ without any group claiming them and the change would silently be
+ dropped from every sub-PR.
+ """
+ binary = [pf.path for pf in parsed_diff.patch_set if pf.is_binary_file]
+ if binary:
+ raise PlanValidationError(ErrorMsg.BINARY_FILES_UNSUPPORTED(files=", ".join(binary)))
def validate_coverage(groups: list[Group], parsed_diff: ParsedDiff) -> None:
@@ -14,6 +29,10 @@ def validate_coverage(groups: list[Group], parsed_diff: ParsedDiff) -> None:
assigned: dict[tuple[str, int], list[str]] = {}
for group in groups:
for assignment in group.assignments:
+ if assignment.file_path not in hunk_counts:
+ raise PlanValidationError(
+ ErrorMsg.UNKNOWN_FILE(file=assignment.file_path, group=group.id)
+ )
# A WHOLE_FILE assignment claims every hunk of the file even when
# its hunk_indices list was left empty.
if assignment.assignment_type is AssignmentType.WHOLE_FILE:
@@ -26,6 +45,12 @@ def validate_coverage(groups: list[Group], parsed_diff: ParsedDiff) -> None:
all_hunks = {(pf.path, i) for pf in parsed_diff.patch_set for i in range(len(pf))}
+ for key, group_ids in assigned.items():
+ if key not in all_hunks:
+ raise PlanValidationError(
+ ErrorMsg.UNKNOWN_HUNK(file=key[0], index=key[1], group=group_ids[0])
+ )
+
for key in all_hunks:
if key not in assigned:
raise PlanValidationError(ErrorMsg.COVERAGE_GAP(file=key[0], index=key[1]))
@@ -46,12 +71,13 @@ def validate_loc(groups: list[Group], parsed_diff: ParsedDiff) -> None:
)
-def validate_no_conflicts(groups: list[Group], dag: PlanDAG) -> None:
+def validate_no_conflicts(groups: list[Group], dag: PlanDAG, hunk_counts: dict[str, int]) -> None:
group_files: dict[str, dict[str, set[int]]] = {}
for group in groups:
file_hunks: dict[str, set[int]] = {}
for assignment in group.assignments:
- file_hunks.setdefault(assignment.file_path, set()).update(assignment.hunk_indices)
+ covered = assignment.covered_indices(hunk_counts.get(assignment.file_path, 0))
+ file_hunks.setdefault(assignment.file_path, set()).update(covered)
group_files[group.id] = file_hunks
group_ids = [g.id for g in groups]
@@ -72,6 +98,48 @@ def validate_no_conflicts(groups: list[Group], dag: PlanDAG) -> None:
)
+def _piece_owners(groups: list[Group], path: str, pieces: int) -> list[str | None]:
+ owners: list[str | None] = [None] * pieces
+ for group in groups:
+ for assignment in group.assignments:
+ if assignment.file_path == path:
+ for idx in assignment.covered_indices(pieces):
+ if 0 <= idx < pieces:
+ owners[idx] = group.id
+ return owners
+
+
+def new_file_pieces_out_of_order(
+ groups: list[Group], parsed_diff: ParsedDiff, dag: PlanDAG
+) -> list[tuple[str, int, str, str]]:
+ """(file, piece, group, parent) for each piece of a split new file whose
+ group neither holds nor builds on the group holding the piece before it.
+
+ A new file is rebuilt from the pieces a branch holds, and a stacked child
+ carries its ancestors' pieces; any other layout creates the file without
+ its beginning.
+ """
+ found: list[tuple[str, int, str, str]] = []
+ for path, pieces in sorted(parsed_diff.new_file_pieces.items()):
+ owners = _piece_owners(groups, path, pieces)
+ for piece in range(1, pieces):
+ parent, child = owners[piece - 1], owners[piece]
+ if parent is None or child is None or parent == child:
+ continue
+ if parent not in dag.ancestors(child):
+ found.append((path, piece, child, parent))
+ return found
+
+
+def validate_new_file_pieces(groups: list[Group], parsed_diff: ParsedDiff, dag: PlanDAG) -> None:
+ for path, piece, child, parent in new_file_pieces_out_of_order(groups, parsed_diff, dag):
+ raise PlanValidationError(
+ ErrorMsg.NEW_FILE_PIECE_ORDER(
+ group=child, piece=piece + 1, file=path, parent=parent, previous=piece
+ )
+ )
+
+
def detect_loc_bound_violations(
groups: list[Group],
max_loc: int,
@@ -130,6 +198,27 @@ def validate_loc_bounds(
]
+def detect_symbol_order_violations(
+ groups: list[Group], parsed_diff: ParsedDiff, dag: PlanDAG
+) -> list[str]:
+ """Groups that use a name defined in a group they do not build on.
+
+ Such a sub-PR cannot compile or import on its own, e.g. a definition
+ placed in a child of the PR that uses it.
+ """
+ warnings: list[str] = []
+ for user, definers in sorted(symbol_dependencies(groups, parsed_diff).items()):
+ ancestors = dag.ancestors(user)
+ for definer, names in sorted(definers.items()):
+ if definer not in ancestors:
+ warnings.append(
+ logs.SYMBOL_ORDER_VIOLATION.format(
+ user=user, definer=definer, names=", ".join(sorted(names)[:5])
+ )
+ )
+ return warnings
+
+
def validate_plan(
groups: list[Group],
parsed_diff: ParsedDiff,
@@ -138,7 +227,11 @@ def validate_plan(
min_loc: int | None = None,
) -> list[str]:
dag.validate_acyclic()
+ validate_new_file_pieces(groups, parsed_diff, dag)
+ validate_no_binary_files(parsed_diff)
validate_coverage(groups, parsed_diff)
validate_loc(groups, parsed_diff)
- validate_no_conflicts(groups, dag)
+ validate_no_conflicts(groups, dag, {pf.path: len(pf) for pf in parsed_diff.patch_set})
+ for warning in detect_symbol_order_violations(groups, parsed_diff, dag):
+ logger.warning(warning)
return validate_loc_bounds(groups, max_loc, min_loc)
diff --git a/pr_split/recover.py b/pr_split/recover.py
new file mode 100644
index 0000000..dc6af3e
--- /dev/null
+++ b/pr_split/recover.py
@@ -0,0 +1,194 @@
+"""Rebuild a lost plan from the branches and PRs of a stack that still exists.
+
+The plan lives in ``.pr-split/plan.json`` of the checkout where ``split`` ran.
+When that checkout is gone, every branch and PR of the stack is still on the
+remote; this recovers the groups, their dependencies, the branches and the
+PRs from them, so ``status``, ``merge``, ``restack`` and ``clean`` work again.
+
+A recovered plan holds no diff and no hunk assignments: commands that rebuild
+branches from the plan's hunks (``execute``, ``move``) cannot use it.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import re
+from dataclasses import dataclass
+
+from loguru import logger
+
+from . import logs
+from .constants import BRANCH_PREFIX, DEFAULT_MAX_LOC, Priority, PRState
+from .exceptions import ErrorMsg, GitOperationError, PRSplitError
+from .git_ops.branches import derive_split_namespace, merge_base, run_git
+from .git_ops.prs import list_prs_with_head_prefix
+from .graph import PlanDAG
+from .schemas import BranchRecord, GitState, Group, PlanFile, PRRecord, SplitPlan
+
+REMOTE = "origin"
+_DEPENDS_ON = re.compile(r"^This PR depends on: (.+)$", re.MULTILINE)
+_BACKTICKED = re.compile(r"`([^`]+)`")
+# An open PR wins over a merged one for the same head, and both over a closed one.
+_STATE_RANK = {PRState.OPEN: 0, PRState.MERGED: 1, PRState.CLOSED: 2}
+
+
+@dataclass(frozen=True)
+class StackPR:
+ number: int
+ url: str
+ state: PRState
+ head: str
+ base: str
+ title: str
+ body: str
+
+ @classmethod
+ def from_gh(cls, raw: dict[str, object]) -> StackPR:
+ state = str(raw.get("state") or "").lower()
+ return cls(
+ number=int(str(raw["number"])),
+ url=str(raw.get("url") or ""),
+ state=PRState(state) if state in PRState.__members__.values() else PRState.CLOSED,
+ head=str(raw.get("headRefName") or ""),
+ base=str(raw.get("baseRefName") or ""),
+ title=str(raw.get("title") or ""),
+ body=str(raw.get("body") or ""),
+ )
+
+
+def stack_prefix(dev_branch_arg: str) -> str:
+ return f"{BRANCH_PREFIX}{derive_split_namespace(dev_branch_arg)}/"
+
+
+def _description(body: str) -> str:
+ # pr-split's own sections follow the description; keep only the description.
+ return body.split("\n## ", 1)[0].strip()
+
+
+def _body_dependencies(body: str) -> list[str]:
+ match = _DEPENDS_ON.search(body)
+ return _BACKTICKED.findall(match.group(1)) if match else []
+
+
+def _pick_prs(prs: list[StackPR]) -> dict[str, StackPR]:
+ """One PR per head branch: the open one, else the merged one, else the newest."""
+ picked: dict[str, StackPR] = {}
+ for pr in sorted(prs, key=lambda p: (_STATE_RANK[p.state], -p.number)):
+ picked.setdefault(pr.head, pr)
+ return picked
+
+
+def _base_branch(prefix: str, prs: list[StackPR], base: str | None) -> str:
+ if base:
+ return base
+ bases = sorted({pr.base for pr in prs if not pr.base.startswith(prefix)})
+ if len(bases) != 1:
+ raise PRSplitError(
+ ErrorMsg.RECOVER_BASE_UNKNOWN(prefix=prefix, bases=", ".join(bases) or "no PRs")
+ )
+ return bases[0]
+
+
+def rebuild_plan(
+ dev_branch_arg: str,
+ *,
+ prs: list[StackPR],
+ branches: dict[str, str],
+ base: str | None = None,
+ stacked: bool = False,
+ merge_base_sha: str | None = None,
+) -> PlanFile:
+ """Build a plan from a stack's PRs and its branches (name -> head sha)."""
+ prefix = stack_prefix(dev_branch_arg)
+ picked = _pick_prs([pr for pr in prs if pr.head.startswith(prefix)])
+ heads = sorted(set(picked) | {b for b in branches if b.startswith(prefix)})
+ if not heads:
+ raise PRSplitError(ErrorMsg.RECOVER_NOTHING_FOUND(prefix=prefix))
+ base_branch = _base_branch(prefix, list(picked.values()), base)
+ ids = {head: head.removeprefix(prefix) for head in heads}
+ known = set(ids.values())
+
+ groups: list[Group] = []
+ branch_records: list[BranchRecord] = []
+ pr_records: list[PRRecord] = []
+ for head in sorted(heads, key=lambda h: (picked[h].number if h in picked else 0, h)):
+ gid = ids[head]
+ pr = picked.get(head)
+ depends_on = _body_dependencies(pr.body) if pr else []
+ if pr and pr.base.startswith(prefix):
+ # A stacked child targets its parent's branch.
+ stacked = True
+ depends_on.append(pr.base.removeprefix(prefix))
+ depends_on = sorted({d for d in depends_on if d in known and d != gid})
+ groups.append(
+ Group(
+ id=gid,
+ title=pr.title if pr else gid,
+ description=_description(pr.body) if pr else "",
+ depends_on=depends_on,
+ )
+ )
+ branch_records.append(
+ BranchRecord(
+ group_id=gid,
+ branch_name=head,
+ base_branch=pr.base if pr else base_branch,
+ commit_sha=branches.get(head, ""),
+ )
+ )
+ if pr:
+ pr_records.append(
+ PRRecord(group_id=gid, pr_number=pr.number, pr_url=pr.url, state=pr.state)
+ )
+
+ PlanDAG(groups).validate_acyclic()
+ return PlanFile(
+ plan=SplitPlan(
+ dev_branch=dev_branch_arg,
+ base_branch=base_branch,
+ max_loc=DEFAULT_MAX_LOC,
+ stacked=stacked,
+ priority=Priority.ORTHOGONAL,
+ groups=groups,
+ merge_base_sha=merge_base_sha,
+ dev_branch_arg=dev_branch_arg,
+ ),
+ git_state=GitState(branches=branch_records, prs=pr_records),
+ )
+
+
+def _stack_branches(prefix: str) -> dict[str, str]:
+ """Head sha of every branch under ``prefix``: the remote copy, else the local one."""
+ remote_refs = f"refs/remotes/{REMOTE}/"
+ try:
+ run_git(
+ "fetch", "--quiet", "--prune", REMOTE, f"+refs/heads/{prefix}*:{remote_refs}{prefix}*"
+ )
+ except GitOperationError as exc:
+ logger.warning(logs.RECOVER_FETCH_FAILED.format(remote=REMOTE, detail=exc))
+ found: dict[str, str] = {}
+ # Remote refs come last, so the copy the PRs show wins over a local one.
+ for refs in ("refs/heads/", remote_refs):
+ out = run_git("for-each-ref", "--format=%(refname) %(objectname)", refs + prefix)
+ for line in out.splitlines():
+ ref, sha = line.split()
+ found[ref.removeprefix(refs)] = sha
+ return found
+
+
+def recover_plan(
+ dev_branch_arg: str, *, base: str | None = None, stacked: bool = False
+) -> PlanFile:
+ prefix = stack_prefix(dev_branch_arg)
+ try:
+ prs = [StackPR.from_gh(raw) for raw in list_prs_with_head_prefix(prefix)]
+ except GitOperationError as exc:
+ raise PRSplitError(ErrorMsg.RECOVER_PR_LIST_FAILED(detail=exc)) from exc
+ plan_file = rebuild_plan(
+ dev_branch_arg, prs=prs, branches=_stack_branches(prefix), base=base, stacked=stacked
+ )
+ plan = plan_file.plan
+ # The dev branch may be gone as well; nothing that reads a recovered plan needs this.
+ with contextlib.suppress(GitOperationError):
+ plan.merge_base_sha = merge_base(plan.dev_branch, plan.base_branch)
+ return plan_file
diff --git a/pr_split/restack.py b/pr_split/restack.py
new file mode 100644
index 0000000..491972a
--- /dev/null
+++ b/pr_split/restack.py
@@ -0,0 +1,294 @@
+"""Carry a change on a lower stacked layer up to every layer above it.
+
+After ``execute --stack`` each child branch was cut from its parent's head at
+that time. A review fix committed on a parent later is missing from every
+layer above it, whose diff against its parent then shows the fix reversed.
+Restacking rebases each child onto its parent's current head, in plan order,
+and pushes the rewritten branches with ``--force-with-lease``.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import tempfile
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+from loguru import logger
+
+from . import logs
+from .exceptions import ErrorMsg, GitOperationError, PRSplitError
+from .git_ops import push_branch, remove_worktree
+from .git_ops.branches import commit_exists, run_git, run_git_in_dir
+from .graph import PlanDAG
+
+if TYPE_CHECKING:
+ from .schemas import BranchRecord, PlanFile
+
+REMOTE = "origin"
+
+
+@dataclass
+class LayerResult:
+ group_id: str
+ branch: str
+ action: str # "restacked", "up to date", "pushed" or "skipped: "
+
+
+def _rev(ref: str) -> str | None:
+ if not commit_exists(ref):
+ return None
+ return run_git("rev-parse", "--verify", f"{ref}^{{commit}}")
+
+
+def _is_ancestor(ancestor: str, descendant: str) -> bool:
+ try:
+ run_git("merge-base", "--is-ancestor", ancestor, descendant)
+ except GitOperationError:
+ return False
+ return True
+
+
+def _remote_ref(branch: str) -> str:
+ return f"refs/remotes/{REMOTE}/{branch}"
+
+
+def _checked_out_branches() -> set[str]:
+ out = run_git("worktree", "list", "--porcelain")
+ return {
+ line.removeprefix("branch refs/heads/")
+ for line in out.splitlines()
+ if line.startswith("branch refs/heads/")
+ }
+
+
+def _fetch(branches: list[str]) -> None:
+ for branch in branches:
+ try:
+ run_git("fetch", "--quiet", REMOTE, f"+refs/heads/{branch}:{_remote_ref(branch)}")
+ except GitOperationError as exc:
+ # A layer whose PR merged may have had its branch deleted.
+ logger.warning(logs.RESTACK_FETCH_FAILED.format(branch=branch, detail=exc))
+
+
+def _sync_with_remote(branch: str) -> None:
+ """Bring the local branch up to its remote copy (a fix pushed from elsewhere)."""
+ local, remote = _rev(f"refs/heads/{branch}"), _rev(_remote_ref(branch))
+ if remote is None or local == remote:
+ return
+ if local is None:
+ run_git("branch", branch, remote)
+ elif _is_ancestor(local, remote):
+ run_git("update-ref", f"refs/heads/{branch}", remote, local)
+ elif not _is_ancestor(remote, local):
+ raise PRSplitError(ErrorMsg.RESTACK_DIVERGED(branch=branch, remote=REMOTE))
+
+
+def _rebase(child: str, onto: str, upstream: str) -> str:
+ """Replay child's commits after upstream onto ``onto``; return the new head."""
+ worktree = tempfile.mkdtemp(prefix="pr-split-restack-")
+ run_git("worktree", "add", "--detach", worktree, f"refs/heads/{child}")
+ try:
+ try:
+ # Hooks belong to the user's checkout; the throwaway worktree has
+ # none of their tooling, so skip them as execute does.
+ run_git_in_dir(
+ worktree, "-c", "core.hooksPath=/dev/null", "rebase", "--onto", onto, upstream
+ )
+ except GitOperationError as exc:
+ with contextlib.suppress(GitOperationError):
+ run_git_in_dir(worktree, "rebase", "--abort")
+ raise PRSplitError(
+ ErrorMsg.RESTACK_CONFLICT(branch=child, parent=onto, detail=exc)
+ ) from exc
+ return run_git_in_dir(worktree, "rev-parse", "HEAD")
+ finally:
+ remove_worktree(worktree)
+
+
+def _layers(plan_file: PlanFile) -> list[tuple[str, str, str]]:
+ """(group id, branch, parent branch) for every layer stacked on one parent."""
+ branches = {r.group_id: r.branch_name for r in plan_file.git_state.branches}
+ dag = PlanDAG(plan_file.plan.groups)
+ layers: list[tuple[str, str, str]] = []
+ for gid in dag.topological_order():
+ parents = dag.parents(gid)
+ # Roots build on the base branch and merge nodes are rebuilt from the
+ # merge base; only a single-parent child is stacked on a branch.
+ if len(parents) == 1 and gid in branches and parents[0] in branches:
+ layers.append((gid, branches[gid], branches[parents[0]]))
+ return layers
+
+
+def stale_layers(plan_file: PlanFile) -> list[tuple[str, str, str]]:
+ """Layers whose branch (local or remote copy) lacks its parent's head. No fetch."""
+ if not plan_file.plan.stacked:
+ return []
+ stale: list[tuple[str, str, str]] = []
+ for gid, branch, parent in _layers(plan_file):
+ for prefix in ("refs/heads/", f"refs/remotes/{REMOTE}/"):
+ child_ref, parent_ref = prefix + branch, prefix + parent
+ if (
+ commit_exists(child_ref)
+ and commit_exists(parent_ref)
+ and not _is_ancestor(parent_ref, child_ref)
+ ):
+ stale.append((gid, branch, parent))
+ break
+ return stale
+
+
+def _base_tracking(base: str) -> tuple[str, str]:
+ """(remote, branch on that remote) the base branch tracks; origin/ by default.
+
+ split diffs against the same upstream (git_ops.diff_base_ref), so a base
+ tracked on, say, upstream/main must be restacked onto upstream/main too.
+ """
+ values: list[str] = []
+ for key in ("remote", "merge"):
+ try:
+ values.append(run_git("config", "--get", f"branch.{base}.{key}"))
+ except GitOperationError:
+ values.append("")
+ remote, merge = values
+ if not remote or remote == "." or not merge.startswith("refs/heads/"):
+ return REMOTE, base
+ return remote, merge.removeprefix("refs/heads/")
+
+
+def _restore(originals: dict[str, str | None]) -> None:
+ """Put every local stack branch back where this run found it."""
+ for branch, sha in originals.items():
+ ref = f"refs/heads/{branch}"
+ if _rev(ref) == sha:
+ continue
+ with contextlib.suppress(GitOperationError):
+ if sha is None:
+ run_git("update-ref", "-d", ref)
+ else:
+ run_git("update-ref", ref, sha)
+
+
+def _stack_order(
+ plan_file: PlanFile, *, base_remote: tuple[str, str] | None
+) -> list[tuple[str, str, str, str]]:
+ """(group id, branch, parent name, parent ref) in plan order.
+
+ Single-parent layers stack on their parent's branch. With ``base_remote``
+ ((remote, branch) of the base), the layers that target the base branch
+ (roots, and merge nodes that are rebuilt from it) are included too, with
+ the base's remote-tracking ref as parent.
+ """
+ branches = {r.group_id: r.branch_name for r in plan_file.git_state.branches}
+ dag = PlanDAG(plan_file.plan.groups)
+ order: list[tuple[str, str, str, str]] = []
+ for gid in dag.topological_order():
+ if gid not in branches:
+ continue
+ parents = dag.parents(gid)
+ if len(parents) == 1 and parents[0] in branches:
+ parent = branches[parents[0]]
+ order.append((gid, branches[gid], parent, f"refs/heads/{parent}"))
+ elif base_remote is not None and len(parents) != 1:
+ remote, branch = base_remote
+ order.append(
+ (gid, branches[gid], f"{remote}/{branch}", f"refs/remotes/{remote}/{branch}")
+ )
+ return order
+
+
+def restack(
+ plan_file: PlanFile, *, dry_run: bool = False, onto_base: bool = False
+) -> list[LayerResult]:
+ if not plan_file.plan.stacked:
+ raise PRSplitError(ErrorMsg.RESTACK_NOT_STACKED())
+ if not plan_file.git_state.branches:
+ raise PRSplitError(ErrorMsg.RESTACK_NO_BRANCHES())
+
+ order = {g: i for i, g in enumerate(PlanDAG(plan_file.plan.groups).topological_order())}
+ records = sorted(plan_file.git_state.branches, key=lambda r: order.get(r.group_id, 0))
+ all_branches = [r.branch_name for r in records]
+ base_remote = _base_tracking(plan_file.plan.base_branch) if onto_base else None
+ layers = _stack_order(plan_file, base_remote=base_remote)
+
+ _fetch(all_branches)
+ if base_remote is not None:
+ remote, branch = base_remote
+ try:
+ run_git(
+ "fetch", "--quiet", remote, f"+refs/heads/{branch}:refs/remotes/{remote}/{branch}"
+ )
+ except GitOperationError as exc:
+ raise PRSplitError(
+ ErrorMsg.RESTACK_BASE_FETCH_FAILED(base=f"{remote}/{branch}", detail=exc)
+ ) from exc
+ if dry_run:
+ return _restack_layers(records, layers, dry_run=True, pushed=set())
+ # A run that stops part way (a conflict, a failed push) must leave no
+ # local branch moved: a lower layer rebased but never pushed would read
+ # as diverged from its remote on the next run.
+ # A branch already pushed stays as pushed, so the remote and local agree.
+ originals = {branch: _rev(f"refs/heads/{branch}") for branch in all_branches}
+ pushed: set[str] = set()
+ try:
+ return _restack_layers(records, layers, dry_run=False, pushed=pushed)
+ except (PRSplitError, GitOperationError):
+ _restore({b: sha for b, sha in originals.items() if b not in pushed})
+ raise
+
+
+def _restack_layers(
+ records: list[BranchRecord],
+ layers: list[tuple[str, str, str, str]],
+ *,
+ dry_run: bool,
+ pushed: set[str],
+) -> list[LayerResult]:
+ all_branches = [r.branch_name for r in records]
+ if not dry_run:
+ busy = _checked_out_branches() & {branch for _, branch, _, _ in layers}
+ if busy:
+ raise PRSplitError(ErrorMsg.RESTACK_CHECKED_OUT(branches=", ".join(sorted(busy))))
+ for branch in all_branches:
+ if commit_exists(f"refs/heads/{branch}") or commit_exists(_remote_ref(branch)):
+ _sync_with_remote(branch)
+
+ results: dict[str, LayerResult] = {}
+ old_heads: dict[str, str] = {}
+ for gid, branch, parent, parent_ref in layers:
+ child_head = _rev(f"refs/heads/{branch}")
+ parent_head = _rev(parent_ref)
+ if child_head is None or parent_head is None:
+ missing = branch if child_head is None else parent
+ results[gid] = LayerResult(gid, branch, f"skipped: branch '{missing}' not found")
+ continue
+ if _is_ancestor(parent_head, child_head):
+ results[gid] = LayerResult(gid, branch, "up to date")
+ continue
+ if dry_run:
+ results[gid] = LayerResult(gid, branch, f"would restack onto {parent}")
+ continue
+ # The child was cut from the parent's head before the parent was
+ # rebased in this run, or from the point where the two diverge.
+ upstream = old_heads.get(parent) or run_git("merge-base", child_head, parent_head)
+ new_head = _rebase(branch, parent_ref, upstream)
+ run_git("update-ref", f"refs/heads/{branch}", new_head, child_head)
+ old_heads[branch] = child_head
+ logger.info(logs.RESTACKED_LAYER.format(branch=branch, parent=parent))
+ results[gid] = LayerResult(gid, branch, "restacked")
+
+ if not dry_run:
+ # Push parents before children: a pushed fix on a layer that needed no
+ # rebase must reach GitHub too, or the child's diff shows it.
+ for record in records:
+ branch = record.branch_name
+ local, remote = _rev(f"refs/heads/{branch}"), _rev(_remote_ref(branch))
+ if local is None or local == remote:
+ continue
+ push_branch(branch)
+ pushed.add(branch)
+ result = results.get(record.group_id)
+ if result is None or result.action == "up to date":
+ results[record.group_id] = LayerResult(record.group_id, branch, "pushed")
+
+ return [results[r.group_id] for r in records if r.group_id in results]
diff --git a/pr_split/schemas.py b/pr_split/schemas.py
index c68774b..ec804cc 100644
--- a/pr_split/schemas.py
+++ b/pr_split/schemas.py
@@ -12,6 +12,18 @@ class GroupAssignment(BaseModel):
assignment_type: AssignmentType
hunk_indices: list[int] = Field(default_factory=list)
+ def covered_indices(self, hunk_count: int) -> list[int]:
+ """Hunk indices this assignment claims for a file with ``hunk_count`` hunks.
+
+ A WHOLE_FILE assignment covers every hunk of the parsed file, no more
+ and no less, whatever ``hunk_indices`` says (it may be empty, partial,
+ or carry a stale index that no longer exists); PARTIAL_HUNKS covers
+ exactly what it lists.
+ """
+ if self.assignment_type is AssignmentType.WHOLE_FILE:
+ return list(range(hunk_count))
+ return list(self.hunk_indices)
+
class Group(BaseModel):
id: str
@@ -42,6 +54,9 @@ class SplitPlan(BaseModel):
max_loc: int
strict_loc_bounds: bool = False
stacked: bool = False
+ # --max-loc when the split cut oversized new files into pieces (stacked
+ # splits only); raw_diff must be re-parsed with it to match the hunk indices.
+ split_new_files_over: int | None = None
draft: bool = False
priority: Priority
groups: list[Group] = Field(default_factory=list)
@@ -49,6 +64,14 @@ class SplitPlan(BaseModel):
merge_base_sha: str | None = None
dev_branch_arg: str | None = None
raw_diff: str | None = None
+ # How the plan was produced, so a saved plan carries its provenance. The
+ # llm and cp_sat backends can return a different plan on each run.
+ partition_strategy: str | None = None
+ chunk_strategy: str | None = None
+ provider: str | None = None
+ model: str | None = None
+ # Groups whose LOC exceeded max_loc when the plan was saved.
+ oversized_groups: list[str] = Field(default_factory=list)
class BranchRecord(BaseModel):
@@ -56,6 +79,8 @@ class BranchRecord(BaseModel):
branch_name: str
base_branch: str
commit_sha: str = ""
+ # Registered by `adopt` rather than created by pr-split: clean leaves it alone.
+ adopted: bool = False
class PRRecord(BaseModel):
@@ -63,6 +88,8 @@ class PRRecord(BaseModel):
pr_number: int
pr_url: str
state: PRState = PRState.OPEN
+ # Registered by `adopt` rather than opened by pr-split: clean leaves it open.
+ adopted: bool = False
class GitState(BaseModel):
diff --git a/pr_split/stack_move.py b/pr_split/stack_move.py
new file mode 100644
index 0000000..95b7be4
--- /dev/null
+++ b/pr_split/stack_move.py
@@ -0,0 +1,162 @@
+"""Move one hunk between layers of an executed stack, keeping every PR open.
+
+The hunk is reverted on the source layer and the revert is carried up the
+stack by a restack, so the layers in between lose it too. The hunk is then
+applied again on the target layer and the layers above it are restacked.
+Only branches whose content changed are pushed, so no PR is closed and no
+review thread is lost. Each step patches the layer's current content with
+``git apply``, so changes that reached a layer by other routes, such as a
+base merge, are left as they are.
+"""
+
+from __future__ import annotations
+
+import tempfile
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from loguru import logger
+
+from . import logs
+from .constants import AssignmentType
+from .diff_ops import parse_diff
+from .exceptions import ErrorMsg, GitOperationError, PRSplitError
+from .git_ops import remove_worktree
+from .git_ops.branches import run_git, run_git_in_dir
+from .graph import PlanDAG
+from .planner.partitioning import refresh_generated_description
+from .restack import LayerResult, restack
+from .schemas import GroupAssignment
+
+if TYPE_CHECKING:
+ from unidiff import PatchedFile
+
+ from .schemas import Group, PlanFile
+
+
+def _hunk_patch(patch_file: PatchedFile, index: int) -> str:
+ path = patch_file.path
+ if patch_file.is_added_file:
+ header = (
+ f"diff --git a/{path} b/{path}\nnew file mode 100644\n--- /dev/null\n+++ b/{path}\n"
+ )
+ else:
+ header = f"diff --git a/{path} b/{path}\n--- a/{path}\n+++ b/{path}\n"
+ return header + str(patch_file[index])
+
+
+def _commit_patch(branch: str, patch: str, *, reverse: bool, message: str) -> None:
+ """Apply the patch to the layer's current tip and commit it to the branch."""
+ worktree = tempfile.mkdtemp(prefix="pr-split-move-")
+ run_git("worktree", "add", worktree, branch)
+ try:
+ patch_path = Path(worktree).parent / f"{Path(worktree).name}.patch"
+ patch_path.write_text(patch, encoding="utf-8", errors="surrogateescape")
+ try:
+ args = ["apply", "--index", *(["--reverse"] if reverse else []), str(patch_path)]
+ run_git_in_dir(worktree, *args)
+ except GitOperationError as exc:
+ raise PRSplitError(
+ ErrorMsg.MOVE_HUNK_DOES_NOT_APPLY(branch=branch, detail=exc)
+ ) from exc
+ finally:
+ patch_path.unlink(missing_ok=True)
+ run_git_in_dir(
+ worktree, "-c", "core.hooksPath=/dev/null", "commit", "--no-verify", "-m", message
+ )
+ finally:
+ remove_worktree(worktree)
+
+
+def _move_in_plan(
+ groups: list[Group], path: str, index: int, source: Group, target: Group, hunk_count: int
+) -> None:
+ for assignment in list(source.assignments):
+ if assignment.file_path != path:
+ continue
+ covered = assignment.covered_indices(hunk_count)
+ assignment.assignment_type = AssignmentType.PARTIAL_HUNKS
+ assignment.hunk_indices = [i for i in covered if i != index]
+ if not assignment.hunk_indices:
+ source.assignments.remove(assignment)
+ existing = next((a for a in target.assignments if a.file_path == path), None)
+ if existing is None:
+ target.assignments.append(
+ GroupAssignment(
+ file_path=path, assignment_type=AssignmentType.PARTIAL_HUNKS, hunk_indices=[index]
+ )
+ )
+ elif index not in existing.covered_indices(hunk_count):
+ existing.hunk_indices = sorted({*existing.hunk_indices, index})
+ for group in (source, target):
+ refresh_generated_description(group)
+
+
+def move_hunk(
+ plan_file: PlanFile, path: str, index: int, source_id: str, target_id: str
+) -> list[LayerResult]:
+ plan = plan_file.plan
+ if not plan.stacked:
+ raise PRSplitError(ErrorMsg.RESTACK_NOT_STACKED())
+ if plan.raw_diff is None:
+ raise PRSplitError(ErrorMsg.MOVE_NO_DIFF())
+ groups = {g.id: g for g in plan.groups}
+ branches = {r.group_id: r.branch_name for r in plan_file.git_state.branches}
+ for gid in (source_id, target_id):
+ if gid not in groups or gid not in branches:
+ raise PRSplitError(ErrorMsg.MOVE_UNKNOWN_LAYER(group=gid))
+ dag = PlanDAG(plan.groups)
+ if source_id not in dag.ancestors(target_id):
+ raise PRSplitError(ErrorMsg.MOVE_NOT_UPWARD(source=source_id, target=target_id))
+ # restack only rebuilds single-parent layers, so the path must be a chain.
+ node = target_id
+ while node != source_id:
+ parents = dag.parents(node)
+ if len(parents) != 1:
+ raise PRSplitError(ErrorMsg.MOVE_NOT_A_CHAIN(group=node))
+ node = parents[0]
+
+ parsed = parse_diff(plan.raw_diff, split_new_files_over=plan.split_new_files_over)
+ patch_file = next((pf for pf in parsed.patch_set if pf.path == path), None)
+ if patch_file is None or not 0 <= index < len(patch_file):
+ raise PRSplitError(ErrorMsg.MOVE_UNKNOWN_HUNK(file=path, index=index))
+ if patch_file.is_removed_file or (patch_file.is_added_file and len(patch_file) > 1):
+ raise PRSplitError(ErrorMsg.MOVE_UNSUPPORTED_FILE(file=path))
+ held = {
+ i
+ for a in groups[source_id].assignments
+ if a.file_path == path
+ for i in a.covered_indices(len(patch_file))
+ }
+ if index not in held:
+ raise PRSplitError(
+ ErrorMsg.MOVE_HUNK_NOT_IN_LAYER(file=path, index=index, group=source_id)
+ )
+
+ patch = _hunk_patch(patch_file, index)
+ _commit_patch(
+ branches[source_id],
+ patch,
+ reverse=True,
+ message=f"Move {path} hunk {index} up to {target_id}",
+ )
+ logger.info(logs.MOVE_REVERTED.format(file=path, index=index, branch=branches[source_id]))
+ first = restack(plan_file)
+ _commit_patch(
+ branches[target_id],
+ patch,
+ reverse=False,
+ message=f"Take {path} hunk {index} from {source_id}",
+ )
+ logger.info(logs.MOVE_APPLIED.format(file=path, index=index, branch=branches[target_id]))
+ second = restack(plan_file)
+
+ _move_in_plan(plan.groups, path, index, groups[source_id], groups[target_id], len(patch_file))
+ # Report each layer once, with its most significant outcome.
+ rank = {"restacked": 2, "pushed": 1}
+ merged: dict[str, LayerResult] = {}
+ for result in [*first, *second]:
+ current = merged.get(result.group_id)
+ if current is None or rank.get(result.action, 0) > rank.get(current.action, 0):
+ merged[result.group_id] = result
+ return [merged[r.group_id] for r in plan_file.git_state.branches if r.group_id in merged]
diff --git a/pyproject.toml b/pyproject.toml
index a37743f..2cf8376 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,6 +15,7 @@ dependencies = [
]
[project.optional-dependencies]
+cp-sat = ["ortools>=9.15.6755"]
dev = ["ruff>=0.9.0"]
test = [
"ortools>=9.15.6755",
@@ -37,6 +38,8 @@ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
[tool.pytest.ini_options]
testpaths = ["tests"]
+# Keeps split from fetching the base branch's upstream during tests.
+addopts = "-p tests.network_guards"
[dependency-groups]
dev = [
diff --git a/scripts/score_pr.py b/scripts/score_pr.py
index 7533fe9..f415751 100644
--- a/scripts/score_pr.py
+++ b/scripts/score_pr.py
@@ -31,10 +31,60 @@ def _skip(reason: str) -> None:
_set_output("should_split", "false")
+def _write_comment(lines: list[str]) -> None:
+ tmp_dir = os.environ.get("RUNNER_TEMP", tempfile.gettempdir())
+ comment_path = Path(tmp_dir) / "pr-split-comment.md"
+ comment_path.write_text("\n".join(lines))
+ _set_output("comment_path", str(comment_path))
+
+
+def _oversized_without_plan(
+ reason: str, total_loc: int, file_count: int, max_loc: int, total_groups: int = 0
+) -> None:
+ """Flag an oversized PR even though no usable split plan was produced.
+
+ The size verdict comes from ``total_loc`` alone; a failing or degenerate
+ planner must not make an oversized PR look acceptable. ``reason`` finishes
+ the sentence "..., but ..." explaining why no split is suggested.
+ """
+ print(f"PR has {total_loc} LOC, over the {max_loc} limit; {reason}")
+ _set_output("total_groups", str(total_groups))
+ _set_output("objective", "0")
+ _set_output("should_split", "true")
+ _write_comment(
+ [
+ "",
+ "## pr-split analysis",
+ "",
+ "| Metric | Value |",
+ "|--------|-------|",
+ f"| Total LOC | {total_loc:,} |",
+ f"| Files changed | {file_count} |",
+ f"| Limit | {max_loc:,} LOC |",
+ "",
+ f"This PR has **{total_loc:,} LOC**, over the **{max_loc:,} LOC** limit, but {reason}",
+ "",
+ "*Run `pr-split split` locally to plan the split.*",
+ ]
+ )
+
+
def _md_escape(s: str) -> str:
return s.replace("|", "\\|")
+def load_plan_groups(plan_path: str) -> list[dict]:
+ """Return the groups from a saved plan file.
+
+ ``pr-split --dry-run`` writes a ``PlanFile`` whose top-level keys are
+ ``plan`` and ``git_state``; the groups live under ``plan``.
+ """
+ with open(plan_path) as f:
+ data = json.load(f)
+ plan = data.get("plan", data)
+ return plan.get("groups", [])
+
+
def _parse_int_env(name: str, default: int) -> int:
raw = os.environ.get(name, str(default))
try:
@@ -51,8 +101,14 @@ def main() -> None:
priority = os.environ.get("PRIORITY", "orthogonal")
threshold = _parse_int_env("THRESHOLD_GROUPS", 2)
pr_number = os.environ.get("PR_NUMBER", "")
- base_branch = os.environ["BASE_BRANCH"]
- head_branch = os.environ["HEAD_BRANCH"]
+ base_branch = os.environ.get("BASE_BRANCH", "")
+ head_branch = os.environ.get("HEAD_BRANCH", "")
+
+ if not base_branch or not head_branch:
+ # github.event.pull_request.* is empty on push / workflow_dispatch / schedule.
+ _set_output("total_loc", "0")
+ _skip("Not a pull_request event (no base/head branch); skipping pr-split score.")
+ return
# Fetch refs — use refs/pull/{n}/head for fork compatibility
_run(["git", "fetch", "origin", base_branch])
@@ -111,19 +167,36 @@ def main() -> None:
result = subprocess.run(cmd, capture_output=True, text=True, input="done\n")
if result.returncode != 0:
print(f"pr-split failed:\n{result.stderr}", file=sys.stderr)
- _skip("pr-split failed to generate a plan.")
+ _oversized_without_plan(
+ "no split plan could be generated: pr-split exited with an error.",
+ total_loc,
+ file_count,
+ max_loc,
+ )
return
plan_path = ".pr-split/plan.json"
if not os.path.exists(plan_path):
- _skip("No plan file generated.")
+ _oversized_without_plan(
+ "no split plan could be generated: pr-split wrote no plan file.",
+ total_loc,
+ file_count,
+ max_loc,
+ )
return
- with open(plan_path) as f:
- plan = json.load(f)
-
- groups = plan.get("groups", [])
+ groups = load_plan_groups(plan_path)
total_groups = len(groups)
+ if total_groups < threshold:
+ _oversized_without_plan(
+ f"the plan has {total_groups} group(s), below the {threshold} needed "
+ "to suggest a split.",
+ total_loc,
+ file_count,
+ max_loc,
+ total_groups,
+ )
+ return
max_group_loc = max((g["estimated_loc"] for g in groups), default=0)
overflow = sum(max(0, g["estimated_loc"] - max_loc) for g in groups)
@@ -134,15 +207,13 @@ def main() -> None:
file_scatter = sum(max(0, len(gids) - 1) for gids in file_groups.values())
objective = overflow * 1000 + file_scatter * 50 + total_groups
- should_split = total_groups >= threshold
-
_set_output("total_groups", str(total_groups))
_set_output("objective", str(objective))
- _set_output("should_split", str(should_split).lower())
+ _set_output("should_split", "true")
print(f"PR: {total_loc} LOC across {file_count} files")
print(f"Split plan: {total_groups} groups, objective={objective}")
- print(f"Should split: {should_split}")
+ print("Should split: True")
# Generate markdown comment
lines = [
@@ -160,34 +231,27 @@ def main() -> None:
"",
]
- if should_split:
- lines.append(
- f"This PR has **{total_loc:,} LOC** and could be split into "
- f"**{total_groups} smaller PRs**:"
- )
- lines.append("")
- lines.append("| Group | Title | Diff | Depends On | Files |")
- lines.append("|-------|-------|------|------------|-------|")
- for g in groups:
- files = ", ".join(f"`{_md_escape(a['file_path'])}`" for a in g.get("assignments", []))
- deps = ", ".join(g.get("depends_on", [])) or "—"
- diff_str = f"+{g.get('estimated_added', 0)}/-{g.get('estimated_removed', 0)}"
- title = _md_escape(g["title"])
- gid = _md_escape(g["id"])
- lines.append(f"| {gid} | {title} | {diff_str} | {deps} | {files} |")
- lines.append("")
- lines.append(
- "*Run `pr-split split` locally to create these sub-PRs, "
- "or adjust `--max-loc` to change the target size.*"
- )
- else:
- lines.append("This PR is within acceptable size limits.")
-
- comment = "\n".join(lines)
- tmp_dir = os.environ.get("RUNNER_TEMP", tempfile.gettempdir())
- comment_path = Path(tmp_dir) / "pr-split-comment.md"
- comment_path.write_text(comment)
- _set_output("comment_path", str(comment_path))
+ lines.append(
+ f"This PR has **{total_loc:,} LOC** and could be split into "
+ f"**{total_groups} smaller PRs**:"
+ )
+ lines.append("")
+ lines.append("| Group | Title | Diff | Depends On | Files |")
+ lines.append("|-------|-------|------|------------|-------|")
+ for g in groups:
+ files = ", ".join(f"`{_md_escape(a['file_path'])}`" for a in g.get("assignments", []))
+ deps = ", ".join(g.get("depends_on", [])) or "—"
+ diff_str = f"+{g.get('estimated_added', 0)}/-{g.get('estimated_removed', 0)}"
+ title = _md_escape(g["title"])
+ gid = _md_escape(g["id"])
+ lines.append(f"| {gid} | {title} | {diff_str} | {deps} | {files} |")
+ lines.append("")
+ lines.append(
+ "*Run `pr-split split` locally to create these sub-PRs, "
+ "or adjust `--max-loc` to change the target size.*"
+ )
+
+ _write_comment(lines)
if __name__ == "__main__":
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..02acbe4
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,10 @@
+from __future__ import annotations
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def _no_live_base_merge_lookup(monkeypatch: pytest.MonkeyPatch) -> None:
+ """status and merge ask GitHub whether the plan's base branch has merged;
+ keep that off the network in tests. Tests that exercise it patch it again."""
+ monkeypatch.setattr("pr_split.cli._base_has_merged", lambda base: False)
diff --git a/tests/network_guards.py b/tests/network_guards.py
new file mode 100644
index 0000000..15df9ca
--- /dev/null
+++ b/tests/network_guards.py
@@ -0,0 +1,23 @@
+from __future__ import annotations
+
+from collections.abc import Iterator
+from unittest.mock import patch
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def _no_base_fetch() -> Iterator[None]:
+ """Keep CLI tests off the network: split would fetch the base's upstream."""
+ with patch("pr_split.cli.diff_base_ref", side_effect=lambda base: base):
+ yield
+
+
+@pytest.fixture(autouse=True)
+def _no_selected_plan() -> Iterator[None]:
+ """A command's plan selection is process-wide; start each test without one."""
+ from pr_split.plan_store import select_plan
+
+ select_plan(None)
+ yield
+ select_plan(None)
diff --git a/tests/test_chunker.py b/tests/test_chunker.py
index 8af1b00..4b1b461 100644
--- a/tests/test_chunker.py
+++ b/tests/test_chunker.py
@@ -4,6 +4,7 @@
from pr_split.constants import AssignmentType, ChunkStrategy
from pr_split.diff_ops.parser import parse_diff
+from pr_split.exceptions import PlanValidationError, PRSplitError
from pr_split.planner.chunker import (
assign_uncovered_hunks,
build_chunk_diff_from_hunks,
@@ -120,7 +121,7 @@ def test_splits_when_budget_exceeded(self) -> None:
def test_hunk_exceeding_budget_raises(self) -> None:
refs = [HunkRef("a.py", 0, 500)]
- with pytest.raises(ValueError, match="exceeds budget"):
+ with pytest.raises(PRSplitError, match="exceeds budget"):
chunk_hunks(refs, 100)
def test_empty_input_returns_empty(self) -> None:
@@ -145,7 +146,7 @@ def test_strategy_override_uses_greedy(self) -> None:
def test_unknown_strategy_raises(self) -> None:
refs = [HunkRef("a.py", 0, 100)]
- with pytest.raises(ValueError, match="Unknown chunk strategy"):
+ with pytest.raises(PRSplitError, match="Unknown chunk strategy"):
chunk_hunks(refs, 100, "invalid")
@@ -223,6 +224,15 @@ def test_recomputes_from_diff(self) -> None:
assert groups[0].estimated_added == 3
assert groups[0].estimated_removed == 0
+ def test_whole_file_with_empty_indices_counts_every_hunk(self) -> None:
+ parsed = parse_diff(TWO_HUNK_DIFF)
+ whole = GroupAssignment(
+ file_path="c.py", assignment_type=AssignmentType.WHOLE_FILE, hunk_indices=[]
+ )
+ groups = [_group("g1", [whole])]
+ recompute_estimated_loc(groups, parsed)
+ assert groups[0].estimated_loc == parsed.stats["total_loc"]
+
def test_invalid_hunk_index_skipped(self) -> None:
parsed = parse_diff(SAMPLE_DIFF)
groups = [_group("g1", [_ga("a.py", [0, 99])])]
@@ -250,6 +260,20 @@ def test_all_covered_returns_zero(self) -> None:
count = assign_uncovered_hunks(groups, parsed)
assert count == 0
+ def test_whole_file_with_empty_indices_is_fully_covered(self) -> None:
+ parsed = parse_diff(TWO_HUNK_DIFF)
+ whole = GroupAssignment(
+ file_path="c.py", assignment_type=AssignmentType.WHOLE_FILE, hunk_indices=[]
+ )
+ groups = [_group("g1", [whole])]
+ assert assign_uncovered_hunks(groups, parsed) == 0
+ assert groups[0].assignments == [whole]
+
+ def test_zero_groups_raises_validation_error(self) -> None:
+ parsed = parse_diff(SAMPLE_DIFF)
+ with pytest.raises(PlanValidationError, match=r"a\.py\[0\] not assigned"):
+ assign_uncovered_hunks([], parsed)
+
def test_uncovered_assigned_to_file_group(self) -> None:
parsed = parse_diff(SAMPLE_DIFF)
groups = [_group("g1", [_ga("a.py", [0])])]
@@ -330,3 +354,11 @@ def test_includes_file_paths(self) -> None:
def test_empty_groups(self) -> None:
result = format_group_catalog([])
assert result == ""
+
+
+class TestChunkerErrorsAreProjectErrors:
+ def test_oversized_hunk_is_pr_split_error_for_both_strategies(self) -> None:
+ refs = [HunkRef("a.py", 0, 500)]
+ for strategy in (ChunkStrategy.GREEDY, ChunkStrategy.DYNAMIC_PROGRAMMING):
+ with pytest.raises(PRSplitError, match=r"a\.py\[0\].*exceeds budget 100"):
+ chunk_hunks(refs, 100, strategy)
diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py
index 8d05885..cb5bfe0 100644
--- a/tests/test_cli_coverage.py
+++ b/tests/test_cli_coverage.py
@@ -7,6 +7,7 @@
from __future__ import annotations
+import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -16,7 +17,9 @@
from pr_split.cli import (
_build_pr_body,
+ _drop_empty_groups,
_handle_loc_bound_warnings,
+ _interactive_edit,
_move_assignment,
_present_plan,
_resolve_fork_ref,
@@ -25,9 +28,19 @@
_validate_inputs,
app,
)
-from pr_split.constants import AssignmentType
-from pr_split.exceptions import PRSplitError
-from pr_split.schemas import Group, GroupAssignment
+from pr_split.constants import AssignmentType, Priority
+from pr_split.diff_ops.parser import parse_diff
+from pr_split.exceptions import GitOperationError, PRCreationError, PRSplitError
+from pr_split.graph import PlanDAG
+from pr_split.schemas import (
+ BranchRecord,
+ GitState,
+ Group,
+ GroupAssignment,
+ PlanFile,
+ PRRecord,
+ SplitPlan,
+)
from pr_split.types_defs import ForkPRInfo
runner = CliRunner()
@@ -69,11 +82,128 @@ def test_all_ok_no_exit(
) -> None:
_validate_inputs("feature", "main")
+ @patch("pr_split.cli.check_gh_stack", return_value=False)
+ @patch("pr_split.cli.is_worktree_clean", return_value=True)
+ @patch("pr_split.cli.check_gh_auth", return_value=True)
+ @patch("pr_split.cli.branch_exists", return_value=True)
+ def test_stacked_requires_gh_stack(
+ self,
+ mock_be: MagicMock,
+ mock_auth: MagicMock,
+ mock_clean: MagicMock,
+ mock_stack: MagicMock,
+ ) -> None:
+ with pytest.raises(typer.Exit):
+ _validate_inputs("feature", "main", stacked=True)
+
+ @patch("pr_split.cli.check_gh_stack", side_effect=GitOperationError("auth rejected"))
+ @patch("pr_split.cli.is_worktree_clean", return_value=True)
+ @patch("pr_split.cli.check_gh_auth", return_value=True)
+ @patch("pr_split.cli.branch_exists", return_value=True)
+ def test_stacked_gh_failure_reports_real_error(
+ self,
+ mock_be: MagicMock,
+ mock_auth: MagicMock,
+ mock_clean: MagicMock,
+ mock_stack: MagicMock,
+ capsys: pytest.CaptureFixture[str],
+ ) -> None:
+ with pytest.raises(typer.Exit):
+ _validate_inputs("feature", "main", stacked=True)
+ out = capsys.readouterr().out
+ assert "auth rejected" in out
+ assert "gh extension install" not in out
+
+ @patch("pr_split.cli.check_gh_stack", return_value=False)
+ @patch("pr_split.cli.is_worktree_clean", return_value=True)
+ @patch("pr_split.cli.check_gh_auth", return_value=True)
+ @patch("pr_split.cli.branch_exists", return_value=True)
+ def test_stacked_dry_run_skips_gh_stack_check(
+ self,
+ mock_be: MagicMock,
+ mock_auth: MagicMock,
+ mock_clean: MagicMock,
+ mock_stack: MagicMock,
+ ) -> None:
+ _validate_inputs("feature", "main", dry_run=True, stacked=True)
+ mock_stack.assert_not_called()
+
+ @patch("pr_split.cli.check_gh_stack", return_value=False)
+ @patch("pr_split.cli.is_worktree_clean", return_value=True)
+ @patch("pr_split.cli.check_gh_auth", return_value=True)
+ @patch("pr_split.cli.branch_exists", return_value=True)
+ def test_unstacked_skips_gh_stack_check(
+ self,
+ mock_be: MagicMock,
+ mock_auth: MagicMock,
+ mock_clean: MagicMock,
+ mock_stack: MagicMock,
+ ) -> None:
+ _validate_inputs("feature", "main")
+ mock_stack.assert_not_called()
+
@patch("pr_split.cli.branch_exists", side_effect=[False])
def test_dev_branch_missing(self, mock_be: MagicMock) -> None:
with pytest.raises(typer.Exit):
_validate_inputs("no-such-branch", "main")
+ @patch("pr_split.cli.is_worktree_clean", return_value=True)
+ @patch("pr_split.cli.check_gh_auth", return_value=True)
+ @patch("pr_split.cli.branch_exists")
+ def test_remote_tracking_base_is_rejected(
+ self, mock_be: MagicMock, mock_auth: MagicMock, mock_clean: MagicMock
+ ) -> None:
+ from pr_split.cli import console
+
+ # origin/main resolves as a ref but is not a local branch head.
+ mock_be.side_effect = lambda ref: ref != "refs/heads/origin/main"
+ with (
+ patch("pr_split.cli._remote_names", return_value={"origin"}),
+ console.capture() as capture,
+ pytest.raises(typer.Exit),
+ ):
+ _validate_inputs("feature", "origin/main")
+ out = " ".join(capture.get().split())
+ assert "Base 'origin/main' is not a local branch" in out
+ assert "for example 'main'" in out
+ mock_clean.assert_not_called()
+
+ @patch("pr_split.cli._remote_names", return_value={"origin"})
+ @patch("pr_split.cli.branch_exists")
+ def test_tag_or_sha_base_gets_no_bogus_suggestion(
+ self, mock_be: MagicMock, mock_remotes: MagicMock
+ ) -> None:
+ from pr_split.cli import console
+
+ mock_be.side_effect = lambda ref: not ref.startswith("refs/heads/")
+ for base in ("v1", "abc123", "feature/topic"):
+ with console.capture() as capture, pytest.raises(typer.Exit):
+ _validate_inputs("feature", base)
+ out = " ".join(capture.get().split())
+ assert f"Base '{base}' is not a local branch" in out
+ assert "for example" not in out
+
+ @patch("pr_split.cli._remote_names", return_value={"origin"})
+ @patch("pr_split.cli.branch_exists")
+ def test_full_remote_ref_suggests_the_branch_name(
+ self, mock_be: MagicMock, mock_remotes: MagicMock
+ ) -> None:
+ from pr_split.cli import console
+
+ mock_be.side_effect = lambda ref: not ref.startswith("refs/heads/")
+ with console.capture() as capture, pytest.raises(typer.Exit):
+ _validate_inputs("feature", "refs/remotes/origin/main")
+ assert "for example 'main'" in " ".join(capture.get().split())
+
+ @patch("pr_split.cli.is_worktree_clean", return_value=True)
+ @patch("pr_split.cli.check_gh_auth", return_value=True)
+ @patch("pr_split.cli.branch_exists", return_value=True)
+ def test_local_base_checks_the_branch_head(
+ self, mock_be: MagicMock, mock_auth: MagicMock, mock_clean: MagicMock
+ ) -> None:
+ _validate_inputs("feature", "main")
+ assert ("refs/heads/main",) in [c.args for c in mock_be.call_args_list]
+
@patch("pr_split.cli.branch_exists", side_effect=[True, False])
def test_base_branch_missing(self, mock_be: MagicMock) -> None:
with pytest.raises(typer.Exit):
@@ -203,7 +333,7 @@ def test_template_os_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
mock_path = MagicMock()
mock_path.exists.return_value = True
mock_path.read_text.side_effect = OSError("Permission denied")
- monkeypatch.setattr("pr_split.cli._PR_TEMPLATE_PATH", mock_path)
+ monkeypatch.setattr("pr_split.cli._pr_template_path", lambda: mock_path)
group = _group("pr-1", "t", files=["a.py"])
with pytest.raises(PRSplitError, match="Could not read PR template"):
@@ -261,6 +391,78 @@ def test_partial_hunks_rendering(self) -> None:
_show_group_detail([g], "pr-1")
+# ---------------------------------------------------------------------------
+# _interactive_edit
+# ---------------------------------------------------------------------------
+class TestInteractiveEditRecomputesLoc:
+ @patch("pr_split.cli.recompute_estimated_loc")
+ @patch("pr_split.cli._move_assignment", return_value=True)
+ @patch("pr_split.cli.typer.prompt", side_effect=["move a.py:0 pr-1 pr-2", "done"])
+ def test_successful_move_recomputes_loc(
+ self, mock_prompt: MagicMock, mock_move: MagicMock, mock_recompute: MagicMock
+ ) -> None:
+ groups = [_group("pr-1", "a", files=["a.py"]), _group("pr-2", "b")]
+ parsed = MagicMock()
+ _interactive_edit(groups, parsed)
+ mock_recompute.assert_called_once_with(groups, parsed)
+
+ @patch("pr_split.cli.recompute_estimated_loc")
+ @patch("pr_split.cli._move_assignment", return_value=False)
+ @patch("pr_split.cli.typer.prompt", side_effect=["move a.py:9 pr-1 pr-2", "done"])
+ def test_failed_move_does_not_recompute(
+ self, mock_prompt: MagicMock, mock_move: MagicMock, mock_recompute: MagicMock
+ ) -> None:
+ _interactive_edit([_group("pr-1", "a", files=["a.py"])], MagicMock())
+ mock_recompute.assert_not_called()
+
+ @patch("pr_split.cli.typer.prompt", side_effect=["move a.py:0 pr-1 pr-2", "done"])
+ def test_destination_keeps_whole_file_loc_after_move(self, mock_prompt: MagicMock) -> None:
+ diff = (
+ "diff --git a/a.py b/a.py\n--- a/a.py\n+++ b/a.py\n"
+ "@@ -1,1 +1,3 @@\n x\n+a1\n+a2\n"
+ "@@ -20,1 +22,2 @@\n y\n+a3\n"
+ "diff --git a/b.py b/b.py\n--- a/b.py\n+++ b/b.py\n"
+ "@@ -1,2 +1,4 @@\n-b0\n+b1\n+b2\n+b3\n z\n"
+ )
+ parsed = parse_diff(diff)
+ groups = [
+ Group(
+ id="pr-1",
+ title="a",
+ description="a",
+ assignments=[
+ GroupAssignment(
+ file_path="a.py",
+ assignment_type=AssignmentType.WHOLE_FILE,
+ hunk_indices=[0, 1],
+ )
+ ],
+ estimated_loc=3,
+ ),
+ Group(
+ id="pr-2",
+ title="b",
+ description="b",
+ assignments=[
+ GroupAssignment(
+ file_path="b.py",
+ assignment_type=AssignmentType.WHOLE_FILE,
+ hunk_indices=[],
+ )
+ ],
+ estimated_loc=4,
+ ),
+ ]
+
+ result = _interactive_edit(groups, parsed)
+
+ by_id = {g.id: g for g in result}
+ assert by_id["pr-1"].estimated_loc == 1
+ assert by_id["pr-2"].estimated_loc == 6
+ assert by_id["pr-2"].estimated_added == 5
+ assert by_id["pr-2"].estimated_removed == 1
+
+
# ---------------------------------------------------------------------------
# _move_assignment
# ---------------------------------------------------------------------------
@@ -464,6 +666,23 @@ def test_split_fork_ref_dirty_worktree(
result = runner.invoke(app, ["split", "#42", "--dry-run"])
assert result.exit_code != 0
+ @patch("pr_split.cli._resolve_fork_ref", side_effect=GitOperationError("PR #999 not found"))
+ @patch("pr_split.cli.is_worktree_clean", return_value=True)
+ @patch("pr_split.cli.check_gh_auth", return_value=True)
+ @patch("pr_split.cli.branch_exists", return_value=False)
+ def test_split_fork_ref_error_is_reported_not_raised(
+ self,
+ mock_be: MagicMock,
+ mock_auth: MagicMock,
+ mock_clean: MagicMock,
+ mock_resolve: MagicMock,
+ ) -> None:
+ result = runner.invoke(app, ["split", "#999", "--dry-run"])
+ assert result.exit_code == 1
+ assert result.exception is None or isinstance(result.exception, SystemExit)
+ assert "PR #999 not found" in result.output
+ assert "Traceback" not in result.output
+
@patch("pr_split.cli._resolve_fork_ref", return_value=None)
@patch("pr_split.cli.is_worktree_clean", return_value=True)
@patch("pr_split.cli.check_gh_auth", return_value=True)
@@ -490,13 +709,14 @@ def test_execute_no_plan(self, mock_pe: MagicMock) -> None:
@patch("pr_split.cli.load_plan")
@patch("pr_split.cli.plan_exists", return_value=True)
- def test_execute_already_has_git_state(self, mock_pe: MagicMock, mock_load: MagicMock) -> None:
+ def test_execute_already_has_prs(self, mock_pe: MagicMock, mock_load: MagicMock) -> None:
mock_plan_file = MagicMock()
mock_plan_file.git_state.branches = [MagicMock()]
- mock_plan_file.git_state.prs = []
+ mock_plan_file.git_state.prs = [MagicMock()]
mock_load.return_value = mock_plan_file
result = runner.invoke(app, ["execute"])
assert result.exit_code != 0
+ assert "already has PRs" in result.output
@patch("pr_split.cli.load_plan")
@patch("pr_split.cli.plan_exists", return_value=True)
@@ -509,6 +729,100 @@ def test_execute_missing_raw_diff(self, mock_pe: MagicMock, mock_load: MagicMock
result = runner.invoke(app, ["execute"])
assert result.exit_code != 0
+ @patch("pr_split.cli.commit_exists", return_value=False)
+ @patch("pr_split.cli.load_plan")
+ @patch("pr_split.cli.plan_exists", return_value=True)
+ def test_execute_unknown_merge_base_sha_is_a_clean_error(
+ self, mock_pe: MagicMock, mock_load: MagicMock, mock_commit: MagicMock
+ ) -> None:
+ mock_plan_file = MagicMock()
+ mock_plan_file.git_state.branches = []
+ mock_plan_file.git_state.prs = []
+ mock_plan_file.plan.raw_diff = "some diff"
+ mock_plan_file.plan.merge_base_sha = "0123456789abcdef"
+ mock_load.return_value = mock_plan_file
+ result = runner.invoke(app, ["execute"])
+ assert result.exit_code == 1
+ assert result.exception is None or isinstance(result.exception, SystemExit)
+ assert "merge base 0123456789abcdef is not in this repository" in result.output
+ mock_commit.assert_called_once_with("0123456789abcdef")
+
+ @patch(
+ "pr_split.cli._create_branches_and_commits",
+ side_effect=PRSplitError("3 branch(es) failed"),
+ )
+ @patch("pr_split.cli.typer.confirm", return_value=True)
+ @patch("pr_split.cli.validate_coverage")
+ @patch("pr_split.cli.parse_diff")
+ @patch("pr_split.cli.commit_exists", return_value=True)
+ @patch("pr_split.cli.check_gh_auth", return_value=True)
+ @patch("pr_split.cli.is_worktree_clean", return_value=True)
+ @patch("pr_split.cli.branch_exists", return_value=True)
+ @patch("pr_split.cli.load_plan")
+ @patch("pr_split.cli.plan_exists", return_value=True)
+ def test_execute_branch_creation_failure_is_a_clean_error(
+ self,
+ mock_pe: MagicMock,
+ mock_load: MagicMock,
+ mock_be: MagicMock,
+ mock_clean: MagicMock,
+ mock_auth: MagicMock,
+ mock_commit: MagicMock,
+ mock_parse: MagicMock,
+ mock_validate: MagicMock,
+ mock_confirm: MagicMock,
+ mock_create: MagicMock,
+ ) -> None:
+ mock_plan_file = MagicMock()
+ mock_plan_file.git_state.branches = []
+ mock_plan_file.git_state.prs = []
+ mock_plan_file.plan.raw_diff = "some diff"
+ mock_plan_file.plan.merge_base_sha = "abc123"
+ mock_plan_file.plan.stacked = False
+ mock_plan_file.plan.dev_branch_arg = "feature"
+ mock_plan_file.plan.dev_branch = "feature"
+ mock_plan_file.plan.base_branch = "main"
+ mock_plan_file.plan.max_loc = 400
+ mock_plan_file.plan.groups = [_group("pr-1", "t", files=["a.py"])]
+ mock_load.return_value = mock_plan_file
+ result = runner.invoke(app, ["execute"])
+ assert result.exit_code == 1
+ assert result.exception is None or isinstance(result.exception, SystemExit)
+ assert "3 branch(es) failed" in result.output
+
+ @patch("pr_split.cli.commit_exists", return_value=True)
+ @patch("pr_split.cli._remote_names", return_value={"origin"})
+ @patch("pr_split.cli._create_branches_and_commits")
+ @patch("pr_split.cli.typer.confirm", return_value=True)
+ @patch("pr_split.cli.is_worktree_clean", return_value=True)
+ @patch("pr_split.cli.branch_exists")
+ @patch("pr_split.cli.load_plan")
+ @patch("pr_split.cli.plan_exists", return_value=True)
+ def test_execute_rejects_a_remote_tracking_base_from_an_old_plan(
+ self,
+ mock_pe: MagicMock,
+ mock_load: MagicMock,
+ mock_be: MagicMock,
+ mock_clean: MagicMock,
+ mock_confirm: MagicMock,
+ mock_create: MagicMock,
+ mock_remotes: MagicMock,
+ mock_commit_exists: MagicMock,
+ ) -> None:
+ mock_be.side_effect = lambda ref: ref != "refs/heads/origin/main"
+ mock_plan_file = MagicMock()
+ mock_plan_file.git_state.branches = []
+ mock_plan_file.git_state.prs = []
+ mock_plan_file.plan.raw_diff = "some diff"
+ mock_plan_file.plan.merge_base_sha = "abc123"
+ mock_plan_file.plan.base_branch = "origin/main"
+ mock_load.return_value = mock_plan_file
+ result = runner.invoke(app, ["execute"])
+ assert result.exit_code == 1
+ assert "Base 'origin/main' is not a local branch" in " ".join(result.output.split())
+ mock_confirm.assert_not_called()
+ mock_create.assert_not_called()
+
@patch("pr_split.cli.load_plan")
@patch("pr_split.cli.plan_exists", return_value=True)
def test_execute_missing_merge_base_sha(
@@ -523,6 +837,76 @@ def test_execute_missing_merge_base_sha(
result = runner.invoke(app, ["execute"])
assert result.exit_code != 0
+ @patch("pr_split.cli.commit_exists", return_value=True)
+ @patch("pr_split.cli._create_branches_and_commits")
+ @patch("pr_split.cli.check_gh_auth", return_value=True)
+ @patch("pr_split.cli.is_worktree_clean", return_value=True)
+ @patch("pr_split.cli.branch_exists", return_value=True)
+ @patch("pr_split.cli.load_plan")
+ @patch("pr_split.cli.plan_exists", return_value=True)
+ def test_execute_rejects_saved_plan_with_binary_files(
+ self,
+ mock_pe: MagicMock,
+ mock_load: MagicMock,
+ mock_be: MagicMock,
+ mock_clean: MagicMock,
+ mock_auth: MagicMock,
+ mock_create: MagicMock,
+ mock_commit_exists: MagicMock,
+ ) -> None:
+ mock_plan_file = MagicMock()
+ mock_plan_file.git_state.branches = []
+ mock_plan_file.git_state.prs = []
+ plan = mock_plan_file.plan
+ plan.raw_diff = (
+ "diff --git a/img.png b/img.png\n"
+ "index 1111111..2222222 100644\n"
+ "Binary files a/img.png and b/img.png differ\n"
+ "diff --git a/a.py b/a.py\n--- a/a.py\n+++ b/a.py\n@@ -1 +1 @@\n-x\n+y\n"
+ )
+ plan.merge_base_sha = "abc123"
+ plan.base_branch = "main"
+ plan.stacked = False
+ plan.groups = [_group("pr-1", "a", files=["a.py"])]
+ mock_load.return_value = mock_plan_file
+ result = runner.invoke(app, ["execute"])
+ assert result.exit_code == 1
+ assert "binary files" in result.output
+ assert "img.png" in result.output
+ mock_create.assert_not_called()
+
+ @patch("pr_split.cli.commit_exists", return_value=True)
+ @patch("pr_split.cli._create_branches_and_commits")
+ @patch("pr_split.cli.check_gh_auth", return_value=True)
+ @patch("pr_split.cli.is_worktree_clean", return_value=True)
+ @patch("pr_split.cli.branch_exists", return_value=True)
+ @patch("pr_split.cli.load_plan")
+ @patch("pr_split.cli.plan_exists", return_value=True)
+ def test_execute_rejects_unknown_dependency_before_branch_creation(
+ self,
+ mock_pe: MagicMock,
+ mock_load: MagicMock,
+ mock_be: MagicMock,
+ mock_clean: MagicMock,
+ mock_auth: MagicMock,
+ mock_create: MagicMock,
+ mock_commit_exists: MagicMock,
+ ) -> None:
+ mock_plan_file = MagicMock()
+ mock_plan_file.git_state.branches = []
+ mock_plan_file.git_state.prs = []
+ plan = mock_plan_file.plan
+ plan.raw_diff = "diff --git a/a.py b/a.py\n--- a/a.py\n+++ b/a.py\n@@ -1 +1 @@\n-x\n+y\n"
+ plan.merge_base_sha = "abc123"
+ plan.base_branch = "main"
+ plan.stacked = False
+ plan.groups = [_group("pr-1", "a", files=["a.py"]), _group("pr-2", "b", ["ghost"])]
+ mock_load.return_value = mock_plan_file
+ result = runner.invoke(app, ["execute"])
+ assert result.exit_code == 1
+ assert "depends on unknown group 'ghost'" in result.output
+ mock_create.assert_not_called()
+
# ---------------------------------------------------------------------------
# status command
@@ -533,7 +917,1035 @@ def test_status_no_plan(self, mock_pe: MagicMock) -> None:
result = runner.invoke(app, ["status"])
assert result.exit_code == 0
+ def _plan_file(self) -> PlanFile:
+ plan = SplitPlan(
+ dev_branch="feature",
+ base_branch="main",
+ max_loc=400,
+ priority=Priority.ORTHOGONAL,
+ groups=[_group("pr-1", "one"), _group("pr-2", "two")],
+ )
+ git_state = GitState(
+ branches=[
+ BranchRecord(group_id="pr-1", branch_name="b1", base_branch="main"),
+ BranchRecord(group_id="pr-2", branch_name="b2", base_branch="main"),
+ ],
+ prs=[
+ PRRecord(group_id="pr-1", pr_number=7, pr_url="u"),
+ PRRecord(group_id="pr-2", pr_number=8, pr_url="u"),
+ ],
+ )
+ return PlanFile(plan=plan, git_state=git_state)
+
+ @patch("pr_split.cli.get_pr_state")
+ @patch("pr_split.cli.load_plan")
+ @patch("pr_split.cli.plan_exists", return_value=True)
+ def test_unfetchable_state_is_unknown_not_open(
+ self, mock_pe: MagicMock, mock_load: MagicMock, mock_state: MagicMock
+ ) -> None:
+ mock_load.return_value = self._plan_file()
+ mock_state.side_effect = lambda n: {} if n == 7 else {"state": "MERGED"}
+
+ result = runner.invoke(app, ["status"])
+
+ assert result.exit_code == 0
+ assert "UNKNOWN" in result.output
+ assert "MERGED" in result.output
+ assert "OPEN" not in result.output
+ assert "Could not fetch live state for 1 PR(s): #7" in result.output
+
+ @patch(
+ "pr_split.cli.get_pr_state", return_value={"state": "OPEN", "reviewDecision": "APPROVED"}
+ )
+ @patch("pr_split.cli.load_plan")
+ @patch("pr_split.cli.plan_exists", return_value=True)
+ def test_live_state_and_review_are_shown(
+ self, mock_pe: MagicMock, mock_load: MagicMock, mock_state: MagicMock
+ ) -> None:
+ mock_load.return_value = self._plan_file()
+ result = runner.invoke(app, ["status"])
+ assert result.exit_code == 0
+ assert "Approved" in result.output
+ assert "Could not fetch" not in result.output
+
@patch("pr_split.cli.plan_exists", return_value=False)
def test_clean_no_plan(self, mock_pe: MagicMock) -> None:
result = runner.invoke(app, ["clean"])
assert result.exit_code == 0
+
+
+class TestExecuteRetriesAfterFailedPush:
+ """Branches without PRs mean a failed push; execute must retry, not refuse.
+
+ Forcing 'clean' + re-plan would delete the plan and require paying for
+ LLM planning again after a transient network failure.
+ """
+
+ @patch("pr_split.cli.typer.confirm", return_value=True)
+ @patch("pr_split.cli.save_plan")
+ @patch("pr_split.cli._push_and_create_prs", return_value=[])
+ @patch("pr_split.cli._create_branches_and_commits", return_value=[])
+ @patch("pr_split.cli.commit_exists", return_value=True)
+ @patch("pr_split.cli.parse_diff")
+ @patch("pr_split.cli.is_worktree_clean", return_value=True)
+ @patch("pr_split.cli.check_gh_auth", return_value=True)
+ @patch("pr_split.cli.branch_exists", return_value=True)
+ @patch("pr_split.cli.load_plan")
+ @patch("pr_split.cli.plan_exists", return_value=True)
+ def test_branches_without_prs_is_retried(
+ self,
+ mock_pe: MagicMock,
+ mock_load: MagicMock,
+ mock_be: MagicMock,
+ mock_auth: MagicMock,
+ mock_clean: MagicMock,
+ mock_parse: MagicMock,
+ mock_commit: MagicMock,
+ mock_create: MagicMock,
+ mock_push: MagicMock,
+ mock_save: MagicMock,
+ mock_confirm: MagicMock,
+ ) -> None:
+ from pr_split.constants import Priority
+ from pr_split.schemas import BranchRecord, GitState, PlanFile, SplitPlan
+
+ plan_file = PlanFile(
+ plan=SplitPlan(
+ dev_branch="feature-branch",
+ base_branch="main",
+ max_loc=400,
+ priority=Priority.ORTHOGONAL,
+ merge_base_sha="0123456789abcdef",
+ raw_diff="some diff",
+ groups=[_group("pr-1", "feat: auth", files=["a.py"])],
+ ),
+ git_state=GitState(
+ branches=[
+ BranchRecord(
+ group_id="pr-1",
+ branch_name="pr-split/feature-branch/pr-1",
+ base_branch="main",
+ commit_sha="abc123",
+ )
+ ]
+ ),
+ )
+ mock_load.return_value = plan_file
+
+ with patch("pr_split.cli.validate_coverage"):
+ result = runner.invoke(app, ["execute"])
+
+ assert result.exit_code == 0, result.output
+ assert "Recreating them and retrying" in result.output.replace("\n", " ")
+ mock_create.assert_called_once()
+ mock_push.assert_called_once()
+ mock_save.assert_called_once()
+
+ @staticmethod
+ def _plan_with_one_of_two_prs(pr_groups: list[str]) -> object:
+ from pr_split.constants import Priority
+ from pr_split.schemas import BranchRecord, GitState, PlanFile, PRRecord, SplitPlan
+
+ return PlanFile(
+ plan=SplitPlan(
+ dev_branch="feature-branch",
+ base_branch="main",
+ max_loc=400,
+ priority=Priority.ORTHOGONAL,
+ merge_base_sha="0123456789abcdef",
+ raw_diff="some diff",
+ groups=[
+ _group("pr-1", "feat: a", files=["a.py"]),
+ _group("pr-2", "feat: b", files=["b.py"]),
+ ],
+ ),
+ git_state=GitState(
+ branches=[
+ BranchRecord(
+ group_id=gid,
+ branch_name=f"pr-split/feature-branch/{gid}",
+ base_branch="main",
+ commit_sha="abc123",
+ )
+ for gid in ("pr-1", "pr-2")
+ ],
+ prs=[PRRecord(group_id=gid, pr_number=10, pr_url="u") for gid in pr_groups],
+ ),
+ )
+
+ @patch("pr_split.cli.typer.confirm", return_value=True)
+ @patch("pr_split.cli.save_plan")
+ @patch("pr_split.cli._push_and_create_prs", return_value=[])
+ @patch("pr_split.cli._create_branches_and_commits", return_value=[])
+ @patch("pr_split.cli.commit_exists", return_value=True)
+ @patch("pr_split.cli.parse_diff")
+ @patch("pr_split.cli.validate_coverage")
+ @patch("pr_split.cli.is_worktree_clean", return_value=True)
+ @patch("pr_split.cli.check_gh_auth", return_value=True)
+ @patch("pr_split.cli.branch_exists", return_value=True)
+ @patch("pr_split.cli.load_plan")
+ @patch("pr_split.cli.plan_exists", return_value=True)
+ def test_partial_prs_are_kept_and_the_rest_created(
+ self,
+ mock_pe: MagicMock,
+ mock_load: MagicMock,
+ mock_be: MagicMock,
+ mock_auth: MagicMock,
+ mock_clean: MagicMock,
+ mock_validate: MagicMock,
+ mock_parse: MagicMock,
+ mock_commit: MagicMock,
+ mock_create: MagicMock,
+ mock_push: MagicMock,
+ mock_save: MagicMock,
+ mock_confirm: MagicMock,
+ ) -> None:
+ mock_load.return_value = self._plan_with_one_of_two_prs(["pr-1"])
+
+ result = runner.invoke(app, ["execute"])
+
+ assert result.exit_code == 0, result.output
+ assert "opened 1 of 2 PR(s)" in result.output.replace("\n", " ")
+ assert set(mock_create.call_args.kwargs["keep"]) == {"pr-1"}
+ assert set(mock_push.call_args.kwargs["existing_prs"]) == {"pr-1"}
+
+ @pytest.mark.parametrize("stacked", [True, False])
+ @patch("pr_split.cli._link_stacks")
+ @patch("pr_split.cli._require_gh_stack")
+ @patch("pr_split.cli.typer.confirm", return_value=True)
+ @patch("pr_split.cli.save_plan")
+ @patch("pr_split.cli._push_and_create_prs", return_value=[])
+ @patch("pr_split.cli._create_branches_and_commits", return_value=[])
+ @patch("pr_split.cli.commit_exists", return_value=True)
+ @patch("pr_split.cli.parse_diff")
+ @patch("pr_split.cli.validate_coverage")
+ @patch("pr_split.cli.is_worktree_clean", return_value=True)
+ @patch("pr_split.cli.check_gh_auth", return_value=True)
+ @patch("pr_split.cli.branch_exists", return_value=True)
+ @patch("pr_split.cli.load_plan")
+ @patch("pr_split.cli.plan_exists", return_value=True)
+ def test_stacked_resume_keeps_the_parents_of_a_layer_with_a_pr(
+ self,
+ mock_pe: MagicMock,
+ mock_load: MagicMock,
+ mock_be: MagicMock,
+ mock_auth: MagicMock,
+ mock_clean: MagicMock,
+ mock_validate: MagicMock,
+ mock_parse: MagicMock,
+ mock_commit: MagicMock,
+ mock_create: MagicMock,
+ mock_push: MagicMock,
+ mock_save: MagicMock,
+ mock_confirm: MagicMock,
+ mock_stack: MagicMock,
+ mock_link: MagicMock,
+ stacked: bool,
+ ) -> None:
+ """pr-2's PR opened but its parent pr-1's did not: rebuilding pr-1 would
+ rewrite the base under pr-2's open PR, so the resume keeps it. A plan
+ with dependencies is laid out along its DAG with or without --stack."""
+ plan_file = self._plan_with_one_of_two_prs(["pr-2"])
+ plan_file.plan.groups[1].depends_on = ["pr-1"]
+ plan_file.plan.stacked = stacked
+ mock_load.return_value = plan_file
+
+ result = runner.invoke(app, ["execute"])
+
+ assert result.exit_code == 0, result.output
+ kept = set(mock_create.call_args.kwargs["keep"])
+ assert kept == {"pr-1", "pr-2"}
+ # pr-1 still gets its PR opened.
+ assert set(mock_push.call_args.kwargs["existing_prs"]) == {"pr-2"}
+
+ @patch("pr_split.cli.load_plan")
+ @patch("pr_split.cli.plan_exists", return_value=True)
+ def test_every_group_with_a_pr_is_refused(
+ self, mock_pe: MagicMock, mock_load: MagicMock
+ ) -> None:
+ mock_load.return_value = self._plan_with_one_of_two_prs(["pr-1", "pr-2"])
+ result = runner.invoke(app, ["execute"])
+ assert result.exit_code == 1
+ assert "already has PRs" in result.output
+
+ @staticmethod
+ def _plan_with_stale_pr2() -> object:
+ from pr_split.constants import Priority
+ from pr_split.schemas import BranchRecord, GitState, PlanFile, SplitPlan
+
+ return PlanFile(
+ plan=SplitPlan(
+ dev_branch="feature-branch",
+ base_branch="main",
+ max_loc=400,
+ priority=Priority.ORTHOGONAL,
+ merge_base_sha="0123456789abcdef",
+ raw_diff="some diff",
+ groups=[_group("pr-1", "feat: auth", files=["a.py"])],
+ ),
+ git_state=GitState(
+ branches=[
+ BranchRecord(
+ group_id="pr-1",
+ branch_name="pr-split/feature-branch/pr-1",
+ base_branch="main",
+ commit_sha="abc123",
+ ),
+ # pr-2 was merged into pr-1 by an edit between the runs.
+ BranchRecord(
+ group_id="pr-2",
+ branch_name="pr-split/feature-branch/pr-2",
+ base_branch="main",
+ commit_sha="def456",
+ ),
+ ]
+ ),
+ )
+
+ @patch("pr_split.cli.run_git")
+ @patch("pr_split.cli.delete_branch")
+ @patch("pr_split.cli.typer.confirm", return_value=True)
+ @patch("pr_split.cli.save_plan")
+ @patch("pr_split.cli._push_and_create_prs", return_value=[])
+ @patch("pr_split.cli._create_branches_and_commits", return_value=[])
+ @patch("pr_split.cli.commit_exists", return_value=True)
+ @patch("pr_split.cli.parse_diff")
+ @patch("pr_split.cli.is_worktree_clean", return_value=True)
+ @patch("pr_split.cli.check_gh_auth", return_value=True)
+ @patch("pr_split.cli.branch_exists", return_value=True)
+ @patch("pr_split.cli.load_plan")
+ @patch("pr_split.cli.plan_exists", return_value=True)
+ def test_branches_dropped_from_an_edited_plan_are_deleted(
+ self,
+ mock_pe: MagicMock,
+ mock_load: MagicMock,
+ mock_be: MagicMock,
+ mock_auth: MagicMock,
+ mock_clean: MagicMock,
+ mock_parse: MagicMock,
+ mock_commit: MagicMock,
+ mock_create: MagicMock,
+ mock_push: MagicMock,
+ mock_save: MagicMock,
+ mock_confirm: MagicMock,
+ mock_delete: MagicMock,
+ mock_rg: MagicMock,
+ ) -> None:
+ """A retry must not orphan branches the edited plan no longer contains."""
+ mock_load.return_value = self._plan_with_stale_pr2()
+
+ with patch("pr_split.cli.validate_coverage"):
+ result = runner.invoke(app, ["execute"])
+
+ assert result.exit_code == 0
+ mock_delete.assert_called_once_with("pr-split/feature-branch/pr-2")
+ mock_rg.assert_any_call(
+ "ls-remote", "--exit-code", "origin", "refs/heads/pr-split/feature-branch/pr-2"
+ )
+ mock_rg.assert_any_call("push", "origin", "--delete", "pr-split/feature-branch/pr-2")
+
+ @patch("pr_split.cli.run_git")
+ @patch("pr_split.cli.delete_branch")
+ @patch("pr_split.cli.typer.confirm", return_value=True)
+ @patch("pr_split.cli.save_plan")
+ @patch("pr_split.cli._push_and_create_prs", return_value=[])
+ @patch("pr_split.cli._create_branches_and_commits", return_value=[])
+ @patch("pr_split.cli.commit_exists", return_value=True)
+ @patch("pr_split.cli.parse_diff")
+ @patch("pr_split.cli.is_worktree_clean", return_value=True)
+ @patch("pr_split.cli.check_gh_auth", return_value=True)
+ @patch("pr_split.cli.branch_exists")
+ @patch("pr_split.cli.load_plan")
+ @patch("pr_split.cli.plan_exists", return_value=True)
+ def test_remote_only_stale_branch_is_still_deleted_on_origin(
+ self,
+ mock_pe: MagicMock,
+ mock_load: MagicMock,
+ mock_be: MagicMock,
+ mock_auth: MagicMock,
+ mock_clean: MagicMock,
+ mock_parse: MagicMock,
+ mock_commit: MagicMock,
+ mock_create: MagicMock,
+ mock_push: MagicMock,
+ mock_save: MagicMock,
+ mock_confirm: MagicMock,
+ mock_delete: MagicMock,
+ mock_rg: MagicMock,
+ ) -> None:
+ """A stale branch pruned locally must still be removed from origin."""
+ mock_be.side_effect = lambda name: name != "pr-split/feature-branch/pr-2"
+ mock_load.return_value = self._plan_with_stale_pr2()
+
+ with patch("pr_split.cli.validate_coverage"):
+ result = runner.invoke(app, ["execute"])
+
+ assert result.exit_code == 0
+ mock_delete.assert_not_called()
+ mock_rg.assert_any_call("push", "origin", "--delete", "pr-split/feature-branch/pr-2")
+
+
+class TestPRCreationErrorIsACleanMessage:
+ """A failed push/PR creation must end with a red message, not a traceback."""
+
+ @patch("pr_split.cli.typer.confirm", return_value=True)
+ @patch("pr_split.cli.save_plan")
+ @patch("pr_split.cli._push_and_create_prs")
+ @patch("pr_split.cli._create_branches_and_commits", return_value=[])
+ @patch("pr_split.cli.commit_exists", return_value=True)
+ @patch("pr_split.cli.parse_diff")
+ @patch("pr_split.cli.is_worktree_clean", return_value=True)
+ @patch("pr_split.cli.check_gh_auth", return_value=True)
+ @patch("pr_split.cli.branch_exists", return_value=True)
+ @patch("pr_split.cli.load_plan")
+ @patch("pr_split.cli.plan_exists", return_value=True)
+ def test_execute_push_failure_exits_cleanly(
+ self,
+ mock_pe: MagicMock,
+ mock_load: MagicMock,
+ mock_be: MagicMock,
+ mock_auth: MagicMock,
+ mock_clean: MagicMock,
+ mock_parse: MagicMock,
+ mock_commit: MagicMock,
+ mock_create: MagicMock,
+ mock_push: MagicMock,
+ mock_save: MagicMock,
+ mock_confirm: MagicMock,
+ ) -> None:
+ from pr_split.constants import Priority
+ from pr_split.schemas import GitState, PlanFile, SplitPlan
+
+ plan_file = PlanFile(
+ plan=SplitPlan(
+ dev_branch="feature-branch",
+ base_branch="main",
+ max_loc=400,
+ priority=Priority.ORTHOGONAL,
+ merge_base_sha="0123456789abcdef",
+ raw_diff="some diff",
+ groups=[_group("pr-1", "feat: auth", files=["a.py"])],
+ ),
+ git_state=GitState(branches=[]),
+ )
+ mock_load.return_value = plan_file
+ # git stderr can contain rich-markup-like text; it must be escaped,
+ # not interpreted (a stray closing tag would raise MarkupError).
+ mock_push.side_effect = PRCreationError(
+ "2 PR(s) failed:\n- pr-1: fatal: remote said [/bold] nonsense", []
+ )
+
+ with patch("pr_split.cli.validate_coverage"):
+ result = runner.invoke(app, ["execute"])
+
+ assert result.exit_code == 1
+ assert result.exception is None or isinstance(result.exception, SystemExit)
+ assert "2 PR(s) failed" in result.output
+ assert "[/bold] nonsense" in result.output
+ assert "saved to the plan file" in result.output
+ mock_save.assert_called_once()
+
+ @patch("pr_split.cli.typer.confirm", return_value=True)
+ @patch("pr_split.cli.save_plan")
+ @patch("pr_split.cli._push_and_create_prs")
+ @patch("pr_split.cli._create_branches_and_commits", return_value=[])
+ @patch("pr_split.cli.merge_base", return_value="abc123")
+ @patch("pr_split.cli._interactive_edit")
+ @patch("pr_split.cli._present_plan")
+ @patch("pr_split.cli.validate_plan", return_value=[])
+ @patch("pr_split.cli.plan_split")
+ @patch("pr_split.cli.parse_diff")
+ @patch("pr_split.cli.extract_diff", return_value="diff --git a/a.py b/a.py\n")
+ @patch("pr_split.cli._validate_inputs")
+ @patch("pr_split.cli.branch_exists", return_value=True)
+ @patch("pr_split.cli.plan_exists", return_value=False)
+ def test_split_push_failure_exits_cleanly(
+ self,
+ mock_plan_exists: MagicMock,
+ mock_branch_exists: MagicMock,
+ mock_validate_inputs: MagicMock,
+ mock_extract_diff: MagicMock,
+ mock_parse_diff: MagicMock,
+ mock_plan_split: MagicMock,
+ mock_validate_plan: MagicMock,
+ mock_present_plan: MagicMock,
+ mock_interactive_edit: MagicMock,
+ mock_merge_base: MagicMock,
+ mock_create: MagicMock,
+ mock_push: MagicMock,
+ mock_save: MagicMock,
+ mock_confirm: MagicMock,
+ ) -> None:
+ parsed_diff = MagicMock()
+ parsed_diff.stats = {
+ "total_files": 1,
+ "total_added": 10,
+ "total_removed": 5,
+ "total_loc": 15,
+ }
+ mock_parse_diff.return_value = parsed_diff
+ group = _group("pr-1", "feat: auth", files=["a.py"])
+ mock_plan_split.return_value = [group]
+ mock_interactive_edit.return_value = [group]
+ mock_push.side_effect = PRCreationError("1 PR(s) failed:\n- pr-1: boom", [])
+
+ result = runner.invoke(
+ app,
+ ["split", "feature-branch"],
+ env={"ANTHROPIC_API_KEY": "sk-test"},
+ )
+
+ assert result.exit_code == 1
+ assert result.exception is None or isinstance(result.exception, SystemExit)
+ assert "1 PR(s) failed" in result.output
+ assert "saved to the plan file" in result.output
+ mock_save.assert_called_once()
+
+
+TWO_HUNK_DIFF = """\
+diff --git a/a.py b/a.py
+--- a/a.py
++++ b/a.py
+@@ -1,3 +1,4 @@
+ x
++one
+ y
+ z
+@@ -20,3 +21,5 @@
+ p
++two
++three
+ q
+ r
+"""
+
+
+class TestDropEmptyGroups:
+ def test_no_empty_groups_returns_same_list(self) -> None:
+ groups = [_group("pr-1", "a", files=["a.py"]), _group("pr-2", "b", files=["b.py"])]
+ assert _drop_empty_groups(groups, {}, {}) is groups
+
+ def test_empty_group_is_dropped_and_dependants_inherit_its_parents(self) -> None:
+ root = _group("pr-1", "root", files=["a.py"])
+ emptied = _group("pr-2", "emptied", depends_on=["pr-1"])
+ leaf = _group("pr-3", "leaf", depends_on=["pr-2"], files=["c.py"])
+ from pr_split.cli import console
+
+ with console.capture() as capture:
+ result = _drop_empty_groups([root, emptied, leaf], {}, {})
+
+ assert [g.id for g in result] == ["pr-1", "pr-3"]
+ assert result[1].depends_on == ["pr-1"]
+ assert "Dropped empty group(s) after editing: pr-2" in capture.get()
+
+ def test_dropping_a_root_leaves_dependants_as_roots(self) -> None:
+ emptied = _group("pr-1", "emptied")
+ leaf = _group("pr-2", "leaf", depends_on=["pr-1"], files=["b.py"])
+ result = _drop_empty_groups([emptied, leaf], {}, {})
+ assert [g.id for g in result] == ["pr-2"]
+ assert result[0].depends_on == []
+
+ def test_chain_of_empty_groups_collapses_transitively(self) -> None:
+ root = _group("pr-1", "root", files=["a.py"])
+ e1 = _group("pr-2", "e1", depends_on=["pr-1"])
+ e2 = _group("pr-3", "e2", depends_on=["pr-2"])
+ leaf = _group("pr-4", "leaf", depends_on=["pr-3"], files=["d.py"])
+ result = _drop_empty_groups([root, e1, e2, leaf], {}, {})
+ assert [g.id for g in result] == ["pr-1", "pr-4"]
+ assert result[1].depends_on == ["pr-1"]
+
+ def test_chain_with_direct_edge_does_not_duplicate(self) -> None:
+ root = _group("pr-1", "root", files=["a.py"])
+ e1 = _group("pr-2", "e1", depends_on=["pr-1"])
+ e2 = _group("pr-3", "e2", depends_on=["pr-2"])
+ leaf = _group("pr-4", "leaf", depends_on=["pr-3", "pr-1"], files=["d.py"])
+ result = _drop_empty_groups([root, e1, e2, leaf], {}, {})
+ assert result[1].depends_on == ["pr-1"]
+
+ def test_diamond_through_dropped_groups(self) -> None:
+ a = _group("pr-1", "a", files=["a.py"])
+ b = _group("pr-2", "b", files=["b.py"])
+ e1 = _group("pr-3", "e1", depends_on=["pr-1"])
+ e2 = _group("pr-4", "e2", depends_on=["pr-2"])
+ e3 = _group("pr-5", "e3", depends_on=["pr-3", "pr-4"])
+ leaf = _group("pr-6", "leaf", depends_on=["pr-5"], files=["f.py"])
+ result = _drop_empty_groups([a, b, e1, e2, e3, leaf], {}, {})
+ assert [g.id for g in result] == ["pr-1", "pr-2", "pr-6"]
+ assert result[2].depends_on == ["pr-1", "pr-2"]
+
+ def test_dependants_of_a_dropped_group_depend_on_its_hunks_new_owner(self) -> None:
+ # E's only hunk moved to the unrelated group D; C was planned on top of
+ # E, so it must now build on D or its stacked branch misses that hunk.
+ partial = AssignmentType.PARTIAL_HUNKS
+ d = _group("pr-1", "d")
+ d.assignments = [
+ GroupAssignment(file_path="a.py", assignment_type=partial, hunk_indices=[0, 1])
+ ]
+ e = _group("pr-2", "e")
+ c = _group("pr-3", "c", depends_on=["pr-2"], files=["c.py"])
+ held_before = {"pr-1": {("a.py", 0)}, "pr-2": {("a.py", 1)}, "pr-3": set()}
+
+ result = _drop_empty_groups([d, e, c], held_before, {"a.py": 2})
+
+ assert [g.id for g in result] == ["pr-1", "pr-3"]
+ assert result[1].depends_on == ["pr-1"]
+
+ def test_recipient_is_added_after_inherited_ancestors(self) -> None:
+ partial = AssignmentType.PARTIAL_HUNKS
+ root = _group("pr-1", "root", files=["r.py"])
+ d = _group("pr-2", "d")
+ d.assignments = [
+ GroupAssignment(file_path="a.py", assignment_type=partial, hunk_indices=[0])
+ ]
+ e = _group("pr-3", "e", depends_on=["pr-1"])
+ c = _group("pr-4", "c", depends_on=["pr-3"], files=["c.py"])
+ held_before = {"pr-2": set(), "pr-3": {("a.py", 0)}}
+
+ result = _drop_empty_groups([root, d, e, c], held_before, {"a.py": 1})
+
+ assert result[-1].depends_on == ["pr-1", "pr-2"]
+
+ def test_recipient_already_reached_through_an_ancestor_adds_no_edge(self) -> None:
+ # pr-3's hunks moved to pr-1; pr-4 already reaches pr-1 through pr-2,
+ # so a direct edge would make pr-4 a multi-parent node for no reason.
+ partial = AssignmentType.PARTIAL_HUNKS
+ root = _group("pr-1", "root")
+ root.assignments = [
+ GroupAssignment(file_path="a.py", assignment_type=partial, hunk_indices=[0])
+ ]
+ mid = _group("pr-2", "mid", depends_on=["pr-1"], files=["b.py"])
+ emptied = _group("pr-3", "emptied", depends_on=["pr-2"])
+ leaf = _group("pr-4", "leaf", depends_on=["pr-3"], files=["d.py"])
+ held_before = {"pr-1": set(), "pr-3": {("a.py", 0)}}
+
+ result = _drop_empty_groups([root, mid, emptied, leaf], held_before, {"a.py": 1})
+
+ assert result[-1].depends_on == ["pr-2"]
+
+ def test_move_to_a_downstream_group_is_refused(self) -> None:
+ # D already builds on C, so it cannot become C's parent; accepting the
+ # edit would build C's stacked branch without the hunk it inherited.
+ partial = AssignmentType.PARTIAL_HUNKS
+ e = _group("pr-1", "e")
+ c = _group("pr-2", "c", depends_on=["pr-1"], files=["c.py"])
+ d = _group("pr-3", "d", depends_on=["pr-2"])
+ d.assignments = [
+ GroupAssignment(file_path="a.py", assignment_type=partial, hunk_indices=[0])
+ ]
+ held_before = {"pr-1": {("a.py", 0)}, "pr-2": set(), "pr-3": set()}
+ from pr_split.cli import console
+
+ with console.capture() as capture, pytest.raises(typer.Exit):
+ _drop_empty_groups([e, c, d], held_before, {"a.py": 1})
+
+ assert "Cannot drop emptied group 'pr-1'" in " ".join(capture.get().split())
+
+ def test_crossed_moves_are_refused(self) -> None:
+ # C depended on E1 whose hunk moved to D; D depended on E2 whose hunk
+ # moved to C. Both edges cannot hold, so the edit is refused.
+ partial = AssignmentType.PARTIAL_HUNKS
+ e1 = _group("pr-1", "e1")
+ e2 = _group("pr-2", "e2")
+ c = _group("pr-3", "c", depends_on=["pr-1"])
+ c.assignments = [
+ GroupAssignment(file_path="a.py", assignment_type=partial, hunk_indices=[1])
+ ]
+ d = _group("pr-4", "d", depends_on=["pr-2"])
+ d.assignments = [
+ GroupAssignment(file_path="a.py", assignment_type=partial, hunk_indices=[0])
+ ]
+ held_before = {"pr-1": {("a.py", 0)}, "pr-2": {("a.py", 1)}}
+
+ with pytest.raises(typer.Exit):
+ _drop_empty_groups([e1, e2, c, d], held_before, {"a.py": 2})
+
+
+class TestEditorEmptiedGroupEndToEnd:
+ @patch("pr_split.cli.typer.prompt")
+ def test_moving_the_last_hunk_out_yields_a_valid_one_group_plan(
+ self, mock_prompt: MagicMock
+ ) -> None:
+ from pr_split.cli import _drop_empty_groups, _held_hunks, _interactive_edit
+ from pr_split.diff_ops.parser import parse_diff
+ from pr_split.planner.validator import validate_plan
+
+ parsed = parse_diff(TWO_HUNK_DIFF)
+ g1 = _group("pr-1", "first", added=1, removed=0)
+ g1.assignments = [
+ GroupAssignment(
+ file_path="a.py", assignment_type=AssignmentType.PARTIAL_HUNKS, hunk_indices=[0]
+ )
+ ]
+ g2 = _group("pr-2", "second", depends_on=["pr-1"], added=2, removed=0)
+ g2.assignments = [
+ GroupAssignment(
+ file_path="a.py", assignment_type=AssignmentType.PARTIAL_HUNKS, hunk_indices=[1]
+ )
+ ]
+ mock_prompt.side_effect = ["move a.py:1 pr-2 pr-1", "done"]
+
+ hunk_counts = {pf.path: len(pf) for pf in parsed.patch_set}
+ held_before = _held_hunks([g1, g2], hunk_counts)
+ edited = _interactive_edit([g1, g2], parsed)
+ groups = _drop_empty_groups(edited, held_before, hunk_counts)
+
+ assert [g.id for g in groups] == ["pr-1"]
+ assert groups[0].assignments[0].hunk_indices == [0, 1]
+ assert groups[0].estimated_loc == 3
+ assert validate_plan(groups, parsed, PlanDAG(groups), max_loc=400) == []
+
+
+class TestAdoptExistingBranches:
+ """`adopt` registers already-built branches as a stack and saves them as a plan."""
+
+ def _repo(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ for var in ("GIT_AUTHOR_NAME", "GIT_COMMITTER_NAME"):
+ monkeypatch.setenv(var, "t")
+ for var in ("GIT_AUTHOR_EMAIL", "GIT_COMMITTER_EMAIL"):
+ monkeypatch.setenv(var, "t@x")
+ monkeypatch.chdir(tmp_path)
+
+ def git(*args: str) -> None:
+ subprocess.run(["git", *args], cwd=tmp_path, check=True, capture_output=True)
+
+ git("init", "-q", "-b", "main")
+ (tmp_path / "a.txt").write_text("a\n")
+ git("add", "a.txt")
+ git("commit", "-qm", "base")
+ git("checkout", "-qb", "test/allowlist")
+ (tmp_path / "b.txt").write_text("b\n")
+ git("add", "b.txt")
+ git("commit", "-qm", "parent")
+ git("checkout", "-qb", "test/derive")
+ (tmp_path / "c.txt").write_text("c\n")
+ git("add", "c.txt")
+ git("commit", "-qm", "child")
+ git("checkout", "-qb", "unrelated", "main")
+ (tmp_path / "d.txt").write_text("d\n")
+ git("add", "d.txt")
+ git("commit", "-qm", "unrelated")
+ git("checkout", "-q", "main")
+
+ @patch("pr_split.cli.find_open_pr")
+ @patch("pr_split.cli.link_stack")
+ @patch("pr_split.cli.check_gh_stack", return_value=True)
+ def test_chain_is_linked_on_base_and_saved_as_a_stacked_plan(
+ self,
+ mock_stack: MagicMock,
+ mock_link: MagicMock,
+ mock_find: MagicMock,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ self._repo(tmp_path, monkeypatch)
+ mock_find.side_effect = [(1951, "https://x/1951"), (1952, "https://x/1952")]
+
+ result = runner.invoke(
+ app, ["adopt", "test/allowlist", "test/derive", "--base", "main", "--yes"]
+ )
+
+ assert result.exit_code == 0, result.output
+ mock_link.assert_called_once_with(["test/allowlist", "test/derive"], base="main")
+ from pr_split.plan_store import load_plan
+
+ saved = load_plan()
+ assert saved.plan.stacked is True
+ assert [(g.id, g.depends_on) for g in saved.plan.groups] == [
+ ("pr-1", []),
+ ("pr-2", ["pr-1"]),
+ ]
+ assert [(b.branch_name, b.base_branch) for b in saved.git_state.branches] == [
+ ("test/allowlist", "main"),
+ ("test/derive", "test/allowlist"),
+ ]
+ assert [p.pr_number for p in saved.git_state.prs] == [1951, 1952]
+ # The branches and PRs are the user's own, so clean must never delete them.
+ assert all(b.adopted for b in saved.git_state.branches)
+ assert all(p.adopted for p in saved.git_state.prs)
+
+ @patch("pr_split.cli.find_open_pr")
+ @patch("pr_split.cli.link_stack")
+ @patch("pr_split.cli.check_gh_stack", return_value=True)
+ def test_another_branchs_plan_does_not_block_adoption(
+ self,
+ mock_stack: MagicMock,
+ mock_link: MagicMock,
+ mock_find: MagicMock,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ from pr_split.constants import Priority
+ from pr_split.plan_store import load_plan, save_plan, select_plan
+ from pr_split.schemas import BranchRecord, GitState, PlanFile, SplitPlan
+
+ self._repo(tmp_path, monkeypatch)
+ for other in ("feat/a", "feat/b"):
+ save_plan(
+ PlanFile(
+ plan=SplitPlan(
+ dev_branch=other,
+ base_branch="main",
+ max_loc=400,
+ priority=Priority.ORTHOGONAL,
+ groups=[],
+ ),
+ git_state=GitState(
+ branches=[
+ BranchRecord(group_id="pr-1", branch_name=other, base_branch="main")
+ ]
+ ),
+ )
+ )
+ mock_find.side_effect = [(1951, "https://x/1951"), (1952, "https://x/1952")]
+
+ result = runner.invoke(
+ app, ["adopt", "test/allowlist", "test/derive", "--base", "main", "--yes"]
+ )
+
+ assert result.exit_code == 0, result.output
+ select_plan("test/derive")
+ assert [p.pr_number for p in load_plan().git_state.prs] == [1951, 1952]
+
+ @patch("pr_split.cli.find_open_pr", return_value=None)
+ @patch("pr_split.cli.link_stack")
+ @patch("pr_split.cli.check_gh_stack", return_value=True)
+ def test_a_failed_lookup_saves_nothing_and_says_to_rerun(
+ self,
+ mock_stack: MagicMock,
+ mock_link: MagicMock,
+ mock_find: MagicMock,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ self._repo(tmp_path, monkeypatch)
+
+ result = runner.invoke(
+ app, ["adopt", "test/allowlist", "test/derive", "--base", "main", "--yes"]
+ )
+
+ assert result.exit_code == 1
+ assert "re-run the same adopt command" in " ".join(result.output.split())
+ from pr_split.plan_store import plan_exists
+
+ assert not plan_exists()
+
+ @patch("pr_split.cli.link_stack")
+ def test_branch_that_does_not_contain_its_parent_is_refused(
+ self, mock_link: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ self._repo(tmp_path, monkeypatch)
+
+ result = runner.invoke(app, ["adopt", "test/allowlist", "unrelated", "--yes"])
+
+ assert result.exit_code == 1
+ assert "unrelated does not contain test/allowlist" in result.output
+ mock_link.assert_not_called()
+
+ def test_a_single_branch_is_refused(self) -> None:
+ result = runner.invoke(app, ["adopt", "only-one"])
+ assert result.exit_code == 1
+ assert "at least two branches" in result.output
+
+
+class TestRetargetMergedBase:
+ def _plan(self, stacked: bool = True) -> object:
+ from pr_split.constants import Priority
+ from pr_split.schemas import BranchRecord, GitState, PlanFile, PRRecord, SplitPlan
+
+ return PlanFile(
+ plan=SplitPlan(
+ dev_branch="feat/change-signature",
+ base_branch="feat/postcondition",
+ max_loc=400,
+ priority=Priority.ORTHOGONAL,
+ stacked=stacked,
+ groups=[_group("pr-1", "a"), _group("pr-2", "b", depends_on=["pr-1"])],
+ ),
+ git_state=GitState(
+ branches=[
+ BranchRecord(
+ group_id="pr-1", branch_name="s/pr-1", base_branch="feat/postcondition"
+ ),
+ BranchRecord(group_id="pr-2", branch_name="s/pr-2", base_branch="s/pr-1"),
+ ],
+ prs=[
+ PRRecord(group_id="pr-1", pr_number=2042, pr_url="u"),
+ PRRecord(group_id="pr-2", pr_number=2043, pr_url="u"),
+ ],
+ ),
+ )
+
+ @patch("pr_split.cli.save_plan")
+ @patch("pr_split.cli.link_stack")
+ @patch("pr_split.cli.set_pr_base")
+ @patch("pr_split.cli.unstack")
+ @patch("pr_split.cli.stack_numbers_for", return_value={2054})
+ @patch("pr_split.cli.default_branch", return_value="main")
+ @patch("pr_split.cli.load_plan")
+ @patch("pr_split.cli.plan_exists", return_value=True)
+ def test_unstacks_retargets_roots_and_relinks_on_the_new_base(
+ self,
+ mock_pe: MagicMock,
+ mock_load: MagicMock,
+ mock_default: MagicMock,
+ mock_stacks: MagicMock,
+ mock_unstack: MagicMock,
+ mock_set_base: MagicMock,
+ mock_link: MagicMock,
+ mock_save: MagicMock,
+ ) -> None:
+ mock_load.return_value = self._plan()
+
+ result = runner.invoke(app, ["retarget", "--yes"])
+
+ assert result.exit_code == 0, result.output
+ mock_unstack.assert_called_once_with(2054)
+ mock_set_base.assert_called_once_with(2042, "main")
+ mock_link.assert_called_once_with([2042, 2043], base="main")
+ saved = mock_save.call_args.args[0]
+ assert saved.plan.base_branch == "main"
+ assert [b.base_branch for b in saved.git_state.branches] == ["main", "s/pr-1"]
+
+ @patch("pr_split.cli.load_plan")
+ @patch("pr_split.cli.plan_exists", return_value=True)
+ def test_merge_refuses_when_the_base_has_merged(
+ self, mock_pe: MagicMock, mock_load: MagicMock, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setattr("pr_split.cli._base_has_merged", lambda base: True)
+ mock_load.return_value = self._plan()
+
+ result = runner.invoke(app, ["merge"])
+
+ assert result.exit_code == 1
+ assert "feat/postcondition has already merged" in " ".join(result.output.split())
+ assert "pr-split retarget" in " ".join(result.output.split())
+
+
+class TestEditorPlanCommands:
+ def _partial(self, file_path: str, indices: list[int]) -> GroupAssignment:
+ return GroupAssignment(
+ file_path=file_path,
+ assignment_type=AssignmentType.PARTIAL_HUNKS,
+ hunk_indices=indices,
+ )
+
+ def _two_groups(self) -> list[Group]:
+ g1 = _group("pr-1", "first")
+ g1.assignments = [self._partial("a.py", [0])]
+ g2 = _group("pr-2", "second")
+ g2.assignments = [self._partial("a.py", [1])]
+ return [g1, g2]
+
+ @patch("pr_split.cli.typer.prompt")
+ def test_dep_title_desc_and_undep(self, mock_prompt: MagicMock) -> None:
+ parsed = parse_diff(TWO_HUNK_DIFF)
+ mock_prompt.side_effect = [
+ "dep pr-2 pr-1",
+ "title pr-2 feat: add the second half",
+ "desc pr-2 Builds on pr-1.",
+ "done",
+ ]
+ groups = _interactive_edit(self._two_groups(), parsed)
+ assert groups[1].depends_on == ["pr-1"]
+ assert groups[1].title == "feat: add the second half"
+ assert groups[1].description == "Builds on pr-1."
+
+ mock_prompt.side_effect = ["undep pr-2 pr-1", "done"]
+ groups = _interactive_edit(groups, parsed)
+ assert groups[1].depends_on == []
+
+ @patch("pr_split.cli.typer.prompt")
+ def test_dep_that_would_cycle_is_refused(self, mock_prompt: MagicMock) -> None:
+ groups = self._two_groups()
+ groups[1].depends_on = ["pr-1"]
+ mock_prompt.side_effect = ["dep pr-1 pr-2", "done"]
+ result = _interactive_edit(groups, parse_diff(TWO_HUNK_DIFF))
+ assert result[0].depends_on == []
+
+ @patch("pr_split.cli.typer.prompt")
+ def test_movefile_moves_every_hunk_and_recomputes_loc(self, mock_prompt: MagicMock) -> None:
+ parsed = parse_diff(TWO_HUNK_DIFF)
+ mock_prompt.side_effect = ["movefile a.py pr-1 pr-2", "done"]
+ groups = _interactive_edit(self._two_groups(), parsed)
+ assert groups[0].assignments == []
+ assert groups[1].assignments[0].assignment_type is AssignmentType.WHOLE_FILE
+ assert groups[1].assignments[0].hunk_indices == [0, 1]
+ assert groups[1].estimated_loc == 3
+
+ @patch("pr_split.cli.typer.prompt")
+ def test_new_group_then_move_into_it(self, mock_prompt: MagicMock) -> None:
+ parsed = parse_diff(TWO_HUNK_DIFF)
+ mock_prompt.side_effect = ["new pr-3", "move a.py:1 pr-2 pr-3", "done"]
+ groups = _interactive_edit(self._two_groups(), parsed)
+ assert [g.id for g in groups] == ["pr-1", "pr-2", "pr-3"]
+ assert groups[2].assignments[0].hunk_indices == [1]
+
+ @patch("pr_split.cli.typer.prompt")
+ def test_merge_folds_hunks_parents_and_dependants(self, mock_prompt: MagicMock) -> None:
+ parsed = parse_diff(TWO_HUNK_DIFF)
+ groups = self._two_groups()
+ leaf = _group("pr-3", "leaf", depends_on=["pr-2"], files=["c.py"])
+ mock_prompt.side_effect = ["merge pr-1 pr-2", "done"]
+
+ result = _interactive_edit([*groups, leaf], parsed)
+
+ assert [g.id for g in result] == ["pr-1", "pr-3"]
+ assert result[0].assignments[0].assignment_type is AssignmentType.WHOLE_FILE
+ assert result[0].assignments[0].hunk_indices == [0, 1]
+ assert result[1].depends_on == ["pr-1"]
+ assert result[0].estimated_loc == 3
+
+ @patch("pr_split.cli.typer.prompt")
+ def test_merge_that_would_cycle_is_refused(self, mock_prompt: MagicMock) -> None:
+ # pr-3 sits between pr-1 and pr-2; folding pr-2 into pr-1 would make
+ # pr-1 depend on pr-3, which depends on pr-1.
+ groups = self._two_groups()
+ mid = _group("pr-3", "mid", depends_on=["pr-1"], files=["c.py"])
+ groups[1].depends_on = ["pr-3"]
+ mock_prompt.side_effect = ["merge pr-1 pr-2", "done"]
+ result = _interactive_edit([*groups, mid], parse_diff(TWO_HUNK_DIFF))
+ assert [g.id for g in result] == ["pr-1", "pr-2", "pr-3"]
+
+
+class TestExecuteYes:
+ @patch("pr_split.cli._create_branches_and_commits", side_effect=PRSplitError("stop here"))
+ @patch("pr_split.cli.typer.confirm")
+ @patch("pr_split.cli.validate_coverage")
+ @patch("pr_split.cli.parse_diff")
+ @patch("pr_split.cli.commit_exists", return_value=True)
+ @patch("pr_split.cli.check_gh_auth", return_value=True)
+ @patch("pr_split.cli.is_worktree_clean", return_value=True)
+ @patch("pr_split.cli.branch_exists", return_value=True)
+ @patch("pr_split.cli.load_plan")
+ @patch("pr_split.cli.plan_exists", return_value=True)
+ def test_yes_skips_the_confirmation_prompt(
+ self,
+ mock_pe: MagicMock,
+ mock_load: MagicMock,
+ mock_be: MagicMock,
+ mock_clean: MagicMock,
+ mock_auth: MagicMock,
+ mock_commit: MagicMock,
+ mock_parse: MagicMock,
+ mock_validate: MagicMock,
+ mock_confirm: MagicMock,
+ mock_create: MagicMock,
+ ) -> None:
+ mock_plan_file = MagicMock()
+ mock_plan_file.git_state.branches = []
+ mock_plan_file.git_state.prs = []
+ mock_plan_file.plan.raw_diff = "some diff"
+ mock_plan_file.plan.merge_base_sha = "abc123"
+ mock_plan_file.plan.stacked = False
+ mock_plan_file.plan.dev_branch_arg = "feature"
+ mock_plan_file.plan.dev_branch = "feature"
+ mock_plan_file.plan.base_branch = "main"
+ mock_plan_file.plan.max_loc = 400
+ mock_plan_file.plan.groups = [_group("pr-1", "t", files=["a.py"])]
+ mock_load.return_value = mock_plan_file
+
+ result = runner.invoke(app, ["execute", "--yes"], input="")
+
+ mock_confirm.assert_not_called()
+ mock_create.assert_called_once()
+ assert "stop here" in result.output
diff --git a/tests/test_cli_helpers.py b/tests/test_cli_helpers.py
index 01b6d8b..69c5820 100644
--- a/tests/test_cli_helpers.py
+++ b/tests/test_cli_helpers.py
@@ -2,6 +2,7 @@
import contextlib
import threading
+from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
@@ -178,6 +179,30 @@ def test_returns_records_for_all_groups(
assert result[0].group_id == "pr-1"
assert result[1].group_id == "pr-2"
+ @patch("pr_split.cli.create_pr", return_value=(2, "https://github.com/pr/2"))
+ @patch("pr_split.cli.push_branch")
+ def test_groups_with_existing_prs_are_not_pushed_or_reopened(
+ self, mock_push: MagicMock, mock_create: MagicMock
+ ) -> None:
+ groups = [_group("pr-1", "feat: a"), _group("pr-2", "feat: b", ["pr-1"])]
+ records = [
+ _branch_record("pr-1", "pr-split/ns/pr-1"),
+ BranchRecord(
+ group_id="pr-2",
+ branch_name="pr-split/ns/pr-2",
+ base_branch="pr-split/ns/pr-1",
+ commit_sha="abc123",
+ ),
+ ]
+ existing = PRRecord(group_id="pr-1", pr_number=1, pr_url="https://github.com/pr/1")
+
+ result = _push_and_create_prs(groups, records, existing_prs={"pr-1": existing})
+
+ assert [call.args[0] for call in mock_push.call_args_list] == ["pr-split/ns/pr-2"]
+ mock_create.assert_called_once()
+ assert result[0] is existing
+ assert result[1].pr_number == 2
+
@patch("pr_split.cli.create_pr", return_value=(1, "https://github.com/pr/1"))
@patch("pr_split.cli.push_branch")
def test_pushes_all_branches(self, mock_push: MagicMock, mock_create: MagicMock) -> None:
@@ -238,6 +263,104 @@ def barrier_create(**kwargs) -> tuple[int, str]:
assert max_concurrent_val >= 3
+class TestCreateBranchesAndCommitsFailureCleanup:
+ @patch("pr_split.cli.delete_branch")
+ @patch("pr_split.cli.commit_files_in_dir")
+ @patch("pr_split.cli.materialize_group_files", return_value={})
+ @patch("pr_split.cli.remove_worktree")
+ @patch("pr_split.cli.add_worktree")
+ def test_failed_group_branch_is_deleted_too(
+ self,
+ mock_add: MagicMock,
+ mock_remove: MagicMock,
+ mock_mat: MagicMock,
+ mock_commit: MagicMock,
+ mock_delete: MagicMock,
+ ) -> None:
+ def commit(worktree_path: str, *args: object, **kwargs: object) -> str:
+ if worktree_path.endswith("pr-2"):
+ raise PRSplitError("commit failed")
+ return "sha1"
+
+ mock_commit.side_effect = commit
+ groups = [_group("pr-1", "a"), _group("pr-2", "b")]
+ with pytest.raises(PRSplitError, match="1 branch\\(es\\) failed"):
+ _create_branches_and_commits(groups, MagicMock(), "main", "base_sha", "ns")
+ deleted = {call.args[0] for call in mock_delete.call_args_list}
+ assert deleted == {"pr-split/ns/pr-1", "pr-split/ns/pr-2"}
+
+ @patch("pr_split.cli.delete_branch")
+ @patch("pr_split.cli.commit_files_in_dir", return_value="sha1")
+ @patch("pr_split.cli.materialize_group_files", return_value={})
+ @patch("pr_split.cli.remove_worktree")
+ @patch("pr_split.cli.add_worktree")
+ def test_branch_restored_by_add_worktree_is_not_deleted(
+ self,
+ mock_add: MagicMock,
+ mock_remove: MagicMock,
+ mock_mat: MagicMock,
+ mock_commit: MagicMock,
+ mock_delete: MagicMock,
+ ) -> None:
+ # add_worktree restores a pre-existing branch when it fails; the
+ # cleanup must not then delete that restored branch.
+ def add(path: str, branch_name: str, start_point: str) -> None:
+ if branch_name.endswith("pr-2"):
+ raise GitOperationError("worktree add failed")
+
+ mock_add.side_effect = add
+ groups = [_group("pr-1", "a"), _group("pr-2", "b")]
+ with pytest.raises(PRSplitError):
+ _create_branches_and_commits(groups, MagicMock(), "main", "base_sha", "ns")
+ deleted = {call.args[0] for call in mock_delete.call_args_list}
+ assert deleted == {"pr-split/ns/pr-1"}
+
+
+class TestCreateBranchesAndCommitsUnstackedDag:
+ """Without --stack, dependency edges still decide branch start points and PR bases."""
+
+ @patch("pr_split.cli.commit_files_in_dir", return_value="sha1")
+ @patch("pr_split.cli.materialize_group_files", return_value={})
+ @patch("pr_split.cli.remove_worktree")
+ @patch("pr_split.cli.add_worktree")
+ def test_dependant_builds_on_and_targets_its_parent(
+ self,
+ mock_add: MagicMock,
+ mock_remove: MagicMock,
+ mock_mat: MagicMock,
+ mock_commit: MagicMock,
+ ) -> None:
+ groups = [_group("pr-1", "feat: base"), _group("pr-2", "feat: top", ["pr-1"])]
+ records = _create_branches_and_commits(groups, MagicMock(), "main", "base_sha", "ns")
+
+ start_points = {call.args[1]: call.args[2] for call in mock_add.call_args_list}
+ assert start_points == {
+ "pr-split/ns/pr-1": "base_sha",
+ "pr-split/ns/pr-2": "pr-split/ns/pr-1",
+ }
+ assert {r.group_id: r.base_branch for r in records} == {
+ "pr-1": "main",
+ "pr-2": "pr-split/ns/pr-1",
+ }
+
+ @patch("pr_split.cli.commit_files_in_dir", return_value="sha1")
+ @patch("pr_split.cli.materialize_group_files", return_value={})
+ @patch("pr_split.cli.remove_worktree")
+ @patch("pr_split.cli.add_worktree")
+ def test_independent_groups_all_target_the_base(
+ self,
+ mock_add: MagicMock,
+ mock_remove: MagicMock,
+ mock_mat: MagicMock,
+ mock_commit: MagicMock,
+ ) -> None:
+ groups = [_group("pr-1", "a"), _group("pr-2", "b")]
+ records = _create_branches_and_commits(groups, MagicMock(), "main", "base_sha", "ns")
+
+ assert {call.args[2] for call in mock_add.call_args_list} == {"base_sha"}
+ assert {r.base_branch for r in records} == {"main"}
+
+
class TestCreateBranchesAndCommitsStacked:
def _stacked_groups(self) -> list[Group]:
return [_group("pr-2", "feat: base"), _group("pr-3", "feat: top", ["pr-2"])]
@@ -277,6 +400,7 @@ def test_child_pr_base_is_parent_branch(
bases = {r.group_id: r.base_branch for r in records}
assert bases == {"pr-2": "main", "pr-3": "pr-split/ns/pr-2"}
+ @pytest.mark.parametrize("stacked", [True, False])
@patch("pr_split.cli.commit_files_in_dir", return_value="sha1")
@patch("pr_split.cli.materialize_group_files", return_value={})
@patch("pr_split.cli.remove_worktree")
@@ -287,20 +411,30 @@ def test_merge_node_falls_back_to_base_branch(
mock_remove: MagicMock,
mock_mat: MagicMock,
mock_commit: MagicMock,
+ stacked: bool,
) -> None:
groups = [
_group("pr-1", "a"),
_group("pr-2", "b"),
_group("pr-3", "c", ["pr-1", "pr-2"]),
]
+ for path, group in zip(("a.py", "b.py", "c.py"), groups, strict=True):
+ group.assignments = [
+ GroupAssignment(file_path=path, assignment_type=AssignmentType.WHOLE_FILE)
+ ]
records = _create_branches_and_commits(
- groups, MagicMock(), "main", "base_sha", "ns", stacked=True
+ groups, MagicMock(), "main", "base_sha", "ns", stacked=stacked
)
bases = {r.group_id: r.base_branch for r in records}
assert bases["pr-3"] == "main"
start_points = {call.args[1]: call.args[2] for call in mock_add.call_args_list}
assert start_points["pr-split/ns/pr-3"] == "base_sha"
+ # Built from the merge base, the merge node must carry both parents' files.
+ merge_calls = [call for call in mock_mat.call_args_list if call.args[1].id == "pr-3"]
+ carried = {a.file_path for a in merge_calls[0].args[1].assignments}
+ assert carried == {"a.py", "b.py", "c.py"}
+ @pytest.mark.parametrize("stacked", [True, False])
@patch("pr_split.cli.commit_files_in_dir", return_value="sha1")
@patch("pr_split.cli.materialize_group_files", return_value={})
@patch("pr_split.cli.remove_worktree")
@@ -311,6 +445,7 @@ def test_child_materializes_with_ancestor_hunks(
mock_remove: MagicMock,
mock_mat: MagicMock,
mock_commit: MagicMock,
+ stacked: bool,
) -> None:
parent = _group("pr-2", "feat: base")
parent.assignments = [
@@ -329,30 +464,12 @@ def test_child_materializes_with_ancestor_hunks(
)
]
_create_branches_and_commits(
- [parent, child], MagicMock(), "main", "base_sha", "ns", stacked=True
+ [parent, child], MagicMock(), "main", "base_sha", "ns", stacked=stacked
)
child_calls = [call for call in mock_mat.call_args_list if call.args[1].id == "pr-3"]
assert child_calls[0].args[1].assignments[0].hunk_indices == [0, 1]
assert child_calls[0].args[2] == "base_sha"
- @patch("pr_split.cli.commit_files_in_dir", return_value="sha1")
- @patch("pr_split.cli.materialize_group_files", return_value={})
- @patch("pr_split.cli.remove_worktree")
- @patch("pr_split.cli.add_worktree")
- def test_flat_mode_unchanged(
- self,
- mock_add: MagicMock,
- mock_remove: MagicMock,
- mock_mat: MagicMock,
- mock_commit: MagicMock,
- ) -> None:
- records = _create_branches_and_commits(
- self._stacked_groups(), MagicMock(), "main", "base_sha", "ns"
- )
- start_points = {call.args[1]: call.args[2] for call in mock_add.call_args_list}
- assert set(start_points.values()) == {"base_sha"}
- assert {r.base_branch for r in records} == {"main"}
-
class TestLinkStacks:
@patch("pr_split.cli.link_stack")
@@ -367,8 +484,34 @@ def test_links_only_chains_of_two_or_more(self, mock_link: MagicMock) -> None:
PRRecord(group_id="pr-2", pr_number=12, pr_url="u"),
PRRecord(group_id="pr-3", pr_number=13, pr_url="u"),
]
- _link_stacks(PlanDAG(groups), prs)
- mock_link.assert_called_once_with([12, 13])
+ branches = [
+ BranchRecord(group_id=g.id, branch_name=f"b/{g.id}", base_branch="feat/base")
+ for g in groups
+ ]
+ _link_stacks(PlanDAG(groups), prs, branches)
+ mock_link.assert_called_once_with([12, 13], base="feat/base")
+
+ @patch("pr_split.cli.link_stack")
+ def test_stack_is_rooted_on_its_bottom_prs_base_not_the_default_branch(
+ self, mock_link: MagicMock
+ ) -> None:
+ # pr-3 depends on two groups, so it targets the plan base directly and
+ # starts a new chain with pr-4; that stack must sit on the plan base.
+ groups = [
+ _group("pr-1", "a"),
+ _group("pr-2", "b"),
+ _group("pr-3", "c", ["pr-1", "pr-2"]),
+ _group("pr-4", "d", ["pr-3"]),
+ ]
+ prs = [PRRecord(group_id=g.id, pr_number=20 + i, pr_url="u") for i, g in enumerate(groups)]
+ branches = [
+ BranchRecord(group_id=g.id, branch_name=f"b/{g.id}", base_branch=base)
+ for g, base in zip(
+ groups, ["feat/base", "feat/base", "feat/base", "b/pr-3"], strict=True
+ )
+ ]
+ _link_stacks(PlanDAG(groups), prs, branches)
+ mock_link.assert_called_once_with([22, 23], base="feat/base")
class TestPushAndCreatePrsDraft:
@@ -431,6 +574,49 @@ def _merge_node_args(self) -> tuple[Group, str, str]:
if merged.id == "pr-3"
)
+ def test_grandchild_carries_grandparent_hunks_in_shared_file(self) -> None:
+ # A edits f.py hunk 0; B (child of A) edits only g.py; C (child of B)
+ # edits f.py hunk 1. C is rebuilt from the merge base, so it must
+ # carry A's hunk 0 or its commit reverts A's change.
+ a = _group("pr-a", "a")
+ a.assignments = [
+ GroupAssignment(
+ file_path="f.py",
+ assignment_type=AssignmentType.PARTIAL_HUNKS,
+ hunk_indices=[0],
+ )
+ ]
+ b = _group("pr-b", "b", ["pr-a"])
+ b.assignments = [
+ GroupAssignment(
+ file_path="g.py",
+ assignment_type=AssignmentType.PARTIAL_HUNKS,
+ hunk_indices=[0],
+ )
+ ]
+ c = _group("pr-c", "c", ["pr-b"])
+ c.assignments = [
+ GroupAssignment(
+ file_path="f.py",
+ assignment_type=AssignmentType.PARTIAL_HUNKS,
+ hunk_indices=[1],
+ )
+ ]
+ groups = [a, b, c]
+ batches = _stacked_batch_args(
+ PlanDAG(groups),
+ {g.id: g for g in groups},
+ {g.id: f"pr-split/ns/{g.id}" for g in groups},
+ "main",
+ "base_sha",
+ {"f.py": 2, "g.py": 1},
+ )
+ merged_c, base, start = next(
+ (m, bs, st) for batch in batches for m, bs, st in batch if m.id == "pr-c"
+ )
+ assert {a.file_path: a.hunk_indices for a in merged_c.assignments} == {"f.py": [0, 1]}
+ assert (base, start) == ("pr-split/ns/pr-b", "pr-split/ns/pr-b")
+
def test_merge_node_carries_both_parents_changes(self) -> None:
merged, _, _ = self._merge_node_args()
assert {a.file_path for a in merged.assignments} == {
@@ -574,3 +760,135 @@ def push(branch: str) -> None:
with pytest.raises(PRSplitError):
_push_and_create_prs(groups, records)
assert mock_create.call_count == 0
+
+
+class TestWorktreeWritesPreserveCrlf:
+ @patch("pr_split.cli.commit_files_in_dir", return_value="sha1")
+ @patch("pr_split.cli.materialize_group_files", return_value={"c.txt": "a\r\nb\r\n"})
+ @patch("pr_split.cli.remove_worktree")
+ @patch("pr_split.cli.add_worktree")
+ def test_crlf_content_written_verbatim(
+ self,
+ mock_add: MagicMock,
+ mock_remove: MagicMock,
+ mock_mat: MagicMock,
+ mock_commit: MagicMock,
+ ) -> None:
+ written: dict[str, bytes] = {}
+
+ def capture(cwd: str, file_paths: list[str], message: str, **kwargs: object) -> str:
+ for file_path in file_paths:
+ written[file_path] = (Path(cwd) / file_path).read_bytes()
+ return "sha1"
+
+ mock_commit.side_effect = capture
+ # On POSIX write_text only translates "\n" (a no-op), so also assert the
+ # newline="" argument that keeps CRLF intact on Windows.
+ with patch.object(Path, "write_text", autospec=True, side_effect=Path.write_text) as wt:
+ _create_branches_and_commits([_group("pr-1", "t")], MagicMock(), "main", "sha", "ns")
+ assert written["c.txt"] == b"a\r\nb\r\n"
+ content_writes = [c for c in wt.call_args_list if c.args[1] == "a\r\nb\r\n"]
+ assert content_writes and all(c.kwargs.get("newline") == "" for c in content_writes)
+
+
+class TestPlanProvenance:
+ def _plan(self, **fields: object) -> object:
+ from pr_split.constants import Priority
+ from pr_split.schemas import SplitPlan
+
+ return SplitPlan(
+ dev_branch="f", base_branch="main", max_loc=400, priority=Priority.ORTHOGONAL, **fields
+ )
+
+ def test_llm_plan_names_provider_and_model_and_warns(self) -> None:
+ from pr_split.cli import _plan_provenance
+
+ line = _plan_provenance(
+ self._plan(partition_strategy="llm", provider="anthropic", model="claude-x")
+ )
+ assert "Planned with llm (anthropic claude-x)" in line
+ assert "may give a different plan" in line
+
+ def test_graph_plan_does_not_warn(self) -> None:
+ from pr_split.cli import _plan_provenance
+
+ line = _plan_provenance(self._plan(partition_strategy="graph"))
+ assert "Planned with graph" in line
+ assert "different plan" not in line
+
+ def test_a_diff_within_max_loc_says_no_backend_ran(self) -> None:
+ from pr_split.cli import _plan_provenance
+ from pr_split.constants import NO_BACKEND_STRATEGY
+
+ line = _plan_provenance(self._plan(partition_strategy=NO_BACKEND_STRATEGY))
+ assert "no backend ran" in line
+ assert "different plan" not in line
+
+ def test_old_plans_without_provenance_still_load(self) -> None:
+ plan = self._plan()
+ assert plan.partition_strategy is None and plan.model is None
+
+
+class TestOversizedGroupsReport:
+ def _sized(self, gid: str, loc: int) -> Group:
+ group = _group(gid, gid)
+ group.estimated_loc = loc
+ return group
+
+ def test_summary_names_count_limit_and_largest(self) -> None:
+ from pr_split.cli import _report_oversized_groups, console
+
+ groups = [self._sized("pr-1", 876), self._sized("pr-2", 120), self._sized("pr-3", 401)]
+ with console.capture() as capture:
+ _report_oversized_groups(groups, 400, {})
+ out = " ".join(capture.get().split())
+ assert "2 of 3 groups exceed --max-loc 400 (largest: pr-1 at 876 LOC)" in out
+
+ def test_single_hunk_group_is_named_as_irreducible(self) -> None:
+ from pr_split.cli import _report_oversized_groups, console
+
+ big_file = self._sized("pr-1", 734)
+ big_file.assignments = [
+ GroupAssignment(
+ file_path="tests/test_big.py",
+ assignment_type=AssignmentType.WHOLE_FILE,
+ hunk_indices=[0],
+ )
+ ]
+ with console.capture() as capture:
+ _report_oversized_groups([big_file], 400, {"tests/test_big.py": 1})
+ out = " ".join(capture.get().split())
+ assert "pr-1 hold a single hunk each and cannot be split below the limit" in out
+
+ def test_whole_file_assignments_count_the_hunks_they_cover(self) -> None:
+ from pr_split.cli import _report_oversized_groups, console
+
+ # WHOLE_FILE may list no indices: count what it covers in the parsed diff.
+ new_file = self._sized("pr-1", 734)
+ new_file.assignments = [
+ GroupAssignment(file_path="big.py", assignment_type=AssignmentType.WHOLE_FILE)
+ ]
+ edited = self._sized("pr-2", 600)
+ edited.assignments = [
+ GroupAssignment(
+ file_path="multi.py", assignment_type=AssignmentType.WHOLE_FILE, hunk_indices=[0]
+ )
+ ]
+ with console.capture() as capture:
+ _report_oversized_groups([new_file, edited], 400, {"big.py": 1, "multi.py": 3})
+ out = " ".join(capture.get().split())
+ assert "pr-1 hold a single hunk each" in out
+ assert "pr-2 hold" not in out and "pr-1, pr-2" not in out
+
+ def test_nothing_printed_within_the_limit(self) -> None:
+ from pr_split.cli import _report_oversized_groups, console
+
+ with console.capture() as capture:
+ _report_oversized_groups([self._sized("pr-1", 400)], 400, {})
+ assert capture.get() == ""
+
+ def test_oversized_ids_are_recorded(self) -> None:
+ from pr_split.cli import _oversized_group_ids
+
+ groups = [self._sized("pr-1", 876), self._sized("pr-2", 400)]
+ assert _oversized_group_ids(groups, 400) == ["pr-1"]
diff --git a/tests/test_cli_new_features.py b/tests/test_cli_new_features.py
index e668f4e..4987110 100644
--- a/tests/test_cli_new_features.py
+++ b/tests/test_cli_new_features.py
@@ -13,15 +13,17 @@
_handle_loc_bound_warnings,
app,
)
-from pr_split.constants import AssignmentType
-from pr_split.exceptions import GitOperationError, PRSplitError
+from pr_split.constants import AssignmentType, ChunkStrategy, PartitionStrategy, Priority
+from pr_split.exceptions import GitOperationError, PlanValidationError, PRSplitError
from pr_split.git_ops.prs import get_pr_state, merge_pr
from pr_split.schemas import (
BranchRecord,
GitState,
Group,
GroupAssignment,
+ PlanFile,
PRRecord,
+ SplitPlan,
)
runner = CliRunner()
@@ -79,6 +81,17 @@ def test_includes_dependencies(self) -> None:
assert "## Dependencies" in body
assert "`pr-1`" in body
+ def test_merge_node_names_the_ancestors_its_diff_carries(self) -> None:
+ g0 = _group("pr-0", "root")
+ g1 = _group("pr-1", "left", depends_on=["pr-0"])
+ g2 = _group("pr-2", "right")
+ g3 = _group("pr-3", "join", depends_on=["pr-1", "pr-2"], files=["c.py"])
+ body = _build_pr_body(g3, [g0, g1, g2, g3])
+ assert "This PR depends on: `pr-1`, `pr-2`\n" in body
+ assert "also includes the changes of: `pr-0`, `pr-1`, `pr-2`." in body
+ single = _build_pr_body(g1, [g0, g1, g2, g3])
+ assert "also includes" not in single
+
def test_no_dependencies_section_for_root(self) -> None:
group = _group("pr-1", "root", files=["a.py"])
body = _build_pr_body(group, [group])
@@ -99,7 +112,7 @@ def test_custom_template(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch)
template_dir.mkdir()
template_file = template_dir / "template.md"
template_file.write_text("{description}\n\nFiles: {files}\nLOC: {loc}")
- monkeypatch.setattr("pr_split.cli._PR_TEMPLATE_PATH", template_file)
+ monkeypatch.setattr("pr_split.cli._pr_template_path", lambda: template_file)
group = _group("pr-1", "feat: auth", files=["auth.py"], added=10, removed=5)
body = _build_pr_body(group, [group])
@@ -114,7 +127,7 @@ def test_custom_template_invalid_placeholder(
template_dir.mkdir()
template_file = template_dir / "template.md"
template_file.write_text("{nonexistent}")
- monkeypatch.setattr("pr_split.cli._PR_TEMPLATE_PATH", template_file)
+ monkeypatch.setattr("pr_split.cli._pr_template_path", lambda: template_file)
group = _group("pr-1", "feat: auth", files=["auth.py"])
with pytest.raises(PRSplitError, match="Invalid PR template"):
@@ -122,6 +135,37 @@ def test_custom_template_invalid_placeholder(
class TestCleanupGitState:
+ @patch("pr_split.cli.get_pr_state", return_value={"state": "OPEN"})
+ @patch("pr_split.cli.delete_branch")
+ @patch("pr_split.cli.close_pr")
+ def test_adopted_prs_and_branches_are_left_alone(
+ self,
+ mock_close: MagicMock,
+ mock_delete: MagicMock,
+ mock_state: MagicMock,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ monkeypatch.chdir(tmp_path)
+ git_state = GitState(
+ branches=[
+ BranchRecord(
+ group_id="pr-1", branch_name="test/mine", base_branch="main", adopted=True
+ ),
+ BranchRecord(group_id="pr-2", branch_name="pr-split/ns/pr-2", base_branch="main"),
+ ],
+ prs=[
+ PRRecord(group_id="pr-1", pr_number=10, pr_url="url1", adopted=True),
+ PRRecord(group_id="pr-2", pr_number=11, pr_url="url2"),
+ ],
+ )
+ closed, deleted = _cleanup_git_state(git_state)
+ # Both count as done, so the plan is forgotten, but only pr-split's own are touched.
+ assert (closed, deleted) == (2, 2)
+ mock_close.assert_called_once_with(11)
+ mock_delete.assert_called_once_with("pr-split/ns/pr-2", remote=True)
+
+ @patch("pr_split.cli.get_pr_state", return_value={"state": "OPEN"})
@patch("pr_split.cli.shutil.rmtree")
@patch("pr_split.cli.Path")
@patch("pr_split.cli.delete_branch")
@@ -132,6 +176,7 @@ def test_closes_prs_and_deletes_branches(
mock_delete: MagicMock,
mock_path: MagicMock,
mock_rmtree: MagicMock,
+ mock_state: MagicMock,
) -> None:
mock_path.return_value.exists.return_value = True
git_state = GitState(
@@ -152,6 +197,7 @@ def test_closes_prs_and_deletes_branches(
mock_delete.assert_any_call("pr-split/ns/pr-1", remote=True)
mock_delete.assert_any_call("pr-split/ns/pr-2", remote=True)
+ @patch("pr_split.cli.get_pr_state", return_value={"state": "OPEN"})
@patch("pr_split.cli.shutil.rmtree")
@patch("pr_split.cli.Path")
@patch("pr_split.cli.delete_branch")
@@ -162,6 +208,7 @@ def test_handles_partial_failures(
mock_delete: MagicMock,
mock_path: MagicMock,
mock_rmtree: MagicMock,
+ mock_state: MagicMock,
) -> None:
mock_path.return_value.exists.return_value = True
mock_close.side_effect = [None, PRSplitError("fail")]
@@ -179,6 +226,8 @@ def test_handles_partial_failures(
closed, deleted = _cleanup_git_state(git_state)
assert closed == 1
assert deleted == 1
+ # Incomplete cleanup keeps the plan so 'clean' can be re-run.
+ mock_path.return_value.unlink.assert_not_called()
class TestGetPrState:
@@ -231,6 +280,152 @@ def test_strict_raises_exit(self, mock_warning: MagicMock, mock_print: MagicMock
mock_warning.assert_not_called()
+class TestSplitBranchCreationFailure:
+ @patch("pr_split.cli.save_plan")
+ @patch(
+ "pr_split.cli._create_branches_and_commits",
+ side_effect=PRSplitError("2 branch(es) failed"),
+ )
+ @patch("pr_split.cli.typer.confirm", return_value=True)
+ @patch("pr_split.cli.plan_exists", return_value=False)
+ @patch("pr_split.cli.merge_base", return_value="abc123")
+ @patch("pr_split.cli._interactive_edit")
+ @patch("pr_split.cli._present_plan")
+ @patch("pr_split.cli.validate_plan", return_value=[])
+ @patch("pr_split.cli.plan_split")
+ @patch("pr_split.cli.parse_diff")
+ @patch("pr_split.cli.extract_diff", return_value="diff --git a/a.py b/a.py\n")
+ @patch("pr_split.cli._validate_inputs")
+ @patch("pr_split.cli.branch_exists", return_value=True)
+ def test_split_reports_branch_creation_failure_cleanly(
+ self,
+ mock_branch_exists: MagicMock,
+ mock_validate_inputs: MagicMock,
+ mock_extract_diff: MagicMock,
+ mock_parse_diff: MagicMock,
+ mock_plan_split: MagicMock,
+ mock_validate_plan: MagicMock,
+ mock_present_plan: MagicMock,
+ mock_interactive_edit: MagicMock,
+ mock_merge_base: MagicMock,
+ mock_plan_exists: MagicMock,
+ mock_confirm: MagicMock,
+ mock_create: MagicMock,
+ mock_save_plan: MagicMock,
+ ) -> None:
+ parsed_diff = MagicMock()
+ parsed_diff.stats = {
+ "total_files": 1,
+ "total_added": 1,
+ "total_removed": 0,
+ "total_loc": 1,
+ }
+ group = _group("pr-1", "t", files=["a.py"])
+ mock_parse_diff.return_value = parsed_diff
+ mock_plan_split.return_value = [group]
+ mock_interactive_edit.return_value = [group]
+
+ result = runner.invoke(
+ app, ["split", "feature-branch"], env={"ANTHROPIC_API_KEY": "sk-test"}
+ )
+
+ assert result.exit_code == 1
+ assert result.exception is None or isinstance(result.exception, SystemExit)
+ assert "2 branch(es) failed" in result.output
+ mock_save_plan.assert_not_called()
+
+
+class TestSplitEditorEmptiedGroup:
+ @patch("pr_split.cli.save_plan")
+ @patch("pr_split.cli.merge_base", return_value="abc123")
+ @patch("pr_split.cli._interactive_edit")
+ @patch("pr_split.cli._present_plan")
+ @patch("pr_split.cli.validate_plan", return_value=[])
+ @patch("pr_split.cli.plan_split")
+ @patch("pr_split.cli.parse_diff")
+ @patch("pr_split.cli.extract_diff", return_value="diff --git a/a.py b/a.py\n")
+ @patch("pr_split.cli._validate_inputs")
+ @patch("pr_split.cli.branch_exists", return_value=True)
+ def test_emptied_group_is_dropped_and_plan_saved(
+ self,
+ mock_branch_exists: MagicMock,
+ mock_validate_inputs: MagicMock,
+ mock_extract_diff: MagicMock,
+ mock_parse_diff: MagicMock,
+ mock_plan_split: MagicMock,
+ mock_validate_plan: MagicMock,
+ mock_present_plan: MagicMock,
+ mock_interactive_edit: MagicMock,
+ mock_merge_base: MagicMock,
+ mock_save_plan: MagicMock,
+ ) -> None:
+ parsed_diff = MagicMock()
+ parsed_diff.stats = {
+ "total_files": 1,
+ "total_added": 1,
+ "total_removed": 0,
+ "total_loc": 1,
+ }
+ mock_parse_diff.return_value = parsed_diff
+ first = _group("pr-1", "keeps a", files=["a.py"])
+ second = _group("pr-2", "emptied", depends_on=["pr-1"])
+ mock_plan_split.return_value = [first, second]
+ mock_interactive_edit.return_value = [first, second]
+
+ result = runner.invoke(
+ app, ["split", "feature-branch", "--dry-run"], env={"ANTHROPIC_API_KEY": "sk-test"}
+ )
+
+ assert result.exit_code == 0, result.output
+ assert "Dropped empty group(s) after editing: pr-2" in result.output
+ saved = mock_save_plan.call_args[0][0].plan
+ assert [g.id for g in saved.groups] == ["pr-1"]
+ validated = mock_validate_plan.call_args_list[-1].args[0]
+ assert [g.id for g in validated] == ["pr-1"]
+
+ @patch("pr_split.cli.save_plan")
+ @patch("pr_split.cli.merge_base", return_value="abc123")
+ @patch("pr_split.cli._interactive_edit")
+ @patch("pr_split.cli._present_plan")
+ @patch("pr_split.cli.validate_plan", return_value=[])
+ @patch("pr_split.cli.plan_split")
+ @patch("pr_split.cli.parse_diff")
+ @patch("pr_split.cli.extract_diff", return_value="diff --git a/a.py b/a.py\n")
+ @patch("pr_split.cli._validate_inputs")
+ @patch("pr_split.cli.branch_exists", return_value=True)
+ def test_all_groups_emptied_is_an_error(
+ self,
+ mock_branch_exists: MagicMock,
+ mock_validate_inputs: MagicMock,
+ mock_extract_diff: MagicMock,
+ mock_parse_diff: MagicMock,
+ mock_plan_split: MagicMock,
+ mock_validate_plan: MagicMock,
+ mock_present_plan: MagicMock,
+ mock_interactive_edit: MagicMock,
+ mock_merge_base: MagicMock,
+ mock_save_plan: MagicMock,
+ ) -> None:
+ parsed_diff = MagicMock()
+ parsed_diff.stats = {
+ "total_files": 1,
+ "total_added": 1,
+ "total_removed": 0,
+ "total_loc": 1,
+ }
+ mock_parse_diff.return_value = parsed_diff
+ mock_plan_split.return_value = [_group("pr-1", "a", files=["a.py"])]
+ mock_interactive_edit.return_value = [_group("pr-1", "emptied")]
+
+ result = runner.invoke(
+ app, ["split", "feature-branch", "--dry-run"], env={"ANTHROPIC_API_KEY": "sk-test"}
+ )
+
+ assert result.exit_code == 1
+ assert "Every group is empty after editing; nothing to split." in result.output
+ mock_save_plan.assert_not_called()
+
+
class TestSplitCliEnvVars:
@patch("pr_split.cli.save_plan")
@patch("pr_split.cli.merge_base", return_value="abc123")
@@ -280,6 +475,59 @@ def test_split_uses_max_loc_envvar(
settings = mock_plan_split.call_args[0][1]
assert settings.max_loc == 123
+ @patch("pr_split.cli.save_plan")
+ @patch("pr_split.cli.merge_base", return_value="abc123")
+ @patch("pr_split.cli._interactive_edit")
+ @patch("pr_split.cli._present_plan")
+ @patch("pr_split.cli.validate_plan", return_value=[])
+ @patch("pr_split.cli.plan_split")
+ @patch("pr_split.cli.parse_diff")
+ @patch("pr_split.cli.extract_diff", return_value="diff --git a/a.py b/a.py\n")
+ @patch("pr_split.cli._validate_inputs")
+ @patch("pr_split.cli.branch_exists", return_value=True)
+ def test_split_uses_strategy_envvars(
+ self,
+ mock_branch_exists: MagicMock,
+ mock_validate_inputs: MagicMock,
+ mock_extract_diff: MagicMock,
+ mock_parse_diff: MagicMock,
+ mock_plan_split: MagicMock,
+ mock_validate_plan: MagicMock,
+ mock_present_plan: MagicMock,
+ mock_interactive_edit: MagicMock,
+ mock_merge_base: MagicMock,
+ mock_save_plan: MagicMock,
+ ) -> None:
+ parsed_diff = MagicMock()
+ parsed_diff.stats = {
+ "total_files": 1,
+ "total_added": 1,
+ "total_removed": 0,
+ "total_loc": 1,
+ }
+ group = _group("pr-1", "t", files=["a.py"])
+ mock_parse_diff.return_value = parsed_diff
+ mock_plan_split.return_value = [group]
+ mock_interactive_edit.return_value = [group]
+
+ result = runner.invoke(
+ app,
+ ["split", "feature-branch", "--dry-run"],
+ env={
+ "PR_SPLIT_PARTITION_STRATEGY": "graph",
+ "PR_SPLIT_PRIORITY": "logical",
+ "PR_SPLIT_CHUNK_STRATEGY": "greedy",
+ "PR_SPLIT_CP_SAT_TIMEOUT": "2.5",
+ },
+ )
+
+ assert result.exit_code == 0, result.output
+ settings = mock_plan_split.call_args[0][1]
+ assert settings.partition_strategy == PartitionStrategy.GRAPH
+ assert settings.priority == Priority.LOGICAL
+ assert settings.chunk_strategy == ChunkStrategy.GREEDY
+ assert settings.cp_sat_timeout == 2.5
+
@patch("pr_split.cli.save_plan")
@patch("pr_split.cli.merge_base", return_value="abc123")
@patch("pr_split.cli._interactive_edit")
@@ -332,3 +580,457 @@ def test_split_revalidates_min_loc_after_interactive_edit(
assert mock_validate_plan.call_args_list[0].kwargs["min_loc"] == 50
assert mock_validate_plan.call_args_list[1].kwargs["min_loc"] == 50
mock_save_plan.assert_not_called()
+
+
+class TestCorruptPlanFile:
+ @pytest.mark.parametrize("command", ["status", "clean", "execute", "merge"])
+ def test_commands_report_corrupt_plan(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, command: str
+ ) -> None:
+ monkeypatch.chdir(tmp_path)
+ (tmp_path / ".pr-split").mkdir()
+ (tmp_path / ".pr-split" / "plan.json").write_text("{not json")
+
+ result = runner.invoke(app, [command])
+
+ assert result.exit_code == 1
+ assert "Cannot load split plan" in result.output
+ assert not isinstance(result.exception, Exception) or isinstance(
+ result.exception, SystemExit
+ )
+
+ @patch("pr_split.cli._validate_inputs")
+ @patch("pr_split.cli.branch_exists", return_value=True)
+ def test_split_reports_corrupt_plan(
+ self,
+ mock_branch_exists: MagicMock,
+ mock_validate_inputs: MagicMock,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ monkeypatch.chdir(tmp_path)
+ (tmp_path / ".pr-split").mkdir()
+ (tmp_path / ".pr-split" / "plan.json").write_text("")
+
+ result = runner.invoke(
+ app, ["split", "feature-branch", "--dry-run"], env={"ANTHROPIC_API_KEY": "sk-test"}
+ )
+
+ assert result.exit_code == 1
+ assert "Cannot load split plan" in result.output
+
+
+class TestCleanupSkipsFinishedPrs:
+ @patch("pr_split.cli.get_pr_state")
+ @patch("pr_split.cli.shutil.rmtree")
+ @patch("pr_split.cli.plan_path")
+ @patch("pr_split.cli.delete_branch")
+ @patch("pr_split.cli.close_pr")
+ def test_merged_and_closed_prs_are_not_closed_again_but_count_as_done(
+ self,
+ mock_close: MagicMock,
+ mock_delete: MagicMock,
+ mock_path: MagicMock,
+ mock_rmtree: MagicMock,
+ mock_state: MagicMock,
+ ) -> None:
+ mock_path.return_value.exists.return_value = True
+ mock_state.side_effect = lambda n: {
+ 7: {"state": "MERGED"},
+ 8: {"state": "CLOSED"},
+ 9: {"state": "OPEN"},
+ }[n]
+ git_state = GitState(
+ prs=[
+ PRRecord(group_id="pr-1", pr_number=7, pr_url="u"),
+ PRRecord(group_id="pr-2", pr_number=8, pr_url="u"),
+ PRRecord(group_id="pr-3", pr_number=9, pr_url="u"),
+ ]
+ )
+ with patch("pr_split.cli.logger") as mock_logger:
+ closed, deleted = _cleanup_git_state(git_state)
+
+ assert (closed, deleted) == (3, 0)
+ mock_close.assert_called_once_with(9)
+ mock_logger.warning.assert_not_called()
+ infos = " ".join(str(c.args[0]) for c in mock_logger.info.call_args_list)
+ assert "PR #7 is already merged" in infos
+ assert "PR #8 is already closed" in infos
+ mock_path.return_value.unlink.assert_called_once()
+
+ @patch("pr_split.cli.get_pr_state", return_value={"state": "OPEN"})
+ @patch("pr_split.cli.shutil.rmtree")
+ @patch("pr_split.cli.plan_path")
+ @patch("pr_split.cli.delete_branch")
+ @patch("pr_split.cli.close_pr", side_effect=GitOperationError("gh: rate limited"))
+ def test_close_failure_warning_includes_the_reason(
+ self,
+ mock_close: MagicMock,
+ mock_delete: MagicMock,
+ mock_path: MagicMock,
+ mock_rmtree: MagicMock,
+ mock_state: MagicMock,
+ ) -> None:
+ mock_path.return_value.exists.return_value = True
+ git_state = GitState(prs=[PRRecord(group_id="pr-1", pr_number=7, pr_url="u")])
+ with patch("pr_split.cli.logger") as mock_logger:
+ closed, _ = _cleanup_git_state(git_state)
+ assert closed == 0
+ assert "Could not close PR #7: gh: rate limited" in str(mock_logger.warning.call_args)
+ mock_path.return_value.unlink.assert_not_called()
+
+
+class TestCleanupAfterMergeDeletedBranches:
+ @patch("pr_split.cli.get_pr_state", return_value={"state": "MERGED"})
+ @patch("pr_split.cli.shutil.rmtree")
+ @patch("pr_split.cli.plan_path")
+ @patch("pr_split.git_ops.branches.run_git")
+ @patch("pr_split.cli.close_pr")
+ def test_fully_merged_split_cleans_up_completely(
+ self,
+ mock_close: MagicMock,
+ mock_git: MagicMock,
+ mock_path: MagicMock,
+ mock_rmtree: MagicMock,
+ mock_state: MagicMock,
+ ) -> None:
+ mock_path.return_value.exists.return_value = True
+ # merge --delete-branch already removed both local and remote branches
+ mock_git.side_effect = [
+ GitOperationError("error: branch 'b1' not found."),
+ GitOperationError("error: unable to delete 'b1': remote ref does not exist"),
+ ]
+ git_state = GitState(
+ branches=[BranchRecord(group_id="pr-1", branch_name="b1", base_branch="main")],
+ prs=[PRRecord(group_id="pr-1", pr_number=7, pr_url="u")],
+ )
+ closed, deleted = _cleanup_git_state(git_state)
+ assert (closed, deleted) == (1, 1)
+ mock_close.assert_not_called()
+ mock_path.return_value.unlink.assert_called_once()
+
+
+def _plan_with_prs(groups: list[Group]) -> MagicMock:
+ plan_file = MagicMock()
+ plan_file.plan.groups = groups
+ plan_file.git_state.prs = [
+ PRRecord(group_id=g.id, pr_number=index + 1, pr_url=f"url{index + 1}")
+ for index, g in enumerate(groups)
+ ]
+ return plan_file
+
+
+class TestMergeBlockedDependencies:
+ @patch("pr_split.cli._send_webhook")
+ @patch("pr_split.cli.merge_pr")
+ @patch("pr_split.cli.get_pr_state")
+ @patch("pr_split.cli.load_plan")
+ @patch("pr_split.cli.plan_exists", return_value=True)
+ def test_child_of_skipped_parent_is_not_merged(
+ self,
+ mock_exists: MagicMock,
+ mock_load: MagicMock,
+ mock_state: MagicMock,
+ mock_merge: MagicMock,
+ mock_webhook: MagicMock,
+ ) -> None:
+ groups = [
+ _group("pr-1", "parent", files=["a.py"]),
+ _group("pr-2", "child", depends_on=["pr-1"], files=["b.py"]),
+ ]
+ mock_load.return_value = _plan_with_prs(groups)
+ mock_state.side_effect = lambda n: (
+ {"state": "OPEN", "isDraft": True, "reviewDecision": "APPROVED"}
+ if n == 1
+ else {"state": "OPEN", "isDraft": False, "reviewDecision": "APPROVED"}
+ )
+
+ result = runner.invoke(app, ["merge", "--notify", "https://example.invalid/hook"])
+
+ mock_merge.assert_not_called()
+ assert result.exit_code == 1
+ assert "pr-2 (dependency pr-1 not merged)" in result.output
+ payload = mock_webhook.call_args[0][1]
+ assert payload["success"] is False
+ assert payload["exit_reason"] == "unmerged_dependency"
+
+ @patch("pr_split.cli.merge_pr")
+ @patch("pr_split.cli.get_pr_state")
+ @patch("pr_split.cli.load_plan")
+ @patch("pr_split.cli.plan_exists", return_value=True)
+ def test_child_of_group_without_pr_is_not_merged(
+ self,
+ mock_exists: MagicMock,
+ mock_load: MagicMock,
+ mock_state: MagicMock,
+ mock_merge: MagicMock,
+ ) -> None:
+ groups = [
+ _group("pr-1", "parent", files=["a.py"]),
+ _group("pr-2", "child", depends_on=["pr-1"], files=["b.py"]),
+ ]
+ plan_file = _plan_with_prs(groups)
+ plan_file.git_state.prs = plan_file.git_state.prs[1:]
+ mock_load.return_value = plan_file
+ mock_state.return_value = {"state": "OPEN", "isDraft": False, "reviewDecision": None}
+
+ result = runner.invoke(app, ["merge"])
+
+ mock_merge.assert_not_called()
+ assert result.exit_code == 1
+ assert "pr-2 (dependency pr-1 not merged)" in result.output
+
+ @patch("pr_split.cli.merge_pr")
+ @patch("pr_split.cli.get_pr_state")
+ @patch("pr_split.cli.load_plan")
+ @patch("pr_split.cli.plan_exists", return_value=True)
+ def test_independent_subtree_still_merges(
+ self,
+ mock_exists: MagicMock,
+ mock_load: MagicMock,
+ mock_state: MagicMock,
+ mock_merge: MagicMock,
+ ) -> None:
+ groups = [
+ _group("pr-1", "draft parent", files=["a.py"]),
+ _group("pr-2", "child", depends_on=["pr-1"], files=["b.py"]),
+ _group("pr-3", "independent", files=["c.py"]),
+ ]
+ mock_load.return_value = _plan_with_prs(groups)
+ mock_state.side_effect = lambda n: {
+ "state": "OPEN",
+ "isDraft": n == 1,
+ "reviewDecision": None,
+ }
+
+ result = runner.invoke(app, ["merge"])
+
+ mock_merge.assert_called_once_with(3, auto=False)
+ assert result.exit_code == 1
+ assert "Merged (1): pr-3" in result.output
+
+ @patch("pr_split.cli.merge_pr")
+ @patch("pr_split.cli.get_pr_state")
+ @patch("pr_split.cli.load_plan")
+ @patch("pr_split.cli.plan_exists", return_value=True)
+ def test_already_merged_child_under_unmerged_parent_is_not_blocked(
+ self,
+ mock_exists: MagicMock,
+ mock_load: MagicMock,
+ mock_state: MagicMock,
+ mock_merge: MagicMock,
+ ) -> None:
+ groups = [
+ _group("pr-1", "draft parent", files=["a.py"]),
+ _group("pr-2", "child already merged", depends_on=["pr-1"], files=["b.py"]),
+ _group("pr-3", "grandchild", depends_on=["pr-2"], files=["c.py"]),
+ ]
+ mock_load.return_value = _plan_with_prs(groups)
+ states = {
+ 1: {"state": "OPEN", "isDraft": True, "reviewDecision": None},
+ 2: {"state": "MERGED", "isDraft": False, "reviewDecision": None},
+ 3: {"state": "OPEN", "isDraft": False, "reviewDecision": None},
+ }
+ mock_state.side_effect = lambda n: states[n]
+
+ result = runner.invoke(app, ["merge"])
+
+ mock_merge.assert_called_once_with(3, auto=False)
+ assert "pr-2 (dependency" not in result.output
+ assert "Merged (2): pr-2, pr-3" in result.output
+
+ @patch("pr_split.cli.merge_pr")
+ @patch("pr_split.cli.get_pr_state")
+ @patch("pr_split.cli.load_plan")
+ @patch("pr_split.cli.plan_exists", return_value=True)
+ def test_child_of_merged_parent_merges(
+ self,
+ mock_exists: MagicMock,
+ mock_load: MagicMock,
+ mock_state: MagicMock,
+ mock_merge: MagicMock,
+ ) -> None:
+ groups = [
+ _group("pr-1", "parent", files=["a.py"]),
+ _group("pr-2", "child", depends_on=["pr-1"], files=["b.py"]),
+ ]
+ mock_load.return_value = _plan_with_prs(groups)
+ mock_state.return_value = {"state": "OPEN", "isDraft": False, "reviewDecision": None}
+
+ result = runner.invoke(app, ["merge"])
+
+ assert [c.args[0] for c in mock_merge.call_args_list] == [1, 2]
+ assert result.exit_code == 0
+
+
+class TestMergeRejectsMalformedSavedPlan:
+ def _plan_file(self, groups: list[Group]) -> PlanFile:
+ from pr_split.constants import Priority
+
+ plan = SplitPlan(
+ dev_branch="feature",
+ base_branch="main",
+ max_loc=400,
+ priority=Priority.ORTHOGONAL,
+ groups=groups,
+ )
+ prs = [PRRecord(group_id=g.id, pr_number=i + 1, pr_url="u") for i, g in enumerate(groups)]
+ return PlanFile(plan=plan, git_state=GitState(prs=prs))
+
+ @patch("pr_split.cli.get_pr_state")
+ @patch("pr_split.cli.load_plan")
+ @patch("pr_split.cli.plan_exists", return_value=True)
+ def test_unknown_dependency_is_a_clean_error(
+ self, mock_pe: MagicMock, mock_load: MagicMock, mock_state: MagicMock
+ ) -> None:
+ mock_load.return_value = self._plan_file(
+ [_group("pr-1", "a", files=["a.py"]), _group("pr-2", "b", depends_on=["pr-9"])]
+ )
+ result = runner.invoke(app, ["merge"])
+ assert result.exit_code == 1
+ assert result.exception is None or isinstance(result.exception, SystemExit)
+ assert "depends on unknown group 'pr-9'" in result.output
+ mock_state.assert_not_called()
+
+ @patch("pr_split.cli.get_pr_state")
+ @patch("pr_split.cli.load_plan")
+ @patch("pr_split.cli.plan_exists", return_value=True)
+ def test_cycle_is_a_clean_error(
+ self, mock_pe: MagicMock, mock_load: MagicMock, mock_state: MagicMock
+ ) -> None:
+ mock_load.return_value = self._plan_file(
+ [
+ _group("pr-1", "a", depends_on=["pr-2"], files=["a.py"]),
+ _group("pr-2", "b", depends_on=["pr-1"], files=["b.py"]),
+ ]
+ )
+ result = runner.invoke(app, ["merge"])
+ assert result.exit_code == 1
+ assert result.exception is None or isinstance(result.exception, SystemExit)
+ assert "Dependency cycle detected" in result.output
+ mock_state.assert_not_called()
+
+
+class TestSplitPlannerErrors:
+ @patch("pr_split.cli.save_plan")
+ @patch("pr_split.cli.plan_split", side_effect=PlanValidationError("Dependency cycle detected"))
+ @patch("pr_split.cli.parse_diff")
+ @patch("pr_split.cli.extract_diff", return_value="diff --git a/a.py b/a.py\n")
+ @patch("pr_split.cli._validate_inputs")
+ @patch("pr_split.cli.branch_exists", return_value=True)
+ def test_planner_error_is_reported_not_raised(
+ self,
+ mock_branch_exists: MagicMock,
+ mock_validate_inputs: MagicMock,
+ mock_extract_diff: MagicMock,
+ mock_parse_diff: MagicMock,
+ mock_plan_split: MagicMock,
+ mock_save_plan: MagicMock,
+ ) -> None:
+ parsed_diff = MagicMock()
+ parsed_diff.stats = {
+ "total_files": 1,
+ "total_added": 1,
+ "total_removed": 0,
+ "total_loc": 1,
+ }
+ mock_parse_diff.return_value = parsed_diff
+
+ result = runner.invoke(
+ app,
+ ["split", "feature-branch", "--dry-run"],
+ env={"ANTHROPIC_API_KEY": "sk-test"},
+ )
+
+ assert result.exit_code == 1
+ assert result.exception is None or isinstance(result.exception, SystemExit)
+ assert "Dependency cycle detected" in result.output
+ mock_save_plan.assert_not_called()
+
+
+class TestSplitMalformedLlmOutput:
+ @patch("pr_split.cli.save_plan")
+ @patch("pr_split.planner.client._count_tokens", return_value=10)
+ @patch("pr_split.planner.client._call_llm")
+ @patch("pr_split.cli.extract_diff")
+ @patch("pr_split.cli._validate_inputs")
+ @patch("pr_split.cli.branch_exists", return_value=True)
+ def test_invalid_assignment_type_is_reported_not_raised(
+ self,
+ mock_branch_exists: MagicMock,
+ mock_validate_inputs: MagicMock,
+ mock_extract_diff: MagicMock,
+ mock_call_llm: MagicMock,
+ mock_count_tokens: MagicMock,
+ mock_save_plan: MagicMock,
+ ) -> None:
+ mock_extract_diff.return_value = (
+ "diff --git a/a.py b/a.py\n--- a/a.py\n+++ b/a.py\n@@ -1 +1,2 @@\n-x\n+y\n+z\n"
+ )
+ mock_call_llm.return_value = {
+ "groups": [
+ {
+ "id": "pr-1",
+ "title": "t",
+ "description": "d",
+ "depends_on": [],
+ "assignments": [
+ {"file_path": "a.py", "assignment_type": "bogus", "hunk_indices": [0]}
+ ],
+ }
+ ]
+ }
+
+ result = runner.invoke(
+ app,
+ ["split", "feature-branch", "--dry-run", "--max-loc", "2"],
+ env={"ANTHROPIC_API_KEY": "sk-test"},
+ )
+
+ assert result.exit_code == 1
+ assert result.exception is None or isinstance(result.exception, SystemExit)
+ assert "Failed to parse LLM response" in result.output
+ mock_save_plan.assert_not_called()
+
+
+class TestSplitInvalidPlanFromPlanner:
+ @patch("pr_split.cli.save_plan")
+ @patch("pr_split.cli.plan_split")
+ @patch("pr_split.cli.extract_diff")
+ @patch("pr_split.cli._validate_inputs")
+ @patch("pr_split.cli.branch_exists", return_value=True)
+ def test_coverage_gap_is_reported_not_raised(
+ self,
+ mock_branch_exists: MagicMock,
+ mock_validate_inputs: MagicMock,
+ mock_extract_diff: MagicMock,
+ mock_plan_split: MagicMock,
+ mock_save_plan: MagicMock,
+ ) -> None:
+ mock_extract_diff.return_value = (
+ "diff --git a/f.py b/f.py\n--- a/f.py\n+++ b/f.py\n"
+ "@@ -1 +1 @@\n-a\n+b\n@@ -10 +10 @@\n-c\n+d\n"
+ )
+ only_first_hunk = Group(
+ id="pr-1",
+ title="t",
+ description="d",
+ assignments=[
+ GroupAssignment(
+ file_path="f.py",
+ assignment_type=AssignmentType.PARTIAL_HUNKS,
+ hunk_indices=[0],
+ )
+ ],
+ estimated_loc=2,
+ )
+ mock_plan_split.return_value = [only_first_hunk]
+
+ result = runner.invoke(
+ app, ["split", "feature-branch", "--dry-run"], env={"ANTHROPIC_API_KEY": "sk-test"}
+ )
+
+ assert result.exit_code == 1
+ assert result.exception is None or isinstance(result.exception, SystemExit)
+ assert "Hunk f.py[1] not assigned to any group" in result.output
+ mock_save_plan.assert_not_called()
diff --git a/tests/test_client.py b/tests/test_client.py
index 1bdfffa..c705628 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -4,12 +4,14 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
+import anthropic
import pytest
from pr_split.config import Settings
from pr_split.constants import AssignmentType, PartitionStrategy, Provider
from pr_split.diff_ops.parser import parse_diff
-from pr_split.exceptions import LLMError, PRSplitError
+from pr_split.exceptions import ErrorMsg, LLMError, PRSplitError
+from pr_split.graph import PlanDAG
from pr_split.planner.client import (
RawToolOutput,
_call_anthropic,
@@ -29,6 +31,7 @@
plan_split,
)
from pr_split.planner.prompts import SPLIT_TOOL_NAME
+from pr_split.planner.validator import validate_plan
from pr_split.schemas import Group, GroupAssignment
@@ -165,6 +168,53 @@ def test_empty_groups_list(self) -> None:
raw = RawToolOutput(groups=[])
assert _parse_groups(raw) == []
+ @pytest.mark.parametrize(
+ ("entry", "detail"),
+ [
+ (
+ {
+ "id": "pr-1",
+ "title": "t",
+ "depends_on": [],
+ "assignments": [],
+ "estimated_loc": 1,
+ },
+ "KeyError",
+ ),
+ (
+ {
+ "id": "pr-1",
+ "title": "t",
+ "description": "d",
+ "depends_on": [],
+ "assignments": [
+ {"file_path": "a.py", "assignment_type": "whole", "hunk_indices": []}
+ ],
+ "estimated_loc": 1,
+ },
+ "ValueError",
+ ),
+ (
+ {
+ "id": "pr-1",
+ "title": "t",
+ "description": "d",
+ "depends_on": [],
+ "assignments": [
+ {"file_path": "a.py", "assignment_type": "whole_file", "hunk_indices": "0"}
+ ],
+ "estimated_loc": 1,
+ },
+ "ValidationError",
+ ),
+ ],
+ ids=["missing-key", "bad-enum", "wrong-type"],
+ )
+ def test_malformed_entry_raises_llm_error(self, entry: dict, detail: str) -> None:
+ raw = RawToolOutput(groups=[entry])
+ with pytest.raises(LLMError, match=f"Failed to parse LLM response: {detail}"):
+ _parse_groups(raw)
+
def test_preserves_estimated_loc(self) -> None:
raw = RawToolOutput(
groups=[
@@ -243,8 +293,10 @@ def test_graph_backend_uses_partition_diff(
new file mode 100644
--- /dev/null
+++ b/a.py
-@@ -0,0 +1 @@
+@@ -0,0 +1,3 @@
+x
++y
++z
"""
)
mock_partition.return_value = [Group(id="pr-1", title="t", description="d")]
@@ -265,11 +317,34 @@ def test_graph_backend_uses_partition_diff(
settings = Settings(
provider=Provider.ANTHROPIC,
partition_strategy=PartitionStrategy.GRAPH,
+ max_loc=2,
)
groups = plan_split(parsed, settings)
assert len(groups) == 1
mock_partition.assert_called_once()
+ @pytest.mark.parametrize("strategy", list(PartitionStrategy))
+ @patch("pr_split.planner.client._plan_split_with_llm")
+ @patch("pr_split.planner.client.partition_diff")
+ def test_diff_within_max_loc_is_one_group_without_a_backend(
+ self,
+ mock_partition: MagicMock,
+ mock_llm: MagicMock,
+ monkeypatch: pytest.MonkeyPatch,
+ strategy: PartitionStrategy,
+ ) -> None:
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-rejected-by-the-server")
+ parsed = parse_diff(_TWO_FILE_DIFF)
+ settings = Settings(provider=Provider.ANTHROPIC, partition_strategy=strategy)
+
+ groups = plan_split(parsed, settings)
+
+ mock_llm.assert_not_called()
+ mock_partition.assert_not_called()
+ assert [g.id for g in groups] == ["pr-1"]
+ assert groups[0].estimated_loc == parsed.stats["total_loc"]
+ assert validate_plan(groups, parsed, PlanDAG(groups), settings.max_loc) == []
+
def test_unsupported_partition_strategy_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
parsed = parse_diff(
@@ -464,6 +539,147 @@ def test_refinement_stops_on_llm_error(
assert len(result) == 2
mock_call_llm.assert_called_once()
+ @patch("pr_split.planner.client._call_llm")
+ def test_refinement_falls_back_on_malformed_response(
+ self, mock_call_llm, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
+ parsed = parse_diff(_TWO_FILE_DIFF)
+ settings = Settings(
+ partition_strategy=PartitionStrategy.GRAPH,
+ min_loc=5,
+ max_loc=10,
+ max_refinement_iterations=3,
+ )
+ # Missing 'description' used to escape as KeyError and abort the split.
+ mock_call_llm.return_value = RawToolOutput(
+ groups=[{"id": "pr-1", "title": "t", "depends_on": [], "assignments": []}]
+ )
+ groups = _undersized_groups()
+ result = _refine_plan_with_llm(groups, parsed, settings, system="system")
+ assert result == groups
+ mock_call_llm.assert_called_once()
+
+ @patch("pr_split.planner.client._call_llm")
+ def test_refinement_that_drops_hunks_is_rejected(
+ self, mock_call_llm: MagicMock, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
+ parsed = parse_diff(_TWO_FILE_DIFF)
+ settings = Settings(
+ partition_strategy=PartitionStrategy.GRAPH,
+ min_loc=5,
+ max_loc=10,
+ max_refinement_iterations=2,
+ )
+ # Only a.py survives: b.py's hunk is gone from the plan.
+ mock_call_llm.return_value = RawToolOutput(
+ groups=[
+ {
+ "id": "pr-1",
+ "title": "feat: add a",
+ "description": "Only a",
+ "depends_on": [],
+ "assignments": [
+ {"file_path": "a.py", "assignment_type": "whole_file", "hunk_indices": [0]}
+ ],
+ "estimated_loc": 3,
+ }
+ ]
+ )
+
+ groups = _undersized_groups()
+ with patch("pr_split.planner.client.logger") as mock_logger:
+ result = _refine_plan_with_llm(groups, parsed, settings, system="system")
+
+ assert result is groups
+ assert [g.id for g in result] == ["pr-1", "pr-2"]
+ mock_call_llm.assert_called_once()
+ warning = mock_logger.warning.call_args[0][0]
+ assert "produced an invalid plan" in warning
+ assert "b.py[0] not assigned to any group" in warning
+
+ @pytest.mark.parametrize(
+ ("ids", "deps", "reason"),
+ [
+ (["pr-1", "pr-2"], [["pr-2"], ["pr-1"]], "cycle"),
+ (["pr-1", "pr-2"], [[], ["pr-9"]], "unknown group 'pr-9'"),
+ (["pr-1", "pr-1"], [[], []], "used more than once"),
+ ],
+ ids=["cycle", "unknown-dependency", "duplicate-id"],
+ )
+ @patch("pr_split.planner.client._call_llm")
+ def test_refinement_with_a_broken_dependency_graph_is_rejected(
+ self,
+ mock_call_llm: MagicMock,
+ monkeypatch: pytest.MonkeyPatch,
+ ids: list[str],
+ deps: list[list[str]],
+ reason: str,
+ ) -> None:
+ monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
+ parsed = parse_diff(_TWO_FILE_DIFF)
+ settings = Settings(
+ partition_strategy=PartitionStrategy.GRAPH,
+ min_loc=5,
+ max_loc=10,
+ max_refinement_iterations=2,
+ )
+ mock_call_llm.return_value = RawToolOutput(
+ groups=[
+ {
+ "id": gid,
+ "title": f"feat: {path}",
+ "description": path,
+ "depends_on": dep,
+ "assignments": [
+ {"file_path": path, "assignment_type": "whole_file", "hunk_indices": [0]}
+ ],
+ "estimated_loc": 3,
+ }
+ for gid, dep, path in zip(ids, deps, ["a.py", "b.py"], strict=True)
+ ]
+ )
+
+ groups = _undersized_groups()
+ with patch("pr_split.planner.client.logger") as mock_logger:
+ result = _refine_plan_with_llm(groups, parsed, settings, system="system")
+
+ assert result is groups
+ warning = mock_logger.warning.call_args[0][0]
+ assert "produced an invalid plan" in warning
+ assert reason in warning
+
+ @patch("pr_split.planner.client._call_llm")
+ def test_refinement_that_does_not_improve_is_rejected(
+ self, mock_call_llm: MagicMock, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
+ parsed = parse_diff(_TWO_FILE_DIFF)
+ settings = Settings(
+ partition_strategy=PartitionStrategy.GRAPH,
+ min_loc=5,
+ max_loc=10,
+ max_refinement_iterations=3,
+ )
+ # Same two undersized groups handed straight back.
+ mock_call_llm.return_value = RawToolOutput(
+ groups=_groups_to_raw_dicts(_undersized_groups()) # type: ignore[typeddict-item]
+ )
+
+ groups = _undersized_groups()
+ with patch("pr_split.planner.client.logger") as mock_logger:
+ result = _refine_plan_with_llm(groups, parsed, settings, system="system")
+
+ assert result is groups
+ mock_call_llm.assert_called_once()
+ warning = mock_logger.warning.call_args[0][0]
+ assert "did not reduce violations (2 -> 2)" in warning
+
def test_no_refinement_when_min_loc_is_none(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
@@ -547,6 +763,26 @@ def test_returns_input_tokens(self, mock_cls: MagicMock) -> None:
assert result == 42
mock_client.messages.count_tokens.assert_called_once()
+ @patch("pr_split.planner.client.anthropic.Anthropic")
+ def test_api_failure_is_an_llm_error(self, mock_cls: MagicMock) -> None:
+ mock_client = mock_cls.return_value
+ mock_client.messages.count_tokens.side_effect = anthropic.APIConnectionError(
+ request=MagicMock()
+ )
+ settings = _make_settings(Provider.ANTHROPIC)
+ with pytest.raises(LLMError, match="Could not count prompt tokens"):
+ _count_tokens_anthropic("sys", "usr", settings=settings)
+
+ @patch("pr_split.planner.client.anthropic.Anthropic")
+ def test_rejected_key_names_the_variable(self, mock_cls: MagicMock) -> None:
+ response = MagicMock(status_code=401, headers={})
+ mock_cls.return_value.messages.count_tokens.side_effect = anthropic.AuthenticationError(
+ "API key is invalid.", response=response, body=None
+ )
+ settings = _make_settings(Provider.ANTHROPIC)
+ with pytest.raises(LLMError, match="ANTHROPIC_API_KEY was rejected"):
+ _count_tokens_anthropic("sys", "usr", settings=settings)
+
# ---------------------------------------------------------------------------
# _count_tokens_openai
@@ -641,7 +877,7 @@ def test_no_tool_use_block_raises(self, mock_cls: MagicMock) -> None:
_call_anthropic("sys", "usr", settings=settings)
@patch("pr_split.planner.client.anthropic.Anthropic")
- def test_stop_reason_not_tool_use_still_succeeds(self, mock_cls: MagicMock) -> None:
+ def test_stop_reason_not_tool_use_raises(self, mock_cls: MagicMock) -> None:
from anthropic.types.beta import BetaToolUseBlock
tool_block = BetaToolUseBlock(
@@ -656,8 +892,8 @@ def test_stop_reason_not_tool_use_still_succeeds(self, mock_cls: MagicMock) -> N
)
mock_cls.return_value.beta.messages.create.return_value = mock_response
settings = _make_settings(Provider.ANTHROPIC)
- result = _call_anthropic("sys", "usr", settings=settings)
- assert result["groups"] == _SAMPLE_RAW_GROUPS
+ with pytest.raises(LLMError, match=r"cut off.*stop_reason=max_tokens"):
+ _call_anthropic("sys", "usr", settings=settings)
# ---------------------------------------------------------------------------
@@ -986,3 +1222,259 @@ def test_large_diff_uses_chunking(
result = _plan_split_with_llm(parsed, settings)
assert len(result) == 1
mock_chunked.assert_called_once()
+
+
+class TestChunkedPlanningSkipsRefinement:
+ @patch("pr_split.planner.client._call_llm")
+ @patch("pr_split.planner.client._plan_split_chunked")
+ @patch("pr_split.planner.client._count_tokens", return_value=10**9)
+ def test_no_refinement_call_after_chunking(
+ self, mock_count: MagicMock, mock_chunked: MagicMock, mock_call: MagicMock
+ ) -> None:
+ parsed = parse_diff(_TWO_FILE_DIFF)
+ settings = _make_settings(
+ Provider.ANTHROPIC, max_refinement_iterations=2, min_loc=50, max_loc=100
+ )
+ oversized = Group(id="pr-1", title="t", description="d", estimated_loc=500)
+ mock_chunked.return_value = [oversized]
+
+ result = _plan_split_with_llm(parsed, settings)
+
+ assert result == [oversized]
+ mock_call.assert_not_called()
+
+ @patch("pr_split.planner.client._refine_plan_with_llm")
+ @patch("pr_split.planner.client._call_llm")
+ @patch("pr_split.planner.client._count_tokens", return_value=1)
+ def test_single_shot_planning_still_refines(
+ self, mock_count: MagicMock, mock_call: MagicMock, mock_refine: MagicMock
+ ) -> None:
+ parsed = parse_diff(_TWO_FILE_DIFF)
+ settings = _make_settings(Provider.ANTHROPIC, max_refinement_iterations=2)
+ mock_call.return_value = {"groups": []}
+ mock_refine.return_value = []
+
+ _plan_split_with_llm(parsed, settings)
+
+ mock_refine.assert_called_once()
+
+
+class TestOpenAIIncompleteResponse:
+ @patch("pr_split.planner.client.openai.OpenAI")
+ def test_incomplete_status_raises(self, mock_cls: MagicMock) -> None:
+ item = SimpleNamespace(
+ type="function_call",
+ name=SPLIT_TOOL_NAME,
+ arguments=json.dumps({"groups": _SAMPLE_RAW_GROUPS}),
+ )
+ mock_response = SimpleNamespace(
+ status="incomplete",
+ incomplete_details=SimpleNamespace(reason="max_output_tokens"),
+ output=[item],
+ )
+ mock_cls.return_value.responses.create.return_value = mock_response
+ settings = _make_settings(Provider.OPENAI)
+ with pytest.raises(LLMError, match=r"cut off.*max_output_tokens"):
+ _call_openai("sys", "usr", settings=settings)
+
+ @patch("pr_split.planner.client.openai.OpenAI")
+ def test_completed_status_is_accepted(self, mock_cls: MagicMock) -> None:
+ item = SimpleNamespace(
+ type="function_call",
+ name=SPLIT_TOOL_NAME,
+ arguments=json.dumps({"groups": _SAMPLE_RAW_GROUPS}),
+ )
+ mock_response = SimpleNamespace(status="completed", output=[item])
+ mock_cls.return_value.responses.create.return_value = mock_response
+ settings = _make_settings(Provider.OPENAI)
+ assert _call_openai("sys", "usr", settings=settings)["groups"] == _SAMPLE_RAW_GROUPS
+
+
+class TestTruncationIsRetried:
+ @patch("pr_split.planner.client._call_llm")
+ def test_chunk_retry_recovers_from_truncated_first_attempt(self, mock_call: MagicMock) -> None:
+ mock_call.side_effect = [
+ LLMError(ErrorMsg.LLM_OUTPUT_TRUNCATED(detail="stop_reason=max_tokens")),
+ {"groups": _SAMPLE_RAW_GROUPS},
+ ]
+ settings = _make_settings(Provider.ANTHROPIC)
+ groups = _call_chunk_with_retry(
+ "sys", "usr", settings=settings, chunk_index=1, total_chunks=2
+ )
+ assert [g.id for g in groups] == [g["id"] for g in _SAMPLE_RAW_GROUPS]
+ assert mock_call.call_count == 2
+
+
+class TestMergeChunkGroupsSameFile:
+ def test_same_file_in_later_chunk_is_merged_into_one_assignment(self) -> None:
+ first = Group(
+ id="pr-1",
+ title="t",
+ description="d",
+ assignments=[
+ GroupAssignment(
+ file_path="f.py",
+ assignment_type=AssignmentType.PARTIAL_HUNKS,
+ hunk_indices=[0],
+ )
+ ],
+ )
+ later = Group(
+ id="pr-1",
+ title="t",
+ description="d",
+ assignments=[
+ GroupAssignment(
+ file_path="f.py",
+ assignment_type=AssignmentType.PARTIAL_HUNKS,
+ hunk_indices=[1],
+ )
+ ],
+ )
+ (merged,) = _merge_chunk_groups([first], [later])
+ assert len(merged.assignments) == 1
+ assert merged.assignments[0].hunk_indices == [0, 1]
+ assert merged.assignments[0].assignment_type == AssignmentType.PARTIAL_HUNKS
+
+ def test_whole_file_wins_when_merging(self) -> None:
+ partial = GroupAssignment(
+ file_path="f.py", assignment_type=AssignmentType.PARTIAL_HUNKS, hunk_indices=[0]
+ )
+ whole = GroupAssignment(
+ file_path="f.py", assignment_type=AssignmentType.WHOLE_FILE, hunk_indices=[]
+ )
+ first = Group(id="pr-1", title="t", description="d", assignments=[partial])
+ later = Group(id="pr-1", title="t", description="d", assignments=[whole])
+ (merged,) = _merge_chunk_groups([first], [later])
+ assert len(merged.assignments) == 1
+ assert merged.assignments[0].assignment_type == AssignmentType.WHOLE_FILE
+ assert merged.assignments[0].hunk_indices == [0]
+
+ def test_other_files_untouched(self) -> None:
+ first = Group(
+ id="pr-1",
+ title="t",
+ description="d",
+ assignments=[
+ GroupAssignment(
+ file_path="a.py",
+ assignment_type=AssignmentType.PARTIAL_HUNKS,
+ hunk_indices=[0],
+ )
+ ],
+ )
+ later = Group(
+ id="pr-1",
+ title="t",
+ description="d",
+ assignments=[
+ GroupAssignment(
+ file_path="b.py",
+ assignment_type=AssignmentType.PARTIAL_HUNKS,
+ hunk_indices=[2],
+ )
+ ],
+ )
+ (merged,) = _merge_chunk_groups([first], [later])
+ assert [(a.file_path, a.hunk_indices) for a in merged.assignments] == [
+ ("a.py", [0]),
+ ("b.py", [2]),
+ ]
+
+ def test_pre_existing_duplicates_in_accumulated_group_are_kept(self) -> None:
+ def _pa(path: str, hunks: list[int]) -> GroupAssignment:
+ return GroupAssignment(
+ file_path=path,
+ assignment_type=AssignmentType.PARTIAL_HUNKS,
+ hunk_indices=hunks,
+ )
+
+ first = Group(
+ id="pr-1", title="t", description="d", assignments=[_pa("f.py", [0]), _pa("f.py", [1])]
+ )
+ later = Group(id="pr-1", title="t", description="d", assignments=[_pa("g.py", [0])])
+ (merged,) = _merge_chunk_groups([first], [later])
+ assert [(a.file_path, a.hunk_indices) for a in merged.assignments] == [
+ ("f.py", [0, 1]),
+ ("g.py", [0]),
+ ]
+
+ first = Group(
+ id="pr-1", title="t", description="d", assignments=[_pa("f.py", [0]), _pa("f.py", [1])]
+ )
+ later = Group(id="pr-1", title="t", description="d", assignments=[_pa("f.py", [2])])
+ (merged,) = _merge_chunk_groups([first], [later])
+ assert [(a.file_path, a.hunk_indices) for a in merged.assignments] == [("f.py", [0, 1, 2])]
+
+
+class TestSurrogateSafePrompts:
+ @patch("pr_split.planner.client._call_anthropic")
+ def test_call_llm_replaces_surrogates_before_the_request(self, mock_call: MagicMock) -> None:
+ from pr_split.planner.client import _call_llm
+
+ user = b"diff caf\xe9".decode("utf-8", errors="surrogateescape")
+ _call_llm("sys", user, settings=_make_settings())
+ sent_user = mock_call.call_args.args[1]
+ assert "\udce9" not in sent_user
+ assert sent_user == "diff caf\ufffd"
+ sent_user.encode("utf-8") # must be JSON-serialisable
+
+ @patch("pr_split.planner.client._count_tokens_anthropic", return_value=3)
+ def test_count_tokens_replaces_surrogates(self, mock_count: MagicMock) -> None:
+ from pr_split.planner.client import _count_tokens
+
+ user = b"caf\xe9".decode("utf-8", errors="surrogateescape")
+ assert _count_tokens("sys", user, settings=_make_settings()) == 3
+ mock_count.call_args.args[1].encode("utf-8")
+
+
+class TestClaudeCliProvider:
+ def _completed(self, returncode: int, stdout: str, stderr: str = "") -> object:
+ import subprocess
+
+ return subprocess.CompletedProcess(["claude"], returncode, stdout, stderr)
+
+ def test_needs_no_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
+ settings = Settings(provider=Provider.CLAUDE_CLI, partition_strategy=PartitionStrategy.LLM)
+ assert settings.api_key == ""
+ assert settings.model == ""
+
+ @patch("pr_split.planner.client.subprocess.run")
+ def test_structured_output_is_the_plan(self, mock_run: MagicMock) -> None:
+ mock_run.return_value = self._completed(
+ 0, json.dumps({"result": "", "structured_output": {"groups": _SAMPLE_RAW_GROUPS}})
+ )
+ settings = _make_settings(Provider.CLAUDE_CLI)
+
+ result = _call_llm("the system prompt", "the diff", settings=settings)
+
+ assert result["groups"] == _SAMPLE_RAW_GROUPS
+ cmd = mock_run.call_args.args[0]
+ assert cmd[:2] == ["claude", "-p"]
+ assert cmd[cmd.index("--system-prompt") + 1] == "the system prompt"
+ assert "--json-schema" in cmd
+ assert "--model" not in cmd
+ assert mock_run.call_args.kwargs["input"] == "the diff"
+
+ @patch("pr_split.planner.client.subprocess.run", side_effect=FileNotFoundError())
+ def test_missing_cli_is_an_llm_error(self, mock_run: MagicMock) -> None:
+ with pytest.raises(LLMError, match="Claude Code CLI"):
+ _call_llm("s", "u", settings=_make_settings(Provider.CLAUDE_CLI))
+
+ @patch("pr_split.planner.client.subprocess.run")
+ def test_nonzero_exit_is_an_llm_error(self, mock_run: MagicMock) -> None:
+ mock_run.return_value = self._completed(1, "", "Not logged in")
+ with pytest.raises(LLMError, match="Not logged in"):
+ _call_llm("s", "u", settings=_make_settings(Provider.CLAUDE_CLI))
+
+ @patch("pr_split.planner.client.subprocess.run")
+ def test_missing_structured_output_is_an_llm_error(self, mock_run: MagicMock) -> None:
+ mock_run.return_value = self._completed(0, json.dumps({"result": "I cannot do that"}))
+ with pytest.raises(LLMError, match="no structured output"):
+ _call_llm("s", "u", settings=_make_settings(Provider.CLAUDE_CLI))
+
+ def test_tokens_are_estimated_locally(self) -> None:
+ assert (
+ _count_tokens("system", "user text", settings=_make_settings(Provider.CLAUDE_CLI)) > 0
+ )
diff --git a/tests/test_config.py b/tests/test_config.py
index 06d3ff9..1ea04f0 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -182,3 +182,24 @@ def test_empty_string_key_raises_openai(self, monkeypatch: pytest.MonkeyPatch) -
monkeypatch.setenv("OPENAI_API_KEY", "")
with pytest.raises(ValidationError, match="OPENAI_API_KEY"):
Settings(provider=Provider.OPENAI)
+
+
+class TestDerivedMinLocFloor:
+ @pytest.mark.parametrize("max_loc", [2, 3])
+ def test_tiny_max_loc_derives_min_loc_of_one(
+ self, monkeypatch: pytest.MonkeyPatch, max_loc: int
+ ) -> None:
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
+ settings = Settings(max_loc=max_loc, max_refinement_iterations=1)
+ assert settings.min_loc == 1
+
+ def test_max_loc_of_one_with_refinement_is_rejected(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
+ with pytest.raises(ValueError, match="min_loc 1 must be less than max_loc 1"):
+ Settings(max_loc=1, max_refinement_iterations=1)
+
+ def test_normal_max_loc_uses_ratio(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
+ assert Settings(max_loc=400, max_refinement_iterations=1).min_loc == 100
diff --git a/tests/test_default_strategy.py b/tests/test_default_strategy.py
new file mode 100644
index 0000000..a71dba1
--- /dev/null
+++ b/tests/test_default_strategy.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+from unittest.mock import patch
+
+import pytest
+from pydantic import ValidationError
+
+from pr_split.cli import _split_settings
+from pr_split.config import Settings
+from pr_split.constants import PartitionStrategy
+
+
+def _build(strategy: PartitionStrategy, **options: int) -> Settings:
+ return Settings(partition_strategy=strategy, **options)
+
+
+@pytest.fixture(autouse=True)
+def _no_keys(monkeypatch: pytest.MonkeyPatch) -> None:
+ for key in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "PR_SPLIT_PROVIDER", "PR_SPLIT_MODEL"):
+ monkeypatch.delenv(key, raising=False)
+
+
+def test_no_key_falls_back_to_graph_and_says_why() -> None:
+ with patch("pr_split.cli.logger") as mock_logger:
+ settings = _split_settings(None, _build)
+
+ assert settings.partition_strategy is PartitionStrategy.GRAPH
+ message = mock_logger.warning.call_args.args[0]
+ assert "ANTHROPIC_API_KEY must be set" in message
+ assert "graph backend" in message
+
+
+def test_key_present_keeps_the_llm_planner(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
+ assert _split_settings(None, _build).partition_strategy is PartitionStrategy.LLM
+
+
+def test_local_provider_needs_no_key(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("PR_SPLIT_PROVIDER", "local")
+ monkeypatch.setenv("PR_SPLIT_MODEL", "qwen2.5-coder:7b")
+ assert _split_settings(None, _build).partition_strategy is PartitionStrategy.LLM
+
+
+def test_explicit_llm_without_a_key_still_fails() -> None:
+ with pytest.raises(ValidationError, match="ANTHROPIC_API_KEY must be set"):
+ _split_settings(PartitionStrategy.LLM, _build)
+
+
+def test_explicit_backend_is_used_as_is() -> None:
+ assert _split_settings(PartitionStrategy.CP_SAT, _build).partition_strategy == "cp_sat"
+
+
+def test_an_error_both_backends_share_is_reported_unchanged() -> None:
+ def build(strategy: PartitionStrategy) -> Settings:
+ return _build(strategy, min_loc=500, max_loc=100)
+
+ with pytest.raises(ValidationError, match=r"ANTHROPIC_API_KEY|min_loc"):
+ _split_settings(None, build)
diff --git a/tests/test_description_refresh.py b/tests/test_description_refresh.py
new file mode 100644
index 0000000..7e70868
--- /dev/null
+++ b/tests/test_description_refresh.py
@@ -0,0 +1,78 @@
+from __future__ import annotations
+
+from pr_split.cli import _move_assignment
+from pr_split.config import Settings
+from pr_split.constants import AssignmentType, PartitionStrategy
+from pr_split.diff_ops.parser import parse_diff
+from pr_split.planner.partitioning import partition_diff, refresh_generated_description
+from pr_split.schemas import Group, GroupAssignment
+
+# The #220 repro: a new module and the main.rs hunk that declares it.
+_DIFF = """\
+diff --git a/src/search_editor.rs b/src/search_editor.rs
+new file mode 100644
+--- /dev/null
++++ b/src/search_editor.rs
+@@ -0,0 +1,6 @@
++pub struct SearchEditor {
++ query: String,
++}
++
++impl SearchEditor {
++}
+diff --git a/src/main.rs b/src/main.rs
+--- a/src/main.rs
++++ b/src/main.rs
+@@ -1,3 +1,4 @@
+ mod app;
++mod search_editor;
+ fn main() {
+ }
+"""
+
+
+def _group(gid: str, description: str, *paths: str) -> Group:
+ return Group(
+ id=gid,
+ title=gid,
+ description=description,
+ assignments=[
+ GroupAssignment(
+ file_path=path, assignment_type=AssignmentType.WHOLE_FILE, hunk_indices=[0]
+ )
+ for path in paths
+ ],
+ )
+
+
+def test_move_in_the_editor_relists_generated_descriptions() -> None:
+ parsed = parse_diff(_DIFF)
+ settings = Settings(partition_strategy=PartitionStrategy.GRAPH, max_loc=6, min_loc=1)
+ groups = partition_diff(parsed, settings)
+ owner = {a.file_path: g for g in groups for a in g.assignments}
+ src, dst = owner["src/main.rs"], owner["src/search_editor.rs"]
+ assert src.id != dst.id
+
+ assert _move_assignment(groups, parsed, "src/main.rs", 0, src.id, dst.id)
+
+ assert dst.description == "graph partition over 2 file(s): src/main.rs, src/search_editor.rs"
+ assert src.description == "graph partition over 0 file(s): "
+
+
+def test_cp_sat_description_is_relisted() -> None:
+ group = _group("pr-1", "cp_sat partition over 1 file(s): old.py", "a.py", "b.py")
+ refresh_generated_description(group)
+ assert group.description == "cp_sat partition over 2 file(s): a.py, b.py"
+
+
+def test_llm_and_hand_written_descriptions_are_kept() -> None:
+ for text in [
+ "Add the search editor and wire it into main",
+ "graph partition over many file(s): a.py",
+ "graph partition over 3 file(s): a.py",
+ "llm partition over 1 file(s): a.py",
+ "",
+ ]:
+ group = _group("pr-1", text, "a.py", "b.py")
+ refresh_generated_description(group)
+ assert group.description == text
diff --git a/tests/test_diff_base.py b/tests/test_diff_base.py
new file mode 100644
index 0000000..9fa40ff
--- /dev/null
+++ b/tests/test_diff_base.py
@@ -0,0 +1,127 @@
+from __future__ import annotations
+
+import json
+import subprocess
+from pathlib import Path
+
+import pytest
+
+from pr_split.git_ops import diff_base_ref
+
+
+def _git(cwd: Path, *args: str) -> str:
+ return subprocess.run(
+ ["git", *args], cwd=cwd, check=True, capture_output=True, text=True
+ ).stdout.strip()
+
+
+def _commit(repo: Path, path: str, text: str, message: str) -> None:
+ (repo / path).parent.mkdir(parents=True, exist_ok=True)
+ (repo / path).write_text(text)
+ _git(repo, "add", path)
+ _git(repo, "commit", "-q", "-m", message)
+
+
+@pytest.fixture
+def repos(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, Path]:
+ """(clone, upstream work tree); the clone's main is 1 commit behind origin/main."""
+ for key, value in {
+ "GIT_AUTHOR_NAME": "t",
+ "GIT_AUTHOR_EMAIL": "t@t",
+ "GIT_COMMITTER_NAME": "t",
+ "GIT_COMMITTER_EMAIL": "t@t",
+ }.items():
+ monkeypatch.setenv(key, value)
+ origin = tmp_path / "origin.git"
+ _git(tmp_path, "init", "-q", "--bare", "-b", "main", str(origin))
+ upstream = tmp_path / "upstream"
+ _git(tmp_path, "clone", "-q", str(origin), str(upstream))
+ _commit(upstream, "README.md", "hello\n", "init")
+ _git(upstream, "push", "-q", "origin", "main")
+
+ clone = tmp_path / "clone"
+ _git(tmp_path, "clone", "-q", str(origin), str(clone))
+ _git(clone, "checkout", "-q", "-b", "feature")
+ _commit(clone, "src/feature.py", "".join(f"x{i} = {i}\n" for i in range(5)), "feature")
+ _git(clone, "checkout", "-q", "main")
+
+ # Upstream moves on: a release commit the local main has not seen.
+ _commit(upstream, "Cargo.toml", 'version = "0.2.0"\n', "release")
+ _git(upstream, "push", "-q", "origin", "main")
+ _git(clone, "checkout", "-q", "feature")
+ _git(clone, "merge", "-q", "--no-edit", "origin/main") # stale: origin/main not fetched yet
+ monkeypatch.chdir(clone)
+ return clone, upstream
+
+
+def test_fetches_and_diffs_against_the_upstream(repos: tuple[Path, Path]) -> None:
+ clone, upstream = repos
+ assert diff_base_ref("main") == "origin/main"
+ assert _git(clone, "rev-parse", "origin/main") == _git(upstream, "rev-parse", "main")
+
+
+def test_up_to_date_base_uses_the_tracking_ref(repos: tuple[Path, Path]) -> None:
+ clone, _ = repos
+ _git(clone, "fetch", "-q")
+ _git(clone, "branch", "-f", "main", "origin/main")
+ assert diff_base_ref("main") == "origin/main"
+
+
+def test_branch_without_upstream_is_used_as_is(repos: tuple[Path, Path]) -> None:
+ clone, _ = repos
+ _git(clone, "branch", "-q", "local-only", "main")
+ assert diff_base_ref("local-only") == "local-only"
+
+
+def test_failed_fetch_falls_back_to_the_last_fetched_ref(repos: tuple[Path, Path]) -> None:
+ clone, upstream = repos
+ _git(clone, "remote", "set-url", "origin", str(clone.parent / "gone.git"))
+ assert diff_base_ref("main") == "origin/main"
+ # Nothing new arrived: origin/main is still the state from clone time.
+ assert _git(clone, "rev-parse", "origin/main") != _git(upstream, "rev-parse", "main")
+
+
+def test_split_does_not_count_upstream_commits_as_branch_work(
+ repos: tuple[Path, Path],
+) -> None:
+ from unittest.mock import patch
+
+ from typer.testing import CliRunner
+
+ from pr_split.cli import app
+ from pr_split.git_ops import diff_base_ref as real_diff_base_ref
+
+ clone, _ = repos
+ _git(clone, "fetch", "-q")
+ _git(clone, "merge", "-q", "--no-edit", "origin/main")
+ # Local main is now 1 commit behind the branch's own base.
+ with patch("pr_split.cli.diff_base_ref", side_effect=real_diff_base_ref):
+ result = CliRunner().invoke(
+ app,
+ ["split", "feature", "--base", "main", "--dry-run", "--partition-strategy", "graph"],
+ input="done\n",
+ )
+
+ assert result.exit_code == 0, result.output
+ plan = json.loads((clone / ".pr-split" / "plans" / "feature.json").read_text())
+ plan = plan.get("plan", plan)
+ files = {a["file_path"] for g in plan["groups"] for a in g["assignments"]}
+ assert files == {"src/feature.py"}
+ assert plan["base_branch"] == "main"
+ assert plan["merge_base_sha"] == _git(clone, "rev-parse", "origin/main")
+
+
+def test_local_commits_missing_upstream_are_warned_about(repos: tuple[Path, Path]) -> None:
+ from unittest.mock import patch
+
+ clone, _ = repos
+ _git(clone, "checkout", "-q", "main")
+ _commit(clone, "local.txt", "unpushed\n", "unpushed")
+ _git(clone, "checkout", "-q", "feature")
+
+ with patch("pr_split.git_ops.branches.logger") as mock_logger:
+ assert diff_base_ref("main") == "origin/main"
+
+ messages = [call.args[0] for call in mock_logger.warning.call_args_list]
+ assert any("1 commit(s) behind 'origin/main'" in m for m in messages)
+ assert any("1 commit(s) not on 'origin/main'" in m for m in messages)
diff --git a/tests/test_diff_parser_extended.py b/tests/test_diff_parser_extended.py
index 3eaaadc..be71784 100644
--- a/tests/test_diff_parser_extended.py
+++ b/tests/test_diff_parser_extended.py
@@ -1,11 +1,18 @@
from __future__ import annotations
+import os
import subprocess
+from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
-from pr_split.diff_ops.parser import extract_diff, parse_diff
+from pr_split.diff_ops.parser import (
+ _normalize_quoted_headers,
+ extract_diff,
+ parse_diff,
+ unquote_git_path,
+)
from pr_split.exceptions import GitOperationError
SAMPLE_DIFF = (
@@ -97,14 +104,26 @@ class TestExtractDiffSubprocess:
@patch("pr_split.diff_ops.parser.subprocess.run")
def test_extract_diff_success(self, mock_run: MagicMock) -> None:
mock_run.return_value = subprocess.CompletedProcess(
- args=["git", "diff"], returncode=0, stdout=EXTRACT_DIFF_SAMPLE, stderr=""
+ args=["git", "diff"], returncode=0, stdout=EXTRACT_DIFF_SAMPLE.encode(), stderr=b""
)
result = extract_diff("feature", "main")
assert result == EXTRACT_DIFF_SAMPLE
mock_run.assert_called_once_with(
- ["git", "diff", "main...feature"],
+ [
+ "git",
+ "-c",
+ "core.quotePath=false",
+ "diff",
+ "--no-color",
+ "--no-ext-diff",
+ "--no-textconv",
+ "--no-renames",
+ "-U3",
+ "--src-prefix=a/",
+ "--dst-prefix=b/",
+ "main...feature",
+ ],
capture_output=True,
- text=True,
)
@patch("pr_split.diff_ops.parser.subprocess.run")
@@ -112,8 +131,8 @@ def test_extract_diff_failure(self, mock_run: MagicMock) -> None:
mock_run.return_value = subprocess.CompletedProcess(
args=["git", "diff"],
returncode=1,
- stdout="",
- stderr="fatal: bad revision",
+ stdout=b"",
+ stderr=b"fatal: bad revision",
)
with pytest.raises(GitOperationError, match="bad revision"):
extract_diff("bad-branch", "main")
@@ -123,3 +142,274 @@ class TestRawDiffPreserved:
def test_raw_diff_preserved(self) -> None:
parsed = parse_diff(EXTRACT_DIFF_SAMPLE)
assert parsed.raw_diff == EXTRACT_DIFF_SAMPLE
+
+
+class TestExtractDiffPreservesCarriageReturns:
+ @patch("pr_split.diff_ops.parser.subprocess.run")
+ def test_crlf_diff_is_returned_verbatim(self, mock_run: MagicMock) -> None:
+ raw = b"--- a/c.txt\n+++ b/c.txt\n@@ -1 +1 @@\n-old\r\n+new\r\n"
+ mock_run.return_value = subprocess.CompletedProcess(
+ args=["git", "diff"], returncode=0, stdout=raw, stderr=b""
+ )
+ result = extract_diff("feature", "main")
+ assert "\r\n" in result
+ assert result == raw.decode()
+ assert mock_run.call_args.kwargs.get("text") is not True
+
+
+class TestNonUtf8FileContent:
+ def test_latin1_file_round_trips_byte_for_byte(self, tmp_path: Path) -> None:
+ from pr_split.constants import AssignmentType
+ from pr_split.diff_ops.reconstructor import materialize_group_files
+ from pr_split.git_ops.branches import merge_base
+ from pr_split.schemas import Group, GroupAssignment
+
+ def git(*args: str) -> str:
+ return subprocess.run(
+ ["git", "-c", "user.name=t", "-c", "user.email=t@x", *args],
+ cwd=tmp_path,
+ capture_output=True,
+ text=True,
+ check=True,
+ ).stdout
+
+ git("init", "-q", "-b", "main")
+ base_bytes = "caf\xe9 one\nkeep\n".encode("latin-1")
+ dev_bytes = "caf\xe9 two\nkeep\n".encode("latin-1")
+ (tmp_path / "legacy.txt").write_bytes(base_bytes)
+ git("add", "-A")
+ git("commit", "-qm", "base")
+ git("checkout", "-qb", "dev")
+ (tmp_path / "legacy.txt").write_bytes(dev_bytes)
+ git("add", "-A")
+ git("commit", "-qm", "dev")
+
+ cwd = os.getcwd()
+ os.chdir(tmp_path)
+ try:
+ parsed = parse_diff(extract_diff("dev", "main"))
+ group = Group(
+ id="pr-1",
+ title="t",
+ description="d",
+ assignments=[
+ GroupAssignment(
+ file_path="legacy.txt",
+ assignment_type=AssignmentType.WHOLE_FILE,
+ hunk_indices=[0],
+ )
+ ],
+ )
+ materialized = materialize_group_files(parsed, group, merge_base("main", "dev"))
+ out = tmp_path / "out.txt"
+ content = materialized["legacy.txt"]
+ assert content is not None
+ out.write_text(content, encoding="utf-8", errors="surrogateescape", newline="")
+ finally:
+ os.chdir(cwd)
+
+ assert [pf.path for pf in parsed.patch_set] == ["legacy.txt"]
+ assert out.read_bytes() == dev_bytes
+
+
+class TestNonAsciiPaths:
+ def test_unicode_paths_survive_extraction_and_parsing(self, tmp_path: Path) -> None:
+ def git(*args: str) -> str:
+ return subprocess.run(
+ ["git", "-c", "user.name=t", "-c", "user.email=t@x", *args],
+ cwd=tmp_path,
+ capture_output=True,
+ text=True,
+ check=True,
+ ).stdout
+
+ git("init", "-q", "-b", "main")
+ (tmp_path / "ünï.txt").write_text("one\n", encoding="utf-8")
+ git("add", "-A")
+ git("commit", "-qm", "base")
+ git("checkout", "-qb", "dev")
+ (tmp_path / "ünï.txt").write_text("two\n", encoding="utf-8")
+ (tmp_path / "new file ü.txt").write_text("x\n", encoding="utf-8")
+ git("add", "-A")
+ git("commit", "-qm", "dev")
+
+ cwd = os.getcwd()
+ os.chdir(tmp_path)
+ try:
+ parsed = parse_diff(extract_diff("dev", "main"))
+ finally:
+ os.chdir(cwd)
+
+ assert sorted(pf.path for pf in parsed.patch_set) == ["new file ü.txt", "ünï.txt"]
+
+
+class TestUnquoteGitPath:
+ @pytest.mark.parametrize(
+ ("quoted", "expected"),
+ [
+ ("plain/path.py", "plain/path.py"),
+ ('"a/we\\"ird.py"', 'a/we"ird.py'),
+ ('"b/tab\\tname.py"', "b/tab\tname.py"),
+ ('"b/back\\\\slash.py"', "b/back\\slash.py"),
+ ('"b/caf\\303\\251.txt"', "b/café.txt"),
+ ('"b/bell\\a\\1.txt"', "b/bell\a\x01.txt"),
+ # A non-UTF-8 filename byte reaches us as a surrogate (see
+ # extract_diff) and must come back out as the same byte.
+ ('"b/q\\"\udcff.txt"', 'b/q"\udcff.txt'),
+ ('""', ""),
+ ('"', '"'),
+ ],
+ )
+ def test_decodes_c_style_quoting(self, quoted: str, expected: str) -> None:
+ assert unquote_git_path(quoted) == expected
+
+ def test_quoted_headers_are_unquoted_after_parse(self) -> None:
+ raw = (
+ 'diff --git "a/we\\"ird.py" "b/we\\"ird.py"\n'
+ "new file mode 100644\n"
+ "--- /dev/null\n"
+ '+++ "b/we\\"ird.py"\n'
+ "@@ -0,0 +1 @@\n"
+ "+x\n"
+ 'diff --git "a/tab\\tname.py" "b/tab\\tname.py"\n'
+ "deleted file mode 100644\n"
+ '--- "a/tab\\tname.py"\n'
+ "+++ /dev/null\n"
+ "@@ -1 +0,0 @@\n"
+ "-y\n"
+ )
+ parsed = parse_diff(raw)
+ assert [pf.path for pf in parsed.patch_set] == ['we"ird.py', "tab\tname.py"]
+ assert parsed.patch_set[0].source_file == "/dev/null"
+ assert parsed.patch_set[1].target_file == "/dev/null"
+ assert parsed.patch_set[1].is_removed_file
+
+ def test_special_paths_survive_real_git_diff(self, tmp_path: Path) -> None:
+ def git(*args: str) -> str:
+ return subprocess.run(
+ ["git", "-c", "user.name=t", "-c", "user.email=t@x", *args],
+ cwd=tmp_path,
+ capture_output=True,
+ text=True,
+ check=True,
+ ).stdout
+
+ git("init", "-q", "-b", "main")
+ (tmp_path / "keep.txt").write_text("one\n", encoding="utf-8")
+ git("add", "-A")
+ git("commit", "-qm", "base")
+ git("checkout", "-qb", "dev")
+ names = ['we"ird.py', "tab\tname.py", "back\\slash.py"]
+ for name in names:
+ (tmp_path / name).write_text("x\n", encoding="utf-8")
+ git("add", "-A")
+ git("commit", "-qm", "dev")
+
+ cwd = os.getcwd()
+ os.chdir(tmp_path)
+ try:
+ parsed = parse_diff(extract_diff("dev", "main"))
+ finally:
+ os.chdir(cwd)
+
+ assert sorted(pf.path for pf in parsed.patch_set) == sorted(names)
+ assert all(pf.is_added_file for pf in parsed.patch_set)
+
+
+class TestQuotedPathsWithSpaces:
+ def test_header_spaces_are_escaped_but_hunk_content_is_not(self) -> None:
+ raw = (
+ 'diff --git "a/my \\"notes\\".md" "b/my \\"notes\\".md"\n'
+ "index 1..2 100644\n"
+ '--- "a/my \\"notes\\".md"\t\n'
+ '+++ "b/my \\"notes\\".md"\t\n'
+ "@@ -1 +1 @@\n"
+ '-a "quoted line" here\n'
+ '+--- "not a header" line\n'
+ )
+ normalized = _normalize_quoted_headers(raw)
+ assert normalized.splitlines()[0] == (
+ 'diff --git "a/my\\040\\"notes\\".md" "b/my\\040\\"notes\\".md"'
+ )
+ assert normalized.splitlines()[2] == '--- "a/my\\040\\"notes\\".md"\t'
+ assert normalized.splitlines()[5:] == raw.splitlines()[5:]
+
+ def test_diff_without_quotes_is_returned_unchanged(self) -> None:
+ raw = (
+ "diff --git a/plain name.md b/plain name.md\n"
+ "--- a/plain name.md\n"
+ "+++ b/plain name.md\n"
+ )
+ assert _normalize_quoted_headers(raw) is raw
+
+ def test_modified_quoted_path_with_space_parses_to_one_file(self) -> None:
+ raw = (
+ 'diff --git "a/my \\"notes\\".md" "b/my \\"notes\\".md"\n'
+ "index 1..2 100644\n"
+ '--- "a/my \\"notes\\".md"\n'
+ '+++ "b/my \\"notes\\".md"\n'
+ "@@ -1 +1 @@\n"
+ "-a\n"
+ "+b\n"
+ )
+ parsed = parse_diff(raw)
+ assert [(pf.path, len(pf)) for pf in parsed.patch_set] == [('my "notes".md', 1)]
+ assert parsed.stats["total_files"] == 1
+
+ def test_added_and_deleted_quoted_paths_with_spaces_parse(self) -> None:
+ raw = (
+ 'diff --git "a/new \\"file\\".txt" "b/new \\"file\\".txt"\n'
+ "new file mode 100644\n"
+ "--- /dev/null\n"
+ '+++ "b/new \\"file\\".txt"\n'
+ "@@ -0,0 +1 @@\n"
+ "+x\n"
+ 'diff --git "a/old \\"file\\".txt" "b/old \\"file\\".txt"\n'
+ "deleted file mode 100644\n"
+ '--- "a/old \\"file\\".txt"\n'
+ "+++ /dev/null\n"
+ "@@ -1 +0,0 @@\n"
+ "-y\n"
+ )
+ parsed = parse_diff(raw)
+ assert [pf.path for pf in parsed.patch_set] == ['new "file".txt', 'old "file".txt']
+ assert parsed.patch_set[0].is_added_file
+ assert parsed.patch_set[1].is_removed_file
+
+ def test_real_git_diff_with_quoted_spaced_paths(self, tmp_path: Path) -> None:
+ def git(*args: str) -> str:
+ return subprocess.run(
+ ["git", "-c", "user.name=t", "-c", "user.email=t@x", *args],
+ cwd=tmp_path,
+ capture_output=True,
+ text=True,
+ check=True,
+ ).stdout
+
+ git("init", "-q", "-b", "main")
+ modified = 'my "notes".md'
+ deleted = 'old "file".txt'
+ (tmp_path / modified).write_text("a\n", encoding="utf-8")
+ (tmp_path / deleted).write_text("y\n", encoding="utf-8")
+ (tmp_path / "plain name.md").write_text("p\n", encoding="utf-8")
+ git("add", "-A")
+ git("commit", "-qm", "base")
+ git("checkout", "-qb", "dev")
+ (tmp_path / modified).write_text("b\n", encoding="utf-8")
+ (tmp_path / deleted).unlink()
+ (tmp_path / "plain name.md").write_text("q\n", encoding="utf-8")
+ (tmp_path / 'new "file".txt').write_text("x\n", encoding="utf-8")
+ git("add", "-A")
+ git("commit", "-qm", "dev")
+
+ cwd = os.getcwd()
+ os.chdir(tmp_path)
+ try:
+ parsed = parse_diff(extract_diff("dev", "main"))
+ finally:
+ os.chdir(cwd)
+
+ assert sorted((pf.path, len(pf)) for pf in parsed.patch_set) == sorted(
+ [(modified, 1), (deleted, 1), ("plain name.md", 1), ('new "file".txt', 1)]
+ )
+ assert parsed.stats["total_files"] == 4
diff --git a/tests/test_git_branches.py b/tests/test_git_branches.py
index 6a9bde2..65b729d 100644
--- a/tests/test_git_branches.py
+++ b/tests/test_git_branches.py
@@ -6,17 +6,18 @@
import pytest
+from pr_split.constants import PLAN_DIR
from pr_split.exceptions import GitOperationError
from pr_split.git_ops.branches import (
+ _no_hooks_dir,
add_worktree,
+ adopt_remote_branch,
branch_exists,
- checkout_branch,
- checkout_new_branch,
- commit_files,
+ commit_exists,
commit_files_in_dir,
- create_group_branch,
delete_branch,
derive_split_namespace,
+ diff_base_ref,
is_worktree_clean,
merge_base,
push_branch,
@@ -80,42 +81,98 @@ def test_returns_sha(self, mock_git: MagicMock) -> None:
assert merge_base("main", "feature") == "abc123def"
-class TestCommitFiles:
+class TestPushBranch:
@patch("pr_split.git_ops.branches.run_git")
- def test_basic_commit(self, mock_git: MagicMock) -> None:
- mock_git.side_effect = ["", "", "abc123"]
- sha = commit_files(["file.py"], "test commit")
- assert sha == "abc123"
+ def test_calls_push(self, mock_git: MagicMock) -> None:
+ mock_git.return_value = ""
+ push_branch("pr-split/pr-1")
+ mock_git.assert_called_once_with(
+ "push", "--force-with-lease", "-u", "origin", "pr-split/pr-1"
+ )
- @patch("pr_split.git_ops.branches.run_git")
- def test_commit_with_author(self, mock_git: MagicMock) -> None:
- mock_git.side_effect = ["", "", "abc123"]
- sha = commit_files(["file.py"], "test commit", author="Jane ")
- assert sha == "abc123"
- commit_call = mock_git.call_args_list[1]
- assert "--author" in commit_call.args[0] or "--author" in commit_call[0]
+class TestPushBranchRetries:
+ @patch("pr_split.git_ops.branches.time.sleep")
@patch("pr_split.git_ops.branches.run_git")
- def test_commit_fallback_on_failure(self, mock_git: MagicMock) -> None:
+ def test_transient_server_error_is_retried(
+ self, mock_git: MagicMock, mock_sleep: MagicMock
+ ) -> None:
mock_git.side_effect = [
+ GitOperationError("remote: fatal error in commit_refs"),
"",
- GitOperationError("nothing to commit"),
- "",
- "",
- "def456",
]
- sha = commit_files(["file.py"], "test commit")
- assert sha == "def456"
+ push_branch("pr-split/pr-5")
+ assert mock_git.call_count == 2
+ mock_sleep.assert_called_once()
+ @patch("pr_split.git_ops.branches.time.sleep")
+ @patch("pr_split.git_ops.branches.run_git")
+ def test_rejection_is_not_retried(self, mock_git: MagicMock, mock_sleep: MagicMock) -> None:
+ mock_git.side_effect = GitOperationError("! [rejected] (stale info)")
+ with pytest.raises(GitOperationError, match="stale info"):
+ push_branch("pr-split/pr-5")
+ assert mock_git.call_count == 1
+ mock_sleep.assert_not_called()
+
+ @patch("pr_split.git_ops.branches.time.sleep")
+ @patch("pr_split.git_ops.branches.run_git")
+ def test_gives_up_after_the_last_attempt(
+ self, mock_git: MagicMock, mock_sleep: MagicMock
+ ) -> None:
+ mock_git.side_effect = GitOperationError("fatal: the remote end hung up unexpectedly")
+ with pytest.raises(GitOperationError, match="hung up"):
+ push_branch("pr-split/pr-5")
+ assert mock_git.call_count == 3
-class TestPushBranch:
+
+class TestPushThatLandedDespiteAnError:
+ @patch("pr_split.git_ops.branches.time.sleep")
@patch("pr_split.git_ops.branches.run_git")
- def test_calls_push(self, mock_git: MagicMock) -> None:
- mock_git.return_value = ""
- push_branch("pr-split/pr-1")
- mock_git.assert_called_once_with(
- "push", "--force-with-lease", "-u", "origin", "pr-split/pr-1"
- )
+ def test_stale_lease_after_a_landed_push_is_success(
+ self, mock_git: MagicMock, mock_sleep: MagicMock
+ ) -> None:
+ def git(*args: str) -> str:
+ if args[0] == "push":
+ if git.pushes == 0:
+ git.pushes += 1
+ raise GitOperationError("fatal: the remote end hung up unexpectedly")
+ raise GitOperationError("! [rejected] pr-split/pr-5 (stale info)")
+ if args[0] == "ls-remote":
+ return "abc123\trefs/heads/pr-split/pr-5"
+ return "abc123" # rev-parse
+
+ git.pushes = 0 # type: ignore[attr-defined]
+ mock_git.side_effect = git
+ push_branch("pr-split/pr-5")
+
+ @patch("pr_split.git_ops.branches.time.sleep")
+ @patch("pr_split.git_ops.branches.run_git")
+ def test_stale_lease_is_still_an_error_when_the_remote_differs(
+ self, mock_git: MagicMock, mock_sleep: MagicMock
+ ) -> None:
+ def git(*args: str) -> str:
+ if args[0] == "push":
+ if git.pushes == 0:
+ git.pushes += 1
+ raise GitOperationError("fatal: the remote end hung up unexpectedly")
+ raise GitOperationError("! [rejected] pr-split/pr-5 (stale info)")
+ if args[0] == "ls-remote":
+ return "fff999\trefs/heads/pr-split/pr-5"
+ return "abc123"
+
+ git.pushes = 0 # type: ignore[attr-defined]
+ mock_git.side_effect = git
+ with pytest.raises(GitOperationError, match="stale info"):
+ push_branch("pr-split/pr-5")
+
+ @patch("pr_split.git_ops.branches.run_git")
+ def test_a_first_attempt_rejection_does_not_consult_the_remote(
+ self, mock_git: MagicMock
+ ) -> None:
+ mock_git.side_effect = GitOperationError("! [rejected] (stale info)")
+ with pytest.raises(GitOperationError):
+ push_branch("pr-split/pr-5")
+ assert [c.args[0] for c in mock_git.call_args_list] == ["push"]
class TestDeleteBranch:
@@ -131,6 +188,58 @@ def test_with_remote(self, mock_git: MagicMock) -> None:
delete_branch("pr-split/pr-1", remote=True)
assert mock_git.call_count == 2
+ @patch("pr_split.git_ops.branches.run_git")
+ def test_local_failure_still_deletes_remote(self, mock_git: MagicMock) -> None:
+ mock_git.side_effect = [GitOperationError("checked out"), ""]
+ with pytest.raises(GitOperationError, match="checked out"):
+ delete_branch("pr-split/pr-1", remote=True)
+ mock_git.assert_any_call("push", "origin", "--delete", "pr-split/pr-1")
+
+ @patch("pr_split.git_ops.branches.run_git")
+ def test_local_failure_without_remote_raises_immediately(self, mock_git: MagicMock) -> None:
+ mock_git.side_effect = GitOperationError("nope")
+ with pytest.raises(GitOperationError):
+ delete_branch("pr-split/pr-1")
+ mock_git.assert_called_once()
+
+ @patch("pr_split.git_ops.branches.run_git")
+ def test_branch_already_deleted_locally_counts_as_deleted(self, mock_git: MagicMock) -> None:
+ # `pr-split merge` deletes the local branch; cleanup must not fail on it.
+ mock_git.side_effect = [GitOperationError("error: branch 'pr-split/pr-1' not found."), ""]
+ delete_branch("pr-split/pr-1", remote=True)
+ mock_git.assert_any_call("push", "origin", "--delete", "pr-split/pr-1")
+
+ @patch("pr_split.git_ops.branches.run_git")
+ def test_branch_already_deleted_on_origin_counts_as_deleted(self, mock_git: MagicMock) -> None:
+ mock_git.side_effect = [
+ "",
+ GitOperationError(
+ "error: unable to delete 'pr-split/pr-1': remote ref does not exist"
+ ),
+ ]
+ delete_branch("pr-split/pr-1", remote=True)
+
+ @patch("pr_split.git_ops.branches.run_git")
+ def test_branch_gone_everywhere_counts_as_deleted(self, mock_git: MagicMock) -> None:
+ mock_git.side_effect = [
+ GitOperationError("error: branch 'pr-split/pr-1' not found."),
+ GitOperationError(
+ "error: unable to delete 'pr-split/pr-1': remote ref does not exist"
+ ),
+ ]
+ delete_branch("pr-split/pr-1", remote=True)
+
+ @patch("pr_split.git_ops.branches.run_git")
+ def test_missing_local_branch_without_remote_is_fine(self, mock_git: MagicMock) -> None:
+ mock_git.side_effect = GitOperationError("error: branch 'pr-split/pr-1' not found.")
+ delete_branch("pr-split/pr-1")
+
+ @patch("pr_split.git_ops.branches.run_git")
+ def test_other_remote_failure_still_raises(self, mock_git: MagicMock) -> None:
+ mock_git.side_effect = ["", GitOperationError("fatal: could not read from remote")]
+ with pytest.raises(GitOperationError, match="could not read from remote"):
+ delete_branch("pr-split/pr-1", remote=True)
+
class TestDeriveSplitNamespace:
def test_simple_branch(self) -> None:
@@ -152,32 +261,6 @@ def test_special_chars_sanitized(self) -> None:
assert "!" not in result
-class TestCreateGroupBranch:
- @patch("pr_split.git_ops.branches.checkout_new_branch")
- @patch("pr_split.git_ops.branches.branch_exists")
- def test_creates_new_branch(self, mock_exists: MagicMock, mock_checkout: MagicMock) -> None:
- mock_exists.return_value = False
- result = create_group_branch("pr-1", "abc123", "my-feat")
- assert result == "pr-split/my-feat/pr-1"
- mock_checkout.assert_called_once()
-
- @patch("pr_split.git_ops.branches.checkout_new_branch")
- @patch("pr_split.git_ops.branches.run_git")
- @patch("pr_split.git_ops.branches.checkout_branch")
- @patch("pr_split.git_ops.branches.branch_exists")
- def test_deletes_existing_branch(
- self,
- mock_exists: MagicMock,
- mock_checkout: MagicMock,
- mock_run_git: MagicMock,
- mock_checkout_new: MagicMock,
- ) -> None:
- mock_exists.return_value = True
- mock_run_git.return_value = ""
- create_group_branch("pr-1", "abc123", "my-feat")
- mock_run_git.assert_called_once_with("branch", "-D", "pr-split/my-feat/pr-1")
-
-
class TestRunGitExtended:
@patch("pr_split.git_ops.branches.subprocess.run")
def test_strips_trailing_whitespace(self, mock_run: MagicMock) -> None:
@@ -221,20 +304,6 @@ def test_renamed_file_is_dirty(self, mock_git: MagicMock) -> None:
assert is_worktree_clean() is False
-class TestCheckoutWrappers:
- @patch("pr_split.git_ops.branches.run_git")
- def test_checkout_new_branch_args(self, mock_git: MagicMock) -> None:
- mock_git.return_value = ""
- checkout_new_branch("feature/x", "abc123")
- mock_git.assert_called_once_with("checkout", "-b", "feature/x", "abc123")
-
- @patch("pr_split.git_ops.branches.run_git")
- def test_checkout_branch_args(self, mock_git: MagicMock) -> None:
- mock_git.return_value = ""
- checkout_branch("main")
- mock_git.assert_called_once_with("checkout", "main")
-
-
class TestRunGitInDir:
@patch("pr_split.git_ops.branches.subprocess.run")
def test_passes_cwd(self, mock_run: MagicMock) -> None:
@@ -260,9 +329,16 @@ class TestAddWorktree:
def test_adds_worktree(self, mock_exists: MagicMock, mock_git: MagicMock) -> None:
mock_git.return_value = ""
add_worktree("/tmp/wt", "pr-split/ns/pr-1", "abc123")
- mock_git.assert_called_once_with(
- "worktree", "add", "-b", "pr-split/ns/pr-1", "/tmp/wt", "abc123"
- )
+ args = mock_git.call_args.args
+ assert args[0] == "-c" and args[1].startswith("core.hooksPath=")
+ assert args[2:] == ("worktree", "add", "-b", "pr-split/ns/pr-1", "/tmp/wt", "abc123")
+
+ def test_hooks_dir_is_private_and_empty(self) -> None:
+ path = Path(_no_hooks_dir())
+ assert path.is_dir()
+ assert list(path.iterdir()) == []
+ # A fixed shared path would let another local user plant hooks there.
+ assert path.stat().st_mode & 0o077 == 0
@patch("pr_split.git_ops.branches.run_git")
@patch("pr_split.git_ops.branches.branch_exists", return_value=True)
@@ -317,6 +393,85 @@ def test_empty_file_paths_raises(self) -> None:
commit_files_in_dir("/tmp/wt", [], "msg")
+class TestGitLocaleIsPinned:
+ @patch("pr_split.git_ops.branches.subprocess.run")
+ def test_run_git_forces_the_c_locale(self, mock_run: MagicMock) -> None:
+ mock_run.return_value = subprocess.CompletedProcess(
+ args=["git"], returncode=0, stdout="", stderr=""
+ )
+ run_git("status")
+ env = mock_run.call_args.kwargs["env"]
+ assert env["LC_ALL"] == "C"
+ assert env["LANGUAGE"] == "C"
+
+ @patch("pr_split.git_ops.branches.subprocess.run")
+ def test_run_git_in_dir_forces_the_c_locale(self, mock_run: MagicMock) -> None:
+ mock_run.return_value = subprocess.CompletedProcess(
+ args=["git"], returncode=0, stdout="", stderr=""
+ )
+ run_git_in_dir("/tmp", "status")
+ assert mock_run.call_args.kwargs["env"]["LC_ALL"] == "C"
+
+ def test_localised_git_still_reports_already_deleted(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ repo = tmp_path / "repo"
+ repo.mkdir()
+ subprocess.run(["git", "init", "-q", "-b", "main"], cwd=repo, check=True)
+ monkeypatch.chdir(repo)
+ monkeypatch.setenv("LC_ALL", "de_DE.UTF-8")
+ monkeypatch.setenv("LANGUAGE", "de")
+ # never existed: must be treated as already deleted, not as an error
+ delete_branch("pr-split/ns/never")
+
+
+class TestIsWorktreeCleanIgnoresPlanDir:
+ """The check must not trip on pr-split's own `.pr-split/plan.json`."""
+
+ @pytest.fixture
+ def repo(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
+ env = {
+ "GIT_AUTHOR_NAME": "t",
+ "GIT_AUTHOR_EMAIL": "t@example.com",
+ "GIT_COMMITTER_NAME": "t",
+ "GIT_COMMITTER_EMAIL": "t@example.com",
+ }
+ for key, value in env.items():
+ monkeypatch.setenv(key, value)
+ subprocess.run(["git", "init", "-q", "-b", "main", str(tmp_path)], check=True)
+ (tmp_path / "src.py").write_text("x = 1\n")
+ plan = tmp_path / PLAN_DIR / "plan.json"
+ plan.parent.mkdir()
+ plan.write_text("{}\n")
+ subprocess.run(["git", "-C", str(tmp_path), "add", "-A"], check=True)
+ subprocess.run(["git", "-C", str(tmp_path), "commit", "-qm", "init"], check=True)
+ monkeypatch.chdir(tmp_path)
+ return tmp_path
+
+ def test_modified_tracked_plan_is_ignored(self, repo: Path) -> None:
+ (repo / PLAN_DIR / "plan.json").write_text('{"groups": []}\n')
+ assert is_worktree_clean() is True
+
+ def test_staged_plan_is_ignored(self, repo: Path) -> None:
+ (repo / PLAN_DIR / "plan.json").write_text('{"groups": []}\n')
+ subprocess.run(["git", "add", "-A"], check=True)
+ assert is_worktree_clean() is True
+
+ def test_plan_dir_is_ignored_from_a_subdirectory(
+ self, repo: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ (repo / PLAN_DIR / "plan.json").write_text('{"groups": []}\n')
+ sub = repo / "pkg"
+ sub.mkdir()
+ monkeypatch.chdir(sub)
+ assert is_worktree_clean() is True
+
+ def test_other_modifications_still_count(self, repo: Path) -> None:
+ (repo / PLAN_DIR / "plan.json").write_text('{"groups": []}\n')
+ (repo / "src.py").write_text("x = 2\n")
+ assert is_worktree_clean() is False
+
+
def _git_identity(monkeypatch: pytest.MonkeyPatch) -> None:
"""CI runners have no git identity configured; commits need one."""
for var in ("GIT_AUTHOR_NAME", "GIT_COMMITTER_NAME"):
@@ -340,12 +495,6 @@ def test_commit_files_in_dir_passes_no_verify(self, mock_git: MagicMock) -> None
"A ",
)
- @patch("pr_split.git_ops.branches.run_git", return_value="sha")
- def test_commit_files_passes_no_verify(self, mock_git: MagicMock) -> None:
- commit_files(["a.py"], "msg")
- commit_call = next(c for c in mock_git.call_args_list if c.args[0] == "commit")
- assert commit_call.args == ("commit", "--no-verify", "-m", "msg")
-
def test_failing_pre_commit_hook_does_not_block_the_sub_pr_commit(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
@@ -406,3 +555,109 @@ def test_deleted_files_are_still_staged(
["git", "ls-files"], cwd=repo, capture_output=True, text=True, check=True
).stdout.split()
assert tracked == []
+
+
+class TestWorktreeAddSkipsHooks:
+ def test_failing_post_checkout_hook_does_not_block_worktree_creation(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ _git_identity(monkeypatch)
+ repo = tmp_path / "repo"
+ repo.mkdir()
+ subprocess.run(["git", "init", "-q", "-b", "main"], cwd=repo, check=True)
+ subprocess.run(
+ ["git", "commit", "-q", "--allow-empty", "-m", "base"], cwd=repo, check=True
+ )
+ hook = repo / ".git" / "hooks" / "post-checkout"
+ hook.write_text("#!/bin/sh\necho 'post-checkout failing' >&2\nexit 1\n")
+ hook.chmod(0o755)
+ monkeypatch.chdir(repo)
+
+ add_worktree(str(tmp_path / "wt"), "pr-split/ns/g1", "main")
+
+ assert (tmp_path / "wt" / ".git").exists()
+
+ def test_hooks_path_from_config_is_also_bypassed(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ _git_identity(monkeypatch)
+ repo = tmp_path / "repo"
+ repo.mkdir()
+ subprocess.run(["git", "init", "-q", "-b", "main"], cwd=repo, check=True)
+ subprocess.run(
+ ["git", "commit", "-q", "--allow-empty", "-m", "base"], cwd=repo, check=True
+ )
+ hooks = tmp_path / "myhooks"
+ hooks.mkdir()
+ (hooks / "post-checkout").write_text("#!/bin/sh\nexit 1\n")
+ (hooks / "post-checkout").chmod(0o755)
+ subprocess.run(["git", "config", "core.hooksPath", str(hooks)], cwd=repo, check=True)
+ monkeypatch.chdir(repo)
+
+ add_worktree(str(tmp_path / "wt"), "pr-split/ns/g2", "main")
+
+ assert (tmp_path / "wt" / ".git").exists()
+
+
+class TestCommitExists:
+ @patch("pr_split.git_ops.branches.run_git", return_value="")
+ def test_true_when_object_resolves(self, mock_git: MagicMock) -> None:
+ assert commit_exists("abc123") is True
+ mock_git.assert_called_once_with("cat-file", "-e", "abc123^{commit}")
+
+ @patch(
+ "pr_split.git_ops.branches.run_git", side_effect=GitOperationError("Not a valid object")
+ )
+ def test_false_when_missing(self, mock_git: MagicMock) -> None:
+ assert commit_exists("0123456789abcdef") is False
+
+
+class TestAdoptRemoteBranch:
+ def _clone_with_remote_branch(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *remotes: str
+ ) -> Path:
+ _git_identity(monkeypatch)
+ upstream = tmp_path / "upstream"
+ upstream.mkdir()
+ subprocess.run(["git", "init", "-q", "-b", "main"], cwd=upstream, check=True)
+ (upstream / "a.txt").write_text("a\n")
+ subprocess.run(["git", "add", "a.txt"], cwd=upstream, check=True)
+ subprocess.run(["git", "commit", "-qm", "a"], cwd=upstream, check=True)
+ subprocess.run(["git", "branch", "feat/move-op"], cwd=upstream, check=True)
+ clone = tmp_path / "clone"
+ subprocess.run(["git", "init", "-q", "-b", "other", str(clone)], check=True)
+ for remote in remotes:
+ subprocess.run(["git", "remote", "add", remote, str(upstream)], cwd=clone, check=True)
+ subprocess.run(["git", "fetch", "-q", remote], cwd=clone, check=True)
+ monkeypatch.chdir(clone)
+ return clone
+
+ def test_branch_on_one_remote_is_created_locally(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ self._clone_with_remote_branch(tmp_path, monkeypatch, "origin")
+ assert not branch_exists("refs/heads/feat/move-op")
+
+ assert adopt_remote_branch("feat/move-op") is True
+
+ assert run_git("rev-parse", "refs/heads/feat/move-op") == run_git(
+ "rev-parse", "origin/feat/move-op"
+ )
+ # It tracks the remote, so diff_base_ref diffs an adopted base against it.
+ assert diff_base_ref("feat/move-op") == "origin/feat/move-op"
+
+ def test_branch_on_two_remotes_is_ambiguous_and_left_alone(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ self._clone_with_remote_branch(tmp_path, monkeypatch, "origin", "upstream")
+ assert adopt_remote_branch("feat/move-op") is False
+ assert not branch_exists("refs/heads/feat/move-op")
+
+ def test_existing_local_branch_is_untouched(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ self._clone_with_remote_branch(tmp_path, monkeypatch, "origin")
+ run_git("branch", "--no-track", "feat/move-op", "origin/main")
+ before = run_git("rev-parse", "refs/heads/feat/move-op")
+ assert adopt_remote_branch("feat/move-op") is False
+ assert run_git("rev-parse", "refs/heads/feat/move-op") == before
diff --git a/tests/test_git_prs.py b/tests/test_git_prs.py
index 25d6031..2043ef9 100644
--- a/tests/test_git_prs.py
+++ b/tests/test_git_prs.py
@@ -9,6 +9,7 @@
from pr_split.git_ops.prs import (
_run_gh,
check_gh_auth,
+ check_gh_stack,
close_pr,
create_pr,
fetch_fork_pr,
@@ -94,21 +95,6 @@ def test_multiline_output_uses_last_line(self, mock_gh: MagicMock) -> None:
class TestFetchForkPr:
- @patch("pr_split.git_ops.prs._run_gh")
- def test_non_fork_raises(self, mock_gh: MagicMock) -> None:
- import json
-
- pr_data = {
- "head": {
- "ref": "feature",
- "repo": {"fork": False, "clone_url": "https://x", "full_name": "u/r"},
- },
- "base": {"ref": "main"},
- }
- mock_gh.return_value = json.dumps(pr_data)
- with pytest.raises(GitOperationError):
- fetch_fork_pr(42)
-
@patch("pr_split.git_ops.prs._run_gh")
def test_api_failure_raises(self, mock_gh: MagicMock) -> None:
mock_gh.side_effect = GitOperationError("Not Found")
@@ -128,13 +114,38 @@ class TestLinkStack:
@patch("pr_split.git_ops.prs._run_gh")
def test_links_bottom_to_top(self, mock_gh: MagicMock) -> None:
mock_gh.return_value = ""
- link_stack([12, 34, 56])
- mock_gh.assert_called_once_with("stack", "link", "12", "34", "56")
+ link_stack([12, 34, 56], base="feat/parent")
+ mock_gh.assert_called_once_with("stack", "link", "--base", "feat/parent", "12", "34", "56")
@patch("pr_split.git_ops.prs._run_gh")
- def test_failure_warns_instead_of_raising(self, mock_gh: MagicMock) -> None:
+ def test_failure_raises(self, mock_gh: MagicMock) -> None:
mock_gh.side_effect = GitOperationError("unknown command: stack")
- link_stack([12, 34])
+ with pytest.raises(GitOperationError, match="Failed to link stack for PRs \\[12, 34\\]"):
+ link_stack([12, 34], base="main")
+
+
+class TestCheckGhStack:
+ @patch("pr_split.git_ops.prs._run_gh")
+ def test_installed(self, mock_gh: MagicMock) -> None:
+ mock_gh.return_value = "gh stack\tgithub/gh-stack\tv0.1.0\ngh dash\tdlvhdr/gh-dash\tv4.0.0"
+ assert check_gh_stack() is True
+ mock_gh.assert_called_once_with("extension", "list")
+
+ @patch("pr_split.git_ops.prs._run_gh")
+ def test_not_installed(self, mock_gh: MagicMock) -> None:
+ mock_gh.return_value = "gh dash\tdlvhdr/gh-dash\tv4.0.0"
+ assert check_gh_stack() is False
+
+ @patch("pr_split.git_ops.prs._run_gh")
+ def test_lookalike_name_does_not_count(self, mock_gh: MagicMock) -> None:
+ mock_gh.return_value = "gh stack\tsomeone/github-gh-stack-fork\tv1.0.0"
+ assert check_gh_stack() is False
+
+ @patch("pr_split.git_ops.prs._run_gh")
+ def test_gh_failure_propagates(self, mock_gh: MagicMock) -> None:
+ mock_gh.side_effect = GitOperationError("gh not found")
+ with pytest.raises(GitOperationError, match="gh not found"):
+ check_gh_stack()
class TestCreatePrDraft:
diff --git a/tests/test_git_prs_coverage.py b/tests/test_git_prs_coverage.py
index 38d132b..6278283 100644
--- a/tests/test_git_prs_coverage.py
+++ b/tests/test_git_prs_coverage.py
@@ -15,6 +15,7 @@
from pr_split.git_ops.prs import (
fetch_fork_branch,
fetch_fork_pr,
+ find_open_pr,
get_pr_state,
merge_pr,
)
@@ -164,3 +165,122 @@ def test_git_fetch_failure(self, mock_gh: MagicMock, mock_git: MagicMock) -> Non
with pytest.raises(GitOperationError, match="Failed to fetch"):
fetch_fork_branch("user", "branch")
+
+
+def _pr_data(head_repo: str, base_repo: str, *, fork: bool) -> str:
+ def repo(full_name: str, *, is_fork: bool) -> dict[str, object]:
+ return {
+ "fork": is_fork,
+ "clone_url": f"https://github.com/{full_name}.git",
+ "full_name": full_name,
+ }
+
+ return json.dumps(
+ {
+ "head": {"ref": "feat/1806-constant-node", "repo": repo(head_repo, is_fork=fork)},
+ "base": {"ref": "main", "repo": repo(base_repo, is_fork=fork)},
+ }
+ )
+
+
+class TestFetchPrHead:
+ """A PR's head is fetched as refs/pull/N/head from the repository gh resolved."""
+
+ @pytest.mark.parametrize(
+ "origin_url",
+ [
+ "https://github.com/org/repo.git",
+ "git@github.com:Org/Repo.git",
+ "ssh://git@github.com/org/repo",
+ ],
+ )
+ @patch("pr_split.git_ops.branches.run_git")
+ @patch("pr_split.git_ops.prs._run_gh")
+ def test_same_repo_pr_is_fetched_from_origin(
+ self, mock_gh: MagicMock, mock_git: MagicMock, origin_url: str
+ ) -> None:
+ mock_gh.return_value = _pr_data("org/repo", "org/repo", fork=False)
+ mock_git.side_effect = [origin_url, "", "A "]
+
+ info = fetch_fork_pr(1931)
+
+ assert mock_git.call_args_list[1].args == (
+ "fetch",
+ "origin",
+ "+refs/pull/1931/head:refs/pr-split/pr-1931",
+ )
+ assert info["local_ref"] == "refs/pr-split/pr-1931"
+ assert info["base_branch"] == "main"
+
+ @patch("pr_split.git_ops.branches.run_git")
+ @patch("pr_split.git_ops.prs._run_gh")
+ def test_internal_pr_of_a_forked_repo_uses_origin(
+ self, mock_gh: MagicMock, mock_git: MagicMock
+ ) -> None:
+ # The repository is itself a fork, so head.repo.fork is true for an internal PR.
+ mock_gh.return_value = _pr_data("me/repo", "me/repo", fork=True)
+ mock_git.side_effect = ["git@github.com:me/repo.git", "", "A "]
+
+ fetch_fork_pr(7)
+
+ assert mock_git.call_args_list[1].args[1] == "origin"
+
+ @patch("pr_split.git_ops.branches.run_git")
+ @patch("pr_split.git_ops.prs._run_gh")
+ def test_pr_of_another_repo_is_not_fetched_from_origin(
+ self, mock_gh: MagicMock, mock_git: MagicMock
+ ) -> None:
+ # gh resolved upstream while origin is the user's fork: a same-named
+ # branch on origin must not stand in for the PR's head.
+ mock_gh.return_value = _pr_data("up/repo", "up/repo", fork=False)
+ mock_git.side_effect = ["git@github.com:me/repo.git", "", "A "]
+
+ fetch_fork_pr(7)
+
+ assert mock_git.call_args_list[1].args == (
+ "fetch",
+ "https://github.com/up/repo.git",
+ "+refs/pull/7/head:refs/pr-split/pr-7",
+ )
+
+ @patch("pr_split.git_ops.branches.run_git")
+ @patch("pr_split.git_ops.prs._run_gh")
+ def test_fetch_failure_names_the_source(self, mock_gh: MagicMock, mock_git: MagicMock) -> None:
+ mock_gh.return_value = _pr_data("org/repo", "org/repo", fork=False)
+ mock_git.side_effect = ["https://github.com/org/repo", GitOperationError("no ref")]
+
+ with pytest.raises(GitOperationError, match="head of PR #7 from origin: no ref"):
+ fetch_fork_pr(7)
+
+
+class TestFetchForkPrMalformedResponse:
+ @pytest.mark.parametrize(
+ "raw", ["not json", "[]", '{"head": {}}'], ids=["text", "list", "no-base"]
+ )
+ @patch("pr_split.git_ops.prs._run_gh")
+ def test_malformed_response_is_a_git_operation_error(
+ self, mock_gh: MagicMock, raw: str
+ ) -> None:
+ mock_gh.return_value = raw
+ with pytest.raises(GitOperationError, match="Unexpected response from GitHub for PR #42"):
+ fetch_fork_pr(42)
+
+
+class TestFindOpenPr:
+ @patch("pr_split.git_ops.prs._run_gh")
+ def test_only_an_exact_same_repository_head_counts(self, mock_gh: MagicMock) -> None:
+ mock_gh.return_value = json.dumps(
+ [
+ {"number": 1, "url": "u1", "headRefName": "feat/x-2", "isCrossRepository": False},
+ {"number": 2, "url": "u2", "headRefName": "feat/x", "isCrossRepository": True},
+ {"number": 3, "url": "u3", "headRefName": "feat/x", "isCrossRepository": False},
+ ]
+ )
+ assert find_open_pr("feat/x") == (3, "u3")
+
+ @patch("pr_split.git_ops.prs._run_gh")
+ def test_no_exact_match_is_none(self, mock_gh: MagicMock) -> None:
+ mock_gh.return_value = json.dumps(
+ [{"number": 1, "url": "u1", "headRefName": "feat/x-2", "isCrossRepository": False}]
+ )
+ assert find_open_pr("feat/x") is None
diff --git a/tests/test_graph.py b/tests/test_graph.py
index 2a2b656..65dcc39 100644
--- a/tests/test_graph.py
+++ b/tests/test_graph.py
@@ -38,6 +38,10 @@ def test_cycle_detected(self) -> None:
with pytest.raises(PlanValidationError):
dag.validate_acyclic()
+ def test_unknown_dependency_raises(self) -> None:
+ with pytest.raises(PlanValidationError, match="'b' depends on unknown group 'zzz'"):
+ PlanDAG([_group("a"), _group("b", ["zzz"])])
+
def test_acyclic_passes(self) -> None:
groups = [_group("a"), _group("b", ["a"])]
dag = PlanDAG(groups)
@@ -137,3 +141,26 @@ def test_fan_out_keeps_descendant_runs(self) -> None:
]
dag = PlanDAG(groups)
assert sorted(dag.linear_chains()) == [["a"], ["b", "c"], ["d"]]
+
+
+class TestPlanDAGRejectsDuplicateIds:
+ def test_duplicate_group_id_is_a_validation_error(self) -> None:
+ with pytest.raises(PlanValidationError, match="'pr-1' is used more than once"):
+ PlanDAG([_group("pr-1"), _group("pr-1")])
+
+ def test_distinct_ids_are_fine(self) -> None:
+ dag = PlanDAG([_group("pr-1"), _group("pr-2", ["pr-1"])])
+ assert dag.parents("pr-2") == ["pr-1"]
+
+
+class TestPlanDAGDedupesDependencies:
+ def test_duplicate_dependency_is_a_single_edge(self) -> None:
+ dag = PlanDAG([_group("pr-1"), _group("pr-2", ["pr-1", "pr-1"])])
+ assert dag.parents("pr-2") == ["pr-1"]
+ assert dag.children("pr-1") == ["pr-2"]
+ assert dag.is_merge_node("pr-2") is False
+ assert dag.linear_chains() == [["pr-1", "pr-2"]]
+
+ def test_duplicate_dependency_does_not_break_batching(self) -> None:
+ dag = PlanDAG([_group("pr-1"), _group("pr-2", ["pr-1", "pr-1"])])
+ assert list(dag.iter_ready()) == [["pr-1"], ["pr-2"]]
diff --git a/tests/test_local_provider.py b/tests/test_local_provider.py
new file mode 100644
index 0000000..0d45f4d
--- /dev/null
+++ b/tests/test_local_provider.py
@@ -0,0 +1,341 @@
+from __future__ import annotations
+
+import json
+import threading
+from collections.abc import Iterator
+from http.server import BaseHTTPRequestHandler, HTTPServer
+
+import pytest
+
+from pr_split.config import Settings
+from pr_split.constants import (
+ LOCAL_BASE_URL,
+ LOCAL_MAX_CONTEXT_TOKENS,
+ LOCAL_MAX_OUTPUT_TOKENS,
+ PartitionStrategy,
+ Provider,
+)
+from pr_split.diff_ops.parser import parse_diff
+from pr_split.exceptions import LLMError
+from pr_split.planner.client import _call_llm, _count_tokens, _json_from_text, plan_split
+from pr_split.planner.prompts import SPLIT_TOOL_NAME
+
+_DIFF = """\
+diff --git a/a.py b/a.py
+new file mode 100644
+--- /dev/null
++++ b/a.py
+@@ -0,0 +1,2 @@
++x = 1
++y = 2
+"""
+
+_GROUPS = [
+ {
+ "id": "pr-1",
+ "title": "Add a.py",
+ "description": "Adds a.py",
+ "depends_on": [],
+ "assignments": [
+ {"file_path": "a.py", "assignment_type": "whole_file", "hunk_indices": [0]}
+ ],
+ "estimated_loc": 2,
+ }
+]
+
+
+def _tool_call_reply(arguments: str) -> dict[str, object]:
+ return {
+ "role": "assistant",
+ "content": None,
+ "tool_calls": [
+ {
+ "id": "call_1",
+ "type": "function",
+ "function": {"name": SPLIT_TOOL_NAME, "arguments": arguments},
+ }
+ ],
+ }
+
+
+class _FakeServer:
+ """An OpenAI-compatible /v1/chat/completions endpoint, as Ollama or llama.cpp serve."""
+
+ def __init__(self) -> None:
+ self.requests: list[dict[str, object]] = []
+ self.message: dict[str, object] = _tool_call_reply(json.dumps({"groups": _GROUPS}))
+ self.finish_reason = "tool_calls"
+ self.status = 200
+ fake = self
+
+ class Handler(BaseHTTPRequestHandler):
+ def do_POST(self) -> None:
+ length = int(self.headers["Content-Length"])
+ fake.requests.append(
+ {
+ "path": self.path,
+ "headers": dict(self.headers),
+ **json.loads(self.rfile.read(length)),
+ }
+ )
+ if fake.status != 200:
+ body = json.dumps({"error": {"message": "model not found"}}).encode()
+ else:
+ body = json.dumps(
+ {
+ "id": "chatcmpl-1",
+ "object": "chat.completion",
+ "created": 0,
+ "model": "m",
+ "choices": [
+ {
+ "index": 0,
+ "message": fake.message,
+ "finish_reason": fake.finish_reason,
+ }
+ ],
+ }
+ ).encode()
+ self.send_response(fake.status)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def log_message(self, *args: object) -> None:
+ pass
+
+ self.httpd = HTTPServer(("127.0.0.1", 0), Handler)
+ self.url = f"http://127.0.0.1:{self.httpd.server_address[1]}/v1"
+ self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
+
+ def __enter__(self) -> _FakeServer:
+ self.thread.start()
+ return self
+
+ def __exit__(self, *exc: object) -> None:
+ self.httpd.shutdown()
+ self.httpd.server_close()
+
+
+@pytest.fixture
+def server() -> Iterator[_FakeServer]:
+ with _FakeServer() as fake:
+ yield fake
+
+
+def _local_settings(url: str, **overrides: object) -> Settings:
+ return Settings(
+ provider=Provider.LOCAL,
+ partition_strategy=PartitionStrategy.LLM,
+ model=overrides.pop("model", "qwen2.5-coder:14b"),
+ local_base_url=url,
+ **overrides, # type: ignore[arg-type]
+ )
+
+
+class TestLocalSettings:
+ def test_needs_no_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
+ settings = Settings(provider=Provider.LOCAL, partition_strategy="llm", model="m")
+ assert settings.local_base_url == LOCAL_BASE_URL
+ assert settings.max_context_tokens == LOCAL_MAX_CONTEXT_TOKENS
+ assert settings.max_output_tokens == LOCAL_MAX_OUTPUT_TOKENS
+
+ def test_requires_a_model(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.delenv("PR_SPLIT_MODEL", raising=False)
+ with pytest.raises(ValueError, match="PR_SPLIT_MODEL must be set"):
+ Settings(provider=Provider.LOCAL, partition_strategy="llm")
+
+ def test_model_not_needed_without_the_llm_backend(self) -> None:
+ settings = Settings(provider=Provider.LOCAL, partition_strategy="graph")
+ assert settings.model == ""
+
+ def test_output_budget_must_fit_the_window(self) -> None:
+ with pytest.raises(ValueError, match="must be less than"):
+ Settings(
+ provider=Provider.LOCAL,
+ partition_strategy="llm",
+ model="m",
+ local_context_tokens=4096,
+ local_max_output_tokens=4096,
+ )
+
+ def test_reads_environment(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("PR_SPLIT_PROVIDER", "local")
+ monkeypatch.setenv("PR_SPLIT_MODEL", "llama3.1:8b")
+ monkeypatch.setenv("PR_SPLIT_LOCAL_BASE_URL", "http://gpu-box:8000/v1")
+ monkeypatch.setenv("PR_SPLIT_LOCAL_CONTEXT_TOKENS", "131072")
+ monkeypatch.setenv("PR_SPLIT_LOCAL_MAX_OUTPUT_TOKENS", "16384")
+ monkeypatch.setenv("PR_SPLIT_LOCAL_API_KEY", "secret")
+ settings = Settings(partition_strategy="llm")
+ assert settings.provider is Provider.LOCAL
+ assert settings.model == "llama3.1:8b"
+ assert settings.local_base_url == "http://gpu-box:8000/v1"
+ assert settings.max_context_tokens == 131072
+ assert settings.max_output_tokens == 16384
+ assert settings.api_key == "secret"
+
+ def test_cloud_providers_keep_their_output_budget(self) -> None:
+ settings = Settings(provider=Provider.OPENAI, partition_strategy="graph")
+ assert settings.max_output_tokens == 128_000
+
+
+class TestCallLocal:
+ def test_forced_tool_call_is_the_plan(self, server: _FakeServer) -> None:
+ result = _call_llm("the system", "the diff", settings=_local_settings(server.url))
+
+ assert result["groups"] == _GROUPS
+ request = server.requests[0]
+ assert request["path"] == "/v1/chat/completions"
+ assert request["model"] == "qwen2.5-coder:14b"
+ assert request["temperature"] == 0
+ assert request["max_tokens"] == LOCAL_MAX_OUTPUT_TOKENS
+ assert request["messages"] == [
+ {"role": "system", "content": "the system"},
+ {"role": "user", "content": "the diff"},
+ ]
+ assert request["tool_choice"] == {
+ "type": "function",
+ "function": {"name": SPLIT_TOOL_NAME},
+ }
+ assert request["tools"][0]["function"]["name"] == SPLIT_TOOL_NAME # type: ignore[index]
+
+ def test_plan_in_message_text_is_accepted(self, server: _FakeServer) -> None:
+ server.message = {
+ "role": "assistant",
+ "content": "Here is the plan:\n```json\n" + json.dumps({"groups": _GROUPS}) + "\n```",
+ }
+ server.finish_reason = "stop"
+ result = _call_llm("s", "u", settings=_local_settings(server.url))
+ assert result["groups"] == _GROUPS
+
+ def test_call_written_as_text_is_unwrapped(self, server: _FakeServer) -> None:
+ server.message = {
+ "role": "assistant",
+ "content": json.dumps({"name": SPLIT_TOOL_NAME, "arguments": {"groups": _GROUPS}}),
+ }
+ server.finish_reason = "stop"
+ result = _call_llm("s", "u", settings=_local_settings(server.url))
+ assert result["groups"] == _GROUPS
+
+ def test_prose_reply_is_an_llm_error(self, server: _FakeServer) -> None:
+ server.message = {"role": "assistant", "content": "I cannot split this."}
+ server.finish_reason = "stop"
+ with pytest.raises(LLMError, match="no tool call or JSON plan"):
+ _call_llm("s", "u", settings=_local_settings(server.url))
+
+ def test_cut_off_reply_is_truncated(self, server: _FakeServer) -> None:
+ server.message = _tool_call_reply('{"groups": [{"id": "pr-1"')
+ server.finish_reason = "length"
+ with pytest.raises(LLMError, match="cut off"):
+ _call_llm("s", "u", settings=_local_settings(server.url))
+
+ def test_bad_tool_arguments_are_an_llm_error(self, server: _FakeServer) -> None:
+ server.message = _tool_call_reply("not json")
+ with pytest.raises(LLMError, match="failed to parse tool arguments"):
+ _call_llm("s", "u", settings=_local_settings(server.url))
+
+ def test_server_error_is_an_llm_error(self, server: _FakeServer) -> None:
+ server.status = 404
+ with pytest.raises(LLMError, match="model not found"):
+ _call_llm("s", "u", settings=_local_settings(server.url))
+
+ def test_api_key_is_sent_when_set(self, server: _FakeServer) -> None:
+ _call_llm("s", "u", settings=_local_settings(server.url, local_api_key="secret"))
+ headers = server.requests[0]["headers"]
+ assert headers["Authorization"] == "Bearer secret" # type: ignore[index]
+
+ def test_unreachable_server_names_the_url(self) -> None:
+ with _FakeServer() as fake:
+ url = fake.url
+ settings = _local_settings(url)
+ with pytest.raises(LLMError, match=f"Cannot reach the local LLM server at {url}"):
+ _call_llm("s", "u", settings=settings)
+
+ def test_tokens_are_estimated_locally(self) -> None:
+ assert _count_tokens("system", "user text", settings=_local_settings(LOCAL_BASE_URL)) > 0
+
+
+class TestPlanSplitLocal:
+ def test_end_to_end_plan(self, server: _FakeServer) -> None:
+ groups = plan_split(parse_diff(_DIFF), _local_settings(server.url))
+ assert [g.id for g in groups] == ["pr-1"]
+ assert groups[0].estimated_loc == 2
+
+ def test_large_diff_is_chunked_within_a_small_window(self, server: _FakeServer) -> None:
+ files = "".join(
+ f"diff --git a/f{i}.py b/f{i}.py\nnew file mode 100644\n--- /dev/null\n"
+ f"+++ b/f{i}.py\n@@ -0,0 +1,40 @@\n"
+ + "".join(f"+value_{i}_{n} = {n} * 12345678\n" for n in range(40))
+ for i in range(30)
+ )
+ group = {
+ **_GROUPS[0],
+ "assignments": [{**_GROUPS[0]["assignments"][0], "file_path": "f0.py"}],
+ } # type: ignore[dict-item]
+ server.message = _tool_call_reply(json.dumps({"groups": [group]}))
+ settings = _local_settings(
+ server.url, local_context_tokens=16_384, local_max_output_tokens=2_048
+ )
+
+ groups = plan_split(parse_diff(files), settings)
+
+ assert len(server.requests) > 1
+ for request in server.requests:
+ assert request["max_tokens"] == 2_048
+ # Hunks the model left out are still placed in its group.
+ assert sum(len(a.hunk_indices) for g in groups for a in g.assignments) == 30
+
+
+class TestJsonFromText:
+ @pytest.mark.parametrize("text", ["", "no json here", "{broken", "[1, 2]"])
+ def test_rejects_non_objects(self, text: str) -> None:
+ assert _json_from_text(text) is None
+
+ def test_bare_object(self) -> None:
+ assert _json_from_text('{"groups": []}') == {"groups": []}
+
+
+class TestOffMachineWarning:
+ @pytest.mark.parametrize(
+ "url", ["http://localhost:11434/v1", "http://127.0.0.1:8080/v1", "http://[::1]:8000/v1"]
+ )
+ def test_loopback_is_silent(self, url: str) -> None:
+ from unittest.mock import patch
+
+ from pr_split.planner.client import _warn_if_off_machine
+
+ with patch("pr_split.planner.client.logger") as mock_logger:
+ _warn_if_off_machine(url)
+ mock_logger.warning.assert_not_called()
+
+ def test_other_host_is_warned_about_once(self) -> None:
+ from unittest.mock import patch
+
+ from pr_split.planner.client import _warn_if_off_machine, _warned_remote_hosts
+
+ _warned_remote_hosts.discard("gpu-box.lan")
+ with patch("pr_split.planner.client.logger") as mock_logger:
+ _warn_if_off_machine("http://gpu-box.lan:8000/v1")
+ _warn_if_off_machine("http://gpu-box.lan:8000/v1")
+ assert mock_logger.warning.call_count == 1
+ assert "gpu-box.lan" in mock_logger.warning.call_args.args[0]
+
+
+def test_window_with_no_room_for_a_diff_is_a_clear_error(server: _FakeServer) -> None:
+ from pr_split.exceptions import PRSplitError
+
+ files = "".join(
+ f"diff --git a/f{i}.py b/f{i}.py\nnew file mode 100644\n--- /dev/null\n"
+ f"+++ b/f{i}.py\n@@ -0,0 +1,40 @@\n"
+ + "".join(f"+value_{i}_{n} = {n} * 12345678\n" for n in range(40))
+ for i in range(30)
+ )
+ settings = _local_settings(
+ server.url, local_context_tokens=16_384, local_max_output_tokens=12_000
+ )
+ with pytest.raises(PRSplitError, match="No room for the diff in a chunk"):
+ plan_split(parse_diff(files), settings)
+ assert server.requests == []
diff --git a/tests/test_new_file_split.py b/tests/test_new_file_split.py
new file mode 100644
index 0000000..5711ace
--- /dev/null
+++ b/tests/test_new_file_split.py
@@ -0,0 +1,340 @@
+from __future__ import annotations
+
+import pytest
+
+from pr_split.config import Settings
+from pr_split.constants import AssignmentType, PartitionStrategy
+from pr_split.diff_ops import materialize_group_files, merge_chain_assignments
+from pr_split.diff_ops.new_file_split import block_starts, cut_points
+from pr_split.diff_ops.parser import parse_diff
+from pr_split.exceptions import PlanValidationError
+from pr_split.graph import PlanDAG
+from pr_split.planner.client import plan_split
+from pr_split.planner.new_file_pieces import link_new_file_pieces
+from pr_split.planner.validator import validate_new_file_pieces, validate_plan
+from pr_split.schemas import Group, GroupAssignment, SplitPlan
+
+
+def _new_file_diff(path: str, lines: list[str], *, trailing_newline: bool = True) -> str:
+ body = "".join(f"+{line}\n" for line in lines)
+ if not trailing_newline:
+ body += "\\ No newline at end of file\n"
+ return (
+ f"diff --git a/{path} b/{path}\nnew file mode 100644\n--- /dev/null\n+++ b/{path}\n"
+ f"@@ -0,0 +1,{len(lines)} @@\n{body}"
+ )
+
+
+def _python_module(functions: int, body_lines: int) -> list[str]:
+ lines = ['"""A generated module."""', "", "import os", ""]
+ for n in range(functions):
+ lines += ["", f"def func_{n}(value):", ' """Docstring."""']
+ lines += [f" value = value + {i}" for i in range(body_lines)]
+ lines += ["", " return os.fspath(str(value))"]
+ return lines
+
+
+_MODIFIED = """\
+diff --git a/old.py b/old.py
+--- a/old.py
++++ b/old.py
+@@ -1,2 +1,3 @@
+ a = 1
++b = 2
+ c = 3
+"""
+
+
+class TestCutPoints:
+ def test_packs_whole_blocks_up_to_the_limit(self) -> None:
+ values = ["a = 1", "", "def f():", " x", "", "def g():", " y", "", "def h():"]
+ # Blocks start at 0, 2, 5 and 8.
+ assert cut_points(values, 5) == [5]
+ assert cut_points(values, 4) == [2, 5]
+
+ def test_block_larger_than_the_limit_stays_whole(self) -> None:
+ values = ["class Big:", *[f" x{i} = {i}" for i in range(20)], "", "y = 1"]
+ assert cut_points(values, 5) == [22]
+
+ def test_never_cuts_inside_indented_code(self) -> None:
+ values = ["def f():", " a = 1", "", " b = 2", "", " return a + b"]
+ assert cut_points(values, 2) == []
+
+ @pytest.mark.parametrize("line", ["}", "} else {", ")", "else:", "except ValueError:"])
+ def test_continuation_lines_do_not_start_a_block(self, line: str) -> None:
+ values = ["x", "y", "", line, "z"]
+ assert cut_points(values, 2) == []
+
+ def test_comment_above_a_definition_stays_with_it(self) -> None:
+ values = ["a = 1", "b = 2", "", "# explains f", "@decorator", "def f():", " pass"]
+ assert cut_points(values, 4) == [3]
+
+
+class TestBlockStarts:
+ def test_python_docstring_paragraphs_are_not_cut(self) -> None:
+ values = [
+ '"""Module doc.\n',
+ "\n",
+ "Second paragraph at column 0.\n",
+ '"""\n',
+ "\n",
+ "X = 1\n",
+ ]
+ assert block_starts(values, "m.py") == [5]
+
+ def test_python_decorators_and_comments_stay_with_their_definition(self) -> None:
+ values = ["import os\n", "\n", "# why\n", "@cache\n", "def f():\n", " pass\n"]
+ assert block_starts(values, "m.py") == [2]
+
+ def test_python_cuts_between_statements_without_blank_lines(self) -> None:
+ assert block_starts(["a = 1\n", "b = 2\n", "c = 3\n"], "m.py") == [1, 2]
+
+ def test_invalid_python_falls_back_to_blank_lines(self) -> None:
+ assert block_starts(["def f()\n", "\n", "x = 1\n"], "m.py") == [2]
+
+ def test_blank_line_inside_brackets_is_not_a_boundary(self) -> None:
+ values = ["const X = [", " 1,", "", "2,", "];", "", "const Y = 2;"]
+ assert block_starts(values, "m.js") == [6]
+
+ @pytest.mark.parametrize(
+ ("opener", "closer"),
+ [("const S = `", "`;"), ("/*", "*/"), ('s = """', '"""'), ("x = '''", "'''")],
+ )
+ def test_multi_line_strings_and_comments_are_not_boundaries(
+ self, opener: str, closer: str
+ ) -> None:
+ values = [opener, "", "inside", closer, "", "after = 1"]
+ assert block_starts(values, "m.txt") == [5]
+
+ def test_brackets_in_strings_and_comments_are_ignored(self) -> None:
+ values = ['let s = "(";', "// see (a", "", "fn g() {}"]
+ assert block_starts(values, "m.rs") == [3]
+
+ def test_rust_lifetimes_do_not_hide_brackets(self) -> None:
+ values = ["fn f<'a>(x: &'a str) -> &'a str {", " x", "}", "", "fn g() {}"]
+ assert block_starts(values, "m.rs") == [4]
+
+
+class TestParseDiffSplitsNewFiles:
+ def test_pieces_reassemble_the_file(self) -> None:
+ lines = _python_module(functions=12, body_lines=20)
+ parsed = parse_diff(_new_file_diff("pkg/big.py", lines), split_new_files_over=100)
+
+ patch_file = parsed.patch_set[0]
+ assert len(patch_file) > 1
+ assert all(hunk.added <= 100 for hunk in patch_file)
+ assert parsed.new_file_pieces == {"pkg/big.py": len(patch_file)}
+ assert sum(hunk.added for hunk in patch_file) == len(lines)
+ assert parsed.stats["total_loc"] == len(lines)
+
+ group = _whole("pr-1", "pkg/big.py")
+ content = materialize_group_files(parsed, group, "unused")["pkg/big.py"]
+ assert content == "".join(f"{line}\n" for line in lines)
+
+ def test_missing_final_newline_is_kept(self) -> None:
+ lines = _python_module(functions=6, body_lines=20)
+ diff = _new_file_diff("big.py", lines, trailing_newline=False)
+ parsed = parse_diff(diff, split_new_files_over=50)
+
+ assert len(parsed.patch_set[0]) > 1
+ content = materialize_group_files(parsed, _whole("pr-1", "big.py"), "unused")["big.py"]
+ assert content == "\n".join(lines)
+
+ def test_labeled_diff_shows_every_piece(self) -> None:
+ lines = _python_module(functions=6, body_lines=20)
+ parsed = parse_diff(_new_file_diff("big.py", lines), split_new_files_over=50)
+ pieces = len(parsed.patch_set[0])
+ for index in range(pieces):
+ assert f"[hunk_index={index}]" in parsed.labeled_diff
+
+ def test_small_new_files_and_modified_files_are_untouched(self) -> None:
+ diff = _MODIFIED + _new_file_diff("small.py", ["x = 1", "", "y = 2"])
+ parsed = parse_diff(diff, split_new_files_over=3)
+ assert [len(pf) for pf in parsed.patch_set] == [1, 1]
+ assert parsed.new_file_pieces == {}
+
+ def test_off_by_default(self) -> None:
+ lines = _python_module(functions=12, body_lines=20)
+ parsed = parse_diff(_new_file_diff("big.py", lines))
+ assert len(parsed.patch_set[0]) == 1
+
+
+def _whole(gid: str, path: str) -> Group:
+ return Group(
+ id=gid,
+ title=gid,
+ description="",
+ assignments=[
+ GroupAssignment(
+ file_path=path, assignment_type=AssignmentType.WHOLE_FILE, hunk_indices=[0]
+ )
+ ],
+ )
+
+
+def _pieces(gid: str, path: str, indices: list[int], depends_on: list[str] | None = None) -> Group:
+ return Group(
+ id=gid,
+ title=gid,
+ description="",
+ depends_on=depends_on or [],
+ assignments=[
+ GroupAssignment(
+ file_path=path,
+ assignment_type=AssignmentType.PARTIAL_HUNKS,
+ hunk_indices=indices,
+ )
+ ],
+ )
+
+
+class TestStackedGraphSplit:
+ def test_every_layer_holds_a_compiling_prefix_under_max_loc(self) -> None:
+ lines = _python_module(functions=16, body_lines=25)
+ diff = _new_file_diff("pkg/big.py", lines)
+ parsed = parse_diff(diff, split_new_files_over=150)
+ settings = Settings(partition_strategy=PartitionStrategy.GRAPH, max_loc=150)
+
+ groups = plan_split(parsed, settings)
+ dag = PlanDAG(groups)
+ assert validate_plan(groups, parsed, dag, settings.max_loc) == []
+ assert len(groups) > 1
+ assert all(group.estimated_loc <= 150 for group in groups)
+
+ hunk_counts = {pf.path: len(pf) for pf in parsed.patch_set}
+ by_id = {g.id: g for g in groups}
+ previous = ""
+ for gid in dag.topological_order():
+ stacked = merge_chain_assignments(
+ by_id[gid], [by_id[a] for a in dag.ancestors(gid)], hunk_counts
+ )
+ content = materialize_group_files(parsed, stacked, "unused")["pkg/big.py"]
+ assert content.startswith(previous)
+ compile(content, "big.py", "exec")
+ previous = content
+ assert previous == "".join(f"{line}\n" for line in lines)
+
+
+class TestPieceOrder:
+ @pytest.fixture
+ def parsed(self): # type: ignore[no-untyped-def]
+ lines = _python_module(functions=6, body_lines=20)
+ parsed = parse_diff(_new_file_diff("big.py", lines), split_new_files_over=50)
+ assert len(parsed.patch_set[0]) >= 3
+ return parsed
+
+ def test_later_piece_without_the_earlier_one_is_rejected(self, parsed) -> None: # type: ignore[no-untyped-def]
+ groups = [_pieces("pr-1", "big.py", [0]), _pieces("pr-2", "big.py", [1, 2])]
+ with pytest.raises(PlanValidationError, match=r"piece 2 of new file 'big\.py'"):
+ validate_new_file_pieces(groups, parsed, PlanDAG(groups))
+
+ def test_linking_adds_the_missing_edges(self, parsed) -> None: # type: ignore[no-untyped-def]
+ groups = [
+ _pieces("pr-1", "big.py", [2]),
+ _pieces("pr-2", "big.py", [0]),
+ _pieces("pr-3", "big.py", [1]),
+ ]
+ link_new_file_pieces(groups, parsed)
+
+ assert groups[2].depends_on == ["pr-2"]
+ assert groups[0].depends_on == ["pr-3"]
+ validate_new_file_pieces(groups, parsed, PlanDAG(groups))
+
+ def test_edge_that_would_close_a_cycle_is_left_for_validation(self, parsed) -> None: # type: ignore[no-untyped-def]
+ groups = [
+ _pieces("pr-1", "big.py", [0], depends_on=["pr-2"]),
+ _pieces("pr-2", "big.py", [1, 2]),
+ ]
+ link_new_file_pieces(groups, parsed)
+
+ assert groups[1].depends_on == []
+ with pytest.raises(PlanValidationError, match="does not build on group 'pr-1'"):
+ validate_new_file_pieces(groups, parsed, PlanDAG(groups))
+
+ def test_one_group_holding_every_piece_is_fine(self, parsed) -> None: # type: ignore[no-untyped-def]
+ groups = [_whole("pr-1", "big.py")]
+ link_new_file_pieces(groups, parsed)
+ validate_new_file_pieces(groups, parsed, PlanDAG(groups))
+ assert groups[0].depends_on == []
+
+
+def test_plan_records_the_threshold() -> None:
+ plan = SplitPlan(
+ dev_branch="d", base_branch="main", max_loc=100, priority="orthogonal", stacked=True
+ )
+ assert plan.split_new_files_over is None
+ restored = SplitPlan.model_validate_json(
+ plan.model_copy(update={"split_new_files_over": 100}).model_dump_json()
+ )
+ assert restored.split_new_files_over == 100
+
+
+class TestExecuteReparsesPieces:
+ def _plan_file(self, groups: list[Group]): # type: ignore[no-untyped-def]
+ from unittest.mock import MagicMock
+
+ lines = _python_module(functions=6, body_lines=20)
+ plan_file = MagicMock()
+ plan_file.git_state.branches = []
+ plan_file.git_state.prs = []
+ plan_file.plan = SplitPlan(
+ dev_branch="feature",
+ base_branch="main",
+ max_loc=50,
+ priority="orthogonal",
+ stacked=True,
+ groups=groups,
+ merge_base_sha="abc123",
+ raw_diff=_new_file_diff("big.py", lines),
+ split_new_files_over=50,
+ )
+ return plan_file
+
+ def _invoke(self, plan_file): # type: ignore[no-untyped-def]
+ from unittest.mock import patch
+
+ from typer.testing import CliRunner
+
+ from pr_split.cli import app
+
+ with (
+ patch("pr_split.cli.plan_exists", return_value=True),
+ patch("pr_split.cli.load_plan", return_value=plan_file),
+ patch("pr_split.cli.branch_exists", return_value=True),
+ patch("pr_split.cli.is_worktree_clean", return_value=True),
+ patch("pr_split.cli.check_gh_auth", return_value=True),
+ patch("pr_split.cli.commit_exists", return_value=True),
+ patch("pr_split.cli._require_gh_stack"),
+ patch("pr_split.cli._create_branches_and_commits") as create,
+ ):
+ create.side_effect = RuntimeError("stop after validation")
+ result = CliRunner().invoke(app, ["execute"], input="y\n")
+ return result, create
+
+ def test_out_of_order_pieces_are_refused_before_any_branch(self) -> None:
+ pieces = len(
+ parse_diff(self._plan_file([]).plan.raw_diff, split_new_files_over=50).patch_set[0]
+ )
+ groups = [
+ _pieces("pr-1", "big.py", [0]),
+ _pieces("pr-2", "big.py", list(range(1, pieces))),
+ ]
+ result, create = self._invoke(self._plan_file(groups))
+
+ assert result.exit_code == 1
+ assert "holds piece 2 of new file 'big.py'" in result.output
+ create.assert_not_called()
+
+ def test_pieces_in_stack_order_reach_branch_creation(self) -> None:
+ pieces = len(
+ parse_diff(self._plan_file([]).plan.raw_diff, split_new_files_over=50).patch_set[0]
+ )
+ groups = [
+ _pieces("pr-1", "big.py", [0]),
+ _pieces("pr-2", "big.py", list(range(1, pieces)), depends_on=["pr-1"]),
+ ]
+ _result, create = self._invoke(self._plan_file(groups))
+
+ create.assert_called_once()
+ parsed = create.call_args.args[1]
+ assert len(parsed.patch_set[0]) == pieces
diff --git a/tests/test_partitioning.py b/tests/test_partitioning.py
index 6f1cfa0..9a9c85e 100644
--- a/tests/test_partitioning.py
+++ b/tests/test_partitioning.py
@@ -5,7 +5,15 @@
from pr_split.config import Settings
from pr_split.constants import PartitionStrategy, Priority
from pr_split.diff_ops.parser import parse_diff
-from pr_split.planner.partitioning import build_partition_units, partition_diff
+from pr_split.exceptions import PlanValidationError
+from pr_split.planner.partitioning import (
+ PartitionUnit,
+ _group_units_graph,
+ _merge_order_is_acyclic,
+ _repair_graph_min_loc,
+ build_partition_units,
+ partition_diff,
+)
SAMPLE_DIFF = """\
diff --git a/a.py b/a.py
@@ -50,12 +58,14 @@ def _settings(
max_loc: int,
partition_strategy: PartitionStrategy,
priority: Priority = Priority.ORTHOGONAL,
+ **overrides: object,
) -> Settings:
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
return Settings(
max_loc=max_loc,
partition_strategy=partition_strategy,
priority=priority,
+ **overrides,
)
@@ -109,3 +119,176 @@ def test_cp_sat_backend_returns_groups(self, monkeypatch: pytest.MonkeyPatch) ->
groups = partition_diff(parsed, settings)
assert len(groups) == 2
assert all(group.depends_on == [] for group in groups)
+
+ def test_feasible_but_not_optimal_is_warned(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ cp_model = pytest.importorskip("ortools.sat.python.cp_model")
+ from unittest.mock import patch
+
+ parsed = parse_diff(SAMPLE_DIFF)
+ settings = _settings(
+ monkeypatch,
+ max_loc=10,
+ partition_strategy=PartitionStrategy.CP_SAT,
+ cp_sat_timeout=0.5,
+ )
+ real_solve = cp_model.CpSolver.Solve
+
+ def solve_feasible(self: object, model: object) -> int:
+ real_solve(self, model)
+ return cp_model.FEASIBLE
+
+ with (
+ patch.object(cp_model.CpSolver, "Solve", solve_feasible),
+ patch("pr_split.planner.partitioning.logger") as mock_logger,
+ ):
+ groups = partition_diff(parsed, settings)
+
+ assert len(groups) == 2
+ warning = mock_logger.warning.call_args[0][0]
+ assert "feasible but unproven-optimal plan" in warning
+ assert "0.5s limit" in warning
+ assert "raise --cp-sat-timeout" in warning
+
+ def test_sub_decisecond_timeout_is_reported_exactly(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ cp_model = pytest.importorskip("ortools.sat.python.cp_model")
+ from unittest.mock import patch
+
+ parsed = parse_diff(SAMPLE_DIFF)
+ settings = _settings(
+ monkeypatch,
+ max_loc=10,
+ partition_strategy=PartitionStrategy.CP_SAT,
+ cp_sat_timeout=0.04,
+ )
+ real_solve = cp_model.CpSolver.Solve
+
+ def solve_feasible(self: object, model: object) -> int:
+ real_solve(self, model)
+ return cp_model.FEASIBLE
+
+ with (
+ patch.object(cp_model.CpSolver, "Solve", solve_feasible),
+ patch("pr_split.planner.partitioning.logger") as mock_logger,
+ ):
+ partition_diff(parsed, settings)
+
+ warning = mock_logger.warning.call_args[0][0]
+ # A timeout below 0.1s must not be rounded away to "0.0s"; the warning
+ # tells the user which limit to raise, so it has to name the real value.
+ assert "0.04s limit" in warning
+
+ def test_optimal_solution_does_not_warn(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ pytest.importorskip("ortools.sat.python.cp_model")
+ from unittest.mock import patch
+
+ parsed = parse_diff(SAMPLE_DIFF)
+ settings = _settings(monkeypatch, max_loc=10, partition_strategy=PartitionStrategy.CP_SAT)
+ with patch("pr_split.planner.partitioning.logger") as mock_logger:
+ partition_diff(parsed, settings)
+ mock_logger.warning.assert_not_called()
+
+
+def _unit(file_path: str, hunks: tuple[int, ...], loc: int, position: int) -> PartitionUnit:
+ return PartitionUnit(
+ id=f"{file_path}:{hunks[0]}-{hunks[-1]}",
+ file_path=file_path,
+ hunk_indices=hunks,
+ loc=loc,
+ position=position,
+ )
+
+
+# Greedy grouping used to produce pr-1 -> pr-5 -> pr-2 -> pr-1 for these units:
+# {f1[1,2]}, {f1[3], f2[0..2]}, {f1[0], f2[3]} invert the f1/f2 order between groups.
+CYCLE_PRONE_UNITS = [
+ _unit("lib/f0.py", (0,), 73, 0),
+ _unit("lib/f1.py", (0,), 84, 1),
+ _unit("lib/f1.py", (1, 2), 126, 2),
+ _unit("lib/f1.py", (3,), 28, 3),
+ _unit("lib/f2.py", (0, 1, 2), 121, 4),
+ _unit("lib/f2.py", (3,), 60, 5),
+ _unit("src/f3.py", (0, 1), 113, 6),
+ _unit("src/f3.py", (2, 3), 104, 7),
+]
+
+
+class TestMergeOrderIsAcyclic:
+ def test_consistent_file_order_is_acyclic(self) -> None:
+ grouped = [
+ [_unit("a.py", (0,), 10, 0), _unit("b.py", (0,), 10, 2)],
+ [_unit("a.py", (1,), 10, 1), _unit("b.py", (1,), 10, 3)],
+ ]
+ assert _merge_order_is_acyclic(grouped) is True
+
+ def test_inverted_file_order_is_cyclic(self) -> None:
+ grouped = [
+ [_unit("a.py", (0,), 10, 0), _unit("b.py", (1,), 10, 3)],
+ [_unit("a.py", (1,), 10, 1), _unit("b.py", (0,), 10, 2)],
+ ]
+ assert _merge_order_is_acyclic(grouped) is False
+
+ def test_three_group_cycle_is_detected(self) -> None:
+ grouped = [
+ [_unit("a.py", (0,), 10, 0), _unit("c.py", (1,), 10, 5)],
+ [_unit("a.py", (1,), 10, 1), _unit("b.py", (0,), 10, 2)],
+ [_unit("b.py", (1,), 10, 3), _unit("c.py", (0,), 10, 4)],
+ ]
+ assert _merge_order_is_acyclic(grouped) is False
+
+
+class TestGraphGroupingStaysAcyclic:
+ def test_greedy_grouping_never_inverts_file_order(self) -> None:
+ settings = Settings(
+ max_loc=150,
+ priority=Priority.LOGICAL,
+ partition_strategy=PartitionStrategy.GRAPH,
+ max_refinement_iterations=0,
+ )
+ grouped = _group_units_graph(CYCLE_PRONE_UNITS, settings=settings)
+ assert _merge_order_is_acyclic(grouped)
+ assert sorted(unit.id for group in grouped for unit in group) == sorted(
+ unit.id for unit in CYCLE_PRONE_UNITS
+ )
+
+ def test_min_loc_repair_refuses_cycle_creating_merge(self) -> None:
+ # g0 is undersized. Merging it into g1 exceeds max_loc; merging it into g2 would
+ # give {a[0], b[1]}, which a.py orders before g1 and b.py orders after g1 -> cycle.
+ grouped = [
+ [_unit("a.py", (0,), 10, 0)],
+ [_unit("a.py", (1,), 95, 1), _unit("b.py", (0,), 0, 2)],
+ [_unit("b.py", (1,), 80, 3)],
+ ]
+ settings = Settings(
+ min_loc=30,
+ max_loc=100,
+ priority=Priority.LOGICAL,
+ partition_strategy=PartitionStrategy.GRAPH,
+ max_refinement_iterations=0,
+ )
+ repaired = _repair_graph_min_loc(grouped, settings=settings)
+ assert repaired == grouped
+ assert _merge_order_is_acyclic(repaired)
+
+ def test_partition_diff_rejects_cyclic_backend_output(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ cyclic = [
+ [_unit("a.py", (0,), 10, 0), _unit("b.py", (1,), 10, 3)],
+ [_unit("a.py", (1,), 10, 1), _unit("b.py", (0,), 10, 2)],
+ ]
+ monkeypatch.setattr(
+ "pr_split.planner.partitioning._group_units_graph", lambda units, settings: cyclic
+ )
+ parsed_diff = parse_diff(
+ "diff --git a/a.py b/a.py\n--- a/a.py\n+++ b/a.py\n"
+ "@@ -1,1 +1,2 @@\n x\n+y\n@@ -10,1 +11,2 @@\n x\n+z\n"
+ "diff --git a/b.py b/b.py\n--- a/b.py\n+++ b/b.py\n"
+ "@@ -1,1 +1,2 @@\n x\n+y\n@@ -10,1 +11,2 @@\n x\n+z\n"
+ )
+ settings = Settings(
+ max_loc=400, partition_strategy=PartitionStrategy.GRAPH, max_refinement_iterations=0
+ )
+ with pytest.raises(PlanValidationError, match="cycle"):
+ partition_diff(parsed_diff, settings)
diff --git a/tests/test_per_group.py b/tests/test_per_group.py
new file mode 100644
index 0000000..7ca7b55
--- /dev/null
+++ b/tests/test_per_group.py
@@ -0,0 +1,157 @@
+from __future__ import annotations
+
+import subprocess
+import textwrap
+from pathlib import Path
+
+import pytest
+
+from pr_split.cli import _create_branches_and_commits
+from pr_split.constants import AssignmentType
+from pr_split.diff_ops import extract_diff, parse_diff
+from pr_split.exceptions import PRSplitError
+from pr_split.per_group import PerGroupStep, load_per_group_step
+from pr_split.schemas import Group, GroupAssignment
+
+# A croft-style gate: raise the version above the parent's, add its notes.
+BUMP = textwrap.dedent(
+ """\
+ set -e
+ old=$(git show "$PR_SPLIT_PARENT_REF:Cargo.toml" | sed -n 's/^version = "0.1.\\(.*\\)"/\\1/p')
+ new="0.1.$((old + 1))"
+ sed -i "s/^version = .*/version = \\"$new\\"/" Cargo.toml
+ mkdir -p notes
+ printf '%s\\n' "$PR_SPLIT_GROUP_TITLE" > "notes/$new.md"
+ """
+)
+
+
+def _git(cwd: Path, *args: str) -> str:
+ return subprocess.run(
+ ["git", *args], cwd=cwd, check=True, capture_output=True, text=True
+ ).stdout.strip()
+
+
+@pytest.fixture
+def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
+ for key in ("GIT_AUTHOR_NAME", "GIT_COMMITTER_NAME"):
+ monkeypatch.setenv(key, "t")
+ for key in ("GIT_AUTHOR_EMAIL", "GIT_COMMITTER_EMAIL"):
+ monkeypatch.setenv(key, "t@t")
+ for key in ("PR_SPLIT_PER_GROUP_RUN", "PR_SPLIT_PER_GROUP_COMMIT_MESSAGE"):
+ monkeypatch.delenv(key, raising=False)
+ repo = tmp_path / "repo"
+ repo.mkdir()
+ _git(repo, "init", "-q", "-b", "main")
+ (repo / "Cargo.toml").write_text('[package]\nversion = "0.1.959"\n')
+ (repo / "bump.sh").write_text(BUMP)
+ _git(repo, "add", ".")
+ _git(repo, "commit", "-q", "-m", "init")
+ _git(repo, "checkout", "-q", "-b", "feature")
+ for name in ("a", "b", "c"):
+ (repo / f"{name}.rs").write_text(f"fn {name}() {{}}\n")
+ _git(repo, "add", ".")
+ _git(repo, "commit", "-q", "-m", "work")
+ _git(repo, "checkout", "-q", "main")
+ monkeypatch.chdir(repo)
+ return repo
+
+
+def _groups() -> list[Group]:
+ return [
+ Group(
+ id=f"pr-{i}",
+ title=f"Add {name}",
+ description="",
+ depends_on=[f"pr-{i - 1}"] if i > 1 else [],
+ assignments=[
+ GroupAssignment(
+ file_path=f"{name}.rs",
+ assignment_type=AssignmentType.WHOLE_FILE,
+ hunk_indices=[0],
+ )
+ ],
+ )
+ for i, name in enumerate(("a", "b", "c"), start=1)
+ ]
+
+
+def _build(repo: Path) -> dict[str, str]:
+ parsed = parse_diff(extract_diff("feature", "main"))
+ merge_base = _git(repo, "merge-base", "main", "feature")
+ records = _create_branches_and_commits(
+ _groups(), parsed, "main", merge_base, "x", stacked=True
+ )
+ return {r.group_id: r.branch_name for r in records}
+
+
+def test_every_stacked_layer_gets_its_own_bump_and_notes(repo: Path) -> None:
+ (repo / ".pr-split.toml").write_text(
+ '[per_group]\nrun = "sh bump.sh"\ncommit_message = "chore: release for {title}"\n'
+ )
+ branches = _build(repo)
+
+ parent = "main"
+ for i, gid in enumerate(("pr-1", "pr-2", "pr-3"), start=960):
+ branch = branches[gid]
+ assert f'version = "0.1.{i}"' in _git(repo, "show", f"{branch}:Cargo.toml")
+ changed = set(_git(repo, "diff", "--name-only", parent, branch).splitlines())
+ # Against its own base each layer bumps the version and adds its notes.
+ assert {"Cargo.toml", f"notes/0.1.{i}.md"} <= changed
+ assert _git(repo, "log", "-1", "--format=%s", branch).startswith("chore: release for")
+ parent = branch
+ assert _git(repo, "show", f"{branches['pr-3']}:notes/0.1.962.md") == "Add c"
+
+
+def test_no_step_configured_leaves_one_commit_per_layer(repo: Path) -> None:
+ branches = _build(repo)
+ assert _git(repo, "rev-list", "--count", f"main..{branches['pr-3']}") == "3"
+
+
+def test_environment_overrides_the_file(repo: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ (repo / ".pr-split.toml").write_text('[per_group]\nrun = "false"\n')
+ monkeypatch.setenv("PR_SPLIT_PER_GROUP_RUN", "touch marker")
+ assert load_per_group_step() == PerGroupStep(run="touch marker")
+ branches = _build(repo)
+ assert "marker" in _git(repo, "ls-tree", "--name-only", branches["pr-1"]).splitlines()
+
+
+def test_failing_step_names_the_group_and_leaves_no_branch(repo: Path) -> None:
+ (repo / ".pr-split.toml").write_text('[per_group]\nrun = "echo nope >&2; exit 3"\n')
+ with pytest.raises(PRSplitError, match=r"group 'pr-1' \(exit 3\): nope"):
+ _build(repo)
+ assert "pr-split/x/pr-1" not in _git(repo, "branch", "--list")
+
+
+def test_title_is_not_spliced_into_the_command(repo: Path) -> None:
+ (repo / ".pr-split.toml").write_text(
+ '[per_group]\nrun = "printf %s \\"$PR_SPLIT_GROUP_TITLE\\" > title.txt"\n'
+ )
+ groups = _groups()
+ groups[0].title = "$(touch pwned); `touch pwned2`"
+ parsed = parse_diff(extract_diff("feature", "main"))
+ records = _create_branches_and_commits(
+ groups, parsed, "main", _git(repo, "merge-base", "main", "feature"), "x", stacked=True
+ )
+ branch = records[0].branch_name
+ assert _git(repo, "show", f"{branch}:title.txt") == groups[0].title
+ tree = _git(repo, "ls-tree", "--name-only", branch).splitlines()
+ assert "pwned" not in tree and "pwned2" not in tree
+
+
+@pytest.mark.parametrize(
+ ("content", "match"),
+ [("[per_group\n", "Cannot read"), ("per_group = 3\n", r"\[per_group\]")],
+)
+def test_bad_config_is_a_clean_error(repo: Path, content: str, match: str) -> None:
+ (repo / ".pr-split.toml").write_text(content)
+ with pytest.raises(PRSplitError, match=match):
+ load_per_group_step()
+
+
+def test_bad_commit_message_template_is_a_clean_error(repo: Path) -> None:
+ (repo / ".pr-split.toml").write_text(
+ '[per_group]\nrun = "touch x"\ncommit_message = "release {version}"\n'
+ )
+ with pytest.raises(PRSplitError, match="commit_message"):
+ _build(repo)
diff --git a/tests/test_plan_store.py b/tests/test_plan_store.py
index 98dd939..eb50dd8 100644
--- a/tests/test_plan_store.py
+++ b/tests/test_plan_store.py
@@ -7,7 +7,7 @@
from pr_split.constants import Priority
from pr_split.exceptions import PRSplitError
-from pr_split.plan_store import load_plan, plan_exists, save_plan
+from pr_split.plan_store import load_plan, plan_exists, plan_path, save_plan
from pr_split.schemas import (
BranchRecord,
GitState,
@@ -101,8 +101,289 @@ def test_saved_file_is_valid_json(
),
)
save_plan(plan_file)
- raw = json.loads((tmp_path / ".pr-split" / "plan.json").read_text())
+ raw = json.loads((tmp_path / ".pr-split" / "plans" / "dev.json").read_text())
assert "plan" in raw
assert raw["plan"]["priority"] == "logical"
assert raw["plan"]["min_loc"] == 25
assert raw["plan"]["strict_loc_bounds"] is True
+
+
+class TestPlanStoreCorruptFiles:
+ @pytest.mark.parametrize(
+ "content",
+ [
+ pytest.param("", id="empty"),
+ pytest.param("{not json", id="not-json"),
+ pytest.param('{"plan": {"dev_branch": "x"}}', id="missing-fields"),
+ pytest.param('{"plan": {"groups": []}, "git_state": {}}', id="old-schema"),
+ ],
+ )
+ def test_unreadable_plan_raises_pr_split_error(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, content: str
+ ) -> None:
+ monkeypatch.chdir(tmp_path)
+ plan_dir = tmp_path / ".pr-split"
+ plan_dir.mkdir()
+ (plan_dir / "plan.json").write_text(content)
+ with pytest.raises(PRSplitError, match="Cannot load split plan") as excinfo:
+ load_plan()
+ assert "pr-split split" in str(excinfo.value)
+
+ def test_invalid_utf8_raises_pr_split_error(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.chdir(tmp_path)
+ (tmp_path / ".pr-split").mkdir()
+ (tmp_path / ".pr-split" / "plan.json").write_bytes(b'{"plan": "\xff\xfe"}')
+ with pytest.raises(PRSplitError, match="Cannot load split plan"):
+ load_plan()
+
+ def test_read_error_raises_pr_split_error(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.chdir(tmp_path)
+ (tmp_path / ".pr-split").mkdir()
+ (tmp_path / ".pr-split" / "plan.json").mkdir()
+ with pytest.raises(PRSplitError, match="Cannot load split plan"):
+ load_plan()
+
+
+class TestPlanPathsResolveAgainstTheRepoRoot:
+ def _repo(self, tmp_path: Path) -> Path:
+ import subprocess
+
+ repo = tmp_path / "repo"
+ (repo / "src").mkdir(parents=True)
+ subprocess.run(["git", "init", "-q", "-b", "main"], cwd=repo, check=True)
+ return repo
+
+ def test_plan_saved_from_a_subdirectory_is_found_from_the_root(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ from pr_split.plan_store import plan_path
+
+ repo = self._repo(tmp_path)
+ monkeypatch.chdir(repo / "src")
+ save_plan(_make_plan_file())
+ assert (repo / ".pr-split" / "plans" / "feat%2Fbig.json").exists()
+ assert not (repo / "src" / ".pr-split").exists()
+ assert plan_path() == (repo / ".pr-split" / "plans" / "feat%2Fbig.json").resolve()
+ # rev-parse gives the git dir relative to src/; the exclude must land in repo/.git.
+ exclude = (repo / ".git" / "info" / "exclude").read_text().splitlines()
+ assert "/.pr-split/" in exclude
+
+ monkeypatch.chdir(repo)
+ assert plan_exists()
+ assert load_plan().plan.dev_branch == "feat/big"
+
+ def test_outside_a_repository_the_cwd_is_used(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ from pr_split.plan_store import plan_path
+
+ monkeypatch.chdir(tmp_path)
+ assert plan_path() == tmp_path / ".pr-split" / "plan.json"
+
+ def test_template_is_read_from_the_repo_root(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ from pr_split.cli import _pr_template_path
+
+ repo = self._repo(tmp_path)
+ (repo / ".pr-split").mkdir()
+ (repo / ".pr-split" / "template.md").write_text("# {title}")
+ monkeypatch.chdir(repo / "src")
+ assert _pr_template_path().read_text() == "# {title}"
+
+
+class TestSavePlanIsAtomic:
+ def test_no_temp_file_left_behind(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.chdir(tmp_path)
+ save_plan(_make_plan_file())
+ target = plan_path()
+ leftovers = [p.name for p in target.parent.iterdir() if p != target]
+ assert leftovers == []
+
+ def test_failed_write_preserves_the_previous_plan(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ """A crash mid-write must not destroy the only record of created PRs."""
+ monkeypatch.chdir(tmp_path)
+ save_plan(_make_plan_file())
+ before = plan_path().read_text()
+
+ def exploding_write(self: Path, *args: object, **kwargs: object) -> int:
+ # Emulate a crash/disk-full: leave a truncated temp file behind.
+ Path(str(self)).parent.mkdir(parents=True, exist_ok=True)
+ with open(self, "w") as fh:
+ fh.write('{"plan": {"dev_branch"')
+ raise OSError(28, "No space left on device")
+
+ # A nested patch context: monkeypatch.undo() would also undo chdir.
+ with pytest.MonkeyPatch.context() as mp:
+ mp.setattr(Path, "write_text", exploding_write)
+ with pytest.raises(OSError):
+ save_plan(_make_plan_file())
+
+ assert plan_path().read_text() == before
+ loaded = load_plan()
+ assert loaded.plan.dev_branch == _make_plan_file().plan.dev_branch
+
+
+class TestPerBranchPlans:
+ @pytest.fixture(autouse=True)
+ def _repo(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ import subprocess
+
+ from pr_split.plan_store import select_plan
+
+ subprocess.run(["git", "init", "-q", "-b", "main"], cwd=tmp_path, check=True)
+ monkeypatch.chdir(tmp_path)
+ select_plan(None)
+ yield
+ select_plan(None)
+
+ def test_two_splits_keep_separate_plans(self, tmp_path: Path) -> None:
+ from pr_split.plan_store import saved_plan_branches, select_plan
+
+ select_plan("feat-a")
+ save_plan(_make_plan_file())
+ select_plan("feat-b")
+ save_plan(_make_plan_file())
+
+ assert saved_plan_branches() == ["feat-a", "feat-b"]
+ select_plan(None)
+ with pytest.raises(
+ PRSplitError, match=r"Several split plans are saved \(feat-a, feat-b\)"
+ ):
+ load_plan()
+ select_plan("feat-a")
+ assert load_plan().plan.dev_branch == "feat/big"
+
+ def test_the_only_plan_is_used_without_selection(self) -> None:
+ from pr_split.plan_store import select_plan
+
+ select_plan("feat-a")
+ save_plan(_make_plan_file())
+ select_plan(None)
+ assert plan_exists()
+ assert plan_path().name == "feat-a.json"
+
+ def test_plan_dir_is_excluded_from_git_once(self, tmp_path: Path) -> None:
+ import subprocess
+
+ from pr_split.plan_store import select_plan
+
+ select_plan("feat-a")
+ save_plan(_make_plan_file())
+ save_plan(_make_plan_file())
+
+ exclude = (tmp_path / ".git" / "info" / "exclude").read_text().splitlines()
+ assert exclude.count("/.pr-split/") == 1
+ status = subprocess.run(
+ ["git", "status", "--porcelain"], cwd=tmp_path, capture_output=True, text=True
+ ).stdout
+ assert ".pr-split" not in status
+
+ def test_legacy_single_plan_file_still_loads(self, tmp_path: Path) -> None:
+ legacy = tmp_path / ".pr-split" / "plan.json"
+ legacy.parent.mkdir()
+ legacy.write_text(_make_plan_file().model_dump_json())
+ assert load_plan().plan.dev_branch == "feat/big"
+
+
+class TestLegacyPlanMigration:
+ @pytest.fixture(autouse=True)
+ def _repo(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ import subprocess
+
+ from pr_split.plan_store import select_plan
+
+ subprocess.run(["git", "init", "-q", "-b", "main"], cwd=tmp_path, check=True)
+ monkeypatch.chdir(tmp_path)
+ select_plan(None)
+ yield
+ select_plan(None)
+
+ def test_legacy_plan_moves_to_its_branch_file(self, tmp_path: Path) -> None:
+ from pr_split.plan_store import saved_plan_branches, select_plan
+
+ legacy = tmp_path / ".pr-split" / "plan.json"
+ legacy.parent.mkdir()
+ legacy.write_text(_make_plan_file().model_dump_json())
+
+ select_plan("feat/big")
+ assert load_plan().plan.dev_branch == "feat/big"
+ assert not legacy.exists()
+ assert saved_plan_branches() == ["feat/big"]
+ # A plan moved without being saved is still kept out of git.
+ exclude = (tmp_path / ".git" / "info" / "exclude").read_text().splitlines()
+ assert "/.pr-split/" in exclude
+
+ def test_a_readable_legacy_plan_never_stands_in_for_another_branch(
+ self, tmp_path: Path
+ ) -> None:
+ from pr_split.plan_store import plan_path, select_plan
+
+ select_plan("feat/big")
+ save_plan(_make_plan_file())
+ # A legacy plan of the same branch cannot move: that branch has a file.
+ legacy = tmp_path / ".pr-split" / "plan.json"
+ legacy.write_text(_make_plan_file().model_dump_json())
+
+ select_plan("feat/other")
+ assert plan_path().name == "feat%2Fother.json"
+ assert not plan_exists()
+ assert legacy.exists()
+
+ def test_an_unreadable_legacy_plan_still_reports_its_error(self, tmp_path: Path) -> None:
+ from pr_split.plan_store import select_plan
+
+ legacy = tmp_path / ".pr-split" / "plan.json"
+ legacy.parent.mkdir()
+ legacy.write_text("{not json")
+
+ select_plan("feat/big")
+ with pytest.raises(PRSplitError, match="Cannot load split plan"):
+ load_plan()
+
+ def test_branch_names_that_differ_only_in_slashes_keep_separate_plans(self) -> None:
+ from pr_split.plan_store import saved_plan_branches, select_plan
+
+ select_plan("feature/x")
+ save_plan(_make_plan_file())
+ select_plan("feature-x")
+ save_plan(_make_plan_file())
+
+ assert sorted(saved_plan_branches()) == ["feature-x", "feature/x"]
+
+
+class TestPlanStoreSurrogates:
+ def test_raw_diff_with_undecodable_bytes_round_trips(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ from pr_split.constants import Priority
+ from pr_split.plan_store import load_plan, save_plan
+ from pr_split.schemas import PlanFile, SplitPlan
+
+ monkeypatch.chdir(tmp_path)
+ raw = b"--- a/x\n+++ b/x\n@@ -1 +1 @@\n-caf\xe9\n+caf\xe9!\n".decode(
+ "utf-8", errors="surrogateescape"
+ )
+ plan = SplitPlan(
+ dev_branch="dev",
+ base_branch="main",
+ max_loc=400,
+ priority=Priority.ORTHOGONAL,
+ raw_diff=raw,
+ )
+ save_plan(PlanFile(plan=plan))
+
+ loaded = load_plan()
+
+ assert loaded.plan.raw_diff == raw
+ assert loaded.plan.raw_diff.encode("utf-8", errors="surrogateescape").endswith(
+ b"+caf\xe9!\n"
+ )
diff --git a/tests/test_reconstructor.py b/tests/test_reconstructor.py
index e9495f6..a9d2303 100644
--- a/tests/test_reconstructor.py
+++ b/tests/test_reconstructor.py
@@ -12,6 +12,7 @@
apply_hunks,
materialize_group_files,
merge_chain_assignments,
+ split_git_lines,
)
from pr_split.exceptions import GitOperationError
from pr_split.schemas import Group, GroupAssignment
@@ -114,13 +115,13 @@ def test_preserves_untouched_lines(self) -> None:
class TestGetBaseFileContent:
@patch("pr_split.diff_ops.reconstructor.subprocess.run")
def test_success(self, mock_run: MagicMock) -> None:
- mock_run.return_value = MagicMock(returncode=0, stdout="file content\n", stderr="")
+ mock_run.return_value = MagicMock(returncode=0, stdout=b"file content\n", stderr=b"")
result = _get_base_file_content("foo.py", "abc123")
assert result == "file content\n"
@patch("pr_split.diff_ops.reconstructor.subprocess.run")
def test_failure_raises(self, mock_run: MagicMock) -> None:
- mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="not found")
+ mock_run.return_value = MagicMock(returncode=1, stdout=b"", stderr=b"not found")
with pytest.raises(GitOperationError):
_get_base_file_content("missing.py", "abc123")
@@ -182,7 +183,7 @@ def test_file_not_in_diff_skipped(self) -> None:
class TestGetBaseFileContentExtended:
@patch("pr_split.diff_ops.reconstructor.subprocess.run")
def test_empty_file_returns_empty(self, mock_run: MagicMock) -> None:
- mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
+ mock_run.return_value = MagicMock(returncode=0, stdout=b"", stderr=b"")
result = _get_base_file_content("empty.py", "abc123")
assert result == ""
@@ -362,6 +363,28 @@ def test_ancestor_only_file_is_carried(self) -> None:
by_path = {a.file_path: a for a in merged.assignments}
assert by_path["parent_only.py"].hunk_indices == [0]
+ def test_stale_whole_file_index_on_ancestor_is_not_carried(self) -> None:
+ stale = Group(
+ id="pr-1",
+ title="stale",
+ description="stale",
+ assignments=[
+ GroupAssignment(
+ file_path="parent_only.py",
+ assignment_type=AssignmentType.WHOLE_FILE,
+ hunk_indices=[0, 99],
+ ),
+ ],
+ )
+ merged = merge_chain_assignments(
+ self._child(),
+ [stale],
+ hunk_counts={"parent_only.py": 1, "child.py": 1},
+ carry_ancestor_files=True,
+ )
+ by_path = {a.file_path: a for a in merged.assignments}
+ assert by_path["parent_only.py"].hunk_indices == [0]
+
class TestAddedFileLineEndings:
def test_added_file_content_is_not_double_spaced(self) -> None:
@@ -379,3 +402,218 @@ def test_added_file_content_is_not_double_spaced(self) -> None:
)
result = materialize_group_files(parsed, group, "abc123")
assert result["new_file.py"] == 'def hello():\n return "world"\n\n'
+
+
+class TestMaterializeDuplicateAssignments:
+ @patch("pr_split.diff_ops.reconstructor._get_base_file_content")
+ def test_two_assignments_for_one_file_apply_both_hunks(self, mock_base: MagicMock) -> None:
+ mock_base.return_value = _base_content()
+ parsed = parse_diff(PATCH_TEXT)
+ group = Group(
+ id="pr-1",
+ title="t",
+ description="d",
+ assignments=[
+ GroupAssignment(
+ file_path="example.py",
+ assignment_type=AssignmentType.PARTIAL_HUNKS,
+ hunk_indices=[0],
+ ),
+ GroupAssignment(
+ file_path="example.py",
+ assignment_type=AssignmentType.PARTIAL_HUNKS,
+ hunk_indices=[1],
+ ),
+ ],
+ )
+ with patch("pr_split.diff_ops.reconstructor.logger.info") as mock_log:
+ result = materialize_group_files(parsed, group, "main")
+ content = result["example.py"]
+ assert content is not None
+ assert "inserted_after_1" in content
+ assert "inserted_after_11" in content
+ assert mock_base.call_count == 1
+ assert "Materializing 1 file" in mock_log.call_args[0][0]
+
+ def test_duplicate_assignments_on_new_file_apply_both_hunks(self) -> None:
+ # Two hunks in a new file (unidiff splits them when the context gap
+ # is large enough), each claimed by a separate PARTIAL assignment.
+ parsed = parse_diff(
+ "diff --git a/n.py b/n.py\nnew file mode 100644\n--- /dev/null\n+++ b/n.py\n"
+ "@@ -0,0 +1,2 @@\n+one\n+two\n"
+ "@@ -0,0 +10,1 @@\n+ten\n"
+ )
+ assert len(parsed.patch_set[0]) == 2
+ group = Group(
+ id="pr-1",
+ title="t",
+ description="d",
+ assignments=[
+ GroupAssignment(
+ file_path="n.py",
+ assignment_type=AssignmentType.PARTIAL_HUNKS,
+ hunk_indices=[0],
+ ),
+ GroupAssignment(
+ file_path="n.py",
+ assignment_type=AssignmentType.PARTIAL_HUNKS,
+ hunk_indices=[1],
+ ),
+ ],
+ )
+ assert materialize_group_files(parsed, group, "main")["n.py"] == "one\ntwo\nten\n"
+
+
+class TestMissingTrailingNewline:
+ NO_EOL_PATCH = """\
+--- a/f.txt
++++ b/f.txt
+@@ -1,2 +1,2 @@
+ a
+-b
+\\ No newline at end of file
++c
+\\ No newline at end of file
+"""
+
+ def test_existing_file_keeps_missing_trailing_newline(self) -> None:
+ patch_file = PatchSet(self.NO_EOL_PATCH)[0]
+ assert apply_hunks("a\nb", patch_file, [0]) == "a\nc"
+
+ def test_newline_added_when_dev_branch_adds_one(self) -> None:
+ patch = """\
+--- a/f.txt
++++ b/f.txt
+@@ -1,2 +1,2 @@
+ a
+-b
+\\ No newline at end of file
++b
+"""
+ patch_file = PatchSet(patch)[0]
+ assert apply_hunks("a\nb", patch_file, [0]) == "a\nb\n"
+
+ def test_marker_after_removed_line_does_not_strip_target(self) -> None:
+ patch = """\
+--- a/f.txt
++++ b/f.txt
+@@ -1,2 +1,2 @@
+ a
++c
+-b
+\\ No newline at end of file
+"""
+ patch_file = PatchSet(patch)[0]
+ assert apply_hunks("a\nb", patch_file, [0]) == "a\nc\n"
+
+ def test_new_file_without_trailing_newline(self) -> None:
+ diff = """\
+diff --git a/n.txt b/n.txt
+new file mode 100644
+--- /dev/null
++++ b/n.txt
+@@ -0,0 +1,2 @@
++x
++y
+\\ No newline at end of file
+"""
+ parsed = parse_diff(diff)
+ group = Group(
+ id="g",
+ title="g",
+ description="g",
+ depends_on=[],
+ assignments=[
+ GroupAssignment(
+ file_path="n.txt",
+ assignment_type=AssignmentType.WHOLE_FILE,
+ hunk_indices=[0],
+ )
+ ],
+ estimated_loc=2,
+ )
+ assert materialize_group_files(parsed, group, "base")["n.txt"] == "x\ny"
+
+
+class TestSplitGitLines:
+ @pytest.mark.parametrize(
+ ("content", "expected"),
+ [
+ pytest.param("", [], id="empty"),
+ pytest.param("a\n", ["a\n"], id="one-line"),
+ pytest.param("a\nb", ["a\n", "b"], id="no-trailing-newline"),
+ pytest.param("a\x0cb\nc\n", ["a\x0cb\n", "c\n"], id="form-feed"),
+ pytest.param("a\x0bb\nc\n", ["a\x0bb\n", "c\n"], id="vertical-tab"),
+ pytest.param("a\u2028b\nc\n", ["a\u2028b\n", "c\n"], id="line-separator"),
+ pytest.param("a\x85b\n", ["a\x85b\n"], id="nel"),
+ pytest.param("a\r\nb\r\n", ["a\r\n", "b\r\n"], id="crlf"),
+ pytest.param("\n\n", ["\n", "\n"], id="blank-lines"),
+ ],
+ )
+ def test_splits_on_newline_only(self, content: str, expected: list[str]) -> None:
+ assert split_git_lines(content) == expected
+
+
+class TestApplyHunksWithSplitlinesSeparators:
+ def test_form_feed_in_earlier_line_does_not_shift_hunk(self) -> None:
+ base = "a\x0cb\n" + "".join(f"{c}\n" for c in "cdefghijkl")
+ dev = base.replace("k\n", "K\n")
+ diff = "--- a/f.txt\n+++ b/f.txt\n@@ -7,5 +7,5 @@\n h\n i\n j\n-k\n+K\n l\n"
+ pf = PatchSet(diff)[0]
+ assert apply_hunks(base, pf, [0]) == dev
+
+
+class TestCrlfPreserved:
+ @patch("pr_split.diff_ops.reconstructor.subprocess.run")
+ def test_base_content_keeps_crlf(self, mock_run: MagicMock) -> None:
+ mock_run.return_value = MagicMock(returncode=0, stdout=b"a\r\nb\r\n", stderr=b"")
+ assert _get_base_file_content("c.txt", "main") == "a\r\nb\r\n"
+
+ @patch("pr_split.diff_ops.reconstructor.subprocess.run")
+ def test_partial_change_to_crlf_file_keeps_endings(self, mock_run: MagicMock) -> None:
+ base = "".join(f"line{i}\r\n" for i in range(1, 8))
+ mock_run.return_value = MagicMock(returncode=0, stdout=base.encode(), stderr=b"")
+ diff = (
+ "diff --git a/c.txt b/c.txt\n--- a/c.txt\n+++ b/c.txt\n"
+ "@@ -1,6 +1,6 @@\n line1\r\n line2\r\n-line3\r\n+LINE3\r\n"
+ " line4\r\n line5\r\n line6\r\n"
+ )
+ parsed = parse_diff(diff)
+ group = Group(
+ id="pr-1",
+ title="t",
+ description="d",
+ assignments=[
+ GroupAssignment(
+ file_path="c.txt",
+ assignment_type=AssignmentType.WHOLE_FILE,
+ hunk_indices=[0],
+ )
+ ],
+ )
+ result = materialize_group_files(parsed, group, "main")
+ assert result["c.txt"] == base.replace("line3\r\n", "LINE3\r\n")
+
+ @patch("pr_split.diff_ops.reconstructor.subprocess.run")
+ def test_bare_carriage_return_does_not_shift_hunks(self, mock_run: MagicMock) -> None:
+ # A lone CR is not a line break for git; with base content no longer
+ # newline-translated, splitting on it would misplace every later hunk.
+ mock_run.return_value = MagicMock(returncode=0, stdout=b"x\ry\nz\n", stderr=b"")
+ diff = (
+ "diff --git a/c.txt b/c.txt\n--- a/c.txt\n+++ b/c.txt\n"
+ "@@ -1,2 +1,2 @@\n x\ry\n-z\n+Z\n"
+ )
+ parsed = parse_diff(diff)
+ group = Group(
+ id="pr-1",
+ title="t",
+ description="d",
+ assignments=[
+ GroupAssignment(
+ file_path="c.txt",
+ assignment_type=AssignmentType.WHOLE_FILE,
+ hunk_indices=[0],
+ )
+ ],
+ )
+ assert materialize_group_files(parsed, group, "main")["c.txt"] == "x\ry\nZ\n"
diff --git a/tests/test_recover.py b/tests/test_recover.py
new file mode 100644
index 0000000..b85c575
--- /dev/null
+++ b/tests/test_recover.py
@@ -0,0 +1,196 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+from typer.testing import CliRunner
+
+from pr_split.cli import app
+from pr_split.constants import PLAN_FILE, PRState
+from pr_split.exceptions import GitOperationError, PRSplitError
+from pr_split.git_ops.prs import list_prs_with_head_prefix
+from pr_split.plan_store import load_plan, save_plan
+from pr_split.recover import StackPR, rebuild_plan
+from pr_split.restack import stale_layers
+from pr_split.schemas import PlanFile
+from tests.test_stack_move import _git, stack # noqa: F401 (fixture)
+
+P = "pr-split/feature/"
+
+
+def _pr(number: int, gid: str, base: str, *, state: str = "OPEN", body: str = "") -> StackPR:
+ return StackPR.from_gh(
+ {
+ "number": number,
+ "url": f"https://github.com/o/r/pull/{number}",
+ "state": state,
+ "headRefName": P + gid,
+ "baseRefName": base,
+ "title": f"title {gid}",
+ "body": body,
+ }
+ )
+
+
+def _deps(plan_file: PlanFile) -> dict[str, list[str]]:
+ return {g.id: g.depends_on for g in plan_file.plan.groups}
+
+
+def test_stacked_layers_depend_on_the_branch_their_pr_targets() -> None:
+ prs = [_pr(1, "pr-1", "main"), _pr(2, "pr-2", P + "pr-1"), _pr(3, "pr-3", P + "pr-2")]
+ plan_file = rebuild_plan("feature", prs=prs, branches={P + "pr-1": "abc"})
+
+ assert _deps(plan_file) == {"pr-1": [], "pr-2": ["pr-1"], "pr-3": ["pr-2"]}
+ plan = plan_file.plan
+ assert (plan.base_branch, plan.stacked, plan.dev_branch) == ("main", True, "feature")
+ records = {r.group_id: r for r in plan_file.git_state.branches}
+ assert records["pr-2"].base_branch == P + "pr-1"
+ assert records["pr-1"].commit_sha == "abc"
+ assert [r.pr_number for r in plan_file.git_state.prs] == [1, 2, 3]
+
+
+def test_dependencies_come_from_the_pr_body_once_a_pr_is_retargeted() -> None:
+ # pr-1 merged and its branch was deleted, so GitHub retargeted pr-2 at main.
+ body = "Adds b.\n\n## Dependencies\n\nThis PR depends on: `pr-1`\n\n## Dependency graph"
+ prs = [_pr(1, "pr-1", "main", state="MERGED"), _pr(2, "pr-2", "main", body=body)]
+ plan_file = rebuild_plan("feature", prs=prs, branches={}, stacked=True)
+
+ assert _deps(plan_file) == {"pr-1": [], "pr-2": ["pr-1"]}
+ assert plan_file.plan.groups[1].description == "Adds b."
+ assert plan_file.git_state.prs[0].state is PRState.MERGED
+
+
+def test_a_merge_node_keeps_every_parent_from_its_body() -> None:
+ body = "Joins.\n\n## Dependencies\n\nThis PR depends on: `pr-1`, `pr-2`, `gone`"
+ prs = [_pr(1, "pr-1", "main"), _pr(2, "pr-2", "main"), _pr(3, "pr-3", "main", body=body)]
+ plan_file = rebuild_plan("feature", prs=prs, branches={})
+
+ # A group that no longer exists is dropped rather than failing the whole plan.
+ assert _deps(plan_file)["pr-3"] == ["pr-1", "pr-2"]
+ assert plan_file.plan.stacked is False
+
+
+def test_open_pr_wins_over_a_closed_one_for_the_same_branch() -> None:
+ prs = [_pr(9, "pr-1", "main", state="CLOSED"), _pr(4, "pr-1", "main")]
+ plan_file = rebuild_plan("feature", prs=prs, branches={})
+ assert [r.pr_number for r in plan_file.git_state.prs] == [4]
+
+
+def test_a_pushed_branch_without_a_pr_is_kept() -> None:
+ plan_file = rebuild_plan("feature", prs=[_pr(1, "pr-1", "main")], branches={P + "pr-2": "def"})
+ assert [g.id for g in plan_file.plan.groups] == ["pr-2", "pr-1"]
+ assert [r.group_id for r in plan_file.git_state.prs] == ["pr-1"]
+ assert plan_file.git_state.branches[0].base_branch == "main"
+
+
+def test_other_stacks_are_ignored() -> None:
+ other = StackPR.from_gh(
+ {"number": 5, "headRefName": "pr-split/feature-2/pr-1", "baseRefName": "dev"}
+ )
+ plan_file = rebuild_plan("feature", prs=[_pr(1, "pr-1", "main"), other], branches={})
+ assert [g.id for g in plan_file.plan.groups] == ["pr-1"]
+
+
+def test_ambiguous_base_asks_for_base() -> None:
+ prs = [_pr(1, "pr-1", "main"), _pr(2, "pr-2", "dev")]
+ with pytest.raises(PRSplitError, match=r"dev, main.*pass --base"):
+ rebuild_plan("feature", prs=prs, branches={})
+ assert rebuild_plan("feature", prs=prs, branches={}, base="main").plan.base_branch == "main"
+
+
+def test_nothing_found() -> None:
+ with pytest.raises(PRSplitError, match="No branch or PR under 'pr-split/feature/'"):
+ rebuild_plan("feature", prs=[], branches={"pr-split/other/pr-1": "x"})
+
+
+@patch("pr_split.git_ops.prs._run_gh")
+def test_pr_listing_keeps_only_the_stack_in_this_repository(mock_gh: MagicMock) -> None:
+ mock_gh.return_value = json.dumps(
+ [
+ {"number": 1, "headRefName": P + "pr-1", "isCrossRepository": False},
+ {"number": 2, "headRefName": "fix/x", "isCrossRepository": False},
+ {"number": 3, "headRefName": P + "pr-2", "isCrossRepository": True},
+ ]
+ )
+ assert [pr["number"] for pr in list_prs_with_head_prefix(P)] == [1]
+ args = mock_gh.call_args.args
+ # The prefix narrows the search before the limit applies.
+ assert args[args.index("--search") + 1] == f"head:{P}"
+ assert args[args.index("--state") + 1] == "all"
+
+
+def _gh_prs(plan_file: PlanFile) -> list[dict[str, object]]:
+ """The PRs GitHub would list for the fixture's stack."""
+ records = {r.group_id: r for r in plan_file.git_state.branches}
+ return [
+ {
+ "number": n,
+ "url": f"https://github.com/o/r/pull/{n}",
+ "state": "OPEN",
+ "headRefName": records[g.id].branch_name,
+ "baseRefName": records[g.id].base_branch,
+ "title": g.title,
+ "body": g.description,
+ }
+ for n, g in enumerate(plan_file.plan.groups, start=10)
+ ]
+
+
+def test_cli_rebuilds_an_executed_stack_whose_plan_is_gone(
+ stack: tuple[Path, PlanFile], # noqa: F811
+) -> None:
+ repo, original = stack
+ # The fixture split under the namespace "x"; a dev branch of that name matches it.
+ _git(repo, "branch", "x", "feature")
+ listed = _gh_prs(original)
+ # A new checkout: none of the stack's branches exist locally.
+ for record in original.git_state.branches:
+ _git(repo, "branch", "-D", record.branch_name)
+ _git(repo, "update-ref", "-d", f"refs/remotes/origin/{record.branch_name}")
+ assert not Path(PLAN_FILE).exists()
+
+ with patch("pr_split.recover.list_prs_with_head_prefix", return_value=listed):
+ result = CliRunner().invoke(app, ["recover", "x"])
+
+ assert result.exit_code == 0, result.output
+ recovered = load_plan()
+ assert _deps(recovered) == {"pr-1": [], "pr-2": ["pr-1"], "pr-3": ["pr-2"]}
+ assert recovered.plan.stacked and recovered.plan.base_branch == "main"
+ assert recovered.plan.merge_base_sha == original.plan.merge_base_sha
+ for record in recovered.git_state.branches:
+ assert record.commit_sha == _git(repo, "rev-parse", f"origin/{record.branch_name}")
+ assert [r.pr_number for r in recovered.git_state.prs] == [10, 11, 12]
+ # The recovered plan drives the stack commands again.
+ assert stale_layers(recovered) == []
+ with patch("pr_split.cli.get_pr_state", return_value={"state": "OPEN"}):
+ status = CliRunner().invoke(app, ["status"])
+ assert status.exit_code == 0, status.output
+ assert "#12" in status.output
+
+
+def test_cli_keeps_an_existing_plan_without_force(
+ stack: tuple[Path, PlanFile], # noqa: F811
+) -> None:
+ _, original = stack
+ # Plans are kept per dev branch; the plan to protect is the one for "x".
+ original.plan.dev_branch = original.plan.dev_branch_arg = "x"
+ save_plan(original)
+ listed = _gh_prs(original)
+ with patch("pr_split.recover.list_prs_with_head_prefix", return_value=listed) as gh:
+ refused = CliRunner().invoke(app, ["recover", "x"])
+ assert refused.exit_code == 1
+ assert "pass --force" in refused.output
+ gh.assert_not_called()
+ replaced = CliRunner().invoke(app, ["recover", "x", "--force"])
+ assert replaced.exit_code == 0, replaced.output
+ assert [r.pr_number for r in load_plan().git_state.prs] == [10, 11, 12]
+
+
+def test_cli_reports_a_gh_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.chdir(tmp_path)
+ with patch("pr_split.git_ops.prs._run_gh", side_effect=GitOperationError("gh: not logged in")):
+ result = CliRunner().invoke(app, ["recover", "feature"])
+ assert result.exit_code == 1
+ assert "Cannot list PRs" in result.output
diff --git a/tests/test_repair.py b/tests/test_repair.py
new file mode 100644
index 0000000..5dbac6c
--- /dev/null
+++ b/tests/test_repair.py
@@ -0,0 +1,168 @@
+from __future__ import annotations
+
+from unittest.mock import MagicMock, patch
+
+from pr_split.constants import AssignmentType, PartitionStrategy, Provider
+from pr_split.diff_ops.parser import parse_diff
+from pr_split.graph import PlanDAG
+from pr_split.planner.client import _plan_split_with_llm
+from pr_split.planner.repair import repair_plan
+from pr_split.planner.validator import validate_plan
+from pr_split.schemas import Group, GroupAssignment
+
+_DIFF = """\
+diff --git a/a.py b/a.py
+--- a/a.py
++++ b/a.py
+@@ -1,3 +1,4 @@
+ a1
++a2
+ a3
+ a4
+@@ -20,3 +21,4 @@
+ a20
++a21
+ a22
+ a23
+diff --git a/b.py b/b.py
+new file mode 100644
+--- /dev/null
++++ b/b.py
+@@ -0,0 +1,2 @@
++b1
++b2
+"""
+
+
+def _partial(path: str, *indices: int) -> GroupAssignment:
+ return GroupAssignment(
+ file_path=path, assignment_type=AssignmentType.PARTIAL_HUNKS, hunk_indices=list(indices)
+ )
+
+
+def _whole(path: str) -> GroupAssignment:
+ return GroupAssignment(
+ file_path=path, assignment_type=AssignmentType.WHOLE_FILE, hunk_indices=[0]
+ )
+
+
+def _group(gid: str, *assignments: GroupAssignment, deps: list[str] | None = None) -> Group:
+ return Group(
+ id=gid, title=gid, description="", depends_on=deps or [], assignments=list(assignments)
+ )
+
+
+def _valid(groups: list[Group]) -> None:
+ parsed = parse_diff(_DIFF)
+ validate_plan(groups, parsed, PlanDAG(groups), max_loc=400)
+
+
+def _held(groups: list[Group]) -> dict[str, list[tuple[str, int]]]:
+ counts = {"a.py": 2, "b.py": 1}
+ return {
+ g.id: sorted(
+ (a.file_path, i) for a in g.assignments for i in a.covered_indices(counts[a.file_path])
+ )
+ for g in groups
+ }
+
+
+def test_left_out_hunk_joins_the_group_holding_its_file() -> None:
+ groups = repair_plan(
+ [_group("pr-1", _partial("a.py", 0)), _group("pr-2", _whole("b.py"))], parse_diff(_DIFF)
+ )
+ assert _held(groups) == {"pr-1": [("a.py", 0), ("a.py", 1)], "pr-2": [("b.py", 0)]}
+ _valid(groups)
+
+
+def test_hunk_claimed_twice_stays_with_the_first_group() -> None:
+ groups = repair_plan(
+ [
+ _group("pr-1", _whole("a.py")),
+ _group("pr-2", _partial("a.py", 1), _whole("b.py")),
+ ],
+ parse_diff(_DIFF),
+ )
+ assert _held(groups) == {"pr-1": [("a.py", 0), ("a.py", 1)], "pr-2": [("b.py", 0)]}
+ _valid(groups)
+
+
+def test_whole_file_overlapping_a_partial_claim_keeps_the_rest() -> None:
+ groups = repair_plan(
+ [_group("pr-1", _partial("a.py", 0), _whole("b.py")), _group("pr-2", _whole("a.py"))],
+ parse_diff(_DIFF),
+ )
+ assert _held(groups) == {"pr-1": [("a.py", 0), ("b.py", 0)], "pr-2": [("a.py", 1)]}
+ _valid(groups)
+
+
+def test_invented_files_hunks_and_dependencies_are_dropped() -> None:
+ groups = repair_plan(
+ [
+ _group("pr-1", _whole("a.py"), _whole("ghost.py"), deps=["pr-9", "pr-1"]),
+ _group("pr-2", _partial("b.py", 0, 7), deps=["pr-1", "pr-1"]),
+ ],
+ parse_diff(_DIFF),
+ )
+ assert _held(groups) == {"pr-1": [("a.py", 0), ("a.py", 1)], "pr-2": [("b.py", 0)]}
+ assert groups[0].depends_on == []
+ assert groups[1].depends_on == ["pr-1"]
+ _valid(groups)
+
+
+def test_emptied_group_is_dropped_and_its_order_kept() -> None:
+ groups = repair_plan(
+ [
+ _group("pr-1", _whole("a.py")),
+ _group("pr-2", _partial("a.py", 0), deps=["pr-1"]),
+ _group("pr-3", _whole("b.py"), deps=["pr-2"]),
+ ],
+ parse_diff(_DIFF),
+ )
+ assert [g.id for g in groups] == ["pr-1", "pr-3"]
+ assert groups[1].depends_on == ["pr-1"]
+ _valid(groups)
+
+
+def test_estimated_loc_matches_the_repaired_plan() -> None:
+ groups = repair_plan([_group("pr-1", _partial("a.py", 0))], parse_diff(_DIFF))
+ assert sum(g.estimated_loc for g in groups) == parse_diff(_DIFF).stats["total_loc"]
+
+
+@patch("pr_split.planner.client._call_llm")
+@patch("pr_split.planner.client._count_tokens", return_value=1)
+def test_single_shot_plan_that_skipped_a_hunk_now_validates(
+ mock_count: MagicMock, mock_call: MagicMock
+) -> None:
+ from tests.test_client import _make_settings
+
+ mock_call.return_value = {
+ "groups": [
+ {
+ "id": "pr-1",
+ "title": "a",
+ "description": "",
+ "depends_on": [],
+ "assignments": [
+ {"file_path": "a.py", "assignment_type": "partial_hunks", "hunk_indices": [0]}
+ ],
+ "estimated_loc": 1,
+ },
+ {
+ "id": "pr-2",
+ "title": "b",
+ "description": "",
+ "depends_on": ["pr-7"],
+ "assignments": [
+ {"file_path": "b.py", "assignment_type": "whole_file", "hunk_indices": [0]}
+ ],
+ "estimated_loc": 2,
+ },
+ ]
+ }
+ settings = _make_settings(Provider.OPENAI, partition_strategy=PartitionStrategy.LLM)
+
+ groups = _plan_split_with_llm(parse_diff(_DIFF), settings)
+
+ _valid(groups)
+ assert groups[1].depends_on == []
diff --git a/tests/test_restack.py b/tests/test_restack.py
new file mode 100644
index 0000000..ef12bef
--- /dev/null
+++ b/tests/test_restack.py
@@ -0,0 +1,369 @@
+from __future__ import annotations
+
+import subprocess
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+from typer.testing import CliRunner
+
+from pr_split.cli import app
+from pr_split.exceptions import PRSplitError
+from pr_split.restack import restack, stale_layers
+from pr_split.schemas import BranchRecord, GitState, Group, PlanFile, SplitPlan
+
+LAYERS = ["pr-1", "pr-2", "pr-3"]
+
+
+def _git(cwd: Path, *args: str) -> str:
+ return subprocess.run(
+ ["git", *args], cwd=cwd, check=True, capture_output=True, text=True
+ ).stdout.strip()
+
+
+def _commit(repo: Path, path: str, text: str, message: str) -> None:
+ (repo / path).write_text(text)
+ _git(repo, "add", path)
+ _git(repo, "commit", "-q", "-m", message)
+
+
+def _branch(gid: str) -> str:
+ return f"pr-split/x/{gid}"
+
+
+@pytest.fixture
+def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
+ """A pushed stack main <- pr-1 <- pr-2 <- pr-3, each layer adding its own file."""
+ for key in ("GIT_AUTHOR_NAME", "GIT_COMMITTER_NAME"):
+ monkeypatch.setenv(key, "t")
+ for key in ("GIT_AUTHOR_EMAIL", "GIT_COMMITTER_EMAIL"):
+ monkeypatch.setenv(key, "t@t")
+ origin = tmp_path / "origin.git"
+ _git(tmp_path, "init", "-q", "--bare", "-b", "main", str(origin))
+ repo = tmp_path / "repo"
+ _git(tmp_path, "clone", "-q", str(origin), str(repo))
+ _commit(repo, "base.txt", "base\n", "init")
+ _git(repo, "push", "-q", "origin", "main")
+ parent = "main"
+ for gid in LAYERS:
+ _git(repo, "checkout", "-q", "-b", _branch(gid), parent)
+ _commit(repo, f"{gid}.txt", f"{gid} line 1\n{gid} line 2\n", f"{gid} work")
+ _git(repo, "push", "-q", "origin", _branch(gid))
+ parent = _branch(gid)
+ _git(repo, "checkout", "-q", "main")
+ monkeypatch.chdir(repo)
+ return repo
+
+
+def _plan_file(*, stacked: bool = True) -> PlanFile:
+ groups = [
+ Group(id=gid, title=gid, description="", depends_on=[LAYERS[i - 1]] if i else [])
+ for i, gid in enumerate(LAYERS)
+ ]
+ return PlanFile(
+ plan=SplitPlan(
+ dev_branch="feature",
+ base_branch="main",
+ max_loc=400,
+ priority="orthogonal",
+ stacked=stacked,
+ groups=groups,
+ ),
+ git_state=GitState(
+ branches=[
+ BranchRecord(
+ group_id=gid,
+ branch_name=_branch(gid),
+ base_branch=_branch(LAYERS[i - 1]) if i else "main",
+ )
+ for i, gid in enumerate(LAYERS)
+ ]
+ ),
+ )
+
+
+def _fix_layer(repo: Path, gid: str, path: str = "fix.txt", text: str = "fix\n") -> None:
+ _git(repo, "checkout", "-q", _branch(gid))
+ _commit(repo, path, text, f"review fix on {gid}")
+ _git(repo, "checkout", "-q", "main")
+
+
+def _contains(repo: Path, ancestor: str, descendant: str) -> bool:
+ return (
+ subprocess.run(
+ ["git", "merge-base", "--is-ancestor", ancestor, descendant], cwd=repo
+ ).returncode
+ == 0
+ )
+
+
+def _own_diff(repo: Path, gid: str, parent: str) -> list[str]:
+ return _git(repo, "diff", "--name-only", parent, _branch(gid)).splitlines()
+
+
+def test_fix_on_the_bottom_layer_reaches_every_layer_above(repo: Path) -> None:
+ _fix_layer(repo, "pr-1")
+ assert [s[0] for s in stale_layers(_plan_file())] == ["pr-2"]
+
+ results = restack(_plan_file())
+
+ assert [(r.group_id, r.action) for r in results] == [
+ ("pr-1", "pushed"),
+ ("pr-2", "restacked"),
+ ("pr-3", "restacked"),
+ ]
+ for lower, upper in [("pr-1", "pr-2"), ("pr-2", "pr-3")]:
+ assert _contains(repo, _branch(lower), _branch(upper))
+ # Each layer still shows only its own change against its parent.
+ assert _own_diff(repo, upper, _branch(lower)) == [f"{upper}.txt"]
+ # Each layer's work is replayed once, not duplicated.
+ assert _git(repo, "log", "--format=%s", _branch("pr-3")).splitlines() == [
+ "pr-3 work",
+ "pr-2 work",
+ "review fix on pr-1",
+ "pr-1 work",
+ "init",
+ ]
+ for gid in LAYERS:
+ assert _git(repo, "rev-parse", _branch(gid)) == _git(
+ repo, "rev-parse", f"origin/{_branch(gid)}"
+ )
+ assert stale_layers(_plan_file()) == []
+
+
+def test_fix_pushed_from_another_clone_is_fetched_first(repo: Path, tmp_path: Path) -> None:
+ other = tmp_path / "other"
+ _git(tmp_path, "clone", "-q", str(tmp_path / "origin.git"), str(other))
+ _git(other, "checkout", "-q", _branch("pr-1"))
+ _commit(other, "fix.txt", "fix\n", "fix from the web")
+ _git(other, "push", "-q", "origin", _branch("pr-1"))
+
+ restack(_plan_file())
+
+ assert _git(repo, "log", "-1", "--format=%s", _branch("pr-1")) == "fix from the web"
+ assert _contains(repo, _branch("pr-1"), _branch("pr-3"))
+ assert _contains(repo, _branch("pr-1"), f"origin/{_branch('pr-3')}")
+
+
+def test_fix_in_the_middle_only_moves_the_layers_above(repo: Path) -> None:
+ pr1_before = _git(repo, "rev-parse", _branch("pr-1"))
+ _fix_layer(repo, "pr-2")
+
+ results = restack(_plan_file())
+
+ assert {r.group_id: r.action for r in results} == {
+ "pr-2": "pushed",
+ "pr-3": "restacked",
+ }
+ assert _git(repo, "rev-parse", _branch("pr-1")) == pr1_before
+ assert _contains(repo, _branch("pr-2"), _branch("pr-3"))
+
+
+def test_conflict_stops_at_the_layer_and_leaves_it_alone(repo: Path) -> None:
+ # The fix rewrites the file pr-2 adds, so replaying pr-2 conflicts.
+ _fix_layer(repo, "pr-1", path="pr-2.txt", text="clashing\n")
+ before = {gid: _git(repo, "rev-parse", _branch(gid)) for gid in ("pr-2", "pr-3")}
+
+ with pytest.raises(PRSplitError, match=r"Rebasing 'pr-split/x/pr-2' onto"):
+ restack(_plan_file())
+
+ assert {gid: _git(repo, "rev-parse", _branch(gid)) for gid in before} == before
+ assert "pr-split-restack" not in _git(repo, "worktree", "list")
+
+
+def test_dry_run_changes_nothing(repo: Path) -> None:
+ _fix_layer(repo, "pr-1")
+ before = {gid: _git(repo, "rev-parse", _branch(gid)) for gid in LAYERS}
+
+ results = restack(_plan_file(), dry_run=True)
+
+ assert [r.action for r in results] == [
+ "would restack onto pr-split/x/pr-1",
+ "up to date",
+ ]
+ assert {gid: _git(repo, "rev-parse", _branch(gid)) for gid in LAYERS} == before
+
+
+def test_checked_out_layer_is_refused(repo: Path) -> None:
+ _fix_layer(repo, "pr-1")
+ _git(repo, "checkout", "-q", _branch("pr-2"))
+ with pytest.raises(PRSplitError, match="checked out"):
+ restack(_plan_file())
+
+
+def test_diverged_layer_is_refused(repo: Path, tmp_path: Path) -> None:
+ other = tmp_path / "other"
+ _git(tmp_path, "clone", "-q", str(tmp_path / "origin.git"), str(other))
+ _git(other, "checkout", "-q", _branch("pr-1"))
+ _commit(other, "remote.txt", "r\n", "remote fix")
+ _git(other, "push", "-q", "origin", _branch("pr-1"))
+ _fix_layer(repo, "pr-1", path="local.txt")
+
+ with pytest.raises(PRSplitError, match="both have commits"):
+ restack(_plan_file())
+
+
+def test_unstacked_plan_is_refused(repo: Path) -> None:
+ with pytest.raises(PRSplitError, match="--stack"):
+ restack(_plan_file(stacked=False))
+
+
+def test_cli_restack_and_status_warning(repo: Path) -> None:
+ _fix_layer(repo, "pr-1")
+ plan_file = _plan_file()
+ runner = CliRunner()
+ with (
+ patch("pr_split.cli.plan_exists", return_value=True),
+ patch("pr_split.cli.load_plan", return_value=plan_file),
+ patch("pr_split.cli.get_pr_state", return_value={}),
+ ):
+ status = runner.invoke(app, ["status"])
+ result = runner.invoke(app, ["restack"])
+ status_after = runner.invoke(app, ["status"])
+
+ assert "pr-split restack" in status.output
+ assert result.exit_code == 0, result.output
+ assert "restacked" in result.output
+ assert "pr-split restack" not in status_after.output
+
+
+def _advance_main(repo: Path, path: str, text: str) -> None:
+ _git(repo, "checkout", "-q", "main")
+ _commit(repo, path, text, f"main: {path}")
+ _git(repo, "push", "-q", "origin", "main")
+
+
+def test_onto_base_carries_the_whole_stack_onto_a_moved_base(repo: Path) -> None:
+ _advance_main(repo, "release.txt", "0.2.0\n")
+
+ results = restack(_plan_file(), onto_base=True)
+
+ assert [(r.group_id, r.action) for r in results] == [
+ ("pr-1", "restacked"),
+ ("pr-2", "restacked"),
+ ("pr-3", "restacked"),
+ ]
+ assert _contains(repo, "origin/main", _branch("pr-1"))
+ parent = "origin/main"
+ for gid in LAYERS:
+ assert _own_diff(repo, gid, parent) == [f"{gid}.txt"]
+ assert _git(repo, "rev-parse", _branch(gid)) == _git(
+ repo, "rev-parse", f"origin/{_branch(gid)}"
+ )
+ parent = _branch(gid)
+
+
+def test_without_onto_base_a_moved_base_is_left_alone(repo: Path) -> None:
+ _advance_main(repo, "release.txt", "0.2.0\n")
+ before = _git(repo, "rev-parse", _branch("pr-1"))
+
+ results = restack(_plan_file())
+
+ assert {r.action for r in results} == {"up to date"}
+ assert _git(repo, "rev-parse", _branch("pr-1")) == before
+
+
+def test_onto_base_conflict_names_the_bottom_layer(repo: Path) -> None:
+ # main adds the file pr-1 adds, with different content.
+ _advance_main(repo, "pr-1.txt", "from main\n")
+ before = {gid: _git(repo, "rev-parse", _branch(gid)) for gid in LAYERS}
+
+ with pytest.raises(PRSplitError, match=r"Rebasing 'pr-split/x/pr-1' onto"):
+ restack(_plan_file(), onto_base=True)
+
+ assert {gid: _git(repo, "rev-parse", _branch(gid)) for gid in LAYERS} == before
+
+
+def test_onto_base_dry_run_reports_the_bottom_layer(repo: Path) -> None:
+ _advance_main(repo, "release.txt", "0.2.0\n")
+
+ results = restack(_plan_file(), onto_base=True, dry_run=True)
+
+ assert results[0].action == "would restack onto origin/main"
+
+
+def test_cli_onto_base(repo: Path) -> None:
+ _advance_main(repo, "release.txt", "0.2.0\n")
+ with (
+ patch("pr_split.cli.plan_exists", return_value=True),
+ patch("pr_split.cli.load_plan", return_value=_plan_file()),
+ ):
+ result = CliRunner().invoke(app, ["restack", "--onto-base"])
+ assert result.exit_code == 0, result.output
+ assert _contains(repo, "origin/main", f"origin/{_branch('pr-3')}")
+
+
+def _local_heads(repo: Path) -> dict[str, str]:
+ return {gid: _git(repo, "rev-parse", _branch(gid)) for gid in LAYERS}
+
+
+def test_conflict_above_a_rebased_layer_leaves_every_branch_as_found(repo: Path) -> None:
+ # main moves: pr-1 rebases cleanly, then pr-2's file clashes with main's.
+ _advance_main(repo, "pr-2.txt", "from main\n")
+ before = _local_heads(repo)
+
+ with pytest.raises(PRSplitError, match=r"Rebasing 'pr-split/x/pr-2' onto"):
+ restack(_plan_file(), onto_base=True)
+
+ assert _local_heads(repo) == before
+ # A rerun meets the same conflict, not a "diverged" refusal on pr-1.
+ with pytest.raises(PRSplitError, match=r"Rebasing 'pr-split/x/pr-2' onto"):
+ restack(_plan_file(), onto_base=True)
+
+
+def test_conflict_undoes_a_fast_forward_to_the_remote(repo: Path, tmp_path: Path) -> None:
+ other = tmp_path / "other"
+ _git(tmp_path, "clone", "-q", str(tmp_path / "origin.git"), str(other))
+ _git(other, "checkout", "-q", _branch("pr-1"))
+ _commit(other, "pr-2.txt", "clashes with pr-2\n", "fix from the web")
+ _git(other, "push", "-q", "origin", _branch("pr-1"))
+ before = _local_heads(repo)
+
+ with pytest.raises(PRSplitError, match="conflicts"):
+ restack(_plan_file())
+
+ assert _local_heads(repo) == before
+
+
+def test_failed_push_keeps_what_was_pushed_and_can_be_rerun(repo: Path) -> None:
+ from pr_split.git_ops import push_branch as real_push
+
+ _fix_layer(repo, "pr-1")
+ calls: list[str] = []
+
+ def flaky_push(branch: str) -> None:
+ calls.append(branch)
+ if len(calls) == 2:
+ raise PRSplitError("remote: fatal error in commit_refs")
+ real_push(branch)
+
+ with (
+ patch("pr_split.restack.push_branch", side_effect=flaky_push),
+ pytest.raises(PRSplitError, match="commit_refs"),
+ ):
+ restack(_plan_file())
+
+ # pr-1 reached the remote and stays there; pr-2 was put back.
+ assert _git(repo, "rev-parse", _branch("pr-1")) == _git(
+ repo, "rev-parse", f"origin/{_branch('pr-1')}"
+ )
+ assert not _contains(repo, _branch("pr-1"), _branch("pr-2"))
+
+ results = restack(_plan_file())
+
+ assert {r.group_id: r.action for r in results} == {"pr-2": "restacked", "pr-3": "restacked"}
+ assert _contains(repo, _branch("pr-1"), f"origin/{_branch('pr-3')}")
+
+
+def test_onto_base_follows_the_base_branchs_configured_remote(repo: Path) -> None:
+ origin_url = _git(repo, "remote", "get-url", "origin")
+ _git(repo, "remote", "add", "upstream", origin_url)
+ _git(repo, "config", "branch.main.remote", "upstream")
+ _git(repo, "config", "branch.main.merge", "refs/heads/main")
+ _advance_main(repo, "release.txt", "0.2.0\n")
+
+ results = restack(_plan_file(), onto_base=True, dry_run=True)
+
+ assert results[0].action == "would restack onto upstream/main"
+ restack(_plan_file(), onto_base=True)
+ assert _contains(repo, "upstream/main", _branch("pr-1"))
diff --git a/tests/test_schemas.py b/tests/test_schemas.py
index 62a87a1..4155375 100644
--- a/tests/test_schemas.py
+++ b/tests/test_schemas.py
@@ -42,6 +42,24 @@ def test_whole_file_assignment(self) -> None:
)
assert assignment.assignment_type == AssignmentType.WHOLE_FILE
+ def test_whole_file_covers_exactly_the_parsed_range(self) -> None:
+ for stored in ([], [1], [0, 1, 2], [99]):
+ assignment = GroupAssignment(
+ file_path="hello.py",
+ assignment_type=AssignmentType.WHOLE_FILE,
+ hunk_indices=stored,
+ )
+ assert assignment.covered_indices(3) == [0, 1, 2]
+ assert assignment.hunk_indices == stored
+
+ def test_partial_hunks_cover_exactly_what_they_list(self) -> None:
+ assignment = GroupAssignment(
+ file_path="hello.py",
+ assignment_type=AssignmentType.PARTIAL_HUNKS,
+ hunk_indices=[2, 0],
+ )
+ assert assignment.covered_indices(3) == [2, 0]
+
def test_partial_hunks_assignment(self) -> None:
assignment = GroupAssignment(
file_path="hello.py",
diff --git a/tests/test_score_pr.py b/tests/test_score_pr.py
new file mode 100644
index 0000000..2b4f95d
--- /dev/null
+++ b/tests/test_score_pr.py
@@ -0,0 +1,59 @@
+from __future__ import annotations
+
+import importlib.util
+import sys
+from pathlib import Path
+
+from pr_split.constants import AssignmentType, Priority
+from pr_split.plan_store import PLAN_FILE
+from pr_split.schemas import GitState, Group, GroupAssignment, PlanFile, SplitPlan
+
+SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "score_pr.py"
+
+
+def _load_script():
+ spec = importlib.util.spec_from_file_location("score_pr", SCRIPT)
+ module = importlib.util.module_from_spec(spec)
+ assert spec.loader is not None
+ sys.modules["score_pr"] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+def _plan_file() -> PlanFile:
+ group = Group(
+ id="pr-1",
+ title="t",
+ description="d",
+ assignments=[
+ GroupAssignment(
+ file_path="a.py",
+ assignment_type=AssignmentType.WHOLE_FILE,
+ hunk_indices=[0],
+ )
+ ],
+ estimated_loc=3,
+ )
+ plan = SplitPlan(
+ dev_branch="feature",
+ base_branch="main",
+ max_loc=400,
+ priority=Priority.ORTHOGONAL,
+ groups=[group, group.model_copy(update={"id": "pr-2", "depends_on": ["pr-1"]})],
+ )
+ return PlanFile(plan=plan, git_state=GitState())
+
+
+class TestLoadPlanGroups:
+ def test_reads_groups_from_saved_plan_file(self, tmp_path: Path) -> None:
+ path = tmp_path / Path(PLAN_FILE).name
+ path.write_text(_plan_file().model_dump_json())
+ groups = _load_script().load_plan_groups(str(path))
+ assert [g["id"] for g in groups] == ["pr-1", "pr-2"]
+ assert groups[0]["assignments"][0]["file_path"] == "a.py"
+ assert groups[1]["depends_on"] == ["pr-1"]
+
+ def test_accepts_bare_plan_document(self, tmp_path: Path) -> None:
+ path = tmp_path / "plan.json"
+ path.write_text(_plan_file().plan.model_dump_json())
+ assert len(_load_script().load_plan_groups(str(path))) == 2
diff --git a/tests/test_score_pr_events.py b/tests/test_score_pr_events.py
new file mode 100644
index 0000000..38b10aa
--- /dev/null
+++ b/tests/test_score_pr_events.py
@@ -0,0 +1,191 @@
+from __future__ import annotations
+
+import importlib.util
+import json
+import subprocess
+import sys
+from pathlib import Path
+from types import ModuleType
+from unittest.mock import patch
+
+import pytest
+
+SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "score_pr.py"
+
+
+def _load_script() -> ModuleType:
+ spec = importlib.util.spec_from_file_location("score_pr_events", SCRIPT)
+ assert spec is not None and spec.loader is not None
+ module = importlib.util.module_from_spec(spec)
+ sys.modules["score_pr_events"] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+def _outputs(path: Path) -> dict[str, str]:
+ return dict(line.split("=", 1) for line in path.read_text().splitlines() if line)
+
+
+class TestNonPullRequestEvents:
+ @pytest.mark.parametrize(
+ "env",
+ [
+ pytest.param({}, id="unset"),
+ pytest.param({"BASE_BRANCH": "", "HEAD_BRANCH": ""}, id="empty"),
+ pytest.param({"BASE_BRANCH": "main", "HEAD_BRANCH": ""}, id="head-missing"),
+ pytest.param({"BASE_BRANCH": "", "HEAD_BRANCH": "feature"}, id="base-missing"),
+ ],
+ )
+ def test_skips_without_running_git(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, env: dict[str, str]
+ ) -> None:
+ output_file = tmp_path / "output.txt"
+ output_file.touch()
+ monkeypatch.delenv("BASE_BRANCH", raising=False)
+ monkeypatch.delenv("HEAD_BRANCH", raising=False)
+ for key, value in env.items():
+ monkeypatch.setenv(key, value)
+ monkeypatch.setenv("GITHUB_OUTPUT", str(output_file))
+ module = _load_script()
+
+ with patch.object(module.subprocess, "run") as mock_run:
+ module.main()
+
+ mock_run.assert_not_called()
+ outputs = _outputs(output_file)
+ assert outputs == {
+ "total_loc": "0",
+ "total_groups": "1",
+ "objective": "0",
+ "should_split": "false",
+ }
+
+ def test_pull_request_event_still_fetches(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ output_file = tmp_path / "output.txt"
+ output_file.touch()
+ monkeypatch.setenv("BASE_BRANCH", "main")
+ monkeypatch.setenv("HEAD_BRANCH", "feature")
+ monkeypatch.setenv("PR_NUMBER", "7")
+ monkeypatch.setenv("GITHUB_OUTPUT", str(output_file))
+ module = _load_script()
+
+ with patch.object(module, "_run") as mock_run:
+ mock_run.return_value.stdout = "1\t0\ta.py\n"
+ module.main()
+
+ fetches = [call.args[0] for call in mock_run.call_args_list if call.args[0][1] == "fetch"]
+ assert ["git", "fetch", "origin", "main"] in fetches
+ assert ["git", "fetch", "origin", "refs/pull/7/head:pr-split/head-7"] in fetches
+ assert _outputs(output_file)["total_loc"] == "1"
+
+
+class TestOversizedVerdictDoesNotDependOnThePlanner:
+ """A PR over max-loc is flagged whatever the planner produced."""
+
+ def _run_main(
+ self,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ returncode: int,
+ plan: dict | None,
+ ) -> tuple[dict[str, str], str]:
+ output_file = tmp_path / "output.txt"
+ output_file.touch()
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("BASE_BRANCH", "main")
+ monkeypatch.setenv("HEAD_BRANCH", "feature")
+ monkeypatch.setenv("PR_NUMBER", "7")
+ monkeypatch.setenv("MAX_LOC", "400")
+ monkeypatch.setenv("GITHUB_OUTPUT", str(output_file))
+ monkeypatch.setenv("RUNNER_TEMP", str(tmp_path))
+ module = _load_script()
+
+ def fake_planner(cmd: list[str], **kwargs: object) -> object:
+ if plan is not None:
+ (tmp_path / ".pr-split").mkdir(exist_ok=True)
+ (tmp_path / ".pr-split" / "plan.json").write_text(json.dumps(plan))
+ return subprocess.CompletedProcess(cmd, returncode, "", "boom")
+
+ with (
+ patch.object(module, "_run") as mock_run,
+ patch.object(module.subprocess, "run", side_effect=fake_planner),
+ ):
+ mock_run.return_value.stdout = "600\t300\tbig.py\n"
+ module.main()
+
+ outputs = _outputs(output_file)
+ return outputs, Path(outputs["comment_path"]).read_text()
+
+ def test_planner_failure_still_flags_an_oversized_pr(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ outputs, comment = self._run_main(tmp_path, monkeypatch, returncode=1, plan=None)
+ assert outputs["should_split"] == "true"
+ assert "**900 LOC**, over the **400 LOC** limit" in comment
+ assert "pr-split exited with an error" in comment
+
+ def test_zero_group_plan_still_flags_an_oversized_pr(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ outputs, comment = self._run_main(
+ tmp_path, monkeypatch, returncode=0, plan={"plan": {"groups": []}, "git_state": {}}
+ )
+ assert outputs["should_split"] == "true"
+ assert outputs["total_groups"] == "0"
+ assert "plan has 0 group(s)" in comment
+ assert "within acceptable size limits" not in comment
+
+ def test_single_group_plan_is_below_threshold_but_still_flagged(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ group = {"id": "pr-1", "title": "t", "estimated_loc": 900, "assignments": []}
+ outputs, comment = self._run_main(
+ tmp_path, monkeypatch, returncode=0, plan={"plan": {"groups": [group]}}
+ )
+ assert outputs["should_split"] == "true"
+ # The plan exists; it is only below the threshold, so its count is kept.
+ assert outputs["total_groups"] == "1"
+ assert "plan has 1 group(s), below the 2 needed" in comment
+ assert "no split plan could be generated" not in comment
+
+ def test_a_plan_at_the_threshold_is_suggested(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ groups = [
+ {
+ "id": "pr-1",
+ "title": "feat: core",
+ "estimated_loc": 500,
+ "estimated_added": 450,
+ "estimated_removed": 50,
+ "assignments": [{"file_path": "big.py"}],
+ },
+ {
+ "id": "pr-2",
+ "title": "feat: api",
+ "estimated_loc": 400,
+ "estimated_added": 150,
+ "estimated_removed": 250,
+ "depends_on": ["pr-1"],
+ "assignments": [{"file_path": "big.py"}],
+ },
+ ]
+ outputs, comment = self._run_main(
+ tmp_path, monkeypatch, returncode=0, plan={"plan": {"groups": groups}}
+ )
+ assert outputs["should_split"] == "true"
+ assert outputs["total_groups"] == "2"
+ # 100 LOC over the limit, and big.py is split across both groups.
+ assert outputs["objective"] == str(100 * 1000 + 1 * 50 + 2)
+ assert "could be split into **2 smaller PRs**" in comment
+ assert "| pr-1 | feat: core | +450/-50 | — | `big.py` |" in comment
+ assert "| pr-2 | feat: api | +150/-250 | pr-1 | `big.py` |" in comment
+
+ def test_missing_plan_file_still_flags_an_oversized_pr(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ outputs, comment = self._run_main(tmp_path, monkeypatch, returncode=0, plan=None)
+ assert outputs["should_split"] == "true"
+ assert "wrote no plan file" in comment
diff --git a/tests/test_scoring.py b/tests/test_scoring.py
index 5ac3004..5d9a774 100644
--- a/tests/test_scoring.py
+++ b/tests/test_scoring.py
@@ -1,6 +1,9 @@
from __future__ import annotations
+import pytest
+
from pr_split.constants import AssignmentType
+from pr_split.exceptions import PlanValidationError
from pr_split.planner.scoring import score_plan
from pr_split.schemas import Group, GroupAssignment
@@ -66,3 +69,33 @@ def test_underflow_and_undersized_groups_are_counted(self) -> None:
metrics = score_plan(groups, 100, min_loc=50)
assert metrics.loc_underflow == 30
assert metrics.undersized_groups == 1
+
+
+class TestScorePlanCycles:
+ def test_cyclic_plan_raises_plan_validation_error(self) -> None:
+ groups = [
+ _group("g1", "a.py", [0], 50, depends_on=["g2"]),
+ _group("g2", "b.py", [0], 50, depends_on=["g1"]),
+ ]
+ with pytest.raises(PlanValidationError, match="cycle"):
+ score_plan(groups, 100)
+
+ def test_self_dependency_raises_plan_validation_error(self) -> None:
+ groups = [_group("g1", "a.py", [0], 50, depends_on=["g1"])]
+ with pytest.raises(PlanValidationError, match="cycle"):
+ score_plan(groups, 100)
+
+
+class TestDependencyEdgesAreDeduped:
+ def test_duplicate_dependency_counts_once(self) -> None:
+ groups = [
+ _group("pr-1", "a.py", [0], 10),
+ _group("pr-2", "b.py", [0], 10, depends_on=["pr-1", "pr-1"]),
+ ]
+ metrics = score_plan(groups, max_loc=100)
+ assert metrics.dependency_edges == 1
+ clean = [
+ _group("pr-1", "a.py", [0], 10),
+ _group("pr-2", "b.py", [0], 10, depends_on=["pr-1"]),
+ ]
+ assert metrics.objective == score_plan(clean, max_loc=100).objective
diff --git a/tests/test_stack_move.py b/tests/test_stack_move.py
new file mode 100644
index 0000000..88f8ee1
--- /dev/null
+++ b/tests/test_stack_move.py
@@ -0,0 +1,209 @@
+from __future__ import annotations
+
+import subprocess
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+from typer.testing import CliRunner
+
+from pr_split.cli import _create_branches_and_commits, app
+from pr_split.constants import AssignmentType
+from pr_split.diff_ops import extract_diff, parse_diff
+from pr_split.exceptions import PRSplitError
+from pr_split.schemas import GitState, Group, GroupAssignment, PlanFile, SplitPlan
+from pr_split.stack_move import move_hunk
+
+DOCS_BASE = "".join(f"line {i}\n" for i in range(1, 31))
+
+
+def _git(cwd: Path, *args: str) -> str:
+ return subprocess.run(
+ ["git", *args], cwd=cwd, check=True, capture_output=True, text=True
+ ).stdout.strip()
+
+
+def _write(repo: Path, path: str, text: str) -> None:
+ (repo / path).parent.mkdir(parents=True, exist_ok=True)
+ (repo / path).write_text(text)
+
+
+def _assign(path: str, *indices: int) -> GroupAssignment:
+ return GroupAssignment(
+ file_path=path, assignment_type=AssignmentType.PARTIAL_HUNKS, hunk_indices=list(indices)
+ )
+
+
+@pytest.fixture
+def stack(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, PlanFile]:
+ """An executed, pushed stack pr-1 <- pr-2 <- pr-3; pr-1 holds both docs hunks."""
+ for key in ("GIT_AUTHOR_NAME", "GIT_COMMITTER_NAME"):
+ monkeypatch.setenv(key, "t")
+ for key in ("GIT_AUTHOR_EMAIL", "GIT_COMMITTER_EMAIL"):
+ monkeypatch.setenv(key, "t@t")
+ monkeypatch.delenv("PR_SPLIT_PER_GROUP_RUN", raising=False)
+ origin = tmp_path / "origin.git"
+ _git(tmp_path, "init", "-q", "--bare", "-b", "main", str(origin))
+ repo = tmp_path / "repo"
+ _git(tmp_path, "clone", "-q", str(origin), str(repo))
+ _write(repo, "a.py", "A = 1\n")
+ _write(repo, "docs.md", DOCS_BASE)
+ _git(repo, "add", ".")
+ _git(repo, "commit", "-q", "-m", "init")
+ _git(repo, "push", "-q", "origin", "main")
+
+ _git(repo, "checkout", "-q", "-b", "feature")
+ _write(repo, "a.py", "A = 2\n")
+ _write(
+ repo,
+ "docs.md",
+ DOCS_BASE.replace("line 2\n", "intro\n").replace(
+ "line 28\n", "--new-flag enables the thing\n"
+ ),
+ )
+ _write(repo, "b.py", "B = 1\n")
+ _write(repo, "flag.py", "NEW_FLAG = '--new-flag'\n")
+ _git(repo, "add", ".")
+ _git(repo, "commit", "-q", "-m", "feature")
+ _git(repo, "checkout", "-q", "main")
+ monkeypatch.chdir(repo)
+
+ raw_diff = extract_diff("feature", "main")
+ parsed = parse_diff(raw_diff)
+ assert len(next(pf for pf in parsed.patch_set if pf.path == "docs.md")) == 2
+ groups = [
+ Group(
+ id="pr-1",
+ title="a and docs",
+ description="",
+ assignments=[_assign("a.py", 0), _assign("docs.md", 0, 1)],
+ ),
+ Group(
+ id="pr-2",
+ title="b",
+ description="",
+ depends_on=["pr-1"],
+ assignments=[_assign("b.py", 0)],
+ ),
+ Group(
+ id="pr-3",
+ title="flag",
+ description="",
+ depends_on=["pr-2"],
+ assignments=[_assign("flag.py", 0)],
+ ),
+ ]
+ merge_base = _git(repo, "merge-base", "main", "feature")
+ records = _create_branches_and_commits(groups, parsed, "main", merge_base, "x", stacked=True)
+ for record in records:
+ _git(repo, "push", "-q", "origin", record.branch_name)
+ plan_file = PlanFile(
+ plan=SplitPlan(
+ dev_branch="feature",
+ base_branch="main",
+ max_loc=400,
+ priority="orthogonal",
+ stacked=True,
+ groups=groups,
+ merge_base_sha=merge_base,
+ raw_diff=raw_diff,
+ ),
+ git_state=GitState(branches=records),
+ )
+ return repo, plan_file
+
+
+def _changed(repo: Path, parent: str, branch: str) -> str:
+ return _git(repo, "diff", parent, branch)
+
+
+def test_hunk_moves_to_the_target_layer_only(stack: tuple[Path, PlanFile]) -> None:
+ repo, plan_file = stack
+ b = {r.group_id: r.branch_name for r in plan_file.git_state.branches}
+ # A base change reaches the stack through a merge, as in the report.
+ _write(repo, "base.py", "BASE_CONSTANT = 7\n")
+ _git(repo, "add", "base.py")
+ _git(repo, "commit", "-q", "-m", "base constant")
+ _git(repo, "checkout", "-q", b["pr-1"])
+ _git(repo, "merge", "-q", "--no-edit", "main")
+ _git(repo, "checkout", "-q", "main")
+ from pr_split.restack import restack
+
+ restack(plan_file)
+
+ results = move_hunk(plan_file, "docs.md", 1, "pr-1", "pr-3")
+
+ assert {r.group_id for r in results} == {"pr-1", "pr-2", "pr-3"}
+ layer1 = _changed(repo, "main", b["pr-1"])
+ assert "+intro" in layer1 and "--new-flag" not in layer1
+ assert "--new-flag" not in _changed(repo, b["pr-1"], b["pr-2"])
+ layer3 = _changed(repo, b["pr-2"], b["pr-3"])
+ assert "+--new-flag enables the thing" in layer3
+ assert set(_git(repo, "diff", "--name-only", b["pr-2"], b["pr-3"]).split()) == {
+ "docs.md",
+ "flag.py",
+ }
+ # Nothing else changed: the top layer is the dev branch plus the base merge.
+ for path in ("a.py", "b.py", "flag.py", "docs.md"):
+ assert _git(repo, "show", f"{b['pr-3']}:{path}") == _git(repo, "show", f"feature:{path}")
+ for branch in b.values():
+ assert _git(repo, "show", f"{branch}:base.py") == "BASE_CONSTANT = 7"
+ for branch in b.values():
+ assert _git(repo, "rev-parse", branch) == _git(repo, "rev-parse", f"origin/{branch}")
+ # The plan follows the move.
+ groups = {g.id: g for g in plan_file.plan.groups}
+ assert [a.hunk_indices for a in groups["pr-1"].assignments if a.file_path == "docs.md"] == [
+ [0]
+ ]
+ assert any(
+ a.file_path == "docs.md" and a.hunk_indices == [1] for a in groups["pr-3"].assignments
+ )
+
+
+def test_moving_down_the_stack_is_refused(stack: tuple[Path, PlanFile]) -> None:
+ _, plan_file = stack
+ with pytest.raises(PRSplitError, match="only move up the stack"):
+ move_hunk(plan_file, "b.py", 0, "pr-2", "pr-1")
+
+
+def test_hunk_the_source_does_not_hold_is_refused(stack: tuple[Path, PlanFile]) -> None:
+ _, plan_file = stack
+ with pytest.raises(PRSplitError, match=r"does not hold hunk b\.py:0"):
+ move_hunk(plan_file, "b.py", 0, "pr-1", "pr-3")
+
+
+def test_unknown_hunk_is_refused(stack: tuple[Path, PlanFile]) -> None:
+ _, plan_file = stack
+ with pytest.raises(PRSplitError, match=r"no hunk docs\.md:9"):
+ move_hunk(plan_file, "docs.md", 9, "pr-1", "pr-3")
+
+
+def test_whole_new_file_moves_up(stack: tuple[Path, PlanFile]) -> None:
+ repo, plan_file = stack
+ b = {r.group_id: r.branch_name for r in plan_file.git_state.branches}
+
+ move_hunk(plan_file, "b.py", 0, "pr-2", "pr-3")
+
+ assert _git(repo, "diff", "--name-only", b["pr-1"], b["pr-2"]) == ""
+ assert "b.py" in _git(repo, "diff", "--name-only", b["pr-2"], b["pr-3"]).split()
+ assert _git(repo, "show", f"{b['pr-3']}:b.py") == "B = 1"
+
+
+def test_cli_move_saves_the_plan(stack: tuple[Path, PlanFile]) -> None:
+ _, plan_file = stack
+ with (
+ patch("pr_split.cli.plan_exists", return_value=True),
+ patch("pr_split.cli.load_plan", return_value=plan_file),
+ patch("pr_split.cli.save_plan") as save,
+ ):
+ result = CliRunner().invoke(app, ["move", "docs.md:1", "--from", "pr-1", "--to", "pr-3"])
+
+ assert result.exit_code == 0, result.output
+ save.assert_called_once_with(plan_file)
+ assert "restacked" in result.output
+
+
+def test_cli_rejects_a_malformed_hunk() -> None:
+ result = CliRunner().invoke(app, ["move", "docs.md", "--from", "pr-1", "--to", "pr-3"])
+ assert result.exit_code == 1
+ assert "Expected :" in result.output
diff --git a/tests/test_symbols.py b/tests/test_symbols.py
new file mode 100644
index 0000000..ab4bd1c
--- /dev/null
+++ b/tests/test_symbols.py
@@ -0,0 +1,189 @@
+from __future__ import annotations
+
+from unittest.mock import patch
+
+from pr_split.constants import AssignmentType
+from pr_split.diff_ops.parser import parse_diff
+from pr_split.graph import PlanDAG
+from pr_split.planner.partitioning import _add_symbol_dependencies
+from pr_split.planner.symbols import symbol_dependencies
+from pr_split.planner.validator import detect_symbol_order_violations
+from pr_split.schemas import Group, GroupAssignment
+
+# A new module and a new test file that imports it: no file in common.
+DIFF = """\
+diff --git a/pkg/context_pruning.py b/pkg/context_pruning.py
+new file mode 100644
+--- /dev/null
++++ b/pkg/context_pruning.py
+@@ -0,0 +1,6 @@
++PRUNE_LIMIT = 3
++
++
++class PruneReport:
++ def describe_prune(self) -> str:
++ return "x"
+diff --git a/tests/test_pruning.py b/tests/test_pruning.py
+new file mode 100644
+--- /dev/null
++++ b/tests/test_pruning.py
+@@ -0,0 +1,4 @@
++from pkg.context_pruning import PRUNE_LIMIT, PruneReport
++
++def test_limit():
++ assert PruneReport().describe_prune() and PRUNE_LIMIT
+"""
+
+
+def _whole(gid: str, path: str, depends_on: list[str] | None = None) -> Group:
+ return Group(
+ id=gid,
+ title=gid,
+ description="",
+ depends_on=depends_on or [],
+ assignments=[
+ GroupAssignment(
+ file_path=path, assignment_type=AssignmentType.WHOLE_FILE, hunk_indices=[0]
+ )
+ ],
+ )
+
+
+def test_test_file_depends_on_the_module_it_imports() -> None:
+ parsed = parse_diff(DIFF)
+ groups = [_whole("pr-1", "tests/test_pruning.py"), _whole("pr-2", "pkg/context_pruning.py")]
+
+ deps = symbol_dependencies(groups, parsed)
+
+ assert deps == {"pr-1": {"pr-2": {"PRUNE_LIMIT", "PruneReport", "describe_prune"}}}
+
+
+def test_graph_edges_make_the_test_build_on_the_module() -> None:
+ parsed = parse_diff(DIFF)
+ groups = [_whole("pr-1", "tests/test_pruning.py"), _whole("pr-2", "pkg/context_pruning.py")]
+
+ _add_symbol_dependencies(groups, parsed)
+
+ assert groups[0].depends_on == ["pr-2"]
+ assert groups[1].depends_on == []
+
+
+def test_edge_that_would_close_a_cycle_is_skipped() -> None:
+ parsed = parse_diff(DIFF)
+ groups = [
+ _whole("pr-1", "tests/test_pruning.py"),
+ _whole("pr-2", "pkg/context_pruning.py", depends_on=["pr-1"]),
+ ]
+
+ with patch("pr_split.planner.partitioning.logger") as mock_logger:
+ _add_symbol_dependencies(groups, parsed)
+
+ assert groups[0].depends_on == []
+ PlanDAG(groups).validate_acyclic()
+ assert "would create a cycle" in mock_logger.warning.call_args.args[0]
+
+
+def test_definition_in_a_child_of_its_user_is_reported() -> None:
+ # The #187 shape: the module is a child of the group that uses it.
+ parsed = parse_diff(DIFF)
+ groups = [
+ _whole("pr-1", "tests/test_pruning.py"),
+ _whole("pr-2", "pkg/context_pruning.py", depends_on=["pr-1"]),
+ ]
+
+ warnings = detect_symbol_order_violations(groups, parsed, PlanDAG(groups))
+
+ assert len(warnings) == 1
+ assert "Group 'pr-1' uses PRUNE_LIMIT, PruneReport, describe_prune" in warnings[0]
+ assert "defined in group 'pr-2'" in warnings[0]
+
+
+def test_correct_order_is_not_reported() -> None:
+ parsed = parse_diff(DIFF)
+ groups = [
+ _whole("pr-1", "tests/test_pruning.py", depends_on=["pr-2"]),
+ _whole("pr-2", "pkg/context_pruning.py"),
+ ]
+ assert detect_symbol_order_violations(groups, parsed, PlanDAG(groups)) == []
+
+
+def test_name_defined_by_two_groups_is_ignored() -> None:
+ diff = """\
+diff --git a/a.py b/a.py
+new file mode 100644
+--- /dev/null
++++ b/a.py
+@@ -0,0 +1,2 @@
++def helper_fn():
++ return 1
+diff --git a/b.py b/b.py
+new file mode 100644
+--- /dev/null
++++ b/b.py
+@@ -0,0 +1,2 @@
++def helper_fn():
++ return helper_fn
+"""
+ groups = [_whole("pr-1", "a.py"), _whole("pr-2", "b.py")]
+ assert symbol_dependencies(groups, parse_diff(diff)) == {}
+
+
+def test_graph_partition_links_the_test_group_to_the_module_group() -> None:
+ from pr_split.config import Settings
+ from pr_split.constants import PartitionStrategy
+ from pr_split.planner.partitioning import partition_diff
+
+ parsed = parse_diff(DIFF)
+ # 10 LOC in total; a limit of 8 forces the two files into separate groups.
+ settings = Settings(partition_strategy=PartitionStrategy.GRAPH, max_loc=8, min_loc=1)
+
+ groups = partition_diff(parsed, settings)
+
+ owner = {a.file_path: g for g in groups for a in g.assignments}
+ test_group = owner["tests/test_pruning.py"]
+ module_group = owner["pkg/context_pruning.py"]
+ assert test_group.id != module_group.id
+ assert module_group.id in PlanDAG(groups).ancestors(test_group.id)
+
+
+def test_graph_groups_a_test_file_with_the_module_it_imports() -> None:
+ from pr_split.config import Settings
+ from pr_split.constants import PartitionStrategy
+ from pr_split.planner.partitioning import partition_diff
+
+ # Different directories and file names: only the shared names relate them.
+ settings = Settings(partition_strategy=PartitionStrategy.GRAPH, max_loc=400)
+
+ groups = partition_diff(parse_diff(DIFF), settings)
+
+ assert len(groups) == 1
+ assert {a.file_path for a in groups[0].assignments} == {
+ "pkg/context_pruning.py",
+ "tests/test_pruning.py",
+ }
+ assert "review-slice" not in groups[0].title
+
+
+def test_multi_file_group_is_named_after_its_shared_directory() -> None:
+ from pr_split.planner.partitioning import PartitionUnit, _build_group_title
+
+ units = [
+ PartitionUnit(
+ id="a", file_path="pkg/edit/rename.py", hunk_indices=(0,), loc=5, position=0
+ ),
+ PartitionUnit(id="b", file_path="pkg/edit/move.py", hunk_indices=(0,), loc=9, position=1),
+ ]
+ assert _build_group_title(3, units) == "chore(split): review edit-3"
+
+
+def test_multi_file_group_without_a_shared_directory_names_its_largest_file() -> None:
+ from pr_split.planner.partitioning import PartitionUnit, _build_group_title
+
+ units = [
+ PartitionUnit(id="a", file_path="README.md", hunk_indices=(0,), loc=2, position=0),
+ PartitionUnit(
+ id="b", file_path="src/parser_core.py", hunk_indices=(0,), loc=40, position=1
+ ),
+ PartitionUnit(id="c", file_path="setup.cfg", hunk_indices=(0,), loc=1, position=2),
+ ]
+ assert _build_group_title(2, units) == "chore(split): review parser-core and 2 more-2"
diff --git a/tests/test_validator.py b/tests/test_validator.py
index 8dc4b82..eef8222 100644
--- a/tests/test_validator.py
+++ b/tests/test_validator.py
@@ -11,7 +11,9 @@
validate_coverage,
validate_loc,
validate_loc_bounds,
+ validate_no_binary_files,
validate_no_conflicts,
+ validate_plan,
)
from pr_split.schemas import Group, GroupAssignment
@@ -80,6 +82,35 @@ def test_missing_hunk_raises(self) -> None:
with pytest.raises(PlanValidationError, match="not assigned"):
validate_coverage(groups, parsed)
+ def test_out_of_range_hunk_index_raises(self) -> None:
+ parsed = parse_diff(SAMPLE_DIFF)
+ groups = [
+ _make_group("g1", [_ga("a.py", PARTIAL, [0, 7])], 3),
+ _make_group("g2", [_ga("b.py", WHOLE, [0])], 4),
+ ]
+ with pytest.raises(PlanValidationError, match=r"a\.py\[7\] assigned to group 'g1'"):
+ validate_coverage(groups, parsed)
+
+ def test_unknown_file_raises(self) -> None:
+ parsed = parse_diff(SAMPLE_DIFF)
+ groups = [
+ _make_group("g1", [_ga("a.py", WHOLE, [0]), _ga("ghost.py", PARTIAL, [0])], 3),
+ _make_group("g2", [_ga("b.py", WHOLE, [0])], 4),
+ ]
+ with pytest.raises(PlanValidationError, match=r"File 'ghost\.py' assigned to group 'g1'"):
+ validate_coverage(groups, parsed)
+
+ def test_unknown_whole_file_path_raises(self) -> None:
+ # A WHOLE_FILE assignment for a path outside the diff expands to zero
+ # hunks, so it must be rejected before expansion.
+ parsed = parse_diff(SAMPLE_DIFF)
+ groups = [
+ _make_group("g1", [_ga("a.py", WHOLE, []), _ga("ghost.py", WHOLE, [])], 3),
+ _make_group("g2", [_ga("b.py", WHOLE, [])], 4),
+ ]
+ with pytest.raises(PlanValidationError, match=r"File 'ghost\.py' assigned to group 'g1'"):
+ validate_coverage(groups, parsed)
+
def test_duplicate_assignment_raises(self) -> None:
parsed = parse_diff(SAMPLE_DIFF)
groups = [
@@ -94,6 +125,35 @@ def test_duplicate_assignment_raises(self) -> None:
validate_coverage(groups, parsed)
+BINARY_DIFF = """\
+diff --git a/img.png b/img.png
+index 1111111..2222222 100644
+Binary files a/img.png and b/img.png differ
+diff --git a/a.py b/a.py
+--- a/a.py
++++ b/a.py
+@@ -1 +1 @@
+-x
++y
+"""
+
+
+class TestValidateNoBinaryFiles:
+ def test_binary_file_raises(self) -> None:
+ parsed = parse_diff(BINARY_DIFF)
+ with pytest.raises(PlanValidationError, match=r"binary files.*img\.png"):
+ validate_no_binary_files(parsed)
+
+ def test_text_only_diff_passes(self) -> None:
+ validate_no_binary_files(parse_diff(SAMPLE_DIFF))
+
+ def test_validate_plan_rejects_binary_even_when_text_hunks_are_covered(self) -> None:
+ parsed = parse_diff(BINARY_DIFF)
+ groups = [_make_group("g1", [_ga("a.py", WHOLE, [0])], 2)]
+ with pytest.raises(PlanValidationError, match="binary files"):
+ validate_plan(groups, parsed, PlanDAG(groups), max_loc=400)
+
+
class TestValidateLoc:
def test_matching_loc_passes(self) -> None:
parsed = parse_diff(SAMPLE_DIFF)
@@ -120,7 +180,7 @@ def test_independent_disjoint_files_passes(self) -> None:
_make_group("g2", [_ga("b.py", WHOLE, [0])], 4),
]
dag = PlanDAG(groups)
- validate_no_conflicts(groups, dag)
+ validate_no_conflicts(groups, dag, {"a.py": 1, "b.py": 1})
def test_independent_overlapping_hunks_raises(self) -> None:
groups = [
@@ -129,7 +189,15 @@ def test_independent_overlapping_hunks_raises(self) -> None:
]
dag = PlanDAG(groups)
with pytest.raises(PlanValidationError, match="overlapping"):
- validate_no_conflicts(groups, dag)
+ validate_no_conflicts(groups, dag, {"a.py": 1, "b.py": 1})
+
+ def test_whole_file_with_empty_indices_conflicts_with_partial(self) -> None:
+ groups = [
+ _make_group("g1", [_ga("a.py", WHOLE, [])], 3),
+ _make_group("g2", [_ga("a.py", PARTIAL, [0])], 0),
+ ]
+ with pytest.raises(PlanValidationError, match=r"overlapping regions in 'a\.py'"):
+ validate_no_conflicts(groups, PlanDAG(groups), {"a.py": 1, "b.py": 1})
def test_dependent_groups_skip_conflict_check(self) -> None:
groups = [
@@ -142,7 +210,7 @@ def test_dependent_groups_skip_conflict_check(self) -> None:
),
]
dag = PlanDAG(groups)
- validate_no_conflicts(groups, dag)
+ validate_no_conflicts(groups, dag, {"a.py": 1, "b.py": 1})
class TestValidateLocBounds:
@@ -195,3 +263,15 @@ def test_whole_file_overlapping_same_file_partial_raises(self) -> None:
]
with pytest.raises(PlanValidationError, match="multiple groups"):
validate_coverage(groups, parsed)
+
+ def test_stale_whole_file_index_does_not_create_a_phantom_conflict(self) -> None:
+ # a.py has exactly one hunk. g1's WHOLE_FILE assignment carries a stale
+ # index 99; an independent g2 lists the same bogus index for a.py.
+ # Coverage validation rejects g2's index as an unknown hunk, so exercise
+ # the conflict check alone: 99 is not a hunk and must not be reported
+ # as a region the two groups both touch.
+ groups = [
+ _make_group("g1", [_ga("a.py", WHOLE, [0, 99])], 3),
+ _make_group("g2", [_ga("b.py", WHOLE, [0]), _ga("a.py", PARTIAL, [99])], 4),
+ ]
+ validate_no_conflicts(groups, PlanDAG(groups), {"a.py": 1, "b.py": 1})
diff --git a/uv.lock b/uv.lock
index b57e873..09d8b1c 100644
--- a/uv.lock
+++ b/uv.lock
@@ -58,15 +58,15 @@ wheels = [
[[package]]
name = "anyio"
-version = "4.12.1"
+version = "4.14.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
+ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
]
[[package]]
@@ -624,6 +624,9 @@ dependencies = [
]
[package.optional-dependencies]
+cp-sat = [
+ { name = "ortools" },
+]
dev = [
{ name = "ruff" },
]
@@ -644,6 +647,7 @@ requires-dist = [
{ name = "anthropic", specifier = ">=0.42.0" },
{ name = "loguru", specifier = ">=0.7.3" },
{ name = "openai", specifier = ">=2.24.0" },
+ { name = "ortools", marker = "extra == 'cp-sat'", specifier = ">=9.15.6755" },
{ name = "ortools", marker = "extra == 'test'", specifier = ">=9.15.6755" },
{ name = "pydantic", specifier = ">=2.10.0" },
{ name = "pydantic-settings", specifier = ">=2.7.0" },
@@ -653,7 +657,7 @@ requires-dist = [
{ name = "typer", specifier = ">=0.15.0" },
{ name = "unidiff", specifier = ">=0.7.5" },
]
-provides-extras = ["dev", "test"]
+provides-extras = ["cp-sat", "dev", "test"]
[package.metadata.requires-dev]
dev = [
@@ -779,11 +783,11 @@ wheels = [
[[package]]
name = "pygments"
-version = "2.19.2"
+version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]