Skip to content
Open
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
46 changes: 38 additions & 8 deletions pathspec/patterns/gitignore/basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,38 @@
_GitIgnoreBasePattern)


def _trim_trailing_spaces(pattern: str) -> str:
"""
Remove the trailing spaces which are not escaped with a backslash
(r'\'). This emulates the *trim_trailing_spaces()* function in Git's
*dir.c*.

*pattern* (:class:`str`) is the gitignore pattern.

Returns the pattern with unescaped trailing spaces removed (:class:`str`).
A space is escaped only if it is preceded by an odd number of consecutive
backslashes. E.g., 'foo\\ ' has an escaped space, but 'foo\\\\ ' (an escaped
backslash followed by a space) does not.
"""
if pattern.endswith('\\ '):
# The pattern ends with a backslash followed by a space. The space is
# only escaped if it is preceded by an odd number of consecutive
# backslashes. With an even number, the backslashes escape each other
# and the trailing space is unescaped (Git strips it).
i = len(pattern) - 2
run = 0
while i >= 0 and pattern[i] == '\\':
run += 1
i -= 1

if run % 2 == 1:
# The trailing space is escaped. Keep the pattern as-is.
return pattern

# The trailing spaces (if any) are not escaped. Strip them.
return pattern.rstrip()


class GitIgnoreBasicPattern(_GitIgnoreBasePattern):
"""
The :class:`GitIgnoreBasicPattern` class represents a compiled gitignore
Expand Down Expand Up @@ -156,14 +188,12 @@ 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 they are escaped with a
# backslash ('\'). A space is only escaped if it is preceded by an odd
# number of consecutive backslashes; an escaped backslash ('\\') itself
# does not escape the space that follows it. Determine the longest run of
# unescaped trailing spaces, and strip only those. See _trim_trailing_spaces().
pattern_str = _trim_trailing_spaces(pattern_str)

regex: Optional[str]
include: Optional[bool]
Expand Down
16 changes: 8 additions & 8 deletions pathspec/patterns/gitignore/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
_BYTES_ENCODING,
_GitIgnoreBasePattern,
_RangeError)
from .basic import (
_trim_trailing_spaces)

_DIR_MARK = 'ps_d'
"""
Expand Down Expand Up @@ -190,14 +192,12 @@ 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 they are escaped with a
# backslash ('\'). A space is only escaped if it is preceded by an odd
# number of consecutive backslashes; an escaped backslash ('\\') itself
# does not escape the space that follows it. Determine the longest run of
# unescaped trailing spaces, and strip only those. See _trim_trailing_spaces().
pattern_str = _trim_trailing_spaces(pattern_str)

regex: Optional[str]
include: Optional[bool]
Expand Down
111 changes: 111 additions & 0 deletions tests/test_07_gitignore_trailing_space.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""
This script tests the trailing space trimming behavior for gitignore
patterns which end with a backslash followed by a space.

Git's *trim_trailing_spaces()* function (dir.c) only keeps a trailing
space when it is escaped by a backslash, and a space is only escaped when
it is preceded by an odd number of consecutive backslashes. When the run
of backslashes preceding the trailing space is even, the backslashes
escape each other and the trailing space is unescaped — Git strips it.

E.g., with an escaped backslash ('foo\\\\') followed by a space, Git
treats the space as unescaped trailing whitespace and matches the file
'foo\\' rather than 'foo\\ '.
"""

import unittest

from pathspec import (
PathSpec)
from pathspec.patterns.gitignore.basic import (
GitIgnoreBasicPattern)
from pathspec.patterns.gitignore.spec import (
GitIgnoreSpecPattern)

BS = '\\'
"""
Backslash.
"""


class TrailingSpaceAfterEscapedBackslashTest(unittest.TestCase):
"""
The :class:`TrailingSpaceAfterEscapedBackslashTest` class tests that a
trailing space preceded by an escaped backslash is stripped, matching
Git's behavior.
"""

def _assert_matches(self, pattern_class, raw_pattern: str, file: str, expected: bool) -> None:
pattern = pattern_class(raw_pattern)
actual = pattern.match_file(file)
self.assertIs(
actual is not None,
expected,
f"Pattern {raw_pattern!r} matching file {file!r}: expected {expected}, got {actual is not None} (regex: {pattern.regex.pattern if pattern.regex is not None else None})",
)

def test_00_even_backslash_run_trailing_space_stripped(self):
"""
Tests that a trailing space preceded by an even number of
backslashes is unescaped and stripped.
"""
# 'foo\\ ' (escaped backslash followed by space): Git strips the
# unescaped trailing space and matches the file 'foo\'.
for pattern_class in (GitIgnoreBasicPattern, GitIgnoreSpecPattern):
with self.subTest(pattern_class=pattern_class.__name__):
self._assert_matches(pattern_class, f'foo{BS * 2} ', f'foo{BS}', True)
self._assert_matches(pattern_class, f'foo{BS * 2} ', f'foo{BS} ', False)

def test_01_four_backslashes_trailing_space_stripped(self):
"""
Tests that a trailing space preceded by four backslashes (two
escaped backslashes) is unescaped and stripped.
"""
for pattern_class in (GitIgnoreBasicPattern, GitIgnoreSpecPattern):
with self.subTest(pattern_class=pattern_class.__name__):
self._assert_matches(pattern_class, f'foo{BS * 4} ', f'foo{BS * 2}', True)
self._assert_matches(pattern_class, f'foo{BS * 4} ', f'foo{BS * 2} ', False)

def test_02_odd_backslash_run_trailing_space_kept(self):
"""
Tests that a trailing space preceded by an odd number of
backslashes is escaped and kept.
"""
# 'foo\ ' has an escaped space and matches the file 'foo '.
for pattern_class in (GitIgnoreBasicPattern, GitIgnoreSpecPattern):
with self.subTest(pattern_class=pattern_class.__name__):
self._assert_matches(pattern_class, f'foo{BS} ', f'foo ', True)
self._assert_matches(pattern_class, f'foo{BS} ', f'foo', False)

def test_03_three_backslashes_trailing_space_kept(self):
"""
Tests that a trailing space preceded by three backslashes (an
escaped backslash followed by an escaped space) is kept.
"""
for pattern_class in (GitIgnoreBasicPattern, GitIgnoreSpecPattern):
with self.subTest(pattern_class=pattern_class.__name__):
self._assert_matches(pattern_class, f'foo{BS * 3} ', f'foo{BS} ', True)
self._assert_matches(pattern_class, f'foo{BS * 3} ', f'foo{BS}', False)

def test_04_regex_normalization(self):
"""
Tests the compiled regular expressions directly.
"""
# With an even run, the trailing space must be stripped before
# compiling: 'foo\\ ' compiles like 'foo\\'.
pattern = GitIgnoreBasicPattern(f'foo{BS * 2} ')
self.assertEqual(pattern.regex.pattern, GitIgnoreBasicPattern(f'foo{BS * 2}').regex.pattern)

def test_05_pathspec_end_to_end(self):
"""
Tests the behavior through the PathSpec interface.
"""
spec = PathSpec.from_lines('gitignore', [f'foo{BS * 2} '])
# These POSIX-style paths contain literal backslashes. Keep Windows
# from normalizing those characters into directory separators.
self.assertIs(spec.match_file(f'foo{BS}', separators=('/',)), True)
self.assertIs(spec.match_file(f'foo{BS} ', separators=('/',)), False)

spec = PathSpec.from_lines('gitignore', [f'foo{BS} '])
self.assertIs(spec.match_file('foo '), True)
self.assertIs(spec.match_file('foo'), False)