Bug: nested .gitignore/.graphifyignore with a bare * silently zeroes the entire corpus, not just its own directory
Version: graphifyy==0.9.15 (installed via uv tool install graphifyy)
Summary: Running graphify.detect.detect() (i.e. /graphify .) on a large project root returns total_files: 0 even though the corpus clearly contains thousands of matching files. Root cause is a scoping bug in non-anchored .gitignore pattern matching: a nested .gitignore file several directories deep, containing a single bare * line (a common idiom for "ignore everything in this scratch dir"), ends up excluding every file in the entire scan tree, not just its own subdirectory.
Reproduction:
- Project root
Code OS/ with no root-level .gitignore/.graphifyignore.
- A deeply nested file, e.g.
agentes/agente-sii/.superpowers/sdd/.gitignore, containing just:
- Run
detect(Path('.')) on the project root.
- Result:
total_files: 0, every category empty, skipped_sensitive: [], unclassified: [] — i.e. every file disappears with no trace, not even a "skipped" note.
- Running
detect() on any subfolder that doesn't contain that nested .gitignore (e.g. docs/) works correctly and returns the expected files.
Root cause (graphify/detect.py, _is_ignored):
For a non-anchored pattern (no leading /), the matcher always tries the path relative to the scan root first, regardless of where the .gitignore that defined the pattern actually lives:
else:
try:
rel = str(target.relative_to(root)).replace(os.sep, "/")
matched = _matches(rel, p, anchored=False)
except ValueError:
pass
if not matched and anchor != root:
try:
rel_anchor = str(target.relative_to(anchor)).replace(os.sep, "/")
matched = _matches(rel_anchor, p, anchored=False)
except ValueError:
pass
_matches also does per-path-component fnmatch matching. Since fnmatch.fnmatch(anything, "*") is always True, a bare * loaded from agentes/agente-sii/.superpowers/sdd/.gitignore (anchor = that nested dir) matches every target.relative_to(root) string in the whole tree — because the root-relative match is attempted unconditionally, before the anchor-relative one. Real gitignore semantics scope a non-anchored pattern from a nested .gitignore to files under that .gitignore's own directory (and its descendants) — never to sibling directories elsewhere in the tree.
Suggested fix: for a non-anchored pattern whose anchor is not the scan root, only test the path relative to that pattern's own anchor directory (and skip entirely for paths outside that anchor's subtree) — drop the root-relative attempt for non-root anchors, or gate it behind target actually being a descendant of anchor.
Impact: any repo with a .superpowers/, build-scratch, or similar nested dir carrying a minimal .gitignore (* to ignore everything in that one folder) silently loses its entire graphify corpus with zero diagnostic output — walk_errors, skipped_sensitive, and unclassified are all empty, so there's no signal pointing at the cause. Took manual bisection (testing detect() against progressively smaller subfolders, then grepping every nested .gitignore for a bare *) to find it.
Bug: nested
.gitignore/.graphifyignorewith a bare*silently zeroes the entire corpus, not just its own directoryVersion:
graphifyy==0.9.15(installed viauv tool install graphifyy)Summary: Running
graphify.detect.detect()(i.e./graphify .) on a large project root returnstotal_files: 0even though the corpus clearly contains thousands of matching files. Root cause is a scoping bug in non-anchored.gitignorepattern matching: a nested.gitignorefile several directories deep, containing a single bare*line (a common idiom for "ignore everything in this scratch dir"), ends up excluding every file in the entire scan tree, not just its own subdirectory.Reproduction:
Code OS/with no root-level.gitignore/.graphifyignore.agentes/agente-sii/.superpowers/sdd/.gitignore, containing just:detect(Path('.'))on the project root.total_files: 0, every category empty,skipped_sensitive: [],unclassified: []— i.e. every file disappears with no trace, not even a "skipped" note.detect()on any subfolder that doesn't contain that nested.gitignore(e.g.docs/) works correctly and returns the expected files.Root cause (
graphify/detect.py,_is_ignored):For a non-anchored pattern (no leading
/), the matcher always tries the path relative to the scan root first, regardless of where the.gitignorethat defined the pattern actually lives:_matchesalso does per-path-componentfnmatchmatching. Sincefnmatch.fnmatch(anything, "*")is alwaysTrue, a bare*loaded fromagentes/agente-sii/.superpowers/sdd/.gitignore(anchor = that nested dir) matches everytarget.relative_to(root)string in the whole tree — because the root-relative match is attempted unconditionally, before the anchor-relative one. Real gitignore semantics scope a non-anchored pattern from a nested.gitignoreto files under that.gitignore's own directory (and its descendants) — never to sibling directories elsewhere in the tree.Suggested fix: for a non-anchored pattern whose anchor is not the scan root, only test the path relative to that pattern's own anchor directory (and skip entirely for paths outside that anchor's subtree) — drop the root-relative attempt for non-root anchors, or gate it behind
targetactually being a descendant ofanchor.Impact: any repo with a
.superpowers/, build-scratch, or similar nested dir carrying a minimal.gitignore(*to ignore everything in that one folder) silently loses its entire graphify corpus with zero diagnostic output —walk_errors,skipped_sensitive, andunclassifiedare all empty, so there's no signal pointing at the cause. Took manual bisection (testingdetect()against progressively smaller subfolders, then grepping every nested.gitignorefor a bare*) to find it.