diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 8a2f63125..d2a8ed0ec 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-5t8wlskctuVk/xt8rAUDN9sexSimURt3it9mmMRXaUQ= +sha256-UunIQIS/Y2LhJWqhZMcU1CM9aVOwQUoKKR5z/kj+JDE= diff --git a/package-lock.json b/package-lock.json index aa30d82b8..b4ecfc8ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9509,6 +9509,280 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@parcel/watcher": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -22991,7 +23265,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -23045,7 +23318,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -37924,6 +38196,7 @@ "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", + "@parcel/watcher": "^2.6.0", "@xterm/headless": "^6.0.0", "ai": "5.0.78", "ajv": "^8.20.0", diff --git a/packages/server/package.json b/packages/server/package.json index 3267e60d7..964acd7a3 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -45,6 +45,7 @@ "typecheck": "tsgo -p tsconfig.server.typecheck.json --noEmit", "generate:config-schema": "tsx scripts/generate-config-schema.ts", "measure:agent-tools": "tsx scripts/measure-agent-tools-context.ts", + "measure:git-observation": "tsx scripts/measure-git-observation.ts", "speech:models": "tsx scripts/list-speech-models.ts", "speech:download": "tsx scripts/download-speech-models.ts", "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", @@ -75,6 +76,7 @@ "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", + "@parcel/watcher": "^2.6.0", "@xterm/headless": "^6.0.0", "ai": "5.0.78", "ajv": "^8.20.0", diff --git a/packages/server/scripts/measure-git-observation.ts b/packages/server/scripts/measure-git-observation.ts new file mode 100644 index 000000000..c501a333b --- /dev/null +++ b/packages/server/scripts/measure-git-observation.ts @@ -0,0 +1,414 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import parcelWatcher from "@parcel/watcher"; +import type pino from "pino"; +import { + getCheckoutDiff, + getCheckoutSnapshotFacts, + getCheckoutWorktreeState, +} from "../src/utils/checkout-git.js"; +import { + startGitCommandMetrics, + stopGitCommandMetrics, + type GitCommandMetricsSnapshot, +} from "../src/utils/run-git-command.js"; +import { CheckoutDiffManager } from "../src/server/checkout-diff-manager.js"; +import { + WorkspaceGitServiceImpl, + type WorkspaceGitRuntimeSnapshot, +} from "../src/server/workspace-git-service.js"; + +interface ProducerCounts { + structural: number; + worktree: number; + detailedDiff: number; +} + +interface PhaseResult { + elapsedMs: number; + gitCommands: number; + failedGitCommands: number; + maxConcurrentGitCommands: number; + byCommand: Record; + producers: ProducerCounts; + queueAfter: { + inFlight: number; + queued: number; + }; +} + +function createLogger(): pino.Logger { + const logger = { + child: () => logger, + debug: () => {}, + warn: () => {}, + }; + return logger as unknown as pino.Logger; +} + +function runGit(cwd: string, args: string[]): void { + execFileSync("git", args, { cwd, stdio: "pipe" }); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitFor(predicate: () => boolean, label: string, timeoutMs = 8_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for ${label}`); + } + await sleep(25); + } +} + +function summarizeCommands(metrics: GitCommandMetricsSnapshot): Record { + const counts = new Map(); + for (const command of metrics.commands) { + const key = command.args.slice(0, 3).join(" "); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return Object.fromEntries([...counts].sort(([left], [right]) => left.localeCompare(right))); +} + +function subtractCounts(after: ProducerCounts, before: ProducerCounts): ProducerCounts { + return { + structural: after.structural - before.structural, + worktree: after.worktree - before.worktree, + detailedDiff: after.detailedDiff - before.detailedDiff, + }; +} + +function snapshotCounts(counts: ProducerCounts): ProducerCounts { + return { ...counts }; +} + +function createTrackedSubscriber(failWorktreeRoot: string | null) { + const subscriptions = new Set(); + const subscribe = async (...args: Parameters) => { + if (failWorktreeRoot && path.resolve(args[0]) === failWorktreeRoot) { + throw new Error("measurement watcher setup failure"); + } + const subscription = await parcelWatcher.subscribe(...args); + subscriptions.add(subscription); + return { + unsubscribe: async () => { + await subscription.unsubscribe(); + subscriptions.delete(subscription); + }, + }; + }; + return { subscribe, subscriptions }; +} + +function createMeasuredService(input: { + repoDir: string; + paseoHome: string; + failWorktreeWatch?: boolean; +}) { + const counts: ProducerCounts = { structural: 0, worktree: 0, detailedDiff: 0 }; + const watcher = createTrackedSubscriber(input.failWorktreeWatch ? input.repoDir : null); + const service = new WorkspaceGitServiceImpl({ + logger: createLogger(), + paseoHome: input.paseoHome, + deps: { + subscribe: watcher.subscribe, + getWorkspaceGitSelfHealPhaseMs: () => 60_000, + getCheckoutSnapshotFacts: async (...args) => { + counts.structural += 1; + return getCheckoutSnapshotFacts(...args); + }, + getCheckoutWorktreeState: async (...args) => { + counts.worktree += 1; + return getCheckoutWorktreeState(...args); + }, + getCheckoutDiff: async (...args) => { + counts.detailedDiff += 1; + return getCheckoutDiff(...args); + }, + }, + }); + return { counts, service, watcher }; +} + +async function measurePhase(input: { + service: WorkspaceGitServiceImpl; + counts: ProducerCounts; + action: () => Promise; +}): Promise { + const countsBefore = snapshotCounts(input.counts); + const startedAtMs = Date.now(); + startGitCommandMetrics(); + let metrics: GitCommandMetricsSnapshot; + try { + await input.action(); + await waitFor(() => { + const serviceMetrics = input.service.getMetrics(); + return ( + serviceMetrics.workspaceRefreshInFlightCount === 0 && + serviceMetrics.workspaceRefreshQueuedCount === 0 + ); + }, "observation queue to drain"); + } finally { + metrics = stopGitCommandMetrics(); + } + const serviceMetrics = input.service.getMetrics(); + return { + elapsedMs: Date.now() - startedAtMs, + gitCommands: metrics.total, + failedGitCommands: metrics.failed, + maxConcurrentGitCommands: metrics.maxConcurrent, + byCommand: summarizeCommands(metrics), + producers: subtractCounts(input.counts, countsBefore), + queueAfter: { + inFlight: serviceMetrics.workspaceRefreshInFlightCount, + queued: serviceMetrics.workspaceRefreshQueuedCount, + }, + }; +} + +async function closeMeasuredService(input: { + service: WorkspaceGitServiceImpl; + subscriptions: Set; +}): Promise { + input.service.dispose(); + await waitFor(() => input.subscriptions.size === 0, "watchers to close"); +} + +async function main(): Promise { + const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-git-observation-measurement-")); + const repoDir = path.join(tempDir, "repo"); + const paseoHome = path.join(tempDir, "paseo-home"); + const trackedPath = path.join(repoDir, "tracked.txt"); + const ignoredDir = path.join(repoDir, "build"); + mkdirSync(repoDir, { recursive: true }); + mkdirSync(ignoredDir, { recursive: true }); + writeFileSync(path.join(ignoredDir, "probe.txt"), "ignored\n"); + writeFileSync(path.join(repoDir, ".gitignore"), "build/\n"); + writeFileSync(trackedPath, "base\n"); + runGit(repoDir, ["init", "-b", "main"]); + runGit(repoDir, ["config", "user.email", "measurement@paseo.local"]); + runGit(repoDir, ["config", "user.name", "Paseo Measurement"]); + runGit(repoDir, ["add", ".gitignore", "tracked.txt"]); + runGit(repoDir, ["commit", "-m", "fixture"]); + runGit(repoDir, ["checkout", "-b", "feature"]); + + const phases: Record = {}; + let healthy: ReturnType | null = null; + let degraded: ReturnType | null = null; + let summarySubscription: { unsubscribe: () => void } | null = null; + let diffSubscription: { unsubscribe: () => void } | null = null; + let diffManager: CheckoutDiffManager | null = null; + + try { + healthy = createMeasuredService({ repoDir, paseoHome }); + let latestSummary: WorkspaceGitRuntimeSnapshot | null = null; + let latestDiffAdditions = 0; + + startGitCommandMetrics(); + const bootstrapStartedAtMs = Date.now(); + const bootstrapCounts = snapshotCounts(healthy.counts); + summarySubscription = healthy.service.registerWorkspace({ cwd: repoDir }, (snapshot) => { + latestSummary = snapshot; + }); + diffManager = new CheckoutDiffManager({ + logger: createLogger(), + paseoHome, + workspaceGitService: healthy.service, + }); + const openedDiff = await diffManager.subscribe( + { cwd: repoDir, compare: { mode: "uncommitted" } }, + (payload) => { + latestDiffAdditions = payload.files[0]?.additions ?? 0; + }, + ); + diffSubscription = openedDiff; + await waitFor( + () => + healthy?.service.peekSnapshot(repoDir) !== null && + healthy.service.getMetrics().workspaceObservationSetupInFlightCount === 0 && + healthy.watcher.subscriptions.size === 2, + "healthy bootstrap", + ); + await sleep(250); + const bootstrapMetrics = stopGitCommandMetrics(); + const bootstrapServiceMetrics = healthy.service.getMetrics(); + phases.bootstrap = { + elapsedMs: Date.now() - bootstrapStartedAtMs, + gitCommands: bootstrapMetrics.total, + failedGitCommands: bootstrapMetrics.failed, + maxConcurrentGitCommands: bootstrapMetrics.maxConcurrent, + byCommand: summarizeCommands(bootstrapMetrics), + producers: subtractCounts(healthy.counts, bootstrapCounts), + queueAfter: { + inFlight: bootstrapServiceMetrics.workspaceRefreshInFlightCount, + queued: bootstrapServiceMetrics.workspaceRefreshQueuedCount, + }, + }; + + phases.legacyFullRefreshReference = await measurePhase({ + service: healthy.service, + counts: healthy.counts, + action: async () => { + await Promise.all([ + healthy?.service.getSnapshot(repoDir, { + force: true, + includeForge: false, + reason: "measurement-legacy-full-refresh-reference", + }), + healthy?.service.getCheckoutDiff( + repoDir, + { mode: "uncommitted", includeStructured: true }, + { force: true, reason: "measurement-legacy-full-refresh-reference" }, + ), + ]); + }, + }); + + phases.idle = await measurePhase({ + service: healthy.service, + counts: healthy.counts, + action: () => sleep(750), + }); + + phases.activeDiff = await measurePhase({ + service: healthy.service, + counts: healthy.counts, + action: async () => { + writeFileSync(trackedPath, "first\nsecond\n"); + await waitFor( + () => (latestSummary?.git.diffStat?.additions ?? 0) >= 2 && latestDiffAdditions >= 2, + "active diff payload", + ); + await sleep(1_200); + }, + }); + + phases.editBurst = await measurePhase({ + service: healthy.service, + counts: healthy.counts, + action: async () => { + for (let index = 0; index < 100; index += 1) { + writeFileSync( + trackedPath, + Array.from({ length: index + 2 }, (_, line) => `burst-${index}-${line}`).join("\n") + + "\n", + ); + } + await waitFor( + () => (latestSummary?.git.diffStat?.additions ?? 0) >= 100 && latestDiffAdditions >= 100, + "edit burst final payload", + ); + await sleep(1_200); + }, + }); + + phases.ignoredStorm = await measurePhase({ + service: healthy.service, + counts: healthy.counts, + action: async () => { + for (let index = 0; index < 100; index += 1) { + writeFileSync(path.join(ignoredDir, `artifact-${index}.txt`), `${index}\n`); + } + await sleep(1_200); + }, + }); + + if (phases.idle.gitCommands !== 0 || phases.ignoredStorm.gitCommands !== 0) { + throw new Error("Idle and ignored-storm phases must perform zero Git commands"); + } + if (phases.editBurst.producers.worktree > 2 || phases.editBurst.producers.detailedDiff > 2) { + throw new Error("One hundred edits exceeded two final-state refresh generations"); + } + + diffSubscription.unsubscribe(); + diffSubscription = null; + summarySubscription.unsubscribe(); + summarySubscription = null; + diffManager.dispose(); + diffManager = null; + await closeMeasuredService({ + service: healthy.service, + subscriptions: healthy.watcher.subscriptions, + }); + healthy = null; + + degraded = createMeasuredService({ repoDir, paseoHome, failWorktreeWatch: true }); + startGitCommandMetrics(); + const degradedBootstrapStartedAtMs = Date.now(); + const degradedBootstrapCounts = snapshotCounts(degraded.counts); + summarySubscription = degraded.service.registerWorkspace({ cwd: repoDir }, () => {}); + await waitFor( + () => + degraded?.service.peekSnapshot(repoDir) !== null && + degraded.service.getMetrics().workspaceObservationSetupInFlightCount === 0, + "degraded bootstrap", + ); + await sleep(250); + const degradedBootstrapMetrics = stopGitCommandMetrics(); + const degradedBootstrapServiceMetrics = degraded.service.getMetrics(); + phases.degradedBootstrap = { + elapsedMs: Date.now() - degradedBootstrapStartedAtMs, + gitCommands: degradedBootstrapMetrics.total, + failedGitCommands: degradedBootstrapMetrics.failed, + maxConcurrentGitCommands: degradedBootstrapMetrics.maxConcurrent, + byCommand: summarizeCommands(degradedBootstrapMetrics), + producers: subtractCounts(degraded.counts, degradedBootstrapCounts), + queueAfter: { + inFlight: degradedBootstrapServiceMetrics.workspaceRefreshInFlightCount, + queued: degradedBootstrapServiceMetrics.workspaceRefreshQueuedCount, + }, + }; + + phases.degradedWatcher = await measurePhase({ + service: degraded.service, + counts: degraded.counts, + action: async () => { + const refreshCount = degraded?.counts.worktree ?? 0; + await waitFor( + () => (degraded?.counts.worktree ?? 0) > refreshCount, + "degraded watcher poll", + 8_000, + ); + }, + }); + + const report = { + generatedAt: new Date().toISOString(), + phases, + }; + const outputPath = process.env.PASEO_GIT_OBSERVATION_REPORT?.trim(); + if (outputPath) { + await writeFile(path.resolve(outputPath), `${JSON.stringify(report, null, 2)}\n`, "utf8"); + } + process.stdout.write(`GIT_OBSERVATION_MEASUREMENT ${JSON.stringify(report)}\n`); + } finally { + stopGitCommandMetrics(); + diffSubscription?.unsubscribe(); + summarySubscription?.unsubscribe(); + diffManager?.dispose(); + if (healthy) { + await closeMeasuredService({ + service: healthy.service, + subscriptions: healthy.watcher.subscriptions, + }).catch(() => {}); + } + if (degraded) { + await closeMeasuredService({ + service: degraded.service, + subscriptions: degraded.watcher.subscriptions, + }).catch(() => {}); + } + rmSync(tempDir, { recursive: true, force: true }); + } +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/packages/server/src/server/checkout-diff-manager.test.ts b/packages/server/src/server/checkout-diff-manager.test.ts index 0f674a755..1d3f2e598 100644 --- a/packages/server/src/server/checkout-diff-manager.test.ts +++ b/packages/server/src/server/checkout-diff-manager.test.ts @@ -12,7 +12,7 @@ vi.mock("./checkout-git-utils.js", () => ({ import type pino from "pino"; import { CheckoutDiffManager } from "./checkout-diff-manager.js"; -import type { WorkspaceGitService } from "./workspace-git-service.js"; +import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; interface Deferred { promise: Promise; @@ -27,6 +27,36 @@ function createDeferred(): Deferred { return { promise, resolve }; } +function createWorkspaceSnapshot( + overrides?: Partial, +): WorkspaceGitRuntimeSnapshot { + return { + cwd: "/tmp/repo", + git: { + isGit: true, + repoRoot: "/tmp/repo", + mainRepoRoot: null, + currentBranch: "feature", + remoteUrl: "https://github.com/acme/repo.git", + isPaseoOwnedWorktree: false, + isDirty: false, + baseRef: "main", + aheadBehind: { ahead: 1, behind: 0 }, + aheadOfOrigin: 1, + behindOfOrigin: 0, + hasRemote: true, + diffStat: { additions: 1, deletions: 0 }, + ...overrides, + }, + forge: { + featuresEnabled: false, + authState: "no_remote", + pullRequest: null, + error: null, + }, + }; +} + function createPendingManager() { const watches: Array<{ cwd: string; @@ -36,6 +66,9 @@ function createPendingManager() { }> = []; const workspaceGitService = { getCheckoutDiff: async () => ({ diff: "", structured: [] }), + getSnapshot: async () => createWorkspaceSnapshot(), + peekSnapshot: () => null, + registerWorkspace: () => ({ unsubscribe: () => {} }), requestWorkingTreeWatch: (cwd: string, onChange: () => void) => { const pending = createDeferred<{ repoRoot: string | null; unsubscribe: () => void }>(); const watch = { @@ -79,7 +112,9 @@ describe("CheckoutDiffManager", () => { getCheckoutDiffImplementation?: ReturnType; }) { const unsubscribe = vi.fn(); + const workspaceUnsubscribe = vi.fn(); let onChange: (() => void) | null = null; + let onWorkspaceSnapshot: ((snapshot: WorkspaceGitRuntimeSnapshot) => void) | null = null; const mockRequestWorkingTreeWatch = vi.fn(async (_cwd: string, listener: () => void) => { onChange = listener; return { @@ -91,7 +126,13 @@ describe("CheckoutDiffManager", () => { const workspaceGitService = { subscribe: vi.fn(), peekSnapshot: vi.fn(), - getSnapshot: vi.fn(), + registerWorkspace: vi.fn( + (_params: { cwd: string }, listener: (snapshot: WorkspaceGitRuntimeSnapshot) => void) => { + onWorkspaceSnapshot = listener; + return { unsubscribe: workspaceUnsubscribe }; + }, + ), + getSnapshot: vi.fn(async () => createWorkspaceSnapshot()), getCheckoutDiff: options?.getCheckoutDiffImplementation ?? vi.fn(async () => ({ diff: "", structured: [] })), refresh: vi.fn(), @@ -117,6 +158,8 @@ describe("CheckoutDiffManager", () => { mockRequestWorkingTreeWatch, unsubscribe, getOnChange: () => onChange, + getOnWorkspaceSnapshot: () => onWorkspaceSnapshot, + workspaceUnsubscribe, }; } @@ -301,6 +344,127 @@ describe("CheckoutDiffManager", () => { }); }); + test("an edit during an in-flight diff refresh produces one final follow-up", async () => { + const inFlightDiff = createDeferred<{ + diff: string; + structured: Array<{ + path: string; + additions: number; + deletions: number; + status: "modified"; + }>; + }>(); + const getCheckoutDiff = vi + .fn() + .mockResolvedValueOnce({ + diff: "", + structured: [{ path: "tracked.ts", additions: 1, deletions: 0, status: "modified" }], + }) + .mockImplementationOnce(() => inFlightDiff.promise) + .mockResolvedValue({ + diff: "", + structured: [{ path: "tracked.ts", additions: 100, deletions: 25, status: "modified" }], + }); + const { manager, getOnChange } = createManager({ + getCheckoutDiffImplementation: getCheckoutDiff, + }); + const listener = vi.fn(); + + await manager.subscribe({ cwd: "/tmp/repo", compare: { mode: "uncommitted" } }, listener); + getOnChange()?.(); + await vi.advanceTimersByTimeAsync(150); + expect(getCheckoutDiff).toHaveBeenCalledTimes(2); + + for (let event = 0; event < 100; event += 1) { + getOnChange()?.(); + } + await vi.advanceTimersByTimeAsync(150); + expect(getCheckoutDiff).toHaveBeenCalledTimes(2); + + inFlightDiff.resolve({ + diff: "", + structured: [{ path: "tracked.ts", additions: 2, deletions: 1, status: "modified" }], + }); + await vi.waitFor(() => { + expect(getCheckoutDiff).toHaveBeenCalledTimes(3); + expect(listener).toHaveBeenLastCalledWith({ + cwd: "/tmp/repo", + files: [{ path: "tracked.ts", additions: 100, deletions: 25, status: "modified" }], + error: null, + }); + }); + }); + + test("base diff subscriptions ignore ordinary working tree edits", async () => { + const getCheckoutDiff = vi.fn(async () => ({ + diff: "", + structured: [{ path: "committed.ts", additions: 1, deletions: 0, status: "modified" }], + })); + const { manager, getOnChange, mockRequestWorkingTreeWatch } = createManager({ + getCheckoutDiffImplementation: getCheckoutDiff, + }); + + await manager.subscribe( + { cwd: "/tmp/repo", compare: { mode: "base", baseRef: "main" } }, + vi.fn(), + ); + getOnChange()?.(); + await vi.advanceTimersByTimeAsync(150); + + expect(getCheckoutDiff).toHaveBeenCalledTimes(1); + expect(mockRequestWorkingTreeWatch).not.toHaveBeenCalled(); + }); + + test("base diff subscriptions ignore worktree-only workspace snapshot updates", async () => { + const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: [] })); + const { manager, getOnWorkspaceSnapshot } = createManager({ + getCheckoutDiffImplementation: getCheckoutDiff, + }); + + await manager.subscribe( + { cwd: "/tmp/repo", compare: { mode: "base", baseRef: "main" } }, + vi.fn(), + ); + + getOnWorkspaceSnapshot()?.( + createWorkspaceSnapshot({ + isDirty: true, + diffStat: { additions: 5, deletions: 2 }, + }), + ); + await vi.advanceTimersByTimeAsync(150); + + expect(getCheckoutDiff).toHaveBeenCalledTimes(1); + }); + + test("base diff subscriptions refresh for structural workspace changes", async () => { + const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: [] })); + const { manager, getOnWorkspaceSnapshot, workspaceGitService } = createManager({ + getCheckoutDiffImplementation: getCheckoutDiff, + }); + + await manager.subscribe( + { cwd: "/tmp/repo", compare: { mode: "base", baseRef: "main" } }, + vi.fn(), + ); + + expect(workspaceGitService.registerWorkspace).toHaveBeenCalledTimes(1); + getOnWorkspaceSnapshot()?.( + createWorkspaceSnapshot({ + currentBranch: "feature-2", + isDirty: true, + diffStat: { additions: 5, deletions: 2 }, + }), + ); + await vi.advanceTimersByTimeAsync(150); + expect(getCheckoutDiff).toHaveBeenCalledTimes(2); + expect(getCheckoutDiff.mock.calls[1]?.[2]).toBeUndefined(); + + getOnWorkspaceSnapshot()?.(createWorkspaceSnapshot({ aheadBehind: { ahead: 2, behind: 0 } })); + await vi.advanceTimersByTimeAsync(150); + expect(getCheckoutDiff).toHaveBeenCalledTimes(3); + }); + test("falls back to cwd when the working tree watch returns no repo root", async () => { const { manager, workspaceGitService } = createManager({ repoRoot: null }); diff --git a/packages/server/src/server/checkout-diff-manager.ts b/packages/server/src/server/checkout-diff-manager.ts index 01e7a8284..9bf0439af 100644 --- a/packages/server/src/server/checkout-diff-manager.ts +++ b/packages/server/src/server/checkout-diff-manager.ts @@ -1,6 +1,6 @@ import type pino from "pino"; import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js"; -import type { WorkspaceGitService } from "./workspace-git-service.js"; +import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; import { expandTilde } from "../utils/path.js"; import { toCheckoutError } from "./checkout-git-utils.js"; @@ -8,7 +8,11 @@ const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150; type CheckoutDiffWorkspace = Pick< WorkspaceGitService, - "getCheckoutDiff" | "requestWorkingTreeWatch" + | "getCheckoutDiff" + | "getSnapshot" + | "peekSnapshot" + | "registerWorkspace" + | "requestWorkingTreeWatch" >; export type CheckoutDiffCompareInput = SubscribeCheckoutDiffRequest["compare"]; @@ -32,9 +36,15 @@ interface CheckoutDiffWatchTarget { compare: CheckoutDiffCompareInput; listeners: Set<(snapshot: CheckoutDiffSnapshotPayload) => void>; workingTreeWatchUnsubscribe: (() => void) | null; + workspaceGitUnsubscribe: (() => void) | null; + latestWorkspaceStructureFingerprint: string | null; + latestWorkspaceWorktreeFingerprint: string | null; + latestWorkspaceForgeFingerprint: string | null; debounceTimer: NodeJS.Timeout | null; + pendingDebounceForce: boolean; refreshPromise: Promise | null; refreshQueued: boolean; + refreshQueuedForce: boolean; latestPayload: CheckoutDiffSnapshotPayload | null; latestFingerprint: string | null; openPromise: Promise | null; @@ -162,9 +172,51 @@ export class CheckoutDiffManager { } target.workingTreeWatchUnsubscribe?.(); target.workingTreeWatchUnsubscribe = null; + target.workspaceGitUnsubscribe?.(); + target.workspaceGitUnsubscribe = null; target.listeners.clear(); } + private rememberWorkspaceSnapshot( + target: CheckoutDiffWatchTarget, + snapshot: WorkspaceGitRuntimeSnapshot, + ): void { + const structureFingerprint = JSON.stringify({ + isGit: snapshot.git.isGit, + repoRoot: snapshot.git.repoRoot, + mainRepoRoot: snapshot.git.mainRepoRoot, + currentBranch: snapshot.git.currentBranch, + remoteUrl: snapshot.git.remoteUrl, + isPaseoOwnedWorktree: snapshot.git.isPaseoOwnedWorktree, + baseRef: snapshot.git.baseRef, + aheadBehind: snapshot.git.aheadBehind, + aheadOfOrigin: snapshot.git.aheadOfOrigin, + behindOfOrigin: snapshot.git.behindOfOrigin, + hasRemote: snapshot.git.hasRemote, + }); + const worktreeFingerprint = JSON.stringify({ + isDirty: snapshot.git.isDirty, + diffStat: snapshot.git.diffStat, + }); + const forgeFingerprint = JSON.stringify(snapshot.forge); + const previousStructureFingerprint = target.latestWorkspaceStructureFingerprint; + const previousWorktreeFingerprint = target.latestWorkspaceWorktreeFingerprint; + const previousForgeFingerprint = target.latestWorkspaceForgeFingerprint; + target.latestWorkspaceStructureFingerprint = structureFingerprint; + target.latestWorkspaceWorktreeFingerprint = worktreeFingerprint; + target.latestWorkspaceForgeFingerprint = forgeFingerprint; + + if (previousStructureFingerprint === null) { + return; + } + const structureChanged = structureFingerprint !== previousStructureFingerprint; + const worktreeChanged = worktreeFingerprint !== previousWorktreeFingerprint; + const forgeChanged = forgeFingerprint !== previousForgeFingerprint; + if (structureChanged || (!worktreeChanged && !forgeChanged)) { + this.scheduleTargetRefresh(target, false); + } + } + private removeListener( target: CheckoutDiffWatchTarget, listener: (snapshot: CheckoutDiffSnapshotPayload) => void, @@ -179,13 +231,16 @@ export class CheckoutDiffManager { } } - private scheduleTargetRefresh(target: CheckoutDiffWatchTarget): void { + private scheduleTargetRefresh(target: CheckoutDiffWatchTarget, force = true): void { + target.pendingDebounceForce ||= force; if (target.debounceTimer) { clearTimeout(target.debounceTimer); } target.debounceTimer = setTimeout(() => { target.debounceTimer = null; - void this.refreshTarget(target); + const pendingForce = target.pendingDebounceForce; + target.pendingDebounceForce = false; + void this.refreshTarget(target, pendingForce); }, CHECKOUT_DIFF_WATCH_DEBOUNCE_MS); } @@ -235,19 +290,22 @@ export class CheckoutDiffManager { } } - private async refreshTarget(target: CheckoutDiffWatchTarget): Promise { + private async refreshTarget(target: CheckoutDiffWatchTarget, force: boolean): Promise { if (target.refreshPromise) { target.refreshQueued = true; + target.refreshQueuedForce ||= force; return; } target.refreshPromise = (async () => { + let currentForce = force; do { target.refreshQueued = false; + target.refreshQueuedForce = false; const snapshot = await this.computeCheckoutDiffSnapshot(target.cwd, target.compare, { diffCwd: target.diffCwd, - force: true, - reason: "working-tree-watch", + force: currentForce, + ...(currentForce ? { reason: "working-tree-watch" } : {}), }); target.latestPayload = snapshot; const fingerprint = JSON.stringify(snapshot); @@ -257,6 +315,7 @@ export class CheckoutDiffManager { listener(snapshot); } } + currentForce = target.refreshQueuedForce; } while (target.refreshQueued); })(); @@ -281,9 +340,15 @@ export class CheckoutDiffManager { compare, listeners: new Set(), workingTreeWatchUnsubscribe: null, + workspaceGitUnsubscribe: null, + latestWorkspaceStructureFingerprint: null, + latestWorkspaceWorktreeFingerprint: null, + latestWorkspaceForgeFingerprint: null, debounceTimer: null, + pendingDebounceForce: false, refreshPromise: null, refreshQueued: false, + refreshQueuedForce: false, latestPayload: null, latestFingerprint: null, openPromise: null, @@ -293,6 +358,27 @@ export class CheckoutDiffManager { } private async openTarget(target: CheckoutDiffWatchTarget): Promise { + if (target.compare.mode === "base") { + const snapshot = + this.workspaceGitService.peekSnapshot(target.cwd) ?? + (await this.workspaceGitService.getSnapshot(target.cwd, { includeForge: false })); + target.diffCwd = snapshot.git.repoRoot ?? target.cwd; + if (this.targets.get(target.key) !== target || target.listeners.size === 0) { + return; + } + this.rememberWorkspaceSnapshot(target, snapshot); + const workspaceSubscription = this.workspaceGitService.registerWorkspace( + { cwd: target.cwd }, + (nextSnapshot) => this.rememberWorkspaceSnapshot(target, nextSnapshot), + ); + if (this.targets.get(target.key) !== target || target.listeners.size === 0) { + workspaceSubscription.unsubscribe(); + return; + } + target.workspaceGitUnsubscribe = workspaceSubscription.unsubscribe; + return; + } + const { repoRoot, unsubscribe } = await this.workspaceGitService.requestWorkingTreeWatch( target.cwd, () => this.scheduleTargetRefresh(target), diff --git a/packages/server/src/server/workspace-git-service.observation.integration.test.ts b/packages/server/src/server/workspace-git-service.observation.integration.test.ts new file mode 100644 index 000000000..8decb8ca8 --- /dev/null +++ b/packages/server/src/server/workspace-git-service.observation.integration.test.ts @@ -0,0 +1,383 @@ +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import parcelWatcher from "@parcel/watcher"; +import type pino from "pino"; +import { afterEach, expect, test, vi } from "vitest"; +import type { CheckoutSnapshotFacts, CheckoutStatusGit } from "../utils/checkout-git.js"; +import { CheckoutDiffManager } from "./checkout-diff-manager.js"; +import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; + +function createLogger(): pino.Logger { + const logger = { + child: () => logger, + debug: vi.fn(), + warn: vi.fn(), + }; + return logger as unknown as pino.Logger; +} + +function createFacts(cwd: string): CheckoutSnapshotFacts { + return { + isGit: true, + worktreeRoot: cwd, + currentBranch: "main", + remoteUrl: null, + absoluteGitDir: path.join(cwd, ".git"), + gitCommonDir: path.join(cwd, ".git"), + paseoWorktree: { isPaseoOwnedWorktree: false }, + storedBaseRef: null, + resolvedBaseRef: "main", + mainRepoRoot: null, + comparisonBaseRef: null, + branchRemoteName: null, + branchMergeRef: null, + pullRequestLookupTarget: { headRef: "main" }, + }; +} + +function createStatus(cwd: string): CheckoutStatusGit { + return { + isGit: true, + repoRoot: cwd, + mainRepoRoot: null, + currentBranch: "main", + isDirty: false, + baseRef: "main", + aheadBehind: { ahead: 0, behind: 0 }, + aheadOfOrigin: null, + behindOfOrigin: null, + hasRemote: false, + remoteUrl: null, + isPaseoOwnedWorktree: false, + }; +} + +const cleanup: Array<() => Promise> = []; + +afterEach(async () => { + while (cleanup.length > 0) { + await cleanup.pop()?.(); + } +}); + +test("native recursive observation updates tracked state and prunes ignored storms", async () => { + const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-git-observation-"))); + const repoDir = path.join(tempDir, "repo"); + const trackedPath = path.join(repoDir, "src", "tracked.txt"); + const ignoredDir = path.join(repoDir, "build"); + const remainingIgnoredDir = path.join(repoDir, "cache"); + const newlyTrackedPath = path.join(ignoredDir, "tracked.txt"); + mkdirSync(path.join(repoDir, ".git"), { recursive: true }); + mkdirSync(path.dirname(trackedPath), { recursive: true }); + mkdirSync(ignoredDir, { recursive: true }); + mkdirSync(remainingIgnoredDir, { recursive: true }); + writeFileSync(trackedPath, "base\n"); + writeFileSync(newlyTrackedPath, "base\n"); + + let activeWatcherCount = 0; + let watcherStartCount = 0; + let onWorkingTreeWatcherStopped: (() => void) | null = null; + const deliveredEvents: Array<{ + directory: string; + events: parcelWatcher.Event[]; + }> = []; + const subscribe: typeof parcelWatcher.subscribe = async (directory, callback, options) => { + const subscription = await parcelWatcher.subscribe( + directory, + (error, events) => { + deliveredEvents.push({ directory, events }); + callback(error, events); + }, + options, + ); + activeWatcherCount += 1; + watcherStartCount += 1; + return { + unsubscribe: async () => { + await subscription.unsubscribe(); + activeWatcherCount -= 1; + if (directory === repoDir) { + const onStopped = onWorkingTreeWatcherStopped; + onWorkingTreeWatcherStopped = null; + onStopped?.(); + } + }, + }; + }; + const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => createFacts(cwd)); + const getCheckoutStatus = vi.fn(async (cwd: string) => createStatus(cwd)); + const getCheckoutShortstat = vi.fn(async () => ({ additions: 0, deletions: 0 })); + let observedPath = trackedPath; + let observedRelativePath = "src/tracked.txt"; + const getCheckoutWorktreeState = vi.fn(async () => { + const additions = readFileSync(observedPath, "utf8").trim().split("\n").length; + return { isDirty: true, diffStat: { additions, deletions: 0 } }; + }); + const getCheckoutDiff = vi.fn(async () => { + const additions = readFileSync(observedPath, "utf8").trim().split("\n").length; + return { + diff: "", + structured: [ + { path: observedRelativePath, additions, deletions: 0, status: "modified" as const }, + ], + }; + }); + let buildIgnored = true; + const runGitCommand = vi.fn(async (args: string[]) => { + if (args[0] === "rev-parse") { + return { + stdout: `${repoDir}\n`, + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + }; + } + if (args[0] === "ls-files") { + return { + stdout: `${buildIgnored ? "build/\n" : ""}cache/\n`, + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + }; + } + throw new Error(`Unexpected Git command: ${args.join(" ")}`); + }); + const service = new WorkspaceGitServiceImpl({ + logger: createLogger(), + paseoHome: path.join(tempDir, "paseo-home"), + deps: { + subscribe, + getCheckoutSnapshotFacts, + getCheckoutStatus, + getCheckoutShortstat, + getCheckoutWorktreeState, + getCheckoutDiff, + runGitCommand, + getWorkspaceGitSelfHealPhaseMs: () => 7_000, + } as never, + }); + const diffManager = new CheckoutDiffManager({ + logger: createLogger(), + paseoHome: path.join(tempDir, "paseo-home"), + workspaceGitService: service, + }); + const summaryListener = vi.fn(); + const diffListener = vi.fn(); + const summarySubscription = service.registerWorkspace({ cwd: repoDir }, summaryListener); + const diffSubscription = await diffManager.subscribe( + { cwd: repoDir, compare: { mode: "uncommitted" } }, + diffListener, + ); + + cleanup.push(async () => { + diffSubscription.unsubscribe(); + summarySubscription.unsubscribe(); + diffManager.dispose(); + service.dispose(); + await vi.waitFor(() => expect(activeWatcherCount).toBe(0), { timeout: 5_000 }); + rmSync(tempDir, { recursive: true, force: true }); + }); + + await vi.waitFor( + () => { + expect(activeWatcherCount).toBe(2); + expect(service.peekSnapshot(repoDir)).not.toBeNull(); + expect(service.getMetrics()).toMatchObject({ + workspaceObservationSetupInFlightCount: 0, + workspaceRefreshInFlightCount: 0, + workspaceRefreshQueuedCount: 0, + }); + }, + { timeout: 5_000 }, + ); + + // Parcel may deliver a startup batch before subscribe resolves. Let its + // already-scheduled consumer debounce finish inside the bootstrap phase. + await new Promise((resolve) => setTimeout(resolve, 250)); + + getCheckoutSnapshotFacts.mockClear(); + getCheckoutStatus.mockClear(); + getCheckoutShortstat.mockClear(); + getCheckoutWorktreeState.mockClear(); + getCheckoutDiff.mockClear(); + runGitCommand.mockClear(); + deliveredEvents.length = 0; + + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(runGitCommand).not.toHaveBeenCalled(); + expect(getCheckoutWorktreeState).not.toHaveBeenCalled(); + expect(getCheckoutDiff, JSON.stringify(deliveredEvents)).not.toHaveBeenCalled(); + expect(service.getMetrics().workspaceRefreshQueuedCount).toBe(0); + + for (let index = 0; index < 100; index += 1) { + writeFileSync(path.join(ignoredDir, `artifact-${index}.txt`), `${index}\n`); + } + await new Promise((resolve) => setTimeout(resolve, 750)); + + expect(runGitCommand).not.toHaveBeenCalled(); + expect(getCheckoutSnapshotFacts).not.toHaveBeenCalled(); + expect(getCheckoutStatus).not.toHaveBeenCalled(); + expect(getCheckoutShortstat).not.toHaveBeenCalled(); + expect(getCheckoutWorktreeState).not.toHaveBeenCalled(); + expect(getCheckoutDiff).not.toHaveBeenCalled(); + expect(service.getMetrics().workspaceRefreshQueuedCount).toBe(0); + + writeFileSync(trackedPath, "first\nsecond\n"); + await vi.waitFor( + () => { + expect(summaryListener).toHaveBeenLastCalledWith( + expect.objectContaining({ + git: expect.objectContaining({ + isDirty: true, + diffStat: { additions: 2, deletions: 0 }, + }), + }), + ); + expect(diffListener).toHaveBeenLastCalledWith({ + cwd: repoDir, + files: [ + { + path: "src/tracked.txt", + additions: 2, + deletions: 0, + status: "modified", + }, + ], + error: null, + }); + expect(service.getMetrics().workspaceRefreshQueuedCount).toBe(0); + }, + { timeout: 5_000 }, + ); + + writeFileSync(trackedPath, "first\nsecond\nthird\n"); + await vi.waitFor( + () => { + expect(summaryListener).toHaveBeenLastCalledWith( + expect.objectContaining({ + git: expect.objectContaining({ + diffStat: { additions: 3, deletions: 0 }, + }), + }), + ); + expect(diffListener).toHaveBeenLastCalledWith( + expect.objectContaining({ + files: [expect.objectContaining({ additions: 3 })], + }), + ); + expect(service.getMetrics()).toMatchObject({ + workspaceRefreshInFlightCount: 0, + workspaceRefreshQueuedCount: 0, + }); + }, + { timeout: 5_000 }, + ); + + expect(getCheckoutSnapshotFacts).not.toHaveBeenCalled(); + expect(getCheckoutStatus).not.toHaveBeenCalled(); + + buildIgnored = false; + observedPath = newlyTrackedPath; + observedRelativePath = "build/tracked.txt"; + let editedDuringWatcherHandoff = false; + onWorkingTreeWatcherStopped = () => { + editedDuringWatcherHandoff = true; + writeFileSync(newlyTrackedPath, "first\nsecond\n"); + }; + writeFileSync(path.join(repoDir, ".git", "index"), "force-added\n"); + await vi.waitFor( + () => { + expect(editedDuringWatcherHandoff).toBe(true); + expect(watcherStartCount).toBe(3); + expect(activeWatcherCount).toBe(2); + expect(summaryListener).toHaveBeenLastCalledWith( + expect.objectContaining({ + git: expect.objectContaining({ + isDirty: true, + diffStat: { additions: 2, deletions: 0 }, + }), + }), + ); + expect(diffListener).toHaveBeenLastCalledWith({ + cwd: repoDir, + files: [ + { + path: "build/tracked.txt", + additions: 2, + deletions: 0, + status: "modified", + }, + ], + error: null, + }); + expect(service.getMetrics()).toMatchObject({ + workspaceRefreshInFlightCount: 0, + workspaceRefreshQueuedCount: 0, + }); + }, + { timeout: 8_000 }, + ); + + getCheckoutSnapshotFacts.mockClear(); + getCheckoutStatus.mockClear(); + getCheckoutShortstat.mockClear(); + getCheckoutWorktreeState.mockClear(); + getCheckoutDiff.mockClear(); + runGitCommand.mockClear(); + summaryListener.mockClear(); + diffListener.mockClear(); + + writeFileSync(newlyTrackedPath, "first\nsecond\nthird\nfourth\n"); + await vi.waitFor( + () => { + expect(summaryListener).toHaveBeenLastCalledWith( + expect.objectContaining({ + git: expect.objectContaining({ + isDirty: true, + diffStat: { additions: 4, deletions: 0 }, + }), + }), + ); + expect(diffListener).toHaveBeenLastCalledWith({ + cwd: repoDir, + files: [ + { + path: "build/tracked.txt", + additions: 4, + deletions: 0, + status: "modified", + }, + ], + error: null, + }); + expect(service.getMetrics()).toMatchObject({ + workspaceRefreshInFlightCount: 0, + workspaceRefreshQueuedCount: 0, + }); + }, + { timeout: 5_000 }, + ); + + getCheckoutSnapshotFacts.mockClear(); + getCheckoutStatus.mockClear(); + getCheckoutShortstat.mockClear(); + getCheckoutWorktreeState.mockClear(); + getCheckoutDiff.mockClear(); + runGitCommand.mockClear(); + + for (let index = 0; index < 100; index += 1) { + writeFileSync(path.join(remainingIgnoredDir, `artifact-${index}.txt`), `${index}\n`); + } + await new Promise((resolve) => setTimeout(resolve, 750)); + + expect(runGitCommand).not.toHaveBeenCalled(); + expect(getCheckoutSnapshotFacts).not.toHaveBeenCalled(); + expect(getCheckoutStatus).not.toHaveBeenCalled(); + expect(getCheckoutShortstat).not.toHaveBeenCalled(); + expect(getCheckoutWorktreeState).not.toHaveBeenCalled(); + expect(getCheckoutDiff).not.toHaveBeenCalled(); + expect(service.getMetrics().workspaceRefreshQueuedCount).toBe(0); +}, 15_000); diff --git a/packages/server/src/server/workspace-git-service.observation.test.ts b/packages/server/src/server/workspace-git-service.observation.test.ts new file mode 100644 index 000000000..aa92a3203 --- /dev/null +++ b/packages/server/src/server/workspace-git-service.observation.test.ts @@ -0,0 +1,929 @@ +import path from "node:path"; +import type pino from "pino"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { CheckoutSnapshotFacts, CheckoutStatusGit } from "../utils/checkout-git.js"; +import { CheckoutDiffManager } from "./checkout-diff-manager.js"; +import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; + +const REPO_CWD = path.resolve("/tmp/paseo-observation-repo"); +const GIT_DIR = path.join(REPO_CWD, ".git"); + +interface WatchEvent { + path: string; + type: "create" | "update" | "delete"; +} + +interface WatchRecord { + directory: string; + callback: (error: Error | null, events: WatchEvent[]) => void; + ignore: Array; + unsubscribe: ReturnType; +} + +function createWatcherHarness(harnessOptions?: { failDirectories?: Set }) { + const records: WatchRecord[] = []; + const subscribe = vi.fn( + async ( + directory: string, + callback: WatchRecord["callback"], + options?: { ignore?: Array }, + ) => { + if (harnessOptions?.failDirectories?.has(directory)) { + throw new Error(`watch failed: ${directory}`); + } + const unsubscribe = vi.fn(async () => {}); + records.push({ + directory, + callback, + ignore: options?.ignore ?? [], + unsubscribe, + }); + return { unsubscribe }; + }, + ); + + return { records, subscribe }; +} + +function createCheckoutFacts(cwd: string): CheckoutSnapshotFacts { + return { + isGit: true, + worktreeRoot: cwd, + currentBranch: "main", + remoteUrl: null, + absoluteGitDir: path.join(cwd, ".git"), + gitCommonDir: path.join(cwd, ".git"), + paseoWorktree: { isPaseoOwnedWorktree: false }, + storedBaseRef: null, + resolvedBaseRef: "main", + mainRepoRoot: null, + comparisonBaseRef: null, + branchRemoteName: null, + branchMergeRef: null, + pullRequestLookupTarget: { headRef: "main" }, + }; +} + +function createCheckoutStatus( + cwd: string, + overrides?: Partial, +): CheckoutStatusGit { + return { + isGit: true, + repoRoot: cwd, + mainRepoRoot: null, + currentBranch: "main", + isDirty: false, + baseRef: "main", + aheadBehind: { ahead: 0, behind: 0 }, + aheadOfOrigin: null, + behindOfOrigin: null, + hasRemote: false, + remoteUrl: null, + isPaseoOwnedWorktree: false, + ...overrides, + }; +} + +function createLogger(): pino.Logger { + const logger = { + child: () => logger, + debug: vi.fn(), + warn: vi.fn(), + }; + return logger as unknown as pino.Logger; +} + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +function createService( + watcher: ReturnType, + overrides?: Record, +) { + const defaultGetCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); + const defaultGetCheckoutShortstat = vi.fn(async () => null); + const getCheckoutStatus = + (overrides?.getCheckoutStatus as typeof defaultGetCheckoutStatus | undefined) ?? + defaultGetCheckoutStatus; + const getCheckoutShortstat = + (overrides?.getCheckoutShortstat as typeof defaultGetCheckoutShortstat | undefined) ?? + defaultGetCheckoutShortstat; + return new WorkspaceGitServiceImpl({ + logger: createLogger(), + paseoHome: "/tmp/paseo-home", + deps: { + subscribe: watcher.subscribe, + getCheckoutSnapshotFacts: vi.fn(async (cwd: string) => createCheckoutFacts(cwd)), + getCheckoutStatus, + getCheckoutShortstat, + getCheckoutWorktreeState: vi.fn(async (cwd: string) => { + const status = await getCheckoutStatus(cwd); + if (!status.isGit) { + throw new Error("Expected a git checkout"); + } + return { + isDirty: status.isDirty, + diffStat: await getCheckoutShortstat(), + }; + }), + resolveAbsoluteGitDir: vi.fn(async () => GIT_DIR), + hasOriginRemote: vi.fn(async () => false), + runGitCommand: vi.fn(async () => ({ + stdout: `${REPO_CWD}\n`, + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + })), + ...overrides, + } as never, + }); +} + +async function flushPromises(): Promise { + for (let index = 0; index < 8; index += 1) { + await Promise.resolve(); + } +} + +describe("WorkspaceGitService checkout observation", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("shares one recursive checkout observer between cwd-equivalent consumers", async () => { + const watcher = createWatcherHarness(); + const runGitCommand = vi.fn(async (args: string[]) => ({ + stdout: args[0] === "rev-parse" ? `${REPO_CWD}\n` : "", + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + })); + const service = createService(watcher, { runGitCommand }); + const firstListener = vi.fn(); + const secondListener = vi.fn(); + + const first = await service.requestWorkingTreeWatch(REPO_CWD, firstListener); + const second = await service.requestWorkingTreeWatch(path.join(REPO_CWD, "."), secondListener); + + expect(first.repoRoot).toBe(REPO_CWD); + expect(second.repoRoot).toBe(REPO_CWD); + expect(watcher.subscribe).toHaveBeenCalledTimes(1); + expect(runGitCommand).toHaveBeenCalledTimes(2); + expect(watcher.records[0]).toMatchObject({ directory: REPO_CWD }); + expect(watcher.records[0]?.ignore).toContain(GIT_DIR); + + watcher.records[0]?.callback(null, [ + { path: path.join(REPO_CWD, "tracked.txt"), type: "update" }, + ]); + expect(firstListener).toHaveBeenCalledTimes(1); + expect(secondListener).toHaveBeenCalledTimes(1); + + first.unsubscribe(); + expect(watcher.records[0]?.unsubscribe).not.toHaveBeenCalled(); + second.unsubscribe(); + await vi.waitFor(() => { + expect(watcher.records[0]?.unsubscribe).toHaveBeenCalledTimes(1); + }); + + service.dispose(); + }); + + test("a second workspace client adds no observation or Git work", async () => { + const watcher = createWatcherHarness(); + const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => createCheckoutFacts(cwd)); + const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); + const runGitCommand = vi.fn(async (args: string[]) => ({ + stdout: args[0] === "rev-parse" ? `${REPO_CWD}\n` : "", + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + })); + const service = createService(watcher, { + getCheckoutSnapshotFacts, + getCheckoutStatus, + runGitCommand, + }); + + const first = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + const second = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + await vi.waitFor(() => { + expect(service.peekSnapshot(REPO_CWD)).not.toBeNull(); + expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); + expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); + }); + + expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1); + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); + expect(runGitCommand).toHaveBeenCalledTimes(2); + expect(watcher.records.filter((record) => record.directory === REPO_CWD)).toHaveLength(1); + expect(watcher.records.filter((record) => record.directory === GIT_DIR)).toHaveLength(1); + + first.unsubscribe(); + second.unsubscribe(); + service.dispose(); + }); + + test("an observer abandoned during async setup is closed", async () => { + const watcher = createWatcherHarness(); + const openedSubscription = createDeferred<{ unsubscribe: () => Promise }>(); + const unsubscribeWatcher = vi.fn(async () => {}); + watcher.subscribe.mockImplementationOnce(async () => openedSubscription.promise); + const service = createService(watcher); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + + await vi.waitFor(() => { + expect(watcher.subscribe).toHaveBeenCalledTimes(1); + }); + subscription.unsubscribe(); + openedSubscription.resolve({ unsubscribe: unsubscribeWatcher }); + + await vi.waitFor(() => { + expect(unsubscribeWatcher).toHaveBeenCalledTimes(1); + expect(service.getMetrics().workingTreeWatchTargetCount).toBe(0); + }); + + service.dispose(); + }); + + test("a tracked edit refreshes summary and active uncommitted diff without structural or forge work", async () => { + const watcher = createWatcherHarness(); + const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => createCheckoutFacts(cwd)); + let diffStat = { additions: 1, deletions: 0 }; + let diffFile = { path: "tracked.txt", additions: 1, deletions: 0, status: "modified" }; + const getCheckoutStatus = vi.fn(async (cwd: string) => + createCheckoutStatus(cwd, { isDirty: true }), + ); + const getCheckoutShortstat = vi.fn(async () => diffStat); + const getPullRequestStatus = vi.fn(); + const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: [diffFile] })); + const service = createService(watcher, { + getCheckoutSnapshotFacts, + getCheckoutStatus, + getCheckoutShortstat, + getPullRequestStatus, + getCheckoutDiff, + }); + const diffManager = new CheckoutDiffManager({ + logger: createLogger(), + paseoHome: "/tmp/paseo-home", + workspaceGitService: service, + }); + const summaryListener = vi.fn(); + const diffListener = vi.fn(); + + await service.getSnapshot(REPO_CWD); + const summarySubscription = service.registerWorkspace({ cwd: REPO_CWD }, summaryListener); + const diffSubscription = await diffManager.subscribe( + { cwd: REPO_CWD, compare: { mode: "uncommitted" } }, + diffListener, + ); + await vi.waitFor(() => { + expect(watcher.subscribe).toHaveBeenCalledWith( + REPO_CWD, + expect.any(Function), + expect.any(Object), + ); + expect(service.getMetrics().workingTreeWatchTargetCount).toBe(1); + expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); + expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); + }); + + diffStat = { additions: 4, deletions: 2 }; + diffFile = { path: "tracked.txt", additions: 4, deletions: 2, status: "modified" }; + watcher.records[0]?.callback(null, [ + { path: path.join(REPO_CWD, "tracked.txt"), type: "update" }, + ]); + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(() => { + expect(summaryListener).toHaveBeenLastCalledWith( + expect.objectContaining({ + git: expect.objectContaining({ + isDirty: true, + diffStat: { additions: 4, deletions: 2 }, + }), + }), + ); + expect(diffListener).toHaveBeenLastCalledWith({ + cwd: REPO_CWD, + files: [{ path: "tracked.txt", additions: 4, deletions: 2, status: "modified" }], + error: null, + }); + }); + await flushPromises(); + + expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1); + expect(getPullRequestStatus).not.toHaveBeenCalled(); + + diffSubscription.unsubscribe(); + summarySubscription.unsubscribe(); + diffManager.dispose(); + service.dispose(); + }); + + test("worktree and metadata events invalidate only their matching diff projections", async () => { + const watcher = createWatcherHarness(); + const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: [] })); + const service = createService(watcher, { getCheckoutDiff }); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + + await vi.waitFor(() => { + expect(service.peekSnapshot(REPO_CWD)).not.toBeNull(); + expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); + }); + await service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" }); + await service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" }); + expect(getCheckoutDiff).toHaveBeenCalledTimes(2); + + watcher.records + .find((record) => record.directory === REPO_CWD) + ?.callback(null, [{ path: path.join(REPO_CWD, "tracked.txt"), type: "update" }]); + await service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" }); + await service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" }); + expect(getCheckoutDiff).toHaveBeenCalledTimes(3); + expect(getCheckoutDiff.mock.calls[2]?.[1]).toMatchObject({ mode: "uncommitted" }); + + watcher.records + .find((record) => record.directory === GIT_DIR) + ?.callback(null, [{ path: path.join(GIT_DIR, "HEAD"), type: "update" }]); + await service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" }); + expect(getCheckoutDiff).toHaveBeenCalledTimes(4); + expect(getCheckoutDiff.mock.calls[3]?.[1]).toMatchObject({ mode: "base" }); + + subscription.unsubscribe(); + service.dispose(); + }); + + test("metadata refreshes notify ref-dependent consumers when the summary is unchanged", async () => { + const watcher = createWatcherHarness(); + const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => createCheckoutFacts(cwd)); + const service = createService(watcher, { getCheckoutSnapshotFacts }); + const listener = vi.fn(); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener); + + await vi.waitFor(() => { + expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); + expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); + }); + listener.mockClear(); + + watcher.records + .find((record) => record.directory === GIT_DIR) + ?.callback(null, [{ path: path.join(GIT_DIR, "packed-refs"), type: "update" }]); + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(() => { + expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2); + }); + + expect(listener).toHaveBeenCalledTimes(1); + + subscription.unsubscribe(); + service.dispose(); + }); + + test("repository metadata observation ignores root and pruned-directory noise", async () => { + const watcher = createWatcherHarness(); + const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => createCheckoutFacts(cwd)); + const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); + const service = createService(watcher, { getCheckoutSnapshotFacts, getCheckoutStatus }); + const listener = vi.fn(); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener); + + await vi.waitFor(() => { + expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); + expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); + }); + getCheckoutSnapshotFacts.mockClear(); + getCheckoutStatus.mockClear(); + listener.mockClear(); + + watcher.records + .find((record) => record.directory === GIT_DIR) + ?.callback(null, [ + { path: GIT_DIR, type: "update" }, + { path: path.join(GIT_DIR, "objects", "pack", "temporary.pack"), type: "update" }, + ]); + await vi.advanceTimersByTimeAsync(1_000); + await flushPromises(); + + expect(getCheckoutSnapshotFacts).not.toHaveBeenCalled(); + expect(getCheckoutStatus).not.toHaveBeenCalled(); + expect(listener).not.toHaveBeenCalled(); + + subscription.unsubscribe(); + service.dispose(); + }); + + test("metadata-only changes refresh worktree summary and active uncommitted diff", async () => { + const watcher = createWatcherHarness(); + let isDirty = true; + let diffStat = { additions: 3, deletions: 1 }; + let diffFiles = [{ path: "tracked.txt", additions: 3, deletions: 1, status: "modified" }]; + const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd, { isDirty })); + const getCheckoutShortstat = vi.fn(async () => diffStat); + const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: diffFiles })); + const service = createService(watcher, { + getCheckoutStatus, + getCheckoutShortstat, + getCheckoutDiff, + }); + const diffManager = new CheckoutDiffManager({ + logger: createLogger(), + paseoHome: "/tmp/paseo-home", + workspaceGitService: service, + }); + const summaryListener = vi.fn(); + const diffListener = vi.fn(); + const summarySubscription = service.registerWorkspace({ cwd: REPO_CWD }, summaryListener); + const diffSubscription = await diffManager.subscribe( + { cwd: REPO_CWD, compare: { mode: "uncommitted" } }, + diffListener, + ); + + await vi.waitFor(() => { + expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); + expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); + }); + isDirty = false; + diffStat = { additions: 0, deletions: 0 }; + diffFiles = []; + + watcher.records + .find((record) => record.directory === GIT_DIR) + ?.callback(null, [{ path: path.join(GIT_DIR, "index"), type: "update" }]); + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(() => { + expect(summaryListener).toHaveBeenLastCalledWith( + expect.objectContaining({ + git: expect.objectContaining({ + isDirty: false, + diffStat: { additions: 0, deletions: 0 }, + }), + }), + ); + expect(diffListener).toHaveBeenLastCalledWith({ + cwd: REPO_CWD, + files: [], + error: null, + }); + }); + + diffSubscription.unsubscribe(); + summarySubscription.unsubscribe(); + diffManager.dispose(); + service.dispose(); + }); + + test("base diff projections follow metadata but ignore volatile worktree updates", async () => { + const watcher = createWatcherHarness(); + const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: [] })); + const getCheckoutWorktreeState = vi.fn(async () => ({ + isDirty: true, + diffStat: { additions: 4, deletions: 2 }, + })); + const service = createService(watcher, { getCheckoutDiff, getCheckoutWorktreeState }); + const diffManager = new CheckoutDiffManager({ + logger: createLogger(), + paseoHome: "/tmp/paseo-home", + workspaceGitService: service, + }); + const diffSubscription = await diffManager.subscribe( + { cwd: REPO_CWD, compare: { mode: "base", baseRef: "main" } }, + vi.fn(), + ); + + await vi.waitFor(() => { + expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); + }); + expect(getCheckoutDiff).toHaveBeenCalledTimes(1); + + watcher.records + .find((record) => record.directory === REPO_CWD) + ?.callback(null, [{ path: path.join(REPO_CWD, "tracked.txt"), type: "update" }]); + await vi.advanceTimersByTimeAsync(1_000); + await flushPromises(); + expect(getCheckoutDiff).toHaveBeenCalledTimes(1); + + watcher.records + .find((record) => record.directory === GIT_DIR) + ?.callback(null, [{ path: path.join(GIT_DIR, "packed-refs"), type: "update" }]); + await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(150); + await vi.waitFor(() => { + expect(getCheckoutDiff).toHaveBeenCalledTimes(2); + }); + + diffSubscription.unsubscribe(); + diffManager.dispose(); + service.dispose(); + }); + + test("an event burst during an in-flight refresh produces one final follow-up", async () => { + const watcher = createWatcherHarness(); + const firstRefresh = createDeferred(); + const getCheckoutStatus = vi + .fn<() => Promise>() + .mockResolvedValueOnce(createCheckoutStatus(REPO_CWD)) + .mockImplementationOnce(() => firstRefresh.promise) + .mockResolvedValue(createCheckoutStatus(REPO_CWD, { isDirty: true })); + const getCheckoutShortstat = vi + .fn() + .mockResolvedValueOnce({ additions: 0, deletions: 0 }) + .mockResolvedValueOnce({ additions: 1, deletions: 0 }) + .mockResolvedValue({ additions: 100, deletions: 25 }); + const service = createService(watcher, { getCheckoutStatus, getCheckoutShortstat }); + const listener = vi.fn(); + + await service.getSnapshot(REPO_CWD); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener); + await vi.waitFor(() => { + expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); + }); + + const edit = { path: path.join(REPO_CWD, "tracked.txt"), type: "update" as const }; + watcher.records[0]?.callback(null, [edit]); + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(() => { + expect(getCheckoutStatus).toHaveBeenCalledTimes(2); + }); + + for (let event = 0; event < 100; event += 1) { + watcher.records[0]?.callback(null, [edit]); + } + await vi.advanceTimersByTimeAsync(1_000); + expect(service.getMetrics().workspaceRefreshQueuedCount).toBe(1); + + firstRefresh.resolve(createCheckoutStatus(REPO_CWD, { isDirty: true })); + await vi.waitFor(() => { + expect(getCheckoutStatus).toHaveBeenCalledTimes(3); + expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); + }); + + expect(getCheckoutStatus).toHaveBeenCalledTimes(3); + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ + git: expect.objectContaining({ + isDirty: true, + diffStat: { additions: 100, deletions: 25 }, + }), + }), + ); + expect(service.getMetrics().workspaceRefreshQueuedCount).toBe(0); + + subscription.unsubscribe(); + service.dispose(); + }); + + test("an event queued during a failed refresh still reaches final state", async () => { + const watcher = createWatcherHarness(); + const failedRefresh = createDeferred(); + const getCheckoutStatus = vi + .fn<() => Promise>() + .mockResolvedValueOnce(createCheckoutStatus(REPO_CWD)) + .mockImplementationOnce(() => failedRefresh.promise) + .mockResolvedValue(createCheckoutStatus(REPO_CWD, { isDirty: true })); + const getCheckoutShortstat = vi + .fn() + .mockResolvedValueOnce({ additions: 0, deletions: 0 }) + .mockResolvedValue({ additions: 42, deletions: 7 }); + const service = createService(watcher, { getCheckoutStatus, getCheckoutShortstat }); + const listener = vi.fn(); + + await service.getSnapshot(REPO_CWD); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener); + await vi.waitFor(() => { + expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); + }); + + const edit = { path: path.join(REPO_CWD, "tracked.txt"), type: "update" as const }; + watcher.records[0]?.callback(null, [edit]); + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(() => { + expect(getCheckoutStatus).toHaveBeenCalledTimes(2); + }); + + watcher.records[0]?.callback(null, [edit]); + await vi.advanceTimersByTimeAsync(1_000); + expect(service.getMetrics().workspaceRefreshQueuedCount).toBe(1); + + failedRefresh.reject(new Error("transient Git read failure")); + await vi.waitFor(() => { + expect(getCheckoutStatus).toHaveBeenCalledTimes(3); + expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); + }); + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ + git: expect.objectContaining({ + isDirty: true, + diffStat: { additions: 42, deletions: 7 }, + }), + }), + ); + + subscription.unsubscribe(); + service.dispose(); + }); + + test("ten worktrees share one repository metadata and fetch observer while retaining checkout state", async () => { + const watcher = createWatcherHarness(); + const fetch = createDeferred(); + const runGitFetch = vi.fn(() => fetch.promise); + const commonGitDir = path.resolve("/tmp/paseo-shared-repository.git"); + const worktrees = Array.from({ length: 10 }, (_, index) => + path.resolve(`/tmp/paseo-shared-worktree-${index}`), + ); + const getCheckoutSnapshotFacts = vi.fn( + async (cwd: string): Promise => ({ + ...createCheckoutFacts(cwd), + remoteUrl: "https://github.com/acme/shared.git", + absoluteGitDir: path.join(commonGitDir, "worktrees", path.basename(cwd)), + gitCommonDir: commonGitDir, + }), + ); + const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); + const runGitCommand = vi.fn(async (_args: string[], options: { cwd: string }) => ({ + stdout: `${options.cwd}\n`, + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + })); + const service = createService(watcher, { + getCheckoutSnapshotFacts, + getCheckoutStatus, + runGitCommand, + runGitFetch, + }); + const subscriptions = worktrees.map((cwd) => service.registerWorkspace({ cwd }, vi.fn())); + + await vi.waitFor(() => { + expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); + expect(service.getMetrics().workingTreeWatchSetupInFlightCount).toBe(0); + expect(runGitFetch).toHaveBeenCalledTimes(1); + }); + + expect(service.getMetrics()).toMatchObject({ + workspaceTargetCount: 10, + repositoryTargetCount: 1, + repositoryWorkspaceLinkCount: 10, + workingTreeWatchTargetCount: 10, + }); + expect(watcher.records.filter((record) => record.directory === commonGitDir)).toHaveLength(1); + expect(watcher.records.filter((record) => worktrees.includes(record.directory))).toHaveLength( + 10, + ); + + subscriptions[0]?.unsubscribe(); + fetch.resolve(); + await vi.waitFor(() => { + expect(service.getMetrics().fetchInFlightCount).toBe(0); + }); + await vi.advanceTimersByTimeAsync(180_000); + await vi.waitFor(() => { + expect(runGitFetch).toHaveBeenCalledTimes(2); + }); + expect(runGitFetch).toHaveBeenLastCalledWith(worktrees[1]); + + for (const subscription of subscriptions.slice(1)) { + subscription.unsubscribe(); + } + service.dispose(); + }); + + test("watcher setup failure uses scoped non-overlapping polling", async () => { + const watcher = createWatcherHarness({ failDirectories: new Set([REPO_CWD]) }); + const blockedPoll = createDeferred(); + const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => createCheckoutFacts(cwd)); + const getCheckoutStatus = vi + .fn<() => Promise>() + .mockResolvedValueOnce(createCheckoutStatus(REPO_CWD)) + .mockImplementationOnce(() => blockedPoll.promise) + .mockResolvedValue(createCheckoutStatus(REPO_CWD, { isDirty: true })); + const getPullRequestStatus = vi.fn(); + const service = createService(watcher, { + getCheckoutSnapshotFacts, + getCheckoutStatus, + getPullRequestStatus, + }); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + + await vi.waitFor(() => { + expect(watcher.subscribe).toHaveBeenCalledWith( + REPO_CWD, + expect.any(Function), + expect.any(Object), + ); + expect(service.getMetrics().workingTreeWatchTargetCount).toBe(1); + expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); + expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); + expect(service.peekSnapshot(REPO_CWD)).not.toBeNull(); + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); + }); + await vi.advanceTimersByTimeAsync(7_000); + await vi.waitFor(() => { + expect(getCheckoutStatus).toHaveBeenCalledTimes(2); + }); + + await vi.advanceTimersByTimeAsync(15_000); + expect(getCheckoutStatus).toHaveBeenCalledTimes(2); + expect(service.getMetrics().workspaceRefreshQueuedCount).toBe(0); + expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1); + expect(getPullRequestStatus).not.toHaveBeenCalled(); + + blockedPoll.resolve(createCheckoutStatus(REPO_CWD, { isDirty: true })); + await vi.waitFor(() => { + expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); + }); + await vi.advanceTimersByTimeAsync(5_000); + await vi.waitFor(() => { + expect(getCheckoutStatus).toHaveBeenCalledTimes(3); + }); + + subscription.unsubscribe(); + service.dispose(); + }); + + test("non-Git fallback promotes an externally initialized checkout", async () => { + const watcher = createWatcherHarness(); + let isGit = false; + const getCheckoutSnapshotFacts = vi.fn( + async (cwd: string): Promise => + isGit ? createCheckoutFacts(cwd) : { isGit: false }, + ); + const getCheckoutStatus = vi.fn(async (cwd: string) => + isGit ? createCheckoutStatus(cwd) : ({ isGit: false } as const), + ); + const runGitCommand = vi.fn(async (args: string[]) => { + if (args[0] === "rev-parse") { + if (!isGit) { + throw new Error("not a git repository"); + } + return { + stdout: `${REPO_CWD}\n`, + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + }; + } + if (args[0] === "ls-files") { + return { + stdout: "", + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + }; + } + throw new Error(`Unexpected Git command: ${args.join(" ")}`); + }); + const service = createService(watcher, { + getCheckoutSnapshotFacts, + getCheckoutStatus, + getWorkspaceGitSelfHealPhaseMs: () => 60_000, + runGitCommand, + }); + const summarySubscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + + await vi.waitFor(() => { + expect(service.peekSnapshot(REPO_CWD)?.git.isGit).toBe(false); + expect(service.getMetrics()).toMatchObject({ + workspaceObservationSetupInFlightCount: 0, + workspaceRefreshInFlightCount: 0, + }); + }); + expect(watcher.records.filter((record) => record.directory === GIT_DIR)).toHaveLength(0); + + isGit = true; + await vi.advanceTimersByTimeAsync(5_000); + await vi.waitFor(() => { + expect(service.peekSnapshot(REPO_CWD)?.git.isGit).toBe(true); + expect(service.getMetrics()).toMatchObject({ + repositoryTargetCount: 1, + repositoryWorkspaceLinkCount: 1, + workspaceObservationSetupInFlightCount: 0, + workspaceRefreshInFlightCount: 0, + }); + }); + expect(watcher.records.filter((record) => record.directory === GIT_DIR)).toHaveLength(1); + expect(watcher.records.filter((record) => record.directory === REPO_CWD)).toHaveLength(1); + + const factsCallCount = getCheckoutSnapshotFacts.mock.calls.length; + await vi.advanceTimersByTimeAsync(5_000); + expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(factsCallCount); + + summarySubscription.unsubscribe(); + service.dispose(); + }); + + test("watcher runtime error switches to scoped polling", async () => { + const watcher = createWatcherHarness(); + const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => createCheckoutFacts(cwd)); + const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); + const service = createService(watcher, { + getCheckoutSnapshotFacts, + getCheckoutStatus, + }); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + + await vi.waitFor(() => { + expect(service.peekSnapshot(REPO_CWD)).not.toBeNull(); + expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); + }); + const checkoutWatcher = watcher.records.find((record) => record.directory === REPO_CWD); + expect(checkoutWatcher).toBeDefined(); + + checkoutWatcher?.callback(new Error("watcher stopped"), []); + await vi.waitFor(() => { + expect(checkoutWatcher?.unsubscribe).toHaveBeenCalledTimes(1); + }); + await vi.advanceTimersByTimeAsync(5_000); + await vi.waitFor(() => { + expect(getCheckoutStatus).toHaveBeenCalledTimes(2); + }); + + expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1); + expect(service.getMetrics().workspaceRefreshQueuedCount).toBe(0); + + subscription.unsubscribe(); + service.dispose(); + }); + + test("a missed watcher event converges through the deterministic audit", async () => { + const watcher = createWatcherHarness(); + let isDirty = false; + let diffStat = { additions: 0, deletions: 0 }; + let diffFiles: Array<{ + path: string; + additions: number; + deletions: number; + status: string; + }> = []; + const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd, { isDirty })); + const getCheckoutShortstat = vi.fn(async () => diffStat); + const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: diffFiles })); + const service = createService(watcher, { + getCheckoutStatus, + getCheckoutShortstat, + getCheckoutDiff, + getWorkspaceGitSelfHealPhaseMs: () => 10_000, + }); + const diffManager = new CheckoutDiffManager({ + logger: createLogger(), + paseoHome: "/tmp/paseo-home", + workspaceGitService: service, + }); + const listener = vi.fn(); + const diffListener = vi.fn(); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener); + const diffSubscription = await diffManager.subscribe( + { cwd: REPO_CWD, compare: { mode: "uncommitted" } }, + diffListener, + ); + + await vi.waitFor(() => { + expect(service.peekSnapshot(REPO_CWD)).not.toBeNull(); + expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); + }); + isDirty = true; + diffStat = { additions: 7, deletions: 3 }; + diffFiles = [{ path: "tracked.txt", additions: 7, deletions: 3, status: "modified" }]; + + await vi.advanceTimersByTimeAsync(70_000); + await vi.waitFor(() => { + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ + git: expect.objectContaining({ + isDirty: true, + diffStat: { additions: 7, deletions: 3 }, + }), + }), + ); + expect(diffListener).toHaveBeenLastCalledWith({ + cwd: REPO_CWD, + files: [{ path: "tracked.txt", additions: 7, deletions: 3, status: "modified" }], + error: null, + }); + }); + expect(getCheckoutStatus).toHaveBeenCalledTimes(2); + + diffSubscription.unsubscribe(); + subscription.unsubscribe(); + diffManager.dispose(); + service.dispose(); + }); +}); diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts index 99664c530..a5a91f54f 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -1,6 +1,5 @@ import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; -import { readdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve as resolvePath } from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; @@ -20,10 +19,10 @@ import { } from "../utils/checkout-git.js"; import { runGitCommand as runGitCommandReal } from "../utils/run-git-command.js"; import { + getWorkspaceGitSelfHealPhaseMs, WorkspaceGitServiceImpl, type WorkspaceGitRuntimeSnapshot, } from "./workspace-git-service.js"; -import { isPlatform } from "../test-utils/platform.js"; const REPO_CWD = resolvePath("/tmp/repo"); @@ -36,13 +35,6 @@ function createLogger() { return logger; } -function createWatcher() { - return { - close: vi.fn(), - on: vi.fn().mockReturnThis(), - }; -} - function createDeferred() { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: unknown) => void; @@ -53,6 +45,10 @@ function createDeferred() { return { promise, resolve, reject }; } +function createAsyncSubscription() { + return { unsubscribe: vi.fn(() => Promise.resolve()) }; +} + async function flushPromises(): Promise { for (let i = 0; i < 5; i += 1) { await Promise.resolve(); @@ -313,9 +309,11 @@ function createGitHubServiceStub(): ForgeService { } interface CreateServiceOptions { + subscribe?: ReturnType; getCheckoutSnapshotFacts?: ReturnType; getCheckoutStatus?: ReturnType; getCheckoutShortstat?: ReturnType; + getCheckoutWorktreeState?: ReturnType; getPullRequestStatus?: ReturnType; getCheckoutDiff?: ReturnType; resolveBranchCheckout?: ReturnType; @@ -327,21 +325,20 @@ interface CreateServiceOptions { hasOriginRemote?: ReturnType; runGitFetch?: ReturnType; runGitCommand?: ReturnType; - watch?: ReturnType; - readdir?: ReturnType; now?: () => Date; + getWorkspaceGitSelfHealPhaseMs?: (cwd: string) => number; } function buildDefaultServiceDeps() { return { - watch: (() => createWatcher()) as never, - readdir: vi.fn(async () => []), + subscribe: vi.fn(async () => ({ unsubscribe: vi.fn(async () => {}) })), getCheckoutSnapshotFacts: vi.fn(async (cwd: string) => createCheckoutFacts(cwd)), getCheckoutStatus: vi.fn(async (cwd: string) => createCheckoutStatus(cwd)), getCheckoutShortstat: vi.fn(async () => ({ additions: 1, deletions: 0, })), + getCheckoutWorktreeState: vi.fn(), getPullRequestStatus: vi.fn(async () => createPullRequestStatusResult()), getCheckoutDiff: vi.fn(async () => ({ diff: "", structured: [] })), resolveBranchCheckout: vi.fn(async () => ({ kind: "not-found" })), @@ -351,7 +348,7 @@ function buildDefaultServiceDeps() { forgeOverrides: { github: createGitHubServiceStub() }, resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")), hasOriginRemote: vi.fn(async () => false), - runGitFetch: vi.fn(async () => {}), + runGitFetch: vi.fn(() => createDeferred().promise), runGitCommand: vi.fn(async () => ({ stdout: `${REPO_CWD}\n`, stderr: "", @@ -359,6 +356,7 @@ function buildDefaultServiceDeps() { exitCode: 0, signal: null, })), + getWorkspaceGitSelfHealPhaseMs: vi.fn(() => 30_000), now: () => new Date("2026-04-12T00:00:00.000Z"), }; } @@ -366,11 +364,24 @@ function buildDefaultServiceDeps() { function buildServiceDeps(options?: CreateServiceOptions) { const { github, ...rest } = options ?? {}; const defaults = buildDefaultServiceDeps(); - return { + const deps = { ...defaults, ...rest, forgeOverrides: github ? { github } : defaults.forgeOverrides, }; + deps.getCheckoutWorktreeState = + options?.getCheckoutWorktreeState ?? + vi.fn(async (cwd: string) => { + const status = await deps.getCheckoutStatus(cwd); + if (!status.isGit) { + throw new Error("Expected a git checkout"); + } + return { + isDirty: status.isDirty, + diffStat: await deps.getCheckoutShortstat(), + }; + }); + return deps; } function createService(options?: CreateServiceOptions) { @@ -494,8 +505,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { expect(listener).not.toHaveBeenCalled(); expect(service.peekSnapshot(REPO_CWD)).toBeNull(); + await vi.waitFor(() => { + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); + }); await flushPromises(); - expect(getCheckoutStatus).toHaveBeenCalledTimes(1); expect(service.peekSnapshot(REPO_CWD)).toBeNull(); @@ -572,8 +585,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { const first = service.getSnapshot(REPO_CWD); const second = service.getSnapshot(join(REPO_CWD, ".")); + await vi.waitFor(() => { + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); + }); await flushPromises(); - expect(getCheckoutStatus).toHaveBeenCalledTimes(1); checkoutStatusDeferred.resolve(createCheckoutStatus(REPO_CWD)); @@ -848,11 +863,14 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { getPullRequestStatus, now: () => new Date(nowMs), }); - const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); - await flushPromises(); + const initialSnapshotReady = createDeferred(); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, () => { + initialSnapshotReady.resolve(); + }); + await initialSnapshotReady.promise; - nowMs = 60_000; - await vi.advanceTimersByTimeAsync(60_000); + nowMs = 120_000; + await vi.advanceTimersByTimeAsync(120_000); await flushPromises(); expect(getCheckoutStatus).toHaveBeenCalledTimes(2); @@ -868,50 +886,108 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { service.dispose(); }); - test("self-heal retries workspace observation setup while a listener remains active", async () => { + test("self-heal phase is stable across process restarts", () => { + expect(getWorkspaceGitSelfHealPhaseMs("/tmp/repo")).toBe(54_185); + expect(getWorkspaceGitSelfHealPhaseMs("/tmp/staggered-repo")).toBe(9_817); + }); + + test("self-heal staggers workspaces and settles into a 120 second cadence", async () => { + vi.setSystemTime(0); + const otherRepoCwd = resolvePath("/tmp/staggered-repo"); + const refreshes = new Map(); + const getCheckoutStatus = vi.fn(async (cwd: string) => { + const timestamps = refreshes.get(cwd) ?? []; + timestamps.push(Date.now()); + refreshes.set(cwd, timestamps); + return createCheckoutStatus(cwd, { hasRemote: false, remoteUrl: null }); + }); + const service = createService({ + getCheckoutSnapshotFacts: vi.fn(async (cwd: string) => + createCheckoutFacts(cwd, { remoteUrl: null }), + ), + getCheckoutStatus, + getWorkspaceGitSelfHealPhaseMs: (cwd) => (cwd === REPO_CWD ? 10_000 : 40_000), + now: () => new Date(Date.now()), + }); + const first = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + const second = service.registerWorkspace({ cwd: otherRepoCwd }, vi.fn()); + await Promise.all([service.getSnapshot(REPO_CWD), service.getSnapshot(otherRepoCwd)]); + + await vi.advanceTimersByTimeAsync(300_000); + await flushPromises(); + + const firstSelfHeals = refreshes.get(REPO_CWD)?.slice(1) ?? []; + const secondSelfHeals = refreshes.get(otherRepoCwd)?.slice(1) ?? []; + expect(firstSelfHeals).toHaveLength(2); + expect(secondSelfHeals).toHaveLength(2); + expect(firstSelfHeals[0]).not.toBe(secondSelfHeals[0]); + expect(firstSelfHeals[1] - firstSelfHeals[0]).toBe(120_000); + expect(secondSelfHeals[1] - secondSelfHeals[0]).toBe(120_000); + + first.unsubscribe(); + second.unsubscribe(); + service.dispose(); + }); + + test("self-heal retries observation setup even when the git snapshot is still recent", async () => { let nowMs = 0; const getCheckoutSnapshotFacts = vi .fn<(cwd: string) => Promise>() .mockRejectedValueOnce(new Error("git facts temporarily unavailable")) - .mockImplementation(async (cwd: string) => createCheckoutFacts(cwd)); - const watch = vi.fn(() => createWatcher() as never); + .mockImplementation(async (cwd: string) => createCheckoutFacts(cwd, { remoteUrl: null })); + const getCheckoutStatus = vi.fn(async (cwd: string) => + createCheckoutStatus(cwd, { remoteUrl: null }), + ); + const subscribe = vi.fn(async () => createAsyncSubscription()); const service = createService({ getCheckoutSnapshotFacts, - watch, + getCheckoutStatus, + subscribe, now: () => new Date(nowMs), }); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); - await flushPromises(); + await vi.waitFor(() => { + expect(getCheckoutSnapshotFacts).toHaveBeenCalled(); + expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); + expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); + }); expect(getCheckoutSnapshotFacts).toHaveBeenCalled(); - expect(watch).not.toHaveBeenCalled(); - const factsCallsBeforeSelfHeal = getCheckoutSnapshotFacts.mock.calls.length; + expect(subscribe).not.toHaveBeenCalled(); + + nowMs = 30_000; + await service.getSnapshot(REPO_CWD, { force: true, reason: "recovered-git-read" }); + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); + expect(subscribe).not.toHaveBeenCalled(); nowMs = 60_000; await vi.advanceTimersByTimeAsync(60_000); await flushPromises(); - expect(getCheckoutSnapshotFacts.mock.calls.length).toBeGreaterThan(factsCallsBeforeSelfHeal); - expect(getCheckoutSnapshotFacts).toHaveBeenLastCalledWith(REPO_CWD, expect.anything()); + expect(subscribe).toHaveBeenCalledTimes(2); + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); subscription.unsubscribe(); service.dispose(); }); test("stale workspace watcher callbacks do not refresh after unsubscribe", async () => { - const watchCallbacks: Array<() => void> = []; - const watch = vi.fn( - (_watchPath: string, _options: { recursive: boolean }, callback: () => void) => { - watchCallbacks.push(callback); - return createWatcher() as never; + const watchCallbacks: Array<{ + path: string; + callback: (error: Error | null, events: Array<{ path: string; type: "update" }>) => void; + }> = []; + const subscribe = vi.fn( + async (watchPath: string, callback: (typeof watchCallbacks)[number]["callback"]) => { + watchCallbacks.push({ path: watchPath, callback }); + return createAsyncSubscription(); }, ); const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); const service = createService({ getCheckoutStatus, resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")), - watch, + subscribe, }); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); @@ -920,10 +996,12 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { await vi.waitFor(() => { expect(watchCallbacks.length).toBeGreaterThan(0); }); + const workingTreeCallback = watchCallbacks.find((entry) => entry.path === REPO_CWD)?.callback; + expect(workingTreeCallback).toBeTypeOf("function"); const callsBeforeStaleCallback = getCheckoutStatus.mock.calls.length; subscription.unsubscribe(); - watchCallbacks[0]?.(); + workingTreeCallback?.(null, [{ path: join(REPO_CWD, "file.ts"), type: "update" }]); await vi.advanceTimersByTimeAsync(1_000); await flushPromises(); @@ -1390,17 +1468,23 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { test("multiple subscribers on the same target share one self-heal timer", async () => { let nowMs = 0; - const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); + const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => + createCheckoutFacts(cwd, { remoteUrl: null }), + ); + const getCheckoutStatus = vi.fn(async (cwd: string) => + createCheckoutStatus(cwd, { remoteUrl: null }), + ); const service = createService({ + getCheckoutSnapshotFacts, getCheckoutStatus, now: () => new Date(nowMs), }); const first = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); const second = service.registerWorkspace({ cwd: join(REPO_CWD, ".") }, vi.fn()); - await flushPromises(); + await service.getSnapshot(REPO_CWD); - nowMs = 60_000; - await vi.advanceTimersByTimeAsync(60_000); + nowMs = 120_000; + await vi.advanceTimersByTimeAsync(120_000); await flushPromises(); expect(getCheckoutStatus).toHaveBeenCalledTimes(2); @@ -1420,11 +1504,12 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); subscription.unsubscribe(); - nowMs = 60_000; - await vi.advanceTimersByTimeAsync(60_000); + nowMs = 600_000; + await vi.advanceTimersByTimeAsync(600_000); await flushPromises(); expect(getCheckoutStatus).toHaveBeenCalledTimes(0); + expect(vi.getTimerCount()).toBe(0); service.dispose(); }); @@ -1439,8 +1524,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); service.dispose(); - nowMs = 60_000; - await vi.advanceTimersByTimeAsync(60_000); + nowMs = 600_000; + await vi.advanceTimersByTimeAsync(600_000); await flushPromises(); expect(getCheckoutStatus).toHaveBeenCalledTimes(0); @@ -1453,15 +1538,19 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { .fn<() => Promise>() .mockImplementationOnce(async () => createCheckoutStatus(REPO_CWD)) .mockImplementationOnce(async () => selfHealRefresh.promise); + const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => + createCheckoutFacts(cwd, { remoteUrl: null }), + ); const service = createService({ + getCheckoutSnapshotFacts, getCheckoutStatus, now: () => new Date(nowMs), }); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); - await flushPromises(); + await service.getSnapshot(REPO_CWD); - nowMs = 60_000; - await vi.advanceTimersByTimeAsync(60_000); + nowMs = 120_000; + await vi.advanceTimersByTimeAsync(120_000); await flushPromises(); const directRead = service.getSnapshot(REPO_CWD); await flushPromises(); @@ -1812,8 +1901,10 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { const first = service.resolveRepoRoot(REPO_CWD); const second = service.resolveRepoRoot(join(REPO_CWD, ".")); + await vi.waitFor(() => { + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); + }); await flushPromises(); - expect(getCheckoutStatus).toHaveBeenCalledTimes(1); checkoutDeferred.resolve(createCheckoutStatus(REPO_CWD)); await expect(Promise.all([first, second])).resolves.toEqual([REPO_CWD, REPO_CWD]); @@ -2024,54 +2115,42 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { service.dispose(); }); - // POSIX-only: this asserts Linux working-tree walker behavior around ignored directories. - test.skipIf(isPlatform("win32"))( - "Linux working tree walker excludes gitignored directories", - async () => { - const originalPlatform = process.platform; - Object.defineProperty(process, "platform", { configurable: true, value: "linux" }); + test("working tree observation prunes gitignored directories", async () => { + const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "workspace-git-service-ignored-"))); + const repoDir = join(tempDir, "repo"); + mkdirSync(join(repoDir, "ignored", "deep"), { recursive: true }); + mkdirSync(join(repoDir, "kept"), { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" }); + writeFileSync(join(repoDir, ".gitignore"), "ignored/\n"); + writeFileSync(join(repoDir, "ignored", "log.txt"), "noise\n"); + writeFileSync(join(repoDir, "ignored", "deep", "log.txt"), "noise\n"); + writeFileSync(join(repoDir, "kept", "file.txt"), "keep\n"); - const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "workspace-git-service-ignored-"))); - const repoDir = join(tempDir, "repo"); - mkdirSync(join(repoDir, "ignored", "deep"), { recursive: true }); - mkdirSync(join(repoDir, "kept"), { recursive: true }); - execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" }); - writeFileSync(join(repoDir, ".gitignore"), "ignored/\n"); - writeFileSync(join(repoDir, "ignored", "log.txt"), "noise\n"); - writeFileSync(join(repoDir, "ignored", "deep", "log.txt"), "noise\n"); - writeFileSync(join(repoDir, "kept", "file.txt"), "keep\n"); + const subscribe = vi.fn(async () => createAsyncSubscription()); - const watchedPaths: string[] = []; - const watchSpy = (watchPath: string) => { - watchedPaths.push(watchPath); - return { close: vi.fn(), on: vi.fn().mockReturnThis() }; - }; + const service = createService({ + subscribe, + runGitCommand: runGitCommandReal as never, + getCheckoutSnapshotFacts: getCheckoutSnapshotFactsUncached as never, + getCheckoutStatus: getCheckoutStatusUncached as never, + resolveAbsoluteGitDir: resolveAbsoluteGitDirReal as never, + }); - const service = createService({ - watch: watchSpy as never, - readdir: readdir as never, - runGitCommand: runGitCommandReal as never, - getCheckoutSnapshotFacts: getCheckoutSnapshotFactsUncached as never, - getCheckoutStatus: getCheckoutStatusUncached as never, - resolveAbsoluteGitDir: resolveAbsoluteGitDirReal as never, + try { + const subscription = await service.requestWorkingTreeWatch(repoDir, vi.fn()); + + const ignoredRoot = join(repoDir, "ignored"); + expect(subscribe).toHaveBeenCalledTimes(1); + expect(subscribe).toHaveBeenCalledWith(repoDir, expect.any(Function), { + ignore: [join(repoDir, ".git"), ignoredRoot], }); - try { - const subscription = await service.requestWorkingTreeWatch(repoDir, vi.fn()); - - const ignoredRoot = join(repoDir, "ignored"); - expect(watchedPaths.filter((path) => path.startsWith(ignoredRoot))).toEqual([]); - expect(watchedPaths).toContain(repoDir); - expect(watchedPaths).toContain(join(repoDir, "kept")); - - subscription.unsubscribe(); - } finally { - service.dispose(); - rmSync(tempDir, { recursive: true, force: true }); - Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform }); - } - }, - ); + subscription.unsubscribe(); + } finally { + service.dispose(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); test("onWorkspaceStateMayHaveChanged invalidates github cache and schedules a forced github-inclusive refresh", async () => { const github = createGitHubServiceStub(); diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index ff95edc3f..a97d02e84 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import os from "node:os"; import path, { join } from "node:path"; -import type { FSWatcher } from "node:fs"; import type pino from "pino"; import type { ForgeService } from "../services/forge-service.js"; import type { @@ -13,7 +12,6 @@ import { WorkspaceGitServiceImpl, type WorkspaceGitRuntimeSnapshot, } from "./workspace-git-service.js"; -import { isPlatform } from "../test-utils/platform.js"; const REPO_CWD = path.resolve("/tmp/repo"); @@ -182,21 +180,6 @@ function createPullRequestStatusResult( }; } -function createWatcher(): FSWatcher & { close: ReturnType } { - const watcher = { - close: vi.fn(), - on: vi.fn().mockReturnThis(), - }; - return watcher as unknown as FSWatcher & { close: ReturnType }; -} - -function createDirent(name: string, isDirectory: boolean) { - return { - name, - isDirectory: () => isDirectory, - }; -} - async function flushPromises(): Promise { for (let i = 0; i < 6; i++) { await Promise.resolve(); @@ -213,6 +196,10 @@ function createDeferred() { return { promise, resolve, reject }; } +function createAsyncSubscription() { + return { unsubscribe: vi.fn(() => Promise.resolve()) }; +} + function createGitHubServiceStub(): ForgeService { return { listPullRequests: vi.fn(async () => []), @@ -255,30 +242,31 @@ function createGitHubServiceStub(): ForgeService { } interface CreateServiceTestOptions { + subscribe?: ReturnType; getCheckoutStatus?: ReturnType; getCheckoutSnapshotFacts?: ReturnType; getCheckoutShortstat?: ReturnType; + getCheckoutWorktreeState?: ReturnType; getPullRequestStatus?: ReturnType; github?: ForgeService; resolveAbsoluteGitDir?: ReturnType; hasOriginRemote?: ReturnType; runGitFetch?: ReturnType; runGitCommand?: ReturnType; - readdir?: ReturnType; - watch?: ReturnType; + getWorkspaceGitSelfHealPhaseMs?: (cwd: string) => number; now?: () => Date; } function buildDefaultTestServiceDeps() { return { - watch: (() => createWatcher()) as unknown as typeof import("node:fs").watch, - readdir: vi.fn(async () => []), + subscribe: vi.fn(async () => ({ unsubscribe: vi.fn(async () => {}) })), getCheckoutSnapshotFacts: vi.fn(async (cwd: string) => createCheckoutSnapshotFacts(cwd)), getCheckoutStatus: vi.fn(async (cwd: string) => createCheckoutStatus(cwd)), getCheckoutShortstat: vi.fn(async () => ({ additions: 1, deletions: 0, })), + getCheckoutWorktreeState: vi.fn(), getPullRequestStatus: vi.fn(async () => createPullRequestStatusResult()), forgeOverrides: { github: createGitHubServiceStub() }, resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")), @@ -291,15 +279,29 @@ function buildDefaultTestServiceDeps() { exitCode: 0, signal: null, })), + getWorkspaceGitSelfHealPhaseMs: vi.fn(() => 30_000), now: () => new Date("2026-04-12T00:00:00.000Z"), }; } function createService(options?: CreateServiceTestOptions) { + const deps = { ...buildDefaultTestServiceDeps(), ...options }; + deps.getCheckoutWorktreeState = + options?.getCheckoutWorktreeState ?? + vi.fn(async (cwd: string) => { + const status = await deps.getCheckoutStatus(cwd); + if (!status.isGit) { + throw new Error("Expected a git checkout"); + } + return { + isDirty: status.isDirty, + diffStat: await deps.getCheckoutShortstat(), + }; + }); return new WorkspaceGitServiceImpl({ logger: createLogger() as unknown as pino.Logger, paseoHome: "/tmp/paseo-test", - deps: { ...buildDefaultTestServiceDeps(), ...options }, + deps, }); } @@ -480,9 +482,9 @@ describe("WorkspaceGitServiceImpl", () => { const firstSnapshotPromise = service.getSnapshot(REPO_CWD); const secondSnapshotPromise = service.getSnapshot(join(REPO_CWD, ".")); - await flushPromises(); - - expect(getCheckoutStatus).toHaveBeenCalledTimes(1); + await vi.waitFor(() => { + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); + }); expect(getPullRequestStatus).toHaveBeenCalledTimes(0); expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0); @@ -563,7 +565,8 @@ describe("WorkspaceGitServiceImpl", () => { }); test("repo-level fetch intervals are shared for workspaces in the same repo", async () => { - const runGitFetch = vi.fn(async () => {}); + const firstFetch = createDeferred(); + const runGitFetch = vi.fn(() => firstFetch.promise); const hasOriginRemote = vi.fn(async () => true); const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({ ...createCheckoutSnapshotFacts(cwd), @@ -587,12 +590,20 @@ describe("WorkspaceGitServiceImpl", () => { await vi.waitFor(() => { expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2); expect(runGitFetch).toHaveBeenCalledTimes(1); + expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); }); expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0); expect(hasOriginRemote).toHaveBeenCalledTimes(0); expect(runGitFetch).toHaveBeenCalledTimes(1); + firstFetch.resolve(); + await flushPromises(); + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(() => { + expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(4); + }); + await vi.advanceTimersByTimeAsync(180_000); await flushPromises(); @@ -771,69 +782,31 @@ describe("WorkspaceGitServiceImpl", () => { service.dispose(); }); - // POSIX-only: this asserts Linux recursive-watch fallback behavior. - test.skipIf(isPlatform("win32"))("watches nested repository directories on Linux", async () => { - const originalPlatform = process.platform; - Object.defineProperty(process, "platform", { - configurable: true, - value: "linux", - }); + test("watches nested repository changes through one recursive checkout subscription", async () => { + const unsubscribe = vi.fn(async () => {}); + const subscribe = vi.fn(async () => ({ unsubscribe })); + const service = createService({ subscribe }); - const watchCalls: Array<{ path: string; close: ReturnType }> = []; - const watch = vi.fn((watchPath: string) => { - const watcher = createWatcher(); - watchCalls.push({ path: watchPath, close: watcher.close }); - return watcher; - }); - const readdir = vi.fn(async (directory: string) => { - if (directory === REPO_CWD) { - return [ - createDirent("packages", true), - createDirent(".git", true), - createDirent("README.md", false), - ]; - } - if (directory === path.join(REPO_CWD, "packages")) { - return [createDirent("server", true), createDirent("app", true)]; - } - if (directory === path.join(REPO_CWD, "packages", "server")) { - return [createDirent("src", true)]; - } - if (directory === path.join(REPO_CWD, "packages", "server", "src")) { - return [createDirent("server", true)]; - } - return []; - }); - - const service = createService({ watch, readdir }); const subscription = await service.requestWorkingTreeWatch( path.join(REPO_CWD, "packages", "server"), vi.fn(), ); expect(subscription.repoRoot).toBe(REPO_CWD); - expect(watchCalls.map((entry) => entry.path).sort()).toEqual([ - REPO_CWD, - join(REPO_CWD, ".git"), - join(REPO_CWD, "packages"), - join(REPO_CWD, "packages", "app"), - join(REPO_CWD, "packages", "server"), - join(REPO_CWD, "packages", "server", "src"), - join(REPO_CWD, "packages", "server", "src", "server"), - ]); + expect(subscribe).toHaveBeenCalledTimes(1); + expect(subscribe).toHaveBeenCalledWith(REPO_CWD, expect.any(Function), { + ignore: [join(REPO_CWD, ".git")], + }); subscription.unsubscribe(); + expect(unsubscribe).toHaveBeenCalledTimes(1); service.dispose(); - Object.defineProperty(process, "platform", { - configurable: true, - value: originalPlatform, - }); }); test("requestWorkingTreeWatch reference-counts watchers by cwd", async () => { - const watchers = [createWatcher(), createWatcher()]; - const watch = vi.fn().mockReturnValueOnce(watchers[0]).mockReturnValueOnce(watchers[1]); - const service = createService({ watch }); + const unsubscribe = vi.fn(async () => {}); + const subscribe = vi.fn(async () => ({ unsubscribe })); + const service = createService({ subscribe }); const firstListener = vi.fn(); const secondListener = vi.fn(); @@ -842,39 +815,22 @@ describe("WorkspaceGitServiceImpl", () => { expect(first.repoRoot).toBe(REPO_CWD); expect(second.repoRoot).toBe(REPO_CWD); - expect(watch).toHaveBeenCalledTimes(2); + expect(subscribe).toHaveBeenCalledTimes(1); first.unsubscribe(); - expect(watchers[0].close).not.toHaveBeenCalled(); - expect(watchers[1].close).not.toHaveBeenCalled(); + expect(unsubscribe).not.toHaveBeenCalled(); second.unsubscribe(); - expect(watchers[0].close).toHaveBeenCalledTimes(1); - expect(watchers[1].close).toHaveBeenCalledTimes(1); + expect(unsubscribe).toHaveBeenCalledTimes(1); service.dispose(); }); - test("sets a 5-second fallback polling interval when recursive watch is unavailable", async () => { - if (process.platform === "linux") { - // On Linux, recursive watch is never attempted — the service uses per-directory - // watchers from the start. This scenario only applies to macOS/Windows where - // recursive watch is tried first and may fail. - return; - } - - const recursiveUnsupported = new Error("recursive unsupported"); - const watch = vi - .fn() - .mockImplementationOnce((_watchPath: string, options: { recursive: boolean }) => { - if (options.recursive) { - throw recursiveUnsupported; - } - return createWatcher(); - }) - .mockImplementationOnce(() => createWatcher()); - - const service = createService({ watch }); + test("sets a bounded polling fallback when recursive observation is unavailable", async () => { + const subscribe = vi.fn(async () => { + throw new Error("watcher unavailable"); + }); + const service = createService({ subscribe }); const subscription = await service.requestWorkingTreeWatch(REPO_CWD, vi.fn()); expect(vi.getTimerCount()).toBe(1); @@ -884,13 +840,13 @@ describe("WorkspaceGitServiceImpl", () => { }); test("non-git directories fall back to watching cwd with polling", async () => { - const watch = vi.fn(() => createWatcher()); + const subscribe = vi.fn(async () => createAsyncSubscription()); const runGitCommand = vi.fn(async () => { throw new Error("not a git repository"); }); const resolveAbsoluteGitDir = vi.fn(async () => null); const service = createService({ - watch, + subscribe, runGitCommand, resolveAbsoluteGitDir, }); @@ -899,12 +855,9 @@ describe("WorkspaceGitServiceImpl", () => { const subscription = await service.requestWorkingTreeWatch(plainCwd, vi.fn()); expect(subscription.repoRoot).toBeNull(); - const expectedRecursive = process.platform !== "linux"; - expect(watch).toHaveBeenCalledWith( - plainCwd, - { recursive: expectedRecursive }, - expect.any(Function), - ); + expect(subscribe).toHaveBeenCalledWith(plainCwd, expect.any(Function), { + ignore: [join(plainCwd, ".git")], + }); expect(vi.getTimerCount()).toBe(1); subscription.unsubscribe(); @@ -912,20 +865,20 @@ describe("WorkspaceGitServiceImpl", () => { }); test("working tree changes notify watch listeners immediately", async () => { - const watchCallbacks: Array<() => void> = []; - const watch = vi.fn( - (_watchPath: string, _options: { recursive: boolean }, callback: () => void) => { - watchCallbacks.push(callback); - return createWatcher(); - }, - ); - const service = createService({ watch }); + const watchCallbacks: Array< + (error: Error | null, events: Array<{ path: string; type: "update" }>) => void + > = []; + const subscribe = vi.fn(async (_path: string, callback: (typeof watchCallbacks)[number]) => { + watchCallbacks.push(callback); + return createAsyncSubscription(); + }); + const service = createService({ subscribe }); const listener = vi.fn(); const subscription = await service.requestWorkingTreeWatch(REPO_CWD, listener); - expect(watchCallbacks).toHaveLength(2); + expect(watchCallbacks).toHaveLength(1); - watchCallbacks[0]?.(); + watchCallbacks[0]?.(null, [{ path: join(REPO_CWD, "file.ts"), type: "update" }]); expect(listener).toHaveBeenCalledTimes(1); @@ -934,40 +887,50 @@ describe("WorkspaceGitServiceImpl", () => { }); test("working tree changes force a fresh diff stat for workspace subscribers", async () => { - const watchCallbacks: Array<{ path: string; callback: () => void }> = []; - const watch = vi.fn( - (watchPath: string, _options: { recursive: boolean }, callback: () => void) => { + const watchCallbacks: Array<{ + path: string; + callback: (error: Error | null, events: Array<{ path: string; type: "update" }>) => void; + }> = []; + const subscribe = vi.fn( + async (watchPath: string, callback: (typeof watchCallbacks)[number]["callback"]) => { watchCallbacks.push({ path: watchPath, callback }); - return createWatcher(); + return createAsyncSubscription(); }, ); - const getCheckoutShortstat = vi - .fn() - .mockResolvedValueOnce({ additions: 1, deletions: 0 }) - .mockResolvedValueOnce({ additions: 8, deletions: 3 }); - const service = createService({ getCheckoutShortstat, watch }); + const getCheckoutWorktreeState = vi.fn(async () => ({ + isDirty: true, + diffStat: { additions: 8, deletions: 3 }, + })); + const backgroundFetch = createDeferred(); + const service = createService({ + getCheckoutWorktreeState, + runGitFetch: vi.fn(() => backgroundFetch.promise), + subscribe, + }); const workspaceListener = vi.fn(); const initialSnapshot = await service.getSnapshot(REPO_CWD); const workspaceSubscription = service.registerWorkspace({ cwd: REPO_CWD }, workspaceListener); const diffSubscription = await service.requestWorkingTreeWatch(REPO_CWD, vi.fn()); + await vi.waitFor(() => { + expect(service.getMetrics().repositoryWorkspaceLinkCount).toBe(1); + }); expect(initialSnapshot.git.diffStat).toEqual({ additions: 1, deletions: 0 }); const repoRootWatch = watchCallbacks.find((entry) => entry.path === REPO_CWD); expect(repoRootWatch).toBeDefined(); - repoRootWatch?.callback(); + repoRootWatch?.callback(null, [{ path: join(REPO_CWD, "file.ts"), type: "update" }]); await vi.advanceTimersByTimeAsync(1_000); await flushPromises(); - expect(getCheckoutShortstat).toHaveBeenLastCalledWith( + expect(getCheckoutWorktreeState).toHaveBeenCalledWith( REPO_CWD, expect.objectContaining({ paseoHome: "/tmp/paseo-test" }), - { force: true }, ); expect(workspaceListener).toHaveBeenCalledWith( createSnapshot(REPO_CWD, { - git: { diffStat: { additions: 8, deletions: 3 } }, + git: { isDirty: true, diffStat: { additions: 8, deletions: 3 } }, }), ); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 7a2a108bb..0fbf9d30f 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -1,8 +1,8 @@ -import { watch, type FSWatcher } from "node:fs"; -import { readFile, readdir } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; import { join, resolve } from "node:path"; +import parcelWatcher from "@parcel/watcher"; import { LRUCache } from "lru-cache"; -import pLimit from "p-limit"; import type pino from "pino"; import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages"; import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; @@ -17,6 +17,7 @@ import { getCheckoutSnapshotFacts, getCheckoutShortstat, getCheckoutStatus, + getCheckoutWorktreeState, getPullRequestStatus, forgeAuthStateFromError, hasOriginRemote, @@ -38,6 +39,7 @@ import { type ForgeResolver, } from "../services/forge-resolver.js"; import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js"; +import { createRealpathAwarePathMatcher, isRealpathInsideRoot } from "../utils/path.js"; import { runGitCommand } from "../utils/run-git-command.js"; import { listPaseoWorktrees, type PaseoWorktreeInfo } from "../utils/worktree.js"; import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js"; @@ -50,7 +52,7 @@ export const WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS = 60_000; const FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS = 20_000; const FORGE_PR_STATUS_POLL_SLOW_INTERVAL_MS = 120_000; const FORGE_PR_STATUS_POLL_ERROR_BACKOFF_CAP_MS = 300_000; -const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_000; +const DEGRADED_GIT_POLL_INTERVAL_MS = 5_000; // Auxiliary reads may reuse cached values within this window; snapshots do not expire on read. const WORKSPACE_GIT_AUXILIARY_READ_TTL_MS = 15_000; // Non-forced refresh triggers share this minimum gap to absorb watcher/self-heal bursts; force bypasses it. @@ -59,14 +61,12 @@ const WORKSPACE_GIT_INTERNAL_MIN_GAP_MS = 2_000; const WORKSPACE_GIT_CHECKOUT_DIFF_CACHE_MAX = 64; // Small values (booleans, short strings, small arrays); generous cap. const WORKSPACE_GIT_AUXILIARY_CACHE_MAX = 256; -const WORKSPACE_GIT_FACTS_REUSE_TTL_MS = 1_000; -const LINUX_WATCH_MAX_DIRS = 5_000; -const LINUX_WATCH_REFRESH_COOLDOWN_MS = 2_000; -const LINUX_WATCH_IGNORE_TTL_MS = 5 * 60 * 1_000; -const linuxWatchReaddirConcurrency = - parseInt(process.env.PASEO_LINUX_WATCH_READDIR_CONCURRENCY ?? "16", 10) || 16; -const linuxWatchReaddirLimit = pLimit(linuxWatchReaddirConcurrency); +export function getWorkspaceGitSelfHealPhaseMs(cwd: string): number { + return ( + createHash("sha256").update(cwd).digest().readUInt32BE(0) % WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS + ); +} export interface WorkspaceGitRuntimeSnapshot { cwd: string; @@ -243,15 +243,22 @@ export type WorkspaceGitSnapshotOptions = interface WorkspaceGitRefreshRequest { force: boolean; + refreshStructure: boolean; + refreshWorktree: boolean; includeForge: boolean; + emitUnchanged?: boolean; reason: string; notify: boolean; + queueIfBusy: boolean; } interface ScheduledWorkspaceGitRefreshOptions { force?: boolean; + scope?: "structure" | "worktree"; includeForge?: boolean; + emitUnchanged?: boolean; reason?: string; + queueIfBusy?: boolean; } type WorkspaceGitRefreshState = @@ -261,17 +268,16 @@ type WorkspaceGitRefreshState = | { status: "in-flight"; promise: Promise; - force: boolean; - includeForge: boolean; + request: WorkspaceGitRefreshRequest; queued: WorkspaceGitRefreshRequest | null; }; interface WorkspaceGitServiceDependencies { - watch: typeof watch; - readdir: typeof readdir; + subscribe: typeof parcelWatcher.subscribe; getCheckoutSnapshotFacts: typeof getCheckoutSnapshotFacts; getCheckoutStatus: typeof getCheckoutStatus; getCheckoutShortstat: typeof getCheckoutShortstat; + getCheckoutWorktreeState: typeof getCheckoutWorktreeState; getCheckoutDiff: typeof getCheckoutDiff; getPullRequestStatus: typeof getPullRequestStatus; resolveBranchCheckout: typeof resolveBranchCheckout; @@ -288,6 +294,7 @@ interface WorkspaceGitServiceDependencies { hasOriginRemote: (cwd: string) => Promise; runGitFetch: (cwd: string) => Promise; runGitCommand: typeof runGitCommand; + getWorkspaceGitSelfHealPhaseMs: typeof getWorkspaceGitSelfHealPhaseMs; now: () => Date; } @@ -301,7 +308,7 @@ interface WorkspaceGitServiceOptions { interface WorkspaceGitTarget { cwd: string; listeners: Set; - watchers: FSWatcher[]; + workingTreeWatchTarget: WorkingTreeWatchTarget | null; debounceTimer: NodeJS.Timeout | null; pendingDebounceRequest: WorkspaceGitRefreshRequest | null; selfHealTimer: NodeJS.Timeout | null; @@ -310,12 +317,14 @@ interface WorkspaceGitTarget { refreshState: WorkspaceGitRefreshState; latestGit: WorkspaceGitRuntimeSnapshot["git"] | null; latestGitLoadedAtMs: number | null; + latestStructuralRefreshAtMs: number | null; + latestWorktreeRefreshAtMs: number | null; + auditWindowStartedAtMs: number | null; latestForge: WorkspaceGitRuntimeSnapshot["forge"] | null; latestForgeLoadedAtMs: number | null; latestSnapshot: WorkspaceGitRuntimeSnapshot | null; latestSnapshotLoadedAtMs: number | null; latestFacts: CheckoutSnapshotFacts | null; - latestFactsLoadedAtMs: number | null; factsPromise: Promise | null; latestFingerprint: string | null; lastShellOutAtMs: number | null; @@ -329,20 +338,25 @@ interface RepoGitTarget { repoGitRoot: string; cwd: string; workspaceKeys: Set; + subscription: parcelWatcher.AsyncSubscription | null; + fallbackPollTimer: NodeJS.Timeout | null; intervalId: NodeJS.Timeout | null; fetchInFlight: boolean; + closed: boolean; } interface WorkingTreeWatchTarget { cwd: string; + watchPath: string; repoRoot: string | null; - repoWatchPath: string | null; - watchers: FSWatcher[]; - watchedPaths: Set; - fallbackRefreshInterval: NodeJS.Timeout | null; - linuxTreeRefreshPromise: Promise | null; - linuxTreeRefreshQueued: boolean; + subscription: parcelWatcher.AsyncSubscription | null; + ignoredDirectories: Set; + ignoredDirectoriesRefreshPromise: Promise | null; + aliases: Set; + workspaceKeys: Set; + fallbackPollTimer: NodeJS.Timeout | null; listeners: Set<() => void>; + closed: boolean; } interface WorkspaceGitAuxiliaryReadCacheEntry { @@ -360,11 +374,11 @@ interface WorkspaceForgePrStatusPollTarget { function buildDefaultWorkspaceGitServiceDeps(): WorkspaceGitServiceDependencies { return { - watch, - readdir, + subscribe: parcelWatcher.subscribe, getCheckoutSnapshotFacts, getCheckoutStatus, getCheckoutShortstat, + getCheckoutWorktreeState, getCheckoutDiff, getPullRequestStatus, resolveBranchCheckout, @@ -375,6 +389,7 @@ function buildDefaultWorkspaceGitServiceDeps(): WorkspaceGitServiceDependencies hasOriginRemote, runGitFetch, runGitCommand, + getWorkspaceGitSelfHealPhaseMs, now: () => new Date(), }; } @@ -396,7 +411,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private readonly repoTargets = new Map(); private readonly workingTreeWatchTargets = new Map(); private readonly workingTreeWatchSetups = new Map>(); - private readonly linuxIgnoredDirsCache = new Map; ts: number }>(); + private readonly workingTreeWatchResolutions = new Map>(); + private readonly workingTreeWatchAliases = new Map(); private readonly branchValidationCache = new LRUCache< string, WorkspaceGitAuxiliaryReadCacheEntry @@ -603,6 +619,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { ]); } + private invalidateCheckoutDiffCache(cwd: string, mode: CheckoutDiffCompare["mode"]): void { + for (const key of this.checkoutDiffCache.keys()) { + const [kind, cachedCwd, cachedMode] = JSON.parse(key) as unknown[]; + if (kind === "checkout-diff" && cachedCwd === cwd && cachedMode === mode) { + this.checkoutDiffCache.delete(key); + } + } + } + validateBranchRef( cwd: string, ref: string, @@ -721,9 +746,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const target = this.ensureWorkspaceTarget(cwd); await this.refreshWorkspaceTarget(target, { force: false, + refreshStructure: true, + refreshWorktree: true, includeForge: false, reason: "refresh", notify: true, + queueIfBusy: false, }); this.scheduleWorkspaceObservationSetup(target); } @@ -739,7 +767,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return { repoRoot: target.repoRoot, unsubscribe: () => { - this.removeWorkingTreeWatchListener(cwd, onChange); + this.removeWorkingTreeWatchListener(target.cwd, onChange); }, }; } @@ -748,7 +776,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { cwd = resolve(cwd); const target = this.workspaceTargets.get(cwd); if (target) { - this.scheduleWorkspaceRefresh(target); + this.scheduleWorkspaceRefresh(target, { queueIfBusy: false }); } } @@ -791,6 +819,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } this.workingTreeWatchTargets.clear(); this.workingTreeWatchSetups.clear(); + this.workingTreeWatchResolutions.clear(); + this.workingTreeWatchAliases.clear(); this.snapshotUpdatedListeners.clear(); } @@ -864,29 +894,68 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return entry; } - private async ensureWorkingTreeWatchTarget(cwd: string): Promise { - const existingTarget = this.workingTreeWatchTargets.get(cwd); + private ensureWorkingTreeWatchTarget(cwd: string): Promise { + const targetCwd = this.workingTreeWatchAliases.get(cwd); + if (targetCwd) { + const existingTarget = this.workingTreeWatchTargets.get(targetCwd); + if (existingTarget) { + return Promise.resolve(existingTarget); + } + this.workingTreeWatchAliases.delete(cwd); + } + + const existingResolution = this.workingTreeWatchResolutions.get(cwd); + if (existingResolution) { + return existingResolution; + } + + const resolution = this.resolveWorkingTreeWatchTarget(cwd).finally(() => { + if (this.workingTreeWatchResolutions.get(cwd) === resolution) { + this.workingTreeWatchResolutions.delete(cwd); + } + }); + this.workingTreeWatchResolutions.set(cwd, resolution); + return resolution; + } + + private async resolveWorkingTreeWatchTarget(cwd: string): Promise { + const repoRoot = await this.resolveCheckoutWatchRoot(cwd); + const targetCwd = repoRoot ?? cwd; + const existingTarget = this.workingTreeWatchTargets.get(targetCwd); if (existingTarget) { + this.rememberWorkingTreeWatchAlias(existingTarget, cwd); return existingTarget; } - const existingSetup = this.workingTreeWatchSetups.get(cwd); + const existingSetup = this.workingTreeWatchSetups.get(targetCwd); if (existingSetup) { - return existingSetup; + const target = await existingSetup; + this.rememberWorkingTreeWatchAlias(target, cwd); + return target; } - const setup = this.createWorkingTreeWatchTarget(cwd).finally(() => { - this.workingTreeWatchSetups.delete(cwd); + const watchPath = repoRoot && createRealpathAwarePathMatcher(repoRoot)(cwd) ? cwd : targetCwd; + const setup = this.createWorkingTreeWatchTarget(targetCwd, watchPath, repoRoot).finally(() => { + if (this.workingTreeWatchSetups.get(targetCwd) === setup) { + this.workingTreeWatchSetups.delete(targetCwd); + } }); - this.workingTreeWatchSetups.set(cwd, setup); - return setup; + this.workingTreeWatchSetups.set(targetCwd, setup); + const target = await setup; + this.rememberWorkingTreeWatchAlias(target, cwd); + return target; + } + + private rememberWorkingTreeWatchAlias(target: WorkingTreeWatchTarget, cwd: string): void { + target.aliases.add(cwd); + this.workingTreeWatchAliases.set(cwd, target.cwd); } private createWorkspaceTarget(cwd: string): WorkspaceGitTarget { const target: WorkspaceGitTarget = { cwd, listeners: new Set(), - watchers: [], + workingTreeWatchTarget: null, debounceTimer: null, pendingDebounceRequest: null, selfHealTimer: null, @@ -895,12 +964,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { refreshState: { status: "idle" }, latestGit: null, latestGitLoadedAtMs: null, + latestStructuralRefreshAtMs: null, + latestWorktreeRefreshAtMs: null, + auditWindowStartedAtMs: null, latestForge: null, latestForgeLoadedAtMs: null, latestSnapshot: null, latestSnapshotLoadedAtMs: null, latestFacts: null, - latestFactsLoadedAtMs: null, factsPromise: null, latestFingerprint: null, lastShellOutAtMs: null, @@ -921,9 +992,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } void this.refreshWorkspaceTarget(target, { force: false, + refreshStructure: true, + refreshWorktree: true, includeForge: true, reason: "initial", notify: true, + queueIfBusy: false, }); }); } @@ -952,61 +1026,63 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private async setupWorkspaceObservation(target: WorkspaceGitTarget): Promise { const facts = await this.getFactsForObservation(target); - const gitDir = facts?.isGit ? facts.absoluteGitDir : null; if (!this.isActiveObservedWorkspaceTarget(target)) { return; } - if (!gitDir) { + const watchCwd = facts.isGit ? facts.worktreeRoot : target.cwd; + const workingTreeTargetPromise = this.ensureWorkingTreeWatchTarget(watchCwd); + const workingTreeTarget = await workingTreeTargetPromise; + if (!this.isActiveObservedWorkspaceTarget(target)) { + queueMicrotask(() => this.closeWorkingTreeWatchTargetIfUnused(workingTreeTarget)); + return; + } + if (target.workingTreeWatchTarget !== workingTreeTarget) { + if (target.workingTreeWatchTarget) { + this.removeWorkspaceWorkingTreeLink(target.workingTreeWatchTarget, target.cwd); + } + target.workingTreeWatchTarget = workingTreeTarget; + } + workingTreeTarget.workspaceKeys.add(target.cwd); + + if (!facts.isGit || !facts.absoluteGitDir) { target.observationSetupComplete = true; return; } - - const repoGitRoot = - facts?.isGit && facts.gitCommonDir - ? facts.gitCommonDir - : await this.resolveWorkspaceGitRefsRoot(gitDir); + await this.promoteWorkingTreeWatchTarget(workingTreeTarget, facts.worktreeRoot); + const gitDir = facts.absoluteGitDir; + const repoGitRoot = facts.gitCommonDir ?? (await this.resolveWorkspaceGitRefsRoot(gitDir)); if (!this.isActiveObservedWorkspaceTarget(target)) { return; } target.repoGitRoot = repoGitRoot; - this.startWorkspaceWatchers(target, gitDir, repoGitRoot); await this.ensureRepoTarget(target); if (this.isActiveObservedWorkspaceTarget(target)) { target.observationSetupComplete = true; } } - private async getFactsForObservation( - target: WorkspaceGitTarget, - ): Promise { + private async getFactsForObservation(target: WorkspaceGitTarget): Promise { + if (target.latestFacts) { + return target.latestFacts; + } return this.loadCheckoutFacts(target, { paseoHome: this.paseoHome, logger: this.logger, - allowRecent: true, }); } private loadCheckoutFacts( target: WorkspaceGitTarget, - options: CheckoutContext & { allowRecent: boolean }, + context: CheckoutContext, ): Promise { - if (options.allowRecent && target.latestFacts && target.latestFactsLoadedAtMs !== null) { - const ageMs = this.deps.now().getTime() - target.latestFactsLoadedAtMs; - if (ageMs < WORKSPACE_GIT_FACTS_REUSE_TTL_MS) { - return Promise.resolve(target.latestFacts); - } - } - if (target.factsPromise) { return target.factsPromise; } - const { allowRecent: _allowRecent, ...context } = options; const promise = this.deps .getCheckoutSnapshotFacts(target.cwd, context) .then((facts) => { target.latestFacts = facts; - target.latestFactsLoadedAtMs = this.deps.now().getTime(); return facts; }) .finally(() => { @@ -1026,70 +1102,278 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { ); } - private async createWorkingTreeWatchTarget(cwd: string): Promise { - const repoRoot = await this.resolveCheckoutWatchRoot(cwd); + private async createWorkingTreeWatchTarget( + cwd: string, + watchPath: string, + repoRoot: string | null, + ): Promise { + const ignoredDirectories = repoRoot ? await this.loadIgnoredDirs(watchPath) : new Set(); const target: WorkingTreeWatchTarget = { cwd, + watchPath, repoRoot, - repoWatchPath: null, - watchers: [], - watchedPaths: new Set(), - fallbackRefreshInterval: null, - linuxTreeRefreshPromise: null, - linuxTreeRefreshQueued: false, + subscription: null, + ignoredDirectories, + ignoredDirectoriesRefreshPromise: null, + aliases: new Set([cwd]), + workspaceKeys: new Set(), + fallbackPollTimer: null, listeners: new Set(), + closed: false, }; - const repoWatchPath = repoRoot ?? cwd; - target.repoWatchPath = repoWatchPath; - const watchPaths = new Set([repoWatchPath]); - const gitDir = await this.deps.resolveAbsoluteGitDir(cwd); - if (gitDir) { - watchPaths.add(gitDir); - } + await this.startWorkingTreeSubscription(target); - let hasRecursiveRepoCoverage = false; - const allowRecursiveRepoWatch = process.platform !== "linux"; - if (process.platform === "linux") { - hasRecursiveRepoCoverage = await this.ensureLinuxRepoTreeWatchers(target, repoWatchPath); - } - for (const watchPath of watchPaths) { - if (process.platform === "linux" && watchPath === repoWatchPath) { - continue; - } - const shouldTryRecursive = watchPath === repoWatchPath && allowRecursiveRepoWatch; - const watcherIsRecursive = this.addWorkingTreeWatcher(target, watchPath, shouldTryRecursive); - if (watchPath === repoWatchPath && watcherIsRecursive) { - hasRecursiveRepoCoverage = true; - } - } - - const missingRepoCoverage = repoRoot === null || !hasRecursiveRepoCoverage; - if (target.watchers.length === 0 || missingRepoCoverage) { - target.fallbackRefreshInterval = setInterval(() => { - this.scheduleWorkspaceRefresh(cwd, { - force: true, - reason: "working-tree-watch-fallback", - }); - for (const listener of target.listeners) { - listener(); - } - }, WORKING_TREE_WATCH_FALLBACK_REFRESH_MS); - this.logger.warn( - { - cwd, - intervalMs: WORKING_TREE_WATCH_FALLBACK_REFRESH_MS, - reason: - target.watchers.length === 0 ? "no_watchers" : "missing_recursive_repo_root_coverage", - }, - "Working tree watchers unavailable; using timed refresh fallback", - ); + if (repoRoot === null) { + this.startWorkingTreeWatchFallback(target, "not_a_git_checkout"); } this.workingTreeWatchTargets.set(cwd, target); + this.workingTreeWatchAliases.set(cwd, cwd); return target; } + private async startWorkingTreeSubscription(target: WorkingTreeWatchTarget): Promise { + const ignore = [join(target.watchPath, ".git"), ...target.ignoredDirectories]; + try { + const subscription = await this.deps.subscribe( + target.watchPath, + (error, events) => { + if (error) { + this.logger.warn( + { err: error, cwd: target.cwd }, + "Working tree watcher error; using degraded polling", + ); + this.degradeWorkingTreeWatch(target, "watcher_error"); + return; + } + if (!this.hasRelevantWorkingTreeEvent(target, events)) { + return; + } + this.notifyWorkingTreeChanged(target, "working-tree-watch"); + }, + { ignore }, + ); + if (target.closed || target.fallbackPollTimer || target.subscription) { + await subscription.unsubscribe(); + } else { + target.subscription = subscription; + } + } catch (error) { + this.logger.warn( + { err: error, cwd: target.cwd }, + "Failed to start working tree watcher; using degraded polling", + ); + this.startWorkingTreeWatchFallback(target, "watcher_setup_failed"); + } + } + + private startWorkingTreeWatchFallback( + target: WorkingTreeWatchTarget, + reason: "not_a_git_checkout" | "watcher_error" | "watcher_setup_failed", + ): void { + if (target.fallbackPollTimer) { + return; + } + const { cwd } = target; + const poll = async () => { + target.fallbackPollTimer = null; + if (target.closed || this.workingTreeWatchTargets.get(target.cwd) !== target) { + return; + } + await Promise.all( + Array.from(target.workspaceKeys, async (workspaceKey) => { + const workspaceTarget = this.workspaceTargets.get(workspaceKey); + if (!workspaceTarget) { + return; + } + await this.refreshWorkspaceTarget(workspaceTarget, { + force: false, + refreshStructure: target.repoRoot === null, + refreshWorktree: true, + includeForge: false, + reason: "working-tree-watch-fallback", + notify: true, + queueIfBusy: true, + }); + if (target.repoRoot === null && workspaceTarget.latestGit?.isGit === true) { + workspaceTarget.observationSetupComplete = false; + this.scheduleWorkspaceObservationSetup(workspaceTarget); + } + }), + ); + this.notifyWorkingTreeConsumers(target); + if (!target.closed && (target.subscription === null || target.repoRoot === null)) { + target.fallbackPollTimer = setTimeout(poll, DEGRADED_GIT_POLL_INTERVAL_MS); + } + }; + target.fallbackPollTimer = setTimeout(poll, DEGRADED_GIT_POLL_INTERVAL_MS); + this.logger.warn( + { + cwd, + intervalMs: DEGRADED_GIT_POLL_INTERVAL_MS, + reason, + }, + "Working tree watcher unavailable; using bounded polling fallback", + ); + } + + private degradeWorkingTreeWatch(target: WorkingTreeWatchTarget, reason: "watcher_error"): void { + if (target.subscription) { + const subscription = target.subscription; + target.subscription = null; + void subscription.unsubscribe().catch((error) => { + this.logger.warn({ err: error, cwd: target.cwd }, "Failed to stop working tree watcher"); + }); + } + this.startWorkingTreeWatchFallback(target, reason); + } + + private async promoteWorkingTreeWatchTarget( + target: WorkingTreeWatchTarget, + repoRoot: string, + ): Promise { + if (target.repoRoot !== null) { + return; + } + target.repoRoot = repoRoot; + if (target.subscription && target.fallbackPollTimer) { + clearTimeout(target.fallbackPollTimer); + target.fallbackPollTimer = null; + } + await this.refreshWorkingTreeIgnoredDirectories(target); + } + + private hasRelevantWorkingTreeEvent( + target: WorkingTreeWatchTarget, + events: parcelWatcher.Event[], + ): boolean { + const gitDir = join(target.watchPath, ".git"); + const matchesWatchPath = createRealpathAwarePathMatcher(target.watchPath); + return events.some((event) => { + // Directory metadata changes at the watch root do not change Git state. + // FSEvents may emit these when every changed descendant was ignored. + if (matchesWatchPath(event.path)) { + return false; + } + if (isRealpathInsideRoot(gitDir, event.path)) { + return false; + } + for (const ignoredDirectory of target.ignoredDirectories) { + if (isRealpathInsideRoot(ignoredDirectory, event.path)) { + return false; + } + } + return true; + }); + } + + private refreshWorkingTreeIgnoredDirectories(target: WorkingTreeWatchTarget): Promise { + if (target.closed || target.repoRoot === null || target.fallbackPollTimer) { + return Promise.resolve(); + } + if (target.ignoredDirectoriesRefreshPromise) { + return target.ignoredDirectoriesRefreshPromise; + } + + const refreshPromise = this.replaceWorkingTreeIgnoredDirectories(target) + .catch((error) => { + this.logger.warn( + { err: error, cwd: target.cwd }, + "Failed to refresh working tree watcher ignore paths", + ); + }) + .finally(() => { + if (target.ignoredDirectoriesRefreshPromise === refreshPromise) { + target.ignoredDirectoriesRefreshPromise = null; + } + }); + target.ignoredDirectoriesRefreshPromise = refreshPromise; + return refreshPromise; + } + + private async replaceWorkingTreeIgnoredDirectories( + target: WorkingTreeWatchTarget, + ): Promise { + const ignoredDirectories = await this.loadIgnoredDirs(target.watchPath); + if ( + target.closed || + target.fallbackPollTimer || + this.haveSamePaths(target.ignoredDirectories, ignoredDirectories) + ) { + return; + } + + const subscription = target.subscription; + if (subscription) { + target.subscription = null; + try { + await subscription.unsubscribe(); + } catch (error) { + if (!target.closed && !target.fallbackPollTimer) { + target.subscription = subscription; + } + this.logger.warn( + { err: error, cwd: target.cwd }, + "Failed to stop working tree watcher while refreshing ignore paths", + ); + return; + } + } + if (target.closed || target.fallbackPollTimer) { + return; + } + + target.ignoredDirectories = ignoredDirectories; + await this.startWorkingTreeSubscription(target); + this.notifyWorkingTreeChanged(target, "working-tree-watch-reconfigured"); + } + + private haveSamePaths(first: Set, second: Set): boolean { + if (first.size !== second.size) { + return false; + } + for (const path of first) { + if (!second.has(path)) { + return false; + } + } + return true; + } + + private notifyWorkingTreeChanged(target: WorkingTreeWatchTarget, reason: string): void { + if (target.closed) { + return; + } + this.notifyWorkingTreeConsumers(target); + this.scheduleWorkingTreeRefreshes(target, reason); + } + + private notifyWorkingTreeConsumers(target: WorkingTreeWatchTarget): void { + for (const alias of target.aliases) { + this.invalidateCheckoutDiffCache(alias, "uncommitted"); + } + for (const listener of target.listeners) { + listener(); + } + } + + private scheduleWorkingTreeRefreshes(target: WorkingTreeWatchTarget, reason: string): void { + for (const workspaceKey of target.workspaceKeys) { + const workspaceTarget = this.workspaceTargets.get(workspaceKey); + if (workspaceTarget) { + this.scheduleWorkspaceRefresh(workspaceTarget, { scope: "worktree", reason }); + } + } + } + + private getWorkingTreeWatchTargetForWorkspace( + workspaceTarget: WorkspaceGitTarget, + ): WorkingTreeWatchTarget | null { + const target = workspaceTarget.workingTreeWatchTarget; + return target && !target.closed ? target : null; + } + private async resolveCheckoutWatchRoot(cwd: string): Promise { try { const { stdout } = await this.deps.runGitCommand(["rev-parse", "--show-toplevel"], { @@ -1115,35 +1399,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return gitDir; } - private startWorkspaceWatchers( - target: WorkspaceGitTarget, - gitDir: string, - repoGitRoot: string, - ): void { - for (const watchPath of new Set([join(gitDir, "HEAD"), join(repoGitRoot, "refs", "heads")])) { - let watcher: FSWatcher | null = null; - try { - watcher = this.deps.watch(watchPath, { recursive: false }, () => { - this.scheduleWorkspaceRefresh(target); - }); - } catch (error) { - this.logger.warn( - { err: error, cwd: target.cwd, watchPath }, - "Failed to start workspace git watcher", - ); - } - - if (!watcher) { - continue; - } - - watcher.on("error", (error) => { - this.logger.warn({ err: error, cwd: target.cwd, watchPath }, "Workspace git watcher error"); - }); - target.watchers.push(watcher); - } - } - private async ensureRepoTarget(workspaceTarget: WorkspaceGitTarget): Promise { const repoGitRoot = workspaceTarget.repoGitRoot; if (!repoGitRoot || !this.isActiveObservedWorkspaceTarget(workspaceTarget)) { @@ -1156,35 +1411,189 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return; } - const facts = workspaceTarget.latestFacts; + const repoTarget: RepoGitTarget = { + repoGitRoot, + cwd: workspaceTarget.cwd, + workspaceKeys: new Set([workspaceTarget.cwd]), + subscription: null, + fallbackPollTimer: null, + intervalId: null, + fetchInFlight: false, + closed: false, + }; + this.repoTargets.set(repoGitRoot, repoTarget); + await this.startRepoMetadataObservation(repoTarget); + if (repoTarget.closed || this.repoTargets.get(repoGitRoot) !== repoTarget) { + return; + } + + const fetchWorkspaceTarget = Array.from(repoTarget.workspaceKeys) + .map((workspaceKey) => this.workspaceTargets.get(workspaceKey)) + .find( + (candidate): candidate is WorkspaceGitTarget => + candidate !== undefined && this.isActiveObservedWorkspaceTarget(candidate), + ); + if (!fetchWorkspaceTarget) { + return; + } + repoTarget.cwd = fetchWorkspaceTarget.cwd; + const facts = fetchWorkspaceTarget.latestFacts; const hasOrigin = facts?.isGit === true ? facts.remoteUrl !== null - : await this.deps.hasOriginRemote(workspaceTarget.cwd); - if (!this.isActiveObservedWorkspaceTarget(workspaceTarget)) { + : await this.deps.hasOriginRemote(fetchWorkspaceTarget.cwd); + if (repoTarget.closed || this.repoTargets.get(repoGitRoot) !== repoTarget) { return; } if (!hasOrigin) { return; } + repoTarget.intervalId = setInterval(() => { + void this.runRepoFetch(repoTarget); + }, BACKGROUND_GIT_FETCH_INTERVAL_MS); + void this.runRepoFetch(repoTarget); + } - const targetAfterProbe = this.repoTargets.get(repoGitRoot); - if (targetAfterProbe) { - targetAfterProbe.workspaceKeys.add(workspaceTarget.cwd); + private async startRepoMetadataObservation(target: RepoGitTarget): Promise { + const ignore = [ + join(target.repoGitRoot, "hooks"), + join(target.repoGitRoot, "logs"), + join(target.repoGitRoot, "objects"), + ]; + const matchesRepoGitRoot = createRealpathAwarePathMatcher(target.repoGitRoot); + try { + const subscription = await this.deps.subscribe( + target.repoGitRoot, + (error, events) => { + if (error) { + this.logger.warn( + { err: error, repoGitRoot: target.repoGitRoot }, + "Repository metadata watcher error; using degraded polling", + ); + this.degradeRepoMetadataWatch(target); + return; + } + const hasRelevantEvent = events.some( + (event) => + !matchesRepoGitRoot(event.path) && + ignore.every((ignoredPath) => !isRealpathInsideRoot(ignoredPath, event.path)), + ); + if (hasRelevantEvent) { + this.scheduleRepoMetadataRefresh(target, "git-metadata-watch", true); + } + }, + { ignore }, + ); + if ( + target.closed || + target.fallbackPollTimer || + this.repoTargets.get(target.repoGitRoot) !== target + ) { + await subscription.unsubscribe(); + } else { + target.subscription = subscription; + } + } catch (error) { + this.logger.warn( + { err: error, repoGitRoot: target.repoGitRoot }, + "Failed to start repository metadata watcher; using degraded polling", + ); + this.startRepoMetadataFallback(target); + } + } + + private degradeRepoMetadataWatch(target: RepoGitTarget): void { + if (target.subscription) { + const subscription = target.subscription; + target.subscription = null; + void subscription.unsubscribe().catch((error) => { + this.logger.warn( + { err: error, repoGitRoot: target.repoGitRoot }, + "Failed to stop repository metadata watcher", + ); + }); + } + this.startRepoMetadataFallback(target); + } + + private scheduleRepoMetadataRefresh( + target: RepoGitTarget, + reason: string, + refreshWorktree: boolean, + ): void { + if (target.closed || this.repoTargets.get(target.repoGitRoot) !== target) { return; } + const workingTreeTargets = new Set(); + for (const workspaceKey of target.workspaceKeys) { + const workspaceTarget = this.workspaceTargets.get(workspaceKey); + if (workspaceTarget) { + this.invalidateCheckoutDiffCache(workspaceTarget.cwd, "base"); + if (workspaceTarget.latestFacts?.isGit) { + this.invalidateCheckoutDiffCache(workspaceTarget.latestFacts.worktreeRoot, "base"); + } + if (refreshWorktree) { + const workingTreeTarget = this.getWorkingTreeWatchTargetForWorkspace(workspaceTarget); + if (workingTreeTarget) { + workingTreeTargets.add(workingTreeTarget); + } + } + this.scheduleWorkspaceRefresh(workspaceTarget, { + scope: refreshWorktree ? undefined : "structure", + emitUnchanged: true, + reason, + }); + } + } + for (const workingTreeTarget of workingTreeTargets) { + this.notifyWorkingTreeConsumers(workingTreeTarget); + } + } - const repoTarget: RepoGitTarget = { - repoGitRoot, - cwd: workspaceTarget.cwd, - workspaceKeys: new Set([workspaceTarget.cwd]), - intervalId: setInterval(() => { - void this.runRepoFetch(repoTarget); - }, BACKGROUND_GIT_FETCH_INTERVAL_MS), - fetchInFlight: false, + private startRepoMetadataFallback(target: RepoGitTarget): void { + if (target.fallbackPollTimer || target.closed) { + return; + } + const poll = async () => { + target.fallbackPollTimer = null; + if (target.closed || this.repoTargets.get(target.repoGitRoot) !== target) { + return; + } + const workingTreeTargets = new Set(); + await Promise.all( + Array.from(target.workspaceKeys, async (workspaceKey) => { + const workspaceTarget = this.workspaceTargets.get(workspaceKey); + if (!workspaceTarget) { + return; + } + this.invalidateCheckoutDiffCache(workspaceTarget.cwd, "base"); + if (workspaceTarget.latestFacts?.isGit) { + this.invalidateCheckoutDiffCache(workspaceTarget.latestFacts.worktreeRoot, "base"); + } + const workingTreeTarget = this.getWorkingTreeWatchTargetForWorkspace(workspaceTarget); + if (workingTreeTarget) { + workingTreeTargets.add(workingTreeTarget); + } + await this.refreshWorkspaceTarget(workspaceTarget, { + force: false, + refreshStructure: true, + refreshWorktree: true, + includeForge: false, + emitUnchanged: true, + reason: "git-metadata-watch-fallback", + notify: true, + queueIfBusy: true, + }); + }), + ); + for (const workingTreeTarget of workingTreeTargets) { + this.notifyWorkingTreeConsumers(workingTreeTarget); + } + if (!target.closed) { + target.fallbackPollTimer = setTimeout(poll, DEGRADED_GIT_POLL_INTERVAL_MS); + } }; - this.repoTargets.set(repoGitRoot, repoTarget); - void this.runRepoFetch(repoTarget); + target.fallbackPollTimer = setTimeout(poll, DEGRADED_GIT_POLL_INTERVAL_MS); } private scheduleWorkspaceRefresh( @@ -1224,20 +1633,57 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private startWorkspaceSubscriptionTimers(target: WorkspaceGitTarget): void { if (!target.selfHealTimer) { - target.selfHealTimer = setInterval(() => { + target.auditWindowStartedAtMs = this.deps.now().getTime(); + const runSelfHealTick = () => { + if (!this.isActiveObservedWorkspaceTarget(target)) { + target.selfHealTimer = null; + return; + } + target.selfHealTimer = setTimeout(runSelfHealTick, WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS); this.scheduleWorkspaceObservationSetup(target); + const auditWindowStartedAtMs = target.auditWindowStartedAtMs; + target.auditWindowStartedAtMs = this.deps.now().getTime(); + if (auditWindowStartedAtMs === null) { + return; + } + const workingTreeTarget = this.getWorkingTreeWatchTargetForWorkspace(target); + if (workingTreeTarget) { + void this.refreshWorkingTreeIgnoredDirectories(workingTreeTarget); + } + const refreshStructure = + target.latestStructuralRefreshAtMs === null || + target.latestStructuralRefreshAtMs < auditWindowStartedAtMs; + const refreshWorktree = + refreshStructure || + target.latestWorktreeRefreshAtMs === null || + target.latestWorktreeRefreshAtMs < auditWindowStartedAtMs; + if (!refreshStructure && !refreshWorktree) { + return; + } + if (refreshWorktree) { + if (workingTreeTarget) { + this.notifyWorkingTreeConsumers(workingTreeTarget); + } + } this.refreshWorkspaceTarget(target, { force: false, + refreshStructure, + refreshWorktree, includeForge: false, reason: "self-heal-git", notify: true, + queueIfBusy: true, }).catch((error) => { this.logger.warn( { err: error, cwd: target.cwd, reason: "self-heal-git" }, "Failed to run workspace git self-heal refresh", ); }); - }, WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS); + }; + target.selfHealTimer = setTimeout( + runSelfHealTick, + this.deps.getWorkspaceGitSelfHealPhaseMs(target.cwd), + ); } this.updateForgePrStatusPollForTarget(target); @@ -1436,184 +1882,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target.forgePrStatusPollKey = null; } - private addWorkingTreeWatcher( - target: WorkingTreeWatchTarget, - watchPath: string, - shouldTryRecursive: boolean, - ): boolean { - if (target.watchedPaths.has(watchPath)) { - return false; - } - - const { cwd } = target; - const onChange = () => { - if (process.platform === "linux" && target.repoWatchPath) { - void this.refreshLinuxRepoTreeWatchers(target); - } - this.scheduleWorkspaceRefresh(cwd, { - force: true, - reason: "working-tree-watch", - }); - for (const listener of target.listeners) { - listener(); - } - }; - const createWatcher = (recursive: boolean): FSWatcher => - this.deps.watch(watchPath, { recursive }, () => { - onChange(); - }); - - let watcher: FSWatcher | null = null; - let watcherIsRecursive = false; - try { - if (shouldTryRecursive) { - watcher = createWatcher(true); - watcherIsRecursive = true; - } else { - watcher = createWatcher(false); - } - } catch (error) { - if (shouldTryRecursive) { - try { - watcher = createWatcher(false); - this.logger.warn( - { err: error, watchPath, cwd }, - "Working tree recursive watch unavailable; using non-recursive fallback", - ); - } catch (fallbackError) { - this.logger.warn( - { err: fallbackError, watchPath, cwd }, - "Failed to start working tree watcher", - ); - } - } else { - this.logger.warn({ err: error, watchPath, cwd }, "Failed to start working tree watcher"); - } - } - - if (!watcher) { - return false; - } - - watcher.on("error", (error) => { - this.logger.warn({ err: error, watchPath, cwd }, "Working tree watcher error"); - }); - target.watchers.push(watcher); - target.watchedPaths.add(watchPath); - return watcherIsRecursive; - } - - private async ensureLinuxRepoTreeWatchers( - target: WorkingTreeWatchTarget, - rootPath: string, - ): Promise { - const directories = await this.listLinuxWatchDirectories(rootPath); - let complete = true; - for (const directory of directories) { - const watcherWasRecursive = this.addWorkingTreeWatcher(target, directory, false); - if (!watcherWasRecursive && !target.watchedPaths.has(directory)) { - complete = false; - } - } - return complete && target.watchedPaths.has(rootPath); - } - - private async refreshLinuxRepoTreeWatchers(target: WorkingTreeWatchTarget): Promise { - if (process.platform !== "linux" || !target.repoWatchPath) { - return; - } - const rootPath = target.repoWatchPath; - if (target.linuxTreeRefreshPromise) { - target.linuxTreeRefreshQueued = true; - return; - } - - target.linuxTreeRefreshPromise = (async () => { - do { - target.linuxTreeRefreshQueued = false; - try { - await this.ensureLinuxRepoTreeWatchers(target, rootPath); - } catch (error) { - this.logger.warn( - { - err: error, - cwd: target.cwd, - rootPath, - }, - "Failed to refresh Linux working tree watchers", - ); - } - if (target.linuxTreeRefreshQueued) { - await new Promise((r) => setTimeout(r, LINUX_WATCH_REFRESH_COOLDOWN_MS)); - } - } while (target.linuxTreeRefreshQueued); - })(); - - try { - await target.linuxTreeRefreshPromise; - } finally { - target.linuxTreeRefreshPromise = null; - } - } - - private async listLinuxWatchDirectories(rootPath: string): Promise { - const ignored = await this.loadLinuxIgnoredDirs(rootPath); - const directories: string[] = []; - let currentLevel: string[] = [rootPath]; - let capped = false; - - while (currentLevel.length > 0) { - directories.push(...currentLevel); - if (directories.length >= LINUX_WATCH_MAX_DIRS) { - capped = true; - break; - } - const readResults = await Promise.all( - currentLevel.map((directory) => - linuxWatchReaddirLimit(async () => { - try { - return await this.deps.readdir(directory, { withFileTypes: true }); - } catch { - return null; - } - }), - ), - ); - const nextLevel: string[] = []; - for (let i = 0; i < currentLevel.length; i += 1) { - const directory = currentLevel[i]; - const entries = readResults[i]; - if (!directory || !entries) continue; - for (const entry of entries) { - if (!entry.isDirectory() || entry.name === ".git") { - continue; - } - const childPath = join(directory, entry.name); - if (ignored.has(childPath)) { - continue; - } - nextLevel.push(childPath); - } - } - currentLevel = nextLevel; - } - - if (capped) { - this.logger.warn( - { rootPath, limit: LINUX_WATCH_MAX_DIRS, walked: directories.length }, - "Linux working tree exceeds watcher cap; skipping deeper directories", - ); - } - - return directories; - } - - private async loadLinuxIgnoredDirs(rootPath: string): Promise> { - const cached = this.linuxIgnoredDirsCache.get(rootPath); - if (cached && Date.now() - cached.ts < LINUX_WATCH_IGNORE_TTL_MS) { - return cached.ignored; - } - + private async loadIgnoredDirs(rootPath: string): Promise> { const ignored = new Set(); try { const result = await this.deps.runGitCommand( @@ -1637,7 +1906,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { ); } - this.linuxIgnoredDirsCache.set(rootPath, { ignored, ts: Date.now() }); return ignored; } @@ -1663,16 +1931,20 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { request: WorkspaceGitRefreshRequest, ): Promise { if (target.refreshState.status === "in-flight") { - const needsForcedRefresh = request.force && !target.refreshState.force; - const needsGitHubRefresh = - request.force && request.includeForge && !target.refreshState.includeForge; - if (needsForcedRefresh || needsGitHubRefresh) { + const active = target.refreshState.request; + const addsWork = + (request.force && !active.force) || + (request.refreshStructure && !active.refreshStructure) || + (request.refreshWorktree && !active.refreshWorktree) || + (request.includeForge && !active.includeForge) || + (request.emitUnchanged === true && active.emitUnchanged !== true); + if (request.queueIfBusy || addsWork) { target.refreshState.queued = this.mergeRefreshRequests(target.refreshState.queued, request); } return target.refreshState.promise; } - if (!request.force && this.shouldThrottleNonForcedRefresh(target)) { + if (!request.force && !request.queueIfBusy && this.shouldThrottleNonForcedRefresh(target)) { return Promise.resolve(target.latestSnapshot); } @@ -1685,8 +1957,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target.refreshState = { status: "in-flight", promise, - force: request.force, - includeForge: request.includeForge, + request, queued: null, }; @@ -1705,9 +1976,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const force = options?.force === true; return { force, + refreshStructure: true, + refreshWorktree: true, includeForge: options?.includeForge ?? true, reason: options?.reason ?? defaultReason, notify, + queueIfBusy: false, }; } @@ -1726,11 +2000,16 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private buildScheduledRefreshRequest( options: ScheduledWorkspaceGitRefreshOptions | undefined, ): WorkspaceGitRefreshRequest { + const scope = options?.scope; return { force: options?.force === true, + refreshStructure: scope !== "worktree", + refreshWorktree: scope !== "structure", includeForge: options?.includeForge ?? false, + emitUnchanged: options?.emitUnchanged, reason: options?.reason ?? "watch", notify: true, + queueIfBusy: options?.queueIfBusy ?? true, }; } @@ -1744,12 +2023,22 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const force = pending.force || request.force; const upgradesForce = request.force && !pending.force; + const upgradesStructure = request.refreshStructure && !pending.refreshStructure; + const upgradesWorktree = request.refreshWorktree && !pending.refreshWorktree; const upgradesForge = request.includeForge && !pending.includeForge; + const upgradesEmit = request.emitUnchanged === true && pending.emitUnchanged !== true; return { force, + refreshStructure: pending.refreshStructure || request.refreshStructure, + refreshWorktree: pending.refreshWorktree || request.refreshWorktree, includeForge: pending.includeForge || request.includeForge, - reason: upgradesForce || upgradesForge ? request.reason : pending.reason, + emitUnchanged: pending.emitUnchanged === true || request.emitUnchanged === true, + reason: + upgradesForce || upgradesStructure || upgradesWorktree || upgradesForge || upgradesEmit + ? request.reason + : pending.reason, notify: pending.notify || request.notify, + queueIfBusy: pending.queueIfBusy || request.queueIfBusy, }; } @@ -1758,14 +2047,26 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { initialRequest: WorkspaceGitRefreshRequest, ): Promise { let request = initialRequest; - let snapshot!: WorkspaceGitRuntimeSnapshot; + let snapshot = target.latestSnapshot ?? buildNotGitSnapshot(target.cwd); + let failure: { error: unknown } | null = null; while (true) { - snapshot = await this.refreshSnapshot(target, request); - this.rememberSnapshot(target, snapshot, { - notify: request.notify, - forceEmit: request.force, - }); + if (request.emitUnchanged === true && target.latestSnapshot) { + this.rememberSnapshot(target, target.latestSnapshot, { + notify: request.notify, + forceEmit: true, + }); + } + try { + snapshot = await this.refreshSnapshot(target, request); + this.rememberSnapshot(target, snapshot, { + notify: request.notify, + forceEmit: request.force, + }); + failure = null; + } catch (error) { + failure = { error }; + } const state = target.refreshState; if (state.status !== "in-flight" || !state.queued) { @@ -1774,10 +2075,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { request = state.queued; state.queued = null; - state.force = request.force; - state.includeForge = request.includeForge; + state.request = request; } + if (failure) { + throw failure.error; + } return snapshot; } @@ -1785,7 +2088,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target: WorkspaceGitTarget, request: WorkspaceGitRefreshRequest, ): Promise { - const facts = await this.refreshGitSnapshot(target, request); + let facts = target.latestFacts; + if (request.refreshStructure || !facts || !target.latestGit) { + facts = await this.refreshGitSnapshot(target, request); + } else if (request.refreshWorktree) { + await this.refreshWorktreeSnapshot(target, facts); + } + if (!facts) { + facts = await this.refreshGitSnapshot(target, request); + } if (request.includeForge) { await this.refreshForgeSnapshot(target, request, facts); } @@ -1795,6 +2106,33 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return snapshot; } + private async refreshWorktreeSnapshot( + target: WorkspaceGitTarget, + facts: CheckoutSnapshotFacts, + ): Promise { + const latestGit = target.latestGit; + if (!latestGit || !facts.isGit) { + return; + } + + target.lastShellOutAtMs = this.deps.now().getTime(); + const context: CheckoutContext = { + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, + logger: this.logger, + facts, + }; + const worktree = await this.deps.getCheckoutWorktreeState(target.cwd, context); + target.latestGit = { + ...latestGit, + isDirty: worktree.isDirty, + diffStat: worktree.diffStat, + }; + const loadedAtMs = this.deps.now().getTime(); + target.latestGitLoadedAtMs = loadedAtMs; + target.latestWorktreeRefreshAtMs = loadedAtMs; + } + private async refreshGitSnapshot( target: WorkspaceGitTarget, request: WorkspaceGitRefreshRequest, @@ -1809,23 +2147,28 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { worktreesRoot: this.worktreesRoot, logger: this.logger, }; - const facts = await this.loadCheckoutFacts(target, { - ...baseContext, - allowRecent: !request.force, - }); + const facts = await this.loadCheckoutFacts(target, baseContext); const context: CheckoutContext = { ...baseContext, facts }; const checkoutStatus = await this.deps.getCheckoutStatus(cwd, context); if (!checkoutStatus.isGit) { target.latestGit = buildNotGitSnapshot(cwd).git; - target.latestGitLoadedAtMs = this.deps.now().getTime(); + const loadedAtMs = this.deps.now().getTime(); + target.latestGitLoadedAtMs = loadedAtMs; + target.latestStructuralRefreshAtMs = loadedAtMs; + target.latestWorktreeRefreshAtMs = loadedAtMs; target.latestForge = buildForgeUnavailableSnapshot(); target.latestForgeLoadedAtMs = target.latestGitLoadedAtMs; return facts; } - const diffStat = await this.deps - .getCheckoutShortstat(cwd, context, { force: request.force }) - .catch(() => null); + const refreshWorktree = request.refreshWorktree || target.latestGit === null; + const diffStat = refreshWorktree + ? await this.deps + .getCheckoutShortstat(cwd, context, { + force: request.force || target.latestGit !== null, + }) + .catch(() => null) + : (target.latestGit?.diffStat ?? null); target.latestGit = { isGit: true, @@ -1834,7 +2177,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { currentBranch: checkoutStatus.currentBranch, remoteUrl: checkoutStatus.remoteUrl, isPaseoOwnedWorktree: checkoutStatus.isPaseoOwnedWorktree, - isDirty: checkoutStatus.isDirty, + isDirty: refreshWorktree + ? checkoutStatus.isDirty + : (target.latestGit?.isDirty ?? checkoutStatus.isDirty), baseRef: checkoutStatus.baseRef, aheadBehind: checkoutStatus.aheadBehind, aheadOfOrigin: checkoutStatus.aheadOfOrigin, @@ -1842,7 +2187,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { hasRemote: checkoutStatus.hasRemote, diffStat, }; - target.latestGitLoadedAtMs = this.deps.now().getTime(); + const loadedAtMs = this.deps.now().getTime(); + target.latestGitLoadedAtMs = loadedAtMs; + target.latestStructuralRefreshAtMs = loadedAtMs; + if (refreshWorktree) { + target.latestWorktreeRefreshAtMs = loadedAtMs; + } if (previousForgePrStatusPollKey !== this.getForgePrStatusPollKey(target)) { target.latestForge = buildForgeUnavailableSnapshot(); @@ -1984,8 +2334,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { "Running background git fetch", ); + let succeeded = false; try { await this.deps.runGitFetch(target.cwd); + succeeded = true; } catch (error) { this.logger.warn( { err: error, repoGitRoot: target.repoGitRoot, cwd: target.cwd }, @@ -1993,21 +2345,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { ); } finally { target.fetchInFlight = false; - await Promise.all( - Array.from(target.workspaceKeys, async (workspaceKey) => { - const workspaceTarget = this.workspaceTargets.get(workspaceKey); - if (!workspaceTarget) { - return; - } - await this.refreshWorkspaceTarget(workspaceTarget, { - force: false, - includeForge: false, - reason: "repo-fetch", - notify: true, - }); - }), - ); } + if (!succeeded) { + return; + } + this.scheduleRepoMetadataRefresh(target, "repo-fetch", false); } private removeWorkspaceListener(cwd: string, listener: WorkspaceGitListener): void { @@ -2031,6 +2373,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (repoTarget && repoTarget.workspaceKeys.size === 0) { this.closeRepoTarget(repoTarget); this.repoTargets.delete(target.repoGitRoot); + } else if (repoTarget?.cwd === target.cwd) { + repoTarget.cwd = repoTarget.workspaceKeys.values().next().value ?? repoTarget.cwd; } } @@ -2045,7 +2389,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } target.listeners.delete(listener); - if (target.listeners.size > 0) { + if (target.listeners.size > 0 || target.workspaceKeys.size > 0) { return; } @@ -2053,47 +2397,95 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { this.workingTreeWatchTargets.delete(cwd); } + private removeWorkspaceWorkingTreeLink( + target: WorkingTreeWatchTarget, + workspaceKey: string, + ): void { + target.workspaceKeys.delete(workspaceKey); + if (target.workspaceKeys.size > 0 || target.listeners.size > 0) { + return; + } + this.closeWorkingTreeWatchTarget(target); + if (this.workingTreeWatchTargets.get(target.cwd) === target) { + this.workingTreeWatchTargets.delete(target.cwd); + } + } + + private closeWorkingTreeWatchTargetIfUnused(target: WorkingTreeWatchTarget): void { + if ( + target.closed || + target.workspaceKeys.size > 0 || + target.listeners.size > 0 || + this.workingTreeWatchTargets.get(target.cwd) !== target + ) { + return; + } + this.closeWorkingTreeWatchTarget(target); + this.workingTreeWatchTargets.delete(target.cwd); + } + private closeWorkspaceTarget(target: WorkspaceGitTarget): void { target.closed = true; + if (target.workingTreeWatchTarget) { + this.removeWorkspaceWorkingTreeLink(target.workingTreeWatchTarget, target.cwd); + target.workingTreeWatchTarget = null; + } if (target.debounceTimer) { clearTimeout(target.debounceTimer); target.debounceTimer = null; } if (target.selfHealTimer) { - clearInterval(target.selfHealTimer); + clearTimeout(target.selfHealTimer); target.selfHealTimer = null; } this.stopForgePrStatusPollForTarget(target); - - for (const watcher of target.watchers) { - watcher.close(); - } - target.watchers = []; target.listeners.clear(); } private closeWorkingTreeWatchTarget(target: WorkingTreeWatchTarget): void { - if (target.fallbackRefreshInterval) { - clearInterval(target.fallbackRefreshInterval); - target.fallbackRefreshInterval = null; + target.closed = true; + for (const alias of target.aliases) { + if (this.workingTreeWatchAliases.get(alias) === target.cwd) { + this.workingTreeWatchAliases.delete(alias); + } + } + target.aliases.clear(); + if (target.fallbackPollTimer) { + clearTimeout(target.fallbackPollTimer); + target.fallbackPollTimer = null; } - for (const watcher of target.watchers) { - watcher.close(); + if (target.subscription) { + const subscription = target.subscription; + target.subscription = null; + void subscription.unsubscribe().catch((error) => { + this.logger.warn({ err: error, cwd: target.cwd }, "Failed to stop working tree watcher"); + }); } - target.watchers = []; - target.watchedPaths.clear(); + target.workspaceKeys.clear(); target.listeners.clear(); - if (target.repoWatchPath) { - this.linuxIgnoredDirsCache.delete(target.repoWatchPath); - } } private closeRepoTarget(target: RepoGitTarget): void { + target.closed = true; if (target.intervalId) { clearInterval(target.intervalId); target.intervalId = null; } + if (target.fallbackPollTimer) { + clearTimeout(target.fallbackPollTimer); + target.fallbackPollTimer = null; + } + if (target.subscription) { + const subscription = target.subscription; + target.subscription = null; + void subscription.unsubscribe().catch((error) => { + this.logger.warn( + { err: error, repoGitRoot: target.repoGitRoot }, + "Failed to stop repository metadata watcher", + ); + }); + } target.workspaceKeys.clear(); } } diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 5180817f1..bd83a8dda 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -2664,6 +2664,22 @@ export async function getCheckoutShortstat( return getOrLoadCheckoutShortstat(cwd, context, options); } +export interface CheckoutWorktreeState { + isDirty: boolean; + diffStat: CheckoutShortstat | null; +} + +export async function getCheckoutWorktreeState( + cwd: string, + context: CheckoutContext, +): Promise { + const [isDirty, diffStat] = await Promise.all([ + isWorkingTreeDirty(cwd, context), + getCheckoutShortstat(cwd, context, { force: true }), + ]); + return { isDirty, diffStat }; +} + export function getCachedCheckoutShortstat(cwd: string): CheckoutShortstat | null | undefined { return shortstatCache.get(getShortstatCacheKey(cwd)); } diff --git a/packages/server/src/utils/git-command-trace.ts b/packages/server/src/utils/git-command-trace.ts new file mode 100644 index 000000000..58aa252af --- /dev/null +++ b/packages/server/src/utils/git-command-trace.ts @@ -0,0 +1,119 @@ +import { appendFile } from "node:fs/promises"; + +interface GitCommandTraceContext { + id: string; + args: string[]; + cwd: string; + submittedAtMs: number; + startedAtMs: number | null; + spawnedAtMs: number | null; + pid: number | null; + submittedQueue: GitCommandTraceQueueState; + startedQueue: GitCommandTraceQueueState | null; + settled: boolean; +} + +interface GitCommandTraceQueueState { + active: number; + pending: number; +} + +interface GitCommandTraceSettlement { + outcome: "closed" | "spawn_error" | "timed_out"; + exitCode?: number | null; + signal?: NodeJS.Signals | null; + truncated?: boolean; +} + +let nextTraceId = 1; +let pendingTraceWrite = Promise.resolve(); + +function appendTrace(event: Record): void { + const tracePath = process.env.PASEO_GIT_TRACE_FILE?.trim(); + if (!tracePath) return; + + pendingTraceWrite = pendingTraceWrite + .then(() => appendFile(tracePath, `${JSON.stringify(event)}\n`, "utf8")) + .catch(() => { + // Diagnostic tracing must never change command execution. + }); +} + +export async function flushGitCommandTrace(): Promise { + await pendingTraceWrite; +} + +export function submitGitCommandTrace( + args: string[], + cwd: string, + queue: GitCommandTraceQueueState, +): GitCommandTraceContext | null { + if (!process.env.PASEO_GIT_TRACE_FILE?.trim()) return null; + + const submittedAtMs = Date.now(); + const context: GitCommandTraceContext = { + id: `${process.pid}:${nextTraceId++}`, + args: [...args], + cwd, + submittedAtMs, + startedAtMs: null, + spawnedAtMs: null, + pid: null, + submittedQueue: { ...queue }, + startedQueue: null, + settled: false, + }; + return context; +} + +export function startGitCommandTrace( + context: GitCommandTraceContext | null, + queue: GitCommandTraceQueueState, +): void { + if (!context) return; + + context.startedAtMs = Date.now(); + context.startedQueue = { ...queue }; +} + +export function spawnGitCommandTrace( + context: GitCommandTraceContext | null, + pid: number | undefined, +): void { + if (!context) return; + + context.spawnedAtMs = Date.now(); + context.pid = pid ?? null; +} + +export function settleGitCommandTrace( + context: GitCommandTraceContext | null, + settlement: GitCommandTraceSettlement, +): void { + if (!context || context.settled) return; + context.settled = true; + + const atMs = Date.now(); + appendTrace({ + kind: "git_command", + event: "settled", + id: context.id, + atMs, + submittedAtMs: context.submittedAtMs, + startedAtMs: context.startedAtMs, + args: context.args, + cwd: context.cwd, + queueWaitMs: context.startedAtMs === null ? null : context.startedAtMs - context.submittedAtMs, + spawnCallMs: + context.startedAtMs === null || context.spawnedAtMs === null + ? null + : context.spawnedAtMs - context.startedAtMs, + pid: context.pid, + queue: { + submitted: context.submittedQueue, + started: context.startedQueue, + }, + durationMs: atMs - context.submittedAtMs, + ...settlement, + }); +} diff --git a/packages/server/src/utils/run-git-command-trace.integration.test.ts b/packages/server/src/utils/run-git-command-trace.integration.test.ts new file mode 100644 index 000000000..cd4c9b842 --- /dev/null +++ b/packages/server/src/utils/run-git-command-trace.integration.test.ts @@ -0,0 +1,53 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { flushGitCommandTrace } from "./git-command-trace.js"; +import { runGitCommand } from "./run-git-command.js"; + +describe("git command trace", () => { + const tempDirectories: string[] = []; + + afterEach(async () => { + vi.unstubAllEnvs(); + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); + }); + + it("writes one asynchronous settlement record for a real git process", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-git-trace-")); + tempDirectories.push(directory); + const tracePath = path.join(directory, "git.jsonl"); + vi.stubEnv("PASEO_GIT_TRACE_FILE", tracePath); + + await runGitCommand(["--version"], { cwd: directory }); + await flushGitCommandTrace(); + + const events = (await readFile(tracePath, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + kind: "git_command", + event: "settled", + args: ["--version"], + cwd: directory, + queue: { + submitted: { active: 0, pending: 0 }, + started: expect.objectContaining({ + active: expect.any(Number), + pending: expect.any(Number), + }), + }, + queueWaitMs: expect.any(Number), + spawnCallMs: expect.any(Number), + pid: expect.any(Number), + outcome: "closed", + exitCode: 0, + signal: null, + durationMs: expect.any(Number), + }); + }); +}); diff --git a/packages/server/src/utils/run-git-command.ts b/packages/server/src/utils/run-git-command.ts index 254c98046..835f5d32e 100644 --- a/packages/server/src/utils/run-git-command.ts +++ b/packages/server/src/utils/run-git-command.ts @@ -6,6 +6,12 @@ import { GitCommandRuntimeMetricsWindow, type GitCommandRuntimeMetricsSnapshot, } from "./git-command-runtime-metrics.js"; +import { + settleGitCommandTrace, + spawnGitCommandTrace, + startGitCommandTrace, + submitGitCommandTrace, +} from "./git-command-trace.js"; import { spawnProcess } from "./spawn.js"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -135,10 +141,18 @@ export function runGitCommand( args: string[], options: GitCommandOptions, ): Promise { + const commandTrace = submitGitCommandTrace(args, options.cwd, { + active: gitLimit.activeCount, + pending: gitLimit.pendingCount, + }); const runtimeMetric = gitRuntimeMetrics.submit(getGitOperation(args)); const promise = gitLimit( () => new Promise((resolve, reject) => { + startGitCommandTrace(commandTrace, { + active: gitLimit.activeCount, + pending: gitLimit.pendingCount, + }); gitRuntimeMetrics.start(runtimeMetric); const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS; const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; @@ -173,6 +187,7 @@ export function runGitCommand( shell: false, stdio: ["ignore", "pipe", "pipe"], }); + spawnGitCommandTrace(commandTrace, child.pid); let settled = false; let metricFinished = false; @@ -199,6 +214,11 @@ export function runGitCommand( const timer = setTimeout(() => { const error = new Error(`Git command timed out after ${timeout}ms: ${command}`); child.kill("SIGKILL"); + settleGitCommandTrace(commandTrace, { + outcome: "timed_out", + exitCode: null, + signal: "SIGKILL", + }); finishMetricOnce( { args, @@ -255,6 +275,11 @@ export function runGitCommand( }); child.on("error", (error) => { + settleGitCommandTrace(commandTrace, { + outcome: "spawn_error", + exitCode: null, + signal: null, + }); finishMetricOnce({ args, cwd: options.cwd, @@ -278,6 +303,12 @@ export function runGitCommand( }); child.on("close", (exitCode, signal) => { + settleGitCommandTrace(commandTrace, { + outcome: "closed", + exitCode, + signal, + truncated, + }); const result: GitCommandResult = { stdout: Buffer.concat(stdoutChunks).toString("utf8"), stderr: Buffer.concat(stderrChunks).toString("utf8"), diff --git a/scripts/trace-daemon.mjs b/scripts/trace-daemon.mjs index a4ece424f..3456cf3fe 100644 --- a/scripts/trace-daemon.mjs +++ b/scripts/trace-daemon.mjs @@ -14,11 +14,30 @@ import { nodeFileTrace } from "@vercel/nft"; import { glob } from "node:fs/promises"; +import { createRequire } from "node:module"; import path from "node:path"; import { pathToFileURL } from "node:url"; const REPO_ROOT = path.resolve(import.meta.dirname, ".."); +function toTracePath(absolutePath) { + return path.relative(REPO_ROOT, absolutePath).split(path.sep).join("/"); +} + +function parcelWatcherNativePackagePath() { + const requireFromRepo = createRequire(path.join(REPO_ROOT, "package.json")); + requireFromRepo("@parcel/watcher"); + + const nativeAddonPath = Object.keys(requireFromRepo.cache) + .map(toTracePath) + .find((file) => /^node_modules\/@parcel\/watcher-[^/]+\/watcher\.node$/.test(file)); + if (!nativeAddonPath) { + throw new Error("@parcel/watcher loaded without a platform native addon"); + } + + return path.posix.dirname(nativeAddonPath); +} + const { sherpaPlatformPackageName } = await import( pathToFileURL( path.join( @@ -65,6 +84,10 @@ const additionalInputs = [ // the Nix derivation builds for one platform at a time and ships only // its own binaries. `node_modules/node-pty/prebuilds/${process.platform}-${process.arch}/**`, + // @parcel/watcher dynamically resolves its optional platform package. npm's + // build closure can contain multiple libc variants, so copy the package the + // wrapper actually loaded rather than every installed optional package. + `${parcelWatcherNativePackagePath()}/**`, // sherpa-onnx-node dynamically resolves a platform-specific native package. // Copy the wrapper plus the host platform package explicitly. "node_modules/sherpa-onnx-node/**",