From e70c73027600dfb344f71f61bec013682b2136f0 Mon Sep 17 00:00:00 2001 From: Mohak Agrawal Date: Mon, 13 Jul 2026 15:38:16 +0530 Subject: [PATCH] fix(detect): honor nested .gitignore/.graphifyignore files below the scan root (#1206) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- graphify/detect.py | 57 +++++++++++++++++++++++++++++------------ tests/test_detect.py | 61 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 17 deletions(-) diff --git a/graphify/detect.py b/graphify/detect.py index 20399eb36..6b54d8913 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -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 _on_walk_error(err: OSError) -> None: 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. diff --git a/tests/test_detect.py b/tests/test_detect.py index 1d31fa711..6f71fe44a 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -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()