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 commit c8e80cd0bf.

* Revert "fix(update): stop stash/restore from clobbering desktop source on managed clones (#38542)"

This reverts commit 8a19884bf3.

* 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:
Teknium
2026-06-05 17:30:10 +05:30
committed by GitHub
co-authored by alt-glitch
parent 947e21b3d6
commit 72eb42d9ec
8 changed files with 335 additions and 255 deletions
+18 -2
View File
@@ -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
View File
@@ -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,
+11
View File
@@ -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.