UNPKG

claude-git-hooks

Version:

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

342 lines (299 loc) 10.6 kB
/** * File: git-operations.js * Purpose: Provides abstraction layer for git commands * * Key responsibilities: * - Execute git commands safely with error handling * - Provide cross-platform git operations * - Abstract git complexity from business logic * * Dependencies: * - child_process: For executing git commands * - logger: For debug and error logging */ import { execSync } from 'child_process'; import path from 'path'; import logger from './logger.js'; /** * Custom error for git operation failures * Why: Provides structured error handling with git-specific context */ class GitError extends Error { constructor(message, { command, cause, output } = {}) { super(message); this.name = 'GitError'; this.command = command; this.cause = cause; this.output = output; } } /** * Executes a git command safely with error handling * Why: Centralizes git command execution with consistent error handling * and logging across all git operations * * @param {string} command - Git command to execute (without 'git' prefix) * @param {Object} options - Options for execSync * @returns {string} Command output (trimmed) * @throws {GitError} If command fails */ const execGitCommand = (command, options = {}) => { const fullCommand = `git ${command}`; logger.debug( 'git-operations - execGitCommand', 'Executing git command', { command: fullCommand } ); try { const output = execSync(fullCommand, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], // Capture stderr ...options }); logger.debug( 'git-operations - execGitCommand', 'Command executed successfully', { command: fullCommand, outputLength: output.length } ); return output.trim(); } catch (error) { logger.error( 'git-operations - execGitCommand', `Git command failed: ${fullCommand}`, error ); throw new GitError('Git command failed', { command: fullCommand, cause: error, output: error.stderr || error.stdout }); } }; /** * Gets list of staged files * Why: Pre-commit hooks need to analyze only staged changes, not all working tree * * @param {Object} options - Filter options * @param {Array<string>} options.extensions - File extensions to filter (e.g., ['.java', '.xml']) * @param {boolean} options.includeDeleted - Include deleted files (default: false) * @returns {Array<string>} Array of staged file paths * * Git diff filter codes: * A = Added, C = Copied, M = Modified, R = Renamed * D = Deleted, T = Type changed, U = Unmerged, X = Unknown */ const getStagedFiles = ({ extensions = [], includeDeleted = false } = {}) => { logger.debug( 'git-operations - getStagedFiles', 'Getting staged files', { extensions, includeDeleted } ); // Why: --diff-filter excludes deleted files unless explicitly requested // ACM = Added, Copied, Modified (excludes Deleted, Renamed, etc.) const filter = includeDeleted ? 'ACMR' : 'ACM'; const output = execGitCommand(`diff --cached --name-only --diff-filter=${filter}`); if (!output) { logger.debug('git-operations - getStagedFiles', 'No staged files found'); return []; } // Why: Split by LF or CRLF to handle Windows line endings const files = output.split(/\r?\n/).filter(f => f.length > 0); // Filter by extensions if provided if (extensions.length > 0) { const filtered = files.filter(file => extensions.some(ext => file.endsWith(ext)) ); logger.debug( 'git-operations - getStagedFiles', 'Filtered files by extension', { totalFiles: files.length, filteredFiles: filtered.length, extensions } ); return filtered; } return files; }; /** * Gets the diff for a specific file * Why: Shows what changed in a file, essential for code review * * @param {string} filePath - Path to the file * @param {Object} options - Diff options * @param {boolean} options.cached - Get staged changes (default: true) * @param {number} options.context - Lines of context around changes (default: 3) * @returns {string} Diff output */ const getFileDiff = (filePath, { cached = true, context = 3 } = {}) => { logger.debug( 'git-operations - getFileDiff', 'Getting file diff', { filePath, cached, context } ); const cachedFlag = cached ? '--cached' : ''; const contextFlag = `-U${context}`; try { return execGitCommand(`diff ${cachedFlag} ${contextFlag} -- "${filePath}"`); } catch (error) { // Why: Empty diff is valid (e.g., new file with no changes), don't throw error if (error.output && error.output.includes('')) { logger.debug('git-operations - getFileDiff', 'Empty diff', { filePath }); return ''; } throw error; } }; /** * Gets file content from git staging area * Why: Reads the staged version of a file, not the working directory version * This ensures we analyze what will be committed, not uncommitted changes * * @param {string} filePath - Path to the file * @returns {string} File content from staging area */ const getFileContentFromStaging = (filePath) => { logger.debug( 'git-operations - getFileContentFromStaging', 'Reading file from staging area', { filePath } ); // Why: git show :path reads from index (staging area), not working tree return execGitCommand(`show ":${filePath}"`); }; /** * Checks if a file is newly added (not in previous commits) * Why: New files need full content analysis, not just diffs * * @param {string} filePath - Path to the file * @returns {boolean} True if file is newly added */ const isNewFile = (filePath) => { logger.debug( 'git-operations - isNewFile', 'Checking if file is new', { filePath } ); try { const status = execGitCommand(`diff --cached --name-status -- "${filePath}"`); // Why: Status starts with 'A' for Added files const isNew = status.startsWith('A\t'); logger.debug( 'git-operations - isNewFile', 'File status checked', { filePath, isNew, status } ); return isNew; } catch (error) { logger.error('git-operations - isNewFile', 'Failed to check file status', error); return false; } }; /** * Gets repository root directory * Why: Needed to resolve relative paths and locate configuration files * * @returns {string} Absolute path to repository root */ const getRepoRoot = () => { logger.debug('git-operations - getRepoRoot', 'Getting repository root'); try { return execGitCommand('rev-parse --show-toplevel'); } catch (error) { throw new GitError('Not a git repository or no git found', { cause: error }); } }; /** * Gets current branch name * Why: Used for context in prompts and logging * * @returns {string} Current branch name */ const getCurrentBranch = () => { logger.debug('git-operations - getCurrentBranch', 'Getting current branch'); try { return execGitCommand('branch --show-current'); } catch (error) { logger.error('git-operations - getCurrentBranch', 'Failed to get branch', error); return 'unknown'; } }; /** * Gets repository name from root directory * Why: Used for context in prompts and reports * * @returns {string} Repository name (last component of path) */ const getRepoName = () => { logger.debug('git-operations - getRepoName', 'Getting repository name'); try { const repoRoot = getRepoRoot(); // Why: Use path.basename() for cross-platform path handling return path.basename(repoRoot); } catch (error) { logger.error('git-operations - getRepoName', 'Failed to get repo name', error); return 'unknown'; } }; /** * Gets statistics about staged files * Why: Provides overview for commit message generation * * @returns {Object} Statistics object with file counts and changes * Statistics structure: * { * totalFiles: number, // Total staged files * addedFiles: number, // Newly added files * modifiedFiles: number, // Modified files * deletedFiles: number, // Deleted files * insertions: number, // Total lines added * deletions: number // Total lines deleted * } */ const getStagedStats = () => { logger.debug('git-operations - getStagedStats', 'Getting staged file statistics'); try { const shortstat = execGitCommand('diff --cached --shortstat'); const numstat = execGitCommand('diff --cached --numstat'); const nameStatus = execGitCommand('diff --cached --name-status'); // Parse shortstat: "X files changed, Y insertions(+), Z deletions(-)" const statsMatch = shortstat.match(/(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/); const insertions = statsMatch?.[2] ? parseInt(statsMatch[2], 10) : 0; const deletions = statsMatch?.[3] ? parseInt(statsMatch[3], 10) : 0; // Count file types from name-status // Why: Split by LF or CRLF to handle Windows line endings const statusLines = nameStatus.split(/\r?\n/).filter(l => l.length > 0); const added = statusLines.filter(l => l.startsWith('A\t')).length; const modified = statusLines.filter(l => l.startsWith('M\t')).length; const deleted = statusLines.filter(l => l.startsWith('D\t')).length; const stats = { totalFiles: statusLines.length, addedFiles: added, modifiedFiles: modified, deletedFiles: deleted, insertions, deletions }; logger.debug('git-operations - getStagedStats', 'Statistics calculated', stats); return stats; } catch (error) { logger.error('git-operations - getStagedStats', 'Failed to get statistics', error); // Return empty stats on error return { totalFiles: 0, addedFiles: 0, modifiedFiles: 0, deletedFiles: 0, insertions: 0, deletions: 0 }; } }; export { GitError, getStagedFiles, getFileDiff, getFileContentFromStaging, isNewFile, getRepoRoot, getCurrentBranch, getRepoName, getStagedStats };