UNPKG

ollama-code-qwen

Version:

Un assistant IA en ligne de commande utilisant Ollama et le modèle qwen2.5-coder pour aider au développement, avec des capacités MCP améliorées et détection d'intentions en français et anglais

537 lines (474 loc) 16.7 kB
/** * Service pour interagir avec Git */ import simpleGit from 'simple-git'; import fs from 'fs/promises'; import path from 'path'; export class GitService { /** * Initialise le service Git * @param {string} repoPath - Chemin vers le dépôt Git (default: current working directory) */ constructor(repoPath = process.cwd()) { this.git = simpleGit(repoPath); this.workingDir = repoPath; } /** * Vérifie si le répertoire courant est un dépôt Git * @returns {Promise<boolean>} - True si c'est un dépôt Git */ async isGitRepository() { try { await this.git.revparse(['--is-inside-work-tree']); return true; } catch (error) { return false; } } /** * Initialise un nouveau dépôt Git * @returns {Promise<string>} - Message de résultat */ async initRepository() { try { await this.git.init(); return 'Git repository initialized successfully.'; } catch (error) { throw new Error(`Failed to initialize Git repository: ${error.message}`); } } /** * Récupère l'état actuel du dépôt * @returns {Promise<Object>} - État du dépôt */ async status() { try { const status = await this.git.status(); return { current: status.current, tracking: status.tracking, changed: status.files.length, staged: status.files.filter(file => file.staged).length, files: status.files, isClean: status.isClean(), }; } catch (error) { throw new Error(`Failed to get Git status: ${error.message}`); } } /** * Ajoute des fichiers à l'index * @param {string} files - Fichiers à ajouter (default: '.') * @returns {Promise<string>} - Message de résultat */ async add(files = '.') { try { await this.git.add(files); const status = await this.status(); return `Added ${status.staged} file(s) to the staging area.`; } catch (error) { throw new Error(`Failed to add files to Git: ${error.message}`); } } /** * Crée un commit * @param {string} message - Message de commit * @returns {Promise<string>} - Message de résultat */ async commit(message) { try { const result = await this.git.commit(message); return `Created commit: ${result.commit} (${result.summary.changes} files changed)`; } catch (error) { throw new Error(`Failed to commit changes: ${error.message}`); } } /** * Récupère les modifications depuis le dépôt distant * @param {string} remote - Nom du dépôt distant (default: 'origin') * @param {string} branch - Nom de la branche (default: current branch) * @returns {Promise<string>} - Message de résultat */ async pull(remote = 'origin', branch = null) { try { const result = await this.git.pull(remote, branch); return `Pulled changes: ${result.summary.changes} files changed, ${result.summary.insertions} insertions, ${result.summary.deletions} deletions`; } catch (error) { throw new Error(`Failed to pull changes: ${error.message}`); } } /** * Pousse les modifications vers le dépôt distant * @param {string} remote - Nom du dépôt distant (default: 'origin') * @param {string} branch - Nom de la branche (default: current branch) * @returns {Promise<string>} - Message de résultat */ async push(remote = 'origin', branch = null) { try { await this.git.push(remote, branch); return `Pushed changes to ${remote}${branch ? `/${branch}` : ''}`; } catch (error) { throw new Error(`Failed to push changes: ${error.message}`); } } /** * Récupère l'historique des commits * @param {number} maxCount - Nombre maximum de commits à récupérer (default: 10) * @returns {Promise<Array>} - Liste des commits */ async log(maxCount = 10) { try { const result = await this.git.log({ maxCount }); return result.all; } catch (error) { throw new Error(`Failed to get Git log: ${error.message}`); } } /** * Crée une nouvelle branche * @param {string} branchName - Nom de la branche * @returns {Promise<string>} - Message de résultat */ async createBranch(branchName) { try { await this.git.checkoutLocalBranch(branchName); return `Created and switched to new branch '${branchName}'`; } catch (error) { throw new Error(`Failed to create branch: ${error.message}`); } } /** * Change de branche * @param {string} branchName - Nom de la branche * @returns {Promise<string>} - Message de résultat */ async checkout(branchName) { try { await this.git.checkout(branchName); return `Switched to branch '${branchName}'`; } catch (error) { throw new Error(`Failed to checkout branch: ${error.message}`); } } /** * Liste toutes les branches * @returns {Promise<Array>} - Liste des branches */ async listBranches() { try { const branchSummary = await this.git.branch(); return { current: branchSummary.current, all: branchSummary.all, branches: branchSummary.branches }; } catch (error) { throw new Error(`Failed to list branches: ${error.message}`); } } /** * Crée un tag * @param {string} tagName - Nom du tag * @param {string} message - Message du tag * @returns {Promise<string>} - Message de résultat */ async createTag(tagName, message) { try { await this.git.addTag(tagName); return `Created tag '${tagName}'`; } catch (error) { throw new Error(`Failed to create tag: ${error.message}`); } } /** * Exécute une commande Git arbitraire * @param {Array<string>} args - Arguments de la commande Git * @returns {Promise<string>} - Résultat de la commande */ async raw(args) { try { const result = await this.git.raw(args); return result; } catch (error) { throw new Error(`Git command failed: ${error.message}`); } } /** * Analyse les changements récents dans le dépôt * @param {number} days - Nombre de jours à analyser * @returns {Promise<Object>} - Statistiques et tendances des changements */ async analyzeRecentChanges(days = 7) { try { // Vérifier si c'est un dépôt Git if (!(await this.isGitRepository())) { throw new Error('Not a Git repository'); } // Récupérer les commits des derniers jours const since = new Date(); since.setDate(since.getDate() - days); const sinceStr = since.toISOString().split('T')[0]; const log = await this.git.log({ from: 'HEAD', since: sinceStr }); // Analyser les auteurs const authors = {}; log.all.forEach(commit => { const author = commit.author_email; authors[author] = (authors[author] || 0) + 1; }); // Analyser les fichiers modifiés const fileChanges = {}; for (const commit of log.all) { const { hash } = commit; const show = await this.git.show([hash, '--name-only', '--oneline']); // Extraire les noms de fichiers const files = show.split('\n').slice(1).filter(Boolean); for (const file of files) { fileChanges[file] = (fileChanges[file] || 0) + 1; } } // Trier les fichiers par nombre de modifications const sortedFiles = Object.entries(fileChanges) .sort((a, b) => b[1] - a[1]) .slice(0, 10); // Top 10 return { commitCount: log.all.length, authors: Object.entries(authors).sort((a, b) => b[1] - a[1]), mostChangedFiles: sortedFiles, period: `${sinceStr} to present` }; } catch (error) { throw new Error(`Failed to analyze recent changes: ${error.message}`); } } /** * Suggère un message de commit basé sur les changements en attente * @returns {Promise<string>} - Message de commit suggéré */ async suggestCommitMessage() { try { // Vérifier s'il y a des changements en attente const status = await this.status(); if (status.files.length === 0) { return 'No changes to commit'; } // Récupérer les diff des fichiers modifiés const diffs = []; for (const file of status.files) { try { // Ignorer les fichiers supprimés if (file.working_dir === 'D') continue; const diff = await this.git.diff([file.path]); diffs.push({ file: file.path, content: diff }); } catch (error) { // Ignorer les erreurs de diff } } // Construire un résumé des changements const changesSummary = diffs.map(d => `File: ${d.file}\n${d.content}`).join('\n\n'); // Si les changements sont trop volumineux, limiter const trimmedSummary = changesSummary.length > 5000 ? changesSummary.substring(0, 5000) + '...(truncated)' : changesSummary; // Créer un message simple basé sur les fichiers modifiés const fileTypesChanged = new Set(); status.files.forEach(file => { const ext = path.extname(file.path).toLowerCase(); if (ext) fileTypesChanged.add(ext.substring(1)); // Supprimer le point // Détecter les opérations courantes if (file.path.includes('test') || file.path.includes('spec')) { fileTypesChanged.add('tests'); } if (file.path.includes('doc') || file.path.includes('README')) { fileTypesChanged.add('docs'); } if (file.path.includes('config') || file.path.includes('.config')) { fileTypesChanged.add('config'); } }); // Règle simple pour le message if (fileTypesChanged.has('tests') && fileTypesChanged.size === 1) { return 'test: add/update tests'; } if (fileTypesChanged.has('docs') && fileTypesChanged.size === 1) { return 'docs: update documentation'; } if (fileTypesChanged.has('config') && fileTypesChanged.size === 1) { return 'config: update configuration files'; } const changedPaths = status.files.map(f => f.path); const commonPrefix = this._findCommonPathPrefix(changedPaths); if (commonPrefix && commonPrefix !== '.') { return `feat(${commonPrefix}): update ${Array.from(fileTypesChanged).join(', ')} files`; } return `feat: update ${Array.from(fileTypesChanged).join(', ')} files`; } catch (error) { throw new Error(`Failed to suggest commit message: ${error.message}`); } } /** * Trouve le préfixe de chemin commun entre plusieurs fichiers * @param {Array<string>} paths - Liste de chemins * @returns {string} - Préfixe commun */ _findCommonPathPrefix(paths) { if (paths.length === 0) return ''; if (paths.length === 1) return path.dirname(paths[0]); const parts = paths.map(p => p.split(path.sep)); const minLength = Math.min(...parts.map(p => p.length)); let commonParts = []; for (let i = 0; i < minLength; i++) { const part = parts[0][i]; if (parts.every(p => p[i] === part)) { commonParts.push(part); } else { break; } } return commonParts.join(path.sep) || '.'; } /** * Gère les conflits de fusion * @returns {Promise<Object>} - Information sur les conflits */ async handleMergeConflicts() { try { // Vérifier s'il y a des conflits en cours const status = await this.status(); const conflictFiles = status.files.filter(file => file.working_dir === 'U' || // U = unmerged file.working_dir === 'A' && file.index === 'A' || // both added file.working_dir === 'D' && file.index === 'D' // both deleted ); if (conflictFiles.length === 0) { return { hasConflicts: false, message: 'No merge conflicts detected' }; } // Compiler des informations sur les conflits const conflicts = []; for (const file of conflictFiles) { try { // Lire le fichier avec les marqueurs de conflit const content = await fs.readFile(path.join(this.workingDir, file.path), 'utf-8'); // Compter les sections de conflit const conflictSections = (content.match(/<<<<<<< HEAD/g) || []).length; conflicts.push({ file: file.path, sections: conflictSections }); } catch (error) { conflicts.push({ file: file.path, error: error.message }); } } return { hasConflicts: true, files: conflicts, message: `Found ${conflictFiles.length} files with merge conflicts` }; } catch (error) { throw new Error(`Failed to check merge conflicts: ${error.message}`); } } /** * Affiche les conflits dans un fichier spécifique * @param {string} filePath - Chemin du fichier * @returns {Promise<string>} - Contenu du fichier avec les conflits */ async showConflictsInFile(filePath) { try { // Lire le fichier const fullPath = path.join(this.workingDir, filePath); const content = await fs.readFile(fullPath, 'utf-8'); // Extraire les sections de conflit const conflictRegex = /(<<<<<<< HEAD\n)([\s\S]*?)(=======\n)([\s\S]*?)(>>>>>>> .*\n)/g; const conflicts = []; let match; while ((match = conflictRegex.exec(content)) !== null) { conflicts.push({ ours: match[2], theirs: match[4], full: match[0] }); } if (conflicts.length === 0) { return { hasConflicts: false, message: `No conflicts found in ${filePath}`, content }; } return { hasConflicts: true, conflictCount: conflicts.length, conflicts, content }; } catch (error) { throw new Error(`Failed to read conflicts in ${filePath}: ${error.message}`); } } /** * Résout un conflit dans un fichier spécifique * @param {string} filePath - Chemin du fichier * @param {string} resolution - Résolution à appliquer ('ours', 'theirs', 'both-ours-first', 'both-theirs-first', 'custom') * @param {string} customContent - Contenu personnalisé (pour le mode 'custom') * @returns {Promise<Object>} - Résultat de la résolution */ async resolveConflict(filePath, resolution, customContent = null) { try { // Lire le fichier avec les conflits const conflictInfo = await this.showConflictsInFile(filePath); if (!conflictInfo.hasConflicts) { throw new Error(`No conflicts found in ${filePath}`); } let newContent = conflictInfo.content; // Appliquer la résolution pour chaque conflit for (const conflict of conflictInfo.conflicts) { let replacement = ''; switch (resolution) { case 'ours': replacement = conflict.ours; break; case 'theirs': replacement = conflict.theirs; break; case 'both-ours-first': replacement = conflict.ours + conflict.theirs; break; case 'both-theirs-first': replacement = conflict.theirs + conflict.ours; break; case 'custom': if (!customContent) { throw new Error('Custom content is required for custom resolution'); } replacement = customContent; break; default: throw new Error(`Unknown resolution strategy: ${resolution}`); } // Remplacer le conflit par la résolution newContent = newContent.replace(conflict.full, replacement); } // Écrire le fichier résolu const fullPath = path.join(this.workingDir, filePath); await fs.writeFile(fullPath, newContent, 'utf-8'); // Marquer comme résolu await this.git.add(filePath); return { success: true, file: filePath, message: `Resolved ${conflictInfo.conflictCount} conflicts in ${filePath} using ${resolution} strategy` }; } catch (error) { throw new Error(`Failed to resolve conflicts in ${filePath}: ${error.message}`); } } }