"""Regression tests for the parked-branch guard in ``hermes update``. Live incident (2026-08-17, Teknium's Linux box): the source checkout was parked on a stale feature branch (``claude-code-inspired/local-terminal- memory-limit``, days behind main) left there by earlier tooling. ``hermes update`` autostashed, refreshed lazy backends, synced skills and printed "✓ Code updated!" / "✓ Update complete!" — while the checkout stayed on the stale branch with none of main's new code. Two sessions burned time on "the fix is missing" confusion that was really this. The guard (``_assess_parked_branch_switch``): - clean tree + branch fully merged into origin/ → safe to auto-switch back to the target (and STAY there — no switch-back). - dirty tree, unmerged commits, git failure, or the ``updates.auto_switch_parked_branch: false`` opt-out → do NOT touch the branch; warn loudly and mark the code update SKIPPED. These tests run the guard against REAL git repositories (init, commit, branch, clone) — not mocked subprocess.run — so they exercise the actual ``git status`` / ``git cherry`` semantics the guard depends on. """ import subprocess from types import SimpleNamespace import pytest from hermes_cli import main as hermes_main from hermes_cli import update_cmd GIT = ["git"] def _git(cwd, *args, check=True): return subprocess.run( GIT + list(args), cwd=cwd, capture_output=True, text=True, check=check, ) @pytest.fixture() def repo_pair(tmp_path): """A real origin repo + local clone, with main two commits ahead of the clone's parked state. Returns (clone_path,). The clone starts parked on feature branch ``old-feature`` cut from the first commit; origin/main has moved on. """ origin = tmp_path / "origin" origin.mkdir() _git(origin, "init", "-q", "-b", "main") _git(origin, "config", "user.email", "test@example.com") _git(origin, "config", "user.name", "Test") (origin / "a.txt").write_text("one\n") _git(origin, "add", "a.txt") _git(origin, "commit", "-qm", "c1") clone = tmp_path / "clone" _git(tmp_path, "clone", "-q", str(origin), str(clone)) _git(clone, "config", "user.email", "test@example.com") _git(clone, "config", "user.name", "Test") # Park the clone on a feature branch cut at c1. _git(clone, "checkout", "-qb", "old-feature") # main advances upstream (two commits). (origin / "a.txt").write_text("two\n") _git(origin, "commit", "-aqm", "c2") (origin / "b.txt").write_text("three\n") _git(origin, "add", "b.txt") _git(origin, "commit", "-qm", "c3") _git(clone, "fetch", "-q", "origin", "main") return clone @pytest.fixture(autouse=True) def _no_config(monkeypatch): """Isolate the guard from the machine's real config.yaml.""" import hermes_cli.config as hermes_config monkeypatch.setattr(hermes_config, "load_config", lambda: {}) # --------------------------------------------------------------------------- # _assess_parked_branch_switch against real repos # --------------------------------------------------------------------------- def test_clean_fully_merged_branch_is_safe_to_switch(repo_pair): """Parked branch == ancestor of origin/main, clean tree → auto-switch.""" safe, reason = update_cmd._assess_parked_branch_switch( GIT, repo_pair, "old-feature", "main" ) assert safe is True assert reason == "" def test_dirty_tree_blocks_auto_switch(repo_pair): """Uncommitted changes on the parked branch → do not touch it.""" (repo_pair / "a.txt").write_text("local edit\n") safe, reason = update_cmd._assess_parked_branch_switch( GIT, repo_pair, "old-feature", "main" ) assert safe is False assert reason == "dirty" def test_untracked_file_blocks_auto_switch(repo_pair): """Untracked files count as dirty too — they'd ride along on checkout.""" (repo_pair / "scratch.py").write_text("wip\n") safe, reason = update_cmd._assess_parked_branch_switch( GIT, repo_pair, "old-feature", "main" ) assert safe is False assert reason == "dirty" def test_unmerged_commits_switch_with_kept_notice(repo_pair): """Commits on the parked branch not in origin/main: still safe to switch (checkout keeps them on the branch) — reason carries the count so the caller prints the loud 'kept' notice. Non-interactive callers (desktop update button, gateway /update, cron) depend on this: they cannot resolve a skip.""" (repo_pair / "feature.txt").write_text("unmerged work\n") _git(repo_pair, "add", "feature.txt") _git(repo_pair, "commit", "-qm", "feature work") safe, reason = update_cmd._assess_parked_branch_switch( GIT, repo_pair, "old-feature", "main" ) assert safe is True assert reason == "unmerged:1" def test_equivalent_cherry_picked_commit_is_still_safe(repo_pair): """A commit whose patch already landed upstream (git cherry '-') does not block the switch — only genuinely unmerged '+' commits do.""" # Cherry-pick origin/main's c2 onto the parked branch: patch-identical. _git(repo_pair, "cherry-pick", "origin/main~1") safe, reason = update_cmd._assess_parked_branch_switch( GIT, repo_pair, "old-feature", "main" ) assert safe is True assert reason == "" def test_config_opt_out_blocks_auto_switch(repo_pair, monkeypatch): """updates.auto_switch_parked_branch: false disables auto-switch even when the branch is clean and merged.""" import hermes_cli.config as hermes_config monkeypatch.setattr( hermes_config, "load_config", lambda: {"updates": {"auto_switch_parked_branch": False}}, ) safe, reason = update_cmd._assess_parked_branch_switch( GIT, repo_pair, "old-feature", "main" ) assert safe is False assert reason == "disabled" def test_missing_origin_ref_is_unverifiable(repo_pair): """If origin/ can't be resolved, the guard refuses to switch.""" safe, reason = update_cmd._assess_parked_branch_switch( GIT, repo_pair, "old-feature", "no-such-branch" ) assert safe is False assert reason == "unverifiable" # --------------------------------------------------------------------------- # Skip warning content # --------------------------------------------------------------------------- def test_skip_warning_names_branch_behind_count_and_commands(repo_pair, capsys): update_cmd._print_parked_branch_skip_warning( GIT, repo_pair, "old-feature", "main", "dirty" ) out = capsys.readouterr().out assert "CODE UPDATE SKIPPED" in out assert "old-feature" in out assert "2 commit(s) BEHIND" in out assert f"git -C {repo_pair} checkout main && hermes update" in out def test_skip_warning_dirty_reason(repo_pair, capsys): update_cmd._print_parked_branch_skip_warning( GIT, repo_pair, "old-feature", "main", "dirty" ) out = capsys.readouterr().out assert "uncommitted changes" in out def test_kept_notice_names_branch_count_and_recovery(capsys): update_cmd._print_parked_branch_kept_notice("old-feature", "main", "3") out = capsys.readouterr().out assert "parked on 'old-feature'" in out assert "3 commit(s) not merged into origin/main" in out assert "safe on 'old-feature'" in out assert "git checkout old-feature" in out assert "CODE UPDATE SKIPPED" not in out # --------------------------------------------------------------------------- # Summary branch/HEAD visibility # --------------------------------------------------------------------------- def test_branch_head_label_reflects_real_checkout(repo_pair): label = update_cmd._branch_head_label(GIT, repo_pair) short = _git(repo_pair, "rev-parse", "--short", "HEAD").stdout.strip() assert label == f"old-feature @ {short}" def test_branch_head_label_detached(repo_pair): _git(repo_pair, "checkout", "-q", "--detach") label = update_cmd._branch_head_label(GIT, repo_pair) assert label is not None assert label.startswith("detached @ ") def test_branch_head_suffix_empty_on_non_repo(tmp_path): assert update_cmd._branch_head_suffix(GIT, tmp_path / "not-a-repo") == "" def test_print_update_completion_carries_branch_and_sha( repo_pair, monkeypatch, capsys ): monkeypatch.setattr(hermes_main, "PROJECT_ROOT", repo_pair) update_cmd._print_update_completion("✓ Update complete!") out = capsys.readouterr().out short = _git(repo_pair, "rev-parse", "--short", "HEAD").stdout.strip() assert f"✓ Update complete! [old-feature @ {short}]" in out # --------------------------------------------------------------------------- # Full update flow: parked branch dirty/unmerged → SKIPPED, no false success # --------------------------------------------------------------------------- def _patch_update_flow(monkeypatch, repo, run_real_git=True): """Point _cmd_update_impl at the real repo and neuter the long tail. Matches the monkeypatch surface of test_update_head_moved_gate.py, but keeps REAL subprocess.run so the git plumbing runs against the fixture repo (the whole point of these regressions). """ monkeypatch.setattr(hermes_main, "PROJECT_ROOT", repo) monkeypatch.setattr(hermes_main, "_resolve_update_branch", lambda args: "main") monkeypatch.setattr(hermes_main, "_is_windows", lambda: False) monkeypatch.setattr( hermes_main, "_get_origin_url", lambda *a, **k: "https://github.com/NousResearch/hermes-agent.git", ) monkeypatch.setattr(hermes_main, "_is_fork", lambda *a, **k: False) monkeypatch.setattr(hermes_main, "_discard_lockfile_churn", lambda *a, **k: None) monkeypatch.setattr(update_cmd, "_discard_lockfile_churn", lambda *a, **k: None) monkeypatch.setattr(update_cmd, "_normalize_managed_eol", 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(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 ) monkeypatch.setattr(hermes_main, "_capture_active_lazy_features", lambda: []) monkeypatch.setattr(hermes_main, "_capture_active_tool_dependencies", lambda: []) def test_update_skips_and_warns_on_dirty_parked_branch( repo_pair, monkeypatch, capsys ): """Tonight's incident shape: parked branch + dirty tree. The update must NOT print '✓ Code updated!', must warn loudly, and must exit non-zero with the branch named in the summary.""" (repo_pair / "a.txt").write_text("local edit\n") _patch_update_flow(monkeypatch, repo_pair) args = SimpleNamespace(branch=None, yes=False, force=False, force_venv=False) with pytest.raises(SystemExit) as exc_info: hermes_main.cmd_update(args) assert exc_info.value.code == 1 out = capsys.readouterr().out assert "CODE UPDATE SKIPPED" in out assert "old-feature" in out assert "code update SKIPPED" in out assert "✓ Code updated!" not in out assert "✓ Update complete!" not in out # Branch untouched. branch = _git(repo_pair, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip() assert branch == "old-feature" # No autostash was created — the guard fires before any stash. stashes = _git(repo_pair, "stash", "list").stdout.strip() assert stashes == "" def test_update_switches_unmerged_parked_branch_with_kept_notice( repo_pair, monkeypatch, capsys ): """Default strategy ("switch"): clean tree + unmerged commits → the update proceeds (non-interactive callers like the desktop update button cannot resolve a skip), prints the loud 'kept' notice, ends on main fast-forwarded to origin/main, and the commits stay on the parked branch untouched.""" (repo_pair / "feature.txt").write_text("unmerged work\n") _git(repo_pair, "add", "feature.txt") _git(repo_pair, "commit", "-qm", "feature work") feature_sha = _git(repo_pair, "rev-parse", "old-feature").stdout.strip() _patch_update_flow(monkeypatch, repo_pair) class _StopFlow(Exception): pass monkeypatch.setattr( hermes_main, "_abort_dependency_sync_if_self_locked", lambda *a, **k: (_ for _ in ()).throw(_StopFlow()), ) args = SimpleNamespace(branch=None, yes=False, force=False, force_venv=False) with pytest.raises(_StopFlow): hermes_main.cmd_update(args) out = capsys.readouterr().out assert "1 commit(s) not merged into origin/main" in out assert "safe on 'old-feature'" in out assert "CODE UPDATE SKIPPED" not in out assert "updating it in place" not in out # Ends on main, fast-forwarded. assert ( _git(repo_pair, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip() == "main" ) head = _git(repo_pair, "rev-parse", "HEAD").stdout.strip() remote = _git(repo_pair, "rev-parse", "origin/main").stdout.strip() assert head == remote # The unmerged commit is still exactly where it was, on the branch. assert ( _git(repo_pair, "rev-parse", "old-feature").stdout.strip() == feature_sha ) def test_update_updates_unmerged_branch_in_place_when_configured( repo_pair, monkeypatch, capsys ): """updates.parked_branch_strategy: update_in_place — a maintained custom branch (local patches on top of main) is updated in place from origin/ instead of switched away from. The running code must advance (origin/main's files arrive) AND the local commits must survive, with the checkout never moving.""" import hermes_cli.config as hermes_config monkeypatch.setattr( hermes_config, "load_config", lambda: {"updates": {"parked_branch_strategy": "update_in_place"}}, ) (repo_pair / "feature.txt").write_text("unmerged work\n") _git(repo_pair, "add", "feature.txt") _git(repo_pair, "commit", "-qm", "feature work") _patch_update_flow(monkeypatch, repo_pair) # Stop right after the pull/branch logic, before dependency install. class _StopFlow(Exception): pass monkeypatch.setattr( hermes_main, "_abort_dependency_sync_if_self_locked", lambda *a, **k: (_ for _ in ()).throw(_StopFlow()), ) args = SimpleNamespace(branch=None, yes=False, force=False, force_venv=False) with pytest.raises(_StopFlow): hermes_main.cmd_update(args) out = capsys.readouterr().out assert "updating it in place" in out assert "CODE UPDATE SKIPPED" not in out # The checkout never moved. assert ( _git(repo_pair, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip() == "old-feature" ) # origin/main's code actually arrived (b.txt lands with c3)... assert (repo_pair / "b.txt").exists() assert (repo_pair / "a.txt").read_text() == "two\n" # ...and the branch's own commit survived it. assert (repo_pair / "feature.txt").read_text() == "unmerged work\n" assert "feature work" in _git(repo_pair, "log", "--oneline").stdout def test_switch_branch_flag_overrides_in_place_strategy( repo_pair, monkeypatch, capsys ): """--switch-branch overrides updates.parked_branch_strategy: update_in_place for one run: the unmerged branch is LEFT ALONE and the update runs on the target instead. A long-lived feature branch does not want an update-driven merge commit in its history (#89507 review). The branch tip must be byte-identical afterwards, while the checkout ends up on the updated target. """ import hermes_cli.config as hermes_config monkeypatch.setattr( hermes_config, "load_config", lambda: {"updates": {"parked_branch_strategy": "update_in_place"}}, ) (repo_pair / "feature.txt").write_text("unmerged work\n") _git(repo_pair, "add", "feature.txt") _git(repo_pair, "commit", "-qm", "feature work") branch_tip_before = _git( repo_pair, "rev-parse", "old-feature" ).stdout.strip() _patch_update_flow(monkeypatch, repo_pair) class _StopFlow(Exception): pass monkeypatch.setattr( hermes_main, "_abort_dependency_sync_if_self_locked", lambda *a, **k: (_ for _ in ()).throw(_StopFlow()), ) args = SimpleNamespace( branch=None, yes=False, force=False, force_venv=False, switch_branch=True, ) with pytest.raises(_StopFlow): hermes_main.cmd_update(args) out = capsys.readouterr().out assert "1 commit(s) not merged into origin/main" in out assert "updating it in place" not in out assert "CODE UPDATE SKIPPED" not in out # Checkout moved to the target and picked up its code... assert ( _git(repo_pair, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip() == "main" ) assert (repo_pair / "b.txt").exists() # ...and the feature branch was not written to at all. assert ( _git(repo_pair, "rev-parse", "old-feature").stdout.strip() == branch_tip_before ) def test_unmerged_branch_still_updates_in_place_without_the_flag( repo_pair, monkeypatch, capsys ): """--switch-branch is opt-in: with the in-place strategy configured and no flag, the update stays in place.""" import hermes_cli.config as hermes_config monkeypatch.setattr( hermes_config, "load_config", lambda: {"updates": {"parked_branch_strategy": "update_in_place"}}, ) (repo_pair / "feature.txt").write_text("unmerged work\n") _git(repo_pair, "add", "feature.txt") _git(repo_pair, "commit", "-qm", "feature work") _patch_update_flow(monkeypatch, repo_pair) class _StopFlow(Exception): pass monkeypatch.setattr( hermes_main, "_abort_dependency_sync_if_self_locked", lambda *a, **k: (_ for _ in ()).throw(_StopFlow()), ) args = SimpleNamespace( branch=None, yes=False, force=False, force_venv=False, switch_branch=False, ) with pytest.raises(_StopFlow): hermes_main.cmd_update(args) out = capsys.readouterr().out assert "updating it in place" in out assert "--switch-branch" not in out assert ( _git(repo_pair, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip() == "old-feature" ) def test_update_auto_switches_clean_merged_parked_branch( repo_pair, monkeypatch, capsys ): """Clean + fully merged parked branch → auto-switch back to main, pull, say so, and STAY on main afterwards (sabotage-proven: reverting the guard re-parks the checkout and this test fails on the branch assert).""" _patch_update_flow(monkeypatch, repo_pair) # Stop the flow right after the pull/branch logic: the dependency # install phase begins with _abort_dependency_sync_if_self_locked. class _StopFlow(Exception): pass monkeypatch.setattr( hermes_main, "_abort_dependency_sync_if_self_locked", lambda *a, **k: (_ for _ in ()).throw(_StopFlow()), ) args = SimpleNamespace(branch=None, yes=False, force=False, force_venv=False) with pytest.raises(_StopFlow): hermes_main.cmd_update(args) out = capsys.readouterr().out assert "parked on 'old-feature'" in out assert "fully merged" in out assert "switching back to main" in out assert "CODE UPDATE SKIPPED" not in out # The checkout ends up ON main, fast-forwarded to origin/main. assert ( _git(repo_pair, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip() == "main" ) head = _git(repo_pair, "rev-parse", "HEAD").stdout.strip() remote = _git(repo_pair, "rev-parse", "origin/main").stdout.strip() assert head == remote def test_update_up_to_date_path_does_not_repark_merged_branch( tmp_path, monkeypatch, capsys ): """commit_count == 0 path: before this fix, the updater switched BACK to the parked feature branch after checking main ("Restore stash and switch back to original branch") — silently re-parking the checkout so every subsequent update repeated the incident. A clean, fully merged parked branch must now END on main.""" origin = tmp_path / "origin" origin.mkdir() _git(origin, "init", "-q", "-b", "main") _git(origin, "config", "user.email", "test@example.com") _git(origin, "config", "user.name", "Test") (origin / "a.txt").write_text("one\n") _git(origin, "add", "a.txt") _git(origin, "commit", "-qm", "c1") clone = tmp_path / "clone" _git(tmp_path, "clone", "-q", str(origin), str(clone)) _git(clone, "config", "user.email", "test@example.com") _git(clone, "config", "user.name", "Test") _git(clone, "checkout", "-qb", "old-feature") # No new upstream commits: local main == origin/main == old-feature tip. _patch_update_flow(monkeypatch, clone) class _StopFlow(Exception): pass import hermes_cli.managed_uv as managed_uv monkeypatch.setattr( managed_uv, "update_managed_uv", lambda *a, **k: (_ for _ in ()).throw(_StopFlow()), ) args = SimpleNamespace(branch=None, yes=False, force=False, force_venv=False) with pytest.raises(_StopFlow): hermes_main.cmd_update(args) out = capsys.readouterr().out assert "switched back to main" in out # The regression: old code ran `git checkout old-feature` here. assert ( _git(clone, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip() == "main" ) def test_update_on_main_fast_path_unchanged(repo_pair, monkeypatch, capsys): """On the target branch already: no guard prints, normal pull flow.""" _git(repo_pair, "checkout", "-q", "main") _patch_update_flow(monkeypatch, repo_pair) class _StopFlow(Exception): pass monkeypatch.setattr( hermes_main, "_abort_dependency_sync_if_self_locked", lambda *a, **k: (_ for _ in ()).throw(_StopFlow()), ) args = SimpleNamespace(branch=None, yes=False, force=False, force_venv=False) with pytest.raises(_StopFlow): hermes_main.cmd_update(args) out = capsys.readouterr().out assert "parked on" not in out assert "CODE UPDATE SKIPPED" not in out assert ( _git(repo_pair, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip() == "main" ) head = _git(repo_pair, "rev-parse", "HEAD").stdout.strip() remote = _git(repo_pair, "rev-parse", "origin/main").stdout.strip() assert head == remote