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
3 changes: 0 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,6 @@ Hunk is a desktop-inspired terminal diff viewer for reviewing agent-authored cha
</tr>
</table>




## Install

```bash
Expand Down
32 changes: 32 additions & 0 deletions src/core/errors.ts
Original file line number Diff line number Diff line change
@@ -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`;
}
201 changes: 201 additions & 0 deletions src/core/git.ts
Original file line number Diff line number Diff line change
@@ -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 <before-file> <after-file>",
" hunk patch <file.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<typeof Bun.spawnSync>;

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<RunGitTextOptions, "input" | "args"> = {},
) {
return runGitText({
input,
args: ["rev-parse", "--show-toplevel"],
...options,
}).trim();
}
32 changes: 7 additions & 25 deletions src/core/loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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"];

Expand All @@ -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
Expand All @@ -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"];

Expand All @@ -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,
Expand All @@ -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"];

Expand All @@ -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,
Expand Down
Loading
Loading