UNPKG

sushil-gitmate

Version:

Professional Git workflow automation powered by AI. Streamline your development process with natural language commands and intelligent automation.

840 lines (724 loc) 25.6 kB
import simpleGit from 'simple-git'; import logger from '../utils/logger.js'; import path from 'path'; import fs from 'fs/promises'; import { getToken } from '../utils/tokenManager.js'; import axios from 'axios'; import chalk from 'chalk'; const serviceName = 'GitService'; // Enhanced Git service with complete functionality /** * Initializes a new Git repository with intelligent defaults */ export async function initRepo(directoryPath = '.', options = {}) { const git = simpleGit(directoryPath); try { await fs.mkdir(directoryPath, { recursive: true }); await git.init(); // Create default .gitignore if requested if (options.createGitignore) { try { const gitignoreContent = `# Default .gitignore\nnode_modules/\n.env\n.DS_Store\n`; await fs.writeFile(path.join(directoryPath, '.gitignore'), gitignoreContent); } catch (e) { logger.warn('Could not create default .gitignore', { error: e.message }); } } const message = `Initialized Git repository in ${path.resolve(directoryPath)}`; logger.info(message, { service: serviceName, path: directoryPath }); return message; } catch (error) { const errMsg = `Failed to initialize repository: ${error.message}`; logger.error(errMsg, { stack: error.stack, service: serviceName }); throw new Error(errMsg); } } /** * Adds files with intelligent handling of paths and patterns */ export async function addFiles(files = '.', directoryPath = '.') { const git = simpleGit(directoryPath); try { // Handle special cases if (files === 'all' || files === '.') files = ['.']; if (files === 'modified') { const status = await git.status(); files = status.modified; } await git.add(files); const message = `Staged ${Array.isArray(files) ? files.length : 1} file(s)`; logger.info(message, { service: serviceName, path: directoryPath }); return message; } catch (error) { // Handle common error: pathspec did not match if (error.message.includes('paths did not match')) { const status = await git.status(); const availableFiles = [ ...status.not_added, ...status.modified, ...status.created ]; const err = new Error(`No matching files. Available files:\n${availableFiles.join('\n')}`); logger.warn(err.message, { service: serviceName }); throw err; } const errMsg = `Failed to stage files: ${error.message}`; logger.error(errMsg, { stack: error.stack, service: serviceName }); throw new Error(errMsg); } } /** * Enhanced commit with auto-staging and commit message generation fallback */ export async function commitChanges(message, directoryPath = '.', authorOptions = {}) { if (!message || message.trim() === '') { const err = new Error('Commit message cannot be empty.'); logger.error(err.message, { service: serviceName }); throw err; } const git = simpleGit(directoryPath); const options = {}; // Get default author from git config if available let defaultAuthor = {}; try { const config = await git.listConfig(); defaultAuthor = { name: config.all['user.name'] || 'GitBot', email: config.all['user.email'] || 'gitbot@example.com' }; } catch (error) { defaultAuthor = { name: 'GitBot', email: 'gitbot@example.com' }; } // Resolve author information const authorName = authorOptions.name || process.env.GIT_USER_NAME || defaultAuthor.name; const authorEmail = authorOptions.email || process.env.GIT_USER_EMAIL || defaultAuthor.email; if (authorName && authorEmail) { options['--author'] = `${authorName} <${authorEmail}>`; } try { const commitSummary = await git.commit(message, undefined, options); logger.info(`Changes committed: "${message}" by ${authorName} <${authorEmail}>`, { summary: commitSummary, service: serviceName, path: directoryPath }); return { ...commitSummary, author: { name: authorName, email: authorEmail } }; } catch (error) { logger.error('Error committing changes:', { message: error.message, commitMessage: message, stack: error.stack, service: serviceName }); // Special handling for author errors if (error.message.includes("option `author' requires a value")) { throw new Error('Invalid author format. Please check your name and email configuration.'); } throw error; } } /** * Get diff output with formatting options */ export async function getDiff(options = '', directoryPath = '.') { const git = simpleGit(directoryPath); try { const diffOptions = typeof options === 'string' ? options.split(' ') : options; const diff = await git.diff(diffOptions); return diff; } catch (error) { const errMsg = `Failed to get diff: ${error.message}`; logger.error(errMsg, { stack: error.stack, service: serviceName }); throw new Error(errMsg); } } /** * Enhanced push with automatic token injection and branch handling */ export async function pushChanges(remoteName = 'origin', branchName, directoryPath = '.', options = {}) { const git = simpleGit(directoryPath); try { // Ensure we have a branch to push const currentBranch = branchName || (await git.branchLocal()).current; if (!currentBranch) throw new Error('No branch to push'); // Check if the branch exists locally const branches = await git.branchLocal(); if (!branches.all.includes(currentBranch)) { throw new Error(`Branch "${currentBranch}" does not exist locally. Please create it first.`); } // Inject token for GitHub remotes if (remoteName === 'origin') { await ensureAuthenticatedRemote(directoryPath); } const pushOptions = []; if (options.setUpstream) pushOptions.push('--set-upstream'); if (options.force) pushOptions.push('--force'); // For new branches, always use --set-upstream if (options.setUpstream || !branches.current) { pushOptions.push('--set-upstream'); } await git.push(remoteName, currentBranch, pushOptions); const result = `Pushed ${currentBranch} to ${remoteName}`; logger.info(result, { service: serviceName }); return result; } catch (error) { let errMsg = `Push failed: ${error.message}`; // Provide helpful solutions for common errors if (error.message.includes('no upstream branch')) { errMsg += '\nSolution: Try pushing with --set-upstream to set tracking'; } else if (error.message.includes('updates were rejected')) { errMsg += '\nSolution: Pull changes first or use --force to overwrite'; } else if (error.message.includes('authentication failed')) { errMsg += '\nSolution: Check your GitHub token using "gitmate config view"'; } else if (error.message.includes('src refspec')) { errMsg += '\nSolution: Ensure the branch exists and has commits before pushing'; } logger.error(errMsg, { stack: error.stack, service: serviceName }); throw new Error(errMsg); } } /** * Enhanced pull with conflict resolution guidance */ export async function pullChanges(remoteName = 'origin', branchName, directoryPath = '.') { const git = simpleGit(directoryPath); try { const currentBranch = branchName || (await git.branchLocal()).current; if (!currentBranch) throw new Error('No branch to pull into'); await ensureAuthenticatedRemote(directoryPath); const result = await git.pull(remoteName, currentBranch); // Check for merge conflicts - safely handle result object if (result && result.conflicts && result.conflicts.length > 0) { const conflictMsg = `${result.conflicts.length} conflict(s) need resolution`; logger.warn(conflictMsg, { service: serviceName }); return `${result.summary || 'Pull completed'}\n${chalk.yellow('Warning: ' + conflictMsg)}`; } return result.summary || 'Pull completed successfully'; } catch (error) { let errMsg = `Pull failed: ${error.message}`; if (error.message.includes('conflict')) { errMsg += '\nSolution: Resolve conflicts manually and commit the resolution'; } else if (error.message.includes('authentication failed')) { errMsg += '\nSolution: Check your GitHub token using "gitmate config view"'; } logger.error(errMsg, { stack: error.stack, service: serviceName }); throw new Error(errMsg); } } /** * Clone repository with progress reporting */ export async function cloneRepository(repoUrl, options = {}) { try { let urlToClone = repoUrl; // Inject token for GitHub private repos if (repoUrl.includes('github.com')) { const token = await getToken('github_access_token'); if (token) { // Parse owner/repo from URL const match = repoUrl.match(/github\.com[/:]([^/]+)\/([^/.]+)(\.git)?$/); if (match) { const [, owner, repo] = match; urlToClone = `https://x-access-token:${token}@github.com/${owner}/${repo}.git`; } } } const git = simpleGit(); // Extract repo name from URL for default directory name let defaultDirName = ''; const match = repoUrl.match(/([^/]+?)(?:\.git)?$/); if (match) { defaultDirName = match[1]; } // Prepare clone options const cloneOptions = []; if (options.directory) { cloneOptions.push(options.directory); } // Add any additional options if (options.branch) { cloneOptions.push('-b', options.branch); } if (options.depth) { cloneOptions.push('--depth', options.depth); } await git.clone(urlToClone, ...cloneOptions); const message = `Cloned ${repoUrl} to ${options.directory || defaultDirName}`; logger.info(message, { service: serviceName }); return message; } catch (error) { let errMsg = `Clone failed: ${error.message}`; if (error.message.includes('authentication failed')) { errMsg += '\nSolution: Check your credentials or use SSH key authentication'; } else if (error.message.includes('already exists')) { errMsg += `\nSolution: Remove existing directory or choose different path`; } logger.error(errMsg, { stack: error.stack, service: serviceName }); throw new Error(errMsg); } } /** * Fetch from remote with pruning */ export async function fetchRemote(remoteName = 'origin', directoryPath = '.', options = { prune: true }) { const git = simpleGit(directoryPath); try { const fetchOptions = []; if (options.prune) fetchOptions.push('--prune'); const result = await git.fetch(remoteName, fetchOptions); return result; } catch (error) { const errMsg = `Fetch failed: ${error.message}`; logger.error(errMsg, { stack: error.stack, service: serviceName }); throw new Error(errMsg); } } /** * Get commit history with formatting */ export async function getLog(directoryPath = '.', options = { maxCount: 10, format: 'medium' }) { const git = simpleGit(directoryPath); try { const logOptions = ['--max-count=' + options.maxCount]; if (options.format) logOptions.push('--format=' + options.format); const log = await git.log(logOptions); return log; } catch (error) { const errMsg = `Failed to get commit log: ${error.message}`; logger.error(errMsg, { stack: error.stack, service: serviceName }); throw new Error(errMsg); } } /** * Create and checkout branch with intelligent branch naming */ export async function createAndCheckoutBranch(branchName, directoryPath = '.', options = {}) { const git = simpleGit(directoryPath); try { // Clean branch name const cleanBranchName = branchName.replace(/[^a-zA-Z0-9_-]/g, '-'); // Check if branch exists const branches = await git.branchLocal(); if (branches.all.includes(cleanBranchName)) { if (options.checkoutExisting) { await git.checkout(cleanBranchName); return `Checked out existing branch: ${cleanBranchName}`; } throw new Error(`Branch "${cleanBranchName}" already exists`); } // Create branch await git.checkoutLocalBranch(cleanBranchName); return `Created and checked out new branch: ${cleanBranchName}`; } catch (error) { const errMsg = `Branch operation failed: ${error.message}`; logger.error(errMsg, { stack: error.stack, service: serviceName }); throw new Error(errMsg); } } /** * Merge branches with conflict detection */ export async function mergeBranch(sourceBranch, directoryPath = '.', options = {}) { const git = simpleGit(directoryPath); try { const result = await git.mergeFromTo(sourceBranch, undefined, options); if (result.conflicts.length > 0) { const conflictMsg = `${result.conflicts.length} conflicts need resolution`; logger.warn(conflictMsg, { service: serviceName }); return { summary: result.summary, conflicts: result.conflicts, message: chalk.yellow(conflictMsg) }; } return result.summary; } catch (error) { let errMsg = `Merge failed: ${error.message}`; if (error.git?.conflicts) { errMsg += `\nConflicts detected: ${error.git.conflicts.length} files`; errMsg += '\nSolution: Resolve conflicts and commit the resolution'; } logger.error(errMsg, { stack: error.stack, service: serviceName }); throw new Error(errMsg); } } /** * Rebase current branch with conflict handling */ export async function rebaseBranch(baseBranch, directoryPath = '.') { const git = simpleGit(directoryPath); try { await git.rebase([baseBranch]); return `Successfully rebased onto ${baseBranch}`; } catch (error) { let errMsg = `Rebase failed: ${error.message}`; if (error.git?.conflicts) { errMsg += `\nConflicts detected: ${error.git.conflicts.length} files`; errMsg += '\nSolution: Resolve conflicts and run "git rebase --continue"'; } logger.error(errMsg, { stack: error.stack, service: serviceName }); throw new Error(errMsg); } } /** * Enhanced revert with multiple commit support */ export async function revertCommit(commitHash = 'HEAD', directoryPath = '.', options = {}) { const git = simpleGit(directoryPath); try { const revertOptions = []; if (options.noEdit) revertOptions.push('--no-edit'); await git.revert([commitHash, ...revertOptions]); return `Reverted commit ${commitHash.slice(0, 7)}`; } catch (error) { const errMsg = `Revert failed: ${error.message}`; logger.error(errMsg, { stack: error.stack, service: serviceName }); throw new Error(errMsg); } } /** * Get file status with color-coded output */ export async function getStatus(directoryPath = '.') { const git = simpleGit(directoryPath); try { const status = await git.status(); // Return the raw status object for programmatic use return status; } catch (error) { const errMsg = `Failed to get status: ${error.message}`; logger.error(errMsg, { stack: error.stack, service: serviceName }); throw new Error(errMsg); } } /** * Get formatted status output for CLI display */ export async function getFormattedStatus(directoryPath = '.') { const git = simpleGit(directoryPath); try { const status = await git.status(); // Format for CLI output const formatFileList = (files, color) => files.map(f => chalk[color](` ${f}`)).join('\n'); const output = [ `On branch ${chalk.blue(status.current)}`, status.ahead ? ` ${chalk.yellow(status.ahead + ' commit(s) ahead')}` : '', status.behind ? ` ${chalk.yellow(status.behind + ' commit(s) behind')}` : '', '', status.staged.length ? chalk.green('Changes to be committed:') : '', formatFileList(status.staged, 'green'), '', status.modified.length ? chalk.yellow('Changes not staged:') : '', formatFileList(status.modified, 'yellow'), '', status.not_added.length ? chalk.red('Untracked files:') : '', formatFileList(status.not_added, 'red'), '', status.conflicted.length ? chalk.magenta('Unmerged paths:') : '', formatFileList(status.conflicted, 'magenta'), ].filter(Boolean).join('\n'); return output; } catch (error) { const errMsg = `Failed to get status: ${error.message}`; logger.error(errMsg, { stack: error.stack, service: serviceName }); throw new Error(errMsg); } } /** * Generate commit message from diff using AI fallback */ async function generateCommitMessageFromDiff(diff) { // Simple heuristic-based message generation if (diff.includes('+') && diff.includes('-')) { return 'refactor: update implementation'; } else if (diff.includes('+')) { return 'feat: add new functionality'; } else if (diff.includes('-')) { return 'fix: remove problematic code'; } return 'chore: update files'; } /** * Ensure authenticated remote URL for GitHub */ export async function ensureAuthenticatedRemote(directoryPath = '.') { const git = simpleGit(directoryPath); try { const remotes = await git.getRemotes(true); const origin = remotes.find(r => r.name === 'origin'); if (!origin) { return; } // Check if already properly authenticated if (origin.refs.fetch.includes('x-access-token:') && origin.refs.fetch.includes('@github.com')) { return; } const token = await getToken('github_access_token'); if (!token) { return; } // Parse repository info const urlMatch = origin.refs.fetch.match(/github\.com[/:]([^/]+)\/([^/.]+)(\.git)?$/); if (!urlMatch) { return; } const [, owner, repo] = urlMatch; const username = 'x-access-token'; const newUrl = `https://${username}:${token}@github.com/${owner}/${repo}.git`; await git.remote(['set-url', 'origin', newUrl]); logger.info(`Updated origin with authenticated URL`, { service: serviceName }); } catch (error) { logger.warn('Failed to update remote URL', { error: error.message }); } } /** * Set default branch via GitHub API */ export async function setDefaultBranch(branchName, directoryPath = '.') { try { const repoInfo = await getRemoteInfo(directoryPath); const [owner, repo] = repoInfo.split('/'); const token = await getToken('github_access_token'); if (!token) throw new Error('GitHub token not available'); await axios.patch( `https://api.github.com/repos/${owner}/${repo}`, { default_branch: branchName }, { headers: { Authorization: `token ${token}` } } ); return `Set default branch to ${branchName} for ${owner}/${repo}`; } catch (error) { let errMsg = `Failed to set default branch: ${error.response?.data?.message || error.message}`; if (error.response?.status === 403) { errMsg += '\nSolution: Ensure your token has repo permissions'; } else if (error.response?.status === 404) { errMsg += '\nSolution: Check repository name and permissions'; } logger.error(errMsg, { stack: error.stack, service: serviceName }); throw new Error(errMsg); } } /** * Get repository info from remote */ export async function getRemoteInfo(directoryPath = '.') { const git = simpleGit(directoryPath); try { const remotes = await git.getRemotes(true); const origin = remotes.find(r => r.name === 'origin'); if (!origin) throw new Error('No origin remote'); const url = origin.refs.push || origin.refs.fetch; const match = url.match(/github\.com[/:]([^/]+)\/([^/.]+)(\.git)?$/); if (!match) throw new Error('Could not parse GitHub URL'); const [, owner, repo] = match; return `${owner}/${repo}`; } catch (error) { const errMsg = `Failed to get remote info: ${error.message}`; logger.error(errMsg, { stack: error.stack, service: serviceName }); throw new Error(errMsg); } } // Additional utility functions /** * Check if directory is a Git repository */ export async function isGitRepository(directoryPath = '.') { const git = simpleGit(directoryPath); try { return await git.checkIsRepo(); } catch (error) { return false; } } /** * Get current branch name */ export async function getCurrentBranch(directoryPath = '.') { const git = simpleGit(directoryPath); try { const branch = await git.branchLocal(); return branch.current; } catch (error) { throw new Error('Could not determine current branch'); } } /** * Get diff between branches */ export async function getDiffBetweenBranches(sourceBranch, targetBranch, directoryPath = '.') { const git = simpleGit(directoryPath); try { return await git.diff([`${targetBranch}...${sourceBranch}`]); } catch (error) { throw new Error(`Failed to get diff between branches: ${error.message}`); } } /** * Get list of remotes */ export async function getRemotes(directoryPath = '.') { const git = simpleGit(directoryPath); try { return await git.getRemotes(true); } catch (error) { throw new Error(`Failed to get remotes: ${error.message}`); } } /** * Add a new remote */ export async function addRemote(name, url, directoryPath = '.') { const git = simpleGit(directoryPath); try { await git.addRemote(name, url); return `Added remote: ${name} -> ${url}`; } catch (error) { throw new Error(`Failed to add remote: ${error.message}`); } } /** * Checkout existing branch */ export async function checkoutBranch(branchName, directoryPath = '.') { const git = simpleGit(directoryPath); try { await git.checkout(branchName); return `Checked out branch: ${branchName}`; } catch (error) { throw new Error(`Failed to checkout branch: ${error.message}`); } } /** * Get branch list */ export async function listBranches(directoryPath = '.') { const git = simpleGit(directoryPath); try { const branches = await git.branch(); return branches.all; } catch (error) { throw new Error(`Failed to list branches: ${error.message}`); } } /** * Get remote branch list */ export async function listRemoteBranches(directoryPath = '.') { const git = simpleGit(directoryPath); try { const branches = await git.branch(['-r']); logger.info(`Remote branches found: ${branches.all?.length || 0}`, { branches: branches.all, service: serviceName }); return branches.all || []; } catch (error) { logger.error('Failed to list remote branches:', { error: error.message, stack: error.stack, service: serviceName }); throw new Error(`Failed to list remote branches: ${error.message}`); } } /** * Create a new branch */ export async function createBranch(branchName, directoryPath = '.') { const git = simpleGit(directoryPath); try { await git.branch([branchName]); return `Created branch: ${branchName}`; } catch (error) { throw new Error(`Failed to create branch: ${error.message}`); } } export async function configureGitUser(directoryPath = '.', userConfig = {}) { const git = simpleGit(directoryPath); try { if (userConfig.name) { await git.addConfig('user.name', userConfig.name); } if (userConfig.email) { await git.addConfig('user.email', userConfig.email); } // Return current config const name = await git.raw(['config', 'user.name']); const email = await git.raw(['config', 'user.email']); return { name: name.trim(), email: email.trim() }; } catch (error) { logger.error('Error configuring git user:', { message: error.message, config: userConfig, stack: error.stack, service: serviceName }); throw error; } } /** * Get Git configuration */ export async function getGitConfig(directoryPath = '.') { const git = simpleGit(directoryPath); try { const config = await git.listConfig(); // Extract user configuration const userConfig = { user: { name: config.all['user.name'] || null, email: config.all['user.email'] || null }, core: { repositoryformatversion: config.all['core.repositoryformatversion'] || null, filemode: config.all['core.filemode'] || null, bare: config.all['core.bare'] || null, logallrefupdates: config.all['core.logallrefupdates'] || null }, remote: {}, branch: {} }; // Extract remote configurations Object.keys(config.all).forEach(key => { if (key.startsWith('remote.')) { const parts = key.split('.'); if (parts.length >= 3) { const remoteName = parts[1]; const property = parts[2]; if (!userConfig.remote[remoteName]) { userConfig.remote[remoteName] = {}; } userConfig.remote[remoteName][property] = config.all[key]; } } }); // Extract branch configurations Object.keys(config.all).forEach(key => { if (key.startsWith('branch.')) { const parts = key.split('.'); if (parts.length >= 3) { const branchName = parts[1]; const property = parts[2]; if (!userConfig.branch[branchName]) { userConfig.branch[branchName] = {}; } userConfig.branch[branchName][property] = config.all[key]; } } }); return userConfig; } catch (error) { logger.error('Error getting git config:', { message: error.message, stack: error.stack, service: serviceName }); throw new Error(`Failed to get Git configuration: ${error.message}`); } }