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:
@@ -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 couldn’t be loaded */}
|
||||
{git.error && git.branches.length === 0 && selectedRepo && (
|
||||
<p
|
||||
className="muted"
|
||||
style={{
|
||||
fontSize: "var(--font-size-xs)",
|
||||
marginBottom: "0.25rem",
|
||||
}}
|
||||
>
|
||||
Couldn’t 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"
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user