claude-git-hooks
Version:
Git hooks with Claude CLI for code analysis and automatic commit messages
291 lines (260 loc) • 8.43 kB
JavaScript
/**
* 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
};