From 2eeb28e2a19c80b0f9d325c19586612e3a184332 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Sun, 1 Nov 2026 18:54:31 -0300 Subject: [PATCH] Use git rename from/to information --- tests/samples/git_rename.diff | 4 +- tests/test_parser.py | 70 +++++++++++++++++++++++++++++++++++ unidiff/constants.py | 5 +++ unidiff/patch.py | 23 ++++++++++++ 4 files changed, 100 insertions(+), 2 deletions(-) diff --git a/tests/samples/git_rename.diff b/tests/samples/git_rename.diff index 86ea7e2..a7a380c 100644 --- a/tests/samples/git_rename.diff +++ b/tests/samples/git_rename.diff @@ -28,5 +28,5 @@ index a071991..4dbab21 100644 diff --git a/sub/onefile b/sub/otherfile similarity index 100% -rename from onefile -rename to otherfile +rename from sub/onefile +rename to sub/otherfile diff --git a/tests/test_parser.py b/tests/test_parser.py index 0c1aab9..9a9ffc4 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -720,6 +720,76 @@ def test_mode_change_file(self): self.assertFalse(res[0].is_symlink) self.assertTrue(res[0].is_rename) + def test_rename_no_prefix(self): + # `git diff --no-prefix`: the header names carry no a/ b/ prefix, + # so the `rename from` / `rename to` lines are the reliable source + diff = ( + 'diff --git a.py b.py\n' + 'similarity index 100%\n' + 'rename from a.py\n' + 'rename to b.py\n' + ) + res = PatchSet(diff) + + self.assertTrue(res[0].is_rename) + self.assertEqual(res[0].path, 'b.py') + self.assertEqual(res[0].source_file, 'a.py') + self.assertEqual(res[0].target_file, 'b.py') + self.assertEqual(res[0].rename_from, 'a.py') + self.assertEqual(res[0].rename_to, 'b.py') + self.assertEqual(str(res), diff) + + def test_rename_no_prefix_single_char_dirs(self): + # directory names that look like git prefixes are not stripped + diff = ( + 'diff --git x/util.py y/util.py\n' + 'similarity index 100%\n' + 'rename from x/util.py\n' + 'rename to y/util.py\n' + ) + res = PatchSet(diff) + + self.assertTrue(res[0].is_rename) + self.assertEqual(res[0].path, 'y/util.py') + + diff = ( + 'diff --git a/x.py b/x.py\n' + 'similarity index 100%\n' + 'rename from a/x.py\n' + 'rename to b/x.py\n' + ) + res = PatchSet(diff) + + self.assertTrue(res[0].is_rename) + self.assertEqual(res[0].path, 'b/x.py') + + def test_rename_quoted_filename(self): + diff = ( + 'diff --git "a/caf\\303\\251.py" "b/th\\303\\251.py"\n' + 'similarity index 100%\n' + 'rename from "caf\\303\\251.py"\n' + 'rename to "th\\303\\251.py"\n' + ) + res = PatchSet(diff) + + self.assertTrue(res[0].is_rename) + self.assertEqual(res[0].path, '"th\\303\\251.py"') + + def test_non_git_tree_diff_not_rename(self): + # `diff -ruN v1 v2` names both trees; this is not a rename + diff = ( + 'diff -ruN v1/src/foo.c v2/src/foo.c\n' + '--- v1/src/foo.c\t2026-10-30 10:00:00.000000000 +0000\n' + '+++ v2/src/foo.c\t2026-10-30 10:01:00.000000000 +0000\n' + '@@ -1 +1 @@\n' + '-a\n' + '+b\n' + ) + res = PatchSet(diff) + + self.assertFalse(res[0].is_rename) + self.assertEqual(res[0].path, 'v1/src/foo.c') + def test_index_line_mode(self): # issue #125: an unchanged mode on the index line applies to both sides diff = ( diff --git a/unidiff/constants.py b/unidiff/constants.py index 8d214da..2422c51 100644 --- a/unidiff/constants.py +++ b/unidiff/constants.py @@ -53,6 +53,11 @@ RE_DIFF_GIT_OLD_MODE = re.compile(r'^old mode (?P\d+)$') RE_DIFF_GIT_NEW_MODE = re.compile(r'^new mode (?P\d+)$') +# check diff git rename markers `rename from old.py` / `rename to new.py` +# (the paths carry no a/ b/ prefix, whatever prefix the header uses) +RE_DIFF_GIT_RENAME_FROM = re.compile(r'^rename from (?P[^\r\n]+)$') +RE_DIFF_GIT_RENAME_TO = re.compile(r'^rename to (?P[^\r\n]+)$') + # check diff git index line with a trailing mode `index abc..def 100644` RE_DIFF_GIT_INDEX = re.compile( r'^index [0-9a-f]+\.\.[0-9a-f]+ (?P\d+)$') diff --git a/unidiff/patch.py b/unidiff/patch.py index 11083d8..145e553 100644 --- a/unidiff/patch.py +++ b/unidiff/patch.py @@ -46,6 +46,8 @@ RE_DIFF_GIT_NEW_FILE, RE_DIFF_GIT_NEW_MODE, RE_DIFF_GIT_OLD_MODE, + RE_DIFF_GIT_RENAME_FROM, + RE_DIFF_GIT_RENAME_TO, RE_HUNK_BODY_LINE, RE_HUNK_EMPTY_BODY_LINE, RE_HUNK_HEADER, @@ -225,6 +227,9 @@ def __init__(self, patch_info: Optional[PatchInfo] = None, # 1-based line number in the diff where this file entry starts; useful # to locate files that have no hunks (e.g. binary changes) self.diff_line_no = diff_line_no + # paths from git `rename from` / `rename to` lines; None if missing + self.rename_from: Optional[str] = None + self.rename_to: Optional[str] = None def __repr__(self) -> str: return "" % self.path @@ -366,6 +371,10 @@ def _append_trailing_empty_line(self) -> None: @property def path(self) -> str: """Return the file path abstracted from VCS.""" + if self.rename_to is not None: + # git states the new path explicitly, without any prefix + return self.rename_to + filepath = self.source_file if filepath in (None, DEV_NULL) or ( self.is_rename and self.target_file not in (None, DEV_NULL)): @@ -396,6 +405,8 @@ def removed(self) -> int: @property def is_rename(self) -> bool: + if self.rename_from is not None and self.rename_to is not None: + return True return (self.source_file != DEV_NULL and self.target_file != DEV_NULL and self.source_file[2:] != self.target_file[2:]) @@ -531,6 +542,18 @@ def _parse(self, diff: Iterable, encoding: Optional[str], patch_info.append(line) continue + is_diff_git_rename_from = RE_DIFF_GIT_RENAME_FROM.match(line) + if is_diff_git_rename_from: + current_file.rename_from = is_diff_git_rename_from.group('filename') + patch_info.append(line) + continue + + is_diff_git_rename_to = RE_DIFF_GIT_RENAME_TO.match(line) + if is_diff_git_rename_to: + current_file.rename_to = is_diff_git_rename_to.group('filename') + patch_info.append(line) + continue + is_diff_git_index = RE_DIFF_GIT_INDEX.match(line) if is_diff_git_index: # an unchanged index mode applies to both source and target