@elsikora/git-branch-lint 1.0.1 → 1.1.1-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.
- package/README.md +74 -15
- package/bin/application/use-cases/check-working-directory-use-case.d.ts +13 -0
- package/bin/application/use-cases/check-working-directory.use-case.d.ts +13 -0
- package/bin/application/use-cases/create-branch-use-case.d.ts +14 -0
- package/bin/application/use-cases/create-branch.use-case.d.ts +14 -0
- package/bin/application/use-cases/get-branch-config.use-case.d.ts +15 -0
- package/bin/application/use-cases/{get-current-branch-use-case.d.ts → get-current-branch.use-case.d.ts} +2 -3
- package/bin/application/use-cases/{lint-branch-name-use-case.d.ts → lint-branch-name.use-case.d.ts} +4 -4
- package/bin/application/use-cases/push-branch.use-case.d.ts +13 -0
- package/bin/application/use-cases/validate-branch-name-use-case.d.ts +15 -0
- package/bin/application/use-cases/validate-branch-name.use-case.d.ts +15 -0
- package/bin/domain/{entities/branch.d.ts → entity/branch.entity.d.ts} +2 -3
- package/bin/domain/errors/branch-creation.error.d.ts +24 -0
- package/bin/domain/interface/branch.repository.interface.d.ts +23 -0
- package/bin/domain/{interfaces/repositories/iconfig-repository.d.ts → interface/config.repository.interface.d.ts} +2 -2
- package/bin/domain/interface/repository.interface.d.ts +2 -0
- package/bin/domain/type/branch.type.d.ts +5 -0
- package/bin/domain/type/config.type.d.ts +7 -0
- package/bin/domain/type/rules.type.d.ts +7 -0
- package/bin/index.d.ts +1 -1
- package/bin/index.js +455 -103
- package/bin/index.js.map +1 -1
- package/bin/infrastructure/config/cosmiconfig.repository.d.ts +17 -0
- package/bin/infrastructure/git/git-branch.repository.d.ts +26 -0
- package/bin/presentation/cli/controllers/create-branch.controller.d.ts +25 -0
- package/bin/presentation/cli/{cli-controller.d.ts → controllers/lint.controller.d.ts} +5 -5
- package/bin/presentation/cli/formatters/branch-choice.formatter.d.ts +27 -0
- package/bin/presentation/cli/formatters/{hint-formatter.d.ts → hint.formatter.d.ts} +2 -2
- package/bin/presentation/cli/prompts/branch-creation.prompt.d.ts +25 -0
- package/package.json +34 -16
- package/bin/application/use-cases/get-branch-config-use-case.d.ts +0 -19
- package/bin/domain/interfaces/branch-interfaces.d.ts +0 -22
- package/bin/domain/interfaces/repositories/ibranch-repository.d.ts +0 -10
- package/bin/domain/interfaces/repository-interfaces.d.ts +0 -2
- package/bin/domain/repositories/branch-repository.d.ts +0 -11
- package/bin/domain/repositories/config-repository.d.ts +0 -12
- package/bin/infrastructure/config/cosmiconfig-repository.d.ts +0 -13
- package/bin/infrastructure/git/git-branch-repository.d.ts +0 -12
- /package/bin/domain/errors/{lint-errors.d.ts → lint.error.d.ts} +0 -0
- /package/bin/presentation/cli/formatters/{error-formatter.d.ts → error.formatter.d.ts} +0 -0
package/bin/index.js
CHANGED
|
@@ -1,28 +1,111 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import yargs from 'yargs';
|
|
2
3
|
import { cosmiconfig } from 'cosmiconfig';
|
|
3
4
|
import { exec } from 'node:child_process';
|
|
4
5
|
import { promisify } from 'node:util';
|
|
6
|
+
import inquirer from 'inquirer';
|
|
5
7
|
import chalk from 'chalk';
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
|
-
*
|
|
10
|
+
* Base error class for branch creation errors
|
|
9
11
|
*/
|
|
10
|
-
class
|
|
11
|
-
|
|
12
|
+
class BranchCreationError extends Error {
|
|
13
|
+
constructor(message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "BranchCreationError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Error thrown when trying to create a branch that already exists
|
|
20
|
+
*/
|
|
21
|
+
class BranchAlreadyExistsError extends BranchCreationError {
|
|
22
|
+
constructor(branchName) {
|
|
23
|
+
super(`You are already on branch ${branchName}!`);
|
|
24
|
+
this.name = "BranchAlreadyExistsError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Error thrown when git operation fails
|
|
29
|
+
*/
|
|
30
|
+
class GitOperationError extends BranchCreationError {
|
|
31
|
+
constructor(operation, details) {
|
|
32
|
+
const message = details ? `Git operation failed: ${operation} - ${details}` : `Git operation failed: ${operation}`;
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "GitOperationError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Error thrown when working directory has uncommitted changes
|
|
39
|
+
*/
|
|
40
|
+
class UncommittedChangesError extends BranchCreationError {
|
|
41
|
+
constructor() {
|
|
42
|
+
super("You have uncommitted changes. Please commit or stash them before creating a new branch.");
|
|
43
|
+
this.name = "UncommittedChangesError";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Use case for checking working directory status
|
|
49
|
+
*/
|
|
50
|
+
class CheckWorkingDirectoryUseCase {
|
|
51
|
+
branchRepository;
|
|
52
|
+
constructor(branchRepository) {
|
|
53
|
+
this.branchRepository = branchRepository;
|
|
54
|
+
}
|
|
12
55
|
/**
|
|
13
|
-
*
|
|
14
|
-
* @
|
|
56
|
+
* Execute the use case
|
|
57
|
+
* @throws {UncommittedChangesError} When there are uncommitted changes
|
|
15
58
|
*/
|
|
59
|
+
async execute() {
|
|
60
|
+
const hasChanges = await this.branchRepository.hasUncommittedChanges();
|
|
61
|
+
if (hasChanges) {
|
|
62
|
+
throw new UncommittedChangesError();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Use case for creating a new branch
|
|
69
|
+
*/
|
|
70
|
+
class CreateBranchUseCase {
|
|
71
|
+
branchRepository;
|
|
72
|
+
constructor(branchRepository) {
|
|
73
|
+
this.branchRepository = branchRepository;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Execute the use case
|
|
77
|
+
* @param branchName The name of the branch to create
|
|
78
|
+
* @throws {BranchAlreadyExistsError} When trying to create current branch
|
|
79
|
+
*/
|
|
80
|
+
async execute(branchName) {
|
|
81
|
+
const currentBranch = await this.branchRepository.getCurrentBranchName();
|
|
82
|
+
if (currentBranch === branchName) {
|
|
83
|
+
throw new BranchAlreadyExistsError(branchName);
|
|
84
|
+
}
|
|
85
|
+
await this.branchRepository.createBranch(branchName);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Use case for retrieving branch configuration
|
|
91
|
+
*/
|
|
92
|
+
class GetBranchConfigUseCase {
|
|
93
|
+
configRepository;
|
|
16
94
|
constructor(configRepository) {
|
|
17
|
-
this.
|
|
95
|
+
this.configRepository = configRepository;
|
|
18
96
|
}
|
|
19
97
|
/**
|
|
20
98
|
* Execute the use case
|
|
21
|
-
* @param appName The application name
|
|
99
|
+
* @param appName - The application name
|
|
22
100
|
* @returns The branch configuration
|
|
23
101
|
*/
|
|
24
102
|
async execute(appName) {
|
|
25
|
-
|
|
103
|
+
try {
|
|
104
|
+
return await this.configRepository.getConfig(appName);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
throw new Error(`Failed to get branch config: ${error.message}`);
|
|
108
|
+
}
|
|
26
109
|
}
|
|
27
110
|
}
|
|
28
111
|
|
|
@@ -89,7 +172,6 @@ class LintError extends Error {
|
|
|
89
172
|
*/
|
|
90
173
|
class BranchTooLongError extends LintError {
|
|
91
174
|
constructor(branchName, maxLength) {
|
|
92
|
-
// eslint-disable-next-line @elsikora-typescript/restrict-template-expressions
|
|
93
175
|
super(`Branch name "${branchName}" is too long (maximum length: ${maxLength})`);
|
|
94
176
|
this.name = "BranchTooLongError";
|
|
95
177
|
}
|
|
@@ -99,7 +181,6 @@ class BranchTooLongError extends LintError {
|
|
|
99
181
|
*/
|
|
100
182
|
class BranchTooShortError extends LintError {
|
|
101
183
|
constructor(branchName, minLength) {
|
|
102
|
-
// eslint-disable-next-line @elsikora-typescript/restrict-template-expressions
|
|
103
184
|
super(`Branch name "${branchName}" is too short (minimum length: ${minLength})`);
|
|
104
185
|
this.name = "BranchTooShortError";
|
|
105
186
|
}
|
|
@@ -124,11 +205,11 @@ class ProhibitedBranchError extends LintError {
|
|
|
124
205
|
}
|
|
125
206
|
|
|
126
207
|
/**
|
|
127
|
-
* Use case for linting
|
|
208
|
+
* Use case for linting branch names
|
|
128
209
|
*/
|
|
129
210
|
class LintBranchNameUseCase {
|
|
130
211
|
/**
|
|
131
|
-
*
|
|
212
|
+
* Execute the use case
|
|
132
213
|
* @param branchName The branch name to lint
|
|
133
214
|
* @param config The branch configuration
|
|
134
215
|
* @throws {ProhibitedBranchError} When branch name is prohibited
|
|
@@ -138,18 +219,19 @@ class LintBranchNameUseCase {
|
|
|
138
219
|
*/
|
|
139
220
|
execute(branchName, config) {
|
|
140
221
|
const branch = new Branch(branchName);
|
|
141
|
-
|
|
222
|
+
const configRules = config.rules ?? {};
|
|
223
|
+
const ignoreList = config.ignore ?? [];
|
|
224
|
+
if (configRules?.["branch-prohibited"] && branch.isProhibited(configRules["branch-prohibited"])) {
|
|
142
225
|
throw new ProhibitedBranchError(branchName);
|
|
143
226
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
227
|
+
if (ignoreList.length > 0 && ignoreList.includes(branchName)) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (configRules?.["branch-min-length"] && branch.isTooShort(configRules["branch-min-length"])) {
|
|
231
|
+
throw new BranchTooShortError(branchName, configRules["branch-min-length"]);
|
|
148
232
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
// @ts-ignore
|
|
152
|
-
throw new BranchTooLongError(branchName, config.MAXLENGTH);
|
|
233
|
+
if (configRules?.["branch-max-length"] && branch.isTooLong(configRules["branch-max-length"])) {
|
|
234
|
+
throw new BranchTooLongError(branchName, configRules["branch-max-length"]);
|
|
153
235
|
}
|
|
154
236
|
this.validatePattern(branch.getName(), config);
|
|
155
237
|
}
|
|
@@ -161,16 +243,24 @@ class LintBranchNameUseCase {
|
|
|
161
243
|
*/
|
|
162
244
|
validatePattern(branchName, config) {
|
|
163
245
|
// Start with original pattern
|
|
164
|
-
let
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
246
|
+
let branchNamePattern = config.rules?.["branch-pattern"];
|
|
247
|
+
const subjectNamePattern = config.rules?.["branch-subject-pattern"];
|
|
248
|
+
if (!branchNamePattern) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
// Get branch types - handle both array and object formats
|
|
252
|
+
const branchTypes = Array.isArray(config.branches) ? config.branches : Object.keys(config.branches);
|
|
253
|
+
const parameters = {
|
|
254
|
+
type: branchTypes,
|
|
255
|
+
// Если branch-name-pattern не определён, не добавляем name в params
|
|
256
|
+
...(subjectNamePattern && { name: [subjectNamePattern] }),
|
|
257
|
+
};
|
|
258
|
+
// Обрабатываем параметры, если они есть
|
|
259
|
+
for (const [key, values] of Object.entries(parameters)) {
|
|
168
260
|
const placeholder = `:${key.toLowerCase()}`;
|
|
169
|
-
const parameterValues = values;
|
|
170
261
|
let replacement = "(";
|
|
171
|
-
for (let index = 0; index <
|
|
172
|
-
const value =
|
|
173
|
-
// Add the value to the replacement pattern
|
|
262
|
+
for (let index = 0; index < values.length; index++) {
|
|
263
|
+
const value = values[index];
|
|
174
264
|
if (value.startsWith("[")) {
|
|
175
265
|
replacement += value;
|
|
176
266
|
}
|
|
@@ -178,17 +268,15 @@ class LintBranchNameUseCase {
|
|
|
178
268
|
const escapedValue = value.replaceAll(/[-/\\^$*+?.()|[\]{}]/g, String.raw `\$&`);
|
|
179
269
|
replacement += escapedValue;
|
|
180
270
|
}
|
|
181
|
-
|
|
182
|
-
if (index < parameterValues.length - 1) {
|
|
271
|
+
if (index < values.length - 1) {
|
|
183
272
|
replacement += "|";
|
|
184
273
|
}
|
|
185
274
|
}
|
|
186
275
|
replacement += ")";
|
|
187
|
-
|
|
188
|
-
pattern = pattern.replaceAll(new RegExp(placeholder, "g"), replacement);
|
|
276
|
+
branchNamePattern = branchNamePattern.replaceAll(new RegExp(placeholder, "g"), replacement);
|
|
189
277
|
}
|
|
190
278
|
// Create the regular expression
|
|
191
|
-
const regexp = new RegExp(`^${
|
|
279
|
+
const regexp = new RegExp(`^${branchNamePattern}$`);
|
|
192
280
|
// Test the branch name against the pattern
|
|
193
281
|
if (!regexp.test(branchName)) {
|
|
194
282
|
throw new PatternMatchError(branchName);
|
|
@@ -196,20 +284,42 @@ class LintBranchNameUseCase {
|
|
|
196
284
|
}
|
|
197
285
|
}
|
|
198
286
|
|
|
287
|
+
/**
|
|
288
|
+
* Use case for pushing a branch to remote repository
|
|
289
|
+
*/
|
|
290
|
+
class PushBranchUseCase {
|
|
291
|
+
branchRepository;
|
|
292
|
+
constructor(branchRepository) {
|
|
293
|
+
this.branchRepository = branchRepository;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Execute the use case
|
|
297
|
+
* @param branchName The name of the branch to push
|
|
298
|
+
*/
|
|
299
|
+
async execute(branchName) {
|
|
300
|
+
await this.branchRepository.pushBranch(branchName);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
199
304
|
/**
|
|
200
305
|
* Default configuration for branch linting
|
|
201
306
|
*/
|
|
202
307
|
const DEFAULT_CONFIG = {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
308
|
+
branches: {
|
|
309
|
+
bugfix: { description: "🐞 Fixing issues in existing functionality", title: "Bugfix" },
|
|
310
|
+
feature: { description: "🆕 Integration of new functionality", title: "Feature" },
|
|
311
|
+
hotfix: { description: "🚑 Critical fixes for urgent issues", title: "Hotfix" },
|
|
312
|
+
release: { description: "📦 Preparing a new release version", title: "Release" },
|
|
313
|
+
support: { description: "🛠️ Support and maintenance tasks", title: "Support" },
|
|
314
|
+
},
|
|
315
|
+
ignore: ["dev"],
|
|
316
|
+
rules: {
|
|
317
|
+
"branch-max-length": 50,
|
|
318
|
+
"branch-min-length": 5,
|
|
319
|
+
"branch-pattern": ":type/:name",
|
|
320
|
+
"branch-prohibited": ["main", "master", "release"],
|
|
321
|
+
"branch-subject-pattern": "[a-z0-9-]+",
|
|
210
322
|
},
|
|
211
|
-
PATTERN: ":type/:name",
|
|
212
|
-
PROHIBITED: ["ci", "wip", "main", "test", "build", "master", "release", "dev", "develop"],
|
|
213
323
|
};
|
|
214
324
|
/**
|
|
215
325
|
* Cosmiconfig implementation of ConfigRepository
|
|
@@ -231,47 +341,10 @@ class CosmiconfigRepository {
|
|
|
231
341
|
}
|
|
232
342
|
// Convert the config to match our interfaces
|
|
233
343
|
const providedConfig = result.config;
|
|
234
|
-
const uppercasedConfig = {};
|
|
235
|
-
// Create a new object with uppercase keys instead of modifying the original
|
|
236
|
-
for (const key of Object.keys(providedConfig)) {
|
|
237
|
-
const uppercaseKey = key.toUpperCase();
|
|
238
|
-
const value = providedConfig[key];
|
|
239
|
-
if (Array.isArray(value)) {
|
|
240
|
-
// Preserve arrays
|
|
241
|
-
// eslint-disable-next-line @elsikora-typescript/no-unsafe-assignment
|
|
242
|
-
uppercasedConfig[uppercaseKey] = [...value];
|
|
243
|
-
}
|
|
244
|
-
else if (typeof value === "object" && value !== null) {
|
|
245
|
-
// Handle nested objects
|
|
246
|
-
const nestedObject = {};
|
|
247
|
-
for (const subKey of Object.keys(value)) {
|
|
248
|
-
const subValue = value[subKey];
|
|
249
|
-
if (Array.isArray(subValue)) {
|
|
250
|
-
// Preserve nested arrays
|
|
251
|
-
// eslint-disable-next-line @elsikora-typescript/no-unsafe-assignment
|
|
252
|
-
nestedObject[subKey.toUpperCase()] = [...subValue];
|
|
253
|
-
}
|
|
254
|
-
else {
|
|
255
|
-
nestedObject[subKey.toUpperCase()] = subValue;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
uppercasedConfig[uppercaseKey] = nestedObject;
|
|
259
|
-
}
|
|
260
|
-
else {
|
|
261
|
-
// Handle primitive values
|
|
262
|
-
uppercasedConfig[uppercaseKey] = value;
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
344
|
const mergedConfig = {
|
|
266
345
|
...DEFAULT_CONFIG,
|
|
267
|
-
...
|
|
346
|
+
...providedConfig,
|
|
268
347
|
};
|
|
269
|
-
if (uppercasedConfig.PARAMS && DEFAULT_CONFIG.PARAMS) {
|
|
270
|
-
mergedConfig.PARAMS = {
|
|
271
|
-
...DEFAULT_CONFIG.PARAMS,
|
|
272
|
-
...uppercasedConfig.PARAMS,
|
|
273
|
-
};
|
|
274
|
-
}
|
|
275
348
|
return mergedConfig;
|
|
276
349
|
}
|
|
277
350
|
}
|
|
@@ -281,14 +354,272 @@ const execAsync = promisify(exec);
|
|
|
281
354
|
* Git implementation of BranchRepository
|
|
282
355
|
*/
|
|
283
356
|
class GitBranchRepository {
|
|
357
|
+
/**
|
|
358
|
+
* Create a new branch
|
|
359
|
+
* @param branchName The name of the branch to create
|
|
360
|
+
*/
|
|
361
|
+
async createBranch(branchName) {
|
|
362
|
+
try {
|
|
363
|
+
const command = `git checkout -b ${branchName}`;
|
|
364
|
+
await execAsync(command);
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
throw new GitOperationError("create branch", error.message);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
284
370
|
/**
|
|
285
371
|
* Get the current branch name
|
|
286
372
|
* @returns A promise that resolves to the current branch name
|
|
287
373
|
*/
|
|
288
374
|
async getCurrentBranchName() {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
375
|
+
try {
|
|
376
|
+
const command = "git rev-parse --abbrev-ref HEAD";
|
|
377
|
+
const { stdout } = await execAsync(command);
|
|
378
|
+
return stdout.trim();
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
throw new GitOperationError("get current branch", error.message);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Check if working directory has uncommitted changes
|
|
386
|
+
* @returns A promise that resolves to true if there are uncommitted changes
|
|
387
|
+
*/
|
|
388
|
+
async hasUncommittedChanges() {
|
|
389
|
+
try {
|
|
390
|
+
const command = "git status --porcelain";
|
|
391
|
+
const { stdout } = await execAsync(command);
|
|
392
|
+
return stdout.trim().length > 0;
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
throw new GitOperationError("check working directory status", error.message);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Push branch to remote repository
|
|
400
|
+
* @param branchName The name of the branch to push
|
|
401
|
+
*/
|
|
402
|
+
async pushBranch(branchName) {
|
|
403
|
+
try {
|
|
404
|
+
const command = `git push --set-upstream origin ${branchName}`;
|
|
405
|
+
await execAsync(command);
|
|
406
|
+
}
|
|
407
|
+
catch (error) {
|
|
408
|
+
throw new GitOperationError("push branch", error.message);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Use case for validating branch name format
|
|
415
|
+
*/
|
|
416
|
+
class ValidateBranchNameUseCase {
|
|
417
|
+
BRANCH_NAME_PATTERN = /^[a-z0-9-]+$/;
|
|
418
|
+
/**
|
|
419
|
+
* Execute the use case
|
|
420
|
+
* @param branchName The branch name to validate
|
|
421
|
+
* @returns Validation result with error message if invalid
|
|
422
|
+
*/
|
|
423
|
+
execute(branchName) {
|
|
424
|
+
if (!branchName.trim()) {
|
|
425
|
+
return {
|
|
426
|
+
errorMessage: "Branch name cannot be empty!",
|
|
427
|
+
isValid: false,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
if (!this.BRANCH_NAME_PATTERN.test(branchName)) {
|
|
431
|
+
return {
|
|
432
|
+
errorMessage: "Branch name can only contain lowercase letters, numbers, and hyphens!",
|
|
433
|
+
isValid: false,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
return { isValid: true };
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Formatter for branch choices in CLI prompts
|
|
442
|
+
*/
|
|
443
|
+
class BranchChoiceFormatter {
|
|
444
|
+
// eslint-disable-next-line @elsikora/typescript/no-magic-numbers
|
|
445
|
+
EMPTY_SPACING_OFFSET = 5;
|
|
446
|
+
/**
|
|
447
|
+
* Format branch list for CLI selection
|
|
448
|
+
* @param branchList The list of branches to format
|
|
449
|
+
* @returns Formatted choices for inquirer prompt
|
|
450
|
+
*/
|
|
451
|
+
format(branchList) {
|
|
452
|
+
if (Array.isArray(branchList)) {
|
|
453
|
+
return this.formatSimpleList(branchList);
|
|
454
|
+
}
|
|
455
|
+
return this.formatDetailedList(branchList);
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Format detailed branch list (object with descriptions)
|
|
459
|
+
*/
|
|
460
|
+
formatDetailedList(branchList) {
|
|
461
|
+
const branchNames = Object.keys(branchList);
|
|
462
|
+
const maxNameLength = Math.max(...branchNames.map((name) => name.length));
|
|
463
|
+
return branchNames.map((branchName) => {
|
|
464
|
+
const branch = branchList[branchName];
|
|
465
|
+
const padding = " ".repeat(maxNameLength - branchName.length + this.EMPTY_SPACING_OFFSET);
|
|
466
|
+
return {
|
|
467
|
+
name: `${branch.title}:${padding}${branch.description}`,
|
|
468
|
+
short: branch.title,
|
|
469
|
+
value: branchName,
|
|
470
|
+
};
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Format simple branch list (array of strings)
|
|
475
|
+
*/
|
|
476
|
+
formatSimpleList(branchList) {
|
|
477
|
+
return branchList.map((branchName) => ({
|
|
478
|
+
name: branchName,
|
|
479
|
+
short: branchName,
|
|
480
|
+
value: branchName,
|
|
481
|
+
}));
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Prompt service for branch creation interactions
|
|
487
|
+
*/
|
|
488
|
+
class BranchCreationPrompt {
|
|
489
|
+
BRANCH_CHOICE_FORMATTER;
|
|
490
|
+
VALIDATE_BRANCH_NAME_USE_CASE;
|
|
491
|
+
constructor() {
|
|
492
|
+
this.BRANCH_CHOICE_FORMATTER = new BranchChoiceFormatter();
|
|
493
|
+
this.VALIDATE_BRANCH_NAME_USE_CASE = new ValidateBranchNameUseCase();
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Prompt for branch name
|
|
497
|
+
* @returns Branch name
|
|
498
|
+
*/
|
|
499
|
+
async promptBranchName() {
|
|
500
|
+
const result = await inquirer.prompt([
|
|
501
|
+
{
|
|
502
|
+
message: "Enter the branch name (e.g., authorization):",
|
|
503
|
+
name: "branchName",
|
|
504
|
+
type: "input",
|
|
505
|
+
// eslint-disable-next-line @elsikora/sonar/function-return-type
|
|
506
|
+
validate: (input) => {
|
|
507
|
+
const validation = this.VALIDATE_BRANCH_NAME_USE_CASE.execute(input);
|
|
508
|
+
if (validation.isValid) {
|
|
509
|
+
return true;
|
|
510
|
+
}
|
|
511
|
+
return validation.errorMessage ?? "Invalid branch name";
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
]);
|
|
515
|
+
return result.branchName;
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Prompt for branch type selection
|
|
519
|
+
* @param branches Available branch types
|
|
520
|
+
* @returns Selected branch type
|
|
521
|
+
*/
|
|
522
|
+
async promptBranchType(branches) {
|
|
523
|
+
const choices = this.BRANCH_CHOICE_FORMATTER.format(branches);
|
|
524
|
+
const result = await inquirer.prompt([
|
|
525
|
+
{
|
|
526
|
+
choices,
|
|
527
|
+
message: "Select the type of branch you're creating:",
|
|
528
|
+
name: "branchType",
|
|
529
|
+
type: "list",
|
|
530
|
+
},
|
|
531
|
+
]);
|
|
532
|
+
return result.branchType;
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Prompt to push branch to remote
|
|
536
|
+
* @returns Whether to push the branch
|
|
537
|
+
*/
|
|
538
|
+
async promptPushBranch() {
|
|
539
|
+
const result = await inquirer.prompt([
|
|
540
|
+
{
|
|
541
|
+
// eslint-disable-next-line @elsikora/typescript/naming-convention
|
|
542
|
+
default: false,
|
|
543
|
+
message: "Do you want to push the branch to the remote repository?",
|
|
544
|
+
name: "shouldPush",
|
|
545
|
+
type: "confirm",
|
|
546
|
+
},
|
|
547
|
+
]);
|
|
548
|
+
return result.shouldPush;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Controller for branch creation CLI operations
|
|
554
|
+
*/
|
|
555
|
+
class CreateBranchController {
|
|
556
|
+
BRANCH_CREATION_PROMPT;
|
|
557
|
+
CHECK_WORKING_DIRECTORY_USE_CASE;
|
|
558
|
+
CREATE_BRANCH_USE_CASE;
|
|
559
|
+
GET_BRANCH_CONFIG_USE_CASE;
|
|
560
|
+
PUSH_BRANCH_USE_CASE;
|
|
561
|
+
constructor(checkWorkingDirectoryUseCase, createBranchUseCase, getBranchConfigUseCase, pushBranchUseCase) {
|
|
562
|
+
this.CHECK_WORKING_DIRECTORY_USE_CASE = checkWorkingDirectoryUseCase;
|
|
563
|
+
this.CREATE_BRANCH_USE_CASE = createBranchUseCase;
|
|
564
|
+
this.GET_BRANCH_CONFIG_USE_CASE = getBranchConfigUseCase;
|
|
565
|
+
this.PUSH_BRANCH_USE_CASE = pushBranchUseCase;
|
|
566
|
+
this.BRANCH_CREATION_PROMPT = new BranchCreationPrompt();
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Execute the branch creation flow
|
|
570
|
+
* @param appName The application name for configuration
|
|
571
|
+
*/
|
|
572
|
+
async execute(appName) {
|
|
573
|
+
try {
|
|
574
|
+
// Check working directory
|
|
575
|
+
await this.CHECK_WORKING_DIRECTORY_USE_CASE.execute();
|
|
576
|
+
console.error("🌿 Creating a new branch...\n");
|
|
577
|
+
// Get configuration
|
|
578
|
+
const config = await this.GET_BRANCH_CONFIG_USE_CASE.execute(appName);
|
|
579
|
+
// Prompt for branch details
|
|
580
|
+
const branchType = await this.BRANCH_CREATION_PROMPT.promptBranchType(config.branches);
|
|
581
|
+
const branchName = await this.BRANCH_CREATION_PROMPT.promptBranchName();
|
|
582
|
+
const fullBranchName = `${branchType}/${branchName}`;
|
|
583
|
+
console.error(`\n⌛️ Creating branch: ${fullBranchName}`);
|
|
584
|
+
// Create the branch
|
|
585
|
+
await this.CREATE_BRANCH_USE_CASE.execute(fullBranchName);
|
|
586
|
+
// Ask about pushing to remote
|
|
587
|
+
const shouldPush = await this.BRANCH_CREATION_PROMPT.promptPushBranch();
|
|
588
|
+
if (shouldPush) {
|
|
589
|
+
await this.PUSH_BRANCH_USE_CASE.execute(fullBranchName);
|
|
590
|
+
console.error(`✅ Branch ${fullBranchName} pushed to remote repository!`);
|
|
591
|
+
}
|
|
592
|
+
else {
|
|
593
|
+
console.error(`✅ Branch ${fullBranchName} created locally!`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
catch (error) {
|
|
597
|
+
this.handleError(error);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Handle errors that occur during branch creation
|
|
602
|
+
* @param error The error that occurred
|
|
603
|
+
*/
|
|
604
|
+
handleError(error) {
|
|
605
|
+
if (error instanceof UncommittedChangesError) {
|
|
606
|
+
console.error(`⚠️ ${error.message}`);
|
|
607
|
+
// eslint-disable-next-line @elsikora/unicorn/no-process-exit
|
|
608
|
+
process.exit(1);
|
|
609
|
+
}
|
|
610
|
+
if (error instanceof BranchAlreadyExistsError) {
|
|
611
|
+
console.error(`⚠️ ${error.message}`);
|
|
612
|
+
// eslint-disable-next-line @elsikora/unicorn/no-process-exit
|
|
613
|
+
process.exit(0);
|
|
614
|
+
}
|
|
615
|
+
if (error instanceof BranchCreationError) {
|
|
616
|
+
console.error(`❌ ${error.message}`);
|
|
617
|
+
// eslint-disable-next-line @elsikora/unicorn/no-process-exit
|
|
618
|
+
process.exit(1);
|
|
619
|
+
}
|
|
620
|
+
console.error(`❌ Failed to create branch: ${error.message}`);
|
|
621
|
+
// eslint-disable-next-line @elsikora/unicorn/no-process-exit
|
|
622
|
+
process.exit(1);
|
|
292
623
|
}
|
|
293
624
|
}
|
|
294
625
|
|
|
@@ -333,12 +664,11 @@ class HintFormatter {
|
|
|
333
664
|
*/
|
|
334
665
|
formatPatternMatchHint(config) {
|
|
335
666
|
let output = "";
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
}
|
|
667
|
+
const branchNamePattern = config.rules?.["branch-pattern"];
|
|
668
|
+
const branchTypeList = Array.isArray(config.branches) ? config.branches : Object.keys(config.branches);
|
|
669
|
+
output += branchNamePattern ? `${chalk.blue("Expected pattern:")} ${chalk.yellow(branchNamePattern)}\n` : "";
|
|
670
|
+
const valuesList = branchTypeList.map((value) => chalk.yellow(value)).join(", ");
|
|
671
|
+
output += chalk.blue(`Valid branch types:`) + " " + valuesList + "\n";
|
|
342
672
|
return output.trim();
|
|
343
673
|
}
|
|
344
674
|
/**
|
|
@@ -347,15 +677,15 @@ class HintFormatter {
|
|
|
347
677
|
* @returns The formatted hint
|
|
348
678
|
*/
|
|
349
679
|
formatProhibitedBranchHint(config) {
|
|
350
|
-
const prohibitedList = config.
|
|
680
|
+
const prohibitedList = config.rules?.["branch-prohibited"]?.map((name) => chalk.yellow(name)).join(", ") ?? "";
|
|
351
681
|
return `${chalk.blue("Prohibited branch names:")} ${prohibitedList}`;
|
|
352
682
|
}
|
|
353
683
|
}
|
|
354
684
|
|
|
355
685
|
/**
|
|
356
|
-
* Controller for CLI operations
|
|
686
|
+
* Controller for linting CLI operations
|
|
357
687
|
*/
|
|
358
|
-
class
|
|
688
|
+
class LintController {
|
|
359
689
|
ERROR_FORMATTER;
|
|
360
690
|
GET_BRANCH_CONFIG_USE_CASE;
|
|
361
691
|
GET_CURRENT_BRANCH_USE_CASE;
|
|
@@ -401,16 +731,16 @@ class CliController {
|
|
|
401
731
|
// Get the configuration using the service instead of hardcoded values
|
|
402
732
|
const config = await this.GET_BRANCH_CONFIG_USE_CASE.execute("git-branch-lint");
|
|
403
733
|
console.error(this.ERROR_FORMATTER.format(error.message));
|
|
404
|
-
console.
|
|
734
|
+
console.error(this.HINT_FORMATTER.format(error, config));
|
|
405
735
|
// Since this is a CLI tool, it's appropriate to exit the process on validation errors
|
|
406
736
|
// ESLint wants us to throw instead, but this is a CLI application where exit is acceptable
|
|
407
|
-
// eslint-disable-next-line elsikora
|
|
737
|
+
// eslint-disable-next-line @elsikora/unicorn/no-process-exit
|
|
408
738
|
process.exit(1);
|
|
409
739
|
}
|
|
410
740
|
catch {
|
|
411
741
|
console.error(this.ERROR_FORMATTER.format("Failed to load configuration for error hint"));
|
|
412
742
|
console.error(this.ERROR_FORMATTER.format(error.message));
|
|
413
|
-
// eslint-disable-next-line elsikora
|
|
743
|
+
// eslint-disable-next-line @elsikora/unicorn/no-process-exit
|
|
414
744
|
process.exit(1);
|
|
415
745
|
}
|
|
416
746
|
}
|
|
@@ -422,6 +752,16 @@ class CliController {
|
|
|
422
752
|
* Application name used for configuration
|
|
423
753
|
*/
|
|
424
754
|
const APP_NAME = "git-branch-lint";
|
|
755
|
+
const ARGS_SLICE_INDEX = 2;
|
|
756
|
+
const argv = yargs(process.argv.slice(ARGS_SLICE_INDEX))
|
|
757
|
+
.option("branch", {
|
|
758
|
+
alias: "b",
|
|
759
|
+
description: "Run branch creation tool",
|
|
760
|
+
type: "boolean",
|
|
761
|
+
})
|
|
762
|
+
.help()
|
|
763
|
+
.usage("Usage: $0 [-b] to create a branch or lint the current branch")
|
|
764
|
+
.parseSync();
|
|
425
765
|
/**
|
|
426
766
|
* Main function that bootstraps the application
|
|
427
767
|
*/
|
|
@@ -429,14 +769,26 @@ const main = async () => {
|
|
|
429
769
|
// Infrastructure layer
|
|
430
770
|
const configRepository = new CosmiconfigRepository();
|
|
431
771
|
const branchRepository = new GitBranchRepository();
|
|
432
|
-
// Application layer
|
|
772
|
+
// Application layer - common use cases
|
|
433
773
|
const getBranchConfigUseCase = new GetBranchConfigUseCase(configRepository);
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
774
|
+
const shouldRunBranch = Boolean(argv.branch);
|
|
775
|
+
if (shouldRunBranch) {
|
|
776
|
+
// Application layer - branch creation use cases
|
|
777
|
+
const checkWorkingDirectoryUseCase = new CheckWorkingDirectoryUseCase(branchRepository);
|
|
778
|
+
const createBranchUseCase = new CreateBranchUseCase(branchRepository);
|
|
779
|
+
const pushBranchUseCase = new PushBranchUseCase(branchRepository);
|
|
780
|
+
// Presentation layer
|
|
781
|
+
const createBranchController = new CreateBranchController(checkWorkingDirectoryUseCase, createBranchUseCase, getBranchConfigUseCase, pushBranchUseCase);
|
|
782
|
+
await createBranchController.execute(APP_NAME);
|
|
783
|
+
}
|
|
784
|
+
else {
|
|
785
|
+
// Application layer - linting use cases
|
|
786
|
+
const getCurrentBranchUseCase = new GetCurrentBranchUseCase(branchRepository);
|
|
787
|
+
const lintBranchNameUseCase = new LintBranchNameUseCase();
|
|
788
|
+
// Presentation layer
|
|
789
|
+
const lintController = new LintController(getBranchConfigUseCase, getCurrentBranchUseCase, lintBranchNameUseCase);
|
|
790
|
+
await lintController.execute(APP_NAME);
|
|
791
|
+
}
|
|
440
792
|
};
|
|
441
793
|
// Bootstrap the application and handle errors
|
|
442
794
|
main().catch((error) => {
|