diff --git a/src/lib/services/git/commit-signer.ts b/src/lib/services/git/commit-signer.ts index 51c1431..ae642cd 100644 --- a/src/lib/services/git/commit-signer.ts +++ b/src/lib/services/git/commit-signer.ts @@ -160,14 +160,19 @@ export async function createGitCommitSignature( useNIP07?: boolean; nip98Event?: NostrEvent; nsecKey?: string; + commitSignatureEvent?: NostrEvent; // Pre-signed event from client (NIP-07) timestamp?: number; } = {} ): Promise<{ signedMessage: string; signatureEvent: NostrEvent }> { const timestamp = options.timestamp || Math.floor(Date.now() / 1000); let signedEvent: NostrEvent; - // Method 1: Use NIP-07 browser extension (client-side) - if (options.useNIP07) { + // Method 1: Use pre-signed commit signature event from client (NIP-07) + if (options.commitSignatureEvent && options.commitSignatureEvent.sig && options.commitSignatureEvent.id) { + signedEvent = options.commitSignatureEvent; + } + // Method 2: Use NIP-07 browser extension (client-side) - DEPRECATED: use commitSignatureEvent instead + else if (options.useNIP07) { // NIP-07 will add pubkey automatically, so we don't need it in the template const eventTemplate: Omit = { kind: KIND.COMMIT_SIGNATURE, @@ -238,7 +243,7 @@ export async function createGitCommitSignature( signedEvent = finalizeEvent(eventTemplate, keyBytes); } else { - throw new Error('No signing method provided. Use useNIP07, nip98Event, or nsecKey.'); + throw new Error('No signing method provided. Use commitSignatureEvent (pre-signed from client), useNIP07, nip98Event, or nsecKey.'); } // Create a signature trailer that git can recognize diff --git a/src/lib/services/git/file-manager.ts b/src/lib/services/git/file-manager.ts index ab812c3..660dd64 100644 --- a/src/lib/services/git/file-manager.ts +++ b/src/lib/services/git/file-manager.ts @@ -70,24 +70,61 @@ export class FileManager { } const worktreeRoot = join(this.repoRoot, npub, `${repoName}.worktrees`); - const worktreePath = join(worktreeRoot, branch); + // Use resolve to ensure we have an absolute path (important for git worktree add) + const worktreePath = resolve(join(worktreeRoot, branch)); + const resolvedWorktreeRoot = resolve(worktreeRoot); // Additional security: Ensure resolved path is still within worktreeRoot - const resolvedPath = resolve(worktreePath).replace(/\\/g, '/'); - const resolvedRoot = resolve(worktreeRoot).replace(/\\/g, '/'); + const resolvedPath = worktreePath.replace(/\\/g, '/'); + const resolvedRoot = resolvedWorktreeRoot.replace(/\\/g, '/'); if (!resolvedPath.startsWith(resolvedRoot + '/')) { throw new Error('Path traversal detected: worktree path outside allowed root'); } const { mkdir, rm } = await import('fs/promises'); - // Ensure worktree root exists - if (!existsSync(worktreeRoot)) { - await mkdir(worktreeRoot, { recursive: true }); + // Ensure worktree root exists (use resolved path) + if (!existsSync(resolvedWorktreeRoot)) { + await mkdir(resolvedWorktreeRoot, { recursive: true }); } const git = simpleGit(repoPath); - // Check if worktree already exists + // Check for existing worktrees for this branch and clean them up if they're in the wrong location + try { + const worktreeList = await git.raw(['worktree', 'list', '--porcelain']); + const lines = worktreeList.split('\n'); + let currentWorktreePath: string | null = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line.startsWith('worktree ')) { + currentWorktreePath = line.substring(9).trim(); + } else if (line.startsWith('branch ') && line.includes(`refs/heads/${branch}`)) { + // Found a worktree for this branch + if (currentWorktreePath && currentWorktreePath !== worktreePath) { + // Worktree exists but in wrong location - remove it + logger.warn({ oldPath: currentWorktreePath, newPath: worktreePath, branch }, 'Removing worktree from incorrect location'); + try { + await git.raw(['worktree', 'remove', currentWorktreePath, '--force']); + } catch (err) { + // If git worktree remove fails, try to remove the directory manually + logger.warn({ error: err, path: currentWorktreePath }, 'Failed to remove worktree via git, will try manual removal'); + try { + await rm(currentWorktreePath, { recursive: true, force: true }); + } catch (rmErr) { + logger.error({ error: rmErr, path: currentWorktreePath }, 'Failed to manually remove worktree directory'); + } + } + } + break; + } + } + } catch (err) { + // If worktree list fails, continue - might be no worktrees yet + logger.debug({ error: err }, 'Could not list worktrees (this is okay if no worktrees exist)'); + } + + // Check if worktree already exists at the correct location if (existsSync(worktreePath)) { // Verify it's a valid worktree try { @@ -202,10 +239,23 @@ export class FileManager { gitProcess.on('error', reject); }); + // Verify the worktree directory was actually created (after the promise resolves) + if (!existsSync(worktreePath)) { + throw new Error(`Worktree directory was not created: ${worktreePath}`); + } + + // Verify it's a valid git repository + const worktreeGit = simpleGit(worktreePath); + try { + await worktreeGit.status(); + } catch (err) { + throw new Error(`Created worktree directory is not a valid git repository: ${worktreePath}`); + } + return worktreePath; } catch (error) { const sanitizedError = sanitizeError(error); - logger.error({ error: sanitizedError, repoPath, branch }, 'Failed to create worktree'); + logger.error({ error: sanitizedError, repoPath, branch, worktreePath }, 'Failed to create worktree'); throw new Error(`Failed to create worktree: ${sanitizedError}`); } } @@ -505,7 +555,38 @@ export class FileManager { try { // Get file content using git show - const content = await git.show([`${ref}:${filePath}`]); + // Use raw() for better error handling and to catch stderr + let content: string; + try { + content = await git.raw(['show', `${ref}:${filePath}`]); + } catch (gitError: any) { + // simple-git might throw errors in different formats + // Check stderr if available + const stderr = gitError?.stderr || gitError?.message || String(gitError); + const stderrLower = stderr.toLowerCase(); + + logger.debug({ gitError, repoPath, filePath, ref, stderr }, 'git.raw() error details'); + + // Check if it's a "not found" type error + if (stderrLower.includes('not found') || + stderrLower.includes('no such file') || + stderrLower.includes('does not exist') || + stderrLower.includes('fatal:') || + stderr.includes('pathspec') || + stderr.includes('ambiguous argument') || + stderr.includes('unknown revision') || + stderr.includes('bad revision')) { + throw new Error(`File not found: ${filePath} at ref ${ref}`); + } + + // Re-throw with more context + throw new Error(`Git command failed: ${stderr}`); + } + + // Check if content is undefined or null (indicates error) + if (content === undefined || content === null) { + throw new Error(`File not found: ${filePath} at ref ${ref}`); + } // Try to determine encoding (assume UTF-8 for text files) const encoding = 'utf-8'; @@ -517,15 +598,40 @@ export class FileManager { size }; } catch (error) { - logger.error({ error, repoPath, filePath, ref }, 'Error reading file'); - throw new Error(`Failed to read file: ${error instanceof Error ? error.message : String(error)}`); + const errorMessage = error instanceof Error ? error.message : String(error); + const errorLower = errorMessage.toLowerCase(); + const errorString = String(error); + const errorStringLower = errorString.toLowerCase(); + + logger.error({ error, repoPath, filePath, ref, errorMessage, errorString }, 'Error reading file'); + + // Check if it's a "not found" type error (check both errorMessage and errorString) + if (errorLower.includes('not found') || + errorStringLower.includes('not found') || + errorLower.includes('no such file') || + errorStringLower.includes('no such file') || + errorLower.includes('does not exist') || + errorStringLower.includes('does not exist') || + errorLower.includes('fatal:') || + errorStringLower.includes('fatal:') || + errorMessage.includes('pathspec') || + errorString.includes('pathspec') || + errorMessage.includes('ambiguous argument') || + errorString.includes('ambiguous argument') || + errorString.includes('unknown revision') || + errorString.includes('bad revision')) { + throw new Error(`File not found: ${filePath} at ref ${ref}`); + } + + throw new Error(`Failed to read file: ${errorMessage}`); } } /** * Write file and commit changes * @param signingOptions - Optional commit signing options: - * - useNIP07: Use NIP-07 browser extension (client-side, secure - keys never leave browser) + * - commitSignatureEvent: Pre-signed commit signature event from client (NIP-07, recommended) + * - useNIP07: Use NIP-07 browser extension (DEPRECATED: use commitSignatureEvent instead) * - nip98Event: Use NIP-98 auth event as signature (server-side, for git operations) * - nsecKey: Use direct nsec/hex key (server-side ONLY, via environment variables - NOT for client requests) */ @@ -539,6 +645,7 @@ export class FileManager { authorEmail: string, branch: string = 'main', signingOptions?: { + commitSignatureEvent?: NostrEvent; useNIP07?: boolean; nip98Event?: NostrEvent; nsecKey?: string; @@ -630,7 +737,7 @@ export class FileManager { // Sign commit if signing options are provided let finalCommitMessage = commitMessage; - if (signingOptions && (signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) { + if (signingOptions && (signingOptions.commitSignatureEvent || signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) { try { const { signedMessage } = await createGitCommitSignature( commitMessage, @@ -652,8 +759,9 @@ export class FileManager { '--author': `${authorName} <${authorEmail}>` }); - // Push to bare repo (worktree is already connected) - await workGit.push(['origin', branch]); + // Note: No push needed - worktrees of bare repos share the same object database, + // so the commit is already in the bare repository. We don't push to remote origin + // to avoid requiring remote authentication and to keep changes local-only.t // Clean up worktree (but keep it for potential reuse) // Note: We could keep worktrees for better performance, but clean up for now @@ -742,10 +850,44 @@ export class FileManager { const git: SimpleGit = simpleGit(repoPath); try { - const branches = await git.branch(['-r']); - const branchList = branches.all + // For bare repositories, list local branches (they're stored in refs/heads/) + // Also check remote branches in case the repo has remotes configured + const [localBranches, remoteBranches] = await Promise.all([ + git.branch(['-a']).catch(() => ({ all: [] })), // List all branches (local and remote) + git.branch(['-r']).catch(() => ({ all: [] })) // Also try remote branches separately + ]); + + // Combine local and remote branches, removing duplicates + const allBranches = new Set(); + + // Add local branches (from -a, filter out remotes) + localBranches.all + .filter(b => !b.startsWith('remotes/') && !b.includes('HEAD')) + .forEach(b => allBranches.add(b)); + + // Add remote branches (remove origin/ prefix) + remoteBranches.all .map(b => b.replace(/^origin\//, '')) - .filter(b => !b.includes('HEAD')); + .filter(b => !b.includes('HEAD')) + .forEach(b => allBranches.add(b)); + + // If no branches found, try listing refs directly (for bare repos) + if (allBranches.size === 0) { + try { + const refs = await git.raw(['for-each-ref', '--format=%(refname:short)', 'refs/heads/']); + if (refs) { + refs.trim().split('\n').forEach(b => { + if (b && !b.includes('HEAD')) { + allBranches.add(b); + } + }); + } + } catch { + // If that fails too, continue with empty set + } + } + + const branchList = Array.from(allBranches).sort(); // Cache the result (cache for 2 minutes) repoCache.set(cacheKey, branchList, 2 * 60 * 1000); @@ -796,6 +938,7 @@ export class FileManager { authorEmail: string, branch: string = 'main', signingOptions?: { + commitSignatureEvent?: NostrEvent; useNIP07?: boolean; nip98Event?: NostrEvent; nsecKey?: string; @@ -867,7 +1010,7 @@ export class FileManager { // Sign commit if signing options are provided let finalCommitMessage = commitMessage; - if (signingOptions && (signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) { + if (signingOptions && (signingOptions.commitSignatureEvent || signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) { try { const { signedMessage } = await createGitCommitSignature( commitMessage, @@ -889,8 +1032,9 @@ export class FileManager { '--author': `${authorName} <${authorEmail}>` }); - // Push to bare repo (worktree is already connected) - await workGit.push(['origin', branch]); + // Note: No push needed - worktrees of bare repos share the same object database, + // so the commit is already in the bare repository. We don't push to remote origin + // to avoid requiring remote authentication and to keep changes local-only. // Clean up worktree await this.removeWorktree(repoPath, workDir); @@ -931,8 +1075,9 @@ export class FileManager { // Create and checkout new branch await workGit.checkout(['-b', branchName]); - // Push new branch - await workGit.push(['origin', branchName]); + // Note: No push needed - worktrees of bare repos share the same object database, + // so the branch is already in the bare repository. We don't push to remote origin + // to avoid requiring remote authentication and to keep changes local-only. // Clean up worktree await this.removeWorktree(repoPath, workDir); @@ -942,6 +1087,67 @@ export class FileManager { } } + /** + * Delete a branch + */ + async deleteBranch( + npub: string, + repoName: string, + branchName: string + ): Promise { + // Security: Validate branch name to prevent path traversal + if (!isValidBranchName(branchName)) { + throw new Error(`Invalid branch name: ${branchName}`); + } + + const repoPath = this.getRepoPath(npub, repoName); + + if (!this.repoExists(npub, repoName)) { + throw new Error('Repository not found'); + } + + // Prevent deleting the default branch + const defaultBranch = await this.getDefaultBranch(npub, repoName); + if (branchName === defaultBranch) { + throw new Error(`Cannot delete the default branch (${defaultBranch}). Please switch to a different branch first.`); + } + + const git: SimpleGit = simpleGit(repoPath); + + try { + // Check if branch exists + const branches = await git.branch(['-a']); + const branchExists = branches.all.some(b => + b === branchName || + b === `refs/heads/${branchName}` || + b.replace(/^origin\//, '') === branchName + ); + + if (!branchExists) { + throw new Error(`Branch ${branchName} does not exist`); + } + + // Delete the branch (use -D to force delete even if not merged) + // For bare repos, we delete the ref directly + await git.raw(['branch', '-D', branchName]).catch(async () => { + // If branch -D fails (might be a remote branch reference), try deleting the ref directly + try { + await git.raw(['update-ref', '-d', `refs/heads/${branchName}`]); + } catch (refError) { + // If that also fails, the branch might not exist locally + throw new Error(`Failed to delete branch: ${branchName}`); + } + }); + + // Invalidate branches cache + const cacheKey = RepoCache.branchesKey(npub, repoName); + repoCache.delete(cacheKey); + } catch (error) { + logger.error({ error, repoPath, branchName, npub }, 'Error deleting branch'); + throw new Error(`Failed to delete branch: ${error instanceof Error ? error.message : String(error)}`); + } + } + /** * Get commit history */ diff --git a/src/lib/utils/api-repo-helper.ts b/src/lib/utils/api-repo-helper.ts index 1217d2d..9eefee9 100644 --- a/src/lib/utils/api-repo-helper.ts +++ b/src/lib/utils/api-repo-helper.ts @@ -160,7 +160,13 @@ export async function tryApiFetchFile( if (platform === 'gitea' || platform === 'github') { // Gitea and GitHub return base64 encoded content if (fileData.content) { - const content = atob(fileData.content.replace(/\s/g, '')); + // Use Buffer on server-side, atob on client-side + let content: string; + if (isServerSide()) { + content = Buffer.from(fileData.content.replace(/\s/g, ''), 'base64').toString('utf-8'); + } else { + content = atob(fileData.content.replace(/\s/g, '')); + } return { content, encoding: 'base64' diff --git a/src/routes/api/repos/[npub]/[repo]/branches/+server.ts b/src/routes/api/repos/[npub]/[repo]/branches/+server.ts index 6c2cb21..e2239cc 100644 --- a/src/routes/api/repos/[npub]/[repo]/branches/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/branches/+server.ts @@ -138,8 +138,25 @@ export const POST: RequestHandler = createRepoPostHandler( throw handleValidationError('Missing branchName parameter', { operation: 'createBranch', npub: context.npub, repo: context.repo }); } - await fileManager.createBranch(context.npub, context.repo, branchName, fromBranch || 'main'); + // Get default branch if fromBranch not provided + const sourceBranch = fromBranch || await fileManager.getDefaultBranch(context.npub, context.repo); + await fileManager.createBranch(context.npub, context.repo, branchName, sourceBranch); return json({ success: true, message: 'Branch created successfully' }); }, { operation: 'createBranch' } ); + +export const DELETE: RequestHandler = createRepoPostHandler( + async (context: RepoRequestContext, event: RequestEvent) => { + const body = await event.request.json(); + const { branchName } = body; + + if (!branchName) { + throw handleValidationError('Missing branchName parameter', { operation: 'deleteBranch', npub: context.npub, repo: context.repo }); + } + + await fileManager.deleteBranch(context.npub, context.repo, branchName); + return json({ success: true, message: 'Branch deleted successfully' }); + }, + { operation: 'deleteBranch' } +); diff --git a/src/routes/api/repos/[npub]/[repo]/default-branch/+server.ts b/src/routes/api/repos/[npub]/[repo]/default-branch/+server.ts new file mode 100644 index 0000000..79c7c95 --- /dev/null +++ b/src/routes/api/repos/[npub]/[repo]/default-branch/+server.ts @@ -0,0 +1,21 @@ +/** + * API endpoint for getting the default branch of a repository + */ + +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { fileManager } from '$lib/services/service-registry.js'; +import { createRepoGetHandler } from '$lib/utils/api-handlers.js'; +import type { RepoRequestContext } from '$lib/utils/api-context.js'; +import { handleApiError } from '$lib/utils/error-handler.js'; + +export const GET: RequestHandler = createRepoGetHandler( + async (context: RepoRequestContext) => { + try { + const defaultBranch = await fileManager.getDefaultBranch(context.npub, context.repo); + return json({ defaultBranch, branch: defaultBranch }); + } catch (error) { + throw handleApiError(error, { operation: 'getDefaultBranch', npub: context.npub, repo: context.repo }); + } + } +); diff --git a/src/routes/api/repos/[npub]/[repo]/file/+server.ts b/src/routes/api/repos/[npub]/[repo]/file/+server.ts index 95fdfdc..415347a 100644 --- a/src/routes/api/repos/[npub]/[repo]/file/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/file/+server.ts @@ -19,17 +19,33 @@ import { KIND } from '$lib/types/nostr.js'; import { join } from 'path'; import { existsSync } from 'fs'; import { repoCache, RepoCache } from '$lib/services/git/repo-cache.js'; +import { extractRequestContext } from '$lib/utils/api-context.js'; const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT ? process.env.GIT_REPO_ROOT : '/repos'; const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); -export const GET: RequestHandler = async ({ params, url, request }: { params: { npub?: string; repo?: string }; url: URL; request: Request }) => { +export const GET: RequestHandler = async (event) => { + const { params, url, request } = event; const { npub, repo } = params; const filePath = url.searchParams.get('path'); let ref = url.searchParams.get('ref') || 'HEAD'; - const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey'); + + // Extract user pubkey using the same method as other endpoints + const requestContext = extractRequestContext(event); + const userPubkey = requestContext.userPubkey; + const userPubkeyHex = requestContext.userPubkeyHex; + + // Debug logging for file endpoint + logger.debug({ + hasUserPubkey: !!userPubkey, + hasUserPubkeyHex: !!userPubkeyHex, + userPubkeyHex: userPubkeyHex ? userPubkeyHex.substring(0, 16) + '...' : null, + npub, + repo, + filePath + }, 'File endpoint - extracted user context'); if (!npub || !repo || !filePath) { return error(400, 'Missing npub, repo, or path parameter'); @@ -61,11 +77,16 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: { if (events.length > 0) { // Try API-based fetching first (no cloning) - const { tryApiFetchFile } = await import('$lib/utils/api-repo-helper.js'); - const fileContent = await tryApiFetchFile(events[0], npub, repo, filePath, ref); - - if (fileContent) { - return json(fileContent); + try { + const { tryApiFetchFile } = await import('$lib/utils/api-repo-helper.js'); + const fileContent = await tryApiFetchFile(events[0], npub, repo, filePath, ref); + + if (fileContent && fileContent.content) { + return json(fileContent); + } + } catch (apiErr) { + // Log the error but don't throw - we'll return a helpful error message below + logger.debug({ error: apiErr, npub, repo, filePath, ref }, 'API file fetch failed, will return 404'); } // API fetch failed - repo is not cloned and API fetch didn't work @@ -74,6 +95,7 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: { return error(404, 'Repository announcement not found in Nostr'); } } catch (err) { + logger.error({ error: err, npub, repo, filePath }, 'Error in on-demand file fetch'); // Check if repo was created by another concurrent request if (existsSync(repoPath)) { // Repo exists now, clear cache and continue with normal flow @@ -103,22 +125,43 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: { try { const branches = await fileManager.getBranches(npub, repo); if (!branches.includes(ref)) { - // Branch doesn't exist, use default branch - ref = await fileManager.getDefaultBranch(npub, repo); + // Branch doesn't exist, try to get default branch + try { + ref = await fileManager.getDefaultBranch(npub, repo); + logger.debug({ npub, repo, originalRef: url.searchParams.get('ref'), newRef: ref }, 'Branch not found, using default branch'); + } catch (defaultBranchErr) { + // If we can't get default branch, fall back to HEAD + logger.warn({ error: defaultBranchErr, npub, repo, ref }, 'Could not get default branch, falling back to HEAD'); + ref = 'HEAD'; + } } - } catch { + } catch (branchErr) { // If we can't get branches, fall back to HEAD + logger.warn({ error: branchErr, npub, repo, ref }, 'Could not get branches, falling back to HEAD'); ref = 'HEAD'; } } // Check repository privacy (repoOwnerPubkey already declared above) - const canView = await maintainerService.canView(userPubkey || null, repoOwnerPubkey, repo); + logger.debug({ + userPubkeyHex: userPubkeyHex ? userPubkeyHex.substring(0, 16) + '...' : null, + repoOwnerPubkey: repoOwnerPubkey.substring(0, 16) + '...', + repo + }, 'File endpoint - checking canView before access check'); + + const canView = await maintainerService.canView(userPubkeyHex || null, repoOwnerPubkey, repo); + + logger.debug({ + canView, + userPubkeyHex: userPubkeyHex ? userPubkeyHex.substring(0, 16) + '...' : null, + repoOwnerPubkey: repoOwnerPubkey.substring(0, 16) + '...', + repo + }, 'File endpoint - canView result'); + if (!canView) { - const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'; auditLogger.logFileOperation( - userPubkey || null, - clientIp, + userPubkeyHex || null, + requestContext.clientIp, 'read', `${npub}/${repo}`, filePath, @@ -127,13 +170,38 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: { ); return error(403, 'This repository is private. Only owners and maintainers can view it.'); } - - const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'; try { - const fileContent = await fileManager.getFileContent(npub, repo, filePath, ref); + // Log what we're trying to do + logger.debug({ npub, repo, filePath, ref }, 'Attempting to read file from cloned repository'); + + let fileContent; + try { + fileContent = await fileManager.getFileContent(npub, repo, filePath, ref); + } catch (firstErr) { + // If the first attempt fails and ref is not HEAD, try with HEAD as fallback + if (ref !== 'HEAD' && !ref.startsWith('refs/')) { + logger.warn({ + error: firstErr, + npub, + repo, + filePath, + originalRef: ref + }, 'Failed to read file with specified ref, trying HEAD as fallback'); + try { + fileContent = await fileManager.getFileContent(npub, repo, filePath, 'HEAD'); + ref = 'HEAD'; // Update ref for logging + } catch (headErr) { + // If HEAD also fails, throw the original error + throw firstErr; + } + } else { + throw firstErr; + } + } + auditLogger.logFileOperation( - userPubkey || null, - clientIp, + userPubkeyHex || null, + requestContext.clientIp, 'read', `${npub}/${repo}`, filePath, @@ -141,18 +209,75 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: { ); return json(fileContent); } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + const errorLower = errorMessage.toLowerCase(); + const errorStack = err instanceof Error ? err.stack : undefined; + + logger.error({ + error: err, + errorStack, + npub, + repo, + filePath, + ref, + repoExists: existsSync(repoPath), + errorMessage + }, 'Error reading file from cloned repository'); auditLogger.logFileOperation( - userPubkey || null, - clientIp, + userPubkeyHex || null, + requestContext.clientIp, 'read', `${npub}/${repo}`, filePath, 'failure', - err instanceof Error ? err.message : String(err) + errorMessage ); - throw err; + // If file not found or path doesn't exist, return 404 instead of 500 + if (errorLower.includes('not found') || + errorLower.includes('no such file') || + errorLower.includes('does not exist') || + errorLower.includes('fatal:') || + errorMessage.includes('pathspec')) { + return error(404, `File not found: ${filePath} at ref ${ref}`); + } + // For other errors, return 500 with a more helpful message + return error(500, `Failed to read file: ${errorMessage}`); } } catch (err) { + // This catch block handles errors that occur outside the file reading try-catch + // (e.g., in branch validation, access checks, etc.) + + // If it's already a Response (from error handlers), return it + if (err instanceof Response) { + return err; + } + + // If it's a SvelteKit HttpError (from error() function), re-throw it + // SvelteKit errors have a status property and body property + if (err && typeof err === 'object' && 'status' in err && 'body' in err) { + throw err; + } + + const errorMessage = err instanceof Error ? err.message : String(err); + const errorStack = err instanceof Error ? err.stack : undefined; + + logger.error({ + error: err, + errorStack, + npub, + repo, + filePath, + ref: url.searchParams.get('ref'), + errorMessage + }, 'Unexpected error in file endpoint (outside file reading block)'); + + // Check if it's a "not found" type error + const errorLower = errorMessage.toLowerCase(); + if (errorLower.includes('not found') || + errorLower.includes('repository not found')) { + return error(404, errorMessage); + } + return handleApiError(err, { operation: 'readFile', npub, repo, filePath }, 'Failed to read file'); } }; @@ -168,7 +293,7 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: { try { const body = await request.json(); path = body.path; - const { content, commitMessage, authorName, authorEmail, branch, action, userPubkey, useNIP07, nsecKey } = body; + const { content, commitMessage, authorName, authorEmail, branch, action, userPubkey, useNIP07, nsecKey, commitSignatureEvent } = body; // Check for NIP-98 authentication (for git operations) const authHeader = request.headers.get('Authorization'); @@ -242,13 +367,16 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: { useNIP07?: boolean; nip98Event?: NostrEvent; nsecKey?: string; + commitSignatureEvent?: NostrEvent; } = {}; - if (useNIP07) { - signingOptions.useNIP07 = true; + // If client sent a pre-signed commit signature event (from NIP-07), use it + if (commitSignatureEvent && commitSignatureEvent.sig && commitSignatureEvent.id) { + signingOptions.commitSignatureEvent = commitSignatureEvent; } else if (nip98Event) { signingOptions.nip98Event = nip98Event; } + // Note: useNIP07 is no longer used since signing happens client-side // Explicitly ignore nsecKey from client requests - it's a security risk // Server-side signing should use NOSTRGIT_SECRET_KEY environment variable instead if (nsecKey) { @@ -270,6 +398,9 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: { if (action === 'delete') { try { + // Get default branch if not provided + const targetBranch = branch || await fileManager.getDefaultBranch(npub, repo); + await fileManager.deleteFile( npub, repo, @@ -277,7 +408,7 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: { commitMessage, authorName, authorEmail, - branch || 'main', + targetBranch, Object.keys(signingOptions).length > 0 ? signingOptions : undefined ); auditLogger.logFileOperation( @@ -306,6 +437,9 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: { return error(400, 'Content is required for create/update operations'); } try { + // Get default branch if not provided + const targetBranch = branch || await fileManager.getDefaultBranch(npub, repo); + await fileManager.writeFile( npub, repo, @@ -314,7 +448,7 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: { commitMessage, authorName, authorEmail, - branch || 'main', + targetBranch, Object.keys(signingOptions).length > 0 ? signingOptions : undefined ); auditLogger.logFileOperation( diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index f5b8845..66d9cf2 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -52,7 +52,8 @@ let hasChanges = $state(false); let saving = $state(false); let branches = $state>([]); - let currentBranch = $state('main'); + let currentBranch = $state(null); + let defaultBranch = $state(null); let commitMessage = $state(''); let userPubkey = $state(null); let userPubkeyHex = $state(null); @@ -73,9 +74,16 @@ if (wasDifferent) { // Reset repoNotFound flag when user logs in, so we can retry loading repoNotFound = false; + // Clear cached email and name when user changes + cachedUserEmail = null; + cachedUserName = null; checkMaintainerStatus().catch(err => console.warn('Failed to reload maintainer status after login:', err)); loadBookmarkStatus().catch(err => console.warn('Failed to reload bookmark status after login:', err)); + // Recheck clone status after login (force refresh) - delay slightly to ensure auth headers are ready + setTimeout(() => { + checkCloneStatus(true).catch(err => console.warn('Failed to recheck clone status after login:', err)); + }, 100); // Reload all repository data with the new user context if (!loading) { loadBranches().catch(err => console.warn('Failed to reload branches after login:', err)); @@ -89,6 +97,9 @@ } else { userPubkey = null; userPubkeyHex = null; + // Clear cached email and name when user logs out + cachedUserEmail = null; + cachedUserName = null; // Reload data when user logs out to hide private content if (wasLoggedIn) { @@ -113,7 +124,7 @@ // Branch creation let showCreateBranchDialog = $state(false); let newBranchName = $state(''); - let newBranchFrom = $state('main'); + let newBranchFrom = $state(null); // Commit history let commits = $state>([]); @@ -557,18 +568,25 @@ return count; } - async function checkCloneStatus() { - if (checkingCloneStatus || isRepoCloned !== null) return; + async function checkCloneStatus(force: boolean = false) { + if (checkingCloneStatus || (!force && isRepoCloned !== null)) return; checkingCloneStatus = true; try { // Check if repo exists locally by trying to fetch branches - // If it returns 404, repo is not cloned + // 404 = repo not cloned, 403 = repo exists but access denied (cloned), 200 = cloned and accessible const response = await fetch(`/api/repos/${npub}/${repo}/branches`, { headers: buildApiHeaders() }); - isRepoCloned = response.ok; + // If response is 403, repo exists (cloned) but user doesn't have access + // If response is 404, repo doesn't exist (not cloned) + // If response is 200, repo exists and is accessible (cloned) + const wasCloned = response.status !== 404; + isRepoCloned = wasCloned; + console.log(`[Clone Status] Repo ${wasCloned ? 'is cloned' : 'is not cloned'} (status: ${response.status})`); } catch (err) { + // On error, assume not cloned + console.warn('[Clone Status] Error checking clone status:', err); isRepoCloned = false; } finally { checkingCloneStatus = false; @@ -594,14 +612,23 @@ } const data = await response.json(); - isRepoCloned = true; if (data.alreadyExists) { alert('Repository already exists locally.'); + // Force refresh clone status + await checkCloneStatus(true); } else { alert('Repository cloned successfully! The repository is now available on this server.'); - // Reload the page to show the cloned repo - window.location.reload(); + // Force refresh clone status + await checkCloneStatus(true); + // Reload data to use the cloned repo instead of API + await Promise.all([ + loadBranches(), + loadFiles(currentPath), + loadReadme(), + loadTags(), + loadCommitHistory() + ]); } } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to clone repository'; @@ -1282,14 +1309,7 @@ loading = false; return; } - // Update currentBranch to first available branch if 'main' doesn't exist - if (branches.length > 0) { - // Branches can be an array of objects with .name property or array of strings - const branchNames = branches.map((b: any) => typeof b === 'string' ? b : b.name); - if (!branchNames.includes(currentBranch)) { - currentBranch = branchNames[0]; - } - } + // loadBranches() already handles setting currentBranch to the default branch await loadFiles(); await checkAuth(); await loadTags(); @@ -1552,8 +1572,34 @@ if (branches.length > 0) { // Branches can be an array of objects with .name property or array of strings const branchNames = branches.map((b: any) => typeof b === 'string' ? b : b.name); - if (!branchNames.includes(currentBranch)) { - currentBranch = branchNames[0]; + + // Fetch the actual default branch from the API + try { + const defaultBranchResponse = await fetch(`/api/repos/${npub}/${repo}/default-branch`, { + headers: buildApiHeaders() + }); + if (defaultBranchResponse.ok) { + const defaultBranchData = await defaultBranchResponse.json(); + defaultBranch = defaultBranchData.defaultBranch || defaultBranchData.branch || null; + } + } catch (err) { + console.warn('Failed to fetch default branch, using fallback logic:', err); + } + + // Fallback: Detect default branch: prefer master, then main, then first branch + if (!defaultBranch) { + if (branchNames.includes('master')) { + defaultBranch = 'master'; + } else if (branchNames.includes('main')) { + defaultBranch = 'main'; + } else { + defaultBranch = branchNames[0]; + } + } + + // Only update currentBranch if it's not set or if the current branch doesn't exist + if (!currentBranch || !branchNames.includes(currentBranch)) { + currentBranch = defaultBranch; } } } else if (response.status === 404) { @@ -1590,7 +1636,10 @@ } else if (response.status === 403) { // 403 means access denied - don't set repoNotFound, just show error // This allows retry after login - throw new Error(`Access denied: ${response.statusText}. You may need to log in or you may not have permission to view this repository.`); + const accessDeniedError = new Error(`Access denied: ${response.statusText}. You may need to log in or you may not have permission to view this repository.`); + // Log as info since this is normal client behavior (not logged in or no access) + console.info('Access denied (normal behavior):', accessDeniedError.message); + throw accessDeniedError; } throw new Error(`Failed to load files: ${response.statusText}`); } @@ -1610,7 +1659,12 @@ } } catch (err) { error = err instanceof Error ? err.message : 'Failed to load files'; - console.error('Error loading files:', err); + // Only log as error if it's not a 403 (access denied), which is normal behavior + if (err instanceof Error && err.message.includes('Access denied')) { + // Already logged as info above, don't log again + } else { + console.error('Error loading files:', err); + } } finally { loading = false; } @@ -1658,13 +1712,18 @@ error = null; try { // Ensure currentBranch is a string (branch name), not an object + // If currentBranch is not set, use the first available branch or 'master' as fallback const branchName = typeof currentBranch === 'string' ? currentBranch : (typeof currentBranch === 'object' && currentBranch !== null && 'name' in currentBranch ? (currentBranch as { name: string }).name - : 'main'); + : (branches.length > 0 + ? (typeof branches[0] === 'string' ? branches[0] : branches[0].name) + : 'master')); const url = `/api/repos/${npub}/${repo}/file?path=${encodeURIComponent(filePath)}&ref=${branchName}`; - const response = await fetch(url); + const response = await fetch(url, { + headers: buildApiHeaders() + }); if (!response.ok) { throw new Error(`Failed to load file: ${response.statusText}`); @@ -1731,6 +1790,179 @@ } } + // Cache for user profile email and name + let cachedUserEmail = $state(null); + let cachedUserName = $state(null); + let fetchingUserEmail = $state(false); + let fetchingUserName = $state(false); + + async function getUserEmail(): Promise { + // Return cached email if available + if (cachedUserEmail) { + return cachedUserEmail; + } + + // If no user pubkey, can't proceed + if (!userPubkeyHex) { + throw new Error('User not authenticated'); + } + + // Prevent concurrent fetches + if (fetchingUserEmail) { + // Wait a bit and retry (shouldn't happen, but just in case) + await new Promise(resolve => setTimeout(resolve, 100)); + if (cachedUserEmail) { + return cachedUserEmail; + } + } + + fetchingUserEmail = true; + let nip05Email: string | null = null; + + try { + const client = new NostrClient(DEFAULT_NOSTR_RELAYS); + const profileEvents = await client.fetchEvents([ + { + kinds: [0], // Kind 0 = profile metadata + authors: [userPubkeyHex], + limit: 1 + } + ]); + + if (profileEvents.length > 0) { + const event = profileEvents[0]; + + // First check tags (newer format where NIP-05 might be in tags) + const nip05Tag = event.tags.find((tag: string[]) => + (tag[0] === 'nip05' || tag[0] === 'l') && tag[1] + ); + if (nip05Tag && nip05Tag[1]) { + nip05Email = nip05Tag[1]; + } + + // Also check JSON content (traditional format) + if (!nip05Email) { + try { + const profile = JSON.parse(event.content); + // NIP-05 is stored as 'nip05' in the profile JSON + if (profile.nip05 && typeof profile.nip05 === 'string') { + nip05Email = profile.nip05; + } + } catch { + // Invalid JSON, ignore + } + } + } + } catch (err) { + console.warn('Failed to fetch user profile for email:', err); + } finally { + fetchingUserEmail = false; + } + + // Always prompt user for email address (they might want to use a different domain) + // Always use userPubkeyHex to generate npub (userPubkey might be hex instead of npub) + const npubFromPubkey = userPubkeyHex ? nip19.npubEncode(userPubkeyHex) : (userPubkey || 'unknown'); + const fallbackEmail = `${npubFromPubkey}@nostr`; + const prefillEmail = nip05Email || fallbackEmail; + + // Prompt user for email address + const userEmail = prompt( + 'Please enter your email address for git commits.\n\n' + + 'This will be used as the author email in your commits.\n' + + 'You can use any email address you prefer.', + prefillEmail + ); + + if (userEmail && userEmail.trim()) { + // Basic email validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (emailRegex.test(userEmail.trim())) { + cachedUserEmail = userEmail.trim(); + return cachedUserEmail; + } else { + alert('Invalid email format. Using fallback email address.'); + } + } + + // Use fallback if user cancelled or entered invalid email + cachedUserEmail = fallbackEmail; + return cachedUserEmail; + } + + async function getUserName(): Promise { + // Return cached name if available + if (cachedUserName) { + return cachedUserName; + } + + // If no user pubkey, can't proceed + if (!userPubkeyHex) { + throw new Error('User not authenticated'); + } + + // Prevent concurrent fetches + if (fetchingUserName) { + // Wait a bit and retry (shouldn't happen, but just in case) + await new Promise(resolve => setTimeout(resolve, 100)); + if (cachedUserName) { + return cachedUserName; + } + } + + fetchingUserName = true; + let profileName: string | null = null; + + try { + const client = new NostrClient(DEFAULT_NOSTR_RELAYS); + const profileEvents = await client.fetchEvents([ + { + kinds: [0], // Kind 0 = profile metadata + authors: [userPubkeyHex], + limit: 1 + } + ]); + + if (profileEvents.length > 0) { + try { + const profile = JSON.parse(profileEvents[0].content); + // Name is stored as 'name' in the profile JSON + if (profile.name && typeof profile.name === 'string') { + profileName = profile.name; + } + } catch { + // Invalid JSON, ignore + } + } + } catch (err) { + console.warn('Failed to fetch user profile for name:', err); + } finally { + fetchingUserName = false; + } + + // Always prompt user for name (they might want to use a different name) + // Always use userPubkeyHex to generate npub (userPubkey might be hex instead of npub) + const npubFromPubkey = userPubkeyHex ? nip19.npubEncode(userPubkeyHex) : (userPubkey || 'unknown'); + const fallbackName = npubFromPubkey; + const prefillName = profileName || fallbackName; + + // Prompt user for name + const userName = prompt( + 'Please enter your name for git commits.\n\n' + + 'This will be used as the author name in your commits.\n' + + 'You can use any name you prefer.', + prefillName + ); + + if (userName && userName.trim()) { + cachedUserName = userName.trim(); + return cachedUserName; + } + + // Use fallback if user cancelled + cachedUserName = fallbackName; + return cachedUserName; + } + async function saveFile() { if (!currentFile || !commitMessage.trim()) { alert('Please enter a commit message'); @@ -1742,12 +1974,42 @@ return; } + // Validate branch selection + if (!currentBranch || typeof currentBranch !== 'string') { + alert('Please select a branch before saving the file'); + return; + } + saving = true; error = null; try { - // Get npub from pubkey - const npubFromPubkey = nip19.npubEncode(userPubkey); + // Get user email and name (from profile or prompt) + const authorEmail = await getUserEmail(); + const authorName = await getUserName(); + + // Sign commit with NIP-07 (client-side) + let commitSignatureEvent: NostrEvent | null = null; + if (isNIP07Available()) { + try { + const { KIND } = await import('$lib/types/nostr.js'); + const timestamp = Math.floor(Date.now() / 1000); + const eventTemplate: Omit = { + kind: KIND.COMMIT_SIGNATURE, + pubkey: '', // Will be filled by NIP-07 + created_at: timestamp, + tags: [ + ['author', authorName, authorEmail], + ['message', commitMessage.trim()] + ], + content: `Signed commit: ${commitMessage.trim()}` + }; + commitSignatureEvent = await signEventWithNIP07(eventTemplate); + } catch (err) { + console.warn('Failed to sign commit with NIP-07:', err); + // Continue without signature if signing fails + } + } const response = await fetch(`/api/repos/${npub}/${repo}/file`, { method: 'POST', @@ -1759,11 +2021,11 @@ path: currentFile, content: editedContent, commitMessage: commitMessage.trim(), - authorName: 'Web Editor', - authorEmail: `${npubFromPubkey}@nostr`, + authorName: authorName, + authorEmail: authorEmail, branch: currentBranch, userPubkey: userPubkey, - useNIP07: true // Use NIP-07 for commit signing in web UI + commitSignatureEvent: commitSignatureEvent // Send the signed event to server }) }); @@ -1786,14 +2048,37 @@ } } - function handleBranchChange(event: Event) { + async function handleBranchChange(event: Event) { const target = event.target as HTMLSelectElement; currentBranch = target.value; + + // Reload all branch-dependent data + const reloadPromises: Promise[] = []; + + // Always reload files (and current file if open) if (currentFile) { - loadFile(currentFile); + reloadPromises.push(loadFile(currentFile).catch(err => console.warn('Failed to reload file after branch change:', err))); } else { - loadFiles(currentPath); + reloadPromises.push(loadFiles(currentPath).catch(err => console.warn('Failed to reload files after branch change:', err))); + } + + // Reload README (branch-specific) + reloadPromises.push(loadReadme().catch(err => console.warn('Failed to reload README after branch change:', err))); + + // Reload commit history if history tab is active + if (activeTab === 'history') { + reloadPromises.push(loadCommitHistory().catch(err => console.warn('Failed to reload commit history after branch change:', err))); + } + + // Reload documentation if docs tab is active (might be branch-specific) + if (activeTab === 'docs') { + // Reset documentation HTML to force reload + documentationHtml = null; + reloadPromises.push(loadDocumentation().catch(err => console.warn('Failed to reload documentation after branch change:', err))); } + + // Wait for all reloads to complete + await Promise.all(reloadPromises); } async function createFile() { @@ -1807,12 +2092,44 @@ return; } + // Validate branch selection + if (!currentBranch || typeof currentBranch !== 'string') { + alert('Please select a branch before creating the file'); + return; + } + saving = true; error = null; try { - const npubFromPubkey = nip19.npubEncode(userPubkey); + // Get user email and name (from profile or prompt) + const authorEmail = await getUserEmail(); + const authorName = await getUserName(); const filePath = currentPath ? `${currentPath}/${newFileName}` : newFileName; + const commitMsg = `Create ${newFileName}`; + + // Sign commit with NIP-07 (client-side) + let commitSignatureEvent: NostrEvent | null = null; + if (isNIP07Available()) { + try { + const { KIND } = await import('$lib/types/nostr.js'); + const timestamp = Math.floor(Date.now() / 1000); + const eventTemplate: Omit = { + kind: KIND.COMMIT_SIGNATURE, + pubkey: '', // Will be filled by NIP-07 + created_at: timestamp, + tags: [ + ['author', authorName, authorEmail], + ['message', commitMsg] + ], + content: `Signed commit: ${commitMsg}` + }; + commitSignatureEvent = await signEventWithNIP07(eventTemplate); + } catch (err) { + console.warn('Failed to sign commit with NIP-07:', err); + // Continue without signature if signing fails + } + } const response = await fetch(`/api/repos/${npub}/${repo}/file`, { method: 'POST', @@ -1823,12 +2140,13 @@ body: JSON.stringify({ path: filePath, content: newFileContent, - commitMessage: `Create ${newFileName}`, - authorName: 'Web Editor', - authorEmail: `${npubFromPubkey}@nostr`, + commitMessage: commitMsg, + authorName: authorName, + authorEmail: authorEmail, branch: currentBranch, action: 'create', - userPubkey: userPubkey + userPubkey: userPubkey, + commitSignatureEvent: commitSignatureEvent // Send the signed event to server }) }); @@ -1859,11 +2177,43 @@ return; } + // Validate branch selection + if (!currentBranch || typeof currentBranch !== 'string') { + alert('Please select a branch before deleting the file'); + return; + } + saving = true; error = null; try { - const npubFromPubkey = nip19.npubEncode(userPubkey); + // Get user email and name (from profile or prompt) + const authorEmail = await getUserEmail(); + const authorName = await getUserName(); + const commitMsg = `Delete ${filePath}`; + + // Sign commit with NIP-07 (client-side) + let commitSignatureEvent: NostrEvent | null = null; + if (isNIP07Available()) { + try { + const { KIND } = await import('$lib/types/nostr.js'); + const timestamp = Math.floor(Date.now() / 1000); + const eventTemplate: Omit = { + kind: KIND.COMMIT_SIGNATURE, + pubkey: '', // Will be filled by NIP-07 + created_at: timestamp, + tags: [ + ['author', authorName, authorEmail], + ['message', commitMsg] + ], + content: `Signed commit: ${commitMsg}` + }; + commitSignatureEvent = await signEventWithNIP07(eventTemplate); + } catch (err) { + console.warn('Failed to sign commit with NIP-07:', err); + // Continue without signature if signing fails + } + } const response = await fetch(`/api/repos/${npub}/${repo}/file`, { method: 'POST', @@ -1873,12 +2223,13 @@ }, body: JSON.stringify({ path: filePath, - commitMessage: `Delete ${filePath}`, - authorName: 'Web Editor', - authorEmail: `${npubFromPubkey}@nostr`, + commitMessage: commitMsg, + authorName: authorName, + authorEmail: authorEmail, branch: currentBranch, action: 'delete', - userPubkey: userPubkey + userPubkey: userPubkey, + commitSignatureEvent: commitSignatureEvent // Send the signed event to server }) }); @@ -1917,7 +2268,7 @@ }, body: JSON.stringify({ branchName: newBranchName, - fromBranch: newBranchFrom + fromBranch: newBranchFrom || currentBranch }) }); @@ -1937,6 +2288,52 @@ } } + async function deleteBranch(branchName: string) { + if (!confirm(`Are you sure you want to delete the branch "${branchName}"? This action cannot be undone.`)) { + return; + } + + if (!userPubkey) { + alert('Please connect your NIP-07 extension'); + return; + } + + // Prevent deleting the current branch + if (branchName === currentBranch) { + alert('Cannot delete the currently selected branch. Please switch to a different branch first.'); + return; + } + + saving = true; + error = null; + + try { + const response = await fetch(`/api/repos/${npub}/${repo}/branches`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + ...buildApiHeaders() + }, + body: JSON.stringify({ + branchName: branchName + }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || 'Failed to delete branch'); + } + + await loadBranches(); + alert('Branch deleted successfully!'); + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to delete branch'; + alert(error); + } finally { + saving = false; + } + } + async function loadCommitHistory() { loadingCommits = true; error = null; @@ -2231,12 +2628,34 @@ } }); - // Only load readme when branch changes, not on every render + // Reload all branch-dependent data when branch changes let lastBranch = $state(null); $effect(() => { if (currentBranch && currentBranch !== lastBranch) { lastBranch = currentBranch; - loadReadme(); + + // Reload README (always branch-specific) + loadReadme().catch(err => console.warn('Failed to reload README after branch change:', err)); + + // Reload files if files tab is active + if (activeTab === 'files') { + if (currentFile) { + loadFile(currentFile).catch(err => console.warn('Failed to reload file after branch change:', err)); + } else { + loadFiles(currentPath).catch(err => console.warn('Failed to reload files after branch change:', err)); + } + } + + // Reload commit history if history tab is active + if (activeTab === 'history') { + loadCommitHistory().catch(err => console.warn('Failed to reload commit history after branch change:', err)); + } + + // Reload documentation if docs tab is active (reset to force reload) + if (activeTab === 'docs') { + documentationHtml = null; + loadDocumentation().catch(err => console.warn('Failed to reload documentation after branch change:', err)); + } } }); @@ -2417,14 +2836,31 @@
- {#if branches.length > 0} - - {/if} +
+ + {#if isMaintainer && branches.length > 0 && currentBranch && branches.length > 1} + {@const canDelete = defaultBranch !== null && currentBranch !== defaultBranch} + {#if canDelete && currentBranch} + + {/if} + {/if} +
{#if verificationStatus} {#if verificationStatus.verified}