diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 0b01e9847c..2cfcabeda2 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -27,6 +27,7 @@ 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.. from hermes_cli.update_abort_recovery import ( # noqa: F401 @@ -589,205 +590,40 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False, ch record_refusal_receipt(refusal) sys.exit(2) - git_dir = _m().PROJECT_ROOT / ".git" - if not git_dir.exists(): + root = _m().PROJECT_ROOT + if not (root / ".git").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))") + 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: - from hermes_cli.source_releases import resolve_source_target - - print(f"→ Update channel: {selected_channel}") - try: - target = resolve_source_target(selected_channel, git_cmd, _m().PROJECT_ROOT) - except (OSError, ValueError, subprocess.SubprocessError) as exc: - print(f"✗ Could not resolve the {selected_channel} source channel: {exc}") - sys.exit(1) - if target.commit: - if target.retired: - print(f"→ {selected_channel} retired; source destination: {target.channel}") - if _capture_head_sha(git_cmd, _m().PROJECT_ROOT) == target.commit: - print(f"✓ Up to date with the latest release ({target.label}).") - else: - print(f"→ Selected release available: {target.label}") - print(" Run `hermes update` to install it.") + branch = _check.channel_compare_branch(selected_channel, git_cmd, root) + if branch is None: return - branch = target.branch - # Fetch only the branch we compare against; prefer upstream as the canonical - # reference. A bare `git fetch ` pulls every ref, and this repo has - # thousands of auto-generated branches, so scope the fetch to . - # Note: upstream/ 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" + # 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 [], ) - 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/ 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 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)") + _check.repair_shallow_grafts(root) - # Verify the compare ref actually exists before asking rev-list about it. - # Without this, `git rev-list HEAD..origin/ --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: + 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: - # 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.source_check 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.") + _check.report_shallow_verdict(git_cmd, root, compare_branch) 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.") + _check.report_rev_list_verdict(git_cmd, root, compare_branch) def _base_git_cmd() -> list[str]: diff --git a/hermes_cli/update_cmd_check.py b/hermes_cli/update_cmd_check.py new file mode 100644 index 0000000000..c3e75f2722 --- /dev/null +++ b/hermes_cli/update_cmd_check.py @@ -0,0 +1,153 @@ +"""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 subprocess +import sys +from pathlib import Path +from typing import Any + + +def _git(git_cmd: list[str], root: Path, args: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + return subprocess.run( + 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). + """ + from hermes_cli.gitlock import clear_stale_git_locks, clear_stale_tmp_packs + + 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))") + + +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. + """ + from hermes_cli.source_releases import resolve_source_target + + print(f"→ Update channel: {selected_channel}") + try: + target = resolve_source_target(selected_channel, git_cmd, root) + 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 _uc()._capture_head_sha(git_cmd, root) == target.commit: + print(f"✓ Up to date with the latest release ({target.label}).") + 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 _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, 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 `` 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}" + return _fetch(git_cmd, root, depth_args, "origin", branch), 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.")