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