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.
132 lines
5.8 KiB
Python
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"
|