From cca13aa8fd19f4140bff5296af5407af27bc4bf4 Mon Sep 17 00:00:00 2001 From: Safi Date: Sat, 30 May 2026 18:16:01 +0100 Subject: [PATCH] fix anchored gitignore pattern leaking basename match into subtree (closes #1087) Co-Authored-By: Claude Sonnet 4.6 --- graphify/detect.py | 20 ++++++----- tests/test_detect.py | 81 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 8 deletions(-) diff --git a/graphify/detect.py b/graphify/detect.py index 7fedfcc..36a0a18 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -671,7 +671,9 @@ def _is_ignored(path: Path, root: Path, patterns: list[tuple[Path, str]]) -> boo def _eval(target: Path) -> bool: """Apply last-match-wins to a single target path.""" - def _matches(rel: str, p: str) -> bool: + def _matches(rel: str, p: str, anchored: bool) -> bool: + if anchored: + return fnmatch.fnmatch(rel, p) parts = rel.split("/") if fnmatch.fnmatch(rel, p): return True @@ -697,19 +699,19 @@ def _is_ignored(path: Path, root: Path, patterns: list[tuple[Path, str]]) -> boo if anchored: try: rel_anchor = str(target.relative_to(anchor)).replace(os.sep, "/") - matched = _matches(rel_anchor, p) + matched = _matches(rel_anchor, p, anchored=True) except ValueError: pass else: try: rel = str(target.relative_to(root)).replace(os.sep, "/") - matched = _matches(rel, p) + matched = _matches(rel, p, anchored=False) except ValueError: pass if not matched and anchor != root: try: rel_anchor = str(target.relative_to(anchor)).replace(os.sep, "/") - matched = _matches(rel_anchor, p) + matched = _matches(rel_anchor, p, anchored=False) except ValueError: pass @@ -769,7 +771,9 @@ def _is_included(path: Path, root: Path, patterns: list[tuple[Path, str]]) -> bo if not patterns: return False - def _matches(rel: str, p: str) -> bool: + def _matches(rel: str, p: str, anchored: bool) -> bool: + if anchored: + return fnmatch.fnmatch(rel, p) parts = rel.split("/") if fnmatch.fnmatch(rel, p): return True @@ -790,21 +794,21 @@ def _is_included(path: Path, root: Path, patterns: list[tuple[Path, str]]) -> bo if anchored: try: rel_anchor = str(path.relative_to(anchor)).replace(os.sep, "/") - if _matches(rel_anchor, p): + if _matches(rel_anchor, p, anchored=True): return True except ValueError: pass else: try: rel = str(path.relative_to(root)).replace(os.sep, "/") - if _matches(rel, p): + if _matches(rel, p, anchored=False): return True except ValueError: pass if anchor != root: try: rel_anchor = str(path.relative_to(anchor)).replace(os.sep, "/") - if _matches(rel_anchor, p): + if _matches(rel_anchor, p, anchored=False): return True except ValueError: pass diff --git a/tests/test_detect.py b/tests/test_detect.py index e2fde04..9008518 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -503,6 +503,87 @@ def test_negation_ancestor_itself_reincluded(tmp_path): assert not _is_ignored(f, tmp_path, patterns) +# Regression tests for #1087 - anchored patterns must not match basename deep in tree + +def test_anchored_dir_not_matched_at_depth(tmp_path): + """/inbox/ must not match src/inbox/ — only inbox/ at the anchor root.""" + from graphify.detect import _is_ignored, _load_graphifyignore + src_inbox = tmp_path / "src" / "inbox" + src_inbox.mkdir(parents=True) + f = src_inbox / "main.rs" + f.write_text("fn main() {}") + (tmp_path / ".graphifyignore").write_text("/inbox/\n") + patterns = _load_graphifyignore(tmp_path) + assert not _is_ignored(f, tmp_path, patterns), ( + "src/inbox/main.rs must NOT be ignored by /inbox/ — the pattern is anchored to root" + ) + assert not _is_ignored(src_inbox, tmp_path, patterns), ( + "src/inbox/ must NOT be ignored by /inbox/ — the pattern is anchored to root" + ) + + +def test_anchored_dir_matches_at_root(tmp_path): + """/inbox/ must still match inbox/ at the anchor root (positive case).""" + from graphify.detect import _is_ignored, _load_graphifyignore + inbox = tmp_path / "inbox" + inbox.mkdir() + f = inbox / "data.json" + f.write_text("{}") + (tmp_path / ".graphifyignore").write_text("/inbox/\n") + patterns = _load_graphifyignore(tmp_path) + assert _is_ignored(f, tmp_path, patterns), ( + "inbox/data.json must be ignored by /inbox/" + ) + assert _is_ignored(inbox, tmp_path, patterns), ( + "inbox/ must be ignored by /inbox/" + ) + + +def test_anchored_file_not_matched_at_depth(tmp_path): + """/build must not match src/build.""" + from graphify.detect import _is_ignored, _load_graphifyignore + src_build = tmp_path / "src" / "build" + src_build.mkdir(parents=True) + (tmp_path / ".graphifyignore").write_text("/build\n") + patterns = _load_graphifyignore(tmp_path) + assert not _is_ignored(src_build, tmp_path, patterns), ( + "src/build must NOT be ignored by /build" + ) + + +def test_unanchored_dir_still_matches_at_depth(tmp_path): + """inbox/ (no leading /) must still match src/inbox/ anywhere in the tree.""" + from graphify.detect import _is_ignored, _load_graphifyignore + src_inbox = tmp_path / "src" / "inbox" + src_inbox.mkdir(parents=True) + f = src_inbox / "main.rs" + f.write_text("fn main() {}") + (tmp_path / ".graphifyignore").write_text("inbox/\n") + patterns = _load_graphifyignore(tmp_path) + assert _is_ignored(f, tmp_path, patterns), ( + "src/inbox/main.rs must be ignored by unanchored inbox/" + ) + + +def test_anchored_multi_segment_pattern(tmp_path): + """/src/inbox/ must match src/inbox/ but not x/src/inbox/.""" + from graphify.detect import _is_ignored, _load_graphifyignore + (tmp_path / "src" / "inbox").mkdir(parents=True) + (tmp_path / "x" / "src" / "inbox").mkdir(parents=True) + target_ok = tmp_path / "src" / "inbox" / "a.py" + target_ok.write_text("x=1") + target_bad = tmp_path / "x" / "src" / "inbox" / "b.py" + target_bad.write_text("x=1") + (tmp_path / ".graphifyignore").write_text("/src/inbox/\n") + patterns = _load_graphifyignore(tmp_path) + assert _is_ignored(target_ok, tmp_path, patterns), ( + "src/inbox/a.py must be ignored by /src/inbox/" + ) + assert not _is_ignored(target_bad, tmp_path, patterns), ( + "x/src/inbox/b.py must NOT be ignored by /src/inbox/" + ) + + # Regression tests for #920 - sensitive pattern misses underscore-prefixed names def test_sensitive_flags_api_token_txt(): assert _is_sensitive(Path("api_token.txt"))