From f764c7135dbbaaa2eec4ddf732c1a66b2106e9e8 Mon Sep 17 00:00:00 2001 From: smillunchick Date: Wed, 11 Mar 2026 20:35:10 +0000 Subject: [PATCH 1/2] fix: auto-stash local changes during updates --- hermes_cli/main.py | 64 +++++++++++++- scripts/install.sh | 21 +++++ tests/hermes_cli/test_update_autostash.py | 102 ++++++++++++++++++++++ 3 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 tests/hermes_cli/test_update_autostash.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 98c204e6..744ee162 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -45,6 +45,7 @@ Usage: import argparse import os +import subprocess import sys from pathlib import Path from typing import Optional @@ -1930,9 +1931,61 @@ def _update_via_zip(args): print("✓ Update complete!") +def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[str]: + status = subprocess.run( + git_cmd + ["status", "--porcelain"], + cwd=cwd, + capture_output=True, + text=True, + check=True, + ) + if not status.stdout.strip(): + return None + + from datetime import datetime, timezone + + stash_name = datetime.now(timezone.utc).strftime("hermes-update-autostash-%Y%m%d-%H%M%S") + print("→ Local changes detected — stashing before update...") + subprocess.run( + git_cmd + ["stash", "push", "--include-untracked", "-m", stash_name], + cwd=cwd, + check=True, + ) + stash_ref = subprocess.run( + git_cmd + ["rev-parse", "--verify", "refs/stash"], + cwd=cwd, + capture_output=True, + text=True, + check=True, + ).stdout.strip() + return stash_ref + + + +def _restore_stashed_changes(git_cmd: list[str], cwd: Path, stash_ref: str) -> None: + print("→ Restoring local changes...") + restore = subprocess.run( + git_cmd + ["stash", "apply", stash_ref], + cwd=cwd, + capture_output=True, + text=True, + ) + if restore.returncode != 0: + print("✗ Update pulled new code, but restoring local changes failed.") + if restore.stdout.strip(): + print(restore.stdout.strip()) + if restore.stderr.strip(): + print(restore.stderr.strip()) + print("Your changes are still preserved in git stash.") + print(f"Resolve manually with: git stash apply {stash_ref}") + sys.exit(1) + + subprocess.run(git_cmd + ["stash", "drop", stash_ref], cwd=cwd, check=True) + + + def cmd_update(args): """Update Hermes Agent to the latest version.""" - import subprocess import shutil print("⚕ Updating Hermes Agent...") @@ -1998,8 +2051,15 @@ def cmd_update(args): return print(f"→ Found {commit_count} new commit(s)") + + auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT) + print("→ Pulling updates...") - subprocess.run(git_cmd + ["pull", "origin", branch], cwd=PROJECT_ROOT, check=True) + try: + subprocess.run(git_cmd + ["pull", "origin", branch], cwd=PROJECT_ROOT, check=True) + finally: + if auto_stash_ref is not None: + _restore_stashed_changes(git_cmd, PROJECT_ROOT, auto_stash_ref) # Reinstall Python dependencies (prefer uv for speed, fall back to pip) print("→ Updating Python dependencies...") diff --git a/scripts/install.sh b/scripts/install.sh index 7862bd9b..5e48799d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -562,9 +562,30 @@ clone_repo() { if [ -d "$INSTALL_DIR/.git" ]; then log_info "Existing installation found, updating..." cd "$INSTALL_DIR" + + local autostash_ref="" + if [ -n "$(git status --porcelain)" ]; then + local stash_name + stash_name="hermes-install-autostash-$(date -u +%Y%m%d-%H%M%S)" + log_info "Local changes detected, stashing before update..." + git stash push --include-untracked -m "$stash_name" + autostash_ref="$(git rev-parse --verify refs/stash)" + fi + git fetch origin git checkout "$BRANCH" git pull origin "$BRANCH" + + if [ -n "$autostash_ref" ]; then + log_info "Restoring local changes..." + if git stash apply "$autostash_ref"; then + git stash drop "$autostash_ref" >/dev/null + else + log_error "Update succeeded, but restoring local changes failed. Your changes are still preserved in git stash." + log_info "Resolve manually with: git stash apply $autostash_ref" + exit 1 + fi + fi else log_error "Directory exists but is not a git repository: $INSTALL_DIR" log_info "Remove it or choose a different directory with --dir" diff --git a/tests/hermes_cli/test_update_autostash.py b/tests/hermes_cli/test_update_autostash.py new file mode 100644 index 00000000..ca6696c8 --- /dev/null +++ b/tests/hermes_cli/test_update_autostash.py @@ -0,0 +1,102 @@ +from pathlib import Path +from subprocess import CalledProcessError +from types import SimpleNamespace + +import pytest + +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_restore_stashed_changes_applies_specific_stash_and_drops_it(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] == ["stash", "drop"]: + return SimpleNamespace(stdout="dropped\n", stderr="", returncode=0) + raise AssertionError(f"unexpected command: {cmd}") + + monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) + + hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123") + + assert calls[0][0] == ["git", "stash", "apply", "abc123"] + assert calls[1][0] == ["git", "stash", "drop", "abc123"] + assert "Restoring local changes" in capsys.readouterr().out + + +def test_restore_stashed_changes_exits_cleanly_when_apply_fails(monkeypatch, tmp_path, capsys): + 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) + 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") + + out = capsys.readouterr().out + assert "Your changes are still preserved in git stash." in out + assert "git stash apply abc123" in out + assert calls == [(["git", "stash", "apply", "abc123"], {"cwd": tmp_path, "capture_output": True, "text": True})] + + +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)) From 42c778b5ebe43799daf9b80384fd32a776ce76a2 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 05:50:18 -0700 Subject: [PATCH 2/2] fix(update): warn and prompt before restoring autostash Add a restore prompt for interactive updates, keep the stash when the user declines, and print a post-restore warning that local changes were reapplied on top of updated code. --- hermes_cli/main.py | 31 +++++++++++- scripts/install.sh | 33 ++++++++++--- tests/hermes_cli/test_update_autostash.py | 57 +++++++++++++++++++++-- 3 files changed, 108 insertions(+), 13 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 744ee162..8b211280 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1962,7 +1962,25 @@ def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[st -def _restore_stashed_changes(git_cmd: list[str], cwd: Path, stash_ref: str) -> None: +def _restore_stashed_changes( + git_cmd: list[str], + cwd: Path, + stash_ref: str, + prompt_user: bool = False, +) -> bool: + if prompt_user: + print() + print("⚠ Local changes were stashed before updating.") + print(" Restoring them may reapply local customizations onto the updated codebase.") + print(" Review the result afterward if Hermes behaves unexpectedly.") + print("Restore local changes now? [Y/n]") + response = input().strip().lower() + if response not in ("", "y", "yes"): + print("Skipped restoring local changes.") + print("Your changes are still preserved in git stash.") + print(f"Restore manually with: git stash apply {stash_ref}") + return False + print("→ Restoring local changes...") restore = subprocess.run( git_cmd + ["stash", "apply", stash_ref], @@ -1981,6 +1999,9 @@ def _restore_stashed_changes(git_cmd: list[str], cwd: Path, stash_ref: str) -> N sys.exit(1) subprocess.run(git_cmd + ["stash", "drop", stash_ref], cwd=cwd, check=True) + print("⚠ Local changes were restored on top of the updated codebase.") + print(" Review `git diff` / `git status` if Hermes behaves unexpectedly.") + return True @@ -2053,13 +2074,19 @@ def cmd_update(args): print(f"→ Found {commit_count} new commit(s)") auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT) + prompt_for_restore = auto_stash_ref is not None and sys.stdin.isatty() and sys.stdout.isatty() print("→ Pulling updates...") try: subprocess.run(git_cmd + ["pull", "origin", branch], cwd=PROJECT_ROOT, check=True) finally: if auto_stash_ref is not None: - _restore_stashed_changes(git_cmd, PROJECT_ROOT, auto_stash_ref) + _restore_stashed_changes( + git_cmd, + PROJECT_ROOT, + auto_stash_ref, + prompt_user=prompt_for_restore, + ) # Reinstall Python dependencies (prefer uv for speed, fall back to pip) print("→ Updating Python dependencies...") diff --git a/scripts/install.sh b/scripts/install.sh index 5e48799d..8c7707b8 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -577,13 +577,34 @@ clone_repo() { git pull origin "$BRANCH" if [ -n "$autostash_ref" ]; then - log_info "Restoring local changes..." - if git stash apply "$autostash_ref"; then - git stash drop "$autostash_ref" >/dev/null + local restore_now="yes" + if [ -t 0 ] && [ -t 1 ]; then + echo + log_warn "Local changes were stashed before updating." + log_warn "Restoring them may reapply local customizations onto the updated codebase." + printf "Restore local changes now? [Y/n] " + read -r restore_answer + case "$restore_answer" in + ""|y|Y|yes|YES|Yes) restore_now="yes" ;; + *) restore_now="no" ;; + esac + fi + + if [ "$restore_now" = "yes" ]; then + log_info "Restoring local changes..." + if git stash apply "$autostash_ref"; then + git stash drop "$autostash_ref" >/dev/null + log_warn "Local changes were restored on top of the updated codebase." + log_warn "Review git diff / git status if Hermes behaves unexpectedly." + else + log_error "Update succeeded, but restoring local changes failed. Your changes are still preserved in git stash." + log_info "Resolve manually with: git stash apply $autostash_ref" + exit 1 + fi else - log_error "Update succeeded, but restoring local changes failed. Your changes are still preserved in git stash." - log_info "Resolve manually with: git stash apply $autostash_ref" - exit 1 + log_info "Skipped restoring local changes." + log_info "Your changes are still preserved in git stash." + log_info "Restore manually with: git stash apply $autostash_ref" fi fi else diff --git a/tests/hermes_cli/test_update_autostash.py b/tests/hermes_cli/test_update_autostash.py index ca6696c8..a05a5cbc 100644 --- a/tests/hermes_cli/test_update_autostash.py +++ b/tests/hermes_cli/test_update_autostash.py @@ -46,7 +46,53 @@ def test_stash_local_changes_if_needed_returns_specific_stash_commit(monkeypatch assert calls[2][0][-3:] == ["rev-parse", "--verify", "refs/stash"] -def test_restore_stashed_changes_applies_specific_stash_and_drops_it(monkeypatch, tmp_path, capsys): +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] == ["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", "stash", "drop", "abc123"] + 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): @@ -59,11 +105,11 @@ def test_restore_stashed_changes_applies_specific_stash_and_drops_it(monkeypatch monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) - hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123") + 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", "stash", "drop", "abc123"] - assert "Restoring local changes" in capsys.readouterr().out + assert "Restore local changes now?" not in capsys.readouterr().out def test_restore_stashed_changes_exits_cleanly_when_apply_fails(monkeypatch, tmp_path, capsys): @@ -76,9 +122,10 @@ def test_restore_stashed_changes_exits_cleanly_when_apply_fails(monkeypatch, tmp 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") + hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=True) out = capsys.readouterr().out assert "Your changes are still preserved in git stash." in out