fix(gitignore): escape directory names before interpolating into pattern position (#1809)

* fix(gitignore): escape directory names before interpolating into pattern position

GitignoreParser rescopes patterns from nested .gitignore files by prefixing the
containing directory's relative path, but interpolates that path into pattern
position without escaping it. A directory whose name contains a gitignore
metacharacter (*, ?, [, ], !, #) is then read as glob syntax instead of a
literal name, so a directory like a stray "***" venv can turn a scoped
pattern into one matching most or all of the project.

Fixes #1806

* fix(gitignore): build patterns with '/' only, never os.sep/os.path.join

The escaping added in the prior commit inserts literal backslash escape
characters before pattern metacharacters. On Windows, os.sep is also
backslash, so os.path.join(rel_dir_pattern, line) mixed path-separator
backslashes with escape backslashes, and the pre-existing trailing
`.replace(os.sep, "/")` normalization step then converted ALL of them
indiscriminately -- turning an escaped 'a\[1\]' into 'a/[1/]', which no
longer matches the literal directory name.

Verified with ntpath (Windows path semantics) on this Linux session:
simulating a Windows relpath through the old os.sep/os.path.join/replace
chain produces 'a/[1/]/mod.py', which pathspec does not match against
'a[1]/mod.py'; the new '/'-only construction produces 'a\[1\]/mod.py',
which matches. Fixed by normalizing rel_dir to '/' immediately and joining
every pattern component with a literal '/', removing the now-unneeded (and
on Windows actively harmful) blanket separator replace.

Also marks the two directory-creation regression tests that use '*'/'?'
literally in a directory name as Windows-skipped (those characters are
illegal in Windows filenames, so the directories cannot be created there --
a platform limitation of the test, not the fix), and adds a pure-function
test for _escape_gitignore_path_component so the '*'/'?' escaping logic
still has cross-platform coverage.

Found via this PR's own Windows CI run (jobs 91787560091/91787560092/91787560183
on run 30843966565), which failed test_gitignore_dir_name_with_metachars_anchored_pattern
with an assertion failure (not the OSError the other two tests hit), before any
maintainer had looked at the PR.
This commit is contained in:
Amir Fathi authored and GitHub committed 2026-08-09 13:26:18 +02:00
1 parent 8ebb247dae
commit c687e751af
3 files changed
+115 -10

No files matched your search

+32 -9
View File
@@ -1,5 +1,6 @@
import logging
import os
import re
from collections.abc import Callable, Iterator
from dataclasses import dataclass, field
from pathlib import Path
@@ -11,6 +12,18 @@ from sensai.util.logging import LogTime
log = logging.getLogger(__name__)
# Characters meaningful to pathspec's gitignore grammar: glob wildcards, bracket expressions,
# the escape character itself, and '!'/'#' which change a whole pattern's meaning when they
# are its first character. Backslash-escaping them makes a literal name safe to interpolate.
_GITIGNORE_PATTERN_SPECIAL_CHARS_RE = re.compile(r"([\\*?\[\]!#])")
def _escape_gitignore_path_component(component: str) -> str:
"""Escape gitignore/pathspec pattern metacharacters in a single path component (no
separators) so it is matched as a literal name rather than as glob syntax.
"""
return _GITIGNORE_PATTERN_SPECIAL_CHARS_RE.sub(r"\\\1", component)
class ScanResult(NamedTuple):
"""Result of scanning a directory."""
@@ -223,11 +236,21 @@ class GitignoreParser:
"""
patterns = []
# Get the relative path from repo root to the gitignore directory
rel_dir = os.path.relpath(gitignore_dir, self.repo_root)
# Get the relative path from repo root to the gitignore directory. Normalize to
# forward slashes immediately: os.path.relpath returns native separators, but
# gitignore/pathspec patterns are always POSIX-style, and on Windows os.sep is
# backslash -- the same character pathspec uses as its escape character. Building
# the pattern with any raw os.sep would make a later blanket backslash->slash
# normalization indistinguishable from the escape backslashes below.
rel_dir = os.path.relpath(gitignore_dir, self.repo_root).replace(os.sep, "/")
if rel_dir == ".":
rel_dir = ""
# rel_dir is a filesystem path, but the code below interpolates it into pattern
# position; escape each of its components so a directory name containing pattern
# metacharacters (e.g. "***") is matched literally instead of as glob syntax.
rel_dir_pattern = "/".join(_escape_gitignore_path_component(part) for part in rel_dir.split("/")) if rel_dir else rel_dir
for line in content.splitlines():
# Strip trailing whitespace (but preserve leading whitespace for now)
line = line.rstrip()
@@ -256,20 +279,23 @@ class GitignoreParser:
if is_anchored:
line = line[1:]
# Adjust pattern based on gitignore file location
# Adjust pattern based on gitignore file location. Joined with a literal "/",
# never os.path.join/os.sep: gitignore patterns are always POSIX-style, and on
# Windows os.sep is backslash, indistinguishable from the escape backslashes
# rel_dir_pattern may already contain.
if rel_dir:
if is_anchored:
# Anchored patterns are relative to the gitignore directory
adjusted_pattern = os.path.join(rel_dir, line)
adjusted_pattern = f"{rel_dir_pattern}/{line}"
else:
# Non-anchored patterns can match anywhere below the gitignore directory
# We need to preserve this behavior
if line.startswith("**/"):
# Even if pattern starts with **, it should still be scoped to the subdirectory
adjusted_pattern = os.path.join(rel_dir, line)
adjusted_pattern = f"{rel_dir_pattern}/{line}"
else:
# Add the directory prefix but also allow matching in subdirectories
adjusted_pattern = os.path.join(rel_dir, "**", line)
adjusted_pattern = f"{rel_dir_pattern}/**/{line}"
else:
if is_anchored:
# Anchored patterns in root should only match at root level
@@ -283,9 +309,6 @@ class GitignoreParser:
if is_negation:
adjusted_pattern = "!" + adjusted_pattern
# Normalize path separators to forward slashes (gitignore uses forward slashes)
adjusted_pattern = adjusted_pattern.replace(os.sep, "/")
patterns.append(adjusted_pattern)
return patterns