Upload via GUI (7543 Dateien)

This commit is contained in:
2026-10-22 06:53:45 +00:00
parent 5571f225d5
commit 047196c4d0
16 changed files with 7030 additions and 7467 deletions
+307 -59
View File
@@ -1,10 +1,42 @@
// main.js — Main-Process with concurrent folder upload/download, progress events, and temp-dir cleanup
const { app, BrowserWindow, ipcMain, dialog, Menu, nativeImage, Tray, shell, clipboard, safeStorage } = require('electron');
// Das Setup startet die frisch installierte EXE einmal mit diesem Schalter, damit Windows Defender
// sie schon während der Installation prüft (installer/Installer.cs → Vorwaermen). Sofort beenden.
if (process.argv.includes('--gm-vorwaermen')) process.exit(0);
// Eingebautes Git aktivieren, bevor irgendein Git-Prozess startet.
require('./src/git/gitBinary.js').useBundledGit();
const ppath = require('path');
const fs = require('fs');
// Allgemeine App-Einstellungen (ohne Geheimnisse) und was sofort davon abhängt
const { applyGitIdentity, commitIdentity } = require('./src/git/gitBinary.js');
const appPrefs = require('./src/features/appPrefs.js').createPrefs(ppath.join(app.getPath('userData'), 'app-settings.json'));
const UPLOAD_EXCLUDES_FILE = ppath.join(app.getPath('userData'), 'upload-excludes.txt');
function applyPrefsToProcess(p) {
try { applyGitIdentity({ name: p.gitName, email: p.gitEmail }, app.getPath('userData')); } catch (e) { console.warn('Git-Identität:', e.message); }
try {
if (p.uploadExtraExcludes.trim()) fs.writeFileSync(UPLOAD_EXCLUDES_FILE, p.uploadExtraExcludes + '\n', 'utf8');
else if (fs.existsSync(UPLOAD_EXCLUDES_FILE)) fs.rmSync(UPLOAD_EXCLUDES_FILE, { force: true });
} catch (e) { console.warn('Upload-Ausschlüsse:', e.message); }
}
applyPrefsToProcess(appPrefs.get());
appPrefs.onChange((p) => {
applyPrefsToProcess(p);
for (const w of BrowserWindow.getAllWindows()) {
try { w.webContents.setZoomFactor(p.zoom / 100); } catch (_) {}
}
});
// Name/E-Mail für Commits in den temporären Upload-Klonen
function gitIdentityConfig() {
const id = commitIdentity(appPrefs.get());
return [`user.email=${id.email}`, `user.name=${id.name}`];
}
// Wird beendet (Tray-Menü, Updater, Herunterfahren)? Dann das Fenster nicht in den Tray schicken.
let quittingForReal = false;
app.on('before-quit', () => { quittingForReal = true; });
const os = require('os');
const crypto = require('crypto');
const { execSync, spawnSync } = require('child_process');
const { execSync, spawnSync, spawn } = require('child_process');
const http = require('http');
const https = require('https');
@@ -16,7 +48,8 @@ const {
formatErrorForUser,
caches,
runParallel,
retryWithBackoff
retryWithBackoff,
bareHostname
} = require('./src/utils/helpers.js');
// OPTIMIERUNG: Updater wird nicht sofort geladen, sondern verzögert nach Startup
@@ -44,6 +77,8 @@ const {
getGiteaFileContent,
uploadGiteaFile,
uploadItemsViaGiteaApi,
listGiteaRepoFiles,
deleteGiteaPathsBatched,
getGiteaCurrentUser,
getGiteaCommits,
getGiteaCommit,
@@ -187,6 +222,14 @@ function markGitTransportWorking(authUrl) {
if (brokenGitTransports.delete(gitTransportKey(authUrl))) saveBrokenGitTransports();
}
// Hebt die Sperre für einen Host auf, z. B. wenn die Diagnose den Git-Weg als funktionierend erkannt hat.
function clearGitTransportBlock(host) {
loadBrokenGitTransports();
if (!brokenGitTransports.delete(host)) return false;
saveBrokenGitTransports();
return true;
}
function isGitTransportKnownBroken(authUrl) {
loadBrokenGitTransports();
const key = gitTransportKey(authUrl);
@@ -975,6 +1018,9 @@ function createWindow() {
});
win.loadFile(ppath.join(__dirname, 'renderer', 'index.html'));
win.webContents.on('did-finish-load', () => {
try { win.webContents.setZoomFactor(appPrefs.get().zoom / 100); } catch (_) {}
});
// win.webContents.openDevTools();
// OPTIMIERUNG: Tray wird verzögert hergestellt (nicht beim Fenster-Create)
@@ -982,10 +1028,12 @@ function createWindow() {
createTray(win);
});
// Schließen-Button -> Tray statt Beenden (nur wenn Autostart aktiv)
// Schließen-Button -> Tray statt Beenden. Einstellung "Beim Schließen": immer Tray, immer beenden
// oder automatisch (Tray nur, wenn die App mit Windows startet).
win.on('close', (e) => {
const { enabled } = app.getLoginItemSettings();
if (enabled) {
const action = appPrefs.get().closeAction;
const toTray = action === 'tray' || (action === 'auto' && app.getLoginItemSettings().enabled);
if (toTray && !quittingForReal) {
e.preventDefault();
win.hide();
}
@@ -1140,18 +1188,37 @@ function sanitizeGitRef(ref, fallback = 'main') {
return normalizeBranch(ref, 'gitea');
}
function runGitSync(args, cwd, options = {}) {
const res = spawnSync('git', args, {
cwd,
encoding: 'utf8',
windowsHide: true,
...options
// Startet Git ohne den Hauptprozess zu blockieren. Synchrone Aufrufe (spawnSync) froren
// die Oberfläche ein, bis Windows "Anwendung reagiert nicht" meldete.
function execGit(args, { cwd, input, env } = {}) {
return new Promise((resolve) => {
let child;
try {
child = spawn('git', args, { cwd, env: env || process.env, windowsHide: true });
} catch (e) {
resolve({ status: -1, stdout: '', stderr: String(e && e.message || e) });
return;
}
const out = [];
const err = [];
child.stdout.on('data', c => out.push(c));
child.stderr.on('data', c => err.push(c));
child.on('error', e => err.push(Buffer.from(String(e && e.message || e))));
child.on('close', (code) => resolve({
status: code,
stdout: Buffer.concat(out).toString('utf8'),
stderr: Buffer.concat(err).toString('utf8')
}));
if (input != null) child.stdin.end(input);
else child.stdin.end();
});
}
async function runGitAsync(args, cwd, options = {}) {
const res = await execGit(args, { cwd, env: options.env });
if (res.status !== 0) {
throw new Error(String(res.stderr || res.stdout || 'Git-Fehler').trim());
}
return String(res.stdout || '').trim();
}
@@ -1489,7 +1556,7 @@ ipcMain.handle('push-project', async (event, data) => {
// Aktuellen Branch ermitteln (NICHT umbenennen!)
let currentBranch = data.branch || null;
try {
const detected = runGitSync(['branch', '--show-current'], data.folder);
const detected = await runGitAsync(['branch', '--show-current'], data.folder);
if (detected) {
currentBranch = currentBranch || sanitizeGitRef(detected, 'main');
console.log('Current local branch:', detected);
@@ -1504,7 +1571,7 @@ ipcMain.handle('push-project', async (event, data) => {
// 2. Prüfen, ob ein 'origin' Remote existiert
let remoteUrl = '';
try {
remoteUrl = runGitSync(['remote', 'get-url', 'origin'], data.folder);
remoteUrl = await runGitAsync(['remote', 'get-url', 'origin'], data.folder);
// Bereits gespeicherte Credential-URLs in sichere Origin-URLs ohne Userinfo konvertieren.
try {
@@ -1513,7 +1580,7 @@ ipcMain.handle('push-project', async (event, data) => {
remoteObj.username = '';
remoteObj.password = '';
const sanitizedRemoteUrl = remoteObj.toString();
runGitSync(['remote', 'set-url', 'origin', sanitizedRemoteUrl], data.folder);
await runGitAsync(['remote', 'set-url', 'origin', sanitizedRemoteUrl], data.folder);
remoteUrl = sanitizedRemoteUrl;
}
} catch (_) {}
@@ -1544,11 +1611,11 @@ ipcMain.handle('push-project', async (event, data) => {
}
if (constructedUrl) {
try {
runGitSync(['remote', 'add', 'origin', constructedUrl], data.folder, { stdio: 'inherit' });
await runGitAsync(['remote', 'add', 'origin', constructedUrl], data.folder, { stdio: 'inherit' });
// Auth nie in der Remote-URL speichern; stattdessen Header scoped auf den Remote-Origin.
if (remoteOrigin && authHeader) {
runGitSync(['config', `http.${remoteOrigin}.extraheader`, authHeader], data.folder);
await runGitAsync(['config', `http.${remoteOrigin}.extraheader`, authHeader], data.folder);
}
console.log(`Remote origin added: ${constructedUrl}`);
@@ -1583,7 +1650,7 @@ ipcMain.handle('getBranches', async (event, data) => {
// Aktuellen Branch ermitteln und nach oben sortieren
let currentBranch = null;
try {
currentBranch = runGitSync(['branch', '--show-current'], data.folder);
currentBranch = await runGitAsync(['branch', '--show-current'], data.folder);
} catch (_) {}
branches.sort((a, b) => {
if (a === currentBranch) return -1;
@@ -1908,7 +1975,7 @@ ipcMain.handle('deleteFile', async (event, data) => {
return new Promise((resolve, reject) => {
const refQuery = useBranch ? `?ref=${encodeURIComponent(useBranch)}` : '';
const req = protocol.request({
hostname: urlObj.hostname, port,
hostname: bareHostname(urlObj.hostname), port,
path: `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${path.split('/').map(encodeURIComponent).join('/')}${refQuery}`,
method: 'GET',
headers: { 'Authorization': `token ${token}`, 'Content-Type': 'application/json' }
@@ -1934,7 +2001,7 @@ ipcMain.handle('deleteFile', async (event, data) => {
if (useBranch) payload.branch = useBranch;
const body = JSON.stringify(payload);
const req = protocol.request({
hostname: urlObj.hostname, port,
hostname: bareHostname(urlObj.hostname), port,
path: `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${filePath.split('/').map(encodeURIComponent).join('/')}`,
method: 'DELETE',
headers: {
@@ -1972,6 +2039,11 @@ ipcMain.handle('deleteFile', async (event, data) => {
const notFound = contents.errors.some(err => String(err).toLowerCase().includes('does not exist'));
if (notFound) return files;
}
// Gitea meldet einen nicht (mehr) vorhandenen Pfad als {"message":"object does not exist ..."}.
if (contents && !Array.isArray(contents) && !contents.sha && typeof contents.message === 'string' &&
/does not exist|not found/i.test(contents.message)) {
return files;
}
if (Array.isArray(contents)) {
// It's a folder — recurse into it
for (const item of contents) {
@@ -2020,6 +2092,23 @@ ipcMain.handle('deleteFile', async (event, data) => {
return remaining.length > 0;
}
// Schnellweg: paketweise löschen (ein Commit pro bis zu 100 Dateien).
// Nur wenn die Trees-API nicht nutzbar ist, läuft der alte Einzelweg weiter unten.
try {
const r = await deleteGiteaPathsBatched({
token, url: giteaUrl, owner, repo, branch: useBranch || 'HEAD', prefix: filePath
});
invalidateRepoContentsCache(owner, repo);
if (r.failed.length === 0) {
logger.info('deleteFile', `Deleted ${r.deleted} files in ${r.commits} commits`);
return { ok: true, deleted: r.deleted };
}
const preview = r.failed.slice(0, 5).map(f => `${f.path}: ${f.error}`).join('; ');
return { ok: false, error: `Löschen unvollständig: ${r.failed.length} von ${r.total} Dateien nicht gelöscht (${preview}${r.failed.length > 5 ? ', ...' : ''}).` };
} catch (batchErr) {
logger.warn('deleteFile', 'Paket-Löschen nicht möglich, nutze Einzelweg', { error: String(batchErr && batchErr.message || batchErr) });
}
// Collect all files to delete (handles both file and folder)
const filesToDelete = await collectAllFiles(filePath);
@@ -2072,6 +2161,70 @@ ipcMain.handle('deleteFile', async (event, data) => {
}
});
/* -----------------------------
Gitea: Dateien finden, die laut lokaler .gitignore nicht ins Repo gehören
----------------------------- */
ipcMain.handle('find-gitignored-in-repo', async (_event, data) => {
try {
const credentials = readCredentials();
const token = credentials && credentials.giteaToken;
const url = credentials && credentials.giteaURL;
if (!token || !url) return { ok: false, error: 'missing-token-or-url' };
const { owner, repo, localFolder } = data || {};
const repoPath = String((data && data.repoPath) || '').replace(/^\/+|\/+$/g, '');
if (!owner || !repo || !localFolder || !fs.existsSync(localFolder)) return { ok: false, error: 'Lokaler Ordner nicht gefunden' };
const { branch, files } = await listGiteaRepoFiles({ token, url, owner, repo, branch: data.branch || 'HEAD' });
const prefix = repoPath ? `${repoPath}/` : '';
const remote = [...files.keys()].filter(p => p.startsWith(prefix));
if (remote.length === 0) return { ok: true, branch, files: [], scanned: 0 };
// Die Regeln des lokalen Ordners auf die Server-Pfade anwenden. check-ignore prüft rein
// nach Muster, die Dateien müssen lokal nicht existieren.
const tmp = fs.mkdtempSync(ppath.join(os.tmpdir(), 'gm-ignore-'));
let ignoredRel = [];
try {
await execGit(['init', '-q', '--bare', tmp]);
const r = await execGit([`--git-dir=${tmp}`, `--work-tree=${localFolder}`, 'check-ignore', '--no-index', '--stdin', '-z'], {
cwd: localFolder,
input: remote.map(p => p.slice(prefix.length)).join('\0') + '\0'
});
// Exit-Code 1 heißt: nichts ignoriert.
if (r.status !== 0 && r.status !== 1) throw new Error((r.stderr || 'git check-ignore fehlgeschlagen').trim());
ignoredRel = r.stdout.split('\0').filter(Boolean);
} finally {
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {}
}
return { ok: true, branch, files: ignoredRel.map(rel => prefix + rel), scanned: remote.length };
} catch (e) {
console.error('find-gitignored-in-repo error', e);
return { ok: false, error: mapIpcError(e) };
}
});
ipcMain.handle('delete-gitea-paths', async (event, data) => {
try {
const credentials = readCredentials();
const token = credentials && credentials.giteaToken;
const url = credentials && credentials.giteaURL;
if (!token || !url) return { ok: false, error: 'missing-token-or-url' };
const { owner, repo, paths } = data || {};
if (!owner || !repo || !Array.isArray(paths) || paths.length === 0) return { ok: false, error: 'Keine Dateien angegeben' };
const r = await deleteGiteaPathsBatched({
token, url, owner, repo, branch: data.branch || 'HEAD', paths,
message: data.message,
onProgress: (percent) => { try { event.sender.send('folder-upload-progress', { percent }); } catch (_) {} }
});
invalidateRepoContentsCache(owner, repo);
return { ok: r.failed.length === 0, ...r };
} catch (e) {
console.error('delete-gitea-paths error', e);
return { ok: false, error: mapIpcError(e) };
}
});
/* -----------------------------
Gitea: list repos, contents, file content
----------------------------- */
@@ -2405,7 +2558,7 @@ ipcMain.handle('upload-gitea-file', async (event, data) => {
}
const tmpDir = getSafeTmpDir(`git-file-${owner}-${repo}`);
const gitConfig = ['user.email=gui@gitmanager.local', 'user.name=Git Manager GUI'];
const gitConfig = gitIdentityConfig();
try {
const { repoGit, isEmptyRepo } = await cloneForUpload({
@@ -2617,6 +2770,7 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
// Exclude-Liste: .git, node_modules etc. überspringen
const excludeList = ['.git', 'node_modules', '.DS_Store', 'thumbs.db', '.vscode', '.idea'];
const isIgnored = await createUploadIgnore(localFolder);
(function walk(dir) {
const entries = fs.readdirSync(dir);
for (const entry of entries) {
@@ -2624,6 +2778,7 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
const full = ppath.join(dir, entry);
let stat;
try { stat = fs.statSync(full); } catch(e) { continue; }
if (isIgnored(full, stat.isDirectory())) continue;
if (stat.isDirectory()) {
walk(full);
} else if (stat.isFile()) {
@@ -2656,7 +2811,7 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
}
const tmpDir = getSafeTmpDir(`git-folder-${owner}-${repo}`);
const gitConfig = ['user.email=gui@gitmanager.local', 'user.name=Git Manager GUI'];
const gitConfig = gitIdentityConfig();
// Fortschritt: Start
try { event.sender.send('folder-upload-progress', { processed: 0, total, percent: 0 }); } catch (_) {}
@@ -2761,18 +2916,78 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => {
Erzeugt EINEN Clone + EINEN Commit + EINEN Push für ALLE Pfade
zusammen – egal wie viele Dateien/Ordner gedroppt wurden.
----------------------------------------------------------------- */
// Liest die .gitignore-Regeln (auch verschachtelte und globale) für einen Upload-Pfad aus
// und liefert eine Prüffunktion. Build-Ausgaben wie build/ oder .gradle/ werden so nicht
// hochgeladen. Maßgeblich ist das Repo, in dem der Pfad liegt; ohne Repo der Elternordner.
// So greift die .gitignore auch, wenn nur einzelne Einträge eines Ordners gezogen werden.
// Git bekommt ein leeres Dummy-Repo und den Ordner nur als Arbeitsverzeichnis.
//
// Läuft asynchron; das Ergebnis pro Arbeitsordner wird im übergebenen Cache gehalten,
// damit 19 gezogene Einträge eines Ordners nur eine Git-Abfrage auslösen.
async function createUploadIgnore(target, cache = new Map()) {
const parent = ppath.dirname(target);
let workTree = parent;
if (cache.has(`top:${parent}`)) {
workTree = cache.get(`top:${parent}`);
} else {
const top = await execGit(['-C', parent, 'rev-parse', '--show-toplevel']);
if (top.status === 0 && top.stdout.trim()) workTree = ppath.resolve(top.stdout.trim());
cache.set(`top:${parent}`, workTree);
}
let ignored = cache.get(`ign:${workTree}`);
if (!ignored) {
ignored = new Set();
let tmp = null;
try {
tmp = fs.mkdtempSync(ppath.join(os.tmpdir(), 'gm-ignore-'));
const init = await execGit(['init', '-q', '--bare', tmp]);
if (init.status === 0) {
const r = await execGit([`--git-dir=${tmp}`, `--work-tree=${workTree}`, 'ls-files', '-z',
'--others', '--ignored', '--exclude-standard', '--directory',
// zusätzliche Muster aus Einstellungen → Uploads
...(fs.existsSync(UPLOAD_EXCLUDES_FILE) ? [`--exclude-from=${UPLOAD_EXCLUDES_FILE}`] : [])], { cwd: workTree });
if (r.status === 0) {
for (const entry of r.stdout.split('\0')) if (entry) ignored.add(entry);
}
}
} catch (e) {
console.warn('createUploadIgnore:', e.message || e);
} finally {
if (tmp) { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} }
}
cache.set(`ign:${workTree}`, ignored);
if (ignored.size) console.log(`[Upload] .gitignore: ${ignored.size} Einträge unter ${workTree} ausgeschlossen`);
}
return (fullPath, isDir) => {
if (!ignored.size) return false;
const rel = ppath.relative(workTree, fullPath).split(ppath.sep).join('/');
if (ignored.has(isDir ? `${rel}/` : rel)) return true;
// Liegt der Pfad in einem ausgeschlossenen Ordner?
for (let i = rel.lastIndexOf('/'); i > 0; i = rel.lastIndexOf('/', i - 1)) {
if (ignored.has(rel.slice(0, i + 1))) return true;
}
return false;
};
}
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) {
async function buildItemsFromPaths(paths, destPath) {
const items = [];
const ignoreCache = new Map();
const skipped = [];
const ignored = [];
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);
const isIgnored = await createUploadIgnore(p, ignoreCache);
if (isIgnored(p, stat.isDirectory())) { skipped.push({ path: p, error: 'gitignore' }); continue; }
if (stat.isFile()) {
const targetPath = destPath ? `${destPath}/${baseName}` : baseName;
items.push({ localFile: p, targetPath });
@@ -2786,6 +3001,7 @@ function buildItemsFromPaths(paths, destPath) {
const full = ppath.join(dir, entry);
let st;
try { st = fs.statSync(full); } catch (e) { continue; }
if (isIgnored(full, st.isDirectory())) { ignored.push({ path: full, isDir: st.isDirectory() }); continue; }
if (st.isDirectory()) {
walk(full);
} else if (st.isFile()) {
@@ -2799,7 +3015,7 @@ function buildItemsFromPaths(paths, destPath) {
skipped.push({ path: p, error: 'unsupported' });
}
}
return { items, skipped };
return { items, skipped, ignored };
}
// Klont das Repo (shallow), kopiert alle items hinein, commit + push.
@@ -2809,7 +3025,7 @@ async function gitPushItemsToGitea({ token, url, owner, repo, branch, items, mes
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 gitConfig = gitIdentityConfig();
const safeBranch = sanitizeGitRef(branch || 'HEAD', 'HEAD');
try {
@@ -2891,7 +3107,10 @@ ipcMain.handle('upload-paths-to-gitea', async (event, data) => {
try { invalidateRepoContentsCache(owner, repo); } catch (_) {}
try { caches.repos.invalidate(`${owner}/${repo}`); } catch (_) {}
const { items, skipped } = buildItemsFromPaths(paths, destPath);
const built = await buildItemsFromPaths(paths, destPath);
const skipped = built.skipped;
const excluded = new Set(Array.isArray(data.excludeFiles) ? data.excludeFiles : []);
const items = excluded.size ? built.items.filter(it => !excluded.has(it.localFile)) : built.items;
if (items.length === 0) {
return { ok: false, error: 'Keine Dateien zum Hochladen gefunden', skipped, debugId: uploadDebugId };
}
@@ -3143,7 +3362,7 @@ ipcMain.handle('delete-gitea-repo', async (event, data) => {
return new Promise((resolve) => {
const options = {
hostname: urlObj.hostname,
hostname: bareHostname(urlObj.hostname),
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
path: deletePath,
method: 'DELETE',
@@ -3278,7 +3497,7 @@ ipcMain.handle('upload-and-push', async (event, data) => {
const cloneArgs = ['clone', '--depth', '1'];
if (branch !== 'HEAD') cloneArgs.push('--branch', branch);
cloneArgs.push(authClone, tmpDir);
runGitSync(cloneArgs, process.cwd(), gitExecOptions);
await runGitAsync(cloneArgs, process.cwd(), gitExecOptions);
// Zielort im Repo bestimmen
let destDirInRepo = tmpDir;
@@ -3293,17 +3512,17 @@ ipcMain.handle('upload-and-push', async (event, data) => {
fs.copyFileSync(data.localFolder, finalFileDest);
// Git Befehle
runGitSync(['-C', tmpDir, 'add', '.'], process.cwd(), gitExecOptions);
try { runGitSync(['-C', tmpDir, 'commit', '-m', `Upload file ${fileName} via GUI`], process.cwd(), gitExecOptions); } catch (_) {}
await runGitAsync(['-C', tmpDir, 'add', '.'], process.cwd(), gitExecOptions);
try { await runGitAsync(['-C', tmpDir, 'commit', '-m', `Upload file ${fileName} via GUI`], process.cwd(), gitExecOptions); } catch (_) {}
let pushBranch = branch;
if (pushBranch === 'HEAD') {
try {
pushBranch = runGitSync(['-C', tmpDir, 'rev-parse', '--abbrev-ref', 'HEAD'], process.cwd(), gitExecOptions).trim();
pushBranch = await runGitAsync(['-C', tmpDir, 'rev-parse', '--abbrev-ref', 'HEAD'], process.cwd(), gitExecOptions);
} catch (_) {
pushBranch = 'main';
}
}
runGitSync(['-C', tmpDir, 'push', 'origin', pushBranch], process.cwd(), gitExecOptions);
await runGitAsync(['-C', tmpDir, 'push', 'origin', pushBranch], process.cwd(), gitExecOptions);
setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5_000);
return { ok: true, usedGit: true, singleFile: true, msg: 'Uploaded via Git (Fallback)', debugId: uploadDebugId };
@@ -3355,7 +3574,7 @@ ipcMain.handle('upload-and-push', async (event, data) => {
const cloneArgs = ['clone', '--depth', '1'];
if (branch !== 'HEAD') cloneArgs.push('--branch', branch);
cloneArgs.push(authClone, tmpDir);
runGitSync(cloneArgs, process.cwd(), gitExecOptions);
await runGitAsync(cloneArgs, process.cwd(), gitExecOptions);
// Zielverzeichnis im Repo: <destPath>/<folderName>/ (wie im API-Weg)
let destDirInRepo = tmpDir;
@@ -3364,14 +3583,20 @@ ipcMain.handle('upload-and-push', async (event, data) => {
ensureDir(destDirInRepo);
// Kompletten Ordner rekursiv kopieren, ausgeschlossene Einträge überspringen
const isIgnoredLocal = await createUploadIgnore(data.localFolder);
fs.cpSync(data.localFolder, destDirInRepo, {
recursive: true,
filter: (src) => !excludeList.includes(ppath.basename(src))
filter: (src) => {
if (excludeList.includes(ppath.basename(src))) return false;
let isDir = false;
try { isDir = fs.statSync(src).isDirectory(); } catch (_) {}
return !isIgnoredLocal(src, isDir);
}
});
runGitSync(['-C', tmpDir, 'add', '.'], process.cwd(), gitExecOptions);
await runGitAsync(['-C', tmpDir, 'add', '.'], process.cwd(), gitExecOptions);
try {
runGitSync(['-C', tmpDir, 'commit', '-m', `Upload ${folderName} via GUI`], process.cwd(), gitExecOptions);
await runGitAsync(['-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;
@@ -3383,11 +3608,11 @@ ipcMain.handle('upload-and-push', async (event, data) => {
let pushBranch = branch;
if (pushBranch === 'HEAD') {
try {
pushBranch = runGitSync(['-C', tmpDir, 'rev-parse', '--abbrev-ref', 'HEAD'], process.cwd(), gitExecOptions).trim();
pushBranch = await runGitAsync(['-C', tmpDir, 'rev-parse', '--abbrev-ref', 'HEAD'], process.cwd(), gitExecOptions);
} catch (_) { pushBranch = 'main'; }
if (!pushBranch || pushBranch === 'HEAD') pushBranch = 'main';
}
runGitSync(['-C', tmpDir, 'push', 'origin', pushBranch], process.cwd(), gitExecOptions);
await runGitAsync(['-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);
@@ -3407,6 +3632,7 @@ ipcMain.handle('upload-and-push', async (event, data) => {
const items = [];
// excludeList und folderName sind oben (FALL 2) bereits definiert
const isIgnoredApi = await createUploadIgnore(data.localFolder);
(function walk(dir) {
const entries = fs.readdirSync(dir);
for (const entry of entries) {
@@ -3417,6 +3643,7 @@ ipcMain.handle('upload-and-push', async (event, data) => {
try {
stat = fs.statSync(full);
} catch(e) { continue; }
if (isIgnoredApi(full, stat.isDirectory())) continue;
if (stat.isDirectory()) {
walk(full);
@@ -3550,7 +3777,7 @@ ipcMain.handle('run-batch-repo-action', async (event, data) => {
}
} catch (_) {}
runGitSync(['clone', '--depth', '1', authCloneUrl, repoDir], process.cwd(), { stdio: 'ignore' });
await runGitAsync(['clone', '--depth', '1', authCloneUrl, repoDir], process.cwd(), { stdio: 'ignore' });
results.push({ repo: item.id, ok: true, message: `Geklont nach ${repoDir}` });
} else if (action === 'create-tag') {
const tag = String(options.tag || '').trim();
@@ -3635,7 +3862,7 @@ ipcMain.handle('rename-gitea-item', async (event, data) => {
return new Promise((resolve, reject) => {
const bodyStr = body ? JSON.stringify(body) : null;
const req = protocol.request({
hostname: urlObj.hostname, port,
hostname: bareHostname(urlObj.hostname), port,
path: apiPath, method,
headers: {
'Authorization': `token ${token}`,
@@ -3743,7 +3970,7 @@ ipcMain.handle('create-gitea-item', async (event, data) => {
return new Promise((resolve) => {
const bodyStr = body ? JSON.stringify(body) : null;
const req = protocol.request({
hostname: urlObj.hostname,
hostname: bareHostname(urlObj.hostname),
port,
path: apiPath,
method,
@@ -4646,7 +4873,7 @@ function isAllowedExternalUrl(rawUrl) {
if (protocol === 'https:' || protocol === 'mailto:') return true;
if (protocol === 'http:') {
const host = String(parsed.hostname || '').toLowerCase();
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
return host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || host === '::1';
}
return false;
} catch (_) {
@@ -4929,12 +5156,8 @@ ipcMain.handle('sync-repo-to-github', async (event, data) => {
: ['clone', '--bare', sourceCloneUrl, mirrorDir];
// runGit: gibt stdout zurück, loggt stderr immer (git push schreibt Status auf stderr)
const runGit = (args, cwd) => {
const res = spawnSync('git', args, {
cwd,
encoding: 'utf8',
windowsHide: true
});
const runGit = async (args, cwd) => {
const res = await execGit(args, { cwd });
const out = (res.stdout || '').trim();
const err = (res.stderr || '').trim();
if (err) console.log(`[sync-repo-to-github][git] ${err}`);
@@ -4945,17 +5168,17 @@ ipcMain.handle('sync-repo-to-github', async (event, data) => {
};
console.log(`[sync-repo-to-github] Klone Quell-Repo (bare): ${sourceCloneUrl}`);
runGit(cloneArgs, process.cwd());
await runGit(cloneArgs, process.cwd());
console.log('[sync-repo-to-github] Bare-Clone abgeschlossen');
// Alle lokalen Branches auflisten
const allBranchesRaw = runGit(['for-each-ref', '--format=%(refname:short)', 'refs/heads/'], mirrorDir);
const allBranchesRaw = await runGit(['for-each-ref', '--format=%(refname:short)', 'refs/heads/'], mirrorDir);
const allBranches = (allBranchesRaw || '').split('\n').map(s => s.trim()).filter(s => s && isSafeGitRef(s));
console.log(`[sync-repo-to-github] Lokale Branches: ${allBranches.join(', ') || '(keine)'}`);
// Default-Branch ermitteln
try {
const headRef = runGit(['symbolic-ref', '--short', 'HEAD'], mirrorDir);
const headRef = await runGit(['symbolic-ref', '--short', 'HEAD'], mirrorDir);
sourceDefaultBranch = String(headRef || '').replace(/^refs\/heads\//, '').trim();
if (!isSafeGitRef(sourceDefaultBranch)) sourceDefaultBranch = '';
} catch (_) {
@@ -4969,19 +5192,19 @@ ipcMain.handle('sync-repo-to-github', async (event, data) => {
const ghAuthArgs = ['-c', 'credential.helper=', '-c', `http.https://github.com/.extraheader=${githubAuthHeader}`];
// GitHub als Push-Remote setzen
runGit(['remote', 'set-url', 'origin', githubRemoteUrl], mirrorDir);
await runGit(['remote', 'set-url', 'origin', githubRemoteUrl], mirrorDir);
// Lokale Commit-SHAs loggen (= Gitea-Stand)
for (const branch of allBranches) {
try {
const localSha = runGit(['rev-parse', `refs/heads/${branch}`], mirrorDir);
const localSha = await runGit(['rev-parse', `refs/heads/${branch}`], mirrorDir);
console.log(`[sync-repo-to-github] Gitea ${branch}: ${localSha}`);
} catch (_) {}
}
// Remote (GitHub) Commit-SHAs VOR dem Push ermitteln und vergleichen
try {
const remoteRefsRaw = runGit([...ghAuthArgs, 'ls-remote', '--heads', 'origin'], mirrorDir);
const remoteRefsRaw = await runGit([...ghAuthArgs, 'ls-remote', '--heads', 'origin'], mirrorDir);
const remoteMap = {};
(remoteRefsRaw || '').split('\n').forEach(line => {
const parts = line.trim().split(/\s+/);
@@ -5003,14 +5226,14 @@ ipcMain.handle('sync-repo-to-github', async (event, data) => {
let pushedCount = 0;
for (const branch of allBranches) {
console.log(`[sync-repo-to-github] Pushe Branch: ${branch}`);
runGit([...ghAuthArgs, 'push', '--force', 'origin', `refs/heads/${branch}:refs/heads/${branch}`], mirrorDir);
await runGit([...ghAuthArgs, 'push', '--force', 'origin', `refs/heads/${branch}:refs/heads/${branch}`], mirrorDir);
pushedCount++;
}
console.log(`[sync-repo-to-github] ${pushedCount} Branch(es) erfolgreich gepusht`);
// Auf GitHub nicht mehr vorhandene Branches aufräumen (prune via API, non-fatal)
try {
const remoteRefsRaw = runGit([...ghAuthArgs, 'ls-remote', '--heads', 'origin'], mirrorDir);
const remoteRefsRaw = await runGit([...ghAuthArgs, 'ls-remote', '--heads', 'origin'], mirrorDir);
const remoteHeads = (remoteRefsRaw || '').split('\n')
.map(l => { const m = l.match(/refs\/heads\/(.+)$/); return m ? m[1].trim() : null; })
.filter(Boolean);
@@ -5018,7 +5241,7 @@ ipcMain.handle('sync-repo-to-github', async (event, data) => {
for (const remoteBranch of remoteHeads) {
if (!localSet.has(remoteBranch) && isSafeGitRef(remoteBranch)) {
console.log(`[sync-repo-to-github] Lösche veralteten Remote-Branch: ${remoteBranch}`);
runGit([...ghAuthArgs, 'push', 'origin', `--delete`, remoteBranch], mirrorDir);
await runGit([...ghAuthArgs, 'push', 'origin', `--delete`, remoteBranch], mirrorDir);
}
}
} catch (pruneErr) {
@@ -5027,7 +5250,7 @@ ipcMain.handle('sync-repo-to-github', async (event, data) => {
// Tags mit Force-Push übertragen (non-fatal)
try {
runGit([...ghAuthArgs, 'push', 'origin', '--tags', '--force'], mirrorDir);
await runGit([...ghAuthArgs, 'push', 'origin', '--tags', '--force'], mirrorDir);
console.log('[sync-repo-to-github] Tags erfolgreich gepusht');
} catch (tagsErr) {
console.warn('[sync-repo-to-github] Tags-Push Warnung:', tagsErr?.message || tagsErr);
@@ -5105,4 +5328,29 @@ ipcMain.handle('test-github-connection', async (_event, data) => {
console.error('test-github-connection error', e);
return { ok: false, error: mapIpcError(e) };
}
});
});
/* -----------------------------
Neue Funktionen: Upload-Vorschau, Diagnose, lokale Änderungen, PRs/Issues, Repo-Backup
----------------------------- */
require('./src/features/featuresIpc.js').registerFeatureIpc({
ipcMain,
app,
readCredentials,
buildItemsFromPaths,
clearGitTransportBlock,
getGitTransportBlocks: () => { loadBrokenGitTransports(); return Object.fromEntries(brokenGitTransports); },
prefs: appPrefs,
openPath: (dir) => shell.openPath(dir),
notify: (title, body) => {
try {
const { Notification } = require('electron');
if (Notification.isSupported()) new Notification({ title, body, icon: ppath.join(__dirname, 'assets', 'icon.png') }).show();
} catch (e) { console.warn('Benachrichtigung:', e.message); }
},
broadcast: (channel, payload) => {
for (const win of BrowserWindow.getAllWindows()) {
try { if (!win.isDestroyed()) win.webContents.send(channel, payload); } catch (_) {}
}
}
});