fix(cli): share Vault-relative path exclusions

Apply watcher and logical-target exclusions within the selected Vault so
hidden or database-like parent directories do not suppress monitoring.
Protect primary, previous, and view databases, and provide a runtime opt-in
for ordinary database-like directories without changing database ownership.

Cover hidden roots, symlink restarts, exclusion parity, and encrypted
CouchDB create, modify, and delete operations with regression tests.

Refs #1340
This commit is contained in:
vorotamoroz committed 2026-11-12 08:51:11 +00:00
1 parent 78ebc2d298
commit 116c2dfbfb
20 files changed
+1703 -107

No files matched your search

@@ -1,5 +1,5 @@
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, readdir, rename, rm, unlink, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, symlink, unlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
@@ -66,6 +66,118 @@ for (let directoryIndex = 0; directoryIndex < 40; directoryIndex++) {
`;
describe("CLI filesystem watcher with a real filesystem", () => {
it.each([
{ name: "an ordinary Vault", parent: "notes", vault: "vault", alias: false, resolveAlias: true },
{
name: "an ordinary Vault through a symlink",
parent: "notes",
vault: "vault",
alias: true,
resolveAlias: true,
},
{
name: "a hidden-parent Vault through a preserved symlink",
parent: ".obsidian",
vault: "vault",
alias: true,
resolveAlias: false,
},
{
name: "a hidden-parent Vault by physical path",
parent: ".obsidian",
vault: "vault",
alias: false,
resolveAlias: true,
},
{
name: "a hidden-parent Vault through a resolved symlink",
parent: ".obsidian",
vault: "vault",
alias: true,
resolveAlias: true,
},
{ name: "a dot-prefixed Vault root", parent: "notes", vault: ".vault", alias: false, resolveAlias: true },
{
name: "a Vault below a database-like parent",
parent: "archive-livesync-v2",
vault: "vault",
alias: false,
resolveAlias: true,
},
{
name: "a database-like Vault root",
parent: "notes",
vault: "vault-livesync-v2",
alias: false,
resolveAlias: true,
},
])(
"keeps monitoring files in $name",
async ({ parent, vault, alias, resolveAlias }) => {
const fixture = await mkdtemp(join(tmpdir(), "livesync-watch-root-"));
const physicalVault = join(fixture, parent, vault);
const aliasPath = join(fixture, "livesync-vault");
const events: WatchEvent[] = [];
const diagnostics: unknown[] = [];
await mkdir(physicalVault, { recursive: true });
await symlink(physicalVault, aliasPath, "dir");
const suppliedPath = alias ? aliasPath : physicalVault;
const watchRoot = resolveAlias ? await realpath(suppliedPath) : suppliedPath;
const adapter = new CLIStorageEventManagerAdapter(watchRoot, undefined, true, (message, detail) =>
diagnostics.push({ message, detail })
);
try {
await adapter.watch.beginWatch(makeHandlers(events));
const noteName = "new-note.md";
const notePath = join(physicalVault, noteName);
await writeFile(notePath, "created after watcher readiness");
const created = await waitFor(() =>
events.some((event) => event.type === "create" && !event.folder && event.path === noteName)
);
expect(await readFile(notePath, "utf8")).toBe("created after watcher readiness");
expect(diagnostics).toEqual([]);
expect(created, `Expected a new-file event below ${watchRoot}`).toBe(true);
await writeFile(notePath, "modified after receiving the creation event");
expect(
await waitFor(() => events.some((event) => event.type === "change" && event.path === noteName))
).toBe(true);
await unlink(notePath);
expect(
await waitFor(() => events.some((event) => event.type === "delete" && event.path === noteName))
).toBe(true);
} finally {
await adapter.close();
await rm(fixture, { recursive: true, force: true });
}
},
20_000
);
it("preserves monitoring through a descendant symlink to another directory", async () => {
const fixture = await mkdtemp(join(tmpdir(), "livesync-watch-child-link-"));
const root = join(fixture, "vault");
const target = join(fixture, "external");
await mkdir(root);
await mkdir(target);
await symlink(target, join(root, "linked"), "dir");
const events: WatchEvent[] = [];
const adapter = new CLIStorageEventManagerAdapter(root, undefined, true);
try {
await adapter.watch.beginWatch(makeHandlers(events));
await writeFile(join(target, "linked-note.md"), "written through the physical target");
expect(
await waitFor(() =>
events.some((event) => event.type === "create" && event.path === "linked/linked-note.md")
)
).toBe(true);
} finally {
await adapter.close();
await rm(fixture, { recursive: true, force: true });
}
}, 15_000);
it("reports all 400 files when another process creates directories before dot-temp renames", async () => {
const root = await mkdtemp(join(tmpdir(), "livesync-issue1328-burst-"));
const events: WatchEvent[] = [];
@@ -111,40 +223,48 @@ describe("CLI filesystem watcher with a real filesystem", () => {
}
}, 30_000);
it("ignores initial, hidden, and custom-ignored files while watching visible files", async () => {
const root = await mkdtemp(join(tmpdir(), "livesync-real-watch-ignore-"));
const events: WatchEvent[] = [];
const ignoreDirectory = join(root, ".livesync");
await mkdir(ignoreDirectory);
await writeFile(join(ignoreDirectory, "ignore"), "private/\n");
const ignoreRules = new IgnoreRules(root);
await ignoreRules.load();
await mkdir(join(root, "private"));
await writeFile(join(root, "present-before-watch.md"), "initial file");
await writeFile(join(root, ".hidden-before-watch.md"), "initial hidden file");
const adapter = new CLIStorageEventManagerAdapter(root, ignoreRules, true);
it.each(["visible-parent", ".hidden-parent"])(
"ignores hidden, internal, and custom-ignored entries below %s",
async (parent) => {
const fixture = await mkdtemp(join(tmpdir(), "livesync-real-watch-ignore-"));
const root = join(fixture, parent, "vault");
await mkdir(root, { recursive: true });
const events: WatchEvent[] = [];
const ignoreDirectory = join(root, ".livesync");
await mkdir(ignoreDirectory);
await writeFile(join(ignoreDirectory, "ignore"), "private/\n");
const ignoreRules = new IgnoreRules(root);
await ignoreRules.load();
await mkdir(join(root, "private"));
await mkdir(join(root, "local-livesync-v2"));
await writeFile(join(root, "present-before-watch.md"), "initial file");
await writeFile(join(root, ".hidden-before-watch.md"), "initial hidden file");
const adapter = new CLIStorageEventManagerAdapter(root, ignoreRules, true);
try {
await adapter.watch.beginWatch(makeHandlers(events));
await writeFile(join(root, "visible-after-watch.md"), "visible");
await writeFile(join(root, ".hidden-after-watch.md"), "hidden");
await writeFile(join(root, "private", "ignored-after-watch.md"), "custom ignored");
try {
await adapter.watch.beginWatch(makeHandlers(events));
await writeFile(join(root, "visible-after-watch.md"), "visible");
await writeFile(join(root, ".hidden-after-watch.md"), "hidden");
await writeFile(join(root, "private", "ignored-after-watch.md"), "custom ignored");
await writeFile(join(root, "local-livesync-v2", "state"), "internal database");
expect(
await waitFor(() =>
events.some((event) => event.type === "create" && event.path === "visible-after-watch.md")
)
).toBe(true);
await delay(700);
expect(
await waitFor(() =>
events.some((event) => event.type === "create" && event.path === "visible-after-watch.md")
)
).toBe(true);
await delay(700);
expect(events.filter((event) => !event.folder)).toEqual([
{ type: "create", path: "visible-after-watch.md", folder: false },
]);
} finally {
await adapter.close();
await rm(root, { recursive: true, force: true });
}
}, 15_000);
expect(events.filter((event) => !event.folder)).toEqual([
{ type: "create", path: "visible-after-watch.md", folder: false },
]);
} finally {
await adapter.close();
await rm(fixture, { recursive: true, force: true });
}
},
15_000
);
it("reports changes, deletions, rename paths, and stops after close", async () => {
const root = await mkdtemp(join(tmpdir(), "livesync-real-watch-lifecycle-"));