@elsikora/git-branch-lint
Version:
Lint your git branch names
1,050 lines (1,021 loc) ⢠39.3 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';
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