UNPKG

claude-git-hooks

Version:

Git hooks with Claude CLI for code analysis and automatic commit messages

291 lines (260 loc) 8.43 kB
/** * File: file-operations.js * Purpose: File system operations and content filtering * * Key responsibilities: * - Read files with size validation * - Validate file extensions * - Check file sizes * * Dependencies: * - fs/promises: Async file operations * - path: Cross-platform path handling * - logger: Debug and error logging */ import fs from 'fs/promises'; import path from 'path'; import logger from './logger.js'; /** * Custom error for file operation failures */ class FileOperationError extends Error { constructor(message, { filePath, cause, context } = {}) { super(message); this.name = 'FileOperationError'; this.filePath = filePath; this.cause = cause; this.context = context; } } /** * Gets file size in bytes * Why: Need to filter large files before reading to avoid memory issues * * @param {string} filePath - Path to file * @returns {Promise<number>} File size in bytes */ const getFileSize = async (filePath) => { logger.debug('file-operations - getFileSize', 'Getting file size', { filePath }); try { const stats = await fs.stat(filePath); return stats.size; } catch (error) { throw new FileOperationError('Failed to get file size', { filePath, cause: error }); } }; /** * Checks if file exists * * @param {string} filePath - Path to file * @returns {Promise<boolean>} True if file exists */ const fileExists = async (filePath) => { try { await fs.access(filePath); return true; } catch { return false; } }; /** * Reads file content with size check * Why: Prevents loading huge files into memory that could cause OOM errors * * @param {string} filePath - Path to file * @param {Object} options - Read options * @param {number} options.maxSize - Maximum file size in bytes (default: 100000 = 100KB) * @param {string} options.encoding - File encoding (default: 'utf8') * @returns {Promise<string>} File content * @throws {FileOperationError} If file too large or read fails */ const readFile = async (filePath, { maxSize = 100000, encoding = 'utf8' } = {}) => { logger.debug( 'file-operations - readFile', 'Reading file', { filePath, maxSize, encoding } ); try { const size = await getFileSize(filePath); // Why: Check size before reading to avoid loading huge files if (size > maxSize) { throw new FileOperationError('File exceeds maximum size', { filePath, context: { size, maxSize } }); } const content = await fs.readFile(filePath, encoding); logger.debug( 'file-operations - readFile', 'File read successfully', { filePath, contentLength: content.length } ); return content; } catch (error) { if (error instanceof FileOperationError) { throw error; } logger.error('file-operations - readFile', 'Failed to read file', error); throw new FileOperationError('Failed to read file', { filePath, cause: error }); } }; /** * Validates file extension against allowed list * Why: Pre-commit hook should only analyze specific file types * * @param {string} filePath - Path to file * @param {Array<string>} allowedExtensions - Array of extensions (e.g., ['.java', '.xml']) * @returns {boolean} True if extension is allowed */ const hasAllowedExtension = (filePath, allowedExtensions = []) => { if (allowedExtensions.length === 0) { return true; // No filter, all allowed } const ext = path.extname(filePath).toLowerCase(); const isAllowed = allowedExtensions.some(allowed => ext === allowed.toLowerCase() ); logger.debug( 'file-operations - hasAllowedExtension', 'Extension check', { filePath, ext, allowedExtensions, isAllowed } ); return isAllowed; }; /** * Filters files by size and extension * Why: Batch validation of files before processing * * @param {Array<string>} files - Array of file paths * @param {Object} options - Filter options * @param {number} options.maxSize - Max file size in bytes * @param {Array<string>} options.extensions - Allowed extensions * @returns {Promise<Array<Object>>} Filtered files with metadata * * Returned object structure: * [ * { * path: string, // File path * size: number, // Size in bytes * valid: boolean, // Whether file passes filters * reason: string // Why file was filtered (if valid=false) * } * ] */ const filterFiles = async (files, { maxSize = 100000, extensions = [] } = {}) => { logger.debug( 'file-operations - filterFiles', 'Filtering files', { fileCount: files.length, maxSize, extensions, 'process.cwd()': process.cwd(), files: files } ); const results = await Promise.allSettled( files.map(async (filePath) => { // Check extension first (fast) if (!hasAllowedExtension(filePath, extensions)) { logger.debug( 'file-operations - filterFiles', 'Extension rejected', { filePath } ); return { path: filePath, size: 0, valid: false, reason: 'Extension not allowed' }; } // Check if file exists const exists = await fileExists(filePath); logger.debug( 'file-operations - filterFiles', 'File exists check', { filePath, exists } ); if (!exists) { return { path: filePath, size: 0, valid: false, reason: 'File not found' }; } // Check size try { const size = await getFileSize(filePath); if (size > maxSize) { logger.debug( 'file-operations - filterFiles', 'File too large', { filePath, size, maxSize, 'size (KB)': Math.round(size / 1024), 'maxSize (KB)': Math.round(maxSize / 1024) } ); return { path: filePath, size, valid: false, reason: `File too large (${size} > ${maxSize})` }; } logger.debug( 'file-operations - filterFiles', 'File passed size check', { filePath, size, maxSize, 'size (KB)': Math.round(size / 1024) } ); return { path: filePath, size, valid: true, reason: null }; } catch (error) { logger.debug( 'file-operations - filterFiles', 'Error reading file', { filePath, error: error.message } ); return { path: filePath, size: 0, valid: false, reason: `Error reading file: ${error.message}` }; } }) ); // Extract successful results const fileMetadata = results .filter(r => r.status === 'fulfilled') .map(r => r.value); const validFiles = fileMetadata.filter(f => f.valid); const invalidFiles = fileMetadata.filter(f => !f.valid); logger.debug( 'file-operations - filterFiles', 'Filtering complete', { totalFiles: files.length, validFiles: validFiles.length, invalidFiles: invalidFiles.length, rejectedFiles: invalidFiles.map(f => ({ path: f.path, reason: f.reason })) } ); return fileMetadata; }; export { FileOperationError, getFileSize, fileExists, readFile, hasAllowedExtension, filterFiles };