From 72d1a289f979ece35858845d867cfa05d8dc6e6d Mon Sep 17 00:00:00 2001 From: Cohen Karnell <7363269+ckarnell@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:14:07 -0400 Subject: [PATCH] Escape trailing spaces in GitIgnoreSpecPattern.escape() escape() escaped \ [ ] ! * # ? but not trailing spaces. Git strips unescaped trailing spaces from a pattern, and the parser here already relies on that: pattern_to_regex keeps trailing spaces only when the pattern ends with an escaped space, otherwise it rstrips them. So an escaped filename that ended with a space no longer matched that file, e.g. escape("foo ") produced "foo " which the parser reduced to "foo". Escape each trailing space (space only, matching git's behavior) so the escaped pattern round-trips. Two existing char-map tests asserted that a lone space escapes to itself, which is the same round-trip gap; updated them to expect an escaped space, and added a round-trip test. --- pathspec/patterns/gitignore/base.py | 8 ++++++++ tests/test_02_gitignore_base.py | 4 ++-- tests/test_04_gitignore_spec.py | 12 ++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/pathspec/patterns/gitignore/base.py b/pathspec/patterns/gitignore/base.py index 7373a4e..02cdb42 100644 --- a/pathspec/patterns/gitignore/base.py +++ b/pathspec/patterns/gitignore/base.py @@ -105,6 +105,14 @@ def escape(s: AnyStr) -> AnyStr: # Reference: https://git-scm.com/docs/gitignore#_pattern_format out_string = ''.join((f"\\{x}" if x in '\\[]!*#?' else x) for x in string) + # EDGE CASE: Git strips trailing spaces from a pattern unless they are + # escaped with a backslash. Escape them so an escaped filename that ends + # with a space still matches that file. + stripped = out_string.rstrip(' ') + trailing = len(out_string) - len(stripped) + if trailing: + out_string = stripped + '\\ ' * trailing + if return_type is bytes: out_bytes = out_string.encode(_BYTES_ENCODING) return out_bytes # type: ignore[return-value] diff --git a/tests/test_02_gitignore_base.py b/tests/test_02_gitignore_base.py index f315194..3612b65 100644 --- a/tests/test_02_gitignore_base.py +++ b/tests/test_02_gitignore_base.py @@ -20,7 +20,7 @@ def test_01_escape_bytes(self): Test escaping binary strings. """ byte_to_escaped = {__b: b'\\' + __b for __b in ( - __c.encode(_BYTES_ENCODING) for __c in '\\[]!*#?' + __c.encode(_BYTES_ENCODING) for __c in '\\[]!*#? ' )} for char_ord in range(256): char_byte = chr(char_ord).encode(_BYTES_ENCODING) @@ -32,7 +32,7 @@ def test_01_escape_str(self): """ Test escaping unicode strings. """ - char_to_escaped = {__c: f"\\{__c}" for __c in '\\[]!*#?'} + char_to_escaped = {__c: f"\\{__c}" for __c in '\\[]!*#? '} for char_ord in range(128): char = chr(char_ord) escape_val = _GitIgnoreBasePattern.escape(char) diff --git a/tests/test_04_gitignore_spec.py b/tests/test_04_gitignore_spec.py index 9851c60..618f658 100644 --- a/tests/test_04_gitignore_spec.py +++ b/tests/test_04_gitignore_spec.py @@ -621,6 +621,18 @@ def test_08_escape(self): result = GitIgnoreSpecPattern.escape(fname) self.assertEqual(result, escaped) + def test_08_escape_trailing_space(self): + """ + Test that escaping a filename with a trailing space keeps the space, so + the escaped pattern still matches the file. Git strips unescaped trailing + spaces from a pattern. + """ + for fname in ['foo ', 'trailing ', ' ']: + escaped = GitIgnoreSpecPattern.escape(fname) + self.assertTrue(escaped.endswith('\\ '), (fname, escaped)) + pattern = GitIgnoreSpecPattern(escaped) + self.assertEqual(set(filter(pattern.match_file, [fname])), {fname}, (fname, escaped)) + def test_09_single_escape_fail(self): """ Test an escape on a line by itself.