Files
hermes-agent/tests/hermes_cli/test_update_detached_rescue_ref.py
teknium1 ff46826872 fix(update): keep commits made on a detached HEAD behind a named rescue ref
Both update shapes move HEAD off a detached commit: the branch update checks
out main, the release update checks out the release commit. A commit made on
the detached HEAD is on no branch, so after the move only the expiring reflog
reached it, and the branch path printed nothing about it (gated on #124643).

One helper, _park_detached_head, now runs at both sites before HEAD moves.
When HEAD is detached at a commit no ref contains (refs/stash excluded: the
autostash is dropped after the update, which is why the branch path parks
before stashing), it writes refs/hermes-update-backups/detached-<branch>-<ts>-<sha>
(the divergence rescue refs' scheme, now pruned on the same terms) and prints
the ref and how to list the commits. If the write fails the update stops
rather than orphan the work. A detached HEAD already on a branch or tag (a
pinned release) gets no ref. It replaces the release path's silent, never
pruned refs/hermes/pre-release/<sha>.
2026-09-27 01:00:31 -07:00

89 lines
4.2 KiB
Python

"""`hermes update` must not orphan commits made on a detached HEAD.
Both update shapes move HEAD off a detached commit: the branch update checks out ``main``, the
release update checks out the release commit. A commit made there is on no branch, so after the
move only the expiring reflog reaches it. The update must park it under
``refs/hermes-update-backups/detached-...`` first and print the ref name. The installed-product
proof is the e2e cell
``tests/e2e/core/upgrade/git/test_foreign_state.py::test_work_committed_on_a_detached_head_stays_reachable``.
"""
from __future__ import annotations
import subprocess
import pytest
from hermes_cli import update_cmd
GIT = ["git"]
def _git(repo, *args, check=True):
return subprocess.run(GIT + list(args), cwd=repo, capture_output=True, text=True, check=check)
def _commit(repo, name, text):
(repo / name).write_text(text, encoding="utf-8")
_git(repo, "add", name)
_git(repo, "-c", "user.name=t", "-c", "user.email=t@example.invalid", "commit", "-q", "-m", f"add {name}")
return _git(repo, "rev-parse", "HEAD").stdout.strip()
@pytest.fixture()
def detached_work(tmp_path, monkeypatch):
"""A checkout detached at ``origin/main~1`` carrying a commit that no ref contains."""
upstream = tmp_path / "upstream"
upstream.mkdir()
_git(upstream, "init", "-q", "-b", "main")
_commit(upstream, "shared.txt", "shared\n")
release = _commit(upstream, "release.txt", "release\n")
checkout = tmp_path / "checkout"
_git(tmp_path, "clone", "-q", str(upstream), str(checkout))
_git(checkout, "checkout", "-q", "--detach", "HEAD~1")
work = _commit(checkout, "detached-work.txt", "work\n")
monkeypatch.setattr(update_cmd._m(), "PROJECT_ROOT", checkout)
return checkout, work, release
def _assert_parked(checkout, work, output):
refs = _git(checkout, "for-each-ref", "--contains", work, "--format=%(refname)").stdout.split()
assert len(refs) == 1 and refs[0].startswith("refs/hermes-update-backups/detached-main-"), \
f"the detached commit must be kept by exactly one rescue ref, got {refs}\n{output}"
assert refs[0] in output, f"the user must be told where the commit went:\n{output}"
assert "1 commit(s) made on the detached HEAD" in output, output
def test_branch_update_from_detached_head_parks_the_work(detached_work, capsys):
"""The branch shape: detached HEAD -> checkout main, with dirty edits riding in the autostash
(whose refs/stash contains HEAD until it is dropped, so it must not count as keeping it)."""
checkout, work, _release = detached_work
(checkout / "shared.txt").write_text("edited\n", encoding="utf-8")
plan = update_cmd._prepare_checkout_for_update(
GIT, "main", "HEAD", is_fork=False, assume_yes=True, gateway_mode=False, gw_input_fn=None,
switch_branch=False, _windows_gateway_resume=None)
if plan.auto_stash_ref is not None:
update_cmd._restore_stashed_changes(GIT, checkout, plan.auto_stash_ref, prompt_user=False)
assert _git(checkout, "branch", "--show-current").stdout.strip() == "main"
_assert_parked(checkout, work, capsys.readouterr().out)
def test_release_update_from_detached_head_parks_only_unreachable_work(detached_work, capsys):
"""The release shape moves HEAD with checkout --detach; a detached HEAD that is already on a
branch or tag (a pinned release) needs no backup and gets none."""
checkout, work, release = detached_work
update_cmd._pull_updates(GIT, "main", None, prompt_for_restore=False, gw_input_fn=None,
discard_local_changes=False, keep_stash=False, target_ref=release)
assert _git(checkout, "rev-parse", "HEAD").stdout.strip() == release
_assert_parked(checkout, work, capsys.readouterr().out)
_git(checkout, "checkout", "-q", "--detach", "origin/main~1")
update_cmd._pull_updates(GIT, "main", None, prompt_for_restore=False, gw_input_fn=None,
discard_local_changes=False, keep_stash=False, target_ref=release)
backups = _git(checkout, "for-each-ref", "--format=%(refname)", "refs/hermes-update-backups/").stdout.split()
assert len(backups) == 1, f"a detached HEAD already on a branch must not get a backup ref: {backups}"