From d1968cc4f1e1239337d4ec0edf9f1918fbb2dd55 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 12:53:03 +0100 Subject: [PATCH 001/143] feat: add cp-sat extra and install it in the action when selected The action exposed partition-strategy=cp_sat but uv tool install never pulled in ortools, so that setting could not work. Add a cp-sat extra, install it from the action when the strategy is cp_sat, point the ImportError message at the extra, and document it. Also pin the README action example to a release tag instead of @main. --- README.md | 9 ++++++--- action.yml | 9 ++++++++- pr_split/planner/partitioning.py | 3 ++- pyproject.toml | 1 + uv.lock | 6 +++++- 5 files changed, 22 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 01a0b24..8f12306 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,9 @@ uv tool install pr-split # With pip pip install pr-split + +# With the optional CP-SAT partitioning backend +uv tool install "pr-split[cp-sat]" ``` ## Prerequisites @@ -219,7 +222,7 @@ jobs: with: fetch-depth: 0 - - uses: vitali87/pr-split@main + - uses: vitali87/pr-split@v1.0.0 with: max-loc: "400" partition-strategy: "graph" @@ -232,7 +235,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 the `cp-sat` extra automatically) | | `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 +257,7 @@ 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]"`. 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/planner/partitioning.py b/pr_split/planner/partitioning.py index 166d207..94b0a83 100644 --- a/pr_split/planner/partitioning.py +++ b/pr_split/planner/partitioning.py @@ -325,7 +325,8 @@ 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 `pip install 'pr-split[cp-sat]'`" ) from exc if not units: diff --git a/pyproject.toml b/pyproject.toml index a37743f..89d46c5 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", diff --git a/uv.lock b/uv.lock index b1b6a76..0f47317 100644 --- a/uv.lock +++ b/uv.lock @@ -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 = [ From c2fea21d587ad6203f64b4b766ca372397c347a5 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 12:59:25 +0100 Subject: [PATCH 002/143] docs: note that cp_sat auto-install needs a release newer than v1.0.0 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8f12306..d5232ba 100644 --- a/README.md +++ b/README.md @@ -235,7 +235,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`; `cp_sat` installs the `cp-sat` extra automatically) | +| `partition-strategy` | `graph` | Backend for partitioning (`graph` or `cp_sat`). Automatic `ortools` install for `cp_sat` needs a release newer than `v1.0.0`; pin the action to that release or `@main` when using it | | `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 | From 761b57c1342b74abad9d811bfc2b439bdfaa7e8b Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 12:56:58 +0100 Subject: [PATCH 003/143] feat: require the gh-stack extension for stacked PRs Stacked splits previously fell back to a warning when gh stack link failed, leaving chains unregistered as native GitHub stacks. Now: - check_gh_stack() verifies the github/gh-stack extension is installed - split --stack and execute on a stacked plan refuse to start without it (dry runs are unaffected) - link_stack raises GitOperationError instead of warning - plan state is saved before linking, so a link failure is recoverable with pr-split clean --- README.md | 3 ++- pr_split/cli.py | 24 ++++++++++++++------- pr_split/exceptions.py | 5 +++++ pr_split/git_ops/__init__.py | 3 +++ pr_split/git_ops/prs.py | 14 ++++++++++-- pr_split/logs.py | 1 - tests/test_cli_coverage.py | 42 ++++++++++++++++++++++++++++++++++++ tests/test_git_prs.py | 29 +++++++++++++++++++++++-- 8 files changed, 107 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 01a0b24..4a8e822 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ pip install pr-split - Python 3.12+ - [GitHub CLI](https://cli.github.com/) (`gh`) authenticated via `gh auth login` +- [`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 ## Usage @@ -94,7 +95,7 @@ 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 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 diff --git a/pr_split/cli.py b/pr_split/cli.py index 7121b06..7ee5c87 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -49,6 +49,7 @@ add_worktree, branch_exists, check_gh_auth, + check_gh_stack, commit_files_in_dir, delete_branch, derive_split_namespace, @@ -125,7 +126,9 @@ 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 _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) @@ -138,6 +141,9 @@ def _validate_inputs(dev_branch: str, base: str, *, dry_run: bool = False) -> No 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 and not check_gh_stack(): + console.print(f"[red]{ErrorMsg.GH_STACK_MISSING()}[/red]") + raise typer.Exit(1) def _handle_loc_bound_warnings(warnings: list[str], *, strict_loc_bounds: bool) -> None: @@ -740,7 +746,7 @@ 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() @@ -860,15 +866,14 @@ def split( ) ) raise - if stack: - _link_stacks(dag, pr_records) - save_plan( PlanFile( plan=split_plan, git_state=GitState(branches=branch_records, prs=pr_records), ) ) + if stack: + _link_stacks(dag, pr_records) logger.success(f"Split complete: {len(groups)} PRs created") @@ -1019,6 +1024,9 @@ def execute( if not check_gh_auth(): console.print(f"[red]{ErrorMsg.GH_AUTH_FAILED()}[/red]") raise typer.Exit(1) + if plan.stacked and not check_gh_stack(): + console.print(f"[red]{ErrorMsg.GH_STACK_MISSING()}[/red]") + raise typer.Exit(1) parsed_diff = parse_diff(plan.raw_diff) @@ -1051,15 +1059,15 @@ def execute( ) ) raise - if plan.stacked: - _link_stacks(PlanDAG(plan.groups), pr_records) - save_plan( PlanFile( plan=plan, git_state=GitState(branches=branch_records, prs=pr_records), ) ) + if plan.stacked: + _link_stacks(PlanDAG(plan.groups), pr_records) + logger.success(f"Execute complete: {len(plan.groups)} PRs created from saved plan") logger.success(f"Execute complete: {len(plan.groups)} PRs created from saved plan") diff --git a/pr_split/exceptions.py b/pr_split/exceptions.py index f5a6958..756972c 100644 --- a/pr_split/exceptions.py +++ b/pr_split/exceptions.py @@ -27,6 +27,11 @@ class ErrorMsg(StrEnum): 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" + 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..7bd6dfb 100644 --- a/pr_split/git_ops/__init__.py +++ b/pr_split/git_ops/__init__.py @@ -37,6 +37,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/prs.py b/pr_split/git_ops/prs.py index 9129796..2fbecf4 100644 --- a/pr_split/git_ops/prs.py +++ b/pr_split/git_ops/prs.py @@ -30,6 +30,17 @@ def check_gh_auth() -> bool: return True +GH_STACK_EXTENSION = "github/gh-stack" + + +def check_gh_stack() -> bool: + try: + installed = _run_gh("extension", "list") + except GitOperationError: + return False + return any(GH_STACK_EXTENSION in line.split() for line in installed.splitlines()) + + def create_pr( head: str, base: str, title: str, body: str, *, draft: bool = False ) -> tuple[int, str]: @@ -83,8 +94,7 @@ def link_stack(pr_numbers: list[int]) -> None: try: _run_gh("stack", "link", *[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)) diff --git a/pr_split/logs.py b/pr_split/logs.py index ec302ce..ad75865 100644 --- a/pr_split/logs.py +++ b/pr_split/logs.py @@ -60,7 +60,6 @@ "Refinement iteration limit reached ({iterations}), {remaining} violation(s) remain" ) 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" " branch and PR target the base branch directly, carrying every ancestor's" diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index 8d05885..7aefae4 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -69,6 +69,48 @@ 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", 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): diff --git a/tests/test_git_prs.py b/tests/test_git_prs.py index 25d6031..f82edc6 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, @@ -132,9 +133,33 @@ def test_links_bottom_to_top(self, mock_gh: MagicMock) -> None: mock_gh.assert_called_once_with("stack", "link", "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]) + + +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(self, mock_gh: MagicMock) -> None: + mock_gh.side_effect = GitOperationError("gh not found") + assert check_gh_stack() is False class TestCreatePrDraft: From ad8e59e294f4ead66024266d240c70a57d631688 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:05:22 +0100 Subject: [PATCH 004/143] fix: honour PR_SPLIT_* env vars for strategy and priority options README documents PR_SPLIT_PRIORITY, PR_SPLIT_CHUNK_STRATEGY and PR_SPLIT_PARTITION_STRATEGY, but the split command's options had no envvar binding, so their explicit defaults always overrode the environment. Bind them (plus the undocumented --cp-sat-timeout) and document the flag and its variable. --- README.md | 2 ++ pr_split/cli.py | 26 +++++++++++++--- tests/test_cli_new_features.py | 55 +++++++++++++++++++++++++++++++++- 3 files changed, 78 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 01a0b24..c06a705 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ 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`) | +| `--cp-sat-timeout` | `15.0` | Maximum seconds to spend in the CP-SAT solver | | `--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 | @@ -192,6 +193,7 @@ 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_CP_SAT_TIMEOUT` | `15.0` | Maximum seconds to spend in the CP-SAT solver | | `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 | diff --git a/pr_split/cli.py b/pr_split/cli.py index 7121b06..c475502 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -691,15 +691,33 @@ 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") + PartitionStrategy, + typer.Option( + "--partition-strategy", + envvar="PR_SPLIT_PARTITION_STRATEGY", + help="Backend for hunk-to-PR partitioning", + ), ] = DEFAULT_PARTITION_STRATEGY, 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, diff --git a/tests/test_cli_new_features.py b/tests/test_cli_new_features.py index e668f4e..149f75c 100644 --- a/tests/test_cli_new_features.py +++ b/tests/test_cli_new_features.py @@ -13,7 +13,7 @@ _handle_loc_bound_warnings, app, ) -from pr_split.constants import AssignmentType +from pr_split.constants import AssignmentType, ChunkStrategy, PartitionStrategy, Priority from pr_split.exceptions import GitOperationError, PRSplitError from pr_split.git_ops.prs import get_pr_state, merge_pr from pr_split.schemas import ( @@ -280,6 +280,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") From 20c757db7eace5dd4677d0c7e28163e35b7a7941 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:06:23 +0100 Subject: [PATCH 005/143] fix: read plan groups from the saved PlanFile in the score action pr-split --dry-run writes a PlanFile whose top-level keys are plan and git_state, but score_pr.py looked for groups at the top level. It always found none, so should_split was never true and every PR was reported as within acceptable size limits. --- scripts/score_pr.py | 17 +++++++++--- tests/test_score_pr.py | 59 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 tests/test_score_pr.py diff --git a/scripts/score_pr.py b/scripts/score_pr.py index 7533fe9..6f7543e 100644 --- a/scripts/score_pr.py +++ b/scripts/score_pr.py @@ -35,6 +35,18 @@ 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: @@ -119,10 +131,7 @@ def main() -> None: _skip("No plan file generated.") 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) max_group_loc = max((g["estimated_loc"] for g in groups), default=0) 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 From 76b435f37d5eba1d5cac468ac4420b5a69c4e653 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:03:54 +0100 Subject: [PATCH 006/143] fix: surface gh failures from the gh-stack check instead of reporting it missing --- pr_split/cli.py | 29 ++++++++++++++++++++++------- pr_split/git_ops/prs.py | 11 +++++++---- tests/test_cli_coverage.py | 20 +++++++++++++++++++- tests/test_git_prs.py | 5 +++-- 4 files changed, 51 insertions(+), 14 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index 7ee5c87..d9d79e8 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -44,7 +44,13 @@ 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, branch_exists, @@ -126,6 +132,17 @@ 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 _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: @@ -141,9 +158,8 @@ def _validate_inputs( 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 and not check_gh_stack(): - console.print(f"[red]{ErrorMsg.GH_STACK_MISSING()}[/red]") - raise typer.Exit(1) + if not dry_run and stacked: + _require_gh_stack() def _handle_loc_bound_warnings(warnings: list[str], *, strict_loc_bounds: bool) -> None: @@ -1024,9 +1040,8 @@ def execute( if not check_gh_auth(): console.print(f"[red]{ErrorMsg.GH_AUTH_FAILED()}[/red]") raise typer.Exit(1) - if plan.stacked and not check_gh_stack(): - console.print(f"[red]{ErrorMsg.GH_STACK_MISSING()}[/red]") - raise typer.Exit(1) + if plan.stacked: + _require_gh_stack() parsed_diff = parse_diff(plan.raw_diff) diff --git a/pr_split/git_ops/prs.py b/pr_split/git_ops/prs.py index 2fbecf4..f49ea31 100644 --- a/pr_split/git_ops/prs.py +++ b/pr_split/git_ops/prs.py @@ -34,10 +34,13 @@ def check_gh_auth() -> bool: def check_gh_stack() -> bool: - try: - installed = _run_gh("extension", "list") - except GitOperationError: - return False + """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()) diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index 7aefae4..00cb397 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -26,7 +26,7 @@ app, ) from pr_split.constants import AssignmentType -from pr_split.exceptions import PRSplitError +from pr_split.exceptions import GitOperationError, PRSplitError from pr_split.schemas import Group, GroupAssignment from pr_split.types_defs import ForkPRInfo @@ -83,6 +83,24 @@ def test_stacked_requires_gh_stack( 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) diff --git a/tests/test_git_prs.py b/tests/test_git_prs.py index f82edc6..0d108bd 100644 --- a/tests/test_git_prs.py +++ b/tests/test_git_prs.py @@ -157,9 +157,10 @@ def test_lookalike_name_does_not_count(self, mock_gh: MagicMock) -> None: assert check_gh_stack() is False @patch("pr_split.git_ops.prs._run_gh") - def test_gh_failure(self, mock_gh: MagicMock) -> None: + def test_gh_failure_propagates(self, mock_gh: MagicMock) -> None: mock_gh.side_effect = GitOperationError("gh not found") - assert check_gh_stack() is False + with pytest.raises(GitOperationError, match="gh not found"): + check_gh_stack() class TestCreatePrDraft: From d02d88d3d83ed5c558c5c7d06381ea0ee555460d Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:45:50 +0100 Subject: [PATCH 007/143] fix: keep one assignment per file when merging chunk groups When a later diff chunk assigned more hunks of a file to a group that already held that file, _merge_chunk_groups appended a second GroupAssignment for the same path. validate_coverage accepted it (each hunk claimed once) but materialize_group_files wrote the file once per assignment, so the last assignment overwrote the first and the earlier hunks were silently missing from the committed branch. - _merge_chunk_groups merges same-path assignments (union of hunks, WHOLE_FILE wins). - materialize_group_files groups assignments by path and writes each file once from the union of their hunks, so duplicates from any source can no longer drop changes. --- pr_split/diff_ops/reconstructor.py | 42 +++++++++------- pr_split/planner/client.py | 28 ++++++++++- tests/test_client.py | 77 ++++++++++++++++++++++++++++++ tests/test_reconstructor.py | 54 +++++++++++++++++++++ 4 files changed, 183 insertions(+), 18 deletions(-) diff --git a/pr_split/diff_ops/reconstructor.py b/pr_split/diff_ops/reconstructor.py index 2132da3..3b70c34 100644 --- a/pr_split/diff_ops/reconstructor.py +++ b/pr_split/diff_ops/reconstructor.py @@ -83,26 +83,39 @@ def apply_hunks(base_content: str, patch_file: PatchedFile, assigned_indices: li 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) + 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] @@ -111,15 +124,10 @@ def materialize_group_files( 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( + result[file_path] = "".join( ln if ln.endswith("\n") else ln + "\n" for ln in target_lines ) 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/planner/client.py b/pr_split/planner/client.py index 0c933c8..2c2b0d6 100644 --- a/pr_split/planner/client.py +++ b/pr_split/planner/client.py @@ -227,12 +227,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 = {a.file_path: a for a in existing.assignments} + for incoming in cg.assignments: + if incoming.file_path in by_path: + by_path[incoming.file_path] = _merge_assignment( + by_path[incoming.file_path], incoming + ) + else: + by_path[incoming.file_path] = incoming + existing.assignments = list(by_path.values()) for dep in cg.depends_on: if dep not in existing.depends_on: existing.depends_on.append(dep) diff --git a/tests/test_client.py b/tests/test_client.py index 1bdfffa..2542c8d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -986,3 +986,80 @@ def test_large_diff_uses_chunking( result = _plan_split_with_llm(parsed, settings) assert len(result) == 1 mock_chunked.assert_called_once() + + +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]), + ] diff --git a/tests/test_reconstructor.py b/tests/test_reconstructor.py index e9495f6..1e9c16b 100644 --- a/tests/test_reconstructor.py +++ b/tests/test_reconstructor.py @@ -379,3 +379,57 @@ 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], + ), + ], + ) + 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 + + def test_duplicate_assignments_on_new_file_apply_both_hunks(self) -> None: + 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" + ) + 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.WHOLE_FILE, + hunk_indices=[], + ), + ], + ) + assert materialize_group_files(parsed, group, "main")["n.py"] == "one\ntwo\n" From 6003df4a32a79a51026d4e23ffd9db024d3dbac3 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:07:26 +0100 Subject: [PATCH 008/143] fix: carry every ancestor's hunks into stacked descendants In stacked mode a child merged only its direct parent's effective assignments. Files are rebuilt from the merge base, so a grandchild that touched a file its parent did not lost the grandparent's hunks and its commit silently reverted them. Merge all ancestors instead, as the multi-parent path already did. --- pr_split/cli.py | 9 +++++++- tests/test_cli_helpers.py | 43 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index 7121b06..dd458f9 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -243,7 +243,14 @@ def _stacked_batch_args( 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: diff --git a/tests/test_cli_helpers.py b/tests/test_cli_helpers.py index 01b6d8b..aed7de7 100644 --- a/tests/test_cli_helpers.py +++ b/tests/test_cli_helpers.py @@ -431,6 +431,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} == { From 3757504192c69ef2f51ee33d8680a41d1ca47b05 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:11:36 +0100 Subject: [PATCH 009/143] fix: report fork-PR resolution errors instead of a traceback Errors from fetch_fork_pr / fetch_fork_branch (PR not found, fetch failed) escaped split as raw GitOperationError tracebacks. Catch them and print the prepared message. Also distinguish a same-repo PR from a missing one so the user is told to pass the branch name instead. --- pr_split/cli.py | 6 +++++- pr_split/exceptions.py | 5 ++++- pr_split/git_ops/prs.py | 5 ++++- tests/test_cli_coverage.py | 19 ++++++++++++++++++- tests/test_git_prs_coverage.py | 19 +++++++++++++++++++ 5 files changed, 50 insertions(+), 4 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index 7121b06..20ac897 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -732,7 +732,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) diff --git a/pr_split/exceptions.py b/pr_split/exceptions.py index f5a6958..8ab56aa 100644 --- a/pr_split/exceptions.py +++ b/pr_split/exceptions.py @@ -21,7 +21,10 @@ class ErrorMsg(StrEnum): 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_NOT_FOUND = "PR #{number} not found" + PR_NOT_FROM_FORK = ( + "PR #{number} is not from a fork; pass its head branch name instead of the PR number" + ) PR_FETCH_FAILED = "Failed to fetch fork branch for PR #{number}: {detail}" FORK_FETCH_FAILED = "Failed to fetch {user}:{branch}: {detail}" HUNK_TOO_LARGE = "Hunk {file}[{index}] has ~{tokens} estimated tokens, exceeds budget {budget}" diff --git a/pr_split/git_ops/prs.py b/pr_split/git_ops/prs.py index 9129796..b80e701 100644 --- a/pr_split/git_ops/prs.py +++ b/pr_split/git_ops/prs.py @@ -104,8 +104,11 @@ def fetch_fork_pr(pr_number: int) -> ForkPRInfo: 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)) + if not head_repo.get("fork"): + raise GitOperationError(ErrorMsg.PR_NOT_FROM_FORK(number=pr_number)) clone_url = str(head_repo["clone_url"]) head_ref = str(head["ref"]) diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index 8d05885..4c8c6e4 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -26,7 +26,7 @@ app, ) from pr_split.constants import AssignmentType -from pr_split.exceptions import PRSplitError +from pr_split.exceptions import GitOperationError, PRSplitError from pr_split.schemas import Group, GroupAssignment from pr_split.types_defs import ForkPRInfo @@ -464,6 +464,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) diff --git a/tests/test_git_prs_coverage.py b/tests/test_git_prs_coverage.py index 38d132b..0483424 100644 --- a/tests/test_git_prs_coverage.py +++ b/tests/test_git_prs_coverage.py @@ -164,3 +164,22 @@ 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") + + +class TestFetchForkPrNotFromFork: + @patch("pr_split.git_ops.prs._run_gh") + def test_same_repo_pr_gets_specific_message(self, mock_gh: MagicMock) -> None: + pr_data = { + "head": { + "ref": "feature", + "repo": { + "fork": False, + "clone_url": "https://github.com/org/repo.git", + "full_name": "org/repo", + }, + }, + "base": {"ref": "main"}, + } + mock_gh.return_value = json.dumps(pr_data) + with pytest.raises(GitOperationError, match="PR #42 is not from a fork"): + fetch_fork_pr(42) From 8470822ee32ac3a4a2a311988f8e4ece02384e01 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:18:43 +0100 Subject: [PATCH 010/143] fix: reject diffs containing binary files instead of silently dropping them Binary patches have no hunks, so coverage validation passed without any group claiming them and the change never reached any sub-PR. Refuse such diffs up front in split (before the LLM call) and in validate_plan. --- pr_split/cli.py | 7 ++++++- pr_split/exceptions.py | 4 ++++ pr_split/planner/__init__.py | 10 ++++++++-- pr_split/planner/validator.py | 13 +++++++++++++ tests/test_validator.py | 31 +++++++++++++++++++++++++++++++ 5 files changed, 62 insertions(+), 3 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index 7121b06..a935039 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -63,7 +63,7 @@ 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_coverage, validate_plan +from .planner import plan_split, validate_coverage, validate_no_binary_files, validate_plan from .schemas import ( BranchRecord, GitState, @@ -787,6 +787,11 @@ def split( except (ValidationError, ValueError) as exc: console.print(f"[red]{exc}[/red]") raise typer.Exit(1) from exc + try: + validate_no_binary_files(parsed_diff) + except PlanValidationError as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Exit(1) from exc groups = plan_split(parsed_diff, settings) logger.info(logs.VALIDATING_PLAN) diff --git a/pr_split/exceptions.py b/pr_split/exceptions.py index f5a6958..7e9b242 100644 --- a/pr_split/exceptions.py +++ b/pr_split/exceptions.py @@ -27,6 +27,10 @@ class ErrorMsg(StrEnum): 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" + ) def __call__(self, **kwargs: object) -> str: return self.value.format(**kwargs) if kwargs else self.value 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/validator.py b/pr_split/planner/validator.py index 0995af9..9cbe0c2 100644 --- a/pr_split/planner/validator.py +++ b/pr_split/planner/validator.py @@ -9,6 +9,18 @@ from ..types_defs import LocBoundViolation +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: hunk_counts = {pf.path: len(pf) for pf in parsed_diff.patch_set} assigned: dict[tuple[str, int], list[str]] = {} @@ -138,6 +150,7 @@ def validate_plan( min_loc: int | None = None, ) -> list[str]: dag.validate_acyclic() + validate_no_binary_files(parsed_diff) validate_coverage(groups, parsed_diff) validate_loc(groups, parsed_diff) validate_no_conflicts(groups, dag) diff --git a/tests/test_validator.py b/tests/test_validator.py index 8dc4b82..134dca0 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 @@ -94,6 +96,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) From e0b3a0597a1e9e2290e65bf53c0fae27f1e370fe Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:31:54 +0100 Subject: [PATCH 011/143] fix: report unreadable plan files instead of a pydantic traceback load_plan validated plan.json without guarding ValidationError or OSError, and no command caught anything around it, so an empty, corrupt or old-schema .pr-split/plan.json crashed status, clean, execute, merge and split with a raw traceback. load_plan now raises PRSplitError(PLAN_LOAD_FAILED) with the detail and a hint to delete the file, and every command goes through _load_plan_or_exit which prints the message and exits 1. --- pr_split/cli.py | 18 +++++++++++----- pr_split/exceptions.py | 3 +++ pr_split/plan_store.py | 6 +++++- tests/test_cli_new_features.py | 38 ++++++++++++++++++++++++++++++++++ tests/test_plan_store.py | 31 +++++++++++++++++++++++++++ 5 files changed, 90 insertions(+), 6 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index 7121b06..e57badc 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -140,6 +140,14 @@ def _validate_inputs(dev_branch: str, base: str, *, dry_run: bool = False) -> No raise typer.Exit(1) +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: if strict_loc_bounds and warnings: console.print(f"[red]{ErrorMsg.LOC_BOUNDS_STRICT_FAILED()}[/red]") @@ -743,7 +751,7 @@ def split( _validate_inputs(dev_branch, base, dry_run=dry_run) 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]") @@ -878,7 +886,7 @@ 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 @@ -951,7 +959,7 @@ def clean() -> None: 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) @@ -985,7 +993,7 @@ def execute( 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}) @@ -1125,7 +1133,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} diff --git a/pr_split/exceptions.py b/pr_split/exceptions.py index f5a6958..8fafefe 100644 --- a/pr_split/exceptions.py +++ b/pr_split/exceptions.py @@ -17,6 +17,9 @@ class ErrorMsg(StrEnum): 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_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}" BRANCH_CREATE_FAILED = "Failed to create branch '{branch}': {detail}" PR_CREATE_FAILED = "Failed to create PR for group '{group}': {detail}" diff --git a/pr_split/plan_store.py b/pr_split/plan_store.py index f7bfd99..8fd5fe7 100644 --- a/pr_split/plan_store.py +++ b/pr_split/plan_store.py @@ -1,6 +1,7 @@ from pathlib import Path from loguru import logger +from pydantic import ValidationError from . import logs from .constants import PLAN_DIR, PLAN_FILE @@ -20,7 +21,10 @@ def load_plan() -> PlanFile: plan_path = Path(PLAN_FILE) if not plan_path.exists(): raise PRSplitError(ErrorMsg.NO_PLAN()) - plan_file = PlanFile.model_validate_json(plan_path.read_text()) + try: + plan_file = PlanFile.model_validate_json(plan_path.read_text()) + except (OSError, ValidationError) as exc: + raise PRSplitError(ErrorMsg.PLAN_LOAD_FAILED(path=plan_path, detail=exc)) from exc logger.info(logs.PLAN_LOADED.format(count=len(plan_file.plan.groups), path=plan_path)) return plan_file diff --git a/tests/test_cli_new_features.py b/tests/test_cli_new_features.py index e668f4e..5d944a1 100644 --- a/tests/test_cli_new_features.py +++ b/tests/test_cli_new_features.py @@ -332,3 +332,41 @@ 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 diff --git a/tests/test_plan_store.py b/tests/test_plan_store.py index 98dd939..af00a43 100644 --- a/tests/test_plan_store.py +++ b/tests/test_plan_store.py @@ -106,3 +106,34 @@ def test_saved_file_is_valid_json( 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_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() From 78e11ddf86fb1d1cce48c23e2853f75fc221305d Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:43:44 +0100 Subject: [PATCH 012/143] fix: never merge a PR whose dependency was skipped merge only stopped a batch when a PR was neither merged nor skipped, so a parent skipped for draft/review-required/changes-requested/no-PR/ fetch-error did not block its children: the child was merged into the base branch without its dependency. Each group now checks that every parent is in the merged set for this run; otherwise it is skipped as '(dependency X not merged)', independent subtrees continue, the run exits 1, and the webhook reports exit_reason=unmerged_dependency. --- README.md | 2 +- pr_split/cli.py | 27 +++++++- tests/test_cli_new_features.py | 123 +++++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 01a0b24..406e052 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,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`): diff --git a/pr_split/cli.py b/pr_split/cli.py index 7121b06..de74634 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -1138,12 +1138,22 @@ def merge_all( merged: list[str] = [] skipped: list[str] = [] skipped_ids: set[str] = set() + blocked: list[str] = [] failed: list[str] = [] stopped = False exited_early = False for batch in dag.iter_ready(): for group_id in batch: + 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 + pr_record = pr_map.get(group_id) if not pr_record: skipped_ids.add(group_id) @@ -1227,9 +1237,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 +1260,11 @@ 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") diff --git a/tests/test_cli_new_features.py b/tests/test_cli_new_features.py index e668f4e..2c78c59 100644 --- a/tests/test_cli_new_features.py +++ b/tests/test_cli_new_features.py @@ -332,3 +332,126 @@ 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() + + +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_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 From 2b0b07f016a646b89ec601fc8efb36879567cd90 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:16:18 +0100 Subject: [PATCH 013/143] fix: count WHOLE_FILE assignments with empty hunk_indices everywhere validate_coverage and merge_chain_assignments already treated a WHOLE_FILE assignment with an empty hunk_indices list as covering every hunk, but recompute_estimated_loc, assign_uncovered_hunks and validate_no_conflicts still iterated only hunk_indices. Such a group got estimated_loc=0 and the plan then failed validate_loc; the conflict check was blind to it. Centralise the expansion in GroupAssignment.covered_indices() and use it in all three. Also turn the zero-groups case in assign_uncovered_hunks into a PlanValidationError instead of a raw ValueError from max([]). --- pr_split/planner/chunker.py | 13 ++++++++++--- pr_split/planner/validator.py | 10 +++++++--- pr_split/schemas.py | 10 ++++++++++ tests/test_chunker.py | 24 ++++++++++++++++++++++++ tests/test_validator.py | 8 ++++++++ 5 files changed, 59 insertions(+), 6 deletions(-) diff --git a/pr_split/planner/chunker.py b/pr_split/planner/chunker.py index 96e3d52..69cd464 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 from ..schemas import Group, GroupAssignment from ..types_defs import DiffStats, FileSummary, HunkRef @@ -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/validator.py b/pr_split/planner/validator.py index 0995af9..663277d 100644 --- a/pr_split/planner/validator.py +++ b/pr_split/planner/validator.py @@ -46,12 +46,16 @@ 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 = None +) -> None: + counts = hunk_counts or {} 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(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] @@ -140,5 +144,5 @@ def validate_plan( dag.validate_acyclic() 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}) return validate_loc_bounds(groups, max_loc, min_loc) diff --git a/pr_split/schemas.py b/pr_split/schemas.py index c68774b..96341fc 100644 --- a/pr_split/schemas.py +++ b/pr_split/schemas.py @@ -12,6 +12,16 @@ 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 even when ``hunk_indices`` + was left empty; PARTIAL_HUNKS covers exactly what it lists. + """ + if self.assignment_type is AssignmentType.WHOLE_FILE: + return sorted(set(range(hunk_count)) | set(self.hunk_indices)) + return list(self.hunk_indices) + class Group(BaseModel): id: str diff --git a/tests/test_chunker.py b/tests/test_chunker.py index 8af1b00..4be11fb 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 from pr_split.planner.chunker import ( assign_uncovered_hunks, build_chunk_diff_from_hunks, @@ -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])])] diff --git a/tests/test_validator.py b/tests/test_validator.py index 8dc4b82..6ef734e 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -131,6 +131,14 @@ def test_independent_overlapping_hunks_raises(self) -> None: with pytest.raises(PlanValidationError, match="overlapping"): validate_no_conflicts(groups, dag) + 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 = [ _make_group("g1", [_ga("a.py", PARTIAL, [0])], 3), From 8b029f1955b4120e0668380bbea27db4b4144371 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:21:13 +0100 Subject: [PATCH 014/143] refactor: drop the now-unused effective mapping in _stacked_batch_args --- pr_split/cli.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index dd458f9..a54fb7f 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -236,7 +236,6 @@ 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: @@ -269,7 +268,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 From da2e12ff2a1e217348dede633cdda6750e0defa5 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:21:20 +0100 Subject: [PATCH 015/143] fix: report malformed GitHub PR responses as GitOperationError --- pr_split/exceptions.py | 1 + pr_split/git_ops/prs.py | 11 ++++++++--- tests/test_git_prs_coverage.py | 13 +++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/pr_split/exceptions.py b/pr_split/exceptions.py index 8ab56aa..8a528f9 100644 --- a/pr_split/exceptions.py +++ b/pr_split/exceptions.py @@ -22,6 +22,7 @@ class ErrorMsg(StrEnum): 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" + PR_RESPONSE_INVALID = "Unexpected response from GitHub for PR #{number}: {detail}" PR_NOT_FROM_FORK = ( "PR #{number} is not from a fork; pass its head branch name instead of the PR number" ) diff --git a/pr_split/git_ops/prs.py b/pr_split/git_ops/prs.py index b80e701..b3ce5f3 100644 --- a/pr_split/git_ops/prs.py +++ b/pr_split/git_ops/prs.py @@ -96,9 +96,14 @@ 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)) diff --git a/tests/test_git_prs_coverage.py b/tests/test_git_prs_coverage.py index 0483424..d4a5052 100644 --- a/tests/test_git_prs_coverage.py +++ b/tests/test_git_prs_coverage.py @@ -183,3 +183,16 @@ def test_same_repo_pr_gets_specific_message(self, mock_gh: MagicMock) -> None: mock_gh.return_value = json.dumps(pr_data) with pytest.raises(GitOperationError, match="PR #42 is not from a fork"): fetch_fork_pr(42) + + +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) From 2485609fe75f8066e2e96a9628437c065754e1b9 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:35:48 +0100 Subject: [PATCH 016/143] fix: reject binary files when executing a saved plan too A dry-run plan saved before this check existed (or produced by a different version) can still carry a binary file; execute now runs validate_no_binary_files on the saved diff before validate_coverage so it stops before creating any branches. --- pr_split/cli.py | 1 + tests/test_cli_coverage.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/pr_split/cli.py b/pr_split/cli.py index a935039..71d636e 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -1028,6 +1028,7 @@ def execute( parsed_diff = parse_diff(plan.raw_diff) try: + validate_no_binary_files(parsed_diff) validate_coverage(plan.groups, parsed_diff) except PlanValidationError as exc: console.print(f"[red]{exc}[/red]") diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index 8d05885..52b7860 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -523,6 +523,42 @@ def test_execute_missing_merge_base_sha( result = runner.invoke(app, ["execute"]) assert result.exit_code != 0 + @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, + ) -> 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() + # --------------------------------------------------------------------------- # status command From a77da0fcbeb956293ec018e792c068db6df0ac75 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:14:28 +0100 Subject: [PATCH 017/143] chore: remove dead checkout-based branch helpers create_group_branch, checkout_new_branch, checkout_branch and commit_files were the pre-worktree path that switched branches in the user's working tree. Nothing calls them since branch creation moved to isolated worktrees; drop them and their tests so the unsafe path cannot be revived by accident. --- pr_split/git_ops/__init__.py | 9 ----- pr_split/git_ops/branches.py | 30 --------------- tests/test_git_branches.py | 72 ------------------------------------ 3 files changed, 111 deletions(-) diff --git a/pr_split/git_ops/__init__.py b/pr_split/git_ops/__init__.py index 5cfddbd..d00b8c7 100644 --- a/pr_split/git_ops/__init__.py +++ b/pr_split/git_ops/__init__.py @@ -4,18 +4,9 @@ from .branches import ( branch_exists as branch_exists, ) -from .branches import ( - checkout_branch as checkout_branch, -) -from .branches import ( - commit_files as commit_files, -) 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, ) diff --git a/pr_split/git_ops/branches.py b/pr_split/git_ops/branches.py index 60bf6d4..e917f97 100644 --- a/pr_split/git_ops/branches.py +++ b/pr_split/git_ops/branches.py @@ -6,7 +6,6 @@ from loguru import logger from .. import logs -from ..constants import BRANCH_PREFIX from ..exceptions import GitOperationError @@ -46,25 +45,6 @@ def is_worktree_clean() -> bool: return all(line.startswith("??") for line in output.splitlines()) -def checkout_new_branch(name: str, start_point: str) -> None: - run_git("checkout", "-b", name, start_point) - - -def checkout_branch(name: str) -> None: - run_git("checkout", name) - - -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 () - try: - run_git("commit", "-m", message, *author_args) - except GitOperationError: - run_git("add", "-u") - run_git("commit", "-m", message, *author_args) - return run_git("rev-parse", "HEAD") - - def push_branch(branch: str) -> None: logger.info(logs.PUSHING_BRANCH.format(branch=branch)) run_git("push", "--force-with-lease", "-u", "origin", branch) @@ -87,16 +67,6 @@ def derive_split_namespace(dev_branch_arg: str) -> str: 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): diff --git a/tests/test_git_branches.py b/tests/test_git_branches.py index f9acff3..7007c0e 100644 --- a/tests/test_git_branches.py +++ b/tests/test_git_branches.py @@ -9,11 +9,7 @@ from pr_split.git_ops.branches import ( add_worktree, branch_exists, - checkout_branch, - checkout_new_branch, - commit_files, commit_files_in_dir, - create_group_branch, delete_branch, derive_split_namespace, is_worktree_clean, @@ -79,34 +75,6 @@ def test_returns_sha(self, mock_git: MagicMock) -> None: assert merge_base("main", "feature") == "abc123def" -class TestCommitFiles: - @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" - - @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] - - @patch("pr_split.git_ops.branches.run_git") - def test_commit_fallback_on_failure(self, mock_git: MagicMock) -> None: - mock_git.side_effect = [ - "", - GitOperationError("nothing to commit"), - "", - "", - "def456", - ] - sha = commit_files(["file.py"], "test commit") - assert sha == "def456" - - class TestPushBranch: @patch("pr_split.git_ops.branches.run_git") def test_calls_push(self, mock_git: MagicMock) -> None: @@ -151,32 +119,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: @@ -220,20 +162,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: From 00d9c052e2f507bda295d510b5eb59a678b336ee Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:29:54 +0100 Subject: [PATCH 018/143] fix: keep graph-backend plans acyclic The greedy grouper could put file A's earlier hunk and file B's later hunk in one group and the reverse in another; the file-order dependency derivation then produced a cycle, which surfaced later as a raw graphlib.CycleError traceback from score_plan. - _merge_order_is_acyclic mirrors _derive_merge_order_dependencies and is consulted before accepting a candidate in _group_units_graph (only when the candidate's file already appears in a finished group) and before accepting a min_loc merge in _best_graph_merge_target. - partition_diff validates the final DAG so any backend that still produces a cycle raises PlanValidationError(CYCLE_DETECTED) instead of a traceback. Fuzzing 800 random diffs across both priorities and min_loc settings produced 0 cyclic plans (1/400 before). --- pr_split/planner/partitioning.py | 44 +++++++++++- tests/test_partitioning.py | 114 ++++++++++++++++++++++++++++++- 2 files changed, 156 insertions(+), 2 deletions(-) diff --git a/pr_split/planner/partitioning.py b/pr_split/planner/partitioning.py index 166d207..169735c 100644 --- a/pr_split/planner/partitioning.py +++ b/pr_split/planner/partitioning.py @@ -2,16 +2,20 @@ 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 ..constants import AssignmentType, PartitionStrategy, Priority from ..exceptions import PRSplitError +from ..graph import PlanDAG from ..schemas import Group, GroupAssignment from .chunker import recompute_estimated_loc if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + from ortools.sat.python.cp_model import IntVar from ..config import Settings @@ -126,11 +130,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 +179,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 +210,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 +282,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) @@ -553,8 +593,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/tests/test_partitioning.py b/tests/test_partitioning.py index 6f1cfa0..76fcf8f 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 @@ -109,3 +117,107 @@ 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 _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) From e644abc6b908c251e4731871e14bc7b186834371 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:32:48 +0100 Subject: [PATCH 019/143] fix: skip the score action on non-pull_request events BASE_BRANCH and HEAD_BRANCH come from github.event.pull_request.*, which are empty on push, workflow_dispatch and schedule events. The script then ran 'git fetch origin ""', which fails, and exited 1 before any output was written, so the action step went red instead of being skipped and downstream steps reading should_split saw nothing. The script now treats a missing base or head branch as a non-PR event: it writes total_loc=0 plus the usual skip outputs and returns. --- scripts/score_pr.py | 10 ++++- tests/test_score_pr_events.py | 79 +++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 tests/test_score_pr_events.py diff --git a/scripts/score_pr.py b/scripts/score_pr.py index 7533fe9..fefeadc 100644 --- a/scripts/score_pr.py +++ b/scripts/score_pr.py @@ -51,8 +51,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]) diff --git a/tests/test_score_pr_events.py b/tests/test_score_pr_events.py new file mode 100644 index 0000000..c22609e --- /dev/null +++ b/tests/test_score_pr_events.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import importlib.util +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" From b7460371a545c6717c60ca2e675d87c28a371da8 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:33:28 +0100 Subject: [PATCH 020/143] docs: describe the wait and timeout behaviour of merge --auto README presented --auto as queueing merges behind CI, but merge also polls each batch for up to 10 minutes and exits 1 (exit_reason incomplete_batch) if any PR is still unmerged, so users running it as a fire-and-forget step got a non-zero exit with no explanation. Document the polling, the timeout, the exit code, and that re-running resumes. --- README.md | 2 ++ pr_split/cli.py | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 01a0b24..eb026f3 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,8 @@ 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 diff --git a/pr_split/cli.py b/pr_split/cli.py index 7121b06..fcf3a3f 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -1110,7 +1110,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, From f51deb7ec5bef564618b1a6db753c15d88fee7cf Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:51:38 +0100 Subject: [PATCH 021/143] fix: raise PRSplitError for chunking failures A hunk larger than the chunk token budget (and an unknown chunk strategy) raised a bare ValueError from the chunker, which bypasses the CLI's PRSplitError boundary and reaches the user as a traceback. --- pr_split/planner/chunker.py | 10 +++++----- tests/test_chunker.py | 13 +++++++++++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/pr_split/planner/chunker.py b/pr_split/planner/chunker.py index 96e3d52..210245e 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, 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: diff --git a/tests/test_chunker.py b/tests/test_chunker.py index 8af1b00..5f4a8a6 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 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") @@ -330,3 +331,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) From 8bea04772b7b5f184d61c1de6ba90149e285f9aa Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:38:00 +0100 Subject: [PATCH 022/143] fix: translate invalid UTF-8 plan files through PLAN_LOAD_FAILED too Path.read_text raises UnicodeDecodeError before pydantic runs, which the load_plan guard did not catch. --- pr_split/plan_store.py | 2 +- tests/test_plan_store.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/pr_split/plan_store.py b/pr_split/plan_store.py index 8fd5fe7..c2c1da7 100644 --- a/pr_split/plan_store.py +++ b/pr_split/plan_store.py @@ -23,7 +23,7 @@ def load_plan() -> PlanFile: raise PRSplitError(ErrorMsg.NO_PLAN()) try: plan_file = PlanFile.model_validate_json(plan_path.read_text()) - except (OSError, ValidationError) as exc: + except (OSError, UnicodeDecodeError, ValidationError) as exc: raise PRSplitError(ErrorMsg.PLAN_LOAD_FAILED(path=plan_path, detail=exc)) from exc logger.info(logs.PLAN_LOADED.format(count=len(plan_file.plan.groups), path=plan_path)) return plan_file diff --git a/tests/test_plan_store.py b/tests/test_plan_store.py index af00a43..11ca925 100644 --- a/tests/test_plan_store.py +++ b/tests/test_plan_store.py @@ -129,6 +129,15 @@ def test_unreadable_plan_raises_pr_split_error( 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: From 8b28de486aadb63be688f800c91558fbeec4fcec Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:18:36 +0100 Subject: [PATCH 023/143] fix: recompute group LOC after an interactive move _move_assignment only mutated assignments, so after a move the plan table, --strict-loc-bounds check, saved plan.json and PR bodies all carried the pre-move estimated_loc/added/removed values. --- pr_split/cli.py | 7 ++++++- tests/test_cli_coverage.py | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index 7121b06..a0998df 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -64,6 +64,7 @@ from .graph import PlanDAG from .plan_store import load_plan, plan_exists, save_plan from .planner import plan_split, validate_coverage, validate_plan +from .planner.chunker import recompute_estimated_loc from .schemas import ( BranchRecord, GitState, @@ -638,7 +639,11 @@ 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) else: console.print( "[yellow]Unknown command. Type 'done' to proceed or 'abort' to cancel.[/yellow]" diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index 8d05885..560d19a 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -17,6 +17,7 @@ from pr_split.cli import ( _build_pr_body, _handle_loc_bound_warnings, + _interactive_edit, _move_assignment, _present_plan, _resolve_fork_ref, @@ -261,6 +262,31 @@ 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() + + # --------------------------------------------------------------------------- # _move_assignment # --------------------------------------------------------------------------- From 6bb9a38da2bd10c877a23a0fd903edf9ddbe6191 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:52:46 +0100 Subject: [PATCH 024/143] fix: floor the auto-derived min_loc at 1 With refinement enabled and min_loc unset, min_loc = max_loc // 4 gave 0 for max_loc < 4, bypassing the ge=1 constraint on the explicit field. Floor it at 1; max_loc=1 now fails the min_loc < max_loc check instead of silently running with min_loc=0. --- pr_split/config.py | 4 +++- tests/test_config.py | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/pr_split/config.py b/pr_split/config.py index 9c4abca..b803fa8 100644 --- a/pr_split/config.py +++ b/pr_split/config.py @@ -59,7 +59,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") 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 From fc10af1f5315acce68e0a8b7f36c7972b8398587 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:36:49 +0100 Subject: [PATCH 025/143] test: cover whole-file assignments retained across an interactive move Greptile showed that recomputing after a move undercounted a group that kept a WHOLE_FILE assignment with empty hunk_indices. That is fixed by the covered_indices change this branch now stacks on; add an end-to-end _interactive_edit test so the combination stays correct. --- tests/test_cli_coverage.py | 48 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index 560d19a..db7c490 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -27,6 +27,7 @@ app, ) from pr_split.constants import AssignmentType +from pr_split.diff_ops.parser import parse_diff from pr_split.exceptions import PRSplitError from pr_split.schemas import Group, GroupAssignment from pr_split.types_defs import ForkPRInfo @@ -286,6 +287,53 @@ def test_failed_move_does_not_recompute( _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 From 963f8fe745695a5730cf6eb213b54ae04765d3e2 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 14:01:53 +0100 Subject: [PATCH 026/143] fix: do not attempt LOC refinement after chunked planning The refinement prompt embeds the full labeled diff, which is exactly what exceeded the context window and forced chunking, so the call was guaranteed to fail and the LLMError was swallowed as an exhausted refinement. --max-refinement-iterations was therefore a silent no-op plus one wasted API call on the diffs that need it most. Skip the refinement in the chunked path and log why, with the violation count. --- pr_split/logs.py | 5 +++++ pr_split/planner/client.py | 9 ++++++++- tests/test_client.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/pr_split/logs.py b/pr_split/logs.py index ec302ce..8f0ac52 100644 --- a/pr_split/logs.py +++ b/pr_split/logs.py @@ -59,6 +59,11 @@ 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" +) STACK_LINKED = "Linked stack for PRs {prs}" STACK_LINK_FAILED = "Could not link stack for PRs {prs}: {detail}" MERGE_NODE_NOT_STACKED = ( diff --git a/pr_split/planner/client.py b/pr_split/planner/client.py index 0c933c8..af8333c 100644 --- a/pr_split/planner/client.py +++ b/pr_split/planner/client.py @@ -407,7 +407,14 @@ def _plan_split_with_llm( 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) diff --git a/tests/test_client.py b/tests/test_client.py index 1bdfffa..2d3ce2d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -986,3 +986,38 @@ 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() From 175b4194443dd955e427d2427e570872a4c8db36 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 14:12:13 +0100 Subject: [PATCH 027/143] fix: count an already-merged child as merged even under an open parent The dependency guard ran before the live-state lookup, so a PR that had already merged on GitHub (e.g. into a still-open parent branch) was reported as blocked and blocked its own descendants. Check live state first and short-circuit MERGED, then apply the guard. --- pr_split/cli.py | 21 ++++++++++++--------- tests/test_cli_new_features.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index de74634..a678a01 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -1145,15 +1145,6 @@ def merge_all( exited_early = False for batch in dag.iter_ready(): for group_id in batch: - 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 - pr_record = pr_map.get(group_id) if not pr_record: skipped_ids.add(group_id) @@ -1176,6 +1167,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) diff --git a/tests/test_cli_new_features.py b/tests/test_cli_new_features.py index 2c78c59..2d1b74d 100644 --- a/tests/test_cli_new_features.py +++ b/tests/test_cli_new_features.py @@ -433,6 +433,36 @@ def test_independent_subtree_still_merges( 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") From 57af5e8ecbb9b3a389bc9c60d2bc7334748aeaef Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 14:13:45 +0100 Subject: [PATCH 028/143] fix: log the number of files materialized, not assignments --- pr_split/diff_ops/reconstructor.py | 2 +- tests/test_reconstructor.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pr_split/diff_ops/reconstructor.py b/pr_split/diff_ops/reconstructor.py index 3b70c34..68b252c 100644 --- a/pr_split/diff_ops/reconstructor.py +++ b/pr_split/diff_ops/reconstructor.py @@ -99,13 +99,13 @@ def _assigned_hunk_indices( 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} # 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: 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) diff --git a/tests/test_reconstructor.py b/tests/test_reconstructor.py index 1e9c16b..2f92385 100644 --- a/tests/test_reconstructor.py +++ b/tests/test_reconstructor.py @@ -403,12 +403,14 @@ def test_two_assignments_for_one_file_apply_both_hunks(self, mock_base: MagicMoc ), ], ) - result = materialize_group_files(parsed, group, "main") + 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: parsed = parse_diff( From ba513eb9605dca783ae45a23479422744ed93276 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 14:17:14 +0100 Subject: [PATCH 029/143] fix: keep pre-existing duplicate assignments when merging chunk groups Seeding the per-path map with a dict comprehension kept only the last existing assignment for a path, so duplicates already present in the accumulated group (chunk 1 output is stored verbatim) lost hunks. Fold every existing and incoming assignment through _merge_assignment. --- pr_split/planner/client.py | 12 ++++++------ tests/test_client.py | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/pr_split/planner/client.py b/pr_split/planner/client.py index 2c2b0d6..68531df 100644 --- a/pr_split/planner/client.py +++ b/pr_split/planner/client.py @@ -250,14 +250,14 @@ def _merge_chunk_groups(accumulated: list[Group], chunk_groups: list[Group]) -> # 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 = {a.file_path: a for a in existing.assignments} - for incoming in cg.assignments: - if incoming.file_path in by_path: - by_path[incoming.file_path] = _merge_assignment( - by_path[incoming.file_path], incoming + 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[incoming.file_path] = incoming + 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: diff --git a/tests/test_client.py b/tests/test_client.py index 2542c8d..a685f91 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1063,3 +1063,28 @@ def test_other_files_untouched(self) -> None: ("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])] From 10301b44d3551e05d26c41f94a9ba5a455371121 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 14:20:50 +0100 Subject: [PATCH 030/143] test: make the added-file duplicate-assignment test fail without the fix --- tests/test_reconstructor.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_reconstructor.py b/tests/test_reconstructor.py index 2f92385..cb87ecd 100644 --- a/tests/test_reconstructor.py +++ b/tests/test_reconstructor.py @@ -413,10 +413,14 @@ def test_two_assignments_for_one_file_apply_both_hunks(self, mock_base: MagicMoc 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", @@ -429,9 +433,9 @@ def test_duplicate_assignments_on_new_file_apply_both_hunks(self) -> None: ), GroupAssignment( file_path="n.py", - assignment_type=AssignmentType.WHOLE_FILE, - hunk_indices=[], + assignment_type=AssignmentType.PARTIAL_HUNKS, + hunk_indices=[1], ), ], ) - assert materialize_group_files(parsed, group, "main")["n.py"] == "one\ntwo\n" + assert materialize_group_files(parsed, group, "main")["n.py"] == "one\ntwo\nten\n" From 0e5721169f30d6b0b3e505970d4f651ca461ecb8 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 14:23:11 +0100 Subject: [PATCH 031/143] fix: reject LLM responses that were cut off by the output limit _call_anthropic only warned on stop_reason=max_tokens and returned the partial tool input; _call_openai never checked status=incomplete. In chunked mode assign_uncovered_hunks then dumped the missing hunks into an existing group, so a truncated plan passed validation. Raise LLMError instead so the chunk retry loop retries and single-shot planning fails with a clear message. --- pr_split/exceptions.py | 4 ++++ pr_split/logs.py | 1 + pr_split/planner/client.py | 13 +++++++++++++ tests/test_client.py | 37 ++++++++++++++++++++++++++++++++++--- 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/pr_split/exceptions.py b/pr_split/exceptions.py index f5a6958..95f4a15 100644 --- a/pr_split/exceptions.py +++ b/pr_split/exceptions.py @@ -18,6 +18,10 @@ class ErrorMsg(StrEnum): MERGE_CONFLICT = "Groups '{a}' and '{b}' modify overlapping regions in '{file}'" NO_PLAN = "No split plan found; run 'pr-split split' first" LLM_PARSE_ERROR = "Failed to parse LLM response: {detail}" + LLM_OUTPUT_TRUNCATED = ( + "LLM response was cut off before the plan was complete ({detail});" + " the partial plan cannot be trusted" + ) 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}" diff --git a/pr_split/logs.py b/pr_split/logs.py index ec302ce..3f61cd4 100644 --- a/pr_split/logs.py +++ b/pr_split/logs.py @@ -40,6 +40,7 @@ LLM_OUTPUT_TRUNCATED = ( "LLM output truncated (stop_reason: {stop_reason}), keys in partial output: {keys}" ) +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" diff --git a/pr_split/planner/client.py b/pr_split/planner/client.py index 0c933c8..d5fc753 100644 --- a/pr_split/planner/client.py +++ b/pr_split/planner/client.py @@ -131,6 +131,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 +143,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 +163,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: diff --git a/tests/test_client.py b/tests/test_client.py index 1bdfffa..76aadd8 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -641,7 +641,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 +656,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="cut off.*stop_reason=max_tokens"): + _call_anthropic("sys", "usr", settings=settings) # --------------------------------------------------------------------------- @@ -986,3 +986,34 @@ def test_large_diff_uses_chunking( result = _plan_split_with_llm(parsed, settings) assert len(result) == 1 mock_chunked.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="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 From b5725dc22c8784f226438f3da4db948bf01c8f6b Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 14:26:55 +0100 Subject: [PATCH 032/143] test: raw-string regexes and a retry-loop test for truncated output --- tests/test_client.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index 76aadd8..97f6145 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -9,7 +9,7 @@ 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.planner.client import ( RawToolOutput, _call_anthropic, @@ -656,7 +656,7 @@ def test_stop_reason_not_tool_use_raises(self, mock_cls: MagicMock) -> None: ) mock_cls.return_value.beta.messages.create.return_value = mock_response settings = _make_settings(Provider.ANTHROPIC) - with pytest.raises(LLMError, match="cut off.*stop_reason=max_tokens"): + with pytest.raises(LLMError, match=r"cut off.*stop_reason=max_tokens"): _call_anthropic("sys", "usr", settings=settings) @@ -1003,7 +1003,7 @@ def test_incomplete_status_raises(self, mock_cls: MagicMock) -> None: ) mock_cls.return_value.responses.create.return_value = mock_response settings = _make_settings(Provider.OPENAI) - with pytest.raises(LLMError, match="cut off.*max_output_tokens"): + with pytest.raises(LLMError, match=r"cut off.*max_output_tokens"): _call_openai("sys", "usr", settings=settings) @patch("pr_split.planner.client.openai.OpenAI") @@ -1017,3 +1017,18 @@ def test_completed_status_is_accepted(self, mock_cls: MagicMock) -> None: 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 From dbc99ed9b932e6963a4052b15c3f396426467816 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 16:09:25 +0100 Subject: [PATCH 033/143] fix: show UNKNOWN in status when a PR's live state cannot be fetched --- README.md | 2 +- pr_split/cli.py | 18 +++++++++-- tests/test_cli_coverage.py | 63 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 01a0b24..cdb1983 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,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 diff --git a/pr_split/cli.py b/pr_split/cli.py index 7121b06..19a19a4 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -905,6 +905,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,12 +913,23 @@ 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) + 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]: diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index 8d05885..713b759 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -25,9 +25,17 @@ _validate_inputs, app, ) -from pr_split.constants import AssignmentType +from pr_split.constants import AssignmentType, Priority from pr_split.exceptions import PRSplitError -from pr_split.schemas import Group, GroupAssignment +from pr_split.schemas import ( + BranchRecord, + GitState, + Group, + GroupAssignment, + PlanFile, + PRRecord, + SplitPlan, +) from pr_split.types_defs import ForkPRInfo runner = CliRunner() @@ -533,6 +541,57 @@ 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"]) From f0f079d88de522a92f5de1c552ee0090615f4c98 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 16:32:39 +0100 Subject: [PATCH 034/143] fix: report branch-creation failures and an unknown merge base as errors instead of tracebacks --- pr_split/cli.py | 41 +++++++++++++++-------- pr_split/git_ops/branches.py | 9 +++++ tests/test_cli_coverage.py | 60 ++++++++++++++++++++++++++++++++++ tests/test_cli_new_features.py | 55 +++++++++++++++++++++++++++++++ tests/test_git_branches.py | 14 ++++++++ 5 files changed, 166 insertions(+), 13 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index 7121b06..fc10ebe 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -59,7 +59,7 @@ push_branch, remove_worktree, ) -from .git_ops.branches import run_git +from .git_ops.branches import commit_exists, run_git 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 @@ -847,9 +847,13 @@ def split( 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: @@ -1009,6 +1013,13 @@ 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]") @@ -1032,15 +1043,19 @@ def execute( 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, - ) + 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, + ) + 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) except PRCreationError as exc: diff --git a/pr_split/git_ops/branches.py b/pr_split/git_ops/branches.py index 60bf6d4..e7bc096 100644 --- a/pr_split/git_ops/branches.py +++ b/pr_split/git_ops/branches.py @@ -33,6 +33,15 @@ def run_git_in_dir(cwd: str, *args: str) -> str: 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) diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index 8d05885..4b8150f 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -509,6 +509,66 @@ 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.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.load_plan") @patch("pr_split.cli.plan_exists", return_value=True) def test_execute_missing_merge_base_sha( diff --git a/tests/test_cli_new_features.py b/tests/test_cli_new_features.py index e668f4e..b9916cb 100644 --- a/tests/test_cli_new_features.py +++ b/tests/test_cli_new_features.py @@ -231,6 +231,61 @@ 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 TestSplitCliEnvVars: @patch("pr_split.cli.save_plan") @patch("pr_split.cli.merge_base", return_value="abc123") diff --git a/tests/test_git_branches.py b/tests/test_git_branches.py index f9acff3..7cc4793 100644 --- a/tests/test_git_branches.py +++ b/tests/test_git_branches.py @@ -11,6 +11,7 @@ branch_exists, checkout_branch, checkout_new_branch, + commit_exists, commit_files, commit_files_in_dir, create_group_branch, @@ -314,3 +315,16 @@ def test_commit_with_author(self, mock_git: MagicMock) -> None: def test_empty_file_paths_raises(self) -> None: with pytest.raises(GitOperationError, match="no file paths"): commit_files_in_dir("/tmp/wt", [], "msg") + + +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 From a6c91acee18d5537f264fe8f01b0adba66d55f49 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 16:42:10 +0100 Subject: [PATCH 035/143] fix: warn when CP-SAT returns a feasible but unproven-optimal partition at the time limit --- pr_split/logs.py | 4 ++++ pr_split/planner/partitioning.py | 15 +++++++++++- tests/test_partitioning.py | 41 ++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/pr_split/logs.py b/pr_split/logs.py index ec302ce..a53db5e 100644 --- a/pr_split/logs.py +++ b/pr_split/logs.py @@ -69,3 +69,7 @@ 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:.1f}s limit with a feasible but unproven-optimal plan " + "({units} units, {groups} groups); raise --cp-sat-timeout for a better partition" +) diff --git a/pr_split/planner/partitioning.py b/pr_split/planner/partitioning.py index 166d207..84d5097 100644 --- a/pr_split/planner/partitioning.py +++ b/pr_split/planner/partitioning.py @@ -6,6 +6,9 @@ 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 ..schemas import Group, GroupAssignment @@ -433,11 +436,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: diff --git a/tests/test_partitioning.py b/tests/test_partitioning.py index 6f1cfa0..bd98e59 100644 --- a/tests/test_partitioning.py +++ b/tests/test_partitioning.py @@ -50,12 +50,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 +111,42 @@ 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_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() From d07c3ed07cb1aaa6e5f999c7437c3c7c576dc46c Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 16:36:39 +0100 Subject: [PATCH 036/143] fix: drop groups emptied in the interactive editor instead of aborting the session --- README.md | 2 +- pr_split/cli.py | 44 ++++++++++++-- tests/test_cli_coverage.py | 107 +++++++++++++++++++++++++++++++++ tests/test_cli_new_features.py | 91 ++++++++++++++++++++++++++++ 4 files changed, 239 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 01a0b24..e297411 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ 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 diff --git a/pr_split/cli.py b/pr_split/cli.py index a0998df..b97425e 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -590,6 +590,43 @@ def _show_group_detail(groups: list[Group], group_id: str) -> None: console.print() +def _drop_empty_groups(groups: list[Group]) -> 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 own dependencies are inherited by its dependants so + ordering is preserved. + """ + dropped = {g.id: list(g.depends_on) for g in groups if not g.assignments} + if not dropped: + return groups + + def _surviving(dep: str, seen: set[str]) -> list[str]: + # Walk through chains of dropped groups to the nearest kept ancestors. + if dep not in dropped: + return [dep] + out: list[str] = [] + for parent in dropped[dep]: + if parent not in seen: + seen.add(parent) + out.extend(_surviving(parent, seen)) + return out + + kept: list[Group] = [] + for group in groups: + if group.id in dropped: + continue + deps: list[str] = [] + for dep in group.depends_on: + for candidate in _surviving(dep, set()): + if candidate not in deps and candidate != group.id: + deps.append(candidate) + kept.append(group.model_copy(update={"depends_on": deps})) + console.print(f"[yellow]Dropped empty group(s) after editing: {', '.join(dropped)}[/yellow]") + return kept + + def _interactive_edit(groups: list[Group], parsed_diff: ParsedDiff) -> list[Group]: console.print( "\n[cyan]Interactive editor. Commands:[/cyan]\n" @@ -806,10 +843,9 @@ def split( 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) + if not groups: + console.print("[red]Every group is empty after editing; nothing to split.[/red]") raise typer.Exit(1) try: dag = PlanDAG(groups) diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index db7c490..b636fa8 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -16,6 +16,7 @@ from pr_split.cli import ( _build_pr_body, + _drop_empty_groups, _handle_loc_bound_warnings, _interactive_edit, _move_assignment, @@ -611,3 +612,109 @@ def test_status_no_plan(self, mock_pe: MagicMock) -> None: def test_clean_no_plan(self, mock_pe: MagicMock) -> None: result = runner.invoke(app, ["clean"]) assert result.exit_code == 0 + + +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"] + + +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, _interactive_edit + from pr_split.diff_ops.parser import parse_diff + from pr_split.graph import PlanDAG + 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"] + + edited = _interactive_edit([g1, g2], parsed) + groups = _drop_empty_groups(edited) + + 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) == [] diff --git a/tests/test_cli_new_features.py b/tests/test_cli_new_features.py index e668f4e..e8dd59b 100644 --- a/tests/test_cli_new_features.py +++ b/tests/test_cli_new_features.py @@ -231,6 +231,97 @@ def test_strict_raises_exit(self, mock_warning: MagicMock, mock_print: MagicMock mock_warning.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") From e7d62239a2baf708f71897c9a30f259220511793 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 17:35:09 +0100 Subject: [PATCH 037/143] fix: cover exactly the parsed hunk range for WHOLE_FILE assignments, ignoring stale stored indices --- pr_split/diff_ops/reconstructor.py | 10 +++------- pr_split/schemas.py | 8 +++++--- tests/test_reconstructor.py | 22 ++++++++++++++++++++++ tests/test_schemas.py | 18 ++++++++++++++++++ tests/test_validator.py | 14 ++++++++++++++ 5 files changed, 62 insertions(+), 10 deletions(-) diff --git a/pr_split/diff_ops/reconstructor.py b/pr_split/diff_ops/reconstructor.py index 2132da3..2bb4a6f 100644 --- a/pr_split/diff_ops/reconstructor.py +++ b/pr_split/diff_ops/reconstructor.py @@ -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 = [] diff --git a/pr_split/schemas.py b/pr_split/schemas.py index 96341fc..232385c 100644 --- a/pr_split/schemas.py +++ b/pr_split/schemas.py @@ -15,11 +15,13 @@ class GroupAssignment(BaseModel): 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 even when ``hunk_indices`` - was left empty; PARTIAL_HUNKS covers exactly what it lists. + 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 sorted(set(range(hunk_count)) | set(self.hunk_indices)) + return list(range(hunk_count)) return list(self.hunk_indices) diff --git a/tests/test_reconstructor.py b/tests/test_reconstructor.py index e9495f6..e621cd5 100644 --- a/tests/test_reconstructor.py +++ b/tests/test_reconstructor.py @@ -362,6 +362,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: 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_validator.py b/tests/test_validator.py index 6ef734e..77343ff 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -12,6 +12,7 @@ validate_loc, validate_loc_bounds, validate_no_conflicts, + validate_plan, ) from pr_split.schemas import Group, GroupAssignment @@ -203,3 +204,16 @@ 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. The + # only real hunk (0) is covered once, so the plan must validate: 99 is + # not a hunk and must not be reported as an overlapping region. + parsed = parse_diff(SAMPLE_DIFF) + 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), + ] + dag = PlanDAG(groups) + assert validate_plan(groups, parsed, dag, max_loc=400) == [] From 3a6f798f8096adf13263b0315c3028ad5d9c13f3 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 18:02:30 +0100 Subject: [PATCH 038/143] fix: reject a remote-tracking --base up front instead of failing at PR creation --- pr_split/cli.py | 33 ++++++++++++++ pr_split/exceptions.py | 4 ++ tests/test_cli_coverage.py | 88 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+) diff --git a/pr_split/cli.py b/pr_split/cli.py index 7121b06..bab45b2 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -125,6 +125,36 @@ 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 _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 _validate_inputs(dev_branch: str, base: str, *, dry_run: bool = False) -> None: if not branch_exists(dev_branch): console.print(f"[red]{ErrorMsg.BRANCH_NOT_FOUND(branch=dev_branch)}[/red]") @@ -132,6 +162,7 @@ def _validate_inputs(dev_branch: str, base: str, *, dry_run: bool = False) -> No 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) @@ -1013,6 +1044,8 @@ def execute( 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) diff --git a/pr_split/exceptions.py b/pr_split/exceptions.py index f5a6958..fe8d6a3 100644 --- a/pr_split/exceptions.py +++ b/pr_split/exceptions.py @@ -9,6 +9,10 @@ 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" diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index 8d05885..f7d951e 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -74,6 +74,63 @@ 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): @@ -509,6 +566,37 @@ 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._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, + ) -> 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( From fa064a5ea66d9dbe11e245b1a98003fc7bbf7795 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 19:21:50 +0100 Subject: [PATCH 039/143] fix: resolve the plan and PR template against the repository root, not the cwd --- pr_split/cli.py | 26 +++++++++----------- pr_split/plan_store.py | 45 +++++++++++++++++++++++++--------- tests/test_cli_coverage.py | 2 +- tests/test_cli_new_features.py | 4 +-- tests/test_plan_store.py | 45 ++++++++++++++++++++++++++++++++++ 5 files changed, 93 insertions(+), 29 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index e57badc..35a87ad 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -30,8 +30,6 @@ DEFAULT_MIN_LOC, DEFAULT_PARTITION_STRATEGY, DEFAULT_STRICT_LOC_BOUNDS, - PLAN_DIR, - PLAN_FILE, AssignmentType, ChunkStrategy, PartitionStrategy, @@ -62,7 +60,7 @@ from .git_ops.branches import run_git 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 .plan_store import load_plan, plan_dir, plan_exists, plan_path, save_plan from .planner import plan_split, validate_coverage, validate_plan from .schemas import ( BranchRecord, @@ -350,11 +348,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, @@ -368,19 +368,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] @@ -849,7 +847,7 @@ def split( 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) @@ -946,9 +944,9 @@ def _cleanup_git_state(git_state: GitState) -> tuple[int, int]: 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() + saved_plan = plan_path() + if saved_plan.exists(): + saved_plan.unlink() return closed_prs, deleted_branches diff --git a/pr_split/plan_store.py b/pr_split/plan_store.py index c2c1da7..243ca7e 100644 --- a/pr_split/plan_store.py +++ b/pr_split/plan_store.py @@ -5,29 +5,50 @@ 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() + + +def plan_dir() -> Path: + return repo_root() / PLAN_DIR + + +def plan_path() -> Path: + return repo_root() / PLAN_FILE + + 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)) + plan_dir().mkdir(parents=True, exist_ok=True) + target = plan_path() + target.write_text(plan_file.model_dump_json(indent=2)) + 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()) try: - plan_file = PlanFile.model_validate_json(plan_path.read_text()) + plan_file = PlanFile.model_validate_json(target.read_text()) except (OSError, UnicodeDecodeError, ValidationError) as exc: - raise PRSplitError(ErrorMsg.PLAN_LOAD_FAILED(path=plan_path, detail=exc)) from exc - logger.info(logs.PLAN_LOADED.format(count=len(plan_file.plan.groups), path=plan_path)) + 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() + return plan_path().exists() diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index 8d05885..590aa59 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -203,7 +203,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"): diff --git a/tests/test_cli_new_features.py b/tests/test_cli_new_features.py index 5d944a1..9bf7f51 100644 --- a/tests/test_cli_new_features.py +++ b/tests/test_cli_new_features.py @@ -99,7 +99,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 +114,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"): diff --git a/tests/test_plan_store.py b/tests/test_plan_store.py index 11ca925..2dc19cb 100644 --- a/tests/test_plan_store.py +++ b/tests/test_plan_store.py @@ -146,3 +146,48 @@ def test_read_error_raises_pr_split_error( (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" / "plan.json").exists() + assert not (repo / "src" / ".pr-split").exists() + assert plan_path() == (repo / ".pr-split" / "plan.json").resolve() + + 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}" From 9332e8c7192e37f4a7e87646e22fdcd53a568bf9 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 19:51:17 +0100 Subject: [PATCH 040/143] fix: do not run repository hooks when creating sub-PR worktrees --- pr_split/git_ops/branches.py | 24 +++++++++++++++++- tests/test_git_branches.py | 48 +++++++++++++++++++++++++++++++++--- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/pr_split/git_ops/branches.py b/pr_split/git_ops/branches.py index d6005c3..1afd020 100644 --- a/pr_split/git_ops/branches.py +++ b/pr_split/git_ops/branches.py @@ -2,6 +2,8 @@ import re import subprocess +import tempfile +from pathlib import Path from loguru import logger @@ -103,13 +105,33 @@ def add_worktree(path: str, branch_name: str, start_point: str) -> None: 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 +def _no_hooks_dir() -> str: + """An empty directory to use as core.hooksPath (no hooks run).""" + path = Path(tempfile.gettempdir()) / "pr-split-no-hooks" + path.mkdir(exist_ok=True) + return str(path) + + def remove_worktree(path: str) -> None: run_git("worktree", "remove", "--force", path) diff --git a/tests/test_git_branches.py b/tests/test_git_branches.py index 6a9bde2..cd7d898 100644 --- a/tests/test_git_branches.py +++ b/tests/test_git_branches.py @@ -260,9 +260,9 @@ 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") @patch("pr_split.git_ops.branches.run_git") @patch("pr_split.git_ops.branches.branch_exists", return_value=True) @@ -406,3 +406,45 @@ 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() From a525630b9de53422acc21582c1f840fae2c74568 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 20:08:22 +0100 Subject: [PATCH 041/143] fix: let execute run when only pr-split's own plan file is modified is_worktree_clean rejected any modified tracked file, including .pr-split/plan.json that split/edit themselves write. A user who commits the plan (to share or review it) and then edits it was told the worktree is dirty by the very command meant to execute that plan. The status check now excludes the plan directory via a top-level exclude pathspec, so it also works from a subdirectory. --- pr_split/git_ops/branches.py | 11 ++++++-- tests/test_git_branches.py | 49 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/pr_split/git_ops/branches.py b/pr_split/git_ops/branches.py index 60bf6d4..7d122cd 100644 --- a/pr_split/git_ops/branches.py +++ b/pr_split/git_ops/branches.py @@ -6,7 +6,7 @@ from loguru import logger from .. import logs -from ..constants import BRANCH_PREFIX +from ..constants import BRANCH_PREFIX, PLAN_DIR from ..exceptions import GitOperationError @@ -42,7 +42,14 @@ def branch_exists(branch: str) -> bool: def is_worktree_clean() -> bool: - output = run_git("status", "--porcelain") + """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()) diff --git a/tests/test_git_branches.py b/tests/test_git_branches.py index f9acff3..c0885ca 100644 --- a/tests/test_git_branches.py +++ b/tests/test_git_branches.py @@ -1,10 +1,12 @@ from __future__ import annotations import subprocess +from pathlib import Path from unittest.mock import MagicMock, patch import pytest +from pr_split.constants import PLAN_DIR from pr_split.exceptions import GitOperationError from pr_split.git_ops.branches import ( add_worktree, @@ -314,3 +316,50 @@ def test_commit_with_author(self, mock_git: MagicMock) -> None: def test_empty_file_paths_raises(self) -> None: with pytest.raises(GitOperationError, match="no file paths"): commit_files_in_dir("/tmp/wt", [], "msg") + + +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 From 56510337edc2183779d4ade7df85d6737a521713 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 12:59:21 +0100 Subject: [PATCH 042/143] fix: pin git diff output format so user config cannot break parsing Renamed files were reported under their new path, so materializing a group ran git show : and failed. Users with diff.mnemonicPrefix, diff.context, diff.external or color.ui=always also produced diffs the parser and reconstructor could not handle. Force --no-color --no-ext-diff --no-renames -U3 and fixed a/ b/ prefixes. --- pr_split/diff_ops/parser.py | 19 ++++++++++++++++++- tests/test_diff_parser_extended.py | 12 +++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/pr_split/diff_ops/parser.py b/pr_split/diff_ops/parser.py index 850ccdc..beb6c1c 100644 --- a/pr_split/diff_ops/parser.py +++ b/pr_split/diff_ops/parser.py @@ -11,11 +11,28 @@ from ..exceptions import DiffParseError, GitOperationError from ..types_defs import DiffStats, FileSummary, HunkInfo +# 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-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-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)) result = subprocess.run( - ["git", "diff", f"{base_branch}...{dev_branch}"], + ["git", "diff", *DIFF_ARGS, f"{base_branch}...{dev_branch}"], capture_output=True, text=True, ) diff --git a/tests/test_diff_parser_extended.py b/tests/test_diff_parser_extended.py index 3eaaadc..11f6753 100644 --- a/tests/test_diff_parser_extended.py +++ b/tests/test_diff_parser_extended.py @@ -102,7 +102,17 @@ def test_extract_diff_success(self, mock_run: MagicMock) -> None: result = extract_diff("feature", "main") assert result == EXTRACT_DIFF_SAMPLE mock_run.assert_called_once_with( - ["git", "diff", "main...feature"], + [ + "git", + "diff", + "--no-color", + "--no-ext-diff", + "--no-renames", + "-U3", + "--src-prefix=a/", + "--dst-prefix=b/", + "main...feature", + ], capture_output=True, text=True, ) From 69a8fcd67c93d8cc8be30b797d748cd4a3d92ee3 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:10:34 +0100 Subject: [PATCH 043/143] fix: disable textconv so extracted hunks match raw blob content --- pr_split/diff_ops/parser.py | 3 +++ tests/test_diff_parser_extended.py | 1 + 2 files changed, 4 insertions(+) diff --git a/pr_split/diff_ops/parser.py b/pr_split/diff_ops/parser.py index beb6c1c..b2a4574 100644 --- a/pr_split/diff_ops/parser.py +++ b/pr_split/diff_ops/parser.py @@ -14,6 +14,8 @@ # 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 @@ -22,6 +24,7 @@ DIFF_ARGS: tuple[str, ...] = ( "--no-color", "--no-ext-diff", + "--no-textconv", "--no-renames", "-U3", "--src-prefix=a/", diff --git a/tests/test_diff_parser_extended.py b/tests/test_diff_parser_extended.py index 11f6753..7745048 100644 --- a/tests/test_diff_parser_extended.py +++ b/tests/test_diff_parser_extended.py @@ -107,6 +107,7 @@ def test_extract_diff_success(self, mock_run: MagicMock) -> None: "diff", "--no-color", "--no-ext-diff", + "--no-textconv", "--no-renames", "-U3", "--src-prefix=a/", From 0615a45ce4f9cf85d075cece8bd393d4327ca7af Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:00:30 +0100 Subject: [PATCH 044/143] fix: preserve missing trailing newline when reconstructing files unidiff attaches a newline to every line value and reports a missing trailing newline only via the '\ No newline at end of file' marker. The reconstructor ignored the marker, so files ending without a newline came out with one, making the sub-PR tree differ from the dev branch. --- pr_split/diff_ops/reconstructor.py | 43 ++++++++++++------ tests/test_reconstructor.py | 71 ++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 13 deletions(-) diff --git a/pr_split/diff_ops/reconstructor.py b/pr_split/diff_ops/reconstructor.py index 2132da3..504d990 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 @@ -70,6 +70,33 @@ def _get_base_file_content(file_path: str, ref: str) -> str: return result.stdout +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 apply_hunks(base_content: str, patch_file: PatchedFile, assigned_indices: list[int]) -> str: lines = base_content.splitlines(keepends=True) sorted_indices = sorted(assigned_indices, reverse=True) @@ -77,9 +104,7 @@ def apply_hunks(base_content: str, patch_file: PatchedFile, assigned_indices: li 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) @@ -103,16 +128,8 @@ def materialize_group_files( 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 + "".join(_hunk_target_lines(patch_file[idx])) for idx in indices ) continue base_content = _get_base_file_content(assignment.file_path, ref) diff --git a/tests/test_reconstructor.py b/tests/test_reconstructor.py index e9495f6..5d669e9 100644 --- a/tests/test_reconstructor.py +++ b/tests/test_reconstructor.py @@ -379,3 +379,74 @@ 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 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" From ab692d44d5eeb564eb866d570dbeb194edbbaeb8 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:48:18 +0100 Subject: [PATCH 045/143] fix: split base file content on newline only when applying hunks apply_hunks used str.splitlines, which also breaks on form feed, vertical tab, \x1c-\x1e, \x85, \u2028 and \u2029. git counts none of those as line breaks, so a file containing one had every later hunk applied at the wrong offset: a line was lost and another duplicated, with no error. split_git_lines splits on \n only, matching git's line numbering. --- pr_split/diff_ops/reconstructor.py | 18 +++++++++++++++++- tests/test_reconstructor.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/pr_split/diff_ops/reconstructor.py b/pr_split/diff_ops/reconstructor.py index 504d990..74d57a4 100644 --- a/pr_split/diff_ops/reconstructor.py +++ b/pr_split/diff_ops/reconstructor.py @@ -97,8 +97,24 @@ def _hunk_target_lines(hunk: Hunk) -> list[str]: 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] diff --git a/tests/test_reconstructor.py b/tests/test_reconstructor.py index 5d669e9..71b4480 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 @@ -450,3 +451,31 @@ def test_new_file_without_trailing_newline(self) -> None: 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 From 5281dd4c82d64c09d9c8544129296275012b1396 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:17:11 +0100 Subject: [PATCH 046/143] fix: surface malformed LLM plan output as LLMError _parse_groups raised KeyError, ValueError or pydantic ValidationError on malformed tool output. The chunk retry loop and the refinement fallback only catch LLMError, so a single bad response aborted the whole split with a traceback instead of retrying or keeping the previous plan. --- pr_split/planner/client.py | 10 ++++++ tests/test_client.py | 69 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/pr_split/planner/client.py b/pr_split/planner/client.py index 0c933c8..1709ea7 100644 --- a/pr_split/planner/client.py +++ b/pr_split/planner/client.py @@ -204,6 +204,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 = [ diff --git a/tests/test_client.py b/tests/test_client.py index 1bdfffa..ddc8d78 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -165,6 +165,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=[ @@ -464,6 +511,28 @@ 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() + 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) From cc1b15aae464fc171ce0a100661c8dfe5ea63118 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:30:52 +0100 Subject: [PATCH 047/143] fix: report cyclic plans from the planner instead of crashing score_plan is called by plan_split before validate_plan ever runs, so a cyclic plan (from LLM output or a backend) hit dag.iter_ready() and died with a raw graphlib.CycleError traceback. score_plan now validates the DAG first and raises PlanValidationError(CYCLE_DETECTED). split also never caught PRSplitError from plan_split, so every planner failure (LLM parse errors, missing ortools, cycles) printed a traceback. It now prints the message and exits 1 like the other validation paths. --- pr_split/cli.py | 6 +++++- pr_split/planner/scoring.py | 1 + tests/test_cli_new_features.py | 39 +++++++++++++++++++++++++++++++++- tests/test_scoring.py | 18 ++++++++++++++++ 4 files changed, 62 insertions(+), 2 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index 7121b06..7ef4087 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -787,7 +787,11 @@ def split( except (ValidationError, ValueError) as exc: console.print(f"[red]{exc}[/red]") raise typer.Exit(1) from exc - groups = plan_split(parsed_diff, settings) + try: + groups = plan_split(parsed_diff, settings) + except PRSplitError as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Exit(1) from exc logger.info(logs.VALIDATING_PLAN) dag = PlanDAG(groups) diff --git a/pr_split/planner/scoring.py b/pr_split/planner/scoring.py index 6a09da6..75b10f1 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 = ( diff --git a/tests/test_cli_new_features.py b/tests/test_cli_new_features.py index e668f4e..d666905 100644 --- a/tests/test_cli_new_features.py +++ b/tests/test_cli_new_features.py @@ -14,7 +14,7 @@ app, ) from pr_split.constants import AssignmentType -from pr_split.exceptions import GitOperationError, PRSplitError +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, @@ -332,3 +332,40 @@ 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 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() diff --git a/tests/test_scoring.py b/tests/test_scoring.py index 5ac3004..123305b 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,18 @@ 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) From bcc1f7d6c088efcaa1fe60791d82dc3fd3b70d39 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:38:52 +0100 Subject: [PATCH 048/143] test: malformed LLM assignment_type reaches the user as a message End-to-end split run with the planner returning an invalid assignment_type: with the LLMError translation this branch now stacks on, the CLI prints the parse error and exits 1 instead of a traceback. --- tests/test_cli_new_features.py | 45 ++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/test_cli_new_features.py b/tests/test_cli_new_features.py index d666905..d8bdd51 100644 --- a/tests/test_cli_new_features.py +++ b/tests/test_cli_new_features.py @@ -369,3 +369,48 @@ def test_planner_error_is_reported_not_raised( 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 @@\n-x\n+y\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"], + 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() From f078879c8ab10b44e5bfe18a2e1a0690ad09f240 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:53:44 +0100 Subject: [PATCH 049/143] fix: report plan validation failures from split as messages The first validate_plan call in split was outside any PRSplitError handler, so a coverage gap, overlap, LOC mismatch or cross-group conflict in the planner's output escaped as a traceback. --- pr_split/cli.py | 10 ++++++-- tests/test_cli_new_features.py | 43 ++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index 7ef4087..d13db8b 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -794,8 +794,14 @@ def split( 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) diff --git a/tests/test_cli_new_features.py b/tests/test_cli_new_features.py index d8bdd51..e25d242 100644 --- a/tests/test_cli_new_features.py +++ b/tests/test_cli_new_features.py @@ -414,3 +414,46 @@ def test_invalid_assignment_type_is_reported_not_raised( 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() From 09698f7728d959f08c70c099c30819a95e8f819a Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:09:21 +0100 Subject: [PATCH 050/143] fix: make branch cleanup reliable on failure and in clean - A group that failed after add_worktree left its branch behind because only successful groups were cleaned up; delete failed groups' branches too - delete_branch(remote=True) skipped the remote deletion when the local branch could not be deleted (checked out, or already removed); always attempt the remote deletion and re-raise the local error afterwards - clean removed plan.json even when some PRs or branches could not be cleaned up, losing the records needed to retry; keep the plan and exit non-zero when cleanup is incomplete --- pr_split/cli.py | 17 +++++++++++++---- pr_split/git_ops/branches.py | 14 ++++++++++++-- pr_split/logs.py | 4 ++++ tests/test_cli_helpers.py | 27 +++++++++++++++++++++++++++ tests/test_cli_new_features.py | 2 ++ tests/test_git_branches.py | 14 ++++++++++++++ 6 files changed, 72 insertions(+), 6 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index d9d79e8..b780398 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -342,11 +342,16 @@ def _create_branches_and_commits( break if errors: - for record in results.values(): + # add_worktree creates the branch before the commit step, so a + # group that failed mid-way has a branch too; include those. + created = [record.branch_name for record in results.values()] + failed = [f"{BRANCH_PREFIX}{namespace}/{gid}" for gid, _ in errors] + for branch_name in created + failed: try: - delete_branch(record.branch_name) + delete_branch(branch_name) except PRSplitError as exc: - logger.warning(f"Could not clean up branch {record.branch_name}: {exc}") + if branch_name in created: + logger.warning(f"Could not clean up branch {branch_name}: {exc}") error_details = "\n".join([f"- {gid}: {exc}" for gid, exc in errors]) raise PRSplitError(f"{len(errors)} branch(es) failed:\n{error_details}") finally: @@ -959,8 +964,9 @@ def _cleanup_git_state(git_state: GitState) -> tuple[int, int]: except PRSplitError: logger.warning(f"Could not delete branch {branch_record.branch_name}") + complete = closed_prs == len(git_state.prs) and deleted_branches == len(git_state.branches) plan_path = Path(PLAN_FILE) - if plan_path.exists(): + if complete and plan_path.exists(): plan_path.unlink() return closed_prs, deleted_branches @@ -978,6 +984,9 @@ def clean() -> None: 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)) diff --git a/pr_split/git_ops/branches.py b/pr_split/git_ops/branches.py index 60bf6d4..577799b 100644 --- a/pr_split/git_ops/branches.py +++ b/pr_split/git_ops/branches.py @@ -71,10 +71,20 @@ def push_branch(branch: str) -> None: def delete_branch(branch: str, *, remote: bool = False) -> None: - run_git("branch", "-D", branch) - logger.info(logs.BRANCH_DELETED.format(branch=branch)) + local_error: GitOperationError | None = None + try: + run_git("branch", "-D", branch) + logger.info(logs.BRANCH_DELETED.format(branch=branch)) + except GitOperationError as exc: + if not remote: + raise + # The local branch may be checked out or already gone; still remove + # the remote branch so the cleanup is not left half done. + local_error = exc if remote: run_git("push", "origin", "--delete", branch) + if local_error is not None: + raise local_error def merge_base(ref_a: str, ref_b: str) -> str: diff --git a/pr_split/logs.py b/pr_split/logs.py index ad75865..3c21c6b 100644 --- a/pr_split/logs.py +++ b/pr_split/logs.py @@ -20,6 +20,10 @@ BRANCH_DELETED = "Deleted branch {branch}" PR_CLOSED = "Closed PR #{number}" 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_FORK_BRANCH = "Fetching branch {branch} from fork {fork}" AUTHOR_PRESERVED = "Preserving author: {author}" diff --git a/tests/test_cli_helpers.py b/tests/test_cli_helpers.py index 01b6d8b..fa5f905 100644 --- a/tests/test_cli_helpers.py +++ b/tests/test_cli_helpers.py @@ -238,6 +238,33 @@ 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"} + + class TestCreateBranchesAndCommitsStacked: def _stacked_groups(self) -> list[Group]: return [_group("pr-2", "feat: base"), _group("pr-3", "feat: top", ["pr-2"])] diff --git a/tests/test_cli_new_features.py b/tests/test_cli_new_features.py index e668f4e..14069f5 100644 --- a/tests/test_cli_new_features.py +++ b/tests/test_cli_new_features.py @@ -179,6 +179,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: diff --git a/tests/test_git_branches.py b/tests/test_git_branches.py index f9acff3..434185e 100644 --- a/tests/test_git_branches.py +++ b/tests/test_git_branches.py @@ -130,6 +130,20 @@ 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() + class TestDeriveSplitNamespace: def test_simple_branch(self) -> None: From 1f73aa3c5730327bf06fe5325d547b0301798fae Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:21:25 +0100 Subject: [PATCH 051/143] fix: delete a failed group's branch only when this run created it Move the failed-group branch deletion into the worker, after add_worktree has succeeded. If add_worktree itself fails it restores any pre-existing branch of that name, and that branch must not be removed. --- pr_split/cli.py | 33 ++++++++++++++++++++++----------- tests/test_cli_helpers.py | 26 ++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index b780398..aaac0f8 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -202,6 +202,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, @@ -236,11 +243,18 @@ def _create_single_branch_and_commit( group.title, author=author, ) - finally: + 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, @@ -342,16 +356,13 @@ def _create_branches_and_commits( break if errors: - # add_worktree creates the branch before the commit step, so a - # group that failed mid-way has a branch too; include those. - created = [record.branch_name for record in results.values()] - failed = [f"{BRANCH_PREFIX}{namespace}/{gid}" for gid, _ in errors] - for branch_name in created + failed: + # Failed groups already removed their own branch inside the + # worker; only the successful ones remain to roll back. + for record in results.values(): try: - delete_branch(branch_name) + delete_branch(record.branch_name) except PRSplitError as exc: - if branch_name in created: - logger.warning(f"Could not clean up branch {branch_name}: {exc}") + logger.warning(f"Could not clean up branch {record.branch_name}: {exc}") error_details = "\n".join([f"- {gid}: {exc}" for gid, exc in errors]) raise PRSplitError(f"{len(errors)} branch(es) failed:\n{error_details}") finally: diff --git a/tests/test_cli_helpers.py b/tests/test_cli_helpers.py index fa5f905..911bfe2 100644 --- a/tests/test_cli_helpers.py +++ b/tests/test_cli_helpers.py @@ -264,6 +264,32 @@ def commit(worktree_path: str, *args: object, **kwargs: object) -> str: 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 TestCreateBranchesAndCommitsStacked: def _stacked_groups(self) -> list[Group]: From 61fd281155110666526a2ac9125f888f4fcde10f Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 18:47:52 +0100 Subject: [PATCH 052/143] fix: clean skips already merged or closed PRs and reports why a close failed --- pr_split/cli.py | 14 ++++++-- pr_split/logs.py | 1 + tests/test_cli_new_features.py | 64 ++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index aaac0f8..2e65e0c 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -960,11 +960,21 @@ def status() -> None: def _cleanup_git_state(git_state: GitState) -> tuple[int, int]: closed_prs = 0 for pr_record in git_state.prs: + # 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 diff --git a/pr_split/logs.py b/pr_split/logs.py index 3c21c6b..9465218 100644 --- a/pr_split/logs.py +++ b/pr_split/logs.py @@ -19,6 +19,7 @@ CLEANING_BRANCHES = "Cleaning up pr-split branches" BRANCH_DELETED = "Deleted branch {branch}" PR_CLOSED = "Closed PR #{number}" +PR_ALREADY_DONE = "PR #{number} is already {state}, nothing to close" CLEAN_COMPLETE = "Cleanup complete: {branches} branches, {prs} PRs" CLEAN_INCOMPLETE = ( "Some PRs or branches could not be cleaned up; the plan file was kept" diff --git a/tests/test_cli_new_features.py b/tests/test_cli_new_features.py index 14069f5..c430c40 100644 --- a/tests/test_cli_new_features.py +++ b/tests/test_cli_new_features.py @@ -122,6 +122,7 @@ def test_custom_template_invalid_placeholder( class TestCleanupGitState: + @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 +133,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 +154,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 +165,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")] @@ -334,3 +338,63 @@ 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 TestCleanupSkipsFinishedPrs: + @patch("pr_split.cli.get_pr_state") + @patch("pr_split.cli.shutil.rmtree") + @patch("pr_split.cli.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.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() From 46453bb71224964d0e8787bb8d22602a77af9ad4 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 18:53:29 +0100 Subject: [PATCH 053/143] fix: count a branch already deleted by merge as cleaned up instead of failing clean --- pr_split/git_ops/branches.py | 37 +++++++++++++++--- pr_split/logs.py | 1 + tests/test_cli_new_features.py | 30 ++++++++++++++ tests/test_git_branches.py | 71 ++++++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 5 deletions(-) diff --git a/pr_split/git_ops/branches.py b/pr_split/git_ops/branches.py index 577799b..0861dfb 100644 --- a/pr_split/git_ops/branches.py +++ b/pr_split/git_ops/branches.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import re import subprocess @@ -10,11 +11,18 @@ 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,6 +35,7 @@ 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()) @@ -70,19 +79,37 @@ def push_branch(branch: str) -> None: run_git("push", "--force-with-lease", "-u", "origin", branch) +_LOCAL_BRANCH_MISSING = "not found" +_REMOTE_REF_MISSING = "remote ref does not exist" + + def delete_branch(branch: str, *, remote: bool = False) -> None: + """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 not remote: + if _LOCAL_BRANCH_MISSING in str(exc): + logger.info(logs.BRANCH_ALREADY_GONE.format(branch=branch, where=" locally")) + elif not remote: raise - # The local branch may be checked out or already gone; still remove - # the remote branch so the cleanup is not left half done. - local_error = exc + 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 diff --git a/pr_split/logs.py b/pr_split/logs.py index 9465218..6e773c9 100644 --- a/pr_split/logs.py +++ b/pr_split/logs.py @@ -18,6 +18,7 @@ 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" CLEAN_COMPLETE = "Cleanup complete: {branches} branches, {prs} PRs" diff --git a/tests/test_cli_new_features.py b/tests/test_cli_new_features.py index c430c40..81716ed 100644 --- a/tests/test_cli_new_features.py +++ b/tests/test_cli_new_features.py @@ -398,3 +398,33 @@ def test_close_failure_warning_includes_the_reason( 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.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() diff --git a/tests/test_git_branches.py b/tests/test_git_branches.py index 434185e..11f9cfd 100644 --- a/tests/test_git_branches.py +++ b/tests/test_git_branches.py @@ -1,6 +1,7 @@ from __future__ import annotations import subprocess +from pathlib import Path from unittest.mock import MagicMock, patch import pytest @@ -144,6 +145,44 @@ def test_local_failure_without_remote_raises_immediately(self, mock_git: MagicMo 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: @@ -328,3 +367,35 @@ def test_commit_with_author(self, mock_git: MagicMock) -> None: def test_empty_file_paths_raises(self) -> None: with pytest.raises(GitOperationError, match="no file paths"): 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") From 73582f361be7622044fb0ad09edb2b3be7124cc9 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 20:29:10 +0100 Subject: [PATCH 054/143] fix: write the plan file atomically so a crash mid-save cannot destroy it save_plan truncated and rewrote .pr-split/plan.json in place. The file is large (it embeds the full raw diff) and the most important save happens right after PRs are created; a crash or full disk mid-write left truncated JSON and wiped the previous good plan, orphaning the just-created PRs and branches with no record for clean/status/merge to act on. The plan is now written to a temp file in the same directory and os.replace()d over the target, with the temp file cleaned up on failure. --- pr_split/plan_store.py | 16 +++++++++++++++- tests/test_plan_store.py | 37 ++++++++++++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/pr_split/plan_store.py b/pr_split/plan_store.py index 243ca7e..28333f8 100644 --- a/pr_split/plan_store.py +++ b/pr_split/plan_store.py @@ -1,3 +1,4 @@ +import os from pathlib import Path from loguru import logger @@ -32,9 +33,22 @@ def plan_path() -> Path: def save_plan(plan_file: PlanFile) -> None: + """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. + """ plan_dir().mkdir(parents=True, exist_ok=True) target = plan_path() - target.write_text(plan_file.model_dump_json(indent=2)) + tmp = target.with_name(target.name + ".tmp") + try: + tmp.write_text(plan_file.model_dump_json(indent=2)) + os.replace(tmp, target) + finally: + tmp.unlink(missing_ok=True) logger.info(logs.SAVING_PLAN.format(path=target)) diff --git a/tests/test_plan_store.py b/tests/test_plan_store.py index 2dc19cb..a89613c 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_dir, plan_exists, plan_path, save_plan from pr_split.schemas import ( BranchRecord, GitState, @@ -191,3 +191,38 @@ def test_template_is_read_from_the_repo_root( (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()) + leftovers = [p.name for p in plan_dir().iterdir() if p.name != "plan.json"] + 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 From a5ea32c910805254632a2a83f3963d65b08aebc1 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 20:33:42 +0100 Subject: [PATCH 055/143] fix: end a failed push/PR creation with a clean message instead of a traceback split and execute caught PRCreationError only to save the partial state, then re-raised it bare; nothing above catches it, so this fully expected and already-handled failure mode (state saved, exit 1) ended with a full rich traceback. Both handlers now print the error and a note that the created branches/PRs were saved to the plan file, then exit 1, matching every other error path. --- pr_split/cli.py | 9 ++- tests/test_cli_coverage.py | 118 ++++++++++++++++++++++++++++++++++++- 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index fc10ebe..1a16831 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -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 @@ -863,7 +864,9 @@ def split( git_state=GitState(branches=branch_records, prs=exc.pr_records), ) ) - raise + 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 if stack: _link_stacks(dag, pr_records) @@ -1065,7 +1068,9 @@ def execute( git_state=GitState(branches=branch_records, prs=exc.pr_records), ) ) - raise + 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 if plan.stacked: _link_stacks(PlanDAG(plan.groups), pr_records) diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index 4b8150f..371d3ac 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -26,7 +26,7 @@ app, ) from pr_split.constants import AssignmentType -from pr_split.exceptions import PRSplitError +from pr_split.exceptions import PRCreationError, PRSplitError from pr_split.schemas import Group, GroupAssignment from pr_split.types_defs import ForkPRInfo @@ -597,3 +597,119 @@ def test_status_no_plan(self, mock_pe: MagicMock) -> None: def test_clean_no_plan(self, mock_pe: MagicMock) -> None: result = runner.invoke(app, ["clean"]) assert result.exit_code == 0 + + +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", [] + ) + + 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() From de40416d3ab4e79bbe4a2c241ca18582a7c6804d Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 20:35:59 +0100 Subject: [PATCH 056/143] fix: retry execute after a failed push instead of forcing a full re-plan A push/PR-creation failure leaves the plan with branches recorded and no PRs. execute then refused to run ('use clean first') and clean deletes the plan file entirely, so recovering from a transient network failure meant regenerating the whole plan - for the LLM backend, paying for planning again and possibly getting a different split. Branch creation is idempotent (add_worktree recreates an existing branch), so execute now retries when the saved state has branches but no PRs, and only refuses when PRs already exist. --- pr_split/cli.py | 60 +++++++++++- tests/test_cli_coverage.py | 186 ++++++++++++++++++++++++++++++++++++- 2 files changed, 241 insertions(+), 5 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index 1a16831..d543c4d 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -45,7 +45,13 @@ 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, branch_exists, @@ -967,6 +973,44 @@ def clean() -> None: 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.", ) @@ -999,9 +1043,18 @@ def execute( 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]") + if plan_file.git_state.prs: + console.print("[red]This plan already has PRs. Use 'pr-split clean' first.[/red]") raise typer.Exit(1) + if 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( @@ -1046,6 +1099,7 @@ def execute( typer.confirm("Proceed with creating branches and PRs?", abort=True) namespace = derive_split_namespace(plan.dev_branch_arg or plan.dev_branch) + _delete_stale_recorded_branches(plan_file.git_state.branches, plan.groups, namespace) try: branch_records = _create_branches_and_commits( plan.groups, diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index 371d3ac..5db3818 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -490,13 +490,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) @@ -599,6 +600,187 @@ def test_clean_no_plan(self, mock_pe: MagicMock) -> None: 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 + + result = runner.invoke(app, ["execute"]) + + assert result.exit_code == 0 + 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_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() + + 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() + + 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.""" From fab0b04f6fe2079a09499cf150f27152de6aaf12 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 12:58:04 +0100 Subject: [PATCH 057/143] fix: reject unknown hunks and dependencies during plan validation A plan referencing a hunk index outside the diff passed validate_coverage and crashed later with IndexError inside a worker thread, after branch creation had started. A depends_on naming a non-existent group raised a raw KeyError while building the DAG. Both are now PlanValidationErrors raised before any git state is touched. --- pr_split/exceptions.py | 2 ++ pr_split/graph.py | 2 ++ pr_split/planner/validator.py | 6 ++++++ tests/test_graph.py | 4 ++++ tests/test_validator.py | 18 ++++++++++++++++++ 5 files changed, 32 insertions(+) diff --git a/pr_split/exceptions.py b/pr_split/exceptions.py index 756972c..b5eb636 100644 --- a/pr_split/exceptions.py +++ b/pr_split/exceptions.py @@ -14,6 +14,8 @@ class ErrorMsg(StrEnum): 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_DEPENDENCY = "Group '{group}' depends on unknown group '{dep}'" 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" diff --git a/pr_split/graph.py b/pr_split/graph.py index 447d863..8d7a1de 100644 --- a/pr_split/graph.py +++ b/pr_split/graph.py @@ -15,6 +15,8 @@ def __init__(self, groups: list[Group]) -> None: self._parents: dict[str, list[str]] = {g.id: list(g.depends_on) for g in groups} for g in groups: for dep in g.depends_on: + 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/planner/validator.py b/pr_split/planner/validator.py index 0995af9..9897418 100644 --- a/pr_split/planner/validator.py +++ b/pr_split/planner/validator.py @@ -26,6 +26,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])) diff --git a/tests/test_graph.py b/tests/test_graph.py index 2a2b656..672dfdc 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) diff --git a/tests/test_validator.py b/tests/test_validator.py index 8dc4b82..eb8e990 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -80,6 +80,24 @@ 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"ghost\.py\[0\]"): + validate_coverage(groups, parsed) + def test_duplicate_assignment_raises(self) -> None: parsed = parse_diff(SAMPLE_DIFF) groups = [ From 8e0b5effd3522986edc5689b2043006fa55008dc Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:09:40 +0100 Subject: [PATCH 058/143] fix: reject assignments for files outside the diff before hunk expansion --- pr_split/exceptions.py | 1 + pr_split/planner/validator.py | 4 ++++ tests/test_validator.py | 13 ++++++++++++- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/pr_split/exceptions.py b/pr_split/exceptions.py index b5eb636..e424fb7 100644 --- a/pr_split/exceptions.py +++ b/pr_split/exceptions.py @@ -15,6 +15,7 @@ class ErrorMsg(StrEnum): 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}'" LOC_MISMATCH = "Total LOC {actual} does not match diff LOC {expected}" MERGE_CONFLICT = "Groups '{a}' and '{b}' modify overlapping regions in '{file}'" diff --git a/pr_split/planner/validator.py b/pr_split/planner/validator.py index 9897418..02762a1 100644 --- a/pr_split/planner/validator.py +++ b/pr_split/planner/validator.py @@ -14,6 +14,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: diff --git a/tests/test_validator.py b/tests/test_validator.py index eb8e990..83e4dcd 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -95,7 +95,18 @@ def test_unknown_file_raises(self) -> None: _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"ghost\.py\[0\]"): + 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: From cf2a183811eee897047691c205115fea17373bc9 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:22:03 +0100 Subject: [PATCH 059/143] fix: validate plan dependencies in execute before creating branches --- pr_split/cli.py | 4 ++++ tests/test_cli_coverage.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/pr_split/cli.py b/pr_split/cli.py index d0f6313..f2abf38 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -1064,6 +1064,10 @@ def execute( parsed_diff = parse_diff(plan.raw_diff) try: + # 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) except PlanValidationError as exc: console.print(f"[red]{exc}[/red]") diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index 00cb397..ef028e1 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -583,6 +583,36 @@ def test_execute_missing_merge_base_sha( result = runner.invoke(app, ["execute"]) assert result.exit_code != 0 + @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, + ) -> 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 From 31229974c07ba4ec0f8cbe52ec83e45fe584f8fe Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 17:41:41 +0100 Subject: [PATCH 060/143] fix: reject duplicate group ids and guard merge against a malformed saved plan --- pr_split/cli.py | 9 +++++- pr_split/exceptions.py | 1 + pr_split/graph.py | 8 +++++- tests/test_cli_new_features.py | 50 ++++++++++++++++++++++++++++++++++ tests/test_graph.py | 10 +++++++ 5 files changed, 76 insertions(+), 2 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index f2abf38..4d0c18f 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -1179,7 +1179,14 @@ def merge_all( console.print("[yellow]No PRs found in plan. Nothing to merge.[/yellow]") raise typer.Exit(0) - dag = PlanDAG(plan.groups) + # 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() diff --git a/pr_split/exceptions.py b/pr_split/exceptions.py index e424fb7..d7d9c2d 100644 --- a/pr_split/exceptions.py +++ b/pr_split/exceptions.py @@ -17,6 +17,7 @@ class ErrorMsg(StrEnum): 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" 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" diff --git a/pr_split/graph.py b/pr_split/graph.py index 8d7a1de..2704515 100644 --- a/pr_split/graph.py +++ b/pr_split/graph.py @@ -10,7 +10,13 @@ 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} for g in groups: diff --git a/tests/test_cli_new_features.py b/tests/test_cli_new_features.py index 149f75c..e43363c 100644 --- a/tests/test_cli_new_features.py +++ b/tests/test_cli_new_features.py @@ -21,7 +21,9 @@ GitState, Group, GroupAssignment, + PlanFile, PRRecord, + SplitPlan, ) runner = CliRunner() @@ -385,3 +387,51 @@ 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 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() diff --git a/tests/test_graph.py b/tests/test_graph.py index 672dfdc..3f9e2b6 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -141,3 +141,13 @@ 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"] From 53ca91ef3f566f0840a18a3d68889d4a45b6b36e Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 17:58:24 +0100 Subject: [PATCH 061/143] fix: treat a dependency listed twice as one edge so stacked children are not demoted to merge nodes --- pr_split/graph.py | 9 +++++++-- tests/test_graph.py | 13 +++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/pr_split/graph.py b/pr_split/graph.py index 2704515..df8d25c 100644 --- a/pr_split/graph.py +++ b/pr_split/graph.py @@ -18,9 +18,14 @@ def __init__(self, groups: list[Group]) -> None: 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) diff --git a/tests/test_graph.py b/tests/test_graph.py index 3f9e2b6..65dcc39 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -151,3 +151,16 @@ def test_duplicate_group_id_is_a_validation_error(self) -> None: 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"]] From 2d25193a5a7ab3c2feabab47e77d4d37c394fbc1 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 18:03:51 +0100 Subject: [PATCH 062/143] fix: count a dependency listed twice as one edge in the plan objective --- pr_split/planner/scoring.py | 3 ++- tests/test_scoring.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/pr_split/planner/scoring.py b/pr_split/planner/scoring.py index 6a09da6..1b816c2 100644 --- a/pr_split/planner/scoring.py +++ b/pr_split/planner/scoring.py @@ -39,7 +39,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/tests/test_scoring.py b/tests/test_scoring.py index 5ac3004..5c29434 100644 --- a/tests/test_scoring.py +++ b/tests/test_scoring.py @@ -66,3 +66,18 @@ 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 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 From 3ba90352cddc596a9ba7b65c5bdb75ad1f6d17d9 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:50:19 +0100 Subject: [PATCH 063/143] fix: preserve CRLF line endings in reconstructed files git diff and git show were run with text=True, whose universal-newline translation strips the CR from CRLF files, and the worktree write used the platform newline. A one-line change to a CRLF file therefore became a whole-file line-ending rewrite in every sub-PR. Capture git output as bytes and decode it, and write reconstructed files with newline="". --- pr_split/cli.py | 3 +- pr_split/diff_ops/parser.py | 8 ++-- pr_split/diff_ops/reconstructor.py | 7 ++-- tests/test_cli_helpers.py | 25 ++++++++++++ tests/test_diff_parser_extended.py | 20 ++++++++-- tests/test_reconstructor.py | 62 ++++++++++++++++++++++++++++-- 6 files changed, 111 insertions(+), 14 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index 2770ca9..1d33689 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -225,7 +225,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", newline="") elif p.exists(): p.unlink() diff --git a/pr_split/diff_ops/parser.py b/pr_split/diff_ops/parser.py index b2a4574..adb020e 100644 --- a/pr_split/diff_ops/parser.py +++ b/pr_split/diff_ops/parser.py @@ -34,14 +34,16 @@ 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", *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()) + return result.stdout.decode("utf-8") def parse_diff(raw_diff: str) -> ParsedDiff: diff --git a/pr_split/diff_ops/reconstructor.py b/pr_split/diff_ops/reconstructor.py index 74d57a4..1ea7a6b 100644 --- a/pr_split/diff_ops/reconstructor.py +++ b/pr_split/diff_ops/reconstructor.py @@ -60,14 +60,15 @@ 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") NO_NEWLINE_MARKER = "\\" diff --git a/tests/test_cli_helpers.py b/tests/test_cli_helpers.py index 01b6d8b..2da1f9b 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 @@ -574,3 +575,27 @@ 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 + _create_branches_and_commits([_group("pr-1", "t")], MagicMock(), "main", "sha", "ns") + assert written["c.txt"] == b"a\r\nb\r\n" diff --git a/tests/test_diff_parser_extended.py b/tests/test_diff_parser_extended.py index 7745048..77d9aba 100644 --- a/tests/test_diff_parser_extended.py +++ b/tests/test_diff_parser_extended.py @@ -97,7 +97,7 @@ 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 @@ -115,7 +115,6 @@ def test_extract_diff_success(self, mock_run: MagicMock) -> None: "main...feature", ], capture_output=True, - text=True, ) @patch("pr_split.diff_ops.parser.subprocess.run") @@ -123,8 +122,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") @@ -134,3 +133,16 @@ 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 diff --git a/tests/test_reconstructor.py b/tests/test_reconstructor.py index 71b4480..f8fe9e2 100644 --- a/tests/test_reconstructor.py +++ b/tests/test_reconstructor.py @@ -115,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") @@ -183,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 == "" @@ -479,3 +479,59 @@ def test_form_feed_in_earlier_line_does_not_shift_hunk(self) -> None: 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" From 03957070a2c64ccf67bc7a18dd74887d4f35f7e2 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 14:15:03 +0100 Subject: [PATCH 064/143] test: assert newline='' on worktree writes and cover a bare CR base file --- tests/test_cli_helpers.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_cli_helpers.py b/tests/test_cli_helpers.py index 2da1f9b..3bc2047 100644 --- a/tests/test_cli_helpers.py +++ b/tests/test_cli_helpers.py @@ -597,5 +597,10 @@ def capture(cwd: str, file_paths: list[str], message: str, **kwargs: object) -> return "sha1" mock_commit.side_effect = capture - _create_branches_and_commits([_group("pr-1", "t")], MagicMock(), "main", "sha", "ns") + # 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) From 5d2f16b70d8f648fad0a7385cc2a2242a891d9b5 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 14:51:53 +0100 Subject: [PATCH 065/143] fix: keep non-ASCII paths unescaped in the extracted diff git quotes and octal-escapes non-ASCII paths by default (core.quotePath). unidiff then fails on new/deleted files ('Target without source') and, for modified files, records the literal quoted string as the path so materialization asks git for a path that does not exist. Run git diff with core.quotePath=false. --- pr_split/diff_ops/parser.py | 5 ++++- tests/test_diff_parser_extended.py | 35 ++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/pr_split/diff_ops/parser.py b/pr_split/diff_ops/parser.py index adb020e..7ef94ba 100644 --- a/pr_split/diff_ops/parser.py +++ b/pr_split/diff_ops/parser.py @@ -38,7 +38,10 @@ def extract_diff(dev_branch: str, base_branch: str) -> str: # turns CRLF into LF and would make every sub-PR rewrite the file's # line endings. result = subprocess.run( - ["git", "diff", *DIFF_ARGS, 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, ) if result.returncode != 0: diff --git a/tests/test_diff_parser_extended.py b/tests/test_diff_parser_extended.py index 77d9aba..f797a0f 100644 --- a/tests/test_diff_parser_extended.py +++ b/tests/test_diff_parser_extended.py @@ -1,6 +1,8 @@ from __future__ import annotations +import os import subprocess +from pathlib import Path from unittest.mock import MagicMock, patch import pytest @@ -104,6 +106,8 @@ def test_extract_diff_success(self, mock_run: MagicMock) -> None: mock_run.assert_called_once_with( [ "git", + "-c", + "core.quotePath=false", "diff", "--no-color", "--no-ext-diff", @@ -146,3 +150,34 @@ def test_crlf_diff_is_returned_verbatim(self, mock_run: MagicMock) -> None: assert "\r\n" in result assert result == raw.decode() assert mock_run.call_args.kwargs.get("text") is not True + + +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"] From fde97b3baa6fd591dd6dd2577e0791dc8b0e1cc7 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 15:19:41 +0100 Subject: [PATCH 066/143] fix: decode C-quoted paths from git diff so files are not written under a quoted name --- pr_split/diff_ops/parser.py | 45 +++++++++++++++++++ tests/test_diff_parser_extended.py | 72 +++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/pr_split/diff_ops/parser.py b/pr_split/diff_ops/parser.py index 7ef94ba..282977a 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 @@ -49,11 +50,55 @@ def extract_diff(dev_branch: str, base_branch: str) -> str: return result.stdout.decode("utf-8") +_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 + out = bytearray() + pos = 0 + body = path[1:-1] + for match in _C_ESCAPE_RE.finditer(body): + out += body[pos : match.start()].encode("utf-8") + 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") + return bytes(out).decode("utf-8", errors="surrogateescape") + + def parse_diff(raw_diff: str) -> ParsedDiff: try: patch_set = PatchSet(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) return ParsedDiff(patch_set=patch_set, raw_diff=raw_diff) diff --git a/tests/test_diff_parser_extended.py b/tests/test_diff_parser_extended.py index f797a0f..4036697 100644 --- a/tests/test_diff_parser_extended.py +++ b/tests/test_diff_parser_extended.py @@ -7,7 +7,7 @@ import pytest -from pr_split.diff_ops.parser import extract_diff, parse_diff +from pr_split.diff_ops.parser import extract_diff, parse_diff, unquote_git_path from pr_split.exceptions import GitOperationError SAMPLE_DIFF = ( @@ -181,3 +181,73 @@ def git(*args: str) -> str: 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"), + ('""', ""), + ('"', '"'), + ], + ) + 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) From e1accdcc65975158a4383c39cf562c2284a998d2 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 15:42:45 +0100 Subject: [PATCH 067/143] fix: parse C-quoted paths that contain spaces without phantom files or parse errors --- pr_split/diff_ops/parser.py | 36 +++++++++- tests/test_diff_parser_extended.py | 106 ++++++++++++++++++++++++++++- 2 files changed, 140 insertions(+), 2 deletions(-) diff --git a/pr_split/diff_ops/parser.py b/pr_split/diff_ops/parser.py index 282977a..1368d41 100644 --- a/pr_split/diff_ops/parser.py +++ b/pr_split/diff_ops/parser.py @@ -91,9 +91,43 @@ def unquote_git_path(path: str) -> str: 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 _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) -> ParsedDiff: 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: diff --git a/tests/test_diff_parser_extended.py b/tests/test_diff_parser_extended.py index 4036697..c8316e5 100644 --- a/tests/test_diff_parser_extended.py +++ b/tests/test_diff_parser_extended.py @@ -7,7 +7,12 @@ import pytest -from pr_split.diff_ops.parser import extract_diff, parse_diff, unquote_git_path +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 = ( @@ -251,3 +256,102 @@ def git(*args: str) -> str: 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 From 494f537cc5a534bc25837ad3b7b8096a8b46d1ea Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 18:16:10 +0100 Subject: [PATCH 068/143] fix: round-trip non-UTF-8 text files instead of crashing with UnicodeDecodeError --- pr_split/cli.py | 2 +- pr_split/diff_ops/parser.py | 5 ++- pr_split/diff_ops/reconstructor.py | 2 +- pr_split/plan_store.py | 9 +++-- pr_split/planner/client.py | 12 +++++++ tests/test_client.py | 21 ++++++++++++ tests/test_diff_parser_extended.py | 55 ++++++++++++++++++++++++++++++ tests/test_plan_store.py | 29 ++++++++++++++++ 8 files changed, 130 insertions(+), 5 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index 1d33689..3b2e2c3 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -226,7 +226,7 @@ def _create_single_branch_and_commit( if content is not None: p.parent.mkdir(parents=True, exist_ok=True) # newline="" keeps CRLF from the reconstructed content intact. - p.write_text(content, encoding="utf-8", newline="") + p.write_text(content, encoding="utf-8", errors="surrogateescape", newline="") elif p.exists(): p.unlink() diff --git a/pr_split/diff_ops/parser.py b/pr_split/diff_ops/parser.py index 1368d41..a54ec75 100644 --- a/pr_split/diff_ops/parser.py +++ b/pr_split/diff_ops/parser.py @@ -47,7 +47,10 @@ def extract_diff(dev_branch: str, base_branch: str) -> str: ) if result.returncode != 0: raise GitOperationError(result.stderr.decode("utf-8", errors="replace").strip()) - return result.stdout.decode("utf-8") + # 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 = { diff --git a/pr_split/diff_ops/reconstructor.py b/pr_split/diff_ops/reconstructor.py index 1ea7a6b..e163905 100644 --- a/pr_split/diff_ops/reconstructor.py +++ b/pr_split/diff_ops/reconstructor.py @@ -68,7 +68,7 @@ def _get_base_file_content(file_path: str, ref: str) -> str: ) if result.returncode != 0: raise GitOperationError(result.stderr.decode("utf-8", errors="replace").strip()) - return result.stdout.decode("utf-8") + return result.stdout.decode("utf-8", errors="surrogateescape") NO_NEWLINE_MARKER = "\\" diff --git a/pr_split/plan_store.py b/pr_split/plan_store.py index f7bfd99..4f3e587 100644 --- a/pr_split/plan_store.py +++ b/pr_split/plan_store.py @@ -1,3 +1,4 @@ +import json from pathlib import Path from loguru import logger @@ -12,7 +13,11 @@ 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)) + # 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) + plan_path.write_text(payload, encoding="utf-8") logger.info(logs.SAVING_PLAN.format(path=plan_path)) @@ -20,7 +25,7 @@ def load_plan() -> PlanFile: plan_path = Path(PLAN_FILE) if not plan_path.exists(): raise PRSplitError(ErrorMsg.NO_PLAN()) - plan_file = PlanFile.model_validate_json(plan_path.read_text()) + plan_file = PlanFile.model_validate(json.loads(plan_path.read_text(encoding="utf-8"))) logger.info(logs.PLAN_LOADED.format(count=len(plan_file.plan.groups), path=plan_path)) return plan_file diff --git a/pr_split/planner/client.py b/pr_split/planner/client.py index 0c933c8..258ed3e 100644 --- a/pr_split/planner/client.py +++ b/pr_split/planner/client.py @@ -107,7 +107,18 @@ 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) @@ -169,6 +180,7 @@ def _call_openai(system: str, user: str, *, settings: Settings) -> RawToolOutput 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) diff --git a/tests/test_client.py b/tests/test_client.py index 1bdfffa..fade584 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -986,3 +986,24 @@ def test_large_diff_uses_chunking( result = _plan_split_with_llm(parsed, settings) assert len(result) == 1 mock_chunked.assert_called_once() + + +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") diff --git a/tests/test_diff_parser_extended.py b/tests/test_diff_parser_extended.py index c8316e5..ffc0ae0 100644 --- a/tests/test_diff_parser_extended.py +++ b/tests/test_diff_parser_extended.py @@ -355,3 +355,58 @@ def git(*args: str) -> str: [(modified, 1), (deleted, 1), ("plain name.md", 1), ('new "file".txt', 1)] ) assert parsed.stats["total_files"] == 4 + + +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 diff --git a/tests/test_plan_store.py b/tests/test_plan_store.py index 98dd939..41df2d5 100644 --- a/tests/test_plan_store.py +++ b/tests/test_plan_store.py @@ -106,3 +106,32 @@ def test_saved_file_is_valid_json( assert raw["plan"]["priority"] == "logical" assert raw["plan"]["min_loc"] == 25 assert raw["plan"]["strict_loc_bounds"] is True + + +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" + ) From fa37abffba41dec57b9c009ba981daad33db43c5 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 20:03:25 +0100 Subject: [PATCH 069/143] fix: report an Anthropic token-count failure as an LLM error instead of an SDK traceback --- pr_split/exceptions.py | 1 + pr_split/planner/client.py | 18 ++++++++++++------ tests/test_client.py | 11 +++++++++++ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/pr_split/exceptions.py b/pr_split/exceptions.py index d7d9c2d..8c97956 100644 --- a/pr_split/exceptions.py +++ b/pr_split/exceptions.py @@ -22,6 +22,7 @@ class ErrorMsg(StrEnum): MERGE_CONFLICT = "Groups '{a}' and '{b}' modify overlapping regions in '{file}'" NO_PLAN = "No split plan found; run 'pr-split split' first" LLM_PARSE_ERROR = "Failed to parse LLM response: {detail}" + LLM_TOKEN_COUNT_FAILED = "Could not count prompt tokens with the LLM API: {detail}" 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}" diff --git a/pr_split/planner/client.py b/pr_split/planner/client.py index 258ed3e..532e70c 100644 --- a/pr_split/planner/client.py +++ b/pr_split/planner/client.py @@ -90,12 +90,18 @@ 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.APIError as exc: + raise LLMError(ErrorMsg.LLM_TOKEN_COUNT_FAILED(detail=str(exc))) from exc return response.input_tokens diff --git a/tests/test_client.py b/tests/test_client.py index fade584..3b7c210 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4,6 +4,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch +import anthropic import pytest from pr_split.config import Settings @@ -547,6 +548,16 @@ 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) + # --------------------------------------------------------------------------- # _count_tokens_openai From 271f9b62445f1b5c2d810d84e2ebbb2bfe9501db Mon Sep 17 00:00:00 2001 From: vitali87 Date: Mon, 31 Aug 2026 13:43:33 +0100 Subject: [PATCH 070/143] fix: report the configured CP-SAT timeout exactly in the not-optimal warning A float timeout below 0.1s rendered as '0.0s' under {timeout:.1f}, so the warning that tells the user which limit to raise did not name the real limit. Use {timeout:g}, and cover the sub-decisecond case; the existing test used 0.5, which formats identically under both spellings. --- pr_split/logs.py | 2 +- tests/test_partitioning.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/pr_split/logs.py b/pr_split/logs.py index 0242078..56ec644 100644 --- a/pr_split/logs.py +++ b/pr_split/logs.py @@ -75,6 +75,6 @@ "Skipping PR for group '{group}': its base branch '{base}' was not pushed" ) CP_SAT_NOT_OPTIMAL = ( - "CP-SAT stopped at the {timeout:.1f}s limit with a feasible but unproven-optimal plan " + "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" ) diff --git a/tests/test_partitioning.py b/tests/test_partitioning.py index c2f7831..9a9c85e 100644 --- a/tests/test_partitioning.py +++ b/tests/test_partitioning.py @@ -149,6 +149,36 @@ def solve_feasible(self: object, model: object) -> int: 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 From f2af1e8cf5e5e0fce961652954275097a2733d6f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:30:50 +0000 Subject: [PATCH 071/143] build(deps): bump the uv group across 1 directory with 2 updates Bumps the uv group with 2 updates in the / directory: [anyio](https://github.com/agronholm/anyio) and [pygments](https://github.com/pygments/pygments). Updates `anyio` from 4.12.1 to 4.14.2 - [Release notes](https://github.com/agronholm/anyio/releases) - [Commits](https://github.com/agronholm/anyio/compare/4.12.1...4.14.2) Updates `pygments` from 2.19.2 to 2.20.0 - [Release notes](https://github.com/pygments/pygments/releases) - [Changelog](https://github.com/pygments/pygments/blob/master/CHANGES) - [Commits](https://github.com/pygments/pygments/compare/2.19.2...2.20.0) --- updated-dependencies: - dependency-name: anyio dependency-version: 4.14.2 dependency-type: indirect dependency-group: uv - dependency-name: pygments dependency-version: 2.20.0 dependency-type: indirect dependency-group: uv ... Signed-off-by: dependabot[bot] --- uv.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/uv.lock b/uv.lock index c50cb41..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]] @@ -783,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]] From 31ddac8055f896d8a45341bd75efbe5cac5ca6a2 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Mon, 21 Sep 2026 21:31:53 +0100 Subject: [PATCH 072/143] ci: pin pr-split action to the fixed main SHA so the split comment posts (v1.0.0 scores every plan as 0 groups) --- .github/workflows/split-score.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/split-score.yml b/.github/workflows/split-score.yml index d304a85..87b941f 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 # main 2026-09-21; v1.0.0 never posts (reads plan groups at the wrong nesting) with: max-loc: "400" partition-strategy: "graph" From 939fdb155abfc0df0f1c3387d70bcaa96bf24ef0 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Mon, 21 Sep 2026 21:53:09 +0100 Subject: [PATCH 073/143] ci: re-pin pr-split to the v1.0.1 release (same SHA) --- .github/workflows/split-score.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/split-score.yml b/.github/workflows/split-score.yml index 87b941f..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@95cdcd9748109351b2aa7129ede2269989d8f61c # main 2026-09-21; v1.0.0 never posts (reads plan groups at the wrong nesting) + uses: vitali87/pr-split@95cdcd9748109351b2aa7129ede2269989d8f61c # v1.0.1 with: max-loc: "400" partition-strategy: "graph" From ee1b7b26cfa0a9b88692557930afc2759fb5ff90 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Mon, 21 Sep 2026 21:53:46 +0100 Subject: [PATCH 074/143] docs: point the action example at v1.0.1 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b46f781..49cb65f 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ jobs: with: fetch-depth: 0 - - uses: vitali87/pr-split@v1.0.0 + - uses: vitali87/pr-split@v1.0.1 with: max-loc: "400" partition-strategy: "graph" @@ -240,7 +240,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`). Automatic `ortools` install for `cp_sat` needs a release newer than `v1.0.0`; pin the action to that release or `@main` when using it | +| `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 | From 865650955d7b675d5b006513da5483d04109d4d9 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:17:11 +0100 Subject: [PATCH 075/143] fix: surface malformed LLM plan output as LLMError _parse_groups raised KeyError, ValueError or pydantic ValidationError on malformed tool output. The chunk retry loop and the refinement fallback only catch LLMError, so a single bad response aborted the whole split with a traceback instead of retrying or keeping the previous plan. --- pr_split/planner/client.py | 10 ++++++ tests/test_client.py | 69 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/pr_split/planner/client.py b/pr_split/planner/client.py index b2703d7..f271042 100644 --- a/pr_split/planner/client.py +++ b/pr_split/planner/client.py @@ -217,6 +217,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 = [ diff --git a/tests/test_client.py b/tests/test_client.py index cfac5e3..8c53f6b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -165,6 +165,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=[ @@ -464,6 +511,28 @@ 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() + 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) From 17c8d655478524bb8b40f9535419b04331365d22 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 13:50:19 +0100 Subject: [PATCH 076/143] fix: preserve CRLF line endings in reconstructed files git diff and git show were run with text=True, whose universal-newline translation strips the CR from CRLF files, and the worktree write used the platform newline. A one-line change to a CRLF file therefore became a whole-file line-ending rewrite in every sub-PR. Capture git output as bytes and decode it, and write reconstructed files with newline="". --- pr_split/cli.py | 3 +- pr_split/diff_ops/parser.py | 8 ++-- pr_split/diff_ops/reconstructor.py | 7 ++-- tests/test_cli_helpers.py | 25 ++++++++++++ tests/test_diff_parser_extended.py | 20 ++++++++-- tests/test_reconstructor.py | 62 ++++++++++++++++++++++++++++-- 6 files changed, 111 insertions(+), 14 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index 263cd6f..f499ec6 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -271,7 +271,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", newline="") elif p.exists(): p.unlink() diff --git a/pr_split/diff_ops/parser.py b/pr_split/diff_ops/parser.py index b2a4574..adb020e 100644 --- a/pr_split/diff_ops/parser.py +++ b/pr_split/diff_ops/parser.py @@ -34,14 +34,16 @@ 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", *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()) + return result.stdout.decode("utf-8") def parse_diff(raw_diff: str) -> ParsedDiff: diff --git a/pr_split/diff_ops/reconstructor.py b/pr_split/diff_ops/reconstructor.py index e645afe..59bbce5 100644 --- a/pr_split/diff_ops/reconstructor.py +++ b/pr_split/diff_ops/reconstructor.py @@ -60,14 +60,15 @@ 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") NO_NEWLINE_MARKER = "\\" diff --git a/tests/test_cli_helpers.py b/tests/test_cli_helpers.py index fbb8fbc..4ce733a 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 @@ -670,3 +671,27 @@ 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 + _create_branches_and_commits([_group("pr-1", "t")], MagicMock(), "main", "sha", "ns") + assert written["c.txt"] == b"a\r\nb\r\n" diff --git a/tests/test_diff_parser_extended.py b/tests/test_diff_parser_extended.py index 7745048..77d9aba 100644 --- a/tests/test_diff_parser_extended.py +++ b/tests/test_diff_parser_extended.py @@ -97,7 +97,7 @@ 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 @@ -115,7 +115,6 @@ def test_extract_diff_success(self, mock_run: MagicMock) -> None: "main...feature", ], capture_output=True, - text=True, ) @patch("pr_split.diff_ops.parser.subprocess.run") @@ -123,8 +122,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") @@ -134,3 +133,16 @@ 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 diff --git a/tests/test_reconstructor.py b/tests/test_reconstructor.py index 42b39ae..1eaa65e 100644 --- a/tests/test_reconstructor.py +++ b/tests/test_reconstructor.py @@ -115,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") @@ -183,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 == "" @@ -539,3 +539,59 @@ def test_form_feed_in_earlier_line_does_not_shift_hunk(self) -> None: 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" From a1386dd78cc152cf2c1e50456d12b0c50cf7d7f8 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 14:15:03 +0100 Subject: [PATCH 077/143] test: assert newline='' on worktree writes and cover a bare CR base file --- tests/test_cli_helpers.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_cli_helpers.py b/tests/test_cli_helpers.py index 4ce733a..aaef898 100644 --- a/tests/test_cli_helpers.py +++ b/tests/test_cli_helpers.py @@ -693,5 +693,10 @@ def capture(cwd: str, file_paths: list[str], message: str, **kwargs: object) -> return "sha1" mock_commit.side_effect = capture - _create_branches_and_commits([_group("pr-1", "t")], MagicMock(), "main", "sha", "ns") + # 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) From 2b16708cc7dbdb1d53ffd2091e868dd4b328f8ff Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 14:51:53 +0100 Subject: [PATCH 078/143] fix: keep non-ASCII paths unescaped in the extracted diff git quotes and octal-escapes non-ASCII paths by default (core.quotePath). unidiff then fails on new/deleted files ('Target without source') and, for modified files, records the literal quoted string as the path so materialization asks git for a path that does not exist. Run git diff with core.quotePath=false. --- pr_split/diff_ops/parser.py | 5 ++++- tests/test_diff_parser_extended.py | 35 ++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/pr_split/diff_ops/parser.py b/pr_split/diff_ops/parser.py index adb020e..7ef94ba 100644 --- a/pr_split/diff_ops/parser.py +++ b/pr_split/diff_ops/parser.py @@ -38,7 +38,10 @@ def extract_diff(dev_branch: str, base_branch: str) -> str: # turns CRLF into LF and would make every sub-PR rewrite the file's # line endings. result = subprocess.run( - ["git", "diff", *DIFF_ARGS, 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, ) if result.returncode != 0: diff --git a/tests/test_diff_parser_extended.py b/tests/test_diff_parser_extended.py index 77d9aba..f797a0f 100644 --- a/tests/test_diff_parser_extended.py +++ b/tests/test_diff_parser_extended.py @@ -1,6 +1,8 @@ from __future__ import annotations +import os import subprocess +from pathlib import Path from unittest.mock import MagicMock, patch import pytest @@ -104,6 +106,8 @@ def test_extract_diff_success(self, mock_run: MagicMock) -> None: mock_run.assert_called_once_with( [ "git", + "-c", + "core.quotePath=false", "diff", "--no-color", "--no-ext-diff", @@ -146,3 +150,34 @@ def test_crlf_diff_is_returned_verbatim(self, mock_run: MagicMock) -> None: assert "\r\n" in result assert result == raw.decode() assert mock_run.call_args.kwargs.get("text") is not True + + +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"] From 9cb1b615703dbb99265e37620a871a707ec5da2b Mon Sep 17 00:00:00 2001 From: vitali87 Date: Thu, 24 Sep 2026 09:27:29 +0100 Subject: [PATCH 079/143] test: exercise the stale WHOLE_FILE index case on the conflict check Coverage validation on main now rejects an index that is not a hunk, so the plan-level test could no longer reach the phantom-conflict path. --- tests/test_validator.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/test_validator.py b/tests/test_validator.py index 7835198..48f5ab8 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -266,13 +266,12 @@ def test_whole_file_overlapping_same_file_partial_raises(self) -> None: 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. The - # only real hunk (0) is covered once, so the plan must validate: 99 is - # not a hunk and must not be reported as an overlapping region. - parsed = parse_diff(SAMPLE_DIFF) + # 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), ] - dag = PlanDAG(groups) - assert validate_plan(groups, parsed, dag, max_loc=400) == [] + validate_no_conflicts(groups, PlanDAG(groups), {"a.py": 1, "b.py": 1}) From d8e8d1db1bee575718258b06c96d12eeb20fd908 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Thu, 24 Sep 2026 09:44:15 +0100 Subject: [PATCH 080/143] fix: require hunk counts in validate_no_conflicts Without counts a WHOLE_FILE assignment covered no hunks, so its overlap with an independent PARTIAL_HUNKS assignment went unreported. --- pr_split/planner/validator.py | 7 ++----- tests/test_validator.py | 6 +++--- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/pr_split/planner/validator.py b/pr_split/planner/validator.py index 1a47482..1d04b80 100644 --- a/pr_split/planner/validator.py +++ b/pr_split/planner/validator.py @@ -68,15 +68,12 @@ def validate_loc(groups: list[Group], parsed_diff: ParsedDiff) -> None: ) -def validate_no_conflicts( - groups: list[Group], dag: PlanDAG, hunk_counts: dict[str, int] | None = None -) -> None: - counts = hunk_counts or {} +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: - covered = assignment.covered_indices(counts.get(assignment.file_path, 0)) + 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 diff --git a/tests/test_validator.py b/tests/test_validator.py index 48f5ab8..eef8222 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -180,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 = [ @@ -189,7 +189,7 @@ 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 = [ @@ -210,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: From 71ae420a3279991c7a263c00382fb87cde51ebb4 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Thu, 24 Sep 2026 13:19:03 +0100 Subject: [PATCH 081/143] fix: make dependants of a dropped group depend on its hunks' new owners Dropping an emptied group rewired its dependants only to its own ancestors, so a stacked dependant lost the group that received the moved hunks and its branch could miss them. Coverage is snapshotted before editing and every kept group now holding a dropped group's former hunks is added as a dependency. --- pr_split/cli.py | 43 ++++++++++++++++++++++++++++---- tests/test_cli_coverage.py | 50 ++++++++++++++++++++++++++++++++------ 2 files changed, 80 insertions(+), 13 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index d03a2af..76ec792 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -672,20 +672,50 @@ def _show_group_detail(groups: list[Group], group_id: str) -> None: console.print() -def _drop_empty_groups(groups: list[Group]) -> list[Group]: +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 own dependencies are inherited by its dependants so - ordering is preserved. + 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. """ 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]) -> list[str]: - # Walk through chains of dropped groups to the nearest kept ancestors. + # Walk through chains of dropped groups to the nearest kept groups. if dep not in dropped: return [dep] out: list[str] = [] @@ -693,6 +723,7 @@ def _surviving(dep: str, seen: set[str]) -> list[str]: if parent not in seen: seen.add(parent) out.extend(_surviving(parent, seen)) + out.extend(recipients[dep]) return out kept: list[Group] = [] @@ -949,10 +980,12 @@ def split( 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 - groups = _drop_empty_groups(groups) + 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) diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index 7100147..1163f60 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -991,7 +991,7 @@ def test_clean_no_plan(self, mock_pe: MagicMock) -> None: 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 + 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"]) @@ -1000,7 +1000,7 @@ def test_empty_group_is_dropped_and_dependants_inherit_its_parents(self) -> None from pr_split.cli import console with console.capture() as capture: - result = _drop_empty_groups([root, emptied, leaf]) + 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"] @@ -1009,7 +1009,7 @@ def test_empty_group_is_dropped_and_dependants_inherit_its_parents(self) -> None 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]) + result = _drop_empty_groups([emptied, leaf], {}, {}) assert [g.id for g in result] == ["pr-2"] assert result[0].depends_on == [] @@ -1018,7 +1018,7 @@ def test_chain_of_empty_groups_collapses_transitively(self) -> None: 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]) + 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"] @@ -1027,7 +1027,7 @@ def test_chain_with_direct_edge_does_not_duplicate(self) -> None: 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]) + result = _drop_empty_groups([root, e1, e2, leaf], {}, {}) assert result[1].depends_on == ["pr-1"] def test_diamond_through_dropped_groups(self) -> None: @@ -1037,17 +1037,49 @@ def test_diamond_through_dropped_groups(self) -> None: 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]) + 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"] + 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, _interactive_edit + from pr_split.cli import _drop_empty_groups, _held_hunks, _interactive_edit from pr_split.diff_ops.parser import parse_diff from pr_split.graph import PlanDAG from pr_split.planner.validator import validate_plan @@ -1067,8 +1099,10 @@ def test_moving_the_last_hunk_out_yields_a_valid_one_group_plan( ] 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) + 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] From 1f6e9150baeaf3f238efc5f66792fd8ce020159a Mon Sep 17 00:00:00 2001 From: vitali87 Date: Thu, 24 Sep 2026 13:33:11 +0100 Subject: [PATCH 082/143] fix: skip recipient dependencies that would create a cycle Dependants are rewired to inherited ancestors first, then each group that took over a dropped group's hunks is added only if it does not already build on the dependant. --- pr_split/cli.py | 57 ++++++++++++++++++++++++++++---------- tests/test_cli_coverage.py | 38 +++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index 76ec792..b239bbf 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -714,28 +714,57 @@ def _drop_empty_groups( for gid in dropped } - def _surviving(dep: str, seen: set[str]) -> list[str]: - # Walk through chains of dropped groups to the nearest kept groups. + def _surviving(dep: str, seen: set[str]) -> tuple[list[str], list[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] - out: list[str] = [] + return [dep], [] + ancestors: list[str] = [] + taken_over: list[str] = list(recipients[dep]) for parent in dropped[dep]: if parent not in seen: seen.add(parent) - out.extend(_surviving(parent, seen)) - out.extend(recipients[dep]) - return out - - kept: list[Group] = [] + 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[str]] = {} for group in groups: if group.id in dropped: continue - deps: list[str] = [] + deps[group.id] = [] + wanted[group.id] = [] for dep in group.depends_on: - for candidate in _surviving(dep, set()): - if candidate not in deps and candidate != group.id: - deps.append(candidate) - kept.append(group.model_copy(update={"depends_on": deps})) + 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, skipping any that already + # build on the dependant (the user moved the hunk downstream of it). + for gid, candidates in wanted.items(): + for candidate in candidates: + if candidate in deps[gid] or candidate == gid or _depends_on(candidate, gid): + continue + 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 diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index 1163f60..39e61c0 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -30,6 +30,7 @@ from pr_split.constants import AssignmentType, Priority from pr_split.diff_ops.parser import parse_diff from pr_split.exceptions import GitOperationError, PRSplitError +from pr_split.graph import PlanDAG from pr_split.schemas import ( BranchRecord, GitState, @@ -1073,6 +1074,43 @@ def test_recipient_is_added_after_inherited_ancestors(self) -> None: assert result[-1].depends_on == ["pr-1", "pr-2"] + def test_downstream_recipient_is_not_added_as_a_dependency(self) -> None: + # D already builds on C; making C depend on D would be a cycle. + 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()} + + result = _drop_empty_groups([e, c, d], held_before, {"a.py": 1}) + + assert {g.id: g.depends_on for g in result} == {"pr-2": [], "pr-3": ["pr-2"]} + PlanDAG(result).validate_acyclic() + + def test_crossed_recipients_do_not_form_a_cycle(self) -> None: + # C depended on E1 whose hunk moved to D; D depended on E2 whose hunk + # moved to C. Only the first recipient edge can be kept. + 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)}} + + result = _drop_empty_groups([e1, e2, c, d], held_before, {"a.py": 2}) + + assert {g.id: g.depends_on for g in result} == {"pr-3": ["pr-4"], "pr-4": []} + PlanDAG(result).validate_acyclic() + class TestEditorEmptiedGroupEndToEnd: @patch("pr_split.cli.typer.prompt") From 67795cfeb01a7d19d7ecf2743e29e551d2da29f9 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Thu, 24 Sep 2026 13:45:30 +0100 Subject: [PATCH 083/143] fix: refuse dropping a group whose hunks moved downstream of its dependant Such a move cannot keep the dependant on the moved hunk without a cycle, and skipping the edge would build the dependant's stacked branch without it. --- pr_split/cli.py | 26 ++++++++++++++++++-------- tests/test_cli_coverage.py | 23 +++++++++++------------ 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index b239bbf..a467268 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -698,7 +698,9 @@ def _drop_empty_groups( 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. + 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: @@ -714,13 +716,13 @@ def _drop_empty_groups( for gid in dropped } - def _surviving(dep: str, seen: set[str]) -> tuple[list[str], list[str]]: + 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: list[str] = list(recipients[dep]) + taken_over = [(recipient, dep) for recipient in recipients[dep]] for parent in dropped[dep]: if parent not in seen: seen.add(parent) @@ -732,7 +734,7 @@ def _surviving(dep: str, seen: set[str]) -> tuple[list[str], list[str]]: # 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[str]] = {} + wanted: dict[str, list[tuple[str, str]]] = {} for group in groups: if group.id in dropped: continue @@ -756,12 +758,20 @@ def _depends_on(start: str, target: str) -> bool: stack.extend(deps.get(node, [])) return False - # Then the groups that took over dropped hunks, skipping any that already - # build on the dependant (the user moved the hunk downstream of it). + # 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 in candidates: - if candidate in deps[gid] or candidate == gid or _depends_on(candidate, gid): + for candidate, source in candidates: + if candidate in deps[gid] or candidate == gid: 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] diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index 39e61c0..d36c838 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -1074,8 +1074,9 @@ def test_recipient_is_added_after_inherited_ancestors(self) -> None: assert result[-1].depends_on == ["pr-1", "pr-2"] - def test_downstream_recipient_is_not_added_as_a_dependency(self) -> None: - # D already builds on C; making C depend on D would be a cycle. + 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"]) @@ -1084,15 +1085,16 @@ def test_downstream_recipient_is_not_added_as_a_dependency(self) -> None: 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 - result = _drop_empty_groups([e, c, d], held_before, {"a.py": 1}) + with console.capture() as capture, pytest.raises(typer.Exit): + _drop_empty_groups([e, c, d], held_before, {"a.py": 1}) - assert {g.id: g.depends_on for g in result} == {"pr-2": [], "pr-3": ["pr-2"]} - PlanDAG(result).validate_acyclic() + assert "Cannot drop emptied group 'pr-1'" in " ".join(capture.get().split()) - def test_crossed_recipients_do_not_form_a_cycle(self) -> None: + 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. Only the first recipient edge can be kept. + # 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") @@ -1106,10 +1108,8 @@ def test_crossed_recipients_do_not_form_a_cycle(self) -> None: ] held_before = {"pr-1": {("a.py", 0)}, "pr-2": {("a.py", 1)}} - result = _drop_empty_groups([e1, e2, c, d], held_before, {"a.py": 2}) - - assert {g.id: g.depends_on for g in result} == {"pr-3": ["pr-4"], "pr-4": []} - PlanDAG(result).validate_acyclic() + with pytest.raises(typer.Exit): + _drop_empty_groups([e1, e2, c, d], held_before, {"a.py": 2}) class TestEditorEmptiedGroupEndToEnd: @@ -1119,7 +1119,6 @@ def test_moving_the_last_hunk_out_yields_a_valid_one_group_plan( ) -> 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.graph import PlanDAG from pr_split.planner.validator import validate_plan parsed = parse_diff(TWO_HUNK_DIFF) From 36164700d651b7e657c266ad9aac51b1fb4c2f18 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Thu, 24 Sep 2026 13:56:12 +0100 Subject: [PATCH 084/143] fix: skip recipient edges the dependant already reaches through an ancestor A redundant direct edge made a linear stack node multi-parent, so --stack built it from the merge base and dropped it from the native stack. --- pr_split/cli.py | 4 +++- tests/test_cli_coverage.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index a467268..c7ffb69 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -763,7 +763,9 @@ def _depends_on(start: str, target: str) -> bool: # would build the dependant's branch without the moved hunk: refuse. for gid, candidates in wanted.items(): for candidate, source in candidates: - if candidate in deps[gid] or candidate == gid: + # 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( diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index d36c838..bef98cc 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -1074,6 +1074,23 @@ def test_recipient_is_added_after_inherited_ancestors(self) -> None: 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. From cdb92a178cf8cda786c2e31e84fcac334350bb20 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 16:12:35 +0100 Subject: [PATCH 085/143] fix: keep the current plan when LLM refinement drops hunks or does not reduce violations --- pr_split/logs.py | 8 +++++ pr_split/planner/client.py | 27 +++++++++++++-- tests/test_client.py | 67 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 2 deletions(-) diff --git a/pr_split/logs.py b/pr_split/logs.py index 56ec644..be16e85 100644 --- a/pr_split/logs.py +++ b/pr_split/logs.py @@ -65,6 +65,14 @@ REFINEMENT_EXHAUSTED = ( "Refinement iteration limit reached ({iterations}), {remaining} violation(s) remain" ) +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}" MERGE_NODE_NOT_STACKED = ( "Group '{group}' depends on multiple groups; native stacks are linear, so its" diff --git a/pr_split/planner/client.py b/pr_split/planner/client.py index f271042..d14830f 100644 --- a/pr_split/planner/client.py +++ b/pr_split/planner/client.py @@ -20,7 +20,7 @@ Provider, ) from ..diff_ops import ParsedDiff -from ..exceptions import ErrorMsg, LLMError, PRSplitError +from ..exceptions import ErrorMsg, LLMError, PlanValidationError, PRSplitError from ..schemas import Group, GroupAssignment from .chunker import ( assign_uncovered_hunks, @@ -42,7 +42,7 @@ build_user_prompt, ) from .scoring import score_plan -from .validator import detect_loc_bound_violations +from .validator import detect_loc_bound_violations, validate_coverage _ANTHROPIC_TOOL_DEF = anthropic.types.ToolParam( name=SPLIT_TOOL_NAME, @@ -418,6 +418,29 @@ 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, or that is no better. + try: + validate_coverage(refined, parsed_diff) + 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( diff --git a/tests/test_client.py b/tests/test_client.py index 8c53f6b..798a2a3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -533,6 +533,73 @@ def test_refinement_falls_back_on_malformed_response( 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 + + @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) From 0a204bd8649ed3174784f28bb0a2058308e5cc9c Mon Sep 17 00:00:00 2001 From: vitali87 Date: Thu, 24 Sep 2026 16:08:29 +0100 Subject: [PATCH 086/143] fix: reject a refined plan whose dependency graph is invalid Coverage alone let a refinement with duplicate ids, an unknown parent, a cycle or overlapping independent groups replace a usable plan and abort the split later. The refined plan now passes the DAG checks before it is kept. --- pr_split/planner/client.py | 12 +++++++-- tests/test_client.py | 52 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/pr_split/planner/client.py b/pr_split/planner/client.py index d14830f..277839c 100644 --- a/pr_split/planner/client.py +++ b/pr_split/planner/client.py @@ -21,6 +21,7 @@ ) from ..diff_ops import ParsedDiff from ..exceptions import ErrorMsg, LLMError, PlanValidationError, PRSplitError +from ..graph import PlanDAG from ..schemas import Group, GroupAssignment from .chunker import ( assign_uncovered_hunks, @@ -42,7 +43,7 @@ build_user_prompt, ) from .scoring import score_plan -from .validator import detect_loc_bound_violations, validate_coverage +from .validator import detect_loc_bound_violations, validate_coverage, validate_no_conflicts _ANTHROPIC_TOOL_DEF = anthropic.types.ToolParam( name=SPLIT_TOOL_NAME, @@ -419,9 +420,16 @@ def _refine_plan_with_llm( 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, or that is no better. + # 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( diff --git a/tests/test_client.py b/tests/test_client.py index 798a2a3..829d390 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -573,6 +573,58 @@ def test_refinement_that_drops_hunks_is_rejected( 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 From 45a016f3a073712823b3cada1366880c1b8178bc Mon Sep 17 00:00:00 2001 From: vitali87 Date: Thu, 24 Sep 2026 18:50:52 +0100 Subject: [PATCH 087/143] docs: install pr-split from the git repository, not PyPI pr-split is not published on PyPI, so `uv tool install pr-split` and `pip install pr-split` fail with a not-found error. The README and the missing-ortools hint now give the git form. --- README.md | 10 ++++++---- pr_split/planner/partitioning.py | 3 ++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index da6e782..a997679 100644 --- a/README.md +++ b/README.md @@ -30,15 +30,17 @@ Vibe coding with AI assistants can produce massive PRs that no one wants to revi ## 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]" +uv tool install "pr-split[cp-sat] @ git+https://github.com/vitali87/pr-split" ``` ## Prerequisites @@ -262,7 +264,7 @@ 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. Install it via the `cp-sat` extra: `uv tool install "pr-split[cp-sat]"`. +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"`. For a deeper explanation of the planning model, optimization methods, scoring, and research directions, see [METHODOLOGY.md](METHODOLOGY.md). diff --git a/pr_split/planner/partitioning.py b/pr_split/planner/partitioning.py index 79897b2..4d0c562 100644 --- a/pr_split/planner/partitioning.py +++ b/pr_split/planner/partitioning.py @@ -369,7 +369,8 @@ def _group_units_cp_sat( except ImportError as exc: raise PRSplitError( "CP-SAT partitioning requires the optional 'ortools' package; " - "install it with `pip install 'pr-split[cp-sat]'`" + "install it with " + "`uv tool install 'pr-split[cp-sat] @ git+https://github.com/vitali87/pr-split'`" ) from exc if not units: From 8d34687d5c05a5f97bb6e6f516b64308d2c7b5fb Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 18:16:10 +0100 Subject: [PATCH 088/143] fix: round-trip non-UTF-8 text files instead of crashing with UnicodeDecodeError --- pr_split/cli.py | 2 +- pr_split/diff_ops/parser.py | 5 ++- pr_split/diff_ops/reconstructor.py | 2 +- pr_split/plan_store.py | 11 ++++-- pr_split/planner/client.py | 12 +++++++ tests/test_client.py | 21 +++++++++++ tests/test_diff_parser_extended.py | 57 ++++++++++++++++++++++++++++++ tests/test_plan_store.py | 29 +++++++++++++++ 8 files changed, 133 insertions(+), 6 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index 1167ab3..a640735 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -273,7 +273,7 @@ def _create_single_branch_and_commit( if content is not None: p.parent.mkdir(parents=True, exist_ok=True) # newline="" keeps CRLF from the reconstructed content intact. - p.write_text(content, encoding="utf-8", newline="") + p.write_text(content, encoding="utf-8", errors="surrogateescape", newline="") elif p.exists(): p.unlink() diff --git a/pr_split/diff_ops/parser.py b/pr_split/diff_ops/parser.py index adb020e..39694bb 100644 --- a/pr_split/diff_ops/parser.py +++ b/pr_split/diff_ops/parser.py @@ -43,7 +43,10 @@ def extract_diff(dev_branch: str, base_branch: str) -> str: ) if result.returncode != 0: raise GitOperationError(result.stderr.decode("utf-8", errors="replace").strip()) - return result.stdout.decode("utf-8") + # 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") def parse_diff(raw_diff: str) -> ParsedDiff: diff --git a/pr_split/diff_ops/reconstructor.py b/pr_split/diff_ops/reconstructor.py index 53765bf..b8df46c 100644 --- a/pr_split/diff_ops/reconstructor.py +++ b/pr_split/diff_ops/reconstructor.py @@ -64,7 +64,7 @@ def _get_base_file_content(file_path: str, ref: str) -> str: ) if result.returncode != 0: raise GitOperationError(result.stderr.decode("utf-8", errors="replace").strip()) - return result.stdout.decode("utf-8") + return result.stdout.decode("utf-8", errors="surrogateescape") NO_NEWLINE_MARKER = "\\" diff --git a/pr_split/plan_store.py b/pr_split/plan_store.py index c2c1da7..e2a892c 100644 --- a/pr_split/plan_store.py +++ b/pr_split/plan_store.py @@ -1,3 +1,4 @@ +import json from pathlib import Path from loguru import logger @@ -13,7 +14,11 @@ 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)) + # 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) + plan_path.write_text(payload, encoding="utf-8") logger.info(logs.SAVING_PLAN.format(path=plan_path)) @@ -22,8 +27,8 @@ def load_plan() -> PlanFile: if not plan_path.exists(): raise PRSplitError(ErrorMsg.NO_PLAN()) try: - plan_file = PlanFile.model_validate_json(plan_path.read_text()) - except (OSError, UnicodeDecodeError, ValidationError) as exc: + plan_file = PlanFile.model_validate(json.loads(plan_path.read_text(encoding="utf-8"))) + except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValidationError) as exc: raise PRSplitError(ErrorMsg.PLAN_LOAD_FAILED(path=plan_path, detail=exc)) from exc logger.info(logs.PLAN_LOADED.format(count=len(plan_file.plan.groups), path=plan_path)) return plan_file diff --git a/pr_split/planner/client.py b/pr_split/planner/client.py index 277839c..3cbe9be 100644 --- a/pr_split/planner/client.py +++ b/pr_split/planner/client.py @@ -108,7 +108,18 @@ 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) @@ -183,6 +194,7 @@ def _call_openai(system: str, user: str, *, settings: Settings) -> RawToolOutput 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) diff --git a/tests/test_client.py b/tests/test_client.py index 829d390..78d4f3e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1322,3 +1322,24 @@ def _pa(path: str, hunks: list[int]) -> GroupAssignment: 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") diff --git a/tests/test_diff_parser_extended.py b/tests/test_diff_parser_extended.py index 77d9aba..bd052e3 100644 --- a/tests/test_diff_parser_extended.py +++ b/tests/test_diff_parser_extended.py @@ -1,6 +1,8 @@ from __future__ import annotations +import os import subprocess +from pathlib import Path from unittest.mock import MagicMock, patch import pytest @@ -146,3 +148,58 @@ def test_crlf_diff_is_returned_verbatim(self, mock_run: MagicMock) -> None: 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 diff --git a/tests/test_plan_store.py b/tests/test_plan_store.py index 11ca925..3282962 100644 --- a/tests/test_plan_store.py +++ b/tests/test_plan_store.py @@ -146,3 +146,32 @@ def test_read_error_raises_pr_split_error( (tmp_path / ".pr-split" / "plan.json").mkdir() with pytest.raises(PRSplitError, match="Cannot load split plan"): load_plan() + + +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" + ) From 19ded1b0cef669740f38b5c1e289c4149464f33e Mon Sep 17 00:00:00 2001 From: vitali87 Date: Thu, 24 Sep 2026 19:06:37 +0100 Subject: [PATCH 089/143] fix: lay out dependent groups along the plan DAG without --stack A plan with depends_on edges was executed with every branch cut from the merge base and every PR targeting the base branch unless --stack was set, so a dependent sub-PR could not build or be reviewed against the code it needs. Dependants now always build on and target their parent's branch; --stack only adds native gh-stack registration. --- README.md | 8 ++--- pr_split/cli.py | 10 +++++-- pr_split/logs.py | 2 +- tests/test_cli_helpers.py | 63 ++++++++++++++++++++++++++++----------- 4 files changed, 57 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index da6e782..1dfe008 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 dependent PR 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. @@ -87,7 +87,7 @@ pr-split split feature-branch --base main --dry-run | `--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`) | | `--cp-sat-timeout` | `15.0` | Maximum seconds to spend in the CP-SAT solver | -| `--stack` | `false` | Stack dependent PRs: each child branches from and targets its parent's branch | +| `--stack` | `false` | Register dependent PR chains as native GitHub stacks (dependent PRs always branch from and target their parent) | | `--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 | @@ -97,7 +97,7 @@ 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. +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, with or without `--stack`. Every PR shows only its own diff, compiles standalone, and GitHub retargets children automatically as parents merge. Groups with no dependencies are cut from the merge base and target the base branch. `--stack` additionally registers the chains as native GitHub stacks. 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. @@ -200,7 +200,7 @@ Settings can be set via environment variables with the `PR_SPLIT_` prefix: | `PR_SPLIT_CHUNK_STRATEGY` | `dynamic_programming` | Large-diff chunking strategy | | `PR_SPLIT_PARTITION_STRATEGY` | `llm` | 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` | Stack dependent PRs on their parent's branch | +| `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 | diff --git a/pr_split/cli.py b/pr_split/cli.py index bf28d61..3c24113 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -360,7 +360,11 @@ def _create_branches_and_commits( ) -> list[BranchRecord]: worktree_base = Path(tempfile.mkdtemp(prefix="pr-split-worktrees-")) - if stacked: + # 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} @@ -920,7 +924,7 @@ def split( 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[ @@ -1232,7 +1236,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[ diff --git a/pr_split/logs.py b/pr_split/logs.py index 07d06bc..79cb827 100644 --- a/pr_split/logs.py +++ b/pr_split/logs.py @@ -80,7 +80,7 @@ ) STACK_LINKED = "Linked stack for PRs {prs}" 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" ) diff --git a/tests/test_cli_helpers.py b/tests/test_cli_helpers.py index fbb8fbc..f46abdc 100644 --- a/tests/test_cli_helpers.py +++ b/tests/test_cli_helpers.py @@ -291,6 +291,51 @@ def add(path: str, branch_name: str, start_point: str) -> None: 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"])] @@ -388,24 +433,6 @@ def test_child_materializes_with_ancestor_hunks( 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") From 0e058d5d7cbab61849375bb6b44752429067302d Mon Sep 17 00:00:00 2001 From: vitali87 Date: Thu, 24 Sep 2026 19:12:31 +0100 Subject: [PATCH 090/143] fix: root each native stack on its bottom PR's base, not the default branch gh stack link without --base roots the stack on the repository default branch and retargets the bottom PR there, so a split onto a non-default base (and a multi-parent group that starts a new chain) ended up targeting main. The chain's bottom base branch is now passed explicitly. --- pr_split/cli.py | 11 +++++++---- pr_split/git_ops/prs.py | 6 ++++-- tests/test_cli_helpers.py | 30 ++++++++++++++++++++++++++++-- tests/test_git_prs.py | 6 +++--- 4 files changed, 42 insertions(+), 11 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index bf28d61..2a340c4 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -570,12 +570,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( @@ -1105,7 +1108,7 @@ def split( ) ) if stack: - _link_stacks(dag, pr_records) + _link_stacks(dag, pr_records, branch_records) logger.success(f"Split complete: {len(groups)} PRs created") @@ -1341,7 +1344,7 @@ def execute( ) ) if plan.stacked: - _link_stacks(PlanDAG(plan.groups), pr_records) + _link_stacks(PlanDAG(plan.groups), pr_records, branch_records) logger.success(f"Execute complete: {len(plan.groups)} PRs created from saved plan") logger.success(f"Execute complete: {len(plan.groups)} PRs created from saved plan") diff --git a/pr_split/git_ops/prs.py b/pr_split/git_ops/prs.py index 616168a..8a37a52 100644 --- a/pr_split/git_ops/prs.py +++ b/pr_split/git_ops/prs.py @@ -93,9 +93,11 @@ 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: list[int], base: str) -> None: + # 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: raise GitOperationError(ErrorMsg.STACK_LINK_FAILED(prs=pr_numbers, detail=exc)) from exc logger.info(logs.STACK_LINKED.format(prs=pr_numbers)) diff --git a/tests/test_cli_helpers.py b/tests/test_cli_helpers.py index fbb8fbc..7e296c2 100644 --- a/tests/test_cli_helpers.py +++ b/tests/test_cli_helpers.py @@ -420,8 +420,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: diff --git a/tests/test_git_prs.py b/tests/test_git_prs.py index 0d108bd..2c0f7ac 100644 --- a/tests/test_git_prs.py +++ b/tests/test_git_prs.py @@ -129,14 +129,14 @@ 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_raises(self, mock_gh: MagicMock) -> None: mock_gh.side_effect = GitOperationError("unknown command: stack") with pytest.raises(GitOperationError, match="Failed to link stack for PRs \\[12, 34\\]"): - link_stack([12, 34]) + link_stack([12, 34], base="main") class TestCheckGhStack: From 4f454b25462069b87d7cee8f757eab7891efc620 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Thu, 24 Sep 2026 19:16:00 +0100 Subject: [PATCH 091/143] fix: flag an oversized PR in the score action even without a usable plan Once the diff exceeds max-loc, a planner failure, a missing plan file or a plan with fewer than threshold-groups groups all reported should_split=false, so a broken planner and a small PR looked the same. The size verdict now comes from the LOC alone, and the comment states the LOC, the limit and why no plan is shown. --- scripts/score_pr.py | 103 +++++++++++++++++++++++----------- tests/test_score_pr_events.py | 76 +++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 34 deletions(-) diff --git a/scripts/score_pr.py b/scripts/score_pr.py index 7f02674..1f8f191 100644 --- a/scripts/score_pr.py +++ b/scripts/score_pr.py @@ -31,6 +31,42 @@ 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) -> 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. + """ + print(f"PR has {total_loc} LOC, over the {max_loc} limit; {reason}") + _set_output("total_groups", "0") + _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, " + f"but no split plan could be generated: {reason}", + "", + "*Run `pr-split split` locally to plan the split.*", + ] + ) + + def _md_escape(s: str) -> str: return s.replace("|", "\\|") @@ -129,16 +165,24 @@ 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("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("pr-split wrote no plan file.", total_loc, file_count, max_loc) return 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), fewer than the {threshold} needed.", + total_loc, + file_count, + max_loc, + ) + 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) @@ -149,15 +193,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 = [ @@ -175,34 +217,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/test_score_pr_events.py b/tests/test_score_pr_events.py index c22609e..83a3433 100644 --- a/tests/test_score_pr_events.py +++ b/tests/test_score_pr_events.py @@ -1,6 +1,8 @@ from __future__ import annotations import importlib.util +import json +import subprocess import sys from pathlib import Path from types import ModuleType @@ -77,3 +79,77 @@ def test_pull_request_event_still_fetches( 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" + assert "fewer than the 2 needed" 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 From d6d6bb3cb92c0cc031d398ef750a16c13186f4a3 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 15:19:41 +0100 Subject: [PATCH 092/143] fix: decode C-quoted paths from git diff so files are not written under a quoted name --- pr_split/diff_ops/parser.py | 45 +++++++++++++++++++ tests/test_diff_parser_extended.py | 72 +++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/pr_split/diff_ops/parser.py b/pr_split/diff_ops/parser.py index ffa4462..762db66 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 @@ -52,11 +53,55 @@ def extract_diff(dev_branch: str, base_branch: str) -> str: 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 + out = bytearray() + pos = 0 + body = path[1:-1] + for match in _C_ESCAPE_RE.finditer(body): + out += body[pos : match.start()].encode("utf-8") + 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") + return bytes(out).decode("utf-8", errors="surrogateescape") + + def parse_diff(raw_diff: str) -> ParsedDiff: try: patch_set = PatchSet(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) return ParsedDiff(patch_set=patch_set, raw_diff=raw_diff) diff --git a/tests/test_diff_parser_extended.py b/tests/test_diff_parser_extended.py index 08af5be..783194f 100644 --- a/tests/test_diff_parser_extended.py +++ b/tests/test_diff_parser_extended.py @@ -7,7 +7,7 @@ import pytest -from pr_split.diff_ops.parser import extract_diff, parse_diff +from pr_split.diff_ops.parser import extract_diff, parse_diff, unquote_git_path from pr_split.exceptions import GitOperationError SAMPLE_DIFF = ( @@ -236,3 +236,73 @@ def git(*args: str) -> str: 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"), + ('""', ""), + ('"', '"'), + ], + ) + 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) From b2d907b47cc0361ce4eefe044ade72b3b42451d1 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Thu, 24 Sep 2026 20:00:45 +0100 Subject: [PATCH 093/143] fix: keep non-UTF-8 filename bytes when unquoting a C-quoted path A path git quotes (for a double quote, backslash or control character) that also holds a non-UTF-8 byte reaches the parser with a surrogate for that byte; the strict encode of the literal span raised UnicodeEncodeError. --- pr_split/diff_ops/parser.py | 6 ++++-- tests/test_diff_parser_extended.py | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/pr_split/diff_ops/parser.py b/pr_split/diff_ops/parser.py index 762db66..3fcccb8 100644 --- a/pr_split/diff_ops/parser.py +++ b/pr_split/diff_ops/parser.py @@ -77,11 +77,13 @@ def unquote_git_path(path: str) -> str: """ 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") + 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 @@ -90,7 +92,7 @@ def unquote_git_path(path: str) -> str: else: out += _C_ESCAPES.get(escape, escape.encode("utf-8")) pos = match.end() - out += body[pos:].encode("utf-8") + out += body[pos:].encode("utf-8", errors="surrogateescape") return bytes(out).decode("utf-8", errors="surrogateescape") diff --git a/tests/test_diff_parser_extended.py b/tests/test_diff_parser_extended.py index 783194f..304f284 100644 --- a/tests/test_diff_parser_extended.py +++ b/tests/test_diff_parser_extended.py @@ -248,6 +248,9 @@ class TestUnquoteGitPath: ('"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'), ('""', ""), ('"', '"'), ], From 0c6204660c54197cf19ac5b973a00126e5083114 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Thu, 24 Sep 2026 20:03:55 +0100 Subject: [PATCH 094/143] fix: accept a same-repo PR number in split split documents its argument as a branch name, PR number or user:branch, but a PR number was only accepted for fork PRs; a same-repo PR exited with 'not from a fork'. A same-repo PR's head is now fetched from origin, and its base branch is used, the same way the fork path works. --- README.md | 6 ++++-- pr_split/exceptions.py | 3 --- pr_split/git_ops/prs.py | 14 +++++++++----- pr_split/logs.py | 1 + tests/test_git_prs_coverage.py | 22 +++++++++++++++++----- 5 files changed, 31 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index da6e782..4c39690 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ 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. pr-split system design @@ -56,7 +56,9 @@ uv tool install "pr-split[cp-sat]" 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 is fetched (from `origin`, or from the fork) and its base branch is used. ```bash pr-split split '#42' --base main diff --git a/pr_split/exceptions.py b/pr_split/exceptions.py index 0e1912a..67a14fd 100644 --- a/pr_split/exceptions.py +++ b/pr_split/exceptions.py @@ -38,9 +38,6 @@ class ErrorMsg(StrEnum): MERGE_FAILED = "Merge of '{source}' into '{target}' failed: {detail}" PR_NOT_FOUND = "PR #{number} not found" PR_RESPONSE_INVALID = "Unexpected response from GitHub for PR #{number}: {detail}" - PR_NOT_FROM_FORK = ( - "PR #{number} is not from a fork; pass its head branch name instead of the PR number" - ) PR_FETCH_FAILED = "Failed to fetch fork branch for PR #{number}: {detail}" FORK_FETCH_FAILED = "Failed to fetch {user}:{branch}: {detail}" HUNK_TOO_LARGE = "Hunk {file}[{index}] has ~{tokens} estimated tokens, exceeds budget {budget}" diff --git a/pr_split/git_ops/prs.py b/pr_split/git_ops/prs.py index 616168a..2d799df 100644 --- a/pr_split/git_ops/prs.py +++ b/pr_split/git_ops/prs.py @@ -125,19 +125,23 @@ def fetch_fork_pr(pr_number: int) -> ForkPRInfo: if not isinstance(head_repo, dict): # head.repo is null when the fork was deleted raise GitOperationError(ErrorMsg.PR_NOT_FOUND(number=pr_number)) - if not head_repo.get("fork"): - raise GitOperationError(ErrorMsg.PR_NOT_FROM_FORK(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)) + if head_repo.get("fork"): + logger.info(logs.FETCHING_FORK_PR.format(number=pr_number, fork=fork_full_name)) + source, refspec = clone_url, f"{head_ref}:{local_ref}" + else: + # A same-repo PR's head lives on origin; fetch it through the remote + # already configured (and authenticated) for this checkout. + logger.info(logs.FETCHING_SAME_REPO_PR.format(number=pr_number, branch=head_ref)) + source, refspec = "origin", f"+refs/heads/{head_ref}:{local_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)) diff --git a/pr_split/logs.py b/pr_split/logs.py index 07d06bc..8af5ed4 100644 --- a/pr_split/logs.py +++ b/pr_split/logs.py @@ -26,6 +26,7 @@ " so 'pr-split clean' can be re-run" ) FETCHING_FORK_PR = "Fetching PR #{number} from fork {fork}" +FETCHING_SAME_REPO_PR = "Fetching PR #{number} from origin branch {branch}" FETCHING_FORK_BRANCH = "Fetching branch {branch} from fork {fork}" AUTHOR_PRESERVED = "Preserving author: {author}" COUNTING_TOKENS = "Counting input tokens ({model})" diff --git a/tests/test_git_prs_coverage.py b/tests/test_git_prs_coverage.py index d4a5052..88c8ad0 100644 --- a/tests/test_git_prs_coverage.py +++ b/tests/test_git_prs_coverage.py @@ -166,12 +166,15 @@ def test_git_fetch_failure(self, mock_gh: MagicMock, mock_git: MagicMock) -> Non fetch_fork_branch("user", "branch") -class TestFetchForkPrNotFromFork: +class TestFetchSameRepoPr: + @patch("pr_split.git_ops.branches.run_git") @patch("pr_split.git_ops.prs._run_gh") - def test_same_repo_pr_gets_specific_message(self, mock_gh: MagicMock) -> None: + def test_same_repo_pr_is_fetched_from_origin( + self, mock_gh: MagicMock, mock_git: MagicMock + ) -> None: pr_data = { "head": { - "ref": "feature", + "ref": "feat/1806-constant-node", "repo": { "fork": False, "clone_url": "https://github.com/org/repo.git", @@ -181,8 +184,17 @@ def test_same_repo_pr_gets_specific_message(self, mock_gh: MagicMock) -> None: "base": {"ref": "main"}, } mock_gh.return_value = json.dumps(pr_data) - with pytest.raises(GitOperationError, match="PR #42 is not from a fork"): - fetch_fork_pr(42) + mock_git.side_effect = ["", "A "] + + info = fetch_fork_pr(1931) + + assert mock_git.call_args_list[0].args == ( + "fetch", + "origin", + "+refs/heads/feat/1806-constant-node:refs/pr-split/pr-1931", + ) + assert info["local_ref"] == "refs/pr-split/pr-1931" + assert info["base_branch"] == "main" class TestFetchForkPrMalformedResponse: From 5951671d6112539e9187f15fb7d0179d1eb2be51 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Thu, 24 Sep 2026 20:06:36 +0100 Subject: [PATCH 095/143] fix: keep a diff within --max-loc as one group without running a backend A diff already under --max-loc needs no grouping, but every backend still ran, so the LLM strategy needed a working API key (a bad key ended the run) just to return what is necessarily a one-PR plan. plan_split now returns a single group holding the whole diff for such a diff, with no backend call. --- pr_split/logs.py | 1 + pr_split/planner/client.py | 42 ++++++++++++++++++++++++++++------ tests/test_cli_new_features.py | 4 ++-- tests/test_client.py | 29 ++++++++++++++++++++++- 4 files changed, 66 insertions(+), 10 deletions(-) diff --git a/pr_split/logs.py b/pr_split/logs.py index 07d06bc..365b1ad 100644 --- a/pr_split/logs.py +++ b/pr_split/logs.py @@ -31,6 +31,7 @@ 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" diff --git a/pr_split/planner/client.py b/pr_split/planner/client.py index af20231..bda8d63 100644 --- a/pr_split/planner/client.py +++ b/pr_split/planner/client.py @@ -516,18 +516,46 @@ def _plan_split_with_llm( 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/tests/test_cli_new_features.py b/tests/test_cli_new_features.py index 8ae68c8..76b8237 100644 --- a/tests/test_cli_new_features.py +++ b/tests/test_cli_new_features.py @@ -894,7 +894,7 @@ def test_invalid_assignment_type_is_reported_not_raised( 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 @@\n-x\n+y\n" + "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": [ @@ -912,7 +912,7 @@ def test_invalid_assignment_type_is_reported_not_raised( result = runner.invoke( app, - ["split", "feature-branch", "--dry-run"], + ["split", "feature-branch", "--dry-run", "--max-loc", "2"], env={"ANTHROPIC_API_KEY": "sk-test"}, ) diff --git a/tests/test_client.py b/tests/test_client.py index c1b43d1..6276b40 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -10,6 +10,7 @@ from pr_split.constants import AssignmentType, PartitionStrategy, Provider from pr_split.diff_ops.parser import parse_diff 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 +30,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 @@ -290,8 +292,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")] @@ -312,11 +316,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( From 61afcf892376d06a241312451418a256cc23a125 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Thu, 24 Sep 2026 20:06:40 +0100 Subject: [PATCH 096/143] fix: adopt a branch that exists only as a remote-tracking ref in split In a fresh clone or worktree a PR branch exists as origin/ but not locally, and split reported it as missing. Like git checkout, split now creates the local branch from the one remote that has it (dev and base alike); an ambiguous name on several remotes is left alone. --- pr_split/cli.py | 7 +++++ pr_split/git_ops/__init__.py | 3 +++ pr_split/git_ops/branches.py | 21 +++++++++++++++ pr_split/logs.py | 1 + tests/test_git_branches.py | 50 ++++++++++++++++++++++++++++++++++++ 5 files changed, 82 insertions(+) diff --git a/pr_split/cli.py b/pr_split/cli.py index a640735..db51345 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -53,6 +53,7 @@ ) from .git_ops import ( add_worktree, + adopt_remote_branch, branch_exists, check_gh_auth, check_gh_stack, @@ -940,6 +941,12 @@ def split( author: str | None = None fork_info: ForkPRInfo | None = None + # 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(): console.print(f"[red]{ErrorMsg.GH_AUTH_FAILED()}[/red]") diff --git a/pr_split/git_ops/__init__.py b/pr_split/git_ops/__init__.py index bd001f2..a8b6fbb 100644 --- a/pr_split/git_ops/__init__.py +++ b/pr_split/git_ops/__init__.py @@ -1,6 +1,9 @@ from .branches import ( add_worktree as add_worktree, ) +from .branches import ( + adopt_remote_branch as adopt_remote_branch, +) from .branches import ( branch_exists as branch_exists, ) diff --git a/pr_split/git_ops/branches.py b/pr_split/git_ops/branches.py index 513964b..70cb550 100644 --- a/pr_split/git_ops/branches.py +++ b/pr_split/git_ops/branches.py @@ -49,6 +49,27 @@ def branch_exists(branch: str) -> bool: return True +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. + 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", "--no-track", branch, candidates[0]) + logger.info(logs.ADOPTED_REMOTE_BRANCH.format(branch=branch, remote_ref=candidates[0])) + return True + + def is_worktree_clean() -> bool: output = run_git("status", "--porcelain") return all(line.startswith("??") for line in output.splitlines()) diff --git a/pr_split/logs.py b/pr_split/logs.py index 07d06bc..8f715c1 100644 --- a/pr_split/logs.py +++ b/pr_split/logs.py @@ -91,3 +91,4 @@ "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" ) +ADOPTED_REMOTE_BRANCH = "Created local branch {branch} from {remote_ref}" diff --git a/tests/test_git_branches.py b/tests/test_git_branches.py index c77c998..6e3c158 100644 --- a/tests/test_git_branches.py +++ b/tests/test_git_branches.py @@ -9,6 +9,7 @@ from pr_split.exceptions import GitOperationError from pr_split.git_ops.branches import ( add_worktree, + adopt_remote_branch, branch_exists, commit_exists, commit_files_in_dir, @@ -319,3 +320,52 @@ def test_true_when_object_resolves(self, mock_git: MagicMock) -> None: ) 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" + ) + + 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 From ecdab9669024332085e59b822e021dcbb3358893 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Thu, 24 Sep 2026 20:18:05 +0100 Subject: [PATCH 097/143] feat: edit dependencies, titles and descriptions in the plan editor The editor could only move one hunk at a time, so fixing depends_on, a PR title or description, a whole-file move or two groups that belong together meant hand-editing plan.json. It now has dep/undep, title, desc, movefile, new and merge; edits that would create a dependency cycle are refused. execute also gains --yes to skip the confirmation prompt, and the duplicated 'Execute complete' log line is removed. --- README.md | 8 +- pr_split/cli.py | 201 ++++++++++++++++++++++++++++++++++++- tests/test_cli_coverage.py | 129 ++++++++++++++++++++++++ 3 files changed, 335 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index da6e782..2f56486 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,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. 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. ### Interactive plan editing @@ -146,6 +146,12 @@ 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 diff --git a/pr_split/cli.py b/pr_split/cli.py index a640735..7c66143 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -782,10 +782,166 @@ def _depends_on(start: str, target: str) -> bool: 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" @@ -836,6 +992,43 @@ def _interactive_edit(groups: list[Group], parsed_diff: ParsedDiff) -> list[Grou # 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]" @@ -1244,6 +1437,10 @@ 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()) @@ -1309,7 +1506,8 @@ def execute( raise typer.Exit(1) from exc _present_plan(plan.groups) - typer.confirm("Proceed with creating branches and PRs?", abort=True) + 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) try: @@ -1344,7 +1542,6 @@ def execute( if plan.stacked: _link_stacks(PlanDAG(plan.groups), pr_records) logger.success(f"Execute complete: {len(plan.groups)} PRs created from saved plan") - logger.success(f"Execute complete: {len(plan.groups)} PRs created from saved plan") _AUTO_MERGE_POLL_INTERVAL = 10 diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index bef98cc..6e39fff 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -1162,3 +1162,132 @@ def test_moving_the_last_hunk_out_yields_a_valid_one_group_plan( 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 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.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 From 3f7810789e036a8c4295135872b58fa6dedc6a4b Mon Sep 17 00:00:00 2001 From: vitali87 Date: Thu, 24 Sep 2026 20:22:55 +0100 Subject: [PATCH 098/143] fix: resume execute past groups whose PRs already exist, and retry transient push errors A push that failed with a transient server error left some groups with PRs and the rest without, and execute then refused to run at all, so the only way on was clean (closing good PRs) and a full re-run. push_branch now retries known transient server errors, and execute keeps the groups that already have PRs (their branches untouched) and creates only the rest. --- pr_split/cli.py | 50 +++++++++++++++++++---- pr_split/git_ops/branches.py | 29 +++++++++++++- pr_split/logs.py | 1 + tests/test_cli_coverage.py | 78 ++++++++++++++++++++++++++++++++++++ tests/test_cli_helpers.py | 24 +++++++++++ tests/test_git_branches.py | 34 ++++++++++++++++ 6 files changed, 207 insertions(+), 9 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index d543c4d..f66df2d 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -283,7 +283,15 @@ def _create_branches_and_commits( *, author: str | None = None, stacked: bool = False, + keep: dict[str, BranchRecord] | None = None, ) -> list[BranchRecord]: + """Create a branch and commit per group; groups in ``keep`` reuse their record. + + ``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-")) if stacked: @@ -298,9 +306,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( @@ -327,7 +336,9 @@ def _create_branches_and_commits( break if errors: - for record in results.values(): + for gid, record in results.items(): + if gid in kept: + continue try: delete_branch(record.branch_name) except PRSplitError as exc: @@ -428,7 +439,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]] = [] @@ -437,8 +451,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: @@ -473,9 +488,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: @@ -1043,10 +1058,26 @@ def execute( if draft and not plan.draft: plan = plan.model_copy(update={"draft": True}) - if plan_file.git_state.prs: + existing_prs = {r.group_id: r for r in plan_file.git_state.prs} + kept_branches = { + r.group_id: r for r in plan_file.git_state.branches if r.group_id in existing_prs + } + 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 plan_file.git_state.branches: + 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 @@ -1109,12 +1140,15 @@ def execute( 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( diff --git a/pr_split/git_ops/branches.py b/pr_split/git_ops/branches.py index e7bc096..170b62e 100644 --- a/pr_split/git_ops/branches.py +++ b/pr_split/git_ops/branches.py @@ -2,6 +2,7 @@ import re import subprocess +import time from loguru import logger @@ -74,9 +75,35 @@ def commit_files(file_paths: list[str], message: str, *, author: str | None = No return run_git("rev-parse", "HEAD") +# 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 push_branch(branch: str) -> None: logger.info(logs.PUSHING_BRANCH.format(branch=branch)) - run_git("push", "--force-with-lease", "-u", "origin", branch) + 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) + if not transient or attempt == _PUSH_ATTEMPTS: + raise + logger.warning( + logs.PUSH_RETRY.format(branch=branch, attempt=attempt, error=str(exc).strip()) + ) + time.sleep(_PUSH_RETRY_DELAY * attempt) def delete_branch(branch: str, *, remote: bool = False) -> None: diff --git a/pr_split/logs.py b/pr_split/logs.py index ec302ce..139fa9c 100644 --- a/pr_split/logs.py +++ b/pr_split/logs.py @@ -69,3 +69,4 @@ PR_SKIPPED_BASE_NOT_PUSHED = ( "Skipping PR for group '{group}': its base branch '{base}' was not pushed" ) +PUSH_RETRY = "Push of {branch} failed (attempt {attempt}): {error}; retrying" diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index 5db3818..6097778 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -666,6 +666,84 @@ def test_branches_without_prs_is_retried( 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"} + + @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 diff --git a/tests/test_cli_helpers.py b/tests/test_cli_helpers.py index 01b6d8b..0470095 100644 --- a/tests/test_cli_helpers.py +++ b/tests/test_cli_helpers.py @@ -178,6 +178,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: diff --git a/tests/test_git_branches.py b/tests/test_git_branches.py index 7cc4793..99953c4 100644 --- a/tests/test_git_branches.py +++ b/tests/test_git_branches.py @@ -118,6 +118,40 @@ def test_calls_push(self, mock_git: MagicMock) -> None: ) +class TestPushBranchRetries: + @patch("pr_split.git_ops.branches.time.sleep") + @patch("pr_split.git_ops.branches.run_git") + 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"), + "", + ] + 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 TestDeleteBranch: @patch("pr_split.git_ops.branches.run_git") def test_local_only(self, mock_git: MagicMock) -> None: From e89daab3bb29e26206261b1b937345e26a281c89 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 15:42:45 +0100 Subject: [PATCH 099/143] fix: parse C-quoted paths that contain spaces without phantom files or parse errors --- pr_split/diff_ops/parser.py | 36 +++++++++- tests/test_diff_parser_extended.py | 106 ++++++++++++++++++++++++++++- 2 files changed, 140 insertions(+), 2 deletions(-) diff --git a/pr_split/diff_ops/parser.py b/pr_split/diff_ops/parser.py index 3fcccb8..99d1a8f 100644 --- a/pr_split/diff_ops/parser.py +++ b/pr_split/diff_ops/parser.py @@ -96,9 +96,43 @@ def unquote_git_path(path: str) -> str: 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 _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) -> ParsedDiff: 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: diff --git a/tests/test_diff_parser_extended.py b/tests/test_diff_parser_extended.py index 304f284..be71784 100644 --- a/tests/test_diff_parser_extended.py +++ b/tests/test_diff_parser_extended.py @@ -7,7 +7,12 @@ import pytest -from pr_split.diff_ops.parser import extract_diff, parse_diff, unquote_git_path +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 = ( @@ -309,3 +314,102 @@ def git(*args: str) -> str: 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 From b11aefb47ec29bab54a84e1dc26c0abc784906bb Mon Sep 17 00:00:00 2001 From: vitali87 Date: Thu, 24 Sep 2026 20:38:05 +0100 Subject: [PATCH 100/143] feat: adopt already-built branches as a native stack split only decomposes one branch, so two dependent branches built and reviewed separately (a review-driven follow-up) could not be stacked without setting a PR base by hand. pr-split adopt ... --base checks that each branch contains the one below it, links them as a native stack on the base (opening missing PRs) and saves them as a plan for status and merge. --- README.md | 8 +++ pr_split/cli.py | 109 ++++++++++++++++++++++++++++++++++++- pr_split/git_ops/prs.py | 18 +++++- tests/test_cli_coverage.py | 84 ++++++++++++++++++++++++++++ 4 files changed, 217 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index da6e782..7f8f3b8 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,14 @@ 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. +### 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. + ### Interactive plan editing After the plan is displayed, an interactive editor lets you adjust the plan before confirming: diff --git a/pr_split/cli.py b/pr_split/cli.py index 2a340c4..63f677d 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -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 @@ -67,7 +68,14 @@ remove_worktree, ) from .git_ops.branches import commit_exists, run_git -from .git_ops.prs import close_pr, create_pr, get_pr_state, link_stack, merge_pr +from .git_ops.prs import ( + close_pr, + create_pr, + find_open_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_coverage, validate_no_binary_files, validate_plan @@ -1208,6 +1216,105 @@ def _cleanup_git_state(git_state: GitState) -> tuple[int, int]: return closed_prs, deleted_branches +@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) + 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]") + 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]") + 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), + ) + ) + pr_records.append(PRRecord(group_id=gid, pr_number=pr[0], pr_url=pr[1])) + 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}") + + @app.command(help="Close all split PRs and delete their branches.") def clean() -> None: if not plan_exists(): diff --git a/pr_split/git_ops/prs.py b/pr_split/git_ops/prs.py index 8a37a52..5410943 100644 --- a/pr_split/git_ops/prs.py +++ b/pr_split/git_ops/prs.py @@ -2,6 +2,7 @@ import json import subprocess +from collections.abc import Sequence from loguru import logger @@ -44,6 +45,20 @@ def check_gh_stack() -> bool: 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.""" + raw = _run_gh( + "pr", "list", "--head", branch, "--state", "open", "--json", "number,url", "--limit", "1" + ) + try: + prs = json.loads(raw or "[]") + except json.JSONDecodeError: + return None + if not prs: + return None + return int(prs[0]["number"]), str(prs[0]["url"]) + + def create_pr( head: str, base: str, title: str, body: str, *, draft: bool = False ) -> tuple[int, str]: @@ -93,7 +108,8 @@ def close_pr(pr_number: int) -> None: logger.info(logs.PR_CLOSED.format(number=pr_number)) -def link_stack(pr_numbers: list[int], base: str) -> 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: diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index bef98cc..2ed1dc8 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 @@ -1162,3 +1163,86 @@ def test_moving_the_last_hunk_out_yields_a_valid_one_group_plan( 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] + + @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 From 5743cc0d19701e3cfbe8f8569965cca2164420b7 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Thu, 24 Sep 2026 20:40:49 +0100 Subject: [PATCH 101/143] feat: add a claude-cli provider that plans through the local Claude Code CLI The LLM planner needed a raw ANTHROPIC_API_KEY or OPENAI_API_KEY, so a machine with Claude Code logged in but no key (an agent session, for one) could only fall back to the graph backend. PR_SPLIT_PROVIDER=claude-cli runs `claude -p --json-schema` with the planner's tool schema, reads the structured output, and estimates tokens locally; no API key is required. --- README.md | 2 +- pr_split/config.py | 8 ++++- pr_split/constants.py | 3 ++ pr_split/exceptions.py | 1 + pr_split/planner/client.py | 63 ++++++++++++++++++++++++++++++++++++++ tests/test_client.py | 52 +++++++++++++++++++++++++++++++ 6 files changed, 127 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index da6e782..0acdcde 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ 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`, or `claude-cli` (the local Claude Code CLI, `claude -p`, using its own login; no API key needed) | | `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) | diff --git a/pr_split/config.py b/pr_split/config.py index b803fa8..f92cef8 100644 --- a/pr_split/config.py +++ b/pr_split/config.py @@ -52,6 +52,8 @@ 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 _: raise NotImplementedError(f"No default model for provider '{self.provider}'") return self @@ -83,6 +85,8 @@ 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 _: raise NotImplementedError( f"API key check not implemented for provider '{self.provider}'" @@ -96,13 +100,15 @@ def api_key(self) -> str: return self.anthropic_api_key case Provider.OPENAI: return self.openai_api_key + case Provider.CLAUDE_CLI: + return "" 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 diff --git a/pr_split/constants.py b/pr_split/constants.py index 4e8ac43..8e320db 100644 --- a/pr_split/constants.py +++ b/pr_split/constants.py @@ -36,6 +36,9 @@ 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" BRANCH_PREFIX = "pr-split/" diff --git a/pr_split/exceptions.py b/pr_split/exceptions.py index 0e1912a..88485f0 100644 --- a/pr_split/exceptions.py +++ b/pr_split/exceptions.py @@ -29,6 +29,7 @@ class ErrorMsg(StrEnum): "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" LLM_OUTPUT_TRUNCATED = ( "LLM response was cut off before the plan was complete ({detail});" " the partial plan cannot be trusted" diff --git a/pr_split/planner/client.py b/pr_split/planner/client.py index af20231..3a41e80 100644 --- a/pr_split/planner/client.py +++ b/pr_split/planner/client.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import subprocess from typing import TypedDict import anthropic @@ -125,6 +126,10 @@ def _count_tokens(system: str, user: str, *, settings: Settings) -> int: return _count_tokens_anthropic(system, user, settings=settings) case Provider.OPENAI: return _count_tokens_openai([system, user], model=settings.model) + case Provider.CLAUDE_CLI: + # No token-count endpoint behind the CLI; a tokenizer estimate is + # only used to decide whether to chunk. + return _count_tokens_openai([system, user], model="") def _call_anthropic(system: str, user: str, *, settings: Settings) -> RawToolOutput: @@ -193,6 +198,62 @@ 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)) + + def _call_llm(system: str, user: str, *, settings: Settings) -> RawToolOutput: system, user = _utf8_safe(system), _utf8_safe(user) match settings.provider: @@ -200,6 +261,8 @@ def _call_llm(system: str, user: str, *, settings: Settings) -> RawToolOutput: 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) def _call_chunk_with_retry( diff --git a/tests/test_client.py b/tests/test_client.py index c1b43d1..fc65dc2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1378,3 +1378,55 @@ def test_count_tokens_replaces_surrogates(self, mock_count: MagicMock) -> None: 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 + ) From 8688aa026b1418b8e754c12ab1c433bc4a3a8b08 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Thu, 24 Sep 2026 20:41:07 +0100 Subject: [PATCH 102/143] fix: say when groups exceed --max-loc and record them in the plan With strict bounds and refinement both off by default, a plan could leave 17 of 36 groups over the 400-line cap with only per-group log warnings. split and execute now print one line with the count, the limit and the largest group, and the plan file records the oversized group ids. --- pr_split/cli.py | 21 +++++++++++++++++++++ pr_split/schemas.py | 2 ++ tests/test_cli_coverage.py | 1 + tests/test_cli_helpers.py | 29 +++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+) diff --git a/pr_split/cli.py b/pr_split/cli.py index a640735..56e499a 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -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 @@ -213,6 +214,23 @@ def _handle_loc_bound_warnings(warnings: list[str], *, strict_loc_bounds: bool) logger.warning(warning) +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) -> None: + """One visible line when groups miss the --max-loc target, so it is never silent.""" + 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) + 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). 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") @@ -1052,6 +1070,7 @@ 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) except PRSplitError as exc: console.print(f"[red]Edited plan is invalid: {exc}[/red]") raise typer.Exit(1) from exc @@ -1072,6 +1091,7 @@ def split( merge_base_sha=merge_base_ref, dev_branch_arg=dev_branch_arg, raw_diff=raw_diff, + oversized_groups=_oversized_group_ids(groups, settings.max_loc), ) if dry_run: @@ -1309,6 +1329,7 @@ def execute( raise typer.Exit(1) from exc _present_plan(plan.groups) + _report_oversized_groups(plan.groups, plan.max_loc) typer.confirm("Proceed with creating branches and PRs?", abort=True) namespace = derive_split_namespace(plan.dev_branch_arg or plan.dev_branch) diff --git a/pr_split/schemas.py b/pr_split/schemas.py index 232385c..491e6de 100644 --- a/pr_split/schemas.py +++ b/pr_split/schemas.py @@ -61,6 +61,8 @@ class SplitPlan(BaseModel): merge_base_sha: str | None = None dev_branch_arg: str | None = None raw_diff: 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): diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index bef98cc..48012fc 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -780,6 +780,7 @@ def test_execute_branch_creation_failure_is_a_clean_error( 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"]) diff --git a/tests/test_cli_helpers.py b/tests/test_cli_helpers.py index aaef898..f03ca17 100644 --- a/tests/test_cli_helpers.py +++ b/tests/test_cli_helpers.py @@ -700,3 +700,32 @@ def capture(cwd: str, file_paths: list[str], message: str, **kwargs: object) -> 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 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_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"] From c2ea61794c1abc0a6d1023b4358187cd0a186372 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Thu, 24 Sep 2026 20:44:51 +0100 Subject: [PATCH 103/143] feat: record how a plan was made and say when re-running may change it Two identical runs of split with the llm strategy returned materially different plans, with nothing in the output saying the plan was one sample. The plan file now records the partition and chunk strategy (and provider and model for llm), split prints that provenance, and for llm and cp_sat it says a re-run may differ so the saved plan is what to keep. The README states that graph is deterministic and the other two are not. --- README.md | 2 +- pr_split/cli.py | 23 +++++++++++++++++++++++ pr_split/schemas.py | 6 ++++++ tests/test_cli_helpers.py | 30 ++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index da6e782..82d8e08 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ 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`) | +| `--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` | Stack dependent PRs: each child branches from and targets its parent's branch | | `--draft` | `false` | Open every sub-PR as a draft | diff --git a/pr_split/cli.py b/pr_split/cli.py index a640735..bd644bf 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -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 @@ -213,6 +214,19 @@ 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 ''})" + 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 _present_plan(groups: list[Group]) -> None: table = Table(title="Split Plan") table.add_column("ID") @@ -1072,7 +1086,16 @@ def split( merge_base_sha=merge_base_ref, dev_branch_arg=dev_branch_arg, raw_diff=raw_diff, + partition_strategy=settings.partition_strategy.value, + chunk_strategy=settings.chunk_strategy.value, + provider=( + settings.provider.value + if settings.partition_strategy is PartitionStrategy.LLM + else None + ), + model=settings.model if settings.partition_strategy is PartitionStrategy.LLM else None, ) + console.print(_plan_provenance(split_plan)) if dry_run: save_plan(PlanFile(plan=split_plan, git_state=GitState(branches=[], prs=[]))) diff --git a/pr_split/schemas.py b/pr_split/schemas.py index 232385c..db6dcbb 100644 --- a/pr_split/schemas.py +++ b/pr_split/schemas.py @@ -61,6 +61,12 @@ 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 class BranchRecord(BaseModel): diff --git a/tests/test_cli_helpers.py b/tests/test_cli_helpers.py index aaef898..bd14d74 100644 --- a/tests/test_cli_helpers.py +++ b/tests/test_cli_helpers.py @@ -700,3 +700,33 @@ def capture(cwd: str, file_paths: list[str], message: str, **kwargs: object) -> 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_old_plans_without_provenance_still_load(self) -> None: + plan = self._plan() + assert plan.partition_strategy is None and plan.model is None From cc72ae2be1775ca6afdbec31c0f46a7b25efe047 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Thu, 24 Sep 2026 20:49:02 +0100 Subject: [PATCH 104/143] fix: name oversized groups that hold a single hunk as unsplittable A single hunk, such as a whole new file, larger than --max-loc cannot be brought under the limit by any plan; the summary now says so. --- pr_split/cli.py | 14 ++++++++++++-- tests/test_cli_helpers.py | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index 56e499a..c1ed459 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -224,10 +224,20 @@ def _report_oversized_groups(groups: list[Group], max_loc: int) -> None: 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.hunk_indices) 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). Use the editor," - " --max-refinement-iterations or --strict-loc-bounds to act on it.[/yellow]" + 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]" ) diff --git a/tests/test_cli_helpers.py b/tests/test_cli_helpers.py index f03ca17..acd923e 100644 --- a/tests/test_cli_helpers.py +++ b/tests/test_cli_helpers.py @@ -717,6 +717,22 @@ def test_summary_names_count_limit_and_largest(self) -> None: 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) + out = " ".join(capture.get().split()) + assert "pr-1 hold a single hunk each and cannot be split below the limit" in out + def test_nothing_printed_within_the_limit(self) -> None: from pr_split.cli import _report_oversized_groups, console From 7a41072516857cebf673e18e404d34698ce2f95a Mon Sep 17 00:00:00 2001 From: vitali87 Date: Thu, 24 Sep 2026 20:53:50 +0100 Subject: [PATCH 105/143] feat: retarget a split whose base branch has merged A --stack split made onto a feature branch was stranded once that branch merged: its root PRs still targeted it, and GitHub refuses to change the base of a stacked PR. pr-split retarget [--to ] unstacks the plan's native stacks, moves the root PRs to the new base, relinks the stacks on it and updates the plan; status warns and merge refuses while the base has merged. --- README.md | 9 ++++ pr_split/cli.py | 90 ++++++++++++++++++++++++++++++++++++++ pr_split/git_ops/prs.py | 35 +++++++++++++++ pr_split/logs.py | 4 ++ tests/conftest.py | 10 +++++ tests/test_cli_coverage.py | 74 +++++++++++++++++++++++++++++++ 6 files changed, 222 insertions(+) create mode 100644 tests/conftest.py diff --git a/README.md b/README.md index 7f8f3b8..3a09150 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,15 @@ 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. +### 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 After the plan is displayed, an interactive editor lets you adjust the plan before confirming: diff --git a/pr_split/cli.py b/pr_split/cli.py index 63f677d..68c03b1 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -69,12 +69,17 @@ ) 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 @@ -1130,6 +1135,11 @@ def status() -> None: 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} @@ -1315,6 +1325,80 @@ def adopt( 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(): @@ -1534,6 +1618,12 @@ def merge_all( console.print("[yellow]No PRs found in plan. Nothing to merge.[/yellow]") raise typer.Exit(0) + 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: diff --git a/pr_split/git_ops/prs.py b/pr_split/git_ops/prs.py index 5410943..3ba0578 100644 --- a/pr_split/git_ops/prs.py +++ b/pr_split/git_ops/prs.py @@ -211,3 +211,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/logs.py b/pr_split/logs.py index 07d06bc..696d71d 100644 --- a/pr_split/logs.py +++ b/pr_split/logs.py @@ -91,3 +91,7 @@ "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" ) +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" +) 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/test_cli_coverage.py b/tests/test_cli_coverage.py index 2ed1dc8..c9754d8 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -1246,3 +1246,77 @@ 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()) From d2cbc213620619cf9a2a11bbd51e211d47dabf25 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Thu, 24 Sep 2026 20:54:18 +0100 Subject: [PATCH 106/143] feat: order groups by the names they define and use Dependency edges came only from shared files, so a new test file that imports a new module got no edge to it, and a plan could put a definition in a child of the PR that uses it; either way a sub-PR could not import or compile on its own. A lexical pass over each group's added lines now finds names a group newly defines and whole-word uses of them elsewhere. The graph and cp_sat backends add the user->definer edge (skipping one that would close a cycle), and plan validation warns when a group uses a name from a group it does not build on. --- pr_split/logs.py | 8 ++ pr_split/planner/partitioning.py | 45 ++++++++-- pr_split/planner/symbols.py | 92 +++++++++++++++++++ pr_split/planner/validator.py | 26 ++++++ tests/test_symbols.py | 146 +++++++++++++++++++++++++++++++ 5 files changed, 312 insertions(+), 5 deletions(-) create mode 100644 pr_split/planner/symbols.py create mode 100644 tests/test_symbols.py diff --git a/pr_split/logs.py b/pr_split/logs.py index 07d06bc..76b4bb1 100644 --- a/pr_split/logs.py +++ b/pr_split/logs.py @@ -91,3 +91,11 @@ "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" ) +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" +) diff --git a/pr_split/planner/partitioning.py b/pr_split/planner/partitioning.py index 79897b2..070fb04 100644 --- a/pr_split/planner/partitioning.py +++ b/pr_split/planner/partitioning.py @@ -15,6 +15,7 @@ from ..graph import PlanDAG from ..schemas import Group, GroupAssignment from .chunker import recompute_estimated_loc +from .symbols import symbol_dependencies if TYPE_CHECKING: from collections.abc import Iterable, Sequence @@ -550,9 +551,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} @@ -586,11 +624,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]: diff --git a/pr_split/planner/symbols.py b/pr_split/planner/symbols.py new file mode 100644 index 0000000..6f85435 --- /dev/null +++ b/pr_split/planner/symbols.py @@ -0,0 +1,92 @@ +"""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()} diff --git a/pr_split/planner/validator.py b/pr_split/planner/validator.py index 1d04b80..412b868 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,7 @@ 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: @@ -153,6 +156,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, @@ -165,4 +189,6 @@ def validate_plan( validate_coverage(groups, parsed_diff) validate_loc(groups, parsed_diff) 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/tests/test_symbols.py b/tests/test_symbols.py new file mode 100644 index 0000000..2ad021a --- /dev/null +++ b/tests/test_symbols.py @@ -0,0 +1,146 @@ +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) From dec4a733cfb153837f37928ee9dac4d339e3c847 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Thu, 24 Sep 2026 20:58:31 +0100 Subject: [PATCH 107/143] feat: group units that share names in the graph backend and name every group The graph backend grouped only by file, directory and suffix, so across unrelated directories it produced size slices named review-slice-N that cannot be reviewed on their own terms. Each partition unit now carries the names its added lines define and use, a unit that uses what another defines gets a strong affinity to it, and a multi-file group is named after the directory its files share or after its largest file. --- pr_split/planner/partitioning.py | 58 +++++++++++++++++++++----------- pr_split/planner/symbols.py | 12 +++++++ tests/test_symbols.py | 43 +++++++++++++++++++++++ 3 files changed, 93 insertions(+), 20 deletions(-) diff --git a/pr_split/planner/partitioning.py b/pr_split/planner/partitioning.py index 070fb04..f25a08f 100644 --- a/pr_split/planner/partitioning.py +++ b/pr_split/planner/partitioning.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from collections import defaultdict from dataclasses import dataclass from graphlib import CycleError, TopologicalSorter @@ -15,12 +16,13 @@ from ..graph import PlanDAG from ..schemas import Group, GroupAssignment from .chunker import recompute_estimated_loc -from .symbols import symbol_dependencies +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 @@ -31,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 @@ -49,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]: @@ -61,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 @@ -78,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 @@ -125,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) @@ -500,7 +510,15 @@ 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 _build_group_description(backend: PartitionStrategy, units: list[PartitionUnit]) -> str: diff --git a/pr_split/planner/symbols.py b/pr_split/planner/symbols.py index 6f85435..cb0b1e1 100644 --- a/pr_split/planner/symbols.py +++ b/pr_split/planner/symbols.py @@ -90,3 +90,15 @@ def symbol_dependencies( 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/tests/test_symbols.py b/tests/test_symbols.py index 2ad021a..ab4bd1c 100644 --- a/tests/test_symbols.py +++ b/tests/test_symbols.py @@ -144,3 +144,46 @@ def test_graph_partition_links_the_test_group_to_the_module_group() -> None: 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" From c3a6541de89ab89846496f53359b0efbd0f1687e Mon Sep 17 00:00:00 2001 From: vitali87 Date: Thu, 24 Sep 2026 21:07:37 +0100 Subject: [PATCH 108/143] feat: keep one plan per dev branch and keep the plan dir out of git The plan lived at one fixed .pr-split/plan.json, so a dry run for a second branch overwrote the first split's plan, and the directory (holding the whole diff) showed up untracked and could be committed by git add -A. Plans are now saved per dev branch under .pr-split/plans/, commands use the only saved plan or the one named with the global --branch option, a legacy plan.json is moved to its branch's file, and .pr-split/ is added to .git/info/exclude. --- README.md | 12 ++++- pr_split/cli.py | 18 +++++++- pr_split/exceptions.py | 4 ++ pr_split/plan_store.py | 98 +++++++++++++++++++++++++++++++++++++-- tests/test_plan_store.py | 99 ++++++++++++++++++++++++++++++++++++++-- 5 files changed, 220 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 01a0b24..3f2d6ec 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ pr-split split feature-branch --base main --dry-run | `--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 | +| `--dry-run` | `false` | Preview plan and save to `.pr-split/plans/.json` without creating branches or PRs | ### Stack dependent PRs @@ -124,6 +124,16 @@ Use `--notify` to POST merge results to a webhook URL (e.g. Slack, Discord): 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 the plan (which holds the whole diff) never shows up in `git status` or gets committed. 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 diff --git a/pr_split/cli.py b/pr_split/cli.py index 35a87ad..7b1a717 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -60,7 +60,7 @@ from .git_ops.branches import run_git 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_dir, plan_exists, plan_path, save_plan +from .plan_store import load_plan, plan_dir, plan_exists, plan_path, save_plan, select_plan from .planner import plan_split, validate_coverage, validate_plan from .schemas import ( BranchRecord, @@ -80,6 +80,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(derive_split_namespace(branch) if branch else None) + + def _render_dag(groups: list[Group]) -> str: roots = [g for g in groups if not g.depends_on] tree = Tree("Split Plan") @@ -730,6 +745,7 @@ def split( dev_branch_arg = dev_branch author: str | None = None fork_info: ForkPRInfo | None = None + select_plan(derive_split_namespace(dev_branch_arg)) if not branch_exists(dev_branch): if not check_gh_auth(): diff --git a/pr_split/exceptions.py b/pr_split/exceptions.py index 8fafefe..72ef8b7 100644 --- a/pr_split/exceptions.py +++ b/pr_split/exceptions.py @@ -17,6 +17,10 @@ class ErrorMsg(StrEnum): 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" ) diff --git a/pr_split/plan_store.py b/pr_split/plan_store.py index 28333f8..864dd5f 100644 --- a/pr_split/plan_store.py +++ b/pr_split/plan_store.py @@ -1,3 +1,4 @@ +import json import os from pathlib import Path @@ -7,7 +8,7 @@ from . import logs from .constants import PLAN_DIR, PLAN_FILE from .exceptions import ErrorMsg, GitOperationError, PRSplitError -from .git_ops.branches import run_git +from .git_ops.branches import derive_split_namespace, run_git from .schemas import PlanFile @@ -24,14 +25,94 @@ def repo_root() -> Path: return Path.cwd() +# The plan the current command works on, keyed by dev-branch slug. 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 select_plan(branch_slug: str | None) -> None: + global _selected + _selected = branch_slug + + def plan_dir() -> Path: return repo_root() / PLAN_DIR +def _plans_dir() -> Path: + return plan_dir() / "plans" + + +def saved_plan_slugs() -> list[str]: + folder = _plans_dir() + return sorted(p.stem for p in folder.glob("*.json")) if folder.is_dir() else [] + + +def _migrate_legacy_plan() -> None: + """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. + """ + legacy = repo_root() / PLAN_FILE + if not legacy.exists(): + return + 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 + target = _plans_dir() / f"{derive_split_namespace(str(branch))}.json" + if target.exists(): + return + target.parent.mkdir(parents=True, exist_ok=True) + os.replace(legacy, target) + + def plan_path() -> Path: + """Where the selected plan lives: per branch, or the legacy single file.""" + _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. + return legacy if not selected.exists() and legacy.exists() else selected + slugs = saved_plan_slugs() + if len(slugs) == 1: + return _plans_dir() / f"{slugs[0]}.json" + if len(slugs) > 1: + raise PRSplitError(ErrorMsg.PLAN_AMBIGUOUS(branches=", ".join(slugs))) return repo_root() / PLAN_FILE +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(): + common = repo_root() / common + 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: """Atomically replace the plan file. @@ -41,8 +122,14 @@ def save_plan(plan_file: PlanFile) -> None: temporary file in the same directory and renamed over the target, which is atomic on POSIX and Windows alike. """ - plan_dir().mkdir(parents=True, exist_ok=True) - target = plan_path() + 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"{derive_split_namespace(branch)}.json" + else: + target = plan_path() + target.parent.mkdir(parents=True, exist_ok=True) + _exclude_plan_dir() tmp = target.with_name(target.name + ".tmp") try: tmp.write_text(plan_file.model_dump_json(indent=2)) @@ -65,4 +152,7 @@ def load_plan() -> PlanFile: def plan_exists() -> bool: - return plan_path().exists() + try: + return plan_path().exists() + except PRSplitError: + return True # several plans: let load_plan report which to pick diff --git a/tests/test_plan_store.py b/tests/test_plan_store.py index a89613c..27321dd 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_dir, plan_exists, plan_path, save_plan +from pr_split.plan_store import load_plan, plan_exists, plan_path, save_plan from pr_split.schemas import ( BranchRecord, GitState, @@ -101,7 +101,7 @@ 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 @@ -165,9 +165,9 @@ def test_plan_saved_from_a_subdirectory_is_found_from_the_root( repo = self._repo(tmp_path) monkeypatch.chdir(repo / "src") save_plan(_make_plan_file()) - assert (repo / ".pr-split" / "plan.json").exists() + assert (repo / ".pr-split" / "plans" / "feat-big.json").exists() assert not (repo / "src" / ".pr-split").exists() - assert plan_path() == (repo / ".pr-split" / "plan.json").resolve() + assert plan_path() == (repo / ".pr-split" / "plans" / "feat-big.json").resolve() monkeypatch.chdir(repo) assert plan_exists() @@ -199,7 +199,8 @@ def test_no_temp_file_left_behind( ) -> None: monkeypatch.chdir(tmp_path) save_plan(_make_plan_file()) - leftovers = [p.name for p in plan_dir().iterdir() if p.name != "plan.json"] + 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( @@ -226,3 +227,91 @@ def exploding_write(self: Path, *args: object, **kwargs: object) -> int: 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_slugs, select_plan + + select_plan("feat-a") + save_plan(_make_plan_file()) + select_plan("feat-b") + save_plan(_make_plan_file()) + + assert saved_plan_slugs() == ["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_slugs, 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_slugs() == ["feat-big"] From 82c3b6c95d4fa0c212f131da475c35ba3104730e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 11:42:35 +0000 Subject: [PATCH 109/143] feat: plan with a local LLM through any OpenAI-compatible server PR_SPLIT_PROVIDER=local sends the llm backend's requests to /v1/chat/completions on PR_SPLIT_LOCAL_BASE_URL (Ollama by default), so a split can be planned by Ollama, llama.cpp, vLLM or LM Studio with no API key and without the diff leaving the machine. - The split tool is forced and temperature is 0, so the same diff gets the same plan. - A plan returned as JSON text, bare or wrapped as {"name", "arguments"}, is accepted from models that ignore the forced tool call. - The context window and the per-reply output budget are configurable (PR_SPLIT_LOCAL_CONTEXT_TOKENS, PR_SPLIT_LOCAL_MAX_OUTPUT_TOKENS). The chunker now subtracts the provider's own output budget instead of a fixed 128k, so a 32k local window gets a positive diff budget. - PR_SPLIT_MODEL is required, and an unreachable server is reported with its URL. Closes #221 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- README.md | 24 ++- pr_split/config.py | 34 ++++ pr_split/constants.py | 6 + pr_split/exceptions.py | 12 ++ pr_split/planner/client.py | 100 +++++++++++- tests/test_local_provider.py | 298 +++++++++++++++++++++++++++++++++++ 6 files changed, 469 insertions(+), 5 deletions(-) create mode 100644 tests/test_local_provider.py diff --git a/README.md b/README.md index da6e782..51177c3 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ uv tool install "pr-split[cp-sat]" - Python 3.12+ - [GitHub CLI](https://cli.github.com/) (`gh`) authenticated via `gh auth login` - [`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 +- `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) ## Usage @@ -188,10 +188,14 @@ 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`, 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 | @@ -264,6 +268,20 @@ jobs: 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]"`. +### 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. The diff never leaves the machine and no API key is needed: + +```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`. + For a deeper explanation of the planning model, optimization methods, scoring, and research directions, see [METHODOLOGY.md](METHODOLOGY.md). ## What it does diff --git a/pr_split/config.py b/pr_split/config.py index b803fa8..2a7d668 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,8 @@ def set_default_model(self): self.model = DEFAULT_MODEL case Provider.OPENAI: self.model = OPENAI_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 @@ -83,6 +93,16 @@ 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.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}'" @@ -96,6 +116,10 @@ def api_key(self) -> str: return self.anthropic_api_key case Provider.OPENAI: return self.openai_api_key + 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") @@ -106,5 +130,15 @@ def max_context_tokens(self) -> int: 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..d8ef25f 100644 --- a/pr_split/constants.py +++ b/pr_split/constants.py @@ -36,6 +36,9 @@ class PRState(StrEnum): class Provider(StrEnum): ANTHROPIC = "anthropic" OPENAI = "openai" + # 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 +57,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/exceptions.py b/pr_split/exceptions.py index 0e1912a..bc195a2 100644 --- a/pr_split/exceptions.py +++ b/pr_split/exceptions.py @@ -29,6 +29,18 @@ class ErrorMsg(StrEnum): "Cannot load split plan from '{path}': {detail}; delete it and run 'pr-split split' again" ) LLM_PARSE_ERROR = "Failed to parse LLM response: {detail}" + 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})" + ) + 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" diff --git a/pr_split/planner/client.py b/pr_split/planner/client.py index af20231..31ce86c 100644 --- a/pr_split/planner/client.py +++ b/pr_split/planner/client.py @@ -8,6 +8,7 @@ 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 @@ -51,6 +52,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, @@ -125,6 +135,10 @@ def _count_tokens(system: str, user: str, *, settings: Settings) -> int: return _count_tokens_anthropic(system, user, settings=settings) case Provider.OPENAI: return _count_tokens_openai([system, user], model=settings.model) + case Provider.LOCAL: + # Local servers have no 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: @@ -193,6 +207,86 @@ def _call_openai(system: str, user: str, *, settings: Settings) -> RawToolOutput raise LLMError(ErrorMsg.LLM_PARSE_ERROR(detail="no function_call in response output")) +_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 + + +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. + """ + 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: @@ -200,6 +294,8 @@ def _call_llm(system: str, user: str, *, settings: Settings) -> RawToolOutput: return _call_anthropic(system, user, settings=settings) case Provider.OPENAI: return _call_openai(system, user, settings=settings) + case Provider.LOCAL: + return _call_local(system, user, settings=settings) def _call_chunk_with_retry( @@ -311,7 +407,7 @@ 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 diff_chars = len(parsed_diff.raw_diff) token_ratio = (full_token_count - overhead) / diff_chars if diff_chars > 0 else 0.25 @@ -492,7 +588,7 @@ 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: diff --git a/tests/test_local_provider.py b/tests/test_local_provider.py new file mode 100644 index 0000000..f19f151 --- /dev/null +++ b/tests/test_local_provider.py @@ -0,0 +1,298 @@ +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": []} From d47d7ab3ff574046da69dd472f0cae9ba8898c42 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 12:08:13 +0000 Subject: [PATCH 110/143] feat: split an oversized new file across a stack at top-level boundaries Git reports a new file as one hunk, so no plan could bring a new file larger than --max-loc under the limit; the group was simply over it. With --stack, parse_diff now cuts such a file into pieces of at most --max-loc lines between top-level blocks. Python files are cut at the top-level statements ast finds, keeping decorators and comments with their definition. Other files are cut at an unindented line after a blank line that is outside any bracket, string or block comment. A single block larger than the limit stays whole. A stacked child already carries its ancestors' hunks, and a new file is rebuilt by concatenating the pieces a branch holds. So every layer holds a valid prefix and the top of the chain holds the whole file, provided each piece's group builds on the previous piece's group. plan_split and the editor add that edge when it is missing, and validate_plan and execute reject a plan that lacks it. The plan records the threshold (split_new_files_over) so execute re-parses the diff to the same hunks. Without --stack nothing changes: a sub-PR holding a later piece would target the base and create the file without its beginning. Checked on 952 site-packages files over 400 lines: every cut prefix compiles, with both the ast path and the generic rules. Refs #190 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- README.md | 2 + pr_split/cli.py | 10 +- pr_split/diff_ops/new_file_split.py | 157 +++++++++++++ pr_split/diff_ops/parser.py | 25 +- pr_split/exceptions.py | 5 + pr_split/logs.py | 7 + pr_split/planner/client.py | 2 + pr_split/planner/new_file_pieces.py | 82 +++++++ pr_split/planner/validator.py | 2 + pr_split/schemas.py | 3 + tests/test_new_file_split.py | 340 ++++++++++++++++++++++++++++ 11 files changed, 632 insertions(+), 3 deletions(-) create mode 100644 pr_split/diff_ops/new_file_split.py create mode 100644 pr_split/planner/new_file_pieces.py create mode 100644 tests/test_new_file_split.py diff --git a/README.md b/README.md index 51177c3..5c4a63a 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,8 @@ 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 new file larger than `--max-loc` cannot fit in any one sub-PR. With `--stack` it is cut into pieces between top-level definitions: Python files at top-level statements, and other languages at unindented lines after a blank line, outside brackets, strings and comments. Each piece is appended by a PR stacked on the one holding the piece before it. Every layer holds a valid prefix of the file, and the top of the chain holds the whole file. Without `--stack` a new file is never cut. + Linear chains in the plan are registered as [native GitHub stacks](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 diff --git a/pr_split/cli.py b/pr_split/cli.py index a640735..4c71f86 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -72,6 +72,7 @@ from .plan_store import load_plan, plan_exists, save_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, validate_new_file_pieces from .schemas import ( BranchRecord, GitState, @@ -981,7 +982,9 @@ def split( logger.info("Overwriting existing dry-run plan") raw_diff = extract_diff(dev_branch, base) - parsed_diff = parse_diff(raw_diff) + # 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( @@ -1041,6 +1044,7 @@ def split( 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( @@ -1072,6 +1076,7 @@ def split( merge_base_sha=merge_base_ref, dev_branch_arg=dev_branch_arg, raw_diff=raw_diff, + split_new_files_over=split_new_files_over, ) if dry_run: @@ -1295,7 +1300,7 @@ def execute( 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) @@ -1304,6 +1309,7 @@ def execute( 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 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 3fcccb8..0ff64f3 100644 --- a/pr_split/diff_ops/parser.py +++ b/pr_split/diff_ops/parser.py @@ -11,6 +11,7 @@ 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: @@ -96,7 +97,14 @@ def unquote_git_path(path: str) -> str: return bytes(out).decode("utf-8", errors="surrogateescape") -def parse_diff(raw_diff: str) -> ParsedDiff: +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) except Exception as exc: @@ -104,6 +112,12 @@ def parse_diff(raw_diff: str) -> ParsedDiff: 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) @@ -112,6 +126,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/exceptions.py b/pr_split/exceptions.py index bc195a2..65fca90 100644 --- a/pr_split/exceptions.py +++ b/pr_split/exceptions.py @@ -22,6 +22,11 @@ class ErrorMsg(StrEnum): 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" diff --git a/pr_split/logs.py b/pr_split/logs.py index 07d06bc..e7aac8c 100644 --- a/pr_split/logs.py +++ b/pr_split/logs.py @@ -91,3 +91,10 @@ "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" ) +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}'" +) diff --git a/pr_split/planner/client.py b/pr_split/planner/client.py index 31ce86c..8d41d50 100644 --- a/pr_split/planner/client.py +++ b/pr_split/planner/client.py @@ -33,6 +33,7 @@ format_group_catalog, recompute_estimated_loc, ) +from .new_file_pieces import link_new_file_pieces from .partitioning import partition_diff from .prompts import ( SPLIT_TOOL_NAME, @@ -624,6 +625,7 @@ def plan_split( groups = partition_diff(parsed_diff, settings) case _: raise PRSplitError(f"Unsupported partition strategy '{settings.partition_strategy}'") + link_new_file_pieces(groups, parsed_diff) 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..679b7e6 --- /dev/null +++ b/pr_split/planner/new_file_pieces.py @@ -0,0 +1,82 @@ +"""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 be the group holding +piece k-1 or build on it; otherwise its sub-PR creates the file without its +beginning. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from loguru import logger + +from .. import logs +from ..exceptions import ErrorMsg, PlanValidationError, PRSplitError +from ..graph import PlanDAG + +if TYPE_CHECKING: + from ..diff_ops import ParsedDiff + from ..schemas import Group + + +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: + continue + for idx in assignment.covered_indices(pieces): + if 0 <= idx < pieces: + owners[idx] = group.id + return owners + + +def _out_of_order( + groups: list[Group], parsed_diff: ParsedDiff, dag: PlanDAG +) -> list[tuple[str, int, str, str]]: + """(file, piece, group, parent) for each piece whose group skips the previous piece's.""" + 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 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 _out_of_order(groups, parsed_diff, PlanDAG(groups)): + group = by_id[child] + # An earlier edge in this loop may already have ordered the pair. + if parent in PlanDAG(groups).ancestors(child): + continue + 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)) + + +def validate_new_file_pieces(groups: list[Group], parsed_diff: ParsedDiff, dag: PlanDAG) -> None: + for path, piece, child, parent in _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 + ) + ) diff --git a/pr_split/planner/validator.py b/pr_split/planner/validator.py index 1d04b80..eff6719 100644 --- a/pr_split/planner/validator.py +++ b/pr_split/planner/validator.py @@ -7,6 +7,7 @@ from ..graph import PlanDAG from ..schemas import Group from ..types_defs import LocBoundViolation +from .new_file_pieces import validate_new_file_pieces def validate_no_binary_files(parsed_diff: ParsedDiff) -> None: @@ -161,6 +162,7 @@ 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) diff --git a/pr_split/schemas.py b/pr_split/schemas.py index 232385c..23c4df8 100644 --- a/pr_split/schemas.py +++ b/pr_split/schemas.py @@ -61,6 +61,9 @@ class SplitPlan(BaseModel): merge_base_sha: str | None = None dev_branch_arg: str | None = None raw_diff: str | None = None + # --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 class BranchRecord(BaseModel): diff --git a/tests/test_new_file_split.py b/tests/test_new_file_split.py new file mode 100644 index 0000000..bd4372b --- /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, validate_new_file_pieces +from pr_split.planner.validator import 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 From 75e56b66ebab7b7bfd02200506360f42efaf6213 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 12:13:14 +0000 Subject: [PATCH 111/143] refactor: place the new-file piece check where it cannot collide with open PRs Several open PRs append to the end of logs.py, add a SplitPlan field after raw_diff, add an import to validator.py, or rewrite plan_split. Move this branch's additions off those lines: the piece-order check now lives in validator.py itself, the linking step runs in the CLI right after plan_split, and the new messages and field sit mid-block. No behaviour change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- pr_split/cli.py | 6 ++-- pr_split/logs.py | 14 ++++---- pr_split/planner/client.py | 2 -- pr_split/planner/new_file_pieces.py | 53 +++++------------------------ pr_split/planner/validator.py | 43 ++++++++++++++++++++++- pr_split/schemas.py | 6 ++-- tests/test_new_file_split.py | 4 +-- 7 files changed, 67 insertions(+), 61 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index 4c71f86..bf383be 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -72,7 +72,8 @@ from .plan_store import load_plan, plan_exists, save_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, validate_new_file_pieces +from .planner.new_file_pieces import link_new_file_pieces +from .planner.validator import validate_new_file_pieces from .schemas import ( BranchRecord, GitState, @@ -1016,6 +1017,7 @@ def split( 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 @@ -1069,6 +1071,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, @@ -1076,7 +1079,6 @@ def split( merge_base_sha=merge_base_ref, dev_branch_arg=dev_branch_arg, raw_diff=raw_diff, - split_new_files_over=split_new_files_over, ) if dry_run: diff --git a/pr_split/logs.py b/pr_split/logs.py index e7aac8c..e15e2e5 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" @@ -91,10 +98,3 @@ "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" ) -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}'" -) diff --git a/pr_split/planner/client.py b/pr_split/planner/client.py index 8d41d50..31ce86c 100644 --- a/pr_split/planner/client.py +++ b/pr_split/planner/client.py @@ -33,7 +33,6 @@ format_group_catalog, recompute_estimated_loc, ) -from .new_file_pieces import link_new_file_pieces from .partitioning import partition_diff from .prompts import ( SPLIT_TOOL_NAME, @@ -625,7 +624,6 @@ def plan_split( groups = partition_diff(parsed_diff, settings) case _: raise PRSplitError(f"Unsupported partition strategy '{settings.partition_strategy}'") - link_new_file_pieces(groups, parsed_diff) 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 index 679b7e6..b95fd48 100644 --- a/pr_split/planner/new_file_pieces.py +++ b/pr_split/planner/new_file_pieces.py @@ -2,9 +2,8 @@ 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 be the group holding -piece k-1 or build on it; otherwise its sub-PR creates the file without its -beginning. +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 @@ -14,42 +13,15 @@ from loguru import logger from .. import logs -from ..exceptions import ErrorMsg, PlanValidationError, PRSplitError +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 _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: - continue - for idx in assignment.covered_indices(pieces): - if 0 <= idx < pieces: - owners[idx] = group.id - return owners - - -def _out_of_order( - groups: list[Group], parsed_diff: ParsedDiff, dag: PlanDAG -) -> list[tuple[str, int, str, str]]: - """(file, piece, group, parent) for each piece whose group skips the previous piece's.""" - 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 link_new_file_pieces(groups: list[Group], parsed_diff: ParsedDiff) -> None: """Make each later piece's group depend on the previous piece's group. @@ -59,11 +31,13 @@ def link_new_file_pieces(groups: list[Group], parsed_diff: ParsedDiff) -> None: if not parsed_diff.new_file_pieces: return by_id = {g.id: g for g in groups} - for path, _piece, child, parent in _out_of_order(groups, parsed_diff, PlanDAG(groups)): - group = by_id[child] - # An earlier edge in this loop may already have ordered the pair. + 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() @@ -71,12 +45,3 @@ def link_new_file_pieces(groups: list[Group], parsed_diff: ParsedDiff) -> None: group.depends_on.remove(parent) continue logger.info(logs.NEW_FILE_PIECE_LINKED.format(child=child, file=path, parent=parent)) - - -def validate_new_file_pieces(groups: list[Group], parsed_diff: ParsedDiff, dag: PlanDAG) -> None: - for path, piece, child, parent in _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 - ) - ) diff --git a/pr_split/planner/validator.py b/pr_split/planner/validator.py index eff6719..996b4e5 100644 --- a/pr_split/planner/validator.py +++ b/pr_split/planner/validator.py @@ -7,7 +7,6 @@ from ..graph import PlanDAG from ..schemas import Group from ..types_defs import LocBoundViolation -from .new_file_pieces import validate_new_file_pieces def validate_no_binary_files(parsed_diff: ParsedDiff) -> None: @@ -96,6 +95,48 @@ def validate_no_conflicts(groups: list[Group], dag: PlanDAG, hunk_counts: dict[s ) +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, diff --git a/pr_split/schemas.py b/pr_split/schemas.py index 23c4df8..7aa3263 100644 --- a/pr_split/schemas.py +++ b/pr_split/schemas.py @@ -54,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) @@ -61,9 +64,6 @@ class SplitPlan(BaseModel): merge_base_sha: str | None = None dev_branch_arg: str | None = None raw_diff: str | None = None - # --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 class BranchRecord(BaseModel): diff --git a/tests/test_new_file_split.py b/tests/test_new_file_split.py index bd4372b..5711ace 100644 --- a/tests/test_new_file_split.py +++ b/tests/test_new_file_split.py @@ -10,8 +10,8 @@ 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, validate_new_file_pieces -from pr_split.planner.validator import validate_plan +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 From 49a664a2dd2a511c7aac3c175f72f1dbf874d423 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 12:15:16 +0000 Subject: [PATCH 112/143] fix: re-list a generated group description after an editor move The graph and cp_sat backends describe a group as " partition over N file(s): ". After `move` in the plan editor the assignments changed but the description did not, so it still listed the moved file under its old group, and that text becomes the PR body. _move_assignment now rebuilds the description of both groups from their current files when the description is still in that generated form. A description from the LLM or edited by hand is left as it is. Fixes #220 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- pr_split/cli.py | 3 ++ pr_split/planner/partitioning.py | 25 ++++++++-- tests/test_description_refresh.py | 78 +++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 tests/test_description_refresh.py diff --git a/pr_split/cli.py b/pr_split/cli.py index bf383be..aeb9b86 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -73,6 +73,7 @@ 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 .schemas import ( BranchRecord, @@ -649,6 +650,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 diff --git a/pr_split/planner/partitioning.py b/pr_split/planner/partitioning.py index 79897b2..60e5e57 100644 --- a/pr_split/planner/partitioning.py +++ b/pr_split/planner/partitioning.py @@ -502,10 +502,29 @@ def _build_group_title(group_index: int, units: list[PartitionUnit]) -> str: return f"chore(split): review-slice-{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( 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 From 7e99e6ef1f8a0f61c0071a2b3084262dffd8c4ab Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 21:13:53 +0000 Subject: [PATCH 113/143] fix: diff against the base branch's upstream so a stale local base is not split split diffed against the local --base ref. When local main lagged behind origin/main, every commit that landed upstream since showed up as branch work, and --base origin/main is refused because PRs need a branch name. split now resolves the diff base with diff_base_ref: when --base tracks a remote branch it fetches it and diffs against the remote-tracking ref (origin/main), which is what the sub-PRs are opened against. The merge base recorded in the plan comes from the same ref, and --base main stays the PR base. A failed fetch falls back to the last fetched state with a warning; a branch with no upstream is used as before. Warnings say when the local base is behind or ahead of its upstream. Tests reach the new fetch only through real temporary repositories; a pytest plugin (tests/network_guards.py, wired in pyproject addopts) keeps every other CLI test off the network. Fixes #198 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- README.md | 4 ++ pr_split/cli.py | 7 +- pr_split/git_ops/__init__.py | 3 + pr_split/git_ops/branches.py | 38 +++++++++++ pr_split/logs.py | 12 ++++ pyproject.toml | 2 + tests/network_guards.py | 13 ++++ tests/test_diff_base.py | 127 +++++++++++++++++++++++++++++++++++ 8 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 tests/network_guards.py create mode 100644 tests/test_diff_base.py diff --git a/README.md b/README.md index 5c4a63a..86bd5ba 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,10 @@ 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}`. +### 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. diff --git a/pr_split/cli.py b/pr_split/cli.py index aeb9b86..f5c7744 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -59,6 +59,7 @@ commit_files_in_dir, delete_branch, derive_split_namespace, + diff_base_ref, fetch_fork_branch, fetch_fork_pr, is_worktree_clean, @@ -985,7 +986,9 @@ def split( else: logger.info("Overwriting existing dry-run plan") - raw_diff = extract_diff(dev_branch, base) + # 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) @@ -1065,7 +1068,7 @@ def split( 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) split_plan = SplitPlan( dev_branch=dev_branch, diff --git a/pr_split/git_ops/__init__.py b/pr_split/git_ops/__init__.py index bd001f2..5d0fdee 100644 --- a/pr_split/git_ops/__init__.py +++ b/pr_split/git_ops/__init__.py @@ -13,6 +13,9 @@ 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, ) diff --git a/pr_split/git_ops/branches.py b/pr_split/git_ops/branches.py index 513964b..30247b4 100644 --- a/pr_split/git_ops/branches.py +++ b/pr_split/git_ops/branches.py @@ -80,6 +80,44 @@ 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) diff --git a/pr_split/logs.py b/pr_split/logs.py index e15e2e5..fcecdbd 100644 --- a/pr_split/logs.py +++ b/pr_split/logs.py @@ -19,6 +19,18 @@ MATERIALIZING_FILES = "Materializing {count} file(s) for group '{group}'" COMMITTING_GROUP = "Committing group '{group}': {title}" PUSHING_BRANCH = "Pushing {branch} to origin" +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}" diff --git a/pyproject.toml b/pyproject.toml index 89d46c5..2cf8376 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,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/tests/network_guards.py b/tests/network_guards.py new file mode 100644 index 0000000..f97a2b1 --- /dev/null +++ b/tests/network_guards.py @@ -0,0 +1,13 @@ +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 diff --git a/tests/test_diff_base.py b/tests/test_diff_base.py new file mode 100644 index 0000000..0b19a21 --- /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" / "plan.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) From bb3c15025316ed9901e2a794aaf4bae39a6fa796 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 21:23:43 +0000 Subject: [PATCH 114/143] feat: add `restack` to carry a lower layer's fix up a stack A review fix committed on a lower stacked layer was missing from every layer above it, and their diffs showed it reversed. The only way through was rebasing each child by hand. `pr-split restack` fetches the stack's branches and fast-forwards local branches to their remote copies, so a fix pushed from GitHub or another clone is included. It then rebases each single-parent layer onto its parent's current head in plan order, in a throwaway worktree with hooks off. When the parent was itself rebased in the same run, its pre-rebase head is the upstream, so each layer's own commits are replayed once. Changed branches, and a fixed layer that needed no rebase, are pushed with --force-with-lease, parents first. It stops at the first conflicting layer, names it, and leaves it and everything above it unchanged. It refuses a branch checked out in a worktree or diverged from its remote. `--dry-run` only reports. `pr-split status` warns about any layer that lacks its parent's head. Fixes #219 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- README.md | 8 ++ pr_split/cli.py | 31 ++++++ pr_split/exceptions.py | 14 +++ pr_split/logs.py | 6 ++ pr_split/restack.py | 198 +++++++++++++++++++++++++++++++++++ tests/test_restack.py | 227 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 484 insertions(+) create mode 100644 pr_split/restack.py create mode 100644 tests/test_restack.py diff --git a/README.md b/README.md index 86bd5ba..6654711 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,14 @@ 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. + ### 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. diff --git a/pr_split/cli.py b/pr_split/cli.py index f5c7744..df0fd8d 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -76,6 +76,8 @@ 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 .restack import restack as restack_layers +from .restack import stale_layers from .schemas import ( BranchRecord, GitState, @@ -1176,6 +1178,9 @@ def status() -> None: 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): " @@ -1583,3 +1588,29 @@ def merge_all( 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, +) -> 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) + 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) diff --git a/pr_split/exceptions.py b/pr_split/exceptions.py index 65fca90..5430d2f 100644 --- a/pr_split/exceptions.py +++ b/pr_split/exceptions.py @@ -50,6 +50,20 @@ class ErrorMsg(StrEnum): "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_CONFLICT = ( + "Rebasing '{branch}' onto '{parent}' conflicts; nothing above it was changed." + " Rebase it by hand, push it, then run 'pr-split restack' again ({detail})" + ) 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}" diff --git a/pr_split/logs.py b/pr_split/logs.py index fcecdbd..146ba58 100644 --- a/pr_split/logs.py +++ b/pr_split/logs.py @@ -19,6 +19,12 @@ MATERIALIZING_FILES = "Materializing {count} file(s) for group '{group}'" COMMITTING_GROUP = "Committing group '{group}': {title}" PUSHING_BRANCH = "Pushing {branch} to origin" +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" ) diff --git a/pr_split/restack.py b/pr_split/restack.py new file mode 100644 index 0000000..91c94f8 --- /dev/null +++ b/pr_split/restack.py @@ -0,0 +1,198 @@ +"""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 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 restack(plan_file: PlanFile, *, dry_run: 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] + layers = _layers(plan_file) + + _fetch(all_branches) + 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 in layers: + child_head = _rev(f"refs/heads/{branch}") + parent_head = _rev(f"refs/heads/{parent}") + 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, 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) + 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/tests/test_restack.py b/tests/test_restack.py new file mode 100644 index 0000000..669a942 --- /dev/null +++ b/tests/test_restack.py @@ -0,0 +1,227 @@ +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 From 9caf815db579bfb3308257613c6d6373284617c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 21:33:44 +0000 Subject: [PATCH 115/143] feat: run a repository-defined step on every sub-PR branch before pushing Repositories that gate each PR on files unique to it, such as a version bump above the PR's base plus a release-notes file, could not be split with --stack. Every layer needs its own bump, and the dev branch holds only one. A [per_group] run command in .pr-split.toml (or PR_SPLIT_PER_GROUP_RUN) now runs in each sub-PR's worktree after that group's commit and before any push. Whatever it changes is committed to that layer, with the commit_message template ({id}, {title}, {index}). Children are cut from the layer after it ran, so each sees its parent's result. The group's id, title and index, the PR's base branch, and the commit the layer was cut from reach the command as environment variables, never spliced into the shell line, so a title cannot inject commands. A failing step stops the run, names the group, and leaves no half-built branch. Fixes #199 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- README.md | 12 +++ pr_split/cli.py | 19 +++++ pr_split/exceptions.py | 2 + pr_split/logs.py | 1 + pr_split/per_group.py | 107 +++++++++++++++++++++++++++ tests/test_per_group.py | 157 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 298 insertions(+) create mode 100644 pr_split/per_group.py create mode 100644 tests/test_per_group.py diff --git a/README.md b/README.md index 6654711..6cbc9c7 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,18 @@ 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. +### 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. diff --git a/pr_split/cli.py b/pr_split/cli.py index df0fd8d..63e0923 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -70,6 +70,7 @@ from .git_ops.branches import commit_exists, run_git from .git_ops.prs import close_pr, create_pr, get_pr_state, link_stack, merge_pr from .graph import PlanDAG +from .per_group import PerGroupStep, load_per_group_step, run_per_group_step from .plan_store import load_plan, plan_exists, save_plan from .planner import plan_split, validate_coverage, validate_no_binary_files, validate_plan from .planner.chunker import recompute_estimated_loc @@ -265,6 +266,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) @@ -290,6 +292,20 @@ def _create_single_branch_and_commit( group.title, author=author, ) + 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 @@ -366,6 +382,8 @@ def _create_branches_and_commits( stacked: bool = False, ) -> list[BranchRecord]: 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)} if stacked: dag = PlanDAG(groups) @@ -394,6 +412,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 } diff --git a/pr_split/exceptions.py b/pr_split/exceptions.py index 5430d2f..20bad4a 100644 --- a/pr_split/exceptions.py +++ b/pr_split/exceptions.py @@ -64,6 +64,8 @@ class ErrorMsg(StrEnum): "Rebasing '{branch}' onto '{parent}' conflicts; nothing above it was changed." " 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}" 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}" diff --git a/pr_split/logs.py b/pr_split/logs.py index 146ba58..de0eeaf 100644 --- a/pr_split/logs.py +++ b/pr_split/logs.py @@ -19,6 +19,7 @@ MATERIALIZING_FILES = "Materializing {count} file(s) for group '{group}'" COMMITTING_GROUP = "Committing group '{group}': {title}" PUSHING_BRANCH = "Pushing {branch} to origin" +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 = ( 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/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) From 40c284d27cb9aecc7e80012c92f687d609c63ab0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 21:52:26 +0000 Subject: [PATCH 116/143] fix: repair a skipped, duplicated or invented hunk in an LLM plan instead of failing A single-shot llm plan was validated as returned. One left-out hunk, a hunk claimed by two groups, an index or file not in the diff, or an unknown depends_on id failed the whole split. Small local models make these mistakes most often. repair_plan now runs on both the single-shot and the chunked plan. Each fix is logged as a warning: - assignments to files not in the diff, and out-of-range hunk indices, are dropped; - a hunk claimed twice stays with the first group that claims it; - unknown, self and repeated dependencies are dropped; - left-out hunks join the group holding the rest of their file, else the largest group, as the chunked path already did; - groups left empty are dropped, and their dependants inherit their parents so the order is kept. A cycle or a duplicate group id has no single right repair and is still reported by validation, as is a plan with no groups at all. Fixes #223 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- pr_split/logs.py | 12 +++ pr_split/planner/client.py | 7 +- pr_split/planner/repair.py | 127 ++++++++++++++++++++++++++++ tests/test_repair.py | 168 +++++++++++++++++++++++++++++++++++++ 4 files changed, 310 insertions(+), 4 deletions(-) create mode 100644 pr_split/planner/repair.py create mode 100644 tests/test_repair.py diff --git a/pr_split/logs.py b/pr_split/logs.py index de0eeaf..a2a8df4 100644 --- a/pr_split/logs.py +++ b/pr_split/logs.py @@ -19,6 +19,18 @@ MATERIALIZING_FILES = "Materializing {count} file(s) for group '{group}'" COMMITTING_GROUP = "Committing group '{group}': {title}" PUSHING_BRANCH = "Pushing {branch} to origin" +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" diff --git a/pr_split/planner/client.py b/pr_split/planner/client.py index 31ce86c..6fe7ff3 100644 --- a/pr_split/planner/client.py +++ b/pr_split/planner/client.py @@ -43,6 +43,7 @@ build_system_prompt, build_user_prompt, ) +from .repair import repair_plan from .scoring import score_plan from .validator import detect_loc_bound_violations, validate_coverage, validate_no_conflicts @@ -473,8 +474,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]]: @@ -606,8 +606,7 @@ def _plan_split_with_llm( 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) 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/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 == [] From b5b596ba220e99d2c75af4f1d1f2f25a1736a0d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 22:03:07 +0000 Subject: [PATCH 117/143] feat: fall back to the graph backend when no LLM provider is configured The default partition strategy was llm, so on a machine without ANTHROPIC_API_KEY `pr-split split` stopped with a missing-key error, although the graph and cp_sat backends need no key and no network. --partition-strategy / PR_SPLIT_PARTITION_STRATEGY now defaults to unset. Unset, split uses llm when its settings validate (a key is set, or the provider needs none, such as local), and otherwise graph, with one warning that gives the reason and how to enable the LLM planner. An explicit --partition-strategy llm still fails loudly without its key. An error both backends share (e.g. --min-loc >= --max-loc) is reported unchanged. Fixes #224 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- README.md | 6 ++-- pr_split/cli.py | 60 ++++++++++++++++++++++++++-------- pr_split/logs.py | 4 +++ tests/test_default_strategy.py | 58 ++++++++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 17 deletions(-) create mode 100644 tests/test_default_strategy.py diff --git a/README.md b/README.md index 6cbc9c7..002e5a0 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ uv tool install "pr-split[cp-sat]" - Python 3.12+ - [GitHub CLI](https://cli.github.com/) (`gh`) authenticated via `gh auth login` - [`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) +- `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 @@ -228,7 +228,7 @@ Settings can be set via environment variables with the `PR_SPLIT_` prefix: | `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_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` | Stack dependent PRs on their parent's branch | | `PR_SPLIT_DRAFT` | `false` | Open every sub-PR as a draft | @@ -306,7 +306,7 @@ PR_SPLIT_PROVIDER=local PR_SPLIT_MODEL=qwen2.5-coder:14b \ 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`. +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/pr_split/cli.py b/pr_split/cli.py index 63e0923..a5d43bb 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 @@ -28,7 +28,6 @@ DEFAULT_MAX_LOC, DEFAULT_MAX_REFINEMENT_ITERATIONS, DEFAULT_MIN_LOC, - DEFAULT_PARTITION_STRATEGY, DEFAULT_STRICT_LOC_BOUNDS, PLAN_DIR, PLAN_FILE, @@ -869,6 +868,34 @@ def _interactive_edit(groups: list[Group], parsed_diff: ParsedDiff) -> list[Grou ) +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(): @@ -928,13 +955,15 @@ def split( ), ] = DEFAULT_CHUNK_STRATEGY, partition_strategy: Annotated[ - PartitionStrategy, + PartitionStrategy | None, typer.Option( "--partition-strategy", envvar="PR_SPLIT_PARTITION_STRATEGY", - help="Backend for hunk-to-PR partitioning", + help="Backend for hunk-to-PR partitioning" + " [default: llm when its provider is configured, else graph]", + show_default=False, ), - ] = DEFAULT_PARTITION_STRATEGY, + ] = None, cp_sat_timeout: Annotated[ float, typer.Option( @@ -1024,15 +1053,18 @@ 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]") diff --git a/pr_split/logs.py b/pr_split/logs.py index a2a8df4..3a703e4 100644 --- a/pr_split/logs.py +++ b/pr_split/logs.py @@ -19,6 +19,10 @@ MATERIALIZING_FILES = "Materializing {count} file(s) for group '{group}'" COMMITTING_GROUP = "Committing group '{group}': {title}" PUSHING_BRANCH = "Pushing {branch} to origin" +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" ) 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) From 2d15c839f63430aacda336d1feb7f986a0cd0d33 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 22:14:58 +0000 Subject: [PATCH 118/143] feat: add `move` to shift a hunk up an executed stack without closing PRs Once a stack was executed there was no way to move a change to a higher layer short of clean + re-split, which closes every PR and loses review threads, or hand-editing the branches. Restoring a file from an old tip, the manual route, silently reverted base changes that had arrived by merge. `pr-split move : --from --to ` reverts the hunk on the source layer with `git apply --reverse` against its current content, and restacks, so the layers in between lose it too. It then applies the hunk on the target layer and restacks the layers above. Every step patches current content, so a base merge or review fix already on a layer is kept. Only changed branches are force-pushed, PRs stay open, and the saved plan moves the assignment (refreshing generated descriptions). A hunk can only move up a single-parent chain. A hunk that no longer applies is reported without touching that branch. Deleted files and pieces of a split new file are refused. Fixes #222 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- README.md | 8 ++ pr_split/cli.py | 34 +++++++ pr_split/exceptions.py | 17 ++++ pr_split/logs.py | 2 + pr_split/stack_move.py | 162 ++++++++++++++++++++++++++++++ tests/test_stack_move.py | 209 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 432 insertions(+) create mode 100644 pr_split/stack_move.py create mode 100644 tests/test_stack_move.py diff --git a/README.md b/README.md index 002e5a0..7274719 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,14 @@ 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. +### 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. + ### 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: diff --git a/pr_split/cli.py b/pr_split/cli.py index a5d43bb..cd445c5 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -87,6 +87,7 @@ PRRecord, SplitPlan, ) +from .stack_move import move_hunk as move_stack_hunk from .types_defs import ForkPRInfo app = typer.Typer( @@ -1665,3 +1666,36 @@ def restack( 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) diff --git a/pr_split/exceptions.py b/pr_split/exceptions.py index 20bad4a..68897d2 100644 --- a/pr_split/exceptions.py +++ b/pr_split/exceptions.py @@ -66,6 +66,23 @@ class ErrorMsg(StrEnum): ) 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" + ) 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}" diff --git a/pr_split/logs.py b/pr_split/logs.py index 3a703e4..f4b30e9 100644 --- a/pr_split/logs.py +++ b/pr_split/logs.py @@ -19,6 +19,8 @@ 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}" 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" 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/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 From 9786ff59ab8919bfbe15cc8c94fe7a1d6b3e77ca Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 22:48:20 +0000 Subject: [PATCH 119/143] fix: warn when the local provider's server is not on this machine The local provider was documented as keeping the diff on the machine, but PR_SPLIT_LOCAL_BASE_URL can name any host. A non-loopback host now logs a warning, once per host, that the diff will be sent there, and the README states the boundary precisely: localhost by default, elsewhere only if you point it there. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- README.md | 2 +- pr_split/logs.py | 4 ++++ pr_split/planner/client.py | 23 +++++++++++++++++++++++ tests/test_local_provider.py | 26 ++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 51177c3..45f9918 100644 --- a/README.md +++ b/README.md @@ -270,7 +270,7 @@ The `cp_sat` backend requires the optional [`ortools`](https://developers.google ### 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. The diff never leaves the machine and no API key is needed: +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 diff --git a/pr_split/logs.py b/pr_split/logs.py index 07d06bc..ad7499e 100644 --- a/pr_split/logs.py +++ b/pr_split/logs.py @@ -45,6 +45,10 @@ 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 = ( diff --git a/pr_split/planner/client.py b/pr_split/planner/client.py index 31ce86c..5bd0928 100644 --- a/pr_split/planner/client.py +++ b/pr_split/planner/client.py @@ -1,7 +1,9 @@ from __future__ import annotations +import ipaddress import json from typing import TypedDict +from urllib.parse import urlsplit import anthropic import openai @@ -233,11 +235,32 @@ def _json_from_text(text: str) -> dict[str, object] | None: 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, diff --git a/tests/test_local_provider.py b/tests/test_local_provider.py index f19f151..15c4eb8 100644 --- a/tests/test_local_provider.py +++ b/tests/test_local_provider.py @@ -296,3 +296,29 @@ def test_rejects_non_objects(self, text: str) -> 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] From aa279c76d7b9cadf54a8164dcc9add3c4773baf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 22:52:48 +0000 Subject: [PATCH 120/143] fix: say when the context window leaves no room for a diff chunk With a small window and a large output budget (e.g. 16384 and 12000) the per-chunk diff budget is negative, and the chunker reported the first hunk as too large for a budget of -1482. Check the budget before chunking and name the two settings to change instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- pr_split/exceptions.py | 5 +++++ pr_split/planner/client.py | 11 +++++++++++ tests/test_local_provider.py | 17 +++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/pr_split/exceptions.py b/pr_split/exceptions.py index bc195a2..f442390 100644 --- a/pr_split/exceptions.py +++ b/pr_split/exceptions.py @@ -37,6 +37,11 @@ class ErrorMsg(StrEnum): "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" diff --git a/pr_split/planner/client.py b/pr_split/planner/client.py index 5bd0928..7c89ab2 100644 --- a/pr_split/planner/client.py +++ b/pr_split/planner/client.py @@ -431,6 +431,17 @@ def _plan_split_chunked( overhead = _count_tokens(system, ".", settings=settings) chunk_limit = int(settings.max_context_tokens * CHUNK_TARGET_RATIO) 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 diff --git a/tests/test_local_provider.py b/tests/test_local_provider.py index 15c4eb8..0d45f4d 100644 --- a/tests/test_local_provider.py +++ b/tests/test_local_provider.py @@ -322,3 +322,20 @@ def test_other_host_is_warned_about_once(self) -> None: _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 == [] From d226eb6f276010e27495fecd241cc3d321bffb90 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 23:59:17 +0000 Subject: [PATCH 121/143] feat: restack --onto-base brings a stack up to date when its base moves When a PR landed on the base branch and touched a line the bottom layer also touched, the bottom sub-PR became conflicting, and nothing in pr-split could bring the stack up to date. The only routes were merging main into a layer by hand, or clean and re-split. `pr-split restack --onto-base` fetches the plan's base branch and also rebases the layers that target it (roots, and merge nodes rebuilt from it) onto its current head. It then restacks every layer above them in the same run. Conflicts stop at the first layer and leave it and everything above it untouched, as restack already does. Without the flag, restack still leaves the base alone. Refs #225 (the first ask; rebuilding a lost plan.json is not covered) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- README.md | 2 ++ pr_split/cli.py | 10 ++++++- pr_split/exceptions.py | 1 + pr_split/restack.py | 43 +++++++++++++++++++++++---- tests/test_restack.py | 66 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 115 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index c90e27e..4275733 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,8 @@ 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 diff --git a/pr_split/cli.py b/pr_split/cli.py index cd445c5..d58c1f5 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -1650,12 +1650,20 @@ 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) + 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 diff --git a/pr_split/exceptions.py b/pr_split/exceptions.py index 866a6db..503dba9 100644 --- a/pr_split/exceptions.py +++ b/pr_split/exceptions.py @@ -65,6 +65,7 @@ class ErrorMsg(StrEnum): "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; nothing above it was changed." " Rebase it by hand, push it, then run 'pr-split restack' again ({detail})" diff --git a/pr_split/restack.py b/pr_split/restack.py index 91c94f8..6fb7227 100644 --- a/pr_split/restack.py +++ b/pr_split/restack.py @@ -138,7 +138,32 @@ def stale_layers(plan_file: PlanFile) -> list[tuple[str, str, str]]: return stale -def restack(plan_file: PlanFile, *, dry_run: bool = False) -> list[LayerResult]: +def _stack_order(plan_file: PlanFile, *, onto_base: bool) -> 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 ``onto_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 copy as parent. + """ + branches = {r.group_id: r.branch_name for r in plan_file.git_state.branches} + base = plan_file.plan.base_branch + 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 onto_base and len(parents) != 1: + order.append((gid, branches[gid], f"{REMOTE}/{base}", _remote_ref(base))) + 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: @@ -147,11 +172,17 @@ def restack(plan_file: PlanFile, *, dry_run: bool = False) -> list[LayerResult]: 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] - layers = _layers(plan_file) + layers = _stack_order(plan_file, onto_base=onto_base) _fetch(all_branches) + if onto_base: + base = plan_file.plan.base_branch + try: + run_git("fetch", "--quiet", REMOTE, f"+refs/heads/{base}:{_remote_ref(base)}") + except GitOperationError as exc: + raise PRSplitError(ErrorMsg.RESTACK_BASE_FETCH_FAILED(base=base, detail=exc)) from exc if not dry_run: - busy = _checked_out_branches() & {branch for _, branch, _ in layers} + 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: @@ -160,9 +191,9 @@ def restack(plan_file: PlanFile, *, dry_run: bool = False) -> list[LayerResult]: results: dict[str, LayerResult] = {} old_heads: dict[str, str] = {} - for gid, branch, parent in layers: + for gid, branch, parent, parent_ref in layers: child_head = _rev(f"refs/heads/{branch}") - parent_head = _rev(f"refs/heads/{parent}") + 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") @@ -176,7 +207,7 @@ def restack(plan_file: PlanFile, *, dry_run: bool = False) -> list[LayerResult]: # 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, upstream) + 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)) diff --git a/tests/test_restack.py b/tests/test_restack.py index 669a942..579b0f4 100644 --- a/tests/test_restack.py +++ b/tests/test_restack.py @@ -225,3 +225,69 @@ def test_cli_restack_and_status_warning(repo: Path) -> None: 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')}") From 2f7d5dbbfb56e5a35c7d28ff7b55c48315fe0452 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 00:13:34 +0000 Subject: [PATCH 122/143] fix: leave every local branch as found when restack stops, and follow the base's remote Two review findings on restack: - A run that stopped part way left local branches moved. It had already fast-forwarded stale branches to their remotes, and, worse, had rebased lower layers without pushing them. A lower layer rebased but never pushed then read as diverged from its remote, so the next run refused it instead of retrying. restack now records every stack branch's local ref before it starts, and on a conflict or a failed push restores every branch it has not pushed. Branches already pushed stay as pushed, so local and remote agree and a rerun carries on. - --onto-base hard-coded origin/. It now follows the base branch's configured upstream (branch..remote/merge), as split does, so a base tracked on upstream/main is fetched and restacked onto upstream/main. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- pr_split/exceptions.py | 2 +- pr_split/restack.py | 91 ++++++++++++++++++++++++++++++++++++------ tests/test_restack.py | 76 +++++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 14 deletions(-) diff --git a/pr_split/exceptions.py b/pr_split/exceptions.py index 503dba9..438b396 100644 --- a/pr_split/exceptions.py +++ b/pr_split/exceptions.py @@ -67,7 +67,7 @@ class ErrorMsg(StrEnum): ) RESTACK_BASE_FETCH_FAILED = "Cannot fetch base branch '{base}' to restack onto it ({detail})" RESTACK_CONFLICT = ( - "Rebasing '{branch}' onto '{parent}' conflicts; nothing above it was changed." + "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}" diff --git a/pr_split/restack.py b/pr_split/restack.py index 6fb7227..5ec6b81 100644 --- a/pr_split/restack.py +++ b/pr_split/restack.py @@ -23,7 +23,7 @@ from .graph import PlanDAG if TYPE_CHECKING: - from .schemas import PlanFile + from .schemas import BranchRecord, PlanFile REMOTE = "origin" @@ -138,15 +138,48 @@ def stale_layers(plan_file: PlanFile) -> list[tuple[str, str, str]]: return stale -def _stack_order(plan_file: PlanFile, *, onto_base: bool) -> list[tuple[str, str, str, str]]: +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: 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 ``onto_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 copy as parent. + 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} - base = plan_file.plan.base_branch dag = PlanDAG(plan_file.plan.groups) order: list[tuple[str, str, str, str]] = [] for gid in dag.topological_order(): @@ -156,8 +189,11 @@ def _stack_order(plan_file: PlanFile, *, onto_base: bool) -> list[tuple[str, str if len(parents) == 1 and parents[0] in branches: parent = branches[parents[0]] order.append((gid, branches[gid], parent, f"refs/heads/{parent}")) - elif onto_base and len(parents) != 1: - order.append((gid, branches[gid], f"{REMOTE}/{base}", _remote_ref(base))) + 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 @@ -172,15 +208,43 @@ def restack( 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] - layers = _stack_order(plan_file, onto_base=onto_base) + 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 onto_base: - base = plan_file.plan.base_branch + if base_remote is not None: + remote, branch = base_remote try: - run_git("fetch", "--quiet", REMOTE, f"+refs/heads/{base}:{_remote_ref(base)}") + 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=base, detail=exc)) from 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: @@ -222,6 +286,7 @@ def restack( 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") diff --git a/tests/test_restack.py b/tests/test_restack.py index 579b0f4..ef12bef 100644 --- a/tests/test_restack.py +++ b/tests/test_restack.py @@ -291,3 +291,79 @@ def test_cli_onto_base(repo: Path) -> None: 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")) From 1e18759a28a7b450865f8deef4d32bd5b81e0f7b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 00:14:04 +0000 Subject: [PATCH 123/143] fix: annotate restack's base_remote as the (remote, branch) pair it is Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- pr_split/restack.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pr_split/restack.py b/pr_split/restack.py index 5ec6b81..491972a 100644 --- a/pr_split/restack.py +++ b/pr_split/restack.py @@ -170,7 +170,7 @@ def _restore(originals: dict[str, str | None]) -> None: def _stack_order( - plan_file: PlanFile, *, base_remote: str | None + 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. From 941bf3bec383270e1aeaa2abc6300444c9f914e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 15:33:08 +0000 Subject: [PATCH 124/143] feat: recover a lost plan from the stack's branches and PRs The plan lives in .pr-split/plan.json of the checkout where split ran. When that checkout is gone (a removed worktree, a fresh clone), status, merge, restack and clean had nothing to work from, although every branch and PR of the stack still existed. `pr-split recover ` lists the PRs whose head is pr-split//..., fetches those branches, and rebuilds the groups, their dependencies, the branch records and the PR records. A layer depends on the layer its PR targets, or on the groups its body's "depends on" line names once a merged parent has been retargeted. The base is read from the PRs (or --base), and an existing plan is kept unless --force is given. Closes #225 (its first ask landed as restack --onto-base). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- README.md | 8 ++ pr_split/cli.py | 54 +++++++++++ pr_split/exceptions.py | 8 ++ pr_split/git_ops/prs.py | 22 +++++ pr_split/logs.py | 4 + pr_split/recover.py | 194 ++++++++++++++++++++++++++++++++++++++++ tests/test_recover.py | 187 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 477 insertions(+) create mode 100644 pr_split/recover.py create mode 100644 tests/test_recover.py diff --git a/README.md b/README.md index 4275733..c4ce627 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,14 @@ 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: diff --git a/pr_split/cli.py b/pr_split/cli.py index d58c1f5..d3df4bb 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -76,6 +76,7 @@ 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 ( @@ -1707,3 +1708,56 @@ def move_hunk_command( 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: + if plan_exists() and not force: + console.print(f"[red]{ErrorMsg.RECOVER_PLAN_EXISTS(path=PLAN_FILE)}[/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_FILE + ) + ) diff --git a/pr_split/exceptions.py b/pr_split/exceptions.py index 438b396..69827d7 100644 --- a/pr_split/exceptions.py +++ b/pr_split/exceptions.py @@ -89,6 +89,14 @@ class ErrorMsg(StrEnum): 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})" 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}" diff --git a/pr_split/git_ops/prs.py b/pr_split/git_ops/prs.py index 616168a..b830e9b 100644 --- a/pr_split/git_ops/prs.py +++ b/pr_split/git_ops/prs.py @@ -80,6 +80,28 @@ 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, whose head branch starts with ``prefix``.""" + raw = _run_gh( + "pr", + "list", + "--state", + "all", + "--limit", + str(PR_LIST_LIMIT), + "--json", + "number,url,state,headRefName,baseRefName,title,body", + ) + try: + prs = json.loads(raw or "[]") + except json.JSONDecodeError as exc: + raise GitOperationError(f"gh pr list returned invalid JSON: {exc}") from exc + return [pr for pr in prs if str(pr.get("headRefName", "")).startswith(prefix)] + + def merge_pr(pr_number: int, *, auto: bool = False) -> None: args = ["pr", "merge", str(pr_number), "--merge", "--delete-branch"] if auto: diff --git a/pr_split/logs.py b/pr_split/logs.py index 767d7b9..21f410d 100644 --- a/pr_split/logs.py +++ b/pr_split/logs.py @@ -21,6 +21,10 @@ 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" 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/tests/test_recover.py b/tests/test_recover.py new file mode 100644 index 0000000..5a1151e --- /dev/null +++ b/tests/test_recover.py @@ -0,0 +1,187 @@ +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(mock_gh: MagicMock) -> None: + mock_gh.return_value = json.dumps( + [{"number": 1, "headRefName": P + "pr-1"}, {"number": 2, "headRefName": "fix/x"}] + ) + assert [pr["number"] for pr in list_prs_with_head_prefix(P)] == [1] + assert "--state" in mock_gh.call_args.args and "all" in mock_gh.call_args.args + + +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 + 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 From 4198a493cbfea2fed25e18b3dd0a3b4929625762 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 15:43:10 +0000 Subject: [PATCH 125/143] fix: narrow recover's PR listing on the server and skip fork PRs gh pr list applied its limit to every PR of the repository before the stack's prefix was matched, so in a repository with more than 1000 PRs a layer's PR could be missed. The listing now searches head: so the limit applies to the stack's PRs only, and drops PRs opened from a fork's branch of the same name, which are not the stack's. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- pr_split/git_ops/prs.py | 14 +++++++++++--- tests/test_recover.py | 13 ++++++++++--- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/pr_split/git_ops/prs.py b/pr_split/git_ops/prs.py index b830e9b..5edf4d1 100644 --- a/pr_split/git_ops/prs.py +++ b/pr_split/git_ops/prs.py @@ -84,22 +84,30 @@ def get_pr_state(pr_number: int) -> dict[str, str | bool | None]: def list_prs_with_head_prefix(prefix: str) -> list[dict[str, object]]: - """Every PR, in any state, whose head branch starts with ``prefix``.""" + """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", + "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 - return [pr for pr in prs if str(pr.get("headRefName", "")).startswith(prefix)] + # 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: diff --git a/tests/test_recover.py b/tests/test_recover.py index 5a1151e..7508430 100644 --- a/tests/test_recover.py +++ b/tests/test_recover.py @@ -106,12 +106,19 @@ def test_nothing_found() -> None: @patch("pr_split.git_ops.prs._run_gh") -def test_pr_listing_keeps_only_the_stack(mock_gh: MagicMock) -> None: +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"}, {"number": 2, "headRefName": "fix/x"}] + [ + {"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] - assert "--state" in mock_gh.call_args.args and "all" in mock_gh.call_args.args + 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]]: From 744bcdb93799f816189bf1974b3fd917055b908e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 21:19:56 +0000 Subject: [PATCH 126/143] fix: log execute's completion once; the main merge doubled the line Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- pr_split/cli.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index 181bac0..94b9b9a 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -1503,7 +1503,6 @@ def execute( if plan.stacked: _link_stacks(PlanDAG(plan.groups), pr_records) logger.success(f"Execute complete: {len(plan.groups)} PRs created from saved plan") - logger.success(f"Execute complete: {len(plan.groups)} PRs created from saved plan") _AUTO_MERGE_POLL_INTERVAL = 10 From 467d8970cc629ff8763f5d886c4e19ad93b6b366 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 22:24:01 +0000 Subject: [PATCH 127/143] test: patch plan validation in execute-retry tests that feed a mocked diff main's execute validates the plan against the parsed diff; these tests mock parse_diff, so the check would reject the plan before the retry logic runs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- tests/test_cli_coverage.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index eeeccfb..d217cae 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -1030,7 +1030,8 @@ def test_branches_without_prs_is_retried( ) mock_load.return_value = plan_file - result = runner.invoke(app, ["execute"]) + with patch("pr_split.cli.validate_coverage"): + result = runner.invoke(app, ["execute"]) assert result.exit_code == 0 assert "Recreating them and retrying" in result.output.replace("\n", " ") @@ -1104,7 +1105,8 @@ def test_branches_dropped_from_an_edited_plan_are_deleted( """A retry must not orphan branches the edited plan no longer contains.""" mock_load.return_value = self._plan_with_stale_pr2() - result = runner.invoke(app, ["execute"]) + 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") @@ -1146,7 +1148,8 @@ def test_remote_only_stale_branch_is_still_deleted_on_origin( mock_be.side_effect = lambda name: name != "pr-split/feature-branch/pr-2" mock_load.return_value = self._plan_with_stale_pr2() - result = runner.invoke(app, ["execute"]) + with patch("pr_split.cli.validate_coverage"): + result = runner.invoke(app, ["execute"]) assert result.exit_code == 0 mock_delete.assert_not_called() From 2d23ac0f2161d8087e11aa9e7421b8ed992e4ad9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 22:38:05 +0000 Subject: [PATCH 128/143] fix: keep a kept layer's parents on a stacked resume, and accept a push that landed - execute: when some layers already have PRs, a stacked resume now also keeps the recorded branches of those layers' ancestors. Rebuilding an ancestor gave it a new commit and force-pushed it, so the kept child's open PR (which targets it) showed the ancestor's changes again. The kept ancestors still get their PRs opened. - push_branch: after a transient error (hung-up remote, timeout) the push may have landed; the retry is then rejected as a stale lease. If the remote branch already holds the local head, the push counts as done. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- pr_split/cli.py | 14 +++++++--- pr_split/git_ops/branches.py | 15 +++++++++++ tests/test_cli_coverage.py | 48 ++++++++++++++++++++++++++++++++++ tests/test_git_branches.py | 50 ++++++++++++++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 3 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index 94b9b9a..ad13190 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -1384,9 +1384,8 @@ def execute( plan = plan.model_copy(update={"draft": True}) existing_prs = {r.group_id: r for r in plan_file.git_state.prs} - kept_branches = { - r.group_id: r for r in plan_file.git_state.branches if r.group_id in existing_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 @@ -1461,6 +1460,15 @@ def execute( console.print(f"[red]{exc}[/red]") raise typer.Exit(1) from exc + if plan.stacked: + # 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. + 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) diff --git a/pr_split/git_ops/branches.py b/pr_split/git_ops/branches.py index 7cc57ff..b481518 100644 --- a/pr_split/git_ops/branches.py +++ b/pr_split/git_ops/branches.py @@ -70,16 +70,31 @@ def is_worktree_clean() -> bool: _PUSH_RETRY_DELAY = 2.0 +def _remote_has_local_head(branch: str) -> bool: + try: + remote = run_git("ls-remote", "origin", f"refs/heads/{branch}").split() + return bool(remote) and remote[0] == run_git("rev-parse", branch) + except GitOperationError: + return False + + def push_branch(branch: str) -> None: logger.info(logs.PUSHING_BRANCH.format(branch=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()) ) diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index c3b02f4..562a53d 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -1107,6 +1107,54 @@ def test_partial_prs_are_kept_and_the_rest_created( 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 a stacked resume keeps it.""" + 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"} if stacked else {"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( diff --git a/tests/test_git_branches.py b/tests/test_git_branches.py index 3ef71f4..abea01f 100644 --- a/tests/test_git_branches.py +++ b/tests/test_git_branches.py @@ -121,6 +121,56 @@ def test_gives_up_after_the_last_attempt( assert mock_git.call_count == 3 +class TestPushThatLandedDespiteAnError: + @patch("pr_split.git_ops.branches.time.sleep") + @patch("pr_split.git_ops.branches.run_git") + 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: @patch("pr_split.git_ops.branches.run_git") def test_local_only(self, mock_git: MagicMock) -> None: From b4b787e09bbf5bbfbf32d2412573aef3da67769e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 22:41:08 +0000 Subject: [PATCH 129/143] =?UTF-8?q?fix:=20per-branch=20plan=20storage=20?= =?UTF-8?q?=E2=80=94=20unique=20names,=20legacy=20handling,=20exclude=20pa?= =?UTF-8?q?th?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From review: - Plan files are named by the percent-encoded dev branch (plan_key), so `feature/x` and `feature-x` no longer share a file; the ambiguity message and saved_plan_branches() show the real branch names. select_plan() now takes the branch itself. - A legacy .pr-split/plan.json stands in for the selected plan only when it is unreadable (so its error surfaces); a readable one that could not move because its branch already has a plan is never used for another branch, so `split` can no longer load, clean up and overwrite another branch's plan. - Moving a legacy plan adds the git exclude entry too, as saving does. - A relative --git-common-dir is resolved against the cwd, where rev-parse prints it from; from a subdirectory the exclude went to the wrong place. - README: the plan file name is the encoded branch, and the exclude only keeps untracked plan files out of git. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- README.md | 4 +-- pr_split/cli.py | 6 ++-- pr_split/plan_store.py | 65 ++++++++++++++++++++++++++-------------- tests/test_plan_store.py | 57 ++++++++++++++++++++++++++++++----- 4 files changed, 97 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 1b2f5aa..0d53ff2 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ pr-split split feature-branch --base main --dry-run | `--cp-sat-timeout` | `15.0` | Maximum seconds to spend in the CP-SAT solver | | `--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/plans/.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 @@ -141,7 +141,7 @@ Each split's plan is saved per dev branch under `.pr-split/plans/`, so splitting pr-split --branch feat/big status ``` -`.pr-split/` is added to the repository's `.git/info/exclude`, so the plan (which holds the whole diff) never shows up in `git status` or gets committed. A plan saved by an older version at `.pr-split/plan.json` is moved to its branch's file the first time it is used. +`.pr-split/` 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 diff --git a/pr_split/cli.py b/pr_split/cli.py index 1b555da..f347b31 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -108,7 +108,7 @@ def _choose_plan( ] = None, ) -> None: # Plans are saved per dev branch; without --branch the only saved plan is used. - select_plan(derive_split_namespace(branch) if branch else None) + select_plan(branch) def _render_dag(groups: list[Group]) -> str: @@ -1010,7 +1010,7 @@ def split( dev_branch_arg = dev_branch author: str | None = None fork_info: ForkPRInfo | None = None - select_plan(derive_split_namespace(dev_branch_arg)) + select_plan(dev_branch_arg) if not branch_exists(dev_branch): if not check_gh_auth(): @@ -1745,7 +1745,7 @@ def recover( 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(derive_split_namespace(dev_branch)) + 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) diff --git a/pr_split/plan_store.py b/pr_split/plan_store.py index 6461dbb..89fb422 100644 --- a/pr_split/plan_store.py +++ b/pr_split/plan_store.py @@ -1,6 +1,7 @@ import json import os from pathlib import Path +from urllib.parse import quote, unquote from loguru import logger from pydantic import ValidationError @@ -8,7 +9,7 @@ from . import logs from .constants import PLAN_DIR, PLAN_FILE from .exceptions import ErrorMsg, GitOperationError, PRSplitError -from .git_ops.branches import derive_split_namespace, run_git +from .git_ops.branches import run_git from .schemas import PlanFile @@ -25,15 +26,21 @@ def repo_root() -> Path: return Path.cwd() -# The plan the current command works on, keyed by dev-branch slug. Set by -# `split` (its own branch) or the global --branch option; otherwise the only +# 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 select_plan(branch_slug: 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 = branch_slug + _selected = plan_key(dev_branch) if dev_branch else None def plan_dir() -> Path: @@ -44,47 +51,58 @@ def _plans_dir() -> Path: return plan_dir() / "plans" -def saved_plan_slugs() -> list[str]: +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 _migrate_legacy_plan() -> None: +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. + 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 + 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 - target = _plans_dir() / f"{derive_split_namespace(str(branch))}.json" + return True + target = _plans_dir() / f"{plan_key(str(branch))}.json" if target.exists(): - return + # 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.""" - _migrate_legacy_plan() + 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. - return legacy if not selected.exists() and legacy.exists() else selected - slugs = saved_plan_slugs() - if len(slugs) == 1: - return _plans_dir() / f"{slugs[0]}.json" - if len(slugs) > 1: - raise PRSplitError(ErrorMsg.PLAN_AMBIGUOUS(branches=", ".join(slugs))) - return repo_root() / PLAN_FILE + # 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: @@ -98,7 +116,8 @@ def _exclude_plan_dir() -> None: except GitOperationError: return if not common.is_absolute(): - common = repo_root() / common + # 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: @@ -125,7 +144,7 @@ def save_plan(plan_file: PlanFile) -> None: 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"{derive_split_namespace(branch)}.json" + target = _plans_dir() / f"{plan_key(branch)}.json" else: target = plan_path() target.parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_plan_store.py b/tests/test_plan_store.py index ef59ad5..eb50dd8 100644 --- a/tests/test_plan_store.py +++ b/tests/test_plan_store.py @@ -165,9 +165,12 @@ def test_plan_saved_from_a_subdirectory_is_found_from_the_root( repo = self._repo(tmp_path) monkeypatch.chdir(repo / "src") save_plan(_make_plan_file()) - assert (repo / ".pr-split" / "plans" / "feat-big.json").exists() + assert (repo / ".pr-split" / "plans" / "feat%2Fbig.json").exists() assert not (repo / "src" / ".pr-split").exists() - assert plan_path() == (repo / ".pr-split" / "plans" / "feat-big.json").resolve() + 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() @@ -243,14 +246,14 @@ def _repo(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: select_plan(None) def test_two_splits_keep_separate_plans(self, tmp_path: Path) -> None: - from pr_split.plan_store import saved_plan_slugs, select_plan + 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_slugs() == ["feat-a", "feat-b"] + 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\)" @@ -305,16 +308,56 @@ def _repo(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: 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_slugs, select_plan + 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") + select_plan("feat/big") assert load_plan().plan.dev_branch == "feat/big" assert not legacy.exists() - assert saved_plan_slugs() == ["feat-big"] + 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: From fe622af84e075f33e59c19fe455fc9ce87c3b286 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sun, 30 Aug 2026 20:35:59 +0100 Subject: [PATCH 130/143] fix: retry execute after a failed push instead of forcing a full re-plan A push/PR-creation failure leaves the plan with branches recorded and no PRs. execute then refused to run ('use clean first') and clean deletes the plan file entirely, so recovering from a transient network failure meant regenerating the whole plan - for the LLM backend, paying for planning again and possibly getting a different split. Branch creation is idempotent (add_worktree recreates an existing branch), so execute now retries when the saved state has branches but no PRs, and only refuses when PRs already exist. --- pr_split/cli.py | 52 ++++++++++- tests/test_cli_coverage.py | 186 ++++++++++++++++++++++++++++++++++++- 2 files changed, 234 insertions(+), 4 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index 3220a83..e387565 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -1298,6 +1298,44 @@ def clean() -> None: 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.", ) @@ -1330,9 +1368,18 @@ def execute( 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]") + if plan_file.git_state.prs: + console.print("[red]This plan already has PRs. Use 'pr-split clean' first.[/red]") raise typer.Exit(1) + if 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( @@ -1387,6 +1434,7 @@ def execute( typer.confirm("Proceed with creating branches and PRs?", abort=True) namespace = derive_split_namespace(plan.dev_branch_arg or plan.dev_branch) + _delete_stale_recorded_branches(plan_file.git_state.branches, plan.groups, namespace) try: branch_records = _create_branches_and_commits( plan.groups, diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index 2ed66d8..eeeccfb 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -708,13 +708,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) @@ -971,6 +972,187 @@ def test_clean_no_plan(self, mock_pe: MagicMock) -> None: 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 + + result = runner.invoke(app, ["execute"]) + + assert result.exit_code == 0 + 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_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() + + 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() + + 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.""" From 1e0ef94e15256882c7147847114101f91e862476 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 22:24:01 +0000 Subject: [PATCH 131/143] test: patch plan validation in execute-retry tests that feed a mocked diff main's execute validates the plan against the parsed diff; these tests mock parse_diff, so the check would reject the plan before the retry logic runs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- tests/test_cli_coverage.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index eeeccfb..d217cae 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -1030,7 +1030,8 @@ def test_branches_without_prs_is_retried( ) mock_load.return_value = plan_file - result = runner.invoke(app, ["execute"]) + with patch("pr_split.cli.validate_coverage"): + result = runner.invoke(app, ["execute"]) assert result.exit_code == 0 assert "Recreating them and retrying" in result.output.replace("\n", " ") @@ -1104,7 +1105,8 @@ def test_branches_dropped_from_an_edited_plan_are_deleted( """A retry must not orphan branches the edited plan no longer contains.""" mock_load.return_value = self._plan_with_stale_pr2() - result = runner.invoke(app, ["execute"]) + 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") @@ -1146,7 +1148,8 @@ def test_remote_only_stale_branch_is_still_deleted_on_origin( mock_be.side_effect = lambda name: name != "pr-split/feature-branch/pr-2" mock_load.return_value = self._plan_with_stale_pr2() - result = runner.invoke(app, ["execute"]) + with patch("pr_split.cli.validate_coverage"): + result = runner.invoke(app, ["execute"]) assert result.exit_code == 0 mock_delete.assert_not_called() From d88f23e389dcdf65622b171afd09f832d69b036c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 23:27:33 +0000 Subject: [PATCH 132/143] Give the pip install command in the cp-sat hint too Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- pr_split/planner/partitioning.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pr_split/planner/partitioning.py b/pr_split/planner/partitioning.py index 887429c..2a84611 100644 --- a/pr_split/planner/partitioning.py +++ b/pr_split/planner/partitioning.py @@ -370,7 +370,9 @@ def _group_units_cp_sat( raise PRSplitError( "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'`" + "`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: From cb8aad61f8f80075ccfff79391641397e111f268 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 23:29:42 +0000 Subject: [PATCH 133/143] Name a merge node's carried ancestors in its PR body; test unstacked content A multi-parent group targets the base branch, so its diff also shows its ancestors' changes; the default PR body now says which. The ancestor-hunk and merge-node tests now run with and without --stack and check the files each branch is built with. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- pr_split/cli.py | 12 +++++++++++- tests/test_cli_helpers.py | 16 ++++++++++++++-- tests/test_cli_new_features.py | 11 +++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index deddc7c..19f78c8 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -502,7 +502,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) diff --git a/tests/test_cli_helpers.py b/tests/test_cli_helpers.py index 3eb77e1..4282e18 100644 --- a/tests/test_cli_helpers.py +++ b/tests/test_cli_helpers.py @@ -376,6 +376,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") @@ -386,20 +387,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") @@ -410,6 +421,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 = [ @@ -428,7 +440,7 @@ 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] diff --git a/tests/test_cli_new_features.py b/tests/test_cli_new_features.py index 8ae68c8..7b80eca 100644 --- a/tests/test_cli_new_features.py +++ b/tests/test_cli_new_features.py @@ -81,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]) From dd43f8e2f62b6de284fb966e06c0f0bd70527a40 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 23:31:49 +0000 Subject: [PATCH 134/143] Keep a below-threshold plan's group count in the score output A plan with fewer groups than the display threshold was reported as missing (total_groups=0, "no split plan could be generated"). It now keeps its count and says it is below the threshold. A plan at the threshold is tested end to end: verdict, outputs and group rows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- scripts/score_pr.py | 30 +++++++++++++++++++-------- tests/test_score_pr_events.py | 38 ++++++++++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/scripts/score_pr.py b/scripts/score_pr.py index 1f8f191..f415751 100644 --- a/scripts/score_pr.py +++ b/scripts/score_pr.py @@ -38,14 +38,17 @@ def _write_comment(lines: list[str]) -> None: _set_output("comment_path", str(comment_path)) -def _oversized_without_plan(reason: str, total_loc: int, file_count: int, max_loc: int) -> None: +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. + 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", "0") + _set_output("total_groups", str(total_groups)) _set_output("objective", "0") _set_output("should_split", "true") _write_comment( @@ -59,8 +62,7 @@ def _oversized_without_plan(reason: str, total_loc: int, file_count: int, max_lo f"| Files changed | {file_count} |", f"| Limit | {max_loc:,} LOC |", "", - f"This PR has **{total_loc:,} LOC**, over the **{max_loc:,} LOC** limit, " - f"but no split plan could be generated: {reason}", + f"This PR has **{total_loc:,} LOC**, over the **{max_loc:,} LOC** limit, but {reason}", "", "*Run `pr-split split` locally to plan the split.*", ] @@ -165,22 +167,34 @@ 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) - _oversized_without_plan("pr-split exited with an error.", total_loc, file_count, max_loc) + _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): - _oversized_without_plan("pr-split wrote no plan file.", total_loc, file_count, max_loc) + _oversized_without_plan( + "no split plan could be generated: pr-split wrote no plan file.", + total_loc, + file_count, + max_loc, + ) return 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), fewer than the {threshold} needed.", + 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 diff --git a/tests/test_score_pr_events.py b/tests/test_score_pr_events.py index 83a3433..38b10aa 100644 --- a/tests/test_score_pr_events.py +++ b/tests/test_score_pr_events.py @@ -145,7 +145,43 @@ def test_single_group_plan_is_below_threshold_but_still_flagged( tmp_path, monkeypatch, returncode=0, plan={"plan": {"groups": [group]}} ) assert outputs["should_split"] == "true" - assert "fewer than the 2 needed" in comment + # 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 From b55c44592c5312b2a7580dc784e3cd80e02919ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 23:33:55 +0000 Subject: [PATCH 135/143] Fetch a PR's head as refs/pull/N/head from the repository gh resolved A same-repo PR was fetched as origin's branch of the same name, which is the wrong code when gh resolves another repository than origin, and a forked repository's internal PR (head.repo.fork true) went through the HTTPS clone URL. Every PR now fetches refs/pull/N/head from its base repository: through origin when origin is that repository, else from its clone URL. The fetch error names the source instead of a fork branch, and the stale test expecting a same-repo PR to fail is removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- README.md | 2 +- pr_split/exceptions.py | 2 +- pr_split/git_ops/prs.py | 35 ++++++++++--- pr_split/logs.py | 2 +- tests/test_git_prs.py | 15 ------ tests/test_git_prs_coverage.py | 89 +++++++++++++++++++++++++++------- 6 files changed, 104 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 797c35b..0c90a52 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ pr-split split feature-branch --base main ### Split a PR by number -Works for same-repo and fork PRs: the PR's head is fetched (from `origin`, or from the fork) and its base branch is used. +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 diff --git a/pr_split/exceptions.py b/pr_split/exceptions.py index fd0bc23..81f39f7 100644 --- a/pr_split/exceptions.py +++ b/pr_split/exceptions.py @@ -103,7 +103,7 @@ class ErrorMsg(StrEnum): MERGE_FAILED = "Merge of '{source}' into '{target}' failed: {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 fork branch 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}" diff --git a/pr_split/git_ops/prs.py b/pr_split/git_ops/prs.py index af359ce..67b9ba5 100644 --- a/pr_split/git_ops/prs.py +++ b/pr_split/git_ops/prs.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import re import subprocess from loguru import logger @@ -131,6 +132,19 @@ def link_stack(pr_numbers: list[int]) -> None: 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 @@ -161,20 +175,29 @@ def fetch_fork_pr(pr_number: int) -> ForkPRInfo: fork_full_name = str(head_repo["full_name"]) local_ref = f"{PR_REF_PREFIX}{pr_number}" - if head_repo.get("fork"): + # 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)) - source, refspec = clone_url, f"{head_ref}:{local_ref}" else: - # A same-repo PR's head lives on origin; fetch it through the remote - # already configured (and authenticated) for this checkout. logger.info(logs.FETCHING_SAME_REPO_PR.format(number=pr_number, branch=head_ref)) - source, refspec = "origin", f"+refs/heads/{head_ref}:{local_ref}" try: 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) diff --git a/pr_split/logs.py b/pr_split/logs.py index 2153a00..bdad667 100644 --- a/pr_split/logs.py +++ b/pr_split/logs.py @@ -74,7 +74,7 @@ " so 'pr-split clean' can be re-run" ) FETCHING_FORK_PR = "Fetching PR #{number} from fork {fork}" -FETCHING_SAME_REPO_PR = "Fetching PR #{number} from origin branch {branch}" +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})" diff --git a/tests/test_git_prs.py b/tests/test_git_prs.py index 0d108bd..38de86e 100644 --- a/tests/test_git_prs.py +++ b/tests/test_git_prs.py @@ -95,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") diff --git a/tests/test_git_prs_coverage.py b/tests/test_git_prs_coverage.py index 88c8ad0..06331de 100644 --- a/tests/test_git_prs_coverage.py +++ b/tests/test_git_prs_coverage.py @@ -166,36 +166,91 @@ def test_git_fetch_failure(self, mock_gh: MagicMock, mock_git: MagicMock) -> Non fetch_fork_branch("user", "branch") -class TestFetchSameRepoPr: +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 + self, mock_gh: MagicMock, mock_git: MagicMock, origin_url: str ) -> None: - pr_data = { - "head": { - "ref": "feat/1806-constant-node", - "repo": { - "fork": False, - "clone_url": "https://github.com/org/repo.git", - "full_name": "org/repo", - }, - }, - "base": {"ref": "main"}, - } - mock_gh.return_value = json.dumps(pr_data) - mock_git.side_effect = ["", "A "] + 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[0].args == ( + assert mock_git.call_args_list[1].args == ( "fetch", "origin", - "+refs/heads/feat/1806-constant-node:refs/pr-split/pr-1931", + "+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( From 27554db9f9ccbd8fdb0763d6089f14c4f596040a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 23:36:50 +0000 Subject: [PATCH 136/143] Qualify the README's parent-targeting claims for multi-parent groups Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4ac470c..7a88d3e 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ ## Latest News 🔥 -- Stacked PR Mode — every dependent PR 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. +- 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. @@ -87,7 +87,7 @@ pr-split split feature-branch --base main --dry-run | `--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`) | | `--cp-sat-timeout` | `15.0` | Maximum seconds to spend in the CP-SAT solver | -| `--stack` | `false` | Register dependent PR chains as native GitHub stacks (dependent PRs always branch from and target their parent) | +| `--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 | @@ -97,7 +97,7 @@ pr-split split feature-branch --base main --dry-run pr-split split feature-branch --base main --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, with or without `--stack`. Every PR shows only its own diff, compiles standalone, and GitHub retargets children automatically as parents merge. 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 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. From 4cc897645c8ada55fefd0d87b83538a5cdb28edf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 03:10:07 +0000 Subject: [PATCH 137/143] Track the remote when adopting a remote-only branch diff_base_ref on main diffs a base against its upstream so a stale local copy is not taken as branch work; an adopted --base created with --no-track had no upstream and would go stale. Adopt with --track, as git checkout does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- pr_split/git_ops/branches.py | 7 ++++--- tests/test_git_branches.py | 3 +++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/pr_split/git_ops/branches.py b/pr_split/git_ops/branches.py index 1fb8f05..e3b70b7 100644 --- a/pr_split/git_ops/branches.py +++ b/pr_split/git_ops/branches.py @@ -53,8 +53,9 @@ 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. - Returns True when a local branch was created. + 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 @@ -65,7 +66,7 @@ def adopt_remote_branch(branch: str) -> bool: candidates = [ref for ref in listing.splitlines() if ref.split("/", 1)[1:] == [branch]] if len(candidates) != 1: return False - run_git("branch", "--no-track", branch, candidates[0]) + run_git("branch", "--track", branch, candidates[0]) logger.info(logs.ADOPTED_REMOTE_BRANCH.format(branch=branch, remote_ref=candidates[0])) return True diff --git a/tests/test_git_branches.py b/tests/test_git_branches.py index 6e3c158..f9bc1c4 100644 --- a/tests/test_git_branches.py +++ b/tests/test_git_branches.py @@ -15,6 +15,7 @@ commit_files_in_dir, delete_branch, derive_split_namespace, + diff_base_ref, is_worktree_clean, merge_base, push_branch, @@ -353,6 +354,8 @@ def test_branch_on_one_remote_is_created_locally( 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 From 4fa89d81605ed208e80e19d27b69f99b5a40b35f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 05:04:07 +0000 Subject: [PATCH 138/143] Count the hunks a WHOLE_FILE assignment covers in the oversized report A whole new file is usually a WHOLE_FILE assignment with no hunk indices, so the single-hunk note missed exactly the case it names. Count covered_indices against the parsed diff instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- pr_split/cli.py | 22 +++++++++++++++++----- tests/test_cli_helpers.py | 26 +++++++++++++++++++++++--- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index 4bcc697..86e25aa 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -227,15 +227,25 @@ 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) -> None: - """One visible line when groups miss the --max-loc target, so it is never silent.""" +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.hunk_indices) for a in g.assignments) == 1] + 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." @@ -1361,7 +1371,7 @@ 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) + _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 @@ -1678,7 +1688,9 @@ def execute( raise typer.Exit(1) from exc _present_plan(plan.groups) - _report_oversized_groups(plan.groups, plan.max_loc) + _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) diff --git a/tests/test_cli_helpers.py b/tests/test_cli_helpers.py index f2ae366..71ed88a 100644 --- a/tests/test_cli_helpers.py +++ b/tests/test_cli_helpers.py @@ -752,7 +752,7 @@ def test_summary_names_count_limit_and_largest(self) -> None: 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) + _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 @@ -768,15 +768,35 @@ def test_single_hunk_group_is_named_as_irreducible(self) -> None: ) ] with console.capture() as capture: - _report_oversized_groups([big_file], 400) + _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) + _report_oversized_groups([self._sized("pr-1", 400)], 400, {}) assert capture.get() == "" def test_oversized_ids_are_recorded(self) -> None: From d291262da7828f49929565f682858bd6791f0152 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 06:12:54 +0000 Subject: [PATCH 139/143] Match an adopted branch's PR exactly and say how to finish a failed adopt gh pr list --head also returns fork PRs and heads that merely start with the branch name, so find_open_pr now keeps only an exact, same-repository head. A failed link or lookup saves nothing, so adopt can be re-run; the error now says so, since the stack may already be partly registered. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- pr_split/cli.py | 10 ++++++++++ pr_split/git_ops/prs.py | 24 +++++++++++++++++++----- tests/test_cli_coverage.py | 23 +++++++++++++++++++++++ tests/test_git_prs_coverage.py | 21 +++++++++++++++++++++ 4 files changed, 73 insertions(+), 5 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index 28a8e17..f92e933 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -1569,6 +1569,14 @@ def _cleanup_git_state(git_state: GitState) -> tuple[int, int]: 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." @@ -1621,10 +1629,12 @@ def adopt( 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] = [] diff --git a/pr_split/git_ops/prs.py b/pr_split/git_ops/prs.py index 29d5542..35eb752 100644 --- a/pr_split/git_ops/prs.py +++ b/pr_split/git_ops/prs.py @@ -47,17 +47,31 @@ def check_gh_stack() -> bool: def find_open_pr(branch: str) -> tuple[int, str] | None: - """The open PR whose head is ``branch`` in this repository, if any.""" + """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", "--limit", "1" + "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 - if not prs: - return None - return int(prs[0]["number"]), str(prs[0]["url"]) + 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( diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index 7afc4f7..626a437 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -1533,6 +1533,29 @@ def test_chain_is_linked_on_base_and_saved_as_a_stacked_plan( ] assert [p.pr_number for p in saved.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 diff --git a/tests/test_git_prs_coverage.py b/tests/test_git_prs_coverage.py index 06331de..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, ) @@ -263,3 +264,23 @@ def test_malformed_response_is_a_git_operation_error( 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 From 4992c9aa7e4401bd4059daf4d7b11f13eb13fee9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 07:44:23 +0000 Subject: [PATCH 140/143] Never close or delete an adopted stack's PRs and branches in clean adopt records the user's own branches and PRs in the plan's git state, so clean (and a re-split's cleanup) would close those PRs and delete the branches locally and remotely. Records now carry an adopted flag, set by adopt; cleanup counts adopted ones as done without touching them, so the plan is forgotten and the user's work is kept. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- README.md | 2 +- pr_split/cli.py | 12 +++++++++++- pr_split/logs.py | 2 ++ pr_split/schemas.py | 4 ++++ tests/test_cli_coverage.py | 3 +++ tests/test_cli_new_features.py | 30 ++++++++++++++++++++++++++++++ 6 files changed, 51 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 363d194..17365fd 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ Creates branches and PRs from a previously saved `--dry-run` plan. Uses the save 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. +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. ### Interactive plan editing diff --git a/pr_split/cli.py b/pr_split/cli.py index 5148809..7a2e386 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -1534,6 +1534,11 @@ def status() -> None: 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. @@ -1553,6 +1558,10 @@ def _cleanup_git_state(git_state: GitState) -> tuple[int, int]: 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 @@ -1656,9 +1665,10 @@ def adopt( 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])) + pr_records.append(PRRecord(group_id=gid, pr_number=pr[0], pr_url=pr[1], adopted=True)) save_plan( PlanFile( plan=SplitPlan( diff --git a/pr_split/logs.py b/pr_split/logs.py index ca8d421..0cb01f5 100644 --- a/pr_split/logs.py +++ b/pr_split/logs.py @@ -68,6 +68,8 @@ BRANCH_DELETED = "Deleted branch {branch}" 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" diff --git a/pr_split/schemas.py b/pr_split/schemas.py index fbd4d50..ec804cc 100644 --- a/pr_split/schemas.py +++ b/pr_split/schemas.py @@ -79,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): @@ -86,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/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index c98d55d..04cbbb7 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -1532,6 +1532,9 @@ def test_chain_is_linked_on_base_and_saved_as_a_stacked_plan( ("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", return_value=None) @patch("pr_split.cli.link_stack") diff --git a/tests/test_cli_new_features.py b/tests/test_cli_new_features.py index d22bafa..2e795e9 100644 --- a/tests/test_cli_new_features.py +++ b/tests/test_cli_new_features.py @@ -135,6 +135,36 @@ 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") From 083fc61c2f414290ba84917b98a20d73f5a5e4fb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 07:57:12 +0000 Subject: [PATCH 141/143] Merge origin/main into feat/per-branch-plans; file an adopted stack under its top branch adopt (from main) checked for an existing plan and saved one without selecting a branch, so with plans for other branches it hit the ambiguous plan error or refused over another branch's state. It now selects the stack's top branch first, as split does with its dev branch. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- pr_split/cli.py | 2 ++ tests/test_cli_coverage.py | 43 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/pr_split/cli.py b/pr_split/cli.py index f4857f9..ba06c34 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -1619,6 +1619,8 @@ def adopt( 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: diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index 04cbbb7..8c5bf6d 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -1536,6 +1536,49 @@ def test_chain_is_linked_on_base_and_saved_as_a_stacked_plan( 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) From e8d00e157ddbce21622e10c308827188c4a02de5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 08:45:21 +0000 Subject: [PATCH 142/143] Keep a resumed layer's ancestors whenever the plan has dependencies Since execute lays out any plan with dependency edges along its DAG, stacked or not, an unstacked child also sits on its parent's branch; rebuilding the parent on resume would rewrite the base under the child's open PR. Keep recorded ancestors whenever the plan has dependencies. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- pr_split/cli.py | 9 +++++---- tests/test_cli_coverage.py | 5 +++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/pr_split/cli.py b/pr_split/cli.py index 354674c..ad690a4 100644 --- a/pr_split/cli.py +++ b/pr_split/cli.py @@ -1972,10 +1972,11 @@ def execute( console.print(f"[red]{exc}[/red]") raise typer.Exit(1) from exc - if plan.stacked: - # 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. + # 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: diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py index b5fd62c..cb5bfe0 100644 --- a/tests/test_cli_coverage.py +++ b/tests/test_cli_coverage.py @@ -1143,7 +1143,8 @@ def test_stacked_resume_keeps_the_parents_of_a_layer_with_a_pr( 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 a stacked resume keeps it.""" + 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 @@ -1153,7 +1154,7 @@ def test_stacked_resume_keeps_the_parents_of_a_layer_with_a_pr( assert result.exit_code == 0, result.output kept = set(mock_create.call_args.kwargs["keep"]) - assert kept == ({"pr-1", "pr-2"} if stacked else {"pr-2"}) + assert kept == {"pr-1", "pr-2"} # pr-1 still gets its PR opened. assert set(mock_push.call_args.kwargs["existing_prs"]) == {"pr-2"} From aaccf31a23bb89cf9c435ee7d09cccecdce8d8bb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 09:47:23 +0000 Subject: [PATCH 143/143] fix: use a private temp dir for the no-hooks hooksPath A fixed path under the shared temp dir could be created first by another local user, who could then plant hooks that run during worktree add. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rnEEjXWDBdePRhKMmZr1w --- pr_split/git_ops/branches.py | 17 ++++++++++++----- tests/test_git_branches.py | 8 ++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/pr_split/git_ops/branches.py b/pr_split/git_ops/branches.py index 586e4da..48825b0 100644 --- a/pr_split/git_ops/branches.py +++ b/pr_split/git_ops/branches.py @@ -1,11 +1,13 @@ from __future__ import annotations +import atexit +import functools import os import re +import shutil import subprocess import tempfile import time -from pathlib import Path from loguru import logger @@ -251,11 +253,16 @@ def add_worktree(path: str, branch_name: str, start_point: str) -> None: raise +@functools.cache def _no_hooks_dir() -> str: - """An empty directory to use as core.hooksPath (no hooks run).""" - path = Path(tempfile.gettempdir()) / "pr-split-no-hooks" - path.mkdir(exist_ok=True) - return str(path) + """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: diff --git a/tests/test_git_branches.py b/tests/test_git_branches.py index d1755c8..65b729d 100644 --- a/tests/test_git_branches.py +++ b/tests/test_git_branches.py @@ -9,6 +9,7 @@ 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, @@ -332,6 +333,13 @@ def test_adds_worktree(self, mock_exists: MagicMock, mock_git: MagicMock) -> Non 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) def test_deletes_existing_branch_first(