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>
This commit is contained in:
NuklearRabbit
2026-08-23 14:31:58 +02:00
co-authored by Claude Opus 5
parent cf1da8a2fa
commit 9260d35957
21 changed files with 858 additions and 147 deletions
+66 -11
View File
@@ -29,6 +29,13 @@ function parseUnifiedDiff(diffText) {
}
class GitService {
constructor() {
// `git remote get-url` is only re-run when the repository configuration file
// itself changed. Status polling asks for the remote URL of every repository
// every few seconds, and on Windows the child process dominates that cost.
this.remoteUrlCache = new Map();
}
async isAvailable() {
try {
const result = await run('git', ['--version'], { timeout: 10_000 });
@@ -42,13 +49,24 @@ class GitService {
const resolved = assertSafeRepositoryPath(repoPath);
const stat = await fs.stat(resolved).catch(() => null);
if (!stat?.isDirectory()) throw new Error('The linked local folder no longer exists.');
// A directory that carries its own `.git` entry is by definition the top level
// of that working tree, for plain repositories as well as for submodules and
// linked worktrees where `.git` is a file. Spawning `git rev-parse` to learn
// that again is pure overhead, and every status poll passes an already
// resolved repository root back in.
const marker = await fs.stat(path.join(resolved, '.git')).catch(() => null);
if (marker) return resolved;
const result = await run('git', ['rev-parse', '--show-toplevel'], { cwd: resolved, timeout: 15_000 });
return path.resolve(result.stdout.trim());
}
async status(repoPath) {
const root = await this.ensureRepository(repoPath);
const result = await run('git', ['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'], {
// `--no-optional-locks` keeps a status read from refreshing and rewriting the
// index. Without it every read writes inside .git, which both fights a
// concurrent Git command for the index lock and retriggers the filesystem
// watcher that asked for this read in the first place.
const result = await run('git', ['--no-optional-locks', 'status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'], {
cwd: root,
timeout: 30_000
});
@@ -66,9 +84,34 @@ class GitService {
});
}
remoteUrlCacheKey(repoPath, remote) {
return JSON.stringify([path.resolve(repoPath), remote]);
}
async getRemoteUrl(repoPath, remote = 'origin') {
const result = await run('git', ['remote', 'get-url', remote], { cwd: repoPath, timeout: 15_000 });
return result.stdout.trim();
const cacheKey = this.remoteUrlCacheKey(repoPath, remote);
const config = await fs.stat(path.join(repoPath, '.git', 'config')).catch(() => null);
const cached = this.remoteUrlCache.get(cacheKey);
if (config && cached && cached.mtimeMs === config.mtimeMs && cached.size === config.size) {
if (cached.error) throw cached.error;
return cached.url;
}
const remember = (entry) => {
if (config) this.remoteUrlCache.set(cacheKey, { ...entry, mtimeMs: config.mtimeMs, size: config.size });
else this.remoteUrlCache.delete(cacheKey);
};
try {
const result = await run('git', ['remote', 'get-url', remote], { cwd: repoPath, timeout: 15_000 });
const url = result.stdout.trim();
remember({ url, error: null });
return url;
} catch (error) {
// A repository that has no such remote keeps failing until its configuration
// changes, so the failure is remembered too. Without this, every status poll
// of an unmatched local repository spawns a child process that cannot succeed.
remember({ url: '', error });
throw error;
}
}
@@ -279,6 +322,7 @@ class GitService {
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 });
this.remoteUrlCache.delete(this.remoteUrlCacheKey(root, name));
return this.status(root);
}
@@ -354,8 +398,7 @@ class GitService {
return { selected, matches };
}
async expandSelectedPaths(root, files, { unstagedOnly = false } = {}) {
const status = await this.status(root);
expandStatusPaths(status, files, { unstagedOnly = false } = {}) {
const { selected, matches } = this.selectedStatusFiles(status, files);
if (!selected.length) return [];
const expanded = new Set();
@@ -367,12 +410,17 @@ class GitService {
return [...expanded];
}
async stage(repoPath, files) {
const root = await this.ensureRepository(repoPath);
async expandSelectedPaths(root, files, options = {}) {
return this.expandStatusPaths(await this.status(root), files, options);
}
// Callers that already read the status pass it in. Reading it again costs a
// child process, and a commit used to pay for four of them.
async applyStage(root, files, knownStatus = null) {
const requested = assertRepositoryRelativePaths(files);
if (!requested.length) {
await run('git', ['add', '--all'], { cwd: root, timeout: 60_000 });
return this.status(root);
return;
}
// Only stage records that still have a worktree-side change. Re-running
@@ -380,10 +428,16 @@ class GitService {
// Git fail with "pathspec did not match any files" because the file no
// longer exists in either the worktree or HEAD. Staged-only deletions and
// renames are already ready for commit and must therefore be left alone.
const selected = await this.expandSelectedPaths(root, requested, { unstagedOnly: true });
const status = knownStatus || await this.status(root);
const selected = this.expandStatusPaths(status, requested, { unstagedOnly: true });
if (selected.length) {
await this.runWithPathspec(root, ['add', '-A'], selected, { timeout: 120_000 });
}
}
async stage(repoPath, files) {
const root = await this.ensureRepository(repoPath);
await this.applyStage(root, files);
return this.status(root);
}
@@ -403,8 +457,9 @@ class GitService {
async prepareSelectedStage(root, files) {
const selected = assertRepositoryRelativePaths(files);
let current = null;
if (selected.length) {
const current = await this.status(root);
current = await this.status(root);
const excludedStaged = current.files
.filter((file) => file.staged)
.filter((file) => !selected.includes(file.path) && !(file.originalPath && selected.includes(file.originalPath)))
@@ -413,7 +468,7 @@ class GitService {
throw new Error(`Some staged files are not selected (${excludedStaged.slice(0, 3).join(', ')}${excludedStaged.length > 3 ? ', …' : ''}). Select them or unstage them first.`);
}
}
await this.stage(root, selected);
await this.applyStage(root, selected, current);
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.');
return selected;