Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion hermes_cli/banner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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`
Expand All @@ -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:
Expand All @@ -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
Expand Down
46 changes: 35 additions & 11 deletions tests/hermes_cli/test_update_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__

Expand All @@ -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):
Expand Down Expand Up @@ -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"
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand Down
Loading