mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-17 04:15:21 +00:00
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:
1 parent
8ebb247dae
commit
c687e751af
3 files changed
+115
-10
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user