import { existsSync } from "node:fs"; import { join, resolve } from "node:path"; import { errMessage, type BaseWorktreeSyncResult, type WorkspaceLandGateLevel, type WorkspaceMergePreview, type WorkspaceMergeResult } from "agent-relay-sdk"; import { git, gitRaw } from "../git"; import { mergeBaseSyncResults, syncBaseWorktreeAfterRefAdvance } from "./base-sync"; import { execProcess } from "../process"; import { deleteBranchIfSafe, owningRepoRoot } from "./cleanup"; import { refreshWorkspaceDeps } from "./deps"; import { landedBaseSha, populateMergeState, resolveBranchRef, syncBaseFromOrigin, upstreamRef, workspaceGitState } from "./git-state"; import { guardRemotePrGitignoredSymlinks, guardShaRangeGitignoredSymlinks, rejectGitignoredLandedSymlinks, runLandGatesOnIntegratedTree, synthesizeNoFfMerge } from "./integrated-land-gates"; import { type MergePhase, mergePhaseTimeoutMs, throwIfMergeAborted, withMergePhaseTimeout } from "./merge-timeouts"; import { worktreePathRedirected } from "./names"; import { parseWorktrees, shortBranch } from "./parse"; import { applyPrMergeState } from "./pr-preview"; import { protectWorkspaceTip, recoverableProtectedTip } from "./protected-tip"; import { json, resolveRequestedPath } from "./request"; import type { WorkspaceMergeInput } from "./types"; import { workspacePushEnabled } from "../config"; async function mergeGit(args: string[], cwd: string, phase: MergePhase, timeoutLabel?: string, signal?: AbortSignal): ReturnType { throwIfMergeAborted(signal); return git(args, cwd, { timeoutMs: mergePhaseTimeoutMs(phase), timeoutLabel: timeoutLabel ?? `workspace merge ${phase} git ${args.join(" ")}`, signal, }); } async function mergeGitRaw(args: string[], cwd: string, phase: MergePhase, timeoutLabel?: string, signal?: AbortSignal): ReturnType { throwIfMergeAborted(signal); return gitRaw(args, cwd, { timeoutMs: mergePhaseTimeoutMs(phase), timeoutLabel: timeoutLabel ?? `workspace merge ${phase} git ${args.join(" ")}`, signal, }); } function gitError(result: Awaited>, fallback: string): string { return result.stderr || result.stdout || fallback; } /** Behind-count of HEAD relative to `base`, from inside `worktreePath`. */ async function countBehind(worktreePath: string, base: string, signal?: AbortSignal): Promise<{ behind: number } | { error: string }> { const counts = await mergeGit(["rev-list", "--left-right", "--count", `${base}...HEAD`], worktreePath, "rebase", "workspace merge count behind integration base", signal); if (!counts.ok || !counts.stdout) return { error: gitError(counts, "failed to count branch behind integration base") }; const behind = Number(counts.stdout.split(/\s+/)[0]); return Number.isFinite(behind) ? { behind } : { error: "git rev-list produced an invalid behind count" }; } async function hasOriginRemote(cwd: string): Promise { return (await git(["remote", "get-url", "origin"], cwd)).ok; } function ghAvailable(): boolean { return Boolean(Bun.which("gh")); } /** * Ground-truth merge state for `branch`, via gh. * This is the only reliable signal once a PR is squash-merged AND base has moved * on: the squash re-creates the work as one new commit (patch ids no longer * match, so `git cherry` can't see it) and the trees diverge (so tree-equality * can't either), leaving local git convinced the branch is still unmerged. * Returns undefined when there's no PR, no branch, or gh fails — * we never invent a merge. */ /** * Predict whether merging the branch's commits into base would conflict, using * git's three-way merge-tree (no working-tree changes). Exit 0 = clean, exit 1 * = conflicts. Anything else is treated as "unknown" (undefined). */ async function predictConflict(worktreePath: string, base: string): Promise { const result = await git(["merge-tree", "--write-tree", "--name-only", base, "HEAD"], worktreePath); if (result.ok) return false; // git exits 1 specifically for merge conflicts; other failures are unknown. return /CONFLICT|conflict/.test(result.stdout + result.stderr) || result.stdout.length > 0 ? true : undefined; } /** Branch name a base ref points at (only meaningful for refs/heads). */ async function baseBranchName(worktreePath: string, baseRef?: string): Promise { if (!baseRef) return undefined; const ref = baseRef.startsWith("refs/heads/") ? baseRef.slice("refs/heads/".length) : baseRef; return (await git(["show-ref", "--verify", "--quiet", `refs/heads/${ref}`], worktreePath)).ok ? ref : undefined; } /** Locate the worktree (if any) that currently has `branch` checked out. */ async function worktreeForBranch(repoRoot: string, branch: string, signal?: AbortSignal): Promise<{ path: string; dirty: boolean } | undefined> { const list = await mergeGit(["worktree", "list", "--porcelain"], repoRoot, "rebase", "workspace merge list worktrees", signal); if (!list.ok) return undefined; const match = parseWorktrees(list.stdout).find((worktree) => worktree.branch === branch); if (!match) return undefined; const status = await mergeGit(["status", "--porcelain"], match.path, "rebase", `workspace merge status ${branch} worktree`, signal); return { path: match.path, dirty: status.ok ? status.stdout.length > 0 : true }; } export async function previewBranchMerge(input: { repoRoot?: string; branch?: string; workspaceId?: string; baseRef?: string; baseSha?: string; strategy?: "pr" | "rebase-ff" | "auto"; checkPr?: boolean; requireRemoteBase?: boolean; }): Promise { if (!input.repoRoot) return { strategy: "rebase-ff", error: "repoRoot required" }; if (!input.branch) return { strategy: "rebase-ff", error: "branch required" }; const repoRoot = resolve(input.repoRoot); if (!existsSync(repoRoot)) return { strategy: "rebase-ff", error: `repoRoot does not exist: ${repoRoot}` }; const remote = await hasOriginRemote(repoRoot); const gh = ghAvailable(); const baseBranch = await baseBranchName(repoRoot, input.baseRef); const strategy: "pr" | "rebase-ff" = input.strategy === "pr" || input.strategy === "rebase-ff" ? input.strategy : remote && gh && baseBranch ? "pr" : "rebase-ff"; const branchRef = await resolveBranchRef(repoRoot, input.branch); const recoverable = branchRef ? undefined : await recoverableProtectedTip(repoRoot, input.workspaceId); if (!branchRef && !recoverable) return null; const gitState = await populateMergeState(repoRoot, branchRef ?? recoverable!.recoverableTip, { dirty: false, dirtyCount: 0, branch: input.branch }, input.baseRef, input.baseSha, input.requireRemoteBase); const base: WorkspaceMergePreview = { strategy, hasRemote: remote, ghAvailable: gh, baseRef: baseBranch ?? gitState.baseRef, ...recoverable }; if (!gitState.baseRef) return { ...base, error: "base ref unavailable" }; base.ahead = gitState.ahead; base.unmergedAhead = gitState.unmergedAhead; base.landed = gitState.landed; if (gitState.landed) base.landedSha = gitState.landedSha; base.behind = gitState.behind; base.strandedBranches = gitState.strandedBranches; base.strandedUnmergedCommits = gitState.strandedUnmergedCommits; base.dirtyCount = 0; let effectiveAhead = gitState.landed ? 0 : (gitState.unmergedAhead ?? gitState.ahead ?? 0); if (input.checkPr && strategy === "pr") { if (await applyPrMergeState(base, repoRoot, input.branch)) effectiveAhead = 0; } if (effectiveAhead === 0) { const reason = base.prMerged ? "PR merged on remote" : gitState.landed ? "already merged into base (squash/cherry-pick)" : "no commits to merge"; return { ...base, reason, noop: true }; } return base; } export async function mergePreviewResponse(url: URL, baseDir: string): Promise { try { const strategy = url.searchParams.get("strategy"); return json(await previewWorkspaceMerge({ worktreePath: resolveRequestedPath(url.searchParams.get("path") || undefined, baseDir), baseRef: url.searchParams.get("baseRef") || undefined, baseSha: url.searchParams.get("baseSha") || undefined, strategy: validPreviewStrategy(strategy), checkPr: url.searchParams.get("checkPr") === "1", requireRemoteBase: url.searchParams.get("requireRemoteBase") === "1", workspaceId: url.searchParams.get("workspaceId") || undefined, protectTip: url.searchParams.get("protectTip") === "1", })); } catch (e) { return json({ error: (e as Error).message }, 400); } } export async function branchMergePreviewResponse(url: URL, baseDir: string): Promise { try { const strategy = url.searchParams.get("strategy"); const preview = await previewBranchMerge({ repoRoot: resolveRequestedPath(url.searchParams.get("repoRoot") || undefined, baseDir), branch: url.searchParams.get("branch") || undefined, workspaceId: url.searchParams.get("workspaceId") || undefined, baseRef: url.searchParams.get("baseRef") || undefined, baseSha: url.searchParams.get("baseSha") || undefined, strategy: validPreviewStrategy(strategy), checkPr: url.searchParams.get("checkPr") === "1", requireRemoteBase: url.searchParams.get("requireRemoteBase") === "1", }); return preview === null ? json({ error: "branch not found" }, 404) : json(preview); } catch (e) { return json({ error: (e as Error).message }, 400); } } /** * Read-only pre-flight for integrating a workspace's work. Reports the strategy * `auto` would pick plus whether the merge is clean, would conflict, or is a * no-op — so the dashboard can warn before the user commits to it. */ export async function previewWorkspaceMerge(input: { worktreePath?: string; baseRef?: string; baseSha?: string; strategy?: "pr" | "rebase-ff" | "auto"; checkPr?: boolean; workspaceId?: string; protectTip?: boolean; requireRemoteBase?: boolean }): Promise { const gitState = await workspaceGitState(input); const remote = input.worktreePath ? await hasOriginRemote(resolve(input.worktreePath)) : false; const gh = ghAvailable(); const baseBranch = input.worktreePath ? await baseBranchName(resolve(input.worktreePath), input.baseRef) : undefined; // PR needs a remote, gh, and a real base branch to target; otherwise land locally. const strategy: "pr" | "rebase-ff" = input.strategy === "pr" || input.strategy === "rebase-ff" ? input.strategy : remote && gh && baseBranch ? "pr" : "rebase-ff"; const base: WorkspaceMergePreview = { strategy, hasRemote: remote, ghAvailable: gh, baseRef: baseBranch ?? gitState.baseRef }; if (gitState.missing) return { ...base, missing: true, reason: "worktree no longer exists" }; if (gitState.error) return { ...base, error: gitState.error }; base.ahead = gitState.ahead; base.unmergedAhead = gitState.unmergedAhead; base.landed = gitState.landed; if (gitState.landed) base.landedSha = gitState.landedSha; base.behind = gitState.behind; base.headSha = gitState.lastCommit?.sha; base.dirtyCount = gitState.dirtyCount; if ((gitState.dirtyCount ?? 0) > 0) return { ...base, reason: "worktree has uncommitted changes" }; let effectiveAhead = gitState.landed ? 0 : (gitState.unmergedAhead ?? gitState.ahead ?? 0); // Ask gh in BOTH cases local git can't see a PR landing: a squash-merge (looks ahead) // and a regular merge-commit (branch becomes an ancestor → ahead=0 no-op). A merged PR // means landed; its SHA lets the relay finalize a parked pr land instead of stalling (#304). if (input.checkPr && strategy === "pr" && input.worktreePath) { if (await applyPrMergeState(base, resolve(input.worktreePath), gitState.branch)) effectiveAhead = 0; } if (input.protectTip && input.worktreePath) { const protection = await protectWorkspaceTip({ worktreePath: resolve(input.worktreePath), workspaceId: input.workspaceId, branch: gitState.branch, baseRef: gitState.baseRef, headSha: gitState.lastCommit?.sha, dirtyCount: gitState.dirtyCount, effectiveAhead, }); Object.assign(base, protection); if (protection.protectedTipRestored) { return { ...await previewWorkspaceMerge({ ...input, protectTip: false }), ...protection }; } if (protection.protectedTipReachable === false) return base; } if (effectiveAhead === 0) { const reason = base.prMerged ? "PR merged on remote" : gitState.landed ? "already merged into base (squash/cherry-pick)" : "no commits to merge"; // Nothing to land and the worktree is clean — a no-op the land path resolves to // a terminal state rather than parking forever in the steward queue (#230). return { ...base, reason, noop: true }; } if (gitState.baseRef && input.worktreePath) { const conflict = await predictConflict(resolve(input.worktreePath), gitState.baseRef); base.conflict = conflict; base.cleanFastForward = conflict === false && (gitState.behind ?? 0) === 0; } return base; } function validPreviewStrategy(strategy: string | null): "pr" | "rebase-ff" | "auto" | undefined { return strategy === "pr" || strategy === "rebase-ff" || strategy === "auto" ? strategy : undefined; } /** * Integrate a workspace's work back into its base branch. Two strategies: * - rebase-ff: rebase the agent branch onto base, fast-forward base to it, * push base to origin, then (unless deleteBranch is false) remove the worktree * and delete the branch. Lands work locally and publishes it. * - pr: push the branch to origin and open a PR via gh. Leaves the worktree * and branch intact (the PR needs them). * Refuses on a dirty worktree, predicted conflicts, or nothing to merge. Never * destroys work on uncertainty. */ export async function mergeWorkspace(input: WorkspaceMergeInput): Promise { if (!input.worktreePath) return { strategy: "rebase-ff", merged: false, status: "review_requested", error: "worktreePath required", workspaceId: input.id }; const signal = input.signal; throwIfMergeAborted(signal); const worktreePath = resolve(input.worktreePath); // #1502 — refuse a worktree whose final component is a symlink redirecting git at another checkout // (see worktreePathRedirected). This is the only place the worktree filesystem lives; recovery // constrains the path structurally on the relay, the host closes the on-disk symlink here. if (worktreePathRedirected(worktreePath)) return { workspaceId: input.id, strategy: "rebase-ff", merged: false, status: "review_requested", error: "worktree path is a symlink or unresolvable; refusing to merge (#1502)" }; const repoRoot = input.repoRoot ? resolve(input.repoRoot) : worktreePath; console.error(`[orchestrator] workspace.merge prep-start workspace=${input.id ?? "(unknown)"} worktree=${worktreePath} repo=${repoRoot}`); // Probe the live HEAD branch first — it's the authoritative source. Fall back to the // DB-recorded branch only when the live probe fails (detached HEAD, missing worktree, etc.). // This fixes #232: a stale DB branch value (non-null mismatch) would pass through the // `input.branch ?? ...` guard unchanged and cause git to attempt merging a non-existent ref. const liveBranch = shortBranch((await mergeGit(["symbolic-ref", "--quiet", "--short", "HEAD"], worktreePath, "prep", "workspace merge resolve live branch", signal)).stdout || undefined); const branch = liveBranch ?? input.branch; // #1690 — branch names are mutable and may be recycled while a queued merge waits for // a host. The relay preview captured the worktree content at dispatch; require the // same tip before any preview, rebase, gate, push, or cleanup can touch this checkout. // Deliberately do NOT require the recorded branch name: #232 supports a legitimate // branch rename with the same content by resolving the live branch above. if (input.expectedHeadSha) { const liveHead = await mergeGit(["rev-parse", "HEAD"], worktreePath, "prep", "workspace merge resolve live HEAD", signal); if (!liveHead.ok || !liveHead.stdout.trim()) { return { workspaceId: input.id, strategy: "rebase-ff", merged: false, status: "review_requested", branch, error: liveHead.stderr || "failed to resolve live worktree HEAD" }; } if (liveHead.stdout.trim() !== input.expectedHeadSha) { return { workspaceId: input.id, strategy: "rebase-ff", merged: false, status: "review_requested", branch, error: "worktree tip changed since merge dispatch; refusing stale merge command" }; } } let preview: WorkspaceMergePreview; try { preview = await withMergePhaseTimeout("preview", () => previewWorkspaceMerge({ worktreePath, baseRef: input.baseRef, baseSha: input.baseSha, strategy: input.strategy }), { signal }); } catch (err) { return { workspaceId: input.id, strategy: "rebase-ff", merged: false, status: "review_requested", branch, error: errMessage(err) }; } throwIfMergeAborted(signal); const strategy = preview.strategy; const head = (field: Partial): WorkspaceMergeResult => ({ workspaceId: input.id, strategy, merged: false, status: "review_requested", branch, baseRef: preview.baseRef, ...field }); if (preview.missing) return head({ status: "cleaned", error: preview.reason }); if (preview.error) return head({ status: "review_requested", error: preview.error }); // #423 squash-recycle: the relay observed this branch's PR merged on the remote (ground // truth) and asked to keep the live owner active. For a squash onto an ADVANCED base — // the common case in a busy PR-land instance — local cherry/tree detection can't see the // landing, so `preview.noop` is false and we'd fall through to mergeRebaseFf → "origin // moved ahead" → review_requested → re-fired every ~2min (soft-loop). Trust prLanded and // resolve the owner active without a checkout (the squash landed on origin; local base was // never advanced). // // Two hard guardrails: // 1. Dirty tree → never alter it. Fall through to the // reason guard below → review_requested, retried next scan. // 2. Only continue once the fetched base actually CONTAINS the merge SHA. If the fetch // hasn't propagated the PR merge yet, fall through rather than declaring the land known. if (input.prLanded?.sha && (preview.dirtyCount ?? 0) === 0) { const startRef = await syncBaseFromOrigin(worktreePath, preview.baseRef, signal); throwIfMergeAborted(signal); if (startRef && (await mergeGit(["merge-base", "--is-ancestor", input.prLanded.sha, startRef], worktreePath, "rebase", "workspace merge verify PR landed sha on base", signal)).ok) { const landedPreview: WorkspaceMergePreview = { ...preview, noop: true, prMerged: true, prMergeSha: input.prLanded.sha, reason: "PR merged on remote" }; return await resolveNoopMerge(input, worktreePath, repoRoot, branch, landedPreview, head, signal); } } // Nothing to land (ahead=0, clean): the branch tree is already in base. Resolve it // to a terminal state so it leaves the steward queue instead of looping forever in // review_requested (#230). Reclaim the spent worktree/branch when the owner is gone. if (preview.noop) return await resolveNoopMerge(input, worktreePath, repoRoot, branch, preview, head, signal); if (preview.reason) return head({ status: "review_requested", error: preview.reason }); if (preview.conflict) return head({ conflict: true, status: "conflict", error: "merge would conflict with base" }); if (strategy === "pr") return await mergePr(input, worktreePath, branch, preview, head, signal); console.error(`[orchestrator] workspace.merge rebase-start workspace=${input.id ?? "(unknown)"} branch=${branch ?? "(unknown)"} base=${preview.baseRef ?? "(unknown)"}`); return await mergeRebaseFf(input, worktreePath, repoRoot, branch, preview, head, signal); } /** * Resolve a no-op land (#230): ahead=0 with a clean worktree, so the branch's tree is * already contained in base. Nothing to merge; no unmerged work to lose either way. * Liveness splits the outcome, mirroring the real-land path in {@link mergeRebaseFf}: * - Live owner (`deleteBranch === false`, from owner liveness #204): keep its current * branch and return to `active` (land-and-continue, #1697). A noop * land must NOT brick a still-connected session — terminal `merged` here strands the * agent with no checkout once cleanup reclaims the worktree (#327). * - Gone owner: reclaim the spent worktree/branch and go terminal `merged`. */ /** * #950 — before a no-op land goes terminal `merged` (or continues a live owner), verify the * branch's landed work is actually on the UPSTREAM, not just on local base. A lost push race * (see {@link mergeRebaseFf}) can leave a merge commit on local base that never reached origin; * preview then fires `noop` because the branch is an ancestor of local base — and the old path * blessed it as `merged` while origin never got it, diverging local base and wedging the repo. * Outcomes: * - "clean": no upstream / push disabled, or local base is already contained in origin * (nothing unpublished) — safe to finalize as-is. * - "published": local base was cleanly AHEAD of origin (carried the unpushed land) and we * fast-forward-pushed it — now safe to finalize, with pushed=true. * - "refuse": local base DIVERGED from origin (unpushed commits that can't fast-forward), * or the publish push failed — must NOT finalize unpushed work as merged. */ async function publishNoopBaseIfStranded( input: WorkspaceMergeInput, worktreePath: string, repoRoot: string, base: string | undefined, signal?: AbortSignal, ): Promise<"clean" | "published" | "refuse"> { if (!base) return "clean"; if (input.push === false || !workspacePushEnabled()) return "clean"; const upstream = await upstreamRef(worktreePath, base, signal); if (!upstream) return "clean"; const slash = upstream.indexOf("/"); const remote = slash > 0 ? upstream.slice(0, slash) : undefined; if (!remote) return "clean"; throwIfMergeAborted(signal); await mergeGit(["fetch", remote, base], worktreePath, "rebase", `workspace merge fetch ${remote}/${base} before noop finalize`, signal); const upstreamSha = (await mergeGit(["rev-parse", "--verify", upstream], worktreePath, "rebase", `workspace merge resolve ${upstream} before noop finalize`, signal)).stdout; const baseSha = (await mergeGit(["rev-parse", "--verify", base], worktreePath, "rebase", `workspace merge resolve ${base} before noop finalize`, signal)).stdout; if (!upstreamSha || !baseSha || baseSha === upstreamSha) return "clean"; // Local base fully contained in origin (behind/equal) — the land is already published. if ((await mergeGit(["merge-base", "--is-ancestor", baseSha, upstreamSha], worktreePath, "rebase", `workspace merge check ${base} contained in ${upstream}`, signal)).ok) return "clean"; // Local base strictly ahead of origin — publish the stranded land with a fast-forward push. if ((await mergeGit(["merge-base", "--is-ancestor", upstreamSha, baseSha], worktreePath, "rebase", `workspace merge check ${base} ahead of ${upstream}`, signal)).ok) { throwIfMergeAborted(signal); // #1145 — this local base history was never itself checked against the tree it's about to // push (it may predate the guard, or be local-only content this land attempt never ran a // land gate over) — guard the exact range being published, same as every other path that // advances a base ref. const guard = await guardShaRangeGitignoredSymlinks(repoRoot, upstreamSha, baseSha, signal); if (!guard.ok) { console.error("[orchestrator] workspace.merge publish-stranded gate-reject " + guard.error); return "refuse"; } const push = await mergeGit(["push", remote, `${base}:${base}`], worktreePath, "rebase", `workspace merge publish stranded ${base} to ${remote}`, signal); return push.ok ? "published" : "refuse"; } // Diverged: unpushed local commits AND origin has commits we lack. Left as-is, local base stays // diverged and EVERY later real land hits the divergence refusal forever — a repo-wide host wedge // (#950 follow-up SHOULD-FIX 3). Attempt an auto-reconcile: replay the unpushed commits onto fresh // origin in a clean base checkout and publish, converting the divergence into a clean, published // state. Content is preserved (SHAs of the reconciled commits change — a merge replays as its // first-parent delta); this only fires on an already-wedged base, so a content-faithful publish // beats a permanent refusal. If it can't be done cleanly/safely, refuse — the caller escalates to // a CLEAR conflict (steward) rather than a silent perpetual refusal. const localOnly = (await mergeGit(["rev-list", "--reverse", "--first-parent", `${upstreamSha}..${baseSha}`], worktreePath, "rebase", `workspace merge scan unpushed ${base} commits before reconcile`, signal)).stdout.split("\n").filter(Boolean); if (localOnly.length === 0) return "refuse"; const baseWorktree = await worktreeForBranch(repoRoot, base, signal); if (!baseWorktree || baseWorktree.dirty) return "refuse"; const replay = await replayCommitsOntoUpstream(baseWorktree.path, upstreamSha, localOnly, baseSha, base, signal); if (!replay.ok) return "refuse"; throwIfMergeAborted(signal); // #1145 — the replay cherry-picked commits onto upstream, producing NEW commit objects // (different SHAs) that were never themselves checked — guard the actual replayed tip, not // the pre-replay `baseSha`. // // round-8 SAME-CLASS HIGH — this used to evaluate the guard directly against // `baseWorktree.path` (the live checkout the replay ran in, reasoning "already reflects it on // disk, no throwaway materialization needed") — but a LIVE checkout is exactly what every other // guard call site had to stop using: it's real `git checkout`/cherry-pick machinery, so any // smudge filter or hook reachable from that shared worktree's `.git` can blank `.gitignore` // before check-ignore ever sees it, the same bypass class closed everywhere else. Route through // the hermetic {@link guardShaRangeGitignoredSymlinks} instead — `repoRoot` shares the same // object database as `baseWorktree.path` (same repo, different checkout), so `replayedSha`'s // objects are already there; only raw object reads are needed, no throwaway worktree. const replayedSha = (await mergeGit(["rev-parse", "HEAD"], baseWorktree.path, "rebase", `workspace merge resolve replayed ${base} tip before publish`, signal)).stdout; if (!replayedSha) return "refuse"; const guard = await guardShaRangeGitignoredSymlinks(repoRoot, upstreamSha, replayedSha, signal); if (!guard.ok) { console.error("[orchestrator] workspace.merge publish-reconciled gate-reject " + guard.error); return "refuse"; } const push = await mergeGit(["push", remote, `${base}:${base}`], baseWorktree.path, "rebase", `workspace merge publish reconciled ${base} to ${remote}`, signal); return push.ok ? "published" : "refuse"; } async function resolveNoopMerge( input: WorkspaceMergeInput, worktreePath: string, repoRoot: string, branch: string | undefined, preview: WorkspaceMergePreview, head: (field: Partial) => WorkspaceMergeResult, signal?: AbortSignal, ): Promise { throwIfMergeAborted(signal); // #950 — a `noop` preview means the branch is already an ancestor of LOCAL base, but that base // may carry a merge commit a lost push race never published. Verify it's on origin (publishing // it if we cleanly can) before going terminal — never bless unpushed work as `merged`. const publishState = await publishNoopBaseIfStranded(input, worktreePath, repoRoot, preview.baseRef, signal); if (publishState === "refuse") { // Diverged and could not auto-reconcile (SHOULD-FIX 3): escalate to a CLEAR steward-actionable // state instead of a benign review_requested that would perpetually re-refuse and wedge the repo. return head({ conflict: true, status: "conflict", error: `local ${preview.baseRef ?? "base"} carries unpushed landed work that diverged from origin and could not be auto-reconciled; a steward must reconcile and land it (#950)` }); } const receiptSha = await landedBaseSha(worktreePath, preview.baseRef, signal); const pushedStranded = publishState === "published" ? { pushed: true } : {}; const ownerRepo = branch ? await owningRepoRoot(worktreePath, repoRoot) : repoRoot; // Live owner (#327): preserve the worktree instead of bricking the session. if (input.deleteBranch === false) { // There is no transaction spanning a Git checkout and a live provider's // next commit. Never cut a successor that can miss and strand that commit. return head({ merged: false, noop: true, status: "active", worktreeRemoved: false, branchDeleted: false, ...pushedStranded, error: undefined }); } // Owner is gone — reclaim the spent worktree/branch and go terminal. if (branch) { throwIfMergeAborted(signal); const removed = await mergeGit(["worktree", "remove", "--force", worktreePath], ownerRepo, "cleanup", "workspace merge remove noop worktree", signal); const worktreeRemoved = removed.ok; const deleteResult = worktreeRemoved ? await deleteBranchIfSafe(ownerRepo, branch, preview.baseRef, undefined, signal, input.id) : { branchDeleted: false }; return head({ status: "merged", noop: true, baseSha: receiptSha, worktreeRemoved, ...deleteResult, ...pushedStranded, error: undefined }); } return head({ status: "merged", noop: true, baseSha: receiptSha, ...pushedStranded, error: undefined }); } async function mergePr( input: WorkspaceMergeInput, worktreePath: string, branch: string | undefined, preview: WorkspaceMergePreview, head: (field: Partial) => WorkspaceMergeResult, signal?: AbortSignal, ): Promise { if (!branch) return head({ status: "review_requested", error: "cannot determine branch to push" }); const base = preview.baseRef; throwIfMergeAborted(signal); // #1145 — the managed-PR path doesn't advance base itself (GitHub does, out-of-band, once the // PR merges), so it can't gate the exact landed tree the way runLandGatesOnIntegratedTree does. // But it's this tool's own last local checkpoint before a bad symlink becomes a reviewable/ // auto-mergeable PR, so refuse to even push/open the PR when the branch already carries one — // closing the tool-initiated path, same as the direct land paths. if (!base) return head({ status: "review_requested", error: "cannot determine base ref for gitignore reject-guard" }); // #1462 — resolve the branch tip to a concrete SHA and gate/push THAT exact SHA, never the // symbolic `HEAD` or the mutable branch ref. Otherwise the guard evaluates one tree while the // push publishes whatever the branch points at by push time — a concurrent advance in that // window would open a PR on ungated content. const prHeadResult = await mergeGit(["rev-parse", "HEAD"], worktreePath, "rebase", "workspace merge resolve PR head before push", signal); const prHeadSha = prHeadResult.stdout; if (!prHeadResult.ok || !prHeadSha) return head({ status: "review_requested", error: gitError(prHeadResult, "failed to resolve branch HEAD before PR push") }); const guard = await rejectGitignoredLandedSymlinks(worktreePath, base, prHeadSha, signal); if (!guard.ok) return head({ status: "review_requested", error: guard.error }); // Push the pinned SHA to the remote branch (source is a SHA, so `-u` can't set tracking and is // dropped — nothing downstream reads this feature branch's upstream; the base's own upstream is // resolved independently). The remote branch is new for a fresh PR, so no lease is needed here. const push = await mergeGit(["push", "origin", `${prHeadSha}:refs/heads/${branch}`], worktreePath, "rebase", `workspace merge push ${branch}@${prHeadSha.slice(0, 12)} to origin`, signal); if (!push.ok) return head({ status: "review_requested", error: push.stderr || "git push failed" }); const title = input.prTitle || (await mergeGit(["log", "-1", "--format=%s"], worktreePath, "rebase", "workspace merge read PR title", signal)).stdout || `Merge ${branch}`; const body = input.prBody || `Automated PR for agent workspace branch \`${branch}\`.`; const args = ["pr", "create", "--head", branch, "--title", title, "--body", body]; if (base) args.push("--base", base); // Pass process.env explicitly so runtime env mutations (e.g. test PATH injection) // are visible to the child process. Bun's default is the startup-time env snapshot. throwIfMergeAborted(signal); const proc = await execProcess(["gh", ...args], { cwd: worktreePath, env: process.env, signal }); const stdout = proc.stdout; if (!proc.ok) { return head({ status: "review_requested", error: proc.stderr || "gh pr create failed" }); } const prUrl = stdout.split("\n").map((line) => line.trim()).find((line) => /^https?:\/\//.test(line)); // Auto-merge policy (#305). Treat absent as "on-green" so new lands always terminate. const autoMerge = input.autoMerge ?? "on-green"; if (autoMerge === "on-approval") { // Reviewer pipeline arms auto-merge later — don't arm here. return head({ status: "merge_planned", prUrl, awaitingApproval: true, error: undefined }); } if (autoMerge === "manual") { // Legacy behavior: open the PR and stop — a human merges. return head({ status: "merge_planned", prUrl, error: undefined }); } // "on-green" (default): arm GitHub auto-merge. Use --merge (repo's merge style). // Never throw — if arming fails (repo has auto-merge disabled) we still return // merge_planned so the relay reconcile scan can finalize when the PR merges. const mergeTarget = prUrl ?? branch!; throwIfMergeAborted(signal); // #1145 round-5 BLOCKER-2 — the guard at line ~598 only ever checked the branch's own HEAD // (before push, before the PR even existed) against the LOCAL base ref — it never sees a // `.gitignore` rule that exists only on a diverged remote base, and it can't: GitHub merges // base+head fresh when auto-merge actually completes, not whatever this process pushed. This // is the exact same client-side arm site `armWorkspacePrAutoMerge` (workspace-pr.ts) already // guards for the reviewer/relay-owned arm path — re-resolve the just-opened PR's CURRENT // remote head/base and evaluate the synthesized merge tree immediately before arming here too, // so the tool-initiated on-green path can't sail a base-only-ignored symlink past this guard. const armGuard = await guardRemotePrGitignoredSymlinks(worktreePath, mergeTarget, signal); if (!armGuard.ok) { return head({ status: "merge_planned", prUrl, autoMergeArmed: false, error: armGuard.error }); } const armProc = await execProcess(["gh", "pr", "merge", mergeTarget, "--auto", "--merge"], { cwd: worktreePath, env: process.env, signal }); if (!armProc.ok) { // Arm failed (e.g. repo has auto-merge disabled) — return merge_planned so the // reconcile scan still finalizes when the PR is merged by a human. return head({ status: "merge_planned", prUrl, autoMergeArmed: false, error: undefined }); } return head({ status: "merge_planned", prUrl, autoMergeArmed: true, error: undefined }); } // Identity stamped on the merge commit a no-ff land records (#287). The merge is a // relay/orchestrator action, not the author's — attribute it clearly so `git log` // shows who tied the branch in, without impersonating the agent's commits (whose // original author and SHA are preserved underneath as the merge's second parent). const LAND_COMMITTER = { name: "Agent Relay", email: "agent-relay@noreply" } as const; function landMergeMessage(branch: string | undefined, subject: string | undefined): string { const name = shortBranch(branch) ?? branch ?? "branch"; return subject ? `Merge ${name}: ${subject}` : `Merge ${name}`; } // Record a no-ff merge of `branchSha` into `base` when base is NOT checked out in any // worktree (#287). We can't run a working-tree merge, so synthesize the merge commit // with plumbing: compute the merged tree, commit it with both parents (base first, so // `--first-parent` still reads as base's mainline), then advance the ref with a CAS on // the old value. Preserves the branch's commit SHAs without a working tree. async function recordNoFfMerge( repoRoot: string, base: string, baseSha: string, branchSha: string, message: string, signal?: AbortSignal, ): Promise<{ ok: true; mergeSha: string } | { ok: false; conflict?: boolean; error: string }> { const synth = await synthesizeNoFfMerge(repoRoot, baseSha, branchSha, message, mergePhaseTimeoutMs("synthesize"), signal); if (!synth.ok) return synth; const update = await mergeGit(["update-ref", `refs/heads/${base}`, synth.mergeSha, baseSha], repoRoot, "rebase", `workspace merge advance ${base} to synthesized merge`, signal); if (!update.ok) return { ok: false, error: update.stderr || "failed to advance base ref" }; return { ok: true, mergeSha: synth.mergeSha }; } /** * Fast-forward the local `base` ref to its fetched `upstream` tip (#638 concurrent-lane * recovery). The caller has verified base is a strict ancestor of upstream — a clean ff, * no divergence, nothing to lose. When base is checked out in a worktree, ff it there so * that working tree stays consistent (refuse if it's dirty — can't ff cleanly); otherwise * advance the ref directly. Returns an error string only when the sync genuinely can't be * performed, so the merge path can surface it as a no-progress failure. */ async function syncLocalBaseToUpstream( repoRoot: string, worktreePath: string, base: string, upstream: string, signal?: AbortSignal, ): Promise<{ ok: true; baseSync?: BaseWorktreeSyncResult } | { ok: false; error: string }> { const upstreamSha = (await mergeGit(["rev-parse", "--verify", upstream], worktreePath, "rebase", `workspace merge resolve ${upstream}`, signal)).stdout; if (!upstreamSha) return { ok: false, error: `cannot resolve ${upstream} to sync ${base}` }; const baseWorktree = await worktreeForBranch(repoRoot, base, signal); // Sync IN the base worktree only when it's clean — that keeps its working tree consistent // with the advanced ref (the pristine home-repo checkout). When the base worktree is dirty // (#644: a human's WIP in the shared checkout) we must NOT refuse and stall the whole repo's // lands: advance the ref directly with update-ref, then best-effort sync the checked-out // index/worktree forward for paths that are not human-modified (#681). if (baseWorktree && !baseWorktree.dirty) { const ff = await mergeGit(["merge", "--ff-only", upstream], baseWorktree.path, "rebase", `workspace merge fast-forward ${base} to ${upstream}`, signal); if (!ff.ok) return { ok: false, error: ff.stderr || `failed to fast-forward ${base} to ${upstream}` }; return { ok: true }; } const oldBaseTip = (await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve ${base} before upstream sync`, signal)).stdout; const updateArgs = oldBaseTip ? ["update-ref", `refs/heads/${base}`, upstreamSha, oldBaseTip] : ["update-ref", `refs/heads/${base}`, upstreamSha]; throwIfMergeAborted(signal); const update = await mergeGit(updateArgs, repoRoot, "rebase", `workspace merge update ${base} to ${upstream}`, signal); if (!update.ok) return { ok: false, error: update.stderr || `failed to advance ${base} to ${upstream}` }; const baseSync = oldBaseTip ? await syncBaseWorktreeAfterRefAdvance(base, baseWorktree, oldBaseTip, upstreamSha, signal) : undefined; return { ok: true, baseSync }; } /** * Restore `baseWorktreePath` to `restoreSha` — the PRE-replay snapshot that still carries the * preserved local-only commits — after an aborted/failed replay (#950). Runs its git ops WITHOUT * the merge signal on purpose: the replay reset already moved base to origin, so the preserved work * survives ONLY in `restoreSha`; if this cleanup reused the (possibly-aborted) merge signal, * `mergeGit` would rethrow immediately on the aborted signal (see its `throwIfMergeAborted` guard) * and SKIP the restoration, stranding base half-reset at origin with the work dropped — the exact * data loss a cancellation must not cause. Clears any in-progress cherry-pick, hard-resets to * `restoreSha`, then VERIFIES HEAD actually landed on it. Returns whether the restore is PROVEN, so * the caller can escalate a hard conflict when it cannot confirm the base is safe (never assume). */ async function restoreBaseWorktreeToSnapshot(baseWorktreePath: string, restoreSha: string, base: string): Promise { try { // No signal: this must run to completion even under an aborted/timed-out merge. await mergeGit(["cherry-pick", "--abort"], baseWorktreePath, "cleanup", `workspace merge abort in-progress replay of ${base}`); const reset = await mergeGit(["reset", "--hard", restoreSha], baseWorktreePath, "cleanup", `workspace merge restore ${base} to pre-replay snapshot`); if (!reset.ok) return false; const head = (await mergeGit(["rev-parse", "HEAD"], baseWorktreePath, "cleanup", `workspace merge verify ${base} restored to snapshot`)).stdout.trim(); return head !== "" && head === restoreSha.trim(); } catch { return false; } } /** * Replay `commits` (oldest-first) onto `upstreamSha` in a CLEAN base worktree via cherry-pick, so * unpushed local commits that can no longer fast-forward are PRESERVED (their content re-lands on * fresh origin) instead of being discarded by a hard reset (#950). Merge commits replay as their * first-parent delta (`-m 1`). Commits are attributed to the relay identity like the no-ff land, so * a base checkout without a configured git identity can still replay. * * ABORT-SAFE (#950 review): the reset-to-origin runs BEFORE the replay, so a merge cancellation / * total-timeout firing after it would leave base at `upstreamSha` with the preserved commits gone. * The whole replay therefore runs inside a try; on ANY failure OR abort we restore `restoreSha` with * a FRESH (non-aborted) signal and VERIFY the restore landed. If restoration cannot be PROVEN we * return an explicit `conflict` so the caller escalates to a steward rather than reporting a benign * state over a half-reset base. The caller escalates for a manual/steward reconcile either way. */ async function replayCommitsOntoUpstream( baseWorktreePath: string, upstreamSha: string, commits: string[], restoreSha: string, base: string, signal?: AbortSignal, ): Promise<{ ok: true } | { ok: false; conflict?: boolean; error: string }> { let failure: string | undefined; try { const reset = await mergeGit(["reset", "--hard", upstreamSha], baseWorktreePath, "rebase", `workspace merge rewind ${base} to origin before replay`, signal); if (!reset.ok) { failure = reset.stderr || `failed to rewind ${base} to origin before replaying unpushed commits`; } else { for (const sha of commits) { throwIfMergeAborted(signal); const parents = (await mergeGit(["rev-list", "--parents", "-n", "1", sha], baseWorktreePath, "rebase", `workspace merge inspect ${sha} parents before replay`, signal)).stdout.split(/\s+/).filter(Boolean); const pickArgs = parents.length > 2 ? ["-c", `user.name=${LAND_COMMITTER.name}`, "-c", `user.email=${LAND_COMMITTER.email}`, "cherry-pick", "-m", "1", sha] : ["-c", `user.name=${LAND_COMMITTER.name}`, "-c", `user.email=${LAND_COMMITTER.email}`, "cherry-pick", sha]; const pick = await mergeGit(pickArgs, baseWorktreePath, "rebase", `workspace merge replay unpushed ${sha} onto origin`, signal); if (!pick.ok) { failure = `cannot cleanly replay unpushed ${base} commit ${sha.slice(0, 9)} onto origin; needs manual reconcile (#950)`; break; } } } } catch (err) { // Abort / total-timeout / unexpected throw mid-replay — base may be half-reset at origin. failure = `replay of unpushed ${base} commits interrupted before completion (${errMessage(err)}); restoring ${base} (#950)`; } if (!failure) return { ok: true }; // Restore the pre-replay snapshot (still holding the preserved commits) with a fresh signal, then // PROVE it landed. If we cannot, the base may be stranded at origin with the work dropped — a hard // conflict for a steward, never a silent half-reset. const restored = await restoreBaseWorktreeToSnapshot(baseWorktreePath, restoreSha, base); if (!restored) { return { ok: false, conflict: true, error: `${failure}; and could not restore ${base} to its pre-replay snapshot ${restoreSha.slice(0, 9)} — base may be left reset to origin with unpushed work dropped; escalating to steward (#950)` }; } return { ok: false, error: failure }; } /** * Undo a base-ref advance whose publish lost a push race (#950). The merge commit is only on the * LOCAL base and origin moved past us, so it can no longer fast-forward — stranding it would * diverge local base from origin and wedge every later land (preview would see the branch as an * ancestor of local base → noop → terminal `merged` for work never published). Rewind local base * to the fetched origin tip so local == origin: no phantom merge, no divergence. * * MUST-FIX (#950 review): local base may carry PRE-EXISTING unpushed commits that are NOT the merge * we just created (e.g. work stranded by an EARLIER push race). Committed work isn't dirty, so the * clean-worktree guard doesn't catch it — a blind hard reset to origin would DISCARD it. So compute * the local-only commits on the PRE-land base tip (`preLandBaseSha`, which excludes the merge we're * intentionally dropping); if there are none, rewind to origin as before; if there are some, PRESERVE * them by replaying onto fresh origin (a clean worktree is required to do this safely — otherwise * REFUSE and surface for a steward, never reset over the unpushed work). Returns an explicit * success/failure so the caller can VERIFY the base actually healed before reporting a recoverable * state (a failed rollback must not masquerade as healed). */ async function rewindBaseAfterPushRace( repoRoot: string, base: string, advancedBaseTip: string, preLandBaseSha: string | undefined, upstreamSha: string, baseWorktree: { path: string; dirty: boolean } | undefined, signal?: AbortSignal, ): Promise<{ ok: true } | { ok: false; conflict?: boolean; error: string }> { // Commits on the PRE-land base tip that origin lacks — pre-existing unpushed work, NOT the merge // we just made (that lives only on advancedBaseTip). These must survive the rewind. // `--first-parent` walks base's MAINLINE (the sequence of lands): each merge replays once as its // first-parent delta (`-m 1`) instead of also re-applying the branch commits it already contains. const localOnly = preLandBaseSha ? (await mergeGit(["rev-list", "--reverse", "--first-parent", `${upstreamSha}..${preLandBaseSha}`], repoRoot, "rebase", `workspace merge scan unpushed ${base} commits before rewind`, signal)).stdout.split("\n").filter(Boolean) : []; if (localOnly.length === 0) { // Nothing but the phantom merge to drop — safe to rewind straight to origin. if (baseWorktree && !baseWorktree.dirty) { const reset = await mergeGit(["reset", "--hard", upstreamSha], baseWorktree.path, "rebase", `workspace merge rewind ${base} to ${upstreamSha} after push race`, signal); if (!reset.ok) return { ok: false, error: reset.stderr || `failed to rewind ${base} worktree to origin after push race` }; return { ok: true }; } const update = await mergeGit(["update-ref", `refs/heads/${base}`, upstreamSha, advancedBaseTip], repoRoot, "rebase", `workspace merge rewind ${base} ref after push race`, signal); if (!update.ok) return { ok: false, error: update.stderr || `failed to rewind ${base} ref to origin after push race` }; if (baseWorktree?.dirty) await syncBaseWorktreeAfterRefAdvance(base, baseWorktree, advancedBaseTip, upstreamSha, signal); return { ok: true }; } // Pre-existing unpushed commits present. Replaying requires a CLEAN base checkout; without one we // refuse rather than risk discarding committed work — the branch and the unpushed commits both // stay put and a steward reconciles. if (!baseWorktree || baseWorktree.dirty) { return { ok: false, error: `local ${base} carries ${localOnly.length} unpushed commit(s) not on origin and its checkout is ${baseWorktree ? "dirty" : "absent"}; refusing to rewind (would discard committed work) — needs manual/steward reconcile (#950)` }; } return await replayCommitsOntoUpstream(baseWorktree.path, upstreamSha, localOnly, advancedBaseTip, base, signal); } async function mergeRebaseFf( input: WorkspaceMergeInput, worktreePath: string, repoRoot: string, branch: string | undefined, preview: WorkspaceMergePreview, head: (field: Partial) => WorkspaceMergeResult, signal?: AbortSignal, ): Promise { throwIfMergeAborted(signal); const gateLevel: WorkspaceLandGateLevel = input.gateLevel === "subset" || input.gateLevel === "none" ? input.gateLevel : "full"; const base = preview.baseRef; if (!base) return head({ status: "review_requested", error: "no base branch to merge into" }); if (!branch) return head({ status: "review_requested", error: "cannot determine agent branch" }); // Reconcile with origin before landing (#190/#203/#638). When base tracks an // upstream (e.g. main -> origin/main) and we'll push, fetch it and check whether // origin has moved ahead of local base. // // Origin-ahead is common under concurrency: a sibling lane lands and advances // origin/ while this lane still sits on a stale local base. Direct land // must not silently synthesize a no-ff merge or PR fallback in that upstream-stale // state (#1184). Local-only base advancement still uses the existing no-ff path: // land gates, timeout cancellation, and abort recovery all exercise that machinery. // Tracks whether the DIRTY base checkout caught up to the advanced HEAD across BOTH heal // points (the upstream sync below and the final land). A mixed state from either is surfaced // loudly rather than swallowed as a log warning (#824). let baseSync: BaseWorktreeSyncResult | undefined; const upstreamResult = await mergeGit(["rev-parse", "--abbrev-ref", `${base}@{upstream}`], worktreePath, "rebase", `workspace merge resolve upstream for ${base}`, signal); const upstream = upstreamResult.ok && upstreamResult.stdout ? upstreamResult.stdout : undefined; const slash = upstream ? upstream.indexOf("/") : -1; const remote = slash > 0 ? upstream!.slice(0, slash) : undefined; // remote of a `remote/branch` upstream const pushEnabled = input.push !== false && workspacePushEnabled() && Boolean(remote); // SHA preservation (#287): never rebase the agent branch — rewriting its commits // gives them new SHAs and breaks traceability (the branch.landed SHA must exist on // base verbatim). headSha is the preserved landed commit; local no-ff lands tie the // branch in with a merge commit so the agent's commits keep their identity. const headResult = await mergeGit(["rev-parse", "HEAD"], worktreePath, "rebase", "workspace merge resolve workspace HEAD before gates", signal); const headSha = headResult.stdout; if (!headResult.ok || !headSha) return head({ status: "review_requested", error: gitError(headResult, "failed to resolve workspace HEAD before gates") }); // Subject of the landed commit for the relay's branch.landed notice (#239). Best-effort: // an empty/failed read just omits it from the message body. const landedSubject = (await mergeGit(["log", "-1", "--format=%s", headSha], worktreePath, "rebase", "workspace merge read landed commit subject", signal)).stdout || undefined; // Resolve the SHA the work will integrate onto WITHOUT moving the ref. Origin-ahead is // the #1184 fail-fast case; local-base no-ff is left to the existing gate/ref machinery. const integrationBaseResult = await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve integration base ${base}`, signal); let integrationBaseSha = integrationBaseResult.stdout; if (!integrationBaseResult.ok || !integrationBaseSha) return head({ status: "review_requested", error: gitError(integrationBaseResult, `failed to resolve integration base ${base}`) }); let needSync = false; if (upstream && remote && pushEnabled) { try { const fetch = await withMergePhaseTimeout("fetch", (fetchSignal) => git( ["fetch", remote, base], worktreePath, { timeoutMs: mergePhaseTimeoutMs("fetch"), timeoutLabel: `workspace merge fetch ${remote}/${base}`, signal: fetchSignal }, ), { signal }); if (!fetch.ok && fetch.timedOut) return head({ status: "review_requested", error: fetch.stderr || `fetch ${remote}/${base} timed out` }); } catch (err) { return head({ status: "review_requested", error: errMessage(err) }); } if (!(await mergeGit(["merge-base", "--is-ancestor", upstream, base], worktreePath, "rebase", `workspace merge compare ${upstream} to ${base}`, signal)).ok) { // Origin moved ahead. If local base is not cleanly behind, it's genuine divergence // and we refuse without mutating. If it is cleanly behind, compute against upstream; // the behind check below decides whether direct land can still fast-forward. if (!(await mergeGit(["merge-base", "--is-ancestor", base, upstream], worktreePath, "rebase", `workspace merge compare ${base} to ${upstream}`, signal)).ok) { return head({ status: "review_requested", error: `local ${base} has diverged from ${upstream} (commits not on origin); sync before landing` }); } const upstreamSha = (await mergeGit(["rev-parse", "--verify", upstream], worktreePath, "rebase", `workspace merge resolve ${upstream} for integration`, signal)).stdout; if (!upstreamSha) return head({ status: "review_requested", error: `cannot resolve ${upstream} to sync ${base}` }); // Consider fresh origin the integration base, but DON'T advance local base yet. integrationBaseSha = upstreamSha; needSync = true; } } // Behind relative to fresh upstream means direct land is not a fast-forward of the // instance's true integration base. Fail before gates/ref mutation so callers see // the reason and can rebase or choose PR explicitly. Local-only behind remains the // pre-existing no-ff path to preserve gate, timeout, and abort-recovery behavior. const behindResult = await countBehind(worktreePath, integrationBaseSha, signal); if ("error" in behindResult) return head({ status: "review_requested", error: behindResult.error }); const behind = behindResult.behind; if (behind > 0 && needSync) { const branchName = branch ?? "workspace branch"; const baseName = base ?? "base"; const advanced = integrationBaseSha ? ` (base advanced to ${integrationBaseSha.slice(0, 12)})` : ""; return head({ status: "review_requested", error: `cannot direct-land: ${branchName} is not a fast-forward of ${baseName}${advanced}; rebase and retry, or land with --strategy pr`, }); } const landedCommitScan = await mergeGit(["rev-list", "--reverse", `${integrationBaseSha}..${headSha}`], worktreePath, "rebase", "workspace merge list landed commits", signal); const landedCommitShas = landedCommitScan.ok ? landedCommitScan.stdout.split("\n").filter(Boolean) : [headSha]; const gateSkippedField = gateLevel !== "full" ? { gateSkipped: { level: gateLevel, reason: input.gateReason ?? "", by: input.gateRequestedBy ?? "unknown", commit: headSha, }, } : {}; const gateRun = await runLandGatesOnIntegratedTree(repoRoot, worktreePath, behind, integrationBaseSha, headSha, landMergeMessage(branch, landedSubject), gateLevel, signal); throwIfMergeAborted(signal); if ("abort" in gateRun) { return gateRun.abort.conflict ? head({ conflict: true, status: "conflict", error: gateRun.abort.error }) : head({ status: "review_requested", error: gateRun.abort.error }); } const gates = gateRun.gates; if (gates.failure) { return head({ status: "review_requested", gateFailure: gates.failure, error: `land gate failed: ${gates.failure.name}` }); } // #1636 finding 2 — `gatesRan` rides along on every landed result (0 included): see // WorkspaceMergeResult.gatesRan for why a land where nothing ran must not be indistinguishable // from one where the full suite passed. const gateResultFields = { ...(gates.warnings.length ? { gateWarnings: gates.warnings } : {}), gatesRan: gates.ran }; // Only NOW touch `refs/heads/`. If origin moved ahead, sync local base up to the fetched // upstream first (#638); this preserves the pre-existing land ordering while gate execution // remains permanently disabled. if (needSync) { throwIfMergeAborted(signal); const synced = await syncLocalBaseToUpstream(repoRoot, worktreePath, base, upstream!, signal); throwIfMergeAborted(signal); if (!synced.ok) return head({ status: "review_requested", error: synced.error }); baseSync = mergeBaseSyncResults(baseSync, synced.baseSync); } // Advance base. `baseTip` is base's new tip after the land: it equals headSha on a // clean fast-forward, or the merge commit on a no-ff merge. Operate IN the base worktree // only when it exists AND is clean — that keeps its working tree consistent with the // advanced ref (the pristine home-repo checkout). When the base worktree is dirty // (#644: a human's WIP in the shared checkout) we must NOT refuse and stall every lane's // land: fall through to ref-plumbing (update-ref / synthesized no-ff merge) below, then // best-effort sync clean landed paths in the dirty checkout while preserving WIP (#681). let baseTip = headSha; // Snapshot base's PRE-advance tip so a lost-push-race rewind can tell the merge we're about to // create (which it should drop) apart from any PRE-EXISTING unpushed commits on base (which it // must PRESERVE, not hard-reset over) — #950 data-loss guard. const preLandBaseSha = (await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge snapshot ${base} tip before advance`, signal)).stdout || undefined; // #1462 round-2 (sol) — base-movement pre-check: a cheap fast-fail when base has OBVIOUSLY moved // off the gated `integrationBaseSha` before we even start advancing (a concurrent local commit to // the shared base checkout, or any out-of-band advance). It is NOT the land-authoritative guard — // it is check-then-use and base can still move in the window between here and the advance. The // atomic guarantee comes from the ref-plumbing CAS below, which pins `integrationBaseSha` as the // update-ref expected-old value on EVERY path. Bounce to review_requested so the next scan // RE-RUNS the gates against the fresh base — same discipline as the origin-moved abort above. if (preLandBaseSha !== integrationBaseSha) { return head({ status: "review_requested", error: `base ${base} advanced from the gated ${integrationBaseSha.slice(0, 12)} to ${preLandBaseSha?.slice(0, 12) ?? "(unresolved)"} between gate and land; re-gating on the next scan (#1462)` }); } throwIfMergeAborted(signal); const baseWorktree = await worktreeForBranch(repoRoot, base, signal); // #1462 round-2 (sol) — the land-authoritative base advance is ALWAYS the ref-plumbing atomic CAS // against the GATED `integrationBaseSha`, for EVERY worktree state (clean checkout, dirty checkout, // or no checkout). We deliberately do NOT `git merge` into the live checked-out base: `git merge` // has no expected-old CAS, so even with the pre-check above, base can advance in the check→merge // window (a concurrent commit lands during the async `worktreeForBranch`/status inspection) and // the merge would silently build on the moved, ungated base. `update-ref`/`recordNoFfMerge` pin // `integrationBaseSha` as expected-old, so any concurrent advance makes the CAS FAIL atomically // (→ review_requested, re-gate next scan) instead of landing ungated content. The checkout is then // synced forward to the advanced ref (clean = fast-forward, dirty = preserve WIP) by the sync helper. // // #1462 round-3 (sol) — the CAS is the atomic point of no return, but the checkout sync AFTER it is // NOT optional and NOT interruptible: once the ref advanced, the checked-out base MUST be made // coherent with it (else the shared checkout is stranded showing e.g. a staged reverse `D` for the // landed file — no MERGE_HEAD, but incoherent, and the next scan reads it as noop/active and never // heals it). So the sync runs WITHOUT the merge `signal` — like the old #1020 abort cleanup — so an // already-aborted/timed-out total signal cannot skip it and leave the checkout half-advanced. if (behind === 0) { throwIfMergeAborted(signal); const update = await mergeGit(["update-ref", `refs/heads/${base}`, headSha, integrationBaseSha], repoRoot, "rebase", `workspace merge update ${base} fast-forward (CAS on gated base)`, signal); if (!update.ok) return head({ status: "review_requested", error: update.stderr || `failed to advance ${base}: gated base ${integrationBaseSha.slice(0, 12)} moved before the CAS (#1462)` }); baseSync = mergeBaseSyncResults(baseSync, await syncBaseWorktreeAfterRefAdvance(base, baseWorktree, integrationBaseSha, headSha)); } else { // Synthesize the no-ff merge FROM the gated `integrationBaseSha` (the exact tree the gate // validated) and CAS the ref against it — atomic, and never built on a post-gate base a // concurrent advance could have moved to. A merge conflict surfaces as `conflict` just as the // old working-tree `git merge --no-ff` did. throwIfMergeAborted(signal); const merged = await recordNoFfMerge(repoRoot, base, integrationBaseSha, headSha, landMergeMessage(branch, landedSubject), signal); if (!merged.ok) return head(merged.conflict ? { conflict: true, status: "conflict", error: merged.error } : { status: "review_requested", error: merged.error }); baseTip = merged.mergeSha; // Mandatory post-CAS sync — runs to completion even under an aborted signal (see note above). baseSync = mergeBaseSyncResults(baseSync, await syncBaseWorktreeAfterRefAdvance(base, baseWorktree, integrationBaseSha, baseTip)); } // #824 — a dirty base checkout left out of sync with the advanced HEAD is a MIXED state: the land // succeeded but the shared/primary checkout now serves STALE files for the landed paths. It is // surfaced on TWO independent channels — the host log (#1462 ADJ-2: emitted INSIDE the mandatory // sync helper, so it fires on every strand regardless of caller and can't be skipped by a post-CAS // throw) and the `baseWorktreeSync` field the relay raises as an operator alert. We never // blind-clobber human content to hide it — the report names which paths still need a manual reconcile. const baseWorktreeSyncField = baseSync && !baseSync.reconciled ? { baseWorktreeSync: baseSync } : {}; // Publish the advanced base so local and origin converge (#190). We verified origin was an // ancestor of base above, so this is a fast-forward — but that check ran BEFORE the base // advance, and origin can move in the fetch→push window (#950: a sibling host landing the same // origin, CI, a human push). If it did, this push is a non-ff and is rejected AFTER local base // already carries the merge commit. Leaving the merge stranded on local base is the historical // multi-host land wedge: local base diverges from origin, later previews see the branch as an // ancestor of local base → noop → terminal `merged` for work never published, and every land // after refuses on divergence. So on a push failure, re-fetch and try to publish in the SAME // execution; if origin genuinely moved past us, rewind local base to the fresh origin tip // (local == origin, no phantom, no divergence) and bounce to review_requested for the next scan. let pushed = false; if (upstream && remote && pushEnabled) { throwIfMergeAborted(signal); // #1462 — publish the EXACT pinned baseTip we just advanced to, not the symbolic `` ref: // a local base advance between the ref update above and this push would otherwise publish an // unintended tip. The existing push-race handling (re-fetch / re-push / rewind) still covers // origin moving under us. let push = await mergeGit(["push", remote, `${baseTip}:refs/heads/${base}`], worktreePath, "rebase", `workspace merge push ${base}@${baseTip.slice(0, 12)} to ${remote}`, signal); if (!push.ok) { throwIfMergeAborted(signal); const refetch = await mergeGit(["fetch", remote, base], worktreePath, "rebase", `workspace merge re-fetch ${remote}/${base} after push race`, signal); const upstreamSha = refetch.ok ? (await mergeGit(["rev-parse", "--verify", upstream], worktreePath, "rebase", `workspace merge resolve ${upstream} after push race`, signal)).stdout : ""; // Transient loss (a ref lock, or a compatible parallel push): origin is still an ancestor // of our advanced base, so the merge is a clean fast-forward — just re-push it. if (upstreamSha && (await mergeGit(["merge-base", "--is-ancestor", upstreamSha, baseTip], worktreePath, "rebase", "workspace merge check base still ff after push race", signal)).ok) { throwIfMergeAborted(signal); push = await mergeGit(["push", remote, `${baseTip}:refs/heads/${base}`], worktreePath, "rebase", `workspace merge re-push ${base}@${baseTip.slice(0, 12)} to ${remote} after race`, signal); } if (!push.ok) { // Origin moved past us — the advanced merge can't fast-forward. Rewind local base to the // fresh origin tip so nothing is stranded (no mergedSha: nothing landed), PRESERVING any // pre-existing unpushed commits (#950 MUST-FIX 1). Then VERIFY the base actually healed // before reporting the retryable review_requested — a failed rollback must surface a HARD // state (conflict → steward), never masquerade as healed (#950 MUST-FIX 2). if (!upstreamSha) { return head({ conflict: true, status: "conflict", error: `push to ${remote}/${base} failed and the fresh origin tip is unresolvable; cannot safely rewind ${base} — escalating (#950)` }); } const rewind = await rewindBaseAfterPushRace(repoRoot, base, baseTip, preLandBaseSha, upstreamSha, baseWorktree, signal); if (!rewind.ok) { return head({ conflict: true, status: "conflict", error: rewind.error }); } // Verify, don't assume: local base must now contain the fresh origin tip AND must NOT still // carry the phantom merge we failed to publish. Anything else means the base is still wedged. const healedBaseSha = (await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge verify ${base} healed after push race`, signal)).stdout; const originContained = Boolean(healedBaseSha) && (await mergeGit(["merge-base", "--is-ancestor", upstreamSha, healedBaseSha], repoRoot, "rebase", `workspace merge verify origin contained in ${base} after rewind`, signal)).ok; const phantomDropped = Boolean(healedBaseSha) && !(await mergeGit(["merge-base", "--is-ancestor", baseTip, healedBaseSha], repoRoot, "rebase", `workspace merge verify phantom dropped from ${base} after rewind`, signal)).ok; if (!originContained || !phantomDropped) { return head({ conflict: true, status: "conflict", error: `rewind of ${base} after push race did not heal (origin ${originContained ? "contained" : "MISSING"}, phantom ${phantomDropped ? "dropped" : "STILL PRESENT"}); escalating rather than reporting healed (#950)` }); } return head({ status: "review_requested", error: push.stderr || `git push to ${remote}/${base} failed` }); } } pushed = true; } // Work is landed (and published). Tear down only when the owner is gone. A live // owner's rebase has already left the current branch at the landed HEAD. There // is no transaction spanning a Git checkout and a live provider's next commit, // so do not create a successor that can strand that work (#951/#1697). const deleteBranch = input.deleteBranch !== false; const ownerRepo = await owningRepoRoot(worktreePath, repoRoot); if (!deleteBranch) { return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, previousBaseSha: preLandBaseSha, landedCommitShas, subject: landedSubject, worktreeRemoved: false, branchDeleted: false, pushed, ...baseWorktreeSyncField, ...gateResultFields, ...gateSkippedField, error: undefined }); } throwIfMergeAborted(signal); const removed = await mergeGit(["worktree", "remove", "--force", worktreePath], ownerRepo, "cleanup", "workspace merge remove landed worktree", signal); const worktreeRemoved = removed.ok; const deleteResult = worktreeRemoved ? await deleteBranchIfSafe(ownerRepo, branch, undefined, baseTip, signal, input.id) : { branchDeleted: false }; return head({ merged: true, status: "merged", mergedSha: headSha, baseSha: baseTip, previousBaseSha: preLandBaseSha, landedCommitShas, subject: landedSubject, worktreeRemoved, ...deleteResult, pushed, ...baseWorktreeSyncField, ...gateResultFields, ...gateSkippedField, error: undefined }); }