rein-aharness/rein_aharness/repository_transaction.py
tegwick 2e16504e1f Validate repository transaction results
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a02b6f-7db1-7222-918b-e813a6bda38d
2026-08-23 12:58:07 +02:00

659 lines
23 KiB
Python

"""Process-safe transaction boundary for a local Git checkout.
This module is intentionally not wired into the claim loop yet. It provides
the repository-local half of HARNESS-WP-0003-T02 while the Activity Core lease
boundary is still under owner review.
"""
from __future__ import annotations
import fcntl
import fnmatch
import hashlib
import json
import os
import stat
import subprocess
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from types import TracebackType
from typing import Any, TextIO
class RepositoryTransactionError(RuntimeError):
"""Base error for repository transaction refusal or setup failure."""
class GitRepositoryError(RepositoryTransactionError):
"""The target cannot provide the required Git repository state."""
class RepositoryBusyError(RepositoryTransactionError):
"""Another process owns the canonical repository lock."""
def __init__(self, repo_id: str, owner: dict[str, Any] | None = None) -> None:
self.repo_id = repo_id
self.owner = owner or {}
owner_tx = self.owner.get("transaction_id")
suffix = f" owner_transaction={owner_tx}" if owner_tx else ""
super().__init__(f"repository is already locked: repo_id={repo_id}{suffix}")
class DirtyRepositoryError(RepositoryTransactionError):
"""An unattended transaction was refused because its baseline is dirty."""
def __init__(self, baseline: "RepositoryBaseline") -> None:
self.baseline = baseline
super().__init__(
"repository baseline is dirty: "
f"repo_id={baseline.repo_id} entries={baseline.dirty_entries}"
)
class RepositoryAcceptanceError(RepositoryTransactionError):
"""The post-run repository state does not satisfy its explicit policy."""
def __init__(self, code: str, detail: str) -> None:
self.code = _bounded_text(code, 80)
self.detail = _bounded_text(detail, 300)
super().__init__(f"repository acceptance failed: {self.code}: {self.detail}")
@dataclass(frozen=True)
class RepositoryAcceptancePolicy:
"""Bounded local-commit policy supplied by a future governed run grant."""
allowed_paths: tuple[str, ...]
min_commits: int = 1
max_commits: int = 1
max_evidence_paths: int = 100
def __post_init__(self) -> None:
if not 1 <= self.min_commits <= self.max_commits <= 32:
raise ValueError("commit bounds must satisfy 1 <= min <= max <= 32")
if not 0 <= self.max_evidence_paths <= 200:
raise ValueError("max_evidence_paths must be between 0 and 200")
if len(self.allowed_paths) > 100:
raise ValueError("allowed_paths must contain at most 100 patterns")
for pattern in self.allowed_paths:
_validate_path_pattern(pattern)
@property
def policy_id(self) -> str:
payload = json.dumps(
{
"allowed_paths": self.allowed_paths,
"min_commits": self.min_commits,
"max_commits": self.max_commits,
"max_evidence_paths": self.max_evidence_paths,
},
sort_keys=True,
separators=(",", ":"),
)
return _digest(payload)[:32]
@dataclass(frozen=True)
class RepositoryBaseline:
"""Exact local facts captured while the canonical repository lock is held."""
repo_root: Path
git_common_dir: Path
repo_id: str
head: str
branch: str | None
clean: bool
dirty_entries: int
status_digest: str
index_diff_digest: str
worktree_diff_digest: str
upstream_ref: str | None
upstream_oid: str | None
remote_refs: tuple[tuple[str, str], ...]
protected_git_metadata_digest: str
@property
def detached(self) -> bool:
return self.branch is None
def evidence(self) -> dict[str, Any]:
"""Return bounded, path-safe baseline evidence for run results."""
remote_payload = "\n".join(f"{name} {oid}" for name, oid in self.remote_refs)
return {
"repo_id": self.repo_id,
"repo_name": self.repo_root.name,
"head": self.head,
"branch": self.branch,
"detached": self.detached,
"clean": self.clean,
"dirty_entries": self.dirty_entries,
"status_digest": self.status_digest,
"index_diff_digest": self.index_diff_digest,
"worktree_diff_digest": self.worktree_diff_digest,
"upstream_ref": self.upstream_ref,
"upstream_oid": self.upstream_oid,
"remote_ref_count": len(self.remote_refs),
"remote_refs_digest": _digest(remote_payload),
"protected_git_metadata_digest": self.protected_git_metadata_digest,
}
@dataclass(frozen=True)
class RepositoryAcceptance:
"""Validated, bounded evidence for one accepted local repository result."""
policy_id: str
head: str
branch: str | None
commits: tuple[str, ...]
changed_paths: tuple[str, ...]
max_evidence_paths: int
def evidence(self) -> dict[str, Any]:
visible_paths = self.changed_paths[: self.max_evidence_paths]
return {
"policy_id": self.policy_id,
"accepted": True,
"head": self.head,
"branch": self.branch,
"commit_count": len(self.commits),
"commits": list(self.commits),
"changed_path_count": len(self.changed_paths),
"changed_paths": list(visible_paths),
"changed_paths_truncated": len(visible_paths) < len(self.changed_paths),
"changed_paths_digest": _digest("\0".join(self.changed_paths)),
"clean_post_state": True,
"remote_refs_unchanged": True,
"protected_git_metadata_unchanged": True,
}
class RepositoryTransaction:
"""Hold one canonical Git repository lock and capture its baseline.
Lock files live outside the checkout and are keyed by the resolved Git
common directory. This makes aliases and linked worktrees for one local
repository contend on the same lock while unrelated repositories proceed
independently.
"""
def __init__(
self,
repo: Path,
*,
correlation_id: str = "",
state_dir: Path | None = None,
require_clean: bool = True,
) -> None:
self.repo = Path(repo).expanduser().resolve()
self.correlation_id = _bounded_text(correlation_id, 200)
self.state_dir = Path(state_dir).expanduser().resolve() if state_dir else _state_dir()
self.require_clean = require_clean
self.transaction_id = uuid.uuid4().hex
self.baseline: RepositoryBaseline | None = None
self.acceptance: RepositoryAcceptance | None = None
self.lock_path: Path | None = None
self._lock_file: TextIO | None = None
@property
def locked(self) -> bool:
return self._lock_file is not None
def __enter__(self) -> "RepositoryTransaction":
if self.locked:
raise RepositoryTransactionError("repository transaction is already entered")
repo_root, common_dir, repo_id = _canonical_repository(self.repo)
locks_dir = self.state_dir / "repository-locks"
locks_dir.mkdir(parents=True, exist_ok=True)
self.lock_path = locks_dir / f"{repo_id}.lock"
lock_file = _open_private_lock(self.lock_path)
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as exc:
owner = _read_lock_metadata(lock_file)
lock_file.close()
raise RepositoryBusyError(repo_id, owner) from exc
self._lock_file = lock_file
self._write_metadata(
{
"transaction_id": self.transaction_id,
"repo_id": repo_id,
"repo_name": repo_root.name,
"pid": os.getpid(),
"correlation_id": self.correlation_id,
"acquired_at": _now(),
"state": "capturing-baseline",
}
)
try:
baseline = _capture_baseline(repo_root, common_dir, repo_id)
self.baseline = baseline
self._write_metadata(
{
"transaction_id": self.transaction_id,
"repo_id": repo_id,
"repo_name": repo_root.name,
"pid": os.getpid(),
"correlation_id": self.correlation_id,
"acquired_at": _now(),
"state": "active",
"baseline": baseline.evidence(),
}
)
if self.require_clean and not baseline.clean:
raise DirtyRepositoryError(baseline)
return self
except BaseException:
self.release()
raise
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
traceback: TracebackType | None,
) -> None:
self.release()
def release(self) -> None:
"""Release the OS lock without deleting checkout or lock-state data."""
lock_file = self._lock_file
if lock_file is None:
return
try:
self._write_metadata(
{
"transaction_id": self.transaction_id,
"repo_id": self.baseline.repo_id if self.baseline else None,
"repo_name": self.baseline.repo_root.name if self.baseline else self.repo.name,
"pid": os.getpid(),
"correlation_id": self.correlation_id,
"released_at": _now(),
"state": "released",
}
)
finally:
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
finally:
lock_file.close()
self._lock_file = None
def evidence(self) -> dict[str, Any]:
"""Return the bounded transaction and baseline result envelope."""
if self.baseline is None:
raise RepositoryTransactionError("repository baseline is not captured")
evidence = {
"transaction_id": self.transaction_id,
"correlation_id": self.correlation_id,
"baseline": self.baseline.evidence(),
}
if self.acceptance is not None:
evidence["acceptance"] = self.acceptance.evidence()
return evidence
def validate_acceptance(
self,
policy: RepositoryAcceptancePolicy,
) -> RepositoryAcceptance:
"""Validate the current checkout without changing it or releasing its lock."""
if not self.locked or self.baseline is None:
raise RepositoryTransactionError(
"repository acceptance requires an active transaction"
)
baseline = self.baseline
post = _capture_baseline(
baseline.repo_root,
baseline.git_common_dir,
baseline.repo_id,
)
if post.branch != baseline.branch:
raise RepositoryAcceptanceError(
"branch-changed",
f"expected={baseline.branch or 'detached'} actual={post.branch or 'detached'}",
)
if post.head == baseline.head:
raise RepositoryAcceptanceError("head-unchanged", "no new commit to accept")
ancestry = _run_git(
baseline.repo_root,
"merge-base",
"--is-ancestor",
baseline.head,
post.head,
)
if ancestry.returncode == 1:
raise RepositoryAcceptanceError(
"non-descendant-head",
"post-run HEAD does not descend from the transaction baseline",
)
if ancestry.returncode != 0:
reason = (ancestry.stderr or ancestry.stdout or "Git ancestry error").strip()
raise GitRepositoryError(f"cannot validate commit ancestry: {reason[:300]}")
commit_lines = _git(
baseline.repo_root,
"rev-list",
"--reverse",
f"{baseline.head}..{post.head}",
).splitlines()
commits = tuple(line for line in commit_lines if line)
if not policy.min_commits <= len(commits) <= policy.max_commits:
raise RepositoryAcceptanceError(
"commit-count",
f"expected={policy.min_commits}..{policy.max_commits} actual={len(commits)}",
)
paths_result = _run_git(
baseline.repo_root,
"diff",
"--name-only",
"-z",
baseline.head,
post.head,
"--",
)
if paths_result.returncode != 0:
reason = (paths_result.stderr or paths_result.stdout or "Git diff error").strip()
raise GitRepositoryError(f"cannot determine changed paths: {reason[:300]}")
changed_paths = tuple(path for path in paths_result.stdout.split("\0") if path)
if not changed_paths:
raise RepositoryAcceptanceError(
"no-changed-paths",
"accepted commits do not change a repository path",
)
invalid_paths = tuple(
path for path in changed_paths if not _path_allowed(path, policy.allowed_paths)
)
if invalid_paths:
visible = ",".join(invalid_paths[:5])
raise RepositoryAcceptanceError(
"path-not-granted",
f"count={len(invalid_paths)} paths={visible[:220]}",
)
if not post.clean:
raise RepositoryAcceptanceError(
"dirty-post-state",
f"entries={post.dirty_entries} status_digest={post.status_digest}",
)
if post.remote_refs != baseline.remote_refs:
raise RepositoryAcceptanceError(
"remote-refs-changed",
"local remote-tracking refs moved during the transaction",
)
if (
post.protected_git_metadata_digest
!= baseline.protected_git_metadata_digest
):
raise RepositoryAcceptanceError(
"git-metadata-changed",
"protected Git config, hooks, or info metadata changed",
)
acceptance = RepositoryAcceptance(
policy_id=policy.policy_id,
head=post.head,
branch=post.branch,
commits=commits,
changed_paths=changed_paths,
max_evidence_paths=policy.max_evidence_paths,
)
self.acceptance = acceptance
return acceptance
def _write_metadata(self, value: dict[str, Any]) -> None:
if self._lock_file is None:
return
self._lock_file.seek(0)
self._lock_file.truncate()
json.dump(value, self._lock_file, sort_keys=True, separators=(",", ":"))
self._lock_file.write("\n")
self._lock_file.flush()
os.fsync(self._lock_file.fileno())
def _state_dir() -> Path:
explicit = os.environ.get("REIN_AHARNESS_STATE_DIR", "").strip()
if explicit:
return Path(explicit).expanduser().resolve()
xdg = os.environ.get("XDG_STATE_HOME", "").strip()
if xdg:
return (Path(xdg).expanduser() / "rein-aharness").resolve()
return (Path.home() / ".local" / "state" / "rein-aharness").resolve()
def _canonical_repository(repo: Path) -> tuple[Path, Path, str]:
root_result = _run_git(repo, "rev-parse", "--show-toplevel")
if root_result.returncode != 0:
raise GitRepositoryError(f"not a Git working tree: {repo}")
repo_root = Path(root_result.stdout.strip()).resolve()
common_result = _run_git(repo_root, "rev-parse", "--git-common-dir")
if common_result.returncode != 0 or not common_result.stdout.strip():
raise GitRepositoryError(f"cannot resolve Git common directory: {repo_root}")
raw_common = Path(common_result.stdout.strip())
common_dir = (repo_root / raw_common).resolve() if not raw_common.is_absolute() else raw_common.resolve()
repo_id = hashlib.sha256(str(common_dir).encode("utf-8")).hexdigest()[:32]
return repo_root, common_dir, repo_id
def _capture_baseline(repo_root: Path, common_dir: Path, repo_id: str) -> RepositoryBaseline:
head = _git(repo_root, "rev-parse", "--verify", "HEAD")
branch_result = _run_git(repo_root, "symbolic-ref", "--quiet", "--short", "HEAD")
if branch_result.returncode not in (0, 1):
raise GitRepositoryError(
f"cannot resolve branch state: {branch_result.stderr.strip()[:200]}"
)
branch = branch_result.stdout.strip() or None
status = _git(repo_root, "status", "--porcelain=v2", "--untracked-files=all")
status_lines = tuple(line for line in status.splitlines() if line)
index_diff = _git(repo_root, "diff", "--cached", "--binary", "--no-ext-diff")
worktree_diff = _git(repo_root, "diff", "--binary", "--no-ext-diff")
remote_lines = _git(
repo_root,
"for-each-ref",
"--format=%(refname) %(objectname)",
"refs/remotes",
)
remote_refs: list[tuple[str, str]] = []
for line in remote_lines.splitlines():
name, separator, oid = line.partition(" ")
if separator and name and oid:
remote_refs.append((name, oid))
upstream_result = _run_git(
repo_root,
"rev-parse",
"--abbrev-ref",
"--symbolic-full-name",
"@{upstream}",
)
upstream_ref = upstream_result.stdout.strip() if upstream_result.returncode == 0 else None
upstream_oid = _git(repo_root, "rev-parse", upstream_ref) if upstream_ref else None
return RepositoryBaseline(
repo_root=repo_root,
git_common_dir=common_dir,
repo_id=repo_id,
head=head,
branch=branch,
clean=not status_lines,
dirty_entries=len(status_lines),
status_digest=_digest(status),
index_diff_digest=_digest(index_diff),
worktree_diff_digest=_digest(worktree_diff),
upstream_ref=upstream_ref,
upstream_oid=upstream_oid,
remote_refs=tuple(remote_refs),
protected_git_metadata_digest=_protected_git_metadata_digest(
repo_root,
common_dir,
),
)
def _protected_git_metadata_digest(repo_root: Path, common_dir: Path) -> str:
raw_git_dir = Path(_git(repo_root, "rev-parse", "--git-dir"))
git_dir = (
(repo_root / raw_git_dir).resolve()
if not raw_git_dir.is_absolute()
else raw_git_dir.resolve()
)
candidates = (
("common/config", common_dir / "config"),
("common/hooks", common_dir / "hooks"),
("common/info/attributes", common_dir / "info" / "attributes"),
("common/info/exclude", common_dir / "info" / "exclude"),
("worktree/config.worktree", git_dir / "config.worktree"),
)
digest = hashlib.sha256()
for label, path in candidates:
_hash_metadata_path(digest, label, path)
return digest.hexdigest()
def _hash_metadata_path(digest: Any, label: str, path: Path) -> None:
digest.update(label.encode("utf-8"))
digest.update(b"\0")
if not path.exists() and not path.is_symlink():
digest.update(b"missing\0")
return
paths = [path]
if path.is_dir():
paths.extend(sorted(path.rglob("*"), key=lambda item: item.as_posix()))
for item in paths:
relative = "." if item == path else item.relative_to(path).as_posix()
try:
item_stat = item.lstat()
mode = stat.S_IMODE(item_stat.st_mode)
digest.update(f"{relative}\0{mode:o}\0".encode("utf-8"))
if item.is_symlink():
digest.update(b"symlink\0")
digest.update(os.readlink(item).encode("utf-8", errors="surrogateescape"))
elif item.is_file():
digest.update(b"file\0")
with item.open("rb") as handle:
for chunk in iter(lambda: handle.read(65536), b""):
digest.update(chunk)
elif item.is_dir():
digest.update(b"dir\0")
else:
digest.update(b"other\0")
digest.update(b"\0")
except OSError as exc:
raise GitRepositoryError(
f"cannot hash protected Git metadata: {label}: {exc}"
) from exc
def _run_git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]:
try:
return subprocess.run(
["git", "-C", str(repo), *args],
capture_output=True,
text=True,
timeout=60,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise GitRepositoryError(f"git {' '.join(args)} failed: {exc}") from exc
def _git(repo: Path, *args: str) -> str:
result = _run_git(repo, *args)
if result.returncode != 0:
reason = (result.stderr or result.stdout or "unknown Git error").strip()[:300]
raise GitRepositoryError(f"git {' '.join(args)} failed: {reason}")
return result.stdout.strip()
def _read_lock_metadata(lock_file: TextIO) -> dict[str, Any] | None:
try:
lock_file.seek(0)
raw = lock_file.read(4096)
value = json.loads(raw) if raw.strip() else None
except (OSError, ValueError):
return None
return value if isinstance(value, dict) else None
def _open_private_lock(path: Path) -> TextIO:
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o600)
try:
os.fchmod(fd, 0o600)
return os.fdopen(fd, "r+", encoding="utf-8")
except BaseException:
os.close(fd)
raise
def _digest(value: str) -> str:
return hashlib.sha256(value.encode("utf-8", errors="replace")).hexdigest()
def _validate_path_pattern(pattern: str) -> None:
if not isinstance(pattern, str) or not pattern or len(pattern) > 256:
raise ValueError("path patterns must be non-empty strings of at most 256 characters")
normalized = pattern.rstrip("/")
parts = tuple(normalized.split("/"))
if (
pattern.startswith("/")
or "\\" in pattern
or "\0" in pattern
or any(part in {"", ".", ".."} for part in parts)
):
raise ValueError(f"path pattern must be repository-relative: {pattern!r}")
if parts and parts[0] == ".git":
raise ValueError("path patterns cannot grant protected .git metadata")
def _path_allowed(path: str, patterns: tuple[str, ...]) -> bool:
normalized = path
parts = tuple(normalized.split("/"))
if (
normalized.startswith("/")
or "\\" in normalized
or any(part in {"", ".", ".."} for part in parts)
or (parts and parts[0] == ".git")
):
return False
for raw_pattern in patterns:
if raw_pattern.endswith("/") and normalized.startswith(raw_pattern):
return True
if _match_path_parts(parts, tuple(raw_pattern.split("/"))):
return True
return False
def _match_path_parts(path_parts: tuple[str, ...], pattern_parts: tuple[str, ...]) -> bool:
if not pattern_parts:
return not path_parts
pattern = pattern_parts[0]
if pattern == "**":
return _match_path_parts(path_parts, pattern_parts[1:]) or bool(
path_parts
and _match_path_parts(path_parts[1:], pattern_parts)
)
return bool(
path_parts
and fnmatch.fnmatchcase(path_parts[0], pattern)
and _match_path_parts(path_parts[1:], pattern_parts[1:])
)
def _bounded_text(value: str, limit: int) -> str:
cleaned = " ".join(str(value).split())
return cleaned[:limit]
def _now() -> str:
return datetime.now(timezone.utc).isoformat()