import os import logging import subprocess import base64 import shutil import threading from pathlib import Path from urllib.parse import urlparse from .config import GITEA_REPOS_ROOT, GITEA_URL, GITEA_TOKEN from gitea.client import GiteaClient logger: logging.Logger = logging.getLogger("gitea-workspace") class WorkspaceManager: """Manages local workspace for Gitea repositories. Uses per-repo threading locks to prevent concurrent git operations on the same repository from causing race conditions. """ _repo_locks: dict[str, threading.Lock] = {} _locks_lock: threading.Lock = threading.Lock() def __init__(self) -> None: self.root_dir: Path = Path(GITEA_REPOS_ROOT).expanduser().resolve() self.root_dir.mkdir(parents=True, exist_ok=True) @classmethod def get_repo_lock(cls, repo_full_name: str) -> threading.Lock: """Get or create a thread-safe lock for a specific repository.""" with cls._locks_lock: if repo_full_name not in cls._repo_locks: cls._repo_locks[repo_full_name] = threading.Lock() return cls._repo_locks[repo_full_name] def _configure_repo_user(self, repo_path: Path) -> None: try: client = GiteaClient() user = client.repos.get_authenticated_user() if not user or not user.login: raise RuntimeError("No authenticated user found.") username: str = user.login auth_str: str = f"{username}:{GITEA_TOKEN}" auth_bytes: bytes = auth_str.encode("utf-8") auth_b64: str = base64.b64encode(auth_bytes).decode("utf-8") # Configure extraHeader locally for the repo subprocess.run( [ "git", "-C", str(repo_path), "config", "http.extraHeader", f"Authorization: Basic {auth_b64}", ], check=True, capture_output=True, ) name: str = user.full_name or user.login email: str = user.email or f"{user.login}@noreply.gitea" subprocess.run( ["git", "-C", str(repo_path), "config", "user.name", name], check=True, capture_output=True, ) subprocess.run( ["git", "-C", str(repo_path), "config", "user.email", email], check=True, capture_output=True, ) except Exception as e: logger.error(f"Error configuring local git user: {e}") raise def get_repo_path(self, repo_full_name: str) -> Path: parts: list[str] = repo_full_name.split("/") return self.root_dir / parts[0] / parts[1] def _get_authenticated_url(self, repo_full_name: str) -> str: parsed = urlparse(GITEA_URL.rstrip("/")) path = parsed.path.rstrip("/") return f"{parsed.scheme}://{parsed.netloc}{path}/{repo_full_name}.git" def sanitize_repo(self, repo_full_name: str, repo_path: Path) -> None: lock = self.get_repo_lock(repo_full_name) with lock: logger.debug(f"Acquired workspace lock for {repo_full_name} (sanitize)") self._sanitize_repo_inner(repo_full_name, repo_path) def _sanitize_repo_inner(self, repo_full_name: str, repo_path: Path) -> None: try: auth_url = self._get_authenticated_url(repo_full_name) subprocess.run( ["git", "-C", str(repo_path), "remote", "set-url", "origin", auth_url], check=True, capture_output=True, ) self._configure_repo_user(repo_path) # Check for any uncommitted changes or untracked files status_res = subprocess.run( ["git", "-C", str(repo_path), "status", "--porcelain"], check=True, capture_output=True, text=True, ) if status_res.stdout.strip(): logger.info( f"Uncommitted changes detected in {repo_path}. Stashing before sanitization." ) subprocess.run( [ "git", "-C", str(repo_path), "stash", "push", "-u", "-m", "Auto-backup before agent sanitization", ], check=True, capture_output=True, ) subprocess.run( ["git", "-C", str(repo_path), "reset", "--hard", "HEAD"], check=True, capture_output=True, ) subprocess.run( ["git", "-C", str(repo_path), "clean", "-fdx"], check=True, capture_output=True, ) try: subprocess.run( ["git", "-C", str(repo_path), "checkout", "main"], check=True, capture_output=True, ) except subprocess.CalledProcessError: subprocess.run( ["git", "-C", str(repo_path), "checkout", "master"], check=True, capture_output=True, ) try: subprocess.run( ["git", "-C", str(repo_path), "pull", "origin", "main"], check=True, capture_output=True, ) except subprocess.CalledProcessError: subprocess.run( ["git", "-C", str(repo_path), "pull", "origin", "master"], check=True, capture_output=True, ) except Exception as e: logger.error(f"Error during sanitization: {e}", exc_info=True) raise RuntimeError( f"Failed to sanitize repository {repo_full_name} at {repo_path}: {e}" ) from e def clone_repo(self, repo_full_name: str, clone_url: str | None = None) -> Path: lock = self.get_repo_lock(repo_full_name) with lock: logger.debug(f"Acquired workspace lock for {repo_full_name} (clone)") return self._clone_repo_inner(repo_full_name, clone_url) def _clone_repo_inner( self, repo_full_name: str, clone_url: str | None = None ) -> Path: repo_path: Path = self.get_repo_path(repo_full_name) if repo_path.exists(): if not (repo_path / ".git").exists(): new_path: Path = ( repo_path.parent / f"{repo_full_name.replace('/', '_')}_old" ) if new_path.exists(): shutil.rmtree(new_path) repo_path.rename(new_path) return repo_path logger.info(f"Cloning repository {repo_full_name} to {repo_path}...") auth_url = self._get_authenticated_url(repo_full_name) client = GiteaClient() user = client.repos.get_authenticated_user() if not user or not user.login: raise RuntimeError("No authenticated user found.") username: str = user.login auth_str: str = f"{username}:{GITEA_TOKEN}" auth_bytes: bytes = auth_str.encode("utf-8") auth_b64: str = base64.b64encode(auth_bytes).decode("utf-8") subprocess.run( [ "git", "clone", "-c", f"http.extraHeader=Authorization: Basic {auth_b64}", auth_url, str(repo_path), ], check=True, capture_output=True, ) self._configure_repo_user(repo_path) return repo_path