Stack watcher changes after metadata scheduler

This commit is contained in:
Neil
2026-08-24 20:05:14 -07:00
parent b69fa1e203
commit 3a31b9e512
6 changed files with 358 additions and 310 deletions
@@ -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)
@@ -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<WorktreeBaseSubscription> {
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<typeof setInterval> | 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.
@@ -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<void>)[] = []
afterEach(async () => {
await Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))
})
async function makePollingCommonDir(): Promise<string> {
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<void> {
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)
})
})
@@ -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,
+9 -278
View File
@@ -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<void>)[] = []
afterEach(async () => {
await Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))
})
async function makePollingCommonDir(): Promise<string> {
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<void> {
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)
})
})
+14 -10
View File
@@ -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 `<common>/.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<Record<NodeJS.Platform, true>> = {
darwin: true,
linux: true,
win32: true
}
function supportsNarrowWatch(platform: NodeJS.Platform): boolean {
return NARROW_WATCH_PLATFORMS[platform] === true
}
export async function startGitCommonWatch(
target: WorktreeBaseWatchTarget,