From c17f09d0ad7976e953031549bb29db2ee2eae530 Mon Sep 17 00:00:00 2001 From: kokotatan Date: Sat, 3 Oct 2026 20:45:56 +0900 Subject: [PATCH 1/2] Match gitignore filenames at the strict end of the path --- CHANGES_1.in.rst | 3 + pathspec/_backends/_utils.py | 25 ++++++++- pathspec/_backends/hyperscan/gitignore.py | 5 +- pathspec/_backends/hyperscan/pathspec.py | 5 +- pathspec/_backends/re2/gitignore.py | 3 + pathspec/_backends/re2/pathspec.py | 5 +- pathspec/patterns/gitignore/basic.py | 4 +- pathspec/patterns/gitignore/spec.py | 2 +- tests/test_03_gitignore_basic.py | 4 +- tests/test_07_gitignore_end_anchor.py | 67 +++++++++++++++++++++++ 10 files changed, 112 insertions(+), 11 deletions(-) create mode 100644 tests/test_07_gitignore_end_anchor.py diff --git a/CHANGES_1.in.rst b/CHANGES_1.in.rst index c1d1769..0b04836 100644 --- a/CHANGES_1.in.rst +++ b/CHANGES_1.in.rst @@ -17,6 +17,9 @@ New features: Bug fixes: +- Anchor gitignore filename matches at the actual end of the path, so a terminal + newline is not mistaken for the end of a filename. + - `Pull #123`_: Ignore invalid gitignore bracket ranges for `GitIgnoreSpec`. - `Pull #128`_: Support POSIX character classes (e.g. `[[:alpha:]]`) in gitignore bracket expressions. - `Issue #129`_ / `Pull #132`_: Fix GitIgnoreSpec re-including files under an excluded directory diff --git a/pathspec/_backends/_utils.py b/pathspec/_backends/_utils.py index 77c7cd9..13a3fc6 100644 --- a/pathspec/_backends/_utils.py +++ b/pathspec/_backends/_utils.py @@ -5,10 +5,13 @@ contents and structure are likely to change. """ +import re from collections.abc import ( Iterable) from typing import ( - TypeVar) + TypeVar, + Union, + overload) from pathspec.pattern import ( Pattern) @@ -16,6 +19,26 @@ TPattern = TypeVar("TPattern", bound=Pattern) +@overload +def translate_end_anchor(regex: str) -> str: ... + + +@overload +def translate_end_anchor(regex: bytes) -> bytes: ... + + +def translate_end_anchor(regex: Union[str, bytes]) -> Union[str, bytes]: + """ + Translate Python's strict end anchor to the RE2 and Hyperscan spelling. + + Escaped backslashes are consumed as pairs, keeping literal ``\\Z`` names intact. + """ + if isinstance(regex, bytes): + return re.sub(rb'\\.', lambda match: rb'\z' if match[0] == rb'\Z' else match[0], regex) + else: + return re.sub(r'\\.', lambda match: r'\z' if match[0] == r'\Z' else match[0], regex) + + def enumerate_patterns( patterns: Iterable[TPattern], filter: bool, diff --git a/pathspec/_backends/hyperscan/gitignore.py b/pathspec/_backends/hyperscan/gitignore.py index 8d3c323..df41432 100644 --- a/pathspec/_backends/hyperscan/gitignore.py +++ b/pathspec/_backends/hyperscan/gitignore.py @@ -33,6 +33,8 @@ HS_FLAGS, HyperscanExprDat, HyperscanExprDebug) +from .._utils import ( + translate_end_anchor) from .pathspec import ( HyperscanPsBackend) @@ -134,7 +136,7 @@ def _init_db( # and file variants. base_regex = regex_str[:-len(_DIR_MARK_OPT)] use_regexes.append((f'{base_regex}/', True)) - use_regexes.append((f'{base_regex}$', False)) + use_regexes.append((rf'{base_regex}\z', False)) else: # Remove capture group. base_regex = regex_str.replace(_DIR_MARK_CG, '/') @@ -145,6 +147,7 @@ def _init_db( use_regexes.append((regex, False)) for regex, is_dir_pattern in use_regexes: + regex = translate_end_anchor(regex) if isinstance(regex, bytes): regex_bytes = regex else: diff --git a/pathspec/_backends/hyperscan/pathspec.py b/pathspec/_backends/hyperscan/pathspec.py index f7a8ddc..8f02269 100644 --- a/pathspec/_backends/hyperscan/pathspec.py +++ b/pathspec/_backends/hyperscan/pathspec.py @@ -26,7 +26,8 @@ override) # Added in 3.12. from .._utils import ( - enumerate_patterns) + enumerate_patterns, + translate_end_anchor) from .base import ( hyperscan_error) @@ -152,7 +153,7 @@ def _init_db( # Encode regex. assert isinstance(pattern, RegexPattern), pattern - regex = pattern.regex.pattern + regex = translate_end_anchor(pattern.regex.pattern) if isinstance(regex, bytes): regex_bytes = regex diff --git a/pathspec/_backends/re2/gitignore.py b/pathspec/_backends/re2/gitignore.py index 987c1e6..ab65b87 100644 --- a/pathspec/_backends/re2/gitignore.py +++ b/pathspec/_backends/re2/gitignore.py @@ -29,6 +29,8 @@ from ._base import ( Re2RegexDat, Re2RegexDebug) +from .._utils import ( + translate_end_anchor) from .pathspec import ( Re2PsBackend) @@ -111,6 +113,7 @@ def _init_set( use_regexes.append((regex, False)) for regex, is_dir_pattern in use_regexes: + regex = translate_end_anchor(regex) if debug: regex_data.append(Re2RegexDebug( include=pattern.include, diff --git a/pathspec/_backends/re2/pathspec.py b/pathspec/_backends/re2/pathspec.py index ca994b5..1e565d6 100644 --- a/pathspec/_backends/re2/pathspec.py +++ b/pathspec/_backends/re2/pathspec.py @@ -25,7 +25,8 @@ override) # Added in 3.12. from .._utils import ( - enumerate_patterns) + enumerate_patterns, + translate_end_anchor) from .base import ( re2_error) @@ -132,7 +133,7 @@ def _init_set( assert pattern.regex is not None, pattern assert isinstance(pattern, RegexPattern), pattern - regex = pattern.regex.pattern + regex = translate_end_anchor(pattern.regex.pattern) if debug: regex_data.append(Re2RegexDebug( diff --git a/pathspec/patterns/gitignore/basic.py b/pathspec/patterns/gitignore/basic.py index f396a71..f86a15a 100644 --- a/pathspec/patterns/gitignore/basic.py +++ b/pathspec/patterns/gitignore/basic.py @@ -374,13 +374,13 @@ def __translate_segments( # A pattern ending with an asterisk ('*') will match a file or # directory (without matching descendant paths). E.g., "foo/*" # matches "foo/test.json", "foo/bar/", but not "foo/bar/hello.c". - out_parts.append('/?$') + out_parts.append(r'/?\Z') else: # A pattern ending without a slash ('/') will match a file or a # directory (with paths underneath it). E.g., "foo" matches "foo", # "foo/bar", "foo/bar/baz", etc. - out_parts.append('(?:/|$)') + out_parts.append(r'(?:/|\Z)') need_slash = True diff --git a/pathspec/patterns/gitignore/spec.py b/pathspec/patterns/gitignore/spec.py index a443868..3fccf2f 100644 --- a/pathspec/patterns/gitignore/spec.py +++ b/pathspec/patterns/gitignore/spec.py @@ -38,7 +38,7 @@ This regular expression matches the directory marker. """ -_DIR_MARK_OPT = f'(?:{_DIR_MARK_CG}|$)' +_DIR_MARK_OPT = rf'(?:{_DIR_MARK_CG}|\Z)' """ This regular expression matches the optional directory marker and sub-path. """ diff --git a/tests/test_03_gitignore_basic.py b/tests/test_03_gitignore_basic.py index 9cd5f48..8685fdf 100644 --- a/tests/test_03_gitignore_basic.py +++ b/tests/test_03_gitignore_basic.py @@ -17,7 +17,7 @@ from pathspec.util import ( lookup_pattern) -_DIR_OPT = '(?:/|$)' +_DIR_OPT = r'(?:/|\Z)' """ Optional directory ending. """ @@ -861,7 +861,7 @@ def test_14_issue_81_b(self): """ pattern = GitIgnoreBasicPattern('!libfoo/*') - self.assertEqual(pattern.regex.pattern, f'^libfoo/[^/]+/?$') + self.assertEqual(pattern.regex.pattern, rf'^libfoo/[^/]+/?\Z') self.assertIs(pattern.include, False) self.assertTrue(pattern.match_file('libfoo/__init__.py')) diff --git a/tests/test_07_gitignore_end_anchor.py b/tests/test_07_gitignore_end_anchor.py new file mode 100644 index 0000000..fd9668a --- /dev/null +++ b/tests/test_07_gitignore_end_anchor.py @@ -0,0 +1,67 @@ +"""Test that a terminal newline remains part of a filename.""" + +import unittest + +from pathspec import GitIgnoreSpec, PathSpec +from pathspec.patterns.gitignore.basic import GitIgnoreBasicPattern +from pathspec.patterns.gitignore.spec import GitIgnoreSpecPattern + +from .util import require_backend + + +class EndAnchorTest(unittest.TestCase): + + def test_pattern_filename_end(self): + for factory in (GitIgnoreBasicPattern, GitIgnoreSpecPattern): + for source, filename in ( + ('foo', 'foo'), + ('foo', 'x/foo'), + ('foo', 'x\ny/foo'), + ('/foo', 'foo'), + ('**/foo', 'x/foo'), + ('foo?', 'foo1'), + ('foo[0-9]', 'foo1'), + (r'foo\\Z', r'foo\Z'), + ): + for as_bytes in (False, True): + with self.subTest(factory=factory, source=source, as_bytes=as_bytes): + pattern = factory(source.encode() if as_bytes else source) + path = filename.encode() if as_bytes else filename + newline = b'\n' if as_bytes else '\n' + self.assertIsNotNone(pattern.match_file(path)) + self.assertIsNone(pattern.match_file(path + newline)) + + def test_backend_filename_end(self): + for backend in ('simple', 're2', 'hyperscan'): + with self.subTest(backend=backend): + require_backend(backend) + for factory in (GitIgnoreBasicPattern, GitIgnoreSpecPattern): + for as_bytes in (False, True): + if as_bytes and backend == 'simple': + continue # The simple backend requires string patterns for string paths. + with self.subTest(factory=factory, as_bytes=as_bytes): + line = b'foo' if as_bytes else 'foo' + spec = PathSpec.from_lines(factory, [line], backend=backend) + self.assertTrue(spec.match_file('foo')) + self.assertFalse(spec.match_file('foo\n')) + spec = GitIgnoreSpec.from_lines([line], backend=backend) + self.assertTrue(spec.match_file('foo')) + self.assertFalse(spec.match_file('foo\n')) + + def test_negation_keeps_newline_filename(self): + for backend in ('simple', 're2', 'hyperscan'): + with self.subTest(backend=backend): + require_backend(backend) + spec = GitIgnoreSpec.from_lines(['*', '!foo'], backend=backend) + self.assertFalse(spec.match_file('foo')) + self.assertTrue(spec.match_file('foo\n')) + + def test_backend_literal_backslash(self): + for backend in ('simple', 're2', 'hyperscan'): + with self.subTest(backend=backend): + require_backend(backend) + for factory in (GitIgnoreBasicPattern, GitIgnoreSpecPattern): + with self.subTest(factory=factory): + spec = PathSpec.from_lines(factory, [r'foo\\Z'], backend=backend) + self.assertTrue(spec.match_file(r'foo\Z', separators=('/',))) + self.assertFalse(spec.match_file(r'foo\z', separators=('/',))) From 031f5ee05f6e86f5a7b860366fd26db5c817887f Mon Sep 17 00:00:00 2001 From: kokotatan Date: Sat, 3 Oct 2026 20:47:05 +0900 Subject: [PATCH 2/2] Link strict filename anchor changelog to PR 152 --- CHANGES_1.in.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGES_1.in.rst b/CHANGES_1.in.rst index 0b04836..a90320d 100644 --- a/CHANGES_1.in.rst +++ b/CHANGES_1.in.rst @@ -17,7 +17,7 @@ New features: Bug fixes: -- Anchor gitignore filename matches at the actual end of the path, so a terminal +- `Pull #152`_: Anchor gitignore filename matches at the actual end of the path, so a terminal newline is not mistaken for the end of a filename. - `Pull #123`_: Ignore invalid gitignore bracket ranges for `GitIgnoreSpec`. @@ -170,6 +170,7 @@ Improvements: .. _`Issue #38`: https://github.com/cpburnz/python-pathspec/issues/38 +.. _`Pull #152`: https://github.com/cpburnz/python-pathspec/pull/152 .. _`Issue #91`: https://github.com/cpburnz/python-pathspec/issues/91 .. _`Issue #93`: https://github.com/cpburnz/python-pathspec/issues/93 .. _`Issue #95`: https://github.com/cpburnz/python-pathspec/issues/95