from __future__ import annotations import subprocess import os from dataclasses import dataclass from pathlib import Path from tools.capabilities import Capability @dataclass(frozen=True) class ToolResult: returncode: int stdout: str stderr: str class CapabilityError(PermissionError): pass class WorktreeTools: def __init__(self, worktree_path: Path, capabilities: set[Capability]) -> None: self.worktree_path = worktree_path.resolve() self.capabilities = capabilities def _require(self, capability: Capability) -> None: if capability not in self.capabilities: raise CapabilityError(f"Missing capability: {capability.value}") def _safe_path(self, relative_path: str) -> Path: path = (self.worktree_path / relative_path).resolve() if self.worktree_path not in path.parents and path != self.worktree_path: raise CapabilityError("Path escapes worktree") return path def read_text(self, relative_path: str) -> str: self._require(Capability.READ_REPOSITORY) return self._safe_path(relative_path).read_text(encoding="utf-8") def write_text(self, relative_path: str, content: str) -> None: self._require(Capability.WRITE_WORKTREE) path = self._safe_path(relative_path) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") def run(self, command: list[str], timeout: int = 60) -> ToolResult: self._require(Capability.RUN_TESTS) env = os.environ.copy() for key in ["DJANGO_SETTINGS_MODULE", "DATABASE_URL"]: env.pop(key, None) completed = subprocess.run( command, cwd=self.worktree_path, check=False, capture_output=True, text=True, timeout=timeout, env=env, ) return ToolResult(completed.returncode, completed.stdout, completed.stderr) def git(self, args: list[str]) -> ToolResult: required = Capability.COMMIT_CHANGES if args and args[0] == "commit" else Capability.READ_REPOSITORY self._require(required) completed = subprocess.run( ["git", *args], cwd=self.worktree_path, check=False, capture_output=True, text=True, ) return ToolResult(completed.returncode, completed.stdout, completed.stderr) def status(self) -> str: return self.git(["status", "--short"]).stdout def diff(self) -> str: return self.git(["diff", "--", "."]).stdout def commit_all(self, message: str) -> str: self._require(Capability.COMMIT_CHANGES) add = self.git(["add", "."]) if add.returncode != 0: raise RuntimeError(add.stderr) commit = self.git(["-c", "user.name=Artifex", "-c", "user.email=artifex@example.invalid", "commit", "-m", message]) if commit.returncode != 0: raise RuntimeError(commit.stderr or commit.stdout) rev = self.git(["rev-parse", "HEAD"]) if rev.returncode != 0: raise RuntimeError(rev.stderr) return rev.stdout.strip()