Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 3 additions & 20 deletions src/search/watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand All @@ -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);
}

Expand Down Expand Up @@ -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);
Expand Down
10 changes: 9 additions & 1 deletion src/storage/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,14 @@ export async function readFile(absolutePath: string): Promise<Result<string, Err
}
}

// Shared exclusion rule for vault-relative POSIX paths, including directories.
// Keep reactive indexing and full walks on the same managed-file boundary.
export function isIgnoredVaultPath(relPath: string): boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isIgnoredVaultPath is a new exported function that now also changes listFiles's output (the .filter() on line 172), but the only test that exercises it is test/search/watcher.test.ts (via watchIgnored) — test/storage/local.test.ts has no direct case for it (e.g. nested dot-dirs or node_modules under listFiles). CLAUDE.md asks for tests to mirror src/ structure; consider adding a case to local.test.ts alongside the existing listFiles describe block so the storage-layer behavior is covered where it lives, not only transitively through the watcher.

return relPath
.split("/")
.some((segment) => 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(
Expand All @@ -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}`));
Expand Down
51 changes: 51 additions & 0 deletions test/search/watcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
});

Expand Down
Loading