"""The single commit point of a source ``hermes update``: everything the tree move needs armed first.

Before git (or the ZIP swap) writes the first file of the new code, three things are durable:

* the interrupted-pull marker (``.git/hermes-update-pull``) naming the commit git moves to, for EVERY
  tree move (branch switch, upstream fork pull, the pull itself) so a launch after any exit that left
  a torn tree puts the old one back (``_early_recovery.restore_interrupted_pull``);
* the source-completion tail (``venv_sync.arm_completion``) and the host fleet-restart obligation,
  so a kill after the move but before the completion child starts still leaves the tail owed to the
  next launch (``venv_sync.prepare_launch``) and the restart owed to the next ``hermes update``.

Failures before the move are no-ops: ``disarm_commit_obligations`` puts both obligations back the way
this run found them once the tree is verified at its pre-update commit again.
"""

from __future__ import annotations

import logging
import os
import subprocess
import sys
from contextlib import suppress
from pathlib import Path
from typing import Optional

from hermes_cli._early_recovery import (
    RECOVERY_CLOSURE,
    RECOVERY_CLOSURE_INIT,
    RECOVERY_CLOSURE_MANIFEST,
    blob_id,
    _lock_identity,
    interrupted_pull_marker,
    is_object_id,
    recovery_closure_dir,
    recovery_closure_verified,
    restore_interrupted_pull,
    write_durable_text,
)
from hermes_cli.update_cmd_common import _record_stop
from hermes_cli.update_custody import run_git

logger = logging.getLogger("hermes_cli.update_cmd")
# What this run found before it armed anything: {path: bytes or None}. None = nothing armed yet.
_armed_snapshot: Optional[dict[Path, Optional[bytes]]] = None
# What this run's latest arm left at each path: disarm hands back only records still exactly these.
_armed_bytes: dict[Path, Optional[bytes]] = {}
# (git_cmd, (HEAD, branch)) when this run reached its checkout phase, and the checkout it names.
_run_start: Optional[tuple[list, tuple[str, str]]] = None
_obligation_root: Optional[Path] = None
# This run's stake in the shared host record (``owners``): disarm removes only this one.
_owner: str = ""


def _owns_live_checkout(root: Path) -> bool:
    from hermes_cli.update_cmd import _m

    return _m()._pytest_owns_live_checkout(root)


def _obligation_paths(root: Path) -> list[Path]:
    from hermes_cli.update_cmd_fleet import _fleet_restart_pending_marker_path
    from hermes_cli.update_host_obligation import host_obligation_path
    from hermes_cli.venv_sync import completion_pending_path

    return [completion_pending_path(root), _fleet_restart_pending_marker_path(), host_obligation_path()]


def _read_or_none(path: Path) -> Optional[bytes]:
    """The record's bytes, None when absent. Any other read error raises: custody is never guessed."""
    try:
        return path.read_bytes()
    except FileNotFoundError:
        return None


def arm_commit_obligations(root: Path, expected_sha: str) -> None:
    """Owe the completion tail and the fleet restart for ``expected_sha`` BEFORE the tree moves.

    Idempotent within a run (the first call snapshots what to restore on a no-op failure). An
    unwritable install state, an unreadable record or a fleet restart neither store accepted
    raises: nothing has moved yet, and moving without the obligation is exactly the
    tail-never-runs state this exists to prevent.
    """
    global _armed_snapshot, _owner
    import uuid

    from hermes_cli.update_cmd_fleet import _write_fleet_restart_pending_marker
    from hermes_cli.venv_sync import arm_completion

    root = Path(root)
    if _owns_live_checkout(root):
        return
    if _armed_snapshot is None:
        _armed_snapshot = {path: _read_or_none(path) for path in _obligation_paths(root)}
        _owner = f"{root}|{os.getpid()}|{uuid.uuid4().hex}"
    try:
        arm_completion(root)
        owed = _write_fleet_restart_pending_marker(expected_sha=expected_sha or "", owner=_owner)
    finally:  # even a half-done arm: disarm must still recognise what this run wrote
        _armed_bytes.update({path: _read_or_none(path) for path in _armed_snapshot})
    if not owed:
        raise OSError("could not record the pending gateway-restart obligation")


def disarm_commit_obligations() -> None:
    """Restore both obligations to what this run found: the tree never left its pre-update commit.

    Off the commit this run STARTED from nothing is handed back: a failed later move (CP1 refused
    or failed after the CP0 branch switch, the upstream fork ff after the origin pull) settles on a
    commit that is already new code, so the obligations are owed for THAT head instead of the
    failed move's target, which the checkout does not contain and no restart could discharge.
    """
    global _armed_snapshot
    if _run_start is not None:
        git_cmd, (start_head, _branch) = _run_start
        root = Path(_obligation_root) if _obligation_root is not None else None
        head = head_and_branch(git_cmd, root)[0] if root is not None else ""
        if root is None or not start_head or head != start_head:
            if root is not None:
                _owe_for(root, head)
            return
    from hermes_cli.update_host_obligation import host_obligation_path, release_host_obligation, replace_bytes

    snapshot, _armed_snapshot = _armed_snapshot, None
    armed = dict(_armed_bytes)
    _armed_bytes.clear()
    host = host_obligation_path()
    for path, data in (snapshot or {}).items():
        try:
            if path == host:
                # Shared by every install of the OS user: same-SHA arms join one record, so bytes
                # cannot tell whose it is; the ``owners`` stake can (review C2).
                release_host_obligation(_owner)
                continue
            if path not in armed or _read_or_none(path) != armed[path]:
                continue  # rewritten since this run armed it (another install's update): theirs now
            if data is None:
                path.unlink(missing_ok=True)
            else:
                # An unpredictable exclusive temp, never a fixed name: nothing at any guessable
                # sibling is written through or deleted first (reviews N05, O1).
                replace_bytes(path, data)
        except OSError:
            pass  # an owed tail/restart left armed is a retry, never a lost obligation


def owe_restore_of(root: Path, pre: str | None) -> None:
    """A failed move left a torn tree under its marker: the next launch's restore puts ``pre`` back,
    so the obligations name ``pre``, never the target the checkout will not hold."""
    _owe_for(Path(root), pre or "")


def _owe_for(root: Path, sha: str) -> None:
    """Re-arm what this run armed for ``sha``, the commit the checkout is (or is restored) on."""
    if _armed_snapshot is None or not is_object_id(sha):
        return  # nothing armed by this run, or nothing names the code: keep what stands
    try:
        arm_commit_obligations(root, sha)
    except OSError as exc:
        logger.warning("Could not re-arm the update obligations for %s: %s", sha[:10], exc)
        print(f"  ⚠ Could not record the restart this update still owes for {sha[:10]} ({exc}); "
              "run `hermes gateway restart` once the update is done.", file=sys.stderr)


def debt_sha_for_move(pre: str, target: str) -> str:
    """The commit a fast-forward ``pre`` -> ``target`` arms the obligations for.

    A later move of this run (the upstream sync after the origin pull) owes ``pre``: the commit the
    earlier move already owes, which every landing of a fast-forward contains, and the fleet
    discharge holds the gateways to the checkout's actual HEAD whenever it contains the debt. Its
    failure then needs no retarget write, which could itself fail and leave the debt naming a commit
    the checkout never reached (review O4). The run's first move owes its ``target``: its failure
    hands the obligations back whole (``disarm_commit_obligations``).
    """
    start = _run_start[1][0] if _run_start is not None else ""
    return pre if start and pre != start else target


def arm_commit_point(git_cmd, root: Path, expected_sha: str, **move) -> str | None:
    """Arm the obligations, then the tree-move marker (``arm_tree_move(**move)``): no marker, no move.

    ``None`` when both are durable; otherwise why not, with the obligations handed back
    (``disarm_commit_obligations``), and the caller must stop before git writes a file.
    Linux/macOS gateways are paused first: this is every route's first checkout move.
    """
    from hermes_cli.update_cmd_posix_pause import pause_at_commit_point
    if refused := pause_at_commit_point():
        return refused
    try:
        arm_commit_obligations(root, expected_sha)
        arm_tree_move(git_cmd, root, **move)
    except OSError as exc:
        disarm_commit_obligations()
        head = head_and_branch(git_cmd, root)[0] if _run_start is not None else ""
        if head and head != _run_start[1][0]:
            # A later move (CP1 after the CP0 switch): THIS step moved nothing, an earlier one did.
            return (f"could not arm the update ({exc}); this step changed nothing, but an earlier step "
                    f"of this update already moved the checkout to {head[:10]}")
        return f"could not arm the update ({exc}); the checkout was not changed"
    return None


def arm_tree_move(git_cmd, root: Path, *, pre: str | None, target: str, stash: str | None,
                  rollback: str | None = None) -> Path:
    """Write the interrupted-pull marker for one git tree move (pre -> target).

    ``rollback`` (``branch``/``detach``): a syntax rollback that moves HEAD and the index back to
    ``pre`` before any file; a kill before that step finds HEAD still on ``target`` and the restore
    redoes it first, only while HEAD still names the ``ref`` recorded here (empty: detached).
    """
    marker = interrupted_pull_marker(root)
    ref = ""
    if rollback:
        named = run_git(git_cmd, ["symbolic-ref", "-q", "HEAD"], cwd=str(root), capture_output=True, text=True,
                        encoding="utf-8", errors="replace", stdin=subprocess.DEVNULL, timeout=60)
        ref = named.stdout.strip() if named.returncode == 0 else ""
    if pre:
        with suppress(OSError, subprocess.SubprocessError):  # no closure: the in-tree repair still runs
            publish_recovery_closure(git_cmd, root, pre)
    # The restore's only record, durable before git writes: temp + fsync + rename, never in place.
    # ``index_lock``: the lock generation already there before this move's git ran (empty: none). Only
    # THAT one is another git's for sure; a lock that appears later can be our own killed git's.
    write_durable_text(marker, f"pid={os.getpid()}\npre={pre or ''}\ntarget={target}\nstash={stash or ''}\n"
                       + f"index_lock={_lock_identity(marker.parent / 'index.lock')}\n"
                       + (f"rollback={rollback}\nref={ref}\n" if rollback else "")
                       + (f"git={git}\n" if (git := _absolute_git(git_cmd)) else ""))
    return marker


def publish_recovery_closure(git_cmd, root: Path, pre: str) -> Path:
    """Copy the launch repair's modules, as committed at ``pre``, beside the marker (outside the tree).

    The bytes come from the commit the repair restores, never from the working tree: a rollback move
    starts from a broken tree, and a second move in one run starts from code this process never ran.
    A manifest names each file's blob id; the launcher runs the closure only when every file hashes
    to it (``recovery_closure_verified``) and rebuilds it from git's objects otherwise. Files and
    directory are fsynced before the atomic rename, so a power loss during the move that follows
    finds the whole closure or none. An older run's closure (another ``pre``) is dropped.
    """
    import shutil

    if not is_object_id(pre):
        raise OSError(f"not a commit id: {pre!r}")
    dest = recovery_closure_dir(Path(root), pre)
    if recovery_closure_verified(dest, pre):
        return dest
    git_cmd = [*([git_cmd] if isinstance(git_cmd, str) else git_cmd), "--no-replace-objects"]
    listed = run_git(git_cmd, ["ls-tree", "-z", "--full-tree", pre, "--", *RECOVERY_CLOSURE], cwd=str(root),
                     capture_output=True, stdin=subprocess.DEVNULL, timeout=120)
    ids = {}
    for entry in (listed.stdout or b"").split(b"\0") if listed.returncode == 0 else ():
        meta, _, rel = entry.decode("utf-8", "replace").partition("\t")
        if meta.split()[1:2] == ["blob"]:
            ids[rel] = meta.split()[2]
    blobs = {RECOVERY_CLOSURE_INIT: b""}
    shown = read_target_files(git_cmd, root, pre, RECOVERY_CLOSURE)  # one spawn, not a cat-file per file
    for rel in RECOVERY_CLOSURE:
        if rel not in ids or shown[rel] is None or blob_id(shown[rel], pre) != ids[rel]:
            raise OSError(f"{rel} is not in {pre[:10]}")
        blobs[rel] = shown[rel]
    manifest = "".join(f"{blob_id(data, pre)} {rel}\n" for rel, data in blobs.items()).encode("utf-8")
    staging = dest.with_name(f".{pre}.{os.getpid()}.staging")
    shutil.rmtree(staging, ignore_errors=True)
    try:
        for rel, data in {**blobs, RECOVERY_CLOSURE_MANIFEST: manifest}.items():
            (staging / rel).parent.mkdir(parents=True, exist_ok=True)
            with open(staging / rel, "wb") as handle:
                handle.write(data)
                handle.flush()
                os.fsync(handle.fileno())
        for directory in (staging / "hermes_cli", staging):
            _fsync_dir(directory)
        shutil.rmtree(dest, ignore_errors=True)
        os.replace(staging, dest)
        _fsync_dir(dest.parent)
    finally:
        shutil.rmtree(staging, ignore_errors=True)
    for stale in dest.parent.iterdir():
        if stale != dest:
            shutil.rmtree(stale, ignore_errors=True)
    return dest


def _fsync_dir(directory: Path) -> None:
    """POSIX: make a directory's entries durable. Windows opens no directory handle; NTFS journals
    the rename itself."""
    if os.name == "nt":
        return
    fd = os.open(directory, os.O_RDONLY)
    try:
        os.fsync(fd)
    finally:
        os.close(fd)


def _absolute_git(git_cmd) -> str:
    """The git this run moves the tree with, as an absolute path for the next launch's repair.

    A Windows install's git is often PM's store copy, on PATH only inside the updater
    (``expose_pm_git``); the repair cannot look it up through ``pm`` when the killed move tore a
    module ``pm`` imports (``hermes_constants``)."""
    import shutil

    head = (list(git_cmd) or ["git"])[0] if not isinstance(git_cmd, str) else git_cmd
    found = shutil.which(head)
    return os.path.abspath(found) if found else ""


def files_added_by(git_cmd, root: Path, pre: str, target: str | None) -> list[str]:
    """Paths ``target`` adds over ``pre``. A rollback's mixed reset un-tracks them and ``reset --hard``
    never touches untracked files, so ``drop_added_files`` removes them by name afterwards."""
    if not target or target == pre:
        return []
    cp = run_git(git_cmd, ["diff", "--name-only", "-z", "--no-renames", "--diff-filter=A", pre, target],
                        cwd=str(root), capture_output=True, text=True, encoding="utf-8", errors="replace",
                        stdin=subprocess.DEVNULL, timeout=120)
    return [p for p in cp.stdout.split("\0") if p] if cp.returncode == 0 else []


def drop_added_files(root: Path, added: list[str]) -> bool:
    """Every one is the move's own file: git refuses to move a checkout over an untracked file.

    False when one of them is still there (the caller keeps the marker; the restore retries)."""
    root = Path(root)
    gone = True
    for rel in added:
        try:
            (root / rel).unlink(missing_ok=True)
        except OSError:
            gone = False
    for parent in sorted({p for rel in added for p in Path(rel).parents if str(p) != "."},
                         key=lambda p: len(p.parts), reverse=True):
        try:
            (root / parent).rmdir()  # only when empty: anything else inside keeps it
        except OSError:
            pass
    return gone and not any(os.path.lexists(root / rel) for rel in added)


def tree_whole_at(git_cmd, root: Path, sha: str) -> bool:
    """HEAD is ``sha``, no ``index.lock`` is left and no tracked file differs from it."""
    def run(*args: str) -> subprocess.CompletedProcess:
        return run_git(git_cmd, [*args], cwd=str(root), capture_output=True, text=True, encoding="utf-8",
                              errors="replace", stdin=subprocess.DEVNULL, timeout=120)

    head = run("rev-parse", "-q", "--verify", "HEAD")
    if head.returncode != 0 or head.stdout.strip() != sha or (interrupted_pull_marker(Path(root)).parent / "index.lock").exists():
        return False
    status = run("status", "--porcelain", "-z", "--untracked-files=no")
    return status.returncode == 0 and not status.stdout.strip("\0")


def settle_failed_tree_move(root: Path) -> bool:
    """Git exited without finishing a tree move: put back what it wrote, keep the marker if we can't.

    True when the tree is verified whole again (at the pre-move commit, or at the target when git got
    there anyway); False leaves the marker for the next launch's ``restore_interrupted_pull``.
    """
    marker = interrupted_pull_marker(Path(root))
    if not marker.is_file():
        return False  # no marker names the move: its absence proves nothing about the tree
    restore_interrupted_pull(Path(root), after_failure=True)
    return not marker.is_file()


def _requires_python_spec(pyproject: bytes | str | None) -> str | None:
    """The target's ``requires-python`` specifier, None when absent or unreadable."""
    if not pyproject:
        return None
    import tomllib

    try:
        text = pyproject.decode("utf-8") if isinstance(pyproject, bytes) else pyproject
        spec = tomllib.loads(text)["project"]["requires-python"]
    except (KeyError, TypeError, ValueError):  # TOMLDecodeError is a ValueError
        return None
    return spec if isinstance(spec, str) else None


def _spec_admits(spec: str, version: str) -> bool:
    import re

    try:
        from packaging.specifiers import InvalidSpecifier, SpecifierSet
    except ImportError:  # the lower bound is the part a Python bump moves
        floor = re.search(r">=\s*(\d+)\.(\d+)", spec)
        found = re.match(r"(\d+)\.(\d+)", version)
        return not floor or bool(found) and (int(found[1]), int(found[2])) >= (int(floor[1]), int(floor[2]))
    try:
        return SpecifierSet(spec).contains(version, prereleases=True)
    except InvalidSpecifier:
        return True  # unknown: keep judging with this interpreter, never wave a file through


def requires_other_python(pyproject: bytes | str | None) -> bool:
    """True when a target's ``requires-python`` excludes the running interpreter.

    Its startup modules may then use a newer Python's syntax that this interpreter's ``compile()``
    cannot judge: ``startup_syntax_error`` asks an installed interpreter the target admits instead.
    Unknown -> False: keep checking, so a real syntax error is never waved through on a guess.
    """
    import platform

    spec = _requires_python_spec(pyproject)
    return spec is not None and not _spec_admits(spec, platform.python_version())


_PYTHON_VERSION = "import platform; print(platform.python_version())"
# Run by the target's interpreter: compile every startup file sent on stdin, print the first error.
_COMPILE_ALL = (
    "import base64, json, sys\n"
    "verdict = None\n"
    "for rel, data in json.load(sys.stdin).items():\n"
    "    try:\n"
    "        compile(base64.b64decode(data), rel, 'exec', dont_inherit=True)\n"
    "    except (SyntaxError, ValueError) as exc:\n"
    "        verdict = [rel, f'{type(exc).__name__}: {exc}']\n"
    "        break\n"
    "print(json.dumps(verdict))\n")


def _run_python(argv: list[str], *, stdin: str = "", timeout: float = 60) -> str | None:
    """Stdout of a read-only interpreter child (it never writes the checkout), None when it failed."""
    from hermes_cli._subprocess_compat import windows_hide_flags

    try:
        done = subprocess.run(argv, input=stdin, capture_output=True, text=True, encoding="utf-8",
                              errors="replace", timeout=timeout, creationflags=windows_hide_flags(), check=False)
    except (OSError, subprocess.SubprocessError):
        return None
    return done.stdout if done.returncode == 0 else None


def _admitted_python(spec: str) -> str | None:
    """An installed ``python3.N`` the target's ``requires-python`` admits (newest first), or None
    when this machine has none. No uv tier: Hermes never resolves the user's uv
    (tests/test_managed_runtime_resolution.py), and a missing interpreter only means this one judges."""
    from hermes_platform.resolver import locate_command

    candidates = [res.command[0] for minor in range(40, sys.version_info[1], -1)
                  if (res := locate_command(f"python3.{minor}")).command][:3]
    for python in candidates:
        version = (_run_python([python, "-I", "-c", _PYTHON_VERSION], timeout=30) or "").strip()
        if version and _spec_admits(spec, version):
            return python
    return None


def _compile_here(sources: dict[str, bytes]) -> tuple[str, str] | None:
    for rel, data in sources.items():
        try:
            compile(data, rel, "exec", dont_inherit=True)
        except (SyntaxError, ValueError) as exc:
            return rel, f"{type(exc).__name__}: {exc}"
    return None


def startup_syntax_error(sources: dict[str, bytes | None], pyproject: bytes | None) -> tuple[str, str] | None:
    """``(path, error)`` for the first startup file that does not compile, else None.

    Judged by the interpreter that will run the target: this one when the target's
    ``requires-python`` admits it, else an installed interpreter it admits (review N15: a
    conflict-marker scan is not a syntax check). With none installed, this interpreter still
    judges: code it parses is code a newer Python parses, and a refusal names the Python to install
    so the release can be judged by its own interpreter. ``None`` values (absent files) are skipped.
    """
    import base64
    import json
    import platform

    present = {rel: data for rel, data in sources.items() if data is not None}
    spec = _requires_python_spec(pyproject)
    if spec is None or _spec_admits(spec, platform.python_version()):
        return _compile_here(present)
    if python := _admitted_python(spec):
        payload = json.dumps({rel: base64.b64encode(data).decode("ascii") for rel, data in present.items()})
        out = _run_python([python, "-I", "-c", _COMPILE_ALL], stdin=payload, timeout=120)
        try:
            verdict = json.loads((out or "").strip().splitlines()[-1])
        except (IndexError, ValueError):
            verdict = False  # the target's interpreter gave no verdict: judge here (below)
        if verdict is None:
            return None
        if isinstance(verdict, list) and len(verdict) == 2:
            return str(verdict[0]), f"{verdict[1]} (judged by {python})"
    broken = _compile_here(present)
    if broken is None:
        return None
    return broken[0], (f"{broken[1]}\nJudged by Python {platform.python_version()}: the target requires Python "
                       f"{spec} and no installed interpreter satisfies it. Install one (`uv python install "
                       f"'{spec}'`) and re-run `hermes update` so the release is judged by its own Python.")


def read_target_files(git_cmd, root: Path, target_ref: str, relpaths) -> dict[str, bytes | None]:
    """``{rel: bytes}`` of each path as committed at ``target_ref`` (None: absent there or unreadable),
    in ONE ``git cat-file --batch`` instead of one ``git show`` per file."""
    names = list(dict.fromkeys(relpaths))
    found: dict[str, bytes | None] = dict.fromkeys(names)
    request = "".join(f"{target_ref}:{rel}\n" for rel in names).encode("utf-8")
    cp = run_git(git_cmd, ["cat-file", "--batch"], cwd=str(root), input=request, capture_output=True, timeout=120)
    out, pos = (cp.stdout or b"") if cp.returncode == 0 else b"", 0
    for rel in names:
        end = out.find(b"\n", pos)
        if end < 0:
            break
        header, pos = out[pos:end].split(), end + 1
        if len(header) == 3 and header[2].isdigit():  # "<oid> <type> <size>", then the bytes and "\n"
            size = int(header[2])
            if header[1] == b"blob":
                found[rel] = out[pos:pos + size]
            pos += size + 1
    return found


def target_syntax_error(git_cmd, root: Path, target_ref: str, relpaths) -> tuple[str, str] | None:
    """``(path, error)`` for the first startup-critical file that does not compile at ``target_ref``.

    Read from the object store, never written to the tree: this runs BEFORE HEAD moves, so a broken
    release is refused with the install untouched (the post-pull rollback stays as the backstop).
    ``startup_syntax_error`` judges it under the interpreter the target admits. A file absent at the
    target (or unreadable) is skipped: the post-pull guard has the last word.
    """
    try:
        files = read_target_files(git_cmd, root, target_ref, ["pyproject.toml", *relpaths])
    except subprocess.TimeoutExpired:
        # A blobless install (install.sh clones --filter=blob:none) lazily fetches each changed blob
        # in its own round trip; on a slow link that outlasts the limit. Unread, like an absent file.
        print("  ⚠ Syntax preflight skipped (slow object fetch); the post-update check still runs.")
        return None
    pyproject = files.pop("pyproject.toml", None)
    return startup_syntax_error(files, pyproject)


def tree_syntax_error(root: Path, relpaths) -> tuple[str, str] | None:
    """``startup_syntax_error`` for the files in the worktree at ``root`` (the post-move backstop)."""
    root = Path(root)
    sources: dict[str, bytes | None] = {}
    for rel in relpaths:
        try:
            sources[rel] = (root / rel).read_bytes() if (root / rel).is_file() else None
        except OSError as exc:
            return str(rel), f"could not read: {exc}"
    pyproject = root / "pyproject.toml"
    return startup_syntax_error(sources, pyproject.read_bytes() if pyproject.is_file() else None)


def head_and_branch(git_cmd, root: Path) -> tuple[str, str]:
    def out(*args: str) -> str:
        cp = run_git(git_cmd, [*args], cwd=str(root), capture_output=True, text=True, encoding="utf-8", errors="replace",
                            stdin=subprocess.DEVNULL, timeout=60)
        return cp.stdout.strip() if cp.returncode == 0 else ""

    return out("rev-parse", "HEAD"), out("rev-parse", "--abbrev-ref", "HEAD")


def checkout_untouched(git_cmd, root: Path, start: tuple[str, str] | None) -> bool:
    """True when the checkout is exactly where this run found it: same HEAD, same branch, no
    autostash taken, no tree move armed. Only then may a failed git update fall back to ZIP."""
    from hermes_cli.update_cmd_stash import _unrestored_autostash_notice

    if start is None or not start[0]:
        return False
    if _unrestored_autostash_notice() is not None or interrupted_pull_marker(root).is_file():
        return False
    return head_and_branch(git_cmd, root) == start


def begin_update_attempt() -> None:
    """A new ``hermes update`` attempt owns nothing yet: no snapshot, no stake, no run start.

    The state above is per attempt, not per process: a second update in one interpreter (a
    long-lived caller) that inherited the first one's snapshot and owner would hand back the
    first update's committed debt as its own undo when its move is refused (review O5).
    """
    global _armed_snapshot, _run_start, _obligation_root, _owner
    _owner = ""
    _armed_snapshot = None
    _armed_bytes.clear()
    _run_start = None
    _obligation_root = None


def record_run_start(git_cmd, root: Path) -> None:
    global _run_start, _obligation_root
    _run_start = (list(git_cmd), head_and_branch(git_cmd, root))
    _obligation_root = Path(root)


def run_checkout_untouched(root: Path) -> bool:
    """``checkout_untouched`` for this run; True before the checkout phase (nothing can have moved)."""
    if _run_start is None:
        return True
    return checkout_untouched(_run_start[0], Path(root), _run_start[1])


def preflight_refusal(git_cmd, root: Path, target_ref: str, critical_files) -> str | None:
    """Why this update must not start, checked BEFORE the first tree move; ``None`` to proceed.

    * a venv owned by another OS user (the completion child used to refuse only after the swap);
    * a target whose startup-critical modules do not compile (rollback used to be the only guard).
    """
    root = Path(root)
    if not _owns_live_checkout(root):
        try:
            from hermes_cli.venv_sync import refuse_foreign_owned_venv

            refuse_foreign_owned_venv(root)
        except ImportError:
            pass
        except Exception as exc:  # health: allow BLE001 -- fail closed: any probe error refuses before the first move
            _record_stop("venv_foreign_owner")
            return f"✗ {exc}"  # pm's refusal carries its own remediation text
    broken = target_syntax_error(git_cmd, root, target_ref, critical_files)
    if broken is not None:
        path, error = broken
        _record_stop("target_syntax_error")
        return (f"✗ The update target has a syntax error in a critical file:\n  {path}\n    "
                + "\n    ".join(error.splitlines()[:6]))
    return None
