Release ForgeFlow 0.5.2
This commit is contained in:
@@ -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
@@ -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.');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user