| | |
| | """ |
| | Git Server Client for connecting to external Gitea instance. |
| | |
| | This module provides a lightweight client for interacting with a shared |
| | Gitea service, optimized for task-based isolation where multiple environment |
| | instances share the same Gitea server but have isolated workspaces. |
| | """ |
| |
|
| | import json |
| | import os |
| | import shutil |
| | import subprocess |
| | import time |
| | from dataclasses import dataclass |
| | from pathlib import Path |
| | from urllib.parse import urlparse |
| |
|
| |
|
| | @dataclass |
| | class RepoInfo: |
| | """Information about a repository.""" |
| |
|
| | name: str |
| | url: str |
| | commit: str |
| | clone_url: str |
| |
|
| |
|
| | class GitServerClient: |
| | """ |
| | Client for connecting to an external Gitea server. |
| | |
| | This client is optimized for task-based isolation where: |
| | - Multiple tasks share the same Gitea instance |
| | - Each task has its own isolated workspace |
| | - Fast reset() via git operations (no server restart) |
| | - Repos are pre-migrated to Gitea once |
| | |
| | Args: |
| | gitea_url: URL of the Gitea server (e.g., "http://gitea:3000") |
| | username: Gitea username for authentication |
| | password: Gitea password for authentication |
| | workspace_dir: Local workspace directory for cloning repos |
| | |
| | Example: |
| | >>> # Connect to shared Gitea (credentials from environment) |
| | >>> import os |
| | >>> client = GitServerClient( |
| | ... gitea_url=os.getenv("GITEA_URL"), |
| | ... username=os.getenv("GITEA_USERNAME"), |
| | ... password=os.getenv("GITEA_PASSWORD") |
| | ... ) |
| | >>> client.wait_for_ready() |
| | >>> # Clone repo to workspace |
| | >>> path = client.clone_to_workspace("my-repo", commit="abc123") |
| | >>> # Fast reset to base state |
| | >>> client.reset_workspace("my-repo", commit="abc123") |
| | """ |
| |
|
| | def __init__( |
| | self, |
| | gitea_url: str, |
| | username: str, |
| | password: str, |
| | workspace_dir: str = "/workspace", |
| | ): |
| | """Initialize Git Server Client.""" |
| | self.gitea_url = gitea_url.rstrip("/") |
| | self.username = username |
| | self.password = password |
| | self.workspace_dir = Path(workspace_dir) |
| | self.is_ready = False |
| |
|
| | |
| | parsed = urlparse(self.gitea_url) |
| | self.domain = parsed.hostname or "localhost" |
| | self.port = parsed.port or 3000 |
| |
|
| | |
| | os.makedirs(self.workspace_dir, exist_ok=True) |
| |
|
| | |
| | self._configure_git() |
| |
|
| | def _configure_git(self): |
| | """Configure git credentials for automatic authentication.""" |
| | home_dir = Path.home() |
| |
|
| | |
| | git_config = f"""[user] |
| | name = {self.username} |
| | email = {self.username}@local.env |
| | [init] |
| | defaultBranch = main |
| | [credential] |
| | helper = store |
| | """ |
| | gitconfig_path = home_dir / ".gitconfig" |
| | gitconfig_path.write_text(git_config) |
| |
|
| | |
| | git_credentials = f"http://{self.username}:{self.password}@{self.domain}:{self.port}\n" |
| | gitcreds_path = home_dir / ".git-credentials" |
| | gitcreds_path.write_text(git_credentials) |
| | gitcreds_path.chmod(0o600) |
| |
|
| | def wait_for_ready(self, timeout: int = 30) -> bool: |
| | """ |
| | Wait for Gitea server to be ready. |
| | |
| | Args: |
| | timeout: Maximum seconds to wait |
| | |
| | Returns: |
| | True if server is ready, False otherwise |
| | """ |
| | start_time = time.time() |
| | while time.time() - start_time < timeout: |
| | try: |
| | result = subprocess.run( |
| | ["curl", "-sf", f"{self.gitea_url}/"], |
| | capture_output=True, |
| | timeout=5, |
| | ) |
| | if result.returncode == 0: |
| | self.is_ready = True |
| | return True |
| | except subprocess.TimeoutExpired: |
| | pass |
| | except Exception: |
| | pass |
| |
|
| | time.sleep(1) |
| |
|
| | return False |
| |
|
| | def list_repositories(self) -> list[dict[str, str]]: |
| | """ |
| | List all repositories in Gitea. |
| | |
| | Returns: |
| | List of repository information dictionaries |
| | """ |
| | if not self.is_ready: |
| | raise RuntimeError("Gitea server is not ready") |
| |
|
| | result = subprocess.run( |
| | [ |
| | "curl", |
| | "-s", |
| | f"{self.gitea_url}/api/v1/user/repos", |
| | "-u", |
| | f"{self.username}:{self.password}", |
| | ], |
| | capture_output=True, |
| | text=True, |
| | ) |
| |
|
| | if result.returncode != 0: |
| | return [] |
| |
|
| | try: |
| | repos = json.loads(result.stdout) |
| | return [ |
| | { |
| | "name": repo["name"], |
| | "full_name": repo["full_name"], |
| | "clone_url": repo["clone_url"], |
| | "description": repo.get("description", ""), |
| | } |
| | for repo in repos |
| | ] |
| | except (json.JSONDecodeError, KeyError): |
| | return [] |
| |
|
| | def clone_to_workspace( |
| | self, repo_name: str, target_dir: str | None = None, commit: str = "main" |
| | ) -> str: |
| | """ |
| | Clone a repository to the workspace at a specific commit. |
| | |
| | This creates a fresh clone optimized for task isolation. |
| | |
| | Args: |
| | repo_name: Name of repository to clone |
| | target_dir: Target directory name (defaults to repo_name) |
| | commit: Commit hash or branch to checkout |
| | |
| | Returns: |
| | Path to cloned repository |
| | |
| | Raises: |
| | RuntimeError: If clone fails |
| | """ |
| | if not self.is_ready: |
| | raise RuntimeError("Gitea server is not ready") |
| |
|
| | target_dir = target_dir or repo_name |
| | target_path = self.workspace_dir / target_dir |
| |
|
| | |
| | if target_path.exists(): |
| | shutil.rmtree(target_path) |
| |
|
| | clone_url = f"{self.gitea_url}/{self.username}/{repo_name}.git" |
| |
|
| | |
| | result = subprocess.run( |
| | ["git", "clone", clone_url, str(target_path)], |
| | capture_output=True, |
| | text=True, |
| | ) |
| |
|
| | if result.returncode != 0: |
| | raise RuntimeError(f"Clone failed: {result.stderr}") |
| |
|
| | |
| | if commit != "main": |
| | result = subprocess.run( |
| | ["git", "checkout", commit], |
| | cwd=str(target_path), |
| | capture_output=True, |
| | text=True, |
| | ) |
| |
|
| | if result.returncode != 0: |
| | raise RuntimeError(f"Checkout failed: {result.stderr}") |
| |
|
| | return str(target_path) |
| |
|
| | def reset_workspace(self, repo_name: str, commit: str = "main") -> bool: |
| | """ |
| | Fast reset of workspace to base state (optimized for task resets). |
| | |
| | This is much faster than re-cloning. It: |
| | 1. Checks out the target commit |
| | 2. Resets to that commit (hard) |
| | 3. Cleans untracked files |
| | |
| | Args: |
| | repo_name: Name of repository (directory in workspace) |
| | commit: Commit hash or branch to reset to |
| | |
| | Returns: |
| | True if reset successful |
| | |
| | Raises: |
| | RuntimeError: If reset fails |
| | """ |
| | repo_path = self.workspace_dir / repo_name |
| |
|
| | if not repo_path.exists(): |
| | raise RuntimeError(f"Repository not found in workspace: {repo_name}") |
| |
|
| | |
| | subprocess.run( |
| | ["git", "fetch", "--all"], |
| | cwd=str(repo_path), |
| | capture_output=True, |
| | ) |
| |
|
| | |
| | result = subprocess.run( |
| | ["git", "checkout", commit], |
| | cwd=str(repo_path), |
| | capture_output=True, |
| | text=True, |
| | ) |
| |
|
| | if result.returncode != 0: |
| | raise RuntimeError(f"Checkout failed: {result.stderr}") |
| |
|
| | result = subprocess.run( |
| | ["git", "reset", "--hard", f"origin/{commit}" if commit != "main" else commit], |
| | cwd=str(repo_path), |
| | capture_output=True, |
| | text=True, |
| | ) |
| |
|
| | if result.returncode != 0: |
| | |
| | result = subprocess.run( |
| | ["git", "reset", "--hard", commit], |
| | cwd=str(repo_path), |
| | capture_output=True, |
| | text=True, |
| | ) |
| | if result.returncode != 0: |
| | raise RuntimeError(f"Reset failed: {result.stderr}") |
| |
|
| | |
| | subprocess.run( |
| | ["git", "clean", "-fdx"], |
| | cwd=str(repo_path), |
| | capture_output=True, |
| | ) |
| |
|
| | return True |
| |
|
| | def execute_git_command( |
| | self, command: str, working_dir: str = "" |
| | ) -> tuple[int, str, str]: |
| | """ |
| | Execute a git command in the workspace. |
| | |
| | Args: |
| | command: Git command to execute (without 'git' prefix) |
| | working_dir: Working directory relative to workspace |
| | |
| | Returns: |
| | Tuple of (exit_code, stdout, stderr) |
| | """ |
| | work_path = ( |
| | self.workspace_dir / working_dir if working_dir else self.workspace_dir |
| | ) |
| |
|
| | if not work_path.exists(): |
| | return (1, "", f"Working directory does not exist: {work_path}") |
| |
|
| | |
| | cmd_parts = ["git"] + command.split() |
| |
|
| | result = subprocess.run( |
| | cmd_parts, |
| | cwd=str(work_path), |
| | capture_output=True, |
| | text=True, |
| | ) |
| |
|
| | return (result.returncode, result.stdout, result.stderr) |
| |
|
| | def get_current_commit(self, repo_name: str) -> str: |
| | """ |
| | Get current commit hash of a workspace repository. |
| | |
| | Args: |
| | repo_name: Name of repository in workspace |
| | |
| | Returns: |
| | Commit hash |
| | """ |
| | repo_path = self.workspace_dir / repo_name |
| |
|
| | if not repo_path.exists(): |
| | raise RuntimeError(f"Repository not found: {repo_name}") |
| |
|
| | result = subprocess.run( |
| | ["git", "rev-parse", "HEAD"], |
| | cwd=str(repo_path), |
| | capture_output=True, |
| | text=True, |
| | ) |
| |
|
| | if result.returncode != 0: |
| | raise RuntimeError(f"Failed to get commit: {result.stderr}") |
| |
|
| | return result.stdout.strip() |
| |
|
| | def workspace_exists(self, repo_name: str) -> bool: |
| | """Check if a repository exists in workspace.""" |
| | return (self.workspace_dir / repo_name).exists() |
| |
|