From fca9d9c66ff304c51779a17005ef268eb1159ff0 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Mon, 2 Feb 2026 17:55:33 +0100 Subject: [PATCH] Update from Git Manager GUI --- src/git/apiHandler.js | 357 +++++++++++++++++++++++++++++++++++++++++- src/git/gitHandler.js | 280 ++++++++++++++++++++++++++++++++- 2 files changed, 635 insertions(+), 2 deletions(-) diff --git a/src/git/apiHandler.js b/src/git/apiHandler.js index 0cfdcde..3693cc5 100644 --- a/src/git/apiHandler.js +++ b/src/git/apiHandler.js @@ -305,11 +305,366 @@ async function uploadGiteaFile({ token, url, owner, repo, path, contentBase64, m } } +/* ================================ + COMMIT HISTORY FUNCTIONS (GITEA) + ================================ */ + +/** + * Get commit history from Gitea repository + */ +async function getGiteaCommits({ token, url, owner, repo, branch = 'main', page = 1, limit = 50 }) { + const base = normalizeBase(url); + if (!base) throw new Error('Invalid Gitea base URL'); + + const endpoint = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits`; + + try { + const response = await axios.get(endpoint, { + headers: { Authorization: `token ${token}` }, + params: { + sha: branch, + page, + limit + } + }); + return response.data; + } catch (err) { + console.error('getGiteaCommits error:', err.response?.data || err.message); + throw err; + } +} + +/** + * Get a specific commit with diff + */ +async function getGiteaCommit({ token, url, owner, repo, sha }) { + const base = normalizeBase(url); + if (!base) throw new Error('Invalid Gitea base URL'); + + const endpoint = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/commits/${sha}`; + + try { + const response = await axios.get(endpoint, { + headers: { Authorization: `token ${token}` } + }); + return response.data; + } catch (err) { + console.error('getGiteaCommit error:', err.response?.data || err.message); + throw err; + } +} + +/** + * Get commit diff/patch + */ +async function getGiteaCommitDiff({ token, url, owner, repo, sha }) { + const base = normalizeBase(url); + if (!base) throw new Error('Invalid Gitea base URL'); + + // Gitea returns diff in the commit endpoint with .diff extension + const endpoint = `${base}/${owner}/${repo}/commit/${sha}.diff`; + + try { + const response = await axios.get(endpoint, { + headers: { Authorization: `token ${token}` } + }); + return response.data; + } catch (err) { + console.error('getGiteaCommitDiff error:', err.response?.data || err.message); + throw err; + } +} + +/** + * Get commit file changes/stats + */ +async function getGiteaCommitFiles({ token, url, owner, repo, sha }) { + const base = normalizeBase(url); + if (!base) throw new Error('Invalid Gitea base URL'); + + const endpoint = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/commits/${sha}`; + + try { + const response = await axios.get(endpoint, { + headers: { Authorization: `token ${token}` } + }); + + // Gitea commit response includes stats + const commit = response.data; + + // Normalize files to match local git format + const files = (commit.files || []).map(f => ({ + file: f.filename || f.file || '', + changes: (f.additions || 0) + (f.deletions || 0), + insertions: f.additions || 0, + deletions: f.deletions || 0, + binary: f.binary || false + })); + + return { + files, + stats: commit.stats || { additions: 0, deletions: 0, total: 0 } + }; + } catch (err) { + console.error('getGiteaCommitFiles error:', err.response?.data || err.message); + throw err; + } +} + +/** + * Search commits in Gitea repository + */ +async function searchGiteaCommits({ token, url, owner, repo, query, branch = 'main' }) { + const base = normalizeBase(url); + if (!base) throw new Error('Invalid Gitea base URL'); + + // Gitea doesn't have direct commit search, so we get commits and filter + const endpoint = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits`; + + try { + const response = await axios.get(endpoint, { + headers: { Authorization: `token ${token}` }, + params: { + sha: branch, + limit: 100 // Get more for searching + } + }); + + // Filter commits by query + const lowerQuery = query.toLowerCase(); + const filtered = response.data.filter(commit => { + const message = (commit.commit?.message || '').toLowerCase(); + const author = (commit.commit?.author?.name || '').toLowerCase(); + return message.includes(lowerQuery) || author.includes(lowerQuery); + }); + + return filtered; + } catch (err) { + console.error('searchGiteaCommits error:', err.response?.data || err.message); + throw err; + } +} + +/** + * Get branches for branch graph visualization + */ +async function getGiteaBranches({ token, url, owner, repo }) { + const base = normalizeBase(url); + if (!base) throw new Error('Invalid Gitea base URL'); + + const endpoint = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches`; + + try { + const response = await axios.get(endpoint, { + headers: { Authorization: `token ${token}` } + }); + return response.data; + } catch (err) { + console.error('getGiteaBranches error:', err.response?.data || err.message); + throw err; + } +} + +/* ================================ + RELEASE MANAGEMENT FUNCTIONS + ================================ */ + +/** + * List all releases for a repository + */ +async function listGiteaReleases({ token, url, owner, repo }) { + const base = normalizeBase(url); + if (!base) throw new Error('Invalid Gitea base URL'); + + const endpoint = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases`; + + try { + const response = await axios.get(endpoint, { + headers: { Authorization: `token ${token}` } + }); + return response.data; + } catch (err) { + console.error('listGiteaReleases error:', err.response?.data || err.message); + throw err; + } +} + +/** + * Get a specific release by tag + */ +async function getGiteaRelease({ token, url, owner, repo, tag }) { + const base = normalizeBase(url); + if (!base) throw new Error('Invalid Gitea base URL'); + + const endpoint = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`; + + try { + const response = await axios.get(endpoint, { + headers: { Authorization: `token ${token}` } + }); + return response.data; + } catch (err) { + console.error('getGiteaRelease error:', err.response?.data || err.message); + throw err; + } +} + +/** + * Create a new release + */ +async function createGiteaRelease({ token, url, owner, repo, data }) { + const base = normalizeBase(url); + if (!base) throw new Error('Invalid Gitea base URL'); + + const endpoint = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases`; + + const body = { + tag_name: data.tag_name, + name: data.name || data.tag_name, + body: data.body || '', + draft: data.draft || false, + prerelease: data.prerelease || false, + target_commitish: data.target_commitish || 'main' + }; + + try { + const response = await axios.post(endpoint, body, { + headers: { + Authorization: `token ${token}`, + 'Content-Type': 'application/json' + } + }); + return response.data; + } catch (err) { + console.error('createGiteaRelease error:', err.response?.data || err.message); + throw err; + } +} + +/** + * Edit/update an existing release + */ +async function editGiteaRelease({ token, url, owner, repo, releaseId, data }) { + const base = normalizeBase(url); + if (!base) throw new Error('Invalid Gitea base URL'); + + const endpoint = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${releaseId}`; + + const body = {}; + if (data.name !== undefined) body.name = data.name; + if (data.body !== undefined) body.body = data.body; + if (data.draft !== undefined) body.draft = data.draft; + if (data.prerelease !== undefined) body.prerelease = data.prerelease; + if (data.tag_name !== undefined) body.tag_name = data.tag_name; + + try { + const response = await axios.patch(endpoint, body, { + headers: { + Authorization: `token ${token}`, + 'Content-Type': 'application/json' + } + }); + return response.data; + } catch (err) { + console.error('editGiteaRelease error:', err.response?.data || err.message); + throw err; + } +} + +/** + * Delete a release + */ +async function deleteGiteaRelease({ token, url, owner, repo, releaseId }) { + const base = normalizeBase(url); + if (!base) throw new Error('Invalid Gitea base URL'); + + const endpoint = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${releaseId}`; + + try { + await axios.delete(endpoint, { + headers: { Authorization: `token ${token}` } + }); + return { ok: true }; + } catch (err) { + console.error('deleteGiteaRelease error:', err.response?.data || err.message); + throw err; + } +} + +/** + * Upload a release asset (attachment) + * Note: Gitea uses multipart/form-data for asset uploads + */ +async function uploadReleaseAsset({ token, url, owner, repo, releaseId, filePath, fileName }) { + const base = normalizeBase(url); + if (!base) throw new Error('Invalid Gitea base URL'); + + const FormData = require('form-data'); + const fs = require('fs'); + + const endpoint = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${releaseId}/assets`; + + const formData = new FormData(); + formData.append('attachment', fs.createReadStream(filePath), { + filename: fileName || require('path').basename(filePath) + }); + + try { + const response = await axios.post(endpoint, formData, { + headers: { + Authorization: `token ${token}`, + ...formData.getHeaders() + }, + maxContentLength: Infinity, + maxBodyLength: Infinity + }); + return response.data; + } catch (err) { + console.error('uploadReleaseAsset error:', err.response?.data || err.message); + throw err; + } +} + +/** + * Delete a release asset + */ +async function deleteReleaseAsset({ token, url, owner, repo, assetId }) { + const base = normalizeBase(url); + if (!base) throw new Error('Invalid Gitea base URL'); + + const endpoint = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/assets/${assetId}`; + + try { + await axios.delete(endpoint, { + headers: { Authorization: `token ${token}` } + }); + return { ok: true }; + } catch (err) { + console.error('deleteReleaseAsset error:', err.response?.data || err.message); + throw err; + } +} + module.exports = { createRepoGitHub, createRepoGitea, listGiteaRepos, getGiteaRepoContents, getGiteaFileContent, - uploadGiteaFile + uploadGiteaFile, + // Commit History + getGiteaCommits, + getGiteaCommit, + getGiteaCommitDiff, + getGiteaCommitFiles, + searchGiteaCommits, + getGiteaBranches, + // Release Management + listGiteaReleases, + getGiteaRelease, + createGiteaRelease, + editGiteaRelease, + deleteGiteaRelease, + uploadReleaseAsset, + deleteReleaseAsset }; \ No newline at end of file diff --git a/src/git/gitHandler.js b/src/git/gitHandler.js index 9f236ea..2d32437 100644 --- a/src/git/gitHandler.js +++ b/src/git/gitHandler.js @@ -54,4 +54,282 @@ async function getCommitLogs(folderPath, count = 50) { return log.all.map(c => `${c.hash.substring(0,7)} - ${c.message}`); } -module.exports = { initRepo, commitAndPush, getBranches, getCommitLogs }; +/* ================================ + EXTENDED COMMIT HISTORY FUNCTIONS + ================================ */ + +/** + * Get detailed commit history with full information + */ +async function getDetailedCommitHistory(folderPath, options = {}) { + const git = gitFor(folderPath); + + const logOptions = { + maxCount: options.limit || 100, + file: options.file || undefined + }; + + if (options.author) { + logOptions['--author'] = options.author; + } + + if (options.since) { + logOptions['--since'] = options.since; + } + + if (options.until) { + logOptions['--until'] = options.until; + } + + if (options.grep) { + logOptions['--grep'] = options.grep; + } + + const log = await git.log(logOptions); + + return log.all.map(commit => ({ + hash: commit.hash, + shortHash: commit.hash.substring(0, 7), + author: commit.author_name, + authorEmail: commit.author_email, + date: commit.date, + message: commit.message, + body: commit.body, + refs: commit.refs || '', + parentHashes: commit.parent || [] + })); +} + +/** + * Get commit diff for a specific commit + */ +async function getCommitDiff(folderPath, commitHash) { + const git = gitFor(folderPath); + + try { + // Get diff from parent to this commit + const diff = await git.diff([`${commitHash}~1`, commitHash]); + return diff; + } catch (error) { + // If no parent (first commit), show diff from empty tree + try { + const diff = await git.diff(['--root', commitHash]); + return diff; + } catch (err) { + console.error('Error getting diff:', err); + return ''; + } + } +} + +/** + * Get file changes for a specific commit + */ +async function getCommitFileChanges(folderPath, commitHash) { + const git = gitFor(folderPath); + + try { + const diff = await git.diffSummary([`${commitHash}~1`, commitHash]); + + return { + files: diff.files.map(file => ({ + file: file.file, + changes: file.changes, + insertions: file.insertions, + deletions: file.deletions, + binary: file.binary || false + })), + insertions: diff.insertions, + deletions: diff.deletions, + changed: diff.changed + }; + } catch (error) { + // First commit case + try { + const diff = await git.diffSummary(['--root', commitHash]); + return { + files: diff.files.map(file => ({ + file: file.file, + changes: file.changes, + insertions: file.insertions, + deletions: file.deletions, + binary: file.binary || false + })), + insertions: diff.insertions, + deletions: diff.deletions, + changed: diff.changed + }; + } catch (err) { + console.error('Error getting file changes:', err); + return { files: [], insertions: 0, deletions: 0, changed: 0 }; + } + } +} + +/** + * Get detailed commit info including stats + */ +async function getCommitDetails(folderPath, commitHash) { + const git = gitFor(folderPath); + + try { + const log = await git.log({ from: commitHash, to: commitHash, maxCount: 1 }); + + if (log.all.length === 0) { + throw new Error('Commit not found'); + } + + const commit = log.all[0]; + const fileChanges = await getCommitFileChanges(folderPath, commitHash); + const diff = await getCommitDiff(folderPath, commitHash); + + return { + hash: commit.hash, + shortHash: commit.hash.substring(0, 7), + author: commit.author_name, + authorEmail: commit.author_email, + date: commit.date, + message: commit.message, + body: commit.body, + refs: commit.refs || '', + parentHashes: commit.parent || [], + fileChanges, + diff + }; + } catch (error) { + console.error('Error getting commit details:', error); + throw error; + } +} + +/** + * Get branch graph data for visualization + */ +async function getBranchGraph(folderPath, limit = 50) { + const git = gitFor(folderPath); + + try { + // Get all branches + const branches = await git.branchLocal(); + const allBranches = branches.all; + + // Get graph structure + const log = await git.log({ + maxCount: limit, + '--all': null, + '--graph': null, + '--decorate': null, + '--oneline': null + }); + + return { + branches: allBranches, + current: branches.current, + commits: log.all + }; + } catch (error) { + console.error('Error getting branch graph:', error); + return { branches: [], current: '', commits: [] }; + } +} + +/** + * Search commits by message, author, or content + */ +async function searchCommits(folderPath, query, options = {}) { + const git = gitFor(folderPath); + + const logOptions = { + maxCount: options.limit || 100, + '--all': null + }; + + // Search in commit message + if (options.searchMessage !== false) { + logOptions['--grep'] = query; + } + + // Search by author + if (options.author) { + logOptions['--author'] = options.author; + } + + // Case insensitive + if (options.caseInsensitive !== false) { + logOptions['--regexp-ignore-case'] = null; + } + + try { + const log = await git.log(logOptions); + + return log.all.map(commit => ({ + hash: commit.hash, + shortHash: commit.hash.substring(0, 7), + author: commit.author_name, + authorEmail: commit.author_email, + date: commit.date, + message: commit.message, + body: commit.body, + refs: commit.refs || '' + })); + } catch (error) { + console.error('Error searching commits:', error); + return []; + } +} + +/** + * Get commit statistics + */ +async function getCommitStats(folderPath, since = '1 month ago') { + const git = gitFor(folderPath); + + try { + const log = await git.log({ + '--since': since, + '--all': null + }); + + const authorStats = {}; + const dailyStats = {}; + + log.all.forEach(commit => { + // Author stats + const author = commit.author_name; + if (!authorStats[author]) { + authorStats[author] = { commits: 0, email: commit.author_email }; + } + authorStats[author].commits++; + + // Daily stats + const date = new Date(commit.date).toISOString().split('T')[0]; + if (!dailyStats[date]) { + dailyStats[date] = 0; + } + dailyStats[date]++; + }); + + return { + totalCommits: log.all.length, + authors: authorStats, + daily: dailyStats + }; + } catch (error) { + console.error('Error getting commit stats:', error); + return { totalCommits: 0, authors: {}, daily: {} }; + } +} + +module.exports = { + initRepo, + commitAndPush, + getBranches, + getCommitLogs, + getDetailedCommitHistory, + getCommitDiff, + getCommitFileChanges, + getCommitDetails, + getBranchGraph, + searchCommits, + getCommitStats +}; \ No newline at end of file