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
JavaScript
/**
* 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}`);
}
}
}