mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
* perf(git): make local Git metadata observation event-driven Replaces the recurring per-repo metadata scan with native filesystem events on macOS, Linux, and Windows. Polling is retained purely as a fallback. - Narrow @parcel/watcher stream over <common>/worktrees, extended from macOS to Linux and Windows, with the Windows backend pinned explicitly. - New shallow watcher mode over the allowlisted primary metadata leaves. It watches the containing directory rather than each file, so Git's atomic write-and-rename does not orphan the binding. - Selected upstream refs stay on the existing bounded stat poll. Verified on real hosts rather than in principle: - Windows: `git worktree remove` and `git worktree prune` both succeed while the narrow stream holds the directory. The historical concern that an open handle would block prune does not reproduce. - Linux: inotify costs one instance per event loop, not one per watch, so the watch budget is not a constraint. - macOS/Linux/Windows: shallow events survive repeated commit, checkout, config, and pack-refs cycles. Failure handling, each reproduced before being fixed: - fs.watch binds an inode and reports nothing once that inode is replaced, with no error. Directory bindings are re-checked on a bounded cadence and rebound. - A host whose notification path is dead accepts registrations and stays mute forever. Observed on a macOS machine whose fseventsd had grown to ~15GB and saturated a core. A one-shot delivery probe now fails the shallow subscribe on such a host so it falls back to polling instead of showing stale metadata. This change stands alone on main and does not depend on the metadata poll scheduler. * test(git-watch): hold reserved inodes across root replacements Linux returns a released reservation to the free list, so the second replacement could land back on the first replacement's inode and look unchanged to reconciliation. Verified on ext4: releasing yields inodes [N, N+43, N+43] while holding yields [N, N+43, N+44]. macOS never recycles, which is why this only failed on CI. * refactor(git-watch): share one single-flight helper between watcher fallbacks Both fallbacks tracked their in-flight promise with the same self-comparison on settle, duplicated verbatim. Hoisting it removes a subtle invariant that was being hand-maintained in two places. * fix(git-watch): close the silent-staleness paths in primary metadata Two independent reviews converged on the same root cause: nothing bounded how long primary metadata could stay wrong once the shallow watcher stopped reporting. Four distinct paths led there. - A terminal watch error arriving while the status-ref poll was still starting left the repo with status-ref coverage only. handleWatcherError ran its teardown against nulls, then the in-flight poll installed itself, and the fallback guard mistook it for coverage and discarded the fallback. Primary metadata was then never observed again. The guard no longer treats status-ref polling as primary coverage, and startup re-checks watcher liveness after its awaits. - Nothing re-read the six primary files while the watcher was nominally live. A lossy notification path, a dropped batch, or inotify queue overflow raises no error, so the error-driven fallback never fired and the inode rebind sweep does not detect loss. A 15-tick backstop re-stats them, turning permanent staleness into one tick. Measured cost is ~0.2 stats/s/repo against the 3/s the old poll cost. - Reconciliation treated any late-observed entry create as a root replacement, so an ordinary tore down a healthy stream ~30s later and opened a deaf window. It now also requires the root itself to be recreated. - The shallow watcher recorded directory identity from a stat issued after binding, so a replacement in that gap pinned the dead inode's watcher to the new identity and the sweep would never rebind. Identity is now read first, which errs toward a harmless extra rebind. Test helper: replacing the worktrees root frees several inodes at once, so reserving one still let the recreated root reuse its own. It now verifies the inode actually changed. Confirmed on ext4, where three holds were needed.
129 lines
4.3 KiB
TypeScript
129 lines
4.3 KiB
TypeScript
import { join } from 'node:path'
|
|
import type { WorktreeBasePollEvent } from './worktree-base-directory-poller'
|
|
import type { GitCommonEntrySnapshot } from './worktree-git-common-entry-snapshot'
|
|
|
|
const LINKED_WORKTREE_INDEX_FILE = 'index'
|
|
const LINKED_WORKTREE_HEAD_LOG_FILE = join('logs', 'HEAD')
|
|
|
|
export type GitCommonSnapshot = {
|
|
worktreesDirSignature: string
|
|
worktreesDirIdentity: string | null
|
|
entries: Map<string, GitCommonEntrySnapshot>
|
|
primarySignatures: Map<string, string>
|
|
statusRefPaths: Set<string>
|
|
statusRefSignatures: Map<string, string>
|
|
didFullScan: boolean
|
|
}
|
|
|
|
// Why: inode identity distinguishes "worktrees/ was replaced" (prune + re-add
|
|
// recreates the dir) from "its contents changed". The narrow watcher's stream is
|
|
// bound to the old inode and is deaf after a swap, so it must resubscribe.
|
|
export function gitCommonDirectoryIdentity(signature: string): string | null {
|
|
if (signature === 'missing') {
|
|
return null
|
|
}
|
|
const sizeSeparator = signature.lastIndexOf(':')
|
|
const inodeSeparator = signature.lastIndexOf(':', sizeSeparator - 1)
|
|
return inodeSeparator === -1 ? signature : signature.slice(inodeSeparator + 1, sizeSeparator)
|
|
}
|
|
|
|
function classifySignatureDiff(
|
|
prevSignature: string | null | undefined,
|
|
nextSignature: string | null | undefined
|
|
): 'create' | 'update' | 'delete' | null {
|
|
if (prevSignature == null && nextSignature == null) {
|
|
return null
|
|
}
|
|
if (prevSignature == null) {
|
|
return 'create'
|
|
}
|
|
if (nextSignature == null) {
|
|
return 'delete'
|
|
}
|
|
return prevSignature === nextSignature ? null : 'update'
|
|
}
|
|
|
|
function diffSignatureMaps(
|
|
prev: Map<string, string>,
|
|
next: Map<string, string>,
|
|
resolvePath: (name: string) => string
|
|
): WorktreeBasePollEvent[] {
|
|
const events: WorktreeBasePollEvent[] = []
|
|
const names = new Set([...prev.keys(), ...next.keys()])
|
|
for (const name of names) {
|
|
const type = classifySignatureDiff(prev.get(name), next.get(name))
|
|
if (type) {
|
|
events.push({ type, path: resolvePath(name) })
|
|
}
|
|
}
|
|
return events
|
|
}
|
|
|
|
export function diffGitCommon(
|
|
commonDirPath: string,
|
|
prev: GitCommonSnapshot,
|
|
next: GitCommonSnapshot
|
|
): WorktreeBasePollEvent[] {
|
|
const events: WorktreeBasePollEvent[] = []
|
|
const worktreesDir = join(commonDirPath, 'worktrees')
|
|
const rootWasReplaced =
|
|
prev.worktreesDirIdentity !== null &&
|
|
next.worktreesDirIdentity !== null &&
|
|
prev.worktreesDirIdentity !== next.worktreesDirIdentity
|
|
if (rootWasReplaced) {
|
|
events.push({ type: 'delete', path: worktreesDir }, { type: 'create', path: worktreesDir })
|
|
} else {
|
|
const worktreesDirDiff = classifySignatureDiff(
|
|
prev.worktreesDirSignature === 'missing' ? null : prev.worktreesDirSignature,
|
|
next.worktreesDirSignature === 'missing' ? null : next.worktreesDirSignature
|
|
)
|
|
if (worktreesDirDiff) {
|
|
events.push({ type: worktreesDirDiff, path: worktreesDir })
|
|
}
|
|
}
|
|
for (const [entryPath, entry] of next.entries) {
|
|
const prevEntry = prev.entries.get(entryPath)
|
|
if (!prevEntry) {
|
|
events.push({ type: 'create', path: entryPath })
|
|
continue
|
|
}
|
|
events.push(
|
|
...diffSignatureMaps(prevEntry.structuralSignatures, entry.structuralSignatures, (name) =>
|
|
join(entryPath, name)
|
|
)
|
|
)
|
|
const indexDiff = classifySignatureDiff(prevEntry.indexSignature, entry.indexSignature)
|
|
if (indexDiff) {
|
|
events.push({ type: indexDiff, path: join(entryPath, LINKED_WORKTREE_INDEX_FILE) })
|
|
}
|
|
const headLogDiff = classifySignatureDiff(prevEntry.headLogSignature, entry.headLogSignature)
|
|
if (headLogDiff) {
|
|
events.push({ type: headLogDiff, path: join(entryPath, LINKED_WORKTREE_HEAD_LOG_FILE) })
|
|
}
|
|
}
|
|
for (const entryPath of prev.entries.keys()) {
|
|
if (!next.entries.has(entryPath)) {
|
|
events.push({ type: 'delete', path: entryPath })
|
|
}
|
|
}
|
|
events.push(
|
|
...diffSignatureMaps(prev.primarySignatures, next.primarySignatures, (name) =>
|
|
join(commonDirPath, name)
|
|
)
|
|
)
|
|
for (const path of next.statusRefPaths) {
|
|
// A newly selected ref is a baseline change, not a filesystem event.
|
|
if (!prev.statusRefPaths.has(path)) {
|
|
continue
|
|
}
|
|
const type = classifySignatureDiff(
|
|
prev.statusRefSignatures.get(path),
|
|
next.statusRefSignatures.get(path)
|
|
)
|
|
if (type) {
|
|
events.push({ type, path })
|
|
}
|
|
}
|
|
return events
|
|
}
|