feat(update): stash/restore by default + settable discard for non-interactive updates (reverts #38542, #39568) (#39645)
* Revert "fix(update): require managed marker before destructive clean" This reverts commitc8e80cd0bf. * Revert "fix(update): stop stash/restore from clobbering desktop source on managed clones (#38542)" This reverts commit8a19884bf3. * 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 <balyan.sid@gmail.com>
This commit is contained in:
+18
-2
@@ -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
|
||||
|
||||
+85
-106
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user