"""Process-safe transaction boundary for a local Git checkout. Wired into `run_task`, profile-absent `execute_approach` mutators, and the profiled claim path under REINAH-WP-0003-T02 / ADR-002. Explicit local TaskSpec grants activate repository acceptance; queued/profiled grant carriage remains an upstream contract dependency. """ 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()