diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 8b84cf9..3ac0dfb 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -68,3 +68,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771923126,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix new branch creation"]],"content":"Signed commit: fix new branch creation","id":"7802c9afbf005e2637282f9d06ac8130fe27bfe3a94cc67c211da51d2e9e8350","sig":"30978d6a71b4935c88ff9cd1412294d850a752977943e1aa65bcfc2290d2f2e8bbce809556849a14f0923da33b12cb53d3339741cdabab3ba949dfbb48e9cc4c"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771923236,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","clean up build warning"]],"content":"Signed commit: clean up build warning","id":"297f43968ae4bcfc8b054037b914a728eaec805770ba0c02e33aab3009c1c046","sig":"91177b6f9c4cd0d69455d5e1c109912588f05c2ddbf287d606a9687ec522ba259ed83750dfbb4b77f20e3cb82a266f251983a14405babc28c0d83eb19bf3da70"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771924650,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","pass announcement"]],"content":"Signed commit: pass announcement","id":"57e1440848e4b322a9b10a6dff49973f29c8dd20b85f6cc75fd40d32eb04f0e4","sig":"3866152051a42592e83a1850bf9f3fd49af597f7dcdb523ef39374d528f6c46df6118682cac3202c29ce89a90fec8b4284c68a57101c6c590d8d1a184cac9731"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771949714,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fallback to API if registered clone unavailble"]],"content":"Signed commit: fallback to API if registered clone unavailble","id":"4921a95aea13f6f72329ff8a278a8ff6321776973e8db327d59ea62b90d363cc","sig":"0efffc826cad23849bd311be582a70cb0a42f3958c742470e8488803c5882955184b9241bf77fcf65fa5ea38feef8bc82de4965de1c783adf53ed05e461dc5de"} diff --git a/src/lib/components/RepoHeaderEnhanced.svelte b/src/lib/components/RepoHeaderEnhanced.svelte index 5c7c298..e74d262 100644 --- a/src/lib/components/RepoHeaderEnhanced.svelte +++ b/src/lib/components/RepoHeaderEnhanced.svelte @@ -87,7 +87,6 @@ topics = [] }: Props = $props(); - let showCloneMenu = $state(false); let showMoreMenu = $state(false); let showBranchMenu = $state(false); let showOwnerMenu = $state(false); @@ -342,34 +341,6 @@ {/if} - - {#if cloneUrls.length > 0} -
- - {#if showCloneMenu} -
- {#each cloneUrls as url} - - {/each} -
- {/if} -
- {/if} {#if branches.length === 0}
diff --git a/src/lib/services/git/api-repo-fetcher.ts b/src/lib/services/git/api-repo-fetcher.ts index 3434339..b01fac7 100644 --- a/src/lib/services/git/api-repo-fetcher.ts +++ b/src/lib/services/git/api-repo-fetcher.ts @@ -275,11 +275,55 @@ async function fetchFromGitHub(owner: string, repo: string): Promise { + + // Fetch all branches with pagination (GitHub API defaults to 30 per page, max 100) + const fetchAllBranches = async (): Promise => { + try { + let allBranches: any[] = []; + let page = 1; + let hasMore = true; + const perPage = 100; // Maximum per page for GitHub API + + while (hasMore) { + const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/branches?per_page=${perPage}&page=${page}`, { headers }); + if (!response.ok) { + if (page === 1) { + // Only return null on first page failure + return null; + } + // If later pages fail, break and return what we have + break; + } + + const pageBranches = await response.json(); + allBranches = allBranches.concat(pageBranches); + + // Check if there are more pages (GitHub API returns Link header) + const linkHeader = response.headers.get('Link'); + hasMore = linkHeader?.includes('rel="next"') || pageBranches.length === perPage; + page++; + + // Safety limit: don't fetch more than 10 pages (1000 branches) + if (page > 10) { + logger.warn({ owner, repo, branchCount: allBranches.length }, 'Reached pagination limit for branches (1000), some branches may be missing'); + break; + } + } + + // Return a mock Response object with the combined branches + return { + ok: true, + json: async () => allBranches, + headers: new Headers() + } as Response; + } catch (err) { logger.debug({ error: err, owner, repo }, 'Failed to fetch branches from GitHub'); return null; - }), + } + }; + + const [branchesResponse, commitsResponse, tagsResponse, treeResponse] = await Promise.all([ + fetchAllBranches(), fetch(`https://api.github.com/repos/${owner}/${repo}/commits?per_page=10`, { headers }).catch((err) => { logger.debug({ error: err, owner, repo }, 'Failed to fetch commits from GitHub'); return null; @@ -305,6 +349,15 @@ async function fetchFromGitHub(owner: string, repo: string): Promise 0 && defaultBranch) { + branches.sort((a, b) => { + if (a.name === defaultBranch) return -1; + if (b.name === defaultBranch) return 1; + return a.name.localeCompare(b.name); + }); + } const commits: ApiCommit[] = commitsResponse?.ok ? (await commitsResponse.json()).map((c: any) => ({ @@ -421,13 +474,61 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr const repoData = await repoResponse.json(); const defaultBranch = repoData.default_branch || 'master'; + // Fetch all branches with pagination (GitLab API defaults to 20 per page, max 100) + const fetchAllBranches = async (): Promise => { + try { + let allBranches: any[] = []; + let page = 1; + let hasMore = true; + const perPage = 100; // Maximum per page for GitLab API + + while (hasMore) { + const response = await fetch(getApiBaseUrl( + `projects/${projectPath}/repository/branches`, + baseUrl, + new URLSearchParams({ per_page: String(perPage), page: String(page) }) + )); + + if (!response.ok) { + if (page === 1) { + return null; + } + break; + } + + const pageBranches = await response.json(); + if (!Array.isArray(pageBranches)) { + break; + } + + allBranches = allBranches.concat(pageBranches); + + // Check if there are more pages + const linkHeader = response.headers.get('Link'); + hasMore = linkHeader?.includes('rel="next"') || pageBranches.length === perPage; + page++; + + // Safety limit: don't fetch more than 10 pages (1000 branches) + if (page > 10) { + logger.warn({ owner, repo, branchCount: allBranches.length }, 'Reached pagination limit for branches (1000), some branches may be missing'); + break; + } + } + + return { + ok: true, + json: async () => allBranches, + headers: new Headers() + } as Response; + } catch (err) { + logger.debug({ error: err, owner, repo }, 'Failed to fetch branches from GitLab'); + return null; + } + }; + // Fetch branches, commits, and tags in parallel const [branchesResponse, commitsResponse, tagsResponse] = await Promise.all([ - fetch(getApiBaseUrl( - `projects/${projectPath}/repository/branches`, - baseUrl, - new URLSearchParams() - )).catch(() => null), + fetchAllBranches(), fetch(getApiBaseUrl( `projects/${projectPath}/repository/commits`, baseUrl, @@ -468,6 +569,15 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr date: b.commit.committed_date } })); + + // Sort branches: default branch first, then alphabetically + if (branches.length > 0 && defaultBranch) { + branches.sort((a, b) => { + if (a.name === defaultBranch) return -1; + if (b.name === defaultBranch) return 1; + return a.name.localeCompare(b.name); + }); + } const commits: ApiCommit[] = commitsData.map((c: any) => ({ sha: c.id, @@ -590,12 +700,59 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro const defaultBranch = repoData.default_branch || 'master'; + // Fetch all branches with pagination (Gitea API defaults to 30 per page, max 50) + const fetchAllBranches = async (): Promise => { + try { + let allBranches: any[] = []; + let page = 1; + let hasMore = true; + const perPage = 50; // Maximum per page for Gitea API + + while (hasMore) { + const response = await fetch(getApiBaseUrl( + `repos/${encodedOwner}/${encodedRepo}/branches`, + baseUrl, + new URLSearchParams({ limit: String(perPage), page: String(page) }) + )); + + if (!response.ok) { + if (page === 1) { + return null; + } + break; + } + + const pageBranches = await response.json(); + if (!Array.isArray(pageBranches)) { + break; + } + + allBranches = allBranches.concat(pageBranches); + + // Gitea doesn't use Link headers, check if we got a full page + hasMore = pageBranches.length === perPage; + page++; + + // Safety limit: don't fetch more than 20 pages (1000 branches) + if (page > 20) { + logger.warn({ owner, repo, branchCount: allBranches.length }, 'Reached pagination limit for branches (1000), some branches may be missing'); + break; + } + } + + return { + ok: true, + json: async () => allBranches, + headers: new Headers() + } as Response; + } catch (err) { + logger.debug({ error: err, owner, repo }, 'Failed to fetch branches from Gitea'); + return null; + } + }; + const [branchesResponse, commitsResponse, tagsResponse] = await Promise.all([ - fetch(getApiBaseUrl( - `repos/${encodedOwner}/${encodedRepo}/branches`, - baseUrl, - new URLSearchParams() - )).catch(() => null), + fetchAllBranches(), fetch(getApiBaseUrl( `repos/${encodedOwner}/${encodedRepo}/commits`, baseUrl, @@ -652,6 +809,15 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro } }; }); + + // Sort branches: default branch first, then alphabetically + if (branches.length > 0 && defaultBranch) { + branches.sort((a, b) => { + if (a.name === defaultBranch) return -1; + if (b.name === defaultBranch) return 1; + return a.name.localeCompare(b.name); + }); + } const commits: ApiCommit[] = commitsData.map((c: any) => { const commitObj = c.commit || {}; diff --git a/src/lib/services/git/file-manager.ts b/src/lib/services/git/file-manager.ts index 5d8afd8..a486dc4 100644 --- a/src/lib/services/git/file-manager.ts +++ b/src/lib/services/git/file-manager.ts @@ -1626,7 +1626,24 @@ export class FileManager { } } - const branchList = Array.from(allBranches).sort(); + // Sort branches: default branch first, then alphabetically + let branchList = Array.from(allBranches); + try { + const defaultBranch = await this.getDefaultBranch(npub, repoName); + if (defaultBranch) { + branchList.sort((a, b) => { + if (a === defaultBranch) return -1; + if (b === defaultBranch) return 1; + return a.localeCompare(b); + }); + } else { + // No default branch found, just sort alphabetically + branchList.sort(); + } + } catch { + // If we can't get default branch, just sort alphabetically + branchList.sort(); + } // Cache the result (cache for 2 minutes) repoCache.set(cacheKey, branchList, 2 * 60 * 1000); diff --git a/src/routes/api/repos/[npub]/[repo]/branches/+server.ts b/src/routes/api/repos/[npub]/[repo]/branches/+server.ts index 292c9dc..137e252 100644 --- a/src/routes/api/repos/[npub]/[repo]/branches/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/branches/+server.ts @@ -84,8 +84,18 @@ export const GET: RequestHandler = createRepoGetHandler( if (apiData && apiData.branches && apiData.branches.length > 0) { logger.debug({ npub: context.npub, repo: context.repo, branchCount: apiData.branches.length }, 'Successfully fetched branches via API fallback'); - // Return API data directly without cloning - return json(apiData.branches); + // Sort branches: default branch first, then alphabetically + const sortedBranches = [...apiData.branches]; + if (apiData.defaultBranch) { + sortedBranches.sort((a: any, b: any) => { + const aName = typeof a === 'string' ? a : a.name; + const bName = typeof b === 'string' ? b : b.name; + if (aName === apiData.defaultBranch) return -1; + if (bName === apiData.defaultBranch) return 1; + return aName.localeCompare(bName); + }); + } + return json(sortedBranches); } // API fetch failed - repo is not cloned and API fetch didn't work @@ -183,7 +193,18 @@ export const GET: RequestHandler = createRepoGetHandler( if (apiData && apiData.branches && apiData.branches.length > 0) { logger.info({ npub: context.npub, repo: context.repo, branchCount: apiData.branches.length }, 'Successfully fetched branches via API fallback for empty repo'); - return json(apiData.branches); + // Sort branches: default branch first, then alphabetically + const sortedBranches = [...apiData.branches]; + if (apiData.defaultBranch) { + sortedBranches.sort((a: any, b: any) => { + const aName = typeof a === 'string' ? a : a.name; + const bName = typeof b === 'string' ? b : b.name; + if (aName === apiData.defaultBranch) return -1; + if (bName === apiData.defaultBranch) return 1; + return aName.localeCompare(bName); + }); + } + return json(sortedBranches); } } } catch (apiErr) { @@ -191,7 +212,36 @@ export const GET: RequestHandler = createRepoGetHandler( } } - return json(branches); + // Sort branches: default branch first, then alphabetically + let sortedBranches = [...branches]; + try { + const defaultBranch = await fileManager.getDefaultBranch(context.npub, context.repo); + if (defaultBranch) { + sortedBranches.sort((a: any, b: any) => { + const aName = typeof a === 'string' ? a : a.name; + const bName = typeof b === 'string' ? b : b.name; + if (aName === defaultBranch) return -1; + if (bName === defaultBranch) return 1; + return aName.localeCompare(bName); + }); + } else { + // No default branch found, just sort alphabetically + sortedBranches.sort((a: any, b: any) => { + const aName = typeof a === 'string' ? a : a.name; + const bName = typeof b === 'string' ? b : b.name; + return aName.localeCompare(bName); + }); + } + } catch { + // If we can't get default branch, just sort alphabetically + sortedBranches.sort((a: any, b: any) => { + const aName = typeof a === 'string' ? a : a.name; + const bName = typeof b === 'string' ? b : b.name; + return aName.localeCompare(bName); + }); + } + + return json(sortedBranches); } catch (err) { // Log the actual error for debugging logger.error({ error: err, npub: context.npub, repo: context.repo }, '[Branches] Error getting branches'); diff --git a/src/routes/api/repos/[npub]/[repo]/diff/+server.ts b/src/routes/api/repos/[npub]/[repo]/diff/+server.ts index bf3c21d..67f5d45 100644 --- a/src/routes/api/repos/[npub]/[repo]/diff/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/diff/+server.ts @@ -2,12 +2,21 @@ * API endpoint for getting diffs */ -import { json } from '@sveltejs/kit'; +import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { fileManager } from '$lib/services/service-registry.js'; +import { fileManager, nostrClient } from '$lib/services/service-registry.js'; import { createRepoGetHandler } from '$lib/utils/api-handlers.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js'; -import { handleValidationError } from '$lib/utils/error-handler.js'; +import { handleValidationError, handleNotFoundError } from '$lib/utils/error-handler.js'; +import { join } from 'path'; +import { existsSync } from 'fs'; +import { eventCache } from '$lib/services/nostr/event-cache.js'; +import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js'; +import logger from '$lib/services/logger.js'; + +const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT + ? process.env.GIT_REPO_ROOT + : '/repos'; export const GET: RequestHandler = createRepoGetHandler( async (context: RepoRequestContext, event: RequestEvent) => { @@ -19,8 +28,52 @@ export const GET: RequestHandler = createRepoGetHandler( throw handleValidationError('Missing from parameter', { operation: 'getDiff', npub: context.npub, repo: context.repo }); } - const diffs = await fileManager.getDiff(context.npub, context.repo, fromRef, toRef, filePath); - return json(diffs); + const repoPath = join(repoRoot, context.npub, `${context.repo}.git`); + + // Check if repo exists + if (!existsSync(repoPath)) { + // Repo doesn't exist - diffs are not available via API fallback + // GitHub/GitLab APIs don't provide easy diff endpoints + logger.debug({ npub: context.npub, repo: context.repo, fromRef, toRef }, 'Diff requested for non-existent repo'); + throw handleNotFoundError( + 'Repository is not cloned locally. Diffs are only available for cloned repositories. Please clone the repository to view diffs.', + { operation: 'getDiff', npub: context.npub, repo: context.repo } + ); + } + + try { + const diffs = await fileManager.getDiff(context.npub, context.repo, fromRef, toRef, filePath); + return json(diffs); + } catch (err) { + // If error occurs, check if repo is empty + logger.debug({ error: err, npub: context.npub, repo: context.repo, fromRef, toRef }, 'Error getting diff, checking if repo is empty'); + + try { + // Check if repo has any branches + const branches = await fileManager.getBranches(context.npub, context.repo); + if (branches.length === 0) { + // Repo is empty - diffs not available + throw handleNotFoundError( + 'Repository is empty. Diffs are only available for repositories with commits.', + { operation: 'getDiff', npub: context.npub, repo: context.repo } + ); + } + } catch (branchErr) { + // If we can't get branches, the repo might be empty or corrupted + logger.debug({ error: branchErr, npub: context.npub, repo: context.repo }, 'Failed to get branches, repo may be empty'); + } + + // Re-throw the original error with better context + const errorMessage = err instanceof Error ? err.message : 'Failed to get diff'; + if (errorMessage.includes('not found') || errorMessage.includes('Invalid object name')) { + throw handleNotFoundError( + `Commit not found: ${errorMessage}. The commit hash may be invalid or the repository may not have the requested commits.`, + { operation: 'getDiff', npub: context.npub, repo: context.repo } + ); + } + + throw err; + } }, - { operation: 'getDiff' } + { operation: 'getDiff', requireRepoExists: false, requireRepoAccess: true } ); diff --git a/src/routes/api/repos/[npub]/[repo]/tree/+server.ts b/src/routes/api/repos/[npub]/[repo]/tree/+server.ts index a4a461e..213e4d4 100644 --- a/src/routes/api/repos/[npub]/[repo]/tree/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/tree/+server.ts @@ -41,7 +41,13 @@ export const GET: RequestHandler = createRepoGetHandler( const apiData = await tryApiFetch(announcement, context.npub, context.repo); - if (apiData && apiData.files && apiData.files.length > 0) { + if (apiData && apiData.files !== undefined) { + // Return empty array if no files (legitimate for empty repos) + // Only proceed if we have files to filter + if (apiData.files.length === 0) { + logger.debug({ npub: context.npub, repo: context.repo, path: context.path }, 'API fallback returned empty files array (repo may be empty)'); + return json([]); + } logger.debug({ npub: context.npub, repo: context.repo, fileCount: apiData.files.length }, 'Successfully fetched files via API fallback'); // Return API data directly without cloning const path = context.path || ''; diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index b952d98..f24a7a9 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -3044,8 +3044,10 @@ : 'master'); } - // Final validation: ensure branchName is a valid string and doesn't contain invalid characters - if (!branchName || typeof branchName !== 'string' || branchName.includes('#') || branchName.trim() === '') { + // Final validation: ensure branchName is a valid string + // Note: We allow '#' in branch names for existing branches (they'll be URL-encoded) + // Only reject if it's empty or not a string + if (!branchName || typeof branchName !== 'string' || branchName.trim() === '') { console.warn('[loadFile] Invalid branch name detected, using fallback:', branchName); branchName = defaultBranch || (branches.length > 0 ? (typeof branches[0] === 'string' ? branches[0] : branches[0].name) @@ -3943,9 +3945,27 @@ if (response.ok) { diffData = await response.json(); showDiff = true; + } else { + // Handle 404 or other errors + const errorText = await response.text().catch(() => response.statusText); + if (response.status === 404) { + // Check if this is an API fallback commit (repo not cloned or empty) + if (isRepoCloned === false || (isRepoCloned === true && apiFallbackAvailable)) { + error = 'Diffs are not available for commits viewed via API fallback. Please clone the repository to view diffs.'; + } else { + error = `Commit not found: ${errorText || 'The commit may not exist in the repository'}`; + } + } else { + error = `Failed to load diff: ${errorText || response.statusText}`; + } } } catch (err) { - error = err instanceof Error ? err.message : 'Failed to load diff'; + // Handle network errors + if (err instanceof TypeError && err.message.includes('NetworkError')) { + error = 'Network error: Unable to fetch diff. Please check your connection and try again.'; + } else { + error = err instanceof Error ? err.message : 'Failed to load diff'; + } } finally { loadingCommits = false; }