Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES_1.in.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 27 additions & 0 deletions pathspec/patterns/gitignore/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion pathspec/patterns/gitignore/basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
_RangeNotationError,
_TrailingBackslashError,
_collapse_star_runs,
_ends_with_escape,
_strip_trailing_ws)


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 == '*':
Expand Down
10 changes: 9 additions & 1 deletion pathspec/patterns/gitignore/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
_RangeNotationError,
_TrailingBackslashError,
_collapse_star_runs,
_ends_with_escape,
_strip_trailing_ws)

_DIR_MARK = 'ps_d'
Expand Down Expand Up @@ -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 ``**``.
Expand Down Expand Up @@ -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
Expand Down
94 changes: 94 additions & 0 deletions tests/test_09_gitignore_escaped_slash.py
Original file line number Diff line number Diff line change
@@ -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'))
Loading