9a17916dd2
- tool_instances.py: 2108 → 496 lines - git_repositories.py: 1422 → 500 lines - config_profiles.py: 474 → 300 lines (already committed) Extract business logic into services: - services/tool/instance_service.py - services/git/operations.py - services/config/crud_service.py Quality gates: py_compile pass on all files
214 lines
6.8 KiB
Python
214 lines
6.8 KiB
Python
"""Git repository operations service."""
|
|
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
import uuid
|
|
|
|
from fastapi import HTTPException, status
|
|
|
|
from src.config import Settings
|
|
from src.models import SSHKey
|
|
from src.services.shared.ssh_keys import _get_fernet
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str:
|
|
"""Generate the filesystem path for a repository."""
|
|
base = Settings().repo_base_path or "/data/repos"
|
|
return os.path.join(base, str(user_id), str(project_id), f"{name}.git")
|
|
|
|
|
|
def build_provider_clone_url(owner: str, repo: str) -> str:
|
|
"""Build the SSH clone URL for the fixed git provider."""
|
|
return f"git@git.commumedia.org:{owner}/{repo}.git"
|
|
|
|
|
|
def prepare_ssh_env(ssh_key: SSHKey | None) -> tuple[dict, str] | None:
|
|
"""Prepare environment variables for git commands with SSH authentication."""
|
|
if ssh_key is None:
|
|
return None
|
|
|
|
import tempfile
|
|
|
|
fernet = _get_fernet()
|
|
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
|
|
|
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
|
|
try:
|
|
os.write(fd, private_key.encode())
|
|
finally:
|
|
os.close(fd)
|
|
os.chmod(key_path, 0o600)
|
|
|
|
env = {
|
|
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
|
}
|
|
return env, key_path
|
|
|
|
|
|
def preflight_remote_repository(remote_url: str, ssh_key: SSHKey | None = None) -> None:
|
|
"""Verify a remote repository is reachable before cloning."""
|
|
env = None
|
|
key_path = None
|
|
|
|
if ssh_key is not None:
|
|
ssh_result = prepare_ssh_env(ssh_key)
|
|
if ssh_result:
|
|
env, key_path = ssh_result
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "ls-remote", remote_url],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=60,
|
|
env={**os.environ, **env} if env else None,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="remote repository check timed out",
|
|
)
|
|
except FileNotFoundError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="git command not found",
|
|
)
|
|
finally:
|
|
if key_path and os.path.exists(key_path):
|
|
os.unlink(key_path)
|
|
|
|
if result.returncode != 0:
|
|
logger.error(
|
|
"Preflight check failed for %s: stderr=%s", remote_url, result.stderr
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"repository not found or inaccessible: {result.stderr}",
|
|
)
|
|
|
|
|
|
def clone_working_repository(remote_url: str, repo_path: str, ssh_key: SSHKey | None = None) -> None:
|
|
"""Clone a remote repository to a local path."""
|
|
env = None
|
|
key_path = None
|
|
|
|
if ssh_key is not None:
|
|
ssh_result = prepare_ssh_env(ssh_key)
|
|
if ssh_result:
|
|
env, key_path = ssh_result
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "clone", remote_url, repo_path],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=300,
|
|
env={**os.environ, **env} if env else None,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out"
|
|
)
|
|
except FileNotFoundError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="git command not found",
|
|
)
|
|
finally:
|
|
if key_path and os.path.exists(key_path):
|
|
os.unlink(key_path)
|
|
|
|
if result.returncode != 0:
|
|
logger.error("Clone failed for %s: stderr=%s", remote_url, result.stderr)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"failed to clone repository: {result.stderr}",
|
|
)
|
|
|
|
|
|
def init_working_repository(repo_path: str) -> None:
|
|
"""Initialize a new git repository at the given path."""
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "init", "-b", "main", repo_path],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
except FileNotFoundError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="git command not found",
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
return
|
|
|
|
fallback = subprocess.run(
|
|
["git", "init", repo_path],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if fallback.returncode != 0:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"failed to initialize repository: {fallback.stderr}",
|
|
)
|
|
|
|
ref_result = subprocess.run(
|
|
["git", "-C", repo_path, "symbolic-ref", "HEAD", "refs/heads/main"],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if ref_result.returncode != 0:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"failed to set initial branch: {ref_result.stderr}",
|
|
)
|
|
|
|
|
|
def list_remote_branches(remote_url: str, ssh_key: SSHKey | None = None) -> tuple[list[str], str]:
|
|
"""List branches from a remote repository via ls-remote.
|
|
|
|
Returns:
|
|
Tuple of (branch_names, default_branch).
|
|
"""
|
|
ssh_result = prepare_ssh_env(ssh_key)
|
|
env, key_path = ssh_result if ssh_result else (None, None)
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "ls-remote", "--heads", remote_url],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
env={**os.environ, **env} if env else None,
|
|
)
|
|
if result.returncode != 0:
|
|
logger.warning("ls-remote returned %d: %s", result.returncode, result.stderr)
|
|
raise RuntimeError(f"ls-remote failed: {result.stderr}")
|
|
branches = []
|
|
default_branch = "main"
|
|
for line in result.stdout.strip().split("\n"):
|
|
if not line:
|
|
continue
|
|
parts = line.split("\t")
|
|
if len(parts) == 2:
|
|
ref = parts[1]
|
|
if ref.startswith("refs/heads/"):
|
|
branch_name = ref[len("refs/heads/"):]
|
|
branches.append(branch_name)
|
|
if branch_name in ("main", "master"):
|
|
default_branch = branch_name
|
|
return branches, default_branch
|
|
except subprocess.TimeoutExpired:
|
|
logger.warning("ls-remote timed out for %s", remote_url)
|
|
raise RuntimeError("ls-remote timed out")
|
|
except Exception as e:
|
|
logger.warning("ls-remote failed for %s: %s", remote_url, str(e))
|
|
raise RuntimeError(f"ls-remote failed: {e}")
|
|
finally:
|
|
if key_path and os.path.exists(key_path):
|
|
os.unlink(key_path)
|