diff --git a/packages/server/src/server/background-git-fetch-manager.test.ts b/packages/server/src/server/background-git-fetch-manager.test.ts deleted file mode 100644 index aa2b1f8f9..000000000 --- a/packages/server/src/server/background-git-fetch-manager.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; - -const execFileMock = vi.hoisted(() => - vi.fn( - ( - _file: string, - _args: string[], - _options: unknown, - callback: (error: Error | null, stdout?: string, stderr?: string) => void, - ) => { - callback(null, "", ""); - }, - ), -); - -vi.mock("node:child_process", async () => { - const actual = await vi.importActual("node:child_process"); - return { - ...actual, - execFile: execFileMock, - }; -}); - -import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js"; - -async function flushPromises(): Promise { - await Promise.resolve(); - await Promise.resolve(); -} - -function createLogger() { - const logger = { - child: () => logger, - debug: vi.fn(), - warn: vi.fn(), - }; - return logger; -} - -describe("BackgroundGitFetchManager", () => { - beforeEach(() => { - vi.useFakeTimers(); - execFileMock.mockReset(); - execFileMock.mockImplementation( - ( - _file: string, - _args: string[], - _options: unknown, - callback: (error: Error | null, stdout?: string, stderr?: string) => void, - ) => { - callback(null, "", ""); - }, - ); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - test("creates a fetch timer for a repo with an origin remote", async () => { - const logger = createLogger(); - const manager = new BackgroundGitFetchManager({ logger: logger as any }); - - const subscription = await manager.subscribe( - { repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" }, - vi.fn(), - ); - await flushPromises(); - - const managerAny = manager as any; - const target = managerAny.targets.get("/tmp/repo/.git"); - expect(target).toBeDefined(); - expect(target.intervalId).toBeTruthy(); - expect(execFileMock).toHaveBeenNthCalledWith( - 1, - "git", - ["remote", "get-url", "origin"], - expect.objectContaining({ - cwd: "/tmp/repo", - env: expect.objectContaining({ GIT_TERMINAL_PROMPT: "0" }), - }), - expect.any(Function), - ); - expect(execFileMock).toHaveBeenNthCalledWith( - 2, - "git", - ["fetch", "origin", "--prune"], - expect.objectContaining({ - cwd: "/tmp/repo", - env: expect.objectContaining({ GIT_TERMINAL_PROMPT: "0" }), - }), - expect.any(Function), - ); - - subscription.unsubscribe(); - manager.dispose(); - }); - - test("dedupes multiple subscribers for the same repo root behind one timer", async () => { - const logger = createLogger(); - const manager = new BackgroundGitFetchManager({ logger: logger as any }); - - const listenerOne = vi.fn(); - const listenerTwo = vi.fn(); - const subscriptionOne = await manager.subscribe( - { repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" }, - listenerOne, - ); - const subscriptionTwo = await manager.subscribe( - { repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo-worktree" }, - listenerTwo, - ); - await flushPromises(); - - const managerAny = manager as any; - const target = managerAny.targets.get("/tmp/repo/.git"); - expect(managerAny.targets.size).toBe(1); - expect(target.listeners).toEqual(new Set([listenerOne, listenerTwo])); - expect(execFileMock.mock.calls.filter((call) => call[1][0] === "remote")).toHaveLength(1); - - subscriptionOne.unsubscribe(); - subscriptionTwo.unsubscribe(); - manager.dispose(); - }); - - test("cleans up the timer when the last subscriber unsubscribes", async () => { - const logger = createLogger(); - const manager = new BackgroundGitFetchManager({ logger: logger as any }); - - const subscription = await manager.subscribe( - { repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" }, - vi.fn(), - ); - await flushPromises(); - - const managerAny = manager as any; - const target = managerAny.targets.get("/tmp/repo/.git"); - const intervalId = target.intervalId; - const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval"); - - subscription.unsubscribe(); - - expect(clearIntervalSpy).toHaveBeenCalledWith(intervalId); - expect(managerAny.targets.size).toBe(0); - - clearIntervalSpy.mockRestore(); - manager.dispose(); - }); - - test("logs fetch errors without crashing", async () => { - const logger = createLogger(); - execFileMock.mockImplementation( - ( - _file: string, - args: string[], - _options: unknown, - callback: (error: Error | null, stdout?: string, stderr?: string) => void, - ) => { - if (args[0] === "remote") { - callback(null, "", ""); - return; - } - callback(new Error("fetch failed")); - }, - ); - const manager = new BackgroundGitFetchManager({ logger: logger as any }); - - await manager.subscribe({ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" }, vi.fn()); - await flushPromises(); - - expect(logger.debug).toHaveBeenCalledWith( - { repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" }, - "Running background git fetch", - ); - expect(logger.warn).toHaveBeenCalledWith( - { - err: expect.any(Error), - repoGitRoot: "/tmp/repo/.git", - cwd: "/tmp/repo", - }, - "Background git fetch failed", - ); - - manager.dispose(); - }); - - test("calls listeners when a fetch completes", async () => { - const logger = createLogger(); - const manager = new BackgroundGitFetchManager({ logger: logger as any }); - const listener = vi.fn(); - - await manager.subscribe({ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" }, listener); - await flushPromises(); - - expect(listener).toHaveBeenCalledTimes(1); - - await vi.advanceTimersByTimeAsync(180_000); - await flushPromises(); - - expect(listener).toHaveBeenCalledTimes(2); - - manager.dispose(); - }); - - test("does not create a timer when the repo has no origin remote", async () => { - const logger = createLogger(); - execFileMock.mockImplementation( - ( - _file: string, - _args: string[], - _options: unknown, - callback: (error: Error | null, stdout?: string, stderr?: string) => void, - ) => { - callback(new Error("missing origin")); - }, - ); - const manager = new BackgroundGitFetchManager({ logger: logger as any }); - - const subscription = await manager.subscribe( - { repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" }, - vi.fn(), - ); - - expect((manager as any).targets.size).toBe(0); - subscription.unsubscribe(); - manager.dispose(); - }); - - test("dispose clears timers and listeners", async () => { - const logger = createLogger(); - const manager = new BackgroundGitFetchManager({ logger: logger as any }); - - const listener = vi.fn(); - await manager.subscribe({ repoGitRoot: "/tmp/repo-one/.git", cwd: "/tmp/repo-one" }, listener); - await manager.subscribe({ repoGitRoot: "/tmp/repo-two/.git", cwd: "/tmp/repo-two" }, vi.fn()); - await flushPromises(); - - const managerAny = manager as any; - const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval"); - - manager.dispose(); - - expect(clearIntervalSpy).toHaveBeenCalledTimes(2); - expect(managerAny.targets.size).toBe(0); - - clearIntervalSpy.mockRestore(); - }); -}); diff --git a/packages/server/src/server/background-git-fetch-manager.ts b/packages/server/src/server/background-git-fetch-manager.ts deleted file mode 100644 index 190220254..000000000 --- a/packages/server/src/server/background-git-fetch-manager.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; -import type pino from "pino"; -import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js"; - -const execFileAsync = promisify(execFile); - -const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000; - -type BackgroundGitFetchTarget = { - repoGitRoot: string; - cwd: string; - listeners: Set<() => void>; - intervalId: NodeJS.Timeout | null; - fetchInFlight: boolean; -}; - -export class BackgroundGitFetchManager { - private readonly logger: pino.Logger; - private readonly targets = new Map(); - - constructor(options: { logger: pino.Logger }) { - this.logger = options.logger.child({ module: "background-git-fetch-manager" }); - } - - async subscribe( - params: { repoGitRoot: string; cwd: string }, - listener: () => void, - ): Promise<{ unsubscribe: () => void }> { - const existingTarget = this.targets.get(params.repoGitRoot); - if (existingTarget) { - existingTarget.listeners.add(listener); - return { - unsubscribe: () => { - this.removeListener(params.repoGitRoot, listener); - }, - }; - } - - const hasOrigin = await this.hasOriginRemote(params.cwd); - if (!hasOrigin) { - return { unsubscribe: () => {} }; - } - - const targetAfterProbe = this.targets.get(params.repoGitRoot); - if (targetAfterProbe) { - targetAfterProbe.listeners.add(listener); - return { - unsubscribe: () => { - this.removeListener(params.repoGitRoot, listener); - }, - }; - } - - const target: BackgroundGitFetchTarget = { - repoGitRoot: params.repoGitRoot, - cwd: params.cwd, - listeners: new Set([listener]), - intervalId: setInterval(() => { - void this.runFetch(target); - }, BACKGROUND_GIT_FETCH_INTERVAL_MS), - fetchInFlight: false, - }; - this.targets.set(params.repoGitRoot, target); - void this.runFetch(target); - - return { - unsubscribe: () => { - this.removeListener(params.repoGitRoot, listener); - }, - }; - } - - dispose(): void { - for (const target of this.targets.values()) { - this.closeTarget(target); - } - this.targets.clear(); - } - - private closeTarget(target: BackgroundGitFetchTarget): void { - if (target.intervalId) { - clearInterval(target.intervalId); - target.intervalId = null; - } - target.listeners.clear(); - } - - private removeListener(targetKey: string, listener: () => void): void { - const target = this.targets.get(targetKey); - if (!target) { - return; - } - - target.listeners.delete(listener); - if (target.listeners.size > 0) { - return; - } - - this.closeTarget(target); - this.targets.delete(targetKey); - } - - private async hasOriginRemote(cwd: string): Promise { - try { - await execFileAsync("git", ["remote", "get-url", "origin"], { - cwd, - env: { - ...READ_ONLY_GIT_ENV, - GIT_TERMINAL_PROMPT: "0", - }, - }); - return true; - } catch { - return false; - } - } - - private async runFetch(target: BackgroundGitFetchTarget): Promise { - if (target.fetchInFlight) { - return; - } - - target.fetchInFlight = true; - this.logger.debug( - { repoGitRoot: target.repoGitRoot, cwd: target.cwd }, - "Running background git fetch", - ); - - try { - await execFileAsync("git", ["fetch", "origin", "--prune"], { - cwd: target.cwd, - env: { - ...process.env, - GIT_TERMINAL_PROMPT: "0", - }, - }); - } catch (error) { - this.logger.warn( - { err: error, repoGitRoot: target.repoGitRoot, cwd: target.cwd }, - "Background git fetch failed", - ); - } finally { - target.fetchInFlight = false; - for (const listener of target.listeners) { - listener(); - } - } - } -} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index a11b3670a..f73051c65 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,9 +1,8 @@ import { v4 as uuidv4 } from "uuid"; -import { watch, type FSWatcher } from "node:fs"; -import { readFile, stat } from "fs/promises"; +import { stat } from "fs/promises"; import { exec, execFile } from "node:child_process"; import { promisify } from "util"; -import { join, resolve, sep } from "path"; +import { resolve, sep } from "path"; import { homedir } from "node:os"; import { z } from "zod"; import type { ToolSet } from "ai"; @@ -67,8 +66,8 @@ import { import { experimental_createMCPClient } from "ai"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js"; -import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js"; import type { DaemonConfigStore } from "./daemon-config-store.js"; +import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; import { buildProviderRegistry } from "./agent/provider-registry.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; @@ -147,7 +146,6 @@ import { type WorktreeConfig } from "../utils/worktree.js"; import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js"; import { getCheckoutDiff, - getCheckoutShortstat, getCheckoutStatus, listBranchSuggestions, commitChanges, @@ -156,12 +154,11 @@ import { pullCurrentBranch, pushCurrentBranch, createPullRequest, - getPullRequestStatus, } from "../utils/checkout-git.js"; import { getProjectIcon } from "../utils/project-icon.js"; import { expandTilde } from "../utils/path.js"; import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js"; -import { READ_ONLY_GIT_ENV, resolveCheckoutGitDir, toCheckoutError } from "./checkout-git-utils.js"; +import { READ_ONLY_GIT_ENV, toCheckoutError } from "./checkout-git-utils.js"; import { CheckoutDiffManager } from "./checkout-diff-manager.js"; import type { LocalSpeechModelId } from "./speech/providers/local/models.js"; import { toResolver, type Resolvable } from "./speech/provider-resolver.js"; @@ -219,8 +216,6 @@ function clientSupportsFlexibleEditorIds(appVersion: string | null): boolean { return isAppVersionAtLeast(appVersion, MIN_VERSION_FLEXIBLE_EDITOR_IDS); } -const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500; -const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__"; const MAX_TERMINAL_STREAM_SLOTS = 256; function deriveInitialAgentTitle(prompt: string): string | null { @@ -270,15 +265,6 @@ export function resolveWaitForFinishError(options: { type ProcessingPhase = "idle" | "transcribing"; -type WorkspaceGitWatchTarget = { - cwd: string; - watchers: FSWatcher[]; - debounceTimer: NodeJS.Timeout | null; - refreshPromise: Promise | null; - refreshQueued: boolean; - latestFingerprint: string | null; -}; - type ActiveTerminalStream = { terminalId: string; slot: number; @@ -406,7 +392,7 @@ export type SessionOptions = { scheduleService: ScheduleService; loopService: LoopService; checkoutDiffManager: CheckoutDiffManager; - backgroundGitFetchManager: BackgroundGitFetchManager; + workspaceGitService: WorkspaceGitService; daemonConfigStore: DaemonConfigStore; mcpBaseUrl?: string | null; stt: Resolvable; @@ -589,7 +575,7 @@ export class Session { private readonly scheduleService: ScheduleService; private readonly loopService: LoopService; private readonly checkoutDiffManager: CheckoutDiffManager; - private readonly backgroundGitFetchManager: BackgroundGitFetchManager; + private readonly workspaceGitService: WorkspaceGitService; private readonly daemonConfigStore: DaemonConfigStore; private readonly mcpBaseUrl: string | null; private readonly downloadTokenStore: DownloadTokenStore; @@ -618,8 +604,7 @@ export class Session { private inflightRequests = 0; private peakInflightRequests = 0; private readonly checkoutDiffSubscriptions = new Map void>(); - private readonly workspaceGitWatchTargets = new Map(); - private readonly workspaceGitFetchSubscriptions = new Map void>(); + private readonly workspaceGitSubscriptions = new Map void>(); private readonly registerVoiceSpeakHandler?: ( agentId: string, handler: VoiceSpeakHandler, @@ -654,7 +639,7 @@ export class Session { scheduleService, loopService, checkoutDiffManager, - backgroundGitFetchManager, + workspaceGitService, daemonConfigStore, mcpBaseUrl, stt, @@ -683,7 +668,7 @@ export class Session { this.scheduleService = scheduleService; this.loopService = loopService; this.checkoutDiffManager = checkoutDiffManager; - this.backgroundGitFetchManager = backgroundGitFetchManager; + this.workspaceGitService = workspaceGitService; this.daemonConfigStore = daemonConfigStore; this.mcpBaseUrl = mcpBaseUrl ?? null; this.terminalManager = terminalManager; @@ -1444,7 +1429,7 @@ export class Session { let removedWorkspaceId: string | null = null; if (needsStaleWorkspaceCleanup) { await this.workspaceRegistry.archive(staleWorkspace.workspaceId, now); - this.removeWorkspaceGitWatchTarget(staleWorkspace.workspaceId); + this.removeWorkspaceGitSubscription(staleWorkspace.workspaceId); removedWorkspaceId = staleWorkspace.workspaceId; } @@ -1460,7 +1445,7 @@ export class Session { await this.workspaceRegistry.upsert(nextWorkspaceRecord); if (existing && existing.workspaceId !== resolvedWorkspaceId) { await this.workspaceRegistry.archive(existing.workspaceId, now); - this.removeWorkspaceGitWatchTarget(existing.workspaceId); + this.removeWorkspaceGitSubscription(existing.workspaceId); removedWorkspaceId ??= existing.workspaceId; } @@ -4181,190 +4166,30 @@ export class Session { } } - private async resolveWorkspaceGitRefsRoot(gitDir: string): Promise { - try { - const commonDir = (await readFile(join(gitDir, "commondir"), "utf8")).trim(); - if (commonDir.length > 0) { - return resolve(gitDir, commonDir); - } - } catch { - // Regular repos do not have a commondir file. - } - return gitDir; - } - - private closeWorkspaceGitWatchTarget(target: WorkspaceGitWatchTarget): void { - if (target.debounceTimer) { - clearTimeout(target.debounceTimer); - target.debounceTimer = null; - } - for (const watcher of target.watchers) { - watcher.close(); - } - target.watchers = []; - } - - private removeWorkspaceGitWatchTarget(cwd: string): void { + private removeWorkspaceGitSubscription(cwd: string): void { const workspaceId = normalizePersistedWorkspaceId(cwd); - const target = this.workspaceGitWatchTargets.get(workspaceId); - if (!target) { - return; - } - const unsubscribeFetch = this.workspaceGitFetchSubscriptions.get(workspaceId); - unsubscribeFetch?.(); - this.workspaceGitFetchSubscriptions.delete(workspaceId); - this.closeWorkspaceGitWatchTarget(target); - this.workspaceGitWatchTargets.delete(workspaceId); - } - - private workspaceGitDescriptorFingerprint(workspace: WorkspaceDescriptorPayload | null): string { - if (!workspace) { - return WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT; - } - return JSON.stringify([ - workspace.name, - workspace.diffStat ? [workspace.diffStat.additions, workspace.diffStat.deletions] : null, - ]); - } - - private shouldSkipWorkspaceGitWatchUpdate( - workspaceId: string, - workspace: WorkspaceDescriptorPayload | null, - ): boolean { - const target = this.workspaceGitWatchTargets.get(workspaceId); - if (!target) { - return false; - } - const nextFingerprint = this.workspaceGitDescriptorFingerprint(workspace); - if (target.latestFingerprint === nextFingerprint) { - return true; - } - target.latestFingerprint = nextFingerprint; - return false; - } - - private rememberWorkspaceGitWatchFingerprint( - workspaceId: string, - workspace: WorkspaceDescriptorPayload | null, - ): void { - const target = this.workspaceGitWatchTargets.get(workspaceId); - if (!target) { - return; - } - target.latestFingerprint = this.workspaceGitDescriptorFingerprint(workspace); - } - - private primeWorkspaceGitWatchFingerprints( - workspaces: Iterable, - ): void { - for (const workspace of workspaces) { - this.rememberWorkspaceGitWatchFingerprint(workspace.id, workspace); - } - } - - private scheduleWorkspaceGitWatchRefresh(target: WorkspaceGitWatchTarget): void { - if (target.debounceTimer) { - clearTimeout(target.debounceTimer); - } - target.debounceTimer = setTimeout(() => { - target.debounceTimer = null; - void this.refreshWorkspaceGitWatchTarget(target); - }, WORKSPACE_GIT_WATCH_DEBOUNCE_MS); - } - - private async refreshWorkspaceGitWatchTarget(target: WorkspaceGitWatchTarget): Promise { - if (target.refreshPromise) { - target.refreshQueued = true; - return; - } - - target.refreshPromise = (async () => { - do { - target.refreshQueued = false; - await this.emitWorkspaceUpdateForCwd(target.cwd, { - dedupeGitState: true, - }); - } while (target.refreshQueued); - })(); - - try { - await target.refreshPromise; - } finally { - target.refreshPromise = null; - } - } - - private async ensureWorkspaceGitWatchTarget(cwd: string): Promise { - const workspaceId = normalizePersistedWorkspaceId(cwd); - if (this.workspaceGitWatchTargets.has(workspaceId)) { - return; - } - - const gitDir = await resolveCheckoutGitDir(cwd); - if (!gitDir) { - return; - } - - const refsRoot = await this.resolveWorkspaceGitRefsRoot(gitDir); - const target: WorkspaceGitWatchTarget = { - cwd: workspaceId, - watchers: [], - debounceTimer: null, - refreshPromise: null, - refreshQueued: false, - latestFingerprint: null, - }; - - for (const watchPath of new Set([join(gitDir, "HEAD"), join(refsRoot, "refs", "heads")])) { - let watcher: FSWatcher | null = null; - try { - watcher = watch(watchPath, { recursive: false }, () => { - this.scheduleWorkspaceGitWatchRefresh(target); - }); - } catch (error) { - this.sessionLogger.warn( - { err: error, cwd, watchPath }, - "Failed to start workspace git watcher", - ); - } - - if (!watcher) { - continue; - } - - watcher.on("error", (error) => { - this.sessionLogger.warn({ err: error, cwd, watchPath }, "Workspace git watcher error"); - }); - target.watchers.push(watcher); - } - - if (target.watchers.length === 0) { - return; - } - - this.workspaceGitWatchTargets.set(workspaceId, target); - const subscription = await this.backgroundGitFetchManager.subscribe( - { repoGitRoot: refsRoot, cwd: workspaceId }, - () => { - const activeTarget = this.workspaceGitWatchTargets.get(workspaceId); - if (activeTarget) { - this.scheduleWorkspaceGitWatchRefresh(activeTarget); - } - }, - ); - this.workspaceGitFetchSubscriptions.set(workspaceId, subscription.unsubscribe); + this.workspaceGitSubscriptions.get(workspaceId)?.(); + this.workspaceGitSubscriptions.delete(workspaceId); } private async syncWorkspaceGitWatchTarget( cwd: string, options: { isGit: boolean }, ): Promise { + const workspaceId = normalizePersistedWorkspaceId(cwd); if (!options.isGit) { - this.removeWorkspaceGitWatchTarget(cwd); + this.removeWorkspaceGitSubscription(workspaceId); return; } - await this.ensureWorkspaceGitWatchTarget(cwd); + if (this.workspaceGitSubscriptions.has(workspaceId)) { + return; + } + + const subscription = await this.workspaceGitService.subscribe({ cwd: workspaceId }, () => { + void this.emitWorkspaceUpdateForCwd(workspaceId); + }); + this.workspaceGitSubscriptions.set(workspaceId, subscription.unsubscribe); } private async handleSubscribeCheckoutDiffRequest( @@ -4806,14 +4631,20 @@ export class Session { const { cwd, requestId } = msg; try { - const prStatus = await getPullRequestStatus(cwd); + await this.workspaceGitService.refresh(cwd, { priority: "high" }); + const snapshot = await this.workspaceGitService.getSnapshot(cwd); this.emit({ type: "checkout_pr_status_response", payload: { cwd, - status: prStatus.status, - githubFeaturesEnabled: prStatus.githubFeaturesEnabled, - error: null, + status: snapshot.github.pullRequest, + githubFeaturesEnabled: snapshot.github.featuresEnabled, + error: snapshot.github.error + ? { + code: "UNKNOWN", + message: snapshot.github.error.message, + } + : null, requestId, }, }); @@ -5510,6 +5341,35 @@ export class Session { }; } + private buildWorkspaceGitRuntimePayload( + snapshot: WorkspaceGitRuntimeSnapshot, + ): NonNullable | null { + if (!snapshot.git.isGit) { + return null; + } + + return { + currentBranch: snapshot.git.currentBranch, + remoteUrl: snapshot.git.remoteUrl, + isPaseoOwnedWorktree: snapshot.git.isPaseoOwnedWorktree, + isDirty: snapshot.git.isDirty, + aheadBehind: snapshot.git.aheadBehind, + aheadOfOrigin: snapshot.git.aheadOfOrigin, + behindOfOrigin: snapshot.git.behindOfOrigin, + }; + } + + private buildWorkspaceGitHubRuntimePayload( + snapshot: WorkspaceGitRuntimeSnapshot, + ): NonNullable { + return { + featuresEnabled: snapshot.github.featuresEnabled, + pullRequest: snapshot.github.pullRequest, + error: snapshot.github.error, + refreshedAt: snapshot.github.refreshedAt, + }; + } + private async describeWorkspaceRecordWithGitData( workspace: PersistedWorkspaceRecord, projectRecord?: PersistedProjectRecord | null, @@ -5526,14 +5386,16 @@ export class Session { // Fall back to the persisted label if checkout metadata is unavailable. } - let diffStat: { additions: number; deletions: number } | null = null; - try { - diffStat = await getCheckoutShortstat(workspace.cwd); - } catch { - // Non-critical — leave null on failure. - } + let snapshot: WorkspaceGitRuntimeSnapshot | null = null; + snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd); - return { ...base, name: displayName, diffStat }; + return { + ...base, + name: displayName, + diffStat: snapshot?.git.diffStat ?? null, + gitRuntime: snapshot ? this.buildWorkspaceGitRuntimePayload(snapshot) : undefined, + githubRuntime: snapshot ? this.buildWorkspaceGitHubRuntimePayload(snapshot) : undefined, + }; } private async buildWorkspaceDescriptor(input: { @@ -5608,16 +5470,6 @@ export class Session { return descriptorsByWorkspaceId; } - private async listWorkspaceDescriptorsSnapshot(): Promise { - return Array.from( - ( - await this.buildWorkspaceDescriptorMap({ - includeGitData: false, - }) - ).values(), - ); - } - private resolveRegisteredWorkspaceIdForCwd( cwd: string, workspaces: PersistedWorkspaceRecord[], @@ -5645,7 +5497,13 @@ export class Session { } private async listWorkspaceDescriptors(): Promise { - return this.listWorkspaceDescriptorsSnapshot(); + return Array.from( + ( + await this.buildWorkspaceDescriptorMap({ + includeGitData: true, + }) + ).values(), + ); } private normalizeFetchWorkspacesSort( @@ -5958,13 +5816,13 @@ export class Session { private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise { const existing = await this.workspaceRegistry.get(workspaceId); if (!existing || existing.archivedAt) { - this.removeWorkspaceGitWatchTarget(workspaceId); + this.removeWorkspaceGitSubscription(workspaceId); return; } const nextArchivedAt = archivedAt ?? new Date().toISOString(); await this.workspaceRegistry.archive(workspaceId, nextArchivedAt); - this.removeWorkspaceGitWatchTarget(workspaceId); + this.removeWorkspaceGitSubscription(workspaceId); const siblingWorkspaces = (await this.workspaceRegistry.list()).filter( (workspace) => workspace.projectId === existing.projectId && !workspace.archivedAt, @@ -5993,7 +5851,7 @@ export class Session { private async emitWorkspaceUpdatesForWorkspaceIds( workspaceIds: Iterable, - options?: { dedupeGitState?: boolean; skipReconcile?: boolean }, + options?: { skipReconcile?: boolean }, ): Promise { const subscription = this.workspaceUpdatesSubscription; if (!subscription) { @@ -6018,13 +5876,6 @@ export class Session { workspace && this.matchesWorkspaceFilter({ workspace, filter: subscription.filter }) ? workspace : null; - if ( - options?.dedupeGitState && - this.shouldSkipWorkspaceGitWatchUpdate(workspaceId, nextWorkspace) - ) { - continue; - } - this.rememberWorkspaceGitWatchFingerprint(workspaceId, nextWorkspace); if (!nextWorkspace) { this.bufferOrEmitWorkspaceUpdate(subscription, { @@ -6045,30 +5896,9 @@ export class Session { } } - private scheduleWorkspaceGitBootstrapUpdates(options: { - subscriptionId: string; - workspaces: Iterable; - }): void { - const gitWorkspaceIds = Array.from(options.workspaces, (workspace) => workspace) - .filter((workspace) => workspace.projectKind === "git") - .map((workspace) => workspace.id); - if (gitWorkspaceIds.length === 0) { - return; - } - - queueMicrotask(() => { - if (this.workspaceUpdatesSubscription?.subscriptionId !== options.subscriptionId) { - return; - } - void this.emitWorkspaceUpdatesForWorkspaceIds(gitWorkspaceIds, { - skipReconcile: true, - }); - }); - } - private async emitWorkspaceUpdateForCwd( cwd: string, - options?: { dedupeGitState?: boolean }, + options?: { skipReconcile?: boolean }, ): Promise { const activeWorkspaces = (await this.workspaceRegistry.list()).filter( (workspace) => !workspace.archivedAt, @@ -6175,7 +6005,6 @@ export class Session { } const payload = await this.listFetchWorkspacesEntries(request); - this.primeWorkspaceGitWatchFingerprints(payload.entries); const snapshotLatestActivityByWorkspaceId = new Map(); for (const entry of payload.entries) { const parsedLatestActivity = entry.activityAt @@ -6198,10 +6027,6 @@ export class Session { if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) { this.flushBootstrappedWorkspaceUpdates({ snapshotLatestActivityByWorkspaceId }); void this.reconcileAndEmitWorkspaceUpdates(); - this.scheduleWorkspaceGitBootstrapUpdates({ - subscriptionId, - workspaces: payload.entries, - }); } } catch (error) { if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) { @@ -6227,7 +6052,9 @@ export class Session { ): Promise { try { const workspace = await this.ensureWorkspaceRegistered(request.cwd); - await this.emitWorkspaceUpdateForCwd(workspace.cwd); + await this.emitWorkspaceUpdateForCwd(workspace.cwd, { + skipReconcile: true, + }); const descriptor = await this.describeWorkspaceRecordWithGitData(workspace); this.emit({ type: "open_project_response", @@ -6349,8 +6176,7 @@ export class Session { return createWorktreeInBackgroundSession( { paseoHome: this.paseoHome, - emitWorkspaceUpdateForCwd: (cwd, emitOptions) => - this.emitWorkspaceUpdateForCwd(cwd, emitOptions), + emitWorkspaceUpdateForCwd: (cwd) => this.emitWorkspaceUpdateForCwd(cwd), sessionLogger: this.sessionLogger, terminalManager: this.terminalManager, }, @@ -7517,14 +7343,10 @@ export class Session { } this.checkoutDiffSubscriptions.clear(); - for (const unsubscribe of this.workspaceGitFetchSubscriptions.values()) { + for (const unsubscribe of this.workspaceGitSubscriptions.values()) { unsubscribe(); } - this.workspaceGitFetchSubscriptions.clear(); - for (const target of this.workspaceGitWatchTargets.values()) { - this.closeWorkspaceGitWatchTarget(target); - } - this.workspaceGitWatchTargets.clear(); + this.workspaceGitSubscriptions.clear(); } // ============================================================================ 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 4f5b3593b..541c35857 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -1,86 +1,84 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; - -const { watchCalls, watchMock } = vi.hoisted(() => { - const hoistedWatchCalls: Array<{ - path: string; - listener: () => void; - close: ReturnType; - }> = []; - - const hoistedWatchMock = vi.fn( - (watchPath: string, _options: { recursive: boolean }, listener: () => void) => { - const close = vi.fn(); - const watcher = { - close, - on: vi.fn().mockReturnThis(), - }; - hoistedWatchCalls.push({ - path: watchPath, - listener, - close, - }); - return watcher as any; - }, - ); - - return { - watchCalls: hoistedWatchCalls, - watchMock: hoistedWatchMock, - }; -}); - -const resolveCheckoutGitDirMock = vi.hoisted(() => vi.fn(async () => null)); - -vi.mock("node:fs", async () => { - const actual = await vi.importActual("node:fs"); - return { - ...actual, - watch: watchMock, - }; -}); - -vi.mock("./checkout-git-utils.js", () => ({ - READ_ONLY_GIT_ENV: { - ...process.env, - GIT_OPTIONAL_LOCKS: "0", - }, - resolveCheckoutGitDir: resolveCheckoutGitDirMock, - toCheckoutError: vi.fn((error: unknown) => ({ - message: error instanceof Error ? error.message : String(error), - })), -})); - +import { describe, expect, test, vi } from "vitest"; import { Session } from "./session.js"; +import type { + WorkspaceGitListener, + WorkspaceGitRuntimeSnapshot, + WorkspaceGitService, +} from "./workspace-git-service.js"; + +function createWorkspaceRuntimeSnapshot( + cwd: string, + overrides?: { + git?: Partial; + github?: Partial; + }, +): WorkspaceGitRuntimeSnapshot { + const base: WorkspaceGitRuntimeSnapshot = { + cwd, + git: { + isGit: true, + repoRoot: cwd, + mainRepoRoot: null, + currentBranch: "main", + remoteUrl: "https://github.com/acme/repo.git", + isPaseoOwnedWorktree: false, + isDirty: false, + aheadBehind: { ahead: 0, behind: 0 }, + aheadOfOrigin: 0, + behindOfOrigin: 0, + diffStat: { additions: 1, deletions: 0 }, + }, + github: { + featuresEnabled: true, + pullRequest: null, + error: null, + refreshedAt: "2026-04-12T00:00:00.000Z", + }, + }; + + return { + cwd, + git: { + ...base.git, + ...overrides?.git, + }, + github: { + ...base.github, + ...overrides?.github, + pullRequest: + overrides?.github && "pullRequest" in overrides.github + ? overrides.github.pullRequest ?? null + : base.github.pullRequest, + error: + overrides?.github && "error" in overrides.github + ? overrides.github.error ?? null + : base.github.error, + }, + }; +} function createSessionForWorkspaceGitWatchTests(): { session: Session; emitted: Array<{ type: string; payload: unknown }>; - backgroundGitFetchManager: { + workspaceGitService: WorkspaceGitService & { subscribe: ReturnType; - subscriptions: Array<{ - params: { repoGitRoot: string; cwd: string }; - listener: () => void; - unsubscribe: ReturnType; - }>; - }; - logger: { - child: () => unknown; - trace: ReturnType; - debug: ReturnType; - info: ReturnType; - warn: ReturnType; - error: ReturnType; + peekSnapshot: ReturnType; + getSnapshot: ReturnType; + refresh: ReturnType; + dispose: ReturnType; }; + subscriptions: Array<{ + params: { cwd: string }; + listener: WorkspaceGitListener; + unsubscribe: ReturnType; + }>; } { const emitted: Array<{ type: string; payload: unknown }> = []; const projects = new Map(); const workspaces = new Map(); - const backgroundGitFetchSubscriptions: Array<{ - params: { repoGitRoot: string; cwd: string }; - listener: () => void; + const subscriptions: Array<{ + params: { cwd: string }; + listener: WorkspaceGitListener; unsubscribe: ReturnType; }> = []; const logger = { @@ -91,16 +89,23 @@ function createSessionForWorkspaceGitWatchTests(): { warn: vi.fn(), error: vi.fn(), }; - const backgroundGitFetchManager = { - subscribe: vi.fn(async (params: { repoGitRoot: string; cwd: string }, listener: () => void) => { + const workspaceGitService = { + subscribe: vi.fn(async (params: { cwd: string }, listener: WorkspaceGitListener) => { const unsubscribe = vi.fn(); - backgroundGitFetchSubscriptions.push({ + subscriptions.push({ params, listener, unsubscribe, }); - return { unsubscribe }; + return { + initial: createWorkspaceRuntimeSnapshot(params.cwd), + unsubscribe, + }; }), + peekSnapshot: vi.fn((cwd: string) => createWorkspaceRuntimeSnapshot(cwd)), + getSnapshot: vi.fn(async (cwd: string) => createWorkspaceRuntimeSnapshot(cwd)), + refresh: vi.fn(async () => {}), + dispose: vi.fn(), }; const session = new Session({ @@ -179,7 +184,7 @@ function createSessionForWorkspaceGitWatchTests(): { }), dispose: () => {}, } as any, - backgroundGitFetchManager: backgroundGitFetchManager as any, + workspaceGitService: workspaceGitService as any, mcpBaseUrl: null, stt: null, tts: null, @@ -191,29 +196,15 @@ function createSessionForWorkspaceGitWatchTests(): { return { session, emitted, - backgroundGitFetchManager: { - subscribe: backgroundGitFetchManager.subscribe, - subscriptions: backgroundGitFetchSubscriptions, - }, - logger, + workspaceGitService: workspaceGitService as any, + subscriptions, }; } describe("workspace git watch targets", () => { - beforeEach(() => { - watchCalls.length = 0; - watchMock.mockClear(); - resolveCheckoutGitDirMock.mockReset(); - resolveCheckoutGitDirMock.mockResolvedValue(null); - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - test("debounces watcher events and skips unchanged branch/diff snapshots", async () => { - const { session, emitted } = createSessionForWorkspaceGitWatchTests(); + test("emits one workspace_update when the workspace git service emits a changed snapshot", async () => { + const { session, emitted, workspaceGitService, subscriptions } = + createSessionForWorkspaceGitWatchTests(); const sessionAny = session as any; sessionAny.buildProjectPlacement = async (cwd: string) => ({ @@ -229,7 +220,6 @@ describe("workspace git watch targets", () => { mainRepoRoot: null, }, }); - resolveCheckoutGitDirMock.mockResolvedValue("/tmp/repo/.git"); sessionAny.workspaceUpdatesSubscription = { subscriptionId: "sub-1", filter: undefined, @@ -254,25 +244,27 @@ describe("workspace git watch targets", () => { sessionAny.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]); await sessionAny.ensureWorkspaceRegistered("/tmp/repo"); - sessionAny.primeWorkspaceGitWatchFingerprints([descriptor]); - expect(watchCalls.map((entry) => entry.path).sort()).toEqual([ - "/tmp/repo/.git/HEAD", - "/tmp/repo/.git/refs/heads", - ]); - - watchCalls[0]!.listener(); - watchCalls[1]!.listener(); - await vi.advanceTimersByTimeAsync(500); - - expect(emitted.filter((message) => message.type === "workspace_update")).toHaveLength(0); + expect(workspaceGitService.subscribe).toHaveBeenCalledWith( + { cwd: "/tmp/repo" }, + expect.any(Function), + ); descriptor = { ...descriptor, name: "renamed-branch", }; - watchCalls[0]!.listener(); - await vi.advanceTimersByTimeAsync(500); + + subscriptions[0]?.listener( + createWorkspaceRuntimeSnapshot("/tmp/repo", { + git: { + currentBranch: "renamed-branch", + }, + }), + ); + + await Promise.resolve(); + await Promise.resolve(); const workspaceUpdates = emitted.filter( (message) => message.type === "workspace_update", @@ -287,220 +279,106 @@ describe("workspace git watch targets", () => { }, }); - descriptor = { - ...descriptor, - diffStat: { additions: 3, deletions: 1 }, - }; - watchCalls[1]!.listener(); - await vi.advanceTimersByTimeAsync(500); - - expect(emitted.filter((message) => message.type === "workspace_update")).toHaveLength(2); - await session.cleanup(); }); - test("closes watchers when a workspace is archived and when the session closes", async () => { - const { session } = createSessionForWorkspaceGitWatchTests(); - const sessionAny = session as any; + test("checkout_pr_status_request reads pull request status from the workspace git service snapshot", async () => { + const { session, emitted, workspaceGitService } = createSessionForWorkspaceGitWatchTests(); - sessionAny.buildProjectPlacement = async (cwd: string) => ({ - projectKey: cwd, - projectName: path.basename(cwd), - checkout: { - cwd, - isGit: true, - currentBranch: "main", - remoteUrl: "https://github.com/acme/repo.git", - worktreeRoot: cwd, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }, - }); - - resolveCheckoutGitDirMock.mockImplementation(async (cwd: string) => path.join(cwd, ".git")); - - await sessionAny.ensureWorkspaceRegistered("/tmp/repo-one"); - expect(sessionAny.workspaceGitWatchTargets.size).toBe(1); - expect(watchCalls).toHaveLength(2); - - await sessionAny.archiveWorkspaceRecord("/tmp/repo-one", "2026-03-21T00:00:00.000Z"); - - expect(sessionAny.workspaceGitWatchTargets.size).toBe(0); - expect(watchCalls.every((entry) => entry.close.mock.calls.length === 1)).toBe(true); - - watchCalls.length = 0; - watchMock.mockClear(); - - await sessionAny.ensureWorkspaceRegistered("/tmp/repo-two"); - expect(sessionAny.workspaceGitWatchTargets.size).toBe(1); - expect(watchCalls).toHaveLength(2); - - await session.cleanup(); - - expect(sessionAny.workspaceGitWatchTargets.size).toBe(0); - expect(watchCalls.every((entry) => entry.close.mock.calls.length === 1)).toBe(true); - }); - - test("resolves refs from the shared git dir for linked worktrees", async () => { - const { session } = createSessionForWorkspaceGitWatchTests(); - const sessionAny = session as any; - const tempDir = mkdtempSync(path.join(tmpdir(), "session-workspace-git-watch-")); - const gitDir = path.join(tempDir, "repo", ".git", "worktrees", "feature"); - - mkdirSync(gitDir, { recursive: true }); - writeFileSync(path.join(gitDir, "commondir"), "../..\n"); - - try { - expect(await sessionAny.resolveWorkspaceGitRefsRoot(gitDir)).toBe( - path.join(tempDir, "repo", ".git"), - ); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - await session.cleanup(); - } - }); - - test("subscribes to the background fetch manager when a git watch target is created", async () => { - const { session, backgroundGitFetchManager } = createSessionForWorkspaceGitWatchTests(); - const sessionAny = session as any; - - sessionAny.buildProjectPlacement = async (cwd: string) => ({ - projectKey: cwd, - projectName: path.basename(cwd), - checkout: { - cwd, - isGit: true, - currentBranch: "main", - remoteUrl: "https://github.com/acme/repo.git", - worktreeRoot: cwd, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }, - }); - resolveCheckoutGitDirMock.mockResolvedValue("/tmp/repo/.git"); - - await sessionAny.ensureWorkspaceRegistered("/tmp/repo"); - - expect(backgroundGitFetchManager.subscribe).toHaveBeenCalledWith( - { repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" }, - expect.any(Function), + workspaceGitService.getSnapshot.mockResolvedValue( + createWorkspaceRuntimeSnapshot("/tmp/repo", { + github: { + featuresEnabled: true, + pullRequest: { + url: "https://github.com/acme/repo/pull/456", + title: "Runtime centralization", + state: "merged", + baseRefName: "main", + headRefName: "workspace-git-service", + isMerged: true, + }, + refreshedAt: "2026-04-12T00:05:00.000Z", + }, + }), ); - expect(sessionAny.workspaceGitFetchSubscriptions.size).toBe(1); - await session.cleanup(); - }); - - test("stores separate background fetch subscriptions per workspace and unsubscribes removed targets", async () => { - const { session, backgroundGitFetchManager } = createSessionForWorkspaceGitWatchTests(); - const sessionAny = session as any; - - sessionAny.buildProjectPlacement = async (cwd: string) => ({ - projectKey: cwd, - projectName: path.basename(cwd), - checkout: { - cwd, - isGit: true, - currentBranch: "main", - remoteUrl: "https://github.com/acme/repo.git", - worktreeRoot: cwd, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }, - }); - resolveCheckoutGitDirMock.mockImplementation(async (cwd: string) => - cwd === "/tmp/repo" ? "/tmp/repo/.git" : "/tmp/repo/.git/worktrees/feature", - ); - sessionAny.resolveWorkspaceGitRefsRoot = vi.fn(async () => "/tmp/repo/.git"); - - await sessionAny.ensureWorkspaceRegistered("/tmp/repo"); - await sessionAny.ensureWorkspaceRegistered("/tmp/repo-feature"); - - expect(backgroundGitFetchManager.subscribe).toHaveBeenCalledTimes(2); - expect(backgroundGitFetchManager.subscriptions[0]?.params).toEqual({ - repoGitRoot: "/tmp/repo/.git", + await session.handleMessage({ + type: "checkout_pr_status_request", cwd: "/tmp/repo", - }); - expect(backgroundGitFetchManager.subscriptions[1]?.params).toEqual({ - repoGitRoot: "/tmp/repo/.git", - cwd: "/tmp/repo-feature", + requestId: "req-pr-status", }); - sessionAny.removeWorkspaceGitWatchTarget("/tmp/repo"); - - expect(backgroundGitFetchManager.subscriptions[0]?.unsubscribe).toHaveBeenCalledTimes(1); - expect(backgroundGitFetchManager.subscriptions[1]?.unsubscribe).not.toHaveBeenCalled(); - expect(sessionAny.workspaceGitFetchSubscriptions.size).toBe(1); - - await session.cleanup(); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo"); + expect(emitted.find((message) => message.type === "checkout_pr_status_response")?.payload).toEqual({ + cwd: "/tmp/repo", + status: { + url: "https://github.com/acme/repo/pull/456", + title: "Runtime centralization", + state: "merged", + baseRefName: "main", + headRefName: "workspace-git-service", + isMerged: true, + }, + githubFeaturesEnabled: true, + error: null, + requestId: "req-pr-status", + }); }); - test("refreshes the workspace when the background fetch manager callback fires and unsubscribes on cleanup", async () => { - const { session, emitted, backgroundGitFetchManager } = - createSessionForWorkspaceGitWatchTests(); - const sessionAny = session as any; + test("checkout_pr_status_request explicitly refreshes the focused workspace before reading runtime data", async () => { + const { session, emitted, workspaceGitService } = createSessionForWorkspaceGitWatchTests(); + let refreshed = false; - sessionAny.buildProjectPlacement = async (cwd: string) => ({ - projectKey: cwd, - projectName: path.basename(cwd), - checkout: { - cwd, - isGit: true, - currentBranch: "main", - remoteUrl: "https://github.com/acme/repo.git", - worktreeRoot: cwd, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }, + workspaceGitService.refresh.mockImplementation(async () => { + refreshed = true; }); - resolveCheckoutGitDirMock.mockResolvedValue("/tmp/repo/.git"); - sessionAny.workspaceUpdatesSubscription = { - subscriptionId: "sub-1", - filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - }; - sessionAny.reconcileActiveWorkspaceRecords = async () => new Set(); + 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", + }, + }), + ); - let descriptor = { - id: "/tmp/repo", - projectId: "/tmp/repo", - projectDisplayName: "repo", - projectRootPath: "/tmp/repo", - projectKind: "git", - workspaceKind: "local_checkout", - name: "main", - status: "done", - activityAt: null, - diffStat: { additions: 1, deletions: 0 }, - }; - - sessionAny.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]); - - await sessionAny.ensureWorkspaceRegistered("/tmp/repo"); - sessionAny.primeWorkspaceGitWatchFingerprints([descriptor]); - - descriptor = { - ...descriptor, - name: "updated-after-fetch", - }; - - backgroundGitFetchManager.subscriptions[0]?.listener(); - await vi.advanceTimersByTimeAsync(500); - - const workspaceUpdates = emitted.filter( - (message) => message.type === "workspace_update", - ) as any[]; - expect(workspaceUpdates).toHaveLength(1); - expect(workspaceUpdates[0]?.payload).toMatchObject({ - kind: "upsert", - workspace: { - id: "/tmp/repo", - name: "updated-after-fetch", - }, + await session.handleMessage({ + type: "checkout_pr_status_request", + cwd: "/tmp/repo", + requestId: "req-pr-refresh", }); - await session.cleanup(); - - expect(backgroundGitFetchManager.subscriptions[0]?.unsubscribe).toHaveBeenCalledTimes(1); + 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", + }); }); }); diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 21d14f762..7398497df 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { describe, expect, test, vi } from "vitest"; import { Session } from "./session.js"; import type { AgentSnapshotPayload } from "../shared/messages.js"; +import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js"; import { createPersistedProjectRecord, createPersistedWorkspaceRecord, @@ -61,7 +62,123 @@ function makeAgent(input: { }; } -function createSessionForWorkspaceTests(options: { appVersion?: string | null } = {}): Session { +function createNoopWorkspaceGitService() { + return { + subscribe: async (params: { cwd: string }) => ({ + 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: (_cwd: string) => 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 () => {}, + dispose: () => {}, + }; +} + +function createWorkspaceRuntimeSnapshot( + cwd: string, + overrides?: { + git?: Partial; + github?: Partial; + }, +): WorkspaceGitRuntimeSnapshot { + const base: WorkspaceGitRuntimeSnapshot = { + cwd, + git: { + isGit: true, + repoRoot: cwd, + mainRepoRoot: null, + currentBranch: "main", + remoteUrl: "https://github.com/acme/repo.git", + isPaseoOwnedWorktree: false, + isDirty: false, + aheadBehind: { ahead: 0, behind: 0 }, + aheadOfOrigin: 0, + behindOfOrigin: 0, + diffStat: { additions: 1, deletions: 0 }, + }, + github: { + featuresEnabled: true, + pullRequest: { + url: "https://github.com/acme/repo/pull/123", + title: "Runtime payloads", + state: "open", + baseRefName: "main", + headRefName: "feature/runtime-payloads", + isMerged: false, + }, + error: null, + refreshedAt: "2026-04-12T00:00:00.000Z", + }, + }; + + return { + cwd, + git: { + ...base.git, + ...overrides?.git, + }, + github: { + ...base.github, + ...overrides?.github, + pullRequest: + overrides?.github && "pullRequest" in overrides.github + ? overrides.github.pullRequest ?? null + : base.github.pullRequest, + error: + overrides?.github && "error" in overrides.github + ? overrides.github.error ?? null + : base.github.error, + }, + }; +} + +function createSessionForWorkspaceTests(options: { + appVersion?: string | null; + workspaceGitService?: ReturnType; +} = {}): Session { const logger = { child: () => logger, trace: vi.fn(), @@ -121,8 +238,9 @@ function createSessionForWorkspaceTests(options: { appVersion?: string | null } checkoutDiffWatcherCount: 0, checkoutDiffFallbackRefreshTargetCount: 0, }), - dispose: () => {}, - } as any, + dispose: () => {}, + } as any, + workspaceGitService: (options.workspaceGitService ?? createNoopWorkspaceGitService()) as any, mcpBaseUrl: null, stt: null, tts: null, @@ -228,6 +346,7 @@ describe("workspace aggregation", () => { }), dispose: () => {}, } as any, + workspaceGitService: createNoopWorkspaceGitService() as any, mcpBaseUrl: null, stt: null, tts: null, @@ -371,6 +490,7 @@ describe("workspace aggregation", () => { }), dispose: () => {}, } as any, + workspaceGitService: createNoopWorkspaceGitService() as any, mcpBaseUrl: null, stt: null, tts: null, @@ -532,6 +652,7 @@ describe("workspace aggregation", () => { }), dispose: () => {}, } as any, + workspaceGitService: createNoopWorkspaceGitService() as any, mcpBaseUrl: null, stt: null, tts: null, @@ -666,6 +787,7 @@ describe("workspace aggregation", () => { }), dispose: () => {}, } as any, + workspaceGitService: createNoopWorkspaceGitService() as any, mcpBaseUrl: null, stt: null, tts: null, @@ -933,6 +1055,7 @@ describe("workspace aggregation", () => { }), dispose: () => {}, } as any, + workspaceGitService: createNoopWorkspaceGitService() as any, mcpBaseUrl: null, stt: null, tts: null, @@ -1685,14 +1808,231 @@ describe("workspace aggregation", () => { session.describeWorkspaceRecord = vi.fn(async () => baselineDescriptor); session.describeWorkspaceRecordWithGitData = vi.fn(async () => gitDescriptor); - const descriptors = await session.listWorkspaceDescriptorsSnapshot(); + const descriptors = Array.from( + ( + await session.buildWorkspaceDescriptorMap({ + includeGitData: false, + }) + ).values(), + ); expect(session.describeWorkspaceRecord).toHaveBeenCalledWith(workspace, project); expect(session.describeWorkspaceRecordWithGitData).not.toHaveBeenCalled(); expect(descriptors).toEqual([baselineDescriptor]); }); - test("subscribed fetch_workspaces emits git enrichment updates after the baseline snapshot", async () => { + test("fetch_workspaces_response reads runtime fields from passive workspace git service snapshots", async () => { + const emitted: Array<{ type: string; payload: any }> = []; + const runtimeSnapshot = createWorkspaceRuntimeSnapshot("/tmp/repo", { + git: { + currentBranch: "runtime-branch", + isDirty: true, + aheadBehind: { ahead: 3, behind: 1 }, + aheadOfOrigin: 3, + behindOfOrigin: 1, + }, + github: { + pullRequest: { + url: "https://github.com/acme/repo/pull/456", + title: "Ship runtime payloads", + state: "open", + baseRefName: "main", + headRefName: "runtime-branch", + isMerged: false, + }, + refreshedAt: "2026-04-12T00:05:00.000Z", + }, + }); + const workspaceGitService = createNoopWorkspaceGitService(); + workspaceGitService.peekSnapshot = vi.fn(() => runtimeSnapshot); + workspaceGitService.getSnapshot = vi.fn(async () => { + throw new Error("fetch_workspaces should not trigger per-workspace refreshes"); + }); + workspaceGitService.subscribe = vi.fn(async () => ({ + initial: runtimeSnapshot, + unsubscribe: () => {}, + })); + + const session = createSessionForWorkspaceTests({ + workspaceGitService, + }) as any; + const project = createPersistedProjectRecord({ + projectId: "/tmp/repo", + rootPath: "/tmp/repo", + kind: "git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "/tmp/repo", + projectId: project.projectId, + cwd: "/tmp/repo", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + + session.emit = (message: any) => emitted.push(message); + session.listAgentPayloads = async () => []; + session.projectRegistry.list = async () => [project]; + session.workspaceRegistry.list = async () => [workspace]; + session.buildProjectPlacement = async (cwd: string) => ({ + projectKey: cwd, + projectName: "repo", + checkout: { + cwd, + isGit: true, + currentBranch: runtimeSnapshot.git.currentBranch, + remoteUrl: runtimeSnapshot.git.remoteUrl, + worktreeRoot: cwd, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }); + + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "req-fetch-workspaces-runtime", + }); + + const response = emitted.find((message) => message.type === "fetch_workspaces_response") as + | { type: "fetch_workspaces_response"; payload: any } + | undefined; + + expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/repo"); + expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); + expect(response?.payload.entries).toEqual([ + expect.objectContaining({ + id: "/tmp/repo", + gitRuntime: { + currentBranch: "runtime-branch", + remoteUrl: "https://github.com/acme/repo.git", + isPaseoOwnedWorktree: false, + isDirty: true, + aheadBehind: { ahead: 3, behind: 1 }, + aheadOfOrigin: 3, + behindOfOrigin: 1, + }, + githubRuntime: { + featuresEnabled: true, + pullRequest: { + url: "https://github.com/acme/repo/pull/456", + title: "Ship runtime payloads", + state: "open", + baseRefName: "main", + headRefName: "runtime-branch", + isMerged: false, + }, + error: null, + refreshedAt: "2026-04-12T00:05:00.000Z", + }, + }), + ]); + }); + + test("workspace_update includes updated runtime fields", async () => { + const emitted: Array<{ type: string; payload: any }> = []; + const runtimeSnapshot = createWorkspaceRuntimeSnapshot("/tmp/repo", { + git: { + currentBranch: "feature/runtime-payloads", + isDirty: true, + }, + github: { + pullRequest: { + url: "https://github.com/acme/repo/pull/789", + title: "Updated runtime payloads", + state: "merged", + baseRefName: "main", + headRefName: "feature/runtime-payloads", + isMerged: true, + }, + refreshedAt: "2026-04-12T00:10:00.000Z", + }, + }); + const workspaceGitService = createNoopWorkspaceGitService(); + workspaceGitService.peekSnapshot = vi.fn(() => runtimeSnapshot); + workspaceGitService.getSnapshot = vi.fn(async () => { + throw new Error("workspace updates should use passive workspace git snapshots"); + }); + + const session = createSessionForWorkspaceTests({ + workspaceGitService, + }) as any; + const project = createPersistedProjectRecord({ + projectId: "/tmp/repo", + rootPath: "/tmp/repo", + kind: "git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "/tmp/repo", + projectId: project.projectId, + cwd: "/tmp/repo", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + + session.emit = (message: any) => emitted.push(message); + session.workspaceUpdatesSubscription = { + subscriptionId: "sub-runtime", + filter: undefined, + isBootstrapping: false, + pendingUpdatesByWorkspaceId: new Map(), + }; + session.reconcileActiveWorkspaceRecords = async () => new Set(); + session.listAgentPayloads = async () => []; + session.projectRegistry.list = async () => [project]; + session.workspaceRegistry.list = async () => [workspace]; + session.buildProjectPlacement = async (cwd: string) => ({ + projectKey: cwd, + projectName: "repo", + checkout: { + cwd, + isGit: true, + currentBranch: runtimeSnapshot.git.currentBranch, + remoteUrl: runtimeSnapshot.git.remoteUrl, + worktreeRoot: cwd, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }); + + await session.emitWorkspaceUpdateForCwd("/tmp/repo", { + skipReconcile: true, + }); + + expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/repo"); + expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); + expect(emitted).toContainEqual({ + type: "workspace_update", + payload: { + kind: "upsert", + workspace: expect.objectContaining({ + id: "/tmp/repo", + gitRuntime: expect.objectContaining({ + currentBranch: "feature/runtime-payloads", + isDirty: true, + }), + githubRuntime: expect.objectContaining({ + featuresEnabled: true, + pullRequest: expect.objectContaining({ + title: "Updated runtime payloads", + isMerged: true, + }), + refreshedAt: "2026-04-12T00:10:00.000Z", + }), + }), + }, + }); + }); + + test("subscribed fetch_workspaces includes git enrichment in the initial snapshot", async () => { const emitted: Array<{ type: string; payload: any }> = []; const session = createSessionForWorkspaceTests() as any; const gitProject = createPersistedProjectRecord({ @@ -1794,21 +2134,13 @@ describe("workspace aggregation", () => { ), ).toEqual([ [directoryDescriptor.id, directoryDescriptor.diffStat], - [baselineGitDescriptor.id, baselineGitDescriptor.diffStat], + [enrichedGitDescriptor.id, enrichedGitDescriptor.diffStat], ]); const workspaceUpdates = emitted.filter( (message) => message.type === "workspace_update", ) as Array<{ type: "workspace_update"; payload: any }>; - expect(workspaceUpdates).toEqual([ - { - type: "workspace_update", - payload: { - kind: "upsert", - workspace: enrichedGitDescriptor, - }, - }, - ]); + expect(workspaceUpdates).toEqual([]); expect(session.describeWorkspaceRecordWithGitData).toHaveBeenCalledWith( gitWorkspace, gitProject, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 913e97b34..259c9966d 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -12,7 +12,6 @@ import type { FileBackedChatService } from "./chat/chat-service.js"; import type { LoopService } from "./loop-service.js"; import type { ScheduleService } from "./schedule/service.js"; import type { CheckoutDiffManager, CheckoutDiffMetrics } from "./checkout-diff-manager.js"; -import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js"; import type { DaemonConfigStore, MutableDaemonConfig } from "./daemon-config-store.js"; import { type ServerInfoStatusPayload, @@ -31,6 +30,7 @@ import type { AgentProvider } from "./agent/agent-sdk-types.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import { buildProviderRegistry } from "./agent/provider-registry.js"; +import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; import { PushTokenStore } from "./push/token-store.js"; import { PushService } from "./push/push-service.js"; import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js"; @@ -234,7 +234,7 @@ export class VoiceAssistantWebSocketServer { private readonly loopService: LoopService; private readonly scheduleService: ScheduleService; private readonly checkoutDiffManager: CheckoutDiffManager; - private readonly backgroundGitFetchManager: BackgroundGitFetchManager; + private readonly workspaceGitService: WorkspaceGitServiceImpl; private readonly downloadTokenStore: DownloadTokenStore; private readonly paseoHome: string; private readonly daemonConfigStore: DaemonConfigStore; @@ -329,8 +329,9 @@ export class VoiceAssistantWebSocketServer { throw new Error("VoiceAssistantWebSocketServer requires a checkout diff manager."); } this.checkoutDiffManager = checkoutDiffManager; - this.backgroundGitFetchManager = new BackgroundGitFetchManager({ + this.workspaceGitService = new WorkspaceGitServiceImpl({ logger: this.logger, + paseoHome, }); this.downloadTokenStore = downloadTokenStore; this.paseoHome = paseoHome; @@ -507,7 +508,7 @@ export class VoiceAssistantWebSocketServer { await Promise.all(cleanupPromises); this.providerSnapshotManager.destroy(); - this.backgroundGitFetchManager.dispose(); + this.workspaceGitService.dispose(); this.checkoutDiffManager.dispose(); this.pendingConnections.clear(); this.sessions.clear(); @@ -638,7 +639,7 @@ export class VoiceAssistantWebSocketServer { loopService: this.loopService, scheduleService: this.scheduleService, checkoutDiffManager: this.checkoutDiffManager, - backgroundGitFetchManager: this.backgroundGitFetchManager, + workspaceGitService: this.workspaceGitService, daemonConfigStore: this.daemonConfigStore, mcpBaseUrl: this.mcpBaseUrl, stt: () => this.speech?.resolveStt() ?? null, diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts new file mode 100644 index 000000000..4bbbf2ef6 --- /dev/null +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -0,0 +1,444 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { + CheckoutStatusGit, + PullRequestStatusResult, +} from "../utils/checkout-git.js"; +import { + WorkspaceGitServiceImpl, + type WorkspaceGitRuntimeSnapshot, +} from "./workspace-git-service.js"; + +function createLogger() { + const logger = { + child: () => logger, + debug: vi.fn(), + warn: vi.fn(), + }; + return logger; +} + +function createSnapshot( + cwd: string, + overrides?: { + git?: Partial; + github?: Partial; + }, +): WorkspaceGitRuntimeSnapshot { + const base: WorkspaceGitRuntimeSnapshot = { + cwd, + git: { + isGit: true, + repoRoot: cwd, + mainRepoRoot: null, + currentBranch: "main", + remoteUrl: "https://github.com/acme/repo.git", + isPaseoOwnedWorktree: false, + isDirty: false, + aheadBehind: { ahead: 0, behind: 0 }, + aheadOfOrigin: 0, + behindOfOrigin: 0, + diffStat: { additions: 1, deletions: 0 }, + }, + github: { + featuresEnabled: true, + pullRequest: { + url: "https://github.com/acme/repo/pull/123", + title: "Update feature", + state: "open", + baseRefName: "main", + headRefName: "feature", + isMerged: false, + }, + error: null, + refreshedAt: "2026-04-12T00:00:00.000Z", + }, + }; + + return { + cwd, + git: { + ...base.git, + ...overrides?.git, + }, + github: { + ...base.github, + ...overrides?.github, + pullRequest: + overrides?.github && "pullRequest" in overrides.github + ? overrides.github.pullRequest ?? null + : base.github.pullRequest, + error: + overrides?.github && "error" in overrides.github + ? overrides.github.error ?? null + : base.github.error, + }, + }; +} + +function createCheckoutStatus( + cwd: string, + overrides?: Partial, +): CheckoutStatusGit { + return { + isGit: true, + repoRoot: cwd, + currentBranch: "main", + isDirty: false, + baseRef: "main", + aheadBehind: { ahead: 0, behind: 0 }, + aheadOfOrigin: 0, + behindOfOrigin: 0, + hasRemote: true, + remoteUrl: "https://github.com/acme/repo.git", + isPaseoOwnedWorktree: false, + ...overrides, + }; +} + +function createPullRequestStatusResult( + overrides?: Partial, +): PullRequestStatusResult { + return { + status: { + url: "https://github.com/acme/repo/pull/123", + title: "Update feature", + state: "open", + baseRefName: "main", + headRefName: "feature", + isMerged: false, + }, + githubFeaturesEnabled: true, + ...overrides, + }; +} + +function createWatcher() { + return { + close: vi.fn(), + on: vi.fn().mockReturnThis(), + }; +} + +async function flushPromises(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +function createService(options?: { + getCheckoutStatus?: ReturnType; + getCheckoutShortstat?: ReturnType; + getPullRequestStatus?: ReturnType; + resolveGhPath?: ReturnType; + resolveAbsoluteGitDir?: ReturnType; + hasOriginRemote?: ReturnType; + runGitFetch?: ReturnType; + watch?: ReturnType; + now?: () => Date; +}) { + return new WorkspaceGitServiceImpl({ + logger: createLogger() as any, + paseoHome: "/tmp/paseo-test", + deps: { + watch: options?.watch ?? (((() => createWatcher()) as unknown) as any), + getCheckoutStatus: + options?.getCheckoutStatus ?? vi.fn(async (cwd: string) => createCheckoutStatus(cwd)), + getCheckoutShortstat: + options?.getCheckoutShortstat ?? + vi.fn(async () => ({ + additions: 1, + deletions: 0, + })), + getPullRequestStatus: + options?.getPullRequestStatus ?? + vi.fn(async () => createPullRequestStatusResult()), + resolveGhPath: options?.resolveGhPath ?? vi.fn(async () => "/usr/bin/gh"), + resolveAbsoluteGitDir: options?.resolveAbsoluteGitDir ?? vi.fn(async () => "/tmp/repo/.git"), + hasOriginRemote: options?.hasOriginRemote ?? vi.fn(async () => false), + runGitFetch: options?.runGitFetch ?? vi.fn(async () => {}), + now: options?.now ?? (() => new Date("2026-04-12T00:00:00.000Z")), + }, + }); +} + +describe("WorkspaceGitServiceImpl", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("subscribe returns an initial workspace runtime snapshot", async () => { + const service = createService(); + + const listener = vi.fn(); + const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener); + + expect(subscription.initial).toEqual(createSnapshot("/tmp/repo")); + expect(listener).not.toHaveBeenCalled(); + + subscription.unsubscribe(); + service.dispose(); + }); + + test("getSnapshot populates github pull request state in the runtime snapshot", async () => { + const getPullRequestStatus = vi.fn(async () => + createPullRequestStatusResult({ + status: { + url: "https://github.com/acme/repo/pull/999", + title: "Ship runtime centralization", + state: "open", + baseRefName: "main", + headRefName: "workspace-git-service", + isMerged: false, + }, + }), + ); + + const service = createService({ + getPullRequestStatus, + now: () => new Date("2026-04-12T02:03:04.000Z"), + }); + + await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual( + createSnapshot("/tmp/repo", { + github: { + pullRequest: { + url: "https://github.com/acme/repo/pull/999", + title: "Ship runtime centralization", + state: "open", + baseRefName: "main", + headRefName: "workspace-git-service", + isMerged: false, + }, + refreshedAt: "2026-04-12T02:03:04.000Z", + }, + }), + ); + 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"); + + const service = createService({ + getPullRequestStatus, + resolveAbsoluteGitDir, + }); + + const [first, second] = await Promise.all([ + service.subscribe({ cwd: "/tmp/repo" }, vi.fn()), + service.subscribe({ cwd: "/tmp/repo" }, vi.fn()), + ]); + + expect(getPullRequestStatus).toHaveBeenCalledTimes(1); + expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1); + expect((service as any).workspaceTargets.size).toBe(1); + + first.unsubscribe(); + second.unsubscribe(); + service.dispose(); + }); + + test("equivalent cwd strings share one workspace target across service entry points", async () => { + const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); + const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git"); + + const service = createService({ + getPullRequestStatus, + resolveAbsoluteGitDir, + }); + + const subscription = await service.subscribe({ cwd: "/tmp/repo/." }, vi.fn()); + + expect(subscription.initial).toEqual(createSnapshot("/tmp/repo")); + expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo")); + + await service.refresh("/tmp/repo"); + await expect(service.getSnapshot("/tmp/repo/.")).resolves.toEqual(createSnapshot("/tmp/repo")); + + expect(getPullRequestStatus).toHaveBeenCalledTimes(2); + expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1); + expect((service as any).workspaceTargets.size).toBe(1); + + subscription.unsubscribe(); + service.dispose(); + }); + + test("repo-level fetch intervals are shared for workspaces in the same repo", async () => { + const runGitFetch = vi.fn(async () => {}); + const hasOriginRemote = vi.fn(async () => true); + + const service = createService({ + resolveAbsoluteGitDir: vi.fn(async () => "/tmp/repo/.git"), + hasOriginRemote, + runGitFetch, + }); + + const first = await service.subscribe({ cwd: "/tmp/repo" }, vi.fn()); + const second = await service.subscribe({ cwd: "/tmp/repo/packages/server" }, vi.fn()); + await flushPromises(); + + expect(hasOriginRemote).toHaveBeenCalledTimes(1); + expect(runGitFetch).toHaveBeenCalledTimes(1); + expect((service as any).repoTargets.size).toBe(1); + + await vi.advanceTimersByTimeAsync(180_000); + await flushPromises(); + + expect(runGitFetch).toHaveBeenCalledTimes(2); + + first.unsubscribe(); + second.unsubscribe(); + service.dispose(); + }); + + test("explicit refresh recomputes github state and notifies listeners", async () => { + const getPullRequestStatus = vi + .fn<() => Promise>() + .mockResolvedValueOnce( + createPullRequestStatusResult({ + status: { + url: "https://github.com/acme/repo/pull/123", + title: "Before refresh", + state: "open", + baseRefName: "main", + headRefName: "feature", + isMerged: false, + }, + }), + ) + .mockResolvedValueOnce( + createPullRequestStatusResult({ + status: { + url: "https://github.com/acme/repo/pull/123", + title: "After refresh", + state: "merged", + baseRefName: "main", + headRefName: "feature", + isMerged: true, + }, + }), + ); + + const nowValues = [ + new Date("2026-04-12T00:00:00.000Z"), + new Date("2026-04-12T00:05:00.000Z"), + ]; + const service = createService({ + getPullRequestStatus, + now: () => nowValues.shift() ?? new Date("2026-04-12T00:05:00.000Z"), + }); + + const listener = vi.fn(); + const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener); + + expect(subscription.initial.github.pullRequest?.title).toBe("Before refresh"); + + service.refresh("/tmp/repo"); + await (service as any).workspaceTargets.get("/tmp/repo")?.refreshPromise; + await flushPromises(); + + expect(getPullRequestStatus).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith( + createSnapshot("/tmp/repo", { + github: { + pullRequest: { + url: "https://github.com/acme/repo/pull/123", + title: "After refresh", + state: "merged", + baseRefName: "main", + headRefName: "feature", + isMerged: true, + }, + refreshedAt: "2026-04-12T00:05:00.000Z", + }, + }), + ); + + subscription.unsubscribe(); + service.dispose(); + }); + + test("unchanged runtime snapshots do not emit duplicate updates", async () => { + const getCheckoutStatus = vi + .fn<() => Promise>() + .mockResolvedValueOnce(createCheckoutStatus("/tmp/repo")) + .mockResolvedValueOnce( + createCheckoutStatus("/tmp/repo", { + currentBranch: "feature/runtime-payloads", + aheadBehind: { ahead: 2, behind: 0 }, + aheadOfOrigin: 2, + }), + ) + .mockResolvedValueOnce( + createCheckoutStatus("/tmp/repo", { + currentBranch: "feature/runtime-payloads", + aheadBehind: { ahead: 2, behind: 0 }, + aheadOfOrigin: 2, + }), + ); + const getPullRequestStatus = vi + .fn<() => Promise>() + .mockResolvedValue( + createPullRequestStatusResult({ + status: { + url: "https://github.com/acme/repo/pull/123", + title: "Runtime payloads", + state: "open", + baseRefName: "main", + headRefName: "feature/runtime-payloads", + isMerged: false, + }, + }), + ); + + const service = createService({ + getCheckoutStatus, + getPullRequestStatus, + now: () => new Date("2026-04-12T00:00:00.000Z"), + }); + + const listener = vi.fn(); + const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener); + + expect(subscription.initial.git.currentBranch).toBe("main"); + + service.refresh("/tmp/repo"); + await (service as any).workspaceTargets.get("/tmp/repo")?.refreshPromise; + await flushPromises(); + + service.refresh("/tmp/repo"); + await (service as any).workspaceTargets.get("/tmp/repo")?.refreshPromise; + await flushPromises(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith( + createSnapshot("/tmp/repo", { + git: { + currentBranch: "feature/runtime-payloads", + aheadBehind: { ahead: 2, behind: 0 }, + aheadOfOrigin: 2, + }, + github: { + pullRequest: { + url: "https://github.com/acme/repo/pull/123", + title: "Runtime payloads", + state: "open", + baseRefName: "main", + headRefName: "feature/runtime-payloads", + isMerged: false, + }, + }, + }), + ); + + subscription.unsubscribe(); + service.dispose(); + }); +}); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts new file mode 100644 index 000000000..a134072ba --- /dev/null +++ b/packages/server/src/server/workspace-git-service.ts @@ -0,0 +1,588 @@ +import { execFile } from "node:child_process"; +import { watch, type FSWatcher } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import type pino from "pino"; +import type { CheckoutContext } from "../utils/checkout-git.js"; +import { + getCheckoutShortstat, + getCheckoutStatus, + getPullRequestStatus, + hasOriginRemote, + resolveGhPath, + resolveAbsoluteGitDir, +} from "../utils/checkout-git.js"; +import { normalizeWorkspaceId } from "./workspace-registry-model.js"; + +const execFileAsync = promisify(execFile); + +const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500; +const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000; + +export type WorkspaceGitRuntimeSnapshot = { + cwd: string; + git: { + isGit: boolean; + repoRoot: string | null; + mainRepoRoot: string | null; + currentBranch: string | null; + remoteUrl: string | null; + isPaseoOwnedWorktree: boolean; + isDirty: boolean | null; + aheadBehind: { ahead: number; behind: number } | null; + aheadOfOrigin: number | null; + behindOfOrigin: number | null; + diffStat: { additions: number; deletions: number } | null; + }; + github: { + featuresEnabled: boolean; + pullRequest: { + url: string; + title: string; + state: string; + baseRefName: string; + headRefName: string; + isMerged: boolean; + } | null; + error: { message: string } | null; + refreshedAt: string | null; + }; +}; + +export interface WorkspaceGitService { + subscribe( + params: { cwd: string }, + listener: WorkspaceGitListener, + ): Promise<{ + initial: WorkspaceGitRuntimeSnapshot; + unsubscribe: () => void; + }>; + + peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null; + getSnapshot(cwd: string): Promise; + refresh(cwd: string, options?: { priority?: "normal" | "high" }): Promise; + dispose(): void; +} + +export type WorkspaceGitListener = (snapshot: WorkspaceGitRuntimeSnapshot) => void; + +interface WorkspaceGitServiceDependencies { + watch: typeof watch; + getCheckoutStatus: typeof getCheckoutStatus; + getCheckoutShortstat: typeof getCheckoutShortstat; + getPullRequestStatus: typeof getPullRequestStatus; + resolveGhPath: typeof resolveGhPath; + resolveAbsoluteGitDir: (cwd: string) => Promise; + hasOriginRemote: (cwd: string) => Promise; + runGitFetch: (cwd: string) => Promise; + now: () => Date; +} + +interface WorkspaceGitServiceOptions { + logger: pino.Logger; + paseoHome: string; + deps?: Partial; +} + +interface WorkspaceGitTarget { + cwd: string; + listeners: Set; + watchers: FSWatcher[]; + debounceTimer: NodeJS.Timeout | null; + refreshPromise: Promise | null; + refreshQueued: boolean; + latestSnapshot: WorkspaceGitRuntimeSnapshot | null; + latestFingerprint: string | null; + repoGitRoot: string | null; +} + +interface RepoGitTarget { + repoGitRoot: string; + cwd: string; + workspaceKeys: Set; + intervalId: NodeJS.Timeout | null; + fetchInFlight: boolean; +} + +export class WorkspaceGitServiceImpl implements WorkspaceGitService { + private readonly logger: pino.Logger; + private readonly paseoHome: string; + private readonly deps: WorkspaceGitServiceDependencies; + private readonly workspaceTargets = new Map(); + private readonly repoTargets = new Map(); + private readonly workspaceTargetSetups = new Map>(); + + constructor(options: WorkspaceGitServiceOptions) { + this.logger = options.logger.child({ module: "workspace-git-service" }); + this.paseoHome = options.paseoHome; + this.deps = { + watch, + getCheckoutStatus: options.deps?.getCheckoutStatus ?? getCheckoutStatus, + getCheckoutShortstat: options.deps?.getCheckoutShortstat ?? getCheckoutShortstat, + getPullRequestStatus: options.deps?.getPullRequestStatus ?? getPullRequestStatus, + resolveGhPath: options.deps?.resolveGhPath ?? resolveGhPath, + resolveAbsoluteGitDir: options.deps?.resolveAbsoluteGitDir ?? resolveAbsoluteGitDir, + hasOriginRemote: options.deps?.hasOriginRemote ?? hasOriginRemote, + runGitFetch: options.deps?.runGitFetch ?? runGitFetch, + now: options.deps?.now ?? (() => new Date()), + }; + } + + async subscribe( + params: { cwd: string }, + listener: WorkspaceGitListener, + ): Promise<{ + initial: WorkspaceGitRuntimeSnapshot; + unsubscribe: () => void; + }> { + const cwd = normalizeWorkspaceId(params.cwd); + const target = await this.ensureWorkspaceTarget(cwd); + target.listeners.add(listener); + + return { + initial: target.latestSnapshot ?? (await this.getSnapshot(cwd)), + unsubscribe: () => { + this.removeWorkspaceListener(cwd, listener); + }, + }; + } + + async getSnapshot(cwd: string): Promise { + cwd = normalizeWorkspaceId(cwd); + const target = this.workspaceTargets.get(cwd); + if (target?.latestSnapshot) { + return target.latestSnapshot; + } + return this.refreshSnapshot(cwd); + } + + peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null { + cwd = normalizeWorkspaceId(cwd); + return this.workspaceTargets.get(cwd)?.latestSnapshot ?? null; + } + + async refresh(cwd: string, _options?: { priority?: "normal" | "high" }): Promise { + cwd = normalizeWorkspaceId(cwd); + const target = this.workspaceTargets.get(cwd); + if (target) { + await this.refreshWorkspaceTarget(target); + return; + } + + await this.ensureWorkspaceTarget(cwd); + } + + dispose(): void { + for (const target of this.workspaceTargets.values()) { + this.closeWorkspaceTarget(target); + } + this.workspaceTargets.clear(); + + for (const target of this.repoTargets.values()) { + this.closeRepoTarget(target); + } + this.repoTargets.clear(); + this.workspaceTargetSetups.clear(); + } + + private async ensureWorkspaceTarget(cwd: string): Promise { + const existingTarget = this.workspaceTargets.get(cwd); + if (existingTarget) { + return existingTarget; + } + + const existingSetup = this.workspaceTargetSetups.get(cwd); + if (existingSetup) { + return existingSetup; + } + + const setup = this.createWorkspaceTarget(cwd).finally(() => { + this.workspaceTargetSetups.delete(cwd); + }); + this.workspaceTargetSetups.set(cwd, setup); + return setup; + } + + private async createWorkspaceTarget(cwd: string): Promise { + const target: WorkspaceGitTarget = { + cwd, + listeners: new Set(), + watchers: [], + debounceTimer: null, + refreshPromise: null, + refreshQueued: false, + latestSnapshot: null, + latestFingerprint: null, + repoGitRoot: null, + }; + + const initial = await this.refreshSnapshot(cwd); + this.rememberSnapshot(target, initial); + this.workspaceTargets.set(cwd, target); + + const gitDir = await this.deps.resolveAbsoluteGitDir(cwd); + if (!gitDir) { + return target; + } + + const repoGitRoot = await this.resolveWorkspaceGitRefsRoot(gitDir); + target.repoGitRoot = repoGitRoot; + this.startWorkspaceWatchers(target, gitDir, repoGitRoot); + await this.ensureRepoTarget(target); + return target; + } + + private async resolveWorkspaceGitRefsRoot(gitDir: string): Promise { + try { + const commonDir = (await readFile(join(gitDir, "commondir"), "utf8")).trim(); + if (commonDir.length > 0) { + return resolve(gitDir, commonDir); + } + } catch { + return gitDir; + } + + 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) { + return; + } + + const existingTarget = this.repoTargets.get(repoGitRoot); + if (existingTarget) { + existingTarget.workspaceKeys.add(workspaceTarget.cwd); + return; + } + + const hasOrigin = await this.deps.hasOriginRemote(workspaceTarget.cwd); + if (!hasOrigin) { + return; + } + + const targetAfterProbe = this.repoTargets.get(repoGitRoot); + if (targetAfterProbe) { + targetAfterProbe.workspaceKeys.add(workspaceTarget.cwd); + return; + } + + 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, + }; + this.repoTargets.set(repoGitRoot, repoTarget); + void this.runRepoFetch(repoTarget); + } + + private scheduleWorkspaceRefresh(target: WorkspaceGitTarget): void { + if (target.debounceTimer) { + clearTimeout(target.debounceTimer); + } + + target.debounceTimer = setTimeout(() => { + target.debounceTimer = null; + void this.refreshWorkspaceTarget(target); + }, WORKSPACE_GIT_WATCH_DEBOUNCE_MS); + } + + private async refreshWorkspaceTarget(target: WorkspaceGitTarget): Promise { + if (target.refreshPromise) { + target.refreshQueued = true; + return; + } + + target.refreshPromise = (async () => { + do { + target.refreshQueued = false; + try { + const snapshot = await this.refreshSnapshot(target.cwd); + this.rememberSnapshot(target, snapshot, { notify: true }); + } catch (error) { + this.logger.warn({ err: error, cwd: target.cwd }, "Failed to refresh workspace git snapshot"); + } + } while (target.refreshQueued); + })(); + + try { + await target.refreshPromise; + } finally { + target.refreshPromise = null; + } + } + + private async refreshSnapshot(cwd: string): Promise { + return loadWorkspaceGitRuntimeSnapshot( + cwd, + { paseoHome: this.paseoHome }, + this.deps.now(), + this.deps, + ); + } + + private rememberSnapshot( + target: WorkspaceGitTarget, + snapshot: WorkspaceGitRuntimeSnapshot, + options?: { notify?: boolean }, + ): void { + target.latestSnapshot = snapshot; + const fingerprint = JSON.stringify(snapshot); + if (target.latestFingerprint === fingerprint) { + return; + } + target.latestFingerprint = fingerprint; + if (!options?.notify) { + return; + } + for (const listener of target.listeners) { + listener(snapshot); + } + } + + private async runRepoFetch(target: RepoGitTarget): Promise { + if (target.fetchInFlight) { + return; + } + + target.fetchInFlight = true; + this.logger.debug({ repoGitRoot: target.repoGitRoot, cwd: target.cwd }, "Running background git fetch"); + + try { + await this.deps.runGitFetch(target.cwd); + } catch (error) { + this.logger.warn( + { err: error, repoGitRoot: target.repoGitRoot, cwd: target.cwd }, + "Background git fetch failed", + ); + } 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); + }), + ); + } + } + + private removeWorkspaceListener(cwd: string, listener: WorkspaceGitListener): void { + const target = this.workspaceTargets.get(cwd); + if (!target) { + return; + } + + target.listeners.delete(listener); + if (target.listeners.size > 0) { + return; + } + + this.removeWorkspaceTarget(target); + } + + private removeWorkspaceTarget(target: WorkspaceGitTarget): void { + if (target.repoGitRoot) { + const repoTarget = this.repoTargets.get(target.repoGitRoot); + repoTarget?.workspaceKeys.delete(target.cwd); + if (repoTarget && repoTarget.workspaceKeys.size === 0) { + this.closeRepoTarget(repoTarget); + this.repoTargets.delete(target.repoGitRoot); + } + } + + this.closeWorkspaceTarget(target); + this.workspaceTargets.delete(target.cwd); + } + + private closeWorkspaceTarget(target: WorkspaceGitTarget): void { + if (target.debounceTimer) { + clearTimeout(target.debounceTimer); + target.debounceTimer = null; + } + + for (const watcher of target.watchers) { + watcher.close(); + } + target.watchers = []; + target.listeners.clear(); + } + + private closeRepoTarget(target: RepoGitTarget): void { + if (target.intervalId) { + clearInterval(target.intervalId); + target.intervalId = null; + } + target.workspaceKeys.clear(); + } +} + +async function loadWorkspaceGitRuntimeSnapshot( + cwd: string, + context: CheckoutContext, + now: Date, + deps: Pick< + WorkspaceGitServiceDependencies, + "getCheckoutStatus" | "getCheckoutShortstat" | "getPullRequestStatus" | "resolveGhPath" + >, +): Promise { + const checkoutStatus = await deps.getCheckoutStatus(cwd, context); + if (!checkoutStatus.isGit) { + return buildNotGitSnapshot(cwd); + } + + const [diffStat, github] = await Promise.all([ + deps.getCheckoutShortstat(cwd, context), + loadGitHubSnapshot({ + cwd, + remoteUrl: checkoutStatus.remoteUrl, + now, + deps, + }), + ]); + + return { + cwd, + git: { + isGit: true, + repoRoot: checkoutStatus.repoRoot, + mainRepoRoot: checkoutStatus.isPaseoOwnedWorktree ? checkoutStatus.mainRepoRoot : null, + currentBranch: checkoutStatus.currentBranch, + remoteUrl: checkoutStatus.remoteUrl, + isPaseoOwnedWorktree: checkoutStatus.isPaseoOwnedWorktree, + isDirty: checkoutStatus.isDirty, + aheadBehind: checkoutStatus.aheadBehind, + aheadOfOrigin: checkoutStatus.aheadOfOrigin, + behindOfOrigin: checkoutStatus.behindOfOrigin, + diffStat, + }, + github, + }; +} + +async function loadGitHubSnapshot(options: { + cwd: string; + remoteUrl: string | null; + now: Date; + deps: Pick; +}): Promise { + if (!hasGitHubRemoteUrl(options.remoteUrl)) { + return { + featuresEnabled: false, + pullRequest: null, + error: null, + refreshedAt: null, + }; + } + + try { + await options.deps.resolveGhPath(); + } catch { + return { + featuresEnabled: false, + pullRequest: null, + error: null, + refreshedAt: null, + }; + } + + try { + const result = await options.deps.getPullRequestStatus(options.cwd); + return { + featuresEnabled: true, + pullRequest: result.status, + error: null, + refreshedAt: options.now.toISOString(), + }; + } catch (error) { + return { + featuresEnabled: true, + pullRequest: null, + error: { + message: error instanceof Error ? error.message : String(error), + }, + refreshedAt: options.now.toISOString(), + }; + } +} + +function hasGitHubRemoteUrl(remoteUrl: string | null): boolean { + if (!remoteUrl) { + return false; + } + + return ( + remoteUrl.includes("github.com/") || + remoteUrl.startsWith("git@github.com:") || + remoteUrl.startsWith("ssh://git@github.com/") + ); +} + +function buildNotGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot { + return { + 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, + }, + }; +} + +async function runGitFetch(cwd: string): Promise { + await execFileAsync("git", ["fetch", "origin", "--prune"], { + cwd, + env: { + ...process.env, + GIT_TERMINAL_PROMPT: "0", + }, + }); +} diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index cc9f0574a..c1974b567 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -95,7 +95,7 @@ type RegisterPendingWorktreeWorkspaceDependencies = { type CreatePaseoWorktreeInBackgroundDependencies = { paseoHome?: string; - emitWorkspaceUpdateForCwd: (cwd: string, options?: { dedupeGitState?: boolean }) => Promise; + emitWorkspaceUpdateForCwd: (cwd: string) => Promise; sessionLogger: Logger; terminalManager: TerminalManager | null; }; diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index ae2aaa098..207523510 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1855,6 +1855,50 @@ export const ProjectPlacementPayloadSchema = z.object({ checkout: ProjectCheckoutLitePayloadSchema, }); +const WorkspaceGitRuntimePayloadSchema = z + .object({ + currentBranch: z.string().nullable().optional(), + remoteUrl: z.string().nullable().optional(), + isPaseoOwnedWorktree: z.boolean().optional(), + isDirty: z.boolean().nullable().optional(), + aheadBehind: z + .object({ + ahead: z.number(), + behind: z.number(), + }) + .nullable() + .optional(), + aheadOfOrigin: z.number().nullable().optional(), + behindOfOrigin: z.number().nullable().optional(), + }) + .optional() + .nullable(); + +const WorkspaceGitHubRuntimePayloadSchema = z + .object({ + featuresEnabled: z.boolean().optional(), + pullRequest: z + .object({ + url: z.string(), + title: z.string(), + state: z.string(), + baseRefName: z.string(), + headRefName: z.string(), + isMerged: z.boolean(), + }) + .nullable() + .optional(), + error: z + .object({ + message: z.string(), + }) + .nullable() + .optional(), + refreshedAt: z.string().nullable().optional(), + }) + .optional() + .nullable(); + export const WorkspaceDescriptorPayloadSchema = z.object({ id: z.string(), projectId: z.string(), @@ -1872,6 +1916,8 @@ export const WorkspaceDescriptorPayloadSchema = z.object({ }) .nullable() .optional(), + gitRuntime: WorkspaceGitRuntimePayloadSchema, + githubRuntime: WorkspaceGitHubRuntimePayloadSchema, }); export const AgentUpdateMessageSchema = z.object({ diff --git a/packages/server/src/shared/messages.workspaces.test.ts b/packages/server/src/shared/messages.workspaces.test.ts index bf785602b..eb4601ee9 100644 --- a/packages/server/src/shared/messages.workspaces.test.ts +++ b/packages/server/src/shared/messages.workspaces.test.ts @@ -1,3 +1,4 @@ +import { z } from "zod"; import { describe, expect, test } from "vitest"; import { SessionInboundMessageSchema, SessionOutboundMessageSchema } from "./messages.js"; @@ -117,6 +118,162 @@ describe("workspace message schemas", () => { expect(result.success).toBe(false); }); + test("parses fetch_workspaces_response with optional runtime fields", () => { + const parsed = SessionOutboundMessageSchema.parse({ + type: "fetch_workspaces_response", + payload: { + requestId: "req-workspaces", + entries: [ + { + id: "/tmp/repo", + projectId: "remote:github.com/acme/repo", + projectDisplayName: "acme/repo", + projectRootPath: "/tmp/repo", + projectKind: "git", + workspaceKind: "local_checkout", + name: "main", + status: "done", + activityAt: null, + diffStat: { + additions: 3, + deletions: 1, + }, + gitRuntime: { + currentBranch: "main", + remoteUrl: "https://github.com/acme/repo.git", + isPaseoOwnedWorktree: false, + isDirty: true, + aheadBehind: { + ahead: 2, + behind: 1, + }, + aheadOfOrigin: 2, + behindOfOrigin: 1, + }, + githubRuntime: { + featuresEnabled: true, + pullRequest: { + url: "https://github.com/acme/repo/pull/123", + title: "Runtime payloads", + state: "open", + baseRefName: "main", + headRefName: "workspace-git-service", + isMerged: false, + }, + error: null, + refreshedAt: "2026-04-12T00:00:00.000Z", + }, + }, + ], + pageInfo: { + nextCursor: null, + prevCursor: null, + hasMore: false, + }, + }, + }); + + expect(parsed.type).toBe("fetch_workspaces_response"); + expect(parsed.payload.entries[0]?.gitRuntime).toMatchObject({ + currentBranch: "main", + isDirty: true, + aheadOfOrigin: 2, + }); + expect(parsed.payload.entries[0]?.githubRuntime?.pullRequest?.title).toBe("Runtime payloads"); + }); + + test("older workspace parsers ignore additive runtime fields", () => { + const message = { + type: "fetch_workspaces_response", + payload: { + requestId: "req-workspaces", + entries: [ + { + id: "/tmp/repo", + projectId: "remote:github.com/acme/repo", + projectDisplayName: "acme/repo", + projectRootPath: "/tmp/repo", + projectKind: "git", + workspaceKind: "local_checkout", + name: "main", + status: "done", + activityAt: null, + diffStat: null, + gitRuntime: { + currentBranch: "main", + remoteUrl: "https://github.com/acme/repo.git", + isPaseoOwnedWorktree: false, + isDirty: false, + aheadBehind: { + ahead: 0, + behind: 0, + }, + aheadOfOrigin: 0, + behindOfOrigin: 0, + }, + githubRuntime: { + featuresEnabled: true, + pullRequest: null, + error: null, + refreshedAt: "2026-04-12T00:00:00.000Z", + }, + }, + ], + pageInfo: { + nextCursor: null, + prevCursor: null, + hasMore: false, + }, + }, + }; + + const legacyWorkspaceSchema = z.object({ + id: z.string(), + projectId: z.string(), + projectDisplayName: z.string(), + projectRootPath: z.string(), + projectKind: z.enum(["git", "non_git"]), + workspaceKind: z.enum(["local_checkout", "worktree", "directory"]), + name: z.string(), + status: z.enum(["needs_input", "failed", "running", "attention", "done"]), + activityAt: z.string().nullable(), + diffStat: z + .object({ + additions: z.number(), + deletions: z.number(), + }) + .nullable() + .optional(), + }); + const legacyMessageSchema = z.object({ + type: z.literal("fetch_workspaces_response"), + payload: z.object({ + requestId: z.string(), + entries: z.array(legacyWorkspaceSchema), + pageInfo: z.object({ + nextCursor: z.string().nullable(), + prevCursor: z.string().nullable(), + hasMore: z.boolean(), + }), + }), + }); + + const parsed = legacyMessageSchema.parse(message); + + expect(parsed.payload.entries[0]).toEqual({ + id: "/tmp/repo", + projectId: "remote:github.com/acme/repo", + projectDisplayName: "acme/repo", + projectRootPath: "/tmp/repo", + projectKind: "git", + workspaceKind: "local_checkout", + name: "main", + status: "done", + activityAt: null, + diffStat: null, + }); + }); + test("parses legacy fetch_agents_response checkout payloads without worktreeRoot", () => { const result = SessionOutboundMessageSchema.safeParse({ type: "fetch_agents_response", diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 92c11cbcf..c8ab0ab57 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -643,7 +643,7 @@ async function requireGitRepo(cwd: string): Promise { } } -async function getCurrentBranch(cwd: string): Promise { +export async function getCurrentBranch(cwd: string): Promise { const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd, env: READ_ONLY_GIT_ENV, @@ -665,7 +665,7 @@ async function getWorktreeRoot(cwd: string): Promise { } } -async function getMainRepoRoot(cwd: string): Promise { +export async function getMainRepoRoot(cwd: string): Promise { const { stdout: commonDirOut } = await execAsync( "git rev-parse --path-format=absolute --git-common-dir", { cwd, env: READ_ONLY_GIT_ENV }, @@ -807,7 +807,7 @@ async function isWorkingTreeDirty(cwd: string): Promise { return stdout.trim().length > 0; } -async function getOriginRemoteUrl(cwd: string): Promise { +export async function getOriginRemoteUrl(cwd: string): Promise { try { const { stdout } = await execAsync("git config --get remote.origin.url", { cwd, @@ -820,12 +820,12 @@ async function getOriginRemoteUrl(cwd: string): Promise { } } -async function hasOriginRemote(cwd: string): Promise { +export async function hasOriginRemote(cwd: string): Promise { const url = await getOriginRemoteUrl(cwd); return url !== null; } -async function resolveAbsoluteGitDir(cwd: string): Promise { +export async function resolveAbsoluteGitDir(cwd: string): Promise { try { const { stdout } = await execAsync("git rev-parse --absolute-git-dir", { cwd, @@ -1850,7 +1850,7 @@ export interface PullRequestStatusResult { githubFeaturesEnabled: boolean; } -async function resolveGhPath(): Promise { +export async function resolveGhPath(): Promise { if (cachedGhPath === undefined) { cachedGhPath = await findExecutable("gh"); }