@elsikora/git-branch-lint 1.2.1-dev.1 → 1.2.2-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/LICENSE.md +3 -15
  2. package/README.md +131 -241
  3. package/bin/application/type/branch-placeholder-definition.type.d.ts +7 -0
  4. package/bin/application/type/build-branch-name-parameters.type.d.ts +4 -0
  5. package/bin/application/type/get-branch-placeholder-definitions-parameters.type.d.ts +4 -0
  6. package/bin/application/type/validate-branch-placeholder-value-parameters.type.d.ts +6 -0
  7. package/bin/application/use-cases/build-branch-name.use-case.d.ts +10 -0
  8. package/bin/application/use-cases/get-branch-pattern.use-case.d.ts +8 -0
  9. package/bin/application/use-cases/get-branch-placeholder-definitions.use-case.d.ts +11 -0
  10. package/bin/application/use-cases/get-current-branch.use-case.d.ts +1 -5
  11. package/bin/application/use-cases/lint-branch-name.use-case.d.ts +3 -9
  12. package/bin/application/use-cases/normalize-ticket-id.use-case.d.ts +9 -0
  13. package/bin/application/use-cases/validate-branch-placeholder-value.use-case.d.ts +8 -0
  14. package/bin/domain/config/default-branch-lint-config.d.ts +2 -0
  15. package/bin/domain/constant/branch-pattern.constant.d.ts +1 -0
  16. package/bin/domain/constant/ticket-id.constant.d.ts +3 -0
  17. package/bin/domain/errors/lint.error.d.ts +6 -0
  18. package/bin/domain/policy/branch-template.policy.d.ts +18 -0
  19. package/bin/domain/policy/ticket-id.policy.d.ts +9 -0
  20. package/bin/domain/type/branch-subject-pattern.type.d.ts +1 -0
  21. package/bin/domain/type/input-validation-result.type.d.ts +4 -0
  22. package/bin/domain/type/rules.type.d.ts +2 -1
  23. package/bin/index.js +340 -166
  24. package/bin/index.js.map +1 -1
  25. package/bin/infrastructure/config/cosmiconfig.repository.d.ts +0 -4
  26. package/bin/presentation/cli/controllers/create-branch.controller.d.ts +11 -1
  27. package/bin/presentation/cli/controllers/lint.controller.d.ts +1 -0
  28. package/bin/presentation/cli/formatters/branch-choice.formatter.d.ts +1 -6
  29. package/bin/presentation/cli/prompts/branch-creation.prompt.d.ts +8 -11
  30. package/bin/presentation/cli/type/branch-choice-formatted.type.d.ts +5 -0
  31. package/bin/presentation/cli/type/branch-placeholder-prompt-options.type.d.ts +6 -0
  32. package/bin/presentation/cli/type/branch-type-answer.type.d.ts +3 -0
  33. package/bin/presentation/cli/type/placeholder-answer.type.d.ts +3 -0
  34. package/bin/presentation/cli/type/push-branch-answer.type.d.ts +3 -0
  35. package/package.json +28 -28
  36. package/bin/application/use-cases/check-working-directory-use-case.d.ts +0 -13
  37. package/bin/application/use-cases/create-branch-use-case.d.ts +0 -14
  38. package/bin/application/use-cases/validate-branch-name-use-case.d.ts +0 -15
  39. package/bin/application/use-cases/validate-branch-name.use-case.d.ts +0 -15
package/bin/index.js CHANGED
@@ -6,6 +6,126 @@ import { promisify } from 'node:util';
6
6
  import inquirer from 'inquirer';
7
7
  import chalk from 'chalk';
8
8
 
9
+ const DEFAULT_BRANCH_SUBJECT_PATTERN_SOURCE = "[a-z0-9-]+";
10
+
11
+ const TICKET_ID_EXAMPLE = "PROJ-123";
12
+ const TICKET_ID_PATTERN_SOURCE = "[a-z]{2,}-[0-9]+";
13
+
14
+ const OPTIONAL_PLACEHOLDER_SUFFIXES = ["-"];
15
+ /**
16
+ * Domain policy for working with branch templates and placeholders.
17
+ */
18
+ class BranchTemplatePolicy {
19
+ static PLACEHOLDER_PATTERN = /:([a-z](?:[a-z0-9-]*[a-z0-9])?)/gi;
20
+ buildBranchName(branchPattern, placeholderValues) {
21
+ let resolvedBranchName = branchPattern;
22
+ for (const placeholderName of this.getPlaceholders(branchPattern)) {
23
+ const placeholderToken = `:${placeholderName}`;
24
+ const rawValue = placeholderValues[placeholderName] ?? "";
25
+ const normalizedValue = rawValue.trim();
26
+ if (normalizedValue.length === 0 && this.isPlaceholderOptional(branchPattern, placeholderName)) {
27
+ resolvedBranchName = this.removeOptionalPlaceholder(resolvedBranchName, placeholderName);
28
+ continue;
29
+ }
30
+ resolvedBranchName = resolvedBranchName.replaceAll(placeholderToken, normalizedValue);
31
+ }
32
+ return this.normalizeBranchNameDelimiters(resolvedBranchName);
33
+ }
34
+ buildValidationPatterns(branchPattern) {
35
+ let patternVariants = [branchPattern];
36
+ for (const placeholderName of this.getPlaceholders(branchPattern)) {
37
+ if (!this.isPlaceholderOptional(branchPattern, placeholderName)) {
38
+ continue;
39
+ }
40
+ patternVariants = patternVariants.flatMap((variant) => [variant, this.removeOptionalPlaceholder(variant, placeholderName)]);
41
+ }
42
+ return [...new Set(patternVariants.map((variant) => this.normalizeBranchNameDelimiters(variant)).filter((variant) => variant.length > 0))];
43
+ }
44
+ getPlaceholders(branchPattern) {
45
+ const matches = branchPattern.matchAll(BranchTemplatePolicy.PLACEHOLDER_PATTERN);
46
+ const orderedPlaceholders = [];
47
+ for (const match of matches) {
48
+ const placeholderName = match[1];
49
+ if (!orderedPlaceholders.includes(placeholderName)) {
50
+ orderedPlaceholders.push(placeholderName);
51
+ }
52
+ }
53
+ return orderedPlaceholders;
54
+ }
55
+ isPlaceholderOptional(branchPattern, placeholderName) {
56
+ const placeholderToken = `:${placeholderName}`;
57
+ return OPTIONAL_PLACEHOLDER_SUFFIXES.some((suffix) => branchPattern.includes(`${placeholderToken}${suffix}`));
58
+ }
59
+ resolvePlaceholderPatternSource(placeholderName, branchTypes, subjectPattern) {
60
+ if (placeholderName === "type") {
61
+ return this.createAlternationPattern(branchTypes);
62
+ }
63
+ if (typeof subjectPattern === "object" && subjectPattern?.[placeholderName]) {
64
+ return subjectPattern[placeholderName];
65
+ }
66
+ if (placeholderName === "ticket") {
67
+ return TICKET_ID_PATTERN_SOURCE;
68
+ }
69
+ if (typeof subjectPattern === "string") {
70
+ return subjectPattern;
71
+ }
72
+ return DEFAULT_BRANCH_SUBJECT_PATTERN_SOURCE;
73
+ }
74
+ createAlternationPattern(branchTypes) {
75
+ if (branchTypes.length === 0) {
76
+ return DEFAULT_BRANCH_SUBJECT_PATTERN_SOURCE;
77
+ }
78
+ return branchTypes.map((branchType) => this.escapeRegex(branchType)).join("|");
79
+ }
80
+ escapeRegex(value) {
81
+ return value.replaceAll(/[\\^$.*+?()[\]{}|/-]/g, String.raw `\$&`);
82
+ }
83
+ isDelimiter(character) {
84
+ return character === "." || character === "-" || character === "/" || character === "_";
85
+ }
86
+ normalizeBranchNameDelimiters(branchName) {
87
+ const compactedBranchName = branchName
88
+ .replaceAll(/\/{2,}/g, "/")
89
+ .replaceAll(/-{2,}/g, "-")
90
+ .replaceAll(/_{2,}/g, "_")
91
+ .replaceAll(/\.{2,}/g, ".");
92
+ return this.trimDelimiterEdges(compactedBranchName);
93
+ }
94
+ removeOptionalPlaceholder(branchPattern, placeholderName) {
95
+ const placeholderToken = `:${placeholderName}`;
96
+ let updatedPattern = branchPattern;
97
+ for (const suffix of OPTIONAL_PLACEHOLDER_SUFFIXES) {
98
+ updatedPattern = updatedPattern.replaceAll(`${placeholderToken}${suffix}`, "");
99
+ }
100
+ updatedPattern = updatedPattern.replaceAll(placeholderToken, "");
101
+ return updatedPattern;
102
+ }
103
+ trimDelimiterEdges(value) {
104
+ let startIndex = 0;
105
+ let endIndex = value.length - 1;
106
+ while (startIndex <= endIndex && this.isDelimiter(value[startIndex])) {
107
+ startIndex++;
108
+ }
109
+ while (endIndex >= startIndex && this.isDelimiter(value[endIndex])) {
110
+ endIndex--;
111
+ }
112
+ return value.slice(startIndex, endIndex + 1);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Use case for assembling a branch name from validated user inputs.
118
+ */
119
+ class BuildBranchNameUseCase {
120
+ branchTemplatePolicy;
121
+ constructor(branchTemplatePolicy = new BranchTemplatePolicy()) {
122
+ this.branchTemplatePolicy = branchTemplatePolicy;
123
+ }
124
+ execute(parameters) {
125
+ return this.branchTemplatePolicy.buildBranchName(parameters.branchPattern, parameters.placeholderValues);
126
+ }
127
+ }
128
+
9
129
  /**
10
130
  * Base error class for branch creation errors
11
131
  */
@@ -109,24 +229,65 @@ class GetBranchConfigUseCase {
109
229
  }
110
230
  }
111
231
 
232
+ /**
233
+ * Use case for retrieving effective branch pattern.
234
+ */
235
+ class GetBranchPatternUseCase {
236
+ DEFAULT_BRANCH_PATTERN = ":type/:name";
237
+ execute(config) {
238
+ return config.rules?.["branch-pattern"] ?? this.DEFAULT_BRANCH_PATTERN;
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Use case for resolving placeholder definitions from branch pattern config.
244
+ */
245
+ class GetBranchPlaceholderDefinitionsUseCase {
246
+ branchTemplatePolicy;
247
+ constructor(branchTemplatePolicy = new BranchTemplatePolicy()) {
248
+ this.branchTemplatePolicy = branchTemplatePolicy;
249
+ }
250
+ execute(parameters) {
251
+ const branchPattern = parameters.config.rules?.["branch-pattern"];
252
+ const subjectPattern = parameters.config.rules?.["branch-subject-pattern"];
253
+ if (!branchPattern) {
254
+ return [];
255
+ }
256
+ const branchTypes = Array.isArray(parameters.config.branches) ? parameters.config.branches : Object.keys(parameters.config.branches);
257
+ const placeholderNames = this.branchTemplatePolicy.getPlaceholders(branchPattern);
258
+ return placeholderNames.map((placeholderName) => {
259
+ if (placeholderName === "type") {
260
+ return {
261
+ isOptional: false,
262
+ isTypePlaceholder: true,
263
+ placeholderName,
264
+ };
265
+ }
266
+ return {
267
+ ...(placeholderName === "ticket" && { example: TICKET_ID_EXAMPLE }),
268
+ isOptional: this.branchTemplatePolicy.isPlaceholderOptional(branchPattern, placeholderName),
269
+ isTypePlaceholder: false,
270
+ patternSource: this.branchTemplatePolicy.resolvePlaceholderPatternSource(placeholderName, branchTypes, subjectPattern),
271
+ placeholderName,
272
+ };
273
+ });
274
+ }
275
+ }
276
+
112
277
  /**
113
278
  * Use case for getting the current branch name
114
279
  */
115
280
  class GetCurrentBranchUseCase {
116
- BRANCH_REPOSITORY;
117
- /**
118
- * Constructor
119
- * @param branchRepository The branch repository
120
- */
281
+ branchRepository;
121
282
  constructor(branchRepository) {
122
- this.BRANCH_REPOSITORY = branchRepository;
283
+ this.branchRepository = branchRepository;
123
284
  }
124
285
  /**
125
286
  * Execute the use case
126
287
  * @returns The current branch name
127
288
  */
128
289
  async execute() {
129
- return this.BRANCH_REPOSITORY.getCurrentBranchName();
290
+ return this.branchRepository.getCurrentBranchName();
130
291
  }
131
292
  }
132
293
 
@@ -185,6 +346,15 @@ class BranchTooShortError extends LintError {
185
346
  this.name = "BranchTooShortError";
186
347
  }
187
348
  }
349
+ /**
350
+ * Error thrown when a branch placeholder pattern in config is invalid.
351
+ */
352
+ class InvalidBranchPatternConfigError extends LintError {
353
+ constructor(placeholderName, patternSource) {
354
+ super(`Invalid branch pattern config for "${placeholderName}": ${patternSource}`);
355
+ this.name = "InvalidBranchPatternConfigError";
356
+ }
357
+ }
188
358
  /**
189
359
  * Error thrown when branch name doesn't match the pattern
190
360
  */
@@ -208,6 +378,10 @@ class ProhibitedBranchError extends LintError {
208
378
  * Use case for linting branch names
209
379
  */
210
380
  class LintBranchNameUseCase {
381
+ BRANCH_TEMPLATE_POLICY;
382
+ constructor(branchTemplatePolicy = new BranchTemplatePolicy()) {
383
+ this.BRANCH_TEMPLATE_POLICY = branchTemplatePolicy;
384
+ }
211
385
  /**
212
386
  * Execute the use case
213
387
  * @param branchName The branch name to lint
@@ -235,48 +409,6 @@ class LintBranchNameUseCase {
235
409
  }
236
410
  this.validatePattern(branch.getName(), config);
237
411
  }
238
- /**
239
- * Test a branch name against a specific pattern
240
- * @param branchName The branch name to test
241
- * @param pattern The pattern to test against
242
- * @param branchTypes Available branch types
243
- * @param subjectNamePattern Pattern for the name/description part
244
- * @returns true if pattern matches
245
- */
246
- testPattern(branchName, pattern, branchTypes, subjectNamePattern) {
247
- let processedPattern = pattern;
248
- const parameters = {
249
- type: branchTypes,
250
- // Add ticket pattern if present (accepts lowercase letters)
251
- ...(processedPattern.includes(":ticket") && { ticket: ["[a-z]{2,}-[0-9]+"] }),
252
- // Add name pattern if specified
253
- ...(subjectNamePattern && { name: [subjectNamePattern] }),
254
- };
255
- // Process parameters
256
- for (const [key, values] of Object.entries(parameters)) {
257
- const placeholder = `:${key.toLowerCase()}`;
258
- let replacement = "(";
259
- for (let index = 0; index < values.length; index++) {
260
- const value = values[index];
261
- if (value.startsWith("[")) {
262
- replacement += value;
263
- }
264
- else {
265
- const escapedValue = value.replaceAll(/[-/\\^$*+?.()|[\]{}]/g, String.raw `\$&`);
266
- replacement += escapedValue;
267
- }
268
- if (index < values.length - 1) {
269
- replacement += "|";
270
- }
271
- }
272
- replacement += ")";
273
- processedPattern = processedPattern.replaceAll(new RegExp(placeholder, "g"), replacement);
274
- }
275
- // Create the regular expression
276
- const regexp = new RegExp(`^${processedPattern}$`);
277
- // Test the branch name against the pattern
278
- return regexp.test(branchName);
279
- }
280
412
  /**
281
413
  * Validate the branch name against the pattern
282
414
  * @param branchName The branch name to validate
@@ -286,36 +418,65 @@ class LintBranchNameUseCase {
286
418
  validatePattern(branchName, config) {
287
419
  // Start with original pattern
288
420
  const branchNamePattern = config.rules?.["branch-pattern"];
289
- const subjectNamePattern = config.rules?.["branch-subject-pattern"];
421
+ const subjectPattern = config.rules?.["branch-subject-pattern"];
290
422
  if (!branchNamePattern) {
291
423
  return;
292
424
  }
293
- // Get branch types - handle both array and object formats
294
425
  const branchTypes = Array.isArray(config.branches) ? config.branches : Object.keys(config.branches);
295
- // Check if pattern contains :ticket placeholder
296
- const hasTicketPlaceholder = branchNamePattern.includes(":ticket");
297
- // Build patterns to try - with ticket and without ticket
298
- const patternsToTry = [];
299
- if (hasTicketPlaceholder) {
300
- // Try pattern with ticket: type/TICKET-123-description
301
- // Try pattern without ticket: type/description (replace :ticket- with empty)
302
- patternsToTry.push(branchNamePattern, branchNamePattern.replace(":ticket-", ""));
303
- }
304
- else {
305
- // Only one pattern available
306
- patternsToTry.push(branchNamePattern);
307
- }
308
- // Try each pattern
309
- for (const patternToTry of patternsToTry) {
310
- if (this.testPattern(branchName, patternToTry, branchTypes, subjectNamePattern)) {
311
- return; // Pattern matched, validation passed
426
+ const placeholders = this.BRANCH_TEMPLATE_POLICY.getPlaceholders(branchNamePattern);
427
+ const patternVariants = this.BRANCH_TEMPLATE_POLICY.buildValidationPatterns(branchNamePattern);
428
+ for (const patternVariant of patternVariants) {
429
+ let resolvedPattern = patternVariant;
430
+ for (const placeholder of placeholders) {
431
+ const placeholderToken = `:${placeholder}`;
432
+ if (!resolvedPattern.includes(placeholderToken)) {
433
+ continue;
434
+ }
435
+ const patternSource = this.BRANCH_TEMPLATE_POLICY.resolvePlaceholderPatternSource(placeholder, branchTypes, subjectPattern);
436
+ resolvedPattern = resolvedPattern.replaceAll(placeholderToken, `(${patternSource})`);
437
+ }
438
+ const expression = new RegExp(`^${resolvedPattern}$`);
439
+ if (expression.test(branchName)) {
440
+ return;
312
441
  }
313
442
  }
314
- // No pattern matched
315
443
  throw new PatternMatchError(branchName);
316
444
  }
317
445
  }
318
446
 
447
+ /**
448
+ * Domain policy for ticket identifier normalization and validation.
449
+ */
450
+ class TicketIdPolicy {
451
+ static TICKET_ID_PATTERN = new RegExp(`^${TICKET_ID_PATTERN_SOURCE}$`);
452
+ isEmpty(candidate) {
453
+ return candidate.trim().length === 0;
454
+ }
455
+ isValid(candidate) {
456
+ const normalizedCandidate = this.normalize(candidate);
457
+ if (normalizedCandidate.length === 0) {
458
+ return false;
459
+ }
460
+ return TicketIdPolicy.TICKET_ID_PATTERN.test(normalizedCandidate);
461
+ }
462
+ normalize(candidate) {
463
+ return candidate.trim().toLowerCase();
464
+ }
465
+ }
466
+
467
+ /**
468
+ * Use case for normalizing optional ticket identifier input.
469
+ */
470
+ class NormalizeTicketIdUseCase {
471
+ ticketIdPolicy;
472
+ constructor(ticketIdPolicy = new TicketIdPolicy()) {
473
+ this.ticketIdPolicy = ticketIdPolicy;
474
+ }
475
+ execute(ticketId) {
476
+ return this.ticketIdPolicy.normalize(ticketId);
477
+ }
478
+ }
479
+
319
480
  /**
320
481
  * Use case for pushing a branch to remote repository
321
482
  */
@@ -335,10 +496,7 @@ class PushBranchUseCase {
335
496
 
336
497
  const BRANCH_MAX_LENGTH = 50;
337
498
  const BRANCH_MIN_LENGTH = 5;
338
- /**
339
- * Default configuration for branch linting
340
- */
341
- const DEFAULT_CONFIG = {
499
+ const DEFAULT_BRANCH_LINT_CONFIG = {
342
500
  branches: {
343
501
  bugfix: { description: "šŸž Fixing issues in existing functionality", title: "Bugfix" },
344
502
  feature: { description: "šŸ†• Integration of new functionality", title: "Feature" },
@@ -355,6 +513,7 @@ const DEFAULT_CONFIG = {
355
513
  "branch-subject-pattern": "[a-z0-9-]+",
356
514
  },
357
515
  };
516
+
358
517
  /**
359
518
  * Cosmiconfig implementation of ConfigRepository
360
519
  */
@@ -371,13 +530,19 @@ class CosmiconfigRepository {
371
530
  });
372
531
  const result = await configExplorer.search();
373
532
  if (!result || result.isEmpty) {
374
- return DEFAULT_CONFIG;
533
+ return DEFAULT_BRANCH_LINT_CONFIG;
375
534
  }
376
535
  // Convert the config to match our interfaces
377
536
  const providedConfig = result.config;
378
537
  const mergedConfig = {
379
- ...DEFAULT_CONFIG,
538
+ ...DEFAULT_BRANCH_LINT_CONFIG,
380
539
  ...providedConfig,
540
+ branches: providedConfig.branches ?? DEFAULT_BRANCH_LINT_CONFIG.branches,
541
+ ignore: providedConfig.ignore ?? DEFAULT_BRANCH_LINT_CONFIG.ignore,
542
+ rules: {
543
+ ...DEFAULT_BRANCH_LINT_CONFIG.rules,
544
+ ...providedConfig.rules,
545
+ },
381
546
  };
382
547
  return mergedConfig;
383
548
  }
@@ -445,25 +610,25 @@ class GitBranchRepository {
445
610
  }
446
611
 
447
612
  /**
448
- * Use case for validating branch name format
613
+ * Use case for validating interactive placeholder values.
449
614
  */
450
- class ValidateBranchNameUseCase {
451
- BRANCH_NAME_PATTERN = /^[a-z0-9-]+$/;
452
- /**
453
- * Execute the use case
454
- * @param branchName The branch name to validate
455
- * @returns Validation result with error message if invalid
456
- */
457
- execute(branchName) {
458
- if (!branchName.trim()) {
459
- return {
460
- errorMessage: "Branch name cannot be empty!",
461
- isValid: false,
462
- };
615
+ class ValidateBranchPlaceholderValueUseCase {
616
+ execute(parameters) {
617
+ const normalizedValue = parameters.value.trim();
618
+ if (parameters.isOptional && normalizedValue.length === 0) {
619
+ return { isValid: true };
620
+ }
621
+ let expression;
622
+ try {
623
+ const flags = parameters.placeholderName === "ticket" ? "i" : "";
624
+ expression = new RegExp(`^${parameters.patternSource}$`, flags);
463
625
  }
464
- if (!this.BRANCH_NAME_PATTERN.test(branchName)) {
626
+ catch {
627
+ throw new InvalidBranchPatternConfigError(parameters.placeholderName, parameters.patternSource);
628
+ }
629
+ if (!expression.test(normalizedValue)) {
465
630
  return {
466
- errorMessage: "Branch name can only contain lowercase letters, numbers, and hyphens!",
631
+ errorMessage: `Invalid ${parameters.placeholderName} format`,
467
632
  isValid: false,
468
633
  };
469
634
  }
@@ -521,31 +686,10 @@ class BranchChoiceFormatter {
521
686
  */
522
687
  class BranchCreationPrompt {
523
688
  BRANCH_CHOICE_FORMATTER;
524
- VALIDATE_BRANCH_NAME_USE_CASE;
689
+ VALIDATE_BRANCH_PLACEHOLDER_VALUE_USE_CASE;
525
690
  constructor() {
526
691
  this.BRANCH_CHOICE_FORMATTER = new BranchChoiceFormatter();
527
- this.VALIDATE_BRANCH_NAME_USE_CASE = new ValidateBranchNameUseCase();
528
- }
529
- /**
530
- * Prompt for branch name
531
- * @returns Branch name
532
- */
533
- async promptBranchName() {
534
- const result = await inquirer.prompt([
535
- {
536
- message: "Enter the branch name (e.g., authorization):",
537
- name: "branchName",
538
- type: "input",
539
- validate: (input) => {
540
- const validation = this.VALIDATE_BRANCH_NAME_USE_CASE.execute(input);
541
- if (validation.isValid) {
542
- return true;
543
- }
544
- return validation.errorMessage ?? "Invalid branch name";
545
- },
546
- },
547
- ]);
548
- return result.branchName;
692
+ this.VALIDATE_BRANCH_PLACEHOLDER_VALUE_USE_CASE = new ValidateBranchPlaceholderValueUseCase();
549
693
  }
550
694
  /**
551
695
  * Prompt for branch type selection
@@ -564,6 +708,37 @@ class BranchCreationPrompt {
564
708
  ]);
565
709
  return result.branchType;
566
710
  }
711
+ /**
712
+ * Prompt for a branch placeholder value based on a validation pattern.
713
+ */
714
+ async promptPlaceholder(options) {
715
+ const result = await inquirer.prompt([
716
+ {
717
+ message: this.buildPlaceholderPromptMessage(options),
718
+ name: "value",
719
+ transformer: (input) => {
720
+ if (options.isOptional && input.trim() === "") {
721
+ return "\u001B[2m(Enter to skip)\u001B[0m";
722
+ }
723
+ return input;
724
+ },
725
+ type: "input",
726
+ validate: (input) => {
727
+ const validation = this.VALIDATE_BRANCH_PLACEHOLDER_VALUE_USE_CASE.execute({
728
+ isOptional: options.isOptional,
729
+ patternSource: options.patternSource,
730
+ placeholderName: options.placeholderName,
731
+ value: input,
732
+ });
733
+ if (validation.isValid) {
734
+ return true;
735
+ }
736
+ return validation.errorMessage ?? this.buildPlaceholderValidationMessage(options);
737
+ },
738
+ },
739
+ ]);
740
+ return result.value.trim();
741
+ }
567
742
  /**
568
743
  * Prompt to push branch to remote
569
744
  * @returns Whether to push the branch
@@ -580,39 +755,15 @@ class BranchCreationPrompt {
580
755
  ]);
581
756
  return result.shouldPush;
582
757
  }
583
- /**
584
- * Prompt for ticket ID (optional)
585
- * @returns Ticket ID in lowercase or empty string if skipped
586
- */
587
- async promptTicketId() {
588
- const result = await inquirer.prompt([
589
- {
590
- message: "Ticket ID (optional, e.g., PROJ-123):",
591
- name: "ticketId",
592
- transformer: (input) => {
593
- // Show placeholder when empty
594
- return input.trim() === "" ? "\u001B[2m(Enter to skip)\u001B[0m" : input;
595
- },
596
- type: "input",
597
- validate: (input) => {
598
- // Empty is valid (optional field)
599
- if (!input || input.trim() === "") {
600
- return true;
601
- }
602
- // Normalize input for validation (case-insensitive)
603
- const normalizedInput = input.trim();
604
- // Validate format: 2+ letters (any case), dash, digits
605
- const ticketPattern = /^[A-Z]{2,}-\d+$/i;
606
- if (!ticketPattern.test(normalizedInput)) {
607
- return "Invalid format. Expected format: PROJ-123 (2+ letters, dash, numbers)";
608
- }
609
- return true;
610
- },
611
- },
612
- ]);
613
- // Return lowercase version or empty string
614
- const trimmed = result.ticketId.trim();
615
- return trimmed ? trimmed.toLowerCase() : "";
758
+ buildPlaceholderPromptMessage(options) {
759
+ const normalizedPlaceholderName = options.placeholderName.replaceAll("-", " ");
760
+ const capitalizedPlaceholderName = normalizedPlaceholderName[0].toUpperCase() + normalizedPlaceholderName.slice(1);
761
+ const optionalPart = options.isOptional ? " optional" : "";
762
+ const examplePart = options.example ? `, e.g., ${options.example}` : "";
763
+ return `${capitalizedPlaceholderName}${optionalPart}${examplePart}:`;
764
+ }
765
+ buildPlaceholderValidationMessage(options) {
766
+ return `Invalid ${options.placeholderName} format`;
616
767
  }
617
768
  }
618
769
 
@@ -621,14 +772,24 @@ class BranchCreationPrompt {
621
772
  */
622
773
  class CreateBranchController {
623
774
  BRANCH_CREATION_PROMPT;
775
+ BUILD_BRANCH_NAME_USE_CASE;
624
776
  CHECK_WORKING_DIRECTORY_USE_CASE;
625
777
  CREATE_BRANCH_USE_CASE;
626
778
  GET_BRANCH_CONFIG_USE_CASE;
779
+ GET_BRANCH_PATTERN_USE_CASE;
780
+ GET_BRANCH_PLACEHOLDER_DEFINITIONS_USE_CASE;
781
+ LINT_BRANCH_NAME_USE_CASE;
782
+ NORMALIZE_TICKET_ID_USE_CASE;
627
783
  PUSH_BRANCH_USE_CASE;
628
- constructor(checkWorkingDirectoryUseCase, createBranchUseCase, getBranchConfigUseCase, pushBranchUseCase) {
784
+ constructor(buildBranchNameUseCase, checkWorkingDirectoryUseCase, createBranchUseCase, getBranchPatternUseCase, getBranchPlaceholderDefinitionsUseCase, getBranchConfigUseCase, lintBranchNameUseCase, normalizeTicketIdUseCase, pushBranchUseCase) {
785
+ this.BUILD_BRANCH_NAME_USE_CASE = buildBranchNameUseCase;
629
786
  this.CHECK_WORKING_DIRECTORY_USE_CASE = checkWorkingDirectoryUseCase;
630
787
  this.CREATE_BRANCH_USE_CASE = createBranchUseCase;
631
788
  this.GET_BRANCH_CONFIG_USE_CASE = getBranchConfigUseCase;
789
+ this.GET_BRANCH_PATTERN_USE_CASE = getBranchPatternUseCase;
790
+ this.GET_BRANCH_PLACEHOLDER_DEFINITIONS_USE_CASE = getBranchPlaceholderDefinitionsUseCase;
791
+ this.LINT_BRANCH_NAME_USE_CASE = lintBranchNameUseCase;
792
+ this.NORMALIZE_TICKET_ID_USE_CASE = normalizeTicketIdUseCase;
632
793
  this.PUSH_BRANCH_USE_CASE = pushBranchUseCase;
633
794
  this.BRANCH_CREATION_PROMPT = new BranchCreationPrompt();
634
795
  }
@@ -643,20 +804,28 @@ class CreateBranchController {
643
804
  console.error("🌿 Creating a new branch...\n");
644
805
  // Get configuration
645
806
  const config = await this.GET_BRANCH_CONFIG_USE_CASE.execute(appName);
646
- // Prompt for branch details
647
- const branchType = await this.BRANCH_CREATION_PROMPT.promptBranchType(config.branches);
648
- const ticketId = await this.BRANCH_CREATION_PROMPT.promptTicketId();
649
- const branchName = await this.BRANCH_CREATION_PROMPT.promptBranchName();
650
- // Build full branch name based on whether ticket ID is provided
651
- let fullBranchName;
652
- if (ticketId) {
653
- // Format: type/TICKET-123-description
654
- fullBranchName = `${branchType}/${ticketId}-${branchName}`;
655
- }
656
- else {
657
- // Format: type/description
658
- fullBranchName = `${branchType}/${branchName}`;
807
+ const branchPattern = this.GET_BRANCH_PATTERN_USE_CASE.execute(config);
808
+ const placeholderDefinitions = this.GET_BRANCH_PLACEHOLDER_DEFINITIONS_USE_CASE.execute({ config });
809
+ const placeholderValues = {};
810
+ for (const placeholderDefinition of placeholderDefinitions) {
811
+ if (placeholderDefinition.isTypePlaceholder) {
812
+ placeholderValues.type = await this.BRANCH_CREATION_PROMPT.promptBranchType(config.branches);
813
+ continue;
814
+ }
815
+ const inputValue = await this.BRANCH_CREATION_PROMPT.promptPlaceholder({
816
+ example: placeholderDefinition.example,
817
+ isOptional: placeholderDefinition.isOptional,
818
+ patternSource: placeholderDefinition.patternSource ?? "",
819
+ placeholderName: placeholderDefinition.placeholderName,
820
+ });
821
+ placeholderValues[placeholderDefinition.placeholderName] = placeholderDefinition.placeholderName === "ticket" ? this.NORMALIZE_TICKET_ID_USE_CASE.execute(inputValue) : inputValue;
659
822
  }
823
+ const fullBranchName = this.BUILD_BRANCH_NAME_USE_CASE.execute({
824
+ branchPattern,
825
+ placeholderValues,
826
+ });
827
+ // Re-validate the final assembled branch name with full lint rules before creation.
828
+ this.LINT_BRANCH_NAME_USE_CASE.execute(fullBranchName, config);
660
829
  console.error(`\nāŒ›ļø Creating branch: ${fullBranchName}`);
661
830
  // Create the branch
662
831
  await this.CREATE_BRANCH_USE_CASE.execute(fullBranchName);
@@ -791,22 +960,22 @@ class LintController {
791
960
  this.LINT_BRANCH_NAME_USE_CASE.execute(branchName, config);
792
961
  }
793
962
  catch (error) {
794
- await this.handleError(error);
963
+ await this.handleError(error, appName);
795
964
  }
796
965
  }
797
966
  /**
798
967
  * Handle errors that occur during execution
799
968
  * @param error The error that occurred
969
+ * @param appName The application name
800
970
  */
801
- async handleError(error) {
971
+ async handleError(error, appName) {
802
972
  if (!(error instanceof Error)) {
803
973
  console.error(this.ERROR_FORMATTER.format("[LintBranchName] Unhandled error occurred"));
804
974
  throw new Error("Unknown error occurred");
805
975
  }
806
976
  if (error instanceof LintError) {
807
977
  try {
808
- // Get the configuration using the service instead of hardcoded values
809
- const config = await this.GET_BRANCH_CONFIG_USE_CASE.execute("git-branch-lint");
978
+ const config = await this.GET_BRANCH_CONFIG_USE_CASE.execute(appName);
810
979
  console.error(this.ERROR_FORMATTER.format(error.message));
811
980
  console.error(this.HINT_FORMATTER.format(error, config));
812
981
  // Since this is a CLI tool, it's appropriate to exit the process on validation errors
@@ -852,10 +1021,15 @@ const main = async () => {
852
1021
  if (shouldRunBranch) {
853
1022
  // Application layer - branch creation use cases
854
1023
  const checkWorkingDirectoryUseCase = new CheckWorkingDirectoryUseCase(branchRepository);
1024
+ const buildBranchNameUseCase = new BuildBranchNameUseCase();
855
1025
  const createBranchUseCase = new CreateBranchUseCase(branchRepository);
1026
+ const getBranchPatternUseCase = new GetBranchPatternUseCase();
1027
+ const getBranchPlaceholderDefinitionsUseCase = new GetBranchPlaceholderDefinitionsUseCase();
1028
+ const lintBranchNameUseCase = new LintBranchNameUseCase();
1029
+ const normalizeTicketIdUseCase = new NormalizeTicketIdUseCase();
856
1030
  const pushBranchUseCase = new PushBranchUseCase(branchRepository);
857
1031
  // Presentation layer
858
- const createBranchController = new CreateBranchController(checkWorkingDirectoryUseCase, createBranchUseCase, getBranchConfigUseCase, pushBranchUseCase);
1032
+ const createBranchController = new CreateBranchController(buildBranchNameUseCase, checkWorkingDirectoryUseCase, createBranchUseCase, getBranchPatternUseCase, getBranchPlaceholderDefinitionsUseCase, getBranchConfigUseCase, lintBranchNameUseCase, normalizeTicketIdUseCase, pushBranchUseCase);
859
1033
  await createBranchController.execute(APP_NAME);
860
1034
  }
861
1035
  else {