from pathlib import Path from subprocess import CalledProcessError from types import SimpleNamespace import pytest from hermes_cli import config as hermes_config from hermes_cli import main as hermes_main def test_stash_local_changes_if_needed_returns_none_when_tree_clean(monkeypatch, tmp_path): calls = [] def fake_run(cmd, **kwargs): calls.append((cmd, kwargs)) if cmd[-2:] == ["status", "--porcelain"]: return SimpleNamespace(stdout="", returncode=0) raise AssertionError(f"unexpected command: {cmd}") monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) stash_ref = hermes_main._stash_local_changes_if_needed(["git"], tmp_path) assert stash_ref is None assert [cmd[-2:] for cmd, _ in calls] == [["status", "--porcelain"]] def test_stash_local_changes_if_needed_returns_specific_stash_commit(monkeypatch, tmp_path): calls = [] def fake_run(cmd, **kwargs): calls.append((cmd, kwargs)) if cmd[-2:] == ["status", "--porcelain"]: return SimpleNamespace(stdout=" M hermes_cli/main.py\n?? notes.txt\n", returncode=0) if cmd[1:4] == ["stash", "push", "--include-untracked"]: return SimpleNamespace(stdout="Saved working directory\n", returncode=0) if cmd[-3:] == ["rev-parse", "--verify", "refs/stash"]: return SimpleNamespace(stdout="abc123\n", returncode=0) raise AssertionError(f"unexpected command: {cmd}") monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) stash_ref = hermes_main._stash_local_changes_if_needed(["git"], tmp_path) assert stash_ref == "abc123" assert calls[1][0][1:4] == ["stash", "push", "--include-untracked"] assert calls[2][0][-3:] == ["rev-parse", "--verify", "refs/stash"] def test_resolve_stash_selector_returns_matching_entry(monkeypatch, tmp_path): def fake_run(cmd, **kwargs): assert cmd == ["git", "stash", "list", "--format=%gd %H"] return SimpleNamespace( stdout="stash@{0} def456\nstash@{1} abc123\n", returncode=0, ) monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) assert hermes_main._resolve_stash_selector(["git"], tmp_path, "abc123") == "stash@{1}" def test_restore_stashed_changes_prompts_before_applying(monkeypatch, tmp_path, capsys): calls = [] def fake_run(cmd, **kwargs): calls.append((cmd, kwargs)) if cmd[1:3] == ["stash", "apply"]: return SimpleNamespace(stdout="applied\n", stderr="", returncode=0) if cmd[1:3] == ["diff", "--name-only"]: return SimpleNamespace(stdout="", stderr="", returncode=0) if cmd[1:3] == ["stash", "list"]: return SimpleNamespace(stdout="stash@{1} abc123\n", stderr="", returncode=0) if cmd[1:3] == ["stash", "drop"]: return SimpleNamespace(stdout="dropped\n", stderr="", returncode=0) raise AssertionError(f"unexpected command: {cmd}") monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) monkeypatch.setattr("builtins.input", lambda: "") restored = hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=True) assert restored is True assert calls[0][0] == ["git", "stash", "apply", "abc123"] assert calls[1][0] == ["git", "diff", "--name-only", "--diff-filter=U"] assert calls[2][0] == ["git", "stash", "list", "--format=%gd %H"] assert calls[3][0] == ["git", "stash", "drop", "stash@{1}"] out = capsys.readouterr().out assert "Restore local changes now? [Y/n]" in out assert "restored on top of the updated codebase" in out assert "git diff" in out assert "git status" in out def test_restore_stashed_changes_can_skip_restore_and_keep_stash(monkeypatch, tmp_path, capsys): calls = [] def fake_run(cmd, **kwargs): calls.append((cmd, kwargs)) raise AssertionError(f"unexpected command: {cmd}") monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) monkeypatch.setattr("builtins.input", lambda: "n") restored = hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=True) assert restored is False assert calls == [] out = capsys.readouterr().out assert "Restore local changes now? [Y/n]" in out assert "Your changes are still preserved in git stash." in out assert "git stash apply abc123" in out def test_restore_stashed_changes_applies_without_prompt_when_disabled(monkeypatch, tmp_path, capsys): calls = [] def fake_run(cmd, **kwargs): calls.append((cmd, kwargs)) if cmd[1:3] == ["stash", "apply"]: return SimpleNamespace(stdout="applied\n", stderr="", returncode=0) if cmd[1:3] == ["diff", "--name-only"]: return SimpleNamespace(stdout="", stderr="", returncode=0) if cmd[1:3] == ["stash", "list"]: return SimpleNamespace(stdout="stash@{0} abc123\n", stderr="", returncode=0) if cmd[1:3] == ["stash", "drop"]: return SimpleNamespace(stdout="dropped\n", stderr="", returncode=0) raise AssertionError(f"unexpected command: {cmd}") monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) restored = hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=False) assert restored is True assert calls[0][0] == ["git", "stash", "apply", "abc123"] assert calls[1][0] == ["git", "diff", "--name-only", "--diff-filter=U"] assert calls[2][0] == ["git", "stash", "list", "--format=%gd %H"] assert calls[3][0] == ["git", "stash", "drop", "stash@{0}"] assert "Restore local changes now?" not in capsys.readouterr().out def test_print_stash_cleanup_guidance_with_selector(capsys): hermes_main._print_stash_cleanup_guidance("abc123", "stash@{2}") out = capsys.readouterr().out assert "Check `git status` first" in out assert "git stash list --format='%gd %H %s'" in out assert "git stash drop stash@{2}" in out def test_restore_stashed_changes_keeps_going_when_stash_entry_cannot_be_resolved(monkeypatch, tmp_path, capsys): calls = [] def fake_run(cmd, **kwargs): calls.append((cmd, kwargs)) if cmd[1:3] == ["stash", "apply"]: return SimpleNamespace(stdout="applied\n", stderr="", returncode=0) if cmd[1:3] == ["diff", "--name-only"]: return SimpleNamespace(stdout="", stderr="", returncode=0) if cmd[1:3] == ["stash", "list"]: return SimpleNamespace(stdout="stash@{0} def456\n", stderr="", returncode=0) raise AssertionError(f"unexpected command: {cmd}") monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) restored = hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=False) assert restored is True assert calls[0] == (["git", "stash", "apply", "abc123"], {"cwd": tmp_path, "capture_output": True, "text": True}) assert calls[1] == (["git", "diff", "--name-only", "--diff-filter=U"], {"cwd": tmp_path, "capture_output": True, "text": True}) assert calls[2] == (["git", "stash", "list", "--format=%gd %H"], {"cwd": tmp_path, "capture_output": True, "text": True, "check": True}) out = capsys.readouterr().out assert "couldn't find the stash entry to drop" in out assert "stash was left in place" in out assert "Check `git status` first" in out assert "git stash list --format='%gd %H %s'" in out assert "Look for commit abc123" in out def test_restore_stashed_changes_keeps_going_when_drop_fails(monkeypatch, tmp_path, capsys): calls = [] def fake_run(cmd, **kwargs): calls.append((cmd, kwargs)) if cmd[1:3] == ["stash", "apply"]: return SimpleNamespace(stdout="applied\n", stderr="", returncode=0) if cmd[1:3] == ["diff", "--name-only"]: return SimpleNamespace(stdout="", stderr="", returncode=0) if cmd[1:3] == ["stash", "list"]: return SimpleNamespace(stdout="stash@{0} abc123\n", stderr="", returncode=0) if cmd[1:3] == ["stash", "drop"]: return SimpleNamespace(stdout="", stderr="drop failed\n", returncode=1) raise AssertionError(f"unexpected command: {cmd}") monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) restored = hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=False) assert restored is True assert calls[3][0] == ["git", "stash", "drop", "stash@{0}"] out = capsys.readouterr().out assert "couldn't drop the saved stash entry" in out assert "drop failed" in out assert "Check `git status` first" in out assert "git stash list --format='%gd %H %s'" in out assert "git stash drop stash@{0}" in out def test_restore_stashed_changes_prompts_before_reset_on_conflict(monkeypatch, tmp_path, capsys): """When conflicts occur interactively, user is prompted before reset.""" calls = [] def fake_run(cmd, **kwargs): calls.append((cmd, kwargs)) if cmd[1:3] == ["stash", "apply"]: return SimpleNamespace(stdout="conflict output\n", stderr="conflict stderr\n", returncode=1) if cmd[1:3] == ["diff", "--name-only"]: return SimpleNamespace(stdout="hermes_cli/main.py\n", stderr="", returncode=0) if cmd[1:3] == ["reset", "--hard"]: return SimpleNamespace(stdout="", stderr="", returncode=0) raise AssertionError(f"unexpected command: {cmd}") monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) monkeypatch.setattr("builtins.input", lambda: "y") with pytest.raises(SystemExit, match="1"): hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=True) out = capsys.readouterr().out assert "Conflicted files:" in out assert "hermes_cli/main.py" in out assert "stashed changes are preserved" in out assert "Reset working tree to clean state" in out assert "Working tree reset to clean state" in out reset_calls = [c for c, _ in calls if c[1:3] == ["reset", "--hard"]] assert len(reset_calls) == 1 def test_restore_stashed_changes_user_declines_reset(monkeypatch, tmp_path, capsys): """When user declines reset, working tree is left as-is.""" calls = [] def fake_run(cmd, **kwargs): calls.append((cmd, kwargs)) if cmd[1:3] == ["stash", "apply"]: return SimpleNamespace(stdout="", stderr="conflict\n", returncode=1) if cmd[1:3] == ["diff", "--name-only"]: return SimpleNamespace(stdout="cli.py\n", stderr="", returncode=0) raise AssertionError(f"unexpected command: {cmd}") monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) # First input: "y" to restore, second input: "n" to decline reset inputs = iter(["y", "n"]) monkeypatch.setattr("builtins.input", lambda: next(inputs)) with pytest.raises(SystemExit, match="1"): hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=True) out = capsys.readouterr().out assert "left as-is" in out reset_calls = [c for c, _ in calls if c[1:3] == ["reset", "--hard"]] assert len(reset_calls) == 0 def test_restore_stashed_changes_auto_resets_non_interactive(monkeypatch, tmp_path, capsys): """Non-interactive mode auto-resets without prompting and returns False instead of sys.exit(1) so the update can continue (gateway /update path).""" calls = [] def fake_run(cmd, **kwargs): calls.append((cmd, kwargs)) if cmd[1:3] == ["stash", "apply"]: return SimpleNamespace(stdout="applied\n", stderr="", returncode=0) if cmd[1:3] == ["diff", "--name-only"]: return SimpleNamespace(stdout="cli.py\n", stderr="", returncode=0) if cmd[1:3] == ["reset", "--hard"]: return SimpleNamespace(stdout="", stderr="", returncode=0) raise AssertionError(f"unexpected command: {cmd}") monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) result = hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=False) assert result is False out = capsys.readouterr().out assert "Working tree reset to clean state" in out reset_calls = [c for c, _ in calls if c[1:3] == ["reset", "--hard"]] assert len(reset_calls) == 1 def test_stash_local_changes_if_needed_raises_when_stash_ref_missing(monkeypatch, tmp_path): def fake_run(cmd, **kwargs): if cmd[-2:] == ["status", "--porcelain"]: return SimpleNamespace(stdout=" M hermes_cli/main.py\n", returncode=0) if cmd[1:4] == ["stash", "push", "--include-untracked"]: return SimpleNamespace(stdout="Saved working directory\n", returncode=0) if cmd[-3:] == ["rev-parse", "--verify", "refs/stash"]: raise CalledProcessError(returncode=128, cmd=cmd) raise AssertionError(f"unexpected command: {cmd}") monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) with pytest.raises(CalledProcessError): hermes_main._stash_local_changes_if_needed(["git"], Path(tmp_path)) # --------------------------------------------------------------------------- # Update uses .[all] with fallback to . # --------------------------------------------------------------------------- def _setup_update_mocks(monkeypatch, tmp_path): """Common setup for cmd_update tests.""" (tmp_path / ".git").mkdir() monkeypatch.setattr(hermes_main, "PROJECT_ROOT", tmp_path) monkeypatch.setattr(hermes_main, "_stash_local_changes_if_needed", lambda *a, **kw: None) monkeypatch.setattr(hermes_main, "_restore_stashed_changes", lambda *a, **kw: True) monkeypatch.setattr(hermes_config, "get_missing_env_vars", lambda required_only=True: []) monkeypatch.setattr(hermes_config, "get_missing_config_fields", lambda: []) monkeypatch.setattr(hermes_config, "check_config_version", lambda: (5, 5)) monkeypatch.setattr(hermes_config, "migrate_config", lambda **kw: {"env_added": [], "config_added": []}) def test_cmd_update_tries_extras_first_then_falls_back(monkeypatch, tmp_path): """When .[all] fails, update should fall back to . instead of aborting.""" _setup_update_mocks(monkeypatch, tmp_path) monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) recorded = [] def fake_run(cmd, **kwargs): recorded.append(cmd) if cmd == ["git", "fetch", "origin"]: return SimpleNamespace(stdout="", stderr="", returncode=0) if cmd == ["git", "rev-parse", "--abbrev-ref", "HEAD"]: return SimpleNamespace(stdout="main\n", stderr="", returncode=0) if cmd == ["git", "rev-list", "HEAD..origin/main", "--count"]: return SimpleNamespace(stdout="1\n", stderr="", returncode=0) if cmd == ["git", "pull", "origin", "main"]: return SimpleNamespace(stdout="Updating\n", stderr="", returncode=0) # .[all] fails if ".[all]" in cmd: raise CalledProcessError(returncode=1, cmd=cmd) # bare . succeeds if cmd == ["/usr/bin/uv", "pip", "install", "-e", ".", "--quiet"]: return SimpleNamespace(returncode=0) return SimpleNamespace(returncode=0) monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) hermes_main.cmd_update(SimpleNamespace()) install_cmds = [c for c in recorded if "pip" in c and "install" in c] assert len(install_cmds) == 2 assert ".[all]" in install_cmds[0] assert "." in install_cmds[1] and ".[all]" not in install_cmds[1] def test_cmd_update_succeeds_with_extras(monkeypatch, tmp_path): """When .[all] succeeds, no fallback should be attempted.""" _setup_update_mocks(monkeypatch, tmp_path) monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) recorded = [] def fake_run(cmd, **kwargs): recorded.append(cmd) if cmd == ["git", "fetch", "origin"]: return SimpleNamespace(stdout="", stderr="", returncode=0) if cmd == ["git", "rev-parse", "--abbrev-ref", "HEAD"]: return SimpleNamespace(stdout="main\n", stderr="", returncode=0) if cmd == ["git", "rev-list", "HEAD..origin/main", "--count"]: return SimpleNamespace(stdout="1\n", stderr="", returncode=0) if cmd == ["git", "pull", "origin", "main"]: return SimpleNamespace(stdout="Updating\n", stderr="", returncode=0) return SimpleNamespace(returncode=0) monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) hermes_main.cmd_update(SimpleNamespace()) install_cmds = [c for c in recorded if "pip" in c and "install" in c] assert len(install_cmds) == 1 assert ".[all]" in install_cmds[0] # --------------------------------------------------------------------------- # ff-only fallback to reset --hard on diverged history # --------------------------------------------------------------------------- def _make_update_side_effect( current_branch="main", commit_count="3", ff_only_fails=False, reset_fails=False, fetch_fails=False, fetch_stderr="", ): """Build a subprocess.run side_effect for cmd_update tests.""" recorded = [] def side_effect(cmd, **kwargs): recorded.append(cmd) joined = " ".join(str(c) for c in cmd) if "fetch" in joined and "origin" in joined: if fetch_fails: return SimpleNamespace(stdout="", stderr=fetch_stderr, returncode=128) return SimpleNamespace(stdout="", stderr="", returncode=0) if "rev-parse" in joined and "--abbrev-ref" in joined: return SimpleNamespace(stdout=f"{current_branch}\n", stderr="", returncode=0) if "checkout" in joined and "main" in joined: return SimpleNamespace(stdout="", stderr="", returncode=0) if "rev-list" in joined: return SimpleNamespace(stdout=f"{commit_count}\n", stderr="", returncode=0) if "--ff-only" in joined: if ff_only_fails: return SimpleNamespace( stdout="", stderr="fatal: Not possible to fast-forward, aborting.\n", returncode=128, ) return SimpleNamespace(stdout="Updating abc..def\n", stderr="", returncode=0) if "reset" in joined and "--hard" in joined: if reset_fails: return SimpleNamespace(stdout="", stderr="error: unable to write\n", returncode=1) return SimpleNamespace(stdout="HEAD is now at abc123\n", stderr="", returncode=0) return SimpleNamespace(returncode=0, stdout="", stderr="") return side_effect, recorded def test_cmd_update_falls_back_to_reset_when_ff_only_fails(monkeypatch, tmp_path, capsys): """When --ff-only fails (diverged history), update resets to origin/{branch}.""" _setup_update_mocks(monkeypatch, tmp_path) monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) side_effect, recorded = _make_update_side_effect(ff_only_fails=True) monkeypatch.setattr(hermes_main.subprocess, "run", side_effect) hermes_main.cmd_update(SimpleNamespace()) reset_calls = [c for c in recorded if "reset" in c and "--hard" in c] assert len(reset_calls) == 1 assert reset_calls[0] == ["git", "reset", "--hard", "origin/main"] out = capsys.readouterr().out assert "Fast-forward not possible" in out def test_cmd_update_no_reset_when_ff_only_succeeds(monkeypatch, tmp_path): """When --ff-only succeeds, no reset is attempted.""" _setup_update_mocks(monkeypatch, tmp_path) monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) side_effect, recorded = _make_update_side_effect() monkeypatch.setattr(hermes_main.subprocess, "run", side_effect) hermes_main.cmd_update(SimpleNamespace()) reset_calls = [c for c in recorded if "reset" in c and "--hard" in c] assert len(reset_calls) == 0 # --------------------------------------------------------------------------- # Non-main branch → auto-checkout main # --------------------------------------------------------------------------- def test_cmd_update_switches_to_main_from_feature_branch(monkeypatch, tmp_path, capsys): """When on a feature branch, update checks out main before pulling.""" _setup_update_mocks(monkeypatch, tmp_path) monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) side_effect, recorded = _make_update_side_effect(current_branch="fix/something") monkeypatch.setattr(hermes_main.subprocess, "run", side_effect) hermes_main.cmd_update(SimpleNamespace()) checkout_calls = [c for c in recorded if "checkout" in c and "main" in c] assert len(checkout_calls) == 1 out = capsys.readouterr().out assert "fix/something" in out assert "switching to main" in out def test_cmd_update_switches_to_main_from_detached_head(monkeypatch, tmp_path, capsys): """When in detached HEAD state, update checks out main before pulling.""" _setup_update_mocks(monkeypatch, tmp_path) monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) side_effect, recorded = _make_update_side_effect(current_branch="HEAD") monkeypatch.setattr(hermes_main.subprocess, "run", side_effect) hermes_main.cmd_update(SimpleNamespace()) checkout_calls = [c for c in recorded if "checkout" in c and "main" in c] assert len(checkout_calls) == 1 out = capsys.readouterr().out assert "detached HEAD" in out def test_cmd_update_restores_stash_and_branch_when_already_up_to_date(monkeypatch, tmp_path, capsys): """When on a feature branch with no updates, stash is restored and branch switched back.""" _setup_update_mocks(monkeypatch, tmp_path) monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) # Enable stash so it returns a ref monkeypatch.setattr( hermes_main, "_stash_local_changes_if_needed", lambda *a, **kw: "abc123deadbeef", ) restore_calls = [] monkeypatch.setattr( hermes_main, "_restore_stashed_changes", lambda *a, **kw: restore_calls.append(1) or True, ) side_effect, recorded = _make_update_side_effect( current_branch="fix/something", commit_count="0", ) monkeypatch.setattr(hermes_main.subprocess, "run", side_effect) hermes_main.cmd_update(SimpleNamespace()) # Stash should have been restored assert len(restore_calls) == 1 # Should have checked out back to the original branch checkout_back = [c for c in recorded if "checkout" in c and "fix/something" in c] assert len(checkout_back) == 1 out = capsys.readouterr().out assert "Already up to date" in out def test_cmd_update_no_checkout_when_already_on_main(monkeypatch, tmp_path): """When already on main, no checkout is needed.""" _setup_update_mocks(monkeypatch, tmp_path) monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) side_effect, recorded = _make_update_side_effect() monkeypatch.setattr(hermes_main.subprocess, "run", side_effect) hermes_main.cmd_update(SimpleNamespace()) checkout_calls = [c for c in recorded if "checkout" in c] assert len(checkout_calls) == 0 # --------------------------------------------------------------------------- # Fetch failure — friendly error messages # --------------------------------------------------------------------------- def test_cmd_update_network_error_shows_friendly_message(monkeypatch, tmp_path, capsys): """Network failures during fetch show a user-friendly message.""" _setup_update_mocks(monkeypatch, tmp_path) side_effect, _ = _make_update_side_effect( fetch_fails=True, fetch_stderr="fatal: unable to access 'https://...': Could not resolve host: github.com", ) monkeypatch.setattr(hermes_main.subprocess, "run", side_effect) with pytest.raises(SystemExit, match="1"): hermes_main.cmd_update(SimpleNamespace()) out = capsys.readouterr().out assert "Network error" in out def test_cmd_update_auth_error_shows_friendly_message(monkeypatch, tmp_path, capsys): """Auth failures during fetch show a user-friendly message.""" _setup_update_mocks(monkeypatch, tmp_path) side_effect, _ = _make_update_side_effect( fetch_fails=True, fetch_stderr="fatal: Authentication failed for 'https://...'", ) monkeypatch.setattr(hermes_main.subprocess, "run", side_effect) with pytest.raises(SystemExit, match="1"): hermes_main.cmd_update(SimpleNamespace()) out = capsys.readouterr().out assert "Authentication failed" in out # --------------------------------------------------------------------------- # reset --hard failure — don't attempt stash restore # --------------------------------------------------------------------------- def test_cmd_update_skips_stash_restore_when_reset_fails(monkeypatch, tmp_path, capsys): """When reset --hard fails, stash restore is skipped with a helpful message.""" _setup_update_mocks(monkeypatch, tmp_path) # Re-enable stash so it actually returns a ref monkeypatch.setattr( hermes_main, "_stash_local_changes_if_needed", lambda *a, **kw: "abc123deadbeef", ) restore_calls = [] monkeypatch.setattr( hermes_main, "_restore_stashed_changes", lambda *a, **kw: restore_calls.append(1) or True, ) side_effect, _ = _make_update_side_effect(ff_only_fails=True, reset_fails=True) monkeypatch.setattr(hermes_main.subprocess, "run", side_effect) with pytest.raises(SystemExit, match="1"): hermes_main.cmd_update(SimpleNamespace()) # Stash restore should NOT have been called assert len(restore_calls) == 0 out = capsys.readouterr().out assert "preserved in stash" in out