/** * TASKMATE - Git Service * ======================= * Lokale Git-Operationen über child_process */ const { execSync, exec } = require('child_process'); const path = require('path'); const fs = require('fs'); const logger = require('../utils/logger'); /** * Konvertiert einen Windows-Pfad zu einem Docker-Container-Pfad * z.B. "D:\Projekte\MyApp" -> "/mnt/d/Projekte/MyApp" */ function windowsToContainerPath(windowsPath) { if (!windowsPath) return null; // Bereits ein Container-Pfad? if (windowsPath.startsWith('/mnt/')) { return windowsPath; } // Windows-Pfad konvertieren (z.B. "C:\foo" oder "C:/foo") const normalized = windowsPath.replace(/\\/g, '/'); const match = normalized.match(/^([a-zA-Z]):[\/](.*)$/); if (match) { const drive = match[1].toLowerCase(); const restPath = match[2]; return `/mnt/${drive}/${restPath}`; } return windowsPath; } /** * Konvertiert einen Docker-Container-Pfad zu einem Windows-Pfad * z.B. "/mnt/d/Projekte/MyApp" -> "D:\Projekte\MyApp" */ function containerToWindowsPath(containerPath) { if (!containerPath) return null; const match = containerPath.match(/^\/mnt\/([a-z])\/(.*)$/); if (match) { const drive = match[1].toUpperCase(); const restPath = match[2].replace(/\//g, '\\'); return `${drive}:\\${restPath}`; } return containerPath; } /** * Prüft, ob ein Pfad existiert und erreichbar ist */ function isPathAccessible(localPath) { const containerPath = windowsToContainerPath(localPath); try { // Prüfe ob das übergeordnete Verzeichnis existiert const parentDir = path.dirname(containerPath); return fs.existsSync(parentDir); } catch (error) { logger.error('Pfadzugriff fehlgeschlagen:', error); return false; } } /** * Prüft, ob ein Verzeichnis ein Git-Repository ist */ function isGitRepository(localPath) { const containerPath = windowsToContainerPath(localPath); try { const gitDir = path.join(containerPath, '.git'); return fs.existsSync(gitDir); } catch (error) { return false; } } /** * Führt einen Git-Befehl aus */ function execGitCommand(command, cwd, options = {}) { const containerPath = windowsToContainerPath(cwd); const timeout = options.timeout || 60000; // 60 Sekunden Standard-Timeout try { const result = execSync(command, { cwd: containerPath, encoding: 'utf8', timeout, maxBuffer: 10 * 1024 * 1024, // 10 MB env: { ...process.env, GIT_TERMINAL_PROMPT: '0', // Keine interaktiven Prompts GIT_SSH_COMMAND: 'ssh -o StrictHostKeyChecking=no' } }); return { success: true, output: result.trim() }; } catch (error) { logger.error(`Git-Befehl fehlgeschlagen: ${command}`, error.message); return { success: false, error: error.message, stderr: error.stderr?.toString() || '' }; } } /** * Repository klonen */ async function cloneRepository(repoUrl, localPath, options = {}) { const containerPath = windowsToContainerPath(localPath); const branch = options.branch || 'main'; // Prüfe, ob das Zielverzeichnis bereits existiert if (fs.existsSync(containerPath)) { if (isGitRepository(localPath)) { return { success: false, error: 'Verzeichnis enthält bereits ein Git-Repository' }; } // Verzeichnis existiert, aber ist kein Git-Repo - prüfe ob leer const files = fs.readdirSync(containerPath); if (files.length > 0) { return { success: false, error: 'Verzeichnis ist nicht leer' }; } } else { // Erstelle Verzeichnis fs.mkdirSync(containerPath, { recursive: true }); } // Gitea-Token für Authentifizierung hinzufügen let authUrl = repoUrl; const giteaToken = process.env.GITEA_TOKEN; if (giteaToken && repoUrl.includes('gitea')) { // Füge Token zur URL hinzu: https://token@gitea.example.com/... authUrl = repoUrl.replace('https://', `https://${giteaToken}@`); } const command = `git clone --branch ${branch} "${authUrl}" "${containerPath}"`; try { execSync(command, { encoding: 'utf8', timeout: 300000, // 5 Minuten für Clone maxBuffer: 50 * 1024 * 1024, // 50 MB env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }); logger.info(`Repository geklont: ${repoUrl} -> ${localPath}`); return { success: true, message: 'Repository erfolgreich geklont' }; } catch (error) { logger.error('Clone fehlgeschlagen:', error.message); // Bereinige bei Fehler try { if (fs.existsSync(containerPath)) { fs.rmSync(containerPath, { recursive: true, force: true }); } } catch (e) { // Ignorieren } return { success: false, error: error.message }; } } /** * Änderungen ziehen (Pull) */ function pullChanges(localPath, options = {}) { if (!isGitRepository(localPath)) { return { success: false, error: 'Kein Git-Repository' }; } const branch = options.branch || ''; const command = branch ? `git pull origin ${branch}` : 'git pull'; return execGitCommand(command, localPath); } /** * Änderungen hochladen (Push) */ function pushChanges(localPath, options = {}) { if (!isGitRepository(localPath)) { return { success: false, error: 'Kein Git-Repository' }; } const branch = options.branch || ''; const command = branch ? `git push origin ${branch}` : 'git push'; return execGitCommand(command, localPath, { timeout: 120000 }); } /** * Git-Status abrufen */ function getStatus(localPath) { if (!isGitRepository(localPath)) { return { success: false, error: 'Kein Git-Repository' }; } const result = execGitCommand('git status --porcelain', localPath); if (!result.success) { return result; } const lines = result.output.split('\n').filter(l => l.trim()); const changes = lines.map(line => { const status = line.substring(0, 2); const file = line.substring(3); return { status, file }; }); // Zusätzliche Infos const branchResult = execGitCommand('git branch --show-current', localPath); const aheadBehindResult = execGitCommand('git rev-list --left-right --count HEAD...@{upstream} 2>/dev/null || echo "0 0"', localPath); let ahead = 0; let behind = 0; if (aheadBehindResult.success) { const parts = aheadBehindResult.output.split(/\s+/); ahead = parseInt(parts[0]) || 0; behind = parseInt(parts[1]) || 0; } return { success: true, branch: branchResult.success ? branchResult.output : 'unknown', changes, hasChanges: changes.length > 0, ahead, behind, isClean: changes.length === 0 && ahead === 0 }; } /** * Alle Änderungen stagen */ function stageAll(localPath) { if (!isGitRepository(localPath)) { return { success: false, error: 'Kein Git-Repository' }; } return execGitCommand('git add -A', localPath); } /** * Commit erstellen * @param {string} localPath - Pfad zum Repository * @param {string} message - Commit-Nachricht * @param {object} author - Autor-Informationen { name, email } */ function commit(localPath, message, author = null) { if (!isGitRepository(localPath)) { return { success: false, error: 'Kein Git-Repository' }; } if (!message || message.trim() === '') { return { success: false, error: 'Commit-Nachricht erforderlich' }; } // Escape für Shell const escapedMessage = message.replace(/"/g, '\\"'); // Commit-Befehl mit optionalem Autor let commitCmd = `git commit -m "${escapedMessage}"`; if (author && author.name) { const authorName = author.name.replace(/"/g, '\\"'); const authorEmail = author.email || `${author.name.toLowerCase().replace(/\s+/g, '.')}@taskmate.local`; commitCmd = `git commit --author="${authorName} <${authorEmail}>" -m "${escapedMessage}"`; logger.info(`Commit mit Autor: ${authorName} <${authorEmail}>`); } return execGitCommand(commitCmd, localPath); } /** * Commit-Historie abrufen */ function getCommitHistory(localPath, limit = 20) { if (!isGitRepository(localPath)) { return { success: false, error: 'Kein Git-Repository' }; } const format = '%H|%h|%an|%ae|%at|%s'; const result = execGitCommand(`git log -${limit} --format="${format}"`, localPath); if (!result.success) { return result; } const commits = result.output.split('\n').filter(l => l.trim()).map(line => { const [hash, shortHash, author, email, timestamp, subject] = line.split('|'); return { hash, shortHash, author, email, date: new Date(parseInt(timestamp) * 1000).toISOString(), message: subject }; }); return { success: true, commits }; } /** * Branches auflisten */ function getBranches(localPath) { if (!isGitRepository(localPath)) { return { success: false, error: 'Kein Git-Repository' }; } const result = execGitCommand('git branch -a', localPath); if (!result.success) { return result; } const branches = result.output.split('\n').filter(l => l.trim()).map(line => { const isCurrent = line.startsWith('*'); const name = line.replace(/^\*?\s+/, '').trim(); const isRemote = name.startsWith('remotes/'); return { name, isCurrent, isRemote }; }); return { success: true, branches }; } /** * Branch wechseln */ function checkoutBranch(localPath, branch) { if (!isGitRepository(localPath)) { return { success: false, error: 'Kein Git-Repository' }; } return execGitCommand(`git checkout ${branch}`, localPath); } /** * Fetch von Remote */ function fetchRemote(localPath) { if (!isGitRepository(localPath)) { return { success: false, error: 'Kein Git-Repository' }; } return execGitCommand('git fetch --all', localPath, { timeout: 120000 }); } /** * Remote-URL abrufen */ function getRemoteUrl(localPath) { if (!isGitRepository(localPath)) { return { success: false, error: 'Kein Git-Repository' }; } const result = execGitCommand('git remote get-url origin', localPath); if (result.success) { // Entferne Token aus URL für Anzeige let url = result.output; url = url.replace(/https:\/\/[^@]+@/, 'https://'); return { success: true, url }; } return result; } /** * Git Repository initialisieren */ function initRepository(localPath, options = {}) { const containerPath = windowsToContainerPath(localPath); const branch = options.branch || 'main'; // Prüfe ob bereits ein Git-Repo existiert if (isGitRepository(localPath)) { return { success: true, message: 'Git-Repository existiert bereits' }; } // Prüfe ob Verzeichnis existiert if (!fs.existsSync(containerPath)) { return { success: false, error: 'Verzeichnis existiert nicht' }; } // Git init ausführen const initResult = execGitCommand(`git init -b ${branch}`, localPath); if (!initResult.success) { return initResult; } logger.info(`Git-Repository initialisiert: ${localPath}`); return { success: true, message: 'Git-Repository initialisiert' }; } /** * Remote hinzufügen oder aktualisieren */ function setRemote(localPath, remoteUrl, remoteName = 'origin') { if (!isGitRepository(localPath)) { return { success: false, error: 'Kein Git-Repository' }; } // Gitea-Token für Authentifizierung hinzufügen let authUrl = remoteUrl; const giteaToken = process.env.GITEA_TOKEN; if (giteaToken && (remoteUrl.includes('gitea') || remoteUrl.includes('aegis-sight'))) { authUrl = remoteUrl.replace('https://', `https://${giteaToken}@`); } // Prüfe ob Remote bereits existiert const checkResult = execGitCommand(`git remote get-url ${remoteName}`, localPath); if (checkResult.success) { // Remote existiert - aktualisieren const result = execGitCommand(`git remote set-url ${remoteName} "${authUrl}"`, localPath); if (result.success) { logger.info(`Remote '${remoteName}' aktualisiert: ${remoteUrl}`); return { success: true, message: 'Remote aktualisiert' }; } return result; } else { // Remote existiert nicht - hinzufügen const result = execGitCommand(`git remote add ${remoteName} "${authUrl}"`, localPath); if (result.success) { logger.info(`Remote '${remoteName}' hinzugefügt: ${remoteUrl}`); return { success: true, message: 'Remote hinzugefügt' }; } return result; } } /** * Prüft ob ein Remote existiert */ function hasRemote(localPath, remoteName = 'origin') { if (!isGitRepository(localPath)) { return false; } const result = execGitCommand(`git remote get-url ${remoteName}`, localPath); return result.success; } /** * Initialen Push mit Upstream-Tracking */ function pushWithUpstream(localPath, targetBranch = null, remoteName = 'origin', force = false) { if (!isGitRepository(localPath)) { return { success: false, error: 'Kein Git-Repository' }; } // Prüfe ob Commits existieren let logResult = execGitCommand('git rev-parse HEAD', localPath); if (!logResult.success) { // Keine Commits - erstelle einen initialen Commit const statusResult = execGitCommand('git status --porcelain', localPath); if (statusResult.success && statusResult.output.trim()) { // Es gibt Dateien - stage und commit const stageResult = stageAll(localPath); if (!stageResult.success) { return { success: false, error: 'Staging fehlgeschlagen: ' + stageResult.error }; } const commitResult = commit(localPath, 'Initial commit'); if (!commitResult.success) { return { success: false, error: 'Initial commit fehlgeschlagen: ' + commitResult.error }; } logger.info('Initialer Commit erstellt vor Push'); // Verifiziere dass der Commit erstellt wurde logResult = execGitCommand('git rev-parse HEAD', localPath); if (!logResult.success) { return { success: false, error: 'Commit wurde erstellt aber HEAD existiert nicht' }; } } else { return { success: false, error: 'Keine Commits und keine Dateien zum Committen vorhanden' }; } } // Aktuellen (lokalen) Branch ermitteln NACH dem Commit let localBranch = null; const branchResult = execGitCommand('git branch --show-current', localPath); if (branchResult.success && branchResult.output) { localBranch = branchResult.output; logger.info(`Lokaler Branch: ${localBranch}`); } else { // Fallback: Branch aus git branch auslesen const branchListResult = execGitCommand('git branch', localPath); logger.info(`Branch-Liste: ${branchListResult.output}`); if (branchListResult.success && branchListResult.output) { const lines = branchListResult.output.split('\n'); for (const line of lines) { if (line.includes('*')) { localBranch = line.replace('*', '').trim(); break; } } } if (!localBranch) { // Letzter Fallback: Versuche den Branch zu erstellen logger.info('Kein Branch gefunden, erstelle main'); execGitCommand('git checkout -b main', localPath); localBranch = 'main'; } } // Wenn kein Ziel-Branch angegeben, verwende lokalen Branch-Namen const remoteBranch = targetBranch || localBranch; logger.info(`Push: lokaler Branch '${localBranch}' → Remote Branch '${remoteBranch}'${force ? ' (FORCE)' : ''}`); // Push mit -u für Upstream-Tracking // Format: git push -u origin localBranch:remoteBranch const forceFlag = force ? ' --force' : ''; let pushCommand; if (localBranch === remoteBranch) { pushCommand = `git push -u${forceFlag} ${remoteName} ${localBranch}`; } else { // Branch-Mapping: lokalen Branch auf anderen Remote-Branch pushen pushCommand = `git push -u${forceFlag} ${remoteName} ${localBranch}:${remoteBranch}`; } const pushResult = execGitCommand(pushCommand, localPath, { timeout: 120000 }); if (!pushResult.success) { logger.error(`Push fehlgeschlagen: ${pushResult.error}`); } // Branch-Namen im Ergebnis inkludieren für Default-Branch Aktualisierung pushResult.localBranch = localBranch; pushResult.branch = remoteBranch; // Remote-Branch für Gitea Default-Branch return pushResult; } /** * Repository für Gitea vorbereiten (init, remote, initial commit) */ function prepareForGitea(localPath, remoteUrl, options = {}) { const branch = options.branch || 'main'; const containerPath = windowsToContainerPath(localPath); // 1. Prüfe ob Git-Repo existiert, wenn nicht initialisieren if (!isGitRepository(localPath)) { const initResult = initRepository(localPath, { branch }); if (!initResult.success) { return initResult; } } // 2. Remote hinzufügen/aktualisieren const remoteResult = setRemote(localPath, remoteUrl); if (!remoteResult.success) { return remoteResult; } // 3. Prüfe ob Commits existieren const logResult = execGitCommand('git rev-parse HEAD', localPath); if (!logResult.success) { // Keine Commits - erstelle initialen Commit wenn Dateien vorhanden const statusResult = execGitCommand('git status --porcelain', localPath); if (statusResult.success && statusResult.output.trim()) { // Es gibt Dateien - stage und commit const stageResult = stageAll(localPath); if (!stageResult.success) { return stageResult; } const commitResult = commit(localPath, 'Initial commit'); if (!commitResult.success) { return commitResult; } } } logger.info(`Repository für Gitea vorbereitet: ${localPath} -> ${remoteUrl}`); return { success: true, message: 'Repository für Gitea vorbereitet' }; } /** * Branch umbenennen */ function renameBranch(localPath, oldName, newName) { if (!isGitRepository(localPath)) { return { success: false, error: 'Kein Git-Repository' }; } // Validiere neuen Branch-Namen if (!newName || !/^[a-zA-Z0-9_\-]+$/.test(newName)) { return { success: false, error: 'Ungültiger Branch-Name. Nur Buchstaben, Zahlen, Unterstriche und Bindestriche erlaubt.' }; } // Prüfe ob wir auf dem zu umbenennenden Branch sind const branchResult = execGitCommand('git branch --show-current', localPath); const currentBranch = branchResult.success ? branchResult.output : null; if (currentBranch !== oldName) { return { success: false, error: `Bitte wechsle zuerst zum Branch "${oldName}" bevor du ihn umbenennst.` }; } // Branch umbenennen const renameResult = execGitCommand(`git branch -m ${oldName} ${newName}`, localPath); if (!renameResult.success) { logger.error(`Branch umbenennen fehlgeschlagen: ${renameResult.error}`); return renameResult; } logger.info(`Branch umbenannt: ${oldName} → ${newName}`); return { success: true, oldName, newName, message: `Branch "${oldName}" zu "${newName}" umbenannt` }; } module.exports = { windowsToContainerPath, containerToWindowsPath, isPathAccessible, isGitRepository, cloneRepository, pullChanges, pushChanges, getStatus, stageAll, commit, getCommitHistory, getBranches, checkoutBranch, fetchRemote, getRemoteUrl, initRepository, setRemote, hasRemote, pushWithUpstream, prepareForGitea, renameBranch };