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.
+
## 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: {}