diff --git a/apps/api/src/api/git_repositories.py b/apps/api/src/api/git_repositories.py index 05b00f0..0b3892d 100644 --- a/apps/api/src/api/git_repositories.py +++ b/apps/api/src/api/git_repositories.py @@ -10,7 +10,12 @@ from pydantic import BaseModel, ConfigDict from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session +from src.auth.dependencies import ( + _get_owned_project, + _get_user, + get_current_user_id, + get_db_session, +) from src.config import Settings from src.models.git_repository import GitRepository from src.models.ssh_key import SSHKey @@ -62,19 +67,19 @@ def _build_provider_clone_url(owner: str, repo: str) -> str: def _prepare_ssh_env(ssh_key: SSHKey | None) -> dict | None: """Prepare environment variables for git commands with SSH authentication. - + Returns a dict of extra env vars, or None if no SSH key provided. The caller is responsible for cleaning up the temporary key file. """ if ssh_key is None: return None - + import tempfile - + # Decrypt private key fernet = _get_fernet() private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode() - + # Write to temp file with restricted permissions fd, key_path = tempfile.mkstemp(prefix="ssh_key_") try: @@ -82,7 +87,7 @@ def _prepare_ssh_env(ssh_key: SSHKey | None) -> dict | None: finally: os.close(fd) os.chmod(key_path, 0o600) - + # Return env vars and the key path for cleanup env = { "GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" @@ -90,16 +95,18 @@ def _prepare_ssh_env(ssh_key: SSHKey | None) -> dict | None: return env, key_path -def _preflight_remote_repository(remote_url: str, ssh_key: SSHKey | None = None) -> None: +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], @@ -109,30 +116,40 @@ def _preflight_remote_repository(remote_url: str, ssh_key: SSHKey | None = None) 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") + 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") + 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) + 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: +def _clone_working_repository( + remote_url: str, repo_path: str, ssh_key: SSHKey | None = None +) -> None: 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], @@ -142,9 +159,14 @@ def _clone_working_repository(remote_url: str, repo_path: str, ssh_key: SSHKey | 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") + 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") + 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) @@ -165,7 +187,10 @@ def _init_working_repository(repo_path: str) -> None: text=True, ) except FileNotFoundError: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="git command not found", + ) if result.returncode == 0: return @@ -310,7 +335,10 @@ async def create_external_repository( ) ) if existing.scalar_one_or_none(): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="repository name already exists", + ) # Validate and potentially correct the URL remote_url = data.remote_url @@ -336,13 +364,21 @@ async def create_external_repository( try: ssh_key_id = uuid.UUID(data.ssh_key_id) except ValueError: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format") - + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="invalid ssh_key_id format", + ) + ssh_key = await session.get(SSHKey, ssh_key_id) if ssh_key is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found" + ) if ssh_key.user_id != user_id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="ssh key does not belong to user", + ) if remote_url: _preflight_remote_repository(remote_url, ssh_key) @@ -369,7 +405,10 @@ async def create_external_repository( repo.is_mirror = False except Exception as exc: await session.rollback() - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to clone repository: {exc}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to clone repository: {exc}", + ) else: # Initialize empty repo os.makedirs(repo_path, exist_ok=True) @@ -438,7 +477,9 @@ async def delete_repository( repo = await session.get(GitRepository, repo_id) if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" + ) # Remove from disk if os.path.exists(repo.path): @@ -484,7 +525,10 @@ async def create_repository( ) ) if existing.scalar_one_or_none(): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="repository name already exists", + ) # Validate and potentially correct the URL remote_url = data.remote_url @@ -511,13 +555,21 @@ async def create_repository( try: ssh_key_id = uuid.UUID(data.ssh_key_id) except ValueError: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format") - + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="invalid ssh_key_id format", + ) + ssh_key = await session.get(SSHKey, ssh_key_id) if ssh_key is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found" + ) if ssh_key.user_id != user_id and ssh_key.project_id != project_id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="ssh key does not belong to user or project", + ) if remote_url: _preflight_remote_repository(remote_url, ssh_key) @@ -581,20 +633,30 @@ async def update_repository_ssh_key( repo = await session.get(GitRepository, repo_id) if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" + ) # Validate SSH key if provided if data.ssh_key_id: try: ssh_key_id = uuid.UUID(data.ssh_key_id) except ValueError: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="invalid ssh_key_id format", + ) ssh_key = await session.get(SSHKey, ssh_key_id) if ssh_key is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found" + ) if ssh_key.user_id != user_id and ssh_key.project_id != project_id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="ssh key does not belong to user or project", + ) repo.ssh_key_id = ssh_key_id else: @@ -640,16 +702,24 @@ async def get_repository_history( repo = await session.get(GitRepository, repo_id) if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" + ) if not os.path.exists(repo.path): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" + ) try: - history = get_commit_history(repo.path, branch=branch, limit=limit, offset=offset) + history = get_commit_history( + repo.path, branch=branch, limit=limit, offset=offset + ) return history except RuntimeError as e: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e) + ) @router.get( @@ -681,10 +751,14 @@ async def get_repository_commit( repo = await session.get(GitRepository, repo_id) if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" + ) if not os.path.exists(repo.path): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" + ) try: detail = get_commit_detail(repo.path, commit_hash) @@ -763,10 +837,14 @@ async def list_repository_files( repo = await session.get(GitRepository, repo_id) if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" + ) if not os.path.exists(repo.path): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" + ) try: entries = list_tree(repo.path, branch=branch, path=path) @@ -829,10 +907,14 @@ async def get_repository_file_content( repo = await session.get(GitRepository, repo_id) if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" + ) if not os.path.exists(repo.path): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" + ) try: file_content = get_file_content(repo.path, branch=branch, path=path) @@ -847,7 +929,9 @@ async def get_repository_file_content( last_commit=file_content.last_commit, ) except FileNotFoundError: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="file not found" + ) except RuntimeError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) @@ -880,10 +964,14 @@ async def get_repository_branches( repo = await session.get(GitRepository, repo_id) if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" + ) - # Try local repo first - is_valid_git_repo = os.path.isdir(os.path.join(repo.path, ".git")) + # Try local repo first (.git subdir for normal repos, HEAD for bare) + is_valid_git_repo = os.path.isdir( + os.path.join(repo.path, ".git") + ) or os.path.isfile(os.path.join(repo.path, "HEAD")) if is_valid_git_repo: try: @@ -906,16 +994,29 @@ async def get_repository_branches( str(e), exc_info=True, ) - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) from e + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e) + ) from e # Local repo missing/corrupt — try remote if available if repo.remote_url: + ssh_key = None + if repo.ssh_key_id: + ssh_key = await session.get(SSHKey, repo.ssh_key_id) + + ssh_result = _prepare_ssh_env(ssh_key) + env = None + key_path = None + if ssh_result: + env, key_path = ssh_result + try: result = subprocess.run( ["git", "ls-remote", "--heads", repo.remote_url], capture_output=True, text=True, timeout=30, + env={**os.environ, **env} if env else None, ) if result.returncode == 0: remote_branches = [] @@ -926,9 +1027,9 @@ async def get_repository_branches( if len(parts) == 2: ref = parts[1] if ref.startswith("refs/heads/"): - branch_name = ref[len("refs/heads/"):] + branch_name = ref[len("refs/heads/") :] remote_branches.append(branch_name) - if branch_name == "main" or branch_name == "master": + if branch_name in ("main", "master"): default_branch = branch_name if remote_branches: return BranchesResponse( @@ -942,10 +1043,20 @@ async def get_repository_branches( ], default_branch=default_branch, ) + else: + logger.warning( + "ls-remote returned %d for repo %s: %s", + result.returncode, + repo_id, + result.stderr, + ) except subprocess.TimeoutExpired: logger.warning("ls-remote timed out for repo %s", repo_id) except Exception as e: logger.warning("ls-remote failed for repo %s: %s", repo_id, str(e)) + finally: + if key_path and os.path.exists(key_path): + os.unlink(key_path) raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -983,10 +1094,14 @@ async def update_repository_file( repo = await session.get(GitRepository, repo_id) if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" + ) if not os.path.exists(repo.path): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" + ) # Get user info for commit user = await _get_user(session, user_id) @@ -1054,10 +1169,14 @@ async def get_repository_status( repo = await session.get(GitRepository, repo_id) if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" + ) if not os.path.exists(repo.path): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" + ) try: status_result = get_status(repo.path) @@ -1113,10 +1232,14 @@ async def create_repository_branch( repo = await session.get(GitRepository, repo_id) if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" + ) if not os.path.exists(repo.path): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" + ) try: create_branch(repo.path, data.name, data.base_branch) @@ -1156,10 +1279,14 @@ async def delete_repository_branch( repo = await session.get(GitRepository, repo_id) if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" + ) if not os.path.exists(repo.path): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" + ) try: delete_branch(repo.path, branch_name, force) @@ -1197,10 +1324,14 @@ async def checkout_repository_branch( repo = await session.get(GitRepository, repo_id) if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" + ) if not os.path.exists(repo.path): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" + ) try: checkout_branch(repo.path, data.branch) @@ -1249,10 +1380,14 @@ async def commit_repository_changes( repo = await session.get(GitRepository, repo_id) if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" + ) if not os.path.exists(repo.path): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" + ) # Get user info for commit user = await _get_user(session, user_id) @@ -1307,10 +1442,14 @@ async def fetch_repository( repo = await session.get(GitRepository, repo_id) if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" + ) if not os.path.exists(repo.path): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" + ) try: fetch(repo.path) @@ -1353,10 +1492,14 @@ async def pull_repository( repo = await session.get(GitRepository, repo_id) if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" + ) if not os.path.exists(repo.path): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" + ) try: pull(repo.path, branch) @@ -1399,10 +1542,14 @@ async def push_repository( repo = await session.get(GitRepository, repo_id) if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" + ) if not os.path.exists(repo.path): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" + ) try: push(repo.path, branch) @@ -1452,10 +1599,14 @@ async def merge_repository_branches( repo = await session.get(GitRepository, repo_id) if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" + ) if not os.path.exists(repo.path): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" + ) try: commit_hash = merge( diff --git a/apps/web/src/hooks/use-git-repo.ts b/apps/web/src/hooks/use-git-repo.ts index 404b2e1..2e5a64e 100644 --- a/apps/web/src/hooks/use-git-repo.ts +++ b/apps/web/src/hooks/use-git-repo.ts @@ -93,6 +93,18 @@ export function useGitRepo( const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const extractError = (err: unknown): string => { + if (typeof err === "object" && err !== null) { + const e = err as Record; + const response = e.response as Record | undefined; + const data = response?.data as Record | undefined; + if (typeof data?.detail === "string") return data.detail; + if (typeof data?.message === "string") return data.message; + if (typeof e.message === "string") return e.message; + } + return "Git operation failed"; + }; + const withLoading = useCallback( async (fn: () => Promise): Promise => { setLoading(true); @@ -100,8 +112,7 @@ export function useGitRepo( try { return await fn(); } catch (err) { - const msg = err instanceof Error ? err.message : "Git operation failed"; - setError(msg); + setError(extractError(err)); throw err; } finally { setLoading(false);