From 72eb42d9ecdf5c20032a405326995c8c1680aa1c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 5 Jun 2026 05:00:10 -0700 Subject: [PATCH] feat(update): stash/restore by default + settable discard for non-interactive updates (reverts #38542, #39568) (#39645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Revert "fix(update): require managed marker before destructive clean" This reverts commit c8e80cd0bfdbbfa0b14296ef59a1c3d353917add. * Revert "fix(update): stop stash/restore from clobbering desktop source on managed clones (#38542)" This reverts commit 8a19884bf3995a8d1144c828582de043abb4331c. * chore(install): keep npm ci desktop-build fix after stash revert The destructive-clean reverts (#38542/#39568) pulled the desktop workspace install back to bare `npm install`. The npm ci -> npm install fallback is orthogonal build-correctness (avoids the Windows workspace-hoisting flake where install reports up-to-date against a stale marker while node_modules is empty, breaking tsc -b). Preserve it. * feat(update): settable stash-or-discard for non-interactive local changes Adds updates.non_interactive_local_changes (stash | discard, default stash). Governs ONLY non-interactive updates (desktop/chat app, gateway, --yes) — interactive terminal updates always stash-and-ask, unchanged. - config.py: new key under existing updates section; _config_version 26->27. - main.py: _cmd_update_impl detects non-interactive (gateway/--yes/no-TTY), reads the setting; new _discard_stashed_changes() drops the stash (stash-and-drop, never reset --hard/clean -fd, so ignored paths survive). Post-pull restore site branches on it; the bail-out and up-to-date restores always preserve work. - web_server.py + apps/desktop settings: exposes it as a stash/discard select (Advanced section, In-App Update Local Changes). - docs + tests (discard drops, stash restores, interactive ignores setting, missing section defaults to stash). * fix(install.ps1): stash/restore instead of reset --hard on Windows update The PR reverted the destructive update path to stash/restore everywhere except scripts/install.ps1, whose managed-clone update path still ran `git reset --hard HEAD` before checkout — silently destroying agent-edited tracked source on Windows (the same #38542 data-loss class the PR fixes). - Replace `git reset --hard HEAD` with stash-before-checkout + restore-after-checkout, mirroring install.sh. Untracked files are included so agent-created dirs (e.g. tinker-atropos/) survive. - Keep `core.autocrlf false` (it prevents the phantom CRLF dirt that made the stash necessary; it's also load-bearing for a clean restore). - Wrap all three checkout modes (Commit/Tag/Branch); Branch case now uses `git pull --ff-only` so local commits are never clobbered. - Only prompt to restore when a real console is attached (UserInteractive + non-redirected stdin/stdout + ConsoleHost); the desktop Update button and bootstrap have no usable console, so they default to restore and never hang on Read-Host. - On restore conflict or a failed update, the stash is preserved with recovery instructions — work is never silently dropped. Validated on Windows (PowerShell 5.1, git 2.54): AST parse clean; E2E non-conflicting restore applies+drops cleanly with ignored paths (node_modules) untouched; conflicting restore preserves the stash. --------- Co-authored-by: alt-glitch --- apps/desktop/src/app/settings/constants.ts | 13 +- hermes_cli/config.py | 20 +- hermes_cli/main.py | 191 +++++++++---------- hermes_cli/web_server.py | 11 ++ scripts/install.ps1 | 76 +++++++- scripts/install.sh | 56 ++++-- tests/hermes_cli/test_update_autostash.py | 205 +++++++++------------ website/docs/getting-started/updating.md | 18 ++ 8 files changed, 335 insertions(+), 255 deletions(-) diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 1a2891d7257..99efb342589 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -241,7 +241,8 @@ export const ENUM_OPTIONS: Record = { 'memory.provider': ['', 'builtin', 'honcho'], 'stt.elevenlabs.model_id': ['scribe_v2', 'scribe_v1'], 'stt.local.model': ['tiny', 'base', 'small', 'medium', 'large-v3'], - 'tts.openai.voice': ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'] + 'tts.openai.voice': ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'], + 'updates.non_interactive_local_changes': ['stash', 'discard'] } export const FIELD_LABELS: Record = { @@ -309,7 +310,8 @@ export const FIELD_LABELS: Record = { 'delegation.max_iterations': 'Subagent Turn Limit', 'delegation.max_concurrent_children': 'Parallel Subagents', 'delegation.child_timeout_seconds': 'Subagent Timeout', - 'delegation.reasoning_effort': 'Subagent Reasoning Effort' + 'delegation.reasoning_effort': 'Subagent Reasoning Effort', + 'updates.non_interactive_local_changes': 'In-App Update Local Changes' } export const FIELD_DESCRIPTIONS: Record = { @@ -336,7 +338,9 @@ export const FIELD_DESCRIPTIONS: Record = { 'voice.auto_tts': 'Automatically speak assistant responses.', 'stt.enabled': 'Enable local or provider-backed speech transcription.', 'stt.elevenlabs.language_code': 'Optional ISO-639-3 language code. Blank lets ElevenLabs auto-detect.', - 'agent.max_turns': 'Upper bound for tool-calling turns before Hermes stops a run.' + 'agent.max_turns': 'Upper bound for tool-calling turns before Hermes stops a run.', + 'updates.non_interactive_local_changes': + 'When Hermes updates itself from the app (no terminal prompt), keep local source edits (stash) or throw them away (discard). Terminal updates always ask.' } // Curated desktop config surface: only fields a user might tune from the app. @@ -449,7 +453,8 @@ export const SECTIONS: DesktopConfigSection[] = [ 'delegation.max_iterations', 'delegation.max_concurrent_children', 'delegation.child_timeout_seconds', - 'delegation.reasoning_effort' + 'delegation.reasoning_effort', + 'updates.non_interactive_local_changes' ] } ] diff --git a/hermes_cli/config.py b/hermes_cli/config.py index b18a8994e18..644485d8d36 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2275,6 +2275,22 @@ DEFAULT_CONFIG = { # disable backups entirely, set ``pre_update_backup: false`` above # rather than ``backup_keep: 0``. "backup_keep": 5, + # What `hermes update` does with uncommitted local changes to the + # source tree when it runs NON-interactively — i.e. triggered from + # the desktop/chat app or the gateway, where there's no TTY to answer + # a restore prompt. Interactive (terminal) updates are unaffected: + # they always stash the changes and ask whether to restore, exactly + # as they always have. + # "stash" — auto-stash the changes, pull, then auto-restore them + # on top of the updated code (the safe default; nothing + # is ever lost — conflicts are preserved in a git stash). + # "discard" — auto-stash the changes and throw the stash away after + # the pull. Use this only if you never intend to keep + # local edits to the source tree on this machine. + # Stash-and-drop (not `reset --hard` + `clean -fd`) so + # ignored paths — node_modules, venv, build outputs — + # are never touched. + "non_interactive_local_changes": "stash", }, # Language Server Protocol — semantic diagnostics from real @@ -2404,7 +2420,7 @@ DEFAULT_CONFIG = { # Config schema version - bump this when adding new required fields - "_config_version": 26, + "_config_version": 27, } # ============================================================================= @@ -3959,7 +3975,7 @@ _KNOWN_ROOT_KEYS = { "fallback_providers", "credential_pool_strategies", "toolsets", "agent", "terminal", "display", "compression", "delegation", "auxiliary", "custom_providers", "context", "memory", "gateway", - "sessions", "streaming", + "sessions", "streaming", "updates", } # Valid fields inside a custom_providers list entry diff --git a/hermes_cli/main.py b/hermes_cli/main.py index f2df6e778d1..7527b4d48af 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -8148,59 +8148,6 @@ def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[st return stash_ref -def _clean_managed_worktree(git_cmd: list[str], cwd: Path) -> bool: - """Discard working-tree dirt on an explicitly managed checkout. - - On a Desktop/bootstrap-managed install the user never edits the source - tree, so any "dirty" state is pure git artifact: CRLF renormalization, npm - lockfile churn, or files left behind when a directory was deleted upstream - (e.g. apps/bootstrap-installer/). Stashing that dirt and re-applying it - after a pull is actively dangerous — the stash/restore cycle has been - observed to clobber freshly-pulled source files (apps/desktop/ deletion → - "[UNRESOLVED_ENTRY] Cannot resolve entry module index.html"). - - For an explicitly managed checkout the correct move is to throw the dirt - away with ``git reset --hard HEAD`` + ``git clean -fd`` (mirroring - install.ps1's update path), NOT preserve it. Ordinary source checkouts, - including upstream-origin checkouts, keep the stash machinery because - their local edits may be intentional. - - Returns True if the tree was cleaned (or was already clean), False on - a git failure (caller should fall back to the stash path). - """ - status = subprocess.run( - git_cmd + ["status", "--porcelain"], - cwd=cwd, - capture_output=True, - text=True, - ) - if status.returncode != 0: - return False - if not status.stdout.strip(): - return True - - print("→ Discarding working-tree changes on managed clone before update...") - reset = subprocess.run( - git_cmd + ["reset", "--hard", "HEAD"], - cwd=cwd, - capture_output=True, - text=True, - ) - if reset.returncode != 0: - return False - # Drop untracked files too (e.g. orphaned build artifacts), but never - # touch ignored paths — node_modules, venv, build outputs, and the like - # are expensive to rebuild and not git-artifact dirt. - subprocess.run( - git_cmd + ["clean", "-fd"], - cwd=cwd, - capture_output=True, - text=True, - ) - return True - - - def _resolve_stash_selector( git_cmd: list[str], cwd: Path, stash_ref: str ) -> Optional[str]: @@ -8341,6 +8288,54 @@ def _restore_stashed_changes( return True +def _discard_stashed_changes( + git_cmd: list[str], + cwd: Path, + stash_ref: str, +) -> bool: + """Throw away a stash created before an update, without applying it. + + Used only on a NON-interactive update when the user has set + ``updates.non_interactive_local_changes: discard`` — i.e. they've opted out + of keeping local source edits on this machine. Drops the stash entry + instead of re-applying it, so the working tree stays clean at the freshly + pulled HEAD. Unlike ``git reset --hard`` + ``git clean -fd``, this only + affects what was stashed (tracked changes + the untracked files we + explicitly captured) — ignored paths like node_modules/venv/build outputs + are never touched, since they were never stashed. + + Returns True if the stash was dropped, False on a git failure (in which + case the stash is left in place for safety). + """ + stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref) + if stash_selector is None: + print( + "⚠ Configured to discard local changes on non-interactive update, " + "but Hermes couldn't find the stash entry to drop." + ) + _print_stash_cleanup_guidance(stash_ref) + return False + + drop = subprocess.run( + git_cmd + ["stash", "drop", stash_selector], + cwd=cwd, + capture_output=True, + text=True, + ) + if drop.returncode != 0: + print( + "⚠ Configured to discard local changes, but Hermes couldn't drop " + "the saved stash entry." + ) + if drop.stderr.strip(): + print(f" {drop.stderr.strip().splitlines()[0]}") + _print_stash_cleanup_guidance(stash_ref, stash_selector) + return False + + print("→ Discarded local source changes (updates.non_interactive_local_changes=discard).") + return True + + # ========================================================================= # Fork detection and upstream management for `hermes update` # ========================================================================= @@ -8353,7 +8348,6 @@ OFFICIAL_REPO_URLS = { } OFFICIAL_REPO_URL = "https://github.com/NousResearch/hermes-agent.git" SKIP_UPSTREAM_PROMPT_FILE = ".skip_upstream_prompt" -MANAGED_CHECKOUT_MARKERS = (".hermes-bootstrap-complete",) def _get_origin_url(git_cmd: list[str], cwd: Path) -> Optional[str]: @@ -8389,19 +8383,6 @@ def _is_fork(origin_url: Optional[str]) -> bool: return True -def _is_managed_update_checkout(origin_url: Optional[str], cwd: Path) -> bool: - """Return True when this official checkout is safe to clean destructively. - - The destructive clean path is only safe for checkouts Hermes explicitly - owns. An official ``origin`` alone is not enough proof: contributors can - also work from upstream-origin source checkouts with intentional local - files. - """ - if not origin_url or _is_fork(origin_url): - return False - return any((cwd / marker).is_file() for marker in MANAGED_CHECKOUT_MARKERS) - - def _has_upstream_remote(git_cmd: list[str], cwd: Path) -> bool: """Check if an 'upstream' remote already exists.""" try: @@ -10156,6 +10137,30 @@ def _cmd_update_impl(args, gateway_mode: bool): ) assume_yes = bool(getattr(args, "yes", False)) + # Whether this update is running without a human at the keyboard. + # Interactive terminal updates always stash-and-ask (unchanged behavior); + # only non-interactive updates (desktop/chat app, gateway, `--yes`) consult + # the `updates.non_interactive_local_changes` config setting to decide + # whether to auto-restore stashed local source changes or throw them away. + _non_interactive_update = ( + gateway_mode + or assume_yes + or not (sys.stdin.isatty() and sys.stdout.isatty()) + ) + discard_local_changes = False + if _non_interactive_update: + try: + from hermes_cli.config import load_config + + _update_cfg = (load_config() or {}).get("updates", {}) + if isinstance(_update_cfg, dict): + _mode = str(_update_cfg.get("non_interactive_local_changes", "stash")).lower() + discard_local_changes = _mode == "discard" + except Exception as exc: + # Never let a config read failure change the safe default. + logger.debug("Could not read updates.non_interactive_local_changes: %s", exc) + discard_local_changes = False + print("⚕ Updating Hermes Agent...") print() @@ -10217,21 +10222,6 @@ def _cmd_update_impl(args, gateway_mode: bool): if sys.platform == "win32": git_cmd = ["git", "-c", "windows.appendAtomically=false"] - # On Windows, Git-for-Windows defaults to core.autocrlf=true, which - # renormalizes the repo's LF-only text files to CRLF in the working tree. - # On a managed, never-user-edited clone that makes tracked files read as - # "locally modified", which forces an autostash on every update (and the - # stash/restore cycle can clobber source files — see _stash_local_changes_ - # if_needed below). Pin autocrlf=false so the dirt is never created. This - # mirrors what install.ps1's update path already does (PR #38239). - if sys.platform == "win32" and git_dir.exists(): - subprocess.run( - git_cmd + ["config", "core.autocrlf", "false"], - cwd=PROJECT_ROOT, - check=False, - capture_output=True, - ) - # Discard npm lockfile churn before any stash/branch logic. npm rewrites # tracked package-lock.json files non-deterministically at install/build # time (platform-specific optional deps, ideallyInert annotations, etc.), @@ -10241,12 +10231,9 @@ def _cmd_update_impl(args, gateway_mode: bool): # lockfile churn) update with a clean tree. _discard_lockfile_churn(git_cmd, PROJECT_ROOT) - # Detect if we're updating from a fork, and whether this official-origin - # checkout has an explicit Hermes-owned marker that makes destructive - # worktree cleanup safe. + # Detect if we're updating from a fork (before any branch logic) origin_url = _get_origin_url(git_cmd, PROJECT_ROOT) is_fork = _is_fork(origin_url) - is_managed_checkout = _is_managed_update_checkout(origin_url, PROJECT_ROOT) if is_fork: print("⚠ Updating from fork:") @@ -10312,15 +10299,8 @@ def _cmd_update_impl(args, gateway_mode: bool): else f"branch '{current_branch}'" ) print(f" ⚠ Currently on {label} — switching to {branch} for update...") - # Stash before checkout so uncommitted work isn't lost — but on an - # explicitly managed checkout there's nothing to preserve, so - # discard git-artifact dirt instead (a dirty tree would otherwise - # block the checkout). Other checkouts keep the stash so their - # edits survive. - if is_managed_checkout and _clean_managed_worktree(git_cmd, PROJECT_ROOT): - auto_stash_ref = None - else: - auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT) + # Stash before checkout so uncommitted work isn't lost + auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT) checkout_result = subprocess.run( git_cmd + ["checkout", branch], cwd=PROJECT_ROOT, @@ -10354,17 +10334,7 @@ def _cmd_update_impl(args, gateway_mode: bool): print(f" {track_result.stderr.strip().splitlines()[0]}") sys.exit(1) else: - # On an explicitly managed checkout the user never edits the - # source tree, so any dirt is git artifact (CRLF, lockfile churn, - # upstream-deleted dirs). Throw it away rather than stash/restore - # it — the stash/restore cycle has clobbered freshly-pulled source - # (apps/desktop/ → "[UNRESOLVED_ENTRY] index.html"). Other - # checkouts fall through to the stash path so their intentional - # edits survive. - if is_managed_checkout and _clean_managed_worktree(git_cmd, PROJECT_ROOT): - auto_stash_ref = None - else: - auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT) + auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT) prompt_for_restore = ( auto_stash_ref is not None @@ -10516,6 +10486,15 @@ def _cmd_update_impl(args, gateway_mode: bool): f" ℹ️ Local changes preserved in stash (ref: {auto_stash_ref})" ) print(f" Restore manually with: git stash apply") + elif discard_local_changes: + # Non-interactive update + user opted into discarding local + # source edits (updates.non_interactive_local_changes: + # discard). Throw the stash away instead of re-applying it. + _discard_stashed_changes( + git_cmd, + PROJECT_ROOT, + auto_stash_ref, + ) else: _restore_stashed_changes( git_cmd, diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 3798f71e8bd..98107dcbe95 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -439,6 +439,16 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = { "description": "Reasoning effort for delegated subagents", "options": ["", "low", "medium", "high"], }, + "updates.non_interactive_local_changes": { + "type": "select", + "description": ( + "When the chat app / gateway updates Hermes (no terminal prompt), " + "what to do with uncommitted local source edits. 'stash' keeps them " + "and re-applies them after the update; 'discard' throws them away. " + "Terminal updates always ask, regardless of this setting." + ), + "options": ["stash", "discard"], + }, } # Categories with fewer fields get merged into "general" to avoid tab sprawl. @@ -455,6 +465,7 @@ _CATEGORY_MERGE: Dict[str, str] = { "code_execution": "agent", "prompt_caching": "agent", "goals": "agent", + "updates": "general", # Only `telegram.reactions` currently lives under telegram — fold it in # with the other messaging-platform config (discord) so it isn't an # orphan tab of one field. diff --git a/scripts/install.ps1 b/scripts/install.ps1 index c6179e1b697..fea73649ca5 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1063,6 +1063,7 @@ function Install-Repository { # EAP=Stop. We rely on $LASTEXITCODE for actual failures. $prevEAP = $ErrorActionPreference $ErrorActionPreference = "Continue" + $autostashRef = "" try { # This is a MANAGED checkout, not a repo the user edits. Git for # Windows defaults to core.autocrlf=true, which renormalizes the @@ -1071,12 +1072,23 @@ function Install-Repository { # show as locally modified even though nobody touched them. A # bare `git checkout` then aborts with "Your local changes would # be overwritten by checkout", which is exactly the failure GUI - # users hit on update. Two-part fix: (1) stop creating the dirt - # by pinning autocrlf=false on this clone, (2) discard any - # pre-existing dirt with a hard reset before the checkout. Safe - # because nothing here is user-authored. + # users hit on update. Pin autocrlf=false so the dirt is never + # created in the first place. git -c windows.appendAtomically=false config core.autocrlf false 2>$null - git -c windows.appendAtomically=false reset --hard HEAD 2>$null + # Preserve any real local changes before the checkout instead of + # discarding them with `reset --hard HEAD`. The old hard reset + # silently destroyed agent-edited source on managed clones (the + # #38542 data-loss class). Stash + restore mirrors install.sh: + # nothing is lost, and a failed restore leaves the work in a + # git stash for manual recovery. Untracked files are included so + # agent-created dirs (e.g. tinker-atropos/) survive too. + $statusOut = git -c windows.appendAtomically=false status --porcelain 2>$null + if (-not [string]::IsNullOrWhiteSpace(($statusOut -join "`n"))) { + $stashName = "hermes-install-autostash-" + (Get-Date -Format "yyyyMMdd-HHmmss") + Write-Info "Local changes detected, stashing before update..." + git -c windows.appendAtomically=false stash push --include-untracked -m "$stashName" + if ($LASTEXITCODE -eq 0) { $autostashRef = "stash@{0}" } + } git -c windows.appendAtomically=false fetch origin if ($LASTEXITCODE -ne 0) { throw "git fetch failed (exit $LASTEXITCODE)" } # Precedence: Commit > Tag > Branch. Commit and Tag check @@ -1095,10 +1107,62 @@ function Install-Repository { } else { git -c windows.appendAtomically=false checkout $Branch if ($LASTEXITCODE -ne 0) { throw "git checkout $Branch failed (exit $LASTEXITCODE)" } - git -c windows.appendAtomically=false pull origin $Branch + git -c windows.appendAtomically=false pull --ff-only origin $Branch if ($LASTEXITCODE -ne 0) { throw "git pull failed (exit $LASTEXITCODE)" } } + + if ($autostashRef) { + # Default to restoring so work is never silently dropped. + # Only prompt when we're certain a human can answer: an + # interactive session AND a real, non-redirected console on + # both stdin and stdout. The desktop "Update" button and + # bootstrap run the installer without a usable console -- in + # those cases Read-Host would hang or return empty, so we + # skip the prompt and just restore (the safe default). + $restoreNow = $true + $hasConsole = $false + try { + $hasConsole = ( + [Environment]::UserInteractive ` + -and (-not [Console]::IsInputRedirected) ` + -and (-not [Console]::IsOutputRedirected) ` + -and ($Host.Name -eq "ConsoleHost") + ) + } catch { $hasConsole = $false } + if ($hasConsole) { + Write-Warn "Local changes were stashed before updating." + Write-Warn "Restoring them may reapply local customizations onto the updated codebase." + $restoreAnswer = Read-Host "Restore local changes now? [Y/n]" + if ($restoreAnswer -match '^(n|no)$') { $restoreNow = $false } + } + + if ($restoreNow) { + Write-Info "Restoring local changes..." + git -c windows.appendAtomically=false stash apply $autostashRef + if ($LASTEXITCODE -eq 0) { + git -c windows.appendAtomically=false stash drop $autostashRef 2>$null + Write-Warn "Local changes were restored on top of the updated codebase." + Write-Warn "Review git diff / git status if Hermes behaves unexpectedly." + } else { + Write-Err "Update succeeded, but restoring local changes failed. Your changes are still preserved in git stash." + Write-Info "Resolve manually with: git stash apply $autostashRef" + throw "git stash apply failed after update" + } + } else { + Write-Info "Skipped restoring local changes." + Write-Info "Your changes are still preserved in git stash." + Write-Info "Restore manually with: git stash apply $autostashRef" + } + $autostashRef = "" + } } finally { + if ($autostashRef) { + # We stashed but never reached the restore block (a fetch/ + # checkout/pull failure threw). Leave the stash in place and + # tell the user how to recover it -- never silently drop it. + Write-Warn "Update did not complete. Your local changes are preserved in git stash." + Write-Info "Restore manually with: git stash apply $autostashRef" + } $ErrorActionPreference = $prevEAP Pop-Location } diff --git a/scripts/install.sh b/scripts/install.sh index 06758f1f0b2..d735c6d75bc 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1097,24 +1097,50 @@ clone_repo() { log_info "Existing installation found, updating..." cd "$INSTALL_DIR" - # This is a managed clone the user never edits, so any working-tree - # dirt is git artifact (CRLF renormalization, npm lockfile churn, - # files left behind when a directory was deleted upstream such as - # apps/bootstrap-installer/). The old path stashed that dirt and - # re-applied it after the pull, but the stash/restore cycle has - # clobbered freshly-pulled source files (apps/desktop/ → - # "[UNRESOLVED_ENTRY] Cannot resolve entry module index.html"). - # Discard the dirt with a hard reset instead — mirrors install.ps1's - # update path. Fork users customize via `hermes update`, which keeps - # the stash machinery; the installer is a managed-only entry point. - git fetch origin + local autostash_ref="" if [ -n "$(git status --porcelain)" ]; then - log_info "Discarding working-tree changes on managed clone before update..." - git reset --hard HEAD >/dev/null 2>&1 || true - git clean -fd >/dev/null 2>&1 || true + 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="stash@{0}" fi + + git fetch origin git checkout "$BRANCH" - git reset --hard "origin/$BRANCH" + git pull --ff-only origin "$BRANCH" + + if [ -n "$autostash_ref" ]; then + 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_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 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 index 16946834ab1..8457784c78b 100644 --- a/tests/hermes_cli/test_update_autostash.py +++ b/tests/hermes_cli/test_update_autostash.py @@ -592,10 +592,6 @@ def test_cmd_update_restores_stash_and_branch_when_already_up_to_date(monkeypatc hermes_main, "_stash_local_changes_if_needed", lambda *a, **kw: "abc123deadbeef", ) - # Force the stash path (not the managed-clone clean path) so this test - # exercises stash restore. A real fork, or a clone where the managed - # clean fails, falls through to stash. - monkeypatch.setattr(hermes_main, "_clean_managed_worktree", lambda *a, **kw: False) restore_calls = [] monkeypatch.setattr( hermes_main, "_restore_stashed_changes", @@ -634,121 +630,6 @@ def test_cmd_update_no_checkout_when_already_on_main(monkeypatch, tmp_path): assert len(checkout_calls) == 0 -def test_cmd_update_managed_clone_cleans_instead_of_stashing(monkeypatch, tmp_path): - """On an explicitly managed clone, working-tree dirt is discarded via - _clean_managed_worktree, NOT preserved via stash/restore. - - The stash/restore cycle has clobbered freshly-pulled source files - (apps/desktop/ deletion → [UNRESOLVED_ENTRY] index.html). A checkout with - the Desktop/bootstrap marker has nothing the user authored, so the correct - move is to throw the git-artifact dirt away and pull cleanly. - """ - _setup_update_mocks(monkeypatch, tmp_path) - (tmp_path / ".hermes-bootstrap-complete").write_text("{}", encoding="utf-8") - monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) - # Official origin → not a fork. - monkeypatch.setattr( - hermes_main, "_get_origin_url", - lambda *a, **kw: "https://github.com/NousResearch/hermes-agent.git", - ) - clean_calls = [] - monkeypatch.setattr( - hermes_main, "_clean_managed_worktree", - lambda *a, **kw: clean_calls.append(1) or True, - ) - stash_calls = [] - monkeypatch.setattr( - hermes_main, "_stash_local_changes_if_needed", - lambda *a, **kw: stash_calls.append(1) or "shouldnotbeused", - ) - restore_calls = [] - monkeypatch.setattr( - hermes_main, "_restore_stashed_changes", - lambda *a, **kw: restore_calls.append(1) or True, - ) - - side_effect, _ = _make_update_side_effect(commit_count="0") - monkeypatch.setattr(hermes_main.subprocess, "run", side_effect) - - hermes_main.cmd_update(SimpleNamespace()) - - # Managed clean path used; stash path never touched. - assert len(clean_calls) == 1 - assert len(stash_calls) == 0 - assert len(restore_calls) == 0 - - -def test_cmd_update_official_checkout_without_managed_marker_stashes(monkeypatch, tmp_path): - """An upstream-origin source checkout is not safe to clean destructively - unless Hermes wrote an explicit managed-checkout marker.""" - _setup_update_mocks(monkeypatch, tmp_path) - monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) - monkeypatch.setattr( - hermes_main, - "_get_origin_url", - lambda *a, **kw: "https://github.com/NousResearch/hermes-agent.git", - ) - clean_calls = [] - monkeypatch.setattr( - hermes_main, - "_clean_managed_worktree", - lambda *a, **kw: clean_calls.append(1) or True, - ) - stash_calls = [] - monkeypatch.setattr( - hermes_main, - "_stash_local_changes_if_needed", - lambda *a, **kw: stash_calls.append(1) or "abc123", - ) - restore_calls = [] - monkeypatch.setattr( - hermes_main, - "_restore_stashed_changes", - lambda *a, **kw: restore_calls.append(1) or True, - ) - - side_effect, _ = _make_update_side_effect(commit_count="0") - monkeypatch.setattr(hermes_main.subprocess, "run", side_effect) - - hermes_main.cmd_update(SimpleNamespace()) - - assert len(clean_calls) == 0 - assert len(stash_calls) == 1 - assert len(restore_calls) == 1 - - -def test_cmd_update_fork_still_uses_stash(monkeypatch, tmp_path): - """A fork (non-official origin) keeps the stash machinery so the user's - intentional local edits survive the update.""" - _setup_update_mocks(monkeypatch, tmp_path) - monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) - monkeypatch.setattr( - hermes_main, "_get_origin_url", - lambda *a, **kw: "https://github.com/someuser/hermes-agent.git", - ) - clean_calls = [] - monkeypatch.setattr( - hermes_main, "_clean_managed_worktree", - lambda *a, **kw: clean_calls.append(1) or True, - ) - stash_calls = [] - monkeypatch.setattr( - hermes_main, "_stash_local_changes_if_needed", - lambda *a, **kw: stash_calls.append(1) or "abc123", - ) - monkeypatch.setattr(hermes_main, "_restore_stashed_changes", lambda *a, **kw: True) - monkeypatch.setattr(hermes_main, "_sync_with_upstream_if_needed", lambda *a, **kw: None) - - side_effect, _ = _make_update_side_effect(commit_count="0") - monkeypatch.setattr(hermes_main.subprocess, "run", side_effect) - - hermes_main.cmd_update(SimpleNamespace()) - - # Fork: stash path used, managed clean NOT used. - assert len(stash_calls) == 1 - assert len(clean_calls) == 0 - - # --------------------------------------------------------------------------- # Fetch failure — friendly error messages # --------------------------------------------------------------------------- @@ -799,9 +680,6 @@ def test_cmd_update_skips_stash_restore_when_reset_fails(monkeypatch, tmp_path, hermes_main, "_stash_local_changes_if_needed", lambda *a, **kw: "abc123deadbeef", ) - # Force the stash path so this test exercises the reset-failure handling - # of the stash branch (not the managed-clone clean path). - monkeypatch.setattr(hermes_main, "_clean_managed_worktree", lambda *a, **kw: False) restore_calls = [] monkeypatch.setattr( hermes_main, "_restore_stashed_changes", @@ -821,6 +699,89 @@ def test_cmd_update_skips_stash_restore_when_reset_fails(monkeypatch, tmp_path, assert "preserved in stash" in out +# --------------------------------------------------------------------------- +# Non-interactive update.non_interactive_local_changes setting +# (chat app / gateway): "discard" throws stashed changes away, "stash" +# (default) restores them. Interactive terminal updates ignore the setting +# and always go through the restore path. +# --------------------------------------------------------------------------- + +def _setup_setting_test(monkeypatch, tmp_path, mode): + """Common wiring: real stash returns a ref, restore + discard are + recorded, and load_config reports the given non_interactive_local_changes + mode.""" + _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 = [] + 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_config, "load_config", + lambda *a, **kw: {"updates": {"non_interactive_local_changes": mode}}, + ) + side_effect, recorded = _make_update_side_effect() + monkeypatch.setattr(hermes_main.subprocess, "run", side_effect) + return restore_calls, discard_calls, recorded + + +def test_non_interactive_discard_throws_changes_away(monkeypatch, tmp_path): + """Gateway/chat-app update with discard mode drops the stash, never restores.""" + restore_calls, discard_calls, _ = _setup_setting_test(monkeypatch, tmp_path, "discard") + + hermes_main.cmd_update(SimpleNamespace(gateway=True)) + + assert len(discard_calls) == 1 + assert len(restore_calls) == 0 + + +def test_non_interactive_stash_restores_changes(monkeypatch, tmp_path): + """Gateway/chat-app update with the default stash mode restores, never discards.""" + restore_calls, discard_calls, _ = _setup_setting_test(monkeypatch, tmp_path, "stash") + + hermes_main.cmd_update(SimpleNamespace(gateway=True)) + + assert len(restore_calls) == 1 + assert len(discard_calls) == 0 + + +def test_interactive_update_ignores_discard_setting(monkeypatch, tmp_path): + """An interactive (TTY) terminal update always restores — the discard + setting only governs non-interactive updates.""" + restore_calls, discard_calls, _ = _setup_setting_test(monkeypatch, tmp_path, "discard") + # Force an interactive TTY so _non_interactive_update is False even though + # the config says discard. + monkeypatch.setattr(hermes_main.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(hermes_main.sys.stdout, "isatty", lambda: True) + + hermes_main.cmd_update(SimpleNamespace()) # no gateway, no --yes + + assert len(restore_calls) == 1 + assert len(discard_calls) == 0 + + +def test_non_interactive_defaults_to_stash_when_setting_absent(monkeypatch, tmp_path): + """A config with no update section falls back to stash (safe default).""" + restore_calls, discard_calls, _ = _setup_setting_test(monkeypatch, tmp_path, "stash") + # Override load_config to return a config with NO update section at all. + monkeypatch.setattr(hermes_config, "load_config", lambda *a, **kw: {"model": {}}) + + hermes_main.cmd_update(SimpleNamespace(gateway=True)) + + assert len(restore_calls) == 1 + assert len(discard_calls) == 0 + + def test_bootstrap_marker_not_autostashed_by_update(tmp_path): """#38529: the Desktop bootstrap marker must be git-ignored so that ``hermes update``'s ``git stash push --include-untracked`` does not sweep it diff --git a/website/docs/getting-started/updating.md b/website/docs/getting-started/updating.md index 333d6d34172..330b730075d 100644 --- a/website/docs/getting-started/updating.md +++ b/website/docs/getting-started/updating.md @@ -59,6 +59,24 @@ hermes update --check --branch experimental # preview behindness only If your local checkout is on a different branch, Hermes auto-stashes any uncommitted work, switches HEAD to the target branch, and then pulls. Branches that don't exist locally are auto-tracked from `origin/` (`git checkout -B origin/`). Branches that don't exist anywhere fail cleanly — your stashed changes are restored before exit so you're never stranded in a weird state. The `main`-only fork-upstream sync logic is automatically skipped on non-`main` branches. +### Local changes on non-interactive updates + +When you run `hermes update` in a terminal, Hermes stashes any uncommitted source-tree changes, pulls, then **asks** whether to restore them — exactly as it always has. Nothing changes for interactive updates. + +When the update runs **without a terminal** — from the desktop/chat app's "Update" button or a gateway-triggered update — there's no prompt to answer. The `updates.non_interactive_local_changes` setting decides what happens to your stashed changes: + +```yaml +# ~/.hermes/config.yaml +updates: + non_interactive_local_changes: stash # default: keep + auto-restore + # non_interactive_local_changes: discard # throw local source edits away +``` + +- `stash` (default) — auto-stash, pull, then auto-restore your changes on top of the updated code. Nothing is lost; if a restore hits conflicts they're preserved in a git stash for manual recovery. +- `discard` — auto-stash and drop the stash after the pull, so the update always lands on a clean tree. Use this only on machines where you never intend to keep local edits to the Hermes source. It stash-drops (not `git reset --hard` + `git clean -fd`), so ignored paths like `node_modules`, `venv`, and build outputs are never touched. + +In the desktop app this is **Settings → Advanced → In-App Update Local Changes**. + ### Preview-only: `hermes update --check` Want to know if an update is available before pulling? Run `hermes update --check` — for git installs it fetches and compares commits against `origin/main`; for pip installs it queries PyPI for the latest release. No files are modified, no gateway is restarted. Useful in scripts and cron jobs that gate on "is there an update".