Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ref>` | Base ref to diff against (default: auto-detect main/master) |
| `--ref <mode>` | 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
```

<img width="1840" height="1196" alt="Stage CLI" src="https://raw.githubusercontent.com/ReviewStage/stage-cli/main/assets/screenshot.png" />

## License
Expand Down
42 changes: 32 additions & 10 deletions packages/cli/src/git.ts
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -204,31 +204,53 @@ 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];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict work ref to uncommitted changes

The WORK branch currently returns git diff <merge-base> args, and git diff <commit> compares the working tree against that commit (per git-diff docs), so this mode includes committed branch changes since merge-base in addition to staged/unstaged edits. In a branch with local commits, --ref work therefore pulls in already-committed diffs and does not match the documented scope of "staged + unstaged + untracked" changes.

Useful? React with 👍 / 👎.

}
}

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);
if (untrackedDiff) {
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),
};
}

Expand Down
21 changes: 16 additions & 5 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -14,12 +16,19 @@ program
.description("Chapter-style code review against your local git branch.")
.version(version);

const refOption = new Option(
"--ref <mode>",
"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 <ref>", "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;
Comment thread
dastratakos marked this conversation as resolved.
const filePath = runPrep(opts.base, ref);
process.stdout.write(filePath);
});

Expand All @@ -28,8 +37,10 @@ program
.description("Load a chapters.json file and open it in a local browser")
.argument("<path>", "Path to a chapters.json file")
.option("--base <ref>", "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;
Comment thread
dastratakos marked this conversation as resolved.
await show(jsonPath, opts.base, ref);
});

program.parseAsync(process.argv).catch((err) => {
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/prep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,16 @@ 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} ===
=== Hunk @${hunk.oldStart}: ${hunk.header} ===
${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);
Expand Down
17 changes: 11 additions & 6 deletions packages/cli/src/show.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
export async function show(jsonPath: string, base?: string, ref?: WorkingTreeRef): Promise<void> {
const db = getDb();
const chaptersFile = loadChaptersFile(jsonPath, base);
const chaptersFile = loadChaptersFile(jsonPath, base, ref);
const { runId } = insertChaptersFile(db, chaptersFile, readRepoContext());

const handle = await startServer({
Expand All @@ -46,7 +47,7 @@ export async function show(jsonPath: string, base?: string): Promise<void> {
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;
Expand All @@ -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);

Expand Down
17 changes: 13 additions & 4 deletions skills/stage-chapters/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ref>` to both `prep` and `show`:
Both `prep` and `show` accept these optional flags:

- **`--base <ref>`** — base ref to diff against (default: auto-detect main/master).
- **`--ref <mode>`** — 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.
Expand Down
Loading