diff --git a/graphify/detect.py b/graphify/detect.py index 7b1a80ab..1c922711 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -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 diff --git a/tests/test_detect.py b/tests/test_detect.py index a0443602..837d8cf3 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -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