Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions tests/samples/git_rename.diff
Original file line number Diff line number Diff line change
Expand Up @@ -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
70 changes: 70 additions & 0 deletions tests/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
5 changes: 5 additions & 0 deletions unidiff/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@
RE_DIFF_GIT_OLD_MODE = re.compile(r'^old mode (?P<mode>\d+)$')
RE_DIFF_GIT_NEW_MODE = re.compile(r'^new mode (?P<mode>\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<filename>[^\r\n]+)$')
RE_DIFF_GIT_RENAME_TO = re.compile(r'^rename to (?P<filename>[^\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<mode>\d+)$')
Expand Down
23 changes: 23 additions & 0 deletions unidiff/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 "<PatchedFile: %s>" % self.path
Expand Down Expand Up @@ -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)):
Expand Down Expand Up @@ -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:])
Expand Down Expand Up @@ -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
Expand Down
Loading