diff --git a/README.md b/README.md index 2b84983f..89d6732e 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,6 @@ Hunk is a desktop-inspired terminal diff viewer for reviewing agent-authored cha - - - ## Install ```bash diff --git a/src/core/errors.ts b/src/core/errors.ts new file mode 100644 index 00000000..6679447f --- /dev/null +++ b/src/core/errors.ts @@ -0,0 +1,32 @@ +export class HunkUserError extends Error { + readonly details: string[]; + + constructor(message: string, details: string[] = []) { + super(message); + this.name = "HunkUserError"; + this.details = details; + } +} + +/** Format CLI and startup failures without exposing Bun internal stack frames for expected errors. */ +export function formatCliError(error: unknown) { + if (error instanceof HunkUserError) { + const lines = [`hunk: ${error.message}`]; + + if (error.details.length > 0) { + lines.push("", ...error.details); + } + + return `${lines.join("\n")}\n`; + } + + if (error instanceof Error) { + if (process.env.HUNK_DEBUG === "1" && error.stack) { + return `${error.stack}\n`; + } + + return `hunk: ${error.message}\n`; + } + + return `hunk: ${String(error)}\n`; +} diff --git a/src/core/git.ts b/src/core/git.ts new file mode 100644 index 00000000..1b751daf --- /dev/null +++ b/src/core/git.ts @@ -0,0 +1,201 @@ +import { HunkUserError } from "./errors"; +import type { GitCommandInput, ShowCommandInput, StashShowCommandInput } from "./types"; + +export type GitBackedInput = GitCommandInput | ShowCommandInput | StashShowCommandInput; + +export interface RunGitTextOptions { + input: GitBackedInput; + args: string[]; + cwd?: string; + gitExecutable?: string; +} + +export function formatGitCommandLabel(input: GitBackedInput) { + switch (input.kind) { + case "git": + if (input.staged) { + return "hunk diff --staged"; + } + + return input.range ? `hunk diff ${input.range}` : "hunk diff"; + case "show": + return input.ref ? `hunk show ${input.ref}` : "hunk show"; + case "stash-show": + return input.ref ? `hunk stash show ${input.ref}` : "hunk stash show"; + } +} + +function getMissingRepoHelp(input: GitBackedInput) { + if (input.kind === "git") { + return [ + "Run the command from a Git checkout, or compare files directly instead:", + " hunk diff ", + " hunk patch ", + ]; + } + + return ["Run the command from a Git checkout."]; +} + +function trimGitPrefix(message: string) { + return message.replace(/^(fatal|error):\s*/i, "").trim(); +} + +function firstGitErrorLine(stderr: string) { + const line = stderr + .split("\n") + .map((entry) => entry.trim()) + .find(Boolean); + + return trimGitPrefix((line ?? stderr.trim()) || "Git command failed."); +} + +function isMissingGitRepoMessage(stderr: string) { + return stderr.includes("not a git repository"); +} + +function isUnknownRevisionMessage(stderr: string) { + return [ + "bad revision", + "unknown revision or path not in the working tree", + "ambiguous argument", + ].some((fragment) => stderr.includes(fragment)); +} + +function isNoStashEntriesMessage(stderr: string) { + return ["No stash entries found.", "log for 'stash' only has"].some((fragment) => + stderr.includes(fragment), + ); +} + +function createMissingGitExecutableError(input: GitBackedInput, gitExecutable: string) { + return new HunkUserError( + `Git is required for \`${formatGitCommandLabel(input)}\`, but \`${gitExecutable}\` was not found in PATH.`, + ["Install Git or make it available on PATH, then try again."], + ); +} + +function createMissingRepoError(input: GitBackedInput) { + return new HunkUserError( + `\`${formatGitCommandLabel(input)}\` must be run inside a Git repository.`, + getMissingRepoHelp(input), + ); +} + +function createInvalidRevisionError(input: GitCommandInput | ShowCommandInput) { + if (input.kind === "git") { + return new HunkUserError( + `\`${formatGitCommandLabel(input)}\` could not resolve Git revision or range \`${input.range}\`.`, + ["Check the revision or range and try again."], + ); + } + + const ref = input.ref ?? "HEAD"; + return new HunkUserError( + `\`${formatGitCommandLabel(input)}\` could not resolve Git ref \`${ref}\`.`, + ["Check the ref name and try again."], + ); +} + +function createMissingStashError(input: StashShowCommandInput) { + if (input.ref) { + return new HunkUserError( + `\`${formatGitCommandLabel(input)}\` could not resolve stash entry \`${input.ref}\`.`, + ["List available stashes with `git stash list`, then try again."], + ); + } + + return new HunkUserError("`hunk stash show` could not find a stash entry to show.", [ + "Create one with `git stash push`, or pass an explicit stash ref like `hunk stash show stash@{0}`.", + ]); +} + +function createGenericGitError(input: GitBackedInput, stderr: string) { + return new HunkUserError(`\`${formatGitCommandLabel(input)}\` failed.`, [ + firstGitErrorLine(stderr), + ]); +} + +function translateGitSpawnFailure( + input: GitBackedInput, + error: unknown, + gitExecutable: string, +): Error { + if (error instanceof HunkUserError) { + return error; + } + + if (error instanceof Error && error.message.includes("Executable not found in $PATH")) { + return createMissingGitExecutableError(input, gitExecutable); + } + + return error instanceof Error ? error : new Error(String(error)); +} + +function translateGitExitFailure(input: GitBackedInput, stderr: string) { + if (isMissingGitRepoMessage(stderr)) { + return createMissingRepoError(input); + } + + if (input.kind === "stash-show" && isNoStashEntriesMessage(stderr)) { + return createMissingStashError(input); + } + + if (input.kind === "git" && input.range && isUnknownRevisionMessage(stderr)) { + return createInvalidRevisionError(input); + } + + if (input.kind === "show" && isUnknownRevisionMessage(stderr)) { + return createInvalidRevisionError(input); + } + + if (input.kind === "stash-show" && input.ref && isUnknownRevisionMessage(stderr)) { + return createMissingStashError(input); + } + + return createGenericGitError(input, stderr); +} + +/** Run a git command and translate common failures into user-facing Hunk errors. */ +export function runGitText({ + input, + args, + cwd = process.cwd(), + gitExecutable = "git", +}: RunGitTextOptions) { + let proc: ReturnType; + + try { + proc = Bun.spawnSync([gitExecutable, ...args], { + cwd, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + } catch (error) { + throw translateGitSpawnFailure(input, error, gitExecutable); + } + + const stdout = Buffer.from(proc.stdout ?? []).toString("utf8"); + const stderr = Buffer.from(proc.stderr ?? []).toString("utf8"); + + if (proc.exitCode !== 0) { + throw translateGitExitFailure( + input, + stderr.trim() || `Command failed: ${gitExecutable} ${args.join(" ")}`, + ); + } + + return stdout; +} + +export function resolveGitRepoRoot( + input: GitBackedInput, + options: Omit = {}, +) { + return runGitText({ + input, + args: ["rev-parse", "--show-toplevel"], + ...options, + }).trim(); +} diff --git a/src/core/loaders.ts b/src/core/loaders.ts index f11c6867..d19bba7b 100644 --- a/src/core/loaders.ts +++ b/src/core/loaders.ts @@ -7,6 +7,7 @@ import { } from "@pierre/diffs"; import { createTwoFilesPatch } from "diff"; import { findAgentFileContext, loadAgentContext } from "./agent"; +import { resolveGitRepoRoot, runGitText } from "./git"; import type { AppBootstrap, AgentContext, @@ -21,25 +22,6 @@ import type { StashShowCommandInput, } from "./types"; -/** Run a command synchronously and return stdout as text. */ -function spawnText(cmd: string[], cwd = process.cwd()) { - const proc = Bun.spawnSync(cmd, { - cwd, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); - - const stdout = Buffer.from(proc.stdout).toString("utf8"); - const stderr = Buffer.from(proc.stderr).toString("utf8"); - - if (proc.exitCode !== 0) { - throw new Error(stderr.trim() || `Command failed: ${cmd.join(" ")}`); - } - - return stdout; -} - /** Return the final path segment for display-oriented labels. */ function basename(path: string) { return path.split("/").filter(Boolean).pop() ?? path; @@ -295,7 +277,7 @@ function appendPathspecs(args: string[], pathspecs?: string[]) { /** Build a changeset from the current repository working tree or a git range. */ async function loadGitChangeset(input: GitCommandInput, agentContext: AgentContext | null) { - const repoRoot = spawnText(["git", "rev-parse", "--show-toplevel"]).trim(); + const repoRoot = resolveGitRepoRoot(input); const repoName = basename(repoRoot); const args = ["git", "diff", "--no-ext-diff", "--find-renames", "--no-color"]; @@ -309,7 +291,7 @@ async function loadGitChangeset(input: GitCommandInput, agentContext: AgentConte appendPathspecs(args, input.pathspecs); - const patchText = spawnText(args); + const patchText = runGitText({ input, args: args.slice(1) }); const title = input.staged ? `${repoName} staged changes` : input.range @@ -321,7 +303,7 @@ async function loadGitChangeset(input: GitCommandInput, agentContext: AgentConte /** Build a changeset from `git show`, suppressing commit-message chrome so only the patch feeds the UI. */ async function loadShowChangeset(input: ShowCommandInput, agentContext: AgentContext | null) { - const repoRoot = spawnText(["git", "rev-parse", "--show-toplevel"]).trim(); + const repoRoot = resolveGitRepoRoot(input); const repoName = basename(repoRoot); const args = ["git", "show", "--format=", "--no-ext-diff", "--find-renames", "--no-color"]; @@ -332,7 +314,7 @@ async function loadShowChangeset(input: ShowCommandInput, agentContext: AgentCon appendPathspecs(args, input.pathspecs); return normalizePatchChangeset( - spawnText(args), + runGitText({ input, args: args.slice(1) }), input.ref ? `${repoName} show ${input.ref}` : `${repoName} show HEAD`, repoRoot, agentContext, @@ -344,7 +326,7 @@ async function loadStashShowChangeset( input: StashShowCommandInput, agentContext: AgentContext | null, ) { - const repoRoot = spawnText(["git", "rev-parse", "--show-toplevel"]).trim(); + const repoRoot = resolveGitRepoRoot(input); const repoName = basename(repoRoot); const args = ["git", "stash", "show", "-p", "--find-renames", "--no-color"]; @@ -353,7 +335,7 @@ async function loadStashShowChangeset( } return normalizePatchChangeset( - spawnText(args), + runGitText({ input, args: args.slice(1) }), input.ref ? `${repoName} stash ${input.ref}` : `${repoName} stash`, repoRoot, agentContext, diff --git a/src/main.tsx b/src/main.tsx index 8427878b..bdbe3e02 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,7 @@ import { createCliRenderer } from "@opentui/core"; import { createRoot } from "@opentui/react"; +import { formatCliError } from "./core/errors"; import { pagePlainText } from "./core/pager"; import { shutdownSession } from "./core/shutdown"; import { prepareStartupPlan } from "./core/startup"; @@ -11,62 +12,69 @@ import { serveHunkMcpServer } from "./mcp/server"; import { createInitialSessionSnapshot, createSessionRegistration } from "./mcp/sessionRegistration"; import { runSessionCommand } from "./session/commands"; -const startupPlan = await prepareStartupPlan(); +async function main() { + const startupPlan = await prepareStartupPlan(); -if (startupPlan.kind === "help") { - process.stdout.write(startupPlan.text); - process.exit(0); -} + if (startupPlan.kind === "help") { + process.stdout.write(startupPlan.text); + process.exit(0); + } -if (startupPlan.kind === "mcp-serve") { - serveHunkMcpServer(); - await new Promise(() => {}); -} + if (startupPlan.kind === "mcp-serve") { + serveHunkMcpServer(); + await new Promise(() => {}); + } -if (startupPlan.kind === "session-command") { - process.stdout.write(await runSessionCommand(startupPlan.input)); - process.exit(0); -} + if (startupPlan.kind === "session-command") { + process.stdout.write(await runSessionCommand(startupPlan.input)); + process.exit(0); + } -if (startupPlan.kind === "plain-text-pager") { - await pagePlainText(startupPlan.text); - process.exit(0); -} + if (startupPlan.kind === "plain-text-pager") { + await pagePlainText(startupPlan.text); + process.exit(0); + } -if (startupPlan.kind !== "app") { - throw new Error("Unreachable startup plan."); -} + if (startupPlan.kind !== "app") { + throw new Error("Unreachable startup plan."); + } -const { bootstrap, cliInput, controllingTerminal } = startupPlan; -const hostClient = new HunkHostClient( - createSessionRegistration(bootstrap), - createInitialSessionSnapshot(bootstrap), -); -hostClient.start(); + const { bootstrap, cliInput, controllingTerminal } = startupPlan; + const hostClient = new HunkHostClient( + createSessionRegistration(bootstrap), + createInitialSessionSnapshot(bootstrap), + ); + hostClient.start(); -const renderer = await createCliRenderer({ - stdin: controllingTerminal?.stdin, - stdout: controllingTerminal?.stdout, - useMouse: !cliInput.options.pager, - useAlternateScreen: true, - exitOnCtrlC: true, - openConsoleOnError: true, - onDestroy: () => controllingTerminal?.close(), -}); + const renderer = await createCliRenderer({ + stdin: controllingTerminal?.stdin, + stdout: controllingTerminal?.stdout, + useMouse: !cliInput.options.pager, + useAlternateScreen: true, + exitOnCtrlC: true, + openConsoleOnError: true, + onDestroy: () => controllingTerminal?.close(), + }); + + const root = createRoot(renderer); + let shuttingDown = false; -const root = createRoot(renderer); -let shuttingDown = false; + /** Tear down the renderer before exit so the primary terminal screen comes back cleanly. */ + function shutdown() { + if (shuttingDown) { + return; + } -/** Tear down the renderer before exit so the primary terminal screen comes back cleanly. */ -function shutdown() { - if (shuttingDown) { - return; + shuttingDown = true; + hostClient.stop(); + shutdownSession({ root, renderer }); } - shuttingDown = true; - hostClient.stop(); - shutdownSession({ root, renderer }); + // The app owns the full alternate screen session from this point on. + root.render(); } -// The app owns the full alternate screen session from this point on. -root.render(); +await main().catch((error) => { + process.stderr.write(formatCliError(error)); + process.exit(1); +}); diff --git a/test/git.test.ts b/test/git.test.ts new file mode 100644 index 00000000..f232d965 --- /dev/null +++ b/test/git.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test"; +import { runGitText } from "../src/core/git"; + +describe("git command helpers", () => { + test("reports a friendly error when git is not installed or not on PATH", () => { + expect(() => + runGitText({ + input: { + kind: "git", + staged: false, + options: { mode: "auto" }, + }, + args: ["status"], + gitExecutable: "definitely-not-a-real-git-binary", + }), + ).toThrow( + "Git is required for `hunk diff`, but `definitely-not-a-real-git-binary` was not found in PATH.", + ); + }); +}); diff --git a/test/help-output.test.ts b/test/help-output.test.ts index 666ea434..08b9d39c 100644 --- a/test/help-output.test.ts +++ b/test/help-output.test.ts @@ -1,4 +1,22 @@ import { describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +function git(cwd: string, ...args: string[]) { + const proc = Bun.spawnSync(["git", ...args], { + cwd, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + + if (proc.exitCode !== 0) { + throw new Error( + Buffer.from(proc.stderr).toString("utf8").trim() || `git ${args.join(" ")} failed`, + ); + } +} describe("CLI help output", () => { test("bare hunk prints standard help without terminal takeover sequences", () => { @@ -86,4 +104,66 @@ describe("CLI help output", () => { expect(stdout).not.toContain("View Navigate Theme Agent Help"); expect(stdout).not.toContain("\u001b[?1049h"); }); + + test("prints a friendly git-repo error without a Bun stack trace", () => { + const nonRepoDir = mkdtempSync(join(tmpdir(), "hunk-nonrepo-")); + const sourceEntrypoint = join(process.cwd(), "src/main.tsx"); + + try { + const proc = Bun.spawnSync(["bun", "run", sourceEntrypoint, "diff"], { + cwd: nonRepoDir, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: process.env, + }); + + const stdout = Buffer.from(proc.stdout).toString("utf8"); + const stderr = Buffer.from(proc.stderr).toString("utf8"); + + expect(proc.exitCode).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toContain("hunk: `hunk diff` must be run inside a Git repository."); + expect(stderr).toContain("hunk diff "); + expect(stderr).not.toContain("at runGitText"); + expect(stderr).not.toContain("loadGitChangeset"); + expect(stderr).not.toContain("Bun v"); + } finally { + rmSync(nonRepoDir, { recursive: true, force: true }); + } + }); + + test("prints a friendly invalid-ref error without a Bun stack trace", () => { + const repoDir = mkdtempSync(join(tmpdir(), "hunk-show-cli-")); + const sourceEntrypoint = join(process.cwd(), "src/main.tsx"); + + try { + git(repoDir, "init"); + git(repoDir, "config", "user.name", "Test User"); + git(repoDir, "config", "user.email", "test@example.com"); + writeFileSync(join(repoDir, "alpha.ts"), "export const alpha = 1;\n"); + git(repoDir, "add", "alpha.ts"); + git(repoDir, "commit", "-m", "initial"); + + const proc = Bun.spawnSync(["bun", "run", sourceEntrypoint, "show", "HEAD~999"], { + cwd: repoDir, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: process.env, + }); + + const stdout = Buffer.from(proc.stdout).toString("utf8"); + const stderr = Buffer.from(proc.stderr).toString("utf8"); + + expect(proc.exitCode).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toContain("hunk: `hunk show HEAD~999` could not resolve Git ref `HEAD~999`."); + expect(stderr).toContain("Check the ref name and try again."); + expect(stderr).not.toContain("runGitText"); + expect(stderr).not.toContain("Bun v"); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } + }); }); diff --git a/test/loaders.test.ts b/test/loaders.test.ts index 1cc00cb8..262c4c1c 100644 --- a/test/loaders.test.ts +++ b/test/loaders.test.ts @@ -119,6 +119,36 @@ describe("loadAppBootstrap", () => { expect(bootstrap.changeset.files[0]?.stats.additions).toBeGreaterThan(0); }); + test("reports a friendly error when git review runs outside a repository", async () => { + const dir = mkdtempSync(join(tmpdir(), "hunk-nonrepo-")); + tempDirs.push(dir); + + await expect( + loadFromRepo(dir, { + kind: "git", + staged: false, + options: { mode: "auto" }, + }), + ).rejects.toThrow("`hunk diff` must be run inside a Git repository."); + }); + + test("reports a friendly error when diff cannot resolve a range", async () => { + const dir = createTempRepo("hunk-git-missing-range-"); + + writeFileSync(join(dir, "alpha.ts"), "export const alpha = 1;\n"); + git(dir, "add", "alpha.ts"); + git(dir, "commit", "-m", "initial"); + + await expect( + loadFromRepo(dir, { + kind: "git", + range: "HEAD~999", + staged: false, + options: { mode: "auto" }, + }), + ).rejects.toThrow("`hunk diff HEAD~999` could not resolve Git revision or range `HEAD~999`."); + }); + test("uses agent sidecar file order for the review stream", async () => { const dir = createTempRepo("hunk-git-"); @@ -235,6 +265,22 @@ describe("loadAppBootstrap", () => { expect(previous.changeset.files.map((file) => file.path)).toEqual(["alpha.ts"]); }); + test("reports a friendly error when show cannot resolve a ref", async () => { + const dir = createTempRepo("hunk-show-missing-ref-"); + + writeFileSync(join(dir, "alpha.ts"), "export const alpha = 1;\n"); + git(dir, "add", "alpha.ts"); + git(dir, "commit", "-m", "initial"); + + await expect( + loadFromRepo(dir, { + kind: "show", + ref: "HEAD~999", + options: { mode: "auto" }, + }), + ).rejects.toThrow("`hunk show HEAD~999` could not resolve Git ref `HEAD~999`."); + }); + test("loads show output limited by pathspec", async () => { const dir = createTempRepo("hunk-show-pathspec-"); @@ -277,6 +323,40 @@ describe("loadAppBootstrap", () => { expect(bootstrap.changeset.title).toContain("stash"); }); + test("reports a friendly error when no stash entries exist", async () => { + const dir = createTempRepo("hunk-stash-empty-"); + + writeFileSync(join(dir, "alpha.ts"), "export const alpha = 1;\n"); + git(dir, "add", "alpha.ts"); + git(dir, "commit", "-m", "initial"); + + await expect( + loadFromRepo(dir, { + kind: "stash-show", + options: { mode: "auto" }, + }), + ).rejects.toThrow("`hunk stash show` could not find a stash entry to show."); + }); + + test("reports a friendly error when a stash ref does not exist", async () => { + const dir = createTempRepo("hunk-stash-missing-ref-"); + + writeFileSync(join(dir, "alpha.ts"), "export const alpha = 1;\n"); + git(dir, "add", "alpha.ts"); + git(dir, "commit", "-m", "initial"); + + writeFileSync(join(dir, "alpha.ts"), "export const alpha = 2;\n"); + git(dir, "stash", "push", "-m", "update alpha"); + + await expect( + loadFromRepo(dir, { + kind: "stash-show", + ref: "stash@{99}", + options: { mode: "auto" }, + }), + ).rejects.toThrow("`hunk stash show stash@{99}` could not resolve stash entry `stash@{99}`."); + }); + test("treats malformed inline patch text as an empty review instead of throwing", async () => { const bootstrap = await loadAppBootstrap({ kind: "patch",