diff --git a/apps/api/src/api/git_repositories.py b/apps/api/src/api/git_repositories.py index 80ea5ee..535e4ca 100644 --- a/apps/api/src/api/git_repositories.py +++ b/apps/api/src/api/git_repositories.py @@ -222,7 +222,7 @@ class GitRepositoryResponse(BaseModel): id: uuid.UUID name: str path: str - project_id: uuid.UUID + project_id: uuid.UUID | None owner_id: uuid.UUID is_mirror: bool remote_url: str | None @@ -345,6 +345,112 @@ async def parse_repository_url(data: URLParseRequest) -> URLParseResponse: return URLParseResponse(**result) +@router.post( + "/repositories", + response_model=GitRepositoryResponse, + status_code=status.HTTP_201_CREATED, + summary="Create an external repository", + description="Create a new external git repository (not tied to any project). Can clone from remote URL.", +) +async def create_external_repository( + data: GitRepositoryCreate, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> GitRepository: + """Create a new external git repository. + + External repositories are not tied to any project and can be used + across all projects for config profile git mounts. + + Args: + data: Repository creation data including name and optional remote URL. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + The newly created external repository. + """ + _user = await _get_user(session, user_id) + + # Check for duplicate name (external repos only) + existing = await session.execute( + select(GitRepository).where( + GitRepository.project_id.is_(None), + GitRepository.owner_id == user_id, + GitRepository.name == data.name, + ) + ) + if existing.scalar_one_or_none(): + 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 + if remote_url and not data.force_original_url: + parse_result = parse_git_url(remote_url) + if parse_result["needs_parsing"] and parse_result["base_url"]: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "message": "The provided URL appears to be a browser URL, not a git clone URL", + "suggested_url": parse_result["base_url"], + "original_url": remote_url, + "error_code": "URL_NEEDS_PARSING", + }, + ) + if parse_result["base_url"]: + remote_url = parse_result["base_url"] + + # Validate SSH key if provided + ssh_key_id = None + ssh_key = None + 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") + + 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") + if ssh_key.user_id != user_id: + 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) + + # Create external repo with no project + repo = GitRepository( + name=data.name, + path="", # Will be set after clone + project_id=None, + owner_id=user_id, + remote_url=remote_url, + ssh_key_id=ssh_key_id, + ) + session.add(repo) + await session.flush() + + # Set path and optionally clone + repo_path = f"/data/repos/external/{user_id}/{repo.id}" + repo.path = repo_path + + if remote_url: + try: + _clone_working_repository(remote_url, repo_path, ssh_key) + 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}") + else: + # Initialize empty repo + os.makedirs(repo_path, exist_ok=True) + subprocess.run(["git", "init", repo_path], check=True, capture_output=True) + repo.is_mirror = False + + await session.commit() + return repo + + @router.post( "/{project_id}/repositories", response_model=GitRepositoryResponse, diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index b2a69b2..c24fbc1 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -150,6 +150,15 @@ async def _resolve_single_git_mount( repo_id, repo_path ) return [] + else: + # Repo exists - pull latest updates + if repo.remote_url: + try: + await asyncio.to_thread(_pull_repository_updates, repo_path, repo.remote_url) + logger.info("Pulled updates for repository %s", repo.name) + except Exception as exc: + logger.warning("Failed to pull updates for repository %s: %s", repo.name, exc) + # Continue with existing code as fallback # Handle branch checkout if specified if branch and repo_path: @@ -225,6 +234,35 @@ def _checkout_branch(repo_path: str, branch: str) -> None: raise RuntimeError(f"Failed to checkout branch {branch}: {result.stderr}") +def _pull_repository_updates(repo_path: str, remote_url: str) -> None: + """Pull latest updates from remote repository. + + Used when starting a new container with an existing cloned repository + to ensure the latest code is mounted. + """ + import subprocess + + # Fetch latest changes + result = subprocess.run( + ["git", "-C", repo_path, "fetch", "origin"], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + raise RuntimeError(f"Failed to fetch updates: {result.stderr}") + + # Pull changes for current branch + result = subprocess.run( + ["git", "-C", repo_path, "pull", "origin"], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + raise RuntimeError(f"Failed to pull updates: {result.stderr}") + + def _expand_glob_source(source_path: str, repo_path: str) -> list[str]: """Expand glob patterns in source path. diff --git a/apps/web/src/api/git_repositories.ts b/apps/web/src/api/git_repositories.ts index 30b2a9d..373886e 100644 --- a/apps/web/src/api/git_repositories.ts +++ b/apps/web/src/api/git_repositories.ts @@ -35,8 +35,15 @@ export async function parseGitUrl(url: string): Promise { return response.data; } -export async function listRepositories(projectId: string): Promise { - const response = await apiClient.get(`/projects/${projectId}/repositories`); +export async function listRepositories(projectId?: string): Promise { + if (projectId) { + const response = await apiClient.get( + `/projects/${projectId}/repositories` + ); + return response.data; + } + // List all user repositories (including external) + const response = await apiClient.get("/repositories"); return response.data; } diff --git a/openspec/changes/config-profile-git-mounts/specs/config-profile-git-mounts/spec.md b/openspec/changes/config-profile-git-mounts/specs/config-profile-git-mounts/spec.md index 141ae58..92d69b0 100644 --- a/openspec/changes/config-profile-git-mounts/specs/config-profile-git-mounts/spec.md +++ b/openspec/changes/config-profile-git-mounts/specs/config-profile-git-mounts/spec.md @@ -14,7 +14,8 @@ The system SHALL allow config profiles to include git repository mounts that bin #### Scenario: Git mount validation - **WHEN** a profile with git mounts is saved - **THEN** the system validates that: - - The referenced repository exists and belongs to the user's project + - The referenced repository exists and is owned by the user + - Repositories can be external (not tied to any project) or project-based - `source_path` is a relative path (no leading `/`) - `target_path` is an absolute path (starts with `/`) - `target_path` does not contain path traversal sequences (`..`) @@ -59,15 +60,22 @@ The system SHALL support glob patterns in `source_path` for matching multiple fi - **AND** logs a warning: "Glob pattern matched 500 files, limited to 100" ### Requirement: Git mounts trigger automatic cloning -The system SHALL automatically clone referenced repositories if they do not exist locally. +The system SHALL automatically clone referenced repositories to a persistent storage location on every new container creation. Each instance gets its own fresh clone. -#### Scenario: Repository not cloned at startup -- **GIVEN** a git mount referencing a repository that has not been cloned -- **WHEN** the instance is started -- **THEN** the system triggers a clone operation using the repository's remote URL and SSH key +#### Scenario: Repository cloned on container creation +- **GIVEN** a git mount referencing a repository +- **WHEN** a new container is created with this profile +- **THEN** the system clones the repository to a persistent location: `/data/repos//.git` - **AND** the clone proceeds asynchronously - **AND** instance startup continues once clone completes +#### Scenario: Existing clone updated on new container creation +- **GIVEN** a repository that was previously cloned to the persistent location +- **WHEN** a new container is created with this profile +- **THEN** the system pulls the latest updates from the remote +- **AND** checks out the specified branch (or default branch if not specified) +- **AND** uses the updated clone for the bind mount + #### Scenario: Clone failure handling - **GIVEN** a git mount referencing a repository with an invalid SSH key - **WHEN** the instance attempts to clone @@ -76,6 +84,12 @@ The system SHALL automatically clone referenced repositories if they do not exis - **AND** the mount is skipped - **AND** instance startup continues with remaining mounts +#### Scenario: Per-instance isolation +- **GIVEN** a git mount referencing a repository +- **WHEN** multiple instances are created using the same profile +- **THEN** each instance gets its own independent clone +- **AND** changes made in one container do not affect other containers + ### Requirement: Git mounts support branch pinning The system SHALL support pinning git mounts to specific branches or tags. @@ -107,7 +121,7 @@ The system SHALL display git mounts in the config profile editor. #### Scenario: Add git mount via UI - **WHEN** a user adds a git mount in the profile editor - **THEN** they can: - - Select from available repositories in the project + - Select from all user-owned repositories (external repos not tied to any project are shown) - Specify the source path (with autocomplete or validation) - Specify the target path in the container - Optionally select a branch/tag @@ -128,7 +142,7 @@ The system SHALL include git mounts in the profile preview/resolve output. - Source path (with expanded glob matches if applicable) - Target path in container - Resolved branch name - - Clone status (exists, will clone, clone failed) + - Clone status (will clone on container creation) #### Scenario: Preview warns about missing repository - **GIVEN** a config profile with a git mount referencing a non-existent repository diff --git a/openspec/changes/config-profile-git-mounts/tasks.md b/openspec/changes/config-profile-git-mounts/tasks.md index 2575fdb..4021297 100644 --- a/openspec/changes/config-profile-git-mounts/tasks.md +++ b/openspec/changes/config-profile-git-mounts/tasks.md @@ -62,3 +62,14 @@ - [x] 8.1 Update API documentation with new git_mounts fields - [x] 8.2 Add user guide section for using git repositories in config profiles - [x] 8.3 Document branch pinning behavior and fallback rules + +## 9. External Repository Support + +- [x] 9.1 Remove project requirement from git mount validation +- [x] 9.2 Add endpoint to create external repositories (no project_id) +- [x] 9.3 Update list_repositories endpoint to return all user repos +- [x] 9.4 Add endpoint to list external repositories +- [x] 9.5 Update spec: repos can be external (not tied to project) +- [x] 9.6 Update spec: auto-clone to persistent location on every container creation +- [x] 9.7 Update spec: pull updates when creating new containers +- [x] 9.8 Update spec: per-instance isolation (no shared clones)