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
624 lines (560 loc) • 17.7 kB
text/typescript
/**
* 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,
HookContext,
HookResult,
HookPriority,
} from '../types.js';
import { HookRegistry } from '../registry.js';
/**
* Git commit hook result
*/
export interface GitCommitResult extends HookResult {
/** Original commit message */
originalMessage: string;
/** Modified commit message */
modifiedMessage: string;
/** Detected commit type */
commitType?: CommitType;
/** Extracted ticket reference */
ticketReference?: string;
/** Whether co-author was added */
coAuthorAdded: boolean;
/** Validation issues */
validationIssues?: CommitValidationIssue[];
/** Suggestions for improvement */
suggestions?: string[];
}
/**
* Commit type definition
*/
export type CommitType =
| 'feat'
| 'fix'
| 'docs'
| 'style'
| 'refactor'
| 'perf'
| 'test'
| 'build'
| 'ci'
| 'chore'
| 'revert';
/**
* Commit validation issue
*/
export interface CommitValidationIssue {
/** Issue type */
type: 'format' | 'length' | 'scope' | 'body' | 'breaking';
/** Issue severity */
severity: 'info' | 'warning' | 'error';
/** Issue description */
description: string;
/** Suggested fix */
suggestedFix?: string;
}
/**
* Conventional commit patterns
*/
interface CommitTypePattern {
/** Keywords that indicate this commit type */
keywords: string[];
/** Commit type */
type: CommitType;
/** Description */
description: string;
}
const COMMIT_TYPE_PATTERNS: CommitTypePattern[] = [
{
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: Array<{
name: string;
pattern: RegExp;
format: (match: RegExpExecArray) => string;
}> = [
{
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],
},
];
/**
* Co-author configuration
*/
interface CoAuthor {
name: string;
email: string;
}
const DEFAULT_CO_AUTHOR: CoAuthor = {
name: 'Claude Opus 4.5',
email: 'noreply@anthropic.com',
};
/**
* Commit message configuration
*/
interface CommitConfig {
/** Maximum subject line length */
maxSubjectLength: number;
/** Maximum body line length */
maxBodyLength: number;
/** Require conventional commit format */
requireConventional: boolean;
/** Add co-author by default */
addCoAuthor: boolean;
/** Co-author to add */
coAuthor: CoAuthor;
/** Add Claude Code reference */
addClaudeReference: boolean;
/** Allowed scopes */
allowedScopes?: string[];
}
const DEFAULT_CONFIG: CommitConfig = {
maxSubjectLength: 72,
maxBodyLength: 100,
requireConventional: true,
addCoAuthor: true,
coAuthor: DEFAULT_CO_AUTHOR,
addClaudeReference: true,
};
/**
* Git Commit Hook Manager
*/
export class GitCommitHook {
private registry: HookRegistry;
private config: CommitConfig;
constructor(registry: HookRegistry, config?: Partial<CommitConfig>) {
this.registry = registry;
this.config = { ...DEFAULT_CONFIG, ...config };
this.registerHooks();
}
/**
* Register git commit hooks
*/
private registerHooks(): void {
// 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: HookContext): Promise<HookResult> {
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 as string | undefined;
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: string,
branchName?: string
): Promise<GitCommitResult> {
const originalMessage = message;
let modifiedMessage = message;
const validationIssues: CommitValidationIssue[] = [];
const suggestions: string[] = [];
// 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: string | undefined;
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: string[] = [];
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
*/
private parseMessage(message: string): {
subject: string;
body?: string;
footer?: string;
} {
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
*/
private detectCommitType(message: string): CommitType | undefined {
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] as CommitType;
}
// Score each commit type based on keyword matches
// More specific/unique keywords get higher weight
const scores: Map<CommitType, number> = new Map();
// High-priority patterns (check these first as they're more specific)
const priorityPatterns: Array<{ pattern: RegExp; type: CommitType; weight: number }> = [
// 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: CommitType | undefined;
for (const [type, score] of scores) {
if (score > maxScore) {
maxScore = score;
detectedType = type;
}
}
return detectedType;
}
/**
* Check if message has conventional commit prefix
*/
private hasConventionalPrefix(message: string): boolean {
return /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?:/.test(message.toLowerCase());
}
/**
* Lowercase first letter of a string
*/
private lowercaseFirstLetter(str: string): string {
// 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
*/
private extractTicket(branchName: string): string | undefined {
for (const { pattern, format } of TICKET_PATTERNS) {
const match = pattern.exec(branchName);
if (match) {
return format(match);
}
}
return undefined;
}
/**
* Add ticket reference to message
*/
private addTicketReference(message: string, ticket: string): string {
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
*/
private validateConventional(message: string): CommitValidationIssue[] {
const issues: CommitValidationIssue[] = [];
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: string, branchName?: string): Promise<GitCommitResult> {
return this.processCommitMessage(message, branchName);
}
/**
* Format a commit message with heredoc-style for git
*/
formatForGit(message: string): string {
// Escape for heredoc usage
return `$(cat <<'EOF'
${message}
EOF
)`;
}
/**
* Generate a commit command with formatted message
*/
generateCommitCommand(message: string): string {
return `git commit -m "${this.formatForGit(message)}"`;
}
/**
* Get commit type description
*/
getCommitTypeDescription(type: CommitType): string {
const pattern = COMMIT_TYPE_PATTERNS.find(p => p.type === type);
return pattern?.description || 'Unknown commit type';
}
/**
* Get all available commit types
*/
getAllCommitTypes(): Array<{ type: CommitType; description: string }> {
return COMMIT_TYPE_PATTERNS.map(p => ({
type: p.type,
description: p.description,
}));
}
/**
* Update configuration
*/
setConfig(config: Partial<CommitConfig>): void {
this.config = { ...this.config, ...config };
}
/**
* Get current configuration
*/
getConfig(): CommitConfig {
return { ...this.config };
}
}
/**
* Create git commit hook
*/
export function createGitCommitHook(
registry: HookRegistry,
config?: Partial<CommitConfig>
): GitCommitHook {
return new GitCommitHook(registry, config);
}