From c7ad2164c0e1d3d8b694755a32d19eddb067c395 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 26 Feb 2026 07:30:24 +0100 Subject: [PATCH] refactoring 1 Nostr-Signature: 533e9f7acbdd4dc16dbe304245469d57d8d37f0c0cce53b60d99719e2acf4502 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 0fad2d7c44f086ceb06ce40ea8cea2d4d002ebe8caec7d78e83483348b1404cfb6256d8d3796ebd9ae6f7866a431ec4a1abe84e417d3e238b9b554b4a32481e4 --- nostr/commit-signatures.jsonl | 1 + server-maintenance-commands.md | 19 +- .../components/PublicationIndexViewer.svelte | 287 ++ src/lib/services/git/file-manager.ts | 2334 ++-------------- src/lib/services/git/file-manager.ts.backup | 2398 +++++++++++++++++ .../git/file-manager/branch-operations.ts | 126 + .../git/file-manager/commit-operations.ts | 171 ++ .../file-manager/file-manager-refactored.ts | 586 ++++ .../git/file-manager/file-operations.ts | 295 ++ src/lib/services/git/file-manager/index.ts | 22 + .../git/file-manager/path-validator.ts | 128 + .../git/file-manager/tag-operations.ts | 187 ++ .../git/file-manager/worktree-manager.ts | 207 ++ .../git/file-manager/write-operations.ts | 311 +++ src/lib/services/logger.ts | 172 +- src/lib/utils/git-process.ts | 177 ++ src/routes/repos/[npub]/[repo]/+page.svelte | 1102 ++------ .../[repo]/components/CommitDialog.svelte | 119 + .../[npub]/[repo]/components/DocsTab.svelte | 240 ++ .../[repo]/components/DocsViewer.svelte | 242 ++ .../[repo]/components/FileBrowser.svelte | 109 + .../[npub]/[repo]/components/FilesTab.svelte | 235 ++ .../[repo]/components/HistoryTab.svelte | 272 ++ .../[npub]/[repo]/components/IssuesTab.svelte | 158 ++ .../[npub]/[repo]/components/PRsTab.svelte | 131 + .../[repo]/components/PatchesTab.svelte | 137 + .../[repo]/components/StatusTabLayout.svelte | 148 + .../[npub]/[repo]/components/TabLayout.svelte | 73 + .../repos/[npub]/[repo]/hooks/use-repo-api.ts | 204 ++ .../[npub]/[repo]/hooks/use-repo-data.ts | 78 + .../repos/[npub]/[repo]/stores/repo-state.ts | 180 ++ .../[npub]/[repo]/utils/repo-announcement.ts | 121 + 32 files changed, 8004 insertions(+), 2966 deletions(-) create mode 100644 src/lib/components/PublicationIndexViewer.svelte create mode 100644 src/lib/services/git/file-manager.ts.backup create mode 100644 src/lib/services/git/file-manager/branch-operations.ts create mode 100644 src/lib/services/git/file-manager/commit-operations.ts create mode 100644 src/lib/services/git/file-manager/file-manager-refactored.ts create mode 100644 src/lib/services/git/file-manager/file-operations.ts create mode 100644 src/lib/services/git/file-manager/index.ts create mode 100644 src/lib/services/git/file-manager/path-validator.ts create mode 100644 src/lib/services/git/file-manager/tag-operations.ts create mode 100644 src/lib/services/git/file-manager/worktree-manager.ts create mode 100644 src/lib/services/git/file-manager/write-operations.ts create mode 100644 src/lib/utils/git-process.ts create mode 100644 src/routes/repos/[npub]/[repo]/components/CommitDialog.svelte create mode 100644 src/routes/repos/[npub]/[repo]/components/DocsTab.svelte create mode 100644 src/routes/repos/[npub]/[repo]/components/DocsViewer.svelte create mode 100644 src/routes/repos/[npub]/[repo]/components/FileBrowser.svelte create mode 100644 src/routes/repos/[npub]/[repo]/components/FilesTab.svelte create mode 100644 src/routes/repos/[npub]/[repo]/components/HistoryTab.svelte create mode 100644 src/routes/repos/[npub]/[repo]/components/IssuesTab.svelte create mode 100644 src/routes/repos/[npub]/[repo]/components/PRsTab.svelte create mode 100644 src/routes/repos/[npub]/[repo]/components/PatchesTab.svelte create mode 100644 src/routes/repos/[npub]/[repo]/components/StatusTabLayout.svelte create mode 100644 src/routes/repos/[npub]/[repo]/components/TabLayout.svelte create mode 100644 src/routes/repos/[npub]/[repo]/hooks/use-repo-api.ts create mode 100644 src/routes/repos/[npub]/[repo]/hooks/use-repo-data.ts create mode 100644 src/routes/repos/[npub]/[repo]/stores/repo-state.ts create mode 100644 src/routes/repos/[npub]/[repo]/utils/repo-announcement.ts diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 45f1eea..844cfea 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -88,3 +88,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772009058,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix doc page redicrects"]],"content":"Signed commit: fix doc page redicrects","id":"be18739cf8e9062e7163dca11c6768086cbf834d52f9758c884a420e4d9dceb7","sig":"2f068184caa9d921f38d6b132992614f39bab6ec6ea8040ac0d337db4c16de4e66de44c4d78162787f1cf8bf13978d01927b8152405f0b48adf608ca6bf34295"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772009909,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix cli sync and refine commit workflow"]],"content":"Signed commit: fix cli sync and refine commit workflow","id":"ddf0b49bb68139efbdacd6308b95b4a5329a37f479b319d609d712bee83e2d45","sig":"aacc22f02a3129d18cd2bdcfc4e2dda66e9358e552eac507cd4c4808bb47cd582298aed7d28f21b677418e1a91f3f1553c08f02671df8f1f43681cf7b19a744e"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772010107,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix build"]],"content":"Signed commit: fix build","id":"968af17f95f1ba0cf6a4d1f04ce108a6e4eb4ec3a4f72ca6a9d2529dacb92811","sig":"1891b6131effda70ec76577efadd9ea7374ebcbd4d738d0b0650e7dce46c3e7253eccb4b8455690297b63b7c30f61a0c7dcc1af0147b2f5a631bbd91c517c32b"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772011169,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","prevent zombie git processes"]],"content":"Signed commit: prevent zombie git processes","id":"fd370d2613105f16b0cfdd55b33f50c5b724ecef272109036a7cce5477da29bc","sig":"1d3cb4392f722b1b356247bde64691576d41fdb697e8dfe62d5e7ecd5ad8ea35757da2d56db310a2005e4b5528013aa1205256e37fc230f024d3b5a2e26735bf"} diff --git a/server-maintenance-commands.md b/server-maintenance-commands.md index 4ae42f5..8206b25 100644 --- a/server-maintenance-commands.md +++ b/server-maintenance-commands.md @@ -123,7 +123,9 @@ ps aux | grep -i plesk | grep -i defunct - Handle signals correctly - Prevent zombie processes -## Immediate Server Fix +## ⚠️ URGENT: Restart Service IMMEDIATELY ⚠️ + +**Zombie count is increasing rapidly (3300 → 5940). Restart NOW to stop the bleeding.** **Option 1: Restart the GitRepublic service (RECOMMENDED)** ```bash @@ -131,13 +133,26 @@ ps aux | grep -i plesk | grep -i defunct docker ps | grep gitrepublic # or systemctl list-units | grep -i gitrepublic +# or find the process +ps aux | grep "node build" | grep -v grep -# Restart it (this will clean up zombies temporarily) +# RESTART IT NOW (this will clean up zombies temporarily) docker restart # or systemctl restart +# or if running directly +kill -TERM # Let systemd/docker restart it +``` + +**After restart, monitor zombie count:** +```bash +watch -n 2 'ps aux | awk '\''$8 ~ /^Z/ { count++ } END { print "Zombies:", count+0 }'\''' ``` +**If zombies continue to increase after restart:** +- The code fix needs to be deployed +- Check if there are other services spawning git processes + **Option 2: Kill and let it restart (if managed by systemd/docker)** ```bash # Find the process diff --git a/src/lib/components/PublicationIndexViewer.svelte b/src/lib/components/PublicationIndexViewer.svelte new file mode 100644 index 0000000..9cee937 --- /dev/null +++ b/src/lib/components/PublicationIndexViewer.svelte @@ -0,0 +1,287 @@ + + +
+ {#if loading} +
Loading publication index...
+ {:else if error} +
{error}
+ {:else if items.length === 0} +
No items found in publication index
+ {:else} +
+ {#each items as item} +
onItemClick?.(item)} + role="button" + tabindex="0" + > +

{item.title}

+ {#if item.description} +

{item.description}

+ {/if} + {#if item.url} + e.stopPropagation()}> + {item.url} + + {/if} +
+ {#if item.id} + ID: {item.id.substring(0, 16)}... + {/if} +
+
+ {/each} +
+ {/if} +
+ + diff --git a/src/lib/services/git/file-manager.ts b/src/lib/services/git/file-manager.ts index f2c49c0..a4667e7 100644 --- a/src/lib/services/git/file-manager.ts +++ b/src/lib/services/git/file-manager.ts @@ -1,18 +1,25 @@ /** - * File manager for git repositories - * Handles reading, writing, and listing files in git repos + * File Manager - Refactored to use modular components + * Main class that delegates to focused modules */ +import { join, resolve } from 'path'; import simpleGit, { type SimpleGit } from 'simple-git'; -import { readdir } from 'fs/promises'; -import { join, dirname, normalize, resolve } from 'path'; -import { spawn } from 'child_process'; -import { RepoManager } from './repo-manager.js'; -import { createGitCommitSignature } from './commit-signer.js'; -import type { NostrEvent } from '../../types/nostr.js'; -import logger from '../logger.js'; -import { sanitizeError, isValidBranchName } from '../../utils/security.js'; -import { repoCache, RepoCache } from './repo-cache.js'; +import { RepoManager } from '../repo-manager.js'; +import logger from '../../logger.js'; +import { sanitizeError, isValidBranchName } from '../../../utils/security.js'; +import { repoCache, RepoCache } from '../repo-cache.js'; + +// Import modular operations +import { getOrCreateWorktree, removeWorktree } from './worktree-manager.js'; +import { validateFilePath, validateRepoName, validateNpub } from './path-validator.js'; +import { listFiles, getFileContent } from './file-operations.js'; +import { getBranches, validateBranchName } from './branch-operations.js'; +import { writeFile, deleteFile } from './write-operations.js'; +import { getCommitHistory, getDiff } from './commit-operations.js'; +import { createTag, getTags } from './tag-operations.js'; + +// Types are defined below export interface FileEntry { name: string; @@ -46,27 +53,21 @@ export interface Tag { name: string; hash: string; message?: string; - date?: number; // Unix timestamp of the commit the tag points to + date?: number; } export class FileManager { private repoManager: RepoManager; private repoRoot: string; - // Cache for directory existence checks (5 minute TTL) private dirExistenceCache: Map = new Map(); - private readonly DIR_CACHE_TTL = 5 * 60 * 1000; // 5 minutes - // Lazy-loaded fs modules (cached after first import) + private readonly DIR_CACHE_TTL = 5 * 60 * 1000; private fsPromises: typeof import('fs/promises') | null = null; - private fsSync: typeof import('fs') | null = null; constructor(repoRoot: string = '/repos') { this.repoRoot = repoRoot; this.repoManager = new RepoManager(repoRoot); } - /** - * Lazy load fs/promises module (cached after first load) - */ private async getFsPromises(): Promise { if (!this.fsPromises) { this.fsPromises = await import('fs/promises'); @@ -74,41 +75,6 @@ export class FileManager { return this.fsPromises; } - /** - * Lazy load fs module (cached after first load) - */ - private async getFsSync(): Promise { - if (!this.fsSync) { - this.fsSync = await import('fs'); - } - return this.fsSync; - } - - /** - * Check if running in a container environment (async) - * Note: This is cached after first check since it won't change during runtime - */ - private containerEnvCache: boolean | null = null; - private async isContainerEnvironment(): Promise { - // Cache the result since it won't change during runtime - if (this.containerEnvCache !== null) { - return this.containerEnvCache; - } - - if (process.env.DOCKER_CONTAINER === 'true') { - this.containerEnvCache = true; - return true; - } - - // Check for /.dockerenv file (async) - this.containerEnvCache = await this.pathExists('/.dockerenv'); - return this.containerEnvCache; - } - - /** - * Check if a path exists (async, non-blocking) - * Uses fs.access() which is the recommended async way to check existence - */ private async pathExists(path: string): Promise { try { const fs = await this.getFsPromises(); @@ -119,1055 +85,88 @@ export class FileManager { } } - /** - * Sanitize error messages to prevent leaking sensitive path information - * Only shows relative paths, not absolute paths - */ private sanitizePathForError(path: string): string { - // If path is within repoRoot, show relative path const resolvedPath = resolve(path).replace(/\\/g, '/'); const resolvedRoot = resolve(this.repoRoot).replace(/\\/g, '/'); if (resolvedPath.startsWith(resolvedRoot + '/')) { return resolvedPath.slice(resolvedRoot.length + 1); } - // For paths outside repoRoot, only show last component for security return path.split(/[/\\]/).pop() || path; } - /** - * Generate container-specific error message for permission issues - */ - private getContainerPermissionError(path: string, operation: string): string { - const sanitizedPath = this.sanitizePathForError(path); - return `Permission denied: ${operation} at ${sanitizedPath}. In Docker, check that the volume mount has correct permissions. The container runs as user 'gitrepublic' (UID 1001). Ensure the host directory is writable by this user or adjust ownership: chown -R 1001:1001 ./repos`; - } - - /** - * Ensure directory exists with proper error handling and security - * @param dirPath - Directory path to ensure exists - * @param description - Description for error messages - * @param checkParent - Whether to check parent directory permissions first - */ - private async ensureDirectoryExists( - dirPath: string, - description: string, - checkParent: boolean = false - ): Promise { - // Check cache first (with TTL) - const cacheKey = `dir:${dirPath}`; - const cached = this.dirExistenceCache.get(cacheKey); - const now = Date.now(); - if (cached && (now - cached.timestamp) < this.DIR_CACHE_TTL) { - if (cached.exists) { - return; // Directory exists, skip - } - } - - // Use async path existence check + private async ensureDirectoryExists(dirPath: string, description: string): Promise { const exists = await this.pathExists(dirPath); - if (exists) { - // Update cache - this.dirExistenceCache.set(cacheKey, { exists: true, timestamp: now }); - return; - } - - // Check parent directory if requested - if (checkParent) { - const parentDir = dirname(dirPath); - const parentExists = await this.pathExists(parentDir); - if (!parentExists) { - await this.ensureDirectoryExists(parentDir, `Parent of ${description}`, true); - } else { - // Verify parent is writable (async) - try { - const fs = await this.getFsPromises(); - await fs.access(parentDir, fs.constants.W_OK); - } catch (accessErr) { - const isContainer = await this.isContainerEnvironment(); - const errorMsg = isContainer - ? this.getContainerPermissionError(parentDir, `writing to parent directory of ${description}`) - : `Parent directory ${this.sanitizePathForError(parentDir)} is not writable`; - logger.error({ error: accessErr, parentDir, description }, errorMsg); - throw new Error(errorMsg); - } - } - } + if (exists) return; - // Create directory try { const { mkdir } = await this.getFsPromises(); await mkdir(dirPath, { recursive: true }); - logger.debug({ dirPath: this.sanitizePathForError(dirPath), description }, 'Created directory'); - // Update cache - this.dirExistenceCache.set(cacheKey, { exists: true, timestamp: now }); - } catch (mkdirErr) { - // Clear cache on error - this.dirExistenceCache.delete(cacheKey); - const isContainer = await this.isContainerEnvironment(); - const errorMsg = isContainer - ? this.getContainerPermissionError(dirPath, `creating ${description}`) - : `Failed to create ${description}: ${mkdirErr instanceof Error ? mkdirErr.message : String(mkdirErr)}`; - logger.error({ error: mkdirErr, dirPath: this.sanitizePathForError(dirPath), description }, errorMsg); - throw new Error(errorMsg); - } - } - - /** - * Verify directory is writable (for security checks) - async - */ - private async verifyDirectoryWritable(dirPath: string, description: string): Promise { - const exists = await this.pathExists(dirPath); - if (!exists) { - throw new Error(`${description} does not exist at ${this.sanitizePathForError(dirPath)}`); - } - - try { - const fs = await this.getFsPromises(); - await fs.access(dirPath, fs.constants.W_OK); - } catch (accessErr) { - const isContainer = await this.isContainerEnvironment(); - const errorMsg = isContainer - ? this.getContainerPermissionError(dirPath, `writing to ${description}`) - : `${description} at ${this.sanitizePathForError(dirPath)} is not writable`; - logger.error({ error: accessErr, dirPath: this.sanitizePathForError(dirPath), description }, errorMsg); - throw new Error(errorMsg); - } - } - - /** - * Clear directory existence cache (useful after operations that create directories) - */ - private clearDirCache(dirPath?: string): void { - if (dirPath) { - const cacheKey = `dir:${dirPath}`; - this.dirExistenceCache.delete(cacheKey); - } else { - // Clear all cache - this.dirExistenceCache.clear(); - } - } - - /** - * Sanitize error messages to prevent information leakage - * Uses sanitizeError from security utils and adds path sanitization - */ - private sanitizeErrorMessage(error: unknown, context?: { npub?: string; repoName?: string; filePath?: string }): string { - let message = sanitizeError(error); - - // Remove sensitive context if present - if (context?.npub) { - const { truncateNpub } = require('../../utils/security.js'); - message = message.replace(new RegExp(context.npub.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), truncateNpub(context.npub)); - } - - // Sanitize file paths - if (context?.filePath) { - const sanitizedPath = this.sanitizePathForError(context.filePath); - message = message.replace(new RegExp(context.filePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), sanitizedPath); - } - - return message; - } - - /** - * Create or get a git worktree for a repository - * More efficient than cloning the entire repo for each operation - * Security: Validates branch name to prevent path traversal attacks - */ - async getWorktree(repoPath: string, branch: string, npub: string, repoName: string): Promise { - // Security: Validate branch name to prevent path traversal - if (!isValidBranchName(branch)) { - throw new Error(`Invalid branch name: ${branch}`); - } - - const worktreeRoot = join(this.repoRoot, npub, `${repoName}.worktrees`); - // 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 = worktreePath.replace(/\\/g, '/'); - const resolvedRoot = resolvedWorktreeRoot.replace(/\\/g, '/'); - if (!resolvedPath.startsWith(resolvedRoot + '/')) { - throw new Error('Path traversal detected: worktree path outside allowed root'); - } - const { rm } = await this.getFsPromises(); - - // Ensure worktree root exists (use resolved path) with parent check - await this.ensureDirectoryExists(resolvedWorktreeRoot, 'worktree root directory', true); - - const git = simpleGit(repoPath); - - // 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; - } - } + logger.debug({ dirPath: this.sanitizePathForError(dirPath) }, `Created ${description}`); } 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 (await this.pathExists(worktreePath)) { - // Verify it's a valid worktree - try { - const worktreeGit = simpleGit(worktreePath); - await worktreeGit.status(); - return worktreePath; - } catch { - // Invalid worktree, remove it - const { rm } = await this.getFsPromises(); - await rm(worktreePath, { recursive: true, force: true }); - } - } - - // Create new worktree - try { - // First, check if the branch exists and has commits - let branchExists = false; - let branchHasCommits = false; - try { - // Check if branch exists - const branchList = await git.branch(['-a']); - const branchNames = branchList.all.map(b => b.replace(/^remotes\/origin\//, '').replace(/^remotes\//, '')); - branchExists = branchNames.includes(branch); - - if (branchExists) { - // Check if branch has commits by trying to get the latest commit - try { - const commitHash = await git.raw(['rev-parse', `refs/heads/${branch}`]); - branchHasCommits = !!(commitHash && commitHash.trim().length > 0); - } catch { - // Branch exists but has no commits (orphan branch) - branchHasCommits = false; - } - } - } catch (err) { - logger.debug({ error: err, branch }, 'Could not check branch status, will try to create worktree'); - } - - // If branch exists but has no commits, create an initial empty commit first - if (branchExists && !branchHasCommits) { - logger.debug({ branch }, 'Branch exists but has no commits, creating initial empty commit'); - try { - // Fetch repo announcement to use as commit message - 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 { nip19 } = await import('nostr-tools'); - 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({ branch, announcementId: announcement.id }, 'Using repo announcement as initial commit message'); - } - } catch (announcementErr) { - logger.debug({ error: announcementErr, branch }, 'Failed to fetch announcement, using default commit message'); - } - - // Create a temporary worktree with a temp branch name to make the initial commit - const tempBranchName = `.temp-init-${Date.now()}`; - const tempWorktreePath = resolve(join(this.repoRoot, npub, `${repoName}.worktrees`, tempBranchName)); - const { mkdir } = await import('fs/promises'); - await mkdir(dirname(tempWorktreePath), { recursive: true }); - - // Create orphan worktree with temp branch - await new Promise((resolve, reject) => { - const orphanProcess = spawn('git', ['worktree', 'add', '--orphan', tempBranchName, tempWorktreePath], { - cwd: repoPath, - stdio: ['ignore', 'pipe', 'pipe'] - }); - - let orphanStderr = ''; - orphanProcess.stderr.on('data', (chunk: Buffer) => { - orphanStderr += chunk.toString(); - }); - - orphanProcess.on('close', (code) => { - if (code === 0) { - resolve(); - } else { - reject(new Error(`Failed to create orphan worktree: ${orphanStderr}`)); - } - }); - - orphanProcess.on('error', reject); - }); - - // Create initial empty commit in temp branch with announcement as message - const tempGit = simpleGit(tempWorktreePath); - await tempGit.commit(commitMessage, ['--allow-empty'], { - '--author': 'GitRepublic ' - }); - - // Get the commit hash - const commitHash = await tempGit.revparse(['HEAD']); - - // Update the actual branch to point to this commit - await git.raw(['update-ref', `refs/heads/${branch}`, commitHash.trim()]); - - // Remove temporary worktree and temp branch - await this.removeWorktree(repoPath, tempWorktreePath); - await git.raw(['branch', '-D', tempBranchName]).catch(() => { - // Ignore if branch deletion fails - }); - - logger.debug({ branch, commitHash }, 'Created initial empty commit on orphan branch with announcement'); - } catch (err) { - logger.warn({ error: err, branch }, 'Failed to create initial commit, will try normal worktree creation'); - } - } - - // Use spawn for worktree add (safer than exec) - await new Promise((resolve, reject) => { - const gitProcess = spawn('git', ['worktree', 'add', worktreePath, branch], { - cwd: repoPath, - stdio: ['ignore', 'pipe', 'pipe'] - }); - - let stderr = ''; - gitProcess.stderr.on('data', (chunk: Buffer) => { - stderr += chunk.toString(); - }); - - gitProcess.on('close', (code) => { - if (code === 0) { - resolve(); - } else { - // If branch doesn't exist, create it first using git branch (works on bare repos) - if (stderr.includes('fatal: invalid reference') || stderr.includes('fatal: not a valid object name') || stderr.includes('Ungültige Referenz')) { - // First, try to find a source branch (HEAD, main, or master) - const findSourceBranch = async (): Promise => { - try { - const branches = await git.branch(['-a']); - // Try HEAD first, then main, then master - if (branches.all.includes('HEAD') || branches.all.includes('origin/HEAD')) { - return 'HEAD'; - } - if (branches.all.includes('main') || branches.all.includes('origin/main')) { - return 'main'; - } - if (branches.all.includes('master') || branches.all.includes('origin/master')) { - return 'master'; - } - // Use the first available branch - const firstBranch = branches.all.find(b => !b.includes('HEAD')); - if (firstBranch) { - return firstBranch.replace(/^origin\//, ''); - } - throw new Error('No source branch found'); - } catch { - // Default to HEAD - return 'HEAD'; - } - }; - - findSourceBranch().then((sourceBranch) => { - // Create branch using git branch command (works on bare repos) - return new Promise((resolveBranch, rejectBranch) => { - const branchProcess = spawn('git', ['branch', branch, sourceBranch], { - cwd: repoPath, - stdio: ['ignore', 'pipe', 'pipe'] - }); - - let branchStderr = ''; - branchProcess.stderr.on('data', (chunk: Buffer) => { - branchStderr += chunk.toString(); - }); - - branchProcess.on('close', (branchCode) => { - if (branchCode === 0) { - resolveBranch(); - } else { - // If creating branch from source fails, try creating orphan branch - if (branchStderr.includes('fatal: invalid reference') || branchStderr.includes('Ungültige Referenz')) { - // Create orphan branch instead - const orphanProcess = spawn('git', ['branch', branch], { - cwd: repoPath, - stdio: ['ignore', 'pipe', 'pipe'] - }); - - let orphanStderr = ''; - orphanProcess.stderr.on('data', (chunk: Buffer) => { - orphanStderr += chunk.toString(); - }); - - orphanProcess.on('close', (orphanCode) => { - if (orphanCode === 0) { - resolveBranch(); - } else { - rejectBranch(new Error(`Failed to create orphan branch: ${orphanStderr}`)); - } - }); - - orphanProcess.on('error', rejectBranch); - } else { - rejectBranch(new Error(`Failed to create branch: ${branchStderr}`)); - } - } - }); - - branchProcess.on('error', rejectBranch); - }); - }).then(() => { - // Retry worktree add after creating the branch - return new Promise((resolve2, reject2) => { - const gitProcess2 = spawn('git', ['worktree', 'add', worktreePath, branch], { - cwd: repoPath, - stdio: ['ignore', 'pipe', 'pipe'] - }); - - let retryStderr = ''; - gitProcess2.stderr.on('data', (chunk: Buffer) => { - retryStderr += chunk.toString(); - }); - - gitProcess2.on('close', (code2) => { - if (code2 === 0) { - resolve2(); - } else { - // If still failing, try with --orphan - if (retryStderr.includes('fatal: invalid reference') || retryStderr.includes('Ungültige Referenz')) { - const orphanWorktreeProcess = spawn('git', ['worktree', 'add', '--orphan', branch, worktreePath], { - cwd: repoPath, - stdio: ['ignore', 'pipe', 'pipe'] - }); - - let orphanWorktreeStderr = ''; - orphanWorktreeProcess.stderr.on('data', (chunk: Buffer) => { - orphanWorktreeStderr += chunk.toString(); - }); - - orphanWorktreeProcess.on('close', (orphanWorktreeCode) => { - if (orphanWorktreeCode === 0) { - resolve2(); - } else { - reject2(new Error(`Failed to create orphan worktree: ${orphanWorktreeStderr}`)); - } - }); - - orphanWorktreeProcess.on('error', reject2); - } else { - reject2(new Error(`Failed to create worktree after creating branch: ${retryStderr}`)); - } - } - }); - - gitProcess2.on('error', reject2); - }); - }).then(resolve).catch(reject); - } else { - reject(new Error(`Failed to create worktree: ${stderr}`)); - } - } - }); - - gitProcess.on('error', reject); - }); - - // Verify the worktree directory was actually created (after the promise resolves) - if (!(await this.pathExists(worktreePath))) { - throw new Error(`Worktree directory was not created: ${this.sanitizePathForError(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, worktreePath }, 'Failed to create worktree'); - throw new Error(`Failed to create worktree: ${sanitizedError}`); + logger.error({ error: err, dirPath: this.sanitizePathForError(dirPath) }, `Failed to create ${description}`); + throw new Error(`Failed to create ${description}: ${err instanceof Error ? err.message : String(err)}`); } } - /** - * Remove a worktree - */ - async removeWorktree(repoPath: string, worktreePath: string): Promise { - try { - // Use spawn for worktree remove (safer than exec) - await new Promise((resolve, reject) => { - const gitProcess = spawn('git', ['worktree', 'remove', worktreePath], { - cwd: repoPath, - stdio: ['ignore', 'pipe', 'pipe'] - }); - - gitProcess.on('close', (code) => { - if (code === 0) { - resolve(); - } else { - // If worktree remove fails, try force remove - const gitProcess2 = spawn('git', ['worktree', 'remove', '--force', worktreePath], { - cwd: repoPath, - stdio: ['ignore', 'pipe', 'pipe'] - }); - - gitProcess2.on('close', (code2) => { - if (code2 === 0) { - resolve(); - } else { - // Last resort: just delete the directory - import('fs/promises').then(({ rm }) => { - return rm(worktreePath, { recursive: true, force: true }); - }).then(() => resolve()).catch(reject); - } - }); - - gitProcess2.on('error', reject); - } - }); - - gitProcess.on('error', reject); - }); - } catch (error) { - const sanitizedError = sanitizeError(error); - logger.warn({ error: sanitizedError, repoPath, worktreePath }, 'Failed to remove worktree cleanly'); - // Try to remove directory directly as fallback - try { - const { rm } = await import('fs/promises'); - await rm(worktreePath, { recursive: true, force: true }); - } catch { - // Ignore cleanup errors - } - } - } - - /** - * Get the full path to a repository - */ getRepoPath(npub: string, repoName: string): string { const repoPath = join(this.repoRoot, npub, `${repoName}.git`); - // Security: Ensure the resolved path is within repoRoot to prevent path traversal - // Normalize paths to handle Windows/Unix differences const resolvedPath = resolve(repoPath).replace(/\\/g, '/'); const resolvedRoot = resolve(this.repoRoot).replace(/\\/g, '/'); - // Must be a subdirectory of repoRoot, not equal to it if (!resolvedPath.startsWith(resolvedRoot + '/')) { throw new Error('Path traversal detected: repository path outside allowed root'); } return repoPath; } - /** - * Validate and sanitize file path to prevent path traversal attacks - */ - private validateFilePath(filePath: string): { valid: boolean; error?: string; normalized?: string } { - // Allow empty string for root directory - if (filePath === '') { - return { valid: true, normalized: '' }; - } - if (!filePath || typeof filePath !== 'string') { - return { valid: false, error: 'File path must be a non-empty string' }; - } - - // Normalize the path (resolves .. and .) - const normalized = normalize(filePath); - - // Check for path traversal attempts - if (normalized.includes('..')) { - return { valid: false, error: 'Path traversal detected (..)' }; - } - - // Check for absolute paths - if (normalized.startsWith('/')) { - return { valid: false, error: 'Absolute paths are not allowed' }; - } - - // Check for null bytes - if (normalized.includes('\0')) { - return { valid: false, error: 'Null bytes are not allowed in paths' }; - } - - // Check for control characters - if (/[\x00-\x1f\x7f]/.test(normalized)) { - return { valid: false, error: 'Control characters are not allowed in paths' }; - } - - // Limit path length (reasonable limit) - if (normalized.length > 4096) { - return { valid: false, error: 'Path is too long (max 4096 characters)' }; - } - - return { valid: true, normalized }; - } - - /** - * Validate repository name to prevent injection attacks - */ - private validateRepoName(repoName: string): { valid: boolean; error?: string } { - if (!repoName || typeof repoName !== 'string') { - return { valid: false, error: 'Repository name must be a non-empty string' }; - } - - // Check length - if (repoName.length > 100) { - return { valid: false, error: 'Repository name is too long (max 100 characters)' }; - } - - // Check for invalid characters (alphanumeric, hyphens, underscores, dots) - if (!/^[a-zA-Z0-9._-]+$/.test(repoName)) { - return { valid: false, error: 'Repository name contains invalid characters' }; - } - - // Check for path traversal - if (repoName.includes('..') || repoName.includes('/') || repoName.includes('\\')) { - return { valid: false, error: 'Repository name contains invalid path characters' }; - } - - return { valid: true }; - } - - /** - * Validate npub format - */ - private validateNpub(npub: string): { valid: boolean; error?: string } { - if (!npub || typeof npub !== 'string') { - return { valid: false, error: 'npub must be a non-empty string' }; - } - - // Basic npub format check (starts with npub, base58 encoded) - if (!npub.startsWith('npub1') || npub.length < 10 || npub.length > 100) { - return { valid: false, error: 'Invalid npub format' }; - } - - return { valid: true }; - } - - /** - * Check if repository exists (with caching) - */ repoExists(npub: string, repoName: string): boolean { - // Validate inputs - const npubValidation = this.validateNpub(npub); - if (!npubValidation.valid) { - return false; - } - const repoValidation = this.validateRepoName(repoName); - if (!repoValidation.valid) { - return false; - } + const npubValidation = validateNpub(npub); + if (!npubValidation.valid) return false; + const repoValidation = validateRepoName(repoName); + if (!repoValidation.valid) return false; - // Check cache first const cacheKey = RepoCache.repoExistsKey(npub, repoName); const cached = repoCache.get(cacheKey); - if (cached !== null) { - return cached; - } + if (cached !== null) return cached; const repoPath = this.getRepoPath(npub, repoName); const exists = this.repoManager.repoExists(repoPath); - - // Cache the result (cache for 1 minute) repoCache.set(cacheKey, exists, 60 * 1000); - return exists; } - /** - * List files and directories in a repository at a given path - * Uses caching to reduce redundant git operations - */ - async listFiles(npub: string, repoName: string, ref: string = 'HEAD', path: string = ''): Promise { - // Validate inputs - const npubValidation = this.validateNpub(npub); - if (!npubValidation.valid) { - throw new Error(`Invalid npub: ${npubValidation.error}`); - } - const repoValidation = this.validateRepoName(repoName); - if (!repoValidation.valid) { - throw new Error(`Invalid repository name: ${repoValidation.error}`); - } - - const pathValidation = this.validateFilePath(path); - if (!pathValidation.valid) { - throw new Error(`Invalid file path: ${pathValidation.error}`); + async getWorktree(repoPath: string, branch: string, npub: string, repoName: string): Promise { + if (!isValidBranchName(branch)) { + throw new Error(`Invalid branch name: ${branch}`); } + return getOrCreateWorktree({ + repoPath, + branch, + npub, + repoName, + repoRoot: this.repoRoot + }); + } + async removeWorktree(repoPath: string, worktreePath: string): Promise { + return removeWorktree(repoPath, worktreePath); + } + + async listFiles(npub: string, repoName: string, ref: string = 'HEAD', path: string = ''): Promise { const repoPath = this.getRepoPath(npub, repoName); - if (!this.repoExists(npub, repoName)) { throw new Error('Repository not found'); } - - // Check cache first (cache for 2 minutes) - const cacheKey = RepoCache.fileListKey(npub, repoName, ref, path); - const cached = repoCache.get(cacheKey); - if (cached !== null) { - logger.debug({ npub, repoName, path, ref, cachedCount: cached.length }, '[FileManager] Returning cached file list'); - return cached; - } - - const git: SimpleGit = simpleGit(repoPath); - - try { - // Get the tree for the specified path - // For directories, git ls-tree needs a trailing slash to list contents - // For root, use '.' - // Note: git ls-tree returns paths relative to repo root, not relative to the specified path - const gitPath = path ? (path.endsWith('/') ? path : `${path}/`) : '.'; - logger.debug({ npub, repoName, path, ref, gitPath }, '[FileManager] Calling git ls-tree'); - - let tree: string; - try { - tree = await git.raw(['ls-tree', '-l', ref, gitPath]); - } catch (lsTreeError) { - // Handle empty branches (orphan branches with no commits) - // git ls-tree will fail with "fatal: not a valid object name" or similar - const errorMsg = lsTreeError instanceof Error ? lsTreeError.message : String(lsTreeError); - const errorStr = String(lsTreeError).toLowerCase(); - const errorMsgLower = errorMsg.toLowerCase(); - - // Check for various error patterns that indicate empty branch/no commits - const isEmptyBranchError = - errorMsgLower.includes('not a valid object') || - errorMsgLower.includes('not found') || - errorMsgLower.includes('bad revision') || - errorMsgLower.includes('ambiguous argument') || - errorStr.includes('not a valid object') || - errorStr.includes('not found') || - errorStr.includes('bad revision') || - errorStr.includes('ambiguous argument') || - errorMsgLower.includes('fatal:') && (errorMsgLower.includes('master') || errorMsgLower.includes('refs/heads')); - - if (isEmptyBranchError) { - logger.debug({ npub, repoName, path, ref, gitPath, error: errorMsg, errorStr }, '[FileManager] Branch has no commits, returning empty list'); - const emptyResult: FileEntry[] = []; - // Cache empty result for shorter time (30 seconds) - repoCache.set(cacheKey, emptyResult, 30 * 1000); - return emptyResult; - } - // Log the error for debugging - logger.error({ npub, repoName, path, ref, gitPath, error: lsTreeError, errorMsg, errorStr }, '[FileManager] Unexpected error from git ls-tree'); - // Re-throw if it's a different error - throw lsTreeError; - } - - if (!tree || !tree.trim()) { - const emptyResult: FileEntry[] = []; - // Cache empty result for shorter time (30 seconds) - repoCache.set(cacheKey, emptyResult, 30 * 1000); - logger.debug({ npub, repoName, path, ref, gitPath }, '[FileManager] git ls-tree returned empty result'); - return emptyResult; - } - - logger.debug({ npub, repoName, path, ref, gitPath, treeLength: tree.length, firstLines: tree.split('\n').slice(0, 5) }, '[FileManager] git ls-tree output'); - - const entries: FileEntry[] = []; - const lines = tree.trim().split('\n').filter(line => line.length > 0); - - // Normalize the path for comparison (ensure it ends with / for directory matching) - const normalizedPath = path ? (path.endsWith('/') ? path : `${path}/`) : ''; - - logger.debug({ path, normalizedPath, lineCount: lines.length }, '[FileManager] Starting to parse entries'); - - for (const line of lines) { - // Format: \t - // Note: git ls-tree uses a tab character between size and filename - // The format is: mode type object sizepath - // Important: git ls-tree returns paths relative to repo root, not relative to the specified path - // We need to handle both spaces and tabs - const tabIndex = line.lastIndexOf('\t'); - if (tabIndex === -1) { - // Try space-separated format as fallback - const match = line.match(/^(\d+)\s+(\w+)\s+(\w+)\s+(\d+|-)\s+(.+)$/); - if (match) { - const [, , type, , size, gitPath] = match; - // git ls-tree always returns paths relative to repo root - // If we're listing a subdirectory, the returned paths will start with that directory - let fullPath: string; - let displayName: string; - - if (normalizedPath) { - // We're listing a subdirectory - if (gitPath.startsWith(normalizedPath)) { - // Path already includes the directory prefix (normal case) - fullPath = gitPath; - // Extract just the filename/dirname (relative to the requested path) - const relativePath = gitPath.slice(normalizedPath.length); - // Remove any leading/trailing slashes - const cleanRelative = relativePath.replace(/^\/+|\/+$/g, ''); - // For display name, get the first component (the immediate child) - // This handles both files (image.png) and nested dirs (screenshots/image.png -> screenshots) - displayName = cleanRelative.split('/')[0] || cleanRelative; - } else { - // Path doesn't start with directory prefix - this shouldn't happen normally - // but handle it by joining - logger.debug({ path, normalizedPath, gitPath }, '[FileManager] Path does not start with normalized path, joining (space-separated)'); - fullPath = join(path, gitPath); - displayName = gitPath.split('/').pop() || gitPath; - } - } else { - // Root directory listing - paths are relative to root - fullPath = gitPath; - displayName = gitPath.split('/')[0]; // Get first component - } - - logger.debug({ gitPath, path, normalizedPath, fullPath, displayName, type }, '[FileManager] Parsed entry (space-separated)'); - - entries.push({ - name: displayName, - path: fullPath, - type: type === 'tree' ? 'directory' : 'file', - size: size !== '-' ? parseInt(size, 10) : undefined - }); - } else { - logger.debug({ line, path, ref }, '[FileManager] Line did not match expected format (space-separated)'); - } - } else { - // Tab-separated format (standard) - const beforeTab = line.substring(0, tabIndex); - const gitPath = line.substring(tabIndex + 1); - const match = beforeTab.match(/^(\d+)\s+(\w+)\s+(\w+)\s+(\d+|-)$/); - if (match) { - const [, , type, , size] = match; - // git ls-tree always returns paths relative to repo root - // If we're listing a subdirectory, the returned paths will start with that directory - let fullPath: string; - let displayName: string; - - if (normalizedPath) { - // We're listing a subdirectory - if (gitPath.startsWith(normalizedPath)) { - // Path already includes the directory prefix (normal case) - fullPath = gitPath; - // Extract just the filename/dirname (relative to the requested path) - const relativePath = gitPath.slice(normalizedPath.length); - // Remove any leading/trailing slashes - const cleanRelative = relativePath.replace(/^\/+|\/+$/g, ''); - // For display name, get the first component (the immediate child) - // This handles both files (image.png) and nested dirs (screenshots/image.png -> screenshots) - displayName = cleanRelative.split('/')[0] || cleanRelative; - } else { - // Path doesn't start with directory prefix - this shouldn't happen normally - // but handle it by joining - logger.debug({ path, normalizedPath, gitPath }, '[FileManager] Path does not start with normalized path, joining (tab-separated)'); - fullPath = join(path, gitPath); - displayName = gitPath.split('/').pop() || gitPath; - } - } else { - // Root directory listing - paths are relative to root - fullPath = gitPath; - displayName = gitPath.split('/')[0]; // Get first component - } - - logger.debug({ gitPath, path, normalizedPath, fullPath, displayName, type }, '[FileManager] Parsed entry (tab-separated)'); - - entries.push({ - name: displayName, - path: fullPath, - type: type === 'tree' ? 'directory' : 'file', - size: size !== '-' ? parseInt(size, 10) : undefined - }); - } else { - logger.debug({ line, path, ref, beforeTab }, '[FileManager] Line did not match expected format (tab-separated)'); - } - } - } - - // Debug logging to help diagnose missing files - logger.debug({ - npub, - repoName, - path, - ref, - entryCount: entries.length, - entries: entries.map(e => ({ name: e.name, path: e.path, type: e.type })) - }, '[FileManager] Parsed file entries'); - - const sortedEntries = entries.sort((a, b) => { - // Directories first, then files, both alphabetically - if (a.type !== b.type) { - return a.type === 'directory' ? -1 : 1; - } - return a.name.localeCompare(b.name); - }); - - // Cache the result (cache for 2 minutes) - repoCache.set(cacheKey, sortedEntries, 2 * 60 * 1000); - - return sortedEntries; - } catch (error) { - // Check if this is an empty branch error that wasn't caught earlier - const errorMsg = error instanceof Error ? error.message : String(error); - const errorStr = String(error).toLowerCase(); - const errorMsgLower = errorMsg.toLowerCase(); - - const isEmptyBranchError = - errorMsgLower.includes('not a valid object') || - errorMsgLower.includes('not found') || - errorMsgLower.includes('bad revision') || - errorMsgLower.includes('ambiguous argument') || - errorStr.includes('not a valid object') || - errorStr.includes('not found') || - errorStr.includes('bad revision') || - errorStr.includes('ambiguous argument') || - (errorMsgLower.includes('fatal:') && (errorMsgLower.includes('master') || errorMsgLower.includes('refs/heads'))); - - if (isEmptyBranchError) { - logger.debug({ npub, repoName, path, ref, error: errorMsg, errorStr }, '[FileManager] Branch has no commits (caught in outer catch), returning empty list'); - const emptyResult: FileEntry[] = []; - // Cache empty result for shorter time (30 seconds) - repoCache.set(cacheKey, emptyResult, 30 * 1000); - return emptyResult; - } - - logger.error({ error, repoPath, ref, errorMsg, errorStr }, 'Error listing files'); - throw new Error(`Failed to list files: ${error instanceof Error ? error.message : String(error)}`); - } + return listFiles({ npub, repoName, ref, path, repoPath }); } - /** - * Get file content from a repository - */ async getFileContent(npub: string, repoName: string, filePath: string, ref: string = 'HEAD'): Promise { - // Validate inputs - const npubValidation = this.validateNpub(npub); - if (!npubValidation.valid) { - throw new Error(`Invalid npub: ${npubValidation.error}`); - } - const repoValidation = this.validateRepoName(repoName); - if (!repoValidation.valid) { - throw new Error(`Invalid repository name: ${repoValidation.error}`); - } - - const pathValidation = this.validateFilePath(filePath); - if (!pathValidation.valid) { - throw new Error(`Invalid file path: ${pathValidation.error}`); - } - const repoPath = this.getRepoPath(npub, repoName); - if (!this.repoExists(npub, repoName)) { throw new Error('Repository not found'); } - - const git: SimpleGit = simpleGit(repoPath); - - try { - // Get file content using git show - // 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'; - const size = Buffer.byteLength(content, encoding); - - return { - content, - encoding, - size - }; - } catch (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}`); - } + return getFileContent({ npub, repoName, filePath, ref, repoPath }); } - /** - * Write file and commit changes - * @param signingOptions - Optional commit signing options: - * - 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) - */ async writeFile( npub: string, repoName: string, @@ -1178,856 +177,221 @@ export class FileManager { authorEmail: string, branch: string = 'main', signingOptions?: { - commitSignatureEvent?: NostrEvent; + commitSignatureEvent?: any; useNIP07?: boolean; - nip98Event?: NostrEvent; + nip98Event?: any; nsecKey?: string; } ): Promise { - // Validate inputs - const npubValidation = this.validateNpub(npub); - if (!npubValidation.valid) { - throw new Error(`Invalid npub: ${npubValidation.error}`); - } - const repoValidation = this.validateRepoName(repoName); - if (!repoValidation.valid) { - throw new Error(`Invalid repository name: ${repoValidation.error}`); - } - - const pathValidation = this.validateFilePath(filePath); - if (!pathValidation.valid) { - throw new Error(`Invalid file path: ${pathValidation.error}`); - } - - // Security: Validate branch name to prevent path traversal - if (!isValidBranchName(branch)) { - throw new Error(`Invalid branch name: ${branch}`); - } - - // Validate content size (prevent extremely large files) - const maxFileSize = 500 * 1024 * 1024; // 500 MB per file (allows for images and demo videos) - if (Buffer.byteLength(content, 'utf-8') > maxFileSize) { - throw new Error(`File is too large (max ${maxFileSize / 1024 / 1024} MB)`); - } - - // Validate commit message - if (!commitMessage || typeof commitMessage !== 'string' || commitMessage.trim().length === 0) { - throw new Error('Commit message is required'); - } - if (commitMessage.length > 1000) { - throw new Error('Commit message is too long (max 1000 characters)'); - } - - // Validate author info - if (!authorName || typeof authorName !== 'string' || authorName.trim().length === 0) { - throw new Error('Author name is required'); - } - if (!authorEmail || typeof authorEmail !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(authorEmail)) { - throw new Error('Valid author email is required'); - } - const repoPath = this.getRepoPath(npub, repoName); - if (!this.repoExists(npub, repoName)) { - throw new Error('Repository not found'); - } - - try { - // Check repository size before writing - const repoSizeCheck = await this.repoManager.checkRepoSizeLimit(repoPath); - if (!repoSizeCheck.withinLimit) { - throw new Error(repoSizeCheck.error || 'Repository size limit exceeded'); - } - - // Use git worktree instead of cloning (much more efficient) - const workDir = await this.getWorktree(repoPath, branch, npub, repoName); - const workGit: SimpleGit = simpleGit(workDir); - - // Write the file (use validated path) - const validatedPath = pathValidation.normalized || filePath; - const fullFilePath = join(workDir, validatedPath); - const fileDir = dirname(fullFilePath); - - // Additional security: ensure the resolved path is still within workDir - // Use trailing slash to ensure directory boundary (prevents sibling directory attacks) - const resolvedPath = resolve(fullFilePath).replace(/\\/g, '/'); - const resolvedWorkDir = resolve(workDir).replace(/\\/g, '/'); - if (!resolvedPath.startsWith(resolvedWorkDir + '/') && resolvedPath !== resolvedWorkDir) { - throw new Error('Path validation failed: resolved path outside work directory'); - } - - // Ensure directory exists - await this.ensureDirectoryExists(fileDir, 'directory for file', true); - - const { writeFile: writeFileFs } = await import('fs/promises'); - await writeFileFs(fullFilePath, content, 'utf-8'); - - // Stage the file (use validated path) - await workGit.add(validatedPath); - - // Sign commit if signing options are provided - let finalCommitMessage = commitMessage; - if (signingOptions && (signingOptions.commitSignatureEvent || signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) { - try { - const { signedMessage } = await createGitCommitSignature( - commitMessage, - authorName, - authorEmail, - signingOptions - ); - finalCommitMessage = signedMessage; - } catch (err) { - // Security: Sanitize error messages (never log private keys) - const sanitizedErr = sanitizeError(err); - logger.warn({ error: sanitizedErr, repoPath, filePath }, 'Failed to sign commit'); - // Continue without signature if signing fails - } - } - - // Commit - const commitResult = await workGit.commit(finalCommitMessage, [filePath], { - '--author': `${authorName} <${authorEmail}>` - }) as string | { commit: string }; - - // Get commit hash from result - let commitHash: string; - if (typeof commitResult === 'string') { - commitHash = commitResult.trim(); - } else if (commitResult && typeof commitResult === 'object' && 'commit' in commitResult) { - commitHash = String(commitResult.commit); - } else { - // Fallback: get latest commit hash - commitHash = await workGit.revparse(['HEAD']); - } - - // Save commit signature event to nostr folder if signing was used - if (signingOptions && (signingOptions.commitSignatureEvent || signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) { - try { - // Get the signature event that was used (already created above) - let signatureEvent: NostrEvent; - if (signingOptions.commitSignatureEvent) { - signatureEvent = signingOptions.commitSignatureEvent; - } else { - // Re-create it to get the event object - const { signedMessage: _, signatureEvent: event } = await createGitCommitSignature( - commitMessage, - authorName, - authorEmail, - signingOptions - ); - signatureEvent = event; - } - - // Save to nostr/commit-signatures.jsonl (use workDir since we have it) - await this.saveCommitSignatureEventToWorktree(workDir, signatureEvent); - - // Check if repo is private - only publish to relays if public - const isPrivate = await this.isRepoPrivate(npub, repoName); - if (!isPrivate) { - // Public repo: publish commit signature event to relays - try { - const { NostrClient } = await import('../nostr/nostr-client.js'); - const { DEFAULT_NOSTR_RELAYS } = await import('../../config.js'); - const { getUserRelays } = await import('../nostr/user-relays.js'); - const { combineRelays } = await import('../../config.js'); - - // Get user's preferred relays (outbox/inbox from kind 10002) - const { nip19 } = await import('nostr-tools'); - const { requireNpubHex } = await import('../../utils/npub-utils.js'); - const userPubkeyHex = requireNpubHex(npub); - - const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); - const { inbox, outbox } = await getUserRelays(userPubkeyHex, nostrClient); - - // Use user's outbox relays if available, otherwise inbox, otherwise defaults - const userRelays = outbox.length > 0 - ? combineRelays(outbox, DEFAULT_NOSTR_RELAYS) - : inbox.length > 0 - ? combineRelays(inbox, DEFAULT_NOSTR_RELAYS) - : DEFAULT_NOSTR_RELAYS; - - // Publish to relays (non-blocking - don't fail if publishing fails) - const publishResult = await nostrClient.publishEvent(signatureEvent, userRelays); - if (publishResult.success.length > 0) { - logger.debug({ - eventId: signatureEvent.id, - commitHash, - relays: publishResult.success - }, 'Published commit signature event to relays'); - } - if (publishResult.failed.length > 0) { - logger.warn({ - eventId: signatureEvent.id, - failed: publishResult.failed - }, 'Some relays failed to publish commit signature event'); - } - } catch (publishErr) { - // Log but don't fail - publishing is nice-to-have, saving to repo is the important part - const sanitizedErr = sanitizeError(publishErr); - logger.debug({ error: sanitizedErr, repoPath, filePath }, 'Failed to publish commit signature event to relays'); - } - } else { - // Private repo: only save to repo, don't publish to relays - logger.debug({ repoPath, filePath }, 'Private repo - commit signature event saved to repo only (not published to relays)'); - } - } catch (err) { - // Log but don't fail - saving event is nice-to-have - const sanitizedErr = sanitizeError(err); - logger.debug({ error: sanitizedErr, repoPath, filePath }, 'Failed to save commit signature event'); - } - } - - // 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 - await this.removeWorktree(repoPath, workDir); - } catch (error) { - logger.error({ error, repoPath, filePath, npub }, 'Error writing file'); - throw new Error(`Failed to write file: ${error instanceof Error ? error.message : String(error)}`); - } - } - - /** - * Save commit signature event to nostr/commit-signatures.jsonl in a worktree - */ - private async saveCommitSignatureEventToWorktree(worktreePath: string, event: NostrEvent): Promise { - try { - const { mkdir, writeFile } = await import('fs/promises'); - const { join } = await import('path'); - - // Create nostr directory in worktree - const nostrDir = join(worktreePath, 'nostr'); - await mkdir(nostrDir, { recursive: true }); - - // Append to commit-signatures.jsonl - const jsonlFile = join(nostrDir, 'commit-signatures.jsonl'); - const eventLine = JSON.stringify(event) + '\n'; - await writeFile(jsonlFile, eventLine, { flag: 'a', encoding: 'utf-8' }); - } catch (err) { - logger.debug({ error: err, worktreePath }, 'Failed to save commit signature event to nostr folder'); - // Don't throw - this is a nice-to-have feature - } - } - - /** - * Save a repo event (announcement or transfer) to nostr/repo-events.jsonl - * This provides a standard location for all repo-related Nostr events for easy analysis - */ - async saveRepoEventToWorktree( - worktreePath: string, - event: NostrEvent, - eventType: 'announcement' | 'transfer', - skipIfExists: boolean = true - ): Promise { - try { - const { mkdir, writeFile, readFile } = await import('fs/promises'); - const { join } = await import('path'); - - // Create nostr directory in worktree - const nostrDir = join(worktreePath, 'nostr'); - await mkdir(nostrDir, { recursive: true }); - - // 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; - } - } - - /** - * Check if a repository is private by fetching its announcement event - */ - private async isRepoPrivate(npub: string, repoName: string): Promise { - try { - const { requireNpubHex } = await import('../../utils/npub-utils.js'); - const repoOwnerPubkey = requireNpubHex(npub); - - // Fetch the repository announcement - const { NostrClient } = await import('../nostr/nostr-client.js'); - const { DEFAULT_NOSTR_RELAYS } = await import('../../config.js'); - const { KIND } = await import('../../types/nostr.js'); - - const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); - const events = await nostrClient.fetchEvents([ - { - kinds: [KIND.REPO_ANNOUNCEMENT], - authors: [repoOwnerPubkey], - '#d': [repoName], - limit: 1 - } - ]); - - if (events.length === 0) { - // No announcement found - assume public (default) - return false; - } - - const announcement = events[0]; - - // Use shared utility to check if repo is private - const { isPrivateRepo: checkIsPrivateRepo } = await import('../../utils/repo-privacy.js'); - return checkIsPrivateRepo(announcement); - } catch (err) { - // If we can't determine, default to public (safer - allows publishing) - logger.debug({ error: err, npub, repoName }, 'Failed to check repo privacy, defaulting to public'); - return false; - } - } - - /** - * Get list of branches (with caching) - */ - /** - * Get the default branch name for a repository - * Tries to detect the actual default branch (master, main, etc.) - */ - async getDefaultBranch(npub: string, repoName: string): Promise { - const repoPath = this.getRepoPath(npub, repoName); - - if (!this.repoExists(npub, repoName)) { - throw new Error('Repository not found'); - } - - const git: SimpleGit = simpleGit(repoPath); - - try { - // Try to get the default branch from symbolic-ref - // For bare repos, this points to the default branch - const defaultRef = await git.raw(['symbolic-ref', 'HEAD']); - if (defaultRef) { - const match = defaultRef.trim().match(/^refs\/heads\/(.+)$/); - if (match) { - return match[1]; - } - } - } catch { - // If symbolic-ref fails, try to get from remote HEAD - try { - const remoteHead = await git.raw(['symbolic-ref', 'refs/remotes/origin/HEAD']); - if (remoteHead) { - const match = remoteHead.trim().match(/^refs\/remotes\/origin\/(.+)$/); - if (match) { - return match[1]; - } - } - } catch { - // Fall through to branch detection - } - } - - // Fallback: get branches and prefer 'main', then 'master', then first branch - try { - const branches = await git.branch(['-r']); - const branchList = branches.all - .map(b => b.replace(/^origin\//, '')) - .filter(b => !b.includes('HEAD')); - - if (branchList.length === 0) { - return 'main'; // Ultimate fallback - } - - // Prefer 'main', then 'master', then first branch - if (branchList.includes('main')) return 'main'; - if (branchList.includes('master')) return 'master'; - return branchList[0]; - } catch { - return 'main'; // Ultimate fallback - } - } - - async getBranches(npub: string, repoName: string): Promise { - const repoPath = this.getRepoPath(npub, repoName); - - if (!this.repoExists(npub, repoName)) { - throw new Error('Repository not found'); - } - - // Check cache first - const cacheKey = RepoCache.branchesKey(npub, repoName); - const cached = repoCache.get(cacheKey); - if (cached !== null) { - return cached; - } - - const git: SimpleGit = simpleGit(repoPath); - - try { - // 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')) - .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 - } - } - - // Sort branches: default branch first, then alphabetically - let branchList = Array.from(allBranches); - try { - const defaultBranch = await this.getDefaultBranch(npub, repoName); - if (defaultBranch) { - branchList.sort((a, b) => { - if (a === defaultBranch) return -1; - if (b === defaultBranch) return 1; - return a.localeCompare(b); - }); - } else { - // No default branch found, just sort alphabetically - branchList.sort(); - } - } catch { - // If we can't get default branch, just sort alphabetically - branchList.sort(); - } - - // Cache the result (cache for 2 minutes) - repoCache.set(cacheKey, branchList, 2 * 60 * 1000); - - return branchList; - } catch (error) { - logger.error({ error, repoPath }, 'Error getting branches'); - const defaultBranches = ['main', 'master']; - // Cache default branches for shorter time (30 seconds) - repoCache.set(cacheKey, defaultBranches, 30 * 1000); - return defaultBranches; + throw new Error('Repository not found'); + } + + // Check repo size + const repoSizeCheck = await this.repoManager.checkRepoSizeLimit(repoPath); + if (!repoSizeCheck.withinLimit) { + throw new Error(repoSizeCheck.error || 'Repository size limit exceeded'); } + + const worktreePath = await this.getWorktree(repoPath, branch, npub, repoName); + + // Save commit signature helper + const saveCommitSignature = async (worktreePath: string, event: any) => { + await this.saveCommitSignatureEventToWorktree(worktreePath, event); + }; + + // Check if repo is private + const isRepoPrivate = async (npub: string, repoName: string) => { + return this.isRepoPrivate(npub, repoName); + }; + + await writeFile({ + npub, + repoName, + filePath, + content, + commitMessage, + authorName, + authorEmail, + branch, + repoPath, + worktreePath, + signingOptions, + saveCommitSignature, + isRepoPrivate + }); + + await this.removeWorktree(repoPath, worktreePath); } - /** - * Create a new file - * @param signingOptions - Optional commit signing options (see writeFile) - */ - async createFile( + async deleteFile( npub: string, repoName: string, filePath: string, - content: string, commitMessage: string, authorName: string, authorEmail: string, branch: string = 'main', signingOptions?: { + commitSignatureEvent?: any; useNIP07?: boolean; - nip98Event?: NostrEvent; + nip98Event?: any; nsecKey?: string; } ): Promise { - // Reuse writeFile logic - it will create the file if it doesn't exist - return this.writeFile(npub, repoName, filePath, content, commitMessage, authorName, authorEmail, branch, signingOptions); + const repoPath = this.getRepoPath(npub, repoName); + if (!this.repoExists(npub, repoName)) { + throw new Error('Repository not found'); + } + + const worktreePath = await this.getWorktree(repoPath, branch, npub, repoName); + + const saveCommitSignature = async (worktreePath: string, event: any) => { + await this.saveCommitSignatureEventToWorktree(worktreePath, event); + }; + + await deleteFile({ + npub, + repoName, + filePath, + commitMessage, + authorName, + authorEmail, + branch, + repoPath, + worktreePath, + signingOptions, + saveCommitSignature + }); + + await this.removeWorktree(repoPath, worktreePath); } - /** - * Delete a file - * @param signingOptions - Optional commit signing options (see writeFile) - */ - async deleteFile( + async createFile( npub: string, repoName: string, filePath: string, + content: string, commitMessage: string, authorName: string, authorEmail: string, branch: string = 'main', signingOptions?: { - commitSignatureEvent?: NostrEvent; useNIP07?: boolean; - nip98Event?: NostrEvent; + nip98Event?: any; nsecKey?: string; } ): Promise { - // Validate inputs - const npubValidation = this.validateNpub(npub); - if (!npubValidation.valid) { - throw new Error(`Invalid npub: ${npubValidation.error}`); - } - const repoValidation = this.validateRepoName(repoName); - if (!repoValidation.valid) { - throw new Error(`Invalid repository name: ${repoValidation.error}`); - } - - const pathValidation = this.validateFilePath(filePath); - if (!pathValidation.valid) { - throw new Error(`Invalid file path: ${pathValidation.error}`); - } - - // Security: Validate branch name to prevent path traversal - if (!isValidBranchName(branch)) { - throw new Error(`Invalid branch name: ${branch}`); - } - - // Validate commit message - if (!commitMessage || typeof commitMessage !== 'string' || commitMessage.trim().length === 0) { - throw new Error('Commit message is required'); - } - - // Validate author info - if (!authorName || typeof authorName !== 'string' || authorName.trim().length === 0) { - throw new Error('Author name is required'); - } - if (!authorEmail || typeof authorEmail !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(authorEmail)) { - throw new Error('Valid author email is required'); - } + return this.writeFile(npub, repoName, filePath, content, commitMessage, authorName, authorEmail, branch, signingOptions); + } + async getDefaultBranch(npub: string, repoName: string): Promise { const repoPath = this.getRepoPath(npub, repoName); - if (!this.repoExists(npub, repoName)) { throw new Error('Repository not found'); } - try { - // Use git worktree instead of cloning (much more efficient) - const workDir = await this.getWorktree(repoPath, branch, npub, repoName); - const workGit: SimpleGit = simpleGit(workDir); + const git: SimpleGit = simpleGit(repoPath); - // Remove the file (use validated path) - const validatedPath = pathValidation.normalized || filePath; - const fullFilePath = join(workDir, validatedPath); - - // Additional security: ensure the resolved path is still within workDir - // Use trailing slash to ensure directory boundary (prevents sibling directory attacks) - const resolvedPath = resolve(fullFilePath).replace(/\\/g, '/'); - const resolvedWorkDir = resolve(workDir).replace(/\\/g, '/'); - if (!resolvedPath.startsWith(resolvedWorkDir + '/') && resolvedPath !== resolvedWorkDir) { - throw new Error('Path validation failed: resolved path outside work directory'); - } - - if (await this.pathExists(fullFilePath)) { - const { unlink } = await this.getFsPromises(); - await unlink(fullFilePath); + try { + const defaultRef = await git.raw(['symbolic-ref', 'HEAD']); + if (defaultRef) { + const match = defaultRef.trim().match(/^refs\/heads\/(.+)$/); + if (match) return match[1]; } - - // Stage the deletion (use validated path) - await workGit.rm([validatedPath]); - - // Sign commit if signing options are provided - let finalCommitMessage = commitMessage; - if (signingOptions && (signingOptions.commitSignatureEvent || signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) { - try { - const { signedMessage } = await createGitCommitSignature( - commitMessage, - authorName, - authorEmail, - signingOptions - ); - finalCommitMessage = signedMessage; - } catch (err) { - // Security: Sanitize error messages (never log private keys) - const sanitizedErr = sanitizeError(err); - logger.warn({ error: sanitizedErr, repoPath, filePath }, 'Failed to sign commit'); - // Continue without signature if signing fails + } catch { + try { + const remoteHead = await git.raw(['symbolic-ref', 'refs/remotes/origin/HEAD']); + if (remoteHead) { + const match = remoteHead.trim().match(/^refs\/remotes\/origin\/(.+)$/); + if (match) return match[1]; } + } catch { + // Fall through } + } - // Commit - await workGit.commit(finalCommitMessage, [filePath], { - '--author': `${authorName} <${authorEmail}>` - }); - - // 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. + try { + const branches = await git.branch(['-r']); + const branchList = branches.all + .map(b => b.replace(/^origin\//, '')) + .filter(b => !b.includes('HEAD')); + + if (branchList.length === 0) return 'main'; + if (branchList.includes('main')) return 'main'; + if (branchList.includes('master')) return 'master'; + return branchList[0]; + } catch { + return 'main'; + } + } - // Clean up worktree - await this.removeWorktree(repoPath, workDir); - } catch (error) { - logger.error({ error, repoPath, filePath, npub }, 'Error deleting file'); - throw new Error(`Failed to delete file: ${error instanceof Error ? error.message : String(error)}`); + async getBranches(npub: string, repoName: string): Promise { + const repoPath = this.getRepoPath(npub, repoName); + if (!this.repoExists(npub, repoName)) { + throw new Error('Repository not found'); } + return getBranches({ + npub, + repoName, + repoPath, + getDefaultBranch: (npub, repoName) => this.getDefaultBranch(npub, repoName) + }); } - /** - * Create a new branch - */ async createBranch( npub: string, repoName: string, branchName: string, - fromBranch?: string, - announcement?: NostrEvent + fromBranch: string = 'main' ): Promise { - // Security: Validate branch names 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'); } - try { - const git: SimpleGit = simpleGit(repoPath); - - // Check if repo has any branches - let hasBranches = false; - try { - const branches = await git.branch(['-a']); - const branchList = branches.all - .map(b => b.replace(/^remotes\/origin\//, '').replace(/^remotes\//, '')) - .filter(b => !b.includes('HEAD') && !b.startsWith('*')); - hasBranches = branchList.length > 0; - } catch { - // If branch listing fails, assume no branches exist - hasBranches = false; - } - - // If no branches exist, create an orphan branch (branch with no parent) - if (!hasBranches) { - // Use provided announcement or fetch repo announcement to use as initial commit message and save to file - let commitMessage = 'Initial commit'; - 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'); - } - } - - 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) - const worktreeRoot = join(this.repoRoot, npub, `${repoName}.worktrees`); - const worktreePath = resolve(join(worktreeRoot, branchName)); - const { rm } = await this.getFsPromises(); - - // Ensure repoRoot is writable if it exists - if (await this.pathExists(this.repoRoot)) { - await this.verifyDirectoryWritable(this.repoRoot, 'GIT_REPO_ROOT directory'); - } - - // Ensure parent directory exists (npub directory) - const parentDir = join(this.repoRoot, npub); - await this.ensureDirectoryExists(parentDir, 'parent directory for worktree', true); - - // Create worktree root directory - await this.ensureDirectoryExists(worktreeRoot, 'worktree root directory', false); - - // Remove existing worktree if it exists - if (await this.pathExists(worktreePath)) { - try { - await git.raw(['worktree', 'remove', worktreePath, '--force']); - } catch { - const { rm } = await this.getFsPromises(); - await rm(worktreePath, { recursive: true, force: true }); - } - } - - // Create worktree with orphan branch - // Note: --orphan must come before branch name, path comes last - await git.raw(['worktree', 'add', '--orphan', branchName, worktreePath]); - - // 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); - 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, announcementSaved }, 'Created orphan branch with initial commit'); - - // Clean up worktree - await this.removeWorktree(repoPath, worktreePath); - } else { - // Repo has branches - use normal branch creation - // Validate fromBranch if provided - if (fromBranch && !isValidBranchName(fromBranch)) { - throw new Error(`Invalid source branch name: ${fromBranch}`); - } - - // Use default branch if fromBranch not provided - const sourceBranch = fromBranch || await this.getDefaultBranch(npub, repoName).catch(() => 'main'); - - // Use git worktree instead of cloning (much more efficient) - const workDir = await this.getWorktree(repoPath, sourceBranch, npub, repoName); - const workGit: SimpleGit = simpleGit(workDir); - - // Create and checkout new branch - await workGit.checkout(['-b', branchName]); + if (!isValidBranchName(branchName)) { + throw new Error(`Invalid branch name: ${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. + const git: SimpleGit = simpleGit(repoPath); - // Clean up worktree - await this.removeWorktree(repoPath, workDir); - } + try { + await git.raw(['branch', branchName, fromBranch]); + const cacheKey = RepoCache.branchesKey(npub, repoName); + repoCache.delete(cacheKey); } catch (error) { - logger.error({ error, repoPath, branchName, npub }, 'Error creating branch'); + logger.error({ error, repoPath, branchName, fromBranch }, 'Error creating branch'); throw new Error(`Failed to create branch: ${error instanceof Error ? error.message : String(error)}`); } } - /** - * 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}`); - } - + async deleteBranch(npub: string, repoName: string, branchName: string): Promise { 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.`); + if (!isValidBranchName(branchName)) { + throw new Error(`Invalid branch name: ${branchName}`); } 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}`); - } + await git.raw(['update-ref', '-d', `refs/heads/${branchName}`]); }); - // Invalidate branches cache const cacheKey = RepoCache.branchesKey(npub, repoName); repoCache.delete(cacheKey); } catch (error) { - logger.error({ error, repoPath, branchName, npub }, 'Error deleting branch'); + logger.error({ error, repoPath, branchName }, 'Error deleting branch'); throw new Error(`Failed to delete branch: ${error instanceof Error ? error.message : String(error)}`); } } - /** - * Get commit history - */ async getCommitHistory( npub: string, repoName: string, @@ -2036,45 +400,12 @@ export class FileManager { path?: string ): Promise { const repoPath = this.getRepoPath(npub, repoName); - if (!this.repoExists(npub, repoName)) { throw new Error('Repository not found'); } - - const git: SimpleGit = simpleGit(repoPath); - - try { - const logOptions: { - maxCount: number; - from: string; - file?: string; - } = { - maxCount: limit, - from: branch - }; - - if (path) { - logOptions.file = path; - } - - const log = await git.log(logOptions); - - return log.all.map(commit => ({ - hash: commit.hash, - message: commit.message, - author: `${commit.author_name} <${commit.author_email}>`, - date: commit.date, - files: commit.diff?.files?.map((f: { file: string }) => f.file) || [] - })); - } catch (error) { - logger.error({ error, repoPath, branch, limit }, 'Error getting commit history'); - throw new Error(`Failed to get commit history: ${error instanceof Error ? error.message : String(error)}`); - } + return getCommitHistory({ npub, repoName, branch, limit, path, repoPath }); } - /** - * Get diff between two commits or for a file - */ async getDiff( npub: string, repoName: string, @@ -2083,87 +414,12 @@ export class FileManager { filePath?: string ): Promise { const repoPath = this.getRepoPath(npub, repoName); - if (!this.repoExists(npub, repoName)) { throw new Error('Repository not found'); } - - const git: SimpleGit = simpleGit(repoPath); - - try { - const diffOptions: string[] = [fromRef, toRef]; - if (filePath) { - diffOptions.push('--', filePath); - } - - const diff = await git.diff(diffOptions); - const stats = await git.diffSummary(diffOptions); - - // Parse diff output - const files: Diff[] = []; - const diffLines = diff.split('\n'); - let currentFile = ''; - let currentDiff = ''; - let inFileHeader = false; - - for (const line of diffLines) { - if (line.startsWith('diff --git')) { - if (currentFile) { - files.push({ - file: currentFile, - additions: 0, - deletions: 0, - diff: currentDiff - }); - } - const match = line.match(/diff --git a\/(.+?) b\/(.+?)$/); - if (match) { - currentFile = match[2]; - currentDiff = line + '\n'; - inFileHeader = true; - } - } else { - currentDiff += line + '\n'; - if (line.startsWith('@@')) { - inFileHeader = false; - } - if (!inFileHeader && (line.startsWith('+') || line.startsWith('-'))) { - // Count additions/deletions - } - } - } - - if (currentFile) { - files.push({ - file: currentFile, - additions: 0, - deletions: 0, - diff: currentDiff - }); - } - - // Add stats from diffSummary - if (stats.files && files.length > 0) { - for (const statFile of stats.files) { - const file = files.find(f => f.file === statFile.file); - if (file && 'insertions' in statFile && 'deletions' in statFile) { - file.additions = statFile.insertions; - file.deletions = statFile.deletions; - } - } - } - - return files; - } catch (error) { - const sanitizedError = sanitizeError(error); - logger.error({ error: sanitizedError, repoPath: this.sanitizePathForError(repoPath), fromRef, toRef }, 'Error getting diff'); - throw new Error(`Failed to get diff: ${sanitizedError}`); - } + return getDiff({ npub, repoName, fromRef, toRef, filePath, repoPath }); } - /** - * Create a tag - */ async createTag( npub: string, repoName: string, @@ -2174,179 +430,123 @@ export class FileManager { authorEmail?: string ): Promise { const repoPath = this.getRepoPath(npub, repoName); - if (!this.repoExists(npub, repoName)) { throw new Error('Repository not found'); } + return createTag({ npub, repoName, tagName, ref, message, authorName, authorEmail, repoPath }); + } - const git: SimpleGit = simpleGit(repoPath); + async getTags(npub: string, repoName: string): Promise { + const repoPath = this.getRepoPath(npub, repoName); + if (!this.repoExists(npub, repoName)) { + throw new Error('Repository not found'); + } + return getTags({ npub, repoName, repoPath }); + } + private async saveCommitSignatureEventToWorktree(worktreePath: string, event: any): Promise { try { - // Check if repository has any commits - let hasCommits = false; - try { - // Try to get HEAD commit - const headCommit = await git.raw(['rev-parse', 'HEAD']).catch(() => null); - hasCommits = !!(headCommit && headCommit.trim().length > 0); - } catch { - // Check if any branch has commits + const { mkdir, writeFile } = await this.getFsPromises(); + const nostrDir = join(worktreePath, 'nostr'); + await mkdir(nostrDir, { recursive: true }); + const jsonlFile = join(nostrDir, 'commit-signatures.jsonl'); + const eventLine = JSON.stringify(event) + '\n'; + await writeFile(jsonlFile, eventLine, { flag: 'a', encoding: 'utf-8' }); + } catch (err) { + logger.debug({ error: err, worktreePath }, 'Failed to save commit signature event'); + } + } + + async saveRepoEventToWorktree( + worktreePath: string, + event: any, + eventType: 'announcement' | 'transfer', + skipIfExists: boolean = true + ): Promise { + try { + const { mkdir, writeFile, readFile } = await this.getFsPromises(); + const nostrDir = join(worktreePath, 'nostr'); + await mkdir(nostrDir, { recursive: true }); + const jsonlFile = join(nostrDir, 'repo-events.jsonl'); + + if (skipIfExists) { try { - const branches = await git.branch(['-a']); - for (const branch of branches.all) { - const branchName = branch.replace(/^remotes\/origin\//, '').replace(/^remotes\//, ''); - if (branchName.includes('HEAD')) continue; + const existingContent = await readFile(jsonlFile, 'utf-8'); + const lines = existingContent.trim().split('\n').filter(l => l.trim()); + for (const line of lines) { try { - const commitHash = await git.raw(['rev-parse', `refs/heads/${branchName}`]).catch(() => null); - if (commitHash && commitHash.trim().length > 0) { - hasCommits = true; - // If ref is HEAD and we found a branch with commits, use that branch - if (ref === 'HEAD') { - ref = branchName; - } - break; + const parsed = JSON.parse(line); + if (parsed.event && parsed.event.id === event.id) { + return false; } } catch { - // Continue checking other branches + // Skip invalid lines } } } catch { - // Could not check branches - } - } - - if (!hasCommits) { - throw new Error('Cannot create tag: repository has no commits. Please create at least one commit first.'); - } - - // Validate that the ref exists - try { - await git.raw(['rev-parse', '--verify', ref]); - } catch (refErr) { - throw new Error(`Invalid reference '${ref}': ${refErr instanceof Error ? refErr.message : String(refErr)}`); - } - - if (message) { - // Create annotated tag - // Note: simple-git addTag doesn't support message directly, use raw command - if (ref !== 'HEAD') { - await git.raw(['tag', '-a', tagName, '-m', message, ref]); - } else { - await git.raw(['tag', '-a', tagName, '-m', message]); - } - } else { - // Create lightweight tag - if (ref !== 'HEAD') { - await git.raw(['tag', tagName, ref]); - } else { - await git.addTag(tagName); + // File doesn't exist yet } } - } catch (error) { - logger.error({ error, repoPath, tagName, ref, message }, 'Error creating tag'); - throw new Error(`Failed to create tag: ${error instanceof Error ? error.message : String(error)}`); + + 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'); + return false; } } - /** - * Get list of tags - */ - async getTags(npub: string, repoName: string): Promise { - const repoPath = this.getRepoPath(npub, repoName); - - if (!this.repoExists(npub, repoName)) { - throw new Error('Repository not found'); - } - - const git: SimpleGit = simpleGit(repoPath); - + private async isRepoPrivate(npub: string, repoName: string): Promise { try { - const tags = await git.tags(); - const tagList: Tag[] = []; - - for (const tagName of tags.all) { - try { - // Get the commit hash the tag points to - const hash = await git.raw(['rev-parse', tagName]); - const commitHash = hash.trim(); - - // Get the commit date (Unix timestamp) - let commitDate: number | undefined; - try { - const dateStr = await git.raw(['log', '-1', '--format=%at', commitHash]); - commitDate = parseInt(dateStr.trim(), 10); - if (isNaN(commitDate)) { - commitDate = undefined; - } - } catch { - // If we can't get the date, continue without it - commitDate = undefined; - } - - // Try to get tag message (for annotated tags) - try { - const tagInfo = await git.raw(['cat-file', '-p', tagName]); - const messageMatch = tagInfo.match(/^(.+)$/m); - - tagList.push({ - name: tagName, - hash: commitHash, - message: messageMatch ? messageMatch[1] : undefined, - date: commitDate - }); - } catch { - // Lightweight tag - no message - tagList.push({ - name: tagName, - hash: commitHash, - date: commitDate - }); - } - } catch (err) { - // If we can't process this tag, skip it - logger.warn({ error: err, tagName }, 'Error processing tag, skipping'); + const { requireNpubHex } = await import('../../../utils/npub-utils.js'); + const repoOwnerPubkey = requireNpubHex(npub); + const { NostrClient } = await import('../../nostr/nostr-client.js'); + const { DEFAULT_NOSTR_RELAYS } = await import('../../../config.js'); + const { KIND } = await import('../../../types/nostr.js'); + + const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); + const events = await nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [repoOwnerPubkey], + '#d': [repoName], + limit: 1 } - } - - return tagList; - } catch (error) { - logger.error({ error, repoPath }, 'Error getting tags'); - return []; + ]); + + if (events.length === 0) return false; + + const { isPrivateRepo: checkIsPrivateRepo } = await import('../../../utils/repo-privacy.js'); + return checkIsPrivateRepo(events[0]); + } catch (err) { + logger.debug({ error: err, npub, repoName }, 'Failed to check repo privacy, defaulting to public'); + return false; } } - /** - * Get the current owner from the most recent announcement file in the repository - * Ownership is determined by the most recent announcement file checked into the git repo - * - * @param npub - Repository owner npub (for path construction) - * @param repoName - The repository name - * @returns The current owner pubkey from the most recent announcement file, or null if not found - */ async getCurrentOwnerFromRepo(npub: string, repoName: string): Promise { try { - if (!this.repoExists(npub, repoName)) { - return null; - } + if (!this.repoExists(npub, repoName)) return null; const repoPath = this.getRepoPath(npub, repoName); const git: SimpleGit = simpleGit(repoPath); - // Get git log for nostr/repo-events.jsonl, most recent first - // Use --all to check all branches, --reverse to get chronological order const logOutput = await git.raw(['log', '--all', '--format=%H', '--reverse', '--', 'nostr/repo-events.jsonl']); const commitHashes = logOutput.trim().split('\n').filter(Boolean); - if (commitHashes.length === 0) { - return null; // No announcement in repo - } + if (commitHashes.length === 0) return null; - // Get the most recent repo-events.jsonl content (last commit in the list) const mostRecentCommit = commitHashes[commitHashes.length - 1]; const repoEventsFile = await this.getFileContent(npub, repoName, 'nostr/repo-events.jsonl', mostRecentCommit); - // Parse the repo-events.jsonl file and find the most recent announcement let announcementEvent: any = null; let latestTimestamp = 0; + try { const lines = repoEventsFile.content.trim().split('\n').filter(Boolean); for (const line of lines) { @@ -2359,36 +559,24 @@ export class FileManager { } } } catch { - // Skip invalid lines continue; } } } catch (parseError) { - logger.warn({ error: parseError, npub, repoName, commit: mostRecentCommit }, 'Failed to parse repo-events.jsonl'); + logger.warn({ error: parseError, npub, repoName }, 'Failed to parse repo-events.jsonl'); return null; } - if (!announcementEvent) { - return null; - } + if (!announcementEvent) return null; - // Validate the announcement event to prevent fake announcements - const { validateAnnouncementEvent } = await import('../nostr/repo-verification.js'); + const { validateAnnouncementEvent } = await import('../../nostr/repo-verification.js'); const validation = validateAnnouncementEvent(announcementEvent, repoName); if (!validation.valid) { - logger.warn({ - error: validation.error, - npub, - repoName, - commit: mostRecentCommit, - eventId: announcementEvent.id, - eventPubkey: announcementEvent.pubkey?.substring(0, 16) + '...' - }, 'Announcement file validation failed - possible fake announcement'); + logger.warn({ error: validation.error, npub, repoName }, 'Announcement validation failed'); return null; } - // Return the pubkey from the validated announcement return announcementEvent.pubkey; } catch (error) { logger.error({ error, npub, repoName }, 'Error getting current owner from repo'); diff --git a/src/lib/services/git/file-manager.ts.backup b/src/lib/services/git/file-manager.ts.backup new file mode 100644 index 0000000..f2c49c0 --- /dev/null +++ b/src/lib/services/git/file-manager.ts.backup @@ -0,0 +1,2398 @@ +/** + * File manager for git repositories + * Handles reading, writing, and listing files in git repos + */ + +import simpleGit, { type SimpleGit } from 'simple-git'; +import { readdir } from 'fs/promises'; +import { join, dirname, normalize, resolve } from 'path'; +import { spawn } from 'child_process'; +import { RepoManager } from './repo-manager.js'; +import { createGitCommitSignature } from './commit-signer.js'; +import type { NostrEvent } from '../../types/nostr.js'; +import logger from '../logger.js'; +import { sanitizeError, isValidBranchName } from '../../utils/security.js'; +import { repoCache, RepoCache } from './repo-cache.js'; + +export interface FileEntry { + name: string; + path: string; + type: 'file' | 'directory'; + size?: number; +} + +export interface FileContent { + content: string; + encoding: string; + size: number; +} + +export interface Commit { + hash: string; + message: string; + author: string; + date: string; + files: string[]; +} + +export interface Diff { + file: string; + additions: number; + deletions: number; + diff: string; +} + +export interface Tag { + name: string; + hash: string; + message?: string; + date?: number; // Unix timestamp of the commit the tag points to +} + +export class FileManager { + private repoManager: RepoManager; + private repoRoot: string; + // Cache for directory existence checks (5 minute TTL) + private dirExistenceCache: Map = new Map(); + private readonly DIR_CACHE_TTL = 5 * 60 * 1000; // 5 minutes + // Lazy-loaded fs modules (cached after first import) + private fsPromises: typeof import('fs/promises') | null = null; + private fsSync: typeof import('fs') | null = null; + + constructor(repoRoot: string = '/repos') { + this.repoRoot = repoRoot; + this.repoManager = new RepoManager(repoRoot); + } + + /** + * Lazy load fs/promises module (cached after first load) + */ + private async getFsPromises(): Promise { + if (!this.fsPromises) { + this.fsPromises = await import('fs/promises'); + } + return this.fsPromises; + } + + /** + * Lazy load fs module (cached after first load) + */ + private async getFsSync(): Promise { + if (!this.fsSync) { + this.fsSync = await import('fs'); + } + return this.fsSync; + } + + /** + * Check if running in a container environment (async) + * Note: This is cached after first check since it won't change during runtime + */ + private containerEnvCache: boolean | null = null; + private async isContainerEnvironment(): Promise { + // Cache the result since it won't change during runtime + if (this.containerEnvCache !== null) { + return this.containerEnvCache; + } + + if (process.env.DOCKER_CONTAINER === 'true') { + this.containerEnvCache = true; + return true; + } + + // Check for /.dockerenv file (async) + this.containerEnvCache = await this.pathExists('/.dockerenv'); + return this.containerEnvCache; + } + + /** + * Check if a path exists (async, non-blocking) + * Uses fs.access() which is the recommended async way to check existence + */ + private async pathExists(path: string): Promise { + try { + const fs = await this.getFsPromises(); + await fs.access(path); + return true; + } catch { + return false; + } + } + + /** + * Sanitize error messages to prevent leaking sensitive path information + * Only shows relative paths, not absolute paths + */ + private sanitizePathForError(path: string): string { + // If path is within repoRoot, show relative path + const resolvedPath = resolve(path).replace(/\\/g, '/'); + const resolvedRoot = resolve(this.repoRoot).replace(/\\/g, '/'); + if (resolvedPath.startsWith(resolvedRoot + '/')) { + return resolvedPath.slice(resolvedRoot.length + 1); + } + // For paths outside repoRoot, only show last component for security + return path.split(/[/\\]/).pop() || path; + } + + /** + * Generate container-specific error message for permission issues + */ + private getContainerPermissionError(path: string, operation: string): string { + const sanitizedPath = this.sanitizePathForError(path); + return `Permission denied: ${operation} at ${sanitizedPath}. In Docker, check that the volume mount has correct permissions. The container runs as user 'gitrepublic' (UID 1001). Ensure the host directory is writable by this user or adjust ownership: chown -R 1001:1001 ./repos`; + } + + /** + * Ensure directory exists with proper error handling and security + * @param dirPath - Directory path to ensure exists + * @param description - Description for error messages + * @param checkParent - Whether to check parent directory permissions first + */ + private async ensureDirectoryExists( + dirPath: string, + description: string, + checkParent: boolean = false + ): Promise { + // Check cache first (with TTL) + const cacheKey = `dir:${dirPath}`; + const cached = this.dirExistenceCache.get(cacheKey); + const now = Date.now(); + if (cached && (now - cached.timestamp) < this.DIR_CACHE_TTL) { + if (cached.exists) { + return; // Directory exists, skip + } + } + + // Use async path existence check + const exists = await this.pathExists(dirPath); + if (exists) { + // Update cache + this.dirExistenceCache.set(cacheKey, { exists: true, timestamp: now }); + return; + } + + // Check parent directory if requested + if (checkParent) { + const parentDir = dirname(dirPath); + const parentExists = await this.pathExists(parentDir); + if (!parentExists) { + await this.ensureDirectoryExists(parentDir, `Parent of ${description}`, true); + } else { + // Verify parent is writable (async) + try { + const fs = await this.getFsPromises(); + await fs.access(parentDir, fs.constants.W_OK); + } catch (accessErr) { + const isContainer = await this.isContainerEnvironment(); + const errorMsg = isContainer + ? this.getContainerPermissionError(parentDir, `writing to parent directory of ${description}`) + : `Parent directory ${this.sanitizePathForError(parentDir)} is not writable`; + logger.error({ error: accessErr, parentDir, description }, errorMsg); + throw new Error(errorMsg); + } + } + } + + // Create directory + try { + const { mkdir } = await this.getFsPromises(); + await mkdir(dirPath, { recursive: true }); + logger.debug({ dirPath: this.sanitizePathForError(dirPath), description }, 'Created directory'); + // Update cache + this.dirExistenceCache.set(cacheKey, { exists: true, timestamp: now }); + } catch (mkdirErr) { + // Clear cache on error + this.dirExistenceCache.delete(cacheKey); + const isContainer = await this.isContainerEnvironment(); + const errorMsg = isContainer + ? this.getContainerPermissionError(dirPath, `creating ${description}`) + : `Failed to create ${description}: ${mkdirErr instanceof Error ? mkdirErr.message : String(mkdirErr)}`; + logger.error({ error: mkdirErr, dirPath: this.sanitizePathForError(dirPath), description }, errorMsg); + throw new Error(errorMsg); + } + } + + /** + * Verify directory is writable (for security checks) - async + */ + private async verifyDirectoryWritable(dirPath: string, description: string): Promise { + const exists = await this.pathExists(dirPath); + if (!exists) { + throw new Error(`${description} does not exist at ${this.sanitizePathForError(dirPath)}`); + } + + try { + const fs = await this.getFsPromises(); + await fs.access(dirPath, fs.constants.W_OK); + } catch (accessErr) { + const isContainer = await this.isContainerEnvironment(); + const errorMsg = isContainer + ? this.getContainerPermissionError(dirPath, `writing to ${description}`) + : `${description} at ${this.sanitizePathForError(dirPath)} is not writable`; + logger.error({ error: accessErr, dirPath: this.sanitizePathForError(dirPath), description }, errorMsg); + throw new Error(errorMsg); + } + } + + /** + * Clear directory existence cache (useful after operations that create directories) + */ + private clearDirCache(dirPath?: string): void { + if (dirPath) { + const cacheKey = `dir:${dirPath}`; + this.dirExistenceCache.delete(cacheKey); + } else { + // Clear all cache + this.dirExistenceCache.clear(); + } + } + + /** + * Sanitize error messages to prevent information leakage + * Uses sanitizeError from security utils and adds path sanitization + */ + private sanitizeErrorMessage(error: unknown, context?: { npub?: string; repoName?: string; filePath?: string }): string { + let message = sanitizeError(error); + + // Remove sensitive context if present + if (context?.npub) { + const { truncateNpub } = require('../../utils/security.js'); + message = message.replace(new RegExp(context.npub.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), truncateNpub(context.npub)); + } + + // Sanitize file paths + if (context?.filePath) { + const sanitizedPath = this.sanitizePathForError(context.filePath); + message = message.replace(new RegExp(context.filePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), sanitizedPath); + } + + return message; + } + + /** + * Create or get a git worktree for a repository + * More efficient than cloning the entire repo for each operation + * Security: Validates branch name to prevent path traversal attacks + */ + async getWorktree(repoPath: string, branch: string, npub: string, repoName: string): Promise { + // Security: Validate branch name to prevent path traversal + if (!isValidBranchName(branch)) { + throw new Error(`Invalid branch name: ${branch}`); + } + + const worktreeRoot = join(this.repoRoot, npub, `${repoName}.worktrees`); + // 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 = worktreePath.replace(/\\/g, '/'); + const resolvedRoot = resolvedWorktreeRoot.replace(/\\/g, '/'); + if (!resolvedPath.startsWith(resolvedRoot + '/')) { + throw new Error('Path traversal detected: worktree path outside allowed root'); + } + const { rm } = await this.getFsPromises(); + + // Ensure worktree root exists (use resolved path) with parent check + await this.ensureDirectoryExists(resolvedWorktreeRoot, 'worktree root directory', true); + + const git = simpleGit(repoPath); + + // 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 (await this.pathExists(worktreePath)) { + // Verify it's a valid worktree + try { + const worktreeGit = simpleGit(worktreePath); + await worktreeGit.status(); + return worktreePath; + } catch { + // Invalid worktree, remove it + const { rm } = await this.getFsPromises(); + await rm(worktreePath, { recursive: true, force: true }); + } + } + + // Create new worktree + try { + // First, check if the branch exists and has commits + let branchExists = false; + let branchHasCommits = false; + try { + // Check if branch exists + const branchList = await git.branch(['-a']); + const branchNames = branchList.all.map(b => b.replace(/^remotes\/origin\//, '').replace(/^remotes\//, '')); + branchExists = branchNames.includes(branch); + + if (branchExists) { + // Check if branch has commits by trying to get the latest commit + try { + const commitHash = await git.raw(['rev-parse', `refs/heads/${branch}`]); + branchHasCommits = !!(commitHash && commitHash.trim().length > 0); + } catch { + // Branch exists but has no commits (orphan branch) + branchHasCommits = false; + } + } + } catch (err) { + logger.debug({ error: err, branch }, 'Could not check branch status, will try to create worktree'); + } + + // If branch exists but has no commits, create an initial empty commit first + if (branchExists && !branchHasCommits) { + logger.debug({ branch }, 'Branch exists but has no commits, creating initial empty commit'); + try { + // Fetch repo announcement to use as commit message + 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 { nip19 } = await import('nostr-tools'); + 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({ branch, announcementId: announcement.id }, 'Using repo announcement as initial commit message'); + } + } catch (announcementErr) { + logger.debug({ error: announcementErr, branch }, 'Failed to fetch announcement, using default commit message'); + } + + // Create a temporary worktree with a temp branch name to make the initial commit + const tempBranchName = `.temp-init-${Date.now()}`; + const tempWorktreePath = resolve(join(this.repoRoot, npub, `${repoName}.worktrees`, tempBranchName)); + const { mkdir } = await import('fs/promises'); + await mkdir(dirname(tempWorktreePath), { recursive: true }); + + // Create orphan worktree with temp branch + await new Promise((resolve, reject) => { + const orphanProcess = spawn('git', ['worktree', 'add', '--orphan', tempBranchName, tempWorktreePath], { + cwd: repoPath, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let orphanStderr = ''; + orphanProcess.stderr.on('data', (chunk: Buffer) => { + orphanStderr += chunk.toString(); + }); + + orphanProcess.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Failed to create orphan worktree: ${orphanStderr}`)); + } + }); + + orphanProcess.on('error', reject); + }); + + // Create initial empty commit in temp branch with announcement as message + const tempGit = simpleGit(tempWorktreePath); + await tempGit.commit(commitMessage, ['--allow-empty'], { + '--author': 'GitRepublic ' + }); + + // Get the commit hash + const commitHash = await tempGit.revparse(['HEAD']); + + // Update the actual branch to point to this commit + await git.raw(['update-ref', `refs/heads/${branch}`, commitHash.trim()]); + + // Remove temporary worktree and temp branch + await this.removeWorktree(repoPath, tempWorktreePath); + await git.raw(['branch', '-D', tempBranchName]).catch(() => { + // Ignore if branch deletion fails + }); + + logger.debug({ branch, commitHash }, 'Created initial empty commit on orphan branch with announcement'); + } catch (err) { + logger.warn({ error: err, branch }, 'Failed to create initial commit, will try normal worktree creation'); + } + } + + // Use spawn for worktree add (safer than exec) + await new Promise((resolve, reject) => { + const gitProcess = spawn('git', ['worktree', 'add', worktreePath, branch], { + cwd: repoPath, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let stderr = ''; + gitProcess.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + gitProcess.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + // If branch doesn't exist, create it first using git branch (works on bare repos) + if (stderr.includes('fatal: invalid reference') || stderr.includes('fatal: not a valid object name') || stderr.includes('Ungültige Referenz')) { + // First, try to find a source branch (HEAD, main, or master) + const findSourceBranch = async (): Promise => { + try { + const branches = await git.branch(['-a']); + // Try HEAD first, then main, then master + if (branches.all.includes('HEAD') || branches.all.includes('origin/HEAD')) { + return 'HEAD'; + } + if (branches.all.includes('main') || branches.all.includes('origin/main')) { + return 'main'; + } + if (branches.all.includes('master') || branches.all.includes('origin/master')) { + return 'master'; + } + // Use the first available branch + const firstBranch = branches.all.find(b => !b.includes('HEAD')); + if (firstBranch) { + return firstBranch.replace(/^origin\//, ''); + } + throw new Error('No source branch found'); + } catch { + // Default to HEAD + return 'HEAD'; + } + }; + + findSourceBranch().then((sourceBranch) => { + // Create branch using git branch command (works on bare repos) + return new Promise((resolveBranch, rejectBranch) => { + const branchProcess = spawn('git', ['branch', branch, sourceBranch], { + cwd: repoPath, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let branchStderr = ''; + branchProcess.stderr.on('data', (chunk: Buffer) => { + branchStderr += chunk.toString(); + }); + + branchProcess.on('close', (branchCode) => { + if (branchCode === 0) { + resolveBranch(); + } else { + // If creating branch from source fails, try creating orphan branch + if (branchStderr.includes('fatal: invalid reference') || branchStderr.includes('Ungültige Referenz')) { + // Create orphan branch instead + const orphanProcess = spawn('git', ['branch', branch], { + cwd: repoPath, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let orphanStderr = ''; + orphanProcess.stderr.on('data', (chunk: Buffer) => { + orphanStderr += chunk.toString(); + }); + + orphanProcess.on('close', (orphanCode) => { + if (orphanCode === 0) { + resolveBranch(); + } else { + rejectBranch(new Error(`Failed to create orphan branch: ${orphanStderr}`)); + } + }); + + orphanProcess.on('error', rejectBranch); + } else { + rejectBranch(new Error(`Failed to create branch: ${branchStderr}`)); + } + } + }); + + branchProcess.on('error', rejectBranch); + }); + }).then(() => { + // Retry worktree add after creating the branch + return new Promise((resolve2, reject2) => { + const gitProcess2 = spawn('git', ['worktree', 'add', worktreePath, branch], { + cwd: repoPath, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let retryStderr = ''; + gitProcess2.stderr.on('data', (chunk: Buffer) => { + retryStderr += chunk.toString(); + }); + + gitProcess2.on('close', (code2) => { + if (code2 === 0) { + resolve2(); + } else { + // If still failing, try with --orphan + if (retryStderr.includes('fatal: invalid reference') || retryStderr.includes('Ungültige Referenz')) { + const orphanWorktreeProcess = spawn('git', ['worktree', 'add', '--orphan', branch, worktreePath], { + cwd: repoPath, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let orphanWorktreeStderr = ''; + orphanWorktreeProcess.stderr.on('data', (chunk: Buffer) => { + orphanWorktreeStderr += chunk.toString(); + }); + + orphanWorktreeProcess.on('close', (orphanWorktreeCode) => { + if (orphanWorktreeCode === 0) { + resolve2(); + } else { + reject2(new Error(`Failed to create orphan worktree: ${orphanWorktreeStderr}`)); + } + }); + + orphanWorktreeProcess.on('error', reject2); + } else { + reject2(new Error(`Failed to create worktree after creating branch: ${retryStderr}`)); + } + } + }); + + gitProcess2.on('error', reject2); + }); + }).then(resolve).catch(reject); + } else { + reject(new Error(`Failed to create worktree: ${stderr}`)); + } + } + }); + + gitProcess.on('error', reject); + }); + + // Verify the worktree directory was actually created (after the promise resolves) + if (!(await this.pathExists(worktreePath))) { + throw new Error(`Worktree directory was not created: ${this.sanitizePathForError(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, worktreePath }, 'Failed to create worktree'); + throw new Error(`Failed to create worktree: ${sanitizedError}`); + } + } + + /** + * Remove a worktree + */ + async removeWorktree(repoPath: string, worktreePath: string): Promise { + try { + // Use spawn for worktree remove (safer than exec) + await new Promise((resolve, reject) => { + const gitProcess = spawn('git', ['worktree', 'remove', worktreePath], { + cwd: repoPath, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + gitProcess.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + // If worktree remove fails, try force remove + const gitProcess2 = spawn('git', ['worktree', 'remove', '--force', worktreePath], { + cwd: repoPath, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + gitProcess2.on('close', (code2) => { + if (code2 === 0) { + resolve(); + } else { + // Last resort: just delete the directory + import('fs/promises').then(({ rm }) => { + return rm(worktreePath, { recursive: true, force: true }); + }).then(() => resolve()).catch(reject); + } + }); + + gitProcess2.on('error', reject); + } + }); + + gitProcess.on('error', reject); + }); + } catch (error) { + const sanitizedError = sanitizeError(error); + logger.warn({ error: sanitizedError, repoPath, worktreePath }, 'Failed to remove worktree cleanly'); + // Try to remove directory directly as fallback + try { + const { rm } = await import('fs/promises'); + await rm(worktreePath, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + } + } + + /** + * Get the full path to a repository + */ + getRepoPath(npub: string, repoName: string): string { + const repoPath = join(this.repoRoot, npub, `${repoName}.git`); + // Security: Ensure the resolved path is within repoRoot to prevent path traversal + // Normalize paths to handle Windows/Unix differences + const resolvedPath = resolve(repoPath).replace(/\\/g, '/'); + const resolvedRoot = resolve(this.repoRoot).replace(/\\/g, '/'); + // Must be a subdirectory of repoRoot, not equal to it + if (!resolvedPath.startsWith(resolvedRoot + '/')) { + throw new Error('Path traversal detected: repository path outside allowed root'); + } + return repoPath; + } + + /** + * Validate and sanitize file path to prevent path traversal attacks + */ + private validateFilePath(filePath: string): { valid: boolean; error?: string; normalized?: string } { + // Allow empty string for root directory + if (filePath === '') { + return { valid: true, normalized: '' }; + } + if (!filePath || typeof filePath !== 'string') { + return { valid: false, error: 'File path must be a non-empty string' }; + } + + // Normalize the path (resolves .. and .) + const normalized = normalize(filePath); + + // Check for path traversal attempts + if (normalized.includes('..')) { + return { valid: false, error: 'Path traversal detected (..)' }; + } + + // Check for absolute paths + if (normalized.startsWith('/')) { + return { valid: false, error: 'Absolute paths are not allowed' }; + } + + // Check for null bytes + if (normalized.includes('\0')) { + return { valid: false, error: 'Null bytes are not allowed in paths' }; + } + + // Check for control characters + if (/[\x00-\x1f\x7f]/.test(normalized)) { + return { valid: false, error: 'Control characters are not allowed in paths' }; + } + + // Limit path length (reasonable limit) + if (normalized.length > 4096) { + return { valid: false, error: 'Path is too long (max 4096 characters)' }; + } + + return { valid: true, normalized }; + } + + /** + * Validate repository name to prevent injection attacks + */ + private validateRepoName(repoName: string): { valid: boolean; error?: string } { + if (!repoName || typeof repoName !== 'string') { + return { valid: false, error: 'Repository name must be a non-empty string' }; + } + + // Check length + if (repoName.length > 100) { + return { valid: false, error: 'Repository name is too long (max 100 characters)' }; + } + + // Check for invalid characters (alphanumeric, hyphens, underscores, dots) + if (!/^[a-zA-Z0-9._-]+$/.test(repoName)) { + return { valid: false, error: 'Repository name contains invalid characters' }; + } + + // Check for path traversal + if (repoName.includes('..') || repoName.includes('/') || repoName.includes('\\')) { + return { valid: false, error: 'Repository name contains invalid path characters' }; + } + + return { valid: true }; + } + + /** + * Validate npub format + */ + private validateNpub(npub: string): { valid: boolean; error?: string } { + if (!npub || typeof npub !== 'string') { + return { valid: false, error: 'npub must be a non-empty string' }; + } + + // Basic npub format check (starts with npub, base58 encoded) + if (!npub.startsWith('npub1') || npub.length < 10 || npub.length > 100) { + return { valid: false, error: 'Invalid npub format' }; + } + + return { valid: true }; + } + + /** + * Check if repository exists (with caching) + */ + repoExists(npub: string, repoName: string): boolean { + // Validate inputs + const npubValidation = this.validateNpub(npub); + if (!npubValidation.valid) { + return false; + } + const repoValidation = this.validateRepoName(repoName); + if (!repoValidation.valid) { + return false; + } + + // Check cache first + const cacheKey = RepoCache.repoExistsKey(npub, repoName); + const cached = repoCache.get(cacheKey); + if (cached !== null) { + return cached; + } + + const repoPath = this.getRepoPath(npub, repoName); + const exists = this.repoManager.repoExists(repoPath); + + // Cache the result (cache for 1 minute) + repoCache.set(cacheKey, exists, 60 * 1000); + + return exists; + } + + /** + * List files and directories in a repository at a given path + * Uses caching to reduce redundant git operations + */ + async listFiles(npub: string, repoName: string, ref: string = 'HEAD', path: string = ''): Promise { + // Validate inputs + const npubValidation = this.validateNpub(npub); + if (!npubValidation.valid) { + throw new Error(`Invalid npub: ${npubValidation.error}`); + } + const repoValidation = this.validateRepoName(repoName); + if (!repoValidation.valid) { + throw new Error(`Invalid repository name: ${repoValidation.error}`); + } + + const pathValidation = this.validateFilePath(path); + if (!pathValidation.valid) { + throw new Error(`Invalid file path: ${pathValidation.error}`); + } + + const repoPath = this.getRepoPath(npub, repoName); + + if (!this.repoExists(npub, repoName)) { + throw new Error('Repository not found'); + } + + // Check cache first (cache for 2 minutes) + const cacheKey = RepoCache.fileListKey(npub, repoName, ref, path); + const cached = repoCache.get(cacheKey); + if (cached !== null) { + logger.debug({ npub, repoName, path, ref, cachedCount: cached.length }, '[FileManager] Returning cached file list'); + return cached; + } + + const git: SimpleGit = simpleGit(repoPath); + + try { + // Get the tree for the specified path + // For directories, git ls-tree needs a trailing slash to list contents + // For root, use '.' + // Note: git ls-tree returns paths relative to repo root, not relative to the specified path + const gitPath = path ? (path.endsWith('/') ? path : `${path}/`) : '.'; + logger.debug({ npub, repoName, path, ref, gitPath }, '[FileManager] Calling git ls-tree'); + + let tree: string; + try { + tree = await git.raw(['ls-tree', '-l', ref, gitPath]); + } catch (lsTreeError) { + // Handle empty branches (orphan branches with no commits) + // git ls-tree will fail with "fatal: not a valid object name" or similar + const errorMsg = lsTreeError instanceof Error ? lsTreeError.message : String(lsTreeError); + const errorStr = String(lsTreeError).toLowerCase(); + const errorMsgLower = errorMsg.toLowerCase(); + + // Check for various error patterns that indicate empty branch/no commits + const isEmptyBranchError = + errorMsgLower.includes('not a valid object') || + errorMsgLower.includes('not found') || + errorMsgLower.includes('bad revision') || + errorMsgLower.includes('ambiguous argument') || + errorStr.includes('not a valid object') || + errorStr.includes('not found') || + errorStr.includes('bad revision') || + errorStr.includes('ambiguous argument') || + errorMsgLower.includes('fatal:') && (errorMsgLower.includes('master') || errorMsgLower.includes('refs/heads')); + + if (isEmptyBranchError) { + logger.debug({ npub, repoName, path, ref, gitPath, error: errorMsg, errorStr }, '[FileManager] Branch has no commits, returning empty list'); + const emptyResult: FileEntry[] = []; + // Cache empty result for shorter time (30 seconds) + repoCache.set(cacheKey, emptyResult, 30 * 1000); + return emptyResult; + } + // Log the error for debugging + logger.error({ npub, repoName, path, ref, gitPath, error: lsTreeError, errorMsg, errorStr }, '[FileManager] Unexpected error from git ls-tree'); + // Re-throw if it's a different error + throw lsTreeError; + } + + if (!tree || !tree.trim()) { + const emptyResult: FileEntry[] = []; + // Cache empty result for shorter time (30 seconds) + repoCache.set(cacheKey, emptyResult, 30 * 1000); + logger.debug({ npub, repoName, path, ref, gitPath }, '[FileManager] git ls-tree returned empty result'); + return emptyResult; + } + + logger.debug({ npub, repoName, path, ref, gitPath, treeLength: tree.length, firstLines: tree.split('\n').slice(0, 5) }, '[FileManager] git ls-tree output'); + + const entries: FileEntry[] = []; + const lines = tree.trim().split('\n').filter(line => line.length > 0); + + // Normalize the path for comparison (ensure it ends with / for directory matching) + const normalizedPath = path ? (path.endsWith('/') ? path : `${path}/`) : ''; + + logger.debug({ path, normalizedPath, lineCount: lines.length }, '[FileManager] Starting to parse entries'); + + for (const line of lines) { + // Format: \t + // Note: git ls-tree uses a tab character between size and filename + // The format is: mode type object sizepath + // Important: git ls-tree returns paths relative to repo root, not relative to the specified path + // We need to handle both spaces and tabs + const tabIndex = line.lastIndexOf('\t'); + if (tabIndex === -1) { + // Try space-separated format as fallback + const match = line.match(/^(\d+)\s+(\w+)\s+(\w+)\s+(\d+|-)\s+(.+)$/); + if (match) { + const [, , type, , size, gitPath] = match; + // git ls-tree always returns paths relative to repo root + // If we're listing a subdirectory, the returned paths will start with that directory + let fullPath: string; + let displayName: string; + + if (normalizedPath) { + // We're listing a subdirectory + if (gitPath.startsWith(normalizedPath)) { + // Path already includes the directory prefix (normal case) + fullPath = gitPath; + // Extract just the filename/dirname (relative to the requested path) + const relativePath = gitPath.slice(normalizedPath.length); + // Remove any leading/trailing slashes + const cleanRelative = relativePath.replace(/^\/+|\/+$/g, ''); + // For display name, get the first component (the immediate child) + // This handles both files (image.png) and nested dirs (screenshots/image.png -> screenshots) + displayName = cleanRelative.split('/')[0] || cleanRelative; + } else { + // Path doesn't start with directory prefix - this shouldn't happen normally + // but handle it by joining + logger.debug({ path, normalizedPath, gitPath }, '[FileManager] Path does not start with normalized path, joining (space-separated)'); + fullPath = join(path, gitPath); + displayName = gitPath.split('/').pop() || gitPath; + } + } else { + // Root directory listing - paths are relative to root + fullPath = gitPath; + displayName = gitPath.split('/')[0]; // Get first component + } + + logger.debug({ gitPath, path, normalizedPath, fullPath, displayName, type }, '[FileManager] Parsed entry (space-separated)'); + + entries.push({ + name: displayName, + path: fullPath, + type: type === 'tree' ? 'directory' : 'file', + size: size !== '-' ? parseInt(size, 10) : undefined + }); + } else { + logger.debug({ line, path, ref }, '[FileManager] Line did not match expected format (space-separated)'); + } + } else { + // Tab-separated format (standard) + const beforeTab = line.substring(0, tabIndex); + const gitPath = line.substring(tabIndex + 1); + const match = beforeTab.match(/^(\d+)\s+(\w+)\s+(\w+)\s+(\d+|-)$/); + if (match) { + const [, , type, , size] = match; + // git ls-tree always returns paths relative to repo root + // If we're listing a subdirectory, the returned paths will start with that directory + let fullPath: string; + let displayName: string; + + if (normalizedPath) { + // We're listing a subdirectory + if (gitPath.startsWith(normalizedPath)) { + // Path already includes the directory prefix (normal case) + fullPath = gitPath; + // Extract just the filename/dirname (relative to the requested path) + const relativePath = gitPath.slice(normalizedPath.length); + // Remove any leading/trailing slashes + const cleanRelative = relativePath.replace(/^\/+|\/+$/g, ''); + // For display name, get the first component (the immediate child) + // This handles both files (image.png) and nested dirs (screenshots/image.png -> screenshots) + displayName = cleanRelative.split('/')[0] || cleanRelative; + } else { + // Path doesn't start with directory prefix - this shouldn't happen normally + // but handle it by joining + logger.debug({ path, normalizedPath, gitPath }, '[FileManager] Path does not start with normalized path, joining (tab-separated)'); + fullPath = join(path, gitPath); + displayName = gitPath.split('/').pop() || gitPath; + } + } else { + // Root directory listing - paths are relative to root + fullPath = gitPath; + displayName = gitPath.split('/')[0]; // Get first component + } + + logger.debug({ gitPath, path, normalizedPath, fullPath, displayName, type }, '[FileManager] Parsed entry (tab-separated)'); + + entries.push({ + name: displayName, + path: fullPath, + type: type === 'tree' ? 'directory' : 'file', + size: size !== '-' ? parseInt(size, 10) : undefined + }); + } else { + logger.debug({ line, path, ref, beforeTab }, '[FileManager] Line did not match expected format (tab-separated)'); + } + } + } + + // Debug logging to help diagnose missing files + logger.debug({ + npub, + repoName, + path, + ref, + entryCount: entries.length, + entries: entries.map(e => ({ name: e.name, path: e.path, type: e.type })) + }, '[FileManager] Parsed file entries'); + + const sortedEntries = entries.sort((a, b) => { + // Directories first, then files, both alphabetically + if (a.type !== b.type) { + return a.type === 'directory' ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + + // Cache the result (cache for 2 minutes) + repoCache.set(cacheKey, sortedEntries, 2 * 60 * 1000); + + return sortedEntries; + } catch (error) { + // Check if this is an empty branch error that wasn't caught earlier + const errorMsg = error instanceof Error ? error.message : String(error); + const errorStr = String(error).toLowerCase(); + const errorMsgLower = errorMsg.toLowerCase(); + + const isEmptyBranchError = + errorMsgLower.includes('not a valid object') || + errorMsgLower.includes('not found') || + errorMsgLower.includes('bad revision') || + errorMsgLower.includes('ambiguous argument') || + errorStr.includes('not a valid object') || + errorStr.includes('not found') || + errorStr.includes('bad revision') || + errorStr.includes('ambiguous argument') || + (errorMsgLower.includes('fatal:') && (errorMsgLower.includes('master') || errorMsgLower.includes('refs/heads'))); + + if (isEmptyBranchError) { + logger.debug({ npub, repoName, path, ref, error: errorMsg, errorStr }, '[FileManager] Branch has no commits (caught in outer catch), returning empty list'); + const emptyResult: FileEntry[] = []; + // Cache empty result for shorter time (30 seconds) + repoCache.set(cacheKey, emptyResult, 30 * 1000); + return emptyResult; + } + + logger.error({ error, repoPath, ref, errorMsg, errorStr }, 'Error listing files'); + throw new Error(`Failed to list files: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Get file content from a repository + */ + async getFileContent(npub: string, repoName: string, filePath: string, ref: string = 'HEAD'): Promise { + // Validate inputs + const npubValidation = this.validateNpub(npub); + if (!npubValidation.valid) { + throw new Error(`Invalid npub: ${npubValidation.error}`); + } + const repoValidation = this.validateRepoName(repoName); + if (!repoValidation.valid) { + throw new Error(`Invalid repository name: ${repoValidation.error}`); + } + + const pathValidation = this.validateFilePath(filePath); + if (!pathValidation.valid) { + throw new Error(`Invalid file path: ${pathValidation.error}`); + } + + const repoPath = this.getRepoPath(npub, repoName); + + if (!this.repoExists(npub, repoName)) { + throw new Error('Repository not found'); + } + + const git: SimpleGit = simpleGit(repoPath); + + try { + // Get file content using git show + // 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'; + const size = Buffer.byteLength(content, encoding); + + return { + content, + encoding, + size + }; + } catch (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: + * - 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) + */ + async writeFile( + npub: string, + repoName: string, + filePath: string, + content: string, + commitMessage: string, + authorName: string, + authorEmail: string, + branch: string = 'main', + signingOptions?: { + commitSignatureEvent?: NostrEvent; + useNIP07?: boolean; + nip98Event?: NostrEvent; + nsecKey?: string; + } + ): Promise { + // Validate inputs + const npubValidation = this.validateNpub(npub); + if (!npubValidation.valid) { + throw new Error(`Invalid npub: ${npubValidation.error}`); + } + const repoValidation = this.validateRepoName(repoName); + if (!repoValidation.valid) { + throw new Error(`Invalid repository name: ${repoValidation.error}`); + } + + const pathValidation = this.validateFilePath(filePath); + if (!pathValidation.valid) { + throw new Error(`Invalid file path: ${pathValidation.error}`); + } + + // Security: Validate branch name to prevent path traversal + if (!isValidBranchName(branch)) { + throw new Error(`Invalid branch name: ${branch}`); + } + + // Validate content size (prevent extremely large files) + const maxFileSize = 500 * 1024 * 1024; // 500 MB per file (allows for images and demo videos) + if (Buffer.byteLength(content, 'utf-8') > maxFileSize) { + throw new Error(`File is too large (max ${maxFileSize / 1024 / 1024} MB)`); + } + + // Validate commit message + if (!commitMessage || typeof commitMessage !== 'string' || commitMessage.trim().length === 0) { + throw new Error('Commit message is required'); + } + if (commitMessage.length > 1000) { + throw new Error('Commit message is too long (max 1000 characters)'); + } + + // Validate author info + if (!authorName || typeof authorName !== 'string' || authorName.trim().length === 0) { + throw new Error('Author name is required'); + } + if (!authorEmail || typeof authorEmail !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(authorEmail)) { + throw new Error('Valid author email is required'); + } + + const repoPath = this.getRepoPath(npub, repoName); + + if (!this.repoExists(npub, repoName)) { + throw new Error('Repository not found'); + } + + try { + // Check repository size before writing + const repoSizeCheck = await this.repoManager.checkRepoSizeLimit(repoPath); + if (!repoSizeCheck.withinLimit) { + throw new Error(repoSizeCheck.error || 'Repository size limit exceeded'); + } + + // Use git worktree instead of cloning (much more efficient) + const workDir = await this.getWorktree(repoPath, branch, npub, repoName); + const workGit: SimpleGit = simpleGit(workDir); + + // Write the file (use validated path) + const validatedPath = pathValidation.normalized || filePath; + const fullFilePath = join(workDir, validatedPath); + const fileDir = dirname(fullFilePath); + + // Additional security: ensure the resolved path is still within workDir + // Use trailing slash to ensure directory boundary (prevents sibling directory attacks) + const resolvedPath = resolve(fullFilePath).replace(/\\/g, '/'); + const resolvedWorkDir = resolve(workDir).replace(/\\/g, '/'); + if (!resolvedPath.startsWith(resolvedWorkDir + '/') && resolvedPath !== resolvedWorkDir) { + throw new Error('Path validation failed: resolved path outside work directory'); + } + + // Ensure directory exists + await this.ensureDirectoryExists(fileDir, 'directory for file', true); + + const { writeFile: writeFileFs } = await import('fs/promises'); + await writeFileFs(fullFilePath, content, 'utf-8'); + + // Stage the file (use validated path) + await workGit.add(validatedPath); + + // Sign commit if signing options are provided + let finalCommitMessage = commitMessage; + if (signingOptions && (signingOptions.commitSignatureEvent || signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) { + try { + const { signedMessage } = await createGitCommitSignature( + commitMessage, + authorName, + authorEmail, + signingOptions + ); + finalCommitMessage = signedMessage; + } catch (err) { + // Security: Sanitize error messages (never log private keys) + const sanitizedErr = sanitizeError(err); + logger.warn({ error: sanitizedErr, repoPath, filePath }, 'Failed to sign commit'); + // Continue without signature if signing fails + } + } + + // Commit + const commitResult = await workGit.commit(finalCommitMessage, [filePath], { + '--author': `${authorName} <${authorEmail}>` + }) as string | { commit: string }; + + // Get commit hash from result + let commitHash: string; + if (typeof commitResult === 'string') { + commitHash = commitResult.trim(); + } else if (commitResult && typeof commitResult === 'object' && 'commit' in commitResult) { + commitHash = String(commitResult.commit); + } else { + // Fallback: get latest commit hash + commitHash = await workGit.revparse(['HEAD']); + } + + // Save commit signature event to nostr folder if signing was used + if (signingOptions && (signingOptions.commitSignatureEvent || signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) { + try { + // Get the signature event that was used (already created above) + let signatureEvent: NostrEvent; + if (signingOptions.commitSignatureEvent) { + signatureEvent = signingOptions.commitSignatureEvent; + } else { + // Re-create it to get the event object + const { signedMessage: _, signatureEvent: event } = await createGitCommitSignature( + commitMessage, + authorName, + authorEmail, + signingOptions + ); + signatureEvent = event; + } + + // Save to nostr/commit-signatures.jsonl (use workDir since we have it) + await this.saveCommitSignatureEventToWorktree(workDir, signatureEvent); + + // Check if repo is private - only publish to relays if public + const isPrivate = await this.isRepoPrivate(npub, repoName); + if (!isPrivate) { + // Public repo: publish commit signature event to relays + try { + const { NostrClient } = await import('../nostr/nostr-client.js'); + const { DEFAULT_NOSTR_RELAYS } = await import('../../config.js'); + const { getUserRelays } = await import('../nostr/user-relays.js'); + const { combineRelays } = await import('../../config.js'); + + // Get user's preferred relays (outbox/inbox from kind 10002) + const { nip19 } = await import('nostr-tools'); + const { requireNpubHex } = await import('../../utils/npub-utils.js'); + const userPubkeyHex = requireNpubHex(npub); + + const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); + const { inbox, outbox } = await getUserRelays(userPubkeyHex, nostrClient); + + // Use user's outbox relays if available, otherwise inbox, otherwise defaults + const userRelays = outbox.length > 0 + ? combineRelays(outbox, DEFAULT_NOSTR_RELAYS) + : inbox.length > 0 + ? combineRelays(inbox, DEFAULT_NOSTR_RELAYS) + : DEFAULT_NOSTR_RELAYS; + + // Publish to relays (non-blocking - don't fail if publishing fails) + const publishResult = await nostrClient.publishEvent(signatureEvent, userRelays); + if (publishResult.success.length > 0) { + logger.debug({ + eventId: signatureEvent.id, + commitHash, + relays: publishResult.success + }, 'Published commit signature event to relays'); + } + if (publishResult.failed.length > 0) { + logger.warn({ + eventId: signatureEvent.id, + failed: publishResult.failed + }, 'Some relays failed to publish commit signature event'); + } + } catch (publishErr) { + // Log but don't fail - publishing is nice-to-have, saving to repo is the important part + const sanitizedErr = sanitizeError(publishErr); + logger.debug({ error: sanitizedErr, repoPath, filePath }, 'Failed to publish commit signature event to relays'); + } + } else { + // Private repo: only save to repo, don't publish to relays + logger.debug({ repoPath, filePath }, 'Private repo - commit signature event saved to repo only (not published to relays)'); + } + } catch (err) { + // Log but don't fail - saving event is nice-to-have + const sanitizedErr = sanitizeError(err); + logger.debug({ error: sanitizedErr, repoPath, filePath }, 'Failed to save commit signature event'); + } + } + + // 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 + await this.removeWorktree(repoPath, workDir); + } catch (error) { + logger.error({ error, repoPath, filePath, npub }, 'Error writing file'); + throw new Error(`Failed to write file: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Save commit signature event to nostr/commit-signatures.jsonl in a worktree + */ + private async saveCommitSignatureEventToWorktree(worktreePath: string, event: NostrEvent): Promise { + try { + const { mkdir, writeFile } = await import('fs/promises'); + const { join } = await import('path'); + + // Create nostr directory in worktree + const nostrDir = join(worktreePath, 'nostr'); + await mkdir(nostrDir, { recursive: true }); + + // Append to commit-signatures.jsonl + const jsonlFile = join(nostrDir, 'commit-signatures.jsonl'); + const eventLine = JSON.stringify(event) + '\n'; + await writeFile(jsonlFile, eventLine, { flag: 'a', encoding: 'utf-8' }); + } catch (err) { + logger.debug({ error: err, worktreePath }, 'Failed to save commit signature event to nostr folder'); + // Don't throw - this is a nice-to-have feature + } + } + + /** + * Save a repo event (announcement or transfer) to nostr/repo-events.jsonl + * This provides a standard location for all repo-related Nostr events for easy analysis + */ + async saveRepoEventToWorktree( + worktreePath: string, + event: NostrEvent, + eventType: 'announcement' | 'transfer', + skipIfExists: boolean = true + ): Promise { + try { + const { mkdir, writeFile, readFile } = await import('fs/promises'); + const { join } = await import('path'); + + // Create nostr directory in worktree + const nostrDir = join(worktreePath, 'nostr'); + await mkdir(nostrDir, { recursive: true }); + + // 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; + } + } + + /** + * Check if a repository is private by fetching its announcement event + */ + private async isRepoPrivate(npub: string, repoName: string): Promise { + try { + const { requireNpubHex } = await import('../../utils/npub-utils.js'); + const repoOwnerPubkey = requireNpubHex(npub); + + // Fetch the repository announcement + const { NostrClient } = await import('../nostr/nostr-client.js'); + const { DEFAULT_NOSTR_RELAYS } = await import('../../config.js'); + const { KIND } = await import('../../types/nostr.js'); + + const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); + const events = await nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [repoOwnerPubkey], + '#d': [repoName], + limit: 1 + } + ]); + + if (events.length === 0) { + // No announcement found - assume public (default) + return false; + } + + const announcement = events[0]; + + // Use shared utility to check if repo is private + const { isPrivateRepo: checkIsPrivateRepo } = await import('../../utils/repo-privacy.js'); + return checkIsPrivateRepo(announcement); + } catch (err) { + // If we can't determine, default to public (safer - allows publishing) + logger.debug({ error: err, npub, repoName }, 'Failed to check repo privacy, defaulting to public'); + return false; + } + } + + /** + * Get list of branches (with caching) + */ + /** + * Get the default branch name for a repository + * Tries to detect the actual default branch (master, main, etc.) + */ + async getDefaultBranch(npub: string, repoName: string): Promise { + const repoPath = this.getRepoPath(npub, repoName); + + if (!this.repoExists(npub, repoName)) { + throw new Error('Repository not found'); + } + + const git: SimpleGit = simpleGit(repoPath); + + try { + // Try to get the default branch from symbolic-ref + // For bare repos, this points to the default branch + const defaultRef = await git.raw(['symbolic-ref', 'HEAD']); + if (defaultRef) { + const match = defaultRef.trim().match(/^refs\/heads\/(.+)$/); + if (match) { + return match[1]; + } + } + } catch { + // If symbolic-ref fails, try to get from remote HEAD + try { + const remoteHead = await git.raw(['symbolic-ref', 'refs/remotes/origin/HEAD']); + if (remoteHead) { + const match = remoteHead.trim().match(/^refs\/remotes\/origin\/(.+)$/); + if (match) { + return match[1]; + } + } + } catch { + // Fall through to branch detection + } + } + + // Fallback: get branches and prefer 'main', then 'master', then first branch + try { + const branches = await git.branch(['-r']); + const branchList = branches.all + .map(b => b.replace(/^origin\//, '')) + .filter(b => !b.includes('HEAD')); + + if (branchList.length === 0) { + return 'main'; // Ultimate fallback + } + + // Prefer 'main', then 'master', then first branch + if (branchList.includes('main')) return 'main'; + if (branchList.includes('master')) return 'master'; + return branchList[0]; + } catch { + return 'main'; // Ultimate fallback + } + } + + async getBranches(npub: string, repoName: string): Promise { + const repoPath = this.getRepoPath(npub, repoName); + + if (!this.repoExists(npub, repoName)) { + throw new Error('Repository not found'); + } + + // Check cache first + const cacheKey = RepoCache.branchesKey(npub, repoName); + const cached = repoCache.get(cacheKey); + if (cached !== null) { + return cached; + } + + const git: SimpleGit = simpleGit(repoPath); + + try { + // 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')) + .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 + } + } + + // Sort branches: default branch first, then alphabetically + let branchList = Array.from(allBranches); + try { + const defaultBranch = await this.getDefaultBranch(npub, repoName); + if (defaultBranch) { + branchList.sort((a, b) => { + if (a === defaultBranch) return -1; + if (b === defaultBranch) return 1; + return a.localeCompare(b); + }); + } else { + // No default branch found, just sort alphabetically + branchList.sort(); + } + } catch { + // If we can't get default branch, just sort alphabetically + branchList.sort(); + } + + // Cache the result (cache for 2 minutes) + repoCache.set(cacheKey, branchList, 2 * 60 * 1000); + + return branchList; + } catch (error) { + logger.error({ error, repoPath }, 'Error getting branches'); + const defaultBranches = ['main', 'master']; + // Cache default branches for shorter time (30 seconds) + repoCache.set(cacheKey, defaultBranches, 30 * 1000); + return defaultBranches; + } + } + + /** + * Create a new file + * @param signingOptions - Optional commit signing options (see writeFile) + */ + async createFile( + npub: string, + repoName: string, + filePath: string, + content: string, + commitMessage: string, + authorName: string, + authorEmail: string, + branch: string = 'main', + signingOptions?: { + useNIP07?: boolean; + nip98Event?: NostrEvent; + nsecKey?: string; + } + ): Promise { + // Reuse writeFile logic - it will create the file if it doesn't exist + return this.writeFile(npub, repoName, filePath, content, commitMessage, authorName, authorEmail, branch, signingOptions); + } + + /** + * Delete a file + * @param signingOptions - Optional commit signing options (see writeFile) + */ + async deleteFile( + npub: string, + repoName: string, + filePath: string, + commitMessage: string, + authorName: string, + authorEmail: string, + branch: string = 'main', + signingOptions?: { + commitSignatureEvent?: NostrEvent; + useNIP07?: boolean; + nip98Event?: NostrEvent; + nsecKey?: string; + } + ): Promise { + // Validate inputs + const npubValidation = this.validateNpub(npub); + if (!npubValidation.valid) { + throw new Error(`Invalid npub: ${npubValidation.error}`); + } + const repoValidation = this.validateRepoName(repoName); + if (!repoValidation.valid) { + throw new Error(`Invalid repository name: ${repoValidation.error}`); + } + + const pathValidation = this.validateFilePath(filePath); + if (!pathValidation.valid) { + throw new Error(`Invalid file path: ${pathValidation.error}`); + } + + // Security: Validate branch name to prevent path traversal + if (!isValidBranchName(branch)) { + throw new Error(`Invalid branch name: ${branch}`); + } + + // Validate commit message + if (!commitMessage || typeof commitMessage !== 'string' || commitMessage.trim().length === 0) { + throw new Error('Commit message is required'); + } + + // Validate author info + if (!authorName || typeof authorName !== 'string' || authorName.trim().length === 0) { + throw new Error('Author name is required'); + } + if (!authorEmail || typeof authorEmail !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(authorEmail)) { + throw new Error('Valid author email is required'); + } + + const repoPath = this.getRepoPath(npub, repoName); + + if (!this.repoExists(npub, repoName)) { + throw new Error('Repository not found'); + } + + try { + // Use git worktree instead of cloning (much more efficient) + const workDir = await this.getWorktree(repoPath, branch, npub, repoName); + const workGit: SimpleGit = simpleGit(workDir); + + // Remove the file (use validated path) + const validatedPath = pathValidation.normalized || filePath; + const fullFilePath = join(workDir, validatedPath); + + // Additional security: ensure the resolved path is still within workDir + // Use trailing slash to ensure directory boundary (prevents sibling directory attacks) + const resolvedPath = resolve(fullFilePath).replace(/\\/g, '/'); + const resolvedWorkDir = resolve(workDir).replace(/\\/g, '/'); + if (!resolvedPath.startsWith(resolvedWorkDir + '/') && resolvedPath !== resolvedWorkDir) { + throw new Error('Path validation failed: resolved path outside work directory'); + } + + if (await this.pathExists(fullFilePath)) { + const { unlink } = await this.getFsPromises(); + await unlink(fullFilePath); + } + + // Stage the deletion (use validated path) + await workGit.rm([validatedPath]); + + // Sign commit if signing options are provided + let finalCommitMessage = commitMessage; + if (signingOptions && (signingOptions.commitSignatureEvent || signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) { + try { + const { signedMessage } = await createGitCommitSignature( + commitMessage, + authorName, + authorEmail, + signingOptions + ); + finalCommitMessage = signedMessage; + } catch (err) { + // Security: Sanitize error messages (never log private keys) + const sanitizedErr = sanitizeError(err); + logger.warn({ error: sanitizedErr, repoPath, filePath }, 'Failed to sign commit'); + // Continue without signature if signing fails + } + } + + // Commit + await workGit.commit(finalCommitMessage, [filePath], { + '--author': `${authorName} <${authorEmail}>` + }); + + // 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); + } catch (error) { + logger.error({ error, repoPath, filePath, npub }, 'Error deleting file'); + throw new Error(`Failed to delete file: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Create a new branch + */ + async createBranch( + npub: string, + repoName: string, + branchName: string, + fromBranch?: string, + announcement?: NostrEvent + ): Promise { + // Security: Validate branch names 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'); + } + + try { + const git: SimpleGit = simpleGit(repoPath); + + // Check if repo has any branches + let hasBranches = false; + try { + const branches = await git.branch(['-a']); + const branchList = branches.all + .map(b => b.replace(/^remotes\/origin\//, '').replace(/^remotes\//, '')) + .filter(b => !b.includes('HEAD') && !b.startsWith('*')); + hasBranches = branchList.length > 0; + } catch { + // If branch listing fails, assume no branches exist + hasBranches = false; + } + + // If no branches exist, create an orphan branch (branch with no parent) + if (!hasBranches) { + // Use provided announcement or fetch repo announcement to use as initial commit message and save to file + let commitMessage = 'Initial commit'; + 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'); + } + } + + 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) + const worktreeRoot = join(this.repoRoot, npub, `${repoName}.worktrees`); + const worktreePath = resolve(join(worktreeRoot, branchName)); + const { rm } = await this.getFsPromises(); + + // Ensure repoRoot is writable if it exists + if (await this.pathExists(this.repoRoot)) { + await this.verifyDirectoryWritable(this.repoRoot, 'GIT_REPO_ROOT directory'); + } + + // Ensure parent directory exists (npub directory) + const parentDir = join(this.repoRoot, npub); + await this.ensureDirectoryExists(parentDir, 'parent directory for worktree', true); + + // Create worktree root directory + await this.ensureDirectoryExists(worktreeRoot, 'worktree root directory', false); + + // Remove existing worktree if it exists + if (await this.pathExists(worktreePath)) { + try { + await git.raw(['worktree', 'remove', worktreePath, '--force']); + } catch { + const { rm } = await this.getFsPromises(); + await rm(worktreePath, { recursive: true, force: true }); + } + } + + // Create worktree with orphan branch + // Note: --orphan must come before branch name, path comes last + await git.raw(['worktree', 'add', '--orphan', branchName, worktreePath]); + + // 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); + 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, announcementSaved }, 'Created orphan branch with initial commit'); + + // Clean up worktree + await this.removeWorktree(repoPath, worktreePath); + } else { + // Repo has branches - use normal branch creation + // Validate fromBranch if provided + if (fromBranch && !isValidBranchName(fromBranch)) { + throw new Error(`Invalid source branch name: ${fromBranch}`); + } + + // Use default branch if fromBranch not provided + const sourceBranch = fromBranch || await this.getDefaultBranch(npub, repoName).catch(() => 'main'); + + // Use git worktree instead of cloning (much more efficient) + const workDir = await this.getWorktree(repoPath, sourceBranch, npub, repoName); + const workGit: SimpleGit = simpleGit(workDir); + + // Create and checkout new branch + await workGit.checkout(['-b', 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); + } + } catch (error) { + logger.error({ error, repoPath, branchName, npub }, 'Error creating branch'); + throw new Error(`Failed to create branch: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * 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 + */ + async getCommitHistory( + npub: string, + repoName: string, + branch: string = 'main', + limit: number = 50, + path?: string + ): Promise { + const repoPath = this.getRepoPath(npub, repoName); + + if (!this.repoExists(npub, repoName)) { + throw new Error('Repository not found'); + } + + const git: SimpleGit = simpleGit(repoPath); + + try { + const logOptions: { + maxCount: number; + from: string; + file?: string; + } = { + maxCount: limit, + from: branch + }; + + if (path) { + logOptions.file = path; + } + + const log = await git.log(logOptions); + + return log.all.map(commit => ({ + hash: commit.hash, + message: commit.message, + author: `${commit.author_name} <${commit.author_email}>`, + date: commit.date, + files: commit.diff?.files?.map((f: { file: string }) => f.file) || [] + })); + } catch (error) { + logger.error({ error, repoPath, branch, limit }, 'Error getting commit history'); + throw new Error(`Failed to get commit history: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Get diff between two commits or for a file + */ + async getDiff( + npub: string, + repoName: string, + fromRef: string, + toRef: string = 'HEAD', + filePath?: string + ): Promise { + const repoPath = this.getRepoPath(npub, repoName); + + if (!this.repoExists(npub, repoName)) { + throw new Error('Repository not found'); + } + + const git: SimpleGit = simpleGit(repoPath); + + try { + const diffOptions: string[] = [fromRef, toRef]; + if (filePath) { + diffOptions.push('--', filePath); + } + + const diff = await git.diff(diffOptions); + const stats = await git.diffSummary(diffOptions); + + // Parse diff output + const files: Diff[] = []; + const diffLines = diff.split('\n'); + let currentFile = ''; + let currentDiff = ''; + let inFileHeader = false; + + for (const line of diffLines) { + if (line.startsWith('diff --git')) { + if (currentFile) { + files.push({ + file: currentFile, + additions: 0, + deletions: 0, + diff: currentDiff + }); + } + const match = line.match(/diff --git a\/(.+?) b\/(.+?)$/); + if (match) { + currentFile = match[2]; + currentDiff = line + '\n'; + inFileHeader = true; + } + } else { + currentDiff += line + '\n'; + if (line.startsWith('@@')) { + inFileHeader = false; + } + if (!inFileHeader && (line.startsWith('+') || line.startsWith('-'))) { + // Count additions/deletions + } + } + } + + if (currentFile) { + files.push({ + file: currentFile, + additions: 0, + deletions: 0, + diff: currentDiff + }); + } + + // Add stats from diffSummary + if (stats.files && files.length > 0) { + for (const statFile of stats.files) { + const file = files.find(f => f.file === statFile.file); + if (file && 'insertions' in statFile && 'deletions' in statFile) { + file.additions = statFile.insertions; + file.deletions = statFile.deletions; + } + } + } + + return files; + } catch (error) { + const sanitizedError = sanitizeError(error); + logger.error({ error: sanitizedError, repoPath: this.sanitizePathForError(repoPath), fromRef, toRef }, 'Error getting diff'); + throw new Error(`Failed to get diff: ${sanitizedError}`); + } + } + + /** + * Create a tag + */ + async createTag( + npub: string, + repoName: string, + tagName: string, + ref: string = 'HEAD', + message?: string, + authorName?: string, + authorEmail?: string + ): Promise { + const repoPath = this.getRepoPath(npub, repoName); + + if (!this.repoExists(npub, repoName)) { + throw new Error('Repository not found'); + } + + const git: SimpleGit = simpleGit(repoPath); + + try { + // Check if repository has any commits + let hasCommits = false; + try { + // Try to get HEAD commit + const headCommit = await git.raw(['rev-parse', 'HEAD']).catch(() => null); + hasCommits = !!(headCommit && headCommit.trim().length > 0); + } catch { + // Check if any branch has commits + try { + const branches = await git.branch(['-a']); + for (const branch of branches.all) { + const branchName = branch.replace(/^remotes\/origin\//, '').replace(/^remotes\//, ''); + if (branchName.includes('HEAD')) continue; + try { + const commitHash = await git.raw(['rev-parse', `refs/heads/${branchName}`]).catch(() => null); + if (commitHash && commitHash.trim().length > 0) { + hasCommits = true; + // If ref is HEAD and we found a branch with commits, use that branch + if (ref === 'HEAD') { + ref = branchName; + } + break; + } + } catch { + // Continue checking other branches + } + } + } catch { + // Could not check branches + } + } + + if (!hasCommits) { + throw new Error('Cannot create tag: repository has no commits. Please create at least one commit first.'); + } + + // Validate that the ref exists + try { + await git.raw(['rev-parse', '--verify', ref]); + } catch (refErr) { + throw new Error(`Invalid reference '${ref}': ${refErr instanceof Error ? refErr.message : String(refErr)}`); + } + + if (message) { + // Create annotated tag + // Note: simple-git addTag doesn't support message directly, use raw command + if (ref !== 'HEAD') { + await git.raw(['tag', '-a', tagName, '-m', message, ref]); + } else { + await git.raw(['tag', '-a', tagName, '-m', message]); + } + } else { + // Create lightweight tag + if (ref !== 'HEAD') { + await git.raw(['tag', tagName, ref]); + } else { + await git.addTag(tagName); + } + } + } catch (error) { + logger.error({ error, repoPath, tagName, ref, message }, 'Error creating tag'); + throw new Error(`Failed to create tag: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Get list of tags + */ + async getTags(npub: string, repoName: string): Promise { + const repoPath = this.getRepoPath(npub, repoName); + + if (!this.repoExists(npub, repoName)) { + throw new Error('Repository not found'); + } + + const git: SimpleGit = simpleGit(repoPath); + + try { + const tags = await git.tags(); + const tagList: Tag[] = []; + + for (const tagName of tags.all) { + try { + // Get the commit hash the tag points to + const hash = await git.raw(['rev-parse', tagName]); + const commitHash = hash.trim(); + + // Get the commit date (Unix timestamp) + let commitDate: number | undefined; + try { + const dateStr = await git.raw(['log', '-1', '--format=%at', commitHash]); + commitDate = parseInt(dateStr.trim(), 10); + if (isNaN(commitDate)) { + commitDate = undefined; + } + } catch { + // If we can't get the date, continue without it + commitDate = undefined; + } + + // Try to get tag message (for annotated tags) + try { + const tagInfo = await git.raw(['cat-file', '-p', tagName]); + const messageMatch = tagInfo.match(/^(.+)$/m); + + tagList.push({ + name: tagName, + hash: commitHash, + message: messageMatch ? messageMatch[1] : undefined, + date: commitDate + }); + } catch { + // Lightweight tag - no message + tagList.push({ + name: tagName, + hash: commitHash, + date: commitDate + }); + } + } catch (err) { + // If we can't process this tag, skip it + logger.warn({ error: err, tagName }, 'Error processing tag, skipping'); + } + } + + return tagList; + } catch (error) { + logger.error({ error, repoPath }, 'Error getting tags'); + return []; + } + } + + /** + * Get the current owner from the most recent announcement file in the repository + * Ownership is determined by the most recent announcement file checked into the git repo + * + * @param npub - Repository owner npub (for path construction) + * @param repoName - The repository name + * @returns The current owner pubkey from the most recent announcement file, or null if not found + */ + async getCurrentOwnerFromRepo(npub: string, repoName: string): Promise { + try { + if (!this.repoExists(npub, repoName)) { + return null; + } + + const repoPath = this.getRepoPath(npub, repoName); + const git: SimpleGit = simpleGit(repoPath); + + // Get git log for nostr/repo-events.jsonl, most recent first + // Use --all to check all branches, --reverse to get chronological order + const logOutput = await git.raw(['log', '--all', '--format=%H', '--reverse', '--', 'nostr/repo-events.jsonl']); + const commitHashes = logOutput.trim().split('\n').filter(Boolean); + + if (commitHashes.length === 0) { + return null; // No announcement in repo + } + + // Get the most recent repo-events.jsonl content (last commit in the list) + const mostRecentCommit = commitHashes[commitHashes.length - 1]; + const repoEventsFile = await this.getFileContent(npub, repoName, 'nostr/repo-events.jsonl', mostRecentCommit); + + // Parse the repo-events.jsonl file and find the most recent announcement + let announcementEvent: any = null; + let latestTimestamp = 0; + try { + const lines = repoEventsFile.content.trim().split('\n').filter(Boolean); + for (const line of lines) { + try { + const entry = JSON.parse(line); + if (entry.type === 'announcement' && entry.event && entry.timestamp) { + if (entry.timestamp > latestTimestamp) { + latestTimestamp = entry.timestamp; + announcementEvent = entry.event; + } + } + } catch { + // Skip invalid lines + continue; + } + } + } catch (parseError) { + logger.warn({ error: parseError, npub, repoName, commit: mostRecentCommit }, 'Failed to parse repo-events.jsonl'); + return null; + } + + if (!announcementEvent) { + return null; + } + + // Validate the announcement event to prevent fake announcements + const { validateAnnouncementEvent } = await import('../nostr/repo-verification.js'); + const validation = validateAnnouncementEvent(announcementEvent, repoName); + + if (!validation.valid) { + logger.warn({ + error: validation.error, + npub, + repoName, + commit: mostRecentCommit, + eventId: announcementEvent.id, + eventPubkey: announcementEvent.pubkey?.substring(0, 16) + '...' + }, 'Announcement file validation failed - possible fake announcement'); + return null; + } + + // Return the pubkey from the validated announcement + return announcementEvent.pubkey; + } catch (error) { + logger.error({ error, npub, repoName }, 'Error getting current owner from repo'); + return null; + } + } +} diff --git a/src/lib/services/git/file-manager/branch-operations.ts b/src/lib/services/git/file-manager/branch-operations.ts new file mode 100644 index 0000000..42c9bad --- /dev/null +++ b/src/lib/services/git/file-manager/branch-operations.ts @@ -0,0 +1,126 @@ +/** + * Branch operations module + * Handles branch creation, deletion, and listing + */ + +import simpleGit, { type SimpleGit } from 'simple-git'; +import logger from '../../logger.js'; +import { sanitizeError } from '../../../utils/security.js'; +import { isValidBranchName } from '../../../utils/security.js'; +import { validateRepoName, validateNpub } from './path-validator.js'; +import { repoCache, RepoCache } from '../repo-cache.js'; + +export interface BranchListOptions { + npub: string; + repoName: string; + repoPath: string; + getDefaultBranch: (npub: string, repoName: string) => Promise; +} + +/** + * Get list of branches in a repository + */ +export async function getBranches(options: BranchListOptions): Promise { + const { npub, repoName, repoPath, getDefaultBranch } = options; + + // Validate inputs + const npubValidation = validateNpub(npub); + if (!npubValidation.valid) { + throw new Error(`Invalid npub: ${npubValidation.error}`); + } + const repoValidation = validateRepoName(repoName); + if (!repoValidation.valid) { + throw new Error(`Invalid repository name: ${repoValidation.error}`); + } + + // Check cache first (cache for 2 minutes) + const cacheKey = RepoCache.branchListKey(npub, repoName); + const cached = repoCache.get(cacheKey); + if (cached !== null) { + logger.debug({ npub, repoName, cachedCount: cached.length }, 'Returning cached branch list'); + return cached; + } + + const git: SimpleGit = simpleGit(repoPath); + + try { + logger.operation('Listing branches', { npub, repoName }); + + const allBranches = new Set(); + + // Get local branches + try { + const localBranches = await git.branchLocal(); + localBranches.all + .filter(b => !b.startsWith('remotes/') && !b.includes('HEAD')) + .forEach(b => allBranches.add(b)); + } catch { + // Ignore if local branches fail + } + + // Get remote branches + try { + const remoteBranches = await git.branch(['-r']); + remoteBranches.all + .map(b => b.replace(/^origin\//, '')) + .filter(b => !b.includes('HEAD')) + .forEach(b => allBranches.add(b)); + } catch { + // Ignore if remote branches fail + } + + // 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 + } + } + + // Sort branches: default branch first, then alphabetically + let branchList = Array.from(allBranches); + try { + const defaultBranch = await getDefaultBranch(npub, repoName); + if (defaultBranch) { + branchList.sort((a, b) => { + if (a === defaultBranch) return -1; + if (b === defaultBranch) return 1; + return a.localeCompare(b); + }); + } else { + branchList.sort(); + } + } catch { + branchList.sort(); + } + + // Cache the result (cache for 2 minutes) + repoCache.set(cacheKey, branchList, 2 * 60 * 1000); + + logger.operation('Branches listed', { npub, repoName, count: branchList.length }); + return branchList; + } catch (error) { + logger.error({ error, repoPath }, 'Error getting branches'); + const defaultBranches = ['main', 'master']; + repoCache.set(cacheKey, defaultBranches, 30 * 1000); + return defaultBranches; + } +} + +/** + * Validate branch name + */ +export function validateBranchName(branch: string): { valid: boolean; error?: string } { + if (!isValidBranchName(branch)) { + return { valid: false, error: `Invalid branch name: ${branch}` }; + } + return { valid: true }; +} diff --git a/src/lib/services/git/file-manager/commit-operations.ts b/src/lib/services/git/file-manager/commit-operations.ts new file mode 100644 index 0000000..b2f5099 --- /dev/null +++ b/src/lib/services/git/file-manager/commit-operations.ts @@ -0,0 +1,171 @@ +/** + * Commit operations module + * Handles commit history and diff operations + */ + +import simpleGit, { type SimpleGit } from 'simple-git'; +import logger from '../../logger.js'; +import { sanitizeError } from '../../../utils/security.js'; +import { validateRepoName, validateNpub } from './path-validator.js'; +import type { Commit, Diff } from '../file-manager.js'; + +export interface CommitHistoryOptions { + npub: string; + repoName: string; + branch?: string; + limit?: number; + path?: string; + repoPath: string; +} + +export interface DiffOptions { + npub: string; + repoName: string; + fromRef: string; + toRef?: string; + filePath?: string; + repoPath: string; +} + +/** + * Get commit history + */ +export async function getCommitHistory(options: CommitHistoryOptions): Promise { + const { npub, repoName, branch = 'main', limit = 50, path, repoPath } = options; + + // Validate inputs + const npubValidation = validateNpub(npub); + if (!npubValidation.valid) { + throw new Error(`Invalid npub: ${npubValidation.error}`); + } + const repoValidation = validateRepoName(repoName); + if (!repoValidation.valid) { + throw new Error(`Invalid repository name: ${repoValidation.error}`); + } + + const git: SimpleGit = simpleGit(repoPath); + + try { + logger.operation('Getting commit history', { npub, repoName, branch, limit, path }); + + const logOptions: { + maxCount: number; + from: string; + file?: string; + } = { + maxCount: limit, + from: branch + }; + + if (path) { + logOptions.file = path; + } + + const log = await git.log(logOptions); + + const commits = log.all.map(commit => ({ + hash: commit.hash, + message: commit.message, + author: `${commit.author_name} <${commit.author_email}>`, + date: commit.date, + files: commit.diff?.files?.map((f: { file: string }) => f.file) || [] + })); + + logger.operation('Commit history retrieved', { npub, repoName, count: commits.length }); + return commits; + } catch (error) { + logger.error({ error, repoPath, branch, limit }, 'Error getting commit history'); + throw new Error(`Failed to get commit history: ${error instanceof Error ? error.message : String(error)}`); + } +} + +/** + * Get diff between two commits or for a file + */ +export async function getDiff(options: DiffOptions): Promise { + const { npub, repoName, fromRef, toRef = 'HEAD', filePath, repoPath } = options; + + // Validate inputs + const npubValidation = validateNpub(npub); + if (!npubValidation.valid) { + throw new Error(`Invalid npub: ${npubValidation.error}`); + } + const repoValidation = validateRepoName(repoName); + if (!repoValidation.valid) { + throw new Error(`Invalid repository name: ${repoValidation.error}`); + } + + const git: SimpleGit = simpleGit(repoPath); + + try { + logger.operation('Getting diff', { npub, repoName, fromRef, toRef, filePath }); + + const diffOptions: string[] = [fromRef, toRef]; + if (filePath) { + diffOptions.push('--', filePath); + } + + const [diff, stats] = await Promise.all([ + git.diff(diffOptions), + git.diffSummary(diffOptions) + ]); + + // Parse diff output + const files: Diff[] = []; + const diffLines = diff.split('\n'); + let currentFile = ''; + let currentDiff = ''; + let inFileHeader = false; + + for (const line of diffLines) { + if (line.startsWith('diff --git')) { + if (currentFile) { + files.push({ + file: currentFile, + additions: 0, + deletions: 0, + diff: currentDiff + }); + } + const match = line.match(/diff --git a\/(.+?) b\/(.+?)$/); + if (match) { + currentFile = match[2]; + currentDiff = line + '\n'; + inFileHeader = true; + } + } else { + currentDiff += line + '\n'; + if (line.startsWith('@@')) { + inFileHeader = false; + } + } + } + + if (currentFile) { + files.push({ + file: currentFile, + additions: 0, + deletions: 0, + diff: currentDiff + }); + } + + // Add stats from diffSummary + if (stats.files && files.length > 0) { + for (const statFile of stats.files) { + const file = files.find(f => f.file === statFile.file); + if (file && 'insertions' in statFile && 'deletions' in statFile) { + file.additions = statFile.insertions; + file.deletions = statFile.deletions; + } + } + } + + logger.operation('Diff retrieved', { npub, repoName, fileCount: files.length }); + return files; + } catch (error) { + const sanitizedError = sanitizeError(error); + logger.error({ error: sanitizedError, repoPath, fromRef, toRef }, 'Error getting diff'); + throw new Error(`Failed to get diff: ${sanitizedError}`); + } +} diff --git a/src/lib/services/git/file-manager/file-manager-refactored.ts b/src/lib/services/git/file-manager/file-manager-refactored.ts new file mode 100644 index 0000000..a4667e7 --- /dev/null +++ b/src/lib/services/git/file-manager/file-manager-refactored.ts @@ -0,0 +1,586 @@ +/** + * File Manager - Refactored to use modular components + * Main class that delegates to focused modules + */ + +import { join, resolve } from 'path'; +import simpleGit, { type SimpleGit } from 'simple-git'; +import { RepoManager } from '../repo-manager.js'; +import logger from '../../logger.js'; +import { sanitizeError, isValidBranchName } from '../../../utils/security.js'; +import { repoCache, RepoCache } from '../repo-cache.js'; + +// Import modular operations +import { getOrCreateWorktree, removeWorktree } from './worktree-manager.js'; +import { validateFilePath, validateRepoName, validateNpub } from './path-validator.js'; +import { listFiles, getFileContent } from './file-operations.js'; +import { getBranches, validateBranchName } from './branch-operations.js'; +import { writeFile, deleteFile } from './write-operations.js'; +import { getCommitHistory, getDiff } from './commit-operations.js'; +import { createTag, getTags } from './tag-operations.js'; + +// Types are defined below + +export interface FileEntry { + name: string; + path: string; + type: 'file' | 'directory'; + size?: number; +} + +export interface FileContent { + content: string; + encoding: string; + size: number; +} + +export interface Commit { + hash: string; + message: string; + author: string; + date: string; + files: string[]; +} + +export interface Diff { + file: string; + additions: number; + deletions: number; + diff: string; +} + +export interface Tag { + name: string; + hash: string; + message?: string; + date?: number; +} + +export class FileManager { + private repoManager: RepoManager; + private repoRoot: string; + private dirExistenceCache: Map = new Map(); + private readonly DIR_CACHE_TTL = 5 * 60 * 1000; + private fsPromises: typeof import('fs/promises') | null = null; + + constructor(repoRoot: string = '/repos') { + this.repoRoot = repoRoot; + this.repoManager = new RepoManager(repoRoot); + } + + private async getFsPromises(): Promise { + if (!this.fsPromises) { + this.fsPromises = await import('fs/promises'); + } + return this.fsPromises; + } + + private async pathExists(path: string): Promise { + try { + const fs = await this.getFsPromises(); + await fs.access(path); + return true; + } catch { + return false; + } + } + + private sanitizePathForError(path: string): string { + const resolvedPath = resolve(path).replace(/\\/g, '/'); + const resolvedRoot = resolve(this.repoRoot).replace(/\\/g, '/'); + if (resolvedPath.startsWith(resolvedRoot + '/')) { + return resolvedPath.slice(resolvedRoot.length + 1); + } + return path.split(/[/\\]/).pop() || path; + } + + private async ensureDirectoryExists(dirPath: string, description: string): Promise { + const exists = await this.pathExists(dirPath); + if (exists) return; + + try { + const { mkdir } = await this.getFsPromises(); + await mkdir(dirPath, { recursive: true }); + logger.debug({ dirPath: this.sanitizePathForError(dirPath) }, `Created ${description}`); + } catch (err) { + logger.error({ error: err, dirPath: this.sanitizePathForError(dirPath) }, `Failed to create ${description}`); + throw new Error(`Failed to create ${description}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + getRepoPath(npub: string, repoName: string): string { + const repoPath = join(this.repoRoot, npub, `${repoName}.git`); + const resolvedPath = resolve(repoPath).replace(/\\/g, '/'); + const resolvedRoot = resolve(this.repoRoot).replace(/\\/g, '/'); + if (!resolvedPath.startsWith(resolvedRoot + '/')) { + throw new Error('Path traversal detected: repository path outside allowed root'); + } + return repoPath; + } + + repoExists(npub: string, repoName: string): boolean { + const npubValidation = validateNpub(npub); + if (!npubValidation.valid) return false; + const repoValidation = validateRepoName(repoName); + if (!repoValidation.valid) return false; + + const cacheKey = RepoCache.repoExistsKey(npub, repoName); + const cached = repoCache.get(cacheKey); + if (cached !== null) return cached; + + const repoPath = this.getRepoPath(npub, repoName); + const exists = this.repoManager.repoExists(repoPath); + repoCache.set(cacheKey, exists, 60 * 1000); + return exists; + } + + async getWorktree(repoPath: string, branch: string, npub: string, repoName: string): Promise { + if (!isValidBranchName(branch)) { + throw new Error(`Invalid branch name: ${branch}`); + } + return getOrCreateWorktree({ + repoPath, + branch, + npub, + repoName, + repoRoot: this.repoRoot + }); + } + + async removeWorktree(repoPath: string, worktreePath: string): Promise { + return removeWorktree(repoPath, worktreePath); + } + + async listFiles(npub: string, repoName: string, ref: string = 'HEAD', path: string = ''): Promise { + const repoPath = this.getRepoPath(npub, repoName); + if (!this.repoExists(npub, repoName)) { + throw new Error('Repository not found'); + } + return listFiles({ npub, repoName, ref, path, repoPath }); + } + + async getFileContent(npub: string, repoName: string, filePath: string, ref: string = 'HEAD'): Promise { + const repoPath = this.getRepoPath(npub, repoName); + if (!this.repoExists(npub, repoName)) { + throw new Error('Repository not found'); + } + return getFileContent({ npub, repoName, filePath, ref, repoPath }); + } + + async writeFile( + npub: string, + repoName: string, + filePath: string, + content: string, + commitMessage: string, + authorName: string, + authorEmail: string, + branch: string = 'main', + signingOptions?: { + commitSignatureEvent?: any; + useNIP07?: boolean; + nip98Event?: any; + nsecKey?: string; + } + ): Promise { + const repoPath = this.getRepoPath(npub, repoName); + if (!this.repoExists(npub, repoName)) { + throw new Error('Repository not found'); + } + + // Check repo size + const repoSizeCheck = await this.repoManager.checkRepoSizeLimit(repoPath); + if (!repoSizeCheck.withinLimit) { + throw new Error(repoSizeCheck.error || 'Repository size limit exceeded'); + } + + const worktreePath = await this.getWorktree(repoPath, branch, npub, repoName); + + // Save commit signature helper + const saveCommitSignature = async (worktreePath: string, event: any) => { + await this.saveCommitSignatureEventToWorktree(worktreePath, event); + }; + + // Check if repo is private + const isRepoPrivate = async (npub: string, repoName: string) => { + return this.isRepoPrivate(npub, repoName); + }; + + await writeFile({ + npub, + repoName, + filePath, + content, + commitMessage, + authorName, + authorEmail, + branch, + repoPath, + worktreePath, + signingOptions, + saveCommitSignature, + isRepoPrivate + }); + + await this.removeWorktree(repoPath, worktreePath); + } + + async deleteFile( + npub: string, + repoName: string, + filePath: string, + commitMessage: string, + authorName: string, + authorEmail: string, + branch: string = 'main', + signingOptions?: { + commitSignatureEvent?: any; + useNIP07?: boolean; + nip98Event?: any; + nsecKey?: string; + } + ): Promise { + const repoPath = this.getRepoPath(npub, repoName); + if (!this.repoExists(npub, repoName)) { + throw new Error('Repository not found'); + } + + const worktreePath = await this.getWorktree(repoPath, branch, npub, repoName); + + const saveCommitSignature = async (worktreePath: string, event: any) => { + await this.saveCommitSignatureEventToWorktree(worktreePath, event); + }; + + await deleteFile({ + npub, + repoName, + filePath, + commitMessage, + authorName, + authorEmail, + branch, + repoPath, + worktreePath, + signingOptions, + saveCommitSignature + }); + + await this.removeWorktree(repoPath, worktreePath); + } + + async createFile( + npub: string, + repoName: string, + filePath: string, + content: string, + commitMessage: string, + authorName: string, + authorEmail: string, + branch: string = 'main', + signingOptions?: { + useNIP07?: boolean; + nip98Event?: any; + nsecKey?: string; + } + ): Promise { + return this.writeFile(npub, repoName, filePath, content, commitMessage, authorName, authorEmail, branch, signingOptions); + } + + async getDefaultBranch(npub: string, repoName: string): Promise { + const repoPath = this.getRepoPath(npub, repoName); + if (!this.repoExists(npub, repoName)) { + throw new Error('Repository not found'); + } + + const git: SimpleGit = simpleGit(repoPath); + + try { + const defaultRef = await git.raw(['symbolic-ref', 'HEAD']); + if (defaultRef) { + const match = defaultRef.trim().match(/^refs\/heads\/(.+)$/); + if (match) return match[1]; + } + } catch { + try { + const remoteHead = await git.raw(['symbolic-ref', 'refs/remotes/origin/HEAD']); + if (remoteHead) { + const match = remoteHead.trim().match(/^refs\/remotes\/origin\/(.+)$/); + if (match) return match[1]; + } + } catch { + // Fall through + } + } + + try { + const branches = await git.branch(['-r']); + const branchList = branches.all + .map(b => b.replace(/^origin\//, '')) + .filter(b => !b.includes('HEAD')); + + if (branchList.length === 0) return 'main'; + if (branchList.includes('main')) return 'main'; + if (branchList.includes('master')) return 'master'; + return branchList[0]; + } catch { + return 'main'; + } + } + + async getBranches(npub: string, repoName: string): Promise { + const repoPath = this.getRepoPath(npub, repoName); + if (!this.repoExists(npub, repoName)) { + throw new Error('Repository not found'); + } + return getBranches({ + npub, + repoName, + repoPath, + getDefaultBranch: (npub, repoName) => this.getDefaultBranch(npub, repoName) + }); + } + + async createBranch( + npub: string, + repoName: string, + branchName: string, + fromBranch: string = 'main' + ): Promise { + const repoPath = this.getRepoPath(npub, repoName); + if (!this.repoExists(npub, repoName)) { + throw new Error('Repository not found'); + } + + if (!isValidBranchName(branchName)) { + throw new Error(`Invalid branch name: ${branchName}`); + } + + const git: SimpleGit = simpleGit(repoPath); + + try { + await git.raw(['branch', branchName, fromBranch]); + const cacheKey = RepoCache.branchesKey(npub, repoName); + repoCache.delete(cacheKey); + } catch (error) { + logger.error({ error, repoPath, branchName, fromBranch }, 'Error creating branch'); + throw new Error(`Failed to create branch: ${error instanceof Error ? error.message : String(error)}`); + } + } + + async deleteBranch(npub: string, repoName: string, branchName: string): Promise { + const repoPath = this.getRepoPath(npub, repoName); + if (!this.repoExists(npub, repoName)) { + throw new Error('Repository not found'); + } + + if (!isValidBranchName(branchName)) { + throw new Error(`Invalid branch name: ${branchName}`); + } + + const git: SimpleGit = simpleGit(repoPath); + + try { + await git.raw(['branch', '-D', branchName]).catch(async () => { + await git.raw(['update-ref', '-d', `refs/heads/${branchName}`]); + }); + + const cacheKey = RepoCache.branchesKey(npub, repoName); + repoCache.delete(cacheKey); + } catch (error) { + logger.error({ error, repoPath, branchName }, 'Error deleting branch'); + throw new Error(`Failed to delete branch: ${error instanceof Error ? error.message : String(error)}`); + } + } + + async getCommitHistory( + npub: string, + repoName: string, + branch: string = 'main', + limit: number = 50, + path?: string + ): Promise { + const repoPath = this.getRepoPath(npub, repoName); + if (!this.repoExists(npub, repoName)) { + throw new Error('Repository not found'); + } + return getCommitHistory({ npub, repoName, branch, limit, path, repoPath }); + } + + async getDiff( + npub: string, + repoName: string, + fromRef: string, + toRef: string = 'HEAD', + filePath?: string + ): Promise { + const repoPath = this.getRepoPath(npub, repoName); + if (!this.repoExists(npub, repoName)) { + throw new Error('Repository not found'); + } + return getDiff({ npub, repoName, fromRef, toRef, filePath, repoPath }); + } + + async createTag( + npub: string, + repoName: string, + tagName: string, + ref: string = 'HEAD', + message?: string, + authorName?: string, + authorEmail?: string + ): Promise { + const repoPath = this.getRepoPath(npub, repoName); + if (!this.repoExists(npub, repoName)) { + throw new Error('Repository not found'); + } + return createTag({ npub, repoName, tagName, ref, message, authorName, authorEmail, repoPath }); + } + + async getTags(npub: string, repoName: string): Promise { + const repoPath = this.getRepoPath(npub, repoName); + if (!this.repoExists(npub, repoName)) { + throw new Error('Repository not found'); + } + return getTags({ npub, repoName, repoPath }); + } + + private async saveCommitSignatureEventToWorktree(worktreePath: string, event: any): Promise { + try { + const { mkdir, writeFile } = await this.getFsPromises(); + const nostrDir = join(worktreePath, 'nostr'); + await mkdir(nostrDir, { recursive: true }); + const jsonlFile = join(nostrDir, 'commit-signatures.jsonl'); + const eventLine = JSON.stringify(event) + '\n'; + await writeFile(jsonlFile, eventLine, { flag: 'a', encoding: 'utf-8' }); + } catch (err) { + logger.debug({ error: err, worktreePath }, 'Failed to save commit signature event'); + } + } + + async saveRepoEventToWorktree( + worktreePath: string, + event: any, + eventType: 'announcement' | 'transfer', + skipIfExists: boolean = true + ): Promise { + try { + const { mkdir, writeFile, readFile } = await this.getFsPromises(); + const nostrDir = join(worktreePath, 'nostr'); + await mkdir(nostrDir, { recursive: true }); + const jsonlFile = join(nostrDir, 'repo-events.jsonl'); + + 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) { + return false; + } + } catch { + // Skip invalid lines + } + } + } catch { + // File doesn't exist yet + } + } + + 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'); + return false; + } + } + + private async isRepoPrivate(npub: string, repoName: string): Promise { + try { + const { requireNpubHex } = await import('../../../utils/npub-utils.js'); + const repoOwnerPubkey = requireNpubHex(npub); + const { NostrClient } = await import('../../nostr/nostr-client.js'); + const { DEFAULT_NOSTR_RELAYS } = await import('../../../config.js'); + const { KIND } = await import('../../../types/nostr.js'); + + const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); + const events = await nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [repoOwnerPubkey], + '#d': [repoName], + limit: 1 + } + ]); + + if (events.length === 0) return false; + + const { isPrivateRepo: checkIsPrivateRepo } = await import('../../../utils/repo-privacy.js'); + return checkIsPrivateRepo(events[0]); + } catch (err) { + logger.debug({ error: err, npub, repoName }, 'Failed to check repo privacy, defaulting to public'); + return false; + } + } + + async getCurrentOwnerFromRepo(npub: string, repoName: string): Promise { + try { + if (!this.repoExists(npub, repoName)) return null; + + const repoPath = this.getRepoPath(npub, repoName); + const git: SimpleGit = simpleGit(repoPath); + + const logOutput = await git.raw(['log', '--all', '--format=%H', '--reverse', '--', 'nostr/repo-events.jsonl']); + const commitHashes = logOutput.trim().split('\n').filter(Boolean); + + if (commitHashes.length === 0) return null; + + const mostRecentCommit = commitHashes[commitHashes.length - 1]; + const repoEventsFile = await this.getFileContent(npub, repoName, 'nostr/repo-events.jsonl', mostRecentCommit); + + let announcementEvent: any = null; + let latestTimestamp = 0; + + try { + const lines = repoEventsFile.content.trim().split('\n').filter(Boolean); + for (const line of lines) { + try { + const entry = JSON.parse(line); + if (entry.type === 'announcement' && entry.event && entry.timestamp) { + if (entry.timestamp > latestTimestamp) { + latestTimestamp = entry.timestamp; + announcementEvent = entry.event; + } + } + } catch { + continue; + } + } + } catch (parseError) { + logger.warn({ error: parseError, npub, repoName }, 'Failed to parse repo-events.jsonl'); + return null; + } + + if (!announcementEvent) return null; + + const { validateAnnouncementEvent } = await import('../../nostr/repo-verification.js'); + const validation = validateAnnouncementEvent(announcementEvent, repoName); + + if (!validation.valid) { + logger.warn({ error: validation.error, npub, repoName }, 'Announcement validation failed'); + return null; + } + + return announcementEvent.pubkey; + } catch (error) { + logger.error({ error, npub, repoName }, 'Error getting current owner from repo'); + return null; + } + } +} diff --git a/src/lib/services/git/file-manager/file-operations.ts b/src/lib/services/git/file-manager/file-operations.ts new file mode 100644 index 0000000..6324070 --- /dev/null +++ b/src/lib/services/git/file-manager/file-operations.ts @@ -0,0 +1,295 @@ +/** + * File operations module + * Handles reading, writing, and listing files in git repositories + */ + +import simpleGit, { type SimpleGit } from 'simple-git'; +import { join } from 'path'; +import logger from '../../logger.js'; +import { sanitizeError } from '../../../utils/security.js'; +import { validateFilePath, validateRepoName, validateNpub } from './path-validator.js'; +import type { FileEntry, FileContent } from '../file-manager.js'; +import { repoCache, RepoCache } from '../repo-cache.js'; + +export interface FileListOptions { + npub: string; + repoName: string; + ref?: string; + path?: string; + repoPath: string; +} + +export interface FileReadOptions { + npub: string; + repoName: string; + filePath: string; + ref?: string; + repoPath: string; +} + +/** + * List files and directories in a repository at a given path + */ +export async function listFiles(options: FileListOptions): Promise { + const { npub, repoName, ref = 'HEAD', path = '', repoPath } = options; + + // Validate inputs + const npubValidation = validateNpub(npub); + if (!npubValidation.valid) { + throw new Error(`Invalid npub: ${npubValidation.error}`); + } + const repoValidation = validateRepoName(repoName); + if (!repoValidation.valid) { + throw new Error(`Invalid repository name: ${repoValidation.error}`); + } + + const pathValidation = validateFilePath(path); + if (!pathValidation.valid) { + throw new Error(`Invalid file path: ${pathValidation.error}`); + } + + // Check cache first (cache for 2 minutes) + const cacheKey = RepoCache.fileListKey(npub, repoName, ref, path); + const cached = repoCache.get(cacheKey); + if (cached !== null) { + logger.debug({ npub, repoName, path, ref, cachedCount: cached.length }, 'Returning cached file list'); + return cached; + } + + const git: SimpleGit = simpleGit(repoPath); + + try { + const gitPath = path ? (path.endsWith('/') ? path : `${path}/`) : '.'; + logger.operation('Listing files', { npub, repoName, path, ref, gitPath }); + + let tree: string; + try { + tree = await git.raw(['ls-tree', '-l', ref, gitPath]); + } catch (lsTreeError) { + const errorMsg = lsTreeError instanceof Error ? lsTreeError.message : String(lsTreeError); + const errorStr = String(lsTreeError).toLowerCase(); + const errorMsgLower = errorMsg.toLowerCase(); + + const isEmptyBranchError = + errorMsgLower.includes('not a valid object') || + errorMsgLower.includes('not found') || + errorMsgLower.includes('bad revision') || + errorMsgLower.includes('ambiguous argument') || + errorStr.includes('not a valid object') || + errorStr.includes('not found') || + errorStr.includes('bad revision') || + errorStr.includes('ambiguous argument') || + (errorMsgLower.includes('fatal:') && (errorMsgLower.includes('master') || errorMsgLower.includes('refs/heads'))); + + if (isEmptyBranchError) { + logger.debug({ npub, repoName, path, ref }, 'Branch has no commits, returning empty list'); + const emptyResult: FileEntry[] = []; + repoCache.set(cacheKey, emptyResult, 30 * 1000); + return emptyResult; + } + + logger.error({ error: lsTreeError, npub, repoName, path, ref }, 'Unexpected error from git ls-tree'); + throw lsTreeError; + } + + if (!tree || !tree.trim()) { + const emptyResult: FileEntry[] = []; + repoCache.set(cacheKey, emptyResult, 30 * 1000); + return emptyResult; + } + + const entries: FileEntry[] = []; + const lines = tree.trim().split('\n').filter(line => line.length > 0); + const normalizedPath = path ? (path.endsWith('/') ? path : `${path}/`) : ''; + + for (const line of lines) { + const tabIndex = line.lastIndexOf('\t'); + if (tabIndex === -1) { + // Space-separated format + const match = line.match(/^(\d+)\s+(\w+)\s+(\w+)\s+(\d+|-)\s+(.+)$/); + if (match) { + const [, , type, , size, gitPath] = match; + const { fullPath, displayName } = parseGitPath(gitPath, normalizedPath, path); + + entries.push({ + name: displayName, + path: fullPath, + type: type === 'tree' ? 'directory' : 'file', + size: size !== '-' ? parseInt(size, 10) : undefined + }); + } + } else { + // Tab-separated format (standard) + const beforeTab = line.substring(0, tabIndex); + const gitPath = line.substring(tabIndex + 1); + const match = beforeTab.match(/^(\d+)\s+(\w+)\s+(\w+)\s+(\d+|-)$/); + if (match) { + const [, , type, , size] = match; + const { fullPath, displayName } = parseGitPath(gitPath, normalizedPath, path); + + entries.push({ + name: displayName, + path: fullPath, + type: type === 'tree' ? 'directory' : 'file', + size: size !== '-' ? parseInt(size, 10) : undefined + }); + } + } + } + + const sortedEntries = entries.sort((a, b) => { + if (a.type !== b.type) { + return a.type === 'directory' ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + + // Cache the result (cache for 2 minutes) + repoCache.set(cacheKey, sortedEntries, 2 * 60 * 1000); + + logger.operation('Files listed', { npub, repoName, path, count: sortedEntries.length }); + return sortedEntries; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + const errorStr = String(error).toLowerCase(); + const errorMsgLower = errorMsg.toLowerCase(); + + const isEmptyBranchError = + errorMsgLower.includes('not a valid object') || + errorMsgLower.includes('not found') || + errorMsgLower.includes('bad revision') || + errorMsgLower.includes('ambiguous argument') || + errorStr.includes('not a valid object') || + errorStr.includes('not found') || + errorStr.includes('bad revision') || + errorStr.includes('ambiguous argument') || + (errorMsgLower.includes('fatal:') && (errorMsgLower.includes('master') || errorMsgLower.includes('refs/heads'))); + + if (isEmptyBranchError) { + logger.debug({ npub, repoName, path, ref }, 'Branch has no commits, returning empty list'); + const emptyResult: FileEntry[] = []; + repoCache.set(cacheKey, emptyResult, 30 * 1000); + return emptyResult; + } + + logger.error({ error, repoPath, ref }, 'Error listing files'); + throw new Error(`Failed to list files: ${errorMsg}`); + } +} + +/** + * Parse git path and extract full path and display name + */ +function parseGitPath( + gitPath: string, + normalizedPath: string, + originalPath: string +): { fullPath: string; displayName: string } { + let fullPath: string; + let displayName: string; + + if (normalizedPath) { + if (gitPath.startsWith(normalizedPath)) { + fullPath = gitPath; + const relativePath = gitPath.slice(normalizedPath.length); + const cleanRelative = relativePath.replace(/^\/+|\/+$/g, ''); + displayName = cleanRelative.split('/')[0] || cleanRelative; + } else { + fullPath = join(originalPath, gitPath); + displayName = gitPath.split('/').pop() || gitPath; + } + } else { + fullPath = gitPath; + displayName = gitPath.split('/')[0]; + } + + return { fullPath, displayName }; +} + +/** + * Get file content from a repository + */ +export async function getFileContent(options: FileReadOptions): Promise { + const { npub, repoName, filePath, ref = 'HEAD', repoPath } = options; + + // Validate inputs + const npubValidation = validateNpub(npub); + if (!npubValidation.valid) { + throw new Error(`Invalid npub: ${npubValidation.error}`); + } + const repoValidation = validateRepoName(repoName); + if (!repoValidation.valid) { + throw new Error(`Invalid repository name: ${repoValidation.error}`); + } + + const pathValidation = validateFilePath(filePath); + if (!pathValidation.valid) { + throw new Error(`Invalid file path: ${pathValidation.error}`); + } + + const git: SimpleGit = simpleGit(repoPath); + + try { + logger.operation('Reading file', { npub, repoName, filePath, ref }); + + let content: string; + try { + content = await git.raw(['show', `${ref}:${filePath}`]); + } catch (gitError: any) { + const stderr = gitError?.stderr || gitError?.message || String(gitError); + const stderrLower = stderr.toLowerCase(); + + 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}`); + } + + throw new Error(`Git command failed: ${stderr}`); + } + + if (content === undefined || content === null) { + throw new Error(`File not found: ${filePath} at ref ${ref}`); + } + + const encoding = 'utf-8'; + const size = Buffer.byteLength(content, encoding); + + logger.operation('File read', { npub, repoName, filePath, size }); + return { + content, + encoding, + size + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const errorLower = errorMessage.toLowerCase(); + const errorString = String(error); + const errorStringLower = errorString.toLowerCase(); + + 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}`); + } + + logger.error({ error, repoPath, filePath, ref }, 'Error reading file'); + throw new Error(`Failed to read file: ${errorMessage}`); + } +} diff --git a/src/lib/services/git/file-manager/index.ts b/src/lib/services/git/file-manager/index.ts new file mode 100644 index 0000000..6c83e5c --- /dev/null +++ b/src/lib/services/git/file-manager/index.ts @@ -0,0 +1,22 @@ +/** + * File Manager - Modular exports + * Re-exports all file manager functionality from focused modules + */ + +// Re-export types +export type { + FileEntry, + FileContent, + Commit, + Diff, + Tag +} from '../file-manager.js'; + +// Re-export modules +export * from './worktree-manager.js'; +export * from './path-validator.js'; +export * from './file-operations.js'; +export * from './branch-operations.js'; +export * from './write-operations.js'; +export * from './commit-operations.js'; +export * from './tag-operations.js'; \ No newline at end of file diff --git a/src/lib/services/git/file-manager/path-validator.ts b/src/lib/services/git/file-manager/path-validator.ts new file mode 100644 index 0000000..c9be7c4 --- /dev/null +++ b/src/lib/services/git/file-manager/path-validator.ts @@ -0,0 +1,128 @@ +/** + * Path validation utilities + * Security-focused path validation to prevent path traversal attacks + */ + +import { normalize, resolve } from 'path'; +import logger from '../../logger.js'; + +export interface ValidationResult { + valid: boolean; + error?: string; + normalized?: string; +} + +/** + * Validate and sanitize file path to prevent path traversal attacks + */ +export function validateFilePath(filePath: string): ValidationResult { + // Allow empty string for root directory + if (filePath === '') { + return { valid: true, normalized: '' }; + } + + if (!filePath || typeof filePath !== 'string') { + return { valid: false, error: 'File path must be a non-empty string' }; + } + + // Normalize the path (resolves .. and .) + const normalized = normalize(filePath); + + // Check for path traversal attempts + if (normalized.includes('..')) { + logger.security('Path traversal attempt detected', { filePath, normalized }); + return { valid: false, error: 'Path traversal detected (..)' }; + } + + // Check for absolute paths + if (normalized.startsWith('/')) { + return { valid: false, error: 'Absolute paths are not allowed' }; + } + + // Check for null bytes + if (normalized.includes('\0')) { + logger.security('Null byte detected in path', { filePath }); + return { valid: false, error: 'Null bytes are not allowed in paths' }; + } + + // Check for control characters + if (/[\x00-\x1f\x7f]/.test(normalized)) { + logger.security('Control character detected in path', { filePath }); + return { valid: false, error: 'Control characters are not allowed in paths' }; + } + + // Limit path length (reasonable limit) + if (normalized.length > 4096) { + return { valid: false, error: 'Path is too long (max 4096 characters)' }; + } + + return { valid: true, normalized }; +} + +/** + * Validate repository name to prevent injection attacks + */ +export function validateRepoName(repoName: string): ValidationResult { + if (!repoName || typeof repoName !== 'string') { + return { valid: false, error: 'Repository name must be a non-empty string' }; + } + + // Check length + if (repoName.length > 100) { + return { valid: false, error: 'Repository name is too long (max 100 characters)' }; + } + + // Check for invalid characters (alphanumeric, hyphens, underscores, dots) + if (!/^[a-zA-Z0-9._-]+$/.test(repoName)) { + logger.security('Invalid characters in repo name', { repoName }); + return { valid: false, error: 'Repository name contains invalid characters' }; + } + + // Check for path traversal + if (repoName.includes('..') || repoName.includes('/') || repoName.includes('\\')) { + logger.security('Path traversal attempt in repo name', { repoName }); + return { valid: false, error: 'Repository name contains invalid path characters' }; + } + + return { valid: true, normalized: repoName }; +} + +/** + * Validate npub format + */ +export function validateNpub(npub: string): ValidationResult { + if (!npub || typeof npub !== 'string') { + return { valid: false, error: 'npub must be a non-empty string' }; + } + + // Basic npub format check (starts with npub, base58 encoded) + if (!npub.startsWith('npub1') || npub.length < 10 || npub.length > 100) { + return { valid: false, error: 'Invalid npub format' }; + } + + return { valid: true, normalized: npub }; +} + +/** + * Validate repository path is within allowed root + */ +export function validateRepoPath( + repoPath: string, + repoRoot: string +): ValidationResult { + try { + const resolvedPath = resolve(repoPath).replace(/\\/g, '/'); + const resolvedRoot = resolve(repoRoot).replace(/\\/g, '/'); + + // Must be a subdirectory of repoRoot, not equal to it + if (!resolvedPath.startsWith(resolvedRoot + '/')) { + logger.security('Repository path outside allowed root', { repoPath, repoRoot }); + return { valid: false, error: 'Path traversal detected: repository path outside allowed root' }; + } + + return { valid: true, normalized: resolvedPath }; + } catch (error) { + logger.error({ error, repoPath, repoRoot }, 'Failed to validate repo path'); + return { valid: false, error: 'Failed to validate repository path' }; + } +} diff --git a/src/lib/services/git/file-manager/tag-operations.ts b/src/lib/services/git/file-manager/tag-operations.ts new file mode 100644 index 0000000..7d23b0a --- /dev/null +++ b/src/lib/services/git/file-manager/tag-operations.ts @@ -0,0 +1,187 @@ +/** + * Tag operations module + * Handles tag creation and listing + */ + +import simpleGit, { type SimpleGit } from 'simple-git'; +import logger from '../../logger.js'; +import { validateRepoName, validateNpub } from './path-validator.js'; +import type { Tag } from '../file-manager.js'; + +export interface CreateTagOptions { + npub: string; + repoName: string; + tagName: string; + ref?: string; + message?: string; + authorName?: string; + authorEmail?: string; + repoPath: string; +} + +export interface GetTagsOptions { + npub: string; + repoName: string; + repoPath: string; +} + +/** + * Create a tag + */ +export async function createTag(options: CreateTagOptions): Promise { + const { npub, repoName, tagName, ref = 'HEAD', message, repoPath } = options; + + // Validate inputs + const npubValidation = validateNpub(npub); + if (!npubValidation.valid) { + throw new Error(`Invalid npub: ${npubValidation.error}`); + } + const repoValidation = validateRepoName(repoName); + if (!repoValidation.valid) { + throw new Error(`Invalid repository name: ${repoValidation.error}`); + } + + const git: SimpleGit = simpleGit(repoPath); + + try { + logger.operation('Creating tag', { npub, repoName, tagName, ref }); + + // Check if repository has any commits + let hasCommits = false; + let actualRef = ref; + + try { + const headCommit = await git.raw(['rev-parse', 'HEAD']).catch(() => null); + hasCommits = !!(headCommit && headCommit.trim().length > 0); + } catch { + // Check if any branch has commits + try { + const branches = await git.branch(['-a']); + for (const branch of branches.all) { + const branchName = branch.replace(/^remotes\/origin\//, '').replace(/^remotes\//, ''); + if (branchName.includes('HEAD')) continue; + try { + const commitHash = await git.raw(['rev-parse', `refs/heads/${branchName}`]).catch(() => null); + if (commitHash && commitHash.trim().length > 0) { + hasCommits = true; + if (ref === 'HEAD') { + actualRef = branchName; + } + break; + } + } catch { + // Continue checking other branches + } + } + } catch { + // Could not check branches + } + } + + if (!hasCommits) { + throw new Error('Cannot create tag: repository has no commits. Please create at least one commit first.'); + } + + // Validate that the ref exists + try { + await git.raw(['rev-parse', '--verify', actualRef]); + } catch (refErr) { + throw new Error(`Invalid reference '${actualRef}': ${refErr instanceof Error ? refErr.message : String(refErr)}`); + } + + if (message) { + // Create annotated tag + if (actualRef !== 'HEAD') { + await git.raw(['tag', '-a', tagName, '-m', message, actualRef]); + } else { + await git.raw(['tag', '-a', tagName, '-m', message]); + } + } else { + // Create lightweight tag + if (actualRef !== 'HEAD') { + await git.raw(['tag', tagName, actualRef]); + } else { + await git.addTag(tagName); + } + } + + logger.operation('Tag created', { npub, repoName, tagName }); + } catch (error) { + logger.error({ error, repoPath, tagName, ref }, 'Error creating tag'); + throw new Error(`Failed to create tag: ${error instanceof Error ? error.message : String(error)}`); + } +} + +/** + * Get list of tags + */ +export async function getTags(options: GetTagsOptions): Promise { + const { npub, repoName, repoPath } = options; + + // Validate inputs + const npubValidation = validateNpub(npub); + if (!npubValidation.valid) { + throw new Error(`Invalid npub: ${npubValidation.error}`); + } + const repoValidation = validateRepoName(repoName); + if (!repoValidation.valid) { + throw new Error(`Invalid repository name: ${repoValidation.error}`); + } + + const git: SimpleGit = simpleGit(repoPath); + + try { + logger.operation('Getting tags', { npub, repoName }); + + const tags = await git.tags(); + const tagList: Tag[] = []; + + for (const tagName of tags.all) { + try { + // Get the commit hash the tag points to + const hash = await git.raw(['rev-parse', tagName]); + const commitHash = hash.trim(); + + // Get the commit date (Unix timestamp) + let commitDate: number | undefined; + try { + const dateStr = await git.raw(['log', '-1', '--format=%at', commitHash]); + commitDate = parseInt(dateStr.trim(), 10); + if (isNaN(commitDate)) { + commitDate = undefined; + } + } catch { + commitDate = undefined; + } + + // Try to get tag message (for annotated tags) + try { + const tagInfo = await git.raw(['cat-file', '-p', tagName]); + const messageMatch = tagInfo.match(/^(.+)$/m); + + tagList.push({ + name: tagName, + hash: commitHash, + message: messageMatch ? messageMatch[1] : undefined, + date: commitDate + }); + } catch { + // Lightweight tag - no message + tagList.push({ + name: tagName, + hash: commitHash, + date: commitDate + }); + } + } catch (err) { + logger.warn({ error: err, tagName }, 'Error processing tag, skipping'); + } + } + + logger.operation('Tags retrieved', { npub, repoName, count: tagList.length }); + return tagList; + } catch (error) { + logger.error({ error, repoPath }, 'Error getting tags'); + return []; + } +} diff --git a/src/lib/services/git/file-manager/worktree-manager.ts b/src/lib/services/git/file-manager/worktree-manager.ts new file mode 100644 index 0000000..7b252fa --- /dev/null +++ b/src/lib/services/git/file-manager/worktree-manager.ts @@ -0,0 +1,207 @@ +/** + * Worktree management module + * Handles git worktree operations with proper cleanup + */ + +import { join, resolve, dirname } from 'path'; +import { spawn } from 'child_process'; +import simpleGit, { type SimpleGit } from 'simple-git'; +import logger from '../../logger.js'; +import { sanitizeError } from '../../../utils/security.js'; +import { execGitProcess } from '../../../utils/git-process.js'; + +export interface WorktreeOptions { + repoPath: string; + branch: string; + npub: string; + repoName: string; + repoRoot: string; +} + +/** + * Get or create a worktree for a branch + */ +export async function getOrCreateWorktree( + options: WorktreeOptions +): Promise { + const { repoPath, branch, npub, repoName, repoRoot } = options; + + const worktreeRoot = join(repoRoot, npub, `${repoName}.worktrees`); + const worktreePath = resolve(join(worktreeRoot, branch)); + const resolvedWorktreeRoot = resolve(worktreeRoot); + + // Security: Ensure resolved path is still within worktreeRoot + 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 + await mkdir(resolvedWorktreeRoot, { recursive: true }); + + const git = simpleGit(repoPath); + + // Check if worktree already exists + try { + const worktrees = await git.raw(['worktree', 'list', '--porcelain']); + const worktreeLines = worktrees.split('\n'); + let currentWorktreePath = ''; + + for (const line of worktreeLines) { + if (line.startsWith('worktree ')) { + currentWorktreePath = line.substring(9).trim(); + } else if (line.startsWith('branch ') && currentWorktreePath) { + const branchRef = line.substring(7).trim(); + if (branchRef === `refs/heads/${branch}` || branchRef.endsWith(`/${branch}`)) { + logger.debug({ branch, worktreePath: currentWorktreePath }, 'Worktree already exists'); + return currentWorktreePath; + } + } + } + } catch (err) { + logger.debug({ error: err }, 'Failed to list worktrees, will create new one'); + } + + // Check if directory exists but is not a valid worktree + try { + const { accessSync, constants } = await import('fs'); + accessSync(worktreePath, constants.F_OK); + // Directory exists, check if it's a valid git repo + const worktreeGit = simpleGit(worktreePath); + await worktreeGit.status(); + logger.debug({ branch, worktreePath }, 'Existing directory is valid worktree'); + return worktreePath; + } catch { + // Directory doesn't exist or is invalid, will create new worktree + } + + // Remove existing directory if it exists but is invalid + try { + await rm(worktreePath, { recursive: true, force: true }); + } catch { + // Ignore errors - directory might not exist + } + + // Create worktree + try { + await execGitProcess(['worktree', 'add', worktreePath, branch], { + cwd: repoPath, + timeoutMs: 5 * 60 * 1000 // 5 minutes + }); + } catch (error: any) { + const stderr = error.message || ''; + + // If branch doesn't exist, create it first + if (stderr.includes('fatal: invalid reference') || + stderr.includes('fatal: not a valid object name') || + stderr.includes('Ungültige Referenz')) { + + // Find source branch + const branches = await git.branch(['-a']); + let sourceBranch = 'HEAD'; + + if (branches.all.includes('HEAD') || branches.all.includes('origin/HEAD')) { + sourceBranch = 'HEAD'; + } else if (branches.all.includes('main') || branches.all.includes('origin/main')) { + sourceBranch = 'main'; + } else if (branches.all.includes('master') || branches.all.includes('origin/master')) { + sourceBranch = 'master'; + } else { + const firstBranch = branches.all.find(b => !b.includes('HEAD')); + if (firstBranch) { + sourceBranch = firstBranch.replace(/^origin\//, ''); + } + } + + // Create branch + try { + await execGitProcess(['branch', branch, sourceBranch], { + cwd: repoPath, + timeoutMs: 2 * 60 * 1000 + }); + } catch (branchError: any) { + // Try creating orphan branch + if (branchError.message?.includes('fatal: invalid reference')) { + await execGitProcess(['branch', branch], { + cwd: repoPath, + timeoutMs: 2 * 60 * 1000 + }); + } else { + throw branchError; + } + } + + // Retry worktree creation + try { + await execGitProcess(['worktree', 'add', worktreePath, branch], { + cwd: repoPath, + timeoutMs: 5 * 60 * 1000 + }); + } catch (retryError: any) { + // Try with --orphan as last resort + if (retryError.message?.includes('fatal: invalid reference')) { + await execGitProcess(['worktree', 'add', '--orphan', branch, worktreePath], { + cwd: repoPath, + timeoutMs: 5 * 60 * 1000 + }); + } else { + throw retryError; + } + } + } else { + throw error; + } + } + + // Verify worktree was created + const { accessSync, constants } = await import('fs'); + try { + accessSync(worktreePath, constants.F_OK); + } catch { + 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 { + throw new Error(`Created worktree directory is not a valid git repository: ${worktreePath}`); + } + + logger.operation('Worktree created', { branch, worktreePath }); + return worktreePath; +} + +/** + * Remove a worktree + */ +export async function removeWorktree( + repoPath: string, + worktreePath: string +): Promise { + try { + await execGitProcess(['worktree', 'remove', worktreePath], { + cwd: repoPath, + timeoutMs: 2 * 60 * 1000 + }); + } catch (error: any) { + // Try force remove + try { + await execGitProcess(['worktree', 'remove', '--force', worktreePath], { + cwd: repoPath, + timeoutMs: 2 * 60 * 1000 + }); + } catch { + // Last resort: delete directory + const { rm } = await import('fs/promises'); + await rm(worktreePath, { recursive: true, force: true }); + logger.warn({ worktreePath }, 'Force removed worktree directory'); + } + } + + logger.operation('Worktree removed', { worktreePath }); +} diff --git a/src/lib/services/git/file-manager/write-operations.ts b/src/lib/services/git/file-manager/write-operations.ts new file mode 100644 index 0000000..01936b1 --- /dev/null +++ b/src/lib/services/git/file-manager/write-operations.ts @@ -0,0 +1,311 @@ +/** + * Write operations module + * Handles file writing, deletion, and commit operations + */ + +import { join, dirname, resolve } from 'path'; +import simpleGit, { type SimpleGit } from 'simple-git'; +import logger from '../../logger.js'; +import { sanitizeError } from '../../../utils/security.js'; +import { isValidBranchName } from '../../../utils/security.js'; +import { validateFilePath, validateRepoName, validateNpub } from './path-validator.js'; +import { getOrCreateWorktree, removeWorktree } from './worktree-manager.js'; +import { createGitCommitSignature } from '../commit-signer.js'; +import type { NostrEvent } from '../../../types/nostr.js'; + +export interface WriteFileOptions { + npub: string; + repoName: string; + filePath: string; + content: string; + commitMessage: string; + authorName: string; + authorEmail: string; + branch?: string; + repoPath: string; + worktreePath: string; + signingOptions?: { + commitSignatureEvent?: NostrEvent; + useNIP07?: boolean; + nip98Event?: NostrEvent; + nsecKey?: string; + }; + saveCommitSignature?: (worktreePath: string, event: NostrEvent) => Promise; + isRepoPrivate?: (npub: string, repoName: string) => Promise; +} + +/** + * Write file and commit changes + */ +export async function writeFile(options: WriteFileOptions): Promise { + const { + npub, + repoName, + filePath, + content, + commitMessage, + authorName, + authorEmail, + branch = 'main', + repoPath, + worktreePath, + signingOptions, + saveCommitSignature, + isRepoPrivate + } = options; + + // Validate inputs + const npubValidation = validateNpub(npub); + if (!npubValidation.valid) { + throw new Error(`Invalid npub: ${npubValidation.error}`); + } + const repoValidation = validateRepoName(repoName); + if (!repoValidation.valid) { + throw new Error(`Invalid repository name: ${repoValidation.error}`); + } + + const pathValidation = validateFilePath(filePath); + if (!pathValidation.valid) { + throw new Error(`Invalid file path: ${pathValidation.error}`); + } + + if (!isValidBranchName(branch)) { + throw new Error(`Invalid branch name: ${branch}`); + } + + // Validate content size (500 MB max) + const maxFileSize = 500 * 1024 * 1024; + if (Buffer.byteLength(content, 'utf-8') > maxFileSize) { + throw new Error(`File is too large (max ${maxFileSize / 1024 / 1024} MB)`); + } + + // Validate commit message + if (!commitMessage || typeof commitMessage !== 'string' || commitMessage.trim().length === 0) { + throw new Error('Commit message is required'); + } + if (commitMessage.length > 1000) { + throw new Error('Commit message is too long (max 1000 characters)'); + } + + // Validate author info + if (!authorName || typeof authorName !== 'string' || authorName.trim().length === 0) { + throw new Error('Author name is required'); + } + if (!authorEmail || typeof authorEmail !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(authorEmail)) { + throw new Error('Valid author email is required'); + } + + try { + logger.operation('Writing file', { npub, repoName, filePath, branch }); + + const workGit: SimpleGit = simpleGit(worktreePath); + + // Write the file + const validatedPath = pathValidation.normalized || filePath; + const fullFilePath = join(worktreePath, validatedPath); + const fileDir = dirname(fullFilePath); + + // Security: ensure resolved path is within workDir + const resolvedPath = resolve(fullFilePath).replace(/\\/g, '/'); + const resolvedWorkDir = resolve(worktreePath).replace(/\\/g, '/'); + if (!resolvedPath.startsWith(resolvedWorkDir + '/') && resolvedPath !== resolvedWorkDir) { + throw new Error('Path validation failed: resolved path outside work directory'); + } + + // Ensure directory exists + const { mkdir } = await import('fs/promises'); + await mkdir(fileDir, { recursive: true }); + + const { writeFile: writeFileFs } = await import('fs/promises'); + await writeFileFs(fullFilePath, content, 'utf-8'); + + // Stage the file + await workGit.add(validatedPath); + + // Sign commit if signing options are provided + let finalCommitMessage = commitMessage; + let signatureEvent: NostrEvent | null = null; + + if (signingOptions && (signingOptions.commitSignatureEvent || signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) { + try { + const result = await createGitCommitSignature( + commitMessage, + authorName, + authorEmail, + signingOptions + ); + finalCommitMessage = result.signedMessage; + signatureEvent = signingOptions.commitSignatureEvent || result.signatureEvent; + } catch (err) { + const sanitizedErr = sanitizeError(err); + logger.warn({ error: sanitizedErr, repoPath, filePath }, 'Failed to sign commit'); + } + } + + // Commit + const commitResult = await workGit.commit(finalCommitMessage, [filePath], { + '--author': `${authorName} <${authorEmail}>` + }) as string | { commit: string }; + + // Get commit hash + let commitHash: string; + if (typeof commitResult === 'string') { + commitHash = commitResult.trim(); + } else if (commitResult && typeof commitResult === 'object' && 'commit' in commitResult) { + commitHash = String(commitResult.commit); + } else { + commitHash = await workGit.revparse(['HEAD']); + } + + // Save commit signature event if signing was used + if (signatureEvent && saveCommitSignature) { + try { + await saveCommitSignature(worktreePath, signatureEvent); + + // Publish to relays if repo is public + if (isRepoPrivate && !(await isRepoPrivate(npub, repoName))) { + try { + const { NostrClient } = await import('../../nostr/nostr-client.js'); + const { DEFAULT_NOSTR_RELAYS } = await import('../../../config.js'); + const { getUserRelays } = await import('../../nostr/user-relays.js'); + const { combineRelays } = await import('../../../config.js'); + const { nip19 } = await import('nostr-tools'); + const { requireNpubHex } = await import('../../../utils/npub-utils.js'); + + const userPubkeyHex = requireNpubHex(npub); + const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); + const { inbox, outbox } = await getUserRelays(userPubkeyHex, nostrClient); + + const userRelays = outbox.length > 0 + ? combineRelays(outbox, DEFAULT_NOSTR_RELAYS) + : inbox.length > 0 + ? combineRelays(inbox, DEFAULT_NOSTR_RELAYS) + : DEFAULT_NOSTR_RELAYS; + + const publishResult = await nostrClient.publishEvent(signatureEvent, userRelays); + if (publishResult.success.length > 0) { + logger.debug({ + eventId: signatureEvent.id, + commitHash, + relays: publishResult.success + }, 'Published commit signature event to relays'); + } + } catch (publishErr) { + logger.debug({ error: publishErr }, 'Failed to publish commit signature event to relays'); + } + } + } catch (err) { + logger.debug({ error: err }, 'Failed to save commit signature event'); + } + } + + logger.operation('File written', { npub, repoName, filePath, commitHash }); + } catch (error) { + logger.error({ error, repoPath, filePath, npub }, 'Error writing file'); + throw new Error(`Failed to write file: ${error instanceof Error ? error.message : String(error)}`); + } +} + +/** + * Delete a file + */ +export async function deleteFile(options: Omit): Promise { + const { + npub, + repoName, + filePath, + commitMessage, + authorName, + authorEmail, + branch = 'main', + repoPath, + worktreePath, + signingOptions, + saveCommitSignature + } = options; + + // Validate inputs + const npubValidation = validateNpub(npub); + if (!npubValidation.valid) { + throw new Error(`Invalid npub: ${npubValidation.error}`); + } + const repoValidation = validateRepoName(repoName); + if (!repoValidation.valid) { + throw new Error(`Invalid repository name: ${repoValidation.error}`); + } + + const pathValidation = validateFilePath(filePath); + if (!pathValidation.valid) { + throw new Error(`Invalid file path: ${pathValidation.error}`); + } + + if (!isValidBranchName(branch)) { + throw new Error(`Invalid branch name: ${branch}`); + } + + if (!commitMessage || typeof commitMessage !== 'string' || commitMessage.trim().length === 0) { + throw new Error('Commit message is required'); + } + + if (!authorName || typeof authorName !== 'string' || authorName.trim().length === 0) { + throw new Error('Author name is required'); + } + if (!authorEmail || typeof authorEmail !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(authorEmail)) { + throw new Error('Valid author email is required'); + } + + try { + logger.operation('Deleting file', { npub, repoName, filePath, branch }); + + const workGit: SimpleGit = simpleGit(worktreePath); + + // Remove the file + const validatedPath = pathValidation.normalized || filePath; + const fullFilePath = join(worktreePath, validatedPath); + + // Security: ensure resolved path is within workDir + const resolvedPath = resolve(fullFilePath).replace(/\\/g, '/'); + const resolvedWorkDir = resolve(worktreePath).replace(/\\/g, '/'); + if (!resolvedPath.startsWith(resolvedWorkDir + '/') && resolvedPath !== resolvedWorkDir) { + throw new Error('Path validation failed: resolved path outside work directory'); + } + + const { accessSync, constants, unlink } = await import('fs'); + try { + accessSync(fullFilePath, constants.F_OK); + await unlink(fullFilePath); + } catch { + // File doesn't exist, that's fine - git rm will handle it + } + + // Stage the deletion + await workGit.rm([validatedPath]); + + // Sign commit if signing options are provided + let finalCommitMessage = commitMessage; + if (signingOptions && (signingOptions.commitSignatureEvent || signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) { + try { + const { signedMessage } = await createGitCommitSignature( + commitMessage, + authorName, + authorEmail, + signingOptions + ); + finalCommitMessage = signedMessage; + } catch (err) { + const sanitizedErr = sanitizeError(err); + logger.warn({ error: sanitizedErr, repoPath, filePath }, 'Failed to sign commit'); + } + } + + // Commit + await workGit.commit(finalCommitMessage, [filePath], { + '--author': `${authorName} <${authorEmail}>` + }); + + logger.operation('File deleted', { npub, repoName, filePath }); + } catch (error) { + logger.error({ error, repoPath, filePath, npub }, 'Error deleting file'); + throw new Error(`Failed to delete file: ${error instanceof Error ? error.message : String(error)}`); + } +} diff --git a/src/lib/services/logger.ts b/src/lib/services/logger.ts index a1da830..47dd225 100644 --- a/src/lib/services/logger.ts +++ b/src/lib/services/logger.ts @@ -1,30 +1,132 @@ /** - * Pino logger service - * Provides structured logging with pino-pretty for development + * Enhanced logging service with better console output and noise reduction + * Provides structured logging with pino for production, enhanced console for development * Browser-safe: falls back to console in browser environments */ import type { Logger } from '../types/logger.js'; -function createConsoleLogger(): Logger { - return { - info: (...args: unknown[]) => console.log('[INFO]', ...args), - error: (...args: unknown[]) => console.error('[ERROR]', ...args), - warn: (...args: unknown[]) => console.warn('[WARN]', ...args), - debug: (...args: unknown[]) => console.debug('[DEBUG]', ...args), - trace: (...args: unknown[]) => console.trace('[TRACE]', ...args), - fatal: (...args: unknown[]) => console.error('[FATAL]', ...args) +export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'; + +interface LogContext { + [key: string]: unknown; +} + +interface EnhancedLogger extends Logger { + logWithContext(level: LogLevel, message: string, context?: LogContext): void; + performance(label: string, fn: () => Promise | unknown): Promise; + security(action: string, context?: LogContext): void; + operation(operation: string, context?: LogContext): void; +} + +function formatContext(context?: LogContext): string { + if (!context || Object.keys(context).length === 0) return ''; + try { + return ' ' + JSON.stringify(context, null, 0); + } catch { + return ' [context serialization failed]'; + } +} + +function shouldLog(level: LogLevel, minLevel: LogLevel = 'info'): boolean { + const levels: LogLevel[] = ['trace', 'debug', 'info', 'warn', 'error', 'fatal']; + return levels.indexOf(level) >= levels.indexOf(minLevel); +} + +function createConsoleLogger(): EnhancedLogger { + const minLevel = (typeof process !== 'undefined' && process.env?.LOG_LEVEL as LogLevel) || 'info'; + const isDev = typeof process !== 'undefined' && process.env?.NODE_ENV === 'development'; + + const baseLogger = { + info: (message: string, ...args: unknown[]) => { + if (shouldLog('info', minLevel)) { + console.log(`[INFO] ${message}`, ...args); + } + }, + error: (message: string, ...args: unknown[]) => { + if (shouldLog('error', minLevel)) { + console.error(`[ERROR] ${message}`, ...args); + } + }, + warn: (message: string, ...args: unknown[]) => { + if (shouldLog('warn', minLevel)) { + console.warn(`[WARN] ${message}`, ...args); + } + }, + debug: (message: string, ...args: unknown[]) => { + if (shouldLog('debug', minLevel)) { + console.debug(`[DEBUG] ${message}`, ...args); + } + }, + trace: (message: string, ...args: unknown[]) => { + if (shouldLog('trace', minLevel)) { + console.trace(`[TRACE] ${message}`, ...args); + } + }, + fatal: (message: string, ...args: unknown[]) => { + console.error(`[FATAL] ${message}`, ...args); + }, + logWithContext: (level: LogLevel, message: string, context?: LogContext) => { + if (!shouldLog(level, minLevel)) return; + const contextStr = formatContext(context); + const timestamp = new Date().toISOString(); + const prefix = `[${timestamp}] [${level.toUpperCase()}]`; + + switch (level) { + case 'error': + case 'fatal': + console.error(`${prefix} ${message}${contextStr}`); + break; + case 'warn': + console.warn(`${prefix} ${message}${contextStr}`); + break; + case 'debug': + case 'trace': + console.debug(`${prefix} ${message}${contextStr}`); + break; + default: + console.log(`${prefix} ${message}${contextStr}`); + } + }, + performance: async (label: string, fn: () => Promise | unknown) => { + const start = performance.now(); + try { + const result = await fn(); + const duration = performance.now() - start; + if (duration > 100 || isDev) { + console.log(`[PERF] ${label} took ${duration.toFixed(2)}ms`); + } + return result; + } catch (error) { + const duration = performance.now() - start; + console.error(`[PERF] ${label} failed after ${duration.toFixed(2)}ms:`, error); + throw error; + } + }, + security: (action: string, context?: LogContext) => { + const contextStr = formatContext(context); + const timestamp = new Date().toISOString(); + console.warn(`[SECURITY] [${timestamp}] ${action}${contextStr}`); + }, + operation: (operation: string, context?: LogContext) => { + if (isDev || shouldLog('info', minLevel)) { + const contextStr = formatContext(context); + const timestamp = new Date().toISOString(); + console.log(`[OP] [${timestamp}] ${operation}${contextStr}`); + } + } }; + + return baseLogger as EnhancedLogger; } // Check if we're in a Node.js environment const isNode = typeof process !== 'undefined' && process.versions?.node; -let logger: Logger; +let logger: EnhancedLogger; if (isNode) { - // Server-side: use pino - // Use dynamic import to avoid bundling for browser + // Server-side: use pino with enhanced console output const initPino = async () => { try { const pinoModule = await import('pino'); @@ -32,7 +134,7 @@ if (isNode) { const logLevel = (typeof process !== 'undefined' && process.env?.LOG_LEVEL) || 'info'; const isDev = typeof process !== 'undefined' && process.env?.NODE_ENV === 'development'; - return pino({ + const pinoLogger = pino({ level: logLevel, ...(isDev && { transport: { @@ -40,11 +142,47 @@ if (isNode) { options: { colorize: true, translateTime: 'HH:MM:ss Z', - ignore: 'pid,hostname' + ignore: 'pid,hostname', + singleLine: false } } }) }); + + // Enhance pino logger with console methods + const enhanced = createConsoleLogger(); + return { + ...pinoLogger, + logWithContext: enhanced.logWithContext, + performance: enhanced.performance, + security: enhanced.security, + operation: enhanced.operation, + // Override pino methods to also log to console in dev + info: (obj: unknown, msg?: string, ...args: unknown[]) => { + pinoLogger.info(obj, msg, ...args); + if (isDev) enhanced.info(typeof msg === 'string' ? msg : String(obj), ...args); + }, + error: (obj: unknown, msg?: string, ...args: unknown[]) => { + pinoLogger.error(obj, msg, ...args); + enhanced.error(typeof msg === 'string' ? msg : String(obj), ...args); + }, + warn: (obj: unknown, msg?: string, ...args: unknown[]) => { + pinoLogger.warn(obj, msg, ...args); + if (isDev) enhanced.warn(typeof msg === 'string' ? msg : String(obj), ...args); + }, + debug: (obj: unknown, msg?: string, ...args: unknown[]) => { + pinoLogger.debug(obj, msg, ...args); + if (isDev) enhanced.debug(typeof msg === 'string' ? msg : String(obj), ...args); + }, + trace: (obj: unknown, msg?: string, ...args: unknown[]) => { + pinoLogger.trace(obj, msg, ...args); + if (isDev) enhanced.trace(typeof msg === 'string' ? msg : String(obj), ...args); + }, + fatal: (obj: unknown, msg?: string, ...args: unknown[]) => { + pinoLogger.fatal(obj, msg, ...args); + enhanced.fatal(typeof msg === 'string' ? msg : String(obj), ...args); + } + } as EnhancedLogger; } catch { return createConsoleLogger(); } @@ -62,8 +200,8 @@ if (isNode) { // Keep console logger if pino fails }); } else { - // Browser-side: use console with similar API + // Browser-side: use enhanced console logger = createConsoleLogger(); } -export default logger; +export default logger as EnhancedLogger; diff --git a/src/lib/utils/git-process.ts b/src/lib/utils/git-process.ts new file mode 100644 index 0000000..eb82ffd --- /dev/null +++ b/src/lib/utils/git-process.ts @@ -0,0 +1,177 @@ +/** + * Utility functions for safely spawning git processes + * Prevents zombie processes by ensuring proper cleanup + */ + +import { spawn, type ChildProcess } from 'child_process'; +import logger from '../services/logger.js'; + +export interface GitProcessOptions { + cwd?: string; + env?: Record; + timeoutMs?: number; + stdio?: ('ignore' | 'pipe')[]; +} + +export interface GitProcessResult { + stdout: string; + stderr: string; + code: number | null; + signal: NodeJS.Signals | null; +} + +/** + * Safely spawn a git process with proper cleanup to prevent zombies + * + * @param args - Git command arguments + * @param options - Process options + * @returns Promise that resolves with process output + */ +export function spawnGitProcess( + args: string[], + options: GitProcessOptions = {} +): Promise { + const { + cwd, + env = {}, + timeoutMs = 30 * 60 * 1000, // 30 minutes default + stdio = ['ignore', 'pipe', 'pipe'] + } = options; + + return new Promise((resolve, reject) => { + const gitProcess = spawn('git', args, { + cwd, + env: Object.keys(env).length > 0 ? env : undefined, + stdio, + detached: false // Keep in same process group to prevent zombies + }); + + let stdout = ''; + let stderr = ''; + let resolved = false; + + // Set timeout to prevent hanging processes + const timeoutId = timeoutMs > 0 ? setTimeout(() => { + if (!resolved && !gitProcess.killed) { + resolved = true; + logger.warn({ args, timeoutMs }, 'Git process timeout, killing process'); + + // Kill the process tree to prevent zombies + try { + gitProcess.kill('SIGTERM'); + // Force kill after grace period + const forceKillTimeout = setTimeout(() => { + if (gitProcess.pid && !gitProcess.killed) { + try { + gitProcess.kill('SIGKILL'); + } catch (err) { + logger.warn({ err, pid: gitProcess.pid }, 'Failed to force kill git process'); + } + } + }, 5000); + + // Clear force kill timeout if process terminates + gitProcess.once('close', () => { + clearTimeout(forceKillTimeout); + }); + } catch (err) { + logger.warn({ err }, 'Error killing timed out git process'); + } + + reject(new Error(`Git command timeout after ${timeoutMs}ms: ${args.join(' ')}`)); + } + }, timeoutMs) : null; + + // Collect stdout + if (gitProcess.stdout) { + gitProcess.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); + } + + // Collect stderr + if (gitProcess.stderr) { + gitProcess.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + } + + // Handle process close (main cleanup point) + gitProcess.on('close', (code, signal) => { + if (timeoutId) clearTimeout(timeoutId); + + if (resolved) return; + resolved = true; + + // Ensure process is fully cleaned up + if (gitProcess.pid) { + try { + // Check if process still exists (this helps ensure cleanup) + process.kill(gitProcess.pid, 0); + } catch { + // Process already dead, that's fine + } + } + + resolve({ + stdout, + stderr, + code, + signal + }); + }); + + // Handle process errors + gitProcess.on('error', (err) => { + if (timeoutId) clearTimeout(timeoutId); + + if (resolved) return; + resolved = true; + + logger.error({ err, args }, 'Git process error'); + reject(err); + }); + + // Handle process exit (backup cleanup) + gitProcess.on('exit', (code, signal) => { + // This is primarily handled by 'close' event + // But we ensure we catch all cases + if (!resolved && code !== null && code !== 0) { + if (timeoutId) clearTimeout(timeoutId); + resolved = true; + + const errorMsg = signal + ? `Git command terminated by signal ${signal}: ${stderr || stdout}` + : `Git command failed with code ${code}: ${stderr || stdout}`; + + reject(new Error(errorMsg)); + } + }); + }); +} + +/** + * Safely spawn a git process and throw on non-zero exit code + * + * @param args - Git command arguments + * @param options - Process options + * @returns Promise that resolves with stdout/stderr only on success + */ +export async function execGitProcess( + args: string[], + options: GitProcessOptions = {} +): Promise<{ stdout: string; stderr: string }> { + const result = await spawnGitProcess(args, options); + + if (result.code !== 0) { + const errorMsg = result.signal + ? `Git command terminated by signal ${result.signal}: ${result.stderr || result.stdout}` + : `Git command failed with code ${result.code}: ${result.stderr || result.stdout}`; + throw new Error(errorMsg); + } + + return { + stdout: result.stdout, + stderr: result.stderr + }; +} diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index 263adb5..3213ab3 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -10,6 +10,12 @@ import TabsMenu from '$lib/components/TabsMenu.svelte'; import NostrLinkRenderer from '$lib/components/NostrLinkRenderer.svelte'; import TagsTab from './components/TagsTab.svelte'; + import FilesTab from './components/FilesTab.svelte'; + import HistoryTab from './components/HistoryTab.svelte'; + import IssuesTab from './components/IssuesTab.svelte'; + import PRsTab from './components/PRsTab.svelte'; + import PatchesTab from './components/PatchesTab.svelte'; + import DocsTab from './components/DocsTab.svelte'; import { downloadRepository as downloadRepoUtil } from './utils/download.js'; import { buildApiHeaders } from './utils/api-client.js'; import '$lib/styles/repo.css'; @@ -5844,182 +5850,95 @@ {:else}
- + {#if activeTab === 'files' && canViewRepo} - + { + currentPath = path; + loadFiles(path); + }} + onNavigateBack={handleBack} + onContentChange={(content) => { + editedContent = content; + hasChanges = content !== fileContent; + }} + {isMaintainer} + readmeContent={readmeContent || null} + readmePath={readmePath || null} + {readmeHtml} + {showFilePreview} + {fileHtml} + {highlightedFileContent} + {isImageFile} + {imageUrl} + {wordWrap} + {supportsPreview} + onSave={() => { + if (!userPubkey || !isMaintainer || needsClone) return; + showCommitDialog = true; + }} + onTogglePreview={() => { + showFilePreview = !showFilePreview; + if (!showFilePreview && fileContent && currentFile) { + const ext = currentFile.split('.').pop() || ''; + applySyntaxHighlighting(fileContent, ext).catch(err => console.error('Error applying syntax highlighting:', err)); + } + }} + onCopyFileContent={copyFileContent} + onDownloadFile={downloadFile} + {copyingFile} + {saving} + {needsClone} + {cloneTooltip} + {branches} + {currentBranch} + {defaultBranch} + onBranchChange={(branch) => { + currentBranch = branch; + handleBranchChangeDirect(branch); + }} + {userPubkey} + /> {/if} - + {#if activeTab === 'history' && canViewRepo} - + { + selectedCommit = hash; + viewDiff(hash); + }} + onVerify={async (hash) => { + verifyingCommits.add(hash); + try { + // Trigger verification logic - find the commit and verify + const commit = commits.find(c => (c.hash || (c as any).sha) === hash); + if (commit) { + await verifyCommit(hash); + } + } finally { + verifyingCommits.delete(hash); + } + }} + {verifyingCommits} + {showDiff} + {diffData} + /> {/if} @@ -6073,166 +5992,137 @@ {/if} - + {#if activeTab === 'issues'} - + { + selectedIssue = id; + loadIssueReplies(id); + }} + onStatusUpdate={async (id, status) => { + // Find issue and update status + const issue = issues.find(i => i.id === id); + if (issue) { + await updateIssueStatus(id, issue.author, status as 'open' | 'closed' | 'resolved' | 'draft'); + await loadIssues(); + } + }} + {issueReplies} + loadingReplies={loadingIssueReplies} + /> {/if} - + {#if activeTab === 'prs'} - + { + selectedPR = id; + }} + onStatusUpdate={async (id, status) => { + // Find PR and update status - similar to updateIssueStatus + const pr = prs.find(p => p.id === id); + if (pr && userPubkeyHex) { + // Check if user is maintainer or PR author + const isAuthor = userPubkeyHex === pr.author; + if (!isMaintainer && !isAuthor) { + alert('Only repository maintainers or PR authors can update PR status'); + return; + } + + try { + const response = await fetch(`/api/repos/${npub}/${repo}/prs`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prId: id, + prAuthor: pr.author, + status + }) + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to update PR status'); + } + + await loadPRs(); + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to update PR status'; + console.error('Error updating PR status:', err); + } + } + }} + /> {/if} - + {#if activeTab === 'patches'} - + { + selectedPatch = id; + }} + onApply={async (id) => { + applying[id] = true; + try { + const patch = patches.find(p => p.id === id); + if (!patch) { + throw new Error('Patch not found'); + } + + if (!userPubkey || !isMaintainer || needsClone) { + alert('Only maintainers can apply patches'); + return; + } + + if (!confirm('Apply this patch to the repository? This will create a commit with the patch changes.')) { + return; + } + + const authorEmail = await fetchUserEmail(); + const authorName = await fetchUserName(); + + const response = await fetch(`/api/repos/${npub}/${repo}/patches/${id}/apply`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...buildApiHeaders() + }, + body: JSON.stringify({ + commitMessage: `Apply patch ${id.slice(0, 8)}: ${patch.subject}`, + authorName, + authorEmail, + branch: currentBranch || defaultBranch || 'main' + }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || 'Failed to apply patch'); + } + + await loadPatches(); + alert('Patch applied successfully!'); + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to apply patch'; + console.error('Error applying patch:', err); + } finally { + applying[id] = false; + } + }} + {applying} + /> {/if} @@ -6298,271 +6188,19 @@ {/if} - + {#if activeTab === 'docs'} - + {/if} - -
- {#if activeTab === 'files' && readmeContent && !currentFile} -
-
-

README

-
- {#if readmePath && supportsPreview((readmePath.split('.').pop() || '').toLowerCase())} - - {/if} - View Raw - - -
-
- {#if loadingReadme} -
Loading README...
- {:else if showFilePreview && readmeHtml && readmeHtml.trim()} -
- {@html readmeHtml} -
- {:else if readmeContent} -
-
{readmeContent}
-
- {/if} -
- {/if} - - {#if activeTab === 'files' && currentFile} -
- {currentFile} -
- {#if branches.length > 0 && isMaintainer} - - {:else if currentBranch && isMaintainer} - {currentBranch} - {/if} - {#if hasChanges} - ● Unsaved changes - {/if} - {#if currentFile && supportsPreview((currentFile.split('.').pop() || '').toLowerCase()) && !isMaintainer} - - {/if} - {#if currentFile && fileContent} - - - {/if} - {#if isMaintainer} - - {:else if userPubkey} - Only maintainers can edit files. Submit a PR instead. - {/if} - -
-
- - {#if loading} -
Loading file...
- {:else} -
- {#if isMaintainer} - - {:else} -
- {#if isImageFile && imageUrl} - -
- {currentFile?.split('/').pop() -
- {:else if currentFile && showFilePreview && fileHtml && supportsPreview((currentFile.split('.').pop() || '').toLowerCase())} - -
- {@html fileHtml} -
- {:else if highlightedFileContent} - - {@html highlightedFileContent} - {:else} - -
{fileContent}
- {/if} -
- {/if} -
- {/if} - {:else if activeTab === 'files'} -
- -
-
-

Select a file from the sidebar to view and edit it

-
- {/if} + - {#if activeTab === 'history'} -
-
- -
- {#if selectedCommit} - {@const commit = commits.find(c => (c.hash || (c as any).sha) === selectedCommit)} - {#if commit} -
-
-

{commit.message || 'No message'}

- -
-
- #{selectedCommit.slice(0, 7)} - {commit.author || 'Unknown'} - {commit.date ? new Date(commit.date).toLocaleString() : 'Unknown date'} -
- {#if loadingCommits} -
Loading diff...
- {:else if showDiff && diffData.length > 0} -
- {#each diffData as diff} -
-
- {diff.file} - - +{diff.additions} - -{diff.deletions} - -
-
{diff.diff}
-
- {/each} -
- {:else if showDiff} -
-

No diff data available

-
- {:else} -
-

Loading diff...

-
- {/if} -
- {/if} - {:else} -
-

Select a commit from the sidebar to view details

-
- {/if} -
- {/if} + @@ -6628,268 +6266,11 @@
{/if} - {#if activeTab === 'issues'} -
-
- -
- {#if loadingIssues} -
-

Loading issues...

-
- {:else if error} -
-

Error loading issues: {error}

- -
- {:else if issues.length === 0} -
-

No issues found. Create one to get started!

-
- {:else if selectedIssue} - {@const issue = issues.find(i => i.id === selectedIssue)} - {#if issue} -
-
-

{issue.subject}

- {#if userPubkeyHex && (isMaintainer || userPubkeyHex === repoOwnerPubkeyDerived || userPubkeyHex === issue.author)} - - {:else} - - {issue.status} - - {/if} -
-
- #{issue.id.slice(0, 7)} - Created {new Date(issue.created_at * 1000).toLocaleString()} - -
-
- {@html issue.content.replace(/\n/g, '
')} -
- - -
-

Replies ({issueReplies.length})

- {#if loadingIssueReplies} -
Loading replies...
- {:else if issueReplies.length === 0} -
-

No replies yet.

-
- {:else} - {#each issueReplies as reply} -
-
- - {new Date(reply.created_at * 1000).toLocaleString()} - -
-
- {@html reply.content.replace(/\n/g, '
')} -
-
- {/each} - {/if} -
-
- {/if} - {:else} -
-

Select an issue to view details

-
- {/if} -
- {/if} + - {#if activeTab === 'prs'} -
-
- -
- {#if prs.length === 0} -
-

No pull requests found. Create one to get started!

-
- {:else if selectedPR} - {#each prs.filter(p => p.id === selectedPR) as pr} - {@const decoded = nip19.decode(npub)} - {#if decoded.type === 'npub'} - {@const repoOwnerPubkey = decoded.data as string} - - - {/if} - {/each} - {:else} - {#each prs as pr} -
selectedPR = pr.id} - onkeydown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - selectedPR = pr.id; - } - }} - style="cursor: pointer;"> -

{pr.subject}

-
- - {pr.status} - - {#if pr.commitId} - Commit: {pr.commitId.slice(0, 7)} - {/if} - Created {new Date(pr.created_at * 1000).toLocaleString()} -
-
- {@html pr.content.replace(/\n/g, '
')} -
-
- {/each} - {/if} -
- {/if} + - {#if activeTab === 'patches'} -
-
- -
- {#if patches.length === 0} -
-

No patches found. Create one to get started!

-
- {:else if selectedPatch} - {#each patches.filter(p => p.id === selectedPatch) as patch} -
-
-

{patch.subject}

- {#if userPubkey && (isMaintainer || userPubkeyHex === repoOwnerPubkeyDerived || userPubkeyHex === patch.author)} - - {:else} - - {patch.status} - - {/if} -
-
- #{patch.id.slice(0, 7)} - Created {new Date(patch.created_at * 1000).toLocaleString()} - - {#if (isMaintainer || userPubkeyHex === repoOwnerPubkeyDerived) && isRepoCloned} - - {/if} -
- {#if patch.description && patch.description !== patch.subject} -
{patch.description}
- {/if} -
-
- +
@@ -7236,34 +6617,7 @@
{/if} - {#if activeTab === 'docs'} -
-
- -
- {#if loadingDocs} -
Loading documentation...
- {:else if documentationHtml} -
- {@html documentationHtml} -
- {:else if documentationContent === null} -
-

No documentation found for this repository.

-
- {:else} -
-

Documentation content is empty.

-
- {/if} -
- {/if} +
{/if} diff --git a/src/routes/repos/[npub]/[repo]/components/CommitDialog.svelte b/src/routes/repos/[npub]/[repo]/components/CommitDialog.svelte new file mode 100644 index 0000000..09701a1 --- /dev/null +++ b/src/routes/repos/[npub]/[repo]/components/CommitDialog.svelte @@ -0,0 +1,119 @@ + + +{#if show} +