feat: seed .understandignore with .gitignore patterns on first generation

Read the project's .gitignore at starter-file generation time and include
non-default patterns as commented suggestions in .understandignore. Patterns
already covered by hardcoded defaults are deduplicated (with trailing-slash
normalization). This is a one-time inclusion — users can later remove patterns
for files they want analyzed without the filter re-reading .gitignore.

Also fixes the starter header listing bin/ as a built-in default when it is
intentionally excluded from DEFAULT_IGNORE_PATTERNS (bin/ is used by Node/Ruby
CLI launchers).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lum1104
2026-04-12 11:39:31 +08:00
co-authored by Claude Opus 4.6
parent bc311c80ad
commit 6ef1a21315
2 changed files with 109 additions and 4 deletions
@@ -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);
});
});
});
@@ -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}`);