format-commit 1.0.1 → 1.1.0
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 +37 -22
- package/lib/ai-service.js +55 -51
- package/lib/commit.js +58 -14
- package/lib/create-branch.js +34 -4
- package/lib/index.js +4 -3
- package/lib/options.json +8 -8
- package/lib/setup.js +31 -5
- package/lib/utils.js +315 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -52,37 +52,51 @@ format-commit --config
|
|
|
52
52
|
|
|
53
53
|
All configuration is stored in the `commit-config.json` file. Here is the list of all options.
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
**`format`**
|
|
56
56
|
|
|
57
57
|
Commit title format:
|
|
58
|
-
- 1: `(type)
|
|
59
|
-
- 3: `type:
|
|
60
|
-
- 5: `type(scope)
|
|
61
|
-
- 7: `type(scope):
|
|
58
|
+
- 1: `(type) Description` / 2: `(type) description`
|
|
59
|
+
- 3: `type: Description` / 4: `type: description`
|
|
60
|
+
- 5: `type(scope) Description` / 6: `type(scope) description`
|
|
61
|
+
- 7: `type(scope): Description` / 8: `type(scope): description`
|
|
62
|
+
- "custom"
|
|
62
63
|
|
|
63
|
-
|
|
64
|
+
**`customFormat`**
|
|
65
|
+
|
|
66
|
+
Custom commit format pattern. Uses keywords `type`, `scope`, `description` and custom field(s) with `{field}` placeholders. Keywords are case-sensitive.
|
|
67
|
+
|
|
68
|
+
Example: `{Issue ID} - type - scope - Description`.
|
|
69
|
+
|
|
70
|
+
**`branchFormat`**
|
|
64
71
|
|
|
65
72
|
Branch naming format:
|
|
66
73
|
- 1: `type/description`
|
|
67
74
|
- 2: `type/scope/description`
|
|
75
|
+
- "custom"
|
|
76
|
+
|
|
77
|
+
**`customBranchFormat`**
|
|
78
|
+
|
|
79
|
+
Custom branch format pattern. Uses keywords `type`, `scope`, `description` and custom fields with `{field}` placeholders. Keywords are case-sensitive. Separators must be valid in Git branch names (no spaces, `~`, `^`, `:`, `?`, `*`, `[`, `\`, `..` or `//`). Dynamic parts (description, custom fields) are automatically sanitized to be branch-safe.
|
|
80
|
+
|
|
81
|
+
Example: `type/{Issue ID}-Description`.
|
|
68
82
|
|
|
69
|
-
|
|
83
|
+
**`types`**
|
|
70
84
|
|
|
71
85
|
Allowed commit and branch types (default: `feat`, `fix`, `core`, `test`, `config`, `doc`)
|
|
72
86
|
|
|
73
|
-
|
|
87
|
+
**`scopes`**
|
|
74
88
|
|
|
75
|
-
Scopes for commit and branch categorization (used in formats 5-8 for commits, format 2 for branches)
|
|
89
|
+
Scopes for commit and branch categorization (used in formats 5-8 for commits, format 2 for branches, or when a custom format includes the `scope` keyword)
|
|
76
90
|
|
|
77
|
-
|
|
91
|
+
**`minLength`**
|
|
78
92
|
|
|
79
93
|
Minimum length required for the commit title.
|
|
80
94
|
|
|
81
|
-
|
|
95
|
+
**`maxLength`**
|
|
82
96
|
|
|
83
97
|
Maximum length required for the commit title and branch description.
|
|
84
98
|
|
|
85
|
-
|
|
99
|
+
**`changeVersion`**
|
|
86
100
|
|
|
87
101
|
Version change policy:
|
|
88
102
|
- `never (ignore)`: Never change version, skip prompt (default)
|
|
@@ -90,44 +104,44 @@ Version change policy:
|
|
|
90
104
|
- `only on release branch`: Only release branch commits require version change
|
|
91
105
|
- `always`: All commits require version change
|
|
92
106
|
|
|
93
|
-
|
|
107
|
+
**`releaseBranch`**
|
|
94
108
|
|
|
95
109
|
Main/release branch name (used if changeVersion = `only on release branch`)
|
|
96
110
|
|
|
97
|
-
|
|
111
|
+
**`showAllVersionTypes`**
|
|
98
112
|
|
|
99
113
|
Show all version types or only main ones (`major`/`minor`/`patch`/`custom`)
|
|
100
114
|
|
|
101
|
-
|
|
115
|
+
**`ai.enabled`**
|
|
102
116
|
|
|
103
117
|
Enable AI commit title suggestions (default: `false`)
|
|
104
118
|
|
|
105
|
-
|
|
119
|
+
**`ai.provider`**
|
|
106
120
|
|
|
107
121
|
AI provider:
|
|
108
122
|
- `anthropic` (Claude)
|
|
109
123
|
- `openai` (GPT)
|
|
110
124
|
- `google` (Gemini)
|
|
111
125
|
|
|
112
|
-
|
|
126
|
+
**`ai.model`**
|
|
113
127
|
|
|
114
128
|
Model identifier (e.g., `claude-haiku-4-5` or `gpt-4o-mini`)
|
|
115
129
|
|
|
116
|
-
|
|
130
|
+
**`ai.envPath`**
|
|
117
131
|
|
|
118
132
|
Path to .env file containing the AI provider API key (e.g., `.env`)
|
|
119
133
|
|
|
120
|
-
|
|
134
|
+
**`ai.envKeyName`**
|
|
121
135
|
|
|
122
136
|
Name of the environment variable for the API key (e.g., `OPENAI_API_KEY`)
|
|
123
137
|
|
|
124
|
-
|
|
138
|
+
**`ai.largeDiffTokenThreshold`**
|
|
125
139
|
|
|
126
140
|
Number of tokens from which not to use AI automatically.
|
|
127
141
|
|
|
128
142
|
### AI Suggestions
|
|
129
143
|
|
|
130
|
-
When AI is enabled, your staged changes will be processed by the defined
|
|
144
|
+
When AI is enabled, your staged changes will be processed by the defined model to suggest commit titles that:
|
|
131
145
|
- Follow your configured format and naming conventions
|
|
132
146
|
- Automatically select appropriate types and scopes
|
|
133
147
|
- Respect your min/max length constraints
|
|
@@ -145,7 +159,8 @@ You can either:
|
|
|
145
159
|
| :---- | :--- | :---------- |
|
|
146
160
|
| `-c` | `--config` | Generate or update configuration file |
|
|
147
161
|
| `-b` | `--branch` | Create a new standardized branch |
|
|
148
|
-
| `-t` | `--test` |
|
|
162
|
+
| `-t` | `--test` | Preview without executing Git commands |
|
|
163
|
+
| `-d` | `--debug` | Display additional logs |
|
|
149
164
|
|
|
150
165
|
## Contributing
|
|
151
166
|
|
package/lib/ai-service.js
CHANGED
|
@@ -30,49 +30,48 @@ const getOptimizedGitDiff = () => {
|
|
|
30
30
|
};
|
|
31
31
|
|
|
32
32
|
/** Build AI prompt */
|
|
33
|
-
const buildPrompt = (diffData, config) => {
|
|
33
|
+
const buildPrompt = (diffData, config, customFieldValues = {}) => {
|
|
34
34
|
const typesList = config.types.map(t => t.value).join(', ');
|
|
35
|
-
const types = config.types.map(t => `${t.value}
|
|
35
|
+
const types = config.types.map(t => `${t.value} is for ${t.description}`).join('\n > ');
|
|
36
36
|
const scopesList = config.scopes ? config.scopes.map(s => s.value).join(', ') : null;
|
|
37
37
|
const scopes = config.scopes
|
|
38
|
-
? config.scopes.map(s => `${s.value}
|
|
38
|
+
? config.scopes.map(s => `${s.value} is for ${s.description}`).join('\n > ')
|
|
39
39
|
: null;
|
|
40
40
|
|
|
41
|
-
let formatInstruction = '';
|
|
42
|
-
switch (config.format) {
|
|
43
|
-
case 1:
|
|
44
|
-
formatInstruction = 'Format: (type) Title with first letter capitalized';
|
|
45
|
-
break;
|
|
46
|
-
case 2:
|
|
47
|
-
formatInstruction = 'Format: (type) title in lowercase';
|
|
48
|
-
break;
|
|
49
|
-
case 3:
|
|
50
|
-
formatInstruction = 'Format: type: Title with first letter capitalized';
|
|
51
|
-
break;
|
|
52
|
-
case 4:
|
|
53
|
-
formatInstruction = 'Format: type: title in lowercase';
|
|
54
|
-
break;
|
|
55
|
-
case 5:
|
|
56
|
-
formatInstruction = 'Format: type(scope) Title with first letter capitalized';
|
|
57
|
-
break;
|
|
58
|
-
case 6:
|
|
59
|
-
formatInstruction = 'Format: type(scope) title in lowercase';
|
|
60
|
-
break;
|
|
61
|
-
case 7:
|
|
62
|
-
formatInstruction = 'Format: type(scope): Title with first letter capitalized';
|
|
63
|
-
break;
|
|
64
|
-
case 8:
|
|
65
|
-
formatInstruction = 'Format: type(scope): title in lowercase';
|
|
66
|
-
break;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
41
|
const exampleTitle = utils.formatCommitTitle(
|
|
70
42
|
config.types[0].value,
|
|
71
|
-
'
|
|
43
|
+
'Description of changes',
|
|
72
44
|
config.format,
|
|
73
|
-
config.scopes?.[0]?.value
|
|
45
|
+
config.scopes?.[0]?.value,
|
|
46
|
+
config.customFormat,
|
|
47
|
+
customFieldValues
|
|
74
48
|
);
|
|
75
49
|
|
|
50
|
+
const formatHasScope = config.format === 'custom'
|
|
51
|
+
? utils.customFormatHasScope(config.customFormat)
|
|
52
|
+
: config.format >= 5;
|
|
53
|
+
|
|
54
|
+
const changeable = formatHasScope ? 'type, scope and description' : 'type and description';
|
|
55
|
+
const scopeInstruction = formatHasScope ? `\n- ONLY use these scopes (do not invent one outside this list): ${scopesList}
|
|
56
|
+
- Scope descriptions, to figure out which one to choose:\n > ${scopes}` : '';
|
|
57
|
+
|
|
58
|
+
let template;
|
|
59
|
+
if (config.format === 'custom' && config.customFormat) {
|
|
60
|
+
const segments = utils.parseCustomFormat(config.customFormat);
|
|
61
|
+
template = segments.map(seg => {
|
|
62
|
+
if (seg.type === 'literal') { return seg.value; }
|
|
63
|
+
if (seg.type === 'field') { return customFieldValues[seg.label] || `{${seg.label}}`; }
|
|
64
|
+
if (seg.type === 'keyword') { return `<${seg.keyword}>`; }
|
|
65
|
+
return '';
|
|
66
|
+
}).join('');
|
|
67
|
+
} else {
|
|
68
|
+
template = utils.formatCommitTitle('<type>', '<description>', config.format, formatHasScope ? '<scope>' : undefined);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const formatInstruction = `- Format pattern: "${template}"
|
|
72
|
+
- Example: "${exampleTitle}"
|
|
73
|
+
- ONLY replace ${changeable} placeholders, keep everything else exactly as shown`;
|
|
74
|
+
|
|
76
75
|
return `You must analyze git changes and return ONLY a valid JSON array. NO explanations, NO markdown, NO additional text.
|
|
77
76
|
|
|
78
77
|
Git diff stats:
|
|
@@ -82,16 +81,16 @@ Git diff:
|
|
|
82
81
|
${diffData.diff}
|
|
83
82
|
|
|
84
83
|
STRICT REQUIREMENTS:
|
|
85
|
-
|
|
86
|
-
-
|
|
87
|
-
-
|
|
88
|
-
- Type descriptions: ${types}
|
|
89
|
-
${scopes ? `- ONLY use these scopes (NO others): ${scopesList}\n- Scope descriptions: ${scopes}` : '- No scopes - DO NOT include scope in output'}
|
|
84
|
+
${formatInstruction}
|
|
85
|
+
- ONLY use these types (do not invent one outside this list): ${typesList}
|
|
86
|
+
- Type descriptions, to figure out which one to choose:\n > ${types}${scopeInstruction}
|
|
90
87
|
- Length: ${config.minLength}-${config.maxLength} chars per title
|
|
91
88
|
- Return exactly 4 different commit titles
|
|
92
|
-
- Output MUST be a raw JSON array
|
|
89
|
+
- Output MUST be a raw JSON array
|
|
90
|
+
|
|
91
|
+
If changes seem to concern very different things, suggest at least one title that attempts to be exhaustive on the most important points, using commas and/or "&". Avoid generic titles such as "Bug fixes and improvements".
|
|
93
92
|
|
|
94
|
-
YOUR RESPONSE MUST BE EXACTLY THIS FORMAT (no other text):
|
|
93
|
+
YOUR RESPONSE MUST BE EXACTLY THIS FORMAT (no other text before or after):
|
|
95
94
|
["title 1", "title 2", "title 3", "title 4"]`;
|
|
96
95
|
};
|
|
97
96
|
|
|
@@ -185,9 +184,10 @@ const callGeminiAPI = async (prompt, apiKey, model) => {
|
|
|
185
184
|
return data.candidates[0].content.parts[0].text;
|
|
186
185
|
};
|
|
187
186
|
|
|
187
|
+
|
|
188
188
|
/** Validate and normalize AI suggestion */
|
|
189
|
-
const validateAndNormalizeSuggestion = (suggestion, config) => {
|
|
190
|
-
const result = utils.parseAndNormalizeCommitTitle(suggestion, config);
|
|
189
|
+
const validateAndNormalizeSuggestion = (suggestion, config, customFieldValues = {}) => {
|
|
190
|
+
const result = utils.parseAndNormalizeCommitTitle(suggestion, config, customFieldValues);
|
|
191
191
|
|
|
192
192
|
// Returns null if invalid format/type/scope/length
|
|
193
193
|
if (result.error) {
|
|
@@ -203,7 +203,7 @@ const validateAndNormalizeSuggestion = (suggestion, config) => {
|
|
|
203
203
|
};
|
|
204
204
|
|
|
205
205
|
/** Generate commit title suggestions using AI */
|
|
206
|
-
const generateCommitSuggestions = async (config,
|
|
206
|
+
const generateCommitSuggestions = async (config, debugMode, customFieldValues = {}) => {
|
|
207
207
|
const diffData = getOptimizedGitDiff();
|
|
208
208
|
if (!diffData) {
|
|
209
209
|
return [];
|
|
@@ -214,10 +214,14 @@ const generateCommitSuggestions = async (config, testMode) => {
|
|
|
214
214
|
throw new Error('AI API key not found in .env');
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
const prompt = buildPrompt(diffData, config);
|
|
217
|
+
const prompt = buildPrompt(diffData, config, customFieldValues);
|
|
218
218
|
const estimatedTokens = Math.ceil(prompt.length / 4);
|
|
219
219
|
const threshold = config.ai.largeDiffTokenThreshold || 20000;
|
|
220
220
|
|
|
221
|
+
if (debugMode) {
|
|
222
|
+
utils.log('<`git diff`>\n\n' + prompt.slice(prompt.indexOf('STRICT REQUIREMENTS:')), 'debug');
|
|
223
|
+
}
|
|
224
|
+
|
|
221
225
|
if (estimatedTokens > threshold) {
|
|
222
226
|
const { confirm } = await prompts({
|
|
223
227
|
type: 'confirm',
|
|
@@ -243,8 +247,8 @@ const generateCommitSuggestions = async (config, testMode) => {
|
|
|
243
247
|
|
|
244
248
|
const jsonMatch = responseText.match(/\[[\s\S]*\]/);
|
|
245
249
|
if (!jsonMatch) {
|
|
246
|
-
if (
|
|
247
|
-
utils.log(responseText, '
|
|
250
|
+
if (debugMode) {
|
|
251
|
+
utils.log(responseText, 'debug');
|
|
248
252
|
}
|
|
249
253
|
throw new Error('No JSON array found in AI response');
|
|
250
254
|
}
|
|
@@ -252,8 +256,8 @@ const generateCommitSuggestions = async (config, testMode) => {
|
|
|
252
256
|
const suggestions = JSON.parse(jsonMatch[0]);
|
|
253
257
|
|
|
254
258
|
if (!Array.isArray(suggestions) || !suggestions.length) {
|
|
255
|
-
if (
|
|
256
|
-
utils.log(responseText, '
|
|
259
|
+
if (debugMode) {
|
|
260
|
+
utils.log(responseText, 'debug');
|
|
257
261
|
}
|
|
258
262
|
throw new Error('Invalid AI response format');
|
|
259
263
|
}
|
|
@@ -261,14 +265,14 @@ const generateCommitSuggestions = async (config, testMode) => {
|
|
|
261
265
|
// Validate and normalize each suggestion
|
|
262
266
|
const validationResults = suggestions.map(s => ({
|
|
263
267
|
original: s,
|
|
264
|
-
normalized: validateAndNormalizeSuggestion(s, config)
|
|
268
|
+
normalized: validateAndNormalizeSuggestion(s, config, customFieldValues)
|
|
265
269
|
}));
|
|
266
270
|
|
|
267
271
|
// Log rejected suggestions in test mode
|
|
268
|
-
if (
|
|
272
|
+
if (debugMode) {
|
|
269
273
|
validationResults.forEach(({ original, normalized }) => {
|
|
270
274
|
if (normalized === null) {
|
|
271
|
-
utils.log(`rejected: "${original}"`, '
|
|
275
|
+
utils.log(`rejected: "${original}"`, 'debug');
|
|
272
276
|
}
|
|
273
277
|
});
|
|
274
278
|
}
|
package/lib/commit.js
CHANGED
|
@@ -24,7 +24,7 @@ const promptWithPrefill = (message, prefill, validate) => {
|
|
|
24
24
|
output: process.stdout
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
const questionText = `${kleur.bold(message)}\n
|
|
27
|
+
const questionText = `${kleur.bold(message)}\n> `;
|
|
28
28
|
|
|
29
29
|
// Handle Ctrl+C to cancel
|
|
30
30
|
rl.on('SIGINT', () => {
|
|
@@ -102,7 +102,7 @@ const finalizeCommit = async (title, description, commitData, currentBranch, tes
|
|
|
102
102
|
};
|
|
103
103
|
|
|
104
104
|
|
|
105
|
-
export default async function commit(config, testMode) {
|
|
105
|
+
export default async function commit(config, testMode, debugMode) {
|
|
106
106
|
if (!config) {
|
|
107
107
|
return;
|
|
108
108
|
}
|
|
@@ -114,7 +114,11 @@ export default async function commit(config, testMode) {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
if (testMode) {
|
|
117
|
-
utils.log('test mode enabled
|
|
117
|
+
utils.log('test mode enabled (commit will not be performed)', 'warning');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (debugMode) {
|
|
121
|
+
utils.log('debug mode enabled (additional visible logs)', 'debug');
|
|
118
122
|
}
|
|
119
123
|
|
|
120
124
|
// Get current git branch for version change option "only on release branch"
|
|
@@ -128,8 +132,20 @@ export default async function commit(config, testMode) {
|
|
|
128
132
|
return;
|
|
129
133
|
}
|
|
130
134
|
|
|
135
|
+
if (config.format === 'custom') {
|
|
136
|
+
const formatValid = utils.validateCustomFormatPattern(config.customFormat);
|
|
137
|
+
if (formatValid !== true) {
|
|
138
|
+
utils.log(`Invalid custom format - ${formatValid}`, 'error');
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const formatNeedsScope = config.format === 'custom'
|
|
144
|
+
? utils.customFormatHasScope(config.customFormat)
|
|
145
|
+
: config.format >= 5;
|
|
146
|
+
|
|
131
147
|
const noScope = !config.scopes || (config.scopes && config.scopes.length === 0);
|
|
132
|
-
if (
|
|
148
|
+
if (formatNeedsScope && noScope) {
|
|
133
149
|
utils.log('no scopes defined - update config or format option', 'error');
|
|
134
150
|
return;
|
|
135
151
|
}
|
|
@@ -137,10 +153,35 @@ export default async function commit(config, testMode) {
|
|
|
137
153
|
// Warn if using default example scope and skip AI suggestions
|
|
138
154
|
const hasDefaultScope = config.scopes && config.scopes.length === 1 && config.scopes[0].value === 'example';
|
|
139
155
|
if (hasDefaultScope) {
|
|
140
|
-
utils.log('You are using the default scope "example" - customize your scopes in commit-config.json or run format-commit --config', 'warning');
|
|
156
|
+
utils.log('You are using the default scope "example" - customize your scopes in commit-config.json or run `format-commit --config`', 'warning');
|
|
141
157
|
utils.log('AI suggestions skipped - configure your scopes', 'warning');
|
|
142
158
|
}
|
|
143
159
|
|
|
160
|
+
// Collect custom field values early (needed before AI and classic flows)
|
|
161
|
+
let cancelled = false;
|
|
162
|
+
let customFieldValues = {};
|
|
163
|
+
if (config.format === 'custom') {
|
|
164
|
+
const fields = utils.getCustomFields(config.customFormat);
|
|
165
|
+
for (const label of fields) {
|
|
166
|
+
const resp = await prompts({
|
|
167
|
+
type: 'text',
|
|
168
|
+
name: 'value',
|
|
169
|
+
message: `${label}?`,
|
|
170
|
+
validate: v => (!v || !v.trim()) ? `${label} cannot be empty` : true,
|
|
171
|
+
}, {
|
|
172
|
+
onCancel: () => {
|
|
173
|
+
cancelled = true;
|
|
174
|
+
return false;
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
if (cancelled) {
|
|
178
|
+
utils.log('commit cancelled', 'error');
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
customFieldValues[label] = resp.value;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
144
185
|
let commitTitle = null;
|
|
145
186
|
let useAISuggestion = false;
|
|
146
187
|
|
|
@@ -150,7 +191,7 @@ export default async function commit(config, testMode) {
|
|
|
150
191
|
let suggestions = [];
|
|
151
192
|
|
|
152
193
|
try {
|
|
153
|
-
suggestions = await aiService.generateCommitSuggestions(config,
|
|
194
|
+
suggestions = await aiService.generateCommitSuggestions(config, debugMode, customFieldValues);
|
|
154
195
|
} catch (err) {
|
|
155
196
|
utils.log(`AI suggestions failed (${err.message})`, 'warning');
|
|
156
197
|
}
|
|
@@ -188,7 +229,7 @@ export default async function commit(config, testMode) {
|
|
|
188
229
|
}
|
|
189
230
|
|
|
190
231
|
// Parse and validate format, type, and scope
|
|
191
|
-
const result = utils.parseAndNormalizeCommitTitle(val, config);
|
|
232
|
+
const result = utils.parseAndNormalizeCommitTitle(val, config, customFieldValues);
|
|
192
233
|
if (result.error) {
|
|
193
234
|
return result.error;
|
|
194
235
|
}
|
|
@@ -204,7 +245,7 @@ export default async function commit(config, testMode) {
|
|
|
204
245
|
);
|
|
205
246
|
|
|
206
247
|
// Normalize the final title (correct case)
|
|
207
|
-
const result = utils.parseAndNormalizeCommitTitle(rawTitle, config);
|
|
248
|
+
const result = utils.parseAndNormalizeCommitTitle(rawTitle, config, customFieldValues);
|
|
208
249
|
commitTitle = result.normalized;
|
|
209
250
|
useAISuggestion = true;
|
|
210
251
|
} catch (err) {
|
|
@@ -217,8 +258,6 @@ export default async function commit(config, testMode) {
|
|
|
217
258
|
}
|
|
218
259
|
}
|
|
219
260
|
|
|
220
|
-
let cancelled = false;
|
|
221
|
-
|
|
222
261
|
// If AI suggestion was accepted, only ask for description, version, and push
|
|
223
262
|
if (useAISuggestion) {
|
|
224
263
|
const commit = await prompts([
|
|
@@ -249,7 +288,7 @@ export default async function commit(config, testMode) {
|
|
|
249
288
|
validate: val => utils.validVersion(val),
|
|
250
289
|
},
|
|
251
290
|
{
|
|
252
|
-
type: 'confirm',
|
|
291
|
+
type: testMode ? null : 'confirm',
|
|
253
292
|
name: 'pushAfterCommit',
|
|
254
293
|
message: 'Push changes?',
|
|
255
294
|
initial: false,
|
|
@@ -279,7 +318,7 @@ export default async function commit(config, testMode) {
|
|
|
279
318
|
choices: config.types,
|
|
280
319
|
},
|
|
281
320
|
{
|
|
282
|
-
type:
|
|
321
|
+
type: formatNeedsScope ? 'select' : null,
|
|
283
322
|
name: 'scope',
|
|
284
323
|
message: 'Scope',
|
|
285
324
|
choices: config.scopes,
|
|
@@ -306,7 +345,10 @@ export default async function commit(config, testMode) {
|
|
|
306
345
|
if (!val || val.trim().length === 0) {
|
|
307
346
|
return 'Commit title cannot be empty';
|
|
308
347
|
}
|
|
309
|
-
const fullTitle = utils.formatCommitTitle(
|
|
348
|
+
const fullTitle = utils.formatCommitTitle(
|
|
349
|
+
typeScope.type, val, config.format, typeScope.scope,
|
|
350
|
+
config.customFormat, customFieldValues
|
|
351
|
+
);
|
|
310
352
|
return utils.validCommitTitle(fullTitle, config.minLength, config.maxLength);
|
|
311
353
|
},
|
|
312
354
|
},
|
|
@@ -359,7 +401,9 @@ export default async function commit(config, testMode) {
|
|
|
359
401
|
typeScope.type,
|
|
360
402
|
commit.title,
|
|
361
403
|
config.format,
|
|
362
|
-
typeScope.scope
|
|
404
|
+
typeScope.scope,
|
|
405
|
+
config.customFormat,
|
|
406
|
+
customFieldValues
|
|
363
407
|
);
|
|
364
408
|
|
|
365
409
|
await finalizeCommit(commitTitle, commit.description, commit, currentBranch, testMode);
|
package/lib/create-branch.js
CHANGED
|
@@ -23,14 +23,36 @@ export default async function createBranch(config, testMode) {
|
|
|
23
23
|
return;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
const isCustom = config.branchFormat === 'custom';
|
|
27
|
+
const customHasScope = isCustom && config.customBranchFormat && utils.customBranchFormatHasScope(config.customBranchFormat);
|
|
28
|
+
const needsScope = config.branchFormat === 2 || customHasScope;
|
|
29
|
+
|
|
26
30
|
const noScope = !config.scopes || (config.scopes && config.scopes.length === 0);
|
|
27
|
-
if (
|
|
31
|
+
if (needsScope && noScope) {
|
|
28
32
|
utils.log('no scopes defined - update config or branch format option', 'error');
|
|
29
33
|
return;
|
|
30
34
|
}
|
|
31
35
|
|
|
36
|
+
if (isCustom) {
|
|
37
|
+
const formatValid = utils.validateCustomBranchFormatPattern(config.customBranchFormat);
|
|
38
|
+
if (formatValid !== true) {
|
|
39
|
+
utils.log(`Invalid custom branch format - ${formatValid}`, 'error');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Collect custom fields if custom format
|
|
45
|
+
const customFields = isCustom ? utils.getCustomBranchFields(config.customBranchFormat) : [];
|
|
46
|
+
const customFieldPrompts = customFields.map(field => ({
|
|
47
|
+
type: 'text',
|
|
48
|
+
name: `custom_${field}`,
|
|
49
|
+
message: `${field}?`,
|
|
50
|
+
validate: val => utils.validBranchCustomField(val, field),
|
|
51
|
+
}));
|
|
52
|
+
|
|
32
53
|
let cancelled = false;
|
|
33
54
|
const branch = await prompts([
|
|
55
|
+
...customFieldPrompts,
|
|
34
56
|
{
|
|
35
57
|
type: 'select',
|
|
36
58
|
name: 'type',
|
|
@@ -38,7 +60,7 @@ export default async function createBranch(config, testMode) {
|
|
|
38
60
|
choices: config.types,
|
|
39
61
|
},
|
|
40
62
|
{
|
|
41
|
-
type:
|
|
63
|
+
type: needsScope ? 'select' : null,
|
|
42
64
|
name: 'scope',
|
|
43
65
|
message: 'Scope',
|
|
44
66
|
choices: config.scopes,
|
|
@@ -50,7 +72,7 @@ export default async function createBranch(config, testMode) {
|
|
|
50
72
|
validate: val => utils.validBranchDescription(val, config.maxLength),
|
|
51
73
|
},
|
|
52
74
|
{
|
|
53
|
-
type: 'confirm',
|
|
75
|
+
type: testMode ? null : 'confirm',
|
|
54
76
|
name: 'checkoutAfterCreate',
|
|
55
77
|
message: 'Switch to the new branch after creation?',
|
|
56
78
|
initial: true,
|
|
@@ -67,13 +89,21 @@ export default async function createBranch(config, testMode) {
|
|
|
67
89
|
return;
|
|
68
90
|
}
|
|
69
91
|
|
|
92
|
+
// Build custom field values from prompt answers
|
|
93
|
+
const customFieldValues = {};
|
|
94
|
+
for (const field of customFields) {
|
|
95
|
+
customFieldValues[field] = branch[`custom_${field}`];
|
|
96
|
+
}
|
|
97
|
+
|
|
70
98
|
// Format branch name and create it
|
|
71
99
|
utils.log('create branch...');
|
|
72
100
|
const branchName = utils.formatBranchName(
|
|
73
101
|
branch.type,
|
|
74
102
|
branch.description,
|
|
75
103
|
config.branchFormat,
|
|
76
|
-
branch.scope
|
|
104
|
+
branch.scope,
|
|
105
|
+
config.customBranchFormat,
|
|
106
|
+
customFieldValues
|
|
77
107
|
);
|
|
78
108
|
|
|
79
109
|
if (testMode) {
|
package/lib/index.js
CHANGED
|
@@ -26,7 +26,8 @@ program
|
|
|
26
26
|
.version('0.3.1')
|
|
27
27
|
.option('-b, --branch', 'create a new branch with standardized naming')
|
|
28
28
|
.option('-c, --config', 'generate a configuration file on your project for format-commit')
|
|
29
|
-
.option('-t, --test', 'start without finalize commit (for tests)')
|
|
29
|
+
.option('-t, --test', 'start without finalize commit (for tests)')
|
|
30
|
+
.option('-d, --debug', 'display additional logs');
|
|
30
31
|
|
|
31
32
|
try {
|
|
32
33
|
program.parse(process.argv);
|
|
@@ -49,7 +50,7 @@ try {
|
|
|
49
50
|
utils.log('no configuration found', 'warning');
|
|
50
51
|
const setupResult = await setup(true);
|
|
51
52
|
if (setupResult && setupResult.commitAfter) {
|
|
52
|
-
commit(setupResult.config, opts.test);
|
|
53
|
+
commit(setupResult.config, opts.test, opts.debug);
|
|
53
54
|
}
|
|
54
55
|
} else {
|
|
55
56
|
if (opts.branch) {
|
|
@@ -57,7 +58,7 @@ try {
|
|
|
57
58
|
createBranch(JSON.parse(data), opts.test);
|
|
58
59
|
return;
|
|
59
60
|
}
|
|
60
|
-
commit(JSON.parse(data), opts.test);
|
|
61
|
+
commit(JSON.parse(data), opts.test, opts.debug);
|
|
61
62
|
}
|
|
62
63
|
});
|
|
63
64
|
})();
|
package/lib/options.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"configFile": "commit-config",
|
|
3
3
|
"commitFormats": [
|
|
4
|
-
{ "value": 1, "title": "(type)
|
|
5
|
-
{ "value": 2, "title": "(type)
|
|
6
|
-
{ "value": 3, "title": "type:
|
|
7
|
-
{ "value": 4, "title": "type:
|
|
8
|
-
{ "value": 5, "title": "type(scope)
|
|
9
|
-
{ "value": 6, "title": "type(scope)
|
|
10
|
-
{ "value": 7, "title": "type(scope):
|
|
11
|
-
{ "value": 8, "title": "type(scope):
|
|
4
|
+
{ "value": 1, "title": "(type) Description" },
|
|
5
|
+
{ "value": 2, "title": "(type) description" },
|
|
6
|
+
{ "value": 3, "title": "type: Description" },
|
|
7
|
+
{ "value": 4, "title": "type: description" },
|
|
8
|
+
{ "value": 5, "title": "type(scope) Description" },
|
|
9
|
+
{ "value": 6, "title": "type(scope) description" },
|
|
10
|
+
{ "value": 7, "title": "type(scope): Description" },
|
|
11
|
+
{ "value": 8, "title": "type(scope): description" }
|
|
12
12
|
],
|
|
13
13
|
"branchFormats": [
|
|
14
14
|
{ "value": 1, "title": "type/description" },
|
package/lib/setup.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import prompts from 'prompts';
|
|
2
|
+
import kleur from 'kleur';
|
|
2
3
|
import fs, { readFileSync } from 'fs';
|
|
3
4
|
import * as utils from './utils.js';
|
|
4
5
|
import * as envUtils from './env-utils.js';
|
|
@@ -29,13 +30,31 @@ export default async function setup(askForCommitAfter) {
|
|
|
29
30
|
type: 'select',
|
|
30
31
|
name: 'format',
|
|
31
32
|
message: 'Commit messages nomenclature',
|
|
32
|
-
choices:
|
|
33
|
+
choices: [
|
|
34
|
+
...options.commitFormats,
|
|
35
|
+
{ value: 'custom', title: kleur.gray('Custom - define your own pattern') },
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
type: prev => prev === 'custom' ? 'text' : null,
|
|
40
|
+
name: 'customFormat',
|
|
41
|
+
message: 'Custom format pattern (e.g. `{Issue ID} - type - scope - Description`)',
|
|
42
|
+
validate: val => utils.validateCustomFormatPattern(val),
|
|
33
43
|
},
|
|
34
44
|
{
|
|
35
45
|
type: 'select',
|
|
36
46
|
name: 'branchFormat',
|
|
37
47
|
message: 'Branch names nomenclature',
|
|
38
|
-
choices:
|
|
48
|
+
choices: [
|
|
49
|
+
...options.branchFormats,
|
|
50
|
+
{ value: 'custom', title: kleur.gray('Custom - define your own pattern') },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
type: prev => prev === 'custom' ? 'text' : null,
|
|
55
|
+
name: 'customBranchFormat',
|
|
56
|
+
message: 'Custom branch format pattern (e.g. `{Issue ID}-type/description`)',
|
|
57
|
+
validate: val => utils.validateCustomBranchFormatPattern(val),
|
|
39
58
|
},
|
|
40
59
|
{
|
|
41
60
|
type: 'number',
|
|
@@ -196,13 +215,20 @@ export default async function setup(askForCommitAfter) {
|
|
|
196
215
|
}
|
|
197
216
|
|
|
198
217
|
// Parse prompt data and write config file
|
|
218
|
+
const needsScope = (
|
|
219
|
+
(configChoices.format !== 'custom' && configChoices.format >= 5) ||
|
|
220
|
+
(configChoices.format === 'custom' && utils.customFormatHasScope(configChoices.customFormat)) ||
|
|
221
|
+
configChoices.branchFormat === 2 ||
|
|
222
|
+
(configChoices.branchFormat === 'custom' && utils.customBranchFormatHasScope(configChoices.customBranchFormat))
|
|
223
|
+
);
|
|
224
|
+
|
|
199
225
|
const config = {
|
|
200
226
|
format: configChoices.format,
|
|
227
|
+
customFormat: configChoices.format === 'custom' ? configChoices.customFormat : undefined,
|
|
201
228
|
branchFormat: configChoices.branchFormat,
|
|
229
|
+
customBranchFormat: configChoices.branchFormat === 'custom' ? configChoices.customBranchFormat : undefined,
|
|
202
230
|
types: defaultConfig.types,
|
|
203
|
-
scopes:
|
|
204
|
-
? defaultConfig.scopes
|
|
205
|
-
: undefined,
|
|
231
|
+
scopes: needsScope ? defaultConfig.scopes : undefined,
|
|
206
232
|
minLength: configChoices.minLength,
|
|
207
233
|
maxLength: configChoices.maxLength,
|
|
208
234
|
changeVersion: configChoices.changeVersion,
|
package/lib/utils.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execSync } from 'child_process';
|
|
2
2
|
import kleur from 'kleur';
|
|
3
|
+
import { magenta } from 'kleur/colors';
|
|
3
4
|
|
|
4
5
|
|
|
5
6
|
const { gray, bold, red, green, yellow } = kleur;
|
|
@@ -53,7 +54,199 @@ const validVersion = (version) => {
|
|
|
53
54
|
return true;
|
|
54
55
|
};
|
|
55
56
|
|
|
56
|
-
const
|
|
57
|
+
const applyCasing = (value, casing) => {
|
|
58
|
+
switch (casing) {
|
|
59
|
+
case 'lower': return value.toLowerCase();
|
|
60
|
+
case 'upper': return value.toUpperCase();
|
|
61
|
+
case 'capitalize': return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
|
|
62
|
+
default: return value;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const detectCasing = (word) => {
|
|
67
|
+
if (word === word.toLowerCase()) { return 'lower'; }
|
|
68
|
+
if (word === word.toUpperCase()) { return 'upper'; }
|
|
69
|
+
return 'capitalize';
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const parseCustomFormat = (pattern) => {
|
|
73
|
+
const regex = /\{([^}]+)\}|\b(type|scope|description)\b/gi;
|
|
74
|
+
const segments = [];
|
|
75
|
+
let lastIndex = 0;
|
|
76
|
+
let match;
|
|
77
|
+
|
|
78
|
+
while ((match = regex.exec(pattern)) !== null) {
|
|
79
|
+
// Add literal text before this match
|
|
80
|
+
if (match.index > lastIndex) {
|
|
81
|
+
segments.push({ type: 'literal', value: pattern.slice(lastIndex, match.index) });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (match[1] !== undefined) {
|
|
85
|
+
// {field} placeholder
|
|
86
|
+
segments.push({ type: 'field', label: match[1] });
|
|
87
|
+
} else {
|
|
88
|
+
// keyword (type, scope, description)
|
|
89
|
+
segments.push({
|
|
90
|
+
type: 'keyword',
|
|
91
|
+
keyword: match[2].toLowerCase(),
|
|
92
|
+
case: detectCasing(match[2]),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
lastIndex = match.index + match[0].length;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Add trailing literal text
|
|
100
|
+
if (lastIndex < pattern.length) {
|
|
101
|
+
segments.push({ type: 'literal', value: pattern.slice(lastIndex) });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return segments;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const customFormatHasScope = (pattern) => /\bscope\b/i.test(pattern);
|
|
108
|
+
|
|
109
|
+
const customBranchFormatHasScope = (pattern) => customFormatHasScope(pattern);
|
|
110
|
+
|
|
111
|
+
const getCustomFields = (pattern) => {
|
|
112
|
+
const fields = [];
|
|
113
|
+
const regex = /\{([^}]+)\}/g;
|
|
114
|
+
let match;
|
|
115
|
+
while ((match = regex.exec(pattern)) !== null) {
|
|
116
|
+
fields.push(match[1]);
|
|
117
|
+
}
|
|
118
|
+
return fields;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const validateCustomFormatPattern = (pattern) => {
|
|
122
|
+
if (!pattern || !pattern.trim()) {
|
|
123
|
+
return 'Pattern cannot be empty';
|
|
124
|
+
}
|
|
125
|
+
if (!/\btype\b/i.test(pattern)) {
|
|
126
|
+
return 'Pattern must contain the "type" keyword';
|
|
127
|
+
}
|
|
128
|
+
if (!/\bdescription\b/i.test(pattern)) {
|
|
129
|
+
return 'Pattern must contain the "description" keyword';
|
|
130
|
+
}
|
|
131
|
+
// Check balanced braces
|
|
132
|
+
let depth = 0;
|
|
133
|
+
for (const ch of pattern) {
|
|
134
|
+
if (ch === '{') { depth++; }
|
|
135
|
+
if (ch === '}') { depth--; }
|
|
136
|
+
if (depth < 0) { return 'Unbalanced braces in pattern'; }
|
|
137
|
+
}
|
|
138
|
+
if (depth !== 0) { return 'Unbalanced braces in pattern'; }
|
|
139
|
+
return true;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const parseCustomBranchFormat = (pattern) => {
|
|
143
|
+
const regex = /\{([^}]+)\}|\b(type|scope|description)\b/gi;
|
|
144
|
+
const segments = [];
|
|
145
|
+
let lastIndex = 0;
|
|
146
|
+
let match;
|
|
147
|
+
|
|
148
|
+
while ((match = regex.exec(pattern)) !== null) {
|
|
149
|
+
if (match.index > lastIndex) {
|
|
150
|
+
segments.push({ type: 'literal', value: pattern.slice(lastIndex, match.index) });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (match[1] !== undefined) {
|
|
154
|
+
segments.push({ type: 'field', label: match[1] });
|
|
155
|
+
} else {
|
|
156
|
+
segments.push({
|
|
157
|
+
type: 'keyword',
|
|
158
|
+
keyword: match[2].toLowerCase(),
|
|
159
|
+
case: detectCasing(match[2]),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
lastIndex = match.index + match[0].length;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (lastIndex < pattern.length) {
|
|
167
|
+
segments.push({ type: 'literal', value: pattern.slice(lastIndex) });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return segments;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const validateCustomBranchFormatPattern = (pattern) => {
|
|
174
|
+
if (!pattern || !pattern.trim()) {
|
|
175
|
+
return 'Pattern cannot be empty';
|
|
176
|
+
}
|
|
177
|
+
if (!/\btype\b/i.test(pattern)) {
|
|
178
|
+
return 'Pattern must contain the "type" keyword';
|
|
179
|
+
}
|
|
180
|
+
if (!/\bdescription\b/i.test(pattern)) {
|
|
181
|
+
return 'Pattern must contain the "description" keyword';
|
|
182
|
+
}
|
|
183
|
+
// Check balanced braces
|
|
184
|
+
let depth = 0;
|
|
185
|
+
for (const ch of pattern) {
|
|
186
|
+
if (ch === '{') { depth++; }
|
|
187
|
+
if (ch === '}') { depth--; }
|
|
188
|
+
if (depth < 0) { return 'Unbalanced braces in pattern'; }
|
|
189
|
+
}
|
|
190
|
+
if (depth !== 0) { return 'Unbalanced braces in pattern'; }
|
|
191
|
+
// Validate literal parts (separators) are branch-safe
|
|
192
|
+
const segments = parseCustomBranchFormat(pattern);
|
|
193
|
+
const invalidBranchChars = /[~^:?*[\\\s]/;
|
|
194
|
+
for (const seg of segments) {
|
|
195
|
+
if (seg.type !== 'literal') { continue; }
|
|
196
|
+
if (invalidBranchChars.test(seg.value)) {
|
|
197
|
+
return 'Pattern contains characters invalid in branch names (spaces, ~, ^, :, ?, *, [, \\)';
|
|
198
|
+
}
|
|
199
|
+
if (seg.value.includes('..')) {
|
|
200
|
+
return 'Pattern cannot contain ".." (invalid in branch names)';
|
|
201
|
+
}
|
|
202
|
+
if (seg.value.includes('//')) {
|
|
203
|
+
return 'Pattern cannot contain "//" (invalid in branch names)';
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return true;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const getCustomBranchFields = (pattern) => getCustomFields(pattern);
|
|
210
|
+
|
|
211
|
+
const sanitizeBranchPart = (value) => {
|
|
212
|
+
return value
|
|
213
|
+
.replace(/\s+/g, '-')
|
|
214
|
+
.replace(/[^a-zA-Z0-9-]/g, '')
|
|
215
|
+
.replace(/-+/g, '-')
|
|
216
|
+
.replace(/^-|-$/g, '');
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const formatCustomBranchName = (type, description, segments, scope, customFieldValues = {}) => {
|
|
220
|
+
return segments.map(seg => {
|
|
221
|
+
if (seg.type === 'literal') { return seg.value; }
|
|
222
|
+
if (seg.type === 'field') { return sanitizeBranchPart(customFieldValues[seg.label] || ''); }
|
|
223
|
+
if (seg.type === 'keyword') {
|
|
224
|
+
switch (seg.keyword) {
|
|
225
|
+
case 'type': return applyCasing(type, seg.case);
|
|
226
|
+
case 'scope': return applyCasing(scope || '', seg.case);
|
|
227
|
+
case 'description': return applyCasing(sanitizeBranchPart(description), seg.case);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return '';
|
|
231
|
+
}).join('');
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const formatCustomCommitTitle = (type, description, segments, scope, customFieldValues = {}) => {
|
|
235
|
+
return segments.map(seg => {
|
|
236
|
+
if (seg.type === 'literal') { return seg.value; }
|
|
237
|
+
if (seg.type === 'field') { return customFieldValues[seg.label] || ''; }
|
|
238
|
+
if (seg.type === 'keyword') {
|
|
239
|
+
switch (seg.keyword) {
|
|
240
|
+
case 'type': return applyCasing(type, seg.case);
|
|
241
|
+
case 'scope': return applyCasing(scope || '', seg.case);
|
|
242
|
+
case 'description': return applyCasing(description, seg.case);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return '';
|
|
246
|
+
}).join('');
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const formatCommitTitle = (type, title, format, scope = '*', customFormat, customFieldValues) => {
|
|
57
250
|
// Handle empty title
|
|
58
251
|
if (!title || title.trim().length === 0) {
|
|
59
252
|
return '';
|
|
@@ -61,6 +254,11 @@ const formatCommitTitle = (type, title, format, scope = '*') => {
|
|
|
61
254
|
|
|
62
255
|
const trimmedTitle = title.trim();
|
|
63
256
|
|
|
257
|
+
if (format === 'custom' && customFormat) {
|
|
258
|
+
const segments = parseCustomFormat(customFormat);
|
|
259
|
+
return formatCustomCommitTitle(type, trimmedTitle, segments, scope, customFieldValues);
|
|
260
|
+
}
|
|
261
|
+
|
|
64
262
|
switch (format) {
|
|
65
263
|
case 1:
|
|
66
264
|
default:
|
|
@@ -104,29 +302,43 @@ const log = (message, type) => {
|
|
|
104
302
|
case 'warning':
|
|
105
303
|
msg = yellow(msg);
|
|
106
304
|
break;
|
|
305
|
+
case 'debug':
|
|
306
|
+
msg = magenta(msg);
|
|
307
|
+
break;
|
|
107
308
|
}
|
|
108
309
|
console.log(`${date} ${type === 'error' ? red(msg) : (type === 'success' ? green(msg) : msg)}`);
|
|
109
310
|
};
|
|
110
311
|
|
|
111
|
-
const
|
|
112
|
-
if (
|
|
113
|
-
return
|
|
114
|
-
}
|
|
115
|
-
if (description.length > maxLength) {
|
|
116
|
-
return `Branch description too long (max ${maxLength} characters)`;
|
|
312
|
+
const validBranchCustomField = (value, label) => {
|
|
313
|
+
if (!value || value.trim().length < 1) {
|
|
314
|
+
return `${label} cannot be empty`;
|
|
117
315
|
}
|
|
118
316
|
const invalidChars = /[~^:?*[\\\s]/;
|
|
119
|
-
if (invalidChars.test(
|
|
120
|
-
return
|
|
317
|
+
if (invalidChars.test(value)) {
|
|
318
|
+
return `${label} contains invalid characters (spaces, ~, ^, :, ?, *, [, \\)`;
|
|
319
|
+
}
|
|
320
|
+
if (value.startsWith('.') || value.startsWith('-') ||
|
|
321
|
+
value.endsWith('.') || value.endsWith('-')) {
|
|
322
|
+
return `${label} cannot start or end with . or -`;
|
|
121
323
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
324
|
+
return true;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const validBranchDescription = (description, maxLength) => {
|
|
328
|
+
const base = validBranchCustomField(description, 'Branch description');
|
|
329
|
+
if (base !== true) { return base; }
|
|
330
|
+
if (description.length > maxLength) {
|
|
331
|
+
return `Branch description too long (max ${maxLength} characters)`;
|
|
125
332
|
}
|
|
126
333
|
return true;
|
|
127
334
|
};
|
|
128
335
|
|
|
129
|
-
const formatBranchName = (type, description, format, scope = null) => {
|
|
336
|
+
const formatBranchName = (type, description, format, scope = null, customBranchFormat = null, customFieldValues = {}) => {
|
|
337
|
+
if (format === 'custom' && customBranchFormat) {
|
|
338
|
+
const segments = parseCustomBranchFormat(customBranchFormat);
|
|
339
|
+
return formatCustomBranchName(type, description, segments, scope, customFieldValues);
|
|
340
|
+
}
|
|
341
|
+
|
|
130
342
|
const cleanDescription = description
|
|
131
343
|
.toLowerCase()
|
|
132
344
|
.replace(/\s+/g, '-')
|
|
@@ -160,7 +372,85 @@ const checkBranchExists = (branchName) => {
|
|
|
160
372
|
};
|
|
161
373
|
|
|
162
374
|
/** Parse and validate commit title format, auto-correct case */
|
|
163
|
-
const parseAndNormalizeCommitTitle = (title, config) => {
|
|
375
|
+
const parseAndNormalizeCommitTitle = (title, config, customFieldValues = {}) => {
|
|
376
|
+
// Custom format parsing
|
|
377
|
+
if (config.format === 'custom' && config.customFormat) {
|
|
378
|
+
const segments = parseCustomFormat(config.customFormat);
|
|
379
|
+
|
|
380
|
+
// Build dynamic regex from segments
|
|
381
|
+
const captureNames = [];
|
|
382
|
+
let regexParts = [];
|
|
383
|
+
const captureSegments = segments.filter(s => s.type === 'keyword' || s.type === 'field');
|
|
384
|
+
|
|
385
|
+
for (let i = 0; i < segments.length; i++) {
|
|
386
|
+
const seg = segments[i];
|
|
387
|
+
if (seg.type === 'literal') {
|
|
388
|
+
regexParts.push(seg.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
389
|
+
} else if (seg.type === 'keyword' || seg.type === 'field') {
|
|
390
|
+
const isLast = captureSegments.indexOf(seg) === captureSegments.length - 1;
|
|
391
|
+
captureNames.push(seg);
|
|
392
|
+
regexParts.push(isLast ? '(.+)' : '(.+?)');
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const dynamicRegex = new RegExp('^' + regexParts.join('') + '$');
|
|
397
|
+
const match = title.match(dynamicRegex);
|
|
398
|
+
|
|
399
|
+
if (!match) {
|
|
400
|
+
const exampleTitle = formatCommitTitle(
|
|
401
|
+
config.types[0].value, 'description', config.format,
|
|
402
|
+
config.scopes?.[0]?.value, config.customFormat, customFieldValues
|
|
403
|
+
);
|
|
404
|
+
return { error: `Wrong format. Expected: "${exampleTitle}"` };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
let type, scope, message;
|
|
408
|
+
const fieldValues = {};
|
|
409
|
+
|
|
410
|
+
for (let i = 0; i < captureNames.length; i++) {
|
|
411
|
+
const seg = captureNames[i];
|
|
412
|
+
const val = match[i + 1].trim();
|
|
413
|
+
if (seg.type === 'keyword') {
|
|
414
|
+
switch (seg.keyword) {
|
|
415
|
+
case 'type': type = val; break;
|
|
416
|
+
case 'scope': scope = val; break;
|
|
417
|
+
case 'description': message = val; break;
|
|
418
|
+
}
|
|
419
|
+
} else if (seg.type === 'field') {
|
|
420
|
+
fieldValues[seg.label] = val;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Validate type
|
|
425
|
+
if (!type) {
|
|
426
|
+
return { error: 'Could not detect type in commit title' };
|
|
427
|
+
}
|
|
428
|
+
const validType = config.types.find(t => t.value.toLowerCase() === type.toLowerCase());
|
|
429
|
+
if (!validType) {
|
|
430
|
+
const validTypes = config.types.map(t => t.value).join(', ');
|
|
431
|
+
return { error: `Invalid type "${type}". Valid types: ${validTypes}` };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Validate scope if present
|
|
435
|
+
let validScope = scope;
|
|
436
|
+
if (scope) {
|
|
437
|
+
if (!config.scopes || config.scopes.length === 0) {
|
|
438
|
+
return { error: 'Scope not allowed in current format configuration' };
|
|
439
|
+
}
|
|
440
|
+
const foundScope = config.scopes.find(s => s.value.toLowerCase() === scope.toLowerCase());
|
|
441
|
+
if (!foundScope) {
|
|
442
|
+
const validScopes = config.scopes.map(s => s.value).join(', ');
|
|
443
|
+
return { error: `Invalid scope "${scope}". Valid scopes: ${validScopes}` };
|
|
444
|
+
}
|
|
445
|
+
validScope = foundScope.value;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const normalized = formatCustomCommitTitle(
|
|
449
|
+
validType.value, message, segments, validScope, fieldValues
|
|
450
|
+
);
|
|
451
|
+
return { normalized };
|
|
452
|
+
}
|
|
453
|
+
|
|
164
454
|
let type, scope, message, detectedFormatGroup;
|
|
165
455
|
|
|
166
456
|
// Try different format patterns
|
|
@@ -202,7 +492,7 @@ const parseAndNormalizeCommitTitle = (title, config) => {
|
|
|
202
492
|
if (detectedFormatGroup !== expectedFormatGroup) {
|
|
203
493
|
const exampleTitle = formatCommitTitle(
|
|
204
494
|
config.types[0].value,
|
|
205
|
-
'
|
|
495
|
+
'description',
|
|
206
496
|
config.format,
|
|
207
497
|
config.scopes?.[0]?.value
|
|
208
498
|
);
|
|
@@ -250,11 +540,21 @@ export {
|
|
|
250
540
|
validCommitTitle,
|
|
251
541
|
validCommitTitleSetupLength,
|
|
252
542
|
validBranchDescription,
|
|
543
|
+
validBranchCustomField,
|
|
253
544
|
validVersion,
|
|
254
545
|
formatCommitTitle,
|
|
255
546
|
formatBranchName,
|
|
256
547
|
checkBranchExists,
|
|
257
548
|
parseAndNormalizeCommitTitle,
|
|
549
|
+
parseCustomFormat,
|
|
550
|
+
customFormatHasScope,
|
|
551
|
+
getCustomFields,
|
|
552
|
+
validateCustomFormatPattern,
|
|
553
|
+
parseCustomBranchFormat,
|
|
554
|
+
validateCustomBranchFormatPattern,
|
|
555
|
+
formatCustomBranchName,
|
|
556
|
+
customBranchFormatHasScope,
|
|
557
|
+
getCustomBranchFields,
|
|
258
558
|
handleCmdExec,
|
|
259
559
|
log,
|
|
260
560
|
};
|