# Conflicts: # apps/desktop/electron/backend-connection-state.test.ts # apps/desktop/electron/backend-connection-state.ts # apps/desktop/electron/backend-exit.test.ts # apps/desktop/electron/main.ts # apps/desktop/electron/pool-spawn-coordinator.test.ts # apps/desktop/electron/pool-stop.ts # apps/desktop/electron/preload.ts # apps/desktop/src/app/settings/about-settings.tsx # apps/desktop/src/app/updates-overlay.tsx # apps/desktop/src/global.d.ts # apps/desktop/src/store/notifications.ts # apps/desktop/src/store/updates.ts # gateway/config_loader.py # hermes_cli/banner.py # plugins/platforms/dingtalk/adapter.py # tests/hermes_cli/test_plugins_cmd.py # tests/test_live_system_guard.py # tui_gateway/server.py # website/docs/user-guide/desktop.md
1836 lines
90 KiB
Python
1836 lines
90 KiB
Python
"""Hermes update pipeline: dispatchers (``_cmd_update_impl``/``_cmd_update_check``) + git plumbing.
|
||
|
||
Each concern lives in ``update_cmd_<concern>.py`` and is re-imported here so
|
||
``hermes_cli.update_cmd.<name>`` keeps resolving (and stays monkeypatchable). Imports are one-way:
|
||
main -> update_cmd -> update_cmd_*; ``_m()`` resolves ``hermes_cli.main`` at call time.
|
||
"""
|
||
|
||
import logging
|
||
from contextlib import suppress
|
||
import os
|
||
import re
|
||
import shlex
|
||
import shutil # noqa: F401 (tests patch update_cmd.shutil.*; split modules resolve it here)
|
||
import subprocess
|
||
import sys
|
||
import time as _time
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
|
||
from hermes_cli.config import get_hermes_home # noqa: F401 (re-exported; patched via update_cmd)
|
||
from hermes_cli.update_cmd_common import _best_effort
|
||
from hermes_constants import venv_python_path
|
||
|
||
# Re-exports: every split-module name stays reachable (and monkeypatchable) as update_cmd.<name>.
|
||
from hermes_cli.update_abort_recovery import ( # noqa: F401
|
||
_abort_recovery_is_complete, _qualified_serve_skips, _recover_gateway_restart_after_abort,
|
||
_serve_unit_recovery_available, _surviving_pre_update_serve_runtimes,
|
||
_warn_stale_serve_runtimes)
|
||
from hermes_cli.update_cmd_windows import ( # noqa: F401
|
||
_HOLDER_VALUE_FLAGS_FALLBACK, _clear_windows_venv_holders_or_exit,
|
||
_cold_start_windows_gateway_after_update, _desktop_owns_gateway_lifecycle,
|
||
_detect_venv_python_processes, _format_venv_python_holders_message,
|
||
_handoff_reapable_backend_pids, _hermes_holder_subcommand, _holder_value_flags,
|
||
_holder_value_flags_cache, _ledger_manual_serve_holders, _ledger_reapable_backend_pids,
|
||
_leftover_pausable_gateway_pids, _looks_like_desktop_control_plane,
|
||
_orphaned_desktop_backend_pids, _pause_windows_gateways_for_update,
|
||
_refresh_bootstrap_cache_scripts, _refresh_windows_gateway_launchers,
|
||
_refuse_gateway_ancestor_tree_kill, _relaunch_stopped_serves,
|
||
_restore_windows_gateway_service, _resume_windows_gateways_after_update,
|
||
_resume_windows_gateways_and_merge_outcome, _self_and_non_gateway_ancestor_pids,
|
||
_serve_relaunch_commands, _start_windows_gateway_service, _stop_process_trees,
|
||
_stop_windows_gateway_service, _venv_launcher_ancestors,
|
||
_wait_for_windows_update_gateway_exit, _write_update_planned_stop_marker)
|
||
from hermes_cli.update_cmd_fleet import ( # noqa: F401
|
||
_FLEET_RESTART_PENDING_NAME, _FRESH_RESTART_SUPERVISORS, _GatewayRestartOutcome,
|
||
_apply_pending_fleet_restart_catchup, _clear_fleet_restart_pending_marker,
|
||
_current_checkout_sha, _drain_or_signal_gateway_for_update, _fleet_probe_expected_runtimes,
|
||
_fleet_restart_pending_marker_path, _for_each_systemd_gateway_unit,
|
||
_gateway_recovery_partition, _gateway_service_matches_profile, _pending_fleet_restart_needed,
|
||
_receipt_looks_unfinished, _receipt_reports_stale_runtime, _resolve_manage_cmd,
|
||
_restart_gateway_fleet_after_update, _restart_launchd_gateway_after_update,
|
||
_restart_macos_launchd_gateways, _restart_phase_failure_is_incomplete,
|
||
_restart_systemd_gateway_units, _restart_systemd_gateway_units_best_effort,
|
||
_run_pending_fleet_restart, _service_restart_sec,
|
||
_service_unit_supports_graceful_sigusr1_restart, _surviving_gateway_pids_after_failed_restart,
|
||
_systemctl, _systemctl_reset_and_restart, _verify_fleet_after_update,
|
||
_wait_for_service_active, _warn_gateway_restart_phase_aborted,
|
||
_warn_incomplete_gateway_fleet_restart, _warn_pending_fleet_restart,
|
||
_warn_pending_fleet_restart_on_startup, _write_fleet_restart_pending_marker,
|
||
_write_gateway_update_exit_code)
|
||
from hermes_cli.update_cmd_zip import ( # noqa: F401
|
||
_ZIP_PRESERVED_TOP_LEVEL, _ZIP_STAGING_ARTIFACT_SUFFIXES, _abort_zip_update_if_dirty_tree,
|
||
_atomic_replace_dir, _commit_staged_replacements, _discard_staged,
|
||
_is_zip_preserved_entry_status_line, _is_zip_staging_artifact_status_line, _stage_replacement,
|
||
_update_via_zip, _zip_overlay_block_reason)
|
||
from hermes_cli.update_cmd_stash import ( # noqa: F401
|
||
_AUTOSTASH_NAME_PREFIX, _AUTOSTASH_WARN_AGE_DAYS, _discard_stashed_changes,
|
||
_git_untracked_paths, _park_stashed_changes, _print_stash_cleanup_guidance,
|
||
_reject_unsafe_stash_restore, _resolve_stash_selector, _restore_stashed_changes,
|
||
_restored_python_paths, _stash_apply_failed_only_on_existing_untracked,
|
||
_stash_local_changes_if_needed, _warn_orphaned_update_autostashes)
|
||
from hermes_cli.update_cmd_config import ( # noqa: F401
|
||
_LAST_SIBLING_SNAPSHOTS, _check_and_apply_config_migration, _migrate_sibling_profile_configs,
|
||
_print_items, _reload_config_modules, _run_config_check_fresh, _run_migrate_config_fresh)
|
||
from hermes_cli.update_cmd_deps import ( # noqa: F401
|
||
_INSTALL_DEFINING_FILES, _UPDATE_CRITICAL_MODULES,
|
||
_capture_active_lazy_features,
|
||
_capture_active_tool_dependencies, _critical_module_import_failures,
|
||
_desktop_app_present,
|
||
_editable_install_is_current,
|
||
_npm_bin_exists,
|
||
_npm_lockfile_changed, _npm_manifest_paths, _npm_manifests_digest, _path_uid,
|
||
_rebuild_desktop_after_update, _record_npm_lockfile_hash, _refresh_active_lazy_features,
|
||
_refresh_active_memory_provider_dependencies, _refuse_update_if_venv_foreign_owned,
|
||
_repair_node_deps_on_current_checkout,
|
||
_sync_python_dependencies_after_pull, _update_node_dependencies,
|
||
_validate_critical_modules_import,
|
||
_venv_core_imports_healthy, _venv_foreign_owned_paths, _web_build_toolchain_ready,
|
||
_web_toolchain_roots)
|
||
from hermes_cli.update_cmd_git import ( # noqa: F401
|
||
OFFICIAL_REPO_URL, OFFICIAL_REPO_URLS, SKIP_UPSTREAM_PROMPT_FILE, _ORPHAN_RESCUE_REFS_TO_KEEP,
|
||
_ORPHAN_RESCUE_REF_MAX_AGE_DAYS, _add_upstream_remote, _assess_parked_branch_switch,
|
||
_branch_head_label, _branch_head_suffix, _classify_fetch_failure, _count_commits_between,
|
||
_discard_lockfile_churn, _ensure_non_trampoline_git, _get_origin_url, _git_is_trampoline,
|
||
_has_upstream_remote, _is_fork, _locate_real_git, _mark_skip_upstream_prompt,
|
||
_normalize_managed_eol, _portable_git_candidates, _print_fetch_failure,
|
||
_print_parked_branch_kept_notice, _print_parked_branch_skip_warning,
|
||
_prune_orphan_rescue_refs, _should_skip_upstream_prompt, _sync_fork_with_upstream,
|
||
_sync_with_upstream_if_needed)
|
||
from hermes_cli.update_cmd_maint import ( # noqa: F401
|
||
_PRE_UPDATE_SNAPSHOT_KEEP, _PRE_UPDATE_SNAPSHOT_MAX_FILE_SIZE, _STALE_PURGE_PREFIXES,
|
||
_STALE_PURGE_PROTECTED, _UPDATE_RUNTIME_RELOAD_MODULES, _clear_stale_sqlite_sidecars,
|
||
_ensure_acp_launcher, _ensure_fhs_path_guard, _finish_dashboard_update_cleanup,
|
||
_format_time_ago, _post_update_sqlite_runtime_status, _print_bundled_skills_sync_report,
|
||
_print_curator_first_run_notice, _print_curator_recent_run_notice,
|
||
_print_fts_optimize_available_notice, _print_update_completion, _print_update_summary,
|
||
_print_verified_update_completion, _purge_stale_hermes_modules, _read_project_version,
|
||
_reload_process_scan_modules, _reload_updated_runtime_modules,
|
||
_resolve_pre_update_backup_mode, _restore_state_db_from_snapshot,
|
||
_run_post_update_maintenance, _run_pre_update_backup, _sweep_bytecode_after_update,
|
||
_update_complete_message, _verify_and_restore_one_state_db,
|
||
_verify_and_restore_state_dbs_post_update)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _m():
|
||
"""Lazy ``hermes_cli.main`` reference.
|
||
|
||
Lets callers keep patching ``hermes_cli.main.<helper>`` (the historical
|
||
test surface) and have those patches reach this code path, and defers the
|
||
import so ``hermes_cli.main`` -> ``hermes_cli.update_cmd`` stays one-way
|
||
at import time.
|
||
"""
|
||
from hermes_cli import main
|
||
|
||
return main
|
||
|
||
|
||
def _updates_config() -> dict:
|
||
"""The ``updates:`` config section (``{}`` when absent/malformed); may raise on config errors."""
|
||
from hermes_cli.config import load_config
|
||
section = (load_config() or {}).get("updates", {})
|
||
return section if isinstance(section, dict) else {}
|
||
|
||
|
||
def _no_prompt_git_kwargs() -> dict:
|
||
"""``subprocess.run`` kwargs for the updater's network git calls.
|
||
|
||
GitHub answers anonymous fetches with HTTP 401 during outages (and for
|
||
unreachable repos); git then prompts ``Username for 'https://github.com':``
|
||
on the inherited terminal and the update sits there forever. Disable the
|
||
prompt so the fetch fails fast into ``_classify_fetch_failure``. Only the
|
||
*prompt* is disabled — a configured credential helper / askpass still
|
||
runs, so a private-fork origin keeps authenticating non-interactively.
|
||
"""
|
||
env = dict(os.environ)
|
||
env["GIT_TERMINAL_PROMPT"] = "0"
|
||
env["GCM_INTERACTIVE"] = "Never"
|
||
return {"stdin": subprocess.DEVNULL, "env": env}
|
||
|
||
|
||
_UPDATE_CRITICAL_FILES = (
|
||
"hermes_cli/main.py", "hermes_cli/config.py", "hermes_cli/__init__.py",
|
||
"hermes_cli/web_server.py", "cli.py", "run_agent.py", "model_tools.py", "toolsets.py",
|
||
"hermes_constants.py")
|
||
|
||
|
||
def _record_update_step(step: str, ok: bool, detail: str = "") -> None:
|
||
"""Best-effort ``update_receipt.record_step``; the receipt must never break an update."""
|
||
with suppress(Exception):
|
||
from hermes_cli.update_receipt import record_step
|
||
record_step(step, ok, detail)
|
||
|
||
|
||
# A fetch whose transport dead-stalls (HTTP/2 to GitHub on some networks, a black-holed proxy)
|
||
# otherwise leaves `hermes update` on "Fetching updates..." forever (#93759, #95777). Five
|
||
# minutes is generous for a scoped single-branch fetch and still ends in a real error.
|
||
NETWORK_GIT_TIMEOUT_SECONDS = 300
|
||
|
||
|
||
def _git_run(git_cmd, args, cwd=None, *, check=False, network=False):
|
||
"""Run git capturing utf-8 text (default cwd: checkout); ``network=True`` disables the
|
||
terminal prompt so an HTTP 401 fails fast instead of hanging, and bounds the wait."""
|
||
try:
|
||
return subprocess.run(
|
||
git_cmd + args, cwd=_m().PROJECT_ROOT if cwd is None else cwd, capture_output=True,
|
||
text=True, encoding="utf-8", errors="replace", check=check,
|
||
**({"timeout": NETWORK_GIT_TIMEOUT_SECONDS, **_no_prompt_git_kwargs()} if network else {}))
|
||
except subprocess.TimeoutExpired as exc:
|
||
# subprocess.run already killed the child; the checkout stays consistent because
|
||
# fetch writes to tmp_pack_* and only renames on success. Report as a failed run
|
||
# so every caller's existing stderr path prints one clear line.
|
||
result = subprocess.CompletedProcess(
|
||
exc.cmd, 124, stdout="",
|
||
stderr=f"git {args[0]} timed out after {NETWORK_GIT_TIMEOUT_SECONDS}s with no response from the remote")
|
||
if check:
|
||
raise subprocess.CalledProcessError(124, exc.cmd, output="", stderr=result.stderr) from exc
|
||
return result
|
||
|
||
|
||
def _capture_head_sha(git_cmd, cwd) -> str | None:
|
||
"""Return the current HEAD SHA, or None if it can't be resolved."""
|
||
try:
|
||
result = subprocess.run(
|
||
git_cmd + ["rev-parse", "HEAD"],
|
||
cwd=cwd,
|
||
capture_output=True,
|
||
text=True, encoding="utf-8", errors="replace",
|
||
check=True,
|
||
)
|
||
return result.stdout.strip() or None
|
||
except (subprocess.CalledProcessError, OSError):
|
||
return None
|
||
|
||
|
||
# Files that define the editable install. A pull that touches none of them
|
||
# cannot have invalidated it.
|
||
|
||
|
||
def _validate_python_files_syntax(
|
||
root, relpaths
|
||
) -> tuple[bool, str | None, str | None]:
|
||
"""Compile *relpaths* under *root* without writing bytecode into the tree."""
|
||
import py_compile
|
||
import tempfile
|
||
|
||
root = Path(root)
|
||
with tempfile.TemporaryDirectory(prefix="hermes-syntax-check-") as tmpdir:
|
||
for relpath in relpaths:
|
||
path = root / relpath
|
||
if not path.exists():
|
||
continue
|
||
cfile = Path(tmpdir) / (str(relpath).replace("/", "__") + "c")
|
||
try:
|
||
py_compile.compile(str(path), cfile=str(cfile), doraise=True)
|
||
except py_compile.PyCompileError as exc:
|
||
return False, str(path), str(exc)
|
||
except OSError as exc:
|
||
return False, str(path), f"could not read: {exc}"
|
||
return True, None, None
|
||
|
||
|
||
def _validate_critical_files_syntax(root) -> tuple[bool, str | None, str | None]:
|
||
"""Compile each file in ``_UPDATE_CRITICAL_FILES`` to catch SyntaxErrors.
|
||
|
||
These are the files imported on every ``hermes`` startup; if any of them
|
||
has a syntax error (orphan merge-conflict markers, bad ref to a name
|
||
that no longer exists, etc.) the CLI can't bootstrap at all. We validate
|
||
them after a successful ``git pull`` so we can auto-roll-back instead of
|
||
leaving the user with a bricked install.
|
||
|
||
The compiled ``.pyc`` is written to a temp directory rather than the
|
||
source tree's ``__pycache__/`` so we don't race with concurrent test
|
||
workers that walk the same dir, and so we don't leave a stale pyc
|
||
behind in production if the next interpreter run picks a different
|
||
Python version. The pyc is discarded on function return either way —
|
||
we only care about the compile-or-not signal.
|
||
|
||
Returns ``(ok, failing_path, error_message)``. ``ok=True`` means every
|
||
file parsed cleanly.
|
||
"""
|
||
return _validate_python_files_syntax(root, _UPDATE_CRITICAL_FILES)
|
||
|
||
|
||
# Modules imported on every agent startup. Unlike _UPDATE_CRITICAL_FILES (which
|
||
# is only parsed), these are actually *imported* so that cross-module breakage
|
||
# is caught — a file can be syntactically perfect and still fail to import
|
||
# because a name it pulls from a sibling module no longer exists.
|
||
|
||
|
||
def _gateway_prompt(prompt_text: str, default: str = "", timeout: float = 300.0) -> str:
|
||
"""File-based IPC prompt for gateway mode.
|
||
|
||
Writes a prompt marker file so the gateway can forward the question to the
|
||
user, then polls for a response file. Falls back to *default* on timeout.
|
||
|
||
Used by ``hermes update --gateway`` so interactive prompts (stash restore,
|
||
config migration) are forwarded to the messenger instead of being silently
|
||
skipped.
|
||
"""
|
||
import json as _json
|
||
import uuid as _uuid
|
||
from hermes_constants import get_hermes_home
|
||
|
||
home = get_hermes_home()
|
||
prompt_path = home / ".update_prompt.json"
|
||
response_path = home / ".update_response"
|
||
|
||
# Clean any stale response file
|
||
response_path.unlink(missing_ok=True)
|
||
|
||
payload = {
|
||
"prompt": prompt_text,
|
||
"default": default,
|
||
"id": str(_uuid.uuid4()),
|
||
}
|
||
tmp = prompt_path.with_suffix(".tmp")
|
||
tmp.write_text(_json.dumps(payload), encoding="utf-8")
|
||
tmp.replace(prompt_path)
|
||
|
||
# Poll for response
|
||
deadline = _time.monotonic() + timeout
|
||
while _time.monotonic() < deadline:
|
||
if response_path.exists():
|
||
try:
|
||
answer = response_path.read_text(encoding="utf-8-sig").strip()
|
||
response_path.unlink(missing_ok=True)
|
||
prompt_path.unlink(missing_ok=True)
|
||
return answer if answer else default
|
||
except (OSError, ValueError):
|
||
pass
|
||
_time.sleep(0.5)
|
||
|
||
# Timeout — clean up and use default
|
||
prompt_path.unlink(missing_ok=True)
|
||
response_path.unlink(missing_ok=True)
|
||
print(f" (no response after {int(timeout)}s, using default: {default!r})")
|
||
return default
|
||
|
||
|
||
def _called_process_error_cmd_parts(exc: subprocess.CalledProcessError) -> list[str]:
|
||
"""Normalize ``CalledProcessError.cmd`` into argv-style tokens."""
|
||
cmd = exc.cmd
|
||
if cmd is None:
|
||
return []
|
||
if isinstance(cmd, (str, bytes)):
|
||
text = cmd.decode("utf-8", "replace") if isinstance(cmd, bytes) else cmd
|
||
try:
|
||
return shlex.split(text, posix=os.name != "nt")
|
||
except ValueError:
|
||
return text.split()
|
||
return [str(part) for part in cmd]
|
||
|
||
|
||
def _called_process_error_is_git(exc: subprocess.CalledProcessError) -> bool:
|
||
"""True when the failed subprocess was git itself."""
|
||
parts = _called_process_error_cmd_parts(exc)
|
||
if not parts:
|
||
return False
|
||
# Windows argv may use backslashes; basename() on POSIX would otherwise
|
||
# keep the whole path. Normalize separators before taking the name.
|
||
name = os.path.basename(parts[0].replace("\\", "/")).lower()
|
||
return name in {"git", "git.exe"}
|
||
|
||
|
||
def _called_process_error_is_python_dep_install(
|
||
exc: subprocess.CalledProcessError,
|
||
) -> bool:
|
||
"""True when the failed subprocess was a uv/pip (or ensurepip) install."""
|
||
parts = [part.lower() for part in _called_process_error_cmd_parts(exc)]
|
||
if not parts:
|
||
return False
|
||
exe = os.path.basename(parts[0].replace("\\", "/"))
|
||
if "ensurepip" in parts:
|
||
return True
|
||
if "install" in parts and (
|
||
"pip" in parts or exe in {"pip", "pip.exe", "pip3", "pip3.exe", "uv", "uv.exe"}
|
||
):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _format_update_failure_stage(exc: subprocess.CalledProcessError) -> str:
|
||
"""Name the update stage that actually failed.
|
||
|
||
The git pull and the Python-dependency install share one ``try`` in
|
||
``_cmd_update_impl``. Calling every ``CalledProcessError`` a git failure
|
||
(the historical Windows message) sent users hunting in the wrong place
|
||
and, worse, keyed the ZIP overlay on exception *type* rather than on git
|
||
actually having failed (#87304, #85840).
|
||
"""
|
||
if _called_process_error_is_python_dep_install(exc):
|
||
return "Python dependency install failed"
|
||
if _called_process_error_is_git(exc):
|
||
return "Git update failed"
|
||
return "Update step failed"
|
||
|
||
|
||
def _should_zip_fallback_on_update_error(exc: BaseException) -> bool:
|
||
"""ZIP fallback is for Windows git file-I/O breakage, not later stages.
|
||
|
||
A dependency-install failure (locked ``hermes.exe`` / ``uv pip install``
|
||
exit 2) is not a git failure. The pull has already succeeded by then, so
|
||
re-downloading the source ZIP cannot fix the install and would replace
|
||
every top-level entry except ``venv`` / ``node_modules`` / ``.git`` /
|
||
``.env`` — permanently deleting uncommitted edits and untracked files.
|
||
"""
|
||
return (
|
||
isinstance(exc, subprocess.CalledProcessError)
|
||
and _m()._is_windows()
|
||
and _called_process_error_is_git(exc)
|
||
)
|
||
|
||
|
||
def _print_called_process_error_tail(
|
||
exc: subprocess.CalledProcessError, *, limit: int = 12
|
||
) -> None:
|
||
"""Print a captured stderr/stdout tail when the failing call recorded one."""
|
||
blob = exc.stderr or exc.stdout or ""
|
||
if isinstance(blob, bytes):
|
||
blob = blob.decode("utf-8", "replace")
|
||
lines = [line for line in str(blob).splitlines() if line.strip()]
|
||
if not lines:
|
||
return
|
||
print(" Last output:")
|
||
for line in lines[-limit:]:
|
||
print(f" {line}")
|
||
|
||
|
||
# Single source of truth for the top-level entries the ZIP swap preserves —
|
||
# consumed by both the dirty-tree filter below and _update_via_zip's swap loop.
|
||
|
||
|
||
# Release tags have the form v1.2.3. A tag can have a pre-release suffix.
|
||
# The stable channel ignores tags with a suffix. Stable means final releases only.
|
||
# The major component is capped at three digits. The historical CalVer tags
|
||
# (for example v2026.7.20) use a four-digit year, and a numeric sort would
|
||
# rank them above every SemVer release. This matches _SEMVER_TAG_RE in
|
||
# scripts/write_install_stamp.py.
|
||
|
||
|
||
def _invalidate_update_cache():
|
||
"""Delete the update-check cache for ALL profiles so no banner
|
||
reports a stale "commits behind" count after a successful update.
|
||
|
||
The git repo is shared across profiles — when one profile runs
|
||
``hermes update``, every profile is now current.
|
||
"""
|
||
homes = []
|
||
# Default profile home (Docker-aware — uses /opt/data in Docker)
|
||
from hermes_constants import get_default_hermes_root
|
||
|
||
default_home = get_default_hermes_root()
|
||
homes.append(default_home)
|
||
# Named profiles under <root>/profiles/
|
||
profiles_root = default_home / "profiles"
|
||
if profiles_root.is_dir():
|
||
for entry in profiles_root.iterdir():
|
||
if entry.is_dir():
|
||
homes.append(entry)
|
||
for home in homes:
|
||
try:
|
||
cache_file = home / ".update_check"
|
||
if cache_file.exists():
|
||
cache_file.unlink()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _write_marker_file(path: Path, *, label: str) -> None:
|
||
"""Drop an update-recovery breadcrumb. Never raises."""
|
||
if _m()._pytest_owns_live_checkout(path.parent):
|
||
logger.debug("Skipping %s marker under pytest (live checkout)", label)
|
||
return
|
||
try:
|
||
path.write_text(
|
||
f"started={_time.time()}\npid={os.getpid()}\n", encoding="utf-8"
|
||
)
|
||
except OSError as exc:
|
||
logger.debug("Could not write %s marker: %s", label, exc)
|
||
|
||
|
||
def _write_update_incomplete_marker() -> None:
|
||
"""Drop the interrupted core-install breadcrumb. Never raises."""
|
||
_write_marker_file(_m()._update_marker_path(), label="update-incomplete")
|
||
|
||
|
||
def _write_lazy_refresh_incomplete_marker() -> None:
|
||
"""Drop the interrupted lazy-refresh breadcrumb. Never raises."""
|
||
_write_marker_file(_m()._lazy_refresh_marker_path(), label="lazy-refresh-incomplete")
|
||
|
||
|
||
def _format_concurrent_instances_message(
|
||
matches: list[tuple[int, str]], scripts_dir: Path
|
||
) -> str:
|
||
"""Build a human-readable explanation + remediation hint for the user."""
|
||
shim = scripts_dir / "hermes.exe"
|
||
lines = ["✗ Another hermes.exe is running:"]
|
||
for pid, name in matches:
|
||
lines.append(f" PID {pid} {name}")
|
||
lines.append("")
|
||
lines.append(f" Updating now would fail to overwrite {shim} because")
|
||
lines.append(" Windows blocks REPLACE on a running executable.")
|
||
lines.append("")
|
||
lines.append(" Close Hermes Desktop, exit any open `hermes` REPLs, and")
|
||
lines.append(" stop the gateway (`hermes gateway stop`) before retrying.")
|
||
lines.append("")
|
||
if matches:
|
||
pid_args = " ".join(f"/PID {pid}" for pid, _ in matches)
|
||
lines.append(" If you've already closed everything and these PIDs are")
|
||
lines.append(" stale, terminate them directly, then retry the update:")
|
||
lines.append(f" taskkill {pid_args} /F")
|
||
lines.append("")
|
||
lines.append(" Override with `hermes update --force` if you've already")
|
||
lines.append(" confirmed those processes will not write to the venv.")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _classify_concurrent_instance(pid: int) -> str:
|
||
"""Return ``"gateway"`` when ``pid``'s command line is a gateway runtime.
|
||
|
||
Delegates to ``_is_pausable_gateway`` — the same canonical
|
||
``gateway run`` matcher (``gateway.status.looks_like_gateway_command_line``,
|
||
shlex-tokenized, profile-selector aware) used by the Desktop preflight
|
||
exemption and the venv-holder guard fallback — so a PID classified as
|
||
``"gateway"`` here is exactly the set the pause/kill+restart machinery
|
||
downstream will stop. That symmetry is what lets the pre-update
|
||
concurrent gate skip the abort for gateway-only matches: the gateway is
|
||
going to be stopped by ``_pause_windows_gateways_for_update()`` moments
|
||
later anyway, so refusing the update just to make the user kill it
|
||
manually is friction without benefit.
|
||
|
||
Returns ``"non-gateway"`` when the cmdline doesn't match, and
|
||
``"unknown"`` when psutil can't read it (process gone, access denied,
|
||
psutil missing). The gate treats ``"unknown"`` as non-gateway — we'd
|
||
rather block an update we could have completed than proceed against a
|
||
process we couldn't positively identify as a gateway.
|
||
"""
|
||
try:
|
||
import psutil # noqa: PLC0415
|
||
except Exception:
|
||
return "unknown"
|
||
|
||
try:
|
||
proc = psutil.Process(int(pid))
|
||
cmdline_list = proc.cmdline()
|
||
except Exception:
|
||
return "unknown"
|
||
|
||
from hermes_cli._scan_venv_blockers import _is_pausable_gateway # noqa: PLC0415
|
||
|
||
cmdline = " ".join(cmdline_list or [])
|
||
if _is_pausable_gateway(cmdline):
|
||
return "gateway"
|
||
return "non-gateway"
|
||
|
||
|
||
def _filter_non_gateway_concurrent_instances(
|
||
matches: list[tuple[int, str]],
|
||
) -> list[tuple[int, str]]:
|
||
"""Return only the concurrent-instance matches that are NOT the gateway.
|
||
|
||
Used by the pre-update concurrent gate to decide whether to abort
|
||
``hermes update``. If every concurrent instance is a gateway, the pause
|
||
machinery (``_pause_windows_gateways_for_update``) and the post-update
|
||
kill+restart block handle it — the update proceeds. If anything else (a
|
||
TUI shell, a Hermes Desktop backend child, an unrelated ``hermes`` REPL)
|
||
is in the list, the gate still aborts with the existing message, since
|
||
those have no pause machinery downstream.
|
||
"""
|
||
non_gateway: list[tuple[int, str]] = []
|
||
for pid, name in matches:
|
||
if _classify_concurrent_instance(pid) != "gateway":
|
||
non_gateway.append((pid, name))
|
||
return non_gateway
|
||
|
||
|
||
def _log_only_write(text: str) -> None:
|
||
"""Write ``text`` to ``~/.hermes/logs/update.log`` only, never the terminal.
|
||
|
||
During ``hermes update`` ``sys.stdout`` is an ``_UpdateOutputStream`` that
|
||
mirrors to both the terminal and ``update.log``. Loud, low-signal
|
||
subprocess output (npm installs, the Electron/vite build, the cua-driver
|
||
installer's "Next steps" wall) should be captured and tucked into the log
|
||
so failures stay debuggable, without flooding the user's terminal. This
|
||
reaches past the mirroring stream straight to the underlying log handle.
|
||
"""
|
||
if not text:
|
||
return
|
||
stream = _m().sys.stdout
|
||
log_file = getattr(stream, "_log", None)
|
||
with suppress(Exception):
|
||
if log_file is None:
|
||
log_path = get_hermes_home() / "logs" / "update.log"
|
||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||
with log_path.open("a", encoding="utf-8") as fallback:
|
||
fallback.write(text)
|
||
else:
|
||
log_file.write(text)
|
||
log_file.flush()
|
||
|
||
|
||
def _run_logged_subprocess(cmd, *, cwd=None, env=None):
|
||
"""Stream combined build output to update.log, retaining it for failure reporting."""
|
||
import codecs
|
||
import io
|
||
from hermes_cli._subprocess_compat import kill_process_tree, windows_hide_flags
|
||
|
||
child_env = dict(os.environ if env is None else env)
|
||
child_env.setdefault("PYTHONUNBUFFERED", "1")
|
||
spawn = {"creationflags": windows_hide_flags()} if os.name == "nt" else {"process_group": 0}
|
||
proc = subprocess.Popen(
|
||
cmd, cwd=cwd, env=child_env, stdin=subprocess.DEVNULL,
|
||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, **spawn)
|
||
# read1 delivers partial lines too; incremental decoding preserves split UTF-8
|
||
# and the universal-newline behavior callers previously got from text=True.
|
||
decoder = io.IncrementalNewlineDecoder(codecs.getincrementaldecoder("utf-8")("replace"), True)
|
||
output = []
|
||
try:
|
||
while True:
|
||
chunk = proc.stdout.read1(8192)
|
||
text = decoder.decode(chunk, final=not chunk)
|
||
output.append(text)
|
||
_log_only_write(text)
|
||
if not chunk:
|
||
break
|
||
return subprocess.CompletedProcess(cmd, proc.wait(), stdout="".join(output))
|
||
except BaseException:
|
||
# Unlike Popen.__exit__, do not wait for a cancelled build to finish.
|
||
kill_process_tree(proc)
|
||
with suppress(subprocess.TimeoutExpired):
|
||
proc.wait(timeout=5)
|
||
raise
|
||
finally:
|
||
proc.stdout.close()
|
||
|
||
|
||
_RELEASE_TAG_RE = re.compile(r"^v(0|[1-9]\d{0,2})\.(\d+)\.(\d+)$")
|
||
def _parse_release_tag(tag: str):
|
||
"""Parse ``vX.Y.Z`` into a sortable (X, Y, Z) tuple, or return None.
|
||
|
||
Tags with a pre-release or build suffix (``v1.2.3-rc1``) return None.
|
||
Tags that do not have the shape of a final release also return None.
|
||
The stable channel only moves between final releases.
|
||
"""
|
||
m = _RELEASE_TAG_RE.match(tag.strip())
|
||
if not m:
|
||
return None
|
||
return tuple(int(g) for g in m.groups())
|
||
def _latest_release_tag_from_ls_remote(output: str):
|
||
"""Select the newest final-release tag from ``git ls-remote --tags`` output.
|
||
|
||
Returns ``(tag, sha)`` or ``(None, None)``. Peeled entries (``^{}``) have
|
||
priority over the tag-object SHA. Thus annotated tags and lightweight tags
|
||
both give the commit SHA.
|
||
"""
|
||
best = None # (version_tuple, tag)
|
||
shas = {} # tag -> commit sha (peeled wins)
|
||
for line in output.splitlines():
|
||
parts = line.split("\t")
|
||
if len(parts) != 2:
|
||
continue
|
||
sha, ref = parts
|
||
if not ref.startswith("refs/tags/"):
|
||
continue
|
||
name = ref[len("refs/tags/"):]
|
||
peeled = name.endswith("^{}")
|
||
if peeled:
|
||
name = name[:-3]
|
||
version = _parse_release_tag(name)
|
||
if version is None:
|
||
continue
|
||
if peeled or name not in shas:
|
||
shas[name] = sha.strip()
|
||
if best is None or version > best[0]:
|
||
best = (version, name)
|
||
if best is None:
|
||
return None, None
|
||
tag = best[1]
|
||
return tag, shas.get(tag)
|
||
def _resolve_latest_release_tag(git_cmd, cwd):
|
||
"""Ask origin for the newest final release tag. Returns (tag, sha) or (None, None)."""
|
||
try:
|
||
result = subprocess.run(
|
||
git_cmd + ["ls-remote", "--tags", "origin", "v*"],
|
||
cwd=cwd,
|
||
capture_output=True,
|
||
text=True, encoding="utf-8", errors="replace",
|
||
timeout=60,
|
||
)
|
||
except (subprocess.TimeoutExpired, OSError) as exc:
|
||
logger.warning("Could not list release tags from origin: %s", exc)
|
||
return None, None
|
||
if result.returncode != 0:
|
||
logger.warning(
|
||
"git ls-remote --tags failed: %s",
|
||
(result.stderr or "").strip().splitlines()[:1],
|
||
)
|
||
return None, None
|
||
return _latest_release_tag_from_ls_remote(result.stdout)
|
||
def _stable_channel_active(args) -> bool:
|
||
"""Return True when this update must track tagged releases, not a branch.
|
||
|
||
``args`` is the update argparse namespace, or None when the caller has no
|
||
flags to honor. An explicit ``--branch`` always wins (the user names the
|
||
exact update target; a tag silently overriding it would resurrect the bug
|
||
class --branch prevents). An explicit ``--channel`` is the transient
|
||
per-invocation override (``--set-channel`` is the persistent one). In all
|
||
other cases the effective channel comes from the per-install record
|
||
(see hermes_cli.update_channel.resolve_update_channel).
|
||
"""
|
||
if getattr(args, "branch", None):
|
||
return False
|
||
transient = getattr(args, "channel", None)
|
||
if transient:
|
||
from hermes_cli.update_channel import CHANNEL_STABLE
|
||
|
||
# canary on a source tree normalizes to main (not stable).
|
||
return transient == CHANNEL_STABLE
|
||
try:
|
||
from hermes_cli.config import load_config
|
||
from hermes_cli.update_channel import CHANNEL_STABLE, resolve_update_channel
|
||
|
||
config = None
|
||
try:
|
||
config = load_config()
|
||
except Exception as exc:
|
||
logger.debug("Could not load config for channel resolution: %s", exc)
|
||
return resolve_update_channel(config, _m().PROJECT_ROOT) == CHANNEL_STABLE
|
||
except Exception as exc:
|
||
logger.warning("Channel resolution failed; defaulting to main: %s", exc)
|
||
return False
|
||
|
||
|
||
def _github_latest_release():
|
||
"""Resolve the official release tag and commit without local Git state."""
|
||
import json
|
||
import urllib.error
|
||
import urllib.request
|
||
from urllib.parse import quote
|
||
|
||
def _get_json(url):
|
||
req = urllib.request.Request(
|
||
url, headers={"Accept": "application/vnd.github+json",
|
||
"User-Agent": "hermes-update"}
|
||
)
|
||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||
return json.loads(resp.read().decode("utf-8"))
|
||
|
||
base = "https://api.github.com/repos/NousResearch/hermes-agent"
|
||
try:
|
||
data = _get_json(f"{base}/releases/latest")
|
||
tag = data.get("tag_name") if isinstance(data, dict) else None
|
||
if isinstance(tag, str) and _parse_release_tag(tag) is not None:
|
||
commit = _get_json(f"{base}/commits/{quote(tag, safe='')}")
|
||
sha = commit.get("sha") if isinstance(commit, dict) else None
|
||
return tag, sha if isinstance(sha, str) and re.fullmatch(r"[0-9a-f]{40}", sha) else None
|
||
except (urllib.error.URLError, OSError, ValueError) as exc:
|
||
logger.debug("GitHub /releases/latest unavailable: %s", exc)
|
||
try:
|
||
tags = _get_json(f"{base}/tags?per_page=100")
|
||
best = None
|
||
for entry in tags if isinstance(tags, list) else []:
|
||
name = entry.get("name") if isinstance(entry, dict) else None
|
||
version = _parse_release_tag(name) if isinstance(name, str) else None
|
||
if version is not None and (best is None or version > best[0]):
|
||
commit = entry.get("commit")
|
||
sha = commit.get("sha") if isinstance(commit, dict) else None
|
||
best = (version, name, sha)
|
||
if best:
|
||
sha = best[2]
|
||
return best[1], sha if isinstance(sha, str) and re.fullmatch(r"[0-9a-f]{40}", sha) else None
|
||
return None, None
|
||
except (urllib.error.URLError, OSError, ValueError) as exc:
|
||
logger.debug("GitHub /tags unavailable: %s", exc)
|
||
return None, None
|
||
|
||
|
||
def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
|
||
"""Implement ``hermes update --check``: fetch and report without installing.
|
||
|
||
``branch`` selects which branch the check compares against. Default is
|
||
"main"; callers can pass another branch to ask "are there new commits
|
||
on origin/<branch>?" without performing the update.
|
||
|
||
``branch_explicit`` is True iff the caller passed --branch on the CLI.
|
||
Installs that can't honor non-default branches (e.g. Docker) surface a
|
||
one-line notice instead of silently dropping the flag.
|
||
"""
|
||
# Shared admission gate (#91277 Phase 3): same marker-first decision as
|
||
# the apply path, so --check can never report git state for an install
|
||
# whose real update mechanism is an image pull.
|
||
from hermes_cli.update_contract import (
|
||
evaluate_update_admission,
|
||
record_refusal_receipt,
|
||
)
|
||
|
||
refusal = evaluate_update_admission(_m().PROJECT_ROOT)
|
||
if refusal is not None:
|
||
print(refusal.message)
|
||
record_refusal_receipt(refusal)
|
||
sys.exit(2)
|
||
|
||
git_dir = _m().PROJECT_ROOT / ".git"
|
||
if not git_dir.exists():
|
||
print("✗ Not a git repository — cannot check for updates.")
|
||
sys.exit(1)
|
||
|
||
git_cmd = ["git"]
|
||
if sys.platform == "win32":
|
||
git_cmd = ["git", "-c", "windows.appendAtomically=false"]
|
||
|
||
# A crashed/interrupted fetch can leave .git/shallow.lock (or another git
|
||
# lock file) behind; every later fetch then fails with "File exists" and
|
||
# the check reports a hard failure (or, in the banner path, silently
|
||
# compares stale refs). Self-heal abandoned locks before fetching.
|
||
from hermes_cli.gitlock import clear_stale_git_locks, clear_stale_tmp_packs
|
||
|
||
cleared = clear_stale_git_locks(_m().PROJECT_ROOT)
|
||
for lock_path in cleared:
|
||
print(f" (removed stale git lock: {lock_path})")
|
||
# Aborted fetches on flaky lines also strand tmp_pack_* debris in
|
||
# .git/objects/pack — unchecked it reached 6 GB and corrupted the pack
|
||
# dir outright (#93732). Same age+process safety contract as the locks.
|
||
swept = clear_stale_tmp_packs(_m().PROJECT_ROOT)
|
||
if swept:
|
||
print(f" (removed {len(swept)} aborted-fetch pack temp file(s))")
|
||
|
||
# Stable channel: if the caller did not ask for a branch, the question is
|
||
# "is there a newer tagged release?". The question is not "are there new
|
||
# commits on main?". Compare against the newest release tag and return.
|
||
if not branch_explicit:
|
||
if _stable_channel_active(None):
|
||
print("→ Update channel: stable (tagged releases)")
|
||
tag, tag_sha = _resolve_latest_release_tag(git_cmd, _m().PROJECT_ROOT)
|
||
if tag is None:
|
||
print("✗ No release tags found on origin. A check of the stable channel is not possible.")
|
||
print(" Switch channels with: hermes update --set-channel main")
|
||
sys.exit(1)
|
||
head_sha = _capture_head_sha(git_cmd, _m().PROJECT_ROOT)
|
||
# Newer releases possibly do not exist locally yet. "At the tag"
|
||
# is a SHA comparison. The merge-base check tells us whether HEAD
|
||
# contains the tag (HEAD is ahead of the release or at the release).
|
||
at_or_past_tag = False
|
||
if head_sha and tag_sha:
|
||
if head_sha == tag_sha:
|
||
at_or_past_tag = True
|
||
else:
|
||
contained = subprocess.run(
|
||
git_cmd + ["merge-base", "--is-ancestor", tag_sha, "HEAD"],
|
||
cwd=_m().PROJECT_ROOT,
|
||
capture_output=True,
|
||
text=True, encoding="utf-8", errors="replace",
|
||
)
|
||
at_or_past_tag = contained.returncode == 0
|
||
if at_or_past_tag:
|
||
print(f"✓ Up to date with the latest release ({tag}).")
|
||
else:
|
||
print(f"→ New release available: {tag}")
|
||
print(" Run `hermes update` to install it.")
|
||
return
|
||
|
||
# Fetch only the branch we compare against; prefer upstream as the canonical
|
||
# reference. A bare `git fetch <remote>` pulls every ref, and this repo has
|
||
# thousands of auto-generated branches, so scope the fetch to <branch>.
|
||
# Note: upstream/<branch> may not exist for non-main branches (a fork's
|
||
# bb/gui has no upstream counterpart), so when the caller picks a
|
||
# non-default branch we skip the upstream probe and use origin directly.
|
||
# Installer checkouts are shallow (`git clone --depth 1`). A plain
|
||
# `git fetch` would unshallow the repo (dragging in the whole history —
|
||
# the exact cost the shallow clone avoided) and the rev-list count below
|
||
# would then report a huge bogus "behind" number. Detect shallow up front:
|
||
# fetch with --depth 1 to preserve the boundary and report presence-only.
|
||
is_shallow = (
|
||
subprocess.run(
|
||
git_cmd + ["rev-parse", "--is-shallow-repository"],
|
||
cwd=_m().PROJECT_ROOT,
|
||
capture_output=True,
|
||
text=True, encoding="utf-8", errors="replace",
|
||
).stdout.strip()
|
||
== "true"
|
||
)
|
||
depth_args = ["--depth", "1"] if is_shallow else []
|
||
|
||
if branch == "main":
|
||
# Probe locally (~6 ms) whether an 'upstream' remote exists at all
|
||
# before spending a network fetch on it. Non-fork installs have no
|
||
# 'upstream' remote, and the old flow burned a failed network attempt
|
||
# (~0.3-1 s) on every --check before falling back to origin.
|
||
has_upstream_remote = (
|
||
subprocess.run(
|
||
git_cmd + ["remote", "get-url", "upstream"],
|
||
cwd=_m().PROJECT_ROOT,
|
||
capture_output=True,
|
||
text=True, encoding="utf-8", errors="replace",
|
||
).returncode
|
||
== 0
|
||
)
|
||
fetch_result = None
|
||
if has_upstream_remote:
|
||
print("→ Fetching from upstream...")
|
||
fetch_result = subprocess.run(
|
||
git_cmd + ["fetch"] + depth_args + ["upstream", branch],
|
||
cwd=_m().PROJECT_ROOT,
|
||
capture_output=True,
|
||
text=True, encoding="utf-8", errors="replace",
|
||
**_no_prompt_git_kwargs(),
|
||
)
|
||
if fetch_result is not None and fetch_result.returncode == 0:
|
||
upstream_exists = True
|
||
compare_branch = f"upstream/{branch}"
|
||
else:
|
||
# No upstream remote, or the upstream fetch failed — use origin.
|
||
print("→ Fetching from origin...")
|
||
fetch_result = subprocess.run(
|
||
git_cmd + ["fetch"] + depth_args + ["origin", branch],
|
||
cwd=_m().PROJECT_ROOT,
|
||
capture_output=True,
|
||
text=True, encoding="utf-8", errors="replace",
|
||
**_no_prompt_git_kwargs(),
|
||
)
|
||
upstream_exists = False
|
||
compare_branch = f"origin/{branch}"
|
||
else:
|
||
# Non-default branch: compare against origin/<branch> directly.
|
||
print("→ Fetching from origin...")
|
||
fetch_result = subprocess.run(
|
||
git_cmd + ["fetch"] + depth_args + ["origin", branch],
|
||
cwd=_m().PROJECT_ROOT,
|
||
capture_output=True,
|
||
text=True, encoding="utf-8", errors="replace",
|
||
**_no_prompt_git_kwargs(),
|
||
)
|
||
upstream_exists = False
|
||
compare_branch = f"origin/{branch}"
|
||
|
||
if fetch_result.returncode != 0:
|
||
_print_fetch_failure(fetch_result.stderr)
|
||
sys.exit(1)
|
||
|
||
if is_shallow:
|
||
# The depth-1 fetch above leaves the previous tip behind as a ``.git/shallow`` graft
|
||
# (git never removes old grafts); prune the stale ones so the file stops growing and
|
||
# merge-base / the orphan-divergence heuristic keep working (#105951).
|
||
from hermes_cli.gitlock import prune_stale_shallow_grafts
|
||
pruned = prune_stale_shallow_grafts(_m().PROJECT_ROOT)
|
||
if pruned:
|
||
print(f" (pruned {pruned} stale shallow graft(s) left by past depth-1 checks)")
|
||
|
||
# Verify the compare ref actually exists before asking rev-list about it.
|
||
# Without this, `git rev-list HEAD..origin/<bogus> --count` exits 128 and
|
||
# (with check=True) raises CalledProcessError, surfacing a Python
|
||
# traceback. Friendlier to detect-and-report.
|
||
verify_result = subprocess.run(
|
||
git_cmd + ["rev-parse", "--verify", "--quiet", compare_branch],
|
||
cwd=_m().PROJECT_ROOT,
|
||
capture_output=True,
|
||
text=True, encoding="utf-8", errors="replace",
|
||
)
|
||
if verify_result.returncode != 0:
|
||
print(f"✗ Branch '{branch}' not found on {compare_branch.split('/', 1)[0]}.")
|
||
sys.exit(1)
|
||
|
||
if is_shallow:
|
||
# No history to count across the shallow boundary. Compare tip SHAs
|
||
# (mirrors the banner's _check_via_local_git), then try to recover the
|
||
# exact count via the GitHub compare API — the remote graph is complete
|
||
# even when the local one is truncated.
|
||
head_sha = subprocess.run(
|
||
git_cmd + ["rev-parse", "HEAD"],
|
||
cwd=_m().PROJECT_ROOT, capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||
).stdout.strip()
|
||
target_sha = subprocess.run(
|
||
git_cmd + ["rev-parse", compare_branch],
|
||
cwd=_m().PROJECT_ROOT, capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||
).stdout.strip()
|
||
if head_sha and target_sha and head_sha == target_sha:
|
||
print("✓ Already up to date.")
|
||
else:
|
||
from hermes_cli.banner import _github_compare_behind
|
||
from hermes_cli.config import recommended_update_command
|
||
|
||
counted = _github_compare_behind(head_sha, target_sha)
|
||
if counted == 0:
|
||
# Local commits on top of the remote tip — not behind.
|
||
print("✓ Already up to date.")
|
||
return
|
||
if counted is not None:
|
||
commits_word = "commit" if counted == 1 else "commits"
|
||
print(f"⚕ Update available: {counted} {commits_word} behind {compare_branch}.")
|
||
else:
|
||
print(f"⚕ Update available (behind {compare_branch}).")
|
||
print(f" Run '{recommended_update_command()}' to install.")
|
||
return
|
||
|
||
rev_result = subprocess.run(
|
||
git_cmd + ["rev-list", f"HEAD..{compare_branch}", "--count"],
|
||
cwd=_m().PROJECT_ROOT,
|
||
capture_output=True,
|
||
text=True, encoding="utf-8", errors="replace",
|
||
check=True,
|
||
)
|
||
behind = int(rev_result.stdout.strip())
|
||
|
||
if behind == 0:
|
||
print("✓ Already up to date.")
|
||
else:
|
||
commits_word = "commit" if behind == 1 else "commits"
|
||
print(f"⚕ Update available: {behind} {commits_word} behind {compare_branch}.")
|
||
from hermes_cli.config import recommended_update_command
|
||
|
||
print(f" Run '{recommended_update_command()}' to install.")
|
||
|
||
|
||
def _base_git_cmd() -> list[str]:
|
||
"""``git`` argv; Windows adds ``-c windows.appendAtomically=false`` (git can fail "unable to
|
||
write loose object file: Invalid argument" on non-atomic appends)."""
|
||
if sys.platform == "win32":
|
||
return ["git", "-c", "windows.appendAtomically=false"]
|
||
return ["git"]
|
||
|
||
|
||
def _is_shallow_checkout(git_cmd) -> bool:
|
||
return _git_run(git_cmd, ["rev-parse", "--is-shallow-repository"]).stdout.strip() == "true"
|
||
|
||
|
||
def _tip_shas(git_cmd, target_ref: str) -> tuple[str, str]:
|
||
"""``(HEAD sha, <target_ref> sha)`` as printed by rev-parse ("" when unresolvable)."""
|
||
return tuple(_git_run(git_cmd, ["rev-parse", ref]).stdout.strip() for ref in ("HEAD", target_ref))
|
||
|
||
|
||
def _print_update_check_result(behind: int | None, compare_branch: str) -> None:
|
||
"""Report ``--check``'s verdict: up to date, N commits behind, or behind by an unknown count."""
|
||
if behind == 0:
|
||
print("✓ Already up to date.")
|
||
return
|
||
if behind is not None:
|
||
print(f"⚕ Update available: {behind} {'commit' if behind == 1 else 'commits'} behind {compare_branch}.")
|
||
else:
|
||
print(f"⚕ Update available (behind {compare_branch}).")
|
||
from hermes_cli.config import recommended_update_command
|
||
print(f" Run '{recommended_update_command()}' to install.")
|
||
|
||
|
||
def _repair_venv_on_current_checkout(
|
||
*, assume_yes, gateway_mode, pre_update_snapshot_id, desktop_dir,
|
||
had_desktop_app_before_update, active_lazy_features, active_tool_dependencies,
|
||
_windows_gateway_resume) -> bool:
|
||
"""Stage a replacement dependency environment; keep the marker on failure."""
|
||
_write_update_incomplete_marker()
|
||
import pm
|
||
|
||
try:
|
||
# A matching stamp cannot certify missing files. Restore the recorded
|
||
# graph first, then refresh it against the current checkout's inputs.
|
||
pm.sync_venv(repair=True)
|
||
pm.sync_venv(["all"] + list(active_lazy_features or []), explicit=True)
|
||
except (pm.InstallError, OSError, ValueError) as _sync_err:
|
||
print(f" ✗ {_sync_err}")
|
||
return False
|
||
healthy_after, detail_after = _venv_core_imports_healthy()
|
||
if not healthy_after:
|
||
print(f"⚠ Venv still unhealthy after repair: {detail_after}")
|
||
print(" Close all Hermes windows/gateways and re-run: hermes update")
|
||
return False
|
||
_m()._clear_update_incomplete_marker()
|
||
print("✓ Dependencies repaired!")
|
||
# Check for config migrations (#91360).
|
||
_check_and_apply_config_migration(
|
||
assume_yes=assume_yes, gateway_mode=gateway_mode,
|
||
pre_update_snapshot_id=pre_update_snapshot_id)
|
||
# The Windows hand-off child lands here after doing the sync its parent could not, and
|
||
# the commits-pulled rebuild is never reached — rebuild the Desktop app here or it
|
||
# silently stays on the old build (#97343).
|
||
if _rebuild_desktop_after_update(
|
||
desktop_dir, had_desktop_app_before_update=had_desktop_app_before_update):
|
||
return _print_verified_update_completion("✓ Update complete!")
|
||
_print_update_completion(
|
||
"⚠ Update partially complete — the desktop app was not rebuilt and is still on the previous build.")
|
||
return False
|
||
|
||
|
||
def _repair_current_checkout(
|
||
*, assume_yes, gateway_mode, pre_update_snapshot_id, desktop_dir,
|
||
had_desktop_app_before_update, active_lazy_features, active_tool_dependencies,
|
||
upstream_checked, _windows_gateway_resume) -> bool:
|
||
"""Already-up-to-date path: keep the managed runtime current, repair a broken venv.
|
||
Returns whether the checkout can be reported complete."""
|
||
# "No new commits" does not mean the venv is safe: pm owns the uv pin now — a pin bump
|
||
# realizes a NEW entry on the next ensure. The update command is the one caller that
|
||
# must SEE realization failures, so use the raising API, not the None-swallowing uv().
|
||
import pm
|
||
|
||
try:
|
||
pm.ensure("uv")
|
||
except pm.InstallError as e:
|
||
print(f"⚠ Managed uv unavailable: {e}")
|
||
|
||
# A current checkout does NOT imply a healthy install: a previous dependency sync may
|
||
# have failed partway (classic on Windows: a running gateway/desktop backend keeps .pyd
|
||
# locked and the installer dies with access-denied, stranding the venv between
|
||
# versions). Probe the venv's core imports and repair if broken — otherwise "Already up
|
||
# to date!" gaslights the user while their install stays bricked.
|
||
healthy, detail = _venv_core_imports_healthy()
|
||
# The Windows shim hand-off spawns this child precisely to run a sync its parent could
|
||
# not. The parent already pulled, so the checkout is current BY DESIGN and venv health
|
||
# is not the question — the pending sync is.
|
||
handed_off_sync = os.environ.get(_m()._UPDATE_REEXEC_ENV) == "1"
|
||
if handed_off_sync:
|
||
print("→ Finishing the dependency install handed off by hermes.exe...")
|
||
elif not healthy:
|
||
print("⚠ Checkout is current, but the venv is unhealthy:")
|
||
print(f" {detail}")
|
||
print("→ Repairing Python dependencies...")
|
||
if handed_off_sync or not healthy:
|
||
return _repair_venv_on_current_checkout(
|
||
assume_yes=assume_yes, gateway_mode=gateway_mode,
|
||
pre_update_snapshot_id=pre_update_snapshot_id, desktop_dir=desktop_dir,
|
||
had_desktop_app_before_update=had_desktop_app_before_update,
|
||
active_lazy_features=active_lazy_features,
|
||
active_tool_dependencies=active_tool_dependencies,
|
||
_windows_gateway_resume=_windows_gateway_resume)
|
||
return _repair_node_deps_on_current_checkout(
|
||
_print_verified_update_completion, assume_yes=assume_yes, gateway_mode=gateway_mode,
|
||
pre_update_snapshot_id=pre_update_snapshot_id,
|
||
completion_message=(
|
||
"✓ Already up to date!" if upstream_checked
|
||
else "✓ Up to date with your fork (official repo not checked)."),
|
||
had_desktop_app_before_update=had_desktop_app_before_update)
|
||
|
||
|
||
def _reconcile_diverged_checkout(git_cmd, branch: str, pre_pull_sha, *, target_ref=None) -> None:
|
||
"""Fast-forward failed: merge on a custom branch (local commits survive) or reset --hard on the
|
||
same branch (rescue ref first when histories share no ancestor). ``sys.exit(1)`` on failure."""
|
||
# A custom branch (local commits atop origin/<branch>) also can't ff, and reset --hard
|
||
# would discard that work: merge instead, stop on conflict.
|
||
merge_ref = target_ref if target_ref is not None else f"origin/{branch}"
|
||
_cur_branch = (_git_run(git_cmd, ["branch", "--show-current"]).stdout or "").strip()
|
||
if _cur_branch and _cur_branch != branch:
|
||
print(
|
||
f" ⚠ Checkout is on custom branch '{_cur_branch}' — "
|
||
f"merging origin/{branch} instead of resetting so local commits survive...")
|
||
# Best-effort safety tag as a recovery anchor.
|
||
_git_run(git_cmd, ["tag", f"pre-update-{_time.strftime('%Y%m%d-%H%M%S')}"])
|
||
if _git_run(git_cmd, ["merge", "--no-edit", merge_ref]).returncode != 0:
|
||
_git_run(git_cmd, ["merge", "--abort"])
|
||
print("✗ Merge conflict between local commits and upstream — update stopped, nothing was changed.")
|
||
print(f" Resolve manually: cd {_m().PROJECT_ROOT} && git merge origin/{branch}")
|
||
print(" Then re-run the update. Local work is untouched.")
|
||
sys.exit(1)
|
||
return
|
||
# Same branch: a true upstream force-push/rebase; local changes are stashed, so reset.
|
||
# Orphan divergence (no common ancestor: corrupted HEAD, re-init) would lose the whole
|
||
# local graph, so park pre_pull_sha behind a rescue ref first.
|
||
merge_base_result = _git_run(git_cmd, ["merge-base", "HEAD", merge_ref])
|
||
has_common_ancestor = merge_base_result.returncode == 0 and merge_base_result.stdout.strip()
|
||
if not has_common_ancestor and pre_pull_sha:
|
||
from datetime import datetime as _dt, timezone
|
||
# SHA suffix so two updates in the same second get distinct refs.
|
||
rescue_ref = (
|
||
f"refs/hermes-update-backups/orphan-{branch}-"
|
||
f"{_dt.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{pre_pull_sha[:12]}")
|
||
head = f" ⚠ Local history shares no common ancestor with origin/{branch} (orphan divergence) — "
|
||
if _git_run(git_cmd, ["update-ref", rescue_ref, pre_pull_sha]).returncode == 0:
|
||
print(
|
||
f"{head}backed up current HEAD to {rescue_ref} before resetting. "
|
||
f"This backup expires after {_ORPHAN_RESCUE_REF_MAX_AGE_DAYS} days.")
|
||
else:
|
||
# update-ref failure is intentionally non-fatal, but never claim a backup exists.
|
||
print(
|
||
f"{head}attempted to back up current HEAD to {rescue_ref} before resetting, "
|
||
f"but the backup write failed (pre-reset SHA was {pre_pull_sha}).")
|
||
_prune_orphan_rescue_refs(git_cmd, _m().PROJECT_ROOT, branch)
|
||
print(" ⚠ Fast-forward not possible (history diverged), resetting to match remote...")
|
||
reset_result = _git_run(git_cmd, ["reset", "--hard", merge_ref])
|
||
if reset_result.returncode != 0:
|
||
print(f"✗ Failed to reset to origin/{branch}.")
|
||
if reset_result.stderr.strip():
|
||
print(f" {reset_result.stderr.strip()}")
|
||
print(f" Try manually: git fetch origin && git reset --hard origin/{branch}")
|
||
sys.exit(1)
|
||
|
||
|
||
def _rollback_if_pulled_syntax_error(git_cmd, pre_pull_sha) -> None:
|
||
"""Post-pull syntax guard: roll back to *pre_pull_sha* and ``sys.exit(1)`` when a critical
|
||
file no longer compiles (a bad admin-merge past CI must not brick the CLI)."""
|
||
syntax_ok, failing_path, syntax_error = _validate_critical_files_syntax(_m().PROJECT_ROOT)
|
||
if syntax_ok:
|
||
return
|
||
print()
|
||
print("✗ Pulled code has a syntax error in a critical file:")
|
||
print(f" {failing_path}")
|
||
# py_compile errors can be multi-line; show enough for the SyntaxError text.
|
||
for line in str(syntax_error).splitlines()[:6] if syntax_error else ():
|
||
print(f" {line}")
|
||
print()
|
||
if pre_pull_sha:
|
||
print(f"→ Rolling back to {pre_pull_sha[:10]}...")
|
||
rollback_result = _git_run(git_cmd, ["reset", "--hard", pre_pull_sha])
|
||
if rollback_result.returncode == 0:
|
||
print(" ✓ Rollback complete — your install is unchanged.")
|
||
print(" Try ``hermes update`` again later once a fix lands.")
|
||
else:
|
||
print(" ✗ Rollback failed. Recover manually with:")
|
||
print(f" cd {_m().PROJECT_ROOT} && git reset --hard {pre_pull_sha}")
|
||
if rollback_result.stderr.strip():
|
||
print(f" ({rollback_result.stderr.strip().splitlines()[0]})")
|
||
else:
|
||
print(" Could not capture pre-pull SHA — recover manually with:")
|
||
print(f" cd {_m().PROJECT_ROOT} && git reflog && git reset --hard <prev-sha>")
|
||
sys.exit(1)
|
||
|
||
|
||
def _pull_updates(
|
||
git_cmd, branch, auto_stash_ref, *, prompt_for_restore, gw_input_fn, discard_local_changes,
|
||
keep_stash, target_ref=None):
|
||
"""Fast-forward onto ``origin/<branch>`` and settle the autostash. Divergence by shape:
|
||
custom branch -> merge, same branch -> reset, orphan history -> rescue ref first; a
|
||
post-pull syntax error in a critical file rolls back. Exits on failure; returns pre-pull SHA."""
|
||
update_succeeded = False
|
||
# Pre-pull SHA for auto-rollback (stray conflict markers once bricked every updater).
|
||
# Capture the pre-pull SHA so we can auto-roll-back if the new code has a syntax error in a
|
||
# critical-path file (PR #28452 incident: orphan merge-conflict markers in hermes_cli/config.py bricked
|
||
# every user who ran ``hermes update`` for the 7 minutes between the bad commit and the fix landing).
|
||
pre_pull_sha = _capture_head_sha(git_cmd, _m().PROJECT_ROOT)
|
||
try:
|
||
# merge --ff-only the already-fetched ref instead of `git pull`, which would do a
|
||
# SECOND network fetch; identical in effect given the fresh tracking ref.
|
||
merge_ref = target_ref if target_ref is not None else f"origin/{branch}"
|
||
if _git_run(git_cmd, ["merge", "--ff-only", merge_ref]).returncode != 0:
|
||
_reconcile_diverged_checkout(git_cmd, branch, pre_pull_sha, target_ref=merge_ref)
|
||
_rollback_if_pulled_syntax_error(git_cmd, pre_pull_sha)
|
||
update_succeeded = True
|
||
finally:
|
||
if auto_stash_ref is not None:
|
||
# No stash restore if the update failed — tree state is unknown.
|
||
if not update_succeeded:
|
||
print(f" ℹ️ Local changes preserved in stash (ref: {auto_stash_ref})")
|
||
print(" Restore manually with: git stash apply")
|
||
elif discard_local_changes:
|
||
# Non-interactive + updates.non_interactive_local_changes: discard.
|
||
_m()._discard_stashed_changes(git_cmd, _m().PROJECT_ROOT, auto_stash_ref)
|
||
elif keep_stash:
|
||
# --keep-stash (desktop updater): leave edits parked rather than re-apply silently.
|
||
_m()._park_stashed_changes(auto_stash_ref)
|
||
else:
|
||
_m()._restore_stashed_changes(
|
||
git_cmd, _m().PROJECT_ROOT, auto_stash_ref, prompt_user=prompt_for_restore,
|
||
input_fn=gw_input_fn)
|
||
return pre_pull_sha
|
||
|
||
|
||
@dataclass
|
||
class _CheckoutPlan:
|
||
"""What the pre-pull checkout phase decided (see ``_prepare_checkout_for_update``)."""
|
||
|
||
auto_stash_ref: "str | None"
|
||
commit_count: int
|
||
in_place_update: bool
|
||
parked_branch_switched: bool
|
||
prompt_for_restore: bool
|
||
switch_block_reason: "str | None"
|
||
upstream_checked: bool
|
||
|
||
|
||
def _apply_parked_branch_guard(
|
||
git_cmd, branch, current_branch, *, switch_branch, _windows_gateway_resume
|
||
) -> tuple[bool, bool, "str | None"]:
|
||
"""Decide how a checkout parked on another branch is brought to *branch* (stash-switch-pull-
|
||
switch-back used to "update" main while the running code stayed behind).
|
||
|
||
By branch contents + updates.parked_branch_strategy: fully merged -> switch back;
|
||
unmerged -> "switch" (default; loud "kept" notice) or "update_in_place" (merge origin/<target>
|
||
INTO the branch, checkout never moves; --switch-branch overrides once); dirty/unverifiable ->
|
||
touch nothing, warn, ``sys.exit(1)`` with the code update SKIPPED (also when the target is
|
||
missing). Returns ``(parked_branch_switched, in_place_update, switch_block_reason)``.
|
||
"""
|
||
if current_branch == branch or current_branch == "HEAD":
|
||
return False, False, None
|
||
switch_safe, switch_block_reason = _m()._assess_parked_branch_switch(
|
||
git_cmd, _m().PROJECT_ROOT, current_branch, branch)
|
||
if not switch_safe:
|
||
_m()._print_parked_branch_skip_warning(
|
||
git_cmd, _m().PROJECT_ROOT, current_branch, branch, switch_block_reason)
|
||
print()
|
||
print(f"⚠ Update finished — code update SKIPPED{_branch_head_suffix(git_cmd, _m().PROJECT_ROOT)}")
|
||
_m()._resume_windows_gateways_after_update(_windows_gateway_resume)
|
||
sys.exit(1)
|
||
if not switch_block_reason.startswith("unmerged:"):
|
||
print(f" ⚠ Checkout was parked on '{current_branch}' (fully merged) — switching back to {branch}...")
|
||
return True, False, switch_block_reason
|
||
_in_place_configured = False
|
||
with _best_effort('Could not read updates.parked_branch_strategy: %s'):
|
||
_in_place_configured = (
|
||
_updates_config().get("parked_branch_strategy", "switch") == "update_in_place")
|
||
if not _in_place_configured or switch_branch:
|
||
_m()._print_parked_branch_kept_notice(
|
||
current_branch, branch, switch_block_reason.split(":", 1)[1])
|
||
return True, False, switch_block_reason
|
||
# --branch typos used to surface via the checkout failing, which this path skips.
|
||
if _git_run(git_cmd, ["rev-parse", "--verify", "--quiet", f"origin/{branch}"]).returncode != 0:
|
||
print(f"✗ Branch '{branch}' does not exist locally or on origin.")
|
||
sys.exit(1)
|
||
print(
|
||
f" ℹ On branch '{current_branch}' — updating it in place from "
|
||
f"origin/{branch} (no branch switch; local commits preserved).")
|
||
return False, True, switch_block_reason
|
||
|
||
|
||
def _prepare_checkout_for_update(
|
||
git_cmd, branch, current_branch, *, is_fork, assume_yes, gateway_mode, gw_input_fn,
|
||
switch_branch, target_ref=None, _windows_gateway_resume):
|
||
"""Parked-branch guard, land on the target, stash, count new commits. Exits when the
|
||
checkout is unsafe to move or the target is missing. ``commit_count`` is 0 when up to
|
||
date, -1 when tips differ but the shallow count is unrecoverable."""
|
||
if target_ref is None:
|
||
target_ref = f"origin/{branch}"
|
||
stable_tag = target_ref != f"origin/{branch}"
|
||
if stable_tag:
|
||
# Stable channel: the checkout does NOT switch branches — the current branch
|
||
# pointer fast-forwards (or merges) to the release tag, so the parked-branch
|
||
# machinery is main-channel only.
|
||
parked_branch_switched, in_place_update, switch_block_reason = False, True, None
|
||
else:
|
||
parked_branch_switched, in_place_update, switch_block_reason = _apply_parked_branch_guard(
|
||
git_cmd, branch, current_branch, switch_branch=switch_branch,
|
||
_windows_gateway_resume=_windows_gateway_resume)
|
||
|
||
if not stable_tag and not in_place_update and current_branch == "HEAD" != branch:
|
||
print(f" ⚠ Currently on detached HEAD — switching to {branch} for update...")
|
||
auto_stash_ref = _m()._stash_local_changes_if_needed(git_cmd, _m().PROJECT_ROOT)
|
||
if (
|
||
not stable_tag and not in_place_update and current_branch != branch
|
||
and _git_run(git_cmd, ["checkout", branch]).returncode != 0):
|
||
track_result = _git_run(git_cmd, ["checkout", "-B", branch, f"origin/{branch}"])
|
||
if track_result.returncode != 0:
|
||
# Restore the stash before bailing so the user isn't stranded.
|
||
if auto_stash_ref is not None:
|
||
_m()._restore_stashed_changes(
|
||
git_cmd, _m().PROJECT_ROOT, auto_stash_ref, prompt_user=False, input_fn=gw_input_fn)
|
||
print(f"✗ Branch '{branch}' does not exist locally or on origin.")
|
||
if track_result.stderr.strip():
|
||
print(f" {track_result.stderr.strip().splitlines()[0]}")
|
||
sys.exit(1)
|
||
|
||
prompt_for_restore = (
|
||
auto_stash_ref is not None
|
||
and not assume_yes
|
||
and (gateway_mode or (sys.stdin.isatty() and sys.stdout.isatty())))
|
||
|
||
# On shallow checkouts `rev-list --count` can report the entire remote ancestry. The
|
||
# zero/nonzero gate is still sound; treat the shallow NUMBER as unknown and recover it
|
||
# via the GitHub compare API when possible.
|
||
result = _git_run(git_cmd, ["rev-list", f"HEAD..{target_ref}", "--count"], check=True)
|
||
commit_count = int(result.stdout.strip())
|
||
|
||
apply_is_shallow = _is_shallow_checkout(git_cmd)
|
||
if commit_count > 0 and apply_is_shallow:
|
||
from hermes_cli.banner import _github_compare_behind
|
||
counted = _github_compare_behind(*_tip_shas(git_cmd, target_ref))
|
||
# counted == 0 means local-ahead: falls through to the up-to-date path.
|
||
commit_count = counted if counted is not None else -1
|
||
|
||
# A fork can match origin yet trail upstream, so the sync can move HEAD with
|
||
# commit_count == 0; detect that BEFORE the no-update return so deps, restarts AND the
|
||
# fleet matrix still run (it used to live in the early-return branch and verified nothing).
|
||
# The sync can therefore advance HEAD even though the origin comparison found no commits. Detect that
|
||
# BEFORE taking the no-update return so dependency refreshes, gateway restarts, AND the fleet version
|
||
# matrix still run for the pulled code (#73108 — previously the sync lived inside the commit_count == 0
|
||
# branch, which returns immediately after: an update that pulled hundreds of upstream commits printed
|
||
# "Already up to date!" and verified nothing). Non-fork checkouts have no upstream question: origin IS
|
||
# the official repo, so "Already up to date!" is fully verified there.
|
||
upstream_checked = True
|
||
if commit_count == 0 and is_fork and branch == "main" and not stable_tag:
|
||
pre_sync_sha = _capture_head_sha(git_cmd, _m().PROJECT_ROOT)
|
||
upstream_checked = _m()._sync_with_upstream_if_needed(
|
||
git_cmd, _m().PROJECT_ROOT, assume_yes=assume_yes, input_fn=gw_input_fn)
|
||
post_sync_sha = _capture_head_sha(git_cmd, _m().PROJECT_ROOT)
|
||
if pre_sync_sha and post_sync_sha and pre_sync_sha != post_sync_sha:
|
||
synced_count = _count_commits_between(
|
||
git_cmd, _m().PROJECT_ROOT, pre_sync_sha, post_sync_sha)
|
||
# HEAD moving is proof of an update even if the count can't be read.
|
||
commit_count = max(1, synced_count)
|
||
|
||
return _CheckoutPlan(
|
||
auto_stash_ref=auto_stash_ref, commit_count=commit_count, in_place_update=in_place_update,
|
||
parked_branch_switched=parked_branch_switched, prompt_for_restore=prompt_for_restore,
|
||
switch_block_reason=switch_block_reason, upstream_checked=upstream_checked)
|
||
|
||
|
||
@dataclass
|
||
class _UpdateOptions:
|
||
"""Resolved ``hermes update`` inputs (flags, config, pre-update snapshots)."""
|
||
|
||
active_lazy_features: object
|
||
active_tool_dependencies: object
|
||
pre_update_version: object
|
||
gw_input_fn: object
|
||
assume_yes: bool
|
||
keep_stash: bool
|
||
switch_branch: bool
|
||
discard_local_changes: bool
|
||
|
||
|
||
def _resolve_update_options(args, gateway_mode: bool) -> _UpdateOptions:
|
||
"""Snapshot pre-update state and resolve the flags/config ``_cmd_update_impl`` runs on."""
|
||
# Snapshot before a managed-runtime refresh can replace site-packages, while the old
|
||
# environment can still prove which optional backends were active.
|
||
active_lazy_features = _m()._capture_active_lazy_features()
|
||
active_tool_dependencies = _m()._capture_active_tool_dependencies()
|
||
|
||
# Captured before any pull so the completion line can report the transition.
|
||
# Snapshot the pre-update version before files are replaced so the completion line can report the
|
||
# transition (prime-agent#630 port).
|
||
# Snapshot the pre-update version before any code is pulled so the completion line can report the
|
||
# transition (prime-agent#630 port).
|
||
pre_update_version = _read_project_version()
|
||
gw_input_fn = (
|
||
(lambda prompt, default="": _gateway_prompt(prompt, default)) if gateway_mode else None)
|
||
assume_yes = bool(getattr(args, "yes", False))
|
||
# --keep-stash (desktop updater): never re-apply the autostash; only when an update
|
||
# landed — abort/no-op paths still restore since the tree is unchanged.
|
||
keep_stash = bool(getattr(args, "keep_stash", False))
|
||
# --switch-branch: prefer switching over an in-place merge so an update never writes the
|
||
# branch's history; only meaningful with parked_branch_strategy "update_in_place".
|
||
# See #89507.
|
||
switch_branch = bool(getattr(args, "switch_branch", False))
|
||
|
||
# Interactive terminals always stash-and-ask; only non-interactive updates consult
|
||
# updates.non_interactive_local_changes (auto-restore vs discard).
|
||
discard_local_changes = False
|
||
if gateway_mode or assume_yes or not (sys.stdin.isatty() and sys.stdout.isatty()):
|
||
# A config read failure must never change the safe default.
|
||
with _best_effort("Could not read updates.non_interactive_local_changes: %s"):
|
||
_mode = str(_updates_config().get("non_interactive_local_changes", "stash")).lower()
|
||
discard_local_changes = _mode == "discard"
|
||
return _UpdateOptions(
|
||
active_lazy_features=active_lazy_features,
|
||
active_tool_dependencies=active_tool_dependencies, pre_update_version=pre_update_version,
|
||
gw_input_fn=gw_input_fn, assume_yes=assume_yes, keep_stash=keep_stash,
|
||
switch_branch=switch_branch, discard_local_changes=discard_local_changes)
|
||
|
||
|
||
def _begin_update_receipt_and_plan(args):
|
||
"""Open the receipt, snapshot the fleet, refuse on Windows shim holders. Returns the
|
||
pre-update plan (None if the probe failed); ``sys.exit(2)`` when a non-gateway hermes.exe
|
||
holds the venv shim."""
|
||
# Structured receipt: record what this run discovers/does/skips so silent failures are diagnosable.
|
||
with _best_effort('Update receipt unavailable: %s'):
|
||
# See #74973, #81193, #85753, #88848, #91277.
|
||
from hermes_cli.update_receipt import begin_update_receipt
|
||
begin_update_receipt()
|
||
|
||
# Plan phase: snapshot runtimes/supervisors/version (read-only; probe failure records
|
||
# nothing). Re-read AFTER the restart phase to reconcile — the plan is the worklist.
|
||
# Plan phase (#91277 Phase 2): snapshot the pre-update fleet — every running Hermes runtime, its
|
||
# supervisor, and its running code version — into the receipt, so a post-mortem can compare what the
|
||
# update SAW against what it did. ``_pre_update_plan`` is read again AFTER the restart phase to
|
||
# reconcile every planned runtime against the phase's bookkeeping (restart via declared mechanism — the
|
||
# plan is the worklist, not just a printout).
|
||
_pre_update_plan = None
|
||
with _best_effort('Update plan phase failed: %s'):
|
||
from hermes_cli.update_inventory import collect_runtime_inventory, record_plan_in_receipt
|
||
_pre_update_plan = collect_runtime_inventory()
|
||
record_plan_in_receipt(_pre_update_plan)
|
||
if _pre_update_plan.runtimes:
|
||
_n = len(_pre_update_plan.runtimes)
|
||
_profiles = ", ".join(sorted({r.profile for r in _pre_update_plan.runtimes}))
|
||
print(f"→ Fleet: {_n} running service(s) across profiles: {_profiles}")
|
||
|
||
# Windows: another hermes.exe holding the venv shim means WinError 32 spam and a
|
||
# deferred-rename leftover or silent ZIP fallback. Positively identified gateways are
|
||
# paused/restarted by the update instead; anything else still aborts.
|
||
# Continuing would result in a string of WinError 32 warnings and then either a deferred-rename leftover
|
||
# or a failed git-pull fast path that silently falls back to the slower ZIP route. See issue #26670.
|
||
# Exception (#37039): when every concurrent instance is a gateway runtime, the pause machinery a few
|
||
# lines below (``_pause_windows_gateways_for_update``) stops it before any file mutation, and the
|
||
# post-update restart phase brings it back. Aborting just to make the user run the same kill manually is
|
||
# friction without benefit. Anything not positively identified as a gateway (TUI shell, Desktop backend
|
||
# child, unreadable cmdline) still aborts exactly as before.
|
||
if _m()._is_windows() and not getattr(args, "force", False):
|
||
scripts_dir = _m()._venv_scripts_dir()
|
||
concurrent = _m()._detect_concurrent_hermes_instances(scripts_dir) if scripts_dir is not None else []
|
||
non_gateway = _m()._filter_non_gateway_concurrent_instances(concurrent) if concurrent else []
|
||
if non_gateway:
|
||
print(_format_concurrent_instances_message(non_gateway, scripts_dir))
|
||
sys.exit(2)
|
||
return _pre_update_plan
|
||
|
||
|
||
def _prepare_git_command() -> tuple[bool, list, bool]:
|
||
"""Return ``(use_zip_update, git_cmd, is_fork)``; ``sys.exit(1)`` when not a git repo
|
||
on a non-Windows host (Windows falls back to ZIP: broken git file I/O, AV, NTFS filters)."""
|
||
git_dir = _m().PROJECT_ROOT / ".git"
|
||
use_zip_update = not git_dir.exists()
|
||
if use_zip_update and sys.platform != "win32":
|
||
print("✗ Not a git repository. Please reinstall:")
|
||
print(" curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash")
|
||
sys.exit(1)
|
||
|
||
git_cmd = _base_git_cmd()
|
||
if sys.platform == "win32" and git_dir.exists():
|
||
_git_run(git_cmd, ["config", "windows.appendAtomically", "false"])
|
||
# A broken Git-for-Windows trampoline refuses every call with a "BUG (fork bomb)" guard;
|
||
# swap in a real binary up front so git survives instead of degrading to ZIP.
|
||
# See #87876.
|
||
git_cmd = _ensure_non_trampoline_git(git_cmd)
|
||
|
||
# Before stash/branch logic: npm rewrites package-lock.json non-deterministically and
|
||
# line-ending churn is machine-made dirt; both would otherwise force an autostash every update.
|
||
_discard_lockfile_churn(git_cmd, _m().PROJECT_ROOT)
|
||
_normalize_managed_eol(git_cmd, _m().PROJECT_ROOT)
|
||
|
||
origin_url = _m()._get_origin_url(git_cmd, _m().PROJECT_ROOT)
|
||
is_fork = _is_fork(origin_url)
|
||
|
||
if is_fork:
|
||
print("⚠ Updating from fork:")
|
||
print(f" {origin_url}")
|
||
print()
|
||
return use_zip_update, git_cmd, is_fork
|
||
|
||
|
||
def _verify_head_after_pull(
|
||
git_cmd, branch: str, pre_pull_sha, *, in_place_update: bool, _windows_gateway_resume
|
||
) -> str | None:
|
||
"""Return the post-pull HEAD SHA; ``sys.exit(1)`` if the pull was a no-op or landed off-branch."""
|
||
# A detached checkout pinned to a SHA can report "N new commit(s)" and a successful
|
||
# merge --ff-only yet stay put; surface the no-op instead of claiming "Code updated!".
|
||
# Verify HEAD actually moved (issue #79678). ``merge --ff-only`` succeeding only means the merge
|
||
# completed, not that the update applied: a checkout that is pinned to a raw SHA (detached HEAD) can
|
||
# report "N new commit(s)" against origin yet still sit on the old commit afterward (the branch-switch
|
||
# step re-detaches to the SHA). Before this guard, ``hermes update`` printed "✓ Code updated!" and
|
||
# reinstalled deps + rebuilt the desktop app against the stale tree — no error, no warning, ``hermes
|
||
# doctor`` healthy. Compare pre-pull and post-pull HEAD; if they match, surface the no-op instead of
|
||
# claiming success.
|
||
post_pull_sha = _capture_head_sha(git_cmd, _m().PROJECT_ROOT)
|
||
if pre_pull_sha and post_pull_sha == pre_pull_sha:
|
||
print()
|
||
print("✗ Code did not move — update was a no-op.")
|
||
print(
|
||
f" HEAD is pinned to {pre_pull_sha[:10]} (detached checkout); "
|
||
f"origin/{branch} advanced but the working tree stayed put.")
|
||
print(
|
||
" Reattach to the branch and retry: "
|
||
f"git -C {_m().PROJECT_ROOT} checkout {branch} && hermes update")
|
||
_m()._resume_windows_gateways_after_update(_windows_gateway_resume)
|
||
sys.exit(1)
|
||
|
||
# HEAD must be on the target or "Code updated!" is a lie; an IN-PLACE update is the one
|
||
# legitimate exception (origin/<target> merged INTO the checked-out branch).
|
||
post_pull_branch = _current_branch_name(git_cmd)
|
||
if not in_place_update and post_pull_branch and post_pull_branch not in {branch, "HEAD"}:
|
||
print()
|
||
print(
|
||
f"✗ Update pulled origin/{branch}, but the checkout is on "
|
||
f"'{post_pull_branch}' — not claiming success.")
|
||
print(
|
||
" Switch to the target branch and retry: "
|
||
f"git -C {_m().PROJECT_ROOT} checkout {branch} && hermes update")
|
||
_m()._resume_windows_gateways_after_update(_windows_gateway_resume)
|
||
sys.exit(1)
|
||
return post_pull_sha
|
||
|
||
|
||
def _current_branch_name(git_cmd, *, check: bool = False) -> str:
|
||
"""``rev-parse --abbrev-ref HEAD`` (literal "HEAD" when detached)."""
|
||
return _git_run(git_cmd, ["rev-parse", "--abbrev-ref", "HEAD"], check=check).stdout.strip()
|
||
|
||
|
||
def _handle_update_called_process_error(
|
||
e, args, gateway_mode: bool, had_desktop_app_before_update: bool,
|
||
*, target_sha: str | None = None) -> None:
|
||
"""Git/installer failure: ZIP-fallback when safe, else report and ``sys.exit(1)``."""
|
||
stage = _format_update_failure_stage(e)
|
||
if _should_zip_fallback_on_update_error(e):
|
||
print(f"⚠ {stage}: {e}")
|
||
print("→ Falling back to ZIP download...")
|
||
print()
|
||
desktop_build_ok = _update_via_zip(
|
||
args, had_desktop_app_before_update=had_desktop_app_before_update,
|
||
target_sha=target_sha)
|
||
if gateway_mode:
|
||
_write_gateway_update_exit_code(desktop_build_ok)
|
||
else:
|
||
print(f"✗ {stage}: {e}")
|
||
_print_called_process_error_tail(e)
|
||
if _called_process_error_is_python_dep_install(e):
|
||
print(
|
||
" The git update already finished. Re-downloading the source "
|
||
"ZIP cannot fix a dependency install error and would overwrite local files.")
|
||
if _m()._is_windows():
|
||
print(" Retry through the venv interpreter:")
|
||
print(
|
||
' venv\\Scripts\\python.exe -c '
|
||
'"from hermes_cli.main import main; main()" update --yes')
|
||
_finalize_receipt("failed", 'Update receipt finalize failed: %s')
|
||
sys.exit(1)
|
||
|
||
|
||
def _finalize_receipt(status: str, debug_message: str) -> None:
|
||
"""Best-effort ``finalize_update_receipt(status)``; the receipt must never break an update."""
|
||
with _best_effort(debug_message):
|
||
from hermes_cli.update_receipt import finalize_update_receipt
|
||
finalize_update_receipt(status)
|
||
|
||
|
||
def _finish_already_up_to_date(
|
||
git_cmd, branch: str, current_branch: str, _plan, *, assume_yes: bool, gateway_mode: bool,
|
||
gw_input_fn, pre_update_snapshot_id, desktop_dir, had_desktop_app_before_update: bool,
|
||
active_lazy_features, active_tool_dependencies, _windows_gateway_resume) -> None:
|
||
""""Already up to date" path: restore stash/branch, repair the checkout, catch up the fleet.
|
||
``sys.exit(1)`` when the repair is incomplete (after gateway exit code + partial receipt)."""
|
||
_invalidate_update_cache()
|
||
|
||
# Restore stash and switch back if we moved. EXCEPTION: a parked branch verified clean +
|
||
# fully merged stays on the target — re-parking on the stale branch recreates the incident.
|
||
if _plan.auto_stash_ref is not None:
|
||
_m()._restore_stashed_changes(
|
||
git_cmd, _m().PROJECT_ROOT, _plan.auto_stash_ref, prompt_user=_plan.prompt_for_restore,
|
||
input_fn=gw_input_fn)
|
||
if _plan.parked_branch_switched:
|
||
if _plan.switch_block_reason.startswith("unmerged:"):
|
||
_count = _plan.switch_block_reason.split(":", 1)[1]
|
||
print(
|
||
f" ✓ Checkout was parked on '{current_branch}' — switched back to {branch}; "
|
||
f"{_count} unmerged commit(s) kept on '{current_branch}'.")
|
||
else:
|
||
print(f" ✓ Checkout was parked on '{current_branch}' (fully merged) — switched back to {branch}.")
|
||
elif current_branch not in {branch, "HEAD"}:
|
||
_git_run(git_cmd, ["checkout", current_branch])
|
||
|
||
current_checkout_complete = _repair_current_checkout(
|
||
assume_yes=assume_yes, gateway_mode=gateway_mode,
|
||
pre_update_snapshot_id=pre_update_snapshot_id, desktop_dir=desktop_dir,
|
||
had_desktop_app_before_update=had_desktop_app_before_update,
|
||
active_lazy_features=active_lazy_features,
|
||
active_tool_dependencies=active_tool_dependencies, upstream_checked=_plan.upstream_checked,
|
||
_windows_gateway_resume=_windows_gateway_resume)
|
||
_m()._resume_windows_gateways_after_update(_windows_gateway_resume)
|
||
# A prior pull may still owe the fleet a restart; catch up here too, BEFORE the exit
|
||
# gate so a partial outcome can't strand the fleet on stale code.
|
||
# Catch up even on the "Already up to date" path — that early return is what left the gateway on stale
|
||
# code for two days. Runs BEFORE the runtime-verification exit gate below: a vulnerable SQLite runtime
|
||
# demotes the outcome to partial, but must not strand the fleet on stale code (#91277 fleet contract —
|
||
# the pending-restart check always executes).
|
||
_apply_pending_fleet_restart_catchup()
|
||
if not current_checkout_complete:
|
||
if gateway_mode:
|
||
_write_gateway_update_exit_code(False)
|
||
_finalize_receipt("partial", 'Update receipt finalize (current checkout) failed: %s')
|
||
sys.exit(1)
|
||
|
||
|
||
def _apply_pulled_update(
|
||
git_cmd, branch, pre_pull_sha, _plan, opts, *, gateway_mode, is_fork, desktop_dir,
|
||
had_desktop_app_before_update, pre_update_snapshot_id, _pre_update_plan,
|
||
_windows_gateway_resume) -> None:
|
||
"""Post-pull phase: verify HEAD, sync Python/Node/web/Desktop, maintenance, fleet restart."""
|
||
_invalidate_update_cache()
|
||
post_pull_sha = _verify_head_after_pull(
|
||
git_cmd, branch, pre_pull_sha, in_place_update=_plan.in_place_update,
|
||
_windows_gateway_resume=_windows_gateway_resume)
|
||
|
||
# Gateways still serve pre-pull modules until the restart phase; an interrupt before a
|
||
# completed restart leaves this marker so the next update catches up even when git is
|
||
# current. Distinct from ``.update-incomplete`` (venv/install repair).
|
||
# See #95294.
|
||
_write_fleet_restart_pending_marker(expected_sha=post_pull_sha or "")
|
||
# Stale .pyc would ImportError on gateway restart when new source references new names.
|
||
_sweep_bytecode_after_update(branch)
|
||
|
||
if is_fork and branch == "main":
|
||
_m()._sync_with_upstream_if_needed(
|
||
git_cmd, _m().PROJECT_ROOT, assume_yes=opts.assume_yes, input_fn=opts.gw_input_fn)
|
||
|
||
# .[all], falling back to base + extras individually so one broken extra doesn't strip
|
||
# the rest; the ownership preflight refuses first on foreign-owned (sudo-pip) venv files.
|
||
# PM dep phase (update_cmd_deps owner): ``pm.sync_venv(["all"], explicit=True)`` — no
|
||
# pip/lazy_deps fallback — plus the node/web/desktop surfaces it owns, so the orchestrator
|
||
# consumes its outcome instead of recomputing it.
|
||
node_failures, desktop_build_ok = _sync_python_dependencies_after_pull(
|
||
git_cmd, branch, pre_pull_sha, active_lazy_features=opts.active_lazy_features,
|
||
active_tool_dependencies=opts.active_tool_dependencies,
|
||
_windows_gateway_resume=_windows_gateway_resume, desktop_dir=desktop_dir,
|
||
had_desktop_app_before_update=had_desktop_app_before_update)
|
||
|
||
print()
|
||
print(f"✓ Code updated!{_branch_head_suffix(git_cmd, _m().PROJECT_ROOT)}")
|
||
|
||
update_complete = _run_post_update_maintenance(
|
||
assume_yes=opts.assume_yes, gateway_mode=gateway_mode,
|
||
pre_update_snapshot_id=pre_update_snapshot_id,
|
||
had_desktop_app_before_update=had_desktop_app_before_update,
|
||
node_failures=node_failures, desktop_build_ok=desktop_build_ok,
|
||
pre_update_version=opts.pre_update_version)
|
||
|
||
# Exit code *before* the restart: under --gateway this process lives in the gateway's
|
||
# systemd cgroup and the systemctl-restart fallback SIGKILLs it (KillMode=mixed), so
|
||
# the marker would never land and the new gateway's watcher would time out spuriously.
|
||
if gateway_mode:
|
||
_write_gateway_update_exit_code(update_complete)
|
||
|
||
_restart = _restart_gateway_fleet_after_update(_pre_update_plan, gateway_mode)
|
||
_resume_windows_gateways_and_merge_outcome(_restart, _windows_gateway_resume, gateway_mode)
|
||
_verify_fleet_after_update(
|
||
_restart, _pre_update_plan=_pre_update_plan, _windows_gateway_resume=_windows_gateway_resume,
|
||
node_failures=node_failures, update_complete=update_complete)
|
||
|
||
|
||
def _cmd_update_impl(args, gateway_mode: bool):
|
||
"""Body of ``cmd_update`` — kept separate so the wrapper can always restore stdio even on
|
||
``sys.exit``. Self-lock deferral deliberately does NOT run here (pre-fetch it stranded users
|
||
on the OLD checkout in an exit-2 loop); it runs right before the dependency sync."""
|
||
opts = _resolve_update_options(args, gateway_mode)
|
||
gw_input_fn, assume_yes = opts.gw_input_fn, opts.assume_yes
|
||
|
||
print("⚕ Updating Hermes Agent...")
|
||
print()
|
||
|
||
_pre_update_plan = _begin_update_receipt_and_plan(args)
|
||
|
||
# Backup before any git/file mutation; the snapshot id (None if disabled/failed) feeds
|
||
# the post-update cron-jobs safety net.
|
||
pre_update_snapshot_id = _m()._run_pre_update_backup(args)
|
||
_record_update_step(
|
||
"pre_update_backup", pre_update_snapshot_id is not None,
|
||
f"snapshot={pre_update_snapshot_id}" if pre_update_snapshot_id else "disabled or failed")
|
||
|
||
_windows_gateway_resume = _m()._pause_windows_gateways_for_update()
|
||
if _windows_gateway_resume:
|
||
import atexit as _atexit
|
||
_atexit.register(_m()._resume_windows_gateways_after_update, _windows_gateway_resume)
|
||
|
||
# Any venv python still running (typically the Desktop `hermes serve` backend) keeps .pyd
|
||
# locked and would corrupt the sync; refuse rather than race (the app respawns a killed
|
||
# backend). NOT bypassed by --force (desktop updater, shim guard only); --force-venv is.
|
||
if _m()._is_windows() and not getattr(args, "force_venv", False):
|
||
_clear_windows_venv_holders_or_exit(args, gateway_mode, _windows_gateway_resume)
|
||
|
||
desktop_dir = _m().PROJECT_ROOT / "apps" / "desktop"
|
||
had_desktop_app_before_update = _desktop_app_present(desktop_dir)
|
||
|
||
use_zip_update, git_cmd, is_fork = _prepare_git_command()
|
||
|
||
branch = _m()._resolve_update_branch(args)
|
||
target_ref = f"origin/{branch}"
|
||
stable_tag, stable_sha = None, None
|
||
if _stable_channel_active(args):
|
||
print("→ Update channel: stable (tagged releases)")
|
||
stable_tag, stable_sha = (
|
||
_github_latest_release() if use_zip_update
|
||
else _resolve_latest_release_tag(git_cmd, _m().PROJECT_ROOT))
|
||
if stable_tag is None or not re.fullmatch(r"[0-9a-f]{40}", stable_sha or ""):
|
||
print("✗ Could not resolve the stable release commit. No update was applied.")
|
||
print(" Retry, or switch channels with: hermes update --set-channel main")
|
||
_m()._resume_windows_gateways_after_update(_windows_gateway_resume)
|
||
sys.exit(1)
|
||
print(f"→ Latest release: {stable_tag}")
|
||
target_ref = stable_sha
|
||
|
||
if use_zip_update:
|
||
try:
|
||
desktop_build_ok = _update_via_zip(
|
||
args, had_desktop_app_before_update=had_desktop_app_before_update,
|
||
target_sha=stable_sha)
|
||
finally:
|
||
_m()._resume_windows_gateways_after_update(_windows_gateway_resume)
|
||
if gateway_mode:
|
||
_write_gateway_update_exit_code(desktop_build_ok)
|
||
return
|
||
|
||
try:
|
||
# Self-heal abandoned .git/*.lock files (crashed fetch) or the fetch fails "File exists".
|
||
from hermes_cli.gitlock import clear_stale_git_locks, clear_stale_tmp_packs
|
||
cleared = clear_stale_git_locks(_m().PROJECT_ROOT)
|
||
if cleared:
|
||
print(" (removed stale git lock(s): %s)" % ", ".join(cleared))
|
||
swept = clear_stale_tmp_packs(_m().PROJECT_ROOT)
|
||
if swept:
|
||
print(" (removed %d aborted-fetch pack temp file(s))" % len(swept))
|
||
# Shallow installer checkouts collect one `.git/shallow` graft per past depth-1 fetch
|
||
# (#105951); stale grafts break merge-base and push this run into the divergence path.
|
||
from hermes_cli.gitlock import prune_stale_shallow_grafts
|
||
pruned = prune_stale_shallow_grafts(_m().PROJECT_ROOT)
|
||
if pruned:
|
||
print(f" (pruned {pruned} stale shallow graft(s) left by past depth-1 checks)")
|
||
|
||
# Surface autostashes left by earlier updates (--keep-stash, failed restores).
|
||
# Surface autostash entries left behind by earlier updates (#63717 problem 6) — parked --keep-stash
|
||
# runs and failed restores preserve the stash but nothing ever mentioned it again.
|
||
_m()._warn_orphaned_update_autostashes(git_cmd, _m().PROJECT_ROOT)
|
||
|
||
print("→ Fetching updates...")
|
||
if stable_tag:
|
||
fetch_result = _git_run(git_cmd, ["fetch", "--no-tags", "origin", target_ref], network=True)
|
||
if fetch_result.returncode != 0:
|
||
# Older servers require a named ref. Do not change local tags.
|
||
fetch_result = _git_run(
|
||
git_cmd, ["fetch", "--no-tags", "origin", f"refs/tags/{stable_tag}"], network=True)
|
||
if fetch_result.returncode == 0:
|
||
fetched = _git_run(git_cmd, ["rev-parse", "--verify", "FETCH_HEAD^{commit}"])
|
||
if fetched.returncode != 0 or fetched.stdout.strip() != target_ref:
|
||
print("✗ The release tag changed during this update. Retry to select its new commit.")
|
||
_m()._resume_windows_gateways_after_update(_windows_gateway_resume)
|
||
sys.exit(1)
|
||
else:
|
||
fetch_result = _git_run(git_cmd, ["fetch", "origin", branch], network=True)
|
||
if fetch_result.returncode != 0:
|
||
_print_fetch_failure(fetch_result.stderr)
|
||
_m()._resume_windows_gateways_after_update(_windows_gateway_resume)
|
||
sys.exit(1)
|
||
|
||
current_branch = _current_branch_name(git_cmd, check=True)
|
||
_plan = _prepare_checkout_for_update(
|
||
git_cmd, branch, current_branch, is_fork=is_fork, assume_yes=assume_yes,
|
||
gateway_mode=gateway_mode, gw_input_fn=gw_input_fn, switch_branch=opts.switch_branch,
|
||
target_ref=target_ref, _windows_gateway_resume=_windows_gateway_resume)
|
||
commit_count = _plan.commit_count
|
||
|
||
if commit_count == 0:
|
||
_finish_already_up_to_date(
|
||
git_cmd, branch, current_branch, _plan, assume_yes=assume_yes,
|
||
gateway_mode=gateway_mode, gw_input_fn=gw_input_fn,
|
||
pre_update_snapshot_id=pre_update_snapshot_id, desktop_dir=desktop_dir,
|
||
had_desktop_app_before_update=had_desktop_app_before_update,
|
||
active_lazy_features=opts.active_lazy_features,
|
||
active_tool_dependencies=opts.active_tool_dependencies,
|
||
_windows_gateway_resume=_windows_gateway_resume)
|
||
return
|
||
|
||
if commit_count > 0:
|
||
print(f"→ Found {commit_count} new commit(s)")
|
||
else:
|
||
# Shallow, exact count unrecoverable — but the tips differ, so there IS an update.
|
||
print("→ Updates available (commit count unknown on this shallow checkout)")
|
||
|
||
print("→ Pulling updates...")
|
||
pre_pull_sha = _pull_updates(
|
||
git_cmd, branch, _plan.auto_stash_ref, prompt_for_restore=_plan.prompt_for_restore,
|
||
gw_input_fn=gw_input_fn, discard_local_changes=opts.discard_local_changes,
|
||
keep_stash=opts.keep_stash, target_ref=target_ref)
|
||
_apply_pulled_update(
|
||
git_cmd, branch, pre_pull_sha, _plan, opts, gateway_mode=gateway_mode,
|
||
is_fork=is_fork and not stable_tag, desktop_dir=desktop_dir,
|
||
had_desktop_app_before_update=had_desktop_app_before_update,
|
||
pre_update_snapshot_id=pre_update_snapshot_id, _pre_update_plan=_pre_update_plan,
|
||
_windows_gateway_resume=_windows_gateway_resume)
|
||
except subprocess.CalledProcessError as e:
|
||
try:
|
||
_handle_update_called_process_error(
|
||
e, args, gateway_mode, had_desktop_app_before_update, target_sha=stable_sha)
|
||
finally:
|
||
_m()._resume_windows_gateways_after_update(_windows_gateway_resume)
|
||
|
||
|
||
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
|
||
# Names external plugins imported from this module before the Sep 2026 decomposition.
|
||
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
|
||
# The whole block is removed by reverting the commit that added it.
|
||
from typing import Optional # noqa: F401,E402
|
||
from datetime import datetime # noqa: F401,E402
|
||
import hashlib # noqa: F401,E402
|
||
import json # noqa: F401,E402
|
||
# ---- END PLUGIN-COMPAT ----
|