Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@

## 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.
- 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.
- LLM Refinement Loop — enable `--max-refinement-iterations` and pr-split will automatically feed LOC violations back to the LLM until every group fits within your configured bounds.

## Why pr-split?

Expand Down Expand Up @@ -85,8 +85,20 @@ pr-split split feature-branch --base main --dry-run
| `--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 |
| `--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 |

### Stack dependent PRs

```bash
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.

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 fall back to targeting the base branch, since native stacks are strictly linear.

### Check status of an existing split

```bash
Expand Down Expand Up @@ -121,7 +133,7 @@ pr-split merge --notify https://hooks.slack.com/...
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.
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.

### Interactive plan editing

Expand Down Expand Up @@ -183,6 +195,8 @@ Settings can be set via environment variables with the `PR_SPLIT_` prefix:
| `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_DRAFT` | `false` | Open every sub-PR as a draft |
| `PR_SPLIT_WEBHOOK_URL` | (none) | Webhook URL for merge notifications |

## GitHub Action
Expand Down
194 changes: 159 additions & 35 deletions pr_split/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import tempfile
import time
import urllib.request
from collections.abc import Generator
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from threading import Lock, Semaphore
Expand Down Expand Up @@ -36,7 +37,13 @@
PartitionStrategy,
Priority,
)
from .diff_ops import ParsedDiff, extract_diff, materialize_group_files, parse_diff
from .diff_ops import (
ParsedDiff,
extract_diff,
materialize_group_files,
merge_chain_assignments,
parse_diff,
)
from .exceptions import ErrorMsg, PRSplitError
from .git_ops import (
add_worktree,
Expand All @@ -53,7 +60,7 @@
remove_worktree,
)
from .git_ops.branches import run_git
from .git_ops.prs import close_pr, create_pr, get_pr_state, merge_pr
from .git_ops.prs import close_pr, create_pr, get_pr_state, link_stack, merge_pr
from .graph import PlanDAG
from .plan_store import load_plan, plan_exists, save_plan
from .planner import plan_split, validate_plan
Expand Down Expand Up @@ -182,13 +189,14 @@ def _create_single_branch_and_commit(
worktree_base: Path,
*,
author: str | None = None,
start_point: str | None = None,
) -> BranchRecord:
branch_name = f"{BRANCH_PREFIX}{namespace}/{group.id}"
worktree_path = str(worktree_base / group.id)
commit_sha: str = ""

with _worktree_ref_lock:
add_worktree(worktree_path, branch_name, merge_base_ref)
add_worktree(worktree_path, branch_name, start_point or merge_base_ref)
try:
materialized = materialize_group_files(parsed_diff, group, merge_base_ref)
for file_path, content in materialized.items():
Expand Down Expand Up @@ -220,6 +228,34 @@ def _create_single_branch_and_commit(
)


def _stacked_batch_args(
dag: PlanDAG,
groups_by_id: dict[str, Group],
branch_names: dict[str, str],
base_branch: str,
merge_base_ref: str,
) -> 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]]])
start_point = branch_names[parents[0]]
group_base = branch_names[parents[0]]
else:
if len(parents) > 1:
logger.warning(logs.MERGE_NODE_NOT_STACKED.format(group=gid))
merged = group
start_point = merge_base_ref
group_base = base_branch
Comment thread
greptile-apps[bot] marked this conversation as resolved.
effective[gid] = merged
batch_args.append((merged, group_base, start_point))
yield batch_args


def _create_branches_and_commits(
groups: list[Group],
parsed_diff: ParsedDiff,
Expand All @@ -228,33 +264,48 @@ def _create_branches_and_commits(
namespace: str,
*,
author: str | None = None,
stacked: bool = False,
) -> list[BranchRecord]:
worktree_base = Path(tempfile.mkdtemp(prefix="pr-split-worktrees-"))

if stacked:
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}
batches = _stacked_batch_args(
dag, groups_by_id, branch_names, base_branch, merge_base_ref
)
else:
batches = iter([[(group, base_branch, merge_base_ref) for group in groups]])

try:
with ThreadPoolExecutor(max_workers=_WORKTREE_MAX_WORKERS) as executor:
future_to_group_id = {
executor.submit(
_create_single_branch_and_commit,
group,
parsed_diff,
base_branch,
merge_base_ref,
namespace,
worktree_base,
author=author,
): group.id
for group in groups
}
results: dict[str, BranchRecord] = {}
errors: list[tuple[str, Exception]] = []
for future in as_completed(future_to_group_id):
group_id = future_to_group_id[future]
try:
results[group_id] = future.result()
except Exception as exc:
logger.error(f"Failed to create branch for {group_id}: {exc}")
errors.append((group_id, exc))
results: dict[str, BranchRecord] = {}
errors: list[tuple[str, Exception]] = []
for batch_args in batches:
with ThreadPoolExecutor(max_workers=_WORKTREE_MAX_WORKERS) as executor:
future_to_group_id = {
executor.submit(
_create_single_branch_and_commit,
group,
parsed_diff,
group_base,
merge_base_ref,
namespace,
worktree_base,
author=author,
start_point=start_point,
): group.id
for group, group_base, start_point in batch_args
}
for future in as_completed(future_to_group_id):
group_id = future_to_group_id[future]
try:
results[group_id] = future.result()
except Exception as exc:
logger.error(f"Failed to create branch for {group_id}: {exc}")
errors.append((group_id, exc))
if errors:
break

if errors:
for record in results.values():
Expand Down Expand Up @@ -331,12 +382,13 @@ def _build_pr_body(group: Group, all_groups: list[Group]) -> str:
return "\n\n".join(sections)


def _push_and_create_single_pr(
def _create_single_pr(
group: Group,
record: BranchRecord,
all_groups: list[Group],
*,
draft: bool = False,
) -> PRRecord:
push_branch(record.branch_name)
logger.info(logs.CREATING_PR.format(group=group.id))
body = _build_pr_body(group, all_groups)
with _gh_semaphore:
Expand All @@ -345,6 +397,7 @@ def _push_and_create_single_pr(
base=record.base_branch,
title=group.title,
body=body,
draft=draft,
)
return PRRecord(
group_id=group.id,
Expand All @@ -356,24 +409,43 @@ def _push_and_create_single_pr(
def _push_and_create_prs(
groups: list[Group],
branch_records: list[BranchRecord],
*,
draft: bool = False,
) -> list[PRRecord]:
record_map = {r.group_id: r for r in branch_records}
errors: list[tuple[str, Exception]] = []

# Children target parent branches, so every branch is pushed before any PR opens.
with ThreadPoolExecutor(max_workers=_PUSH_MAX_WORKERS) as executor:
push_futures = {
executor.submit(push_branch, record_map[group.id].branch_name): group.id
for group in groups
}
pushed: set[str] = set()
for future in as_completed(push_futures):
group_id = push_futures[future]
try:
future.result()
pushed.add(group_id)
except Exception as exc:
logger.error(f"Failed to push branch for {group_id}: {exc}")
errors.append((group_id, exc))

with ThreadPoolExecutor(max_workers=_PUSH_MAX_WORKERS) as executor:
future_to_group_id = {
executor.submit(
_push_and_create_single_pr, group, record_map[group.id], groups
_create_single_pr, group, record_map[group.id], groups, draft=draft
): group.id
for group in groups
if group.id in pushed
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
}
results: dict[str, PRRecord] = {}
errors: list[tuple[str, Exception]] = []
for future in as_completed(future_to_group_id):
group_id = future_to_group_id[future]
try:
results[group_id] = future.result()
except Exception as exc:
logger.error(f"Failed to push/create PR for {group_id}: {exc}")
logger.error(f"Failed to create PR for {group_id}: {exc}")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
errors.append((group_id, exc))

if errors:
Expand All @@ -383,6 +455,14 @@ def _push_and_create_prs(
return [results[g.id] for g in groups]


def _link_stacks(dag: PlanDAG, pr_records: list[PRRecord]) -> None:
pr_by_group = {r.group_id: r.pr_number for r in pr_records}
for chain in dag.linear_chains():
if len(chain) < 2:
continue
link_stack([pr_by_group[gid] for gid in chain])


def _move_assignment(
groups: list[Group],
parsed_diff: ParsedDiff,
Expand Down Expand Up @@ -600,6 +680,22 @@ def split(
cp_sat_timeout: Annotated[
float, typer.Option(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",
),
] = False,
draft: Annotated[
bool,
typer.Option(
"--draft",
envvar="PR_SPLIT_DRAFT",
help="Open every sub-PR as a draft",
),
] = False,
dry_run: Annotated[
bool, typer.Option("--dry-run", help="Preview plan without creating branches or PRs")
] = False,
Expand Down Expand Up @@ -716,6 +812,8 @@ def split(
min_loc=settings.min_loc,
max_loc=settings.max_loc,
strict_loc_bounds=settings.strict_loc_bounds,
stacked=stack,
draft=draft,
priority=priority,
groups=groups,
author=author,
Expand All @@ -733,9 +831,11 @@ def split(

namespace = derive_split_namespace(dev_branch_arg)
branch_records = _create_branches_and_commits(
groups, parsed_diff, base, merge_base_ref, namespace, author=author
groups, parsed_diff, base, merge_base_ref, namespace, author=author, stacked=stack
)
pr_records = _push_and_create_prs(groups, branch_records)
pr_records = _push_and_create_prs(groups, branch_records, draft=draft)
if stack:
_link_stacks(dag, pr_records)

save_plan(PlanFile(
plan=split_plan,
Expand Down Expand Up @@ -835,13 +935,34 @@ def clean() -> None:
@app.command(
help="Execute a previously saved dry-run plan, creating branches and PRs.",
)
def execute() -> None:
def execute(
stack: Annotated[
bool,
typer.Option(
"--stack",
envvar="PR_SPLIT_STACK",
help="Stack dependent PRs even if the saved plan was not created with --stack",
),
] = False,
draft: Annotated[
bool,
typer.Option(
"--draft",
envvar="PR_SPLIT_DRAFT",
help="Open every sub-PR as a draft even if the plan was not saved with --draft",
),
] = False,
) -> None:
if not plan_exists():
console.print(ErrorMsg.NO_PLAN())
raise typer.Exit(1)

plan_file = load_plan()
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(
Expand Down Expand Up @@ -891,8 +1012,11 @@ def execute() -> None:
plan.merge_base_sha,
namespace,
author=plan.author,
stacked=plan.stacked,
)
pr_records = _push_and_create_prs(plan.groups, branch_records)
pr_records = _push_and_create_prs(plan.groups, branch_records, draft=plan.draft)
if plan.stacked:
_link_stacks(PlanDAG(plan.groups), pr_records)

save_plan(PlanFile(
plan=plan,
Expand Down
3 changes: 2 additions & 1 deletion pr_split/diff_ops/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from .parser import ParsedDiff, extract_diff, parse_diff
from .reconstructor import materialize_group_files
from .reconstructor import materialize_group_files, merge_chain_assignments

__all__ = [
"ParsedDiff",
"extract_diff",
"materialize_group_files",
"merge_chain_assignments",
"parse_diff",
]
Loading
Loading