Files
hermes-agent/hermes_cli/update_cmd_stash.py

485 lines
20 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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.<name>`` 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 <entry>")
print(" Restore with: git stash apply <entry> Discard with: git stash drop <entry>")
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