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:
|
if repo is None or repo.project_id != project_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
||||||
|
|
||||||
if not os.path.exists(repo.path):
|
# Try local repo first
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
is_valid_git_repo = os.path.isdir(os.path.join(repo.path, ".git"))
|
||||||
|
|
||||||
try:
|
if is_valid_git_repo:
|
||||||
branches, default_branch = list_branches(repo.path)
|
try:
|
||||||
return BranchesResponse(
|
branches, default_branch = list_branches(repo.path)
|
||||||
branches=[
|
return BranchesResponse(
|
||||||
{
|
branches=[
|
||||||
"name": b.name,
|
{
|
||||||
"is_default": b.is_default,
|
"name": b.name,
|
||||||
"last_commit": b.last_commit,
|
"is_default": b.is_default,
|
||||||
}
|
"last_commit": b.last_commit,
|
||||||
for b in branches
|
}
|
||||||
],
|
for b in branches
|
||||||
default_branch=default_branch,
|
],
|
||||||
)
|
default_branch=default_branch,
|
||||||
except RuntimeError as e:
|
)
|
||||||
logger.error(
|
except RuntimeError as e:
|
||||||
"Failed to list branches for repo %s: %s",
|
logger.error(
|
||||||
repo_id,
|
"Failed to list branches for repo %s: %s",
|
||||||
str(e),
|
repo_id,
|
||||||
exc_info=True,
|
str(e),
|
||||||
)
|
exc_info=True,
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
)
|
||||||
|
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(
|
@router.post(
|
||||||
|
|||||||
@@ -57,8 +57,12 @@ export function WorkspaceCreateForm({
|
|||||||
: git.branches[0];
|
: git.branches[0];
|
||||||
setSelectedBranch(preferred);
|
setSelectedBranch(preferred);
|
||||||
setIsNewBranch(false);
|
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) ── */
|
/* ── Load projects (standalone mode only) ── */
|
||||||
const loadProjects = useCallback(async () => {
|
const loadProjects = useCallback(async () => {
|
||||||
@@ -236,6 +240,20 @@ export function WorkspaceCreateForm({
|
|||||||
<label>
|
<label>
|
||||||
<Icon name="branch" size="sm" /> Branch
|
<Icon name="branch" size="sm" /> Branch
|
||||||
</label>
|
</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
|
<select
|
||||||
value={selectedBranch}
|
value={selectedBranch}
|
||||||
onChange={(e) => handleBranchChange(e.target.value)}
|
onChange={(e) => handleBranchChange(e.target.value)}
|
||||||
@@ -258,14 +276,9 @@ export function WorkspaceCreateForm({
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
<option value="__new__">+ Create new branch...</option>
|
<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>
|
</select>
|
||||||
|
|
||||||
{/* New branch text input */}
|
{/* Text input for new branch or manual entry */}
|
||||||
{isNewBranch && (
|
{isNewBranch && (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -72,7 +72,11 @@ export interface UseGitRepoResult {
|
|||||||
/** Delete a branch. */
|
/** Delete a branch. */
|
||||||
deleteBranch: (name: string, force?: boolean) => Promise<void>;
|
deleteBranch: (name: string, force?: boolean) => Promise<void>;
|
||||||
/** Merge source into current (or target) branch. */
|
/** 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. */
|
/** Clear the current error. */
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
}
|
}
|
||||||
@@ -90,7 +94,7 @@ export function useGitRepo(
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const withLoading = useCallback(
|
const withLoading = useCallback(
|
||||||
async <T,>(fn: () => Promise<T>): Promise<T> => {
|
async <T>(fn: () => Promise<T>): Promise<T> => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -157,9 +161,7 @@ export function useGitRepo(
|
|||||||
const commit = useCallback(
|
const commit = useCallback(
|
||||||
async (message: string, files?: string[]) => {
|
async (message: string, files?: string[]) => {
|
||||||
if (!projectId || !repoId) return;
|
if (!projectId || !repoId) return;
|
||||||
await withLoading(() =>
|
await withLoading(() => commitChanges(projectId, repoId, message, files));
|
||||||
commitChanges(projectId, repoId, message, files),
|
|
||||||
);
|
|
||||||
await refreshStatus();
|
await refreshStatus();
|
||||||
},
|
},
|
||||||
[projectId, repoId, withLoading, refreshStatus],
|
[projectId, repoId, withLoading, refreshStatus],
|
||||||
|
|||||||
Reference in New Issue
Block a user