From 8d24570eb3404a7f8c16410e008d7367c584e97f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Webmania=C2=AE?= Date: Tue, 13 Oct 2026 01:21:17 -0300 Subject: [PATCH 1/3] feat(ingest): configurable file filters for every repo walk Add src/ingest/filters.ts and thread one per-walk FileFilter through the three enumeration paths in walkDir (shallow git, recursive git, filesystem): - GRAFT_MAX_FILE_BYTES overrides the 1 MB per-file cap; an invalid value warns once and keeps the default. - .graftignore (repo root), $GIT_COMMON_DIR/info/graftignore (clone-local, shared by worktrees) and ~/.graft/ignore (user-wide) exclude paths with gitignore-style patterns. Unlike .gitignore they apply to tracked files. - GRAFT_SKIP_MINIFIED=1 skips .js/.mjs/.cjs files of at least 100 KB whose longest line exceeds 20,000 characters (memoized by size+mtime). Everything is read at walk time, so build, the fingerprint probe, the pre-query refresh, the MCP server and the sync hook agree on the file set. Co-Authored-By: Claude Code --- src/ingest/filters.ts | 509 ++++++++++++++++++++++++++++++++++++++++++ src/ingest/fs.ts | 51 +++-- 2 files changed, 547 insertions(+), 13 deletions(-) create mode 100644 src/ingest/filters.ts diff --git a/src/ingest/filters.ts b/src/ingest/filters.ts new file mode 100644 index 000000000..88d8cf1ff --- /dev/null +++ b/src/ingest/filters.ts @@ -0,0 +1,509 @@ +/** + * User-configurable file filters applied by every repo walk (`walkDir` in + * `./fs.ts`): a size cap, `.graftignore`-style glob excludes, and an opt-in + * skip for minified/bundled JavaScript. + * + * Everything here is read from the environment and from ignore files AT WALK + * TIME, never from CLI flags. That is deliberate: the fingerprint probe, the + * pre-query auto-refresh, the MCP server and the Claude Code sync hook all + * enumerate through `walkDir` without ever seeing a flag, so a filter that + * lived only on the command line would make a build and the next refresh + * disagree about the file set — an endless "drift" loop. + */ +import { existsSync, readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; + +/** Default size cap: files above this are generated/vendored in practice, not + * hand-written code. Override with `GRAFT_MAX_FILE_BYTES`. */ +export const DEFAULT_MAX_FILE_BYTES = 1_000_000; + +/** `GRAFT_SKIP_MINIFIED=1` only inspects JavaScript files at least this big. */ +export const MINIFIED_MIN_BYTES = 100_000; + +/** ...and treats one as minified when its longest line exceeds this many + * characters. Hand-written code stays far below it; bundlers and minifiers + * emit whole files as one or two lines. */ +export const MINIFIED_MIN_LINE_CHARS = 20_000; + +/** Extensions `GRAFT_SKIP_MINIFIED` applies to. */ +export const MINIFIED_EXTENSIONS: ReadonlySet = new Set([".js", ".mjs", ".cjs"]); + +/** Repo-root ignore file, versioned with the code. */ +export const GRAFTIGNORE_FILE = ".graftignore"; + +type Env = NodeJS.ProcessEnv; + +/** Same truthy parsing as the other `GRAFT_*` switches. */ +function envTruthy(env: Env, name: string): boolean { + const v = env[name]; + return v !== undefined && v !== "" && v !== "0" && v !== "false"; +} + +let warnedMaxBytes: string | undefined; + +/** + * The effective per-file size cap: `GRAFT_MAX_FILE_BYTES` when it is a positive + * integer, {@link DEFAULT_MAX_FILE_BYTES} otherwise. An invalid value warns once + * per process (on stderr — stdout may be an MCP transport) and falls back to the + * default rather than failing a build or a query. + */ +export function maxFileBytes(env: Env = process.env): number { + const raw = env.GRAFT_MAX_FILE_BYTES; + if (raw === undefined || raw.trim() === "") return DEFAULT_MAX_FILE_BYTES; + const value = raw.trim(); + const n = Number(value); + if (/^\d+$/.test(value) && Number.isSafeInteger(n) && n > 0) return n; + if (warnedMaxBytes !== value) { + warnedMaxBytes = value; + console.warn( + `graft: ignoring GRAFT_MAX_FILE_BYTES=${JSON.stringify(raw)} (expected a positive integer); using ${DEFAULT_MAX_FILE_BYTES}`, + ); + } + return DEFAULT_MAX_FILE_BYTES; +} + +/** Whether `GRAFT_SKIP_MINIFIED` is on. */ +export function skipMinifiedEnabled(env: Env = process.env): boolean { + return envTruthy(env, "GRAFT_SKIP_MINIFIED"); +} + +/** Length of the longest line in `text`, in UTF-16 code units. */ +export function longestLine(text: string): number { + let longest = 0; + let start = 0; + for (;;) { + const nl = text.indexOf("\n", start); + const end = nl === -1 ? text.length : nl; + if (end - start > longest) longest = end - start; + if (nl === -1) return longest; + start = nl + 1; + } +} + +function extensionOf(path: string): string { + const slash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + const dot = path.lastIndexOf("."); + return dot > slash ? path.slice(dot).toLowerCase() : ""; +} + +/** Memo keyed by path and invalidated by (size, mtime): the MCP server and the + * pre-query probe walk the same tree over and over, and reading every large + * bundle on each query would cost more than the probe itself. */ +const minifiedMemo = new Map(); + +/** + * Whether `abs` is a minified/bundled JavaScript file: a `.js`/`.mjs`/`.cjs` + * of at least {@link MINIFIED_MIN_BYTES} whose longest line is longer than + * {@link MINIFIED_MIN_LINE_CHARS}. Callers pass the stat they already took. + * An unreadable file is reported as not minified — the walk's other guards + * decide what happens to it. + */ +export function isMinifiedJs(abs: string, stat: { size: number; mtimeMs: number }): boolean { + if (stat.size < MINIFIED_MIN_BYTES || !MINIFIED_EXTENSIONS.has(extensionOf(abs))) return false; + const memo = minifiedMemo.get(abs); + if (memo && memo.size === stat.size && memo.mtimeMs === stat.mtimeMs) return memo.minified; + let minified = false; + try { + minified = longestLine(readFileSync(abs, "utf8")) > MINIFIED_MIN_LINE_CHARS; + } catch { + minified = false; + } + minifiedMemo.set(abs, { size: stat.size, mtimeMs: stat.mtimeMs, minified }); + return minified; +} + +// --------------------------------------------------------------------------- +// .graftignore +// --------------------------------------------------------------------------- + +// Pattern matching is done WITHOUT regular expressions. A glob translated to a +// regex (one "[^/]*" per star, an optional ".*/" group per globstar) backtracks +// polynomially, and `.graftignore` content is arbitrary user input evaluated +// against every path on every query's freshness probe: a pattern such as +// `**/**/**/a*a*a*b` against a long path would stall the walk. Instead a +// pattern is split into path segments and matched with the classic greedy +// wildcard algorithm at two levels: characters within a segment (`*`), and +// segments within a path (`**`). With a single backtrack point per level that +// is O(pattern x input) at each level, whatever the pattern. + +/** One unit of a segment pattern: a literal character, `?`, a `[...]` class or `*`. */ +type CharToken = + | { kind: "lit"; ch: string } + | { kind: "any" } + | { kind: "class"; negated: boolean; items: Array<[string, string]> } + | { kind: "star" }; + +/** One unit of a path pattern: a segment pattern, or `**` (any number of segments). */ +type SegToken = { kind: "seg"; chars: CharToken[] } | { kind: "globstar" }; + +interface IgnoreRule { + segments: SegToken[]; + negate: boolean; + dirOnly: boolean; +} + +/** Tokenize one path segment of a glob. Consecutive `*` collapse into one. */ +function tokenizeSegment(glob: string): CharToken[] { + const out: CharToken[] = []; + let i = 0; + while (i < glob.length) { + const ch = glob[i]; + if (ch === "*") { + if (out.length === 0 || out[out.length - 1].kind !== "star") out.push({ kind: "star" }); + } else if (ch === "?") { + out.push({ kind: "any" }); + } else if (ch === "\\" && i + 1 < glob.length) { + i += 1; + out.push({ kind: "lit", ch: glob[i] }); + } else if (ch === "[") { + const parsed = parseClass(glob, i); + if (parsed) { + out.push(parsed.token); + i = parsed.end; + } else { + out.push({ kind: "lit", ch: "[" }); + } + } else { + out.push({ kind: "lit", ch }); + } + i += 1; + } + return out; +} + +/** Parse a `[...]` class starting at `glob[start] === "["`; `null` when unterminated. */ +function parseClass(glob: string, start: number): { token: CharToken; end: number } | null { + let i = start + 1; + let negated = false; + if (glob[i] === "!" || glob[i] === "^") { + negated = true; + i += 1; + } + const items: Array<[string, string]> = []; + let first = true; + while (i < glob.length) { + let ch = glob[i]; + if (ch === "]" && !first) return { token: { kind: "class", negated, items }, end: i }; + first = false; + if (ch === "\\" && i + 1 < glob.length) { + i += 1; + ch = glob[i]; + } + if (glob[i + 1] === "-" && i + 2 < glob.length && glob[i + 2] !== "]") { + let hi = glob[i + 2]; + let skip = 2; + if (hi === "\\" && i + 3 < glob.length) { + hi = glob[i + 3]; + skip = 3; + } + items.push([ch, hi]); + i += skip + 1; + continue; + } + items.push([ch, ch]); + i += 1; + } + return null; +} + +function charMatches(token: CharToken, ch: string): boolean { + switch (token.kind) { + case "lit": + return token.ch === ch; + case "any": + return true; + case "class": { + const hit = token.items.some(([lo, hi]) => ch >= lo && ch <= hi); + return hit !== token.negated; + } + case "star": + return false; + } +} + +/** Greedy wildcard match of one segment — one backtrack point, no recursion. */ +function segmentMatches(tokens: CharToken[], text: string): boolean { + let t = 0; + let s = 0; + let starT = -1; + let starS = 0; + while (s < text.length) { + if (t < tokens.length && tokens[t].kind !== "star" && charMatches(tokens[t], text[s])) { + t += 1; + s += 1; + } else if (t < tokens.length && tokens[t].kind === "star") { + starT = t; + starS = s; + t += 1; + } else if (starT !== -1) { + t = starT + 1; + starS += 1; + s = starS; + } else { + return false; + } + } + while (t < tokens.length && tokens[t].kind === "star") t += 1; + return t === tokens.length; +} + +/** The same greedy algorithm one level up: segment patterns over path segments, + * with `**` as the star. */ +function pathMatches(tokens: SegToken[], segments: string[], length = segments.length): boolean { + let t = 0; + let s = 0; + let starT = -1; + let starS = 0; + while (s < length) { + const tok = tokens[t]; + if (tok !== undefined && tok.kind === "seg" && segmentMatches(tok.chars, segments[s])) { + t += 1; + s += 1; + } else if (tok !== undefined && tok.kind === "globstar") { + starT = t; + starS = s; + t += 1; + } else if (starT !== -1) { + t = starT + 1; + starS += 1; + s = starS; + } else { + return false; + } + } + while (t < tokens.length && tokens[t].kind === "globstar") t += 1; + return t === tokens.length; +} + +/** Compile the glob part of one pattern into segment tokens. */ +function compileSegments(glob: string, anchored: boolean): SegToken[] { + const parts = glob.split("/"); + const out: SegToken[] = []; + // An unanchored pattern matches at any depth: an implicit leading `**`. + if (!anchored) out.push({ kind: "globstar" }); + parts.forEach((part, i) => { + if (part === "**") { + if (i === parts.length - 1 && i > 0) { + // Trailing `/**` matches everything INSIDE, not the directory itself: + // at least one more segment. + out.push({ kind: "seg", chars: [{ kind: "star" }] }); + } + if (out.length === 0 || out[out.length - 1].kind !== "globstar") out.push({ kind: "globstar" }); + return; + } + // Any other `**` (inside a segment) is an ordinary `*`, as in Git; the + // tokenizer collapses it. + out.push({ kind: "seg", chars: tokenizeSegment(part) }); + }); + return out; +} + +/** Strip trailing whitespace unless the last one is escaped (`\ `). A manual + * loop rather than `/\s+$/`, which is quadratic on a long run of spaces. */ +function trimTrailing(p: string): string { + let end = p.length; + while (end > 0 && /\s/.test(p[end - 1])) { + if (end >= 2 && p[end - 2] === "\\") break; + end -= 1; + } + return p.slice(0, end); +} + +/** Compile one line of an ignore file; `null` for blanks and comments. */ +function compileRule(line: string): IgnoreRule | null { + let p = trimTrailing(line.endsWith("\r") ? line.slice(0, -1) : line); + if (p === "" || p.startsWith("#")) return null; + let negate = false; + if (p.startsWith("!")) { + negate = true; + p = p.slice(1); + } else if (p.startsWith("\\!") || p.startsWith("\\#")) { + p = p.slice(1); + } + let dirOnly = false; + while (p.endsWith("/")) { + dirOnly = true; + p = p.slice(0, -1); + } + if (p === "") return null; + // A slash anywhere but the end anchors the pattern to the base directory; + // without one it matches at any depth. + const anchored = p.includes("/"); + while (p.startsWith("/")) p = p.slice(1); + if (p === "") return null; + return { segments: compileSegments(p, anchored), negate, dirOnly }; +} + +/** Parse ignore-file text into rules, in file order. */ +export function parseIgnore(text: string): IgnoreRule[] { + const rules: IgnoreRule[] = []; + for (const line of text.split("\n")) { + const rule = compileRule(line); + if (rule) rules.push(rule); + } + return rules; +} + +/** + * A compiled set of ignore rules, matched against posix paths relative to + * {@link IgnoreMatcher.base}. Semantics follow `.gitignore`: last matching rule + * wins, `!` re-includes, a trailing `/` matches directories only, and — as in + * Git — a file cannot be re-included when one of its parent directories is + * excluded. + */ +export class IgnoreMatcher { + private readonly dirMemo = new Map(); + + constructor( + readonly base: string, + private readonly rules: IgnoreRule[], + ) {} + + get empty(): boolean { + return this.rules.length === 0; + } + + /** Whether the first `length` of `segments` (a path) is excluded by the rules alone. */ + private matches(segments: string[], length: number, isDir: boolean): boolean { + let ignored = false; + for (const rule of this.rules) { + if (rule.dirOnly && !isDir) continue; + if (pathMatches(rule.segments, segments, length)) ignored = !rule.negate; + } + return ignored; + } + + /** Directory verdicts are shared by every file beneath them, so memoize them + * per walk; keyed by the directory's base-relative path. */ + private dirIgnored(key: string, segments: string[], length: number): boolean { + let hit = this.dirMemo.get(key); + if (hit === undefined) { + hit = this.matches(segments, length, true); + this.dirMemo.set(key, hit); + } + return hit; + } + + /** Whether the posix, base-relative `rel` is excluded. */ + ignoresRel(rel: string, isDir = false): boolean { + if (this.rules.length === 0 || rel === "") return false; + const segments = rel.split("/"); + let prefix = ""; + for (let i = 0; i < segments.length - 1; i++) { + prefix = prefix === "" ? segments[i] : `${prefix}/${segments[i]}`; + if (this.dirIgnored(prefix, segments, i + 1)) return true; + } + return this.matches(segments, segments.length, isDir); + } + + /** Whether the absolute path `abs` is excluded. Paths outside `base` never are. */ + ignores(abs: string, isDir = false): boolean { + if (this.rules.length === 0) return false; + const rel = relative(this.base, abs); + if (rel === "" || rel === ".." || rel.startsWith("../") || rel.startsWith("..\\") || isAbsolute(rel)) { + return false; + } + return this.ignoresRel(rel.replace(/\\/g, "/"), isDir); + } +} + +/** The repository's top-level directory and Git common dir, found by walking up + * from `dir` for a `.git` entry. Read from disk instead of spawning + * `git rev-parse`: this runs on every query's freshness probe. In a linked + * worktree `.git` is a file pointing at `/worktrees/`, whose + * `commondir` file names the shared directory. */ +export function findGitDirs(dir: string): { top: string; commonDir: string } | null { + let cur = resolve(dir); + for (;;) { + const dotGit = join(cur, ".git"); + let gitDir: string | null = null; + try { + const st = statSync(dotGit); + if (st.isDirectory()) { + gitDir = dotGit; + } else if (st.isFile()) { + const m = readFileSync(dotGit, "utf8").match(/^gitdir:\s*(.+?)\s*$/m); + if (m) gitDir = resolve(cur, m[1]); + } + } catch { + /* no .git here */ + } + if (gitDir) { + let commonDir = gitDir; + try { + const common = readFileSync(join(gitDir, "commondir"), "utf8").trim(); + if (common) commonDir = resolve(gitDir, common); + } catch { + /* not a linked worktree */ + } + return { top: cur, commonDir }; + } + const parent = dirname(cur); + if (parent === cur) return null; + cur = parent; + } +} + +/** The user-wide ignore file: `~/.graft/ignore`, next to graft's other + * per-user state. */ +export function globalIgnorePath(home: string = homedir()): string { + return join(home, ".graft", "ignore"); +} + +/** The ignore files that apply to a walk rooted at `root`, lowest precedence + * first (later rules override earlier ones, as Git orders core.excludesFile, + * info/exclude and .gitignore): + * + * 1. `~/.graft/ignore` — user-wide + * 2. `$GIT_COMMON_DIR/info/graftignore` — this clone, unversioned, shared by + * every worktree of it + * 3. `/.graftignore` — versioned with the code + * + * Patterns in all three are relative to the repository root (the walk root + * outside Git). */ +export function ignoreFilesFor(root: string, home: string = homedir()): { base: string; files: string[] } { + const git = findGitDirs(root); + const base = git?.top ?? resolve(root); + const files = [globalIgnorePath(home)]; + if (git) files.push(join(git.commonDir, "info", "graftignore")); + files.push(join(base, GRAFTIGNORE_FILE)); + return { base, files }; +} + +/** Load every applicable ignore file for a walk rooted at `root`. Missing or + * unreadable files contribute nothing. */ +export function loadIgnoreMatcher(root: string, home: string = homedir()): IgnoreMatcher { + const { base, files } = ignoreFilesFor(root, home); + const rules: IgnoreRule[] = []; + for (const file of files) { + if (!existsSync(file)) continue; + try { + rules.push(...parseIgnore(readFileSync(file, "utf8"))); + } catch { + /* unreadable ignore file — treat as absent */ + } + } + return new IgnoreMatcher(base, rules); +} + +/** + * The per-walk filter `walkDir` threads through every enumeration path. Built + * once per walk so env and ignore files are read once, not per file. + */ +export interface FileFilter { + /** Excluded by an ignore rule (checked before any stat). */ + ignored(abs: string, isDir?: boolean): boolean; + /** Passes the size cap and, when enabled, the minified-bundle check. */ + admits(abs: string, stat: { size: number; mtimeMs: number }): boolean; +} + +export function createFileFilter(root: string, env: Env = process.env): FileFilter { + const matcher = loadIgnoreMatcher(root); + const maxBytes = maxFileBytes(env); + const skipMinified = skipMinifiedEnabled(env); + return { + ignored: (abs, isDir = false) => matcher.ignores(abs, isDir), + admits: (abs, stat) => stat.size <= maxBytes && !(skipMinified && isMinifiedJs(abs, stat)), + }; +} diff --git a/src/ingest/fs.ts b/src/ingest/fs.ts index 1f24567dd..64fbc87c5 100644 --- a/src/ingest/fs.ts +++ b/src/ingest/fs.ts @@ -4,6 +4,7 @@ import { spawnSync } from "node:child_process"; import { existsSync, lstatSync, readdirSync, realpathSync, statSync } from "node:fs"; import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import { createFileFilter, DEFAULT_MAX_FILE_BYTES, type FileFilter } from "./filters.js"; /** Directories that are dependency/build output, never source. */ export const SKIP_DIRS = new Set([ @@ -24,8 +25,9 @@ export const SKIP_DIRS = new Set([ "venv", ]); -/** Files above this size are generated/vendored in practice, not hand-written code. */ -export const MAX_FILE_BYTES = 1_000_000; +/** Default size cap: files above this are generated/vendored in practice, not + * hand-written code. `GRAFT_MAX_FILE_BYTES` overrides it (see `./filters.ts`). */ +export const MAX_FILE_BYTES = DEFAULT_MAX_FILE_BYTES; /** * Whether a directory named `name` should be skipped when walking a repo tree: @@ -56,7 +58,10 @@ export function shouldSkipDir(name: string, includes?: ReadonlySet): boo /** * Recursively list all files under a directory. Skips dot-directories, * dependency/build directories (node_modules, dist, …) not named in - * `includes`, and files over 1 MB. + * `includes`, files over the size cap (1 MB, or `GRAFT_MAX_FILE_BYTES`), paths + * excluded by `.graftignore`-style rules, and — with `GRAFT_SKIP_MINIFIED=1` — + * minified JavaScript bundles (see `./filters.ts`). Unlike `.gitignore`, the + * graftignore rules apply to TRACKED files too: that is their whole point. * In a Git worktree, tracked files plus untracked, non-ignored files come from * `git ls-files`; this gives indexing exactly Git's nested `.gitignore`, * negation, and global-exclude semantics. Non-Git directories retain the plain @@ -158,7 +163,12 @@ export function walkDir( ): string[] { const requested = resolve(dir); const root = canonicalWalkRoot(requested); - const files = gitVisibleFiles(root, includes, opts) ?? walkFilesystem(root, includes); + // Env and ignore files are read once per walk, so every path below — and the + // fingerprint probe, refreshes and hooks, which all enumerate through here — + // apply the identical filter. + const filter = createFileFilter(root); + const files = + gitVisibleFiles(root, includes, opts, undefined, filter) ?? walkFilesystem(root, includes, filter); return remapWalkPaths(requested, root, files); } @@ -170,13 +180,14 @@ function gitVisibleFiles( includes?: ReadonlySet, opts: WalkOptions = {}, traversal?: { topRoot: string; activeRoots: Set }, + filter: FileFilter = createFileFilter(dir), ): string[] | null { const root = resolve(dir); // The recursive path costs an extra `-t --stage` classification, so take it // only when at least one boundary-crossing opt-in is on. With both off the // walk is byte-for-byte the historical one. if (opts.followSubmodules !== true && opts.followNestedRepos !== true) { - return gitVisibleFilesShallow(root, includes); + return gitVisibleFilesShallow(root, includes, filter); } const state = traversal ?? { topRoot: root, activeRoots: new Set() }; @@ -245,6 +256,7 @@ function gitVisibleFiles( // mounted at vendor/ or build/ would bypass the parent's skip policy when // recursion resets its relative root. if (skippedPath(relative(state.topRoot, abs), includes)) continue; + if (filter.ignored(abs, entry.gitlink || entry.nested)) continue; const followChild = (entry.gitlink && opts.followSubmodules === true) || @@ -255,7 +267,7 @@ function gitVisibleFiles( // Requiring the child's own .git prevents duplicate, mis-prefixed parent // files and recursive loops. if (!existsSync(join(abs, ".git"))) continue; - let childFiles = gitVisibleFiles(abs, includes, opts, state); + let childFiles = gitVisibleFiles(abs, includes, opts, state, filter); if (childFiles === null) { // Do not let one broken child recreate a "healthy but incomplete" // graph. Match the top-level fail-soft contract locally: fall back to @@ -265,7 +277,7 @@ function gitVisibleFiles( // top-level Git command fails and walkDir uses its filesystem fallback. // Let an unreadable filesystem fallback surface rather than claiming a // healthy graph that silently omitted the child again. - childFiles = walkFilesystem(abs, includes); + childFiles = walkFilesystem(abs, includes, filter); } for (const file of childFiles) out.add(file); continue; @@ -273,7 +285,7 @@ function gitVisibleFiles( try { const stat = lstatSync(abs); - if (!stat.isFile() || stat.size > MAX_FILE_BYTES) continue; + if (!stat.isFile() || !filter.admits(abs, stat)) continue; } catch { // A tracked file deleted from the working tree is still printed by // `--cached`; absence means it is not part of the current source set. @@ -289,7 +301,11 @@ function gitVisibleFiles( /** The historical, non-recursive Git path. Kept separate so the default does * exactly the same command, filtering, ordering, and duplicate handling as it * did before submodule support existed. */ -function gitVisibleFilesShallow(root: string, includes?: ReadonlySet): string[] | null { +function gitVisibleFilesShallow( + root: string, + includes?: ReadonlySet, + filter: FileFilter = createFileFilter(root), +): string[] | null { const result = spawnSync( "git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z", "--"], @@ -306,9 +322,10 @@ function gitVisibleFilesShallow(root: string, includes?: ReadonlySet): s for (const rel of result.stdout.split("\0")) { if (!rel || skippedPath(rel, includes)) continue; const abs = resolve(root, rel); + if (filter.ignored(abs)) continue; try { const stat = lstatSync(abs); - if (!stat.isFile() || stat.size > MAX_FILE_BYTES) continue; + if (!stat.isFile() || !filter.admits(abs, stat)) continue; } catch { // A tracked file deleted from the working tree is still printed by // `--cached`; absence means it is not part of the current source set. @@ -326,17 +343,25 @@ function skippedPath(path: string, includes?: ReadonlySet): boolean { return path.replace(/\\/g, "/").split("/").some((segment) => shouldSkipDir(segment, includes)); } -function walkFilesystem(dir: string, includes?: ReadonlySet): string[] { +function walkFilesystem( + dir: string, + includes?: ReadonlySet, + filter: FileFilter = createFileFilter(dir), +): string[] { const out: string[] = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = join(dir, entry.name); if (entry.isDirectory()) { if (shouldSkipDir(entry.name, includes)) continue; - out.push(...walkFilesystem(full, includes)); + // An excluded directory is pruned whole, as in Git: nothing under it can + // be re-included. + if (filter.ignored(full, true)) continue; + out.push(...walkFilesystem(full, includes, filter)); } else if (entry.isFile()) { if (entry.name.startsWith(".")) continue; // dot-files are not source either + if (filter.ignored(full)) continue; try { - if (statSync(full).size > MAX_FILE_BYTES) continue; + if (!filter.admits(full, statSync(full))) continue; } catch { continue; } From 59cd4e508782ecb854269cfa0f7e1d0e3984376c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Webmania=C2=AE?= Date: Tue, 13 Oct 2026 09:48:20 -0300 Subject: [PATCH 2/3] test(ingest): cover size cap, graftignore sources and minified skip Includes an end-to-end build followed by the freshness probe, so a filter that the refresh path saw differently would show up as drift. Co-Authored-By: Claude Code --- test/ingest-filters.test.ts | 450 ++++++++++++++++++++++++++++++++++++ 1 file changed, 450 insertions(+) create mode 100644 test/ingest-filters.test.ts diff --git a/test/ingest-filters.test.ts b/test/ingest-filters.test.ts new file mode 100644 index 000000000..bb7f125d0 --- /dev/null +++ b/test/ingest-filters.test.ts @@ -0,0 +1,450 @@ +/** + * Configurable file filters: `GRAFT_MAX_FILE_BYTES`, `.graftignore` (plus the + * user-wide `~/.graft/ignore` and the clone-local `info/graftignore`), and the + * opt-in `GRAFT_SKIP_MINIFIED` bundle skip. + * + * Each is read at walk time, so the build, the fingerprint probe and the + * hooks/MCP refresh path — none of which see a CLI flag — must agree on the + * resulting file set. The end-to-end tests below check exactly that: a build + * followed by a probe reports clean, never a perpetual drift loop. + */ +import { execFileSync } from "node:child_process"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; +import { walkDir } from "../src/ingest/fs.js"; +import { + DEFAULT_MAX_FILE_BYTES, + MINIFIED_MIN_BYTES, + MINIFIED_MIN_LINE_CHARS, + IgnoreMatcher, + findGitDirs, + isMinifiedJs, + longestLine, + maxFileBytes, + parseIgnore, +} from "../src/ingest/filters.js"; +import { readGraph, wiringPath } from "../src/graph/write.js"; +import { probeDrift, isClean } from "../src/graph/fingerprint.js"; + +function gitRepo(tag: string): string { + const dir = mkdtempSync(join(tmpdir(), `graft-filters-${tag}-`)); + execFileSync("git", ["init", "-q"], { cwd: dir }); + return dir; +} + +function plainDir(tag: string): string { + return mkdtempSync(join(tmpdir(), `graft-filters-${tag}-`)); +} + +function write(root: string, path: string, content = "export const value = 1;\n"): void { + mkdirSync(join(root, path, ".."), { recursive: true }); + writeFileSync(join(root, path), content); +} + +function git(root: string, args: string[]): void { + execFileSync("git", args, { cwd: root, stdio: "ignore" }); +} + +function commitAll(root: string): void { + git(root, ["add", "-A"]); + git(root, [ + "-c", "user.name=Graft Tests", + "-c", "user.email=graft-tests@example.invalid", + "commit", "-qm", "fixture", + ]); +} + +function walked(root: string): string[] { + return walkDir(root).map((p) => relative(root, p).replace(/\\/g, "/")).sort(); +} + +/** Run `fn` with `vars` applied to process.env, restoring the old values after. */ +function withEnv(vars: Record, fn: () => T): T { + const saved: Record = {}; + for (const [k, v] of Object.entries(vars)) { + saved[k] = process.env[k]; + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + try { + return fn(); + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } +} + +/** An isolated HOME, so a developer's real `~/.graft/ignore` never leaks in. */ +function withHome(home: string, fn: () => T): T { + return withEnv({ HOME: home, USERPROFILE: home }, fn); +} + +function runCli(args: string[], env: Record = {}): void { + execFileSync(process.execPath, ["--import", "tsx", "src/cli.ts", ...args], { + stdio: "pipe", + env: { ...process.env, ...env }, + }); +} + +const bigLine = (n: number): string => `var a=${"1+".repeat(Math.ceil(n / 2))}1;\n`; + +// --------------------------------------------------------------------------- +// GRAFT_MAX_FILE_BYTES +// --------------------------------------------------------------------------- + +test("maxFileBytes: default, a valid override, and invalid values falling back to the default", () => { + assert.equal(DEFAULT_MAX_FILE_BYTES, 1_000_000); + assert.equal(maxFileBytes({}), DEFAULT_MAX_FILE_BYTES); + assert.equal(maxFileBytes({ GRAFT_MAX_FILE_BYTES: "" }), DEFAULT_MAX_FILE_BYTES); + assert.equal(maxFileBytes({ GRAFT_MAX_FILE_BYTES: "2000000" }), 2_000_000); + assert.equal(maxFileBytes({ GRAFT_MAX_FILE_BYTES: " 1500 " }), 1500); + for (const bad of ["0", "-5", "1.5", "abc", "2e6", "1_000", "99999999999999999999"]) { + assert.equal(maxFileBytes({ GRAFT_MAX_FILE_BYTES: bad }), DEFAULT_MAX_FILE_BYTES, bad); + } +}); + +test("GRAFT_MAX_FILE_BYTES raises (and lowers) the cap for Git and non-Git walks", () => { + const home = plainDir("home"); + for (const make of [gitRepo, plainDir]) { + const dir = make("maxbytes"); + try { + write(dir, "small.php", " { + withEnv({ GRAFT_MAX_FILE_BYTES: undefined }, () => + assert.deepEqual(walked(dir), ["small.php"], "a file over 1 MB is skipped by default"), + ); + withEnv({ GRAFT_MAX_FILE_BYTES: "2000000" }, () => + assert.deepEqual(walked(dir), ["large.php", "small.php"]), + ); + withEnv({ GRAFT_MAX_FILE_BYTES: "10" }, () => assert.deepEqual(walked(dir), [])); + withEnv({ GRAFT_MAX_FILE_BYTES: "not-a-number" }, () => + assert.deepEqual(walked(dir), ["small.php"], "an invalid value keeps the default"), + ); + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + rmSync(home, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// .graftignore pattern semantics +// --------------------------------------------------------------------------- + +function matcher(text: string): IgnoreMatcher { + return new IgnoreMatcher("/repo", parseIgnore(text)); +} + +test("ignore patterns: unanchored, anchored, **, dir-only, negation, comments, classes", () => { + const m = matcher( + [ + "# a comment", + "", + "*.min.js", + "/root-only.ts", + "docs/*.md", + "**/webdriver/extensions/**", + "cache/", + "generated/**", + "!generated/keep.ts", + "file[0-9].ts", + "\\#literal.ts", + ].join("\n"), + ); + const yes = [ + "a.min.js", + "deep/nested/b.min.js", + "root-only.ts", + "docs/readme.md", + "app/webdriver/extensions/cap/bundle.js", + "webdriver/extensions/x.js", + "src/cache/blob.ts", + "generated/drop.ts", + "file7.ts", + "#literal.ts", + ]; + const no = [ + "a.js", + "sub/root-only.ts", + "docs/deep/readme.md", + "app/webdriver/other.js", + "cache.ts", + "fileA.ts", + ]; + for (const p of yes) assert.equal(m.ignoresRel(p), true, `expected ${p} ignored`); + for (const p of no) assert.equal(m.ignoresRel(p), false, `expected ${p} kept`); + // `generated/**` matches the files, not the directory, so a later `!` can + // re-include one of them — exactly as in Git. + assert.equal(m.ignoresRel("generated/keep.ts"), false); +}); + +test("ignore patterns: pathological globs finish in bounded time (no ReDoS)", () => { + const patterns = [ + "**/**/**/**/a*a*a*a*b", + "a*a*a*a*a*a*a*a*a*a*a*a*b", + "**/a/**/a/**/a/**/a/**/b", + "*?*?*?*?*?*?*?*?*?*?*b", + `${"*".repeat(500)}b`, + `x${" ".repeat(20_000)}y`, + ]; + const m = matcher(patterns.join("\n")); + const longSegment = "a".repeat(10_000); + // Far deeper than any real checkout. (A regex translation of these patterns + // does not finish on these inputs at all; the bound is generous so a loaded + // CI machine running the suite in parallel stays well under it.) + const deepPath = Array.from({ length: 200 }, () => "a").join("/"); + const started = performance.now(); + assert.equal(m.ignoresRel(longSegment), false); + assert.equal(m.ignoresRel(`dir/${longSegment}`), false); + assert.equal(m.ignoresRel(deepPath), false); + const elapsed = performance.now() - started; + assert.ok(elapsed < 50, `matching took ${elapsed.toFixed(1)}ms`); + // ...and the patterns still mean what they say. + assert.equal(m.ignoresRel(`x/y/${"a".repeat(8)}b`), true); + assert.equal(m.ignoresRel("p/a/q/a/r/a/s/a/t/b"), true); +}); + +test("ignore patterns: trailing /** matches inside a directory but not the directory itself", () => { + const m = matcher("assets/**\n"); + assert.equal(m.ignoresRel("assets", true), false); + assert.equal(m.ignoresRel("assets/a.js"), true); + assert.equal(m.ignoresRel("assets/deep/a.js"), true); +}); + +test("ignore patterns: a file cannot be re-included when its parent directory is excluded", () => { + const m = matcher("build-out/\n!build-out/keep.ts\n"); + assert.equal(m.ignoresRel("build-out/keep.ts"), true); + assert.equal(m.ignoresRel("build-out/drop.ts"), true); +}); + +test("ignore patterns: last matching rule wins", () => { + const m = matcher("*.ts\n!*.ts\n*.gen.ts\n"); + assert.equal(m.ignoresRel("a.ts"), false); + assert.equal(m.ignoresRel("a.gen.ts"), true); +}); + +test("IgnoreMatcher.ignores: paths outside the base are never excluded", () => { + const m = matcher("*\n"); + assert.equal(m.ignores("/elsewhere/a.ts"), false); + assert.equal(m.ignores("/repo/a.ts"), true); +}); + +// --------------------------------------------------------------------------- +// .graftignore, info/graftignore and ~/.graft/ignore in real walks +// --------------------------------------------------------------------------- + +test(".graftignore excludes TRACKED files (which .gitignore cannot), in a Git repo", () => { + const dir = gitRepo("tracked"); + const home = plainDir("home"); + try { + write(dir, "src/app.ts"); + write(dir, "public/js/vendor.min.js", "var x=1;\n"); + write(dir, "public/emissor/app.js"); + write(dir, "public/keep.js"); + commitAll(dir); + write(dir, "untracked.min.js", "var y=2;\n"); + write(dir, ".graftignore", "*.min.js\npublic/emissor/\n"); + + withHome(home, () => assert.deepEqual(walked(dir), ["public/keep.js", "src/app.ts"])); + } finally { + rmSync(dir, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } +}); + +test(".graftignore applies to the filesystem walk outside Git, pruning excluded directories", () => { + const dir = plainDir("nogit"); + const home = plainDir("home"); + try { + write(dir, "src/app.ts"); + write(dir, "src/lib.min.js", "var x=1;\n"); + write(dir, "assets/extensions/bundle.js"); + write(dir, ".graftignore", "*.min.js\nassets/extensions/\n"); + withHome(home, () => assert.deepEqual(walked(dir), ["src/app.ts"])); + } finally { + rmSync(dir, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } +}); + +test("info/graftignore lives in the Git common dir and is shared by every linked worktree", () => { + const dir = gitRepo("info"); + const home = plainDir("home"); + const wt = join(plainDir("wt-parent"), "wt"); + try { + write(dir, "src/app.ts"); + write(dir, "src/bundle.min.js", "var x=1;\n"); + commitAll(dir); + git(dir, ["worktree", "add", "-q", wt]); + write(join(dir, ".git"), "info/graftignore", "*.min.js\n"); + + const dirs = findGitDirs(wt); + assert.ok(dirs, "the linked worktree resolves to a repository"); + assert.equal(realpathSync(dirs!.commonDir), realpathSync(join(dir, ".git")), "commondir points at the main .git"); + + withHome(home, () => { + assert.deepEqual(walked(dir), ["src/app.ts"]); + assert.deepEqual(walked(wt), ["src/app.ts"]); + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + rmSync(join(wt, ".."), { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } +}); + +test("~/.graft/ignore applies to every repo, and the repo's .graftignore overrides it", () => { + const dir = gitRepo("global"); + const home = plainDir("home"); + try { + write(dir, "src/app.ts"); + write(dir, "src/a.min.js", "var x=1;\n"); + write(dir, "src/b.min.js", "var x=1;\n"); + write(home, ".graft/ignore", "*.min.js\n"); + withHome(home, () => assert.deepEqual(walked(dir), ["src/app.ts"])); + + write(dir, ".graftignore", "!src/b.min.js\n"); + withHome(home, () => assert.deepEqual(walked(dir), ["src/app.ts", "src/b.min.js"])); + } finally { + rmSync(dir, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } +}); + +test(".graftignore applies inside followed submodules, against superproject paths", () => { + const child = gitRepo("child"); + const parent = gitRepo("parent"); + const home = plainDir("home"); + try { + write(child, "lib.ts"); + write(child, "dist-bundle.min.js", "var x=1;\n"); + commitAll(child); + write(parent, "main.ts"); + git(parent, [ + "-c", "protocol.file.allow=always", + "submodule", "add", "-q", child.replace(/\\/g, "/"), "deps/child", + ]); + commitAll(parent); + write(parent, ".graftignore", "deps/child/*.min.js\n"); + + withHome(home, () => { + const files = walkDir(parent, undefined, { followSubmodules: true }) + .map((p) => relative(parent, p).replace(/\\/g, "/")) + .sort(); + assert.deepEqual(files, ["deps/child/lib.ts", "main.ts"]); + }); + } finally { + rmSync(child, { recursive: true, force: true }); + rmSync(parent, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } +}); + +// --------------------------------------------------------------------------- +// GRAFT_SKIP_MINIFIED +// --------------------------------------------------------------------------- + +test("longestLine and isMinifiedJs thresholds", () => { + assert.equal(longestLine(""), 0); + assert.equal(longestLine("ab\nabcd\nc"), 4); + assert.equal(longestLine("abc"), 3); + assert.ok(MINIFIED_MIN_BYTES === 100_000 && MINIFIED_MIN_LINE_CHARS === 20_000); + + const dir = plainDir("minified-unit"); + try { + write(dir, "one-line.js", bigLine(MINIFIED_MIN_BYTES + 10)); + const st = { size: MINIFIED_MIN_BYTES + 20, mtimeMs: 1 }; + assert.equal(isMinifiedJs(join(dir, "one-line.js"), st), true); + assert.equal(isMinifiedJs(join(dir, "one-line.js"), { size: MINIFIED_MIN_BYTES - 1, mtimeMs: 1 }), false, "below the size floor"); + write(dir, "one-line.ts", bigLine(MINIFIED_MIN_BYTES + 10)); + assert.equal(isMinifiedJs(join(dir, "one-line.ts"), st), false, "only .js/.mjs/.cjs are inspected"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("GRAFT_SKIP_MINIFIED=1 skips large single-line JS bundles and keeps everything else", () => { + const dir = gitRepo("minified"); + const home = plainDir("home"); + try { + write(dir, "bundle.js", bigLine(150_000)); // big + one long line → skipped + write(dir, "bundle.cjs", bigLine(150_000)); + write(dir, "bundle.mjs", bigLine(150_000)); + write(dir, "handwritten.js", "function f() { return 1; }\n".repeat(6_000)); // big, short lines + write(dir, "tiny.min.js", bigLine(25_000)); // long line but under the size floor + write(dir, "types.ts", `export const s = "${"x".repeat(150_000)}";\n`); // not JS + commitAll(dir); + + withHome(home, () => { + withEnv({ GRAFT_SKIP_MINIFIED: undefined }, () => + assert.deepEqual(walked(dir), [ + "bundle.cjs", "bundle.js", "bundle.mjs", "handwritten.js", "tiny.min.js", "types.ts", + ]), + ); + withEnv({ GRAFT_SKIP_MINIFIED: "1" }, () => + assert.deepEqual(walked(dir), ["handwritten.js", "tiny.min.js", "types.ts"]), + ); + withEnv({ GRAFT_SKIP_MINIFIED: "0" }, () => assert.equal(walked(dir).length, 6)); + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } +}); + +// --------------------------------------------------------------------------- +// End to end: build, then the probe the hooks/MCP refresh path uses +// --------------------------------------------------------------------------- + +test("a build honors all three filters and the freshness probe agrees (no drift loop)", () => { + const dir = gitRepo("e2e"); + const home = plainDir("home"); + const env = { + HOME: home, + USERPROFILE: home, + GRAFT_MAX_FILE_BYTES: "2000000", + GRAFT_SKIP_MINIFIED: "1", + }; + try { + write(dir, "main.ts", "export function main(): number {\n return 1;\n}\n"); + write(dir, "big.ts", `export function big(): number {\n return 2;\n}\n${"// pad\n".repeat(180_000)}`); + write(dir, "bundle.js", `function bundled(){return 3}${bigLine(150_000)}`); + write(dir, "thirdparty/ignored.ts", "export function ignoredFn(): number {\n return 4;\n}\n"); + write(dir, "lib/skip.ts", "export function skipped(): number {\n return 5;\n}\n"); + commitAll(dir); + write(dir, ".graftignore", "thirdparty/\n"); + + runCli(["build", dir], env); + const graph = readGraph(wiringPath(join(dir, "graft"))); + assert.ok(graph, "expected a built graph"); + const paths = new Set(graph!.nodes.map((n) => n.path)); + assert.ok(paths.has("main.ts")); + assert.ok(paths.has("big.ts"), "GRAFT_MAX_FILE_BYTES lets the 1.26 MB file in"); + assert.ok(paths.has("lib/skip.ts")); + assert.ok(!paths.has("bundle.js"), "GRAFT_SKIP_MINIFIED drops the bundle"); + assert.ok(![...paths].some((p) => p.startsWith("thirdparty/")), ".graftignore drops the tracked dir"); + + withEnv(env, () => { + const drift = probeDrift(dir, join(dir, "graft")); + assert.ok(drift && isClean(drift), `probe must be clean, got ${JSON.stringify(drift)}`); + }); + + // Editing the ignore file is picked up by the next probe — which is what + // makes the pre-query refresh and the MCP server rebuild without a flag. + write(dir, ".graftignore", "thirdparty/\nlib/\n"); + withEnv(env, () => { + const drift = probeDrift(dir, join(dir, "graft")); + assert.deepEqual(drift?.removed, ["lib/skip.ts"]); + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } +}); From 9e0edbb4ce4abc8e32ca7b0810da009c0d41ca7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Webmania=C2=AE?= Date: Tue, 13 Oct 2026 01:21:17 -0300 Subject: [PATCH 3/3] docs: document how to choose which files get indexed Co-Authored-By: Claude Code --- README.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/README.md b/README.md index 709a27680..2274cdf44 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ - [CLI](#cli) - [Search & orient](#search--orient-graft-grep--graft-map) (`graft grep` / `graft map`) - [Monorepos & multi-repo folders](#monorepos--multi-repo-folders) +- [Choosing which files get indexed](#choosing-which-files-get-indexed) - [Visualize it](#visualize-it-graft-viz) (`graft viz`) - [Tested on your popular repos](#tested-on-your-popular-repos) - [Development](#development) @@ -424,6 +425,11 @@ graft upgrade # npm install -g the latest published versi # global graft --dir # use a context dir other than /graft graft --version, -v # print the installed version and exit + +# which files are indexed (see "Choosing which files get indexed"): +# GRAFT_MAX_FILE_BYTES=2000000 # raise the 1 MB per-file cap +# GRAFT_SKIP_MINIFIED=1 # skip minified JS bundles +# .graftignore # gitignore-style excludes that also apply to tracked files ``` Method calls resolve through the receiver's type — constructor assignments @@ -501,6 +507,47 @@ Commands also find the graph from a subdirectory: with no `[dir]` argument they walk up to the nearest `graft/`, so `graft ask` works from `src/deep/inside/` without a `cd` to the repo root. +## Choosing which files get indexed + +Git decides the visible file set: tracked files plus untracked files your +`.gitignore` does not exclude. On top of that graft always skips dot-paths, +dependency/build directories (`node_modules`, `dist`, `vendor`, … — lift one +with `--include-dir`) and files over 1 MB. Three settings adjust this; all of +them are read on every walk, so `graft build`, the pre-query refresh, the MCP +server and the Claude Code hooks always agree on the same file set. + +| Setting | Effect | +| --- | --- | +| `GRAFT_MAX_FILE_BYTES=` | Size cap in bytes (default `1000000`). A value that is not a positive integer is ignored with a warning. | +| `.graftignore` | Exclude paths with gitignore-style patterns — **tracked files included**, which `.gitignore` cannot do. | +| `GRAFT_SKIP_MINIFIED=1` | Skip minified/bundled JavaScript: a `.js`/`.mjs`/`.cjs` of at least 100 KB whose longest line exceeds 20,000 characters. Off by default. | + +Ignore rules are read from three files, lowest precedence first; later rules +override earlier ones, as in Git: + +1. `~/.graft/ignore` — user-wide, applies to every repo. +2. `$GIT_COMMON_DIR/info/graftignore` (usually `.git/info/graftignore`) — + this clone only, never committed, and shared by all of its worktrees. +3. `.graftignore` at the repository root — committed with the code. + +Patterns are relative to the repository root and follow `.gitignore` +syntax: `#` comments, `*`, `?`, `[a-z]`, `**`, a leading `/` or an inner `/` +anchors to the root, a trailing `/` matches directories only, `!` re-includes, +and the last matching rule wins. As in Git, a file cannot be re-included when +a parent directory is excluded. Matching is case-sensitive; rules from a +submodule's own `.graftignore` are not read (write submodule paths in the +superproject's file). + +```gitignore +# .graftignore +**/*.min.js +public/assets/bundles/ +!public/assets/bundles/keep-this.js +``` + +Changing any of these is picked up by the next query's freshness check: files +that drop out are removed from the graph on the following refresh. + ## Visualize it (`graft viz`) `graft viz` opens a local, interactive view of both graphs — no install, no dev