Files
hermes-agent/tests/tools/test_checkpoint_gitlinks.py
unsupportedpastels b846c17fe0 test(checkpoints): cover a gitlink below an ordinary directory
A one-component gitlink cannot catch separator or component-splitting
mistakes in the scoped restore guard. Pin refusal for the exact gitlink,
its ancestors and descendants in every spelling (including Windows
separators on Windows), and unrelated recovery beside it.

Co-authored-by: FroRaut <79215537+Froraut@users.noreply.github.com>
2026-09-29 14:12:41 -04:00

212 lines
9.4 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
@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.skipif(os.name == "nt", reason="POSIX control characters")),
pytest.param(os.fsdecode(b"bad-\xff.txt"),
marks=pytest.mark.skipif(os.name != "posix", reason="POSIX filename bytes"))])
def test_unusual_filename_does_not_break_tree_inspection(checkpoint, name):
mgr, work, _ = checkpoint
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
shutil.rmtree(work / "tool")
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("name", ["nested space", "nested-雪", "vendor/tool",
pytest.param("nested\tline\n", marks=pytest.mark.skipif(os.name == "nt", reason="POSIX control characters")),
pytest.param(os.fsdecode(b"nested-\xff"),
marks=pytest.mark.skipif(os.name != "posix", reason="POSIX filename bytes"))])
def test_gitlink_name_roundtrips_in_refusal(checkpoint, name):
mgr, work, _ = checkpoint
(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
@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.skipif(os.name != "nt", reason="Windows separator")),
pytest.param("packages\\nested\\main.py", marks=pytest.mark.skipif(os.name != "nt", reason="Windows separator")),
])
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
@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.skipif(os.name == "nt", reason="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.skipif(os.name == "nt", reason="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