Skip to content
Closed
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
24 changes: 24 additions & 0 deletions pathspec/patterns/gitignore/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 6 additions & 9 deletions pathspec/patterns/gitignore/basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
from .base import (
GitIgnorePatternError,
_BYTES_ENCODING,
_GitIgnoreBasePattern)
_GitIgnoreBasePattern,
trim_trailing_spaces)


class GitIgnoreBasicPattern(_GitIgnoreBasePattern):
Expand Down Expand Up @@ -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]
Expand Down
15 changes: 6 additions & 9 deletions pathspec/patterns/gitignore/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
GitIgnorePatternError,
_BYTES_ENCODING,
_GitIgnoreBasePattern,
_RangeError)
_RangeError,
trim_trailing_spaces)

_DIR_MARK = 'ps_d'
"""
Expand Down Expand Up @@ -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]
Expand Down
47 changes: 47 additions & 0 deletions tests/test_07_gitignore_escaped_trailing_spaces.py
Original file line number Diff line number Diff line change
@@ -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()