hermes-agent/tests/hermes_cli/test_banner_git_state.py
Ben 66489f38c7 fix(docker): bake build-time git SHA into the image
`hermes dump` and the startup banner both call `git rev-parse HEAD` to
report the running commit, but `.dockerignore` line 2 excludes `.git` —
so inside the published image `hermes dump` shows
`version: ... [(unknown)]` and the banner drops its `· upstream <sha>`
suffix entirely.  That makes support triage from container bug reports
impossible: we can't tell which commit the user is actually running.

Fix: thread the build-time SHA through as a Docker build-arg, write it
to `/opt/hermes/.hermes_build_sha` in the image, and have a new
`hermes_cli/build_info.get_build_sha()` read it as a fallback after the
existing live-git lookup fails.  Output format is unchanged in both
callsites — same 8-char short SHA whether resolved live or baked.

Wiring:
  - Dockerfile: `ARG HERMES_GIT_SHA=` + write-file step after the source
    copy.  Empty/missing arg → no file written → callers fall through to
    live git (so local `docker build` without --build-arg is unchanged).
  - docker-publish.yml: passes `HERMES_GIT_SHA=${{ github.sha }}` on all
    four build-push-action steps (amd64/arm64, smoke-test + final push).
  - dump.py:_get_git_commit() / banner.py:get_git_banner_state(): try
    live git first, fall back to baked SHA, then to legacy `(unknown)`
    / None.  Banner returns `upstream == local, ahead=0` because a built
    image is by definition pinned to one commit.

Coverage:
  - Unit tests cover build_info (file present/absent/empty/error,
    truncation, whitespace), dump (live-git wins, both fallbacks,
    identical output-format regression guard), and banner (no-repo +
    baked, no-repo + no-sha, shallow-clone fallback).
  - tests/docker/test_dump_build_sha.py is an integration regression
    guard that runs against the real image, reads
    `/opt/hermes/.hermes_build_sha`, and asserts `hermes dump` surfaces
    its content (or stays at `(unknown)` if no file).
  - Verified end-to-end: `docker build --build-arg HERMES_GIT_SHA=abc...`
    → `docker run ... dump` reports `[abc12345]`; without the build-arg
    it reports `[(unknown)]` as before.
2026-05-28 15:14:05 +10:00

116 lines
4.1 KiB
Python

from unittest.mock import MagicMock, patch
def test_format_banner_version_label_without_git_state():
from hermes_cli import banner
with patch.object(banner, "get_git_banner_state", return_value=None):
value = banner.format_banner_version_label()
assert value == f"Hermes Agent v{banner.VERSION} ({banner.RELEASE_DATE})"
def test_format_banner_version_label_on_upstream_main():
from hermes_cli import banner
with patch.object(
banner,
"get_git_banner_state",
return_value={"upstream": "b2f477a3", "local": "b2f477a3", "ahead": 0},
):
value = banner.format_banner_version_label()
assert value.endswith("· upstream b2f477a3")
assert "local" not in value
def test_format_banner_version_label_with_carried_commits():
from hermes_cli import banner
with patch.object(
banner,
"get_git_banner_state",
return_value={"upstream": "b2f477a3", "local": "af8aad31", "ahead": 3},
):
value = banner.format_banner_version_label()
assert "upstream b2f477a3" in value
assert "local af8aad31" in value
assert "+3 carried commits" in value
def test_get_git_banner_state_reads_origin_and_head(tmp_path):
from hermes_cli import banner
repo_dir = tmp_path / "repo"
(repo_dir / ".git").mkdir(parents=True)
results = {
("git", "rev-parse", "--short=8", "origin/main"): MagicMock(returncode=0, stdout="b2f477a3\n"),
("git", "rev-parse", "--short=8", "HEAD"): MagicMock(returncode=0, stdout="af8aad31\n"),
("git", "rev-list", "--count", "origin/main..HEAD"): MagicMock(returncode=0, stdout="3\n"),
}
def fake_run(cmd, **kwargs):
key = tuple(cmd)
if key not in results:
raise AssertionError(f"unexpected command: {cmd}")
return results[key]
with patch("hermes_cli.banner.subprocess.run", side_effect=fake_run):
state = banner.get_git_banner_state(repo_dir)
assert state == {"upstream": "b2f477a3", "local": "af8aad31", "ahead": 3}
def test_get_git_banner_state_falls_back_to_build_sha_when_no_repo():
"""Docker image case: no .git checkout — baked build SHA fills the gap.
``_resolve_repo_dir`` returns None when neither the running code's
parent nor ``$HERMES_HOME/hermes-agent/`` is a git repo (the canonical
case inside the published container, where .git is dockerignored).
The banner should still report the build SHA so support bug reports
can identify the running commit.
"""
from hermes_cli import banner
with patch.object(banner, "_resolve_repo_dir", return_value=None), \
patch("hermes_cli.build_info.get_build_sha", return_value="abcdef12"):
state = banner.get_git_banner_state()
assert state == {"upstream": "abcdef12", "local": "abcdef12", "ahead": 0}
def test_get_git_banner_state_returns_none_when_no_repo_and_no_build_sha():
"""Pip-installed wheel with neither git checkout nor baked SHA → None.
Banner correctly omits the upstream/local suffix in this case.
"""
from hermes_cli import banner
with patch.object(banner, "_resolve_repo_dir", return_value=None), \
patch("hermes_cli.build_info.get_build_sha", return_value=None):
state = banner.get_git_banner_state()
assert state is None
def test_get_git_banner_state_falls_back_when_live_git_returns_nothing(tmp_path):
"""Shallow clone without origin/main → still surface build SHA if baked.
Some install paths (e.g. ``git clone --depth 1`` without a remote) have
a ``.git`` directory but ``git rev-parse origin/main`` fails. When that
happens AND a baked SHA exists, return the baked one instead of None.
"""
from hermes_cli import banner
repo_dir = tmp_path / "repo"
(repo_dir / ".git").mkdir(parents=True)
# All git invocations fail (returncode=1, empty stdout).
failed = MagicMock(returncode=1, stdout="")
with patch("hermes_cli.banner.subprocess.run", return_value=failed), \
patch("hermes_cli.build_info.get_build_sha", return_value="cafef00d"):
state = banner.get_git_banner_state(repo_dir)
assert state == {"upstream": "cafef00d", "local": "cafef00d", "ahead": 0}