UNPKG

@elsikora/git-branch-lint

Version:
799 lines (779 loc) 27.6 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'; /** * 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 getting the current branch name */ class GetCurrentBranchUseCase { BRANCH_REPOSITORY; /** * Constructor * @param branchRepository The branch repository */ constructor(branchRepository) { this.BRANCH_REPOSITORY = branchRepository; } /** * Execute the use case * @returns The current branch name */ async execute() { return this.BRANCH_REPOSITORY.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 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 { /** * 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 let branchNamePattern = config.rules?.["branch-pattern"]; const subjectNamePattern = config.rules?.["branch-subject-pattern"]; if (!branchNamePattern) { return; } // Get branch types - handle both array and object formats const branchTypes = Array.isArray(config.branches) ? config.branches : Object.keys(config.branches); const parameters = { type: branchTypes, // Если branch-name-pattern не определён, не добавляем name в params ...(subjectNamePattern && { name: [subjectNamePattern] }), }; // Обрабатываем параметры, если они есть for (const [key, values] of Object.entries(parameters)) { const placeholder = `:${key.toLowerCase()}`; let replacement = "("; for (let index = 0; index < values.length; index++) { const value = values[index]; if (value.startsWith("[")) { replacement += value; } else { const escapedValue = value.replaceAll(/[-/\\^$*+?.()|[\]{}]/g, String.raw `\$&`); replacement += escapedValue; } if (index < values.length - 1) { replacement += "|"; } } replacement += ")"; branchNamePattern = branchNamePattern.replaceAll(new RegExp(placeholder, "g"), replacement); } // Create the regular expression const regexp = new RegExp(`^${branchNamePattern}$`); // Test the branch name against the pattern if (!regexp.test(branchName)) { throw new PatternMatchError(branchName); } } } /** * 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); } } /** * Default configuration for branch linting */ const DEFAULT_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": 50, "branch-min-length": 5, "branch-pattern": ":type/: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_CONFIG; } // Convert the config to match our interfaces const providedConfig = result.config; const mergedConfig = { ...DEFAULT_CONFIG, ...providedConfig, }; 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 branch name format */ class ValidateBranchNameUseCase { BRANCH_NAME_PATTERN = /^[a-z0-9-]+$/; /** * Execute the use case * @param branchName The branch name to validate * @returns Validation result with error message if invalid */ execute(branchName) { if (!branchName.trim()) { return { errorMessage: "Branch name cannot be empty!", isValid: false, }; } if (!this.BRANCH_NAME_PATTERN.test(branchName)) { return { errorMessage: "Branch name can only contain lowercase letters, numbers, and hyphens!", 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_NAME_USE_CASE; constructor() { this.BRANCH_CHOICE_FORMATTER = new BranchChoiceFormatter(); this.VALIDATE_BRANCH_NAME_USE_CASE = new ValidateBranchNameUseCase(); } /** * Prompt for branch name * @returns Branch name */ async promptBranchName() { const result = await inquirer.prompt([ { message: "Enter the branch name (e.g., authorization):", name: "branchName", type: "input", // eslint-disable-next-line @elsikora/sonar/function-return-type validate: (input) => { const validation = this.VALIDATE_BRANCH_NAME_USE_CASE.execute(input); if (validation.isValid) { return true; } return validation.errorMessage ?? "Invalid branch name"; }, }, ]); return result.branchName; } /** * 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 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; } } /** * Controller for branch creation CLI operations */ class CreateBranchController { BRANCH_CREATION_PROMPT; CHECK_WORKING_DIRECTORY_USE_CASE; CREATE_BRANCH_USE_CASE; GET_BRANCH_CONFIG_USE_CASE; PUSH_BRANCH_USE_CASE; constructor(checkWorkingDirectoryUseCase, createBranchUseCase, getBranchConfigUseCase, pushBranchUseCase) { this.CHECK_WORKING_DIRECTORY_USE_CASE = checkWorkingDirectoryUseCase; this.CREATE_BRANCH_USE_CASE = createBranchUseCase; this.GET_BRANCH_CONFIG_USE_CASE = getBranchConfigUseCase; 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); // Prompt for branch details const branchType = await this.BRANCH_CREATION_PROMPT.promptBranchType(config.branches); const branchName = await this.BRANCH_CREATION_PROMPT.promptBranchName(); const fullBranchName = `${branchType}/${branchName}`; 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); } } /** * Handle errors that occur during execution * @param error The error that occurred */ async handleError(error) { 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 { // Get the configuration using the service instead of hardcoded values const config = await this.GET_BRANCH_CONFIG_USE_CASE.execute("git-branch-lint"); 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 createBranchUseCase = new CreateBranchUseCase(branchRepository); const pushBranchUseCase = new PushBranchUseCase(branchRepository); // Presentation layer const createBranchController = new CreateBranchController(checkWorkingDirectoryUseCase, createBranchUseCase, getBranchConfigUseCase, 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