Skip to content

Commit b6d77e1

Browse files
authored
Improve friendly git command errors (#75)
* Improve friendly git command errors * Format README after merging main
1 parent 0d43088 commit b6d77e1

8 files changed

Lines changed: 474 additions & 74 deletions

File tree

README.md

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,6 @@ Hunk is a desktop-inspired terminal diff viewer for reviewing agent-authored cha
2929
</tr>
3030
</table>
3131

32-
33-
34-
3532
## Install
3633

3734
```bash

src/core/errors.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
export class HunkUserError extends Error {
2+
readonly details: string[];
3+
4+
constructor(message: string, details: string[] = []) {
5+
super(message);
6+
this.name = "HunkUserError";
7+
this.details = details;
8+
}
9+
}
10+
11+
/** Format CLI and startup failures without exposing Bun internal stack frames for expected errors. */
12+
export function formatCliError(error: unknown) {
13+
if (error instanceof HunkUserError) {
14+
const lines = [`hunk: ${error.message}`];
15+
16+
if (error.details.length > 0) {
17+
lines.push("", ...error.details);
18+
}
19+
20+
return `${lines.join("\n")}\n`;
21+
}
22+
23+
if (error instanceof Error) {
24+
if (process.env.HUNK_DEBUG === "1" && error.stack) {
25+
return `${error.stack}\n`;
26+
}
27+
28+
return `hunk: ${error.message}\n`;
29+
}
30+
31+
return `hunk: ${String(error)}\n`;
32+
}

src/core/git.ts

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { HunkUserError } from "./errors";
2+
import type { GitCommandInput, ShowCommandInput, StashShowCommandInput } from "./types";
3+
4+
export type GitBackedInput = GitCommandInput | ShowCommandInput | StashShowCommandInput;
5+
6+
export interface RunGitTextOptions {
7+
input: GitBackedInput;
8+
args: string[];
9+
cwd?: string;
10+
gitExecutable?: string;
11+
}
12+
13+
export function formatGitCommandLabel(input: GitBackedInput) {
14+
switch (input.kind) {
15+
case "git":
16+
if (input.staged) {
17+
return "hunk diff --staged";
18+
}
19+
20+
return input.range ? `hunk diff ${input.range}` : "hunk diff";
21+
case "show":
22+
return input.ref ? `hunk show ${input.ref}` : "hunk show";
23+
case "stash-show":
24+
return input.ref ? `hunk stash show ${input.ref}` : "hunk stash show";
25+
}
26+
}
27+
28+
function getMissingRepoHelp(input: GitBackedInput) {
29+
if (input.kind === "git") {
30+
return [
31+
"Run the command from a Git checkout, or compare files directly instead:",
32+
" hunk diff <before-file> <after-file>",
33+
" hunk patch <file.patch>",
34+
];
35+
}
36+
37+
return ["Run the command from a Git checkout."];
38+
}
39+
40+
function trimGitPrefix(message: string) {
41+
return message.replace(/^(fatal|error):\s*/i, "").trim();
42+
}
43+
44+
function firstGitErrorLine(stderr: string) {
45+
const line = stderr
46+
.split("\n")
47+
.map((entry) => entry.trim())
48+
.find(Boolean);
49+
50+
return trimGitPrefix((line ?? stderr.trim()) || "Git command failed.");
51+
}
52+
53+
function isMissingGitRepoMessage(stderr: string) {
54+
return stderr.includes("not a git repository");
55+
}
56+
57+
function isUnknownRevisionMessage(stderr: string) {
58+
return [
59+
"bad revision",
60+
"unknown revision or path not in the working tree",
61+
"ambiguous argument",
62+
].some((fragment) => stderr.includes(fragment));
63+
}
64+
65+
function isNoStashEntriesMessage(stderr: string) {
66+
return ["No stash entries found.", "log for 'stash' only has"].some((fragment) =>
67+
stderr.includes(fragment),
68+
);
69+
}
70+
71+
function createMissingGitExecutableError(input: GitBackedInput, gitExecutable: string) {
72+
return new HunkUserError(
73+
`Git is required for \`${formatGitCommandLabel(input)}\`, but \`${gitExecutable}\` was not found in PATH.`,
74+
["Install Git or make it available on PATH, then try again."],
75+
);
76+
}
77+
78+
function createMissingRepoError(input: GitBackedInput) {
79+
return new HunkUserError(
80+
`\`${formatGitCommandLabel(input)}\` must be run inside a Git repository.`,
81+
getMissingRepoHelp(input),
82+
);
83+
}
84+
85+
function createInvalidRevisionError(input: GitCommandInput | ShowCommandInput) {
86+
if (input.kind === "git") {
87+
return new HunkUserError(
88+
`\`${formatGitCommandLabel(input)}\` could not resolve Git revision or range \`${input.range}\`.`,
89+
["Check the revision or range and try again."],
90+
);
91+
}
92+
93+
const ref = input.ref ?? "HEAD";
94+
return new HunkUserError(
95+
`\`${formatGitCommandLabel(input)}\` could not resolve Git ref \`${ref}\`.`,
96+
["Check the ref name and try again."],
97+
);
98+
}
99+
100+
function createMissingStashError(input: StashShowCommandInput) {
101+
if (input.ref) {
102+
return new HunkUserError(
103+
`\`${formatGitCommandLabel(input)}\` could not resolve stash entry \`${input.ref}\`.`,
104+
["List available stashes with `git stash list`, then try again."],
105+
);
106+
}
107+
108+
return new HunkUserError("`hunk stash show` could not find a stash entry to show.", [
109+
"Create one with `git stash push`, or pass an explicit stash ref like `hunk stash show stash@{0}`.",
110+
]);
111+
}
112+
113+
function createGenericGitError(input: GitBackedInput, stderr: string) {
114+
return new HunkUserError(`\`${formatGitCommandLabel(input)}\` failed.`, [
115+
firstGitErrorLine(stderr),
116+
]);
117+
}
118+
119+
function translateGitSpawnFailure(
120+
input: GitBackedInput,
121+
error: unknown,
122+
gitExecutable: string,
123+
): Error {
124+
if (error instanceof HunkUserError) {
125+
return error;
126+
}
127+
128+
if (error instanceof Error && error.message.includes("Executable not found in $PATH")) {
129+
return createMissingGitExecutableError(input, gitExecutable);
130+
}
131+
132+
return error instanceof Error ? error : new Error(String(error));
133+
}
134+
135+
function translateGitExitFailure(input: GitBackedInput, stderr: string) {
136+
if (isMissingGitRepoMessage(stderr)) {
137+
return createMissingRepoError(input);
138+
}
139+
140+
if (input.kind === "stash-show" && isNoStashEntriesMessage(stderr)) {
141+
return createMissingStashError(input);
142+
}
143+
144+
if (input.kind === "git" && input.range && isUnknownRevisionMessage(stderr)) {
145+
return createInvalidRevisionError(input);
146+
}
147+
148+
if (input.kind === "show" && isUnknownRevisionMessage(stderr)) {
149+
return createInvalidRevisionError(input);
150+
}
151+
152+
if (input.kind === "stash-show" && input.ref && isUnknownRevisionMessage(stderr)) {
153+
return createMissingStashError(input);
154+
}
155+
156+
return createGenericGitError(input, stderr);
157+
}
158+
159+
/** Run a git command and translate common failures into user-facing Hunk errors. */
160+
export function runGitText({
161+
input,
162+
args,
163+
cwd = process.cwd(),
164+
gitExecutable = "git",
165+
}: RunGitTextOptions) {
166+
let proc: ReturnType<typeof Bun.spawnSync>;
167+
168+
try {
169+
proc = Bun.spawnSync([gitExecutable, ...args], {
170+
cwd,
171+
stdin: "ignore",
172+
stdout: "pipe",
173+
stderr: "pipe",
174+
});
175+
} catch (error) {
176+
throw translateGitSpawnFailure(input, error, gitExecutable);
177+
}
178+
179+
const stdout = Buffer.from(proc.stdout ?? []).toString("utf8");
180+
const stderr = Buffer.from(proc.stderr ?? []).toString("utf8");
181+
182+
if (proc.exitCode !== 0) {
183+
throw translateGitExitFailure(
184+
input,
185+
stderr.trim() || `Command failed: ${gitExecutable} ${args.join(" ")}`,
186+
);
187+
}
188+
189+
return stdout;
190+
}
191+
192+
export function resolveGitRepoRoot(
193+
input: GitBackedInput,
194+
options: Omit<RunGitTextOptions, "input" | "args"> = {},
195+
) {
196+
return runGitText({
197+
input,
198+
args: ["rev-parse", "--show-toplevel"],
199+
...options,
200+
}).trim();
201+
}

src/core/loaders.ts

Lines changed: 7 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from "@pierre/diffs";
88
import { createTwoFilesPatch } from "diff";
99
import { findAgentFileContext, loadAgentContext } from "./agent";
10+
import { resolveGitRepoRoot, runGitText } from "./git";
1011
import type {
1112
AppBootstrap,
1213
AgentContext,
@@ -21,25 +22,6 @@ import type {
2122
StashShowCommandInput,
2223
} from "./types";
2324

24-
/** Run a command synchronously and return stdout as text. */
25-
function spawnText(cmd: string[], cwd = process.cwd()) {
26-
const proc = Bun.spawnSync(cmd, {
27-
cwd,
28-
stdin: "ignore",
29-
stdout: "pipe",
30-
stderr: "pipe",
31-
});
32-
33-
const stdout = Buffer.from(proc.stdout).toString("utf8");
34-
const stderr = Buffer.from(proc.stderr).toString("utf8");
35-
36-
if (proc.exitCode !== 0) {
37-
throw new Error(stderr.trim() || `Command failed: ${cmd.join(" ")}`);
38-
}
39-
40-
return stdout;
41-
}
42-
4325
/** Return the final path segment for display-oriented labels. */
4426
function basename(path: string) {
4527
return path.split("/").filter(Boolean).pop() ?? path;
@@ -295,7 +277,7 @@ function appendPathspecs(args: string[], pathspecs?: string[]) {
295277

296278
/** Build a changeset from the current repository working tree or a git range. */
297279
async function loadGitChangeset(input: GitCommandInput, agentContext: AgentContext | null) {
298-
const repoRoot = spawnText(["git", "rev-parse", "--show-toplevel"]).trim();
280+
const repoRoot = resolveGitRepoRoot(input);
299281
const repoName = basename(repoRoot);
300282
const args = ["git", "diff", "--no-ext-diff", "--find-renames", "--no-color"];
301283

@@ -309,7 +291,7 @@ async function loadGitChangeset(input: GitCommandInput, agentContext: AgentConte
309291

310292
appendPathspecs(args, input.pathspecs);
311293

312-
const patchText = spawnText(args);
294+
const patchText = runGitText({ input, args: args.slice(1) });
313295
const title = input.staged
314296
? `${repoName} staged changes`
315297
: input.range
@@ -321,7 +303,7 @@ async function loadGitChangeset(input: GitCommandInput, agentContext: AgentConte
321303

322304
/** Build a changeset from `git show`, suppressing commit-message chrome so only the patch feeds the UI. */
323305
async function loadShowChangeset(input: ShowCommandInput, agentContext: AgentContext | null) {
324-
const repoRoot = spawnText(["git", "rev-parse", "--show-toplevel"]).trim();
306+
const repoRoot = resolveGitRepoRoot(input);
325307
const repoName = basename(repoRoot);
326308
const args = ["git", "show", "--format=", "--no-ext-diff", "--find-renames", "--no-color"];
327309

@@ -332,7 +314,7 @@ async function loadShowChangeset(input: ShowCommandInput, agentContext: AgentCon
332314
appendPathspecs(args, input.pathspecs);
333315

334316
return normalizePatchChangeset(
335-
spawnText(args),
317+
runGitText({ input, args: args.slice(1) }),
336318
input.ref ? `${repoName} show ${input.ref}` : `${repoName} show HEAD`,
337319
repoRoot,
338320
agentContext,
@@ -344,7 +326,7 @@ async function loadStashShowChangeset(
344326
input: StashShowCommandInput,
345327
agentContext: AgentContext | null,
346328
) {
347-
const repoRoot = spawnText(["git", "rev-parse", "--show-toplevel"]).trim();
329+
const repoRoot = resolveGitRepoRoot(input);
348330
const repoName = basename(repoRoot);
349331
const args = ["git", "stash", "show", "-p", "--find-renames", "--no-color"];
350332

@@ -353,7 +335,7 @@ async function loadStashShowChangeset(
353335
}
354336

355337
return normalizePatchChangeset(
356-
spawnText(args),
338+
runGitText({ input, args: args.slice(1) }),
357339
input.ref ? `${repoName} stash ${input.ref}` : `${repoName} stash`,
358340
repoRoot,
359341
agentContext,

0 commit comments

Comments
 (0)