feat: desktop updates no longer re-apply local source edits (--keep-stash)
The desktop updater ran `hermes update --yes`, which auto-restored any uncommitted source-tree edits onto the freshly updated checkout. On dirty from-source installs this silently carried local modifications across every update and could break the rebuilt app (field report: Windows update handoff leaving the app 'crashed'). New `hermes update --keep-stash`: local changes are still autostashed so the update can proceed, but are never re-applied — they stay parked in git stash with printed recovery guidance. Both desktop handoff scripts (windows.ps1, posix.sh) now pass it, probing `update --help` first so older installed backends without the flag keep working. Failure paths are unchanged (stash preserved, no restore); updates.non_interactive_local_changes: discard still wins. Tests: park/restore/failure-path coverage incl. a sabotage-verified regression test; docs updated.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 <ref>` 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".
|
||||
|
||||
Reference in New Issue
Block a user