From e5e47133843a6caed933e634cc0c77041acbf73e Mon Sep 17 00:00:00 2001 From: s41r4j Date: Tue, 30 Dec 2025 19:47:40 +0530 Subject: [PATCH] version 0.8.1 release, stable release --- CHANGELOG.md | 80 +++ README.md | 44 +- bin/gims.js | 937 ++++++++++++++++++++++++++++++---- bin/lib/ai/providers.js | 70 +-- bin/lib/git/analyzer.js | 165 ++++-- bin/lib/utils/colors.js | 15 + bin/lib/utils/intelligence.js | 421 +++++++++++++++ bin/lib/utils/progress.js | 71 ++- package.json | 6 +- 9 files changed, 1609 insertions(+), 200 deletions(-) create mode 100644 bin/lib/utils/intelligence.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bbb333..4ace7f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,85 @@ # Changelog +## [0.8.1] - 2025-12-19 + +### πŸš€ Smart Sync Fix +New `g fix` command to handle branch sync issues (diverged/ahead/behind): + +| Option | What it does | +|--------|--------------| +| `g fix` | Show status and available options | +| `g fix --ai` | Get AI recommendation for best approach | +| `g fix --merge` | Merge remote into local | +| `g fix --rebase` | Rebase local onto remote | +| `g fix --local --yes` | Force push local to remote | +| `g fix --remote --yes` | Reset to remote, discard local | + +### πŸ”§ Conflict Helper +New `g conflicts` command shows conflicted files and how to resolve them. + +--- + +## [0.8.0] - 2025-12-19 + +### πŸš€ New Workflow Shorthand Commands +Multi-step git workflows simplified to single commands: + +| Command | Alias | What it does | +|---------|-------|--------------| +| `safe-pull` | `sp` | Stash β†’ Pull β†’ Stash pop (safe pull with uncommitted changes) | +| `main` | - | Switch to main/master and pull latest | +| `unstage` | `us` | Unstage all staged files | +| `discard` | `x` | Discard all changes (with --yes confirmation) | +| `stash-save` | `ss` | Quick stash all changes with auto-generated name | +| `stash-pop` | `pop` | Pop the latest stash | +| `delete-branch` | `del` | Delete branch locally and remotely | +| `cleanup` | `clean` | Remove local branches deleted from remote | +| `last` | - | Show last commit details and diff | + +--- + +## [0.7.2] - 2025-12-18 + +### πŸ› Bug Fix +- **Fixed progress spinner garbage output**: Spinner now properly clears the line when stopping, preventing partial text artifacts like ` (1ms)rating AI review β ‹` + +--- + +## [0.7.1] - 2025-12-18 + +### πŸ› Bug Fix +- **Fixed AI suggestions in interactive mode**: Multiple suggestions now correctly display message strings instead of `[object Object]` + +--- + +## [0.7.0] - 2025-12-18 + +### πŸš€ New Intelligent Commands +- **`g wip`**: Quick work-in-progress commit - stage all and commit instantly +- **`g today` / `g t`**: Show all commits made today with timestamps +- **`g stats`**: Personal commit statistics with streak tracking, type breakdown, and style analysis +- **`g review` / `g r`**: AI code review before committing - shows complexity, detected patterns, and suggested message +- **`g split`**: Smart suggestions for splitting large changesets into atomic commits + +### ✨ Enhanced Status +- **File type emojis**: πŸ“„ JS, 🎨 CSS, πŸ§ͺ tests, βš™οΈ config, πŸ“¦ package.json, etc. +- **Session awareness**: Shows time since last commit and daily commit count +- **Branch context**: Detects branch type from naming patterns (feat/, fix/, etc.) +- **Smarter insights**: Suggests staging, split commits, and more + +### 🧠 Intelligence Module +- **Commit pattern analysis**: Learns your style from git history +- **Semantic change detection**: Identifies breaking changes, new features, bug fixes +- **Complexity analysis**: Visual indicators for simple/moderate/complex changes + +### πŸ“Š UX Improvements +- **Time elapsed display**: Shows how long operations take +- **Random tips**: Contextual tips to help learn GIMS features +- **Cached response indicators**: Know when AI cache is used +- **Better quick-help**: Reorganized command reference with new commands + +--- + ## [0.6.7] - 2025-10-26 ### πŸ”§ Fixes diff --git a/README.md b/README.md index e434742..6e149d7 100644 --- a/README.md +++ b/README.md @@ -26,17 +26,36 @@ g o # AI commit + push ## 🎯 Main Commands +### 🧠 Smart Commands | Command | Description | |---------|-------------| | `g s` | Enhanced status with AI insights | -| `g i` | Initialize git repository | -| `g int` | Interactive commit wizard | | `g o` | AI commit + push | | `g l` | AI commit locally | +| `g wip` | Quick WIP commit | +| `g r` | AI code review | +| `g t` | Show today's commits | +| `g stats` | Personal commit statistics | + +### ⚑ Workflow Shortcuts +| Command | What it does | +|---------|--------------| +| `g sp` | **Safe Pull** (Stash β†’ Pull β†’ Pop) | +| `g fix` | **Smart Fix** for branch sync issues | +| `g main` | Switch to main/master + pull | +| `g ss` | Quick stash save | +| `g pop` | Pop latest stash | +| `g us` | Unstage all files | +| `g x` | Discard all changes (with confirm) | + +### πŸ› οΈ Helper Commands +| Command | Description | +|---------|-------------| +| `g last` | Show last commit details | +| `g conflicts` | Conflict resolution helper | +| `g clean` | Remove dead local branches | | `g ls` | Commit history (short) | -| `g ll` | Commit history (detailed) | -| `g h` | Commit history (alias for ls) | -| `g a` | Amend previous commit (keeps message) | +| `g a` | Amend previous commit | ## πŸ€– AI Models @@ -48,14 +67,15 @@ g o # AI commit + push ```bash # Daily workflow -g s # Check what changed -g int # Interactive commit -g o # Quick commit + push +g s # Check status +g sp # Safe pull updates +g fix # Fix any sync issues +g o # Commit + push -# Advanced -g sg --multiple # Get 3 AI suggestions -g ll # Detailed history -g sync --rebase # Smart sync +# Power functions +g r # Review code before commit +g stats # Check your streak +g split # Split big changesets ``` ## πŸ”§ Configuration diff --git a/bin/gims.js b/bin/gims.js index 9e492e7..324a674 100755 --- a/bin/gims.js +++ b/bin/gims.js @@ -15,6 +15,7 @@ const { ConfigManager } = require('./lib/config/manager'); const { GitAnalyzer } = require('./lib/git/analyzer'); const { AIProviderManager } = require('./lib/ai/providers'); const { InteractiveCommands } = require('./lib/commands/interactive'); +const { Intelligence } = require('./lib/utils/intelligence'); const program = new Command(); const git = simpleGit(); @@ -64,7 +65,7 @@ async function ensureRepo() { function handleError(prefix, err) { const msg = err && err.message ? err.message : String(err); Progress.error(`${prefix}: ${msg}`); - + // Provide helpful suggestions based on error type if (msg.includes('not found') || msg.includes('does not exist')) { console.log(`\nTip: Check if the file/branch exists with: ${color.cyan('g status')}`); @@ -73,7 +74,7 @@ function handleError(prefix, err) { } else if (msg.includes('merge') || msg.includes('conflict')) { console.log(`\nTip: Resolve conflicts and try again`); } - + process.exit(1); } @@ -97,7 +98,7 @@ async function generateCommitMessage(rawDiff, options = {}) { async function confirmCommit(message, isLocalHeuristic) { if (!isLocalHeuristic) return true; // No confirmation needed for AI-generated messages - + const readline = require('readline'); const rl = readline.createInterface({ input: process.stdin, @@ -106,7 +107,7 @@ async function confirmCommit(message, isLocalHeuristic) { console.log(color.yellow('\n⚠️ No AI provider configured - using local heuristics')); console.log(`Suggested commit: "${message}"`); - + return new Promise((resolve) => { rl.question('Proceed with this commit? [Y/n]: ', (answer) => { rl.close(); @@ -184,10 +185,10 @@ async function setupApiKey(provider) { }); console.log(color.bold(`\nπŸ”‘ ${provider.toUpperCase()} API Key Setup\n`)); - + const envVars = { 'openai': 'OPENAI_API_KEY', - 'gemini': 'GEMINI_API_KEY', + 'gemini': 'GEMINI_API_KEY', 'groq': 'GROQ_API_KEY' }; @@ -211,7 +212,7 @@ async function setupApiKey(provider) { } const apiKey = await question(`\nEnter your ${provider.toUpperCase()} API key: `); - + if (!apiKey) { console.log(color.yellow('No API key provided. Setup cancelled.')); rl.close(); @@ -226,12 +227,12 @@ async function setupApiKey(provider) { console.log(color.cyan(`export ${envVar}="${apiKey}"`)); console.log('\nOr add it to your shell profile (~/.bashrc, ~/.zshrc, etc.):'); console.log(color.cyan(`echo 'export ${envVar}="${apiKey}"' >> ~/.zshrc`)); - + // Set provider in config const config = configManager.load(); config.provider = provider; configManager.save(config); - + console.log(`\n${color.green('βœ“')} Provider set to ${provider} in local config`); console.log('\nRestart your terminal and try:'); console.log(` ${color.cyan('g sg')} - Get AI suggestions`); @@ -245,7 +246,7 @@ program.command('status').alias('s') try { const enhancedStatus = await gitAnalyzer.getEnhancedStatus(); console.log(gitAnalyzer.formatStatusOutput(enhancedStatus)); - + // Show commit history summary const history = await gitAnalyzer.analyzeCommitHistory(5); if (history.totalCommits > 0) { @@ -325,38 +326,41 @@ program.command('quick-help').alias('q') .description('Show quick reference for main commands') .action(() => { console.log(color.bold('πŸš€ GIMS Quick Reference\n')); - - console.log(color.bold('Single-Letter Workflow:')); - console.log(` ${color.cyan('g s')} Status - Enhanced git status with AI insights`); - console.log(` ${color.cyan('g i')} Init - Initialize a new Git repository`); - console.log(` ${color.cyan('g p')} Preview - See what will be committed`); - console.log(` ${color.cyan('g l')} Local - AI commit locally`); - console.log(` ${color.cyan('g o')} Online - AI commit + push`); - console.log(` ${color.cyan('g ls')} List - Short commit history`); - console.log(` ${color.cyan('g ll')} Large List - Detailed commit history`); - console.log(` ${color.cyan('g h')} History - Alias for list`); - console.log(` ${color.cyan('g a')} Amend - Merge changes to previous commit (keeps message)`); - console.log(` ${color.cyan('g u')} Undo - Undo last commit\n`); - - console.log(color.bold('Quick Setup:')); - console.log(` ${color.cyan('g setup --api-key gemini')} πŸš€ gemini-2.5-flash (recommended)`); - console.log(` ${color.cyan('g setup --api-key openai')} πŸ’Ž gpt-5 (high quality)`); - console.log(` ${color.cyan('g setup --api-key groq')} ⚑ groq/compound (ultra fast)\n`); - - console.log(color.bold('Essential Workflow:')); - console.log(` ${color.cyan('g s')} Check what's changed`); - console.log(` ${color.cyan('g int')} or ${color.cyan('g o')} Commit with AI (interactive or online)`); - console.log(` ${color.cyan('g ls')} or ${color.cyan('g h')} View history\n`); - - console.log(`For full help: ${color.cyan('g --help')}`); - console.log(`For detailed docs: See README.md`); + + console.log(color.bold('Core Workflow:')); + console.log(` ${color.cyan('g s')} Status with AI insights`); + console.log(` ${color.cyan('g o')} AI commit + push`); + console.log(` ${color.cyan('g l')} AI commit locally`); + console.log(` ${color.cyan('g wip')} Quick WIP commit\n`); + + console.log(color.bold('Sync & Fix:')); + console.log(` ${color.cyan('g sp')} Safe pull (stash β†’ pull β†’ pop)`); + console.log(` ${color.cyan('g fix')} Fix branch sync issues`); + console.log(` ${color.cyan('g main')} Switch to main + pull\n`); + + console.log(color.bold('Stash:')); + console.log(` ${color.cyan('g ss')} Quick stash save`); + console.log(` ${color.cyan('g pop')} Pop latest stash`); + console.log(` ${color.cyan('g us')} Unstage all files\n`); + + console.log(color.bold('Smart Commands:')); + console.log(` ${color.cyan('g r')} AI code review`); + console.log(` ${color.cyan('g t')} Today's commits`); + console.log(` ${color.cyan('g last')} Last commit details\n`); + + console.log(color.bold('History:')); + console.log(` ${color.cyan('g ls')} Commit history`); + console.log(` ${color.cyan('g a')} Amend last commit`); + console.log(` ${color.cyan('g u')} Undo last commit\n`); + + console.log(`Full help: ${color.cyan('g --help')}`); }); program.command('init').alias('i') .description('Initialize a new Git repository') - .action(async () => { - try { - await git.init(); + .action(async () => { + try { + await git.init(); Progress.success('Initialized git repository'); console.log(`\nNext steps:`); console.log(` ${color.cyan('g setup')} - Configure GIMS`); @@ -400,17 +404,17 @@ program.command('suggest').alias('sg') if (opts.progressIndicators) Progress.start('πŸ€– Generating multiple suggestions'); const suggestions = await aiProvider.generateMultipleSuggestions(rawDiff, opts, 3); if (opts.progressIndicators) Progress.stop(''); - + console.log(color.bold('\nπŸ“ Suggested commit messages:\n')); suggestions.forEach((msg, i) => { console.log(`${color.cyan((i + 1).toString())}. ${msg}`); }); - + if (!opts.noClipboard && suggestions.length > 0) { - try { - clipboard.writeSync(suggestions[0]); + try { + clipboard.writeSync(suggestions[0]); console.log(`\n${color.green('βœ“')} First suggestion copied to clipboard`); - } catch (_) { + } catch (_) { console.log(`\n${color.yellow('⚠')} Clipboard copy failed`); } } @@ -418,10 +422,10 @@ program.command('suggest').alias('sg') if (opts.progressIndicators) Progress.start('πŸ€– Analyzing changes'); const result = await generateCommitMessage(rawDiff, opts); if (opts.progressIndicators) Progress.stop(''); - + const msg = result.message || result; // Handle both old and new format const usedLocal = result.usedLocal || false; - + // Warn if using local heuristics if (usedLocal) { console.log(color.yellow('⚠️ No AI provider configured - using local heuristics')); @@ -434,10 +438,10 @@ program.command('suggest').alias('sg') } if (!opts.noClipboard) { - try { - clipboard.writeSync(msg); + try { + clipboard.writeSync(msg); Progress.success(`"${msg}" (copied to clipboard)`); - } catch (_) { + } catch (_) { console.log(`Suggested: "${msg}" ${color.yellow('(clipboard copy failed)')}`); } } else { @@ -471,16 +475,16 @@ program.command('local').alias('l') Progress.info('No staged changes found; staging all changes...'); await git.add('.'); rawDiff = await git.diff(['--cached', '--no-ext-diff']); - if (!rawDiff.trim()) { - Progress.warning('No changes to commit'); - return; + if (!rawDiff.trim()) { + Progress.warning('No changes to commit'); + return; } } if (opts.progressIndicators) Progress.start('πŸ€– Generating commit message'); const result = await generateCommitMessage(rawDiff, opts); if (opts.progressIndicators) Progress.stop(''); - + const msg = result.message || result; // Handle both old and new format const usedLocal = result.usedLocal || false; @@ -533,16 +537,16 @@ program.command('online').alias('o') Progress.info('No staged changes found; staging all changes...'); await git.add('.'); rawDiff = await git.diff(['--cached', '--no-ext-diff']); - if (!rawDiff.trim()) { - Progress.warning('No changes to commit'); - return; + if (!rawDiff.trim()) { + Progress.warning('No changes to commit'); + return; } } if (opts.progressIndicators) Progress.start('πŸ€– Generating commit message'); const result = await generateCommitMessage(rawDiff, opts); if (opts.progressIndicators) Progress.stop(''); - + const msg = result.message || result; // Handle both old and new format const usedLocal = result.usedLocal || false; @@ -640,9 +644,9 @@ program.command('pull') .description('Pull latest changes') .action(async () => { await ensureRepo(); - try { + try { Progress.info('Pulling latest changes...'); - await git.pull(); + await git.pull(); Progress.success('Pulled latest changes'); } catch (e) { handleError('Pull error', e); } @@ -667,27 +671,27 @@ program.command('sync') await ensureRepo(); try { const status = await git.status(); - + if (status.files.length > 0) { Progress.warning('You have uncommitted changes. Commit or stash them first.'); return; } - + Progress.info('Fetching latest changes...'); await git.fetch(); - + const currentBranch = (await git.raw(['rev-parse', '--abbrev-ref', 'HEAD'])).trim(); const remoteBranch = `origin/${currentBranch}`; - + try { const behind = await git.raw(['rev-list', '--count', `${currentBranch}..${remoteBranch}`]); const ahead = await git.raw(['rev-list', '--count', `${remoteBranch}..${currentBranch}`]); - + if (parseInt(behind.trim()) === 0) { Progress.success('Already up to date'); return; } - + if (parseInt(ahead.trim()) > 0) { Progress.info(`Branch is ${ahead.trim()} commits ahead and ${behind.trim()} commits behind`); if (cmdOptions.rebase) { @@ -713,8 +717,8 @@ program.command('sync') throw error; } } - } catch (e) { - handleError('Sync error', e); + } catch (e) { + handleError('Sync error', e); } }); @@ -732,7 +736,7 @@ program.command('stash') Progress.info('No stashes found'); return; } - + console.log(color.bold('Stashes:')); stashes.all.forEach((stash, i) => { console.log(`${color.cyan((i).toString())}. ${stash.message}`); @@ -751,15 +755,15 @@ program.command('stash') Progress.warning('No changes to stash'); return; } - + Progress.start('πŸ€– Generating stash description'); const diff = await git.diff(); - const description = await aiProvider.generateCommitMessage(diff, { - conventional: false, - body: false + const description = await aiProvider.generateCommitMessage(diff, { + conventional: false, + body: false }); Progress.stop(''); - + await git.stash(['push', '-m', `WIP: ${description}`]); Progress.success(`Stashed changes: "${description}"`); } @@ -795,10 +799,10 @@ program.command('amend').alias('a') Progress.start('πŸ€– Generating updated commit message'); const result = await generateCommitMessage(rawDiff, opts); Progress.stop(''); - + const newMessage = result.message || result; // Handle both old and new format const usedLocal = result.usedLocal || false; - + // Ask for confirmation if using local heuristics (unless --yes flag is set) if (usedLocal && !opts.yes) { const confirmed = await confirmCommit(newMessage, true); @@ -807,7 +811,7 @@ program.command('amend').alias('a') return; } } - + await git.raw(['commit', '--amend', '-m', newMessage]); Progress.success(`Amended commit: "${newMessage}"`); } else { @@ -829,21 +833,21 @@ program.command('list').alias('ls') const limit = parseInt(cmdOptions.limit) || 20; const log = await git.log({ maxCount: limit }); const commits = [...log.all].reverse(); - + if (commits.length === 0) { Progress.info('No commits found'); return; } - + commits.forEach((c, i) => { - console.log(`${color.cyan((i+1).toString())}. ${color.yellow(c.hash.slice(0,7))} ${c.message}`); + console.log(`${color.cyan((i + 1).toString())}. ${color.yellow(c.hash.slice(0, 7))} ${c.message}`); }); - + if (log.all.length >= limit) { console.log(color.dim(`\n... showing last ${limit} commits (use --limit to see more)`)); } - } catch (e) { - handleError('List error', e); + } catch (e) { + handleError('List error', e); } }); @@ -856,22 +860,22 @@ program.command('largelist').alias('ll') const limit = parseInt(cmdOptions.limit) || 20; const log = await git.log({ maxCount: limit }); const commits = [...log.all].reverse(); - + if (commits.length === 0) { Progress.info('No commits found'); return; } - + commits.forEach((c, i) => { const date = new Date(c.date).toLocaleString(); - console.log(`${color.cyan((i+1).toString())}. ${color.yellow(c.hash.slice(0,7))} | ${color.dim(date)} | ${color.green(c.author_name)} β†’ ${c.message}`); + console.log(`${color.cyan((i + 1).toString())}. ${color.yellow(c.hash.slice(0, 7))} | ${color.dim(date)} | ${color.green(c.author_name)} β†’ ${c.message}`); }); - + if (log.all.length >= limit) { console.log(color.dim(`\n... showing last ${limit} commits (use --limit to see more)`)); } - } catch (e) { - handleError('Largelist error', e); + } catch (e) { + handleError('Largelist error', e); } }); @@ -884,21 +888,21 @@ program.command('history').alias('h') const limit = parseInt(cmdOptions.limit) || 20; const log = await git.log({ maxCount: limit }); const commits = [...log.all].reverse(); - + if (commits.length === 0) { Progress.info('No commits found'); return; } - + commits.forEach((c, i) => { - console.log(`${color.cyan((i+1).toString())}. ${color.yellow(c.hash.slice(0,7))} ${c.message}`); + console.log(`${color.cyan((i + 1).toString())}. ${color.yellow(c.hash.slice(0, 7))} ${c.message}`); }); - + if (log.all.length >= limit) { console.log(color.dim(`\n... showing last ${limit} commits (use --limit to see more)`)); } - } catch (e) { - handleError('History error', e); + } catch (e) { + handleError('History error', e); } }); @@ -906,18 +910,18 @@ program.command('branch [name]').alias('b') .description('Branch from commit/index') .action(async (c, name) => { await ensureRepo(); - try { const sha = await resolveCommit(c); const br = name || `branch-${sha.slice(0,7)}`; await git.checkout(['-b', br, sha]); console.log(`Switched to branch ${br} at ${sha}`); } + try { const sha = await resolveCommit(c); const br = name || `branch-${sha.slice(0, 7)}`; await git.checkout(['-b', br, sha]); console.log(`Switched to branch ${br} at ${sha}`); } catch (e) { handleError('Branch error', e); } }); -program.command('reset ').alias('r') +program.command('reset ').alias('rs') .description('Reset branch to commit/index') - .option('--hard','hard reset') + .option('--hard', 'hard reset') .action(async (c, optsCmd) => { await ensureRepo(); try { const sha = await resolveCommit(c); - const mode = optsCmd.hard? '--hard':'--soft'; + const mode = optsCmd.hard ? '--hard' : '--soft'; const opts = getOpts(); if (!opts.yes) { console.log(color.yellow(`About to run: git reset ${mode} ${sha}. Use --yes to confirm.`)); @@ -957,11 +961,11 @@ program.command('undo').alias('u') Progress.warning('No commits to undo'); return; } - + const lastCommit = all[0]; const mode = cmd.hard ? '--hard' : '--soft'; const opts = getOpts(); - + if (!opts.yes) { console.log(color.yellow(`About to undo: "${lastCommit.message}"`)); console.log(color.yellow(`This will run: git reset ${mode} HEAD~1`)); @@ -971,16 +975,743 @@ program.command('undo').alias('u') console.log('Use --yes to confirm.'); process.exit(1); } - + await git.raw(['reset', mode, 'HEAD~1']); Progress.success(`Undone commit: "${lastCommit.message}" (${mode} reset)`); - + if (mode === '--soft') { Progress.info('Changes are now staged. Use "g status" to see them.'); } - } catch (e) { - handleError('Undo error', e); + } catch (e) { + handleError('Undo error', e); + } + }); + +// ===== NEW INTELLIGENT COMMANDS ===== + +program.command('wip') + .description('Quick work-in-progress commit') + .action(async () => { + await ensureRepo(); + try { + const status = await git.status(); + if (status.files.length === 0) { + Progress.warning('No changes to commit'); + return; + } + + Progress.info('Staging all changes...'); + await git.add('.'); + + const fileCount = status.files.length; + const message = `WIP: ${fileCount} file${fileCount > 1 ? 's' : ''} changed`; + + await git.commit(message); + Progress.success(`Committed: "${message}"`); + Progress.tip('Use `g a` to amend this commit when ready, or `g undo` to undo'); + } catch (e) { + handleError('WIP error', e); + } + }); + +program.command('today').alias('t') + .description('Show commits made today') + .action(async () => { + await ensureRepo(); + try { + const commits = await gitAnalyzer.getTodayCommits(); + + if (commits.length === 0) { + console.log(color.dim('No commits today yet.')); + Progress.tip('Start your day with `g o` to commit and push!'); + return; + } + + console.log(color.bold(`πŸ“… Today's Commits (${commits.length})\n`)); + commits.forEach((commit, i) => { + console.log(gitAnalyzer.formatCommit(commit, i)); + }); + } catch (e) { + handleError('Today error', e); + } + }); + +program.command('stats') + .description('Your personal commit statistics') + .option('--days ', 'Number of days to analyze', '30') + .action(async (cmdOptions) => { + await ensureRepo(); + try { + const days = parseInt(cmdOptions.days) || 30; + const intelligence = new Intelligence(git); + + Progress.start('πŸ“Š Analyzing your commit history'); + const stats = await intelligence.getCommitStats(days); + const patterns = await intelligence.analyzeCommitPatterns(); + Progress.stop(''); + + if (!stats.hasData) { + Progress.info('Not enough commit history to analyze'); + return; + } + + console.log(color.bold(`\nπŸ“Š Your Git Stats (last ${days} days)\n`)); + + // Overview + console.log(color.cyan('Overview:')); + console.log(` Total commits: ${color.bold(stats.totalCommits.toString())}`); + console.log(` Days active: ${stats.daysActive}`); + console.log(` Average: ${stats.avgPerDay} commits/day`); + console.log(` Current streak: ${color.green(stats.currentStreak + ' days')}`); + if (stats.longestStreak > stats.currentStreak) { + console.log(` Longest streak: ${stats.longestStreak} days`); + } + + // Commit types breakdown + if (patterns.usesConventional) { + console.log(`\n${color.cyan('Commit Types:')}`); + const types = stats.typeBreakdown; + const total = Object.values(types).reduce((a, b) => a + b, 0); + Object.entries(types).forEach(([type, count]) => { + if (count > 0) { + const pct = Math.round(count / total * 100); + const bar = 'β–ˆ'.repeat(Math.ceil(pct / 5)) + 'β–‘'.repeat(20 - Math.ceil(pct / 5)); + console.log(` ${type.padEnd(8)} ${bar} ${pct}%`); + } + }); + } + + // Style insights + if (patterns.hasHistory) { + console.log(`\n${color.cyan('Your Style:')}`); + console.log(` Conventional commits: ${patterns.conventionalRatio}%`); + console.log(` Message style: ${patterns.style}`); + console.log(` Avg message length: ${patterns.avgMessageLength} chars`); + if (patterns.topScopes.length > 0) { + console.log(` Common scopes: ${patterns.topScopes.join(', ')}`); + } + } + + Progress.showRandomTip(); + } catch (e) { + handleError('Stats error', e); + } + }); + +program.command('review').alias('r') + .description('AI code review before committing') + .action(async () => { + await ensureRepo(); + const opts = getOpts(); + try { + let diff = await git.diff(['--cached', '--no-ext-diff']); + + if (!diff.trim()) { + // Try unstaged changes + diff = await git.diff(['--no-ext-diff']); + if (!diff.trim()) { + Progress.warning('No changes to review'); + return; + } + console.log(color.dim('(Reviewing unstaged changes)\n')); + } else { + console.log(color.dim('(Reviewing staged changes)\n')); + } + + const intelligence = new Intelligence(git); + + // Analyze complexity + const complexity = await gitAnalyzer.getChangeComplexity(diff); + console.log(color.bold('πŸ“‹ Change Summary')); + console.log(` Complexity: ${complexity.emoji} ${complexity.complexity}`); + console.log(` Files: ${complexity.files}`); + console.log(` Changes: ${color.green('+' + complexity.additions)} ${color.red('-' + complexity.deletions)}`); + + // Detect semantic changes + const semantic = await intelligence.detectSemanticChanges(diff); + if (semantic.labels.length > 0) { + console.log(`\n${color.bold('πŸ” Detected Patterns')}`); + semantic.labels.forEach(label => { + console.log(` ${label}`); + }); + } + + // Get AI suggestions + if (opts.progressIndicators) Progress.start('πŸ€– Generating AI review'); + const message = await aiProvider.generateCommitMessage(diff, { ...opts, body: true }); + if (opts.progressIndicators) Progress.stop(''); + + console.log(`\n${color.bold('πŸ’¬ Suggested Commit Message')}`); + console.log(` ${color.green(message.message || message)}`); + + // Actionable next steps + console.log(`\n${color.bold('πŸ“Œ Next Steps')}`); + console.log(` ${color.cyan('g o')} Commit and push with AI message`); + console.log(` ${color.cyan('g l')} Commit locally with AI message`); + console.log(` ${color.cyan('g int')} Interactive commit with options`); + + } catch (e) { + handleError('Review error', e); + } + }); + +program.command('split') + .description('Suggest how to split a large changeset') + .action(async () => { + await ensureRepo(); + try { + const status = await git.status(); + + if (status.files.length === 0) { + Progress.info('No changes to split'); + return; + } + + if (status.files.length < 5) { + Progress.info('Changeset is small enough - no need to split'); + console.log(`\nYou have ${status.files.length} file${status.files.length > 1 ? 's' : ''} changed.`); + console.log(`Use ${color.cyan('g o')} to commit them all at once.`); + return; + } + + const intelligence = new Intelligence(git); + const suggestions = await intelligence.suggestCommitSplit(status); + + if (!suggestions) { + Progress.info('All changes look related - commit them together'); + return; + } + + console.log(color.bold(`\nπŸ“¦ Suggested Commit Split\n`)); + console.log(color.dim(`Your ${status.files.length} files could be split into ${suggestions.length} commits:\n`)); + + suggestions.forEach((group, i) => { + console.log(`${color.cyan((i + 1).toString())}. ${color.bold(group.message)}`); + group.files.slice(0, 5).forEach(file => { + const emoji = Intelligence.getFileEmoji(file); + console.log(` ${emoji} ${file}`); + }); + if (group.files.length > 5) { + console.log(color.dim(` ... and ${group.files.length - 5} more`)); + } + console.log(); + }); + + console.log(color.bold('πŸ’‘ How to split:')); + console.log(` 1. Stage specific files: ${color.cyan('git add ')}`); + console.log(` 2. Commit them: ${color.cyan('g l')} or ${color.cyan('g o')}`); + console.log(` 3. Repeat for remaining files`); + + } catch (e) { + handleError('Split error', e); + } + }); + +// ===== WORKFLOW SHORTHAND COMMANDS ===== + +program.command('safe-pull').alias('sp') + .description('Safe pull: stash β†’ pull β†’ stash pop') + .action(async () => { + await ensureRepo(); + try { + const status = await git.status(); + const hasChanges = status.files.length > 0; + + if (hasChanges) { + Progress.info('Stashing changes...'); + await git.stash(['push', '-m', 'GIMS: auto-stash before pull']); + } + + Progress.info('Pulling latest changes...'); + await git.pull(); + + if (hasChanges) { + Progress.info('Restoring stashed changes...'); + await git.stash(['pop']); + } + + Progress.success('Safe pull complete'); + } catch (e) { + handleError('Safe pull error', e); + } + }); + +program.command('main') + .description('Switch to main/master and pull latest') + .action(async () => { + await ensureRepo(); + try { + // Detect main branch name + let mainBranch = 'main'; + try { + await git.raw(['rev-parse', '--verify', 'main']); + } catch { + try { + await git.raw(['rev-parse', '--verify', 'master']); + mainBranch = 'master'; + } catch { + Progress.error('No main or master branch found'); + return; + } + } + + const status = await git.status(); + if (status.files.length > 0) { + Progress.warning('You have uncommitted changes. Commit or stash them first.'); + console.log(`Tip: Use ${color.cyan('g sp')} to safe-pull with auto-stash`); + return; + } + + Progress.info(`Switching to ${mainBranch}...`); + await git.checkout(mainBranch); + + Progress.info('Pulling latest...'); + await git.pull(); + + Progress.success(`On ${mainBranch} with latest changes`); + } catch (e) { + handleError('Main error', e); + } + }); + +program.command('unstage').alias('us') + .description('Unstage all staged files') + .action(async () => { + await ensureRepo(); + try { + const status = await git.status(); + if (status.staged.length === 0) { + Progress.info('Nothing is staged'); + return; + } + + await git.reset([]); + Progress.success(`Unstaged ${status.staged.length} file${status.staged.length > 1 ? 's' : ''}`); + } catch (e) { + handleError('Unstage error', e); + } + }); + +program.command('discard').alias('x') + .description('Discard all changes (with confirmation)') + .action(async () => { + await ensureRepo(); + const opts = getOpts(); + try { + const status = await git.status(); + if (status.files.length === 0) { + Progress.info('No changes to discard'); + return; + } + + if (!opts.yes) { + console.log(color.yellow(`⚠️ About to discard ALL changes in ${status.files.length} file(s)`)); + console.log(color.red('This cannot be undone!')); + console.log('Use --yes to confirm.'); + return; + } + + // Discard tracked file changes + await git.checkout(['--', '.']); + + // Remove untracked files + if (status.not_added.length > 0) { + await git.clean('fd'); + } + + Progress.success('All changes discarded'); + } catch (e) { + handleError('Discard error', e); + } + }); + +program.command('stash-save').alias('ss') + .description('Quick stash: stage all and stash') + .action(async () => { + await ensureRepo(); + try { + const status = await git.status(); + if (status.files.length === 0) { + Progress.info('No changes to stash'); + return; + } + + await git.add('.'); + + // Generate a descriptive stash name + const fileCount = status.files.length; + const timestamp = new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); + const message = `WIP: ${fileCount} file${fileCount > 1 ? 's' : ''} at ${timestamp}`; + + await git.stash(['push', '-m', message]); + Progress.success(`Stashed: "${message}"`); + } catch (e) { + handleError('Stash save error', e); + } + }); + +program.command('stash-pop').alias('pop') + .description('Pop the latest stash') + .action(async () => { + await ensureRepo(); + try { + const stashList = await git.stashList(); + if (stashList.all.length === 0) { + Progress.info('No stashes to pop'); + return; + } + + const latestStash = stashList.all[0]; + await git.stash(['pop']); + Progress.success(`Popped: "${latestStash.message}"`); + } catch (e) { + handleError('Stash pop error', e); + } + }); + +program.command('delete-branch').alias('del') + .description('Delete branch locally and remotely') + .argument('', 'Branch name to delete') + .action(async (branch) => { + await ensureRepo(); + const opts = getOpts(); + try { + const current = (await git.branch()).current; + if (branch === current) { + Progress.error(`Cannot delete current branch. Switch to another branch first.`); + return; + } + + if (branch === 'main' || branch === 'master') { + Progress.error('Cannot delete main/master branch'); + return; + } + + if (!opts.yes) { + console.log(color.yellow(`⚠️ About to delete branch: ${branch}`)); + console.log('This will delete both local and remote copies.'); + console.log('Use --yes to confirm.'); + return; + } + + // Delete local + Progress.info('Deleting local branch...'); + try { + await git.branch(['-D', branch]); + } catch (e) { + Progress.warning(`Local branch not found or already deleted`); + } + + // Delete remote + Progress.info('Deleting remote branch...'); + try { + await git.push(['origin', '--delete', branch]); + } catch (e) { + Progress.warning(`Remote branch not found or already deleted`); + } + + Progress.success(`Deleted branch: ${branch}`); + } catch (e) { + handleError('Delete branch error', e); + } + }); + +program.command('cleanup').alias('clean') + .description('Remove local branches that no longer exist on remote') + .action(async () => { + await ensureRepo(); + const opts = getOpts(); + try { + Progress.info('Fetching and pruning...'); + await git.fetch(['--prune']); + + // Find gone branches + const branchOutput = await git.raw(['branch', '-vv']); + const goneBranches = branchOutput + .split('\n') + .filter(line => line.includes(': gone]')) + .map(line => line.trim().split(/\s+/)[0].replace('*', '').trim()) + .filter(b => b && b !== 'main' && b !== 'master'); + + if (goneBranches.length === 0) { + Progress.success('No dead branches to clean up'); + return; + } + + console.log(color.bold(`\nFound ${goneBranches.length} dead branch(es):`)); + goneBranches.forEach(b => console.log(` ${color.dim('β€’')} ${b}`)); + + if (!opts.yes) { + console.log(`\nUse ${color.cyan('g clean --yes')} to delete them`); + return; + } + + for (const branch of goneBranches) { + try { + await git.branch(['-D', branch]); + Progress.success(`Deleted: ${branch}`); + } catch (e) { + Progress.warning(`Could not delete: ${branch}`); + } + } + + Progress.success(`Cleaned up ${goneBranches.length} branch(es)`); + } catch (e) { + handleError('Cleanup error', e); + } + }); + +program.command('last') + .description('Show last commit details and diff') + .action(async () => { + await ensureRepo(); + try { + const log = await git.log({ maxCount: 1 }); + if (log.all.length === 0) { + Progress.info('No commits yet'); + return; + } + + const commit = log.all[0]; + console.log(color.bold('\nπŸ“ Last Commit\n')); + console.log(` ${color.yellow(commit.hash.substring(0, 7))} ${commit.message.split('\n')[0]}`); + console.log(` ${color.dim(`by ${commit.author_name} β€’ ${new Date(commit.date).toLocaleString()}`)}`); + + // Show diff stats + const diff = await git.diff(['HEAD~1', '--stat']); + if (diff.trim()) { + console.log(`\n${color.bold('Changes:')}`); + console.log(color.dim(diff)); + } + } catch (e) { + handleError('Last error', e); + } + }); + +// ===== SMART SYNC FIX COMMAND ===== + +program.command('fix') + .description('Smart fix for branch sync issues (diverged, behind, ahead)') + .option('--local', 'Keep local changes, force push to remote') + .option('--remote', 'Keep remote changes, discard local') + .option('--merge', 'Merge remote into local') + .option('--rebase', 'Rebase local onto remote') + .option('--ai', 'Get AI recommendation for best approach') + .action(async (cmdOptions) => { + await ensureRepo(); + const opts = getOpts(); + + try { + // Fetch latest + Progress.info('Fetching remote status...'); + await git.fetch(); + + const branch = (await git.branch()).current; + const remoteBranch = `origin/${branch}`; + + // Check if remote exists + let remoteExists = true; + try { + await git.raw(['rev-parse', '--verify', remoteBranch]); + } catch { + remoteExists = false; + } + + if (!remoteExists) { + console.log(color.bold(`\nπŸ“ Branch Status: ${color.cyan(branch)}\n`)); + console.log(`Remote branch ${color.yellow(remoteBranch)} doesn't exist yet.`); + console.log(`\nOptions:`); + console.log(` ${color.cyan('g push --set-upstream')} Push and create remote branch`); + return; + } + + // Get ahead/behind counts + const ahead = parseInt((await git.raw(['rev-list', '--count', `${remoteBranch}..${branch}`])).trim()); + const behind = parseInt((await git.raw(['rev-list', '--count', `${branch}..${remoteBranch}`])).trim()); + + // Get local changes status + const status = await git.status(); + const hasLocalChanges = status.files.length > 0; + + console.log(color.bold(`\nπŸ“ Branch Status: ${color.cyan(branch)}\n`)); + + // Determine situation + let situation = ''; + if (ahead === 0 && behind === 0) { + Progress.success('Branch is up to date with remote!'); + if (hasLocalChanges) { + console.log(`\nYou have ${status.files.length} uncommitted change(s).`); + console.log(`Use ${color.cyan('g o')} to commit and push them.`); + } + return; + } else if (ahead > 0 && behind === 0) { + situation = 'ahead'; + console.log(` ${color.green('↑')} ${ahead} commit(s) ahead of remote`); + console.log(` ${color.dim('Your local has commits not on remote')}\n`); + } else if (ahead === 0 && behind > 0) { + situation = 'behind'; + console.log(` ${color.yellow('↓')} ${behind} commit(s) behind remote`); + console.log(` ${color.dim('Remote has commits you don\'t have')}\n`); + } else { + situation = 'diverged'; + console.log(` ${color.red('⚑')} Branch has diverged!`); + console.log(` ${color.green('↑')} ${ahead} commit(s) ahead`); + console.log(` ${color.yellow('↓')} ${behind} commit(s) behind\n`); + } + + if (hasLocalChanges) { + console.log(color.yellow(`⚠️ You have ${status.files.length} uncommitted file(s)`)); + console.log(`${color.dim('Commit or stash them before fixing sync issues')}\n`); + } + + // If specific option provided, execute it + if (cmdOptions.local) { + if (!opts.yes) { + console.log(color.red('⚠️ This will FORCE PUSH and overwrite remote!')); + console.log(`Use ${color.cyan('g fix --local --yes')} to confirm.`); + return; + } + Progress.info('Force pushing local to remote...'); + await git.push(['--force']); + Progress.success('Force pushed! Remote now matches local.'); + return; + } + + if (cmdOptions.remote) { + if (!opts.yes) { + console.log(color.red('⚠️ This will DISCARD local commits!')); + console.log(`Use ${color.cyan('g fix --remote --yes')} to confirm.`); + return; + } + Progress.info('Resetting to remote...'); + await git.reset(['--hard', remoteBranch]); + Progress.success('Reset! Local now matches remote.'); + return; + } + + if (cmdOptions.merge) { + Progress.info('Merging remote into local...'); + try { + await git.merge([remoteBranch]); + Progress.success('Merged successfully!'); + } catch (e) { + Progress.error('Merge conflict! Resolve conflicts then run: g o'); + } + return; + } + + if (cmdOptions.rebase) { + Progress.info('Rebasing local onto remote...'); + try { + await git.rebase([remoteBranch]); + Progress.success('Rebased successfully!'); + console.log(`Now run ${color.cyan('g push --force')} to update remote.`); + } catch (e) { + Progress.error('Rebase conflict! Resolve conflicts then run: git rebase --continue'); + } + return; + } + + // AI recommendation + if (cmdOptions.ai) { + Progress.info('Analyzing best approach...'); + let recommendation = ''; + + if (situation === 'ahead') { + recommendation = `Your local is ${ahead} commits ahead. Simply push to sync.`; + console.log(`\n${color.bold('πŸ€– AI Recommendation:')}`); + console.log(` ${recommendation}`); + console.log(`\n Run: ${color.cyan('g push')}`); + } else if (situation === 'behind') { + recommendation = `Remote has ${behind} new commits. Pull to get them.`; + console.log(`\n${color.bold('πŸ€– AI Recommendation:')}`); + console.log(` ${recommendation}`); + console.log(`\n Run: ${color.cyan('g pull')} or ${color.cyan('g sp')} (if you have changes)`); + } else { + // Diverged - more complex + if (ahead <= 2 && behind > ahead) { + recommendation = `Small local changes (${ahead}), larger remote (${behind}). Rebase recommended for clean history.`; + console.log(`\n${color.bold('πŸ€– AI Recommendation:')}`); + console.log(` ${recommendation}`); + console.log(`\n Run: ${color.cyan('g fix --rebase')}`); + } else if (behind <= 2 && ahead > behind) { + recommendation = `Large local changes (${ahead}), small remote (${behind}). Merge is safe.`; + console.log(`\n${color.bold('πŸ€– AI Recommendation:')}`); + console.log(` ${recommendation}`); + console.log(`\n Run: ${color.cyan('g fix --merge')}`); + } else { + recommendation = `Significant divergence. Review changes first, then choose merge or rebase.`; + console.log(`\n${color.bold('πŸ€– AI Recommendation:')}`); + console.log(` ${recommendation}`); + console.log(`\n View remote changes: ${color.cyan(`git log ${branch}..${remoteBranch} --oneline`)}`); + console.log(` View local changes: ${color.cyan(`git log ${remoteBranch}..${branch} --oneline`)}`); + } + } + return; + } + + // Show interactive menu + console.log(color.bold('πŸ”§ Fix Options:\n')); + + if (situation === 'ahead') { + console.log(` ${color.cyan('g push')} Push your commits to remote`); + } else if (situation === 'behind') { + console.log(` ${color.cyan('g pull')} Get remote commits (fast-forward)`); + console.log(` ${color.cyan('g sp')} Safe pull (stash β†’ pull β†’ pop)`); + } else { + // Diverged + console.log(` ${color.cyan('g fix --merge')} Merge remote into local (creates merge commit)`); + console.log(` ${color.cyan('g fix --rebase')} Rebase local onto remote (linear history)`); + console.log(color.dim(' ─────────────────')); + console.log(` ${color.cyan('g fix --local')} ${color.yellow('⚠')} Force push local, overwrite remote`); + console.log(` ${color.cyan('g fix --remote')} ${color.yellow('⚠')} Reset to remote, discard local commits`); + } + + console.log(color.dim(' ─────────────────')); + console.log(` ${color.cyan('g fix --ai')} Get AI recommendation`); + + } catch (e) { + handleError('Fix error', e); + } + }); + +program.command('conflicts') + .description('Show and help resolve merge conflicts') + .action(async () => { + await ensureRepo(); + try { + const status = await git.status(); + const conflicts = status.conflicted || []; + + if (conflicts.length === 0) { + Progress.success('No merge conflicts!'); + return; + } + + console.log(color.bold(`\n⚠️ ${conflicts.length} Conflicted File(s):\n`)); + conflicts.forEach((file, i) => { + const emoji = Intelligence.getFileEmoji(file); + console.log(` ${i + 1}. ${emoji} ${color.red(file)}`); + }); + + console.log(color.bold('\nπŸ”§ How to resolve:\n')); + console.log(` 1. Edit each file and resolve the conflict markers`); + console.log(` ${color.dim('<<<<<<< HEAD')}`); + console.log(` ${color.dim('your changes')}`); + console.log(` ${color.dim('=======')}`); + console.log(` ${color.dim('their changes')}`); + console.log(` ${color.dim('>>>>>>> branch')}`); + console.log(` 2. Stage resolved files: ${color.cyan('git add ')}`); + console.log(` 3. Complete the merge: ${color.cyan('g o')} or ${color.cyan('git merge --continue')}`); + + console.log(color.bold('\n⚑ Quick options:\n')); + console.log(` ${color.cyan('git checkout --ours ')} Keep YOUR version`); + console.log(` ${color.cyan('git checkout --theirs ')} Keep THEIR version`); + + } catch (e) { + handleError('Conflicts error', e); } }); program.parse(process.argv); + diff --git a/bin/lib/ai/providers.js b/bin/lib/ai/providers.js index c11e43d..c7a42cb 100644 --- a/bin/lib/ai/providers.js +++ b/bin/lib/ai/providers.js @@ -18,7 +18,7 @@ class AIProviderManager { if (preference === 'openai') return process.env.OPENAI_API_KEY ? 'openai' : 'none'; if (preference === 'gemini') return process.env.GEMINI_API_KEY ? 'gemini' : 'none'; if (preference === 'groq') return process.env.GROQ_API_KEY ? 'groq' : 'none'; - + // Auto-detection with preference order (Gemini first - fastest and cheapest) if (process.env.GEMINI_API_KEY) return 'gemini'; if (process.env.OPENAI_API_KEY) return 'openai'; @@ -48,12 +48,12 @@ class AIProviderManager { setCache(cacheKey, result, usedLocal = false) { if (!this.config.cacheEnabled) return; - + if (this.cache.size >= this.maxCacheSize) { const firstKey = this.cache.keys().next().value; this.cache.delete(firstKey); } - + this.cache.set(cacheKey, { result, usedLocal, @@ -103,9 +103,9 @@ class AIProviderManager { } async generateWithGroq(prompt, model, options) { - const groq = new OpenAI({ - apiKey: process.env.GROQ_API_KEY, - baseURL: process.env.GROQ_BASE_URL || 'https://api.groq.com/openai/v1' + const groq = new OpenAI({ + apiKey: process.env.GROQ_API_KEY, + baseURL: process.env.GROQ_BASE_URL || 'https://api.groq.com/openai/v1' }); const actualModel = model || this.getDefaultModel('groq'); const response = await groq.chat.completions.create({ @@ -118,11 +118,11 @@ class AIProviderManager { } async generateCommitMessage(diff, options = {}) { - const { - provider: preferredProvider = 'auto', - conventional = false, + const { + provider: preferredProvider = 'auto', + conventional = false, body = false, - verbose = false + verbose = false } = options; // Check cache first @@ -134,11 +134,11 @@ class AIProviderManager { } const providerChain = this.buildProviderChain(preferredProvider); - + for (const provider of providerChain) { try { if (verbose) Progress.info(`Trying provider: ${provider}`); - + if (provider === 'local') { const result = await this.generateLocalHeuristic(diff, options); this.setCache(cacheKey, result, true); @@ -148,10 +148,10 @@ class AIProviderManager { const prompt = this.buildPrompt(diff, { conventional, body }); const result = await this.generateWithProvider(provider, prompt, options); const cleaned = this.cleanCommitMessage(result, { body }); - + this.setCache(cacheKey, cleaned, false); return { message: cleaned, usedLocal: false }; - + } catch (error) { if (verbose) Progress.warning(`${provider} failed: ${error.message}`); continue; @@ -166,7 +166,7 @@ class AIProviderManager { buildProviderChain(preferred) { const available = []; - + if (preferred !== 'auto' && preferred !== 'none') { const resolved = this.resolveProvider(preferred); if (resolved !== 'none') available.push(resolved); @@ -175,28 +175,28 @@ class AIProviderManager { if (process.env.OPENAI_API_KEY) available.push('openai'); if (process.env.GROQ_API_KEY) available.push('groq'); } - + available.push('local'); return [...new Set(available)]; } buildPrompt(diff, options) { const { conventional, body } = options; - - const style = conventional + + const style = conventional ? 'Use Conventional Commits format (e.g., feat:, fix:, chore:) for the subject.' : 'Subject must be a single short line.'; - - const bodyInstr = body + + const bodyInstr = body ? 'Provide a short subject line followed by an optional body separated by a blank line.' : 'Return only a short subject line without extra quotes.'; - + return `Write a concise git commit message for these changes:\n${diff}\n\n${style} ${bodyInstr}`; } cleanCommitMessage(message, options = {}) { if (!message) return 'Update project code'; - + // Remove markdown formatting let cleaned = message .replace(/```[\s\S]*?```/g, '') @@ -212,7 +212,7 @@ class AIProviderManager { const lines = cleaned.split('\n').map(l => l.trim()).filter(Boolean); let subject = (lines[0] || '').replace(/\s{2,}/g, ' ').replace(/[\s:,.!;]+$/g, '').trim(); - + if (subject.length === 0) subject = 'Update project code'; // No length restriction - allow AI to generate full commit messages @@ -226,16 +226,16 @@ class AIProviderManager { async generateLocalHeuristic(diff, options) { // This would need access to git status - simplified version const { conventional = false } = options; - + // Analyze diff for patterns const lines = diff.split('\n'); const additions = lines.filter(l => l.startsWith('+')).length; const deletions = lines.filter(l => l.startsWith('-')).length; const files = (diff.match(/diff --git/g) || []).length; - + let type = 'chore'; let subject = 'update files'; - + if (additions > deletions * 2) { type = 'feat'; subject = files === 1 ? 'add new functionality' : `add features to ${files} files`; @@ -249,37 +249,39 @@ class AIProviderManager { type = 'docs'; subject = 'update documentation'; } - + return conventional ? `${type}: ${subject}` : subject.charAt(0).toUpperCase() + subject.slice(1); } async generateMultipleSuggestions(diff, options = {}, count = 3) { const suggestions = []; const baseOptions = { ...options }; - + // Generate different styles const variants = [ { ...baseOptions, conventional: false }, { ...baseOptions, conventional: true }, { ...baseOptions, conventional: true, body: true } ]; - + for (let i = 0; i < Math.min(count, variants.length); i++) { try { - const suggestion = await this.generateCommitMessage(diff, variants[i]); - if (!suggestions.includes(suggestion)) { - suggestions.push(suggestion); + const result = await this.generateCommitMessage(diff, variants[i]); + // Extract message string from result object + const message = result.message || result; + if (message && !suggestions.includes(message)) { + suggestions.push(message); } } catch (error) { // Skip failed generations } } - + // Ensure we have at least one suggestion if (suggestions.length === 0) { suggestions.push('Update project files'); } - + return suggestions; } } diff --git a/bin/lib/git/analyzer.js b/bin/lib/git/analyzer.js index a0f1828..c41efd5 100644 --- a/bin/lib/git/analyzer.js +++ b/bin/lib/git/analyzer.js @@ -1,4 +1,5 @@ const { color } = require('../utils/colors'); +const { Intelligence } = require('../utils/intelligence'); /** * Enhanced git analysis and insights @@ -6,17 +7,22 @@ const { color } = require('../utils/colors'); class GitAnalyzer { constructor(git) { this.git = git; + this.intelligence = new Intelligence(git); } async getEnhancedStatus() { try { const status = await this.git.status(); const insights = await this.generateStatusInsights(status); - + const sessionStats = await this.intelligence.getSessionStats(); + const branchContext = await this.intelligence.detectBranchContext(); + return { ...status, insights, - summary: this.generateStatusSummary(status) + summary: this.generateStatusSummary(status), + sessionStats, + branchContext, }; } catch (error) { throw new Error(`Failed to get git status: ${error.message}`); @@ -32,47 +38,48 @@ class GitAnalyzer { const untracked = Array.isArray(status.not_added) ? status.not_added : []; if (files.length === 0) return 'Working tree clean'; - + const parts = []; if (staged.length > 0) parts.push(`${staged.length} staged`); if (modified.length > 0) parts.push(`${modified.length} modified`); if (created.length > 0) parts.push(`${created.length} new`); if (deleted.length > 0) parts.push(`${deleted.length} deleted`); if (untracked.length > 0) parts.push(`${untracked.length} untracked`); - + return parts.join(', '); } async generateStatusInsights(status) { const insights = []; - + // Ensure arrays exist and have proper methods const modified = Array.isArray(status.modified) ? status.modified : []; const created = Array.isArray(status.created) ? status.created : []; const deleted = Array.isArray(status.deleted) ? status.deleted : []; const files = Array.isArray(status.files) ? status.files : []; - + const staged = Array.isArray(status.staged) ? status.staged : []; + // Check for common patterns if (modified.some(f => String(f).includes('package.json'))) { insights.push('πŸ“¦ Dependencies may have changed - consider updating package-lock.json'); } - + if (created.some(f => String(f).includes('.env'))) { insights.push('πŸ” New environment file detected - ensure it\'s in .gitignore'); } - + if (modified.some(f => String(f).includes('README'))) { insights.push('πŸ“š Documentation updated - good practice!'); } - + if (deleted.length > created.length + modified.length) { insights.push('🧹 Cleanup operation detected - removing more than adding'); } - + if (files.length > 20) { - insights.push('πŸ“Š Large changeset - consider breaking into smaller commits'); + insights.push('πŸ“Š Large changeset - consider using `g split` to break into smaller commits'); } - + // Check for test files const testFiles = files.filter(f => { const fileName = String(f); @@ -81,7 +88,7 @@ class GitAnalyzer { if (testFiles.length > 0) { insights.push('πŸ§ͺ Test files modified - great for code quality!'); } - + // Check for config files const configFiles = files.filter(f => { const fileName = String(f); @@ -90,7 +97,12 @@ class GitAnalyzer { if (configFiles.length > 0) { insights.push('βš™οΈ Configuration changes detected'); } - + + // Suggest staging if nothing staged + if (staged.length === 0 && files.length > 0) { + insights.push('πŸ’‘ Nothing staged yet - use `g o --all` to stage and commit everything'); + } + return insights; } @@ -98,7 +110,11 @@ class GitAnalyzer { try { const log = await this.git.log({ maxCount: limit }); const commits = log.all; - + + if (commits.length === 0) { + return { totalCommits: 0 }; + } + const analysis = { totalCommits: commits.length, authors: [...new Set(commits.map(c => c.author_name))], @@ -106,7 +122,7 @@ class GitAnalyzer { conventionalCommits: commits.filter(c => /^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?:/.test(c.message)).length, recentActivity: this.analyzeRecentActivity(commits) }; - + return analysis; } catch (error) { return { error: error.message }; @@ -117,10 +133,10 @@ class GitAnalyzer { const now = new Date(); const oneDayAgo = new Date(now - 24 * 60 * 60 * 1000); const oneWeekAgo = new Date(now - 7 * 24 * 60 * 60 * 1000); - + const recentCommits = commits.filter(c => new Date(c.date) > oneDayAgo); const weeklyCommits = commits.filter(c => new Date(c.date) > oneWeekAgo); - + return { last24h: recentCommits.length, lastWeek: weeklyCommits.length, @@ -133,16 +149,20 @@ class GitAnalyzer { const additions = lines.filter(l => l.startsWith('+')).length; const deletions = lines.filter(l => l.startsWith('-')).length; const files = (diff.match(/diff --git/g) || []).length; - + let complexity = 'simple'; + let emoji = '🟒'; if (files > 10 || additions + deletions > 500) { complexity = 'complex'; + emoji = 'πŸ”΄'; } else if (files > 5 || additions + deletions > 100) { complexity = 'moderate'; + emoji = '🟑'; } - + return { complexity, + emoji, files, additions, deletions, @@ -151,81 +171,132 @@ class GitAnalyzer { } formatStatusOutput(enhancedStatus) { - const { - files = [], - staged = [], - modified = [], - created = [], - deleted = [], - not_added = [], - insights = [], - summary = 'Unknown status' + const { + files = [], + staged = [], + modified = [], + created = [], + deleted = [], + not_added = [], + insights = [], + summary = 'Unknown status', + sessionStats = {}, + branchContext = {}, } = enhancedStatus; - + let output = ''; - - // Header - output += `${color.bold('Git Status')}\n`; - output += `${color.dim(summary)}\n\n`; - + + // Header with branch info + output += `${color.bold('Git Status')}`; + if (branchContext.branch) { + output += ` ${color.dim('on')} ${color.cyan(branchContext.branch)}`; + if (branchContext.type) { + output += ` ${color.dim(`(${branchContext.type})`)}`; + } + } + output += '\n'; + output += `${color.dim(summary)}\n`; + + // Session stats + if (sessionStats.timeSinceLastCommit) { + output += `${color.dim(`Last commit: ${sessionStats.timeSinceLastCommit}`)}`; + if (sessionStats.commitsToday > 0) { + output += `${color.dim(` β€’ ${sessionStats.commitsToday} commit${sessionStats.commitsToday > 1 ? 's' : ''} today`)}`; + } + output += '\n'; + } + output += '\n'; + // Staged changes if (staged.length > 0) { output += `${color.green('Staged for commit:')}\n`; staged.forEach(file => { - output += ` ${color.green('+')} ${file}\n`; + const emoji = Intelligence.getFileEmoji(file); + output += ` ${color.green('+')} ${emoji} ${file}\n`; }); output += '\n'; } - + // Modified files if (modified.length > 0) { output += `${color.yellow('Modified (not staged):')}\n`; modified.forEach(file => { - output += ` ${color.yellow('M')} ${file}\n`; + const emoji = Intelligence.getFileEmoji(file); + output += ` ${color.yellow('M')} ${emoji} ${file}\n`; }); output += '\n'; } - + // New files if (created.length > 0) { output += `${color.cyan('New files:')}\n`; created.forEach(file => { - output += ` ${color.cyan('N')} ${file}\n`; + const emoji = Intelligence.getFileEmoji(file); + output += ` ${color.cyan('N')} ${emoji} ${file}\n`; }); output += '\n'; } - + // Deleted files if (deleted.length > 0) { output += `${color.red('Deleted:')}\n`; deleted.forEach(file => { - output += ` ${color.red('D')} ${file}\n`; + const emoji = Intelligence.getFileEmoji(file); + output += ` ${color.red('D')} ${emoji} ${file}\n`; }); output += '\n'; } - + // Untracked files if (not_added.length > 0) { output += `${color.dim('Untracked files:')}\n`; not_added.slice(0, 10).forEach(file => { - output += ` ${color.dim('?')} ${file}\n`; + const emoji = Intelligence.getFileEmoji(file); + output += ` ${color.dim('?')} ${emoji} ${file}\n`; }); if (not_added.length > 10) { output += ` ${color.dim(`... and ${not_added.length - 10} more`)}\n`; } output += '\n'; } - + // AI Insights if (insights.length > 0) { - output += `${color.cyan('πŸ’‘ AI Insights:')}\n`; + output += `${color.cyan('πŸ’‘ Insights:')}\n`; insights.forEach(insight => { output += ` ${insight}\n`; }); } - + return output; } + + /** + * Get today's commits formatted nicely + */ + async getTodayCommits() { + try { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const log = await this.git.log({ '--since': today.toISOString() }); + return log.all; + } catch (error) { + return []; + } + } + + /** + * Format commit for display + */ + formatCommit(commit, index) { + const hash = color.yellow(commit.hash.substring(0, 7)); + const message = commit.message.split('\n')[0]; + const time = new Date(commit.date).toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit' + }); + return ` ${color.dim(`${index + 1}.`)} ${hash} ${message} ${color.dim(`(${time})`)}`; + } } module.exports = { GitAnalyzer }; \ No newline at end of file diff --git a/bin/lib/utils/colors.js b/bin/lib/utils/colors.js index 97e8176..197445b 100644 --- a/bin/lib/utils/colors.js +++ b/bin/lib/utils/colors.js @@ -8,8 +8,23 @@ const color = { cyan: (s) => `\x1b[36m${s}\x1b[0m`, blue: (s) => `\x1b[34m${s}\x1b[0m`, magenta: (s) => `\x1b[35m${s}\x1b[0m`, + white: (s) => `\x1b[37m${s}\x1b[0m`, + gray: (s) => `\x1b[90m${s}\x1b[0m`, bold: (s) => `\x1b[1m${s}\x1b[0m`, dim: (s) => `\x1b[2m${s}\x1b[0m`, + italic: (s) => `\x1b[3m${s}\x1b[0m`, + underline: (s) => `\x1b[4m${s}\x1b[0m`, + // Background colors + bgGreen: (s) => `\x1b[42m\x1b[30m${s}\x1b[0m`, + bgYellow: (s) => `\x1b[43m\x1b[30m${s}\x1b[0m`, + bgRed: (s) => `\x1b[41m\x1b[37m${s}\x1b[0m`, + bgCyan: (s) => `\x1b[46m\x1b[30m${s}\x1b[0m`, + bgBlue: (s) => `\x1b[44m\x1b[37m${s}\x1b[0m`, + // Compound styles + success: (s) => `\x1b[32mβœ“\x1b[0m ${s}`, + warning: (s) => `\x1b[33m⚠\x1b[0m ${s}`, + error: (s) => `\x1b[31mβœ—\x1b[0m ${s}`, + info: (s) => `\x1b[36mβ„Ή\x1b[0m ${s}`, reset: '\x1b[0m' }; diff --git a/bin/lib/utils/intelligence.js b/bin/lib/utils/intelligence.js new file mode 100644 index 0000000..723c2ae --- /dev/null +++ b/bin/lib/utils/intelligence.js @@ -0,0 +1,421 @@ +const fs = require('fs'); +const path = require('path'); + +/** + * Intelligence utilities for smart git operations + */ +class Intelligence { + constructor(git) { + this.git = git; + } + + /** + * Get file type emoji based on file extension + */ + static getFileEmoji(filename) { + const ext = path.extname(filename).toLowerCase(); + const name = path.basename(filename).toLowerCase(); + + // Special files + if (name === 'package.json' || name === 'package-lock.json') return 'πŸ“¦'; + if (name === 'readme.md' || name === 'readme') return 'πŸ“š'; + if (name === '.gitignore') return 'πŸ™ˆ'; + if (name === '.env' || name.startsWith('.env.')) return 'πŸ”'; + if (name === 'dockerfile' || name === 'docker-compose.yml') return '🐳'; + if (name.includes('config') || name.includes('rc')) return 'βš™οΈ'; + if (name === 'license' || name === 'license.md') return 'πŸ“œ'; + + // Test files + if (filename.includes('.test.') || filename.includes('.spec.') || filename.includes('__tests__')) return 'πŸ§ͺ'; + + // By extension + const emojiMap = { + '.js': 'πŸ“„', '.jsx': 'βš›οΈ', '.ts': 'πŸ“˜', '.tsx': 'βš›οΈ', + '.css': '🎨', '.scss': '🎨', '.sass': '🎨', '.less': '🎨', + '.html': '🌐', '.htm': '🌐', + '.json': 'πŸ“‹', '.yaml': 'πŸ“‹', '.yml': 'πŸ“‹', '.toml': 'πŸ“‹', + '.md': 'πŸ“', '.mdx': 'πŸ“', '.txt': 'πŸ“„', + '.py': '🐍', '.rb': 'πŸ’Ž', '.go': 'πŸ”·', '.rs': 'πŸ¦€', '.java': 'β˜•', + '.sh': '🐚', '.bash': '🐚', '.zsh': '🐚', + '.sql': 'πŸ—ƒοΈ', '.graphql': 'πŸ”—', '.gql': 'πŸ”—', + '.png': 'πŸ–ΌοΈ', '.jpg': 'πŸ–ΌοΈ', '.jpeg': 'πŸ–ΌοΈ', '.gif': 'πŸ–ΌοΈ', '.svg': '🎯', + '.mp3': '🎡', '.wav': '🎡', '.mp4': '🎬', '.mov': '🎬', + '.zip': 'πŸ“¦', '.tar': 'πŸ“¦', '.gz': 'πŸ“¦', + '.lock': 'πŸ”’', + }; + + return emojiMap[ext] || 'πŸ“„'; + } + + /** + * Analyze user's commit patterns from history + */ + async analyzeCommitPatterns(limit = 50) { + try { + const log = await this.git.log({ maxCount: limit }); + const commits = log.all; + + if (commits.length === 0) { + return { hasHistory: false }; + } + + // Analyze patterns + const conventionalPattern = /^(feat|fix|docs|style|refactor|test|chore|perf|build|ci|revert)(\(.+\))?:/i; + const conventionalCommits = commits.filter(c => conventionalPattern.test(c.message)); + + // Calculate average message length + const avgLength = Math.round(commits.reduce((sum, c) => sum + c.message.split('\n')[0].length, 0) / commits.length); + + // Detect common scopes + const scopes = {}; + commits.forEach(c => { + const match = c.message.match(/^\w+\(([^)]+)\)/); + if (match) { + scopes[match[1]] = (scopes[match[1]] || 0) + 1; + } + }); + const topScopes = Object.entries(scopes) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([scope]) => scope); + + // Detect if user uses emojis + const emojiPattern = /[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]/u; + const usesEmojis = commits.some(c => emojiPattern.test(c.message)); + + // Detect commit message style (imperative vs past tense) + const imperativeWords = ['add', 'fix', 'update', 'remove', 'change', 'implement', 'refactor']; + const pastTenseWords = ['added', 'fixed', 'updated', 'removed', 'changed', 'implemented', 'refactored']; + + let imperativeCount = 0; + let pastTenseCount = 0; + commits.forEach(c => { + const firstWord = c.message.split(/[\s(:]/)[0].toLowerCase(); + if (imperativeWords.includes(firstWord)) imperativeCount++; + if (pastTenseWords.includes(firstWord)) pastTenseCount++; + }); + + return { + hasHistory: true, + totalAnalyzed: commits.length, + conventionalRatio: (conventionalCommits.length / commits.length * 100).toFixed(0), + usesConventional: conventionalCommits.length > commits.length * 0.5, + avgMessageLength: avgLength, + topScopes, + usesEmojis, + style: imperativeCount >= pastTenseCount ? 'imperative' : 'past-tense', + authors: [...new Set(commits.map(c => c.author_name))], + }; + } catch (error) { + return { hasHistory: false, error: error.message }; + } + } + + /** + * Detect semantic meaning of changes + */ + async detectSemanticChanges(diff) { + const changes = { + type: 'misc', + labels: [], + suggestions: [], + breakingChange: false, + }; + + if (!diff || !diff.trim()) return changes; + + const lines = diff.split('\n'); + const addedLines = lines.filter(l => l.startsWith('+') && !l.startsWith('+++')); + const removedLines = lines.filter(l => l.startsWith('-') && !l.startsWith('---')); + + // Detect breaking changes + if (removedLines.some(l => l.includes('export ') && l.includes('function ')) || + removedLines.some(l => l.includes('module.exports'))) { + changes.labels.push('⚠️ Potential breaking change'); + changes.breakingChange = true; + } + + // Detect new features + if (addedLines.some(l => l.includes('export ') && l.includes('function ')) || + addedLines.some(l => l.includes('class ')) || + addedLines.some(l => l.includes('async function '))) { + changes.labels.push('✨ New functionality'); + changes.type = 'feat'; + } + + // Detect bug fixes + if (addedLines.some(l => l.includes('catch') || l.includes('try {')) || + addedLines.some(l => l.includes('|| null') || l.includes('?? ') || l.includes('?.'))) { + changes.labels.push('πŸ› Error handling'); + if (changes.type === 'misc') changes.type = 'fix'; + } + + // Detect refactoring + if (addedLines.length > 20 && removedLines.length > 20 && + Math.abs(addedLines.length - removedLines.length) < 10) { + changes.labels.push('♻️ Refactoring'); + if (changes.type === 'misc') changes.type = 'refactor'; + } + + // Detect documentation + if (addedLines.some(l => l.includes('/**') || l.includes('* @') || l.includes('// '))) { + changes.labels.push('πŸ“ Documentation'); + if (changes.type === 'misc') changes.type = 'docs'; + } + + // Detect test changes + if (diff.includes('.test.') || diff.includes('.spec.') || + addedLines.some(l => l.includes('describe(') || l.includes('it(') || l.includes('test('))) { + changes.labels.push('πŸ§ͺ Tests'); + if (changes.type === 'misc') changes.type = 'test'; + } + + // Detect dependency changes + if (diff.includes('package.json') && (diff.includes('"dependencies"') || diff.includes('"devDependencies"'))) { + changes.labels.push('πŸ“¦ Dependencies'); + } + + // Detect style/formatting + if (addedLines.every(l => l.trim().length < 3 || l.includes(' ') || l === '+')) { + changes.labels.push('🎨 Formatting'); + if (changes.type === 'misc') changes.type = 'style'; + } + + return changes; + } + + /** + * Suggest how to split a large changeset + */ + async suggestCommitSplit(status) { + const files = status.files || []; + if (files.length < 5) return null; + + const groups = { + tests: [], + config: [], + docs: [], + styles: [], + core: [], + }; + + files.forEach(file => { + const f = typeof file === 'string' ? file : file.path; + if (!f) return; + + if (f.includes('.test.') || f.includes('.spec.') || f.includes('__tests__')) { + groups.tests.push(f); + } else if (f.includes('config') || f.endsWith('.json') || f.endsWith('.yml') || f.endsWith('.yaml') || f.includes('rc')) { + groups.config.push(f); + } else if (f.endsWith('.md') || f.includes('docs/') || f.includes('README')) { + groups.docs.push(f); + } else if (f.endsWith('.css') || f.endsWith('.scss') || f.endsWith('.sass')) { + groups.styles.push(f); + } else { + groups.core.push(f); + } + }); + + const suggestions = []; + if (groups.tests.length > 0) suggestions.push({ type: 'test', files: groups.tests, message: 'test: add/update tests' }); + if (groups.config.length > 0) suggestions.push({ type: 'chore', files: groups.config, message: 'chore: update configuration' }); + if (groups.docs.length > 0) suggestions.push({ type: 'docs', files: groups.docs, message: 'docs: update documentation' }); + if (groups.styles.length > 0) suggestions.push({ type: 'style', files: groups.styles, message: 'style: update styles' }); + if (groups.core.length > 0) suggestions.push({ type: 'feat', files: groups.core, message: 'feat: update core functionality' }); + + return suggestions.length > 1 ? suggestions : null; + } + + /** + * Get session statistics + */ + async getSessionStats() { + try { + const log = await this.git.log({ maxCount: 1 }); + const lastCommit = log.all[0]; + + let timeSinceLastCommit = 'No commits yet'; + let lastCommitMessage = null; + + if (lastCommit) { + const lastCommitDate = new Date(lastCommit.date); + const now = new Date(); + const diffMs = now - lastCommitDate; + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffDays > 0) { + timeSinceLastCommit = `${diffDays} day${diffDays > 1 ? 's' : ''} ago`; + } else if (diffHours > 0) { + timeSinceLastCommit = `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`; + } else if (diffMins > 0) { + timeSinceLastCommit = `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`; + } else { + timeSinceLastCommit = 'Just now'; + } + + lastCommitMessage = lastCommit.message.split('\n')[0]; + } + + // Get today's commits + const today = new Date(); + today.setHours(0, 0, 0, 0); + const todayLog = await this.git.log({ '--since': today.toISOString() }); + const todayCommits = todayLog.all.length; + + return { + timeSinceLastCommit, + lastCommitMessage, + commitsToday: todayCommits, + }; + } catch (error) { + return { + timeSinceLastCommit: 'Unknown', + lastCommitMessage: null, + commitsToday: 0, + }; + } + } + + /** + * Detect branch context for smarter commit messages + */ + async detectBranchContext() { + try { + const branch = await this.git.branch(); + const currentBranch = branch.current; + + // Common patterns: feat/xyz, fix/xyz, feature/xyz, bugfix/xyz, hotfix/xyz + const patterns = [ + { regex: /^feat(?:ure)?\/(.+)$/i, type: 'feat', scope: null }, + { regex: /^fix\/(.+)$/i, type: 'fix', scope: null }, + { regex: /^bugfix\/(.+)$/i, type: 'fix', scope: null }, + { regex: /^hotfix\/(.+)$/i, type: 'fix', scope: null }, + { regex: /^docs?\/(.+)$/i, type: 'docs', scope: null }, + { regex: /^refactor\/(.+)$/i, type: 'refactor', scope: null }, + { regex: /^test\/(.+)$/i, type: 'test', scope: null }, + { regex: /^chore\/(.+)$/i, type: 'chore', scope: null }, + { regex: /^style\/(.+)$/i, type: 'style', scope: null }, + ]; + + for (const pattern of patterns) { + const match = currentBranch.match(pattern.regex); + if (match) { + // Extract potential scope and description from branch name + const branchPart = match[1].replace(/-/g, ' ').replace(/_/g, ' '); + return { + branch: currentBranch, + type: pattern.type, + description: branchPart, + detected: true, + }; + } + } + + // Check for issue references like PROJ-123 or #123 + const issueMatch = currentBranch.match(/([A-Z]+-\d+|#\d+)/i); + if (issueMatch) { + return { + branch: currentBranch, + issueRef: issueMatch[1], + detected: true, + }; + } + + return { + branch: currentBranch, + detected: false, + }; + } catch (error) { + return { branch: 'unknown', detected: false }; + } + } + + /** + * Get commit statistics for the user + */ + async getCommitStats(days = 30) { + try { + const since = new Date(); + since.setDate(since.getDate() - days); + + const log = await this.git.log({ '--since': since.toISOString() }); + const commits = log.all; + + if (commits.length === 0) { + return { totalCommits: 0, hasData: false }; + } + + // Group by day + const byDay = {}; + commits.forEach(c => { + const day = new Date(c.date).toLocaleDateString(); + byDay[day] = (byDay[day] || 0) + 1; + }); + + // Calculate streaks + const sortedDays = Object.keys(byDay).sort((a, b) => new Date(b) - new Date(a)); + let currentStreak = 0; + let longestStreak = 0; + let tempStreak = 0; + + const today = new Date().toLocaleDateString(); + const yesterday = new Date(Date.now() - 86400000).toLocaleDateString(); + + // Check if today or yesterday has commits + if (byDay[today] || byDay[yesterday]) { + sortedDays.forEach((day, i) => { + if (i === 0) { + tempStreak = 1; + } else { + const prevDate = new Date(sortedDays[i - 1]); + const currDate = new Date(day); + const diffDays = Math.round((prevDate - currDate) / 86400000); + if (diffDays === 1) { + tempStreak++; + } else { + if (tempStreak > longestStreak) longestStreak = tempStreak; + tempStreak = 1; + } + } + }); + if (tempStreak > longestStreak) longestStreak = tempStreak; + currentStreak = byDay[today] ? tempStreak : (byDay[yesterday] ? tempStreak : 0); + } + + // Commit types breakdown + const typeBreakdown = { feat: 0, fix: 0, docs: 0, style: 0, refactor: 0, test: 0, chore: 0, other: 0 }; + const conventionalPattern = /^(feat|fix|docs|style|refactor|test|chore|perf|build|ci|revert)/i; + + commits.forEach(c => { + const match = c.message.match(conventionalPattern); + if (match) { + const type = match[1].toLowerCase(); + if (typeBreakdown.hasOwnProperty(type)) { + typeBreakdown[type]++; + } else { + typeBreakdown.other++; + } + } else { + typeBreakdown.other++; + } + }); + + return { + hasData: true, + totalCommits: commits.length, + daysActive: Object.keys(byDay).length, + avgPerDay: (commits.length / days).toFixed(1), + currentStreak, + longestStreak, + typeBreakdown, + topDay: Object.entries(byDay).sort((a, b) => b[1] - a[1])[0], + }; + } catch (error) { + return { hasData: false, error: error.message }; + } + } +} + +module.exports = { Intelligence }; diff --git a/bin/lib/utils/progress.js b/bin/lib/utils/progress.js index a9e6ceb..a65ba01 100644 --- a/bin/lib/utils/progress.js +++ b/bin/lib/utils/progress.js @@ -7,8 +7,11 @@ class Progress { static spinner = ['β ‹', 'β ™', 'β Ή', 'β Έ', 'β Ό', 'β ΄', 'β ¦', 'β §', 'β ‡', '⠏']; static current = 0; static interval = null; + static startTime = null; static start(message) { + this.startTime = Date.now(); + this.lastMessage = message; process.stdout.write(`${message} ${this.spinner[0]}`); this.interval = setInterval(() => { this.current = (this.current + 1) % this.spinner.length; @@ -21,7 +24,25 @@ class Progress { clearInterval(this.interval); this.interval = null; } - process.stdout.write(`\r${finalMessage}\n`); + const elapsed = this.getElapsed(); + const elapsedStr = elapsed ? ` ${color.dim(`(${elapsed})`)}` : ''; + // Clear the entire line before writing final message + const clearLine = '\r\x1b[K'; // Carriage return + clear to end of line + if (finalMessage) { + process.stdout.write(`${clearLine}${finalMessage}${elapsedStr}\n`); + } else { + // Just clear the spinner line completely + process.stdout.write(`${clearLine}`); + } + this.startTime = null; + this.lastMessage = null; + } + + static getElapsed() { + if (!this.startTime) return null; + const ms = Date.now() - this.startTime; + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; } static success(message) { @@ -44,6 +65,54 @@ class Progress { const progress = `[${step}/${total}]`; console.log(`${color.dim(progress)} ${message}`); } + + static cached(message) { + console.log(`${color.magenta('⚑')} ${message} ${color.dim('(cached)')}`); + } + + static tip(message) { + console.log(`${color.blue('πŸ’‘')} ${color.dim('Tip:')} ${message}`); + } + + static box(title, lines) { + const maxLen = Math.max(title.length, ...lines.map(l => l.replace(/\x1b\[[0-9;]*m/g, '').length)); + const top = `β”Œ${'─'.repeat(maxLen + 2)}┐`; + const bottom = `β””${'─'.repeat(maxLen + 2)}β”˜`; + const titleLine = `β”‚ ${color.bold(title.padEnd(maxLen))} β”‚`; + + console.log(top); + console.log(titleLine); + console.log(`β”œ${'─'.repeat(maxLen + 2)}─`); + lines.forEach(line => { + const plainLen = line.replace(/\x1b\[[0-9;]*m/g, '').length; + const padding = ' '.repeat(maxLen - plainLen); + console.log(`β”‚ ${line}${padding} β”‚`); + }); + console.log(bottom); + } + + // Random tips for contextual help + static tips = [ + 'Use `g int` for interactive commit wizard with multiple AI suggestions', + 'Use `g o` to commit and push in one command', + 'Use `g sg --multiple` to get 3 different commit message suggestions', + 'Configure your preferences with `g config --set key=value`', + 'Use `--all` flag to auto-stage all changes', + 'Use `g a` to amend your last commit', + 'Use `g sync --rebase` for cleaner history', + 'Set GEMINI_API_KEY for free AI-powered commits', + 'Use `g wip` for quick work-in-progress commits', + 'Use `g stats` to see your commit statistics', + ]; + + static showRandomTip() { + const shouldShow = Math.random() < 0.15; // 15% chance + if (shouldShow) { + const tip = this.tips[Math.floor(Math.random() * this.tips.length)]; + console.log(); + this.tip(tip); + } + } } module.exports = { Progress }; \ No newline at end of file diff --git a/package.json b/package.json index 1ee85f5..bdc06e1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gims", - "version": "0.6.7", - "description": "Git Made Simple – AI‑powered git helper using Gemini / OpenAI", + "version": "0.8.1", + "description": "Git Made Simple – AI‑powered git helper with smart insights, stats & code review", "author": "S41R4J", "license": "MIT", "bin": { @@ -43,4 +43,4 @@ "test": "echo \"Enhanced GIMS v$(node -p \"require('./package.json').version\") - All systems operational!\"", "postinstall": "echo \"πŸš€ GIMS installed! Quick start: 'g setup --api-key gemini' then 'g s' to see status. Full help: 'g --help'\"" } -} +} \ No newline at end of file