- 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).
280 lines
12 KiB
Python
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
|