What: _sync_with_upstream_if_needed compiles the upstream commit's startup inventory before its
fast-forward (the origin target's preflight, applied to the second move) and raises
UpstreamTargetBroken without moving; both callers roll the update back the way the post-pull
guard did (the late one checks the branch first, so an unexpected branch's ref is never reset).
A later move of the run arms its obligations for its pre commit (debt_sha_for_move): every
landing of a fast-forward contains it and the fleet discharge holds the gateways to the actual
HEAD, so a failed merge needs no retarget write. The merge verifies HEAD is the counted commit
before reporting success.
Why: the fork ff dropped its marker on git's exit 0 and only the caller's later guard judged the
code, so a kill in between left a broken commit with no recovery record (G1); a failed merge's
retarget to the pulled commit could itself fail and leave the debt naming the unmerged commit
with HEAD on the pulled one (O4, the residual of F23).
Changed test: test_update_syntax_failure_restores_pre_update_head pinned the post-pull guard
catching the upstream target ("Pulled code has a syntax error") and the broken commit landing on
the unexpected branch; the target is now refused before its move, so it asserts the refusal
text and that the broken commit never landed. Outcomes (exit 1, HEAD back at base, stash) hold.
Tests (RED at fd5c37f17bf): a refused retarget leaves the debt on the pulled commit; a broken
upstream target is refused with HEAD unmoved and no marker.
723 lines
38 KiB
Python
723 lines
38 KiB
Python
"""Git plumbing for ``hermes update``: fork/upstream sync, trampoline-git detection, lockfile/EOL churn cleanup, orphan rescue refs, parked-branch assessment, fetch-failure classification.
|
||
|
||
Split out of ``update_cmd.py``, which re-imports every name so ``hermes_cli.update_cmd.<name>``
|
||
still resolves/monkeypatches. Origin helpers are imported lazily per function (no cycle;
|
||
test patches on ``update_cmd`` stay effective).
|
||
"""
|
||
|
||
import logging
|
||
from contextlib import nullcontext, suppress
|
||
import subprocess
|
||
import sys
|
||
from datetime import datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
from hermes_cli._subprocess_compat import NO_LAZY_FETCH_ENV, noninteractive_git_env, windows_hide_flags
|
||
|
||
logger = logging.getLogger("hermes_cli.update_cmd") # log-record parity with the origin module
|
||
|
||
_ORPHAN_RESCUE_REFS_TO_KEEP = 10
|
||
_ORPHAN_RESCUE_REF_MAX_AGE_DAYS = 30
|
||
|
||
# creationflags is folded in here so every ``**_GIT_TEXT_KW`` spawn (rev-parse label,
|
||
# fork-bomb probe, EOL churn normalization) hides its console under the console-less
|
||
# desktop backend (#117781).
|
||
_GIT_TEXT_KW = dict(capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||
creationflags=windows_hide_flags())
|
||
_BAR = "=" * 68
|
||
_UPSTREAM_ADD_CMD = "git remote add upstream https://github.com/NousResearch/hermes-agent.git"
|
||
|
||
|
||
def _git_ok(git_cmd, args, cwd, **kw) -> bool:
|
||
"""True when ``_git_run`` exits 0; any exception counts as failure."""
|
||
return _git_stdout(git_cmd, args, cwd, **kw) is not None
|
||
|
||
|
||
def _git_run(git_cmd, args, cwd=None, *, check=False, env=None):
|
||
"""Run ``git_cmd + args`` and return the CompletedProcess.
|
||
|
||
The updater's git runner: capture all output and decode as UTF-8 regardless of the
|
||
Windows ANSI code page (#52649). ``check=True`` raises on non-zero exit. The spawn
|
||
always hides its console window (#117781). ``env`` replaces the child's environment.
|
||
"""
|
||
from hermes_cli.update_custody import run_git
|
||
|
||
# THE custody policy (R2): stash push / reset --hard keep the update's lock fd, fetch does not.
|
||
return run_git(
|
||
git_cmd, list(args),
|
||
cwd=cwd,
|
||
capture_output=True,
|
||
text=True, encoding="utf-8", errors="replace",
|
||
check=check,
|
||
creationflags=windows_hide_flags(),
|
||
env=env,
|
||
)
|
||
|
||
|
||
def _git_stdout(git_cmd, args, cwd, **kw) -> Optional[str]:
|
||
"""Stripped stdout of a successful ``_git_run``; ``None`` on non-zero exit or any exception."""
|
||
from hermes_cli.update_cmd import _git_run
|
||
with suppress(Exception):
|
||
result = _git_run(git_cmd, args, cwd, **kw)
|
||
if result.returncode == 0:
|
||
return result.stdout.strip()
|
||
return None
|
||
|
||
|
||
def _prune_orphan_rescue_refs(
|
||
git_cmd, cwd, branch, keep=_ORPHAN_RESCUE_REFS_TO_KEEP, max_age_days=_ORPHAN_RESCUE_REF_MAX_AGE_DAYS
|
||
) -> None:
|
||
"""Expire old rescue refs (``refs/hermes-update-backups/<kind>-<branch>-<ts>-<sha>``).
|
||
|
||
``<kind>`` is ``orphan`` (no common ancestor), ``diverged`` (local commits on the target
|
||
branch) or ``detached`` (commits made on a detached HEAD the update moved off). All are written
|
||
before the update moves HEAD and all pin objects, so they expire on the same terms; each kind
|
||
keeps its own ``keep`` newest.
|
||
|
||
Each ref pins a possibly multi-GB snapshot against ``git gc``, so a repeatedly corrupted install would
|
||
grow ``.git`` unbounded. Keep the ``keep`` newest AND drop any older than ``max_age_days`` by the
|
||
``YYYYMMDD-HHMMSS`` stamp (unparseable names left alone); names sort chronologically so
|
||
``for-each-ref`` order is creation order. Best-effort, never blocks.
|
||
|
||
A rescue ref pins every object reachable from that commit against ``git gc`` — and in the incident shape
|
||
those objects include a full working-tree snapshot (the autostash orphan commit), which can be multi-GB
|
||
when the tree holds large stray files. See #87694.
|
||
"""
|
||
from hermes_cli.update_cmd_git import _git_run
|
||
with suppress(OSError):
|
||
stale: set[str] = set()
|
||
for kind in ("orphan", "diverged", "detached"):
|
||
prefix = f"refs/hermes-update-backups/{kind}-{branch}-"
|
||
list_result = _git_run(
|
||
git_cmd, ["for-each-ref", "--format=%(refname)", "--sort=refname", f"{prefix}*"], cwd)
|
||
if list_result.returncode != 0:
|
||
continue
|
||
refs = [line.strip() for line in list_result.stdout.splitlines() if line.strip()]
|
||
stale |= set(refs[:-keep] if keep > 0 else refs)
|
||
if max_age_days > 0:
|
||
cutoff = datetime.now(timezone.utc) - timedelta(days=max_age_days)
|
||
for ref in refs:
|
||
with suppress(ValueError):
|
||
stamp = datetime.strptime(ref[len(prefix):][:15], "%Y%m%d-%H%M%S")
|
||
if stamp.replace(tzinfo=timezone.utc) < cutoff:
|
||
stale.add(ref)
|
||
for ref in sorted(stale):
|
||
_git_run(git_cmd, ["update-ref", "-d", ref], cwd)
|
||
|
||
|
||
def _park_detached_head(git_cmd, cwd, branch) -> None:
|
||
"""Keep commits made on a detached HEAD reachable before the update moves HEAD off it.
|
||
|
||
Such commits belong to no branch: once HEAD moves, only the expiring reflog still reaches them,
|
||
and nothing in the output would name them. When HEAD is detached at a commit no ref contains
|
||
(the autostash's ``refs/stash`` does not count: it is dropped after the update), write
|
||
``refs/hermes-update-backups/detached-<branch>-<ts>-<sha12>`` (the divergence rescue refs'
|
||
scheme and expiry) and name it. When that write fails, refuse (``sys.exit(1)``) rather than
|
||
orphan the work. Attached HEADs and already-reachable commits are left alone.
|
||
"""
|
||
from hermes_cli.update_cmd import _git_run
|
||
if _git_run(git_cmd, ["symbolic-ref", "-q", "HEAD"], cwd).returncode == 0:
|
||
return # on a branch
|
||
head = _git_run(git_cmd, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], cwd)
|
||
sha = (head.stdout or "").strip()
|
||
if head.returncode != 0 or not sha:
|
||
return
|
||
contains = _git_run(git_cmd, ["for-each-ref", "--contains", sha, "--format=%(refname)"], cwd)
|
||
holders = [r for r in (contains.stdout or "").split() if r != "refs/stash"]
|
||
if contains.returncode == 0 and holders:
|
||
return # already reachable from a branch, tag, remote or backup ref
|
||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||
rescue_ref = f"refs/hermes-update-backups/detached-{branch}-{stamp}-{sha[:12]}"
|
||
if _git_run(git_cmd, ["update-ref", rescue_ref, sha], cwd).returncode != 0:
|
||
print(f"✗ HEAD is detached at {sha[:12]}, which no branch or tag contains, and backing it up "
|
||
f"to {rescue_ref} failed.")
|
||
print(f" Update stopped so those commits are not orphaned. Keep them with: "
|
||
f"git -C {cwd} branch <name> {sha[:12]}")
|
||
sys.exit(1)
|
||
count = (_git_run(git_cmd, ["rev-list", "--count", sha, "--not", "--branches", "--tags", "--remotes"],
|
||
cwd).stdout or "").strip()
|
||
print(f" ⚠ {count or 'Some'} commit(s) made on the detached HEAD are on no branch — backed up to "
|
||
f"{rescue_ref} before moving HEAD. This backup expires after {_ORPHAN_RESCUE_REF_MAX_AGE_DAYS} days.")
|
||
print(f" List them with: git log {rescue_ref} --not --branches --tags --remotes")
|
||
_prune_orphan_rescue_refs(git_cmd, cwd, branch)
|
||
|
||
|
||
def _branch_head_label(git_cmd=None, cwd=None) -> str | None:
|
||
"""``"<branch> @ <short-sha>"`` for the checkout (``detached`` when not on a branch), or None. Never raises.
|
||
|
||
Appended to summary lines so a checkout parked on a stale branch is visible."""
|
||
from hermes_cli.update_cmd import _m
|
||
from hermes_cli.update_custody import run_git
|
||
try:
|
||
cmd = list(git_cmd) if git_cmd else ["git"]
|
||
root = cwd if cwd is not None else _m().PROJECT_ROOT
|
||
|
||
def _rev_parse(*args):
|
||
return run_git(cmd, ["rev-parse", *args], cwd=root, **_GIT_TEXT_KW)
|
||
|
||
branch, sha = _rev_parse("--abbrev-ref", "HEAD"), _rev_parse("--short", "HEAD")
|
||
branch_name, sha_text = branch.stdout.strip(), sha.stdout.strip()
|
||
if branch.returncode != 0 or sha.returncode != 0 or not sha_text or not branch_name:
|
||
return None
|
||
return f"{'detached' if branch_name == 'HEAD' else branch_name} @ {sha_text}"
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _branch_head_suffix(git_cmd=None, cwd=None) -> str:
|
||
"""`` [<branch> @ <sha>]`` suffix for summary lines ("" when unknown)."""
|
||
label = _branch_head_label(git_cmd, cwd)
|
||
return f" [{label}]" if label else ""
|
||
|
||
|
||
def _assess_parked_branch_switch(git_cmd: list[str], cwd: Path, current_branch: str, target_branch: str) -> tuple[bool, str]:
|
||
"""Decide whether a parked feature branch may be auto-switched back to the update target.
|
||
|
||
- (True, "") — tree clean and every parked commit is in ``origin/<target>`` (no ``git cherry +``).
|
||
- (True, "unmerged:<n>") — tree clean but commits not in target; switching is safe (checkout keeps
|
||
committed work) but caller must print a LOUD notice. Non-interactive callers (desktop, gateway
|
||
/update, cron) can't resolve a skip, so a clean checkout must reach target.
|
||
- (False, "disabled"|"dirty"|"unverifiable") — caller must NOT touch the branch. Dirty is the
|
||
genuinely unsafe case: uncommitted work riding an autostash across branches.
|
||
A config read failure must not disable the safety checks: fall through with the default."""
|
||
from hermes_cli.update_cmd_git import _git_run
|
||
try:
|
||
from hermes_cli.config import load_config
|
||
_update_cfg = (load_config() or {}).get("updates", {})
|
||
if isinstance(_update_cfg, dict) and not bool(_update_cfg.get("auto_switch_parked_branch", True)):
|
||
return False, "disabled"
|
||
except Exception as exc:
|
||
logger.debug("Could not read updates.auto_switch_parked_branch: %s", exc)
|
||
status = _git_run(git_cmd, ["status", "--porcelain"], cwd)
|
||
if status.returncode != 0:
|
||
return False, "unverifiable"
|
||
if status.stdout.strip():
|
||
return False, "dirty"
|
||
# Count parked-only commits from the commit graph first: rev-list needs no trees,
|
||
# so a treeless (tree:0) partial clone answers from local objects. `git cherry`'s
|
||
# patch-id walk needs upstream-side trees and lazy-fetches them in many promisor
|
||
# batches; one failed batch failed the whole verification and skipped a clean
|
||
# checkout (#124767).
|
||
ahead = _git_run(git_cmd, ["rev-list", "--count", f"origin/{target_branch}..HEAD"], cwd)
|
||
if ahead.returncode != 0 or not ahead.stdout.strip().isdigit():
|
||
return False, "unverifiable"
|
||
ahead_count = int(ahead.stdout.strip())
|
||
if ahead_count == 0:
|
||
return True, ""
|
||
# Git before 2.44 ignores GIT_NO_LAZY_FETCH, so on a partial clone cherry still lazy-fetches
|
||
# without bound (git 2.43 on WSL hung here, #124767). Its patch-ids need blobs a partial clone
|
||
# keeps on the remote, so it rarely refines anything there: settle for the commit count.
|
||
promisor = _git_run(git_cmd, ["config", "--get-regexp", r"^(remote\..*\.promisor|extensions\.partialclone)$"], cwd)
|
||
if any(line.split()[-1].lower() != "false" for line in promisor.stdout.splitlines()):
|
||
return True, f"unmerged:{ahead_count}"
|
||
# The patch-id refinement must never fetch: on a tree:0 clone `git cherry` lazy-fetches
|
||
# a tree batch per commit and, with nothing bounding it, one such walk wrote 332 packs /
|
||
# 180 GiB over 7 h on Windows (#131444). With lazy fetch off a missing object fails the
|
||
# command fast and the commit count below stands in.
|
||
cherry = _git_run(git_cmd, ["cherry", f"origin/{target_branch}"], cwd,
|
||
env={**noninteractive_git_env(), **NO_LAZY_FETCH_ENV})
|
||
if cherry.returncode != 0:
|
||
# Patch-equivalence only refines the count for rebase/squash-merged branches;
|
||
# a partial clone whose lazy fetch failed must not block a clean checkout,
|
||
# so degrade to the conservative commit count instead of "unverifiable".
|
||
return True, f"unmerged:{ahead_count}"
|
||
unmerged = [line for line in cherry.stdout.splitlines() if line.startswith("+")]
|
||
return True, f"unmerged:{len(unmerged)}" if unmerged else ""
|
||
|
||
|
||
_PARKED_SKIP_WHY = {
|
||
"dirty": "the working tree has uncommitted changes",
|
||
"disabled": "updates.auto_switch_parked_branch is set to false in config.yaml",
|
||
}
|
||
|
||
|
||
def _print_parked_branch_skip_warning(git_cmd: list[str], cwd: Path, current_branch: str, target_branch: str, reason: str) -> None:
|
||
"""LOUD block: why the update was skipped on a parked branch, behind-count, fix commands."""
|
||
behind = None
|
||
with suppress(Exception):
|
||
behind_text = _git_stdout(git_cmd, ["rev-list", f"HEAD..origin/{target_branch}", "--count"], cwd)
|
||
if behind_text:
|
||
behind = int(behind_text)
|
||
why = _PARKED_SKIP_WHY.get(reason, f"the branch state could not be verified against origin/{target_branch}")
|
||
print(f"\n{_BAR}\n⚠ CODE UPDATE SKIPPED — checkout is parked on '{current_branch}'")
|
||
print(f" Not auto-switching to {target_branch}: {why}.")
|
||
if behind is not None and behind > 0:
|
||
print(f" This checkout is {behind} commit(s) BEHIND origin/{target_branch} — the code you are running is stale.")
|
||
print(
|
||
f"\n To resolve, inspect the branch and switch back yourself:\n"
|
||
f" git -C {cwd} status\n"
|
||
f" git -C {cwd} checkout {target_branch} && hermes update\n"
|
||
f" (commit or stash your work on the branch first if you want to keep it)\n{_BAR}"
|
||
)
|
||
|
||
|
||
def _print_parked_branch_kept_notice(current_branch: str, target_branch: str, unmerged_count: str) -> None:
|
||
"""LOUD notice when a clean parked branch with unmerged commits is auto-switched.
|
||
|
||
Non-interactive callers can't resolve a skip, so we proceed — but the unmerged work
|
||
(still safe on its branch) must be impossible to miss."""
|
||
print(
|
||
f"\n{_BAR}\n"
|
||
f"⚠ Checkout was parked on '{current_branch}' with "
|
||
f"{unmerged_count} commit(s) not merged into origin/{target_branch}.\n"
|
||
f" Switching to {target_branch} so the update can proceed — your "
|
||
f"commit(s) are safe on '{current_branch}'.\n\n"
|
||
f" To pick the work back up later:\n git checkout {current_branch}\n{_BAR}"
|
||
)
|
||
|
||
|
||
OFFICIAL_REPO_URLS = {
|
||
"https://github.com/NousResearch/hermes-agent.git",
|
||
"git@github.com:NousResearch/hermes-agent.git",
|
||
"https://github.com/NousResearch/hermes-agent",
|
||
"git@github.com:NousResearch/hermes-agent",
|
||
}
|
||
OFFICIAL_REPO_URL = "https://github.com/NousResearch/hermes-agent.git"
|
||
SKIP_UPSTREAM_PROMPT_FILE = ".skip_upstream_prompt"
|
||
|
||
|
||
def _get_origin_url(git_cmd: list[str], cwd: Path) -> Optional[str]:
|
||
"""Get the URL of the origin remote, or None if not set."""
|
||
return _git_stdout(git_cmd, ["remote", "get-url", "origin"], cwd)
|
||
|
||
|
||
def _is_fork(origin_url: Optional[str]) -> bool:
|
||
"""Check if the origin remote points to a fork (not the official repo)."""
|
||
if not origin_url:
|
||
return False
|
||
|
||
def _norm(url: str) -> str:
|
||
url = url.rstrip("/")
|
||
return url[:-4] if url.endswith(".git") else url
|
||
|
||
return _norm(origin_url) not in {_norm(official) for official in OFFICIAL_REPO_URLS}
|
||
|
||
|
||
def _has_upstream_remote(git_cmd: list[str], cwd: Path) -> bool:
|
||
"""Check if an 'upstream' remote already exists."""
|
||
return _git_ok(git_cmd, ["remote", "get-url", "upstream"], cwd)
|
||
|
||
|
||
def _add_upstream_remote(git_cmd: list[str], cwd: Path) -> bool:
|
||
"""Add the official repo as the 'upstream' remote. Returns True on success."""
|
||
return _git_ok(git_cmd, ["remote", "add", "upstream", OFFICIAL_REPO_URL], cwd)
|
||
|
||
|
||
def _count_commits_between(git_cmd: list[str], cwd: Path, base: str, head: str) -> int:
|
||
"""Count commits on `head` that are not on `base`. Returns -1 on error."""
|
||
with suppress(Exception):
|
||
count = _git_stdout(git_cmd, ["rev-list", "--count", f"{base}..{head}"], cwd)
|
||
if count is not None:
|
||
return int(count)
|
||
return -1
|
||
|
||
|
||
def _should_skip_upstream_prompt() -> bool:
|
||
"""Check if user previously declined to add upstream."""
|
||
from hermes_constants import get_hermes_home
|
||
return (get_hermes_home() / SKIP_UPSTREAM_PROMPT_FILE).exists()
|
||
|
||
|
||
def _mark_skip_upstream_prompt():
|
||
"""Create marker file to skip future upstream prompts."""
|
||
with suppress(Exception):
|
||
from hermes_constants import get_hermes_home
|
||
(get_hermes_home() / SKIP_UPSTREAM_PROMPT_FILE).touch()
|
||
|
||
|
||
def _sync_fork_with_upstream(git_cmd: list[str], cwd: Path) -> bool:
|
||
"""Push updated main to origin (sync fork); True on success."""
|
||
return _git_ok(git_cmd, ["push", "origin", "main", "--force-with-lease"], cwd, network=True)
|
||
|
||
|
||
def _offer_upstream_remote(git_cmd: list[str], cwd: Path, *, assume_yes: bool, input_fn) -> bool:
|
||
"""Prompt to add ``upstream`` and add it; False when the user declined, the run is non-interactive, or add failed.
|
||
|
||
``--yes`` means "don't block", not "mutate my remotes", so a non-interactive skip is NOT persisted."""
|
||
from hermes_cli.update_cmd import _add_upstream_remote, _mark_skip_upstream_prompt
|
||
print(
|
||
"\nℹ Your fork is not tracking the official Hermes repository.\n"
|
||
" This means you may miss updates from NousResearch/hermes-agent.\n"
|
||
)
|
||
if assume_yes or (input_fn is None and not (sys.stdin.isatty() and sys.stdout.isatty())):
|
||
print(f" Skipping upstream setup (non-interactive run).\n Add it later with: {_UPSTREAM_ADD_CMD}")
|
||
return False
|
||
if input_fn is not None:
|
||
response = input_fn("Add official repo as 'upstream' remote? [y/N]", "n").strip().lower()
|
||
else:
|
||
try:
|
||
response = input("Add official repo as 'upstream' remote? [Y/n]: ").strip().lower()
|
||
except (EOFError, KeyboardInterrupt, UnicodeDecodeError):
|
||
print()
|
||
response = "n"
|
||
if response not in {"", "y", "yes"}:
|
||
print(f" Skipped. Run '{_UPSTREAM_ADD_CMD}' to add later.")
|
||
_mark_skip_upstream_prompt()
|
||
return False
|
||
print("→ Adding upstream remote...")
|
||
if not _add_upstream_remote(git_cmd, cwd):
|
||
print(" ✗ Failed to add upstream remote. Skipping upstream sync.")
|
||
return False
|
||
print(" ✓ Added upstream: https://github.com/NousResearch/hermes-agent.git")
|
||
return True
|
||
|
||
|
||
class UpstreamTargetBroken(Exception):
|
||
"""The fork sync's upstream target fails the startup syntax check: refused before its move."""
|
||
|
||
def __init__(self, sha: str, path: str, error: str) -> None:
|
||
super().__init__(f"upstream/main ({sha[:10]}) has a syntax error in {path}")
|
||
self.sha, self.path, self.error = sha, path, error
|
||
|
||
|
||
def _sync_with_upstream_if_needed(git_cmd: list[str], cwd: Path, *, assume_yes: bool = False, input_fn=None,
|
||
checkout_move=None) -> bool:
|
||
"""Offer to add ``upstream``, compare origin/main vs upstream/main, ff-pull when strictly behind, then push origin.
|
||
|
||
Returns True only when origin/main was actually verified against upstream/main; False when the check never
|
||
happened, so the caller never reports "up to date" on an origin-only compare. Fetches only upstream/main:
|
||
a bare fetch drags in thousands of auto-generated branches. ``UpstreamTargetBroken`` when the
|
||
upstream commit fails the startup syntax check: nothing moved, the caller rolls the update back.
|
||
|
||
``checkout_move(target)`` is the caller's context for the one tree write (the merge): the
|
||
paused gateways' tree gate is bound to *target* before git writes a file.
|
||
|
||
See #97052.
|
||
"""
|
||
from hermes_cli.update_cmd import _count_commits_between, _has_upstream_remote, _no_prompt_git_kwargs, _should_skip_upstream_prompt
|
||
from hermes_cli.update_cmd_check import tracking_refspec
|
||
from hermes_cli.update_custody import run_git
|
||
if not _has_upstream_remote(git_cmd, cwd) and (
|
||
_should_skip_upstream_prompt() or not _offer_upstream_remote(git_cmd, cwd, assume_yes=assume_yes, input_fn=input_fn)
|
||
):
|
||
return False
|
||
print("\n→ Fetching upstream...")
|
||
try:
|
||
run_git(git_cmd, ["fetch", "upstream", tracking_refspec("upstream", "main"), "--quiet"], cwd=cwd, capture_output=True, check=True, **_no_prompt_git_kwargs())
|
||
except subprocess.CalledProcessError:
|
||
print(" ✗ Failed to fetch upstream. Skipping upstream sync.")
|
||
return False
|
||
# One commit for the whole sync (m2): the short `upstream/main` also names a local branch of
|
||
# that name, so the count and the merge could each see a different commit.
|
||
upstream = _git_stdout(git_cmd, ["rev-parse", "-q", "--verify", "refs/remotes/upstream/main^{commit}"], cwd)
|
||
origin_ahead = _count_commits_between(git_cmd, cwd, upstream, "refs/remotes/origin/main") if upstream else -1
|
||
upstream_ahead = _count_commits_between(git_cmd, cwd, "refs/remotes/origin/main", upstream) if upstream else -1
|
||
if origin_ahead < 0 or upstream_ahead < 0:
|
||
print(" ✗ Could not compare branches. Skipping upstream sync.")
|
||
return False
|
||
if origin_ahead > 0:
|
||
print(
|
||
f"\nℹ Your fork has {origin_ahead} commit(s) not on upstream.\n"
|
||
" Skipping upstream sync to preserve your changes.\n"
|
||
" If you want to merge upstream changes, run:\n git pull upstream main"
|
||
)
|
||
return True
|
||
if upstream_ahead == 0:
|
||
print(" ✓ Fork is up to date with upstream")
|
||
return True
|
||
print(f"\n→ Fork is {upstream_ahead} commit(s) behind upstream\n→ Pulling from upstream...")
|
||
# A tree move like the pull itself: the marker and the owed tail are armed before git writes,
|
||
# and the just-fetched ref is merged (a second `git pull` fetch could move past the marker's target).
|
||
from hermes_cli import update_cmd_commit as _commit
|
||
from hermes_cli._early_recovery import interrupted_pull_marker, is_object_id
|
||
pre = _git_stdout(git_cmd, ["rev-parse", "HEAD"], cwd)
|
||
if not is_object_id(pre):
|
||
# The marker's ``pre`` is what a killed move is restored to: never arm ``pre=``.
|
||
print(" ✗ Could not resolve HEAD. Skipping upstream sync.")
|
||
return False
|
||
target = upstream # the counted commit is the marker's target and the merge's (m2)
|
||
# Judged BEFORE the move, like the origin target's preflight: the marker goes on git's exit 0,
|
||
# so a broken target must never land on the strength of the caller's later guard (review G1).
|
||
from hermes_cli.update_cmd import _UPDATE_CRITICAL_FILES
|
||
if broken := _commit.target_syntax_error(git_cmd, cwd, target, _UPDATE_CRITICAL_FILES):
|
||
raise UpstreamTargetBroken(target, *broken)
|
||
refused = _commit.arm_commit_point(git_cmd, cwd, _commit.debt_sha_for_move(pre, target), pre=pre,
|
||
target=target, stash=None)
|
||
try:
|
||
# The fetch above already brought upstream/main: a local fast-forward (no network, so no
|
||
# credential helper is started under the checkout lock fd a mutator inherits) to the
|
||
# very commit counted above.
|
||
if refused:
|
||
raise subprocess.CalledProcessError(1, "merge", stderr=refused) # no marker, no move
|
||
with (checkout_move or _no_move)(upstream):
|
||
run_git(git_cmd, ["merge", "--ff-only", upstream], cwd=cwd, check=True, **_no_prompt_git_kwargs())
|
||
except (OSError, subprocess.SubprocessError) as exc: # CustodyRefused is an OSError; a timeout too
|
||
if refused or _commit.settle_failed_tree_move(cwd):
|
||
# Back at ``pre``; that is still new code when the origin pull moved first, and disarm
|
||
# then owes the restart for it instead of ``target`` (it hands obligations back only at
|
||
# the run's start commit).
|
||
_commit.disarm_commit_obligations()
|
||
else:
|
||
_commit.owe_restore_of(cwd, pre) # torn under its marker: the restore lands on ``pre``
|
||
print(" ✗ Failed to pull from upstream. You may need to resolve conflicts manually.")
|
||
if detail := (getattr(exc, "stderr", None) or ("" if isinstance(exc, subprocess.CalledProcessError) else str(exc))):
|
||
print(f" ({detail})")
|
||
return False
|
||
interrupted_pull_marker(cwd).unlink(missing_ok=True) # git exited 0: whole at what it landed on
|
||
if (landed := _git_stdout(git_cmd, ["rev-parse", "-q", "--verify", "HEAD"], cwd)) != upstream:
|
||
# Not the commit the debt was armed for (O3's check, here): owed for the HEAD it is on, or
|
||
# handed back at the run's start commit; the caller's HEAD check refuses the update.
|
||
_commit.disarm_commit_obligations()
|
||
print(f" ✗ HEAD is on {(landed or '?')[:10]} after the upstream merge, not {upstream[:10]}.")
|
||
return False
|
||
print(" ✓ Updated from upstream")
|
||
# The fork push waits for a validated update: ``_push_synced_fork`` after the syntax guard.
|
||
return True
|
||
|
||
|
||
def _push_synced_fork(git_cmd: list[str], cwd: Path) -> None:
|
||
"""Publish an upstream sync to the fork's main once the update is validated.
|
||
|
||
Pushes only when local HEAD is upstream/main and strictly ahead of origin/main, i.e. this run
|
||
(or an earlier one whose push failed) fast-forwarded main from upstream.
|
||
"""
|
||
head = _git_stdout(git_cmd, ["rev-parse", "HEAD"], cwd)
|
||
# Full ref names: a local branch called upstream/main or origin/main must not answer (m2).
|
||
upstream = _git_stdout(git_cmd, ["rev-parse", "-q", "--verify", "refs/remotes/upstream/main^{commit}"], cwd)
|
||
origin = _git_stdout(git_cmd, ["rev-parse", "-q", "--verify", "refs/remotes/origin/main^{commit}"], cwd)
|
||
if not head or head != upstream or head == origin:
|
||
return
|
||
if origin and not _git_ok(git_cmd, ["merge-base", "--is-ancestor", origin, head], cwd):
|
||
return
|
||
print("→ Syncing fork...")
|
||
if _sync_fork_with_upstream(git_cmd, cwd):
|
||
print(" ✓ Fork synced with upstream")
|
||
else:
|
||
print(
|
||
" ℹ Got updates from upstream but couldn't push to fork (no write access?)\n"
|
||
" Your local repo is updated, but your fork on GitHub may be behind."
|
||
)
|
||
|
||
|
||
def _has_http_code(stderr: str, *codes: str) -> bool:
|
||
return any(f"HTTP {code}" in stderr or f"returned error: {code}" in stderr for code in codes)
|
||
|
||
|
||
# Ordered (predicate, diagnosis): curl reports HTTP errors as ``unable to access '<url>': ... error: 429``,
|
||
# so rate-limit/outage checks must run BEFORE the generic "unable to access" check. An anonymous fetch
|
||
# answered with HTTP 401 ("could not read Username") is GitHub during an outage (or a renamed/private
|
||
# repo), not a user credentials problem.
|
||
_FETCH_FAILURE_RULES = (
|
||
(lambda s: _has_http_code(s, "429") or "rate limit" in s.lower(),
|
||
"✗ GitHub is rate limiting requests or having an outage (HTTP 429) — try again in 5 minutes."),
|
||
(lambda s: _has_http_code(s, "500", "502", "503", "504"),
|
||
"✗ GitHub appears to be having an outage — try again in a few minutes (https://www.githubstatus.com)."),
|
||
(lambda s: "Could not resolve host" in s or "unable to access" in s,
|
||
"✗ Network error — cannot reach the remote repository."),
|
||
(lambda s: "could not read Username" in s or "terminal prompts disabled" in s,
|
||
"✗ GitHub rejected the anonymous fetch (asked for a login) — this usually means a GitHub outage;"
|
||
" try again in a few minutes (https://www.githubstatus.com). If it persists, check"
|
||
" `git remote -v` points at a public repo."),
|
||
(lambda s: "Authentication failed" in s,
|
||
"✗ Authentication failed — check your git credentials or SSH key."),
|
||
# SSH auth failures never say "Authentication failed" — OpenSSH prints its own
|
||
# "Permission denied (publickey)"/"Host key verification failed" and git wraps
|
||
# it as "Could not read from remote repository", which otherwise fell through
|
||
# to the generic message below and left an SSH-remote user with no idea their
|
||
# key (or lack of one) was the cause (#82169).
|
||
(lambda s: "Permission denied (publickey)" in s or "Host key verification failed" in s,
|
||
"✗ SSH authentication failed — check your SSH key is added to GitHub, or switch"
|
||
" `origin` to HTTPS: `git remote set-url origin https://github.com/NousResearch/hermes-agent.git`."),
|
||
)
|
||
|
||
|
||
def _classify_fetch_failure(stderr: str) -> str:
|
||
"""Map git-fetch stderr to a one-line diagnosis (caller also prints the raw first line)."""
|
||
return next((message for matches, message in _FETCH_FAILURE_RULES if matches(stderr)), "✗ Failed to fetch updates from origin.")
|
||
|
||
|
||
def _print_fetch_failure(stderr: str) -> None:
|
||
"""Print the classified diagnosis plus the first raw stderr line."""
|
||
stderr = (stderr or "").strip()
|
||
print(_classify_fetch_failure(stderr))
|
||
if stderr:
|
||
print(f" {stderr.splitlines()[0]}")
|
||
|
||
|
||
def _probe_fork_bomb(argv: list) -> Optional[bool]:
|
||
"""Run ``<argv> --version``; True/False = guard message seen/absent, None = probe itself failed."""
|
||
try:
|
||
from hermes_cli.update_custody import run_git
|
||
|
||
result = run_git(argv, ["--version"], timeout=15, **_GIT_TEXT_KW)
|
||
except Exception:
|
||
return None
|
||
return "fork bomb" in ((result.stdout or "") + (result.stderr or "")).lower()
|
||
|
||
|
||
def _git_is_trampoline(git_cmd: list) -> bool:
|
||
"""Whether *git_cmd* is a broken Git-for-Windows trampoline shim.
|
||
|
||
The ~46KB ``bin\\git.exe``/``cmd\\git.exe`` shims re-exec git-core; when they can't find it every call
|
||
dies with the launcher's guard message (a PATH problem, not network). Never raises; unknown states
|
||
report False so a probe failure can't block an update.
|
||
|
||
Git for Windows ships two ~46KB shims (``bin\\git.exe``, ``cmd\\git.exe``) that re-exec the real
|
||
``mingw64\\libexec\\git-core\\git.exe``. See #87876.
|
||
"""
|
||
return _probe_fork_bomb(git_cmd) is True
|
||
|
||
|
||
def _portable_git_candidates() -> list:
|
||
"""PortableGit candidates: shared root first (where the managed tree actually lives, not the
|
||
profile-scoped HERMES_HOME), then profile home as a fallback for custom layouts.
|
||
|
||
The Hermes-managed PortableGit tree lives under the SHARED root (``<root>/git/...``), not the
|
||
profile-scoped HERMES_HOME (``<root>/profiles/<name>``), so a profile-scoped ``hermes update`` must look
|
||
there (monerostar review, #87876).
|
||
"""
|
||
from hermes_constants import get_default_hermes_root, get_hermes_home
|
||
candidates = []
|
||
with suppress(Exception):
|
||
candidates += [root / "git" / "mingw64" / "libexec" / "git-core" / "git.exe" for root in (get_default_hermes_root(), Path(get_hermes_home()))]
|
||
return candidates
|
||
|
||
|
||
def _locate_real_git() -> Optional[Path]:
|
||
"""Find a real Git-for-Windows ``git-core/git.exe`` (standard locations + managed PortableGit) that runs
|
||
without the trampoline guard. None when nothing suits — callers keep the broken command and let the
|
||
fetch-failure ZIP fallback handle it. A failed probe (None) disqualifies a candidate like a guard hit.
|
||
|
||
The trampoline symptom is PATH-level: ``bin\\git.exe`` / ``cmd\\git.exe`` (both ~46KB shims) fail to
|
||
re-exec git-core, while the real binary at ``mingw64\\libexec\\git-core\\git.exe`` (≈4.4MB) works when
|
||
invoked directly (#87876).
|
||
"""
|
||
candidates = [
|
||
Path(base) / "Git" / arch / "libexec" / "git-core" / "git.exe"
|
||
for base in (r"C:\Program Files", r"C:\Program Files (x86)")
|
||
for arch in ("clangarm64", "mingw64", "mingw32")
|
||
] + _portable_git_candidates()
|
||
return next((c for c in candidates if c.exists() and _probe_fork_bomb([str(c)]) is False), None)
|
||
|
||
|
||
def _ensure_non_trampoline_git(git_cmd: list) -> list:
|
||
"""Swap a broken Git-for-Windows trampoline for a real git binary so fetch/pull/checkout keep working;
|
||
if none is found leave the command untouched (fetch-failure handler falls back to ZIP). No-op off
|
||
Windows and when git is healthy."""
|
||
from hermes_cli.update_cmd import _locate_real_git
|
||
if sys.platform != "win32" or not _git_is_trampoline(git_cmd):
|
||
return git_cmd
|
||
real_git = _locate_real_git()
|
||
if real_git is None:
|
||
print("⚠ Detected a broken git trampoline and could not locate a real git binary — the update will fall back to the ZIP path.")
|
||
return git_cmd
|
||
print(f"⚠ Detected a broken git trampoline; switching to real git at {real_git}")
|
||
return [str(real_git)] + list(git_cmd[1:])
|
||
|
||
|
||
def _npm_lockfile_owners(repo_root: Path) -> set[Path]:
|
||
"""Manifest directories whose specs the single root ``package-lock.json`` records: the root plus every
|
||
workspace from the root ``workspaces`` globs (same model as ``update_cmd_deps._npm_manifest_paths``).
|
||
A manifest outside that graph (``website/``, ``scripts/whatsapp-bridge/``) has its own lockfile."""
|
||
owners = {Path(".")}
|
||
try:
|
||
import json
|
||
package = json.loads((repo_root / "package.json").read_text(encoding="utf-8-sig"))
|
||
workspaces = package.get("workspaces", [])
|
||
if isinstance(workspaces, dict):
|
||
workspaces = workspaces.get("packages", [])
|
||
if not isinstance(workspaces, list):
|
||
return owners
|
||
for pattern in workspaces:
|
||
# One bad glob (absolute pattern -> NotImplementedError) degrades to "not an owner"
|
||
# instead of aborting the whole churn cleanup through the caller's suppress(Exception).
|
||
with suppress(Exception):
|
||
for directory in repo_root.glob(str(pattern)):
|
||
if (directory / "package.json").is_file():
|
||
owners.add(directory.relative_to(repo_root))
|
||
except (OSError, ValueError, TypeError):
|
||
pass
|
||
return owners
|
||
|
||
|
||
def _no_move(*_targets, **_bound):
|
||
"""The ``checkout_move`` of a run with no paused gateways: the step runs unbound."""
|
||
return nullcontext({})
|
||
|
||
|
||
def _discard_lockfile_churn(git_cmd, repo_root, *, checkout_move=None):
|
||
"""Restore ``package-lock.json`` files npm rewrote non-deterministically, so the update sees a clean tree
|
||
instead of autostashing every run. A lockfile is kept when a manifest it records is dirty: for the root
|
||
lock that is the root or ANY workspace ``package.json`` (reverting it under a dirty ``apps/desktop``
|
||
manifest desyncs spec and lock and every later ``npm ci`` fails, #112378); a nested lock is kept only
|
||
with its sibling manifest. Best-effort."""
|
||
from hermes_cli.update_cmd_git import _git_run
|
||
with suppress(Exception):
|
||
diff = _git_run(git_cmd, ["diff", "--name-only"], repo_root)
|
||
if diff.returncode != 0:
|
||
return
|
||
changed = [line.strip() for line in diff.stdout.splitlines()]
|
||
dirty_manifests = {Path(p).parent for p in changed if p.endswith("package.json")}
|
||
root_owners = _npm_lockfile_owners(Path(repo_root))
|
||
dirty = []
|
||
for path in changed:
|
||
if not path.endswith("package-lock.json"):
|
||
continue
|
||
lock_dir = Path(path).parent
|
||
protected = (lock_dir == Path(".") and bool(dirty_manifests & root_owners)) or (
|
||
lock_dir != Path(".") and lock_dir in dirty_manifests
|
||
)
|
||
if not protected:
|
||
dirty.append(path)
|
||
if not dirty:
|
||
return
|
||
with (checkout_move or _no_move)(paths=dirty):
|
||
_git_run(git_cmd, ["checkout", "--", *dirty], repo_root)
|
||
print(f"→ Discarded npm lockfile churn ({len(dirty)} file(s))")
|
||
|
||
|
||
def _normalize_managed_eol(git_cmd, repo_root, *, checkout_move=None):
|
||
"""Take a managed checkout off ``core.autocrlf=true`` without leaving it dirty.
|
||
|
||
Git for Windows sets ``autocrlf=true`` system-wide, turning LF files CRLF and breaking ``git checkout``
|
||
on update; install.ps1 pins ``false`` but older checkouts never got it and only ``hermes update`` can
|
||
fix them. Pin and cleanup are one operation: under ``autocrlf=true`` a CRLF tree reads clean, so pinning
|
||
alone would expose every file as modified (whole-tree autostash). Pin only after the tree verifies clean
|
||
under it; a checkout we can't fully normalize is left as-is. Only ``true`` rewrites LF->CRLF
|
||
(unset/false/input leave the tree alone). Best-effort.
|
||
|
||
Checkouts created before that landed never got the pin and cannot receive it — the bootstrap installer
|
||
reuses its build-pinned ``install.ps1`` forever — so ``hermes update``, which ships with the checkout
|
||
itself, is the only path left that can fix them. See #67730.
|
||
"""
|
||
from hermes_cli.update_cmd_git import _git_run
|
||
from hermes_cli.update_custody import run_git
|
||
# -c, not config: evaluate the tree as it WOULD look pinned, persisting nothing.
|
||
probe = git_cmd + ["-c", "core.autocrlf=false"]
|
||
|
||
def _probe_run(*args, **kw):
|
||
return run_git(probe, list(args), cwd=repo_root, **_GIT_TEXT_KW, **kw)
|
||
|
||
def _eol_only():
|
||
"""Dirty paths whose ONLY change is CRLF; None when either probe fails."""
|
||
all_dirty = _probe_run("diff", "-z", "--name-only")
|
||
# Files with a *content* change ignoring CRLF. ``--name-only --ignore-cr-at-eol`` still LISTS
|
||
# CR-only files; ``--numstat`` honors the filter (no record for them). Records are
|
||
# "<added>\\t<deleted>\\t<path>"; rename detection is off, so exactly one path.
|
||
real_dirty = _probe_run("-c", "core.quotepath=false", "diff", "--numstat", "--ignore-cr-at-eol")
|
||
if all_dirty.returncode != 0 or real_dirty.returncode != 0:
|
||
return None
|
||
return {p for p in all_dirty.stdout.split("\0") if p} - {
|
||
parts[2]
|
||
for parts in (line.split("\t", 2) for line in real_dirty.stdout.splitlines() if line.strip())
|
||
if len(parts) == 3 and parts[2]
|
||
}
|
||
|
||
with suppress(Exception):
|
||
if _git_run(git_cmd, ["config", "--get", "core.autocrlf"], repo_root).stdout.strip().lower() != "true":
|
||
return
|
||
eol_only = _eol_only()
|
||
if eol_only is None:
|
||
return
|
||
if eol_only:
|
||
# Pathspec via stdin: thousands of paths exceed the Windows argv limit.
|
||
with (checkout_move or _no_move)(paths=sorted(eol_only)):
|
||
_probe_run("checkout", "--pathspec-from-file=-", "--pathspec-file-nul", "--",
|
||
input="\0".join(sorted(eol_only)), check=False)
|
||
if _eol_only(): # still dirty: pinning would only surface churn we failed to clear
|
||
return
|
||
print(f"→ Normalized line-ending churn ({len(eol_only)} file(s))")
|
||
run_git(git_cmd, ["config", "core.autocrlf", "false"], cwd=repo_root, capture_output=True, check=False,
|
||
creationflags=windows_hide_flags())
|