Files
ForgeFlow/src/main/repository-service.cjs
T
NuklearRabbitandClaude Opus 5 9260d35957 fix: repair broken IPC wiring and cut the cost of repository polling
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>
2026-08-23 14:31:58 +02:00

304 lines
12 KiB
JavaScript

'use strict';
const fs = require('node:fs/promises');
const path = require('node:path');
const { matchRemoteToRepository, repositoryKey } = require('../shared/repository-match.cjs');
const SKIP_DIRECTORIES = new Set([
'.git', '.svn', '.hg', 'node_modules', '.next', '.nuxt', 'dist', 'build', 'coverage',
'.cache', '.venv', 'venv', '__pycache__', '$RECYCLE.BIN', 'System Volume Information'
]);
async function mapLimit(items, limit, mapper) {
const output = new Array(items.length);
let cursor = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (cursor < items.length) {
const index = cursor++;
output[index] = await mapper(items[index], index);
}
});
await Promise.all(workers);
return output;
}
class RepositoryService {
constructor(store, gitService, giteaService, diagnostics = null) {
this.store = store;
this.git = gitService;
this.gitea = giteaService;
this.diagnostics = diagnostics;
this.lastKnownLocalPaths = [];
this.lastKnownRemoteRepositories = [];
this.lastSuccessfulRemoteRefreshAt = null;
this.lastRemoteRefreshAtMs = 0;
this.lastDiscoveredPaths = [];
this.lastDiscoveryAtMs = 0;
this.refreshPromise = null;
this.lastResult = null;
}
async discoverInRoot(root, maxDepth = 4) {
const found = [];
const seen = new Set();
const visit = async (directory, depth) => {
let real;
try { real = await fs.realpath(directory); } catch { return; }
if (seen.has(real)) return;
seen.add(real);
const gitMarker = path.join(directory, '.git');
const marker = await fs.stat(gitMarker).catch(() => null);
if (marker) {
found.push(real);
return;
}
if (depth >= maxDepth) return;
let entries;
try { entries = await fs.readdir(real, { withFileTypes: true }); } catch { return; }
// Directory entries report as a symbolic link instead of a directory, which
// is how Windows junctions surface. Skipping those made a project folder
// that is mapped through a junction invisible; visit() resolves each entry
// and the `seen` set above keeps links that point back into the tree from
// being scanned twice.
await mapLimit(entries
.filter((entry) => (entry.isDirectory() || entry.isSymbolicLink()) && !SKIP_DIRECTORIES.has(entry.name)), 12,
(entry) => visit(path.join(real, entry.name), depth + 1));
};
await visit(root, 0);
return found;
}
async discoverAll(roots) {
const grouped = await mapLimit((roots || []).filter(Boolean), 4, (root) => this.discoverInRoot(root));
return [...new Set(grouped.flat())];
}
async getLocalDescriptors(paths) {
return mapLimit(paths, 5, async (localPath) => {
try {
const status = await this.git.status(localPath);
return { localPath: status.root, remoteUrl: status.remoteUrl, status };
} catch (error) {
return { localPath, remoteUrl: '', status: null, error: error.message };
}
});
}
getWatchPaths() {
return [...this.lastKnownLocalPaths];
}
async getRemoteRepositories({ force = false } = {}) {
if (!this.store.data.gitea.baseUrl || !this.store.getToken()) {
this.lastKnownRemoteRepositories = [];
this.lastSuccessfulRemoteRefreshAt = null;
return { repositories: [], stale: false, error: null };
}
if (!force && this.lastSuccessfulRemoteRefreshAt && Date.now() - this.lastRemoteRefreshAtMs < 15_000) {
return {
repositories: this.lastKnownRemoteRepositories.map((repository) => ({ ...repository })),
stale: false,
error: null,
cached: true
};
}
try {
const repositories = await this.gitea.listRepositories();
this.lastKnownRemoteRepositories = repositories.map((repository) => ({ ...repository }));
this.lastSuccessfulRemoteRefreshAt = new Date().toISOString();
this.lastRemoteRefreshAtMs = Date.now();
return { repositories, stale: false, error: null };
} catch (error) {
if (!this.lastSuccessfulRemoteRefreshAt) throw error;
await this.diagnostics?.warning('repositories.remote-refresh.degraded', {
message: error.message,
cachedCount: this.lastKnownRemoteRepositories.length,
lastSuccessfulAt: this.lastSuccessfulRemoteRefreshAt
});
return {
repositories: this.lastKnownRemoteRepositories.map((repository) => ({ ...repository })),
stale: true,
error: error.message
};
}
}
async getDiscoveredPaths({ force = false } = {}) {
if (!force && this.lastDiscoveryAtMs && Date.now() - this.lastDiscoveryAtMs < 30_000) {
return [...this.lastDiscoveredPaths];
}
const paths = await this.discoverAll(this.store.data.workspaceRoots);
this.lastDiscoveredPaths = [...paths];
this.lastDiscoveryAtMs = Date.now();
return paths;
}
// Resolving a single repository used to go through a full refresh, which runs
// `git status` for every discovered repository. Handlers that act on one
// repository only need that one, so its local state is read directly. Anything
// this cannot answer confidently still falls back to the full scan.
async resolveByFullName(fullName) {
const name = String(fullName || '').trim();
if (!name) return null;
const fromFullRefresh = async () => (await this.refresh()).find((item) => item.fullName === name) || null;
const remoteResult = await this.getRemoteRepositories({});
const remote = remoteResult.repositories.find((item) => item.full_name === name);
if (!remote) return fromFullRefresh();
const explicitPath = this.store.data.repositoryMappings[repositoryKey(remote)];
const knownPath = explicitPath || (this.lastResult || []).find((item) => item.fullName === name)?.localPath || null;
// Without a known path the link can still exist through remote-URL matching,
// which only the discovery pass can establish.
if (!knownPath && !this.lastResult) return fromFullRefresh();
const local = knownPath ? (await this.getLocalDescriptors([knownPath]))[0] : null;
const profiles = this.store.getDeploymentProfiles(remote.full_name).map((profile) => ({
...profile,
state: this.store.getDeploymentState(profile.id)
}));
return {
...this.decorate(remote, local, profiles),
remoteStale: remoteResult.stale,
remoteRefreshError: remoteResult.error,
remoteLastRefreshedAt: this.lastSuccessfulRemoteRefreshAt
};
}
async refresh(options = {}) {
if (this.refreshPromise) return this.refreshPromise;
this.refreshPromise = this.performRefresh(options).finally(() => { this.refreshPromise = null; });
return this.refreshPromise;
}
async performRefresh({ force = false } = {}) {
const started = Date.now();
const remoteResult = await this.getRemoteRepositories({ force });
const remoteRepositories = remoteResult.repositories;
const discoveredPaths = await this.getDiscoveredPaths({ force });
const mappedPaths = Object.values(this.store.data.repositoryMappings || {});
const localPaths = [...new Set([...discoveredPaths, ...mappedPaths])];
const localDescriptors = await this.getLocalDescriptors(localPaths);
this.lastKnownLocalPaths = localDescriptors.filter((item) => item.status).map((item) => item.status.root);
const usedLocalPaths = new Set();
const repositories = [];
for (const remote of remoteRepositories) {
const key = repositoryKey(remote);
const explicitPath = this.store.data.repositoryMappings[key];
let local = explicitPath ? localDescriptors.find((item) => path.resolve(item.localPath) === path.resolve(explicitPath)) : null;
if (!local) local = localDescriptors.find((item) => !usedLocalPaths.has(item.localPath) && matchRemoteToRepository(item.remoteUrl, [remote]));
if (local) usedLocalPaths.add(local.localPath);
const profiles = this.store.getDeploymentProfiles(remote.full_name).map((profile) => ({
...profile,
state: this.store.getDeploymentState(profile.id)
}));
repositories.push({
...this.decorate(remote, local, profiles),
remoteStale: remoteResult.stale,
remoteRefreshError: remoteResult.error,
remoteLastRefreshedAt: this.lastSuccessfulRemoteRefreshAt
});
}
for (const local of localDescriptors.filter((item) => !usedLocalPaths.has(item.localPath))) {
const name = path.basename(local.localPath);
repositories.push({
id: `local:${local.localPath}`,
name,
fullName: name,
owner: { login: 'local' },
description: 'Local repository not matched to Gitea',
private: true,
defaultBranch: local.status?.branch.head || 'main',
htmlUrl: null,
cloneUrl: null,
sshUrl: null,
preferredCloneUrl: null,
localPath: local.localPath,
localStatus: local.status,
linkState: 'unmatched-local',
deploymentProfiles: [],
readyToDeploy: false,
favorite: false,
attention: Boolean(local.error),
attentionReason: local.error || null
});
}
const sorted = repositories.sort((a, b) => {
const score = (repo) => (repo.attention ? 100 : 0)
+ (repo.localStatus?.counts.changed ? 50 : 0)
+ (repo.localStatus?.branch.ahead ? 30 : 0)
+ (repo.readyToDeploy ? 20 : 0)
+ (repo.favorite ? 5 : 0);
return score(b) - score(a) || a.fullName.localeCompare(b.fullName);
});
await this.diagnostics?.debug('repositories.refresh.completed', {
durationMs: Date.now() - started,
remoteCount: remoteRepositories.length,
remoteStale: remoteResult.stale,
remoteCached: remoteResult.cached === true,
discoveredCount: discoveredPaths.length,
linkedCount: sorted.filter((item) => item.localPath).length,
attentionCount: sorted.filter((item) => item.attention).length,
readyToDeployCount: sorted.filter((item) => item.readyToDeploy).length
});
this.lastResult = sorted;
return sorted;
}
decorate(remote, local, profiles) {
const status = local?.status || null;
const hasChanges = Boolean(status?.counts.changed);
const ahead = status?.branch.ahead || 0;
const behind = status?.branch.behind || 0;
const conflict = Boolean(status?.counts.conflicts);
const profileForBranch = profiles.find((profile) => profile.branch === status?.branch.head);
const synchronized = Boolean(profileForBranch && status?.head && status?.branch.upstream && !hasChanges && ahead === 0 && behind === 0);
const alreadyLiveAndHealthy = Boolean(
synchronized
&& profileForBranch?.state?.liveSha === status.head
&& profileForBranch?.state?.healthy !== false
);
const readyToDeploy = synchronized && !alreadyLiveAndHealthy;
const key = String(remote.full_name || '').toLowerCase();
const preferredCloneUrl = this.store.data.preferences.preferredCloneProtocol === 'ssh'
? (remote.ssh_url || remote.clone_url)
: (remote.clone_url || remote.ssh_url);
return {
id: remote.id,
name: remote.name,
fullName: remote.full_name,
owner: remote.owner,
description: remote.description || '',
private: remote.private,
defaultBranch: remote.default_branch || 'main',
htmlUrl: remote.html_url,
cloneUrl: remote.clone_url,
sshUrl: remote.ssh_url,
preferredCloneUrl,
updatedAt: remote.updated_at,
localPath: local?.localPath || null,
localStatus: status,
linkState: local ? 'linked' : 'remote-only',
deploymentProfiles: profiles,
readyToDeploy,
favorite: (this.store.data.favorites || []).includes(key),
attention: conflict || behind > 0 || Boolean(local?.error),
attentionReason: conflict ? 'Merge conflict' : behind > 0 ? `${behind} commit${behind === 1 ? '' : 's'} behind remote` : local?.error || null
};
}
}
module.exports = { RepositoryService, SKIP_DIRECTORIES, mapLimit };