fix(update): 'hermes update' no longer claims success on a parked feature branch — switches back when safe, warns loudly when not

Live incident 2026-08-17: 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.

- Parked-branch guard: auto-switch back to the update target ONLY when the
  parked branch is clean and fully merged (git cherry origin/<target> shows
  nothing unmerged); the checkout then STAYS on the target instead of being
  re-parked. Otherwise: loud CODE UPDATE SKIPPED block naming the branch,
  behind-count, and resolution commands; exit 1; branch untouched.
- The up-to-date (commit_count == 0) path no longer switches back to a
  fully-merged parked branch either.
- Post-pull gate additionally refuses to print '✓ Code updated!' when HEAD
  ends up attached to a non-target branch.
- Summary lines now carry the actual branch + HEAD short-sha:
  '✓ Update complete! [main @ 30fcf9580]' — drift visible at a glance.
- New config toggle updates.auto_switch_parked_branch (default true).
- Real-git-fixture regression tests (init/clone/branch, no subprocess
  mocks): clean+merged auto-switch, dirty skip, unmerged skip, cherry-picked
  equivalence, config opt-out, unverifiable ref, on-main fast path,
  up-to-date no-repark, summary branch/sha assertions.
This commit is contained in:
teknium1
2026-08-17 22:07:01 -07:00
committed by Teknium
parent a943895f2e
commit 8ce8ffd429
6 changed files with 678 additions and 11 deletions
+9
View File
@@ -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
+4
View File
@@ -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",
+236 -11
View File
@@ -828,11 +828,171 @@ def _commit_staged_replacements(staged) -> None:
pass
def _branch_head_label(git_cmd=None, cwd=None) -> str | None:
"""``"<branch> @ <short-sha>"`` 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:
"""`` [<branch> @ <sha>]`` 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/<target_branch>`` (``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, <reason>) and the caller must NOT touch the branch.
Reasons: "disabled", "dirty", "unmerged:<count>", "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
@@ -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/<target> → 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/<target> 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
+6
View File
@@ -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/<name>` (`git checkout -B <name> origin/<name>`). 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 '<branch>' (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.
+1
View File
@@ -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`).