1 Commits
Author SHA1 Message Date
M_Viper 5571f225d5 Upload via GUI (27 Dateien) 2026-08-13 07:21:45 +00:00
9 changed files with 1244 additions and 711 deletions
File diff suppressed because one or more lines are too long
+389 -83
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
+189
View File
@@ -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,