@elsikora/git-branch-lint
Version:
Lint your git branch names
799 lines (779 loc) • 27.6 kB
JavaScript
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