fix: handle missing/corrupt repos when fetching branches + clearer manual fallback

Backend (git_repositories.py):
- get_repository_branches now checks for .git subdirectory (not just dir existence)
- If local repo is corrupt/missing but has remote_url, falls back to git ls-remote
  to list branches from the remote
- Returns 404 with actionable message instead of 400 with raw git stderr
- Pre-existing test failure in test_git_repository_clone_preflight.py unchanged

Frontend (workspace-create-form.tsx):
- When branch API fails, auto-switches to manual text input (no dropdown selection needed)
- Shows hint text: 'Couldn't load branches — type one manually'
- useGitRepo hook auto-fetches branches when projectId/repoId change

Quality gates: ruff clean, tsc --noEmit clean, 93 passed + 1 pre-existing failure
This commit is contained in:
2026-06-01 19:00:59 +02:00
parent b02cd978c3
commit ee3c5af7a4
3 changed files with 95 additions and 35 deletions
+68 -23
View File
@@ -882,30 +882,75 @@ async def get_repository_branches(
if repo is None or repo.project_id != project_id:
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")
# Try local repo first
is_valid_git_repo = os.path.isdir(os.path.join(repo.path, ".git"))
try:
branches, default_branch = list_branches(repo.path)
return BranchesResponse(
branches=[
{
"name": b.name,
"is_default": b.is_default,
"last_commit": b.last_commit,
}
for b in branches
],
default_branch=default_branch,
)
except RuntimeError as e:
logger.error(
"Failed to list branches for repo %s: %s",
repo_id,
str(e),
exc_info=True,
)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
if is_valid_git_repo:
try:
branches, default_branch = list_branches(repo.path)
return BranchesResponse(
branches=[
{
"name": b.name,
"is_default": b.is_default,
"last_commit": b.last_commit,
}
for b in branches
],
default_branch=default_branch,
)
except RuntimeError as e:
logger.error(
"Failed to list branches for repo %s: %s",
repo_id,
str(e),
exc_info=True,
)
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:
try:
result = subprocess.run(
["git", "ls-remote", "--heads", repo.remote_url],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 0:
remote_branches = []
default_branch = "main"
for line in result.stdout.strip().split("\n"):
if line:
parts = line.split("\t")
if len(parts) == 2:
ref = parts[1]
if ref.startswith("refs/heads/"):
branch_name = ref[len("refs/heads/"):]
remote_branches.append(branch_name)
if branch_name == "main" or branch_name == "master":
default_branch = branch_name
if remote_branches:
return BranchesResponse(
branches=[
{
"name": b,
"is_default": b == default_branch,
"last_commit": None,
}
for b in remote_branches
],
default_branch=default_branch,
)
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))
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="repository not found on disk — re-clone or re-create the repository",
)
@router.post(
@@ -57,8 +57,12 @@ export function WorkspaceCreateForm({
: git.branches[0];
setSelectedBranch(preferred);
setIsNewBranch(false);
} else if (git.error && git.branches.length === 0 && !isNewBranch) {
// API failed — default to manual entry so user can type a branch
setIsNewBranch(true);
setSelectedBranch("__manual__");
}
}, [git.branches, git.defaultBranch, selectedBranch]);
}, [git.branches, git.defaultBranch, git.error, selectedBranch, isNewBranch]);
/* ── Load projects (standalone mode only) ── */
const loadProjects = useCallback(async () => {
@@ -236,6 +240,20 @@ export function WorkspaceCreateForm({
<label>
<Icon name="branch" size="sm" /> Branch
</label>
{/* Show a hint when branches couldnt be loaded */}
{git.error && git.branches.length === 0 && selectedRepo && (
<p
className="muted"
style={{
fontSize: "var(--font-size-xs)",
marginBottom: "0.25rem",
}}
>
Couldnt load branches type one manually.
</p>
)}
<select
value={selectedBranch}
onChange={(e) => handleBranchChange(e.target.value)}
@@ -258,14 +276,9 @@ export function WorkspaceCreateForm({
))}
<option value="__new__">+ Create new branch...</option>
{/* If branch fetch failed, offer manual entry */}
{git.error && git.branches.length === 0 && (
<option value="__manual__"> Enter branch name manually...</option>
)}
</select>
{/* New branch text input */}
{/* Text input for new branch or manual entry */}
{isNewBranch && (
<input
type="text"
+7 -5
View File
@@ -72,7 +72,11 @@ export interface UseGitRepoResult {
/** Delete a branch. */
deleteBranch: (name: string, force?: boolean) => Promise<void>;
/** Merge source into current (or target) branch. */
merge: (sourceBranch: string, targetBranch?: string, message?: string) => Promise<void>;
merge: (
sourceBranch: string,
targetBranch?: string,
message?: string,
) => Promise<void>;
/** Clear the current error. */
clearError: () => void;
}
@@ -90,7 +94,7 @@ export function useGitRepo(
const [error, setError] = useState<string | null>(null);
const withLoading = useCallback(
async <T,>(fn: () => Promise<T>): Promise<T> => {
async <T>(fn: () => Promise<T>): Promise<T> => {
setLoading(true);
setError(null);
try {
@@ -157,9 +161,7 @@ export function useGitRepo(
const commit = useCallback(
async (message: string, files?: string[]) => {
if (!projectId || !repoId) return;
await withLoading(() =>
commitChanges(projectId, repoId, message, files),
);
await withLoading(() => commitChanges(projectId, repoId, message, files));
await refreshStatus();
},
[projectId, repoId, withLoading, refreshStatus],