diff --git a/CHANGES_1.in.rst b/CHANGES_1.in.rst index e2a76ae..f837873 100644 --- a/CHANGES_1.in.rst +++ b/CHANGES_1.in.rst @@ -21,6 +21,7 @@ New features: Bug fixes: +- A gitignore pattern segment made of three or more asterisks (e.g., `***/x`) is now treated as a double-asterisk, matching Git. - Hyperscan backend no longer fails on multibyte UTF-8 characters. - `Pull #123`_: Ignore invalid gitignore bracket ranges for `GitIgnoreSpec`. - `Pull #128`_: Support POSIX character classes (e.g. `[[:alpha:]]`) in gitignore bracket expressions. diff --git a/pathspec/patterns/gitignore/base.py b/pathspec/patterns/gitignore/base.py index eaf16cb..e3e8322 100644 --- a/pathspec/patterns/gitignore/base.py +++ b/pathspec/patterns/gitignore/base.py @@ -102,6 +102,22 @@ def _strip_trailing_ws(pattern: str) -> str: return pattern[:last_ws] +def _collapse_star_runs(segments: list[str]) -> list[str]: + """ + Replace each pattern segment that consists only of three or more asterisks + (e.g., ``***``) with a double-asterisk (``**``). Git's wildmatch treats a run + of asterisks as a single globstar when it spans a whole path segment. + + *segments* (:class:`list` of :class:`str`) are the pattern segments. + + Returns the modified segments (:class:`list` of :class:`str`). + """ + return [ + '**' if len(seg) > 2 and seg.count('*') == len(seg) else seg + for seg in segments + ] + + def _sub_bracket_token(match: re.Match[str]) -> str: """ Translate a single token matched by :data:`_BRACKET_TOKEN_REGEX`. A token can diff --git a/pathspec/patterns/gitignore/basic.py b/pathspec/patterns/gitignore/basic.py index f86a15a..269d722 100644 --- a/pathspec/patterns/gitignore/basic.py +++ b/pathspec/patterns/gitignore/basic.py @@ -23,6 +23,7 @@ _PosixClassError, _RangeNotationError, _TrailingBackslashError, + _collapse_star_runs, _strip_trailing_ws) @@ -221,7 +222,7 @@ def pattern_to_regex( return (None, None) # Split pattern into segments. - orig_segs = pattern_str.split('/') + orig_segs = _collapse_star_runs(pattern_str.split('/')) # Check whether the pattern is specifically a directory pattern before # normalization. diff --git a/pathspec/patterns/gitignore/spec.py b/pathspec/patterns/gitignore/spec.py index 7b5fcc2..8eae04c 100644 --- a/pathspec/patterns/gitignore/spec.py +++ b/pathspec/patterns/gitignore/spec.py @@ -25,6 +25,7 @@ _PosixClassError, _RangeNotationError, _TrailingBackslashError, + _collapse_star_runs, _strip_trailing_ws) _DIR_MARK = 'ps_d' @@ -265,7 +266,7 @@ def pattern_to_regex( return (None, None) # Split pattern into segments. - orig_segs = pattern_str.split('/') + orig_segs = _collapse_star_runs(pattern_str.split('/')) # Check whether the pattern is specifically a directory pattern before # normalization. diff --git a/tests/test_10_gitignore_star_runs.py b/tests/test_10_gitignore_star_runs.py new file mode 100644 index 0000000..fd1692f --- /dev/null +++ b/tests/test_10_gitignore_star_runs.py @@ -0,0 +1,54 @@ +""" +Test runs of three or more asterisks that span a whole gitignore path segment. +""" + +import unittest + +from pathspec import GitIgnoreSpec +from pathspec.patterns.gitignore.basic import GitIgnoreBasicPattern +from pathspec.patterns.gitignore.spec import GitIgnoreSpecPattern + +# Each case is (pattern, paths that Git matches, paths that Git does not match). +# Git's wildmatch treats a run of asterisks that spans a whole path segment as +# a single globstar (``**``). +CASES = ( + ('***/x', ('x', 'a/x', 'a/b/x'), ('xx', 'a/xx')), + ('****/x', ('x', 'a/x', 'a/b/x'), ('xx',)), + ('a/***', ('a/b', 'a/b/c'), ('a', 'b/a', 'b/a/c')), + ('a/***/b', ('a/b', 'a/x/b', 'a/x/y/b'), ('a/x', 'b/x/b')), + ('***', ('a', 'a/b', 'a/b/c'), ()), + # A run of asterisks inside a segment is still a single asterisk. + ('a***b', ('ab', 'axxb'), ('a/b', 'a/x/b')), +) + + +class StarRunTest(unittest.TestCase): + """ + The :class:`StarRunTest` class tests asterisk runs against Git's behavior. + """ + + def test_pattern_classes(self): + """ + Tests that both gitignore pattern classes treat a segment of asterisks + like a double-asterisk. + """ + for factory in (GitIgnoreBasicPattern, GitIgnoreSpecPattern): + for line, matches, misses in CASES: + with self.subTest(factory=factory.__name__, line=line): + pattern = factory(line) + for path in matches: + self.assertTrue(pattern.match_file(path), path) + for path in misses: + self.assertFalse(pattern.match_file(path), path) + + def test_gitignore_spec(self): + """ + Tests that :class:`.GitIgnoreSpec` matches the same paths as Git. + """ + for line, matches, misses in CASES: + with self.subTest(line=line): + spec = GitIgnoreSpec.from_lines([line]) + for path in matches: + self.assertTrue(spec.match_file(path), path) + for path in misses: + self.assertFalse(spec.match_file(path), path)