import { existsSync } from "node:fs"; import { resolve } from "node:path"; import type { WorkspaceDiff, WorkspaceDiffFile, WorkspaceGitState, WorkspaceStatus } from "agent-relay-sdk"; import { git } from "../git"; import { cleanupWorkspace } from "./cleanup"; import { shortBranch } from "./parse"; const MAX_DIFF_PATCH_BYTES = 200_000; /** * Read-only git interrogation of a worktree: how much work it holds (commits * ahead/behind base, dirty files, last commit). Computed on the host because * that is where git and the worktree live. Never throws — failures land in the * returned `error`/`missing` fields so callers can degrade gracefully. */ export async function workspaceGitState(input: { worktreePath?: string; baseRef?: string; baseSha?: string; requireRemoteBase?: boolean }): Promise { if (!input.worktreePath) return { error: "worktreePath required" }; const path = resolve(input.worktreePath); if (!existsSync(path)) return { missing: true }; const status = await git(["status", "--porcelain"], path); if (!status.ok) return { error: status.stderr || "git status failed" }; const dirtyCount = status.stdout ? status.stdout.split("\n").filter(Boolean).length : 0; const state: WorkspaceGitState = { dirty: dirtyCount > 0, dirtyCount }; const liveBranch = shortBranch((await git(["symbolic-ref", "--quiet", "--short", "HEAD"], path)).stdout || undefined); if (liveBranch) state.branch = liveBranch; const log = await git(["log", "-1", "--format=%H%x1f%ct%x1f%s"], path); if (log.ok && log.stdout) { const [sha, ct, ...rest] = log.stdout.split("\x1f"); if (sha) { const at = Number(ct) * 1000; state.lastCommit = { sha, message: rest.join("\x1f"), ...(Number.isFinite(at) ? { at } : {}) }; } } const merged = await populateMergeState(path, "HEAD", state, input.baseRef, input.baseSha, input.requireRemoteBase); // A live workspace that appears empty can still have unmerged work marooned on a // prior cycle ref. Only report the definitive zero-ahead case; a branch with its // own work is not ambiguous and should remain cheap to probe (#1697). if (liveBranch && (merged.unmergedAhead ?? merged.ahead ?? 0) === 0) { const stranded = await findStrandedCycleBranches(path, liveBranch, merged.baseRef ?? input.baseRef, input.baseSha, input.requireRemoteBase ?? false); if (stranded.length) { merged.strandedBranches = stranded; merged.strandedUnmergedCommits = stranded.reduce((total, item) => total + item.unmergedAhead, 0); } } return merged; } async function findStrandedCycleBranches( cwd: string, branch: string, baseRef: string | undefined, baseSha: string | undefined, requireRemoteBase: boolean, ): Promise> { const stem = branch.replace(/-\d+$/, ""); const escapedStem = stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const cycle = new RegExp(`^${escapedStem}(?:-\\d+)?$`); const refs = await git(["for-each-ref", "--format=%(refname:short)", "refs/heads"], cwd); if (!refs.ok) return []; const found: Array<{ branch: string; unmergedAhead: number; unmergedShas?: string[] }> = []; for (const sibling of refs.stdout.split("\n").filter((ref) => ref && ref !== branch && cycle.test(ref))) { // An old ref that is already contained in the current branch is history, not a // stranded delivery candidate. if ((await git(["merge-base", "--is-ancestor", sibling, "HEAD"], cwd)).ok) continue; const siblingState = await populateMergeState(cwd, sibling, {}, baseRef, baseSha, requireRemoteBase); const unmergedAhead = siblingState.unmergedAhead ?? 0; if (unmergedAhead > 0) found.push({ branch: sibling, unmergedAhead, ...(siblingState.unmergedShas ? { unmergedShas: siblingState.unmergedShas } : {}) }); } return found.slice(0, 10); } export async function populateMergeState(cwd: string, targetRef: string, state: WorkspaceGitState, baseRef?: string, baseSha?: string, requireRemoteBase = false): Promise { const configuredBase = await resolveBaseRef(cwd, baseRef, baseSha); if (!configuredBase) return state; // Destructive/reconciliation callers require a freshly-fetched remote-tracking // ref. Falling back to a stale local `main` is safe for a dashboard preview but // cannot prove that work has landed fleet-wide (#1524). const base = requireRemoteBase ? await syncBaseFromOrigin(cwd, configuredBase, undefined, { requireUpstream: true }) : configuredBase; if (!base) return { ...state, error: `remote-tracking base unavailable for ${configuredBase}` }; state.baseRef = base; const resolvedBase = await git(["rev-parse", "--verify", `${base}^{commit}`], cwd); const counts = await git(["rev-list", "--left-right", "--count", `${base}...${targetRef}`], cwd); if (counts.ok && counts.stdout) { const [behind, ahead] = counts.stdout.split(/\s+/).map((n) => Number(n)); if (Number.isFinite(behind)) state.behind = behind; if (Number.isFinite(ahead)) state.ahead = ahead; } if ((state.ahead ?? 0) > 0) { // A squash or cherry-pick merge re-creates the work as a NEW commit on // base, so the branch tip is never an ancestor and raw `ahead` stays // positive even though the content has already landed. Discount that: // compare against the base branch's upstream when it has one (squash PRs // land on the remote), treat an identical tree as fully landed (covers a // multi-commit squash), else count only commits whose patch isn't already // present in base (git cherry '+'). When the base tracks an upstream, fetch // before comparing: a stale origin/main can both hide a real squash landing // and prove a false tree match against old remote state. const cherryBase = requireRemoteBase ? base : await syncBaseFromOrigin(cwd, base) ?? base; if ((await git(["diff", "--quiet", cherryBase, targetRef], cwd)).ok) { state.unmergedAhead = 0; } else { const cherry = await git(["cherry", cherryBase, targetRef], cwd); if (cherry.ok) { const unmergedLines = cherry.stdout ? cherry.stdout.split("\n").filter((line) => line.startsWith("+")) : []; state.unmergedAhead = unmergedLines.length; if (unmergedLines.length) { state.unmergedShas = unmergedLines.slice(0, 20).map((line) => line.slice(2).trim().slice(0, 8)); } } } if (state.unmergedAhead === 0) state.landed = true; } if (state.landed && resolvedBase.ok && resolvedBase.stdout) state.landedSha = resolvedBase.stdout; return state; } /** Remote-tracking branch a local base tracks (e.g. `main` -> `origin/main`), * or undefined when base has no upstream or is a bare sha. Squash PRs land on * the remote, so the upstream is the truthful "has this merged?" reference. */ export async function upstreamRef(worktreePath: string, base: string, signal?: AbortSignal): Promise { const res = await git(["rev-parse", "--abbrev-ref", `${base}@{upstream}`], worktreePath, { signal }); return res.ok && res.stdout ? res.stdout : undefined; } /** * Best-effort fetch of `base`'s upstream. The PR-land path uses this to verify that a * remotely merged squash is actually present before returning a live owner to active. * Falls back to the local base ref when there is no upstream (no remote) or the upstream * ref can't be fetched/resolved — callers must not trust stale remote-tracking state. */ export async function syncBaseFromOrigin( worktreePath: string, base: string | undefined, signal?: AbortSignal, opts: { requireUpstream?: boolean } = {}, ): Promise { if (!base) return undefined; let upstream = await upstreamRef(worktreePath, base, signal); if (!upstream && opts.requireUpstream) { const symbolic = await git(["rev-parse", "--symbolic-full-name", base], worktreePath, { signal }); if (symbolic.ok && symbolic.stdout.startsWith("refs/remotes/")) { upstream = symbolic.stdout.slice("refs/remotes/".length); } else { const branch = base.startsWith("refs/heads/") ? base.slice("refs/heads/".length) : !base.includes("/") ? base : undefined; if (branch && (await git(["remote", "get-url", "origin"], worktreePath, { signal })).ok) upstream = `origin/${branch}`; } } if (!upstream) return opts.requireUpstream ? undefined : base; const slash = upstream.indexOf("/"); const remote = slash > 0 ? upstream.slice(0, slash) : undefined; const remoteBranch = slash > 0 ? upstream.slice(slash + 1) : base; if (remote) { const fetch = await git(["fetch", remote, `${remoteBranch}:refs/remotes/${remote}/${remoteBranch}`], worktreePath, { signal }); if (!fetch.ok) return opts.requireUpstream ? undefined : base; } return (await git(["rev-parse", "--verify", "--quiet", upstream], worktreePath, { signal })).ok ? upstream : opts.requireUpstream ? undefined : base; } /** Resolve the base tip suitable for a durable land receipt. A configured * upstream must fetch successfully; otherwise stale local state is never * promoted to authority. Local-only projects retain their actual base tip. */ export async function landedBaseSha(worktreePath: string, base: string | undefined, signal?: AbortSignal): Promise { if (!base) return undefined; const hasRemoteBase = Boolean(await upstreamRef(worktreePath, base, signal)) || (await git(["remote", "get-url", "origin"], worktreePath, { signal })).ok; const landedRef = hasRemoteBase ? await syncBaseFromOrigin(worktreePath, base, signal, { requireUpstream: true }) : base; if (!landedRef) return undefined; const resolved = await git(["rev-parse", "--verify", landedRef], worktreePath, { signal }); return resolved.ok && resolved.stdout ? resolved.stdout : undefined; } async function resolveBaseRef(worktreePath: string, baseRef?: string, baseSha?: string): Promise { for (const candidate of [baseRef, baseSha]) { if (candidate && (await git(["rev-parse", "--verify", "--quiet", `${candidate}^{commit}`], worktreePath)).ok) { return candidate; } } return undefined; } export async function resolveBranchRef(repoRoot: string, branch?: string): Promise { if (!branch) return undefined; for (const candidate of [branch, `refs/heads/${branch}`]) { if ((await git(["rev-parse", "--verify", "--quiet", `${candidate}^{commit}`], repoRoot)).ok) return candidate; } return undefined; } /** * Exit-time decision for an orphaned worktree (owner agent disappeared). Probe * the worktree and either remove it (genuinely empty — clean tree, no commits * ahead of base) or leave it intact and report a status the relay can flag for * review. Never destroys work on uncertainty: any error or unknown ahead-count * results in a flag, not a delete. */ export async function reconcileWorkspace(workspace: { id?: string; repoRoot?: string; worktreePath?: string; branch?: string; baseRef?: string; baseSha?: string }): Promise<{ workspaceId?: string; removed: boolean; status: WorkspaceStatus; gitState: WorkspaceGitState; }> { const gitState = await workspaceGitState(workspace); if (gitState.missing) { return { workspaceId: workspace.id, removed: false, status: "cleaned", gitState }; } // Empty = nothing left to preserve: clean tree and either no commits ahead or // the work already landed in base via squash/cherry-pick (`landed`). Landing // detection can only under-report, so this never deletes unmerged work. const empty = gitState.error === undefined && gitState.dirtyCount === 0 && ((gitState.ahead ?? 0) === 0 || gitState.landed === true); if (empty) { await cleanupWorkspace({ id: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, branch: workspace.branch, baseRef: workspace.baseRef, baseSha: workspace.baseSha }); return { workspaceId: workspace.id, removed: true, status: "cleaned", gitState }; } return { workspaceId: workspace.id, removed: false, status: "review_requested", gitState }; } /** * Diff a worktree's committed work against its base (base...HEAD): per-file * line counts plus a size-capped unified patch, so the dashboard can show what * an agent produced without an SSH session. Read-only; degrades into fields. */ export async function workspaceDiff(input: { worktreePath?: string; baseRef?: string; baseSha?: string; includePatch?: boolean }): Promise { if (!input.worktreePath) return { files: [], error: "worktreePath required" }; const path = resolve(input.worktreePath); if (!existsSync(path)) return { files: [], missing: true }; const base = await resolveBaseRef(path, input.baseRef, input.baseSha); const range = base ? `${base}...HEAD` : "HEAD"; const result: WorkspaceDiff = { files: [], baseRef: base }; const status = await git(["status", "--porcelain"], path); if (status.ok) result.dirtyCount = status.stdout ? status.stdout.split("\n").filter(Boolean).length : 0; if (base) { const counts = await git(["rev-list", "--count", `${base}..HEAD`], path); if (counts.ok && counts.stdout) { const ahead = Number(counts.stdout); if (Number.isFinite(ahead)) result.ahead = ahead; } } const numstat = await git(["diff", "--numstat", range], path); if (!numstat.ok) return { ...result, error: numstat.stderr || "git diff failed" }; result.files = parseNumstat(numstat.stdout); if (input.includePatch !== false) { const patch = await git(["diff", range], path); if (patch.ok && patch.stdout) { if (patch.stdout.length > MAX_DIFF_PATCH_BYTES) { result.patch = patch.stdout.slice(0, MAX_DIFF_PATCH_BYTES); result.truncated = true; } else { result.patch = patch.stdout; } } } return result; } function parseNumstat(output: string): WorkspaceDiffFile[] { if (!output) return []; return output.split("\n").filter(Boolean).map((line) => { const [add, del, ...rest] = line.split("\t"); const path = rest.join("\t"); const binary = add === "-" && del === "-"; return { path, binary, ...(binary ? {} : { additions: Number(add) || 0, deletions: Number(del) || 0 }), }; }).filter((file) => file.path); }