Skip to content

Nested .gitignore with a bare '*' zeroes the entire corpus, not just its own dir #1887

Description

@spacefilmer

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:

  1. Project root Code OS/ with no root-level .gitignore/.graphifyignore.
  2. A deeply nested file, e.g. agentes/agente-sii/.superpowers/sdd/.gitignore, containing just:
    *
    
  3. Run detect(Path('.')) on the project root.
  4. Result: total_files: 0, every category empty, skipped_sensitive: [], unclassified: [] — i.e. every file disappears with no trace, not even a "skipped" note.
  5. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions