From 7c30e3b815aac22a92287361f73316b9f33083ca Mon Sep 17 00:00:00 2001 From: mavaali <40620108+mavaali@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:49:39 -0700 Subject: [PATCH] fix(search): share walker and watcher exclusions --- src/search/watcher.ts | 23 +++-------------- src/storage/local.ts | 10 +++++++- test/search/watcher.test.ts | 51 +++++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 21 deletions(-) diff --git a/src/search/watcher.ts b/src/search/watcher.ts index 7ceb2aa7..a5368519 100644 --- a/src/search/watcher.ts +++ b/src/search/watcher.ts @@ -34,7 +34,7 @@ import { relative, resolve, sep } from "node:path"; import { default as chokidar, type FSWatcher } from "chokidar"; import { ok, type Result } from "../frontmatter/types.js"; import { deleteDocument, openIndexDb } from "../storage/index-db.js"; -import { resolveVaultPath } from "../storage/local.js"; +import { isIgnoredVaultPath, resolveVaultPath } from "../storage/local.js"; import { getIndexStatus, markPathIndexing, markPathReady, onceIndexReady } from "./index-state.js"; import { indexDocument, readManifest, writeManifest } from "./reindex.js"; import { consumeSelfWrite } from "./self-write.js"; @@ -148,23 +148,6 @@ function toVaultRelative(vaultRoot: string, absPath: string): string | null { return sep === "/" ? rel : rel.split(sep).join("/"); } -// Returns true when chokidar's path points inside a directory we want to -// ignore (the .daftari control dir, .git, any other hidden top-level path). -// chokidar's `ignored` option already excludes these at watch time, but we -// double-check at dispatch time because chokidar sometimes ignores its own -// `ignored` pattern for `unlinkDir` events on macOS. -function isIgnoredPath(relPath: string): boolean { - // Anything inside .daftari/ is the index itself or a lock file. Watching - // it would feed our own writes back as events. - if (relPath.startsWith(".daftari/") || relPath === ".daftari") return true; - // .git/ — same problem, plus we don't index git internals. - if (relPath.startsWith(".git/") || relPath === ".git") return true; - // Other hidden top-level paths (editor swap files, etc). - const first = relPath.split("/")[0] ?? ""; - if (first.startsWith(".") && first !== ".") return true; - return false; -} - // Markdown-only: chokidar watches every file under the root, but only .md // files are indexed. Skipping non-markdown here keeps random sibling files // (LICENSE, CHANGELOG.md aside — .md, that counts — images, .DS_Store) from @@ -187,7 +170,7 @@ export function watchIgnored(root: string, p: string, stats?: Stats): boolean { if (p === root) return false; const rel = toVaultRelative(root, p); if (rel === null) return false; - if (isIgnoredPath(rel)) return true; + if (isIgnoredVaultPath(rel)) return true; return stats?.isFile() === true && !isMarkdown(rel); } @@ -343,7 +326,7 @@ export function startWatcher(vaultRoot: string, opts: WatcherOptions = {}): Vaul // followed by an add inside the window arrives at dispatch() as an add. function schedule(relPath: string, event: PendingEvent): void { if (closed) return; - if (isIgnoredPath(relPath)) return; + if (isIgnoredVaultPath(relPath)) return; if (!isMarkdown(relPath)) return; const existing = pending.get(relPath); diff --git a/src/storage/local.ts b/src/storage/local.ts index 03186fd5..0b3de759 100644 --- a/src/storage/local.ts +++ b/src/storage/local.ts @@ -143,6 +143,14 @@ export async function readFile(absolutePath: string): Promise segment.startsWith(".") || segment === "node_modules"); +} + // Lists files under vaultRoot matching a glob pattern. Returns vault-relative // POSIX-style paths, sorted. The .daftari control directory is always excluded. export async function listFiles( @@ -161,7 +169,7 @@ export async function listFiles( // versions. .daftari (control dir) and node_modules are excluded too. ignore: ["**/.daftari/**", "**/node_modules/**", "**/.obsidian/**", "**/.trash/**"], }); - return ok([...matches].sort()); + return ok(matches.filter((path) => !isIgnoredVaultPath(path)).sort()); } catch (e) { const reason = e instanceof Error ? e.message : String(e); return err(new Error(`cannot list files: ${reason}`)); diff --git a/test/search/watcher.test.ts b/test/search/watcher.test.ts index b8e3fe72..4a5fd38b 100644 --- a/test/search/watcher.test.ts +++ b/test/search/watcher.test.ts @@ -14,6 +14,7 @@ import { err, ok, type Result } from "../../src/frontmatter/types.js"; import { getInflightPaths, resetIndexState } from "../../src/search/index-state.js"; import { noteSelfWrite, resetSelfWriteState } from "../../src/search/self-write.js"; import { startWatcher, watchIgnored } from "../../src/search/watcher.js"; +import { listFiles } from "../../src/storage/local.js"; // Sleep helper: tests use a tiny debounce window (20ms) so a single waitFor // covers both the debounce timer and the indexFn microtask resolution. @@ -280,6 +281,50 @@ describe("startWatcher", () => { await w.close(); }); + it("matches walker exclusions at every depth and rejects leaked events", async () => { + const files = [ + "notes/keep.md", + "notes/my.notes/keep.md", + "node_modules/pkg/readme.md", + "notes/node_modules/pkg/readme.md", + "notes/.cache/note.md", + "notes/.draft.md", + "notes/.git/note.md", + "notes/.obsidian/note.md", + ]; + const calls: string[] = []; + const fake = new FakeChokidar(); + const watcher = startWatcher(vault, { + watcherFactory: () => fake as never, + debounceMs: 20, + indexFn: async (_root, path) => { + calls.push(path); + return ok(undefined); + }, + }); + try { + for (const path of files) { + await mkdir(join(vault, ...path.split("/").slice(0, -1)), { recursive: true }); + await writeFile(abs(vault, path), "fixture"); + fake.emit("add", abs(vault, path)); + } + const listed = await listFiles(vault); + expect(listed.ok).toBe(true); + if (!listed.ok) return; + expect(listed.value).toEqual(["notes/keep.md", "notes/my.notes/keep.md"]); + for (const path of files) { + expect(watchIgnored(vault, abs(vault, path)), path).toBe(!listed.value.includes(path)); + } + for (const dir of ["node_modules", "notes/node_modules", "notes/.cache"]) { + expect(watchIgnored(vault, abs(vault, dir))).toBe(true); + } + await sleep(60); + expect(calls.sort()).toEqual(listed.value); + } finally { + await watcher.close(); + } + }); + it("ignores .daftari/** events even if chokidar leaks them", async () => { // The chokidar `ignored` option already filters these at watch time; // the dispatch-level check is a defense in depth for macOS quirks. We @@ -412,12 +457,18 @@ describe("startWatcher", () => { await sleep(300); await writeFile(filePath, "# changed\n"); + // Excluded directories created after startup must not admit new files. + for (const dir of ["notes/.cache", "notes/node_modules/pkg"]) { + await mkdir(abs(vault, dir), { recursive: true }); + await writeFile(abs(vault, `${dir}/ignored.md`), "# ignored\n"); + } // Wait well past the debounce window. FSEvents on macOS can take // hundreds of ms to deliver — 1500ms is generous but bounded. await sleep(1500); await w.close(); expect(calls).toContain("live.md"); + expect(calls.every((path) => path === "live.md")).toBe(true); }, 10_000); });