rein-aharness/tests/test_repository_transaction.py
tegwick eaae4357eb Build repository transaction core
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a02b6f-7db1-7222-918b-e813a6bda38d
2026-08-23 12:31:13 +02:00

225 lines
7.6 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,
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 _status(repo: Path) -> str:
return subprocess.run(
["git", "status", "--porcelain=v2", "--untracked-files=all"],
cwd=repo,
check=True,
capture_output=True,
text=True,
).stdout