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

/**
* 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;
}
}
}