diff --git a/src/shared/ignore-rules.test.ts b/src/shared/ignore-rules.test.ts new file mode 100644 index 000000000000..fe70e6938210 --- /dev/null +++ b/src/shared/ignore-rules.test.ts @@ -0,0 +1,97 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import ignore from "ignore"; +import { describe, expect, it } from "vitest"; +import { addIgnoreRules } from "./ignore-rules.js"; + +function writeIgnoreTree(root: string, rules: string, dir = root) { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, ".gitignore"), rules); +} + +function buildMatcher(root: string, dir: string) { + const ig = ignore(); + addIgnoreRules(ig, join(root, dir), root); + return ig; +} + +describe("addIgnoreRules", () => { + it("ignores nested slash-free patterns at any depth", () => { + const root = join(tmpdir(), "ignore-rules-test-1"); + writeIgnoreTree(root, "*.log\n", join(root, "sub")); + writeFileSync(join(root, "sub", "y.log"), ""); + mkdirSync(join(root, "sub", "deep"), { recursive: true }); + writeFileSync(join(root, "sub", "deep", "x.log"), ""); + + const ig = buildMatcher(root, "sub"); + + expect(ig.ignores("sub/y.log")).toBe(true); + expect(ig.ignores("sub/deep/x.log")).toBe(true); + }); + + it("keeps anchored patterns relative to the ignore file directory", () => { + const root = join(tmpdir(), "ignore-rules-test-2"); + writeIgnoreTree(root, "dir/*.log\n", join(root, "sub")); + mkdirSync(join(root, "sub", "dir"), { recursive: true }); + writeFileSync(join(root, "sub", "dir", "x.log"), ""); + mkdirSync(join(root, "sub", "other"), { recursive: true }); + writeFileSync(join(root, "sub", "other", "x.log"), ""); + + const ig = buildMatcher(root, "sub"); + + expect(ig.ignores("sub/dir/x.log")).toBe(true); + expect(ig.ignores("sub/other/x.log")).toBe(false); + }); + + it("keeps leading-slash patterns relative to the ignore file directory", () => { + const root = join(tmpdir(), "ignore-rules-test-3"); + writeIgnoreTree(root, "/dir/*.log\n", join(root, "sub")); + mkdirSync(join(root, "sub", "dir"), { recursive: true }); + writeFileSync(join(root, "sub", "dir", "x.log"), ""); + + const ig = buildMatcher(root, "sub"); + + expect(ig.ignores("sub/dir/x.log")).toBe(true); + }); + + it("treats trailing-slash directory patterns as unanchored", () => { + const root = join(tmpdir(), "ignore-rules-test-4"); + writeIgnoreTree(root, "node_modules/\n", join(root, "sub")); + mkdirSync(join(root, "sub", "node_modules"), { recursive: true }); + mkdirSync(join(root, "sub", "deep", "node_modules"), { recursive: true }); + + const ig = buildMatcher(root, "sub"); + + expect(ig.ignores("sub/node_modules/file.js")).toBe(true); + expect(ig.ignores("sub/deep/node_modules/file.js")).toBe(true); + }); + + it("does not corrupt escaped ! patterns at the root", () => { + const root = join(tmpdir(), "ignore-rules-test-5"); + writeIgnoreTree(root, "*.txt\n\\!keep.txt\n"); + writeFileSync(join(root, "keep.txt"), ""); + writeFileSync(join(root, "!keep.txt"), ""); + writeFileSync(join(root, "drop.txt"), ""); + + const ig = buildMatcher(root, ""); + + expect(ig.ignores("keep.txt")).toBe(true); + expect(ig.ignores("!keep.txt")).toBe(true); + expect(ig.ignores("drop.txt")).toBe(true); + }); + + it("does not corrupt escaped ! patterns in nested directories", () => { + const root = join(tmpdir(), "ignore-rules-test-6"); + writeIgnoreTree(root, "*.txt\n\\!keep.txt\n", join(root, "sub")); + writeFileSync(join(root, "sub", "keep.txt"), ""); + writeFileSync(join(root, "sub", "!keep.txt"), ""); + writeFileSync(join(root, "sub", "drop.txt"), ""); + + const ig = buildMatcher(root, "sub"); + + expect(ig.ignores("sub/keep.txt")).toBe(true); + expect(ig.ignores("sub/!keep.txt")).toBe(true); + expect(ig.ignores("sub/drop.txt")).toBe(true); + }); +}); diff --git a/src/shared/ignore-rules.ts b/src/shared/ignore-rules.ts index 5f4e3fba0da5..e262c4ab030d 100644 --- a/src/shared/ignore-rules.ts +++ b/src/shared/ignore-rules.ts @@ -48,14 +48,27 @@ function prefixIgnorePattern(line: string, prefix: string): string | null { if (pattern.startsWith("!")) { negated = true; pattern = pattern.slice(1); - } else if (pattern.startsWith("\\!")) { - pattern = pattern.slice(1); } + // Keep escaped "!" intact; the ignore library handles "\!" as a literal filename. + let anchored = false; if (pattern.startsWith("/")) { + anchored = true; pattern = pattern.slice(1); } - const prefixed = prefix ? `${prefix}${pattern}` : pattern; + const slashIndex = pattern.indexOf("/"); + if (slashIndex !== -1 && slashIndex !== pattern.length - 1) { + anchored = true; + } + + // A pattern without a middle/beginning slash matches at any depth below the + // ignore file's directory; prefix with **/ so the ignore library agrees. + const needsDepthGlob = prefix && !anchored; + const prefixed = prefix + ? needsDepthGlob + ? `${prefix}**/${pattern}` + : `${prefix}${pattern}` + : pattern; return negated ? `!${prefixed}` : prefixed; }