diff --git a/apps/api/src/api/git_repositories.py b/apps/api/src/api/git_repositories.py index 4d04789..b00a20d 100644 --- a/apps/api/src/api/git_repositories.py +++ b/apps/api/src/api/git_repositories.py @@ -89,6 +89,32 @@ def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str: 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 _preflight_remote_repository(remote_url: str) -> None: + """Verify a remote repository is reachable before cloning.""" + try: + result = subprocess.run( + ["git", "ls-remote", remote_url], + capture_output=True, + text=True, + timeout=60, + ) + 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") + + if result.returncode != 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="repository not found or inaccessible", + ) + + class GitRepositoryCreate(BaseModel): name: str remote_url: str | None = None @@ -267,6 +293,9 @@ async def create_repository( if parse_result["base_url"]: remote_url = parse_result["base_url"] + if remote_url: + _preflight_remote_repository(remote_url) + repo_path = _get_repo_path(user_id, project_id, data.name) # Ensure parent directory exists diff --git a/apps/api/tests/unit/test_git_repository_clone_preflight.py b/apps/api/tests/unit/test_git_repository_clone_preflight.py new file mode 100644 index 0000000..5381879 --- /dev/null +++ b/apps/api/tests/unit/test_git_repository_clone_preflight.py @@ -0,0 +1,28 @@ +from unittest.mock import Mock, patch + +import pytest +from fastapi import HTTPException + +from src.api.git_repositories import _build_provider_clone_url, _preflight_remote_repository + + +def test_build_provider_clone_url_uses_fixed_host() -> None: + assert _build_provider_clone_url("alice", "demo") == "git@git.commumedia.org:alice/demo.git" + + +def test_preflight_remote_repository_allows_accessible_repo() -> None: + completed = Mock(returncode=0) + with patch("src.api.git_repositories.subprocess.run", return_value=completed) as run_mock: + _preflight_remote_repository("git@git.commumedia.org:alice/demo.git") + + run_mock.assert_called_once() + + +def test_preflight_remote_repository_rejects_missing_repo() -> None: + completed = Mock(returncode=128) + with patch("src.api.git_repositories.subprocess.run", return_value=completed): + with pytest.raises(HTTPException) as exc_info: + _preflight_remote_repository("git@git.commumedia.org:alice/missing.git") + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "repository not found or inaccessible" diff --git a/apps/web/src/components/repositories-settings-tab.test.tsx b/apps/web/src/components/repositories-settings-tab.test.tsx index eccecc6..54bbf56 100644 --- a/apps/web/src/components/repositories-settings-tab.test.tsx +++ b/apps/web/src/components/repositories-settings-tab.test.tsx @@ -46,8 +46,39 @@ describe("RepositoriesSettingsTab", () => { fireEvent.change(screen.getByPlaceholderText(/repository-name/i), { target: { value: "New Repo" }, }); - fireEvent.click(screen.getByLabelText(/clone existing repository/i)); - fireEvent.change(screen.getByPlaceholderText(/github.com\/user\/repo.git/i), { + fireEvent.change(screen.getByPlaceholderText(/owner/i), { + target: { value: "alice" }, + }); + fireEvent.change(screen.getByPlaceholderText(/repo-name/i), { + target: { value: "demo" }, + }); + fireEvent.click(screen.getByRole("button", { name: /clone repository/i })); + + await waitFor(() => { + expect(createMock).toHaveBeenCalledWith("proj-1", { + name: "New Repo", + remote_url: "git@git.commumedia.org:alice/demo.git", + }); + }); + expect(listMock).toHaveBeenCalledTimes(2); + }); + + it("uses advanced url fallback when requested", async () => { + const listMock = vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories); + const createMock = vi.spyOn(gitRepositoriesApi, "createRepository").mockResolvedValue(mockRepositories[0]); + + render(); + + await waitFor(() => { + expect(screen.getByText("Main Repo")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /add repository/i })); + fireEvent.change(screen.getByPlaceholderText(/repository-name/i), { + target: { value: "New Repo" }, + }); + fireEvent.click(screen.getByRole("button", { name: /use full url instead/i })); + fireEvent.change(screen.getByPlaceholderText(/https:\/\/github.com\/user\/repo.git/i), { target: { value: "https://github.com/user/repo.git" }, }); fireEvent.click(screen.getByRole("button", { name: /clone repository/i })); @@ -73,9 +104,11 @@ describe("RepositoriesSettingsTab", () => { fireEvent.change(screen.getByPlaceholderText(/repository-name/i), { target: { value: "New Repo" }, }); - fireEvent.click(screen.getByLabelText(/clone existing repository/i)); + fireEvent.change(screen.getByPlaceholderText(/owner/i), { + target: { value: "" }, + }); fireEvent.click(screen.getByRole("button", { name: /clone repository/i })); - expect(screen.getByText(/remote url is required/i)).toBeInTheDocument(); + expect(screen.getByText(/owner and repository name are required/i)).toBeInTheDocument(); }); }); diff --git a/apps/web/src/components/repository-create-dialog.tsx b/apps/web/src/components/repository-create-dialog.tsx index 5c58521..dda7269 100644 --- a/apps/web/src/components/repository-create-dialog.tsx +++ b/apps/web/src/components/repository-create-dialog.tsx @@ -17,7 +17,10 @@ interface RepositoryCreateDialogProps { export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCreated }: RepositoryCreateDialogProps) => { const [createMode, setCreateMode] = useState("clone"); const [formName, setFormName] = useState(""); - const [formRemoteUrl, setFormRemoteUrl] = useState(""); + const [owner, setOwner] = useState(""); + const [repoName, setRepoName] = useState(""); + const [advancedUrl, setAdvancedUrl] = useState(""); + const [useAdvancedUrl, setUseAdvancedUrl] = useState(false); const [formError, setFormError] = useState(null); const [urlValidation, setUrlValidation] = useState<{ status: UrlValidationStatus; @@ -34,12 +37,16 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea useEffect(() => { if (!open) return; + if (!useAdvancedUrl) { + setUrlValidation({ status: "idle", result: null }); + return; + } if (debounceTimer.current) { clearTimeout(debounceTimer.current); } - if (!formRemoteUrl.trim()) { + if (!advancedUrl.trim()) { setUrlValidation({ status: "idle", result: null }); return; } @@ -48,7 +55,7 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea debounceTimer.current = setTimeout(async () => { try { - const result = await parseGitUrl(formRemoteUrl.trim()); + const result = await parseGitUrl(advancedUrl.trim()); if (result.is_valid_clone_url) { setUrlValidation({ status: "valid", result }); } else if (result.needs_parsing) { @@ -66,12 +73,15 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea clearTimeout(debounceTimer.current); } }; - }, [formRemoteUrl, open]); + }, [advancedUrl, open, useAdvancedUrl]); const resetForm = () => { setCreateMode("clone"); setFormName(""); - setFormRemoteUrl(""); + setOwner(""); + setRepoName(""); + setAdvancedUrl(""); + setUseAdvancedUrl(false); setFormError(null); setUrlValidation({ status: "idle", result: null }); }; @@ -90,27 +100,41 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea return; } - if (createMode === "clone" && !formRemoteUrl.trim()) { - setFormError("Remote URL is required to clone an existing repository"); - return; - } - try { const input: GitRepositoryCreate = { name: formName.trim(), - remote_url: formRemoteUrl.trim() || undefined, + remote_url: undefined, }; + + if (createMode === "clone") { + if (useAdvancedUrl) { + if (!advancedUrl.trim()) { + setFormError("Remote URL is required for advanced cloning"); + return; + } + input.remote_url = advancedUrl.trim(); + } else { + if (!owner.trim() || !repoName.trim()) { + setFormError("Owner and repository name are required"); + return; + } + input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`; + } + } + await createRepository(projectId, input); handleClose(); await onCreated(); - } catch { - setFormError("Failed to create repository"); + } catch (error: unknown) { + const response = error as { response?: { data?: { detail?: string } } }; + const detail = response.response?.data?.detail; + setFormError(typeof detail === "string" ? detail : "Failed to create repository"); } }; const handleUseSuggestedUrl = () => { if (urlValidation.result?.base_url) { - setFormRemoteUrl(urlValidation.result.base_url); + setAdvancedUrl(urlValidation.result.base_url); setUrlValidation({ status: "idle", result: null }); setFormError(null); } @@ -136,7 +160,7 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea {title} - Clone an existing repository from a git server, or create a blank bare repo here. + Clone an existing repository from git.commumedia.org, or create a blank bare repo here. @@ -168,46 +192,85 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea placeholder="repository-name" /> - - Remote URL {createMode === "clone" ? "(required)" : "(optional)"} - setFormRemoteUrl(event.target.value)} - placeholder="https://github.com/user/repo.git" - className={getUrlInputClass()} - /> - {urlValidation.status === "validating" && ( - Validating... - )} - {urlValidation.status === "valid" && ( - - Valid git URL - - )} - {urlValidation.status === "needs-parsing" && urlValidation.result && ( - - - This looks like a browser URL + {createMode === "clone" && !useAdvancedUrl && ( + <> + + Owner + setOwner(event.target.value)} + placeholder="owner" + /> + + + Repository + setRepoName(event.target.value)} + placeholder="repo-name" + /> + + SSH target: git@git.commumedia.org:{owner || "owner"}/{repoName || "repo"}.git + setUseAdvancedUrl(true)} + > + Use full URL instead + + > + )} + {createMode === "clone" && useAdvancedUrl && ( + + Remote URL + setAdvancedUrl(event.target.value)} + placeholder="https://github.com/user/repo.git" + className={getUrlInputClass()} + /> + {urlValidation.status === "validating" && ( + Validating... + )} + {urlValidation.status === "valid" && ( + + Valid git URL - - Suggested: {urlValidation.result.base_url} - - Use Suggested - + )} + {urlValidation.status === "needs-parsing" && urlValidation.result && ( + + + This looks like a browser URL + + + Suggested: {urlValidation.result.base_url} + + Use Suggested + + - - )} - {urlValidation.status === "invalid" && ( - - Invalid URL - - )} - + )} + {urlValidation.status === "invalid" && ( + + Invalid URL + + )} + setUseAdvancedUrl(false)} + > + Use owner/repo instead + + + )} {formError && ( {formError} diff --git a/docs/features/repositories.md b/docs/features/repositories.md index 216bcea..a338ffd 100644 --- a/docs/features/repositories.md +++ b/docs/features/repositories.md @@ -12,7 +12,7 @@ Git repositories are managed within projects. You can create bare repositories f 2. Click the **"New Repository"** button 3. Fill in the form: - **Name**: Repository name (required) - - **Remote URL**: For cloning (optional) + - **Owner** and **Repository**: For SSH cloning from `git.commumedia.org` - **Mirror Clone**: Toggle for mirror clones 4. Click **"Create Repository"** @@ -25,12 +25,12 @@ Creates a new bare git repository. Use this for: #### Clone from Remote -Enter a git URL to clone from: -- `https://github.com/user/repo.git` -- `git@github.com:user/repo.git` -- `https://gitlab.com/user/repo.git` +Enter the repository owner and name to clone from `git.commumedia.org` over SSH: +- `owner`: `alice` +- `repository`: `demo` +- Resulting SSH URL: `git@git.commumedia.org:alice/demo.git` -**Smart URL Parsing:** If you paste a browser URL (like `https://github.com/user/repo/tree/main`), the system will automatically suggest the correct git URL. +**Advanced fallback:** If needed, you can still paste a full git URL and the system will suggest the correct clone URL. #### Mirror Clone diff --git a/openspec/changes/git-repo-ssh-clone-check/.openspec.yaml b/openspec/changes/git-repo-ssh-clone-check/.openspec.yaml new file mode 100644 index 0000000..4a1c677 --- /dev/null +++ b/openspec/changes/git-repo-ssh-clone-check/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-22 diff --git a/openspec/changes/git-repo-ssh-clone-check/design.md b/openspec/changes/git-repo-ssh-clone-check/design.md new file mode 100644 index 0000000..5829d65 --- /dev/null +++ b/openspec/changes/git-repo-ssh-clone-check/design.md @@ -0,0 +1,45 @@ +## Context + +The current repository creation flow already supports cloning remote repositories via `remote_url` and can normalize pasted browser URLs. However, the UI asks for a full URL, which is awkward for the fixed provider `git.commumedia.org`. The requested behavior is to enter `owner` and `repo`, check whether the repository exists, and clone only if it does. + +## Goals / Non-Goals + +**Goals:** +- Accept SSH-only `owner` and `repo` inputs for cloning from `git.commumedia.org` +- Verify repository existence before clone +- Preserve full URL paste as a fallback path +- Preserve blank repository creation +- Reuse the existing repository create endpoint and shared dialog + +**Non-Goals:** +- Supporting multiple git providers +- Adding a remote repository discovery API +- Supporting HTTPS clone flow for the new structured path +- Changing repository storage or clone behavior beyond preflight validation + +## Decisions + +**1. Provider assumption** +- Hardcode `git.commumedia.org` for the structured clone path +- Build SSH URLs as `git@git.commumedia.org:{owner}/{repo}.git` + +**2. Existence check** +- Use `git ls-remote` on the constructed SSH URL before cloning +- If the command fails, surface a repository-not-found/inaccessible error and do not clone + +**3. UI structure** +- Keep the shared repository creation dialog as the single entry point +- In clone mode, collect `owner` and `repo` instead of asking for a full URL +- Keep an advanced paste-URL fallback for existing behavior and browser URL parsing +- Keep blank repository creation available in the same dialog + +**4. Backend behavior** +- Reuse `POST /projects/{project_id}/repositories` +- Add preflight logic before the existing `git clone --mirror` +- Leave the database schema unchanged + +## Risks / Trade-offs + +**[Risk] SSH auth may still fail even if the repo exists** → Mitigation: preflight error should be explicit and user-facing. +**[Risk] Command availability** → Mitigation: reuse the same `git` dependency already required for cloning. +**[Risk] UI complexity** → Mitigation: keep the dialog shared and minimal, with fallback URL paste. diff --git a/openspec/changes/git-repo-ssh-clone-check/proposal.md b/openspec/changes/git-repo-ssh-clone-check/proposal.md new file mode 100644 index 0000000..66e512c --- /dev/null +++ b/openspec/changes/git-repo-ssh-clone-check/proposal.md @@ -0,0 +1,25 @@ +## Why + +Repository creation already supports cloning from a remote URL, but the current UI only accepts a full URL. For the common fixed-provider case (`git.commumedia.org`), users should be able to enter `owner` and `repo` and have the app verify the repository exists before cloning. If the repository does not exist, the app should surface a clear error. Existing blank repository creation must remain available. + +## What Changes + +- Change the shared repository create dialog to support an SSH-only clone form with `owner` and `repo` +- Build the clone target as `git@git.commumedia.org:{owner}/{repo}.git` +- Preflight clone targets with `git ls-remote` before cloning +- Return a clear error when the repository is missing or inaccessible +- Keep the current full URL paste flow as an advanced fallback +- Keep blank repository creation as a fallback option + +## Capabilities + +### Modified Capabilities + +- `git-repo`: Repository creation UX and clone validation reuse the existing create endpoint and clone path + +## Impact + +- Frontend: `repository-create-dialog.tsx`, `git-repositories.tsx`, `repositories-settings-tab.tsx` +- Backend: `git_repositories.py` create endpoint clone preflight +- Docs: repository creation guidance must reflect SSH-only owner/repo input +- Tests: add coverage for SSH repo existence checks and fallback URL behavior diff --git a/openspec/changes/git-repo-ssh-clone-check/tasks.md b/openspec/changes/git-repo-ssh-clone-check/tasks.md new file mode 100644 index 0000000..542c7b6 --- /dev/null +++ b/openspec/changes/git-repo-ssh-clone-check/tasks.md @@ -0,0 +1,22 @@ +## 1. Backend - SSH Existence Check + +- [ ] 1.1 Add `git ls-remote` preflight to repository creation in `git_repositories.py` +- [ ] 1.2 Build SSH clone URL from `owner` and `repo` for `git.commumedia.org` +- [ ] 1.3 Return a clear error when the repository is missing or inaccessible + +## 2. Frontend - Structured Clone Form + +- [ ] 2.1 Update `RepositoryCreateDialog` clone mode to accept `owner` and `repo` +- [ ] 2.2 Keep advanced full-URL paste flow and blank repository fallback +- [ ] 2.3 Reuse the shared dialog from repository settings and repositories page + +## 3. Validation and Docs + +- [ ] 3.1 Update repository docs to explain SSH-only owner/repo input +- [ ] 3.2 Add tests for success, missing repo, and URL fallback behavior + +## 4. Quality Gates + +- [ ] 4.1 Run backend and frontend targeted tests +- [ ] 4.2 Run frontend typecheck and lint where applicable +- [ ] 4.3 Commit and push changes
- Clone an existing repository from a git server, or create a blank bare repo here. + Clone an existing repository from git.commumedia.org, or create a blank bare repo here.
SSH target: git@git.commumedia.org:{owner || "owner"}/{repoName || "repo"}.git
{formError}