fix(update): don't count across shallow-clone boundary (bogus '12492 commits behind') (#50784)

* chore: re-trigger CI (workflows did not dispatch on prior head)

* fix(update): don't count across shallow-clone boundary (bogus '12492 commits behind')

Installer checkouts are shallow (git clone --depth 1). The CLI banner and
hermes update --check both did a plain git fetch (silently unshallowing the
repo) then git rev-list --count HEAD..origin/main, which counts across the
shallow boundary and prints a huge nonsense number like '12492 commits behind'.

Detect shallow up front, fetch with --depth 1 to preserve the boundary, and
compare tip SHAs instead of counting:
- banner _check_via_local_git: returns UPDATE_AVAILABLE_NO_COUNT when behind
  (renders as 'update available') instead of the bogus count.
- _cmd_update_check: reports presence-only on shallow clones.
Full clones keep the exact count path unchanged. Mirrors the desktop fix in
apps/desktop/electron/main.cjs (commit 2950c6fa2).
This commit is contained in:
Teknium 2026-06-22 05:39:11 -07:00 committed by GitHub
parent 2e779d11a0
commit eecb5b9dd1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 163 additions and 5 deletions

View file

@ -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"],

View file

@ -8040,10 +8040,26 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
# Note: upstream/<branch> 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/<branch> 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,

View file

@ -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