mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
Make local Git metadata observation event-driven
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
# Git Metadata Event-Driven Refresh
|
||||
|
||||
## Problem
|
||||
|
||||
Orca currently polls Git metadata targets on a short interval. A Git-common target can stat six primary metadata files, read `.git/worktrees`, and stat approximately six metadata paths per linked worktree on each poll. The cost grows with the number of linked worktrees and the number of watched repositories, even when nothing changed.
|
||||
|
||||
This recurring work competes with renderer updates, Git subprocesses, and IPC notifications. It contributes to input lag and delayed worktree refreshes.
|
||||
|
||||
## Decision summary
|
||||
|
||||
Use the existing native filesystem watcher dependency for narrow local Git metadata roots. Keep polling only for:
|
||||
|
||||
- watcher failure or overflow;
|
||||
- filesystems without reliable native events;
|
||||
- SSH/WSL providers without an event stream;
|
||||
- periodic missed-event reconciliation.
|
||||
|
||||
Do not use a content-search process for Git metadata observation. Content search and filesystem observation have different requirements.
|
||||
|
||||
The healthy local path should be event-driven:
|
||||
|
||||
```text
|
||||
native event -> path classification -> merged target invalidation -> targeted refresh
|
||||
```
|
||||
|
||||
The fallback path should be bounded:
|
||||
|
||||
```text
|
||||
watcher failure/backstop -> reconciliation queue -> bounded snapshot
|
||||
```
|
||||
|
||||
## Existing Orca foundation
|
||||
|
||||
Orca already has the core pieces needed for the first implementation:
|
||||
|
||||
- A native watcher dependency is already a production dependency.
|
||||
- Native watcher subscriptions run in a crash-isolated child process.
|
||||
- Watcher event delivery has active/pending batch limits and overflow signaling.
|
||||
- Darwin already has a narrow `.git/worktrees` watcher.
|
||||
- Watcher failure can fall back to the existing metadata poller.
|
||||
- Root deletion/recreation and hidden-window parking are already handled.
|
||||
- Renderer notifications already debounce changes for 250 ms.
|
||||
|
||||
Relevant modules:
|
||||
|
||||
- `src/main/ipc/worktree-git-common-watch.ts`
|
||||
- `src/main/ipc/parcel-watcher-process.ts`
|
||||
- `src/main/ipc/parcel-watcher-process-entry.ts`
|
||||
- `src/main/ipc/parcel-watcher-event-delivery.ts`
|
||||
- `src/main/ipc/worktree-git-common-watch-reconciliation.ts`
|
||||
- `src/main/ipc/worktree-git-common-polling.ts`
|
||||
- `src/main/ipc/worktree-base-directory-notifications.ts`
|
||||
|
||||
## Native watcher capabilities
|
||||
|
||||
The current native dependency provides:
|
||||
|
||||
- recursive create/update/delete events;
|
||||
- platform-native backends for macOS, Linux, and Windows;
|
||||
- ignore paths and ignore globs;
|
||||
- event coalescing in the native layer;
|
||||
- historical event queries using saved snapshots;
|
||||
- overflow/error reporting.
|
||||
|
||||
The current API is recursive. It does not provide a nonrecursive directory watcher through the existing subscription wrapper. That distinction matters for primary common-directory metadata: recursively watching the entire common `.git` directory would include objects and unrelated reference churn.
|
||||
|
||||
The upstream API does not currently expose a `recursive: false` subscription mode. Its public type surface accepts the watch root, callback, ignore options, and backend selection, but no recursion flag. A nonrecursive-mode request remains an open upstream design issue.
|
||||
|
||||
The candidate runtime's `node:fs.watch` implementation does provide a shallow directory watch by default and accepts an abort signal. That is useful for the primary common-directory files, but the current desktop main process runs inside Electron's Node runtime. Adopting that runtime only for one watcher would add another packaged process without improving the recursive watcher path. If the application runtime moves later, the same shallow-watch contract can be used inside the existing watcher child; it should not block this design.
|
||||
|
||||
## Target architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Local recursive watcher: common/.git/worktrees] --> C[Git metadata path classifier]
|
||||
B[Local primary metadata observer] --> C
|
||||
R[SSH/WSL provider events] --> C
|
||||
C --> D[Per-target invalidation queue]
|
||||
D --> E[One in-flight refresh plus one trailing refresh]
|
||||
E --> F[Targeted Git metadata snapshot]
|
||||
F --> G[Scoped renderer notification]
|
||||
H[Reconciliation and fallback polling] --> D
|
||||
```
|
||||
|
||||
### Watch scopes
|
||||
|
||||
#### Linked worktree metadata
|
||||
|
||||
Watch recursively:
|
||||
|
||||
```text
|
||||
<common-dir>/.git/worktrees
|
||||
```
|
||||
|
||||
Classify events by path:
|
||||
|
||||
```text
|
||||
<name>/HEAD
|
||||
<name>/gitdir
|
||||
<name>/locked
|
||||
<name>/config.worktree
|
||||
-> structure/head-identity invalidation
|
||||
|
||||
<name>/index
|
||||
<name>/logs/HEAD
|
||||
-> status invalidation
|
||||
|
||||
<name> creation/deletion
|
||||
-> worktree-list invalidation
|
||||
```
|
||||
|
||||
This scope excludes the working tree and the common Git object database.
|
||||
|
||||
#### Primary checkout metadata
|
||||
|
||||
Observe only:
|
||||
|
||||
```text
|
||||
HEAD
|
||||
packed-refs
|
||||
index
|
||||
config
|
||||
config.worktree
|
||||
logs/HEAD
|
||||
```
|
||||
|
||||
Preferred implementation: a nonrecursive watcher in the crash-isolated watcher child, attached to the common Git directory and filtered to these names.
|
||||
|
||||
Fallback implementation: retain the existing six-file stat poll.
|
||||
|
||||
#### Selected upstream refs
|
||||
|
||||
Continue observing only the selected upstream ref paths. Do not recursively watch all refs unless a consumer explicitly requires that behavior.
|
||||
|
||||
## Refresh coalescing
|
||||
|
||||
Key refresh state by execution host and repository/common-directory identity. Each target owns:
|
||||
|
||||
- current watcher state;
|
||||
- one in-flight refresh;
|
||||
- one merged trailing request;
|
||||
- last successful snapshot;
|
||||
- next reconciliation deadline;
|
||||
- active-consumer count.
|
||||
|
||||
Merge invalidations by strength:
|
||||
|
||||
```text
|
||||
worktree structure/full refresh > status refresh > ref-only refresh
|
||||
```
|
||||
|
||||
A new event while a refresh is running must not start another concurrent refresh. It updates the trailing request instead.
|
||||
|
||||
Internal Orca mutations should enqueue the same semantic invalidation directly rather than waiting for the filesystem watcher to observe Orca's own write.
|
||||
|
||||
## Failure and recovery
|
||||
|
||||
A watcher failure must not silently freeze metadata.
|
||||
|
||||
Required behavior:
|
||||
|
||||
1. Mark the watcher unhealthy.
|
||||
2. Preserve the last known snapshot.
|
||||
3. Schedule bounded fallback reconciliation.
|
||||
4. Retry native subscription with backoff.
|
||||
5. Resync after reattachment.
|
||||
6. Return to event-driven mode only after a successful subscription.
|
||||
|
||||
Root deletion is not equivalent to watcher failure. A deleted `.git/worktrees` directory should:
|
||||
|
||||
- emit the appropriate structural invalidation;
|
||||
- release the old subscription;
|
||||
- watch for directory recreation;
|
||||
- reattach when the directory returns.
|
||||
|
||||
Overflow is a correctness signal. On overflow, discard assumptions about missed paths and run a full target reconciliation.
|
||||
|
||||
## Platform matrix
|
||||
|
||||
| Execution location | Healthy observation | Fallback |
|
||||
| ------------------ | ----------------------------------------------------------- | ------------------------------------------------- |
|
||||
| macOS local | Existing narrow watcher plus primary observer | Existing poller and reconciliation |
|
||||
| Linux local | Narrow watcher after inotify/watch-count validation | Bounded poller and reconciliation |
|
||||
| Windows local | Narrow watcher after active `git worktree prune` validation | Bounded poller and reconciliation |
|
||||
| SSH | Provider-owned remote watch when available | Provider-scoped fallback polling |
|
||||
| WSL | WSL-side observation when available | WSL-scoped fallback polling |
|
||||
| Folder workspace | Existing folder watcher behavior | Explicit refresh or bounded folder reconciliation |
|
||||
|
||||
No local watcher or local content-search process may be used as a substitute for remote filesystem observation.
|
||||
|
||||
## Scope split
|
||||
|
||||
### Watcher implementation
|
||||
|
||||
- Use the existing narrow watcher for macOS, Linux, and Windows.
|
||||
- Pass the Windows backend explicitly to avoid backend probing noise.
|
||||
- Watch the six primary metadata paths shallowly in the crash-isolated child.
|
||||
- Poll only selected upstream ref paths on the healthy local path.
|
||||
- Keep six-file polling for watcher failure and reconciliation.
|
||||
- Keep the current structural fallback poller and reconciliation.
|
||||
- Use the full poller on unsupported local platforms.
|
||||
- Validate root deletion/recreation, overflow, watcher failure, and active `git worktree prune`.
|
||||
|
||||
The implementation contract is intentionally small:
|
||||
|
||||
```text
|
||||
darwin/linux/win32 -> watcher child: <common>/.git/worktrees
|
||||
darwin/linux/win32 -> watcher child: selected primary metadata leaves
|
||||
darwin/linux/win32 -> bounded poll: selected upstream refs only
|
||||
other local host -> existing bounded metadata poller
|
||||
watcher failure -> bounded metadata poller and reconciliation
|
||||
```
|
||||
|
||||
The watcher child remains the only owner of native subscriptions. The main
|
||||
process only receives normalized events and owns fallback scheduling. No remote
|
||||
wire message or renderer contract changes in this slice.
|
||||
|
||||
This removes recurring linked-worktree scans and primary metadata stats from the
|
||||
healthy local path without introducing a new remote protocol.
|
||||
|
||||
### Separate refresh-admission implementation
|
||||
|
||||
- Measure downstream `git status`, `git for-each-ref`, and `git worktree` fanout.
|
||||
- Add one-in-flight plus one-trailing refresh admission per target.
|
||||
- Add foreground priority only if measurements show background Git refreshes competing with active worktree interaction.
|
||||
|
||||
Do not combine this with the first watcher-platform change unless the measurements require it.
|
||||
|
||||
## Performance expectations
|
||||
|
||||
For a Git-common target with `E` linked worktrees, the current recurring work is approximately:
|
||||
|
||||
```text
|
||||
6 × E linked-worktree stats + 6 primary stats + one worktrees listing every 2 seconds
|
||||
```
|
||||
|
||||
With a healthy narrow linked-worktree watcher:
|
||||
|
||||
```text
|
||||
6 primary stats every 2 seconds + targeted work only after events
|
||||
```
|
||||
|
||||
Approximate reduction in recurring local metadata operations:
|
||||
|
||||
| Linked worktrees | Reduction |
|
||||
| ---------------: | --------: |
|
||||
| 1 | ~50% |
|
||||
| 5 | ~84% |
|
||||
| 10 | ~91% |
|
||||
| 50 | ~98% |
|
||||
| 100 | ~99% |
|
||||
|
||||
These are filesystem-operation reductions, not guaranteed UI-latency reductions. Git subprocess and renderer invalidation costs must be measured separately.
|
||||
|
||||
## Instrumentation
|
||||
|
||||
Add counters before changing behavior:
|
||||
|
||||
- watcher events received by target and platform;
|
||||
- watcher overflows and failures;
|
||||
- fallback poll ticks;
|
||||
- full reconciliations;
|
||||
- stat/readdir operations by target;
|
||||
- refresh requests merged while in flight;
|
||||
- Git subprocesses started per target;
|
||||
- active and pending refresh counts;
|
||||
- event-to-notification latency.
|
||||
|
||||
The key success condition for the first implementation is:
|
||||
|
||||
```text
|
||||
healthy local watcher + idle repository -> no recurring linked-worktree snapshot scans
|
||||
```
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Replacing Git commands with content search.
|
||||
- Recursively watching an entire working tree.
|
||||
- Recursively watching the common Git object database.
|
||||
- Adding a local watcher for SSH or WSL paths.
|
||||
- Removing fallback polling before native watcher recovery is proven.
|
||||
- Changing remote wire protocols in the first implementation.
|
||||
- Optimizing Settings rendering before background metadata load is bounded.
|
||||
@@ -1,14 +1,17 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { statMock, subscribeMock, writeFileSyncMock } = vi.hoisted(() => ({
|
||||
const { statMock, subscribeMock, watchMock, writeFileSyncMock } = vi.hoisted(() => ({
|
||||
statMock: vi.fn(),
|
||||
subscribeMock: vi.fn(),
|
||||
watchMock: vi.fn(),
|
||||
writeFileSyncMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
mkdtempSync: vi.fn(() => '/tmp/orca-watcher-canary-test'),
|
||||
rmSync: vi.fn(),
|
||||
watch: watchMock,
|
||||
writeFileSync: writeFileSyncMock
|
||||
}))
|
||||
vi.mock('node:fs/promises', () => ({ stat: statMock }))
|
||||
@@ -25,6 +28,7 @@ describe('parcel watcher process canary', () => {
|
||||
vi.resetModules()
|
||||
subscribeMock.mockReset()
|
||||
statMock.mockReset()
|
||||
watchMock.mockReset()
|
||||
writeFileSyncMock.mockReset()
|
||||
originalMessageListeners = process.listeners('message')
|
||||
originalExitListeners = process.listeners('exit')
|
||||
@@ -53,6 +57,49 @@ describe('parcel watcher process canary', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('hosts shallow subscriptions without invoking the recursive native watcher', async () => {
|
||||
const watcherCallbacks = new Map<
|
||||
string,
|
||||
(eventType: string, fileName: string | Buffer | null) => void
|
||||
>()
|
||||
watchMock.mockImplementation(
|
||||
(
|
||||
path: string,
|
||||
_options: unknown,
|
||||
callback: (eventType: string, fileName: string | Buffer | null) => void
|
||||
) => {
|
||||
watcherCallbacks.set(path, callback)
|
||||
const watcher = new EventEmitter() as EventEmitter & { close: () => void }
|
||||
watcher.close = () => watcher.emit('close')
|
||||
return watcher
|
||||
}
|
||||
)
|
||||
const sendMock = vi.fn()
|
||||
process.send = sendMock as typeof process.send
|
||||
|
||||
await import('./parcel-watcher-process-entry')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
process.emit('message', {
|
||||
op: 'subscribe',
|
||||
id: 1,
|
||||
dir: '/repo/.git',
|
||||
opts: { mode: 'shallow', include: ['HEAD'] }
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(subscribeMock).toHaveBeenCalledTimes(1)
|
||||
watcherCallbacks.get('/repo/.git')?.('change', 'HEAD')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
{
|
||||
op: 'events',
|
||||
id: 1,
|
||||
events: [{ type: 'update', path: '/repo/.git/HEAD' }]
|
||||
},
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('does not restart while a native subscription is still crawling', async () => {
|
||||
subscribeMock
|
||||
.mockResolvedValueOnce({ unsubscribe: vi.fn() })
|
||||
|
||||
@@ -8,6 +8,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type * as ParcelWatcher from '@parcel/watcher'
|
||||
import { startShallowWatcher } from './parcel-watcher-shallow-subscription'
|
||||
import {
|
||||
createWatcherProcessEventDeliveryQueue,
|
||||
type WatcherProcessEventDeliveryQueue
|
||||
@@ -23,6 +24,10 @@ function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
|
||||
type WatcherSubscription = {
|
||||
unsubscribe: () => Promise<void>
|
||||
}
|
||||
|
||||
// Canary self-check. Why: @parcel/watcher can wedge silently — a lock-order
|
||||
// inversion between Debounce::notify (debounce mutex → watcher mutex) and
|
||||
// Watcher teardown (watcher mutex → debounce mutex in ~Watcher) deadlocks the
|
||||
@@ -35,9 +40,9 @@ const CANARY_EVENT_TIMEOUT_MS = 5_000
|
||||
const CANARY_MAX_MISSES = 2
|
||||
|
||||
async function startCanary(getStableActivityRevision: () => number | null): Promise<void> {
|
||||
const configuredCanaryDir = process.env.ORCA_WATCHER_CANARY_DIR
|
||||
let canaryDir: string
|
||||
let lastEventAt = 0
|
||||
const configuredCanaryDir = process.env.ORCA_WATCHER_CANARY_DIR
|
||||
try {
|
||||
canaryDir = configuredCanaryDir ?? mkdtempSync(join(tmpdir(), 'orca-watcher-canary-'))
|
||||
const watcher = await import('@parcel/watcher')
|
||||
@@ -140,7 +145,7 @@ function main(): void {
|
||||
// Subscribe promises are kept (not just subscriptions) so an unsubscribe
|
||||
// that races a still-crawling subscribe awaits it instead of leaking the
|
||||
// native handle — on Windows a leaked handle keeps the worktree dir locked.
|
||||
const subscriptions = new Map<number, Promise<ParcelWatcher.AsyncSubscription | null>>()
|
||||
const subscriptions = new Map<number, Promise<WatcherSubscription | null>>()
|
||||
const liveSubscriptionIds = new Set<number>()
|
||||
const eventDeliveries = new Map<number, WatcherProcessEventDeliveryQueue>()
|
||||
let pendingSubscriptionCrawls = 0
|
||||
@@ -172,7 +177,7 @@ function main(): void {
|
||||
dir: string,
|
||||
opts: WatcherProcessSubscribeOptions,
|
||||
delivery: WatcherProcessDeliveryOptions | undefined
|
||||
): Promise<ParcelWatcher.AsyncSubscription | null> => {
|
||||
): Promise<WatcherSubscription | null> => {
|
||||
const eventDelivery = createWatcherProcessEventDeliveryQueue(
|
||||
delivery,
|
||||
async (events) => {
|
||||
@@ -194,6 +199,11 @@ function main(): void {
|
||||
beginSubscriptionCrawl()
|
||||
send({ op: 'subscribe-started', id })
|
||||
try {
|
||||
if (opts.mode === 'shallow') {
|
||||
return startShallowWatcher(dir, opts.include ?? [], eventDelivery.enqueue, (error) =>
|
||||
send({ op: 'watch-error', id, message: errorMessage(error) })
|
||||
)
|
||||
}
|
||||
const watcher = await import('@parcel/watcher')
|
||||
return await watcher.subscribe(
|
||||
dir,
|
||||
|
||||
@@ -8,6 +8,8 @@ export type WatcherProcessSubscribeOptions = {
|
||||
ignore?: string[]
|
||||
ignoreGlobs?: string[]
|
||||
backend?: string
|
||||
mode?: 'recursive' | 'shallow'
|
||||
include?: string[]
|
||||
}
|
||||
|
||||
export type WatcherProcessDeliveryOptions = {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { watcherState } = vi.hoisted(() => ({
|
||||
watcherState: new Map<
|
||||
string,
|
||||
{
|
||||
callback: (eventType: string, fileName: string | Buffer | null) => void
|
||||
watcher: EventEmitter & { close: () => void }
|
||||
}
|
||||
>()
|
||||
}))
|
||||
|
||||
vi.mock('node:fs', async () => {
|
||||
const actual = await vi.importActual('node:fs')
|
||||
return {
|
||||
...actual,
|
||||
watch: vi.fn(
|
||||
(
|
||||
path: string,
|
||||
_options: unknown,
|
||||
callback: (eventType: string, fileName: string | Buffer | null) => void
|
||||
) => {
|
||||
const watcher = new EventEmitter() as EventEmitter & { close: () => void }
|
||||
watcher.close = () => watcher.emit('close')
|
||||
watcherState.set(path, { callback, watcher })
|
||||
return watcher
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
import { startShallowWatcher } from './parcel-watcher-shallow-subscription'
|
||||
|
||||
function emit(path: string, fileName: string): void {
|
||||
watcherState.get(path)?.callback('change', fileName)
|
||||
}
|
||||
|
||||
describe('shallow watcher subscription', () => {
|
||||
beforeEach(() => {
|
||||
watcherState.clear()
|
||||
})
|
||||
|
||||
it('emits only included primary files, including an existing nested directory', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-shallow-watcher-'))
|
||||
try {
|
||||
await mkdir(join(root, 'logs'))
|
||||
const events: string[] = []
|
||||
const { promise, resolve } = Promise.withResolvers<void>()
|
||||
const subscription = startShallowWatcher(
|
||||
root,
|
||||
['HEAD', 'config', 'logs/HEAD'],
|
||||
(nextEvents) => {
|
||||
events.push(...nextEvents.map((event) => event.path))
|
||||
if (
|
||||
events.includes(join(root, 'config')) &&
|
||||
events.includes(join(root, 'logs', 'HEAD'))
|
||||
) {
|
||||
resolve()
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
throw error
|
||||
}
|
||||
)
|
||||
|
||||
emit(root, 'config')
|
||||
emit(join(root, 'logs'), 'HEAD')
|
||||
await promise
|
||||
|
||||
expect(events).toContain(join(root, 'config'))
|
||||
expect(events).toContain(join(root, 'logs', 'HEAD'))
|
||||
expect(events).not.toContain(join(root, 'unrelated'))
|
||||
await subscription.unsubscribe()
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not forward events after unsubscribe', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-shallow-watcher-'))
|
||||
try {
|
||||
const events: string[] = []
|
||||
const subscription = startShallowWatcher(
|
||||
root,
|
||||
['HEAD'],
|
||||
(nextEvents) => events.push(...nextEvents.map((event) => event.path)),
|
||||
(error) => {
|
||||
throw error
|
||||
}
|
||||
)
|
||||
await subscription.unsubscribe()
|
||||
emit(root, 'HEAD')
|
||||
const { promise, resolve } = Promise.withResolvers<void>()
|
||||
setImmediate(resolve)
|
||||
await promise
|
||||
expect(events).toEqual([])
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import { watch, type FSWatcher } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type { Event as ParcelWatcherEvent } from '@parcel/watcher'
|
||||
|
||||
export type ShallowWatcherSubscription = {
|
||||
unsubscribe: () => Promise<void>
|
||||
}
|
||||
|
||||
function closeFileSystemWatcher(watcher: FSWatcher): Promise<void> {
|
||||
const { promise, resolve } = Promise.withResolvers<void>()
|
||||
watcher.once('close', resolve)
|
||||
try {
|
||||
watcher.close()
|
||||
} catch {
|
||||
resolve()
|
||||
}
|
||||
return promise
|
||||
}
|
||||
|
||||
export function startShallowWatcher(
|
||||
rootPath: string,
|
||||
relativePaths: readonly string[],
|
||||
onEvents: (events: ParcelWatcherEvent[]) => void,
|
||||
onError: (error: Error) => void
|
||||
): ShallowWatcherSubscription {
|
||||
const pathsByDirectory = new Map<string, Set<string>>()
|
||||
for (const relativePath of relativePaths) {
|
||||
const parts = relativePath.split(/[\\/]+/).filter(Boolean)
|
||||
const fileName = parts.pop()
|
||||
if (!fileName) {
|
||||
continue
|
||||
}
|
||||
const parent = parts.join('/')
|
||||
const fileNames = pathsByDirectory.get(parent) ?? new Set<string>()
|
||||
fileNames.add(fileName)
|
||||
pathsByDirectory.set(parent, fileNames)
|
||||
}
|
||||
|
||||
const watchers = new Map<string, FSWatcher>()
|
||||
let disposed = false
|
||||
let reportedError = false
|
||||
|
||||
const reportError = (error: unknown): void => {
|
||||
if (disposed || reportedError) {
|
||||
return
|
||||
}
|
||||
reportedError = true
|
||||
onError(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
|
||||
const emitUpdates = (parent: string, fileNames: Iterable<string>): void => {
|
||||
onEvents(
|
||||
[...fileNames].map((fileName) => ({
|
||||
type: 'update' as const,
|
||||
path: join(rootPath, parent, fileName)
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
const watchDirectory = (parent: string, fileNames: Set<string>): void => {
|
||||
if (watchers.has(parent)) {
|
||||
return
|
||||
}
|
||||
const directoryPath = join(rootPath, parent)
|
||||
try {
|
||||
const watcher = watch(directoryPath, { persistent: false }, (_eventType, fileName) => {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
const name = fileName?.toString()
|
||||
if (!name) {
|
||||
emitUpdates(parent, fileNames)
|
||||
return
|
||||
}
|
||||
if (parent === '' && pathsByDirectory.has(name)) {
|
||||
const nestedNames = pathsByDirectory.get(name)
|
||||
if (nestedNames) {
|
||||
watchDirectory(name, nestedNames)
|
||||
emitUpdates(name, nestedNames)
|
||||
}
|
||||
}
|
||||
if (fileNames.has(name)) {
|
||||
emitUpdates(parent, [name])
|
||||
}
|
||||
})
|
||||
watcher.on('error', reportError)
|
||||
watchers.set(parent, watcher)
|
||||
} catch (error) {
|
||||
// Nested metadata directories may not exist until Git creates them.
|
||||
if (parent === '') {
|
||||
reportError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [parent, fileNames] of pathsByDirectory) {
|
||||
watchDirectory(parent, fileNames)
|
||||
}
|
||||
|
||||
return {
|
||||
unsubscribe: async () => {
|
||||
disposed = true
|
||||
await Promise.all([...watchers.values()].map(closeFileSystemWatcher))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,12 +18,15 @@ type PrimaryMetadataSnapshot = {
|
||||
|
||||
async function snapshotPrimaryCheckoutMetadata(
|
||||
commonDirPath: string,
|
||||
getStatusRefPaths: () => readonly string[]
|
||||
getStatusRefPaths: () => readonly string[],
|
||||
includePrimary: boolean
|
||||
): Promise<PrimaryMetadataSnapshot> {
|
||||
const signatures = new Map<string, string>()
|
||||
const statusRefPaths = new Set(getStatusRefPaths())
|
||||
const paths = [
|
||||
...PRIMARY_CHECKOUT_METADATA_FILES.map((name) => join(commonDirPath, name)),
|
||||
...(includePrimary
|
||||
? PRIMARY_CHECKOUT_METADATA_FILES.map((name) => join(commonDirPath, name))
|
||||
: []),
|
||||
...statusRefPaths
|
||||
]
|
||||
await Promise.all(
|
||||
@@ -82,18 +85,29 @@ export async function startGitCommonPrimaryPolling(
|
||||
onEvents: (events: WorktreeBasePollEvent[]) => void,
|
||||
pollIntervalMs: number,
|
||||
visibility: WorktreePollerWindowVisibility,
|
||||
onFullScan?: () => void
|
||||
onFullScan?: () => void,
|
||||
includePrimary = true
|
||||
): Promise<WorktreeBaseSubscription> {
|
||||
let disposed = false
|
||||
let snapshot = await snapshotPrimaryCheckoutMetadata(commonDirPath, getStatusRefPaths)
|
||||
let snapshot = await snapshotPrimaryCheckoutMetadata(
|
||||
commonDirPath,
|
||||
getStatusRefPaths,
|
||||
includePrimary
|
||||
)
|
||||
const schedule = gitMetadataPollScheduler.schedule({
|
||||
key: `git-common-primary:${commonDirPath}`,
|
||||
intervalMs: pollIntervalMs,
|
||||
visibility,
|
||||
run: async () => {
|
||||
onFullScan?.()
|
||||
if (includePrimary) {
|
||||
onFullScan?.()
|
||||
}
|
||||
try {
|
||||
const next = await snapshotPrimaryCheckoutMetadata(commonDirPath, getStatusRefPaths)
|
||||
const next = await snapshotPrimaryCheckoutMetadata(
|
||||
commonDirPath,
|
||||
getStatusRefPaths,
|
||||
includePrimary
|
||||
)
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { join } from 'node:path'
|
||||
import { subscribeViaWatcherProcess } from './parcel-watcher-process'
|
||||
import type { WatcherProcessSubscription } from './parcel-watcher-process-subscription'
|
||||
import type {
|
||||
WorktreeBasePollEvent,
|
||||
WorktreeBaseSubscription,
|
||||
WorktreePollerWindowVisibility
|
||||
} from './worktree-base-directory-poller'
|
||||
import { PRIMARY_CHECKOUT_METADATA_FILES } from './worktree-git-common-polling'
|
||||
import { startGitCommonPrimaryPolling } from './worktree-git-common-primary-polling'
|
||||
|
||||
const PRIMARY_WATCH_OPTIONS = {
|
||||
mode: 'shallow' as const,
|
||||
include: PRIMARY_CHECKOUT_METADATA_FILES
|
||||
}
|
||||
|
||||
function primaryMetadataEvents(commonDirPath: string): WorktreeBasePollEvent[] {
|
||||
return PRIMARY_CHECKOUT_METADATA_FILES.map((name) => ({
|
||||
type: 'update',
|
||||
path: join(commonDirPath, name)
|
||||
}))
|
||||
}
|
||||
|
||||
export async function startGitCommonPrimaryWatch(
|
||||
commonDirPath: string,
|
||||
getStatusRefPaths: () => readonly string[],
|
||||
onEvents: (events: WorktreeBasePollEvent[]) => void,
|
||||
pollIntervalMs: number,
|
||||
visibility: WorktreePollerWindowVisibility,
|
||||
onFullScan?: () => void,
|
||||
onWatchError?: (error: Error) => void
|
||||
): Promise<WorktreeBaseSubscription> {
|
||||
let disposed = false
|
||||
let watcher: WatcherProcessSubscription | null = null
|
||||
let statusRefPolling: WorktreeBaseSubscription | null = null
|
||||
let fallback: WorktreeBaseSubscription | null = null
|
||||
let fallbackPromise: Promise<void> | null = null
|
||||
|
||||
const startFallback = (): Promise<void> => {
|
||||
if (fallbackPromise) {
|
||||
return fallbackPromise
|
||||
}
|
||||
const pending = startGitCommonPrimaryPolling(
|
||||
commonDirPath,
|
||||
getStatusRefPaths,
|
||||
onEvents,
|
||||
pollIntervalMs,
|
||||
visibility,
|
||||
onFullScan
|
||||
).then(async (nextFallback) => {
|
||||
if (disposed || watcher || statusRefPolling) {
|
||||
await nextFallback.unsubscribe()
|
||||
return
|
||||
}
|
||||
fallback = nextFallback
|
||||
})
|
||||
const tracked = pending.finally(() => {
|
||||
if (fallbackPromise === tracked) {
|
||||
fallbackPromise = null
|
||||
}
|
||||
})
|
||||
fallbackPromise = tracked
|
||||
return fallbackPromise
|
||||
}
|
||||
|
||||
const stopStatusRefPolling = async (): Promise<void> => {
|
||||
const current = statusRefPolling
|
||||
statusRefPolling = null
|
||||
if (current) {
|
||||
await current.unsubscribe().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
const handleWatcherError = (error: Error): void => {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
onWatchError?.(error)
|
||||
if (!onWatchError) {
|
||||
onEvents(primaryMetadataEvents(commonDirPath))
|
||||
}
|
||||
const current = watcher
|
||||
watcher = null
|
||||
if (current) {
|
||||
void current.unsubscribe().catch(() => {})
|
||||
}
|
||||
void stopStatusRefPolling()
|
||||
.then(() => startFallback())
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
try {
|
||||
watcher = await subscribeViaWatcherProcess(
|
||||
commonDirPath,
|
||||
(error, events) => {
|
||||
if (error) {
|
||||
handleWatcherError(error)
|
||||
return
|
||||
}
|
||||
if (events.length > 0) {
|
||||
onEvents(events.map((event) => ({ type: event.type, path: event.path })))
|
||||
}
|
||||
},
|
||||
PRIMARY_WATCH_OPTIONS,
|
||||
{
|
||||
onInterruption: () => {
|
||||
if (!disposed) {
|
||||
const error = new Error('Git primary metadata watcher interrupted')
|
||||
if (onWatchError) {
|
||||
onWatchError(error)
|
||||
} else {
|
||||
onEvents(primaryMetadataEvents(commonDirPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
if (watcher && !disposed && !fallbackPromise) {
|
||||
statusRefPolling = await startGitCommonPrimaryPolling(
|
||||
commonDirPath,
|
||||
getStatusRefPaths,
|
||||
onEvents,
|
||||
pollIntervalMs,
|
||||
visibility,
|
||||
undefined,
|
||||
false
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
handleWatcherError(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
|
||||
return {
|
||||
unsubscribe: async () => {
|
||||
disposed = true
|
||||
const current = watcher
|
||||
watcher = null
|
||||
if (current) {
|
||||
await current.unsubscribe().catch(() => {})
|
||||
}
|
||||
await stopStatusRefPolling()
|
||||
await fallbackPromise?.catch(() => {})
|
||||
if (fallback) {
|
||||
await fallback.unsubscribe().catch(() => {})
|
||||
fallback = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,7 @@ type ChildSubscription = {
|
||||
unsubscribe: ReturnType<typeof vi.fn<() => Promise<void>>>
|
||||
}
|
||||
|
||||
describe('worktree git-common narrow watch (darwin)', () => {
|
||||
describe('worktree git-common narrow watch (local native platforms)', () => {
|
||||
const cleanups: (() => Promise<void>)[] = []
|
||||
const subscribeMock = vi.mocked(subscribeViaWatcherProcess)
|
||||
let childSubscriptions: ChildSubscription[] = []
|
||||
@@ -120,6 +120,26 @@ describe('worktree git-common narrow watch (darwin)', () => {
|
||||
})
|
||||
}
|
||||
|
||||
function narrowSubscriptions(): ChildSubscription[] {
|
||||
return childSubscriptions.filter((subscription) => subscription.dir.endsWith('worktrees'))
|
||||
}
|
||||
|
||||
function narrowSubscription(): ChildSubscription {
|
||||
const subscription = narrowSubscriptions()[0]
|
||||
if (!subscription) {
|
||||
throw new Error('narrow watcher subscription not installed')
|
||||
}
|
||||
return subscription
|
||||
}
|
||||
|
||||
function primarySubscription(): ChildSubscription {
|
||||
const subscription = childSubscriptions.find((item) => !item.dir.endsWith('worktrees'))
|
||||
if (!subscription) {
|
||||
throw new Error('primary watcher subscription not installed')
|
||||
}
|
||||
return subscription
|
||||
}
|
||||
|
||||
async function makeCommonDir(withWorktrees: boolean): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-git-common-watch-'))
|
||||
cleanups.push(() => rm(root, { recursive: true, force: true }))
|
||||
@@ -143,13 +163,14 @@ describe('worktree git-common narrow watch (darwin)', () => {
|
||||
commonDir: string,
|
||||
received: WorktreeBasePollEvent[][],
|
||||
getStatusRefPaths: () => readonly string[] = () => [],
|
||||
onWatchError?: (error: Error) => void
|
||||
onWatchError?: (error: Error) => void,
|
||||
platform: NodeJS.Platform = 'darwin'
|
||||
): Promise<void> {
|
||||
const watch = await startGitCommonWatch(
|
||||
makeTarget(commonDir),
|
||||
(events) => received.push(events),
|
||||
POLL_MS,
|
||||
'darwin',
|
||||
platform,
|
||||
alwaysVisible,
|
||||
undefined,
|
||||
getStatusRefPaths,
|
||||
@@ -164,20 +185,38 @@ describe('worktree git-common narrow watch (darwin)', () => {
|
||||
const received: WorktreeBasePollEvent[][] = []
|
||||
await startWatch(commonDir, received)
|
||||
|
||||
expect(subscribeMock).toHaveBeenCalledTimes(1)
|
||||
const [dir, , opts] = subscribeMock.mock.calls[0]
|
||||
expect(dir).toBe(join(commonDir, 'worktrees'))
|
||||
expect(opts).toEqual({})
|
||||
expect(subscribeMock).toHaveBeenCalledTimes(2)
|
||||
const narrowCall = subscribeMock.mock.calls.find(
|
||||
([dir]) => dir === join(commonDir, 'worktrees')
|
||||
)
|
||||
expect(narrowCall?.[0]).toBe(join(commonDir, 'worktrees'))
|
||||
expect(narrowCall?.[2]).toEqual({})
|
||||
|
||||
const entryPath = join(commonDir, 'worktrees', 'wt-a')
|
||||
childSubscriptions[0].callback(null, [{ type: 'create', path: entryPath }])
|
||||
narrowSubscription().callback(null, [{ type: 'create', path: entryPath }])
|
||||
expect(received.flat()).toContainEqual({ type: 'create', path: entryPath })
|
||||
})
|
||||
|
||||
it('detects a common config write via the primary-metadata poll', async () => {
|
||||
it.each(['linux', 'win32'] as const)(
|
||||
'uses the crash-isolated narrow stream on %s',
|
||||
async (platform) => {
|
||||
installSubscribeMock()
|
||||
const commonDir = await makeCommonDir(true)
|
||||
const received: WorktreeBasePollEvent[][] = []
|
||||
await startWatch(commonDir, received, undefined, undefined, platform)
|
||||
|
||||
expect(subscribeMock).toHaveBeenCalledTimes(2)
|
||||
const narrowCall = subscribeMock.mock.calls.find(([dir]) => dir.endsWith('worktrees'))
|
||||
expect(narrowCall?.[2]).toEqual(platform === 'win32' ? { backend: 'windows' } : {})
|
||||
const entryPath = join(commonDir, 'worktrees', `${platform}-entry`)
|
||||
narrowSubscription().callback(null, [{ type: 'create', path: entryPath }])
|
||||
expect(received.flat()).toContainEqual({ type: 'create', path: entryPath })
|
||||
}
|
||||
)
|
||||
|
||||
it('detects a common config write via the shallow primary-metadata watcher', async () => {
|
||||
// Why: an external `git push -u` rewrites only the common config (plus
|
||||
// remote-tracking refs) — outside the narrow worktrees/ stream, so the
|
||||
// primary-metadata poll must carry it.
|
||||
// remote-tracking refs) — outside the narrow worktrees/ stream.
|
||||
installSubscribeMock()
|
||||
const commonDir = await makeCommonDir(true)
|
||||
const configPath = join(commonDir, 'config')
|
||||
@@ -186,11 +225,31 @@ describe('worktree git-common narrow watch (darwin)', () => {
|
||||
await startWatch(commonDir, received)
|
||||
|
||||
await appendFile(configPath, '[branch "main"]\n\tremote = origin\n')
|
||||
primarySubscription().callback(null, [{ type: 'update', path: configPath }])
|
||||
await vi.waitFor(() => {
|
||||
expect(received.flat()).toContainEqual({ type: 'update', path: configPath })
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves primary metadata stats to the shallow child on the healthy path', async () => {
|
||||
installSubscribeMock()
|
||||
const commonDir = await makeCommonDir(true)
|
||||
const headPath = join(commonDir, 'HEAD')
|
||||
const configPath = join(commonDir, 'config')
|
||||
await Promise.all([
|
||||
writeFile(headPath, 'ref: refs/heads/main'),
|
||||
writeFile(configPath, '[core]\n')
|
||||
])
|
||||
statCalls.length = 0
|
||||
|
||||
const received: WorktreeBasePollEvent[][] = []
|
||||
await startWatch(commonDir, received)
|
||||
|
||||
expect(statCalls).not.toContain(headPath)
|
||||
expect(statCalls).not.toContain(configPath)
|
||||
expect(primarySubscription().dir).toBe(commonDir)
|
||||
})
|
||||
|
||||
it('polls only the accepted upstream ref and rebinds without synthetic events', async () => {
|
||||
installSubscribeMock()
|
||||
const commonDir = await makeCommonDir(true)
|
||||
@@ -234,16 +293,16 @@ describe('worktree git-common narrow watch (darwin)', () => {
|
||||
await startWatch(commonDir, received)
|
||||
|
||||
await rm(worktreesDir, { recursive: true, force: true })
|
||||
childSubscriptions[0].callback(null, [{ type: 'delete', path: worktreesDir }])
|
||||
narrowSubscription().callback(null, [{ type: 'delete', path: worktreesDir }])
|
||||
await vi.waitFor(() => {
|
||||
expect(childSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1)
|
||||
expect(narrowSubscription().unsubscribe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(received.flat()).toContainEqual({ type: 'delete', path: worktreesDir })
|
||||
|
||||
// The existence poll re-subscribes once a new first worktree recreates it.
|
||||
await mkdir(worktreesDir)
|
||||
await vi.waitFor(() => {
|
||||
expect(subscribeMock).toHaveBeenCalledTimes(2)
|
||||
expect(subscribeMock).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
expect(received.flat()).toContainEqual({ type: 'create', path: worktreesDir })
|
||||
})
|
||||
@@ -255,28 +314,28 @@ describe('worktree git-common narrow watch (darwin)', () => {
|
||||
const received: WorktreeBasePollEvent[][] = []
|
||||
await startWatch(commonDir, received)
|
||||
|
||||
childSubscriptions[0].callback(new Error('watcher child reported failure'), [])
|
||||
narrowSubscription().callback(new Error('watcher child reported failure'), [])
|
||||
await vi.waitFor(() => {
|
||||
expect(childSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1)
|
||||
expect(narrowSubscription().unsubscribe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
// The error is surfaced as a structural change so worktrees re-sync.
|
||||
expect(received.flat()).toContainEqual({ type: 'update', path: worktreesDir })
|
||||
|
||||
// The dir still exists, so the existence poll re-subscribes.
|
||||
await vi.waitFor(() => {
|
||||
expect(subscribeMock).toHaveBeenCalledTimes(2)
|
||||
expect(subscribeMock).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
const receivedAfterRearm = received.length
|
||||
childSubscriptions[0].callback(new Error('late error from replaced watcher'), [])
|
||||
childSubscriptions[0].callback(null, [
|
||||
narrowSubscription().callback(new Error('late error from replaced watcher'), [])
|
||||
narrowSubscription().callback(null, [
|
||||
{ type: 'create', path: join(worktreesDir, 'late-old-event') }
|
||||
])
|
||||
childSubscriptions[0].hooks.onInterruption?.()
|
||||
narrowSubscription().hooks.onInterruption?.()
|
||||
|
||||
// A replaced watch cannot tear down its successor or report stale events.
|
||||
expect(received).toHaveLength(receivedAfterRearm)
|
||||
expect(childSubscriptions[1].unsubscribe).not.toHaveBeenCalled()
|
||||
expect(narrowSubscriptions()[1].unsubscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes watcher failures through the unknown-metadata callback', async () => {
|
||||
@@ -287,7 +346,7 @@ describe('worktree git-common narrow watch (darwin)', () => {
|
||||
await startWatch(commonDir, received, () => [], onWatchError)
|
||||
|
||||
const error = new Error('watcher child reported failure')
|
||||
childSubscriptions[0].callback(error, [])
|
||||
narrowSubscription().callback(error, [])
|
||||
|
||||
expect(onWatchError).toHaveBeenCalledWith(error)
|
||||
expect(received).toEqual([])
|
||||
@@ -307,7 +366,7 @@ describe('worktree git-common narrow watch (darwin)', () => {
|
||||
visibility.source
|
||||
)
|
||||
|
||||
childSubscriptions[0].callback(
|
||||
narrowSubscription().callback(
|
||||
new WatcherProcessFailure(
|
||||
'watcher process crashed repeatedly',
|
||||
'supervisor',
|
||||
@@ -316,7 +375,7 @@ describe('worktree git-common narrow watch (darwin)', () => {
|
||||
[]
|
||||
)
|
||||
await vi.waitFor(() => {
|
||||
expect(childSubscriptions[0].unsubscribe).toHaveBeenCalledOnce()
|
||||
expect(narrowSubscription().unsubscribe).toHaveBeenCalledOnce()
|
||||
expect(visibility.listenerCount()).toBe(3)
|
||||
})
|
||||
|
||||
@@ -329,7 +388,7 @@ describe('worktree git-common narrow watch (darwin)', () => {
|
||||
await vi.waitFor(() => {
|
||||
expect(received.flat()).toContainEqual({ type: 'delete', path: entryPath })
|
||||
})
|
||||
expect(subscribeMock).toHaveBeenCalledOnce()
|
||||
expect(subscribeMock).toHaveBeenCalledTimes(2)
|
||||
await watch.unsubscribe()
|
||||
expect(visibility.listenerCount()).toBe(0)
|
||||
})
|
||||
@@ -356,7 +415,7 @@ describe('worktree git-common narrow watch (darwin)', () => {
|
||||
expect(received.flat()).toContainEqual({ type: 'create', path: entryPath })
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2))
|
||||
expect(subscribeMock).toHaveBeenCalledOnce()
|
||||
expect(subscribeMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
await watch.unsubscribe()
|
||||
expect(visibility.listenerCount()).toBe(0)
|
||||
@@ -369,10 +428,10 @@ describe('worktree git-common narrow watch (darwin)', () => {
|
||||
const received: WorktreeBasePollEvent[][] = []
|
||||
await startWatch(commonDir, received)
|
||||
|
||||
childSubscriptions[0].hooks.onInterruption?.()
|
||||
narrowSubscription().hooks.onInterruption?.()
|
||||
expect(received.flat()).toContainEqual({ type: 'update', path: worktreesDir })
|
||||
// The supervisor resubscribed the same record; no teardown should happen.
|
||||
expect(childSubscriptions[0].unsubscribe).not.toHaveBeenCalled()
|
||||
expect(narrowSubscription().unsubscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('arms via existence polling when the worktrees dir appears later', async () => {
|
||||
@@ -380,7 +439,7 @@ describe('worktree git-common narrow watch (darwin)', () => {
|
||||
const commonDir = await makeCommonDir(false)
|
||||
const received: WorktreeBasePollEvent[][] = []
|
||||
await startWatch(commonDir, received)
|
||||
expect(subscribeMock).not.toHaveBeenCalled()
|
||||
expect(subscribeMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
await mkdir(join(commonDir, 'worktrees'))
|
||||
await vi.waitFor(() => {
|
||||
@@ -418,7 +477,7 @@ describe('worktree git-common narrow watch (darwin)', () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_MS * 4))
|
||||
|
||||
expect(statCalls.filter((path) => path === worktreesDir)).toHaveLength(0)
|
||||
expect(subscribeMock).not.toHaveBeenCalled()
|
||||
expect(subscribeMock).toHaveBeenCalledTimes(1)
|
||||
expect(received.flat()).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -429,13 +488,13 @@ describe('worktree git-common narrow watch (darwin)', () => {
|
||||
|
||||
await mkdir(worktreesDir)
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_MS * 4))
|
||||
expect(subscribeMock).not.toHaveBeenCalled()
|
||||
expect(subscribeMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
visibility.show()
|
||||
// Promptly: the re-check stats on show, not a poll interval later.
|
||||
expect(statCalls.filter((path) => path === worktreesDir)).toHaveLength(1)
|
||||
await vi.waitFor(() => {
|
||||
expect(subscribeMock).toHaveBeenCalledTimes(1)
|
||||
expect(subscribeMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
expect(received.flat()).toContainEqual({ type: 'create', path: worktreesDir })
|
||||
})
|
||||
@@ -469,7 +528,7 @@ describe('worktree git-common narrow watch (darwin)', () => {
|
||||
|
||||
await mkdir(worktreesDir)
|
||||
await vi.waitFor(() => {
|
||||
expect(subscribeMock).toHaveBeenCalledTimes(1)
|
||||
expect(subscribeMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
expect(received.flat()).toContainEqual({ type: 'create', path: worktreesDir })
|
||||
})
|
||||
@@ -486,7 +545,7 @@ describe('worktree git-common narrow watch (darwin)', () => {
|
||||
visibility.source
|
||||
)
|
||||
|
||||
// Narrow watch + primary-metadata poll each park on window visibility.
|
||||
// Narrow existence + selected-ref polling each park on window visibility.
|
||||
expect(visibility.listenerCount()).toBe(2)
|
||||
await watch.unsubscribe()
|
||||
expect(visibility.listenerCount()).toBe(0)
|
||||
@@ -513,19 +572,16 @@ describe('worktree git-common narrow watch (darwin)', () => {
|
||||
visibility.hide()
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2))
|
||||
await writeFile(headFile, 'ref: refs/heads/feature')
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_MS * 2))
|
||||
primarySubscription().callback(null, [{ type: 'update', path: headFile }])
|
||||
|
||||
expect(fullScans).toHaveLength(0)
|
||||
const entryPath = join(commonDir, 'worktrees', 'native-while-hidden')
|
||||
childSubscriptions[0].callback(null, [{ type: 'create', path: entryPath }])
|
||||
narrowSubscription().callback(null, [{ type: 'create', path: entryPath }])
|
||||
expect(received.flat()).toContainEqual({ type: 'create', path: entryPath })
|
||||
expect(childSubscriptions[0].unsubscribe).not.toHaveBeenCalled()
|
||||
expect(narrowSubscription().unsubscribe).not.toHaveBeenCalled()
|
||||
|
||||
visibility.show()
|
||||
expect(fullScans).toHaveLength(1)
|
||||
await vi.waitFor(() => {
|
||||
expect(received.flat()).toContainEqual({ type: 'update', path: headFile })
|
||||
})
|
||||
expect(fullScans).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('re-arms the native stream when the root is replaced with the same child names', async () => {
|
||||
@@ -641,17 +697,17 @@ describe('worktree git-common narrow watch (darwin)', () => {
|
||||
alwaysVisible
|
||||
)
|
||||
await watch.unsubscribe()
|
||||
expect(childSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1)
|
||||
expect(narrowSubscription().unsubscribe).toHaveBeenCalledTimes(1)
|
||||
|
||||
received.length = 0
|
||||
childSubscriptions[0].callback(null, [
|
||||
narrowSubscription().callback(null, [
|
||||
{ type: 'create', path: join(commonDir, 'worktrees', 'late') }
|
||||
])
|
||||
expect(received).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('worktree git-common polling gate (non-darwin)', () => {
|
||||
describe('worktree git-common polling gate (unsupported platform)', () => {
|
||||
const cleanups: (() => Promise<void>)[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -686,7 +742,7 @@ describe('worktree git-common polling gate (non-darwin)', () => {
|
||||
makePollingTarget(commonDir),
|
||||
(events) => received.push(events),
|
||||
POLL_MS,
|
||||
'linux',
|
||||
'freebsd',
|
||||
visibility,
|
||||
onFullScan,
|
||||
getStatusRefPaths
|
||||
|
||||
@@ -5,8 +5,8 @@ import type {
|
||||
WorktreePollerWindowVisibility
|
||||
} from './worktree-base-directory-poller'
|
||||
import { startGitCommonNarrowWatch } from './worktree-git-common-narrow-watch'
|
||||
import { startGitCommonPrimaryWatch } from './worktree-git-common-primary-watch'
|
||||
import { startGitCommonPolling } from './worktree-git-common-polling'
|
||||
import { startGitCommonPrimaryPolling } from './worktree-git-common-primary-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
|
||||
@@ -29,28 +29,30 @@ export async function startGitCommonWatch(
|
||||
getStatusRefPaths: () => readonly string[] = () => [],
|
||||
onWatchError?: (error: Error) => void
|
||||
): Promise<WorktreeBaseSubscription> {
|
||||
if (platform === 'darwin') {
|
||||
const [narrowWatch, primaryMetadataPoll] = await Promise.all([
|
||||
if (supportsNarrowWatch(platform)) {
|
||||
const [narrowWatch, primaryWatch] = await Promise.all([
|
||||
startGitCommonNarrowWatch(
|
||||
target,
|
||||
onEvents,
|
||||
pollIntervalMs,
|
||||
platform,
|
||||
visibility,
|
||||
onFullScan,
|
||||
onWatchError
|
||||
),
|
||||
startGitCommonPrimaryPolling(
|
||||
startGitCommonPrimaryWatch(
|
||||
target.path,
|
||||
getStatusRefPaths,
|
||||
onEvents,
|
||||
pollIntervalMs,
|
||||
visibility,
|
||||
onFullScan
|
||||
onFullScan,
|
||||
onWatchError
|
||||
)
|
||||
])
|
||||
return {
|
||||
unsubscribe: async () => {
|
||||
await Promise.all([narrowWatch.unsubscribe(), primaryMetadataPoll.unsubscribe()])
|
||||
await Promise.all([narrowWatch.unsubscribe(), primaryWatch.unsubscribe()])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user