import { git } from "../git"; import { contentContainedIn, landedBaseRef, type ContentContainment } from "./content-landed"; import { mergePhaseTimeoutMs, throwIfMergeAborted } from "./merge-timeouts"; async function previewGit(args: string[], cwd: string, timeoutLabel: string, signal?: AbortSignal): ReturnType { throwIfMergeAborted(signal); return git(args, cwd, { timeoutMs: mergePhaseTimeoutMs("preview"), timeoutLabel, signal, }); } function gitError(result: Awaited>, fallback: string): string { return result.stderr || result.stdout || fallback; } export function protectedTipRef(workspaceId: string | undefined): string | undefined { const safe = workspaceId?.replace(/[^A-Za-z0-9._/-]+/g, "-").replace(/^\/+|\/+$/g, "").slice(0, 180); return safe ? `refs/agent-relay/workspace-tips/${safe}` : undefined; } export async function recoverableProtectedTip(cwd: string, workspaceId: string | undefined): Promise<{ recoverableTip: string; recoverableTipRef: string; reason: string } | undefined> { const ref = protectedTipRef(workspaceId); if (!ref) return undefined; const tip = await readCommit(cwd, ref); return tip ? { recoverableTip: tip, recoverableTipRef: ref, reason: "branch ref is gone but protected workspace tip is recoverable" } : undefined; } function discardedTipRef(ref: string): string { return `${ref}-discarded-${Date.now()}`; } async function readCommit(cwd: string, ref: string, signal?: AbortSignal): Promise { const result = await previewGit(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], cwd, `workspace preview read ${ref}`, signal); return result.ok && result.stdout ? result.stdout.trim() : undefined; } async function isAncestor(cwd: string, ancestor: string, descendant: string, signal?: AbortSignal): Promise { return (await previewGit(["merge-base", "--is-ancestor", ancestor, descendant], cwd, "workspace preview ancestor check", signal)).ok; } // #1634 — every containment question this file asks routes through the one shared primitive, so // the host cannot drift from the cleanup path (or from #1458's carry verification) about what // "already landed" means. The timeout/abort envelope is the preview's, same as previewGit's. async function contained(cwd: string, tip: string, target: string, label: string, signal?: AbortSignal): Promise { throwIfMergeAborted(signal); return contentContainedIn(cwd, tip, target, { timeoutMs: mergePhaseTimeoutMs("preview"), timeoutLabel: label, signal }); } export interface ProtectedTipCheck { protectedTipRef?: string; protectedTip?: string; protectedTipReachable?: boolean; protectedTipRestored?: boolean; protectedTipDiscardedRef?: string; protectedTipDiscardedHead?: string; /** True when the pin's content was proven already on the base, so the stale SHA was retired (#1634). */ protectedTipRetired?: boolean; protectedTipError?: string; } export async function protectWorkspaceTip(input: { worktreePath: string; workspaceId?: string; branch?: string; baseRef?: string; headSha?: string; dirtyCount?: number; effectiveAhead: number; signal?: AbortSignal; }): Promise { const ref = protectedTipRef(input.workspaceId); if (!ref || !input.headSha) return {}; const existing = await readCommit(input.worktreePath, ref, input.signal); if (!existing) { if (input.effectiveAhead <= 0) return { protectedTipRef: ref }; const update = await previewGit(["update-ref", ref, input.headSha], input.worktreePath, "workspace preview protect worker tip", input.signal); return update.ok ? { protectedTipRef: ref, protectedTip: input.headSha, protectedTipReachable: true } : { protectedTipRef: ref, protectedTipError: gitError(update, "failed to protect worker branch tip") }; } if (await isAncestor(input.worktreePath, existing, input.headSha, input.signal)) { if (input.effectiveAhead > 0 && existing !== input.headSha) { const update = await previewGit(["update-ref", ref, input.headSha, existing], input.worktreePath, "workspace preview advance protected worker tip", input.signal); if (!update.ok) return { protectedTipRef: ref, protectedTip: existing, protectedTipReachable: true, protectedTipError: gitError(update, "failed to advance protected worker tip") }; return { protectedTipRef: ref, protectedTip: input.headSha, protectedTipReachable: true }; } return { protectedTipRef: ref, protectedTip: existing, protectedTipReachable: true }; } const base = input.baseRef ? await landedBaseRef(input.worktreePath, input.baseRef, { signal: input.signal }) : undefined; // RETIREMENT (#1634). The pin exists to stop committed work going missing; once that work is on // the published base it protects nothing, and a pin that can never retire is worse than no pin — // it is a stable attractor that discards every head the workspace produces from then on. The old // test was `merge-base --is-ancestor(pin, base)`, which only retires a pin that landed under its // OWN sha; a cherry-pick, a squash, or a rebase landed the same CONTENT under a different one and // pinned the workspace forever. Ask the content question instead, and act on the answer: re-pin to // the live head while it still carries work, drop the ref when it does not. if (base) { const pinLanded = await contained(input.worktreePath, existing, base, "workspace preview protected-tip landed-on-base", input.signal); if (pinLanded.verdict === "contained") { if (input.effectiveAhead > 0 && existing !== input.headSha) { const update = await previewGit(["update-ref", ref, input.headSha, existing], input.worktreePath, "workspace preview re-pin retired protected worker tip", input.signal); return update.ok ? { protectedTipRef: ref, protectedTip: input.headSha, protectedTipReachable: true, protectedTipRetired: true } : { protectedTipRef: ref, protectedTip: existing, protectedTipReachable: true, protectedTipError: gitError(update, "failed to re-pin retired protected worker tip") }; } const drop = await previewGit(["update-ref", "-d", ref, existing], input.worktreePath, "workspace preview retire landed protected worker tip", input.signal); return drop.ok ? { protectedTipRef: ref, protectedTipReachable: true, protectedTipRetired: true } : { protectedTipRef: ref, protectedTip: existing, protectedTipReachable: true, protectedTipError: gitError(drop, "failed to retire landed protected worker tip") }; } } const carriedByHead = await contained(input.worktreePath, existing, input.headSha, "workspace preview protected-tip carried-by-head", input.signal); if (carriedByHead.verdict === "contained") { const update = await previewGit(["update-ref", ref, input.headSha, existing], input.worktreePath, "workspace preview advance rebased protected worker tip", input.signal); if (!update.ok) return { protectedTipRef: ref, protectedTip: existing, protectedTipReachable: true, protectedTipError: gitError(update, "failed to advance rebased protected worker tip") }; return { protectedTipRef: ref, protectedTip: input.headSha, protectedTipReachable: true }; } if ((input.dirtyCount ?? 0) > 0) { return { protectedTipRef: ref, protectedTip: existing, protectedTipReachable: false, protectedTipError: "protected worker tip is no longer reachable, and the worktree is dirty" }; } if (!input.branch) { return { protectedTipRef: ref, protectedTip: existing, protectedTipReachable: false, protectedTipError: "protected worker tip is no longer reachable, and the current branch is unknown" }; } // NEVER REWRITE A BRANCH THAT CARRIES COMMITTED WORK OF ITS OWN (#1634). Below this line the // branch gets reset to the pin, which is a destructive act fired by a sweep — no land attempt // behind it, against a possibly-live agent. #977/#1242 introduced it for ONE situation: a branch // clobbered back to base, where the reset provably destroys nothing. Prove that situation holds // rather than assuming it. `unknown` is not proof: it leaves the head alone, keeps the pin, and // reports both heads so a steward decides — the pin ref still holds the old tip either way, so // refusing costs nothing while guessing costs the worker's commits. const headCarry = base ? await contained(input.worktreePath, input.headSha, base, "workspace preview protected-tip head-carries-work", input.signal) : { verdict: "unknown" as const, via: "unprovable" as const }; if (headCarry.verdict !== "contained") { const headOnBase = base ? await isAncestor(input.worktreePath, base, input.headSha, input.signal) : false; const headClaim = headCarry.verdict === "unknown" ? "the head could not be proven free of committed work of its own" : "the head carries committed work of its own"; return { protectedTipRef: ref, protectedTip: existing, protectedTipReachable: false, protectedTipError: `protected worker tip ${existing.slice(0, 12)} is not carried by head ${input.headSha.slice(0, 12)} (${carriedByHead.via}), and ${headClaim} (${headCarry.via})` + ` — refusing to reset ${input.branch} onto the pin (#1634).` + ` The head ${headOnBase ? "DOES" : "does NOT"} descend from base ${base ?? "(unknown)"}; the pin is preserved at ${ref}. Reconcile the two by hand.`, }; } const discardedRef = discardedTipRef(ref); const snapshot = await previewGit(["update-ref", discardedRef, input.headSha], input.worktreePath, "workspace preview snapshot discarded worker head", input.signal); if (!snapshot.ok) { return { protectedTipRef: ref, protectedTip: existing, protectedTipReachable: false, protectedTipError: gitError(snapshot, "failed to snapshot discarded worker head before protected-tip restore") }; } const restore = await previewGit(["checkout", "-B", input.branch, existing], input.worktreePath, "workspace preview restore protected worker tip", input.signal); return restore.ok ? { protectedTipRef: ref, protectedTip: existing, protectedTipReachable: true, protectedTipRestored: true, protectedTipDiscardedRef: discardedRef, protectedTipDiscardedHead: input.headSha } : { protectedTipRef: ref, protectedTip: existing, protectedTipReachable: false, protectedTipDiscardedRef: discardedRef, protectedTipDiscardedHead: input.headSha, protectedTipError: gitError(restore, "failed to restore protected worker tip") }; }