mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 08:32:09 +00:00
* Revert "fix(update): require managed marker before destructive clean" This reverts commitc8e80cd0bf. * Revert "fix(update): stop stash/restore from clobbering desktop source on managed clones (#38542)" This reverts commit8a19884bf3. * chore(install): keep npm ci desktop-build fix after stash revert The destructive-clean reverts (#38542/#39568) pulled the desktop workspace install back to bare `npm install`. The npm ci -> npm install fallback is orthogonal build-correctness (avoids the Windows workspace-hoisting flake where install reports up-to-date against a stale marker while node_modules is empty, breaking tsc -b). Preserve it. * feat(update): settable stash-or-discard for non-interactive local changes Adds updates.non_interactive_local_changes (stash | discard, default stash). Governs ONLY non-interactive updates (desktop/chat app, gateway, --yes) — interactive terminal updates always stash-and-ask, unchanged. - config.py: new key under existing updates section; _config_version 26->27. - main.py: _cmd_update_impl detects non-interactive (gateway/--yes/no-TTY), reads the setting; new _discard_stashed_changes() drops the stash (stash-and-drop, never reset --hard/clean -fd, so ignored paths survive). Post-pull restore site branches on it; the bail-out and up-to-date restores always preserve work. - web_server.py + apps/desktop settings: exposes it as a stash/discard select (Advanced section, In-App Update Local Changes). - docs + tests (discard drops, stash restores, interactive ignores setting, missing section defaults to stash). * fix(install.ps1): stash/restore instead of reset --hard on Windows update The PR reverted the destructive update path to stash/restore everywhere except scripts/install.ps1, whose managed-clone update path still ran `git reset --hard HEAD` before checkout — silently destroying agent-edited tracked source on Windows (the same #38542 data-loss class the PR fixes). - Replace `git reset --hard HEAD` with stash-before-checkout + restore-after-checkout, mirroring install.sh. Untracked files are included so agent-created dirs (e.g. tinker-atropos/) survive. - Keep `core.autocrlf false` (it prevents the phantom CRLF dirt that made the stash necessary; it's also load-bearing for a clean restore). - Wrap all three checkout modes (Commit/Tag/Branch); Branch case now uses `git pull --ff-only` so local commits are never clobbered. - Only prompt to restore when a real console is attached (UserInteractive + non-redirected stdin/stdout + ConsoleHost); the desktop Update button and bootstrap have no usable console, so they default to restore and never hang on Read-Host. - On restore conflict or a failed update, the stash is preserved with recovery instructions — work is never silently dropped. Validated on Windows (PowerShell 5.1, git 2.54): AST parse clean; E2E non-conflicting restore applies+drops cleanly with ignored paths (node_modules) untouched; conflicting restore preserves the stash. --------- Co-authored-by: alt-glitch <balyan.sid@gmail.com>
829 lines
34 KiB
Python
829 lines
34 KiB
Python
from pathlib import Path
|
|
from subprocess import CalledProcessError
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from hermes_cli import config as hermes_config
|
|
from hermes_cli import main as hermes_main
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Managed-uv compatibility for tests that patch shutil.which
|
|
# ---------------------------------------------------------------------------
|
|
# The production code now uses ``ensure_uv()`` / ``update_managed_uv()``
|
|
# instead of ``shutil.which("uv")``. Many tests in this file patch
|
|
# ``shutil.which`` to control whether uv is "available" — these autouse
|
|
# fixtures make the managed_uv functions delegate to the patched
|
|
# ``shutil.which`` so the existing test setup keeps working without
|
|
# per-test changes.
|
|
@pytest.fixture(autouse=True)
|
|
def _patch_managed_uv(request):
|
|
"""Make managed_uv helpers follow shutil.which mocking in tests."""
|
|
import shutil
|
|
|
|
# resolve_uv delegates to shutil.which("uv") so that test patches
|
|
# on shutil.which flow through naturally.
|
|
def _fake_resolve_uv():
|
|
return shutil.which("uv")
|
|
|
|
def _fake_ensure_uv():
|
|
return shutil.which("uv")
|
|
|
|
def _fake_update_managed_uv():
|
|
return None # never actually self-update in tests
|
|
|
|
with patch("hermes_cli.managed_uv.resolve_uv", side_effect=_fake_resolve_uv), \
|
|
patch("hermes_cli.managed_uv.ensure_uv", side_effect=_fake_ensure_uv), \
|
|
patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv):
|
|
yield
|
|
|
|
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[-2:] == ["ls-files", "--unmerged"]:
|
|
return SimpleNamespace(stdout="", 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][-2:] == ["ls-files", "--unmerged"]
|
|
assert calls[2][0][1:4] == ["stash", "push", "--include-untracked"]
|
|
assert calls[3][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_always_resets_on_conflict(monkeypatch, tmp_path, capsys):
|
|
"""Conflicts always auto-reset (no prompt) and return False, even interactively.
|
|
|
|
Leaving conflict markers in source files makes hermes unrunnable (SyntaxError).
|
|
The stash is preserved for manual recovery; cmd_update continues normally.
|
|
"""
|
|
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")
|
|
|
|
result = hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=True)
|
|
|
|
assert result is False
|
|
out = capsys.readouterr().out
|
|
assert "Conflicted files:" in out
|
|
assert "hermes_cli/main.py" in out
|
|
assert "stashed changes are preserved" in out
|
|
assert "Working tree reset to clean state" in out
|
|
assert "git stash apply abc123" 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_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[-2:] == ["ls-files", "--unmerged"]:
|
|
return SimpleNamespace(stdout="", 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": []})
|
|
monkeypatch.setattr(hermes_main, "_refresh_active_lazy_features", lambda: None)
|
|
|
|
|
|
def test_cmd_update_retries_optional_extras_individually_when_all_fails(monkeypatch, tmp_path, capsys):
|
|
"""When .[all] fails, update should keep base deps and retry extras individually."""
|
|
_setup_update_mocks(monkeypatch, tmp_path)
|
|
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
|
|
monkeypatch.setattr(hermes_main, "_is_termux_env", lambda env=None: False)
|
|
monkeypatch.setattr(hermes_main, "_load_installable_optional_extras", lambda group="all": ["matrix", "mcp"])
|
|
|
|
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", "--ff-only", "origin", "main"]:
|
|
return SimpleNamespace(stdout="Updating\n", stderr="", returncode=0)
|
|
if cmd == ["/usr/bin/uv", "pip", "install", "-e", ".[all]"]:
|
|
raise CalledProcessError(returncode=1, cmd=cmd)
|
|
if cmd == ["/usr/bin/uv", "pip", "install", "-e", "."]:
|
|
return SimpleNamespace(returncode=0)
|
|
if cmd == ["/usr/bin/uv", "pip", "install", "-e", ".[matrix]"]:
|
|
raise CalledProcessError(returncode=1, cmd=cmd)
|
|
if cmd == ["/usr/bin/uv", "pip", "install", "-e", ".[mcp]"]:
|
|
return SimpleNamespace(returncode=0)
|
|
# Catch-all must include stdout/stderr so consumers that parse
|
|
# output (e.g. the dashboard-restart `ps -A` scan added in the
|
|
# updater) don't crash on AttributeError.
|
|
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
|
|
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 install_cmds == [
|
|
["/usr/bin/uv", "pip", "install", "-e", ".[all]"],
|
|
["/usr/bin/uv", "pip", "install", "-e", "."],
|
|
["/usr/bin/uv", "pip", "install", "-e", ".[matrix]"],
|
|
["/usr/bin/uv", "pip", "install", "-e", ".[mcp]"],
|
|
]
|
|
|
|
out = capsys.readouterr().out
|
|
assert "retrying extras individually" in out
|
|
assert "Reinstalled optional extras individually: mcp" in out
|
|
assert "Skipped optional extras that still failed: matrix" in out
|
|
|
|
|
|
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)
|
|
monkeypatch.setattr(hermes_main, "_is_termux_env", lambda env=None: False)
|
|
|
|
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", "--ff-only", "origin", "main"]:
|
|
return SimpleNamespace(stdout="Updating\n", stderr="", returncode=0)
|
|
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
|
|
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]
|
|
|
|
|
|
def test_install_with_optional_fallback_honors_custom_group(monkeypatch):
|
|
"""Termux update path should target .[termux-all] when requested."""
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
hermes_main,
|
|
"_load_installable_optional_extras",
|
|
lambda group="all": ["termux", "mcp"] if group == "termux-all" else [],
|
|
)
|
|
|
|
def fake_run_with_heartbeat(cmd, **kwargs):
|
|
calls.append(cmd)
|
|
if cmd[-1] == ".[termux-all]":
|
|
raise CalledProcessError(returncode=1, cmd=cmd)
|
|
return None
|
|
|
|
monkeypatch.setattr(hermes_main, "_run_install_with_heartbeat", fake_run_with_heartbeat)
|
|
|
|
hermes_main._install_python_dependencies_with_optional_fallback(
|
|
["/usr/bin/uv", "pip"],
|
|
group="termux-all",
|
|
)
|
|
|
|
assert calls == [
|
|
["/usr/bin/uv", "pip", "install", "-e", ".[termux-all]"],
|
|
["/usr/bin/uv", "pip", "install", "-e", "."],
|
|
["/usr/bin/uv", "pip", "install", "-e", ".[termux]"],
|
|
["/usr/bin/uv", "pip", "install", "-e", ".[mcp]"],
|
|
]
|
|
|
|
|
|
def test_install_heartbeat_prints_when_dependency_install_is_silent(monkeypatch, capsys):
|
|
"""Long quiet installs should emit periodic heartbeat lines."""
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
hermes_main._time.sleep(1.2)
|
|
return SimpleNamespace(returncode=0)
|
|
|
|
monkeypatch.setattr(hermes_main.subprocess, "run", fake_run)
|
|
|
|
hermes_main._run_install_with_heartbeat(
|
|
["uv", "pip", "install", "-e", "."],
|
|
heartbeat_interval_seconds=1,
|
|
)
|
|
|
|
out = capsys.readouterr().out
|
|
assert "still installing dependencies" in out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Non-interactive update.non_interactive_local_changes setting
|
|
# (chat app / gateway): "discard" throws stashed changes away, "stash"
|
|
# (default) restores them. Interactive terminal updates ignore the setting
|
|
# and always go through the restore path.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _setup_setting_test(monkeypatch, tmp_path, mode):
|
|
"""Common wiring: real stash returns a ref, restore + discard are
|
|
recorded, and load_config reports the given non_interactive_local_changes
|
|
mode."""
|
|
_setup_update_mocks(monkeypatch, tmp_path)
|
|
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
|
|
monkeypatch.setattr(
|
|
hermes_main, "_stash_local_changes_if_needed",
|
|
lambda *a, **kw: "abc123deadbeef",
|
|
)
|
|
restore_calls = []
|
|
discard_calls = []
|
|
monkeypatch.setattr(
|
|
hermes_main, "_restore_stashed_changes",
|
|
lambda *a, **kw: restore_calls.append(1) or True,
|
|
)
|
|
monkeypatch.setattr(
|
|
hermes_main, "_discard_stashed_changes",
|
|
lambda *a, **kw: discard_calls.append(1) or True,
|
|
)
|
|
monkeypatch.setattr(
|
|
hermes_config, "load_config",
|
|
lambda *a, **kw: {"updates": {"non_interactive_local_changes": mode}},
|
|
)
|
|
side_effect, recorded = _make_update_side_effect()
|
|
monkeypatch.setattr(hermes_main.subprocess, "run", side_effect)
|
|
return restore_calls, discard_calls, recorded
|
|
|
|
|
|
def test_non_interactive_discard_throws_changes_away(monkeypatch, tmp_path):
|
|
"""Gateway/chat-app update with discard mode drops the stash, never restores."""
|
|
restore_calls, discard_calls, _ = _setup_setting_test(monkeypatch, tmp_path, "discard")
|
|
|
|
hermes_main.cmd_update(SimpleNamespace(gateway=True))
|
|
|
|
assert len(discard_calls) == 1
|
|
assert len(restore_calls) == 0
|
|
|
|
|
|
def test_non_interactive_stash_restores_changes(monkeypatch, tmp_path):
|
|
"""Gateway/chat-app update with the default stash mode restores, never discards."""
|
|
restore_calls, discard_calls, _ = _setup_setting_test(monkeypatch, tmp_path, "stash")
|
|
|
|
hermes_main.cmd_update(SimpleNamespace(gateway=True))
|
|
|
|
assert len(restore_calls) == 1
|
|
assert len(discard_calls) == 0
|
|
|
|
|
|
def test_interactive_update_ignores_discard_setting(monkeypatch, tmp_path):
|
|
"""An interactive (TTY) terminal update always restores — the discard
|
|
setting only governs non-interactive updates."""
|
|
restore_calls, discard_calls, _ = _setup_setting_test(monkeypatch, tmp_path, "discard")
|
|
# Force an interactive TTY so _non_interactive_update is False even though
|
|
# the config says discard.
|
|
monkeypatch.setattr(hermes_main.sys.stdin, "isatty", lambda: True)
|
|
monkeypatch.setattr(hermes_main.sys.stdout, "isatty", lambda: True)
|
|
|
|
hermes_main.cmd_update(SimpleNamespace()) # no gateway, no --yes
|
|
|
|
assert len(restore_calls) == 1
|
|
assert len(discard_calls) == 0
|
|
|
|
|
|
def test_non_interactive_defaults_to_stash_when_setting_absent(monkeypatch, tmp_path):
|
|
"""A config with no update section falls back to stash (safe default)."""
|
|
restore_calls, discard_calls, _ = _setup_setting_test(monkeypatch, tmp_path, "stash")
|
|
# Override load_config to return a config with NO update section at all.
|
|
monkeypatch.setattr(hermes_config, "load_config", lambda *a, **kw: {"model": {}})
|
|
|
|
hermes_main.cmd_update(SimpleNamespace(gateway=True))
|
|
|
|
assert len(restore_calls) == 1
|
|
assert len(discard_calls) == 0
|
|
|
|
|
|
def test_bootstrap_marker_not_autostashed_by_update(tmp_path):
|
|
"""#38529: the Desktop bootstrap marker must be git-ignored so that
|
|
``hermes update``'s ``git stash push --include-untracked`` does not sweep it
|
|
into an autostash on every run.
|
|
|
|
Behavioral + hermetic: build a throwaway repo that adopts the project's real
|
|
``.gitignore`` (the contract under test), drop the marker, and confirm the
|
|
same stash invocation the updater uses leaves it untouched.
|
|
"""
|
|
import shutil
|
|
import subprocess
|
|
|
|
if shutil.which("git") is None:
|
|
pytest.skip("git not available")
|
|
|
|
repo_gitignore = Path(hermes_main.__file__).resolve().parents[1] / ".gitignore"
|
|
|
|
def git(*args):
|
|
return subprocess.run(
|
|
["git", *args], cwd=tmp_path, capture_output=True, text=True, check=True
|
|
)
|
|
|
|
git("init", "-q")
|
|
git("config", "user.email", "t@example.com")
|
|
git("config", "user.name", "t")
|
|
(tmp_path / ".gitignore").write_text(repo_gitignore.read_text())
|
|
(tmp_path / "tracked.txt").write_text("x\n")
|
|
git("add", "-A")
|
|
git("commit", "-qm", "init")
|
|
|
|
marker = tmp_path / ".hermes-bootstrap-complete"
|
|
marker.write_text("")
|
|
|
|
# Exact flags used by hermes update (hermes_cli/main.py).
|
|
git("stash", "push", "--include-untracked", "-m", "hermes-update-autostash")
|
|
|
|
assert marker.exists(), (
|
|
".hermes-bootstrap-complete was swept into the update autostash — it must "
|
|
"be listed in .gitignore so `git stash -u` skips it (#38529)."
|
|
)
|
|
# It must not even register as a dirty/untracked change.
|
|
status = subprocess.run(
|
|
["git", "status", "--porcelain"], cwd=tmp_path, capture_output=True, text=True
|
|
).stdout
|
|
assert ".hermes-bootstrap-complete" not in status
|