From 3a31b9e51210e4d75b0d41a9cf09eb4d09dfc279 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:05:14 -0700 Subject: [PATCH] Stack watcher changes after metadata scheduler --- .../worktree-base-directory-poller.test.ts | 32 +- .../ipc/worktree-git-common-narrow-watch.ts | 4 +- .../worktree-git-common-polling-gate.test.ts | 316 ++++++++++++++++++ .../worktree-git-common-primary-polling.ts | 5 +- .../ipc/worktree-git-common-watch.test.ts | 287 +--------------- src/main/ipc/worktree-git-common-watch.ts | 24 +- 6 files changed, 358 insertions(+), 310 deletions(-) create mode 100644 src/main/ipc/worktree-git-common-polling-gate.test.ts diff --git a/src/main/ipc/worktree-base-directory-poller.test.ts b/src/main/ipc/worktree-base-directory-poller.test.ts index 101d48e22cb..877a2bd0219 100644 --- a/src/main/ipc/worktree-base-directory-poller.test.ts +++ b/src/main/ipc/worktree-base-directory-poller.test.ts @@ -10,6 +10,7 @@ import { type WorktreeBasePollEvent, type WorktreePollerWindowVisibility } from './worktree-base-directory-poller' +import { startGitCommonPrimaryPolling } from './worktree-git-common-primary-polling' import type { WorktreeBaseRepoWatchConfig, WorktreeBaseWatchTarget @@ -401,7 +402,7 @@ describe('worktree base directory poller', () => { () => target.repos, (events) => received.push(events), // Force the non-darwin poll path so this test is deterministic on all CI. - { pollIntervalMs: POLL_MS, platform: 'linux' } + { pollIntervalMs: POLL_MS, platform: 'freebsd' } ) cleanups.push(() => poller.unsubscribe()) @@ -441,7 +442,7 @@ describe('worktree base directory poller', () => { target, () => target.repos, (events) => received.push(events), - { pollIntervalMs: POLL_MS, platform: 'linux' } + { pollIntervalMs: POLL_MS, platform: 'freebsd' } ) cleanups.push(() => poller.unsubscribe()) @@ -468,7 +469,7 @@ describe('worktree base directory poller', () => { target, () => target.repos, (events) => received.push(events), - { pollIntervalMs: POLL_MS, platform: 'linux' } + { pollIntervalMs: POLL_MS, platform: 'freebsd' } ) cleanups.push(() => poller.unsubscribe()) @@ -493,7 +494,7 @@ describe('worktree base directory poller', () => { target, () => target.repos, (events) => received.push(events), - { pollIntervalMs: POLL_MS, platform: 'linux' } + { pollIntervalMs: POLL_MS, platform: 'freebsd' } ) cleanups.push(() => poller.unsubscribe()) @@ -517,7 +518,7 @@ describe('worktree base directory poller', () => { target, () => target.repos, (events) => received.push(events), - { pollIntervalMs: POLL_MS, platform: 'linux' } + { pollIntervalMs: POLL_MS, platform: 'freebsd' } ) cleanups.push(() => poller.unsubscribe()) @@ -539,7 +540,7 @@ describe('worktree base directory poller', () => { () => target.repos, (events) => received.push(events), // Force the non-darwin poll path so this test is deterministic on all CI. - { pollIntervalMs: POLL_MS, platform: 'linux' } + { pollIntervalMs: POLL_MS, platform: 'freebsd' } ) cleanups.push(() => poller.unsubscribe()) @@ -571,7 +572,7 @@ describe('worktree base directory poller', () => { (events) => received.push(events), { pollIntervalMs: POLL_MS, - platform: 'linux', + platform: 'freebsd', visibility: visibility.source, onFullScan: () => fullScans.push(Date.now()) } @@ -644,28 +645,25 @@ describe('worktree base directory poller', () => { ) }) - it('covers primary-checkout metadata alongside the narrow stream', async () => { + it('covers primary-checkout metadata through its bounded fallback poll', async () => { const commonDir = await makeRoot() - await mkdir(join(commonDir, 'worktrees')) const received: WorktreeBasePollEvent[][] = [] - const target = makeTarget('git-common', commonDir) - const poller = await startWorktreeBaseDirectoryPoller( - target, - () => target.repos, + const visibility = createWorktreePollerWindowVisibility(() => null) + const poller = await startGitCommonPrimaryPolling( + commonDir, + () => [], (events) => received.push(events), - { pollIntervalMs: POLL_MS, platform: 'darwin' } + POLL_MS, + visibility ) cleanups.push(() => poller.unsubscribe()) - // The narrow stream is rooted at worktrees/, so top-level HEAD writes - // must arrive through the companion metadata poll. const headFile = join(commonDir, 'HEAD') await writeFile(headFile, 'ref: refs/heads/main') await waitForEvents(received, (flat) => flat.some((event) => event.type === 'create' && event.path === headFile) ) - await new Promise((resolve) => setTimeout(resolve, 10)) await writeFile(headFile, 'ref: refs/heads/feature') await waitForEvents(received, (flat) => flat.some((event) => event.type === 'update' && event.path === headFile) diff --git a/src/main/ipc/worktree-git-common-narrow-watch.ts b/src/main/ipc/worktree-git-common-narrow-watch.ts index a0cbd9c670b..f9ad20c3adc 100644 --- a/src/main/ipc/worktree-git-common-narrow-watch.ts +++ b/src/main/ipc/worktree-git-common-narrow-watch.ts @@ -20,11 +20,13 @@ export async function startGitCommonNarrowWatch( target: WorktreeBaseWatchTarget, onEvents: (events: WorktreeBasePollEvent[]) => void, pollIntervalMs: number, + platform: NodeJS.Platform, visibility: WorktreePollerWindowVisibility, onFullScan?: () => void, onWatchError?: (error: Error) => void ): Promise { const worktreesDir = join(target.path, 'worktrees') + const watcherOptions = platform === 'win32' ? { backend: 'windows' as const } : {} let disposed = false let subscription: WorktreeBaseSubscription | null = null let existenceTimer: ReturnType | null = null @@ -209,7 +211,7 @@ export async function startGitCommonNarrowWatch( } } }, - {}, + watcherOptions, { // Why: a watcher-child crash drops events during the automatic // resubscribe gap; report a structural change so worktrees re-sync. diff --git a/src/main/ipc/worktree-git-common-polling-gate.test.ts b/src/main/ipc/worktree-git-common-polling-gate.test.ts new file mode 100644 index 00000000000..41c13e4e655 --- /dev/null +++ b/src/main/ipc/worktree-git-common-polling-gate.test.ts @@ -0,0 +1,316 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { appendFile, mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises' +import { chmodSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { + WorktreeBasePollEvent, + WorktreePollerWindowVisibility +} from './worktree-base-directory-poller' +import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' +import { startGitCommonPolling } from './worktree-git-common-polling' +import { startGitCommonWatch } from './worktree-git-common-watch' + +const POLL_MS = 25 +const alwaysVisible: WorktreePollerWindowVisibility = { + isWindowVisible: () => true, + onWindowBecameVisible: () => () => {} +} + +type VisibilityHarness = { + source: WorktreePollerWindowVisibility + hide: () => void + show: () => void +} + +function createVisibilityHarness(initiallyVisible = true): VisibilityHarness { + let visible = initiallyVisible + let listener: (() => void) | null = null + return { + source: { + isWindowVisible: () => visible, + onWindowBecameVisible: (nextListener) => { + listener = nextListener + return () => { + if (listener === nextListener) { + listener = null + } + } + } + }, + hide: () => { + visible = false + }, + show: () => { + visible = true + listener?.() + } + } +} + +describe('worktree git-common polling gate (unsupported platform)', () => { + const cleanups: (() => Promise)[] = [] + + afterEach(async () => { + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())) + }) + + async function makePollingCommonDir(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-git-common-polling-')) + cleanups.push(() => rm(root, { recursive: true, force: true })) + const commonDir = await realpath(root) + await mkdir(join(commonDir, 'worktrees')) + return commonDir + } + + function makePollingTarget(path: string): WorktreeBaseWatchTarget { + return { + key: `git-common:local:${path}`, + kind: 'git-common', + path, + repos: new Map([['repo-1', { repoId: 'repo-1', repoName: 'project', nestWorkspaces: false }]]) + } + } + + async function startPollingWatch( + commonDir: string, + received: WorktreeBasePollEvent[][], + onFullScan?: () => void, + visibility: WorktreePollerWindowVisibility = alwaysVisible, + getStatusRefPaths: () => readonly string[] = () => [] + ): Promise { + const watch = await startGitCommonWatch( + makePollingTarget(commonDir), + (events) => received.push(events), + POLL_MS, + 'freebsd', + visibility, + onFullScan, + getStatusRefPaths + ) + cleanups.push(() => watch.unsubscribe()) + } + + 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 }) + await writeFile(join(entry, 'HEAD'), 'ref: refs/heads/main') + await writeFile(join(entry, 'logs', 'HEAD'), 'baseline\n') + const received: WorktreeBasePollEvent[][] = [] + const fullScans = vi.fn() + + await startPollingWatch(commonDir, received, fullScans) + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 6)) + + expect(fullScans).not.toHaveBeenCalled() + expect(received.flat()).toHaveLength(0) + }) + + it('forces the linked-index scan on every reconciliation tick', async () => { + const commonDir = await makePollingCommonDir() + const entry = join(commonDir, 'worktrees', 'reconciliation') + await mkdir(entry) + await writeFile(join(entry, 'HEAD'), 'ref: refs/heads/main') + await writeFile(join(entry, 'index'), 'baseline') + const fullScans = vi.fn() + const watch = await startGitCommonPolling( + commonDir, + () => {}, + 50, + alwaysVisible, + fullScans, + false, + () => [], + { forceFullScanEveryTick: true } + ) + cleanups.push(() => watch.unsubscribe()) + + // Real filesystem stats cannot be flushed by a fake clock. Two scans must + // arrive before the ordinary 15-tick (750ms) index backstop could fire. + await vi.waitFor( + () => { + expect(fullScans.mock.calls.length).toBeGreaterThanOrEqual(2) + }, + { timeout: 500 } + ) + }) + + 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() + await startPollingWatch(commonDir, received, fullScans) + + const entry = join(commonDir, 'worktrees', 'added') + await mkdir(entry) + await vi.waitFor(() => { + expect(received.flat()).toContainEqual({ type: 'create', path: entry }) + }) + // The add is caught by the every-tick listing, NOT the 15-tick index backstop: detection lands well + // before a backstop could fire, so onFullScan must not have run. (On the old gated impl a coarse-FS + // signature collision would have deferred this to the backstop.) + expect(fullScans).not.toHaveBeenCalled() + + await rm(entry, { recursive: true }) + await vi.waitFor(() => { + expect(received.flat()).toContainEqual({ type: 'delete', path: entry }) + }) + }) + + // Why runIf: chmod 0 cannot revoke directory listing on Windows or for root, so the EACCES injection is inert there. + it.runIf(process.platform !== 'win32' && process.getuid?.() !== 0)( + 'does not fabricate worktree deletions when the readdir fails non-ENOENT (transient)', + async () => { + // Why: a transient readdir failure (EIO/ESTALE/EMFILE/EACCES, network/SSH hiccup) must not be read + // as "every linked worktree removed". Revoke dir permissions so readdir throws EACCES; the known + // entry must NOT be reported deleted. On the old catch-all (entryPaths = []) this emitted a false + // delete for every entry. chmod (not a dir->file swap) because it is one atomic syscall: an + // in-flight tick's threadpool readdir sees success or EACCES, never a transient ENOENT window + // that would legitimately emit a delete and flake this assertion. + const commonDir = await makePollingCommonDir() + const entry = join(commonDir, 'worktrees', 'keep') + await mkdir(entry) + await writeFile(join(entry, 'HEAD'), 'ref: refs/heads/main') + const received: WorktreeBasePollEvent[][] = [] + await startPollingWatch(commonDir, received) + + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2)) + const worktreesDir = join(commonDir, 'worktrees') + chmodSync(worktreesDir, 0o000) + try { + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 4)) + } finally { + // Why: restore before cleanup so the afterEach recursive rm can traverse the dir. + chmodSync(worktreesDir, 0o755) + } + + expect(received.flat()).not.toContainEqual({ type: 'delete', path: entry }) + } + ) + + 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 ungated index-metadata backstop (onFullScan). + const commonDir = await makePollingCommonDir() + const entry = join(commonDir, 'worktrees', 'structural') + await mkdir(entry) + await writeFile(join(entry, 'HEAD'), 'ref: refs/heads/main') + const received: WorktreeBasePollEvent[][] = [] + const fullScans = vi.fn() + await startPollingWatch(commonDir, received, fullScans) + + const headPath = join(entry, 'HEAD') + // In-place rewrite: same file, different contents — no entry-dir mtime change. + await writeFile(headPath, 'ref: refs/heads/feature') + await vi.waitFor(() => { + expect(received.flat()).toContainEqual({ type: 'update', path: headPath }) + }) + expect(fullScans).not.toHaveBeenCalled() + }) + + it('polls linked logs/HEAD on every idle tick', async () => { + const commonDir = await makePollingCommonDir() + const entry = join(commonDir, 'worktrees', 'reflog') + await mkdir(join(entry, 'logs'), { recursive: true }) + const headLogPath = join(entry, 'logs', 'HEAD') + await writeFile(headLogPath, 'baseline\n') + const received: WorktreeBasePollEvent[][] = [] + const fullScans = vi.fn() + await startPollingWatch(commonDir, received, fullScans) + + await appendFile(headLogPath, 'next\n') + await vi.waitFor(() => { + expect(received.flat()).toContainEqual({ type: 'update', path: headLogPath }) + }) + expect(fullScans).not.toHaveBeenCalled() + }) + + it('polls the common config so an external push -u surfaces its new upstream', async () => { + // Why: `git push -u` from an external shell rewrites only the common + // config (plus remote-tracking refs); the primary-metadata list must + // surface it or the upstream stays invisible until a safety poll. + const commonDir = await makePollingCommonDir() + const configPath = join(commonDir, 'config') + await writeFile(configPath, '[core]\n\tbare = false\n') + const received: WorktreeBasePollEvent[][] = [] + const fullScans = vi.fn() + await startPollingWatch(commonDir, received, fullScans) + + await appendFile(configPath, '[branch "main"]\n\tremote = origin\n') + await vi.waitFor(() => { + expect(received.flat()).toContainEqual({ type: 'update', path: configPath }) + }) + expect(fullScans).not.toHaveBeenCalled() + }) + + it('detects create, update, and delete for one transient upstream ref', async () => { + const commonDir = await makePollingCommonDir() + const refPath = join(commonDir, 'refs', 'remotes', 'origin', 'feature', 'nested') + await mkdir(join(commonDir, 'refs', 'remotes', 'origin', 'feature'), { recursive: true }) + const received: WorktreeBasePollEvent[][] = [] + await startPollingWatch(commonDir, received, undefined, alwaysVisible, () => [refPath]) + + await writeFile(refPath, 'aaa\n') + await vi.waitFor(() => { + expect(received.flat()).toContainEqual({ type: 'create', path: refPath }) + }) + received.length = 0 + await appendFile(refPath, 'bbb\n') + await vi.waitFor(() => { + expect(received.flat()).toContainEqual({ type: 'update', path: refPath }) + }) + received.length = 0 + await rm(refPath) + await vi.waitFor(() => { + expect(received.flat()).toContainEqual({ type: 'delete', path: refPath }) + }) + }) + + it('forces a full scan on the 15-tick backstop', async () => { + const commonDir = await makePollingCommonDir() + const entry = join(commonDir, 'worktrees', 'backstop') + await mkdir(entry) + await writeFile(join(entry, 'index'), 'baseline') + const received: WorktreeBasePollEvent[][] = [] + const fullScans = vi.fn() + await startPollingWatch(commonDir, received, fullScans) + + await vi.waitFor(() => { + expect(fullScans).toHaveBeenCalledTimes(1) + }) + expect(received.flat()).toHaveLength(0) + }) + + it('forces a full fan-out when resuming after hidden', async () => { + const commonDir = await makePollingCommonDir() + const entry = join(commonDir, 'worktrees', 'resume') + await mkdir(entry) + const indexPath = join(entry, 'index') + await writeFile(indexPath, 'before') + const received: WorktreeBasePollEvent[][] = [] + const fullScans = vi.fn() + const visibility = createVisibilityHarness() + await startPollingWatch(commonDir, received, fullScans, visibility.source) + + visibility.hide() + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2)) + await writeFile(indexPath, 'after-longer') + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2)) + expect(fullScans).not.toHaveBeenCalled() + expect(received.flat()).toHaveLength(0) + + visibility.show() + await vi.waitFor(() => { + expect(received.flat()).toContainEqual({ type: 'update', path: indexPath }) + }) + expect(fullScans).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/ipc/worktree-git-common-primary-polling.ts b/src/main/ipc/worktree-git-common-primary-polling.ts index 1af5619e018..c060b07a920 100644 --- a/src/main/ipc/worktree-git-common-primary-polling.ts +++ b/src/main/ipc/worktree-git-common-primary-polling.ts @@ -1,9 +1,6 @@ import { stat } from 'node:fs/promises' import { join } from 'node:path' -import { - gitMetadataPollScheduler, - runGitMetadataFilesystemIo -} from './git-metadata-poll-scheduler' +import { gitMetadataPollScheduler, runGitMetadataFilesystemIo } from './git-metadata-poll-scheduler' import type { WorktreeBasePollEvent, WorktreeBaseSubscription, diff --git a/src/main/ipc/worktree-git-common-watch.test.ts b/src/main/ipc/worktree-git-common-watch.test.ts index 3e13538d33a..cea22f0b992 100644 --- a/src/main/ipc/worktree-git-common-watch.test.ts +++ b/src/main/ipc/worktree-git-common-watch.test.ts @@ -2,7 +2,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { appendFile, mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises' import type * as NodeFsPromises from 'node:fs/promises' import { performance as nodePerformance } from 'node:perf_hooks' -import { chmodSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { subscribeViaWatcherProcess } from './parcel-watcher-process' @@ -17,7 +16,6 @@ import type { WorktreePollerWindowVisibility } from './worktree-base-directory-poller' import { startGitCommonWatch } from './worktree-git-common-watch' -import { startGitCommonPolling } from './worktree-git-common-polling' vi.mock('./parcel-watcher-process', () => ({ subscribeViaWatcherProcess: vi.fn() @@ -597,11 +595,11 @@ describe('worktree git-common narrow watch (local native platforms)', () => { await mkdir(retainedEntry) const received: WorktreeBasePollEvent[][] = [] await startWatch(commonDir, received) - const staleSubscription = childSubscriptions[0] + const staleSubscription = narrowSubscription() await replaceWorktreesRoot(commonDir, worktreesDir, retainedEntry) await vi.advanceTimersByTimeAsync(POLL_MS * RECONCILIATION_TICKS * 4) - expect(subscribeMock).toHaveBeenCalledTimes(2) + expect(subscribeMock).toHaveBeenCalledTimes(3) expect(staleSubscription.unsubscribe).toHaveBeenCalledOnce() const beforeStaleEvent = received.length @@ -609,7 +607,7 @@ describe('worktree git-common narrow watch (local native platforms)', () => { expect(received).toHaveLength(beforeStaleEvent) const immediateEntry = join(worktreesDir, 'after-rearm') - childSubscriptions[1].callback(null, [{ type: 'create', path: immediateEntry }]) + narrowSubscriptions()[1].callback(null, [{ type: 'create', path: immediateEntry }]) expect(received.flat()).toContainEqual({ type: 'create', path: immediateEntry }) } finally { await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())) @@ -630,7 +628,7 @@ describe('worktree git-common narrow watch (local native platforms)', () => { subscribeMock.mockImplementation(async (dir, callback, _opts, hooks = {}) => { const unsubscribe = vi.fn(async () => {}) childSubscriptions.push({ dir, callback, hooks, unsubscribe }) - return childSubscriptions.length === 2 ? deferredSubscribe.promise : { unsubscribe } + return narrowSubscriptions().length === 2 ? deferredSubscribe.promise : { unsubscribe } }) const commonDir = await makeCommonDir(true) const worktreesDir = join(commonDir, 'worktrees') @@ -644,12 +642,12 @@ describe('worktree git-common narrow watch (local native platforms)', () => { await vi.advanceTimersByTimeAsync(POLL_MS * RECONCILIATION_TICKS * 4) await vi.waitFor( () => { - expect(subscribeMock).toHaveBeenCalledTimes(2) - expect(childSubscriptions[1]).toBeDefined() + expect(subscribeMock).toHaveBeenCalledTimes(3) + expect(narrowSubscriptions()[1]).toBeDefined() }, { timeout: 1_000 } ) - const stalePendingSubscription = childSubscriptions[1] + const stalePendingSubscription = narrowSubscriptions()[1] await replaceWorktreesRoot(commonDir, worktreesDir, retainedEntry) await vi.advanceTimersByTimeAsync(POLL_MS * RECONCILIATION_TICKS * 4) expect( @@ -669,14 +667,14 @@ describe('worktree git-common narrow watch (local native platforms)', () => { await vi.advanceTimersByTimeAsync(POLL_MS) await vi.waitFor( () => { - expect(subscribeMock).toHaveBeenCalledTimes(3) + expect(subscribeMock).toHaveBeenCalledTimes(4) expect(stalePendingSubscription.unsubscribe).toHaveBeenCalledOnce() }, { timeout: 1_000 } ) const immediateEntry = join(worktreesDir, 'current-generation') - childSubscriptions[2].callback(null, [{ type: 'create', path: immediateEntry }]) + narrowSubscriptions()[2].callback(null, [{ type: 'create', path: immediateEntry }]) expect(received.flat()).toContainEqual({ type: 'create', path: immediateEntry }) } finally { await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())) @@ -706,270 +704,3 @@ describe('worktree git-common narrow watch (local native platforms)', () => { expect(received).toHaveLength(0) }) }) - -describe('worktree git-common polling gate (unsupported platform)', () => { - const cleanups: (() => Promise)[] = [] - - afterEach(async () => { - await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())) - }) - - async function makePollingCommonDir(): Promise { - const root = await mkdtemp(join(tmpdir(), 'orca-git-common-polling-')) - cleanups.push(() => rm(root, { recursive: true, force: true })) - const commonDir = await realpath(root) - await mkdir(join(commonDir, 'worktrees')) - return commonDir - } - - function makePollingTarget(path: string): WorktreeBaseWatchTarget { - return { - key: `git-common:local:${path}`, - kind: 'git-common', - path, - repos: new Map([['repo-1', { repoId: 'repo-1', repoName: 'project', nestWorkspaces: false }]]) - } - } - - async function startPollingWatch( - commonDir: string, - received: WorktreeBasePollEvent[][], - onFullScan?: () => void, - visibility: WorktreePollerWindowVisibility = alwaysVisible, - getStatusRefPaths: () => readonly string[] = () => [] - ): Promise { - const watch = await startGitCommonWatch( - makePollingTarget(commonDir), - (events) => received.push(events), - POLL_MS, - 'freebsd', - visibility, - onFullScan, - getStatusRefPaths - ) - cleanups.push(() => watch.unsubscribe()) - } - - 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 }) - await writeFile(join(entry, 'HEAD'), 'ref: refs/heads/main') - await writeFile(join(entry, 'logs', 'HEAD'), 'baseline\n') - const received: WorktreeBasePollEvent[][] = [] - const fullScans = vi.fn() - - await startPollingWatch(commonDir, received, fullScans) - await new Promise((resolve) => setTimeout(resolve, POLL_MS * 6)) - - expect(fullScans).not.toHaveBeenCalled() - expect(received.flat()).toHaveLength(0) - }) - - it('forces the linked-index scan on every reconciliation tick', async () => { - const commonDir = await makePollingCommonDir() - const entry = join(commonDir, 'worktrees', 'reconciliation') - await mkdir(entry) - await writeFile(join(entry, 'HEAD'), 'ref: refs/heads/main') - await writeFile(join(entry, 'index'), 'baseline') - const fullScans = vi.fn() - const watch = await startGitCommonPolling( - commonDir, - () => {}, - 50, - alwaysVisible, - fullScans, - false, - () => [], - { forceFullScanEveryTick: true } - ) - cleanups.push(() => watch.unsubscribe()) - - // Real filesystem stats cannot be flushed by a fake clock. Two scans must - // arrive before the ordinary 15-tick (750ms) index backstop could fire. - await vi.waitFor( - () => { - expect(fullScans.mock.calls.length).toBeGreaterThanOrEqual(2) - }, - { timeout: 500 } - ) - }) - - 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() - await startPollingWatch(commonDir, received, fullScans) - - const entry = join(commonDir, 'worktrees', 'added') - await mkdir(entry) - await vi.waitFor(() => { - expect(received.flat()).toContainEqual({ type: 'create', path: entry }) - }) - // The add is caught by the every-tick listing, NOT the 15-tick index backstop: detection lands well - // before a backstop could fire, so onFullScan must not have run. (On the old gated impl a coarse-FS - // signature collision would have deferred this to the backstop.) - expect(fullScans).not.toHaveBeenCalled() - - await rm(entry, { recursive: true }) - await vi.waitFor(() => { - expect(received.flat()).toContainEqual({ type: 'delete', path: entry }) - }) - }) - - // Why runIf: chmod 0 cannot revoke directory listing on Windows or for root, so the EACCES injection is inert there. - it.runIf(process.platform !== 'win32' && process.getuid?.() !== 0)( - 'does not fabricate worktree deletions when the readdir fails non-ENOENT (transient)', - async () => { - // Why: a transient readdir failure (EIO/ESTALE/EMFILE/EACCES, network/SSH hiccup) must not be read - // as "every linked worktree removed". Revoke dir permissions so readdir throws EACCES; the known - // entry must NOT be reported deleted. On the old catch-all (entryPaths = []) this emitted a false - // delete for every entry. chmod (not a dir->file swap) because it is one atomic syscall: an - // in-flight tick's threadpool readdir sees success or EACCES, never a transient ENOENT window - // that would legitimately emit a delete and flake this assertion. - const commonDir = await makePollingCommonDir() - const entry = join(commonDir, 'worktrees', 'keep') - await mkdir(entry) - await writeFile(join(entry, 'HEAD'), 'ref: refs/heads/main') - const received: WorktreeBasePollEvent[][] = [] - await startPollingWatch(commonDir, received) - - await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2)) - const worktreesDir = join(commonDir, 'worktrees') - chmodSync(worktreesDir, 0o000) - try { - await new Promise((resolve) => setTimeout(resolve, POLL_MS * 4)) - } finally { - // Why: restore before cleanup so the afterEach recursive rm can traverse the dir. - chmodSync(worktreesDir, 0o755) - } - - expect(received.flat()).not.toContainEqual({ type: 'delete', path: entry }) - } - ) - - 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 ungated index-metadata backstop (onFullScan). - const commonDir = await makePollingCommonDir() - const entry = join(commonDir, 'worktrees', 'structural') - await mkdir(entry) - await writeFile(join(entry, 'HEAD'), 'ref: refs/heads/main') - const received: WorktreeBasePollEvent[][] = [] - const fullScans = vi.fn() - await startPollingWatch(commonDir, received, fullScans) - - const headPath = join(entry, 'HEAD') - // In-place rewrite: same file, different contents — no entry-dir mtime change. - await writeFile(headPath, 'ref: refs/heads/feature') - await vi.waitFor(() => { - expect(received.flat()).toContainEqual({ type: 'update', path: headPath }) - }) - expect(fullScans).not.toHaveBeenCalled() - }) - - it('polls linked logs/HEAD on every idle tick', async () => { - const commonDir = await makePollingCommonDir() - const entry = join(commonDir, 'worktrees', 'reflog') - await mkdir(join(entry, 'logs'), { recursive: true }) - const headLogPath = join(entry, 'logs', 'HEAD') - await writeFile(headLogPath, 'baseline\n') - const received: WorktreeBasePollEvent[][] = [] - const fullScans = vi.fn() - await startPollingWatch(commonDir, received, fullScans) - - await appendFile(headLogPath, 'next\n') - await vi.waitFor(() => { - expect(received.flat()).toContainEqual({ type: 'update', path: headLogPath }) - }) - expect(fullScans).not.toHaveBeenCalled() - }) - - it('polls the common config so an external push -u surfaces its new upstream', async () => { - // Why: `git push -u` from an external shell rewrites only the common - // config (plus remote-tracking refs); the primary-metadata list must - // surface it or the upstream stays invisible until a safety poll. - const commonDir = await makePollingCommonDir() - const configPath = join(commonDir, 'config') - await writeFile(configPath, '[core]\n\tbare = false\n') - const received: WorktreeBasePollEvent[][] = [] - const fullScans = vi.fn() - await startPollingWatch(commonDir, received, fullScans) - - await appendFile(configPath, '[branch "main"]\n\tremote = origin\n') - await vi.waitFor(() => { - expect(received.flat()).toContainEqual({ type: 'update', path: configPath }) - }) - expect(fullScans).not.toHaveBeenCalled() - }) - - it('detects create, update, and delete for one transient upstream ref', async () => { - const commonDir = await makePollingCommonDir() - const refPath = join(commonDir, 'refs', 'remotes', 'origin', 'feature', 'nested') - await mkdir(join(commonDir, 'refs', 'remotes', 'origin', 'feature'), { recursive: true }) - const received: WorktreeBasePollEvent[][] = [] - await startPollingWatch(commonDir, received, undefined, alwaysVisible, () => [refPath]) - - await writeFile(refPath, 'aaa\n') - await vi.waitFor(() => { - expect(received.flat()).toContainEqual({ type: 'create', path: refPath }) - }) - received.length = 0 - await appendFile(refPath, 'bbb\n') - await vi.waitFor(() => { - expect(received.flat()).toContainEqual({ type: 'update', path: refPath }) - }) - received.length = 0 - await rm(refPath) - await vi.waitFor(() => { - expect(received.flat()).toContainEqual({ type: 'delete', path: refPath }) - }) - }) - - it('forces a full scan on the 15-tick backstop', async () => { - const commonDir = await makePollingCommonDir() - const entry = join(commonDir, 'worktrees', 'backstop') - await mkdir(entry) - await writeFile(join(entry, 'index'), 'baseline') - const received: WorktreeBasePollEvent[][] = [] - const fullScans = vi.fn() - await startPollingWatch(commonDir, received, fullScans) - - await vi.waitFor(() => { - expect(fullScans).toHaveBeenCalledTimes(1) - }) - expect(received.flat()).toHaveLength(0) - }) - - it('forces a full fan-out when resuming after hidden', async () => { - const commonDir = await makePollingCommonDir() - const entry = join(commonDir, 'worktrees', 'resume') - await mkdir(entry) - const indexPath = join(entry, 'index') - await writeFile(indexPath, 'before') - const received: WorktreeBasePollEvent[][] = [] - const fullScans = vi.fn() - const visibility = createVisibilityHarness() - await startPollingWatch(commonDir, received, fullScans, visibility.source) - - visibility.hide() - await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2)) - await writeFile(indexPath, 'after-longer') - await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2)) - expect(fullScans).not.toHaveBeenCalled() - expect(received.flat()).toHaveLength(0) - - visibility.show() - await vi.waitFor(() => { - expect(received.flat()).toContainEqual({ type: 'update', path: indexPath }) - }) - expect(fullScans).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/main/ipc/worktree-git-common-watch.ts b/src/main/ipc/worktree-git-common-watch.ts index ad89ef1718f..9de2c8c3392 100644 --- a/src/main/ipc/worktree-git-common-watch.ts +++ b/src/main/ipc/worktree-git-common-watch.ts @@ -8,16 +8,20 @@ import { startGitCommonNarrowWatch } from './worktree-git-common-narrow-watch' import { startGitCommonPrimaryWatch } from './worktree-git-common-primary-watch' import { startGitCommonPolling } from './worktree-git-common-polling' -// Watches a repo's `/.git/worktrees` metadata plus the primary -// checkout's shallow branch/index files — the only paths the git-common event -// filter consumes. -// macOS: a narrow native stream rooted at `worktrees/` — a tiny, rare-churn -// tree — gives instant detection with zero idle cost and zero wide-scope -// fseventsd delivery; the primary files are covered by a few stat calls per -// tick (a native stream would have to span the whole common dir, objects -// included). Other platforms: dir-listing poll (no fseventsd to protect, and -// on Windows an open directory handle on `worktrees/` could interfere with -// `git worktree prune` removing it). +// Local macOS, Linux, and Windows use the crash-isolated native stream for the +// narrow `worktrees/` tree and primary metadata leaves. Selected upstream refs +// stay on the scheduler's bounded stat path. Unsupported platforms retain the +// full polling fallback. + +const NARROW_WATCH_PLATFORMS: Partial> = { + darwin: true, + linux: true, + win32: true +} + +function supportsNarrowWatch(platform: NodeJS.Platform): boolean { + return NARROW_WATCH_PLATFORMS[platform] === true +} export async function startGitCommonWatch( target: WorktreeBaseWatchTarget,