@@ -273,6 +273,7 @@ def __init__(self):
273273 self .header = []
274274
275275 self .type = None
276+ self .filemode = None
276277
277278 def __iter__ (self ):
278279 return iter (self .hunks )
@@ -290,6 +291,7 @@ def __init__(self, stream=None):
290291 self .name = None
291292 # patch set type - one of constants
292293 self .type = None
294+ self .filemode = None
293295
294296 # list of Patch objects
295297 self .items = []
@@ -660,6 +662,8 @@ def lineno(self):
660662 # ---- detect patch and patchset types ----
661663 for idx , p in enumerate (self .items ):
662664 self .items [idx ].type = self ._detect_type (p )
665+ if self .items [idx ].type == GIT :
666+ self .items [idx ].filemode = self ._detect_file_mode (p )
663667
664668 types = set ([p .type for p in self .items ])
665669 if len (types ) > 1 :
@@ -706,13 +710,48 @@ def _detect_type(self, p):
706710 if p .header [idx ].startswith (b"diff --git" ):
707711 break
708712 if p .header [idx ].startswith (b'diff --git a/' ):
709- if (idx + 1 < len (p .header )
710- and re .match (
711- b'(?:index \\ w{4,40}\\ .\\ .\\ w{4,40}(?: \\ d{6})?|new file mode \\ d+|deleted file mode \\ d+)' ,
712- p .header [idx + 1 ])):
713- if DVCS :
714- return GIT
715-
713+ git_indicators = []
714+ for i in range (idx + 1 , len (p .header )):
715+ git_indicators .append (p .header [i ])
716+ for line in git_indicators :
717+ if re .match (
718+ b'(?:index \\ w{4,40}\\ .\\ .\\ w{4,40}(?: \\ d{6})?|new file mode \\ d+|deleted file mode \\ d+|old mode \\ d+|new mode \\ d+)' ,
719+ line ):
720+ if DVCS :
721+ return GIT
722+
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
716755 # HG check
717756 #
718757 # - for plain HG format header is like "diff -r b2d9961ff1f5 filename"
@@ -735,6 +774,40 @@ def _detect_type(self, p):
735774
736775 return PLAIN
737776
777+ def _detect_file_mode (self , p ):
778+ """ Detect the file mode listed in the patch header
779+
780+ INFO: Only working with Git-style patches
781+ """
782+ if len (p .header ) > 1 :
783+ for idx in reversed (range (len (p .header ))):
784+ if p .header [idx ].startswith (b"diff --git" ):
785+ break
786+ if p .header [idx ].startswith (b'diff --git a/' ):
787+ if idx + 1 < len (p .header ):
788+ # new file (e.g)
789+ # diff --git a/quote.txt b/quote.txt
790+ # new file mode 100755
791+ match = re .match (b'new file mode (\\ d+)' , p .header [idx + 1 ])
792+ if match :
793+ return int (match .group (1 ), 8 )
794+ # changed mode (e.g)
795+ # diff --git a/quote.txt b/quote.txt
796+ # old mode 100755
797+ # new mode 100644
798+ if idx + 2 < len (p .header ):
799+ match = re .match (b'new mode (\\ d+)' , p .header [idx + 2 ])
800+ if match :
801+ return int (match .group (1 ), 8 )
802+ return None
803+
804+ def _apply_filemode (self , filepath , filemode ):
805+ if filemode is not None and stat .S_ISREG (filemode ):
806+ try :
807+ only_file_permissions = filemode & 0o777
808+ os .chmod (filepath , only_file_permissions )
809+ except Exception as error :
810+ warning (f"Could not set filemode { oct (filemode )} for { filepath } : { str (error )} " )
738811
739812 def _normalize_filenames (self ):
740813 """ sanitize filenames, normalizing paths, i.e.:
@@ -752,6 +825,7 @@ def _normalize_filenames(self):
752825 for i ,p in enumerate (self .items ):
753826 if debugmode :
754827 debug (" patch type = %s" % p .type )
828+ debug (" filemode = %s" % p .filemode )
755829 debug (" source = %s" % p .source )
756830 debug (" target = %s" % p .target )
757831 if p .type in (HG , GIT ):
@@ -928,6 +1002,7 @@ def apply(self, strip=0, root=None, fuzz=False):
9281002 hunks = [s .decode ("utf-8" ) for s in item .hunks [0 ].text ]
9291003 new_file = "" .join (hunk [1 :] for hunk in hunks )
9301004 save (target , new_file )
1005+ self ._apply_filemode (target , item .filemode )
9311006 elif "dev/null" in target :
9321007 source = self .strip_path (source , root , strip )
9331008 safe_unlink (source )
@@ -1059,6 +1134,7 @@ def apply(self, strip=0, root=None, fuzz=False):
10591134 else :
10601135 shutil .move (filenamen , backupname )
10611136 if self .write_hunks (backupname if filenameo == filenamen else filenameo , filenamen , p .hunks ):
1137+ self ._apply_filemode (filenamen , p .filemode )
10621138 info ("successfully patched %d/%d:\t %s" % (i + 1 , total , filenamen ))
10631139 safe_unlink (backupname )
10641140 if new == b'/dev/null' :
0 commit comments