mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-18 13:27:06 +00:00
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:
committed by
safishamsi
parent
98c7ec039a
commit
8a5287a979
+40
-17
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user