Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

from api.config import GZIP_MIN_BYTES
from api.models.responses import ErrorResponse
from api.routers import commit, file, manifest, meta
from api.routers import branches, commit, file, manifest, meta
from api.sse_compression import SSEGZipMiddleware
from api.static import make_static_router

Expand Down Expand Up @@ -84,6 +84,7 @@ def create_app(static_dir: Path | None = None) -> FastAPI:
app.include_router(meta.router)
app.include_router(file.router)
app.include_router(commit.router)
app.include_router(branches.router)
app.include_router(manifest.router)
app.include_router(make_static_router(static_dir or DEFAULT_STATIC_DIR))
return app
Expand Down
5 changes: 5 additions & 0 deletions api/models/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,8 @@ class CommitDetailResponse(BaseModel):
date: str # YYYY-MM-DD
subject: str
body: str


class BranchListResponse(BaseModel):
branches: list[str]
default: str | None
35 changes: 35 additions & 0 deletions api/routers/branches.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""GET /api/branches?src=<git-url> — remote branch list for the branch picker.

Remote git URLs only: local sources scan the working tree in place and ignore
branch, so there is nothing to choose. Uses git ls-remote (no clone) and reuses
the clone error taxonomy so failures map to clean HTTP statuses."""

from __future__ import annotations

from fastapi import APIRouter, HTTPException, Query

from api.models.responses import BranchListResponse
from api.services.clone import (
CloneError,
HostUnreachableError,
RepoNotFoundError,
list_remote_branches,
)
from api.services.source import SourceKind, classify

router = APIRouter(prefix="/api", tags=["branches"])


@router.get("/branches", response_model=BranchListResponse)
def get_branches(src: str = Query(...)) -> BranchListResponse:
if classify(src) is not SourceKind.REMOTE:
raise HTTPException(400, "branches are only available for remote git URLs")
try:
branches, default = list_remote_branches(src)
except RepoNotFoundError as e:
raise HTTPException(404, str(e)) from e
except HostUnreachableError as e:
raise HTTPException(502, str(e)) from e
except CloneError as e:
raise HTTPException(400, str(e)) from e
return BranchListResponse(branches=branches, default=default)
48 changes: 48 additions & 0 deletions api/services/clone.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ class HostUnreachableError(CloneError):
"RepoNotFoundError",
"clone_dir_for",
"ensure_clone",
"list_remote_branches",
"remove_clone",
]

Expand Down Expand Up @@ -569,3 +570,50 @@ def remove_clone(url: str, branch: str | None) -> bool:
return False
shutil.rmtree(target, ignore_errors=True)
return True


# ls-remote timeout: bounded so a black-holed remote can't wedge a request.
# 20s covers a slow-but-live remote; a real hang trips it and surfaces a clean
# HostUnreachableError to the caller.
_LS_REMOTE_TIMEOUT_S = 20


def list_remote_branches(url: str) -> tuple[list[str], str | None]:
"""Return ``(branch_names, default_branch)`` for a remote git URL via
``git ls-remote --symref`` — no clone required (strictly less capability
than the clone the server already does for the same URL).

``default_branch`` is the name HEAD points at (None if the remote has no
symbolic HEAD, e.g. an empty repo). Raises the same clean CloneError
subclasses ``ensure_clone`` raises so routers reuse one error taxonomy."""
try:
proc = subprocess.run(
["git", "ls-remote", "--symref", "--", url],
capture_output=True,
text=True,
check=False,
env=_git_env(),
timeout=_LS_REMOTE_TIMEOUT_S,
)
except FileNotFoundError as e:
raise CloneError("git executable not found on PATH") from e
except subprocess.TimeoutExpired as e:
raise HostUnreachableError("timed out reaching remote") from e
if proc.returncode != 0:
_maybe_raise_clean_clone_error(url, None, proc.stderr)
raise CloneError(f"git ls-remote failed: {proc.stderr.strip()}")

default: str | None = None
branches: list[str] = []
for line in proc.stdout.splitlines():
# Symref line: "ref: refs/heads/main\tHEAD"
if line.startswith("ref: ") and line.endswith("\tHEAD"):
target = line[len("ref: ") :].split("\t", 1)[0]
if target.startswith("refs/heads/"):
default = target[len("refs/heads/") :]
continue
# Ref line: "<sha>\trefs/heads/<name>"
parts = line.split("\t")
if len(parts) == 2 and parts[1].startswith("refs/heads/"):
branches.append(parts[1][len("refs/heads/") :])
return branches, default
24 changes: 24 additions & 0 deletions api/tests/test_branches_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Unit coverage for list_remote_branches (git ls-remote parsing)."""

from __future__ import annotations

from pathlib import Path

import pytest

from api.services.clone import RepoNotFoundError, list_remote_branches


def test_lists_heads_and_default(make_fake_remote, tmp_path: Path) -> None:
bare, _ = make_fake_remote(tmp_path) # bare repo, HEAD → main, has 'feature' too
branches, resolved_default = list_remote_branches(f"file://{bare}")
assert "main" in branches
assert "feature" in branches
assert resolved_default == "main"
# Only heads, no tags/HEAD pseudo-refs leak in.
assert all("/" not in b for b in branches)


def test_bogus_url_raises(tmp_path: Path) -> None:
with pytest.raises((RepoNotFoundError, Exception)):
list_remote_branches(f"file://{tmp_path / 'nope.git'}")
38 changes: 38 additions & 0 deletions api/tests/test_server_branches.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""TestClient coverage for GET /api/branches."""

from __future__ import annotations

from pathlib import Path

import pytest
from fastapi.testclient import TestClient

from api.app import create_app


@pytest.fixture()
def client(tmp_path: Path) -> TestClient:
static = tmp_path / "static"
static.mkdir()
(static / "index.html").write_text("x")
return TestClient(create_app(static_dir=static))


def test_branches_happy_path(
client: TestClient, make_fake_remote, tmp_path: Path
) -> None:
bare, _ = make_fake_remote(tmp_path)
r = client.get("/api/branches", params={"src": f"file://{bare}"})
assert r.status_code == 200
body = r.json()
assert "main" in body["branches"]
assert body["default"] == "main"


def test_branches_rejects_local_path(client: TestClient) -> None:
r = client.get("/api/branches", params={"src": "/tmp/some/local/path"})
assert r.status_code == 400


def test_branches_missing_src(client: TestClient) -> None:
assert client.get("/api/branches").status_code == 422 # FastAPI required-query
28 changes: 28 additions & 0 deletions app/src/api/branches.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// api/branches.ts — Client for GET /api/branches. Fetches the remote branch
// list for a git URL so the picker can offer a valid-for-this-repo dropdown
// instead of a free-text field. Remote URLs only (local sources have no branch).

import { URL_PARAMS } from '@/constants/urlParams';

export interface BranchList {
branches: string[];
default: string | null;
}

export async function fetchBranches(src: string): Promise<BranchList> {
const url = new URL('/api/branches', window.location.origin);
url.searchParams.set(URL_PARAMS.SRC, src);
const resp = await fetch(url.toString());
if (!resp.ok) {
let message = `branch lookup failed (${resp.status})`;
try {
const body = (await resp.json()) as { detail?: string };
if (body?.detail) message = body.detail;
} catch (_) {
/* non-JSON error body: keep the status-based message */
}
throw new Error(message);
}
const body = (await resp.json()) as BranchList;
return { branches: body.branches ?? [], default: body.default ?? null };
}
11 changes: 10 additions & 1 deletion app/src/api/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,9 @@ export type ScanProgressEvent = Extract<
*/
export function streamManifest(
url: string,
EventSourceImpl: typeof EventSource = EventSource
opts: { signal?: AbortSignal; EventSourceImpl?: typeof EventSource } = {}
): AsyncIterable<ScanStreamEvent> {
const EventSourceImpl = opts.EventSourceImpl ?? EventSource;
return {
[Symbol.asyncIterator](): AsyncIterator<ScanStreamEvent> {
const es = new EventSourceImpl(url);
Expand Down Expand Up @@ -188,6 +189,14 @@ export function streamManifest(
}
};

// Abort: close the stream cleanly (done, not error) so the consumer's
// for-await exits without a manifest. loadSource treats signal.aborted as
// a user cancel, not a failure.
if (opts.signal) {
if (opts.signal.aborted) finish();
else opts.signal.addEventListener('abort', () => finish(), { once: true });
}

// Parse an event's JSON data, ending the stream with an error (rather
// than throwing into the swallowed event listener, which would leave the
// iterator hanging forever) if the payload is malformed/truncated.
Expand Down
10 changes: 9 additions & 1 deletion app/src/components/LoadingOverlay/LoadingOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,16 @@
// visible advancement maps to a real NDJSON phase event (cloning, scanning,
// skeleton, building) or the client-side decoration pass — no wall-clock timers.
// The step vocabulary lives in constants/loadingSteps.
//
// Narrow role by design: a load driven from <ProjectsView> (every switch)
// renders its OWN inline progress and this overlay stays hidden — two
// full-viewport surfaces stacking on the same load would leave the user
// looking at this overlay's no-controls backdrop on top of the view's Cancel
// button. This overlay now only ever shows for a deep-link cold boot (no
// view open yet).

import './LoadingOverlay.css';
import { LOADING_OVERLAY } from '@/state/stores/ui';
import { LOADING_OVERLAY, PROJECTS_VIEW } from '@/state/stores/ui';
import { PENDING_SOURCE_LABEL } from '@/state/stores/source';
import { SourceKind } from '@/utils/sources';
import {
Expand Down Expand Up @@ -52,6 +59,7 @@ export function LoadingOverlay() {
stepTails: lo.stepTails,
};
if (!s.visible || !s.activeStep) return null;
if (PROJECTS_VIEW.value.visible) return null;

const activeStep = s.activeStep;
const activeIdx = LOADING_STEPS.indexOf(activeStep);
Expand Down
6 changes: 0 additions & 6 deletions app/src/components/PaneTabs/PaneTabs.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,6 @@
display: none;
}

/* Modal context (source picker): the strip sits above form fields and needs a
gap below that the in-pane usage doesn't. */
.pane-tabs--modal {
margin-bottom: 10px;
}

.pane-tab {
display: inline-flex;
align-items: center;
Expand Down
25 changes: 25 additions & 0 deletions app/src/constants/loadingSteps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
// sites and the data-step attribute stay self-documenting; the overlay and the
// uiState setters both render/advance from these.

import { ScanPhase } from '@/api/manifest';
import { SourceKind } from '@/utils/sources';

export enum LoadingStep {
Resolving = 'resolving',
Cloning = 'cloning',
Expand Down Expand Up @@ -46,3 +49,25 @@ export const LOADING_STEP_LABELS: Record<LoadingStep, string> = {
[LoadingStep.Building]: 'Building city',
[LoadingStep.Decorating]: 'Adding decorations',
};

/**
* Map a scan-stream phase to the step it represents, given the source kind
* (local skips resolving/cloning — see LoadingOverlay's kind-based hiding).
* Shared by the loading-overlay reactions and ProjectsView's inline progress
* so the phase→step mapping has exactly one definition.
*/
export function stepForPhase(phase: ScanPhase | null, kind: SourceKind): LoadingStep {
switch (phase) {
case ScanPhase.CloneProgress:
return LoadingStep.Cloning;
case ScanPhase.ScanProgress:
return LoadingStep.Scanning;
case ScanPhase.PartialManifest:
return LoadingStep.Skeleton;
case ScanPhase.CompleteManifest:
return LoadingStep.Building;
default:
// phase === null: just-started, no stream event yet.
return kind === SourceKind.Local ? LoadingStep.Scanning : LoadingStep.Resolving;
}
}
Loading