diff --git a/README.md b/README.md index 711c0e2..47f1434 100644 --- a/README.md +++ b/README.md @@ -34,28 +34,29 @@ g o # AI analyzes changes, commits with perfect message, and pushes! ## 🌟 Features ### πŸ€– **AI-Powered Commit Messages** -- **OpenAI GPT-4** integration for intelligent commit message generation -- **Google Gemini** support for lightning-fast analysis +- OpenAI, Google Gemini, and Groq support with automatic provider selection - Smart diff analysis that understands your code changes -- Handles large codebases with intelligent summarization +- Handles large codebases with intelligent summarization and safe truncation +- Optional Conventional Commits formatting and optional commit body generation (`--conventional`, `--body`) ### ⚑ **Lightning Fast Workflow** -- **One command commits**: `g o` - analyze, commit, and push in seconds -- **Smart suggestions**: `g s` - get AI-generated messages copied to clipboard -- **Local commits**: `g l` - commit locally with AI messages -- **Instant setup**: `g i` - initialize repos in a flash +- One command commits: `g o` - analyze, commit, and push +- Smart suggestions: `g s` - get AI-generated messages copied to clipboard +- Local commits: `g l` - commit locally with AI messages +- Staged-only by default for suggestions for precise control (use `--all` to stage everything) ### 🧠 **Intelligent Code Analysis** - Analyzes actual code changes, not just file names - Understands context from function changes, imports, and logic -- Handles everything from bug fixes to feature additions -- Graceful fallbacks for extremely large changesets +- Graceful fallbacks for extremely large changesets and offline use ### πŸ› οΈ **Developer-Friendly** -- **Numbered commit history**: Easy navigation with `g ls` -- **Smart branching**: `g b 5` creates branch from commit #5 -- **Safe operations**: Built-in error handling and validation -- **Clean interface**: Intuitive commands that just make sense +- Numbered commit history with `g ls` / `g ll` and index-aware commands +- Smart branching: `g b 5` creates branch from commit #5 +- Safe operations with confirmations and dry-run support +- JSON output for editor integrations +- Quality-of-life: `--amend`, `undo` command, and automatic upstream setup on push +- Manual commit command for custom messages: `g m "your message"` ## πŸš€ Quick Start @@ -67,16 +68,25 @@ npm install -g gims ### Setup AI (Choose One) -**Option 1: OpenAI (Recommended)** +**Option 1: OpenAI** ```bash export OPENAI_API_KEY="your-api-key-here" ``` -**Option 2: Google Gemini (Faster)** +**Option 2: Google Gemini** ```bash export GEMINI_API_KEY="your-api-key-here" ``` +**Option 3: Groq** +```bash +export GROQ_API_KEY="your-api-key-here" +# Optional, if self-hosting/proxying +export GROQ_BASE_URL="https://api.groq.com/openai/v1" +``` + +GIMS auto-detects configured providers. If none are configured, it uses a local heuristic to generate sensible messages. + ### Your First AI Commit ```bash @@ -94,15 +104,33 @@ g o |---------|-------|-------------|---------| | `gims init` | `g i` | Initialize new Git repo | `g i` | | `gims clone ` | `g c` | Clone repository | `g c https://github.com/user/repo` | -| `gims suggest` | `g s` | Generate & copy commit message | `g s` | +| `gims suggest` | `g s` | Generate & copy commit message from staged changes (use `--all` to stage) | `g s --all` | | `gims local` | `g l` | AI commit locally | `g l` | -| `gims online` | `g o` | AI commit + push | `g o` | +| `gims online` | `g o` | AI commit + push (use `--set-upstream` on first push) | `g o --set-upstream` | +| `gims commit ` | `g m` | Commit with a custom message (no AI) | `g m "fix: handle empty input"` | | `gims pull` | `g p` | Pull latest changes | `g p` | | `gims list` | `g ls` | Show numbered commit history | `g ls` | | `gims largelist` | `g ll` | Detailed commit history | `g ll` | | `gims branch ` | `g b` | Branch from commit #n | `g b 3 feature-x` | -| `gims reset ` | `g r` | Reset to commit #n | `g r 5 --hard` | -| `gims revert ` | `g rv` | Safely revert commit #n | `g rv 2` | +| `gims reset ` | `g r` | Reset to commit #n (`--hard` needs `--yes`) | `g r 5 --hard --yes` | +| `gims revert ` | `g rv` | Safely revert commit #n (requires `--yes`) | `g rv 2 --yes` | +| `gims undo` | `g u` | Undo last commit (soft reset by default) | `g u` or `g u --hard --yes` | + +### Global Options + +- `--provider `: AI provider: `auto` | `openai` | `gemini` | `groq` | `none` +- `--model `: Override model identifier for the chosen provider +- `--staged-only`: Use only staged changes (default behavior for `g s`) +- `--all`: Stage all changes before running +- `--no-clipboard`: Do not copy suggestion to clipboard (for `g s`) +- `--body`: Generate a commit body in addition to subject +- `--conventional`: Format subject using Conventional Commits +- `--dry-run`: Print what would happen without committing/pushing +- `--verbose`: Verbose logging +- `--json`: Machine-readable output for `g s` +- `--yes`: Confirm destructive actions without prompting (e.g., reset/revert/undo) +- `--amend`: Amend the last commit instead of creating a new one +- `--set-upstream`: On push, set upstream if the current branch has none ## πŸ’‘ Real-World Examples @@ -136,50 +164,71 @@ g o ## πŸ”₯ Pro Tips -### 🎯 **Perfect Workflow** +### 🎯 Perfect Workflow ```bash -# Daily development cycle -g p # Pull latest changes +g p # Pull latest changes # ... code your features ... -g s # Preview AI suggestion -g l # Commit locally first +g s # Preview AI suggestion from staged changes +g s --all # Or stage everything and suggest +g l # Commit locally first # ... test your changes ... -g push # Push when ready +g o --set-upstream # Push with automatic upstream setup on first push ``` -### 🧠 **Smart Branching** +### 🧠 Smart Branching ```bash -g ls # See numbered history -g b 5 hotfix # Branch from commit #5 -g l # Make changes and commit +g ls # See numbered history +g b 5 hotfix # Branch from commit #5 +g l # Make changes and commit g checkout main && g pull # Back to main ``` -### πŸ›‘οΈ **Safe Experimentation** +### πŸ›‘οΈ Safe Experimentation ```bash -g l # Commit your experiment +g l # Commit your experiment # ... code breaks something ... -g r 1 --soft # Soft reset to previous commit -# ... fix and try again ... +g r 1 --soft --yes # Soft reset to previous commit (confirmed) +# ... or ... +g u --yes # Undo last commit (soft) ``` ## βš™οΈ Configuration ### Environment Variables -| Variable | Purpose | Required | -|----------|---------|----------| -| `OPENAI_API_KEY` | OpenAI API access | One of these | -| `GEMINI_API_KEY` | Google Gemini API access | One of these | +| Variable | Purpose | +|----------|---------| +| `OPENAI_API_KEY` | OpenAI API access | +| `GEMINI_API_KEY` | Google Gemini API access | +| `GROQ_API_KEY` | Groq API access (OpenAI-compatible) | +| `GROQ_BASE_URL` | Groq API base URL (optional) | +| `GIMS_PROVIDER` | Default provider: `auto` | `openai` | `gemini` | `groq` | `none` | +| `GIMS_MODEL` | Default model identifier for provider | +| `GIMS_CONVENTIONAL` | `1` to enable Conventional Commits by default | +| `GIMS_COPY` | `0` to disable clipboard copying in `g s` by default | + +### .gimsrc (optional) + +Place a `.gimsrc` JSON file in your repo root or home directory to set defaults: + +```json +{ + "provider": "auto", + "model": "gpt-4o-mini", + "conventional": true, + "copy": true +} +``` ### Smart Fallbacks GIMS handles edge cases gracefully: -- **πŸ”„ Large diffs**: Automatically switches to file summary mode -- **πŸ“Š Massive changes**: Falls back to status-based analysis -- **πŸ›œ No API key**: Uses sensible default messages -- **⚠️ API failures**: Graceful degradation with helpful errors +- πŸ”„ Large diffs: Automatically switches to summary or status view +- βœ‚οΈ Massive text: Truncates safely with informative context +- πŸ›œ No API key: Uses a local heuristic that summarizes your changes +- ⚠️ API failures: Clear errors and local fallback so you keep moving +- πŸ”’ Privacy-first: Only sends diffs when you explicitly run AI features ## 🀝 Contributing @@ -225,10 +274,10 @@ mno7890 Fix memory leak in image processing pipeline ## πŸ“ˆ Stats -- ⚑ **10x faster** commits than traditional Git workflow -- 🎯 **95%+ accuracy** in commit message relevance -- πŸ“š **Zero learning curve** - if you know Git, you know GIMS -- 🌍 **Works everywhere** - Mac, Windows, Linux, WSL +- ⚑ Faster commits than traditional Git workflow +- 🎯 High accuracy in commit message relevance +- πŸ“š Zero learning curve - if you know Git, you know GIMS +- 🌍 Works everywhere - Mac, Windows, Linux, WSL ## πŸ—ΊοΈ Roadmap diff --git a/bin/gims.js b/bin/gims.js index d23d76e..c082db7 100755 --- a/bin/gims.js +++ b/bin/gims.js @@ -9,10 +9,82 @@ const clipboard = require('clipboardy'); const process = require('process'); const { OpenAI } = require('openai'); const { GoogleGenAI } = require('@google/genai'); +const fs = require('fs'); +const path = require('path'); const program = new Command(); const git = simpleGit(); +// Utility: ANSI colors without extra deps +const color = { + green: (s) => `\x1b[32m${s}\x1b[0m`, + yellow: (s) => `\x1b[33m${s}\x1b[0m`, + red: (s) => `\x1b[31m${s}\x1b[0m`, + cyan: (s) => `\x1b[36m${s}\x1b[0m`, + bold: (s) => `\x1b[1m${s}\x1b[0m`, +}; + +// Load simple config from .gimsrc (JSON) in cwd or home and env vars +function loadConfig() { + const defaults = { + provider: process.env.GIMS_PROVIDER || 'auto', // auto | openai | gemini | groq | none + model: process.env.GIMS_MODEL || '', + conventional: !!(process.env.GIMS_CONVENTIONAL === '1'), + copy: process.env.GIMS_COPY !== '0', + }; + const tryFiles = [ + path.join(process.cwd(), '.gimsrc'), + path.join(process.env.HOME || process.cwd(), '.gimsrc'), + ]; + for (const fp of tryFiles) { + try { + if (fs.existsSync(fp)) { + const txt = fs.readFileSync(fp, 'utf8'); + const json = JSON.parse(txt); + return { ...defaults, ...json }; + } + } catch (_) { + // ignore malformed config + } + } + return defaults; +} + +function getOpts() { + // Merge precedence: CLI > config > env handled in loadConfig + const cfg = loadConfig(); + const cli = program.opts(); + return { + provider: cli.provider || cfg.provider, + model: cli.model || cfg.model, + stagedOnly: !!cli.stagedOnly, + all: !!cli.all, + noClipboard: !!cli.noClipboard || cfg.copy === false, + body: !!cli.body, + conventional: !!cli.conventional || cfg.conventional, + dryRun: !!cli.dryRun, + verbose: !!cli.verbose, + json: !!cli.json, + yes: !!cli.yes, + amend: !!cli.amend, + setUpstream: !!cli.setUpstream, + }; +} + +async function ensureRepo() { + const isRepo = await git.checkIsRepo(); + if (!isRepo) { + console.error(color.red('Not a git repository (or any of the parent directories).')); + process.exit(1); + } +} + +function handleError(prefix, err) { + const msg = err && err.message ? err.message : String(err); + console.error(color.red(`${prefix}: ${msg}`)); + process.exit(1); +} + // Safe log: returns { all: [] } on empty repo async function safeLog() { try { @@ -24,7 +96,8 @@ async function safeLog() { } // Clean up AI-generated commit message -function cleanCommitMessage(message) { +function cleanCommitMessage(message, { body = false } = {}) { + if (!message) return 'Update project code'; // Remove markdown code blocks and formatting let cleaned = message .replace(/```[\s\S]*?```/g, '') // Remove code blocks @@ -34,28 +107,98 @@ function cleanCommitMessage(message) { .replace(/^\s*#+\s*/gm, '') // Remove headers .replace(/\*\*(.*?)\*\*/g, '$1') // Remove bold formatting .replace(/\*(.*?)\*/g, '$1') // Remove italic formatting + .replace(/[\u{1F300}-\u{1FAFF}]/gu, '') // strip most emojis + .replace(/[\t\r]+/g, ' ') .trim(); - - // Take only the first line if multiple lines exist - const firstLine = cleaned.split('\n')[0].trim(); - - // Ensure it's not too long - return firstLine.length > 72 ? firstLine.substring(0, 69) + '...' : firstLine; + + // If a body is allowed, split subject/body, otherwise keep first line only + 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'; + // Enforce concise subject + if (subject.length > 72) subject = subject.substring(0, 69) + '...'; + + if (!body) return subject; + + const bodyLines = lines.slice(1).filter(l => l.length > 0); + const bodyText = bodyLines.join('\n').trim(); + return bodyText ? `${subject}\n\n${bodyText}` : subject; } // Estimate tokens (rough approximation: 1 token β‰ˆ 4 characters) function estimateTokens(text) { - return Math.ceil(text.length / 4); + return Math.ceil((text || '').length / 4); +} + +function resolveProvider(pref) { + // pref: auto|openai|gemini|groq|none + if (pref === 'none') return 'none'; + if (pref === 'openai') return process.env.OPENAI_API_KEY ? 'openai' : 'none'; + if (pref === 'gemini') return process.env.GEMINI_API_KEY ? 'gemini' : 'none'; + if (pref === 'groq') return process.env.GROQ_API_KEY ? 'groq' : 'none'; + // auto + if (process.env.GEMINI_API_KEY) return 'gemini'; + if (process.env.OPENAI_API_KEY) return 'openai'; + if (process.env.GROQ_API_KEY) return 'groq'; + return 'none'; +} + +async function getHumanReadableChanges(limitPerList = 10) { + try { + const status = await git.status(); + const modified = status.modified.slice(0, limitPerList); + const created = status.created.slice(0, limitPerList); + const deleted = status.deleted.slice(0, limitPerList); + const renamed = status.renamed.map(r => `${r.from}β†’${r.to}`).slice(0, limitPerList); + const parts = []; + if (created.length) parts.push(`Added: ${created.join(', ')}`); + if (modified.length) parts.push(`Modified: ${modified.join(', ')}`); + if (deleted.length) parts.push(`Deleted: ${deleted.join(', ')}`); + if (renamed.length) parts.push(`Renamed: ${renamed.join(', ')}`); + return parts.join('\n'); + } catch (_) { + return 'Multiple file changes.'; + } +} + +function localHeuristicMessage(status, { conventional = false } = {}) { + const created = status.created.length; + const modified = status.modified.length; + const deleted = status.deleted.length; + const total = created + modified + deleted + status.renamed.length; + + const listFew = (arr) => arr.slice(0, 3).join(', ') + (arr.length > 3 ? ` and ${arr.length - 3} more` : ''); + + let type = 'chore'; + let subject = 'update files'; + if (created > 0 && modified === 0 && deleted === 0) { + type = 'feat'; + subject = created <= 3 ? `add ${listFew(status.created)}` : `add ${created} files`; + } else if (deleted > 0 && created === 0 && modified === 0) { + type = 'chore'; + subject = deleted <= 3 ? `remove ${listFew(status.deleted)}` : `remove ${deleted} files`; + } else if (modified > 0 && created === 0 && deleted === 0) { + type = 'chore'; + subject = modified <= 3 ? `update ${listFew(status.modified)}` : `update ${modified} files`; + } else if (created > 0 || deleted > 0 || modified > 0) { + type = 'chore'; + subject = `update ${total} files`; + } + const msg = conventional ? `${type}: ${subject}` : subject.charAt(0).toUpperCase() + subject.slice(1); + return msg; } // Generate commit message with multiple fallback strategies -async function generateCommitMessage(rawDiff) { +async function generateCommitMessage(rawDiff, options = {}) { + const { conventional = false, body = false, provider: prefProvider = 'auto', model = '', verbose = false } = options; const MAX_TOKENS = 100000; // Conservative limit (well below 128k) const MAX_CHARS = MAX_TOKENS * 4; - + let content = rawDiff; let strategy = 'full'; + const logv = (m) => { if (verbose) console.log(color.cyan(`[gims] ${m}`)); }; + // Strategy 1: Check if full diff is too large if (estimateTokens(rawDiff) > MAX_TOKENS) { strategy = 'summary'; @@ -78,13 +221,15 @@ async function generateCommitMessage(rawDiff) { const modified = status.modified.slice(0, 10); const created = status.created.slice(0, 10); const deleted = status.deleted.slice(0, 10); - + const renamed = status.renamed.map(r => `${r.from}β†’${r.to}`).slice(0, 10); + content = [ modified.length > 0 ? `Modified: ${modified.join(', ')}` : '', created.length > 0 ? `Added: ${created.join(', ')}` : '', - deleted.length > 0 ? `Deleted: ${deleted.join(', ')}` : '' + deleted.length > 0 ? `Deleted: ${deleted.join(', ')}` : '', + renamed.length > 0 ? `Renamed: ${renamed.join(', ')}` : '', ].filter(Boolean).join('\n'); - + if (status.files.length > 30) { content += `\n... and ${status.files.length - 30} more files`; } @@ -105,54 +250,81 @@ async function generateCommitMessage(rawDiff) { summary: 'Changes are large; using summary. Write a concise git commit message for these changes:', status: 'Many files changed. Write a concise git commit message based on these file changes:', truncated: 'Large diff truncated. Write a concise git commit message for these changes:', - fallback: 'Write a concise git commit message for:' + fallback: 'Write a concise git commit message for:', }; - const prompt = `${prompts[strategy]}\n${content}`; + const style = conventional ? 'Use Conventional Commits (e.g., feat:, fix:, chore:) for the subject.' : 'Subject must be a single short line.'; + 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.'; + const prompt = `${prompts[strategy]}\n${content}\n\n${style} ${bodyInstr}`; // Final safety check if (estimateTokens(prompt) > MAX_TOKENS) { - console.warn('Changes too large for AI analysis, using default message'); - return 'Update multiple files'; + console.warn(color.yellow('Changes too large for AI analysis, using default message')); + return cleanCommitMessage('Update multiple files', { body }); } let message = 'Update project code'; // Default fallback + const provider = resolveProvider(prefProvider); + logv(`strategy=${strategy}, provider=${provider}${model ? `, model=${model}` : ''}`); try { - if (process.env.GEMINI_API_KEY) { + if (provider === 'gemini') { const genai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY }); - const res = await genai.models.generateContent({ - model: 'gemini-2.0-flash', - contents: prompt + const res = await genai.models.generateContent({ + model: model || 'gemini-2.0-flash', + contents: prompt, }); message = (await res.response.text()).trim(); - } else if (process.env.OPENAI_API_KEY) { + } else if (provider === 'openai') { const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); const res = await openai.chat.completions.create({ - model: 'gpt-4o-mini', + model: model || 'gpt-4o-mini', messages: [{ role: 'user', content: prompt }], - temperature: 0.5, - max_tokens: 100 // Limit response length + temperature: 0.3, + max_tokens: body ? 200 : 80, }); - message = res.choices[0].message.content.trim(); + message = (res.choices[0] && res.choices[0].message && res.choices[0].message.content || '').trim(); + } else if (provider === 'groq') { + // Use OpenAI-compatible API via baseURL + const groq = new OpenAI({ apiKey: process.env.GROQ_API_KEY, baseURL: process.env.GROQ_BASE_URL || 'https://api.groq.com/openai/v1' }); + const res = await groq.chat.completions.create({ + model: model || 'llama-3.1-8b-instant', + messages: [{ role: 'user', content: prompt }], + temperature: 0.3, + max_tokens: body ? 200 : 80, + }); + message = (res.choices[0] && res.choices[0].message && res.choices[0].message.content || '').trim(); + } else { + // Local heuristic fallback + const status = await git.status(); + message = localHeuristicMessage(status, { conventional }); + const human = await getHumanReadableChanges(); + if (body) message = `${message}\n\n${human}`; } } catch (error) { - if (error.code === 'context_length_exceeded') { - console.warn('Content still too large for AI, using default message'); - return 'Update multiple files'; + if (error && error.code === 'context_length_exceeded') { + console.warn(color.yellow('Content still too large for AI, using default message')); + return cleanCommitMessage('Update multiple files', { body }); } - console.warn('AI generation failed:', error.message); + console.warn(color.yellow(`AI generation failed: ${error && error.message ? error.message : error}`)); + // fallback to local heuristic + const status = await git.status(); + message = localHeuristicMessage(status, { conventional }); + const human = await getHumanReadableChanges(); + if (body) message = `${message}\n\n${human}`; } - return cleanCommitMessage(message); + return cleanCommitMessage(message, { body }); } async function resolveCommit(input) { if (/^\d+$/.test(input)) { const { all } = await safeLog(); + // Align with list/largelist which show oldest -> newest + const ordered = [...all].reverse(); const idx = Number(input) - 1; - if (idx < 0 || idx >= all.length) throw new Error('Index out of range'); - return all[idx].hash; + if (idx < 0 || idx >= ordered.length) throw new Error('Index out of range'); + return ordered[idx].hash; } return input; } @@ -162,144 +334,296 @@ async function hasChanges() { return status.files.length > 0; } -program.name('gims').alias('g').version('0.4.3'); +program + .name('gims') + .alias('g') + .version('0.5.0') + .option('--provider ', 'AI provider: auto|openai|gemini|groq|none') + .option('--model ', 'Model identifier for provider') + .option('--staged-only', 'Use only staged changes (default for suggest)') + .option('--all', 'Stage all changes before running') + .option('--no-clipboard', 'Do not copy suggestions to clipboard') + .option('--body', 'Generate a commit body in addition to subject') + .option('--conventional', 'Format messages using Conventional Commits') + .option('--dry-run', 'Do not perform writes (no commit or push)') + .option('--verbose', 'Verbose logging') + .option('--json', 'JSON output for suggest') + .option('--yes', 'Assume yes for confirmations') + .option('--amend', 'Amend the last commit instead of creating a new one') + .option('--set-upstream', 'Set upstream on push if missing'); program.command('init').alias('i') .description('Initialize a new Git repository') - .action(async () => { await git.init(); console.log('Initialized repo.'); }); + .action(async () => { + try { await git.init(); console.log('Initialized repo.'); } + catch (e) { handleError('Init error', e); } + }); program.command('clone ').alias('c') .description('Clone a Git repository') .action(async (repo) => { try { await git.clone(repo); console.log(`Cloned ${repo}`); } - catch (e) { console.error('Clone error:', e.message); } + catch (e) { handleError('Clone error', e); } }); program.command('suggest').alias('s') .description('Suggest commit message and copy to clipboard') .action(async () => { - if (!(await hasChanges())) { - return console.log('No changes to suggest.'); - } + await ensureRepo(); + const opts = getOpts(); - const { all } = await safeLog(); - const isFirst = all.length === 0; - - // Always add changes first - await git.add('.'); - - // Get the appropriate diff - const rawDiff = await git.diff(['--cached']); - - if (!rawDiff.trim()) { - return console.log('No changes to suggest.'); - } - - const msg = await generateCommitMessage(rawDiff); - try { - clipboard.writeSync(msg); - console.log(`Suggested: "${msg}" (copied to clipboard)`); - } catch (error) { - console.log(`Suggested: "${msg}" (clipboard copy failed)`); + if (opts.all) { + await git.add('.'); + } + + // Use staged changes only; do not auto-stage unless --all + const rawDiff = await git.diff(['--cached', '--no-ext-diff']); + if (!rawDiff.trim()) { + if (opts.all) { + console.log('No changes to suggest.'); + return; + } + console.log('No staged changes. Use --all to stage everything or stage files manually.'); + return; + } + + const msg = await generateCommitMessage(rawDiff, opts); + + if (opts.json) { + const out = { message: msg }; + console.log(JSON.stringify(out)); + return; + } + + if (!opts.noClipboard) { + try { clipboard.writeSync(msg); console.log(`Suggested: "${msg}" ${color.green('(copied to clipboard)')}`); } + catch (_) { console.log(`Suggested: "${msg}" ${color.yellow('(clipboard copy failed)')}`); } + } else { + console.log(`Suggested: "${msg}"`); + } + } catch (e) { + handleError('Suggest error', e); } }); program.command('local').alias('l') .description('AI-powered local commit') .action(async () => { - if (!(await hasChanges())) { - return console.log('No changes to commit.'); - } + await ensureRepo(); + const opts = getOpts(); - const { all } = await safeLog(); - const isFirst = all.length === 0; - - // Always add changes first - await git.add('.'); - - // Get the appropriate diff - const rawDiff = await git.diff(['--cached']); - - if (!rawDiff.trim()) { - return console.log('No changes to commit.'); + try { + if (!(await hasChanges()) && !opts.all) { + console.log('No changes to commit.'); + return; + } + + if (opts.all) await git.add('.'); + + const rawDiff = await git.diff(['--cached', '--no-ext-diff']); + if (!rawDiff.trim()) { console.log('No staged changes to commit.'); return; } + + const msg = await generateCommitMessage(rawDiff, opts); + + if (opts.dryRun) { + console.log(color.yellow('[dry-run] Would commit with message:')); + console.log(msg); + return; + } + + if (opts.amend) { + await git.raw(['commit', '--amend', '-m', msg]); + } else { + await git.commit(msg); + } + console.log(`Committed locally: "${msg}"`); + } catch (e) { + handleError('Local commit error', e); } - - const msg = await generateCommitMessage(rawDiff); - await git.commit(msg); - console.log(`Committed locally: "${msg}"`); }); program.command('online').alias('o') .description('AI commit + push') .action(async () => { - if (!(await hasChanges())) { - return console.log('No changes to commit.'); - } + await ensureRepo(); + const opts = getOpts(); - const { all } = await safeLog(); - const isFirst = all.length === 0; - - // Always add changes first - await git.add('.'); - - // Get the appropriate diff - const rawDiff = await git.diff(['--cached']); - - if (!rawDiff.trim()) { - return console.log('No changes to commit.'); + try { + if (!(await hasChanges()) && !opts.all) { + console.log('No changes to commit.'); + return; + } + + if (opts.all) await git.add('.'); + + const rawDiff = await git.diff(['--cached', '--no-ext-diff']); + if (!rawDiff.trim()) { console.log('No staged changes to commit.'); return; } + + const msg = await generateCommitMessage(rawDiff, opts); + + if (opts.dryRun) { + console.log(color.yellow('[dry-run] Would commit & push with message:')); + console.log(msg); + return; + } + + if (opts.amend) { + await git.raw(['commit', '--amend', '-m', msg]); + } else { + await git.commit(msg); + } + + try { + await git.push(); + console.log(`Committed & pushed: "${msg}"`); + } catch (pushErr) { + const msgErr = pushErr && pushErr.message ? pushErr.message : String(pushErr); + if (/no upstream|set the remote as upstream|have no upstream/.test(msgErr)) { + // Try to set upstream if requested + if (opts.setUpstream) { + const branch = (await git.raw(['rev-parse', '--abbrev-ref', 'HEAD'])).trim(); + await git.push(['--set-upstream', 'origin', branch]); + console.log(`Committed & pushed (upstream set to origin/${branch}): "${msg}"`); + } else { + console.log(color.yellow('Current branch has no upstream. Use --set-upstream to set origin/ automatically.')); + } + } else { + throw pushErr; + } + } + } catch (e) { + handleError('Online commit error', e); + } + }); + +program.command('commit ').alias('m') + .description('Commit with a custom message (no AI)') + .action(async (messageParts) => { + await ensureRepo(); + const opts = getOpts(); + + try { + const msg = (messageParts || []).join(' ').trim(); + if (!msg) { console.log('Provide a commit message.'); return; } + + if (!(await hasChanges()) && !opts.all) { + console.log('No changes to commit.'); + return; + } + + if (opts.all) await git.add('.'); + + const rawDiff = await git.diff(['--cached', '--no-ext-diff']); + if (!rawDiff.trim()) { console.log('No staged changes to commit.'); return; } + + if (opts.dryRun) { + console.log(color.yellow('[dry-run] Would commit with custom message:')); + console.log(msg); + return; + } + + if (opts.amend) { + await git.raw(['commit', '--amend', '-m', msg]); + } else { + await git.commit(msg); + } + console.log(`Committed locally: "${msg}"`); + } catch (e) { + handleError('Commit error', e); } - - const msg = await generateCommitMessage(rawDiff); - await git.commit(msg); - await git.push(); - console.log(`Committed & pushed: "${msg}"`); }); program.command('pull').alias('p') .description('Pull latest changes') .action(async () => { + await ensureRepo(); try { await git.pull(); console.log('Pulled latest.'); } - catch (e) { console.error('Pull error:', e.message); } + catch (e) { handleError('Pull error', e); } }); program.command('list').alias('ls') .description('Short numbered git log (oldest β†’ newest)') .action(async () => { - const { all } = await safeLog(); - all.reverse().forEach((c, i) => console.log(`${i+1}. ${c.hash.slice(0,7)} ${c.message}`)); + await ensureRepo(); + try { + const { all } = await safeLog(); + [...all].reverse().forEach((c, i) => console.log(`${i+1}. ${c.hash.slice(0,7)} ${c.message}`)); + } catch (e) { handleError('List error', e); } }); program.command('largelist').alias('ll') .description('Full numbered git log (oldest β†’ newest)') .action(async () => { - const { all } = await safeLog(); - all.reverse().forEach((c, i) => { - const date = new Date(c.date).toLocaleString(); - console.log(`${i+1}. ${c.hash.slice(0,7)} | ${date} | ${c.author_name} β†’ ${c.message}`); - }); + await ensureRepo(); + try { + const { all } = await safeLog(); + [...all].reverse().forEach((c, i) => { + const date = new Date(c.date).toLocaleString(); + console.log(`${i+1}. ${c.hash.slice(0,7)} | ${date} | ${c.author_name} β†’ ${c.message}`); + }); + } catch (e) { handleError('Largelist error', e); } }); 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}`); } - catch (e) { console.error('Branch error:', e.message); } + catch (e) { handleError('Branch error', e); } }); program.command('reset ').alias('r') .description('Reset branch to commit/index') .option('--hard','hard reset') - .action(async (c, opts) => { - try { const sha = await resolveCommit(c); const mode = opts.hard? '--hard':'--soft'; await git.raw(['reset', mode, sha]); console.log(`Reset (${mode}) to ${sha}`); } - catch (e) { console.error('Reset error:', e.message); } + .action(async (c, optsCmd) => { + await ensureRepo(); + try { + const sha = await resolveCommit(c); + 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.`)); + process.exit(1); + } + await git.raw(['reset', mode, sha]); + console.log(`Reset (${mode}) to ${sha}`); + } + catch (e) { handleError('Reset error', e); } }); program.command('revert ').alias('rv') .description('Revert commit/index safely') .action(async (c) => { - try { const sha = await resolveCommit(c); await git.revert(sha); console.log(`Reverted ${sha}`); } - catch (e) { console.error('Revert error:', e.message); } + await ensureRepo(); + try { + const sha = await resolveCommit(c); + const opts = getOpts(); + if (!opts.yes) { + console.log(color.yellow(`About to run: git revert ${sha}. Use --yes to confirm.`)); + process.exit(1); + } + await git.revert(sha); + console.log(`Reverted ${sha}`); + } + catch (e) { handleError('Revert error', e); } + }); + +program.command('undo').alias('u') + .description('Undo last commit (soft reset to HEAD~1)') + .option('--hard', 'Hard reset instead (destructive)') + .action(async (cmd) => { + await ensureRepo(); + try { + const mode = cmd.hard ? '--hard' : '--soft'; + const opts = getOpts(); + if (!opts.yes) { + console.log(color.yellow(`About to run: git reset ${mode} HEAD~1. Use --yes to confirm.`)); + process.exit(1); + } + await git.raw(['reset', mode, 'HEAD~1']); + console.log(`Reset (${mode}) to HEAD~1`); + } catch (e) { handleError('Undo error', e); } }); program.parse(process.argv); \ No newline at end of file