@freetison/git-super 0.2.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.
@@ -0,0 +1,576 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @theia-core/git-super - AI-powered git commits with customizable templates
4
+ *
5
+ * Automates: git add . → AI-generated commit → git push
6
+ * Works with ANY git repository (Node.js, Python, Java, C++, etc.)
7
+ *
8
+ * Usage:
9
+ * git super # add + commit + push
10
+ * git super --no-push # add + commit only (no push)
11
+ * git super --dry-run # preview message without committing
12
+ * git super --amend # amend last commit with new AI message
13
+ * git super --no-verify # skip pre-commit hooks (not recommended)
14
+ * git super --init # create config file with defaults
15
+ *
16
+ * Installation:
17
+ * npm install -g @theia-core/git-super
18
+ * git config --global alias.super '!git-super'
19
+ * git super --init # Optional: customize config
20
+ *
21
+ * Configuration:
22
+ * Edit ~/.gitsuperrc to customize:
23
+ * - Message templates (Jira, Linear, GitHub issues)
24
+ * - Commit types and max length
25
+ * - AI provider defaults
26
+ *
27
+ * Environment Variables:
28
+ * AI_PROVIDER - AI provider: 'ollama' (default), 'anthropic', 'openai'
29
+ * AI_MODEL - Model to use (default: 'mistral:latest' for Ollama)
30
+ * OLLAMA_URL - Ollama API URL (default: http://localhost:11434)
31
+ */
32
+
33
+ import { execSync } from 'node:child_process';
34
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
35
+ import { basename, join } from 'node:path';
36
+ import { homedir } from 'node:os';
37
+ import { fileURLToPath } from 'node:url';
38
+ import { dirname } from 'node:path';
39
+
40
+ // Import refactored modules
41
+ const __filename = fileURLToPath(import.meta.url);
42
+ const __dirname = dirname(__filename);
43
+
44
+ import { loadConfig } from '../lib/config/config-loader.mjs';
45
+ import { ProviderRegistry } from '../lib/providers/provider-registry.mjs';
46
+ import { FallbackResolver } from '../lib/fallback/fallback-resolver.mjs';
47
+ import { handleAuthLogin, handleAuthLogout, handleAuthStatus, handleContext } from '../lib/cli/auth-commands.mjs';
48
+
49
+ // ============================================================================
50
+ // CONFIGURATION
51
+ // ============================================================================
52
+
53
+ const CONFIG = loadConfig();
54
+ const providerRegistry = new ProviderRegistry(CONFIG);
55
+ const fallbackResolver = new FallbackResolver();
56
+
57
+ // Parse command line args
58
+ const args = process.argv.slice(2);
59
+
60
+ // Check for auth/context commands first (before flags parsing)
61
+ const command = args[0];
62
+ if (command === 'auth') {
63
+ const subcommand = args[1];
64
+ const parsedArgs = parseArgs(args.slice(2));
65
+ parsedArgs._ = [command, subcommand];
66
+
67
+ (async () => {
68
+ switch (subcommand) {
69
+ case 'login':
70
+ await handleAuthLogin(parsedArgs);
71
+ break;
72
+ case 'logout':
73
+ await handleAuthLogout(parsedArgs);
74
+ break;
75
+ case 'status':
76
+ await handleAuthStatus();
77
+ break;
78
+ default:
79
+ log('Usage: git super auth <login|logout|status>', 'yellow');
80
+ log('\nCommands:', 'bright');
81
+ log(' login --provider <name> [--org <id>] Authenticate with OAuth provider', 'cyan');
82
+ log(' logout [--provider <name>] [--all] Log out from provider(s)', 'cyan');
83
+ log(' status Show authentication status', 'cyan');
84
+ }
85
+ process.exit(0);
86
+ })();
87
+ } else if (command === 'context' || command === 'ctx') {
88
+ const parsedArgs = parseArgs(args.slice(1));
89
+ parsedArgs._ = [command, ...parsedArgs._];
90
+
91
+ handleContext(parsedArgs);
92
+ process.exit(0);
93
+ }
94
+
95
+ // Traditional flags for normal git super flow
96
+ const flags = {
97
+ dryRun: args.includes('--dry-run'),
98
+ noPush: args.includes('--no-push'),
99
+ amend: args.includes('--amend'),
100
+ noVerify: args.includes('--no-verify'),
101
+ init: args.includes('--init'),
102
+ help: args.includes('--help') || args.includes('-h'),
103
+ };
104
+
105
+ // Simple args parser for auth commands
106
+ function parseArgs(argv) {
107
+ const parsed = { _: [] };
108
+ for (let i = 0; i < argv.length; i++) {
109
+ const arg = argv[i];
110
+ if (arg.startsWith('--')) {
111
+ const key = arg.slice(2);
112
+ const next = argv[i + 1];
113
+ if (next && !next.startsWith('--')) {
114
+ parsed[key] = next;
115
+ i++;
116
+ } else {
117
+ parsed[key] = true;
118
+ }
119
+ } else if (arg.startsWith('-') && arg.length === 2) {
120
+ const key = arg.slice(1);
121
+ const next = argv[i + 1];
122
+ if (next && !next.startsWith('-')) {
123
+ parsed[key] = next;
124
+ i++;
125
+ } else {
126
+ parsed[key] = true;
127
+ }
128
+ } else {
129
+ parsed._.push(arg);
130
+ }
131
+ }
132
+ return parsed;
133
+ }
134
+
135
+ // ============================================================================
136
+ // UTILITIES
137
+ // ============================================================================
138
+
139
+ const colors = {
140
+ reset: '\x1b[0m',
141
+ bright: '\x1b[1m',
142
+ red: '\x1b[31m',
143
+ green: '\x1b[32m',
144
+ yellow: '\x1b[33m',
145
+ blue: '\x1b[34m',
146
+ magenta: '\x1b[35m',
147
+ cyan: '\x1b[36m',
148
+ };
149
+
150
+ function log(message, color = 'reset') {
151
+ console.log(`${colors[color]}${message}${colors.reset}`);
152
+ }
153
+
154
+ function exec(command, options = {}) {
155
+ try {
156
+ return execSync(command, {
157
+ encoding: 'utf-8',
158
+ stdio: options.silent ? 'pipe' : 'inherit',
159
+ maxBuffer: 10 * 1024 * 1024,
160
+ ...options,
161
+ });
162
+ } catch (error) {
163
+ if (options.silent) {
164
+ return null;
165
+ }
166
+ throw error;
167
+ }
168
+ }
169
+
170
+ function getRepoName() {
171
+ try {
172
+ const remote = exec('git config --get remote.origin.url', { silent: true });
173
+ if (remote) {
174
+ return basename(remote.trim().replace(/\.git$/, ''));
175
+ }
176
+ } catch {}
177
+
178
+ return basename(process.cwd());
179
+ }
180
+
181
+ function hasChanges() {
182
+ const status = exec('git status --porcelain', { silent: true });
183
+ return status && status.trim().length > 0;
184
+ }
185
+
186
+ function getGitDiff() {
187
+ try {
188
+ // Get staged changes
189
+ const staged = exec('git diff --cached', { silent: true }) || '';
190
+
191
+ // Get unstaged changes
192
+ const unstaged = exec('git diff', { silent: true }) || '';
193
+
194
+ // Get status
195
+ const status = exec('git status --short', { silent: true }) || '';
196
+
197
+ return {
198
+ diff: staged || unstaged,
199
+ status,
200
+ hasStaged: staged.length > 0,
201
+ hasUnstaged: unstaged.length > 0,
202
+ };
203
+ } catch (error) {
204
+ return { diff: '', status: '', hasStaged: false, hasUnstaged: false };
205
+ }
206
+ }
207
+
208
+ // ============================================================================
209
+ // TEMPLATE HELPERS
210
+ // ============================================================================
211
+
212
+ function extractType(msg) {
213
+ const match = msg.match(/^(\w+)(?:\(|:)/);
214
+ return match ? match[1] : 'chore';
215
+ }
216
+
217
+ function extractScope(msg) {
218
+ const match = msg.match(/\(([^)]+)\)/);
219
+ return match ? match[1] : '';
220
+ }
221
+
222
+ function applyTemplate(message, template) {
223
+ if (!template) return message;
224
+
225
+ const type = extractType(message);
226
+ const scope = extractScope(message);
227
+
228
+ // Extract just the description part
229
+ let description = message;
230
+ const typeScorMatch = message.match(/^[^:]+:\s*(.+)$/);
231
+ if (typeScorMatch) {
232
+ description = typeScorMatch[1];
233
+ }
234
+
235
+ return template
236
+ .replace('{message}', description)
237
+ .replace('{type}', type)
238
+ .replace('{scope}', scope)
239
+ .replace('{ticket}', CONFIG.ticketNumber || '');
240
+ }
241
+
242
+ // ============================================================================
243
+ // AI PROVIDERS
244
+ // ============================================================================
245
+
246
+ async function generateCommitMessage(diff, status, repoName) {
247
+ // Extract just filenames for context
248
+ const files = status.split('\n')
249
+ .filter(l => l.trim())
250
+ .map(l => l.substring(3).trim())
251
+ .slice(0, 10);
252
+
253
+ const hasTemplate = CONFIG.messageTemplate && CONFIG.messageTemplate.includes('{message}');
254
+
255
+ const prompt = `Generate a git commit message following Conventional Commits format.
256
+
257
+ Files changed:
258
+ ${files.join('\n')}
259
+
260
+ Diff (first 6000 chars):
261
+ ${diff.substring(0, 6000)}
262
+
263
+ Rules:
264
+ - Format: ${hasTemplate ? 'type(scope): description (will be inserted in template)' : 'type(scope): description'}
265
+ - Types: ${CONFIG.commitRules.types.join(', ')}
266
+ - Max ${CONFIG.commitRules.maxLength} characters
267
+ - Focus on WHAT changed, not HOW
268
+ - Be specific but concise
269
+ - NO quotes, NO explanations, NO extra text
270
+
271
+ Output ONLY the commit message:`;
272
+
273
+ log('🤖 Generating AI message...', 'cyan');
274
+
275
+ try {
276
+ // Use Strategy Pattern - get provider from registry
277
+ const provider = providerRegistry.get(CONFIG.aiProvider);
278
+ let message = await provider.generate(prompt);
279
+
280
+ // Apply template if configured
281
+ if (CONFIG.messageTemplate) {
282
+ message = applyTemplate(message, CONFIG.messageTemplate);
283
+ }
284
+
285
+ // Validate length
286
+ if (message.length > CONFIG.commitRules.maxLength) {
287
+ log(`⚠️ Message too long (${message.length}>${CONFIG.commitRules.maxLength}), truncating`, 'yellow');
288
+ message = message.substring(0, CONFIG.commitRules.maxLength);
289
+ }
290
+
291
+ return message;
292
+
293
+ } catch (error) {
294
+ log(`⚠️ Error generating message: ${error.message}`, 'yellow');
295
+
296
+ // Use Strategy Pattern for fallback selection
297
+ const lines = status.split('\n').filter(l => l.trim());
298
+ const stats = {
299
+ added: lines.filter(l => l.startsWith('A ')).length,
300
+ modified: lines.filter(l => l.startsWith('M ')).length,
301
+ deleted: lines.filter(l => l.startsWith('D ')).length,
302
+ };
303
+
304
+ let fallback = fallbackResolver.resolve(stats);
305
+
306
+ // Apply template to fallback too
307
+ if (CONFIG.messageTemplate) {
308
+ fallback = applyTemplate(fallback, CONFIG.messageTemplate);
309
+ }
310
+
311
+ log(`Using fallback message: "${fallback}"`, 'yellow');
312
+ return fallback;
313
+ }
314
+ }
315
+
316
+ // ============================================================================
317
+ // AI Provider implementations moved to lib/providers/
318
+ // - OllamaProvider: lib/providers/ollama-provider.mjs
319
+ // - AnthropicProvider: lib/providers/anthropic-provider.mjs
320
+ // - OpenAIProvider: lib/providers/openai-provider.mjs
321
+ // ============================================================================
322
+
323
+ // ============================================================================
324
+ // INIT CONFIG
325
+ // ============================================================================
326
+
327
+ function initConfig() {
328
+ const configPath = join(homedir(), '.gitsuperrc');
329
+
330
+ if (existsSync(configPath)) {
331
+ log('\n⚠️ Config already exists:', 'yellow');
332
+ log(` ${configPath}\n`, 'bright');
333
+
334
+ try {
335
+ const current = JSON.parse(readFileSync(configPath, 'utf-8'));
336
+ log(JSON.stringify(current, null, 2), 'cyan');
337
+ } catch (error) {
338
+ log(`Error reading config: ${error.message}`, 'red');
339
+ }
340
+
341
+ log('\n💡 Edit manually or delete to recreate\n', 'yellow');
342
+ return;
343
+ }
344
+
345
+ const template = {
346
+ "$comment": "git-super configuration - Edit this file to customize behavior",
347
+ "aiProvider": "ollama",
348
+ "aiModel": "mistral:latest",
349
+ "ollamaUrl": "http://localhost:11434",
350
+ "messageTemplate": null,
351
+ "ticketNumber": "",
352
+ "commitRules": {
353
+ "types": ["feat", "fix", "docs", "style", "refactor", "test", "chore", "perf", "ci", "build"],
354
+ "maxLength": 72,
355
+ "allowEmptyScope": true
356
+ },
357
+ "_examples": {
358
+ "$comment": "Copy one of these to 'messageTemplate' above",
359
+ "jira": "VTT-3020: {type}({scope}): {message}",
360
+ "linear": "LIN-{ticket}: {type}({scope}): {message}",
361
+ "github": "#{ticket}: {type}({scope}): {message}",
362
+ "simple": "{type}: {message}",
363
+ "default": null
364
+ }
365
+ };
366
+
367
+ log('\n📝 Creating config at:', 'cyan');
368
+ log(` ${configPath}\n`, 'bright');
369
+
370
+ try {
371
+ writeFileSync(configPath, JSON.stringify(template, null, 2));
372
+
373
+ log('✅ Config file created!\n', 'green');
374
+ log('Edit to customize:', 'cyan');
375
+ log(' • messageTemplate - Add Jira/Linear/GitHub prefixes', 'blue');
376
+ log(' • commitRules - Customize types and max length', 'blue');
377
+ log(' • aiProvider - Change AI provider (ollama/anthropic/openai)', 'blue');
378
+ log('\nExample template:', 'bright');
379
+ log(' "messageTemplate": "VTT-3020: {type}({scope}): {message}"', 'cyan');
380
+ log('\nVariables:', 'bright');
381
+ log(' {message} - AI-generated description', 'blue');
382
+ log(' {type} - Commit type (feat, fix, etc.)', 'blue');
383
+ log(' {scope} - Commit scope (if any)', 'blue');
384
+ log(' {ticket} - Value from ticketNumber config', 'blue');
385
+ log(`\n📄 File: ${configPath}\n`, 'magenta');
386
+ } catch (error) {
387
+ log(`\n❌ Error creating config: ${error.message}`, 'red');
388
+ process.exit(1);
389
+ }
390
+ }
391
+
392
+ // ============================================================================
393
+ // MAIN LOGIC
394
+ // ============================================================================
395
+
396
+ function showHelp() {
397
+ log('\n╔════════════════════════════════════════════════════╗', 'cyan');
398
+ log('║ git-super - AI-Powered Git Commits ║', 'cyan');
399
+ log('╚════════════════════════════════════════════════════╝\n', 'cyan');
400
+
401
+ log('Usage:', 'bright');
402
+ log(' git super # Stage, commit with AI message, push', 'blue');
403
+ log(' git super --init # Create config file with defaults', 'blue');
404
+ log(' git super --dry-run # Preview AI message without committing', 'blue');
405
+ log(' git super --no-push # Commit but don\'t push', 'blue');
406
+ log(' git super --amend # Amend last commit with new AI message', 'blue');
407
+ log(' git super --no-verify # Skip pre-commit hooks', 'blue');
408
+ log(' git super --help # Show this help\n', 'blue');
409
+
410
+ log('Authentication (OAuth/SSO):', 'bright');
411
+ log(' git super auth login --provider <name> # Authenticate with OAuth', 'blue');
412
+ log(' git super auth status # Show auth status', 'blue');
413
+ log(' git super auth logout [--provider name] # Log out', 'blue');
414
+ log('', '');
415
+ log(' OAuth Providers:', 'cyan');
416
+ log(' • github-copilot GitHub Copilot Enterprise', 'blue');
417
+ log(' • azure-openai Azure OpenAI with Azure AD', 'blue');
418
+ log(' • generic-oidc Generic OIDC provider\n', 'blue');
419
+
420
+ log('Multi-Organization Context:', 'bright');
421
+ log(' git super context # Show current context', 'blue');
422
+ log(' git super context list # List all contexts', 'blue');
423
+ log(' git super context switch <id> # Switch to context\n', 'blue');
424
+
425
+ log('Configuration:', 'bright');
426
+ log(' Edit ~/.gitsuperrc to customize:', 'blue');
427
+ log(' • Message templates (Jira, Linear, GitHub issues)', 'blue');
428
+ log(' • Commit types and max length', 'blue');
429
+ log(' • AI provider defaults', 'blue');
430
+ log(' • Multiple organization contexts (enterprise)\n', 'blue');
431
+
432
+ log('Environment Variables:', 'bright');
433
+ log(' API Key Providers:', 'cyan');
434
+ log(' AI_PROVIDER - ollama, anthropic, openai', 'blue');
435
+ log(' AI_MODEL - model name/version', 'blue');
436
+ log(' OLLAMA_URL - http://localhost:11434 (default)', 'blue');
437
+ log(' ANTHROPIC_API_KEY - Anthropic API key', 'blue');
438
+ log(' OPENAI_API_KEY - OpenAI API key', 'blue');
439
+ log('', '');
440
+ log(' OAuth/Enterprise:', 'cyan');
441
+ log(' GITHUB_ORG - GitHub organization', 'blue');
442
+ log(' AZURE_TENANT_ID - Azure AD tenant ID', 'blue');
443
+ log(' AZURE_CLIENT_ID - Azure application client ID', 'blue');
444
+ log(' AZURE_OPENAI_ENDPOINT - Azure OpenAI resource endpoint', 'blue');
445
+ log(' OIDC_ISSUER - OIDC issuer URL', 'blue');
446
+ log(' OIDC_CLIENT_ID - OIDC client ID\n', 'blue');
447
+
448
+ log('Examples:', 'bright');
449
+ log(' # Basic usage (Ollama local)', 'blue');
450
+ log(' git super\n', 'green');
451
+
452
+ log(' # Authenticate with Azure OpenAI (enterprise)', 'blue');
453
+ log(' git super auth login --provider azure-openai', 'green');
454
+ log(' git super # Now uses Azure OpenAI with SSO\n', 'green');
455
+
456
+ log(' # Setup custom templates', 'blue');
457
+ log(' git super --init', 'green');
458
+ log(' # Edit ~/.gitsuperrc: "messageTemplate": "VTT-3020: {type}: {message}"', 'cyan');
459
+ log(' git super # Now all commits have VTT-3020 prefix\n', 'green');
460
+
461
+ log(' # Preview message', 'blue');
462
+ log(' git super --dry-run\n', 'green');
463
+
464
+ log(' # Use Claude', 'blue');
465
+ log(' AI_PROVIDER=anthropic git super\n', 'green');
466
+
467
+ log(' # Use different Ollama model', 'blue');
468
+ log(' AI_MODEL=qwen2.5-coder git super\n', 'green');
469
+
470
+ log('Installation:', 'bright');
471
+ log(' npm install -g @theia-core/git-super', 'blue');
472
+ log(' git config --global alias.super \'!git-super\'', 'blue');
473
+ log(' git super --init # Optional: customize config\n', 'blue');
474
+
475
+ log('Works with ANY git repo (Node.js, Python, Java, C++, etc.)\n', 'cyan');
476
+ }
477
+
478
+ async function main() {
479
+ if (flags.init) {
480
+ initConfig();
481
+ process.exit(0);
482
+ }
483
+
484
+ if (flags.help) {
485
+ showHelp();
486
+ process.exit(0);
487
+ }
488
+
489
+ log('\n✨ git-super - AI-powered commits\n', 'cyan');
490
+
491
+ // Check if in git repo
492
+ try {
493
+ exec('git rev-parse --git-dir', { silent: true });
494
+ } catch {
495
+ log('❌ Not a git repository', 'red');
496
+ process.exit(1);
497
+ }
498
+
499
+ const repoName = getRepoName();
500
+ log(`📦 Repository: ${repoName}`, 'blue');
501
+
502
+ // Check for changes
503
+ if (!hasChanges()) {
504
+ log('ℹ️ No changes to commit', 'yellow');
505
+ process.exit(0);
506
+ }
507
+
508
+ try {
509
+ // Stage all changes if not amending
510
+ if (!flags.amend) {
511
+ log('\n→ git add .', 'bright');
512
+ exec('git add .');
513
+ }
514
+
515
+ // Get diff
516
+ const { diff, status, hasStaged, hasUnstaged } = getGitDiff();
517
+
518
+ if (!diff || (!hasStaged && !flags.amend)) {
519
+ log('ℹ️ No changes to commit', 'yellow');
520
+ process.exit(0);
521
+ }
522
+
523
+ // Generate commit message
524
+ const message = await generateCommitMessage(diff, status, repoName);
525
+ log(`\n📝 Commit message:\n`, 'magenta');
526
+ log(` "${message}"\n`, 'bright');
527
+
528
+ // Dry run - stop here
529
+ if (flags.dryRun) {
530
+ log('🔍 Dry run mode - no commit made', 'yellow');
531
+ process.exit(0);
532
+ }
533
+
534
+ // Commit
535
+ const commitFlags = [];
536
+ if (flags.amend) commitFlags.push('--amend');
537
+ if (flags.noVerify) commitFlags.push('--no-verify');
538
+
539
+ const commitCmd = `git commit ${commitFlags.join(' ')} -m "${message.replace(/"/g, '\\"')}"`;
540
+ log(`→ git commit${flags.amend ? ' --amend' : ''}${flags.noVerify ? ' --no-verify' : ''}`, 'bright');
541
+
542
+ try {
543
+ exec(commitCmd);
544
+ } catch (error) {
545
+ log('\n❌ Commit failed (possibly rejected by pre-commit hooks)', 'red');
546
+ process.exit(1);
547
+ }
548
+
549
+ log('✅ Commit successful', 'green');
550
+
551
+ // Push
552
+ if (!flags.noPush && !flags.amend) {
553
+ log('\n→ git push origin HEAD', 'bright');
554
+ try {
555
+ exec('git push origin HEAD');
556
+ log('✅ Push successful', 'green');
557
+ } catch (error) {
558
+ log('❌ Push failed', 'red');
559
+ log('Commit was successful but push failed. Run git push manually.', 'yellow');
560
+ process.exit(1);
561
+ }
562
+ } else if (flags.amend) {
563
+ log('\n💡 Tip: Use `git push --force-with-lease` to push amended commit', 'yellow');
564
+ } else {
565
+ log('\n💡 Commit successful (not pushed)', 'yellow');
566
+ }
567
+
568
+ log('\n✨ Done!\n', 'cyan');
569
+
570
+ } catch (error) {
571
+ log(`\n❌ Error: ${error.message}`, 'red');
572
+ process.exit(1);
573
+ }
574
+ }
575
+
576
+ main();