mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-13 10:57:13 +00:00
fix: negation pattern no longer disables all directory pruning
A single `!` rule in .graphifyignore set a blanket `has_negation` flag that disabled directory-level pruning for EVERY ignored directory during the os.walk in detect(). One unrelated `!docs/**` therefore made the walk descend bin/, obj/, wwwroot/, generated/, … on large repos — a pathological slowdown. Output stayed correct (the per-file `_is_ignored` filter still excluded those files), but the walk visited the entire tree. The bypass was unnecessary: `_is_ignored` already honours negations correctly — last-match-wins lets `!dir/` un-ignore a directory (so it is not pruned), and the gitignore parent-exclusion rule means a `!` cannot rescue a file beneath an excluded directory, so descending an ignored dir to find a re-included file is never needed. Prune purely on `_is_noise_dir` + `_is_ignored`. Adds a regression test that tracks os.walk and asserts the ignored dir is never descended while the negation still re-includes its target. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+10
-4
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user