diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 0fcc4e6170..62dc3f611d 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4870,6 +4870,7 @@ _LAZY_COMMAND_EXPORTS = { "_defer_update_for_self_lock", "_discard_lockfile_churn", "_discard_stashed_changes", + "_park_stashed_changes", "_ensure_acp_launcher", "_ensure_fhs_path_guard", "_ensure_uv_for_termux", diff --git a/hermes_cli/subcommands/update.py b/hermes_cli/subcommands/update.py index 680693c393..e29fa168a5 100644 --- a/hermes_cli/subcommands/update.py +++ b/hermes_cli/subcommands/update.py @@ -50,6 +50,18 @@ def build_update_parser(subparsers, *, cmd_update: Callable) -> None: default=False, help="Assume yes for interactive prompts (config migration, stash restore). API-key entry is skipped; run 'hermes config migrate' separately for those.", ) + update_parser.add_argument( + "--keep-stash", + action="store_true", + default=False, + help=( + "Do NOT re-apply local changes after the update. Uncommitted " + "changes are still stashed so the update can proceed, but they " + "stay parked in git stash instead of being restored onto the " + "updated code. Used by the desktop updater so local source edits " + "never silently ride along across updates." + ), + ) update_parser.add_argument( "--branch", default=None, diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 84da415cba..37ed4ed567 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -1611,6 +1611,20 @@ def _stash_apply_failed_only_on_existing_untracked(stderr: str) -> bool: return False return saw_untracked_error +def _park_stashed_changes(stash_ref: str) -> None: + """Leave a pre-update autostash parked instead of re-applying it. + + Used by ``hermes update --keep-stash`` (the desktop updater's mode): the + stash made the update possible on a dirty tree, but local source edits + must never be silently re-applied onto the updated code. Nothing is + lost — the entry stays in ``git stash`` with printed recovery guidance. + """ + print() + print("ℹ️ Local changes were stashed before updating and were NOT re-applied (--keep-stash).") + print(f" Stash ref: {stash_ref}") + print(f" Restore manually with: git stash apply {stash_ref}") + + def _restore_stashed_changes( git_cmd: list[str], cwd: Path, @@ -4828,6 +4842,11 @@ def _cmd_update_impl(args, gateway_mode: bool): else None ) assume_yes = bool(getattr(args, "yes", False)) + # --keep-stash (desktop updater): stash local changes so the update can + # proceed, but never re-apply them afterward — they stay parked in git + # stash. Only applies when an update actually landed; abort/no-op paths + # still restore, since the tree they restore onto is unchanged. + keep_stash = bool(getattr(args, "keep_stash", False)) # Whether this update is running without a human at the keyboard. # Interactive terminal updates always stash-and-ask (unchanged behavior); @@ -5508,6 +5527,11 @@ def _cmd_update_impl(args, gateway_mode: bool): _m().PROJECT_ROOT, auto_stash_ref, ) + elif keep_stash: + # --keep-stash (desktop updater): the update landed; leave + # local edits parked in the stash instead of silently + # re-applying them onto the updated code. + _m()._park_stashed_changes(auto_stash_ref) else: _m()._restore_stashed_changes( git_cmd, diff --git a/scripts/desktop-update/posix.sh b/scripts/desktop-update/posix.sh index 1ad37ecdb6..50f36d787c 100755 --- a/scripts/desktop-update/posix.sh +++ b/scripts/desktop-update/posix.sh @@ -498,9 +498,19 @@ cd "$INSTALL_ROOT" || { log "$FINAL_MSG"; exit 3 } export PYTHONUNBUFFERED=1 -log "running: hermes update --yes --gateway --branch $BRANCH" +# --keep-stash: never re-apply local source edits after the update (they stay +# parked in git stash). Probe --help first: older installed backends don't +# know the flag and argparse would abort with exit 2, which collides with the +# "close all Hermes windows" sentinel. +KEEP_STASH="" +if "$HERMES_BIN" update --help 2>/dev/null | grep -q -- '--keep-stash'; then + KEEP_STASH="--keep-stash" +else + log "installed hermes predates --keep-stash; running without it" +fi +log "running: hermes update --yes --gateway $KEEP_STASH --branch $BRANCH" publish_stage "Updating code and dependencies" -OUT="$("$HERMES_BIN" update --yes --gateway --branch "$BRANCH" 2>&1)"; CODE=$? +OUT="$("$HERMES_BIN" update --yes --gateway $KEEP_STASH --branch "$BRANCH" 2>&1)"; CODE=$? printf '%s\n' "$OUT" >> "$LOG" 2>/dev/null log "hermes update exit code: $CODE" @@ -509,7 +519,7 @@ if [ "$CODE" -ne 0 ] && [ "$CODE" -ne 2 ]; then # Exit 2 ("close all Hermes windows") is not retryable. log "retrying once (freshly pulled fix loads on the second run)" publish_stage "Retrying update" - OUT="$("$HERMES_BIN" update --yes --gateway --branch "$BRANCH" 2>&1)"; CODE=$? + OUT="$("$HERMES_BIN" update --yes --gateway $KEEP_STASH --branch "$BRANCH" 2>&1)"; CODE=$? printf '%s\n' "$OUT" >> "$LOG" 2>/dev/null log "retry exit code: $CODE" fi diff --git a/scripts/desktop-update/windows.ps1 b/scripts/desktop-update/windows.ps1 index 8bb2956c74..94889ed4ac 100644 --- a/scripts/desktop-update/windows.ps1 +++ b/scripts/desktop-update/windows.ps1 @@ -776,6 +776,20 @@ try { exit $finalCode } $updateArgs = @("-m", "hermes_cli.main", "update", "--yes", "--gateway", "--force", "--branch", $Branch) + # --keep-stash: never re-apply local source edits after the update (they + # stay parked in git stash). Probe --help first: the flag ships with newer + # backends and an unknown flag would abort argparse with exit 2, which + # collides with the "close all Hermes windows" sentinel. + try { + $updateHelp = & $pythonExe -m hermes_cli.main update --help 2>$null | Out-String + if ($updateHelp -match "--keep-stash") { + $updateArgs += "--keep-stash" + } else { + Write-HandoffLog "installed hermes predates --keep-stash; running without it" + } + } catch { + Write-HandoffLog "could not probe update --help; running without --keep-stash" + } Write-HandoffLog ("running: python " + ($updateArgs -join " ")) Publish-UiProgress "Updating code and dependencies" $res = Invoke-HermesStep $pythonExe $updateArgs "update" diff --git a/tests/hermes_cli/test_update_autostash.py b/tests/hermes_cli/test_update_autostash.py index 53af5ae20e..6a0d743412 100644 --- a/tests/hermes_cli/test_update_autostash.py +++ b/tests/hermes_cli/test_update_autostash.py @@ -229,6 +229,104 @@ def _setup_setting_test(monkeypatch, tmp_path, mode): return restore_calls, discard_calls, recorded +# --------------------------------------------------------------------------- +# --keep-stash (desktop updater): stash for the update, never re-apply. +# --------------------------------------------------------------------------- + +def _setup_keep_stash_test(monkeypatch, tmp_path): + """Wiring for --keep-stash tests: stash returns a ref; restore, discard, + and park are all recorded.""" + _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 = [] + park_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_main, "_park_stashed_changes", + lambda *a, **kw: park_calls.append(a) or None, + ) + # Keep the update flow away from the real gateway fleet on this machine — + # a live gateway PID would trip the test-suite kill guard and turn the + # run into exit 1 (gateway_fleet_restart_incomplete). + monkeypatch.setattr( + "hermes_cli.gateway.find_gateway_pids", lambda **kw: [], raising=False + ) + return restore_calls, discard_calls, park_calls + + +def test_update_keep_stash_parks_instead_of_restoring(monkeypatch, tmp_path): + """--keep-stash: after a successful update, the autostash is parked (left + in git stash) — never re-applied, never discarded.""" + restore_calls, discard_calls, park_calls = _setup_keep_stash_test(monkeypatch, tmp_path) + side_effect, _ = _make_update_side_effect() + monkeypatch.setattr(hermes_main.subprocess, "run", side_effect) + + hermes_main.cmd_update(SimpleNamespace(yes=True, keep_stash=True)) + + assert len(park_calls) == 1 + assert park_calls[0][0] == "abc123deadbeef" + assert restore_calls == [] + assert discard_calls == [] + + +def test_update_without_keep_stash_still_restores(monkeypatch, tmp_path): + """Regression guard: default behavior (no --keep-stash) is unchanged — + the autostash is auto-restored under --yes.""" + restore_calls, discard_calls, park_calls = _setup_keep_stash_test(monkeypatch, tmp_path) + side_effect, _ = _make_update_side_effect() + monkeypatch.setattr(hermes_main.subprocess, "run", side_effect) + + hermes_main.cmd_update(SimpleNamespace(yes=True, keep_stash=False)) + + assert restore_calls == [1] + assert park_calls == [] + assert discard_calls == [] + + +def test_update_keep_stash_failure_path_still_preserves(monkeypatch, tmp_path, capsys): + """--keep-stash + failed update: neither restore nor park runs; the + existing preserved-in-stash message fires (working tree unknown).""" + restore_calls, discard_calls, park_calls = _setup_keep_stash_test(monkeypatch, tmp_path) + 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(yes=True, keep_stash=True)) + + assert restore_calls == [] + assert park_calls == [] + assert discard_calls == [] + assert "preserved in stash" in capsys.readouterr().out + + +def test_update_parser_accepts_keep_stash(): + """The flag parses and defaults off.""" + import argparse + + from hermes_cli.subcommands.update import build_update_parser + + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers() + build_update_parser(subparsers, cmd_update=lambda args: None) + + args = parser.parse_args(["update", "--keep-stash"]) + assert args.keep_stash is True + args = parser.parse_args(["update"]) + assert args.keep_stash is False + + diff --git a/website/docs/getting-started/updating.md b/website/docs/getting-started/updating.md index 0181b59f2c..4e1f33e9bc 100644 --- a/website/docs/getting-started/updating.md +++ b/website/docs/getting-started/updating.md @@ -66,6 +66,16 @@ updates: In the desktop app this is **Settings → Advanced → In-App Update Local Changes**. +**Desktop updates never auto-restore.** The desktop updater invokes `hermes update --keep-stash`: local source edits are still stashed so the update can proceed, but they are **not** re-applied afterward — they stay parked in `git stash` and the update log prints the exact `git stash apply ` command to bring them back. This prevents local edits from silently riding along across desktop updates and breaking the freshly updated install. (`non_interactive_local_changes: discard` still wins if you've opted into discarding.) To restore parked changes manually: + +```bash +cd ~/.hermes/hermes-agent # or your install root +git stash list --format='%gd %H %s' # find the hermes-update-autostash entry +git stash apply stash@{0} +``` + +You can pass `--keep-stash` to a terminal `hermes update` too if you want the same never-reapply behavior interactively. + ### Preview-only: `hermes update --check` Want to know if an update is available before pulling? Run `hermes update --check` — it fetches and compares commits against `origin/main`. No files are modified, no gateway is restarted. Useful in scripts and cron jobs that gate on "is there an update".