From f974d183d10df6a0fa26243b7328dc0e33823171 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Sat, 18 Jul 2026 20:14:43 -0300 Subject: [PATCH] Expose git file mode / permissions (#125) --- README.rst | 5 +++ tests/samples/git_symlink.diff | 8 +++++ tests/test_parser.py | 58 ++++++++++++++++++++++++++++++++++ unidiff/constants.py | 18 +++++++++-- unidiff/patch.py | 47 ++++++++++++++++++++++++++- 5 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 tests/samples/git_symlink.diff diff --git a/README.rst b/README.rst index 425cc5b..604b24f 100644 --- a/README.rst +++ b/README.rst @@ -62,6 +62,11 @@ you can get stats (if it is a new, removed or modified file; the source/target lines; etc), besides having access to each hunk (also like a list) and its respective info. +For git diffs, the file mode is exposed through the :code:`source_mode` and +:code:`target_mode` attributes (e.g. :code:`'100644'`, :code:`'100755'`, +:code:`'120000'`), or :code:`None` when unknown. The :code:`is_symlink` +property is a shortcut to detect symbolic links (mode :code:`120000`). + At any point you can get the string representation of the current object, and that will return the unified diff data of it. diff --git a/tests/samples/git_symlink.diff b/tests/samples/git_symlink.diff new file mode 100644 index 0000000..118fd91 --- /dev/null +++ b/tests/samples/git_symlink.diff @@ -0,0 +1,8 @@ +diff --git a/bin/check b/bin/check +new file mode 120000 +index 000000000..f35a09670 +--- /dev/null ++++ b/bin/check +@@ -0,0 +1 @@ ++.pyenv-wrapper +\ No newline at end of file diff --git a/tests/test_parser.py b/tests/test_parser.py index 97d83fd..a5c0c47 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -460,6 +460,64 @@ def test_deleted_file(self): self.assertEqual(res[0].target_file, '/dev/null') self.assertTrue(res[0].is_removed_file) + def test_added_symlink_file_mode(self): + # issue #125: expose the file mode; a new symlink has mode 120000 + filename = os.path.join(self.samples_dir, 'samples/git_symlink.diff') + with open(filename) as f: + res = PatchSet(f) + + self.assertEqual(len(res), 1) + self.assertTrue(res[0].is_added_file) + self.assertIsNone(res[0].source_mode) + self.assertEqual(res[0].target_mode, '120000') + self.assertTrue(res[0].is_symlink) + + def test_new_file_mode(self): + # issue #125: a regular new file carries `new file mode 100644` + filename = os.path.join(self.samples_dir, 'samples/git_quoted_filename.diff') + with open(filename) as f: + res = PatchSet(f) + + self.assertEqual(res[0].target_mode, '100644') + self.assertFalse(res[0].is_symlink) + + def test_mode_change_file(self): + # issue #125: `old mode` / `new mode` expose a chmod + diff = ( + 'diff --git a/server.py b/bin/server.py\n' + 'old mode 100644\n' + 'new mode 100755\n' + 'similarity index 100%\n' + 'rename from server.py\n' + 'rename to bin/server.py\n' + ) + res = PatchSet(diff) + + self.assertEqual(res[0].source_mode, '100644') + self.assertEqual(res[0].target_mode, '100755') + self.assertFalse(res[0].is_symlink) + self.assertTrue(res[0].is_rename) + + def test_index_line_mode(self): + # issue #125: an unchanged mode on the index line applies to both sides + diff = ( + 'diff --git a/info.sh b/info.sh\n' + 'index ddbe53c40..6c84b8acf 100755\n' + '--- a/info.sh\n' + '+++ b/info.sh\n' + '@@ -1,2 +1,2 @@\n' + ' a\n' + '-b\n' + '+c\n' + ) + res = PatchSet(diff) + + self.assertEqual(res[0].source_mode, '100755') + self.assertEqual(res[0].target_mode, '100755') + self.assertFalse(res[0].is_symlink) + # the index line is preserved so the diff still round-trips + self.assertEqual(str(res), diff) + def test_diff_lines_linenos(self): with open(self.sample_file, 'rb') as diff_file: res = PatchSet(diff_file, encoding='utf-8') diff --git a/unidiff/constants.py b/unidiff/constants.py index e03bc0e..f3429fc 100644 --- a/unidiff/constants.py +++ b/unidiff/constants.py @@ -43,11 +43,19 @@ RE_DIFF_GIT_HEADER_NO_PREFIX = re.compile( r'^diff --git (?P[^\t\n]+) (?P[^\t\n]+)') -# check diff git new file marker `deleted file mode 100644` -RE_DIFF_GIT_DELETED_FILE = re.compile(r'^deleted file mode \d+$') +# check diff git deleted file marker `deleted file mode 100644` +RE_DIFF_GIT_DELETED_FILE = re.compile(r'^deleted file mode (?P\d+)$') # check diff git new file marker `new file mode 100644` -RE_DIFF_GIT_NEW_FILE = re.compile(r'^new file mode \d+$') +RE_DIFF_GIT_NEW_FILE = re.compile(r'^new file mode (?P\d+)$') + +# check diff git file mode change markers `old mode 100644` / `new mode 100755` +RE_DIFF_GIT_OLD_MODE = re.compile(r'^old mode (?P\d+)$') +RE_DIFF_GIT_NEW_MODE = re.compile(r'^new mode (?P\d+)$') + +# check diff git index line with a trailing mode `index abc..def 100644` +RE_DIFF_GIT_INDEX = re.compile( + r'^index [0-9a-f]+\.\.[0-9a-f]+ (?P\d+)$') # @@ (source offset, length) (target offset, length) @@ (section header) @@ -74,6 +82,10 @@ DEFAULT_ENCODING = 'UTF-8' DEV_NULL = '/dev/null' + +# git file mode for a symbolic link +SYMLINK_FILE_MODE = '120000' + LINE_TYPE_ADDED = '+' LINE_TYPE_REMOVED = '-' LINE_TYPE_CONTEXT = ' ' diff --git a/unidiff/patch.py b/unidiff/patch.py index a8443dd..0be59b5 100644 --- a/unidiff/patch.py +++ b/unidiff/patch.py @@ -42,7 +42,10 @@ RE_DIFF_GIT_HEADER, RE_DIFF_GIT_HEADER_URI_LIKE, RE_DIFF_GIT_HEADER_NO_PREFIX, + RE_DIFF_GIT_INDEX, RE_DIFF_GIT_NEW_FILE, + RE_DIFF_GIT_NEW_MODE, + RE_DIFF_GIT_OLD_MODE, RE_HUNK_BODY_LINE, RE_HUNK_EMPTY_BODY_LINE, RE_HUNK_HEADER, @@ -50,6 +53,7 @@ RE_TARGET_FILENAME, RE_NO_NEWLINE_MARKER, RE_BINARY_DIFF, + SYMLINK_FILE_MODE, ) from unidiff.errors import UnidiffParseError @@ -202,7 +206,9 @@ def __init__(self, patch_info: Optional[PatchInfo] = None, source: str = '', target: str = '', source_timestamp: Optional[str] = None, target_timestamp: Optional[str] = None, - is_binary_file: bool = False) -> None: + is_binary_file: bool = False, + source_mode: Optional[str] = None, + target_mode: Optional[str] = None) -> None: super(PatchedFile, self).__init__() self.patch_info = patch_info self.source_file = source @@ -210,6 +216,9 @@ def __init__(self, patch_info: Optional[PatchInfo] = None, self.target_file = target self.target_timestamp = target_timestamp self.is_binary_file = is_binary_file + # git file modes (e.g. '100644', '100755', '120000'); None if unknown + self.source_mode = source_mode + self.target_mode = target_mode def __repr__(self) -> str: return "" % self.path @@ -402,6 +411,14 @@ def is_modified_file(self) -> bool: """Return True if this patch modifies the file.""" return not (self.is_added_file or self.is_removed_file) + @property + def is_symlink(self) -> bool: + """Return True if the patched file is a symbolic link.""" + # prefer the target mode; fall back to the source mode (e.g. a + # removed symlink only carries the old mode) + mode = self.target_mode if self.target_mode is not None else self.source_mode + return mode == SYMLINK_FILE_MODE + class PatchSet(list[PatchedFile]): """A list of PatchedFiles.""" @@ -459,6 +476,7 @@ def _parse(self, diff: Iterable, encoding: Optional[str], if current_file is None or patch_info is None: raise UnidiffParseError('Unexpected new file found: %s' % line) current_file.source_file = DEV_NULL + current_file.target_mode = is_diff_git_new_file.group('mode') patch_info.append(line) continue @@ -468,9 +486,36 @@ def _parse(self, diff: Iterable, encoding: Optional[str], if current_file is None or patch_info is None: raise UnidiffParseError('Unexpected deleted file found: %s' % line) current_file.target_file = DEV_NULL + current_file.source_mode = is_diff_git_deleted_file.group('mode') patch_info.append(line) continue + # check for git file mode change / index lines (extract the mode + # but keep the line as patch info so the diff still round-trips) + if current_file is not None and patch_info is not None: + is_diff_git_old_mode = RE_DIFF_GIT_OLD_MODE.match(line) + if is_diff_git_old_mode: + current_file.source_mode = is_diff_git_old_mode.group('mode') + patch_info.append(line) + continue + + is_diff_git_new_mode = RE_DIFF_GIT_NEW_MODE.match(line) + if is_diff_git_new_mode: + current_file.target_mode = is_diff_git_new_mode.group('mode') + patch_info.append(line) + continue + + is_diff_git_index = RE_DIFF_GIT_INDEX.match(line) + if is_diff_git_index: + # an unchanged index mode applies to both source and target + mode = is_diff_git_index.group('mode') + if current_file.source_mode is None: + current_file.source_mode = mode + if current_file.target_mode is None: + current_file.target_mode = mode + patch_info.append(line) + continue + # check for source file header is_source_filename = RE_SOURCE_FILENAME.match(line) if is_source_filename: