Skip to content
Merged
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
27 changes: 23 additions & 4 deletions hermes_cli/update_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -6181,13 +6181,32 @@ def _cmd_update_impl(args, gateway_mode: bool):
# not behind, fall through to the up-to-date path.
commit_count = counted if counted is not None else -1

# A fork can match origin while still trailing upstream. The sync can
# therefore advance HEAD even though the origin comparison found no
# commits. Detect that BEFORE taking the no-update return so dependency
# refreshes, gateway restarts, AND the fleet version matrix still run
# for the pulled code (#73108 — previously the sync lived inside the
# commit_count == 0 branch, which returns immediately after: an update
# that pulled hundreds of upstream commits printed "Already up to
# date!" and verified nothing).
if commit_count == 0 and is_fork and branch == "main":
pre_sync_sha = _capture_head_sha(git_cmd, _m().PROJECT_ROOT)
_m()._sync_with_upstream_if_needed(git_cmd, _m().PROJECT_ROOT)
post_sync_sha = _capture_head_sha(git_cmd, _m().PROJECT_ROOT)
if pre_sync_sha and post_sync_sha and pre_sync_sha != post_sync_sha:
synced_count = _count_commits_between(
git_cmd,
_m().PROJECT_ROOT,
pre_sync_sha,
post_sync_sha,
)
# HEAD moving is itself proof of an update. Keep the update
# path active even if the informational count cannot be read.
commit_count = max(1, synced_count)

if commit_count == 0:
_invalidate_update_cache()

# Even if origin is up to date, the fork may be behind upstream
if is_fork and branch == "main":
_m()._sync_with_upstream_if_needed(git_cmd, _m().PROJECT_ROOT)

# Restore stash and switch back to original branch if we moved.
# EXCEPTION: a parked feature branch we verified clean + fully
# merged stays on the target — re-parking the checkout on the
Expand Down
70 changes: 70 additions & 0 deletions tests/hermes_cli/test_cmd_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,76 @@ def test_update_on_fork_checks_upstream_when_origin_up_to_date(
captured = capsys.readouterr()
assert "Already up to date!" in captured.out

@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_fork_upstream_sync_that_moves_head_runs_post_update_steps(
self, mock_run, _mock_which, mock_args, capsys
):
"""A fork sync that pulls code must continue through post-update work."""
from hermes_cli import main as hm
from hermes_cli import update_cmd

mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="0"
)

# The first two reads bracket the upstream sync (aaaaaaa -> bbbbbbb:
# the sync moved HEAD). The NEXT two bracket the pull inside the
# normal update path (bbbbbbb -> ccccccc) — the head-moved no-op
# guard added after this PR exits 1 when that pair is equal, so the
# mock must show the pull advancing HEAD too.
shas = iter(["aaaaaaa", "bbbbbbb", "bbbbbbb", "ccccccc"])

with patch.object(
hm,
"_get_origin_url",
return_value="https://github.com/example/hermes-agent.git",
), patch.object(
update_cmd,
"_capture_head_sha",
side_effect=lambda *_args, **_kwargs: next(shas, "ccccccc"),
), patch(
# The full post-update path runs the fleet version check, which
# reads the REAL machine's profile gateway_state.json files —
# live gateways on a dev box read as STALE vs this checkout and
# exit 1. Pin an empty fleet: this test asserts the post-update
# path RUNS, not the fleet's health.
"hermes_cli.update_receipt.collect_fleet_versions",
return_value=[],
), patch(
# Same isolation for the restart phase: without these, the real
# machine's live gateways enter the restart discovery, the
# mocked-subprocess restart phase can't verify replacements, and
# the fail-closed contract (#78574) exits 1 (locally the
# live-system guard blocks the os.kill outright).
"hermes_cli.gateway.find_gateway_pids",
return_value=[],
), patch(
"hermes_cli.gateway.find_profile_gateway_processes",
return_value=[],
), patch(
"hermes_cli.gateway._get_service_pids",
return_value=set(),
), patch.object(
hm, "_sync_with_upstream_if_needed"
), patch.object(
hm,
"_reload_updated_runtime_modules",
# Reaching the reload step IS the proof the post-update path ran
# (the bug returned from "Already up to date!" before it). Abort
# the pipeline right here: everything past this point (skills
# sync, desktop rebuild, gateway restart, fleet check) would run
# for real against the host machine.
side_effect=SystemExit(0),
) as post_update_step:
with pytest.raises(SystemExit) as exit_info:
cmd_update(mock_args)

assert exit_info.value.code == 0
post_update_step.assert_called_once_with()
captured = capsys.readouterr()
assert "Already up to date!" not in captured.out

def test_update_non_interactive_runs_safe_config_migrations(self, mock_args, capsys):
"""Dashboard/web updates apply non-interactive migrations before restart."""
with patch("shutil.which", return_value=None), patch(
Expand Down
Loading