ba030bc0db
Tests did monkeypatch.setattr(<facade module>, name) where name is now defined in a sibling module and the production path reads the sibling's binding. Where production reads through BOTH bindings the setattr is duplicated onto the defining module (import added next to the existing alias import); where only the sibling reads it the target is repointed. Seams whose production readers go through the facade are left alone.
618 lines
23 KiB
Python
618 lines
23 KiB
Python
"""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
|
|
import hermes_cli.main_web_build as main_web_build
|
|
import hermes_cli.main_install_repair as main_install_repair
|
|
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_switch_with_kept_notice(repo_pair):
|
|
"""Commits on the parked branch not in origin/main: still safe to switch
|
|
(checkout keeps them on the branch) — reason carries the count so the
|
|
caller prints the loud 'kept' notice. Non-interactive callers (desktop
|
|
update button, gateway /update, cron) depend on this: they cannot
|
|
resolve a 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 True
|
|
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", "dirty"
|
|
)
|
|
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
|
|
|
|
|
|
def test_kept_notice_names_branch_count_and_recovery(capsys):
|
|
update_cmd._print_parked_branch_kept_notice("old-feature", "main", "3")
|
|
out = capsys.readouterr().out
|
|
assert "parked on 'old-feature'" in out
|
|
assert "3 commit(s) not merged into origin/main" in out
|
|
assert "safe on 'old-feature'" in out
|
|
assert "git checkout old-feature" in out
|
|
assert "CODE UPDATE SKIPPED" not 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(main_install_repair, "_is_windows", lambda: False)
|
|
monkeypatch.setattr(
|
|
hermes_main, "_get_origin_url",
|
|
lambda *a, **k: "https://github.com/NousResearch/hermes-agent.git",
|
|
)
|
|
monkeypatch.setattr(update_cmd, "_is_fork", lambda *a, **k: False)
|
|
monkeypatch.setattr(update_cmd, "_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(main_web_build, "_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_switches_unmerged_parked_branch_with_kept_notice(
|
|
repo_pair, monkeypatch, capsys
|
|
):
|
|
"""Default strategy ("switch"): clean tree + unmerged commits → the
|
|
update proceeds (non-interactive callers like the desktop update button
|
|
cannot resolve a skip), prints the loud 'kept' notice, ends on main
|
|
fast-forwarded to origin/main, and the commits stay on the parked
|
|
branch untouched."""
|
|
(repo_pair / "feature.txt").write_text("unmerged work\n")
|
|
_git(repo_pair, "add", "feature.txt")
|
|
_git(repo_pair, "commit", "-qm", "feature work")
|
|
feature_sha = _git(repo_pair, "rev-parse", "old-feature").stdout.strip()
|
|
_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 "1 commit(s) not merged into origin/main" in out
|
|
assert "safe on 'old-feature'" in out
|
|
assert "CODE UPDATE SKIPPED" not in out
|
|
assert "updating it in place" not in out
|
|
# Ends on main, fast-forwarded.
|
|
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
|
|
# The unmerged commit is still exactly where it was, on the branch.
|
|
assert (
|
|
_git(repo_pair, "rev-parse", "old-feature").stdout.strip()
|
|
== feature_sha
|
|
)
|
|
|
|
|
|
def test_update_updates_unmerged_branch_in_place_when_configured(
|
|
repo_pair, monkeypatch, capsys
|
|
):
|
|
"""updates.parked_branch_strategy: update_in_place — a maintained custom
|
|
branch (local patches on top of main) is updated in place from
|
|
origin/<target> instead of switched away from. The running code must
|
|
advance (origin/main's files arrive) AND the local commits must survive,
|
|
with the checkout never moving."""
|
|
import hermes_cli.config as hermes_config
|
|
|
|
monkeypatch.setattr(
|
|
hermes_config,
|
|
"load_config",
|
|
lambda: {"updates": {"parked_branch_strategy": "update_in_place"}},
|
|
)
|
|
(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)
|
|
|
|
# Stop right after the pull/branch logic, before dependency install.
|
|
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 "updating it in place" in out
|
|
assert "CODE UPDATE SKIPPED" not in out
|
|
# The checkout never moved.
|
|
assert (
|
|
_git(repo_pair, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
|
|
== "old-feature"
|
|
)
|
|
# origin/main's code actually arrived (b.txt lands with c3)...
|
|
assert (repo_pair / "b.txt").exists()
|
|
assert (repo_pair / "a.txt").read_text() == "two\n"
|
|
# ...and the branch's own commit survived it.
|
|
assert (repo_pair / "feature.txt").read_text() == "unmerged work\n"
|
|
assert "feature work" in _git(repo_pair, "log", "--oneline").stdout
|
|
|
|
|
|
def test_switch_branch_flag_overrides_in_place_strategy(
|
|
repo_pair, monkeypatch, capsys
|
|
):
|
|
"""--switch-branch overrides updates.parked_branch_strategy:
|
|
update_in_place for one run: the unmerged branch is LEFT ALONE and the
|
|
update runs on the target instead.
|
|
|
|
A long-lived feature branch does not want an update-driven merge commit
|
|
in its history (#89507 review). The branch tip must be byte-identical
|
|
afterwards, while the checkout ends up on the updated target.
|
|
"""
|
|
import hermes_cli.config as hermes_config
|
|
|
|
monkeypatch.setattr(
|
|
hermes_config,
|
|
"load_config",
|
|
lambda: {"updates": {"parked_branch_strategy": "update_in_place"}},
|
|
)
|
|
(repo_pair / "feature.txt").write_text("unmerged work\n")
|
|
_git(repo_pair, "add", "feature.txt")
|
|
_git(repo_pair, "commit", "-qm", "feature work")
|
|
branch_tip_before = _git(
|
|
repo_pair, "rev-parse", "old-feature"
|
|
).stdout.strip()
|
|
_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,
|
|
switch_branch=True,
|
|
)
|
|
|
|
with pytest.raises(_StopFlow):
|
|
hermes_main.cmd_update(args)
|
|
|
|
out = capsys.readouterr().out
|
|
assert "1 commit(s) not merged into origin/main" in out
|
|
assert "updating it in place" not in out
|
|
assert "CODE UPDATE SKIPPED" not in out
|
|
# Checkout moved to the target and picked up its code...
|
|
assert (
|
|
_git(repo_pair, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
|
|
== "main"
|
|
)
|
|
assert (repo_pair / "b.txt").exists()
|
|
# ...and the feature branch was not written to at all.
|
|
assert (
|
|
_git(repo_pair, "rev-parse", "old-feature").stdout.strip()
|
|
== branch_tip_before
|
|
)
|
|
|
|
|
|
def test_unmerged_branch_still_updates_in_place_without_the_flag(
|
|
repo_pair, monkeypatch, capsys
|
|
):
|
|
"""--switch-branch is opt-in: with the in-place strategy configured and
|
|
no flag, the update stays in place."""
|
|
import hermes_cli.config as hermes_config
|
|
|
|
monkeypatch.setattr(
|
|
hermes_config,
|
|
"load_config",
|
|
lambda: {"updates": {"parked_branch_strategy": "update_in_place"}},
|
|
)
|
|
(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)
|
|
|
|
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,
|
|
switch_branch=False,
|
|
)
|
|
|
|
with pytest.raises(_StopFlow):
|
|
hermes_main.cmd_update(args)
|
|
|
|
out = capsys.readouterr().out
|
|
assert "updating it in place" in out
|
|
assert "--switch-branch" 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
|