format-commit 1.0.0 → 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
@@ -1,13 +1,12 @@
1
1
  # format-commit
2
2
 
3
- [![npm version](https://badge.fury.io/js/format-commit.svg)](https://badge.fury.io/js/format-commit)
4
3
  [![Node.js Version](https://img.shields.io/node/v/format-commit.svg)](https://nodejs.org/)
5
4
  [![npm downloads](https://img.shields.io/npm/dm/format-commit.svg)](https://www.npmjs.com/package/format-commit)
6
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
6
 
8
- 🚀 Lightweight CLI for consistent Git workflow & and optional AI support.
7
+ Lightweight CLI for consistent Git workflow & and optional AI support.
9
8
 
10
- Standardize your commit messages and branch naming with configurable rules, and guide your development workflow through automated scripts. No bloat, no complexity just clean, consistent Git practices. Feel free to let AI suggest commit titles for you in the expected format.
9
+ Standardize your commit messages and branch naming with configurable rules, and guide your development workflow through automated scripts. No bloat, no complexity - just clean, consistent Git practices. Feel free to let AI suggest commit titles for you in the expected format.
11
10
 
12
11
  ## Installation
13
12
 
@@ -25,7 +24,7 @@ Add to your `package.json` scripts:
25
24
  }
26
25
  ```
27
26
 
28
- Then use:
27
+ And use:
29
28
  ```sh
30
29
  npm run commit # to commit
31
30
  npm run branch # to create a branch
@@ -42,39 +41,107 @@ format-commit --branch
42
41
 
43
42
  ### Initial Setup
44
43
 
45
- On first use, format-commit will prompt you to configure your commit and branch formats, then create a `commit-config.json` file.
44
+ On first use, format-commit will prompt you to configure your commit and branch.
46
45
 
47
- To reconfigure later, run:
46
+ If you want to reconfigure later from scratch, run:
48
47
  ```sh
49
48
  format-commit --config
50
49
  ```
51
50
 
52
51
  ## Configuration
53
52
 
54
- | Property | Description |
55
- | :------- | :---------- |
56
- | **format** | Commit title format:<br>1 - `(type) Name` / 2 - `(type) name`<br>3 - `type: Name` / 4 - `type: name`<br>5 - `type(scope) Name` / 6 - `type(scope) name`<br>7 - `type(scope): Name` / 8 - `type(scope): name` |
57
- | **branchFormat** | Branch naming format:<br>1 - `type/description`<br>2 - `type/scope/description` |
58
- | **types** | Allowed commit and branch types (default: `feat`, `fix`, `core`, `test`, `config`, `doc`) |
59
- | **scopes** | Scopes for commit and branch categorization (used in formats 5-8 for commits, format 2 for branches) |
60
- | **minLength** | Minimum length required for the commit title |
61
- | **maxLength** | Maximum length required for the commit title and branch description |
62
- | **changeVersion** | Version change policy:<br>`never (ignore)` - Never change version, skip prompt (default)<br>`never (always ask)` - Always prompt for version change<br>`only on release branch` - Only release branch commits require version change<br>`always` - All commits require version change |
63
- | **releaseBranch** | Main/release branch name (used if changeVersion = `only on release branch`) |
64
- | **showAllVersionTypes** | Show all version types or only main ones (`major`/`minor`/`patch`/`custom`) |
53
+ All configuration is stored in the `commit-config.json` file. Here is the list of all options.
65
54
 
66
- ### AI Suggestions
55
+ **`format`**
56
+
57
+ Commit title format:
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"
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`**
71
+
72
+ Branch naming format:
73
+ - 1: `type/description`
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`.
82
+
83
+ **`types`**
84
+
85
+ Allowed commit and branch types (default: `feat`, `fix`, `core`, `test`, `config`, `doc`)
86
+
87
+ **`scopes`**
88
+
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)
90
+
91
+ **`minLength`**
92
+
93
+ Minimum length required for the commit title.
94
+
95
+ **`maxLength`**
96
+
97
+ Maximum length required for the commit title and branch description.
98
+
99
+ **`changeVersion`**
67
100
 
68
- | Property | Description |
69
- | :------- | :---------- |
70
- | **ai.enabled** | Enable AI commit title suggestions (default: `false`) |
71
- | **ai.provider** | AI provider:<br>`anthropic` (Claude)<br>`openai` (GPT)<br>`google` (Gemini) |
72
- | **ai.model** | Eg. `claude-haiku-4-5` or `gpt-4o-mini` |
73
- | **ai.envPath** | Path to .env file containing the AI provider API key (e.g., `.env`) |
74
- | **ai.envKeyName** | Name of the environment variable for the API key (e.g., `OPENAI_API_KEY`) |
75
- | **ai.largeDiffTokenThreshold** | Number of tokens from which not to use AI automatically |
101
+ Version change policy:
102
+ - `never (ignore)`: Never change version, skip prompt (default)
103
+ - `never (always ask)`: Always prompt for version change
104
+ - `only on release branch`: Only release branch commits require version change
105
+ - `always`: All commits require version change
106
+
107
+ **`releaseBranch`**
108
+
109
+ Main/release branch name (used if changeVersion = `only on release branch`)
110
+
111
+ **`showAllVersionTypes`**
112
+
113
+ Show all version types or only main ones (`major`/`minor`/`patch`/`custom`)
114
+
115
+ **`ai.enabled`**
116
+
117
+ Enable AI commit title suggestions (default: `false`)
118
+
119
+ **`ai.provider`**
120
+
121
+ AI provider:
122
+ - `anthropic` (Claude)
123
+ - `openai` (GPT)
124
+ - `google` (Gemini)
125
+
126
+ **`ai.model`**
127
+
128
+ Model identifier (e.g., `claude-haiku-4-5` or `gpt-4o-mini`)
129
+
130
+ **`ai.envPath`**
131
+
132
+ Path to .env file containing the AI provider API key (e.g., `.env`)
133
+
134
+ **`ai.envKeyName`**
135
+
136
+ Name of the environment variable for the API key (e.g., `OPENAI_API_KEY`)
137
+
138
+ **`ai.largeDiffTokenThreshold`**
139
+
140
+ Number of tokens from which not to use AI automatically.
141
+
142
+ ### AI Suggestions
76
143
 
77
- When AI is enabled, format-commit will analyze your staged changes and suggest 4 complete commit titles that:
144
+ When AI is enabled, your staged changes will be processed by the defined model to suggest commit titles that:
78
145
  - Follow your configured format and naming conventions
79
146
  - Automatically select appropriate types and scopes
80
147
  - Respect your min/max length constraints
@@ -84,15 +151,16 @@ You can either:
84
151
  - Choose one of the 4 AI suggestions for quick commits (and can edit it)
85
152
  - Select "Custom" to enter commit details manually (classic flow)
86
153
 
87
- **Security:** Your AI provider API key is stored in a `.env` file (not versioned) and automatically added to `.gitignore`.
154
+ **Security:** AI provider API key is stored in a `.env` file automatically added to `.gitignore`.
88
155
 
89
156
  ## CLI Options
90
157
 
91
- | Option | Description |
92
- | :----- | :---------- |
93
- | `--config` / `-c` | Generate or update configuration file |
94
- | `--branch` / `-b` | Create a new standardized branch |
95
- | `--test` / `-t` | Test mode - preview without executing Git commands |
158
+ | Short | Long | Description |
159
+ | :---- | :--- | :---------- |
160
+ | `-c` | `--config` | Generate or update configuration file |
161
+ | `-b` | `--branch` | Create a new standardized branch |
162
+ | `-t` | `--test` | Preview without executing Git commands |
163
+ | `-d` | `--debug` | Display additional logs |
96
164
 
97
165
  ## Contributing
98
166
 
package/lib/ai-service.js CHANGED
@@ -30,47 +30,48 @@ const getOptimizedGitDiff = () => {
30
30
  };
31
31
 
32
32
  /** Build AI prompt */
33
- const buildPrompt = (diffData, config) => {
34
- const types = config.types.map(t => `${t.value} (${t.description})`).join(', ');
33
+ const buildPrompt = (diffData, config, customFieldValues = {}) => {
34
+ const typesList = config.types.map(t => t.value).join(', ');
35
+ const types = config.types.map(t => `${t.value} is for ${t.description}`).join('\n > ');
36
+ const scopesList = config.scopes ? config.scopes.map(s => s.value).join(', ') : null;
35
37
  const scopes = config.scopes
36
- ? config.scopes.map(s => `${s.value} (${s.description})`).join(', ')
38
+ ? config.scopes.map(s => `${s.value} is for ${s.description}`).join('\n > ')
37
39
  : null;
38
40
 
39
- let formatInstruction = '';
40
- switch (config.format) {
41
- case 1:
42
- formatInstruction = 'Format: (type) Title with first letter capitalized';
43
- break;
44
- case 2:
45
- formatInstruction = 'Format: (type) title in lowercase';
46
- break;
47
- case 3:
48
- formatInstruction = 'Format: type: Title with first letter capitalized';
49
- break;
50
- case 4:
51
- formatInstruction = 'Format: type: title in lowercase';
52
- break;
53
- case 5:
54
- formatInstruction = 'Format: type(scope) Title with first letter capitalized';
55
- break;
56
- case 6:
57
- formatInstruction = 'Format: type(scope) title in lowercase';
58
- break;
59
- case 7:
60
- formatInstruction = 'Format: type(scope): Title with first letter capitalized';
61
- break;
62
- case 8:
63
- formatInstruction = 'Format: type(scope): title in lowercase';
64
- break;
65
- }
66
-
67
41
  const exampleTitle = utils.formatCommitTitle(
68
42
  config.types[0].value,
69
- 'example change description',
43
+ 'Description of changes',
70
44
  config.format,
71
- config.scopes?.[0]?.value
45
+ config.scopes?.[0]?.value,
46
+ config.customFormat,
47
+ customFieldValues
72
48
  );
73
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
+
74
75
  return `You must analyze git changes and return ONLY a valid JSON array. NO explanations, NO markdown, NO additional text.
75
76
 
76
77
  Git diff stats:
@@ -80,15 +81,16 @@ Git diff:
80
81
  ${diffData.diff}
81
82
 
82
83
  STRICT REQUIREMENTS:
83
- - ${formatInstruction}
84
- - Example format: "${exampleTitle}"
85
- - Available types: ${types}
86
- ${scopes ? `- Available scopes: ${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}
87
87
  - Length: ${config.minLength}-${config.maxLength} chars per title
88
88
  - Return exactly 4 different commit titles
89
- - Output MUST be a raw JSON array with NO text before or after
89
+ - Output MUST be a raw JSON array
90
90
 
91
- YOUR RESPONSE MUST BE EXACTLY THIS FORMAT (no other text):
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".
92
+
93
+ YOUR RESPONSE MUST BE EXACTLY THIS FORMAT (no other text before or after):
92
94
  ["title 1", "title 2", "title 3", "title 4"]`;
93
95
  };
94
96
 
@@ -182,89 +184,108 @@ const callGeminiAPI = async (prompt, apiKey, model) => {
182
184
  return data.candidates[0].content.parts[0].text;
183
185
  };
184
186
 
185
- /** Validate a commit suggestion */
186
- const validateSuggestion = (suggestion, config) => {
187
- if (suggestion.length < config.minLength || suggestion.length > config.maxLength) {
188
- return false;
189
- }
190
187
 
191
- // Check that the suggestion contains a valid type
192
- const validTypes = config.types.map(t => t.value);
193
- const hasValidType = validTypes.some(type => {
194
- return suggestion.includes(type);
195
- });
188
+ /** Validate and normalize AI suggestion */
189
+ const validateAndNormalizeSuggestion = (suggestion, config, customFieldValues = {}) => {
190
+ const result = utils.parseAndNormalizeCommitTitle(suggestion, config, customFieldValues);
196
191
 
197
- return hasValidType;
192
+ // Returns null if invalid format/type/scope/length
193
+ if (result.error) {
194
+ return null;
195
+ }
196
+ const lengthCheck = utils.validCommitTitle(result.normalized, config.minLength, config.maxLength);
197
+ if (lengthCheck !== true) {
198
+ return null;
199
+ }
200
+
201
+ // Returns normalized title (auto-corrected case)
202
+ return result.normalized;
198
203
  };
199
204
 
200
205
  /** Generate commit title suggestions using AI */
201
- const generateCommitSuggestions = async (config, testMode) => {
202
- try {
203
- const diffData = getOptimizedGitDiff();
204
- if (!diffData) {
205
- return [];
206
- }
206
+ const generateCommitSuggestions = async (config, debugMode, customFieldValues = {}) => {
207
+ const diffData = getOptimizedGitDiff();
208
+ if (!diffData) {
209
+ return [];
210
+ }
207
211
 
208
- const apiKey = envUtils.getEnvKey(config.ai.envPath, config.ai.envKeyName);
209
- if (!apiKey) {
210
- utils.log('AI API key not found in .env', 'warning');
211
- return [];
212
- }
212
+ const apiKey = envUtils.getEnvKey(config.ai.envPath, config.ai.envKeyName);
213
+ if (!apiKey) {
214
+ throw new Error('AI API key not found in .env');
215
+ }
213
216
 
214
- const prompt = buildPrompt(diffData, config);
215
- const estimatedTokens = Math.ceil(prompt.length / 4);
216
- const threshold = config.ai.largeDiffTokenThreshold || 20000;
217
-
218
- if (estimatedTokens > threshold) {
219
- const { confirm } = await prompts({
220
- type: 'confirm',
221
- name: 'confirm',
222
- message: `Large diff detected (~${estimatedTokens} tokens). Generate AI suggestions?`,
223
- initial: false,
224
- });
225
- if (!confirm) {
226
- return [];
227
- }
228
- }
217
+ const prompt = buildPrompt(diffData, config, customFieldValues);
218
+ const estimatedTokens = Math.ceil(prompt.length / 4);
219
+ const threshold = config.ai.largeDiffTokenThreshold || 20000;
229
220
 
230
- let responseText;
231
- if (config.ai.provider === 'anthropic') {
232
- responseText = await callAnthropicAPI(prompt, apiKey, config.ai.model);
233
- } else if (config.ai.provider === 'openai') {
234
- responseText = await callOpenAIAPI(prompt, apiKey, config.ai.model);
235
- } else if (config.ai.provider === 'google') {
236
- responseText = await callGeminiAPI(prompt, apiKey, config.ai.model);
237
- } else {
238
- throw new Error(`Unknown AI provider: ${config.ai.provider}`);
239
- }
221
+ if (debugMode) {
222
+ utils.log('<`git diff`>\n\n' + prompt.slice(prompt.indexOf('STRICT REQUIREMENTS:')), 'debug');
223
+ }
240
224
 
241
- const jsonMatch = responseText.match(/\[[\s\S]*\]/);
242
- if (!jsonMatch) {
243
- if (testMode) {
244
- utils.log(responseText, 'warning');
245
- }
246
- throw new Error('No JSON array found in AI response');
225
+ if (estimatedTokens > threshold) {
226
+ const { confirm } = await prompts({
227
+ type: 'confirm',
228
+ name: 'confirm',
229
+ message: `Large diff detected (~${estimatedTokens} tokens). Generate AI suggestions?`,
230
+ initial: false,
231
+ });
232
+ if (!confirm) {
233
+ return [];
247
234
  }
235
+ }
248
236
 
249
- const suggestions = JSON.parse(jsonMatch[0]);
237
+ let responseText;
238
+ if (config.ai.provider === 'anthropic') {
239
+ responseText = await callAnthropicAPI(prompt, apiKey, config.ai.model);
240
+ } else if (config.ai.provider === 'openai') {
241
+ responseText = await callOpenAIAPI(prompt, apiKey, config.ai.model);
242
+ } else if (config.ai.provider === 'google') {
243
+ responseText = await callGeminiAPI(prompt, apiKey, config.ai.model);
244
+ } else {
245
+ throw new Error(`Unknown AI provider: ${config.ai.provider}`);
246
+ }
250
247
 
251
- if (!Array.isArray(suggestions) || suggestions.length !== 4) {
252
- throw new Error('Invalid AI response format');
248
+ const jsonMatch = responseText.match(/\[[\s\S]*\]/);
249
+ if (!jsonMatch) {
250
+ if (debugMode) {
251
+ utils.log(responseText, 'debug');
253
252
  }
253
+ throw new Error('No JSON array found in AI response');
254
+ }
254
255
 
255
- const validSuggestions = suggestions.filter(s => validateSuggestion(s, config));
256
+ const suggestions = JSON.parse(jsonMatch[0]);
256
257
 
257
- if (validSuggestions.length < 4) {
258
- utils.log('Some AI suggestions were invalid', 'warning');
259
- return [];
258
+ if (!Array.isArray(suggestions) || !suggestions.length) {
259
+ if (debugMode) {
260
+ utils.log(responseText, 'debug');
260
261
  }
262
+ throw new Error('Invalid AI response format');
263
+ }
261
264
 
262
- return validSuggestions;
265
+ // Validate and normalize each suggestion
266
+ const validationResults = suggestions.map(s => ({
267
+ original: s,
268
+ normalized: validateAndNormalizeSuggestion(s, config, customFieldValues)
269
+ }));
270
+
271
+ // Log rejected suggestions in test mode
272
+ if (debugMode) {
273
+ validationResults.forEach(({ original, normalized }) => {
274
+ if (normalized === null) {
275
+ utils.log(`rejected: "${original}"`, 'debug');
276
+ }
277
+ });
278
+ }
263
279
 
264
- } catch (error) {
265
- utils.log(`AI suggestion failed: ${error.message}`, 'warning');
266
- return [];
280
+ const validSuggestions = validationResults
281
+ .filter(({ normalized }) => normalized !== null)
282
+ .map(({ normalized }) => normalized);
283
+
284
+ if (validSuggestions.length === 0) {
285
+ throw new Error('All AI suggestions were invalid');
267
286
  }
287
+
288
+ return validSuggestions;
268
289
  };
269
290
 
270
291
  export {