diff --git a/CHANGES.rst b/CHANGES.rst index 671e0a6..2bcd208 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -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 @@ -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) diff --git a/CHANGES_1.in.rst b/CHANGES_1.in.rst index 13f12b4..5838c9f 100644 --- a/CHANGES_1.in.rst +++ b/CHANGES_1.in.rst @@ -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 @@ -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) diff --git a/pathspec/patterns/gitignore/spec.py b/pathspec/patterns/gitignore/spec.py index 8eae04c..dfd9c0f 100644 --- a/pathspec/patterns/gitignore/spec.py +++ b/pathspec/patterns/gitignore/spec.py @@ -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) diff --git a/tests/test_06_gitignore.py b/tests/test_06_gitignore.py index 523e905..8ec40a7 100644 --- a/tests/test_06_gitignore.py +++ b/tests/test_06_gitignore.py @@ -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]): @@ -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"))