diff --git a/CHANGES_1.in.rst b/CHANGES_1.in.rst index d962455..cf11f37 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 547b894..44b2400 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 7775122..f8bd159 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 f28ea08..9d4c960 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, '^(?s:.+/)?api/') + self.assertEqual(regex, '^(?s:.+/)?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 be19055..f43b32c 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, '^(?s:.+/)?api/') + self.assertEqual(regex, '^(?s:.+/)?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 a35d9d5..9fcae86 100644 --- a/tests/test_06_gitignore.py +++ b/tests/test_06_gitignore.py @@ -908,7 +908,19 @@ def test_11_issue_134(self): "node_modules/leaf.txt", }, debug) - def test_12_issue_139(self): + + 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/")) + self.assertTrue(spec.match_file("d/file")) + self.assertTrue(spec.match_file("d/child/")) + self.assertTrue(spec.match_file("d/\nfile")) + + def test_13_issue_139(self): """ Test that glob-stars match newlines in names. """ @@ -924,3 +936,4 @@ def test_12_issue_139(self): for sub_test in self.parameterize_from_lines([pattern]): with sub_test() as spec: self.assertTrue(spec.match_file(path)) +