diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 48b8adebc2..29ae25d7f7 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -3171,6 +3171,15 @@ DEFAULT_CONFIG = { # ignored paths — node_modules, venv, build outputs — # are never touched. "non_interactive_local_changes": "stash", + # When `hermes update` finds the source checkout parked on a feature + # branch (left behind by tooling or a manual checkout), switch back + # to the update target automatically — but only when the branch is + # clean and every commit on it is already merged into the target. + # When it is not safe, the code update is SKIPPED with a loud + # warning instead of pretending success (2026-08-17 incident: + # "✓ Code updated!" printed while the checkout stayed days behind + # main on a stale branch). Set false to never auto-switch. + "auto_switch_parked_branch": True, # Refresh an already-installed cua-driver during `hermes update`. # The refresh is best-effort and macOS-only. Turn this off if the # upstream installer is not appropriate for the machine, for example diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 280747d2f3..1e8a83b18c 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4799,6 +4799,9 @@ _LAZY_COMMAND_EXPORTS = { "_capture_active_lazy_features", "_capture_active_tool_dependencies", "_capture_head_sha", + "_assess_parked_branch_switch", + "_branch_head_label", + "_branch_head_suffix", "_cmd_update_check", "_cmd_update_impl", "_cold_start_windows_gateway_after_update", @@ -4836,6 +4839,7 @@ _LAZY_COMMAND_EXPORTS = { "_print_curator_first_run_notice", "_print_curator_recent_run_notice", "_print_fts_optimize_available_notice", + "_print_parked_branch_skip_warning", "_print_stash_cleanup_guidance", "_print_update_completion", "_record_npm_lockfile_hash", diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index e914d38bb7..e05850c7b9 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -828,11 +828,171 @@ def _commit_staged_replacements(staged) -> None: pass +def _branch_head_label(git_cmd=None, cwd=None) -> str | None: + """``" @ "`` for the checkout, or None when unknown. + + Appended to the update summary lines so branch drift is visible at a + glance (live incident 2026-08-17: a checkout parked on a stale feature + branch got "✓ Update complete!" with nothing on the line saying WHERE + the checkout actually sat). Never raises — summary decoration must not + break an update. + """ + try: + cmd = list(git_cmd) if git_cmd else ["git"] + root = cwd if cwd is not None else _m().PROJECT_ROOT + branch = subprocess.run( + cmd + ["rev-parse", "--abbrev-ref", "HEAD"], + cwd=root, capture_output=True, + text=True, encoding="utf-8", errors="replace", + ) + sha = subprocess.run( + cmd + ["rev-parse", "--short", "HEAD"], + cwd=root, capture_output=True, + text=True, encoding="utf-8", errors="replace", + ) + branch_name = branch.stdout.strip() + sha_text = sha.stdout.strip() + if branch.returncode != 0 or sha.returncode != 0 or not sha_text: + return None + if not branch_name: + return None + label = "detached" if branch_name == "HEAD" else branch_name + return f"{label} @ {sha_text}" + except Exception: + return None + + +def _branch_head_suffix(git_cmd=None, cwd=None) -> str: + """`` [ @ ]`` suffix for summary lines ("" when unknown).""" + label = _branch_head_label(git_cmd, cwd) + return f" [{label}]" if label else "" + + +def _assess_parked_branch_switch( + git_cmd: list[str], cwd: Path, current_branch: str, target_branch: str +) -> tuple[bool, str]: + """Decide whether it is safe to auto-switch a parked feature branch back + to the update target. + + Live incident (2026-08-17, Teknium's box): the source checkout sat on a + stale feature branch left behind by earlier tooling; ``hermes update`` + autostashed, ran its post-update steps and printed "✓ Code updated!" + while the running code stayed days behind main. The guard's contract: + + - safe (True, "") only when the working tree + index are clean AND every + commit on the parked branch is already contained in + ``origin/`` (``git cherry`` reports no ``+`` lines). + - anything else — dirty tree, unmerged commits, git errors, or the + ``updates.auto_switch_parked_branch: false`` config opt-out — returns + (False, ) and the caller must NOT touch the branch. + + Reasons: "disabled", "dirty", "unmerged:", "unverifiable". + """ + try: + from hermes_cli.config import load_config + + _update_cfg = (load_config() or {}).get("updates", {}) + if isinstance(_update_cfg, dict) and not bool( + _update_cfg.get("auto_switch_parked_branch", True) + ): + return False, "disabled" + except Exception as exc: + # A config read failure must not disable the guard's safety checks — + # fall through to them with the default (auto-switch allowed). + logger.debug("Could not read updates.auto_switch_parked_branch: %s", exc) + + status = subprocess.run( + git_cmd + ["status", "--porcelain"], + cwd=cwd, capture_output=True, + text=True, encoding="utf-8", errors="replace", + ) + if status.returncode != 0: + return False, "unverifiable" + if status.stdout.strip(): + return False, "dirty" + + cherry = subprocess.run( + git_cmd + ["cherry", f"origin/{target_branch}"], + cwd=cwd, capture_output=True, + text=True, encoding="utf-8", errors="replace", + ) + if cherry.returncode != 0: + return False, "unverifiable" + unmerged = [ + line for line in cherry.stdout.splitlines() if line.startswith("+") + ] + if unmerged: + return False, f"unmerged:{len(unmerged)}" + return True, "" + + +def _print_parked_branch_skip_warning( + git_cmd: list[str], + cwd: Path, + current_branch: str, + target_branch: str, + reason: str, +) -> None: + """LOUD block explaining why the code update was skipped on a parked + branch, with the behind-count and the exact commands to resolve.""" + behind = None + try: + behind_result = subprocess.run( + git_cmd + ["rev-list", f"HEAD..origin/{target_branch}", "--count"], + cwd=cwd, capture_output=True, + text=True, encoding="utf-8", errors="replace", + ) + if behind_result.returncode == 0 and behind_result.stdout.strip(): + behind = int(behind_result.stdout.strip()) + except Exception: + behind = None + + if reason == "dirty": + why = "the working tree has uncommitted changes" + elif reason.startswith("unmerged:"): + count = reason.split(":", 1)[1] + why = ( + f"the branch has {count} commit(s) not merged into " + f"origin/{target_branch}" + ) + elif reason == "disabled": + why = "updates.auto_switch_parked_branch is set to false in config.yaml" + else: + why = ( + f"the branch state could not be verified against " + f"origin/{target_branch}" + ) + + bar = "=" * 68 + print() + print(bar) + print(f"⚠ CODE UPDATE SKIPPED — checkout is parked on '{current_branch}'") + print(f" Not auto-switching to {target_branch}: {why}.") + if behind is not None and behind > 0: + print( + f" This checkout is {behind} commit(s) BEHIND " + f"origin/{target_branch} — the code you are running is stale." + ) + print() + print(" To resolve, inspect the branch and switch back yourself:") + print(f" git -C {cwd} status") + print(f" git -C {cwd} checkout {target_branch} && hermes update") + print( + " (commit or stash your work on the branch first if you want to " + "keep it)" + ) + print(bar) + + def _print_update_completion(message: str) -> None: """Print an update outcome plus, when the dashboard launched this run with an action id, a terminal receipt line the Desktop can match after - the dashboard restarts (see #47359 / #58764).""" - print(message) + the dashboard restarts (see #47359 / #58764). + + The outcome line carries the checkout's actual branch + HEAD short-sha + so branch drift is visible at a glance (2026-08-17 parked-branch + incident).""" + print(f"{message}{_branch_head_suffix()}") action_id = os.environ.get("HERMES_ACTION_ID", "") if len(action_id) == 32 and all(char in "0123456789abcdef" for char in action_id): print(f"=== hermes-update completed {action_id} ===") @@ -4718,13 +4878,47 @@ def _cmd_update_impl(args, gateway_mode: bool): # "always update against main" behavior; for any other target it's # the same thing — get HEAD onto the requested branch first, then # fast-forward. + # + # Parked-branch guard (2026-08-17 live incident): the checkout can be + # left parked on a stale feature branch by earlier tooling. Blindly + # stash-switch-pull-switch-back "updates" main while the running code + # stays days behind, then prints "✓ Code updated!". Only auto-switch + # when the parked branch is clean AND fully merged into the target; + # otherwise warn loudly, mark the code update SKIPPED, and stop + # before the post-update steps reinforce the stale tree. + parked_branch_switched = False if current_branch != branch: - label = ( - "detached HEAD" - if current_branch == "HEAD" - else f"branch '{current_branch}'" - ) - print(f" ⚠ Currently on {label} — switching to {branch} for update...") + if current_branch != "HEAD": + switch_safe, switch_block_reason = _m()._assess_parked_branch_switch( + git_cmd, _m().PROJECT_ROOT, current_branch, branch + ) + if not switch_safe: + _m()._print_parked_branch_skip_warning( + git_cmd, + _m().PROJECT_ROOT, + current_branch, + branch, + switch_block_reason, + ) + print() + print( + "⚠ Update finished — code update SKIPPED" + f"{_branch_head_suffix(git_cmd, _m().PROJECT_ROOT)}" + ) + _m()._resume_windows_gateways_after_update( + _windows_gateway_resume + ) + sys.exit(1) + parked_branch_switched = True + print( + f" ⚠ Checkout was parked on '{current_branch}' " + f"(fully merged) — switching back to {branch}..." + ) + else: + print( + f" ⚠ Currently on detached HEAD — switching to {branch} " + "for update..." + ) # Stash before checkout so uncommitted work isn't lost auto_stash_ref = _m()._stash_local_changes_if_needed(git_cmd, _m().PROJECT_ROOT) checkout_result = subprocess.run( @@ -4817,7 +5011,10 @@ def _cmd_update_impl(args, gateway_mode: bool): 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 + # 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 + # stale branch is the 2026-08-17 incident all over again. if auto_stash_ref is not None: _m()._restore_stashed_changes( git_cmd, @@ -4826,7 +5023,12 @@ def _cmd_update_impl(args, gateway_mode: bool): prompt_user=prompt_for_restore, input_fn=gw_input_fn, ) - if current_branch not in {branch, "HEAD"}: + if parked_branch_switched: + print( + f" ✓ Checkout was parked on '{current_branch}' (fully " + f"merged) — switched back to {branch}." + ) + elif current_branch not in {branch, "HEAD"}: subprocess.run( git_cmd + ["checkout", current_branch], cwd=_m().PROJECT_ROOT, @@ -5079,6 +5281,29 @@ def _cmd_update_impl(args, gateway_mode: bool): _m()._resume_windows_gateways_after_update(_windows_gateway_resume) sys.exit(1) + # And verify HEAD actually sits on the target branch. The parked- + # branch guard above should make this unreachable, but if any path + # leaves the checkout attached elsewhere, "✓ Code updated!" would be + # a lie — refuse to claim success (2026-08-17 incident class). + post_pull_branch = subprocess.run( + git_cmd + ["rev-parse", "--abbrev-ref", "HEAD"], + cwd=_m().PROJECT_ROOT, + capture_output=True, + text=True, encoding="utf-8", errors="replace", + ).stdout.strip() + if post_pull_branch and post_pull_branch not in {branch, "HEAD"}: + print() + print( + f"✗ Update pulled origin/{branch}, but the checkout is on " + f"'{post_pull_branch}' — not claiming success." + ) + print( + " Switch to the target branch and retry: " + f"git -C {_m().PROJECT_ROOT} checkout {branch} && hermes update" + ) + _m()._resume_windows_gateways_after_update(_windows_gateway_resume) + sys.exit(1) + # Clear stale .pyc bytecode cache — prevents ImportError on gateway # restart when updated source references names that didn't exist in # the old bytecode (e.g. get_hermes_home added to hermes_constants). @@ -5245,7 +5470,7 @@ def _cmd_update_impl(args, gateway_mode: bool): ) print() - print("✓ Code updated!") + print(f"✓ Code updated!{_branch_head_suffix(git_cmd, _m().PROJECT_ROOT)}") # ── Post-update state.db integrity guard (#68474) ───────────────── # Verify that state.db survived the update intact. If the live file diff --git a/tests/hermes_cli/test_update_parked_branch_guard.py b/tests/hermes_cli/test_update_parked_branch_guard.py new file mode 100644 index 0000000000..f4670643e8 --- /dev/null +++ b/tests/hermes_cli/test_update_parked_branch_guard.py @@ -0,0 +1,422 @@ +"""Regression tests for the parked-branch guard in ``hermes update``. + +Live incident (2026-08-17, Teknium's Linux box): the source checkout was +parked on a stale feature branch (``claude-code-inspired/local-terminal- +memory-limit``, days behind main) left there by earlier tooling. ``hermes +update`` autostashed, refreshed lazy backends, synced skills and printed +"✓ Code updated!" / "✓ Update complete!" — while the checkout stayed on the +stale branch with none of main's new code. Two sessions burned time on +"the fix is missing" confusion that was really this. + +The guard (``_assess_parked_branch_switch``): +- clean tree + branch fully merged into origin/ → safe to + auto-switch back to the target (and STAY there — no switch-back). +- dirty tree, unmerged commits, git failure, or the + ``updates.auto_switch_parked_branch: false`` opt-out → do NOT touch the + branch; warn loudly and mark the code update SKIPPED. + +These tests run the guard against REAL git repositories (init, commit, +branch, clone) — not mocked subprocess.run — so they exercise the actual +``git status`` / ``git cherry`` semantics the guard depends on. +""" + +import subprocess +from types import SimpleNamespace + +import pytest + +from hermes_cli import main as hermes_main +from hermes_cli import update_cmd + + +GIT = ["git"] + + +def _git(cwd, *args, check=True): + return subprocess.run( + GIT + list(args), + cwd=cwd, + capture_output=True, + text=True, + check=check, + ) + + +@pytest.fixture() +def repo_pair(tmp_path): + """A real origin repo + local clone, with main two commits ahead of the + clone's parked state. + + Returns (clone_path,). The clone starts parked on feature branch + ``old-feature`` cut from the first commit; origin/main has moved on. + """ + origin = tmp_path / "origin" + origin.mkdir() + _git(origin, "init", "-q", "-b", "main") + _git(origin, "config", "user.email", "test@example.com") + _git(origin, "config", "user.name", "Test") + (origin / "a.txt").write_text("one\n") + _git(origin, "add", "a.txt") + _git(origin, "commit", "-qm", "c1") + + clone = tmp_path / "clone" + _git(tmp_path, "clone", "-q", str(origin), str(clone)) + _git(clone, "config", "user.email", "test@example.com") + _git(clone, "config", "user.name", "Test") + # Park the clone on a feature branch cut at c1. + _git(clone, "checkout", "-qb", "old-feature") + + # main advances upstream (two commits). + (origin / "a.txt").write_text("two\n") + _git(origin, "commit", "-aqm", "c2") + (origin / "b.txt").write_text("three\n") + _git(origin, "add", "b.txt") + _git(origin, "commit", "-qm", "c3") + + _git(clone, "fetch", "-q", "origin", "main") + return clone + + +@pytest.fixture(autouse=True) +def _no_config(monkeypatch): + """Isolate the guard from the machine's real config.yaml.""" + import hermes_cli.config as hermes_config + + monkeypatch.setattr(hermes_config, "load_config", lambda: {}) + + +# --------------------------------------------------------------------------- +# _assess_parked_branch_switch against real repos +# --------------------------------------------------------------------------- + +def test_clean_fully_merged_branch_is_safe_to_switch(repo_pair): + """Parked branch == ancestor of origin/main, clean tree → auto-switch.""" + safe, reason = update_cmd._assess_parked_branch_switch( + GIT, repo_pair, "old-feature", "main" + ) + assert safe is True + assert reason == "" + + +def test_dirty_tree_blocks_auto_switch(repo_pair): + """Uncommitted changes on the parked branch → do not touch it.""" + (repo_pair / "a.txt").write_text("local edit\n") + safe, reason = update_cmd._assess_parked_branch_switch( + GIT, repo_pair, "old-feature", "main" + ) + assert safe is False + assert reason == "dirty" + + +def test_untracked_file_blocks_auto_switch(repo_pair): + """Untracked files count as dirty too — they'd ride along on checkout.""" + (repo_pair / "scratch.py").write_text("wip\n") + safe, reason = update_cmd._assess_parked_branch_switch( + GIT, repo_pair, "old-feature", "main" + ) + assert safe is False + assert reason == "dirty" + + +def test_unmerged_commits_block_auto_switch(repo_pair): + """Commits on the parked branch not contained in origin/main → skip.""" + (repo_pair / "feature.txt").write_text("unmerged work\n") + _git(repo_pair, "add", "feature.txt") + _git(repo_pair, "commit", "-qm", "feature work") + + safe, reason = update_cmd._assess_parked_branch_switch( + GIT, repo_pair, "old-feature", "main" + ) + assert safe is False + assert reason == "unmerged:1" + + +def test_equivalent_cherry_picked_commit_is_still_safe(repo_pair): + """A commit whose patch already landed upstream (git cherry '-') does + not block the switch — only genuinely unmerged '+' commits do.""" + # Cherry-pick origin/main's c2 onto the parked branch: patch-identical. + _git(repo_pair, "cherry-pick", "origin/main~1") + safe, reason = update_cmd._assess_parked_branch_switch( + GIT, repo_pair, "old-feature", "main" + ) + assert safe is True + assert reason == "" + + +def test_config_opt_out_blocks_auto_switch(repo_pair, monkeypatch): + """updates.auto_switch_parked_branch: false disables auto-switch even + when the branch is clean and merged.""" + import hermes_cli.config as hermes_config + + monkeypatch.setattr( + hermes_config, + "load_config", + lambda: {"updates": {"auto_switch_parked_branch": False}}, + ) + safe, reason = update_cmd._assess_parked_branch_switch( + GIT, repo_pair, "old-feature", "main" + ) + assert safe is False + assert reason == "disabled" + + +def test_missing_origin_ref_is_unverifiable(repo_pair): + """If origin/ can't be resolved, the guard refuses to switch.""" + safe, reason = update_cmd._assess_parked_branch_switch( + GIT, repo_pair, "old-feature", "no-such-branch" + ) + assert safe is False + assert reason == "unverifiable" + + +# --------------------------------------------------------------------------- +# Skip warning content +# --------------------------------------------------------------------------- + +def test_skip_warning_names_branch_behind_count_and_commands(repo_pair, capsys): + update_cmd._print_parked_branch_skip_warning( + GIT, repo_pair, "old-feature", "main", "unmerged:1" + ) + out = capsys.readouterr().out + assert "CODE UPDATE SKIPPED" in out + assert "old-feature" in out + assert "2 commit(s) BEHIND" in out + assert f"git -C {repo_pair} checkout main && hermes update" in out + + +def test_skip_warning_dirty_reason(repo_pair, capsys): + update_cmd._print_parked_branch_skip_warning( + GIT, repo_pair, "old-feature", "main", "dirty" + ) + out = capsys.readouterr().out + assert "uncommitted changes" in out + + +# --------------------------------------------------------------------------- +# Summary branch/HEAD visibility +# --------------------------------------------------------------------------- + +def test_branch_head_label_reflects_real_checkout(repo_pair): + label = update_cmd._branch_head_label(GIT, repo_pair) + short = _git(repo_pair, "rev-parse", "--short", "HEAD").stdout.strip() + assert label == f"old-feature @ {short}" + + +def test_branch_head_label_detached(repo_pair): + _git(repo_pair, "checkout", "-q", "--detach") + label = update_cmd._branch_head_label(GIT, repo_pair) + assert label is not None + assert label.startswith("detached @ ") + + +def test_branch_head_suffix_empty_on_non_repo(tmp_path): + assert update_cmd._branch_head_suffix(GIT, tmp_path / "not-a-repo") == "" + + +def test_print_update_completion_carries_branch_and_sha( + repo_pair, monkeypatch, capsys +): + monkeypatch.setattr(hermes_main, "PROJECT_ROOT", repo_pair) + update_cmd._print_update_completion("✓ Update complete!") + out = capsys.readouterr().out + short = _git(repo_pair, "rev-parse", "--short", "HEAD").stdout.strip() + assert f"✓ Update complete! [old-feature @ {short}]" in out + + +# --------------------------------------------------------------------------- +# Full update flow: parked branch dirty/unmerged → SKIPPED, no false success +# --------------------------------------------------------------------------- + +def _patch_update_flow(monkeypatch, repo, run_real_git=True): + """Point _cmd_update_impl at the real repo and neuter the long tail. + + Matches the monkeypatch surface of test_update_head_moved_gate.py, but + keeps REAL subprocess.run so the git plumbing runs against the fixture + repo (the whole point of these regressions). + """ + monkeypatch.setattr(hermes_main, "PROJECT_ROOT", repo) + monkeypatch.setattr(hermes_main, "_resolve_update_branch", lambda args: "main") + monkeypatch.setattr(hermes_main, "_is_windows", lambda: False) + monkeypatch.setattr( + hermes_main, "_get_origin_url", + lambda *a, **k: "https://github.com/NousResearch/hermes-agent.git", + ) + monkeypatch.setattr(hermes_main, "_is_fork", lambda *a, **k: False) + monkeypatch.setattr(hermes_main, "_discard_lockfile_churn", lambda *a, **k: None) + monkeypatch.setattr(update_cmd, "_discard_lockfile_churn", lambda *a, **k: None) + monkeypatch.setattr(update_cmd, "_normalize_managed_eol", lambda *a, **k: None) + monkeypatch.setattr(hermes_main, "_clear_bytecode_cache", lambda *a, **k: 0) + monkeypatch.setattr(hermes_main, "_record_bytecode_fingerprint", lambda *a, **k: None) + monkeypatch.setattr(hermes_main, "_run_pre_update_backup", lambda *a, **k: None) + monkeypatch.setattr(hermes_main, "_pause_windows_gateways_for_update", lambda: None) + monkeypatch.setattr( + hermes_main, "_resume_windows_gateways_after_update", lambda *a, **k: None + ) + monkeypatch.setattr(hermes_main, "_capture_active_lazy_features", lambda: []) + monkeypatch.setattr(hermes_main, "_capture_active_tool_dependencies", lambda: []) + + +def test_update_skips_and_warns_on_dirty_parked_branch( + repo_pair, monkeypatch, capsys +): + """Tonight's incident shape: parked branch + dirty tree. The update must + NOT print '✓ Code updated!', must warn loudly, and must exit non-zero + with the branch named in the summary.""" + (repo_pair / "a.txt").write_text("local edit\n") + _patch_update_flow(monkeypatch, repo_pair) + args = SimpleNamespace(branch=None, yes=False, force=False, force_venv=False) + + with pytest.raises(SystemExit) as exc_info: + hermes_main.cmd_update(args) + + assert exc_info.value.code == 1 + out = capsys.readouterr().out + assert "CODE UPDATE SKIPPED" in out + assert "old-feature" in out + assert "code update SKIPPED" in out + assert "✓ Code updated!" not in out + assert "✓ Update complete!" not in out + # Branch untouched. + branch = _git(repo_pair, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip() + assert branch == "old-feature" + # No autostash was created — the guard fires before any stash. + stashes = _git(repo_pair, "stash", "list").stdout.strip() + assert stashes == "" + + +def test_update_skips_on_unmerged_parked_branch(repo_pair, monkeypatch, capsys): + (repo_pair / "feature.txt").write_text("unmerged work\n") + _git(repo_pair, "add", "feature.txt") + _git(repo_pair, "commit", "-qm", "feature work") + _patch_update_flow(monkeypatch, repo_pair) + args = SimpleNamespace(branch=None, yes=False, force=False, force_venv=False) + + with pytest.raises(SystemExit) as exc_info: + hermes_main.cmd_update(args) + + assert exc_info.value.code == 1 + out = capsys.readouterr().out + assert "CODE UPDATE SKIPPED" in out + assert "1 commit(s) not merged" in out + assert "✓ Code updated!" not in out + assert ( + _git(repo_pair, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip() + == "old-feature" + ) + + +def test_update_auto_switches_clean_merged_parked_branch( + repo_pair, monkeypatch, capsys +): + """Clean + fully merged parked branch → auto-switch back to main, pull, + say so, and STAY on main afterwards (sabotage-proven: reverting the + guard re-parks the checkout and this test fails on the branch assert).""" + _patch_update_flow(monkeypatch, repo_pair) + # Stop the flow right after the pull/branch logic: the dependency + # install phase begins with _abort_dependency_sync_if_self_locked. + class _StopFlow(Exception): + pass + + monkeypatch.setattr( + hermes_main, + "_abort_dependency_sync_if_self_locked", + lambda *a, **k: (_ for _ in ()).throw(_StopFlow()), + ) + args = SimpleNamespace(branch=None, yes=False, force=False, force_venv=False) + + with pytest.raises(_StopFlow): + hermes_main.cmd_update(args) + + out = capsys.readouterr().out + assert "parked on 'old-feature'" in out + assert "fully merged" in out + assert "switching back to main" in out + assert "CODE UPDATE SKIPPED" not in out + # The checkout ends up ON main, fast-forwarded to origin/main. + assert ( + _git(repo_pair, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip() + == "main" + ) + head = _git(repo_pair, "rev-parse", "HEAD").stdout.strip() + remote = _git(repo_pair, "rev-parse", "origin/main").stdout.strip() + assert head == remote + + +def test_update_up_to_date_path_does_not_repark_merged_branch( + tmp_path, monkeypatch, capsys +): + """commit_count == 0 path: before this fix, the updater switched BACK to + the parked feature branch after checking main ("Restore stash and switch + back to original branch") — silently re-parking the checkout so every + subsequent update repeated the incident. A clean, fully merged parked + branch must now END on main.""" + origin = tmp_path / "origin" + origin.mkdir() + _git(origin, "init", "-q", "-b", "main") + _git(origin, "config", "user.email", "test@example.com") + _git(origin, "config", "user.name", "Test") + (origin / "a.txt").write_text("one\n") + _git(origin, "add", "a.txt") + _git(origin, "commit", "-qm", "c1") + + clone = tmp_path / "clone" + _git(tmp_path, "clone", "-q", str(origin), str(clone)) + _git(clone, "config", "user.email", "test@example.com") + _git(clone, "config", "user.name", "Test") + _git(clone, "checkout", "-qb", "old-feature") + # No new upstream commits: local main == origin/main == old-feature tip. + + _patch_update_flow(monkeypatch, clone) + + class _StopFlow(Exception): + pass + + import hermes_cli.managed_uv as managed_uv + + monkeypatch.setattr( + managed_uv, + "update_managed_uv", + lambda *a, **k: (_ for _ in ()).throw(_StopFlow()), + ) + args = SimpleNamespace(branch=None, yes=False, force=False, force_venv=False) + + with pytest.raises(_StopFlow): + hermes_main.cmd_update(args) + + out = capsys.readouterr().out + assert "switched back to main" in out + # The regression: old code ran `git checkout old-feature` here. + assert ( + _git(clone, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip() == "main" + ) + + +def test_update_on_main_fast_path_unchanged(repo_pair, monkeypatch, capsys): + """On the target branch already: no guard prints, normal pull flow.""" + _git(repo_pair, "checkout", "-q", "main") + + _patch_update_flow(monkeypatch, repo_pair) + + class _StopFlow(Exception): + pass + + monkeypatch.setattr( + hermes_main, + "_abort_dependency_sync_if_self_locked", + lambda *a, **k: (_ for _ in ()).throw(_StopFlow()), + ) + args = SimpleNamespace(branch=None, yes=False, force=False, force_venv=False) + + with pytest.raises(_StopFlow): + hermes_main.cmd_update(args) + + out = capsys.readouterr().out + assert "parked on" not in out + assert "CODE UPDATE SKIPPED" not in out + assert ( + _git(repo_pair, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip() + == "main" + ) + head = _git(repo_pair, "rev-parse", "HEAD").stdout.strip() + remote = _git(repo_pair, "rev-parse", "origin/main").stdout.strip() + assert head == remote diff --git a/website/docs/getting-started/updating.md b/website/docs/getting-started/updating.md index 24ac9e0cc2..ab51e86541 100644 --- a/website/docs/getting-started/updating.md +++ b/website/docs/getting-started/updating.md @@ -42,6 +42,12 @@ hermes update --check --branch experimental # preview behindness only If your local checkout is on a different branch, Hermes auto-stashes any uncommitted work, switches HEAD to the target branch, and then pulls. Branches that don't exist locally are auto-tracked from `origin/` (`git checkout -B origin/`). Branches that don't exist anywhere fail cleanly — your stashed changes are restored before exit so you're never stranded in a weird state. The `main`-only fork-upstream sync logic is automatically skipped on non-`main` branches. +### Checkout parked on a feature branch + +If the source checkout was left sitting on a feature branch (by tooling, a worktree experiment, or a manual checkout), `hermes update` only switches it back to the update target automatically when that is provably safe: the working tree is clean **and** every commit on the parked branch is already contained in `origin/main` (`git cherry` reports nothing unmerged). In that case the update says so — `Checkout was parked on '' (fully merged) — switched back to main` — and stays on `main` afterwards. + +When the parked branch has uncommitted changes or unmerged commits, Hermes does **not** touch it. The code update is marked **SKIPPED** with a loud warning naming the branch, how far behind `origin/main` it is, and the exact commands to resolve — instead of pretending the update succeeded. The completion line always shows the actual branch and HEAD (`✓ Update complete! [main @ 30fcf9580]`) so drift is visible at a glance. Set `updates.auto_switch_parked_branch: false` in `config.yaml` to disable the auto-switch entirely (the skip warning still fires). + ### Local changes on non-interactive updates When you run `hermes update` in a terminal, Hermes stashes any uncommitted source-tree changes, pulls, then **asks** whether to restore them — exactly as it always has. Nothing changes for interactive updates. diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index b7e3d72c74..0eccf2d5aa 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -124,6 +124,7 @@ updates: pre_update_backup: quick # quick (state snapshot, default) | full (snapshot + HERMES_HOME zip) | off backup_keep: 5 # Keep this many full pre-update backup zips non_interactive_local_changes: stash # stash | discard + auto_switch_parked_branch: true # auto-switch a clean, fully merged parked branch back to main ``` `pre_update_backup` is the single pre-update safety knob: `quick` (default) snapshots critical state files (pairing data, cron jobs, config, auth; files over 1 GiB are skipped) into `state-snapshots/`; `full` additionally zips all of `HERMES_HOME` into `backups/` and can add minutes on large homes; `off` disables both. Legacy booleans are honored (`true` → `full`, `false` → `off`).