diff --git a/server/src/__tests__/heartbeat-managed-clone-credentials.test.ts b/server/src/__tests__/heartbeat-managed-clone-credentials.test.ts index 5279f75d39c..3d2f7e96772 100644 --- a/server/src/__tests__/heartbeat-managed-clone-credentials.test.ts +++ b/server/src/__tests__/heartbeat-managed-clone-credentials.test.ts @@ -6,7 +6,7 @@ import { promisify } from "node:util"; import { pathToFileURL } from "node:url"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { ensureManagedProjectWorkspace, prepareProjectRepositoryWorkspaces } from "../services/heartbeat.ts"; -import { materializeManagedProjectWorkspace } from "../services/managed-repository-checkout.js"; +import { materializeManagedProjectWorkspace, RepositoryRefNotFoundError } from "../services/managed-repository-checkout.js"; import { buildGitAuthInvocation, GIT_CREDENTIAL_TOKEN_ENV_KEY } from "../services/git-credentials.ts"; import { sanitizeRuntimeServiceBaseEnv } from "../services/workspace-runtime.ts"; import { resolveManagedProjectWorkspaceDir } from "../home-paths.ts"; @@ -114,6 +114,36 @@ exit 128 } }); + it.each([ + ["the ref still resolves", "exit 0"], + ["the ref probe fails for another reason", "exit 128"], + ])("keeps the original checkout failure when %s", async (_label, revParseExit) => { + const bin = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-fake-resolvable-ref-")); + const previousPath = process.env.PATH; + await fs.writeFile(path.join(bin, "git"), `#!/bin/sh +if [ "$1" = clone ]; then + for argument in "$@"; do destination="$argument"; done + /bin/mkdir -p "$destination/.git" + exit 0 +fi +if [ "$3" = rev-parse ]; then ${revParseExit}; fi +printf '%s\\n' "error: pathspec 'release' did not match any file(s) known to git" >&2 +exit 1 +`, { mode: 0o755 }); + try { + process.env.PATH = bin; + const failure = await materializeManagedProjectWorkspace(path.join(bin, "checkout"), { + repoUrl: "https://example.test/repo.git", repoRef: "release", + }).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(Error); + expect(failure).not.toBeInstanceOf(RepositoryRefNotFoundError); + expect((failure as Error).message).toContain("did not match any file(s) known to git"); + } finally { + if (previousPath === undefined) delete process.env.PATH; else process.env.PATH = previousPath; + await fs.rm(bin, { recursive: true, force: true }); + } + }); + it("materializes every repository-only project row inside the task workspace and reuses local edits", async () => { const first = await createLocalSourceRepo(); const second = await createLocalSourceRepo(); diff --git a/server/src/__tests__/task-workspace-repositories-persistence.test.ts b/server/src/__tests__/task-workspace-repositories-persistence.test.ts index 9205c692e25..0f023f500b0 100644 --- a/server/src/__tests__/task-workspace-repositories-persistence.test.ts +++ b/server/src/__tests__/task-workspace-repositories-persistence.test.ts @@ -15,7 +15,7 @@ import * as remoteWorkspaces from "../services/remote-workspaces.js"; import type { EnvironmentLease } from "@paperclipai/shared"; import { listLocalProjectRepositories } from "@paperclipai/adapter-utils/ssh"; import { readManagedWorkspaceRepositories, disposeGitWorkspaceSnapshot } from "@paperclipai/adapter-utils/git-workspace-sync"; -import { captureDirectorySnapshot, disposeDirectorySnapshot } from "@paperclipai/adapter-utils/workspace-restore-merge"; +import { captureDirectorySnapshot, disposeDirectorySnapshot, withDirectoryPublicationLock } from "@paperclipai/adapter-utils/workspace-restore-merge"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; const catalog = vi.hoisted(() => ({ available: true })); @@ -173,6 +173,103 @@ const support = externalDatabaseUrl ? { supported: true } : await getEmbeddedPos await rm(root, { recursive: true, force: true }); } }); + it("lets a missing requested ref be corrected before any checkout is published", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paperclip-missing-ref-repository-")); + const previousPath = process.env.PATH; + const exec = promisify(execFileCallback); + try { + const realGit = (await exec("which", ["git"])).stdout.trim(); + const source = path.join(root, "source"), taskRoot = path.join(root, "task"), bin = path.join(root, "bin"); + await Promise.all([mkdir(source), mkdir(taskRoot), mkdir(bin)]); + const git = async (...args: string[]) => (await exec(realGit, ["-C", source, ...args])).stdout.trim(); + // The repository's default branch is master; an agent asks for "main". + await git("init", "--initial-branch=master"); + await writeFile(path.join(source, "source.txt"), "master source\n"); + await git("add", "."); + await git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"); + const pin = await git("rev-parse", "HEAD"); + await writeFile(path.join(bin, "git"), `#!${process.execPath}\n` + + `const { spawnSync } = require('node:child_process'); const args = process.argv.slice(2);\n` + + `const remote = args.includes('clone') ? args.at(-2) : null;\n` + + `if (remote && remote !== 'https://github.com/team/source') process.exit(97);\n` + + `if (remote) args[args.length - 2] = ${JSON.stringify(source)};\n` + + `const result = spawnSync(${JSON.stringify(realGit)}, args, { stdio: 'inherit' });\n` + + `if (result.status) process.exit(result.status);\n` + + `if (remote) process.exit(spawnSync(${JSON.stringify(realGit)}, ['-C', args.at(-1), 'remote', 'set-url', 'origin', remote], { stdio: 'inherit' }).status ?? 98);\n` + + `process.exit(result.status ?? 99);\n`, { mode: 0o755 }); + process.env.PATH = `${bin}${path.delimiter}${previousPath ?? ""}`; + const service = executionWorkspaceRepositoryService(db); + const [workspace] = await db.insert(executionWorkspaces).values({ companyId, name: "Missing ref root", cwd: taskRoot, + mode: "shared_workspace", strategyType: "task_directory" }).returning(); + const [task] = await db.insert(issues).values({ companyId, title: "Missing ref", executionWorkspaceId: workspace.id }).returning(); + const repository = { kind: "catalog" as const, id: "123" }; + const intent = await service.request({ companyId, issueId: task.id, actor, request: { repository, ref: "main", requestKey: "missing-ref" } }); + const checkout = path.join(taskRoot, intent.repository.relativePath); + const admission = () => prepareForAdmission({ companyId, issueId: task.id, workspaceId: workspace.id, cwd: taskRoot, + agentId, runId: randomUUID(), responsibleUserId: "local-board" }); + const receipt = async () => (await db.select().from(executionWorkspaceRepositories) + .where(eq(executionWorkspaceRepositories.id, intent.operationId)))[0]; + + await expect(admission()).rejects.toThrow(/ref "main" does not exist/); + expect(await receipt()).toMatchObject({ state: "needs_repair", failureCode: "repository_ref_not_found", pinnedCommit: null }); + expect(await lstat(checkout).catch(() => null)).toBeNull(); + + // Retrying with the same missing ref keeps the receipt; a corrected ref replaces it. + await expect(service.request({ companyId, issueId: task.id, actor, request: { repository, ref: "main", requestKey: "missing-ref" } })) + .resolves.toMatchObject({ operationId: intent.operationId }); + // An admission that listed the receipt before the correction must use the corrected ref. + const stagingIgnore = path.join(taskRoot, ".paperclip-runtime", "repository-staging", ".gitignore"); + await rm(stagingIgnore, { force: true }); + let releaseLock!: () => void; + const lockHeld = new Promise(resolve => { releaseLock = resolve; }); + let lockAcquired!: () => void; + const acquired = new Promise(resolve => { lockAcquired = resolve; }); + const holder = withDirectoryPublicationLock(checkout, async () => { lockAcquired(); await lockHeld; }); + await acquired; + const pending = admission(); + try { + await vi.waitFor(async () => expect(await lstat(stagingIgnore).catch(() => null)).not.toBeNull()); + const corrected = await service.request({ companyId, issueId: task.id, actor, request: { repository, ref: "master", requestKey: "corrected-ref" } }); + expect(corrected.operationId).toBe(intent.operationId); + expect(await receipt()).toMatchObject({ requestedRef: "master", state: "pending", failureCode: null, pinnedCommit: null }); + expect((await receipt()).requestKeys.sort()).toEqual(["corrected-ref", "missing-ref"]); + } finally { releaseLock(); await holder; } + + await expect(pending).resolves.toEqual([expect.objectContaining({ id: intent.operationId, pinnedCommit: pin })]); + expect(await readFile(path.join(checkout, "source.txt"), "utf8")).toBe("master source\n"); + expect(await receipt()).toMatchObject({ state: "ready", requestedRef: "master", pinnedCommit: pin, failureCode: null }); + // Published files are never switched to another ref. + await expect(service.request({ companyId, issueId: task.id, actor, request: { repository, ref: "main", requestKey: "after-ready" } })) + .rejects.toThrow(/conflicts/); + } finally { + if (previousPath === undefined) delete process.env.PATH; else process.env.PATH = previousPath; + await rm(root, { recursive: true, force: true }); + } + }); + it("keeps a missing-ref receipt immutable when files exist at its checkout path", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paperclip-missing-ref-occupied-")); + try { + const service = executionWorkspaceRepositoryService(db); + const [workspace] = await db.insert(executionWorkspaces).values({ companyId, name: "Occupied checkout root", cwd: root, + mode: "shared_workspace", strategyType: "task_directory" }).returning(); + const [task] = await db.insert(issues).values({ companyId, title: "Occupied checkout", executionWorkspaceId: workspace.id }).returning(); + const repository = { kind: "catalog" as const, id: "123" }; + const intent = await service.request({ companyId, issueId: task.id, actor, request: { repository, ref: "main", requestKey: "occupied" } }); + await db.update(executionWorkspaceRepositories).set({ state: "needs_repair", failureCode: "repository_ref_not_found" }) + .where(eq(executionWorkspaceRepositories.id, intent.operationId)); + await mkdir(path.join(root, intent.repository.relativePath), { recursive: true }); + await expect(service.request({ companyId, issueId: task.id, actor, request: { repository, ref: "master", requestKey: "occupied-fix" } })) + .rejects.toThrow(/conflicts/); + // A failure that was not a missing ref also keeps the receipt. + await rm(path.join(root, intent.repository.relativePath), { recursive: true, force: true }); + await db.update(executionWorkspaceRepositories).set({ failureCode: "repository_preparation_failed" }) + .where(eq(executionWorkspaceRepositories.id, intent.operationId)); + await expect(service.request({ companyId, issueId: task.id, actor, request: { repository, ref: "master", requestKey: "generic-fix" } })) + .rejects.toThrow(/conflicts/); + expect((await db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.id, intent.operationId)))[0]) + .toMatchObject({ requestedRef: "main" }); + } finally { await rm(root, { recursive: true, force: true }); } + }); it.skipIf(process.platform === "win32").each(["parent", "ignore_file"])("rejects a symlinked runtime staging %s before cloning", async (unsafeEntry) => { const root = await mkdtemp(path.join(tmpdir(), "paperclip-unsafe-repository-staging-")); try { diff --git a/server/src/services/execution-workspace-repositories.ts b/server/src/services/execution-workspace-repositories.ts index 4c2033c2d76..6ac6150a864 100644 --- a/server/src/services/execution-workspace-repositories.ts +++ b/server/src/services/execution-workspace-repositories.ts @@ -9,7 +9,7 @@ import { promisify } from "node:util"; import fs from "node:fs/promises"; import { constants as fsConstants } from "node:fs"; import path from "node:path"; -import { and, eq, or, sql } from "drizzle-orm"; +import { and, eq, isNull, or, sql } from "drizzle-orm"; import { activityLog, agents, heartbeatRuns, projects, projectWorkspaces, executionWorkspaceRepositories, executionWorkspaces, issues, type Db } from "@paperclipai/db"; import { prepareWorkspaceRepositorySchema, type PrepareWorkspaceRepository, type ProjectRepository } from "@paperclipai/shared"; import { withDirectoryPublicationLock } from "@paperclipai/adapter-utils/workspace-restore-merge"; @@ -22,7 +22,7 @@ import { captureRunIdentity } from "./run-identity.js"; import { toolAccessService } from "./tool-access.js"; import { normalizeProjectRepositoryUrl } from "./project-repositories.js"; import { buildGitAuthInvocation, createGitRemoteAuthProvider } from "./git-credentials.js"; -import { materializeManagedProjectWorkspace, ensureManagedRepositoriesIgnored } from "./managed-repository-checkout.js"; +import { materializeManagedProjectWorkspace, ensureManagedRepositoriesIgnored, RepositoryRefNotFoundError } from "./managed-repository-checkout.js"; import { requiresAnonymousRepositoryAcquisition, taskRepositoryRunColumns } from "./task-repository-acquisition.js"; import { canonicalRepositoryRoot } from "./execution-workspace-source-roots.js"; @@ -32,6 +32,8 @@ const execFile = promisify(execFileCallback); type RepositoryRow = typeof executionWorkspaceRepositories.$inferSelect; type DbTransaction = Parameters[0]>[0]; const controllerAcquisitionMessage = "New controller repository acquisition requires a trusted task. Existing verified public checkouts remain available."; +const REPOSITORY_REF_NOT_FOUND = "repository_ref_not_found"; +const repositoryRetryConflictMessage = "Repository preparation conflicts with an existing receipt. A different ref requires a separate workspace; existing files are never switched automatically."; const lowTrustRepositoryMessage = "Low-trust repository reuse permits anonymous public URLs only; new acquisition is unavailable. Use an authorized configured source for private repositories."; function withRepositoryPublication(cwd: string, work: () => Promise): Promise { @@ -73,6 +75,21 @@ function resolveAcquisitionSource(request: PrepareWorkspaceRepository["repositor return resolveTaskRepository(request, anonymousOnly ? [] : available); } +/** + * A receipt whose requested ref was proven absent can take a corrected ref only + * while nothing was ever published for it. Published files are never switched. + */ +async function refCorrectionAllowed(row: RepositoryRow, workspace: Pick) { + if (row.state !== "needs_repair" || row.failureCode !== REPOSITORY_REF_NOT_FOUND || row.pinnedCommit) return false; + // A remote checkout cannot be inspected from the control plane; keep it immutable. + if (!workspace.cwd || readRemoteWorkspaceAuthority(workspace.metadata)) return false; + // Only proven absence allows correction; an unreadable path keeps the receipt. + return !await fs.lstat(path.join(workspace.cwd, row.relativePath)).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }); +} + function assertAcquisitionReceipt(row: Pick, anonymousOnly: boolean) { if (anonymousOnly && row.catalogRepositoryId) throw forbidden(lowTrustRepositoryMessage); } @@ -91,7 +108,7 @@ export function taskRepositoryRelativePath(identity: string) { export function assertRepositoryRetryMatches(row: Pick, identity: string, ref: string) { if (row.repositoryIdentity !== identity || row.requestedRef !== ref) { - throw conflict("Repository preparation conflicts with an existing receipt. A different ref requires a separate workspace; existing files are never switched automatically."); + throw conflict(repositoryRetryConflictMessage); } } @@ -163,10 +180,20 @@ export function executionWorkspaceRepositoryService(db: Db) { if (!workspace.cwd) throw conflict("The task workspace has no directory for repository preparation"); if (anonymousOnly) throw forbidden(controllerAcquisitionMessage); const existing = await measureRunPhase("workspace.sources.inventory", async () => tx.select().from(executionWorkspaceRepositories).where(and(eq(executionWorkspaceRepositories.companyId, input.companyId), eq(executionWorkspaceRepositories.executionWorkspaceId, workspace.id), or(eq(executionWorkspaceRepositories.repositoryIdentity, source.repositoryIdentity), sql`${executionWorkspaceRepositories.requestKeys} ? ${parsed.requestKey}`)))); - for (const row of existing) { - assertAcquisitionReceipt(row, anonymousOnly); - assertRepositoryRetryMatches(row, source.repositoryIdentity, requestedRef); + for (const row of existing) assertAcquisitionReceipt(row, anonymousOnly); + if (existing.length === 1 && existing[0].repositoryIdentity === source.repositoryIdentity && existing[0].requestedRef !== requestedRef + && await refCorrectionAllowed(existing[0], workspace)) { + const row = existing[0]; + const [corrected] = await measureRunPhase("workspace.write", () => tx.update(executionWorkspaceRepositories).set({ + requestedRef, state: "pending", failureCode: null, updatedAt: new Date(), + requestKeys: row.requestKeys.includes(parsed.requestKey) ? row.requestKeys : [...row.requestKeys, parsed.requestKey], + }).where(and(eq(executionWorkspaceRepositories.id, row.id), eq(executionWorkspaceRepositories.state, "needs_repair"), + eq(executionWorkspaceRepositories.requestedRef, row.requestedRef), isNull(executionWorkspaceRepositories.pinnedCommit))).returning()); + if (!corrected) throw conflict(repositoryRetryConflictMessage); + await measureRunPhase("workspace.write", () => tx.insert(activityLog).values({ companyId: input.companyId, actorType: input.actor.type === "agent" ? "agent" : "user", actorId: input.actor.agentId ?? input.actor.userId ?? "local-board", action: "workspace.repository.ref_corrected", entityType: "execution_workspace", entityId: workspace.id, runId: input.actor.runId ?? null, responsibleUserId: userId, details: { repositoryId: row.id, issueId: input.issueId, previousRef: row.requestedRef, requestedRef, applies: "next_normal_admission" } })); + return corrected; } + for (const row of existing) assertRepositoryRetryMatches(row, source.repositoryIdentity, requestedRef); if (existing[0]) { const row = existing[0]; if (!row.requestKeys.includes(parsed.requestKey)) { @@ -426,11 +453,18 @@ export function executionWorkspaceRepositoryService(db: Db) { return stagingParent; }); const prepared: Array<{ id: string; cwd: string; repoUrl: string; relativePath: string; pinnedCommit: string; branchName: string | null }> = []; - for (const [index, row] of rows.entries()) { + for (const [index, listedRow] of rows.entries()) { const source = sources[index]; - if (source.repositoryIdentity !== row.repositoryIdentity || row.relativePath !== taskRepositoryRelativePath(row.repositoryIdentity)) throw conflict("Repository preparation receipt does not match its source"); - const cwd = path.join(root, row.relativePath); + if (source.repositoryIdentity !== listedRow.repositoryIdentity || listedRow.relativePath !== taskRepositoryRelativePath(listedRow.repositoryIdentity)) throw conflict("Repository preparation receipt does not match its source"); + const cwd = path.join(root, listedRow.relativePath); await withRepositoryPublication(cwd, async () => { + // A ref correction can commit after the receipts were listed. Use the + // current receipt so admission never checks out a discarded ref. + const [row] = await measureRunPhase("workspace.repository.receipts", () => db.select().from(executionWorkspaceRepositories) + .where(and(eq(executionWorkspaceRepositories.id, listedRow.id), eq(executionWorkspaceRepositories.companyId, input.companyId)))); + if (!row || row.repositoryIdentity !== listedRow.repositoryIdentity || row.relativePath !== listedRow.relativePath) { + throw conflict("Repository preparation receipt changed during admission"); + } const existing = await measureRunPhase("workspace.repository.checkout_identity", async () => { const existing = await fs.lstat(cwd).catch(() => null); if (existing && (!existing.isDirectory() || existing.isSymbolicLink() || await fs.realpath(cwd) !== cwd)) throw conflict("Repository checkout path is not a contained directory"); @@ -444,7 +478,13 @@ export function executionWorkspaceRepositoryService(db: Db) { }); const anonymous = buildGitAuthInvocation({ token: "", source: "managed_connection", secretName: null }); anonymous.env = { ...anonymous.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" }; - if (!acquisitionRestricted) await measureRunPhase("workspace.repository.state_write", () => db.update(executionWorkspaceRepositories).set({ state: "preparing", catalogRepositoryId: source.catalogRepositoryId, failureCode: null, updatedAt: new Date() }).where(eq(executionWorkspaceRepositories.id, row.id))); + if (!acquisitionRestricted) { + // Claim the receipt at the ref this admission uses. Correction only + // applies to needs_repair receipts, so it cannot change a claimed ref. + const [claimed] = await measureRunPhase("workspace.repository.state_write", () => db.update(executionWorkspaceRepositories).set({ state: "preparing", catalogRepositoryId: source.catalogRepositoryId, failureCode: null, updatedAt: new Date() }) + .where(and(eq(executionWorkspaceRepositories.id, row.id), eq(executionWorkspaceRepositories.requestedRef, row.requestedRef))).returning({ id: executionWorkspaceRepositories.id })); + if (!claimed) throw conflict("Repository preparation receipt changed during admission"); + } try { const ownershipPath = path.join(cwd, ".git", "paperclip-workspace-owner.json"); const readOwnership = () => measureRunPhase("workspace.repository.ownership_read", async () => { @@ -521,7 +561,8 @@ export function executionWorkspaceRepositoryService(db: Db) { await measureRunPhase("workspace.repository.state_write", () => db.update(executionWorkspaceRepositories).set({ state: "ready", pinnedCommit, branchName, failureCode: null, updatedAt: new Date() }).where(eq(executionWorkspaceRepositories.id, row.id))); prepared.push({ id: row.id, cwd, repoUrl: row.repoUrl, relativePath: row.relativePath, pinnedCommit, branchName }); } catch (error) { - await measureRunPhase("workspace.repository.state_write", () => db.update(executionWorkspaceRepositories).set({ state: "needs_repair", failureCode: "repository_preparation_failed", updatedAt: new Date() }).where(eq(executionWorkspaceRepositories.id, row.id))); + const failureCode = error instanceof RepositoryRefNotFoundError ? REPOSITORY_REF_NOT_FOUND : "repository_preparation_failed"; + await measureRunPhase("workspace.repository.state_write", () => db.update(executionWorkspaceRepositories).set({ state: "needs_repair", failureCode, updatedAt: new Date() }).where(eq(executionWorkspaceRepositories.id, row.id))); throw error; } }); diff --git a/server/src/services/managed-repository-checkout.ts b/server/src/services/managed-repository-checkout.ts index 3d18bb49769..d6ac5bc6576 100644 --- a/server/src/services/managed-repository-checkout.ts +++ b/server/src/services/managed-repository-checkout.ts @@ -14,6 +14,30 @@ import { resolveRepositoryObjectCache, readRepositoryObjectCache, publishReposit const execFile = promisify(execFileCallback); const MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS = 10 * 60 * 1000; +/** The requested branch, tag, or commit is absent from a complete fresh clone. */ +export class RepositoryRefNotFoundError extends Error { + constructor(message: string, readonly requestedRef: string) { + super(message); + this.name = "RepositoryRefNotFoundError"; + } +} + +/** + * True only when Git reported an unknown ref and the clone positively cannot + * resolve it. `rev-parse --verify --quiet` exits 1 for an unknown name; any + * other failure (auth, corruption, timeout) is not evidence of a missing ref. + */ +async function isMissingRef(cloneCwd: string, ref: string, checkoutError: unknown): Promise { + const stderr = String((checkoutError as { stderr?: unknown } | null)?.stderr ?? ""); + if (!/did not match any file\(s\) known to git|invalid reference/.test(stderr)) return false; + for (const candidate of [`${ref}^{commit}`, `refs/remotes/origin/${ref}^{commit}`]) { + const exitCode = await execFile("git", ["-C", cloneCwd, "rev-parse", "--verify", "--quiet", "--end-of-options", candidate], { timeout: 30_000 }) + .then(() => 0, (error: { code?: unknown }) => error.code); + if (exitCode !== 1) return false; + } + return true; +} + export async function materializeManagedProjectWorkspace( cwd: string, input: { @@ -122,7 +146,21 @@ export async function materializeManagedProjectWorkspace( } await execFile("git", ["-C", cloneTmpDir, "remote", "set-url", "origin", input.repoUrl], { timeout: 10_000 }); } else if (input.repoRef) { - await measureRunPhase("workspace.checkout.git_ref", () => execFile("git", ["-C", cloneTmpDir, "checkout", input.repoRef!], { timeout: MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS })); + const repoRef = input.repoRef; + try { + await measureRunPhase("workspace.checkout.git_ref", () => execFile("git", ["-C", cloneTmpDir, "checkout", repoRef], { + // Untranslated Git messages keep missing-ref classification locale independent. + timeout: MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS, env: { ...process.env, LC_ALL: "C", LANGUAGE: "C" }, + })); + } catch (error) { + // Only a ref that a complete fresh clone cannot resolve is a missing ref; + // every other checkout failure keeps its original cause. + if (!await isMissingRef(cloneTmpDir, repoRef, error)) throw error; + throw new RepositoryRefNotFoundError( + `Requested ref "${repoRef}" does not exist in the repository. Request the repository again with an existing branch, tag, or commit.`, + repoRef, + ); + } } await input.beforePublish?.(cloneTmpDir); } catch (error) { @@ -138,6 +176,7 @@ export async function materializeManagedProjectWorkspace( `Failed to prepare managed checkout for "${input.repoUrl}" at "${cwd}": ${reason}${authNote ? ` ${authNote}` : ""}`, ); if (isWorkspaceGitScanError(error)) throw new WorkspaceGitScanError(error.code, message); + if (error instanceof RepositoryRefNotFoundError) throw new RepositoryRefNotFoundError(message, error.requestedRef); const connectionFailure = readGitConnectionFailure(error); if (connectionFailure) throw new GitConnectionFailureError(message, connectionFailure); throw new Error(message);