diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 3cda3a7..79677e5 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -66,3 +66,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771849427,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"1d4e6ff4059b064d7cdd465d623a606cfcc5d0565681a34f6384463d40cc8c71","sig":"f5fe3547289e994ff1a3b191607e76d778d318ca4538e70253406867ecef214c1be437dca373f9a461c9cf2ca2978a581b54a9d323baeb2c91851e9cc6ffbfd6"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771850840,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","rearrange repo pages"]],"content":"Signed commit: rearrange repo pages","id":"9f8b68f36189073807510a2dac268b466629ecbc6b8dca66ba809cbf3a36dab5","sig":"911debb546c23038bbf77a57bee089130c7cce3a51f2cfb385c3904ec39bc76b90dc9bef2e8e501824ecff13925523d802b6c916d07fef2718554f4f65e6f4d2"} {"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"} diff --git a/src/lib/services/git/file-manager.ts b/src/lib/services/git/file-manager.ts index efa0c51..5d8afd8 100644 --- a/src/lib/services/git/file-manager.ts +++ b/src/lib/services/git/file-manager.ts @@ -1422,10 +1422,11 @@ export class FileManager { async saveRepoEventToWorktree( worktreePath: string, event: NostrEvent, - eventType: 'announcement' | 'transfer' - ): Promise { + eventType: 'announcement' | 'transfer', + skipIfExists: boolean = true + ): Promise { try { - const { mkdir, writeFile } = await import('fs/promises'); + const { mkdir, writeFile, readFile } = await import('fs/promises'); const { join } = await import('path'); // Create nostr directory in worktree @@ -1434,15 +1435,39 @@ export class FileManager { // Append to repo-events.jsonl with event type metadata const jsonlFile = join(nostrDir, 'repo-events.jsonl'); + + // Check if event already exists if skipIfExists is true + if (skipIfExists) { + try { + const existingContent = await readFile(jsonlFile, 'utf-8'); + const lines = existingContent.trim().split('\n').filter(l => l.trim()); + for (const line of lines) { + try { + const parsed = JSON.parse(line); + if (parsed.event && parsed.event.id === event.id) { + logger.debug({ eventId: event.id, worktreePath }, 'Event already exists in nostr/repo-events.jsonl, skipping'); + return false; + } + } catch { + // Skip invalid JSON lines + } + } + } catch { + // File doesn't exist yet, that's fine + } + } + const eventLine = JSON.stringify({ type: eventType, timestamp: event.created_at, event }) + '\n'; await writeFile(jsonlFile, eventLine, { flag: 'a', encoding: 'utf-8' }); + return true; } catch (err) { logger.debug({ error: err, worktreePath, eventType }, 'Failed to save repo event to nostr/repo-events.jsonl'); // Don't throw - this is a nice-to-have feature + return false; } } @@ -1765,7 +1790,8 @@ export class FileManager { npub: string, repoName: string, branchName: string, - fromBranch?: string + fromBranch?: string, + announcement?: NostrEvent ): Promise { // Security: Validate branch names to prevent path traversal if (!isValidBranchName(branchName)) { @@ -1796,29 +1822,34 @@ export class FileManager { // If no branches exist, create an orphan branch (branch with no parent) if (!hasBranches) { - // Fetch repo announcement to use as initial commit message + // Use provided announcement or fetch repo announcement to use as initial commit message and save to file let commitMessage = 'Initial commit'; - try { - const { NostrClient } = await import('../nostr/nostr-client.js'); - const { DEFAULT_NOSTR_RELAYS } = await import('../../config.js'); - const { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } = await import('../../utils/nostr-utils.js'); - const { eventCache } = await import('../nostr/event-cache.js'); - const { requireNpubHex } = await import('../../utils/npub-utils.js'); - - const repoOwnerPubkey = requireNpubHex(npub); - const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); - const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, repoOwnerPubkey, eventCache); - const announcement = findRepoAnnouncement(allEvents, repoName); - - if (announcement) { - // Format announcement as commit message - const name = announcement.tags.find((t: string[]) => t[0] === 'name')?.[1] || repoName; - const description = announcement.tags.find((t: string[]) => t[0] === 'description')?.[1] || ''; - commitMessage = `Repository announcement: ${name}${description ? '\n\n' + description : ''}\n\nEvent ID: ${announcement.id}`; - logger.debug({ branchName, announcementId: announcement.id }, 'Using repo announcement as initial commit message'); + let announcementEvent: NostrEvent | null = announcement || null; + + // If announcement not provided, try to fetch it + if (!announcementEvent) { + try { + const { NostrClient } = await import('../nostr/nostr-client.js'); + const { DEFAULT_NOSTR_RELAYS } = await import('../../config.js'); + const { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } = await import('../../utils/nostr-utils.js'); + const { eventCache } = await import('../nostr/event-cache.js'); + const { requireNpubHex } = await import('../../utils/npub-utils.js'); + + const repoOwnerPubkey = requireNpubHex(npub); + const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); + const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, repoOwnerPubkey, eventCache); + announcementEvent = findRepoAnnouncement(allEvents, repoName); + } catch (announcementErr) { + logger.debug({ error: announcementErr, branchName }, 'Failed to fetch announcement, using default commit message'); } - } catch (announcementErr) { - logger.debug({ error: announcementErr, branchName }, 'Failed to fetch announcement, using default commit message'); + } + + if (announcementEvent) { + // Format announcement as commit message + const name = announcementEvent.tags.find((t: string[]) => t[0] === 'name')?.[1] || repoName; + const description = announcementEvent.tags.find((t: string[]) => t[0] === 'description')?.[1] || ''; + commitMessage = `Repository announcement: ${name}${description ? '\n\n' + description : ''}\n\nEvent ID: ${announcementEvent.id}`; + logger.debug({ branchName, announcementId: announcementEvent.id }, 'Using repo announcement as initial commit message'); } // Create worktree for the new branch directly (orphan branch) @@ -1852,16 +1883,40 @@ export class FileManager { // Note: --orphan must come before branch name, path comes last await git.raw(['worktree', 'add', '--orphan', branchName, worktreePath]); - // Create initial empty commit with announcement as message + // Save announcement to nostr/repo-events.jsonl if we have it + let announcementSaved = false; + if (announcementEvent) { + try { + announcementSaved = await this.saveRepoEventToWorktree(worktreePath, announcementEvent, 'announcement', true); + logger.debug({ branchName, announcementId: announcementEvent.id }, 'Saved announcement to nostr/repo-events.jsonl'); + } catch (saveErr) { + logger.warn({ error: saveErr, branchName }, 'Failed to save announcement to nostr/repo-events.jsonl, continuing with empty commit'); + } + } + + // Stage files if announcement was saved const workGit: SimpleGit = simpleGit(worktreePath); - await workGit.commit(commitMessage, ['--allow-empty'], { - '--author': 'GitRepublic ' - }); + const filesToAdd: string[] = []; + if (announcementSaved) { + filesToAdd.push('nostr/repo-events.jsonl'); + } + + // Create initial commit with announcement file (if saved) or empty commit + if (filesToAdd.length > 0) { + await workGit.add(filesToAdd); + await workGit.commit(commitMessage, filesToAdd, { + '--author': 'GitRepublic ' + }); + } else { + await workGit.commit(commitMessage, ['--allow-empty'], { + '--author': 'GitRepublic ' + }); + } // Set the default branch to the new branch in the bare repo await git.raw(['symbolic-ref', 'HEAD', `refs/heads/${branchName}`]); - logger.debug({ branchName }, 'Created orphan branch with initial commit'); + logger.debug({ branchName, announcementSaved }, 'Created orphan branch with initial commit'); // Clean up worktree await this.removeWorktree(repoPath, worktreePath); diff --git a/src/routes/api/repos/[npub]/[repo]/branches/+server.ts b/src/routes/api/repos/[npub]/[repo]/branches/+server.ts index 2ae68c8..30b8a5a 100644 --- a/src/routes/api/repos/[npub]/[repo]/branches/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/branches/+server.ts @@ -161,7 +161,7 @@ export const GET: RequestHandler = createRepoGetHandler( export const POST: RequestHandler = createRepoPostHandler( async (context: RepoRequestContext, event: RequestEvent) => { const body = await event.request.json(); - const { branchName, fromBranch } = body; + const { branchName, fromBranch, announcement } = body; if (!branchName) { throw handleValidationError('Missing branchName parameter', { operation: 'createBranch', npub: context.npub, repo: context.repo }); @@ -269,7 +269,7 @@ export const POST: RequestHandler = createRepoPostHandler( } // If repo has no branches, sourceBranch will be undefined/null, which createBranch will handle correctly - await fileManager.createBranch(context.npub, context.repo, branchName, sourceBranch); + await fileManager.createBranch(context.npub, context.repo, branchName, sourceBranch, announcement); return json({ success: true, message: 'Branch created successfully' }); }, { operation: 'createBranch', requireRepoExists: false } // Allow creating branches in empty repos diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index f944517..b952d98 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -30,22 +30,37 @@ description?: string; image?: string; banner?: string; - repoName?: string; - repoDescription?: string; repoUrl?: string; - repoCloneUrls?: string[]; - repoMaintainers?: string[]; - repoOwnerPubkey?: string; - repoLanguage?: string; - repoTopics?: string[]; - repoWebsite?: string; - repoIsPrivate?: boolean; + announcement?: NostrEvent; gitDomain?: string; }); const npub = ($page.params as { npub?: string; repo?: string }).npub || ''; const repo = ($page.params as { npub?: string; repo?: string }).repo || ''; + // Extract fields from announcement for convenience + const repoAnnouncement = $derived(pageData.announcement); + const repoName = $derived(repoAnnouncement?.tags.find((t: string[]) => t[0] === 'name')?.[1] || repo); + const repoDescription = $derived(repoAnnouncement?.tags.find((t: string[]) => t[0] === 'description')?.[1] || ''); + const repoCloneUrls = $derived(repoAnnouncement?.tags + .filter((t: string[]) => t[0] === 'clone') + .flatMap((t: string[]) => t.slice(1)) + .filter((url: string) => url && typeof url === 'string') as string[] || []); + const repoMaintainers = $derived(repoAnnouncement?.tags + .filter((t: string[]) => t[0] === 'maintainers') + .flatMap((t: string[]) => t.slice(1)) + .filter((m: string) => m && typeof m === 'string') as string[] || []); + const repoOwnerPubkeyDerived = $derived(repoAnnouncement?.pubkey || ''); + const repoLanguage = $derived(repoAnnouncement?.tags.find((t: string[]) => t[0] === 'language')?.[1]); + const repoTopics = $derived(repoAnnouncement?.tags + .filter((t: string[]) => t[0] === 't' && t[1] !== 'private') + .map((t: string[]) => t[1]) + .filter((t: string) => t && typeof t === 'string') as string[] || []); + const repoWebsite = $derived(repoAnnouncement?.tags.find((t: string[]) => t[0] === 'website')?.[1]); + const repoIsPrivate = $derived(repoAnnouncement?.tags.some((t: string[]) => + (t[0] === 'private' && t[1] === 'true') || (t[0] === 't' && t[1] === 'private') + ) || false); + let loading = $state(true); let error = $state(null); let repoNotFound = $state(false); // Track if repository doesn't exist @@ -91,7 +106,7 @@ // 1. We have page data // 2. Effect hasn't run yet for this repo // 3. We're not currently loading - if ((data.repoOwnerPubkey || (data.repoMaintainers && data.repoMaintainers.length > 0)) && + if ((repoOwnerPubkeyDerived || (repoMaintainers && repoMaintainers.length > 0)) && !maintainersEffectRan && !loadingMaintainers) { maintainersEffectRan = true; // Mark as ran to prevent re-running @@ -813,8 +828,8 @@ let repoImage = $state(null); let repoBanner = $state(null); - // Repository owner pubkey (decoded from npub) - let repoOwnerPubkey = $state(null); + // Repository owner pubkey (decoded from npub) - kept for backward compatibility with some functions + let repoOwnerPubkeyState = $state(null); // Mobile view toggle for file list/file viewer let showFileListOnMobile = $state(true); @@ -836,7 +851,7 @@ // Load clone URL reachability status async function loadCloneUrlReachability(forceRefresh: boolean = false) { - if (!pageData.repoCloneUrls || pageData.repoCloneUrls.length === 0) { + if (!repoCloneUrls || repoCloneUrls.length === 0) { return; } @@ -1422,7 +1437,7 @@ // If repo is not cloned, check if API fallback is available if (!wasCloned) { // Try to detect API fallback by checking if we have clone URLs - if (pageData.repoCloneUrls && pageData.repoCloneUrls.length > 0) { + if (repoCloneUrls && repoCloneUrls.length > 0) { // We have clone URLs, so API fallback might work - will be detected when loadBranches() runs apiFallbackAvailable = null; // Will be set to true if a subsequent request succeeds } else { @@ -1569,7 +1584,7 @@ const events = await client.fetchEvents([ { kinds: [KIND.REPO_ANNOUNCEMENT], - authors: [repoOwnerPubkey], + authors: [repoOwnerPubkeyDerived], '#d': [repo], limit: 1 } @@ -1702,7 +1717,7 @@ const events = await client.fetchEvents([ { kinds: [KIND.REPO_ANNOUNCEMENT], - authors: [repoOwnerPubkey], + authors: [repoOwnerPubkeyDerived], '#d': [repo], limit: 1 } @@ -1804,7 +1819,7 @@ const events = await client.fetchEvents([ { kinds: [KIND.REPO_ANNOUNCEMENT], - authors: [repoOwnerPubkey], + authors: [repoOwnerPubkeyDerived], '#d': [repo], limit: 1 } @@ -1946,7 +1961,7 @@ try { // Check if repo is private and user has access const data = $page.data as typeof pageData; - if (data.repoIsPrivate) { + if (repoIsPrivate) { // Check access via API const accessResponse = await fetch(`/api/repos/${npub}/${repo}/access`, { headers: buildApiHeaders() @@ -1974,7 +1989,7 @@ const announcementEvents = await client.fetchEvents([ { kinds: [KIND.REPO_ANNOUNCEMENT], - authors: [repoOwnerPubkey], + authors: [repoOwnerPubkeyDerived], '#d': [repo], limit: 1 } @@ -2095,7 +2110,7 @@ if (!repoImage && !repoBanner) { const data = $page.data as typeof pageData; // Check access for private repos - if (data.repoIsPrivate) { + if (repoIsPrivate) { const headers: Record = {}; if (userPubkey) { try { @@ -2129,7 +2144,7 @@ const events = await client.fetchEvents([ { kinds: [30617], // REPO_ANNOUNCEMENT - authors: [repoOwnerPubkey], + authors: [repoOwnerPubkeyDerived], '#d': [repo], limit: 1 } @@ -2187,8 +2202,8 @@ try { const decoded = nip19.decode(npub); if (decoded.type === 'npub') { - repoOwnerPubkey = decoded.data as string; - repoAddress = `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkey}:${repo}`; + repoOwnerPubkeyState = decoded.data as string; + repoAddress = `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkeyState}:${repo}`; } } catch (err) { console.warn('Failed to decode npub for bookmark address:', err); @@ -2436,7 +2451,7 @@ } async function copyEventId() { - if (!repoAddress || !repoOwnerPubkey) { + if (!repoAddress || !repoOwnerPubkeyDerived) { alert('Repository address not available'); return; } @@ -2565,11 +2580,11 @@ console.error('Failed to load maintainers:', err); maintainersLoaded = false; // Reset flag on error // Fallback to pageData if available - if (pageData.repoOwnerPubkey) { - allMaintainers = [{ pubkey: pageData.repoOwnerPubkey, isOwner: true }]; - if (pageData.repoMaintainers) { - for (const maintainer of pageData.repoMaintainers) { - if (maintainer.toLowerCase() !== pageData.repoOwnerPubkey.toLowerCase()) { + if (repoOwnerPubkeyDerived) { + allMaintainers = [{ pubkey: repoOwnerPubkeyDerived, isOwner: true }]; + if (repoMaintainers) { + for (const maintainer of repoMaintainers) { + if (maintainer.toLowerCase() !== repoOwnerPubkeyDerived.toLowerCase()) { allMaintainers.push({ pubkey: maintainer, isOwner: false }); } } @@ -2605,7 +2620,7 @@ } async function generateAnnouncementFileForRepo() { - if (!pageData.repoOwnerPubkey || !userPubkeyHex) { + if (!repoOwnerPubkeyDerived || !userPubkeyHex) { error = 'Unable to generate announcement file: missing repository or user information'; return; } @@ -2616,7 +2631,7 @@ const events = await nostrClient.fetchEvents([ { kinds: [KIND.REPO_ANNOUNCEMENT], - authors: [pageData.repoOwnerPubkey], + authors: [repoOwnerPubkeyDerived], '#d': [repo], limit: 1 } @@ -2654,7 +2669,7 @@ return; } - if (!pageData.repoOwnerPubkey || userPubkeyHex !== pageData.repoOwnerPubkey) { + if (!repoOwnerPubkeyDerived || userPubkeyHex !== repoOwnerPubkeyDerived) { alert('Only the repository owner can delete the announcement'); return; } @@ -2678,7 +2693,7 @@ const events = await nostrClient.fetchEvents([ { kinds: [KIND.REPO_ANNOUNCEMENT], - authors: [pageData.repoOwnerPubkey], + authors: [repoOwnerPubkeyDerived], '#d': [repo], limit: 1 } @@ -2703,7 +2718,7 @@ content: `Requesting deletion of repository announcement for ${repo}`, tags: [ ['e', announcement.id], // Reference to the announcement event - ['a', `${KIND.REPO_ANNOUNCEMENT}:${pageData.repoOwnerPubkey}:${repo}`], // Repository address + ['a', `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkeyDerived}:${repo}`], // Repository address ['k', KIND.REPO_ANNOUNCEMENT.toString()] // Kind of event being deleted ] }; @@ -2814,7 +2829,7 @@ const errorText = await response.text().catch(() => ''); if (errorText.includes('not cloned locally')) { // Repository is not cloned - check if API fallback might be available - if (pageData.repoCloneUrls && pageData.repoCloneUrls.length > 0) { + if (repoCloneUrls && repoCloneUrls.length > 0) { // We have clone URLs, so API fallback might work - mark as unknown for now // It will be set to true if a subsequent request succeeds apiFallbackAvailable = null; @@ -2877,7 +2892,7 @@ const errorText = await response.text().catch(() => ''); if (errorText.includes('not cloned locally')) { // Repository is not cloned - check if API fallback might be available - if (pageData.repoCloneUrls && pageData.repoCloneUrls.length > 0) { + if (repoCloneUrls && repoCloneUrls.length > 0) { // We have clone URLs, so API fallback might work - mark as unknown for now // It will be set to true if a subsequent request succeeds apiFallbackAvailable = null; @@ -3800,13 +3815,17 @@ // Otherwise, use the selected branch or current branch let fromBranch: string | undefined = newBranchFrom || currentBranch || undefined; - // Only include fromBranch if repo has branches - const requestBody: { branchName: string; fromBranch?: string } = { + // Include announcement if available (for empty repos) + const requestBody: { branchName: string; fromBranch?: string; announcement?: NostrEvent } = { branchName: newBranchName }; if (branches.length > 0 && fromBranch) { requestBody.fromBranch = fromBranch; } + // Pass announcement if available (especially useful for empty repos) + if (repoAnnouncement) { + requestBody.announcement = repoAnnouncement; + } const response = await fetch(`/api/repos/${npub}/${repo}/branches`, { method: 'POST', @@ -4452,8 +4471,8 @@ - - + + {#if (pageData.image || repoImage) && String(pageData.image || repoImage).trim()} @@ -4465,8 +4484,8 @@ - - + + {#if pageData.banner || repoBanner} {:else if pageData.image || repoImage} @@ -4486,18 +4505,18 @@ {/if} - {#if repoOwnerPubkey} + {#if repoOwnerPubkeyDerived} goto(`/signup?npub=${npub}&repo=${repo}`)} - onGenerateVerification={pageData.repoOwnerPubkey && userPubkeyHex === pageData.repoOwnerPubkey && verificationStatus?.verified !== true ? generateAnnouncementFileForRepo : undefined} - onDeleteAnnouncement={pageData.repoOwnerPubkey && userPubkeyHex === pageData.repoOwnerPubkey ? deleteAnnouncement : undefined} + onGenerateVerification={repoOwnerPubkeyDerived && userPubkeyHex === repoOwnerPubkeyDerived && verificationStatus?.verified !== true ? generateAnnouncementFileForRepo : undefined} + onDeleteAnnouncement={repoOwnerPubkeyDerived && userPubkeyHex === repoOwnerPubkeyDerived ? deleteAnnouncement : undefined} deletingAnnouncement={deletingAnnouncement} hasUnlimitedAccess={hasUnlimitedAccess($userStore.userLevel)} needsClone={needsClone} @@ -4544,26 +4563,26 @@ - {#if pageData.repoWebsite || (pageData.repoCloneUrls && pageData.repoCloneUrls.length > 0) || pageData.repoLanguage || (pageData.repoTopics && pageData.repoTopics.length > 0) || forkInfo?.isFork} + {#if repoWebsite || (repoCloneUrls && repoCloneUrls.length > 0) || repoLanguage || (repoTopics && repoTopics.length > 0) || forkInfo?.isFork}