format-commit 0.3.1 → 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 CHANGED
@@ -5,9 +5,9 @@
5
5
  [![npm downloads](https://img.shields.io/npm/dm/format-commit.svg)](https://www.npmjs.com/package/format-commit)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
7
 
8
- 🚀 Lightweight CLI for consistent commit message formatting.
8
+ 🚀 Lightweight CLI for consistent Git workflow & and optional AI support.
9
9
 
10
- Standardize your commit naming with basic rules, and guide your workflow through an automated script. No bloat, no complexity — just clean, consistent commits.
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
 
@@ -20,22 +20,29 @@ npm i format-commit --save-dev
20
20
  Add to your `package.json` scripts:
21
21
  ```json
22
22
  "scripts": {
23
- "commit": "format-commit"
23
+ "commit": "format-commit",
24
+ "branch": "format-commit --branch"
24
25
  }
25
26
  ```
26
27
 
27
28
  Then use:
28
29
  ```sh
29
- npm run commit
30
+ npm run commit # to commit
31
+ npm run branch # to create a branch
30
32
  ```
31
33
 
34
+ ### Global Installation
35
+
32
36
  Or install globally:
33
37
  ```sh
34
38
  npm i -g format-commit
35
39
  format-commit
40
+ format-commit --branch
36
41
  ```
37
42
 
38
- On first use, format-commit will prompt you to configure your commit format and create a `commit-config.json` file.
43
+ ### Initial Setup
44
+
45
+ On first use, format-commit will prompt you to configure your commit and branch formats, then create a `commit-config.json` file.
39
46
 
40
47
  To reconfigure later, run:
41
48
  ```sh
@@ -47,14 +54,45 @@ format-commit --config
47
54
  | Property | Description |
48
55
  | :------- | :---------- |
49
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` |
50
- | **types** | Allowed commit types (default: `feat`, `fix`, `core`, `test`, `config`, `doc`) |
51
- | **scopes** | Scopes for commit categorization (formats 5-8 only) |
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) |
52
60
  | **minLength** | Minimum length required for the commit title |
53
- | **maxLength** | Maximum length required for the commit title |
54
- | **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 |
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 |
55
63
  | **releaseBranch** | Main/release branch name (used if changeVersion = `only on release branch`) |
56
64
  | **showAllVersionTypes** | Show all version types or only main ones (`major`/`minor`/`patch`/`custom`) |
57
- | **stageAllChanges** | Auto-stage all changes before commit ⚠️ |
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
+
89
+ ## CLI Options
90
+
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 |
58
96
 
59
97
  ## Contributing
60
98
 
@@ -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
+ };