diff --git a/CHANGES_1.in.rst b/CHANGES_1.in.rst index 13f12b4..9da2b23 100644 --- a/CHANGES_1.in.rst +++ b/CHANGES_1.in.rst @@ -21,6 +21,7 @@ New features: Bug fixes: +- Match escaped internal path separators in gitignore patterns without treating them as trailing backslashes. - 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 3b1a0ec..cde1dd7 100644 --- a/pathspec/patterns/gitignore/base.py +++ b/pathspec/patterns/gitignore/base.py @@ -84,6 +84,13 @@ def _collapse_star_runs(segments: list[str]) -> list[str]: ] +def _ends_with_escape(pattern: str) -> bool: + """ + Return whether *pattern* ends with an unpaired backslash. + """ + return (len(pattern) - len(pattern.rstrip('\\'))) % 2 == 1 + + def _strip_trailing_ws(pattern: str) -> str: """ Strip trailing spaces and line endings from the pattern while considering @@ -235,6 +242,26 @@ def escape(s: AnyStr) -> AnyStr: else: return out_string # type: ignore[return-value] + @classmethod + def _translate_segment( + cls, + pattern: str, + errors: Literal['literal', 'raise'], + has_separator: bool, + ) -> str: + """ + Translate a segment, allowing a final backslash to escape the following + separator when *has_separator* is true. + """ + if has_separator and _ends_with_escape(pattern): + pattern = pattern[:-1] + if len(pattern) > 1 and pattern.count('*') == len(pattern): + # Git's globstar can cross directories before an escaped slash, but + # unlike "**/", that slash must still be matched. + return '(?s:.*)' + + return cls._translate_segment_glob(pattern, errors) + @staticmethod def _translate_segment_glob( pattern: str, diff --git a/pathspec/patterns/gitignore/basic.py b/pathspec/patterns/gitignore/basic.py index 269d722..ce18caf 100644 --- a/pathspec/patterns/gitignore/basic.py +++ b/pathspec/patterns/gitignore/basic.py @@ -24,6 +24,7 @@ _RangeNotationError, _TrailingBackslashError, _collapse_star_runs, + _ends_with_escape, _strip_trailing_ws) @@ -228,6 +229,13 @@ def pattern_to_regex( # normalization. is_dir_pattern = not orig_segs[-1] + if is_dir_pattern and _ends_with_escape(orig_segs[-2]): + # Git removes a terminal slash before matching, leaving an invalid + # trailing backslash when that slash was escaped. + if errors == 'raise': + raise GitIgnorePatternError(f"Invalid git pattern: {original_pattern!r}") + return (None, None) + if pattern_str == '/': # EDGE CASE: A single slash ('/') is not addressed by the gitignore # documentation. Git treats it as a no-op (does not match any files). The @@ -368,7 +376,7 @@ def __translate_segments( # Match segment glob pattern. # - EDGE CASE: The gitignore docs defer to *fnmatch(3)* which treats # invalid range notation as a literal. - out_parts.append(cls._translate_segment_glob(seg, errors)) + out_parts.append(cls._translate_segment(seg, errors, i < end)) if i == end: if seg == '*': diff --git a/pathspec/patterns/gitignore/spec.py b/pathspec/patterns/gitignore/spec.py index 8eae04c..0bc41d2 100644 --- a/pathspec/patterns/gitignore/spec.py +++ b/pathspec/patterns/gitignore/spec.py @@ -26,6 +26,7 @@ _RangeNotationError, _TrailingBackslashError, _collapse_star_runs, + _ends_with_escape, _strip_trailing_ws) _DIR_MARK = 'ps_d' @@ -271,6 +272,13 @@ def pattern_to_regex( # Check whether the pattern is specifically a directory pattern before # normalization. is_dir_pattern = not orig_segs[-1] + if is_dir_pattern and _ends_with_escape(orig_segs[-2]): + # Git removes a terminal slash before matching, leaving an invalid + # trailing backslash when that slash was escaped. + if errors == 'raise': + raise GitIgnorePatternError(f"Invalid git pattern: {original_pattern!r}") + return (None, None) + # A terminal directory globstar with a concrete prefix must consume a # descendant segment before the directory marker. Keep this semantic bit # separate from normalized segments, which collapse adjacent ``**``. @@ -418,7 +426,7 @@ def __translate_segments( else: # Match segment glob pattern. # - EDGE CASE: Git discards patterns with invalid range notation. - out_parts.append(cls._translate_segment_glob(seg, errors)) + out_parts.append(cls._translate_segment(seg, errors, i < end)) if i == end: # A pattern ending without a slash ('/') will match a file or a diff --git a/tests/test_09_gitignore_escaped_slash.py b/tests/test_09_gitignore_escaped_slash.py new file mode 100644 index 0000000..e549b9b --- /dev/null +++ b/tests/test_09_gitignore_escaped_slash.py @@ -0,0 +1,94 @@ +"""Test gitignore patterns containing escaped path separators.""" + +from unittest import TestCase + +from pathspec import GitIgnoreSpec, PathSpec +from pathspec.patterns.gitignore import GitIgnorePatternError +from pathspec.patterns.gitignore.basic import GitIgnoreBasicPattern +from pathspec.patterns.gitignore.spec import GitIgnoreSpecPattern + +from .util import require_backend + + +CASES = [ + (r'a\/b', ['a/b', 'a/b/child'], ['a', 'ab', 'x/a/b']), + (r'/a\/b', ['a/b', 'a/b/child'], ['ab', 'x/a/b']), + (r'a\/b\/c', ['a/b/c', 'a/b/c/child'], ['a/b', 'x/a/b/c']), + (r'a\/b/', ['a/b/', 'a/b/child'], ['a/b', 'x/a/b/child']), + (r'a\/b*.py', ['a/b.py', 'a/beta.py'], ['a/b.txt', 'x/a/b.py']), + (r'a\/[bc].py', ['a/b.py', 'a/c.py'], ['a/d.py', 'x/a/b.py']), + (r'a\/*/b', ['a/x/b'], ['a/b', 'a/x/y/b']), + (r'a\/**/b', ['a/b', 'a/x/b', 'a/x/y/b'], ['x/a/b', 'a/c']), + # An escaped slash after a globstar is required, unlike the slash in "**/". + (r'**\/b', ['a/b', 'a/x/b', 'a/b/child'], ['b', 'a/c']), + (r'***\/b', ['a/b', 'a/x/b'], ['b', 'a/c']), + (r'a/**\/b', ['a/x/b', 'a/x/y/b'], ['a/b', 'x/a/x/b']), +] +"""Patterns with matching and non-matching paths, verified against Git.""" + + +class EscapedSlashTest(TestCase): + + def test_pattern_matching(self): + for factory in (GitIgnoreBasicPattern, GitIgnoreSpecPattern): + for line, included, excluded in CASES: + for as_bytes in (False, True): + with self.subTest(factory=factory, line=line, as_bytes=as_bytes): + pattern = factory(line.encode() if as_bytes else line) + for path in included + excluded: + with self.subTest(path=path): + self.assertEqual( + bool(pattern.match_file(path.encode() if as_bytes else path)), + path in included, + ) + + def test_spec_matching(self): + for backend in ('simple', 're2', 'hyperscan'): + with self.subTest(backend=backend): + require_backend(backend) + for line, included, excluded in CASES: + for spec in ( + GitIgnoreSpec.from_lines([line], backend=backend), + PathSpec.from_lines('gitignore', [line], backend=backend), + ): + with self.subTest(line=line, spec=type(spec)): + self.assertEqual(set(spec.match_files(included + excluded)), set(included)) + + def test_negated_rule(self): + for backend in ('simple', 're2', 'hyperscan'): + with self.subTest(backend=backend): + require_backend(backend) + for spec in ( + GitIgnoreSpec.from_lines(['a/*.txt', r'!a\/keep.txt'], backend=backend), + PathSpec.from_lines('gitignore', ['a/*.txt', r'!a\/keep.txt'], backend=backend), + ): + with self.subTest(spec=type(spec)): + self.assertTrue(spec.match_file('a/drop.txt')) + result = spec.check_file('a/keep.txt') + self.assertIs(result.include, False) + self.assertEqual(result.index, 1) + + def test_backslash_parity(self): + for factory in (GitIgnoreBasicPattern, GitIgnoreSpecPattern): + for count in (1, 2, 3, 4): + for as_bytes in (False, True): + line = 'a' + '\\' * count + '/b' + path = 'a' + '\\' * (count // 2) + '/b' + other = 'a' + '\\' * (count // 2 + 1) + '/b' + with self.subTest(factory=factory, count=count, as_bytes=as_bytes): + pattern = factory(line.encode() if as_bytes else line) + self.assertIsNotNone(pattern.match_file(path.encode() if as_bytes else path)) + self.assertIsNone(pattern.match_file(other.encode() if as_bytes else other)) + + def test_leading_and_terminal_slashes(self): + for factory in (GitIgnoreBasicPattern, GitIgnoreSpecPattern): + for line in (r'a\/', r'a/**\/', r'a\/b\/'): + with self.subTest(factory=factory, line=line): + for errors in (None, 'literal', 'null'): + self.assertIsNone(factory(line, errors=errors).regex) + with self.assertRaises(GitIgnorePatternError): + factory(line, errors='raise') + # An escaped leading slash is literal, rather than a root anchor. + pattern = factory(r'\/b') + self.assertIsNone(pattern.match_file('b')) + self.assertIsNone(pattern.match_file('a/b'))