From 79895b7157710ecb6a595b860a20600646d06cac Mon Sep 17 00:00:00 2001 From: Gyanu Mayank Date: Fri, 9 Oct 2026 20:23:48 +0530 Subject: [PATCH] Handle backslash escapes in gitignore bracket expressions --- pathspec/patterns/gitignore/base.py | 41 ++++++++++++++++++++++++----- tests/test_03_gitignore_basic.py | 20 ++++++++++++++ tests/test_04_gitignore_spec.py | 26 ++++++++++++++++++ 3 files changed, 81 insertions(+), 6 deletions(-) diff --git a/pathspec/patterns/gitignore/base.py b/pathspec/patterns/gitignore/base.py index 135a66c..d7f670f 100644 --- a/pathspec/patterns/gitignore/base.py +++ b/pathspec/patterns/gitignore/base.py @@ -49,6 +49,15 @@ group 2 captures the class name. """ +_BRACKET_TOKEN_REGEX = re.compile( + _POSIX_CLASS_REGEX.pattern + r'|\\(.)', re.DOTALL, +) +""" +Matches either a POSIX bracket character class token (groups 1 and 2, see +:data:`_POSIX_CLASS_REGEX`) or a backslash escaped character (group 3) inside a +bracket expression. +""" + def _strip_trailing_ws(pattern: str) -> str: """ @@ -101,6 +110,20 @@ class name, matching git's treatment of it as a malformed pattern. return class_regex +def _translate_bracket_token(match: 're.Match') -> str: + """ + Translate a single token matched by :data:`_BRACKET_TOKEN_REGEX`. A backslash + escaped character becomes that literal character, the same as git's + wildmatch. A POSIX character class is translated by + :func:`_translate_posix_class`. + """ + escaped = match.group(3) + if escaped is not None: + return re.escape(escaped) + + return _translate_posix_class(match) + + class _GitIgnoreBasePattern(RegexPattern): """ .. warning:: This class is not part of the public API. It is subject to @@ -262,7 +285,11 @@ def _translate_segment_glob( # Find closing bracket. Stop once we reach the end or find it. while j < end and pattern[j] != ']': - if pattern[j] == '[' and j + 1 < end and pattern[j + 1] == ':': + if pattern[j] == '\\': + # Skip over an escaped character so an escaped closing bracket + # ("\]") does not end the bracket expression. + j += 2 + elif pattern[j] == '[' and j + 1 < end and pattern[j + 1] == ':': # Skip over a POSIX character class token ("[:name:]") so its # internal closing bracket is not mistaken for the end of the whole # bracket expression. @@ -298,17 +325,19 @@ def _translate_segment_glob( expr += '^' i += 1 - # Build regex bracket expression. Escape slashes so they are treated - # as literal slashes by regex as defined by POSIX. - body = pattern[i:j].replace('\\', '\\\\') - + # Build regex bracket expression. A backslash escapes the next + # character (e.g. "[\]]" matches ']'), the same as outside of a bracket + # expression. + # # Translate POSIX character classes (e.g. "[:alpha:]") into their # ASCII regex equivalents. Git's wildmatch supports these, but # Python's `re` does not, so passing them through verbatim builds a # broken regex that silently mismatches (and warns about a nested # set). try: - body = _POSIX_CLASS_REGEX.sub(_translate_posix_class, body) + body = _BRACKET_TOKEN_REGEX.sub( + _translate_bracket_token, pattern[i:j], + ) except _PosixClassError: if errors == 'raise': # EDGE CASE: Git discards patterns with an invalid range notation diff --git a/tests/test_03_gitignore_basic.py b/tests/test_03_gitignore_basic.py index a9c7f99..868c667 100644 --- a/tests/test_03_gitignore_basic.py +++ b/tests/test_03_gitignore_basic.py @@ -743,6 +743,26 @@ def test_13_globstars_match_newlines(self): with self.subTest(pattern=pattern, path=path): self.assertIsNotNone(GitIgnoreBasicPattern(pattern).match_file(path)) + def test_14_bracket_escape(self): + """ + Test that a backslash inside a bracket expression escapes the next + character. + """ + for raw_pattern, positives, negatives in [ + ('[a\\-c]', ['a', '-', 'c'], ['b', '\\']), + ('[\\]]', [']'], ['\\', '\\]']), + ('[a\\]]', ['a', ']'], ['\\', '\\]']), + ('[\\!a]', ['!', 'a'], ['\\']), + ('[\\\\]', ['\\'], ['a']), + ]: + pattern = GitIgnoreBasicPattern(raw_pattern) + for path in positives: + with self.subTest(f"p={raw_pattern!r} match {path!r}"): + self.assertIsNotNone(pattern.match_file(path)) + for path in negatives: + with self.subTest(f"p={raw_pattern!r} no-match {path!r}"): + self.assertIsNone(pattern.match_file(path)) + class GitIgnoreBasicPatternIssue19Test(TestCase): """ diff --git a/tests/test_04_gitignore_spec.py b/tests/test_04_gitignore_spec.py index aed3f36..7169380 100644 --- a/tests/test_04_gitignore_spec.py +++ b/tests/test_04_gitignore_spec.py @@ -865,6 +865,32 @@ def test_12_posix_class_e_invalid(self): self.assertIs(pattern.include, None) self.assertIs(pattern.regex, None) + def test_13_bracket_escape(self): + """ + Test that a backslash inside a bracket expression escapes the next + character, matching git's ``check-ignore``. + """ + for raw_pattern, positives, negatives in [ + ('[a\\-c]', ['a', '-', 'c'], ['b', '\\']), + ('[\\]]', [']'], ['\\', '\\]']), + ('[\\]a]', [']', 'a'], ['\\', '\\]a']), + ('[a\\]]', ['a', ']'], ['\\', '\\]']), + ('[!\\]]', ['a', '\\'], [']']), + ('[\\!a]', ['!', 'a'], ['\\']), + ('[\\^a]', ['^', 'a'], ['\\', 'b']), + ('[\\n]', ['n'], ['\\', '\n']), + ('[\\\\]', ['\\'], ['a']), + ('[\\[:alpha:]]', ['[]', ':]', 'a]'], ['a', 'b', '\\']), + ('[[:alpha:]\\]]', ['a', ']'], ['5', '\\']), + ]: + pattern = GitIgnoreSpecPattern(raw_pattern) + for path in positives: + with self.subTest(f"p={raw_pattern!r} match {path!r}"): + self.assertTrue(pattern.match_file(path)) + for path in negatives: + with self.subTest(f"p={raw_pattern!r} no-match {path!r}"): + self.assertFalse(pattern.match_file(path)) + class GitIgnoreSpecPatternIssue19Test(TestCase): """