"""Boot-time post-update bootstrap: identity, records, locks, single-flight. The record files are an optimization layer over idempotent steps; these tests assert the contracts that keep that safe: identity resolution from real git trees and stamps, record scoping (per-install AND per-home vs per-machine), and the lock protocol including the double-check under lock. """ import json import os import subprocess import time from pathlib import Path import pytest from hermes_cli import boot_bootstrap from hermes_cli.boot_bootstrap import ( _RecordLock, current_install_identity, needs_bootstrap, read_git_head, read_last_known, record_path, run_boot_bootstrap, write_record, ) def _git(args, cwd): env = dict(os.environ) env.update({ "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t", "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull, }) return subprocess.run( ["git", *args], cwd=cwd, env=env, capture_output=True, text=True, check=True ) @pytest.fixture def repo(tmp_path): root = tmp_path / "repo" root.mkdir() _git(["init", "-b", "main"], root) (root / "f.txt").write_text("1", encoding="utf-8") _git(["add", "."], root) _git(["commit", "-m", "one"], root) return root def _head_sha(root): return _git(["rev-parse", "HEAD"], root).stdout.strip() # ── read_git_head ──────────────────────────────────────────────────── def test_read_git_head_branch_ref(repo): assert read_git_head(repo) == _head_sha(repo) def test_read_git_head_detached(repo): sha = _head_sha(repo) _git(["checkout", "--detach", sha], repo) assert read_git_head(repo) == sha def test_read_git_head_packed_refs(repo): sha = _head_sha(repo) _git(["pack-refs", "--all"], repo) # Loose ref is gone; only packed-refs carries the branch now. assert not (repo / ".git" / "refs" / "heads" / "main").exists() assert read_git_head(repo) == sha def test_read_git_head_worktree_gitfile(repo, tmp_path): wt = tmp_path / "wt" _git(["worktree", "add", str(wt)], repo) assert (wt / ".git").is_file() # gitfile pointer, not a directory assert read_git_head(wt) == _head_sha(wt) def test_read_git_head_missing_and_garbage(tmp_path): assert read_git_head(tmp_path) is None (tmp_path / ".git").write_text("not a gitdir pointer", encoding="utf-8") assert read_git_head(tmp_path) is None # ── current_install_identity ───────────────────────────────────────── def test_identity_prefers_git(repo): assert current_install_identity(repo) == _head_sha(repo) def test_identity_sealed_stamp(tmp_path): (tmp_path / "install-stamp.json").write_text( json.dumps({"commit": "a" * 40, "distribution": "desktop-app"}), encoding="utf-8", ) assert current_install_identity(tmp_path) == "a" * 40 def test_identity_broken_tree_is_none(tmp_path): assert current_install_identity(tmp_path) is None (tmp_path / "install-stamp.json").write_text("garbage", encoding="utf-8") assert current_install_identity(tmp_path) is None # ── record paths ───────────────────────────────────────────────────── def test_record_paths_key_on_install_root(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) a = record_path(tmp_path / "install-a", "home") b = record_path(tmp_path / "install-b", "home") assert a != b assert a.parent == b.parent # same dir, different keys def test_home_records_differ_per_profile_machine_record_shared(tmp_path, monkeypatch): monkeypatch.setattr(Path, "home", lambda: tmp_path) base = tmp_path / ".hermes" profile = base / "profiles" / "coder" install = tmp_path / "install" monkeypatch.setenv("HERMES_HOME", str(base)) home_default = record_path(install, "home") machine_default = record_path(install, "machine") monkeypatch.setenv("HERMES_HOME", str(profile)) home_profile = record_path(install, "home") machine_profile = record_path(install, "machine") assert home_default != home_profile # each profile bootstraps its own home assert machine_default == machine_profile # machine record is shared def test_record_path_rejects_unknown_scope(tmp_path): with pytest.raises(ValueError): record_path(tmp_path, "galaxy") def test_symlinked_root_canonicalizes(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) real = tmp_path / "real-install" real.mkdir() link = tmp_path / "link-install" link.symlink_to(real) assert record_path(real, "home") == record_path(link, "home") # ── needs_bootstrap ────────────────────────────────────────────────── def test_needs_bootstrap_lifecycle(repo, tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) sha = _head_sha(repo) # No record yet → identity returned. assert needs_bootstrap(repo, "home") == sha write_record(repo, "home", sha) assert needs_bootstrap(repo, "home") is None # New commit → mismatch again. (repo / "f.txt").write_text("2", encoding="utf-8") _git(["add", "."], repo) _git(["commit", "-m", "two"], repo) assert needs_bootstrap(repo, "home") == _head_sha(repo) def test_needs_bootstrap_broken_tree_never_fires(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) assert needs_bootstrap(tmp_path / "nope", "home") is None # ── lock protocol ──────────────────────────────────────────────────── def test_lock_loser_skips(tmp_path): record = tmp_path / "r.json" first = _RecordLock(record) second = _RecordLock(record) assert first.acquire() assert not second.acquire() first.release() assert second.acquire() second.release() def test_stale_lock_is_broken(tmp_path): record = tmp_path / "r.json" lock_path = record.with_name(record.name + ".lock") lock_path.parent.mkdir(parents=True, exist_ok=True) lock_path.write_text( json.dumps({"pid": 1, "startedAt": time.time() - 3600}), encoding="utf-8" ) lock = _RecordLock(record) assert lock.acquire() lock.release() def test_fresh_lock_is_respected(tmp_path): record = tmp_path / "r.json" lock_path = record.with_name(record.name + ".lock") lock_path.parent.mkdir(parents=True, exist_ok=True) lock_path.write_text( json.dumps({"pid": os.getpid(), "startedAt": time.time()}), encoding="utf-8" ) assert not _RecordLock(record).acquire() # ── run_boot_bootstrap ─────────────────────────────────────────────── @pytest.fixture def fake_steps(monkeypatch): calls = {"home": 0, "machine": 0} def home_step(): calls["home"] += 1 return {"ok": True} def machine_step(): calls["machine"] += 1 return {"ok": True} from hermes_cli import post_update monkeypatch.setattr(post_update, "HOME_STEPS", (("h", home_step),)) monkeypatch.setattr(post_update, "MACHINE_STEPS", (("m", machine_step),)) # Machine steps run on a thread; make them synchronous for the test. # boot_bootstrap does `import threading` inside the function, so patching # the stdlib module's Thread attribute is what reaches it. import threading class _SyncThread: def __init__(self, target=None, args=(), **kw): self._target, self._args = target, args def start(self): if self._target is not None: self._target(*self._args) monkeypatch.setattr(threading, "Thread", _SyncThread) return calls def test_run_boot_bootstrap_runs_then_noops(repo, tmp_path, monkeypatch, fake_steps): monkeypatch.setattr(Path, "home", lambda: tmp_path) monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) first = run_boot_bootstrap(repo) assert fake_steps == {"home": 1, "machine": 1} assert first["home"] == {"h": {"ok": True}} assert first["machine"] == "deferred" second = run_boot_bootstrap(repo) assert fake_steps == {"home": 1, "machine": 1} # no re-run assert second == {"home": "skipped", "machine": "skipped"} def test_machine_step_runs_once_across_profiles(repo, tmp_path, monkeypatch, fake_steps): monkeypatch.setattr(Path, "home", lambda: tmp_path) base = tmp_path / ".hermes" monkeypatch.setenv("HERMES_HOME", str(base)) run_boot_bootstrap(repo) monkeypatch.setenv("HERMES_HOME", str(base / "profiles" / "coder")) run_boot_bootstrap(repo) # Each home bootstraps itself; the machine step fires once. assert fake_steps == {"home": 2, "machine": 1} def test_step_failure_still_writes_record(repo, tmp_path, monkeypatch): monkeypatch.setattr(Path, "home", lambda: tmp_path) monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) from hermes_cli import post_update def boom(): raise RuntimeError("step exploded") monkeypatch.setattr(post_update, "HOME_STEPS", (("boom", boom),)) monkeypatch.setattr(post_update, "MACHINE_STEPS", ()) run_boot_bootstrap(repo) record = read_last_known(record_path(repo, "home")) assert record["identity"] == _head_sha(repo) assert record["results"]["boom"]["ok"] is False # A broken step must not retrigger the slow path every boot. assert needs_bootstrap(repo, "home") is None def test_double_check_under_lock(repo, tmp_path, monkeypatch, fake_steps): """A racer that finished between our read and our acquire wins.""" monkeypatch.setattr(Path, "home", lambda: tmp_path) monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) sha = _head_sha(repo) real_acquire = _RecordLock.acquire def acquire_after_racer_finished(self): got = real_acquire(self) if got and self.path.name.endswith(".json.lock"): # Simulate the previous holder completing just before us. write_record(repo, "home", sha) return got monkeypatch.setattr(_RecordLock, "acquire", acquire_after_racer_finished) result = run_boot_bootstrap(repo) assert result["home"] == "done-by-other" assert fake_steps["home"] == 0 def test_maybe_run_never_raises(monkeypatch, tmp_path): monkeypatch.setattr( boot_bootstrap, "run_boot_bootstrap", lambda root: (_ for _ in ()).throw(RuntimeError("boom")), ) boot_bootstrap.maybe_run_boot_bootstrap(tmp_path) # must not raise