Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5571f225d5 | ||
|
|
565835418e |
File diff suppressed because one or more lines are too long
+7
-7
@@ -490,13 +490,13 @@
|
||||
<!-- Features -->
|
||||
<section class="section" id="features">
|
||||
<h2 class="section-title">Warum Git Manager Pro?</h2>
|
||||
<p class="section-desc">Alles was du brauchst um deine Repositories effizient zu verwalten.</p>
|
||||
<p class="section-desc">Alles, was du brauchst, um deine Repositories effizient zu verwalten.</p>
|
||||
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🌐</div>
|
||||
<h3>Gitea & GitHub</h3>
|
||||
<p>Unterstützt beide Plattformen seamless — alle Repositories an einem Ort.</p>
|
||||
<p>Unterstützt beide Plattformen nahtlos — alle Repositories an einem Ort.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📂</div>
|
||||
@@ -535,16 +535,16 @@
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📥</div>
|
||||
<h3>Ein-Klick Migration</h3>
|
||||
<h3>Ein-Klick-Migration</h3>
|
||||
<p>Migriere Repos zwischen Services — mit kompletter History.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h3 style="color: var(--accent2); margin-bottom: 16px;">🎥 Demo (baldiger Update)</h3>
|
||||
<h3 style="color: var(--accent2); margin-bottom: 16px;">🎥 Demo (baldiges Update)</h3>
|
||||
<div class="demo-mockup">
|
||||
Screenshot-Bereich — Demo und UI-Preview kommen bald!<br><br>
|
||||
<em>Starte die App mit npm start um einen Live-Demo zu sehen</em>
|
||||
Screenshot-Bereich — Demo und UI-Vorschau kommen bald!<br><br>
|
||||
<em>Starte die App mit npm start, um eine Live-Demo zu sehen</em>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -603,7 +603,7 @@
|
||||
<div class="links-section">
|
||||
<div class="link-card">
|
||||
<h3>📖 Benutzerhandbuch</h3>
|
||||
<p>Vollständiges Handbuch mit Screenshots und Schritt-für-Schritt Anleitungen.</p>
|
||||
<p>Vollständiges Handbuch mit Screenshots und Schritt-für-Schritt-Anleitungen.</p>
|
||||
<a href="HANDBUCH.html" target="_blank">Zum Handbuch →</a>
|
||||
</div>
|
||||
<div class="link-card">
|
||||
|
||||
@@ -43,6 +43,7 @@ const {
|
||||
getGiteaRepoContents,
|
||||
getGiteaFileContent,
|
||||
uploadGiteaFile,
|
||||
uploadItemsViaGiteaApi,
|
||||
getGiteaCurrentUser,
|
||||
getGiteaCommits,
|
||||
getGiteaCommit,
|
||||
@@ -112,6 +113,278 @@ const TMP_CLEANUP_MS = 20_000;
|
||||
const RETRY_QUEUE_INTERVAL_MS = 15_000;
|
||||
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 retryQueueRunning = false;
|
||||
let retryQueueTimer = null;
|
||||
@@ -818,6 +1091,10 @@ function sanitizeErrorForLog(errorLike) {
|
||||
function mapIpcError(errorLike) {
|
||||
const raw = String(errorLike && errorLike.message ? errorLike.message : errorLike || '').toLowerCase();
|
||||
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')) {
|
||||
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.';
|
||||
}
|
||||
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')) {
|
||||
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')) {
|
||||
return 'Ungültige URL. Beispiel für IPv6: http://[2001:db8::1]:3000';
|
||||
if (
|
||||
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);
|
||||
}
|
||||
@@ -2107,8 +2394,7 @@ ipcMain.handle('upload-gitea-file', async (event, data) => {
|
||||
return { ok: failedCount === 0, results, failedCount, debugId: uploadDebugId };
|
||||
}
|
||||
|
||||
// Git-basierter Upload via simple-git — umgeht Giteas API-Index-Timing-Probleme (422 SHA) zuverlässig.
|
||||
const simpleGit = require('simple-git');
|
||||
// Git-basierter Upload via cloneForUpload() — umgeht Giteas API-Index-Timing-Probleme (422 SHA) zuverlässig.
|
||||
let authUrl;
|
||||
try {
|
||||
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'];
|
||||
|
||||
try {
|
||||
const git = simpleGit({ config: gitConfig });
|
||||
let repoGit;
|
||||
let isEmptyRepo = false;
|
||||
|
||||
try {
|
||||
const cloneArgs = ['--depth', '1', '--no-single-branch'];
|
||||
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;
|
||||
}
|
||||
}
|
||||
const { repoGit, isEmptyRepo } = await cloneForUpload({
|
||||
authUrl,
|
||||
tmpDir,
|
||||
branch,
|
||||
gitConfig,
|
||||
label: 'FileUpload'
|
||||
});
|
||||
|
||||
// Dateien in den Zielordner kopieren
|
||||
for (const localFile of validFiles) {
|
||||
@@ -2154,7 +2426,7 @@ ipcMain.handle('upload-gitea-file', async (event, data) => {
|
||||
results.push({ file: localFile, ok: true, targetPath });
|
||||
}
|
||||
|
||||
await repoGit.add('.');
|
||||
await stageUploadedFiles(repoGit);
|
||||
|
||||
let hasChanges = true;
|
||||
try {
|
||||
@@ -2186,7 +2458,45 @@ ipcMain.handle('upload-gitea-file', async (event, data) => {
|
||||
} catch (gitErr) {
|
||||
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 });
|
||||
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;
|
||||
@@ -2334,7 +2644,6 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
|
||||
|
||||
// Git-basierter Upload: alle Dateien in einem einzigen Commit.
|
||||
// Vermeidet Giteas API-Index-Timing-Probleme (422 SHA) komplett.
|
||||
const simpleGit = require('simple-git');
|
||||
|
||||
// Auth-URL mit Token für HTTPS-Auth bauen
|
||||
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 });
|
||||
|
||||
try {
|
||||
const git = simpleGit({ config: gitConfig });
|
||||
let repoGit;
|
||||
let isEmptyRepo = false;
|
||||
|
||||
try {
|
||||
const cloneArgs = ['--depth', '1', '--no-single-branch'];
|
||||
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;
|
||||
}
|
||||
}
|
||||
const { repoGit, isEmptyRepo } = await cloneForUpload({
|
||||
authUrl,
|
||||
tmpDir,
|
||||
branch,
|
||||
gitConfig,
|
||||
label: 'FolderUpload'
|
||||
});
|
||||
|
||||
// Fortschritt: 30% (nach Clone)
|
||||
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 (_) {}
|
||||
|
||||
// git add + commit + push
|
||||
await repoGit.add('.');
|
||||
await stageUploadedFiles(repoGit);
|
||||
|
||||
let hasChanges = true;
|
||||
try {
|
||||
@@ -2436,7 +2726,29 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
|
||||
} catch (gitErr) {
|
||||
console.error('[FolderUpload] Git-Fehler:', String(gitErr));
|
||||
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) {
|
||||
console.error('upload-local-folder-to-gitea error', e);
|
||||
@@ -2444,6 +2756,168 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
|
||||
}
|
||||
});
|
||||
|
||||
/* -----------------------------------------------------------------
|
||||
Gemeinsamer Git-Upload für einen kompletten Drop (Dateien + Ordner).
|
||||
Erzeugt EINEN Clone + EINEN Commit + EINEN Push für ALLE Pfade
|
||||
zusammen – egal wie viele Dateien/Ordner gedroppt wurden.
|
||||
----------------------------------------------------------------- */
|
||||
const GIT_UPLOAD_EXCLUDE = ['.git', 'node_modules', '.DS_Store', 'thumbs.db', '.vscode', '.idea'];
|
||||
|
||||
// Wandelt gedroppte Pfade (Dateien und/oder Ordner) in eine flache Liste
|
||||
// { localFile, targetPath } um. Dateien -> destPath/<name>, Ordner ->
|
||||
// destPath/<ordnername>/<rel> (identisch zum bisherigen Verhalten).
|
||||
function buildItemsFromPaths(paths, destPath) {
|
||||
const items = [];
|
||||
const skipped = [];
|
||||
for (const p of paths) {
|
||||
let stat;
|
||||
try { stat = fs.statSync(p); } catch (e) { skipped.push({ path: p, error: 'not-found' }); continue; }
|
||||
const baseName = ppath.basename(p);
|
||||
if (stat.isFile()) {
|
||||
const targetPath = destPath ? `${destPath}/${baseName}` : baseName;
|
||||
items.push({ localFile: p, targetPath });
|
||||
} else if (stat.isDirectory()) {
|
||||
const folderName = baseName;
|
||||
(function walk(dir) {
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir); } catch (e) { return; }
|
||||
for (const entry of entries) {
|
||||
if (GIT_UPLOAD_EXCLUDE.includes(entry)) continue;
|
||||
const full = ppath.join(dir, entry);
|
||||
let st;
|
||||
try { st = fs.statSync(full); } catch (e) { continue; }
|
||||
if (st.isDirectory()) {
|
||||
walk(full);
|
||||
} else if (st.isFile()) {
|
||||
const rel = ppath.relative(p, full).split(ppath.sep).join('/');
|
||||
const targetPath = destPath ? `${destPath}/${folderName}/${rel}` : `${folderName}/${rel}`;
|
||||
items.push({ localFile: full, targetPath });
|
||||
}
|
||||
}
|
||||
})(p);
|
||||
} else {
|
||||
skipped.push({ path: p, error: 'unsupported' });
|
||||
}
|
||||
}
|
||||
return { items, skipped };
|
||||
}
|
||||
|
||||
// Klont das Repo (shallow), kopiert alle items hinein, commit + push.
|
||||
async function gitPushItemsToGitea({ token, url, owner, repo, branch, items, message, onProgress }) {
|
||||
const rawUrl = url.startsWith('http') ? url : `https://${url}`;
|
||||
const urlObj = new URL(rawUrl.replace(/\/$/, ''));
|
||||
const authUrl = `${urlObj.protocol}//${encodeURIComponent(token)}@${urlObj.host}/${owner}/${repo}.git`;
|
||||
|
||||
const tmpDir = getSafeTmpDir(`git-paths-${owner}-${repo}`);
|
||||
const gitConfig = ['user.email=gui@gitmanager.local', 'user.name=Git Manager GUI'];
|
||||
const safeBranch = sanitizeGitRef(branch || 'HEAD', 'HEAD');
|
||||
|
||||
try {
|
||||
const { repoGit, isEmptyRepo } = await cloneForUpload({
|
||||
authUrl,
|
||||
tmpDir,
|
||||
branch: safeBranch,
|
||||
gitConfig,
|
||||
label: 'UploadPaths'
|
||||
});
|
||||
|
||||
if (onProgress) onProgress(30);
|
||||
|
||||
for (const item of items) {
|
||||
const destFile = ppath.join(tmpDir, ...item.targetPath.split('/'));
|
||||
fs.mkdirSync(ppath.dirname(destFile), { recursive: true });
|
||||
fs.copyFileSync(item.localFile, destFile);
|
||||
}
|
||||
|
||||
if (onProgress) onProgress(60);
|
||||
|
||||
await stageUploadedFiles(repoGit);
|
||||
|
||||
let hasChanges = true;
|
||||
try {
|
||||
await repoGit.commit(message);
|
||||
} catch (commitErr) {
|
||||
if (String(commitErr).includes('nothing to commit')) hasChanges = false;
|
||||
else throw commitErr;
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
let pushBranch = safeBranch;
|
||||
if (pushBranch === 'HEAD' || isEmptyRepo) {
|
||||
try { const bs = await repoGit.branch(); pushBranch = bs.current || 'main'; } catch (_) { pushBranch = 'main'; }
|
||||
}
|
||||
if (isEmptyRepo) await repoGit.push(['-u', 'origin', pushBranch]);
|
||||
else await repoGit.push('origin', pushBranch);
|
||||
}
|
||||
|
||||
if (onProgress) onProgress(100);
|
||||
setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5000);
|
||||
return { hasChanges };
|
||||
} catch (gitErr) {
|
||||
try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
||||
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle('upload-paths-to-gitea', async (event, data) => {
|
||||
const uploadDebugId = `p-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
try {
|
||||
const credentials = readCredentials();
|
||||
const token = (data && data.token) || (credentials && credentials.giteaToken);
|
||||
const url = (data && data.url) || (credentials && credentials.giteaURL);
|
||||
if (!token || !url) return { ok: false, error: 'Zugangsdaten fehlen', debugId: uploadDebugId };
|
||||
|
||||
const owner = data.owner;
|
||||
const repo = data.repo;
|
||||
if (!owner || !repo) return { ok: false, error: 'missing-owner-or-repo', debugId: uploadDebugId };
|
||||
|
||||
const destPath = (data.destPath || '').replace(/^\//, '').replace(/\/$/, '');
|
||||
const branch = sanitizeGitRef(data.branch || 'HEAD', 'HEAD');
|
||||
const messagePrefix = data.messagePrefix || 'Upload via GUI';
|
||||
const paths = Array.isArray(data.paths) ? data.paths.filter(Boolean) : [];
|
||||
if (paths.length === 0) return { ok: false, error: 'Keine gültigen Pfade übergeben', debugId: uploadDebugId };
|
||||
|
||||
try { invalidateRepoContentsCache(owner, repo); } catch (_) {}
|
||||
try { caches.repos.invalidate(`${owner}/${repo}`); } catch (_) {}
|
||||
|
||||
const { items, skipped } = buildItemsFromPaths(paths, destPath);
|
||||
if (items.length === 0) {
|
||||
return { ok: false, error: 'Keine Dateien zum Hochladen gefunden', skipped, debugId: uploadDebugId };
|
||||
}
|
||||
|
||||
const total = items.length;
|
||||
const onProgress = (percent) => {
|
||||
try { event.sender.send('folder-upload-progress', { processed: Math.floor(total * percent / 100), total, percent }); } catch (_) {}
|
||||
};
|
||||
onProgress(0);
|
||||
|
||||
console.log('[UploadPaths] start', { uploadDebugId, owner, repo, branch, total, destPath, pathCount: paths.length });
|
||||
await gitPushItemsToGitea({
|
||||
token, url, owner, repo, branch, items,
|
||||
message: `${messagePrefix} (${total} Datei${total === 1 ? '' : 'en'})`,
|
||||
onProgress
|
||||
});
|
||||
console.log('[UploadPaths] done', { uploadDebugId, total });
|
||||
|
||||
const results = items.map(it => ({ ok: true, localFile: it.localFile, targetPath: it.targetPath }));
|
||||
return { ok: true, results, failedCount: 0, skipped, debugId: uploadDebugId };
|
||||
} catch (e) {
|
||||
console.error('[UploadPaths] fatal', { uploadDebugId, error: String(e) });
|
||||
return { ok: false, error: String(e && e.message ? e.message : e), debugId: uploadDebugId };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('download-gitea-file', async (event, data) => {
|
||||
try {
|
||||
const credentials = readCredentials();
|
||||
@@ -2841,19 +3315,97 @@ ipcMain.handle('upload-and-push', async (event, data) => {
|
||||
}
|
||||
}
|
||||
|
||||
// --- FALL 2: Ordner (Normale Git-Logik) ---
|
||||
// --- FALL 2: Ordner ---
|
||||
if (!isDirectory) {
|
||||
return { ok: false, error: `Path is neither file nor directory (${uploadDebugId})` };
|
||||
}
|
||||
|
||||
const folderName = ppath.basename(data.localFolder);
|
||||
// Dateien/Ordner, die niemals hochgeladen werden sollen
|
||||
const excludeList = ['.git', 'node_modules', '.DS_Store', 'thumbs.db', '.vscode', '.idea'];
|
||||
|
||||
// === PRIMÄRWEG: git push =========================================
|
||||
// Ein einziger komprimierter Transfer + genau EIN Commit statt
|
||||
// hunderter API-Requests. Das behebt sowohl die Langsamkeit als auch
|
||||
// die "cannot lock ref"/Timeout-Fehler durch parallele Einzel-Commits.
|
||||
// Bei Fehler (z.B. git nicht installiert) fällt der Code auf den
|
||||
// API-Upload weiter unten zurück.
|
||||
try {
|
||||
let finalCloneUrl = cloneUrl;
|
||||
if (!finalCloneUrl && giteaUrl) {
|
||||
const base = giteaUrl.replace(/\/$/, '');
|
||||
const urlObj = new URL(base);
|
||||
finalCloneUrl = `${urlObj.protocol}//${urlObj.host}/${owner}/${repo}.git`;
|
||||
}
|
||||
if (!finalCloneUrl) throw new Error('no-clone-url');
|
||||
|
||||
// Token in die Clone-URL einbetten (für Authentifizierung beim Push)
|
||||
let authClone = finalCloneUrl;
|
||||
try {
|
||||
const urlObj = new URL(finalCloneUrl);
|
||||
if (token && urlObj.protocol.startsWith('http')) {
|
||||
urlObj.username = encodeURIComponent(token);
|
||||
authClone = urlObj.toString();
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
const tmpDir = getSafeTmpDir(`git-push-folder-${owner}-${repo}`);
|
||||
try {
|
||||
console.log('[UPLOAD_DEBUG][main] directory-upload:git-clone', { uploadDebugId, branch, destPath });
|
||||
const cloneArgs = ['clone', '--depth', '1'];
|
||||
if (branch !== 'HEAD') cloneArgs.push('--branch', branch);
|
||||
cloneArgs.push(authClone, tmpDir);
|
||||
runGitSync(cloneArgs, process.cwd(), gitExecOptions);
|
||||
|
||||
// Zielverzeichnis im Repo: <destPath>/<folderName>/ (wie im API-Weg)
|
||||
let destDirInRepo = tmpDir;
|
||||
if (destPath) destDirInRepo = ppath.join(tmpDir, destPath.split('/').join(ppath.sep));
|
||||
destDirInRepo = ppath.join(destDirInRepo, folderName);
|
||||
ensureDir(destDirInRepo);
|
||||
|
||||
// Kompletten Ordner rekursiv kopieren, ausgeschlossene Einträge überspringen
|
||||
fs.cpSync(data.localFolder, destDirInRepo, {
|
||||
recursive: true,
|
||||
filter: (src) => !excludeList.includes(ppath.basename(src))
|
||||
});
|
||||
|
||||
runGitSync(['-C', tmpDir, 'add', '.'], process.cwd(), gitExecOptions);
|
||||
try {
|
||||
runGitSync(['-C', tmpDir, 'commit', '-m', `Upload ${folderName} via GUI`], process.cwd(), gitExecOptions);
|
||||
} catch (commitErr) {
|
||||
// "nothing to commit" ist kein echter Fehler
|
||||
if (!/nothing to commit/i.test(String(commitErr))) throw commitErr;
|
||||
console.log('[UPLOAD_DEBUG][main] directory-upload:git-nothing-to-commit', { uploadDebugId });
|
||||
setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5_000);
|
||||
return { ok: true, usedGit: true, results: [], debugId: uploadDebugId, msg: 'Keine Änderungen zum Hochladen' };
|
||||
}
|
||||
|
||||
let pushBranch = branch;
|
||||
if (pushBranch === 'HEAD') {
|
||||
try {
|
||||
pushBranch = runGitSync(['-C', tmpDir, 'rev-parse', '--abbrev-ref', 'HEAD'], process.cwd(), gitExecOptions).trim();
|
||||
} catch (_) { pushBranch = 'main'; }
|
||||
if (!pushBranch || pushBranch === 'HEAD') pushBranch = 'main';
|
||||
}
|
||||
runGitSync(['-C', tmpDir, 'push', 'origin', pushBranch], process.cwd(), gitExecOptions);
|
||||
|
||||
console.log('[UPLOAD_DEBUG][main] directory-upload:git-push-done', { uploadDebugId, pushBranch });
|
||||
setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5_000);
|
||||
return { ok: true, usedGit: true, results: [], debugId: uploadDebugId };
|
||||
} catch (gitErr) {
|
||||
try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
||||
throw gitErr;
|
||||
}
|
||||
} catch (gitErr) {
|
||||
console.warn('[UPLOAD_DEBUG][main] directory-upload:git-failed-fallback-to-api', { uploadDebugId, error: String(gitErr && gitErr.message ? gitErr.message : gitErr) });
|
||||
// -> weiter zum API-Fallback unten
|
||||
}
|
||||
|
||||
console.log('[UPLOAD_DEBUG][main] directory-upload:using-api-path', { uploadDebugId, branch, destPath });
|
||||
|
||||
// Fallback: API Upload (paralleler Upload)
|
||||
const items = [];
|
||||
const folderName = ppath.basename(data.localFolder);
|
||||
|
||||
// FIXED EXCLUDE LIST: Filter out .git, node_modules etc.
|
||||
const excludeList = ['.git', 'node_modules', '.DS_Store', 'thumbs.db', '.vscode', '.idea'];
|
||||
// excludeList und folderName sind oben (FALL 2) bereits definiert
|
||||
|
||||
(function walk(dir) {
|
||||
const entries = fs.readdirSync(dir);
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "git-manager-gui",
|
||||
"version": "2.1.5",
|
||||
"version": "2.1.7",
|
||||
"description": "Git Manager GUI - Verwaltung von Git Repositories",
|
||||
"author": "M_Viper",
|
||||
"main": "main.js",
|
||||
|
||||
+1
-2
@@ -38,6 +38,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
downloadGiteaFolder: (data) => ipcRenderer.invoke('download-gitea-folder', data),
|
||||
downloadGiteaFile: (data) => ipcRenderer.invoke('download-gitea-file', data),
|
||||
uploadLocalFolderToGitea: (data) => ipcRenderer.invoke('upload-local-folder-to-gitea', data),
|
||||
uploadPathsToGitea: (data) => ipcRenderer.invoke('upload-paths-to-gitea', data),
|
||||
|
||||
// Repository & Git Management
|
||||
saveCredentials: (data) => ipcRenderer.invoke('save-credentials', data),
|
||||
@@ -95,7 +96,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
|
||||
// Commit History & Visualization
|
||||
getCommits: (data) => ipcRenderer.invoke('get-commits', data),
|
||||
getCommitDetails: (data) => ipcRenderer.invoke('get-commit-details', data),
|
||||
getCommitDiff: (data) => ipcRenderer.invoke('get-commit-diff', data),
|
||||
getCommitFiles: (data) => ipcRenderer.invoke('get-commit-files', data),
|
||||
searchCommits: (data) => ipcRenderer.invoke('search-commits', data),
|
||||
@@ -105,7 +105,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getLocalCommits: (data) => ipcRenderer.invoke('get-local-commits', data),
|
||||
getLocalCommitDiff: (data) => ipcRenderer.invoke('get-local-commit-diff', data),
|
||||
getLocalCommitDetails: (data) => ipcRenderer.invoke('get-local-commit-details', data),
|
||||
searchLocalCommits: (data) => ipcRenderer.invoke('search-local-commits', data),
|
||||
|
||||
// === FAVORITEN & ZULETZT GEÖFFNET ===
|
||||
loadFavorites: () => ipcRenderer.invoke('load-favorites'),
|
||||
|
||||
+97
-82
@@ -360,6 +360,23 @@ function loadRepos() {
|
||||
}
|
||||
}
|
||||
|
||||
// Gravur-/Projekttitel zurücksetzen, wenn zur Übersicht gewechselt wird.
|
||||
// Einmalig auf Modulebene definiert, damit die Funktion sofort ab Programmstart
|
||||
// verfügbar ist (früher versehentlich in loadRepoContents definiert -> beim
|
||||
// Klick vor dem ersten Repo-Laden "resetProjectGravurTitle is not a function").
|
||||
window.resetProjectGravurTitle = function() {
|
||||
const gravurTitle = document.getElementById('project-gravur-title');
|
||||
if (gravurTitle) gravurTitle.textContent = '';
|
||||
};
|
||||
|
||||
// loadRepos EINMAL umhüllen, sodass beim Laden der Repo-Liste der Projekttitel
|
||||
// zurückgesetzt wird (früher wurde bei jedem Repo-Aufruf neu umhüllt).
|
||||
const origLoadRepos = loadRepos;
|
||||
loadRepos = function(...args) {
|
||||
window.resetProjectGravurTitle();
|
||||
return origLoadRepos.apply(this, args);
|
||||
};
|
||||
|
||||
/** Load repositories from GitHub and render the grid using loadGiteaRepos's renderer */
|
||||
async function loadGithubRepos(requestId = null) {
|
||||
const activeRequestId = requestId || ++repoLoadRequestId;
|
||||
@@ -831,10 +848,6 @@ async function uploadDroppedPaths({ paths, owner, repo, destPath = '', cloneUrl
|
||||
return { ok: false, error: 'Drag-and-Drop Upload ist aktuell nur für Gitea aktiv.' };
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
const errors = [];
|
||||
|
||||
const withTimeout = (promise, ms, label) => {
|
||||
return Promise.race([
|
||||
promise,
|
||||
@@ -842,79 +855,57 @@ async function uploadDroppedPaths({ paths, owner, repo, destPath = '', cloneUrl
|
||||
]);
|
||||
};
|
||||
|
||||
for (let i = 0; i < safePaths.length; i++) {
|
||||
const p = safePaths[i];
|
||||
const baseName = p.split(/[\\/]/).pop() || p;
|
||||
showProgress(Math.round(((i + 1) / safePaths.length) * 100), `Upload ${i + 1}/${safePaths.length}: ${baseName}`);
|
||||
// Der KOMPLETTE Drop (alle Dateien UND Ordner zusammen) wird in EINEM
|
||||
// Aufruf hochgeladen: ein Repo-Clone, ein Commit, ein Push – egal wie
|
||||
// viele Objekte gedroppt wurden. Das war die Ursache für die langen
|
||||
// Wartezeiten und Zeitüberschreitungen (vorher: ein kompletter Clone+Push
|
||||
// pro einzelner Datei bzw. pro Ordner).
|
||||
showProgress(15, `Bereite Upload von ${safePaths.length} Objekt(en) vor ...`);
|
||||
try {
|
||||
let res = null;
|
||||
|
||||
const pathType = await withTimeout(window.electronAPI.getPathType(p), 5000, 'get-path-type');
|
||||
console.log('[UPLOAD_DEBUG][renderer] uploadDroppedPaths:pathType', { path: p, pathType });
|
||||
try { window.electronAPI.debugToMain('log', 'uploadDroppedPaths:pathType', { path: p, pathType }); } catch (_) {}
|
||||
|
||||
if (!pathType?.ok) {
|
||||
throw new Error(pathType?.error || 'path-type-failed');
|
||||
}
|
||||
|
||||
if (pathType.type === 'file') {
|
||||
res = await withTimeout(window.electronAPI.uploadGiteaFile({
|
||||
owner,
|
||||
repo,
|
||||
localPath: [p],
|
||||
destPath,
|
||||
branch,
|
||||
platform: currentState.platform
|
||||
}), 15000, 'upload-gitea-file');
|
||||
} else if (pathType.type === 'dir') {
|
||||
res = await withTimeout(window.electronAPI.uploadLocalFolderToGitea({
|
||||
localFolder: p,
|
||||
const res = await withTimeout(window.electronAPI.uploadPathsToGitea({
|
||||
paths: safePaths,
|
||||
owner,
|
||||
repo,
|
||||
destPath,
|
||||
branch,
|
||||
messagePrefix: 'Upload folder via GUI'
|
||||
}), 600000, 'upload-local-folder-to-gitea');
|
||||
} else {
|
||||
throw new Error(`Nicht unterstuetzter Pfadtyp: ${pathType.type || 'unknown'}`);
|
||||
}
|
||||
|
||||
console.log('[UPLOAD_DEBUG][renderer] uploadDroppedPaths:itemResult', { path: p, res });
|
||||
try { window.electronAPI.debugToMain('log', 'uploadDroppedPaths:itemResult', { path: p, res }); } catch (_) {}
|
||||
messagePrefix: 'Upload via GUI'
|
||||
}), 900000, 'upload-paths-to-gitea'); // 15 Min Sicherheits-Timeout
|
||||
showProgress(100, 'Upload abgeschlossen');
|
||||
console.log('[UPLOAD_DEBUG][renderer] uploadDroppedPaths:result', res);
|
||||
try { window.electronAPI.debugToMain('log', 'uploadDroppedPaths:result', { count: safePaths.length, ok: res?.ok, failedCount: res?.failedCount }); } catch (_) {}
|
||||
|
||||
if (!res?.ok) {
|
||||
failedCount++;
|
||||
const detailedFailure = Array.isArray(res?.failedFiles) && res.failedFiles.length > 0
|
||||
? `${baseName}: ${res.failedFiles[0].targetPath} - ${res.failedFiles[0].error || 'Unbekannter Fehler'}`
|
||||
: `${baseName}: ${res?.error || 'Unbekannter Fehler'}`;
|
||||
errors.push(detailedFailure);
|
||||
continue;
|
||||
return {
|
||||
ok: false,
|
||||
error: res?.error || 'Upload fehlgeschlagen',
|
||||
failedFiles: Array.isArray(res?.results) ? res.results.filter(r => !r.ok) : [],
|
||||
successCount: 0,
|
||||
failedCount: safePaths.length
|
||||
};
|
||||
}
|
||||
|
||||
const failedEntries = Array.isArray(res.results) ? res.results.filter(r => !r.ok) : [];
|
||||
const uploaded = (Array.isArray(res.results) ? res.results.length : 0) - failedEntries.length;
|
||||
if (failedEntries.length > 0) {
|
||||
failedCount++;
|
||||
errors.push(`${baseName}: ${failedEntries[0]?.error || 'Teilweise fehlgeschlagen'}`);
|
||||
} else {
|
||||
successCount++;
|
||||
}
|
||||
} catch (err) {
|
||||
failedCount++;
|
||||
const errMsg = String(err && err.message ? err.message : err);
|
||||
errors.push(`${baseName}: ${errMsg}`);
|
||||
console.error('[UPLOAD_DEBUG][renderer] uploadDroppedPaths:itemError', { path: p, err: errMsg });
|
||||
try { window.electronAPI.debugToMain('error', 'uploadDroppedPaths:itemError', { path: p, err: errMsg }); } catch (_) {}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: failedEntries[0]?.error || 'Teilweise fehlgeschlagen',
|
||||
failedFiles: failedEntries,
|
||||
successCount: uploaded,
|
||||
failedCount: failedEntries.length
|
||||
};
|
||||
}
|
||||
|
||||
if (failedCount > 0) {
|
||||
return { ok: false, error: errors[0] || `${failedCount} Upload(s) fehlgeschlagen`, successCount, failedCount };
|
||||
}
|
||||
|
||||
const result = { ok: true, uploadedFiles: successCount, uploadedDirs: 0 };
|
||||
const result = { ok: true, uploadedFiles: uploaded, uploadedDirs: 0 };
|
||||
try { window.electronAPI.debugToMain('log', 'uploadDroppedPaths:done', result); } catch (_) {}
|
||||
console.log('[UPLOAD_DEBUG][renderer] uploadDroppedPaths:done', result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const errMsg = String(err && err.message ? err.message : err);
|
||||
console.error('[UPLOAD_DEBUG][renderer] uploadDroppedPaths:error', { err: errMsg });
|
||||
try { window.electronAPI.debugToMain('error', 'uploadDroppedPaths:error', { err: errMsg }); } catch (_) {}
|
||||
return { ok: false, error: errMsg, successCount: 0, failedCount: safePaths.length };
|
||||
}
|
||||
}
|
||||
|
||||
function extractDroppedPaths(files) {
|
||||
@@ -2134,6 +2125,36 @@ function updateSettingsHealth(patch) {
|
||||
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) {
|
||||
const value = (rawUrl || '').trim();
|
||||
if (!value) return { ok: true, value: '' };
|
||||
@@ -2144,7 +2165,7 @@ function normalizeAndValidateGiteaUrl(rawUrl) {
|
||||
} catch (_) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Ungültige URL. Beispiel für IPv6: http://[2001:db8::1]:3000'
|
||||
error: INVALID_URL_HINT
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2182,8 +2203,16 @@ function renderGithubTokenHint(rawToken) {
|
||||
}
|
||||
|
||||
function mapErrorMessage(message) {
|
||||
const raw = String(message || '').toLowerCase();
|
||||
if (!raw) return 'Unbekannter Fehler';
|
||||
const original = String(message ?? '').trim();
|
||||
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')) {
|
||||
return 'Authentifizierung fehlgeschlagen. Bitte Token prüfen.';
|
||||
@@ -2195,15 +2224,15 @@ function mapErrorMessage(message) {
|
||||
return 'Server oder Ressource nicht gefunden. URL/Repo prüfen.';
|
||||
}
|
||||
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')) {
|
||||
return 'Zeitüberschreitung bei der Verbindung. Bitte erneut versuchen.';
|
||||
}
|
||||
if (raw.includes('ungueltige') || raw.includes('ungültige') || raw.includes('invalid') || raw.includes('url')) {
|
||||
return 'Ungültige URL. Beispiel für IPv6: http://[2001:db8::1]:3000';
|
||||
if (isInvalidUrlError(raw)) {
|
||||
return INVALID_URL_HINT;
|
||||
}
|
||||
return String(message);
|
||||
return original;
|
||||
}
|
||||
|
||||
function setStatus(txt) {
|
||||
@@ -3941,20 +3970,6 @@ async function loadRepoContents(owner, repo, path) {
|
||||
showError('Error: ' + (res.error || 'Unknown error'));
|
||||
return;
|
||||
}
|
||||
// Wenn zur Übersicht gewechselt wird, Gravur zurücksetzen
|
||||
window.resetProjectGravurTitle = function() {
|
||||
const gravurTitle = document.getElementById('project-gravur-title');
|
||||
if (gravurTitle) gravurTitle.textContent = '';
|
||||
};
|
||||
|
||||
// ...existing code...
|
||||
|
||||
// Nach dem Laden der Repo-Liste oder beim Klick auf "Zurück" rufe resetProjectGravurTitle() auf
|
||||
const origLoadRepos = loadRepos;
|
||||
loadRepos = function(...args) {
|
||||
resetProjectGravurTitle();
|
||||
return origLoadRepos.apply(this, args);
|
||||
};
|
||||
|
||||
const grid = $('explorerGrid');
|
||||
if (!grid) return;
|
||||
|
||||
+224
-1
@@ -3,6 +3,7 @@
|
||||
// getGiteaRepoContents, getGiteaFileContent, uploadGiteaFile
|
||||
|
||||
const axios = require('axios');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
|
||||
@@ -16,6 +17,12 @@ const axiosInstance = axios.create({
|
||||
httpsAgent: ipv4HttpsAgent,
|
||||
});
|
||||
|
||||
// Kurzlebiger Cache für die Default-Branch-Auflösung (HEAD -> z.B. "main").
|
||||
// Verhindert, dass bei einem Ordner-Upload für jede einzelne Datei erneut
|
||||
// die Repo-Info vom Server geladen wird.
|
||||
const defaultBranchCache = new Map();
|
||||
const DEFAULT_BRANCH_TTL_MS = 60_000;
|
||||
|
||||
function normalizeAndValidateBaseUrl(rawUrl) {
|
||||
const value = (rawUrl || '').trim();
|
||||
if (!value) {
|
||||
@@ -685,8 +692,15 @@ async function uploadGiteaFile({ token, url, owner, repo, path, contentBase64, m
|
||||
// Behalte den branch so wie übergeben - aber 'HEAD' muss zum echten Branch aufgelöst werden
|
||||
let branchName = branch || 'HEAD';
|
||||
|
||||
// HEAD-Auflösung: Wenn branch === 'HEAD', den Default-Branch des Repos abrufen
|
||||
// HEAD-Auflösung: Wenn branch === 'HEAD', den Default-Branch des Repos abrufen.
|
||||
// Ergebnis kurz cachen, damit bei einem Ordner-Upload nicht für JEDE Datei
|
||||
// erneut die Repo-Info geladen wird (das war eine Hauptursache der Langsamkeit).
|
||||
if (branchName === 'HEAD') {
|
||||
const cacheKey = `${base}::${owner}/${repo}`;
|
||||
const cached = defaultBranchCache.get(cacheKey);
|
||||
if (cached && (Date.now() - cached.ts) < DEFAULT_BRANCH_TTL_MS) {
|
||||
branchName = cached.branch;
|
||||
} else {
|
||||
try {
|
||||
const repoInfoUrl = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
|
||||
const repoInfo = await tryRequest(repoInfoUrl, token);
|
||||
@@ -703,6 +717,8 @@ async function uploadGiteaFile({ token, url, owner, repo, path, contentBase64, m
|
||||
branchName = 'main';
|
||||
console.warn(`[Upload Debug] HEAD-Auflösung fehlgeschlagen (${e.message}), verwende Fallback: ${branchName}`);
|
||||
}
|
||||
defaultBranchCache.set(cacheKey, { branch: branchName, ts: Date.now() });
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSha = async () => {
|
||||
@@ -767,6 +783,25 @@ async function uploadGiteaFile({ token, url, owner, repo, path, contentBase64, m
|
||||
} catch (err) {
|
||||
console.error(`Upload Attempt ${retryCount + 1} for ${path}:`, err.response ? err.response.data : err.message);
|
||||
|
||||
// Netzwerk-Timeout / abgebrochene Verbindung: mit Backoff erneut versuchen,
|
||||
// statt sofort abzubrechen. Deckt ECONNABORTED (axios-Timeout), ETIMEDOUT,
|
||||
// ECONNRESET und EPIPE ab.
|
||||
const netCode = err.code || '';
|
||||
const isNetworkTimeout = !err.response &&
|
||||
(netCode === 'ECONNABORTED' || netCode === 'ETIMEDOUT' ||
|
||||
netCode === 'ECONNRESET' || netCode === 'EPIPE' ||
|
||||
/timeout/i.test(String(err.message || '')));
|
||||
if (isNetworkTimeout) {
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount++;
|
||||
const wait = 1000 * retryCount; // 1s, 2s, 3s ...
|
||||
console.warn(`-> Netzwerk-Timeout (${netCode || 'timeout'}). Erneuter Versuch in ${wait}ms... (Retry ${retryCount}/${MAX_RETRIES})`);
|
||||
await sleep(wait);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Zeitüberschreitung beim Hochladen von "${path}". Bitte Netzwerk/Server prüfen und erneut versuchen.`);
|
||||
}
|
||||
|
||||
// Behandle 500 Server-Fehler speziell
|
||||
if (err.response && err.response.status === 500) {
|
||||
const errorMsg = err.response.data?.message || err.message;
|
||||
@@ -2021,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 = {
|
||||
normalizeAndValidateBaseUrl,
|
||||
createRepoGitHub,
|
||||
@@ -2039,6 +2261,7 @@ module.exports = {
|
||||
getGiteaRepoContents,
|
||||
getGiteaFileContent,
|
||||
uploadGiteaFile,
|
||||
uploadItemsViaGiteaApi,
|
||||
// Commit History
|
||||
getGiteaCommits,
|
||||
getGiteaCommit,
|
||||
|
||||
Reference in New Issue
Block a user