"""Single deterministic git-push primitive for the controller worker. Every worker-side push — implementer finalize, conflict-resolution finalize, salvage — goes through :func:`worker_push` so the lease / force / stale-detection semantics live in exactly one place. Design (controller spec v3): - The push is **leased against the SHA the worker started from** (``expected_sha``), never a freshly-fetched remote tip. A concurrent push that moved the branch is therefore *detected*, not silently clobbered (the pre-fix MCP push leased against a just-fetched tip, so the lease always passed — it destroyed an out-of-band commit). - ``allow_rewrite=False`` (implementer): the worktree branch is a fast-forward of ``expected_sha``; a plain push is used and *cannot* clobber. A non-fast-forward rejection means the remote moved → ``stale_input``. A worktree HEAD that is NOT a descendant of ``expected_sha`` (the agent rewrote history) → ``diverged``. - ``allow_rewrite=True`` (conflict-resolver rebase track): history was rewritten on purpose; ``--force-with-lease`` pinned to ``expected_sha`` performs the rewrite while still refusing to clobber a concurrently-moved branch. - Transient infra failures (network / auth) are retried a bounded number of times; ref-rejection failures are never retried — they surface as ``stale_input`` so the caller re-prefetches. """ from __future__ import annotations import subprocess import time from dataclasses import dataclass from pathlib import Path from typing import Literal PushStatus = Literal["pushed", "stale_input", "diverged", "infra_error"] # stderr fragments that mark a genuine *ref rejection* — the remote # moved under us — as opposed to a transport/auth/policy failure. # Matched case-insensitively. These are the PRECISE markers git prints # for a non-fast-forward / stale-lease rejection; the generic summary # line ("failed to push some refs") is deliberately NOT included — it # accompanies every push failure (hook declines, permission denied, # quota), and matching it would misclassify those as stale_input. _REJECTION_MARKERS = ( "non-fast-forward", "fetch first", "stale info", "[rejected]", "cannot lock ref", ) @dataclass(frozen=True) class PushResult: """Outcome of a :func:`worker_push`. - ``pushed`` — the push landed; ``remote_sha`` is the verified tip. - ``stale_input`` — the remote moved off ``expected_sha`` during the attempt (detected before OR at push time). The caller must re-prefetch; nothing was overwritten. - ``diverged`` — the worktree HEAD is not a fast-forward of ``expected_sha`` (a non-rewrite caller's agent rewrote history). - ``infra_error`` — a transport / auth / verification failure that bounded retries did not clear. """ status: PushStatus remote_sha: str = "" detail: str = "" def _run( worktree: Path, *args: str, timeout: float = 180.0, env: dict | None = None, ) -> tuple[int, str, str]: """Run a git command in ``worktree``; return ``(rc, stdout, stderr)``. Never raises — callers branch on the return code.""" try: r = subprocess.run( ["git", "-C", str(worktree), *args], capture_output=True, text=True, timeout=timeout, env=env, ) except subprocess.TimeoutExpired: return 124, "", "git command timed out" return r.returncode, r.stdout, r.stderr def _is_rejection(stderr: str) -> bool: low = (stderr or "").lower() return any(m in low for m in _REJECTION_MARKERS) def worker_push( worktree: Path, *, remote: str = "origin", head_ref: str, expected_sha: str, allow_rewrite: bool, env: dict | None = None, timeout: float = 180.0, infra_retries: int = 2, _sleep=time.sleep, ) -> PushResult: """Push the worktree HEAD to ``/``, leased against ``expected_sha`` (the SHA the worker validated + reset to at setup). See the module docstring for the ``allow_rewrite`` contract. The ``_sleep`` parameter is an injection point for tests. """ worktree = Path(worktree) tracking = f"{remote}/{head_ref}" refspec_fetch = ( "fetch", remote, f"+{head_ref}:refs/remotes/{remote}/{head_ref}", ) # 1. Observe the current remote tip. The explicit-refspec form only # touches the remote-tracking ref, so it works even when # is the worktree's checked-out branch. rc, _, ferr = _run( worktree, *refspec_fetch, timeout=min(timeout, 60.0), env=env ) if rc != 0: return PushResult( "infra_error", detail=f"pre-push fetch failed: {ferr.strip()[:300]}", ) rc, out, _ = _run(worktree, "rev-parse", tracking, timeout=30.0, env=env) if rc != 0: return PushResult("infra_error", detail=f"could not resolve {tracking}") remote_tip = out.strip() # 2. Lease — pinned to the SHA the worker started from. A mismatch # means a concurrent push moved the branch; bail without pushing. if remote_tip != expected_sha: return PushResult( "stale_input", detail=( f"{tracking} moved {expected_sha[:12]} -> " f"{remote_tip[:12]} during the attempt" ), ) rc, out, _ = _run(worktree, "rev-parse", "HEAD", timeout=30.0, env=env) if rc != 0: return PushResult("infra_error", detail="could not resolve worktree HEAD") local_head = out.strip() # 3. Fast-forward contract for non-rewrite callers: the worktree # HEAD must descend from expected_sha. If not, the agent rewrote # history — that is the agent's error, not a stale remote. if not allow_rewrite: anc_rc, _, _ = _run( worktree, "merge-base", "--is-ancestor", expected_sha, "HEAD", timeout=30.0, env=env, ) if anc_rc == 1: return PushResult( "diverged", detail=( f"worktree HEAD {local_head[:12]} is not a fast-forward " f"of base {expected_sha[:12]}" ), ) if anc_rc not in (0, 1): return PushResult( "infra_error", detail="merge-base --is-ancestor errored" ) push_cmd: list[str] = ["push", remote, f"HEAD:refs/heads/{head_ref}"] if allow_rewrite: push_cmd.append( f"--force-with-lease=refs/heads/{head_ref}:{expected_sha}" ) # 4. Push. Retry only transient infra failures; a ref rejection is # a real concurrent push and surfaces immediately as stale_input. last_detail = "" pushed = False for attempt in range(infra_retries + 1): rc, _, perr = _run(worktree, *push_cmd, timeout=timeout, env=env) if rc == 0: pushed = True break if _is_rejection(perr): return PushResult( "stale_input", detail=f"push rejected (remote moved): {perr.strip()[:300]}", ) last_detail = f"push failed: {perr.strip()[:300]}" if attempt < infra_retries: _sleep(2.0 * (attempt + 1)) if not pushed: return PushResult("infra_error", detail=last_detail) # 5. Verify the push actually landed on the remote. _run(worktree, *refspec_fetch, timeout=min(timeout, 60.0), env=env) rc, out, _ = _run(worktree, "rev-parse", tracking, timeout=30.0, env=env) landed = out.strip() if rc == 0 else "" if landed != local_head: return PushResult( "infra_error", detail=( f"push reported success but {tracking}={landed[:12]} != " f"HEAD={local_head[:12]}" ), ) return PushResult( "pushed", remote_sha=local_head, detail=f"pushed {local_head[:12]} to {tracking}", ) __all__ = ["PushResult", "PushStatus", "worker_push"]