import { resolve } from "node:path"; import { fireAndForget } from "./async-guard"; // #1020 (B2) — in-process serialization of repo-root-mutating git operations on ONE physical repo. // // The command poller serializes every command inline on the poll tick EXCEPT `agent.spawn`, which // is dispatched to an unawaited background task (control.ts). That carve-out lets spawn-time // acquisition (`git fetch` / `checkout` / `merge --ff-only`) and `git worktree add` run at the same // wall-clock instant as an inline land's `merge --no-ff` / `push` / `worktree remove --force` in the // SAME checkout — a `checkout` mid-land can yank HEAD out from under the merge, and concurrent // worktree-admin ops race `.git/worktrees/` metadata. // // Every mutator of a given physical repoRoot takes this lock, so the background spawn and an inline // land can no longer interleave on the same `.git`. A physical repo on a host is owned by exactly one // orchestrator process, so an in-process mutex is sufficient for this same-host race (cross-process // same-dir collisions are the separate #B3 concern). Distinct repoRoots use distinct keys, so // spawns/lands on different repos still run fully in parallel. const chains = new Map>(); /** * Run `fn` while holding the exclusive lock for `repoRoot`. Calls for the SAME resolved path run * strictly one-at-a-time in FIFO order; calls for DIFFERENT paths run concurrently. A failure in one * holder never poisons the queue — the next waiter runs regardless of how the prior one settled. */ export function withRepoLock(repoRoot: string, fn: () => Promise): Promise { const key = resolve(repoRoot); const prior = chains.get(key) ?? Promise.resolve(); // Chain onto the prior holder; run `fn` whether the prior resolved or rejected. const run = prior.then(fn, fn); // Tail the queue on an error-swallowed link so one holder's rejection can't break ordering. const link = run.then(() => {}, () => {}); chains.set(key, link); void fireAndForget("Repo-lock queue GC", link.then(() => { // GC the map entry once this link is the tail and has settled. if (chains.get(key) === link) chains.delete(key); })); return run; }