diff --git a/pathspec/patterns/gitignore/base.py b/pathspec/patterns/gitignore/base.py index 02cdb42..f09fede 100644 --- a/pathspec/patterns/gitignore/base.py +++ b/pathspec/patterns/gitignore/base.py @@ -296,6 +296,30 @@ def _translate_segment_glob( return regex + +def trim_trailing_spaces(pattern: str) -> str: + """ + Remove trailing spaces that are not escaped with a backslash. + + This matches Git's *trim_trailing_spaces()* in *dir.c*: a trailing space is + kept only when preceded by an odd number of consecutive backslashes. Extra + unescaped spaces after an escaped space are still stripped, and an even + backslash run does not escape the following space. + """ + while pattern.endswith(' '): + # Count consecutive backslashes immediately before this trailing space. + i = len(pattern) - 2 + run = 0 + while i >= 0 and pattern[i] == '\\': + run += 1 + i -= 1 + if run % 2 == 1: + # Odd run → the space is escaped; stop trimming. + break + pattern = pattern[:-1] + return pattern + + class GitIgnorePatternError(ValueError): """ The :class:`GitIgnorePatternError` class indicates an invalid gitignore diff --git a/pathspec/patterns/gitignore/basic.py b/pathspec/patterns/gitignore/basic.py index 44b2400..bac63eb 100644 --- a/pathspec/patterns/gitignore/basic.py +++ b/pathspec/patterns/gitignore/basic.py @@ -18,7 +18,8 @@ from .base import ( GitIgnorePatternError, _BYTES_ENCODING, - _GitIgnoreBasePattern) + _GitIgnoreBasePattern, + trim_trailing_spaces) class GitIgnoreBasicPattern(_GitIgnoreBasePattern): @@ -156,14 +157,10 @@ def pattern_to_regex( original_pattern = pattern_str del pattern - if pattern_str.endswith('\\ '): - # EDGE CASE: Spaces can be escaped with backslash. If a pattern that ends - # with a backslash is followed by a space, do not strip from the left. - pass - else: - # EDGE CASE: Leading spaces should be kept (only trailing spaces should be - # removed). - pattern_str = pattern_str.rstrip() + # EDGE CASE: Trailing spaces are stripped unless escaped with a backslash. + # A space is escaped only when preceded by an odd number of consecutive + # backslashes (Git dir.c trim_trailing_spaces). Leading spaces are kept. + pattern_str = trim_trailing_spaces(pattern_str) regex: Optional[str] include: Optional[bool] diff --git a/pathspec/patterns/gitignore/spec.py b/pathspec/patterns/gitignore/spec.py index f8bd159..ccf8f52 100644 --- a/pathspec/patterns/gitignore/spec.py +++ b/pathspec/patterns/gitignore/spec.py @@ -21,7 +21,8 @@ GitIgnorePatternError, _BYTES_ENCODING, _GitIgnoreBasePattern, - _RangeError) + _RangeError, + trim_trailing_spaces) _DIR_MARK = 'ps_d' """ @@ -190,14 +191,10 @@ def pattern_to_regex( original_pattern = pattern_str del pattern - if pattern_str.endswith('\\ '): - # EDGE CASE: Spaces can be escaped with backslash. If a pattern that ends - # with a backslash is followed by a space, do not strip from the left. - pass - else: - # EDGE CASE: Leading spaces should be kept (only trailing spaces should be - # removed). Git does not remove leading spaces. - pattern_str = pattern_str.rstrip() + # EDGE CASE: Trailing spaces are stripped unless escaped with a backslash. + # A space is escaped only when preceded by an odd number of consecutive + # backslashes (Git dir.c trim_trailing_spaces). Leading spaces are kept. + pattern_str = trim_trailing_spaces(pattern_str) regex: Optional[str] include: Optional[bool] diff --git a/tests/test_07_gitignore_escaped_trailing_spaces.py b/tests/test_07_gitignore_escaped_trailing_spaces.py new file mode 100644 index 0000000..c242a0c --- /dev/null +++ b/tests/test_07_gitignore_escaped_trailing_spaces.py @@ -0,0 +1,47 @@ +"""Regression: gitignore trailing-space trimming must match Git dir.c.""" + +import unittest + +from pathspec import PathSpec +from pathspec.patterns.gitignore.base import trim_trailing_spaces +from pathspec.patterns.gitignore.basic import GitIgnoreBasicPattern +from pathspec.patterns.gitignore.spec import GitIgnoreSpecPattern + + +class TrimTrailingSpacesTest(unittest.TestCase): + def test_extra_spaces_after_escaped_space(self): + # Git: 'bar\\ ' (backslash, space, space) → keep escaped space only. + self.assertEqual(trim_trailing_spaces('bar\\ '), 'bar\\ ') + self.assertEqual(trim_trailing_spaces('bar\\ '), 'bar\\ ') + + def test_even_backslash_run_strips_space(self): + # Even backslashes: space is unescaped and must be stripped. + self.assertEqual(trim_trailing_spaces('foo\\\\ '), 'foo\\\\') + self.assertEqual(trim_trailing_spaces('foo\\\\\\\\ '), 'foo\\\\\\\\') + + def test_odd_backslash_run_keeps_space(self): + self.assertEqual(trim_trailing_spaces('foo\\ '), 'foo\\ ') + self.assertEqual(trim_trailing_spaces('foo\\\\\\ '), 'foo\\\\\\ ') + + def test_plain_trailing_spaces(self): + self.assertEqual(trim_trailing_spaces('foo '), 'foo') + self.assertEqual(trim_trailing_spaces('foo'), 'foo') + + +class PathSpecEscapedTrailingSpaceTest(unittest.TestCase): + def test_pathspec_matches_file_with_trailing_space(self): + # Previously raised GitIgnorePatternError (dangling backslash). + spec = PathSpec.from_lines('gitwildmatch', ['bar\\ ']) + self.assertFalse(spec.match_file('bar')) + self.assertTrue(spec.match_file('bar ')) + self.assertFalse(spec.match_file('bar ')) + + def test_basic_and_spec_pattern_classes(self): + for cls in (GitIgnoreBasicPattern, GitIgnoreSpecPattern): + pattern = cls('bar\\ ') + self.assertTrue(pattern.include) + self.assertIsNotNone(pattern.regex) + + +if __name__ == '__main__': + unittest.main()