Files
gh-jbabin91-super-claude-pl…/hooks/branch-name-validator.ts
2025-11-29 18:50:12 +08:00

237 lines
6.2 KiB
TypeScript
Executable File

#!/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 <branch-name>
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<string>,
): boolean {
// Allow protected branches
if (allowedBranches.has(branchName)) {
return true;
}
// Check if follows pattern: <type>/<description>
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:',
' <type>/<description>',
'',
'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<void> {
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();