from __future__ import annotations
import subprocess
from pathlib import Path
from typing import Any
from pydantic import BaseModel, Field
from .config import Settings
from .validation import validate_repository
DENIED_PARTS = {
".codex",
".git",
".pytest_cache",
".ruff_cache",
".venv",
"__pycache__",
"gnexus_book_server.egg-info",
"node_modules",
}
class CommitRequest(BaseModel):
summary: str = Field(min_length=1, max_length=200)
details: str = Field(default="", max_length=4000)
files: list[str] = Field(min_length=1)
class GitAdapterError(ValueError):
pass
class GitAdapter:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self.repo_root = settings.repo_root.resolve()
def status(self) -> dict[str, Any]:
result = self._git("status", "--short")
entries = []
for line in result.stdout.splitlines():
if not line:
continue
entries.append({"status": line[:2], "path": line[3:]})
return {"entries": entries, "raw": result.stdout}
def diff(self, files: list[str] | None = None) -> dict[str, str]:
args = ["diff", "--"]
if files:
args.extend(self._validate_paths(files))
result = self._git(*args)
return {"diff": result.stdout}
def commit(self, request: CommitRequest) -> dict[str, Any]:
validation = validate_repository(self.settings)
if validation["status"] != "ok":
raise GitAdapterError("Repository validation failed; commit blocked")
files = self._validate_paths(request.files)
self._git("add", "--", *files)
staged = self._git("diff", "--cached", "--name-only").stdout.splitlines()
if not staged:
raise GitAdapterError("No staged changes to commit")
message = request.summary.strip()
if request.details.strip():
message += "\n\n" + request.details.strip()
result = self._git("commit", "-m", message)
return {
"status": "committed",
"files": staged,
"stdout": result.stdout,
"stderr": result.stderr,
}
def _validate_paths(self, paths: list[str]) -> list[str]:
valid: list[str] = []
for raw_path in paths:
path = raw_path.strip()
if not path:
raise GitAdapterError("Empty file path is not allowed")
if path.startswith("/") or "\\" in path:
raise GitAdapterError(f"Invalid repository-relative path: {raw_path}")
candidate = (self.repo_root / path).resolve()
if self.repo_root not in candidate.parents and candidate != self.repo_root:
raise GitAdapterError(f"Path escapes repository root: {raw_path}")
rel = candidate.relative_to(self.repo_root).as_posix()
if any(part in DENIED_PARTS for part in Path(rel).parts):
raise GitAdapterError(f"Path is not allowed for commit: {raw_path}")
valid.append(rel)
return valid
def _git(self, *args: str) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
["git", *args],
cwd=self.repo_root,
check=False,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if result.returncode != 0:
raise GitAdapterError(result.stderr.strip() or result.stdout.strip() or "Git command failed")
return result