claude-flow
Version:
Ruflo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration
450 lines • 16.4 kB
JavaScript
/**
* V3 Git Commit Hook
*
* TypeScript conversion of V2 git-commit-hook.sh.
* Provides conventional commit formatting, JIRA ticket extraction,
* co-author addition, and commit message validation.
*
* @module v3/shared/hooks/safety/git-commit
*/
import { HookEvent, HookPriority, } from '../types.js';
const COMMIT_TYPE_PATTERNS = [
{
keywords: ['add', 'implement', 'create', 'introduce', 'new'],
type: 'feat',
description: 'A new feature',
},
{
keywords: ['fix', 'resolve', 'repair', 'patch', 'correct', 'bug'],
type: 'fix',
description: 'A bug fix',
},
{
keywords: ['doc', 'docs', 'readme', 'comment', 'documentation'],
type: 'docs',
description: 'Documentation changes',
},
{
keywords: ['style', 'format', 'lint', 'whitespace', 'prettier'],
type: 'style',
description: 'Code style changes',
},
{
keywords: ['refactor', 'restructure', 'reorganize', 'extract', 'simplify'],
type: 'refactor',
description: 'Code refactoring',
},
{
keywords: ['perf', 'performance', 'optimize', 'speed', 'faster'],
type: 'perf',
description: 'Performance improvements',
},
{
keywords: ['test', 'tests', 'spec', 'coverage', 'unittest'],
type: 'test',
description: 'Adding or updating tests',
},
{
keywords: ['build', 'webpack', 'rollup', 'vite', 'esbuild', 'package'],
type: 'build',
description: 'Build system changes',
},
{
keywords: ['ci', 'github action', 'workflow', 'pipeline', 'travis', 'jenkins'],
type: 'ci',
description: 'CI/CD changes',
},
{
keywords: ['chore', 'update', 'upgrade', 'bump', 'dependency', 'deps'],
type: 'chore',
description: 'Maintenance tasks',
},
{
keywords: ['revert', 'rollback', 'undo'],
type: 'revert',
description: 'Reverting changes',
},
];
/**
* Ticket patterns (JIRA, GitHub, etc.)
*/
const TICKET_PATTERNS = [
{
name: 'JIRA',
pattern: /([A-Z]{2,10}-\d+)/,
format: (match) => match[1],
},
{
name: 'GitHub Issue',
pattern: /#(\d+)/,
format: (match) => `#${match[1]}`,
},
{
name: 'Linear',
pattern: /([A-Z]{2,10}-[A-Z0-9]+)/,
format: (match) => match[1],
},
];
const DEFAULT_CO_AUTHOR = {
name: 'Claude Opus 4.5',
email: 'noreply@anthropic.com',
};
const DEFAULT_CONFIG = {
maxSubjectLength: 72,
maxBodyLength: 100,
requireConventional: true,
addCoAuthor: true,
coAuthor: DEFAULT_CO_AUTHOR,
addClaudeReference: true,
};
/**
* Git Commit Hook Manager
*/
export class GitCommitHook {
registry;
config;
constructor(registry, config) {
this.registry = registry;
this.config = { ...DEFAULT_CONFIG, ...config };
this.registerHooks();
}
/**
* Register git commit hooks
*/
registerHooks() {
// We use PreCommand hook since there's no specific commit hook event
// In practice, this would be called when detecting git commit commands
this.registry.register(HookEvent.PreCommand, this.handlePreCommit.bind(this), HookPriority.Normal, { name: 'git-commit:pre-commit' });
}
/**
* Handle pre-commit (when a git commit command is detected)
*/
async handlePreCommit(context) {
const command = context.command?.command || '';
// Only process git commit commands
if (!command.includes('git commit')) {
return { success: true };
}
// Extract message from command if present
const messageMatch = command.match(/-m\s+["']([^"']+)["']/);
if (!messageMatch) {
return { success: true }; // No message to process
}
const message = messageMatch[1];
const branchName = context.metadata?.branchName;
const result = await this.processCommitMessage(message, branchName);
// Modify the command with the new message
if (result.success && result.modifiedMessage !== message) {
const modifiedCommand = command.replace(/-m\s+["'][^"']+["']/, `-m "${result.modifiedMessage.replace(/"/g, '\\"')}"`);
return {
...result,
data: {
command: {
...context.command,
command: modifiedCommand,
},
},
};
}
return result;
}
/**
* Process commit message
*/
async processCommitMessage(message, branchName) {
const originalMessage = message;
let modifiedMessage = message;
const validationIssues = [];
const suggestions = [];
// Parse existing message structure
const { subject, body, footer } = this.parseMessage(message);
// Detect commit type
const commitType = this.detectCommitType(subject);
// Add commit type prefix if not present and type was detected
if (commitType && !this.hasConventionalPrefix(subject)) {
modifiedMessage = `${commitType}: ${this.lowercaseFirstLetter(subject)}`;
suggestions.push(`Added conventional commit prefix: ${commitType}`);
}
else if (!commitType && this.config.requireConventional && !this.hasConventionalPrefix(subject)) {
// No type detected but conventional commits are required - suggest adding a prefix
suggestions.push('Consider adding a conventional commit prefix (feat:, fix:, docs:, etc.)');
}
// Validate subject length
if (subject.length > this.config.maxSubjectLength) {
validationIssues.push({
type: 'length',
severity: 'warning',
description: `Subject line exceeds ${this.config.maxSubjectLength} characters`,
suggestedFix: 'Shorten the subject line',
});
}
// Extract ticket reference from branch name
let ticketReference;
if (branchName) {
ticketReference = this.extractTicket(branchName);
if (ticketReference && !modifiedMessage.includes(ticketReference)) {
modifiedMessage = this.addTicketReference(modifiedMessage, ticketReference);
suggestions.push(`Added ticket reference: ${ticketReference}`);
}
}
// Add Claude Code reference and co-author
let coAuthorAdded = false;
if (this.config.addClaudeReference || this.config.addCoAuthor) {
const additions = [];
if (this.config.addClaudeReference) {
additions.push('\n\nGenerated with [Claude Code](https://claude.com/claude-code)');
}
if (this.config.addCoAuthor) {
additions.push(`\n\nCo-Authored-By: ${this.config.coAuthor.name} <${this.config.coAuthor.email}>`);
coAuthorAdded = true;
}
// Only add if not already present
for (const addition of additions) {
const searchStr = addition.trim().split('\n')[0];
if (!modifiedMessage.includes(searchStr)) {
modifiedMessage += addition;
}
}
}
// Validate conventional commit format
if (this.config.requireConventional) {
const conventionalIssues = this.validateConventional(modifiedMessage);
validationIssues.push(...conventionalIssues);
}
return {
success: true,
originalMessage,
modifiedMessage,
commitType,
ticketReference,
coAuthorAdded,
validationIssues: validationIssues.length > 0 ? validationIssues : undefined,
suggestions: suggestions.length > 0 ? suggestions : undefined,
};
}
/**
* Parse commit message into parts
*/
parseMessage(message) {
const parts = message.split('\n\n');
return {
subject: parts[0] || '',
body: parts[1],
footer: parts.slice(2).join('\n\n'),
};
}
/**
* Detect commit type from message
*/
detectCommitType(message) {
const lowerMessage = message.toLowerCase();
// First check if message already has conventional prefix
const prefixMatch = lowerMessage.match(/^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?:/);
if (prefixMatch) {
return prefixMatch[1];
}
// Score each commit type based on keyword matches
// More specific/unique keywords get higher weight
const scores = new Map();
// High-priority patterns (check these first as they're more specific)
const priorityPatterns = [
// Test patterns - high priority because "add tests" should be 'test' not 'feat'
{ pattern: /\b(test|tests|spec|specs|unittest|unit test|testing)\b/i, type: 'test', weight: 3 },
// Docs patterns
{ pattern: /\b(doc|docs|documentation|readme|comment|comments)\b/i, type: 'docs', weight: 3 },
// Revert patterns
{ pattern: /\b(revert|rollback|undo)\b/i, type: 'revert', weight: 3 },
// Fix patterns (bug-specific)
{ pattern: /\b(fix|bug|bugfix|resolve|patch|hotfix)\b/i, type: 'fix', weight: 2 },
// CI patterns
{ pattern: /\b(ci|github action|workflow|pipeline|travis|jenkins|circleci)\b/i, type: 'ci', weight: 3 },
// Build patterns
{ pattern: /\b(build|webpack|rollup|vite|esbuild|bundler|package\.json)\b/i, type: 'build', weight: 2 },
// Perf patterns
{ pattern: /\b(perf|performance|optimize|speed|faster|slow)\b/i, type: 'perf', weight: 2 },
// Refactor patterns
{ pattern: /\b(refactor|restructure|reorganize|extract|simplify|clean)\b/i, type: 'refactor', weight: 2 },
// Style patterns
{ pattern: /\b(style|format|lint|whitespace|prettier|eslint)\b/i, type: 'style', weight: 2 },
// Chore patterns - specifically for dependencies
{ pattern: /\b(dependency|dependencies|deps|bump|upgrade version)\b/i, type: 'chore', weight: 2 },
// Generic update is lower priority (could be chore or other)
{ pattern: /\b(update)\b/i, type: 'chore', weight: 1 },
// Feat patterns (generic add/create/implement)
{ pattern: /\b(add|implement|create|introduce|new feature)\b/i, type: 'feat', weight: 1 },
];
// Calculate scores for each pattern
for (const { pattern, type, weight } of priorityPatterns) {
if (pattern.test(lowerMessage)) {
const currentScore = scores.get(type) || 0;
scores.set(type, currentScore + weight);
}
}
// Find highest scoring type
let maxScore = 0;
let detectedType;
for (const [type, score] of scores) {
if (score > maxScore) {
maxScore = score;
detectedType = type;
}
}
return detectedType;
}
/**
* Check if message has conventional commit prefix
*/
hasConventionalPrefix(message) {
return /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?:/.test(message.toLowerCase());
}
/**
* Lowercase first letter of a string
*/
lowercaseFirstLetter(str) {
// Don't lowercase if it's an acronym or proper noun
if (/^[A-Z]{2,}/.test(str)) {
return str;
}
return str.charAt(0).toLowerCase() + str.slice(1);
}
/**
* Extract ticket reference from branch name
*/
extractTicket(branchName) {
for (const { pattern, format } of TICKET_PATTERNS) {
const match = pattern.exec(branchName);
if (match) {
return format(match);
}
}
return undefined;
}
/**
* Add ticket reference to message
*/
addTicketReference(message, ticket) {
const parts = message.split('\n\n');
const subject = parts[0];
const rest = parts.slice(1).join('\n\n');
// Add refs line
if (rest) {
return `${subject}\n\nRefs: ${ticket}\n\n${rest}`;
}
return `${subject}\n\nRefs: ${ticket}`;
}
/**
* Validate conventional commit format
*/
validateConventional(message) {
const issues = [];
const lines = message.split('\n');
const subject = lines[0] || '';
// Check for conventional prefix
if (!this.hasConventionalPrefix(subject)) {
issues.push({
type: 'format',
severity: 'warning',
description: 'Missing conventional commit prefix',
suggestedFix: 'Add a prefix like feat:, fix:, docs:, etc.',
});
}
// Check subject line starts with lowercase (after prefix)
const afterPrefix = subject.replace(/^[a-z]+(\(.+\))?: /, '');
if (afterPrefix && /^[A-Z]/.test(afterPrefix) && !/^[A-Z]{2,}/.test(afterPrefix)) {
issues.push({
type: 'format',
severity: 'info',
description: 'Subject should start with lowercase (conventional style)',
suggestedFix: 'Use lowercase for the first word after the prefix',
});
}
// Check for period at end of subject
if (subject.endsWith('.')) {
issues.push({
type: 'format',
severity: 'info',
description: 'Subject line should not end with a period',
suggestedFix: 'Remove the trailing period',
});
}
// Check body line lengths
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (line.length > this.config.maxBodyLength && !line.startsWith('Co-Authored-By:')) {
issues.push({
type: 'body',
severity: 'info',
description: `Line ${i + 1} exceeds ${this.config.maxBodyLength} characters`,
suggestedFix: 'Wrap long lines in the commit body',
});
break; // Only report first occurrence
}
}
// Check for breaking change indicator
if (subject.includes('!:') || message.includes('BREAKING CHANGE:')) {
issues.push({
type: 'breaking',
severity: 'info',
description: 'Breaking change detected - ensure changelog is updated',
});
}
return issues;
}
/**
* Process commit message manually
*/
async process(message, branchName) {
return this.processCommitMessage(message, branchName);
}
/**
* Format a commit message with heredoc-style for git
*/
formatForGit(message) {
// Escape for heredoc usage
return `$(cat <<'EOF'
${message}
EOF
)`;
}
/**
* Generate a commit command with formatted message
*/
generateCommitCommand(message) {
return `git commit -m "${this.formatForGit(message)}"`;
}
/**
* Get commit type description
*/
getCommitTypeDescription(type) {
const pattern = COMMIT_TYPE_PATTERNS.find(p => p.type === type);
return pattern?.description || 'Unknown commit type';
}
/**
* Get all available commit types
*/
getAllCommitTypes() {
return COMMIT_TYPE_PATTERNS.map(p => ({
type: p.type,
description: p.description,
}));
}
/**
* Update configuration
*/
setConfig(config) {
this.config = { ...this.config, ...config };
}
/**
* Get current configuration
*/
getConfig() {
return { ...this.config };
}
}
/**
* Create git commit hook
*/
export function createGitCommitHook(registry, config) {
return new GitCommitHook(registry, config);
}
//# sourceMappingURL=git-commit.js.map