From 7293cff273c821385e4dc1bfeb183841f0383689 Mon Sep 17 00:00:00 2001 From: Tirth Kanani Date: Sun, 14 Jun 2026 18:05:15 +0100 Subject: [PATCH 1/2] fix(ignore-generator): dedupe anchored .gitignore patterns against defaults and skip negation patterns in suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isCoveredByDefaults only stripped trailing slashes, so anchored .gitignore lines like /node_modules and /dist (Vite/CRA/many JS templates) never matched the built-in defaults node_modules/ and dist/ and were wrongly re-suggested in the generated .understandignore. Strip a leading root anchor too so anchored forms normalize to the default name. parseGitignorePatterns also kept negation (!) lines, which were then emitted under "uncomment to exclude" — semantically inverted (a !-pattern force-includes, it does not exclude) and meaningless without the project's own .gitignore context. Drop !-prefixed lines from suggestable patterns. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/__tests__/ignore-generator.test.ts | 25 +++++++++++++++++++ .../packages/core/src/ignore-generator.ts | 9 ++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/understand-anything-plugin/packages/core/src/__tests__/ignore-generator.test.ts b/understand-anything-plugin/packages/core/src/__tests__/ignore-generator.test.ts index 5d47140f3..d676c28ff 100644 --- a/understand-anything-plugin/packages/core/src/__tests__/ignore-generator.test.ts +++ b/understand-anything-plugin/packages/core/src/__tests__/ignore-generator.test.ts @@ -158,5 +158,30 @@ describe("generateStarterIgnoreFile", () => { const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#")); expect(lines).toHaveLength(0); }); + + it("treats anchored .gitignore patterns as covered by defaults", () => { + writeFileSync(join(testDir, ".gitignore"), "/node_modules\n/dist\n.env\n"); + const content = generateStarterIgnoreFile(testDir); + // Extract the pattern lines under the .gitignore section (between its + // header and the next "# ---" section header). + const lines = content.split("\n"); + const headerIdx = lines.findIndex((l) => l.includes("From .gitignore")); + const nextSectionIdx = lines.findIndex((l, i) => i > headerIdx && l.startsWith("# ---")); + const sectionLines = lines.slice(headerIdx + 1, nextSectionIdx === -1 ? undefined : nextSectionIdx); + const patterns = sectionLines + .filter((l) => l.startsWith("# ") && !l.startsWith("# ---")) + .map((l) => l.slice(2)); + // /node_modules and /dist are anchored forms of defaults node_modules/ and dist/. + expect(patterns).not.toContain("/node_modules"); // fails today: '# /node_modules' is present + expect(patterns).not.toContain("/dist"); // fails today: '# /dist' is present + expect(patterns).toContain(".env"); // still suggested + }); + + it("does not suggest .gitignore negation (!) patterns", () => { + writeFileSync(join(testDir, ".gitignore"), "build/\n!build/keep.txt\n.env\n"); + const content = generateStarterIgnoreFile(testDir); + expect(content).not.toContain("!build/keep.txt"); // fails today: '# !build/keep.txt' is emitted + expect(content).toContain("# .env"); + }); }); }); diff --git a/understand-anything-plugin/packages/core/src/ignore-generator.ts b/understand-anything-plugin/packages/core/src/ignore-generator.ts index f0e49ac14..1db09aa69 100644 --- a/understand-anything-plugin/packages/core/src/ignore-generator.ts +++ b/understand-anything-plugin/packages/core/src/ignore-generator.ts @@ -40,15 +40,18 @@ function parseGitignorePatterns(gitignorePath: string): string[] { return content .split("\n") .map((line) => line.trim()) - .filter((line) => line.length > 0 && !line.startsWith("#")); + .filter( + (line) => line.length > 0 && !line.startsWith("#") && !line.startsWith("!"), + ); } /** * Returns true if a gitignore pattern is already covered by the hardcoded defaults. - * Normalizes trailing slashes for comparison. + * Normalizes a leading root anchor and trailing slashes for comparison so that + * anchored forms like `/node_modules` and `/dist/` match defaults `node_modules/`/`dist/`. */ function isCoveredByDefaults(pattern: string): boolean { - const normalize = (p: string) => p.replace(/\/+$/, ""); + const normalize = (p: string) => p.replace(/^\//, "").replace(/\/+$/, ""); const normalized = normalize(pattern); return DEFAULT_IGNORE_PATTERNS.some((d) => normalize(d) === normalized); } From da48e7d8045b7cb11f86f087a75d93fd832d10ef Mon Sep 17 00:00:00 2001 From: Tirth Kanani Date: Tue, 16 Jun 2026 23:30:46 +0100 Subject: [PATCH 2/2] fix(ignore-generator): dedupe **/ and ./ anchor variants; document dropped negations normalize() now strips leading `./`, root `/`, and globstar `**/` anchors so common .gitignore forms like `**/node_modules`, `./dist/`, and `**/build/` dedupe against built-in defaults instead of being re-suggested. Documents in parseGitignorePatterns why `!`-prefixed re-include lines are dropped (their semantics are inverted under an "uncomment to exclude" section and only meaningful against git's own ignore stack). Adds a coverage test for the new anchor variants and a headerIdx guard so the anchored-patterns test fails loudly if the section is ever fully deduped. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/__tests__/ignore-generator.test.ts | 26 +++++++++++++++++++ .../packages/core/src/ignore-generator.ts | 18 ++++++++++--- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/understand-anything-plugin/packages/core/src/__tests__/ignore-generator.test.ts b/understand-anything-plugin/packages/core/src/__tests__/ignore-generator.test.ts index d676c28ff..d2a86c81e 100644 --- a/understand-anything-plugin/packages/core/src/__tests__/ignore-generator.test.ts +++ b/understand-anything-plugin/packages/core/src/__tests__/ignore-generator.test.ts @@ -166,6 +166,10 @@ describe("generateStarterIgnoreFile", () => { // header and the next "# ---" section header). const lines = content.split("\n"); const headerIdx = lines.findIndex((l) => l.includes("From .gitignore")); + // Guard: if the section were fully deduped, findIndex returns -1 and the + // slice below would silently scan from the top of the file (masking a + // regression). Assert the section is actually present before slicing. + expect(headerIdx).toBeGreaterThanOrEqual(0); const nextSectionIdx = lines.findIndex((l, i) => i > headerIdx && l.startsWith("# ---")); const sectionLines = lines.slice(headerIdx + 1, nextSectionIdx === -1 ? undefined : nextSectionIdx); const patterns = sectionLines @@ -177,6 +181,28 @@ describe("generateStarterIgnoreFile", () => { expect(patterns).toContain(".env"); // still suggested }); + it("treats **/ and ./ anchor variants as covered by defaults", () => { + writeFileSync( + join(testDir, ".gitignore"), + "**/node_modules\n**/build/\n./dist/\n.env\n", + ); + const content = generateStarterIgnoreFile(testDir); + const lines = content.split("\n"); + const headerIdx = lines.findIndex((l) => l.includes("From .gitignore")); + expect(headerIdx).toBeGreaterThanOrEqual(0); + const nextSectionIdx = lines.findIndex((l, i) => i > headerIdx && l.startsWith("# ---")); + const sectionLines = lines.slice(headerIdx + 1, nextSectionIdx === -1 ? undefined : nextSectionIdx); + const patterns = sectionLines + .filter((l) => l.startsWith("# ") && !l.startsWith("# ---")) + .map((l) => l.slice(2)); + // **/node_modules, **/build/, and ./dist/ are anchor variants of defaults. + expect(patterns).not.toContain("**/node_modules"); + expect(patterns).not.toContain("**/build/"); + expect(patterns).not.toContain("./dist/"); + // A non-default pattern still survives. + expect(patterns).toContain(".env"); + }); + it("does not suggest .gitignore negation (!) patterns", () => { writeFileSync(join(testDir, ".gitignore"), "build/\n!build/keep.txt\n.env\n"); const content = generateStarterIgnoreFile(testDir); diff --git a/understand-anything-plugin/packages/core/src/ignore-generator.ts b/understand-anything-plugin/packages/core/src/ignore-generator.ts index 1db09aa69..22a3f104c 100644 --- a/understand-anything-plugin/packages/core/src/ignore-generator.ts +++ b/understand-anything-plugin/packages/core/src/ignore-generator.ts @@ -33,6 +33,13 @@ const GENERIC_SUGGESTIONS = [ /** * Parses a .gitignore file and returns active patterns (no comments, no blanks). + * + * Negation (`!`) lines are intentionally dropped: a `!pattern` force-INCLUDES a + * path and is only meaningful relative to the project's own .gitignore ignore + * stack, which understand-anything does not apply. Surfacing it under the + * "uncomment to exclude" suggestion section would invert its semantics, so it is + * omitted rather than presented as a candidate exclusion. The header still + * documents the general `!` mechanism for users who want to author one by hand. */ function parseGitignorePatterns(gitignorePath: string): string[] { if (!existsSync(gitignorePath)) return []; @@ -47,11 +54,16 @@ function parseGitignorePatterns(gitignorePath: string): string[] { /** * Returns true if a gitignore pattern is already covered by the hardcoded defaults. - * Normalizes a leading root anchor and trailing slashes for comparison so that - * anchored forms like `/node_modules` and `/dist/` match defaults `node_modules/`/`dist/`. + * Normalizes leading anchors (root "/", "./", and globstar) and trailing slashes + * for comparison so that anchored forms like `/node_modules`, `./dist/`, and a + * leading globstar `node_modules` match the defaults `node_modules/`, `dist/`. */ function isCoveredByDefaults(pattern: string): boolean { - const normalize = (p: string) => p.replace(/^\//, "").replace(/\/+$/, ""); + const normalize = (p: string) => + p + .replace(/^\.?\/+/, "") + .replace(/^\*\*\/+/, "") + .replace(/\/+$/, ""); const normalized = normalize(pattern); return DEFAULT_IGNORE_PATTERNS.some((d) => normalize(d) === normalized); }