claude-git-hooks
Version:
Git hooks with Claude CLI for code analysis and automatic commit messages
288 lines (258 loc) • 10.1 kB
JavaScript
/**
* File: config.js
* Purpose: Simplified configuration management (v2.8.0)
*
* Priority: preset config > .claude/config.json > hardcoded defaults
*
* v2.8.0 Changes:
* - Removed 21 redundant parameters (hardcoded sensible defaults)
* - Only 5 user-configurable parameters remain
* - Preset can override: subagents.batchSize
* - User can override: github.pr.*, subagents.batchSize
* - Advanced params moved to config.advanced.example.json
*
* User-configurable:
* - preset (required)
* - github.pr.defaultBase
* - github.pr.reviewers
* - github.pr.labelRules
* - subagents.batchSize
*
* Advanced (in example file only):
* - analysis.ignoreExtensions
* - commitMessage.taskIdPattern
* - subagents.model
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import logger from './utils/logger.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/**
* Hardcoded defaults (v3.0.0)
* These are NOT user-configurable - sensible defaults that work for everyone
*/
const HARDCODED = {
analysis: {
maxFileSize: 1000000, // 1MB - sufficient for most files
maxFiles: 20, // Reasonable limit per commit
timeout: 300000, // 5 minutes - adequate for Claude API
contextLines: 3, // Git default
ignoreExtensions: [], // Can be set in advanced config only
},
commitMessage: {
autoKeyword: 'auto', // Standard keyword
timeout: 300000, // Use same timeout as analysis
taskIdPattern: '([A-Z]{1,3}[-\\s]\\d{3,5})', // Jira/GitHub/Linear pattern
},
subagents: {
enabled: true, // Enable by default (faster analysis)
model: 'haiku', // Fast and cost-effective
batchSize: 3, // Reasonable parallelization
},
templates: {
baseDir: '.claude/prompts',
analysis: 'CLAUDE_ANALYSIS_PROMPT.md',
guidelines: 'CLAUDE_PRE_COMMIT.md',
commitMessage: 'COMMIT_MESSAGE.md',
analyzeDiff: 'ANALYZE_DIFF.md',
resolution: 'CLAUDE_RESOLUTION_PROMPT.md',
subagentInstruction: 'SUBAGENT_INSTRUCTION.md',
createGithubPR: 'CREATE_GITHUB_PR.md',
},
output: {
outputDir: '.claude/out',
debugFile: '.claude/out/debug-claude-response.json',
resolutionFile: '.claude/out/claude_resolution_prompt.md',
prAnalysisFile: '.claude/out/pr-analysis.json',
},
system: {
debug: false, // Controlled by --debug flag
telemetry: true, // Opt-out telemetry for debugging (local only, set to false to disable)
wslCheckTimeout: 15000, // System behavior
},
git: {
diffFilter: 'ACM', // Standard: Added, Copied, Modified
},
github: {
enabled: true, // Always enabled
},
};
/**
* Default user-configurable values (v2.8.0)
* Only these can be overridden in .claude/config.json
*/
const defaults = {
// GitHub PR configuration (user-specific)
github: {
pr: {
defaultBase: 'develop', // Project default branch
reviewers: [], // Project reviewers
labelRules: { // Labels by preset
backend: ['backend', 'java'],
frontend: ['frontend', 'react'],
fullstack: ['fullstack'],
database: ['database', 'sql'],
ai: ['ai', 'tooling'],
default: []
},
},
},
// Subagent configuration (preset can override)
subagents: {
batchSize: 3, // Files per parallel batch
},
};
/**
* Loads user configuration from .claude/config.json (v2.8.0)
*
* Format detection:
* - v2.8.0: { version: "2.8.0", preset: "...", overrides: {...} }
* - Legacy: { preset: "...", analysis: {...}, ... } (auto-migrates with warning)
*
* Merge priority: HARDCODED < defaults < preset config < user overrides
*
* @param {string} baseDir - Base directory to search for config (default: cwd)
* @returns {Promise<Object>} Merged configuration
*/
const loadUserConfig = async (baseDir = process.cwd()) => {
const configPath = path.join(baseDir, '.claude', 'config.json');
let rawConfig = {};
let configFormat = 'default';
try {
if (fs.existsSync(configPath)) {
rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
// Detect format
if (rawConfig.version === '2.8.0') {
configFormat = 'v3';
} else if (rawConfig.version) {
throw new Error(`Unsupported config version: ${rawConfig.version}. Please run 'claude-hooks migrate-config'`);
} else if (Object.keys(rawConfig).length > 0 && rawConfig.preset) {
configFormat = 'legacy';
logger.warning('⚠️ Legacy config format detected. Run "claude-hooks migrate-config" to update to v2.8.0');
}
}
} catch (error) {
if (error.message.includes('Unsupported config version')) {
throw error;
}
logger.warning(`⚠️ Warning: Could not load .claude/config.json: ${error.message}`);
logger.warning('Using default configuration');
}
// Extract user overrides based on format
let userOverrides = {};
let presetName = null;
if (configFormat === 'v3') {
presetName = rawConfig.preset;
userOverrides = rawConfig.overrides || {};
} else if (configFormat === 'legacy') {
presetName = rawConfig.preset;
// Legacy format: extract only allowed parameters
userOverrides = extractAllowedParams(rawConfig);
}
// Load preset if specified
let presetConfig = {};
if (presetName) {
try {
const { loadPreset } = await import('./utils/preset-loader.js');
const { config } = await loadPreset(presetName);
presetConfig = config;
} catch (error) {
logger.warning(`⚠️ Warning: Preset "${presetName}" not found, using defaults`);
}
}
// Merge priority: HARDCODED < defaults < preset < user overrides
const baseConfig = deepMerge(HARDCODED, defaults);
const withPreset = deepMerge(baseConfig, presetConfig);
const final = deepMerge(withPreset, userOverrides);
// Add preset name to final config (so hooks can display it)
if (presetName) {
final.preset = presetName;
}
return final;
};
/**
* Extracts only allowed parameters from legacy config
* v2.8.0 only allows: github.pr.*, subagents.batchSize
* Advanced: analysis.ignoreExtensions, commitMessage.taskIdPattern, subagents.model
*
* @param {Object} legacyConfig - Legacy format config
* @returns {Object} Allowed parameters only
*/
const extractAllowedParams = (legacyConfig) => {
const allowed = {};
// GitHub PR config (fully allowed)
if (legacyConfig.github?.pr) {
allowed.github = { pr: {} };
if (legacyConfig.github.pr.defaultBase !== undefined) {
allowed.github.pr.defaultBase = legacyConfig.github.pr.defaultBase;
}
if (legacyConfig.github.pr.reviewers !== undefined) {
allowed.github.pr.reviewers = legacyConfig.github.pr.reviewers;
}
if (legacyConfig.github.pr.labelRules !== undefined) {
allowed.github.pr.labelRules = legacyConfig.github.pr.labelRules;
}
}
// Subagent batchSize (allowed)
if (legacyConfig.subagents?.batchSize !== undefined) {
allowed.subagents = { batchSize: legacyConfig.subagents.batchSize };
}
// Advanced params (allowed but warn)
if (legacyConfig.analysis?.ignoreExtensions !== undefined) {
if (!allowed.analysis) allowed.analysis = {};
allowed.analysis.ignoreExtensions = legacyConfig.analysis.ignoreExtensions;
logger.warning('ℹ️ Using advanced parameter: analysis.ignoreExtensions');
}
if (legacyConfig.commitMessage?.taskIdPattern !== undefined) {
if (!allowed.commitMessage) allowed.commitMessage = {};
allowed.commitMessage.taskIdPattern = legacyConfig.commitMessage.taskIdPattern;
logger.warning('ℹ️ Using advanced parameter: commitMessage.taskIdPattern');
}
if (legacyConfig.subagents?.model !== undefined) {
if (!allowed.subagents) allowed.subagents = {};
allowed.subagents.model = legacyConfig.subagents.model;
logger.warning('ℹ️ Using advanced parameter: subagents.model');
}
return allowed;
};
/**
* Deep merge two objects (user config overrides defaults)
*
* @param {Object} target - Target object (defaults)
* @param {Object} source - Source object (user overrides)
* @returns {Object} Merged object
*/
const deepMerge = (target, source) => {
const result = { ...target };
for (const key in source) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
result[key] = deepMerge(target[key] || {}, source[key]);
} else {
result[key] = source[key];
}
}
return result;
};
/**
* Get configuration instance
* Loads once, caches result
*
* Usage:
* import { getConfig } from './lib/config.js';
* const config = await getConfig();
* const maxFiles = config.analysis.maxFiles;
*/
let configInstance = null;
const getConfig = async () => {
if (!configInstance) {
configInstance = await loadUserConfig();
}
return configInstance;
};
// Export async loader and constants
export { getConfig, loadUserConfig, defaults, HARDCODED };
// Export a sync default for backwards compatibility (loads without preset)
// NOTE: Returns HARDCODED merged with defaults
export default deepMerge(HARDCODED, defaults);