-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.js
More file actions
executable file
·722 lines (624 loc) · 22.5 KB
/
index.js
File metadata and controls
executable file
·722 lines (624 loc) · 22.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
#!/usr/bin/env node
const { Command } = require('commander');
const OpenAI = require('openai');
const simpleGit = require('simple-git');
const logger = require('./log');
const inquirer = require('inquirer').default;
const { query } = require('llm-querier');
const { gatherSmartContext, extractChangedFiles, filterLockFilesFromDiff } = require('./contextGatherer');
const ora = require('ora');
// Load API key from environment variables or a config file
const { loadCredentials, saveCredentials } = require('@edjl/config');
const credentials = loadCredentials('gitp');
// Cool waiting messages for LLM responses
const WAITING_MESSAGES = [
"🤖 AI is crafting the perfect commit message...",
"🧠 Neural networks are analyzing your genius code...",
"✨ Magic is happening in the cloud...",
"🚀 Loading AI superpowers...",
"🎯 Targeting the most descriptive words...",
"🔮 Consulting the commit message oracle...",
"⚡ Charging up the creativity circuits...",
"🎨 Painting your changes with words...",
"🧙♂️ Casting commit message spells...",
"🔥 Igniting the AI engines...",
"💎 Polishing your commit to perfection...",
"🌟 Stardust is settling into words...",
"🎪 The AI circus is performing for you...",
"🦄 Unicorns are writing your commit...",
"🎵 Composing a symphony of code changes...",
"🍕 AI is ordering pizza... wait, analyzing code...",
"🎲 Rolling the dice of perfect descriptions...",
"🏆 Competing for the best commit award...",
"🌈 Painting rainbows with your diffs...",
"🎭 Performing commit message theater...",
"🔬 Running scientific commit experiments...",
"🎪 Ladies and gentlemen, the amazing AI...",
"🌙 Nighttime coding deserves stellar commits...",
"☕ AI needs coffee too... processing...",
"🎨 Bob Ross would be proud of this commit...",
"🎯 Bullseye! Aiming for commit perfection...",
"🚁 Helicopter view of your code changes...",
"🎪 Step right up to the commit carnival...",
"🔍 CSI: Code Scene Investigation...",
"🎵 Your code changes are music to my circuits..."
];
function getRandomWaitingMessage() {
return WAITING_MESSAGES[Math.floor(Math.random() * WAITING_MESSAGES.length)];
}
// Interactive file selection function
async function interactiveFileSelection() {
const spinner = ora('📂 Getting file status...').start();
try {
const status = await git.status();
spinner.succeed('📋 Files ready for selection!');
// Prepare choices with status indicators
const choices = [];
// Add staged files (pre-checked)
status.staged.forEach((file) => {
choices.push({
name: `${file}`,
value: file,
checked: true
});
});
// Add unstaged files (not checked)
status.not_added.forEach((file) => {
choices.push({
name: `${file}`,
value: file,
checked: false
});
});
status.modified.forEach(file => {
// Only add if not already in staged
if (!status.staged.includes(file)) {
choices.push({
name: `${file}`,
value: file,
checked: false
});
}
});
if (choices.length === 0) {
logger('[yellow]⚠️ No files available for selection.');
return { selectedFiles: [], shouldContinue: false };
}
// Show interactive checkbox prompt
const answers = await inquirer.prompt([
{
type: 'checkbox',
name: 'selectedFiles',
message: '📁 Select files to stage (use ↑↓ to navigate, space to select, enter to confirm):',
choices: choices,
pageSize: 15,
loop: false
}
]);
return {
selectedFiles: answers.selectedFiles,
currentlyStaged: status.staged,
shouldContinue: true
};
} catch (error) {
spinner.fail('❌ Failed to get file status');
throw error;
}
}
// Handle file staging based on interactive selection
async function handleFileStaging(selectedFiles, currentlyStaged) {
const spinner = ora('📁 Updating file staging...').start();
try {
// Files to unstage (were staged but not selected)
const filesToUnstage = currentlyStaged.filter(file => !selectedFiles.includes(file));
// Files to stage (selected but not currently staged)
const filesToStage = selectedFiles.filter(file => !currentlyStaged.includes(file));
// Unstage files that were deselected
for (const file of filesToUnstage) {
await git.reset(['HEAD', file]);
spinner.text = `📤 Unstaged ${file}`;
}
// Stage selected files
for (const file of filesToStage) {
await git.add(file);
spinner.text = `📥 Staged ${file}`;
}
const stagedCount = selectedFiles.length;
const unstagedCount = filesToUnstage.length;
let message = '✨ File staging complete!';
if (stagedCount > 0 && unstagedCount > 0) {
message = `✨ Staged ${stagedCount} files, unstaged ${unstagedCount} files!`;
} else if (stagedCount > 0) {
message = `✨ Staged ${stagedCount} files!`;
} else if (unstagedCount > 0) {
message = `✨ Unstaged ${unstagedCount} files!`;
}
spinner.succeed(message);
return true;
} catch (error) {
spinner.fail('❌ Failed to update file staging');
throw error;
}
}
const git = simpleGit();
// Check if conventional commits should be used for current path
function shouldUseConventionalCommits() {
const currentPath = process.cwd();
const useConventionalCommitsIn = credentials.useConventionalCommitsIn || [];
return useConventionalCommitsIn.some((item) => {
try {
const regex = new RegExp(item.path);
return regex.test(currentPath);
} catch (error) {
// If regex is invalid, fall back to simple string matching
return currentPath.includes(item.path);
}
});
}
async function generateCommit(diff, branchName, history = [], smartMode = false) {
const provider = credentials.provider;
const model = credentials.model;
const apiKey = credentials.apiKey;
if (!provider) {
logger('[red]❌ No provider selected.');
return {};
}
if (!apiKey) {
logger('[red]❌ No API key provided.');
return {};
}
if (!model) {
logger('[red]❌ No model selected.');
return {};
}
let historyContext = '';
if (history.length > 0) {
historyContext = '\n\n# Previous attempts and feedback:\n';
history.forEach((attempt, index) => {
historyContext += `\nAttempt ${index + 1}:\n`;
historyContext += `Message: ${attempt.message}\n`;
historyContext += `Description: ${attempt.description}\n`;
historyContext += `User feedback: ${attempt.feedback}\n`;
});
}
// Filter out lock files from diff before sending to LLM
const filteredDiff = filterLockFilesFromDiff(diff);
// Prepare context array
const contextArray = [filteredDiff];
// Add branch name to context
if (branchName) {
contextArray.push(`Current branch: ${branchName}`);
}
// Add smart context if enabled
if (smartMode) {
const changedFiles = extractChangedFiles(diff);
const smartContext = await gatherSmartContext(diff, changedFiles);
if (smartContext) {
contextArray.push(smartContext);
}
}
// Check if conventional commits should be used
const useConventionalCommits = shouldUseConventionalCommits();
// Start the LLM spinner with a random message
const llmSpinner = ora(getRandomWaitingMessage()).start();
const result = await query({
model,
provider,
apiKey,
prompt: `
# Task
Generate a commit message and a commit description for code changes
# How to
Use the provided context to generate the commit message and description. In the context section you will find the diff of the changes${
smartMode
? ' and additional smart context about the affected components and their relationships'
: ''
}. If a branch name is provided, extract any ticket information from it to include in the commit message.
# Ticket Extraction from Branch Name
Look for ticket patterns in the branch name and extract them. Common patterns include:
- feature/TICKET-123 → TICKET-123
- feat/AB12-1234 → AB12-1234
- OP-1234 → OP-1234
- fix--TT-1234--finally → TT-1234
- hotfix/ASDF-1234--for-now-urgent → ASDF-1234
# CRITICAL Requirements
${
useConventionalCommits
? `- MUST use conventional commit format following the npm library @semantic-release standard
- You must decide which conventional commit type best applies to the changes
- For ticket formatting in conventional commits:
* If ticket exists: feat(TICKET-123): description here
* If no ticket but descriptive branch: feat(feature name): description here
* If ticket and scope: feat(scope, TICKET-123): description here
- Examples of conventional commits with tickets:
* feature/ASDF-1234 → feat(ASDF-1234): add user authentication
* feature/some-new-feature → feat(new feature): implement dashboard
* bugfix/TICKET-456 → fix(TICKET-456): resolve memory leak
- The commit message should be very short and straight to the point
- The commit description should provide meaningful technical context about WHY the changes were made, not just WHAT was changed
- AVOID stating obvious file changes like "updated package.json" or "modified index.js"
- Focus on the business logic, architectural decisions, or problem being solved
- Think about what a developer reading this commit in 6 months would need to know
${
smartMode
? '- Use the smart context provided to understand component relationships and architecture'
: ''
}`
: `- DO NOT use conventional commit prefixes like "feat:", "fix:", "chore:", "docs:", "style:", "refactor:", "test:", "build:", etc.
- Start the commit message directly with the action verb (e.g., "Add", "Update", "Fix", "Implement", "Refactor")
- For ticket formatting in non-conventional commits:
* If ticket exists: TICKET-123: description here
* If no ticket: description here
- Examples of non-conventional commits with tickets:
* feature/ASDF-1234 → ASDF-1234: Add user authentication
* feature/whatever → Add user authentication
* bugfix/TICKET-456 → TICKET-456: Fix memory leak
- Write as a SENIOR DEVELOPER would - professional, concise, and technically accurate
- The commit message should be very short and straight to the point (max 50 characters)
- The commit description should provide meaningful technical context about WHY the changes were made, not just WHAT was changed
- AVOID stating obvious file changes like "updated package.json" or "modified index.js"
- Focus on the business logic, architectural decisions, or problem being solved
- Think about what a developer reading this commit in 6 months would need to know
${
smartMode
? '- Use the smart context provided to understand component relationships and architecture'
: ''
}`
}
# Format
The output format should consist of two lines, prefixed with "COMMIT_MESSAGE" for the commit message and "COMMIT_DESCRIPTION" for the commit description, like this:
COMMIT_MESSAGE: <commit message here>
COMMIT_DESCRIPTION: <commit description here>${historyContext}
`,
context: contextArray,
examples: useConventionalCommits ? [
`
COMMIT_MESSAGE: feat(PERF-123): implement async request batching for API calls
COMMIT_DESCRIPTION: Reduces server load by combining multiple API requests into batched operations. This architectural change improves performance by 40% during peak usage and prevents rate limiting issues encountered in production.
`,
`
COMMIT_MESSAGE: fix(MEM-456): resolve memory leak in event listener cleanup
COMMIT_DESCRIPTION: Event listeners were not being properly removed on component unmount, causing memory consumption to grow over time. Implemented proper cleanup in lifecycle methods to ensure all listeners are detached when components are destroyed.
`
] : [
`
COMMIT_MESSAGE: PERF-123: Implement async request batching for API calls
COMMIT_DESCRIPTION: Reduces server load by combining multiple API requests into batched operations. This architectural change improves performance by 40% during peak usage and prevents rate limiting issues encountered in production.
`,
`
COMMIT_MESSAGE: MEM-456: Fix memory leak in event listener cleanup
COMMIT_DESCRIPTION: Event listeners were not being properly removed on component unmount, causing memory consumption to grow over time. Implemented proper cleanup in lifecycle methods to ensure all listeners are detached when components are destroyed.
`
]
});
llmSpinner.succeed('🎉 Perfect commit message generated!');
const commit = result.split('\n').map((line) => line.trim());
let commitMessage = '';
let commitDescription = '';
commit.forEach((line) => {
if (line.startsWith('COMMIT_MESSAGE:')) {
commitMessage = line.replace('COMMIT_MESSAGE:', '').trim();
} else if (line.startsWith('COMMIT_DESCRIPTION:')) {
commitDescription = line.replace('COMMIT_DESCRIPTION:', '').trim();
}
});
return { commitMessage, commitDescription };
}
async function commitCommand(cmd) {
let addExecuted = false;
let commitCompleted = false;
// Cleanup function to revert git add if needed
const cleanup = async () => {
if (addExecuted && !commitCompleted) {
try {
logger('[yellow]⚠️ Reverting git add due to cancellation...');
await git.reset(['HEAD']);
logger('[green]✅ Git add reverted successfully.');
} catch (error) {
logger('[red]❌ Failed to revert git add:', error.message);
}
}
};
// Set up signal handlers
const signalHandler = async (signal) => {
logger(`\n[yellow]⚠️ Received ${signal}, cleaning up...`);
await cleanup();
process.exit(0);
};
process.on('SIGINT', signalHandler);
process.on('SIGTERM', signalHandler);
try {
const isGitRepo = await git.checkIsRepo();
if (!isGitRepo) {
logger('[red]❌ This is not a Git repository.');
return;
}
if (cmd.add) {
if (cmd.interactive) {
// Interactive file selection mode
const selection = await interactiveFileSelection();
if (!selection.shouldContinue) {
logger('[yellow]⚠️ No files selected for staging.');
return;
}
if (selection.selectedFiles.length === 0) {
logger('[yellow]⚠️ No files selected for staging.');
return;
}
await handleFileStaging(selection.selectedFiles, selection.currentlyStaged);
addExecuted = true;
} else {
// Traditional add all mode
await git.add('.');
addExecuted = true;
}
}
const diff = await git.diff(['--cached']); // ['--cached']
const branch = await git
.revparse(['--abbrev-ref', 'HEAD'])
.catch(() => null);
if (!branch) {
logger('[red]❌ Unable to get current branch name.');
return;
}
if (!diff.length) {
logger(
'[red]❗ No changes to commit. If they are not staged, run [yellow]git add .[red] first or use the [yellow]--add[red] flag.'
);
return;
}
let userSatisfied = false;
let finalCommitMessage = '';
let finalCommitDescription = '';
let attemptCount = 0;
const history = [];
while (!userSatisfied && attemptCount < 10) {
const { commitMessage, commitDescription } = await generateCommit(
diff,
branch,
history,
cmd.smart
);
if (!commitMessage) return;
logger(`\n\n[green]Message: [white] ${commitMessage}`);
if (cmd.description) {
logger(`[green]Description: [white] ${commitDescription}`);
}
if (cmd.y) {
finalCommitMessage = commitMessage;
finalCommitDescription = commitDescription;
userSatisfied = true;
break;
}
let answers;
try {
answers = await inquirer.prompt([
{
type: 'input',
name: 'feedback',
message: 'Accept? (Press Enter to accept, or provide feedback for improvement):',
default: ''
}
]);
} catch (error) {
// User cancelled with Ctrl+C
if (error.isTtyError === false || error.name === 'ExitPromptError') {
logger('\n[yellow]⚠️ Operation cancelled by user.');
await cleanup();
return;
}
throw error;
}
if (answers.feedback === '') {
// User pressed Enter without feedback - accept the commit
finalCommitMessage = commitMessage;
finalCommitDescription = commitDescription;
userSatisfied = true;
} else {
// User provided feedback - add to history and regenerate
history.push({
message: commitMessage,
description: commitDescription,
feedback: answers.feedback
});
attemptCount++;
}
}
if (!userSatisfied) {
logger('[yellow]Proceeding with the last generated commit message.');
}
if (!finalCommitMessage) return;
if (cmd.dryRun) {
logger('[yellow]Dry-run enabled. Skipping commit and push.');
return;
}
let commitArgs = cmd.description
? `${finalCommitMessage}\n\n${finalCommitDescription}`
: finalCommitMessage;
if (cmd.verify) {
commitArgs += ' --no-verify';
}
await git.commit(commitArgs);
commitCompleted = true;
} catch (error) {
logger('❌ Error:', error.message);
await cleanup();
} finally {
// Clean up signal handlers
process.removeListener('SIGINT', signalHandler);
process.removeListener('SIGTERM', signalHandler);
}
}
async function addAliasToGitConfig() {
const { exec } = require('child_process');
return new Promise((resolve, reject) => {
exec('git config --global alias.c "!gitp commit $*"', (error) => {
if (error) return reject(error);
exec('git config --global alias.ca "!gitp commit --add $*"', (error) => {
if (error) return reject(error);
exec('git config --global alias.cs "!gitp commit --smart $*"', (error) => {
if (error) return reject(error);
exec('git config --global alias.cas "!gitp commit --add --smart $*"', (error) => {
if (error) return reject(error);
exec('git config --global alias.cai "!gitp commit --add --interactive $*"', (error) => {
if (error) return reject(error);
exec('git config --global alias.casi "!gitp commit --add --smart --interactive $*"', (error) => {
if (error) return reject(error);
resolve();
});
});
});
});
});
});
}).finally(() => {
logger('[green]✅ Aliases added to git config.');
logger(
'[yellow]You can now use:\n' +
'[green]git c[yellow] - commit\n' +
'[green]git ca[yellow] - commit with add\n' +
'[green]git cs[yellow] - commit with smart context\n' +
'[green]git cas[yellow] - commit with add and smart context\n' +
'[green]git cai[yellow] - commit with add and interactive selection\n' +
'[green]git casi[yellow] - commit with add, smart context, and interactive selection'
);
});
}
async function checkForUpdates() {
try {
const response = await fetch('https://registry.npmjs.org/git-gpt/latest');
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const latestVersion = await response.json();
return latestVersion.version;
} catch (error) {
logger(`[red]❌ Error fetching latest version: ${error.message}`);
}
}
async function main() {
const program = new Command();
// Check if there is a new version
const latestVersion = await checkForUpdates();
const currentVersion = require('./package.json').version;
if (latestVersion && latestVersion !== currentVersion) {
logger(
`[yellow]A new version of gitp is available. Please update to the latest version by running [green]npm install -g git-gpt[yellow].`
);
}
program
.command('commit')
.option('--dry-run', 'Perform OpenAI request without git operations', false)
.option('--no-verify', 'Skip git commit hooks', false)
.option('--add', 'Automatically add changes to the staging area', false)
.option('--interactive', 'Interactive file selection (use with --add)', false)
.option('-y', 'Skip user confirmation', false)
.option('--smart', 'Enable smart context analysis for better commit messages', false)
.option('--description', 'Include commit description along with message', false)
.action(commitCommand);
program
.command('set-api-key <key>')
.description('Set the API key')
.action((apiKey) => {
saveCredentials('gitp', { ...credentials, apiKey });
});
program
.command('set-default-ticket <ticket>')
.description('Set the default ticket when no ticket is found')
.action((ticket) => {
saveCredentials('gitp', { ...credentials, defaultTicket: ticket });
});
program
.command('set-default-ticket-for <path> <ticket>')
.description('Set the default ticket for a specific path')
.action((path, ticket) => {
const current = credentials.defaultTicketFor || [];
let final = [];
if (current.find((item) => item.path === path)) {
final = current.map((item) => {
if (item.path === path) {
return { path, ticket };
}
return item;
});
} else {
final = [...current, { path, ticket }];
}
saveCredentials('gitp', {
...credentials,
defaultTicketFor: final
});
});
program
.command('set-conventional-commits-for <path>')
.description('Add a path to use conventional commits format')
.action((path) => {
const current = credentials.useConventionalCommitsIn || [];
let final = [];
if (current.find((item) => item.path === path)) {
logger('[yellow]Path already configured for conventional commits.');
return;
} else {
final = [...current, { path }];
}
saveCredentials('gitp', {
...credentials,
useConventionalCommitsIn: final
});
logger('[green]✅ Path added to conventional commits configuration.');
});
program
.command('remove-conventional-commits-for <path>')
.description('Remove a path from conventional commits format')
.action((path) => {
const current = credentials.useConventionalCommitsIn || [];
const final = current.filter((item) => item.path !== path);
if (final.length === current.length) {
logger('[yellow]Path not found in conventional commits configuration.');
return;
}
saveCredentials('gitp', {
...credentials,
useConventionalCommitsIn: final
});
logger('[green]✅ Path removed from conventional commits configuration.');
});
program
.command('list-conventional-commits')
.description('List all paths configured for conventional commits')
.action(() => {
const current = credentials.useConventionalCommitsIn || [];
if (current.length === 0) {
logger('[yellow]No paths configured for conventional commits.');
return;
}
logger('[green]Paths configured for conventional commits:');
current.forEach((item) => {
logger(`[white] - ${item.path}`);
});
});
program
.command('set-provider <provider>')
.description('Set the provider')
.action((provider) => {
saveCredentials('gitp', {
...credentials,
provider
});
});
program
.command('set-model <model>')
.description('Set the model')
.action((model) => {
saveCredentials('gitp', {
...credentials,
model
});
});
program
.command('add-alias')
.description('Add aliases to the git config')
.action(addAliasToGitConfig);
program.parse(process.argv);
}
main();