diff --git a/README.md b/README.md index 1db7de3..48fce30 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ True ('there was a fix\n', 2) ``` -## Git file modes, symlinks and line numbers +## Git file modes, symlinks, submodules and line numbers For git diffs, the file mode is exposed through the `source_mode` and `target_mode` attributes (e.g. `'100644'`, `'100755'`, `'120000'`), or `None` @@ -178,6 +178,21 @@ True True ``` +Likewise, `is_submodule` detects git submodule (gitlink) entries, mode +`160000`: + +```python +>>> from unidiff import PatchSet +>>> patch = PatchSet.from_filename('tests/samples/git_submodule.diff') +>>> patched_file = patch[0] +>>> patched_file.path +'tests/diffs/submodule' +>>> patched_file.target_mode +'160000' +>>> patched_file.is_submodule +True +``` + Each `PatchedFile` also exposes `diff_line_no`, the 1-based line number in the diff where its entry starts. This is useful to locate files that have no hunks, such as binary changes: diff --git a/tests/samples/git_submodule.diff b/tests/samples/git_submodule.diff new file mode 100644 index 0000000..b993e1b --- /dev/null +++ b/tests/samples/git_submodule.diff @@ -0,0 +1,7 @@ +diff --git a/tests/diffs/submodule b/tests/diffs/submodule +new file mode 160000 +index 0000000..b399108 +--- /dev/null ++++ b/tests/diffs/submodule +@@ -0,0 +1 @@ ++Subproject commit b399108e316b17e4e6eed616d112c33796289533 diff --git a/tests/test_parser.py b/tests/test_parser.py index 432d9fb..506d2db 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -615,6 +615,57 @@ def test_added_symlink_file_mode(self): self.assertEqual(res[0].target_mode, '120000') self.assertTrue(res[0].is_symlink) + def test_added_submodule_file_mode(self): + # issue #147: a new git submodule (gitlink) has mode 160000 + filename = os.path.join(self.samples_dir, 'samples/git_submodule.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, '160000') + self.assertTrue(res[0].is_submodule) + self.assertFalse(res[0].is_symlink) + + def test_deleted_submodule_file_mode(self): + # issue #147: a removed submodule only carries the old mode + diff = ( + 'diff --git a/submodule b/submodule\n' + 'deleted file mode 160000\n' + 'index b399108..0000000\n' + '--- a/submodule\n' + '+++ /dev/null\n' + '@@ -1 +0,0 @@\n' + '-Subproject commit b399108e316b17e4e6eed616d112c33796289533\n' + ) + res = PatchSet(diff) + + self.assertTrue(res[0].is_removed_file) + self.assertEqual(res[0].source_mode, '160000') + self.assertIsNone(res[0].target_mode) + self.assertTrue(res[0].is_submodule) + + def test_updated_submodule_file_mode(self): + # issue #147: a submodule pointer update carries the mode on the + # index line + diff = ( + 'diff --git a/submodule b/submodule\n' + 'index b399108..c0ffee1 160000\n' + '--- a/submodule\n' + '+++ b/submodule\n' + '@@ -1 +1 @@\n' + '-Subproject commit b399108e316b17e4e6eed616d112c33796289533\n' + '+Subproject commit c0ffee1e316b17e4e6eed616d112c33796289533\n' + ) + res = PatchSet(diff) + + self.assertEqual(res[0].source_mode, '160000') + self.assertEqual(res[0].target_mode, '160000') + self.assertTrue(res[0].is_submodule) + self.assertFalse(res[0].is_symlink) + self.assertTrue(res[0].is_modified_file) + 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') diff --git a/unidiff/constants.py b/unidiff/constants.py index c85501e..8d214da 100644 --- a/unidiff/constants.py +++ b/unidiff/constants.py @@ -91,6 +91,9 @@ # git file mode for a symbolic link SYMLINK_FILE_MODE = '120000' +# git file mode for a submodule (gitlink) entry +SUBMODULE_FILE_MODE = '160000' + LINE_TYPE_ADDED = '+' LINE_TYPE_REMOVED = '-' LINE_TYPE_CONTEXT = ' ' diff --git a/unidiff/patch.py b/unidiff/patch.py index 3cb87f0..e549751 100644 --- a/unidiff/patch.py +++ b/unidiff/patch.py @@ -54,6 +54,7 @@ RE_NO_NEWLINE_MARKER, RE_BINARY_DIFF, RE_PATCH_FILE_PREFIX, + SUBMODULE_FILE_MODE, SYMLINK_FILE_MODE, ) from unidiff.errors import UnidiffParseError @@ -421,12 +422,21 @@ def is_modified_file(self) -> bool: 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.""" + def _file_mode(self) -> Optional[str]: + """Return the relevant git file mode, if known.""" # 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 + return self.target_mode if self.target_mode is not None else self.source_mode + + @property + def is_symlink(self) -> bool: + """Return True if the patched file is a symbolic link.""" + return self._file_mode == SYMLINK_FILE_MODE + + @property + def is_submodule(self) -> bool: + """Return True if the patched file is a git submodule (gitlink).""" + return self._file_mode == SUBMODULE_FILE_MODE class PatchSet(list[PatchedFile]):