Skip to content

Commit 958cd4d

Browse files
committed
Merge branch 'master' into issue-132
2 parents e67792f + 4c7fdd1 commit 958cd4d

6 files changed

Lines changed: 79 additions & 6 deletions

File tree

‎CHANGES.rst‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,14 @@ Bug fixes:
2121

2222
- `Pull #123`_: Ignore invalid gitignore bracket ranges for `GitIgnoreSpec`.
2323
- `Pull #128`_: Support POSIX character classes (e.g. `[[:alpha:]]`) in gitignore bracket expressions.
24+
- `Pull #133`_: Capture the directory marker in the `*/**` regex shortcut so `!*/` can re-include directories.
2425

2526

2627
.. _`Issue #116`: https://github.com/cpburnz/python-pathspec/issues/116
2728
.. _`Pull #123`: https://github.com/cpburnz/python-pathspec/pull/123
2829
.. _`Issue #126`: https://github.com/cpburnz/python-pathspec/issues/126
2930
.. _`Pull #128`: https://github.com/cpburnz/python-pathspec/pull/128
31+
.. _`Pull #133`: https://github.com/cpburnz/python-pathspec/pull/133
3032

3133

3234
1.1.1 (2026-04-26)

‎CHANGES_1.in.rst‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@ Bug fixes:
1818

1919
- `Pull #123`_: Ignore invalid gitignore bracket ranges for `GitIgnoreSpec`.
2020
- `Pull #128`_: Support POSIX character classes (e.g. `[[:alpha:]]`) in gitignore bracket expressions.
21+
- `Pull #133`_: Capture the directory marker in the `*/**` regex shortcut so `!*/` can re-include directories.
2122

2223

2324
.. _`Issue #116`: https://github.com/cpburnz/python-pathspec/issues/116
2425
.. _`Pull #123`: https://github.com/cpburnz/python-pathspec/pull/123
2526
.. _`Issue #126`: https://github.com/cpburnz/python-pathspec/issues/126
2627
.. _`Pull #128`: https://github.com/cpburnz/python-pathspec/pull/128
28+
.. _`Pull #133`: https://github.com/cpburnz/python-pathspec/pull/133
2729

2830

2931
1.1.1 (2026-04-26)

‎pathspec/_meta.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,5 +69,6 @@
6969
"Julie Jones <https://github.com/julie777>",
7070
"Guillermo Garcia <https://github.com/Gares95>",
7171
"Eesh Saxena <https://github.com/eeshsaxena>",
72+
"Midas <https://github.com/KaizenShogun>",
7273
]
7374
__license__ = "MPL 2.0"

‎pathspec/patterns/gitignore/spec.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@
3939
This regular expression matches the optional directory marker and sub-path.
4040
"""
4141

42+
_MATCH_ALL = f'^(?:.+/)?[^/]+{_DIR_MARK_OPT}'
43+
"""
44+
This regular expression matches every path. It is the expansion of the patterns
45+
"*" and "**" (i.e., "**/{any name}"), and it has to capture the directory marker
46+
like any other pattern so that :class:`.GitIgnoreSpec` can tell a directory
47+
match from a file match.
48+
"""
49+
4250

4351
class GitIgnoreSpecPattern(_GitIgnoreBasePattern):
4452
"""
@@ -121,7 +129,7 @@ def __normalize_segments(
121129
return (None, _DIR_MARK_CG)
122130
else:
123131
# The pattern "**" will match every path. Special case this pattern.
124-
return (None, '.')
132+
return (None, _MATCH_ALL)
125133

126134
elif (
127135
seg_count == 2
@@ -130,7 +138,7 @@ def __normalize_segments(
130138
):
131139
# The pattern "*" will be normalized to "**/*" and will match every
132140
# path. Special case this pattern for efficiency.
133-
return (None, '.')
141+
return (None, _MATCH_ALL)
134142

135143
elif (
136144
seg_count == 3

‎tests/test_04_gitignore_spec.py‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@
1414
_BYTES_ENCODING)
1515
from pathspec.patterns.gitignore.spec import (
1616
GitIgnoreSpecPattern,
17+
_DIR_MARK,
1718
_DIR_MARK_CG,
18-
_DIR_MARK_OPT)
19+
_DIR_MARK_OPT,
20+
_MATCH_ALL)
1921
from pathspec.patterns.gitwildmatch import (
2022
GitWildMatchPattern)
2123
from pathspec.util import (
@@ -275,7 +277,7 @@ def test_03_only_double_asterisk(self):
275277
"""
276278
regex, include = GitIgnoreSpecPattern.pattern_to_regex('**')
277279
self.assertTrue(include)
278-
self.assertEqual(regex, '.')
280+
self.assertEqual(regex, _MATCH_ALL)
279281

280282
pattern = GitIgnoreSpecPattern(re.compile(regex), include)
281283
results = set(filter(pattern.match_file, [
@@ -332,7 +334,7 @@ def test_03_duplicate_leading_double_asterisk_edge_case(self):
332334
"""
333335
regex, include = GitIgnoreSpecPattern.pattern_to_regex('**')
334336
self.assertTrue(include)
335-
self.assertEqual(regex, '.')
337+
self.assertEqual(regex, _MATCH_ALL)
336338

337339
equiv_regex, include = GitIgnoreSpecPattern.pattern_to_regex('**/**')
338340
self.assertTrue(include)
@@ -753,7 +755,23 @@ def test_12_asterisk_1_regex(self):
753755
"""
754756
regex, include = GitIgnoreSpecPattern.pattern_to_regex('*')
755757
self.assertTrue(include)
756-
self.assertEqual(regex, '.')
758+
self.assertEqual(regex, _MATCH_ALL)
759+
760+
def test_12_asterisk_1b_regex_marks_directories(self):
761+
"""
762+
Test that the relative asterisk path pattern captures the directory marker.
763+
764+
Without the marker, "*" outranks a later directory-only pattern (e.g.
765+
"!*/") and :class:`.GitIgnoreSpec` reports a directory as ignored where Git
766+
does not.
767+
"""
768+
regex, include = GitIgnoreSpecPattern.pattern_to_regex('*')
769+
self.assertTrue(include)
770+
771+
compiled = re.compile(regex)
772+
self.assertIsNotNone(compiled.search('dirA/').group(_DIR_MARK))
773+
self.assertIsNone(compiled.search('fileA').group(_DIR_MARK))
774+
self.assertIsNone(compiled.search('dirA/fileB').group(_DIR_MARK))
757775

758776
def test_12_asterisk_2_regex_equivalent(self):
759777
"""

‎tests/test_06_gitignore.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,48 @@ def test_02_dir_exclusions(self):
205205
'test2/b.bin',
206206
}, debug)
207207

208+
def test_02_dir_reinclusion_whitelist(self):
209+
"""
210+
Test that a directory re-included by a directory-only pattern is not
211+
reported as ignored.
212+
213+
The whitelist idiom ("*" ignores everything, "!*/" keeps descending into
214+
directories, "!*.py" keeps the files of interest) only works if asking
215+
about the directory answers what Git answers. A consumer asks about the
216+
directory precisely to decide whether to descend, so reporting "sub/" as
217+
ignored silently drops every file below it.
218+
"""
219+
for sub_test in self.parameterize_from_lines([
220+
'*',
221+
'!*/',
222+
'!*.py',
223+
]):
224+
with sub_test() as spec:
225+
# Confirmed results with git check-ignore (v2.55.0).
226+
dirs = {
227+
'sub/', # 2:!*/
228+
'sub/d/', # 2:!*/
229+
}
230+
self.assertEqual({_dir for _dir in dirs if spec.match_file(_dir)}, set())
231+
232+
# Confirmed results with git check-ignore (v2.55.0).
233+
files = {
234+
'a.py', # 3:!*.py
235+
'a.txt', # 1:*
236+
'sub/b.py', # 3:!*.py
237+
'sub/b.txt', # 1:*
238+
'sub/d/c.py', # 3:!*.py
239+
}
240+
241+
results = list(spec.check_files(files))
242+
ignores = get_includes(results)
243+
debug = debug_results(spec, results)
244+
245+
self.assertEqual(ignores, {
246+
'a.txt',
247+
'sub/b.txt',
248+
}, debug)
249+
208250
def test_02_file_exclusions(self):
209251
"""
210252
Test file exclusions.

0 commit comments

Comments
 (0)