From 2950c6fa2eda487cbf24c725754fa19a1ca16e54 Mon Sep 17 00:00:00 2001 From: emozilla Date: Sat, 6 Jun 2026 00:41:54 -0400 Subject: [PATCH] preserve shallow clones and show correct update values for them --- apps/desktop/electron/main.cjs | 52 ++++++- hermes_cli/banner.py | 135 +++++++++++++++-- hermes_cli/main.py | 223 ++++++++++++++++++++++------ tests/hermes_cli/test_cmd_update.py | 102 +++++++++++++ 4 files changed, 454 insertions(+), 58 deletions(-) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index ce8e4bb83ca..f3f444bb8e9 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -1243,7 +1243,23 @@ async function checkUpdates() { } branch = await resolveHealedBranch(updateRoot, branch) - const fetched = await runGit(['fetch', '--quiet', 'origin', branch], { cwd: updateRoot }) + + // Installer checkouts are shallow (`git clone --depth 1`, PR #39423). On a + // shallow clone a plain `git fetch` unshallows the repo — dragging in the + // entire history — and `rev-list HEAD..origin/ --count` then reports + // a huge bogus "behind" number. Detect shallow up front and (a) fetch with + // --depth 1 to preserve the boundary, (b) compare tip SHAs instead of + // counting. Full clones (developers, pre-#39423 installs) keep the exact + // count path unchanged. + const shallowProbe = await runGit(['rev-parse', '--is-shallow-repository'], { cwd: updateRoot }) + const isShallow = shallowProbe.code === 0 + ? shallowProbe.stdout.trim() === 'true' + : fileExists(path.join(gitDir, 'shallow')) // older git fallback + + const fetchArgs = isShallow + ? ['fetch', '--depth', '1', '--quiet', 'origin', branch] + : ['fetch', '--quiet', 'origin', branch] + const fetched = await runGit(fetchArgs, { cwd: updateRoot }) if (fetched.code !== 0) { return { supported: true, @@ -1256,6 +1272,38 @@ async function checkUpdates() { } const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim()) + + if (isShallow) { + // No history to count across the shallow boundary. `fetch origin ` + // updated FETCH_HEAD; origin/ may not be a tracking ref in a + // `clone --depth 1`, so prefer FETCH_HEAD and fall back to origin/. + const [currentSha, fetchHeadSha, originSha, dirtyStr, currentBranch] = await Promise.all([ + git(['rev-parse', 'HEAD']), + git(['rev-parse', 'FETCH_HEAD']).catch(() => ''), + git(['rev-parse', `origin/${branch}`]).catch(() => ''), + git(['status', '--porcelain']), + git(['rev-parse', '--abbrev-ref', 'HEAD']) + ]) + const targetSha = fetchHeadSha || originSha + // Can't enumerate commits across a shallow boundary; surface presence only. + const behind = targetSha && currentSha && targetSha !== currentSha ? 1 : 0 + + return { + supported: true, + branch, + currentBranch, + behind, + behindExact: false, + shallow: true, + currentSha, + targetSha, + commits: [], + dirty: dirtyStr.length > 0, + hermesRoot: updateRoot, + fetchedAt: Date.now() + } + } + const [currentSha, targetSha, countStr, dirtyStr, currentBranch] = await Promise.all([ git(['rev-parse', 'HEAD']), git(['rev-parse', `origin/${branch}`]), @@ -1272,6 +1320,8 @@ async function checkUpdates() { branch, currentBranch, behind, + behindExact: true, + shallow: false, currentSha, targetSha, commits, diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 1955b009df2..6eab797f005 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -144,17 +144,117 @@ def _check_via_rev(local_rev: str) -> Optional[int]: return 0 if upstream_rev == local_rev else UPDATE_AVAILABLE_NO_COUNT +def _is_shallow_clone(repo_dir: Path) -> bool: + """Return True if ``repo_dir`` is a shallow git clone. + + Installer-created checkouts are shallow (``git clone --depth 1``, see + PR #39423). A shallow clone has no usable history before the grafted + boundary, so commit-distance math (``rev-list --count A..B``) is + meaningless and an ordinary ``git fetch`` would *unshallow* the repo — + pulling the entire history and making the count explode. Callers use + this to branch into a tip-comparison path instead. + + Defaults to False (treat as full clone) on any error, so developer + checkouts and pre-#39423 full installs keep the exact count path. + """ + try: + result = subprocess.run( + ["git", "rev-parse", "--is-shallow-repository"], + capture_output=True, text=True, timeout=5, + cwd=str(repo_dir), + ) + except Exception: + return False + if result.returncode != 0: + # Older git (<2.15) lacks --is-shallow-repository; the presence of a + # `.git/shallow` file is the portable fallback signal. + return (repo_dir / ".git" / "shallow").exists() + return result.stdout.strip() == "true" + + +def _git_rev(repo_dir: Path, rev: str) -> Optional[str]: + """Resolve ``rev`` to a full commit SHA in ``repo_dir``, or None.""" + try: + result = subprocess.run( + ["git", "rev-parse", rev], + capture_output=True, text=True, timeout=5, + cwd=str(repo_dir), + ) + except Exception: + return None + if result.returncode != 0: + return None + value = (result.stdout or "").strip() + return value or None + + +def _ls_remote_main(repo_dir: Path) -> Optional[str]: + """Return origin's ``refs/heads/main`` tip SHA via ls-remote, or None. + + Authoritative upstream-tip lookup that does NOT depend on local tracking + refs — the reliable way to learn the remote head of a shallow clone, + where ``origin/main`` may be missing (tag-pinned clone) or stale. + """ + try: + result = subprocess.run( + ["git", "ls-remote", "origin", "refs/heads/main"], + capture_output=True, text=True, timeout=10, + cwd=str(repo_dir), + ) + except Exception: + return None + if result.returncode != 0 or not result.stdout: + return None + return result.stdout.split()[0] or None + + def _check_via_local_git(repo_dir: Path) -> Optional[int]: - """Count commits behind origin/main in a local checkout.""" + """Report whether a local checkout is behind origin/main. + + Supports both shapes of install: + + * **Shallow clone** (installer, ``--depth 1``): a plain ``git fetch`` + would unshallow the repo and a ``HEAD..origin/main`` count would be + bogus. So we fetch shallow (``--depth 1``) to keep the boundary, then + compare the local HEAD SHA against the freshly-fetched ``origin/main`` + tip. Equal → ``0`` (up to date); different → ``UPDATE_AVAILABLE_NO_COUNT`` + (behind, but an exact count is impossible across a shallow boundary). + + * **Full clone** (developer checkout, or installs from before #39423): + ordinary fetch + exact ``rev-list --count HEAD..origin/main``. + """ + shallow = _is_shallow_clone(repo_dir) + + fetch_cmd = ["git", "fetch", "origin", "main", "--quiet"] + if shallow: + # Keep the clone shallow — otherwise the fetch drags in the entire + # history and the count below explodes (see #39423 fallout). + fetch_cmd = ["git", "fetch", "--depth", "1", "origin", "main", "--quiet"] try: subprocess.run( - ["git", "fetch", "origin", "--quiet"], + fetch_cmd, capture_output=True, timeout=10, cwd=str(repo_dir), ) except Exception: pass # Offline or timeout — use stale refs, that's fine + if shallow: + # No usable history to count across the shallow boundary — compare + # tip SHAs instead. Resolving the upstream tip from local tracking + # refs is unreliable on a `clone --depth 1` (origin/main may be a + # detached fetch, a tag-pinned clone has no origin/main at all), so + # ask the remote authoritatively via ls-remote — the same approach + # _check_via_rev() uses for nix builds — and only fall back to + # FETCH_HEAD / origin/main if the remote probe fails (offline). + local = _git_rev(repo_dir, "HEAD") + upstream = _ls_remote_main(repo_dir) + if not upstream: + upstream = _git_rev(repo_dir, "FETCH_HEAD") or _git_rev(repo_dir, "origin/main") + if not local or not upstream: + return None + return 0 if local == upstream else UPDATE_AVAILABLE_NO_COUNT + try: result = subprocess.run( ["git", "rev-list", "--count", "HEAD..origin/main"], @@ -358,18 +458,25 @@ def get_git_banner_state(repo_dir: Optional[Path] = None) -> Optional[dict]: return None ahead = 0 - try: - result = subprocess.run( - ["git", "rev-list", "--count", "origin/main..HEAD"], - capture_output=True, - text=True, - timeout=5, - cwd=str(repo_dir), - ) - if result.returncode == 0: - ahead = int((result.stdout or "0").strip() or "0") - except Exception: - ahead = 0 + # On a shallow clone there is no history before the grafted boundary, so + # `origin/main..HEAD` would be bogus (and any fetch that populated + # origin/main already happened via the shallow-preserving path elsewhere). + # A `--depth 1` install is pinned to a single commit, so "carried commits" + # is definitionally zero — skip the count and report ahead=0. Full clones + # (developers, pre-#39423 installs) keep the exact count. + if not _is_shallow_clone(repo_dir): + try: + result = subprocess.run( + ["git", "rev-list", "--count", "origin/main..HEAD"], + capture_output=True, + text=True, + timeout=5, + cwd=str(repo_dir), + ) + if result.returncode == 0: + ahead = int((result.stdout or "0").strip() or "0") + except Exception: + ahead = 0 return {"upstream": upstream, "local": local, "ahead": max(ahead, 0)} diff --git a/hermes_cli/main.py b/hermes_cli/main.py index ab145c38116..9838d729e94 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -6761,6 +6761,54 @@ def _capture_head_sha(git_cmd, cwd) -> str | None: return None +def _is_shallow_clone(git_cmd, cwd) -> bool: + """Return True if ``cwd`` is a shallow git clone. + + Installer checkouts are shallow (``git clone --depth 1``, see PR #39423). + On a shallow clone an ordinary ``git fetch`` un-shallows the repo — + pulling the entire history and defeating the installer's bandwidth + savings — and commit-distance math (``rev-list --count A..B``) across the + grafted boundary is bogus. ``hermes update`` uses this to fetch with + ``--depth 1`` and reset to the fetched tip instead of counting + ff-merging. + + Defaults to False (treat as full clone) on any error, preserving the + historical behavior for developer checkouts and pre-#39423 full installs. + """ + try: + result = subprocess.run( + git_cmd + ["rev-parse", "--is-shallow-repository"], + cwd=cwd, + capture_output=True, + text=True, + ) + except OSError: + return False + if result.returncode != 0: + # Older git (<2.15) lacks --is-shallow-repository; fall back to the + # presence of a `.git/shallow` file, the portable shallow signal. + try: + return (Path(cwd) / ".git" / "shallow").exists() + except OSError: + return False + return result.stdout.strip() == "true" + + +def _git_rev(git_cmd, cwd, rev: str) -> str | None: + """Resolve ``rev`` to a commit SHA in ``cwd``, or None.""" + try: + result = subprocess.run( + git_cmd + ["rev-parse", rev], + cwd=cwd, + capture_output=True, + text=True, + ) + except OSError: + return None + if result.returncode != 0: + return None + return result.stdout.strip() or None + + def _validate_critical_files_syntax(root) -> tuple[bool, str | None, str | None]: """Compile each file in ``_UPDATE_CRITICAL_FILES`` to catch SyntaxErrors. @@ -9799,11 +9847,29 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False): if sys.platform == "win32": git_cmd = ["git", "-c", "windows.appendAtomically=false"] - # Fetch both origin and upstream; prefer upstream as the canonical reference. - # 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. - if branch == "main": + # Installer checkouts are shallow (`git clone --depth 1`, PR #39423). + # A plain fetch would un-shallow the repo and the `rev-list --count` below + # would be bogus across the grafted boundary. On a shallow clone we fetch + # `--depth 1` straight from origin (a shallow install has no `upstream` + # remote) and compare tip SHAs instead of counting. Full clones keep the + # historical upstream-preferred fetch + exact count path. + is_shallow = _is_shallow_clone(git_cmd, PROJECT_ROOT) + + if is_shallow: + print("→ Fetching from origin...") + fetch_result = subprocess.run( + git_cmd + ["fetch", "--depth", "1", "origin", branch], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + ) + upstream_exists = False + compare_branch = f"origin/{branch}" + elif branch == "main": + # Fetch both origin and upstream; prefer upstream as the canonical reference. + # 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. print("→ Fetching from upstream...") fetch_result = subprocess.run( git_cmd + ["fetch", "upstream"], @@ -9863,17 +9929,32 @@ 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) - rev_result = subprocess.run( - git_cmd + ["rev-list", f"HEAD..{compare_branch}", "--count"], - cwd=PROJECT_ROOT, - capture_output=True, - text=True, - check=True, - ) - behind = int(rev_result.stdout.strip()) + if is_shallow: + # No history to count across the shallow boundary — compare tip SHAs. + local_sha = _git_rev(git_cmd, PROJECT_ROOT, "HEAD") + target_sha = ( + _git_rev(git_cmd, PROJECT_ROOT, compare_branch) + or _git_rev(git_cmd, PROJECT_ROOT, "FETCH_HEAD") + ) + behind = 0 if (local_sha and target_sha and local_sha == target_sha) else 1 + else: + rev_result = subprocess.run( + git_cmd + ["rev-list", f"HEAD..{compare_branch}", "--count"], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=True, + ) + behind = int(rev_result.stdout.strip()) if behind == 0: print("✓ Already up to date.") + elif is_shallow: + # Exact count is unknowable on a shallow clone — report availability. + print(f"⚕ Update available on {compare_branch}.") + from hermes_cli.config import recommended_update_command + + print(f" Run '{recommended_update_command()}' to install.") else: commits_word = "commit" if behind == 1 else "commits" print(f"⚕ Update available: {behind} {commits_word} behind {compare_branch}.") @@ -10337,9 +10418,20 @@ def _cmd_update_impl(args, gateway_mode: bool): # Fetch and pull try: + # Installer checkouts are shallow (`git clone --depth 1`, PR #39423). + # A plain `git fetch` would un-shallow the repo (negating the + # installer's bandwidth savings) and make the `rev-list --count` + # below report a bogus huge number. Detect shallow once and fetch + # `--depth 1` to keep the boundary; full clones (developers, + # pre-#39423 installs) keep the historical plain-fetch + count path. + is_shallow = _is_shallow_clone(git_cmd, PROJECT_ROOT) + print("→ Fetching updates...") + fetch_args = ["fetch", "origin"] + if is_shallow: + fetch_args = ["fetch", "--depth", "1", "origin", _resolve_update_branch(args)] fetch_result = subprocess.run( - git_cmd + ["fetch", "origin"], + git_cmd + fetch_args, cwd=PROJECT_ROOT, capture_output=True, text=True, @@ -10431,15 +10523,29 @@ def _cmd_update_impl(args, gateway_mode: bool): and (gateway_mode or (sys.stdin.isatty() and sys.stdout.isatty())) ) - # Check if there are updates - result = subprocess.run( - git_cmd + ["rev-list", f"HEAD..origin/{branch}", "--count"], - cwd=PROJECT_ROOT, - capture_output=True, - text=True, - check=True, - ) - commit_count = int(result.stdout.strip()) + # Check if there are updates. On a shallow clone there is no history + # to count across the grafted boundary, so `rev-list HEAD..origin/X` + # would be bogus — compare tip SHAs instead and treat any difference + # as "an update is available" (exact count is unknowable). Full clones + # keep the precise count. + if is_shallow: + local_sha = _git_rev(git_cmd, PROJECT_ROOT, "HEAD") + target_sha = ( + _git_rev(git_cmd, PROJECT_ROOT, f"origin/{branch}") + or _git_rev(git_cmd, PROJECT_ROOT, "FETCH_HEAD") + ) + commit_count = ( + 0 if (local_sha and target_sha and local_sha == target_sha) else 1 + ) + else: + result = subprocess.run( + git_cmd + ["rev-list", f"HEAD..origin/{branch}", "--count"], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=True, + ) + commit_count = int(result.stdout.strip()) if commit_count == 0: _invalidate_update_cache() @@ -10468,7 +10574,10 @@ def _cmd_update_impl(args, gateway_mode: bool): print("✓ Already up to date!") return - print(f"→ Found {commit_count} new commit(s)") + if is_shallow: + print("→ Update available") + else: + print(f"→ Found {commit_count} new commit(s)") # Snapshot critical state (state.db, config, pairing JSONs, etc.) # before pulling so a user can recover if something goes wrong. @@ -10496,33 +10605,61 @@ def _cmd_update_impl(args, gateway_mode: bool): # the bad commit and the fix landing). pre_pull_sha = _capture_head_sha(git_cmd, PROJECT_ROOT) try: - pull_result = subprocess.run( - git_cmd + ["pull", "--ff-only", "origin", branch], - cwd=PROJECT_ROOT, - capture_output=True, - text=True, - ) - if pull_result.returncode != 0: - # ff-only failed — local and remote have diverged (e.g. upstream - # force-pushed or rebase). Since local changes are already - # stashed, reset to match the remote exactly. - print( - " ⚠ Fast-forward not possible (history diverged), resetting to match remote..." + if is_shallow: + # Shallow clone: there's no merge base to fast-forward across, + # and `pull` would re-negotiate full history and un-shallow the + # repo. The `--depth 1` fetch above already advanced + # origin/{branch} (and FETCH_HEAD) to the new tip, so hard-reset + # the working tree to it — this keeps the clone shallow and is + # the equivalent of a fast-forward for a single-commit install. + reset_target = ( + f"origin/{branch}" + if _git_rev(git_cmd, PROJECT_ROOT, f"origin/{branch}") + else "FETCH_HEAD" ) - reset_result = subprocess.run( - git_cmd + ["reset", "--hard", f"origin/{branch}"], + pull_result = subprocess.run( + git_cmd + ["reset", "--hard", reset_target], cwd=PROJECT_ROOT, capture_output=True, text=True, ) - if reset_result.returncode != 0: - print(f"✗ Failed to reset to origin/{branch}.") - if reset_result.stderr.strip(): - print(f" {reset_result.stderr.strip()}") + if pull_result.returncode != 0: + print(f"✗ Failed to update to {reset_target} (shallow).") + if pull_result.stderr.strip(): + print(f" {pull_result.stderr.strip().splitlines()[0]}") print( - f" Try manually: git fetch origin && git reset --hard origin/{branch}" + f" Try manually: git fetch --depth 1 origin {branch} && " + f"git reset --hard origin/{branch}" ) sys.exit(1) + else: + pull_result = subprocess.run( + git_cmd + ["pull", "--ff-only", "origin", branch], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + ) + if pull_result.returncode != 0: + # ff-only failed — local and remote have diverged (e.g. upstream + # force-pushed or rebase). Since local changes are already + # stashed, reset to match the remote exactly. + print( + " ⚠ Fast-forward not possible (history diverged), resetting to match remote..." + ) + reset_result = subprocess.run( + git_cmd + ["reset", "--hard", f"origin/{branch}"], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + ) + if reset_result.returncode != 0: + print(f"✗ Failed to reset to origin/{branch}.") + if reset_result.stderr.strip(): + print(f" {reset_result.stderr.strip()}") + print( + f" Try manually: git fetch origin && git reset --hard origin/{branch}" + ) + sys.exit(1) # Post-pull syntax guard: validate critical-path files actually # parse before declaring the update successful. If a bad commit diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py index d8204524c54..e537834ebae 100644 --- a/tests/hermes_cli/test_cmd_update.py +++ b/tests/hermes_cli/test_cmd_update.py @@ -825,3 +825,105 @@ termux = ["rich>=14"] assert hm._load_installable_optional_extras(group="all") == ["mcp"] assert hm._load_installable_optional_extras(group="termux-all") == ["termux", "mcp"] + + +class TestCmdUpdateShallowClone: + """Shallow-aware update flow (installer `git clone --depth 1`, PR #39423). + + On a shallow clone a plain `git fetch` un-shallows the repo and + `rev-list --count HEAD..origin/main` reports a bogus huge number. The + update flow must instead fetch `--depth 1` and reset to the fetched tip, + while full clones keep the historical fetch + count + ff-only path. + + These tests fully mock `subprocess.run` — they never touch a real repo. + """ + + @staticmethod + def _shallow_side_effect(*, head_sha="aaa", target_sha="bbb"): + """subprocess.run side-effect simulating a shallow clone. + + Records every git invocation in ``calls`` (attached to the returned + function) so tests can assert which commands ran. + """ + calls: list[str] = [] + + def side_effect(cmd, **kwargs): + joined = " ".join(str(c) for c in cmd) + calls.append(joined) + + # Shallow probe → "true" + if "rev-parse" in joined and "--is-shallow-repository" in joined: + return subprocess.CompletedProcess(cmd, 0, stdout="true\n", stderr="") + # current branch + if "rev-parse" in joined and "--abbrev-ref" in joined: + return subprocess.CompletedProcess(cmd, 0, stdout="main\n", stderr="") + # HEAD sha + if "rev-parse" in joined and joined.strip().endswith("HEAD"): + return subprocess.CompletedProcess(cmd, 0, stdout=f"{head_sha}\n", stderr="") + # tip sha (origin/main or FETCH_HEAD) + if "rev-parse" in joined and ("origin/main" in joined or "FETCH_HEAD" in joined): + return subprocess.CompletedProcess(cmd, 0, stdout=f"{target_sha}\n", stderr="") + if "rev-parse" in joined and "--verify" in joined: + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + # rev-list must never run on a shallow clone — flag loudly if it does + if "rev-list" in joined: + raise AssertionError(f"rev-list should not run on shallow clone: {joined}") + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + side_effect.calls = calls + return side_effect + + @patch("hermes_cli.main._build_web_ui") + @patch("hermes_cli.main._update_node_dependencies") + @patch("hermes_cli.main._refresh_active_lazy_features") + @patch("hermes_cli.main._install_python_dependencies_with_optional_fallback") + @patch("hermes_cli.main._validate_critical_files_syntax", return_value=(True, None, None)) + @patch("hermes_cli.main._clear_bytecode_cache", return_value=0) + @patch("shutil.which", return_value=None) + @patch("subprocess.run") + def test_update_shallow_fetches_depth1_and_resets( + self, mock_run, _which, _bytecode, _syntax, _deps, _lazy, _node, _web, + mock_args, monkeypatch, capsys, + ): + from hermes_cli import main as hm + + # HEAD != tip → an update is available. + se = self._shallow_side_effect(head_sha="aaa", target_sha="bbb") + mock_run.side_effect = se + # Avoid touching real install-method detection / snapshots. + monkeypatch.setattr(hm, "detect_use_zip_update", lambda *a, **k: False, raising=False) + + try: + hm.cmd_update(mock_args) + except SystemExit: + pass # build steps are mocked; we only care about the git pipeline + + joined_calls = se.calls + # 1) The fetch is shallow-preserving. + assert any("fetch --depth 1 origin main" in c for c in joined_calls), joined_calls + # 2) No plain `fetch origin` (which would un-shallow). + assert not any(c.endswith("fetch origin") for c in joined_calls), joined_calls + # 3) Advanced via reset to the fetched tip, not `pull --ff-only`. + assert any("reset --hard" in c for c in joined_calls), joined_calls + assert not any("pull --ff-only" in c for c in joined_calls), joined_calls + + @patch("hermes_cli.config.detect_install_method", return_value="source") + @patch("shutil.which", return_value=None) + @patch("subprocess.run") + def test_update_check_shallow_compares_tips( + self, mock_run, _which, _method, capsys, monkeypatch, + ): + from hermes_cli import main as hm + + # HEAD == tip → already up to date, and no rev-list runs. + se = self._shallow_side_effect(head_sha="same", target_sha="same") + mock_run.side_effect = se + + hm._cmd_update_check(branch="main") + + out = capsys.readouterr().out + assert "Already up to date" in out + # Shallow-preserving fetch, no plain fetch, no rev-list. + assert any("fetch --depth 1 origin main" in c for c in se.calls), se.calls + assert not any("rev-list" in c for c in se.calls), se.calls +