rein-aharness/rein_aharness/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

358 lines
12 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 hashlib
import json
import os
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}"
)
@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], ...]
@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),
}
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.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")
return {
"transaction_id": self.transaction_id,
"correlation_id": self.correlation_id,
"baseline": self.baseline.evidence(),
}
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),
)
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 _bounded_text(value: str, limit: int) -> str:
cleaned = " ".join(str(value).split())
return cleaned[:limit]
def _now() -> str:
return datetime.now(timezone.utc).isoformat()