fix anchored gitignore pattern leaking basename match into subtree (closes #1087)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Safi
2026-05-30 18:16:01 +01:00
co-authored by Claude Sonnet 4.6
parent a9d6be6537
commit cca13aa8fd
2 changed files with 93 additions and 8 deletions
+12 -8
View File
@@ -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
+81
View File
@@ -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"))