diff --git a/src/main/ipc/worktree-git-common-polling.ts b/src/main/ipc/worktree-git-common-polling.ts index 8713ff6ab11..c3414430ca8 100644 --- a/src/main/ipc/worktree-git-common-polling.ts +++ b/src/main/ipc/worktree-git-common-polling.ts @@ -132,21 +132,20 @@ async function snapshotGitCommon( dirSignature(worktreesDir), includePrimary ? snapshotPrimaryCheckoutSignatures(commonDirPath) : new Map() ]) - const shouldReadWorktreesDir = - forceFullScan || !previous || previous.worktreesDirSignature !== worktreesDirSignature + // Why: enumerate the worktrees dir EVERY tick rather than gating the readdir on its stat signature. + // A single readdir of a small dir is negligible next to the per-entry structural stats that already + // run each tick, and the signature gate could miss a same-granule add+remove on a coarse-mtime/FAT + // filesystem (its size/mtime/ino/ctime all collide), leaving a linked worktree add/remove undetected + // until the ~30s index backstop (#9882 review). The listing is the authoritative add/remove signal. let entryPaths: string[] - if (shouldReadWorktreesDir) { - try { - const entries = await readdir(worktreesDir, { withFileTypes: true }) - entryPaths = entries - .filter((entry) => entry.isDirectory()) - .map((entry) => join(worktreesDir, entry.name)) - } catch { - // Missing worktrees dir is normal for repos without linked worktrees. - entryPaths = [] - } - } else { - entryPaths = [...previous.entries.keys()] + try { + const entries = await readdir(worktreesDir, { withFileTypes: true }) + entryPaths = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => join(worktreesDir, entry.name)) + } catch { + // Missing worktrees dir is normal for repos without linked worktrees. + entryPaths = [] } const entries = new Map() @@ -156,13 +155,14 @@ async function snapshotGitCommon( entries.set(entryPath, await snapshotGitCommonEntry(entryPath, previousEntry, forceFullScan)) }) ) - // Why: structural leaves are re-stat'd every tick now, so the only gated work is the worktrees-dir - // readdir; onFullScan reflects that enumeration running (vs a gated skip when the dir is unchanged). + // Why: the expensive per-entry `index` read stays gated on each entry's own dir signature; onFullScan + // now reflects an ungated index-metadata backstop fan-out (forceFullScan) — the real periodic cost — + // rather than the always-run worktrees-dir readdir. return { worktreesDirSignature, entries, primarySignatures, - didFullScan: shouldReadWorktreesDir + didFullScan: forceFullScan } } diff --git a/src/main/ipc/worktree-git-common-watch.test.ts b/src/main/ipc/worktree-git-common-watch.test.ts index f37bd0839b2..f37fe816827 100644 --- a/src/main/ipc/worktree-git-common-watch.test.ts +++ b/src/main/ipc/worktree-git-common-watch.test.ts @@ -304,7 +304,10 @@ describe('worktree git-common polling gate (non-darwin)', () => { cleanups.push(() => watch.unsubscribe()) } - it('skips worktrees readdir and entry metadata fan-out on idle ticks', async () => { + it('skips the ungated index-metadata backstop on idle ticks', async () => { + // Why: idle ticks still re-stat structural leaves and list the (small) worktrees dir cheaply, but the + // heavier ungated per-entry index fan-out (onFullScan) must NOT run until the backstop — and no + // spurious events are emitted while nothing changes. const commonDir = await makePollingCommonDir() const entry = join(commonDir, 'worktrees', 'idle') await mkdir(join(entry, 'logs'), { recursive: true }) @@ -320,7 +323,10 @@ describe('worktree git-common polling gate (non-darwin)', () => { expect(received.flat()).toHaveLength(0) }) - it('fans out when linked worktrees are added and removed', async () => { + it('detects linked worktree add and remove from the every-tick readdir', async () => { + // Why: the worktrees-dir listing runs every tick (not gated on its stat signature), so an add/remove + // surfaces within one poll interval even on a coarse-mtime filesystem whose dir signature would not + // move — without waiting on the index backstop (onFullScan). const commonDir = await makePollingCommonDir() const received: WorktreeBasePollEvent[][] = [] const fullScans = vi.fn() @@ -331,20 +337,17 @@ describe('worktree git-common polling gate (non-darwin)', () => { await vi.waitFor(() => { expect(received.flat()).toContainEqual({ type: 'create', path: entry }) }) - const scansAfterAdd = fullScans.mock.calls.length - expect(scansAfterAdd).toBeGreaterThan(0) await rm(entry, { recursive: true }) await vi.waitFor(() => { expect(received.flat()).toContainEqual({ type: 'delete', path: entry }) }) - expect(fullScans.mock.calls.length).toBeGreaterThan(scansAfterAdd) }) - it('detects an in-place structural (HEAD) write on a known entry every tick, without a readdir', async () => { + it('detects an in-place structural (HEAD) write on a known entry every tick, without the index backstop', async () => { // Why: a raw HEAD/gitdir/config.worktree rewrite does not bump the entry-dir mtime, so the // structural leaves are re-stat'd every tick (never gated) — the change surfaces within one tick - // and does NOT require the gated worktrees-dir readdir (onFullScan). + // and does NOT require the ungated index-metadata backstop (onFullScan). const commonDir = await makePollingCommonDir() const entry = join(commonDir, 'worktrees', 'structural') await mkdir(entry)