diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 62f9f40e7a6..68d33e43fdb 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -199,15 +199,43 @@ def _check_via_local_git(repo_dir: Path) -> Optional[int]: head_rev = _git_stdout(["rev-parse", "HEAD"], cwd=repo_dir) return _check_via_rev(head_rev) if head_rev else None + # Installer checkouts are shallow (`git clone --depth 1`). On a shallow + # clone the history stops at a single commit, so a plain `git fetch` would + # unshallow the repo (dragging in the whole history) and + # `rev-list --count HEAD..origin/main` would report a huge bogus "behind" + # number (e.g. "12492 commits behind"). Detect shallow up front: fetch with + # --depth 1 to preserve the boundary and compare tip SHAs instead of + # counting. Full clones (developers, Docker dev images) keep the exact + # count path unchanged. Mirrors the desktop fix in apps/desktop/electron/main.cjs. + shallow = _git_stdout(["rev-parse", "--is-shallow-repository"], cwd=repo_dir) + is_shallow = shallow == "true" + try: + fetch_args = ["git", "fetch", "origin"] + if is_shallow: + fetch_args += ["--depth", "1"] + fetch_args.append("--quiet") subprocess.run( - ["git", "fetch", "origin", "--quiet"], + fetch_args, capture_output=True, timeout=10, cwd=str(repo_dir), ) except Exception: pass # Offline or timeout — use stale refs, that's fine + if is_shallow: + # No history to count across the shallow boundary. `origin/main` may not + # be a tracking ref in a `clone --depth 1`, so prefer FETCH_HEAD (just + # updated by the fetch above) and fall back to origin/main. + head_rev = _git_stdout(["rev-parse", "HEAD"], cwd=repo_dir) + target_rev = ( + _git_stdout(["rev-parse", "FETCH_HEAD"], cwd=repo_dir) + or _git_stdout(["rev-parse", "origin/main"], cwd=repo_dir) + ) + if not head_rev or not target_rev: + return None + return 0 if head_rev == target_rev else UPDATE_AVAILABLE_NO_COUNT + try: result = subprocess.run( ["git", "rev-list", "--count", "HEAD..origin/main"], diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 6050e80b2c1..df6c7329c15 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -8040,10 +8040,26 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False): # Note: upstream/ may not exist for non-main branches (a fork's # bb/gui has no upstream counterpart), so when the caller picks a # non-default branch we skip the upstream probe and use origin directly. + # Installer checkouts are shallow (`git clone --depth 1`). A plain + # `git fetch` would unshallow the repo (dragging in the whole history — + # the exact cost the shallow clone avoided) and the rev-list count below + # would then report a huge bogus "behind" number. Detect shallow up front: + # fetch with --depth 1 to preserve the boundary and report presence-only. + is_shallow = ( + subprocess.run( + git_cmd + ["rev-parse", "--is-shallow-repository"], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + ).stdout.strip() + == "true" + ) + depth_args = ["--depth", "1"] if is_shallow else [] + if branch == "main": print("→ Fetching from upstream...") fetch_result = subprocess.run( - git_cmd + ["fetch", "upstream", branch], + git_cmd + ["fetch"] + depth_args + ["upstream", branch], cwd=PROJECT_ROOT, capture_output=True, text=True, @@ -8052,7 +8068,7 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False): # Fallback to origin if upstream doesn't exist print("→ Fetching from origin...") fetch_result = subprocess.run( - git_cmd + ["fetch", "origin", branch], + git_cmd + ["fetch"] + depth_args + ["origin", branch], cwd=PROJECT_ROOT, capture_output=True, text=True, @@ -8066,7 +8082,7 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False): # Non-default branch: compare against origin/ directly. print("→ Fetching from origin...") fetch_result = subprocess.run( - git_cmd + ["fetch", "origin", branch], + git_cmd + ["fetch"] + depth_args + ["origin", branch], cwd=PROJECT_ROOT, capture_output=True, text=True, @@ -8100,6 +8116,26 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False): print(f"✗ Branch '{branch}' not found on {compare_branch.split('/', 1)[0]}.") sys.exit(1) + if is_shallow: + # No history to count across the shallow boundary. Compare tip SHAs and + # report presence-only (mirrors the banner's _check_via_local_git). + head_sha = subprocess.run( + git_cmd + ["rev-parse", "HEAD"], + cwd=PROJECT_ROOT, capture_output=True, text=True, + ).stdout.strip() + target_sha = subprocess.run( + git_cmd + ["rev-parse", compare_branch], + cwd=PROJECT_ROOT, capture_output=True, text=True, + ).stdout.strip() + if head_sha and target_sha and head_sha == target_sha: + print("✓ Already up to date.") + else: + print(f"⚕ Update available (behind {compare_branch}).") + from hermes_cli.config import recommended_update_command + + print(f" Run '{recommended_update_command()}' to install.") + return + rev_result = subprocess.run( git_cmd + ["rev-list", f"HEAD..{compare_branch}", "--count"], cwd=PROJECT_ROOT, diff --git a/tests/hermes_cli/test_update_check.py b/tests/hermes_cli/test_update_check.py index 5c590bff15c..66c40a5ab17 100644 --- a/tests/hermes_cli/test_update_check.py +++ b/tests/hermes_cli/test_update_check.py @@ -93,7 +93,8 @@ def test_check_for_updates_expired_cache(tmp_path, monkeypatch): result = check_for_updates() assert result == 5 - assert mock_run.call_count == 3 # origin probe + git fetch + git rev-list + # origin probe + is-shallow probe + git fetch + git rev-list + assert mock_run.call_count == 4 def test_check_for_updates_official_ssh_origin_uses_https_probe(tmp_path): @@ -128,6 +129,99 @@ def test_check_for_updates_official_ssh_origin_uses_https_probe(tmp_path): assert ["git", "fetch", "origin", "--quiet"] not in calls +def test_check_via_local_git_shallow_clone_behind_reports_no_count(tmp_path): + """Shallow installer clones must report presence-only, never a bogus count. + + On a ``git clone --depth 1`` checkout the history stops at one commit, so + counting ``HEAD..origin/main`` across the shallow boundary yields a huge + nonsense number (the "12492 commits behind" banner). The shallow path must + compare tip SHAs and return UPDATE_AVAILABLE_NO_COUNT instead, and must + never run ``git rev-list --count``. + """ + import hermes_cli.banner as banner + + repo_dir = tmp_path / "hermes-agent" + repo_dir.mkdir() + (repo_dir / ".git").mkdir() + + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + if cmd == ["git", "remote", "get-url", "origin"]: + return MagicMock(returncode=0, stdout="https://github.com/NousResearch/hermes-agent.git\n") + if cmd == ["git", "rev-parse", "--is-shallow-repository"]: + return MagicMock(returncode=0, stdout="true\n") + if cmd[:2] == ["git", "fetch"]: + return MagicMock(returncode=0, stdout="") + if cmd == ["git", "rev-parse", "HEAD"]: + return MagicMock(returncode=0, stdout="local-sha\n") + if cmd == ["git", "rev-parse", "FETCH_HEAD"]: + return MagicMock(returncode=0, stdout="upstream-sha\n") + if cmd[:3] == ["git", "rev-list", "--count"]: + raise AssertionError("shallow path must not count across the boundary") + raise AssertionError(f"unexpected git command: {cmd!r}") + + with patch("hermes_cli.banner.subprocess.run", side_effect=fake_run): + result = banner._check_via_local_git(repo_dir) + + assert result == banner.UPDATE_AVAILABLE_NO_COUNT + # The shallow fetch must preserve the boundary (--depth 1), not unshallow. + assert ["git", "fetch", "origin", "--depth", "1", "--quiet"] in calls + + +def test_check_via_local_git_shallow_clone_up_to_date(tmp_path): + """Shallow clone whose tip matches upstream reports up-to-date (0).""" + import hermes_cli.banner as banner + + repo_dir = tmp_path / "hermes-agent" + repo_dir.mkdir() + (repo_dir / ".git").mkdir() + + def fake_run(cmd, **kwargs): + if cmd == ["git", "remote", "get-url", "origin"]: + return MagicMock(returncode=0, stdout="https://github.com/NousResearch/hermes-agent.git\n") + if cmd == ["git", "rev-parse", "--is-shallow-repository"]: + return MagicMock(returncode=0, stdout="true\n") + if cmd[:2] == ["git", "fetch"]: + return MagicMock(returncode=0, stdout="") + if cmd == ["git", "rev-parse", "HEAD"]: + return MagicMock(returncode=0, stdout="same-sha\n") + if cmd == ["git", "rev-parse", "FETCH_HEAD"]: + return MagicMock(returncode=0, stdout="same-sha\n") + raise AssertionError(f"unexpected git command: {cmd!r}") + + with patch("hermes_cli.banner.subprocess.run", side_effect=fake_run): + result = banner._check_via_local_git(repo_dir) + + assert result == 0 + + +def test_check_via_local_git_full_clone_keeps_exact_count(tmp_path): + """Full (non-shallow) clones keep the exact rev-list count path.""" + import hermes_cli.banner as banner + + repo_dir = tmp_path / "hermes-agent" + repo_dir.mkdir() + (repo_dir / ".git").mkdir() + + def fake_run(cmd, **kwargs): + if cmd == ["git", "remote", "get-url", "origin"]: + return MagicMock(returncode=0, stdout="https://github.com/NousResearch/hermes-agent.git\n") + if cmd == ["git", "rev-parse", "--is-shallow-repository"]: + return MagicMock(returncode=0, stdout="false\n") + if cmd[:2] == ["git", "fetch"]: + return MagicMock(returncode=0, stdout="") + if cmd[:3] == ["git", "rev-list", "--count"]: + return MagicMock(returncode=0, stdout="7\n") + raise AssertionError(f"unexpected git command: {cmd!r}") + + with patch("hermes_cli.banner.subprocess.run", side_effect=fake_run): + 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