| 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) |
| |
| 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.""" |
| 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) |
|
|
| with pytest.raises(SystemExit, match="1"): |
| hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=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)) |
|
|
|
|
| |
| |
| |
|
|
| 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) |
| |
| if ".[all]" in cmd: |
| raise CalledProcessError(returncode=1, cmd=cmd) |
| |
| 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] |
|
|