Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ Bug fixes:
- `Pull #165`_: Directory negations no longer re-include descendants of an excluded ancestor.
- `Pull #166`_: Escape literal regex set operators in gitignore bracket expressions so valid patterns compile when FutureWarning is treated as an error.
- `Pull #167`_: - A gitignore pattern segment made of three or more asterisks (e.g., `***/x`) is now treated as a double-asterisk, matching Git.
- `Pull #171`_: An explicit `**/*/**` pattern no longer matches top-level directories, so a negation can re-include files below them.


.. _`Issue #116`: https://github.com/cpburnz/python-pathspec/issues/116
Expand Down Expand Up @@ -85,6 +86,7 @@ Bug fixes:
.. _`Pull #165`: https://github.com/cpburnz/python-pathspec/pull/165
.. _`Pull #166`: https://github.com/cpburnz/python-pathspec/pull/166
.. _`Pull #167`: https://github.com/cpburnz/python-pathspec/pull/167
.. _`Pull #171`: https://github.com/cpburnz/python-pathspec/pull/171


1.1.1 (2026-04-26)
Expand Down
2 changes: 2 additions & 0 deletions CHANGES_1.in.rst
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Bug fixes:
- `Pull #165`_: Directory negations no longer re-include descendants of an excluded ancestor.
- `Pull #166`_: Escape literal regex set operators in gitignore bracket expressions so valid patterns compile when FutureWarning is treated as an error.
- `Pull #167`_: - A gitignore pattern segment made of three or more asterisks (e.g., `***/x`) is now treated as a double-asterisk, matching Git.
- `Pull #171`_: An explicit `**/*/**` pattern no longer matches top-level directories, so a negation can re-include files below them.


.. _`Issue #116`: https://github.com/cpburnz/python-pathspec/issues/116
Expand Down Expand Up @@ -82,6 +83,7 @@ Bug fixes:
.. _`Pull #165`: https://github.com/cpburnz/python-pathspec/pull/165
.. _`Pull #166`: https://github.com/cpburnz/python-pathspec/pull/166
.. _`Pull #167`: https://github.com/cpburnz/python-pathspec/pull/167
.. _`Pull #171`: https://github.com/cpburnz/python-pathspec/pull/171


1.1.1 (2026-04-26)
Expand Down
12 changes: 6 additions & 6 deletions pathspec/patterns/gitignore/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,15 +157,15 @@ def __normalize_segments(
and pattern_segs[0] == '**'
and pattern_segs[1] == '*'
and pattern_segs[2] == '**'
and is_dir_pattern
and not requires_dir_descendant
):
# The pattern "*/" will be normalized to "**/*/**" which will match every
# file not in the root directory. Special case this pattern for
# efficiency.
if is_dir_pattern:
return (None, _DIR_MARK_CG)
else:
return (None, '/')
# directory. Special case this pattern for efficiency. An explicit
# "**/*/**" (without a trailing slash) is not equivalent: it requires a
# descendant segment after the "*", so it must go through translation to
# keep directories such as "a/" from matching.
return (None, _DIR_MARK_CG)

# No regular expression override, return modified pattern segments.
return (pattern_segs, None)
Expand Down
40 changes: 40 additions & 0 deletions tests/test_06_gitignore.py
Original file line number Diff line number Diff line change
Expand Up @@ -1125,6 +1125,11 @@ def test_c(self):
{"d/top.txt", "child/file.txt"},
{"d/child/file.txt", "a/x/child/file.txt", "child/grand/deep.txt"},
),
(
"**/*/**",
{"top.txt", "d/"},
{"d/top.txt", "d/child/", "a/b/deep.txt"},
),
]
for pattern, unignored, ignored in cases:
for sub_test in self.parameterize_from_lines([pattern]):
Expand Down Expand Up @@ -1265,3 +1270,38 @@ def test_3_character_after_slash(self):
self.assertTrue(spec.match_file(name))
self.assertFalse(spec.match_file('foo/'))
self.assertFalse(spec.match_file('foo/other'))


class GitIgnoreSpecGlobstarStarGlobstarTest(GitIgnoreSpecMixin, TestCase):
"""
The :class:`GitIgnoreSpecGlobstarStarGlobstarTest` class tests that an
explicit ``**/*/**`` pattern requires a descendant segment after the ``*``.
Unlike ``*/``, it must not match a top-level directory itself, so a negation
can still re-include a file below such a directory (Git stages the file).
"""

def test_1_no_dir_descendant(self):
"""
Test that "**/*/**" leaves top-level directories and files unmatched.
"""
for sub_test in self.parameterize_from_lines(["**/*/**"]):
with sub_test() as spec:
self.assertFalse(spec.match_file("top.txt"))
self.assertFalse(spec.match_file("d/"))
self.assertTrue(spec.match_file("d/top.txt"))
self.assertTrue(spec.match_file("d/child/"))
self.assertTrue(spec.match_file("a/b/deep.txt"))

def test_2_negation_reinclusion(self):
"""
Test that a negation re-includes a file below a directory which
"**/*/**" does not itself exclude.
"""
for sub_test in self.parameterize_from_lines([
"**/*/**",
"!a/keep.txt",
]):
with sub_test() as spec:
self.assertFalse(spec.match_file("a/"))
self.assertFalse(spec.match_file("a/keep.txt"))
self.assertTrue(spec.match_file("a/other.txt"))