diff --git a/mcp/checkout.ts b/mcp/checkout.ts index 1b3eb48..5201f28 100644 --- a/mcp/checkout.ts +++ b/mcp/checkout.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { writeFileSync } from "node:fs"; +import { statSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { Octokit, RestEndpointMethodTypes } from "@octokit/rest"; import { type } from "arktype"; @@ -275,6 +275,39 @@ type CheckoutPrBranchParams = GitContext & { beforeSha?: string | undefined; }; +// stale lock files left over from a crashed/cancelled prior git process block +// every subsequent fetch with `Unable to create '': File exists`. only +// sweep locks older than this threshold so we never race a concurrent +// legitimate git op that's holding the lock. +const STALE_LOCK_AGE_MS = 30_000; + +const GIT_LOCK_PATHS = [ + ".git/shallow.lock", + ".git/index.lock", + ".git/objects/maintenance.lock", +] as const; + +function cleanupStaleGitLocks(): void { + const now = Date.now(); + for (const relPath of GIT_LOCK_PATHS) { + let mtimeMs: number; + try { + mtimeMs = statSync(relPath).mtimeMs; + } catch { + continue; + } + if (now - mtimeMs < STALE_LOCK_AGE_MS) continue; + try { + unlinkSync(relPath); + log.warning(`» removed stale ${relPath} from prior run`); + } catch (e) { + log.debug( + `» failed to remove stale ${relPath}: ${e instanceof Error ? e.message : String(e)}` + ); + } + } +} + /** * Shared helper to checkout a PR branch and configure fork remotes. * Assumes origin remote is already configured with authentication. @@ -296,6 +329,12 @@ export async function checkoutPrBranch( rejectIfLeadingDash(pr.baseRef, "PR base ref"); rejectIfLeadingDash(pr.headRef, "PR head ref"); + // self-hosted runners and cancelled jobs frequently leave stale .git/*.lock + // files behind. without this sweep, the first fetch below aborts with + // `Unable to create '.git/shallow.lock': File exists` and the agent has to + // shell out to `rm -f` (issue #564). + cleanupStaleGitLocks(); + const isFork = pr.headRepoFullName !== pr.baseRepoFullName; // always use pr-{number} as local branch name for consistency diff --git a/mcp/git.ts b/mcp/git.ts index f828747..f5f24ea 100644 --- a/mcp/git.ts +++ b/mcp/git.ts @@ -499,6 +499,21 @@ const GitFetch = type({ depth: type.number.describe("Fetch depth (for shallow clones)").optional(), }); +// when an agent-supplied depth is too shallow to reach the merge base, git +// surfaces "Could not read " and "remote did not send all necessary +// objects". detect both wordings so a single deepen retry can recover before +// the error reaches the agent (issue #564). git emits the full OID via +// oid_to_hex, so the bound is 40 (SHA-1) or 64 (SHA-256). +const SHALLOW_UNREACHABLE_PATTERNS: RegExp[] = [ + /Could not read [a-f0-9]{40,64}/, + /remote did not send all necessary objects/, +]; + +// large enough to clear the merge base on most real-world PRs without +// downloading the full history; matches the fallback used by checkoutPrBranch +// when the compare API is unavailable. +const DEEPEN_RETRY_DEPTH = 1000; + export function GitFetchTool(ctx: ToolContext) { return tool({ name: "git_fetch", @@ -510,9 +525,22 @@ export function GitFetchTool(ctx: ToolContext) { if (params.depth !== undefined) { fetchArgs.push(`--depth=${params.depth}`); } - await $git("fetch", fetchArgs, { - token: ctx.gitToken, - }); + try { + await $git("fetch", fetchArgs, { token: ctx.gitToken }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const isShallowUnreachable = SHALLOW_UNREACHABLE_PATTERNS.some((p) => p.test(msg)); + const isShallow = + isShallowUnreachable && + $("git", ["rev-parse", "--is-shallow-repository"], { log: false }).trim() === "true"; + if (!isShallow) throw err; + log.info( + `» git_fetch hit shallow-unreachable error, retrying with --deepen=${DEEPEN_RETRY_DEPTH}` + ); + await $git("fetch", [`--deepen=${DEEPEN_RETRY_DEPTH}`, "--no-tags", "origin", params.ref], { + token: ctx.gitToken, + }); + } return { success: true, ref: params.ref }; }), });