From 5e0786ee4aad5a40b8b60b63f94d3dad75719ad0 Mon Sep 17 00:00:00 2001 From: Jack Walker Date: Mon, 7 Sep 2026 22:37:47 -0400 Subject: [PATCH 1/2] Keep trailing globstars from matching their parent directory --- CHANGES_1.in.rst | 2 ++ pathspec/patterns/gitignore/basic.py | 11 +++++++---- pathspec/patterns/gitignore/spec.py | 4 ++-- tests/test_03_gitignore_basic.py | 7 ++++--- tests/test_04_gitignore_spec.py | 7 ++++--- tests/test_06_gitignore.py | 8 ++++++++ 6 files changed, 27 insertions(+), 12 deletions(-) diff --git a/CHANGES_1.in.rst b/CHANGES_1.in.rst index 33926fc..96f9419 100644 --- a/CHANGES_1.in.rst +++ b/CHANGES_1.in.rst @@ -16,6 +16,8 @@ New features: Bug fixes: +- Patterns ending in ``/**`` no longer match their bare parent directory, preserving traversal to re-included children (issue #137, part A). + - `Pull #123`_: Ignore invalid gitignore bracket ranges for `GitIgnoreSpec`. - `Pull #128`_: Support POSIX character classes (e.g. `[[:alpha:]]`) in gitignore bracket expressions. - `Issue #129`_ / `Pull #132`_: Fix GitIgnoreSpec re-including files under an excluded directory diff --git a/pathspec/patterns/gitignore/basic.py b/pathspec/patterns/gitignore/basic.py index 770eab1..afd85c3 100644 --- a/pathspec/patterns/gitignore/basic.py +++ b/pathspec/patterns/gitignore/basic.py @@ -221,7 +221,7 @@ def pattern_to_regex( elif pattern_segs is not None: # Build regular expression from pattern. try: - regex_parts = cls.__translate_segments(pattern_segs) + regex_parts = cls.__translate_segments(is_dir_pattern, pattern_segs) except ValueError as e: raise GitIgnorePatternError(( f"Invalid git pattern: {original_pattern!r}" @@ -245,10 +245,13 @@ def pattern_to_regex( return (out_regex, include) @classmethod - def __translate_segments(cls, pattern_segs: list[str]) -> list[str]: + def __translate_segments(cls, is_dir_pattern: bool, pattern_segs: list[str]) -> list[str]: """ Translate the pattern segments to regular expressions. + *is_dir_pattern* (:class:`bool`) is whether the original pattern ends + with a slash. + *pattern_segs* (:class:`list` of :class:`str`) contains the pattern segments. @@ -276,8 +279,8 @@ def __translate_segments(cls, pattern_segs: list[str]) -> list[str]: else: assert i == end, (i, end) # A normalized pattern ending with double-asterisks ('**') will match - # any trailing path segments. - out_parts.append('/') + # nonempty trailing path segments, not the parent directory itself. + out_parts.append('/' if is_dir_pattern else '/[^/]') else: # Match path segment. diff --git a/pathspec/patterns/gitignore/spec.py b/pathspec/patterns/gitignore/spec.py index c6d5133..1bb1e76 100644 --- a/pathspec/patterns/gitignore/spec.py +++ b/pathspec/patterns/gitignore/spec.py @@ -317,11 +317,11 @@ def __translate_segments( else: assert i == end, (i, end) # A normalized pattern ending with double-asterisks ('**') will match - # any trailing path segments. + # nonempty trailing path segments, not the parent directory itself. if is_dir_pattern: out_parts.append(_DIR_MARK_CG) else: - out_parts.append('/') + out_parts.append('/[^/]') else: # Match path segment. diff --git a/tests/test_03_gitignore_basic.py b/tests/test_03_gitignore_basic.py index f85cf2f..3179ef8 100644 --- a/tests/test_03_gitignore_basic.py +++ b/tests/test_03_gitignore_basic.py @@ -229,11 +229,12 @@ def test_03_child_double_asterisk(self): """ regex, include = GitIgnoreBasicPattern.pattern_to_regex('spam/**') self.assertTrue(include) - self.assertEqual(regex, '^spam/') + self.assertEqual(regex, '^spam/[^/]') pattern = GitIgnoreBasicPattern(re.compile(regex), include) results = set(filter(pattern.match_file, [ 'spam/bar', + 'spam/', 'foo/spam/bar', ])) self.assertEqual(results, {'spam/bar'}) @@ -363,7 +364,7 @@ def test_03_duplicate_leading_double_asterisk_edge_case(self): regex, include = GitIgnoreBasicPattern.pattern_to_regex('**/api/**') self.assertTrue(include) - self.assertEqual(regex, '^(?:.+/)?api/') + self.assertEqual(regex, '^(?:.+/)?api/[^/]') equiv_regex, include = GitIgnoreBasicPattern.pattern_to_regex('**/**/api/**/**') self.assertTrue(include) @@ -843,7 +844,7 @@ def test_14_issue_81_a(self): """ pattern = GitIgnoreBasicPattern('!libfoo/**') - self.assertEqual(pattern.regex.pattern, '^libfoo/') + self.assertEqual(pattern.regex.pattern, '^libfoo/[^/]') self.assertIs(pattern.include, False) self.assertTrue(pattern.match_file('libfoo/__init__.py')) diff --git a/tests/test_04_gitignore_spec.py b/tests/test_04_gitignore_spec.py index 618f658..e7458e1 100644 --- a/tests/test_04_gitignore_spec.py +++ b/tests/test_04_gitignore_spec.py @@ -228,11 +228,12 @@ def test_03_child_double_asterisk(self): """ regex, include = GitIgnoreSpecPattern.pattern_to_regex('spam/**') self.assertTrue(include) - self.assertEqual(regex, '^spam/') + self.assertEqual(regex, '^spam/[^/]') pattern = GitIgnoreSpecPattern(re.compile(regex), include) results = set(filter(pattern.match_file, [ 'spam/bar', + 'spam/', 'foo/spam/bar', ])) self.assertEqual(results, {'spam/bar'}) @@ -362,7 +363,7 @@ def test_03_duplicate_leading_double_asterisk_edge_case(self): regex, include = GitIgnoreSpecPattern.pattern_to_regex('**/api/**') self.assertTrue(include) - self.assertEqual(regex, '^(?:.+/)?api/') + self.assertEqual(regex, '^(?:.+/)?api/[^/]') equiv_regex, include = GitIgnoreSpecPattern.pattern_to_regex('**/**/api/**/**') self.assertTrue(include) @@ -876,7 +877,7 @@ def test_14_issue_81_a(self): """ pattern = GitIgnoreSpecPattern('!libfoo/**') - self.assertEqual(pattern.regex.pattern, '^libfoo/') + self.assertEqual(pattern.regex.pattern, '^libfoo/[^/]') self.assertIs(pattern.include, False) self.assertTrue(pattern.match_file('libfoo/__init__.py')) diff --git a/tests/test_06_gitignore.py b/tests/test_06_gitignore.py index 980386f..7a87b54 100644 --- a/tests/test_06_gitignore.py +++ b/tests/test_06_gitignore.py @@ -907,3 +907,11 @@ def test_11_issue_134(self): "node_modules/", "node_modules/leaf.txt", }, debug) + + def test_trailing_globstar_does_not_ignore_parent(self): + for sub_test in self.parameterize_from_lines(["d/**"]): + with sub_test() as spec: + self.assertFalse(spec.match_file("d/")) + self.assertTrue(spec.match_file("d/file")) + self.assertTrue(spec.match_file("d/child/")) + self.assertTrue(spec.match_file("d/\nfile")) From 760ee4caf8bcf55e31ba19db10eb901f742a44b2 Mon Sep 17 00:00:00 2001 From: "Caleb P. Burns" <2126043+cpburnz@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:47:50 -0400 Subject: [PATCH 2/2] Update test_06_gitignore.py --- tests/test_06_gitignore.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_06_gitignore.py b/tests/test_06_gitignore.py index 4985751..9fcae86 100644 --- a/tests/test_06_gitignore.py +++ b/tests/test_06_gitignore.py @@ -909,10 +909,10 @@ def test_11_issue_134(self): }, debug) - def test_12_issue_132_a(self): - """ - Test that trailing glob-stars do not ignore parent. - """ + def test_12_issue_137_a(self): + """ + Test that trailing glob-stars do not ignore parent. + """ for sub_test in self.parameterize_from_lines(["d/**"]): with sub_test() as spec: self.assertFalse(spec.match_file("d/"))