You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
2362 lines
91 KiB
2362 lines
91 KiB
/** |
|
* 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; |
|
} |
|
|
|
export class FileManager { |
|
private repoManager: RepoManager; |
|
private repoRoot: string; |
|
// Cache for directory existence checks (5 minute TTL) |
|
private dirExistenceCache: Map<string, { exists: boolean; timestamp: number }> = 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<typeof import('fs/promises')> { |
|
if (!this.fsPromises) { |
|
this.fsPromises = await import('fs/promises'); |
|
} |
|
return this.fsPromises; |
|
} |
|
|
|
/** |
|
* Lazy load fs module (cached after first load) |
|
*/ |
|
private async getFsSync(): Promise<typeof import('fs')> { |
|
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<boolean> { |
|
// 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<boolean> { |
|
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<void> { |
|
// 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<void> { |
|
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<string> { |
|
// 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<void>((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 <noreply@gitrepublic.com>' |
|
}); |
|
|
|
// 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<void>((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<string> => { |
|
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<void>((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<void>((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<void> { |
|
try { |
|
// Use spawn for worktree remove (safer than exec) |
|
await new Promise<void>((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<boolean>(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<FileEntry[]> { |
|
// 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<FileEntry[]>(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: <mode> <type> <object> <size>\t<file> |
|
// Note: git ls-tree uses a tab character between size and filename |
|
// The format is: mode type object size<TAB>path |
|
// 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<FileContent> { |
|
// 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<void> { |
|
// 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; |
|
} |
|
|
|
// Update signature event with actual commit hash |
|
const { updateCommitSignatureWithHash } = await import('./commit-signer.js'); |
|
const updatedEvent = updateCommitSignatureWithHash(signatureEvent, commitHash); |
|
|
|
// Save to nostr/commit-signatures.jsonl (use workDir since we have it) |
|
await this.saveCommitSignatureEventToWorktree(workDir, updatedEvent); |
|
|
|
// 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(updatedEvent, userRelays); |
|
if (publishResult.success.length > 0) { |
|
logger.debug({ |
|
eventId: updatedEvent.id, |
|
commitHash, |
|
relays: publishResult.success |
|
}, 'Published commit signature event to relays'); |
|
} |
|
if (publishResult.failed.length > 0) { |
|
logger.warn({ |
|
eventId: updatedEvent.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<void> { |
|
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<boolean> { |
|
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<boolean> { |
|
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<string> { |
|
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<string[]> { |
|
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<string[]>(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<string>(); |
|
|
|
// 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 |
|
} |
|
} |
|
|
|
const branchList = Array.from(allBranches).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<void> { |
|
// 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<void> { |
|
// 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<void> { |
|
// 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 <noreply@gitrepublic.com>' |
|
}); |
|
} else { |
|
await workGit.commit(commitMessage, ['--allow-empty'], { |
|
'--author': 'GitRepublic <noreply@gitrepublic.com>' |
|
}); |
|
} |
|
|
|
// 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<void> { |
|
// 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<Commit[]> { |
|
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<Diff[]> { |
|
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<void> { |
|
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<Tag[]> { |
|
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 { |
|
// Try to get tag message |
|
const tagInfo = await git.raw(['cat-file', '-p', tagName]); |
|
const messageMatch = tagInfo.match(/^(.+)$/m); |
|
const hash = await git.raw(['rev-parse', tagName]); |
|
|
|
tagList.push({ |
|
name: tagName, |
|
hash: hash.trim(), |
|
message: messageMatch ? messageMatch[1] : undefined |
|
}); |
|
} catch { |
|
// Lightweight tag |
|
const hash = await git.raw(['rev-parse', tagName]); |
|
tagList.push({ |
|
name: tagName, |
|
hash: hash.trim() |
|
}); |
|
} |
|
} |
|
|
|
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<string | null> { |
|
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; |
|
} |
|
} |
|
}
|
|
|