Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ This organizes your local changes into reviewable chapters and opens a browser U
| Flag | Description |
|------|-------------|
| `--base <ref>` | Base ref to diff against (default: auto-detect main/master) |
| `--compare <ref>` | Compare ref to diff against `--base` |
| `--ref <mode>` | Diff scope: `work` (staged + unstaged + untracked), `staged`, or `unstaged` (default: auto-detect) |

Examples:
Expand All @@ -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
```

<img width="1840" height="1196" alt="Stage CLI" src="https://raw.githubusercontent.com/ReviewStage/stage-cli/main/assets/screenshot.png" />
Expand Down
123 changes: 121 additions & 2 deletions packages/cli/src/__tests__/git.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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";
Expand Down Expand Up @@ -37,3 +97,62 @@ 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("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();

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);
});
});
145 changes: 140 additions & 5 deletions packages/cli/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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:
Expand Down Expand Up @@ -233,12 +250,78 @@ 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 {
const effectiveLeft = left || "HEAD";
const effectiveRight = right || "HEAD";

const mergeBaseSha = resolveMergeBaseBetween(effectiveLeft, effectiveRight);
const headSha = resolveRefToSha(effectiveRight);

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, 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 {
Expand All @@ -265,3 +348,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);
Comment thread
dastratakos marked this conversation as resolved.
}

const base = options.base === undefined ? detectBaseRef() : options.base;
return resolveSingleRefScope(base, options.workingTreeRef);
}
30 changes: 24 additions & 6 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,26 +21,44 @@ 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(workingTreeRef?: string) {
return workingTreeRef !== undefined ? z.enum(WORKING_TREE_REF).parse(workingTreeRef) : undefined;
}

function readWorkingTreeRef(options: DiffCommandOptions) {
return parseWorkingTreeRef(options.ref);
}

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 <ref>", "Base ref to diff against (default: auto-detect main/master)")
.option("--compare <ref>", "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 workingTreeRef = readWorkingTreeRef(opts);
const filePath = runPrep(opts.base, workingTreeRef, refs, opts.compare);
process.stdout.write(filePath);
});

program
.command("show")
.description("Load a chapters.json file and open it in a local browser")
.argument("<path>", "Path to a chapters.json file")
.argument("[refs...]", "Git refs to diff, for example: main, main feature, or main..feature")
.option("--base <ref>", "Base ref to diff against (default: auto-detect main/master)")
.option("--compare <ref>", "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 workingTreeRef = readWorkingTreeRef(opts);
await show(jsonPath, opts.base, workingTreeRef, refs, opts.compare);
});

program.parseAsync(process.argv).catch((err) => {
Expand Down
Loading
Loading