UNPKG

claude-git-hooks

Version:

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

329 lines (274 loc) 10.4 kB
#!/usr/bin/env node /** * File: prepare-commit-msg.js * Purpose: Generates automatic commit messages with Claude CLI * * Flow: * 1. Check if message is "auto" * 2. Get staged files and diffs * 3. Build commit message prompt * 4. Send to Claude CLI * 5. Parse JSON response * 6. Format conventional commit message * 7. Write to commit message file * * Dependencies: * - git-operations, claude-client, logger */ import fs from 'fs/promises'; import { getStagedFiles, getStagedStats, getFileDiff } from '../utils/git-operations.js'; import { analyzeCode } from '../utils/claude-client.js'; import { loadPrompt } from '../utils/prompt-builder.js'; import { getVersion, calculateBatches } from '../utils/package-info.js'; import logger from '../utils/logger.js'; import { getConfig } from '../config.js'; import { getOrPromptTaskId, formatWithTaskId } from '../utils/task-id.js'; /** * Builds commit message generation prompt * Why: Provides structured information to Claude for message generation * * @param {Array<Object>} filesData - Array of file change data * @param {Object} stats - Staging statistics * @returns {Promise<string>} Complete prompt */ const buildCommitPrompt = async (filesData, stats) => { logger.debug( 'prepare-commit-msg - buildCommitPrompt', 'Building commit message prompt', { fileCount: filesData.length } ); // Build file list const fileList = filesData.map(({ path }) => path).join('\n'); // Build file diffs let fileDiffs = ''; filesData.forEach(({ path, diff }) => { if (diff) { fileDiffs += `\n--- Diff of ${path} ---\n`; fileDiffs += diff; } }); // Load prompt from template const prompt = await loadPrompt('COMMIT_MESSAGE.md', { FILE_LIST: fileList, FILE_COUNT: stats.totalFiles || filesData.length, INSERTIONS: stats.insertions || 0, DELETIONS: stats.deletions || 0, FILE_DIFFS: fileDiffs }); return prompt; }; /** * Formats commit message from Claude's JSON response * Why: Converts structured JSON into conventional commit format * * @param {Object} json - Parsed JSON from Claude * @returns {string} Formatted commit message * * JSON structure: * { * type: string, // Commit type * scope: string, // Optional scope * title: string, // Short description * body: string // Optional body * } */ const formatCommitMessage = (json) => { logger.debug( 'prepare-commit-msg - formatCommitMessage', 'Formatting commit message', { type: json.type, hasScope: !!json.scope, hasBody: !!json.body } ); const type = json.type || 'feat'; const scope = json.scope && json.scope !== 'null' ? json.scope : null; const title = json.title || ''; const body = json.body && json.body !== 'null' ? json.body : null; if (!title) { throw new Error('No title found in Claude response'); } // Build message: type(scope): title let message = type; if (scope) { message += `(${scope})`; } message += `: ${title}`; // Add body if present if (body) { message += `\n\n${body}`; } return message; }; /** * Main prepare-commit-msg hook execution */ const main = async () => { const startTime = Date.now(); // Load configuration (includes preset + user overrides) const config = await getConfig(); // Enable debug mode from config if (config.system.debug) { logger.setDebugMode(true); } try { // Get hook arguments const args = process.argv.slice(2); const commitMsgFile = args[0]; const commitSource = args[1]; logger.debug( 'prepare-commit-msg - main', 'Hook started', { commitMsgFile, commitSource } ); // Only process normal commits // Why: Don't interfere with merge commits, amend, squash, etc. if (commitSource && commitSource !== 'message') { logger.debug( 'prepare-commit-msg - main', `Skipping: commit source is ${commitSource}` ); process.exit(0); } // Read current message const currentMsg = await fs.readFile(commitMsgFile, 'utf8'); const firstLine = currentMsg.split('\n')[0].trim(); logger.debug( 'prepare-commit-msg - main', 'Current commit message', { firstLine } ); // Check if message is "auto" if (firstLine !== config.commitMessage.autoKeyword) { logger.debug( 'prepare-commit-msg - main', 'Not generating: message is not "auto"' ); process.exit(0); } // Display configuration info const version = await getVersion(); const subagentsEnabled = config.subagents?.enabled || false; const subagentModel = config.subagents?.model || 'haiku'; const batchSize = config.subagents?.batchSize || 3; console.log(`\n🤖 claude-git-hooks v${version}`); if (subagentsEnabled) { console.log(`⚡ Parallel analysis: ${subagentModel} model, batch size ${batchSize}`); } logger.info('Generating commit message automatically...'); // Step 1: Auto-detect task ID from branch (no prompt) logger.debug('prepare-commit-msg - main', 'Detecting task ID from branch'); const taskId = await getOrPromptTaskId({ prompt: false, // Don't prompt - just detect from branch required: false, config: config // Pass config for custom pattern }); // Note: getOrPromptTaskId() already logs the task ID if found if (!taskId) { logger.debug('prepare-commit-msg - main', 'No task ID found in branch, continuing without it'); } // Step 2: Get staged files const stagedFiles = getStagedFiles(); if (stagedFiles.length === 0) { logger.warning('No staged files found'); logger.warning('Commit canceled. Run again without "auto" to write manual message'); process.exit(1); } // Get statistics const stats = getStagedStats(); logger.debug( 'prepare-commit-msg - main', 'Staged files', { fileCount: stagedFiles.length, stats } ); // Build file data with diffs const filesData = await Promise.all( stagedFiles.map(async (filePath) => { try { // Get file size const fileStats = await fs.stat(filePath); // Only include diff for small files let diff = null; if (fileStats.size < config.analysis.maxFileSize) { diff = getFileDiff(filePath); } return { path: filePath, diff, size: fileStats.size }; } catch (error) { logger.error( 'prepare-commit-msg - main', `Failed to process file: ${filePath}`, error ); return { path: filePath, diff: null, size: 0 }; } }) ); // Build prompt const prompt = await buildCommitPrompt(filesData, stats); logger.debug( 'prepare-commit-msg - main', 'Prompt built', { promptLength: prompt.length } ); // Calculate batches if subagents enabled and applicable if (subagentsEnabled && filesData.length >= 3) { const { numBatches, shouldShowBatches } = calculateBatches(filesData.length, batchSize); if (shouldShowBatches) { console.log(`📊 Analyzing ${filesData.length} files in ${numBatches} batch${numBatches > 1 ? 'es' : ''}`); } } // Generate message with Claude logger.info('Sending to Claude...'); // Build telemetry context const telemetryContext = { fileCount: filesData.length, batchSize: config.subagents?.batchSize || 3, totalBatches: subagentsEnabled && filesData.length >= 3 ? Math.ceil(filesData.length / (config.subagents?.batchSize || 3)) : 1, model: subagentModel, hook: 'prepare-commit-msg' }; const response = await analyzeCode(prompt, { timeout: config.commitMessage.timeout, saveDebug: config.system.debug, telemetryContext }); logger.debug( 'prepare-commit-msg - main', 'Response received', { hasType: !!response.type, hasTitle: !!response.title } ); // Format message let message = formatCommitMessage(response); // Add task ID prefix if available if (taskId) { message = formatWithTaskId(message, taskId); logger.debug( 'prepare-commit-msg - main', 'Task ID added to message', { taskId, message: message.split('\n')[0] } ); } // Write to commit message file await fs.writeFile(commitMsgFile, message + '\n', 'utf8'); const duration = ((Date.now() - startTime) / 1000).toFixed(2); console.error(`⏱️ Message generation completed in ${duration}s`); logger.success(`Message generated: ${message.split('\n')[0]}`); process.exit(0); } catch (error) { const duration = ((Date.now() - startTime) / 1000).toFixed(2); console.error(`⏱️ Message generation failed after ${duration}s`); logger.error('prepare-commit-msg - main', 'Failed to generate commit message', error); logger.warning('Could not generate message automatically with Claude'); logger.warning('Commit canceled. Run again without "auto" to write manual message'); process.exit(1); } }; // Execute main main();