mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix: auto-stash local changes during updates
This commit is contained in:
parent
1a857123b3
commit
f764c7135d
3 changed files with 185 additions and 2 deletions
|
|
@ -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...")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
102
tests/hermes_cli/test_update_autostash.py
Normal file
102
tests/hermes_cli/test_update_autostash.py
Normal file
|
|
@ -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))
|
||||
Loading…
Add table
Add a link
Reference in a new issue