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