Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ src/
after every run, read by terminal coding agents; see plugin/)
update/ auto-update: UpdateProvider seam, electron-updater-backed provider, pure
semver/channel/reducer/gate logic (update-core.ts), orchestration (updater.ts)
repo/ repository list, file-system watcher (recursive fs.watch with polling fallback),
repo/ repository list, Git-pruned directory watcher in a worker thread,
watched folders: a pure bounded directory walker (scan.ts) plus the scan
orchestration, exclusions and folder validation (watched-folders.ts); path
comparison with the platform's case rules lives in paths.ts
Expand All @@ -35,4 +35,10 @@ src/

Renderer and main process communicate over one typed channel (`src/shared/ipc.ts`). All git commands run with `GIT_TERMINAL_PROMPT=0`, so nothing ever blocks on a hidden prompt; errors are classified (auth, network, non-fast-forward, conflicts, protected branch, …) to drive the right dialog.

Repository watching runs in `repo/watcher-worker.ts`; `watcher.ts` only owns the worker and forwards coalesced change notifications. Git enumerates cached and non-ignored files, preserving tracked files inside ignored directories and honoring nested, negated, global and repository-local ignore rules. Only their containing directories and relevant Git metadata directories receive non-recursive `fs.watch` subscriptions; ignored output trees and Git object storage are not recursively scanned. Git commands, file metadata comparisons, directory subscription updates and polling all stay off the Electron main thread.

Native events trigger bounded 120 ms reconciliation. A four-second reconciliation also detects missed events, changes to external ignore rules and files added inside previously empty directories; submodule dirty status is refreshed on reconciliation. If native watching fails, the same worker continues polling. File metadata includes nanosecond modification/change times, so editing an already-modified file still refreshes its diff. Switching repositories stops the old worker and suppresses late notifications; shutdown aborts its Git child. electron-vite's `?nodeWorker` import bundles the worker alongside the main entry, including in ASAR packages.

History, changes, and text diffs window their rendered rows. `TextDiff` computes word-level changes only when either row of a paired deletion/addition renders, caches both sides, and invalidates them when the hunks or word-highlighting setting change. Syntax highlighting remains lazy per 400-line block.

See [DEVELOPMENT.md](DEVELOPMENT.md) for how to build and test this, and [RELEASING.md](RELEASING.md) for how packaged builds and releases work.
10 changes: 5 additions & 5 deletions src/main/git/branches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,11 @@ export async function getMergedBranchNames(git: GitClient, repoPath: string, ref
export async function getDefaultBranch(git: GitClient, repoPath: string): Promise<string | null> {
const head = await git.tryRun(repoPath, ['symbolic-ref', '-q', 'refs/remotes/origin/HEAD'], { readOnly: true });
if (head && head.stdout.trim()) return head.stdout.trim().replace(/^refs\/remotes\/origin\//, '');
for (const candidate of ['main', 'master', 'develop', 'trunk']) {
const exists = await git.tryRun(repoPath, ['show-ref', '--verify', '--quiet', `refs/heads/${candidate}`], { readOnly: true });
if (exists) return candidate;
const remoteExists = await git.tryRun(repoPath, ['show-ref', '--verify', '--quiet', `refs/remotes/origin/${candidate}`], { readOnly: true });
if (remoteExists) return candidate;
const candidates = ['main', 'master', 'develop', 'trunk'];
const refs = await git.tryRun(repoPath, ['for-each-ref', '--format=%(refname)', ...candidates.flatMap((c) => [`refs/heads/${c}`, `refs/remotes/origin/${c}`])], { readOnly: true });
const present = new Set(refs?.stdout.split('\n').map((l) => l.trim()) ?? []);
for (const candidate of candidates) {
if (present.has(`refs/heads/${candidate}`) || present.has(`refs/remotes/origin/${candidate}`)) return candidate;
}
const configured = await git.tryRun(repoPath, ['config', '--get', 'init.defaultBranch'], { readOnly: true });
return configured?.stdout.trim() || null;
Expand Down
7 changes: 5 additions & 2 deletions src/main/git/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ export interface GitRunOptions {
export class GitClient {
constructor(private readonly tools: ToolLocator) {}

async executable(): Promise<string> {
await this.tools.ensureLocated();
return this.tools.gitPath();
}
async baseEnv(extra?: NodeJS.ProcessEnv): Promise<NodeJS.ProcessEnv> {
const env: NodeJS.ProcessEnv = { ...(await this.tools.env()) };
env.GIT_TERMINAL_PROMPT = '0';
Expand All @@ -99,8 +103,7 @@ export class GitClient {
* Runs git in the given repository. Throws GitError on failure.
*/
async run(repoPath: string | null, args: string[], opts: GitRunOptions = {}): Promise<ExecResult> {
await this.tools.ensureLocated();
const gitPath = this.tools.gitPath();
const gitPath = await this.executable();
const env = await this.baseEnv(opts.env);
if (opts.readOnly) env.GIT_OPTIONAL_LOCKS = '0';
const fullArgs = ['-c', 'core.quotePath=false', '-c', 'color.ui=never', '-c', 'advice.detachedHead=false'];
Expand Down
35 changes: 23 additions & 12 deletions src/main/repo/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export function repositoryId(path: string): string {
export class RepositoryManager {
private watchers = new Map<string, RepositoryWatcher>();
private githubCache = new Map<string, GitHubRepoRef | null>();
private watchGeneration = 0;
private watchingPath: string | null = null;

constructor(
private readonly store: Store,
Expand Down Expand Up @@ -484,26 +486,33 @@ export class RepositoryManager {

async watch(repoPath: string): Promise<void> {
if (this.watchers.has(repoPath)) return;
// Only the active repository is watched; stop the others.
for (const [p, w] of this.watchers) {
if (p !== repoPath) {
w.stop();
this.watchers.delete(p);
}
}
const generation = ++this.watchGeneration;
this.watchingPath = repoPath;
for (const w of this.watchers.values()) void w.stop();
this.watchers.clear();
let watcher: RepositoryWatcher | undefined;
try {
const gitDir = await getGitDir(this.git, repoPath);
const commonDir = await getCommonDir(this.git, repoPath).catch(() => gitDir);
const watcher = new RepositoryWatcher(repoPath, gitDir, (reason) => this.send('repo.changed', { repoPath, reason }), { commonDir });
watcher.start();
const gitPath = await this.git.executable();
const env = await this.git.baseEnv();
if (generation !== this.watchGeneration) return;
watcher = new RepositoryWatcher(repoPath, gitDir, (reason) => this.send('repo.changed', { repoPath, reason }), { commonDir, gitPath, env });
this.watchers.set(repoPath, watcher);
await watcher.start();
} catch (err) {
log.warn(`Failed to watch ${repoPath}: ${(err as Error).message}`);
if (watcher && this.watchers.get(repoPath) === watcher) this.watchers.delete(repoPath);
if (watcher) void watcher.stop();
if (generation === this.watchGeneration) log.warn(`Failed to watch ${repoPath}: ${(err as Error).message}`);
}
}

stopWatching(repoPath: string): void {
this.watchers.get(repoPath)?.stop();
if (this.watchingPath === repoPath) {
this.watchingPath = null;
this.watchGeneration++;
}
void this.watchers.get(repoPath)?.stop();
this.watchers.delete(repoPath);
}

Expand Down Expand Up @@ -582,7 +591,9 @@ export class RepositoryManager {
}

dispose(): void {
for (const w of this.watchers.values()) w.stop();
this.watchGeneration++;
this.watchingPath = null;
for (const w of this.watchers.values()) void w.stop();
this.watchers.clear();
}
}
191 changes: 191 additions & 0 deletions src/main/repo/watcher-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { lstatSync, watch, type FSWatcher } from 'node:fs';
import { dirname, join } from 'node:path';
import { parentPort, workerData } from 'node:worker_threads';
import { exec } from '../exec';
import type { WatcherData, WatcherMessage } from './watcher';

const port = parentPort!;
const { repoPath, gitDir, commonDir = gitDir, gitPath, env, pollIntervalMs = 4000, forcePolling } = workerData as WatcherData;
const abort = new AbortController();
const watchers = new Map<string, { watcher: FSWatcher; identity: string }>();
let native = !forcePolling;
let stopped = false;
let paused = 0;
let running = false;
let again = false;
let ready = false;
let lastError = '';
let timer: ReturnType<typeof setTimeout> | undefined;
let worktree = new Map<string, string>();
let refs = new Map<string, string>();

function send(message: WatcherMessage): void {
if (!stopped) port.postMessage(message);
}

function warn(err: unknown): void {
const message = err instanceof Error ? err.message : String(err);
if (message !== lastError) send({ type: 'error', message });
lastError = message;
}

function stat(path: string) {
try {
return lstatSync(path, { bigint: true });
} catch (err) {
if (['ENOENT', 'ENOTDIR'].includes((err as NodeJS.ErrnoException).code ?? '')) return null;
throw err;
}
}

function stamp(s: ReturnType<typeof stat>): string {
return s ? `${s.dev}:${s.ino}:${s.mode}:${s.size}:${s.mtimeNs}:${s.ctimeNs}` : 'missing';
}

function addParents(file: string, root: string, directories: Set<string>): void {
for (let dir = dirname(file); ; dir = dirname(dir)) {
if (directories.has(dir)) break;
directories.add(dir);
if (dir === root || dir === dirname(dir)) break;
}
}

async function git(args: string[]): Promise<string> {
return (await exec(gitPath, ['-c', 'core.quotePath=false', '-c', 'core.longpaths=true', '--no-pager', ...args], {
cwd: repoPath, env: { ...env, GIT_OPTIONAL_LOCKS: '0' }, signal: abort.signal, timeoutMs: 30000,
})).stdout;
}

function differs(before: Map<string, string>, after: Map<string, string>): boolean {
if (before.size !== after.size) return true;
for (const [key, value] of after) if (before.get(key) !== value) return true;
return false;
}

function schedule(): void {
if (stopped || timer) return;
// Bound event bursts without postponing refresh indefinitely during continuous writes.
timer = setTimeout(() => { timer = undefined; void scan(); }, 120);
}

function closeWatchers(): void {
for (const { watcher } of watchers.values()) watcher.close();
watchers.clear();
}

function updateWatchers(directories: Set<string>): void {
if (!native || stopped) return;
for (const [dir, entry] of watchers) {
if (!directories.has(dir)) { entry.watcher.close(); watchers.delete(dir); }
}
for (const dir of directories) {
const s = stat(dir);
const identity = s?.isDirectory() ? `${s.dev}:${s.ino}` : '';
const previous = watchers.get(dir);
if (previous?.identity === identity) continue;
previous?.watcher.close();
watchers.delete(dir);
if (!identity) continue;
try {
// Never use recursive fs.watch: on Linux its synchronous rescans amplify rename bursts.
const watcher = watch(dir, { persistent: false }, schedule);
watchers.set(dir, { watcher, identity });
watcher.on('error', (err) => {
if (stopped) return;
native = false;
closeWatchers();
warn(new Error(`Native watch failed; using polling: ${err.message}`));
});
} catch (err) {
if (['ENOENT', 'ENOTDIR'].includes((err as NodeJS.ErrnoException).code ?? '')) continue;
native = false;
closeWatchers();
warn(new Error(`Native watch unavailable; using polling: ${(err as Error).message}`));
break;
}
}
}

async function scan(): Promise<void> {
if (stopped) return;
if (running) { again = true; return; }
running = true;
try {
// Git handles nested/negated/global ignores and info/exclude. Cached paths retain
// force-added files even when their containing tree is otherwise ignored.
const files = await git(['ls-files', '--cached', '--others', '--exclude-standard', '--deduplicate', '-z']);
const refList = await git(['for-each-ref', '--format=%(refname) %(objectname)']);
if (stopped) return;
const directories = new Set([repoPath, gitDir, commonDir]);
const nextWorktree = new Map<string, string>();
const nextRefs = new Map<string, string>();
let hasSubmodules = false;
for (const file of files.split('\0')) {
if (!file) continue;
const path = join(repoPath, file);
const s = stat(path);
nextWorktree.set(path, stamp(s));
addParents(path, repoPath, directories);
if (s?.isDirectory()) hasSubmodules = true;
}
nextWorktree.set(join(gitDir, 'index'), stamp(stat(join(gitDir, 'index'))));
for (const base of new Set([gitDir, commonDir])) {
for (const file of ['HEAD', 'FETCH_HEAD', 'ORIG_HEAD', 'MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'packed-refs', 'config', 'info/exclude', 'logs/HEAD', 'rebase-merge', 'rebase-apply', 'sequencer']) {
const path = join(base, file);
const s = stat(path);
nextRefs.set(path, stamp(s));
addParents(path, base, directories);
if (s?.isDirectory()) directories.add(path);
}
for (const name of ['refs', 'refs/heads', 'refs/remotes', 'refs/tags']) directories.add(join(base, name));
}
for (const line of refList.split('\n')) {
if (!line) continue;
const space = line.indexOf(' ');
const path = join(commonDir, line.slice(0, space));
nextRefs.set(path, line.slice(space + 1));
addParents(path, commonDir, directories);
}
updateWatchers(directories);
// Gitlinks are directories, not their files in the superproject's index. Refresh
// their dirty status on reconciliation without recursively subscribing to them.
const workChanged = differs(worktree, nextWorktree) || hasSubmodules;
const refsChanged = differs(refs, nextRefs);
worktree = nextWorktree;
refs = nextRefs;
if (!ready) {
ready = true;
send({ type: 'ready' });
} else if (!paused && (workChanged || refsChanged)) {
send({ type: 'change', reason: workChanged && refsChanged ? 'both' : refsChanged ? 'refs' : 'worktree' });
}
lastError = '';
} catch (err) {
if (!stopped) warn(err);
if (!ready) {
stopped = true;
clearInterval(poll);
clearTimeout(timer);
closeWatchers();
}
} finally {
running = false;
if (stopped) port.close();
else if (again) { again = false; schedule(); }
}
}

const poll = setInterval(schedule, pollIntervalMs);
port.on('message', (message: 'pause' | 'resume' | 'stop') => {
if (message === 'pause') paused++;
else if (message === 'resume') paused = Math.max(0, paused - 1);
else {
stopped = true;
abort.abort();
clearInterval(poll);
clearTimeout(timer);
closeWatchers();
if (!running) port.close();
}
});
void scan();
Loading
Loading