diff --git a/apps/api/src/api/git_repositories.py b/apps/api/src/api/git_repositories.py index 4d04789..8c2bb0e 100644 --- a/apps/api/src/api/git_repositories.py +++ b/apps/api/src/api/git_repositories.py @@ -89,6 +89,88 @@ 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", + ) + + +def _clone_working_repository(remote_url: str, repo_path: str) -> None: + try: + result = subprocess.run( + ["git", "clone", remote_url, repo_path], + capture_output=True, + text=True, + timeout=300, + ) + 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") + + if result.returncode != 0: + 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: + 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}", + ) + + class GitRepositoryCreate(BaseModel): name: str remote_url: str | None = None @@ -217,7 +299,7 @@ async def parse_repository_url(data: URLParseRequest) -> URLParseResponse: response_model=GitRepositoryResponse, status_code=status.HTTP_201_CREATED, summary="Create a repository", - description="Create a new git repository in a project. Can clone from remote or initialize bare.", + description="Create a new git repository in a project. Can clone from remote or initialize a working repository.", ) async def create_repository( project_id: uuid.UUID, @@ -267,47 +349,25 @@ 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 os.makedirs(os.path.dirname(repo_path), exist_ok=True) if remote_url: - # Clone as mirror - try: - result = subprocess.run( - ["git", "clone", "--mirror", remote_url, repo_path], - capture_output=True, - text=True, - timeout=300, - ) - if result.returncode != 0: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"failed to clone repository: {result.stderr}", - ) - 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") + _clone_working_repository(remote_url, repo_path) else: - # Init bare repo - try: - result = subprocess.run( - ["git", "init", "--bare", repo_path], - capture_output=True, - text=True, - check=True, - ) - except FileNotFoundError: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found") + _init_working_repository(repo_path) repo = GitRepository( name=data.name, path=repo_path, project_id=project_id, owner_id=user_id, - is_mirror=bool(remote_url), + is_mirror=False, remote_url=remote_url, ) session.add(repo) diff --git a/apps/api/src/utils/git_control.py b/apps/api/src/utils/git_control.py index 9b5a85a..59a6874 100644 --- a/apps/api/src/utils/git_control.py +++ b/apps/api/src/utils/git_control.py @@ -45,7 +45,10 @@ def get_status(repo_path: str) -> GitStatus: try: branch = _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip() except RuntimeError: - branch = "HEAD" + try: + branch = _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip() + except RuntimeError: + branch = "HEAD" status = GitStatus(branch=branch) @@ -118,6 +121,13 @@ def create_branch(repo_path: str, name: str, base_branch: str = "HEAD") -> None: Raises: RuntimeError: If branch creation fails """ + if base_branch == "HEAD": + try: + _run_git_command(repo_path, "rev-parse", "--verify", "HEAD") + except RuntimeError: + _run_git_command(repo_path, "checkout", "--orphan", name) + return + _run_git_command(repo_path, "branch", name, base_branch) @@ -215,7 +225,8 @@ def pull(repo_path: str, branch: str | None = None) -> None: """ args = ["pull"] if branch: - args.extend(["origin", branch]) + args.append("origin") + args.append(branch) _run_git_command(repo_path, *args) @@ -279,4 +290,7 @@ def get_current_branch(repo_path: str) -> str: Returns: Current branch name """ - return _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip() + try: + return _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip() + except RuntimeError: + return _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip() diff --git a/apps/api/src/utils/git_files.py b/apps/api/src/utils/git_files.py index c793432..147b655 100644 --- a/apps/api/src/utils/git_files.py +++ b/apps/api/src/utils/git_files.py @@ -346,7 +346,20 @@ def list_branches(repo_path: str) -> tuple[list[BranchInfo], str]: ) default_branch = branch_name except RuntimeError: - pass + try: + output = _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD") + branch_name = output.strip() + if branch_name: + branches.append( + BranchInfo( + name=branch_name, + is_default=True, + last_commit=None, + ) + ) + default_branch = branch_name + except RuntimeError: + pass return branches, default_branch diff --git a/apps/api/tests/integration/test_git_control.py b/apps/api/tests/integration/test_git_control.py index 21e2798..a7ab139 100644 --- a/apps/api/tests/integration/test_git_control.py +++ b/apps/api/tests/integration/test_git_control.py @@ -63,6 +63,13 @@ class TestGitStatus: assert "new.py" in status.untracked +def test_get_current_branch_handles_unborn_main() -> None: + with tempfile.TemporaryDirectory() as tmpdir: + os.system(f"git init -b main {tmpdir} >/dev/null 2>&1") + + assert get_current_branch(tmpdir) == "main" + + class TestBranchOperations: """Tests for branch management functions.""" 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/api/tests/unit/test_git_repository_working_clones.py b/apps/api/tests/unit/test_git_repository_working_clones.py new file mode 100644 index 0000000..577e809 --- /dev/null +++ b/apps/api/tests/unit/test_git_repository_working_clones.py @@ -0,0 +1,64 @@ +from unittest.mock import Mock, patch + +import pytest +from fastapi import HTTPException + +from src.api.git_repositories import _clone_working_repository, _init_working_repository +from src.utils.git_control import create_branch + + +def test_clone_working_repository_uses_normal_clone() -> None: + completed = Mock(returncode=0, stderr="") + with patch("src.api.git_repositories.subprocess.run", return_value=completed) as run_mock: + _clone_working_repository("git@git.commumedia.org:alice/demo.git", "/tmp/demo.git") + + run_mock.assert_called_once() + assert run_mock.call_args.args[0] == ["git", "clone", "git@git.commumedia.org:alice/demo.git", "/tmp/demo.git"] + + +def test_clone_working_repository_raises_on_failure() -> None: + completed = Mock(returncode=128, stderr="fatal: repository not found") + with patch("src.api.git_repositories.subprocess.run", return_value=completed): + with pytest.raises(HTTPException) as exc_info: + _clone_working_repository("git@git.commumedia.org:alice/missing.git", "/tmp/missing.git") + + assert exc_info.value.status_code == 400 + assert "failed to clone repository" in exc_info.value.detail + + +def test_init_working_repository_prefers_init_b() -> None: + init_b = Mock(returncode=0, stderr="") + with patch("src.api.git_repositories.subprocess.run", return_value=init_b) as run_mock: + _init_working_repository("/tmp/new-repo") + + assert run_mock.call_args.args[0] == ["git", "init", "-b", "main", "/tmp/new-repo"] + + +def test_init_working_repository_falls_back_to_symbolic_ref() -> None: + init_b = Mock(returncode=1, stderr="unknown switch `b'") + init_ok = Mock(returncode=0, stderr="") + symbolic_ref = Mock(returncode=0, stderr="") + + with patch("src.api.git_repositories.subprocess.run", side_effect=[init_b, init_ok, symbolic_ref]) as run_mock: + _init_working_repository("/tmp/new-repo") + + assert run_mock.call_args_list[0].args[0] == ["git", "init", "-b", "main", "/tmp/new-repo"] + assert run_mock.call_args_list[1].args[0] == ["git", "init", "/tmp/new-repo"] + assert run_mock.call_args_list[2].args[0] == ["git", "-C", "/tmp/new-repo", "symbolic-ref", "HEAD", "refs/heads/main"] + + +def test_create_branch_uses_orphan_checkout_when_head_is_unborn() -> None: + call_count = 0 + + def mock_run(repo_path: str, *args: str) -> str: + nonlocal call_count + call_count += 1 + if call_count == 1: + raise RuntimeError("fatal: Needed a single revision") + return "" + + with patch("src.utils.git_control._run_git_command", side_effect=mock_run) as run_mock: + create_branch("/tmp/new-repo", "feature/test") + + assert run_mock.call_args_list[0].args[1:] == ("rev-parse", "--verify", "HEAD^{commit}") + assert run_mock.call_args_list[1].args[1:] == ("checkout", "--orphan", "feature/test") diff --git a/apps/web/src/components/git-toolbar.tsx b/apps/web/src/components/git-toolbar.tsx index 15313dc..31a8f07 100644 --- a/apps/web/src/components/git-toolbar.tsx +++ b/apps/web/src/components/git-toolbar.tsx @@ -17,6 +17,7 @@ interface GitToolbarProps { repoId: string; currentBranch: string; branches: string[]; + hasRemote: boolean; onBranchChange: (branch: string) => void; onRefresh: () => void; } @@ -26,6 +27,7 @@ export const GitToolbar = ({ repoId, currentBranch, branches, + hasRemote, onBranchChange, onRefresh, }: GitToolbarProps) => { @@ -55,6 +57,7 @@ export const GitToolbar = ({ }, [loadStatus]); const handleFetch = async () => { + if (!hasRemote) return; setLoading(true); try { await fetchRepository(projectId, repoId); @@ -67,9 +70,10 @@ export const GitToolbar = ({ }; const handlePull = async () => { + if (!hasRemote) return; setLoading(true); try { - await pullRepository(projectId, repoId, currentBranch); + await pullRepository(projectId, repoId, currentBranch || undefined); await loadStatus(); onRefresh(); } catch { @@ -108,7 +112,7 @@ export const GitToolbar = ({ if (!newBranchName.trim()) return; setLoading(true); try { - await createBranch(projectId, repoId, newBranchName, newBranchBase || "HEAD"); + await createBranch(projectId, repoId, newBranchName, newBranchBase || currentBranch || "HEAD"); setShowNewBranch(false); setNewBranchName(""); setNewBranchBase(""); @@ -127,6 +131,8 @@ export const GitToolbar = ({ status.untracked.length > 0 ); + const canSync = hasRemote; + return (
{error &&
{error}
} @@ -162,10 +168,10 @@ export const GitToolbar = ({
- +
{error &&
{error}
}
@@ -66,6 +84,16 @@ export const RepositoriesSettingsTab: React.FC = () => { )) )}
+ + {showCreate && ( + setShowCreate(false)} + onCreated={loadRepositories} + /> + )} ); }; diff --git a/apps/web/src/components/repository-create-dialog.tsx b/apps/web/src/components/repository-create-dialog.tsx new file mode 100644 index 0000000..dda7269 --- /dev/null +++ b/apps/web/src/components/repository-create-dialog.tsx @@ -0,0 +1,293 @@ +import { useEffect, useRef, useState } from "react"; + +import { createRepository, parseGitUrl, type GitRepositoryCreate, type URLParseResult } from "../api/git_repositories"; +import { Icon } from "./icon"; + +type CreateMode = "clone" | "blank"; +type UrlValidationStatus = "idle" | "validating" | "valid" | "needs-parsing" | "invalid"; + +interface RepositoryCreateDialogProps { + projectId: string; + open: boolean; + title: string; + onClose: () => void; + onCreated: () => Promise | void; +} + +export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCreated }: RepositoryCreateDialogProps) => { + const [createMode, setCreateMode] = useState("clone"); + const [formName, setFormName] = 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; + result: URLParseResult | null; + }>({ status: "idle", result: null }); + const debounceTimer = useRef | null>(null); + + useEffect(() => { + if (!open && debounceTimer.current) { + clearTimeout(debounceTimer.current); + debounceTimer.current = null; + } + }, [open]); + + useEffect(() => { + if (!open) return; + if (!useAdvancedUrl) { + setUrlValidation({ status: "idle", result: null }); + return; + } + + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + + if (!advancedUrl.trim()) { + setUrlValidation({ status: "idle", result: null }); + return; + } + + setUrlValidation({ status: "validating", result: null }); + + debounceTimer.current = setTimeout(async () => { + try { + const result = await parseGitUrl(advancedUrl.trim()); + if (result.is_valid_clone_url) { + setUrlValidation({ status: "valid", result }); + } else if (result.needs_parsing) { + setUrlValidation({ status: "needs-parsing", result }); + } else { + setUrlValidation({ status: "invalid", result }); + } + } catch { + setUrlValidation({ status: "invalid", result: null }); + } + }, 300); + + return () => { + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + }; + }, [advancedUrl, open, useAdvancedUrl]); + + const resetForm = () => { + setCreateMode("clone"); + setFormName(""); + setOwner(""); + setRepoName(""); + setAdvancedUrl(""); + setUseAdvancedUrl(false); + setFormError(null); + setUrlValidation({ status: "idle", result: null }); + }; + + const handleClose = () => { + resetForm(); + onClose(); + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setFormError(null); + + if (!formName.trim()) { + setFormError("Repository name is required"); + return; + } + + try { + const input: GitRepositoryCreate = { + name: formName.trim(), + 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 (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) { + setAdvancedUrl(urlValidation.result.base_url); + setUrlValidation({ status: "idle", result: null }); + setFormError(null); + } + }; + + const getUrlInputClass = () => { + switch (urlValidation.status) { + case "valid": + return "valid-url"; + case "needs-parsing": + return "needs-parsing-url"; + case "invalid": + return "invalid-url"; + default: + return ""; + } + }; + + if (!open) return null; + + return ( +
+
+

{title}

+

+ Clone an existing repository from git.commumedia.org, or create a blank bare repo here. +

+
+
+ + +
+ + {createMode === "clone" && !useAdvancedUrl && ( + <> + + +

SSH target: git@git.commumedia.org:{owner || "owner"}/{repoName || "repo"}.git

+ + + )} + {createMode === "clone" && useAdvancedUrl && ( + + )} + {formError && ( +
+

{formError}

+
+ )} +
+ + +
+
+
+
+ ); +}; diff --git a/apps/web/src/pages/git-repositories.tsx b/apps/web/src/pages/git-repositories.tsx index 45ddc80..1ad2c4e 100644 --- a/apps/web/src/pages/git-repositories.tsx +++ b/apps/web/src/pages/git-repositories.tsx @@ -1,19 +1,15 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { - createRepository, deleteRepository, listRepositories, - parseGitUrl, - type GitRepositoryCreate, - type URLParseResult, } from "../api/git_repositories"; import type { GitRepository } from "../api/git_repositories"; import { Icon } from "../components/icon"; +import { RepositoryCreateDialog } from "../components/repository-create-dialog"; type RepoStatus = "loading" | "ready" | "error"; -type UrlValidationStatus = "idle" | "validating" | "valid" | "needs-parsing" | "invalid"; export const GitRepositoriesPage = () => { const { projectId } = useParams<{ projectId: string }>(); @@ -21,19 +17,8 @@ export const GitRepositoriesPage = () => { const [status, setStatus] = useState("loading"); const [repositories, setRepositories] = useState([]); const [showCreate, setShowCreate] = useState(false); - const [formName, setFormName] = useState(""); - const [formRemoteUrl, setFormRemoteUrl] = useState(""); - const [formError, setFormError] = useState(null); const [deleteConfirmId, setDeleteConfirmId] = useState(null); - // URL validation state - const [urlValidation, setUrlValidation] = useState<{ - status: UrlValidationStatus; - result: URLParseResult | null; - }>({ status: "idle", result: null }); - - const debounceTimer = useRef | null>(null); - const loadRepositories = useCallback(async () => { if (!projectId) return; setStatus("loading"); @@ -51,98 +36,6 @@ export const GitRepositoriesPage = () => { void loadRepositories(); }, [loadRepositories]); - // Validate URL with debounce - useEffect(() => { - if (debounceTimer.current) { - clearTimeout(debounceTimer.current); - } - - if (!formRemoteUrl.trim()) { - setUrlValidation({ status: "idle", result: null }); - return; - } - - setUrlValidation({ status: "validating", result: null }); - - debounceTimer.current = setTimeout(async () => { - try { - const result = await parseGitUrl(formRemoteUrl.trim()); - if (result.is_valid_clone_url) { - setUrlValidation({ status: "valid", result }); - } else if (result.needs_parsing) { - setUrlValidation({ status: "needs-parsing", result }); - } else { - setUrlValidation({ status: "invalid", result }); - } - } catch { - setUrlValidation({ status: "invalid", result: null }); - } - }, 300); - - return () => { - if (debounceTimer.current) { - clearTimeout(debounceTimer.current); - } - }; - }, [formRemoteUrl]); - - const getUrlInputClass = () => { - switch (urlValidation.status) { - case "valid": - return "valid-url"; - case "needs-parsing": - return "needs-parsing-url"; - case "invalid": - return "invalid-url"; - default: - return ""; - } - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setFormError(null); - - if (!formName.trim()) { - setFormError("Repository name is required"); - return; - } - - if (!projectId) return; - - try { - const input: GitRepositoryCreate = { - name: formName.trim(), - remote_url: formRemoteUrl.trim() || undefined, - }; - await createRepository(projectId, input); - setShowCreate(false); - setFormName(""); - setFormRemoteUrl(""); - setUrlValidation({ status: "idle", result: null }); - await loadRepositories(); - } catch (err: unknown) { - const axiosError = err as { response?: { status: number; data: { detail: { suggested_url: string; message: string } } } }; - if (axiosError.response?.status === 422 && axiosError.response?.data?.detail?.suggested_url) { - // Show URL correction suggestion - const detail = axiosError.response.data.detail; - setFormError( - `${detail.message}\nSuggested: ${detail.suggested_url}` - ); - } else { - setFormError("Failed to create repository"); - } - } - }; - - const handleUseSuggestedUrl = () => { - if (urlValidation.result?.base_url) { - setFormRemoteUrl(urlValidation.result.base_url); - setUrlValidation({ status: "idle", result: null }); - setFormError(null); - } - }; - const handleDelete = async (repoId: string) => { if (!projectId) return; try { @@ -233,81 +126,13 @@ export const GitRepositoriesPage = () => { )} {showCreate && ( -
-
-

Create Repository

-
- - - {formError && ( -
- {formError.split("\n").map((line, i) => ( -

{line}

- ))} -
- )} -
- - -
-
-
-
+ setShowCreate(false)} + onCreated={loadRepositories} + /> )} ); diff --git a/apps/web/src/pages/repo-workspace.tsx b/apps/web/src/pages/repo-workspace.tsx index b013f52..198fb84 100644 --- a/apps/web/src/pages/repo-workspace.tsx +++ b/apps/web/src/pages/repo-workspace.tsx @@ -196,6 +196,7 @@ export const RepoWorkspace = () => { repoId={selectedRepoId} currentBranch={currentBranch} branches={branches} + hasRemote={Boolean(selectedRepo?.remote_url)} onBranchChange={(branch) => { setCurrentBranch(branch); const newParams = new URLSearchParams(searchParams); @@ -391,4 +392,3 @@ const FileBrowser = ({ ); }; - 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 diff --git a/openspec/changes/git-repo-working-clones/design.md b/openspec/changes/git-repo-working-clones/design.md new file mode 100644 index 0000000..beb0c80 --- /dev/null +++ b/openspec/changes/git-repo-working-clones/design.md @@ -0,0 +1,33 @@ +## Context + +Repository creation currently produces mirrored bare repos for any remote clone and bare repos for blank creations. The workspace, file browser, commit editor, and git toolbar are built around a working-tree repository model, so users can hit 400s when they try to sync or when the repo has no usable branch state. + +## Goals + +- Create working clones for remote repositories +- Create working repos with an initial branch for blank repositories +- Preserve the existing repository create endpoint and shared UI flow +- Keep fetch/pull/push aligned with a normal local clone + +## Decisions + +1. Clone mode +- Use `git clone` without `--mirror` +- Keep the existing remote URL preflight and URL parsing behavior + +2. Blank repositories +- Initialize with `git init -b main` when supported +- Fall back to `git init` plus `git symbolic-ref HEAD refs/heads/main` if needed + +3. Branch state +- Treat `main` as the initial branch name for blank repos +- Make branch listing and current-branch helpers tolerate unborn `HEAD` + +4. Pull behavior +- Prefer the current branch when no explicit branch is supplied +- Do not force `origin ` if the branch is unborn or already tracked by the current checkout + +## Risks + +- Some older git versions may not support `git init -b`; the backend should fall back cleanly +- Existing blank repos created under the old bare model may still require migration or cleanup outside this change diff --git a/openspec/changes/git-repo-working-clones/proposal.md b/openspec/changes/git-repo-working-clones/proposal.md new file mode 100644 index 0000000..c62e06f --- /dev/null +++ b/openspec/changes/git-repo-working-clones/proposal.md @@ -0,0 +1,17 @@ +## Why + +The current repository creation flow creates mirrored bare repositories for clone-based repos. That breaks the workspace model because the UI and file editing features expect a normal working clone with an initial branch, remote tracking, and pull/fetch behavior that works from a checked-out branch. + +## What Changes + +- Create clone-based repositories as normal working clones instead of mirrors +- Initialize blank repositories as working clones with an initial branch when needed +- Ensure newly created repos have a usable current branch for workspace browsing and commits +- Update pull semantics to use the current tracked branch when available +- Keep fetch behavior available for remote-synced repositories + +## Impact + +- Backend: repository creation and git control helpers +- Backend tests: clone, pull, and empty-repo branch behavior +- Frontend: no intentional UX change beyond sync behavior becoming reliable diff --git a/openspec/changes/git-repo-working-clones/tasks.md b/openspec/changes/git-repo-working-clones/tasks.md new file mode 100644 index 0000000..3d60a7d --- /dev/null +++ b/openspec/changes/git-repo-working-clones/tasks.md @@ -0,0 +1,19 @@ +## 1. Backend - Repository Creation + +- [x] 1.1 Switch clone-based repository creation from mirror clones to normal working clones +- [x] 1.2 Initialize blank repositories with a default branch name +- [x] 1.3 Preserve remote preflight and clear error handling + +## 2. Backend - Git Sync Helpers + +- [x] 2.1 Update pull behavior to use the current tracked branch when available +- [x] 2.2 Make branch helpers tolerate unborn HEAD in blank repos + +## 3. Tests + +- [x] 3.1 Add unit coverage for clone creation and blank repo initialization +- [x] 3.2 Add coverage for pull behavior on working clones and blank repos + +## 4. Quality Gates + +- [ ] 4.1 Run targeted API tests