diff --git a/README.md b/README.md index b8b66ef..2c3489f 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,24 @@ Examples: /stage-chapters --base main --compare feature ``` +### `.stageignore` + +Add a `.stageignore` file to your repo root to exclude files from the diff analysis. Uses `.gitignore`-style patterns, one per line: + +``` +# Build artifacts +build/** +dist/** + +# Generated code +*.generated.ts + +# But keep this one +!dist/important.js +``` + +Ignored files still appear in the "Other changes" chapter so nothing is silently hidden. Comments (`#`), blank lines, and negation patterns (`!`) are supported — last matching pattern wins. + Stage CLI ## License diff --git a/packages/cli/package.json b/packages/cli/package.json index 29644af..903cce8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -49,6 +49,7 @@ "better-sqlite3": "^12.9.0", "commander": "^14.0.3", "drizzle-orm": "^0.45.2", + "ignore": "^7.0.5", "open": "^11.0.0", "parse-diff": "^0.11.1", "zod": "^4.3.6" diff --git a/packages/cli/src/__tests__/filter-files.test.ts b/packages/cli/src/__tests__/filter-files.test.ts index b5ef1a9..09c6dc1 100644 --- a/packages/cli/src/__tests__/filter-files.test.ts +++ b/packages/cli/src/__tests__/filter-files.test.ts @@ -1,7 +1,11 @@ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import type { Hunk, PullRequestFile } from "@stagereview/types/parsed-diff"; import { LINE_TYPE } from "@stagereview/types/parsed-diff"; +import ignore from "ignore"; import { describe, expect, it } from "vitest"; -import { filterFilesForLlm, shouldIncludeFile } from "../filter-files.js"; +import { filterFilesForLlm, loadStageIgnore, shouldIncludeFile } from "../filter-files.js"; function makeHunk(lineCount: number, overrides?: Partial): Hunk { return { @@ -32,6 +36,10 @@ function makeFile(overrides?: Partial): PullRequestFile { }; } +function ig(patterns: string[]) { + return ignore().add(patterns); +} + describe("shouldIncludeFile", () => { const denylistedFilenames = [ "package-lock.json", @@ -134,4 +142,142 @@ describe("filterFilesForLlm", () => { expect(result.files).toEqual([]); expect(result.excludedByPath).toEqual(["pnpm-lock.yaml", "yarn.lock"]); }); + + it("excludes files matching .stageignore patterns", () => { + const files = [ + makeFile({ path: "src/app.ts" }), + makeFile({ path: "build/config.gypi" }), + makeFile({ path: "dist/bundle.js" }), + ]; + const result = filterFilesForLlm(files, ig(["build/**", "dist/**"])); + expect(result.files).toHaveLength(1); + expect(result.files[0]?.path).toBe("src/app.ts"); + expect(result.excludedByPath).toEqual(["build/config.gypi", "dist/bundle.js"]); + }); + + it("combines built-in denylist with .stageignore patterns", () => { + const files = [ + makeFile({ path: "src/app.ts" }), + makeFile({ path: "pnpm-lock.yaml" }), + makeFile({ path: "generated/schema.ts" }), + ]; + const result = filterFilesForLlm(files, ig(["generated/**"])); + expect(result.files).toHaveLength(1); + expect(result.files[0]?.path).toBe("src/app.ts"); + expect(result.excludedByPath).toEqual(["pnpm-lock.yaml", "generated/schema.ts"]); + }); + + it("works normally when stageIgnore is undefined", () => { + const files = [makeFile({ path: "src/app.ts" }), makeFile({ path: "pnpm-lock.yaml" })]; + const result = filterFilesForLlm(files, undefined); + expect(result.files).toHaveLength(1); + expect(result.files[0]?.path).toBe("src/app.ts"); + }); + + it("works normally when stageIgnore is null", () => { + const files = [makeFile({ path: "src/app.ts" }), makeFile({ path: "src/utils.ts" })]; + const result = filterFilesForLlm(files, null); + expect(result.files).toHaveLength(2); + }); + + it("slashless globs match nested paths", () => { + const files = [ + makeFile({ path: "src/app.ts" }), + makeFile({ path: "src/schema.generated.ts" }), + makeFile({ path: "lib/deep/nested/types.generated.ts" }), + ]; + const result = filterFilesForLlm(files, ig(["*.generated.ts"])); + expect(result.files).toHaveLength(1); + expect(result.files[0]?.path).toBe("src/app.ts"); + }); + + it("negation re-includes a previously excluded file", () => { + const files = [ + makeFile({ path: "build/output.js" }), + makeFile({ path: "build/important.js" }), + makeFile({ path: "src/app.ts" }), + ]; + const result = filterFilesForLlm(files, ig(["build/**", "!build/important.js"])); + expect(result.files).toHaveLength(2); + expect(result.files.map((f) => f.path)).toEqual(["build/important.js", "src/app.ts"]); + }); + + it("last matching pattern wins with negation", () => { + const files = [makeFile({ path: "dist/bundle.js" })]; + const result = filterFilesForLlm(files, ig(["dist/**", "!dist/bundle.js", "*.js"])); + expect(result.files).toHaveLength(0); + }); + + it("leading slash anchors a pattern to the repo root", () => { + const files = [makeFile({ path: "dist/bundle.js" }), makeFile({ path: "src/app.ts" })]; + const result = filterFilesForLlm(files, ig(["/dist/**"])); + expect(result.files).toHaveLength(1); + expect(result.files[0]?.path).toBe("src/app.ts"); + }); + + it("root-anchored pattern does not match nested paths", () => { + const files = [makeFile({ path: "foo/bar.js" }), makeFile({ path: "src/foo/bar.js" })]; + const result = filterFilesForLlm(files, ig(["/foo/**"])); + expect(result.files).toHaveLength(1); + expect(result.files[0]?.path).toBe("src/foo/bar.js"); + }); + + it("trailing slash matches directory contents", () => { + const files = [makeFile({ path: "build/output.js" }), makeFile({ path: "src/app.ts" })]; + const result = filterFilesForLlm(files, ig(["build/"])); + expect(result.files).toHaveLength(1); + expect(result.files[0]?.path).toBe("src/app.ts"); + }); + + it("negation with slashless pattern re-includes nested files", () => { + const files = [ + makeFile({ path: "generated/schema.ts" }), + makeFile({ path: "generated/keep-this.ts" }), + ]; + const result = filterFilesForLlm(files, ig(["generated/**", "!keep-this.ts"])); + expect(result.files).toHaveLength(1); + expect(result.files[0]?.path).toBe("generated/keep-this.ts"); + }); +}); + +describe("loadStageIgnore", () => { + function makeTempDir(): string { + return mkdtempSync(path.join(tmpdir(), "stage-test-")); + } + + it("returns null when .stageignore does not exist", () => { + const dir = makeTempDir(); + expect(loadStageIgnore(dir)).toBeNull(); + }); + + it("parses patterns from .stageignore", () => { + const dir = makeTempDir(); + writeFileSync(path.join(dir, ".stageignore"), "build/**\ndist/**\n"); + const matcher = loadStageIgnore(dir); + expect(matcher).not.toBeNull(); + expect(matcher?.ignores("build/config.gypi")).toBe(true); + expect(matcher?.ignores("dist/bundle.js")).toBe(true); + expect(matcher?.ignores("src/app.ts")).toBe(false); + }); + + it("ignores comments and blank lines", () => { + const dir = makeTempDir(); + writeFileSync( + path.join(dir, ".stageignore"), + "# Build artifacts\nbuild/**\n\n# Output\ndist/**\n\n", + ); + const matcher = loadStageIgnore(dir); + expect(matcher?.ignores("build/config.gypi")).toBe(true); + expect(matcher?.ignores("dist/bundle.js")).toBe(true); + expect(matcher?.ignores("src/app.ts")).toBe(false); + }); + + it("empty .stageignore matches nothing", () => { + const dir = makeTempDir(); + writeFileSync(path.join(dir, ".stageignore"), ""); + const matcher = loadStageIgnore(dir); + expect(matcher).not.toBeNull(); + expect(matcher?.ignores("src/app.ts")).toBe(false); + expect(matcher?.ignores("build/anything.js")).toBe(false); + }); }); diff --git a/packages/cli/src/filter-files.ts b/packages/cli/src/filter-files.ts index c519bbe..239c4d1 100644 --- a/packages/cli/src/filter-files.ts +++ b/packages/cli/src/filter-files.ts @@ -1,4 +1,7 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; import type { PullRequestFile } from "@stagereview/types/parsed-diff"; +import ignore, { type Ignore } from "ignore"; const IGNORED_FILENAMES = new Set([ "package-lock.json", @@ -44,17 +47,32 @@ export function shouldIncludeFile(filePath: string): boolean { return !IGNORED_EXTENSIONS.some((ext) => lowerPath.endsWith(ext)); } +/** + * Load a `.stageignore` file from the repo root into an `Ignore` matcher. + * Returns `null` when the file is absent. Parsing, comments, blank lines, + * negation, and anchoring semantics all follow `.gitignore` via the + * `ignore` package. + */ +export function loadStageIgnore(repoRoot: string): Ignore | null { + const ignorePath = path.join(repoRoot, ".stageignore"); + if (!existsSync(ignorePath)) return null; + return ignore().add(readFileSync(ignorePath, "utf8")); +} + export interface FilterFilesResult { files: PullRequestFile[]; excludedByPath: string[]; } -export function filterFilesForLlm(files: PullRequestFile[]): FilterFilesResult { +export function filterFilesForLlm( + files: PullRequestFile[], + stageIgnore?: Ignore | null, +): FilterFilesResult { const excludedByPath: string[] = []; const reviewable: PullRequestFile[] = []; for (const file of files) { - if (!shouldIncludeFile(file.path)) { + if (!shouldIncludeFile(file.path) || stageIgnore?.ignores(file.path)) { excludedByPath.push(file.path); continue; } diff --git a/packages/cli/src/prep.ts b/packages/cli/src/prep.ts index 9ad6968..3e5a5ee 100644 --- a/packages/cli/src/prep.ts +++ b/packages/cli/src/prep.ts @@ -3,9 +3,9 @@ import { tmpdir } from "node:os"; import path from "node:path"; import type { Hunk, PullRequestFile } from "@stagereview/types/parsed-diff"; import { parseGitDiff } from "./diff-parser.js"; -import { filterFilesForLlm } from "./filter-files.js"; +import { filterFilesForLlm, loadStageIgnore } from "./filter-files.js"; import { formatHunkDiffWithLineNumbers } from "./format-diff.js"; -import { getCommitMessages, type ResolveScopeOptions, resolveScope } from "./git.js"; +import { getCommitMessages, type ResolveScopeOptions, readRepoRoot, resolveScope } from "./git.js"; import type { WorkingTreeRef } from "./schema.js"; function formatHunkForPrompt(file: PullRequestFile, hunk: Hunk): string { @@ -29,7 +29,8 @@ export function runPrep( const { scope, rawDiff, mergeBaseSha } = resolveScope(options); const allFiles = parseGitDiff(rawDiff); - const { files } = filterFilesForLlm(allFiles); + const stageIgnore = loadStageIgnore(readRepoRoot()); + const { files } = filterFilesForLlm(allFiles, stageIgnore); const formattedHunks = files .flatMap((file) => file.hunks.map((hunk) => formatHunkForPrompt(file, hunk))) diff --git a/packages/cli/src/show.ts b/packages/cli/src/show.ts index a81f1e6..dd40d5b 100644 --- a/packages/cli/src/show.ts +++ b/packages/cli/src/show.ts @@ -4,8 +4,8 @@ import open from "open"; import { buildOtherChangesChapter } from "./build-other-changes.js"; import { closeDb, getDb } from "./db/client.js"; import { parseGitDiff } from "./diff-parser.js"; -import { filterFilesForLlm } from "./filter-files.js"; -import { type ResolveScopeOptions, readRepoContext, resolveScope } from "./git.js"; +import { filterFilesForLlm, loadStageIgnore } from "./filter-files.js"; +import { type ResolveScopeOptions, readRepoContext, readRepoRoot, resolveScope } from "./git.js"; import { diffRoutes } from "./routes/diff.js"; import { runRoutes } from "./routes/runs.js"; import { viewStateRoutes } from "./routes/view-state.js"; @@ -90,7 +90,8 @@ function assembleChaptersFile( }; const { scope, rawDiff } = resolveScope(options); const allFiles = parseGitDiff(rawDiff); - const { files: filteredFiles, excludedByPath } = filterFilesForLlm(allFiles); + const stageIgnore = loadStageIgnore(readRepoRoot()); + const { files: filteredFiles, excludedByPath } = filterFilesForLlm(allFiles, stageIgnore); validateHunkCoverage(filteredFiles, agentOutput.chapters); const sanitized = sanitizeLineRefs(agentOutput.chapters, filteredFiles); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f28d8b..1c46629 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: drizzle-orm: specifier: ^0.45.2 version: 0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.9.0) + ignore: + specifier: ^7.0.5 + version: 7.0.5 open: specifier: ^11.0.0 version: 11.0.0 @@ -2638,6 +2641,10 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + import-without-cache@0.3.3: resolution: {integrity: sha512-bDxwDdF04gm550DfZHgffvlX+9kUlcz32UD0AeBTmVPFiWkrexF2XVmiuFFbDhiFuP8fQkrkvI2KdSNPYWAXkQ==} engines: {node: '>=20.19.0'} @@ -5801,6 +5808,8 @@ snapshots: ieee754@1.2.1: {} + ignore@7.0.5: {} + import-without-cache@0.3.3: {} inherits@2.0.4: {}