fix(detect): honor nested .gitignore/.graphifyignore files below the scan root (#1206)

detect() only read .gitignore/.graphifyignore in the scan root and its
ancestor directories (up to the nearest VCS root), loaded once before the
walk began. A .gitignore sitting in a descendant directory — e.g.
vendor/sub/.gitignore — was never read, so files/dirs it excluded leaked
into the graph. Real git (and every other gitignore-aware tool) honors
.gitignore at every directory level, not just the ancestor chain.

Extracts the per-directory read+parse logic into a shared
_load_dir_own_ignore() helper (used by both the existing ancestor-chain
loader and the new call site) and invokes it live inside detect()'s
os.walk loop for every directory visited, before that directory's
children are pruned — so a nested ignore file governs its own subtree
with the same closer-file-wins precedence git uses.

Adds three regression tests: nested file exclude, nested directory prune
(the walk never descends into it), and nested negation overriding a
broader root-level rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Mohak Agrawal
2026-07-13 15:38:16 +05:30
committed by safishamsi
parent 98c7ec039a
commit 8a5287a979
2 changed files with 101 additions and 17 deletions
+40 -17
View File
@@ -832,6 +832,33 @@ def _git_info_exclude(vcs_root: Path) -> Path | None:
return exclude if exclude.is_file() else None
def _load_dir_own_ignore(d: Path) -> list[tuple[Path, str]]:
"""Read .gitignore/.graphifyignore directly inside *d* (not its ancestors).
Merges .gitignore and .graphifyignore for this one directory (#1363):
.gitignore is read first and .graphifyignore last, so .graphifyignore
patterns (including `!` negations) win on conflict via last-match-wins;
adding a .graphifyignore can only ever exclude MORE, never re-include a
.gitignore-excluded file (#945 kept: a dir with only a .gitignore still
gets sensible defaults).
Shared by `_load_graphifyignore` (ancestor chain, loaded once before the
scan) and the live os.walk loop in `detect()` (called per-directory as
each descendant is visited), so nested ignore files *below* the scan
root are honored too — previously only the scan root and its ancestors
were read, so e.g. `vendor/sub/.gitignore` was silently ignored (#1206).
"""
patterns: list[tuple[Path, str]] = []
for fname in (".gitignore", ".graphifyignore"):
ignore_file = d / fname
if ignore_file.exists():
for raw in ignore_file.read_text(encoding="utf-8", errors="ignore").splitlines():
line = _parse_gitignore_line(raw)
if line:
patterns.append((d, line))
return patterns
def _load_graphifyignore(root: Path) -> list[tuple[Path, str]]:
"""Read .graphifyignore files and return (anchor_dir, pattern) pairs.
@@ -841,6 +868,10 @@ def _load_graphifyignore(root: Path) -> list[tuple[Path, str]]:
Walk ceiling: the nearest VCS root if inside a repo, otherwise the scan
root itself (hermetic — no leakage across unrelated sibling projects).
Covers the scan root and its ancestors only — directories *below* the
scan root are picked up live during the os.walk in `detect()` instead,
since they aren't known until the walk reaches them (#1206).
"""
root = root.resolve()
ceiling = _find_vcs_root(root) or root
@@ -869,23 +900,7 @@ def _load_graphifyignore(root: Path) -> list[tuple[Path, str]]:
patterns.append((ceiling, line))
for d in dirs:
# Merge .gitignore and .graphifyignore for this dir (#1363). Previously
# the presence of a .graphifyignore made graphify skip that dir's
# .gitignore entirely, so a file excluded only by .gitignore (e.g. a
# neutrally-named secret like prod-dump.sql) silently got indexed into
# the graph — whose artifacts embed file contents and are often
# committed. .gitignore is read first and .graphifyignore last, so
# .graphifyignore patterns (including `!` negations) win on conflict via
# last-match-wins; adding a .graphifyignore can only ever exclude MORE,
# never re-include a .gitignore-excluded file (#945 kept: a project with
# only a .gitignore still gets sensible defaults).
for fname in (".gitignore", ".graphifyignore"):
ignore_file = d / fname
if ignore_file.exists():
for raw in ignore_file.read_text(encoding="utf-8", errors="ignore").splitlines():
line = _parse_gitignore_line(raw)
if line:
patterns.append((d, line))
patterns.extend(_load_dir_own_ignore(d))
return patterns
@@ -1194,6 +1209,14 @@ def detect(root: Path, *, follow_symlinks: bool | None = None, google_workspace:
dirnames.clear()
continue
if not in_memory_tree:
# dp == root was already loaded by _load_graphifyignore (root is
# the last entry in its ancestor chain); every other directory
# reached by the walk is a descendant below the scan root, whose
# own .gitignore/.graphifyignore is unknown until we get here.
# Load it now, before pruning dp's children, so a nested ignore
# file governs its own subtree the same way git honors it (#1206).
if dp != root:
ignore_patterns.extend(_load_dir_own_ignore(dp))
# Prune noise dirs in-place so os.walk never descends into them.
# Dot dirs are allowed — users often want .github/, .claude/, etc.
# Framework caches (.next, .nuxt, …) are caught by _is_noise_dir.
+61
View File
@@ -227,6 +227,67 @@ def test_graphifyignore_at_git_root_is_included(tmp_path):
assert result["graphifyignore_patterns"] == 1
def test_gitignore_nested_below_root_excludes_file(tmp_path):
"""A .gitignore in a subdirectory below the scan root is honored too (#1206).
Previously only the scan root and its ancestors were read, so a
.gitignore sitting inside e.g. vendor/sub/ was silently skipped.
"""
(tmp_path / ".gitignore").write_text("*.log\n")
sub = tmp_path / "vendor" / "sub"
sub.mkdir(parents=True)
(sub / ".gitignore").write_text("secret.txt\n")
(tmp_path / "root.py").write_text("x = 1")
(tmp_path / "root.log").write_text("noise")
(sub / "keep.py").write_text("y = 2")
(sub / "secret.txt").write_text("shh")
result = detect(tmp_path)
code_files = result["files"]["code"]
assert any("root.py" in f for f in code_files)
assert any("keep.py" in f for f in code_files)
assert not any("root.log" in f for f in code_files)
assert not any("secret.txt" in f for f in code_files)
assert result["graphifyignore_patterns"] == 2
def test_gitignore_nested_below_root_prunes_whole_directory(tmp_path):
"""A nested .gitignore excluding a directory prevents descending into it."""
sub = tmp_path / "vendor" / "sub"
sub.mkdir(parents=True)
(sub / ".gitignore").write_text("build/\n")
build = sub / "build"
build.mkdir()
(build / "generated.py").write_text("x = 1")
(sub / "keep.py").write_text("y = 2")
result = detect(tmp_path)
code_files = result["files"]["code"]
assert any("keep.py" in f for f in code_files)
assert not any("generated.py" in f for f in code_files)
def test_gitignore_nested_negation_overrides_broader_root_rule(tmp_path):
"""A closer (nested) .gitignore's `!` re-include wins over a root exclude,
matching git's closer-file-wins precedence."""
(tmp_path / ".gitignore").write_text("*.txt\n")
sub = tmp_path / "vendor" / "sub"
sub.mkdir(parents=True)
(sub / ".gitignore").write_text("!important.txt\n")
(tmp_path / "root.txt").write_text("a")
(sub / "important.txt").write_text("b")
(sub / "other.txt").write_text("c")
result = detect(tmp_path)
# .txt is not a code extension so check the document bucket instead
doc_files = result["files"]["document"] if "document" in result["files"] else []
unclassified = result.get("unclassified", [])
all_seen = doc_files + unclassified
assert any("important.txt" in f for f in all_seen)
assert not any(f.endswith("root.txt") for f in all_seen)
assert not any(f.endswith("other.txt") for f in all_seen)
def test_detect_handles_circular_symlinks(tmp_path):
sub = tmp_path / "a"
sub.mkdir()