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 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
- `format`
55
+ **`format`**
56
56
 
57
57
  Commit title format:
58
- - 1: `(type) Name` / 2: `(type) name`
59
- - 3: `type: Name` / 4: `type: name`
60
- - 5: `type(scope) Name` / 6: `type(scope) name`
61
- - 7: `type(scope): Name` / 8: `type(scope): name`
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
- `branchFormat`
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
- `types`
83
+ **`types`**
70
84
 
71
85
  Allowed commit and branch types (default: `feat`, `fix`, `core`, `test`, `config`, `doc`)
72
86
 
73
- `scopes`
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
- `minLength`
91
+ **`minLength`**
78
92
 
79
93
  Minimum length required for the commit title.
80
94
 
81
- `maxLength`
95
+ **`maxLength`**
82
96
 
83
97
  Maximum length required for the commit title and branch description.
84
98
 
85
- `changeVersion`
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
- `releaseBranch`
107
+ **`releaseBranch`**
94
108
 
95
109
  Main/release branch name (used if changeVersion = `only on release branch`)
96
110
 
97
- `showAllVersionTypes`
111
+ **`showAllVersionTypes`**
98
112
 
99
113
  Show all version types or only main ones (`major`/`minor`/`patch`/`custom`)
100
114
 
101
- `ai.enabled`
115
+ **`ai.enabled`**
102
116
 
103
117
  Enable AI commit title suggestions (default: `false`)
104
118
 
105
- `ai.provider`
119
+ **`ai.provider`**
106
120
 
107
121
  AI provider:
108
122
  - `anthropic` (Claude)
109
123
  - `openai` (GPT)
110
124
  - `google` (Gemini)
111
125
 
112
- `ai.model`
126
+ **`ai.model`**
113
127
 
114
128
  Model identifier (e.g., `claude-haiku-4-5` or `gpt-4o-mini`)
115
129
 
116
- `ai.envPath`
130
+ **`ai.envPath`**
117
131
 
118
132
  Path to .env file containing the AI provider API key (e.g., `.env`)
119
133
 
120
- `ai.envKeyName`
134
+ **`ai.envKeyName`**
121
135
 
122
136
  Name of the environment variable for the API key (e.g., `OPENAI_API_KEY`)
123
137
 
124
- `ai.largeDiffTokenThreshold`
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 AI to suggest commit titles that:
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` | Test mode - preview without executing Git commands |
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} (${t.description})`).join(', ');
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} (${s.description})`).join(', ')
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
- 'example change description',
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
- - ${formatInstruction}
86
- - Example format: "${exampleTitle}"
87
- - ONLY use these types (NO others): ${typesList}
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 with NO text before or after
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, testMode) => {
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 (testMode) {
247
- utils.log(responseText, 'warning');
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 (testMode) {
256
- utils.log(responseText, 'warning');
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 (testMode) {
272
+ if (debugMode) {
269
273
  validationResults.forEach(({ original, normalized }) => {
270
274
  if (normalized === null) {
271
- utils.log(`rejected: "${original}"`, 'warning');
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 - commit will not be performed', 'warning');
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 (config.format >= 5 && noScope) {
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, testMode);
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: config.format >= 5 ? 'select' : null,
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(typeScope.type, val, config.format, typeScope.scope);
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);
@@ -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 (config.branchFormat === 2 && noScope) {
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: config.branchFormat === 2 ? 'select' : null,
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) Name" },
5
- { "value": 2, "title": "(type) name" },
6
- { "value": 3, "title": "type: Name" },
7
- { "value": 4, "title": "type: name" },
8
- { "value": 5, "title": "type(scope) Name" },
9
- { "value": 6, "title": "type(scope) name" },
10
- { "value": 7, "title": "type(scope): Name" },
11
- { "value": 8, "title": "type(scope): name" }
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: options.commitFormats,
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: options.branchFormats,
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: (configChoices.format >= 5 || configChoices.branchFormat === 2)
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 formatCommitTitle = (type, title, format, scope = '*') => {
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 validBranchDescription = (description, maxLength) => {
112
- if (description.length < 1) {
113
- return 'Branch description cannot be empty';
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(description)) {
120
- return 'Branch description contains invalid characters (spaces, ~, ^, :, ?, *, [, \\)';
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
- if (description.startsWith('.') || description.startsWith('-') ||
123
- description.endsWith('.') || description.endsWith('-')) {
124
- return 'Branch description cannot start or end with . or -';
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
- 'name',
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "format-commit",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Lightweight CLI to standardize commit messages with AI-powered suggestions",
5
5
  "license": "MIT",
6
6
  "author": "Thomas BARKATS",