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.
784 lines
30 KiB
784 lines
30 KiB
/** |
|
* File Manager - Core service for git repository operations |
|
* Handles branches, files, commits, tags, and worktrees |
|
*/ |
|
|
|
import { join, resolve } from 'path'; |
|
import simpleGit, { type SimpleGit } from 'simple-git'; |
|
import { RepoManager } from './repo-manager.js'; |
|
import logger from '$lib/services/logger.js'; |
|
import { isValidBranchName } from '$lib/utils/security.js'; |
|
import { repoCache, RepoCache } from './repo-cache.js'; |
|
|
|
// Import modular operations |
|
import { getOrCreateWorktree, removeWorktree } from './file-manager/worktree-manager.js'; |
|
import { validateFilePath, validateRepoName, validateNpub } from './file-manager/path-validator.js'; |
|
import { listFiles, getFileContent } from './file-manager/file-operations.js'; |
|
import { getBranches as getBranchesModule } from './file-manager/branch-operations.js'; |
|
import { writeFile, deleteFile } from './file-manager/write-operations.js'; |
|
import { getCommitHistory, getDiff } from './file-manager/commit-operations.js'; |
|
import { createTag, getTags } from './file-manager/tag-operations.js'; |
|
|
|
// Type definitions |
|
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 fsPromises: typeof import('fs/promises') | null = null; |
|
|
|
constructor(repoRoot: string = '/repos') { |
|
this.repoRoot = repoRoot; |
|
this.repoManager = new RepoManager(repoRoot); |
|
} |
|
|
|
private async getFsPromises(): Promise<typeof import('fs/promises')> { |
|
if (!this.fsPromises) { |
|
this.fsPromises = await import('fs/promises'); |
|
} |
|
return this.fsPromises; |
|
} |
|
|
|
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<boolean>(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<string> { |
|
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<void> { |
|
return removeWorktree(repoPath, worktreePath); |
|
} |
|
|
|
async listFiles(npub: string, repoName: string, ref: string = 'HEAD', path: string = ''): Promise<FileEntry[]> { |
|
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<FileContent> { |
|
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<void> { |
|
const repoPath = this.getRepoPath(npub, repoName); |
|
if (!this.repoExists(npub, repoName)) { |
|
throw new Error('Repository not found'); |
|
} |
|
|
|
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); |
|
|
|
const saveCommitSignature = async (worktreePath: string, event: any) => { |
|
await this.saveCommitSignatureEventToWorktree(worktreePath, event); |
|
}; |
|
|
|
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<void> { |
|
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<void> { |
|
return this.writeFile(npub, repoName, filePath, content, commitMessage, authorName, authorEmail, branch, signingOptions); |
|
} |
|
|
|
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 { |
|
const defaultRef = await git.raw(['symbolic-ref', 'HEAD']); |
|
if (defaultRef) { |
|
const match = defaultRef.trim().match(/^refs\/heads\/(.+)$/); |
|
if (match) return match[1]; |
|
} |
|
} catch { |
|
// HEAD doesn't point to a branch, try remote |
|
} |
|
|
|
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 { |
|
// No remote HEAD |
|
} |
|
|
|
// Try to get branches and find main/master |
|
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<string[]> { |
|
const repoPath = this.getRepoPath(npub, repoName); |
|
if (!this.repoExists(npub, repoName)) { |
|
throw new Error('Repository not found'); |
|
} |
|
return getBranchesModule({ |
|
npub, |
|
repoName, |
|
repoPath, |
|
getDefaultBranch: (npub: string, repoName: string) => this.getDefaultBranch(npub, repoName) |
|
}); |
|
} |
|
|
|
/** |
|
* Create a branch in a repository |
|
* Handles empty repositories by creating orphan branches |
|
*/ |
|
async createBranch( |
|
npub: string, |
|
repoName: string, |
|
branchName: string, |
|
fromBranch?: string |
|
): Promise<void> { |
|
logger.info({ npub, repoName, branchName, fromBranch }, '[FileManager.createBranch] START - called with parameters'); |
|
|
|
const repoPath = this.getRepoPath(npub, repoName); |
|
logger.info({ npub, repoName, repoPath }, '[FileManager.createBranch] Repository path resolved'); |
|
|
|
if (!this.repoExists(npub, repoName)) { |
|
logger.error({ npub, repoName, repoPath }, '[FileManager.createBranch] Repository does not exist'); |
|
throw new Error('Repository not found'); |
|
} |
|
logger.info({ npub, repoName }, '[FileManager.createBranch] Repository exists confirmed'); |
|
|
|
if (!isValidBranchName(branchName)) { |
|
logger.error({ npub, repoName, branchName }, '[FileManager.createBranch] Invalid branch name'); |
|
throw new Error(`Invalid branch name: ${branchName}`); |
|
} |
|
logger.info({ npub, repoName, branchName }, '[FileManager.createBranch] Branch name validated'); |
|
|
|
const git: SimpleGit = simpleGit(repoPath); |
|
logger.info({ npub, repoName, repoPath }, '[FileManager.createBranch] Git instance created'); |
|
|
|
try { |
|
// Check if repository has any commits - use multiple methods for reliability |
|
let hasCommits = false; |
|
let commitCount = 0; |
|
try { |
|
// Method 1: rev-list count |
|
const commitCountStr = await git.raw(['rev-list', '--count', '--all']); |
|
commitCount = parseInt(commitCountStr.trim(), 10); |
|
hasCommits = !isNaN(commitCount) && commitCount > 0; |
|
|
|
logger.info({ npub, repoName, branchName, fromBranch, commitCount, hasCommits }, '[FileManager] createBranch - rev-list result'); |
|
|
|
// Method 2: Verify by checking if any refs exist |
|
if (hasCommits) { |
|
try { |
|
const refs = await git.raw(['for-each-ref', '--count=1', 'refs/heads/']); |
|
if (!refs || refs.trim().length === 0) { |
|
hasCommits = false; |
|
logger.warn({ npub, repoName }, '[FileManager] No refs found despite commit count, treating as empty'); |
|
} |
|
} catch (refError) { |
|
hasCommits = false; |
|
logger.warn({ npub, repoName, error: refError }, '[FileManager] Failed to check refs, treating as empty'); |
|
} |
|
} |
|
} catch (revListError) { |
|
// rev-list fails for empty repos - this is expected |
|
hasCommits = false; |
|
logger.info({ npub, repoName, error: revListError }, '[FileManager] rev-list failed (empty repo expected)'); |
|
} |
|
|
|
// CRITICAL SAFETY: Use local variable and clear it if no commits |
|
let sourceBranch: string | undefined = fromBranch; |
|
|
|
// CRITICAL SAFETY: If fromBranch is 'master' or 'main' and we have no commits, clear it |
|
if ((sourceBranch === 'master' || sourceBranch === 'main') && !hasCommits) { |
|
logger.error({ npub, repoName, sourceBranch, hasCommits }, '[FileManager] ERROR: sourceBranch is master/main but no commits! Clearing it.'); |
|
sourceBranch = undefined; |
|
} |
|
|
|
// CRITICAL: If no commits, ALWAYS clear sourceBranch |
|
if (!hasCommits) { |
|
sourceBranch = undefined; |
|
logger.info({ npub, repoName }, '[FileManager] No commits - forcing sourceBranch to undefined'); |
|
} |
|
|
|
logger.info({ npub, repoName, branchName, sourceBranch, fromBranch, hasCommits, commitCount }, '[FileManager] createBranch - final values before branch creation'); |
|
|
|
// CRITICAL: If repo has no commits, ALWAYS create orphan branch (completely ignore sourceBranch) |
|
if (!hasCommits) { |
|
logger.info({ npub, repoName, branchName, sourceBranch, fromBranch, hasCommits }, '[FileManager.createBranch] PATH: Creating orphan branch (no commits)'); |
|
// For empty repos, we need to create an empty commit first, then create the branch |
|
// This is the only way git will recognize the branch |
|
try { |
|
// Fetch user profile to get author name and email |
|
let authorName = 'GitRepublic User'; |
|
let authorEmail = 'gitrepublic@gitrepublic.web'; |
|
try { |
|
const { requireNpubHex } = await import('$lib/utils/npub-utils.js'); |
|
const { fetchUserProfile, extractProfileData, getUserName, getUserEmail } = await import('$lib/utils/user-profile.js'); |
|
const { DEFAULT_NOSTR_RELAYS } = await import('$lib/config.js'); |
|
const userPubkeyHex = requireNpubHex(npub); |
|
const profileEvent = await fetchUserProfile(userPubkeyHex, DEFAULT_NOSTR_RELAYS); |
|
const profile = extractProfileData(profileEvent); |
|
authorName = getUserName(profile, userPubkeyHex, npub); |
|
authorEmail = getUserEmail(profile, userPubkeyHex, npub); |
|
logger.info({ npub, repoName, authorName, authorEmail }, '[FileManager.createBranch] Fetched user profile for author identity'); |
|
} catch (profileError) { |
|
logger.warn({ npub, repoName, error: profileError }, '[FileManager.createBranch] Failed to fetch user profile, using defaults'); |
|
} |
|
|
|
// Set git config for user.name and user.email in this repository |
|
// This is required for commit-tree to work without errors |
|
try { |
|
await git.addConfig('user.name', authorName, false, 'local'); |
|
await git.addConfig('user.email', authorEmail, false, 'local'); |
|
logger.info({ npub, repoName, authorName, authorEmail }, '[FileManager.createBranch] Set git config for user.name and user.email'); |
|
} catch (configError) { |
|
logger.warn({ npub, repoName, error: configError }, '[FileManager.createBranch] Failed to set git config, will use --author flag'); |
|
} |
|
|
|
logger.info({ npub, repoName, branchName, repoPath }, '[FileManager.createBranch] Step 1: Creating empty tree for initial commit'); |
|
// Create empty tree object - empty tree hash is always the same: 4b825dc642cb6eb9a060e54bf8d69288fbee4904 |
|
// We'll use mktree to create it if needed |
|
let emptyTreeHash = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; |
|
// Ensure it exists in the repo |
|
try { |
|
await git.raw(['cat-file', '-e', emptyTreeHash]); |
|
logger.info({ npub, repoName, branchName }, '[FileManager.createBranch] Empty tree already exists in repo'); |
|
} catch { |
|
// Create it using mktree with empty input |
|
logger.info({ npub, repoName, branchName }, '[FileManager.createBranch] Creating empty tree object'); |
|
const { spawn } = await import('child_process'); |
|
const createdHash = await new Promise<string>((resolve, reject) => { |
|
const proc = spawn('git', ['hash-object', '-t', 'tree', '-w', '--stdin'], { cwd: repoPath }); |
|
proc.stdin.end(); |
|
let output = ''; |
|
proc.stdout.on('data', (data) => { output += data.toString(); }); |
|
proc.on('close', (code) => { |
|
if (code === 0) resolve(output.trim()); |
|
else reject(new Error(`hash-object failed with code ${code}`)); |
|
}); |
|
proc.on('error', reject); |
|
}); |
|
emptyTreeHash = createdHash || emptyTreeHash; |
|
logger.info({ npub, repoName, branchName, emptyTreeHash }, '[FileManager.createBranch] Created empty tree object'); |
|
} |
|
logger.info({ npub, repoName, branchName, emptyTreeHash }, '[FileManager.createBranch] Step 1 complete: empty tree ready'); |
|
|
|
logger.info({ npub, repoName, branchName }, '[FileManager.createBranch] Step 2: Creating empty commit'); |
|
// Create an empty commit pointing to the empty tree with author information |
|
// git commit-tree doesn't support --author flag, so we use environment variables |
|
const { spawn } = await import('child_process'); |
|
const commit = await new Promise<string>((resolve, reject) => { |
|
const env = { |
|
...process.env, |
|
GIT_AUTHOR_NAME: authorName, |
|
GIT_AUTHOR_EMAIL: authorEmail, |
|
GIT_COMMITTER_NAME: authorName, |
|
GIT_COMMITTER_EMAIL: authorEmail |
|
}; |
|
const proc = spawn('git', ['commit-tree', '-m', `Initial commit on ${branchName}`, emptyTreeHash], { |
|
cwd: repoPath, |
|
env |
|
}); |
|
let output = ''; |
|
proc.stdout.on('data', (data) => { output += data.toString(); }); |
|
proc.stderr.on('data', (data) => { |
|
const error = data.toString(); |
|
if (error.trim()) { |
|
logger.warn({ npub, repoName, branchName, error }, '[FileManager.createBranch] commit-tree stderr'); |
|
} |
|
}); |
|
proc.on('close', (code) => { |
|
if (code === 0) { |
|
resolve(output.trim()); |
|
} else { |
|
reject(new Error(`commit-tree failed with code ${code}: ${output || 'no output'}`)); |
|
} |
|
}); |
|
proc.on('error', reject); |
|
}); |
|
logger.info({ npub, repoName, branchName, commit }, '[FileManager.createBranch] Step 2 complete: empty commit created'); |
|
|
|
logger.info({ npub, repoName, branchName, commit }, '[FileManager.createBranch] Step 3: Creating branch ref pointing to empty commit'); |
|
// Create the branch ref pointing to the empty commit |
|
const updateRefResult = await git.raw(['update-ref', `refs/heads/${branchName}`, commit]); |
|
logger.info({ npub, repoName, branchName, commit, updateRefResult }, '[FileManager.createBranch] Step 3 complete: update-ref created branch'); |
|
|
|
logger.info({ npub, repoName, branchName }, '[FileManager.createBranch] Step 4: Setting HEAD to point to new branch'); |
|
// Set HEAD to point to the new branch |
|
const symRefResult = await git.raw(['symbolic-ref', 'HEAD', `refs/heads/${branchName}`]); |
|
logger.info({ npub, repoName, branchName, symRefResult }, '[FileManager.createBranch] Step 4 complete: symbolic-ref HEAD set'); |
|
|
|
// Verify the branch was created |
|
try { |
|
const branches = await git.branchLocal(); |
|
logger.info({ npub, repoName, branchName, branches: branches.all }, '[FileManager.createBranch] Verification: branch list after creation'); |
|
} catch (verifyErr) { |
|
logger.warn({ npub, repoName, branchName, error: verifyErr }, '[FileManager.createBranch] Warning: Could not verify branch in list'); |
|
} |
|
|
|
logger.info({ npub, repoName, branchName }, '[FileManager.createBranch] SUCCESS: Orphan branch created with empty commit'); |
|
} catch (orphanError) { |
|
logger.error({ |
|
error: orphanError, |
|
errorMessage: orphanError instanceof Error ? orphanError.message : String(orphanError), |
|
errorStack: orphanError instanceof Error ? orphanError.stack : undefined, |
|
npub, |
|
repoName, |
|
branchName, |
|
sourceBranch, |
|
fromBranch, |
|
hasCommits, |
|
repoPath |
|
}, '[FileManager.createBranch] ERROR: Failed to create orphan branch'); |
|
throw new Error(`Failed to create orphan branch: ${orphanError instanceof Error ? orphanError.message : String(orphanError)}`); |
|
} |
|
} else if (!sourceBranch) { |
|
// Repository has commits but no source branch - create orphan branch |
|
logger.info({ npub, repoName, branchName, hasCommits }, '[FileManager.createBranch] PATH: Creating orphan branch (has commits but no source branch)'); |
|
logger.info({ npub, repoName, branchName }, '[FileManager.createBranch] Setting HEAD to new branch'); |
|
await git.raw(['symbolic-ref', 'HEAD', `refs/heads/${branchName}`]); |
|
logger.info({ npub, repoName, branchName }, '[FileManager.createBranch] Creating branch'); |
|
await git.raw(['branch', branchName]); |
|
logger.info({ npub, repoName, branchName }, '[FileManager.createBranch] SUCCESS: Orphan branch created'); |
|
} else { |
|
// Repository has commits and source branch provided - verify it exists first |
|
logger.info({ npub, repoName, branchName, sourceBranch, hasCommits }, '[FileManager.createBranch] PATH: Creating branch from source'); |
|
try { |
|
logger.info({ npub, repoName, sourceBranch }, '[FileManager.createBranch] Verifying source branch exists'); |
|
// Verify the source branch exists |
|
const verifyResult = await git.raw(['rev-parse', '--verify', `refs/heads/${sourceBranch}`]); |
|
logger.info({ npub, repoName, sourceBranch, verifyResult }, '[FileManager.createBranch] Source branch verified, creating branch'); |
|
await git.raw(['branch', branchName, sourceBranch]); |
|
logger.info({ npub, repoName, branchName, sourceBranch }, '[FileManager.createBranch] SUCCESS: Branch created from source'); |
|
} catch (verifyError) { |
|
// Source branch doesn't exist - create orphan branch instead |
|
logger.warn({ |
|
npub, |
|
repoName, |
|
branchName, |
|
sourceBranch, |
|
error: verifyError, |
|
errorMessage: verifyError instanceof Error ? verifyError.message : String(verifyError) |
|
}, '[FileManager.createBranch] Source branch does not exist, creating orphan branch instead'); |
|
await git.raw(['symbolic-ref', 'HEAD', `refs/heads/${branchName}`]); |
|
await git.raw(['branch', branchName]); |
|
logger.info({ npub, repoName, branchName }, '[FileManager.createBranch] SUCCESS: Orphan branch created (fallback)'); |
|
} |
|
} |
|
|
|
// Clear branch cache |
|
const cacheKey = RepoCache.branchesKey(npub, repoName); |
|
repoCache.delete(cacheKey); |
|
logger.info({ npub, repoName, branchName }, '[FileManager.createBranch] Branch cache cleared'); |
|
} catch (error) { |
|
logger.error({ |
|
error, |
|
errorMessage: error instanceof Error ? error.message : String(error), |
|
errorStack: error instanceof Error ? error.stack : undefined, |
|
repoPath, |
|
branchName, |
|
fromBranch, |
|
npub, |
|
repoName |
|
}, '[FileManager.createBranch] ERROR: Exception caught in createBranch'); |
|
throw new Error(`Failed to create branch: ${error instanceof Error ? error.message : String(error)}`); |
|
} |
|
} |
|
|
|
async deleteBranch(npub: string, repoName: string, branchName: string): Promise<void> { |
|
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<Commit[]> { |
|
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<Diff[]> { |
|
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<void> { |
|
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<Tag[]> { |
|
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<void> { |
|
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<boolean> { |
|
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<boolean> { |
|
try { |
|
const { requireNpubHex } = await import('$lib/utils/npub-utils.js'); |
|
const repoOwnerPubkey = requireNpubHex(npub); |
|
const { NostrClient } = await import('$lib/services/nostr/nostr-client.js'); |
|
const { DEFAULT_NOSTR_RELAYS } = await import('$lib/config.js'); |
|
const { KIND } = await import('$lib/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('$lib/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<string | null> { |
|
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('$lib/services/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; |
|
} |
|
} |
|
}
|
|
|