Skip to content

Commit ef2d25e

Browse files
authored
Merge branch 'main' into fix/apply-hunks-line-splitting
2 parents ab692d4 + bc6c585 commit ef2d25e

17 files changed

Lines changed: 491 additions & 30 deletions

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ uv tool install "pr-split[cp-sat]"
4545

4646
- Python 3.12+
4747
- [GitHub CLI](https://cli.github.com/) (`gh`) authenticated via `gh auth login`
48+
- [`gh-stack` extension](https://github.com/github/gh-stack) (`gh extension install github/gh-stack`) when using `--stack`
4849
- `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` environment variable set when using the `llm` partition backend
4950

5051
## Usage
@@ -85,6 +86,7 @@ pr-split split feature-branch --base main --dry-run
8586
| `--priority` | `orthogonal` | Grouping priority (`orthogonal` or `logical`) |
8687
| `--chunk-strategy` | `dynamic_programming` | Large-diff chunking strategy (`dynamic_programming` or `greedy`) |
8788
| `--partition-strategy` | `llm` | Hunk-to-PR partition backend (`llm`, `graph`, or `cp_sat`) |
89+
| `--cp-sat-timeout` | `15.0` | Maximum seconds to spend in the CP-SAT solver |
8890
| `--stack` | `false` | Stack dependent PRs: each child branches from and targets its parent's branch |
8991
| `--draft` | `false` | Open every sub-PR as a draft |
9092
| `--dry-run` | `false` | Preview plan and save to `.pr-split/plan.json` without creating branches or PRs |
@@ -97,7 +99,7 @@ pr-split split feature-branch --base main --stack
9799

98100
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.
99101

100-
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.
102+
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.
101103

102104
### Check status of an existing split
103105

@@ -195,6 +197,7 @@ Settings can be set via environment variables with the `PR_SPLIT_` prefix:
195197
| `PR_SPLIT_PRIORITY` | `orthogonal` | Default grouping priority |
196198
| `PR_SPLIT_CHUNK_STRATEGY` | `dynamic_programming` | Large-diff chunking strategy |
197199
| `PR_SPLIT_PARTITION_STRATEGY` | `llm` | Hunk-to-PR partition backend |
200+
| `PR_SPLIT_CP_SAT_TIMEOUT` | `15.0` | Maximum seconds to spend in the CP-SAT solver |
198201
| `PR_SPLIT_STACK` | `false` | Stack dependent PRs on their parent's branch |
199202
| `PR_SPLIT_DRAFT` | `false` | Open every sub-PR as a draft |
200203
| `PR_SPLIT_WEBHOOK_URL` | (none) | Webhook URL for merge notifications |

pr_split/cli.py

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,18 @@
4444
merge_chain_assignments,
4545
parse_diff,
4646
)
47-
from .exceptions import ErrorMsg, PlanValidationError, PRCreationError, PRSplitError
47+
from .exceptions import (
48+
ErrorMsg,
49+
GitOperationError,
50+
PlanValidationError,
51+
PRCreationError,
52+
PRSplitError,
53+
)
4854
from .git_ops import (
4955
add_worktree,
5056
branch_exists,
5157
check_gh_auth,
58+
check_gh_stack,
5259
commit_files_in_dir,
5360
delete_branch,
5461
derive_split_namespace,
@@ -125,7 +132,20 @@ def _add_children(parent_id: str, prefix: str) -> None:
125132
return f"## Dependency graph\n\nMerge in this order:\n\n```\n{tree_block}\n```"
126133

127134

128-
def _validate_inputs(dev_branch: str, base: str, *, dry_run: bool = False) -> None:
135+
def _require_gh_stack() -> None:
136+
try:
137+
installed = check_gh_stack()
138+
except GitOperationError as exc:
139+
console.print(f"[red]{exc}[/red]")
140+
raise typer.Exit(1) from exc
141+
if not installed:
142+
console.print(f"[red]{ErrorMsg.GH_STACK_MISSING()}[/red]")
143+
raise typer.Exit(1)
144+
145+
146+
def _validate_inputs(
147+
dev_branch: str, base: str, *, dry_run: bool = False, stacked: bool = False
148+
) -> None:
129149
if not branch_exists(dev_branch):
130150
console.print(f"[red]{ErrorMsg.BRANCH_NOT_FOUND(branch=dev_branch)}[/red]")
131151
raise typer.Exit(1)
@@ -138,6 +158,8 @@ def _validate_inputs(dev_branch: str, base: str, *, dry_run: bool = False) -> No
138158
if not dry_run and not check_gh_auth():
139159
console.print(f"[red]{ErrorMsg.GH_AUTH_FAILED()}[/red]")
140160
raise typer.Exit(1)
161+
if not dry_run and stacked:
162+
_require_gh_stack()
141163

142164

143165
def _handle_loc_bound_warnings(warnings: list[str], *, strict_loc_bounds: bool) -> None:
@@ -691,15 +713,33 @@ def split(
691713
help="Maximum LLM refinement iterations to fix LOC bound violations (0 = disabled)",
692714
),
693715
] = DEFAULT_MAX_REFINEMENT_ITERATIONS,
694-
priority: Annotated[Priority, typer.Option(help="Grouping priority")] = Priority.ORTHOGONAL,
716+
priority: Annotated[
717+
Priority,
718+
typer.Option("--priority", envvar="PR_SPLIT_PRIORITY", help="Grouping priority"),
719+
] = Priority.ORTHOGONAL,
695720
chunk_strategy: Annotated[
696-
ChunkStrategy, typer.Option(help="Chunking strategy for large diffs")
721+
ChunkStrategy,
722+
typer.Option(
723+
"--chunk-strategy",
724+
envvar="PR_SPLIT_CHUNK_STRATEGY",
725+
help="Chunking strategy for large diffs",
726+
),
697727
] = DEFAULT_CHUNK_STRATEGY,
698728
partition_strategy: Annotated[
699-
PartitionStrategy, typer.Option(help="Backend for hunk-to-PR partitioning")
729+
PartitionStrategy,
730+
typer.Option(
731+
"--partition-strategy",
732+
envvar="PR_SPLIT_PARTITION_STRATEGY",
733+
help="Backend for hunk-to-PR partitioning",
734+
),
700735
] = DEFAULT_PARTITION_STRATEGY,
701736
cp_sat_timeout: Annotated[
702-
float, typer.Option(help="Maximum seconds to spend in the CP-SAT solver")
737+
float,
738+
typer.Option(
739+
"--cp-sat-timeout",
740+
envvar="PR_SPLIT_CP_SAT_TIMEOUT",
741+
help="Maximum seconds to spend in the CP-SAT solver",
742+
),
703743
] = DEFAULT_CP_SAT_TIMEOUT_SECONDS,
704744
stack: Annotated[
705745
bool,
@@ -740,7 +780,7 @@ def split(
740780
base = fork_info["base_branch"]
741781
author = fork_info["author"]
742782

743-
_validate_inputs(dev_branch, base, dry_run=dry_run)
783+
_validate_inputs(dev_branch, base, dry_run=dry_run, stacked=stack)
744784

745785
if plan_exists():
746786
existing = load_plan()
@@ -860,15 +900,14 @@ def split(
860900
)
861901
)
862902
raise
863-
if stack:
864-
_link_stacks(dag, pr_records)
865-
866903
save_plan(
867904
PlanFile(
868905
plan=split_plan,
869906
git_state=GitState(branches=branch_records, prs=pr_records),
870907
)
871908
)
909+
if stack:
910+
_link_stacks(dag, pr_records)
872911
logger.success(f"Split complete: {len(groups)} PRs created")
873912

874913

@@ -1019,10 +1058,16 @@ def execute(
10191058
if not check_gh_auth():
10201059
console.print(f"[red]{ErrorMsg.GH_AUTH_FAILED()}[/red]")
10211060
raise typer.Exit(1)
1061+
if plan.stacked:
1062+
_require_gh_stack()
10221063

10231064
parsed_diff = parse_diff(plan.raw_diff)
10241065

10251066
try:
1067+
# Building the DAG rejects unknown dependency ids; do it here so a
1068+
# malformed saved plan fails before any branch is created.
1069+
dag = PlanDAG(plan.groups)
1070+
dag.validate_acyclic()
10261071
validate_coverage(plan.groups, parsed_diff)
10271072
except PlanValidationError as exc:
10281073
console.print(f"[red]{exc}[/red]")
@@ -1051,15 +1096,15 @@ def execute(
10511096
)
10521097
)
10531098
raise
1054-
if plan.stacked:
1055-
_link_stacks(PlanDAG(plan.groups), pr_records)
1056-
10571099
save_plan(
10581100
PlanFile(
10591101
plan=plan,
10601102
git_state=GitState(branches=branch_records, prs=pr_records),
10611103
)
10621104
)
1105+
if plan.stacked:
1106+
_link_stacks(PlanDAG(plan.groups), pr_records)
1107+
logger.success(f"Execute complete: {len(plan.groups)} PRs created from saved plan")
10631108
logger.success(f"Execute complete: {len(plan.groups)} PRs created from saved plan")
10641109

10651110

@@ -1134,7 +1179,14 @@ def merge_all(
11341179
console.print("[yellow]No PRs found in plan. Nothing to merge.[/yellow]")
11351180
raise typer.Exit(0)
11361181

1137-
dag = PlanDAG(plan.groups)
1182+
# A hand-edited plan.json can carry unknown or cyclic dependencies;
1183+
# report that instead of a traceback from the DAG walk.
1184+
try:
1185+
dag = PlanDAG(plan.groups)
1186+
dag.validate_acyclic()
1187+
except PlanValidationError as exc:
1188+
console.print(f"[red]{exc}[/red]")
1189+
raise typer.Exit(1) from exc
11381190
merged: list[str] = []
11391191
skipped: list[str] = []
11401192
skipped_ids: set[str] = set()

pr_split/exceptions.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ class ErrorMsg(StrEnum):
1414
CYCLE_DETECTED = "Dependency cycle detected in split plan"
1515
COVERAGE_GAP = "Hunk {file}[{index}] not assigned to any group"
1616
COVERAGE_OVERLAP = "Hunk {file}[{index}] assigned to multiple groups: {groups}"
17+
UNKNOWN_HUNK = "Hunk {file}[{index}] assigned to group '{group}' does not exist in the diff"
18+
UNKNOWN_FILE = "File '{file}' assigned to group '{group}' does not exist in the diff"
19+
UNKNOWN_DEPENDENCY = "Group '{group}' depends on unknown group '{dep}'"
20+
DUPLICATE_GROUP_ID = "Group id '{group}' is used more than once"
1721
LOC_MISMATCH = "Total LOC {actual} does not match diff LOC {expected}"
1822
MERGE_CONFLICT = "Groups '{a}' and '{b}' modify overlapping regions in '{file}'"
1923
NO_PLAN = "No split plan found; run 'pr-split split' first"
@@ -27,6 +31,11 @@ class ErrorMsg(StrEnum):
2731
HUNK_TOO_LARGE = "Hunk {file}[{index}] has ~{tokens} estimated tokens, exceeds budget {budget}"
2832
MIN_LOC_GE_MAX_LOC = "min_loc {min_loc} must be less than max_loc {max_loc}"
2933
LOC_BOUNDS_STRICT_FAILED = "Plan violates configured LOC bounds"
34+
GH_STACK_MISSING = (
35+
"The gh-stack extension is required for stacked PRs;"
36+
" run 'gh extension install github/gh-stack'"
37+
)
38+
STACK_LINK_FAILED = "Failed to link stack for PRs {prs}: {detail}"
3039

3140
def __call__(self, **kwargs: object) -> str:
3241
return self.value.format(**kwargs) if kwargs else self.value

pr_split/git_ops/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@
3737
from .prs import (
3838
check_gh_auth as check_gh_auth,
3939
)
40+
from .prs import (
41+
check_gh_stack as check_gh_stack,
42+
)
4043
from .prs import (
4144
close_pr as close_pr,
4245
)

pr_split/git_ops/prs.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,20 @@ def check_gh_auth() -> bool:
3030
return True
3131

3232

33+
GH_STACK_EXTENSION = "github/gh-stack"
34+
35+
36+
def check_gh_stack() -> bool:
37+
"""Return whether the gh-stack extension is installed.
38+
39+
Raises GitOperationError if ``gh extension list`` itself fails, so an
40+
operational problem (gh missing, auth rejected) is not mistaken for a
41+
missing extension.
42+
"""
43+
installed = _run_gh("extension", "list")
44+
return any(GH_STACK_EXTENSION in line.split() for line in installed.splitlines())
45+
46+
3347
def create_pr(
3448
head: str, base: str, title: str, body: str, *, draft: bool = False
3549
) -> tuple[int, str]:
@@ -83,8 +97,7 @@ def link_stack(pr_numbers: list[int]) -> None:
8397
try:
8498
_run_gh("stack", "link", *[str(n) for n in pr_numbers])
8599
except GitOperationError as exc:
86-
logger.warning(logs.STACK_LINK_FAILED.format(prs=pr_numbers, detail=exc))
87-
return
100+
raise GitOperationError(ErrorMsg.STACK_LINK_FAILED(prs=pr_numbers, detail=exc)) from exc
88101
logger.info(logs.STACK_LINKED.format(prs=pr_numbers))
89102

90103

pr_split/graph.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,24 @@
1010

1111
class PlanDAG:
1212
def __init__(self, groups: list[Group]) -> None:
13-
self._groups: dict[str, Group] = {g.id: g for g in groups}
13+
self._groups: dict[str, Group] = {}
14+
for g in groups:
15+
# Silently collapsing duplicates would drop a group and later
16+
# surface as a bogus merge conflict between a group and itself.
17+
if g.id in self._groups:
18+
raise PlanValidationError(ErrorMsg.DUPLICATE_GROUP_ID(group=g.id))
19+
self._groups[g.id] = g
1420
self._children: dict[str, list[str]] = {g.id: [] for g in groups}
15-
self._parents: dict[str, list[str]] = {g.id: list(g.depends_on) for g in groups}
21+
# A dependency listed twice ("pr-1", "pr-1") is one edge: keeping the
22+
# duplicate would make the group look like a merge node, so a stacked
23+
# child would be built from the merge base and left out of the stack.
24+
self._parents: dict[str, list[str]] = {
25+
g.id: list(dict.fromkeys(g.depends_on)) for g in groups
26+
}
1627
for g in groups:
17-
for dep in g.depends_on:
28+
for dep in self._parents[g.id]:
29+
if dep not in self._groups:
30+
raise PlanValidationError(ErrorMsg.UNKNOWN_DEPENDENCY(group=g.id, dep=dep))
1831
self._children[dep].append(g.id)
1932

2033
def _build_sorter(self) -> TopologicalSorter[str]:

pr_split/logs.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@
6060
"Refinement iteration limit reached ({iterations}), {remaining} violation(s) remain"
6161
)
6262
STACK_LINKED = "Linked stack for PRs {prs}"
63-
STACK_LINK_FAILED = "Could not link stack for PRs {prs}: {detail}"
6463
MERGE_NODE_NOT_STACKED = (
6564
"Group '{group}' depends on multiple groups; native stacks are linear, so its"
6665
" branch and PR target the base branch directly, carrying every ancestor's"

pr_split/planner/scoring.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ def score_plan(groups: list[Group], max_loc: int, min_loc: int | None = None) ->
3939
else 0
4040
)
4141
loc_overflow = sum(max(0, group.estimated_loc - max_loc) for group in groups)
42-
dependency_edges = sum(len(group.depends_on) for group in groups)
42+
# Count edges as the DAG sees them (a dependency listed twice is one edge).
43+
dependency_edges = sum(len(dag.parents(group.id)) for group in groups)
4344
dag_width = max((len(batch) for batch in dag.iter_ready()), default=0)
4445
dag_depth = _dag_depth(dag)
4546

pr_split/planner/validator.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ def validate_coverage(groups: list[Group], parsed_diff: ParsedDiff) -> None:
1414
assigned: dict[tuple[str, int], list[str]] = {}
1515
for group in groups:
1616
for assignment in group.assignments:
17+
if assignment.file_path not in hunk_counts:
18+
raise PlanValidationError(
19+
ErrorMsg.UNKNOWN_FILE(file=assignment.file_path, group=group.id)
20+
)
1721
# A WHOLE_FILE assignment claims every hunk of the file even when
1822
# its hunk_indices list was left empty.
1923
if assignment.assignment_type is AssignmentType.WHOLE_FILE:
@@ -26,6 +30,12 @@ def validate_coverage(groups: list[Group], parsed_diff: ParsedDiff) -> None:
2630

2731
all_hunks = {(pf.path, i) for pf in parsed_diff.patch_set for i in range(len(pf))}
2832

33+
for key, group_ids in assigned.items():
34+
if key not in all_hunks:
35+
raise PlanValidationError(
36+
ErrorMsg.UNKNOWN_HUNK(file=key[0], index=key[1], group=group_ids[0])
37+
)
38+
2939
for key in all_hunks:
3040
if key not in assigned:
3141
raise PlanValidationError(ErrorMsg.COVERAGE_GAP(file=key[0], index=key[1]))

scripts/score_pr.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@ def _md_escape(s: str) -> str:
3535
return s.replace("|", "\\|")
3636

3737

38+
def load_plan_groups(plan_path: str) -> list[dict]:
39+
"""Return the groups from a saved plan file.
40+
41+
``pr-split --dry-run`` writes a ``PlanFile`` whose top-level keys are
42+
``plan`` and ``git_state``; the groups live under ``plan``.
43+
"""
44+
with open(plan_path) as f:
45+
data = json.load(f)
46+
plan = data.get("plan", data)
47+
return plan.get("groups", [])
48+
49+
3850
def _parse_int_env(name: str, default: int) -> int:
3951
raw = os.environ.get(name, str(default))
4052
try:
@@ -119,10 +131,7 @@ def main() -> None:
119131
_skip("No plan file generated.")
120132
return
121133

122-
with open(plan_path) as f:
123-
plan = json.load(f)
124-
125-
groups = plan.get("groups", [])
134+
groups = load_plan_groups(plan_path)
126135
total_groups = len(groups)
127136

128137
max_group_loc = max((g["estimated_loc"] for g in groups), default=0)

0 commit comments

Comments
 (0)