From fc7e00efbb5b5d36c2c398b3fcd6408bafd7b766 Mon Sep 17 00:00:00 2001 From: xingzhao-ji <185012714+xingzhao-ji@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:12:48 -0700 Subject: [PATCH] Keep gitignore bracket expressions within path segments --- pathspec/patterns/gitignore/base.py | 45 +++++++++---- tests/test_03_gitignore_basic.py | 8 +-- tests/test_04_gitignore_spec.py | 14 ++-- tests/test_08_gitignore_bracket_slash.py | 82 ++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 25 deletions(-) create mode 100644 tests/test_08_gitignore_bracket_slash.py diff --git a/pathspec/patterns/gitignore/base.py b/pathspec/patterns/gitignore/base.py index ceab283..acf49f4 100644 --- a/pathspec/patterns/gitignore/base.py +++ b/pathspec/patterns/gitignore/base.py @@ -15,19 +15,23 @@ AnyStr) # Removed in 3.18. _BRACKET_TOKEN_REGEX = re.compile(( + # Range, including escaped endpoints, but not the start of a POSIX class. + r'(\\.|[^\\])-(?!\[:)(\\.|[^\\])' # Escaped character. - r'\\(.)' + r'|\\(.)' # POSIX class. r'|\[:(\^?)([^:\]]*):\]' ), re.DOTALL) """ Matches tokens inside a bracket expression: -- A backslash escaped character (group 1). +- A character range (groups 1 and 2). + +- A backslash escaped character (group 3). - Matches a POSIX bracket character class token such as ``[:alpha:]`` inside a - bracket expression. Group 2 captures a leading caret (unsupported by git); - group 3 captures the class name. + bracket expression. Group 4 captures a leading caret (unsupported by git); + group 5 captures the class name. """ _BYTES_ENCODING = 'latin1' @@ -46,17 +50,17 @@ 'blank': '\\t ', 'cntrl': '\\x00-\\x1f\\x7f', 'digit': '0-9', - 'graph': '\\x21-\\x7e', + 'graph': '\\x21-\\x2e\\x30-\\x7e', 'lower': 'a-z', - 'print': '\\x20-\\x7e', - 'punct': '!-/:-@\\[-`{-~', + 'print': '\\x20-\\x2e\\x30-\\x7e', + 'punct': '!-.:-@\\[-`{-~', 'space': '\\t\\n\\r ', 'upper': 'A-Z', 'xdigit': '0-9A-Fa-f', } """ -Maps each POSIX bracket character class name to the ASCII regex range that -reproduces git's wildmatch behavior. +Maps each POSIX bracket character class name to ASCII regex ranges, excluding +the directory separator as required by git's wildmatch behavior. """ @@ -97,14 +101,21 @@ def _strip_trailing_ws(pattern: str) -> str: def _sub_bracket_token(match: re.Match[str]) -> str: """ Translate a single token matched by :data:`_BRACKET_TOKEN_REGEX`. A token can - be a backslash escaped character (group 1), a POSIX bracket character class - (groups 2 and 3). + be a range (groups 1 and 2), a backslash escaped character (group 3), or a + POSIX bracket character class (groups 4 and 5). *match* (:class:`re.Match`) is the match object. Returns the replacement string (:class:`str`). """ - escape_char, class_negated, class_name = match.groups() + range_start, range_end, escape_char, class_negated, class_name = match.groups() + + # Character range. A bracket expression cannot match a directory separator. + if range_start is not None: + range_start, range_end = range_start[-1], range_end[-1] + if range_start < '/' < range_end: + return re.escape(range_start) + '-.0-' + re.escape(range_end) + return re.escape(range_start) + '-' + re.escape(range_end) # Backslash escaped character. if escape_char: @@ -333,7 +344,7 @@ def _translate_segment_glob( # broken regex that silently mismatches (and warns about a nested # set). try: - body = _BRACKET_TOKEN_REGEX.sub(_sub_bracket_token, pattern[i:j]) + body = _BRACKET_TOKEN_REGEX.sub(_sub_bracket_token, pattern[i:j - 1]) except _PosixClassError: if errors == 'raise': # EDGE CASE: Git discards patterns with an invalid range notation @@ -345,7 +356,13 @@ def _translate_segment_glob( i = j continue - expr += body + if expr == '[^': + # Negated expressions must also exclude the directory separator. + # Preserve literal ']' and '-' when inserting '/' ahead of them. + if body.startswith((']', '-')): + body = '\\' + body + expr += '/' + expr += body + ']' if errors == 'raise': try: diff --git a/tests/test_03_gitignore_basic.py b/tests/test_03_gitignore_basic.py index 3302625..e4029e5 100644 --- a/tests/test_03_gitignore_basic.py +++ b/tests/test_03_gitignore_basic.py @@ -997,11 +997,11 @@ def test_c_1_valid(self): Test patterns with valid range notation. """ for raw_pattern, regex in [ - ('[!a-z]', f'^(?s:.+/)?[^a-z]{_DIR_OPT}'), - ('[^a-z]', f'^(?s:.+/)?[^a-z]{_DIR_OPT}'), + ('[!a-z]', f'^(?s:.+/)?[^/a-z]{_DIR_OPT}'), + ('[^a-z]', f'^(?s:.+/)?[^/a-z]{_DIR_OPT}'), ('[a-z]', f'^(?s:.+/)?[a-z]{_DIR_OPT}'), - ('a[!a-z]', f'^(?s:.+/)?a[^a-z]{_DIR_OPT}'), - ('a[^a-z]', f'^(?s:.+/)?a[^a-z]{_DIR_OPT}'), + ('a[!a-z]', f'^(?s:.+/)?a[^/a-z]{_DIR_OPT}'), + ('a[^a-z]', f'^(?s:.+/)?a[^/a-z]{_DIR_OPT}'), ('a[a-z]', f'^(?s:.+/)?a[a-z]{_DIR_OPT}'), ]: with self.subTest(f"p={raw_pattern!r}"): diff --git a/tests/test_04_gitignore_spec.py b/tests/test_04_gitignore_spec.py index 7169380..dbab0c2 100644 --- a/tests/test_04_gitignore_spec.py +++ b/tests/test_04_gitignore_spec.py @@ -767,10 +767,10 @@ def test_12_posix_class_a_regex(self): ('[[:blank:]]', '[\\t ]'), ('[[:cntrl:]]', '[\\x00-\\x1f\\x7f]'), ('[[:digit:]]', '[0-9]'), - ('[[:graph:]]', '[\\x21-\\x7e]'), + ('[[:graph:]]', '[\\x21-\\x2e\\x30-\\x7e]'), ('[[:lower:]]', '[a-z]'), - ('[[:print:]]', '[\\x20-\\x7e]'), - ('[[:punct:]]', '[!-/:-@\\[-`{-~]'), + ('[[:print:]]', '[\\x20-\\x2e\\x30-\\x7e]'), + ('[[:punct:]]', '[!-.:-@\\[-`{-~]'), ('[[:space:]]', '[\\t\\n\\r ]'), ('[[:upper:]]', '[A-Z]'), ('[[:xdigit:]]', '[0-9A-Fa-f]'), @@ -1123,11 +1123,11 @@ def test_c_1_valid(self): Test patterns with valid range notation. """ for raw_pattern, regex in [ - ('[!a-z]', f'^(?s:.+/)?[^a-z]{_DIR_MARK_OPT}'), - ('[^a-z]', f'^(?s:.+/)?[^a-z]{_DIR_MARK_OPT}'), + ('[!a-z]', f'^(?s:.+/)?[^/a-z]{_DIR_MARK_OPT}'), + ('[^a-z]', f'^(?s:.+/)?[^/a-z]{_DIR_MARK_OPT}'), ('[a-z]', f'^(?s:.+/)?[a-z]{_DIR_MARK_OPT}'), - ('a[!a-z]', f'^(?s:.+/)?a[^a-z]{_DIR_MARK_OPT}'), - ('a[^a-z]', f'^(?s:.+/)?a[^a-z]{_DIR_MARK_OPT}'), + ('a[!a-z]', f'^(?s:.+/)?a[^/a-z]{_DIR_MARK_OPT}'), + ('a[^a-z]', f'^(?s:.+/)?a[^/a-z]{_DIR_MARK_OPT}'), ('a[a-z]', f'^(?s:.+/)?a[a-z]{_DIR_MARK_OPT}'), ]: with self.subTest(f"p={raw_pattern!r}"): diff --git a/tests/test_08_gitignore_bracket_slash.py b/tests/test_08_gitignore_bracket_slash.py new file mode 100644 index 0000000..42399f2 --- /dev/null +++ b/tests/test_08_gitignore_bracket_slash.py @@ -0,0 +1,82 @@ +"""Test that bracket expressions only match within a path segment.""" + +import unittest +import warnings + +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 pathspec.patterns.gitwildmatch import GitWildMatchPattern + +from .util import require_backend + + +class BracketSlashTest(unittest.TestCase): + + def test_invalid_posix_after_range(self): + for factory in (GitIgnoreBasicPattern, GitIgnoreSpecPattern): + for line in ('[z-[:bogus:]]', '[z-[:^alpha:]]', '[.-[:bogus:]]'): + with self.subTest(factory=factory, line=line): + pattern = factory(line, errors='literal') + self.assertIsNotNone(pattern.match_file(line)) + self.assertIsNone(factory(line, errors='null').regex) + with self.assertRaises(GitIgnorePatternError): + factory(line, errors='raise') + + def test_pattern_separator(self): + for factory in (GitIgnoreBasicPattern, GitIgnoreSpecPattern, GitWildMatchPattern): + for bracket, matched in ( + ('[!x]', '.0'), + ('[^x]', '.0'), + ('[!]-]', '.0'), + ('[!-x]', '.0'), + ('[.-0]', '.0'), + (r'[\.-\0]', '.0'), + ('[!0-9]', '.x'), + ('[[:punct:]]', '.!'), + ('[[:graph:]]', '.0'), + ('[[:print:]]', '. '), + ('[[:digit:].]', '.0'), + ): + for as_bytes in (False, True): + with self.subTest(factory=factory, bracket=bracket, as_bytes=as_bytes): + line = 'a' + bracket + 'b' + with warnings.catch_warnings(): + warnings.simplefilter('ignore', DeprecationWarning) + pattern = factory(line.encode() if as_bytes else line) + for char in matched + '/': + path = 'a' + char + 'b' + self.assertEqual( + bool(pattern.match_file(path.encode() if as_bytes else path)), + char != '/', + path, + ) + + def test_spec_separator(self): + for backend in ('simple', 're2', 'hyperscan'): + with self.subTest(backend=backend): + require_backend(backend) + for line in ('a[!x]b', 'a[.-0]b', 'a[[:punct:]]b'): + with warnings.catch_warnings(): + warnings.simplefilter('ignore', DeprecationWarning) + specs = ( + GitIgnoreSpec.from_lines([line], backend=backend), + PathSpec.from_lines('gitignore', [line], backend=backend), + PathSpec.from_lines('gitwildmatch', [line], backend=backend), + ) + for spec in specs: + for prefix in ('', 'dir/'): + with self.subTest(line=line, spec=spec, prefix=prefix): + self.assertTrue(spec.match_file(prefix + 'a.b')) + self.assertFalse(spec.match_file(prefix + 'a/b')) + + def test_negated_rule(self): + for backend in ('simple', 're2', 'hyperscan'): + with self.subTest(backend=backend): + require_backend(backend) + for line in ('a[!x]b', 'a[.-0]b', 'a[[:punct:]]b'): + with self.subTest(line=line): + spec = GitIgnoreSpec.from_lines(['*', '!' + line], backend=backend) + self.assertFalse(spec.match_file('a.b')) + self.assertTrue(spec.match_file('a/b'))