Release ForgeFlow 0.8.1

Add advanced Git and deployment workflows, secure backups and auditing, live Gitea integration, desktop notifications, connection validation, and the premium responsive UX refresh.
This commit is contained in:
NuklearRabbit
2026-07-26 00:42:17 +02:00
parent 971896a1d5
commit 4ad698c4eb
47 changed files with 9114 additions and 1648 deletions
+117 -1
View File
@@ -16,6 +16,18 @@ const {
assertCloneRemote
} = require('../shared/validation.cjs');
function parseUnifiedDiff(diffText) {
const text = String(diffText || '').replace(/\r\n/g, '\n');
const firstHunk = text.search(/^@@ /m);
if (firstHunk < 0) return { header: text, hunks: [] };
const header = text.slice(0, firstHunk);
const hunks = text.slice(firstHunk).split(/(?=^@@ )/m).filter(Boolean).map((patch, index) => {
const heading = patch.split('\n', 1)[0];
return { index, heading, patch, additions: (patch.match(/^\+(?!\+\+)/gm) || []).length, deletions: (patch.match(/^-(?!---)/gm) || []).length };
});
return { header, hunks };
}
class GitService {
async isAvailable() {
try {
@@ -195,6 +207,40 @@ class GitService {
};
}
async abortInterruptedOperation(repoPath) {
const root = await this.ensureRepository(repoPath);
const gitDirResult = await run('git', ['rev-parse', '--git-dir'], { cwd: root, timeout: 30_000 });
const gitDir = path.resolve(root, gitDirResult.stdout.trim());
const exists = async (name) => fs.access(path.join(gitDir, name)).then(() => true).catch(() => false);
let aborted = null;
if (await exists('rebase-merge') || await exists('rebase-apply')) {
await run('git', ['rebase', '--abort'], { cwd: root, timeout: 120_000 });
aborted = 'rebase';
} else if (await exists('MERGE_HEAD')) {
await run('git', ['merge', '--abort'], { cwd: root, timeout: 120_000 });
aborted = 'merge';
} else if (await exists('CHERRY_PICK_HEAD')) {
await run('git', ['cherry-pick', '--abort'], { cwd: root, timeout: 120_000 });
aborted = 'cherry-pick';
} else if (await exists('REVERT_HEAD')) {
await run('git', ['revert', '--abort'], { cwd: root, timeout: 120_000 });
aborted = 'revert';
}
return { aborted, status: await this.status(root), lockReport: await this.listGitLocks(root) };
}
async detectInterruptedOperation(repoPath) {
const root = await this.ensureRepository(repoPath);
const gitDirResult = await run('git', ['rev-parse', '--git-dir'], { cwd: root, timeout: 30_000 });
const gitDir = path.resolve(root, gitDirResult.stdout.trim());
const exists = async (name) => fs.access(path.join(gitDir, name)).then(() => true).catch(() => false);
if (await exists('rebase-merge') || await exists('rebase-apply')) return 'rebase';
if (await exists('MERGE_HEAD')) return 'merge';
if (await exists('CHERRY_PICK_HEAD')) return 'cherry-pick';
if (await exists('REVERT_HEAD')) return 'revert';
return null;
}
async repairSync(repoPath, strategy) {
const root = await this.ensureRepository(repoPath);
const requested = String(strategy || '').trim();
@@ -252,6 +298,54 @@ class GitService {
return result.stdout;
}
async diffHunks(repoPath, filePath) {
const safeFile = assertRepositoryRelativePath(filePath);
const diff = await this.diff(repoPath, safeFile, false);
const parsed = parseUnifiedDiff(diff);
return { filePath: safeFile, partialSupported: parsed.hunks.length > 0, hunks: parsed.hunks.map(({ patch, ...hunk }) => ({ ...hunk, lines: patch.split('\n') })) };
}
async stageHunks(repoPath, filePath, hunkIndexes) {
const root = await this.ensureRepository(repoPath);
const safeFile = assertRepositoryRelativePath(filePath);
const indexes = [...new Set((Array.isArray(hunkIndexes) ? hunkIndexes : []).map(Number))];
if (!indexes.length || indexes.some((index) => !Number.isInteger(index) || index < 0)) throw new Error('Select at least one valid diff hunk.');
const parsed = parseUnifiedDiff(await this.diff(root, safeFile, false));
if (!parsed.hunks.length) throw new Error('Partial staging is unavailable for this file. Stage the complete file instead.');
if (indexes.some((index) => index >= parsed.hunks.length)) throw new Error('The file changed after its diff was loaded. Refresh the diff and try again.');
const patch = `${parsed.header}${indexes.map((index) => parsed.hunks[index].patch).join('')}`;
await run('git', ['apply', '--cached', '--whitespace=nowarn', '-'], { cwd: root, input: patch, timeout: 60_000, maxBuffer: 16 * 1024 * 1024 });
return this.status(root);
}
async conflictState(repoPath) {
const root = await this.ensureRepository(repoPath);
const operation = await this.detectInterruptedOperation(root);
const result = await run('git', ['diff', '--name-only', '--diff-filter=U', '-z'], { cwd: root, timeout: 30_000 });
const files = result.stdout.split('\0').filter(Boolean).map(assertRepositoryRelativePath);
return { operation, files, canContinue: Boolean(operation) && files.length === 0, status: await this.status(root) };
}
async resolveConflict(repoPath, filePath, resolution) {
const root = await this.ensureRepository(repoPath);
const safeFile = assertRepositoryRelativePath(filePath);
const choice = String(resolution || 'resolved');
if (!['ours', 'theirs', 'resolved'].includes(choice)) throw new Error('Unsupported conflict resolution choice.');
if (choice !== 'resolved') await this.runWithPathspec(root, ['checkout', `--${choice}`], [safeFile], { timeout: 30_000 });
await this.runWithPathspec(root, ['add'], [safeFile], { timeout: 30_000 });
return this.conflictState(root);
}
async continueInterruptedOperation(repoPath) {
const root = await this.ensureRepository(repoPath);
const state = await this.conflictState(root);
if (!state.operation) throw new Error('No interrupted Git operation is active.');
if (state.files.length) throw new Error('Resolve every conflicted file before continuing.');
const commands = { rebase: ['rebase', '--continue'], merge: ['merge', '--continue'], 'cherry-pick': ['cherry-pick', '--continue'], revert: ['revert', '--continue'] };
await run('git', commands[state.operation], { cwd: root, env: { GIT_EDITOR: 'true' }, timeout: 120_000 });
return this.conflictState(root);
}
selectedStatusFiles(status, files) {
const selected = assertRepositoryRelativePaths(files);
if (!selected.length) return { selected, matches: status.files };
@@ -334,6 +428,28 @@ class GitService {
return { output: result.stdout.trim(), sha: status.head, shortSha: status.shortHead, status };
}
async commitStaged(repoPath, message) {
const root = await this.ensureRepository(repoPath);
const commitMessage = assertCommitMessage(message);
const stagedCheck = await run('git', ['diff', '--cached', '--quiet'], { cwd: root, allowExitCodes: [1] });
if (stagedCheck.exitCode === 0) throw new Error('There are no staged changes to commit.');
const result = await run('git', ['commit', '-m', commitMessage], { cwd: root, timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
const status = await this.status(root);
return { output: result.stdout.trim(), sha: status.head, shortSha: status.shortHead, status };
}
async commitStagedAndPush(repoPath, message) {
const committed = await this.commitStaged(repoPath, message);
try {
const pushed = await this.push(repoPath);
return { commitOutput: committed.output, pushOutput: pushed.output, status: pushed.status, sha: committed.sha };
} catch (error) {
const wrapped = new Error(`Commit ${committed.shortSha} was created locally, but push failed: ${error.message}`);
wrapped.code = 'PUSH_AFTER_COMMIT_FAILED'; wrapped.commitSha = committed.sha; wrapped.recoverable = true;
throw wrapped;
}
}
async commitAndPush(repoPath, message, files = []) {
const committed = await this.commit(repoPath, message, files);
try {
@@ -512,4 +628,4 @@ class GitService {
}
}
module.exports = { GitService };
module.exports = { GitService, parseUnifiedDiff };