fix: use working clones for git repos
- Create normal working clones for remote repositories - Initialize blank repositories with a main branch - Align pull and branch helpers with unborn HEAD handling - Gate fetch/pull on repositories with a remote Quality gates: vitest repositories-settings-tab (passed); api pytest blocked by missing fastapi in environment
This commit is contained in:
@@ -115,6 +115,62 @@ def _preflight_remote_repository(remote_url: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
class GitRepositoryCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
remote_url: str | None = None
|
remote_url: str | None = None
|
||||||
@@ -243,7 +299,7 @@ async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
|
|||||||
response_model=GitRepositoryResponse,
|
response_model=GitRepositoryResponse,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
summary="Create a repository",
|
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(
|
async def create_repository(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
@@ -302,41 +358,16 @@ async def create_repository(
|
|||||||
os.makedirs(os.path.dirname(repo_path), exist_ok=True)
|
os.makedirs(os.path.dirname(repo_path), exist_ok=True)
|
||||||
|
|
||||||
if remote_url:
|
if remote_url:
|
||||||
# Clone as mirror
|
_clone_working_repository(remote_url, repo_path)
|
||||||
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")
|
|
||||||
else:
|
else:
|
||||||
# Init bare repo
|
_init_working_repository(repo_path)
|
||||||
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")
|
|
||||||
|
|
||||||
repo = GitRepository(
|
repo = GitRepository(
|
||||||
name=data.name,
|
name=data.name,
|
||||||
path=repo_path,
|
path=repo_path,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
owner_id=user_id,
|
owner_id=user_id,
|
||||||
is_mirror=bool(remote_url),
|
is_mirror=False,
|
||||||
remote_url=remote_url,
|
remote_url=remote_url,
|
||||||
)
|
)
|
||||||
session.add(repo)
|
session.add(repo)
|
||||||
|
|||||||
@@ -45,7 +45,10 @@ def get_status(repo_path: str) -> GitStatus:
|
|||||||
try:
|
try:
|
||||||
branch = _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
branch = _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
branch = "HEAD"
|
try:
|
||||||
|
branch = _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip()
|
||||||
|
except RuntimeError:
|
||||||
|
branch = "HEAD"
|
||||||
|
|
||||||
status = GitStatus(branch=branch)
|
status = GitStatus(branch=branch)
|
||||||
|
|
||||||
@@ -215,7 +218,8 @@ def pull(repo_path: str, branch: str | None = None) -> None:
|
|||||||
"""
|
"""
|
||||||
args = ["pull"]
|
args = ["pull"]
|
||||||
if branch:
|
if branch:
|
||||||
args.extend(["origin", branch])
|
args.append("origin")
|
||||||
|
args.append(branch)
|
||||||
_run_git_command(repo_path, *args)
|
_run_git_command(repo_path, *args)
|
||||||
|
|
||||||
|
|
||||||
@@ -279,4 +283,7 @@ def get_current_branch(repo_path: str) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
Current branch name
|
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()
|
||||||
|
|||||||
@@ -346,7 +346,20 @@ def list_branches(repo_path: str) -> tuple[list[BranchInfo], str]:
|
|||||||
)
|
)
|
||||||
default_branch = branch_name
|
default_branch = branch_name
|
||||||
except RuntimeError:
|
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
|
return branches, default_branch
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,13 @@ class TestGitStatus:
|
|||||||
assert "new.py" in status.untracked
|
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:
|
class TestBranchOperations:
|
||||||
"""Tests for branch management functions."""
|
"""Tests for branch management functions."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from src.api.git_repositories import _clone_working_repository, _init_working_repository
|
||||||
|
|
||||||
|
|
||||||
|
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"]
|
||||||
@@ -17,6 +17,7 @@ interface GitToolbarProps {
|
|||||||
repoId: string;
|
repoId: string;
|
||||||
currentBranch: string;
|
currentBranch: string;
|
||||||
branches: string[];
|
branches: string[];
|
||||||
|
hasRemote: boolean;
|
||||||
onBranchChange: (branch: string) => void;
|
onBranchChange: (branch: string) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
}
|
}
|
||||||
@@ -26,6 +27,7 @@ export const GitToolbar = ({
|
|||||||
repoId,
|
repoId,
|
||||||
currentBranch,
|
currentBranch,
|
||||||
branches,
|
branches,
|
||||||
|
hasRemote,
|
||||||
onBranchChange,
|
onBranchChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: GitToolbarProps) => {
|
}: GitToolbarProps) => {
|
||||||
@@ -55,6 +57,7 @@ export const GitToolbar = ({
|
|||||||
}, [loadStatus]);
|
}, [loadStatus]);
|
||||||
|
|
||||||
const handleFetch = async () => {
|
const handleFetch = async () => {
|
||||||
|
if (!hasRemote) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await fetchRepository(projectId, repoId);
|
await fetchRepository(projectId, repoId);
|
||||||
@@ -67,9 +70,10 @@ export const GitToolbar = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePull = async () => {
|
const handlePull = async () => {
|
||||||
|
if (!hasRemote) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await pullRepository(projectId, repoId, currentBranch);
|
await pullRepository(projectId, repoId, currentBranch || undefined);
|
||||||
await loadStatus();
|
await loadStatus();
|
||||||
onRefresh();
|
onRefresh();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -162,10 +166,10 @@ export const GitToolbar = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="toolbar-group">
|
<div className="toolbar-group">
|
||||||
<button
|
<button
|
||||||
className="toolbar-button"
|
className="toolbar-button"
|
||||||
onClick={handleFetch}
|
onClick={handleFetch}
|
||||||
disabled={loading}
|
disabled={loading || !hasRemote}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name="fetch" size="sm" /> Fetch
|
<Icon name="fetch" size="sm" /> Fetch
|
||||||
@@ -173,7 +177,7 @@ export const GitToolbar = ({
|
|||||||
<button
|
<button
|
||||||
className="toolbar-button"
|
className="toolbar-button"
|
||||||
onClick={handlePull}
|
onClick={handlePull}
|
||||||
disabled={loading}
|
disabled={loading || !hasRemote}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name="pull" size="sm" /> Pull
|
<Icon name="pull" size="sm" /> Pull
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ export const RepoWorkspace = () => {
|
|||||||
repoId={selectedRepoId}
|
repoId={selectedRepoId}
|
||||||
currentBranch={currentBranch}
|
currentBranch={currentBranch}
|
||||||
branches={branches}
|
branches={branches}
|
||||||
|
hasRemote={Boolean(selectedRepo?.remote_url)}
|
||||||
onBranchChange={(branch) => {
|
onBranchChange={(branch) => {
|
||||||
setCurrentBranch(branch);
|
setCurrentBranch(branch);
|
||||||
const newParams = new URLSearchParams(searchParams);
|
const newParams = new URLSearchParams(searchParams);
|
||||||
@@ -391,4 +392,3 @@ const FileBrowser = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 <branch>` 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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user