Browse Source

more work on branches

Nostr-Signature: adaaea7f2065a00cfd04c9de9bf82b1b976ac3d20c32389a8bd8aa7ad0a95677 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 71ce678d0a0732beab1f49f8318cbfe3d8b33d45eacf13392fdb9553e8b1f4732c28d8ffc33b50c9736a8324cf7604c223bb71ff4cfd32f41d7f3e81e1591fcc
main
Silberengel 3 weeks ago
parent
commit
9c42c2b164
  1. 1
      nostr/commit-signatures.jsonl
  2. 29
      src/lib/components/RepoHeaderEnhanced.svelte
  3. 190
      src/lib/services/git/api-repo-fetcher.ts
  4. 19
      src/lib/services/git/file-manager.ts
  5. 58
      src/routes/api/repos/[npub]/[repo]/branches/+server.ts
  6. 61
      src/routes/api/repos/[npub]/[repo]/diff/+server.ts
  7. 8
      src/routes/api/repos/[npub]/[repo]/tree/+server.ts
  8. 24
      src/routes/repos/[npub]/[repo]/+page.svelte

1
nostr/commit-signatures.jsonl

@ -68,3 +68,4 @@ @@ -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"}

29
src/lib/components/RepoHeaderEnhanced.svelte

@ -87,7 +87,6 @@ @@ -87,7 +87,6 @@
topics = []
}: Props = $props();
let showCloneMenu = $state(false);
let showMoreMenu = $state(false);
let showBranchMenu = $state(false);
let showOwnerMenu = $state(false);
@ -343,34 +342,6 @@ @@ -343,34 +342,6 @@
{/if}
</div>
{#if cloneUrls.length > 0}
<div class="repo-clone">
<button
class="clone-button"
onclick={() => showCloneMenu = !showCloneMenu}
aria-expanded={showCloneMenu}
>
<img src="/icons/git-branch.svg" alt="" class="icon" />
Clone
</button>
{#if showCloneMenu}
<div class="clone-menu">
{#each cloneUrls as url}
<button
class="clone-url-item"
onclick={() => {
navigator.clipboard.writeText(url);
showCloneMenu = false;
}}
>
{url}
</button>
{/each}
</div>
{/if}
</div>
{/if}
{#if branches.length === 0}
<div class="repo-branch">
<div class="branch-button" style="opacity: 0.6; cursor: not-allowed;">

190
src/lib/services/git/api-repo-fetcher.ts

@ -275,11 +275,55 @@ async function fetchFromGitHub(owner: string, repo: string): Promise<Partial<Api @@ -275,11 +275,55 @@ async function fetchFromGitHub(owner: string, repo: string): Promise<Partial<Api
// Fetch branches, commits, tags, and tree in parallel
// Use SHA if available, otherwise fall back to branch name
const treeRef = defaultBranchSha || defaultBranch;
const [branchesResponse, commitsResponse, tagsResponse, treeResponse] = await Promise.all([
fetch(`https://api.github.com/repos/${owner}/${repo}/branches`, { headers }).catch((err) => {
// Fetch all branches with pagination (GitHub API defaults to 30 per page, max 100)
const fetchAllBranches = async (): Promise<Response | null> => {
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;
@ -306,6 +350,15 @@ async function fetchFromGitHub(owner: string, repo: string): Promise<Partial<Api @@ -306,6 +350,15 @@ async function fetchFromGitHub(owner: string, repo: string): Promise<Partial<Api
}))
: [];
// 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[] = commitsResponse?.ok
? (await commitsResponse.json()).map((c: any) => ({
sha: c.sha,
@ -421,13 +474,61 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr @@ -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 branches, commits, and tags in parallel
const [branchesResponse, commitsResponse, tagsResponse] = await Promise.all([
fetch(getApiBaseUrl(
// Fetch all branches with pagination (GitLab API defaults to 20 per page, max 100)
const fetchAllBranches = async (): Promise<Response | null> => {
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()
)).catch(() => null),
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([
fetchAllBranches(),
fetch(getApiBaseUrl(
`projects/${projectPath}/repository/commits`,
baseUrl,
@ -469,6 +570,15 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr @@ -469,6 +570,15 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr
}
}));
// 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,
message: c.message.split('\n')[0],
@ -590,12 +700,59 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro @@ -590,12 +700,59 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro
const defaultBranch = repoData.default_branch || 'master';
const [branchesResponse, commitsResponse, tagsResponse] = await Promise.all([
fetch(getApiBaseUrl(
// Fetch all branches with pagination (Gitea API defaults to 30 per page, max 50)
const fetchAllBranches = async (): Promise<Response | null> => {
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()
)).catch(() => null),
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([
fetchAllBranches(),
fetch(getApiBaseUrl(
`repos/${encodedOwner}/${encodedRepo}/commits`,
baseUrl,
@ -653,6 +810,15 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro @@ -653,6 +810,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 || {};
return {

19
src/lib/services/git/file-manager.ts

@ -1626,7 +1626,24 @@ export class FileManager { @@ -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);

58
src/routes/api/repos/[npub]/[repo]/branches/+server.ts

@ -84,8 +84,18 @@ export const GET: RequestHandler = createRepoGetHandler( @@ -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( @@ -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( @@ -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');

61
src/routes/api/repos/[npub]/[repo]/diff/+server.ts

@ -2,12 +2,21 @@ @@ -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( @@ -19,8 +28,52 @@ export const GET: RequestHandler = createRepoGetHandler(
throw handleValidationError('Missing from parameter', { operation: 'getDiff', npub: context.npub, repo: context.repo });
}
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 }
);

8
src/routes/api/repos/[npub]/[repo]/tree/+server.ts

@ -41,7 +41,13 @@ export const GET: RequestHandler = createRepoGetHandler( @@ -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 || '';

24
src/routes/repos/[npub]/[repo]/+page.svelte

@ -3044,8 +3044,10 @@ @@ -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 @@ @@ -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) {
// 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;
}

Loading…
Cancel
Save