diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 600b4d4a9958a..9b88dd93d913e 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -7547,6 +7547,47 @@ def _count_commits_between(git_cmd: list[str], cwd: Path, base: str, head: str) return -1 +def _get_remote_update_commit_count( + git_cmd: list[str], cwd: Path, compare_branch: str +) -> tuple[int | None, bool]: + """Return ``(count, exact)`` for ``HEAD..compare_branch``. + + Shallow installs can end up with no merge-base against the remote branch. + In that state ``git rev-list --count`` often returns a large but misleading + distance, even though the actionable state is simply "remote tip differs". + When that guard trips, return ``(None, False)`` so callers can surface a + generic update message and continue with the real pull/reset flow. + """ + shallow_result = subprocess.run( + git_cmd + ["rev-parse", "--is-shallow-repository"], + cwd=cwd, + capture_output=True, + text=True, + ) + is_shallow = ( + shallow_result.returncode == 0 + and shallow_result.stdout.strip().lower() == "true" + ) + if is_shallow: + merge_base_result = subprocess.run( + git_cmd + ["merge-base", "HEAD", compare_branch], + cwd=cwd, + capture_output=True, + text=True, + ) + if merge_base_result.returncode != 0 or not merge_base_result.stdout.strip(): + return None, False + + result = subprocess.run( + git_cmd + ["rev-list", f"HEAD..{compare_branch}", "--count"], + cwd=cwd, + capture_output=True, + text=True, + check=True, + ) + return int(result.stdout.strip()), True + + def _should_skip_upstream_prompt() -> bool: """Check if user previously declined to add upstream.""" from hermes_constants import get_hermes_home @@ -8631,20 +8672,22 @@ 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, exact_count = _get_remote_update_commit_count( + git_cmd, PROJECT_ROOT, compare_branch ) - behind = int(rev_result.stdout.strip()) if behind == 0: print("✓ Already up to date.") else: - commits_word = "commit" if behind == 1 else "commits" - print(f"⚕ Update available: {behind} {commits_word} behind {compare_branch}.") + if exact_count: + commits_word = "commit" if behind == 1 else "commits" + print(f"⚕ Update available: {behind} {commits_word} behind {compare_branch}.") + else: + print( + "⚕ Update available: remote tip differs from " + f"{compare_branch}, but exact commit count is unavailable for " + "this shallow history." + ) from hermes_cli.config import recommended_update_command print(f" Run '{recommended_update_command()}' to install.") @@ -9091,14 +9134,9 @@ def _cmd_update_impl(args, gateway_mode: bool): ) # 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, exact_commit_count = _get_remote_update_commit_count( + git_cmd, PROJECT_ROOT, f"origin/{branch}" ) - commit_count = int(result.stdout.strip()) if commit_count == 0: _invalidate_update_cache() @@ -9127,7 +9165,13 @@ def _cmd_update_impl(args, gateway_mode: bool): print("✓ Already up to date!") return - print(f"→ Found {commit_count} new commit(s)") + if exact_commit_count: + print(f"→ Found {commit_count} new commit(s)") + else: + print( + "→ Update available (shallow history without a merge-base; " + "exact commit count unavailable)" + ) # Snapshot critical state (state.db, config, pairing JSONs, etc.) # before pulling so a user can recover if something goes wrong. diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py index 0cb8d033eb820..8acdfd00c44e3 100644 --- a/tests/hermes_cli/test_cmd_update.py +++ b/tests/hermes_cli/test_cmd_update.py @@ -602,6 +602,95 @@ def test_check_branch_warns_on_pypi_install( assert "bb/gui" in out +class TestCmdUpdateShallowHistory: + """Shallow installs without a merge-base should avoid bogus rev-list counts.""" + + def _apply_side_effect(self, *, check_mode: bool = False): + compare_ref = "upstream/main" if check_mode else "origin/main" + + def side_effect(cmd, **kwargs): + joined = " ".join(str(c) for c in cmd) + + if "fetch" in joined and ("origin" in joined or "upstream" in joined): + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if "rev-parse" in joined and "--abbrev-ref" in joined: + return subprocess.CompletedProcess(cmd, 0, stdout="main\n", stderr="") + if "rev-parse" in joined and "--verify" in joined: + return subprocess.CompletedProcess(cmd, 0, stdout=f"{compare_ref}\n", stderr="") + if "rev-parse" in joined and "--is-shallow-repository" in joined: + return subprocess.CompletedProcess(cmd, 0, stdout="true\n", stderr="") + if "merge-base" in joined: + return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="") + if "pull" in joined and "--ff-only" in joined: + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if "pip" in joined and "--version" in joined: + return subprocess.CompletedProcess(cmd, 0, stdout="pip 25.0\n", stderr="") + if "rev-list" in joined: + raise AssertionError(f"rev-list should be skipped for {joined}") + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + return side_effect + + @patch("shutil.which", return_value=None) + @patch("subprocess.run") + def test_update_skips_exact_count_when_shallow_history_has_no_merge_base( + self, mock_run, _mock_which, mock_args, capsys + ): + from hermes_cli import main as hm + + mock_run.side_effect = self._apply_side_effect(check_mode=False) + empty_sync = {"copied": [], "updated": [], "user_modified": [], "cleaned": []} + + with ( + patch.object(hm, "_capture_head_sha", return_value="oldsha"), + patch.object( + hm, + "_validate_critical_files_syntax", + return_value=(True, None, None), + ), + patch.object(hm, "_clear_bytecode_cache", return_value=0), + patch.object( + hm, "_install_python_dependencies_with_optional_fallback" + ), + patch.object(hm, "_refresh_active_lazy_features"), + patch.object(hm, "_update_node_dependencies"), + patch.object(hm, "_build_web_ui"), + patch("tools.skills_sync.sync_skills", return_value=empty_sync), + patch("hermes_cli.profiles.list_profiles", return_value=[]), + patch("hermes_cli.config.get_missing_env_vars", return_value=[]), + patch("hermes_cli.config.get_missing_config_fields", return_value=[]), + patch("hermes_cli.config.check_config_version", return_value=(1, 1)), + ): + cmd_update(mock_args) + + out = capsys.readouterr().out + assert "exact commit count unavailable" in out + assert "shallow history" in out + + commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list] + assert any("merge-base HEAD origin/main" in c for c in commands), commands + assert not any("rev-list" in c for c in commands), commands + assert any("pull --ff-only origin main" in c for c in commands), commands + + @patch("hermes_cli.config.detect_install_method", return_value="git") + @patch("subprocess.run") + def test_check_reports_generic_update_when_shallow_history_has_no_merge_base( + self, mock_run, _mock_method, capsys + ): + mock_run.side_effect = self._apply_side_effect(check_mode=True) + args = SimpleNamespace(check=True, branch=None) + + cmd_update(args) + + out = capsys.readouterr().out + assert "remote tip differs from upstream/main" in out + assert "exact commit count is unavailable" in out + + commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list] + assert any("merge-base HEAD upstream/main" in c for c in commands), commands + assert not any("rev-list" in c for c in commands), commands + + class TestCmdUpdateZipBranchRefusal: """``hermes update --branch=`` must refuse on the ZIP fallback path.