diff --git a/CI_STATUS.md b/CI_STATUS.md deleted file mode 100644 index 3c2798e24..000000000 --- a/CI_STATUS.md +++ /dev/null @@ -1,19 +0,0 @@ -# CI Test Status - -Tracking progress toward all-green CI. - -## CI Jobs - -| Job | Status | Notes | -|-----|--------|-------| -| format | unknown | `npx biome format .` | -| typecheck | unknown | `npm run typecheck` | -| server-tests | unknown | unit + integration (vitest) | -| app-tests | unknown | unit tests (vitest) | -| playwright | unknown | E2E tests (playwright) | -| relay-tests | unknown | unit tests (vitest) | -| cli-tests | unknown | local tests | - -## Log - -- 2026-04-10: Branch created, automated agents begin iterating diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index e0f0d1896..327675b3d 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -288,7 +288,6 @@ export function useSidebarWorkspacesList(options?: { if (!serverId) { return; } - }, [connectionStatus, hasHydratedWorkspaces, projects, serverId, sessionWorkspaces]); useEffect(() => { diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index a2a6b7223..6a5e9b476 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -107,6 +107,7 @@ import { CheckoutDiffManager } from "./checkout-diff-manager.js"; import { LoopService } from "./loop-service.js"; import { ScheduleService } from "./schedule/service.js"; import { DaemonConfigStore } from "./daemon-config-store.js"; +import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js"; import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js"; import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js"; @@ -368,6 +369,10 @@ export async function createPaseoDaemon( }); const terminalManager = createTerminalManager(); + const workspaceGitService = new WorkspaceGitServiceImpl({ + logger, + paseoHome: config.paseoHome, + }); const detachAgentStoragePersistence = attachAgentStoragePersistence( logger, @@ -381,6 +386,7 @@ export async function createPaseoDaemon( agentStorage, projectRegistry, workspaceRegistry, + workspaceGitService, logger, }); logger.info({ elapsed: elapsed() }, "Workspace registries bootstrapped"); @@ -389,6 +395,7 @@ export async function createPaseoDaemon( const checkoutDiffManager = new CheckoutDiffManager({ logger, paseoHome: config.paseoHome, + workspaceGitService, }); const loopService = new LoopService({ paseoHome: config.paseoHome, @@ -619,6 +626,7 @@ export async function createPaseoDaemon( loopService, scheduleService, checkoutDiffManager, + workspaceGitService, ); if (typeof process.send === "function" && process.env.PASEO_SUPERVISED === "1") { diff --git a/packages/server/src/server/checkout-diff-manager.test.ts b/packages/server/src/server/checkout-diff-manager.test.ts index 356f8cf15..35d573e7b 100644 --- a/packages/server/src/server/checkout-diff-manager.test.ts +++ b/packages/server/src/server/checkout-diff-manager.test.ts @@ -1,137 +1,184 @@ -import path from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -const { getCheckoutDiffMock, resolveCheckoutGitDirMock, readdirMock, watchCalls } = vi.hoisted( - () => { - const hoistedWatchCalls: Array<{ path: string; close: ReturnType }> = []; - return { - getCheckoutDiffMock: vi.fn(async () => ({ diff: "", structured: [] })), - resolveCheckoutGitDirMock: vi.fn(async () => "/tmp/repo/.git"), - readdirMock: vi.fn(async (directory: string) => { - if (directory === "/tmp/repo") { - return [ - { name: "packages", isDirectory: () => true }, - { name: ".git", isDirectory: () => true }, - { name: "README.md", isDirectory: () => false }, - ]; - } - if (directory === path.join("/tmp/repo", "packages")) { - return [ - { name: "server", isDirectory: () => true }, - { name: "app", isDirectory: () => true }, - ]; - } - if (directory === path.join("/tmp/repo", "packages", "server")) { - return [{ name: "src", isDirectory: () => true }]; - } - if (directory === path.join("/tmp/repo", "packages", "server", "src")) { - return [{ name: "server", isDirectory: () => true }]; - } - return []; - }), - watchCalls: hoistedWatchCalls, - }; - }, -); - -vi.mock("../utils/run-git-command.js", () => ({ - runGitCommand: vi.fn(async () => ({ - stdout: "/tmp/repo\n", - stderr: "", - truncated: false, - exitCode: 0, - signal: null, +const { getCheckoutDiffMock, toCheckoutErrorMock } = vi.hoisted(() => ({ + getCheckoutDiffMock: vi.fn(async () => ({ diff: "", structured: [] })), + toCheckoutErrorMock: vi.fn((error: unknown) => ({ + message: error instanceof Error ? error.message : String(error), })), })); -vi.mock("node:fs/promises", async () => { - const actual = await vi.importActual("node:fs/promises"); - return { - ...actual, - readdir: readdirMock, - }; -}); - -vi.mock("node:fs", async () => { - const actual = await vi.importActual("node:fs"); - return { - ...actual, - watch: vi.fn((watchPath: string) => { - const close = vi.fn(); - watchCalls.push({ path: watchPath, close }); - return { - close, - on: vi.fn().mockReturnThis(), - } as any; - }), - }; -}); - vi.mock("../utils/checkout-git.js", () => ({ getCheckoutDiff: getCheckoutDiffMock, })); vi.mock("./checkout-git-utils.js", () => ({ - READ_ONLY_GIT_ENV: {}, - resolveCheckoutGitDir: resolveCheckoutGitDirMock, - toCheckoutError: vi.fn((error: unknown) => ({ - message: error instanceof Error ? error.message : String(error), - })), + toCheckoutError: toCheckoutErrorMock, })); import { CheckoutDiffManager } from "./checkout-diff-manager.js"; -describe("CheckoutDiffManager Linux watchers", () => { - const originalPlatform = process.platform; - +describe("CheckoutDiffManager", () => { beforeEach(() => { - watchCalls.length = 0; - getCheckoutDiffMock.mockClear(); - resolveCheckoutGitDirMock.mockClear(); - readdirMock.mockClear(); - Object.defineProperty(process, "platform", { - configurable: true, - value: "linux", - }); + vi.useFakeTimers(); + getCheckoutDiffMock.mockReset(); + getCheckoutDiffMock.mockResolvedValue({ diff: "", structured: [] }); + toCheckoutErrorMock.mockClear(); }); afterEach(() => { - Object.defineProperty(process, "platform", { - configurable: true, - value: originalPlatform, - }); + vi.useRealTimers(); }); - test("watches nested repository directories on Linux", async () => { + function createManager(options?: { + repoRoot?: string | null; + getCheckoutDiffImplementation?: typeof getCheckoutDiffMock; + }) { + const unsubscribe = vi.fn(); + let onChange: (() => void) | null = null; + const mockRequestWorkingTreeWatch = vi.fn(async (_cwd: string, listener: () => void) => { + onChange = listener; + return { + repoRoot: options?.repoRoot === undefined ? "/tmp/repo" : options.repoRoot, + unsubscribe, + }; + }); + + const workspaceGitService = { + subscribe: vi.fn(), + peekSnapshot: vi.fn(), + getSnapshot: vi.fn(), + refresh: vi.fn(), + scheduleRefreshForCwd: vi.fn(), + requestWorkingTreeWatch: mockRequestWorkingTreeWatch, + dispose: vi.fn(), + }; + + if (options?.getCheckoutDiffImplementation) { + getCheckoutDiffMock.mockImplementation(options.getCheckoutDiffImplementation); + } + const logger = { child: () => logger, warn: vi.fn(), }; + const manager = new CheckoutDiffManager({ logger: logger as any, paseoHome: "/tmp/paseo-test", + workspaceGitService: workspaceGitService as any, }); - const subscription = await manager.subscribe( + return { + manager, + workspaceGitService, + mockRequestWorkingTreeWatch, + unsubscribe, + getOnChange: () => onChange, + }; + } + + test("subscribe requests a working tree watch with the correct cwd", async () => { + const { manager, mockRequestWorkingTreeWatch } = createManager(); + + await manager.subscribe( { - cwd: path.join("/tmp/repo", "packages", "server"), + cwd: "/tmp/repo/packages/server", compare: { mode: "uncommitted" }, }, () => {}, ); - expect(subscription.initial.error).toBeNull(); - expect(watchCalls.map((entry) => entry.path).sort()).toEqual([ - "/tmp/repo", - "/tmp/repo/.git", - "/tmp/repo/packages", - "/tmp/repo/packages/app", + expect(mockRequestWorkingTreeWatch).toHaveBeenCalledWith( "/tmp/repo/packages/server", - "/tmp/repo/packages/server/src", - "/tmp/repo/packages/server/src/server", - ]); + expect.any(Function), + ); + }); + + test("unsubscribe calls the working tree watch unsubscribe", async () => { + const { manager, unsubscribe } = createManager(); + + const subscription = await manager.subscribe( + { + cwd: "/tmp/repo/packages/server", + compare: { mode: "uncommitted" }, + }, + () => {}, + ); subscription.unsubscribe(); - manager.dispose(); + + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); + + test("diffCwd uses repoRoot from the working tree watch result", async () => { + const { manager } = createManager({ repoRoot: "/tmp/repo" }); + + await manager.subscribe( + { + cwd: "/tmp/repo/packages/server", + compare: { mode: "uncommitted" }, + }, + () => {}, + ); + + expect(getCheckoutDiffMock).toHaveBeenCalledWith( + "/tmp/repo", + expect.objectContaining({ mode: "uncommitted", includeStructured: true }), + { paseoHome: "/tmp/paseo-test" }, + ); + }); + + test("diff refresh is triggered when the working tree watch callback fires", async () => { + getCheckoutDiffMock + .mockResolvedValueOnce({ + diff: "", + structured: [{ path: "a.ts", additions: 1, deletions: 0, status: "modified" }], + }) + .mockResolvedValueOnce({ + diff: "", + structured: [{ path: "b.ts", additions: 2, deletions: 0, status: "modified" }], + }); + + const { manager, getOnChange } = createManager(); + const listener = vi.fn(); + + await manager.subscribe( + { + cwd: "/tmp/repo/packages/server", + compare: { mode: "uncommitted" }, + }, + listener, + ); + + const onChange = getOnChange(); + expect(onChange).toBeTypeOf("function"); + + onChange?.(); + await vi.advanceTimersByTimeAsync(150); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith({ + cwd: "/tmp/repo/packages/server", + files: [{ path: "b.ts", additions: 2, deletions: 0, status: "modified" }], + error: null, + }); + }); + + test("falls back to cwd when the working tree watch returns no repo root", async () => { + const { manager } = createManager({ repoRoot: null }); + + await manager.subscribe( + { + cwd: "/tmp/plain", + compare: { mode: "uncommitted" }, + }, + () => {}, + ); + + expect(getCheckoutDiffMock).toHaveBeenCalledWith( + "/tmp/plain", + expect.objectContaining({ mode: "uncommitted", includeStructured: true }), + { paseoHome: "/tmp/paseo-test" }, + ); }); }); diff --git a/packages/server/src/server/checkout-diff-manager.ts b/packages/server/src/server/checkout-diff-manager.ts index 0274fff22..8e2029d96 100644 --- a/packages/server/src/server/checkout-diff-manager.ts +++ b/packages/server/src/server/checkout-diff-manager.ts @@ -1,15 +1,11 @@ -import { watch, type FSWatcher } from "node:fs"; -import { readdir } from "node:fs/promises"; -import { join } from "node:path"; import type pino from "pino"; import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js"; +import type { WorkspaceGitService } from "./workspace-git-service.js"; import { getCheckoutDiff } from "../utils/checkout-git.js"; import { expandTilde } from "../utils/path.js"; -import { runGitCommand } from "../utils/run-git-command.js"; -import { READ_ONLY_GIT_ENV, resolveCheckoutGitDir, toCheckoutError } from "./checkout-git-utils.js"; +import { toCheckoutError } from "./checkout-git-utils.js"; const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150; -const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000; export type CheckoutDiffCompareInput = SubscribeCheckoutDiffRequest["compare"]; @@ -31,27 +27,26 @@ type CheckoutDiffWatchTarget = { diffCwd: string; compare: CheckoutDiffCompareInput; listeners: Set<(snapshot: CheckoutDiffSnapshotPayload) => void>; - watchers: FSWatcher[]; - fallbackRefreshInterval: NodeJS.Timeout | null; + workingTreeWatchUnsubscribe: (() => void) | null; debounceTimer: NodeJS.Timeout | null; refreshPromise: Promise | null; refreshQueued: boolean; latestPayload: CheckoutDiffSnapshotPayload | null; latestFingerprint: string | null; - watchedPaths: Set; - repoWatchPath: string | null; - linuxTreeRefreshPromise: Promise | null; - linuxTreeRefreshQueued: boolean; }; export class CheckoutDiffManager { - private readonly logger: pino.Logger; private readonly paseoHome: string; + private readonly workspaceGitService: WorkspaceGitService; private readonly targets = new Map(); - constructor(options: { logger: pino.Logger; paseoHome: string }) { - this.logger = options.logger.child({ module: "checkout-diff-manager" }); + constructor(options: { + logger: pino.Logger; + paseoHome: string; + workspaceGitService: WorkspaceGitService; + }) { this.paseoHome = options.paseoHome; + this.workspaceGitService = options.workspaceGitService; } async subscribe( @@ -93,22 +88,16 @@ export class CheckoutDiffManager { getMetrics(): CheckoutDiffMetrics { let checkoutDiffSubscriptionCount = 0; - let checkoutDiffWatcherCount = 0; - let checkoutDiffFallbackRefreshTargetCount = 0; for (const target of this.targets.values()) { checkoutDiffSubscriptionCount += target.listeners.size; - checkoutDiffWatcherCount += target.watchers.length; - if (target.fallbackRefreshInterval) { - checkoutDiffFallbackRefreshTargetCount += 1; - } } return { checkoutDiffTargetCount: this.targets.size, checkoutDiffSubscriptionCount, - checkoutDiffWatcherCount, - checkoutDiffFallbackRefreshTargetCount, + checkoutDiffWatcherCount: 0, + checkoutDiffFallbackRefreshTargetCount: 0, }; } @@ -144,15 +133,8 @@ export class CheckoutDiffManager { clearTimeout(target.debounceTimer); target.debounceTimer = null; } - if (target.fallbackRefreshInterval) { - clearInterval(target.fallbackRefreshInterval); - target.fallbackRefreshInterval = null; - } - for (const watcher of target.watchers) { - watcher.close(); - } - target.watchers = []; - target.watchedPaths.clear(); + target.workingTreeWatchUnsubscribe?.(); + target.workingTreeWatchUnsubscribe = null; target.listeners.clear(); } @@ -172,22 +154,6 @@ export class CheckoutDiffManager { this.targets.delete(targetKey); } - private async resolveCheckoutWatchRoot(cwd: string): Promise { - try { - const { stdout } = await runGitCommand( - ["rev-parse", "--path-format=absolute", "--show-toplevel"], - { - cwd, - env: READ_ONLY_GIT_ENV, - }, - ); - const root = stdout.trim(); - return root.length > 0 ? root : null; - } catch { - return null; - } - } - private scheduleTargetRefresh(target: CheckoutDiffWatchTarget): void { if (target.debounceTimer) { clearTimeout(target.debounceTimer); @@ -274,212 +240,27 @@ export class CheckoutDiffManager { return existing; } - const watchRoot = await this.resolveCheckoutWatchRoot(cwd); const target: CheckoutDiffWatchTarget = { key: targetKey, cwd, - diffCwd: watchRoot ?? cwd, + diffCwd: cwd, compare, listeners: new Set(), - watchers: [], - fallbackRefreshInterval: null, + workingTreeWatchUnsubscribe: null, debounceTimer: null, refreshPromise: null, refreshQueued: false, latestPayload: null, latestFingerprint: null, - watchedPaths: new Set(), - repoWatchPath: null, - linuxTreeRefreshPromise: null, - linuxTreeRefreshQueued: false, }; - - const repoWatchPath = watchRoot ?? cwd; - target.repoWatchPath = repoWatchPath; - const watchPaths = new Set([repoWatchPath]); - const gitDir = await resolveCheckoutGitDir(cwd); - if (gitDir) { - watchPaths.add(gitDir); - } - - 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.addWatcher(target, watchPath, shouldTryRecursive); - if (watchPath === repoWatchPath && watcherIsRecursive) { - hasRecursiveRepoCoverage = true; - } - } - - const missingRepoCoverage = !hasRecursiveRepoCoverage; - if (target.watchers.length === 0 || missingRepoCoverage) { - target.fallbackRefreshInterval = setInterval(() => { - this.scheduleTargetRefresh(target); - }, CHECKOUT_DIFF_FALLBACK_REFRESH_MS); - this.logger.warn( - { - cwd, - compare, - intervalMs: CHECKOUT_DIFF_FALLBACK_REFRESH_MS, - reason: - target.watchers.length === 0 ? "no_watchers" : "missing_recursive_repo_root_coverage", - }, - "Checkout diff watchers unavailable; using timed refresh fallback", - ); - } + const { repoRoot, unsubscribe } = await this.workspaceGitService.requestWorkingTreeWatch( + cwd, + () => this.scheduleTargetRefresh(target), + ); + target.diffCwd = repoRoot ?? cwd; + target.workingTreeWatchUnsubscribe = unsubscribe; this.targets.set(targetKey, target); return target; } - - private addWatcher( - target: CheckoutDiffWatchTarget, - watchPath: string, - shouldTryRecursive: boolean, - ): boolean { - if (target.watchedPaths.has(watchPath)) { - return false; - } - - const { cwd, compare } = target; - const onChange = () => { - if (process.platform === "linux" && target.repoWatchPath) { - void this.refreshLinuxRepoTreeWatchers(target); - } - this.scheduleTargetRefresh(target); - }; - const createWatcher = (recursive: boolean): FSWatcher => - 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, compare }, - "Checkout diff recursive watch unavailable; using non-recursive fallback", - ); - } catch (fallbackError) { - this.logger.warn( - { err: fallbackError, watchPath, cwd, compare }, - "Failed to start checkout diff watcher", - ); - } - } else { - this.logger.warn( - { err: error, watchPath, cwd, compare }, - "Failed to start checkout diff watcher", - ); - } - } - - if (!watcher) { - return false; - } - - watcher.on("error", (error) => { - this.logger.warn({ err: error, watchPath, cwd, compare }, "Checkout diff watcher error"); - }); - target.watchers.push(watcher); - target.watchedPaths.add(watchPath); - return watcherIsRecursive; - } - - private async ensureLinuxRepoTreeWatchers( - target: CheckoutDiffWatchTarget, - rootPath: string, - ): Promise { - const directories = await this.listLinuxWatchDirectories(rootPath); - let complete = true; - for (const directory of directories) { - const watcherWasRecursive = this.addWatcher(target, directory, false); - if (!watcherWasRecursive && !target.watchedPaths.has(directory)) { - complete = false; - } - } - return complete && target.watchedPaths.has(rootPath); - } - - private async refreshLinuxRepoTreeWatchers(target: CheckoutDiffWatchTarget): 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, - compare: target.compare, - rootPath, - }, - "Failed to refresh Linux checkout diff tree watchers", - ); - } - } while (target.linuxTreeRefreshQueued); - })(); - - try { - await target.linuxTreeRefreshPromise; - } finally { - target.linuxTreeRefreshPromise = null; - } - } - - private async listLinuxWatchDirectories(rootPath: string): Promise { - const directories: string[] = []; - const pending = [rootPath]; - - while (pending.length > 0) { - const directory = pending.pop(); - if (!directory) { - continue; - } - directories.push(directory); - - let entries; - try { - entries = await readdir(directory, { withFileTypes: true }); - } catch { - continue; - } - - for (const entry of entries) { - if (!entry.isDirectory() || entry.name === ".git") { - continue; - } - pending.push(join(directory, entry.name)); - } - } - - return directories; - } } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 8ef1fb197..2a80b0e2d 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1404,7 +1404,7 @@ export class Session { private async buildProjectPlacement(cwd: string): Promise { return buildProjectPlacementForCwd({ cwd, - paseoHome: this.paseoHome, + workspaceGitService: this.workspaceGitService, }); } @@ -3209,6 +3209,7 @@ export class Session { { paseoHome: this.paseoHome, sessionLogger: this.sessionLogger, + workspaceGitService: this.workspaceGitService, checkoutExistingBranch: (cwd, branch) => this.checkoutExistingBranch(cwd, branch), createBranchFromBase: (params) => this.createBranchFromBase(params), }, @@ -6263,6 +6264,7 @@ export class Session { return handleCreateWorktreeRequest( { paseoHome: this.paseoHome, + workspaceGitService: this.workspaceGitService, describeWorkspaceRecord: (workspace) => this.describeWorkspaceRecordWithGitData(workspace), emit: (message) => this.emit(message), registerPendingWorktreeWorkspace: (options) => diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index 3e0ca570f..d893ca586 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -65,6 +65,8 @@ function createSessionForWorkspaceGitWatchTests(): { peekSnapshot: ReturnType; getSnapshot: ReturnType; refresh: ReturnType; + requestWorkingTreeWatch: ReturnType; + scheduleRefreshForCwd: ReturnType; dispose: ReturnType; }; subscriptions: Array<{ @@ -105,6 +107,11 @@ function createSessionForWorkspaceGitWatchTests(): { peekSnapshot: vi.fn((cwd: string) => createWorkspaceRuntimeSnapshot(cwd)), getSnapshot: vi.fn(async (cwd: string) => createWorkspaceRuntimeSnapshot(cwd)), refresh: vi.fn(async () => {}), + requestWorkingTreeWatch: vi.fn(async (cwd: string) => ({ + repoRoot: cwd, + unsubscribe: vi.fn(), + })), + scheduleRefreshForCwd: vi.fn(), dispose: vi.fn(), }; @@ -328,62 +335,17 @@ describe("workspace git watch targets", () => { }); }); - test("checkout_pr_status_request explicitly refreshes the focused workspace before reading runtime data", async () => { + test("checkout_pr_status_request reads cached snapshot without forcing a refresh", async () => { const { session, emitted, workspaceGitService } = createSessionForWorkspaceGitWatchTests(); - let refreshed = false; - - workspaceGitService.refresh.mockImplementation(async () => { - refreshed = true; - }); - workspaceGitService.getSnapshot.mockImplementation(async (cwd: string) => - createWorkspaceRuntimeSnapshot(cwd, { - github: { - pullRequest: refreshed - ? { - url: "https://github.com/acme/repo/pull/457", - title: "After explicit refresh", - state: "merged", - baseRefName: "main", - headRefName: "workspace-git-service", - isMerged: true, - } - : { - url: "https://github.com/acme/repo/pull/456", - title: "Before explicit refresh", - state: "open", - baseRefName: "main", - headRefName: "workspace-git-service", - isMerged: false, - }, - refreshedAt: refreshed ? "2026-04-12T00:10:00.000Z" : "2026-04-12T00:05:00.000Z", - }, - }), - ); await session.handleMessage({ type: "checkout_pr_status_request", cwd: "/tmp/repo", - requestId: "req-pr-refresh", + requestId: "req-pr-cached", }); - expect(workspaceGitService.refresh).toHaveBeenCalledWith("/tmp/repo", { - priority: "high", - }); - expect( - emitted.find((message) => message.type === "checkout_pr_status_response")?.payload, - ).toEqual({ - cwd: "/tmp/repo", - status: { - url: "https://github.com/acme/repo/pull/457", - title: "After explicit refresh", - state: "merged", - baseRefName: "main", - headRefName: "workspace-git-service", - isMerged: true, - }, - githubFeaturesEnabled: true, - error: null, - requestId: "req-pr-refresh", - }); + expect(workspaceGitService.refresh).not.toHaveBeenCalled(); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo"); + expect(emitted.find((message) => message.type === "checkout_pr_status_response")).toBeDefined(); }); }); diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 464c66fb6..fe9062de5 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -113,6 +113,11 @@ function createNoopWorkspaceGitService() { }, }), refresh: async () => {}, + requestWorkingTreeWatch: async (cwd: string) => ({ + repoRoot: cwd, + unsubscribe: () => {}, + }), + scheduleRefreshForCwd: () => {}, dispose: () => {}, }; } @@ -1132,7 +1137,6 @@ describe("workspace aggregation", () => { test("create paseo worktree request returns a registered workspace descriptor", async () => { const emitted: Array<{ type: string; payload: unknown }> = []; - const session = createSessionForWorkspaceTests() as any; const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-worktree-test-"))); const repoDir = path.join(tempDir, "repo"); const paseoHome = path.join(tempDir, "paseo-home"); @@ -1143,6 +1147,45 @@ describe("workspace aggregation", () => { writeFileSync(path.join(repoDir, "file.txt"), "hello\n"); execSync("git add .", { cwd: repoDir, stdio: "pipe" }); execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" }); + const workspaceGitService = createNoopWorkspaceGitService(); + workspaceGitService.getSnapshot = vi.fn(async (cwd: string) => { + if (cwd === repoDir) { + return createWorkspaceRuntimeSnapshot(cwd, { + git: { + repoRoot: repoDir, + currentBranch: "main", + remoteUrl: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }); + } + + if (cwd.includes("worktree-123")) { + return createWorkspaceRuntimeSnapshot(cwd, { + git: { + repoRoot: cwd, + currentBranch: "worktree-123", + remoteUrl: null, + isPaseoOwnedWorktree: true, + mainRepoRoot: repoDir, + }, + }); + } + + return createWorkspaceRuntimeSnapshot(cwd, { + git: { + repoRoot: cwd, + currentBranch: "main", + remoteUrl: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }); + }); + const session = createSessionForWorkspaceTests({ + workspaceGitService, + }) as any; const workspaces = new Map(); const projects = new Map(); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 411c8dc7e..637a1902b 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -66,6 +66,66 @@ type WebSocketServerConfig = { type WebSocketRuntimeMetrics = SessionRuntimeMetrics & CheckoutDiffMetrics; +function createFallbackWorkspaceGitService(): WorkspaceGitServiceImpl { + return { + subscribe: async ({ cwd }: { cwd: string }) => ({ + initial: { + cwd, + git: { + isGit: false, + repoRoot: null, + mainRepoRoot: null, + currentBranch: null, + remoteUrl: null, + isPaseoOwnedWorktree: false, + isDirty: null, + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + diffStat: null, + }, + github: { + featuresEnabled: false, + pullRequest: null, + error: null, + refreshedAt: null, + }, + }, + unsubscribe: () => {}, + }), + peekSnapshot: () => null, + getSnapshot: async (cwd: string) => ({ + cwd, + git: { + isGit: false, + repoRoot: null, + mainRepoRoot: null, + currentBranch: null, + remoteUrl: null, + isPaseoOwnedWorktree: false, + isDirty: null, + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + diffStat: null, + }, + github: { + featuresEnabled: false, + pullRequest: null, + error: null, + refreshedAt: null, + }, + }), + refresh: async () => {}, + requestWorkingTreeWatch: async (cwd: string) => ({ + repoRoot: cwd, + unsubscribe: () => {}, + }), + scheduleRefreshForCwd: () => {}, + dispose: () => {}, + } as unknown as WorkspaceGitServiceImpl; +} + function createNoopProjectRegistry(): ProjectRegistry { return { initialize: async () => {}, @@ -307,6 +367,7 @@ export class VoiceAssistantWebSocketServer { loopService?: LoopService, scheduleService?: ScheduleService, checkoutDiffManager?: CheckoutDiffManager, + workspaceGitService?: WorkspaceGitServiceImpl, ) { this.logger = logger.child({ module: "websocket-server" }); this.serverId = serverId; @@ -334,10 +395,7 @@ export class VoiceAssistantWebSocketServer { throw new Error("VoiceAssistantWebSocketServer requires a checkout diff manager."); } this.checkoutDiffManager = checkoutDiffManager; - this.workspaceGitService = new WorkspaceGitServiceImpl({ - logger: this.logger, - paseoHome, - }); + this.workspaceGitService = workspaceGitService ?? createFallbackWorkspaceGitService(); this.downloadTokenStore = downloadTokenStore; this.paseoHome = paseoHome; this.daemonConfigStore = daemonConfigStore; @@ -515,8 +573,8 @@ export class VoiceAssistantWebSocketServer { await Promise.all(cleanupPromises); this.providerSnapshotManager.destroy(); - this.workspaceGitService.dispose(); this.checkoutDiffManager.dispose(); + this.workspaceGitService.dispose(); this.pendingConnections.clear(); this.sessions.clear(); this.externalSessionsByKey.clear(); diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index 97baf930c..8d9ecc886 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import path from "node:path"; import type { CheckoutStatusGit, PullRequestStatusResult } from "../utils/checkout-git.js"; import { WorkspaceGitServiceImpl, @@ -116,11 +117,28 @@ function createWatcher() { }; } +function createDirent(name: string, isDirectory: boolean) { + return { + name, + isDirectory: () => isDirectory, + }; +} + async function flushPromises(): Promise { await Promise.resolve(); await Promise.resolve(); } +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + function createService(options?: { getCheckoutStatus?: ReturnType; getCheckoutShortstat?: ReturnType; @@ -129,6 +147,8 @@ function createService(options?: { resolveAbsoluteGitDir?: ReturnType; hasOriginRemote?: ReturnType; runGitFetch?: ReturnType; + runGitCommand?: ReturnType; + readdir?: ReturnType; watch?: ReturnType; now?: () => Date; }) { @@ -137,6 +157,7 @@ function createService(options?: { paseoHome: "/tmp/paseo-test", deps: { watch: options?.watch ?? ((() => createWatcher()) as unknown as any), + readdir: options?.readdir ?? vi.fn(async () => []), getCheckoutStatus: options?.getCheckoutStatus ?? vi.fn(async (cwd: string) => createCheckoutStatus(cwd)), getCheckoutShortstat: @@ -151,6 +172,15 @@ function createService(options?: { resolveAbsoluteGitDir: options?.resolveAbsoluteGitDir ?? vi.fn(async () => "/tmp/repo/.git"), hasOriginRemote: options?.hasOriginRemote ?? vi.fn(async () => false), runGitFetch: options?.runGitFetch ?? vi.fn(async () => {}), + runGitCommand: + options?.runGitCommand ?? + vi.fn(async () => ({ + stdout: "/tmp/repo\n", + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + })), now: options?.now ?? (() => new Date("2026-04-12T00:00:00.000Z")), }, }); @@ -217,6 +247,48 @@ describe("WorkspaceGitServiceImpl", () => { service.dispose(); }); + test("cold getSnapshot calls share one workspace target setup and cache the snapshot", async () => { + const checkoutStatusDeferred = createDeferred(); + const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise); + const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); + const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git"); + + const service = createService({ + getCheckoutStatus, + getPullRequestStatus, + resolveAbsoluteGitDir, + }); + + const firstSnapshotPromise = service.getSnapshot("/tmp/repo"); + const secondSnapshotPromise = service.getSnapshot("/tmp/repo/."); + await flushPromises(); + + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); + expect(getPullRequestStatus).toHaveBeenCalledTimes(0); + expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0); + expect((service as any).workspaceTargets.size).toBe(0); + expect((service as any).workspaceTargetSetups.size).toBe(1); + + checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo")); + + await expect(Promise.all([firstSnapshotPromise, secondSnapshotPromise])).resolves.toEqual([ + createSnapshot("/tmp/repo"), + createSnapshot("/tmp/repo"), + ]); + + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); + expect(getPullRequestStatus).toHaveBeenCalledTimes(1); + expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1); + expect((service as any).workspaceTargets.size).toBe(1); + expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo")); + + await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo")); + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); + expect(getPullRequestStatus).toHaveBeenCalledTimes(1); + + service.dispose(); + }); + test("multiple listeners on the same workspace share one GitHub pull request lookup", async () => { const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git"); @@ -432,4 +504,160 @@ describe("WorkspaceGitServiceImpl", () => { subscription.unsubscribe(); service.dispose(); }); + + test("watches nested repository directories on Linux", async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { + configurable: true, + value: "linux", + }); + + 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 as any; + }); + const readdir = vi.fn(async (directory: string) => { + if (directory === "/tmp/repo") { + return [ + createDirent("packages", true), + createDirent(".git", true), + createDirent("README.md", false), + ]; + } + if (directory === path.join("/tmp/repo", "packages")) { + return [createDirent("server", true), createDirent("app", true)]; + } + if (directory === path.join("/tmp/repo", "packages", "server")) { + return [createDirent("src", true)]; + } + if (directory === path.join("/tmp/repo", "packages", "server", "src")) { + return [createDirent("server", true)]; + } + return []; + }); + + const service = createService({ watch, readdir }); + const subscription = await service.requestWorkingTreeWatch( + path.join("/tmp/repo", "packages", "server"), + vi.fn(), + ); + + expect(subscription.repoRoot).toBe("/tmp/repo"); + expect(watchCalls.map((entry) => entry.path).sort()).toEqual([ + "/tmp/repo", + "/tmp/repo/.git", + "/tmp/repo/packages", + "/tmp/repo/packages/app", + "/tmp/repo/packages/server", + "/tmp/repo/packages/server/src", + "/tmp/repo/packages/server/src/server", + ]); + + subscription.unsubscribe(); + 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] as any) + .mockReturnValueOnce(watchers[1] as any); + const service = createService({ watch }); + + const firstListener = vi.fn(); + const secondListener = vi.fn(); + const first = await service.requestWorkingTreeWatch("/tmp/repo", firstListener); + const second = await service.requestWorkingTreeWatch("/tmp/repo/.", secondListener); + + expect(first.repoRoot).toBe("/tmp/repo"); + expect(second.repoRoot).toBe("/tmp/repo"); + expect(watch).toHaveBeenCalledTimes(2); + + first.unsubscribe(); + expect(watchers[0].close).not.toHaveBeenCalled(); + expect(watchers[1].close).not.toHaveBeenCalled(); + + second.unsubscribe(); + expect(watchers[0].close).toHaveBeenCalledTimes(1); + expect(watchers[1].close).toHaveBeenCalledTimes(1); + + service.dispose(); + }); + + test("sets a 5-second fallback polling interval when recursive watch is unavailable", async () => { + const recursiveUnsupported = new Error("recursive unsupported"); + const watch = vi + .fn() + .mockImplementationOnce((_watchPath: string, options: { recursive: boolean }) => { + if (options.recursive) { + throw recursiveUnsupported; + } + return createWatcher() as any; + }) + .mockImplementationOnce(() => createWatcher() as any); + + const service = createService({ watch }); + const subscription = await service.requestWorkingTreeWatch("/tmp/repo", vi.fn()); + const target = (service as any).workingTreeWatchTargets.get("/tmp/repo"); + + expect(target?.fallbackRefreshInterval).not.toBeNull(); + + subscription.unsubscribe(); + service.dispose(); + }); + + test("non-git directories fall back to watching cwd with polling", async () => { + const watch = vi.fn(() => createWatcher() as any); + const runGitCommand = vi.fn(async () => { + throw new Error("not a git repository"); + }); + const resolveAbsoluteGitDir = vi.fn(async () => null); + const service = createService({ + watch, + runGitCommand, + resolveAbsoluteGitDir, + }); + + const subscription = await service.requestWorkingTreeWatch("/tmp/plain", vi.fn()); + const target = (service as any).workingTreeWatchTargets.get("/tmp/plain"); + + expect(subscription.repoRoot).toBeNull(); + expect(watch).toHaveBeenCalledWith("/tmp/plain", { recursive: true }, expect.any(Function)); + expect(target?.repoWatchPath).toBe("/tmp/plain"); + expect(target?.fallbackRefreshInterval).not.toBeNull(); + + subscription.unsubscribe(); + service.dispose(); + }); + + test("working tree changes notify listeners and schedule workspace refresh", async () => { + const watchCallbacks: Array<() => void> = []; + const watch = vi.fn( + (_watchPath: string, _options: { recursive: boolean }, callback: () => void) => { + watchCallbacks.push(callback); + return createWatcher() as any; + }, + ); + const service = createService({ watch }); + const refreshSpy = vi.spyOn(service as any, "scheduleWorkspaceRefresh"); + const listener = vi.fn(); + + const subscription = await service.requestWorkingTreeWatch("/tmp/repo", listener); + expect(watchCallbacks).toHaveLength(2); + + watchCallbacks[0]?.(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(refreshSpy).toHaveBeenCalledWith("/tmp/repo"); + + subscription.unsubscribe(); + service.dispose(); + }); }); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 62508470a..2adf7d315 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -1,5 +1,5 @@ import { watch, type FSWatcher } from "node:fs"; -import { readFile } from "node:fs/promises"; +import { readFile, readdir } from "node:fs/promises"; import { join, resolve } from "node:path"; import type pino from "pino"; import type { CheckoutContext } from "../utils/checkout-git.js"; @@ -12,10 +12,12 @@ import { resolveAbsoluteGitDir, } from "../utils/checkout-git.js"; import { runGitCommand } from "../utils/run-git-command.js"; +import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js"; import { normalizeWorkspaceId } from "./workspace-registry-model.js"; const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500; const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000; +const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_000; export type WorkspaceGitRuntimeSnapshot = { cwd: string; @@ -59,6 +61,11 @@ export interface WorkspaceGitService { peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null; getSnapshot(cwd: string): Promise; refresh(cwd: string, options?: { priority?: "normal" | "high" }): Promise; + requestWorkingTreeWatch( + cwd: string, + onChange: () => void, + ): Promise<{ repoRoot: string | null; unsubscribe: () => void }>; + scheduleRefreshForCwd(cwd: string): void; dispose(): void; } @@ -66,6 +73,7 @@ export type WorkspaceGitListener = (snapshot: WorkspaceGitRuntimeSnapshot) => vo interface WorkspaceGitServiceDependencies { watch: typeof watch; + readdir: typeof readdir; getCheckoutStatus: typeof getCheckoutStatus; getCheckoutShortstat: typeof getCheckoutShortstat; getPullRequestStatus: typeof getPullRequestStatus; @@ -73,6 +81,7 @@ interface WorkspaceGitServiceDependencies { resolveAbsoluteGitDir: (cwd: string) => Promise; hasOriginRemote: (cwd: string) => Promise; runGitFetch: (cwd: string) => Promise; + runGitCommand: typeof runGitCommand; now: () => Date; } @@ -102,6 +111,18 @@ interface RepoGitTarget { fetchInFlight: boolean; } +interface WorkingTreeWatchTarget { + cwd: string; + repoRoot: string | null; + repoWatchPath: string | null; + watchers: FSWatcher[]; + watchedPaths: Set; + fallbackRefreshInterval: NodeJS.Timeout | null; + linuxTreeRefreshPromise: Promise | null; + linuxTreeRefreshQueued: boolean; + listeners: Set<() => void>; +} + export class WorkspaceGitServiceImpl implements WorkspaceGitService { private readonly logger: pino.Logger; private readonly paseoHome: string; @@ -109,12 +130,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private readonly workspaceTargets = new Map(); private readonly repoTargets = new Map(); private readonly workspaceTargetSetups = new Map>(); + private readonly workingTreeWatchTargets = new Map(); + private readonly workingTreeWatchSetups = new Map>(); constructor(options: WorkspaceGitServiceOptions) { this.logger = options.logger.child({ module: "workspace-git-service" }); this.paseoHome = options.paseoHome; this.deps = { - watch, + watch: options.deps?.watch ?? watch, + readdir: options.deps?.readdir ?? readdir, getCheckoutStatus: options.deps?.getCheckoutStatus ?? getCheckoutStatus, getCheckoutShortstat: options.deps?.getCheckoutShortstat ?? getCheckoutShortstat, getPullRequestStatus: options.deps?.getPullRequestStatus ?? getPullRequestStatus, @@ -122,6 +146,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { resolveAbsoluteGitDir: options.deps?.resolveAbsoluteGitDir ?? resolveAbsoluteGitDir, hasOriginRemote: options.deps?.hasOriginRemote ?? hasOriginRemote, runGitFetch: options.deps?.runGitFetch ?? runGitFetch, + runGitCommand: options.deps?.runGitCommand ?? runGitCommand, now: options.deps?.now ?? (() => new Date()), }; } @@ -151,7 +176,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (target?.latestSnapshot) { return target.latestSnapshot; } - return this.refreshSnapshot(cwd); + + const ensuredTarget = await this.ensureWorkspaceTarget(cwd); + return ensuredTarget.latestSnapshot ?? (await this.refreshSnapshot(cwd)); } peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null { @@ -170,17 +197,47 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { await this.ensureWorkspaceTarget(cwd); } + async requestWorkingTreeWatch( + cwd: string, + onChange: () => void, + ): Promise<{ repoRoot: string | null; unsubscribe: () => void }> { + cwd = normalizeWorkspaceId(cwd); + const target = await this.ensureWorkingTreeWatchTarget(cwd); + target.listeners.add(onChange); + + return { + repoRoot: target.repoRoot, + unsubscribe: () => { + this.removeWorkingTreeWatchListener(cwd, onChange); + }, + }; + } + + scheduleRefreshForCwd(cwd: string): void { + cwd = normalizeWorkspaceId(cwd); + const target = this.workspaceTargets.get(cwd); + if (target) { + this.scheduleWorkspaceRefresh(target); + } + } + dispose(): void { for (const target of this.workspaceTargets.values()) { this.closeWorkspaceTarget(target); } this.workspaceTargets.clear(); + this.workspaceTargetSetups.clear(); for (const target of this.repoTargets.values()) { this.closeRepoTarget(target); } this.repoTargets.clear(); - this.workspaceTargetSetups.clear(); + + for (const target of this.workingTreeWatchTargets.values()) { + this.closeWorkingTreeWatchTarget(target); + } + this.workingTreeWatchTargets.clear(); + this.workingTreeWatchSetups.clear(); } private async ensureWorkspaceTarget(cwd: string): Promise { @@ -201,6 +258,24 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return setup; } + private async ensureWorkingTreeWatchTarget(cwd: string): Promise { + const existingTarget = this.workingTreeWatchTargets.get(cwd); + if (existingTarget) { + return existingTarget; + } + + const existingSetup = this.workingTreeWatchSetups.get(cwd); + if (existingSetup) { + return existingSetup; + } + + const setup = this.createWorkingTreeWatchTarget(cwd).finally(() => { + this.workingTreeWatchSetups.delete(cwd); + }); + this.workingTreeWatchSetups.set(cwd, setup); + return setup; + } + private async createWorkspaceTarget(cwd: string): Promise { const target: WorkspaceGitTarget = { cwd, @@ -230,6 +305,83 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return target; } + private async createWorkingTreeWatchTarget(cwd: string): Promise { + const repoRoot = await this.resolveCheckoutWatchRoot(cwd); + const target: WorkingTreeWatchTarget = { + cwd, + repoRoot, + repoWatchPath: null, + watchers: [], + watchedPaths: new Set(), + fallbackRefreshInterval: null, + linuxTreeRefreshPromise: null, + linuxTreeRefreshQueued: false, + listeners: new Set(), + }; + + const repoWatchPath = repoRoot ?? cwd; + target.repoWatchPath = repoWatchPath; + const watchPaths = new Set([repoWatchPath]); + const gitDir = await this.deps.resolveAbsoluteGitDir(cwd); + if (gitDir) { + watchPaths.add(gitDir); + } + + 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); + 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", + ); + } + + this.workingTreeWatchTargets.set(cwd, target); + return target; + } + + private async resolveCheckoutWatchRoot(cwd: string): Promise { + try { + const { stdout } = await this.deps.runGitCommand( + ["rev-parse", "--path-format=absolute", "--show-toplevel"], + { + cwd, + env: READ_ONLY_GIT_ENV, + }, + ); + const root = stdout.trim(); + return root.length > 0 ? root : null; + } catch { + return null; + } + } + private async resolveWorkspaceGitRefsRoot(gitDir: string): Promise { try { const commonDir = (await readFile(join(gitDir, "commondir"), "utf8")).trim(); @@ -308,7 +460,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { void this.runRepoFetch(repoTarget); } - private scheduleWorkspaceRefresh(target: WorkspaceGitTarget): void { + private scheduleWorkspaceRefresh(targetOrCwd: WorkspaceGitTarget | string): void { + const target = + typeof targetOrCwd === "string" + ? this.workspaceTargets.get(normalizeWorkspaceId(targetOrCwd)) + : targetOrCwd; + if (!target) { + return; + } + if (target.debounceTimer) { clearTimeout(target.debounceTimer); } @@ -319,6 +479,149 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { }, WORKSPACE_GIT_WATCH_DEBOUNCE_MS); } + 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); + 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", + ); + } + } while (target.linuxTreeRefreshQueued); + })(); + + try { + await target.linuxTreeRefreshPromise; + } finally { + target.linuxTreeRefreshPromise = null; + } + } + + private async listLinuxWatchDirectories(rootPath: string): Promise { + const directories: string[] = []; + const pending = [rootPath]; + + while (pending.length > 0) { + const directory = pending.pop(); + if (!directory) { + continue; + } + directories.push(directory); + + let entries; + try { + entries = await this.deps.readdir(directory, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (!entry.isDirectory() || entry.name === ".git") { + continue; + } + pending.push(join(directory, entry.name)); + } + } + + return directories; + } + private async refreshWorkspaceTarget(target: WorkspaceGitTarget): Promise { if (target.refreshPromise) { target.refreshQueued = true; @@ -435,6 +738,21 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { this.workspaceTargets.delete(target.cwd); } + private removeWorkingTreeWatchListener(cwd: string, listener: () => void): void { + const target = this.workingTreeWatchTargets.get(cwd); + if (!target) { + return; + } + + target.listeners.delete(listener); + if (target.listeners.size > 0) { + return; + } + + this.closeWorkingTreeWatchTarget(target); + this.workingTreeWatchTargets.delete(cwd); + } + private closeWorkspaceTarget(target: WorkspaceGitTarget): void { if (target.debounceTimer) { clearTimeout(target.debounceTimer); @@ -448,6 +766,20 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target.listeners.clear(); } + private closeWorkingTreeWatchTarget(target: WorkingTreeWatchTarget): void { + if (target.fallbackRefreshInterval) { + clearInterval(target.fallbackRefreshInterval); + target.fallbackRefreshInterval = null; + } + + for (const watcher of target.watchers) { + watcher.close(); + } + target.watchers = []; + target.watchedPaths.clear(); + target.listeners.clear(); + } + private closeRepoTarget(target: RepoGitTarget): void { if (target.intervalId) { clearInterval(target.intervalId); diff --git a/packages/server/src/server/workspace-registry-bootstrap.test.ts b/packages/server/src/server/workspace-registry-bootstrap.test.ts index 66fa5d442..5f81aa3e2 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.test.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.test.ts @@ -6,15 +6,77 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { createTestLogger } from "../test-utils/test-logger.js"; import { AgentStorage } from "./agent/agent-storage.js"; +import type { WorkspaceGitService } from "./workspace-git-service.js"; import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js"; import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js"; +function createNoopWorkspaceGitService(): WorkspaceGitService { + return { + subscribe: async (params) => ({ + initial: { + cwd: params.cwd, + git: { + isGit: false, + repoRoot: null, + mainRepoRoot: null, + currentBranch: null, + remoteUrl: null, + isPaseoOwnedWorktree: false, + isDirty: null, + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + diffStat: null, + }, + github: { + featuresEnabled: false, + pullRequest: null, + error: null, + refreshedAt: null, + }, + }, + unsubscribe: () => {}, + }), + peekSnapshot: () => null, + getSnapshot: async (cwd) => ({ + cwd, + git: { + isGit: false, + repoRoot: null, + mainRepoRoot: null, + currentBranch: null, + remoteUrl: null, + isPaseoOwnedWorktree: false, + isDirty: null, + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + diffStat: null, + }, + github: { + featuresEnabled: false, + pullRequest: null, + error: null, + refreshedAt: null, + }, + }), + refresh: async () => {}, + requestWorkingTreeWatch: async () => ({ + repoRoot: null, + unsubscribe: () => {}, + }), + scheduleRefreshForCwd: () => {}, + dispose: () => {}, + }; +} + describe("bootstrapWorkspaceRegistries", () => { let tmpDir: string; let paseoHome: string; let agentStorage: AgentStorage; let projectRegistry: FileBackedProjectRegistry; let workspaceRegistry: FileBackedWorkspaceRegistry; + let workspaceGitService: WorkspaceGitService; const logger = createTestLogger(); beforeEach(() => { @@ -29,6 +91,7 @@ describe("bootstrapWorkspaceRegistries", () => { path.join(paseoHome, "projects", "workspaces.json"), logger, ); + workspaceGitService = createNoopWorkspaceGitService(); }); afterEach(() => { @@ -94,6 +157,7 @@ describe("bootstrapWorkspaceRegistries", () => { agentStorage, projectRegistry, workspaceRegistry, + workspaceGitService, logger, }); @@ -157,6 +221,7 @@ describe("bootstrapWorkspaceRegistries", () => { agentStorage, projectRegistry, workspaceRegistry, + workspaceGitService, logger, }); diff --git a/packages/server/src/server/workspace-registry-bootstrap.ts b/packages/server/src/server/workspace-registry-bootstrap.ts index c9403f810..625334ffe 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.ts @@ -13,6 +13,7 @@ import { deriveWorkspaceKind, normalizeWorkspaceId, } from "./workspace-registry-model.js"; +import type { WorkspaceGitService } from "./workspace-git-service.js"; import { createPersistedProjectRecord, createPersistedWorkspaceRecord, @@ -53,6 +54,7 @@ export async function bootstrapWorkspaceRegistries(options: { agentStorage: AgentStorage; projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; + workspaceGitService: WorkspaceGitService; logger: Logger; }): Promise { const [projectsExists, workspacesExists] = await Promise.all([ @@ -79,7 +81,7 @@ export async function bootstrapWorkspaceRegistries(options: { const normalizedCwd = normalizeWorkspaceId(record.cwd); const placement = await buildProjectPlacementForCwd({ cwd: normalizedCwd, - paseoHome: options.paseoHome, + workspaceGitService: options.workspaceGitService, }); const workspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout); const existing = recordsByWorkspaceId.get(workspaceId) ?? { placement, records: [] }; diff --git a/packages/server/src/server/workspace-registry-model.ts b/packages/server/src/server/workspace-registry-model.ts index 545095e71..8bce33279 100644 --- a/packages/server/src/server/workspace-registry-model.ts +++ b/packages/server/src/server/workspace-registry-model.ts @@ -1,7 +1,7 @@ import { resolve } from "node:path"; -import { getCheckoutStatusLite } from "../utils/checkout-git.js"; import type { ProjectCheckoutLitePayload, ProjectPlacementPayload } from "../shared/messages.js"; +import type { WorkspaceGitService } from "./workspace-git-service.js"; import type { PersistedWorkspaceRecord } from "./workspace-registry.js"; export type PersistedProjectKind = "git" | "non_git"; @@ -198,45 +198,15 @@ export async function detectStaleWorkspaces( export async function buildProjectPlacementForCwd(input: { cwd: string; - paseoHome: string; + workspaceGitService: WorkspaceGitService; }): Promise { const normalizedCwd = normalizeWorkspaceId(input.cwd); - const checkout = await getCheckoutStatusLite(normalizedCwd, { paseoHome: input.paseoHome }) - .then((status): ProjectCheckoutLitePayload => { - if (!status.isGit) { - return { - cwd: normalizedCwd, - isGit: false, - currentBranch: null, - remoteUrl: null, - worktreeRoot: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }; - } - - if (status.isPaseoOwnedWorktree && status.mainRepoRoot) { - return { - cwd: normalizedCwd, - isGit: true, - currentBranch: status.currentBranch, - remoteUrl: status.remoteUrl, - worktreeRoot: status.worktreeRoot, - isPaseoOwnedWorktree: true, - mainRepoRoot: status.mainRepoRoot, - }; - } - - return { - cwd: normalizedCwd, - isGit: true, - currentBranch: status.currentBranch, - remoteUrl: status.remoteUrl, - worktreeRoot: status.worktreeRoot, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }; - }) + const checkout = await input.workspaceGitService + .getSnapshot(normalizedCwd) + .then( + (snapshot): ProjectCheckoutLitePayload => + checkoutLiteFromGitSnapshot(normalizedCwd, snapshot.git), + ) .catch( (): ProjectCheckoutLitePayload => ({ cwd: normalizedCwd, diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 3ce9af075..2b343e6c3 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -17,10 +17,11 @@ import type { ProjectRegistry, WorkspaceRegistry, } from "./workspace-registry.js"; +import type { WorkspaceGitService } from "./workspace-git-service.js"; import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js"; import { createAgentWorktree } from "./worktree-bootstrap.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; -import { getCheckoutStatusLite, resolveRepositoryDefaultBranch } from "../utils/checkout-git.js"; +import { resolveRepositoryDefaultBranch } from "../utils/checkout-git.js"; import { expandTilde } from "../utils/path.js"; import { computeWorktreePath, @@ -51,6 +52,7 @@ type EmitSessionMessage = (message: SessionOutboundMessage) => void; type BuildAgentSessionConfigDependencies = { paseoHome?: string; sessionLogger: Logger; + workspaceGitService: WorkspaceGitService; checkoutExistingBranch: (cwd: string, branch: string) => Promise; createBranchFromBase: (params: { cwd: string; @@ -99,6 +101,7 @@ type CreatePaseoWorktreeInBackgroundDependencies = { type HandleCreatePaseoWorktreeRequestDependencies = { paseoHome?: string; + workspaceGitService: WorkspaceGitService; describeWorkspaceRecord: ( workspace: PersistedWorkspaceRecord, ) => Promise; @@ -167,7 +170,8 @@ export async function buildAgentSessionConfig( ); const baseBranch = - normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome)); + normalized.baseBranch ?? + (await resolveGitCreateBaseBranch(cwd, dependencies.workspaceGitService)); const createdWorktree = await createAgentWorktree({ branchName: targetBranch, cwd, @@ -179,7 +183,8 @@ export async function buildAgentSessionConfig( worktreeConfig = createdWorktree; } else if (normalized.createNewBranch) { const baseBranch = - normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome)); + normalized.baseBranch ?? + (await resolveGitCreateBaseBranch(cwd, dependencies.workspaceGitService)); await dependencies.createBranchFromBase({ cwd, baseBranch, @@ -264,13 +269,18 @@ export function assertSafeGitRef(ref: string, label: string): void { } } -export async function resolveGitCreateBaseBranch(cwd: string, paseoHome?: string): Promise { - const checkout = await getCheckoutStatusLite(cwd, { paseoHome }); - if (!checkout.isGit) { +export async function resolveGitCreateBaseBranch( + cwd: string, + workspaceGitService: WorkspaceGitService, +): Promise { + const snapshot = await workspaceGitService.getSnapshot(cwd); + if (!snapshot.git.isGit) { throw new Error("Cannot create a worktree outside a git repository"); } - const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : cwd; + const repoRoot = snapshot.git.isPaseoOwnedWorktree + ? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? cwd) + : (snapshot.git.repoRoot ?? cwd); const baseBranch = await resolveRepositoryDefaultBranch(repoRoot); if (!baseBranch) { throw new Error("Unable to resolve repository default branch"); @@ -545,14 +555,14 @@ export async function handleCreatePaseoWorktreeRequest( request: Extract, ): Promise { try { - const checkout = await getCheckoutStatusLite(request.cwd, { - paseoHome: dependencies.paseoHome, - }); - if (!checkout.isGit) { + const snapshot = await dependencies.workspaceGitService.getSnapshot(request.cwd); + if (!snapshot.git.isGit) { throw new Error("Create worktree requires a git repository"); } - const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : request.cwd; + const repoRoot = snapshot.git.isPaseoOwnedWorktree + ? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? request.cwd) + : (snapshot.git.repoRoot ?? request.cwd); const baseBranch = await resolveRepositoryDefaultBranch(repoRoot); if (!baseBranch) { throw new Error("Unable to resolve repository default branch"); diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 9a42ad928..3ce01f7cc 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -22,7 +22,6 @@ import { getCheckoutShortstat, getPullRequestStatus, getCheckoutStatus, - getCheckoutStatusLite, listBranchSuggestions, mergeToBase, mergeFromBase, @@ -164,15 +163,6 @@ const x = 1; expect(removedLine?.tokens).toEqual([{ text: "old comment line", style: "comment" }]); }); - it("returns lightweight checkout status for normal repos", async () => { - const status = await getCheckoutStatusLite(repoDir); - expect(status.isGit).toBe(true); - expect(status.currentBranch).toBe("main"); - expect(status.worktreeRoot).toBe(repoDir); - expect(status.isPaseoOwnedWorktree).toBe(false); - expect(status.mainRepoRoot).toBeNull(); - }); - it("exposes hasRemote when origin is configured", async () => { const remoteDir = join(tempDir, "remote.git"); execSync(`git init --bare -b main ${remoteDir}`); @@ -380,22 +370,6 @@ const x = 1; expect(message).toBe("worktree update"); }); - it("returns lightweight checkout status for .paseo worktrees", async () => { - const result = await createWorktree({ - branchName: "main", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "lite-alpha", - paseoHome, - }); - - const status = await getCheckoutStatusLite(result.worktreePath, { paseoHome }); - expect(status.isGit).toBe(true); - expect(status.worktreeRoot).toBe(result.worktreePath); - expect(status.isPaseoOwnedWorktree).toBe(true); - expect(status.mainRepoRoot).toBe(repoDir); - }); - it("returns mainRepoRoot pointing to first non-bare worktree for bare repos", async () => { const bareRepoDir = join(tempDir, "bare-repo"); execSync(`git clone --bare ${repoDir} ${bareRepoDir}`); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 45cd97f35..19669e8bc 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -482,38 +482,6 @@ export type CheckoutStatusGit = CheckoutStatusGitNonPaseo | CheckoutStatusGitPas export type CheckoutStatusResult = CheckoutStatus | CheckoutStatusGit; -export type CheckoutStatusLiteNotGit = { - isGit: false; - currentBranch: null; - remoteUrl: null; - worktreeRoot: null; - isPaseoOwnedWorktree: false; - mainRepoRoot: null; -}; - -export type CheckoutStatusLiteGitNonPaseo = { - isGit: true; - currentBranch: string | null; - remoteUrl: string | null; - worktreeRoot: string; - isPaseoOwnedWorktree: false; - mainRepoRoot: null; -}; - -export type CheckoutStatusLiteGitPaseo = { - isGit: true; - currentBranch: string | null; - remoteUrl: string | null; - worktreeRoot: string; - isPaseoOwnedWorktree: true; - mainRepoRoot: string; -}; - -export type CheckoutStatusLiteResult = - | CheckoutStatusLiteNotGit - | CheckoutStatusLiteGitNonPaseo - | CheckoutStatusLiteGitPaseo; - export interface CheckoutDiffResult { diff: string; structured?: ParsedDiffFile[]; @@ -1156,43 +1124,6 @@ export async function getCheckoutStatus( }; } -export async function getCheckoutStatusLite( - cwd: string, - context?: CheckoutContext, -): Promise { - const inspected = await inspectCheckoutContext(cwd, context); - if (!inspected) { - return { - isGit: false, - currentBranch: null, - remoteUrl: null, - worktreeRoot: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }; - } - - if (inspected.configured.isPaseoOwnedWorktree) { - return { - isGit: true, - currentBranch: inspected.currentBranch, - remoteUrl: inspected.remoteUrl, - worktreeRoot: inspected.worktreeRoot, - isPaseoOwnedWorktree: true, - mainRepoRoot: await getMainRepoRoot(cwd), - }; - } - - return { - isGit: true, - currentBranch: inspected.currentBranch, - remoteUrl: inspected.remoteUrl, - worktreeRoot: inspected.worktreeRoot, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }; -} - export interface CheckoutShortstat { additions: number; deletions: number; diff --git a/packages/server/vitest.config.ts b/packages/server/vitest.config.ts index a332dbac5..c4bf466d5 100644 --- a/packages/server/vitest.config.ts +++ b/packages/server/vitest.config.ts @@ -21,6 +21,6 @@ export default defineConfig({ maxForks: 1, }, }, - exclude: ["**/node_modules/**", "**/dist/**"], + exclude: ["**/node_modules/**", "**/dist/**", "**/.claude/**"], }, }); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..c39f1daeb --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + exclude: ["**/.claude/**"], + }, +});