Files
hermes-agent/tests/tools/test_checkpoint_gitlinks.py
unsupportedpastels 99721dca80 fix(checkpoints): refuse an empty restore selection and delete read-only test repos
- A selection that resolves to no entries (e.g. ":(exclude)*") is refused
  before the pre-rollback snapshot. Checkout with an empty pathspec file
  switches the store's HEAD instead of restoring nothing.
- Remove the nested repository with utils.rmtree_readonly so the
  deleted-repo test runs on Git for Windows (read-only loose objects).
2026-09-29 14:12:41 -04:00

280 lines
12 KiB
Python

"""Real-Git rollback contracts for checkpoints containing uncaptured gitlinks."""
import os
import shutil
import subprocess
import pytest
from tools import checkpoint_manager as cm
from utils import rmtree_readonly
@pytest.fixture
def checkpoint(tmp_path, monkeypatch):
monkeypatch.setattr(cm, "CHECKPOINT_BASE", tmp_path / "checkpoints")
work = tmp_path / "workspace"
work.mkdir()
nested = work / "tool"
nested.mkdir()
env = {**os.environ, "GIT_CONFIG_GLOBAL": os.devnull,
"GIT_CONFIG_NOSYSTEM": "1"}
for args in (["init", "-q"], ["config", "user.name", "Test"],
["config", "user.email", "test@example.invalid"]):
subprocess.run(["git", *args], cwd=nested, env=env, check=True, capture_output=True)
(nested / "main.py").write_text("committed\n")
subprocess.run(["git", "add", "."], cwd=nested, env=env, check=True, capture_output=True)
subprocess.run(["git", "commit", "-qm", "seed"], cwd=nested, env=env, check=True, capture_output=True)
(nested / "main.py").write_text("user work not captured\n")
(nested / "untracked.txt").write_text("not captured either\n")
(work / "notes.txt").write_text("before\n")
mgr = cm.CheckpointManager(enabled=True)
assert mgr.ensure_checkpoint(str(work), "baseline")
commit = mgr.list_checkpoints(str(work))[0]["hash"]
ok, tree, _ = cm._run_git(["ls-tree", "-r", "-z", commit], cm._store_path(), str(work))
assert ok and "160000 commit " in tree
(work / "notes.txt").write_text("after\n")
(nested / "main.py").write_text("agent overwrite\n")
return mgr, work, commit
def durable_state(work):
"""Include refs, indexes, ledger and working files, not transient lock files."""
roots = [work, cm._store_path()]
return {(str(root), str(path.relative_to(root))): path.read_bytes() if path.is_file() else None
for root in roots for path in root.rglob("*")}
@pytest.mark.parametrize("safe", [False, True])
@pytest.mark.parametrize("spec", [None, ".", "tool", "tool/", "tool/main.py", "*",
"t*", ":(glob)*", ":(top,glob)**", ":(icase)TOOL"])
def test_refusal_is_nonmutating(checkpoint, safe, spec):
mgr, work, commit = checkpoint
before = durable_state(work)
result = mgr.restore(str(work), commit, file_path=spec, safe=safe)
assert result["success"] is False
assert result["nested_repositories"] == ["tool"]
assert "not captured" in result["error"]
assert durable_state(work) == before
@pytest.mark.parametrize("safe", [False, True])
@pytest.mark.parametrize("spec", ["notes.txt", "*.txt", ":(glob)*.txt", ":(exclude)tool",
":(literal)notes.txt"])
def test_unrelated_selection_still_restores(checkpoint, safe, spec):
mgr, work, commit = checkpoint
result = mgr.restore(str(work), commit, file_path=spec, safe=safe)
assert result["success"] is True, result
assert (work / "notes.txt").read_text() == "before\n"
assert (work / "tool" / "main.py").read_text() == "agent overwrite\n"
@pytest.mark.parametrize("name", ["space name.txt", "unicode-雪.txt",
pytest.param("tab\tline\n.txt", marks=pytest.mark.platforms("posix")),
# Raw bytes stay bytes until the test runs: decoding at
# collection fails on Windows, and macOS rejects them.
pytest.param(b"bad-\xff.txt", marks=pytest.mark.platforms("linux"))])
def test_unusual_filename_does_not_break_tree_inspection(checkpoint, name):
mgr, work, _ = checkpoint
name = os.fsdecode(name)
target = work / name
target.write_text("original\n")
mgr.new_turn()
assert mgr.ensure_checkpoint(str(work), "unusual filename")
commit = mgr.list_checkpoints(str(work))[0]["hash"]
target.write_text("changed\n")
result = mgr.restore(str(work), commit, file_path=name)
assert result["success"] is True, result
assert target.read_text() == "original\n"
@pytest.mark.parametrize("safe", [False, True])
@pytest.mark.parametrize("spec", [None, "*", "tool/main.py"])
def test_deleted_nested_repository_is_not_reported_restored(checkpoint, safe, spec):
mgr, work, commit = checkpoint
# Git for Windows makes loose objects read-only; plain rmtree fails there.
rmtree_readonly(work / "tool")
assert not (work / "tool").exists()
before = durable_state(work)
result = mgr.restore(str(work), commit, file_path=spec, safe=safe)
assert result["success"] is False
assert result["nested_repositories"] == ["tool"]
assert not (work / "tool").exists()
assert durable_state(work) == before
@pytest.mark.parametrize("notes", ["before\n", "after\n"])
def test_empty_selection_refuses_without_mutation(checkpoint, tmp_path, notes):
# Checkout with an empty pathspec file switches HEAD instead of restoring
# nothing. "after" is the dirty-file control, which Git itself protects.
mgr, work, commit = checkpoint
(work / "notes.txt").write_text(notes)
(work / "tool").rename(tmp_path / "moved-tool")
before = durable_state(work)
result = mgr.restore(str(work), commit, file_path=":(exclude)*")
assert result["success"] is False, result
assert "matched no files" in result["error"]
assert not (work / "tool").exists()
assert durable_state(work) == before
@pytest.mark.parametrize("name", ["nested space", "nested-雪", "vendor/tool",
pytest.param("nested\tline\n", marks=pytest.mark.platforms("posix")),
pytest.param(b"nested-\xff", marks=pytest.mark.platforms("linux"))])
def test_gitlink_name_roundtrips_in_refusal(checkpoint, name):
mgr, work, _ = checkpoint
name = os.fsdecode(name)
(work / name).parent.mkdir(parents=True, exist_ok=True)
(work / "tool").rename(work / name)
mgr.new_turn()
assert mgr.ensure_checkpoint(str(work), "renamed nested repository")
commit = mgr.list_checkpoints(str(work))[0]["hash"]
before = durable_state(work)
result = mgr.restore(str(work), commit, file_path="*")
assert result["success"] is False
assert result["nested_repositories"] == [name]
assert durable_state(work) == before
def test_checkpoint_listing_discloses_uncaptured_nested_repository(checkpoint, tmp_path):
mgr, work, _ = checkpoint
entry = mgr.list_checkpoints(str(work))[0]
assert entry["reason"] == "baseline [nested git repos not captured: tool]"
assert "[nested git repos not captured: tool]" in cm.format_checkpoint_list([entry], str(work))
plain = tmp_path / "plain"
plain.mkdir()
(plain / "a.txt").write_text("a\n")
assert mgr.ensure_checkpoint(str(plain), "no nested repo")
assert mgr.list_checkpoints(str(plain))[0]["reason"] == "no nested repo"
def test_multiline_reason_keeps_disclosure_in_listing(checkpoint):
mgr, work, _ = checkpoint
(work / "notes.txt").write_text("changed again\n")
mgr.new_turn()
assert mgr.ensure_checkpoint(str(work), "before terminal: rm -rf build\n\nprintf done")
entry = mgr.list_checkpoints(str(work))[0]
assert entry["reason"] == (
"before terminal: rm -rf build printf done [nested git repos not captured: tool]")
assert "[nested git repos not captured: tool]" in cm.format_checkpoint_list([entry], str(work))
def test_checkpoint_disclosure_caps_listed_repositories(checkpoint):
assert cm._uncaptured_note(["a", "b", "c", "d", "e"]) == " [nested git repos not captured: a, b, c (+2)]"
@pytest.fixture
def deep_checkpoint(checkpoint):
mgr, work, _ = checkpoint
(work / "packages").mkdir()
(work / "tool").rename(work / "packages" / "nested")
(work / "notes.txt").write_text("before\n")
mgr.new_turn()
assert mgr.ensure_checkpoint(str(work), "deeper nested repository")
commit = mgr.list_checkpoints(str(work))[0]["hash"]
(work / "notes.txt").write_text("after\n")
return mgr, work, commit
@pytest.mark.parametrize("spec", [
"packages", "packages/", "packages/nested", "packages/nested/", "packages/nested/main.py",
"packages/./nested", "notes/../packages/nested",
pytest.param("packages\\nested", marks=pytest.mark.platforms("windows")),
pytest.param("packages\\nested\\main.py", marks=pytest.mark.platforms("windows")),
])
def test_deeper_gitlink_scope_refuses_in_any_spelling(deep_checkpoint, spec):
mgr, work, commit = deep_checkpoint
before = durable_state(work)
result = mgr.restore(str(work), commit, file_path=spec)
assert result["success"] is False
assert result["nested_repositories"] == ["packages/nested"]
assert durable_state(work) == before
@pytest.mark.parametrize("spec", ["notes.txt", "packages/../notes.txt"])
def test_deeper_gitlink_leaves_unrelated_recovery_available(deep_checkpoint, spec):
mgr, work, commit = deep_checkpoint
result = mgr.restore(str(work), commit, file_path=spec)
assert result["success"] is True, result
assert (work / "notes.txt").read_text() == "before\n"
assert (work / "packages" / "nested" / "main.py").read_text() == "agent overwrite\n"
@pytest.mark.parametrize("spec", [":(literal)tool/main.py", ":(glob)tool/*.py", ":(invalid)tool"])
def test_unresolvable_selection_is_nonmutating(checkpoint, spec):
mgr, work, commit = checkpoint
before = durable_state(work)
result = mgr.restore(str(work), commit, file_path=spec)
assert result["success"] is False
assert durable_state(work) == before
def test_attribute_pathspec_restores_only_the_inspected_selection(checkpoint, tmp_path):
# The pre-rollback snapshot restages the index that attribute pathspecs
# read .gitattributes from, so checkout must not re-evaluate the spec.
mgr, work, _ = checkpoint
attrs = work / ".gitattributes"
attrs.write_text("tool restore\nnotes.txt restore\n")
(work / "notes.txt").write_text("before\n")
mgr.new_turn()
assert mgr.ensure_checkpoint(str(work), "attributes")
commit = mgr.list_checkpoints(str(work))[0]["hash"]
(work / "tool").rename(tmp_path / "moved-tool")
attrs.unlink()
(work / "notes.txt").write_text("after\n")
result = mgr.restore(str(work), commit, file_path=":(attr:!restore)*")
assert result["success"] is True, result
assert attrs.read_text() == "tool restore\nnotes.txt restore\n"
assert (work / "notes.txt").read_text() == "after\n"
assert not (work / "tool").exists()
@pytest.mark.parametrize("command", ["read-tree", "ls-files"])
def test_inspection_failure_leaves_files_and_history_untouched(checkpoint, monkeypatch, command):
mgr, work, commit = checkpoint
run_git = cm._run_git
def fail_inspection(args, *a, **kw):
if args[0] == command:
return False, "", "inspection unavailable"
return run_git(args, *a, **kw)
monkeypatch.setattr(cm, "_run_git", fail_inspection)
before = durable_state(work)
result = mgr.restore(str(work), commit, file_path="*")
assert result["success"] is False
assert "inspection unavailable" in result["error"]
assert durable_state(work) == before
assert not list(cm._store_path().glob("restore-inspect-*"))
@pytest.mark.platforms("posix") # symlink creation needs privileges on Windows
@pytest.mark.parametrize("safe", [False, True])
def test_captured_file_replaced_by_symlink_into_nested_repo_restores(checkpoint, safe):
mgr, work, commit = checkpoint
notes = work / "notes.txt"
notes.unlink()
notes.symlink_to(work / "tool" / "main.py")
result = mgr.restore(str(work), commit, file_path="notes.txt", safe=safe)
assert result["success"] is True, result
assert not notes.is_symlink()
assert notes.read_text() == "before\n"
assert (work / "tool" / "main.py").read_text() == "agent overwrite\n"
@pytest.mark.platforms("posix") # symlink creation needs privileges on Windows
@pytest.mark.parametrize("spec", [None, "tool", "tool/main.py"])
def test_gitlink_replaced_by_unrelated_symlink_still_refuses(checkpoint, tmp_path, spec):
mgr, work, commit = checkpoint
(work / "tool").rename(tmp_path / "moved-tool")
other = work / "other"
other.mkdir()
(other / "main.py").write_text("unrelated\n")
(work / "tool").symlink_to(other, target_is_directory=True)
before = durable_state(work)
result = mgr.restore(str(work), commit, file_path=spec)
assert result["success"] is False
assert result["nested_repositories"] == ["tool"]
assert (work / "tool").is_symlink()
assert durable_state(work) == before