Files
hermes-agent/tests/hermes_cli/test_update_autostash.py
Teknium 9d4b3e5470 fix: harden hermes update against diverged history, non-main branches, and gateway edge cases (salvage #3489) (#3492)
* fix: harden `hermes update` against diverged history, non-main branches, and gateway edge cases

The self-update command (`hermes update` / gateway `/update`) could fail
or silently corrupt state in several scenarios:

1. **Diverged history** — `git pull --ff-only` aborts with a cryptic
   subprocess error when upstream has force-pushed or rebased. Now falls
   back to `git reset --hard origin/main` since local changes are already
   stashed.

2. **User on a feature branch / detached HEAD** — the old code would
   either clobber the feature branch HEAD to point at origin/main, or
   silently pull against a non-existent remote branch. Now auto-checkouts
   main before pulling, with a clear warning.

3. **Fetch failures** — network or auth errors produced raw subprocess
   tracebacks. Now shows user-friendly messages ("Network error",
   "Authentication failed") with actionable hints.

4. **reset --hard failure** — if the fallback reset itself fails (disk
   full, permissions), the old code would still attempt stash restore on
   a broken working tree. Now skips restore and tells the user their
   changes are safe in stash.

5. **Gateway /update stash conflicts** — non-interactive mode (Telegram
   `/update`) called sys.exit(1) when stash restore had conflicts, making
   the entire update report as failed even though the code update itself
   succeeded. Now treats stash conflicts as non-fatal in non-interactive
   mode (returns False instead of exiting).

* fix: restore stash and branch on 'already up to date' early return

The PR moved stash creation before the commit-count check (needed for
the branch-switching feature), but the 'already up to date' early return
didn't restore the stash or switch back to the original branch — leaving
the user stranded on main with changes trapped in a stash.

Now the early-return path restores the stash and checks out the original
branch when applicable.

---------

Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
2026-03-27 23:12:43 -07:00

621 lines
26 KiB
Python

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