fix(main): enumerate git-common worktrees every tick to catch coarse-FS add/remove

The worktrees-dir readdir was gated on the dir's mtime:ctime:ino:size signature, so on
a coarse-mtime/FAT filesystem a same-granule add+remove (all four fields collide) went
undetected until the ~30s index backstop. A single readdir of a small dir is negligible
next to the per-entry structural stats that already run every tick, so always enumerate —
the listing is the authoritative add/remove signal. The expensive per-entry index read
stays gated on each entry's own dir signature; onFullScan now reflects the ungated
index-metadata backstop fan-out (the real periodic cost) rather than the readdir.

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