Skip to content
Merged
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
14 changes: 10 additions & 4 deletions graphify/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -1042,13 +1042,19 @@ def detect(root: Path, *, follow_symlinks: bool | None = None, google_workspace:
# 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.
# When negation patterns (!) exist, skip directory-level ignore
# pruning so negated files inside can still be reached.
has_negation = any(p.startswith("!") for _, p in ignore_patterns)
# Negations need no special-casing here: _is_ignored already applies
# last-match-wins (so `!dir/` un-ignores a directory and it won't be
# pruned) and the gitignore parent-exclusion rule (a `!` cannot rescue
# a file beneath an excluded dir), so descending an ignored directory to
# look for a re-included file is never necessary. The previous blanket
# `has_negation` disabled directory pruning for EVERY ignored dir whenever
# any `!` rule existed — e.g. a single `!docs/**` made the walk descend
# bin/, obj/, wwwroot/, generated/, … : a pathological slowdown on large
# repos for no correctness gain.
dirnames[:] = [
d for d in dirnames
if not _is_noise_dir(d, dp)
and (has_negation or not _is_ignored(dp / d, root, ignore_patterns, _cache=ignore_cache))
and not _is_ignored(dp / d, root, ignore_patterns, _cache=ignore_cache)
]
for fname in filenames:
if fname in _SKIP_FILES:
Expand Down
45 changes: 45 additions & 0 deletions tests/test_detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,51 @@ def test_negation_ancestor_itself_reincluded(tmp_path):
assert not _is_ignored(f, tmp_path, patterns)


def test_negation_does_not_disable_directory_pruning(tmp_path, monkeypatch):
"""A single `!` re-include must not switch off pruning of *unrelated* ignored dirs.

Regression: a blanket ``has_negation`` flag used to disable directory-level pruning
for EVERY ignored dir whenever any ``!`` pattern existed, so a single ``!docs/**``
made os.walk descend bin/, obj/, wwwroot/, generated/, … — a pathological slowdown
on large repos. Output stayed correct (the per-file ``_is_ignored`` filter still
excluded those files), so this guards the *walk* itself: the ignored dir must never
be descended, while the negation must still re-include its target.
"""
import os
import graphify.detect as det

(tmp_path / ".graphifyignore").write_text("myignored/\n*.md\n!docs/**\n")
deep = tmp_path / "myignored" / "deep" / "deeper"
deep.mkdir(parents=True)
(deep / "junk.py").write_text("x = 1")
(tmp_path / "docs").mkdir()
(tmp_path / "docs" / "guide.md").write_text("# guide")
(tmp_path / "src").mkdir()
(tmp_path / "src" / "app.py").write_text("y = 2")

visited: list[str] = []
real_walk = os.walk

def tracking_walk(top, *args, **kwargs):
for dirpath, dirnames, filenames in real_walk(top, *args, **kwargs):
visited.append(dirpath)
yield dirpath, dirnames, filenames

monkeypatch.setattr(det.os, "walk", tracking_walk)
result = det.detect(tmp_path)

# The ignored (non-noise) dir must never be descended, despite the !docs/** negation.
assert not any("myignored" in Path(v).parts for v in visited), (
"ignored 'myignored/' was walked despite being ignored — the has_negation bypass regressed"
)
# Detection itself is unaffected: negation still re-includes docs/*.md, real source is
# found, and nothing leaks out of the ignored dir.
all_files = [p for cat in result["files"].values() for p in cat]
assert any(p.endswith("app.py") for p in all_files)
assert any(p.endswith("guide.md") for p in all_files)
assert not any("junk.py" in p for p in all_files)


# Regression tests for #1087 - anchored patterns must not match basename deep in tree

def test_anchored_dir_not_matched_at_depth(tmp_path):
Expand Down