From 54eb2f68cd06be436987596329b848c72ad5d663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=A0=EC=9A=B0?= Date: Wed, 13 May 2026 18:57:08 +0900 Subject: [PATCH] feat: add release update channel --- hermes_cli/banner.py | 120 ++++++++- hermes_cli/config.py | 29 ++ hermes_cli/main.py | 373 +++++++++++++++++++------- tests/hermes_cli/test_cmd_update.py | 69 +++++ tests/hermes_cli/test_update_check.py | 43 +++ 5 files changed, 530 insertions(+), 104 deletions(-) diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 1cfb0d51f7606..0314e3decd98e 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -175,6 +175,83 @@ def _check_via_local_git(repo_dir: Path) -> Optional[int]: return None +def _fetch_release_tags(repo_dir: Path) -> Optional[str]: + """Best-effort release tag fetch from upstream, then origin.""" + for remote in ("upstream", "origin"): + try: + result = subprocess.run( + ["git", "fetch", remote, "--tags", "--force", "--quiet"], + capture_output=True, timeout=10, + cwd=str(repo_dir), + ) + if result.returncode == 0: + return remote + except Exception: + continue + return None + + +def _release_tag_sort_key(tag: str) -> tuple: + """Sort v* release tags without trusting lexicographic order.""" + import re + + parts = re.split(r"(\d+)", tag.lstrip("vV")) + return tuple((0, int(part)) if part.isdigit() else (1, part) for part in parts) + + +def _latest_release_tag(repo_dir: Path, remote: Optional[str] = None) -> Optional[str]: + """Return the newest v* release tag, preferring the fetched remote.""" + if remote: + try: + result = subprocess.run( + ["git", "ls-remote", "--tags", "--refs", remote, "v*"], + capture_output=True, text=True, timeout=5, + cwd=str(repo_dir), + ) + if result.returncode == 0: + tags: list[str] = [] + for line in (result.stdout or "").splitlines(): + ref = line.strip().split()[-1] if line.strip() else "" + prefix = "refs/tags/" + if ref.startswith(prefix): + tags.append(ref[len(prefix):]) + if tags: + return max(tags, key=_release_tag_sort_key) + except Exception: + pass + try: + result = subprocess.run( + ["git", "tag", "--list", "v*", "--sort=-version:refname"], + capture_output=True, text=True, timeout=5, + cwd=str(repo_dir), + ) + if result.returncode == 0: + for line in (result.stdout or "").splitlines(): + tag = line.strip() + if tag: + return tag + except Exception: + pass + return None + + +def _check_via_local_release_tag(repo_dir: Path) -> Optional[int]: + """Return whether HEAD contains the latest tagged release.""" + remote = _fetch_release_tags(repo_dir) + tag = _latest_release_tag(repo_dir, remote=remote) + if not tag: + return None + try: + result = subprocess.run( + ["git", "merge-base", "--is-ancestor", tag, "HEAD"], + capture_output=True, text=True, timeout=5, + cwd=str(repo_dir), + ) + return 0 if result.returncode == 0 else UPDATE_AVAILABLE_NO_COUNT + except Exception: + return None + + def check_for_updates() -> Optional[int]: """Check whether a Hermes update is available. @@ -190,7 +267,14 @@ def check_for_updates() -> Optional[int]: cache_file = hermes_home / ".update_check" embedded_rev = os.environ.get("HERMES_REVISION") or None - # Read cache — invalidate if the embedded rev has changed since last check + try: + from hermes_cli.config import get_update_channel + + update_channel = get_update_channel() + except Exception: + update_channel = "main" + + # Read cache — invalidate if the embedded rev or update channel has changed since last check now = time.time() try: if cache_file.exists(): @@ -198,26 +282,37 @@ def check_for_updates() -> Optional[int]: if ( now - cached.get("ts", 0) < _UPDATE_CHECK_CACHE_SECONDS and cached.get("rev") == embedded_rev + and cached.get("channel", "main") == update_channel ): return cached.get("behind") except Exception: pass - if embedded_rev: + # Prefer the running code's location over the profile-scoped path. + # $HERMES_HOME/hermes-agent/ may be a stale copy from --clone-all; + # Path(__file__) always resolves to the actual installed checkout. + repo_dir = Path(__file__).parent.parent.resolve() + if not (repo_dir / ".git").exists(): + repo_dir = hermes_home / "hermes-agent" + + if embedded_rev and update_channel != "release": behind = _check_via_rev(embedded_rev) else: - # Prefer the running code's location over the profile-scoped path. - # $HERMES_HOME/hermes-agent/ may be a stale copy from --clone-all; - # Path(__file__) always resolves to the actual installed checkout. - repo_dir = Path(__file__).parent.parent.resolve() - if not (repo_dir / ".git").exists(): - repo_dir = hermes_home / "hermes-agent" if not (repo_dir / ".git").exists(): return None - behind = _check_via_local_git(repo_dir) + if update_channel == "release": + behind = _check_via_local_release_tag(repo_dir) + else: + behind = _check_via_local_git(repo_dir) try: - cache_file.write_text(json.dumps({"ts": now, "behind": behind, "rev": embedded_rev})) + payload = { + "ts": now, + "behind": behind, + "rev": embedded_rev, + "channel": update_channel, + } + cache_file.write_text(json.dumps(payload)) except Exception: pass @@ -608,9 +703,10 @@ def build_welcome_banner(console: Console, model: str, cwd: str, # exists but not by how much, and we don't know how the user # installed it (nix run, profile, system flake, home-manager). managed_cmd = get_managed_update_command() + update_cmd = managed_cmd or recommended_update_command() line = "[bold yellow]⚠ update available[/]" - if managed_cmd: - line += f"[dim yellow] — run [bold]{managed_cmd}[/bold][/]" + if update_cmd: + line += f"[dim yellow] — run [bold]{update_cmd}[/bold][/]" right_lines.append(line) except Exception: pass # Never break the banner over an update check diff --git a/hermes_cli/config.py b/hermes_cli/config.py index dc3e414948b6f..7f0022468b4e6 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -199,6 +199,30 @@ def get_managed_update_command() -> Optional[str]: return None +VALID_UPDATE_CHANNELS = {"main", "release"} + + +def get_update_channel(override: Optional[str] = None) -> str: + """Return the configured Hermes update channel. + + ``main`` preserves the historical commit-tracking behavior. ``release`` + only updates when a tagged release advances. + """ + channel = override + if channel is None: + try: + cfg = load_config() + except Exception: + cfg = {} + updates_cfg = cfg.get("updates", {}) if isinstance(cfg, dict) else {} + channel = updates_cfg.get("channel", "main") + channel = str(channel or "main").strip().lower() + if channel not in VALID_UPDATE_CHANNELS: + valid = ", ".join(sorted(VALID_UPDATE_CHANNELS)) + raise ValueError(f"Invalid updates.channel {channel!r}; expected one of: {valid}") + return channel + + def recommended_update_command() -> str: """Return the best update command for the current installation.""" return get_managed_update_command() or "hermes update" @@ -1496,6 +1520,11 @@ def _ensure_hermes_home_managed(home: Path): # on large HERMES_HOME directories the zip can add minutes to every # update. Set to true to re-enable, or pass ``--backup`` to opt in # for a single update run. + # Update source. ``main`` preserves the historical commit-tracking + # behavior. ``release`` only updates when a tagged release advances; + # use ``hermes update --channel main`` for emergency hotfixes between + # releases. + "channel": "main", "pre_update_backup": False, # How many pre-update backup zips to retain. Older ones are pruned # automatically after each successful backup. Values below 1 are diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 64310dc6af1ce..271d3c21a97c0 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -7047,7 +7047,95 @@ def _finalize_update_output(state): pass -def _cmd_update_check(): +def _latest_release_tag_from_git(git_cmd: list[str], cwd: Path) -> Optional[str]: + """Return the newest fetched v* tag by version sort.""" + result = subprocess.run( + git_cmd + ["tag", "--list", "v*", "--sort=-version:refname"], + cwd=cwd, + capture_output=True, + text=True, + ) + if result.returncode != 0: + return None + for line in (result.stdout or "").splitlines(): + tag = line.strip() + if tag: + return tag + return None + + +def _release_tag_sort_key(tag: str) -> tuple: + """Sort v* release tags without trusting lexicographic order.""" + import re + + parts = re.split(r"(\d+)", tag.lstrip("vV")) + return tuple((0, int(part)) if part.isdigit() else (1, part) for part in parts) + + +def _latest_release_tag_from_remote( + git_cmd: list[str], cwd: Path, remote: Optional[str] +) -> Optional[str]: + """Return the newest v* tag advertised by ``remote`` only.""" + if not remote: + return None + result = subprocess.run( + git_cmd + ["ls-remote", "--tags", "--refs", remote, "v*"], + cwd=cwd, + capture_output=True, + text=True, + ) + if result.returncode != 0: + return None + tags: list[str] = [] + for line in (result.stdout or "").splitlines(): + ref = line.strip().split()[-1] if line.strip() else "" + prefix = "refs/tags/" + if ref.startswith(prefix): + tags.append(ref[len(prefix):]) + return max(tags, key=_release_tag_sort_key) if tags else None + + +def _head_contains_ref(git_cmd: list[str], cwd: Path, ref: str) -> bool: + """Return True when HEAD already contains ``ref``.""" + result = subprocess.run( + git_cmd + ["merge-base", "--is-ancestor", ref, "HEAD"], + cwd=cwd, + capture_output=True, + text=True, + ) + return result.returncode == 0 + + +def _fetch_release_tags(git_cmd: list[str], cwd: Path) -> tuple[Optional[str], subprocess.CompletedProcess]: + """Fetch release tags from the canonical remote, falling back to origin.""" + last_result: Optional[subprocess.CompletedProcess] = None + for remote in ("upstream", "origin"): + result = subprocess.run( + git_cmd + ["fetch", remote, "--tags", "--force"], + cwd=cwd, + capture_output=True, + text=True, + ) + if result.returncode == 0: + return remote, result + last_result = result + return None, last_result or subprocess.CompletedProcess(git_cmd, 1, stdout="", stderr="") + + +def _create_pre_update_snapshot() -> None: + """Create a best-effort pre-update state snapshot.""" + try: + from hermes_cli.backup import create_quick_snapshot + + snap_id = create_quick_snapshot(label="pre-update") + if snap_id: + print(f" ✓ Pre-update snapshot: {snap_id}") + except Exception as exc: + # Never let a snapshot failure block an update. + logger.debug("Pre-update snapshot failed: %s", exc) + + +def _cmd_update_check(args=None): """Implement ``hermes update --check``: fetch and report without installing.""" git_dir = PROJECT_ROOT / ".git" if not git_dir.exists(): @@ -7058,6 +7146,43 @@ def _cmd_update_check(): if sys.platform == "win32": git_cmd = ["git", "-c", "windows.appendAtomically=false"] + from hermes_cli.config import get_update_channel + + channel = get_update_channel(getattr(args, "channel", None)) + if channel == "release": + print("→ Fetching release tags...") + release_remote, fetch_result = _fetch_release_tags(git_cmd, PROJECT_ROOT) + if fetch_result.returncode != 0: + stderr = fetch_result.stderr.strip() + if "Could not resolve host" in stderr or "unable to access" in stderr: + print("✗ Network error — cannot reach the remote repository.") + elif "Authentication failed" in stderr or "could not read Username" in stderr: + print("✗ Authentication failed — check your git credentials or SSH key.") + else: + print("✗ Failed to fetch release tags.") + if stderr: + print(f" {stderr.splitlines()[0]}") + sys.exit(1) + + latest_tag = _latest_release_tag_from_remote( + git_cmd, PROJECT_ROOT, release_remote + ) or _latest_release_tag_from_git(git_cmd, PROJECT_ROOT) + if not latest_tag: + print("✗ No release tags found.") + sys.exit(1) + if _head_contains_ref(git_cmd, PROJECT_ROOT, latest_tag): + print(f"✓ Already up to date with latest release {latest_tag}.") + else: + source = f" from {release_remote}" if release_remote else "" + print(f"⚕ Release update available: {latest_tag}{source}.") + from hermes_cli.config import recommended_update_command + + install_cmd = recommended_update_command() + if getattr(args, "channel", None): + install_cmd = f"{install_cmd} --channel {channel}" + print(f" Run '{install_cmd}' to install.") + return + # Fetch both origin and upstream; prefer upstream as the canonical reference print("→ Fetching from upstream...") fetch_result = subprocess.run( @@ -7075,10 +7200,8 @@ def _cmd_update_check(): capture_output=True, text=True, ) - upstream_exists = False compare_branch = "origin/main" else: - upstream_exists = True compare_branch = "upstream/main" if fetch_result.returncode != 0: @@ -7311,7 +7434,7 @@ def cmd_update(args): return if getattr(args, "check", False): - _cmd_update_check() + _cmd_update_check(args) return gateway_mode = getattr(args, "gateway", False) @@ -7336,8 +7459,12 @@ def _cmd_update_impl(args, gateway_mode: bool): else None ) assume_yes = bool(getattr(args, "yes", False)) + from hermes_cli.config import get_update_channel + + update_channel = get_update_channel(getattr(args, "channel", None)) print("⚕ Updating Hermes Agent...") + print(f"→ Update channel: {update_channel}") print() # Pre-update backup — runs before any git/file mutation so users can @@ -7399,12 +7526,16 @@ def _cmd_update_impl(args, gateway_mode: bool): try: print("→ Fetching updates...") - fetch_result = subprocess.run( - git_cmd + ["fetch", "origin"], - cwd=PROJECT_ROOT, - capture_output=True, - text=True, - ) + if update_channel == "release": + release_remote, fetch_result = _fetch_release_tags(git_cmd, PROJECT_ROOT) + else: + release_remote = None + fetch_result = subprocess.run( + git_cmd + ["fetch", "origin"], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + ) if fetch_result.returncode != 0: stderr = fetch_result.stderr.strip() if "Could not resolve host" in stderr or "unable to access" in stderr: @@ -7461,19 +7592,48 @@ def _cmd_update_impl(args, gateway_mode: bool): and (gateway_mode or (sys.stdin.isatty() and sys.stdout.isatty())) ) - # Check if there are updates - result = subprocess.run( - git_cmd + ["rev-list", f"HEAD..origin/{branch}", "--count"], - cwd=PROJECT_ROOT, - capture_output=True, - text=True, - check=True, - ) - commit_count = int(result.stdout.strip()) - - if commit_count == 0: - _invalidate_update_cache() - # Restore stash and switch back to original branch if we moved + if update_channel == "release": + latest_tag = _latest_release_tag_from_remote( + git_cmd, PROJECT_ROOT, release_remote + ) or _latest_release_tag_from_git(git_cmd, PROJECT_ROOT) + if not latest_tag: + print("✗ No release tags found.") + sys.exit(1) + if _head_contains_ref(git_cmd, PROJECT_ROOT, latest_tag): + _invalidate_update_cache() + if auto_stash_ref is not None: + _restore_stashed_changes( + git_cmd, + PROJECT_ROOT, + auto_stash_ref, + prompt_user=prompt_for_restore, + input_fn=gw_input_fn, + ) + if current_branch not in {"main", "HEAD"}: + subprocess.run( + git_cmd + ["checkout", current_branch], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=False, + ) + print(f"✓ Already up to date with latest release {latest_tag}!") + return + source = f" from {release_remote}" if release_remote else "" + print(f"→ Found release update: {latest_tag}{source}") + _create_pre_update_snapshot() + print("→ Moving main to latest release tag...") + reset_result = subprocess.run( + git_cmd + ["reset", "--hard", latest_tag], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + ) + if reset_result.returncode != 0: + print(f"✗ Failed to reset to release {latest_tag}.") + if reset_result.stderr.strip(): + print(f" {reset_result.stderr.strip()}") + sys.exit(1) if auto_stash_ref is not None: _restore_stashed_changes( git_cmd, @@ -7482,76 +7642,22 @@ def _cmd_update_impl(args, gateway_mode: bool): prompt_user=prompt_for_restore, input_fn=gw_input_fn, ) - if current_branch not in {"main", "HEAD"}: - subprocess.run( - git_cmd + ["checkout", current_branch], - cwd=PROJECT_ROOT, - capture_output=True, - text=True, - check=False, - ) - print("✓ Already up to date!") - return - - print(f"→ Found {commit_count} new commit(s)") - - # Snapshot critical state (state.db, config, pairing JSONs, etc.) - # before pulling so a user can recover if something goes wrong. - # Issue #15733 reported missing pairing data after an update; even - # though `git pull` can't touch $HERMES_HOME, this is cheap - # belt-and-suspenders insurance and gives the user something to - # restore from via `/snapshot list` / `/snapshot restore `. - try: - from hermes_cli.backup import create_quick_snapshot - - snap_id = create_quick_snapshot(label="pre-update") - if snap_id: - print(f" ✓ Pre-update snapshot: {snap_id}") - except Exception as exc: - # Never let a snapshot failure block an update. - logger.debug("Pre-update snapshot failed: %s", exc) - - print("→ Pulling updates...") - update_succeeded = False - try: - pull_result = subprocess.run( - git_cmd + ["pull", "--ff-only", "origin", branch], + _invalidate_update_cache() + else: + # Check if there are updates + result = subprocess.run( + git_cmd + ["rev-list", f"HEAD..origin/{branch}", "--count"], cwd=PROJECT_ROOT, capture_output=True, text=True, + check=True, ) - if pull_result.returncode != 0: - # ff-only failed — local and remote have diverged (e.g. upstream - # force-pushed or rebase). Since local changes are already - # stashed, reset to match the remote exactly. - print( - " ⚠ Fast-forward not possible (history diverged), resetting to match remote..." - ) - reset_result = subprocess.run( - git_cmd + ["reset", "--hard", f"origin/{branch}"], - cwd=PROJECT_ROOT, - capture_output=True, - text=True, - ) - 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( - " Try manually: git fetch origin && git reset --hard origin/main" - ) - sys.exit(1) - update_succeeded = True - finally: - if auto_stash_ref is not None: - # Don't attempt stash restore if the code update itself failed — - # working tree is in an unknown state. - if not update_succeeded: - print( - f" ℹ️ Local changes preserved in stash (ref: {auto_stash_ref})" - ) - print(f" Restore manually with: git stash apply") - else: + commit_count = int(result.stdout.strip()) + + if commit_count == 0: + _invalidate_update_cache() + # Restore stash and switch back to original branch if we moved + if auto_stash_ref is not None: _restore_stashed_changes( git_cmd, PROJECT_ROOT, @@ -7559,8 +7665,85 @@ def _cmd_update_impl(args, gateway_mode: bool): prompt_user=prompt_for_restore, input_fn=gw_input_fn, ) + if current_branch not in {"main", "HEAD"}: + subprocess.run( + git_cmd + ["checkout", current_branch], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=False, + ) + print("✓ Already up to date!") + return + + print(f"→ Found {commit_count} new commit(s)") + + # Snapshot critical state (state.db, config, pairing JSONs, etc.) + # before pulling so a user can recover if something goes wrong. + # Issue #15733 reported missing pairing data after an update; even + # though `git pull` can't touch $HERMES_HOME, this is cheap + # belt-and-suspenders insurance and gives the user something to + # restore from via `/snapshot list` / `/snapshot restore `. + try: + from hermes_cli.backup import create_quick_snapshot - _invalidate_update_cache() + snap_id = create_quick_snapshot(label="pre-update") + if snap_id: + print(f" ✓ Pre-update snapshot: {snap_id}") + except Exception as exc: + # Never let a snapshot failure block an update. + logger.debug("Pre-update snapshot failed: %s", exc) + + print("→ Pulling updates...") + update_succeeded = False + try: + pull_result = subprocess.run( + git_cmd + ["pull", "--ff-only", "origin", branch], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + ) + if pull_result.returncode != 0: + # ff-only failed — local and remote have diverged (e.g. upstream + # force-pushed or rebase). Since local changes are already + # stashed, reset to match the remote exactly. + print( + " ⚠ Fast-forward not possible (history diverged), resetting to match remote..." + ) + reset_result = subprocess.run( + git_cmd + ["reset", "--hard", f"origin/{branch}"], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + ) + 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( + " Try manually: git fetch origin && git reset --hard origin/main" + ) + sys.exit(1) + update_succeeded = True + finally: + if auto_stash_ref is not None: + # Don't attempt stash restore if the code update itself failed — + # working tree is in an unknown state. + if not update_succeeded: + print( + f" ℹ️ Local changes preserved in stash (ref: {auto_stash_ref})" + ) + print(f" Restore manually with: git stash apply") + else: + _restore_stashed_changes( + git_cmd, + PROJECT_ROOT, + auto_stash_ref, + prompt_user=prompt_for_restore, + input_fn=gw_input_fn, + ) + + _invalidate_update_cache() # Clear stale .pyc bytecode cache — prevents ImportError on gateway # restart when updated source references names that didn't exist in @@ -7571,8 +7754,8 @@ def _cmd_update_impl(args, gateway_mode: bool): f" ✓ Cleared {removed} stale __pycache__ director{'y' if removed == 1 else 'ies'}" ) - # Fork upstream sync logic (only for main branch on forks) - if is_fork and branch == "main": + # Fork upstream sync logic (only for main-channel updates on forks) + if update_channel == "main" and is_fork and branch == "main": _sync_with_upstream_if_needed(git_cmd, PROJECT_ROOT) # Reinstall Python dependencies. Prefer .[all], but if one optional extra @@ -11371,6 +11554,12 @@ def cmd_claw(args): default=False, help="Check whether an update is available without installing anything", ) + update_parser.add_argument( + "--channel", + choices=("main", "release"), + default=None, + help="Update channel override: main tracks commits, release tracks tagged releases", + ) update_parser.add_argument( "--no-backup", action="store_true", diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py index f059e54ac05f7..db837f7adeb87 100644 --- a/tests/hermes_cli/test_cmd_update.py +++ b/tests/hermes_cli/test_cmd_update.py @@ -286,3 +286,72 @@ def test_load_installable_optional_extras_supports_termux_group(tmp_path, monkey assert hm._load_installable_optional_extras(group="all") == ["mcp"] assert hm._load_installable_optional_extras(group="termux-all") == ["termux", "mcp"] + + + +def _make_release_run_side_effect(branch="main", latest_tag="v2026.5.7", contains_latest=False): + """Simulate release-channel git commands for cmd_update tests.""" + + def side_effect(cmd, **kwargs): + joined = " ".join(str(c) for c in cmd) + if "remote get-url origin" in joined: + return subprocess.CompletedProcess( + cmd, 0, stdout="https://github.com/NousResearch/hermes-agent.git\n", stderr="" + ) + if "fetch upstream --tags" in joined: + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if "fetch origin --tags" in joined: + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if "ls-remote --tags --refs upstream v*" in joined: + return subprocess.CompletedProcess( + cmd, + 0, + stdout=f"abc123\trefs/tags/{latest_tag}\ndef456\trefs/tags/v2026.4.30\n", + stderr="", + ) + if "rev-parse --abbrev-ref HEAD" in joined: + return subprocess.CompletedProcess(cmd, 0, stdout=f"{branch}\n", stderr="") + if "tag --list v* --sort=-version:refname" in joined: + return subprocess.CompletedProcess( + cmd, 0, stdout=f"{latest_tag}\nv2026.4.30\n", stderr="" + ) + if f"merge-base --is-ancestor {latest_tag} HEAD" in joined: + rc = 0 if contains_latest else 1 + return subprocess.CompletedProcess(cmd, rc, stdout="", stderr="") + if f"reset --hard {latest_tag}" in joined: + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + return side_effect + + +@patch("shutil.which", return_value=None) +@patch("subprocess.run") +def test_update_release_channel_resets_to_latest_tag(mock_run, _mock_which, capsys): + mock_run.side_effect = _make_release_run_side_effect(contains_latest=False) + + cmd_update(SimpleNamespace(channel="release")) + + commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list] + assert any("fetch upstream --tags" in c for c in commands) + assert any("ls-remote --tags --refs upstream v*" in c for c in commands) + assert any("reset --hard v2026.5.7" in c for c in commands) + assert not any("pull --ff-only origin main" in c for c in commands) + assert "Found release update: v2026.5.7" in capsys.readouterr().out + + +@patch("shutil.which", return_value=None) +@patch("subprocess.run") +def test_update_release_channel_skips_when_latest_tag_is_reachable( + mock_run, _mock_which, capsys +): + mock_run.side_effect = _make_release_run_side_effect(contains_latest=True) + + cmd_update(SimpleNamespace(channel="release")) + + commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list] + assert any("fetch upstream --tags" in c for c in commands) + assert any("ls-remote --tags --refs upstream v*" in c for c in commands) + assert not any("reset --hard" in c for c in commands) + assert not any("pull --ff-only origin main" in c for c in commands) + assert "Already up to date" in capsys.readouterr().out diff --git a/tests/hermes_cli/test_update_check.py b/tests/hermes_cli/test_update_check.py index 2bdc9b2462158..b1f432e2d6287 100644 --- a/tests/hermes_cli/test_update_check.py +++ b/tests/hermes_cli/test_update_check.py @@ -151,3 +151,46 @@ def test_invalidate_update_cache_no_profiles_dir(tmp_path): _invalidate_update_cache() assert not (default_home / ".update_check").exists() + + + +def test_check_for_updates_release_channel_uses_tags(tmp_path, monkeypatch): + """Release channel update checks should use release tags, not main commits.""" + import hermes_cli.banner as banner + + repo_dir = tmp_path / "hermes-agent" + repo_dir.mkdir() + (repo_dir / ".git").mkdir() + fake_banner = repo_dir / "hermes_cli" / "banner.py" + fake_banner.parent.mkdir(parents=True, exist_ok=True) + fake_banner.touch() + + def fake_run(cmd, **kwargs): + joined = " ".join(str(c) for c in cmd) + if "fetch upstream --tags" in joined: + return MagicMock(returncode=0, stdout="", stderr="") + if "fetch origin --tags" in joined: + return MagicMock(returncode=0, stdout="", stderr="") + if "ls-remote --tags --refs upstream v*" in joined: + return MagicMock( + returncode=0, + stdout="abc123\trefs/tags/v2026.5.7\ndef456\trefs/tags/v2026.4.30\n", + stderr="", + ) + if "tag --list v* --sort=-version:refname" in joined: + return MagicMock(returncode=0, stdout="v2026.5.7\nv2026.4.30\n", stderr="") + if "merge-base --is-ancestor v2026.5.7 HEAD" in joined: + return MagicMock(returncode=1, stdout="", stderr="") + raise AssertionError(f"unexpected command: {joined}") + + monkeypatch.setattr(banner, "__file__", str(fake_banner)) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + with patch("hermes_cli.config.load_config", return_value={"updates": {"channel": "release"}}), \ + patch("hermes_cli.banner.subprocess.run", side_effect=fake_run) as mock_run: + result = banner.check_for_updates() + + assert result == banner.UPDATE_AVAILABLE_NO_COUNT + commands = [" ".join(str(a) for a in call.args[0]) for call in mock_run.call_args_list] + assert any("fetch upstream --tags" in c for c in commands) + assert any("ls-remote --tags --refs upstream v*" in c for c in commands) + assert not any("rev-list" in c for c in commands)