diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 7b0beef7bb35f..b0413e2616d77 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -191,6 +191,31 @@ def _check_via_rev(local_rev: str) -> Optional[int]: return 0 if upstream_rev == local_rev else UPDATE_AVAILABLE_NO_COUNT +def _local_head_sha() -> Optional[str]: + """Return the active checkout's HEAD SHA, or None for non-git installs. + + Used as part of the update-check cache key so an in-place ``git pull`` / + rebase that moves HEAD without changing ``VERSION`` or ``HERMES_REVISION`` + (the common case for source installs tracking a fork) self-invalidates the + cached "commits behind" count instead of serving a stale value for the full + 6-hour TTL. See #34491 for the version-bump variant of the same staleness. + """ + repo_dir = _resolve_repo_dir() + if repo_dir is None: + return None + try: + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + capture_output=True, text=True, timeout=5, + cwd=str(repo_dir), + ) + if result.returncode == 0: + return (result.stdout or "").strip() or None + except Exception: + pass + return None + + def _check_via_local_git(repo_dir: Path) -> Optional[int]: """Count commits behind origin/main in a local checkout.""" origin_url = _git_stdout(["remote", "get-url", "origin"], cwd=repo_dir) @@ -327,6 +352,12 @@ def check_for_updates() -> Optional[int]: except Exception: pass + # HEAD SHA participates in the cache key so an in-place `git pull` / rebase + # that moves HEAD without changing VERSION or HERMES_REVISION self-invalidates + # the cached count. Computed AFTER the docker short-circuit so containers + # (no .git) never shell out to git here. + head_sha = _local_head_sha() + # 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` @@ -340,6 +371,7 @@ def check_for_updates() -> Optional[int]: now - cached.get("ts", 0) < _UPDATE_CHECK_CACHE_SECONDS and cached.get("rev") == embedded_rev and cached.get("ver") == VERSION + and cached.get("head") == head_sha ): return cached.get("behind") except Exception: @@ -361,7 +393,7 @@ def check_for_updates() -> Optional[int]: try: cache_file.write_text( - json.dumps({"ts": now, "behind": behind, "rev": embedded_rev, "ver": VERSION}) + json.dumps({"ts": now, "behind": behind, "rev": embedded_rev, "ver": VERSION, "head": head_sha}) ) except Exception: pass diff --git a/tests/hermes_cli/test_update_check.py b/tests/hermes_cli/test_update_check.py index 84b9e3a6c991b..94cc705121cc5 100644 --- a/tests/hermes_cli/test_update_check.py +++ b/tests/hermes_cli/test_update_check.py @@ -17,7 +17,13 @@ def test_version_string_no_v_prefix(): def test_check_for_updates_uses_cache(tmp_path, monkeypatch): - """When cache is fresh, check_for_updates should return cached value without calling git.""" + """When cache is fresh AND HEAD matches, return cached value without a fetch. + + The cache key includes the local HEAD SHA, so the cached entry must carry + the current HEAD for the fast-path to fire. A cheap ``git rev-parse HEAD`` + still runs to read the current HEAD, but no ``git fetch`` / ``rev-list`` + network/compute work happens. + """ from hermes_cli.banner import check_for_updates from hermes_cli import __version__ @@ -26,15 +32,24 @@ def test_check_for_updates_uses_cache(tmp_path, monkeypatch): repo_dir.mkdir() (repo_dir / ".git").mkdir() + head = "a" * 40 cache_file = tmp_path / ".update_check" - cache_file.write_text(json.dumps({"ts": time.time(), "behind": 3, "ver": __version__})) + cache_file.write_text( + json.dumps({"ts": time.time(), "behind": 3, "ver": __version__, "head": head}) + ) monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - with patch("hermes_cli.banner.subprocess.run") as mock_run: + + def fake_run(cmd, *a, **k): + # Only the HEAD read should occur; fetch/rev-list must NOT. + if cmd[:3] == ["git", "rev-parse", "HEAD"]: + return MagicMock(returncode=0, stdout=head + "\n") + raise AssertionError(f"unexpected git call on cache hit: {cmd}") + + with patch("hermes_cli.banner.subprocess.run", side_effect=fake_run): result = check_for_updates() assert result == 3 - mock_run.assert_not_called() def test_check_for_updates_invalidates_on_version_change(tmp_path, monkeypatch): @@ -75,7 +90,11 @@ def test_check_for_updates_invalidates_on_version_change(tmp_path, monkeypatch): def test_check_for_updates_expired_cache(tmp_path, monkeypatch): - """When cache is expired, check_for_updates should call git fetch.""" + """When cache is expired, check_for_updates should call git fetch. + + Call sequence now: cache-key HEAD + origin/shallow probes + fetch + + rev-list = 5 subprocess calls. + """ from hermes_cli.banner import check_for_updates repo_dir = tmp_path / "hermes-agent" @@ -86,15 +105,22 @@ def test_check_for_updates_expired_cache(tmp_path, monkeypatch): cache_file = tmp_path / ".update_check" cache_file.write_text(json.dumps({"ts": 0, "behind": 1})) - mock_result = MagicMock(returncode=0, stdout="5\n") + def fake_run(cmd, *a, **k): + if cmd[:3] == ["git", "rev-parse", "HEAD"]: + return MagicMock(returncode=0, stdout="abc123\n") + if cmd[:2] == ["git", "fetch"]: + return MagicMock(returncode=0, stdout="") + if cmd[:3] == ["git", "rev-list", "--count"]: + return MagicMock(returncode=0, stdout="5\n") + return MagicMock(returncode=0, stdout="") monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - with patch("hermes_cli.banner.subprocess.run", return_value=mock_result) as mock_run: + with patch("hermes_cli.banner.subprocess.run", side_effect=fake_run) as mock_run: result = check_for_updates() assert result == 5 - # origin probe + is-shallow probe + git fetch + git rev-list - assert mock_run.call_count == 4 + # cache-key HEAD + origin probe + shallow probe + git fetch + git rev-list + assert mock_run.call_count == 5 def test_check_for_updates_official_ssh_origin_uses_https_probe(tmp_path): @@ -220,8 +246,6 @@ def fake_run(cmd, **kwargs): result = banner._check_via_local_git(repo_dir) assert result == 7 - - def test_check_for_updates_no_git_dir(tmp_path, monkeypatch): """Falls back to PyPI check when .git directory doesn't exist anywhere.""" import hermes_cli.banner as banner