Browse Source

fix file management and refactor

Nostr-Signature: 626196cdbf9eab28b44990706281878083d66983b503e8a81df7421054ed6caf 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 516c0001a800083411a1e04340e82116a82c975f38b984e92ebe021b61271ba7d6f645466ddba3594320c228193e708675a5d7a144b2f3d5e9bfbc65c4c7372b
main
Silberengel 3 weeks ago
parent
commit
87c514a346
  1. 32
      Dockerfile
  2. 17
      docker-compose.yml
  3. 75
      docker-entrypoint.sh
  4. 1
      nostr/commit-signatures.jsonl
  5. 305
      src/lib/services/git/file-manager.ts
  6. 4
      src/lib/services/git/repo-manager.ts
  7. 86
      src/routes/api/repos/[npub]/[repo]/branches/+server.ts

32
Dockerfile

@ -31,7 +31,8 @@ FROM node:20-alpine @@ -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 @@ -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 @@ -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"]

17
docker-compose.yml

@ -1,8 +1,12 @@ @@ -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: @@ -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)})"]

75
docker-entrypoint.sh

@ -0,0 +1,75 @@ @@ -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

1
nostr/commit-signatures.jsonl

@ -55,3 +55,4 @@ @@ -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"}

305
src/lib/services/git/file-manager.ts

@ -6,7 +6,6 @@ @@ -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 { @@ -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<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
@ -80,12 +290,10 @@ export class FileManager { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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]);

4
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 @@ -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 <remote>' 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 {

86
src/routes/api/repos/[npub]/[repo]/branches/+server.ts

@ -11,7 +11,7 @@ import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js @@ -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'; @@ -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( @@ -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);
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
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

Loading…
Cancel
Save