version 0.8.1 release, stable release

This commit is contained in:
s41r4j
2025-12-30 19:47:40 +05:30
parent 4a30dd018f
commit e5e4713384
9 changed files with 1609 additions and 200 deletions
+80
View File
@@ -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
+32 -12
View File
@@ -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
+834 -103
View File
File diff suppressed because it is too large Load Diff
+36 -34
View File
@@ -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;
}
}
+118 -47
View File
@@ -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 };
+15
View File
@@ -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'
};
+421
View File
@@ -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 };
+70 -1
View File
@@ -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 };
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "gims",
"version": "0.6.7",
"description": "Git Made Simple AIpowered git helper using Gemini / OpenAI",
"version": "0.8.1",
"description": "Git Made Simple AIpowered 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'\""
}
}
}