Skip to content

Commit a771a5c

Browse files
authored
Merge branch 'main' into fix/cp-sat-feasible-warning
2 parents 271f9b6 + d7edd1e commit a771a5c

5 files changed

Lines changed: 99 additions & 6 deletions

File tree

pr_split/cli.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,14 @@ def _validate_inputs(
193193
_require_gh_stack()
194194

195195

196+
def _load_plan_or_exit() -> PlanFile:
197+
try:
198+
return load_plan()
199+
except PRSplitError as exc:
200+
console.print(f"[red]{exc}[/red]")
201+
raise typer.Exit(1) from exc
202+
203+
196204
def _handle_loc_bound_warnings(warnings: list[str], *, strict_loc_bounds: bool) -> None:
197205
if strict_loc_bounds and warnings:
198206
console.print(f"[red]{ErrorMsg.LOC_BOUNDS_STRICT_FAILED()}[/red]")
@@ -839,7 +847,7 @@ def split(
839847
_validate_inputs(dev_branch, base, dry_run=dry_run, stacked=stack)
840848

841849
if plan_exists():
842-
existing = load_plan()
850+
existing = _load_plan_or_exit()
843851
has_git_state = existing.git_state.branches or existing.git_state.prs
844852
if has_git_state:
845853
console.print("[yellow]An existing split plan with branches/PRs was found.[/yellow]")
@@ -982,7 +990,7 @@ def status() -> None:
982990
console.print(ErrorMsg.NO_PLAN())
983991
raise typer.Exit(0)
984992

985-
plan_file = load_plan()
993+
plan_file = _load_plan_or_exit()
986994
plan = plan_file.plan
987995
git_state = plan_file.git_state
988996

@@ -1078,7 +1086,7 @@ def clean() -> None:
10781086
console.print(ErrorMsg.NO_PLAN())
10791087
raise typer.Exit(0)
10801088

1081-
plan_file = load_plan()
1089+
plan_file = _load_plan_or_exit()
10821090
git_state = plan_file.git_state
10831091

10841092
typer.confirm("Delete all pr-split branches and close PRs?", abort=True)
@@ -1115,7 +1123,7 @@ def execute(
11151123
console.print(ErrorMsg.NO_PLAN())
11161124
raise typer.Exit(1)
11171125

1118-
plan_file = load_plan()
1126+
plan_file = _load_plan_or_exit()
11191127
plan = plan_file.plan
11201128
if stack and not plan.stacked:
11211129
plan = plan.model_copy(update={"stacked": True})
@@ -1282,7 +1290,7 @@ def merge_all(
12821290
console.print(ErrorMsg.NO_PLAN())
12831291
raise typer.Exit(0)
12841292

1285-
plan_file = load_plan()
1293+
plan_file = _load_plan_or_exit()
12861294
plan = plan_file.plan
12871295
git_state = plan_file.git_state
12881296
pr_map = {r.group_id: r for r in git_state.prs}

pr_split/exceptions.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ class ErrorMsg(StrEnum):
2525
LOC_MISMATCH = "Total LOC {actual} does not match diff LOC {expected}"
2626
MERGE_CONFLICT = "Groups '{a}' and '{b}' modify overlapping regions in '{file}'"
2727
NO_PLAN = "No split plan found; run 'pr-split split' first"
28+
PLAN_LOAD_FAILED = (
29+
"Cannot load split plan from '{path}': {detail}; delete it and run 'pr-split split' again"
30+
)
2831
LLM_PARSE_ERROR = "Failed to parse LLM response: {detail}"
2932
LLM_OUTPUT_TRUNCATED = (
3033
"LLM response was cut off before the plan was complete ({detail});"

pr_split/plan_store.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from pathlib import Path
22

33
from loguru import logger
4+
from pydantic import ValidationError
45

56
from . import logs
67
from .constants import PLAN_DIR, PLAN_FILE
@@ -20,7 +21,10 @@ def load_plan() -> PlanFile:
2021
plan_path = Path(PLAN_FILE)
2122
if not plan_path.exists():
2223
raise PRSplitError(ErrorMsg.NO_PLAN())
23-
plan_file = PlanFile.model_validate_json(plan_path.read_text())
24+
try:
25+
plan_file = PlanFile.model_validate_json(plan_path.read_text())
26+
except (OSError, UnicodeDecodeError, ValidationError) as exc:
27+
raise PRSplitError(ErrorMsg.PLAN_LOAD_FAILED(path=plan_path, detail=exc)) from exc
2428
logger.info(logs.PLAN_LOADED.format(count=len(plan_file.plan.groups), path=plan_path))
2529
return plan_file
2630

tests/test_cli_new_features.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,44 @@ def test_split_revalidates_min_loc_after_interactive_edit(
450450
mock_save_plan.assert_not_called()
451451

452452

453+
class TestCorruptPlanFile:
454+
@pytest.mark.parametrize("command", ["status", "clean", "execute", "merge"])
455+
def test_commands_report_corrupt_plan(
456+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, command: str
457+
) -> None:
458+
monkeypatch.chdir(tmp_path)
459+
(tmp_path / ".pr-split").mkdir()
460+
(tmp_path / ".pr-split" / "plan.json").write_text("{not json")
461+
462+
result = runner.invoke(app, [command])
463+
464+
assert result.exit_code == 1
465+
assert "Cannot load split plan" in result.output
466+
assert not isinstance(result.exception, Exception) or isinstance(
467+
result.exception, SystemExit
468+
)
469+
470+
@patch("pr_split.cli._validate_inputs")
471+
@patch("pr_split.cli.branch_exists", return_value=True)
472+
def test_split_reports_corrupt_plan(
473+
self,
474+
mock_branch_exists: MagicMock,
475+
mock_validate_inputs: MagicMock,
476+
tmp_path: Path,
477+
monkeypatch: pytest.MonkeyPatch,
478+
) -> None:
479+
monkeypatch.chdir(tmp_path)
480+
(tmp_path / ".pr-split").mkdir()
481+
(tmp_path / ".pr-split" / "plan.json").write_text("")
482+
483+
result = runner.invoke(
484+
app, ["split", "feature-branch", "--dry-run"], env={"ANTHROPIC_API_KEY": "sk-test"}
485+
)
486+
487+
assert result.exit_code == 1
488+
assert "Cannot load split plan" in result.output
489+
490+
453491
class TestCleanupSkipsFinishedPrs:
454492
@patch("pr_split.cli.get_pr_state")
455493
@patch("pr_split.cli.shutil.rmtree")

tests/test_plan_store.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,43 @@ def test_saved_file_is_valid_json(
106106
assert raw["plan"]["priority"] == "logical"
107107
assert raw["plan"]["min_loc"] == 25
108108
assert raw["plan"]["strict_loc_bounds"] is True
109+
110+
111+
class TestPlanStoreCorruptFiles:
112+
@pytest.mark.parametrize(
113+
"content",
114+
[
115+
pytest.param("", id="empty"),
116+
pytest.param("{not json", id="not-json"),
117+
pytest.param('{"plan": {"dev_branch": "x"}}', id="missing-fields"),
118+
pytest.param('{"plan": {"groups": []}, "git_state": {}}', id="old-schema"),
119+
],
120+
)
121+
def test_unreadable_plan_raises_pr_split_error(
122+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, content: str
123+
) -> None:
124+
monkeypatch.chdir(tmp_path)
125+
plan_dir = tmp_path / ".pr-split"
126+
plan_dir.mkdir()
127+
(plan_dir / "plan.json").write_text(content)
128+
with pytest.raises(PRSplitError, match="Cannot load split plan") as excinfo:
129+
load_plan()
130+
assert "pr-split split" in str(excinfo.value)
131+
132+
def test_invalid_utf8_raises_pr_split_error(
133+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
134+
) -> None:
135+
monkeypatch.chdir(tmp_path)
136+
(tmp_path / ".pr-split").mkdir()
137+
(tmp_path / ".pr-split" / "plan.json").write_bytes(b'{"plan": "\xff\xfe"}')
138+
with pytest.raises(PRSplitError, match="Cannot load split plan"):
139+
load_plan()
140+
141+
def test_read_error_raises_pr_split_error(
142+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
143+
) -> None:
144+
monkeypatch.chdir(tmp_path)
145+
(tmp_path / ".pr-split").mkdir()
146+
(tmp_path / ".pr-split" / "plan.json").mkdir()
147+
with pytest.raises(PRSplitError, match="Cannot load split plan"):
148+
load_plan()

0 commit comments

Comments
 (0)