fix: resolve false 404 errors and stale repo context during multi-repo switching on Windows (#633)
Some checks are pending
CI / quality (push) Waiting to run
CI / tests (push) Waiting to run
CI / e2e (push) Waiting to run
CI / Save PR Metadata (push) Blocked by required conditions
CI / CI Gate (push) Blocked by required conditions

* fix: resolve false 404s and stale repo context during multi-repo switching on Windows

* test(e2e): add repo-switching tests — hold-queue 503, ?project= URL, Windows path normalization

* test(e2e): fix repo-switching specs — use live backend with ?server= param
This commit is contained in:
Yogesh Singh 2026-04-11 00:44:16 +05:45 committed by GitHub
parent 100858f8c8
commit d87744fffc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 381 additions and 84 deletions

View file

@ -0,0 +1,168 @@
import { test, expect } from '@playwright/test';
/**
* E2E tests for the repo-switching and false-404 fixes.
*
* Most tests use the live backend (same pattern as multi-repo-scoping.spec.ts).
* The 503 hold-queue test uses route interception to simulate a slow analysis.
*/
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747';
const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:5173';
let firstRepoName: string;
test.beforeAll(async () => {
if (process.env.E2E) {
try {
const res = await fetch(`${BACKEND_URL}/api/repos`);
const repos = await res.json();
firstRepoName = repos[0]?.name ?? '';
} catch {
firstRepoName = '';
}
return;
}
try {
const [backendRes, frontendRes] = await Promise.allSettled([
fetch(`${BACKEND_URL}/api/repos`),
fetch(FRONTEND_URL),
]);
if (
backendRes.status === 'rejected' ||
(backendRes.status === 'fulfilled' && !backendRes.value.ok)
) {
test.skip(true, 'gitnexus serve not available');
return;
}
if (
frontendRes.status === 'rejected' ||
(frontendRes.status === 'fulfilled' && !frontendRes.value.ok)
) {
test.skip(true, 'Vite dev server not available');
return;
}
if (backendRes.status === 'fulfilled') {
const repos = await backendRes.value.json();
if (!repos.length) {
test.skip(true, 'No indexed repos');
return;
}
firstRepoName = repos[0].name;
}
} catch {
test.skip(true, 'servers not available');
}
});
// ── 1. Hold-queue: 503 → descriptive user message ────────────────────────────
test.describe('Hold-queue timeout error', () => {
test('shows descriptive message when /api/repo returns 503', async ({ page }, testInfo) => {
// Intercept only /api/repo (singular) — not /api/repos — to return a 503
// regex: /api/repo followed by end, ?, or # — NOT /api/repos
await page.route(/\/api\/repo(?!s)(\?.*)?$/, (route) =>
route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({
error: `Repository analysis for "${firstRepoName}" is taking longer than expected. Please try again in a moment.`,
}),
}),
);
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
// UI should show the 503 error message
await expect(page.getByText(/taking longer than expected/i)).toBeVisible({
timeout: 20_000,
});
await page.screenshot({ path: testInfo.outputPath('hold-queue-503.png') });
});
});
// ── 2. ?project= URL persistence ─────────────────────────────────────────────
test.describe('?project= URL persistence', () => {
test('?project= is set in URL after connecting via ?server=', async ({ page }) => {
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
const url = new URL(page.url());
const project = url.searchParams.get('project');
expect(project).toBeTruthy();
// first repo returned by the live backend
if (firstRepoName) expect(project).toBe(firstRepoName);
});
test('?project= is still present after F5 reload', async ({ page }) => {
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
// After connect, URL has ?server=&project= — F5 re-uses both params
await page.reload();
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
const url = new URL(page.url());
expect(url.searchParams.get('project')).toBeTruthy();
});
});
// ── 3. ?project= + ?server= combined auto-connect ────────────────────────────
test.describe('?project= auto-connect', () => {
test('navigating with ?server=&project= connects to the correct repo', async ({
page,
}, testInfo) => {
if (!firstRepoName) test.skip(true, 'no repo name available');
await page.goto(
`/?server=${encodeURIComponent(BACKEND_URL)}&project=${encodeURIComponent(firstRepoName)}`,
);
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
// ?project= in URL should match what we passed in
const url = new URL(page.url());
expect(url.searchParams.get('project')).toBe(firstRepoName);
await page.screenshot({ path: testInfo.outputPath('project-param-connect.png') });
});
});
// ── 4. Windows path normalization ─────────────────────────────────────────────
test.describe('Windows path normalization', () => {
test('project name uses basename when /api/repo returns a Windows-style repoPath', async ({
page,
}) => {
const repoName = firstRepoName || 'test-repo';
const windowsPath = `C:\\Users\\LENOVO\\.gitnexus\\repos\\${repoName}`;
// Mock /api/repo to return a Windows backslash path while keeping name correct
await page.route(/\/api\/repo(?!s)(\?.*)?$/, (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
// intentionally omit `name` to force path-based extraction
path: windowsPath,
repoPath: windowsPath,
}),
}),
);
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
// URL ?project= must be the short basename, NOT the full Windows path
const url = new URL(page.url());
const project = url.searchParams.get('project');
expect(project).toBeTruthy();
expect(project).not.toContain('\\');
expect(project).not.toContain('LENOVO');
expect(project).toBe(repoName);
});
});

View file

@ -56,16 +56,14 @@ const AppContent = () => {
// backend calls (queries, search, grep, readFile) scope to this repo.
const repoName = result.repoInfo.name;
const repoPath = result.repoInfo.repoPath ?? result.repoInfo.path;
// Normalize both Windows (\) and Unix (/) path separators before splitting
const projectName =
repoName || repoPath?.split('/').filter(Boolean).pop() || 'server-project';
result.repoInfo.name ||
(repoPath || '').replace(/\\/g, '/').split('/').filter(Boolean).pop() ||
'server-project';
setProjectName(projectName);
setCurrentRepo(projectName);
// Update URL so F5 / bookmarks preserve which repo is open
const url = new URL(window.location.href);
url.searchParams.set('project', projectName);
window.history.replaceState(null, '', url.toString());
// Build KnowledgeGraph from server data for visualization
const graph = createKnowledgeGraph();
for (const node of result.nodes) {
@ -76,6 +74,11 @@ const AppContent = () => {
}
setGraph(graph);
// Persist the active project in the URL for bookmarkability and F5 refresh resilience
const urlObj = new URL(window.location.href);
urlObj.searchParams.set('project', projectName);
window.history.replaceState(null, '', urlObj.toString());
// Transition directly to exploring view
setViewMode('exploring');
@ -99,22 +102,17 @@ const AppContent = () => {
],
);
// Auto-connect when ?server query param is present (bookmarkable shortcut).
// Also reads ?project= to connect to a specific repo.
// Auto-connect when ?server or ?project query param is present (bookmarkable shortcut)
const autoConnectRan = useRef(false);
useEffect(() => {
if (autoConnectRan.current) return;
const params = new URLSearchParams(window.location.search);
if (!params.has('server')) return;
const serverUrlParam = params.get('server');
const projectParam = params.get('project');
if (!serverUrlParam && !projectParam) return;
autoConnectRan.current = true;
const serverUrl = params.get('server') || window.location.origin;
const projectParam = params.get('project') || undefined;
// Keep ?server= in the URL so F5 reconnects to the same server.
// autoConnectRan.current prevents re-trigger within the same session.
// handleServerConnect() will add/update ?project= after connecting.
setProgress({
phase: 'extracting',
percent: 0,
@ -123,39 +121,45 @@ const AppContent = () => {
});
setViewMode('loading');
const serverUrl = serverUrlParam || window.location.origin;
const baseUrl = normalizeServerUrl(serverUrl);
connectToServer(
serverUrl,
(phase, downloaded, total) => {
if (phase === 'validating') {
setProgress({
phase: 'extracting',
percent: 5,
message: 'Connecting to server...',
detail: 'Validating server',
});
} else if (phase === 'downloading') {
const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50;
const mb = (downloaded / (1024 * 1024)).toFixed(1);
setProgress({
phase: 'extracting',
percent: pct,
message: 'Downloading graph...',
detail: `${mb} MB downloaded`,
});
} else if (phase === 'extracting') {
setProgress({
phase: 'extracting',
percent: 97,
message: 'Processing...',
detail: 'Extracting file contents',
});
}
},
undefined,
projectParam,
)
const tryConnect = async () => {
return await connectToServer(
serverUrl,
(phase, downloaded, total) => {
if (phase === 'validating') {
setProgress({
phase: 'extracting',
percent: 5,
message: 'Connecting to server...',
detail: 'Validating server',
});
} else if (phase === 'downloading') {
const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50;
const mb = (downloaded / (1024 * 1024)).toFixed(1);
setProgress({
phase: 'extracting',
percent: pct,
message: 'Downloading graph...',
detail: `${mb} MB downloaded`,
});
} else if (phase === 'extracting') {
setProgress({
phase: 'extracting',
percent: 97,
message: 'Processing...',
detail: 'Extracting file contents',
});
}
},
undefined,
projectParam || undefined,
{ awaitAnalysis: true }, // enable backend hold-queue for repos still being analyzed
);
};
tryConnect()
.then(async (result) => {
await handleServerConnect(result);
setProgress(null);

View file

@ -231,7 +231,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
repo: projectName,
};
readFile(selectedFilePath, options)
readFile(selectedFilePath, { ...options, repo: projectName || undefined })
.then((result) => {
if (!cancelled) {
setFileResult(result);

View file

@ -579,6 +579,13 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
try {
const effectiveProjectName = overrideProjectName || projectName || 'project';
// Sync repoRef so all agent backend calls target the correct repo.
// initializeAgent can be called from App.tsx (handleServerConnect) which
// never sets repoRef.current directly — without this, queries default to repo[0].
if (overrideProjectName) {
repoRef.current = overrideProjectName;
}
const repo = repoRef.current;
// Build backend interface for Graph RAG tools
@ -610,7 +617,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
setIsAgentInitializing(false);
}
},
[projectName],
// eslint-disable-next-line react-hooks/exhaustive-deps
[], // repoRef is a stable ref — we sync it explicitly on entry; no state deps needed
);
const sendChatMessage = useCallback(
@ -1042,6 +1050,9 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
setCodePanelOpen(false);
setCodeReferenceFocus(null);
let connectedRepo: BackendRepo | undefined;
let pNameStr = repoName || 'server-project';
try {
const result: ConnectResult = await connectToServer(
serverBaseUrl,
@ -1073,44 +1084,28 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
},
undefined,
repoName,
{ awaitAnalysis: true }, // enable backend hold-queue for repos still being analyzed
);
// Build graph for visualization
const repoPath = result.repoInfo.repoPath ?? result.repoInfo.path;
// Prefer the registry name, then normalize Windows \ and Unix / paths
const pName =
repoName || result.repoInfo.name || repoPath?.split('/').pop() || 'server-project';
repoName ||
result.repoInfo.name ||
(repoPath || '').replace(/\\/g, '/').split('/').filter(Boolean).pop() ||
'server-project';
setProjectName(pName);
repoRef.current = pName;
// Update URL so F5 / bookmarks open the correct repo
const url = new URL(window.location.href);
url.searchParams.set('project', pName);
window.history.replaceState(null, '', url.toString());
connectedRepo = result.repoInfo;
pNameStr = pName;
const newGraph = createKnowledgeGraph();
for (const node of result.nodes) newGraph.addNode(node);
for (const rel of result.relationships) newGraph.addRelationship(rel);
setGraph(newGraph);
// No fileContents needed — grep/read tools use backend HTTP
// Initialize agent with backend queries, then start embeddings
try {
if (getActiveProviderConfig()) {
await initializeAgent(pName);
}
setViewMode('exploring');
startEmbeddingsWithFallback();
setProgress(null);
} catch (err) {
console.warn('Failed to initialize agent:', err);
setIsAgentReady(false);
agentRef.current = null;
setAgentError('Failed to initialize agent');
setViewMode('exploring');
setProgress(null);
}
} catch (err) {
} catch (err: unknown) {
console.error('Repo switch failed:', err);
setProgress({
phase: 'error',
@ -1124,6 +1119,36 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
setViewMode('exploring');
setProgress(null);
}, ERROR_RESET_DELAY_MS);
return; // Abort the whole switchRepo process
}
if (pNameStr) {
// Persist the selected project in the URL so a refresh re-opens it
const urlObj = new URL(window.location.href);
urlObj.searchParams.set('project', pNameStr);
window.history.replaceState(null, '', urlObj.toString());
}
// Reset the agent and clear chat history so the AI starts fresh for the new repo
agentRef.current = null;
setIsAgentReady(false);
setChatMessages([]);
// Re-initialize agent with the new repo's graph context
try {
if (getActiveProviderConfig()) {
await initializeAgent(pNameStr);
}
setViewMode('exploring');
startEmbeddingsWithFallback();
setProgress(null);
} catch (err) {
console.warn('Failed to initialize agent:', err);
setIsAgentReady(false);
agentRef.current = null;
setAgentError('Failed to initialize agent');
setViewMode('exploring');
setProgress(null);
}
},
[
@ -1143,6 +1168,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
setCodeReferences,
setCodePanelOpen,
setCodeReferenceFocus,
setChatMessages,
],
);

View file

@ -386,10 +386,22 @@ export const fetchRepos = async (): Promise<BackendRepo[]> => {
return response.json() as Promise<BackendRepo[]>;
};
/** Fetch repo metadata. */
export const fetchRepoInfo = async (repo?: string): Promise<BackendRepo> => {
/** Fetch repo metadata.
* Pass `awaitAnalysis: true` when connecting to a repo that may still be cloning/analyzing
* this enables the backend's hold-queue and uses a 5-minute timeout to match.
* Normal calls (e.g. repo switching between already-indexed repos) use the default 10s timeout.
*
* Must stay in sync with HOLD_QUEUE_TIMEOUT_SECS in gitnexus/src/server/api.ts.
*/
const HOLD_QUEUE_TIMEOUT_MS = 300_000; // 5 minutes — matches backend HOLD_QUEUE_TIMEOUT_SECS
export const fetchRepoInfo = async (
repo?: string,
opts?: { awaitAnalysis?: boolean },
): Promise<BackendRepo> => {
const url = `${_backendUrl}/api/repo${repo ? `?${repoParam(repo)}` : ''}`;
const response = await fetchWithTimeout(url);
const timeout = opts?.awaitAnalysis ? HOLD_QUEUE_TIMEOUT_MS : undefined;
const response = await fetchWithTimeout(url, {}, timeout);
await assertOk(response);
const data = await response.json();
return { ...data, repoPath: data.repoPath ?? data.path };
@ -736,18 +748,21 @@ export interface ConnectResult {
/**
* Connect to a server: validate, fetch repo info, download graph.
* Content is NOT included (use readFile/grep for file access).
* Pass `awaitAnalysis: true` when the repo may still be cloning/analyzing
* this enables the backend hold-queue and a 5-minute fetch timeout.
*/
export async function connectToServer(
url: string,
onProgress?: (phase: string, downloaded: number, total: number | null) => void,
signal?: AbortSignal,
repoName?: string,
opts?: { awaitAnalysis?: boolean },
): Promise<ConnectResult> {
const baseUrl = normalizeServerUrl(url);
setBackendUrl(baseUrl);
onProgress?.('validating', 0, null);
const repoInfo = await fetchRepoInfo(repoName);
const repoInfo = await fetchRepoInfo(repoName, { awaitAnalysis: opts?.awaitAnalysis });
onProgress?.('downloading', 0, null);
const { nodes, relationships } = await fetchGraph(repoName, {

View file

@ -87,6 +87,11 @@ export class JobManager {
return this.jobs.get(id);
}
/** Return a snapshot of all tracked jobs for inspection. */
listJobs(): AnalyzeJob[] {
return Array.from(this.jobs.values());
}
updateJob(
id: string,
update: Partial<

View file

@ -480,12 +480,84 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
activeRepoPaths.delete(repoPath);
};
// Helper: resolve a repo by name from the global registry, or default to first
const resolveRepo = async (repoName?: string) => {
/**
* Maximum time the hold-queue will wait for an active analysis job to complete.
* Must stay in sync with the frontend's `fetchRepoInfo({ awaitAnalysis: true })` timeout.
*/
const HOLD_QUEUE_TIMEOUT_SECS = 300; // 5 minutes
// Helper: resolve a repo by name from the global registry, or default to first.
// Pass `req` to enable early exit if the client disconnects during the hold-queue wait.
const resolveRepo = async (repoName?: string, isRetry = false, req?: any): Promise<any> => {
const repos = await listRegisteredRepos();
if (repos.length === 0) return null;
if (repoName) return repos.find((r) => r.name === repoName) || null;
return repos[0]; // default to first
let found = null;
// Normalize: if a full path is passed, extract just the basename.
// e.g. "C:\Users\LENOVO\.gitnexus\repos\todo.txt-cli" -> "todo.txt-cli"
const normalizedName = repoName ? path.basename(repoName) : undefined;
if (normalizedName) {
found =
repos.find((r) => r.name === normalizedName) ||
repos.find((r) => r.name.toLowerCase() === normalizedName.toLowerCase()) ||
null;
} else if (repos.length > 0) {
found = repos[0]; // default to first repo
}
// If not yet in the registry, check whether a background job is actively cloning or
// analyzing this repo. Hold the connection open (up to 5 minutes) until it completes.
// We only wait for in-progress jobs ('queued'|'cloning'|'analyzing') — a 'complete' job
// whose repo is still missing means the registry sync failed; the fallback below handles it.
if (!found && normalizedName) {
const lower = normalizedName.toLowerCase();
// Track client disconnect to cancel the wait early
let clientGone = false;
req?.on('close', () => {
clientGone = true;
});
for (const job of jobManager.listJobs()) {
const isMatch =
job.repoName?.toLowerCase() === lower ||
(job.repoUrl && path.basename(job.repoUrl).replace('.git', '').toLowerCase() === lower) ||
(job.repoPath && path.basename(job.repoPath).toLowerCase() === lower);
if (isMatch && ['queued', 'cloning', 'analyzing'].includes(job.status)) {
if (process.env.DEBUG) {
console.log(
`[debug] resolveRepo waiting for active job ${job.id} (${normalizedName})...`,
);
}
for (let wait = 0; wait < HOLD_QUEUE_TIMEOUT_SECS; wait++) {
if (clientGone) return null; // client disconnected — stop polling
const currentJob = jobManager.getJob(job.id);
if (!currentJob || currentJob.status === 'failed') break;
if (currentJob.status === 'complete') {
await backend.init();
const freshRepos = await listRegisteredRepos();
return freshRepos.find((r) => r.name === normalizedName) || null;
}
await new Promise((r) => setTimeout(r, 1000));
}
// Timed out — signal to the caller with a specific message
return { __timedOut: true, repoName: normalizedName };
}
}
}
// Emergency fallback: re-sync the registry to handle Windows file-system race conditions
// (e.g. registry file not yet flushed after clone completes).
if (!found && normalizedName && !isRetry) {
if (process.env.DEBUG) {
console.log(`[debug] resolveRepo 404 for "${normalizedName}". Triggering deep init...`);
}
await backend.init();
return await resolveRepo(normalizedName, true, req);
}
return found;
};
// SSE heartbeat — clients connect to detect server liveness instantly.
@ -548,11 +620,18 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
// Get repo info
app.get('/api/repo', async (req, res) => {
try {
const entry = await resolveRepo(requestedRepo(req));
const entry = await resolveRepo(requestedRepo(req), false, req);
if (!entry) {
res.status(404).json({ error: 'Repository not found. Run: gitnexus analyze' });
return;
}
// Timed out waiting for an active analysis job
if (entry.__timedOut) {
res.status(503).json({
error: `Repository analysis for "${entry.repoName}" is taking longer than expected. Please try again in a moment.`,
});
return;
}
const meta = await loadMeta(entry.storagePath);
res.json({
name: entry.name,