Initial commit
Dieser Commit ist enthalten in:
549
backend/services/gitService.js
Normale Datei
549
backend/services/gitService.js
Normale Datei
@ -0,0 +1,549 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
function commit(localPath, message) {
|
||||
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, '\\"');
|
||||
return execGitCommand(`git commit -m "${escapedMessage}"`, 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, branch = null, remoteName = 'origin') {
|
||||
if (!isGitRepository(localPath)) {
|
||||
return { success: false, error: 'Kein Git-Repository' };
|
||||
}
|
||||
|
||||
// Aktuellen Branch ermitteln falls nicht angegeben
|
||||
if (!branch) {
|
||||
const branchResult = execGitCommand('git branch --show-current', localPath);
|
||||
branch = branchResult.success && branchResult.output ? branchResult.output : 'main';
|
||||
}
|
||||
|
||||
// Prüfe ob Commits existieren
|
||||
const 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');
|
||||
} else {
|
||||
return { success: false, error: 'Keine Commits und keine Dateien zum Committen vorhanden' };
|
||||
}
|
||||
}
|
||||
|
||||
// Push mit -u für Upstream-Tracking
|
||||
return execGitCommand(`git push -u ${remoteName} ${branch}`, localPath, { timeout: 120000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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' };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
windowsToContainerPath,
|
||||
containerToWindowsPath,
|
||||
isPathAccessible,
|
||||
isGitRepository,
|
||||
cloneRepository,
|
||||
pullChanges,
|
||||
pushChanges,
|
||||
getStatus,
|
||||
stageAll,
|
||||
commit,
|
||||
getCommitHistory,
|
||||
getBranches,
|
||||
checkoutBranch,
|
||||
fetchRemote,
|
||||
getRemoteUrl,
|
||||
initRepository,
|
||||
setRemote,
|
||||
hasRemote,
|
||||
pushWithUpstream,
|
||||
prepareForGitea
|
||||
};
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren