Skip to content

Commit 32364f4

Browse files
authored
[fix] Apply a patch that moves a file and updated it later (#48)
* Validate when moving a file Signed-off-by: Uilian Ries <[email protected]> * Add support patch that rename files Signed-off-by: Uilian Ries <[email protected]> --------- Signed-off-by: Uilian Ries <[email protected]>
1 parent 283d7fa commit 32364f4

File tree

3 files changed

+192
-36
lines changed

3 files changed

+192
-36
lines changed

patch_ng.py

Lines changed: 99 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ def __init__(self):
274274

275275
self.type = None
276276
self.filemode = None
277+
self.mode = None
277278

278279
def __iter__(self):
279280
return iter(self.hunks)
@@ -378,6 +379,7 @@ def lineno(self):
378379
header = []
379380
srcname = None
380381
tgtname = None
382+
rename = False
381383

382384
# start of main cycle
383385
# each parsing block already has line available in fe.line
@@ -388,19 +390,26 @@ def lineno(self):
388390
# -- line fetched at the start of this cycle
389391
if hunkparsed:
390392
hunkparsed = False
393+
rename = False
391394
if re_hunk_start.match(fe.line):
392395
hunkhead = True
393396
elif fe.line.startswith(b"--- "):
394397
filenames = True
398+
elif fe.line.startswith(b"rename from "):
399+
filenames = True
395400
else:
396401
headscan = True
397402
# -- ------------------------------------
398403

399404
# read out header
400405
if headscan:
401-
while not fe.is_empty and not fe.line.startswith(b"--- "):
402-
header.append(fe.line)
403-
fe.next()
406+
while not fe.is_empty and not fe.line.startswith(b"--- ") and not fe.line.startswith(b"rename from "):
407+
header.append(fe.line)
408+
fe.next()
409+
if not fe.is_empty and fe.line.startswith(b"rename from "):
410+
rename = True
411+
hunkskip = True
412+
hunkbody = False
404413
if fe.is_empty:
405414
if p is None:
406415
debug("no patch data found") # error is shown later
@@ -495,7 +504,7 @@ def lineno(self):
495504
# switch to hunkhead state
496505
hunkskip = False
497506
hunkhead = True
498-
elif line.startswith(b"--- "):
507+
elif line.startswith(b"--- ") or line.startswith(b"rename from "):
499508
# switch to filenames state
500509
hunkskip = False
501510
filenames = True
@@ -538,6 +547,50 @@ def lineno(self):
538547
# switch back to headscan state
539548
filenames = False
540549
headscan = True
550+
elif rename:
551+
if line.startswith(b"rename from "):
552+
re_rename_from = br"^rename from (.+)"
553+
match = re.match(re_rename_from, line)
554+
if match:
555+
srcname = match.group(1).strip()
556+
else:
557+
warning("skipping invalid rename from at line %d" % (lineno+1))
558+
self.errors += 1
559+
# XXX p.header += line
560+
# switch back to headscan state
561+
filenames = False
562+
headscan = True
563+
if not fe.is_empty:
564+
fe.next()
565+
line = fe.line
566+
lineno = fe.lineno
567+
re_rename_to = br"^rename to (.+)"
568+
match = re.match(re_rename_to, line)
569+
if match:
570+
tgtname = match.group(1).strip()
571+
else:
572+
warning("skipping invalid rename from at line %d" % (lineno + 1))
573+
self.errors += 1
574+
# XXX p.header += line
575+
# switch back to headscan state
576+
filenames = False
577+
headscan = True
578+
if p: # for the first run p is None
579+
self.items.append(p)
580+
p = Patch()
581+
p.source = srcname
582+
srcname = None
583+
p.target = tgtname
584+
tgtname = None
585+
p.header = header
586+
header = []
587+
# switch to hunkhead state
588+
filenames = False
589+
hunkhead = False
590+
nexthunkno = 0
591+
p.hunkends = lineends.copy()
592+
hunkparsed = True
593+
continue
541594
elif not line.startswith(b"+++ "):
542595
if srcname != None:
543596
warning("skipping invalid patch with no target for %s" % srcname)
@@ -664,6 +717,7 @@ def lineno(self):
664717
self.items[idx].type = self._detect_type(p)
665718
if self.items[idx].type == GIT:
666719
self.items[idx].filemode = self._detect_file_mode(p)
720+
self.items[idx].mode = self._detect_patch_mode(p)
667721

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

723-
# Additional check: look for mode change patterns
724-
# "old mode XXXXX" followed by "new mode XXXXX"
725-
has_old_mode = False
726-
has_new_mode = False
727-
728-
for line in git_indicators:
729-
if re.match(b'old mode \\d+', line):
730-
has_old_mode = True
731-
elif re.match(b'new mode \\d+', line):
732-
has_new_mode = True
733-
734-
# If we have both old and new mode, it's definitely Git
735-
if has_old_mode and has_new_mode and DVCS:
736-
return GIT
737-
738-
# Check for similarity index (Git renames/copies)
739-
for line in git_indicators:
740-
if re.match(b'similarity index \\d+%', line):
741-
if DVCS:
742-
return GIT
743-
744-
# Check for rename patterns
745-
for line in git_indicators:
746-
if re.match(b'rename from .+', line) or re.match(b'rename to .+', line):
747-
if DVCS:
748-
return GIT
749-
750-
# Check for copy patterns
751-
for line in git_indicators:
752-
if re.match(b'copy from .+', line) or re.match(b'copy to .+', line):
753-
if DVCS:
754-
return GIT
777+
# Additional check: look for mode change patterns
778+
# "old mode XXXXX" followed by "new mode XXXXX"
779+
has_old_mode = False
780+
has_new_mode = False
781+
782+
for line in git_indicators:
783+
if re.match(b'old mode \\d+', line):
784+
has_old_mode = True
785+
elif re.match(b'new mode \\d+', line):
786+
has_new_mode = True
787+
788+
# If we have both old and new mode, it's definitely Git
789+
if has_old_mode and has_new_mode and DVCS:
790+
return GIT
791+
792+
# Check for similarity index (Git renames/copies)
793+
for line in git_indicators:
794+
if re.match(b'similarity index \\d+%', line):
795+
return GIT
796+
755797
# HG check
756798
#
757799
# - for plain HG format header is like "diff -r b2d9961ff1f5 filename"
@@ -809,6 +851,20 @@ def _apply_filemode(self, filepath, filemode):
809851
except Exception as error:
810852
warning(f"Could not set filemode {oct(filemode)} for {filepath}: {str(error)}")
811853

854+
def _detect_patch_mode(self, p):
855+
"""Detect patch mode - add, delete, rename, etc.
856+
"""
857+
if len(p.header) > 1:
858+
for idx in reversed(range(len(p.header))):
859+
if p.header[idx].startswith(b"diff --git"):
860+
break
861+
change_pattern = re.compile(rb"^diff --git a/([^ ]+) b/(.+)")
862+
match = change_pattern.match(p.header[idx])
863+
if match:
864+
if match.group(1) != match.group(2) and not p.hunks and p.source != b'/dev/null' and p.target != b'/dev/null':
865+
return 'rename'
866+
return None
867+
812868
def _normalize_filenames(self):
813869
""" sanitize filenames, normalizing paths, i.e.:
814870
1. strip a/ and b/ prefixes from GIT and HG style patches
@@ -1006,6 +1062,13 @@ def apply(self, strip=0, root=None, fuzz=False):
10061062
elif "dev/null" in target:
10071063
source = self.strip_path(source, root, strip)
10081064
safe_unlink(source)
1065+
elif item.mode == 'rename':
1066+
source = self.strip_path(source, root, strip)
1067+
target = self.strip_path(target, root, strip)
1068+
if exists(source):
1069+
os.makedirs(os.path.dirname(target), exist_ok=True)
1070+
shutil.move(source, target)
1071+
self._apply_filemode(target, item.filemode)
10091072
else:
10101073
items.append(item)
10111074
self.items = items

tests/movefile/0001-quote.patch

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
From 90ffe300b588f40f1409deb414498af8bc681072 Mon Sep 17 00:00:00 2001
2+
From: John Doe <[email protected]>
3+
Date: Fri, 3 Oct 2025 11:28:55 +0200
4+
Subject: [PATCH 1/3] Add file
5+
6+
Signed-off-by: John Doe <[email protected]>
7+
---
8+
quotes.txt | 1 +
9+
1 file changed, 1 insertion(+)
10+
create mode 100644 quotes.txt
11+
12+
diff --git a/quotes.txt b/quotes.txt
13+
new file mode 100644
14+
index 0000000000000000000000000000000000000000..6da41619625400f0b6c99beccc47c328f3967366
15+
--- /dev/null
16+
+++ b/quotes.txt
17+
@@ -0,0 +1 @@
18+
+in herbis, salus.
19+
--
20+
2.51.0
21+
22+
23+
From a055d1be31d11c149bfd9ca88d9554199d57d444 Mon Sep 17 00:00:00 2001
24+
From: John Doe <[email protected]>
25+
Date: Fri, 3 Oct 2025 11:29:27 +0200
26+
Subject: [PATCH 2/3] Move file
27+
28+
Signed-off-by: John Doe <[email protected]>
29+
---
30+
quotes.txt => quote/quotes.txt | 0
31+
1 file changed, 0 insertions(+), 0 deletions(-)
32+
rename quotes.txt => quote/quotes.txt (100%)
33+
34+
diff --git a/quotes.txt b/quote/quotes.txt
35+
similarity index 100%
36+
rename from quotes.txt
37+
rename to quote/quotes.txt
38+
--
39+
2.51.0
40+
41+
42+
From c275530ce83dc6eeeae671c2082660f1b6c16c4f Mon Sep 17 00:00:00 2001
43+
From: John Doe <[email protected]>
44+
Date: Fri, 3 Oct 2025 11:30:14 +0200
45+
Subject: [PATCH 3/3] Update quote
46+
47+
Signed-off-by: John Doe <[email protected]>
48+
---
49+
quote/quotes.txt | 2 +-
50+
1 file changed, 1 insertion(+), 1 deletion(-)
51+
52+
diff --git a/quote/quotes.txt b/quote/quotes.txt
53+
index 6da41619625400f0b6c99beccc47c328f3967366..928532e38b2a8e607814da280bb9e02862d2b4ea 100644
54+
--- a/quote/quotes.txt
55+
+++ b/quote/quotes.txt
56+
@@ -1 +1 @@
57+
-in herbis, salus.
58+
+dum tempus habemus, operemur bonum.
59+
--
60+
2.51.0
61+

tests/run_tests.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,38 @@ def test_apply_patch_only_file_mode(self):
557557
self.assertTrue(pto.apply())
558558
self.assertEqual(os.stat(join(self.tmpdir, 'quote.txt')).st_mode, 0o644 | stat.S_IFREG)
559559

560+
class TestMoveAndPatch(unittest.TestCase):
561+
562+
def setUp(self):
563+
self.save_cwd = os.getcwd()
564+
self.tmpdir = mkdtemp(prefix=self.__class__.__name__)
565+
shutil.copytree(join(TESTS, 'movefile'), join(self.tmpdir, 'movefile'))
566+
567+
def tearDown(self):
568+
os.chdir(self.save_cwd)
569+
remove_tree_force(self.tmpdir)
570+
571+
def test_add_move_and_update_file(self):
572+
"""When a patch file contains a file move (rename) and an update to the file,
573+
the patch should be applied correctly.
574+
575+
Reported by https://github.com/conan-io/python-patch-ng/issues/24
576+
"""
577+
578+
os.chdir(self.tmpdir)
579+
pto = patch_ng.fromfile(join(self.tmpdir, 'movefile', '0001-quote.patch'))
580+
self.assertEqual(len(pto), 3)
581+
self.assertEqual(pto.items[0].type, patch_ng.GIT)
582+
self.assertEqual(pto.items[1].type, patch_ng.GIT)
583+
self.assertEqual(pto.items[2].type, patch_ng.GIT)
584+
self.assertEqual(pto.items[1].mode, 'rename')
585+
self.assertTrue(pto.apply())
586+
self.assertFalse(os.path.exists(join(self.tmpdir, 'quotes.txt')))
587+
self.assertTrue(os.path.exists(join(self.tmpdir, 'quote', 'quotes.txt')))
588+
with open(join(self.tmpdir, 'quote', 'quotes.txt'), 'rb') as f:
589+
content = f.read()
590+
self.assertTrue(b'dum tempus habemus, operemur bonum' in content)
591+
560592
class TestHelpers(unittest.TestCase):
561593
# unittest setting
562594
longMessage = True

0 commit comments

Comments
 (0)