Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 40 additions & 17 deletions graphify/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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.
Expand Down
61 changes: 61 additions & 0 deletions tests/test_detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down