diff --git a/src/apps/cli/serviceModules/IgnoreRules.ts b/src/apps/cli/serviceModules/IgnoreRules.ts index 0a4138a..018216c 100644 --- a/src/apps/cli/serviceModules/IgnoreRules.ts +++ b/src/apps/cli/serviceModules/IgnoreRules.ts @@ -1,4 +1,4 @@ -import { minimatch } from "minimatch"; +import { Minimatch } from "minimatch"; import { fsPromises as fs, path } from "@/apps/cli/node-compat"; /** @@ -17,7 +17,7 @@ import { fsPromises as fs, path } from "@/apps/cli/node-compat"; * Missing files (`.livesync/ignore` or `.gitignore`) are silently skipped. */ export class IgnoreRules { - private patterns: string[] = []; + private patterns: Minimatch[] = []; constructor(private vaultPath: string) {} @@ -108,7 +108,7 @@ export class IgnoreRules { `Remove it from .livesync/ignore or use a separate include/exclude file.` ); } - this.patterns.push(this._normalisePattern(raw)); + this.patterns.push(new Minimatch(this._normalisePattern(raw), { dot: true })); } /** @@ -124,6 +124,6 @@ export class IgnoreRules { } // Normalise to forward slashes for minimatch. const normalised = relativePath.replace(/\\/g, "/"); - return this.patterns.some((p) => minimatch(normalised, p, { dot: true })); + return this.patterns.some((pattern) => pattern.match(normalised)); } } diff --git a/src/apps/cli/serviceModules/IgnoreRules.unit.spec.ts b/src/apps/cli/serviceModules/IgnoreRules.unit.spec.ts index 4f6a606..85b4a07 100644 --- a/src/apps/cli/serviceModules/IgnoreRules.unit.spec.ts +++ b/src/apps/cli/serviceModules/IgnoreRules.unit.spec.ts @@ -1,7 +1,28 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const minimatchStats = vi.hoisted(() => ({ constructions: 0 })); + +vi.mock("minimatch", async (importOriginal) => { + const actual = await importOriginal(); + + class CountingMinimatch extends actual.Minimatch { + constructor(pattern: string, options?: import("minimatch").MinimatchOptions) { + super(pattern, options); + minimatchStats.constructions++; + } + } + + return { + ...actual, + Minimatch: CountingMinimatch, + minimatch: (path: string, pattern: string, options?: import("minimatch").MinimatchOptions) => + new CountingMinimatch(pattern, options).match(path), + }; +}); + import { IgnoreRules } from "./IgnoreRules"; describe("IgnoreRules", () => { @@ -19,6 +40,10 @@ describe("IgnoreRules", () => { await fs.writeFile(path.join(ignoreDir, "ignore"), content, "utf-8"); } + beforeEach(() => { + minimatchStats.constructions = 0; + }); + afterEach(async () => { await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); @@ -55,6 +80,20 @@ describe("IgnoreRules", () => { }); describe("shouldIgnore", () => { + it("compiles loaded patterns once", async () => { + const vaultPath = await createVault(); + await writeIgnoreFile(vaultPath, "*.tmp\nbuild/\n"); + const rules = new IgnoreRules(vaultPath); + await rules.load(); + + expect(rules.shouldIgnore("notes/readme.md")).toBe(false); + expect(rules.shouldIgnore("notes/scratch.tmp")).toBe(true); + expect(rules.shouldIgnore("build/output.js")).toBe(true); + expect(rules.shouldIgnore("other.md")).toBe(false); + + expect(minimatchStats.constructions).toBe(2); + }); + it("matches **/*.tmp against notes/scratch.tmp", async () => { const vaultPath = await createVault(); await writeIgnoreFile(vaultPath, "*.tmp\n");