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
78 changes: 61 additions & 17 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shallow probe is reached only after _cmd_update_impl has fetched from origin. The existing merged fix #50784 requires probing before fetch and using --depth 1; otherwise a plain fetch can unshallow the checkout before this guard runs, so the exact-count path remains reachable for the reported failure.

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
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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.
Expand Down
89 changes: 89 additions & 0 deletions tests/hermes_cli/test_cmd_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=<non-main>`` must refuse on the ZIP fallback path.

Expand Down