Bug: .graphifyignore anchored negation (!/dir/) doesn't re-include nested files — "exclude-all, re-include a subtree" collapses to zero files
Version: graphifyy 0.9.17 (also observed on 0.9.16)
Platform: macOS (Darwin), Python 3.13
Summary
The standard gitignore idiom for scoping a build to a subtree —
("ignore everything at the root, then re-include src/") — does not behave like gitignore under graphify. The re-include rescues the bare src directory entry but not any file nested beneath it, so detect() returns zero files. A /dir/** companion is required to make it work.
Under real .gitignore semantics, !/src/ re-includes the directory and its descendants are then eligible (git's rule is subtler — a ! can't rescue a file under a still-excluded dir — but !/src/ does un-exclude the directory itself so its contents are walked). Users reasonably expect .graphifyignore to match .gitignore semantics, especially since graphify reads and merges .gitignore files.
Minimal reproduction
import tempfile
from pathlib import Path
from graphify.detect import detect
base = Path(tempfile.mkdtemp()).resolve() # .resolve() matters on macOS (/var -> /private/var)
for rel in ["src/app/main.py", "src/lib/util.py", "docs/guide.md", "README.md"]:
p = base / rel; p.parent.mkdir(parents=True, exist_ok=True); p.write_text("x\n")
(base / ".graphifyignore").write_text("/*\n!/src/\n")
print("exclude-all + !/src/ :", sum(len(v) for v in detect(base)["files"].values()), "files")
# -> 0 (expected: the 2 files under src/)
(base / ".graphifyignore").write_text("/*\n!/src/\n!/src/**\n")
print("exclude-all + !/src/ + ** :", sum(len(v) for v in detect(base)["files"].values()), "files")
# -> 2 (correct)
Output (deterministic across runs and PYTHONHASHSEED values):
exclude-all + !/src/ : 0 files
exclude-all + !/src/ + ** : 2 files
Root cause (as far as I traced it)
In graphify/detect.py, _is_ignored matches an anchored pattern by stripping the surrounding slashes and using fnmatch. For !/src/ the effective pattern becomes the literal string src, which matches only the exact path src — never src/app/main.py. Meanwhile /* (stripped to *) matches at every depth because fnmatch's * crosses /. Net effect: /* ignores everything including nested files, and !/src/ un-ignores only the directory node, so every file stays ignored.
Impact
Anyone using the documented "scope to a subtree" pattern silently gets an empty graph. It's especially sharp because:
- The bare directory is re-included, so a superficial check ("is
src ignored? no") looks fine.
- graphify merges
.gitignore too, so repos that use dir/* + !dir/keep/ idioms in their existing .gitignore may be mis-scoped without the author realizing.
Workaround (works today)
Pair every anchored directory re-include with a ** companion:
Suggested fix
Make anchored-directory negations (!/dir/) re-include the directory's descendants, matching gitignore semantics — i.e. treat !/dir/ as also covering dir/** for the purpose of un-ignoring, or translate anchored patterns to a form whose * does not cross /.
Note on a red herring: when the reproduction is run with an unresolved tempfile.mkdtemp() path on macOS (/var/... rather than the real /private/var/...), _is_ignored's path relativization mismatches and nothing matches, which can make it look like the negation "works" (files not ignored). Using a resolved absolute path — as any real checkout has — shows the true, deterministic collapse. Mentioning in case it saves triage time.
Bug:
.graphifyignoreanchored negation (!/dir/) doesn't re-include nested files — "exclude-all, re-include a subtree" collapses to zero filesVersion: graphifyy 0.9.17 (also observed on 0.9.16)
Platform: macOS (Darwin), Python 3.13
Summary
The standard gitignore idiom for scoping a build to a subtree —
("ignore everything at the root, then re-include
src/") — does not behave like gitignore under graphify. The re-include rescues the baresrcdirectory entry but not any file nested beneath it, sodetect()returns zero files. A/dir/**companion is required to make it work.Under real
.gitignoresemantics,!/src/re-includes the directory and its descendants are then eligible (git's rule is subtler — a!can't rescue a file under a still-excluded dir — but!/src/does un-exclude the directory itself so its contents are walked). Users reasonably expect.graphifyignoreto match.gitignoresemantics, especially since graphify reads and merges.gitignorefiles.Minimal reproduction
Output (deterministic across runs and
PYTHONHASHSEEDvalues):Root cause (as far as I traced it)
In
graphify/detect.py,_is_ignoredmatches an anchored pattern by stripping the surrounding slashes and usingfnmatch. For!/src/the effective pattern becomes the literal stringsrc, which matches only the exact pathsrc— neversrc/app/main.py. Meanwhile/*(stripped to*) matches at every depth becausefnmatch's*crosses/. Net effect:/*ignores everything including nested files, and!/src/un-ignores only the directory node, so every file stays ignored.Impact
Anyone using the documented "scope to a subtree" pattern silently gets an empty graph. It's especially sharp because:
srcignored? no") looks fine..gitignoretoo, so repos that usedir/*+!dir/keep/idioms in their existing.gitignoremay be mis-scoped without the author realizing.Workaround (works today)
Pair every anchored directory re-include with a
**companion:Suggested fix
Make anchored-directory negations (
!/dir/) re-include the directory's descendants, matching gitignore semantics — i.e. treat!/dir/as also coveringdir/**for the purpose of un-ignoring, or translate anchored patterns to a form whose*does not cross/.Note on a red herring: when the reproduction is run with an unresolved
tempfile.mkdtemp()path on macOS (/var/...rather than the real/private/var/...),_is_ignored's path relativization mismatches and nothing matches, which can make it look like the negation "works" (files not ignored). Using a resolved absolute path — as any real checkout has — shows the true, deterministic collapse. Mentioning in case it saves triage time.