fix(detect): keep ignore globs within path segments

This commit is contained in:
oleksii-tumanov
2026-07-18 12:04:48 +01:00
committed by safishamsi
parent d56b70a451
commit aad595571d
2 changed files with 100 additions and 5 deletions
+37 -5
View File
@@ -7,6 +7,7 @@ import re
import shlex
from concurrent.futures import ThreadPoolExecutor
from enum import Enum
from functools import lru_cache
from pathlib import Path
from graphify.google_workspace import (
@@ -934,6 +935,34 @@ def _load_graphifyignore(root: Path) -> list[tuple[Path, str]]:
return patterns
def _match_anchored_ignore_pattern(path: str, pattern: str) -> bool:
"""Match an anchored gitignore pattern without letting ``*`` cross ``/``."""
path_parts = tuple(path.split("/"))
pattern_parts = tuple(pattern.split("/"))
@lru_cache(maxsize=None)
def _matches(path_idx: int, pattern_idx: int) -> bool:
if pattern_idx == len(pattern_parts):
return path_idx == len(path_parts)
part = pattern_parts[pattern_idx]
if part == "**":
if pattern_idx == len(pattern_parts) - 1:
return path_idx < len(path_parts)
return _matches(path_idx, pattern_idx + 1) or (
path_idx < len(path_parts)
and _matches(path_idx + 1, pattern_idx)
)
return (
path_idx < len(path_parts)
and fnmatch.fnmatchcase(path_parts[path_idx], part)
and _matches(path_idx + 1, pattern_idx + 1)
)
return _matches(0, 0)
def _is_ignored(
path: Path,
root: Path,
@@ -961,9 +990,9 @@ def _is_ignored(
"""Apply last-match-wins to a single target path."""
if _cache is not None and target in _cache:
return _cache[target]
def _matches(rel: str, p: str, anchored: bool) -> bool:
if anchored:
return fnmatch.fnmatch(rel, p)
def _matches(rel: str, p: str, path_relative: bool) -> bool:
if path_relative:
return _match_anchored_ignore_pattern(rel, p)
parts = rel.split("/")
if fnmatch.fnmatch(rel, p):
return True
@@ -980,7 +1009,8 @@ def _is_ignored(
for anchor, pattern in patterns:
negated = pattern.startswith("!")
raw = pattern[1:] if negated else pattern
anchored = raw.startswith("/")
directory_only = raw.endswith("/")
path_relative = "/" in raw.rstrip("/")
p = raw.strip("/")
if not p:
continue
@@ -996,7 +1026,9 @@ def _is_ignored(
except ValueError:
continue # target outside this pattern's anchor: cannot match
if rel_anchor != ".":
matched = _matches(rel_anchor, p, anchored=anchored)
matched = _matches(rel_anchor, p, path_relative=path_relative)
if matched and directory_only and not target.is_dir():
matched = False
if matched:
result = not negated # last match wins; ! flips to un-ignore
+63
View File
@@ -738,6 +738,69 @@ def test_detect_skips_graphify_own_cache(tmp_path):
# --- #882: gitignore parent-exclusion rule for ! re-includes ---
def test_anchored_root_wildcard_negation_reincludes_subtree(tmp_path):
"""`/*` stays at the root, so `!/src/` makes the subtree walkable (#1975)."""
for rel in ("src/app/main.py", "src/lib/util.py", "docs/guide.md", "README.md"):
path = tmp_path / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("x\n")
(tmp_path / ".graphifyignore").write_text("/*\n!/src/\n")
result = detect(tmp_path)
files = {
Path(path).relative_to(tmp_path).as_posix()
for paths in result["files"].values()
for path in paths
}
assert files == {"src/app/main.py", "src/lib/util.py"}
def test_anchored_negation_cannot_skip_excluded_parent(tmp_path):
"""Re-including a child cannot rescue it while its parent stays excluded."""
victim = tmp_path / "src" / "app" / "main.py"
victim.parent.mkdir(parents=True)
victim.write_text("x\n")
(tmp_path / ".graphifyignore").write_text("/*\n!/src/app/\n")
assert detect(tmp_path)["total_files"] == 0
def test_path_pattern_single_star_does_not_cross_segment(tmp_path):
"""A regular `*` matches one component; recursive matching requires `**`."""
direct = tmp_path / "src" / "main.py"
nested = tmp_path / "src" / "app" / "main.py"
nested.parent.mkdir(parents=True)
direct.write_text("x\n")
nested.write_text("x\n")
for pattern in ("/src/*.py", "src/*.py"):
(tmp_path / ".graphifyignore").write_text(f"{pattern}\n")
result = detect(tmp_path)
files = [path for paths in result["files"].values() for path in paths]
assert not any(path.endswith("src/main.py") for path in files)
assert any(path.endswith("src/app/main.py") for path in files)
def test_directory_only_negation_does_not_reinclude_file(tmp_path):
"""A trailing slash restricts a pattern to directories, as in gitignore."""
readme = tmp_path / "README.md"
readme.write_text("# docs\n")
(tmp_path / ".graphifyignore").write_text("/*\n!/README.md/\n")
assert detect(tmp_path)["total_files"] == 0
def test_anchored_double_star_crosses_path_segments(tmp_path):
"""`**` retains recursive gitignore matching at zero or more depths."""
direct = tmp_path / "src" / "generated.py"
nested = tmp_path / "src" / "app" / "deep" / "generated.py"
nested.parent.mkdir(parents=True)
direct.write_text("x\n")
nested.write_text("x\n")
(tmp_path / ".graphifyignore").write_text("/src/**/generated.py\n")
assert detect(tmp_path)["total_files"] == 0
def test_negation_cannot_rescue_file_under_excluded_dir(tmp_path):
"""A ! re-include cannot un-ignore a file whose parent dir is excluded (#882)."""
from graphify.detect import _is_ignored, _load_graphifyignore