Skip to content

Commit 3d23757

Browse files
authored
Merge pull request #142 from Str0k/githubpower/t_01b4cd93
Fix trailing-space trimming after escaped backslashes
2 parents d7b1505 + 73120f3 commit 3d23757

3 files changed

Lines changed: 157 additions & 16 deletions

File tree

pathspec/patterns/gitignore/basic.py

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,38 @@
2121
_GitIgnoreBasePattern)
2222

2323

24+
def _trim_trailing_spaces(pattern: str) -> str:
25+
"""
26+
Remove the trailing spaces which are not escaped with a backslash
27+
(r'\'). This emulates the *trim_trailing_spaces()* function in Git's
28+
*dir.c*.
29+
30+
*pattern* (:class:`str`) is the gitignore pattern.
31+
32+
Returns the pattern with unescaped trailing spaces removed (:class:`str`).
33+
A space is escaped only if it is preceded by an odd number of consecutive
34+
backslashes. E.g., 'foo\\ ' has an escaped space, but 'foo\\\\ ' (an escaped
35+
backslash followed by a space) does not.
36+
"""
37+
if pattern.endswith('\\ '):
38+
# The pattern ends with a backslash followed by a space. The space is
39+
# only escaped if it is preceded by an odd number of consecutive
40+
# backslashes. With an even number, the backslashes escape each other
41+
# and the trailing space is unescaped (Git strips it).
42+
i = len(pattern) - 2
43+
run = 0
44+
while i >= 0 and pattern[i] == '\\':
45+
run += 1
46+
i -= 1
47+
48+
if run % 2 == 1:
49+
# The trailing space is escaped. Keep the pattern as-is.
50+
return pattern
51+
52+
# The trailing spaces (if any) are not escaped. Strip them.
53+
return pattern.rstrip()
54+
55+
2456
class GitIgnoreBasicPattern(_GitIgnoreBasePattern):
2557
"""
2658
The :class:`GitIgnoreBasicPattern` class represents a compiled gitignore
@@ -156,14 +188,12 @@ def pattern_to_regex(
156188
original_pattern = pattern_str
157189
del pattern
158190

159-
if pattern_str.endswith('\\ '):
160-
# EDGE CASE: Spaces can be escaped with backslash. If a pattern that ends
161-
# with a backslash is followed by a space, do not strip from the left.
162-
pass
163-
else:
164-
# EDGE CASE: Leading spaces should be kept (only trailing spaces should be
165-
# removed).
166-
pattern_str = pattern_str.rstrip()
191+
# EDGE CASE: Trailing spaces are stripped unless they are escaped with a
192+
# backslash ('\'). A space is only escaped if it is preceded by an odd
193+
# number of consecutive backslashes; an escaped backslash ('\\') itself
194+
# does not escape the space that follows it. Determine the longest run of
195+
# unescaped trailing spaces, and strip only those. See _trim_trailing_spaces().
196+
pattern_str = _trim_trailing_spaces(pattern_str)
167197

168198
regex: Optional[str]
169199
include: Optional[bool]

pathspec/patterns/gitignore/spec.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
_BYTES_ENCODING,
2323
_GitIgnoreBasePattern,
2424
_RangeError)
25+
from .basic import (
26+
_trim_trailing_spaces)
2527

2628
_DIR_MARK = 'ps_d'
2729
"""
@@ -190,14 +192,12 @@ def pattern_to_regex(
190192
original_pattern = pattern_str
191193
del pattern
192194

193-
if pattern_str.endswith('\\ '):
194-
# EDGE CASE: Spaces can be escaped with backslash. If a pattern that ends
195-
# with a backslash is followed by a space, do not strip from the left.
196-
pass
197-
else:
198-
# EDGE CASE: Leading spaces should be kept (only trailing spaces should be
199-
# removed). Git does not remove leading spaces.
200-
pattern_str = pattern_str.rstrip()
195+
# EDGE CASE: Trailing spaces are stripped unless they are escaped with a
196+
# backslash ('\'). A space is only escaped if it is preceded by an odd
197+
# number of consecutive backslashes; an escaped backslash ('\\') itself
198+
# does not escape the space that follows it. Determine the longest run of
199+
# unescaped trailing spaces, and strip only those. See _trim_trailing_spaces().
200+
pattern_str = _trim_trailing_spaces(pattern_str)
201201

202202
regex: Optional[str]
203203
include: Optional[bool]
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""
2+
This script tests the trailing space trimming behavior for gitignore
3+
patterns which end with a backslash followed by a space.
4+
5+
Git's *trim_trailing_spaces()* function (dir.c) only keeps a trailing
6+
space when it is escaped by a backslash, and a space is only escaped when
7+
it is preceded by an odd number of consecutive backslashes. When the run
8+
of backslashes preceding the trailing space is even, the backslashes
9+
escape each other and the trailing space is unescaped — Git strips it.
10+
11+
E.g., with an escaped backslash ('foo\\\\') followed by a space, Git
12+
treats the space as unescaped trailing whitespace and matches the file
13+
'foo\\' rather than 'foo\\ '.
14+
"""
15+
16+
import unittest
17+
18+
from pathspec import (
19+
PathSpec)
20+
from pathspec.patterns.gitignore.basic import (
21+
GitIgnoreBasicPattern)
22+
from pathspec.patterns.gitignore.spec import (
23+
GitIgnoreSpecPattern)
24+
25+
BS = '\\'
26+
"""
27+
Backslash.
28+
"""
29+
30+
31+
class TrailingSpaceAfterEscapedBackslashTest(unittest.TestCase):
32+
"""
33+
The :class:`TrailingSpaceAfterEscapedBackslashTest` class tests that a
34+
trailing space preceded by an escaped backslash is stripped, matching
35+
Git's behavior.
36+
"""
37+
38+
def _assert_matches(self, pattern_class, raw_pattern: str, file: str, expected: bool) -> None:
39+
pattern = pattern_class(raw_pattern)
40+
actual = pattern.match_file(file)
41+
self.assertIs(
42+
actual is not None,
43+
expected,
44+
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})",
45+
)
46+
47+
def test_00_even_backslash_run_trailing_space_stripped(self):
48+
"""
49+
Tests that a trailing space preceded by an even number of
50+
backslashes is unescaped and stripped.
51+
"""
52+
# 'foo\\ ' (escaped backslash followed by space): Git strips the
53+
# unescaped trailing space and matches the file 'foo\'.
54+
for pattern_class in (GitIgnoreBasicPattern, GitIgnoreSpecPattern):
55+
with self.subTest(pattern_class=pattern_class.__name__):
56+
self._assert_matches(pattern_class, f'foo{BS * 2} ', f'foo{BS}', True)
57+
self._assert_matches(pattern_class, f'foo{BS * 2} ', f'foo{BS} ', False)
58+
59+
def test_01_four_backslashes_trailing_space_stripped(self):
60+
"""
61+
Tests that a trailing space preceded by four backslashes (two
62+
escaped backslashes) is unescaped and stripped.
63+
"""
64+
for pattern_class in (GitIgnoreBasicPattern, GitIgnoreSpecPattern):
65+
with self.subTest(pattern_class=pattern_class.__name__):
66+
self._assert_matches(pattern_class, f'foo{BS * 4} ', f'foo{BS * 2}', True)
67+
self._assert_matches(pattern_class, f'foo{BS * 4} ', f'foo{BS * 2} ', False)
68+
69+
def test_02_odd_backslash_run_trailing_space_kept(self):
70+
"""
71+
Tests that a trailing space preceded by an odd number of
72+
backslashes is escaped and kept.
73+
"""
74+
# 'foo\ ' has an escaped space and matches the file 'foo '.
75+
for pattern_class in (GitIgnoreBasicPattern, GitIgnoreSpecPattern):
76+
with self.subTest(pattern_class=pattern_class.__name__):
77+
self._assert_matches(pattern_class, f'foo{BS} ', f'foo ', True)
78+
self._assert_matches(pattern_class, f'foo{BS} ', f'foo', False)
79+
80+
def test_03_three_backslashes_trailing_space_kept(self):
81+
"""
82+
Tests that a trailing space preceded by three backslashes (an
83+
escaped backslash followed by an escaped space) is kept.
84+
"""
85+
for pattern_class in (GitIgnoreBasicPattern, GitIgnoreSpecPattern):
86+
with self.subTest(pattern_class=pattern_class.__name__):
87+
self._assert_matches(pattern_class, f'foo{BS * 3} ', f'foo{BS} ', True)
88+
self._assert_matches(pattern_class, f'foo{BS * 3} ', f'foo{BS}', False)
89+
90+
def test_04_regex_normalization(self):
91+
"""
92+
Tests the compiled regular expressions directly.
93+
"""
94+
# With an even run, the trailing space must be stripped before
95+
# compiling: 'foo\\ ' compiles like 'foo\\'.
96+
pattern = GitIgnoreBasicPattern(f'foo{BS * 2} ')
97+
self.assertEqual(pattern.regex.pattern, GitIgnoreBasicPattern(f'foo{BS * 2}').regex.pattern)
98+
99+
def test_05_pathspec_end_to_end(self):
100+
"""
101+
Tests the behavior through the PathSpec interface.
102+
"""
103+
spec = PathSpec.from_lines('gitignore', [f'foo{BS * 2} '])
104+
# These POSIX-style paths contain literal backslashes. Keep Windows
105+
# from normalizing those characters into directory separators.
106+
self.assertIs(spec.match_file(f'foo{BS}', separators=('/',)), True)
107+
self.assertIs(spec.match_file(f'foo{BS} ', separators=('/',)), False)
108+
109+
spec = PathSpec.from_lines('gitignore', [f'foo{BS} '])
110+
self.assertIs(spec.match_file('foo '), True)
111+
self.assertIs(spec.match_file('foo'), False)

0 commit comments

Comments
 (0)