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:
android/ matches (component android exists) → result = True
* matches everything → still True (no state change; correct so far)
!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.
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_ignoreddoes 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-ignoresandroid/app/src/main/x.kteven whenandroid/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
Reference behavior (real
git)Same pattern contents, copied verbatim into
.gitignorein a vanilla git repo, against the same file:Git matches the
*pattern, not!src/. The!src/rule never re-includes the file becauseandroid/(line 1) already excluded the parent. For comparison,src/utils/x.tscorrectly stays included under the same config — git's parent-exclusion rule only kicks in when the parent is actually excluded.Expected vs actual
Same
.graphifyignoreas above:android/app/src/main/x.ktTrue(denied viaandroid/)False← bugandroid/app/lib/x.ktTrue(denied viaandroid/)True✓android/res/mipmap/x.webpTrueTrue✓src/utils/coords.tsFalse(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 underandroid/that don't containsrc/(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 returnsTrueiffnmatch.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.ktunder the example.graphifyignore, evaluated with last-match-wins:android/matches (componentandroidexists) → result =True*matches everything → stillTrue(no state change; correct so far)!src/matches (componentsrcexists) → flipped toFalse← incorrect; parentandroid/was excluded at step 1Real 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 followsandroid/app/src/main/java/.... So the canonical scope.graphifyignore:…silently re-includes the entire
android/tree, because every android file path containssrc/.detect()returns 25+ android files (Kotlin sources, webp icons, splash PNGs, even.cxx/CMake test artifacts underandroid/app/.cxx/Debug/.../src/foo.cpp) that the user's.graphifyignoreexplicitly 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_incrementalto 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: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)
Related issues
!re-include support.*+!src/+!src/**)..graphifyignorebypass (auto-converted office sidecars sit undergraphify-out/converted/and bypass_is_ignoredentirely); same family of issue but a separate code path, not a duplicate.