diff --git a/hermes_cli/version_info.py b/hermes_cli/version_info.py index d3b4005adc..6fb7d32200 100644 --- a/hermes_cli/version_info.py +++ b/hermes_cli/version_info.py @@ -13,6 +13,7 @@ from __future__ import annotations import json import subprocess +import tomllib from dataclasses import dataclass from pathlib import Path from typing import Literal, cast @@ -82,6 +83,29 @@ def _parse_nonnegative(value: str | None) -> int | None: return parsed if parsed >= 0 else None +def _calver_release_version(repo_dir: Path) -> tuple[str, int] | None: + """The version the nearest CalVer release shipped, and the commits since it. + + Releases before semver tags existed are tagged ``vYYYY.M.D`` only; the + version users actually run is in that tag's pyproject. Without this, a + checkout past such a release would compare as "unknown" against plugins' + ``requires_hermes``. + """ + described = _run_git(repo_dir, "describe", "--tags", "--long", "--match", "v2[0-9][0-9][0-9].*", "HEAD") + if not described: + return None + tag, count, _ = described.rsplit("-", 2) + distance = _parse_nonnegative(count) + try: + project = tomllib.loads(_run_git(repo_dir, "show", f"{tag}:pyproject.toml") or "").get("project", {}) + except tomllib.TOMLDecodeError: + return None + version = project.get("version") + if distance is None or not isinstance(version, str) or not STABLE_TAG_RE.fullmatch(f"v{version}"): + return None + return version, distance + + # --- Install stamp reader --------------------------------------------------- def _resolve_stamp_file() -> Path | None: @@ -212,6 +236,8 @@ def _git_version_info(repo_dir: Path, *, include_untracked: bool = False) -> Ver distance = _parse_nonnegative( _run_git(repo_dir, "rev-list", "--count", f"v{base_version}..HEAD") ) if releases else None + if not releases: + base_version, distance = _calver_release_version(repo_dir) or ("unknown", None) short_commit = _run_git(repo_dir, "rev-parse", "--short=7", "HEAD") if base_version == "unknown" and short_commit: display_version = f"git.{short_commit}{'.dirty' if dirty else ''}" diff --git a/tests/hermes_cli/test_version_info.py b/tests/hermes_cli/test_version_info.py index 033f0d7edd..4f166f706a 100644 --- a/tests/hermes_cli/test_version_info.py +++ b/tests/hermes_cli/test_version_info.py @@ -150,6 +150,38 @@ def test_get_version_info_derives_identity_from_reachable_release_tag(tmp_path, assert info.source == "git" +def test_get_version_info_takes_the_version_a_calver_only_release_shipped(tmp_path, monkeypatch): + """Releases tagged only vYYYY.M.D resolve to their pyproject version, not "unknown".""" + repo = tmp_path / "repo" + repo.mkdir() + + def git(*args: str) -> str: + result = subprocess.run( + ["git", *args], cwd=repo, text=True, capture_output=True, check=True, + env={"HOME": str(tmp_path), "PATH": __import__("os").environ["PATH"]}, + ) + return result.stdout.strip() + + git("init", "-q") + git("config", "user.name", "Hermes Test") + git("config", "user.email", "hermes@example.invalid") + (repo / "pyproject.toml").write_text('[project]\nname = "hermes-agent"\nversion = "0.21.4"\n', encoding="utf-8") + git("add", "pyproject.toml") + git("commit", "-qm", "release") + git("tag", "v2026.9.21") + (repo / "pyproject.toml").write_text('[project]\nname = "hermes-agent"\nversion = "0.0.0"\n', encoding="utf-8") + git("commit", "-qam", "next") + + monkeypatch.setattr("hermes_cli.version_info._resolve_stamp_file", lambda: None) + monkeypatch.setattr("hermes_cli.version_info._resolve_repo_dir", lambda: repo) + + info = get_version_info() + + assert info.base_version == "0.21.4" + assert info.distance == 1 + assert info.derived_version == f"0.21.4+1.g{git('rev-parse', '--short=7', 'HEAD')}" + + def test_resolve_stamp_file_honors_install_root(tmp_path, monkeypatch): """Sealed installs (the Nix wrapper) point HERMES_INSTALL_ROOT at the stamp dir.""" stamp = {"commit": "e" * 40, "source": "nix", "distribution": "nix", "updateMechanism": "external"}