diff --git a/pr_split/exceptions.py b/pr_split/exceptions.py index 55ac79c..b8aafdf 100644 --- a/pr_split/exceptions.py +++ b/pr_split/exceptions.py @@ -121,6 +121,10 @@ class ErrorMsg(StrEnum): "Diff contains binary files, which cannot be split into hunks: {files}." " Commit them separately and re-run" ) + HUNKLESS_FILES_UNSUPPORTED = ( + "Diff contains files with no text hunks (mode-only or empty changes), which" + " cannot be assigned to a sub-PR: {files}. Commit them separately and re-run" + ) GH_STACK_MISSING = ( "The gh-stack extension is required for stacked PRs;" " run 'gh extension install github/gh-stack'" diff --git a/pr_split/planner/validator.py b/pr_split/planner/validator.py index bd4c3b2..ab9da8d 100644 --- a/pr_split/planner/validator.py +++ b/pr_split/planner/validator.py @@ -13,15 +13,20 @@ def validate_no_binary_files(parsed_diff: ParsedDiff) -> None: - """Refuse diffs with binary files. + """Refuse diffs with binary files or hunk-less entries. - 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 patches and mode-only changes 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))) + # Mode-only changes (chmod +x) and other hunk-less entries have the same + # failure mode: nothing for a group to claim, so the change vanishes. + hunkless = [pf.path for pf in parsed_diff.patch_set if len(pf) == 0] + if hunkless: + raise PlanValidationError(ErrorMsg.HUNKLESS_FILES_UNSUPPORTED(files=", ".join(hunkless))) def validate_coverage(groups: list[Group], parsed_diff: ParsedDiff) -> None: diff --git a/tests/test_validator.py b/tests/test_validator.py index eef8222..6b34b27 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -275,3 +275,29 @@ def test_stale_whole_file_index_does_not_create_a_phantom_conflict(self) -> None _make_group("g2", [_ga("b.py", WHOLE, [0]), _ga("a.py", PARTIAL, [99])], 4), ] validate_no_conflicts(groups, PlanDAG(groups), {"a.py": 1, "b.py": 1}) + + +MODE_ONLY_DIFF = """\ +diff --git a/run.sh b/run.sh +old mode 100644 +new mode 100755 +diff --git a/a.py b/a.py +--- a/a.py ++++ b/a.py +@@ -1 +1 @@ +-x ++y +""" + + +class TestValidateNoHunklessFiles: + def test_mode_only_change_raises(self) -> None: + parsed = parse_diff(MODE_ONLY_DIFF) + with pytest.raises(PlanValidationError, match=r"no text hunks.*run\.sh"): + validate_no_binary_files(parsed) + + def test_validate_plan_rejects_mode_only_even_when_text_hunks_are_covered(self) -> None: + parsed = parse_diff(MODE_ONLY_DIFF) + groups = [_make_group("g1", [_ga("a.py", WHOLE, [0])], 2)] + with pytest.raises(PlanValidationError, match="no text hunks"): + validate_plan(groups, parsed, PlanDAG(groups), max_loc=400)