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