Commit6f1fd83unknown_key
fix(pr-detail): prevent 500 crash when git repo is missing from filesystem
fix(pr-detail): prevent 500 crash when git repo is missing from filesystem When a PR exists in the DB but the bare git repository directory hasn't been initialised on the server (e.g. a user-owned fork whose push never completed), Bun.spawn throws ENOENT synchronously. This propagated uncaught through exec() → resolveRef() → the PR detail handler and produced a blanket 500 for any PR on that repo. Three-layer fix: - exec() in repository.ts now catches Bun.spawn failures and returns exitCode 128 so all callers treat it as a failed git command (not a crash) - checkMergeability() in gate.ts uses a local spawnGit() wrapper that returns null on ENOENT and short-circuits with a skipped result - The gate-check block in the PR detail handler is wrapped in try/catch so even unexpected gate errors degrade gracefully to "no gate data" rather than a 500 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NFjwnPstUAEMzxmM4DrMiQ
3 files changed+50−326f1fd83a0d5d96f5b14f2344a3894c72498ae7e4
3 changed files+50−32
Modifiedsrc/git/repository.ts+12−6View fileUnifiedSplit
@@ -43,12 +43,18 @@ async function exec(
4343 cmd: string[],
4444 opts?: { cwd?: string; env?: Record<string, string> }
4545): Promise<{ stdout: string; stderr: string; exitCode: number }> {
46 const proc = Bun.spawn(cmd, {
47 cwd: opts?.cwd,
48 env: { ...process.env, ...opts?.env },
49 stdout: "pipe",
50 stderr: "pipe",
51 });
46 let proc: ReturnType<typeof Bun.spawn>;
47 try {
48 proc = Bun.spawn(cmd, {
49 cwd: opts?.cwd,
50 env: { ...process.env, ...opts?.env },
51 stdout: "pipe",
52 stderr: "pipe",
53 });
54 } catch {
55 // cwd doesn't exist or the binary is missing — treat as a non-zero exit
56 return { stdout: "", stderr: "", exitCode: 128 };
57 }
5258 const [stdout, stderr] = await Promise.all([
5359 new Response(proc.stdout).text(),
5460 new Response(proc.stderr).text(),
Modifiedsrc/lib/gate.ts+20−12View fileUnifiedSplit
@@ -238,19 +238,27 @@ export async function checkMergeability(
238238 const { getRepoPath } = await import("../git/repository");
239239 const repoDir = getRepoPath(owner, repo);
240240
241 const ffCheck = Bun.spawn(
242 ["git", "merge-base", "--is-ancestor", baseBranch, headBranch],
243 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
244 );
241 function spawnGit(args: string[]) {
242 try {
243 return Bun.spawn(["git", ...args], { cwd: repoDir, stdout: "pipe", stderr: "pipe" });
244 } catch {
245 return null;
246 }
247 }
248
249 const ffCheck = spawnGit(["merge-base", "--is-ancestor", baseBranch, headBranch]);
250 if (!ffCheck) {
251 return { name: "Merge check", passed: false, skipped: true, details: "Repository not accessible" };
252 }
245253 const ffExit = await ffCheck.exited;
246254 if (ffExit === 0) {
247255 return { name: "Merge check", passed: true, details: "Fast-forward merge possible" };
248256 }
249257
250 const mergeBase = Bun.spawn(
251 ["git", "merge-base", baseBranch, headBranch],
252 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
253 );
258 const mergeBase = spawnGit(["merge-base", baseBranch, headBranch]);
259 if (!mergeBase) {
260 return { name: "Merge check", passed: false, details: "Branches have no common ancestor" };
261 }
254262 const baseOut = await new Response(mergeBase.stdout).text();
255263 const baseExit = await mergeBase.exited;
256264
@@ -258,10 +266,10 @@ export async function checkMergeability(
258266 return { name: "Merge check", passed: false, details: "Branches have no common ancestor" };
259267 }
260268
261 const mergeTree = Bun.spawn(
262 ["git", "merge-tree", baseOut.trim(), baseBranch, headBranch],
263 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
264 );
269 const mergeTree = spawnGit(["merge-tree", baseOut.trim(), baseBranch, headBranch]);
270 if (!mergeTree) {
271 return { name: "Merge check", passed: false, details: "Branches have no common ancestor" };
272 }
265273 const treeOut = await new Response(mergeTree.stdout).text();
266274 await mergeTree.exited;
267275
Modifiedsrc/routes/pulls.tsx+18−14View fileUnifiedSplit
@@ -4085,20 +4085,24 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
40854085 let gateChecks: GateCheckResult[] = [];
40864086 let ciStatuses: CommitStatus[] = [];
40874087 if (pr.state === "open") {
4088 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
4089 if (headSha) {
4090 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
4091 const aiApproved = aiComments.length === 0 || aiComments.some(
4092 ({ comment }) => comment.body.includes("**Approved**")
4093 );
4094 const [gateResult, fetchedCiStatuses] = await Promise.all([
4095 runAllGateChecks(
4096 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
4097 ),
4098 listStatuses(resolved.repo.id, headSha).catch(() => [] as CommitStatus[]),
4099 ]);
4100 gateChecks = gateResult.checks;
4101 ciStatuses = fetchedCiStatuses;
4088 try {
4089 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
4090 if (headSha) {
4091 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
4092 const aiApproved = aiComments.length === 0 || aiComments.some(
4093 ({ comment }) => comment.body.includes("**Approved**")
4094 );
4095 const [gateResult, fetchedCiStatuses] = await Promise.all([
4096 runAllGateChecks(
4097 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
4098 ).catch(() => ({ allPassed: false, checks: [] as GateCheckResult[] })),
4099 listStatuses(resolved.repo.id, headSha).catch(() => [] as CommitStatus[]),
4100 ]);
4101 gateChecks = gateResult.checks;
4102 ciStatuses = fetchedCiStatuses;
4103 }
4104 } catch {
4105 // git repo missing or unreachable — show PR without gate status
41024106 }
41034107 }
41044108
41054109