Skip to content
Merged
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
135 changes: 99 additions & 36 deletions patch_ng.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ def __init__(self):

self.type = None
self.filemode = None
self.mode = None

def __iter__(self):
return iter(self.hunks)
Expand Down Expand Up @@ -378,6 +379,7 @@ def lineno(self):
header = []
srcname = None
tgtname = None
rename = False

# start of main cycle
# each parsing block already has line available in fe.line
Expand All @@ -388,19 +390,26 @@ def lineno(self):
# -- line fetched at the start of this cycle
if hunkparsed:
hunkparsed = False
rename = False
if re_hunk_start.match(fe.line):
hunkhead = True
elif fe.line.startswith(b"--- "):
filenames = True
elif fe.line.startswith(b"rename from "):
filenames = True
else:
headscan = True
# -- ------------------------------------

# read out header
if headscan:
while not fe.is_empty and not fe.line.startswith(b"--- "):
header.append(fe.line)
fe.next()
while not fe.is_empty and not fe.line.startswith(b"--- ") and not fe.line.startswith(b"rename from "):
header.append(fe.line)
fe.next()
if not fe.is_empty and fe.line.startswith(b"rename from "):
rename = True
hunkskip = True
hunkbody = False
if fe.is_empty:
if p is None:
debug("no patch data found") # error is shown later
Expand Down Expand Up @@ -495,7 +504,7 @@ def lineno(self):
# switch to hunkhead state
hunkskip = False
hunkhead = True
elif line.startswith(b"--- "):
elif line.startswith(b"--- ") or line.startswith(b"rename from "):
# switch to filenames state
hunkskip = False
filenames = True
Expand Down Expand Up @@ -538,6 +547,50 @@ def lineno(self):
# switch back to headscan state
filenames = False
headscan = True
elif rename:
if line.startswith(b"rename from "):
re_rename_from = br"^rename from (.+)"
match = re.match(re_rename_from, line)
if match:
srcname = match.group(1).strip()
else:
warning("skipping invalid rename from at line %d" % (lineno+1))
self.errors += 1
# XXX p.header += line
# switch back to headscan state
filenames = False
headscan = True
if not fe.is_empty:
fe.next()
line = fe.line
lineno = fe.lineno
re_rename_to = br"^rename to (.+)"
match = re.match(re_rename_to, line)
if match:
tgtname = match.group(1).strip()
else:
warning("skipping invalid rename from at line %d" % (lineno + 1))
self.errors += 1
# XXX p.header += line
# switch back to headscan state
filenames = False
headscan = True
if p: # for the first run p is None
self.items.append(p)
p = Patch()
p.source = srcname
srcname = None
p.target = tgtname
tgtname = None
p.header = header
header = []
# switch to hunkhead state
filenames = False
hunkhead = False
nexthunkno = 0
p.hunkends = lineends.copy()
hunkparsed = True
continue
elif not line.startswith(b"+++ "):
if srcname != None:
warning("skipping invalid patch with no target for %s" % srcname)
Expand Down Expand Up @@ -664,6 +717,7 @@ def lineno(self):
self.items[idx].type = self._detect_type(p)
if self.items[idx].type == GIT:
self.items[idx].filemode = self._detect_file_mode(p)
self.items[idx].mode = self._detect_patch_mode(p)

types = set([p.type for p in self.items])
if len(types) > 1:
Expand Down Expand Up @@ -720,38 +774,26 @@ def _detect_type(self, p):
if DVCS:
return GIT

# Additional check: look for mode change patterns
# "old mode XXXXX" followed by "new mode XXXXX"
has_old_mode = False
has_new_mode = False

for line in git_indicators:
if re.match(b'old mode \\d+', line):
has_old_mode = True
elif re.match(b'new mode \\d+', line):
has_new_mode = True

# If we have both old and new mode, it's definitely Git
if has_old_mode and has_new_mode and DVCS:
return GIT

# Check for similarity index (Git renames/copies)
for line in git_indicators:
if re.match(b'similarity index \\d+%', line):
if DVCS:
return GIT

# Check for rename patterns
for line in git_indicators:
if re.match(b'rename from .+', line) or re.match(b'rename to .+', line):
if DVCS:
return GIT

# Check for copy patterns
for line in git_indicators:
if re.match(b'copy from .+', line) or re.match(b'copy to .+', line):
if DVCS:
return GIT
# Additional check: look for mode change patterns
# "old mode XXXXX" followed by "new mode XXXXX"
has_old_mode = False
has_new_mode = False

for line in git_indicators:
if re.match(b'old mode \\d+', line):
has_old_mode = True
elif re.match(b'new mode \\d+', line):
has_new_mode = True

# If we have both old and new mode, it's definitely Git
if has_old_mode and has_new_mode and DVCS:
return GIT

# Check for similarity index (Git renames/copies)
for line in git_indicators:
if re.match(b'similarity index \\d+%', line):
return GIT

# HG check
#
# - for plain HG format header is like "diff -r b2d9961ff1f5 filename"
Expand Down Expand Up @@ -809,6 +851,20 @@ def _apply_filemode(self, filepath, filemode):
except Exception as error:
warning(f"Could not set filemode {oct(filemode)} for {filepath}: {str(error)}")

def _detect_patch_mode(self, p):
"""Detect patch mode - add, delete, rename, etc.
"""
if len(p.header) > 1:
for idx in reversed(range(len(p.header))):
if p.header[idx].startswith(b"diff --git"):
break
change_pattern = re.compile(rb"^diff --git a/([^ ]+) b/(.+)")
match = change_pattern.match(p.header[idx])
if match:
if match.group(1) != match.group(2) and not p.hunks and p.source != b'/dev/null' and p.target != b'/dev/null':
return 'rename'
return None

def _normalize_filenames(self):
""" sanitize filenames, normalizing paths, i.e.:
1. strip a/ and b/ prefixes from GIT and HG style patches
Expand Down Expand Up @@ -1006,6 +1062,13 @@ def apply(self, strip=0, root=None, fuzz=False):
elif "dev/null" in target:
source = self.strip_path(source, root, strip)
safe_unlink(source)
elif item.mode == 'rename':
source = self.strip_path(source, root, strip)
target = self.strip_path(target, root, strip)
if exists(source):
os.makedirs(os.path.dirname(target), exist_ok=True)
shutil.move(source, target)
self._apply_filemode(target, item.filemode)
else:
items.append(item)
self.items = items
Expand Down
61 changes: 61 additions & 0 deletions tests/movefile/0001-quote.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
From 90ffe300b588f40f1409deb414498af8bc681072 Mon Sep 17 00:00:00 2001
From: John Doe <john.doe@mail.com>
Date: Fri, 3 Oct 2025 11:28:55 +0200
Subject: [PATCH 1/3] Add file

Signed-off-by: John Doe <john.doe@mail.com>
---
quotes.txt | 1 +
1 file changed, 1 insertion(+)
create mode 100644 quotes.txt

diff --git a/quotes.txt b/quotes.txt
new file mode 100644
index 0000000000000000000000000000000000000000..6da41619625400f0b6c99beccc47c328f3967366
--- /dev/null
+++ b/quotes.txt
@@ -0,0 +1 @@
+in herbis, salus.
--
2.51.0


From a055d1be31d11c149bfd9ca88d9554199d57d444 Mon Sep 17 00:00:00 2001
From: John Doe <john.doe@mail.com>
Date: Fri, 3 Oct 2025 11:29:27 +0200
Subject: [PATCH 2/3] Move file

Signed-off-by: John Doe <john.doe@mail.com>
---
quotes.txt => quote/quotes.txt | 0
1 file changed, 0 insertions(+), 0 deletions(-)
rename quotes.txt => quote/quotes.txt (100%)

diff --git a/quotes.txt b/quote/quotes.txt
similarity index 100%
rename from quotes.txt
rename to quote/quotes.txt
--
2.51.0


From c275530ce83dc6eeeae671c2082660f1b6c16c4f Mon Sep 17 00:00:00 2001
From: John Doe <john.doe@mail.com>
Date: Fri, 3 Oct 2025 11:30:14 +0200
Subject: [PATCH 3/3] Update quote

Signed-off-by: John Doe <john.doe@mail.com>
---
quote/quotes.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/quote/quotes.txt b/quote/quotes.txt
index 6da41619625400f0b6c99beccc47c328f3967366..928532e38b2a8e607814da280bb9e02862d2b4ea 100644
--- a/quote/quotes.txt
+++ b/quote/quotes.txt
@@ -1 +1 @@
-in herbis, salus.
+dum tempus habemus, operemur bonum.
--
2.51.0

32 changes: 32 additions & 0 deletions tests/run_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,38 @@ def test_apply_patch_only_file_mode(self):
self.assertTrue(pto.apply())
self.assertEqual(os.stat(join(self.tmpdir, 'quote.txt')).st_mode, 0o644 | stat.S_IFREG)

class TestMoveAndPatch(unittest.TestCase):

def setUp(self):
self.save_cwd = os.getcwd()
self.tmpdir = mkdtemp(prefix=self.__class__.__name__)
shutil.copytree(join(TESTS, 'movefile'), join(self.tmpdir, 'movefile'))

def tearDown(self):
os.chdir(self.save_cwd)
remove_tree_force(self.tmpdir)

def test_add_move_and_update_file(self):
"""When a patch file contains a file move (rename) and an update to the file,
the patch should be applied correctly.

Reported by https://github.com/conan-io/python-patch-ng/issues/24
"""

os.chdir(self.tmpdir)
pto = patch_ng.fromfile(join(self.tmpdir, 'movefile', '0001-quote.patch'))
self.assertEqual(len(pto), 3)
self.assertEqual(pto.items[0].type, patch_ng.GIT)
self.assertEqual(pto.items[1].type, patch_ng.GIT)
self.assertEqual(pto.items[2].type, patch_ng.GIT)
self.assertEqual(pto.items[1].mode, 'rename')
self.assertTrue(pto.apply())
self.assertFalse(os.path.exists(join(self.tmpdir, 'quotes.txt')))
self.assertTrue(os.path.exists(join(self.tmpdir, 'quote', 'quotes.txt')))
with open(join(self.tmpdir, 'quote', 'quotes.txt'), 'rb') as f:
content = f.read()
self.assertTrue(b'dum tempus habemus, operemur bonum' in content)

class TestHelpers(unittest.TestCase):
# unittest setting
longMessage = True
Expand Down