mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
The tag's rev-parse sat outside the try, so an OSError (a missing git, or a custody refusal) escaped _cmd_update_impl, and a failed tag fetch was swallowed silently, leaving every version reader on the previous release, the symptom the fetch exists to prevent. Both are inside the best-effort block now, and a miss logs a warning and prints one line naming the tag. (itsflownium review on #144894, #3.)
328 lines
16 KiB
Python
328 lines
16 KiB
Python
"""Steps of ``hermes update --check``: debris cleanup, channel target, scoped fetch, verdict.
|
|
|
|
``update_cmd._cmd_update_check`` (a frozen updater surface, see ``tests/compat``) orchestrates
|
|
these. Facade helpers are read through ``_uc()`` at call time and origin helpers are imported
|
|
per function, so test patches on ``hermes_cli.update_cmd`` and the origin modules stay effective.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _git(git_cmd: list[str], root: Path, args: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
|
|
from hermes_cli._subprocess_compat import windows_hide_flags
|
|
# Callers pass **_no_prompt_git_kwargs() which already carries creationflags;
|
|
# OR the hide flag into the shared kwargs instead of passing the keyword twice.
|
|
kwargs["creationflags"] = kwargs.get("creationflags", 0) | windows_hide_flags()
|
|
from hermes_cli.update_custody import run_git
|
|
|
|
return run_git(
|
|
git_cmd, args, cwd=root, capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
def _uc():
|
|
from hermes_cli import update_cmd
|
|
|
|
return update_cmd
|
|
|
|
|
|
def clear_git_debris(root: Path) -> None:
|
|
"""Remove abandoned git locks and aborted-fetch pack temps before fetching.
|
|
|
|
A crashed fetch can leave ``.git/shallow.lock`` (or another lock) behind, and every later
|
|
fetch then fails with "File exists". Aborted fetches on flaky lines also strand
|
|
``tmp_pack_*`` debris: unchecked it reached 6 GB and corrupted the pack dir (#93732).
|
|
A partial clone also gets its commit-graph-off keys re-applied (#127711).
|
|
"""
|
|
from hermes_cli.gitlock import clear_stale_git_locks, clear_stale_tmp_packs, settle_partial_clone_maintenance
|
|
|
|
for lock_path in clear_stale_git_locks(root):
|
|
print(f" (removed stale git lock: {lock_path})")
|
|
swept = clear_stale_tmp_packs(root)
|
|
if swept:
|
|
print(f" (removed {len(swept)} aborted-fetch pack temp file(s))")
|
|
settle_partial_clone_maintenance(root)
|
|
|
|
|
|
def report_pack_tidy(root: Path) -> None:
|
|
"""Spend the update's bounded slice on a partial clone's on-demand packs, and say what it did."""
|
|
from hermes_cli.git_pack_tidy import TIDY_BUDGET_SECONDS, tidy_partial_clone_packs
|
|
|
|
tidy = tidy_partial_clone_packs(root)
|
|
if tidy.erased or tidy.merged:
|
|
print(f" (git cleanup: erased {tidy.erased} duplicate pack(s), {tidy.freed_bytes / 1e6:.0f} MB freed;"
|
|
f" merged {tidy.merged}; {tidy.packs_left} left)")
|
|
if tidy.out_of_time:
|
|
print(f" (git cleanup stopped at its {TIDY_BUDGET_SECONDS}s limit; the next update continues it)")
|
|
|
|
|
|
def _refuses(git_cmd: list[str], root: Path, target: str, repository: str) -> bool:
|
|
"""`hermes update` would refuse ``target`` (see update_downgrade_guard), so --check must not offer it."""
|
|
from hermes_cli.update_downgrade_guard import would_refuse
|
|
from hermes_cli._subprocess_compat import NO_LAZY_FETCH_ENV, noninteractive_git_env
|
|
from hermes_cli.source_check import _github_predates_runtime
|
|
from hermes_constants import get_hermes_home
|
|
|
|
def git(*args: str) -> int:
|
|
try:
|
|
return _git(git_cmd, root, list(args), env={**noninteractive_git_env(), **NO_LAZY_FETCH_ENV},
|
|
timeout=20).returncode
|
|
except (OSError, subprocess.SubprocessError):
|
|
return -1
|
|
|
|
def remote(sha: str):
|
|
return _github_predates_runtime(repository, sha, get_hermes_home() / "source-checks" / "release-runtime.json")
|
|
return would_refuse(root, target, git=git, remote=remote)
|
|
|
|
|
|
def channel_compare_branch(selected_channel: str, git_cmd: list[str], root: Path) -> str | None:
|
|
"""Report a release-pinned channel's verdict, or return the branch to compare against.
|
|
|
|
``None`` means the verdict is printed (the channel pins a commit); exits 1 when the channel
|
|
cannot be resolved. An install riding its default channel only ever moves forward.
|
|
"""
|
|
from hermes_cli.config import get_config_path, require_readable_config_before_write
|
|
from hermes_cli.source_releases import resolve_source_target
|
|
from hermes_cli.update_channel import channel_record, rides_default_channel
|
|
|
|
print(f"→ Update channel: {selected_channel}")
|
|
record = channel_record(require_readable_config_before_write(get_config_path()), root)
|
|
forward_only = rides_default_channel(record, selected_channel, root)
|
|
try:
|
|
target = resolve_source_target(selected_channel, git_cmd, root, forward_only=forward_only)
|
|
except (OSError, ValueError, subprocess.SubprocessError) as exc:
|
|
print(f"✗ Could not resolve the {selected_channel} source channel: {exc}")
|
|
sys.exit(1)
|
|
if not target.commit:
|
|
return target.branch
|
|
if target.retired:
|
|
print(f"→ {selected_channel} retired; source destination: {target.channel}")
|
|
if target.ahead:
|
|
print(f"✓ No newer release: {target.label}.")
|
|
elif _uc()._capture_head_sha(git_cmd, root) == target.commit:
|
|
print(f"✓ Up to date with the latest release ({target.label}).")
|
|
elif _refuses(git_cmd, root, target.commit, target.repository):
|
|
# Before the rollback branch: a release this checkout contains is held too, not moved back to.
|
|
print(f"→ Holding at this build: {target.label} predates the gateway runtime your sessions use.")
|
|
print(" `hermes update` stays on this build; the next release that includes the runtime"
|
|
" installs normally (`hermes update --set-channel main` follows every commit).")
|
|
elif _git(git_cmd, root, ["merge-base", "--is-ancestor", target.commit, "HEAD"]).returncode == 0:
|
|
print(f"→ This checkout is newer than the selected release: {target.label}.")
|
|
print(" `hermes update` moves it back to that release"
|
|
" (`hermes update --set-channel main` follows every commit).")
|
|
else:
|
|
print(f"→ Selected release available: {target.label}")
|
|
print(" Run `hermes update` to install it.")
|
|
return None
|
|
|
|
|
|
def is_shallow_repository(git_cmd: list[str], root: Path) -> bool:
|
|
return _git(git_cmd, root, ["rev-parse", "--is-shallow-repository"]).stdout.strip() == "true"
|
|
|
|
|
|
def tracking_refspec(remote: str, branch: str) -> str:
|
|
"""Refspec that always writes ``<remote>/<branch>``, whatever ``remote.<remote>.fetch`` says.
|
|
|
|
Narrow clones (tag-pinned ``--single-branch``, #125112) map only the tag, so a by-name fetch
|
|
writes FETCH_HEAD and never the tracking ref the updater resolves. The ``+`` is load-bearing:
|
|
on a depth-1 clone the new tip is not a descendant of the old one.
|
|
"""
|
|
return f"+refs/heads/{branch}:refs/remotes/{remote}/{branch}"
|
|
|
|
|
|
def _fetch(git_cmd: list[str], root: Path, depth_args: list[str], remote: str, branch: str):
|
|
print(f"→ Fetching from {remote}...")
|
|
return _git(
|
|
git_cmd, root, ["fetch", *depth_args, remote, tracking_refspec(remote, branch)],
|
|
**_uc()._no_prompt_git_kwargs())
|
|
|
|
|
|
def fetch_compare_branch(git_cmd: list[str], root: Path, branch: str, depth_args: list[str]):
|
|
"""Fetch only ``branch`` and return ``(fetch_result, compare_ref)``.
|
|
|
|
A bare ``git fetch <remote>`` pulls every ref, and this repo has thousands of auto-generated
|
|
branches. ``main`` prefers upstream as the canonical reference; other branches go straight
|
|
to origin, because a fork's branch usually has no upstream counterpart.
|
|
"""
|
|
if branch == "main":
|
|
# A local probe (~6 ms) spares non-fork installs a failed network fetch (~0.3-1 s).
|
|
if _git(git_cmd, root, ["remote", "get-url", "upstream"]).returncode == 0:
|
|
fetch_result = _fetch(git_cmd, root, depth_args, "upstream", branch)
|
|
if fetch_result.returncode == 0:
|
|
return fetch_result, f"upstream/{branch}"
|
|
from hermes_cli.gitlock import fetch_with_partial_clone_recovery
|
|
# Marking the unmarked packs clears the git 2.53+ partial-clone pack-objects crash (#124272).
|
|
print("→ Fetching from origin...")
|
|
return fetch_with_partial_clone_recovery(
|
|
lambda gc, a: _git(gc, root, a, **_uc()._no_prompt_git_kwargs()),
|
|
git_cmd, ["fetch", *depth_args, "origin", tracking_refspec("origin", branch)], root), f"origin/{branch}"
|
|
|
|
|
|
def repair_shallow_grafts(root: Path) -> None:
|
|
"""Drop the stale ``.git/shallow`` grafts a depth-1 fetch leaves behind.
|
|
|
|
Git never removes old grafts; unpruned, the file keeps growing and merge-base / the
|
|
orphan-divergence heuristic stop working (#105951).
|
|
"""
|
|
from hermes_cli.gitlock import prune_stale_shallow_grafts, repair_broken_shallow_boundaries
|
|
|
|
repaired = repair_broken_shallow_boundaries(root)
|
|
if repaired:
|
|
print(f" (restored {repaired} broken shallow boundary(ies))")
|
|
pruned = prune_stale_shallow_grafts(root)
|
|
if pruned:
|
|
print(f" (pruned {pruned} stale shallow graft(s) left by past depth-1 checks)")
|
|
|
|
|
|
def compare_ref_exists(git_cmd: list[str], root: Path, compare_branch: str) -> bool:
|
|
# rev-list on a missing ref exits 128 and would surface a traceback; report it instead.
|
|
return _git(git_cmd, root, ["rev-parse", "--verify", "--quiet", compare_branch]).returncode == 0
|
|
|
|
|
|
def report_shallow_verdict(git_cmd: list[str], root: Path, compare_branch: str) -> None:
|
|
"""Report behind-ness without local history across the shallow boundary.
|
|
|
|
Compares tip SHAs (mirrors the banner's ``_check_via_local_git``), then asks the GitHub
|
|
compare API for the exact count: the remote graph is complete even when the local one is not.
|
|
"""
|
|
head_sha = _git(git_cmd, root, ["rev-parse", "HEAD"]).stdout.strip()
|
|
target_sha = _git(git_cmd, root, ["rev-parse", compare_branch]).stdout.strip()
|
|
if head_sha and target_sha and head_sha == target_sha:
|
|
print("✓ Already up to date.")
|
|
return
|
|
from hermes_cli.config import recommended_update_command
|
|
from hermes_cli.source_check import _github_compare_behind
|
|
|
|
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.")
|
|
|
|
|
|
def report_rev_list_verdict(git_cmd: list[str], root: Path, compare_branch: str) -> None:
|
|
rev_result = _git(git_cmd, root, ["rev-list", f"HEAD..{compare_branch}", "--count"], check=True)
|
|
behind = int(rev_result.stdout.strip())
|
|
if behind == 0:
|
|
print("✓ Already up to date.")
|
|
return
|
|
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 select_apply_target(args, branch: str, request: dict, *, git_cmd, stop) -> tuple:
|
|
"""Resolve the update's target before any tree write: ``(target_ref, release_sha,
|
|
target_is_head, repository)``; records branch/expected_sha/retirement on ``request``.
|
|
|
|
``target_is_head`` means an unchosen default subscription is already past the release,
|
|
so HEAD itself is the target. Exits 1 (after ``stop()``) when the channel is unresolvable.
|
|
"""
|
|
from copy import deepcopy
|
|
|
|
from hermes_cli.config import require_readable_config_before_write
|
|
from hermes_cli.release_channels import retrying_reads
|
|
from hermes_cli.source_releases import resolve_source_target
|
|
from hermes_cli.update_channel import channel_record, rides_default_channel
|
|
from hermes_cli.update_cmd_common import _record_stop
|
|
|
|
if getattr(args, "branch", None):
|
|
return f"origin/{branch}", None, False, None
|
|
root = _uc()._m().PROJECT_ROOT
|
|
selected = _uc()._update_run_channel(args)
|
|
original = deepcopy(channel_record(require_readable_config_before_write(
|
|
Path(request["home"]) / "config.yaml"), root))
|
|
print(f"→ Update channel: {selected}")
|
|
try:
|
|
with retrying_reads():
|
|
target = resolve_source_target(selected, git_cmd, root,
|
|
forward_only=rides_default_channel(original, selected, root))
|
|
except (OSError, ValueError, subprocess.SubprocessError) as exc:
|
|
print(f"✗ Could not resolve the {selected} source channel: {exc}. No update was applied.")
|
|
stop()
|
|
_record_stop("channel_unresolved")
|
|
sys.exit(1)
|
|
if target.retired:
|
|
print(f"→ {selected} retired; source destination: {target.channel}")
|
|
if not getattr(args, "channel", None) and original.get("channel", "main") == selected:
|
|
request["channel_retirement"] = {"original": original, "destination": target.channel}
|
|
if target.commit:
|
|
print(f"→ {'Release' if target.ahead else 'Latest release'}: {target.label}")
|
|
request["expected_sha"] = target.commit
|
|
if target.channel == "stable" and target.version and not target.ahead:
|
|
request["release_tag"] = f"v{target.version}" # verified on origin by the resolver
|
|
return target.commit, target.commit, target.ahead, target.repository
|
|
assert target.branch is not None # a SourceTarget without a commit names its branch
|
|
request["branch"] = target.branch
|
|
return f"origin/{target.branch}", None, False, target.repository
|
|
|
|
|
|
def fetch_release_tag(git_cmd: list[str], root: Path, request: dict) -> None:
|
|
"""Fetch a pinned release's own tag when this checkout lacks it.
|
|
|
|
The checkout's version is the newest release tag merged into HEAD (version_info), so a
|
|
tagless release fetch moved HEAD to vX.Y.Z while every reader -- /health, the install
|
|
stamp, the receipt, About -- still named the previous release (#143656). Best effort and
|
|
create-only: a fork or mirror without the tag still updates, and an existing local tag
|
|
never moves (the stable pin contract). Only that one tag: others drag in their histories.
|
|
"""
|
|
from hermes_cli._subprocess_compat import no_prompt_git_kwargs
|
|
from hermes_cli.update_cmd import NETWORK_GIT_TIMEOUT_SECONDS
|
|
|
|
tag = request.get("release_tag")
|
|
if not tag:
|
|
return
|
|
try:
|
|
if _git(git_cmd, root, ["rev-parse", "--verify", "--quiet", f"refs/tags/{tag}"]).returncode == 0:
|
|
return
|
|
fetched = _git(git_cmd, root, ["fetch", "--no-tags", "origin", f"refs/tags/{tag}:refs/tags/{tag}"],
|
|
timeout=NETWORK_GIT_TIMEOUT_SECONDS, **no_prompt_git_kwargs())
|
|
detail = (fetched.stderr or "").strip().splitlines()[-1:] if fetched.returncode else None
|
|
except (OSError, subprocess.SubprocessError) as exc: # custody refusals are OSErrors too
|
|
detail = [str(exc)]
|
|
if detail is not None:
|
|
# Not fatal (a mirror may lack the tag), but say so: without it every version reader keeps
|
|
# naming the previous release, the symptom this fetch exists to prevent.
|
|
reason = detail[0] if detail else "git fetch failed"
|
|
logger.warning("Could not fetch release tag %s: %s", tag, reason)
|
|
print(f"⚠ Could not fetch the {tag} tag ({reason}); the version shown may name the previous "
|
|
"release until the next update fetches it.")
|
|
|
|
|
|
def hold_channel_release_behind_runtime(git_cmd, target_ref: str, channel: str) -> str | None:
|
|
"""HEAD's sha when the channel's release predates the gateway runtime these stores use, else None.
|
|
|
|
The channel picked the release, the user did not ask for that commit: moving there is the
|
|
refused downgrade, so the update stays where it is and says why (#140729). An explicit
|
|
``--branch`` never reaches here and keeps the refusal in ``update_cmd._refuse_before_commit_point``.
|
|
"""
|
|
from hermes_cli.update_downgrade_guard import downgrade_refusal
|
|
if downgrade_refusal(git_cmd, _uc()._m().PROJECT_ROOT, target_ref) is None:
|
|
return None
|
|
head = _uc()._capture_head_sha(git_cmd, _uc()._m().PROJECT_ROOT)
|
|
if not head:
|
|
return None # HEAD unreadable: the caller's refusal still stops the move
|
|
# "newer", not "ahead": the release may sit beside HEAD's line rather than behind it (#140729).
|
|
print(f"→ This build is newer than the {channel} release {target_ref[:12]}: that release predates the "
|
|
"gateway runtime your sessions use, so there is nothing to update to yet.")
|
|
print(f" Hermes stays on {head[:12]} until a newer {channel} release ships. "
|
|
"To follow every commit instead: `hermes update --set-channel main`.")
|
|
return head
|