From c904f6ebc2bfbd5de7918d28689c8e191a575f38 Mon Sep 17 00:00:00 2001 From: Uilian Ries Date: Fri, 3 Oct 2025 11:43:58 +0200 Subject: [PATCH 1/2] Validate when moving a file Signed-off-by: Uilian Ries --- tests/movefile/0001-quote.patch | 61 +++++++++++++++++++++++++++++++++ tests/run_tests.py | 27 +++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 tests/movefile/0001-quote.patch diff --git a/tests/movefile/0001-quote.patch b/tests/movefile/0001-quote.patch new file mode 100644 index 0000000..277c4f9 --- /dev/null +++ b/tests/movefile/0001-quote.patch @@ -0,0 +1,61 @@ +From 90ffe300b588f40f1409deb414498af8bc681072 Mon Sep 17 00:00:00 2001 +From: John Doe +Date: Fri, 3 Oct 2025 11:28:55 +0200 +Subject: [PATCH 1/3] Add file + +Signed-off-by: John Doe +--- + 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 +Date: Fri, 3 Oct 2025 11:29:27 +0200 +Subject: [PATCH 2/3] Move file + +Signed-off-by: John Doe +--- + 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 +Date: Fri, 3 Oct 2025 11:30:14 +0200 +Subject: [PATCH 3/3] Update quote + +Signed-off-by: John Doe +--- + 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 + diff --git a/tests/run_tests.py b/tests/run_tests.py index f8f019b..0f67f66 100755 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -557,6 +557,33 @@ 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), 2) + self.assertEqual(pto.items[0].type, patch_ng.GIT) + self.assertEqual(pto.items[1].type, patch_ng.GIT) + self.assertTrue(pto.apply()) + self.assertTrue(os.path.exists(join(self.tmpdir, 'quote', 'quotes.txt'))) + + class TestHelpers(unittest.TestCase): # unittest setting longMessage = True From c4128588617f09b996b6e056050e055a4e41e0e4 Mon Sep 17 00:00:00 2001 From: Uilian Ries Date: Fri, 3 Oct 2025 17:29:58 +0200 Subject: [PATCH 2/2] Add support patch that rename files Signed-off-by: Uilian Ries --- patch_ng.py | 135 +++++++++++++++++++++++++++++++++------------ tests/run_tests.py | 9 ++- 2 files changed, 106 insertions(+), 38 deletions(-) diff --git a/patch_ng.py b/patch_ng.py index 2fc0208..7312ba1 100755 --- a/patch_ng.py +++ b/patch_ng.py @@ -274,6 +274,7 @@ def __init__(self): self.type = None self.filemode = None + self.mode = None def __iter__(self): return iter(self.hunks) @@ -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 @@ -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 @@ -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 @@ -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) @@ -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: @@ -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" @@ -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 @@ -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 diff --git a/tests/run_tests.py b/tests/run_tests.py index 0f67f66..38f5e3c 100755 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -577,12 +577,17 @@ def test_add_move_and_update_file(self): os.chdir(self.tmpdir) pto = patch_ng.fromfile(join(self.tmpdir, 'movefile', '0001-quote.patch')) - self.assertEqual(len(pto), 2) + 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