Release ForgeFlow 0.5.2

This commit is contained in:
NuklearRabbit
2026-07-25 01:07:54 +02:00
parent 602309b203
commit cf1f67a823
23 changed files with 550 additions and 72 deletions
+49 -3
View File
@@ -57,6 +57,50 @@ class GitService {
return result.stdout.trim();
}
pathspecInput(paths) {
const selected = assertRepositoryRelativePaths(paths);
return selected.length ? `${selected.join('\0')}\0` : '';
}
async runWithPathspec(root, args, paths, options = {}) {
const selected = assertRepositoryRelativePaths(paths);
if (!selected.length) return run('git', args, { cwd: root, ...options });
return run('git', [...args, '--pathspec-from-file=-', '--pathspec-file-nul'], {
cwd: root,
input: this.pathspecInput(selected),
...options
});
}
async getIndexLockInfo(repoPath) {
const root = await this.ensureRepository(repoPath);
const lockPath = path.join(root, '.git', 'index.lock');
const stat = await fs.stat(lockPath).catch(() => null);
return stat ? { exists: true, lockPath, ageMs: Math.max(0, Date.now() - stat.mtimeMs) } : { exists: false, lockPath, ageMs: 0 };
}
async removeStaleIndexLock(repoPath, minimumAgeMs = 30_000) {
const info = await this.getIndexLockInfo(repoPath);
if (!info.exists) return { removed: false, reason: 'missing', ...info };
if (info.ageMs < minimumAgeMs) {
const error = new Error('The Git index lock is recent. Close other Git tools and try again before removing it.');
error.code = 'INDEX_LOCK_RECENT';
throw error;
}
await fs.rm(info.lockPath, { force: true });
return { removed: true, ...info };
}
async setRemoteUrl(repoPath, remoteUrl, remote = 'origin') {
const root = await this.ensureRepository(repoPath);
const safeRemote = assertCloneRemote(remoteUrl);
const name = String(remote || 'origin').trim();
if (!/^[A-Za-z0-9._-]+$/.test(name)) throw new Error('Invalid Git remote name.');
await run('git', ['remote', 'set-url', name, safeRemote], { cwd: root, timeout: 30_000 });
return this.status(root);
}
async diff(repoPath, filePath, staged = false) {
const root = await this.ensureRepository(repoPath);
const safeFile = filePath ? assertRepositoryRelativePath(filePath) : '';
@@ -109,7 +153,7 @@ class GitService {
// renames are already ready for commit and must therefore be left alone.
const selected = await this.expandSelectedPaths(root, requested, { unstagedOnly: true });
if (selected.length) {
await run('git', ['add', '-A', '--', ...selected], { cwd: root, timeout: 60_000 });
await this.runWithPathspec(root, ['add', '-A'], selected, { timeout: 120_000 });
}
return this.status(root);
}
@@ -119,9 +163,11 @@ class GitService {
const selected = await this.expandSelectedPaths(root, files);
const hasHead = await run('git', ['rev-parse', '--verify', 'HEAD'], { cwd: root, allowExitCodes: [128] });
if (hasHead.exitCode === 0) {
await run('git', selected.length ? ['restore', '--staged', '--', ...selected] : ['restore', '--staged', '.'], { cwd: root });
if (selected.length) await this.runWithPathspec(root, ['restore', '--staged'], selected, { timeout: 120_000 });
else await run('git', ['restore', '--staged', '.'], { cwd: root });
} else {
await run('git', selected.length ? ['rm', '--cached', '--ignore-unmatch', '--', ...selected] : ['rm', '--cached', '-r', '.'], { cwd: root, allowExitCodes: [1] });
if (selected.length) await this.runWithPathspec(root, ['rm', '--cached', '--ignore-unmatch'], selected, { timeout: 120_000, allowExitCodes: [1] });
else await run('git', ['rm', '--cached', '-r', '.'], { cwd: root, allowExitCodes: [1] });
}
return this.status(root);
}
+39 -11
View File
@@ -55,11 +55,20 @@ function register(channel, handler) {
function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh, updates, preflight, diagnostics, monitor }) {
diagnosticsService = diagnostics;
const repositoryMutations = new Map();
const withRepositoryPause = async (localPath, action) => {
monitor?.pause(localPath);
try { return await action(); }
finally { monitor?.resume(localPath); }
};
const withRepositoryMutation = async (localPath, action) => {
const key = path.resolve(localPath);
const previous = repositoryMutations.get(key) || Promise.resolve();
const current = previous.catch(() => {}).then(() => withRepositoryPause(key, action));
repositoryMutations.set(key, current);
try { return await current; }
finally { if (repositoryMutations.get(key) === current) repositoryMutations.delete(key); }
};
const canonicalPath = async (value) => {
const resolved = path.resolve(String(value || ''));
@@ -262,20 +271,39 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
register('repository:status', async ({ localPath }) => git.status(await assertKnownRepositoryPath(localPath)));
register('repository:diff', async ({ localPath, filePath, staged }) => git.diff(await assertKnownRepositoryPath(localPath), filePath, staged));
register('repository:stage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.stage(safePath, files)); });
register('repository:unstage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.unstage(safePath, files)); });
register('repository:commit', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.commit(safePath, message, files)); });
register('repository:commit-push', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.commitAndPush(safePath, message, files)); });
register('repository:push', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.push(safePath)); });
register('repository:fetch', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.fetch(safePath)); });
register('repository:pull', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.pullFastForward(safePath)); });
register('repository:stage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.stage(safePath, files)); });
register('repository:unstage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.unstage(safePath, files)); });
register('repository:commit', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.commit(safePath, message, files)); });
register('repository:commit-push', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.commitAndPush(safePath, message, files)); });
register('repository:push', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.push(safePath)); });
register('repository:fetch', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.fetch(safePath)); });
register('repository:pull', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.pullFastForward(safePath)); });
register('repository:history', async ({ localPath, limit }) => git.history(await assertKnownRepositoryPath(localPath), limit));
register('repository:branches', async ({ localPath }) => git.branches(await assertKnownRepositoryPath(localPath)));
register('repository:checkout-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.checkoutBranch(safePath, branch)); });
register('repository:create-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.createBranch(safePath, branch)); });
register('repository:stash', async ({ localPath, message }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.stash(safePath, message)); });
register('repository:checkout-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.checkoutBranch(safePath, branch)); });
register('repository:create-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.createBranch(safePath, branch)); });
register('repository:stash', async ({ localPath, message }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.stash(safePath, message)); });
register('repository:stash-list', async ({ localPath }) => git.stashList(await assertKnownRepositoryPath(localPath)));
register('repository:stash-pop', async ({ localPath, ref }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.popStash(safePath, ref)); });
register('repository:stash-pop', async ({ localPath, ref }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.popStash(safePath, ref)); });
register('repository:index-lock', async ({ localPath }) => git.getIndexLockInfo(await assertKnownRepositoryPath(localPath)));
register('repository:repair-index-lock', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.removeStaleIndexLock(safePath)); });
register('repository:set-origin', async ({ localPath, remoteUrl }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.setRemoteUrl(safePath, remoteUrl)); });
register('repositories:normalize-origins', async () => {
const current = await repositories.refresh();
const changes = [];
for (const repository of current) {
if (!repository.localPath || !repository.sshUrl) continue;
const actual = await git.getRemoteUrl(repository.localPath).catch(() => '');
if (actual === repository.sshUrl) continue;
await withRepositoryMutation(repository.localPath, () => git.setRemoteUrl(repository.localPath, repository.sshUrl));
changes.push({ fullName: repository.fullName, previous: actual, next: repository.sshUrl });
}
const refreshed = await repositories.refresh();
monitor?.setPaths(repositories.getWatchPaths());
await diagnostics.info('repositories.origins.normalized', { count: changes.length, changes });
return { changes, repositories: refreshed };
});
register('repository:clone', async ({ fullName, mode = 'default' }) => {
if (!['default', 'custom'].includes(mode)) throw new Error('Unsupported clone location mode.');
+15 -2
View File
@@ -8,11 +8,12 @@ function run(command, args = [], options = {}) {
timeout = 60_000,
maxBuffer = 8 * 1024 * 1024,
env,
input = null,
allowExitCodes = []
} = options;
return new Promise((resolve, reject) => {
execFile(command, args, {
const child = execFile(command, args, {
cwd,
timeout,
maxBuffer,
@@ -21,8 +22,16 @@ function run(command, args = [], options = {}) {
env: { ...process.env, ...(env || {}) }
}, (error, stdout, stderr) => {
if (error && !allowExitCodes.includes(error.code)) {
const wrapped = new Error((stderr || stdout || error.message).trim());
const message = (stderr || stdout || error.message).trim();
const wrapped = new Error(message);
wrapped.code = error.code;
if (/\.git[\\/]index\.lock[\s\S]*File exists/i.test(message) || /Unable to create .*index\.lock/i.test(message)) {
wrapped.code = 'GIT_INDEX_LOCKED';
wrapped.recoverable = true;
} else if (error.code === 'ENAMETOOLONG') {
wrapped.code = 'GIT_ARGUMENT_LIST_TOO_LONG';
wrapped.recoverable = true;
}
wrapped.stdout = stdout;
wrapped.stderr = stderr;
wrapped.command = `${command} ${args.join(' ')}`;
@@ -31,6 +40,10 @@ function run(command, args = [], options = {}) {
}
resolve({ stdout: stdout || '', stderr: stderr || '', exitCode: error?.code || 0 });
});
if (input !== null && input !== undefined) {
child.stdin.on('error', () => {});
child.stdin.end(input);
}
});
}