From 87c514a3463fdd5c89b9d62c934c7048e9bdf6f8 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 23 Feb 2026 07:43:51 +0100 Subject: [PATCH] fix file management and refactor Nostr-Signature: 626196cdbf9eab28b44990706281878083d66983b503e8a81df7421054ed6caf 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 516c0001a800083411a1e04340e82116a82c975f38b984e92ebe021b61271ba7d6f645466ddba3594320c228193e708675a5d7a144b2f3d5e9bfbc65c4c7372b --- Dockerfile | 32 +- docker-compose.yml | 17 +- docker-entrypoint.sh | 75 +++++ nostr/commit-signatures.jsonl | 1 + src/lib/services/git/file-manager.ts | 305 ++++++++++++++++-- src/lib/services/git/repo-manager.ts | 4 +- .../repos/[npub]/[repo]/branches/+server.ts | 96 +++++- 7 files changed, 490 insertions(+), 40 deletions(-) create mode 100644 docker-entrypoint.sh diff --git a/Dockerfile b/Dockerfile index 6059d89..0027ffa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,7 +31,8 @@ FROM node:20-alpine # - git: for git operations and git-http-backend # - zip: for creating ZIP archives (download endpoint) # - util-linux: for whereis command (used to find git-http-backend) -RUN apk add --no-cache git zip util-linux +# - su-exec: for switching users (lightweight alternative to gosu) +RUN apk add --no-cache git zip util-linux su-exec # Create app directory WORKDIR /app @@ -56,13 +57,30 @@ RUN mkdir -p /app/logs && chmod 755 /app/logs # Create dedicated non-root user for gitrepublic # Using a dedicated user (not generic 'nodejs') is better security practice -RUN addgroup -g 1001 -S gitrepublic && \ - adduser -S gitrepublic -u 1001 -G gitrepublic && \ +# Use UID/GID 10000 to avoid conflicts with common system users (1000-9999) +# This can be overridden via build args if needed for specific deployments +ARG GITREPUBLIC_UID=10000 +ARG GITREPUBLIC_GID=10000 + +# Create gitrepublic group and user with standardized UID/GID +# Using 10000 avoids conflicts with common system users while being predictable +RUN addgroup -g $GITREPUBLIC_GID -S gitrepublic && \ + adduser -S gitrepublic -u $GITREPUBLIC_UID -G gitrepublic && \ chown -R gitrepublic:gitrepublic /app /repos /app/logs && \ - chown -R gitrepublic:gitrepublic /app/docs + chown -R gitrepublic:gitrepublic /app/docs && \ + echo "Created gitrepublic user (UID: $GITREPUBLIC_UID, GID: $GITREPUBLIC_GID)" + +# Copy entrypoint script +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# Create entrypoint wrapper that runs as root initially to fix permissions +# Then switches to gitrepublic user +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] -# Switch to non-root user -USER gitrepublic +# Note: We start as root to fix permissions, then switch to gitrepublic user +# This allows the entrypoint to fix permissions on mounted volumes +USER root # Expose port EXPOSE 6543 @@ -77,5 +95,5 @@ ENV PORT=6543 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD node -e "require('http').get('http://localhost:6543', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)}).on('error', () => {process.exit(1)})" -# Start the application +# Start the application (entrypoint will switch to gitrepublic user) CMD ["node", "build"] diff --git a/docker-compose.yml b/docker-compose.yml index ccb1fc9..10d327c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,12 @@ services: gitrepublic: - # For using a pre-built image (e.g., from local build or registry): + # OPTION 1: Use pre-built image from registry (recommended for production) + # Only requires: docker-compose.yml and ./repos directory + # No Dockerfile or docker-entrypoint.sh needed on the server image: ${DOCKER_IMAGE:-silberengel/gitrepublic-web:latest} - # For building from source, comment out image above and uncomment build below: + + # OPTION 2: Build from source (uncomment below and comment out image above) + # Requires: Dockerfile, docker-entrypoint.sh, and source code # build: # context: . # dockerfile: Dockerfile @@ -25,12 +29,19 @@ services: - PORT=6543 volumes: # Persist git repositories - # Note: Ensure ./repos directory exists on the remote machine, or Docker will create it + # The entrypoint script will automatically fix permissions on startup + # No manual chown needed - works out of the box! - ./repos:/repos # Optional: persist audit logs # - ./logs:/app/logs # Optional: mount config file if needed # - ./config:/app/config:ro + # Optional: Override UID/GID via environment variables if needed + # Default is 10000:10000 (standardized, avoids conflicts) + # Uncomment and adjust if you need to match a specific host user: + # environment: + # - GITREPUBLIC_UID=1001 + # - GITREPUBLIC_GID=1001 restart: unless-stopped healthcheck: test: ["CMD", "node", "-e", "require('http').get('http://localhost:6543', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)}).on('error', () => {process.exit(1)})"] diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..7d671fc --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,75 @@ +#!/bin/sh +set -e + +# Get the UID and GID of the gitrepublic user +# Default to 10000 (standardized, avoids conflicts with system users) +# These can be overridden via environment variables for custom setups +GITREPUBLIC_UID=${GITREPUBLIC_UID:-10000} +GITREPUBLIC_GID=${GITREPUBLIC_GID:-10000} +REPO_ROOT=${GIT_REPO_ROOT:-/repos} + +echo "==========================================" +echo "GitRepublic Container Startup" +echo "==========================================" +# Get actual user/group names (may differ if UID/GID already existed) +ACTUAL_USER=$(getent passwd $GITREPUBLIC_UID 2>/dev/null | cut -d: -f1 || echo "unknown") +ACTUAL_GROUP=$(getent group $GITREPUBLIC_GID 2>/dev/null | cut -d: -f1 || echo "unknown") +echo "User: $ACTUAL_USER (UID: $GITREPUBLIC_UID)" +echo "Group: $ACTUAL_GROUP (GID: $GITREPUBLIC_GID)" +echo "Repository root: $REPO_ROOT" +echo "==========================================" + +# Only fix permissions if running as root (which we do initially) +if [ "$(id -u)" = "0" ]; then + # Ensure the repos directory exists and has correct permissions + if [ -d "$REPO_ROOT" ]; then + echo "Fixing permissions on existing $REPO_ROOT directory..." + # Fix ownership and permissions (suppress errors for read-only mounts) + chown -R $GITREPUBLIC_UID:$GITREPUBLIC_GID "$REPO_ROOT" 2>/dev/null || { + echo "Warning: Could not change ownership of $REPO_ROOT (may be read-only mount)" + } + chmod -R 755 "$REPO_ROOT" 2>/dev/null || { + echo "Warning: Could not change permissions of $REPO_ROOT" + } + else + echo "Creating $REPO_ROOT directory..." + mkdir -p "$REPO_ROOT" + chown -R $GITREPUBLIC_UID:$GITREPUBLIC_GID "$REPO_ROOT" + chmod 755 "$REPO_ROOT" + echo "✓ Created and configured $REPO_ROOT" + fi + + # Verify permissions were set correctly + if [ -d "$REPO_ROOT" ] && [ -w "$REPO_ROOT" ]; then + echo "✓ $REPO_ROOT is writable" + else + echo "⚠ Warning: $REPO_ROOT may not be writable" + fi + + # Get the gitrepublic user (should always exist with our standardized UID) + ACTUAL_USER=$(getent passwd $GITREPUBLIC_UID 2>/dev/null | cut -d: -f1) + if [ -z "$ACTUAL_USER" ]; then + echo "Error: gitrepublic user (UID: $GITREPUBLIC_UID) not found in container" + echo "This should not happen - the user should be created during image build" + exit 1 + fi + + if [ "$ACTUAL_USER" != "gitrepublic" ]; then + echo "Warning: User with UID $GITREPUBLIC_UID is '$ACTUAL_USER', expected 'gitrepublic'" + echo "This may indicate a UID conflict. Consider using a different GITREPUBLIC_UID." + fi + + echo "Switching to user: $ACTUAL_USER (UID: $GITREPUBLIC_UID)..." + exec su-exec $ACTUAL_USER "$@" +else + # Already running as gitrepublic user (shouldn't happen with our setup, but handle gracefully) + echo "Already running as non-root user: $(id -u)" + if [ ! -d "$REPO_ROOT" ]; then + echo "Creating $REPO_ROOT directory..." + mkdir -p "$REPO_ROOT" || { + echo "Error: Could not create $REPO_ROOT directory" + exit 1 + } + fi + exec "$@" +fi diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 1cd1b04..5591534 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -55,3 +55,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771753256,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","markup and csv previews in file viewer\ncorrect image view\ncorrect syntax view\nadd copy, raw, and download buttons"]],"content":"Signed commit: markup and csv previews in file viewer\ncorrect image view\ncorrect syntax view\nadd copy, raw, and download buttons","id":"40e64c0a716e0ff594b736db14021e43583d5ff0918c1ec0c4fe2c07ddbdbc73","sig":"bb3a50267214a005104853e9b78dd94e4980024146978baef8612ef0400024032dd620749621f832ee9f0458e582084f12ed9c85a40c306f5bbc92e925198a97"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771754094,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"146ea5bbc462c4f0188ec4a35a248c2cf518af7088714a4c1ce8e6e35f524e2a","sig":"dfc5d8d9a2f35e1898404d096f6e3e334885cdb0076caab0f3ea3efd1236e53d4172ed2b9ec16cff80ff364898c287ddb400b7a52cb65a3aedc05bb9df0f7ace"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771754488,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix menu responsivenes on repo-header"]],"content":"Signed commit: fix menu responsivenes on repo-header","id":"4dd8101d8edc9431df49d9fe23b7e1e545e11ef32b024b44f871bb962fb8ad4c","sig":"dbcfbfafe02495971b3f3d18466ecf1d894e4001a41e4038d17fd78bb65124de347017273a0a437c397a79ff8226ec6b0718436193e474ef8969392df027fa34"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771755811,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix creating new branch"]],"content":"Signed commit: fix creating new branch","id":"bc6c623532064f9b2db08fa41bbc6c5ff42419415ca7e1ecb1162a884face2eb","sig":"ad1152e2848755e1afa7d9350716fa6bb709698a5036e21efa61b3ac755d334155f02a0622ad49f6dc060d523f4f886eb2acc8c80356a426b0d8ba454fdcb8ee"} diff --git a/src/lib/services/git/file-manager.ts b/src/lib/services/git/file-manager.ts index 6ebd0cd..5e0a33b 100644 --- a/src/lib/services/git/file-manager.ts +++ b/src/lib/services/git/file-manager.ts @@ -6,7 +6,6 @@ import simpleGit, { type SimpleGit } from 'simple-git'; import { readdir } from 'fs/promises'; import { join, dirname, normalize, resolve } from 'path'; -import { existsSync } from 'fs'; import { spawn } from 'child_process'; import { RepoManager } from './repo-manager.js'; import { createGitCommitSignature } from './commit-signer.js'; @@ -52,12 +51,223 @@ export interface Tag { export class FileManager { private repoManager: RepoManager; private repoRoot: string; + // Cache for directory existence checks (5 minute TTL) + private dirExistenceCache: Map = new Map(); + private readonly DIR_CACHE_TTL = 5 * 60 * 1000; // 5 minutes + // Lazy-loaded fs modules (cached after first import) + private fsPromises: typeof import('fs/promises') | null = null; + private fsSync: typeof import('fs') | null = null; constructor(repoRoot: string = '/repos') { this.repoRoot = repoRoot; this.repoManager = new RepoManager(repoRoot); } + /** + * Lazy load fs/promises module (cached after first load) + */ + private async getFsPromises(): Promise { + if (!this.fsPromises) { + this.fsPromises = await import('fs/promises'); + } + return this.fsPromises; + } + + /** + * Lazy load fs module (cached after first load) + */ + private async getFsSync(): Promise { + if (!this.fsSync) { + this.fsSync = await import('fs'); + } + return this.fsSync; + } + + /** + * Check if running in a container environment (async) + * Note: This is cached after first check since it won't change during runtime + */ + private containerEnvCache: boolean | null = null; + private async isContainerEnvironment(): Promise { + // Cache the result since it won't change during runtime + if (this.containerEnvCache !== null) { + return this.containerEnvCache; + } + + if (process.env.DOCKER_CONTAINER === 'true') { + this.containerEnvCache = true; + return true; + } + + // Check for /.dockerenv file (async) + this.containerEnvCache = await this.pathExists('/.dockerenv'); + return this.containerEnvCache; + } + + /** + * Check if a path exists (async, non-blocking) + * Uses fs.access() which is the recommended async way to check existence + */ + private async pathExists(path: string): Promise { + try { + const fs = await this.getFsPromises(); + await fs.access(path); + return true; + } catch { + return false; + } + } + + /** + * Sanitize error messages to prevent leaking sensitive path information + * Only shows relative paths, not absolute paths + */ + private sanitizePathForError(path: string): string { + // If path is within repoRoot, show relative path + const resolvedPath = resolve(path).replace(/\\/g, '/'); + const resolvedRoot = resolve(this.repoRoot).replace(/\\/g, '/'); + if (resolvedPath.startsWith(resolvedRoot + '/')) { + return resolvedPath.slice(resolvedRoot.length + 1); + } + // For paths outside repoRoot, only show last component for security + return path.split(/[/\\]/).pop() || path; + } + + /** + * Generate container-specific error message for permission issues + */ + private getContainerPermissionError(path: string, operation: string): string { + const sanitizedPath = this.sanitizePathForError(path); + return `Permission denied: ${operation} at ${sanitizedPath}. In Docker, check that the volume mount has correct permissions. The container runs as user 'gitrepublic' (UID 1001). Ensure the host directory is writable by this user or adjust ownership: chown -R 1001:1001 ./repos`; + } + + /** + * Ensure directory exists with proper error handling and security + * @param dirPath - Directory path to ensure exists + * @param description - Description for error messages + * @param checkParent - Whether to check parent directory permissions first + */ + private async ensureDirectoryExists( + dirPath: string, + description: string, + checkParent: boolean = false + ): Promise { + // Check cache first (with TTL) + const cacheKey = `dir:${dirPath}`; + const cached = this.dirExistenceCache.get(cacheKey); + const now = Date.now(); + if (cached && (now - cached.timestamp) < this.DIR_CACHE_TTL) { + if (cached.exists) { + return; // Directory exists, skip + } + } + + // Use async path existence check + const exists = await this.pathExists(dirPath); + if (exists) { + // Update cache + this.dirExistenceCache.set(cacheKey, { exists: true, timestamp: now }); + return; + } + + // Check parent directory if requested + if (checkParent) { + const parentDir = dirname(dirPath); + const parentExists = await this.pathExists(parentDir); + if (!parentExists) { + await this.ensureDirectoryExists(parentDir, `Parent of ${description}`, true); + } else { + // Verify parent is writable (async) + try { + const fs = await this.getFsPromises(); + await fs.access(parentDir, fs.constants.W_OK); + } catch (accessErr) { + const isContainer = await this.isContainerEnvironment(); + const errorMsg = isContainer + ? this.getContainerPermissionError(parentDir, `writing to parent directory of ${description}`) + : `Parent directory ${this.sanitizePathForError(parentDir)} is not writable`; + logger.error({ error: accessErr, parentDir, description }, errorMsg); + throw new Error(errorMsg); + } + } + } + + // Create directory + try { + const { mkdir } = await this.getFsPromises(); + await mkdir(dirPath, { recursive: true }); + logger.debug({ dirPath: this.sanitizePathForError(dirPath), description }, 'Created directory'); + // Update cache + this.dirExistenceCache.set(cacheKey, { exists: true, timestamp: now }); + } catch (mkdirErr) { + // Clear cache on error + this.dirExistenceCache.delete(cacheKey); + const isContainer = await this.isContainerEnvironment(); + const errorMsg = isContainer + ? this.getContainerPermissionError(dirPath, `creating ${description}`) + : `Failed to create ${description}: ${mkdirErr instanceof Error ? mkdirErr.message : String(mkdirErr)}`; + logger.error({ error: mkdirErr, dirPath: this.sanitizePathForError(dirPath), description }, errorMsg); + throw new Error(errorMsg); + } + } + + /** + * Verify directory is writable (for security checks) - async + */ + private async verifyDirectoryWritable(dirPath: string, description: string): Promise { + const exists = await this.pathExists(dirPath); + if (!exists) { + throw new Error(`${description} does not exist at ${this.sanitizePathForError(dirPath)}`); + } + + try { + const fs = await this.getFsPromises(); + await fs.access(dirPath, fs.constants.W_OK); + } catch (accessErr) { + const isContainer = await this.isContainerEnvironment(); + const errorMsg = isContainer + ? this.getContainerPermissionError(dirPath, `writing to ${description}`) + : `${description} at ${this.sanitizePathForError(dirPath)} is not writable`; + logger.error({ error: accessErr, dirPath: this.sanitizePathForError(dirPath), description }, errorMsg); + throw new Error(errorMsg); + } + } + + /** + * Clear directory existence cache (useful after operations that create directories) + */ + private clearDirCache(dirPath?: string): void { + if (dirPath) { + const cacheKey = `dir:${dirPath}`; + this.dirExistenceCache.delete(cacheKey); + } else { + // Clear all cache + this.dirExistenceCache.clear(); + } + } + + /** + * Sanitize error messages to prevent information leakage + * Uses sanitizeError from security utils and adds path sanitization + */ + private sanitizeErrorMessage(error: unknown, context?: { npub?: string; repoName?: string; filePath?: string }): string { + let message = sanitizeError(error); + + // Remove sensitive context if present + if (context?.npub) { + const { truncateNpub } = require('../../utils/security.js'); + message = message.replace(new RegExp(context.npub.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), truncateNpub(context.npub)); + } + + // Sanitize file paths + if (context?.filePath) { + const sanitizedPath = this.sanitizePathForError(context.filePath); + message = message.replace(new RegExp(context.filePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), sanitizedPath); + } + + return message; + } + /** * Create or get a git worktree for a repository * More efficient than cloning the entire repo for each operation @@ -80,12 +290,10 @@ export class FileManager { if (!resolvedPath.startsWith(resolvedRoot + '/')) { throw new Error('Path traversal detected: worktree path outside allowed root'); } - const { mkdir, rm } = await import('fs/promises'); + const { rm } = await this.getFsPromises(); - // Ensure worktree root exists (use resolved path) - if (!existsSync(resolvedWorktreeRoot)) { - await mkdir(resolvedWorktreeRoot, { recursive: true }); - } + // Ensure worktree root exists (use resolved path) with parent check + await this.ensureDirectoryExists(resolvedWorktreeRoot, 'worktree root directory', true); const git = simpleGit(repoPath); @@ -125,7 +333,7 @@ export class FileManager { } // Check if worktree already exists at the correct location - if (existsSync(worktreePath)) { + if (await this.pathExists(worktreePath)) { // Verify it's a valid worktree try { const worktreeGit = simpleGit(worktreePath); @@ -133,6 +341,7 @@ export class FileManager { return worktreePath; } catch { // Invalid worktree, remove it + const { rm } = await this.getFsPromises(); await rm(worktreePath, { recursive: true, force: true }); } } @@ -393,8 +602,8 @@ export class FileManager { }); // Verify the worktree directory was actually created (after the promise resolves) - if (!existsSync(worktreePath)) { - throw new Error(`Worktree directory was not created: ${worktreePath}`); + if (!(await this.pathExists(worktreePath))) { + throw new Error(`Worktree directory was not created: ${this.sanitizePathForError(worktreePath)}`); } // Verify it's a valid git repository @@ -1047,10 +1256,7 @@ export class FileManager { } // Ensure directory exists - if (!existsSync(fileDir)) { - const { mkdir } = await import('fs/promises'); - await mkdir(fileDir, { recursive: true }); - } + await this.ensureDirectoryExists(fileDir, 'directory for file', true); const { writeFile: writeFileFs } = await import('fs/promises'); await writeFileFs(fullFilePath, content, 'utf-8'); @@ -1508,8 +1714,8 @@ export class FileManager { throw new Error('Path validation failed: resolved path outside work directory'); } - if (existsSync(fullFilePath)) { - const { unlink } = await import('fs/promises'); + if (await this.pathExists(fullFilePath)) { + const { unlink } = await this.getFsPromises(); await unlink(fullFilePath); } @@ -1621,23 +1827,33 @@ export class FileManager { // Create worktree for the new branch directly (orphan branch) const worktreeRoot = join(this.repoRoot, npub, `${repoName}.worktrees`); const worktreePath = resolve(join(worktreeRoot, branchName)); - const { mkdir, rm } = await import('fs/promises'); + const { rm } = await this.getFsPromises(); - if (!existsSync(worktreeRoot)) { - await mkdir(worktreeRoot, { recursive: true }); + // 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 (existsSync(worktreePath)) { + 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 - await git.raw(['worktree', 'add', worktreePath, '--orphan', branchName]); + // Note: --orphan must come before branch name, path comes last + await git.raw(['worktree', 'add', '--orphan', branchName, worktreePath]); // Create initial empty commit with announcement as message const workGit: SimpleGit = simpleGit(worktreePath); @@ -1865,8 +2081,9 @@ export class FileManager { return files; } catch (error) { - logger.error({ error, repoPath, fromRef, toRef }, 'Error getting diff'); - throw new Error(`Failed to get diff: ${error instanceof Error ? error.message : String(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}`); } } @@ -1891,9 +2108,51 @@ export class FileManager { 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 - await git.addTag(tagName); // Note: simple-git addTag doesn't support message directly, use raw command if (ref !== 'HEAD') { await git.raw(['tag', '-a', tagName, '-m', message, ref]); diff --git a/src/lib/services/git/repo-manager.ts b/src/lib/services/git/repo-manager.ts index 3090474..0f415e4 100644 --- a/src/lib/services/git/repo-manager.ts +++ b/src/lib/services/git/repo-manager.ts @@ -442,7 +442,9 @@ Your commits will all be signed by your Nostr keys and saved to the event files // Fetch from remote with appropriate environment // Use spawn with proper argument arrays for security - await execGitWithEnv(repoPath, ['fetch', remoteName, '--all'], gitEnv); + // Note: 'git fetch ' already fetches all branches from that remote + // The --all flag is only for fetching from all remotes (without specifying a remote) + await execGitWithEnv(repoPath, ['fetch', remoteName], gitEnv); // Update remote head try { diff --git a/src/routes/api/repos/[npub]/[repo]/branches/+server.ts b/src/routes/api/repos/[npub]/[repo]/branches/+server.ts index f17f7a4..52fcc39 100644 --- a/src/routes/api/repos/[npub]/[repo]/branches/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/branches/+server.ts @@ -11,7 +11,7 @@ import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js import { handleValidationError, handleApiError, handleNotFoundError } from '$lib/utils/error-handler.js'; import { KIND } from '$lib/types/nostr.js'; import { join, dirname } from 'path'; -import { existsSync } from 'fs'; +import { existsSync, accessSync, constants } from 'fs'; import { repoCache, RepoCache } from '$lib/services/git/repo-cache.js'; import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS } from '$lib/config.js'; import { NostrClient } from '$lib/services/nostr/nostr-client.js'; @@ -19,6 +19,30 @@ import { eventCache } from '$lib/services/nostr/event-cache.js'; import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js'; import logger from '$lib/services/logger.js'; +/** + * Check if a directory exists and is writable + * Provides helpful error messages for container environments + */ +function checkDirectoryWritable(dirPath: string, description: string): void { + if (!existsSync(dirPath)) { + const isContainer = existsSync('/.dockerenv') || process.env.DOCKER_CONTAINER === 'true'; + const errorMsg = isContainer + ? `${description} does not exist at ${dirPath}. In Docker, ensure the volume is mounted correctly and the directory exists on the host. Check docker-compose.yml volumes section.` + : `${description} does not exist at ${dirPath}`; + throw new Error(errorMsg); + } + + try { + accessSync(dirPath, constants.W_OK); + } catch (accessErr) { + const isContainer = existsSync('/.dockerenv') || process.env.DOCKER_CONTAINER === 'true'; + const errorMsg = isContainer + ? `${description} at ${dirPath} is not writable. 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` + : `${description} at ${dirPath} is not writable`; + throw new Error(errorMsg); + } +} + const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT ? process.env.GIT_REPO_ROOT : '/repos'; @@ -139,14 +163,74 @@ export const POST: RequestHandler = createRepoPostHandler( if (!repoExists) { logger.info({ npub: context.npub, repo: context.repo }, 'Creating new empty repository for branch creation'); const { mkdir } = await import('fs/promises'); + + // Check if repoRoot exists and is writable (with helpful container error messages) + if (!existsSync(repoRoot)) { + try { + await mkdir(repoRoot, { recursive: true }); + logger.debug({ repoRoot }, 'Created repoRoot directory'); + } catch (rootErr) { + logger.error({ error: rootErr, repoRoot }, 'Failed to create repoRoot directory'); + // Check if parent directory is writable + const parentRoot = dirname(repoRoot); + if (existsSync(parentRoot)) { + try { + checkDirectoryWritable(parentRoot, 'Parent directory of GIT_REPO_ROOT'); + } catch (checkErr) { + throw handleApiError( + checkErr, + { operation: 'createBranch', npub: context.npub, repo: context.repo }, + checkErr instanceof Error ? checkErr.message : String(checkErr) + ); + } + } + throw handleApiError( + rootErr, + { operation: 'createBranch', npub: context.npub, repo: context.repo }, + `Failed to create repository root directory: ${rootErr instanceof Error ? rootErr.message : String(rootErr)}` + ); + } + } else { + // Directory exists, check if it's writable + try { + checkDirectoryWritable(repoRoot, 'GIT_REPO_ROOT directory'); + } catch (checkErr) { + throw handleApiError( + checkErr, + { operation: 'createBranch', npub: context.npub, repo: context.repo }, + checkErr instanceof Error ? checkErr.message : String(checkErr) + ); + } + } + + // Create repo directory const repoDir = dirname(repoPath); - await mkdir(repoDir, { recursive: true }); + try { + await mkdir(repoDir, { recursive: true }); + logger.debug({ repoDir }, 'Created repository directory'); + } catch (dirErr) { + logger.error({ error: dirErr, repoDir, npub: context.npub, repo: context.repo }, 'Failed to create repository directory'); + throw handleApiError( + dirErr, + { operation: 'createBranch', npub: context.npub, repo: context.repo }, + `Failed to create repository directory: ${dirErr instanceof Error ? dirErr.message : String(dirErr)}` + ); + } // Initialize bare repository - const simpleGit = (await import('simple-git')).default; - const git = simpleGit(); - await git.init(['--bare', repoPath]); - logger.info({ npub: context.npub, repo: context.repo }, 'Empty repository created successfully'); + try { + const simpleGit = (await import('simple-git')).default; + const git = simpleGit(); + await git.init(['--bare', repoPath]); + logger.info({ npub: context.npub, repo: context.repo }, 'Empty repository created successfully'); + } catch (initErr) { + logger.error({ error: initErr, repoPath, npub: context.npub, repo: context.repo }, 'Failed to initialize bare repository'); + throw handleApiError( + initErr, + { operation: 'createBranch', npub: context.npub, repo: context.repo }, + `Failed to initialize repository: ${initErr instanceof Error ? initErr.message : String(initErr)}` + ); + } } // Get default branch if fromBranch not provided