format-commit 0.4.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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 formatting.
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.
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,34 +41,111 @@ 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/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` - 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.
54
+
55
+ `format`
56
+
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`
62
+
63
+ `branchFormat`
64
+
65
+ Branch naming format:
66
+ - 1: `type/description`
67
+ - 2: `type/scope/description`
68
+
69
+ `types`
70
+
71
+ Allowed commit and branch types (default: `feat`, `fix`, `core`, `test`, `config`, `doc`)
72
+
73
+ `scopes`
74
+
75
+ Scopes for commit and branch categorization (used in formats 5-8 for commits, format 2 for branches)
76
+
77
+ `minLength`
78
+
79
+ Minimum length required for the commit title.
80
+
81
+ `maxLength`
82
+
83
+ Maximum length required for the commit title and branch description.
84
+
85
+ `changeVersion`
86
+
87
+ Version change policy:
88
+ - `never (ignore)`: Never change version, skip prompt (default)
89
+ - `never (always ask)`: Always prompt for version change
90
+ - `only on release branch`: Only release branch commits require version change
91
+ - `always`: All commits require version change
92
+
93
+ `releaseBranch`
94
+
95
+ Main/release branch name (used if changeVersion = `only on release branch`)
96
+
97
+ `showAllVersionTypes`
98
+
99
+ Show all version types or only main ones (`major`/`minor`/`patch`/`custom`)
100
+
101
+ `ai.enabled`
102
+
103
+ Enable AI commit title suggestions (default: `false`)
104
+
105
+ `ai.provider`
106
+
107
+ AI provider:
108
+ - `anthropic` (Claude)
109
+ - `openai` (GPT)
110
+ - `google` (Gemini)
111
+
112
+ `ai.model`
113
+
114
+ Model identifier (e.g., `claude-haiku-4-5` or `gpt-4o-mini`)
115
+
116
+ `ai.envPath`
117
+
118
+ Path to .env file containing the AI provider API key (e.g., `.env`)
119
+
120
+ `ai.envKeyName`
121
+
122
+ Name of the environment variable for the API key (e.g., `OPENAI_API_KEY`)
123
+
124
+ `ai.largeDiffTokenThreshold`
125
+
126
+ Number of tokens from which not to use AI automatically.
127
+
128
+ ### AI Suggestions
129
+
130
+ When AI is enabled, your staged changes will be processed by the defined AI to suggest commit titles that:
131
+ - Follow your configured format and naming conventions
132
+ - Automatically select appropriate types and scopes
133
+ - Respect your min/max length constraints
134
+ - Describe the actual changes in your code
135
+
136
+ You can either:
137
+ - Choose one of the 4 AI suggestions for quick commits (and can edit it)
138
+ - Select "Custom" to enter commit details manually (classic flow)
139
+
140
+ **Security:** AI provider API key is stored in a `.env` file automatically added to `.gitignore`.
65
141
 
66
142
  ## CLI Options
67
143
 
68
- | Option | Description |
69
- | :----- | :---------- |
70
- | `--config` / `-c` | Generate or update configuration file |
71
- | `--branch` / `-b` | Create a new standardized branch |
72
- | `--test` / `-t` | Test mode - preview without executing Git commands |
144
+ | Short | Long | Description |
145
+ | :---- | :--- | :---------- |
146
+ | `-c` | `--config` | Generate or update configuration file |
147
+ | `-b` | `--branch` | Create a new standardized branch |
148
+ | `-t` | `--test` | Test mode - preview without executing Git commands |
73
149
 
74
150
  ## Contributing
75
151
 
@@ -0,0 +1,289 @@
1
+ import { execSync } from 'child_process';
2
+ import prompts from 'prompts';
3
+ import * as envUtils from './env-utils.js';
4
+ import * as utils from './utils.js';
5
+
6
+
7
+ /* global fetch */
8
+
9
+ const AI_SYSTEM_PROMPT = 'You are a commit message generator. You MUST respond with ONLY a JSON array. NO explanations. NO markdown. NO additional text whatsoever.';
10
+
11
+ /** Get optimized git diff */
12
+ const getOptimizedGitDiff = () => {
13
+ try {
14
+ const stats = execSync('git diff --cached --stat', { encoding: 'utf-8' });
15
+ const diff = execSync(
16
+ 'git diff --cached --no-color --unified=1 -- . ":(exclude)package-lock.json" ":(exclude)*.lock" ":(exclude)*.min.*"',
17
+ { encoding: 'utf-8', maxBuffer: 1024 * 500 }
18
+ );
19
+
20
+ if (!stats.trim() && !diff.trim()) {
21
+ return null;
22
+ }
23
+
24
+ const lines = diff.split('\n').slice(0, 500).join('\n');
25
+ return { stats, diff: lines };
26
+ } catch {
27
+ utils.log('Error getting git diff', 'error');
28
+ return null;
29
+ }
30
+ };
31
+
32
+ /** Build AI prompt */
33
+ const buildPrompt = (diffData, config) => {
34
+ const typesList = config.types.map(t => t.value).join(', ');
35
+ const types = config.types.map(t => `${t.value} (${t.description})`).join(', ');
36
+ const scopesList = config.scopes ? config.scopes.map(s => s.value).join(', ') : null;
37
+ const scopes = config.scopes
38
+ ? config.scopes.map(s => `${s.value} (${s.description})`).join(', ')
39
+ : null;
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
+ const exampleTitle = utils.formatCommitTitle(
70
+ config.types[0].value,
71
+ 'example change description',
72
+ config.format,
73
+ config.scopes?.[0]?.value
74
+ );
75
+
76
+ return `You must analyze git changes and return ONLY a valid JSON array. NO explanations, NO markdown, NO additional text.
77
+
78
+ Git diff stats:
79
+ ${diffData.stats}
80
+
81
+ Git diff:
82
+ ${diffData.diff}
83
+
84
+ 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'}
90
+ - Length: ${config.minLength}-${config.maxLength} chars per title
91
+ - Return exactly 4 different commit titles
92
+ - Output MUST be a raw JSON array with NO text before or after
93
+
94
+ YOUR RESPONSE MUST BE EXACTLY THIS FORMAT (no other text):
95
+ ["title 1", "title 2", "title 3", "title 4"]`;
96
+ };
97
+
98
+ /** Call Anthropic API */
99
+ const callAnthropicAPI = async (prompt, apiKey, model) => {
100
+ const response = await fetch('https://api.anthropic.com/v1/messages', {
101
+ method: 'POST',
102
+ headers: {
103
+ 'Content-Type': 'application/json',
104
+ 'x-api-key': apiKey,
105
+ 'anthropic-version': '2023-06-01',
106
+ },
107
+ body: JSON.stringify({
108
+ model: model,
109
+ max_tokens: 200,
110
+ system: AI_SYSTEM_PROMPT,
111
+ messages: [
112
+ {
113
+ role: 'user',
114
+ content: prompt,
115
+ },
116
+ ],
117
+ }),
118
+ });
119
+
120
+ if (!response.ok) {
121
+ const errorText = await response.text();
122
+ throw new Error(`Anthropic API error: ${response.status} ${errorText}`);
123
+ }
124
+
125
+ const data = await response.json();
126
+ return data.content[0].text;
127
+ };
128
+
129
+ /** Call OpenAI API */
130
+ const callOpenAIAPI = async (prompt, apiKey, model) => {
131
+ const response = await fetch('https://api.openai.com/v1/chat/completions', {
132
+ method: 'POST',
133
+ headers: {
134
+ 'Content-Type': 'application/json',
135
+ 'Authorization': `Bearer ${apiKey}`,
136
+ },
137
+ body: JSON.stringify({
138
+ model: model,
139
+ max_tokens: 200,
140
+ messages: [
141
+ {
142
+ role: 'system',
143
+ content: AI_SYSTEM_PROMPT,
144
+ },
145
+ {
146
+ role: 'user',
147
+ content: prompt,
148
+ },
149
+ ],
150
+ }),
151
+ });
152
+
153
+ if (!response.ok) {
154
+ const errorText = await response.text();
155
+ throw new Error(`OpenAI API error: ${response.status} ${errorText}`);
156
+ }
157
+
158
+ const data = await response.json();
159
+ return data.choices[0].message.content;
160
+ };
161
+
162
+ /** Call Google Gemini API */
163
+ const callGeminiAPI = async (prompt, apiKey, model) => {
164
+ const response = await fetch(
165
+ `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
166
+ {
167
+ method: 'POST',
168
+ headers: {
169
+ 'Content-Type': 'application/json',
170
+ 'x-goog-api-key': apiKey,
171
+ },
172
+ body: JSON.stringify({
173
+ system_instruction: { parts: [{ text: AI_SYSTEM_PROMPT }] },
174
+ contents: [{ parts: [{ text: prompt }] }],
175
+ }),
176
+ }
177
+ );
178
+
179
+ if (!response.ok) {
180
+ const errorText = await response.text();
181
+ throw new Error(`Gemini API error: ${response.status} ${errorText}`);
182
+ }
183
+
184
+ const data = await response.json();
185
+ return data.candidates[0].content.parts[0].text;
186
+ };
187
+
188
+ /** Validate and normalize AI suggestion */
189
+ const validateAndNormalizeSuggestion = (suggestion, config) => {
190
+ const result = utils.parseAndNormalizeCommitTitle(suggestion, config);
191
+
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;
203
+ };
204
+
205
+ /** Generate commit title suggestions using AI */
206
+ const generateCommitSuggestions = async (config, testMode) => {
207
+ const diffData = getOptimizedGitDiff();
208
+ if (!diffData) {
209
+ return [];
210
+ }
211
+
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
+ }
216
+
217
+ const prompt = buildPrompt(diffData, config);
218
+ const estimatedTokens = Math.ceil(prompt.length / 4);
219
+ const threshold = config.ai.largeDiffTokenThreshold || 20000;
220
+
221
+ if (estimatedTokens > threshold) {
222
+ const { confirm } = await prompts({
223
+ type: 'confirm',
224
+ name: 'confirm',
225
+ message: `Large diff detected (~${estimatedTokens} tokens). Generate AI suggestions?`,
226
+ initial: false,
227
+ });
228
+ if (!confirm) {
229
+ return [];
230
+ }
231
+ }
232
+
233
+ let responseText;
234
+ if (config.ai.provider === 'anthropic') {
235
+ responseText = await callAnthropicAPI(prompt, apiKey, config.ai.model);
236
+ } else if (config.ai.provider === 'openai') {
237
+ responseText = await callOpenAIAPI(prompt, apiKey, config.ai.model);
238
+ } else if (config.ai.provider === 'google') {
239
+ responseText = await callGeminiAPI(prompt, apiKey, config.ai.model);
240
+ } else {
241
+ throw new Error(`Unknown AI provider: ${config.ai.provider}`);
242
+ }
243
+
244
+ const jsonMatch = responseText.match(/\[[\s\S]*\]/);
245
+ if (!jsonMatch) {
246
+ if (testMode) {
247
+ utils.log(responseText, 'warning');
248
+ }
249
+ throw new Error('No JSON array found in AI response');
250
+ }
251
+
252
+ const suggestions = JSON.parse(jsonMatch[0]);
253
+
254
+ if (!Array.isArray(suggestions) || !suggestions.length) {
255
+ if (testMode) {
256
+ utils.log(responseText, 'warning');
257
+ }
258
+ throw new Error('Invalid AI response format');
259
+ }
260
+
261
+ // Validate and normalize each suggestion
262
+ const validationResults = suggestions.map(s => ({
263
+ original: s,
264
+ normalized: validateAndNormalizeSuggestion(s, config)
265
+ }));
266
+
267
+ // Log rejected suggestions in test mode
268
+ if (testMode) {
269
+ validationResults.forEach(({ original, normalized }) => {
270
+ if (normalized === null) {
271
+ utils.log(`rejected: "${original}"`, 'warning');
272
+ }
273
+ });
274
+ }
275
+
276
+ const validSuggestions = validationResults
277
+ .filter(({ normalized }) => normalized !== null)
278
+ .map(({ normalized }) => normalized);
279
+
280
+ if (validSuggestions.length === 0) {
281
+ throw new Error('All AI suggestions were invalid');
282
+ }
283
+
284
+ return validSuggestions;
285
+ };
286
+
287
+ export {
288
+ generateCommitSuggestions,
289
+ };