Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02b6f-7db1-7222-918b-e813a6bda38d
358 lines
12 KiB
Python
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()
|