Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5571f225d5 |
File diff suppressed because one or more lines are too long
@@ -43,6 +43,7 @@ const {
|
|||||||
getGiteaRepoContents,
|
getGiteaRepoContents,
|
||||||
getGiteaFileContent,
|
getGiteaFileContent,
|
||||||
uploadGiteaFile,
|
uploadGiteaFile,
|
||||||
|
uploadItemsViaGiteaApi,
|
||||||
getGiteaCurrentUser,
|
getGiteaCurrentUser,
|
||||||
getGiteaCommits,
|
getGiteaCommits,
|
||||||
getGiteaCommit,
|
getGiteaCommit,
|
||||||
@@ -112,6 +113,278 @@ const TMP_CLEANUP_MS = 20_000;
|
|||||||
const RETRY_QUEUE_INTERVAL_MS = 15_000;
|
const RETRY_QUEUE_INTERVAL_MS = 15_000;
|
||||||
const RETRY_MAX_ATTEMPTS = 8;
|
const RETRY_MAX_ATTEMPTS = 8;
|
||||||
|
|
||||||
|
// Git-Konfiguration gegen abbrechende HTTP-Transfers bei grossen Repos.
|
||||||
|
// HTTP/1.1 statt HTTP/2 verhindert die haeufigen "early EOF"/"invalid index-pack output"-Abbrueche
|
||||||
|
// hinter Reverse-Proxies; die lowSpeed-Werte verhindern, dass der Client langsame Packs abbricht.
|
||||||
|
const GIT_TRANSFER_CONFIG = [
|
||||||
|
'http.version=HTTP/1.1',
|
||||||
|
'http.postBuffer=524288000',
|
||||||
|
'http.lowSpeedLimit=0',
|
||||||
|
'http.lowSpeedTime=600',
|
||||||
|
'core.longpaths=true'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Anzahl der Clone-Versuche bei abgebrochenen Uebertragungen.
|
||||||
|
const GIT_CLONE_MAX_ATTEMPTS = 3;
|
||||||
|
|
||||||
|
// Server, deren Git-Transport sich als unbrauchbar erwiesen hat (z. B. weil upload-pack
|
||||||
|
// keine Packfiles erzeugen kann). Für diese wird der Clone übersprungen und sofort der
|
||||||
|
// API-Weg genommen — sonst kostet jeder Upload erneut sechs vergebliche Versuche.
|
||||||
|
//
|
||||||
|
// Der Merker liegt auf der Platte, damit auch der erste Upload nach einem Neustart
|
||||||
|
// sofort den funktionierenden Weg nimmt. Die Ablaufzeit sorgt dafür, dass ein wieder
|
||||||
|
// reparierter Server von selbst erneut probiert wird — ohne Eingriff im Code.
|
||||||
|
const brokenGitTransports = new Map();
|
||||||
|
const GIT_TRANSPORT_BLOCK_MS = 24 * 60 * 60 * 1000;
|
||||||
|
let brokenGitTransportsLoaded = false;
|
||||||
|
|
||||||
|
function getGitTransportStateFilePath() {
|
||||||
|
return ppath.join(app.getPath('userData'), 'git-transport-state.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadBrokenGitTransports() {
|
||||||
|
if (brokenGitTransportsLoaded) return;
|
||||||
|
brokenGitTransportsLoaded = true;
|
||||||
|
try {
|
||||||
|
const file = getGitTransportStateFilePath();
|
||||||
|
if (!fs.existsSync(file)) return;
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
for (const [host, since] of Object.entries(parsed || {})) {
|
||||||
|
if (typeof since === 'number') brokenGitTransports.set(host, since);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('loadBrokenGitTransports:', e.message || e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveBrokenGitTransports() {
|
||||||
|
try {
|
||||||
|
const file = getGitTransportStateFilePath();
|
||||||
|
const dir = app.getPath('userData');
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(file, JSON.stringify(Object.fromEntries(brokenGitTransports), null, 2), 'utf8');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('saveBrokenGitTransports:', e.message || e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitTransportKey(authUrl) {
|
||||||
|
try {
|
||||||
|
return new URL(authUrl).host;
|
||||||
|
} catch (_) {
|
||||||
|
return String(authUrl || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markGitTransportBroken(authUrl) {
|
||||||
|
loadBrokenGitTransports();
|
||||||
|
brokenGitTransports.set(gitTransportKey(authUrl), Date.now());
|
||||||
|
saveBrokenGitTransports();
|
||||||
|
}
|
||||||
|
|
||||||
|
function markGitTransportWorking(authUrl) {
|
||||||
|
loadBrokenGitTransports();
|
||||||
|
if (brokenGitTransports.delete(gitTransportKey(authUrl))) saveBrokenGitTransports();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGitTransportKnownBroken(authUrl) {
|
||||||
|
loadBrokenGitTransports();
|
||||||
|
const key = gitTransportKey(authUrl);
|
||||||
|
const since = brokenGitTransports.get(key);
|
||||||
|
if (!since) return false;
|
||||||
|
if (Date.now() - since > GIT_TRANSPORT_BLOCK_MS) {
|
||||||
|
brokenGitTransports.delete(key);
|
||||||
|
saveBrokenGitTransports();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fehler, die auf einen abgebrochenen Transfer hindeuten (nicht auf ein leeres/fehlendes Repo).
|
||||||
|
function isTransientGitTransferError(message) {
|
||||||
|
const raw = String(message || '').toLowerCase();
|
||||||
|
return raw.includes('early eof')
|
||||||
|
|| raw.includes('index-pack')
|
||||||
|
|| raw.includes('rpc failed')
|
||||||
|
|| raw.includes('remote end hung up')
|
||||||
|
|| raw.includes('unexpected disconnect')
|
||||||
|
|| raw.includes('transfer closed')
|
||||||
|
|| raw.includes('connection reset')
|
||||||
|
|| raw.includes('curl 92')
|
||||||
|
|| raw.includes('curl 56')
|
||||||
|
|| raw.includes('curl 18');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leert das Zielverzeichnis, damit ein erneuter Clone-Versuch nicht an Resten scheitert.
|
||||||
|
function clearDirContents(dirPath) {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(dirPath)) {
|
||||||
|
fs.mkdirSync(dirPath, { recursive: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const entry of fs.readdirSync(dirPath)) {
|
||||||
|
fs.rmSync(ppath.join(dirPath, entry), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone-Strategien, absteigend nach Sparsamkeit.
|
||||||
|
// "blobless" laedt nur Commits und Trees, keine Dateiinhalte: Der Server muss keine Blobs packen.
|
||||||
|
// Das ist bei Repos mit grossen Binaerdateien der entscheidende Unterschied und umgeht
|
||||||
|
// serverseitige Abbrueche beim Packen. --no-checkout haelt den Arbeitsbaum leer, der Index
|
||||||
|
// wird trotzdem aus HEAD befuellt, sodass Commit und Push den bestehenden Stand erhalten.
|
||||||
|
// "shallow" ist der klassische Fallback fuer Server ohne Partial-Clone-Unterstuetzung.
|
||||||
|
const GIT_CLONE_STRATEGIES = [
|
||||||
|
{ name: 'blobless', args: ['--filter=blob:none', '--no-checkout'], checkout: false },
|
||||||
|
{ name: 'shallow', args: [], checkout: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shallow-Clone fuer die Upload-Flows, mit Strategie- und Wiederholungs-Fallback.
|
||||||
|
* Nutzt --single-branch: es wird nur der Branch geholt, in den anschliessend gepusht wird.
|
||||||
|
* Bei abgebrochenen Transfers ("early EOF", "invalid index-pack output") wird erneut versucht,
|
||||||
|
* danach mit der naechsten Strategie. Erkennt leere Repos und initialisiert sie lokal.
|
||||||
|
*
|
||||||
|
* @returns {Promise<{ repoGit: object, isEmptyRepo: boolean, strategy: string }>}
|
||||||
|
*/
|
||||||
|
async function cloneForUpload({ authUrl, tmpDir, branch, gitConfig, label = 'Clone' }) {
|
||||||
|
const simpleGit = require('simple-git');
|
||||||
|
const config = [...gitConfig, ...GIT_TRANSFER_CONFIG];
|
||||||
|
const safeBranch = sanitizeGitRef(branch || 'HEAD', 'HEAD');
|
||||||
|
|
||||||
|
// Bekannt defekter Server: gar nicht erst versuchen.
|
||||||
|
if (isGitTransportKnownBroken(authUrl)) {
|
||||||
|
console.log(`[${label}] Git-Transport für ${gitTransportKey(authUrl)} ist als unbrauchbar bekannt — überspringe Clone`);
|
||||||
|
throw new Error('Git-Transport für diesen Server ist als unbrauchbar bekannt (übersprungen).');
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastErr = null;
|
||||||
|
|
||||||
|
for (const strategy of GIT_CLONE_STRATEGIES) {
|
||||||
|
const cloneArgs = ['--depth', '1', '--single-branch', ...strategy.args];
|
||||||
|
if (safeBranch !== 'HEAD') cloneArgs.push('--branch', safeBranch);
|
||||||
|
|
||||||
|
let previousMsg = null;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= GIT_CLONE_MAX_ATTEMPTS; attempt++) {
|
||||||
|
try {
|
||||||
|
if (attempt > 1) clearDirContents(tmpDir);
|
||||||
|
console.log(`[${label}] Clone-Versuch ${attempt}/${GIT_CLONE_MAX_ATTEMPTS} (${strategy.name}):`, { cloneArgs, tmpDir });
|
||||||
|
await simpleGit({ config }).clone(authUrl, tmpDir, cloneArgs);
|
||||||
|
const repoGit = simpleGit({ baseDir: tmpDir, config });
|
||||||
|
|
||||||
|
// Ein Clone mit --no-checkout laesst den Index leer. Ohne read-tree wuerde der
|
||||||
|
// folgende Commit den kompletten Bestand des Branches als geloescht markieren.
|
||||||
|
if (!strategy.checkout) {
|
||||||
|
await repoGit.raw(['read-tree', 'HEAD']);
|
||||||
|
}
|
||||||
|
|
||||||
|
markGitTransportWorking(authUrl);
|
||||||
|
return { repoGit, isEmptyRepo: false, strategy: strategy.name };
|
||||||
|
} catch (cloneErr) {
|
||||||
|
const cloneMsg = String(cloneErr);
|
||||||
|
|
||||||
|
// Leeres Repo ohne Commits: lokal initialisieren statt klonen.
|
||||||
|
if (cloneMsg.includes('empty') || cloneMsg.includes('nothing to clone') ||
|
||||||
|
cloneMsg.includes('did not match') || cloneMsg.includes('Remote branch')) {
|
||||||
|
console.log(`[${label}] Leeres Repo erkannt — initialisiere lokal als "${safeBranch}"`);
|
||||||
|
clearDirContents(tmpDir);
|
||||||
|
const repoGit = simpleGit({ baseDir: tmpDir, config });
|
||||||
|
await repoGit.init();
|
||||||
|
|
||||||
|
// git init nutzt init.defaultBranch (haeufig "master"). Ohne diese Korrektur landet
|
||||||
|
// der Upload auf einem anderen Branch als dem angeforderten und ist im Gitea-Web
|
||||||
|
// nicht sichtbar, weil dort "main" der Standard ist.
|
||||||
|
if (safeBranch !== 'HEAD') {
|
||||||
|
await repoGit.raw(['symbolic-ref', 'HEAD', `refs/heads/${safeBranch}`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await repoGit.addRemote('origin', authUrl);
|
||||||
|
return { repoGit, isEmptyRepo: true, strategy: 'init' };
|
||||||
|
}
|
||||||
|
|
||||||
|
lastErr = cloneErr;
|
||||||
|
clearDirContents(tmpDir);
|
||||||
|
|
||||||
|
// Nicht-transiente Fehler (z. B. fehlende Partial-Clone-Unterstuetzung)
|
||||||
|
// sofort mit der naechsten Strategie beantworten statt zu wiederholen.
|
||||||
|
if (!isTransientGitTransferError(cloneMsg)) {
|
||||||
|
console.warn(`[${label}] Strategie "${strategy.name}" nicht nutzbar:`, cloneMsg);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt === GIT_CLONE_MAX_ATTEMPTS) {
|
||||||
|
console.warn(`[${label}] Strategie "${strategy.name}" nach ${attempt} Versuchen aufgegeben`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zweimal exakt derselbe Fehler heißt: Das ist kein Aussetzer, sondern ein
|
||||||
|
// dauerhafter Defekt. Weitere Versuche kosten nur Zeit.
|
||||||
|
if (cloneMsg === previousMsg) {
|
||||||
|
console.warn(`[${label}] Strategie "${strategy.name}" scheitert reproduzierbar — keine weiteren Versuche`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
previousMsg = cloneMsg;
|
||||||
|
|
||||||
|
console.warn(`[${label}] Transfer abgebrochen, neuer Versuch:`, cloneMsg);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, attempt * 1500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle Strategien erschöpft: Server für die nächsten Uploads überspringen.
|
||||||
|
markGitTransportBroken(authUrl);
|
||||||
|
console.warn(`[${label}] Git-Transport für ${gitTransportKey(authUrl)} wird künftig übersprungen — Uploads laufen über die API`);
|
||||||
|
|
||||||
|
throw lastErr || new Error('Clone fehlgeschlagen');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staged die hochgeladenen Dateien.
|
||||||
|
* --ignore-removal: Bei der blobless-Strategie steht der Bestand nur im Index (siehe read-tree
|
||||||
|
* in cloneForUpload), nicht im Arbeitsbaum. Ohne dieses Flag wuerde git add die fehlenden
|
||||||
|
* Dateien als Loeschungen stagen. Uploads loeschen ohnehin nie etwas.
|
||||||
|
*/
|
||||||
|
async function stageUploadedFiles(repoGit) {
|
||||||
|
await repoGit.raw(['add', '--ignore-removal', '.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ausweichweg, wenn der Git-Transport nicht funktioniert.
|
||||||
|
*
|
||||||
|
* Nötig, wenn git-upload-pack auf dem Server keine Packfiles liefert ("early EOF",
|
||||||
|
* "invalid index-pack output"). Der Upload läuft dann rein über die REST-API und
|
||||||
|
* braucht weder Clone noch Push.
|
||||||
|
*
|
||||||
|
* @returns {Promise<{ok: boolean, uploaded: string[], failed: Array<{path: string, error: string}>, commits: number, branch: string}>}
|
||||||
|
*/
|
||||||
|
async function uploadItemsViaApiFallback({ token, url, owner, repo, branch, items, message, onProgress, label = 'Upload' }) {
|
||||||
|
console.warn(`[${label}] Git-Transport nicht nutzbar — weiche auf die Contents-API aus (${items.length} Dateien)`);
|
||||||
|
|
||||||
|
const result = await uploadItemsViaGiteaApi({
|
||||||
|
token, url, owner, repo, branch, items, message, onProgress
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[${label}] API-Upload fertig:`, {
|
||||||
|
commits: result.commits,
|
||||||
|
uploaded: result.uploaded.length,
|
||||||
|
failed: result.failed.length,
|
||||||
|
branch: result.branch
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: result.failed.length === 0, ...result };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fasst die Fehler eines API-Fallbacks zu einer Meldung zusammen.
|
||||||
|
function describeApiFallbackFailure(gitErr, apiResult) {
|
||||||
|
const gitPart = `Git-Transport fehlgeschlagen: ${gitErr && gitErr.message ? gitErr.message : String(gitErr)}`;
|
||||||
|
if (!apiResult) return gitPart;
|
||||||
|
|
||||||
|
const firstErrors = apiResult.failed.slice(0, 3).map(f => `${f.path}: ${f.error}`).join('; ');
|
||||||
|
const more = apiResult.failed.length > 3 ? ` (+${apiResult.failed.length - 3} weitere)` : '';
|
||||||
|
return `${gitPart} — auch der API-Upload schlug fehl: ${firstErrors}${more}`;
|
||||||
|
}
|
||||||
|
|
||||||
let retryQueue = [];
|
let retryQueue = [];
|
||||||
let retryQueueRunning = false;
|
let retryQueueRunning = false;
|
||||||
let retryQueueTimer = null;
|
let retryQueueTimer = null;
|
||||||
@@ -818,6 +1091,10 @@ function sanitizeErrorForLog(errorLike) {
|
|||||||
function mapIpcError(errorLike) {
|
function mapIpcError(errorLike) {
|
||||||
const raw = String(errorLike && errorLike.message ? errorLike.message : errorLike || '').toLowerCase();
|
const raw = String(errorLike && errorLike.message ? errorLike.message : errorLike || '').toLowerCase();
|
||||||
if (!raw) return 'Unbekannter Fehler.';
|
if (!raw) return 'Unbekannter Fehler.';
|
||||||
|
// Zuerst pruefen: abgebrochene Uebertragungen sind spezifischer als die Status-Code-Heuristik unten.
|
||||||
|
if (isTransientGitTransferError(raw)) {
|
||||||
|
return 'Übertragung vom Server abgebrochen (early EOF / index-pack). Repository zu groß oder Timeout beim Gitea-Server bzw. Reverse-Proxy. Bitte erneut versuchen.';
|
||||||
|
}
|
||||||
if (raw.includes('401') || raw.includes('authentifizierung') || raw.includes('unauthorized')) {
|
if (raw.includes('401') || raw.includes('authentifizierung') || raw.includes('unauthorized')) {
|
||||||
return 'Authentifizierung fehlgeschlagen. Bitte Token in den Einstellungen prüfen.';
|
return 'Authentifizierung fehlgeschlagen. Bitte Token in den Einstellungen prüfen.';
|
||||||
}
|
}
|
||||||
@@ -828,13 +1105,23 @@ function mapIpcError(errorLike) {
|
|||||||
return 'Server oder Ressource nicht gefunden. Bitte URL und Repository prüfen.';
|
return 'Server oder Ressource nicht gefunden. Bitte URL und Repository prüfen.';
|
||||||
}
|
}
|
||||||
if (raw.includes('econnrefused') || raw.includes('enotfound') || raw.includes('eai_again') || raw.includes('getaddrinfo')) {
|
if (raw.includes('econnrefused') || raw.includes('enotfound') || raw.includes('eai_again') || raw.includes('getaddrinfo')) {
|
||||||
return 'Server nicht erreichbar. Bitte DNS, IPv4/IPv6 und Port prüfen.';
|
return 'Server nicht erreichbar. Bitte DNS, Server-IP und Port prüfen.';
|
||||||
}
|
}
|
||||||
if (raw.includes('timeout') || raw.includes('econnaborted')) {
|
if (raw.includes('timeout') || raw.includes('econnaborted')) {
|
||||||
return 'Zeitüberschreitung bei der Verbindung. Bitte Netzwerk oder Server prüfen.';
|
return 'Zeitüberschreitung bei der Verbindung. Bitte Netzwerk oder Server prüfen.';
|
||||||
}
|
}
|
||||||
if (raw.includes('http://') || raw.includes('https://') || raw.includes('ungültige gitea url') || raw.includes('ungültige gitea url') || raw.includes('invalid')) {
|
if (
|
||||||
return 'Ungültige URL. Beispiel für IPv6: http://[2001:db8::1]:3000';
|
raw.includes('ungültige url') ||
|
||||||
|
raw.includes('ungueltige url') ||
|
||||||
|
raw.includes('ungültige gitea url') ||
|
||||||
|
raw.includes('ungueltige gitea url') ||
|
||||||
|
raw.includes('invalid url') ||
|
||||||
|
raw.includes('err_invalid_url') ||
|
||||||
|
raw.includes('invalid uri') ||
|
||||||
|
raw.includes('unsupported protocol') ||
|
||||||
|
raw.includes('only absolute urls')
|
||||||
|
) {
|
||||||
|
return 'Ungültige URL. Beispiel: https://git.example.net oder http://192.168.1.10:3000';
|
||||||
}
|
}
|
||||||
return String(errorLike && errorLike.message ? errorLike.message : errorLike);
|
return String(errorLike && errorLike.message ? errorLike.message : errorLike);
|
||||||
}
|
}
|
||||||
@@ -2107,8 +2394,7 @@ ipcMain.handle('upload-gitea-file', async (event, data) => {
|
|||||||
return { ok: failedCount === 0, results, failedCount, debugId: uploadDebugId };
|
return { ok: failedCount === 0, results, failedCount, debugId: uploadDebugId };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Git-basierter Upload via simple-git — umgeht Giteas API-Index-Timing-Probleme (422 SHA) zuverlässig.
|
// Git-basierter Upload via cloneForUpload() — umgeht Giteas API-Index-Timing-Probleme (422 SHA) zuverlässig.
|
||||||
const simpleGit = require('simple-git');
|
|
||||||
let authUrl;
|
let authUrl;
|
||||||
try {
|
try {
|
||||||
const rawUrl = url.startsWith('http') ? url : `https://${url}`;
|
const rawUrl = url.startsWith('http') ? url : `https://${url}`;
|
||||||
@@ -2122,27 +2408,13 @@ ipcMain.handle('upload-gitea-file', async (event, data) => {
|
|||||||
const gitConfig = ['user.email=gui@gitmanager.local', 'user.name=Git Manager GUI'];
|
const gitConfig = ['user.email=gui@gitmanager.local', 'user.name=Git Manager GUI'];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const git = simpleGit({ config: gitConfig });
|
const { repoGit, isEmptyRepo } = await cloneForUpload({
|
||||||
let repoGit;
|
authUrl,
|
||||||
let isEmptyRepo = false;
|
tmpDir,
|
||||||
|
branch,
|
||||||
try {
|
gitConfig,
|
||||||
const cloneArgs = ['--depth', '1', '--no-single-branch'];
|
label: 'FileUpload'
|
||||||
if (branch !== 'HEAD') cloneArgs.push('--branch', branch);
|
});
|
||||||
await git.clone(authUrl, tmpDir, cloneArgs);
|
|
||||||
repoGit = simpleGit({ baseDir: tmpDir, config: gitConfig });
|
|
||||||
} catch (cloneErr) {
|
|
||||||
const cloneMsg = String(cloneErr);
|
|
||||||
if (cloneMsg.includes('empty') || cloneMsg.includes('nothing to clone') ||
|
|
||||||
cloneMsg.includes('did not match') || cloneMsg.includes('Remote branch')) {
|
|
||||||
isEmptyRepo = true;
|
|
||||||
repoGit = simpleGit({ baseDir: tmpDir, config: gitConfig });
|
|
||||||
await repoGit.init();
|
|
||||||
await repoGit.addRemote('origin', authUrl);
|
|
||||||
} else {
|
|
||||||
throw cloneErr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dateien in den Zielordner kopieren
|
// Dateien in den Zielordner kopieren
|
||||||
for (const localFile of validFiles) {
|
for (const localFile of validFiles) {
|
||||||
@@ -2154,7 +2426,7 @@ ipcMain.handle('upload-gitea-file', async (event, data) => {
|
|||||||
results.push({ file: localFile, ok: true, targetPath });
|
results.push({ file: localFile, ok: true, targetPath });
|
||||||
}
|
}
|
||||||
|
|
||||||
await repoGit.add('.');
|
await stageUploadedFiles(repoGit);
|
||||||
|
|
||||||
let hasChanges = true;
|
let hasChanges = true;
|
||||||
try {
|
try {
|
||||||
@@ -2186,7 +2458,45 @@ ipcMain.handle('upload-gitea-file', async (event, data) => {
|
|||||||
} catch (gitErr) {
|
} catch (gitErr) {
|
||||||
try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
||||||
logger.error('upload-gitea-file', `Git upload failed`, { error: String(gitErr), uploadDebugId });
|
logger.error('upload-gitea-file', `Git upload failed`, { error: String(gitErr), uploadDebugId });
|
||||||
return { ok: false, error: `Upload fehlgeschlagen: ${gitErr.message || String(gitErr)}`, debugId: uploadDebugId };
|
|
||||||
|
// Ausweichweg über die REST-API, falls der Git-Transport nicht nutzbar ist.
|
||||||
|
try {
|
||||||
|
const apiItems = validFiles.map(localFile => {
|
||||||
|
const fileName = ppath.basename(localFile);
|
||||||
|
return { localFile, targetPath: destPath ? `${destPath}/${fileName}` : fileName };
|
||||||
|
});
|
||||||
|
|
||||||
|
const apiResult = await uploadItemsViaApiFallback({
|
||||||
|
token, url, owner, repo, branch,
|
||||||
|
items: apiItems,
|
||||||
|
message,
|
||||||
|
label: 'FileUpload'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (apiResult.ok) {
|
||||||
|
// Erfolgsmeldungen des gescheiterten Git-Versuchs verwerfen, damit keine
|
||||||
|
// Dubletten entstehen. Die Fehler zu ungültigen Pfaden bleiben erhalten.
|
||||||
|
for (let i = results.length - 1; i >= 0; i--) {
|
||||||
|
if (results[i].ok) results.splice(i, 1);
|
||||||
|
}
|
||||||
|
for (const item of apiItems) {
|
||||||
|
results.push({ file: item.localFile, ok: true, targetPath: item.targetPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiFailedCount = results.filter(r => !r.ok).length;
|
||||||
|
logger.info('upload-gitea-file', 'Upload über API-Fallback erfolgreich', {
|
||||||
|
commits: apiResult.commits,
|
||||||
|
failedCount: apiFailedCount,
|
||||||
|
uploadDebugId
|
||||||
|
});
|
||||||
|
return { ok: apiFailedCount === 0, results, failedCount: apiFailedCount, viaApi: true, debugId: uploadDebugId };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, error: describeApiFallbackFailure(gitErr, apiResult), debugId: uploadDebugId };
|
||||||
|
} catch (apiErr) {
|
||||||
|
logger.error('upload-gitea-file', 'API-Fallback fehlgeschlagen', { error: String(apiErr), uploadDebugId });
|
||||||
|
return { ok: false, error: describeApiFallbackFailure(gitErr, null) + ` — API-Fallback: ${apiErr.message || String(apiErr)}`, debugId: uploadDebugId };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const failedCount = results.filter(r => !r.ok).length;
|
const failedCount = results.filter(r => !r.ok).length;
|
||||||
@@ -2334,7 +2644,6 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
|
|||||||
|
|
||||||
// Git-basierter Upload: alle Dateien in einem einzigen Commit.
|
// Git-basierter Upload: alle Dateien in einem einzigen Commit.
|
||||||
// Vermeidet Giteas API-Index-Timing-Probleme (422 SHA) komplett.
|
// Vermeidet Giteas API-Index-Timing-Probleme (422 SHA) komplett.
|
||||||
const simpleGit = require('simple-git');
|
|
||||||
|
|
||||||
// Auth-URL mit Token für HTTPS-Auth bauen
|
// Auth-URL mit Token für HTTPS-Auth bauen
|
||||||
let authUrl;
|
let authUrl;
|
||||||
@@ -2355,32 +2664,13 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
|
|||||||
console.log('[FolderUpload] Starte Git-Upload:', { owner, repo, branch, total, destPath, tmpDir });
|
console.log('[FolderUpload] Starte Git-Upload:', { owner, repo, branch, total, destPath, tmpDir });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const git = simpleGit({ config: gitConfig });
|
const { repoGit, isEmptyRepo } = await cloneForUpload({
|
||||||
let repoGit;
|
authUrl,
|
||||||
let isEmptyRepo = false;
|
tmpDir,
|
||||||
|
branch,
|
||||||
try {
|
gitConfig,
|
||||||
const cloneArgs = ['--depth', '1', '--no-single-branch'];
|
label: 'FolderUpload'
|
||||||
if (branch !== 'HEAD') cloneArgs.push('--branch', branch);
|
});
|
||||||
console.log('[FolderUpload] Clone-Versuch:', { cloneArgs, tmpDir });
|
|
||||||
await git.clone(authUrl, tmpDir, cloneArgs);
|
|
||||||
console.log('[FolderUpload] Clone erfolgreich');
|
|
||||||
repoGit = simpleGit({ baseDir: tmpDir, config: gitConfig });
|
|
||||||
} catch (cloneErr) {
|
|
||||||
const cloneMsg = String(cloneErr);
|
|
||||||
console.warn('[FolderUpload] Clone-Fehler:', cloneMsg);
|
|
||||||
// Leeres Repo ohne Commits: lokal initialisieren
|
|
||||||
if (cloneMsg.includes('empty') || cloneMsg.includes('nothing to clone') ||
|
|
||||||
cloneMsg.includes('did not match') || cloneMsg.includes('Remote branch')) {
|
|
||||||
console.log('[FolderUpload] Leeres Repo erkannt — initialisiere lokal');
|
|
||||||
isEmptyRepo = true;
|
|
||||||
repoGit = simpleGit({ baseDir: tmpDir, config: gitConfig });
|
|
||||||
await repoGit.init();
|
|
||||||
await repoGit.addRemote('origin', authUrl);
|
|
||||||
} else {
|
|
||||||
throw cloneErr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fortschritt: 30% (nach Clone)
|
// Fortschritt: 30% (nach Clone)
|
||||||
try { event.sender.send('folder-upload-progress', { processed: Math.floor(total * 0.3), total, percent: 30 }); } catch (_) {}
|
try { event.sender.send('folder-upload-progress', { processed: Math.floor(total * 0.3), total, percent: 30 }); } catch (_) {}
|
||||||
@@ -2396,7 +2686,7 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
|
|||||||
try { event.sender.send('folder-upload-progress', { processed: Math.floor(total * 0.6), total, percent: 60 }); } catch (_) {}
|
try { event.sender.send('folder-upload-progress', { processed: Math.floor(total * 0.6), total, percent: 60 }); } catch (_) {}
|
||||||
|
|
||||||
// git add + commit + push
|
// git add + commit + push
|
||||||
await repoGit.add('.');
|
await stageUploadedFiles(repoGit);
|
||||||
|
|
||||||
let hasChanges = true;
|
let hasChanges = true;
|
||||||
try {
|
try {
|
||||||
@@ -2436,7 +2726,29 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
|
|||||||
} catch (gitErr) {
|
} catch (gitErr) {
|
||||||
console.error('[FolderUpload] Git-Fehler:', String(gitErr));
|
console.error('[FolderUpload] Git-Fehler:', String(gitErr));
|
||||||
try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
||||||
throw gitErr;
|
|
||||||
|
// Ausweichweg über die REST-API, falls der Git-Transport nicht nutzbar ist.
|
||||||
|
const apiResult = await uploadItemsViaApiFallback({
|
||||||
|
token, url, owner, repo, branch, items,
|
||||||
|
message: `${messagePrefix} - ${folderName}`,
|
||||||
|
label: 'FolderUpload',
|
||||||
|
onProgress: percent => {
|
||||||
|
try {
|
||||||
|
event.sender.send('folder-upload-progress', {
|
||||||
|
processed: Math.floor(total * (percent / 100)),
|
||||||
|
total,
|
||||||
|
percent
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!apiResult.ok) {
|
||||||
|
throw new Error(describeApiFallbackFailure(gitErr, apiResult));
|
||||||
|
}
|
||||||
|
|
||||||
|
const successResults = items.map(item => ({ ok: true, localFile: item.localFile, targetPath: item.targetPath }));
|
||||||
|
return { ok: true, results: successResults, failedCount: 0, viaApi: true };
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('upload-local-folder-to-gitea error', e);
|
console.error('upload-local-folder-to-gitea error', e);
|
||||||
@@ -2492,8 +2804,6 @@ function buildItemsFromPaths(paths, destPath) {
|
|||||||
|
|
||||||
// Klont das Repo (shallow), kopiert alle items hinein, commit + push.
|
// Klont das Repo (shallow), kopiert alle items hinein, commit + push.
|
||||||
async function gitPushItemsToGitea({ token, url, owner, repo, branch, items, message, onProgress }) {
|
async function gitPushItemsToGitea({ token, url, owner, repo, branch, items, message, onProgress }) {
|
||||||
const simpleGit = require('simple-git');
|
|
||||||
|
|
||||||
const rawUrl = url.startsWith('http') ? url : `https://${url}`;
|
const rawUrl = url.startsWith('http') ? url : `https://${url}`;
|
||||||
const urlObj = new URL(rawUrl.replace(/\/$/, ''));
|
const urlObj = new URL(rawUrl.replace(/\/$/, ''));
|
||||||
const authUrl = `${urlObj.protocol}//${encodeURIComponent(token)}@${urlObj.host}/${owner}/${repo}.git`;
|
const authUrl = `${urlObj.protocol}//${encodeURIComponent(token)}@${urlObj.host}/${owner}/${repo}.git`;
|
||||||
@@ -2503,28 +2813,13 @@ async function gitPushItemsToGitea({ token, url, owner, repo, branch, items, mes
|
|||||||
const safeBranch = sanitizeGitRef(branch || 'HEAD', 'HEAD');
|
const safeBranch = sanitizeGitRef(branch || 'HEAD', 'HEAD');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const git = simpleGit({ config: gitConfig });
|
const { repoGit, isEmptyRepo } = await cloneForUpload({
|
||||||
let repoGit;
|
authUrl,
|
||||||
let isEmptyRepo = false;
|
tmpDir,
|
||||||
|
branch: safeBranch,
|
||||||
try {
|
gitConfig,
|
||||||
const cloneArgs = ['--depth', '1', '--no-single-branch'];
|
label: 'UploadPaths'
|
||||||
if (safeBranch !== 'HEAD') cloneArgs.push('--branch', safeBranch);
|
});
|
||||||
console.log('[UploadPaths] Clone-Versuch:', { cloneArgs, tmpDir });
|
|
||||||
await git.clone(authUrl, tmpDir, cloneArgs);
|
|
||||||
repoGit = simpleGit({ baseDir: tmpDir, config: gitConfig });
|
|
||||||
} catch (cloneErr) {
|
|
||||||
const cloneMsg = String(cloneErr);
|
|
||||||
if (cloneMsg.includes('empty') || cloneMsg.includes('nothing to clone') ||
|
|
||||||
cloneMsg.includes('did not match') || cloneMsg.includes('Remote branch')) {
|
|
||||||
isEmptyRepo = true;
|
|
||||||
repoGit = simpleGit({ baseDir: tmpDir, config: gitConfig });
|
|
||||||
await repoGit.init();
|
|
||||||
await repoGit.addRemote('origin', authUrl);
|
|
||||||
} else {
|
|
||||||
throw cloneErr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onProgress) onProgress(30);
|
if (onProgress) onProgress(30);
|
||||||
|
|
||||||
@@ -2536,7 +2831,7 @@ async function gitPushItemsToGitea({ token, url, owner, repo, branch, items, mes
|
|||||||
|
|
||||||
if (onProgress) onProgress(60);
|
if (onProgress) onProgress(60);
|
||||||
|
|
||||||
await repoGit.add('.');
|
await stageUploadedFiles(repoGit);
|
||||||
|
|
||||||
let hasChanges = true;
|
let hasChanges = true;
|
||||||
try {
|
try {
|
||||||
@@ -2560,7 +2855,18 @@ async function gitPushItemsToGitea({ token, url, owner, repo, branch, items, mes
|
|||||||
return { hasChanges };
|
return { hasChanges };
|
||||||
} catch (gitErr) {
|
} catch (gitErr) {
|
||||||
try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
||||||
throw gitErr;
|
|
||||||
|
// Ausweichweg über die REST-API, falls der Git-Transport nicht nutzbar ist.
|
||||||
|
const apiResult = await uploadItemsViaApiFallback({
|
||||||
|
token, url, owner, repo, branch: safeBranch, items, message, onProgress,
|
||||||
|
label: 'UploadPaths'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!apiResult.ok) {
|
||||||
|
throw new Error(describeApiFallbackFailure(gitErr, apiResult));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { hasChanges: apiResult.uploaded.length > 0, viaApi: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "git-manager-gui",
|
"name": "git-manager-gui",
|
||||||
"version": "2.1.6",
|
"version": "2.1.7",
|
||||||
"description": "Git Manager GUI - Verwaltung von Git Repositories",
|
"description": "Git Manager GUI - Verwaltung von Git Repositories",
|
||||||
"author": "M_Viper",
|
"author": "M_Viper",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
|
|||||||
+45
-7
@@ -2125,6 +2125,36 @@ function updateSettingsHealth(patch) {
|
|||||||
syncSettingsPanelHeights();
|
syncSettingsPanelHeights();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INVALID_URL_HINT = 'Ungültige URL. Beispiel: https://git.example.net oder http://192.168.1.10:3000';
|
||||||
|
|
||||||
|
// Interne Fehlercodes aus dem Main-Prozess in verständliche Texte übersetzen.
|
||||||
|
const ERROR_CODE_MESSAGES = {
|
||||||
|
'missing-token-or-url': 'Gitea Token oder URL fehlt. Bitte in den Einstellungen speichern.',
|
||||||
|
'missing-token': 'Token fehlt. Bitte in den Einstellungen speichern.',
|
||||||
|
'missing-url': 'Gitea URL fehlt. Bitte in den Einstellungen speichern.'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Abgebrochene Git-Uebertragungen erkennen (grosses Repo, Server-/Proxy-Timeout).
|
||||||
|
function isTransferAbortedError(raw) {
|
||||||
|
return raw.includes('early eof')
|
||||||
|
|| raw.includes('index-pack')
|
||||||
|
|| raw.includes('rpc failed')
|
||||||
|
|| raw.includes('remote end hung up')
|
||||||
|
|| raw.includes('unexpected disconnect')
|
||||||
|
|| raw.includes('transfer closed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur echte URL-Parse-Fehler erkennen - nicht jede Meldung, in der "URL" vorkommt.
|
||||||
|
function isInvalidUrlError(raw) {
|
||||||
|
return raw.includes('ungültige url')
|
||||||
|
|| raw.includes('ungueltige url')
|
||||||
|
|| raw.includes('invalid url')
|
||||||
|
|| raw.includes('err_invalid_url')
|
||||||
|
|| raw.includes('invalid uri')
|
||||||
|
|| raw.includes('unsupported protocol')
|
||||||
|
|| raw.includes('only absolute urls');
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAndValidateGiteaUrl(rawUrl) {
|
function normalizeAndValidateGiteaUrl(rawUrl) {
|
||||||
const value = (rawUrl || '').trim();
|
const value = (rawUrl || '').trim();
|
||||||
if (!value) return { ok: true, value: '' };
|
if (!value) return { ok: true, value: '' };
|
||||||
@@ -2135,7 +2165,7 @@ function normalizeAndValidateGiteaUrl(rawUrl) {
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: 'Ungültige URL. Beispiel für IPv6: http://[2001:db8::1]:3000'
|
error: INVALID_URL_HINT
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2173,8 +2203,16 @@ function renderGithubTokenHint(rawToken) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mapErrorMessage(message) {
|
function mapErrorMessage(message) {
|
||||||
const raw = String(message || '').toLowerCase();
|
const original = String(message ?? '').trim();
|
||||||
if (!raw) return 'Unbekannter Fehler';
|
if (!original) return 'Unbekannter Fehler';
|
||||||
|
const raw = original.toLowerCase();
|
||||||
|
|
||||||
|
if (ERROR_CODE_MESSAGES[raw]) return ERROR_CODE_MESSAGES[raw];
|
||||||
|
|
||||||
|
// Zuerst pruefen: abgebrochene Uebertragungen sind spezifischer als die Status-Code-Heuristik unten.
|
||||||
|
if (isTransferAbortedError(raw)) {
|
||||||
|
return 'Übertragung vom Server abgebrochen (early EOF / index-pack). Repository zu groß oder Timeout beim Gitea-Server bzw. Reverse-Proxy. Bitte erneut versuchen.';
|
||||||
|
}
|
||||||
|
|
||||||
if (raw.includes('401') || raw.includes('unauthorized') || raw.includes('authentifizierung')) {
|
if (raw.includes('401') || raw.includes('unauthorized') || raw.includes('authentifizierung')) {
|
||||||
return 'Authentifizierung fehlgeschlagen. Bitte Token prüfen.';
|
return 'Authentifizierung fehlgeschlagen. Bitte Token prüfen.';
|
||||||
@@ -2186,15 +2224,15 @@ function mapErrorMessage(message) {
|
|||||||
return 'Server oder Ressource nicht gefunden. URL/Repo prüfen.';
|
return 'Server oder Ressource nicht gefunden. URL/Repo prüfen.';
|
||||||
}
|
}
|
||||||
if (raw.includes('econnrefused') || raw.includes('enotfound') || raw.includes('eai_again') || raw.includes('getaddrinfo')) {
|
if (raw.includes('econnrefused') || raw.includes('enotfound') || raw.includes('eai_again') || raw.includes('getaddrinfo')) {
|
||||||
return 'Server nicht erreichbar. DNS, IPv4/IPv6 und Port prüfen.';
|
return 'Server nicht erreichbar. DNS, Server-IP und Port prüfen.';
|
||||||
}
|
}
|
||||||
if (raw.includes('timeout') || raw.includes('econnaborted') || raw.includes('zeitueberschreitung') || raw.includes('zeitüberschreitung')) {
|
if (raw.includes('timeout') || raw.includes('econnaborted') || raw.includes('zeitueberschreitung') || raw.includes('zeitüberschreitung')) {
|
||||||
return 'Zeitüberschreitung bei der Verbindung. Bitte erneut versuchen.';
|
return 'Zeitüberschreitung bei der Verbindung. Bitte erneut versuchen.';
|
||||||
}
|
}
|
||||||
if (raw.includes('ungueltige') || raw.includes('ungültige') || raw.includes('invalid') || raw.includes('url')) {
|
if (isInvalidUrlError(raw)) {
|
||||||
return 'Ungültige URL. Beispiel für IPv6: http://[2001:db8::1]:3000';
|
return INVALID_URL_HINT;
|
||||||
}
|
}
|
||||||
return String(message);
|
return original;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStatus(txt) {
|
function setStatus(txt) {
|
||||||
|
|||||||
+183
-183
@@ -1,183 +1,183 @@
|
|||||||
/**
|
/**
|
||||||
* BackupManager — Orchestrates backup operations
|
* BackupManager — Orchestrates backup operations
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const archiver = require('archiver')
|
const archiver = require('archiver')
|
||||||
const { createReadStream, createWriteStream } = require('fs')
|
const { createReadStream, createWriteStream } = require('fs')
|
||||||
const { mkdir } = require('fs').promises
|
const { mkdir } = require('fs').promises
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const { Transform } = require('stream')
|
const { Transform } = require('stream')
|
||||||
|
|
||||||
class BackupManager {
|
class BackupManager {
|
||||||
constructor(provider) {
|
constructor(provider) {
|
||||||
this.provider = provider
|
this.provider = provider
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a backup from a project folder
|
* Create a backup from a project folder
|
||||||
* @param {string} projectPath - Path to project folder
|
* @param {string} projectPath - Path to project folder
|
||||||
* @param {string} repoName - Repository name (used for filenames)
|
* @param {string} repoName - Repository name (used for filenames)
|
||||||
* @returns {Promise<{filename, size, timestamp}>}
|
* @returns {Promise<{filename, size, timestamp}>}
|
||||||
*/
|
*/
|
||||||
async createBackup(projectPath, repoName) {
|
async createBackup(projectPath, repoName) {
|
||||||
try {
|
try {
|
||||||
// Create ZIP buffer
|
// Create ZIP buffer
|
||||||
const buffer = await this._createZip(projectPath)
|
const buffer = await this._createZip(projectPath)
|
||||||
const timestamp = this._getTimestamp()
|
const timestamp = this._getTimestamp()
|
||||||
const filename = `${repoName}-backup-${timestamp}.zip`
|
const filename = `${repoName}-backup-${timestamp}.zip`
|
||||||
|
|
||||||
// Upload to provider
|
// Upload to provider
|
||||||
const result = await this.provider.uploadBackup(buffer, filename)
|
const result = await this.provider.uploadBackup(buffer, filename)
|
||||||
|
|
||||||
// Cleanup old backups
|
// Cleanup old backups
|
||||||
await this._cleanupOldBackups(repoName)
|
await this._cleanupOldBackups(repoName)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filename,
|
filename,
|
||||||
size: result.size || buffer.length,
|
size: result.size || buffer.length,
|
||||||
timestamp
|
timestamp
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(`Backup creation failed: ${err.message}`)
|
throw new Error(`Backup creation failed: ${err.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all backups for a repository
|
* List all backups for a repository
|
||||||
* @param {string} repoName - Repository name
|
* @param {string} repoName - Repository name
|
||||||
* @returns {Promise<Array>}
|
* @returns {Promise<Array>}
|
||||||
*/
|
*/
|
||||||
async listBackups(repoName) {
|
async listBackups(repoName) {
|
||||||
try {
|
try {
|
||||||
const backups = await this.provider.listBackups()
|
const backups = await this.provider.listBackups()
|
||||||
return backups
|
return backups
|
||||||
.filter(b => b.name.startsWith(repoName))
|
.filter(b => b.name.startsWith(repoName))
|
||||||
.sort((a, b) => new Date(b.date || b.name) - new Date(a.date || a.name))
|
.sort((a, b) => new Date(b.date || b.name) - new Date(a.date || a.name))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(`Failed to list backups: ${err.message}`)
|
throw new Error(`Failed to list backups: ${err.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restore a backup to a target folder
|
* Restore a backup to a target folder
|
||||||
* @param {string} repoName - Repository name
|
* @param {string} repoName - Repository name
|
||||||
* @param {string} filename - Backup filename
|
* @param {string} filename - Backup filename
|
||||||
* @param {string} targetPath - Target folder path
|
* @param {string} targetPath - Target folder path
|
||||||
*/
|
*/
|
||||||
async restoreBackup(repoName, filename, targetPath) {
|
async restoreBackup(repoName, filename, targetPath) {
|
||||||
try {
|
try {
|
||||||
// Download backup
|
// Download backup
|
||||||
const buffer = await this.provider.downloadBackup(filename)
|
const buffer = await this.provider.downloadBackup(filename)
|
||||||
|
|
||||||
// Extract ZIP
|
// Extract ZIP
|
||||||
await this._extractZip(buffer, targetPath)
|
await this._extractZip(buffer, targetPath)
|
||||||
|
|
||||||
return { ok: true, restored: filename }
|
return { ok: true, restored: filename }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(`Restore failed: ${err.message}`)
|
throw new Error(`Restore failed: ${err.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a backup
|
* Delete a backup
|
||||||
* @param {string} filename - Backup filename
|
* @param {string} filename - Backup filename
|
||||||
*/
|
*/
|
||||||
async deleteBackup(filename) {
|
async deleteBackup(filename) {
|
||||||
try {
|
try {
|
||||||
await this.provider.deleteBackup(filename)
|
await this.provider.deleteBackup(filename)
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(`Delete failed: ${err.message}`)
|
throw new Error(`Delete failed: ${err.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== PRIVATE METHODS ====================
|
// ==================== PRIVATE METHODS ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create ZIP buffer from project folder
|
* Create ZIP buffer from project folder
|
||||||
* Excludes: .git, node_modules, dist, build, .env
|
* Excludes: .git, node_modules, dist, build, .env
|
||||||
*/
|
*/
|
||||||
async _createZip(projectPath) {
|
async _createZip(projectPath) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const output = []
|
const output = []
|
||||||
const archive = archiver('zip', { zlib: { level: 5 } })
|
const archive = archiver('zip', { zlib: { level: 5 } })
|
||||||
|
|
||||||
archive.on('data', chunk => output.push(chunk))
|
archive.on('data', chunk => output.push(chunk))
|
||||||
archive.on('end', () => resolve(Buffer.concat(output)))
|
archive.on('end', () => resolve(Buffer.concat(output)))
|
||||||
archive.on('error', reject)
|
archive.on('error', reject)
|
||||||
|
|
||||||
// Add files with exclusions
|
// Add files with exclusions
|
||||||
archive.glob('**/*', {
|
archive.glob('**/*', {
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
ignore: [
|
ignore: [
|
||||||
'.git/**',
|
'.git/**',
|
||||||
'.git',
|
'.git',
|
||||||
'node_modules/**',
|
'node_modules/**',
|
||||||
'node_modules',
|
'node_modules',
|
||||||
'dist/**',
|
'dist/**',
|
||||||
'dist',
|
'dist',
|
||||||
'build/**',
|
'build/**',
|
||||||
'build',
|
'build',
|
||||||
'.env',
|
'.env',
|
||||||
'.env.local',
|
'.env.local',
|
||||||
'.env.*.local',
|
'.env.*.local',
|
||||||
'*.log',
|
'*.log',
|
||||||
'data/backups/**'
|
'data/backups/**'
|
||||||
],
|
],
|
||||||
dot: true
|
dot: true
|
||||||
})
|
})
|
||||||
|
|
||||||
archive.finalize()
|
archive.finalize()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract ZIP buffer to target folder
|
* Extract ZIP buffer to target folder
|
||||||
*/
|
*/
|
||||||
async _extractZip(buffer, targetPath) {
|
async _extractZip(buffer, targetPath) {
|
||||||
const unzipper = require('unzipper')
|
const unzipper = require('unzipper')
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const { Readable } = require('stream')
|
const { Readable } = require('stream')
|
||||||
const stream = Readable.from(buffer)
|
const stream = Readable.from(buffer)
|
||||||
|
|
||||||
stream
|
stream
|
||||||
.pipe(unzipper.Extract({ path: targetPath }))
|
.pipe(unzipper.Extract({ path: targetPath }))
|
||||||
.on('close', resolve)
|
.on('close', resolve)
|
||||||
.on('error', reject)
|
.on('error', reject)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get ISO timestamp (YYYYMMDD-HHMMSS format)
|
* Get ISO timestamp (YYYYMMDD-HHMMSS format)
|
||||||
*/
|
*/
|
||||||
_getTimestamp() {
|
_getTimestamp() {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const year = now.getFullYear()
|
const year = now.getFullYear()
|
||||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||||
const day = String(now.getDate()).padStart(2, '0')
|
const day = String(now.getDate()).padStart(2, '0')
|
||||||
const hours = String(now.getHours()).padStart(2, '0')
|
const hours = String(now.getHours()).padStart(2, '0')
|
||||||
const minutes = String(now.getMinutes()).padStart(2, '0')
|
const minutes = String(now.getMinutes()).padStart(2, '0')
|
||||||
const seconds = String(now.getSeconds()).padStart(2, '0')
|
const seconds = String(now.getSeconds()).padStart(2, '0')
|
||||||
|
|
||||||
return `${year}${month}${day}-${hours}${minutes}${seconds}`
|
return `${year}${month}${day}-${hours}${minutes}${seconds}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cleanup old backups (keep only last N versions)
|
* Cleanup old backups (keep only last N versions)
|
||||||
*/
|
*/
|
||||||
async _cleanupOldBackups(repoName, maxVersions = 5) {
|
async _cleanupOldBackups(repoName, maxVersions = 5) {
|
||||||
try {
|
try {
|
||||||
const backups = await this.listBackups(repoName)
|
const backups = await this.listBackups(repoName)
|
||||||
|
|
||||||
for (let i = maxVersions; i < backups.length; i++) {
|
for (let i = maxVersions; i < backups.length; i++) {
|
||||||
await this.provider.deleteBackup(backups[i].name)
|
await this.provider.deleteBackup(backups[i].name)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Silently ignore cleanup errors
|
// Silently ignore cleanup errors
|
||||||
console.warn(`Cleanup warning: ${err.message}`)
|
console.warn(`Cleanup warning: ${err.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = BackupManager
|
module.exports = BackupManager
|
||||||
|
|||||||
@@ -1,59 +1,59 @@
|
|||||||
/**
|
/**
|
||||||
* BackupProvider — Abstract base class for backup providers
|
* BackupProvider — Abstract base class for backup providers
|
||||||
* All providers must implement these methods
|
* All providers must implement these methods
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class BackupProvider {
|
class BackupProvider {
|
||||||
/**
|
/**
|
||||||
* Authenticate with the backup service
|
* Authenticate with the backup service
|
||||||
* @param {Object} credentials - Provider-specific credentials
|
* @param {Object} credentials - Provider-specific credentials
|
||||||
*/
|
*/
|
||||||
async authenticate(credentials) {
|
async authenticate(credentials) {
|
||||||
throw new Error('authenticate() not implemented in ' + this.constructor.name)
|
throw new Error('authenticate() not implemented in ' + this.constructor.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload a backup file
|
* Upload a backup file
|
||||||
* @param {Buffer} buffer - File content
|
* @param {Buffer} buffer - File content
|
||||||
* @param {string} filename - Filename (e.g., 'repo-backup-2025-03-24.zip')
|
* @param {string} filename - Filename (e.g., 'repo-backup-2025-03-24.zip')
|
||||||
* @returns {Promise<{size: number}>}
|
* @returns {Promise<{size: number}>}
|
||||||
*/
|
*/
|
||||||
async uploadBackup(buffer, filename) {
|
async uploadBackup(buffer, filename) {
|
||||||
throw new Error('uploadBackup() not implemented in ' + this.constructor.name)
|
throw new Error('uploadBackup() not implemented in ' + this.constructor.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all backups for a repository
|
* List all backups for a repository
|
||||||
* @returns {Promise<Array>} Array of {name, size, date}
|
* @returns {Promise<Array>} Array of {name, size, date}
|
||||||
*/
|
*/
|
||||||
async listBackups() {
|
async listBackups() {
|
||||||
throw new Error('listBackups() not implemented in ' + this.constructor.name)
|
throw new Error('listBackups() not implemented in ' + this.constructor.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download a specific backup
|
* Download a specific backup
|
||||||
* @param {string} filename - Filename to download
|
* @param {string} filename - Filename to download
|
||||||
* @returns {Promise<Buffer>}
|
* @returns {Promise<Buffer>}
|
||||||
*/
|
*/
|
||||||
async downloadBackup(filename) {
|
async downloadBackup(filename) {
|
||||||
throw new Error('downloadBackup() not implemented in ' + this.constructor.name)
|
throw new Error('downloadBackup() not implemented in ' + this.constructor.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a backup file
|
* Delete a backup file
|
||||||
* @param {string} filename - Filename to delete
|
* @param {string} filename - Filename to delete
|
||||||
*/
|
*/
|
||||||
async deleteBackup(filename) {
|
async deleteBackup(filename) {
|
||||||
throw new Error('deleteBackup() not implemented in ' + this.constructor.name)
|
throw new Error('deleteBackup() not implemented in ' + this.constructor.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test connection to backup service
|
* Test connection to backup service
|
||||||
* @returns {Promise<{ok: boolean, error?: string}>}
|
* @returns {Promise<{ok: boolean, error?: string}>}
|
||||||
*/
|
*/
|
||||||
async testConnection() {
|
async testConnection() {
|
||||||
throw new Error('testConnection() not implemented in ' + this.constructor.name)
|
throw new Error('testConnection() not implemented in ' + this.constructor.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = BackupProvider
|
module.exports = BackupProvider
|
||||||
|
|||||||
+84
-84
@@ -1,84 +1,84 @@
|
|||||||
/**
|
/**
|
||||||
* LocalProvider — Backup provider for local folders
|
* LocalProvider — Backup provider for local folders
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const fs = require('fs').promises
|
const fs = require('fs').promises
|
||||||
const BackupProvider = require('./BackupProvider')
|
const BackupProvider = require('./BackupProvider')
|
||||||
|
|
||||||
class LocalProvider extends BackupProvider {
|
class LocalProvider extends BackupProvider {
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.basePath = null
|
this.basePath = null
|
||||||
}
|
}
|
||||||
|
|
||||||
async authenticate(credentials) {
|
async authenticate(credentials) {
|
||||||
const basePath = String(credentials && credentials.basePath ? credentials.basePath : '').trim()
|
const basePath = String(credentials && credentials.basePath ? credentials.basePath : '').trim()
|
||||||
if (!basePath) {
|
if (!basePath) {
|
||||||
throw new Error('Lokaler Backup-Ordner fehlt')
|
throw new Error('Lokaler Backup-Ordner fehlt')
|
||||||
}
|
}
|
||||||
|
|
||||||
this.basePath = path.resolve(basePath)
|
this.basePath = path.resolve(basePath)
|
||||||
await fs.mkdir(this.basePath, { recursive: true })
|
await fs.mkdir(this.basePath, { recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
async testConnection() {
|
async testConnection() {
|
||||||
if (!this.basePath) return { ok: false, error: 'Not authenticated' }
|
if (!this.basePath) return { ok: false, error: 'Not authenticated' }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stat = await fs.stat(this.basePath)
|
const stat = await fs.stat(this.basePath)
|
||||||
if (!stat.isDirectory()) {
|
if (!stat.isDirectory()) {
|
||||||
return { ok: false, error: 'Pfad ist kein Ordner' }
|
return { ok: false, error: 'Pfad ist kein Ordner' }
|
||||||
}
|
}
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { ok: false, error: err.message }
|
return { ok: false, error: err.message }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadBackup(buffer, filename) {
|
async uploadBackup(buffer, filename) {
|
||||||
if (!this.basePath) throw new Error('Not authenticated')
|
if (!this.basePath) throw new Error('Not authenticated')
|
||||||
|
|
||||||
const target = path.join(this.basePath, filename)
|
const target = path.join(this.basePath, filename)
|
||||||
await fs.mkdir(path.dirname(target), { recursive: true })
|
await fs.mkdir(path.dirname(target), { recursive: true })
|
||||||
await fs.writeFile(target, buffer)
|
await fs.writeFile(target, buffer)
|
||||||
return { size: buffer.length }
|
return { size: buffer.length }
|
||||||
}
|
}
|
||||||
|
|
||||||
async listBackups() {
|
async listBackups() {
|
||||||
if (!this.basePath) throw new Error('Not authenticated')
|
if (!this.basePath) throw new Error('Not authenticated')
|
||||||
|
|
||||||
const entries = await fs.readdir(this.basePath, { withFileTypes: true })
|
const entries = await fs.readdir(this.basePath, { withFileTypes: true })
|
||||||
const files = entries.filter(e => e.isFile() && e.name.endsWith('.zip'))
|
const files = entries.filter(e => e.isFile() && e.name.endsWith('.zip'))
|
||||||
|
|
||||||
const backups = []
|
const backups = []
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
const full = path.join(this.basePath, f.name)
|
const full = path.join(this.basePath, f.name)
|
||||||
const stat = await fs.stat(full)
|
const stat = await fs.stat(full)
|
||||||
backups.push({
|
backups.push({
|
||||||
name: f.name,
|
name: f.name,
|
||||||
size: stat.size,
|
size: stat.size,
|
||||||
date: stat.mtime.toISOString()
|
date: stat.mtime.toISOString()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
backups.sort((a, b) => new Date(b.date) - new Date(a.date))
|
backups.sort((a, b) => new Date(b.date) - new Date(a.date))
|
||||||
return backups
|
return backups
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadBackup(filename) {
|
async downloadBackup(filename) {
|
||||||
if (!this.basePath) throw new Error('Not authenticated')
|
if (!this.basePath) throw new Error('Not authenticated')
|
||||||
|
|
||||||
const file = path.join(this.basePath, filename)
|
const file = path.join(this.basePath, filename)
|
||||||
return fs.readFile(file)
|
return fs.readFile(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteBackup(filename) {
|
async deleteBackup(filename) {
|
||||||
if (!this.basePath) throw new Error('Not authenticated')
|
if (!this.basePath) throw new Error('Not authenticated')
|
||||||
|
|
||||||
const file = path.join(this.basePath, filename)
|
const file = path.join(this.basePath, filename)
|
||||||
await fs.unlink(file)
|
await fs.unlink(file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = LocalProvider
|
module.exports = LocalProvider
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// getGiteaRepoContents, getGiteaFileContent, uploadGiteaFile
|
// getGiteaRepoContents, getGiteaFileContent, uploadGiteaFile
|
||||||
|
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
|
const fs = require('fs');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
|
|
||||||
@@ -2055,6 +2056,193 @@ async function updateGiteaAvatar({ token, url, imageBase64 }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -----------------------------
|
||||||
|
Batch-Upload über die Contents-API (Gitea >= 1.20)
|
||||||
|
|
||||||
|
Fallback für den Fall, dass der Git-Transport nicht nutzbar ist — etwa wenn
|
||||||
|
git-upload-pack auf dem Server keine Packfiles erzeugen kann ("early EOF").
|
||||||
|
Dieser Weg braucht weder Clone noch Push, sondern schreibt die Dateien direkt
|
||||||
|
über die REST-API. Mehrere Dateien landen dabei in einem gemeinsamen Commit.
|
||||||
|
----------------------------- */
|
||||||
|
|
||||||
|
// Zielgröße einer Anfrage (Rohbytes vor Base64). Bewusst klein gehalten, damit
|
||||||
|
// ein Reverse-Proxy mit knappem client_max_body_size nicht dazwischenfunkt.
|
||||||
|
const API_UPLOAD_TARGET_BYTES = 6 * 1024 * 1024;
|
||||||
|
const API_UPLOAD_MAX_FILES_PER_COMMIT = 50;
|
||||||
|
|
||||||
|
// Löst "HEAD" zum tatsächlichen Default-Branch auf.
|
||||||
|
async function resolveGiteaBranchName({ token, base, owner, repo, branch }) {
|
||||||
|
const name = branch || 'HEAD';
|
||||||
|
if (name !== 'HEAD') return name;
|
||||||
|
|
||||||
|
const cacheKey = `${base}::${owner}/${repo}`;
|
||||||
|
const cached = defaultBranchCache.get(cacheKey);
|
||||||
|
if (cached && (Date.now() - cached.ts) < DEFAULT_BRANCH_TTL_MS) return cached.branch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await tryRequest(
|
||||||
|
`${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
const resolved = (info.ok && info.data && info.data.default_branch) ? info.data.default_branch : 'main';
|
||||||
|
defaultBranchCache.set(cacheKey, { branch: resolved, ts: Date.now() });
|
||||||
|
return resolved;
|
||||||
|
} catch (_) {
|
||||||
|
return 'main';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ermittelt die SHAs bereits vorhandener Dateien. Die Contents-API verlangt bei einem
|
||||||
|
// "update" die SHA der Vorgängerversion. Abgefragt wird pro Verzeichnis, nicht pro Datei.
|
||||||
|
async function fetchExistingFileShas({ token, base, owner, repo, branch, paths }) {
|
||||||
|
const shaByPath = new Map();
|
||||||
|
const dirs = new Set(paths.map(p => (p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '')));
|
||||||
|
|
||||||
|
for (const dir of dirs) {
|
||||||
|
try {
|
||||||
|
const res = await getGiteaRepoContents({ token, url: base, owner, repo, path: dir, ref: branch });
|
||||||
|
const list = (res && res.items) ? res.items : (Array.isArray(res) ? res : []);
|
||||||
|
for (const item of list) {
|
||||||
|
if (item && item.type === 'file' && item.path && item.sha) {
|
||||||
|
shaByPath.set(item.path, item.sha);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Verzeichnis existiert noch nicht — dann sind alle Dateien darin neu.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return shaByPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Commit mit mehreren Dateiänderungen.
|
||||||
|
async function changeGiteaFiles({ token, base, owner, repo, branch, files, message }) {
|
||||||
|
const endpoint = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents`;
|
||||||
|
const res = await axiosInstance.post(
|
||||||
|
endpoint,
|
||||||
|
{ branch, message, files },
|
||||||
|
{
|
||||||
|
headers: { Authorization: `token ${token}` },
|
||||||
|
timeout: 180000,
|
||||||
|
maxBodyLength: Infinity,
|
||||||
|
maxContentLength: Infinity
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPayloadTooLargeError(err) {
|
||||||
|
const status = err && err.response && err.response.status;
|
||||||
|
if (status === 413) return true;
|
||||||
|
const msg = String((err && err.message) || '').toLowerCase();
|
||||||
|
return msg.includes('entity too large') || msg.includes('request body too large');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt Dateien ausschließlich über die REST-API hoch — ohne Clone, ohne Push.
|
||||||
|
*
|
||||||
|
* @param {Array<{localFile: string, targetPath: string}>} items
|
||||||
|
* @param {function(number):void} [onProgress] Fortschritt in Prozent
|
||||||
|
* @returns {Promise<{branch: string, commits: number, uploaded: string[], failed: Array<{path: string, error: string}>}>}
|
||||||
|
*/
|
||||||
|
async function uploadItemsViaGiteaApi({ token, url, owner, repo, branch, items, message, onProgress }) {
|
||||||
|
const base = normalizeBase(url);
|
||||||
|
if (!base) throw new Error('Invalid Gitea base URL');
|
||||||
|
if (!Array.isArray(items) || items.length === 0) throw new Error('Keine Dateien zum Hochladen');
|
||||||
|
|
||||||
|
const branchName = await resolveGiteaBranchName({ token, base, owner, repo, branch });
|
||||||
|
const shaByPath = await fetchExistingFileShas({
|
||||||
|
token, base, owner, repo, branch: branchName,
|
||||||
|
paths: items.map(i => i.targetPath)
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploaded = [];
|
||||||
|
const failed = [];
|
||||||
|
let commits = 0;
|
||||||
|
let processedBytes = 0;
|
||||||
|
|
||||||
|
// Gesamtgröße für die Fortschrittsanzeige.
|
||||||
|
let totalBytes = 0;
|
||||||
|
for (const item of items) {
|
||||||
|
try { totalBytes += fs.statSync(item.localFile).size; } catch (_) {}
|
||||||
|
}
|
||||||
|
if (totalBytes === 0) totalBytes = 1;
|
||||||
|
|
||||||
|
// Dateien zu Paketen bündeln, die je einen Commit ergeben.
|
||||||
|
const batches = [];
|
||||||
|
let current = [];
|
||||||
|
let currentBytes = 0;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
let size = 0;
|
||||||
|
try { size = fs.statSync(item.localFile).size; } catch (e) {
|
||||||
|
failed.push({ path: item.targetPath, error: `Datei nicht lesbar: ${e.message || e}` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.length > 0 &&
|
||||||
|
(currentBytes + size > API_UPLOAD_TARGET_BYTES || current.length >= API_UPLOAD_MAX_FILES_PER_COMMIT)) {
|
||||||
|
batches.push(current);
|
||||||
|
current = [];
|
||||||
|
currentBytes = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
current.push({ ...item, size });
|
||||||
|
currentBytes += size;
|
||||||
|
}
|
||||||
|
if (current.length > 0) batches.push(current);
|
||||||
|
|
||||||
|
// Ein Paket absenden; bei zu großem Body rekursiv halbieren.
|
||||||
|
const sendBatch = async (batch) => {
|
||||||
|
const files = batch.map(item => {
|
||||||
|
const existingSha = shaByPath.get(item.targetPath);
|
||||||
|
const entry = {
|
||||||
|
operation: existingSha ? 'update' : 'create',
|
||||||
|
path: item.targetPath,
|
||||||
|
content: fs.readFileSync(item.localFile).toString('base64')
|
||||||
|
};
|
||||||
|
if (existingSha) entry.sha = existingSha;
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await changeGiteaFiles({ token, base, owner, repo, branch: branchName, files, message });
|
||||||
|
commits++;
|
||||||
|
for (const item of batch) {
|
||||||
|
uploaded.push(item.targetPath);
|
||||||
|
processedBytes += item.size;
|
||||||
|
}
|
||||||
|
if (onProgress) onProgress(Math.min(99, Math.round((processedBytes / totalBytes) * 100)));
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
// Body zu groß: aufteilen und erneut versuchen.
|
||||||
|
if (isPayloadTooLargeError(err) && batch.length > 1) {
|
||||||
|
const half = Math.ceil(batch.length / 2);
|
||||||
|
console.warn(`[ApiUpload] Paket zu groß (${batch.length} Dateien) — wird geteilt`);
|
||||||
|
await sendBatch(batch.slice(0, half));
|
||||||
|
await sendBatch(batch.slice(half));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = (err.response && err.response.data && err.response.data.message)
|
||||||
|
|| err.message || String(err);
|
||||||
|
|
||||||
|
for (const item of batch) {
|
||||||
|
failed.push({ path: item.targetPath, error: String(detail) });
|
||||||
|
processedBytes += item.size;
|
||||||
|
}
|
||||||
|
if (onProgress) onProgress(Math.min(99, Math.round((processedBytes / totalBytes) * 100)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const batch of batches) {
|
||||||
|
await sendBatch(batch);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onProgress) onProgress(100);
|
||||||
|
return { branch: branchName, commits, uploaded, failed };
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
normalizeAndValidateBaseUrl,
|
normalizeAndValidateBaseUrl,
|
||||||
createRepoGitHub,
|
createRepoGitHub,
|
||||||
@@ -2073,6 +2261,7 @@ module.exports = {
|
|||||||
getGiteaRepoContents,
|
getGiteaRepoContents,
|
||||||
getGiteaFileContent,
|
getGiteaFileContent,
|
||||||
uploadGiteaFile,
|
uploadGiteaFile,
|
||||||
|
uploadItemsViaGiteaApi,
|
||||||
// Commit History
|
// Commit History
|
||||||
getGiteaCommits,
|
getGiteaCommits,
|
||||||
getGiteaCommit,
|
getGiteaCommit,
|
||||||
|
|||||||
+293
-293
@@ -1,293 +1,293 @@
|
|||||||
/**
|
/**
|
||||||
* Gemeinsame Utility-Funktionen für Git Manager GUI
|
* Gemeinsame Utility-Funktionen für Git Manager GUI
|
||||||
* - Branch Handling
|
* - Branch Handling
|
||||||
* - API Error Handling
|
* - API Error Handling
|
||||||
* - Standardisiertes Logging
|
* - Standardisiertes Logging
|
||||||
* - Caching
|
* - Caching
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const ppath = require('path');
|
const ppath = require('path');
|
||||||
|
|
||||||
// ===== LOGGING SYSTEM =====
|
// ===== LOGGING SYSTEM =====
|
||||||
const LOG_LEVELS = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 };
|
const LOG_LEVELS = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 };
|
||||||
let currentLogLevel = process.env.NODE_ENV === 'production' ? LOG_LEVELS.INFO : LOG_LEVELS.DEBUG;
|
let currentLogLevel = process.env.NODE_ENV === 'production' ? LOG_LEVELS.INFO : LOG_LEVELS.DEBUG;
|
||||||
let logQueue = [];
|
let logQueue = [];
|
||||||
const MAX_LOG_BUFFER = 2000;
|
const MAX_LOG_BUFFER = 2000;
|
||||||
|
|
||||||
function formatLog(level, context, message, details = null) {
|
function formatLog(level, context, message, details = null) {
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
const levelStr = Object.keys(LOG_LEVELS).find(k => LOG_LEVELS[k] === level);
|
const levelStr = Object.keys(LOG_LEVELS).find(k => LOG_LEVELS[k] === level);
|
||||||
return {
|
return {
|
||||||
timestamp,
|
timestamp,
|
||||||
level: levelStr,
|
level: levelStr,
|
||||||
context,
|
context,
|
||||||
message,
|
message,
|
||||||
details,
|
details,
|
||||||
pid: process.pid
|
pid: process.pid
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeLog(logEntry) {
|
function writeLog(logEntry) {
|
||||||
logQueue.push(logEntry);
|
logQueue.push(logEntry);
|
||||||
if (logQueue.length > MAX_LOG_BUFFER) {
|
if (logQueue.length > MAX_LOG_BUFFER) {
|
||||||
logQueue.shift();
|
logQueue.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auch in Console schreiben
|
// Auch in Console schreiben
|
||||||
const { level, timestamp, context, message, details } = logEntry;
|
const { level, timestamp, context, message, details } = logEntry;
|
||||||
const prefix = `[${timestamp}] [${level}] [${context}]`;
|
const prefix = `[${timestamp}] [${level}] [${context}]`;
|
||||||
|
|
||||||
if (level === 'ERROR' && details?.error) {
|
if (level === 'ERROR' && details?.error) {
|
||||||
console.error(prefix, message, details.error);
|
console.error(prefix, message, details.error);
|
||||||
} else if (level === 'WARN') {
|
} else if (level === 'WARN') {
|
||||||
console.warn(prefix, message, details ? JSON.stringify(details) : '');
|
console.warn(prefix, message, details ? JSON.stringify(details) : '');
|
||||||
} else if (level !== 'DEBUG' || process.env.DEBUG) {
|
} else if (level !== 'DEBUG' || process.env.DEBUG) {
|
||||||
console.log(prefix, message, details ? JSON.stringify(details) : '');
|
console.log(prefix, message, details ? JSON.stringify(details) : '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const logger = {
|
const logger = {
|
||||||
debug: (context, message, details) => writeLog(formatLog(LOG_LEVELS.DEBUG, context, message, details)),
|
debug: (context, message, details) => writeLog(formatLog(LOG_LEVELS.DEBUG, context, message, details)),
|
||||||
info: (context, message, details) => writeLog(formatLog(LOG_LEVELS.INFO, context, message, details)),
|
info: (context, message, details) => writeLog(formatLog(LOG_LEVELS.INFO, context, message, details)),
|
||||||
warn: (context, message, details) => writeLog(formatLog(LOG_LEVELS.WARN, context, message, details)),
|
warn: (context, message, details) => writeLog(formatLog(LOG_LEVELS.WARN, context, message, details)),
|
||||||
error: (context, message, details) => writeLog(formatLog(LOG_LEVELS.ERROR, context, message, details)),
|
error: (context, message, details) => writeLog(formatLog(LOG_LEVELS.ERROR, context, message, details)),
|
||||||
getRecent: (count = 20) => logQueue.slice(-count),
|
getRecent: (count = 20) => logQueue.slice(-count),
|
||||||
setLevel: (level) => { currentLogLevel = LOG_LEVELS[level] || LOG_LEVELS.INFO; }
|
setLevel: (level) => { currentLogLevel = LOG_LEVELS[level] || LOG_LEVELS.INFO; }
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===== BRANCH HANDLING =====
|
// ===== BRANCH HANDLING =====
|
||||||
const BRANCH_DEFAULTS = {
|
const BRANCH_DEFAULTS = {
|
||||||
gitea: 'main',
|
gitea: 'main',
|
||||||
github: 'main'
|
github: 'main'
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeBranch(branch = 'HEAD', platform = 'gitea') {
|
function normalizeBranch(branch = 'HEAD', platform = 'gitea') {
|
||||||
const value = String(branch || '').trim();
|
const value = String(branch || '').trim();
|
||||||
|
|
||||||
// HEAD sollte immer zu Standard konvertiert werden
|
// HEAD sollte immer zu Standard konvertiert werden
|
||||||
if (value.toLowerCase() === 'head') {
|
if (value.toLowerCase() === 'head') {
|
||||||
return BRANCH_DEFAULTS[platform] || 'main';
|
return BRANCH_DEFAULTS[platform] || 'main';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validierung: nur sichere Git-Referenzen
|
// Validierung: nur sichere Git-Referenzen
|
||||||
if (/^[a-zA-Z0-9._\-/]+$/.test(value)) {
|
if (/^[a-zA-Z0-9._\-/]+$/.test(value)) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
return BRANCH_DEFAULTS[platform] || 'main';
|
return BRANCH_DEFAULTS[platform] || 'main';
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSafeBranch(branch) {
|
function isSafeBranch(branch) {
|
||||||
return /^[a-zA-Z0-9._\-/]+$/.test(String(branch || ''));
|
return /^[a-zA-Z0-9._\-/]+$/.test(String(branch || ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== ERROR HANDLING =====
|
// ===== ERROR HANDLING =====
|
||||||
const ERROR_CODES = {
|
const ERROR_CODES = {
|
||||||
NETWORK: 'NETWORK_ERROR',
|
NETWORK: 'NETWORK_ERROR',
|
||||||
AUTH_FAILED: 'AUTH_FAILED',
|
AUTH_FAILED: 'AUTH_FAILED',
|
||||||
NOT_FOUND: 'NOT_FOUND',
|
NOT_FOUND: 'NOT_FOUND',
|
||||||
VALIDATION: 'VALIDATION_ERROR',
|
VALIDATION: 'VALIDATION_ERROR',
|
||||||
RATE_LIMIT: 'RATE_LIMIT',
|
RATE_LIMIT: 'RATE_LIMIT',
|
||||||
SERVER_ERROR: 'SERVER_ERROR',
|
SERVER_ERROR: 'SERVER_ERROR',
|
||||||
UNKNOWN: 'UNKNOWN_ERROR'
|
UNKNOWN: 'UNKNOWN_ERROR'
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseApiError(error, defaultCode = ERROR_CODES.UNKNOWN) {
|
function parseApiError(error, defaultCode = ERROR_CODES.UNKNOWN) {
|
||||||
if (!error) {
|
if (!error) {
|
||||||
return { code: defaultCode, message: 'Unknown error', statusCode: null };
|
return { code: defaultCode, message: 'Unknown error', statusCode: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Axios-style error
|
// Axios-style error
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
const status = error.response.status;
|
const status = error.response.status;
|
||||||
const data = error.response.data;
|
const data = error.response.data;
|
||||||
let code = defaultCode;
|
let code = defaultCode;
|
||||||
|
|
||||||
if (status === 401 || status === 403) {
|
if (status === 401 || status === 403) {
|
||||||
code = ERROR_CODES.AUTH_FAILED;
|
code = ERROR_CODES.AUTH_FAILED;
|
||||||
} else if (status === 404) {
|
} else if (status === 404) {
|
||||||
code = ERROR_CODES.NOT_FOUND;
|
code = ERROR_CODES.NOT_FOUND;
|
||||||
} else if (status === 429) {
|
} else if (status === 429) {
|
||||||
code = ERROR_CODES.RATE_LIMIT;
|
code = ERROR_CODES.RATE_LIMIT;
|
||||||
} else if (status >= 500) {
|
} else if (status >= 500) {
|
||||||
code = ERROR_CODES.SERVER_ERROR;
|
code = ERROR_CODES.SERVER_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
code,
|
code,
|
||||||
message: data?.message || error.message || `HTTP ${status}`,
|
message: data?.message || error.message || `HTTP ${status}`,
|
||||||
statusCode: status,
|
statusCode: status,
|
||||||
rawMessage: data?.message
|
rawMessage: data?.message
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network error
|
// Network error
|
||||||
if (error.message?.includes('timeout') || error.code?.includes('TIMEOUT')) {
|
if (error.message?.includes('timeout') || error.code?.includes('TIMEOUT')) {
|
||||||
return { code: ERROR_CODES.NETWORK, message: 'Request timeout', statusCode: null };
|
return { code: ERROR_CODES.NETWORK, message: 'Request timeout', statusCode: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error.code?.includes('ECONNREFUSED') || error.message?.includes('ECONNREFUSED')) {
|
if (error.code?.includes('ECONNREFUSED') || error.message?.includes('ECONNREFUSED')) {
|
||||||
return { code: ERROR_CODES.NETWORK, message: 'Connection refused', statusCode: null };
|
return { code: ERROR_CODES.NETWORK, message: 'Connection refused', statusCode: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
code: ERROR_CODES.UNKNOWN,
|
code: ERROR_CODES.UNKNOWN,
|
||||||
message: error.message || String(error),
|
message: error.message || String(error),
|
||||||
statusCode: null
|
statusCode: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatErrorForUser(error, context = 'Operation') {
|
function formatErrorForUser(error, context = 'Operation') {
|
||||||
const parsed = parseApiError(error);
|
const parsed = parseApiError(error);
|
||||||
const messages = {
|
const messages = {
|
||||||
[ERROR_CODES.AUTH_FAILED]: `Authentifizierung fehlgeschlagen. Bitte Token überprüfen.`,
|
[ERROR_CODES.AUTH_FAILED]: `Authentifizierung fehlgeschlagen. Bitte Token überprüfen.`,
|
||||||
[ERROR_CODES.NOT_FOUND]: `Ressource nicht gefunden.`,
|
[ERROR_CODES.NOT_FOUND]: `Ressource nicht gefunden.`,
|
||||||
[ERROR_CODES.NETWORK]: `Netzwerkfehler. Bitte Verbindung überprüfen.`,
|
[ERROR_CODES.NETWORK]: `Netzwerkfehler. Bitte Verbindung überprüfen.`,
|
||||||
[ERROR_CODES.RATE_LIMIT]: `Zu viele Anfragen. Bitte später versuchen.`,
|
[ERROR_CODES.RATE_LIMIT]: `Zu viele Anfragen. Bitte später versuchen.`,
|
||||||
[ERROR_CODES.SERVER_ERROR]: `Server-Fehler. Bitte später versuchen.`,
|
[ERROR_CODES.SERVER_ERROR]: `Server-Fehler. Bitte später versuchen.`,
|
||||||
[ERROR_CODES.UNKNOWN]: `${context} fehlgeschlagen.`
|
[ERROR_CODES.UNKNOWN]: `${context} fehlgeschlagen.`
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userMessage: messages[parsed.code],
|
userMessage: messages[parsed.code],
|
||||||
technicalMessage: parsed.message,
|
technicalMessage: parsed.message,
|
||||||
code: parsed.code,
|
code: parsed.code,
|
||||||
details: parsed
|
details: parsed
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== CACHING SYSTEM =====
|
// ===== CACHING SYSTEM =====
|
||||||
class Cache {
|
class Cache {
|
||||||
constructor(ttl = 300000) { // 5 min default
|
constructor(ttl = 300000) { // 5 min default
|
||||||
this.store = new Map();
|
this.store = new Map();
|
||||||
this.ttl = ttl;
|
this.ttl = ttl;
|
||||||
}
|
}
|
||||||
|
|
||||||
set(key, value, customTtl = null) {
|
set(key, value, customTtl = null) {
|
||||||
const expiry = Date.now() + (customTtl || this.ttl);
|
const expiry = Date.now() + (customTtl || this.ttl);
|
||||||
this.store.set(key, { value, expiry });
|
this.store.set(key, { value, expiry });
|
||||||
}
|
}
|
||||||
|
|
||||||
get(key) {
|
get(key) {
|
||||||
const item = this.store.get(key);
|
const item = this.store.get(key);
|
||||||
if (!item) return null;
|
if (!item) return null;
|
||||||
if (Date.now() > item.expiry) {
|
if (Date.now() > item.expiry) {
|
||||||
this.store.delete(key);
|
this.store.delete(key);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return item.value;
|
return item.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidate(keyPattern) {
|
invalidate(keyPattern) {
|
||||||
for (const [key] of this.store) {
|
for (const [key] of this.store) {
|
||||||
if (key.includes(keyPattern)) {
|
if (key.includes(keyPattern)) {
|
||||||
this.store.delete(key);
|
this.store.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
this.store.clear();
|
this.store.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
size() {
|
size() {
|
||||||
return this.store.size;
|
return this.store.size;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Standard Caches
|
// Standard Caches
|
||||||
const caches = {
|
const caches = {
|
||||||
repos: new Cache(600000), // 10 min
|
repos: new Cache(600000), // 10 min
|
||||||
fileTree: new Cache(300000), // 5 min
|
fileTree: new Cache(300000), // 5 min
|
||||||
api: new Cache(120000) // 2 min
|
api: new Cache(120000) // 2 min
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===== PARALLEL OPERATIONS =====
|
// ===== PARALLEL OPERATIONS =====
|
||||||
async function runParallel(operations, concurrency = 4, onProgress = null) {
|
async function runParallel(operations, concurrency = 4, onProgress = null) {
|
||||||
const results = new Array(operations.length);
|
const results = new Array(operations.length);
|
||||||
let completed = 0;
|
let completed = 0;
|
||||||
let index = 0;
|
let index = 0;
|
||||||
|
|
||||||
async function worker() {
|
async function worker() {
|
||||||
while (index < operations.length) {
|
while (index < operations.length) {
|
||||||
const i = index++;
|
const i = index++;
|
||||||
try {
|
try {
|
||||||
results[i] = { ok: true, result: await operations[i]() };
|
results[i] = { ok: true, result: await operations[i]() };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
results[i] = { ok: false, error: e };
|
results[i] = { ok: false, error: e };
|
||||||
}
|
}
|
||||||
completed++;
|
completed++;
|
||||||
if (onProgress) {
|
if (onProgress) {
|
||||||
try { onProgress(completed, operations.length); } catch (_) {}
|
try { onProgress(completed, operations.length); } catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const workers = Array.from({ length: Math.min(concurrency, operations.length) }, () => worker());
|
const workers = Array.from({ length: Math.min(concurrency, operations.length) }, () => worker());
|
||||||
await Promise.all(workers);
|
await Promise.all(workers);
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== RETRY LOGIC =====
|
// ===== RETRY LOGIC =====
|
||||||
async function retryWithBackoff(fn, maxAttempts = 3, baseDelay = 1000) {
|
async function retryWithBackoff(fn, maxAttempts = 3, baseDelay = 1000) {
|
||||||
let lastError;
|
let lastError;
|
||||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
try {
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
lastError = e;
|
lastError = e;
|
||||||
if (attempt < maxAttempts - 1) {
|
if (attempt < maxAttempts - 1) {
|
||||||
const delay = baseDelay * Math.pow(2, attempt);
|
const delay = baseDelay * Math.pow(2, attempt);
|
||||||
await new Promise(resolve => setTimeout(resolve, delay));
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw lastError;
|
throw lastError;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== FILE OPERATIONS =====
|
// ===== FILE OPERATIONS =====
|
||||||
function ensureDirectory(dirPath) {
|
function ensureDirectory(dirPath) {
|
||||||
if (!fs.existsSync(dirPath)) {
|
if (!fs.existsSync(dirPath)) {
|
||||||
fs.mkdirSync(dirPath, { recursive: true });
|
fs.mkdirSync(dirPath, { recursive: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeReadFile(filePath, defaultValue = null) {
|
function safeReadFile(filePath, defaultValue = null) {
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(filePath)) return defaultValue;
|
if (!fs.existsSync(filePath)) return defaultValue;
|
||||||
return fs.readFileSync(filePath, 'utf8');
|
return fs.readFileSync(filePath, 'utf8');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn('safeReadFile', `Failed to read ${filePath}`, { error: e.message });
|
logger.warn('safeReadFile', `Failed to read ${filePath}`, { error: e.message });
|
||||||
return defaultValue;
|
return defaultValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeWriteFile(filePath, content) {
|
function safeWriteFile(filePath, content) {
|
||||||
try {
|
try {
|
||||||
ensureDirectory(ppath.dirname(filePath));
|
ensureDirectory(ppath.dirname(filePath));
|
||||||
fs.writeFileSync(filePath, content, 'utf8');
|
fs.writeFileSync(filePath, content, 'utf8');
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('safeWriteFile', `Failed to write ${filePath}`, { error: e.message });
|
logger.error('safeWriteFile', `Failed to write ${filePath}`, { error: e.message });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== EXPORTS =====
|
// ===== EXPORTS =====
|
||||||
module.exports = {
|
module.exports = {
|
||||||
logger,
|
logger,
|
||||||
normalizeBranch,
|
normalizeBranch,
|
||||||
isSafeBranch,
|
isSafeBranch,
|
||||||
parseApiError,
|
parseApiError,
|
||||||
formatErrorForUser,
|
formatErrorForUser,
|
||||||
ERROR_CODES,
|
ERROR_CODES,
|
||||||
Cache,
|
Cache,
|
||||||
caches,
|
caches,
|
||||||
runParallel,
|
runParallel,
|
||||||
retryWithBackoff,
|
retryWithBackoff,
|
||||||
ensureDirectory,
|
ensureDirectory,
|
||||||
safeReadFile,
|
safeReadFile,
|
||||||
safeWriteFile,
|
safeWriteFile,
|
||||||
LOG_LEVELS
|
LOG_LEVELS
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user