Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02b6f-7db1-7222-918b-e813a6bda38d
471 lines
16 KiB
Python
471 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from rein_aharness.repository_transaction import (
|
|
DirtyRepositoryError,
|
|
GitRepositoryError,
|
|
RepositoryAcceptanceError,
|
|
RepositoryAcceptancePolicy,
|
|
RepositoryBusyError,
|
|
RepositoryTransaction,
|
|
)
|
|
|
|
|
|
def _make_repo(path: Path, name: str = "repo") -> Path:
|
|
repo = path / name
|
|
repo.mkdir()
|
|
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
|
(repo / "README.md").write_text("baseline\n", encoding="utf-8")
|
|
subprocess.run(["git", "add", "."], cwd=repo, check=True)
|
|
subprocess.run(
|
|
[
|
|
"git",
|
|
"-c",
|
|
"user.email=test@example.invalid",
|
|
"-c",
|
|
"user.name=test",
|
|
"commit",
|
|
"-qm",
|
|
"baseline",
|
|
],
|
|
cwd=repo,
|
|
check=True,
|
|
)
|
|
return repo
|
|
|
|
|
|
def test_transaction_captures_clean_branch_and_remote_refs(tmp_path: Path) -> None:
|
|
repo = _make_repo(tmp_path)
|
|
subprocess.run(
|
|
["git", "update-ref", "refs/remotes/origin/main", "HEAD"],
|
|
cwd=repo,
|
|
check=True,
|
|
)
|
|
state_dir = tmp_path / "state"
|
|
|
|
with RepositoryTransaction(repo, correlation_id="run-1", state_dir=state_dir) as tx:
|
|
assert tx.locked
|
|
assert tx.baseline is not None
|
|
evidence = tx.baseline.evidence()
|
|
assert evidence["clean"] is True
|
|
assert evidence["head"]
|
|
assert evidence["branch"] in {"main", "master"}
|
|
assert evidence["detached"] is False
|
|
assert evidence["remote_ref_count"] == 1
|
|
assert len(evidence["repo_id"]) == 32
|
|
assert tx.evidence() == {
|
|
"transaction_id": tx.transaction_id,
|
|
"correlation_id": "run-1",
|
|
"baseline": evidence,
|
|
}
|
|
assert tx.lock_path is not None
|
|
assert not tx.lock_path.is_relative_to(repo)
|
|
assert tx.lock_path.stat().st_mode & 0o777 == 0o600
|
|
metadata = json.loads(tx.lock_path.read_text(encoding="utf-8"))
|
|
assert metadata["transaction_id"] == tx.transaction_id
|
|
assert metadata["correlation_id"] == "run-1"
|
|
|
|
assert tx.locked is False
|
|
|
|
|
|
def test_dirty_baseline_is_refused_without_changing_user_files(tmp_path: Path) -> None:
|
|
repo = _make_repo(tmp_path)
|
|
changed = repo / "README.md"
|
|
changed.write_text("operator change\n", encoding="utf-8")
|
|
before = _status(repo)
|
|
|
|
with pytest.raises(DirtyRepositoryError) as excinfo:
|
|
with RepositoryTransaction(repo, state_dir=tmp_path / "state"):
|
|
pytest.fail("dirty transaction must not enter")
|
|
|
|
assert excinfo.value.baseline.clean is False
|
|
assert excinfo.value.baseline.dirty_entries == 1
|
|
assert changed.read_text(encoding="utf-8") == "operator change\n"
|
|
assert _status(repo) == before
|
|
|
|
# Refusal released the lock; inspection can explicitly opt into dirty state.
|
|
with RepositoryTransaction(
|
|
repo,
|
|
state_dir=tmp_path / "state",
|
|
require_clean=False,
|
|
) as tx:
|
|
assert tx.baseline is not None
|
|
assert tx.baseline.clean is False
|
|
|
|
|
|
def test_detached_head_is_recorded_without_refusal(tmp_path: Path) -> None:
|
|
repo = _make_repo(tmp_path)
|
|
subprocess.run(["git", "checkout", "--detach", "-q"], cwd=repo, check=True)
|
|
|
|
with RepositoryTransaction(repo, state_dir=tmp_path / "state") as tx:
|
|
assert tx.baseline is not None
|
|
assert tx.baseline.detached is True
|
|
assert tx.baseline.branch is None
|
|
|
|
|
|
def test_staged_and_untracked_state_are_captured_as_dirty(tmp_path: Path) -> None:
|
|
repo = _make_repo(tmp_path)
|
|
(repo / "README.md").write_text("staged change\n", encoding="utf-8")
|
|
(repo / "UNTRACKED.txt").write_text("operator file\n", encoding="utf-8")
|
|
subprocess.run(["git", "add", "README.md"], cwd=repo, check=True)
|
|
|
|
with RepositoryTransaction(
|
|
repo,
|
|
state_dir=tmp_path / "state",
|
|
require_clean=False,
|
|
) as tx:
|
|
assert tx.baseline is not None
|
|
assert tx.baseline.clean is False
|
|
assert tx.baseline.dirty_entries == 2
|
|
assert tx.baseline.index_diff_digest != hashlib.sha256(b"").hexdigest()
|
|
assert tx.baseline.status_digest
|
|
|
|
|
|
def test_resolved_path_alias_contends_on_the_same_lock(tmp_path: Path) -> None:
|
|
repo = _make_repo(tmp_path)
|
|
alias = tmp_path / "repo-alias"
|
|
alias.symlink_to(repo, target_is_directory=True)
|
|
state_dir = tmp_path / "state"
|
|
|
|
with RepositoryTransaction(repo, state_dir=state_dir):
|
|
with pytest.raises(RepositoryBusyError):
|
|
with RepositoryTransaction(alias, state_dir=state_dir):
|
|
pytest.fail("path alias must use the canonical repository lock")
|
|
|
|
|
|
def test_same_repo_is_locked_across_processes(tmp_path: Path) -> None:
|
|
repo = _make_repo(tmp_path)
|
|
state_dir = tmp_path / "state"
|
|
child = """
|
|
import sys
|
|
from pathlib import Path
|
|
from rein_aharness.repository_transaction import RepositoryBusyError, RepositoryTransaction
|
|
try:
|
|
with RepositoryTransaction(Path(sys.argv[1]), state_dir=Path(sys.argv[2])):
|
|
pass
|
|
except RepositoryBusyError:
|
|
raise SystemExit(23)
|
|
raise SystemExit(0)
|
|
"""
|
|
|
|
with RepositoryTransaction(repo, state_dir=state_dir):
|
|
result = subprocess.run(
|
|
[sys.executable, "-c", child, str(repo), str(state_dir)],
|
|
cwd=Path(__file__).resolve().parents[1],
|
|
env=os.environ.copy(),
|
|
check=False,
|
|
)
|
|
assert result.returncode == 23
|
|
|
|
|
|
def test_distinct_repositories_use_distinct_locks(tmp_path: Path) -> None:
|
|
first = _make_repo(tmp_path, "first")
|
|
second = _make_repo(tmp_path, "second")
|
|
state_dir = tmp_path / "state"
|
|
|
|
with RepositoryTransaction(first, state_dir=state_dir) as first_tx:
|
|
with RepositoryTransaction(second, state_dir=state_dir) as second_tx:
|
|
assert first_tx.baseline is not None
|
|
assert second_tx.baseline is not None
|
|
assert first_tx.baseline.repo_id != second_tx.baseline.repo_id
|
|
assert first_tx.lock_path != second_tx.lock_path
|
|
|
|
|
|
def test_exception_releases_lock_for_next_transaction(tmp_path: Path) -> None:
|
|
repo = _make_repo(tmp_path)
|
|
state_dir = tmp_path / "state"
|
|
|
|
with pytest.raises(RuntimeError, match="adapter failed"):
|
|
with RepositoryTransaction(repo, state_dir=state_dir):
|
|
raise RuntimeError("adapter failed")
|
|
|
|
with RepositoryTransaction(repo, state_dir=state_dir) as tx:
|
|
assert tx.locked
|
|
|
|
|
|
def test_non_repository_is_refused_before_lock_creation(tmp_path: Path) -> None:
|
|
state_dir = tmp_path / "state"
|
|
target = tmp_path / "not-a-repo"
|
|
target.mkdir()
|
|
|
|
with pytest.raises(GitRepositoryError, match="not a Git working tree"):
|
|
with RepositoryTransaction(target, state_dir=state_dir):
|
|
pytest.fail("non-repository transaction must not enter")
|
|
|
|
assert not state_dir.exists()
|
|
|
|
|
|
def test_busy_error_exposes_bounded_owner_metadata(tmp_path: Path) -> None:
|
|
repo = _make_repo(tmp_path)
|
|
state_dir = tmp_path / "state"
|
|
long_ref = "run " + ("x" * 500)
|
|
|
|
with RepositoryTransaction(repo, correlation_id=long_ref, state_dir=state_dir) as owner:
|
|
with pytest.raises(RepositoryBusyError) as excinfo:
|
|
with RepositoryTransaction(repo, state_dir=state_dir):
|
|
pytest.fail("second transaction must not enter")
|
|
|
|
assert excinfo.value.owner["transaction_id"] == owner.transaction_id
|
|
assert len(excinfo.value.owner["correlation_id"]) == 200
|
|
|
|
|
|
def test_acceptance_proves_one_descendant_commit_on_granted_paths(tmp_path: Path) -> None:
|
|
repo = _make_repo(tmp_path)
|
|
policy = RepositoryAcceptancePolicy(allowed_paths=("docs/",))
|
|
|
|
with RepositoryTransaction(repo, state_dir=tmp_path / "state") as tx:
|
|
_commit(repo, "docs/result.md", "accepted\n")
|
|
accepted = tx.validate_acceptance(policy)
|
|
|
|
assert len(accepted.commits) == 1
|
|
assert accepted.changed_paths == ("docs/result.md",)
|
|
evidence = tx.evidence()["acceptance"]
|
|
assert evidence["accepted"] is True
|
|
assert evidence["policy_id"] == policy.policy_id
|
|
assert evidence["changed_paths"] == ["docs/result.md"]
|
|
assert evidence["clean_post_state"] is True
|
|
assert evidence["remote_refs_unchanged"] is True
|
|
|
|
|
|
def test_acceptance_rejects_ungranted_extra_path(tmp_path: Path) -> None:
|
|
repo = _make_repo(tmp_path)
|
|
|
|
with RepositoryTransaction(repo, state_dir=tmp_path / "state") as tx:
|
|
_commit(repo, "UNRELATED.md", "not granted\n")
|
|
with pytest.raises(RepositoryAcceptanceError) as excinfo:
|
|
tx.validate_acceptance(
|
|
RepositoryAcceptancePolicy(allowed_paths=("docs/",))
|
|
)
|
|
|
|
assert excinfo.value.code == "path-not-granted"
|
|
assert "UNRELATED.md" in excinfo.value.detail
|
|
|
|
|
|
def test_acceptance_rejects_more_commits_than_policy_allows(tmp_path: Path) -> None:
|
|
repo = _make_repo(tmp_path)
|
|
|
|
with RepositoryTransaction(repo, state_dir=tmp_path / "state") as tx:
|
|
_commit(repo, "docs/one.md", "one\n")
|
|
_commit(repo, "docs/two.md", "two\n")
|
|
with pytest.raises(RepositoryAcceptanceError) as excinfo:
|
|
tx.validate_acceptance(
|
|
RepositoryAcceptancePolicy(allowed_paths=("docs/",))
|
|
)
|
|
|
|
assert excinfo.value.code == "commit-count"
|
|
assert "actual=2" in excinfo.value.detail
|
|
|
|
|
|
def test_acceptance_rejects_dirty_post_state(tmp_path: Path) -> None:
|
|
repo = _make_repo(tmp_path)
|
|
|
|
with RepositoryTransaction(repo, state_dir=tmp_path / "state") as tx:
|
|
_commit(repo, "docs/result.md", "committed\n")
|
|
(repo / "docs" / "result.md").write_text("left dirty\n", encoding="utf-8")
|
|
with pytest.raises(RepositoryAcceptanceError) as excinfo:
|
|
tx.validate_acceptance(
|
|
RepositoryAcceptancePolicy(allowed_paths=("docs/",))
|
|
)
|
|
|
|
assert excinfo.value.code == "dirty-post-state"
|
|
|
|
|
|
def test_acceptance_rejects_remote_tracking_ref_movement(tmp_path: Path) -> None:
|
|
repo = _make_repo(tmp_path)
|
|
subprocess.run(
|
|
["git", "update-ref", "refs/remotes/origin/main", "HEAD"],
|
|
cwd=repo,
|
|
check=True,
|
|
)
|
|
|
|
with RepositoryTransaction(repo, state_dir=tmp_path / "state") as tx:
|
|
_commit(repo, "docs/result.md", "committed\n")
|
|
subprocess.run(
|
|
["git", "update-ref", "refs/remotes/origin/main", "HEAD"],
|
|
cwd=repo,
|
|
check=True,
|
|
)
|
|
with pytest.raises(RepositoryAcceptanceError) as excinfo:
|
|
tx.validate_acceptance(
|
|
RepositoryAcceptancePolicy(allowed_paths=("docs/",))
|
|
)
|
|
|
|
assert excinfo.value.code == "remote-refs-changed"
|
|
|
|
|
|
@pytest.mark.parametrize("metadata_kind", ["config", "hook"])
|
|
def test_acceptance_rejects_protected_git_metadata_changes(
|
|
tmp_path: Path,
|
|
metadata_kind: str,
|
|
) -> None:
|
|
repo = _make_repo(tmp_path)
|
|
|
|
with RepositoryTransaction(repo, state_dir=tmp_path / "state") as tx:
|
|
_commit(repo, "docs/result.md", "committed\n")
|
|
if metadata_kind == "config":
|
|
subprocess.run(
|
|
["git", "config", "rein-aharness.test", "changed"],
|
|
cwd=repo,
|
|
check=True,
|
|
)
|
|
else:
|
|
hook = repo / ".git" / "hooks" / "pre-commit"
|
|
hook.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
|
with pytest.raises(RepositoryAcceptanceError) as excinfo:
|
|
tx.validate_acceptance(
|
|
RepositoryAcceptancePolicy(allowed_paths=("docs/",))
|
|
)
|
|
|
|
assert excinfo.value.code == "git-metadata-changed"
|
|
|
|
|
|
def test_acceptance_rejects_non_descendant_head(tmp_path: Path) -> None:
|
|
repo = _make_repo(tmp_path)
|
|
branch = subprocess.run(
|
|
["git", "branch", "--show-current"],
|
|
cwd=repo,
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
).stdout.strip()
|
|
|
|
with RepositoryTransaction(repo, state_dir=tmp_path / "state") as tx:
|
|
subprocess.run(["git", "checkout", "--orphan", "unrelated"], cwd=repo, check=True)
|
|
subprocess.run(["git", "rm", "-qf", "README.md"], cwd=repo, check=True)
|
|
(repo / "docs").mkdir()
|
|
(repo / "docs" / "result.md").write_text("unrelated\n", encoding="utf-8")
|
|
subprocess.run(["git", "add", "."], cwd=repo, check=True)
|
|
subprocess.run(
|
|
[
|
|
"git",
|
|
"-c",
|
|
"user.email=test@example.invalid",
|
|
"-c",
|
|
"user.name=test",
|
|
"commit",
|
|
"-qm",
|
|
"unrelated",
|
|
],
|
|
cwd=repo,
|
|
check=True,
|
|
)
|
|
subprocess.run(["git", "checkout", "-qB", branch], cwd=repo, check=True)
|
|
with pytest.raises(RepositoryAcceptanceError) as excinfo:
|
|
tx.validate_acceptance(
|
|
RepositoryAcceptancePolicy(allowed_paths=("docs/",))
|
|
)
|
|
|
|
assert excinfo.value.code == "non-descendant-head"
|
|
|
|
|
|
def test_acceptance_rejects_branch_change_and_unchanged_head(tmp_path: Path) -> None:
|
|
repo = _make_repo(tmp_path)
|
|
policy = RepositoryAcceptancePolicy(allowed_paths=("docs/",))
|
|
|
|
with RepositoryTransaction(repo, state_dir=tmp_path / "state") as tx:
|
|
with pytest.raises(RepositoryAcceptanceError) as excinfo:
|
|
tx.validate_acceptance(policy)
|
|
assert excinfo.value.code == "head-unchanged"
|
|
|
|
with RepositoryTransaction(repo, state_dir=tmp_path / "state") as tx:
|
|
subprocess.run(["git", "checkout", "-qb", "other"], cwd=repo, check=True)
|
|
_commit(repo, "docs/result.md", "committed\n")
|
|
with pytest.raises(RepositoryAcceptanceError) as excinfo:
|
|
tx.validate_acceptance(policy)
|
|
assert excinfo.value.code == "branch-changed"
|
|
|
|
|
|
def test_acceptance_evidence_bounds_changed_path_list(tmp_path: Path) -> None:
|
|
repo = _make_repo(tmp_path)
|
|
policy = RepositoryAcceptancePolicy(
|
|
allowed_paths=("docs/",),
|
|
max_evidence_paths=2,
|
|
)
|
|
|
|
with RepositoryTransaction(repo, state_dir=tmp_path / "state") as tx:
|
|
_commit(repo, "docs/a.md", "a\n", extra={"docs/b.md": "b\n", "docs/c.md": "c\n"})
|
|
evidence = tx.validate_acceptance(policy).evidence()
|
|
|
|
assert evidence["changed_path_count"] == 3
|
|
assert len(evidence["changed_paths"]) == 2
|
|
assert evidence["changed_paths_truncated"] is True
|
|
assert len(evidence["changed_paths_digest"]) == 64
|
|
|
|
|
|
def test_acceptance_path_glob_does_not_cross_directory_boundaries(tmp_path: Path) -> None:
|
|
repo = _make_repo(tmp_path)
|
|
|
|
with RepositoryTransaction(repo, state_dir=tmp_path / "state") as tx:
|
|
_commit(repo, "docs/nested/result.md", "nested\n")
|
|
with pytest.raises(RepositoryAcceptanceError) as excinfo:
|
|
tx.validate_acceptance(
|
|
RepositoryAcceptancePolicy(allowed_paths=("docs/*.md",))
|
|
)
|
|
assert excinfo.value.code == "path-not-granted"
|
|
|
|
|
|
def test_acceptance_double_star_path_glob_allows_nested_paths(tmp_path: Path) -> None:
|
|
repo = _make_repo(tmp_path)
|
|
|
|
with RepositoryTransaction(repo, state_dir=tmp_path / "state") as tx:
|
|
_commit(repo, "docs/nested/result.md", "nested\n")
|
|
accepted = tx.validate_acceptance(
|
|
RepositoryAcceptancePolicy(allowed_paths=("docs/**/*.md",))
|
|
)
|
|
assert accepted.changed_paths == ("docs/nested/result.md",)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"pattern",
|
|
["/absolute", "../escape", ".git/config", "docs\\windows"],
|
|
)
|
|
def test_acceptance_policy_rejects_unsafe_path_patterns(pattern: str) -> None:
|
|
with pytest.raises(ValueError, match="path pattern"):
|
|
RepositoryAcceptancePolicy(allowed_paths=(pattern,))
|
|
|
|
|
|
def _commit(
|
|
repo: Path,
|
|
relative_path: str,
|
|
content: str,
|
|
*,
|
|
extra: dict[str, str] | None = None,
|
|
) -> None:
|
|
files = {relative_path: content, **(extra or {})}
|
|
for name, value in files.items():
|
|
path = repo / name
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(value, encoding="utf-8")
|
|
subprocess.run(["git", "add", "."], cwd=repo, check=True)
|
|
subprocess.run(
|
|
[
|
|
"git",
|
|
"-c",
|
|
"user.email=test@example.invalid",
|
|
"-c",
|
|
"user.name=test",
|
|
"commit",
|
|
"-qm",
|
|
"task result",
|
|
],
|
|
cwd=repo,
|
|
check=True,
|
|
)
|
|
|
|
|
|
def _status(repo: Path) -> str:
|
|
return subprocess.run(
|
|
["git", "status", "--porcelain=v2", "--untracked-files=all"],
|
|
cwd=repo,
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
).stdout
|