#!/usr/bin/env bun /** * Branch Name Validator Hook * * Enforces conventional commit prefixes for branch names (GitHub Flow). * Valid patterns: feat/*, fix/*, chore/*, docs/*, test/*, refactor/*, perf/* * Allows main/master (protected branches). * * Performance target: <50ms (ADR-0010) * * @see {@link https://github.com/jbabin91/super-claude} for documentation */ import { checkPerformance, formatError, parseStdin } from './utils/index.js'; import { checkHookEnabled, getHookConfig, } from './utils/super-claude-config-loader.js'; /** * Extract branch name from git checkout command * * @param command Git command string * @returns Branch name or null if not a checkout -b command */ function extractBranchName(command: string): string | null { // Match: git checkout -b const match = /git\s+checkout\s+-b\s+([^\s]+)/.exec(command); return match ? match[1] : null; } /** * Validate branch name follows conventional commit pattern * * @param branchName Branch name to validate * @param allowedPrefixes List of valid prefixes * @param allowedBranches Set of branches allowed without prefix * @returns true if valid, false otherwise */ function isValidBranchName( branchName: string, allowedPrefixes: string[], allowedBranches: Set, ): boolean { // Allow protected branches if (allowedBranches.has(branchName)) { return true; } // Check if follows pattern: / const pattern = new RegExp(`^(${allowedPrefixes.join('|')})/[a-z0-9-]+$`); return pattern.test(branchName); } /** * Get suggested branch name from invalid name * * @param branchName Invalid branch name * @param allowedPrefixes List of valid prefixes * @returns Suggested valid branch name */ function suggestBranchName( branchName: string, allowedPrefixes: string[], ): string { // Try to extract a valid prefix if present for (const prefix of allowedPrefixes) { if (branchName.toLowerCase().includes(prefix)) { const description = branchName .toLowerCase() .replace(new RegExp(`^${prefix}[/-]?`), '') .replaceAll(/[^a-z0-9-]/g, '-') .replaceAll(/-+/g, '-') .replaceAll(/^-|-$/g, ''); return description ? `${prefix}/${description}` : `${prefix}/description`; } } // Default suggestion return `feat/${branchName.toLowerCase().replaceAll(/[^a-z0-9-]/g, '-')}`; } /** * Format blocking message * * @param branchName Invalid branch name * @param allowedPrefixes List of valid prefixes * @returns Formatted error message */ function formatBlockMessage( branchName: string, allowedPrefixes: string[], ): string { const suggested = suggestBranchName(branchName, allowedPrefixes); return [ '', '═'.repeat(70), '❌ INVALID BRANCH NAME', '═'.repeat(70), '', `Branch name: ${branchName}`, '', 'GitHub Flow + Conventional Commits requires:', ' /', '', 'Valid types:', ` ${allowedPrefixes.join(', ')}`, '', 'Description rules:', ' • Use kebab-case (lowercase with hyphens)', ' • Be descriptive and concise', ' • Use letters, numbers, and hyphens only', '', '✅ Valid examples:', ' feat/user-authentication', ' fix/memory-leak-in-parser', ' docs/api-reference', ' test/validate-branch-names', ' refactor/simplify-hooks', '', '❌ Invalid examples:', ' feature/auth (use "feat" not "feature")', ' fix_bug (use "/" not "_")', ' MyFeature (no type prefix)', ' feat/Fix Bug (use kebab-case)', '', `💡 Suggested: git checkout -b ${suggested}`, '', '═'.repeat(70), '', ].join('\n'); } /** * Main hook execution */ async function main(): Promise { const startTime = Date.now(); try { const input = await parseStdin(); // Check if hook is enabled checkHookEnabled(input.cwd, 'workflow', 'branchNameValidator'); // Get hook configuration const config = getHookConfig(input.cwd, 'workflow', 'branchNameValidator'); const allowedPrefixes = (config.allowedPrefixes as string[]) ?? [ 'feat', 'fix', 'chore', 'docs', 'test', 'refactor', 'perf', 'build', 'ci', 'revert', 'style', ]; const allowedBranchList = (config.allowedBranches as string[]) ?? [ 'main', 'master', 'develop', ]; const allowedBranches = new Set(allowedBranchList); // Only run for Bash tool if (input.tool_name !== 'Bash') { process.exit(0); } // Extract command from tool input const toolInput = input.tool_input!; const command = toolInput?.command as string | undefined; if (!command) { process.exit(0); } // Only check git checkout -b commands if (!command.includes('git checkout -b')) { process.exit(0); } // Extract branch name const branchName = extractBranchName(command); if (!branchName) { process.exit(0); } console.error('[DEBUG] branch-name-validator: Checking branch name'); console.error(`[DEBUG] Branch name: ${branchName}`); console.error(`[DEBUG] Allowed prefixes: ${allowedPrefixes.join(', ')}`); console.error( `[DEBUG] Allowed branches: ${[...allowedBranches].join(', ')}`, ); // Validate branch name if (!isValidBranchName(branchName, allowedPrefixes, allowedBranches)) { // Block the operation console.error('[DEBUG] BLOCKING - invalid branch name'); const output = { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: formatBlockMessage( branchName, allowedPrefixes, ), }, }; console.log(JSON.stringify(output)); checkPerformance(startTime, 50, 'branch-name-validator'); process.exit(0); } // Allow the operation - valid branch name console.error('[DEBUG] ALLOWING - valid branch name'); checkPerformance(startTime, 50, 'branch-name-validator'); process.exit(0); } catch (error) { console.error(formatError(error, 'branch-name-validator')); // On hook error, don't block the operation process.exit(0); } } // Execute await main();