A git-less Windows ZIP install has no .git and never runs git, yet _prepare_git_command called expose_pm_git() before it checked use_zip_update: offline or with no pin, pm.ensure raised and aborted an update that never needed git. expose_pm_git now takes the project root and does nothing unless it is a git checkout (both callers). When install.ps1 runs as one process, the git it staged into PM's store is on the inherited PATH, so expose_pm_git returned early and PM's facts never recorded it; plain hermes processes (plugin git installs, doctor, version info) had no git until the first `hermes update`. A git found under PM's store is now treated as that unrecorded copy and ensured, so the source completion writes the git fact at install time. Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
1441 lines
71 KiB
Python
1441 lines
71 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 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 typing import NoReturn
|
||
|
||
from hermes_cli.config import get_hermes_home # noqa: F401 (re-exported; patched via update_cmd)
|
||
from hermes_cli import update_handoff as _update_handoff
|
||
from hermes_cli.update_cmd_common import _best_effort
|
||
# Captured BEFORE a checkout swap: parent transport/lifecycle never imports new code.
|
||
from hermes_cli.update_completion import run_completion
|
||
from hermes_cli.update_channel import adopt_retired_channel
|
||
from pm.receipt import accept_worker_receipt as _accept_completion_pm_receipt
|
||
from hermes_cli import update_receipt as _completion_receipt, update_cmd_config as _completion_config
|
||
from hermes_cli._old_updater import stop_for_relaunch
|
||
from hermes_cli._early_recovery import interrupted_pull_marker
|
||
from hermes_cli import update_cmd_check as _check
|
||
|
||
# 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,
|
||
_cold_start_windows_gateway_after_update, _desktop_owns_gateway_lifecycle,
|
||
_detect_venv_python_processes, _hermes_holder_subcommand, _holder_value_flags,
|
||
_holder_value_flags_cache, _looks_like_desktop_control_plane,
|
||
_pause_windows_gateways_for_update,
|
||
_refresh_bootstrap_cache_scripts, _refresh_windows_gateway_launchers,
|
||
_refuse_gateway_ancestor_tree_kill,
|
||
_restore_windows_gateway_service, _resume_windows_gateways_after_update,
|
||
_resume_windows_gateways_and_merge_outcome, _self_and_non_gateway_ancestor_pids,
|
||
_start_windows_gateway_service,
|
||
_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,
|
||
_clear_fleet_restart_pending_marker,
|
||
_current_checkout_sha, _drain_or_signal_gateway_for_update, _fleet_probe_expected_runtimes,
|
||
_fleet_restart_pending_marker_path, _fleet_restart_skip_reason, _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,
|
||
_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_validation import ( # noqa: F401 — frozen updater surface (tests/compat)
|
||
_UPDATE_CRITICAL_MODULES, _critical_module_import_failures,
|
||
_validate_critical_modules_import)
|
||
from hermes_cli.old_updater_deps import ( # noqa: F401 — frozen updater surface (tests/compat + test_old_updater_shims)
|
||
_capture_active_lazy_features, _npm_lockfile_changed, _path_uid,
|
||
_rebuild_desktop_after_update, _refresh_active_lazy_features,
|
||
_refresh_active_memory_provider_dependencies, _update_node_dependencies)
|
||
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, _park_detached_head, _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, _clear_stale_sqlite_sidecars,
|
||
_checkout_version, _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,
|
||
_prepare_updated_checkout,
|
||
_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 get_default_hermes_root() -> NoReturn:
|
||
# Shim to suppress old updater work until relaunch. No path is safe to invent.
|
||
stop_for_relaunch()
|
||
|
||
|
||
def _ensure_uv_for_termux(pip_cmd: list[str]) -> NoReturn:
|
||
# Shim to stop the old updater doing work until relaunch, not bootstrap uv.
|
||
stop_for_relaunch()
|
||
|
||
|
||
def _ensure_venv_pip(pip_cmd: list, python_exe: str) -> NoReturn:
|
||
# Shim to stop the old updater doing work until relaunch, not bootstrap pip.
|
||
stop_for_relaunch()
|
||
|
||
|
||
def _pip_install_prefix(uv_bin) -> NoReturn:
|
||
# Shim to stop the old updater doing work until relaunch, not form an install.
|
||
stop_for_relaunch()
|
||
|
||
|
||
def _refuse_update_for_contended_shims(exc: BaseException) -> NoReturn:
|
||
# Shim to stop the old updater doing work until relaunch. Write no markers.
|
||
stop_for_relaunch()
|
||
|
||
|
||
def _shim_quarantine_error_type() -> type[Exception]:
|
||
# Shim to stop the old updater doing work until relaunch. Its old except
|
||
# clause needs an exception type, but must not catch real failures.
|
||
class _NeverRaised(Exception):
|
||
pass
|
||
|
||
return _NeverRaised
|
||
|
||
|
||
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 _record_update_skip(step: str, reason: str) -> None:
|
||
"""Best-effort ``update_receipt.record_skip``; the receipt must never break an update."""
|
||
with suppress(Exception):
|
||
from hermes_cli.update_receipt import record_skip
|
||
record_skip(step, reason)
|
||
|
||
|
||
def _record_pre_update_backup_outcome(args, snapshot_id) -> None:
|
||
"""Record the pre-update backup as a skip when it was disabled, else as a step.
|
||
|
||
``snapshot_id`` is None both when the backup was deliberately turned off (config
|
||
``updates.pre_update_backup: off``/``false``, or ``--no-backup``) and when a requested backup
|
||
produced nothing. Recording both as ``ok=false, "disabled or failed"`` made an opt-out
|
||
indistinguishable from a real failure in the receipt, so a disabled safety net read as a
|
||
broken one (#94944 is the shipped-opt-out case). A deliberate opt-out is a SKIP WITH its
|
||
reason; only a requested-but-empty backup is a failed step.
|
||
"""
|
||
if snapshot_id:
|
||
_record_update_step("pre_update_backup", True, f"snapshot={snapshot_id}")
|
||
return
|
||
if _resolve_pre_update_backup_mode(args) == "off":
|
||
reason = ("disabled by --no-backup" if getattr(args, "no_backup", False)
|
||
else "disabled by updates.pre_update_backup (mode: off)")
|
||
_record_update_skip("pre_update_backup", reason)
|
||
return
|
||
_record_update_step("pre_update_backup", False, "no snapshot captured")
|
||
|
||
|
||
|
||
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 _write_update_incomplete_marker() -> None:
|
||
# Historical updater hook. PM's successful facts determine completion.
|
||
stop_for_relaunch()
|
||
|
||
|
||
def _write_lazy_refresh_incomplete_marker() -> None:
|
||
# Historical updater hook. There is no separate lazy-refresh transaction.
|
||
stop_for_relaunch()
|
||
|
||
|
||
|
||
def _filter_non_gateway_concurrent_instances(
|
||
matches: list[tuple[int, str]],
|
||
) -> list[tuple[int, str]]:
|
||
# Historical updater hook; PM never replaces a running venv's executables.
|
||
stop_for_relaunch()
|
||
|
||
|
||
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()
|
||
|
||
|
||
def _source_update_channel(args=None, *, channel=None, branch_explicit=False) -> str:
|
||
"""Explicit branches win; otherwise transient channel, then this install's record."""
|
||
if branch_explicit or getattr(args, "branch", None):
|
||
return "main"
|
||
transient = channel if channel is not None else getattr(args, "channel", None)
|
||
if transient is not None:
|
||
from hermes_cli.release_channels import validate_name
|
||
return validate_name(transient)
|
||
from hermes_cli.update_channel import resolve_update_channel
|
||
|
||
from hermes_cli.config import get_config_path, require_readable_config_before_write
|
||
|
||
config = require_readable_config_before_write(get_config_path())
|
||
return resolve_update_channel(config, _m().PROJECT_ROOT)
|
||
|
||
|
||
def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False, channel=None):
|
||
"""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)
|
||
|
||
root = _m().PROJECT_ROOT
|
||
if not (root / ".git").exists():
|
||
print("✗ Not a git repository — cannot check for updates.")
|
||
sys.exit(1)
|
||
|
||
git_cmd = _base_git_cmd()
|
||
_check.clear_git_debris(root)
|
||
|
||
selected_channel = _source_update_channel(channel=channel, branch_explicit=branch_explicit)
|
||
if not branch_explicit:
|
||
branch = _check.channel_compare_branch(selected_channel, git_cmd, root)
|
||
if branch is None:
|
||
return
|
||
|
||
# Installer checkouts are shallow (`git clone --depth 1`). A plain fetch would unshallow
|
||
# the repo (the exact cost the shallow clone avoided) and rev-list would then report a
|
||
# huge bogus "behind" count, so fetch with --depth 1 and report presence-only.
|
||
is_shallow = _check.is_shallow_repository(git_cmd, root)
|
||
fetch_result, compare_branch = _check.fetch_compare_branch(
|
||
git_cmd, root, branch, ["--depth", "1"] if is_shallow else [],
|
||
)
|
||
if fetch_result.returncode != 0:
|
||
_print_fetch_failure(fetch_result.stderr)
|
||
sys.exit(1)
|
||
if is_shallow:
|
||
_check.repair_shallow_grafts(root)
|
||
|
||
if not _check.compare_ref_exists(git_cmd, root, compare_branch):
|
||
print(f"✗ Branch '{branch}' not found on {compare_branch.split('/', 1)[0]}.")
|
||
sys.exit(1)
|
||
if is_shallow:
|
||
_check.report_shallow_verdict(git_cmd, root, compare_branch)
|
||
else:
|
||
_check.report_rev_list_verdict(git_cmd, root, compare_branch)
|
||
|
||
|
||
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 _source_completion_request(opts, plan, snapshot_id, windows_resume, desktop, gateway_mode) -> dict:
|
||
"""Freeze data before mutation; no pre-swap module objects cross the seam."""
|
||
from copy import deepcopy
|
||
current = _completion_receipt._current.get()
|
||
if current is None:
|
||
_completion_receipt.begin_update_receipt()
|
||
current = _completion_receipt._current.get()
|
||
return {
|
||
"schema": 1, "source": str(_m().PROJECT_ROOT.resolve()),
|
||
"home": str(get_hermes_home()), "branch": "main", "desktop": desktop,
|
||
"assume_yes": opts.assume_yes, "gateway_mode": gateway_mode,
|
||
"no_gateway_restart": getattr(opts, "no_gateway_restart", False),
|
||
"pre_update_version": opts.pre_update_version, "snapshot_id": snapshot_id,
|
||
"sibling_snapshots": deepcopy(_completion_config._LAST_SIBLING_SNAPSHOTS),
|
||
"plan": plan.to_dict() if plan is not None else None,
|
||
"receipt": deepcopy(current.data), "windows_resume": windows_resume,
|
||
}
|
||
|
||
|
||
def _complete_source_update(request: dict | None) -> None:
|
||
if request is None:
|
||
stop_for_relaunch(incomplete=True)
|
||
from copy import deepcopy
|
||
current = _completion_receipt._current.get()
|
||
if current is not None:
|
||
request["receipt"] = deepcopy(current.data)
|
||
_write_fleet_restart_pending_marker(expected_sha=request.get("expected_sha") or "")
|
||
result = run_completion(request)
|
||
_accept_completion_pm_receipt(result.get("pm_receipt"), request["receipt"]["update_id"])
|
||
token = request["windows_resume"]
|
||
if token is not None and result.get("windows_resume") is not None:
|
||
resumed = dict(result["windows_resume"])
|
||
token.clear()
|
||
token.update(resumed)
|
||
if result.get("receipt") is not None:
|
||
current = _completion_receipt._current.get()
|
||
if current is not None:
|
||
_completion_receipt._current.reset(current.current_token)
|
||
if result["exit_code"]:
|
||
raise SystemExit(result["exit_code"])
|
||
if adopt_retired_channel(request):
|
||
print(f"→ Source subscription moved to {request['channel_retirement']['destination']}")
|
||
|
||
|
||
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 after parking the old HEAD behind a rescue ref. ``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: the reset below is right either way, but the two causes of divergence here
|
||
# are indistinguishable from the checkout alone. An upstream force-push/rebase loses
|
||
# nothing; local commits on this branch lose everything, and the reflog is the only way
|
||
# back — an expiring log the user has to know to reach for, in a directory Hermes updates
|
||
# unattended. So park pre_pull_sha behind a rescue ref for BOTH, orphan divergence (no
|
||
# common ancestor: corrupted HEAD, re-init) included.
|
||
merge_base_result = _git_run(git_cmd, ["merge-base", "HEAD", merge_ref])
|
||
has_common_ancestor = bool(
|
||
merge_base_result.returncode == 0 and merge_base_result.stdout.strip())
|
||
if pre_pull_sha:
|
||
from datetime import datetime as _dt, timezone
|
||
# SHA suffix so two updates in the same second get distinct refs.
|
||
kind = "diverged" if has_common_ancestor else "orphan"
|
||
rescue_ref = (
|
||
f"refs/hermes-update-backups/{kind}-{branch}-"
|
||
f"{_dt.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{pre_pull_sha[:12]}")
|
||
head = (
|
||
f" ⚠ Local history has diverged from origin/{branch} — "
|
||
if has_common_ancestor else
|
||
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.")
|
||
if has_common_ancestor:
|
||
dropped = (_git_run(
|
||
git_cmd, ["rev-list", "--count", f"origin/{branch}..{pre_pull_sha}"]).stdout or "").strip()
|
||
print(f" {dropped or 'Some'} commit(s) not on origin/{branch} leave the branch; "
|
||
f"list them with: git log origin/{branch}..{rescue_ref}")
|
||
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, pre_sync_sha=None, sync_upstream=False, assume_yes=False,
|
||
in_place_update=False, _windows_gateway_resume=None):
|
||
"""Fast-forward onto ``origin/<branch>`` and settle the autostash. Divergence by shape:
|
||
custom branch -> merge, same branch -> rescue ref then reset; a
|
||
post-pull syntax error in a critical file rolls back. Exits on failure; returns pre-pull SHA."""
|
||
update_succeeded = False
|
||
# Rescue refs must retain the immediate pre-pull tip, even when syntax
|
||
# rollback needs to cross an earlier upstream sync.
|
||
pre_pull_sha = _capture_head_sha(git_cmd, _m().PROJECT_ROOT)
|
||
# Git moves the tree file by file and HEAD last: if this process dies in between, the next launch
|
||
# of any entry point finds this marker and puts the old tree back (_early_recovery). The target is
|
||
# the resolved commit, so a later `git fetch` cannot widen what that restore considers.
|
||
pull_marker = interrupted_pull_marker(_m().PROJECT_ROOT)
|
||
# A release update moves the tree to its tag, not the branch tip: the marker names what git writes.
|
||
merge_ref = target_ref if target_ref is not None else f"origin/{branch}"
|
||
target_sha = (_git_run(git_cmd, ["rev-parse", f"{merge_ref}^{{commit}}"]).stdout or "").strip()
|
||
with _best_effort('Could not write the interrupted-pull marker: %s'):
|
||
pull_marker.write_text(
|
||
f"pid={os.getpid()}\npre={pre_pull_sha}\ntarget={target_sha}\nstash={auto_stash_ref or ''}\n",
|
||
encoding="utf-8")
|
||
try:
|
||
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.
|
||
if merge_ref != f"origin/{branch}":
|
||
# Keep detached local commits reachable, too. Named branches are
|
||
# untouched by checkout --detach; an autostash protects dirty files.
|
||
_park_detached_head(git_cmd, _m().PROJECT_ROOT, branch)
|
||
_git_run(git_cmd, ["checkout", "--detach", merge_ref], check=True)
|
||
elif _git_run(git_cmd, ["merge", "--ff-only", merge_ref]).returncode != 0:
|
||
_reconcile_diverged_checkout(git_cmd, branch, pre_pull_sha, target_ref=merge_ref)
|
||
except KeyboardInterrupt:
|
||
raise # Ctrl-C reached git too (same process group): the tree may be torn, keep the marker
|
||
except BaseException:
|
||
pull_marker.unlink(missing_ok=True) # git exited on its own (sys.exit on conflict/reset failure)
|
||
raise
|
||
pull_marker.unlink(missing_ok=True) # git is done: the tree is whole again
|
||
if sync_upstream:
|
||
# Do not let a second mutation hide a failed origin merge or move an
|
||
# unexpected branch. Keep local edits parked through the final check.
|
||
_verify_head_after_pull(
|
||
git_cmd, branch, pre_sync_sha or pre_pull_sha, in_place_update=in_place_update,
|
||
_windows_gateway_resume=_windows_gateway_resume)
|
||
_m()._sync_with_upstream_if_needed(
|
||
git_cmd, _m().PROJECT_ROOT, assume_yes=assume_yes, input_fn=gw_input_fn)
|
||
# Refuse an unexpected branch before syntax rollback can reset its ref.
|
||
_verify_head_after_pull(
|
||
git_cmd, branch, pre_sync_sha or pre_pull_sha, in_place_update=in_place_update,
|
||
_windows_gateway_resume=_windows_gateway_resume)
|
||
_rollback_if_pulled_syntax_error(git_cmd, pre_sync_sha or 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
|
||
pre_sync_sha: str | None = None
|
||
|
||
|
||
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}"
|
||
release_tag = target_ref != f"origin/{branch}"
|
||
if release_tag:
|
||
# A release lands detached at its exact commit, never merges into or
|
||
# rewrites the user's branch. Branch-policy machinery is main-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 release_tag and not in_place_update and current_branch == "HEAD" != branch:
|
||
print(f" ⚠ Currently on detached HEAD — switching to {branch} for update...")
|
||
# Before the stash: its refs/stash would contain HEAD until it is dropped.
|
||
_park_detached_head(git_cmd, _m().PROJECT_ROOT, branch)
|
||
auto_stash_ref = _m()._stash_local_changes_if_needed(git_cmd, _m().PROJECT_ROOT)
|
||
if (
|
||
not release_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())))
|
||
|
||
if release_tag:
|
||
# An ancestor release still needs applying when switching channels.
|
||
head_sha = _capture_head_sha(git_cmd, _m().PROJECT_ROOT)
|
||
return _CheckoutPlan(
|
||
auto_stash_ref=auto_stash_ref, commit_count=0 if head_sha == target_ref else -1,
|
||
in_place_update=True, parked_branch_switched=False,
|
||
prompt_for_restore=prompt_for_restore, switch_block_reason=None, upstream_checked=True)
|
||
|
||
# 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.source_check 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
|
||
moved_from_sha = None
|
||
if commit_count == 0 and is_fork and branch == "main" and not release_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)
|
||
moved_from_sha = pre_sync_sha
|
||
|
||
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,
|
||
pre_sync_sha=moved_from_sha)
|
||
|
||
|
||
@dataclass
|
||
class _UpdateOptions:
|
||
"""Resolved ``hermes update`` inputs (flags, config, pre-update snapshots)."""
|
||
|
||
pre_update_version: object
|
||
gw_input_fn: object
|
||
assume_yes: bool
|
||
keep_stash: bool
|
||
switch_branch: bool
|
||
discard_local_changes: bool
|
||
no_gateway_restart: bool = False
|
||
|
||
|
||
def _resolve_update_options(args, gateway_mode: bool) -> _UpdateOptions:
|
||
"""Snapshot pre-update state and resolve the flags/config ``_cmd_update_impl`` runs on."""
|
||
|
||
# Captured before any pull so the completion line can report the transition (prime-agent#630 port).
|
||
pre_update_version = _checkout_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))
|
||
# --no-gateway-restart (cron inside the gateway's own cgroup): update code
|
||
# and dependencies but defer the fleet restart so the updater is not killed
|
||
# by its own restart. The pending-restart marker is kept for catch-up.
|
||
no_gateway_restart = bool(getattr(args, "no_gateway_restart", 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(
|
||
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,
|
||
no_gateway_restart=no_gateway_restart)
|
||
|
||
|
||
def _begin_update_receipt_and_plan(args):
|
||
"""Open the receipt and snapshot the fleet before changing the checkout."""
|
||
# 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}")
|
||
|
||
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)
|
||
|
||
from hermes_cli._subprocess_compat import expose_pm_git
|
||
|
||
expose_pm_git(_m().PROJECT_ROOT)
|
||
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, target_repository: str | None = None, completion_request=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()
|
||
_update_via_zip(
|
||
args, had_desktop_app_before_update=had_desktop_app_before_update,
|
||
target_sha=target_sha, completion_request=completion_request,
|
||
**({"target_repository": target_repository} if target_repository else {}))
|
||
|
||
else:
|
||
if _called_process_error_is_python_dep_install(e):
|
||
print(f"✗ {stage} (the code update itself succeeded).")
|
||
_print_called_process_error_tail(e)
|
||
print()
|
||
print(" Hermes may not start until the dependencies are installed. Fix the error above")
|
||
print(" (usually network or disk space), then run `hermes update` again.")
|
||
if _m()._is_windows():
|
||
print(" If `hermes update` itself will not start, retry through the venv interpreter:")
|
||
print(
|
||
' venv\\Scripts\\python.exe -c '
|
||
'"from hermes_cli.main import main; main()" update --yes')
|
||
else:
|
||
print(f"✗ {stage}.")
|
||
print(f" Details: {e}")
|
||
_print_called_process_error_tail(e)
|
||
_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, *, gw_input_fn, completion_request: dict) -> 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)."""
|
||
# 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])
|
||
|
||
if completion_request is not None:
|
||
# Same code, same host obligation: an SHA-less arm would REPLACE the standing record
|
||
# (and its restarted proof), so a sibling profile's no-op update re-kills the multiplexer.
|
||
completion_request["expected_sha"] = _capture_head_sha(git_cmd, _m().PROJECT_ROOT) or ""
|
||
completion_request["completion_message"] = (
|
||
"✓ Already up to date!" if _plan.upstream_checked
|
||
else "✓ Up to date with your fork (official repo not checked).")
|
||
_complete_source_update(completion_request)
|
||
|
||
|
||
def _apply_pulled_update(
|
||
git_cmd, branch, pre_pull_sha, _plan, *, _windows_gateway_resume, completion_request: dict) -> None:
|
||
"""Post-pull phase: verify HEAD, sync Python/Node/web/Desktop, maintenance, fleet restart."""
|
||
post_pull_sha = _verify_head_after_pull(
|
||
git_cmd, branch, _plan.pre_sync_sha or pre_pull_sha, in_place_update=_plan.in_place_update,
|
||
_windows_gateway_resume=_windows_gateway_resume)
|
||
|
||
if completion_request is not None:
|
||
observed = _capture_head_sha(git_cmd, _m().PROJECT_ROOT) or post_pull_sha
|
||
pinned = completion_request.get("expected_sha")
|
||
if pinned is not None and observed != pinned:
|
||
print("✗ Checkout no longer matches the selected channel commit. No completion was applied.")
|
||
_m()._resume_windows_gateways_after_update(_windows_gateway_resume)
|
||
sys.exit(1)
|
||
completion_request["expected_sha"] = pinned or observed
|
||
_complete_source_update(completion_request)
|
||
|
||
|
||
def _cmd_update_impl(args, gateway_mode: bool):
|
||
"""Apply the update; the command boundary owns errors, receipts and stdio."""
|
||
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. A deliberate opt-out is recorded as a skip with its
|
||
# reason, not as a failed step (see _record_pre_update_backup_outcome).
|
||
pre_update_snapshot_id = _m()._run_pre_update_backup(args)
|
||
_record_pre_update_backup_outcome(args, pre_update_snapshot_id)
|
||
|
||
_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)
|
||
|
||
|
||
desktop_dir = _m().PROJECT_ROOT / "apps" / "desktop"
|
||
# An installed Hermes.app only this update refreshes counts even with no release/ build
|
||
# beside it: without one it was never rebuilt, so it never got newer (#52339).
|
||
had_desktop_app_before_update = (
|
||
_m()._desktop_packaged_executable(desktop_dir) is not None
|
||
or _m()._desktop_dist_exists(desktop_dir)
|
||
or bool(_m()._installed_desktop_apps()))
|
||
|
||
use_zip_update, git_cmd, is_fork = _prepare_git_command()
|
||
|
||
completion_request = _source_completion_request(
|
||
opts, _pre_update_plan, pre_update_snapshot_id, _windows_gateway_resume,
|
||
had_desktop_app_before_update, gateway_mode)
|
||
branch = _m()._resolve_update_branch(args)
|
||
completion_request["branch"] = branch
|
||
target_ref = f"origin/{branch}"
|
||
release_sha = None
|
||
target_repository = None
|
||
selected_channel = _source_update_channel(args)
|
||
if not getattr(args, "branch", None):
|
||
from hermes_cli.source_releases import resolve_source_target
|
||
|
||
from copy import deepcopy
|
||
from hermes_cli.config import require_readable_config_before_write
|
||
from hermes_cli.update_channel import channel_record
|
||
|
||
original_record = deepcopy(channel_record(require_readable_config_before_write(
|
||
Path(completion_request["home"]) / "config.yaml"), _m().PROJECT_ROOT))
|
||
print(f"→ Update channel: {selected_channel}")
|
||
try:
|
||
target = resolve_source_target(
|
||
selected_channel, None if use_zip_update else git_cmd, _m().PROJECT_ROOT)
|
||
except (OSError, ValueError, subprocess.SubprocessError) as exc:
|
||
print(f"✗ Could not resolve the {selected_channel} source channel: {exc}. No update was applied.")
|
||
_m()._resume_windows_gateways_after_update(_windows_gateway_resume)
|
||
sys.exit(1)
|
||
if target.retired:
|
||
print(f"→ {selected_channel} retired; source destination: {target.channel}")
|
||
if (not getattr(args, "channel", None)
|
||
and original_record.get("channel", "main") == selected_channel):
|
||
completion_request["channel_retirement"] = {
|
||
"original": original_record, "destination": target.channel}
|
||
target_repository = target.repository
|
||
release_sha = target.commit
|
||
if release_sha:
|
||
print(f"→ Latest release: {target.label}")
|
||
target_ref = release_sha
|
||
completion_request["expected_sha"] = release_sha
|
||
else:
|
||
branch = target.branch
|
||
completion_request["branch"] = branch
|
||
target_ref = f"origin/{branch}"
|
||
|
||
if use_zip_update:
|
||
try:
|
||
_update_via_zip(
|
||
args, had_desktop_app_before_update=had_desktop_app_before_update,
|
||
target_sha=release_sha, completion_request=completion_request,
|
||
**({"target_repository": target_repository} if target_repository else {}))
|
||
finally:
|
||
if _windows_gateway_resume and _windows_gateway_resume.get("resume_needed"):
|
||
_m()._resume_windows_gateways_after_update(_windows_gateway_resume)
|
||
|
||
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 repair_broken_shallow_boundaries, prune_stale_shallow_grafts
|
||
repaired = repair_broken_shallow_boundaries(_m().PROJECT_ROOT)
|
||
if repaired:
|
||
print(f" (restored {repaired} broken shallow boundary(ies))")
|
||
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 release_sha:
|
||
fetch_result = _git_run(git_cmd, ["fetch", "--no-tags", "origin", target_ref], network=True)
|
||
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, gw_input_fn=gw_input_fn,
|
||
completion_request=completion_request)
|
||
return
|
||
|
||
if release_sha:
|
||
print(f"→ Switching to source commit {release_sha[:10]}")
|
||
elif 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, pre_sync_sha=_plan.pre_sync_sha,
|
||
sync_upstream=is_fork and branch == "main" and not release_sha, assume_yes=assume_yes,
|
||
in_place_update=_plan.in_place_update, _windows_gateway_resume=_windows_gateway_resume)
|
||
_apply_pulled_update(
|
||
git_cmd, branch, pre_pull_sha, _plan,
|
||
_windows_gateway_resume=_windows_gateway_resume, completion_request=completion_request)
|
||
except subprocess.CalledProcessError as e:
|
||
try:
|
||
_handle_update_called_process_error(
|
||
e, args, gateway_mode, had_desktop_app_before_update, target_sha=release_sha,
|
||
target_repository=target_repository, completion_request=completion_request)
|
||
finally:
|
||
if _windows_gateway_resume and _windows_gateway_resume.get("resume_needed"):
|
||
_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 ----
|