Skip to content

_is_ignored doesn't honor gitignore's parent-exclusion rule for ! re-includes #882

Description

@hoybilli

Affected version

graphifyy 0.8.4 (Python 3.11, macOS 14). Reproduced deterministically with the snippet below.

Summary

Per the gitignore spec ("It is not possible to re-include a file if a parent directory of that file is excluded"), once a parent directory is matched by a deny pattern, ! patterns cannot un-ignore files inside it. graphify.detect._is_ignored does last-match-wins across all patterns against the full path, with no check for whether an ancestor directory has already been excluded — so a !src/ re-include un-ignores android/app/src/main/x.kt even when android/ is excluded one line earlier.

This breaks the canonical * + !whitelist/ deny-everything-but pattern that closed feature requests #628 and #676 were filed to support — #676's example pattern is exactly the trigger.

4-line repro

from graphify.detect import _load_graphifyignore, _is_ignored
from pathlib import Path
import tempfile

with tempfile.TemporaryDirectory() as tdir:
    sb = Path(tdir)
    (sb / ".graphifyignore").write_text("android/\n*\n!src/\n")
    target = sb / "android" / "app" / "src" / "main" / "x.kt"
    target.parent.mkdir(parents=True)
    target.write_text("x")
    patterns = _load_graphifyignore(sb)
    print(_is_ignored(target, sb, patterns))  # prints False; should be True

Reference behavior (real git)

Same pattern contents, copied verbatim into .gitignore in a vanilla git repo, against the same file:

$ cat .gitignore
android/
*
!src/

$ git check-ignore -v android/app/src/main/x.kt
.gitignore:2:*	android/app/src/main/x.kt

Git matches the * pattern, not !src/. The !src/ rule never re-includes the file because android/ (line 1) already excluded the parent. For comparison, src/utils/x.ts correctly stays included under the same config — git's parent-exclusion rule only kicks in when the parent is actually excluded.

Expected vs actual

Same .graphifyignore as above:

Path Expected Actual on 0.8.4
android/app/src/main/x.kt True (denied via android/) False ← bug
android/app/lib/x.kt True (denied via android/) True
android/res/mipmap/x.webp True True
src/utils/coords.ts False (re-included via !src/) False

The bug only fires when a path under an excluded directory happens to contain a path component matching a ! re-include pattern. Paths under android/ that don't contain src/ (or any other whitelisted segment) are correctly ignored.

Suspected root cause

In graphify/detect.py (0.8.4 installed copy, line numbers approximate):

  • _matches() at detect.py:488–499 — iterates every component of the path and returns True if fnmatch.fnmatch(part, p) matches any one. This is fine for deny patterns (and matches gitignore's "match any directory named X at any level" semantics for bare patterns), but the same matcher is then reused for ! re-include patterns without anchoring.
  • _is_ignored() at detect.py:478–525 — runs all patterns through _matches() and applies last-match-wins. There's no traversal step that checks "has any ancestor directory already been excluded by a deny pattern?" before allowing ! to re-include.

So for android/app/src/main/x.kt under the example .graphifyignore, evaluated with last-match-wins:

  1. android/ matches (component android exists) → result = True
  2. * matches everything → still True (no state change; correct so far)
  3. !src/ matches (component src exists) → flipped to False ← incorrect; parent android/ was excluded at step 1

Real git would have stopped at step 1 conceptually: the file is under an excluded parent, so subsequent ! patterns are skipped.

One plausible fix

The simplest gitignore-spec-aligned fix is to evaluate ! patterns conditionally: before allowing a ! pattern to un-ignore a path, check whether any earlier deny pattern matched an ancestor directory of the path (not just the path itself). If an ancestor was matched, the ! is skipped.

This matches gitignore's documented "parent-directory-excluded" rule and avoids needing to change how _matches() treats bare-pattern matching for deny patterns. There may be a cleaner internal approach (e.g. directory-traversal evaluation like git itself does); this is just one shape of fix that closes the reported case without disturbing the rest of the matching API.

Real-world impact

In a React Native / Expo project, every Android resource path follows android/app/src/main/res/... and every Java source follows android/app/src/main/java/.... So the canonical scope .graphifyignore:

node_modules/
dist/
ios/
android/
coverage/
graphify-out/

*
!src/
!src/**
!__tests__/
!__tests__/**
!docs/
!docs/**
!supabase/
!supabase/**
!scripts/
!scripts/**

…silently re-includes the entire android/ tree, because every android file path contains src/. detect() returns 25+ android files (Kotlin sources, webp icons, splash PNGs, even .cxx/ CMake test artifacts under android/app/.cxx/Debug/.../src/foo.cpp) that the user's .graphifyignore explicitly denies. detect() re-scans the full corpus whenever called, so any commit (including docs-only) triggers a polluted refresh on the next /graphify --update; in our project this manifested as the graph more than doubling on a docs-only commit before we diagnosed it.

Note on 0.7.8 → 0.8.4

The user-visible symptom shifted between 0.7.8 and 0.8.4 — coverage HTML files moved from appearing as "changed documents" via detect_incremental to appearing as "deleted files" (the prior manifest has them, the current scan no longer classifies the directory's contents the same way). The underlying matcher is unchanged; the bug repro above fires identically in both versions.

Workaround for affected projects

Replace the * + !whitelist/ pattern with deny-only patterns:

node_modules/
dist/
ios/
android/
coverage/
graphify-out/
.expo/
.claude/
.github/
assets/
*.generated.py
*.generated.ts
**/.cxx/
**/build/
**/Pods/

This avoids the ! re-include branch entirely. Tradeoff: any directory not in the deny list is scanned, so root-level files (App.tsx, package.json, configs) are admitted — typically desirable, just not always.

Verified against these test paths (all classify correctly)
test_paths = [
    ("android/app/src/main/x.kt",      True),   # denied via android/
    ("android/app/lib/x.kt",           True),   # denied via android/
    ("android/res/mipmap/x.webp",      True),   # denied via android/
    ("coverage/lcov-report/index.html", True),  # denied via coverage/
    ("graphify-out/graph.json",        True),   # denied via graphify-out/
    ("node_modules/foo/bar.js",        True),   # denied via node_modules/
    ("src/utils/coords.ts",            False),  # admitted (not under any deny)
    ("src/components/Icon/index.ts",   False),  # admitted
    ("docs/sprint-prompts.md",         False),  # admitted
    ("__tests__/integration/rpcs.test.ts", False),  # admitted
    ("App.tsx",                        False),  # admitted (root file)
]

Related issues

  • #628 (closed) — original feature request for ! re-include support.
  • #676 (closed) — duplicate request, includes the exact failing example pattern (* + !src/ + !src/**).
  • #861 (closed) — different .graphifyignore bypass (auto-converted office sidecars sit under graphify-out/converted/ and bypass _is_ignored entirely); same family of issue but a separate code path, not a duplicate.

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