import { existsSync } from "node:fs"; import { resolve } from "node:path"; import type { WorkspaceRecoveryBranch, WorkspaceRecoveryBranchDiscardResult } from "agent-relay-sdk"; import { git } from "../git"; import { populateMergeState } from "./git-state"; import { json, resolveRequestedPath } from "./request"; interface BranchSafety { safe: boolean; reason?: string; ahead?: number; unmergedAhead?: number; landed?: boolean; baseRef?: string; } export async function listRecoveryBranches(input: { repoRoot?: string; baseRef?: string; baseSha?: string; now?: number }): Promise<{ repoRoot?: string; branches: WorkspaceRecoveryBranch[]; error?: string }> { if (!input.repoRoot) return { branches: [], error: "repoRoot required" }; const repoRoot = resolve(input.repoRoot); if (!existsSync(repoRoot)) return { repoRoot, branches: [], error: `repoRoot does not exist: ${repoRoot}` }; const refs = await git(["for-each-ref", "--format=%(refname:short)%00%(objectname)%00%(committerdate:unix)%00%(subject)", "refs/heads/agent"], repoRoot); if (!refs.ok) return { repoRoot, branches: [], error: refs.stderr || "branch listing failed" }; const now = input.now ?? Date.now(); const branches = (await Promise.all(refs.stdout.split("\n").filter(Boolean).map(async (line): Promise => { const [branch, headSha, unix, ...subjectParts] = line.split("\0"); if (!branch?.startsWith("agent/")) return null; const safety = await recoveryBranchSafety(repoRoot, branch, input.baseRef, input.baseSha); const at = Number(unix) * 1000; const lastCommit = headSha ? { sha: headSha, message: subjectParts.join("\0"), ...(Number.isFinite(at) ? { at } : {}) } : undefined; return { repoRoot, branch, headSha, ...(safety.baseRef ? { baseRef: safety.baseRef } : {}), ...(input.baseSha ? { baseSha: input.baseSha } : {}), ...(safety.ahead !== undefined ? { ahead: safety.ahead } : {}), ...(safety.unmergedAhead !== undefined ? { unmergedAhead: safety.unmergedAhead } : {}), ...(safety.landed !== undefined ? { landed: safety.landed } : {}), ...(lastCommit ? { lastCommit } : {}), ...(lastCommit?.at ? { ageMs: Math.max(0, now - lastCommit.at) } : {}), safeToDelete: safety.safe, ...(safety.reason ? { preserveReason: safety.reason } : {}), }; }))).filter((branch): branch is WorkspaceRecoveryBranch => Boolean(branch)); branches.sort((a, b) => (b.ageMs ?? 0) - (a.ageMs ?? 0) || a.branch.localeCompare(b.branch)); return { repoRoot, branches }; } export async function recoveryBranchesResponse(url: URL, baseDir: string): Promise { try { return json(await listRecoveryBranches({ repoRoot: resolveRequestedPath(url.searchParams.get("repoRoot") || undefined, baseDir), baseRef: url.searchParams.get("baseRef") || undefined, baseSha: url.searchParams.get("baseSha") || undefined, })); } catch (e) { return json({ error: (e as Error).message }, 400); } } export async function discardRecoveryBranch(input: { repoRoot?: string; branch?: string; baseRef?: string; baseSha?: string; force?: boolean }): Promise { if (!input.repoRoot) throw new Error("repoRoot required"); if (!input.branch) throw new Error("branch required"); if (!input.branch.startsWith("agent/")) throw new Error("only agent/* branches can be discarded through recovery cleanup"); const repoRoot = resolve(input.repoRoot); const safety = await recoveryBranchSafety(repoRoot, input.branch, input.baseRef, input.baseSha); if (!safety.safe && input.force !== true) { throw new Error(`branch is not safe to delete: ${safety.reason ?? "land-state unavailable"}`); } const head = await git(["rev-parse", "--verify", "--quiet", `${input.branch}^{commit}`], repoRoot); const deleted = await git(["branch", "-D", input.branch], repoRoot); if (!deleted.ok) throw new Error(deleted.stderr || "branch delete failed"); return { repoRoot, branch: input.branch, branchDeleted: true, ...(input.force === true ? { forced: true } : {}), ...(safety.reason ? { preserveReason: safety.reason } : {}), ...(head.stdout ? { headSha: head.stdout } : {}), }; } async function recoveryBranchSafety(repoRoot: string, branch: string, baseRef?: string, baseSha?: string): Promise { const branchRef = await resolveBranchRef(repoRoot, branch); if (!branchRef) return { safe: true, reason: "branch ref already gone" }; const base = await resolveRecoveryBase(repoRoot, baseRef, baseSha); if (!base) return { safe: false, reason: "base ref unavailable" }; const gitState = await populateMergeState(repoRoot, branchRef, { dirty: false, dirtyCount: 0, branch }, base, baseSha); const ahead = gitState.ahead; const unmergedAhead = gitState.unmergedAhead; if (gitState.error) return { safe: false, reason: gitState.error, baseRef: gitState.baseRef ?? base }; if (!gitState.baseRef) return { safe: false, reason: "base ref unavailable", baseRef: base }; const effectiveAhead = gitState.landed ? 0 : (unmergedAhead ?? ahead); if (effectiveAhead === undefined) return { safe: false, reason: "ahead count unavailable", baseRef: gitState.baseRef }; if (effectiveAhead === 0) return { safe: true, ahead, unmergedAhead, landed: gitState.landed, baseRef: gitState.baseRef }; return { safe: false, reason: `${effectiveAhead} unlanded commit(s)`, ahead, unmergedAhead, landed: gitState.landed, baseRef: gitState.baseRef, }; } async function resolveRecoveryBase(repoRoot: string, baseRef?: string, baseSha?: string): Promise { for (const candidate of [baseRef, baseSha, "main", "master"]) { if (candidate && (await git(["rev-parse", "--verify", "--quiet", `${candidate}^{commit}`], repoRoot)).ok) return candidate; } return undefined; } async function resolveBranchRef(repoRoot: string, branch: string): Promise { for (const candidate of [branch, `refs/heads/${branch}`]) { if ((await git(["rev-parse", "--verify", "--quiet", `${candidate}^{commit}`], repoRoot)).ok) return candidate; } return undefined; }