fix(git): degrade gracefully when git binary is missing

The read-only git helpers spawned `git` directly and only inspected the
result exit code. When git is absent from PATH, Bun's spawn throws ENOENT
(uv_spawn 'git') at launch, which escaped as an unhandled rejection and
crashed the process (e.g. Windows without git, relying on WSL git).

Translate a missing-binary launch failure into a non-zero GitCommandResult
in the async git() wrapper and into a null degrade in the sync reftable ref
readers, matching the existing non-zero-exit handling. Mutating/checked
commands keep surfacing the clean "git is not installed." error.

Fixes #6169
This commit is contained in:
roboomp
2026-07-21 22:07:42 +00:00
parent 89d6a8f6d1
commit d0f1cd1b77
4 changed files with 126 additions and 50 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed `omp` crashing with `[Unhandled Rejection] Error: ENOENT ... uv_spawn 'git'` when `git` is not installed/on `PATH` (e.g. Windows relying on WSL git). The read-only git helpers in `utils/git.ts` spawned `git` directly and only inspected the exit code, so `Bun.spawn`/`Bun.spawnSync` throwing `ENOENT` at launch escaped as an unhandled rejection; missing-binary launches now degrade to a non-zero result (async) or `null` (sync ref readers) instead of crashing, while mutating/checked commands keep surfacing the clean "git is not installed." error ([#6169](https://github.com/can1357/oh-my-pi/issues/6169)).
## [17.0.6] - 2026-07-20
- Fixed failed plan-mode exits leaving the session on the restored execution model while plan mode remained active and silently changing ambient `xd://` tool presentation; rollback now restores the plan model, thinking level, and exact top-level-versus-mounted tool partition so exit can be retried safely ([#6013](https://github.com/can1357/oh-my-pi/pull/6013)).
+52 -49
View File
@@ -217,6 +217,12 @@ export const GIT_NETWORK_TIMEOUT_MS = 30 * 60 * 1000;
export const GIT_COMMAND_OUTPUT_LIMIT_BYTES = 8 * 1024 * 1024;
const GIT_COMMAND_TIMEOUT_EXIT_CODE = 124;
// Exit code returned when the `git` binary cannot be launched at all (spawn
// ENOENT). Mirrors the POSIX "command not found" code so read-only callers that
// degrade on any non-zero exit treat a missing git the same as a failed
// invocation instead of letting the raw spawn ENOENT escape as an unhandled
// rejection.
const GIT_SPAWN_ENOENT_EXIT_CODE = 127;
const GIT_OUTPUT_TRUNCATED_MARKER = "\n[git subprocess output truncated after 8 MiB]\n";
const GIT_COMMAND_TERMINATE_GRACE_MS = 5_000;
@@ -388,6 +394,29 @@ function ensureAvailable(): void {
}
}
/**
* Launch a `git` plumbing command synchronously and decode stdout. Returns the
* exit code plus trimmed stdout; a missing `git` binary (spawn ENOENT) is
* reported as {@link GIT_SPAWN_ENOENT_EXIT_CODE} so sync read-only callers
* degrade to `null` instead of throwing an uncaught error during rendering.
*/
function gitSpawnSyncText(cwd: string, args: readonly string[]): { exitCode: number; stdout: string } {
const commandArgs = withShortLivedGitConfig(withNoOptionalLocks(args));
try {
const result = Bun.spawnSync(["git", ...commandArgs], {
cwd,
env: buildGitEnv(),
stdout: "pipe",
stderr: "pipe",
windowsHide: true,
});
return { exitCode: result.exitCode ?? 0, stdout: new TextDecoder().decode(result.stdout).trim() };
} catch (err) {
if (isEnoent(err)) return { exitCode: GIT_SPAWN_ENOENT_EXIT_CODE, stdout: "" };
throw err;
}
}
function formatCommandFailure(
args: readonly string[],
result: Pick<GitCommandResult, "exitCode" | "stdout" | "stderr">,
@@ -401,15 +430,21 @@ function formatCommandFailure(
async function git(cwd: string, args: readonly string[], options: CommandOptions = {}): Promise<GitCommandResult> {
const commandArgs = withShortLivedGitConfig(options.readOnly ? withNoOptionalLocks(args) : [...args]);
const child = Bun.spawn(["git", ...commandArgs], {
cwd,
env: buildGitEnv(options.env),
signal: options.signal,
stdin: normalizeStdin(options.stdin),
stdout: "pipe",
stderr: "pipe",
windowsHide: true,
});
let child: Subprocess;
try {
child = Bun.spawn(["git", ...commandArgs], {
cwd,
env: buildGitEnv(options.env),
signal: options.signal,
stdin: normalizeStdin(options.stdin),
stdout: "pipe",
stderr: "pipe",
windowsHide: true,
});
} catch (err) {
if (isEnoent(err)) return { exitCode: GIT_SPAWN_ENOENT_EXIT_CODE, stdout: "", stderr: "git is not installed." };
throw err;
}
return await collectSubprocessResult("git", commandArgs, child, options);
}
@@ -890,28 +925,12 @@ async function resolveHeadStateReftable(repository: GitRepository, signal?: Abor
}
function resolveHeadStateReftableSync(repository: GitRepository): GitHeadState | null {
ensureAvailable();
const symArgs = withShortLivedGitConfig(withNoOptionalLocks(["symbolic-ref", "HEAD"]));
const symResult = Bun.spawnSync(["git", ...symArgs], {
cwd: repository.repoRoot,
env: buildGitEnv(),
stdout: "pipe",
stderr: "pipe",
windowsHide: true,
});
const revArgs = withShortLivedGitConfig(withNoOptionalLocks(["rev-parse", "--verify", "HEAD"]));
const revResult = Bun.spawnSync(["git", ...revArgs], {
cwd: repository.repoRoot,
env: buildGitEnv(),
stdout: "pipe",
stderr: "pipe",
windowsHide: true,
});
const commit = revResult.exitCode === 0 ? new TextDecoder().decode(revResult.stdout).trim() || null : null;
const symResult = gitSpawnSyncText(repository.repoRoot, ["symbolic-ref", "HEAD"]);
const revResult = gitSpawnSyncText(repository.repoRoot, ["rev-parse", "--verify", "HEAD"]);
const commit = revResult.exitCode === 0 ? revResult.stdout || null : null;
if (symResult.exitCode === 0) {
const ref = new TextDecoder().decode(symResult.stdout).trim();
const ref = symResult.stdout;
const branchName = ref.startsWith(LOCAL_BRANCH_PREFIX) ? ref.slice(LOCAL_BRANCH_PREFIX.length) : null;
return {
...repository,
@@ -933,29 +952,13 @@ function resolveHeadStateReftableSync(repository: GitRepository): GitHeadState |
function readRefSync(repository: GitRepository, targetRef: string): string | null {
if (isReftableRepoSync(repository)) {
ensureAvailable();
const symArgs = withShortLivedGitConfig(withNoOptionalLocks(["symbolic-ref", targetRef]));
const symResult = Bun.spawnSync(["git", ...symArgs], {
cwd: repository.repoRoot,
env: buildGitEnv(),
stdout: "pipe",
stderr: "pipe",
windowsHide: true,
});
const symResult = gitSpawnSyncText(repository.repoRoot, ["symbolic-ref", targetRef]);
if (symResult.exitCode === 0) {
const stdoutText = new TextDecoder().decode(symResult.stdout).trim();
return `${HEAD_REF_PREFIX} ${stdoutText}`;
return `${HEAD_REF_PREFIX} ${symResult.stdout}`;
}
const revArgs = withShortLivedGitConfig(withNoOptionalLocks(["rev-parse", "--verify", targetRef]));
const revResult = Bun.spawnSync(["git", ...revArgs], {
cwd: repository.repoRoot,
env: buildGitEnv(),
stdout: "pipe",
stderr: "pipe",
windowsHide: true,
});
const revResult = gitSpawnSyncText(repository.repoRoot, ["rev-parse", "--verify", targetRef]);
if (revResult.exitCode === 0) {
return new TextDecoder().decode(revResult.stdout).trim() || null;
return revResult.stdout || null;
}
return null;
}
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import * as git from "@oh-my-pi/pi-coding-agent/utils/git";
// Regression coverage for #6169: when `git` is not on PATH, `Bun.spawn`/
// `Bun.spawnSync` throw synchronously with `code: "ENOENT"` (`uv_spawn 'git'`)
// rather than resolving to a non-zero exit. The read-only git helpers only
// inspect the result's exit code, so an unguarded spawn escaped as an unhandled
// rejection and crashed the process. The helpers must now degrade to their
// "no result" value instead.
function throwSpawnEnoent(): never {
const err = new Error('Executable not found in $PATH: "git"');
(err as NodeJS.ErrnoException).code = "ENOENT";
throw err;
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("git helpers with git binary absent (#6169)", () => {
it("status.summary degrades to null instead of throwing ENOENT", async () => {
vi.spyOn(Bun, "spawn").mockImplementation(throwSpawnEnoent);
// A path that is not inside a git repo forces the subprocess fallback.
expect(await git.status.summary("/")).toBeNull();
});
it("diff.has surfaces a clean GitCommandError instead of a raw ENOENT rejection", async () => {
vi.spyOn(Bun, "spawn").mockImplementation(throwSpawnEnoent);
// `has` must tell exit 0 (no diff) from exit 1 (diff), so a missing binary
// (exit 127) cannot collapse to a boolean — but it surfaces as the tidy
// "git is not installed." error rather than the raw uv_spawn ENOENT crash.
await expect(git.diff.has("/")).rejects.toThrow("git is not installed.");
});
it("repo.root degrades to null instead of throwing ENOENT", async () => {
vi.spyOn(Bun, "spawn").mockImplementation(throwSpawnEnoent);
expect(await git.repo.root("/")).toBeNull();
});
it("re-raises non-ENOENT spawn failures", async () => {
vi.spyOn(Bun, "spawn").mockImplementation(() => {
const err = new Error("EACCES: permission denied");
(err as NodeJS.ErrnoException).code = "EACCES";
throw err;
});
await expect(git.status.summary("/")).rejects.toThrow("EACCES");
});
});
@@ -1,4 +1,4 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { afterAll, beforeAll, describe, expect, test, vi } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
@@ -103,6 +103,26 @@ describe.skipIf(!supportsReftable)("git reftable support", () => {
expect(nonexistentExists).toBe(false);
});
// #6169: the sync reftable ref readers spawn `git symbolic-ref`/`rev-parse`.
// When git is absent from PATH the spawn throws ENOENT synchronously; the
// readers must degrade (HEAD resolves as detached with no commit) instead of
// letting the raw ENOENT escape during a TUI render.
test("head.resolveSync degrades when the git binary is missing", () => {
const err = new Error('Executable not found in $PATH: "git"');
(err as NodeJS.ErrnoException).code = "ENOENT";
vi.spyOn(Bun, "spawnSync").mockImplementation(() => {
throw err;
});
try {
const headStateSync = git.head.resolveSync(sharedRepoDir);
expect(headStateSync).not.toBeNull();
expect(headStateSync?.kind).toBe("detached");
expect(headStateSync?.commit).toBeNull();
} finally {
vi.restoreAllMocks();
}
});
test("handles git config trailing comments correctly", async () => {
const repository = await git.repo.resolve(configRepoDir);
expect(repository).not.toBeNull();