`commit_obligations_armed()` had only test callers and `reset_for_tests()` was a bare alias of `begin_update_attempt()` (review K132361 P3). Both are gone, with no compat alias. The tests now reset through the real `begin_update_attempt()` and assert the obligations a run owes where they live: the completion-pending record and the host restart record on disk, not the module's in-memory snapshot.
435 lines
19 KiB
Python
435 lines
19 KiB
Python
"""The commit point's obligations: armed before the tree moves, handed back only when still ours.
|
|
|
|
``arm_commit_obligations`` owes the completion tail and the host fleet restart before git (or the
|
|
ZIP swap) writes a file; ``disarm_commit_obligations`` puts them back after a failure that left the
|
|
tree at its start commit. The host record is shared by every install of the OS user.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from hermes_cli import update_cmd_commit as commit
|
|
from hermes_cli.update_host_obligation import host_obligation_path
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _fresh_commit_point(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
|
|
(tmp_path / "home").mkdir()
|
|
commit.begin_update_attempt()
|
|
yield
|
|
commit.begin_update_attempt()
|
|
|
|
|
|
@pytest.fixture
|
|
def root(tmp_path) -> Path:
|
|
install = tmp_path / "install"
|
|
install.mkdir()
|
|
return install
|
|
|
|
|
|
def test_arm_refuses_when_no_store_accepts_the_fleet_restart(root):
|
|
"""Both the host record and the per-home breadcrumb unwritable: the move must not start (F26)."""
|
|
from hermes_cli.update_cmd_fleet import _fleet_restart_pending_marker_path
|
|
|
|
host_obligation_path().mkdir(parents=True) # a directory where the record goes
|
|
_fleet_restart_pending_marker_path().mkdir(parents=True)
|
|
|
|
with pytest.raises(OSError):
|
|
commit.arm_commit_obligations(root, "b" * 40)
|
|
|
|
|
|
def test_disarm_leaves_another_installs_newer_host_record(root):
|
|
"""A failed run hands back only what it armed: another install's record written since stays (F27)."""
|
|
host = host_obligation_path()
|
|
host.parent.mkdir(parents=True, exist_ok=True)
|
|
host.write_text("OLD", encoding="utf-8")
|
|
commit.arm_commit_obligations(root, "a" * 40)
|
|
host.write_text("OTHER-INSTALL", encoding="utf-8")
|
|
|
|
commit.disarm_commit_obligations()
|
|
|
|
assert host.read_text(encoding="utf-8-sig") == "OTHER-INSTALL"
|
|
|
|
|
|
def test_plain_arm_and_disarm_restores_what_the_run_found(root):
|
|
host = host_obligation_path()
|
|
host.parent.mkdir(parents=True, exist_ok=True)
|
|
host.write_text("OLD", encoding="utf-8")
|
|
commit.arm_commit_obligations(root, "a" * 40)
|
|
assert host.read_text(encoding="utf-8-sig") != "OLD"
|
|
|
|
commit.disarm_commit_obligations()
|
|
|
|
assert host.read_text(encoding="utf-8-sig") == "OLD"
|
|
|
|
|
|
def test_an_unreadable_record_refuses_the_arm_and_survives(root, monkeypatch):
|
|
"""A read error is not absence: guessing 'none' would delete the record on disarm (F28)."""
|
|
host = host_obligation_path()
|
|
host.parent.mkdir(parents=True, exist_ok=True)
|
|
host.write_text("OLD", encoding="utf-8")
|
|
real = Path.read_bytes
|
|
|
|
def flaky(self):
|
|
if self == host:
|
|
raise PermissionError(13, "sharing violation", str(self))
|
|
return real(self)
|
|
|
|
monkeypatch.setattr(Path, "read_bytes", flaky)
|
|
with pytest.raises(OSError):
|
|
commit.arm_commit_obligations(root, "a" * 40)
|
|
monkeypatch.setattr(Path, "read_bytes", real)
|
|
commit.disarm_commit_obligations()
|
|
|
|
assert host.read_text(encoding="utf-8-sig") == "OLD"
|
|
|
|
|
|
@pytest.mark.platforms("posix") # unprivileged symlinks
|
|
def test_disarm_never_writes_through_a_planted_restore_alias(root, tmp_path):
|
|
"""A same-user link at a temp name must not redirect the restore (N05)."""
|
|
host = host_obligation_path()
|
|
host.parent.mkdir(parents=True, exist_ok=True)
|
|
host.write_text("ORIGINAL", encoding="utf-8")
|
|
sentinel = tmp_path / "sentinel"
|
|
sentinel.write_text("PRECIOUS", encoding="utf-8")
|
|
commit.arm_commit_obligations(root, "a" * 40)
|
|
for alias in (host.with_name(host.name + ".restore"), host.with_name(f".{host.name}.{os.getpid()}.restore")):
|
|
alias.symlink_to(sentinel)
|
|
|
|
commit.disarm_commit_obligations()
|
|
|
|
assert sentinel.read_text(encoding="utf-8-sig") == "PRECIOUS"
|
|
assert not host.is_symlink() and host.read_text(encoding="utf-8-sig") == "ORIGINAL"
|
|
|
|
|
|
def _git(cwd: Path, *args: str) -> str:
|
|
env = {**os.environ, "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_NOSYSTEM": "1",
|
|
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@example.invalid",
|
|
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@example.invalid"}
|
|
return subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True,
|
|
encoding="utf-8", env=env).stdout.strip()
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("git") is None, reason="needs git")
|
|
def test_a_refused_pull_after_the_branch_switch_owes_the_head_the_switch_landed_on(tmp_path, monkeypatch):
|
|
"""CP0 switched a parked feature branch (X) to main (A); CP1's arm for B then failed. The
|
|
obligation used to stay on B, which the checkout at A never contains, while disarm refused
|
|
(HEAD != X): an undischargeable debt (review C1). It must name A, and the refusal must not
|
|
claim the checkout was untouched."""
|
|
import hermes_cli.main as hermes_main
|
|
from hermes_cli import update_cmd
|
|
from hermes_cli._early_recovery import interrupted_pull_marker
|
|
from hermes_cli.update_host_obligation import read_host_obligation
|
|
|
|
up, clone = tmp_path / "up", tmp_path / "clone"
|
|
up.mkdir()
|
|
_git(up, "init", "-q", "-b", "main")
|
|
(up / "f").write_text("A", encoding="utf-8")
|
|
_git(up, "add", "-A")
|
|
_git(up, "commit", "-qm", "A")
|
|
a = _git(up, "rev-parse", "HEAD")
|
|
_git(tmp_path, "clone", "-q", str(up), str(clone))
|
|
_git(clone, "checkout", "-q", "-b", "feat")
|
|
(clone / "g").write_text("X", encoding="utf-8")
|
|
_git(clone, "add", "-A")
|
|
_git(clone, "commit", "-qm", "X")
|
|
x = _git(clone, "rev-parse", "HEAD")
|
|
(up / "f").write_text("B", encoding="utf-8")
|
|
_git(up, "commit", "-qam", "B")
|
|
b = _git(up, "rev-parse", "HEAD")
|
|
_git(clone, "fetch", "-q", "origin")
|
|
monkeypatch.setattr(hermes_main, "PROJECT_ROOT", clone)
|
|
monkeypatch.setattr(commit, "_owns_live_checkout", lambda _root: False)
|
|
monkeypatch.chdir(clone)
|
|
|
|
commit.record_run_start(["git"], clone)
|
|
switched = update_cmd._switch_branch_at_commit_point(["git"], "main", "origin/main", pre=x, stash=None)
|
|
assert switched.returncode == 0 and _git(clone, "rev-parse", "HEAD") == a
|
|
interrupted_pull_marker(clone).mkdir() # CP1's marker cannot be written
|
|
reason = commit.arm_commit_point(["git"], clone, b, pre=a, target=b, stash=None)
|
|
|
|
assert reason and "the checkout was not changed" not in reason and a[:10] in reason
|
|
assert (read_host_obligation() or {}).get("expected_sha") == a
|
|
|
|
|
|
def _run_state() -> dict:
|
|
return {name: getattr(commit, name, None) for name in ("_armed_snapshot", "_owner")} | {
|
|
"_armed_bytes": dict(commit._armed_bytes)}
|
|
|
|
|
|
def _enter_run(state: dict) -> None:
|
|
for name, value in state.items():
|
|
if name == "_armed_bytes":
|
|
commit._armed_bytes.clear()
|
|
commit._armed_bytes.update(value)
|
|
else:
|
|
setattr(commit, name, value)
|
|
|
|
|
|
def test_one_installs_disarm_never_deletes_another_installs_debt_for_the_same_sha(root, tmp_path):
|
|
"""Install A and install B both arm SHA X. Same-SHA arms share one host record, so a byte
|
|
compare cannot tell them apart: A's failed run used to delete the record B still owes through
|
|
(review C2). A's disarm hands back only A's stake; the last owner to leave puts back what the first found."""
|
|
from hermes_cli.update_host_obligation import read_host_obligation
|
|
|
|
other = tmp_path / "other-install"
|
|
other.mkdir()
|
|
commit.arm_commit_obligations(root, "a" * 40) # install A
|
|
run_a = _run_state()
|
|
commit.begin_update_attempt()
|
|
commit.arm_commit_obligations(other, "a" * 40) # install B, same pulled SHA
|
|
run_b = _run_state()
|
|
|
|
_enter_run(run_a)
|
|
commit.disarm_commit_obligations()
|
|
assert (read_host_obligation() or {}).get("expected_sha") == "a" * 40 # B still owes it
|
|
|
|
_enter_run(run_b)
|
|
commit.disarm_commit_obligations()
|
|
assert not host_obligation_path().exists() # the last owner puts back what the FIRST one found
|
|
|
|
|
|
def _abc_clone(tmp_path, monkeypatch):
|
|
"""Upstream commits A, B, C; a clone of it with PROJECT_ROOT pointed at it. Returns (clone, a, b, c)."""
|
|
import hermes_cli.main as hermes_main
|
|
|
|
up, clone = tmp_path / "up", tmp_path / "clone"
|
|
up.mkdir()
|
|
_git(up, "init", "-q", "-b", "main")
|
|
shas = []
|
|
for name in "ABC":
|
|
(up / "f").write_text(name, encoding="utf-8")
|
|
_git(up, "add", "-A")
|
|
_git(up, "commit", "-qm", name)
|
|
shas.append(_git(up, "rev-parse", "HEAD"))
|
|
_git(tmp_path, "clone", "-q", str(up), str(clone))
|
|
monkeypatch.setattr(hermes_main, "PROJECT_ROOT", clone)
|
|
monkeypatch.setattr(commit, "_owns_live_checkout", lambda _root: False)
|
|
monkeypatch.chdir(clone)
|
|
return (clone, *shas)
|
|
|
|
|
|
def _move_ref_after_arm(monkeypatch, clone, ref, sha):
|
|
"""The race: ``ref`` moves to ``sha`` right after the commit point armed (marker + debt)."""
|
|
real = commit.arm_commit_point
|
|
|
|
def arm_then_move(*args, **kwargs):
|
|
refused = real(*args, **kwargs)
|
|
_git(clone, "update-ref", ref, sha)
|
|
return refused
|
|
|
|
monkeypatch.setattr(commit, "arm_commit_point", arm_then_move)
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("git") is None, reason="needs git")
|
|
def test_the_pull_merges_the_armed_commit_not_a_tracking_ref_that_moved(tmp_path, monkeypatch):
|
|
"""CP1 armed B, then origin/main moved to C before ``merge --ff-only origin/main``: HEAD landed C
|
|
while the debt named B and the marker was gone (review O3). The merge names the armed OID."""
|
|
from hermes_cli import update_cmd
|
|
from hermes_cli._early_recovery import interrupted_pull_marker
|
|
from hermes_cli.update_host_obligation import read_host_obligation
|
|
|
|
clone, a, b, c = _abc_clone(tmp_path, monkeypatch)
|
|
_git(clone, "reset", "-q", "--hard", a)
|
|
_git(clone, "update-ref", "refs/remotes/origin/main", b)
|
|
commit.record_run_start(["git"], clone)
|
|
_move_ref_after_arm(monkeypatch, clone, "refs/remotes/origin/main", c)
|
|
update_cmd._pull_updates(["git"], "main", None, prompt_for_restore=False, gw_input_fn=None,
|
|
discard_local_changes=False, keep_stash=False)
|
|
assert _git(clone, "rev-parse", "HEAD") == b
|
|
assert (read_host_obligation() or {}).get("expected_sha") == b
|
|
assert not interrupted_pull_marker(clone).exists()
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("git") is None, reason="needs git")
|
|
def test_a_branch_that_moves_during_the_switch_leaves_head_and_debt_agreeing(tmp_path, monkeypatch):
|
|
"""CP0 resolved main to A and armed A, then main moved to B before ``checkout main``: the switch
|
|
landed B, reported success and dropped the marker while the debt named A (review O2). It must
|
|
refuse, with the debt following the HEAD it landed on."""
|
|
from hermes_cli import update_cmd
|
|
from hermes_cli._early_recovery import interrupted_pull_marker
|
|
from hermes_cli.update_host_obligation import read_host_obligation
|
|
|
|
clone, a, b, _c = _abc_clone(tmp_path, monkeypatch)
|
|
_git(clone, "reset", "-q", "--hard", a)
|
|
_git(clone, "checkout", "-q", "-b", "feat")
|
|
(clone / "g").write_text("X", encoding="utf-8")
|
|
_git(clone, "add", "-A")
|
|
_git(clone, "commit", "-qm", "X")
|
|
x = _git(clone, "rev-parse", "HEAD")
|
|
commit.record_run_start(["git"], clone)
|
|
_move_ref_after_arm(monkeypatch, clone, "refs/heads/main", b)
|
|
switched = update_cmd._switch_branch_at_commit_point(["git"], "main", "origin/main", pre=x, stash=None)
|
|
assert switched.returncode == 1 and "moved" in switched.stderr
|
|
assert _git(clone, "rev-parse", "HEAD") == b
|
|
assert (read_host_obligation() or {}).get("expected_sha") == b
|
|
assert not interrupted_pull_marker(clone).exists()
|
|
|
|
|
|
def _repo_at_a(tmp_path, monkeypatch) -> tuple[Path, str]:
|
|
repo = tmp_path / "repo"
|
|
repo.mkdir()
|
|
_git(repo, "init", "-q", "-b", "main")
|
|
(repo / "f").write_text("A", encoding="utf-8")
|
|
_git(repo, "add", "-A")
|
|
_git(repo, "commit", "-qm", "A")
|
|
monkeypatch.setattr(commit, "_owns_live_checkout", lambda _root: False)
|
|
return repo, _git(repo, "rev-parse", "HEAD")
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("git") is None, reason="needs git")
|
|
def test_a_retarget_within_one_run_keeps_the_host_records_saved_baseline(tmp_path, monkeypatch):
|
|
"""One run arms B, then retargets to C (CP0 then the pull), and the checkout ends back on its
|
|
start commit A. The second arm used to overwrite the saved baseline with "absent", so the
|
|
disarm deleted the restart debt that stood before the run (review R1)."""
|
|
host = host_obligation_path()
|
|
host.parent.mkdir(parents=True, exist_ok=True)
|
|
host.write_bytes(b"PRIOR-DEBT")
|
|
repo, _a = _repo_at_a(tmp_path, monkeypatch)
|
|
commit.record_run_start(["git"], repo)
|
|
|
|
commit.arm_commit_obligations(repo, "b" * 40)
|
|
commit.arm_commit_obligations(repo, "c" * 40)
|
|
commit.disarm_commit_obligations()
|
|
|
|
assert host.read_bytes() == b"PRIOR-DEBT"
|
|
|
|
|
|
def test_a_later_runs_failed_retarget_puts_back_the_earlier_runs_debt(root):
|
|
"""Run 1 committed B and left its restart owed; run 2 (a new owner) armed C and failed before
|
|
its move. Its release must put back run 1's record byte for byte, not delete it (review R1)."""
|
|
host = host_obligation_path()
|
|
host.parent.mkdir(parents=True, exist_ok=True)
|
|
host.write_bytes(b"PRIOR-DEBT")
|
|
commit.arm_commit_obligations(root, "b" * 40) # run 1: its move commits, nothing hands back
|
|
run_one_record = host.read_bytes()
|
|
commit.begin_update_attempt()
|
|
|
|
commit.arm_commit_obligations(root, "c" * 40) # run 2
|
|
commit.disarm_commit_obligations()
|
|
|
|
assert host.read_bytes() == run_one_record
|
|
|
|
|
|
def test_an_owner_leaving_a_shared_record_for_another_target_hands_the_others_stake_back(root, tmp_path):
|
|
"""Installs X and Y joined one record for SHA S; X then retargets to T and fails. Y's stake
|
|
(and the first owner's baseline) comes back, and Y's own release then restores what stood
|
|
before either of them (review R1)."""
|
|
from hermes_cli.update_host_obligation import read_host_obligation
|
|
|
|
host = host_obligation_path()
|
|
host.parent.mkdir(parents=True, exist_ok=True)
|
|
host.write_bytes(b"PRIOR-DEBT")
|
|
other = tmp_path / "other-install"
|
|
other.mkdir()
|
|
commit.arm_commit_obligations(other, "a" * 40) # install Y
|
|
run_y = _run_state()
|
|
commit.begin_update_attempt()
|
|
commit.arm_commit_obligations(root, "a" * 40) # install X joins
|
|
commit.arm_commit_obligations(root, "d" * 40) # X retargets
|
|
commit.disarm_commit_obligations()
|
|
|
|
record = read_host_obligation() or {}
|
|
assert record.get("expected_sha") == "a" * 40 and record.get("owners") == [run_y["_owner"]]
|
|
_enter_run(run_y)
|
|
commit.disarm_commit_obligations()
|
|
assert host.read_bytes() == b"PRIOR-DEBT"
|
|
|
|
|
|
def test_a_second_update_in_one_process_never_hands_back_the_first_updates_debt(root, monkeypatch):
|
|
"""Update 1 moved to B and left its tail and restart owed; update 2, in the same interpreter,
|
|
armed C and was refused before its move. It used to reuse update 1's snapshot and owner and
|
|
delete B's debt as its own undo (review O5). ``_cmd_update_impl`` starts a fresh attempt."""
|
|
from types import SimpleNamespace
|
|
|
|
from hermes_cli import update_cmd
|
|
from hermes_cli.venv_sync import completion_pending_path
|
|
|
|
commit.arm_commit_obligations(root, "b" * 40) # update 1: committed, nothing handed back
|
|
tail, host = completion_pending_path(root), host_obligation_path()
|
|
owed = {tail: tail.read_bytes(), host: host.read_bytes()}
|
|
|
|
class _Entered(Exception):
|
|
pass
|
|
|
|
def stop(_root):
|
|
raise _Entered
|
|
|
|
monkeypatch.setattr(update_cmd, "git_operation_in_progress", stop)
|
|
with pytest.raises(_Entered): # update 2's entry, stopped right after the attempt begins
|
|
update_cmd._cmd_update_impl(SimpleNamespace(), gateway_mode=False)
|
|
commit.arm_commit_obligations(root, "c" * 40)
|
|
commit.disarm_commit_obligations()
|
|
|
|
assert {path: path.read_bytes() if path.exists() else None for path in owed} == owed
|
|
|
|
|
|
@pytest.mark.platforms("posix") # unprivileged symlinks
|
|
@pytest.mark.parametrize("kind", ["regular", "symlink", "hardlink", "none"])
|
|
def test_disarm_restores_through_an_unpredictable_temp_and_leaves_every_sibling(root, tmp_path, kind):
|
|
"""The restore used a fixed ``.<name>.<pid>.restore`` temp and unlinked whatever stood there
|
|
first: a user's ordinary file of that name was deleted (review O1). Nothing at the old name, or
|
|
any alias target, is touched; the record still comes back."""
|
|
from hermes_cli.venv_sync import completion_pending_path
|
|
|
|
tail = completion_pending_path(root)
|
|
tail.parent.mkdir(parents=True, exist_ok=True)
|
|
tail.write_bytes(b"OLD-TAIL")
|
|
sentinel = tmp_path / "sentinel"
|
|
sentinel.write_bytes(b"PRECIOUS")
|
|
commit.arm_commit_obligations(root, "a" * 40)
|
|
planted = tail.with_name(f".{tail.name}.{os.getpid()}.restore")
|
|
if kind == "regular":
|
|
planted.write_bytes(b"USER-FILE")
|
|
elif kind == "symlink":
|
|
planted.symlink_to(sentinel)
|
|
elif kind == "hardlink":
|
|
os.link(sentinel, planted)
|
|
|
|
commit.disarm_commit_obligations()
|
|
|
|
assert tail.read_bytes() == b"OLD-TAIL" and not tail.is_symlink()
|
|
assert sentinel.read_bytes() == b"PRECIOUS"
|
|
if kind == "regular":
|
|
assert planted.read_bytes() == b"USER-FILE"
|
|
elif kind != "none":
|
|
assert os.path.lexists(planted) and planted.read_bytes() == b"PRECIOUS"
|
|
assert sorted(p.name for p in tail.parent.iterdir() if p.name.endswith(".restore")) == (
|
|
[planted.name] if kind != "none" else [])
|
|
|
|
|
|
def test_an_arm_racing_a_release_is_never_undone_by_it(root, monkeypatch):
|
|
"""Run X's release judged the record (X its last owner, nothing found before it); another
|
|
install armed SHA S2 before X's release acted, and X's unlink then deleted that fresh debt: a
|
|
look-then-write with no compare-and-swap (kshitijk4poor F22/N05). Release and arm now judge and
|
|
write under one mutex, so the racing arm lands after the release, never under it."""
|
|
import threading
|
|
|
|
from hermes_cli.update_host_obligation import read_host_obligation, write_host_obligation
|
|
|
|
commit.arm_commit_obligations(root, "a" * 40) # run X, over an absent record
|
|
host, real_unlink, racer = host_obligation_path(), Path.unlink, []
|
|
|
|
def unlink_after_a_racing_arm(self, *args, **kwargs):
|
|
if self == host and not racer:
|
|
racer.append(threading.Thread(target=write_host_obligation,
|
|
kwargs={"expected_sha": "e" * 40, "owner": "other-install"}))
|
|
racer[0].start()
|
|
racer[0].join(timeout=1.0) # under the mutex it can only wait for this release
|
|
return real_unlink(self, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Path, "unlink", unlink_after_a_racing_arm)
|
|
commit.disarm_commit_obligations()
|
|
racer[0].join(timeout=15)
|
|
monkeypatch.setattr(Path, "unlink", real_unlink)
|
|
|
|
record = read_host_obligation() or {}
|
|
assert record.get("expected_sha") == "e" * 40 and record.get("owners") == ["other-install"]
|