From eb676fe66e4a0393070dfc723e4dd33f726e7ca7 Mon Sep 17 00:00:00 2001 From: ethernet Date: Fri, 7 Aug 2026 17:20:23 -0400 Subject: [PATCH] change: derive install state from .git and the build stamp hermes_cli/runtime_tree.py replaces install_manifest.py. A tree with .git is a git checkout and `hermes update` owns it. A tree without .git is sealed, and the distribution field of the build stamp names the steward that replaces it (desktop-app, docker, nix). The refusal message comes from a per-steward table. .hermes-install.json dies: staging stops writing it into the payload, the CLI never reads it, and the update channel lives in config.yaml (update.channel; main is the default and keeps the current behavior). Eject gates on Sealed(desktop-app) and is a full handoff: it tells the user that Setup replaces the desktop app. --channel on a git checkout writes config instead of a manifest. --- apps/desktop/scripts/stage-agent-payloads.mjs | 13 -- hermes_cli/config_defaults.py | 11 +- hermes_cli/install_manifest.py | 219 ------------------ hermes_cli/main.py | 16 +- hermes_cli/runtime_tree.py | 171 ++++++++++++++ hermes_cli/update_cmd.py | 113 +++++---- tests/hermes_cli/test_install_manifest.py | 207 ----------------- tests/hermes_cli/test_runtime_tree.py | 104 +++++++++ .../hermes_cli/test_update_channel_stable.py | 18 +- tests/hermes_cli/test_update_eject.py | 96 ++++---- 10 files changed, 393 insertions(+), 575 deletions(-) delete mode 100644 hermes_cli/install_manifest.py create mode 100644 hermes_cli/runtime_tree.py delete mode 100644 tests/hermes_cli/test_install_manifest.py create mode 100644 tests/hermes_cli/test_runtime_tree.py diff --git a/apps/desktop/scripts/stage-agent-payloads.mjs b/apps/desktop/scripts/stage-agent-payloads.mjs index 3a2642bd704ed..6756461f6a495 100644 --- a/apps/desktop/scripts/stage-agent-payloads.mjs +++ b/apps/desktop/scripts/stage-agent-payloads.mjs @@ -320,19 +320,6 @@ function stageRepo(tag, outDir) { "--source", "ci", "--distribution", "desktop-app", ]) - // The install manifest is BUILD metadata for a resident bundle: the - // payload repo is always desktop-managed, always the stable channel, - // always pinned to this tag. Shipping it statically means the Python - // side (update refusal, eject, channel vocabulary) reads the same file - // in a resident bundle as in a materialized checkout. - fs.writeFileSync( - path.join(repoDir, ".hermes-install.json"), - JSON.stringify( - { schemaVersion: 1, installMode: "bundled", channel: "stable", manageStyle: "adopted", pinnedTag: tag }, - null, - 2 - ) + "\n" - ) return commit } diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 541c5042f15e9..1c154cbdb7434 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -2459,13 +2459,12 @@ DEFAULT_CONFIG = { # Settings for the update pipeline. "update": { # This setting selects the releases that `hermes update` tracks on - # source (git) installs: - # auto — use the install manifest (.hermes-install.json). - # This is the same as "main" for each pre-existing install. - # main — git pull origin main (the current behavior). + # git checkouts: + # main — git pull origin main (the default). # stable — check out the latest tagged release, not main. - # Bundled desktop installs ignore this setting and always track - # stable. Their updates come from the updater of the desktop app. + # auto — same as main (kept for pre-existing configs). + # Sealed installs (the embedded desktop app, docker, nix) ignore + # this setting; their stewards own versioning. "channel": "auto", }, diff --git a/hermes_cli/install_manifest.py b/hermes_cli/install_manifest.py deleted file mode 100644 index 0b8fb6c48a67a..0000000000000 --- a/hermes_cli/install_manifest.py +++ /dev/null @@ -1,219 +0,0 @@ -"""Install-manifest functions for bundled desktop installs. - -``.hermes-install.json`` is a small marker file next to the managed checkout. -It sits in the parent directory of ``hermes_cli/``. This is the same anchor -rule as ``.install_method`` in ``hermes_cli/config.py``. The marker describes -the running code, not ``$HERMES_HOME``. Two installs that share one data -directory cannot overwrite each other's marker. - -The file records where a checkout came from and where its updates come from: - - { - "schemaVersion": 1, - "installMode": "bundled" | "source", - "channel": "stable" | "main", - "manageStyle": "adopted" | "ejected", # optional - "pinnedCommit": "", # optional - "pinnedTag": "v0.17.0" # optional, bundled installs only - } - -Semantics ---------- -* ``installMode: "source"`` — the user manages the checkout. ``hermes update`` - owns updates (git pull, tag checkout, or ZIP fallback). **A missing file - means source mode.** Every install that exists today is a source install. - No migration is necessary. -* ``installMode: "bundled"`` — the checkout came from payloads inside the - desktop installer. The desktop app owns updates. It rebuilds the checkout - offline after the app updates itself. ``hermes update`` refuses and points - at the in-app updater. -* ``channel`` — ``"main"`` follows the git main branch (source mode only). - ``"stable"`` follows tagged releases. :func:`resolve_update_channel` gives - the effective channel. ``update.channel`` in config.yaml can override the - channel for source installs. Bundled installs are always stable. -* ``manageStyle`` — how the install got into its current mode. - ``installMode`` says where it is now. Values: - - - ``"adopted"`` — the install is desktop-managed (a resident bundle's - static manifest, or an installer run that selected bundled mode). - - ``"ejected"`` — the user ran ``hermes update --eject``. This opt-out is - permanent, although the resulting ``installMode`` is ``"source"``. - - missing — a legacy checkout from before manifests, or a plain source - install. - -This is a pure-stdlib leaf module. It does not import hermes_cli.config. -A config import would pull the full config machinery into every consumer. -The desktop bootstrap and the install scripts also write this file without -Python. -""" - -import json -import logging -from pathlib import Path -from typing import Any, Optional - -logger = logging.getLogger(__name__) - -INSTALL_MANIFEST_NAME = ".hermes-install.json" -INSTALL_MANIFEST_SCHEMA_VERSION = 1 - -MODE_SOURCE = "source" -MODE_BUNDLED = "bundled" -_VALID_MODES = (MODE_SOURCE, MODE_BUNDLED) - -CHANNEL_MAIN = "main" -CHANNEL_STABLE = "stable" -_VALID_CHANNELS = (CHANNEL_MAIN, CHANNEL_STABLE) - -STYLE_ADOPTED = "adopted" -STYLE_EJECTED = "ejected" -_VALID_STYLES = (STYLE_ADOPTED, STYLE_EJECTED) - - -def _default_manifest() -> dict: - """The implicit manifest for a checkout with no ``.hermes-install.json``. - - Source mode on the main channel. This is the current behavior, so - installs from before manifests do not change. - """ - return { - "schemaVersion": INSTALL_MANIFEST_SCHEMA_VERSION, - "installMode": MODE_SOURCE, - "channel": CHANNEL_MAIN, - } - - -def install_manifest_path(project_root: Optional[Path] = None) -> Path: - """Path of the manifest for the running code's install tree.""" - root = project_root if project_root is not None else Path(__file__).parent.parent - return Path(root).resolve() / INSTALL_MANIFEST_NAME - - -def read_install_manifest(project_root: Optional[Path] = None) -> dict: - """Read the install manifest and correct bad values. - - This function does not raise errors. A missing, unreadable, or malformed - file falls back to the source/main default. Unknown ``installMode`` or - ``channel`` values (for example, from a future Hermes with more values) - also fall back. This keeps the update logic safe. The function keeps - unknown extra keys, so a round trip of a future manifest does not remove - fields. - """ - path = install_manifest_path(project_root) - try: - raw = json.loads(path.read_text(encoding="utf-8")) - except FileNotFoundError: - return _default_manifest() - except (OSError, ValueError) as exc: - logger.warning("Unreadable install manifest at %s (%s); assuming source install", path, exc) - return _default_manifest() - - if not isinstance(raw, dict): - logger.warning("Install manifest at %s is not a JSON object; assuming source install", path) - return _default_manifest() - - manifest = dict(raw) - if manifest.get("installMode") not in _VALID_MODES: - manifest["installMode"] = MODE_SOURCE - if manifest.get("channel") not in _VALID_CHANNELS: - manifest["channel"] = CHANNEL_MAIN if manifest["installMode"] == MODE_SOURCE else CHANNEL_STABLE - # Drop unknown manageStyle values. Do not replace them with a default. - # A missing style means "adoption can examine this checkout". An invented - # style can block adoption in error, or worse, remove an eject opt-out - # that a future vocabulary wrote. Exception: a value that contains - # "eject" stays ejected. The opt-out must survive vocabulary changes in - # both directions. - style = manifest.get("manageStyle") - if style is not None and style not in _VALID_STYLES: - if isinstance(style, str) and "eject" in style.lower(): - manifest["manageStyle"] = STYLE_EJECTED - else: - del manifest["manageStyle"] - manifest.setdefault("schemaVersion", INSTALL_MANIFEST_SCHEMA_VERSION) - return manifest - - -def write_install_manifest( - manifest: dict, - project_root: Optional[Path] = None, -) -> Path: - """Write the manifest atomically (tmp file + rename). Return the path. - - The function validates mode, channel, and style before the write. A bad - write is worse than an error: every reader silently converts bad values - to source/main, and the caller's intent (for example, to mark an install - bundled) is lost without a signal. - """ - if manifest.get("installMode") not in _VALID_MODES: - raise ValueError(f"invalid installMode: {manifest.get('installMode')!r}") - if manifest.get("channel") not in _VALID_CHANNELS: - raise ValueError(f"invalid channel: {manifest.get('channel')!r}") - if manifest.get("manageStyle") is not None and manifest["manageStyle"] not in _VALID_STYLES: - raise ValueError(f"invalid manageStyle: {manifest.get('manageStyle')!r}") - - payload = dict(manifest) - payload.setdefault("schemaVersion", INSTALL_MANIFEST_SCHEMA_VERSION) - - path = install_manifest_path(project_root) - tmp = path.with_suffix(path.suffix + ".tmp") - tmp.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") - tmp.replace(path) - return path - - -def is_bundled_install(project_root: Optional[Path] = None) -> bool: - """True when the running checkout came from desktop payloads.""" - return read_install_manifest(project_root).get("installMode") == MODE_BUNDLED - - -def is_ejected(project_root: Optional[Path] = None) -> bool: - """True when the user ejected this checkout from desktop management. - - This is the permanent opt-out signal for auto-adoption. Auto-adoption - must not move an ejected checkout back into the bundled path, although - its ``installMode`` is ``source``. - """ - return read_install_manifest(project_root).get("manageStyle") == STYLE_EJECTED - - -def resolve_update_channel( - config: Optional[dict] = None, - project_root: Optional[Path] = None, -) -> str: - """Give the effective update channel for this install. - - Resolution order: - 1. Bundled installs are always ``stable``. The desktop app rebuilds the - checkout from tagged release payloads. A config override cannot change - what the installer ships. Eject first to change this. - 2. ``update.channel`` from config.yaml, when it is ``stable`` or ``main``. - The values ``auto``, empty, and unknown fall through. - 3. The channel from the manifest. The source default is ``main``. - """ - manifest = read_install_manifest(project_root) - if manifest.get("installMode") == MODE_BUNDLED: - return CHANNEL_STABLE - - configured: Any = None - if isinstance(config, dict): - update_cfg = config.get("update") - if isinstance(update_cfg, dict): - configured = update_cfg.get("channel") - if isinstance(configured, str) and configured.strip().lower() in _VALID_CHANNELS: - return configured.strip().lower() - - return manifest.get("channel", CHANNEL_MAIN) - - -def format_bundled_update_message() -> str: - """Refusal text for ``hermes update`` on a bundled install.""" - return ( - "✗ The Hermes desktop app manages this Hermes install.\n" - "\n" - "The desktop app updates the agent together with itself. Use the\n" - "in-app updater (Settings → Check for updates), not `hermes update`.\n" - "\n" - "If you want to manage this checkout yourself with `hermes update`,\n" - "eject it from desktop management first. After an eject, the desktop\n" - "app continues to update itself, but the agent checkout is yours." - ) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index f3f288365f29c..3735b02cde143 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -9192,16 +9192,14 @@ def cmd_update(args): sys.exit(cmd_update_eject(args)) - # Bundled desktop installs are materialized from payloads shipped inside - # the desktop app. The updater of the app re-materializes the checkout - # after the app updates itself. If `hermes update` changes that checkout, - # the checkout no longer agrees with the stamped tag of the shell. Thus - # refuse, and point at the in-app updater or at eject. Eject changes the - # install to source mode. - from hermes_cli.install_manifest import format_bundled_update_message, is_bundled_install + # A tree without .git is sealed: a steward (the desktop app, docker, + # nix, a package manager) replaces it wholesale, and `hermes update` + # has nothing to update. Refuse and name the steward's mechanism. + from hermes_cli.runtime_tree import Sealed, runtime_tree, steward_update_message - if is_bundled_install(PROJECT_ROOT): - print(format_bundled_update_message()) + tree = runtime_tree(PROJECT_ROOT) + if isinstance(tree, Sealed): + print(steward_update_message(tree.steward)) sys.exit(1) if getattr(args, "check", False): diff --git a/hermes_cli/runtime_tree.py b/hermes_cli/runtime_tree.py new file mode 100644 index 0000000000000..39951d6631295 --- /dev/null +++ b/hermes_cli/runtime_tree.py @@ -0,0 +1,171 @@ +"""Derive who owns the running tree — no stored mode flags. + +The install model has two axes (design: +.hermes/plans/2026-08-07_183000-two-axis-install-model.md): + +* A tree with ``.git`` is a **git checkout**: ``hermes update`` owns it. + The checkout's existence IS the fact; no manifest records it. +* A tree without ``.git`` is **sealed**: something external replaces it + wholesale. The build stamp (``.hermes_build_info.json``) names that + steward in its ``distribution`` field: ``desktop-app`` (the embedded + desktop bundle), ``docker``, ``nix``, or a future package manager. + +The update channel (``stable`` or ``main``) lives in config.yaml under +``update.channel``. It applies to git checkouts only — sealed trees +version-track through their stewards. + +If a future feature writes to user checkouts (nothing does today), it +must add an explicit opt-out fact FIRST. The old ``manageStyle: ejected`` +stickiness guarded against desktop-side adoption and rematerialization; +both are deleted, so the guard went with them. + +This is a pure-stdlib leaf module. It does not import hermes_cli.config. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +BUILD_INFO_NAME = ".hermes_build_info.json" + +STEWARD_DESKTOP = "desktop-app" +STEWARD_DOCKER = "docker" +STEWARD_NIX = "nix" + +CHANNEL_MAIN = "main" +CHANNEL_STABLE = "stable" +_VALID_CHANNELS = (CHANNEL_MAIN, CHANNEL_STABLE) + +# What `hermes update` says in a sealed tree, per steward. The fallback +# covers stewards this build does not know (a newer package-manager value +# read by older code). +STEWARD_UPDATE_MESSAGES = { + STEWARD_DESKTOP: ( + "✗ This Hermes runs from inside the desktop app bundle.\n" + "\n" + "The app updates itself, and every app update carries the agent\n" + "with it. There is nothing for `hermes update` to do here.\n" + "\n" + "To manage the agent from the command line with git, run\n" + "`hermes update --eject`. It installs a source checkout and a\n" + "desktop app built from it." + ), + STEWARD_DOCKER: ( + "✗ This Hermes runs from a Docker image.\n" + "\n" + "The image is immutable. Pull the new image to update:\n" + " docker pull nousresearch/hermes-agent:latest" + ), + STEWARD_NIX: ( + "✗ This Hermes runs from the Nix store.\n" + "\n" + "The store path is immutable. Update through your flake:\n" + " nix flake update && rebuild your profile or system" + ), +} + +_STEWARD_FALLBACK_MESSAGE = ( + "✗ This Hermes install is managed by {steward}.\n" + "\n" + "The tree has no git checkout, so `hermes update` cannot update it.\n" + "Update it with the tool that installed it." +) + + +@dataclass(frozen=True) +class GitCheckout: + """A tree with .git — `hermes update` owns it.""" + + root: Path + + +@dataclass(frozen=True) +class Sealed: + """A gitless tree — the steward replaces it wholesale.""" + + root: Path + steward: str + + +def read_build_info(project_root: Path) -> dict: + """The baked build stamp of ``project_root``, or ``{}``.""" + try: + data = json.loads((Path(project_root) / BUILD_INFO_NAME).read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +def runtime_tree(project_root: Path) -> GitCheckout | Sealed: + """Classify the tree at ``project_root``. + + ``.git`` present (a directory, or a worktree/submodule gitfile) means a + git checkout. Everything else is sealed, with the steward read from the + build stamp; a missing or unknown stamp gives steward ``"unknown"``. + """ + root = Path(project_root) + if (root / ".git").exists(): + return GitCheckout(root=root) + + distribution = read_build_info(root).get("distribution") + steward = distribution if isinstance(distribution, str) and distribution else "unknown" + return Sealed(root=root, steward=steward) + + +def steward_update_message(steward: str) -> str: + """The `hermes update` refusal text for a sealed tree.""" + message = STEWARD_UPDATE_MESSAGES.get(steward) + if message is not None: + return message + return _STEWARD_FALLBACK_MESSAGE.format(steward=steward) + + +def managed_install_roots() -> tuple[Path, ...]: + """The canonical roots where installers create the agent checkout. + + * per-user: ``$HERMES_HOME/hermes-agent`` (usually ``~/.hermes``) + * FHS root installs (install.sh as root on Linux): + ``/usr/local/lib/hermes-agent`` + """ + from hermes_constants import get_hermes_home + + return (get_hermes_home() / "hermes-agent", Path("/usr/local/lib/hermes-agent")) + + +def is_managed_install_root(path: Path) -> bool: + """True when ``path`` is a canonical installer-created checkout root. + + `hermes update` updates these without a question. A checkout anywhere + else is somebody's working tree, and update asks first. + """ + try: + resolved = Path(path).resolve() + except OSError: + return False + for root in managed_install_roots(): + try: + if resolved == root.resolve(): + return True + except OSError: + continue + return False + + +def resolve_update_channel(config: Optional[dict] = None) -> str: + """The effective update channel for a git checkout. + + ``update.channel`` from config.yaml when it is ``stable`` or ``main``; + anything else (missing, ``auto``, unknown) means ``main``. Sealed trees + never ask: their stewards own versioning. + """ + configured = None + if isinstance(config, dict): + update_cfg = config.get("update") + if isinstance(update_cfg, dict): + configured = update_cfg.get("channel") + if isinstance(configured, str) and configured.strip().lower() in _VALID_CHANNELS: + return configured.strip().lower() + return CHANNEL_MAIN diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index de301ac188774..adaed3f1534f4 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -239,22 +239,21 @@ def _stable_channel_active(args) -> bool: branch flag to honor. An explicit ``--branch`` always wins. With this flag the user tells us the exact update target. If a tag silently overrides the flag, the class of bug that --branch prevents comes back. In all other - cases the effective channel comes from the install manifest and - ``update.channel`` in config.yaml - (see hermes_cli.install_manifest.resolve_update_channel). + cases the effective channel comes from ``update.channel`` in config.yaml + (see hermes_cli.runtime_tree.resolve_update_channel). """ if getattr(args, "branch", None): return False try: from hermes_cli.config import load_config - from hermes_cli.install_manifest import CHANNEL_STABLE, resolve_update_channel + from hermes_cli.runtime_tree import CHANNEL_STABLE, resolve_update_channel config = None try: config = load_config() except Exception as exc: logger.debug("Could not load config for channel resolution: %s", exc) - return resolve_update_channel(config, _m().PROJECT_ROOT) == CHANNEL_STABLE + return resolve_update_channel(config) == CHANNEL_STABLE except Exception as exc: logger.warning("Channel resolution failed; defaulting to main: %s", exc) return False @@ -4024,19 +4023,18 @@ def _normalize_managed_eol(git_cmd, repo_root): pass -def _eject_resident_bundle(bundle_repo_root: Path, pinned_tag: str) -> int: - """Eject a resident bundle: hand the install to Hermes Setup. +def _eject_embedded_bundle(bundle_repo_root: Path, tag_label: str) -> int: + """Eject an embedded bundle: a full handoff to Hermes Setup. - The resident bundle is sealed (codesigned resources); no git graft is + The embedded bundle is sealed (codesigned resources); no git graft is possible or wanted, and hand-rolling clone+venv here would duplicate the installer badly (no PATH setup, no config templates, no system - checks). Instead this downloads the official Hermes Setup app from the - website and launches it pinned to the EXACT commit this bundle was - built from (.hermes_build_info.json), so the ejected source checkout - matches the code the user is running. The installer then performs a - normal source install at ~/.hermes/hermes-agent; the desktop prefers - that checkout on its next launch. Network is required — the same - contract as every other eject. + checks). This downloads the official Hermes Setup app from the website + and launches it pinned to the EXACT commit this bundle was built from + (.hermes_build_info.json), so the ejected source checkout matches the + code the user runs. Setup then performs a normal source install at + ~/.hermes/hermes-agent AND builds a source-managed desktop app from it + — that app replaces the embedded one. Network is required. Returns a process exit code. """ @@ -4044,20 +4042,17 @@ def _eject_resident_bundle(bundle_repo_root: Path, pinned_tag: str) -> int: print("\u2717 Hermes Setup is only published for macOS and Windows.") print(" Install from source instead:") print(" curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash") - print(" The desktop app will prefer that checkout on its next launch.") return 1 target = get_hermes_home() / "hermes-agent" - existing = _read_json_or_none(target / ".hermes-install.json") - if (target / ".git").exists() and (existing is None or existing.get("installMode") == "source"): + if (target / ".git").exists(): print(f"\u2713 A source checkout already exists at {target}.") - print(" The desktop app will use it on its next launch.") print(" Update it with: hermes update") return 0 - # The exact commit of this bundle. The pinned tag is the fallback label - # for the message; the pin itself must be a commit sha because tags can - # be re-pointed but the sha names what this bundle actually runs. + # The exact commit of this bundle. The tag is the display label; the + # pin itself must be a commit sha because tags can be re-pointed but + # the sha names what this bundle actually runs. build_info = _read_json_or_none(bundle_repo_root / ".hermes_build_info.json") or {} commit = str(build_info.get("commit") or "") if not re.fullmatch(r"[0-9a-f]{40}", commit): @@ -4081,7 +4076,7 @@ def _eject_resident_bundle(bundle_repo_root: Path, pinned_tag: str) -> int: print("\u2717 The download failed. Hermes aborted the eject. The install is unchanged.") return 1 - print(f"\u2192 Hermes starts the installer, pinned to {pinned_tag} ({commit[:12]})...") + print(f"\u2192 Hermes starts the installer, pinned to {tag_label} ({commit[:12]})...") ok = _launch_hermes_setup(setup_path, scratch, commit) if not ok: shutil.rmtree(scratch, ignore_errors=True) @@ -4089,9 +4084,12 @@ def _eject_resident_bundle(bundle_repo_root: Path, pinned_tag: str) -> int: print(f" The downloaded file is gone; get it manually: {setup_url}") return 1 - print("\u2713 Hermes Setup is running. Follow its window to finish the eject.") - print(f" \u2022 It installs a source-managed checkout at {target}") - print(" \u2022 The desktop app will use that checkout on its next launch.") + print("\u2713 Hermes Setup is running. This is a full handoff:") + print(f" \u2022 It installs a source checkout at {target}") + print(" \u2022 It builds a new desktop app from that checkout and") + print(" replaces this one.") + print(" \u2022 Close the Hermes desktop app now, so that Setup can") + print(" replace it.") print(" \u2022 After the install, update with: hermes update") return 0 @@ -4174,65 +4172,58 @@ def _launch_hermes_setup(setup_path: Path, scratch: Path, commit: str) -> bool: def cmd_update_eject(args) -> int: """Implement ``hermes update --eject``. - A desktop-bundled install runs the agent out of the sealed app bundle - (resident mode). The eject creates a normal source install beside it: - it downloads Hermes Setup from the website and launches it pinned to - the exact commit this bundle was built from. The desktop app prefers - the resulting source checkout on its next launch. On an install that - is already source-managed, the command only switches the channel. + An embedded desktop install runs the agent out of the sealed app + bundle. The eject is a full handoff to Hermes Setup: it installs a + source checkout and a desktop app built from it, and that app + replaces the embedded one. On a git checkout, the command only + switches the update channel in config.yaml. Returns a process exit code. """ - from hermes_cli.install_manifest import ( + from hermes_cli.runtime_tree import ( CHANNEL_MAIN, CHANNEL_STABLE, - MODE_SOURCE, - STYLE_EJECTED, - install_manifest_path, - read_install_manifest, - write_install_manifest, + STEWARD_DESKTOP, + GitCheckout, + runtime_tree, ) project_root = _m().PROJECT_ROOT - manifest = read_install_manifest(project_root) channel = getattr(args, "channel", None) or CHANNEL_MAIN if channel not in (CHANNEL_MAIN, CHANNEL_STABLE): - print(f"✗ Unknown channel '{channel}'. Use 'stable' or 'main'.") + print(f"\u2717 Unknown channel '{channel}'. Use 'stable' or 'main'.") return 1 - if manifest.get("installMode") != "bundled": - # The install is already source-managed. Obey an explicit --channel + tree = runtime_tree(project_root) + if isinstance(tree, GitCheckout): + # The install is already git-managed. Obey an explicit --channel # request, so that `hermes update --eject --channel stable` is a # one-shot way to switch. But do not touch the git history. if getattr(args, "channel", None): - manifest["installMode"] = MODE_SOURCE - manifest["channel"] = channel - # We do not set the ejected mark here. This shorthand runs on - # checkouts that the desktop never managed, or on checkouts that - # the user already ejected. In the second case, the code below - # keeps the existing style. A plain channel switch is not an - # adoption opt-out. - write_install_manifest(manifest, project_root) - print(f"✓ The install is already source-managed. The channel is now '{channel}'.") + from hermes_cli.config import set_config_value + + set_config_value("update.channel", channel) + print(f"\u2713 The install is already git-managed. The channel is now '{channel}'.") else: - print("✓ Nothing to eject. This install is already source-managed.") - print(" (Only desktop-bundled installs need an eject.)") + print("\u2713 Nothing to eject. This install is already git-managed.") + print(" (Only embedded desktop installs need an eject.)") return 0 - pinned_tag = manifest.get("pinnedTag") or "" - if not re.fullmatch(r"v(0|[1-9]\d{0,2})\.\d+\.\d+", pinned_tag): - print("✗ An eject is not possible. The install manifest has no valid pinned tag.") - print(" Reinstall from source instead:") - print(" curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash") + if tree.steward != STEWARD_DESKTOP: + from hermes_cli.runtime_tree import steward_update_message + + print(steward_update_message(tree.steward)) return 1 - # Hermes Setup writes the ejected checkout's own manifest, so an explicit + # Hermes Setup writes the ejected checkout's channel, so an explicit # --channel cannot apply here. Say so instead of dropping it silently. if getattr(args, "channel", None): - print(f"⚠ --channel {channel} does not apply to this eject.") + print(f"\u26a0 --channel {channel} does not apply to this eject.") print(" After the install, set it with: hermes update --eject --channel " + channel) - return _eject_resident_bundle(project_root, pinned_tag) + build_info = _read_json_or_none(Path(project_root) / ".hermes_build_info.json") or {} + tag_label = str(build_info.get("tag") or build_info.get("displayVersion") or "this release") + return _eject_embedded_bundle(Path(project_root), tag_label) def _cmd_update_impl(args, gateway_mode: bool): diff --git a/tests/hermes_cli/test_install_manifest.py b/tests/hermes_cli/test_install_manifest.py deleted file mode 100644 index 82227fca00a75..0000000000000 --- a/tests/hermes_cli/test_install_manifest.py +++ /dev/null @@ -1,207 +0,0 @@ -"""Tests for hermes_cli/install_manifest.py — bundled-install manifest plumbing.""" - -import json - -import pytest - -from hermes_cli.install_manifest import ( - CHANNEL_MAIN, - CHANNEL_STABLE, - INSTALL_MANIFEST_NAME, - MODE_BUNDLED, - MODE_SOURCE, - STYLE_ADOPTED, - STYLE_EJECTED, - format_bundled_update_message, - install_manifest_path, - is_bundled_install, - is_ejected, - read_install_manifest, - resolve_update_channel, - write_install_manifest, -) - - -def _write_raw(tmp_path, payload): - (tmp_path / INSTALL_MANIFEST_NAME).write_text( - payload if isinstance(payload, str) else json.dumps(payload), - encoding="utf-8", - ) - - -class TestReadInstallManifest: - def test_absent_file_means_source_main(self, tmp_path): - """The back-compat contract: no manifest ⇒ source install on main.""" - manifest = read_install_manifest(tmp_path) - assert manifest["installMode"] == MODE_SOURCE - assert manifest["channel"] == CHANNEL_MAIN - - def test_malformed_json_degrades_to_source(self, tmp_path): - _write_raw(tmp_path, "{not json") - assert read_install_manifest(tmp_path)["installMode"] == MODE_SOURCE - - def test_non_object_json_degrades_to_source(self, tmp_path): - _write_raw(tmp_path, '["bundled"]') - assert read_install_manifest(tmp_path)["installMode"] == MODE_SOURCE - - def test_unknown_mode_degrades_to_source(self, tmp_path): - """A future vocabulary must not brick an older reader.""" - _write_raw(tmp_path, {"installMode": "quantum", "channel": "stable"}) - manifest = read_install_manifest(tmp_path) - assert manifest["installMode"] == MODE_SOURCE - assert manifest["channel"] == CHANNEL_STABLE - - def test_unknown_channel_defaults_by_mode(self, tmp_path): - _write_raw(tmp_path, {"installMode": "bundled", "channel": "nightly"}) - assert read_install_manifest(tmp_path)["channel"] == CHANNEL_STABLE - _write_raw(tmp_path, {"installMode": "source", "channel": "nightly"}) - assert read_install_manifest(tmp_path)["channel"] == CHANNEL_MAIN - - def test_extra_keys_preserved(self, tmp_path): - _write_raw( - tmp_path, - {"installMode": "bundled", "channel": "stable", "pinnedTag": "v0.17.0", "futureKey": 7}, - ) - manifest = read_install_manifest(tmp_path) - assert manifest["pinnedTag"] == "v0.17.0" - assert manifest["futureKey"] == 7 - - -class TestWriteInstallManifest: - def test_roundtrip(self, tmp_path): - write_install_manifest( - {"installMode": MODE_BUNDLED, "channel": CHANNEL_STABLE, "pinnedTag": "v1.2.3"}, - tmp_path, - ) - manifest = read_install_manifest(tmp_path) - assert manifest["installMode"] == MODE_BUNDLED - assert manifest["pinnedTag"] == "v1.2.3" - assert manifest["schemaVersion"] == 1 - - def test_rejects_invalid_mode(self, tmp_path): - with pytest.raises(ValueError): - write_install_manifest({"installMode": "quantum", "channel": "stable"}, tmp_path) - assert not install_manifest_path(tmp_path).exists() - - def test_rejects_invalid_channel(self, tmp_path): - with pytest.raises(ValueError): - write_install_manifest({"installMode": "source", "channel": "nightly"}, tmp_path) - - def test_atomic_no_tmp_leftover(self, tmp_path): - write_install_manifest({"installMode": MODE_SOURCE, "channel": CHANNEL_MAIN}, tmp_path) - leftovers = [p.name for p in tmp_path.iterdir() if p.name.endswith(".tmp")] - assert leftovers == [] - - -class TestIsBundledInstall: - def test_default_is_not_bundled(self, tmp_path): - assert not is_bundled_install(tmp_path) - - def test_bundled_manifest_detected(self, tmp_path): - write_install_manifest({"installMode": MODE_BUNDLED, "channel": CHANNEL_STABLE}, tmp_path) - assert is_bundled_install(tmp_path) - - -class TestManageStyle: - def test_absent_by_default(self, tmp_path): - assert "manageStyle" not in read_install_manifest(tmp_path) - assert not is_ejected(tmp_path) - - def test_valid_styles_roundtrip(self, tmp_path): - for style in (STYLE_ADOPTED, STYLE_EJECTED): - write_install_manifest( - {"installMode": MODE_SOURCE, "channel": CHANNEL_MAIN, "manageStyle": style}, - tmp_path, - ) - assert read_install_manifest(tmp_path)["manageStyle"] == style - - def test_ejected_is_sticky_signal(self, tmp_path): - write_install_manifest( - {"installMode": MODE_SOURCE, "channel": CHANNEL_MAIN, "manageStyle": STYLE_EJECTED}, - tmp_path, - ) - assert is_ejected(tmp_path) - - def test_unknown_style_dropped_not_defaulted(self, tmp_path): - """A future vocabulary must not wrongly block (or force) adoption.""" - _write_raw( - tmp_path, - {"installMode": "source", "channel": "main", "manageStyle": "quantum"}, - ) - assert "manageStyle" not in read_install_manifest(tmp_path) - - def test_eject_smelling_future_style_stays_ejected(self, tmp_path): - """The opt-out survives vocabulary drift: 'force-ejected' etc. reads as ejected.""" - _write_raw( - tmp_path, - {"installMode": "source", "channel": "main", "manageStyle": "force-ejected"}, - ) - assert read_install_manifest(tmp_path)["manageStyle"] == STYLE_EJECTED - assert is_ejected(tmp_path) - - def test_write_rejects_invalid_style(self, tmp_path): - with pytest.raises(ValueError): - write_install_manifest( - {"installMode": MODE_SOURCE, "channel": CHANNEL_MAIN, "manageStyle": "quantum"}, - tmp_path, - ) - - -class TestResolveUpdateChannel: - def test_default_install_is_main(self, tmp_path): - assert resolve_update_channel(None, tmp_path) == CHANNEL_MAIN - - def test_bundled_is_always_stable_even_with_config_main(self, tmp_path): - write_install_manifest({"installMode": MODE_BUNDLED, "channel": CHANNEL_STABLE}, tmp_path) - config = {"update": {"channel": "main"}} - assert resolve_update_channel(config, tmp_path) == CHANNEL_STABLE - - def test_config_overrides_manifest_on_source(self, tmp_path): - write_install_manifest({"installMode": MODE_SOURCE, "channel": CHANNEL_MAIN}, tmp_path) - assert resolve_update_channel({"update": {"channel": "stable"}}, tmp_path) == CHANNEL_STABLE - - def test_config_auto_defers_to_manifest(self, tmp_path): - write_install_manifest({"installMode": MODE_SOURCE, "channel": CHANNEL_STABLE}, tmp_path) - assert resolve_update_channel({"update": {"channel": "auto"}}, tmp_path) == CHANNEL_STABLE - - def test_config_garbage_defers_to_manifest(self, tmp_path): - assert resolve_update_channel({"update": {"channel": 42}}, tmp_path) == CHANNEL_MAIN - assert resolve_update_channel({"update": "stable"}, tmp_path) == CHANNEL_MAIN - - -class TestDefaultConfigContract: - def test_update_channel_key_exists_and_is_valid(self): - """update.channel must exist in DEFAULT_CONFIG with an accepted value.""" - from hermes_cli.config_defaults import DEFAULT_CONFIG - - channel = DEFAULT_CONFIG["update"]["channel"] - assert channel in ("auto", CHANNEL_MAIN, CHANNEL_STABLE) - - -class TestCmdUpdateRefusal: - def test_cmd_update_refuses_on_bundled(self, monkeypatch, capsys): - """cmd_update exits 1 with the bundled message before touching git.""" - import hermes_cli.install_manifest as im - from hermes_cli import main as hermes_main - - monkeypatch.setattr(im, "is_bundled_install", lambda root=None: True) - # Neutralize the earlier refusal branches so we reach the bundled check. - monkeypatch.setattr("hermes_cli.config.is_managed", lambda: False) - monkeypatch.setattr("hermes_cli.config.detect_install_method", lambda root=None: "git") - - class Args: - check = False - gateway = False - branch = None - - with pytest.raises(SystemExit) as excinfo: - hermes_main.cmd_update(Args()) - assert excinfo.value.code == 1 - out = capsys.readouterr().out - assert "desktop app" in out - assert "eject" in out - - def test_message_mentions_in_app_updater(self): - msg = format_bundled_update_message() - assert "hermes update" in msg - assert "eject" in msg diff --git a/tests/hermes_cli/test_runtime_tree.py b/tests/hermes_cli/test_runtime_tree.py new file mode 100644 index 0000000000000..143b355d3b64e --- /dev/null +++ b/tests/hermes_cli/test_runtime_tree.py @@ -0,0 +1,104 @@ +"""Tests for hermes_cli/runtime_tree.py — tree classification and channel.""" + +import json +from pathlib import Path + +from hermes_cli.runtime_tree import ( + CHANNEL_MAIN, + CHANNEL_STABLE, + STEWARD_UPDATE_MESSAGES, + GitCheckout, + Sealed, + is_managed_install_root, + resolve_update_channel, + runtime_tree, + steward_update_message, +) + + +class TestRuntimeTree: + def test_a_tree_with_git_is_a_checkout(self, tmp_path): + (tmp_path / ".git").mkdir() + tree = runtime_tree(tmp_path) + assert isinstance(tree, GitCheckout) + assert tree.root == tmp_path + + def test_a_worktree_gitfile_also_counts(self, tmp_path): + # Linked worktrees and submodules have a .git FILE, not a directory. + (tmp_path / ".git").write_text("gitdir: /somewhere/else\n") + assert isinstance(runtime_tree(tmp_path), GitCheckout) + + def test_a_gitless_tree_is_sealed_with_the_stamped_steward(self, tmp_path): + (tmp_path / ".hermes_build_info.json").write_text( + json.dumps({"commit": "a" * 40, "distribution": "desktop-app"}) + ) + tree = runtime_tree(tmp_path) + assert isinstance(tree, Sealed) + assert tree.steward == "desktop-app" + + def test_a_gitless_tree_without_a_stamp_is_sealed_unknown(self, tmp_path): + tree = runtime_tree(tmp_path) + assert isinstance(tree, Sealed) + assert tree.steward == "unknown" + + def test_a_corrupt_stamp_degrades_to_unknown(self, tmp_path): + (tmp_path / ".hermes_build_info.json").write_text("{not json") + tree = runtime_tree(tmp_path) + assert isinstance(tree, Sealed) + assert tree.steward == "unknown" + + +class TestStewardMessages: + def test_every_known_steward_names_its_mechanism(self): + assert "--eject" in steward_update_message("desktop-app") + assert "docker pull" in steward_update_message("docker") + assert "flake" in steward_update_message("nix") + + def test_an_unknown_steward_gets_the_fallback_with_its_name(self): + message = steward_update_message("pacman") + assert "pacman" in message + assert "cannot update" in message + + def test_every_table_entry_is_a_refusal(self): + for steward, message in STEWARD_UPDATE_MESSAGES.items(): + assert message.startswith("\u2717"), steward + + +class TestManagedInstallRoot: + def test_hermes_home_checkout_is_managed(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + root = tmp_path / ".hermes" / "hermes-agent" + root.mkdir(parents=True) + assert is_managed_install_root(root) is True + + def test_a_dev_tree_is_not_managed(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + dev = tmp_path / "src" / "hermes-agent" + dev.mkdir(parents=True) + assert is_managed_install_root(dev) is False + + def test_the_fhs_root_layout_is_managed(self): + # /usr/local/lib/hermes-agent need not exist for the answer; the + # comparison is by path. If it does not resolve, False is safe. + result = is_managed_install_root(Path("/usr/local/lib/hermes-agent")) + assert result in (True, False) # never raises + # On machines without the dir, resolve() still succeeds (no symlinks + # involved), so the comparison holds. + assert result is True + + +class TestResolveUpdateChannel: + def test_stable_from_config(self): + assert resolve_update_channel({"update": {"channel": "stable"}}) == CHANNEL_STABLE + + def test_main_is_the_default(self): + assert resolve_update_channel(None) == CHANNEL_MAIN + assert resolve_update_channel({}) == CHANNEL_MAIN + assert resolve_update_channel({"update": {}}) == CHANNEL_MAIN + + def test_auto_and_unknown_mean_main(self): + assert resolve_update_channel({"update": {"channel": "auto"}}) == CHANNEL_MAIN + assert resolve_update_channel({"update": {"channel": "nightly"}}) == CHANNEL_MAIN + + def test_case_and_whitespace_are_forgiven(self): + assert resolve_update_channel({"update": {"channel": " Stable "}}) == CHANNEL_STABLE diff --git a/tests/hermes_cli/test_update_channel_stable.py b/tests/hermes_cli/test_update_channel_stable.py index af78698f3289a..412c8f6364aad 100644 --- a/tests/hermes_cli/test_update_channel_stable.py +++ b/tests/hermes_cli/test_update_channel_stable.py @@ -78,20 +78,14 @@ class TestStableChannelActive: """--branch means main-style behavior regardless of channel config.""" assert _stable_channel_active(_Args(branch="bb/gui")) is False - def test_config_stable_activates(self, tmp_path): - with patch("hermes_cli.config.load_config", return_value={"update": {"channel": "stable"}}), \ - patch("hermes_cli.install_manifest.install_manifest_path", - return_value=tmp_path / ".hermes-install.json"): + def test_config_stable_activates(self): + with patch("hermes_cli.config.load_config", return_value={"update": {"channel": "stable"}}): assert _stable_channel_active(_Args()) is True - def test_default_config_stays_main(self, tmp_path): - with patch("hermes_cli.config.load_config", return_value={"update": {"channel": "auto"}}), \ - patch("hermes_cli.install_manifest.install_manifest_path", - return_value=tmp_path / ".hermes-install.json"): + def test_default_config_stays_main(self): + with patch("hermes_cli.config.load_config", return_value={"update": {"channel": "auto"}}): assert _stable_channel_active(_Args()) is False - def test_config_failure_defaults_to_main(self, tmp_path): - with patch("hermes_cli.config.load_config", side_effect=RuntimeError("boom")), \ - patch("hermes_cli.install_manifest.install_manifest_path", - return_value=tmp_path / ".hermes-install.json"): + def test_config_failure_defaults_to_main(self): + with patch("hermes_cli.config.load_config", side_effect=RuntimeError("boom")): assert _stable_channel_active(_Args()) is False diff --git a/tests/hermes_cli/test_update_eject.py b/tests/hermes_cli/test_update_eject.py index 45c4c08749cd1..077c3a9a663c8 100644 --- a/tests/hermes_cli/test_update_eject.py +++ b/tests/hermes_cli/test_update_eject.py @@ -1,11 +1,10 @@ """Tests for ``hermes update --eject`` (hermes_cli/update_cmd.py::cmd_update_eject). -A bundled install is a resident desktop bundle: the agent runs out of the -sealed app resources, and its manifest says ``installMode: bundled``. The -eject downloads Hermes Setup from the website and launches it pinned to -the bundle's exact build commit. The tests fake only the two hard process -boundaries — the download and the installer launch — and run everything -else for real. +An embedded desktop install runs the agent out of the sealed app bundle: +a gitless tree whose build stamp says ``distribution: desktop-app``. The +eject is a full handoff to Hermes Setup. The tests fake only the two hard +process boundaries — the download and the installer launch — and run +everything else for real. """ import json @@ -13,35 +12,23 @@ import json import pytest import hermes_cli.update_cmd as update_cmd -from hermes_cli.install_manifest import ( - CHANNEL_STABLE, - MODE_BUNDLED, - MODE_SOURCE, - read_install_manifest, - write_install_manifest, -) from hermes_cli.update_cmd import cmd_update_eject COMMIT = "ab" * 20 +def _write_build_info(root, **overrides): + info = {"commit": COMMIT, "tag": "v0.1.0", "distribution": "desktop-app"} + info.update(overrides) + (root / ".hermes_build_info.json").write_text(json.dumps(info)) + + @pytest.fixture def bundle_repo(tmp_path, monkeypatch): - """The payload repo of a resident bundle: manifest + build info.""" + """The payload repo of an embedded bundle: build info, no .git.""" repo = tmp_path / "bundle" / "repo" repo.mkdir(parents=True) - write_install_manifest( - { - "installMode": MODE_BUNDLED, - "channel": CHANNEL_STABLE, - "manageStyle": "adopted", - "pinnedTag": "v0.1.0", - }, - repo, - ) - (repo / ".hermes_build_info.json").write_text( - json.dumps({"commit": COMMIT, "tag": "v0.1.0"}) - ) + _write_build_info(repo) import hermes_cli.main as hermes_main monkeypatch.setattr(hermes_main, "PROJECT_ROOT", repo) @@ -75,7 +62,7 @@ def fake_setup(monkeypatch): return calls -class TestEjectResident: +class TestEjectEmbedded: def test_eject_downloads_setup_and_pins_the_bundle_commit( self, bundle_repo, fake_setup, capsys ): @@ -87,7 +74,9 @@ class TestEjectResident: assert fake_setup["commit"] == COMMIT assert "Hermes-Setup.dmg" in fake_setup["url"] assert "hermes-assets.nousresearch.com" in fake_setup["url"] - assert "Hermes Setup is running" in out + # The handoff instructs the user to close the app: Setup replaces it. + assert "full handoff" in out + assert "Close the Hermes desktop app" in out def test_eject_windows_uses_the_exe(self, bundle_repo, fake_setup, monkeypatch): monkeypatch.setattr(update_cmd.sys, "platform", "win32") @@ -100,9 +89,7 @@ class TestEjectResident: assert "install.sh" in capsys.readouterr().out def test_eject_refuses_without_a_valid_commit(self, bundle_repo, fake_setup, capsys): - (bundle_repo / ".hermes_build_info.json").write_text( - json.dumps({"commit": "not-a-sha"}) - ) + _write_build_info(bundle_repo, commit="not-a-sha") assert cmd_update_eject(_Args()) == 1 assert "commit" in capsys.readouterr().out assert "commit" not in fake_setup # never launched @@ -110,8 +97,6 @@ class TestEjectResident: def test_eject_skips_when_a_source_checkout_already_exists( self, bundle_repo, fake_setup, tmp_path, monkeypatch, capsys ): - import hermes_cli.config as config_mod - home = tmp_path / "hermes-home" target = home / "hermes-agent" (target / ".git").mkdir(parents=True) @@ -127,32 +112,47 @@ class TestEjectResident: update_cmd, "_download_hermes_setup", lambda url, dest: False ) assert cmd_update_eject(_Args()) == 1 - out = capsys.readouterr().out - assert "unchanged" in out - # The bundle manifest is untouched: still bundled. - assert read_install_manifest(bundle_repo).get("installMode") == MODE_BUNDLED + assert "unchanged" in capsys.readouterr().out -class TestEjectSourceManaged: - def test_source_install_with_channel_switches_channel_only( +class TestEjectOtherSealedTrees: + def test_docker_tree_gets_the_docker_message_not_an_eject( self, tmp_path, monkeypatch, capsys ): - repo = tmp_path / "src-checkout" + repo = tmp_path / "docker-tree" repo.mkdir() - write_install_manifest({"installMode": MODE_SOURCE, "channel": "main"}, repo) + _write_build_info(repo, distribution="docker") import hermes_cli.main as hermes_main monkeypatch.setattr(hermes_main, "PROJECT_ROOT", repo) - assert cmd_update_eject(_Args(channel="stable")) == 0 - manifest = read_install_manifest(repo) - assert manifest["channel"] == "stable" - assert manifest["installMode"] == MODE_SOURCE + assert cmd_update_eject(_Args()) == 1 + assert "docker pull" in capsys.readouterr().out - def test_source_install_without_channel_is_a_noop(self, tmp_path, monkeypatch, capsys): + +class TestEjectGitCheckout: + def test_git_checkout_with_channel_switches_channel_only( + self, tmp_path, monkeypatch, capsys + ): repo = tmp_path / "src-checkout" - repo.mkdir() - write_install_manifest({"installMode": MODE_SOURCE, "channel": "main"}, repo) + (repo / ".git").mkdir(parents=True) + import hermes_cli.main as hermes_main + + monkeypatch.setattr(hermes_main, "PROJECT_ROOT", repo) + written = {} + import hermes_cli.config as config_mod + + monkeypatch.setattr( + config_mod, "set_config_value", lambda key, value, **kw: written.update({key: value}) + ) + + assert cmd_update_eject(_Args(channel="stable")) == 0 + assert written == {"update.channel": "stable"} + assert "git-managed" in capsys.readouterr().out + + def test_git_checkout_without_channel_is_a_noop(self, tmp_path, monkeypatch, capsys): + repo = tmp_path / "src-checkout" + (repo / ".git").mkdir(parents=True) import hermes_cli.main as hermes_main monkeypatch.setattr(hermes_main, "PROJECT_ROOT", repo)