diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 0f5622b..43d00a0 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -118,3 +118,5 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772264490,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","polling update"]],"content":"Signed commit: polling update","id":"42c1a2a63a4568c65d82d78701451b3b4363bdf9c8c57e804535b5f3f0d7b6fc","sig":"8e5f32ecb79da876ac41eba04c3b1541b21d039ae50d1b9fefa630d35f31c97dd29af64e4b695742fa7d4eaec17db8f4a066b4db99ce628aed596971975d4a87"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772267611,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor API"]],"content":"Signed commit: refactor API","id":"934f8809638cea0bc7b8158fca959bc60880e0cae9ab8ff653687313adcd2f57","sig":"c9d8e5b821ae8182f8d39599c50fd0a4db6040ead1d8d83730a608a1d94d5078770a6ccbfc525a98691e98fabd9f9d24f0298680fb564c6b76c2f34bed9889b5"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772269280,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","api refactor part 2"]],"content":"Signed commit: api refactor part 2","id":"ece894a60057bba46ebd4ac0dca2aca55ffce05e44671fe07b29516809fc86f6","sig":"176706a271659834e441ea5eab4bb1480667dad4468fe8315803284f4a183debf595523dd33d0d3cabe0c35013f4a72b9169b5f10afefaf8a82a721d8b0f3b08"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772270859,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes and fallback relay"]],"content":"Signed commit: bug-fixes and fallback relay","id":"1d85d0c5e1451c90bca5d59e08043f29adeaad4db4ac5495c8e9a4247775780f","sig":"a1960b76c78db9f64dad20378d26f500ffc09f1f6d137314db548470202712222a1d391f682146ba281fd23355c574fcbb260310db61b3458bba3dec0c724a18"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772271656,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"f4a5e0d3e2aa7d0d99803f26008ab68e40551e36362bb6d04acf639c5b78d959","sig":"59da9e59a6fb5648f4c889e0045b571e0d2d66a555100d60dec373455309a640bea89e4bb3a42a0e502aa4d2091e4b698203721e79b346ff30e6b2bcdc5f48b3"} diff --git a/src/lib/services/git/announcement-manager.ts b/src/lib/services/git/announcement-manager.ts index dbdce37..1dbbcce 100644 --- a/src/lib/services/git/announcement-manager.ts +++ b/src/lib/services/git/announcement-manager.ts @@ -190,7 +190,7 @@ export class AnnouncementManager { * Ensure announcement event is saved to nostr/repo-events.jsonl in the repository * Only saves if not already present (avoids redundant entries) */ - async ensureAnnouncementInRepo(repoPath: string, event: NostrEvent, selfTransferEvent?: NostrEvent): Promise { + async ensureAnnouncementInRepo(repoPath: string, event: NostrEvent, selfTransferEvent?: NostrEvent, preferredDefaultBranch?: string): Promise { let isEmpty = false; try { // Create a temporary working directory @@ -218,8 +218,8 @@ export class AnnouncementManager { if (isEmpty) { // Repo is empty - initialize worktree and create initial branch - // Use default branch from environment or try 'main' first, then 'master' - const defaultBranch = process.env.DEFAULT_BRANCH || 'main'; + // Use preferred branch, then environment, then try 'main' first, then 'master' + const defaultBranch = preferredDefaultBranch || process.env.DEFAULT_BRANCH || 'main'; // Initialize git in workdir workGit = simpleGit(workDir); @@ -233,6 +233,45 @@ export class AnnouncementManager { await git.clone(repoPath, workDir); // Create workGit instance after clone workGit = simpleGit(workDir); + + // Determine the correct default branch to commit to + let targetBranch = preferredDefaultBranch || process.env.DEFAULT_BRANCH || 'main'; + + // Check if the preferred branch exists, if not try to find the actual default branch + try { + const branches = await workGit.branch(['-a']); + const branchList = branches.all + .map(b => b.replace(/^remotes\/origin\//, '').replace(/^remotes\//, '').replace(/^refs\/heads\//, '')) + .filter(b => b && !b.includes('HEAD')); + + // If preferred branch exists, use it; otherwise try main/master, then first branch + if (preferredDefaultBranch && branchList.includes(preferredDefaultBranch)) { + targetBranch = preferredDefaultBranch; + } else if (branchList.includes('main')) { + targetBranch = 'main'; + } else if (branchList.includes('master')) { + targetBranch = 'master'; + } else if (branchList.length > 0) { + targetBranch = branchList[0]; + } + + // Checkout the target branch + try { + await workGit.checkout(targetBranch); + logger.debug({ repoPath, targetBranch }, 'Checked out target branch for announcement commit'); + } catch (checkoutErr) { + // If checkout fails, try to create the branch + logger.debug({ repoPath, targetBranch, error: checkoutErr }, 'Failed to checkout branch, will try to create it'); + try { + await workGit.checkout(['-b', targetBranch]); + logger.debug({ repoPath, targetBranch }, 'Created and checked out new branch for announcement commit'); + } catch (createErr) { + logger.warn({ repoPath, targetBranch, error: createErr }, 'Failed to create branch, will commit to current branch'); + } + } + } catch (branchErr) { + logger.warn({ repoPath, error: branchErr }, 'Failed to determine or checkout branch, will commit to current branch'); + } } // Check if announcement already exists in nostr/repo-events.jsonl @@ -316,8 +355,8 @@ export class AnnouncementManager { logger.info({ repoPath, commitHash, objectCount: objectEntries.length }, 'Objects verified after commit'); // Push back to bare repo - // Use default branch from environment or try 'main' first, then 'master' - const defaultBranch = process.env.DEFAULT_BRANCH || 'main'; + // Use preferred branch, then environment, then try 'main' first, then 'master' + const defaultBranch = preferredDefaultBranch || process.env.DEFAULT_BRANCH || 'main'; if (isEmpty) { // For empty repos, directly copy objects and update refs (more reliable than push) diff --git a/src/lib/services/git/repo-manager.ts b/src/lib/services/git/repo-manager.ts index aaa2dff..315eb7b 100644 --- a/src/lib/services/git/repo-manager.ts +++ b/src/lib/services/git/repo-manager.ts @@ -71,8 +71,9 @@ export class RepoManager { * @param selfTransferEvent - Optional self-transfer event to include in initial commit * @param isExistingRepo - Whether this is an existing repo being added to the server * @param allowMissingDomainUrl - In development, allow provisioning even if domain URL isn't in announcement + * @param preferredDefaultBranch - Preferred default branch name (e.g., from user settings) */ - async provisionRepo(event: NostrEvent, selfTransferEvent?: NostrEvent, isExistingRepo: boolean = false, allowMissingDomainUrl: boolean = false): Promise { + async provisionRepo(event: NostrEvent, selfTransferEvent?: NostrEvent, isExistingRepo: boolean = false, allowMissingDomainUrl: boolean = false, preferredDefaultBranch?: string): Promise { const cloneUrls = this.urlParser.extractCloneUrls(event); let domainUrl = cloneUrls.find(url => url.includes(this.domain)); @@ -161,16 +162,22 @@ export class RepoManager { if (!hasBranches) { // No branches exist - create initial branch and README (which includes announcement) - await this.createInitialBranchAndReadme(repoPath.fullPath, repoPath.npub, repoPath.repoName, event); + await this.createInitialBranchAndReadme(repoPath.fullPath, repoPath.npub, repoPath.repoName, event, preferredDefaultBranch); } else { - // Branches exist (from sync) - ensure announcement is committed to the default branch + // Branches exist (from sync) - ensure README exists and announcement is committed to the default branch + // Check if README exists, and create it if missing + await this.ensureReadmeExists(repoPath.fullPath, repoPath.npub, repoPath.repoName, event, preferredDefaultBranch); + + // Ensure announcement is committed to the default branch // This must happen after syncing so we can commit it to the existing default branch - // Non-blocking: fire and forget - we have the announcement from relays, so this is just for offline papertrail - this.announcementManager.ensureAnnouncementInRepo(repoPath.fullPath, event, selfTransferEvent) - .catch((err) => { - logger.warn({ error: err, repoPath: repoPath.fullPath, eventId: event.id }, - 'Failed to save announcement to repo (non-blocking, announcement available from relays)'); - }); + // Make it blocking so the commit is complete before returning + try { + await this.announcementManager.ensureAnnouncementInRepo(repoPath.fullPath, event, selfTransferEvent, preferredDefaultBranch); + logger.info({ repoPath: repoPath.fullPath, eventId: event.id }, 'Announcement committed to repository'); + } catch (err) { + logger.warn({ error: err, repoPath: repoPath.fullPath, eventId: event.id }, + 'Failed to save announcement to repo (announcement available from relays)'); + } } } else { // For existing repos, check if announcement exists in repo @@ -204,12 +211,13 @@ export class RepoManager { repoPath: string, npub: string, repoName: string, - announcementEvent: NostrEvent + announcementEvent: NostrEvent, + preferredDefaultBranch?: string ): Promise { try { - // Get default branch from git config, environment, or use 'master' - // Check git's init.defaultBranch config first (respects user's git settings) - let defaultBranch = process.env.DEFAULT_BRANCH || 'master'; + // Get default branch from preferred branch, git config, environment, or use 'master' + // Check preferred branch first (from user settings), then git's init.defaultBranch config + let defaultBranch = preferredDefaultBranch || process.env.DEFAULT_BRANCH || 'master'; try { const git = simpleGit(); @@ -233,17 +241,24 @@ export class RepoManager { // If branches exist, check if one matches our default branch preference if (existingBranches.length > 0) { - // Prefer existing branches that match common defaults - const preferredBranches = [defaultBranch, 'main', 'master', 'dev']; - for (const preferred of preferredBranches) { - if (existingBranches.includes(preferred)) { - defaultBranch = preferred; - break; + // If we have a preferred branch and it exists, use it + if (preferredDefaultBranch && existingBranches.includes(preferredDefaultBranch)) { + defaultBranch = preferredDefaultBranch; + } else { + // Prefer existing branches that match common defaults, prioritizing preferred branch + const preferredBranches = preferredDefaultBranch + ? [preferredDefaultBranch, defaultBranch, 'main', 'master', 'dev'] + : [defaultBranch, 'main', 'master', 'dev']; + for (const preferred of preferredBranches) { + if (existingBranches.includes(preferred)) { + defaultBranch = preferred; + break; + } + } + // If no match, use the first existing branch + if (!existingBranches.includes(defaultBranch)) { + defaultBranch = existingBranches[0]; } - } - // If no match, use the first existing branch - if (!existingBranches.includes(defaultBranch)) { - defaultBranch = existingBranches[0]; } } } catch { @@ -337,6 +352,130 @@ Your commits will all be signed by your Nostr keys and saved to the event files } } + /** + * Ensure README.md exists in the repository, creating it if missing + * This is called when branches exist from sync but README might be missing + */ + private async ensureReadmeExists( + repoPath: string, + npub: string, + repoName: string, + announcementEvent: NostrEvent, + preferredDefaultBranch?: string + ): Promise { + try { + // Get default branch + const { FileManager } = await import('./file-manager.js'); + const fileManager = new FileManager(this.repoRoot); + let defaultBranch = preferredDefaultBranch; + + if (!defaultBranch) { + try { + defaultBranch = await fileManager.getDefaultBranch(npub, repoName); + } catch { + // If getDefaultBranch fails, try to determine from git + const repoGit = simpleGit(repoPath); + try { + const branches = await repoGit.branch(['-a']); + const branchList = branches.all + .map(b => b.replace(/^remotes\/origin\//, '').replace(/^remotes\//, '').replace(/^refs\/heads\//, '')) + .filter(b => b && !b.includes('HEAD')); + if (branchList.length > 0) { + // Prefer preferred branch, then main, then master, then first branch + if (preferredDefaultBranch && branchList.includes(preferredDefaultBranch)) { + defaultBranch = preferredDefaultBranch; + } else if (branchList.includes('main')) { + defaultBranch = 'main'; + } else if (branchList.includes('master')) { + defaultBranch = 'master'; + } else { + defaultBranch = branchList[0]; + } + } + } catch { + defaultBranch = preferredDefaultBranch || process.env.DEFAULT_BRANCH || 'master'; + } + } + } + + if (!defaultBranch) { + defaultBranch = preferredDefaultBranch || process.env.DEFAULT_BRANCH || 'master'; + } + + // Check if README.md already exists + const workDir = await fileManager.getWorktree(repoPath, defaultBranch, npub, repoName); + const { readFile: readFileFs, writeFile: writeFileFs } = await import('fs/promises'); + const { join } = await import('path'); + const readmePath = join(workDir, 'README.md'); + + try { + await readFileFs(readmePath, 'utf-8'); + // README exists, nothing to do + await fileManager.removeWorktree(repoPath, workDir); + logger.debug({ npub, repoName, branch: defaultBranch }, 'README.md already exists'); + return; + } catch { + // README doesn't exist, create it + } + + // Get repo name from d-tag or use repoName from path + const dTag = announcementEvent.tags.find(t => t[0] === 'd')?.[1] || repoName; + + // Get name tag for README title, fallback to d-tag + const nameTag = announcementEvent.tags.find(t => t[0] === 'name')?.[1] || dTag; + + // Get author info from user profile (fetch from relays) + const { fetchUserProfile, extractProfileData, getUserName, getUserEmail } = await import('../../utils/user-profile.js'); + const { nip19 } = await import('nostr-tools'); + const { DEFAULT_NOSTR_RELAYS } = await import('../../config.js'); + const userNpub = nip19.npubEncode(announcementEvent.pubkey); + + const profileEvent = await fetchUserProfile(announcementEvent.pubkey, DEFAULT_NOSTR_RELAYS); + const profile = extractProfileData(profileEvent); + const authorName = getUserName(profile, announcementEvent.pubkey, userNpub); + const authorEmail = getUserEmail(profile, announcementEvent.pubkey, userNpub); + + // Create README.md content + const readmeContent = `# ${nameTag} + +Welcome to your new GitRepublic repo. + +You can use this read-me file to explain the purpose of this repo to everyone who looks at it. You can also make a ReadMe.adoc file and delete this one, if you prefer. GitRepublic supports both markups. + +Your commits will all be signed by your Nostr keys and saved to the event files in the ./nostr folder. +`; + + // Write README.md + await writeFileFs(readmePath, readmeContent, 'utf-8'); + + // Stage and commit README.md + const workGit = simpleGit(workDir); + await workGit.add('README.md'); + + // Configure git user.name and user.email for this repository + try { + await workGit.addConfig('user.name', 'GitRepublic', false, 'local'); + await workGit.addConfig('user.email', 'gitrepublic@gitrepublic.web', false, 'local'); + } catch (configError) { + logger.warn({ repoPath, npub, repoName, error: configError }, 'Failed to set git config'); + } + + // Commit README.md + await workGit.commit('Add README.md', ['README.md'], { + '--author': `${authorName} <${authorEmail}>` + }); + + // Clean up worktree + await fileManager.removeWorktree(repoPath, workDir); + + logger.info({ npub, repoName, branch: defaultBranch }, 'Created README.md in existing repository'); + } catch (err) { + // Log but don't fail - README creation is nice-to-have + const sanitizedErr = sanitizeError(err); + logger.warn({ error: sanitizedErr, repoPath, npub, repoName }, 'Failed to ensure README exists, continuing anyway'); + } + } + /** * Sync repository from multiple remote URLs (parallelized for efficiency) */ @@ -370,7 +509,8 @@ Your commits will all be signed by your Nostr keys and saved to the event files async fetchRepoOnDemand( npub: string, repoName: string, - announcementEvent?: NostrEvent + announcementEvent?: NostrEvent, + preferredDefaultBranch?: string ): Promise<{ success: boolean; needsAnnouncement?: boolean; announcement?: NostrEvent; error?: string; cloneUrls?: string[]; remoteUrls?: string[] }> { const repoPath = join(this.repoRoot, npub, `${repoName}.git`); @@ -518,7 +658,7 @@ Your commits will all be signed by your Nostr keys and saved to the event files // No remote URLs - this is an empty repo, provision it instead logger.info({ npub, repoName, cloneUrls, announcementEventId: announcementEvent.id }, 'No remote clone URLs found - provisioning empty repository'); try { - await this.provisionRepo(announcementEvent, undefined, false); + await this.provisionRepo(announcementEvent, undefined, false, false, preferredDefaultBranch); logger.info({ npub, repoName }, 'Empty repository provisioned successfully'); return { success: true, cloneUrls, remoteUrls: [] }; } catch (err) { @@ -539,7 +679,7 @@ Your commits will all be signed by your Nostr keys and saved to the event files logger.info({ npub, repoName, url: remoteUrls[0] }, 'Localhost URL specified but repo does not exist locally - provisioning instead'); try { // In development, allow provisioning even if domain URL isn't in announcement - await this.provisionRepo(announcementEvent, undefined, false, true); + await this.provisionRepo(announcementEvent, undefined, false, true, preferredDefaultBranch); logger.info({ npub, repoName }, 'Repository provisioned successfully on localhost'); return { success: true, cloneUrls, remoteUrls: [] }; } catch (err) { diff --git a/src/routes/api/repos/[npub]/[repo]/clone/+server.ts b/src/routes/api/repos/[npub]/[repo]/clone/+server.ts index 01d0837..42684fc 100644 --- a/src/routes/api/repos/[npub]/[repo]/clone/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/clone/+server.ts @@ -51,6 +51,30 @@ export const POST: RequestHandler = async (event) => { hasUnlimitedAccess: userLevel ? hasUnlimitedAccess(userLevel.level) : false }, 'Checking user access level for clone operation'); + // Extract defaultBranch from request body if present (before body is consumed) + let preferredDefaultBranch: string | undefined; + const contentType = event.request.headers.get('content-type') || ''; + if (contentType.includes('application/json')) { + try { + // Clone the request to read body without consuming it + const clonedRequest = event.request.clone(); + const bodyText = await clonedRequest.text().catch(() => ''); + if (bodyText) { + try { + const body = JSON.parse(bodyText); + if (body.defaultBranch && typeof body.defaultBranch === 'string') { + preferredDefaultBranch = body.defaultBranch; + logger.debug({ preferredDefaultBranch }, 'Extracted defaultBranch from request body'); + } + } catch { + // Not valid JSON or missing defaultBranch - continue + } + } + } catch { + // Body reading failed - continue + } + } + // If cache is empty, try to verify from NIP-98 auth header first (doesn't consume body), then proof event in body if (!userLevel || !hasUnlimitedAccess(userLevel.level)) { let verification: { valid: boolean; error?: string; relay?: string; relayDown?: boolean } | null = null; @@ -66,7 +90,6 @@ export const POST: RequestHandler = async (event) => { // If auth header didn't work, try to get proof event from request body (if content-type is JSON) // Note: This consumes the body, but only if auth header is not present if (!verification) { - const contentType = event.request.headers.get('content-type') || ''; if (contentType.includes('application/json')) { try { // Read body only if auth header verification failed @@ -85,6 +108,10 @@ export const POST: RequestHandler = async (event) => { logger.warn({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' }, 'Invalid proof event in request body'); } } + // Also extract defaultBranch if not already extracted + if (!preferredDefaultBranch && body.defaultBranch && typeof body.defaultBranch === 'string') { + preferredDefaultBranch = body.defaultBranch; + } } catch (parseErr) { // Not valid JSON or missing proofEvent - continue logger.debug({ error: parseErr }, 'Request body is not valid JSON or missing proofEvent'); @@ -276,7 +303,7 @@ export const POST: RequestHandler = async (event) => { }, 'Repository announcement clone URLs'); // Attempt to clone the repository - const result = await repoManager.fetchRepoOnDemand(npub, repo, announcementEvent); + const result = await repoManager.fetchRepoOnDemand(npub, repo, announcementEvent, preferredDefaultBranch); if (!result.success) { if (result.needsAnnouncement) { diff --git a/src/routes/repos/[npub]/[repo]/services/repo-operations.ts b/src/routes/repos/[npub]/[repo]/services/repo-operations.ts index 348c5f1..efee88f 100644 --- a/src/routes/repos/[npub]/[repo]/services/repo-operations.ts +++ b/src/routes/repos/[npub]/[repo]/services/repo-operations.ts @@ -193,11 +193,27 @@ export async function cloneRepository( logger.debug({ error: proofErr }, '[Clone] Failed to create proof event, will rely on cache'); } - // Send clone request with proof event in body (if available) - const requestBody: { proofEvent?: NostrEvent } = {}; + // Get user's default branch preference from settings + let defaultBranch: string | undefined; + try { + const { settingsStore } = await import('$lib/services/settings-store.js'); + const settings = await settingsStore.getSettings(); + if (settings.defaultBranch) { + defaultBranch = settings.defaultBranch; + logger.debug({ defaultBranch }, '[Clone] Using default branch from user settings'); + } + } catch (settingsErr) { + logger.debug({ error: settingsErr }, '[Clone] Failed to get default branch from settings'); + } + + // Send clone request with proof event and defaultBranch in body (if available) + const requestBody: { proofEvent?: NostrEvent; defaultBranch?: string } = {}; if (proofEvent) { requestBody.proofEvent = proofEvent; } + if (defaultBranch) { + requestBody.defaultBranch = defaultBranch; + } const data = await apiPost<{ alreadyExists?: boolean }>(`/api/repos/${state.npub}/${state.repo}/clone`, requestBody);