@sbeeredd04/auto-git
Version:
AI-powered Git automation with intelligent commit decisions using Gemini function calling, smart diff optimization, push control, and enhanced interactive terminal session with persistent command history
706 lines (593 loc) • 23.3 kB
JavaScript
import { spawn } from 'child_process';
import { execa } from 'execa';
import chalk from 'chalk';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import logger from '../utils/logger.js';
import { generateErrorSuggestion } from './gemini.js';
import { formatMarkdown, formatAISuggestion, formatGitCommand, createBox } from '../utils/markdown.js';
let replActive = false;
let currentInput = '';
let inputHandler = null;
let shouldExit = false;
let sessionHistory = [];
let historyIndex = -1;
// Session persistence
const HISTORY_FILE = path.join(os.homedir(), '.auto-git-history.json');
const MAX_HISTORY_SIZE = 100;
// Input sanitization function to remove duplicate characters
function sanitizeInput(input) {
if (!input || typeof input !== 'string') return input;
let result = '';
let i = 0;
while (i < input.length) {
const currentChar = input[i];
result += currentChar;
// If the next character is the same, skip it (remove one duplicate)
if (i + 1 < input.length && input[i + 1] === currentChar) {
i += 2; // Skip the duplicate
} else {
i += 1; // Move to next character
}
}
return result.trim();
}
// Load session history from file
async function loadSessionHistory() {
try {
const data = await fs.readFile(HISTORY_FILE, 'utf8');
const history = JSON.parse(data);
sessionHistory = Array.isArray(history) ? history.slice(-MAX_HISTORY_SIZE) : [];
} catch (error) {
// File doesn't exist or is corrupted, start with empty history
sessionHistory = [];
}
}
// Save session history to file
async function saveSessionHistory() {
try {
const historyToSave = sessionHistory.slice(-MAX_HISTORY_SIZE);
await fs.writeFile(HISTORY_FILE, JSON.stringify(historyToSave, null, 2));
} catch (error) {
// Silently fail if we can't save history
logger.debug('Failed to save session history:', error.message);
}
}
// Add command to history
function addToHistory(command) {
if (command && command.trim() && command !== sessionHistory[sessionHistory.length - 1]) {
sessionHistory.push(command.trim());
if (sessionHistory.length > MAX_HISTORY_SIZE) {
sessionHistory = sessionHistory.slice(-MAX_HISTORY_SIZE);
}
}
historyIndex = sessionHistory.length;
}
// Get command from history
function getFromHistory(direction) {
if (sessionHistory.length === 0) return '';
if (direction === 'up') {
historyIndex = Math.max(0, historyIndex - 1);
} else if (direction === 'down') {
historyIndex = Math.min(sessionHistory.length, historyIndex + 1);
}
return historyIndex < sessionHistory.length ? sessionHistory[historyIndex] : '';
}
export async function startInteractiveSession() {
if (replActive) {
logger.warning('Interactive session already active', 'Ignoring duplicate request');
return;
}
replActive = true;
shouldExit = false;
currentInput = '';
// Load session history
await loadSessionHistory();
try {
logger.space();
logger.section('Auto-Git Interactive Session v3.10.4', 'Enhanced terminal with AI assistance and session persistence');
logger.space();
logger.info('💡 Enhanced Features:', 'GUIDE');
logger.info(' • Type any command - executed with full terminal support');
logger.info(' • AI-powered error analysis with markdown formatting');
logger.info(' • Session history with ↑↓ arrow key navigation');
logger.info(' • Persistent command history across sessions');
logger.info(' • Git command syntax highlighting');
logger.info(' • Use Ctrl+C to exit');
logger.space();
logger.info('Examples:', 'EXAMPLES');
logger.info(' git status # Show repository status');
logger.info(' git pull # Pull latest changes');
logger.info(' git log --oneline -10 # Show recent commits');
logger.info(' ls -la # List directory contents');
logger.info(' history # Show command history');
logger.info(' clear # Clear terminal screen');
logger.info(' help # Show available commands');
if (sessionHistory.length > 0) {
logger.space();
logger.info(`📚 Session history loaded: ${sessionHistory.length} commands`, 'HISTORY');
logger.info('Use ↑↓ arrow keys to navigate through previous commands', 'TIP');
}
logger.space();
// Setup stdin for raw input
setupRawInput();
// Display initial prompt
showPrompt();
// Main interactive loop
await runInteractiveLoop();
} finally {
replActive = false;
currentInput = '';
shouldExit = false;
// Save session history
await saveSessionHistory();
// Cleanup handlers
cleanupHandlers();
logger.space();
logger.info('Session saved. Exiting interactive session...', 'SHUTDOWN');
}
}
function setupRawInput() {
if (process.stdin.setRawMode) {
process.stdin.setRawMode(true);
}
process.stdin.resume();
process.stdin.setEncoding('utf8');
}
function showPrompt() {
const promptText = 'auto-git> ';
process.stdout.write(chalk.green(promptText));
}
function clearCurrentLine() {
process.stdout.write('\r\x1b[K');
}
function redrawInput() {
clearCurrentLine();
showPrompt();
process.stdout.write(currentInput);
}
async function runInteractiveLoop() {
return new Promise((resolve, reject) => {
let isProcessingCommand = false;
// Add error handler for the process
const errorHandler = (error) => {
logger.debug(`Process error: ${error.message}`);
// Don't exit on process errors, just log them
};
process.on('error', errorHandler);
inputHandler = (key) => {
try {
// Handle Ctrl+C - ONLY way to exit
if (key === '\u0003') {
logger.space();
logger.info('Exiting interactive session...', 'SHUTDOWN');
shouldExit = true;
process.removeListener('error', errorHandler);
resolve();
return;
}
// Ignore input while processing a command
if (isProcessingCommand) {
return;
}
// Handle arrow keys for history navigation
if (key === '\u001b[A') { // Up arrow
const historyCommand = getFromHistory('up');
if (historyCommand) {
currentInput = historyCommand;
redrawInput();
}
return;
}
if (key === '\u001b[B') { // Down arrow
const historyCommand = getFromHistory('down');
currentInput = historyCommand;
redrawInput();
return;
}
// Handle Enter - check for both \r and \n, and handle line-by-line input
if (key === '\r' || key === '\n' || key.includes('\n')) {
process.stdout.write('\n');
// Handle multi-line input by processing each line
if (key.includes('\n')) {
const lines = key.split('\n').filter(line => line.trim());
if (lines.length > 0) {
// Process the first line with current input
const firstLine = lines[0].replace('\r', '');
currentInput += firstLine;
if (currentInput.trim()) {
const sanitizedInput = sanitizeInput(currentInput);
addToHistory(sanitizedInput);
// Set flag to prevent input during command execution
isProcessingCommand = true;
// Execute command asynchronously with proper error handling
executeCommand(sanitizedInput)
.then((result) => {
// Only exit if explicitly requested
if (result === 'exit') {
shouldExit = true;
process.removeListener('error', errorHandler);
resolve();
return;
}
// Reset input and show new prompt - ALWAYS continue session
currentInput = '';
historyIndex = sessionHistory.length;
isProcessingCommand = false;
// Always show prompt again to continue session
if (!shouldExit) {
showPrompt();
}
})
.catch((error) => {
logger.debug(`Command execution error: ${error.message}`);
// Reset state and continue session even on error
currentInput = '';
historyIndex = sessionHistory.length;
isProcessingCommand = false;
// Always show prompt again to continue session
if (!shouldExit) {
logger.space();
logger.warning('Command execution failed, continuing session...', 'RECOVERY');
showPrompt();
}
});
} else {
// Empty command, just show prompt again
currentInput = '';
historyIndex = sessionHistory.length;
if (!shouldExit) {
showPrompt();
}
}
} else {
// No valid lines, just show prompt again
currentInput = '';
historyIndex = sessionHistory.length;
if (!shouldExit) {
showPrompt();
}
}
} else {
// Single Enter key press
if (currentInput.trim()) {
const sanitizedInput = sanitizeInput(currentInput);
addToHistory(sanitizedInput);
// Set flag to prevent input during command execution
isProcessingCommand = true;
// Execute command asynchronously with proper error handling
executeCommand(sanitizedInput)
.then((result) => {
// Only exit if explicitly requested
if (result === 'exit') {
shouldExit = true;
process.removeListener('error', errorHandler);
resolve();
return;
}
// Reset input and show new prompt - ALWAYS continue session
currentInput = '';
historyIndex = sessionHistory.length;
isProcessingCommand = false;
// Always show prompt again to continue session
if (!shouldExit) {
showPrompt();
}
})
.catch((error) => {
logger.debug(`Command execution error: ${error.message}`);
// Reset state and continue session even on error
currentInput = '';
historyIndex = sessionHistory.length;
isProcessingCommand = false;
// Always show prompt again to continue session
if (!shouldExit) {
logger.space();
logger.warning('Command execution failed, continuing session...', 'RECOVERY');
showPrompt();
}
});
} else {
// Empty command, just show prompt again
currentInput = '';
historyIndex = sessionHistory.length;
if (!shouldExit) {
showPrompt();
}
}
}
return;
}
// Handle Backspace
if (key === '\u007f' || key === '\b') {
if (currentInput.length > 0) {
currentInput = currentInput.slice(0, -1);
process.stdout.write('\b \b');
}
return;
}
// Handle regular character input
if (key >= ' ' && key <= '~') {
currentInput += key;
process.stdout.write(key);
}
} catch (error) {
logger.debug(`Input handler error: ${error.message}`);
// Don't exit on input errors, just log them and continue
if (!shouldExit) {
logger.space();
logger.warning('Input handling error, continuing session...', 'RECOVERY');
showPrompt();
}
}
};
process.stdin.on('data', inputHandler);
// Add error handler for stdin - don't exit on stdin errors
process.stdin.on('error', (error) => {
logger.debug(`Stdin error: ${error.message}`);
// Don't exit on stdin errors, just log them
});
// Add additional safety handlers to prevent unexpected exits
process.stdin.on('end', () => {
logger.debug('Stdin ended - this should not happen in interactive mode');
// Don't automatically exit, let user control with Ctrl+C
});
process.stdin.on('close', () => {
logger.debug('Stdin closed - this should not happen in interactive mode');
// Don't automatically exit, let user control with Ctrl+C
});
});
}
function cleanupHandlers() {
// Remove input handler
if (inputHandler) {
process.stdin.removeListener('data', inputHandler);
inputHandler = null;
}
// Restore stdin
if (process.stdin.setRawMode) {
process.stdin.setRawMode(false);
}
}
async function executeCommand(command) {
try {
const [action, ...args] = command.split(' ');
// Handle special commands
switch (action.toLowerCase()) {
case 'exit':
case 'quit':
case 'q':
return 'exit';
case 'help':
await showHelp();
return 'continue';
case 'clear':
console.clear();
return 'continue';
case 'history':
await showHistory();
return 'continue';
case 'version':
logger.info('Auto-Git Interactive Session v3.10.4', 'VERSION');
return 'continue';
}
// Execute as terminal command
await executeTerminalCommand(command);
return 'continue';
} catch (error) {
logger.debug(`Execute command error: ${error.message}`);
logger.space();
logger.warning('Command execution failed, continuing session...', 'RECOVERY');
logger.space();
return 'continue'; // Always continue session
}
}
async function executeTerminalCommand(command) {
try {
logger.space();
// Show formatted command if it's a git command
if (command.startsWith('git ')) {
logger.info(`Executing: ${formatGitCommand(command)}`, 'COMMAND');
} else {
logger.info(`Executing: ${chalk.cyan(command)}`, 'COMMAND');
}
const spinner = logger.startSpinner('Running command...');
try {
// Temporarily disable raw mode to prevent conflicts
if (process.stdin.setRawMode) {
process.stdin.setRawMode(false);
}
// Use spawn without inheriting stdin to avoid conflicts
const child = spawn('sh', ['-c', command], {
stdio: ['ignore', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
const exitCode = await new Promise((resolve) => {
child.on('close', resolve);
child.on('error', (error) => {
logger.debug(`Child process error: ${error.message}`);
resolve(1); // Return non-zero exit code on error
});
});
// Always restore raw mode after command execution
if (process.stdin.setRawMode) {
process.stdin.setRawMode(true);
}
if (exitCode === 0) {
logger.succeedSpinner('Command completed successfully');
if (stdout.trim()) {
logger.space();
logger.info('Output:', 'RESULT');
console.log(chalk.gray(stdout.trim()));
}
} else {
logger.failSpinner(`Command failed with exit code ${exitCode}`);
if (stderr.trim()) {
logger.space();
logger.error('Error Output', stderr.trim());
// Get AI suggestion for failed commands - wrapped in try-catch
try {
await getAISuggestion(command, stderr.trim());
} catch (aiError) {
logger.debug(`AI suggestion failed: ${aiError.message}`);
logger.space();
logger.warning('AI suggestion unavailable, continuing session...', 'RECOVERY');
}
}
}
} catch (error) {
// Ensure raw mode is restored even if there's an error
if (process.stdin.setRawMode) {
process.stdin.setRawMode(true);
}
logger.failSpinner(`Command execution failed: ${command}`);
logger.space();
logger.error('Execution Error', error.message);
// Get AI suggestion for execution errors - wrapped in try-catch
try {
await getAISuggestion(command, error.message);
} catch (aiError) {
logger.debug(`AI suggestion failed: ${aiError.message}`);
logger.space();
logger.warning('AI suggestion unavailable, continuing session...', 'RECOVERY');
}
}
logger.space();
} catch (error) {
// Catch any unexpected errors to prevent session termination
logger.debug(`Terminal command error: ${error.message}`);
// Ensure raw mode is restored
if (process.stdin.setRawMode) {
process.stdin.setRawMode(true);
}
logger.space();
logger.warning('Command execution failed, continuing session...', 'RECOVERY');
logger.space();
}
}
async function getAISuggestion(command, errorMessage) {
try {
logger.space();
const aiSpinner = logger.startSpinner('🤖 Analyzing error with AI...');
try {
const suggestion = await generateErrorSuggestion(
`Command failed: ${command}\nError: ${errorMessage}`
);
logger.succeedSpinner('AI analysis complete');
logger.space();
// Use markdown formatting for AI suggestions
const formattedSuggestion = formatAISuggestion(suggestion);
console.log(formattedSuggestion);
logger.space();
logger.info('💡 You can run the suggested commands directly in this terminal!', 'TIP');
} catch (aiError) {
logger.failSpinner('AI suggestion failed');
logger.space();
// Show a more user-friendly error message
if (aiError.message.includes('API') || aiError.message.includes('fetch')) {
logger.warning('AI service temporarily unavailable', 'Check your internet connection and API key');
} else if (aiError.message.includes('rate limit')) {
logger.warning('Rate limit reached', 'Please wait a moment before trying again');
} else {
logger.warning('Could not get AI suggestion', 'Falling back to basic troubleshooting');
}
// Provide basic troubleshooting
logger.space();
logger.info('💡 Basic troubleshooting:', 'HELP');
if (command.startsWith('git ')) {
logger.info(' • Check: git status');
logger.info(' • Check: git log --oneline -5');
logger.info(' • Try: git --help ' + command.split(' ')[1]);
} else {
logger.info(' • Check command syntax');
logger.info(' • Verify file/directory exists');
logger.info(' • Check permissions');
logger.info(' • Try: man ' + command.split(' ')[0]);
}
}
logger.space();
} catch (error) {
// Catch any unexpected errors to prevent session termination
logger.debug(`AI suggestion error: ${error.message}`);
logger.space();
logger.warning('Unable to provide AI suggestion', 'Continuing with session...');
logger.space();
}
}
async function showHistory() {
logger.space();
logger.section('Command History', `${sessionHistory.length} commands in session`);
if (sessionHistory.length === 0) {
logger.info('No commands in history yet', 'EMPTY');
logger.space();
return;
}
logger.space();
const recentHistory = sessionHistory.slice(-20); // Show last 20 commands
recentHistory.forEach((cmd, index) => {
const number = chalk.gray(`${sessionHistory.length - recentHistory.length + index + 1}.`);
const command = cmd.startsWith('git ') ? formatGitCommand(cmd) : chalk.cyan(cmd);
console.log(` ${number} ${command}`);
});
if (sessionHistory.length > 20) {
logger.space();
logger.info(`... and ${sessionHistory.length - 20} more commands`, 'INFO');
}
logger.space();
logger.info('Use ↑↓ arrow keys to navigate through history', 'TIP');
logger.space();
}
async function showHelp() {
logger.space();
logger.section('Auto-Git Interactive Help v3.10.4', 'Enhanced terminal with AI assistance');
logger.space();
const commands = {
'Any command': 'Execute directly in terminal (e.g., git status, ls, pwd)',
'history': 'Show command history for this session',
'clear': 'Clear the terminal screen',
'version': 'Show interactive session version',
'help': 'Show this help message',
'exit': 'Exit interactive session',
'Ctrl+C': 'Exit interactive session'
};
logger.config('AVAILABLE COMMANDS', commands);
logger.space();
const examples = {
'git status': 'Show repository status with syntax highlighting',
'git pull': 'Pull latest changes from remote',
'git log --oneline -10': 'Show recent commit history',
'git branch -a': 'List all branches',
'ls -la': 'List directory contents with details',
'pwd': 'Show current directory path'
};
logger.config('EXAMPLE COMMANDS', examples);
logger.space();
const features = {
'Session Persistence': 'Command history saved across sessions',
'Arrow Key Navigation': 'Use ↑↓ to browse command history',
'AI Error Analysis': 'Intelligent suggestions for failed commands',
'Markdown Formatting': 'Rich formatting for AI responses',
'Git Syntax Highlighting': 'Enhanced display for Git commands',
'Input Sanitization': 'Automatic duplicate character removal'
};
logger.config('ENHANCED FEATURES (v3.10.4)', features);
logger.space();
logger.info('💡 Pro Tips:', 'TIPS');
logger.info(' • Commands are automatically saved to history');
logger.info(' • Git commands get special syntax highlighting');
logger.info(' • AI suggestions are formatted with markdown');
logger.info(' • Use arrow keys to quickly repeat commands');
logger.info(' • Session history persists between restarts');
logger.space();
}
export function isInteractiveActive() {
return replActive;
}