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..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 @@ -158,5 +158,56 @@ 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")); + // 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 + .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("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); + 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..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 []; @@ -40,15 +47,23 @@ 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 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(/\/+$/, ""); + const normalize = (p: string) => + p + .replace(/^\.?\/+/, "") + .replace(/^\*\*\/+/, "") + .replace(/\/+$/, ""); const normalized = normalize(pattern); return DEFAULT_IGNORE_PATTERNS.some((d) => normalize(d) === normalized); }