UNPKG

@elsikora/git-branch-lint

Version:
1,050 lines (1,021 loc) • 39.3 kB
#!/usr/bin/env node import yargs from 'yargs'; import { cosmiconfig } from 'cosmiconfig'; import { exec } from 'node:child_process'; import { promisify } from 'node:util'; import inquirer from 'inquirer'; import chalk from 'chalk'; const DEFAULT_BRANCH_SUBJECT_PATTERN_SOURCE = "[a-z0-9-]+"; const TICKET_ID_EXAMPLE = "PROJ-123"; const TICKET_ID_PATTERN_SOURCE = "[a-z]{2,}-[0-9]+"; const OPTIONAL_PLACEHOLDER_SUFFIXES = ["-"]; /** * Domain policy for working with branch templates and placeholders. */ class BranchTemplatePolicy { static PLACEHOLDER_PATTERN = /:([a-z](?:[a-z0-9-]*[a-z0-9])?)/gi; buildBranchName(branchPattern, placeholderValues) { let resolvedBranchName = branchPattern; for (const placeholderName of this.getPlaceholders(branchPattern)) { const placeholderToken = `:${placeholderName}`; const rawValue = placeholderValues[placeholderName] ?? ""; const normalizedValue = rawValue.trim(); if (normalizedValue.length === 0 && this.isPlaceholderOptional(branchPattern, placeholderName)) { resolvedBranchName = this.removeOptionalPlaceholder(resolvedBranchName, placeholderName); continue; } resolvedBranchName = resolvedBranchName.replaceAll(placeholderToken, normalizedValue); } return this.normalizeBranchNameDelimiters(resolvedBranchName); } buildValidationPatterns(branchPattern) { let patternVariants = [branchPattern]; for (const placeholderName of this.getPlaceholders(branchPattern)) { if (!this.isPlaceholderOptional(branchPattern, placeholderName)) { continue; } patternVariants = patternVariants.flatMap((variant) => [variant, this.removeOptionalPlaceholder(variant, placeholderName)]); } return [...new Set(patternVariants.map((variant) => this.normalizeBranchNameDelimiters(variant)).filter((variant) => variant.length > 0))]; } getPlaceholders(branchPattern) { const matches = branchPattern.matchAll(BranchTemplatePolicy.PLACEHOLDER_PATTERN); const orderedPlaceholders = []; for (const match of matches) { const placeholderName = match[1]; if (!orderedPlaceholders.includes(placeholderName)) { orderedPlaceholders.push(placeholderName); } } return orderedPlaceholders; } isPlaceholderOptional(branchPattern, placeholderName) { const placeholderToken = `:${placeholderName}`; return OPTIONAL_PLACEHOLDER_SUFFIXES.some((suffix) => branchPattern.includes(`${placeholderToken}${suffix}`)); } resolvePlaceholderPatternSource(placeholderName, branchTypes, subjectPattern) { if (placeholderName === "type") { return this.createAlternationPattern(branchTypes); } if (typeof subjectPattern === "object" && subjectPattern?.[placeholderName]) { return subjectPattern[placeholderName]; } if (placeholderName === "ticket") { return TICKET_ID_PATTERN_SOURCE; } if (typeof subjectPattern === "string") { return subjectPattern; } return DEFAULT_BRANCH_SUBJECT_PATTERN_SOURCE; } createAlternationPattern(branchTypes) { if (branchTypes.length === 0) { return DEFAULT_BRANCH_SUBJECT_PATTERN_SOURCE; } return branchTypes.map((branchType) => this.escapeRegex(branchType)).join("|"); } escapeRegex(value) { return value.replaceAll(/[\\^$.*+?()[\]{}|/-]/g, String.raw `\$&`); } isDelimiter(character) { return character === "." || character === "-" || character === "/" || character === "_"; } normalizeBranchNameDelimiters(branchName) { const compactedBranchName = branchName .replaceAll(/\/{2,}/g, "/") .replaceAll(/-{2,}/g, "-") .replaceAll(/_{2,}/g, "_") .replaceAll(/\.{2,}/g, "."); return this.trimDelimiterEdges(compactedBranchName); } removeOptionalPlaceholder(branchPattern, placeholderName) { const placeholderToken = `:${placeholderName}`; let updatedPattern = branchPattern; for (const suffix of OPTIONAL_PLACEHOLDER_SUFFIXES) { updatedPattern = updatedPattern.replaceAll(`${placeholderToken}${suffix}`, ""); } updatedPattern = updatedPattern.replaceAll(placeholderToken, ""); return updatedPattern; } trimDelimiterEdges(value) { let startIndex = 0; let endIndex = value.length - 1; while (startIndex <= endIndex && this.isDelimiter(value[startIndex])) { startIndex++; } while (endIndex >= startIndex && this.isDelimiter(value[endIndex])) { endIndex--; } return value.slice(startIndex, endIndex + 1); } } /** * Use case for assembling a branch name from validated user inputs. */ class BuildBranchNameUseCase { branchTemplatePolicy; constructor(branchTemplatePolicy = new BranchTemplatePolicy()) { this.branchTemplatePolicy = branchTemplatePolicy; } execute(parameters) { return this.branchTemplatePolicy.buildBranchName(parameters.branchPattern, parameters.placeholderValues); } } /** * Base error class for branch creation errors */ class BranchCreationError extends Error { constructor(message) { super(message); this.name = "BranchCreationError"; } } /** * Error thrown when trying to create a branch that already exists */ class BranchAlreadyExistsError extends BranchCreationError { constructor(branchName) { super(`You are already on branch ${branchName}!`); this.name = "BranchAlreadyExistsError"; } } /** * Error thrown when git operation fails */ class GitOperationError extends BranchCreationError { constructor(operation, details) { const message = details ? `Git operation failed: ${operation} - ${details}` : `Git operation failed: ${operation}`; super(message); this.name = "GitOperationError"; } } /** * Error thrown when working directory has uncommitted changes */ class UncommittedChangesError extends BranchCreationError { constructor() { super("You have uncommitted changes. Please commit or stash them before creating a new branch."); this.name = "UncommittedChangesError"; } } /** * Use case for checking working directory status */ class CheckWorkingDirectoryUseCase { branchRepository; constructor(branchRepository) { this.branchRepository = branchRepository; } /** * Execute the use case * @throws {UncommittedChangesError} When there are uncommitted changes */ async execute() { const hasChanges = await this.branchRepository.hasUncommittedChanges(); if (hasChanges) { throw new UncommittedChangesError(); } } } /** * Use case for creating a new branch */ class CreateBranchUseCase { branchRepository; constructor(branchRepository) { this.branchRepository = branchRepository; } /** * Execute the use case * @param branchName The name of the branch to create * @throws {BranchAlreadyExistsError} When trying to create current branch */ async execute(branchName) { const currentBranch = await this.branchRepository.getCurrentBranchName(); if (currentBranch === branchName) { throw new BranchAlreadyExistsError(branchName); } await this.branchRepository.createBranch(branchName); } } /** * Use case for retrieving branch configuration */ class GetBranchConfigUseCase { configRepository; constructor(configRepository) { this.configRepository = configRepository; } /** * Execute the use case * @param appName - The application name * @returns The branch configuration */ async execute(appName) { try { return await this.configRepository.getConfig(appName); } catch (error) { throw new Error(`Failed to get branch config: ${error.message}`); } } } /** * Use case for retrieving effective branch pattern. */ class GetBranchPatternUseCase { DEFAULT_BRANCH_PATTERN = ":type/:name"; execute(config) { return config.rules?.["branch-pattern"] ?? this.DEFAULT_BRANCH_PATTERN; } } /** * Use case for resolving placeholder definitions from branch pattern config. */ class GetBranchPlaceholderDefinitionsUseCase { branchTemplatePolicy; constructor(branchTemplatePolicy = new BranchTemplatePolicy()) { this.branchTemplatePolicy = branchTemplatePolicy; } execute(parameters) { const branchPattern = parameters.config.rules?.["branch-pattern"]; const subjectPattern = parameters.config.rules?.["branch-subject-pattern"]; if (!branchPattern) { return []; } const branchTypes = Array.isArray(parameters.config.branches) ? parameters.config.branches : Object.keys(parameters.config.branches); const placeholderNames = this.branchTemplatePolicy.getPlaceholders(branchPattern); return placeholderNames.map((placeholderName) => { if (placeholderName === "type") { return { isOptional: false, isTypePlaceholder: true, placeholderName, }; } return { ...(placeholderName === "ticket" && { example: TICKET_ID_EXAMPLE }), isOptional: this.branchTemplatePolicy.isPlaceholderOptional(branchPattern, placeholderName), isTypePlaceholder: false, patternSource: this.branchTemplatePolicy.resolvePlaceholderPatternSource(placeholderName, branchTypes, subjectPattern), placeholderName, }; }); } } /** * Use case for getting the current branch name */ class GetCurrentBranchUseCase { branchRepository; constructor(branchRepository) { this.branchRepository = branchRepository; } /** * Execute the use case * @returns The current branch name */ async execute() { return this.branchRepository.getCurrentBranchName(); } } /** * Domain entity representing a Git branch */ class Branch { VALUE; constructor(name) { this.VALUE = name; } getName() { return this.VALUE; } isProhibited(prohibitedNames) { return prohibitedNames.includes(this.VALUE); } isTooLong(maxLength) { if (maxLength === undefined) { return false; } return this.VALUE.length > maxLength; } isTooShort(minLength) { if (minLength === undefined) { return false; } return this.VALUE.length < minLength; } } /** * Base error class for branch linting errors */ class LintError extends Error { constructor(message) { super(message); this.name = "LintError"; } } /** * Error thrown when branch name is too long */ class BranchTooLongError extends LintError { constructor(branchName, maxLength) { super(`Branch name "${branchName}" is too long (maximum length: ${maxLength})`); this.name = "BranchTooLongError"; } } /** * Error thrown when branch name is too short */ class BranchTooShortError extends LintError { constructor(branchName, minLength) { super(`Branch name "${branchName}" is too short (minimum length: ${minLength})`); this.name = "BranchTooShortError"; } } /** * Error thrown when a branch placeholder pattern in config is invalid. */ class InvalidBranchPatternConfigError extends LintError { constructor(placeholderName, patternSource) { super(`Invalid branch pattern config for "${placeholderName}": ${patternSource}`); this.name = "InvalidBranchPatternConfigError"; } } /** * Error thrown when branch name doesn't match the pattern */ class PatternMatchError extends LintError { constructor(branchName) { super(`Branch name "${branchName}" doesn't match pattern`); this.name = "PatternMatchError"; } } /** * Error thrown when branch name is prohibited */ class ProhibitedBranchError extends LintError { constructor(branchName) { super(`Branch name "${branchName}" is prohibited`); this.name = "ProhibitedBranchError"; } } /** * Use case for linting branch names */ class LintBranchNameUseCase { BRANCH_TEMPLATE_POLICY; constructor(branchTemplatePolicy = new BranchTemplatePolicy()) { this.BRANCH_TEMPLATE_POLICY = branchTemplatePolicy; } /** * Execute the use case * @param branchName The branch name to lint * @param config The branch configuration * @throws {ProhibitedBranchError} When branch name is prohibited * @throws {PatternMatchError} When branch name doesn't match pattern * @throws {BranchTooShortError} When branch name is shorter than the minimum length * @throws {BranchTooLongError} When branch name is longer than the maximum length */ execute(branchName, config) { const branch = new Branch(branchName); const configRules = config.rules ?? {}; const ignoreList = config.ignore ?? []; if (configRules?.["branch-prohibited"] && branch.isProhibited(configRules["branch-prohibited"])) { throw new ProhibitedBranchError(branchName); } if (ignoreList.length > 0 && ignoreList.includes(branchName)) { return; } if (configRules?.["branch-min-length"] && branch.isTooShort(configRules["branch-min-length"])) { throw new BranchTooShortError(branchName, configRules["branch-min-length"]); } if (configRules?.["branch-max-length"] && branch.isTooLong(configRules["branch-max-length"])) { throw new BranchTooLongError(branchName, configRules["branch-max-length"]); } this.validatePattern(branch.getName(), config); } /** * Validate the branch name against the pattern * @param branchName The branch name to validate * @param config The branch configuration * @throws {PatternMatchError} When branch name doesn't match pattern */ validatePattern(branchName, config) { // Start with original pattern const branchNamePattern = config.rules?.["branch-pattern"]; const subjectPattern = config.rules?.["branch-subject-pattern"]; if (!branchNamePattern) { return; } const branchTypes = Array.isArray(config.branches) ? config.branches : Object.keys(config.branches); const placeholders = this.BRANCH_TEMPLATE_POLICY.getPlaceholders(branchNamePattern); const patternVariants = this.BRANCH_TEMPLATE_POLICY.buildValidationPatterns(branchNamePattern); for (const patternVariant of patternVariants) { let resolvedPattern = patternVariant; for (const placeholder of placeholders) { const placeholderToken = `:${placeholder}`; if (!resolvedPattern.includes(placeholderToken)) { continue; } const patternSource = this.BRANCH_TEMPLATE_POLICY.resolvePlaceholderPatternSource(placeholder, branchTypes, subjectPattern); resolvedPattern = resolvedPattern.replaceAll(placeholderToken, `(${patternSource})`); } const expression = new RegExp(`^${resolvedPattern}$`); if (expression.test(branchName)) { return; } } throw new PatternMatchError(branchName); } } /** * Domain policy for ticket identifier normalization and validation. */ class TicketIdPolicy { static TICKET_ID_PATTERN = new RegExp(`^${TICKET_ID_PATTERN_SOURCE}$`); isEmpty(candidate) { return candidate.trim().length === 0; } isValid(candidate) { const normalizedCandidate = this.normalize(candidate); if (normalizedCandidate.length === 0) { return false; } return TicketIdPolicy.TICKET_ID_PATTERN.test(normalizedCandidate); } normalize(candidate) { return candidate.trim().toLowerCase(); } } /** * Use case for normalizing optional ticket identifier input. */ class NormalizeTicketIdUseCase { ticketIdPolicy; constructor(ticketIdPolicy = new TicketIdPolicy()) { this.ticketIdPolicy = ticketIdPolicy; } execute(ticketId) { return this.ticketIdPolicy.normalize(ticketId); } } /** * Use case for pushing a branch to remote repository */ class PushBranchUseCase { branchRepository; constructor(branchRepository) { this.branchRepository = branchRepository; } /** * Execute the use case * @param branchName The name of the branch to push */ async execute(branchName) { await this.branchRepository.pushBranch(branchName); } } const BRANCH_MAX_LENGTH = 50; const BRANCH_MIN_LENGTH = 5; const DEFAULT_BRANCH_LINT_CONFIG = { branches: { bugfix: { description: "šŸž Fixing issues in existing functionality", title: "Bugfix" }, feature: { description: "šŸ†• Integration of new functionality", title: "Feature" }, hotfix: { description: "šŸš‘ Critical fixes for urgent issues", title: "Hotfix" }, release: { description: "šŸ“¦ Preparing a new release version", title: "Release" }, support: { description: "šŸ› ļø Support and maintenance tasks", title: "Support" }, }, ignore: ["dev"], rules: { "branch-max-length": BRANCH_MAX_LENGTH, "branch-min-length": BRANCH_MIN_LENGTH, "branch-pattern": ":type/:ticket-:name", "branch-prohibited": ["main", "master", "release"], "branch-subject-pattern": "[a-z0-9-]+", }, }; /** * Cosmiconfig implementation of ConfigRepository */ class CosmiconfigRepository { /** * Get the branch configuration * @param appName The name of the application * @returns A promise that resolves to the branch configuration */ async getConfig(appName) { const configExplorer = cosmiconfig(appName, { packageProp: `elsikora.${appName}`, searchPlaces: ["package.json", `.elsikora/.${appName}rc`, `.elsikora/.${appName}rc.json`, `.elsikora/.${appName}rc.yaml`, `.elsikora/.${appName}rc.yml`, `.elsikora/.${appName}rc.js`, `.elsikora/.${appName}rc.ts`, `.elsikora/.${appName}rc.mjs`, `.elsikora/.${appName}rc.cjs`, `.elsikora/${appName}.config.js`, `.elsikora/${appName}.config.ts`, `.elsikora/${appName}.config.mjs`, `.elsikora/${appName}.config.cjs`], }); const result = await configExplorer.search(); if (!result || result.isEmpty) { return DEFAULT_BRANCH_LINT_CONFIG; } // Convert the config to match our interfaces const providedConfig = result.config; const mergedConfig = { ...DEFAULT_BRANCH_LINT_CONFIG, ...providedConfig, branches: providedConfig.branches ?? DEFAULT_BRANCH_LINT_CONFIG.branches, ignore: providedConfig.ignore ?? DEFAULT_BRANCH_LINT_CONFIG.ignore, rules: { ...DEFAULT_BRANCH_LINT_CONFIG.rules, ...providedConfig.rules, }, }; return mergedConfig; } } const execAsync = promisify(exec); /** * Git implementation of BranchRepository */ class GitBranchRepository { /** * Create a new branch * @param branchName The name of the branch to create */ async createBranch(branchName) { try { const command = `git checkout -b ${branchName}`; await execAsync(command); } catch (error) { throw new GitOperationError("create branch", error.message); } } /** * Get the current branch name * @returns A promise that resolves to the current branch name */ async getCurrentBranchName() { try { const command = "git rev-parse --abbrev-ref HEAD"; const { stdout } = await execAsync(command); return stdout.trim(); } catch (error) { throw new GitOperationError("get current branch", error.message); } } /** * Check if working directory has uncommitted changes * @returns A promise that resolves to true if there are uncommitted changes */ async hasUncommittedChanges() { try { const command = "git status --porcelain"; const { stdout } = await execAsync(command); return stdout.trim().length > 0; } catch (error) { throw new GitOperationError("check working directory status", error.message); } } /** * Push branch to remote repository * @param branchName The name of the branch to push */ async pushBranch(branchName) { try { const command = `git push --set-upstream origin ${branchName}`; await execAsync(command); } catch (error) { throw new GitOperationError("push branch", error.message); } } } /** * Use case for validating interactive placeholder values. */ class ValidateBranchPlaceholderValueUseCase { execute(parameters) { const normalizedValue = parameters.value.trim(); if (parameters.isOptional && normalizedValue.length === 0) { return { isValid: true }; } let expression; try { const flags = parameters.placeholderName === "ticket" ? "i" : ""; expression = new RegExp(`^${parameters.patternSource}$`, flags); } catch { throw new InvalidBranchPatternConfigError(parameters.placeholderName, parameters.patternSource); } if (!expression.test(normalizedValue)) { return { errorMessage: `Invalid ${parameters.placeholderName} format`, isValid: false, }; } return { isValid: true }; } } /** * Formatter for branch choices in CLI prompts */ class BranchChoiceFormatter { // eslint-disable-next-line @elsikora/typescript/no-magic-numbers EMPTY_SPACING_OFFSET = 5; /** * Format branch list for CLI selection * @param branchList The list of branches to format * @returns Formatted choices for inquirer prompt */ format(branchList) { if (Array.isArray(branchList)) { return this.formatSimpleList(branchList); } return this.formatDetailedList(branchList); } /** * Format detailed branch list (object with descriptions) */ formatDetailedList(branchList) { const branchNames = Object.keys(branchList); const maxNameLength = Math.max(...branchNames.map((name) => name.length)); return branchNames.map((branchName) => { const branch = branchList[branchName]; const padding = " ".repeat(maxNameLength - branchName.length + this.EMPTY_SPACING_OFFSET); return { name: `${branch.title}:${padding}${branch.description}`, short: branch.title, value: branchName, }; }); } /** * Format simple branch list (array of strings) */ formatSimpleList(branchList) { return branchList.map((branchName) => ({ name: branchName, short: branchName, value: branchName, })); } } /** * Prompt service for branch creation interactions */ class BranchCreationPrompt { BRANCH_CHOICE_FORMATTER; VALIDATE_BRANCH_PLACEHOLDER_VALUE_USE_CASE; constructor() { this.BRANCH_CHOICE_FORMATTER = new BranchChoiceFormatter(); this.VALIDATE_BRANCH_PLACEHOLDER_VALUE_USE_CASE = new ValidateBranchPlaceholderValueUseCase(); } /** * Prompt for branch type selection * @param branches Available branch types * @returns Selected branch type */ async promptBranchType(branches) { const choices = this.BRANCH_CHOICE_FORMATTER.format(branches); const result = await inquirer.prompt([ { choices, message: "Select the type of branch you're creating:", name: "branchType", type: "list", }, ]); return result.branchType; } /** * Prompt for a branch placeholder value based on a validation pattern. */ async promptPlaceholder(options) { const result = await inquirer.prompt([ { message: this.buildPlaceholderPromptMessage(options), name: "value", transformer: (input) => { if (options.isOptional && input.trim() === "") { return "\u001B[2m(Enter to skip)\u001B[0m"; } return input; }, type: "input", validate: (input) => { const validation = this.VALIDATE_BRANCH_PLACEHOLDER_VALUE_USE_CASE.execute({ isOptional: options.isOptional, patternSource: options.patternSource, placeholderName: options.placeholderName, value: input, }); if (validation.isValid) { return true; } return validation.errorMessage ?? this.buildPlaceholderValidationMessage(options); }, }, ]); return result.value.trim(); } /** * Prompt to push branch to remote * @returns Whether to push the branch */ async promptPushBranch() { const result = await inquirer.prompt([ { // eslint-disable-next-line @elsikora/typescript/naming-convention default: false, message: "Do you want to push the branch to the remote repository?", name: "shouldPush", type: "confirm", }, ]); return result.shouldPush; } buildPlaceholderPromptMessage(options) { const normalizedPlaceholderName = options.placeholderName.replaceAll("-", " "); const capitalizedPlaceholderName = normalizedPlaceholderName[0].toUpperCase() + normalizedPlaceholderName.slice(1); const optionalPart = options.isOptional ? " optional" : ""; const examplePart = options.example ? `, e.g., ${options.example}` : ""; return `${capitalizedPlaceholderName}${optionalPart}${examplePart}:`; } buildPlaceholderValidationMessage(options) { return `Invalid ${options.placeholderName} format`; } } /** * Controller for branch creation CLI operations */ class CreateBranchController { BRANCH_CREATION_PROMPT; BUILD_BRANCH_NAME_USE_CASE; CHECK_WORKING_DIRECTORY_USE_CASE; CREATE_BRANCH_USE_CASE; GET_BRANCH_CONFIG_USE_CASE; GET_BRANCH_PATTERN_USE_CASE; GET_BRANCH_PLACEHOLDER_DEFINITIONS_USE_CASE; LINT_BRANCH_NAME_USE_CASE; NORMALIZE_TICKET_ID_USE_CASE; PUSH_BRANCH_USE_CASE; constructor(buildBranchNameUseCase, checkWorkingDirectoryUseCase, createBranchUseCase, getBranchPatternUseCase, getBranchPlaceholderDefinitionsUseCase, getBranchConfigUseCase, lintBranchNameUseCase, normalizeTicketIdUseCase, pushBranchUseCase) { this.BUILD_BRANCH_NAME_USE_CASE = buildBranchNameUseCase; this.CHECK_WORKING_DIRECTORY_USE_CASE = checkWorkingDirectoryUseCase; this.CREATE_BRANCH_USE_CASE = createBranchUseCase; this.GET_BRANCH_CONFIG_USE_CASE = getBranchConfigUseCase; this.GET_BRANCH_PATTERN_USE_CASE = getBranchPatternUseCase; this.GET_BRANCH_PLACEHOLDER_DEFINITIONS_USE_CASE = getBranchPlaceholderDefinitionsUseCase; this.LINT_BRANCH_NAME_USE_CASE = lintBranchNameUseCase; this.NORMALIZE_TICKET_ID_USE_CASE = normalizeTicketIdUseCase; this.PUSH_BRANCH_USE_CASE = pushBranchUseCase; this.BRANCH_CREATION_PROMPT = new BranchCreationPrompt(); } /** * Execute the branch creation flow * @param appName The application name for configuration */ async execute(appName) { try { // Check working directory await this.CHECK_WORKING_DIRECTORY_USE_CASE.execute(); console.error("🌿 Creating a new branch...\n"); // Get configuration const config = await this.GET_BRANCH_CONFIG_USE_CASE.execute(appName); const branchPattern = this.GET_BRANCH_PATTERN_USE_CASE.execute(config); const placeholderDefinitions = this.GET_BRANCH_PLACEHOLDER_DEFINITIONS_USE_CASE.execute({ config }); const placeholderValues = {}; for (const placeholderDefinition of placeholderDefinitions) { if (placeholderDefinition.isTypePlaceholder) { placeholderValues.type = await this.BRANCH_CREATION_PROMPT.promptBranchType(config.branches); continue; } const inputValue = await this.BRANCH_CREATION_PROMPT.promptPlaceholder({ example: placeholderDefinition.example, isOptional: placeholderDefinition.isOptional, patternSource: placeholderDefinition.patternSource ?? "", placeholderName: placeholderDefinition.placeholderName, }); placeholderValues[placeholderDefinition.placeholderName] = placeholderDefinition.placeholderName === "ticket" ? this.NORMALIZE_TICKET_ID_USE_CASE.execute(inputValue) : inputValue; } const fullBranchName = this.BUILD_BRANCH_NAME_USE_CASE.execute({ branchPattern, placeholderValues, }); // Re-validate the final assembled branch name with full lint rules before creation. this.LINT_BRANCH_NAME_USE_CASE.execute(fullBranchName, config); console.error(`\nāŒ›ļø Creating branch: ${fullBranchName}`); // Create the branch await this.CREATE_BRANCH_USE_CASE.execute(fullBranchName); // Ask about pushing to remote const shouldPush = await this.BRANCH_CREATION_PROMPT.promptPushBranch(); if (shouldPush) { await this.PUSH_BRANCH_USE_CASE.execute(fullBranchName); console.error(`āœ… Branch ${fullBranchName} pushed to remote repository!`); } else { console.error(`āœ… Branch ${fullBranchName} created locally!`); } } catch (error) { this.handleError(error); } } /** * Handle errors that occur during branch creation * @param error The error that occurred */ handleError(error) { if (error instanceof UncommittedChangesError) { console.error(`āš ļø ${error.message}`); // eslint-disable-next-line @elsikora/unicorn/no-process-exit process.exit(1); } if (error instanceof BranchAlreadyExistsError) { console.error(`āš ļø ${error.message}`); // eslint-disable-next-line @elsikora/unicorn/no-process-exit process.exit(0); } if (error instanceof BranchCreationError) { console.error(`āŒ ${error.message}`); // eslint-disable-next-line @elsikora/unicorn/no-process-exit process.exit(1); } console.error(`āŒ Failed to create branch: ${error.message}`); // eslint-disable-next-line @elsikora/unicorn/no-process-exit process.exit(1); } } /** * Format error messages for CLI output */ class ErrorFormatter { /** * Format an error message * @param message The error message * @returns The formatted error message */ format(message) { return chalk.red(`āœ– ${message}`); } } /** * Format hint messages for CLI output */ class HintFormatter { /** * Format a hint message based on error type and config * @param error The error that occurred * @param config The branch configuration * @returns The formatted hint message */ format(error, config) { let output = ""; if (error instanceof PatternMatchError) { output += this.formatPatternMatchHint(config); } else if (error instanceof ProhibitedBranchError) { output += this.formatProhibitedBranchHint(config); } return output; } /** * Format a hint for pattern match errors * @param config The branch configuration * @returns The formatted hint */ formatPatternMatchHint(config) { let output = ""; const branchNamePattern = config.rules?.["branch-pattern"]; const branchTypeList = Array.isArray(config.branches) ? config.branches : Object.keys(config.branches); output += branchNamePattern ? `${chalk.blue("Expected pattern:")} ${chalk.yellow(branchNamePattern)}\n` : ""; const valuesList = branchTypeList.map((value) => chalk.yellow(value)).join(", "); output += chalk.blue(`Valid branch types:`) + " " + valuesList + "\n"; return output.trim(); } /** * Format a hint for prohibited branch errors * @param config The branch configuration * @returns The formatted hint */ formatProhibitedBranchHint(config) { const prohibitedList = config.rules?.["branch-prohibited"]?.map((name) => chalk.yellow(name)).join(", ") ?? ""; return `${chalk.blue("Prohibited branch names:")} ${prohibitedList}`; } } /** * Controller for linting CLI operations */ class LintController { ERROR_FORMATTER; GET_BRANCH_CONFIG_USE_CASE; GET_CURRENT_BRANCH_USE_CASE; HINT_FORMATTER; LINT_BRANCH_NAME_USE_CASE; /** * Constructor * @param getBranchConfigUseCase The use case for getting branch configuration * @param getCurrentBranchUseCase The use case for getting the current branch * @param lintBranchNameUseCase The use case for linting branch names */ constructor(getBranchConfigUseCase, getCurrentBranchUseCase, lintBranchNameUseCase) { this.GET_BRANCH_CONFIG_USE_CASE = getBranchConfigUseCase; this.GET_CURRENT_BRANCH_USE_CASE = getCurrentBranchUseCase; this.LINT_BRANCH_NAME_USE_CASE = lintBranchNameUseCase; this.ERROR_FORMATTER = new ErrorFormatter(); this.HINT_FORMATTER = new HintFormatter(); } /** * Execute the CLI command * @param appName The application name */ async execute(appName) { try { const [config, branchName] = await Promise.all([this.GET_BRANCH_CONFIG_USE_CASE.execute(appName), this.GET_CURRENT_BRANCH_USE_CASE.execute()]); this.LINT_BRANCH_NAME_USE_CASE.execute(branchName, config); } catch (error) { await this.handleError(error, appName); } } /** * Handle errors that occur during execution * @param error The error that occurred * @param appName The application name */ async handleError(error, appName) { if (!(error instanceof Error)) { console.error(this.ERROR_FORMATTER.format("[LintBranchName] Unhandled error occurred")); throw new Error("Unknown error occurred"); } if (error instanceof LintError) { try { const config = await this.GET_BRANCH_CONFIG_USE_CASE.execute(appName); console.error(this.ERROR_FORMATTER.format(error.message)); console.error(this.HINT_FORMATTER.format(error, config)); // Since this is a CLI tool, it's appropriate to exit the process on validation errors // ESLint wants us to throw instead, but this is a CLI application where exit is acceptable // eslint-disable-next-line @elsikora/unicorn/no-process-exit process.exit(1); } catch { console.error(this.ERROR_FORMATTER.format("Failed to load configuration for error hint")); console.error(this.ERROR_FORMATTER.format(error.message)); // eslint-disable-next-line @elsikora/unicorn/no-process-exit process.exit(1); } } throw error; } } /** * Application name used for configuration */ const APP_NAME = "git-branch-lint"; const ARGS_SLICE_INDEX = 2; const argv = yargs(process.argv.slice(ARGS_SLICE_INDEX)) .option("branch", { alias: "b", description: "Run branch creation tool", type: "boolean", }) .help() .usage("Usage: $0 [-b] to create a branch or lint the current branch") .parseSync(); /** * Main function that bootstraps the application */ const main = async () => { // Infrastructure layer const configRepository = new CosmiconfigRepository(); const branchRepository = new GitBranchRepository(); // Application layer - common use cases const getBranchConfigUseCase = new GetBranchConfigUseCase(configRepository); const shouldRunBranch = Boolean(argv.branch); if (shouldRunBranch) { // Application layer - branch creation use cases const checkWorkingDirectoryUseCase = new CheckWorkingDirectoryUseCase(branchRepository); const buildBranchNameUseCase = new BuildBranchNameUseCase(); const createBranchUseCase = new CreateBranchUseCase(branchRepository); const getBranchPatternUseCase = new GetBranchPatternUseCase(); const getBranchPlaceholderDefinitionsUseCase = new GetBranchPlaceholderDefinitionsUseCase(); const lintBranchNameUseCase = new LintBranchNameUseCase(); const normalizeTicketIdUseCase = new NormalizeTicketIdUseCase(); const pushBranchUseCase = new PushBranchUseCase(branchRepository); // Presentation layer const createBranchController = new CreateBranchController(buildBranchNameUseCase, checkWorkingDirectoryUseCase, createBranchUseCase, getBranchPatternUseCase, getBranchPlaceholderDefinitionsUseCase, getBranchConfigUseCase, lintBranchNameUseCase, normalizeTicketIdUseCase, pushBranchUseCase); await createBranchController.execute(APP_NAME); } else { // Application layer - linting use cases const getCurrentBranchUseCase = new GetCurrentBranchUseCase(branchRepository); const lintBranchNameUseCase = new LintBranchNameUseCase(); // Presentation layer const lintController = new LintController(getBranchConfigUseCase, getCurrentBranchUseCase, lintBranchNameUseCase); await lintController.execute(APP_NAME); } }; // Bootstrap the application and handle errors main().catch((error) => { console.error("[LintBranchName] Unhandled error occurred"); throw error; }); //# sourceMappingURL=index.js.map