/** * GitFlow Branch — Name normalization, type detection, validation. * Self-contained: imports only from local lib. */ import type { BranchType, GitFlowConfig } from './types.js'; import { execGit } from './git.js'; export function normalizeBranchName(input: string, maxLength = 50): string { return input .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .toLowerCase() .replace(/[ _']/g, '-') .replace(/[^a-z0-9-]/g, '') .replace(/-{2,}/g, '-') .replace(/^-/, '') .replace(/-$/, '') .slice(0, maxLength) .replace(/-$/, ''); } /** * Normalize a release/hotfix name while PRESERVING dots — versions like * `3.53.0` must stay `3.53.0` (never `3530`). Unlike normalizeBranchName, * keeps `.` so the segment maps 1:1 to the semantic version / tag. */ export function normalizeReleaseName(input: string, maxLength = 50): string { return input .normalize('NFD') .replace(/[̀-ͯ]/g, '') .toLowerCase() .replace(/[ _']/g, '-') .replace(/[^a-z0-9.\-]/g, '') // keep dots (vs normalizeBranchName which strips them) .replace(/-{2,}/g, '-') .replace(/\.{2,}/g, '.') .replace(/^[-.]+/, '') .replace(/[-.]+$/, '') .slice(0, maxLength) .replace(/[-.]+$/, ''); } /** * Per-type name normalization: feature names become kebab-case (dots stripped), * but release/hotfix names keep their version dots intact. */ export function normalizeNameForType(name: string, branchType: BranchType): string { return branchType === 'release' || branchType === 'hotfix' ? normalizeReleaseName(name) : normalizeBranchName(name); } export function detectBranchType(branchName: string): BranchType { if (branchName === 'main' || branchName === 'master') return 'main'; if (branchName === 'develop' || branchName === 'development') return 'develop'; if (branchName.startsWith('feature/')) return 'feature'; if (branchName.startsWith('release/')) return 'release'; if (branchName.startsWith('hotfix/')) return 'hotfix'; return 'other'; } export function getBaseBranch(branchType: BranchType, config: GitFlowConfig): string { switch (branchType) { case 'feature': return config.git.branches.develop; // GitFlow 4.x rule: a release is cut FROM develop (the integrated branch), // then PR'd to main. Only hotfixes branch off main. case 'release': return config.git.branches.develop; case 'hotfix': return config.git.branches.main; default: return config.git.branches.develop; } } export function getTargetBranch(branchType: BranchType, config: GitFlowConfig): string { switch (branchType) { case 'feature': return config.git.branches.develop; case 'release': case 'hotfix': return config.git.branches.main; default: return config.git.branches.develop; } } /** * Hard branch-target invariant (defense-in-depth against the feature→main * incident). Throws BEFORE any PR/merge so a misconfigured or empty `develop` * can never let a feature target main. * * - config must define both main and develop, and they must differ * - the resolved target must be non-empty * - a feature must NEVER target main/master */ export function assertBranchTarget(branchType: BranchType, target: string, config: GitFlowConfig): void { const main = config.git.branches.main; const develop = config.git.branches.develop; if (!main || !develop) { throw new Error( `GitFlow config invalid: branches.main ('${main}') and branches.develop ('${develop}') must both be set. ` + `Run gitflow init to reconfigure.`, ); } if (main === develop) { throw new Error(`GitFlow config invalid: develop ('${develop}') must differ from main ('${main}').`); } if (!target || !target.trim()) { throw new Error(`Refusing to proceed: empty target branch for ${branchType} branch.`); } if (branchType === 'feature' && (target === main || /^(main|master)$/i.test(target))) { throw new Error( `Refusing: a feature branch must target '${develop}', never '${target}'. ` + `(A release/hotfix targets main; a feature never does.)`, ); } } export function buildFullBranchName(name: string, branchType: BranchType, config: GitFlowConfig): string { const prefix = config.git.prefixes[branchType as keyof typeof config.git.prefixes]; if (!prefix) return name; return `${prefix}${name}`; } export function stripPrefix(fullBranch: string): string { return fullBranch.replace(/^(feature|release|hotfix)\//, ''); } export async function branchExists(branch: string, remote = false, cwd?: string): Promise { if (remote) { const result = await execGit(['ls-remote', '--heads', 'origin', branch], cwd); return result.stdout.length > 0; } const result = await execGit(['rev-parse', '--verify', branch], cwd); return result.exitCode === 0; } export function validateBranchName(name: string): { valid: boolean; error?: string } { if (!name || name.trim().length === 0) { return { valid: false, error: 'Branch name cannot be empty' }; } if (name.length > 100) { return { valid: false, error: 'Branch name too long (max 100 characters)' }; } if (/[~^:?*\[\\]/.test(name)) { return { valid: false, error: `Branch name contains invalid characters: ${name}` }; } if (name.startsWith('-') || name.endsWith('.') || name.endsWith('/') || name.includes('..')) { return { valid: false, error: `Branch name has invalid format: ${name}` }; } return { valid: true }; }