diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index dec5aecdb..1955b009d 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -225,6 +225,25 @@ def check_for_updates() -> Optional[int]: cache_file = hermes_home / ".update_check" embedded_rev = os.environ.get("HERMES_REVISION") or None + # Docker images have no working tree to count commits against — the + # published image excludes `.git` (see .dockerignore) and sets no + # HERMES_REVISION (that's nix-only). Without this guard the checks below + # fall through to `check_via_pypi()`, whose PyPI-version mismatch flag (1) + # then gets rendered by the CLI banner and the TUI badge as a phantom + # "1 commit behind" — even though no git repo or commit math is involved, + # and `hermes update` correctly refuses to run in-place inside the + # container anyway. The dashboard's REST `/api/hermes/update/check` + # endpoint already short-circuits docker the same way (web_server.py); + # mirror that here so the banner/TUI surfaces agree. Returning None makes + # both the Rich banner (build_welcome_banner) and the Ink badge + # (branding.tsx, guarded on `typeof === 'number' && > 0`) show nothing. + try: + from hermes_cli.config import detect_install_method + if detect_install_method() == "docker": + return None + except Exception: + pass + # Read cache — invalidate if the embedded rev OR installed version has # changed since the last check. The version guard matters for pip installs: # `check_via_pypi()` compares against VERSION, so a `pip install --upgrade` diff --git a/tests/hermes_cli/test_update_check.py b/tests/hermes_cli/test_update_check.py index c7cc1c867..47c018cbb 100644 --- a/tests/hermes_cli/test_update_check.py +++ b/tests/hermes_cli/test_update_check.py @@ -131,6 +131,62 @@ def test_check_for_updates_fallback_to_project_root(tmp_path, monkeypatch): assert mock_run.call_count >= 1 +def test_check_for_updates_docker_returns_none(tmp_path, monkeypatch): + """Inside the Docker image, check_for_updates() must short-circuit to None. + + Regression: the published image excludes .git (.dockerignore) and sets no + HERMES_REVISION (nix-only), so without a docker guard check_for_updates() + falls through to check_via_pypi(), whose version-mismatch flag (1) gets + rendered by both the Rich banner and the Ink TUI badge as a phantom + "1 commit behind" — despite there being no git repo or commit math in the + container, and `hermes update` correctly refusing to run there. The guard + must return None (so the > 0 render guards stay false) AND not reach the + git/pypi probes or write a cache entry. + """ + import hermes_cli.banner as banner + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + cache_file = tmp_path / ".update_check" + + with patch("hermes_cli.config.detect_install_method", return_value="docker"), \ + patch("hermes_cli.banner.subprocess.run") as mock_run, \ + patch("hermes_cli.banner.check_via_pypi") as mock_pypi: + result = banner.check_for_updates() + + assert result is None + # Neither the git probe nor the PyPI probe should have run. + mock_run.assert_not_called() + mock_pypi.assert_not_called() + # And no phantom "behind" count should be cached for the next 6h. + assert not cache_file.exists() + + +def test_check_for_updates_non_docker_still_checks(tmp_path, monkeypatch): + """The docker guard must NOT over-broaden: a pip install still version-checks. + + Invariant guarding against the guard firing for non-docker methods — pip + installs legitimately reach check_via_pypi() and surface a real update. + """ + import hermes_cli.banner as banner + + # No local git checkout -> the PyPI (pip-install) path is exercised. + fake_banner = tmp_path / "hermes_cli" / "banner.py" + fake_banner.parent.mkdir(parents=True, exist_ok=True) + fake_banner.touch() + monkeypatch.setattr(banner, "__file__", str(fake_banner)) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.delenv("HERMES_REVISION", raising=False) + + with patch("hermes_cli.config.detect_install_method", return_value="pip"), \ + patch("hermes_cli.banner.subprocess.run") as mock_run, \ + patch("hermes_cli.banner.check_via_pypi", return_value=1) as mock_pypi: + result = banner.check_for_updates() + + assert result == 1 + mock_pypi.assert_called_once() + mock_run.assert_not_called() + + def test_prefetch_non_blocking(): """prefetch_update_check() should return immediately without blocking.""" import hermes_cli.banner as banner