format-commit 0.4.0 → 1.0.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 +27 -4
- package/lib/ai-service.js +272 -0
- package/lib/commit.js +350 -76
- package/lib/create-branch.js +5 -24
- package/lib/default-config.json +7 -2
- package/lib/env-utils.js +109 -0
- package/lib/index.js +19 -12
- package/lib/options.json +27 -0
- package/lib/setup.js +167 -17
- package/lib/utils.js +31 -14
- package/package.json +11 -6
package/README.md
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/format-commit)
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
7
7
|
|
|
8
|
-
🚀 Lightweight CLI for consistent Git workflow
|
|
8
|
+
🚀 Lightweight CLI for consistent Git workflow & and optional AI support.
|
|
9
9
|
|
|
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.
|
|
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.
|
|
11
11
|
|
|
12
12
|
## Installation
|
|
13
13
|
|
|
@@ -56,13 +56,36 @@ format-commit --config
|
|
|
56
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
57
|
| **branchFormat** | Branch naming format:<br>1 - `type/description`<br>2 - `type/scope/description` |
|
|
58
58
|
| **types** | Allowed commit and branch types (default: `feat`, `fix`, `core`, `test`, `config`, `doc`) |
|
|
59
|
-
| **scopes** | Scopes for commit
|
|
59
|
+
| **scopes** | Scopes for commit and branch categorization (used in formats 5-8 for commits, format 2 for branches) |
|
|
60
60
|
| **minLength** | Minimum length required for the commit title |
|
|
61
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 |
|
|
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
63
|
| **releaseBranch** | Main/release branch name (used if changeVersion = `only on release branch`) |
|
|
64
64
|
| **showAllVersionTypes** | Show all version types or only main ones (`major`/`minor`/`patch`/`custom`) |
|
|
65
65
|
|
|
66
|
+
### AI Suggestions
|
|
67
|
+
|
|
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 |
|
|
76
|
+
|
|
77
|
+
When AI is enabled, format-commit will analyze your staged changes and suggest 4 complete commit titles that:
|
|
78
|
+
- Follow your configured format and naming conventions
|
|
79
|
+
- Automatically select appropriate types and scopes
|
|
80
|
+
- Respect your min/max length constraints
|
|
81
|
+
- Describe the actual changes in your code
|
|
82
|
+
|
|
83
|
+
You can either:
|
|
84
|
+
- Choose one of the 4 AI suggestions for quick commits (and can edit it)
|
|
85
|
+
- Select "Custom" to enter commit details manually (classic flow)
|
|
86
|
+
|
|
87
|
+
**Security:** Your AI provider API key is stored in a `.env` file (not versioned) and automatically added to `.gitignore`.
|
|
88
|
+
|
|
66
89
|
## CLI Options
|
|
67
90
|
|
|
68
91
|
| Option | Description |
|
|
@@ -0,0 +1,272 @@
|
|
|
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 types = config.types.map(t => `${t.value} (${t.description})`).join(', ');
|
|
35
|
+
const scopes = config.scopes
|
|
36
|
+
? config.scopes.map(s => `${s.value} (${s.description})`).join(', ')
|
|
37
|
+
: null;
|
|
38
|
+
|
|
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
|
+
const exampleTitle = utils.formatCommitTitle(
|
|
68
|
+
config.types[0].value,
|
|
69
|
+
'example change description',
|
|
70
|
+
config.format,
|
|
71
|
+
config.scopes?.[0]?.value
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return `You must analyze git changes and return ONLY a valid JSON array. NO explanations, NO markdown, NO additional text.
|
|
75
|
+
|
|
76
|
+
Git diff stats:
|
|
77
|
+
${diffData.stats}
|
|
78
|
+
|
|
79
|
+
Git diff:
|
|
80
|
+
${diffData.diff}
|
|
81
|
+
|
|
82
|
+
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'}
|
|
87
|
+
- Length: ${config.minLength}-${config.maxLength} chars per title
|
|
88
|
+
- Return exactly 4 different commit titles
|
|
89
|
+
- Output MUST be a raw JSON array with NO text before or after
|
|
90
|
+
|
|
91
|
+
YOUR RESPONSE MUST BE EXACTLY THIS FORMAT (no other text):
|
|
92
|
+
["title 1", "title 2", "title 3", "title 4"]`;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Call Anthropic API */
|
|
96
|
+
const callAnthropicAPI = async (prompt, apiKey, model) => {
|
|
97
|
+
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: {
|
|
100
|
+
'Content-Type': 'application/json',
|
|
101
|
+
'x-api-key': apiKey,
|
|
102
|
+
'anthropic-version': '2023-06-01',
|
|
103
|
+
},
|
|
104
|
+
body: JSON.stringify({
|
|
105
|
+
model: model,
|
|
106
|
+
max_tokens: 200,
|
|
107
|
+
system: AI_SYSTEM_PROMPT,
|
|
108
|
+
messages: [
|
|
109
|
+
{
|
|
110
|
+
role: 'user',
|
|
111
|
+
content: prompt,
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
}),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
const errorText = await response.text();
|
|
119
|
+
throw new Error(`Anthropic API error: ${response.status} ${errorText}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const data = await response.json();
|
|
123
|
+
return data.content[0].text;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/** Call OpenAI API */
|
|
127
|
+
const callOpenAIAPI = async (prompt, apiKey, model) => {
|
|
128
|
+
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers: {
|
|
131
|
+
'Content-Type': 'application/json',
|
|
132
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
133
|
+
},
|
|
134
|
+
body: JSON.stringify({
|
|
135
|
+
model: model,
|
|
136
|
+
max_tokens: 200,
|
|
137
|
+
messages: [
|
|
138
|
+
{
|
|
139
|
+
role: 'system',
|
|
140
|
+
content: AI_SYSTEM_PROMPT,
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
role: 'user',
|
|
144
|
+
content: prompt,
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
}),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
if (!response.ok) {
|
|
151
|
+
const errorText = await response.text();
|
|
152
|
+
throw new Error(`OpenAI API error: ${response.status} ${errorText}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const data = await response.json();
|
|
156
|
+
return data.choices[0].message.content;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/** Call Google Gemini API */
|
|
160
|
+
const callGeminiAPI = async (prompt, apiKey, model) => {
|
|
161
|
+
const response = await fetch(
|
|
162
|
+
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
|
|
163
|
+
{
|
|
164
|
+
method: 'POST',
|
|
165
|
+
headers: {
|
|
166
|
+
'Content-Type': 'application/json',
|
|
167
|
+
'x-goog-api-key': apiKey,
|
|
168
|
+
},
|
|
169
|
+
body: JSON.stringify({
|
|
170
|
+
system_instruction: { parts: [{ text: AI_SYSTEM_PROMPT }] },
|
|
171
|
+
contents: [{ parts: [{ text: prompt }] }],
|
|
172
|
+
}),
|
|
173
|
+
}
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
if (!response.ok) {
|
|
177
|
+
const errorText = await response.text();
|
|
178
|
+
throw new Error(`Gemini API error: ${response.status} ${errorText}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const data = await response.json();
|
|
182
|
+
return data.candidates[0].content.parts[0].text;
|
|
183
|
+
};
|
|
184
|
+
|
|
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
|
+
|
|
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
|
+
});
|
|
196
|
+
|
|
197
|
+
return hasValidType;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/** Generate commit title suggestions using AI */
|
|
201
|
+
const generateCommitSuggestions = async (config, testMode) => {
|
|
202
|
+
try {
|
|
203
|
+
const diffData = getOptimizedGitDiff();
|
|
204
|
+
if (!diffData) {
|
|
205
|
+
return [];
|
|
206
|
+
}
|
|
207
|
+
|
|
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
|
+
}
|
|
213
|
+
|
|
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
|
+
}
|
|
229
|
+
|
|
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
|
+
}
|
|
240
|
+
|
|
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');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const suggestions = JSON.parse(jsonMatch[0]);
|
|
250
|
+
|
|
251
|
+
if (!Array.isArray(suggestions) || suggestions.length !== 4) {
|
|
252
|
+
throw new Error('Invalid AI response format');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const validSuggestions = suggestions.filter(s => validateSuggestion(s, config));
|
|
256
|
+
|
|
257
|
+
if (validSuggestions.length < 4) {
|
|
258
|
+
utils.log('Some AI suggestions were invalid', 'warning');
|
|
259
|
+
return [];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return validSuggestions;
|
|
263
|
+
|
|
264
|
+
} catch (error) {
|
|
265
|
+
utils.log(`AI suggestion failed: ${error.message}`, 'warning');
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
export {
|
|
271
|
+
generateCommitSuggestions,
|
|
272
|
+
};
|