Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ edit> desc pr-3 Adds the parser and its tests. # set the PR body text
edit> new pr-9 # create an empty group to move hunks into
edit> merge pr-1 pr-2 # fold pr-2 (hunks, parents, dependants) into pr-1
edit> plan # redisplay the plan table
edit> help # list the commands again
edit> done # proceed (default — just press Enter)
edit> abort # cancel
```
Expand Down Expand Up @@ -378,11 +379,23 @@ For a deeper explanation of the planning model, optimization methods, scoring, a

1. Extracts the merge-base diff between your branch and the base (same view as GitHub's PR page)
2. Sends the diff to the configured backend (LLM, graph, or CP-SAT), which groups hunks into logical sub-PRs with dependency ordering
3. Validates the plan: full coverage (every hunk assigned exactly once), no cycles, no merge conflicts between independent groups
4. Shows you the plan (table + dependency tree) with an optional interactive editor to move hunks between groups
5. Creates branches, commits, pushes, and opens GitHub PRs — materialization and push/PR creation run in parallel using git worktrees. Use `--dry-run` to save the plan for later execution with `execute`
6. For diffs exceeding the model's context window, uses the configured chunking strategy and processes chunks sequentially while carrying forward the group catalog across chunks
7. `status`, `merge`, and `clean` commands are available to track progress, merge PRs in dependency order, or clean up branches and PRs
3. Makes each sub-PR work on its own, whichever backend planned it (see [Standalone sub-PRs](#standalone-sub-prs))
4. Validates the plan: full coverage (every hunk assigned exactly once), no cycles, no merge conflicts between independent groups
5. Shows you the plan (table + dependency tree) with an optional interactive editor to move hunks between groups
6. Creates branches, commits, pushes, and opens GitHub PRs — materialization and push/PR creation run in parallel using git worktrees. Use `--dry-run` to save the plan for later execution with `execute`
7. For diffs exceeding the model's context window, uses the configured chunking strategy and processes chunks sequentially while carrying forward the group catalog across chunks
8. `status`, `merge`, and `clean` commands are available to track progress, merge PRs in dependency order, or clean up branches and PRs

### Standalone sub-PRs

A backend groups hunks by size and affinity, which can leave a sub-PR needing code another sub-PR adds; its CI would then fail on its own. After planning, pr-split finds what each group needs and fixes the plan:

- **Dependencies it uses.** A group depends on every group whose new functions, classes, constants or fields it uses. It also depends on the group holding an added import it relies on, on the code that fills a new field it reads, and on the group that adds a name its `mock.patch("pkg.mod.name")` targets.
- **Tests after the code they test.** Tests are taken out of groups of unrelated code. A test group depends on every changed module its tests import (followed through unchanged modules), on the files it loads by path, and on its `conftest.py`. Where the size limit allows, a test rejoins the group whose code it tests.
- **Test updates with the change they follow.** An edit to an existing test, a conftest change, or a new autouse fixture moves into the group whose code change it adapts to. Otherwise the old test would fail in that group's own PR.
- **Pieces that only work together.** Hunks of one function or its decorators stay in one group, as does a lockfile with its manifest (`uv.lock` and `pyproject.toml`, `package-lock.json` and `package.json`, …).

Groups that end up needing each other are combined, and each combination is logged with the reason. The combined PR may exceed `--max-loc`; with `--strict-loc-bounds` that is an error, as for any oversized group. Import analysis is for Python; for other languages tests are paired with the file they are named after. The analysis is static, so behaviour a test only exercises indirectly can still slip through: check the sub-PRs' CI.

## License

Expand Down
55 changes: 35 additions & 20 deletions pr_split/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import urllib.request
from collections.abc import Callable, Generator
from concurrent.futures import ThreadPoolExecutor, as_completed
from functools import partial
from pathlib import Path
from threading import Lock, Semaphore
from typing import Annotated
Expand Down Expand Up @@ -46,6 +47,7 @@
materialize_group_files,
merge_chain_assignments,
parse_diff,
read_file_at,
target_file_modes,
)
from .exceptions import (
Expand Down Expand Up @@ -369,12 +371,17 @@ def _report_oversized_groups(
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 ""
)
if len(single_hunk) == 1:
irreducible = (
f" {escape(single_hunk[0])} holds a single hunk and cannot be split below the limit."
)
elif single_hunk:
irreducible = (
f" {', '.join(escape(gid) for gid in single_hunk)} each hold a single hunk and"
" cannot be split below the limit."
)
else:
irreducible = ""
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}"
Expand Down Expand Up @@ -1272,25 +1279,29 @@ def _stdin_is_interactive() -> bool:
return sys.stdin.isatty()


_EDITOR_HELP = (
"\n[cyan]Interactive editor. Commands:[/cyan]\n"
" [bold]move[/bold] <file>:<hunk> <from_group> <to_group>\n"
" [bold]movefile[/bold] <file> <from_group> <to_group>\n"
" [bold]dep[/bold] <child> <parent> / [bold]undep[/bold] <child> <parent>\n"
" [bold]title[/bold] <group_id> <text> / [bold]desc[/bold] <group_id> <text>\n"
" [bold]new[/bold] <group_id> / [bold]merge[/bold] <keep_id> <absorb_id>\n"
" [bold]show[/bold] <group_id>\n"
" [bold]plan[/bold] — redisplay the plan table\n"
" [bold]help[/bold] — show these commands again\n"
" [bold]done[/bold] — proceed\n"
" [bold]abort[/bold] — cancel\n"
)


def _interactive_edit(groups: list[Group], parsed_diff: ParsedDiff) -> list[Group]:
if not _stdin_is_interactive():
# Scripts and CI (`split --dry-run < /dev/null`) have no one to answer
# the editor prompt; typer.prompt would turn the EOF into an Abort
# before the plan is ever saved. Accept the plan as-is instead.
console.print("[yellow]Non-interactive stdin; accepting the plan as-is.[/yellow]")
return groups
console.print(
"\n[cyan]Interactive editor. Commands:[/cyan]\n"
" [bold]move[/bold] <file>:<hunk> <from_group> <to_group>\n"
" [bold]movefile[/bold] <file> <from_group> <to_group>\n"
" [bold]dep[/bold] <child> <parent> / [bold]undep[/bold] <child> <parent>\n"
" [bold]title[/bold] <group_id> <text> / [bold]desc[/bold] <group_id> <text>\n"
" [bold]new[/bold] <group_id> / [bold]merge[/bold] <keep_id> <absorb_id>\n"
" [bold]show[/bold] <group_id>\n"
" [bold]plan[/bold] — redisplay the plan table\n"
" [bold]done[/bold] — proceed\n"
" [bold]abort[/bold] — cancel\n"
)
console.print(_EDITOR_HELP)
while True:
try:
cmd = typer.prompt("edit", default="done")
Expand Down Expand Up @@ -1373,9 +1384,12 @@ def _interactive_edit(groups: list[Group], parsed_diff: ParsedDiff) -> list[Grou
continue
if _merge_groups(groups, parsed_diff, parts[1], parts[2]):
recompute_estimated_loc(groups, parsed_diff)
elif action in ("help", "?"):
console.print(_EDITOR_HELP)
else:
console.print(
"[yellow]Unknown command. Type 'done' to proceed or 'abort' to cancel.[/yellow]"
"[yellow]Unknown command. Type 'help' for the commands, 'done' to proceed"
" or 'abort' to cancel.[/yellow]"
)


Expand Down Expand Up @@ -1646,7 +1660,8 @@ def split(
console.print(f"[red]{exc}[/red]")
raise typer.Exit(1) from exc
try:
groups = plan_split(parsed_diff, settings)
# Tests' full imports are read at the dev head to find what they exercise.
groups = plan_split(parsed_diff, settings, read_file=partial(read_file_at, dev_branch))
link_new_file_pieces(groups, parsed_diff)
except PRSplitError as exc:
console.print(f"[red]{exc}[/red]")
Expand Down
8 changes: 7 additions & 1 deletion pr_split/diff_ops/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
from .parser import ParsedDiff, extract_diff, parse_diff
from .reconstructor import materialize_group_files, merge_chain_assignments, target_file_modes
from .reconstructor import (
materialize_group_files,
merge_chain_assignments,
read_file_at,
target_file_modes,
)

__all__ = [
"ParsedDiff",
"extract_diff",
"materialize_group_files",
"merge_chain_assignments",
"parse_diff",
"read_file_at",
"target_file_modes",
]
8 changes: 8 additions & 0 deletions pr_split/diff_ops/reconstructor.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ def _get_base_file_content(file_path: str, ref: str) -> str:
return result.stdout.decode("utf-8", errors="surrogateescape")


def read_file_at(ref: str, file_path: str) -> str | None:
"""A file's content at ``ref``, or None if it does not exist there."""
try:
return _get_base_file_content(file_path, ref)
except GitOperationError:
return None


NO_NEWLINE_MARKER = "\\"


Expand Down
10 changes: 7 additions & 3 deletions pr_split/logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,13 @@
"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"
TEST_UPDATES_ATTACHED = (
"Moved {count} changed existing test hunk(s) into the PR whose code change they follow,"
" so that PR's own tests still pass"
)
GROUPS_COMBINED = (
"Combined {others} into {keep}: they need each other's code, so neither would work"
" on its own ({reasons})"
)
SYMBOL_ORDER_VIOLATION = (
"Group '{user}' uses {names} defined in group '{definer}', which it does not build on;"
Expand Down
29 changes: 27 additions & 2 deletions pr_split/planner/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import ipaddress
import json
import subprocess
from typing import TypedDict
from typing import TYPE_CHECKING, TypedDict
from urllib.parse import urlsplit

import anthropic
Expand Down Expand Up @@ -36,7 +36,8 @@
format_group_catalog,
recompute_estimated_loc,
)
from .partitioning import partition_diff
from .coherence import make_groups_standalone
from .partitioning import describe_files, partition_diff, retitle_groups
from .prompts import (
SPLIT_TOOL_NAME,
SPLIT_TOOL_SCHEMA,
Expand All @@ -50,6 +51,9 @@
from .scoring import score_plan
from .validator import detect_loc_bound_violations, validate_coverage, validate_no_conflicts

if TYPE_CHECKING:
from collections.abc import Callable

_ANTHROPIC_TOOL_DEF = anthropic.types.ToolParam(
name=SPLIT_TOOL_NAME,
description="Propose a plan to split the diff into groups",
Expand Down Expand Up @@ -742,7 +746,14 @@ def _single_group_plan(parsed_diff: ParsedDiff) -> list[Group]:
def plan_split(
parsed_diff: ParsedDiff,
settings: Settings,
read_file: Callable[[str], str | None] | None = None,
) -> list[Group]:
"""Plan the split, then make each group a sub-PR that works on its own.

``read_file`` returns a file as it stands at the dev branch's head (None
if absent); with it, a test's full imports are followed, not just the
lines its hunks show.
"""
if settings.partition_strategy not in (
PartitionStrategy.LLM,
PartitionStrategy.GRAPH,
Expand All @@ -760,6 +771,20 @@ def plan_split(
groups = _plan_split_with_llm(parsed_diff, settings)
else:
groups = partition_diff(parsed_diff, settings)
groups = make_groups_standalone(
groups,
parsed_diff,
read_file,
settings.max_loc,
keep_declared_deps=settings.partition_strategy is PartitionStrategy.LLM,
)
if settings.partition_strategy is not PartitionStrategy.LLM:
# Combining groups changed what some hold; name them after it again.
retitle_groups(groups, parsed_diff)
for group in groups:
group.description = describe_files(
settings.partition_strategy.value, (a.file_path for a in group.assignments)
)

metrics = score_plan(groups, settings.max_loc, settings.min_loc)
logger.info(
Expand Down
Loading
Loading