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 8c2189b..5d47140 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 @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { generateStarterIgnoreFile } from "../ignore-generator"; -import { mkdirSync, rmSync } from "node:fs"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -94,4 +94,69 @@ describe("generateStarterIgnoreFile", () => { expect(content).not.toContain("# .storybook/"); expect(content).not.toContain("# fixtures/"); }); + + describe(".gitignore integration", () => { + it("includes .gitignore patterns not covered by defaults", () => { + writeFileSync(join(testDir, ".gitignore"), ".env\nsecrets/\n*.pyc\n"); + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("From .gitignore"); + expect(content).toContain("# .env"); + expect(content).toContain("# secrets/"); + expect(content).toContain("# *.pyc"); + }); + + it("excludes .gitignore patterns already in defaults", () => { + writeFileSync(join(testDir, ".gitignore"), "node_modules/\ndist/\n.env\n"); + const content = generateStarterIgnoreFile(testDir); + // .env is not in defaults, should appear + expect(content).toContain("# .env"); + // node_modules/ and dist/ are in defaults, should not appear in .gitignore section + const gitignoreSection = content.split("From .gitignore")[1]?.split("---")[0] ?? ""; + expect(gitignoreSection).not.toContain("node_modules"); + expect(gitignoreSection).not.toContain("dist"); + }); + + it("skips .gitignore comments and blank lines", () => { + writeFileSync(join(testDir, ".gitignore"), "# a comment\n\n.env\n \n"); + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("# .env"); + // Should not include the original comment as a pattern + const gitignoreSection = content.split("From .gitignore")[1]?.split("---")[0] ?? ""; + expect(gitignoreSection).not.toContain("a comment"); + }); + + it("handles .gitignore with trailing-slash normalization for defaults", () => { + // "dist" without trailing slash should still match "dist/" default + writeFileSync(join(testDir, ".gitignore"), "dist\ncoverage\n.env\n"); + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("From .gitignore"); + // Extract lines between the .gitignore 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)); + expect(patterns).toContain(".env"); + expect(patterns).not.toContain("dist"); + expect(patterns).not.toContain("coverage"); + }); + + it("omits .gitignore section when no .gitignore exists", () => { + const content = generateStarterIgnoreFile(testDir); + expect(content).not.toContain("From .gitignore"); + }); + + it("omits .gitignore section when all patterns are covered by defaults", () => { + writeFileSync(join(testDir, ".gitignore"), "node_modules/\ndist/\n*.lock\n"); + const content = generateStarterIgnoreFile(testDir); + expect(content).not.toContain("From .gitignore"); + }); + + it("all .gitignore suggestions are commented out", () => { + writeFileSync(join(testDir, ".gitignore"), ".env\nsecrets/\n*.pyc\n"); + const content = generateStarterIgnoreFile(testDir); + const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#")); + expect(lines).toHaveLength(0); + }); + }); }); diff --git a/understand-anything-plugin/packages/core/src/ignore-generator.ts b/understand-anything-plugin/packages/core/src/ignore-generator.ts index 021e170..f0e49ac 100644 --- a/understand-anything-plugin/packages/core/src/ignore-generator.ts +++ b/understand-anything-plugin/packages/core/src/ignore-generator.ts @@ -1,5 +1,6 @@ -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; +import { DEFAULT_IGNORE_PATTERNS } from "./ignore-filter.js"; const HEADER = `# .understandignore — patterns for files/dirs to exclude from analysis # Syntax: same as .gitignore (globs, # comments, ! negation, trailing / for dirs) @@ -7,7 +8,7 @@ const HEADER = `# .understandignore — patterns for files/dirs to exclude from # Use ! prefix to force-include something excluded by defaults. # # Built-in defaults (always excluded unless negated): -# node_modules/, .git/, dist/, build/, bin/, obj/, *.lock, *.min.js, etc. +# node_modules/, .git/, dist/, build/, obj/, *.lock, *.min.js, etc. # `; @@ -30,13 +31,51 @@ const GENERIC_SUGGESTIONS = [ "*.snap", ]; +/** + * Parses a .gitignore file and returns active patterns (no comments, no blanks). + */ +function parseGitignorePatterns(gitignorePath: string): string[] { + if (!existsSync(gitignorePath)) return []; + const content = readFileSync(gitignorePath, "utf-8"); + return content + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("#")); +} + +/** + * Returns true if a gitignore pattern is already covered by the hardcoded defaults. + * Normalizes trailing slashes for comparison. + */ +function isCoveredByDefaults(pattern: string): boolean { + const normalize = (p: string) => p.replace(/\/+$/, ""); + const normalized = normalize(pattern); + return DEFAULT_IGNORE_PATTERNS.some((d) => normalize(d) === normalized); +} + /** * Generates a starter .understandignore file content by scanning the project - * for common directories. All suggestions are commented out. + * for common directories and reading .gitignore patterns. + * All suggestions are commented out — this is a one-time generation. */ export function generateStarterIgnoreFile(projectRoot: string): string { const sections: string[] = [HEADER]; + // Section 1: patterns from .gitignore not already in defaults + const gitignorePath = join(projectRoot, ".gitignore"); + const gitignorePatterns = parseGitignorePatterns(gitignorePath).filter( + (p) => !isCoveredByDefaults(p), + ); + + if (gitignorePatterns.length > 0) { + sections.push("# --- From .gitignore (uncomment to exclude) ---\n"); + for (const pattern of gitignorePatterns) { + sections.push(`# ${pattern}`); + } + sections.push(""); + } + + // Section 2: detected directories const detected: string[] = []; for (const { dir, pattern } of DETECTABLE_DIRS) { if (existsSync(join(projectRoot, dir))) { @@ -52,6 +91,7 @@ export function generateStarterIgnoreFile(projectRoot: string): string { sections.push(""); } + // Section 3: generic test patterns sections.push("# --- Test file patterns (uncomment to exclude) ---\n"); for (const pattern of GENERIC_SUGGESTIONS) { sections.push(`# ${pattern}`);