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
90 changes: 83 additions & 7 deletions patch_ng.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ def __init__(self):
self.header = []

self.type = None
self.filemode = None

def __iter__(self):
return iter(self.hunks)
Expand All @@ -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 = []
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand All @@ -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.:
Expand All @@ -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):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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':
Expand Down
21 changes: 21 additions & 0 deletions tests/filepermission/create755.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
From 39fdfb57a112a3b00cc352b45d17aba4f0f58005 Mon Sep 17 00:00:00 2001
From: John Doe <john.doe@mail.com>
Date: Wed, 1 Oct 2025 12:39:25 +0200
Subject: [PATCH] Add quotes.txt

Signed-off-by: John Doe <john.doe@mail.com>
---
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

23 changes: 23 additions & 0 deletions tests/filepermission/update644.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
From 5f6ac26ddfe6ad80f76a1ec982abe95c11c7e947 Mon Sep 17 00:00:00 2001
From: John Doe <john.doe@mail.com>
Date: Wed, 1 Oct 2025 15:56:37 +0200
Subject: [PATCH] Read only

Signed-off-by: John Doe <john.doe@mail.com>
---
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

34 changes: 34 additions & 0 deletions tests/run_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down