Files
hermes-agent/tests/hermes_cli/test_update_pause_step_interruptions.py
Teknium 636fbbd7c4 fix: the stash and churn steps bind the paused gateways' tree gate before writing files
Round 6 bound the branch switch, the upstream syncs, the pull and the rollback.
Other steps that run while the Windows pause holds the gateways still rewrote
tracked files unbound:
- `git stash push` and its cleanup `reset --hard`;
- `git stash apply` and the reset after a conflict;
- the rejected-restore `reset --hard` + `clean`;
- the lockfile-churn `checkout -- <locks>` and the line-ending `checkout`.

If one of them was interrupted mid-write, the gate judged the torn file only
against whatever refs a fetch had left. A restore after the pull had no
baseline at the moved HEAD at all. Either way the paused set was admitted.

Each step now runs inside `_checkout_move`. `paths=` binds the files it
rewrites in place, and `revert=True` binds every tracked change it finds,
because it puts them back to HEAD. A restore binds its stash commit. The gate
counts those paths as writable. A restore that applied its tracked changes in
full reports `whole`, so the tree it changed by design retires its baseline
instead of holding the set.

Removed the "already up to date" switch-back `checkout <current_branch>`. It
only ran when no checkout had left current_branch: in-place and release
updates never switch, and a parked switch is excluded. It was a no-op that
nothing gated. Helpers take a `checkout_move` binder (`_moves_for(token)`);
test doubles of `_prepare_git_command` accept its new keyword.
2026-10-06 06:18:39 -07:00

132 lines
5.8 KiB
Python

"""The stash and churn steps bind the paused gateways' tree gate too (#132338 R3 follow-up).
``git stash push`` / ``stash apply`` and the lockfile-churn ``checkout -- <paths>`` rewrite tracked
files while the Windows pause holds the gateways. Each now records, before git runs, the HEAD it
starts from, the tree's dirty bytes and what it may write; a step interrupted mid-write keeps the
set held instead of being judged against unrelated refs (or, at a moved HEAD, against nothing).
Real git, real update steps, real durable record and gate. The interruption is real too: a git
wrapper copies the durable record as git starts (what was on disk before the write), runs real git,
leaves one file half-written and sends the updater SIGINT, as a terminal's Ctrl-C reaches both.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import pytest
from hermes_cli import update_cmd, update_cmd_git, update_cmd_stash
from hermes_cli import update_pause_record as pause_record
pytestmark = pytest.mark.platforms("posix")
def git(root, *args) -> str:
return subprocess.run(["git", "-C", str(root), *args], check=True, capture_output=True, text=True).stdout.strip()
def commit(root, message, **files) -> str:
for name, text in files.items():
(root / name).write_text(text, encoding="utf-8")
git(root, "add", ".")
git(root, "commit", "-qm", message)
return git(root, "rev-parse", "HEAD")
@pytest.fixture
def repo(tmp_path, monkeypatch):
root = tmp_path / "checkout"
root.mkdir()
git(root, "init", "-q", "-b", "main")
git(root, "config", "user.name", "Fixture")
git(root, "config", "user.email", "fixture@example.com")
monkeypatch.setattr(update_cmd._m(), "PROJECT_ROOT", root)
monkeypatch.setattr(pause_record, "install_root", lambda: root)
a = commit(root, "A", **{"a.py": "A=1\n", "mine.py": "M=1\n", "package-lock.json": "{}\n"})
t = commit(root, "T", **{"a.py": "A=2\n"})
git(root, "reset", "-q", "--hard", a)
# A fetched ref that never touches the files these steps write: what the gate fell back to.
git(root, "update-ref", "refs/remotes/origin/main", t)
return root, a, t
def interrupting_git(tmp_path, match: str, tear) -> list[str]:
"""A git that, on the *match* subcommand, snapshots the durable record, runs real git, leaves
*tear* half-written and interrupts the updater."""
wrapper = tmp_path / "git-interrupted"
wrapper.write_text(
"#!/bin/sh\n"
f'case " $* " in *" {match} "*) cp "{pause_record.record_path()}" "{tmp_path / "at-start.json"}"\n'
f' "{shutil.which("git")}" "$@"\n'
f" printf 'M=' > \"{tear}\"; kill -INT $PPID; sleep 5; exit 130;;\n"
f'esac\nexec "{shutil.which("git")}" "$@"\n',
encoding="utf-8")
wrapper.chmod(0o755)
return [str(wrapper)]
def pause(root) -> dict:
token = pause_record.stamp_tree({"resume_needed": True, "profiles": {"default": 999999}}, root)
pause_record.write(token)
return token
def gate(root) -> bool:
return pause_record.tree_is_whole(pause_record.read(pause_record.record_path())["token"], root)[0]
def at_start(tmp_path, head) -> dict:
"""The baseline at *head* in the record as it was on disk the moment git started."""
token = pause_record.read(tmp_path / "at-start.json")["token"]
return next(b for b in token["baselines"] if b["pre_sha"] == head)
def test_an_interrupted_stash_push_stays_held(repo, tmp_path):
root, a, _t = repo
(root / "mine.py").write_text("M=2 # my edit\n", encoding="utf-8")
token = pause(root)
with pytest.raises(KeyboardInterrupt):
update_cmd_stash._stash_local_changes_if_needed(
interrupting_git(tmp_path, "stash push", root / "mine.py"), root,
checkout_move=update_cmd._moves_for(token))
started = at_start(tmp_path, a)
assert "mine.py" in started["move_paths"] and started["dirty_at_pause"] == ["mine.py"]
assert gate(root) is False, "a stash push interrupted mid-write was admitted"
def test_an_interrupted_lockfile_churn_cleanup_stays_held(repo, tmp_path):
root, a, _t = repo
(root / "package-lock.json").write_text('{"npm": "rewrote me"}\n', encoding="utf-8")
token = pause(root)
with pytest.raises(KeyboardInterrupt):
update_cmd_git._discard_lockfile_churn(
interrupting_git(tmp_path, "checkout --", root / "package-lock.json"), root,
checkout_move=update_cmd._moves_for(token))
assert at_start(tmp_path, a)["move_paths"] == ["package-lock.json"]
assert gate(root) is False, "a lockfile cleanup interrupted mid-write was admitted"
@pytest.mark.parametrize("interrupted", [True, False])
def test_a_stash_restore_at_the_moved_head_is_gated_from_that_head(repo, tmp_path, interrupted):
root, a, t = repo
(root / "mine.py").write_text("M=2 # my edit\n", encoding="utf-8")
token = pause(root)
moves = update_cmd._moves_for(token)
stash = update_cmd_stash._stash_local_changes_if_needed(["git"], root, checkout_move=moves)
with update_cmd._checkout_move(token, t):
git(root, "merge", "-q", "--ff-only", t) # the pull lands whole
git_cmd = interrupting_git(tmp_path, "stash apply", root / "mine.py") if interrupted else ["git"]
if interrupted:
with pytest.raises(KeyboardInterrupt):
update_cmd_stash._restore_stashed_changes(git_cmd, root, stash, checkout_move=moves)
started = at_start(tmp_path, t)
assert started["move_targets"] == [stash] and started["dirty_at_pause"] == []
assert gate(root) is False, "a stash restore interrupted mid-write at the moved HEAD was admitted"
else:
assert update_cmd_stash._restore_stashed_changes(git_cmd, root, stash, checkout_move=moves)
assert (root / "mine.py").read_text() == "M=2 # my edit\n"
assert gate(root) is True, "a stash restore that applied cleanly held the paused set"