"""Autostash handling for ``hermes update``: stash before the pull, restore/park/discard afterwards, warn about orphans. Split out of ``update_cmd.py``; names are re-imported there so ``hermes_cli.update_cmd.`` still resolves/monkeypatches. Origin helpers are imported lazily per function (no cycle; test patches on the origin stay effective). """ import logging import subprocess from datetime import datetime from pathlib import Path from typing import Optional # Log-record parity with the origin module. logger = logging.getLogger("hermes_cli.update_cmd") def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[str]: from hermes_cli.update_cmd import _git_run status = _git_run(git_cmd, ["status", "--porcelain"], cwd, check=True) if not status.stdout.strip(): return None # Unmerged index entries (interrupted merge/rebase) make `git stash` fail with # "needs merge"; `git reset` drops only the index conflict state, not the tree. unmerged = _git_run(git_cmd, ["ls-files", "--unmerged"], cwd) if unmerged.stdout.strip(): print("→ Clearing unmerged index entries from a previous conflict...") subprocess.run(git_cmd + ["reset"], cwd=cwd, capture_output=True) from datetime import datetime, timezone stash_name = datetime.now(timezone.utc).strftime(f"{_AUTOSTASH_NAME_PREFIX}%Y%m%d-%H%M%S") print("→ Local changes detected — stashing before update...") prev_stash = _git_run(git_cmd, ["rev-parse", "--verify", "refs/stash"], cwd).stdout.strip() push = _git_run(git_cmd, ["stash", "push", "--include-untracked", "-m", stash_name], cwd) if push.stdout.strip(): print(push.stdout.strip()) stash_probe = _git_run(git_cmd, ["rev-parse", "--verify", "refs/stash"], cwd) stash_ref = stash_probe.stdout.strip() stash_created = stash_probe.returncode == 0 and bool(stash_ref) and stash_ref != prev_stash if push.returncode != 0: if stash_created: # Non-zero but entry created: push saved everything yet couldn't delete # some untracked files (e.g. root-owned dir). Not a failure — continue. if push.stderr.strip(): print(push.stderr.strip()) print( " ⚠ Some untracked files could not be removed from the " "working tree (permission denied)." ) print( " They were still saved to the stash and were left in " "place — the update will continue." ) # A partially-failed push also skips cleanup of TRACKED modifications; # they'd break the following pull. Safe to reset: all is in the stash. subprocess.run(git_cmd + ["reset", "--hard", "HEAD"], cwd=cwd, capture_output=True) else: # No entry created: changes NOT saved — bail before touching HEAD. print("✗ Could not stash local changes — update aborted.") if push.stderr.strip(): print(f" {push.stderr.strip().splitlines()[0]}") print( " Commit, stash, or clean up your local changes manually, " "then re-run `hermes update`." ) raise subprocess.CalledProcessError( push.returncode, push.args, output=push.stdout, stderr=push.stderr ) return stash_ref def _resolve_stash_selector( git_cmd: list[str], cwd: Path, stash_ref: str ) -> Optional[str]: from hermes_cli.update_cmd import _git_run stash_list = _git_run(git_cmd, ["stash", "list", "--format=%gd %H"], cwd, check=True) for line in stash_list.stdout.splitlines(): selector, _, commit = line.partition(" ") if commit.strip() == stash_ref: return selector.strip() return None #: Autostash subject contract: this prefix + UTC YYYYMMDD-HHMMSS stamp #: (producer _stash_local_changes_if_needed, consumer _warn_orphaned_update_autostashes). _AUTOSTASH_NAME_PREFIX = "hermes-update-autostash-" #: Age past which a leftover autostash is called out. Younger entries are normal #: (recent --keep-stash park); older ones are almost always forgotten. _AUTOSTASH_WARN_AGE_DAYS = 7 def _warn_orphaned_update_autostashes(git_cmd: list[str], cwd: Path) -> int: """Print a notice for update autostashes older than the warn threshold; return the count (0 on any git failure). Autostashes legitimately outlive a run (--keep-stash, failed restore) but nothing re-surfaces them. Deliberately NOT a GC: a stash may be the only copy of the user's work, so Hermes never drops one. """ from hermes_cli.update_cmd import _git_run from datetime import timedelta, timezone try: stash_list = _git_run(git_cmd, ["stash", "list", "--format=%gd %s"], cwd) if stash_list.returncode != 0: return 0 cutoff = datetime.now(timezone.utc) - timedelta(days=_AUTOSTASH_WARN_AGE_DAYS) marker = _AUTOSTASH_NAME_PREFIX stale: list[tuple[str, str]] = [] for line in stash_list.stdout.splitlines(): selector, _, subject = line.strip().partition(" ") pos = subject.find(marker) if pos < 0: continue stamp = subject[pos + len(marker):][:15] # "YYYYMMDD-HHMMSS" try: stash_time = datetime.strptime(stamp, "%Y%m%d-%H%M%S").replace(tzinfo=timezone.utc) except ValueError: continue # age unknown — leave it alone rather than guess if stash_time < cutoff: stale.append((selector, stamp)) if not stale: return 0 print() print( f"⚠ {len(stale)} leftover update autostash entr" f"{'y is' if len(stale) == 1 else 'ies are'} more than " f"{_AUTOSTASH_WARN_AGE_DAYS} days old:" ) for selector, stamp in stale: print(f" {selector} ({_AUTOSTASH_NAME_PREFIX}{stamp})") print(" These hold local changes stashed by earlier updates and never") print(" restored. Review with: git stash show -p ") print(" Restore with: git stash apply Discard with: git stash drop ") return len(stale) except Exception as exc: logger.debug("Autostash age check failed: %s", exc) return 0 def _print_stash_cleanup_guidance( stash_ref: str, stash_selector: Optional[str] = None ) -> None: print(" Check `git status` first so you don't accidentally reapply the same change twice.") print(" Find the saved entry with: git stash list --format='%gd %H %s'") if stash_selector: print(f" Remove it with: git stash drop {stash_selector}") else: print( f" Look for commit {stash_ref}, then drop its selector with: git stash drop stash@{{N}}" ) def _stash_apply_failed_only_on_existing_untracked(stderr: str) -> bool: """True when a ``git stash apply`` failure is ONLY about untracked files that already exist in the tree. Tail of the permission-denied class: push swept undeletable files into the stash but couldn't remove them; apply restores tracked changes, then refuses to overwrite those files and exits non-zero though nothing was lost. Any other error line (e.g. ``would be overwritten by merge``) means the tracked apply failed -> False. """ lines = [ln.strip() for ln in (stderr or "").splitlines() if ln.strip()] if not lines: return False saw_untracked_error = False for ln in lines: if "already exists, no checkout" in ln: saw_untracked_error = True elif "could not restore untracked files from stash" in ln: saw_untracked_error = True elif ln.startswith(("warning:", "hint:")): continue else: return False return saw_untracked_error def _park_stashed_changes(stash_ref: str) -> None: """Leave a pre-update autostash parked (``--keep-stash``, the desktop updater's mode). Local source edits must never be silently re-applied onto updated code; the entry stays in ``git stash``. """ 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 _git_untracked_paths(git_cmd: list[str], cwd: Path) -> set[str] | None: """Return untracked paths, or ``None`` when Git cannot enumerate them.""" try: result = subprocess.run( git_cmd + ["ls-files", "--others", "--exclude-standard", "-z"], cwd=cwd, capture_output=True, text=True, encoding="utf-8", errors="surrogateescape", ) except (OSError, subprocess.SubprocessError): result = None if result is None or result.returncode != 0: print(" ⚠ Could not enumerate untracked files while validating the restored stash.") return None return {path for path in result.stdout.split("\0") if path} def _restored_python_paths( git_cmd: list[str], cwd: Path ) -> tuple[str, ...] | None: """Restored ``.py`` paths changed from ``HEAD``; deliberately Python-only (entry scripts stay outside the health check).""" from hermes_cli.update_cmd import _git_untracked_paths try: changed = subprocess.run( git_cmd + ["diff", "--name-only", "-z", "HEAD", "--", "*.py"], cwd=cwd, capture_output=True, text=True, encoding="utf-8", errors="surrogateescape", ) except (OSError, subprocess.SubprocessError): changed = None if changed is None or changed.returncode != 0: print(" ⚠ Could not enumerate tracked Python files restored from the stash.") return None paths = set(changed.stdout.split("\0")) untracked = _git_untracked_paths(git_cmd, cwd) if untracked is None: return None paths.update(path for path in untracked if path.endswith(".py")) paths.discard("") return tuple(sorted(paths)) def _reject_unsafe_stash_restore( git_cmd: list[str], cwd: Path, stash_ref: str, preexisting_untracked: set[str], failing_target: str, detail: str | None, ) -> None: """Restore the clean updated tree, preserve the stash, and abort the update.""" from hermes_cli.update_cmd import _git_untracked_paths print() print("✗ Restored local changes made the Hermes agent unexecutable.") print(f" Health check failed: {failing_target}") if detail: for line in str(detail).splitlines()[:6]: print(f" {line}") current_untracked = _git_untracked_paths(git_cmd, cwd) restored_untracked = ( current_untracked - preexisting_untracked if current_untracked is not None else set() ) try: reset = subprocess.run(git_cmd + ["reset", "--hard", "HEAD"], cwd=cwd, capture_output=True) except (OSError, subprocess.SubprocessError): reset = None clean = None if restored_untracked: try: clean = subprocess.run( git_cmd + ["clean", "-fd", "--", *sorted(restored_untracked)], cwd=cwd, capture_output=True, ) except (OSError, subprocess.SubprocessError): clean = None cleanup_ok = ( current_untracked is not None and reset is not None and reset.returncode == 0 and (not restored_untracked or (clean is not None and clean.returncode == 0)) ) if cleanup_ok: try: verify = subprocess.run( git_cmd + ["diff", "--quiet", "HEAD", "--"], cwd=cwd, capture_output=True, ) cleanup_ok = verify.returncode == 0 except (OSError, subprocess.SubprocessError): cleanup_ok = False if cleanup_ok: print(" The clean updated tree has been restored; the gateway was not restarted.") else: print(" ⚠ The clean updated tree could not be fully restored automatically.") print(" Inspect `git status` and run `git reset --hard HEAD` before retrying.") print(" Platform connectivity alone does not mean the agent can execute turns.") print(f" Your local changes remain preserved in stash: {stash_ref}") print(f" Inspect them with: git stash show --stat {stash_ref}") print(f" Restore manually after fixing them: git stash apply {stash_ref}") raise SystemExit(1) def _restore_stashed_changes( git_cmd: list[str], cwd: Path, stash_ref: str, prompt_user: bool = False, input_fn=None, ) -> bool: from hermes_cli.update_cmd import ( _critical_module_import_failures, _git_run, _git_untracked_paths, _restored_python_paths, _validate_python_files_syntax, ) if prompt_user: remote_prompt = input_fn is not None prompt_suffix = "[y/N]" if remote_prompt else "[Y/n]" print() print("⚠ Local changes were stashed before updating.") print(" Restoring them may reapply local customizations onto the updated codebase.") print(" Review the result afterward if Hermes behaves unexpectedly.") print(f"Restore local changes now? {prompt_suffix}") if input_fn is not None: response = input_fn(f"Restore local changes now? {prompt_suffix}", "n") else: try: response = input().strip().lower() except (EOFError, UnicodeDecodeError): response = "n" # closed stdin/encoding error must not crash mid-restore accepted = response in {"y", "yes"} or (not remote_prompt and response == "") if not accepted: print("Skipped restoring local changes.") print("Your changes are still preserved in git stash.") print(f"Restore manually with: git stash apply {stash_ref}") return False preexisting_untracked = _git_untracked_paths(git_cmd, cwd) if preexisting_untracked is None: print(" The stash was not restored because its cleanup baseline is unknown.") print(f" Restore manually with: git stash apply {stash_ref}") return False clean_import_failures = _critical_module_import_failures(cwd, report_runtime_errors=True) print("→ Restoring local changes...") restore = _git_run(git_cmd, ["stash", "apply", stash_ref], cwd) # Conflicts can exist even when returncode is 0 unmerged = _git_run(git_cmd, ["diff", "--name-only", "--diff-filter=U"], cwd) has_conflicts = bool(unmerged.stdout.strip()) if restore.returncode != 0 and not has_conflicts and ( _stash_apply_failed_only_on_existing_untracked(restore.stderr) ): # Tracked changes applied; only undeletable-at-stash-time untracked files # were refused. Their content is untouched — treat as restored. print( " ⚠ Some stashed untracked files already exist in the working " "tree and were kept as-is." ) elif restore.returncode != 0 or has_conflicts: print("✗ Update pulled new code, but restoring local changes hit conflicts.") if restore.stdout.strip(): print(restore.stdout.strip()) if restore.stderr.strip(): print(restore.stderr.strip()) conflicted_files = unmerged.stdout.strip() if conflicted_files: print("\nConflicted files:") for f in conflicted_files.splitlines(): print(f" • {f}") print("\nYour stashed changes are preserved — nothing is lost.") print(f" Stash ref: {stash_ref}") # Always reset: conflict markers make hermes unrunnable; changes stay in the stash. subprocess.run(git_cmd + ["reset", "--hard", "HEAD"], cwd=cwd, capture_output=True) print("Working tree reset to clean state.") print(f"Restore your changes later with: git stash apply {stash_ref}") # Don't exit: code update succeeded; cmd_update continues (deps, skills, gateway). return False restored_python = _restored_python_paths(git_cmd, cwd) if restored_python is None: _reject_unsafe_stash_restore( git_cmd, cwd, stash_ref, preexisting_untracked, "restored Python source discovery", "could not determine which restored Python files require validation", ) syntax_ok, failing_path, syntax_error = _validate_python_files_syntax(cwd, restored_python) if not syntax_ok: _reject_unsafe_stash_restore( git_cmd, cwd, stash_ref, preexisting_untracked, failing_path or "restored Python source", syntax_error, ) restored_import_failures = _critical_module_import_failures(cwd, report_runtime_errors=True) changed_import_failure = next( ( (module, error) for module, error in restored_import_failures.items() if clean_import_failures.get(module) != error ), None, ) if changed_import_failure is not None: failing_module, import_error = changed_import_failure _reject_unsafe_stash_restore( git_cmd, cwd, stash_ref, preexisting_untracked, f"agent import {failing_module or 'unknown'}", import_error[1], ) stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref) if stash_selector is None: print("⚠ Local changes were restored, but Hermes couldn't find the stash entry to drop.") print( " The stash was left in place. You can remove it manually after checking the result." ) _print_stash_cleanup_guidance(stash_ref) else: drop = _git_run(git_cmd, ["stash", "drop", stash_selector], cwd) if drop.returncode != 0: print("⚠ Local changes were restored, but Hermes couldn't drop the saved stash entry.") if drop.stdout.strip(): print(drop.stdout.strip()) if drop.stderr.strip(): print(drop.stderr.strip()) print( " The stash was left in place. You can remove it manually after checking the result." ) _print_stash_cleanup_guidance(stash_ref, stash_selector) print("⚠ Local changes were restored on top of the updated codebase.") print(" Review `git diff` / `git status` if Hermes behaves unexpectedly.") return True def _discard_stashed_changes( git_cmd: list[str], cwd: Path, stash_ref: str, ) -> bool: """Drop a pre-update stash without applying (non-interactive ``updates.non_interactive_local_changes: discard``). Unlike reset --hard + clean -fd this touches only what was stashed; ignored paths are never affected. Returns True if dropped, False on git failure (stash left in place). """ from hermes_cli.update_cmd import _git_run 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 = _git_run(git_cmd, ["stash", "drop", stash_selector], cwd) 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