Three handlers referenced a dependency they were never given, which made them throw a ReferenceError as soon as they ran: - deployment:preflight for Gitea Actions profiles (`preflight` was passed to registerOperationsIpc but not to registerDeploymentIpc) - Unraid write-access repair (`safeRelativeRemoteFile` was missing from createUnraidAccessMethods) - a dead reference of the same name in unraid-state-methods no-undef and no-unused-vars were disabled for every file, which is why none of these were caught. Both are now enabled for src/main and src/shared, where the dependency graph is explicit. The renderer keeps them off because its functions are deliberately cross-script globals. Performance: - git.status() spawned three processes (rev-parse, status, remote get-url) per call. A directory holding its own .git is by definition the work tree root, so rev-parse is unnecessary, and the remote URL is cached against the mtime of .git/config, including the failure for a repository without that remote. - git status runs with --no-optional-locks so a read no longer rewrites the index. That stops it fighting a concurrent Git command for the index lock, and is what makes filesystem watching viable at all. - One commit issued four `git status` reads; callers that already hold the status now pass it on, leaving two. - The repository monitor is event driven. A watched repository is read on filesystem activity, with a 30s safety net for watchers that stop delivering and a 1s floor so a busy tree cannot drive a read per event. Repositories that cannot be watched keep using the interval. Idle cost for one repository over 35s: 24 git processes before, 3 after. - Resolving one repository by name no longer refreshes the whole workspace. - Concurrent configuration saves share a single write of the latest state. - Repository discovery follows directory junctions again. The filter that skipped them made the realpath cycle guard dead code, and hid any project folder reached through a junction. Renderer: - render() replaced the whole shell on every poll, discarding focus, caret and scroll position while the user was typing. Those are preserved now, and an unchanged render leaves the DOM alone entirely. - The four sections that enhanceRenderedUi() injected after render moved into the views, so the rendered markup is the single source of truth. - The monitor no longer keeps a repository paused forever when it is unlinked mid-mutation, scheduleAutoRefresh honours its delay argument, the demo bridges no longer block startup, and #app is no longer an aria-live region announcing the entire UI on every render. IPC channel plumbing moved to src/main/ipc/channel.cjs, replacing a module-level mutable diagnostics singleton with an argument. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
291 lines
13 KiB
JavaScript
291 lines
13 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtemp, mkdir, symlink } from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import repositoryModule from '../src/main/repository-service.cjs';
|
|
|
|
const { RepositoryService } = repositoryModule;
|
|
|
|
function status(head = 'a'.repeat(40)) {
|
|
return {
|
|
head,
|
|
shortHead: head.slice(0, 7),
|
|
clean: true,
|
|
counts: { changed: 0, conflicts: 0 },
|
|
branch: { head: 'main', upstream: 'origin/main', ahead: 0, behind: 0 }
|
|
};
|
|
}
|
|
|
|
const remote = {
|
|
id: 1,
|
|
name: 'Portfolio',
|
|
full_name: 'Jens/Portfolio',
|
|
owner: { login: 'Jens' },
|
|
private: true,
|
|
default_branch: 'main',
|
|
html_url: 'https://gitea.example/Jens/Portfolio',
|
|
clone_url: 'https://gitea.example/Jens/Portfolio.git',
|
|
ssh_url: 'git@gitea.example:Jens/Portfolio.git'
|
|
};
|
|
|
|
function service() {
|
|
return new RepositoryService({ data: { preferences: { preferredCloneProtocol: 'ssh' }, favorites: [] } }, {}, {});
|
|
}
|
|
|
|
test('a synchronized commit is deployable when the server is unknown or older', () => {
|
|
const current = status();
|
|
const unknown = service().decorate(remote, { localPath: 'C:/Projects/Portfolio', status: current }, [
|
|
{ id: 'prod', branch: 'main', state: { liveSha: null, healthy: null } }
|
|
]);
|
|
assert.equal(unknown.readyToDeploy, true);
|
|
|
|
const older = service().decorate(remote, { localPath: 'C:/Projects/Portfolio', status: current }, [
|
|
{ id: 'prod', branch: 'main', state: { liveSha: 'b'.repeat(40), healthy: true } }
|
|
]);
|
|
assert.equal(older.readyToDeploy, true);
|
|
});
|
|
|
|
test('a healthy commit already live on the server is not offered for deployment again', () => {
|
|
const current = status();
|
|
const repository = service().decorate(remote, { localPath: 'C:/Projects/Portfolio', status: current }, [
|
|
{ id: 'prod', branch: 'main', state: { liveSha: current.head, healthy: true } }
|
|
]);
|
|
assert.equal(repository.readyToDeploy, false);
|
|
});
|
|
|
|
test('an unhealthy live commit remains eligible for a controlled redeploy', () => {
|
|
const current = status();
|
|
const repository = service().decorate(remote, { localPath: 'C:/Projects/Portfolio', status: current }, [
|
|
{ id: 'prod', branch: 'main', state: { liveSha: current.head, healthy: false } }
|
|
]);
|
|
assert.equal(repository.readyToDeploy, true);
|
|
});
|
|
|
|
test('repository discovery is bounded, skips generated trees and ignores inaccessible roots', async (context) => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-repositories-'));
|
|
context.after(() => import('node:fs/promises').then(({ rm }) => rm(root, { recursive: true, force: true })));
|
|
await mkdir(path.join(root, 'group', 'app', '.git'), { recursive: true });
|
|
await mkdir(path.join(root, 'node_modules', 'ignored', '.git'), { recursive: true });
|
|
await mkdir(path.join(root, 'too', 'deep', 'repository', '.git'), { recursive: true });
|
|
try { await symlink(path.join(root, 'group'), path.join(root, 'linked'), 'junction'); } catch {}
|
|
|
|
const instance = service();
|
|
const found = await instance.discoverInRoot(root, 2);
|
|
assert.deepEqual(found, [await import('node:fs/promises').then(({ realpath }) => realpath(path.join(root, 'group', 'app')))]);
|
|
assert.deepEqual(await instance.discoverInRoot(path.join(root, 'missing')), []);
|
|
const all = await instance.discoverAll([root, root, '', null]);
|
|
assert.equal(all.length, 2);
|
|
assert.equal(new Set(all).size, 2);
|
|
assert.ok(all.includes(found[0]));
|
|
});
|
|
|
|
test('a repository reached through a directory junction is discovered once', async (context) => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-junction-'));
|
|
context.after(() => import('node:fs/promises').then(({ rm }) => rm(root, { recursive: true, force: true })));
|
|
const elsewhere = path.join(root, 'elsewhere', 'service');
|
|
await mkdir(path.join(elsewhere, '.git'), { recursive: true });
|
|
await mkdir(path.join(root, 'workspace'), { recursive: true });
|
|
try {
|
|
await symlink(elsewhere, path.join(root, 'workspace', 'linked-service'), 'junction');
|
|
} catch {
|
|
context.skip('this platform does not allow creating directory links');
|
|
return;
|
|
}
|
|
|
|
const { realpath } = await import('node:fs/promises');
|
|
const found = await service().discoverInRoot(path.join(root, 'workspace'), 3);
|
|
assert.deepEqual(found, [await realpath(elsewhere)]);
|
|
});
|
|
|
|
test('local descriptors preserve Git failures and watch paths are defensive copies', async () => {
|
|
const instance = new RepositoryService({ data: {} }, {
|
|
status: async (localPath) => {
|
|
if (localPath.endsWith('bad')) throw new Error('not a repository');
|
|
return { ...status(), root: `${localPath}/canonical`, remoteUrl: remote.clone_url };
|
|
}
|
|
}, {});
|
|
const descriptors = await instance.getLocalDescriptors(['good', 'bad']);
|
|
assert.equal(descriptors[0].localPath, 'good/canonical');
|
|
assert.equal(descriptors[1].error, 'not a repository');
|
|
instance.lastKnownLocalPaths = ['one'];
|
|
const watched = instance.getWatchPaths();
|
|
watched.push('two');
|
|
assert.deepEqual(instance.getWatchPaths(), ['one']);
|
|
});
|
|
|
|
test('refresh links explicit and remote-matched repositories and retains unmatched locals', async () => {
|
|
const diagnostics = [];
|
|
const store = {
|
|
data: {
|
|
gitea: { baseUrl: 'https://gitea.example' },
|
|
workspaceRoots: ['root'],
|
|
repositoryMappings: { 'jens/portfolio': 'C:/explicit' },
|
|
preferences: { preferredCloneProtocol: 'https' },
|
|
favorites: ['jens/portfolio']
|
|
},
|
|
getToken: () => 'token',
|
|
getDeploymentProfiles: (name) => name === remote.full_name ? [{ id: 'prod', branch: 'main' }] : [],
|
|
getDeploymentState: () => ({ liveSha: null, healthy: null })
|
|
};
|
|
const secondRemote = { ...remote, id: 2, name: 'Other', full_name: 'Jens/Other', clone_url: 'https://gitea.example/Jens/Other.git' };
|
|
const instance = new RepositoryService(store, {
|
|
status: async (localPath) => ({
|
|
...status(localPath.includes('unmatched') ? 'b'.repeat(40) : 'a'.repeat(40)),
|
|
root: localPath,
|
|
remoteUrl: localPath.includes('matched') ? secondRemote.clone_url : remote.clone_url
|
|
})
|
|
}, { listRepositories: async () => [remote, secondRemote] }, { debug: async (...args) => diagnostics.push(args) });
|
|
instance.discoverAll = async () => ['C:/matched', 'C:/unmatched'];
|
|
|
|
const repositories = await instance.refresh();
|
|
const explicit = repositories.find((item) => item.fullName === remote.full_name);
|
|
const matched = repositories.find((item) => item.fullName === secondRemote.full_name);
|
|
const unmatched = repositories.find((item) => item.linkState === 'unmatched-local');
|
|
assert.equal(explicit.localPath, 'C:/explicit');
|
|
assert.equal(explicit.favorite, true);
|
|
assert.equal(explicit.preferredCloneUrl, remote.clone_url);
|
|
assert.equal(matched.localPath, 'C:/matched');
|
|
assert.equal(unmatched.localPath, 'C:/unmatched');
|
|
assert.deepEqual(instance.getWatchPaths().sort(), ['C:/explicit', 'C:/matched', 'C:/unmatched'].sort());
|
|
assert.equal(diagnostics[0][0], 'repositories.refresh.completed');
|
|
});
|
|
|
|
test('resolving one repository reads only that repository, not the whole workspace', async () => {
|
|
const scanned = [];
|
|
const store = {
|
|
data: {
|
|
gitea: { baseUrl: 'https://gitea.example' },
|
|
workspaceRoots: ['root'],
|
|
repositoryMappings: { 'jens/portfolio': 'C:/explicit' },
|
|
preferences: { preferredCloneProtocol: 'https' },
|
|
favorites: []
|
|
},
|
|
getToken: () => 'token',
|
|
getDeploymentProfiles: () => [{ id: 'prod', branch: 'main' }],
|
|
getDeploymentState: () => ({ liveSha: null, healthy: null })
|
|
};
|
|
const instance = new RepositoryService(store, {
|
|
status: async (localPath) => {
|
|
scanned.push(localPath);
|
|
return { ...status(), root: localPath, remoteUrl: remote.clone_url };
|
|
}
|
|
}, { listRepositories: async () => [remote, { ...remote, id: 2, full_name: 'Jens/Other', name: 'Other' }] });
|
|
instance.discoverAll = async () => ['C:/explicit', 'C:/other', 'C:/third'];
|
|
|
|
await instance.refresh();
|
|
const duringRefresh = scanned.length;
|
|
assert.equal(duringRefresh, 3);
|
|
|
|
scanned.length = 0;
|
|
const resolved = await instance.resolveByFullName(remote.full_name);
|
|
assert.equal(resolved.fullName, remote.full_name);
|
|
assert.equal(resolved.localPath, 'C:/explicit');
|
|
assert.equal(resolved.deploymentProfiles[0].id, 'prod');
|
|
assert.deepEqual(scanned, ['C:/explicit']);
|
|
|
|
assert.equal(await instance.resolveByFullName(''), null);
|
|
});
|
|
|
|
test('resolving an unknown repository still falls back to a full refresh', async () => {
|
|
const store = {
|
|
data: { gitea: { baseUrl: 'https://gitea.example' }, workspaceRoots: [], repositoryMappings: {}, preferences: { preferredCloneProtocol: 'https' }, favorites: [] },
|
|
getToken: () => 'token',
|
|
getDeploymentProfiles: () => [],
|
|
getDeploymentState: () => null
|
|
};
|
|
const instance = new RepositoryService(store, {
|
|
status: async (localPath) => ({ ...status(), root: localPath, remoteUrl: '' })
|
|
}, { listRepositories: async () => [remote] });
|
|
instance.discoverAll = async () => ['C:/loose-checkout'];
|
|
|
|
const local = await instance.resolveByFullName('loose-checkout');
|
|
assert.equal(local.linkState, 'unmatched-local');
|
|
assert.equal(await instance.resolveByFullName('Jens/Missing'), null);
|
|
});
|
|
|
|
test('refresh remains local-only without configured Gitea credentials', async () => {
|
|
const store = {
|
|
data: { gitea: { baseUrl: '' }, workspaceRoots: [], repositoryMappings: {}, preferences: { preferredCloneProtocol: 'ssh' }, favorites: [] },
|
|
getToken: () => '', getDeploymentProfiles: () => [], getDeploymentState: () => null
|
|
};
|
|
const instance = new RepositoryService(store, {}, { listRepositories: async () => { throw new Error('must not call'); } });
|
|
instance.discoverAll = async () => [];
|
|
assert.deepEqual(await instance.refresh(), []);
|
|
});
|
|
|
|
test('refresh uses last-known Gitea repositories after a transient remote failure', async () => {
|
|
const warnings = [];
|
|
let remoteAvailable = true;
|
|
const store = {
|
|
data: {
|
|
gitea: { baseUrl: 'https://gitea.example' }, workspaceRoots: [], repositoryMappings: {},
|
|
preferences: { preferredCloneProtocol: 'ssh' }, favorites: []
|
|
},
|
|
getToken: () => 'token', getDeploymentProfiles: () => [], getDeploymentState: () => null
|
|
};
|
|
const instance = new RepositoryService(store, {}, {
|
|
listRepositories: async () => {
|
|
if (!remoteAvailable) throw new Error('Gitea timed out');
|
|
return [remote];
|
|
}
|
|
}, { debug: async () => {}, warning: async (...args) => warnings.push(args) });
|
|
instance.discoverAll = async () => [];
|
|
|
|
const fresh = await instance.refresh();
|
|
remoteAvailable = false;
|
|
const degraded = await instance.refresh({ force: true });
|
|
|
|
assert.equal(fresh[0].remoteStale, false);
|
|
assert.equal(degraded[0].fullName, remote.full_name);
|
|
assert.equal(degraded[0].remoteStale, true);
|
|
assert.equal(degraded[0].remoteRefreshError, 'Gitea timed out');
|
|
assert.ok(degraded[0].remoteLastRefreshedAt);
|
|
assert.equal(warnings[0][0], 'repositories.remote-refresh.degraded');
|
|
});
|
|
|
|
test('initial Gitea failure remains visible when no safe cache exists', async () => {
|
|
const store = {
|
|
data: { gitea: { baseUrl: 'https://gitea.example' } },
|
|
getToken: () => 'token'
|
|
};
|
|
const instance = new RepositoryService(store, {}, {
|
|
listRepositories: async () => { throw new Error('Gitea unavailable'); }
|
|
});
|
|
await assert.rejects(() => instance.refresh(), /Gitea unavailable/);
|
|
});
|
|
|
|
test('refresh coalesces concurrent work and briefly reuses remote and discovery results', async () => {
|
|
let remoteCalls = 0;
|
|
let discoveryCalls = 0;
|
|
const store = {
|
|
data: { gitea: { baseUrl: 'https://gitea.example' }, workspaceRoots: [], repositoryMappings: {}, preferences: { preferredCloneProtocol: 'ssh' }, favorites: [] },
|
|
getToken: () => 'token', getDeploymentProfiles: () => [], getDeploymentState: () => null
|
|
};
|
|
const instance = new RepositoryService(store, {}, { listRepositories: async () => { remoteCalls += 1; await new Promise((resolve) => setTimeout(resolve, 10)); return [remote]; } });
|
|
instance.discoverAll = async () => { discoveryCalls += 1; return []; };
|
|
|
|
const [first, second] = await Promise.all([instance.refresh(), instance.refresh()]);
|
|
assert.deepEqual(first, second);
|
|
await instance.refresh();
|
|
assert.equal(remoteCalls, 1);
|
|
assert.equal(discoveryCalls, 1);
|
|
await instance.refresh({ force: true });
|
|
assert.equal(remoteCalls, 2);
|
|
assert.equal(discoveryCalls, 2);
|
|
});
|
|
|
|
test('decoration reports conflicts, behind branches, errors and remote-only repositories', () => {
|
|
const instance = service();
|
|
const conflicted = instance.decorate(remote, { localPath: 'repo', status: { ...status(), counts: { changed: 1, conflicts: 2 }, branch: { ...status().branch, behind: 3 } } }, []);
|
|
assert.equal(conflicted.attentionReason, 'Merge conflict');
|
|
assert.equal(conflicted.readyToDeploy, false);
|
|
const behind = instance.decorate(remote, { localPath: 'repo', status: { ...status(), branch: { ...status().branch, behind: 1 } } }, []);
|
|
assert.equal(behind.attentionReason, '1 commit behind remote');
|
|
const broken = instance.decorate(remote, { localPath: 'repo', status: null, error: 'broken checkout' }, []);
|
|
assert.equal(broken.attentionReason, 'broken checkout');
|
|
const remoteOnly = instance.decorate({ ...remote, ssh_url: '', clone_url: '' }, null, []);
|
|
assert.equal(remoteOnly.linkState, 'remote-only');
|
|
assert.equal(remoteOnly.preferredCloneUrl, '');
|
|
});
|