diff --git a/pathspec/patterns/gitignore/spec.py b/pathspec/patterns/gitignore/spec.py index 5f5644b..c6d5133 100644 --- a/pathspec/patterns/gitignore/spec.py +++ b/pathspec/patterns/gitignore/spec.py @@ -39,6 +39,14 @@ This regular expression matches the optional directory marker and sub-path. """ +_MATCH_ALL = f'^(?:.+/)?[^/]+{_DIR_MARK_OPT}' +""" +This regular expression matches every path. It is the expansion of the patterns +"*" and "**" (i.e., "**/{any name}"), and it has to capture the directory marker +like any other pattern so that :class:`.GitIgnoreSpec` can tell a directory +match from a file match. +""" + class GitIgnoreSpecPattern(_GitIgnoreBasePattern): """ @@ -121,7 +129,7 @@ def __normalize_segments( return (None, _DIR_MARK_CG) else: # The pattern "**" will match every path. Special case this pattern. - return (None, '.') + return (None, _MATCH_ALL) elif ( seg_count == 2 @@ -130,7 +138,7 @@ def __normalize_segments( ): # The pattern "*" will be normalized to "**/*" and will match every # path. Special case this pattern for efficiency. - return (None, '.') + return (None, _MATCH_ALL) elif ( seg_count == 3 diff --git a/tests/test_04_gitignore_spec.py b/tests/test_04_gitignore_spec.py index 4b525f0..f6f7ec8 100644 --- a/tests/test_04_gitignore_spec.py +++ b/tests/test_04_gitignore_spec.py @@ -14,8 +14,10 @@ _BYTES_ENCODING) from pathspec.patterns.gitignore.spec import ( GitIgnoreSpecPattern, + _DIR_MARK, _DIR_MARK_CG, - _DIR_MARK_OPT) + _DIR_MARK_OPT, + _MATCH_ALL) from pathspec.patterns.gitwildmatch import ( GitWildMatchPattern) from pathspec.util import ( @@ -275,7 +277,7 @@ def test_03_only_double_asterisk(self): """ regex, include = GitIgnoreSpecPattern.pattern_to_regex('**') self.assertTrue(include) - self.assertEqual(regex, '.') + self.assertEqual(regex, _MATCH_ALL) pattern = GitIgnoreSpecPattern(re.compile(regex), include) results = set(filter(pattern.match_file, [ @@ -332,7 +334,7 @@ def test_03_duplicate_leading_double_asterisk_edge_case(self): """ regex, include = GitIgnoreSpecPattern.pattern_to_regex('**') self.assertTrue(include) - self.assertEqual(regex, '.') + self.assertEqual(regex, _MATCH_ALL) equiv_regex, include = GitIgnoreSpecPattern.pattern_to_regex('**/**') self.assertTrue(include) @@ -753,7 +755,23 @@ def test_12_asterisk_1_regex(self): """ regex, include = GitIgnoreSpecPattern.pattern_to_regex('*') self.assertTrue(include) - self.assertEqual(regex, '.') + self.assertEqual(regex, _MATCH_ALL) + + def test_12_asterisk_1b_regex_marks_directories(self): + """ + Test that the relative asterisk path pattern captures the directory marker. + + Without the marker, "*" outranks a later directory-only pattern (e.g. + "!*/") and :class:`.GitIgnoreSpec` reports a directory as ignored where Git + does not. + """ + regex, include = GitIgnoreSpecPattern.pattern_to_regex('*') + self.assertTrue(include) + + compiled = re.compile(regex) + self.assertIsNotNone(compiled.search('dirA/').group(_DIR_MARK)) + self.assertIsNone(compiled.search('fileA').group(_DIR_MARK)) + self.assertIsNone(compiled.search('dirA/fileB').group(_DIR_MARK)) def test_12_asterisk_2_regex_equivalent(self): """ diff --git a/tests/test_06_gitignore.py b/tests/test_06_gitignore.py index 9d00c90..1712e5f 100644 --- a/tests/test_06_gitignore.py +++ b/tests/test_06_gitignore.py @@ -205,6 +205,47 @@ def test_02_dir_exclusions(self): 'test2/b.bin', }, debug) + def test_02_dir_reinclusion_whitelist(self): + """ + Test that a directory re-included by a directory-only pattern is not + reported as ignored. + + The whitelist idiom ("*" ignores everything, "!*/" keeps descending into + directories, "!*.py" keeps the files of interest) only works if asking + about the directory answers what Git answers. A consumer asks about the + directory precisely to decide whether to descend, so reporting "sub/" as + ignored silently drops every file below it. + """ + for sub_test in self.parameterize_from_lines([ + '*', + '!*/', + '!*.py', + ]): + with sub_test() as spec: + # Confirmed results with git check-ignore (v2.55.0). + dirs = { + 'sub/', + 'sub/d/', + } + self.assertEqual({_dir for _dir in dirs if spec.match_file(_dir)}, set()) + + files = { + 'a.py', + 'a.txt', + 'sub/b.py', + 'sub/b.txt', + 'sub/d/c.py', + } + + results = list(spec.check_files(files)) + ignores = get_includes(results) + debug = debug_results(spec, results) + + self.assertEqual(ignores, { + 'a.txt', + 'sub/b.txt', + }, debug) + def test_02_file_exclusions(self): """ Test file exclusions.