feat(cli): auto-stage when nothing staged for g m/g l/g o; docs: update README
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
const { OpenAI } = require('openai');
|
||||
const { GoogleGenAI } = require('@google/genai');
|
||||
const { Progress } = require('../utils/progress');
|
||||
const { color } = require('../utils/colors');
|
||||
|
||||
/**
|
||||
* Enhanced AI provider management with caching and fallbacks
|
||||
*/
|
||||
class AIProviderManager {
|
||||
constructor(config = {}) {
|
||||
this.config = config;
|
||||
this.cache = new Map();
|
||||
this.maxCacheSize = 100;
|
||||
}
|
||||
|
||||
resolveProvider(preference = 'auto') {
|
||||
if (preference === 'none') return 'none';
|
||||
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';
|
||||
if (process.env.GROQ_API_KEY) return 'groq';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
getDefaultModel(provider) {
|
||||
const defaults = {
|
||||
'gemini': 'gemini-2.0-flash-exp', // Latest and fastest
|
||||
'openai': 'gpt-4o-mini', // Cost-effective and fast
|
||||
'groq': 'llama-3.1-8b-instant' // Fast inference
|
||||
};
|
||||
return defaults[provider] || '';
|
||||
}
|
||||
|
||||
getCacheKey(prompt, options) {
|
||||
const crypto = require('crypto');
|
||||
const key = JSON.stringify({ prompt: prompt.substring(0, 1000), options });
|
||||
return crypto.createHash('md5').update(key).digest('hex');
|
||||
}
|
||||
|
||||
getFromCache(cacheKey) {
|
||||
if (!this.config.cacheEnabled) return null;
|
||||
return this.cache.get(cacheKey);
|
||||
}
|
||||
|
||||
setCache(cacheKey, result) {
|
||||
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,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
async generateWithProvider(provider, prompt, options = {}) {
|
||||
const { model = '', temperature = 0.3, maxTokens = 200 } = options;
|
||||
|
||||
try {
|
||||
switch (provider) {
|
||||
case 'gemini':
|
||||
return await this.generateWithGemini(prompt, model || 'gemini-2.0-flash', options);
|
||||
case 'openai':
|
||||
return await this.generateWithOpenAI(prompt, model || 'gpt-4o-mini', options);
|
||||
case 'groq':
|
||||
return await this.generateWithGroq(prompt, model || 'llama-3.1-8b-instant', options);
|
||||
default:
|
||||
throw new Error(`Unknown provider: ${provider}`);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`${provider} generation failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async generateWithGemini(prompt, model, options) {
|
||||
const genai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
|
||||
const actualModel = model || this.getDefaultModel('gemini');
|
||||
const response = await genai.models.generateContent({
|
||||
model: actualModel,
|
||||
contents: prompt,
|
||||
});
|
||||
return (await response.response.text()).trim();
|
||||
}
|
||||
|
||||
async generateWithOpenAI(prompt, model, options) {
|
||||
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||
const actualModel = model || this.getDefaultModel('openai');
|
||||
const response = await openai.chat.completions.create({
|
||||
model: actualModel,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
temperature: options.temperature || 0.3,
|
||||
max_tokens: options.maxTokens || 200,
|
||||
});
|
||||
return (response.choices[0]?.message?.content || '').trim();
|
||||
}
|
||||
|
||||
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 actualModel = model || this.getDefaultModel('groq');
|
||||
const response = await groq.chat.completions.create({
|
||||
model: actualModel,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
temperature: options.temperature || 0.3,
|
||||
max_tokens: options.maxTokens || 200,
|
||||
});
|
||||
return (response.choices[0]?.message?.content || '').trim();
|
||||
}
|
||||
|
||||
async generateCommitMessage(diff, options = {}) {
|
||||
const {
|
||||
provider: preferredProvider = 'auto',
|
||||
conventional = false,
|
||||
body = false,
|
||||
verbose = false
|
||||
} = options;
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = this.getCacheKey(diff, { conventional, body });
|
||||
const cached = this.getFromCache(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < 3600000) { // 1 hour cache
|
||||
if (verbose) Progress.info('Using cached result');
|
||||
return cached.result;
|
||||
}
|
||||
|
||||
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);
|
||||
return result;
|
||||
}
|
||||
|
||||
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);
|
||||
return cleaned;
|
||||
|
||||
} catch (error) {
|
||||
if (verbose) Progress.warning(`${provider} failed: ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback
|
||||
const fallback = 'Update project files';
|
||||
this.setCache(cacheKey, fallback);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
buildProviderChain(preferred) {
|
||||
const available = [];
|
||||
|
||||
if (preferred !== 'auto' && preferred !== 'none') {
|
||||
const resolved = this.resolveProvider(preferred);
|
||||
if (resolved !== 'none') available.push(resolved);
|
||||
} else if (preferred === 'auto') {
|
||||
if (process.env.GEMINI_API_KEY) available.push('gemini');
|
||||
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
|
||||
? 'Use Conventional Commits format (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.';
|
||||
|
||||
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, '')
|
||||
.replace(/`([^`]+)`/g, '$1')
|
||||
.replace(/^\s*[-*+]\s*/gm, '')
|
||||
.replace(/^\s*\d+\.\s*/gm, '')
|
||||
.replace(/^\s*#+\s*/gm, '')
|
||||
.replace(/\*\*(.*?)\*\*/g, '$1')
|
||||
.replace(/\*(.*?)\*/g, '$1')
|
||||
.replace(/[\u{1F300}-\u{1FAFF}]/gu, '')
|
||||
.replace(/[\t\r]+/g, ' ')
|
||||
.trim();
|
||||
|
||||
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';
|
||||
if (subject.length > 72) subject = subject.substring(0, 69) + '...';
|
||||
|
||||
if (!options.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;
|
||||
}
|
||||
|
||||
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`;
|
||||
} else if (deletions > additions * 2) {
|
||||
type = 'chore';
|
||||
subject = files === 1 ? 'remove unused code' : `clean up ${files} files`;
|
||||
} else if (diff.includes('test') || diff.includes('spec')) {
|
||||
type = 'test';
|
||||
subject = 'update tests';
|
||||
} else if (diff.includes('README') || diff.includes('doc')) {
|
||||
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);
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip failed generations
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure we have at least one suggestion
|
||||
if (suggestions.length === 0) {
|
||||
suggestions.push('Update project files');
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { AIProviderManager };
|
||||
@@ -0,0 +1,241 @@
|
||||
const readline = require('readline');
|
||||
const { color } = require('../utils/colors');
|
||||
const { Progress } = require('../utils/progress');
|
||||
|
||||
/**
|
||||
* Interactive commit wizard and user input utilities
|
||||
*/
|
||||
class InteractiveCommands {
|
||||
constructor(git, aiProvider, analyzer) {
|
||||
this.git = git;
|
||||
this.aiProvider = aiProvider;
|
||||
this.analyzer = analyzer;
|
||||
}
|
||||
|
||||
async promptUser(question, defaultValue = '') {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
return new Promise(resolve => {
|
||||
rl.question(question, answer => {
|
||||
rl.close();
|
||||
resolve(answer.trim() || defaultValue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async selectFromList(items, prompt = 'Select an option:', allowCustom = false) {
|
||||
console.log(`\n${color.bold(prompt)}`);
|
||||
|
||||
items.forEach((item, index) => {
|
||||
console.log(` ${color.cyan((index + 1).toString())}. ${item}`);
|
||||
});
|
||||
|
||||
if (allowCustom) {
|
||||
console.log(` ${color.cyan('c')}. Custom message`);
|
||||
}
|
||||
|
||||
const maxChoice = items.length;
|
||||
const validChoices = Array.from({length: maxChoice}, (_, i) => (i + 1).toString());
|
||||
if (allowCustom) validChoices.push('c');
|
||||
|
||||
let choice;
|
||||
do {
|
||||
choice = await this.promptUser(`\nChoice (1-${maxChoice}${allowCustom ? ', c' : ''}): `);
|
||||
} while (!validChoices.includes(choice));
|
||||
|
||||
if (choice === 'c') {
|
||||
return await this.promptUser('Enter custom message: ');
|
||||
}
|
||||
|
||||
return items[parseInt(choice) - 1];
|
||||
}
|
||||
|
||||
async runInteractiveCommit(options = {}) {
|
||||
try {
|
||||
console.log(color.bold('\n🎯 Interactive Commit Wizard\n'));
|
||||
|
||||
// Step 1: Check for changes
|
||||
Progress.step(1, 4, 'Analyzing changes...');
|
||||
const status = await this.git.status();
|
||||
|
||||
if (status.files.length === 0) {
|
||||
Progress.warning('No changes detected');
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Show status and get staging preference
|
||||
Progress.step(2, 4, 'Reviewing file changes...');
|
||||
const enhancedStatus = await this.analyzer.getEnhancedStatus();
|
||||
console.log(this.analyzer.formatStatusOutput(enhancedStatus));
|
||||
|
||||
// Ask about staging
|
||||
let shouldStage = false;
|
||||
if (status.staged.length === 0) {
|
||||
const stageChoice = await this.promptUser(
|
||||
'No files are staged. Stage all changes? (y/n) [y]: ',
|
||||
'y'
|
||||
);
|
||||
shouldStage = stageChoice.toLowerCase() === 'y';
|
||||
|
||||
if (shouldStage) {
|
||||
await this.git.add('.');
|
||||
Progress.success('All changes staged');
|
||||
} else {
|
||||
console.log('You can manually stage files with: git add <file>');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Generate commit message suggestions
|
||||
Progress.step(3, 4, 'Generating AI suggestions...');
|
||||
Progress.start('🤖 AI is analyzing your changes');
|
||||
|
||||
const diff = await this.git.diff(['--cached', '--no-ext-diff']);
|
||||
if (!diff.trim()) {
|
||||
Progress.stop(color.yellow('No staged changes to commit'));
|
||||
return;
|
||||
}
|
||||
|
||||
const suggestions = await this.aiProvider.generateMultipleSuggestions(diff, options, 3);
|
||||
Progress.stop(color.green('✓ Generated suggestions'));
|
||||
|
||||
// Step 4: Let user choose
|
||||
Progress.step(4, 4, 'Select commit message...');
|
||||
const selectedMessage = await this.selectFromList(
|
||||
suggestions,
|
||||
'Choose a commit message:',
|
||||
true
|
||||
);
|
||||
|
||||
// Confirm and commit
|
||||
console.log(`\nSelected message: ${color.green(selectedMessage)}`);
|
||||
const confirm = await this.promptUser('Proceed with commit? (y/n) [y]: ', 'y');
|
||||
|
||||
if (confirm.toLowerCase() === 'y') {
|
||||
if (options.dryRun) {
|
||||
console.log(color.yellow('[dry-run] Would commit with message:'));
|
||||
console.log(selectedMessage);
|
||||
} else {
|
||||
await this.git.commit(selectedMessage);
|
||||
Progress.success(`Committed: "${selectedMessage}"`);
|
||||
|
||||
// Ask about pushing
|
||||
const pushChoice = await this.promptUser('Push to remote? (y/n) [n]: ', 'n');
|
||||
if (pushChoice.toLowerCase() === 'y') {
|
||||
try {
|
||||
await this.git.push();
|
||||
Progress.success('Pushed to remote');
|
||||
} catch (error) {
|
||||
Progress.error(`Push failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('Commit cancelled');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
Progress.error(`Interactive commit failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async runQuickCommit(message, options = {}) {
|
||||
try {
|
||||
const status = await this.git.status();
|
||||
|
||||
if (status.files.length === 0) {
|
||||
console.log('No changes to commit');
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-stage if nothing is staged
|
||||
if (status.staged.length === 0) {
|
||||
console.log(color.yellow('Auto-staging all changes...'));
|
||||
await this.git.add('.');
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
console.log(color.yellow('[dry-run] Would commit with message:'));
|
||||
console.log(message);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.git.commit(message);
|
||||
Progress.success(`Committed: "${message}"`);
|
||||
|
||||
if (options.push) {
|
||||
try {
|
||||
await this.git.push();
|
||||
Progress.success('Pushed to remote');
|
||||
} catch (error) {
|
||||
Progress.warning(`Push failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
Progress.error(`Quick commit failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async showCommitPreview(options = {}) {
|
||||
try {
|
||||
const status = await this.git.status();
|
||||
|
||||
if (status.files.length === 0) {
|
||||
console.log('No changes to preview');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show what would be committed
|
||||
const diff = await this.git.diff(['--cached', '--no-ext-diff']);
|
||||
if (!diff.trim()) {
|
||||
console.log(color.yellow('No staged changes. Use --all to stage everything.'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(color.bold('\n📋 Commit Preview\n'));
|
||||
|
||||
// Show file summary
|
||||
const complexity = await this.analyzer.getChangeComplexity(diff);
|
||||
console.log(`Complexity: ${color.cyan(complexity.complexity)}`);
|
||||
console.log(`Files: ${complexity.files}, +${complexity.additions} -${complexity.deletions}`);
|
||||
|
||||
// Generate and show AI suggestion
|
||||
Progress.start('🤖 Generating commit message');
|
||||
const suggestion = await this.aiProvider.generateCommitMessage(diff, options);
|
||||
Progress.stop('');
|
||||
|
||||
console.log(`\nSuggested message: ${color.green(suggestion)}`);
|
||||
|
||||
// Show diff summary (first few lines)
|
||||
console.log(`\n${color.bold('Changes:')}`);
|
||||
const diffLines = diff.split('\n').slice(0, 20);
|
||||
diffLines.forEach(line => {
|
||||
if (line.startsWith('+')) {
|
||||
console.log(color.green(line));
|
||||
} else if (line.startsWith('-')) {
|
||||
console.log(color.red(line));
|
||||
} else if (line.startsWith('@@')) {
|
||||
console.log(color.cyan(line));
|
||||
} else {
|
||||
console.log(color.dim(line));
|
||||
}
|
||||
});
|
||||
|
||||
if (diff.split('\n').length > 20) {
|
||||
console.log(color.dim('... (truncated)'));
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
Progress.error(`Preview failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { InteractiveCommands };
|
||||
@@ -0,0 +1,213 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { color } = require('../utils/colors');
|
||||
|
||||
/**
|
||||
* Enhanced configuration management with validation and setup wizard
|
||||
*/
|
||||
class ConfigManager {
|
||||
constructor() {
|
||||
this.configPaths = [
|
||||
path.join(process.cwd(), '.gimsrc'),
|
||||
path.join(process.env.HOME || process.cwd(), '.gimsrc'),
|
||||
];
|
||||
}
|
||||
|
||||
getDefaults() {
|
||||
return {
|
||||
provider: process.env.GIMS_PROVIDER || 'auto',
|
||||
model: process.env.GIMS_MODEL || '',
|
||||
conventional: !!(process.env.GIMS_CONVENTIONAL === '1'),
|
||||
copy: process.env.GIMS_COPY !== '0',
|
||||
autoStage: process.env.GIMS_AUTO_STAGE === '1',
|
||||
maxDiffSize: parseInt(process.env.GIMS_MAX_DIFF_SIZE) || 100000,
|
||||
cacheEnabled: process.env.GIMS_CACHE !== '0',
|
||||
progressIndicators: process.env.GIMS_PROGRESS !== '0'
|
||||
};
|
||||
}
|
||||
|
||||
load() {
|
||||
const defaults = this.getDefaults();
|
||||
|
||||
for (const configPath of this.configPaths) {
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
const config = JSON.parse(content);
|
||||
return { ...defaults, ...config, _source: configPath };
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(color.yellow(`Warning: Invalid config file ${configPath}: ${error.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
return { ...defaults, _source: 'defaults' };
|
||||
}
|
||||
|
||||
save(config, global = false) {
|
||||
const configPath = global
|
||||
? path.join(process.env.HOME || process.cwd(), '.gimsrc')
|
||||
: path.join(process.cwd(), '.gimsrc');
|
||||
|
||||
// Remove internal properties
|
||||
const { _source, ...cleanConfig } = config;
|
||||
|
||||
try {
|
||||
fs.writeFileSync(configPath, JSON.stringify(cleanConfig, null, 2));
|
||||
return configPath;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to save config: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
set(key, value, global = false) {
|
||||
const config = this.load();
|
||||
|
||||
// Validate key
|
||||
const validKeys = Object.keys(this.getDefaults());
|
||||
if (!validKeys.includes(key)) {
|
||||
throw new Error(`Invalid config key: ${key}. Valid keys: ${validKeys.join(', ')}`);
|
||||
}
|
||||
|
||||
// Type conversion
|
||||
if (typeof this.getDefaults()[key] === 'boolean') {
|
||||
value = value === 'true' || value === '1';
|
||||
} else if (typeof this.getDefaults()[key] === 'number') {
|
||||
value = parseInt(value);
|
||||
if (isNaN(value)) {
|
||||
throw new Error(`Invalid number value for ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
config[key] = value;
|
||||
const savedPath = this.save(config, global);
|
||||
return { key, value, savedPath };
|
||||
}
|
||||
|
||||
get(key) {
|
||||
const config = this.load();
|
||||
if (key) {
|
||||
return config[key];
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
detectProjectType() {
|
||||
const cwd = process.cwd();
|
||||
|
||||
if (fs.existsSync(path.join(cwd, 'package.json'))) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
||||
if (pkg.dependencies?.react || pkg.devDependencies?.react) return 'react';
|
||||
if (pkg.dependencies?.vue || pkg.devDependencies?.vue) return 'vue';
|
||||
if (pkg.dependencies?.angular || pkg.devDependencies?.angular) return 'angular';
|
||||
if (pkg.dependencies?.express || pkg.devDependencies?.express) return 'express';
|
||||
return 'node';
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync(path.join(cwd, 'requirements.txt')) ||
|
||||
fs.existsSync(path.join(cwd, 'pyproject.toml'))) return 'python';
|
||||
if (fs.existsSync(path.join(cwd, 'Cargo.toml'))) return 'rust';
|
||||
if (fs.existsSync(path.join(cwd, 'go.mod'))) return 'go';
|
||||
if (fs.existsSync(path.join(cwd, 'pom.xml'))) return 'java';
|
||||
|
||||
return 'generic';
|
||||
}
|
||||
|
||||
getProjectCommitStyle(projectType) {
|
||||
const styles = {
|
||||
react: { conventional: true, types: ['feat', 'fix', 'style', 'refactor', 'test'] },
|
||||
vue: { conventional: true, types: ['feat', 'fix', 'style', 'refactor', 'test'] },
|
||||
angular: { conventional: true, types: ['feat', 'fix', 'docs', 'style', 'refactor', 'test', 'chore'] },
|
||||
node: { conventional: true, types: ['feat', 'fix', 'perf', 'refactor', 'test', 'chore'] },
|
||||
python: { conventional: false, style: 'descriptive' },
|
||||
generic: { conventional: false, style: 'simple' }
|
||||
};
|
||||
|
||||
return styles[projectType] || styles.generic;
|
||||
}
|
||||
|
||||
async runSetupWizard() {
|
||||
const readline = require('readline');
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
const question = (prompt) => new Promise(resolve => {
|
||||
rl.question(prompt, answer => {
|
||||
resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
|
||||
console.log(color.bold('\n🚀 GIMS Setup Wizard\n'));
|
||||
|
||||
// Detect project
|
||||
const projectType = this.detectProjectType();
|
||||
const projectStyle = this.getProjectCommitStyle(projectType);
|
||||
|
||||
console.log(`Detected project type: ${color.cyan(projectType)}`);
|
||||
|
||||
// Provider setup
|
||||
console.log('\n📡 AI Provider Setup:');
|
||||
const hasOpenAI = !!process.env.OPENAI_API_KEY;
|
||||
const hasGemini = !!process.env.GEMINI_API_KEY;
|
||||
const hasGroq = !!process.env.GROQ_API_KEY;
|
||||
|
||||
if (!hasOpenAI && !hasGemini && !hasGroq) {
|
||||
console.log(color.yellow('No AI providers detected. GIMS will use local heuristics.'));
|
||||
console.log('\nTo enable AI features, run:');
|
||||
console.log(` ${color.cyan('g setup --api-key gemini')} # Recommended: Fast & free`);
|
||||
console.log(` ${color.cyan('g setup --api-key openai')} # High quality`);
|
||||
console.log(` ${color.cyan('g setup --api-key groq')} # Ultra fast`);
|
||||
console.log('\nOr set environment variables manually:');
|
||||
console.log(' - OPENAI_API_KEY (gpt-4o-mini)');
|
||||
console.log(' - GEMINI_API_KEY (gemini-2.0-flash-exp)');
|
||||
console.log(' - GROQ_API_KEY (llama-3.1-8b-instant)');
|
||||
} else {
|
||||
console.log('Available providers with default models:');
|
||||
if (hasGemini) console.log(` ${color.green('✓')} Google Gemini (gemini-2.0-flash-exp)`);
|
||||
if (hasOpenAI) console.log(` ${color.green('✓')} OpenAI (gpt-4o-mini)`);
|
||||
if (hasGroq) console.log(` ${color.green('✓')} Groq (llama-3.1-8b-instant)`);
|
||||
}
|
||||
|
||||
const provider = await question(`\nPreferred provider (auto/openai/gemini/groq/none) [auto]: `) || 'auto';
|
||||
|
||||
// Commit style
|
||||
const conventionalDefault = projectStyle.conventional ? 'y' : 'n';
|
||||
const conventional = await question(`\nUse Conventional Commits? (y/n) [${conventionalDefault}]: `) || conventionalDefault;
|
||||
|
||||
// Other preferences
|
||||
const autoStage = await question('Auto-stage all changes by default? (y/n) [n]: ') || 'n';
|
||||
const copy = await question('Copy suggestions to clipboard? (y/n) [y]: ') || 'y';
|
||||
|
||||
// Global or local config
|
||||
const scope = await question('\nSave config globally or for this project? (global/local) [local]: ') || 'local';
|
||||
|
||||
rl.close();
|
||||
|
||||
// Save configuration
|
||||
const config = {
|
||||
provider,
|
||||
conventional: conventional.toLowerCase() === 'y',
|
||||
autoStage: autoStage.toLowerCase() === 'y',
|
||||
copy: copy.toLowerCase() === 'y',
|
||||
projectType
|
||||
};
|
||||
|
||||
const savedPath = this.save(config, scope === 'global');
|
||||
|
||||
console.log(`\n${color.green('✓')} Configuration saved to: ${savedPath}`);
|
||||
console.log('\nYou\'re all set! Try running:');
|
||||
console.log(` ${color.cyan('g status')} - See enhanced git status`);
|
||||
console.log(` ${color.cyan('g o')} - AI commit and push`);
|
||||
console.log(` ${color.cyan('g s')} - Get AI suggestions`);
|
||||
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ConfigManager };
|
||||
@@ -0,0 +1,231 @@
|
||||
const { color } = require('../utils/colors');
|
||||
|
||||
/**
|
||||
* Enhanced git analysis and insights
|
||||
*/
|
||||
class GitAnalyzer {
|
||||
constructor(git) {
|
||||
this.git = git;
|
||||
}
|
||||
|
||||
async getEnhancedStatus() {
|
||||
try {
|
||||
const status = await this.git.status();
|
||||
const insights = await this.generateStatusInsights(status);
|
||||
|
||||
return {
|
||||
...status,
|
||||
insights,
|
||||
summary: this.generateStatusSummary(status)
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get git status: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
generateStatusSummary(status) {
|
||||
const files = Array.isArray(status.files) ? status.files : [];
|
||||
const staged = Array.isArray(status.staged) ? status.staged : [];
|
||||
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 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 : [];
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
// Check for test files
|
||||
const testFiles = files.filter(f => {
|
||||
const fileName = String(f);
|
||||
return fileName.includes('.test.') || fileName.includes('.spec.') || fileName.includes('__tests__');
|
||||
});
|
||||
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);
|
||||
return fileName.includes('config') || fileName.includes('.json') || fileName.includes('.yml') || fileName.includes('.yaml');
|
||||
});
|
||||
if (configFiles.length > 0) {
|
||||
insights.push('⚙️ Configuration changes detected');
|
||||
}
|
||||
|
||||
return insights;
|
||||
}
|
||||
|
||||
async analyzeCommitHistory(limit = 10) {
|
||||
try {
|
||||
const log = await this.git.log({ maxCount: limit });
|
||||
const commits = log.all;
|
||||
|
||||
const analysis = {
|
||||
totalCommits: commits.length,
|
||||
authors: [...new Set(commits.map(c => c.author_name))],
|
||||
averageMessageLength: commits.reduce((sum, c) => sum + c.message.length, 0) / commits.length,
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
analyzeRecentActivity(commits) {
|
||||
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,
|
||||
frequency: weeklyCommits.length > 0 ? 'active' : 'quiet'
|
||||
};
|
||||
}
|
||||
|
||||
async getChangeComplexity(diff) {
|
||||
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 complexity = 'simple';
|
||||
if (files > 10 || additions + deletions > 500) {
|
||||
complexity = 'complex';
|
||||
} else if (files > 5 || additions + deletions > 100) {
|
||||
complexity = 'moderate';
|
||||
}
|
||||
|
||||
return {
|
||||
complexity,
|
||||
files,
|
||||
additions,
|
||||
deletions,
|
||||
total: additions + deletions
|
||||
};
|
||||
}
|
||||
|
||||
formatStatusOutput(enhancedStatus) {
|
||||
const {
|
||||
files = [],
|
||||
staged = [],
|
||||
modified = [],
|
||||
created = [],
|
||||
deleted = [],
|
||||
not_added = [],
|
||||
insights = [],
|
||||
summary = 'Unknown status'
|
||||
} = enhancedStatus;
|
||||
|
||||
let output = '';
|
||||
|
||||
// Header
|
||||
output += `${color.bold('Git Status')}\n`;
|
||||
output += `${color.dim(summary)}\n\n`;
|
||||
|
||||
// Staged changes
|
||||
if (staged.length > 0) {
|
||||
output += `${color.green('Staged for commit:')}\n`;
|
||||
staged.forEach(file => {
|
||||
output += ` ${color.green('+')} ${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`;
|
||||
});
|
||||
output += '\n';
|
||||
}
|
||||
|
||||
// New files
|
||||
if (created.length > 0) {
|
||||
output += `${color.cyan('New files:')}\n`;
|
||||
created.forEach(file => {
|
||||
output += ` ${color.cyan('N')} ${file}\n`;
|
||||
});
|
||||
output += '\n';
|
||||
}
|
||||
|
||||
// Deleted files
|
||||
if (deleted.length > 0) {
|
||||
output += `${color.red('Deleted:')}\n`;
|
||||
deleted.forEach(file => {
|
||||
output += ` ${color.red('D')} ${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`;
|
||||
});
|
||||
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`;
|
||||
insights.forEach(insight => {
|
||||
output += ` ${insight}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { GitAnalyzer };
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* ANSI color utilities without external dependencies
|
||||
*/
|
||||
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`,
|
||||
blue: (s) => `\x1b[34m${s}\x1b[0m`,
|
||||
magenta: (s) => `\x1b[35m${s}\x1b[0m`,
|
||||
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
||||
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
||||
reset: '\x1b[0m'
|
||||
};
|
||||
|
||||
module.exports = { color };
|
||||
@@ -0,0 +1,49 @@
|
||||
const { color } = require('./colors');
|
||||
|
||||
/**
|
||||
* Progress indicators and user feedback utilities
|
||||
*/
|
||||
class Progress {
|
||||
static spinner = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||
static current = 0;
|
||||
static interval = null;
|
||||
|
||||
static start(message) {
|
||||
process.stdout.write(`${message} ${this.spinner[0]}`);
|
||||
this.interval = setInterval(() => {
|
||||
this.current = (this.current + 1) % this.spinner.length;
|
||||
process.stdout.write(`\r${message} ${this.spinner[this.current]}`);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
static stop(finalMessage = '') {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
process.stdout.write(`\r${finalMessage}\n`);
|
||||
}
|
||||
|
||||
static success(message) {
|
||||
console.log(`${color.green('✓')} ${message}`);
|
||||
}
|
||||
|
||||
static warning(message) {
|
||||
console.log(`${color.yellow('⚠')} ${message}`);
|
||||
}
|
||||
|
||||
static error(message) {
|
||||
console.log(`${color.red('✗')} ${message}`);
|
||||
}
|
||||
|
||||
static info(message) {
|
||||
console.log(`${color.cyan('ℹ')} ${message}`);
|
||||
}
|
||||
|
||||
static step(step, total, message) {
|
||||
const progress = `[${step}/${total}]`;
|
||||
console.log(`${color.dim(progress)} ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { Progress };
|
||||
Reference in New Issue
Block a user