import { existsSync } from "node:fs"; import { resolve } from "node:path"; import type { LandGateRunResult, WorkspaceLandGateLevel, WorkspaceMergeResult } from "agent-relay-sdk"; import { git } from "../git"; import { deleteBranchIfSafe, owningRepoRoot } from "./cleanup"; import { rejectGitignoredLandedSymlinks, runLandGatesOnRebasedTree } from "./integrated-land-gates"; import { throwIfMergeAborted } from "./merge-timeouts"; import { worktreePathRedirected } from "./names"; import { shortBranch } from "./parse"; import type { WorkspaceMergeInput } from "./types"; const MAX_PUSH_ATTEMPTS = 3; function result(input: WorkspaceMergeInput, branch: string | undefined, fields: Partial): WorkspaceMergeResult { return { workspaceId: input.id, strategy: "rebase-ff", landMechanism: "plain-git", merged: false, status: "review_requested", ...(branch ? { branch } : {}), ...fields, }; } function baseBranch(baseRef: string | undefined): string | undefined { if (!baseRef) return undefined; return baseRef.startsWith("refs/heads/") ? baseRef.slice("refs/heads/".length) : baseRef; } /** * The deliberately small direct executor. It does not touch Relay's merge lease: * each attempt fetches current origin/base, rebases the worker tip, gates the REBASED * RESULT, then atomically pushes that exact SHA to base. A rejected push simply repeats * that sequence — re-fetch, re-rebase, RE-GATE — against the new origin tip. * * #1628 — the gate step is the one thing this executor may not skip. It used to be * gateless for typecheck/test/build on the theory that the worker already validated its * own branch; but a rebase COMPOSES that branch with everything that landed since, and a * composition is red far more often than either part. `git rebase` reports success on a * textually clean replay, which says nothing about whether the two changes still agree * semantically — so "no conflict" was being read as "still green" by every land on this * path, including the ones a steward drove (the steward's own `ci:land` runs BEFORE this * executor re-fetches and re-rebases, so its green describes a tree that never landed). */ export async function mergeWorkspacePlainGit(input: WorkspaceMergeInput): Promise { if (!input.worktreePath) return result(input, input.branch, { error: "worktreePath required" }); const worktreePath = resolve(input.worktreePath); if (!existsSync(worktreePath)) return result(input, input.branch, { status: "cleaned", error: `worktree does not exist: ${worktreePath}` }); // #1502 — refuse a worktree whose final component is a symlink redirecting git at another checkout. // `resolve()` is lexical, so without this a final-component symlink would be followed through // rebase+push. Mirrors the mergeWorkspace guard; plain-git land is a separate host consumer. if (worktreePathRedirected(worktreePath)) return result(input, input.branch, { error: "worktree path is a symlink or unresolvable; refusing to land (#1502)" }); const signal = input.signal; throwIfMergeAborted(signal); const branch = shortBranch((await git(["symbolic-ref", "--quiet", "--short", "HEAD"], worktreePath, { signal })).stdout || undefined) ?? input.branch; // #1690 — bind the queued command to the previewed worktree content, not its // mutable branch name. Keep resolving the live branch so a same-tip rename (#232) // remains recoverable, but refuse a recycled/tipped worktree before rebase or push. if (input.expectedHeadSha) { const liveHead = await git(["rev-parse", "HEAD"], worktreePath, { signal }); if (!liveHead.ok || !liveHead.stdout.trim()) return result(input, branch, { error: liveHead.stderr || "failed to resolve live worktree HEAD" }); if (liveHead.stdout.trim() !== input.expectedHeadSha) return result(input, branch, { error: "worktree tip changed since merge dispatch; refusing stale merge command" }); } const base = baseBranch(input.baseRef); if (!base) return result(input, branch, { error: "no base branch to push" }); const dirty = await git(["status", "--porcelain"], worktreePath, { signal }); if (!dirty.ok) return result(input, branch, { error: dirty.stderr || "failed to inspect workspace status" }); if (dirty.stdout.trim()) return result(input, branch, { error: "worktree has uncommitted changes" }); // A gate level below `full` is an authorized operator override resolved on the relay // (resolveWorkspaceLandGate); it is reported on the land result so a skipped gate is never // silent, exactly as the managed path reports it. const gateLevel: WorkspaceLandGateLevel = input.gateLevel === "subset" || input.gateLevel === "none" ? input.gateLevel : "full"; let gateWarnings: LandGateRunResult[] | undefined; // #1636 finding 2 — how many gates actually executed on the tree this attempt pushed. Reported // even when 0, so "the base declares no gates" stops being indistinguishable from "the full // suite passed"; re-assigned on every retry pass so it always describes the tree that landed. let gatesRan: number | undefined; const gateFields = (commit: string): Partial => ({ ...(gatesRan !== undefined ? { gatesRan } : {}), ...(gateWarnings?.length ? { gateWarnings } : {}), ...(gateLevel !== "full" ? { gateSkipped: { level: gateLevel, reason: input.gateReason ?? "", by: input.gateRequestedBy ?? "unknown", commit } } : {}), }); let lastError = "plain-git land failed"; for (let attempt = 1; attempt <= MAX_PUSH_ATTEMPTS; attempt += 1) { throwIfMergeAborted(signal); const fetch = await git(["fetch", "origin", base], worktreePath, { signal }); if (!fetch.ok) return result(input, branch, { error: fetch.stderr || `git fetch origin ${base} failed` }); const previousBaseSha = (await git(["rev-parse", `origin/${base}`], worktreePath, { signal })).stdout.trim() || undefined; const rebase = await git(["rebase", `origin/${base}`], worktreePath, { signal }); if (!rebase.ok) { await git(["rebase", "--abort"], worktreePath, { signal }); return result(input, branch, { conflict: true, status: "conflict", error: rebase.stderr || `git rebase origin/${base} failed` }); } const head = await git(["rev-parse", "HEAD"], worktreePath, { signal }); if (!head.ok || !head.stdout.trim()) return result(input, branch, { error: head.stderr || "failed to resolve rebased HEAD" }); const headSha = head.stdout.trim(); // #1145 — the gitignored-symlink data-loss invariant is not a configurable gate: it must hold // on every path that can push to base, this one included, and (unlike the configured gates // below) it holds even when an operator waives them. worktreePath is checked out at the // just-rebased HEAD, so it's exactly the tree that's about to land. if (!previousBaseSha) return result(input, branch, { error: `failed to resolve origin/${base} for gitignore reject-guard` }); const guard = await rejectGitignoredLandedSymlinks(worktreePath, previousBaseSha, headSha, signal); if (!guard.ok) return result(input, branch, { error: guard.error }); // #1628 — the configured land gates, run on the tree this attempt is about to push: the // rebase above may have composed a green branch with a green base into a red result, and // nothing before this point would have noticed (see the class docstring). Runs INSIDE the // retry loop on purpose — a rejected `--force-with-lease` below re-enters the loop, re-fetches, // re-rebases and arrives back here, so every pushed tree is one this gate passed on the tree it // was actually built from. `worktreePath` doubles as the repo for the // gate's throwaway checkout: it certainly holds `headSha`'s objects (we just made them) and // its git dir resolves to the same common dir `repoRoot` would. const gateRun = await runLandGatesOnRebasedTree(worktreePath, worktreePath, previousBaseSha, headSha, gateLevel, signal); if ("abort" in gateRun) return result(input, branch, { error: gateRun.abort.error }); if (gateRun.gates.failure) { console.error(`[orchestrator] plain-git land gate-fail workspace=${input.id ?? "(unknown)"} gate=${gateRun.gates.failure.name} head=${headSha.slice(0, 12)}`); return result(input, branch, { gateFailure: gateRun.gates.failure, error: `land gate failed: ${gateRun.gates.failure.name}` }); } gatesRan = gateRun.gates.ran; if (gateRun.gates.warnings.length) gateWarnings = gateRun.gates.warnings; // #1462 — push the EXACT gated SHA, never the symbolic `HEAD` ref. The gate above validated // `headSha`; pushing `HEAD` would land whatever HEAD points at by push time, so a concurrent // advance of the worktree HEAD in that window could sail ungated content straight to base. // Pair it with an expected-old-SHA compare-and-swap on base (--force-with-lease against the // `previousBaseSha` we fetched and rebased onto): if origin/base moved since, the push is // rejected rather than racing, and the retry loop re-fetches, re-rebases, and re-gates. The // pushed tip always fast-forwards previousBaseSha (we just rebased onto it), so the lease can // never force past newer base commits — it only refuses a stale-base push. const push = await git(["push", "origin", `--force-with-lease=refs/heads/${base}:${previousBaseSha}`, `${headSha}:refs/heads/${base}`], worktreePath, { signal }); if (!push.ok) { lastError = push.stderr || push.stdout || `push to origin/${base} rejected`; console.warn(`[orchestrator] plain-git land push rejected workspace=${input.id ?? "(unknown)"} attempt=${attempt}/${MAX_PUSH_ATTEMPTS}; refetching and rebasing`); continue; } const mergedSha = headSha; const landedCommitScan = previousBaseSha ? await git(["rev-list", "--reverse", `${previousBaseSha}..${mergedSha}`], worktreePath, { signal }) : undefined; const landedCommitShas = landedCommitScan?.ok ? landedCommitScan.stdout.split("\n").filter(Boolean) : [mergedSha]; // A successful `git push` can still be a no-op: if the branch resolved by this command has // no commits beyond the fetched base, Git says "Everything up-to-date" and exits zero. That // must not be reported as a land of the base tip (or trigger cleanup/recycle of this workspace). if (landedCommitShas.length === 0) { return result(input, branch, { noop: true, landedCommitShas: [], baseSha: previousBaseSha, previousBaseSha, error: "plain-git land made no progress: no commits beyond the fetched base", }); } const verifyFetch = await git(["fetch", "origin", base], worktreePath, { signal }); const verified = verifyFetch.ok && (await git(["merge-base", "--is-ancestor", mergedSha, `origin/${base}`], worktreePath, { signal })).ok; if (!verified) return result(input, branch, { error: `push reported success but ${mergedSha} is not on origin/${base}` }); const subject = (await git(["log", "-1", "--format=%s", mergedSha], worktreePath, { signal })).stdout.trim() || undefined; const repoRoot = input.repoRoot ? resolve(input.repoRoot) : worktreePath; if (input.deleteBranch === false) { // There is no transaction spanning a Git checkout and a live provider's // next commit. A successor checkout can therefore strand that commit; the // rebase above already leaves this branch at its post-land HEAD (#1697). return result(input, branch, { merged: true, status: "active", mergedSha, baseSha: mergedSha, previousBaseSha, landedCommitShas, subject, pushed: true, worktreeRemoved: false, branchDeleted: false, ...gateFields(mergedSha), error: undefined }); } const ownerRepo = branch ? await owningRepoRoot(worktreePath, repoRoot) : repoRoot; const removed = await git(["worktree", "remove", "--force", worktreePath], ownerRepo, { signal }); const worktreeRemoved = removed.ok; const deleteResult = worktreeRemoved && branch ? await deleteBranchIfSafe(ownerRepo, branch, undefined, mergedSha, signal, input.id) : { branchDeleted: false }; return result(input, branch, { merged: true, status: "merged", mergedSha, baseSha: mergedSha, previousBaseSha, landedCommitShas, subject, pushed: true, worktreeRemoved, ...deleteResult, ...gateFields(mergedSha), error: undefined }); } return result(input, branch, { error: `plain-git push rejected after ${MAX_PUSH_ATTEMPTS} attempts: ${lastError}` }); }