// Checkout-sync half of the #1462 atomic land: after the base ref is advanced with ref plumbing, bring // the CHECKED-OUT base worktree forward to match — WITHOUT clobbering any human content (worktree, index, // staged, ignored-untracked, or late writes). Extracted from merge.ts (epic #291) as a cohesive, // self-contained unit: it depends only on the low-level git primitives and the merge-phase timeout, and // exposes two entry points back to the land pipeline — syncBaseWorktreeAfterRefAdvance and // mergeBaseSyncResults. See the doc on syncBaseWorktreeAfterRefAdvance for the model. import { closeSync, existsSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { type BaseWorktreeSyncResult, errMessage } from "agent-relay-sdk"; import { git, gitRaw } from "../git"; import { mergePhaseTimeoutMs, throwIfMergeAborted } from "./merge-timeouts"; function splitNul(stdout: string): string[] { return stdout.split("\0").filter(Boolean); } /** gitRaw for the pre-transaction delta scans: honors the merge signal + phase timeout (a fast-fail if * already aborted), unlike the mandatory in-transaction ops which run signal-free. */ async function probeGitRaw(args: string[], cwd: string, label: string, signal?: AbortSignal): ReturnType { throwIfMergeAborted(signal); return gitRaw(args, cwd, { timeoutMs: mergePhaseTimeoutMs("rebase"), timeoutLabel: `workspace merge ${label}`, signal }); } /** Every path touched by the landed delta. `--no-renames` on purpose (matching {@link addedPathScan}): * default rename detection reports only the DESTINATION, so the rename SOURCE (delete-side) would never * enter the modify/delete scan and a dirty human edit at that source path would be stranded SILENTLY * (#824 contract violation). Decomposing every rename into A(new)+D(old) puts BOTH sides here, so the * delete-side is reported as unsynced when read-tree refuses to remove it. #1462 round-6. */ async function changedPathList(worktreePath: string, oldBaseTip: string, newBaseTip: string, signal?: AbortSignal): Promise { const diff = await probeGitRaw(["diff", "--name-only", "-z", "--no-renames", oldBaseTip, newBaseTip], worktreePath, "changed path scan", signal); return diff.ok ? splitNul(diff.stdout) : []; } /** Paths ADDED by the land — present in newBaseTip, absent in oldBaseTip, INCLUDING the add-side of a * rename and of a file↔directory transition. Rename-agnostic on purpose (`--no-renames` decomposes * every rename into A(new)+D(old)) so an add-side that could collide with an ignored-untracked file is * never hidden behind a rename. `ok:false` when the scan itself fails — the caller then conservatively * refuses to run read-tree on the whole tree (which would silently clobber an ignored-untracked file * sitting at an added path — git read-tree -m -u returns 0 and overwrites it). #1462 round-5. */ async function addedPathScan(worktreePath: string, oldBaseTip: string, newBaseTip: string, signal?: AbortSignal): Promise<{ ok: boolean; paths: string[] }> { const diff = await probeGitRaw(["diff", "--name-only", "-z", "--diff-filter=A", "--no-renames", oldBaseTip, newBaseTip], worktreePath, "added path scan", signal); return diff.ok ? { ok: true, paths: splitNul(diff.stdout) } : { ok: false, paths: [] }; } /** git scoped to a specific index (`GIT_INDEX_FILE`) with `GIT_LITERAL_PATHSPECS` forced on. Runs * WITHOUT the merge signal on purpose: once the ref advanced the checkout sync is mandatory and * non-interruptible (#1462 round-3), and a half-applied index transaction must never be torn by an * abort. Literal pathspecs guarantee exact path matching for every path handed to git (no glob / `:` * magic) — load-bearing for the P/N classification (ADJ-D). */ async function txnGit(args: string[], cwd: string, label: string, indexFile?: string): ReturnType { return git(args, cwd, { timeoutMs: mergePhaseTimeoutMs("rebase"), timeoutLabel: `workspace merge ${label}`, env: { ...process.env, GIT_LITERAL_PATHSPECS: "1", ...(indexFile ? { GIT_INDEX_FILE: indexFile } : {}) }, }); } async function txnGitRaw(args: string[], cwd: string, label: string, indexFile?: string): ReturnType { return gitRaw(args, cwd, { timeoutMs: mergePhaseTimeoutMs("rebase"), timeoutLabel: `workspace merge ${label}`, env: { ...process.env, GIT_LITERAL_PATHSPECS: "1", ...(indexFile ? { GIT_INDEX_FILE: indexFile } : {}) }, }); } function sleepMs(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function safeUnlink(path: string): void { try { rmSync(path, { force: true }); } catch { /* best effort */ } } /** ADJ-A — atomically acquire git's OWN index lock via O_CREAT|O_EXCL (`fs.open(..,"wx")`), never a * racy `cp`, then seed it with the current index bytes (exactly git's write-new-index protocol). * EEXIST means another writer already owns it → brief bounded retry, then give up WITHOUT stealing * (the caller surfaces a durable strand). Returns true ONLY when THIS process * created the lock, so the caller's `finally` releases a lock it actually owns — never one it didn't. */ async function acquireIndexLock(indexPath: string, lockPath: string): Promise { for (let attempt = 0; attempt < 10; attempt += 1) { let fd: number; try { fd = openSync(lockPath, "wx"); // O_CREAT | O_EXCL | O_WRONLY } catch (e) { if ((e as { code?: string }).code === "EEXIST") { await sleepMs(25); continue; } return false; // unexpected (e.g. missing gitdir) — cannot acquire; touch nothing } try { if (existsSync(indexPath)) writeFileSync(fd, readFileSync(indexPath)); closeSync(fd); return true; } catch { try { closeSync(fd); } catch { /* already closed */ } safeUnlink(lockPath); // seeding failed on OUR just-created lock — remove only what we made return false; } } return false; // persistent contention — never force-steal another writer's lock } /** ADJ-D — exact-literal set of paths carrying ANY entry in `indexFile` (the LOCKED snapshot): normal * staged, intent-to-add, assume-unchanged, skip-worktree, and every conflict stage all list here. * Matched by exact string equality (no pathspec). `undefined` = the scan failed → the caller biases to * preserve ALL added paths, because a false-N would normalize (and thus lose) human-staged content. */ async function stagedIndexPaths(worktreePath: string, indexFile: string): Promise | undefined> { const out = await txnGitRaw(["ls-files", "--stage", "-z"], worktreePath, "classify base index entries", indexFile); if (!out.ok) return undefined; const set = new Set(); for (const entry of splitNul(out.stdout)) { const tab = entry.indexOf("\t"); // " \t" if (tab >= 0) set.add(entry.slice(tab + 1)); } return set; } /** Added paths that land as a GITLINK (submodule, mode 160000). checkout-index cannot detect a * human-populated dir collision at a gitlink (it returns 0 with an empty `git diff HEAD`), so these * are NEVER materialized — always stranded + surfaced (sol's blessed simplification). `undefined` = the * classification scan itself failed → the caller biases to PRESERVE ALL added paths (never fail-open: a * swallowed failure here would misclassify a real gitlink as a normal add and materialize it). #1462 round-6. */ async function gitlinkAddedPaths(worktreePath: string, tree: string, addedPaths: string[]): Promise | undefined> { const set = new Set(); if (addedPaths.length === 0) return set; const out = await txnGitRaw(["ls-tree", "-z", tree, "--", ...addedPaths], worktreePath, "classify gitlink added paths"); if (!out.ok) return undefined; for (const entry of splitNul(out.stdout)) { const tab = entry.indexOf("\t"); const space = entry.indexOf(" "); if (tab < 0 || space < 0) continue; if (entry.slice(0, space) === "160000") set.add(entry.slice(tab + 1)); } return set; } /** Build a throwaway tree = `tree` with `removePaths` stripped, in a temp index (never the real index). * Scopes read-tree's carry-forward to modify/delete paths only: the added paths are absent from this * tree, so read-tree never unpacks them and the ignored-untracked clobber vector cannot occur. */ async function treeWithout(worktreePath: string, tree: string, removePaths: string[], tempIndex: string): Promise { safeUnlink(tempIndex); try { if (!(await txnGit(["read-tree", tree], worktreePath, "build carry-forward tree seed", tempIndex)).ok) return undefined; if (removePaths.length && !(await txnGit(["rm", "--cached", "-q", "--", ...removePaths], worktreePath, "build carry-forward tree drop-adds", tempIndex)).ok) return undefined; const wtree = await txnGit(["write-tree"], worktreePath, "build carry-forward tree write", tempIndex); return wtree.ok ? wtree.stdout : undefined; } finally { safeUnlink(tempIndex); safeUnlink(`${tempIndex}.lock`); } } /** True when EVERY non-empty stderr line is git's documented no-overwrite collision notice * (` already exists, no checkout`). That refusal is the EXPECTED checkout-index behavior at a path * a human already occupies — detected downstream by {@link pathsOffHead} as a preserved collision. Any * OTHER stderr (or an empty stderr on a non-zero exit) is a genuine failure that must surface, not be * swallowed as a fake collision (BLOCKER 2). */ function isOnlyCheckoutCollision(stderr: string): boolean { const lines = stderr.split("\n").map((l) => l.trim()).filter(Boolean); return lines.length > 0 && lines.every((l) => l.endsWith("already exists, no checkout")); } /** Install `paths`' landed blobs into the WORKTREE via checkout-index against a throwaway temp index * (the real index / held lock is never touched by the install). checkout-index CREATES an absent path * and REFUSES (preserving) any path that already exists — ignored-untracked and late-appearing files * included (git's documented no-overwrite; the create is O_EXCL, so it is race-safe against late * writes). Handles regular, executable, symlink and filtered adds correctly via git's own machinery. * THROWS on any unexpected non-ok (seed failure, or a checkout-index error that is NOT a pure collision) * so the caller's ADJ-E catch surfaces it loudly instead of silently swallowing it. */ async function installAddedWorktree(worktreePath: string, tree: string, paths: string[], tempIndex: string): Promise { if (paths.length === 0) return; safeUnlink(tempIndex); try { const seed = await txnGit(["read-tree", tree], worktreePath, "seed add-install index", tempIndex); if (!seed.ok) throw new Error(`add-install seed read-tree failed: ${seed.stderr || `exit ${seed.exitCode}`}`); const checkout = await txnGit(["checkout-index", "--", ...paths], worktreePath, "install landed added paths", tempIndex); if (!checkout.ok && !isOnlyCheckoutCollision(checkout.stderr)) { throw new Error(`checkout-index install failed: ${checkout.stderr || `exit ${checkout.exitCode}`}`); } } finally { safeUnlink(tempIndex); safeUnlink(`${tempIndex}.lock`); } } /** Normalize `lockIndex` to the landed blob for `paths` (index-only; worktree untouched). Only ever * called for N — added paths with NO human index entry — so it never overwrites human-staged content * (ADJ-D). Uses the exact landed mode+sha from `tree`; gitlinks never reach here (they are in P). * THROWS on any unexpected non-ok (ls-tree resolve or update-index) so a swallowed index-write failure * never masquerades as a fake collision downstream (BLOCKER 2). */ async function normalizeAddedIndex(worktreePath: string, tree: string, paths: string[], lockIndex: string): Promise { if (paths.length === 0) return; const ls = await txnGitRaw(["ls-tree", "-z", tree, "--", ...paths], worktreePath, "resolve landed added blobs"); if (!ls.ok) throw new Error(`resolve landed added blobs failed: ${ls.stderr || `exit ${ls.exitCode}`}`); const args: string[] = []; for (const entry of splitNul(ls.stdout)) { const tab = entry.indexOf("\t"); if (tab < 0) continue; const path = entry.slice(tab + 1); const meta = entry.slice(0, tab).split(" "); // [mode, type, sha] if (meta.length < 3 || meta[0] === "160000") continue; args.push("--cacheinfo", `${meta[0]},${meta[2]},${path}`); } if (args.length) { const upd = await txnGit(["update-index", "--add", ...args], worktreePath, "normalize landed added paths in base index", lockIndex); if (!upd.ok) throw new Error(`normalize landed added paths failed: ${upd.stderr || `exit ${upd.exitCode}`}`); } } /** Report-only ground-truth: which of `paths` still differ from the advanced HEAD in the worktree. * Drives NO write, so it stays outside the observe-then-force-restore ban (#1462). A failed probe * can't prove a path synced — treat every candidate as still-unsynced. */ async function pathsOffHead(worktreePath: string, paths: string[]): Promise { if (paths.length === 0) return []; const diff = await txnGitRaw(["diff", "--name-only", "-z", "HEAD", "--", ...paths], worktreePath, "report unsynced base paths"); return diff.ok ? splitNul(diff.stdout) : [...paths]; } const RECONCILED: BaseWorktreeSyncResult = { reconciled: true }; /** Build + emit a loud, operator-visible mixed-state signal for a base checkout that could not be fully * synced. ADJ-2/3: the host alert is emitted HERE, inside the mandatory sync path, so it fires on EVERY * strand regardless of which caller invoked us (including rewindBaseAfterPushRace, which discards our * return value) and cannot be skipped by a later throw. Reporting is truthful — every named path is * genuinely unsynced; we never claim which paths carry human WIP (proving that is exactly the * observation that had the TOCTOU), so there is no preservedWipPaths over-claim. */ function surfaceStrand(base: string, worktreePath: string, unsyncedPaths: string[], cause: string): BaseWorktreeSyncResult { const result: BaseWorktreeSyncResult = { reconciled: false, unsyncedPaths, message: `advanced ${base} but did not fully sync the base checkout at ${worktreePath} (${cause}); ${unsyncedPaths.length} landed path(s) left unsynced (no content was overwritten) — reconcile by hand: ${unsyncedPaths.join(", ")}`, }; logBaseWorktreeStrand(base, worktreePath, result); return result; } /** Combine two heal outcomes from a single land (an upstream sync + the final land can each touch the * dirty checkout). A mixed state from either wins; unsynced paths union. */ export function mergeBaseSyncResults(a: BaseWorktreeSyncResult | undefined, b: BaseWorktreeSyncResult | undefined): BaseWorktreeSyncResult | undefined { if (!a || a.reconciled) return b && !b.reconciled ? b : a ?? b; if (!b || b.reconciled) return a; const unsyncedPaths = [...new Set([...(a.unsyncedPaths ?? []), ...(b.unsyncedPaths ?? [])])]; return { reconciled: false, unsyncedPaths, message: [a.message, b.message].filter(Boolean).join("; "), }; } /** * After the base ref is advanced with ref plumbing (update-ref / synthesized no-ff merge), bring the * checked-out base worktree forward to match — WITHOUT clobbering any human content (worktree, index, * staged, ignored-untracked, or late writes). This is the sync half of the #1462 atomic land: the ref * moved, but the checkout's index/working files are still at the old tip, so a shared/primary checkout * would otherwise serve STALE files for the landed paths (#823/#824). * * #1462 round-5 — the delta is SPLIT because the two path classes need different primitives: * - MODIFY/DELETE-only delta → `read-tree -m -u oldTip newTip`: git's atomic carry-forward. It refuses * (touching nothing) on any conflicting tracked worktree/index state, carries non-conflicting local * edits forward, and materializes clean paths. No added path exists, so it is safe outright. * - ADD/RENAME present → read-tree -m -u would SILENTLY OVERWRITE an ignored-untracked file sitting at * an added path (git returns 0 and clobbers). So the sync scopes read-tree to a tree with the added * paths STRIPPED (they are never unpacked), and installs each added path with checkout-index — a * genuinely no-overwrite primitive (creates absent paths, refuses existing ones). The whole thing * runs as ONE git-native atomic index transaction on a held `.git/index.lock`, committed with a * single rename, so the real index never shows a hybrid state and no concurrent writer can interleave. * * The heal is best-effort and NON-authoritative (the ref is already authoritatively advanced). Anything * it cannot sync WITHOUT overwriting human content is left exactly in place and surfaced loudly * (reconciled:false + a host alert), never force-restored. Nothing re-runs this sync after a successful * land, so a surfaced strand is a durable MANUAL mixed state until an operator reconciles it (ADJ-1). */ export async function syncBaseWorktreeAfterRefAdvance( base: string, baseWorktree: { path: string; dirty: boolean } | undefined, oldBaseTip: string, newBaseTip: string, signal?: AbortSignal, ): Promise { // Base isn't checked out in any worktree — the ref advance is the whole story, nothing to sync. if (!baseWorktree) return RECONCILED; const wt = baseWorktree.path; let changedPaths: string[] = []; try { changedPaths = await changedPathList(wt, oldBaseTip, newBaseTip, signal); const added = await addedPathScan(wt, oldBaseTip, newBaseTip, signal); if (!added.ok) return surfaceStrand(base, wt, changedPaths, "could not classify the landed delta's added paths — left the checkout untouched rather than risk an ignored-untracked clobber"); if (added.paths.length === 0) { // Modify/delete-only: the settled atomic carry-forward. Runs signal-free (mandatory sync). const readTree = await txnGit(["read-tree", "-m", "-u", oldBaseTip, newBaseTip], wt, "sync base worktree after ref advance"); return readTree.ok ? RECONCILED : surfaceStrand(base, wt, changedPaths, "read-tree refused to fast-forward the dirty base checkout (conflicting worktree/index)"); } return await materializeAddSplit(base, wt, oldBaseTip, newBaseTip, changedPaths, added.paths); } catch (err) { // Never propagate: the ref already advanced (land done); the sync is best-effort + surfaced. return surfaceStrand(base, wt, changedPaths, `base checkout sync errored (${errMessage(err)})`); } } /** * The ADD/RENAME sync path: split the delta, run one git-native atomic index transaction (#1462 round-5). * Preconditions: `addedPaths` is non-empty; index+worktree are at oldBaseTip; HEAD is newBaseTip. */ async function materializeAddSplit(base: string, wt: string, oldBaseTip: string, newBaseTip: string, changedPaths: string[], addedPaths: string[]): Promise { // ADJ-A part 2 — resolve the EXACT index path (a linked worktree keeps its index under // common-dir/worktrees/, and `.git` is a FILE there), never hard-code `.git/index`. const indexPathRaw = (await txnGit(["rev-parse", "--git-path", "index"], wt, "resolve base index path")).stdout; if (!indexPathRaw) return surfaceStrand(base, wt, changedPaths, "could not resolve the base checkout's index path"); const indexPath = resolve(wt, indexPathRaw); // absolute in a linked worktree, relative in the main checkout const lockPath = `${indexPath}.lock`; const tmpT = `${indexPath}.arsync-t`; const tmpAdd = `${indexPath}.arsync-add`; if (!(await acquireIndexLock(indexPath, lockPath))) { return surfaceStrand(base, wt, changedPaths, "the base checkout index is locked by another writer — left unsynced as a durable manual strand"); } let committed = false; let worktreeWritten = false; try { // Classify against the LOCKED SNAPSHOT before any mutation. Bias to preserve (ADJ-D): a false-P only // strands safely, a false-N would normalize and thus LOSE human-staged content. const stagedInLock = await stagedIndexPaths(wt, lockPath); const gitlinks = await gitlinkAddedPaths(wt, newBaseTip, addedPaths); let preserve: string[]; let normalize: string[]; if (stagedInLock === undefined || gitlinks === undefined) { // Either classification scan failed → bias to PRESERVE every add: a false-P only strands safely, // a false-N would normalize and thus LOSE human-staged content or materialize a gitlink (ADJ-D). preserve = [...addedPaths]; normalize = []; } else { preserve = addedPaths.filter((p) => stagedInLock.has(p) || gitlinks.has(p)); normalize = addedPaths.filter((p) => !stagedInLock.has(p) && !gitlinks.has(p)); } // (1) modify/delete carry-forward INTO the held lock index (index + worktree), atomic refuse-on-conflict. const tTree = await treeWithout(wt, newBaseTip, addedPaths, tmpT); if (!tTree) throw new Error("failed to build the modify/delete carry-forward tree"); const readTree = await txnGit(["read-tree", "-m", "-u", oldBaseTip, tTree], wt, "carry modify/delete into base index", lockPath); const modifyDeleteRefused = !readTree.ok; if (readTree.ok) worktreeWritten = true; // (2) install N adds into the WORKTREE via a SEPARATE temp index — the real index / held lock is // never written with landed content, so a human-staged path's blob is never transiently clobbered. if (normalize.length) { worktreeWritten = true; await installAddedWorktree(wt, newBaseTip, normalize, tmpAdd); } // (3) normalize the held lock index to landed for N only (index-only; P is never touched). await normalizeAddedIndex(wt, newBaseTip, normalize, lockPath); // COMMIT the whole transaction: one atomic rename(lock → index). renameSync(lockPath, indexPath); committed = true; // Report (ground truth; drives no write): every KNOWN refusal plus every path still off HEAD. const addedSet = new Set(addedPaths); const modifyDeletePaths = changedPaths.filter((p) => !addedSet.has(p)); const collided = await pathsOffHead(wt, normalize); const unsynced = [...new Set([...(modifyDeleteRefused ? modifyDeletePaths : []), ...preserve, ...collided])]; if (unsynced.length === 0) return RECONCILED; const cause = [ modifyDeleteRefused ? "read-tree refused conflicting modify/delete paths" : null, preserve.length ? `${preserve.length} added path(s) carry human-staged/gitlink state (preserved)` : null, collided.length ? `${collided.length} added path(s) collided with an existing file (preserved)` : null, ].filter(Boolean).join("; "); return surfaceStrand(base, wt, unsynced, cause || "some landed paths could not be synced"); } catch (err) { // ADJ-E — an unexpected failure mid-transaction. NEVER leave a SILENT strand. Prefer committing the // held index (coherent with any modify/delete worktree writes already applied) over discarding it // (which would leave index=old while the worktree already moved); either way, surface loudly. `finally` // below only RELEASES this process's uncommitted lock — it is NOT recovery. if (worktreeWritten && !committed) { try { renameSync(lockPath, indexPath); committed = true; } catch { /* fall through and report the strand */ } } // TRUTHFUL reporting (locked contract), gated on whether the held partial index actually went live: // - committed: the partial IS the live index, so pathsOffHead is ground truth — report only paths that // genuinely still differ from HEAD (never changedPaths wholesale; a synced path must not be overclaimed). // pathsOffHead falls back to all probed paths if the probe itself fails, staying conservative. // - NOT committed: the held lock never replaced the live index, so it is still at the PRE-LAND tip and // EVERY oldTip→newTip changed path is genuinely index-unsynced — list them ALL. pathsOffHead would // UNDER-report here: `git diff HEAD` nets out a stale live index whenever the worktree already equals // the new HEAD (read-tree advanced the worktree), silently dropping a path that IS index-unsynced. const unsynced = committed ? await pathsOffHead(wt, changedPaths) : changedPaths; const cause = committed ? `sync errored; committed the coherent partial base index and surfaced (${errMessage(err)})` : `sync errored; base index left at the pre-land tip (${errMessage(err)})`; return surfaceStrand(base, wt, unsynced, cause); } finally { safeUnlink(tmpT); safeUnlink(`${tmpT}.lock`); safeUnlink(tmpAdd); safeUnlink(`${tmpAdd}.lock`); // `${lockPath}.lock` is git's OWN transient lock on our held index during an update-index write (we // pass lockPath as GIT_INDEX_FILE, so it is exclusively ours) — clean it UNCONDITIONALLY so a failed // txn never leaves `.git/index.lock.lock` wedging the next scan (BLOCKER 2). safeUnlink(`${lockPath}.lock`); // Release ONLY this process's uncommitted held lock (lockPath). Never remove it after a commit — it // was renamed onto the index and any fresh `.git/index.lock` now belongs to another writer. if (!committed) safeUnlink(lockPath); } } /** Loud, operator-visible signal that a dirty base checkout was left in a mixed state. Replaces * the old swallow-as-warning behavior (#824): a stale primary checkout must never be silent. */ function logBaseWorktreeStrand(base: string, worktreePath: string, sync: BaseWorktreeSyncResult): void { console.error( `[orchestrator] ALERT (#824): dirty base ${base} checkout left in a MIXED state after land — ` + `${worktreePath} working files are out of sync with the advanced HEAD. ${sync.message ?? ""} ` + "Reads/builds/publishes from this checkout will see STALE content until reconciled.", ); }