Merge branch 'simp/r2-update-cmd-sub' into simp/integration2

This commit is contained in:
Teknium
2026-09-02 17:05:23 -07:00
15 changed files with 8152 additions and 9278 deletions
+673 -9270
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
"""Shared leaf helpers for the ``hermes update`` modules (no Hermes imports; no cycle)."""
import logging
from contextlib import contextmanager
# Log-record parity with the origin module.
logger = logging.getLogger("hermes_cli.update_cmd")
@contextmanager
def _best_effort(message: str):
"""Run a non-critical update step; swallow ``Exception`` and log it at debug.
The updater must never die on bookkeeping (receipt, notices, cache seeds):
``message`` is the ``%s``-style debug line the inline ``try/except`` used.
"""
try:
yield
except Exception as exc:
logger.debug(message, exc)
+337
View File
@@ -0,0 +1,337 @@
"""Post-``hermes update`` config-schema migration for the active profile and every sibling.
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; patches hold).
"""
import logging
import sys
from pathlib import Path
from hermes_cli.update_cmd_common import _best_effort
# Log-record parity with the origin module.
logger = logging.getLogger("hermes_cli.update_cmd")
def _reload_config_modules() -> None:
"""Force-reload modules from disk after git pull.
``hermes update`` runs in the PRE-pull process, so cached ``config_defaults`` /
``config`` / ``config_migrations`` hold OLD code and ``check_config_version()``
reports "up to date" despite a newer pulled version with a migration to run.
Also reloads ``_subprocess_compat`` / ``dashboard_procs`` so the later dashboard
cleanup sees symbols the update added instead of dying with ImportError.
"""
import importlib
importlib.invalidate_caches()
for mod_name in (
"hermes_cli.config_defaults",
"hermes_cli.config",
"hermes_cli.config_migrations",
"hermes_cli._subprocess_compat",
"hermes_cli.dashboard_procs",
):
mod = sys.modules.get(mod_name)
if mod is not None:
try:
importlib.reload(mod)
except Exception as exc:
logger.debug("Could not reload %s for fresh post-update code: %s", mod_name, exc)
def _run_config_check_fresh() -> tuple:
"""Return ``(current_ver, latest_ver)`` using freshly-reloaded modules (see ``_reload_config_modules``)."""
from hermes_cli.update_cmd import _reload_config_modules
_reload_config_modules()
from hermes_cli.config import check_config_version
return check_config_version()
def _run_migrate_config_fresh(*, interactive: bool = False, quiet: bool = False) -> dict:
"""Run config migration using freshly-reloaded modules (see ``_reload_config_modules``); returns results dict."""
from hermes_cli.update_cmd import _reload_config_modules
_reload_config_modules()
from hermes_cli.config import migrate_config
return migrate_config(interactive=interactive, quiet=quiet)
def _migrate_sibling_profile_configs() -> list[tuple[str, int, int]]:
"""Migrate every SIBLING profile's config.yaml (the shared checkout serves all profiles;
migrating only the active one left siblings on configs the new code couldn't read).
Per sibling home (active one skipped — caller already did it): scope reads/writes via the
context-local HERMES_HOME override (thread-safe — never ``os.environ``) and run the
NON-INTERACTIVE quiet migration; prompt-requiring settings wait for that profile's own
interactive session. Returns ``[(profile_name, from_version, to_version), ...]`` for
profiles actually migrated. Never raises; a failing profile is skipped (its startup
migration remains the fallback).
"""
from hermes_cli.update_cmd import _run_config_check_fresh, _run_migrate_config_fresh
migrated: list[tuple[str, int, int]] = []
with _best_effort('Sibling profile enumeration failed: %s'):
from hermes_constants import (
get_process_hermes_home,
reset_hermes_home_override,
set_hermes_home_override,
)
from hermes_cli.profiles import _get_profiles_root, _PROFILE_ID_RE
active_home = get_process_hermes_home()
root = _get_profiles_root()
if not root.is_dir():
return migrated
for entry in sorted(root.iterdir()):
if not entry.is_dir() or not _PROFILE_ID_RE.match(entry.name):
continue
try:
if entry.resolve() == Path(active_home).resolve():
continue
except OSError:
continue
if not (entry / "config.yaml").is_file():
continue # profile never configured — nothing to migrate
token = set_hermes_home_override(entry)
try:
current_ver, latest_ver = _run_config_check_fresh()
if current_ver >= latest_ver:
continue
_run_migrate_config_fresh(interactive=False, quiet=True)
after_ver, _ = _run_config_check_fresh()
if after_ver > current_ver:
migrated.append((entry.name, current_ver, after_ver))
except Exception as exc:
logger.debug("Config migration for profile %s failed: %s", entry.name, exc)
finally:
reset_hermes_home_override(token)
return migrated
def _restore_snapshot_safety_nets(pre_update_snapshot_id) -> None:
"""Post-migration safety nets: restore cron jobs / protected model settings lost during the update,
for the active profile (from *pre_update_snapshot_id*) and every sibling profile (own snapshot)."""
# Safety net: migrations/desktop scheduler have emptied or truncated cron/jobs.json;
# restore from the pre-update snapshot if jobs went missing.
try:
from hermes_cli.backup import restore_cron_jobs_if_emptied
cron_restore = restore_cron_jobs_if_emptied(pre_update_snapshot_id)
if cron_restore:
print()
print(
" ⚠️ cron/jobs.json lost jobs during this update — "
f"restored {cron_restore['job_count']} job(s) from "
f"pre-update snapshot {cron_restore['snapshot_id']}."
)
except Exception as exc:
# Never let the cron safety net break an otherwise-good update.
logger.debug("Cron jobs auto-restore check failed: %s", exc)
# Desktop update/repair cycles have rewritten model.provider/model.default and dropped
# moa:; restore only those protected keys from the same pre-update snapshot.
try:
from hermes_cli.backup import restore_config_model_settings_if_rewritten
cfg_restore = restore_config_model_settings_if_rewritten(pre_update_snapshot_id)
if cfg_restore:
print()
print(
" ⚠️ config.yaml user model settings were rewritten during "
f"this update — restored {', '.join(cfg_restore['keys'])} "
f"from pre-update snapshot {cfg_restore['snapshot_id']}."
)
except Exception as exc:
# Never let the config safety net break an otherwise-good update.
logger.debug("Config model-settings auto-restore check failed: %s", exc)
# Same cron-jobs safety net per sibling profile against ITS OWN pre-update snapshot.
with _best_effort('Sibling cron auto-restore check failed: %s'):
from hermes_cli.backup import restore_cron_jobs_all_profiles
for _restored in restore_cron_jobs_all_profiles(
_LAST_SIBLING_SNAPSHOTS
):
print()
print(
f" ⚠️ Profile '{_restored['profile']}': cron/jobs.json "
f"lost jobs during this update — restored "
f"{_restored['job_count']} job(s) from pre-update "
f"snapshot {_restored['snapshot_id']}."
)
# Same config model-settings safety net for sibling profiles.
with _best_effort('Sibling config auto-restore check failed: %s'):
from hermes_cli.backup import restore_config_model_settings_all_profiles
for _cfg_restored in restore_config_model_settings_all_profiles(
_LAST_SIBLING_SNAPSHOTS
):
print()
print(
f" ⚠️ Profile '{_cfg_restored['profile']}': config.yaml "
f"user model settings were rewritten during this update — "
f"restored {', '.join(_cfg_restored['keys'])} from "
f"pre-update snapshot {_cfg_restored['snapshot_id']}."
)
def _check_and_apply_config_migration(
*,
assume_yes: bool = False,
gateway_mode: bool = False,
pre_update_snapshot_id: str | None = None,
) -> None:
"""Check/apply config migrations on an update completion path.
Must use freshly-reloaded modules (see ``_reload_config_modules``) and run on EVERY
completion path (post-pull, venv-repair retry, Node-deps repair on ``commit_count == 0``)
so an interrupted update that already pulled code doesn't strand an old config version.
"""
from hermes_cli.update_cmd import (
_gateway_prompt,
_migrate_sibling_profile_configs,
_reload_config_modules,
_run_config_check_fresh,
_run_migrate_config_fresh,
)
print()
print("→ Checking configuration for new options...")
# Reload BEFORE any config reads so all checks use the updated code.
_reload_config_modules()
from hermes_cli.config import (
get_missing_env_vars,
get_missing_config_fields,
)
# A config-check failure must not break an otherwise-successful update.
try:
missing_env = get_missing_env_vars(required_only=True)
missing_config = get_missing_config_fields()
current_ver, latest_ver = _run_config_check_fresh()
except Exception as exc:
logger.debug("Config check during update failed: %s", exc)
print(" ⚠️ Could not check config version.")
print(" Run 'hermes config migrate' to check manually.")
return
has_new_options = bool(missing_env or missing_config)
version_bump_only = not has_new_options and current_ver < latest_ver
needs_migration = has_new_options or current_ver < latest_ver
if version_bump_only:
# Only the format version changed (defaults merge transparently); prompting
# would look like a no-op on yes — apply silently and say what happened.
print()
print(f" ℹ Updating config format (v{current_ver} → v{latest_ver})…")
try:
_mig_results = _run_migrate_config_fresh(interactive=False, quiet=True)
print(" ✓ Config format updated (no new settings to configure)")
# quiet=True also mutes steps that RESET/REMOVE a setting; re-surface them so an
# unattended update never silently changes config (config_added holds only mutations here).
for _note in _mig_results.get("config_added") or []:
print(f" ℹ {_note}")
for _warn in _mig_results.get("warnings") or []:
print(f" ⚠️ {_warn}")
except Exception as _mig_err:
print(f" ⚠️ Config format update failed: {_mig_err}")
print(" Run 'hermes config migrate' to retry.")
elif needs_migration:
print()
# Show WHAT changed, not just a count, for an informed yes/no.
if missing_env:
print(f" ⚠️ {len(missing_env)} new required setting(s) need configuration")
_print_items(missing_env, "New settings", "name")
if missing_config:
print(f" ℹ️ {len(missing_config)} new config option(s) available")
_print_items(missing_config, "New options", "key")
print()
if assume_yes:
print(" ℹ --yes: auto-applying config migration (skipping API-key prompts).")
response = "y"
elif gateway_mode:
response = (
_gateway_prompt(
"Would you like to configure new options now? [Y/n]", "n"
)
.strip()
.lower()
)
elif not (sys.stdin.isatty() and sys.stdout.isatty()):
print(" ℹ Non-interactive session — applying safe config migrations.")
response = "auto"
else:
try:
response = (
input("Would you like to configure them now? [Y/n]: ")
.strip()
.lower()
)
except EOFError:
response = "n"
except UnicodeDecodeError:
# Non-UTF-8 locales / embedded terminals can make input() raise this.
print(
" ⚠ Could not read input (encoding issue). Skipping. "
"Run 'hermes config migrate' manually to configure."
)
response = "n"
if response in {"", "y", "yes", "auto"}:
print()
# Gateway/--yes/non-interactive can't prompt for API keys; still run the
# non-interactive pass so defaults and version bumps land before the gateway restarts.
interactive_migration = not (gateway_mode or assume_yes or response == "auto")
results = _run_migrate_config_fresh(interactive=interactive_migration, quiet=False)
if results["env_added"] or results["config_added"]:
print()
print("✓ Configuration updated!")
if (gateway_mode or assume_yes or response == "auto") and missing_env:
print(" ℹ API keys require manual entry: hermes config migrate")
else:
print()
print("Skipped. Run 'hermes config migrate' later to configure.")
else:
print(" ✓ Configuration is up to date")
# The migration above touched only the active profile; run the same NON-INTERACTIVE
# migration per sibling home via the context-local HERMES_HOME override (never os.environ).
with _best_effort('Sibling config migration failed: %s'):
_migrated_siblings = _migrate_sibling_profile_configs()
for _name, _from_ver, _to_ver in _migrated_siblings:
print(f" ✓ Profile '{_name}': config format updated " f"(v{_from_ver} → v{_to_ver})")
_restore_snapshot_safety_nets(pre_update_snapshot_id)
# {profile: snapshot_id} from this run's pre-update backup, consumed by the per-profile
# safety nets. Module-level because snapshot and restore run far apart in _cmd_update_impl.
_LAST_SIBLING_SNAPSHOTS: dict = {}
def _print_items(items, label, key, fallback_key=None):
if not items:
return
print(f" {label}:")
shown = items[:8]
for it in shown:
if isinstance(it, dict):
name = it.get(key) or (fallback_key and it.get(fallback_key)) or "?"
desc = (it.get("description") or "").strip()
else:
# Defensive: some callers/mocks pass bare name strings.
name = str(it)
desc = ""
if desc:
print(f" • {name} — {desc}")
else:
print(f" • {name}")
extra = len(items) - len(shown)
if extra > 0:
print(f" … and {extra} more")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+679
View File
@@ -0,0 +1,679 @@
"""Git plumbing for ``hermes update``: fork/upstream sync, trampoline-git detection, lockfile/EOL churn cleanup, orphan rescue refs, parked-branch assessment, fetch-failure classification.
Split out of ``update_cmd.py``, which re-imports every name so ``hermes_cli.update_cmd.<name>``
still resolves/monkeypatches. Origin helpers are imported lazily per function (no cycle;
test patches on ``update_cmd`` stay effective).
"""
import logging
from contextlib import suppress
import subprocess
import sys
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")
_ORPHAN_RESCUE_REFS_TO_KEEP = 10
_ORPHAN_RESCUE_REF_MAX_AGE_DAYS = 30
def _prune_orphan_rescue_refs(
git_cmd,
cwd,
branch,
keep=_ORPHAN_RESCUE_REFS_TO_KEEP,
max_age_days=_ORPHAN_RESCUE_REF_MAX_AGE_DAYS,
) -> None:
"""Expire old orphan rescue refs (``refs/hermes-update-backups/orphan-<branch>-<ts>-<sha>``).
Each ref pins a possibly multi-GB snapshot against ``git gc``, so a repeatedly corrupted
install would grow ``.git`` unbounded. Keep the ``keep`` newest AND drop any older than
``max_age_days`` by the ``YYYYMMDD-HHMMSS`` stamp (unparseable names left alone); names
sort chronologically so ``for-each-ref`` order is creation order. Best-effort, never blocks.
"""
from hermes_cli.update_cmd import _git_run
with suppress(OSError):
list_result = _git_run(
git_cmd,
["for-each-ref", "--format=%(refname)", "--sort=refname",
f"refs/hermes-update-backups/orphan-{branch}-*"],
cwd,
)
if list_result.returncode != 0:
return
refs = [line.strip() for line in list_result.stdout.splitlines() if line.strip()]
stale = set(refs[:-keep] if keep > 0 else refs)
if max_age_days > 0:
from datetime import timedelta, timezone
cutoff = datetime.now(timezone.utc) - timedelta(days=max_age_days)
prefix = f"refs/hermes-update-backups/orphan-{branch}-"
for ref in refs:
stamp = ref[len(prefix):][:15] # "YYYYMMDD-HHMMSS"
try:
ref_time = datetime.strptime(stamp, "%Y%m%d-%H%M%S").replace(
tzinfo=timezone.utc
)
except ValueError:
continue
if ref_time < cutoff:
stale.add(ref)
for ref in sorted(stale):
_git_run(git_cmd, ["update-ref", "-d", ref], cwd)
def _branch_head_label(git_cmd=None, cwd=None) -> str | None:
"""``"<branch> @ <short-sha>"`` for the checkout, or None when unknown. Never raises.
Appended to summary lines so a checkout parked on a stale branch is visible.
"""
from hermes_cli.update_cmd import _m
try:
cmd = list(git_cmd) if git_cmd else ["git"]
root = cwd if cwd is not None else _m().PROJECT_ROOT
branch = subprocess.run(
cmd + ["rev-parse", "--abbrev-ref", "HEAD"],
cwd=root, capture_output=True,
text=True, encoding="utf-8", errors="replace",
)
sha = subprocess.run(
cmd + ["rev-parse", "--short", "HEAD"],
cwd=root, capture_output=True,
text=True, encoding="utf-8", errors="replace",
)
branch_name = branch.stdout.strip()
sha_text = sha.stdout.strip()
if branch.returncode != 0 or sha.returncode != 0 or not sha_text:
return None
if not branch_name:
return None
label = "detached" if branch_name == "HEAD" else branch_name
return f"{label} @ {sha_text}"
except Exception:
return None
def _branch_head_suffix(git_cmd=None, cwd=None) -> str:
"""`` [<branch> @ <sha>]`` suffix for summary lines ("" when unknown)."""
label = _branch_head_label(git_cmd, cwd)
return f" [{label}]" if label else ""
def _assess_parked_branch_switch(
git_cmd: list[str], cwd: Path, current_branch: str, target_branch: str
) -> tuple[bool, str]:
"""Decide whether a parked feature branch may be auto-switched back to the update target.
- (True, "") — tree clean and every parked commit is in ``origin/<target>`` (no ``git cherry +``).
- (True, "unmerged:<n>") — tree clean but commits not in target; switching is safe (checkout
keeps committed work) but caller must print a LOUD notice. Non-interactive callers
(desktop, gateway /update, cron) can't resolve a skip, so a clean checkout must reach target.
- (False, "disabled"|"dirty"|"unverifiable") — caller must NOT touch the branch. Dirty is
the genuinely unsafe case: uncommitted work riding an autostash across branches.
"""
from hermes_cli.update_cmd import _git_run
try:
from hermes_cli.config import load_config
_update_cfg = (load_config() or {}).get("updates", {})
if isinstance(_update_cfg, dict) and not bool(
_update_cfg.get("auto_switch_parked_branch", True)
):
return False, "disabled"
except Exception as exc:
# Config read failure must not disable the safety checks; fall through with default.
logger.debug("Could not read updates.auto_switch_parked_branch: %s", exc)
status = _git_run(git_cmd, ["status", "--porcelain"], cwd)
if status.returncode != 0:
return False, "unverifiable"
if status.stdout.strip():
return False, "dirty"
cherry = _git_run(git_cmd, ["cherry", f"origin/{target_branch}"], cwd)
if cherry.returncode != 0:
return False, "unverifiable"
unmerged = [line for line in cherry.stdout.splitlines() if line.startswith("+")]
if unmerged:
# Safe (checkout keeps commits); reason tells caller to print the loud notice.
return True, f"unmerged:{len(unmerged)}"
return True, ""
def _print_parked_branch_skip_warning(
git_cmd: list[str],
cwd: Path,
current_branch: str,
target_branch: str,
reason: str,
) -> None:
"""LOUD block: why the update was skipped on a parked branch, behind-count, fix commands."""
from hermes_cli.update_cmd import _git_run
behind = None
try:
behind_result = _git_run(git_cmd, ["rev-list", f"HEAD..origin/{target_branch}", "--count"], cwd)
if behind_result.returncode == 0 and behind_result.stdout.strip():
behind = int(behind_result.stdout.strip())
except Exception:
behind = None
if reason == "dirty":
why = "the working tree has uncommitted changes"
elif reason == "disabled":
why = "updates.auto_switch_parked_branch is set to false in config.yaml"
else:
why = f"the branch state could not be verified against origin/{target_branch}"
bar = "=" * 68
print()
print(bar)
print(f"⚠ CODE UPDATE SKIPPED — checkout is parked on '{current_branch}'")
print(f" Not auto-switching to {target_branch}: {why}.")
if behind is not None and behind > 0:
print(
f" This checkout is {behind} commit(s) BEHIND "
f"origin/{target_branch} — the code you are running is stale."
)
print()
print(" To resolve, inspect the branch and switch back yourself:")
print(f" git -C {cwd} status")
print(f" git -C {cwd} checkout {target_branch} && hermes update")
print(" (commit or stash your work on the branch first if you want to keep it)")
print(bar)
def _print_parked_branch_kept_notice(
current_branch: str, target_branch: str, unmerged_count: str
) -> None:
"""LOUD notice when a clean parked branch with unmerged commits is auto-switched.
Non-interactive callers can't resolve a skip, so we proceed — but the unmerged work
(still safe on its branch) must be impossible to miss.
"""
bar = "=" * 68
print()
print(bar)
print(
f"⚠ Checkout was parked on '{current_branch}' with "
f"{unmerged_count} commit(s) not merged into origin/{target_branch}."
)
print(
f" Switching to {target_branch} so the update can proceed — your "
f"commit(s) are safe on '{current_branch}'."
)
print()
print(" To pick the work back up later:")
print(f" git checkout {current_branch}")
print(bar)
OFFICIAL_REPO_URLS = {
"https://github.com/NousResearch/hermes-agent.git",
"git@github.com:NousResearch/hermes-agent.git",
"https://github.com/NousResearch/hermes-agent",
"git@github.com:NousResearch/hermes-agent",
}
OFFICIAL_REPO_URL = "https://github.com/NousResearch/hermes-agent.git"
SKIP_UPSTREAM_PROMPT_FILE = ".skip_upstream_prompt"
def _get_origin_url(git_cmd: list[str], cwd: Path) -> Optional[str]:
"""Get the URL of the origin remote, or None if not set."""
from hermes_cli.update_cmd import _git_run
with suppress(Exception):
result = _git_run(git_cmd, ["remote", "get-url", "origin"], cwd)
if result.returncode == 0:
return result.stdout.strip()
return None
def _is_fork(origin_url: Optional[str]) -> bool:
"""Check if the origin remote points to a fork (not the official repo)."""
if not origin_url:
return False
normalized = origin_url.rstrip("/")
if normalized.endswith(".git"):
normalized = normalized[:-4]
for official in OFFICIAL_REPO_URLS:
official_normalized = official.rstrip("/")
if official_normalized.endswith(".git"):
official_normalized = official_normalized[:-4]
if normalized == official_normalized:
return False
return True
def _has_upstream_remote(git_cmd: list[str], cwd: Path) -> bool:
"""Check if an 'upstream' remote already exists."""
from hermes_cli.update_cmd import _git_run
try:
result = _git_run(git_cmd, ["remote", "get-url", "upstream"], cwd)
return result.returncode == 0
except Exception:
return False
def _add_upstream_remote(git_cmd: list[str], cwd: Path) -> bool:
"""Add the official repo as the 'upstream' remote. Returns True on success."""
from hermes_cli.update_cmd import _git_run
try:
result = _git_run(git_cmd, ["remote", "add", "upstream", OFFICIAL_REPO_URL], cwd)
return result.returncode == 0
except Exception:
return False
def _count_commits_between(git_cmd: list[str], cwd: Path, base: str, head: str) -> int:
"""Count commits on `head` that are not on `base`. Returns -1 on error."""
from hermes_cli.update_cmd import _git_run
with suppress(Exception):
result = _git_run(git_cmd, ["rev-list", "--count", f"{base}..{head}"], cwd)
if result.returncode == 0:
return int(result.stdout.strip())
return -1
def _should_skip_upstream_prompt() -> bool:
"""Check if user previously declined to add upstream."""
from hermes_constants import get_hermes_home
return (get_hermes_home() / SKIP_UPSTREAM_PROMPT_FILE).exists()
def _mark_skip_upstream_prompt():
"""Create marker file to skip future upstream prompts."""
with suppress(Exception):
from hermes_constants import get_hermes_home
(get_hermes_home() / SKIP_UPSTREAM_PROMPT_FILE).touch()
def _sync_fork_with_upstream(git_cmd: list[str], cwd: Path) -> bool:
"""Push updated main to origin (sync fork); True on success."""
from hermes_cli.update_cmd import _git_run
try:
result = _git_run(git_cmd, ["push", "origin", "main", "--force-with-lease"], cwd, network=True)
return result.returncode == 0
except Exception:
return False
def _sync_with_upstream_if_needed(
git_cmd: list[str],
cwd: Path,
*,
assume_yes: bool = False,
input_fn=None,
) -> bool:
"""Offer to add ``upstream``, compare origin/main vs upstream/main, ff-pull when strictly
behind, then push origin.
Returns True only when origin/main was actually verified against upstream/main; False when
the check never happened, so the caller never reports "up to date" on an origin-only compare.
"""
from hermes_cli.update_cmd import (
_add_upstream_remote,
_count_commits_between,
_has_upstream_remote,
_mark_skip_upstream_prompt,
_no_prompt_git_kwargs,
_should_skip_upstream_prompt,
)
has_upstream = _has_upstream_remote(git_cmd, cwd)
if not has_upstream:
if _should_skip_upstream_prompt():
return False
print()
print("ℹ Your fork is not tracking the official Hermes repository.")
print(" This means you may miss updates from NousResearch/hermes-agent.")
print()
if assume_yes or (
input_fn is None and not (sys.stdin.isatty() and sys.stdout.isatty())
):
# --yes means "don't block", not "mutate my remotes"; don't persist the decline.
print(" Skipping upstream setup (non-interactive run).")
print(
" Add it later with: git remote add upstream https://github.com/NousResearch/hermes-agent.git"
)
return False
if input_fn is not None:
response = (
input_fn("Add official repo as 'upstream' remote? [y/N]", "n")
.strip()
.lower()
)
else:
try:
response = (
input("Add official repo as 'upstream' remote? [Y/n]: ")
.strip()
.lower()
)
except (EOFError, KeyboardInterrupt, UnicodeDecodeError):
print()
response = "n"
if response in {"", "y", "yes"}:
print("→ Adding upstream remote...")
if _add_upstream_remote(git_cmd, cwd):
print(" ✓ Added upstream: https://github.com/NousResearch/hermes-agent.git")
has_upstream = True
else:
print(" ✗ Failed to add upstream remote. Skipping upstream sync.")
return False
else:
print(
" Skipped. Run 'git remote add upstream https://github.com/NousResearch/hermes-agent.git' to add later."
)
_mark_skip_upstream_prompt()
return False
# Only upstream/main: a bare fetch drags in thousands of auto-generated branches.
print()
print("→ Fetching upstream...")
try:
subprocess.run(
git_cmd + ["fetch", "upstream", "main", "--quiet"],
cwd=cwd,
capture_output=True,
check=True,
**_no_prompt_git_kwargs(),
)
except subprocess.CalledProcessError:
print(" ✗ Failed to fetch upstream. Skipping upstream sync.")
return False
origin_ahead = _count_commits_between(git_cmd, cwd, "upstream/main", "origin/main")
upstream_ahead = _count_commits_between(git_cmd, cwd, "origin/main", "upstream/main")
if origin_ahead < 0 or upstream_ahead < 0:
print(" ✗ Could not compare branches. Skipping upstream sync.")
return False
if origin_ahead > 0:
print()
print(f"ℹ Your fork has {origin_ahead} commit(s) not on upstream.")
print(" Skipping upstream sync to preserve your changes.")
print(" If you want to merge upstream changes, run:")
print(" git pull upstream main")
return True
if upstream_ahead == 0:
print(" ✓ Fork is up to date with upstream")
return True
print()
print(f"→ Fork is {upstream_ahead} commit(s) behind upstream")
print("→ Pulling from upstream...")
try:
subprocess.run(
git_cmd + ["pull", "--ff-only", "upstream", "main"],
cwd=cwd,
check=True,
**_no_prompt_git_kwargs(),
)
except subprocess.CalledProcessError:
print(" ✗ Failed to pull from upstream. You may need to resolve conflicts manually.")
return False
print(" ✓ Updated from upstream")
print("→ Syncing fork...")
if _sync_fork_with_upstream(git_cmd, cwd):
print(" ✓ Fork synced with upstream")
else:
print(" ℹ Got updates from upstream but couldn't push to fork (no write access?)")
print(" Your local repo is updated, but your fork on GitHub may be behind.")
return True
def _classify_fetch_failure(stderr: str) -> str:
"""Map git-fetch stderr to a one-line diagnosis (caller also prints the raw first line).
Order matters: curl reports HTTP errors as ``unable to access '<url>': ... error: 429``,
so rate-limit/outage checks must run BEFORE the generic "unable to access" check.
"""
def _has_http_code(*codes: str) -> bool:
return any(
f"HTTP {code}" in stderr or f"returned error: {code}" in stderr
for code in codes
)
if _has_http_code("429") or "rate limit" in stderr.lower():
return (
"✗ GitHub is rate limiting requests or having an outage (HTTP 429)"
" — try again in 5 minutes."
)
if _has_http_code("500", "502", "503", "504"):
return (
"✗ GitHub appears to be having an outage — try again in a few"
" minutes (https://www.githubstatus.com)."
)
if "Could not resolve host" in stderr or "unable to access" in stderr:
return "✗ Network error — cannot reach the remote repository."
if "could not read Username" in stderr or "terminal prompts disabled" in stderr:
# Anonymous fetch got HTTP 401: GitHub does this during outages (and for
# renamed/private repos) — not a user credentials problem.
return (
"✗ GitHub rejected the anonymous fetch (asked for a login) — this"
" usually means a GitHub outage; try again in a few minutes"
" (https://www.githubstatus.com). If it persists, check"
" `git remote -v` points at a public repo."
)
if "Authentication failed" in stderr:
return "✗ Authentication failed — check your git credentials or SSH key."
return "✗ Failed to fetch updates from origin."
def _print_fetch_failure(stderr: str) -> None:
"""Print the classified diagnosis plus the first raw stderr line."""
stderr = (stderr or "").strip()
print(_classify_fetch_failure(stderr))
if stderr:
print(f" {stderr.splitlines()[0]}")
def _git_is_trampoline(git_cmd: list) -> bool:
"""Whether *git_cmd* is a broken Git-for-Windows trampoline shim.
The ~46KB ``bin\\git.exe``/``cmd\\git.exe`` shims re-exec git-core; when they can't find
it every call dies with the launcher's guard message (a PATH problem, not network).
Never raises; unknown states report False so a probe failure can't block an update.
"""
try:
result = subprocess.run(
git_cmd + ["--version"],
capture_output=True,
text=True, encoding="utf-8", errors="replace",
timeout=15,
)
except Exception:
return False
output = ((result.stdout or "") + (result.stderr or "")).lower()
return "fork bomb" in output
def _portable_git_candidates() -> list:
"""PortableGit candidates: shared root first (where the managed tree actually lives,
not the profile-scoped HERMES_HOME), then profile home as a fallback for custom layouts."""
from hermes_cli.update_cmd import get_default_hermes_root, get_hermes_home
candidates = []
with suppress(Exception):
for root in (get_default_hermes_root(), Path(get_hermes_home())):
candidates.append(root / "git" / "mingw64" / "libexec" / "git-core" / "git.exe")
return candidates
def _locate_real_git() -> Optional[Path]:
"""Find a real Git-for-Windows ``git-core/git.exe`` (standard locations + managed PortableGit)
that runs without the trampoline guard. None when nothing suits — callers keep the broken
command and let the fetch-failure ZIP fallback handle it."""
candidates = [
Path(r"C:\Program Files\Git\mingw64\libexec\git-core\git.exe"),
Path(r"C:\Program Files (x86)\Git\mingw64\libexec\git-core\git.exe"),
] + _portable_git_candidates()
for candidate in candidates:
if not candidate.exists():
continue
try:
result = subprocess.run(
[str(candidate), "--version"],
capture_output=True,
text=True, encoding="utf-8", errors="replace",
timeout=15,
)
except Exception:
continue
output = ((result.stdout or "") + (result.stderr or "")).lower()
if "fork bomb" in output:
continue
return candidate
return None
def _ensure_non_trampoline_git(git_cmd: list) -> list:
"""Swap a broken Git-for-Windows trampoline for a real git binary so fetch/pull/checkout
keep working; if none is found leave the command untouched (fetch-failure handler falls
back to ZIP). No-op off Windows and when git is healthy."""
from hermes_cli.update_cmd import _locate_real_git
if sys.platform != "win32":
return git_cmd
if not _git_is_trampoline(git_cmd):
return git_cmd
real_git = _locate_real_git()
if real_git is None:
print(
"⚠ Detected a broken git trampoline and could not locate a real "
"git binary — the update will fall back to the ZIP path."
)
return git_cmd
print(f"⚠ Detected a broken git trampoline; switching to real git at {real_git}")
return [str(real_git)] + list(git_cmd[1:])
def _discard_lockfile_churn(git_cmd, repo_root):
"""Restore ``package-lock.json`` files npm rewrote non-deterministically, so the update
sees a clean tree instead of autostashing every run. Only touches lockfiles whose
package.json is NOT also dirty. Best-effort."""
from hermes_cli.update_cmd import _git_run
with suppress(Exception):
diff = _git_run(git_cmd, ["diff", "--name-only"], repo_root)
if diff.returncode != 0:
return
dirty_package_dirs = {
Path(line.strip()).parent
for line in diff.stdout.splitlines()
if line.strip().endswith("package.json")
}
dirty = [
line.strip()
for line in diff.stdout.splitlines()
if line.strip().endswith("package-lock.json")
and Path(line.strip()).parent not in dirty_package_dirs
]
if not dirty:
return
_git_run(git_cmd, ["checkout", "--", *dirty], repo_root)
print(f"→ Discarded npm lockfile churn ({len(dirty)} file(s))")
def _normalize_managed_eol(git_cmd, repo_root):
"""Take a managed checkout off ``core.autocrlf=true`` without leaving it dirty.
Git for Windows sets ``autocrlf=true`` system-wide, turning LF files CRLF and breaking
``git checkout`` on update; install.ps1 pins ``false`` but older checkouts never got it
and only ``hermes update`` can fix them. Pin and cleanup are one operation: under
``autocrlf=true`` a CRLF tree reads clean, so pinning alone would expose every file as
modified (whole-tree autostash). Pin only after the tree verifies clean under it; a
checkout we can't fully normalize is left as-is. Best-effort.
"""
from hermes_cli.update_cmd import _git_run
# -c, not config: evaluate the tree as it WOULD look pinned, persisting nothing.
probe = git_cmd + ["-c", "core.autocrlf=false"]
def _dirty(*extra):
out = subprocess.run(
probe + ["diff", "-z", "--name-only", *extra],
cwd=repo_root,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
)
if out.returncode != 0:
return None
return {p for p in out.stdout.split("\0") if p}
def _real_dirty():
# Files with a *content* change ignoring CRLF. ``--name-only --ignore-cr-at-eol``
# still LISTS CR-only files; ``--numstat`` honors the filter (no record for them).
out = subprocess.run(
probe + ["-c", "core.quotepath=false",
"diff", "--numstat", "--ignore-cr-at-eol"],
cwd=repo_root,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
)
if out.returncode != 0:
return None
paths = set()
for line in out.stdout.splitlines():
if not line.strip():
continue
# "<added>\t<deleted>\t<path>"; rename detection off, so exactly one path.
parts = line.split("\t", 2)
if len(parts) == 3 and parts[2]:
paths.add(parts[2])
return paths
def _eol_only():
all_dirty, real_dirty = _dirty(), _real_dirty()
if all_dirty is None or real_dirty is None:
return None
return all_dirty - real_dirty
with suppress(Exception):
effective = _git_run(git_cmd, ["config", "--get", "core.autocrlf"], repo_root)
# Only "true" rewrites LF->CRLF; unset/false/input leave the tree alone.
if effective.stdout.strip().lower() != "true":
return
eol_only = _eol_only()
if eol_only is None:
return
if eol_only:
# Pathspec via stdin: thousands of paths exceed the Windows argv limit.
subprocess.run(
probe
+ ["checkout", "--pathspec-from-file=-", "--pathspec-file-nul", "--"],
cwd=repo_root,
input="\0".join(sorted(eol_only)),
capture_output=True,
text=True, encoding="utf-8", errors="replace",
check=False,
)
if _eol_only():
# Still dirty: pinning would only surface churn we failed to clear.
return
print(f"→ Normalized line-ending churn ({len(eol_only)} file(s))")
subprocess.run(
git_cmd + ["config", "core.autocrlf", "false"],
cwd=repo_root,
capture_output=True,
check=False,
)
File diff suppressed because it is too large Load Diff
+484
View File
@@ -0,0 +1,484 @@
"""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
File diff suppressed because it is too large Load Diff
+482
View File
@@ -0,0 +1,482 @@
"""ZIP-download fallback for ``hermes update`` (Windows with broken git): two-phase stage/commit swap, dirty-tree guard.
Split out of ``update_cmd.py``; every name is re-imported there so ``hermes_cli.update_cmd.<name>`` keeps
resolving/monkeypatching. Origin helpers are imported lazily per function (no cycle; test patches stay effective).
"""
import logging
from contextlib import suppress
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Optional
from hermes_cli.update_cmd_common import _best_effort
# Log-record parity with the origin module.
logger = logging.getLogger("hermes_cli.update_cmd")
def _atomic_replace_dir(src: str, dst: str) -> None:
"""Replace *dst* with *src* without a half-deleted window: naive ``rmtree; copytree`` loses the old
tree when the copy fails partway (likely here, since the ZIP path only runs when file I/O is flaky).
Thin alias over the two-phase helpers; retained for the ``hermes_cli.main`` re-export surface.
"""
_commit_staged_replacements([(_stage_replacement(src, dst), dst)])
def _stage_replacement(src: str, dst: str) -> str:
"""Phase 1: copy *src* (dir or file) to a sibling staging path for *dst*; return it.
Touches nothing live, so a failure here leaves the install untouched.
"""
staging = f"{dst}.hermes-update-staging"
backup = f"{dst}.hermes-update-old"
# A prior run may have died mid-swap leaving the backup as the ONLY copy. Restore it BEFORE
# clearing leftovers, else deleting it then failing to stage (disk exhaustion) leaves a hole.
if not os.path.exists(dst) and os.path.exists(backup):
os.rename(backup, dst)
for leftover in (staging, backup):
if os.path.isdir(leftover):
shutil.rmtree(leftover, ignore_errors=True)
elif os.path.exists(leftover):
os.remove(leftover)
if os.path.isdir(src):
shutil.copytree(src, staging)
else:
shutil.copy2(src, staging)
return staging
def _discard_staged(staged) -> None:
"""Remove staging paths for never-committed entries; otherwise a phase-1 failure (disk exhaustion)
orphans up to a full second tree and the advised retry fails harder with less free space.
"""
for staging, _dst in staged:
try:
if os.path.isdir(staging):
shutil.rmtree(staging, ignore_errors=True)
elif os.path.exists(staging):
os.remove(staging)
except OSError as exc: # best-effort cleanup, never fatal
logger.warning("could not remove staging path %s: %s", staging, exc)
def _commit_staged_replacements(staged) -> None:
"""Phase 2: swap every staged entry into place, rolling back all on failure.
Per-entry safety wasn't enough: a partway failure over ~90 entries left a mixed-version tree (every
file valid, combination unbootable). Covers plain files too (repo root holds 20 first-party modules).
Each swap is an ``os.rename`` onto a just-moved-aside path — atomic on POSIX and NTFS, unlike
``copy2`` onto a live path. Stage-all-then-swap-all shrinks the failure window to N renames and a
failed swap restores every entry already swapped, so the tree lands wholly new or wholly old.
"""
swapped: list[tuple[str, str]] = [] # (dst, backup) in swap order; "" = absent
try:
for staging, dst in staged:
backup = f"{dst}.hermes-update-old"
if os.path.exists(dst):
os.rename(dst, backup)
swapped.append((dst, backup))
else:
swapped.append((dst, ""))
os.rename(staging, dst)
except OSError:
# Undo every swap already made so the install stays self-consistent.
for dst, backup in reversed(swapped):
try:
if os.path.isdir(dst):
shutil.rmtree(dst, ignore_errors=True)
elif os.path.exists(dst):
os.remove(dst)
if backup and os.path.exists(backup):
os.rename(backup, dst)
except OSError as exc:
# Keep restoring the rest; a silent failure here turns a recoverable rollback into a mixed tree.
logger.warning("rollback failed for %s: %s", dst, exc)
raise
# All swaps succeeded — drop the backups (best-effort, never fatal).
for _dst, backup in swapped:
if backup and os.path.isdir(backup):
shutil.rmtree(backup, ignore_errors=True)
elif backup and os.path.exists(backup):
with suppress(OSError):
os.remove(backup)
def _zip_overlay_block_reason(
root: Path, *, ignore_staging_artifacts: bool = False
) -> Optional[str]:
"""Why overlaying a ZIP onto ``root`` would destroy work, or None if safe.
The swap replaces every top-level entry (minus a tiny preserve set) and deletes backups, so uncommitted
edits and untracked files are gone. Fails closed when git status cannot run. ``ignore_staging_artifacts``
is for the pre-swap re-check: phase 1 leaves our own ``*.hermes-update-staging`` siblings that git
reports as untracked; without the filter the re-check always refuses.
"""
if not (root / ".git").exists():
return None
git_cmd = ["git"]
if sys.platform == "win32":
git_cmd = ["git", "-c", "windows.appendAtomically=false"]
result = subprocess.run(
# -uall: a user-level ``status.showUntrackedFiles = no`` must not blind this guard. --ignored=matching:
# gitignored files are still USER DATA the overlay would delete; ``matching`` reports an ignored dir
# as one ``dir/`` line. ``--ignored=all`` is NOT a valid git mode (exits 128, would fail-close every update).
git_cmd + ["status", "--porcelain", "--untracked-files=all", "--ignored=matching"],
cwd=root,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if result.returncode != 0:
detail = (result.stderr or result.stdout or "").strip().splitlines()
suffix = f" ({detail[0]})" if detail else ""
return f"could not check the working tree{suffix}"
lines = [line for line in (result.stdout or "").splitlines() if line.strip()]
# Preserved entries (venv, node_modules are gitignored on every normal install) are never touched by the
# swap, so they must not cause a false refusal. Everything else — including ignored files — blocks.
lines = [line for line in lines if not _is_zip_preserved_entry_status_line(line)]
if ignore_staging_artifacts:
lines = [line for line in lines if not _is_zip_staging_artifact_status_line(line)]
if lines:
return "the working tree has uncommitted changes or untracked files"
return None
_ZIP_STAGING_ARTIFACT_SUFFIXES = ".hermes-update-staging", ".hermes-update-old"
# Single source of truth for entries the ZIP swap preserves — used by the dirty-tree filter and the swap loop.
_ZIP_PRESERVED_TOP_LEVEL = {"venv", "node_modules", ".git", ".env"}
def _is_zip_preserved_entry_status_line(line: str) -> bool:
"""True when every path on a porcelain status line sits under a preserved top-level entry.
The ``" -> "`` split applies ONLY to R/C codes: porcelain v1 doesn't quote plain names with spaces, so
``venv -> node_modules`` on a ``!!``/``??`` line is ONE path and splitting would fail-open. Requiring
EVERY path preserved keeps renames out of a preserved dir (``R venv/x -> src/x``) blocking.
"""
status, payload = (line[:2], line[3:]) if len(line) >= 3 else ("", line)
is_rename = any(code in "RC" for code in status)
paths = payload.split(" -> ") if is_rename else [payload]
for path in paths:
top_level = path.strip().strip('"').replace("\\", "/").rstrip("/").split("/", 1)[0]
if top_level not in _ZIP_PRESERVED_TOP_LEVEL:
return False
return True
def _is_zip_staging_artifact_status_line(line: str) -> bool:
"""True when a porcelain status line is our own two-phase-swap artifact."""
payload = line[3:] if len(line) >= 3 else line
top_level = payload.strip().strip('"').replace("\\", "/").rstrip("/").split("/", 1)[0]
return top_level.endswith(_ZIP_STAGING_ARTIFACT_SUFFIXES)
def _abort_zip_update_if_dirty_tree() -> None:
"""Refuse to overlay a ZIP onto a dirty git checkout."""
from hermes_cli.update_cmd import _m
reason = _zip_overlay_block_reason(_m().PROJECT_ROOT)
if reason is None:
return
print(f"✗ ZIP fallback refused: {reason}.")
print(
" Overlaying the ZIP would overwrite uncommitted edits and permanently "
"delete untracked files."
)
print(" Stash or commit your changes, then rerun `hermes update`.")
print(" To inspect: git status --porcelain")
_m().sys.exit(1)
def _download_and_swap_zip(branch: str, zip_url: str) -> None:
"""Download the source ZIP for *branch* and two-phase swap it into the checkout.
``sys.exit(1)`` on any failure; the install ends fully updated or fully rolled back.
"""
from hermes_cli.update_cmd import _m
import tempfile
import zipfile
from urllib.request import urlretrieve
print("→ Downloading latest version...")
tmp_dir = tempfile.mkdtemp(prefix="hermes-update-")
try:
zip_path = os.path.join(tmp_dir, f"hermes-agent-{branch}.zip")
urlretrieve(zip_url, zip_path)
print("→ Extracting...")
import stat as _stat
with zipfile.ZipFile(zip_path, "r") as zf:
# Reject zip-slip AND symlink members: a source ZIP never legitimately contains symlinks,
# and a compromised mirror could use them to plant files anywhere.
tmp_dir_real = os.path.realpath(tmp_dir)
for member in zf.infolist():
member_path = os.path.realpath(os.path.join(tmp_dir, member.filename))
if (
not member_path.startswith(tmp_dir_real + os.sep)
and member_path != tmp_dir_real
):
raise ValueError(
f"Zip-slip detected: {member.filename} escapes extraction directory"
)
# Unix mode lives in the upper 16 bits of external_attr; mask to the file-type bits.
mode = (member.external_attr >> 16) & 0o170000
if _stat.S_ISLNK(mode):
raise ValueError(f"ZIP contains unsupported symlink member: {member.filename}")
zf.extractall(tmp_dir)
# GitHub ZIPs extract to hermes-agent-<branch>/
extracted = os.path.join(tmp_dir, f"hermes-agent-{branch}")
if not os.path.isdir(extracted):
for d in os.listdir(tmp_dir):
candidate = os.path.join(tmp_dir, d)
if os.path.isdir(candidate) and d != "__MACOSX":
extracted = candidate
break
preserve = _ZIP_PRESERVED_TOP_LEVEL
entries = [i for i in os.listdir(extracted) if i not in preserve]
# Two-phase replace: stage every entry (dirs AND top-level files) beside its target, then swap all
# in with same-filesystem renames, rolling back on failure — one-at-a-time replacement left a
# mixed, unbootable tree on interruption. Staging costs one extra tree copy: check space up front.
need = sum(
os.path.getsize(os.path.join(dirpath, f))
for entry in entries
for dirpath, _dirs, files in os.walk(os.path.join(extracted, entry))
for f in files
) + sum(
os.path.getsize(os.path.join(extracted, e))
for e in entries
if os.path.isfile(os.path.join(extracted, e))
)
# Swaps are renames, so only the staging copy is new: require it plus 20% headroom, not 2x,
# which would block updates on exactly the space-constrained machines that hit this path.
required = int(need * 1.2)
free = shutil.disk_usage(str(_m().PROJECT_ROOT)).free
if free < required:
raise RuntimeError(
f"not enough free disk space to stage the update safely "
f"(need ~{required // (1024 * 1024)} MB, have "
f"{free // (1024 * 1024)} MB)"
)
staged: list[tuple[str, str]] = []
try:
for item in entries:
src = os.path.join(extracted, item)
dst = os.path.join(str(_m().PROJECT_ROOT), item)
staged.append((_stage_replacement(src, dst), dst))
# The source ZIP lacks apps/desktop/release/ (the BUILT desktop app); swapping `apps` without
# it deletes the build and breaks the shortcut. Graft the live release dir in BEFORE the swap.
if item == "apps":
live_release = os.path.join(dst, "desktop", "release")
staged_release = os.path.join(staged[-1][0], "desktop", "release")
if os.path.isdir(live_release) and not os.path.exists(
staged_release
):
os.makedirs(os.path.dirname(staged_release), exist_ok=True)
shutil.copytree(live_release, staged_release)
except Exception:
# Nothing is live yet; drop partial staging copies so a retry starts from the same free space.
_discard_staged(staged)
raise
try:
# TOCTOU re-check right before the swap: download + extract + staging can take minutes and
# work created meanwhile would be destroyed. Our own staging siblings are filtered out.
recheck_reason = _zip_overlay_block_reason(
_m().PROJECT_ROOT, ignore_staging_artifacts=True
)
if recheck_reason is not None:
_discard_staged(staged)
print(f"✗ ZIP fallback aborted before the swap: {recheck_reason}.")
print(
" Files appeared in the checkout while the update was "
"downloading; committing the swap would delete them."
)
print(" Stash or commit your changes, then rerun `hermes update`.")
_m().sys.exit(1)
_commit_staged_replacements(staged)
except Exception:
# Rollback restored swapped entries but staging copies for the rest remain; drop them or the
# retry's up-front free-space check (runs BEFORE per-entry leftover cleanup) fails on our litter.
# Safe post-rollback: _discard_staged skips paths that no longer exist.
_discard_staged(staged)
raise
update_count = len(staged)
print(f"✓ Updated {update_count} items from ZIP")
except Exception as e:
print(f"✗ ZIP update failed: {e}")
# Two-phase replace commits all or rolls all back, so no mixed tree here — don't push a needless reinstall.
print(" Your existing install was left in place.")
print(
" Re-run `hermes update` to retry; if the agent won't start, "
"reinstall from https://hermes-agent.nousresearch.com"
)
_m().sys.exit(1)
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
def _reinstall_python_deps_after_zip(active_tool_dependencies) -> None:
"""Reinstall Python deps (uv preferred, pip fallback) and re-arm active tool deps."""
from hermes_cli.update_cmd import (
_ensure_uv_for_termux,
_ensure_venv_pip,
_m,
_refuse_update_for_contended_shims,
_shim_quarantine_error_type,
)
from hermes_cli.managed_uv import ensure_uv, update_managed_uv
# Keep managed uv current — runs `uv self update` if we already have one.
update_managed_uv()
uv_bin = ensure_uv()
pip_cmd = [_m().sys.executable, "-m", "pip"]
if not uv_bin:
uv_bin = _ensure_uv_for_termux(pip_cmd)
if uv_bin:
# Same UV-env isolation as the main update path: a user-level UV_PYTHON_INSTALL_DIR / UV_PYTHON
# from unrelated software must not steer which interpreter uv resolves here.
from hermes_cli.managed_uv import managed_python_env
uv_env = managed_python_env()
uv_env["VIRTUAL_ENV"] = str(_m().PROJECT_ROOT / "venv")
if _m()._is_termux_env(uv_env):
uv_env.pop("PYTHONPATH", None)
uv_env.pop("PYTHONHOME", None)
try:
_m()._install_python_dependencies_with_optional_fallback([uv_bin, "pip"], env=uv_env)
except _shim_quarantine_error_type() as _sqe:
# Runs inside the ZIP-fallback error handler, so cmd_update's boundary except cannot catch
# it — refuse here with the same defer-via-marker contract.
_refuse_update_for_contended_shims(_sqe)
else:
# sys.executable -m pip avoids PEP 668 'externally-managed-environment' errors.
_ensure_venv_pip(pip_cmd, _m().sys.executable)
_m()._install_python_dependencies_with_optional_fallback(pip_cmd)
install_prefix = [uv_bin, "pip"] if uv_bin else pip_cmd
install_env = uv_env if uv_bin else None
_m()._restore_active_tool_dependencies(
active_tool_dependencies,
install_prefix,
env=install_env,
)
# Parity with git-pull path: heal the active memory provider's bridge packages after the reinstall.
_m()._refresh_active_memory_provider_dependencies()
def _update_via_zip(args, *, had_desktop_app_before_update: bool = False) -> bool:
"""Update via ZIP archive; used on Windows when git file I/O is broken (antivirus / NTFS filter
drivers causing 'Invalid argument'). Returns ``False`` when a Desktop rebuild ran and failed.
"""
from hermes_cli.update_cmd import (
_finish_dashboard_update_cleanup,
_m,
_print_bundled_skills_sync_report,
_print_curator_first_run_notice,
_print_curator_recent_run_notice,
_print_update_summary,
_read_project_version,
_rebuild_desktop_after_update,
_sweep_bytecode_after_update,
_update_node_dependencies,
_validate_critical_modules_import,
_verify_and_restore_state_dbs_post_update,
)
active_tool_dependencies = _m()._capture_active_tool_dependencies()
# Snapshot the pre-update version before files are replaced so the completion line can report it.
pre_update_version = _read_project_version()
# The static archive would silently ignore --branch — the exact silent-divergence bug it exists to
# prevent. Refuse rather than lie.
branch = _m()._resolve_update_branch(args)
if branch != "main":
print(f"✗ --branch={branch} is not supported on the Windows ZIP-fallback " "update path.")
print(
" This path runs when git file I/O is broken on the system. "
"Either resolve the git-side breakage (typically an antivirus "
"or NTFS filter holding files open) and rerun `hermes update "
f"--branch {branch}`, or update against main with `hermes update`."
)
_m().sys.exit(1)
_abort_zip_update_if_dirty_tree()
zip_url = f"https://github.com/NousResearch/hermes-agent/archive/refs/heads/{branch}.zip"
_download_and_swap_zip(branch, zip_url)
_sweep_bytecode_after_update(branch)
# Prefer .[all]; if one extra breaks, keep base deps and retry remaining extras individually so
# capabilities aren't silently stripped. Self-lock deferral: the code swap is committed; defer only
# the dependency sync when this process holds a native extension the sync must rewrite.
_m()._abort_dependency_sync_if_self_locked()
print("→ Updating Python dependencies...")
_reinstall_python_deps_after_zip(active_tool_dependencies)
# Verify the tree imports (catches the parse-OK-but-skewed tree an interrupted copy leaves). Runs
# *after* the dep reinstall so a genuinely-new third-party requirement isn't misreported as a partial
# copy. No SHA to roll back to — surface a concrete recovery step instead of success over a bricked install.
import_ok, failing_module, import_error = _validate_critical_modules_import(_m().PROJECT_ROOT)
if not import_ok:
print()
print("✗ Update left the install in an unimportable state:")
print(f" {failing_module}: {import_error}")
print()
print(" This usually means the copy was interrupted partway through.")
print(" Re-run `hermes update` to complete it.")
_m().sys.exit(1)
node_failures = _update_node_dependencies()
_m()._build_web_ui(_m().PROJECT_ROOT / "web")
desktop_build_ok = _rebuild_desktop_after_update(
_m().PROJECT_ROOT / "apps" / "desktop",
had_desktop_app_before_update=had_desktop_app_before_update,
)
with suppress(Exception):
print("→ Syncing bundled skills...")
_print_bundled_skills_sync_report()
# Seed the model-catalog disk cache from the fresh checkout (same rationale as _cmd_update_impl). Non-fatal.
with _best_effort('Model catalog seed during zip update failed: %s'):
from hermes_cli.model_catalog import seed_cache_from_checkout
if seed_cache_from_checkout(_m().PROJECT_ROOT):
print(" ✓ Model catalog cache refreshed from checkout")
# state.db integrity guard: root home AND every sibling profile, each auto-restored from its own snapshot.
with _best_effort('Post-update state.db integrity check (zip path) failed: %s'):
_verify_and_restore_state_dbs_post_update()
update_complete = _print_update_summary(
node_failures=node_failures,
desktop_build_ok=desktop_build_ok,
pre_update_version=pre_update_version,
)
with _best_effort('Curator first-run notice failed: %s'):
_print_curator_first_run_notice()
with _best_effort('Curator recent-run notice failed: %s'):
_print_curator_recent_run_notice()
# Don't stop a working dashboard when the Node refresh failed — see the git-update path for rationale.
_finish_dashboard_update_cleanup(node_failures)
with _best_effort('Update receipt finalize (zip path) failed: %s'):
from hermes_cli.update_receipt import finalize_update_receipt
finalize_update_receipt("success" if update_complete and not node_failures else "partial")
return update_complete
@@ -105,7 +105,10 @@ def test_update_network_git_calls_never_prompt_for_credentials():
# configured so a private-fork origin still authenticates.
assert "GIT_CONFIG_COUNT" not in kw["env"] or kw["env"]["GIT_CONFIG_COUNT"] == os.environ.get("GIT_CONFIG_COUNT")
src = inspect.getsource(update_cmd)
from hermes_cli import update_cmd_git
# Network git calls live in the origin (``_git_run``) and the split git module.
src = inspect.getsource(update_cmd) + inspect.getsource(update_cmd_git)
# Every subprocess.run(...) whose argv is a fetch/pull must spread the kwargs.
calls = []
for m in re.finditer(r"subprocess\.run\(", src):
@@ -19,6 +19,8 @@ from pathlib import Path
import hermes_cli.main as main_mod
import hermes_cli.update_cmd as update_mod
import hermes_cli.update_cmd_maint as update_maint_mod
import hermes_cli.update_cmd_zip as update_zip_mod
_COUNT_RE = re.compile(r"user-modified \(kept\)")
@@ -26,11 +28,11 @@ _HINT_RE = re.compile(r"hermes skills list-modified")
def _source_lines() -> list[str]:
# The update pipeline was extracted to hermes_cli/update_cmd.py
# (main.py decomposition); scan both homes of the notice.
# The update pipeline was extracted to hermes_cli/update_cmd.py and then
# split into update_cmd_*.py; scan every home of the notice.
return [
line
for mod in (main_mod, update_mod)
for mod in (main_mod, update_mod, update_maint_mod, update_zip_mod)
for line in Path(mod.__file__).read_text(encoding="utf-8").splitlines()
]
@@ -339,7 +339,10 @@ def test_swap_preserve_set_is_the_module_constant():
truth for the preserved entries (no comment-synced duplicate)."""
import inspect
src = inspect.getsource(update_cmd._update_via_zip)
from hermes_cli import update_cmd_zip
# The swap loop lives in the download/swap collaborator the ZIP path calls.
src = inspect.getsource(update_cmd_zip._download_and_swap_zip)
assert "preserve = _ZIP_PRESERVED_TOP_LEVEL" in src
@@ -439,7 +439,10 @@ def test_update_via_zip_wires_discard_into_the_commit_failure_path():
import inspect
import textwrap
src = textwrap.dedent(inspect.getsource(update_cmd._update_via_zip))
# The swap lives in the download/swap collaborator the ZIP path calls.
from hermes_cli import update_cmd_zip
src = textwrap.dedent(inspect.getsource(update_cmd_zip._download_and_swap_zip))
tree = ast.parse(src)
def _calls(node, name):
+2 -2
View File
@@ -64,11 +64,11 @@ _ALLOWED: dict[tuple[str, str], str] = {
"can only run what is on that subshell's PATH, which local.py populates "
"with the managed dirs — so PATH is the correct question to ask here."
),
("hermes_cli/update_cmd.py", "uv"): (
("hermes_cli/update_cmd_deps.py", "uv"): (
"Termux fallback: a pkg-installed uv lands on PATH but not in the "
"managed bin dir, and it is checked only after resolve_uv() misses."
),
("hermes_cli/update_cmd.py", "npm"): (
("hermes_cli/update_cmd_deps.py", "npm"): (
"WSL diagnostic: deliberately inspects what PATH resolves so it can "
"warn that the only reachable npm is the Windows one."
),