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