Files
hermes-agent/tests/hermes_cli/test_update_head_moved_gate.py
T
Teknium ba030bc0db fix(test-seams): monkeypatch.setattr facade aliases — also patch the defining module (57 files)
Tests did monkeypatch.setattr(<facade module>, name) where name is now defined in a
sibling module and the production path reads the sibling's binding. Where production
reads through BOTH bindings the setattr is duplicated onto the defining module (import
added next to the existing alias import); where only the sibling reads it the target is
repointed. Seams whose production readers go through the facade are left alone.
2026-09-03 19:33:28 -07:00

158 lines
6.4 KiB
Python

"""Tests for the post-pull HEAD-movement gate in ``hermes update``.
Issue #79678: a detached/pinned checkout can report "N new commit(s)"
against origin, run the ff-only merge successfully, and still sit on the
old commit afterward (the branch-switch step re-detaches to the raw SHA).
Before this guard ``hermes update`` printed "✓ Code updated!" and
reinstalled deps + rebuilt the desktop app against the stale tree — no
error, no warning. The gate compares the pre-pull and post-pull HEAD SHA
and fails loudly when the update was a no-op.
"""
from types import SimpleNamespace
import pytest
from hermes_cli import main as hermes_main
import hermes_cli.main_web_build as main_web_build
import hermes_cli.main_install_repair as main_install_repair
from hermes_cli import update_cmd
def _make_head_moved_side_effect(pre_sha="abc123", post_sha="def456"):
"""Simulate git commands where HEAD advances from pre_sha to post_sha."""
calls = {"n": 0}
def side_effect(cmd, **kwargs):
joined = " ".join(str(c) for c in cmd)
# git rev-parse --abbrev-ref HEAD (get current branch)
if "rev-parse" in joined and "--abbrev-ref" in joined:
return SimpleNamespace(returncode=0, stdout="main\n", stderr="")
# git rev-list HEAD..origin/main --count (behind count)
if "rev-list" in joined:
return SimpleNamespace(returncode=0, stdout="3\n", stderr="")
# git rev-parse HEAD — first call (pre-pull) returns pre_sha,
# subsequent calls (post-pull) return post_sha.
if joined.endswith("rev-parse HEAD"):
if calls["n"] == 0:
calls["n"] += 1
return SimpleNamespace(returncode=0, stdout=f"{pre_sha}\n", stderr="")
return SimpleNamespace(returncode=0, stdout=f"{post_sha}\n", stderr="")
# Everything else (merge, checkout, etc.) succeeds quietly.
return SimpleNamespace(returncode=0, stdout="", stderr="")
return side_effect
def _make_head_pinned_side_effect(sha="abc123"):
"""Simulate a detached checkout pinned to ``sha``: HEAD never moves."""
def side_effect(cmd, **kwargs):
joined = " ".join(str(c) for c in cmd)
if "rev-parse" in joined and "--abbrev-ref" in joined:
return SimpleNamespace(returncode=0, stdout="HEAD\n", stderr="")
if "rev-list" in joined:
return SimpleNamespace(returncode=0, stdout="3\n", stderr="")
if joined.endswith("rev-parse HEAD"):
return SimpleNamespace(returncode=0, stdout=f"{sha}\n", stderr="")
return SimpleNamespace(returncode=0, stdout="", stderr="")
return side_effect
def _patch_update_deps(monkeypatch, tmp_path, run_side_effect):
"""Patch the hermes_cli.main helpers ``_cmd_update_impl`` touches.
``_m()`` in update_cmd.py lazily returns hermes_cli.main, so patching
attributes on that module is the canonical test surface (matches
tests/hermes_cli/test_cmd_update.py).
"""
monkeypatch.setattr(hermes_main.subprocess, "run", run_side_effect)
monkeypatch.setattr(hermes_main, "PROJECT_ROOT", tmp_path)
(tmp_path / ".git").mkdir() # pass the "is a git repo" gate
monkeypatch.setattr(
hermes_main, "_resolve_update_branch", lambda args: "main"
)
monkeypatch.setattr(hermes_main, "_is_windows", lambda: False)
monkeypatch.setattr(main_install_repair, "_is_windows", lambda: False)
monkeypatch.setattr(
hermes_main, "_get_origin_url",
lambda *a, **k: "https://github.com/NousResearch/hermes-agent.git",
)
monkeypatch.setattr(update_cmd, "_is_fork", lambda *a, **k: False)
monkeypatch.setattr(
hermes_main, "_stash_local_changes_if_needed", lambda *a, **k: None
)
monkeypatch.setattr(hermes_main, "_clear_bytecode_cache", lambda *a, **k: 0)
monkeypatch.setattr(
hermes_main, "_record_bytecode_fingerprint", lambda *a, **k: None
)
monkeypatch.setattr(
main_web_build, "_record_bytecode_fingerprint", lambda *a, **k: None
)
monkeypatch.setattr(
hermes_main, "_run_pre_update_backup", lambda *a, **k: None
)
monkeypatch.setattr(
hermes_main, "_pause_windows_gateways_for_update", lambda: None
)
monkeypatch.setattr(
hermes_main, "_resume_windows_gateways_after_update", lambda *a, **k: None
)
# Short-circuit the long tail: dependency install + desktop build.
monkeypatch.setattr(hermes_main, "_write_update_incomplete_marker", lambda: None)
monkeypatch.setattr(hermes_main, "_clear_update_incomplete_marker", lambda: None)
monkeypatch.setattr(main_install_repair, "_clear_update_incomplete_marker", lambda: None)
# Gateway restart path (called after a successful update).
monkeypatch.setattr(update_cmd, "_finish_dashboard_update_cleanup", lambda *a, **k: None)
# Keep the (now surfaced — #78574) gateway auto-restart phase away from
# this machine's real gateways: discovery returns nothing, systemd is
# unsupported, so the phase is a clean no-op for both snapshots.
import hermes_cli.gateway as hermes_gateway
monkeypatch.setattr(
hermes_gateway, "find_gateway_pids", lambda all_profiles=False: []
)
monkeypatch.setattr(
hermes_gateway, "supports_systemd_services", lambda: False
)
monkeypatch.setattr(
hermes_gateway, "find_profile_gateway_processes", lambda *a, **k: []
)
def test_update_success_when_head_moves(monkeypatch, tmp_path, capsys):
"""When the pull advances HEAD, the update proceeds normally."""
args = SimpleNamespace(branch=None, yes=False, force=False, force_venv=False)
_patch_update_deps(monkeypatch, tmp_path, _make_head_moved_side_effect())
hermes_main.cmd_update(args) # completes normally (no SystemExit)
out = capsys.readouterr().out
assert "✓ Code updated!" in out
assert "Code did not move" not in out
def test_update_fails_loudly_when_head_pinned(monkeypatch, tmp_path, capsys):
"""A detached/pinned HEAD that never moves must fail loudly, not print
'✓ Code updated!' against the stale tree."""
args = SimpleNamespace(branch=None, yes=False, force=False, force_venv=False)
_patch_update_deps(monkeypatch, tmp_path, _make_head_pinned_side_effect())
with pytest.raises(SystemExit) as exc_info:
hermes_main.cmd_update(args)
assert exc_info.value.code == 1
out = capsys.readouterr().out
assert "Code did not move" in out
assert "✓ Code updated!" not in out
assert "checkout main" in out