From 620f742ad41310b3c5ec76fe1b671f3de20d5e86 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Fri, 22 May 2026 19:13:33 -0700 Subject: [PATCH 1/4] Support comparing git refs in CLI --- README.md | 6 ++ packages/cli/src/__tests__/git.test.ts | 101 ++++++++++++++++- packages/cli/src/git.ts | 144 ++++++++++++++++++++++++- packages/cli/src/index.ts | 26 +++-- packages/cli/src/prep.ts | 19 +++- packages/cli/src/show.ts | 32 ++++-- skills/stage-chapters/SKILL.md | 22 +++- 7 files changed, 327 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 571e226..5603478 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ This organizes your local changes into reviewable chapters and opens a browser U | Flag | Description | |------|-------------| | `--base ` | Base ref to diff against (default: auto-detect main/master) | +| `--compare ` | Compare ref to diff against `--base` | | `--ref ` | Diff scope: `work` (staged + unstaged + untracked), `staged`, or `unstaged` (default: auto-detect) | Examples: @@ -62,6 +63,11 @@ Examples: # Diff against a specific branch /stage-chapters --base feature-a + +# Compare two branches +/stage-chapters main feature +/stage-chapters main..feature +/stage-chapters --base main --compare feature ``` Stage CLI diff --git a/packages/cli/src/__tests__/git.test.ts b/packages/cli/src/__tests__/git.test.ts index 5c341b9..f622c3a 100644 --- a/packages/cli/src/__tests__/git.test.ts +++ b/packages/cli/src/__tests__/git.test.ts @@ -1,5 +1,65 @@ -import { describe, expect, it } from "vitest"; -import { parseRepoName } from "../git.js"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { parseRepoName, resolveScope } from "../git.js"; +import { SCOPE_KIND } from "../schema.js"; + +let tmpDir: string; +let originalCwd: string; + +beforeEach(async () => { + originalCwd = process.cwd(); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "stage-cli-git-")); +}); + +afterEach(async () => { + process.chdir(originalCwd); + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +function git(...args: string[]): string { + return execFileSync("git", args, { + cwd: tmpDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" }, + }); +} + +async function writeFile(filePath: string, contents: string): Promise { + await fs.writeFile(path.join(tmpDir, filePath), contents); +} + +async function initDivergedRepo(): Promise<{ + commonSha: string; + mainSha: string; + featureSha: string; +}> { + git("init", "--initial-branch=main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + git("config", "commit.gpgsign", "false"); + + await writeFile("file.txt", "common\n"); + git("add", "file.txt"); + git("commit", "-m", "common"); + const commonSha = git("rev-parse", "HEAD").trim(); + + git("checkout", "-b", "feature"); + await writeFile("file.txt", "common\nfeature\n"); + git("commit", "-am", "feature change"); + const featureSha = git("rev-parse", "HEAD").trim(); + + git("checkout", "main"); + await writeFile("file.txt", "common\nmain\n"); + git("commit", "-am", "main change"); + const mainSha = git("rev-parse", "HEAD").trim(); + + process.chdir(tmpDir); + return { commonSha, mainSha, featureSha }; +} describe("parseRepoName", () => { const FALLBACK_ROOT = "/Users/dev/conductor/workspaces/stage-cli/monterrey-v3"; @@ -37,3 +97,40 @@ describe("parseRepoName", () => { expect(parseRepoName(".git", FALLBACK_ROOT)).toBe("monterrey-v3"); }); }); + +describe("resolveScope", () => { + it("compares two positional refs through their merge base", async () => { + const { commonSha, mainSha, featureSha } = await initDivergedRepo(); + + const result = resolveScope({ refs: ["main", "feature"] }); + + expect(mainSha).not.toBe(featureSha); + expect(result.scope.kind).toBe(SCOPE_KIND.COMMITTED); + expect(result.scope.baseSha).toBe(commonSha); + expect(result.scope.mergeBaseSha).toBe(commonSha); + expect(result.scope.headSha).toBe(featureSha); + expect(result.rawDiff).toContain("+feature"); + expect(result.rawDiff).not.toContain("+main"); + }); + + it("compares range refs through their merge base", async () => { + const { commonSha, featureSha } = await initDivergedRepo(); + + const result = resolveScope({ refs: ["main..feature"] }); + + expect(result.scope.kind).toBe(SCOPE_KIND.COMMITTED); + expect(result.scope.baseSha).toBe(commonSha); + expect(result.scope.headSha).toBe(featureSha); + expect(result.rawDiff).toContain("+feature"); + }); + + it("compares --base and --compare through their merge base", async () => { + const { commonSha, featureSha } = await initDivergedRepo(); + + const result = resolveScope({ base: "main", compare: "feature" }); + + expect(result.scope.kind).toBe(SCOPE_KIND.COMMITTED); + expect(result.scope.baseSha).toBe(commonSha); + expect(result.scope.headSha).toBe(featureSha); + }); +}); diff --git a/packages/cli/src/git.ts b/packages/cli/src/git.ts index 17add88..34356a7 100644 --- a/packages/cli/src/git.ts +++ b/packages/cli/src/git.ts @@ -183,8 +183,8 @@ export function getUntrackedDiff(files: string[]): string { return patches.join("\n"); } -export function getCommitMessages(mergeBase: string): string { - return execFileSync("git", ["log", "--oneline", `${mergeBase}..HEAD`], { +export function getCommitMessages(mergeBase: string, head: string): string { + return execFileSync("git", ["log", "--oneline", `${mergeBase}..${head}`], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }).trim(); @@ -204,6 +204,23 @@ export interface ResolvedScope { rawDiff: string; } +export interface ResolveScopeOptions { + base?: string; + compare?: string; + refs?: string[]; + workingTreeRef?: WorkingTreeRef; +} + +const RANGE_SEPARATOR = { + TWO_DOT: "..", + THREE_DOT: "...", +} as const; + +interface RefRange { + left: string; + right: string; +} + function workingTreeDiffArgs(ref: WorkingTreeRef, mergeBaseSha: string): string[] { switch (ref) { case WORKING_TREE_REF.UNSTAGED: @@ -233,8 +250,75 @@ function buildWorkingTreeDiff(ref: WorkingTreeRef, mergeBaseSha: string): string return rawDiff; } -export function resolveScope(baseOverride?: string, ref?: WorkingTreeRef): ResolvedScope { - const base = baseOverride ?? detectBaseRef(); +function resolveRefToSha(ref: string): string { + return execFileSync("git", ["rev-parse", "--verify", ref], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); +} + +function resolveMergeBaseBetween(left: string, right: string): string { + return execFileSync("git", ["merge-base", left, right], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); +} + +function parseRefRange(ref: string): RefRange | null { + const threeDotIndex = ref.indexOf(RANGE_SEPARATOR.THREE_DOT); + if (threeDotIndex !== -1) { + return { + left: ref.slice(0, threeDotIndex), + right: ref.slice(threeDotIndex + RANGE_SEPARATOR.THREE_DOT.length), + }; + } + + const twoDotIndex = ref.indexOf(RANGE_SEPARATOR.TWO_DOT); + if (twoDotIndex !== -1) { + return { + left: ref.slice(0, twoDotIndex), + right: ref.slice(twoDotIndex + RANGE_SEPARATOR.TWO_DOT.length), + }; + } + + return null; +} + +function resolveCommittedComparison(left: string, right: string): ResolvedScope { + if (!left || !right) { + throw new Error("Git ranges must include both a base ref and a compare ref."); + } + + const mergeBaseSha = resolveMergeBaseBetween(left, right); + const headSha = resolveRefToSha(right); + + return { + scope: { + kind: SCOPE_KIND.COMMITTED, + baseSha: mergeBaseSha, + headSha, + mergeBaseSha, + }, + mergeBaseSha, + rawDiff: getRawDiff([`${mergeBaseSha}..${headSha}`]), + }; +} + +function parseWorkingTreeRefArg(ref: string): WorkingTreeRef | null { + switch (ref) { + case ".": + case WORKING_TREE_REF.WORK: + return WORKING_TREE_REF.WORK; + case WORKING_TREE_REF.STAGED: + return WORKING_TREE_REF.STAGED; + case WORKING_TREE_REF.UNSTAGED: + return WORKING_TREE_REF.UNSTAGED; + default: + return null; + } +} + +function resolveSingleRefScope(base: string, ref?: WorkingTreeRef): ResolvedScope { const mergeBaseSha = resolveMergeBase(base); const headSha = resolveHead(); @@ -265,3 +349,55 @@ export function resolveScope(baseOverride?: string, ref?: WorkingTreeRef): Resol rawDiff: getRawDiff([`${mergeBaseSha}..${headSha}`]), }; } + +export function resolveScope(options: ResolveScopeOptions = {}): ResolvedScope { + const refs = options.refs === undefined ? [] : options.refs; + if (refs.length > 2) { + throw new Error("Expected at most two git ref arguments."); + } + if (refs.length > 0 && (options.base !== undefined || options.compare !== undefined)) { + throw new Error("Cannot use --base/--compare with positional git ref arguments."); + } + if (refs.length > 0 && options.workingTreeRef !== undefined) { + throw new Error("Cannot use --ref with positional git ref arguments."); + } + if (options.compare !== undefined && options.workingTreeRef !== undefined) { + throw new Error("Cannot use --compare with --ref."); + } + + if (options.compare !== undefined) { + if (options.base === undefined) { + throw new Error("--compare requires --base."); + } + return resolveCommittedComparison(options.base, options.compare); + } + + if (refs.length === 2) { + const left = refs[0]; + const right = refs[1]; + if (left === undefined || right === undefined) { + throw new Error("Expected both base and compare refs."); + } + return resolveCommittedComparison(left, right); + } + + if (refs.length === 1) { + const ref = refs[0]; + if (ref === undefined) { + throw new Error("Expected a git ref argument."); + } + + const range = parseRefRange(ref); + if (range) return resolveCommittedComparison(range.left, range.right); + + const workingTreeRef = parseWorkingTreeRefArg(ref); + if (workingTreeRef) { + return resolveSingleRefScope(detectBaseRef(), workingTreeRef); + } + + return resolveSingleRefScope(ref); + } + + const base = options.base === undefined ? detectBaseRef() : options.base; + return resolveSingleRefScope(base, options.workingTreeRef); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2f61207..ecd1cd6 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -21,14 +21,26 @@ const refOption = new Option( "Diff scope: work (staged + unstaged + untracked), staged, or unstaged (default: auto-detect)", ).choices(Object.values(WORKING_TREE_REF)); +interface DiffCommandOptions { + base?: string; + compare?: string; + ref?: string; +} + +function parseWorkingTreeRef(ref?: string) { + return ref !== undefined ? z.enum(WORKING_TREE_REF).parse(ref) : undefined; +} + program .command("prep") .description("Parse the current branch diff and prepare input for chapter generation") + .argument("[refs...]", "Git refs to diff, for example: main, main feature, or main..feature") .option("--base ", "Base ref to diff against (default: auto-detect main/master)") + .option("--compare ", "Compare ref to diff against --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); + .action((refs: string[], opts: DiffCommandOptions) => { + const ref = parseWorkingTreeRef(opts.ref); + const filePath = runPrep(opts.base, ref, refs, opts.compare); process.stdout.write(filePath); }); @@ -36,11 +48,13 @@ program .command("show") .description("Load a chapters.json file and open it in a local browser") .argument("", "Path to a chapters.json file") + .argument("[refs...]", "Git refs to diff, for example: main, main feature, or main..feature") .option("--base ", "Base ref to diff against (default: auto-detect main/master)") + .option("--compare ", "Compare ref to diff against --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); + .action(async (jsonPath: string, refs: string[], opts: DiffCommandOptions) => { + const ref = parseWorkingTreeRef(opts.ref); + await show(jsonPath, opts.base, ref, refs, opts.compare); }); program.parseAsync(process.argv).catch((err) => { diff --git a/packages/cli/src/prep.ts b/packages/cli/src/prep.ts index 4476cb6..a0e20ff 100644 --- a/packages/cli/src/prep.ts +++ b/packages/cli/src/prep.ts @@ -5,7 +5,7 @@ import type { Hunk, PullRequestFile } from "@stagereview/types/parsed-diff"; 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 { getCommitMessages, type ResolveScopeOptions, resolveScope } from "./git.js"; import type { WorkingTreeRef } from "./schema.js"; function formatHunkForPrompt(file: PullRequestFile, hunk: Hunk): string { @@ -14,8 +14,19 @@ function formatHunkForPrompt(file: PullRequestFile, hunk: Hunk): string { ${formatHunkDiffWithLineNumbers(hunk)}`; } -export function runPrep(base?: string, ref?: WorkingTreeRef): string { - const { rawDiff, mergeBaseSha } = resolveScope(base, ref); +export function runPrep( + base?: string, + ref?: WorkingTreeRef, + refs?: string[], + compare?: string, +): string { + const options: ResolveScopeOptions = { + base, + compare, + refs, + workingTreeRef: ref, + }; + const { scope, rawDiff, mergeBaseSha } = resolveScope(options); const allFiles = parseGitDiff(rawDiff); const { files } = filterFilesForLlm(allFiles); @@ -24,7 +35,7 @@ export function runPrep(base?: string, ref?: WorkingTreeRef): string { .flatMap((file) => file.hunks.map((hunk) => formatHunkForPrompt(file, hunk))) .join("\n\n"); - const commitMessages = getCommitMessages(mergeBaseSha); + const commitMessages = getCommitMessages(mergeBaseSha, scope.headSha); const sections = ["=== COMMIT MESSAGES ===", commitMessages, "", "=== HUNKS ===", formattedHunks]; diff --git a/packages/cli/src/show.ts b/packages/cli/src/show.ts index 6a5fe8d..d100d7b 100644 --- a/packages/cli/src/show.ts +++ b/packages/cli/src/show.ts @@ -5,7 +5,7 @@ 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 { readRepoContext, resolveScope } from "./git.js"; +import { type ResolveScopeOptions, readRepoContext, resolveScope } from "./git.js"; import { diffRoutes } from "./routes/diff.js"; import { runRoutes } from "./routes/runs.js"; import { viewStateRoutes } from "./routes/view-state.js"; @@ -21,9 +21,15 @@ import { } from "./schema.js"; import { LOOPBACK_HOST, startServer } from "./server.js"; -export async function show(jsonPath: string, base?: string, ref?: WorkingTreeRef): Promise { +export async function show( + jsonPath: string, + base?: string, + ref?: WorkingTreeRef, + refs?: string[], + compare?: string, +): Promise { const db = getDb(); - const chaptersFile = loadChaptersFile(jsonPath, base, ref); + const chaptersFile = loadChaptersFile(jsonPath, base, ref, refs, compare); const { runId } = insertChaptersFile(db, chaptersFile, readRepoContext()); const handle = await startServer({ @@ -47,7 +53,13 @@ export async function show(jsonPath: string, base?: string, ref?: WorkingTreeRef closeDb(); } -function loadChaptersFile(jsonPath: string, base?: string, ref?: WorkingTreeRef): ChaptersFile { +function loadChaptersFile( + jsonPath: string, + base?: string, + ref?: WorkingTreeRef, + refs?: string[], + compare?: string, +): ChaptersFile { const absolute = path.resolve(jsonPath); const raw = readFileSync(absolute, "utf8"); const parsed = JSON.parse(raw) as unknown; @@ -56,7 +68,7 @@ function loadChaptersFile(jsonPath: string, base?: string, ref?: WorkingTreeRef) if (fullResult.success) return fullResult.data; const agentResult = AgentOutputSchema.safeParse(parsed); - if (agentResult.success) return assembleChaptersFile(agentResult.data, base, ref); + if (agentResult.success) return assembleChaptersFile(agentResult.data, base, ref, refs, compare); throw fullResult.error; } @@ -65,8 +77,16 @@ function assembleChaptersFile( agentOutput: AgentOutput, base?: string, ref?: WorkingTreeRef, + refs?: string[], + compare?: string, ): ChaptersFile { - const { scope, rawDiff } = resolveScope(base, ref); + const options: ResolveScopeOptions = { + base, + compare, + refs, + workingTreeRef: ref, + }; + const { scope, rawDiff } = resolveScope(options); 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 67b1b3d..d6b59e2 100644 --- a/skills/stage-chapters/SKILL.md +++ b/skills/stage-chapters/SKILL.md @@ -38,21 +38,41 @@ PREP_FILE=$(stagereview prep) `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. +`prep` and `show` also accept positional git refs, similar to diffity: + +```bash +PREP_FILE=$(stagereview prep main) +PREP_FILE=$(stagereview prep main feature) +PREP_FILE=$(stagereview prep main..feature) +PREP_FILE=$(stagereview prep main...feature) +``` + +Use the same positional refs for `show`: + +```bash +stagereview show "$AGENT_OUTPUT" main..feature +``` + Both `prep` and `show` accept these optional flags: - **`--base `** — base ref to diff against (default: auto-detect main/master). +- **`--compare `** — compare ref to diff against `--base`. - **`--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`: +When flags or positional refs are specified, pass the same scope to **both** `prep` and `show`: ```bash PREP_FILE=$(stagereview prep --base feature-a --ref staged) # ... later ... stagereview show --base feature-a --ref staged "$AGENT_OUTPUT" + +PREP_FILE=$(stagereview prep --base main --compare feature) +# ... later ... +stagereview show --base main --compare feature "$AGENT_OUTPUT" ``` If `prep` exits non-zero, relay its stderr to the user and stop. From 2f1cb90fb06647faa4a57c839a4f3ecb81885758 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Fri, 22 May 2026 19:37:15 -0700 Subject: [PATCH 2/4] Support open-ended range refs --- packages/cli/src/__tests__/git.test.ts | 22 ++++++++++++++++++++++ packages/cli/src/git.ts | 9 ++++----- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/__tests__/git.test.ts b/packages/cli/src/__tests__/git.test.ts index f622c3a..4cef734 100644 --- a/packages/cli/src/__tests__/git.test.ts +++ b/packages/cli/src/__tests__/git.test.ts @@ -124,6 +124,28 @@ describe("resolveScope", () => { expect(result.rawDiff).toContain("+feature"); }); + it("defaults a missing left range ref to HEAD", async () => { + const { commonSha, featureSha } = await initDivergedRepo(); + + const result = resolveScope({ refs: ["..feature"] }); + + expect(result.scope.kind).toBe(SCOPE_KIND.COMMITTED); + expect(result.scope.baseSha).toBe(commonSha); + expect(result.scope.headSha).toBe(featureSha); + expect(result.rawDiff).toContain("+feature"); + }); + + it("defaults a missing right range ref to HEAD", async () => { + const { commonSha, mainSha } = await initDivergedRepo(); + + const result = resolveScope({ refs: ["feature.."] }); + + expect(result.scope.kind).toBe(SCOPE_KIND.COMMITTED); + expect(result.scope.baseSha).toBe(commonSha); + expect(result.scope.headSha).toBe(mainSha); + expect(result.rawDiff).toContain("+main"); + }); + it("compares --base and --compare through their merge base", async () => { const { commonSha, featureSha } = await initDivergedRepo(); diff --git a/packages/cli/src/git.ts b/packages/cli/src/git.ts index 34356a7..b382b1a 100644 --- a/packages/cli/src/git.ts +++ b/packages/cli/src/git.ts @@ -285,12 +285,11 @@ function parseRefRange(ref: string): RefRange | null { } function resolveCommittedComparison(left: string, right: string): ResolvedScope { - if (!left || !right) { - throw new Error("Git ranges must include both a base ref and a compare ref."); - } + const effectiveLeft = left || "HEAD"; + const effectiveRight = right || "HEAD"; - const mergeBaseSha = resolveMergeBaseBetween(left, right); - const headSha = resolveRefToSha(right); + const mergeBaseSha = resolveMergeBaseBetween(effectiveLeft, effectiveRight); + const headSha = resolveRefToSha(effectiveRight); return { scope: { From 903de8179025bd73e636179ffe6b11b25164a2ab Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Fri, 22 May 2026 23:01:42 -0700 Subject: [PATCH 3/4] Rename working tree ref plumbing --- packages/cli/src/git.ts | 4 ++-- packages/cli/src/index.ts | 16 ++++++++++------ packages/cli/src/prep.ts | 4 ++-- packages/cli/src/show.ts | 14 ++++++++------ skills/stage-chapters/SKILL.md | 2 +- 5 files changed, 23 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/git.ts b/packages/cli/src/git.ts index b382b1a..9854056 100644 --- a/packages/cli/src/git.ts +++ b/packages/cli/src/git.ts @@ -317,11 +317,11 @@ function parseWorkingTreeRefArg(ref: string): WorkingTreeRef | null { } } -function resolveSingleRefScope(base: string, ref?: WorkingTreeRef): ResolvedScope { +function resolveSingleRefScope(base: string, workingTreeRef?: WorkingTreeRef): ResolvedScope { const mergeBaseSha = resolveMergeBase(base); const headSha = resolveHead(); - const effectiveRef = ref ?? (hasUncommittedChanges() ? WORKING_TREE_REF.WORK : null); + const effectiveRef = workingTreeRef ?? (hasUncommittedChanges() ? WORKING_TREE_REF.WORK : null); if (effectiveRef) { return { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ecd1cd6..3518dba 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -27,8 +27,12 @@ interface DiffCommandOptions { ref?: string; } -function parseWorkingTreeRef(ref?: string) { - return ref !== undefined ? z.enum(WORKING_TREE_REF).parse(ref) : undefined; +function parseWorkingTreeRef(workingTreeRef?: string) { + return workingTreeRef !== undefined ? z.enum(WORKING_TREE_REF).parse(workingTreeRef) : undefined; +} + +function readWorkingTreeRef(options: DiffCommandOptions) { + return parseWorkingTreeRef(options.ref); } program @@ -39,8 +43,8 @@ program .option("--compare ", "Compare ref to diff against --base") .addOption(refOption) .action((refs: string[], opts: DiffCommandOptions) => { - const ref = parseWorkingTreeRef(opts.ref); - const filePath = runPrep(opts.base, ref, refs, opts.compare); + const workingTreeRef = readWorkingTreeRef(opts); + const filePath = runPrep(opts.base, workingTreeRef, refs, opts.compare); process.stdout.write(filePath); }); @@ -53,8 +57,8 @@ program .option("--compare ", "Compare ref to diff against --base") .addOption(refOption) .action(async (jsonPath: string, refs: string[], opts: DiffCommandOptions) => { - const ref = parseWorkingTreeRef(opts.ref); - await show(jsonPath, opts.base, ref, refs, opts.compare); + const workingTreeRef = readWorkingTreeRef(opts); + await show(jsonPath, opts.base, workingTreeRef, refs, opts.compare); }); program.parseAsync(process.argv).catch((err) => { diff --git a/packages/cli/src/prep.ts b/packages/cli/src/prep.ts index a0e20ff..9ad6968 100644 --- a/packages/cli/src/prep.ts +++ b/packages/cli/src/prep.ts @@ -16,7 +16,7 @@ ${formatHunkDiffWithLineNumbers(hunk)}`; export function runPrep( base?: string, - ref?: WorkingTreeRef, + workingTreeRef?: WorkingTreeRef, refs?: string[], compare?: string, ): string { @@ -24,7 +24,7 @@ export function runPrep( base, compare, refs, - workingTreeRef: ref, + workingTreeRef, }; const { scope, rawDiff, mergeBaseSha } = resolveScope(options); diff --git a/packages/cli/src/show.ts b/packages/cli/src/show.ts index d100d7b..a81f1e6 100644 --- a/packages/cli/src/show.ts +++ b/packages/cli/src/show.ts @@ -24,12 +24,12 @@ import { LOOPBACK_HOST, startServer } from "./server.js"; export async function show( jsonPath: string, base?: string, - ref?: WorkingTreeRef, + workingTreeRef?: WorkingTreeRef, refs?: string[], compare?: string, ): Promise { const db = getDb(); - const chaptersFile = loadChaptersFile(jsonPath, base, ref, refs, compare); + const chaptersFile = loadChaptersFile(jsonPath, base, workingTreeRef, refs, compare); const { runId } = insertChaptersFile(db, chaptersFile, readRepoContext()); const handle = await startServer({ @@ -56,7 +56,7 @@ export async function show( function loadChaptersFile( jsonPath: string, base?: string, - ref?: WorkingTreeRef, + workingTreeRef?: WorkingTreeRef, refs?: string[], compare?: string, ): ChaptersFile { @@ -68,7 +68,9 @@ function loadChaptersFile( if (fullResult.success) return fullResult.data; const agentResult = AgentOutputSchema.safeParse(parsed); - if (agentResult.success) return assembleChaptersFile(agentResult.data, base, ref, refs, compare); + if (agentResult.success) { + return assembleChaptersFile(agentResult.data, base, workingTreeRef, refs, compare); + } throw fullResult.error; } @@ -76,7 +78,7 @@ function loadChaptersFile( function assembleChaptersFile( agentOutput: AgentOutput, base?: string, - ref?: WorkingTreeRef, + workingTreeRef?: WorkingTreeRef, refs?: string[], compare?: string, ): ChaptersFile { @@ -84,7 +86,7 @@ function assembleChaptersFile( base, compare, refs, - workingTreeRef: ref, + workingTreeRef, }; const { scope, rawDiff } = resolveScope(options); const allFiles = parseGitDiff(rawDiff); diff --git a/skills/stage-chapters/SKILL.md b/skills/stage-chapters/SKILL.md index d6b59e2..594838d 100644 --- a/skills/stage-chapters/SKILL.md +++ b/skills/stage-chapters/SKILL.md @@ -38,7 +38,7 @@ PREP_FILE=$(stagereview prep) `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. -`prep` and `show` also accept positional git refs, similar to diffity: +`prep` and `show` also accept positional git refs: ```bash PREP_FILE=$(stagereview prep main) From 946bddf0e0153a1a2cb366671c3750637917eec4 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Fri, 22 May 2026 23:18:03 -0700 Subject: [PATCH 4/4] Prefer branch refs over keyword aliases --- packages/cli/src/__tests__/git.test.ts | 23 ++++++++++++++++++++++- packages/cli/src/git.ts | 17 ++++++++++++++--- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/__tests__/git.test.ts b/packages/cli/src/__tests__/git.test.ts index 4cef734..f6d537f 100644 --- a/packages/cli/src/__tests__/git.test.ts +++ b/packages/cli/src/__tests__/git.test.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { parseRepoName, resolveScope } from "../git.js"; -import { SCOPE_KIND } from "../schema.js"; +import { SCOPE_KIND, WORKING_TREE_REF } from "../schema.js"; let tmpDir: string; let originalCwd: string; @@ -155,4 +155,25 @@ describe("resolveScope", () => { expect(result.scope.baseSha).toBe(commonSha); expect(result.scope.headSha).toBe(featureSha); }); + + it("prefers a valid branch over a positional working-tree keyword", async () => { + const { mainSha } = await initDivergedRepo(); + git("checkout", "-b", "staged", "main"); + await writeFile("file.txt", "common\nbranch named staged\n"); + git("commit", "-am", "branch named staged"); + git("checkout", "main"); + await writeFile("file.txt", "common\nmain\nstaged index change\n"); + git("add", "file.txt"); + await writeFile("file.txt", "common\nmain\nstaged index change\nunstaged change\n"); + + const result = resolveScope({ refs: ["staged"] }); + + expect(result.scope.kind).toBe(SCOPE_KIND.WORKING_TREE); + if (result.scope.kind !== SCOPE_KIND.WORKING_TREE) { + throw new Error("Expected working-tree scope"); + } + expect(result.scope.ref).toBe(WORKING_TREE_REF.WORK); + expect(result.scope.headSha).toBe(mainSha); + expect(result.rawDiff).toContain("+unstaged change"); + }); }); diff --git a/packages/cli/src/git.ts b/packages/cli/src/git.ts index 9854056..3b0f7a4 100644 --- a/packages/cli/src/git.ts +++ b/packages/cli/src/git.ts @@ -257,6 +257,15 @@ function resolveRefToSha(ref: string): string { }).trim(); } +function canResolveRef(ref: string): boolean { + try { + resolveRefToSha(ref); + return true; + } catch { + return false; + } +} + function resolveMergeBaseBetween(left: string, right: string): string { return execFileSync("git", ["merge-base", left, right], { encoding: "utf8", @@ -389,9 +398,11 @@ export function resolveScope(options: ResolveScopeOptions = {}): ResolvedScope { const range = parseRefRange(ref); if (range) return resolveCommittedComparison(range.left, range.right); - const workingTreeRef = parseWorkingTreeRefArg(ref); - if (workingTreeRef) { - return resolveSingleRefScope(detectBaseRef(), workingTreeRef); + if (!canResolveRef(ref)) { + const workingTreeRef = parseWorkingTreeRefArg(ref); + if (workingTreeRef) { + return resolveSingleRefScope(detectBaseRef(), workingTreeRef); + } } return resolveSingleRefScope(ref);