diff --git a/patch_ng.py b/patch_ng.py index b46f532..2fc0208 100755 --- a/patch_ng.py +++ b/patch_ng.py @@ -273,6 +273,7 @@ def __init__(self): self.header = [] self.type = None + self.filemode = None def __iter__(self): return iter(self.hunks) @@ -290,6 +291,7 @@ def __init__(self, stream=None): self.name = None # patch set type - one of constants self.type = None + self.filemode = None # list of Patch objects self.items = [] @@ -660,6 +662,8 @@ def lineno(self): # ---- detect patch and patchset types ---- for idx, p in enumerate(self.items): self.items[idx].type = self._detect_type(p) + if self.items[idx].type == GIT: + self.items[idx].filemode = self._detect_file_mode(p) types = set([p.type for p in self.items]) if len(types) > 1: @@ -706,13 +710,48 @@ def _detect_type(self, p): if p.header[idx].startswith(b"diff --git"): break if p.header[idx].startswith(b'diff --git a/'): - if (idx+1 < len(p.header) - and re.match( - b'(?:index \\w{4,40}\\.\\.\\w{4,40}(?: \\d{6})?|new file mode \\d+|deleted file mode \\d+)', - p.header[idx+1])): - if DVCS: - return GIT - + git_indicators = [] + for i in range(idx + 1, len(p.header)): + git_indicators.append(p.header[i]) + for line in git_indicators: + if re.match( + b'(?:index \\w{4,40}\\.\\.\\w{4,40}(?: \\d{6})?|new file mode \\d+|deleted file mode \\d+|old mode \\d+|new mode \\d+)', + 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): + 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 # HG check # # - for plain HG format header is like "diff -r b2d9961ff1f5 filename" @@ -735,6 +774,40 @@ def _detect_type(self, p): return PLAIN + def _detect_file_mode(self, p): + """ Detect the file mode listed in the patch header + + INFO: Only working with Git-style patches + """ + if len(p.header) > 1: + for idx in reversed(range(len(p.header))): + if p.header[idx].startswith(b"diff --git"): + break + if p.header[idx].startswith(b'diff --git a/'): + if idx + 1 < len(p.header): + # new file (e.g) + # diff --git a/quote.txt b/quote.txt + # new file mode 100755 + match = re.match(b'new file mode (\\d+)', p.header[idx + 1]) + if match: + return int(match.group(1), 8) + # changed mode (e.g) + # diff --git a/quote.txt b/quote.txt + # old mode 100755 + # new mode 100644 + if idx + 2 < len(p.header): + match = re.match(b'new mode (\\d+)', p.header[idx + 2]) + if match: + return int(match.group(1), 8) + return None + + def _apply_filemode(self, filepath, filemode): + if filemode is not None and stat.S_ISREG(filemode): + try: + only_file_permissions = filemode & 0o777 + os.chmod(filepath, only_file_permissions) + except Exception as error: + warning(f"Could not set filemode {oct(filemode)} for {filepath}: {str(error)}") def _normalize_filenames(self): """ sanitize filenames, normalizing paths, i.e.: @@ -752,6 +825,7 @@ def _normalize_filenames(self): for i,p in enumerate(self.items): if debugmode: debug(" patch type = %s" % p.type) + debug(" filemode = %s" % p.filemode) debug(" source = %s" % p.source) debug(" target = %s" % p.target) if p.type in (HG, GIT): @@ -928,6 +1002,7 @@ def apply(self, strip=0, root=None, fuzz=False): hunks = [s.decode("utf-8") for s in item.hunks[0].text] new_file = "".join(hunk[1:] for hunk in hunks) save(target, new_file) + self._apply_filemode(target, item.filemode) elif "dev/null" in target: source = self.strip_path(source, root, strip) safe_unlink(source) @@ -1059,6 +1134,7 @@ def apply(self, strip=0, root=None, fuzz=False): else: shutil.move(filenamen, backupname) if self.write_hunks(backupname if filenameo == filenamen else filenameo, filenamen, p.hunks): + self._apply_filemode(filenamen, p.filemode) info("successfully patched %d/%d:\t %s" % (i+1, total, filenamen)) safe_unlink(backupname) if new == b'/dev/null': diff --git a/tests/filepermission/create755.patch b/tests/filepermission/create755.patch new file mode 100644 index 0000000..398ff4b --- /dev/null +++ b/tests/filepermission/create755.patch @@ -0,0 +1,21 @@ +From 39fdfb57a112a3b00cc352b45d17aba4f0f58005 Mon Sep 17 00:00:00 2001 +From: John Doe +Date: Wed, 1 Oct 2025 12:39:25 +0200 +Subject: [PATCH] Add quotes.txt + +Signed-off-by: John Doe +--- + quote.txt | 1 + + 1 file changed, 1 insertion(+) + create mode 100755 quote.txt + +diff --git a/quote.txt b/quote.txt +new file mode 100755 +index 0000000000000000000000000000000000000000..cbfafe956ec35385f5b728daa390603ff71f1933 +--- /dev/null ++++ b/quote.txt +@@ -0,0 +1 @@ ++post malam segetem, serendum est. +-- +2.51.0 + diff --git a/tests/filepermission/update644.patch b/tests/filepermission/update644.patch new file mode 100644 index 0000000..49d83c6 --- /dev/null +++ b/tests/filepermission/update644.patch @@ -0,0 +1,23 @@ +From 5f6ac26ddfe6ad80f76a1ec982abe95c11c7e947 Mon Sep 17 00:00:00 2001 +From: John Doe +Date: Wed, 1 Oct 2025 15:56:37 +0200 +Subject: [PATCH] Read only + +Signed-off-by: John Doe +--- + quote.txt | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + mode change 100755 => 100644 quote.txt + +diff --git a/quote.txt b/quote.txt +old mode 100755 +new mode 100644 +index cbfafe956ec35385f5b728daa390603ff71f1933..155913b0aafa16e4b37278209e772e946cecb393 +--- a/quote.txt ++++ b/quote.txt +@@ -1 +1 @@ +-post malam segetem, serendum est. ++praestat cautela quam medela. +-- +2.51.0 + diff --git a/tests/run_tests.py b/tests/run_tests.py index 4145d29..bcdc629 100755 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -37,6 +37,7 @@ import shutil import unittest import stat +import platform from os import listdir, chmod from os.path import abspath, dirname, exists, join, isdir, isfile from tempfile import mkdtemp @@ -487,6 +488,39 @@ def test_apply_huge_patch(self): self.assertTrue(pto.apply(root=self.tmpdir)) +class TestPreserveFilePermissions(unittest.TestCase): + + def setUp(self): + self.save_cwd = os.getcwd() + self.tmpdir = mkdtemp(prefix=self.__class__.__name__) + shutil.copytree(join(TESTS, 'filepermission'), join(self.tmpdir, 'filepermission')) + + def tearDown(self): + os.chdir(self.save_cwd) + remove_tree_force(self.tmpdir) + + @unittest.skipIf(platform.system() == "Windows", "File permission modes are not supported on Windows") + def test_handle_full_index_patch_format(self): + """Test that when file permission mode is listed in the patch, + the same should be applied to the target file after patching. + """ + + os.chdir(self.tmpdir) + pto = patch_ng.fromfile(join(self.tmpdir, 'filepermission', 'create755.patch')) + self.assertEqual(len(pto), 1) + self.assertEqual(pto.items[0].type, patch_ng.GIT) + self.assertEqual(pto.items[0].filemode, 0o100755) + self.assertTrue(pto.apply()) + self.assertTrue(os.path.exists(join(self.tmpdir, 'quote.txt'))) + self.assertEqual(os.stat(join(self.tmpdir, 'quote.txt')).st_mode, 0o755 | stat.S_IFREG) + + pto = patch_ng.fromfile(join(self.tmpdir, 'filepermission', 'update644.patch')) + self.assertEqual(len(pto), 1) + self.assertEqual(pto.items[0].type, patch_ng.GIT) + self.assertEqual(pto.items[0].filemode, 0o100644) + self.assertTrue(pto.apply()) + self.assertEqual(os.stat(join(self.tmpdir, 'quote.txt')).st_mode, 0o644 | stat.S_IFREG) + class TestHelpers(unittest.TestCase): # unittest setting longMessage = True