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/lib/commit.js CHANGED
@@ -1,25 +1,210 @@
1
- 'use strict';
1
+ import prompts from 'prompts';
2
+ import kleur from 'kleur';
3
+ import readline from 'readline';
4
+ import { readFileSync } from 'fs';
5
+ import { fileURLToPath } from 'url';
6
+ import { dirname, join } from 'path';
7
+ import * as utils from './utils.js';
2
8
 
3
- const prompts = require('prompts');
4
- const utils = require('./utils');
5
- const options = require('./options.json');
6
9
 
10
+ const { gray } = kleur;
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = dirname(__filename);
7
13
 
8
- module.exports = async (config, testMode) => {
14
+ const options = JSON.parse(
15
+ readFileSync(join(__dirname, 'options.json'), 'utf-8')
16
+ );
17
+
18
+
19
+ /** Parse and validate commit title format, auto-correct case */
20
+ const parseAndNormalizeCommitTitle = (title, config) => {
21
+ let type, scope, message, detectedFormatGroup;
22
+
23
+ // Try different format patterns
24
+ const format7_8 = /^([^(]+)\(([^)]+)\):\s*(.+)$/; // type(scope): message
25
+ const format5_6 = /^([^(]+)\(([^)]+)\)\s+(.+)$/; // type(scope) message
26
+ const format3_4 = /^([^:]+):\s*(.+)$/; // type: message
27
+ const format1_2 = /^\(([^)]+)\)\s+(.+)$/; // (type) message
28
+
29
+ let match;
30
+
31
+ if ((match = title.match(format7_8))) {
32
+ [, type, scope, message] = match;
33
+ detectedFormatGroup = 'type(scope):';
34
+ } else if ((match = title.match(format5_6))) {
35
+ [, type, scope, message] = match;
36
+ detectedFormatGroup = 'type(scope)';
37
+ } else if ((match = title.match(format3_4))) {
38
+ [, type, message] = match;
39
+ detectedFormatGroup = 'type:';
40
+ } else if ((match = title.match(format1_2))) {
41
+ [, type, message] = match;
42
+ detectedFormatGroup = '(type)';
43
+ } else {
44
+ return { error: 'Invalid commit format. Expected format with type prefix.' };
45
+ }
46
+
47
+ // Verify format matches config
48
+ let expectedFormatGroup;
49
+ if (config.format >= 7) {
50
+ expectedFormatGroup = 'type(scope):';
51
+ } else if (config.format >= 5) {
52
+ expectedFormatGroup = 'type(scope)';
53
+ } else if (config.format >= 3) {
54
+ expectedFormatGroup = 'type:';
55
+ } else {
56
+ expectedFormatGroup = '(type)';
57
+ }
58
+
59
+ if (detectedFormatGroup !== expectedFormatGroup) {
60
+ const exampleTitle = utils.formatCommitTitle(
61
+ config.types[0].value,
62
+ 'name',
63
+ config.format,
64
+ config.scopes?.[0]?.value
65
+ );
66
+ return { error: `Wrong format. Expected: "${exampleTitle}"` };
67
+ }
68
+
69
+ type = type.trim();
70
+ message = message.trim();
71
+ if (scope) {
72
+ scope = scope.trim();
73
+ }
74
+
75
+ // Validate type exists (case-insensitive)
76
+ const validType = config.types.find(t => t.value.toLowerCase() === type.toLowerCase());
77
+ if (!validType) {
78
+ const validTypes = config.types.map(t => t.value).join(', ');
79
+ return { error: `Invalid type "${type}". Valid types: ${validTypes}` };
80
+ }
81
+
82
+ // Validate scope if present (case-insensitive)
83
+ let validScope = scope;
84
+ if (scope) {
85
+ if (!config.scopes || config.scopes.length === 0) {
86
+ return { error: 'Scope not allowed in current format configuration' };
87
+ }
88
+ const foundScope = config.scopes.find(s => s.value.toLowerCase() === scope.toLowerCase());
89
+ if (!foundScope) {
90
+ const validScopes = config.scopes.map(s => s.value).join(', ');
91
+ return { error: `Invalid scope "${scope}". Valid scopes: ${validScopes}` };
92
+ }
93
+ validScope = foundScope.value;
94
+ }
95
+
96
+ // Re-format with correct case
97
+ const normalized = utils.formatCommitTitle(validType.value, message, config.format, validScope);
98
+
99
+ return { normalized };
100
+ };
101
+
102
+ /** Prompt with pre-filled text (editable) using readline */
103
+ const promptWithPrefill = (message, prefill, validate) => {
104
+ return new Promise((resolve, reject) => {
105
+ const askQuestion = (text) => {
106
+ const rl = readline.createInterface({
107
+ input: process.stdin,
108
+ output: process.stdout
109
+ });
110
+
111
+ const questionText = `${kleur.bold(message)}\n› `;
112
+
113
+ // Handle Ctrl+C to cancel
114
+ rl.on('SIGINT', () => {
115
+ rl.close();
116
+ console.log('');
117
+ reject(new Error('cancelled'));
118
+ });
119
+
120
+ rl.question(questionText, (answer) => {
121
+ rl.close();
122
+
123
+ const validation = validate ? validate(answer) : true;
124
+ if (validation !== true) {
125
+ utils.log(validation, 'error');
126
+ // Ask again with current answer as prefill
127
+ askQuestion(answer);
128
+ return;
129
+ }
130
+
131
+ resolve(answer);
132
+ });
133
+
134
+ rl.write(text);
135
+ };
136
+
137
+ askQuestion(prefill);
138
+ });
139
+ };
140
+
141
+
142
+ /** Execute commit, handle version bump, push and show status */
143
+ const finalizeCommit = async (title, description, commitData, currentBranch, testMode) => {
144
+ utils.log('commit changes...');
145
+
146
+ if (testMode) {
147
+ utils.log(title, 'warning');
148
+ return;
149
+ }
150
+
151
+ const commitRes = utils.handleCmdExec(`git commit -m "${title}" -m "${description}"`);
152
+ if (!commitRes) {
153
+ return;
154
+ }
155
+ utils.log('commit successfully completed', 'success');
156
+ console.log(commitRes);
157
+
158
+ let newVersion = null;
159
+ if (commitData.version === 'prerelease') {
160
+ const preRelease = await prompts([
161
+ {
162
+ type: 'text',
163
+ name: 'tag',
164
+ message: 'Pre-release tag?',
165
+ },
166
+ ]);
167
+ utils.log('update version...');
168
+ newVersion = utils.handleCmdExec(`npm version ${commitData.version} --preid=${preRelease.tag}`);
169
+ } else if (commitData.version) {
170
+ utils.log('update version...');
171
+ const version = commitData.customVersion ? commitData.customVersion : commitData.version;
172
+ newVersion = utils.handleCmdExec(`npm version ${version} --allow-same-version`);
173
+ }
174
+
175
+ if (newVersion) {
176
+ utils.log(`package updated to ${newVersion}`);
177
+ }
178
+
179
+ if (commitData.pushAfterCommit) {
180
+ utils.log('push changes...');
181
+ const gitPush = utils.handleCmdExec(`git push -u origin ${currentBranch}`);
182
+ console.log(gitPush);
183
+ }
184
+ const gitStatus = utils.handleCmdExec('git status');
185
+ console.log(gitStatus);
186
+ };
187
+
188
+
189
+ export default async function commit(config, testMode) {
9
190
  if (!config) {
10
191
  return;
11
192
  }
12
193
  utils.log('new commit');
13
194
 
195
+ if (!testMode && !utils.hasStagedChanges()) {
196
+ utils.log('No staged changes found - stage your changes with git add', 'error');
197
+ return;
198
+ }
199
+
14
200
  if (testMode) {
15
201
  utils.log('test mode enabled - commit will not be performed', 'warning');
16
202
  }
17
203
 
18
- /**
19
- * Get current git branch for version change option "only on release branch"
20
- */
204
+ // Get current git branch for version change option "only on release branch"
21
205
  const currentBranch = utils.getCurrentBranch();
22
206
  const askForVersion = utils.askForVersion(config, currentBranch);
207
+ const ignoreVersion = config.changeVersion === 'ignore';
23
208
 
24
209
  const noType = !config.types || (config.types && config.types.length === 0);
25
210
  if (noType) {
@@ -33,8 +218,134 @@ module.exports = async (config, testMode) => {
33
218
  return;
34
219
  }
35
220
 
221
+ let commitTitle = null;
222
+ let useAISuggestion = false;
223
+
224
+ // Try to generate AI suggestions if enabled
225
+ if (config.ai?.enabled) {
226
+ utils.log('generating suggestions...');
227
+ const aiService = await import('./ai-service.js');
228
+ const suggestions = await aiService.generateCommitSuggestions(config, testMode);
229
+
230
+ if (suggestions && suggestions.length === 4) {
231
+ let aiCancelled = false;
232
+ const aiChoice = await prompts({
233
+ type: 'select',
234
+ name: 'selectedTitle',
235
+ message: 'Start with a suggested title?',
236
+ choices: [
237
+ ...suggestions.map(s => ({ title: s, value: s })),
238
+ { title: gray('Custom (enter manually)'), value: '__custom__' },
239
+ ],
240
+ }, {
241
+ onCancel: () => {
242
+ aiCancelled = true;
243
+ return false;
244
+ },
245
+ });
246
+
247
+ if (aiCancelled) {
248
+ utils.log('commit cancelled', 'error');
249
+ return;
250
+ }
251
+
252
+ if (aiChoice.selectedTitle && aiChoice.selectedTitle !== '__custom__') {
253
+ try {
254
+ const rawTitle = await promptWithPrefill(
255
+ 'Edit commit title or continue:',
256
+ aiChoice.selectedTitle,
257
+ val => {
258
+ if (!val || val.trim().length === 0) {
259
+ return 'Commit title cannot be empty';
260
+ }
261
+
262
+ // Parse and validate format, type, and scope
263
+ const result = parseAndNormalizeCommitTitle(val, config);
264
+ if (result.error) {
265
+ return result.error;
266
+ }
267
+
268
+ // Validate length of normalized title
269
+ const lengthCheck = utils.validCommitTitle(result.normalized, config.minLength, config.maxLength);
270
+ if (lengthCheck !== true) {
271
+ return lengthCheck;
272
+ }
273
+
274
+ return true;
275
+ }
276
+ );
277
+
278
+ // Normalize the final title (correct case)
279
+ const result = parseAndNormalizeCommitTitle(rawTitle, config);
280
+ commitTitle = result.normalized;
281
+ useAISuggestion = true;
282
+ } catch (err) {
283
+ if (err.message === 'cancelled') {
284
+ utils.log('commit cancelled', 'error');
285
+ }
286
+ return;
287
+ }
288
+ }
289
+ } else {
290
+ utils.log('AI suggestions failed, using manual input', 'warning');
291
+ }
292
+ }
293
+
36
294
  let cancelled = false;
37
- const commit = await prompts([
295
+
296
+ // If AI suggestion was accepted, only ask for description, version, and push
297
+ if (useAISuggestion) {
298
+ const commit = await prompts([
299
+ {
300
+ type: 'text',
301
+ name: 'description',
302
+ message: 'Commit description?',
303
+ validate: val => val.length > 255 ? 'Commit description too long' : true,
304
+ },
305
+ {
306
+ type: ignoreVersion ? null : (askForVersion ? null : 'confirm'),
307
+ name: 'changeVersion',
308
+ message: 'Change package version?',
309
+ initial: false,
310
+ },
311
+ {
312
+ type: prev => ignoreVersion ? null : (askForVersion | prev ? 'select' : null),
313
+ name: 'version',
314
+ message: 'Type of version change',
315
+ choices: config.showAllVersionTypes
316
+ ? [...options.versionTypes, ...options.allVersionTypes]
317
+ : options.versionTypes,
318
+ },
319
+ {
320
+ type: prev => prev === 'custom' ? 'text' : null,
321
+ name: 'customVersion',
322
+ message: 'Version?',
323
+ validate: val => utils.validVersion(val),
324
+ },
325
+ {
326
+ type: 'confirm',
327
+ name: 'pushAfterCommit',
328
+ message: 'Push changes?',
329
+ initial: false,
330
+ },
331
+ ], {
332
+ onCancel: () => {
333
+ cancelled = true;
334
+ return false;
335
+ },
336
+ });
337
+
338
+ if (cancelled) {
339
+ utils.log('commit cancelled', 'error');
340
+ return;
341
+ }
342
+
343
+ await finalizeCommit(commitTitle, commit.description, commit, currentBranch, testMode);
344
+ return;
345
+ }
346
+
347
+ // Classic flow: ask for type and scope first
348
+ const typeScope = await prompts([
38
349
  {
39
350
  type: 'select',
40
351
  name: 'type',
@@ -47,11 +358,31 @@ module.exports = async (config, testMode) => {
47
358
  message: 'Scope',
48
359
  choices: config.scopes,
49
360
  },
361
+ ], {
362
+ onCancel: () => {
363
+ cancelled = true;
364
+ return false;
365
+ },
366
+ });
367
+
368
+ if (cancelled) {
369
+ utils.log('commit cancelled', 'error');
370
+ return;
371
+ }
372
+
373
+ // Ask for title with full formatted length validation, then description, version and push
374
+ const commit = await prompts([
50
375
  {
51
376
  type: 'text',
52
377
  name: 'title',
53
378
  message: 'Commit title?',
54
- validate: val => utils.validCommitTitle(val, config.minLength, config.maxLength),
379
+ validate: val => {
380
+ if (!val || val.trim().length === 0) {
381
+ return 'Commit title cannot be empty';
382
+ }
383
+ const fullTitle = utils.formatCommitTitle(typeScope.type, val, config.format, typeScope.scope);
384
+ return utils.validCommitTitle(fullTitle, config.minLength, config.maxLength);
385
+ },
55
386
  },
56
387
  {
57
388
  type: 'text',
@@ -60,18 +391,16 @@ module.exports = async (config, testMode) => {
60
391
  validate: val => val.length > 255 ? 'Commit description too long' : true,
61
392
  },
62
393
  {
63
- type: askForVersion ? null : 'confirm',
394
+ type: ignoreVersion ? null : (askForVersion ? null : 'confirm'),
64
395
  name: 'changeVersion',
65
396
  message: 'Change package version?',
66
397
  initial: false,
67
398
  },
68
399
  {
69
- type: prev => askForVersion | prev ? 'select' : null,
400
+ type: prev => ignoreVersion ? null : (askForVersion | prev ? 'select' : null),
70
401
  name: 'version',
71
402
  message: 'Type of version change',
72
- /**
73
- * Display only some npm version options or all depending on config
74
- */
403
+ // Display only some npm version options or all depending on config
75
404
  choices: config.showAllVersionTypes
76
405
  ? [...options.versionTypes, ...options.allVersionTypes]
77
406
  : options.versionTypes,
@@ -95,73 +424,18 @@ module.exports = async (config, testMode) => {
95
424
  },
96
425
  });
97
426
 
98
- /**
99
- * Handle prompt cancellation and stop commit execution
100
- */
101
427
  if (cancelled) {
102
428
  utils.log('commit cancelled', 'error');
103
429
  return;
104
430
  }
105
431
 
106
- /**
107
- * Format changes message and commit it
108
- */
109
- utils.log('commit changes...');
110
- const commitTitle = utils.formatCommitTitle(
111
- commit.type,
432
+ // Format changes message and commit it
433
+ commitTitle = utils.formatCommitTitle(
434
+ typeScope.type,
112
435
  commit.title,
113
436
  config.format,
114
- commit.scope
437
+ typeScope.scope
115
438
  );
116
439
 
117
- if (testMode) {
118
- utils.log(commitTitle, 'warning');
119
- return;
120
- }
121
-
122
- const commitRes = utils.handleCmdExec(`git commit -m "${commitTitle}" -m "${commit.description}"`);
123
- if (!commitRes) {
124
- return;
125
- }
126
- utils.log('commit successfully completed', 'success');
127
- console.log(commitRes);
128
-
129
- let newVersion = null;
130
- if (commit.version === 'prerelease') {
131
- /**
132
- * Ask tag if new version is a prerelease and update it
133
- */
134
- const preRelease = await prompts([
135
- {
136
- type: 'text',
137
- name: 'tag',
138
- message: 'Pre-release tag?',
139
- },
140
- ]);
141
- utils.log('update version...');
142
- newVersion = utils.handleCmdExec(`npm version ${commit.version} --preid=${preRelease.tag}`);
143
-
144
- } else if (commit.version) {
145
- /**
146
- * Ask version if custom option selected and update it
147
- */
148
- utils.log('update version...');
149
- const version = commit.customVersion ? commit.customVersion : commit.version;
150
- newVersion = utils.handleCmdExec(`npm version ${version} --allow-same-version`);
151
- }
152
-
153
- if (newVersion) {
154
- utils.log(`package updated to ${newVersion}`);
155
- }
156
-
157
- /**
158
- * Push commit if option selection
159
- */
160
- if (commit.pushAfterCommit) {
161
- utils.log('push changes...');
162
- const gitPush = utils.handleCmdExec(`git push -u origin ${currentBranch}`);
163
- console.log(gitPush);
164
- }
165
- const gitStatus = utils.handleCmdExec('git status');
166
- console.log(gitStatus);
167
- };
440
+ await finalizeCommit(commitTitle, commit.description, commit, currentBranch, testMode);
441
+ }
@@ -1,10 +1,8 @@
1
- 'use strict';
1
+ import prompts from 'prompts';
2
+ import * as utils from './utils.js';
2
3
 
3
- const prompts = require('prompts');
4
- const utils = require('./utils');
5
4
 
6
-
7
- module.exports = async (config, testMode) => {
5
+ export default async function createBranch(config, testMode) {
8
6
  if (!config) {
9
7
  return;
10
8
  }
@@ -14,9 +12,6 @@ module.exports = async (config, testMode) => {
14
12
  utils.log('test mode enabled - branch will not be created', 'warning');
15
13
  }
16
14
 
17
- /**
18
- * Check if branchFormat is configured
19
- */
20
15
  if (!config.branchFormat) {
21
16
  utils.log('no branch format defined - please update config', 'error');
22
17
  return;
@@ -67,17 +62,12 @@ module.exports = async (config, testMode) => {
67
62
  },
68
63
  });
69
64
 
70
- /**
71
- * Handle prompt cancellation and stop branch creation
72
- */
73
65
  if (cancelled) {
74
66
  utils.log('branch creation cancelled', 'error');
75
67
  return;
76
68
  }
77
69
 
78
- /**
79
- * Format branch name and create it
80
- */
70
+ // Format branch name and create it
81
71
  utils.log('create branch...');
82
72
  const branchName = utils.formatBranchName(
83
73
  branch.type,
@@ -91,18 +81,12 @@ module.exports = async (config, testMode) => {
91
81
  return;
92
82
  }
93
83
 
94
- /**
95
- * Check if branch already exists
96
- */
97
84
  const branchExists = utils.checkBranchExists(branchName);
98
85
  if (branchExists) {
99
86
  utils.log(`branch "${branchName}" already exists`, 'error');
100
87
  return;
101
88
  }
102
89
 
103
- /**
104
- * Create the branch
105
- */
106
90
  const createCommand = branch.checkoutAfterCreate
107
91
  ? `git checkout -b ${branchName}`
108
92
  : `git branch ${branchName}`;
@@ -119,9 +103,6 @@ module.exports = async (config, testMode) => {
119
103
  utils.log(successMessage, 'success');
120
104
  console.log(createRes);
121
105
 
122
- /**
123
- * Show current git status
124
- */
125
106
  const gitStatus = utils.handleCmdExec('git status');
126
107
  console.log(gitStatus);
127
- };
108
+ }
@@ -3,7 +3,7 @@
3
3
  "branchFormat": 1,
4
4
  "minLength": 8,
5
5
  "maxLength": 80,
6
- "changeVersion": "never",
6
+ "changeVersion": "ignore",
7
7
  "releaseBranch": "main",
8
8
  "showAllVersionTypes": false,
9
9
  "types": [
@@ -16,5 +16,10 @@
16
16
  ],
17
17
  "scopes": [
18
18
  { "value": "example", "description": "Your scope's description" }
19
- ]
19
+ ],
20
+ "ai": {
21
+ "enabled": false,
22
+ "envPath": ".env",
23
+ "largeDiffTokenThreshold": 20000
24
+ }
20
25
  }
@@ -0,0 +1,109 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+
5
+ /** Check if a file path is in .gitignore */
6
+ const isInGitignore = (filePath) => {
7
+ try {
8
+ const gitignorePath = path.join(process.cwd(), '.gitignore');
9
+ if (!fs.existsSync(gitignorePath)) {
10
+ return false;
11
+ }
12
+ const gitignoreContent = fs.readFileSync(gitignorePath, 'utf-8');
13
+ const lines = gitignoreContent.split('\n').map(l => l.trim());
14
+
15
+ const normalizedPath = filePath.startsWith('./') ? filePath.slice(2) : filePath;
16
+
17
+ return lines.some(line => {
18
+ if (!line || line.startsWith('#')) {return false;}
19
+ return line === normalizedPath || line === `/${normalizedPath}`;
20
+ });
21
+ } catch {
22
+ return false;
23
+ }
24
+ };
25
+
26
+ /** Add a file to .gitignore */
27
+ const addToGitignore = (filePath) => {
28
+ try {
29
+ const gitignorePath = path.join(process.cwd(), '.gitignore');
30
+ const normalizedPath = filePath.startsWith('./') ? filePath.slice(2) : filePath;
31
+
32
+ let content = '';
33
+ if (fs.existsSync(gitignorePath)) {
34
+ content = fs.readFileSync(gitignorePath, 'utf-8');
35
+ if (!content.endsWith('\n')) {
36
+ content += '\n';
37
+ }
38
+ }
39
+
40
+ content += `${normalizedPath}\n`;
41
+ fs.writeFileSync(gitignorePath, content);
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ };
47
+
48
+ /** Check if a key exists in .env file */
49
+ const keyExistsInEnv = (envPath, keyName) => {
50
+ try {
51
+ if (!fs.existsSync(envPath)) {
52
+ return false;
53
+ }
54
+ const content = fs.readFileSync(envPath, 'utf-8');
55
+ const regex = new RegExp(`^${keyName}=`, 'm');
56
+ return regex.test(content);
57
+ } catch {
58
+ return false;
59
+ }
60
+ };
61
+
62
+ /** Add or update a key in .env file */
63
+ const setEnvKey = (envPath, keyName, value) => {
64
+ try {
65
+ let content = '';
66
+
67
+ if (fs.existsSync(envPath)) {
68
+ content = fs.readFileSync(envPath, 'utf-8');
69
+ const regex = new RegExp(`^${keyName}=.*$`, 'm');
70
+
71
+ if (regex.test(content)) {
72
+ content = content.replace(regex, `${keyName}=${value}`);
73
+ } else {
74
+ if (!content.endsWith('\n')) {content += '\n';}
75
+ content += `${keyName}=${value}\n`;
76
+ }
77
+ } else {
78
+ content = `${keyName}=${value}\n`;
79
+ }
80
+
81
+ fs.writeFileSync(envPath, content);
82
+ return true;
83
+ } catch {
84
+ return false;
85
+ }
86
+ };
87
+
88
+ /** Read a key from .env file */
89
+ const getEnvKey = (envPath, keyName) => {
90
+ try {
91
+ if (!fs.existsSync(envPath)) {
92
+ return null;
93
+ }
94
+ const content = fs.readFileSync(envPath, 'utf-8');
95
+ const regex = new RegExp(`^${keyName}=(.*)$`, 'm');
96
+ const match = content.match(regex);
97
+ return match ? match[1].trim() : null;
98
+ } catch {
99
+ return null;
100
+ }
101
+ };
102
+
103
+ export {
104
+ isInGitignore,
105
+ addToGitignore,
106
+ keyExistsInEnv,
107
+ setEnvKey,
108
+ getEnvKey,
109
+ };