diff --git a/README.md b/README.md index 6970629..188c674 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,23 @@ In your AI agent, run: This organizes your local changes into reviewable chapters and opens a browser UI. Everything happens on your machine. +### Options + +| Flag | Description | +|------|-------------| +| `--base ` | Base ref to diff against (default: auto-detect main/master) | +| `--ref ` | Diff scope: `work` (staged + unstaged + untracked), `staged`, or `unstaged` (default: auto-detect) | + +Examples: + +```bash +# Review only staged changes +/stage-chapters --ref staged + +# Diff against a specific branch +/stage-chapters --base feature-a +``` + Stage CLI ## License diff --git a/packages/cli/src/git.ts b/packages/cli/src/git.ts index 8e7ddf3..17add88 100644 --- a/packages/cli/src/git.ts +++ b/packages/cli/src/git.ts @@ -1,7 +1,7 @@ import { execFileSync } from "node:child_process"; import path from "node:path"; import type { ChapterRunRow } from "./db/schema/chapter-run.js"; -import { SCOPE_KIND, type Scope, WORKING_TREE_REF } from "./schema.js"; +import { SCOPE_KIND, type Scope, WORKING_TREE_REF, type WorkingTreeRef } from "./schema.js"; export class NotInGitRepoError extends Error { constructor() { @@ -204,14 +204,24 @@ export interface ResolvedScope { rawDiff: string; } -export function resolveScope(baseOverride?: string): ResolvedScope { - const base = baseOverride ?? detectBaseRef(); - const mergeBaseSha = resolveMergeBase(base); - const headSha = resolveHead(); - const uncommitted = hasUncommittedChanges(); +function workingTreeDiffArgs(ref: WorkingTreeRef, mergeBaseSha: string): string[] { + switch (ref) { + case WORKING_TREE_REF.UNSTAGED: + return []; + case WORKING_TREE_REF.STAGED: + return ["--cached"]; + case WORKING_TREE_REF.WORK: + return [mergeBaseSha]; + } +} + +function includesUntrackedFiles(ref: WorkingTreeRef): boolean { + return ref === WORKING_TREE_REF.WORK; +} - if (uncommitted) { - let rawDiff = getRawDiff([mergeBaseSha]); +function buildWorkingTreeDiff(ref: WorkingTreeRef, mergeBaseSha: string): string { + let rawDiff = getRawDiff(workingTreeDiffArgs(ref, mergeBaseSha)); + if (includesUntrackedFiles(ref)) { const untrackedFiles = getUntrackedFiles(); if (untrackedFiles.length > 0) { const untrackedDiff = getUntrackedDiff(untrackedFiles); @@ -219,16 +229,28 @@ export function resolveScope(baseOverride?: string): ResolvedScope { rawDiff = rawDiff ? `${rawDiff}\n${untrackedDiff}` : untrackedDiff; } } + } + return rawDiff; +} + +export function resolveScope(baseOverride?: string, ref?: WorkingTreeRef): ResolvedScope { + const base = baseOverride ?? detectBaseRef(); + const mergeBaseSha = resolveMergeBase(base); + const headSha = resolveHead(); + + const effectiveRef = ref ?? (hasUncommittedChanges() ? WORKING_TREE_REF.WORK : null); + + if (effectiveRef) { return { scope: { kind: SCOPE_KIND.WORKING_TREE, - ref: WORKING_TREE_REF.WORK, + ref: effectiveRef, baseSha: mergeBaseSha, headSha, mergeBaseSha, }, mergeBaseSha, - rawDiff, + rawDiff: buildWorkingTreeDiff(effectiveRef, mergeBaseSha), }; } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2330bac..2f61207 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,7 +1,9 @@ #!/usr/bin/env node import { createRequire } from "node:module"; -import { Command } from "commander"; +import { Command, Option } from "commander"; +import { z } from "zod"; import { runPrep } from "./prep.js"; +import { WORKING_TREE_REF } from "./schema.js"; import { show } from "./show.js"; const require = createRequire(import.meta.url); @@ -14,12 +16,19 @@ program .description("Chapter-style code review against your local git branch.") .version(version); +const refOption = new Option( + "--ref ", + "Diff scope: work (staged + unstaged + untracked), staged, or unstaged (default: auto-detect)", +).choices(Object.values(WORKING_TREE_REF)); + program .command("prep") .description("Parse the current branch diff and prepare input for chapter generation") .option("--base ", "Base ref to diff against (default: auto-detect main/master)") - .action((opts: { base?: string }) => { - const filePath = runPrep(opts.base); + .addOption(refOption) + .action((opts: { base?: string; ref?: string }) => { + const ref = opts.ref !== undefined ? z.enum(WORKING_TREE_REF).parse(opts.ref) : undefined; + const filePath = runPrep(opts.base, ref); process.stdout.write(filePath); }); @@ -28,8 +37,10 @@ program .description("Load a chapters.json file and open it in a local browser") .argument("", "Path to a chapters.json file") .option("--base ", "Base ref to diff against (default: auto-detect main/master)") - .action(async (jsonPath: string, opts: { base?: string }) => { - await show(jsonPath, opts.base); + .addOption(refOption) + .action(async (jsonPath: string, opts: { base?: string; ref?: string }) => { + const ref = opts.ref !== undefined ? z.enum(WORKING_TREE_REF).parse(opts.ref) : undefined; + await show(jsonPath, opts.base, ref); }); program.parseAsync(process.argv).catch((err) => { diff --git a/packages/cli/src/prep.ts b/packages/cli/src/prep.ts index 67b3975..4476cb6 100644 --- a/packages/cli/src/prep.ts +++ b/packages/cli/src/prep.ts @@ -6,6 +6,7 @@ import { parseGitDiff } from "./diff-parser.js"; import { filterFilesForLlm } from "./filter-files.js"; import { formatHunkDiffWithLineNumbers } from "./format-diff.js"; import { getCommitMessages, resolveScope } from "./git.js"; +import type { WorkingTreeRef } from "./schema.js"; function formatHunkForPrompt(file: PullRequestFile, hunk: Hunk): string { return `=== File: ${file.path} (${file.status}) | filePath: "${file.path}", oldStart: ${hunk.oldStart} === @@ -13,8 +14,8 @@ function formatHunkForPrompt(file: PullRequestFile, hunk: Hunk): string { ${formatHunkDiffWithLineNumbers(hunk)}`; } -export function runPrep(base?: string): string { - const { rawDiff, mergeBaseSha } = resolveScope(base); +export function runPrep(base?: string, ref?: WorkingTreeRef): string { + const { rawDiff, mergeBaseSha } = resolveScope(base, ref); const allFiles = parseGitDiff(rawDiff); const { files } = filterFilesForLlm(allFiles); diff --git a/packages/cli/src/show.ts b/packages/cli/src/show.ts index 9680ec2..6a5fe8d 100644 --- a/packages/cli/src/show.ts +++ b/packages/cli/src/show.ts @@ -17,12 +17,13 @@ import { type ChaptersFile, ChaptersFileSchema, DIFF_SIDE, + type WorkingTreeRef, } from "./schema.js"; import { LOOPBACK_HOST, startServer } from "./server.js"; -export async function show(jsonPath: string, base?: string): Promise { +export async function show(jsonPath: string, base?: string, ref?: WorkingTreeRef): Promise { const db = getDb(); - const chaptersFile = loadChaptersFile(jsonPath, base); + const chaptersFile = loadChaptersFile(jsonPath, base, ref); const { runId } = insertChaptersFile(db, chaptersFile, readRepoContext()); const handle = await startServer({ @@ -46,7 +47,7 @@ export async function show(jsonPath: string, base?: string): Promise { closeDb(); } -function loadChaptersFile(jsonPath: string, base?: string): ChaptersFile { +function loadChaptersFile(jsonPath: string, base?: string, ref?: WorkingTreeRef): ChaptersFile { const absolute = path.resolve(jsonPath); const raw = readFileSync(absolute, "utf8"); const parsed = JSON.parse(raw) as unknown; @@ -55,13 +56,17 @@ function loadChaptersFile(jsonPath: string, base?: string): ChaptersFile { if (fullResult.success) return fullResult.data; const agentResult = AgentOutputSchema.safeParse(parsed); - if (agentResult.success) return assembleChaptersFile(agentResult.data, base); + if (agentResult.success) return assembleChaptersFile(agentResult.data, base, ref); throw fullResult.error; } -function assembleChaptersFile(agentOutput: AgentOutput, base?: string): ChaptersFile { - const { scope, rawDiff } = resolveScope(base); +function assembleChaptersFile( + agentOutput: AgentOutput, + base?: string, + ref?: WorkingTreeRef, +): ChaptersFile { + const { scope, rawDiff } = resolveScope(base, ref); const allFiles = parseGitDiff(rawDiff); const { files: filteredFiles, excludedByPath } = filterFilesForLlm(allFiles); diff --git a/skills/stage-chapters/SKILL.md b/skills/stage-chapters/SKILL.md index abc57b9..67b1b3d 100644 --- a/skills/stage-chapters/SKILL.md +++ b/skills/stage-chapters/SKILL.md @@ -36,14 +36,23 @@ Run these checks before any other work. If either fails, stop with the error mes PREP_FILE=$(stagereview prep) ``` -`stagereview prep` auto-detects the base ref (main/master), computes the merge-base, generates the diff (including uncommitted and untracked changes when present), filters out lockfiles/binaries, and formats hunks with line numbers for analysis. It writes a plain-text file and prints only the file path to stdout. +`stagereview prep` auto-detects the base ref (main/master), computes the merge-base, generates the diff, filters out lockfiles/binaries, and formats hunks with line numbers for analysis. By default it auto-detects the diff scope: if uncommitted changes are present the diff includes staged, unstaged, and untracked files; otherwise it uses the committed branch diff. It writes a plain-text file and prints only the file path to stdout. -If the user specifies a base branch (e.g., "generate chapters against `feature-a`"), pass `--base ` to both `prep` and `show`: +Both `prep` and `show` accept these optional flags: + +- **`--base `** — base ref to diff against (default: auto-detect main/master). +- **`--ref `** — diff scope. One of: + - `work` — staged + unstaged + untracked changes (full working tree vs merge-base). + - `staged` — only staged changes (index vs HEAD). + - `unstaged` — only unstaged changes (working tree vs index). + - Omitted — auto-detect (equivalent to `work` when uncommitted changes exist, committed branch diff otherwise). + +When either flag is specified, pass it to **both** `prep` and `show`: ```bash -PREP_FILE=$(stagereview prep --base feature-a) +PREP_FILE=$(stagereview prep --base feature-a --ref staged) # ... later ... -stagereview show --base feature-a "$AGENT_OUTPUT" +stagereview show --base feature-a --ref staged "$AGENT_OUTPUT" ``` If `prep` exits non-zero, relay its stderr to the user and stop.