From 5a2708653c0f0365f0524295b125ec475c6f154e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 19 Apr 2026 22:35:26 +0700 Subject: [PATCH] feat(server,app): redesign git/GitHub refresh architecture with server authority Centralizes refresh orchestration in WorkspaceGitService and GitHubService: server coalesces, deduplicates, and throttles git/GitHub reads; clients peek snapshots (cold reads fall through to a one-shot fetch) and never drive refreshes. Introduces a two-TTL model (consumer vs internal), force-emit bypass so fingerprint-matched refreshes still propagate, notifyGitMutation wiring at ~13 action handlers, adaptive GitHub polling based on check state, and checkout_status_update / prStatus push-to-cache wire messages. Fixes the stale diff-stat bug and the inverted base-branch diff direction, and flips next-action affordances promptly after commit / PR merge. --- packages/app/src/components/git-diff-pane.tsx | 23 - .../src/hooks/checkout-status-revalidation.ts | 24 - .../app/src/hooks/use-checkout-diff-query.ts | 20 +- .../src/hooks/use-checkout-pr-status-query.ts | 50 +- .../hooks/use-checkout-status-query.test.ts | 360 ++++- .../src/hooks/use-checkout-status-query.ts | 89 +- .../app/src/hooks/use-pr-pane-data.test.ts | 197 ++- packages/app/src/hooks/use-pr-pane-data.ts | 9 +- .../src/screens/agent/draft-agent-screen.tsx | 17 +- .../app/src/screens/new-workspace-screen.tsx | 4 + .../screens/workspace/workspace-screen.tsx | 8 +- packages/server/src/client/daemon-client.ts | 9 +- .../server/agent/agent-metadata-generator.ts | 58 +- .../agent-metadata-generator.unit.test.ts | 143 +- .../src/server/agent/mcp-server.test.ts | 103 +- .../server/src/server/agent/mcp-server.ts | 77 +- .../src/server/agent/provider-registry.ts | 18 +- .../providers/codex-app-server-agent.test.ts | 33 +- .../agent/providers/codex-app-server-agent.ts | 50 +- packages/server/src/server/bootstrap.ts | 26 +- .../src/server/checkout-diff-manager.test.ts | 34 +- .../src/server/checkout-diff-manager.ts | 19 +- .../src/server/daemon-e2e/checkout-debug.ts | 14 +- .../src/server/paseo-worktree-service.test.ts | 11 + .../src/server/paseo-worktree-service.ts | 2 +- .../resolve-worktree-creation-intent.test.ts | 5 +- .../resolve-worktree-creation-intent.ts | 4 +- .../server/script-status-projection.test.ts | 44 + .../src/server/script-status-projection.ts | 10 +- packages/server/src/server/session.test.ts | 1196 ++++++++++++++- packages/server/src/server/session.ts | 451 +++--- .../session.workspace-git-watch.test.ts | 149 ++ .../src/server/session.workspaces.test.ts | 5 + .../src/server/workspace-git-metadata.test.ts | 12 +- .../src/server/workspace-git-metadata.ts | 68 +- .../workspace-git-service.primitive.test.ts | 1278 +++++++++++++++++ .../src/server/workspace-git-service.test.ts | 74 +- .../src/server/workspace-git-service.ts | 787 +++++++++- .../workspace-reconciliation-service.test.ts | 63 + .../workspace-reconciliation-service.ts | 26 +- .../workspace-registry-bootstrap.test.ts | 1 + .../server/src/server/worktree-core.test.ts | 33 +- packages/server/src/server/worktree-core.ts | 39 +- .../src/server/worktree-session.test.ts | 135 +- .../server/src/server/worktree-session.ts | 64 +- .../src/services/github-service.test.ts | 503 ++++++- .../server/src/services/github-service.ts | 391 ++++- packages/server/src/shared/messages.ts | 87 +- .../server/src/utils/checkout-git.test.ts | 304 ++-- packages/server/src/utils/checkout-git.ts | 266 ++-- 50 files changed, 6320 insertions(+), 1073 deletions(-) delete mode 100644 packages/app/src/hooks/checkout-status-revalidation.ts create mode 100644 packages/server/src/server/workspace-git-service.primitive.test.ts diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 6a416cc40..e3279d646 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -661,10 +661,8 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi const { status, isLoading: isStatusLoading, - isFetching: isStatusFetching, isError: isStatusError, error: statusError, - refresh: refreshStatus, } = useCheckoutStatusQuery({ serverId, cwd }); const gitStatus = status && status.isGit ? status : null; const isGit = Boolean(gitStatus); @@ -683,10 +681,8 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi files, payloadError: diffPayloadError, isLoading: isDiffLoading, - isFetching: isDiffFetching, isError: isDiffError, error: diffError, - refresh: refreshDiff, } = useCheckoutDiffQuery({ serverId, cwd, @@ -699,14 +695,11 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi status: prStatus, githubFeaturesEnabled, payloadError: prPayloadError, - refresh: refreshPrStatus, } = useCheckoutPrStatusQuery({ serverId, cwd, enabled: isGit, }); - // Track user-initiated refresh to avoid iOS RefreshControl animation on background fetches - const [isManualRefresh, setIsManualRefresh] = useState(false); const normalizedWorkspaceRoot = useMemo(() => cwd.trim(), [cwd]); const workspaceStateKey = useMemo( () => @@ -732,13 +725,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi const headerHeightByPathRef = useRef>({}); const bodyHeightByPathRef = useRef>({}); const defaultHeaderHeightRef = useRef(44); - const handleRefresh = useCallback(() => { - setIsManualRefresh(true); - void refreshDiff(); - void refreshStatus(); - void refreshPrStatus(); - }, [refreshDiff, refreshStatus, refreshPrStatus]); - const shipDefaultStorageKey = useMemo(() => { if (!gitStatus?.repoRoot) { return null; @@ -901,13 +887,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi } }, [allExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]); - // Reset manual refresh flag when fetch completes - useEffect(() => { - if (!(isDiffFetching || isStatusFetching) && isManualRefresh) { - setIsManualRefresh(false); - } - }, [isDiffFetching, isStatusFetching, isManualRefresh]); - // Clear diff mode override when auto mode changes (e.g., after commit) useEffect(() => { setDiffModeOverride(null); @@ -1199,8 +1178,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi onContentSizeChange={scrollbar.onContentSizeChange} scrollEventThrottle={16} showsVerticalScrollIndicator={!showDesktopWebScrollbar} - onRefresh={handleRefresh} - refreshing={isManualRefresh && isDiffFetching} // Mixed-height rows (header + potentially very large body) are prone to clipping artifacts. // Keep a larger render window and disable clipping to avoid bodies disappearing mid-scroll. removeClippedSubviews={false} diff --git a/packages/app/src/hooks/checkout-status-revalidation.ts b/packages/app/src/hooks/checkout-status-revalidation.ts deleted file mode 100644 index d1174b55f..000000000 --- a/packages/app/src/hooks/checkout-status-revalidation.ts +++ /dev/null @@ -1,24 +0,0 @@ -export type CheckoutStatusRevalidationParams = { - serverId: string; - cwd: string; - isOpen: boolean; - explorerTab: string; -}; - -export function checkoutStatusRevalidationKey( - params: CheckoutStatusRevalidationParams, -): string | null { - if (!params.cwd) return null; - if (!params.isOpen) return null; - if (params.explorerTab !== "changes") return null; - return `${params.serverId}:${params.cwd}`; -} - -export function nextCheckoutStatusRefetchDecision( - prevKey: string | null, - nextKey: string | null, -): { nextSeenKey: string | null; shouldRefetch: boolean } { - if (!nextKey) return { nextSeenKey: null, shouldRefetch: false }; - if (prevKey === nextKey) return { nextSeenKey: prevKey, shouldRefetch: false }; - return { nextSeenKey: nextKey, shouldRefetch: true }; -} diff --git a/packages/app/src/hooks/use-checkout-diff-query.ts b/packages/app/src/hooks/use-checkout-diff-query.ts index 3af185c53..c81d36588 100644 --- a/packages/app/src/hooks/use-checkout-diff-query.ts +++ b/packages/app/src/hooks/use-checkout-diff-query.ts @@ -1,13 +1,11 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useId, useMemo } from "react"; +import { useEffect, useId, useMemo } from "react"; import { useIsCompactFormFactor } from "@/constants/layout"; import { usePanelStore } from "@/stores/panel-store"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages"; import { orderCheckoutDiffFiles } from "./checkout-diff-order"; -const CHECKOUT_DIFF_STALE_TIME = 30_000; - function checkoutDiffQueryKey( serverId: string, cwd: string, @@ -95,7 +93,10 @@ export function useCheckoutDiffQuery({ }; }, enabled: !!client && isConnected && !!cwd && enabled, - staleTime: CHECKOUT_DIFF_STALE_TIME, + staleTime: Infinity, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, }); useEffect(() => { @@ -118,9 +119,6 @@ export function useCheckoutDiffQuery({ let cancelled = false; const unsubscribeUpdate = client.on("checkout_diff_update", (message) => { - if (message.type !== "checkout_diff_update") { - return; - } if (message.payload.subscriptionId !== subscriptionId) { return; } @@ -134,9 +132,6 @@ export function useCheckoutDiffQuery({ const unsubscribeSubscribeResponse = client.on( "subscribe_checkout_diff_response", (message) => { - if (message.type !== "subscribe_checkout_diff_response") { - return; - } if (message.payload.subscriptionId !== subscriptionId) { return; } @@ -207,10 +202,6 @@ export function useCheckoutDiffQuery({ queryClient, ]); - const refresh = useCallback(() => { - return query.refetch(); - }, [query]); - const payload = query.data ?? null; const payloadError = payload?.error ?? null; @@ -221,6 +212,5 @@ export function useCheckoutDiffQuery({ isFetching: query.isFetching, isError: query.isError || Boolean(payloadError), error: query.error, - refresh, }; } diff --git a/packages/app/src/hooks/use-checkout-pr-status-query.ts b/packages/app/src/hooks/use-checkout-pr-status-query.ts index 77cd9bfcc..18a7d4f36 100644 --- a/packages/app/src/hooks/use-checkout-pr-status-query.ts +++ b/packages/app/src/hooks/use-checkout-pr-status-query.ts @@ -1,10 +1,8 @@ -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { CheckoutPrStatusResponse } from "@server/shared/messages"; -const CHECKOUT_PR_STATUS_STALE_TIME = 20_000; -const WORKSPACE_PR_HINT_REFETCH_INTERVAL = 60_000; - export function checkoutPrStatusQueryKey(serverId: string, cwd: string) { return ["checkoutPrStatus", serverId, cwd] as const; } @@ -16,6 +14,7 @@ interface UseCheckoutPrStatusQueryOptions { } export type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"]; + export interface PrHint { url: string; number: number; @@ -71,9 +70,24 @@ export function useCheckoutPrStatusQuery({ cwd, enabled = true, }: UseCheckoutPrStatusQueryOptions) { + const queryClient = useQueryClient(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); + useEffect(() => { + if (!client || !isConnected || !cwd) { + return; + } + + return client.on("checkout_status_update", (message) => { + const prStatus = message.payload.prStatus; + if (!prStatus || prStatus.cwd !== cwd) { + return; + } + queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), prStatus); + }); + }, [client, isConnected, cwd, queryClient, serverId]); + const query = useQuery({ queryKey: checkoutPrStatusQueryKey(serverId, cwd), queryFn: async () => { @@ -83,8 +97,10 @@ export function useCheckoutPrStatusQuery({ return await client.checkoutPrStatus(cwd); }, enabled: !!client && isConnected && !!cwd && enabled, - staleTime: CHECKOUT_PR_STATUS_STALE_TIME, - refetchInterval: 15_000, + staleTime: Infinity, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, }); return { @@ -95,7 +111,6 @@ export function useCheckoutPrStatusQuery({ isFetching: query.isFetching, isError: query.isError, error: query.error, - refresh: query.refetch, }; } @@ -104,9 +119,24 @@ export function useWorkspacePrHint({ cwd, enabled = true, }: UseCheckoutPrStatusQueryOptions): PrHint | null { + const queryClient = useQueryClient(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); + useEffect(() => { + if (!client || !isConnected || !cwd) { + return; + } + + return client.on("checkout_status_update", (message) => { + const prStatus = message.payload.prStatus; + if (!prStatus || prStatus.cwd !== cwd) { + return; + } + queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), prStatus); + }); + }, [client, isConnected, cwd, queryClient, serverId]); + const query = useQuery({ queryKey: checkoutPrStatusQueryKey(serverId, cwd), queryFn: async () => { @@ -116,8 +146,10 @@ export function useWorkspacePrHint({ return await client.checkoutPrStatus(cwd); }, enabled: !!client && isConnected && !!cwd && enabled, - staleTime: CHECKOUT_PR_STATUS_STALE_TIME, - refetchInterval: WORKSPACE_PR_HINT_REFETCH_INTERVAL, + staleTime: Infinity, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, select: selectWorkspacePrHint, }); diff --git a/packages/app/src/hooks/use-checkout-status-query.test.ts b/packages/app/src/hooks/use-checkout-status-query.test.ts index 8911561e8..17026bb2b 100644 --- a/packages/app/src/hooks/use-checkout-status-query.test.ts +++ b/packages/app/src/hooks/use-checkout-status-query.test.ts @@ -1,75 +1,317 @@ -import { describe, expect, it } from "vitest"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "jsdom"; import { - checkoutStatusRevalidationKey, - nextCheckoutStatusRefetchDecision, -} from "./checkout-status-revalidation"; + QueryClient, + QueryClientProvider, + focusManager, + onlineManager, +} from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CheckoutStatusResponse } from "@server/shared/messages"; +import { checkoutStatusQueryKey, useCheckoutStatusQuery } from "./use-checkout-status-query"; + +type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; + +const { mockRuntime, mockClient, checkoutStatusUpdateHandlers } = vi.hoisted(() => { + const checkoutStatusUpdateHandlers = new Set<(message: unknown) => void>(); + const mockClient = { + getCheckoutStatus: vi.fn(), + on: vi.fn((type: string, handler: (message: unknown) => void) => { + if (type !== "checkout_status_update") { + return () => {}; + } + checkoutStatusUpdateHandlers.add(handler); + return () => { + checkoutStatusUpdateHandlers.delete(handler); + }; + }), + }; + + return { + mockClient, + checkoutStatusUpdateHandlers, + mockRuntime: { + client: mockClient, + isConnected: true, + }, + }; +}); + +vi.mock("@/runtime/host-runtime", () => ({ + useHostRuntimeClient: () => mockRuntime.client, + useHostRuntimeIsConnected: () => mockRuntime.isConnected, +})); + +const serverId = "server-1"; +const cwd = "/repo"; + +function checkoutStatus(overrides: Partial = {}): CheckoutStatusPayload { + return { + cwd, + error: null, + requestId: "checkout-status-1", + isGit: true, + isPaseoOwnedWorktree: false, + repoRoot: cwd, + currentBranch: "main", + isDirty: false, + baseRef: "origin/main", + aheadBehind: { ahead: 0, behind: 0 }, + aheadOfOrigin: 0, + behindOfOrigin: 0, + hasRemote: true, + remoteUrl: "git@github.com:getpaseo/paseo.git", + ...overrides, + } as CheckoutStatusPayload; +} + +function createTestQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); +} + +function renderCheckoutStatusHook({ + queryClient = createTestQueryClient(), + hookCwd = cwd, +}: { + queryClient?: QueryClient; + hookCwd?: string; +} = {}) { + let latest: ReturnType | null = null; + + function Probe() { + latest = useCheckoutStatusQuery({ serverId, cwd: hookCwd }); + return null; + } + + const container = document.getElementById("root"); + if (!container) { + throw new Error("Missing root container"); + } + + const root = createRoot(container); + + return { + get latest() { + if (!latest) { + throw new Error("Expected hook result"); + } + return latest; + }, + async mount() { + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement(Probe), + ), + ); + }); + }, + async unmount() { + await act(async () => { + root.unmount(); + }); + }, + }; +} + +async function waitForExpectation(assertion: () => void): Promise { + let lastError: unknown; + + for (let attempt = 0; attempt < 60; attempt += 1) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await act(async () => { + vi.advanceTimersByTime(10); + await Promise.resolve(); + }); + } + } + + throw lastError; +} + +async function emitCheckoutStatusUpdate(payload: CheckoutStatusPayload): Promise { + await act(async () => { + for (const handler of checkoutStatusUpdateHandlers) { + handler({ + type: "checkout_status_update", + payload, + }); + } + await Promise.resolve(); + }); +} describe("useCheckoutStatusQuery", () => { - describe("checkoutStatusRevalidationKey", () => { - it("returns null when sidebar is closed", () => { - expect( - checkoutStatusRevalidationKey({ - serverId: "daemon-1", - cwd: "/path/to/project", - isOpen: false, - explorerTab: "changes", - }), - ).toBeNull(); + beforeEach(() => { + vi.useFakeTimers(); + + const dom = new JSDOM("
", { + url: "http://localhost", }); - it("returns null when tab is not changes", () => { - expect( - checkoutStatusRevalidationKey({ - serverId: "daemon-1", - cwd: "/path/to/project", - isOpen: true, - explorerTab: "files", - }), - ).toBeNull(); + Object.defineProperty(globalThis, "document", { + value: dom.window.document, + configurable: true, + }); + Object.defineProperty(globalThis, "window", { + value: dom.window, + configurable: true, + }); + Object.defineProperty(globalThis, "navigator", { + value: dom.window.navigator, + configurable: true, + }); + Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { + value: true, + configurable: true, }); - it("returns a stable key when open and on changes tab", () => { - expect( - checkoutStatusRevalidationKey({ - serverId: "daemon-1", - cwd: "/path/to/project", - isOpen: true, - explorerTab: "changes", - }), - ).toBe("daemon-1:/path/to/project"); - }); + mockRuntime.client = mockClient; + mockRuntime.isConnected = true; + mockClient.getCheckoutStatus.mockReset(); + mockClient.on.mockClear(); + checkoutStatusUpdateHandlers.clear(); }); - describe("nextCheckoutStatusRefetchDecision", () => { - it("refetches only once per key until reset", () => { - const key = "daemon-1:/path/to/project"; + afterEach(() => { + focusManager.setFocused(undefined); + onlineManager.setOnline(true); + vi.useRealTimers(); + }); - expect(nextCheckoutStatusRefetchDecision(null, key)).toEqual({ - nextSeenKey: key, - shouldRefetch: true, - }); + it("does not schedule polling after the cold read", async () => { + mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus()); + const hook = renderCheckoutStatusHook(); - expect(nextCheckoutStatusRefetchDecision(key, key)).toEqual({ - nextSeenKey: key, - shouldRefetch: false, - }); - - expect(nextCheckoutStatusRefetchDecision(key, null)).toEqual({ - nextSeenKey: null, - shouldRefetch: false, - }); - - expect(nextCheckoutStatusRefetchDecision(null, key)).toEqual({ - nextSeenKey: key, - shouldRefetch: true, - }); + await hook.mount(); + await waitForExpectation(() => { + expect(hook.latest.status?.currentBranch).toBe("main"); }); - it("refetches again when cwd changes while active", () => { - expect(nextCheckoutStatusRefetchDecision("daemon-1:/path/a", "daemon-1:/path/b")).toEqual({ - nextSeenKey: "daemon-1:/path/b", - shouldRefetch: true, - }); + await act(async () => { + vi.advanceTimersByTime(60_000); }); + + expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1); + }); + + it("returns the cached snapshot without calling the daemon", async () => { + const queryClient = createTestQueryClient(); + const cached = checkoutStatus({ requestId: "cached" }); + queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), cached); + mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus({ requestId: "uncached" })); + const hook = renderCheckoutStatusHook({ queryClient }); + + await hook.mount(); + + expect(hook.latest.status).toEqual(cached); + expect(mockClient.getCheckoutStatus).not.toHaveBeenCalled(); + }); + + it("fetches once on a cold read and uses cache on remount", async () => { + const queryClient = createTestQueryClient(); + const fetched = checkoutStatus({ requestId: "cold-read" }); + mockClient.getCheckoutStatus.mockResolvedValue(fetched); + const firstHook = renderCheckoutStatusHook({ queryClient }); + + await firstHook.mount(); + await waitForExpectation(() => { + expect(firstHook.latest.status).toEqual(fetched); + }); + await firstHook.unmount(); + + const secondHook = renderCheckoutStatusHook({ queryClient }); + await secondHook.mount(); + + expect(secondHook.latest.status).toEqual(fetched); + expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1); + }); + + it("does not refetch checkout status on focus or reconnect", async () => { + mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus()); + const hook = renderCheckoutStatusHook(); + + await hook.mount(); + await waitForExpectation(() => { + expect(hook.latest.status?.currentBranch).toBe("main"); + }); + + await act(async () => { + focusManager.setFocused(false); + focusManager.setFocused(true); + onlineManager.setOnline(false); + onlineManager.setOnline(true); + await Promise.resolve(); + }); + + expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1); + }); + + it("server-pushed checkout status update writes into the React Query cache at checkoutStatusQueryKey(serverId, cwd)", async () => { + const queryClient = createTestQueryClient(); + mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus({ requestId: "initial" })); + const hook = renderCheckoutStatusHook({ queryClient }); + + await hook.mount(); + await waitForExpectation(() => { + expect(hook.latest.status?.requestId).toBe("initial"); + }); + + mockClient.getCheckoutStatus.mockClear(); + const pushed = checkoutStatus({ + requestId: "server-push", + currentBranch: "pushed-branch", + isDirty: true, + aheadBehind: { ahead: 2, behind: 1 }, + aheadOfOrigin: 2, + behindOfOrigin: 1, + }); + await emitCheckoutStatusUpdate(pushed); + + expect(mockClient.getCheckoutStatus).not.toHaveBeenCalled(); + expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed); + }); + + it("server push only updates the matching cwd's cache key, not other workspaces", async () => { + const queryClient = createTestQueryClient(); + const otherCwd = "/other-repo"; + const otherCached = checkoutStatus({ + cwd: otherCwd, + repoRoot: otherCwd, + requestId: "other-cached", + currentBranch: "other-main", + }); + queryClient.setQueryData(checkoutStatusQueryKey(serverId, otherCwd), otherCached); + mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus({ requestId: "initial" })); + const hook = renderCheckoutStatusHook({ queryClient }); + + await hook.mount(); + await waitForExpectation(() => { + expect(hook.latest.status?.requestId).toBe("initial"); + }); + + const pushed = checkoutStatus({ + requestId: "server-push", + currentBranch: "pushed-branch", + }); + await emitCheckoutStatusUpdate(pushed); + + expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed); + expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, otherCwd))).toEqual( + otherCached, + ); }); }); diff --git a/packages/app/src/hooks/use-checkout-status-query.ts b/packages/app/src/hooks/use-checkout-status-query.ts index 7ff55e5c5..cab8b9678 100644 --- a/packages/app/src/hooks/use-checkout-status-query.ts +++ b/packages/app/src/hooks/use-checkout-status-query.ts @@ -1,13 +1,7 @@ -import { useQuery } from "@tanstack/react-query"; -import { useEffect, useMemo, useRef } from "react"; -import { useIsCompactFormFactor } from "@/constants/layout"; -import { usePanelStore } from "@/stores/panel-store"; +import { type QueryClient, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { CheckoutStatusResponse } from "@server/shared/messages"; -import { - checkoutStatusRevalidationKey, - nextCheckoutStatusRefetchDecision, -} from "./checkout-status-revalidation"; export const CHECKOUT_STATUS_STALE_TIME = 15_000; @@ -22,22 +16,56 @@ interface UseCheckoutStatusQueryOptions { export type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; +interface CheckoutStatusClient { + getCheckoutStatus: (cwd: string) => Promise; +} + function fetchCheckoutStatus( - client: { getCheckoutStatus: (cwd: string) => Promise }, + client: CheckoutStatusClient, cwd: string, ): Promise { return client.getCheckoutStatus(cwd); } +async function peekOrFetchSnapshot({ + queryClient, + client, + serverId, + cwd, +}: { + queryClient: QueryClient; + client: CheckoutStatusClient; + serverId: string; + cwd: string; +}): Promise { + const queryKey = checkoutStatusQueryKey(serverId, cwd); + const cached = queryClient.getQueryData(queryKey); + if (cached) { + return cached; + } + + const snapshot = await fetchCheckoutStatus(client, cwd); + queryClient.setQueryData(queryKey, snapshot); + return snapshot; +} + export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) { + const queryClient = useQueryClient(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); - const isMobile = useIsCompactFormFactor(); - const mobileView = usePanelStore((state) => state.mobileView); - const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); - const explorerTab = usePanelStore((state) => state.explorerTab); - const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; - const shouldPoll = isOpen && explorerTab === "changes"; + + useEffect(() => { + if (!client || !isConnected || !cwd) { + return; + } + + return client.on("checkout_status_update", (message) => { + if (message.payload.cwd !== cwd) { + return; + } + queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), message.payload); + }); + }, [client, isConnected, cwd, queryClient, serverId]); const query = useQuery({ queryKey: checkoutStatusQueryKey(serverId, cwd), @@ -45,42 +73,21 @@ export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQuery if (!client) { throw new Error("Daemon client not available"); } - return await fetchCheckoutStatus(client, cwd); + return await peekOrFetchSnapshot({ queryClient, client, serverId, cwd }); }, enabled: !!client && isConnected && !!cwd, - staleTime: CHECKOUT_STATUS_STALE_TIME, - refetchInterval: (query) => { - if (!shouldPoll) return false; - const data = query.state.data as CheckoutStatusPayload | undefined; - return data?.isGit ? 10_000 : false; - }, - refetchIntervalInBackground: shouldPoll, - refetchOnMount: "always", + staleTime: Infinity, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, }); - // Revalidate when sidebar is open with "changes" tab active. - const revalidationKey = useMemo( - () => checkoutStatusRevalidationKey({ serverId, cwd, isOpen, explorerTab }), - [serverId, cwd, isOpen, explorerTab], - ); - const lastRevalidationKey = useRef(null); - useEffect(() => { - const decision = nextCheckoutStatusRefetchDecision( - lastRevalidationKey.current, - revalidationKey, - ); - lastRevalidationKey.current = decision.nextSeenKey; - if (!decision.shouldRefetch) return; - void query.refetch(); - }, [revalidationKey, query.refetch]); - return { status: query.data ?? null, isLoading: query.isLoading, isFetching: query.isFetching, isError: query.isError, error: query.error, - refresh: query.refetch, }; } diff --git a/packages/app/src/hooks/use-pr-pane-data.test.ts b/packages/app/src/hooks/use-pr-pane-data.test.ts index b82f1222b..a647ad986 100644 --- a/packages/app/src/hooks/use-pr-pane-data.test.ts +++ b/packages/app/src/hooks/use-pr-pane-data.test.ts @@ -1,8 +1,13 @@ import React, { act } from "react"; import { createRoot } from "react-dom/client"; import { JSDOM } from "jsdom"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + QueryClient, + QueryClientProvider, + focusManager, + onlineManager, +} from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { CheckoutPrStatusResponse, PullRequestTimelineResponse, @@ -18,14 +23,25 @@ type CheckoutPrStatus = NonNullable { +const { mockRuntime, mockClient, checkoutStatusUpdateHandlers } = vi.hoisted(() => { + const checkoutStatusUpdateHandlers = new Set<(message: unknown) => void>(); const mockClient = { checkoutPrStatus: vi.fn(), pullRequestTimeline: vi.fn(), + on: vi.fn((type: string, handler: (message: unknown) => void) => { + if (type !== "checkout_status_update") { + return () => {}; + } + checkoutStatusUpdateHandlers.add(handler); + return () => { + checkoutStatusUpdateHandlers.delete(handler); + }; + }), }; return { mockClient, + checkoutStatusUpdateHandlers, mockRuntime: { client: mockClient, isConnected: true, @@ -85,6 +101,34 @@ function timelinePayload( }; } +async function emitCheckoutStatusUpdatePrStatus(payload: CheckoutPrStatusPayload): Promise { + await act(async () => { + for (const handler of checkoutStatusUpdateHandlers) { + handler({ + type: "checkout_status_update", + payload: { + cwd: payload.cwd, + requestId: `subscription:${payload.cwd}`, + isGit: true, + isPaseoOwnedWorktree: false, + repoRoot: payload.cwd, + currentBranch: "main", + isDirty: false, + baseRef: "main", + aheadBehind: { ahead: 0, behind: 0 }, + aheadOfOrigin: 0, + behindOfOrigin: 0, + hasRemote: true, + remoteUrl: "https://github.com/getpaseo/paseo.git", + error: null, + prStatus: payload, + }, + }); + } + await Promise.resolve(); + }); +} + function unsupportedTimelineError(): Error { const error = new Error( "Unknown request schema requestType=pull_request_timeline_request code=unknown_schema", @@ -246,6 +290,14 @@ describe("usePrPaneData", () => { mockRuntime.isConnected = true; mockClient.checkoutPrStatus.mockReset(); mockClient.pullRequestTimeline.mockReset(); + mockClient.on.mockClear(); + checkoutStatusUpdateHandlers.clear(); + }); + + afterEach(() => { + focusManager.setFocused(undefined); + onlineManager.setOnline(true); + vi.useRealTimers(); }); it("returns null when status has no PR number", async () => { @@ -349,6 +401,82 @@ describe("usePrPaneData", () => { expect(mockClient.checkoutPrStatus).toHaveBeenCalledTimes(1); }); + it("server-pushed PR status update writes into the checkout PR status cache", async () => { + const queryClient = createTestQueryClient(); + mockClient.checkoutPrStatus.mockResolvedValue( + statusPayload({ + status: status({ + checksStatus: "pending", + checks: [{ name: "test", status: "pending", url: "https://github.com/checks/1" }], + }), + }), + ); + mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); + + const hook = renderPrPaneHook({ queryClient }); + await hook.mount(); + await waitForExpectation(() => { + expect(hook.latest.data?.checks[0]?.status).toBe("pending"); + }); + + mockClient.checkoutPrStatus.mockClear(); + await emitCheckoutStatusUpdatePrStatus( + statusPayload({ + requestId: "server-push", + status: status({ + checksStatus: "success", + checks: [{ name: "test", status: "success", url: "https://github.com/checks/1" }], + }), + }), + ); + + await waitForExpectation(() => { + expect(hook.latest.data?.checks[0]?.status).toBe("success"); + }); + expect(queryClient.getQueryData(["checkoutPrStatus", serverId, cwd])).toEqual( + statusPayload({ + requestId: "server-push", + status: status({ + checksStatus: "success", + checks: [{ name: "test", status: "success", url: "https://github.com/checks/1" }], + }), + }), + ); + expect(mockClient.checkoutPrStatus).not.toHaveBeenCalled(); + }); + + it("server-pushed PR status for a different cwd does not overwrite the current cache", async () => { + const queryClient = createTestQueryClient(); + const initial = statusPayload({ + status: status({ + checksStatus: "pending", + checks: [{ name: "test", status: "pending", url: "https://github.com/checks/1" }], + }), + }); + mockClient.checkoutPrStatus.mockResolvedValue(initial); + mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); + + const hook = renderPrPaneHook({ queryClient }); + await hook.mount(); + await waitForExpectation(() => { + expect(hook.latest.data?.checks[0]?.status).toBe("pending"); + }); + + await emitCheckoutStatusUpdatePrStatus( + statusPayload({ + cwd: "/other-repo", + requestId: "other-server-push", + status: status({ + checksStatus: "success", + checks: [{ name: "test", status: "success", url: "https://github.com/checks/1" }], + }), + }), + ); + + expect(queryClient.getQueryData(["checkoutPrStatus", serverId, cwd])).toEqual(initial); + expect(queryClient.getQueryData(["checkoutPrStatus", serverId, "/other-repo"])).toBeUndefined(); + }); + it("passes repoOwner and repoName to the timeline request when present", async () => { mockClient.checkoutPrStatus.mockResolvedValue( statusPayload({ @@ -517,6 +645,50 @@ describe("usePrPaneData", () => { expect(mockClient.pullRequestTimeline).not.toHaveBeenCalled(); }); + it("does not poll PR status or timeline after the initial load", async () => { + vi.useFakeTimers(); + mockClient.checkoutPrStatus.mockResolvedValue(statusPayload()); + mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); + + const hook = renderPrPaneHook(); + await hook.mount(); + + await waitForHookResult(() => { + expect(hook.latest.data?.number).toBe(42); + }); + + await act(async () => { + vi.advanceTimersByTime(120_000); + await Promise.resolve(); + }); + + expect(mockClient.checkoutPrStatus).toHaveBeenCalledTimes(1); + expect(mockClient.pullRequestTimeline).toHaveBeenCalledTimes(1); + }); + + it("does not refetch PR data on focus or reconnect", async () => { + mockClient.checkoutPrStatus.mockResolvedValue(statusPayload()); + mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); + + const hook = renderPrPaneHook(); + await hook.mount(); + + await waitForExpectation(() => { + expect(hook.latest.data?.number).toBe(42); + }); + + await act(async () => { + focusManager.setFocused(false); + focusManager.setFocused(true); + onlineManager.setOnline(false); + onlineManager.setOnline(true); + await Promise.resolve(); + }); + + expect(mockClient.checkoutPrStatus).toHaveBeenCalledTimes(1); + expect(mockClient.pullRequestTimeline).toHaveBeenCalledTimes(1); + }); + it("reports first-load, background refresh, and non-suppressed errors", async () => { const statusDeferred = createDeferred(); mockClient.checkoutPrStatus.mockReturnValue(statusDeferred.promise); @@ -563,6 +735,25 @@ describe("usePrPaneData", () => { }); }); +async function waitForHookResult(assertion: () => void): Promise { + let lastError: unknown; + + for (let attempt = 0; attempt < 60; attempt += 1) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await act(async () => { + vi.advanceTimersByTime(10); + await Promise.resolve(); + }); + } + } + + throw lastError; +} + function createDeferred(): { promise: Promise; resolve: (value: T) => void; diff --git a/packages/app/src/hooks/use-pr-pane-data.ts b/packages/app/src/hooks/use-pr-pane-data.ts index a51de0e51..4db6e7cb5 100644 --- a/packages/app/src/hooks/use-pr-pane-data.ts +++ b/packages/app/src/hooks/use-pr-pane-data.ts @@ -7,9 +7,6 @@ import type { import { mapPrPaneData, type PrPaneData } from "../utils/pr-pane-data"; import { useCheckoutPrStatusQuery } from "./use-checkout-pr-status-query"; -const PR_PANE_TIMELINE_STALE_TIME = 15_000; -const PR_PANE_TIMELINE_REFETCH_INTERVAL = 30_000; - type CheckoutPrStatusPayloadError = CheckoutPrStatusResponse["payload"]["error"]; type PullRequestTimeline = PullRequestTimelineResponse["payload"]; @@ -81,8 +78,10 @@ export function usePrPaneData({ } }, enabled: shouldFetchTimeline, - staleTime: PR_PANE_TIMELINE_STALE_TIME, - refetchInterval: PR_PANE_TIMELINE_REFETCH_INTERVAL, + staleTime: Infinity, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, placeholderData: keepPreviousData, retry: (failureCount, error) => !isUnsupportedTimelineError(error) && failureCount < 3, }); diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index da72b5ce8..d579a72fa 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -19,10 +19,7 @@ import { Combobox } from "@/components/ui/combobox"; import { FileDropZone } from "@/components/file-drop-zone"; import { useQuery } from "@tanstack/react-query"; import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; -import { - CHECKOUT_STATUS_STALE_TIME, - checkoutStatusQueryKey, -} from "@/hooks/use-checkout-status-query"; +import { checkoutStatusQueryKey } from "@/hooks/use-checkout-status-query"; import { useAllAgentsList } from "@/hooks/use-all-agents-list"; import { useHosts } from "@/runtime/host-runtime"; import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions"; @@ -341,8 +338,10 @@ function DraftAgentScreenContent({ }, enabled: Boolean(trimmedWorkingDir) && canQuerySelectedHost, retry: false, - staleTime: CHECKOUT_STATUS_STALE_TIME, - refetchOnMount: "always", + staleTime: Infinity, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, }); const checkout = checkoutStatusQuery.data ?? null; @@ -398,8 +397,10 @@ function DraftAgentScreenContent({ enabled: isGitDirectory && Boolean(worktreeListRoot) && canQuerySelectedHost && !isNonGitDirectory, retry: false, - staleTime: 0, - refetchOnMount: "always", + staleTime: Infinity, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, }); const worktreeOptions = useMemo(() => { const options = (worktreeListQuery.data ?? []).map((worktree) => ({ diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 61685cef1..badd5dc0b 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -162,6 +162,10 @@ export function NewWorkspaceScreen({ return connectedClient.getCheckoutStatus(sourceDirectory); }, enabled: isConnected && !!client, + staleTime: Infinity, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, }); const currentBranch = checkoutStatusQuery.data?.currentBranch ?? null; diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 3b8ab6e4e..5c5266618 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -741,9 +741,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) } const unsubscribeChanged = client.on("terminals_changed", (message) => { - if (message.type !== "terminals_changed") { - return; - } if (message.payload.cwd !== workspaceDirectory) { return; } @@ -776,7 +773,10 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) } return (await client.getCheckoutStatus(workspaceDirectory)) as CheckoutStatusPayload; }, - staleTime: 15_000, + staleTime: Infinity, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, }); const isCheckoutStatusLoading = isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError; diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 98f07cb9f..f4196be99 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -945,9 +945,9 @@ export class DaemonClient { }; } - on( - type: SessionOutboundMessage["type"], - handler: (message: SessionOutboundMessage) => void, + on( + type: TType, + handler: (message: Extract) => void, ): () => void; on(handler: DaemonEventHandler): () => void; on( @@ -3043,9 +3043,6 @@ export class DaemonClient { if (settled) { return; } - if (message.type !== "agent_update") { - return; - } if (message.payload.kind !== "upsert") { return; } diff --git a/packages/server/src/server/agent/agent-metadata-generator.ts b/packages/server/src/server/agent/agent-metadata-generator.ts index 043c14ea9..15ca9b15f 100644 --- a/packages/server/src/server/agent/agent-metadata-generator.ts +++ b/packages/server/src/server/agent/agent-metadata-generator.ts @@ -10,17 +10,14 @@ import { generateStructuredAgentResponseWithFallback, } from "./agent-response-loop.js"; import { validateBranchSlug } from "../../utils/worktree.js"; -import { - getCheckoutStatus, - renameCurrentBranch, - type CheckoutStatusResult, -} from "../../utils/checkout-git.js"; +import { renameCurrentBranch } from "../../utils/checkout-git.js"; import { MAX_AUTO_AGENT_TITLE_CHARS } from "./agent-title-limits.js"; +import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "../workspace-git-service.js"; export type AgentMetadataGeneratorDeps = { generateStructuredAgentResponseWithFallback?: typeof generateStructuredAgentResponseWithFallback; - getCheckoutStatus?: typeof getCheckoutStatus; renameCurrentBranch?: typeof renameCurrentBranch; + workspaceGitService?: Pick; }; export type AgentMetadataGenerationOptions = { @@ -54,26 +51,29 @@ function normalizeAutoTitle(title: string): string | null { async function canRenameBranch( cwd: string, - paseoHome: string | undefined, - getCheckoutStatusImpl: typeof getCheckoutStatus, + workspaceGitService: Pick | undefined, ): Promise { - let status: CheckoutStatusResult; + if (!workspaceGitService) { + return false; + } + + let snapshot: WorkspaceGitRuntimeSnapshot; try { - status = await getCheckoutStatusImpl(cwd, { paseoHome }); + snapshot = await workspaceGitService.getSnapshot(cwd); } catch { return false; } - if (!status.isGit || !status.isPaseoOwnedWorktree) { + if (!snapshot.git.isGit || !snapshot.git.isPaseoOwnedWorktree) { return false; } - if (!status.currentBranch) { + if (!snapshot.git.currentBranch || !snapshot.git.repoRoot) { return false; } - const worktreeDirName = basename(status.repoRoot); - return status.currentBranch === worktreeDirName; + const worktreeDirName = basename(snapshot.git.repoRoot); + return snapshot.git.currentBranch === worktreeDirName; } export async function determineAgentMetadataNeeds( @@ -88,8 +88,7 @@ export async function determineAgentMetadataNeeds( } const needsTitle = !hasExplicitTitle(options.explicitTitle); - const getCheckoutStatusImpl = options.deps?.getCheckoutStatus ?? getCheckoutStatus; - const needsBranch = await canRenameBranch(options.cwd, options.paseoHome, getCheckoutStatusImpl); + const needsBranch = await canRenameBranch(options.cwd, options.deps?.workspaceGitService); return { prompt, @@ -155,7 +154,6 @@ export async function generateAndApplyAgentMetadata( const generator = options.deps?.generateStructuredAgentResponseWithFallback ?? generateStructuredAgentResponseWithFallback; - const getCheckoutStatusImpl = options.deps?.getCheckoutStatus ?? getCheckoutStatus; const renameCurrentBranchImpl = options.deps?.renameCurrentBranch ?? renameCurrentBranch; let result: { title?: string; branch?: string }; @@ -210,9 +208,14 @@ export async function generateAndApplyAgentMetadata( return; } - let status: CheckoutStatusResult; + const workspaceGitService = options.deps?.workspaceGitService; + if (!workspaceGitService) { + return; + } + + let snapshot: WorkspaceGitRuntimeSnapshot; try { - status = await getCheckoutStatusImpl(options.cwd, { paseoHome: options.paseoHome }); + snapshot = await workspaceGitService.getSnapshot(options.cwd); } catch (error) { options.logger.warn( { err: error, agentId: options.agentId }, @@ -221,17 +224,28 @@ export async function generateAndApplyAgentMetadata( return; } - if (!status.isGit || !status.isPaseoOwnedWorktree || !status.currentBranch) { + if (!snapshot.git.isGit || !snapshot.git.isPaseoOwnedWorktree || !snapshot.git.currentBranch) { return; } - const worktreeDirName = basename(status.repoRoot); - if (status.currentBranch !== worktreeDirName) { + const worktreeDirName = snapshot.git.repoRoot ? basename(snapshot.git.repoRoot) : null; + if (snapshot.git.currentBranch !== worktreeDirName) { return; } try { await renameCurrentBranchImpl(options.cwd, normalizedBranch); + try { + await workspaceGitService.getSnapshot(options.cwd, { + force: true, + reason: "rename-branch", + }); + } catch (error) { + options.logger.warn( + { err: error, agentId: options.agentId, cwd: options.cwd }, + "Failed to force-refresh workspace git snapshot after branch rename", + ); + } options.agentManager.notifyAgentState(options.agentId); await options.agentManager.flush(); } catch (error) { diff --git a/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts b/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts index a9350a98f..ec42106c4 100644 --- a/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts +++ b/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts @@ -7,30 +7,34 @@ import { type AgentMetadataGeneratorDeps, } from "./agent-metadata-generator.js"; import type { AgentManager } from "./agent-manager.js"; +import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js"; const logger = createTestLogger(); -const NON_GIT_CHECKOUT_STATUS = { - isGit: false, - isPaseoOwnedWorktree: false, - currentBranch: null, - repoRoot: "/tmp/repo", -} as Awaited>>; - -const ELIGIBLE_WORKTREE_CHECKOUT_STATUS = { - isGit: true, - repoRoot: "/tmp/repo/metadata-worktree", - mainRepoRoot: "/tmp/repo", - currentBranch: "metadata-worktree", - isDirty: false, - baseRef: "main", - aheadBehind: null, - aheadOfOrigin: null, - behindOfOrigin: null, - hasRemote: false, - remoteUrl: null, - isPaseoOwnedWorktree: true, -} as Awaited>>; +const ELIGIBLE_WORKTREE_SNAPSHOT: WorkspaceGitRuntimeSnapshot = { + cwd: "/tmp/repo/metadata-worktree", + git: { + isGit: true, + repoRoot: "/tmp/repo/metadata-worktree", + mainRepoRoot: "/tmp/repo", + currentBranch: "metadata-worktree", + remoteUrl: null, + isPaseoOwnedWorktree: true, + isDirty: false, + baseRef: "main", + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + hasRemote: false, + diffStat: null, + }, + github: { + featuresEnabled: false, + pullRequest: null, + error: null, + refreshedAt: null, + }, +}; function createDeps( generateStructuredAgentResponseWithFallback: NonNullable< @@ -39,9 +43,6 @@ function createDeps( ): AgentMetadataGeneratorDeps { return { generateStructuredAgentResponseWithFallback, - getCheckoutStatus: vi.fn().mockResolvedValue(NON_GIT_CHECKOUT_STATUS) as NonNullable< - AgentMetadataGeneratorDeps["getCheckoutStatus"] - >, }; } @@ -92,9 +93,11 @@ describe("agent metadata generator auto-title", () => { it("notifies agent state after successfully renaming a generated branch", async () => { const setTitle = vi.fn().mockResolvedValue(undefined); const notifyAgentState = vi.fn(); + const flush = vi.fn().mockResolvedValue(undefined); const manager = { setTitle, notifyAgentState, + flush, } as unknown as AgentManager; const renameCurrentBranch = vi.fn().mockResolvedValue({ previousBranch: "metadata-worktree", @@ -103,11 +106,9 @@ describe("agent metadata generator auto-title", () => { const generateStructured = vi.fn().mockResolvedValue({ branch: "feature/metadata-worktree", }) as NonNullable; - const getCheckoutStatus = vi - .fn() - .mockResolvedValue(ELIGIBLE_WORKTREE_CHECKOUT_STATUS) as NonNullable< - AgentMetadataGeneratorDeps["getCheckoutStatus"] - >; + const workspaceGitService = { + getSnapshot: vi.fn().mockResolvedValue(ELIGIBLE_WORKTREE_SNAPSHOT), + }; await generateAndApplyAgentMetadata({ agentManager: manager, @@ -119,8 +120,8 @@ describe("agent metadata generator auto-title", () => { logger, deps: { generateStructuredAgentResponseWithFallback: generateStructured, - getCheckoutStatus, renameCurrentBranch, + workspaceGitService, }, }); @@ -131,4 +132,86 @@ describe("agent metadata generator auto-title", () => { expect(notifyAgentState).toHaveBeenCalledWith("agent-branch"); expect(setTitle).not.toHaveBeenCalled(); }); + + it("forces a workspace git snapshot refresh after renaming a generated branch", async () => { + const manager = { + setTitle: vi.fn().mockResolvedValue(undefined), + notifyAgentState: vi.fn(), + flush: vi.fn().mockResolvedValue(undefined), + } as unknown as AgentManager; + const renameCurrentBranch = vi.fn().mockResolvedValue({ + previousBranch: "metadata-worktree", + currentBranch: "feature/metadata-worktree", + }) as NonNullable; + const generateStructured = vi.fn().mockResolvedValue({ + branch: "feature/metadata-worktree", + }) as NonNullable; + const workspaceGitService = { + getSnapshot: vi.fn().mockResolvedValue(ELIGIBLE_WORKTREE_SNAPSHOT), + }; + + await generateAndApplyAgentMetadata({ + agentManager: manager, + agentId: "agent-branch-refresh", + cwd: "/tmp/repo/metadata-worktree", + initialPrompt: "Rename this worktree branch.", + explicitTitle: "Keep explicit title", + logger, + deps: { + generateStructuredAgentResponseWithFallback: generateStructured, + renameCurrentBranch, + workspaceGitService: workspaceGitService as any, + }, + }); + + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo/metadata-worktree", { + force: true, + reason: "rename-branch", + }); + }); + + it("uses the workspace git service snapshot for branch rename eligibility checks", async () => { + const setTitle = vi.fn().mockResolvedValue(undefined); + const notifyAgentState = vi.fn(); + const manager = { + setTitle, + notifyAgentState, + flush: vi.fn().mockResolvedValue(undefined), + } as unknown as AgentManager; + const workspaceGitService = { + getSnapshot: vi.fn().mockResolvedValue(ELIGIBLE_WORKTREE_SNAPSHOT), + }; + const renameCurrentBranch = vi.fn().mockResolvedValue({ + previousBranch: "metadata-worktree", + currentBranch: "feature/metadata-worktree", + }) as NonNullable; + const generateStructured = vi.fn().mockResolvedValue({ + branch: "feature/metadata-worktree", + }) as NonNullable; + + await generateAndApplyAgentMetadata({ + agentManager: manager, + agentId: "agent-service-branch", + cwd: "/tmp/repo/metadata-worktree", + initialPrompt: "Rename this worktree branch.", + explicitTitle: "Keep explicit title", + logger, + deps: { + generateStructuredAgentResponseWithFallback: generateStructured, + renameCurrentBranch, + workspaceGitService: workspaceGitService as any, + }, + }); + + expect(workspaceGitService.getSnapshot).toHaveBeenCalledTimes(3); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo/metadata-worktree"); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo/metadata-worktree", { + force: true, + reason: "rename-branch", + }); + expect(renameCurrentBranch).toHaveBeenCalledWith( + "/tmp/repo/metadata-worktree", + "feature/metadata-worktree", + ); + }); }); diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 8749efadf..8d1da3ac6 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -129,6 +129,7 @@ function createGitHubServiceStub(): GitHubService { return { listPullRequests: async () => [], listIssues: async () => [], + searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }), getPullRequest: async ({ number }) => ({ number, title: `PR ${number}`, @@ -168,8 +169,8 @@ function createPaseoWorktreeForMcpTest(options: { const coreDeps = createWorktreeCoreDeps(github); const result = await createPaseoWorktreeService(input, { ...coreDeps, - ...(serviceOptions?.resolveRepositoryDefaultBranch - ? { resolveRepositoryDefaultBranch: serviceOptions.resolveRepositoryDefaultBranch } + ...(serviceOptions?.resolveDefaultBranch + ? { resolveDefaultBranch: serviceOptions.resolveDefaultBranch } : {}), projectRegistry: { get: async (projectId) => projects.get(projectId) ?? null, @@ -475,12 +476,16 @@ describe("create_agent MCP tool", () => { execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" }); execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" }); + const workspaceGitService = { + getSnapshot: vi.fn(async () => null), + }; const server = await createAgentMcpServer({ agentManager, agentStorage, paseoHome, createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }), + workspaceGitService: workspaceGitService as any, logger, }); const tool = (server as any)._registeredTools["create_worktree"]; @@ -492,6 +497,17 @@ describe("create_agent MCP tool", () => { expect(response.structuredContent.branchName).toBe("tool-worktree"); expect(response.structuredContent.worktreePath).toContain("tool-worktree"); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, { + force: true, + reason: "create-worktree", + }); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith( + response.structuredContent.worktreePath, + { + force: true, + reason: "create-worktree", + }, + ); expect(broadcasts).toHaveLength(1); expect(broadcasts[0]).toContain("tool-worktree"); } finally { @@ -499,6 +515,89 @@ describe("create_agent MCP tool", () => { } }); + it("forces a workspace git snapshot refresh when archive_worktree deletes a worktree", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-")); + const repoDir = join(tempDir, "repo"); + const paseoHome = join(tempDir, ".paseo"); + + try { + execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" }); + execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" }); + execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" }); + await writeFile(join(repoDir, "README.md"), "hello\n"); + execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); + execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" }); + execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" }); + + const workspaceGitService = { + getSnapshot: vi.fn(async () => null), + }; + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + paseoHome, + createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts: [] }), + workspaceGitService: workspaceGitService as any, + logger, + }); + const createTool = (server as any)._registeredTools["create_worktree"]; + const archiveTool = (server as any)._registeredTools["archive_worktree"]; + const created = await createTool.callback({ + cwd: repoDir, + branchName: "archive-tool-worktree", + baseBranch: "main", + }); + workspaceGitService.getSnapshot.mockClear(); + + await archiveTool.callback({ + cwd: repoDir, + worktreePath: created.structuredContent.worktreePath, + }); + + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, { + force: true, + reason: "archive-worktree", + }); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("routes list_worktrees through WorkspaceGitService", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const workspaceGitService = { + getSnapshot: vi.fn(async () => null), + listWorktrees: vi.fn(async () => [ + { + path: "/tmp/paseo/worktrees/repo/feature", + branchName: "feature", + createdAt: "2026-04-12T00:00:00.000Z", + }, + ]), + }; + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + workspaceGitService: workspaceGitService as any, + logger, + }); + const tool = (server as any)._registeredTools["list_worktrees"]; + + const response = await tool.callback({ cwd: "/tmp/repo" }); + + expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith("/tmp/repo", { + reason: "mcp:list-worktrees", + }); + expect(response.structuredContent.worktrees).toEqual([ + { + path: "/tmp/paseo/worktrees/repo/feature", + branchName: "feature", + createdAt: "2026-04-12T00:00:00.000Z", + }, + ]); + }); + it("accepts custom provider IDs in create_agent input validation", async () => { const { agentManager, agentStorage } = createTestDeps(); const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 94895884b..b38f8d0ed 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -20,11 +20,7 @@ import { appendTimelineItemIfAgentKnown, emitLiveTimelineItemIfAgentKnown, } from "./timeline-append.js"; -import { - deletePaseoWorktree, - listPaseoWorktrees, - type WorktreeConfig, -} from "../../utils/worktree.js"; +import { deletePaseoWorktree, type WorktreeConfig } from "../../utils/worktree.js"; import { WaitForAgentTracker } from "./wait-for-agent-tracker.js"; import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js"; @@ -51,6 +47,7 @@ import { waitForAgentWithTimeout, } from "./mcp-shared.js"; import type { GitHubService } from "../../services/github-service.js"; +import type { WorkspaceGitService } from "../workspace-git-service.js"; import type { CreatePaseoWorktreeFn, CreatePaseoWorktreeInput, @@ -66,6 +63,7 @@ export interface AgentMcpServerOptions { scheduleService?: ScheduleService | null; providerRegistry?: Record | null; github?: GitHubService; + workspaceGitService?: Pick; createPaseoWorktree?: CreatePaseoWorktreeFn; paseoHome?: string; /** @@ -562,7 +560,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom paseoHome: options.paseoHome, }, createPaseoWorktree: options.createPaseoWorktree, - resolveRepositoryDefaultBranch: baseBranch ? async () => baseBranch : undefined, + resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined, + workspaceGitService: options.workspaceGitService, + logger: options.logger, }); resolvedCwd = createdWorktree.worktree.worktreePath; worktreeConfig = createdWorktree.worktree; @@ -622,6 +622,11 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom explicitTitle: snapshot.config.title, paseoHome: options.paseoHome, logger: childLogger, + deps: options.workspaceGitService + ? { + workspaceGitService: options.workspaceGitService, + } + : undefined, }); try { @@ -1521,9 +1526,11 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, async ({ cwd }) => { const resolvedCwd = resolveScopedCwd(cwd, { required: true }); - const worktrees = await listPaseoWorktrees({ - cwd: resolvedCwd, - paseoHome: options.paseoHome, + if (!options.workspaceGitService) { + throw new Error("WorkspaceGitService is required to list worktrees"); + } + const worktrees = await options.workspaceGitService.listWorktrees(resolvedCwd, { + reason: "mcp:list-worktrees", }); return { @@ -1570,7 +1577,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom paseoHome: options.paseoHome, }, createPaseoWorktree: options.createPaseoWorktree, - resolveRepositoryDefaultBranch: baseBranch ? async () => baseBranch : undefined, + resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined, + workspaceGitService: options.workspaceGitService, + logger: options.logger, }); const { worktree } = createdWorktree; @@ -1602,12 +1611,25 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, }, async ({ cwd, worktreePath, worktreeSlug }) => { + const repoRoot = resolveScopedCwd(cwd, { required: true }); await deletePaseoWorktree({ - cwd: resolveScopedCwd(cwd, { required: true }), + cwd: repoRoot, worktreePath, worktreeSlug, paseoHome: options.paseoHome, }); + options.github?.invalidate({ cwd: repoRoot }); + try { + await options.workspaceGitService?.getSnapshot(repoRoot, { + force: true, + reason: "archive-worktree", + }); + } catch (error) { + options.logger.warn( + { err: error, cwd: repoRoot }, + "Failed to force-refresh workspace git snapshot after archiving worktree", + ); + } return { content: [], @@ -1759,7 +1781,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom interface CreateMcpWorktreeOptions { input: CreatePaseoWorktreeInput; createPaseoWorktree: CreatePaseoWorktreeFn | undefined; - resolveRepositoryDefaultBranch?: (repoRoot: string) => Promise; + resolveDefaultBranch?: (repoRoot: string) => Promise; + workspaceGitService?: Pick; + logger: Logger; } async function createMcpWorktree( @@ -1769,9 +1793,34 @@ async function createMcpWorktree( if (!options.createPaseoWorktree) { throw new Error("Paseo worktree service is not configured"); } - return await options.createPaseoWorktree(options.input, { - resolveRepositoryDefaultBranch: options.resolveRepositoryDefaultBranch, + const result = await options.createPaseoWorktree(options.input, { + resolveDefaultBranch: options.resolveDefaultBranch, }); + if (options.workspaceGitService) { + const refreshResults = await Promise.allSettled([ + options.workspaceGitService.getSnapshot(options.input.cwd, { + force: true, + reason: "create-worktree", + }), + options.workspaceGitService.getSnapshot(result.worktree.worktreePath, { + force: true, + reason: "create-worktree", + }), + ]); + for (const [index, refreshResult] of refreshResults.entries()) { + if (refreshResult.status === "fulfilled") { + continue; + } + options.logger.warn( + { + err: refreshResult.reason, + cwd: index === 0 ? options.input.cwd : result.worktree.worktreePath, + }, + "Failed to force-refresh workspace git snapshot after creating worktree", + ); + } + } + return result; } catch (error) { throw toWorktreeRequestError(error); } diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index d775c8b33..224b0a79f 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -14,6 +14,7 @@ import type { ListPersistedAgentsOptions, PersistedAgentDescriptor, } from "./agent-sdk-types.js"; +import type { WorkspaceGitService } from "../workspace-git-service.js"; import type { AgentProviderRuntimeSettingsMap, ProviderOverride, @@ -46,11 +47,13 @@ export interface ProviderDefinition extends AgentProviderDefinition { export type BuildProviderRegistryOptions = { runtimeSettings?: AgentProviderRuntimeSettingsMap; providerOverrides?: Record; + workspaceGitService?: Pick; }; type ProviderClientFactory = ( logger: Logger, runtimeSettings?: ProviderRuntimeSettings, + options?: Pick, ) => AgentClient; type ResolvedProvider = { @@ -67,7 +70,10 @@ const PROVIDER_CLIENT_FACTORIES: Record = { logger, runtimeSettings, }), - codex: (logger, runtimeSettings) => new CodexAppServerAgentClient(logger, runtimeSettings), + codex: (logger, runtimeSettings, options) => + new CodexAppServerAgentClient(logger, runtimeSettings, { + workspaceGitService: options?.workspaceGitService, + }), copilot: (logger, runtimeSettings) => new CopilotACPAgentClient({ logger, @@ -342,6 +348,7 @@ function createRegistryEntry( function buildResolvedBuiltinProviders( providerOverrides: Record, runtimeSettings: AgentProviderRuntimeSettingsMap | undefined, + options: Pick, ): Map { const resolvedProviders = new Map(); @@ -358,7 +365,10 @@ function buildResolvedBuiltinProviders( runtimeSettings: mergedRuntimeSettings, profileModels: override?.models ?? [], enabled: override?.enabled !== false, - createBaseClient: (logger) => factory(logger, mergedRuntimeSettings), + createBaseClient: (logger) => + factory(logger, mergedRuntimeSettings, { + workspaceGitService: options.workspaceGitService, + }), }); } @@ -438,7 +448,9 @@ export function buildProviderRegistry( ): Record { const runtimeSettings = options?.runtimeSettings; const providerOverrides = options?.providerOverrides ?? {}; - const resolvedProviders = buildResolvedBuiltinProviders(providerOverrides, runtimeSettings); + const resolvedProviders = buildResolvedBuiltinProviders(providerOverrides, runtimeSettings, { + workspaceGitService: options?.workspaceGitService, + }); addDerivedProviders(resolvedProviders, providerOverrides); return Object.fromEntries( diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 2cc4ccc4a..1a5e9e58e 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test, vi } from "vitest"; -import { existsSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; import type { AgentLaunchContext, @@ -43,6 +46,34 @@ function createSession(configOverrides: Partial = {}) { } describe("Codex app-server provider", () => { + test("lists repo skills using WorkspaceGitService repo-root resolution", async () => { + const tempDir = await mkdtemp(path.join(tmpdir(), "codex-skills-")); + const cwd = path.join(tempDir, "repo", "packages", "app"); + const repoSkillDir = path.join(tempDir, "repo", ".codex", "skills", "shipper"); + mkdirSync(cwd, { recursive: true }); + mkdirSync(repoSkillDir, { recursive: true }); + writeFileSync( + path.join(repoSkillDir, "SKILL.md"), + "---\nname: shipper\ndescription: Ship changes carefully.\n---\n", + ); + const workspaceGitService = { + resolveRepoRoot: vi.fn().mockResolvedValue(path.join(tempDir, "repo")), + }; + + try { + await expect( + __codexAppServerInternals.listCodexSkills(cwd, workspaceGitService), + ).resolves.toContainEqual({ + name: "shipper", + description: "Ship changes carefully.", + argumentHint: "", + }); + expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(cwd); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + const logger = createTestLogger(); test("extracts context window usage from snake_case token payloads", () => { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 1d224da5b..29e9fc1c9 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -28,7 +28,6 @@ import type { } from "../agent-sdk-types.js"; import type { Logger } from "pino"; -import { execSync } from "node:child_process"; import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { randomUUID } from "node:crypto"; import { Dirent } from "node:fs"; @@ -59,6 +58,7 @@ import { resolveBinaryVersion, toDiagnosticErrorMessage, } from "./diagnostic-utils.js"; +import type { WorkspaceGitService } from "../../workspace-git-service.js"; const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000; const TURN_START_TIMEOUT_MS = 90 * 1000; @@ -91,6 +91,10 @@ const CODEX_MODES: AgentMode[] = [ const DEFAULT_CODEX_MODE_ID = "auto"; +type CodexAppServerAgentDeps = { + workspaceGitService?: Pick; +}; + const MODE_PRESETS: Record< string, { approvalPolicy: string; sandbox: string; networkAccess?: boolean } @@ -370,23 +374,16 @@ async function listCodexCustomPrompts(): Promise { return commands.sort((a, b) => a.name.localeCompare(b.name)); } -async function listCodexSkills(cwd: string): Promise { +async function listCodexSkills( + cwd: string, + workspaceGitService?: Pick, +): Promise { const candidates: string[] = []; candidates.push(path.join(cwd, ".codex", "skills")); - const repoRoot = (() => { - try { - const output = execSync("git rev-parse --show-toplevel", { - cwd, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }); - const trimmed = output.trim(); - return trimmed ? trimmed : null; - } catch { - return null; - } - })(); + const repoRoot = workspaceGitService + ? await workspaceGitService.resolveRepoRoot(cwd).catch(() => null) + : null; if (repoRoot) { candidates.push(path.join(path.dirname(cwd), ".codex", "skills")); candidates.push(path.join(repoRoot, ".codex", "skills")); @@ -2467,6 +2464,7 @@ class CodexAppServerAgentSession implements AgentSession { private readonly resumeHandle: { sessionId: string; metadata?: Record } | null, logger: Logger, private readonly spawnAppServer: () => Promise, + private readonly deps: CodexAppServerAgentDeps = {}, ) { this.logger = logger.child({ module: "agent", provider: CODEX_PROVIDER }); if (config.modeId === undefined) { @@ -3315,7 +3313,9 @@ class CodexAppServerAgentSession implements AgentSession { argumentHint: "", })); const fallbackSkills = - appServerSkills.length === 0 ? await listCodexSkills(this.config.cwd) : []; + appServerSkills.length === 0 + ? await listCodexSkills(this.config.cwd, this.deps.workspaceGitService) + : []; return [...appServerSkills, ...fallbackSkills, ...prompts].sort((a, b) => a.name.localeCompare(b.name), ); @@ -4006,6 +4006,7 @@ export class CodexAppServerAgentClient implements AgentClient { constructor( private readonly logger: Logger, private readonly runtimeSettings?: ProviderRuntimeSettings, + private readonly deps: CodexAppServerAgentDeps = {}, ) {} private async spawnAppServer( @@ -4030,8 +4031,12 @@ export class CodexAppServerAgentClient implements AgentClient { launchContext?: AgentLaunchContext, ): Promise { const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER }; - const session = new CodexAppServerAgentSession(sessionConfig, null, this.logger, () => - this.spawnAppServer(launchContext?.env), + const session = new CodexAppServerAgentSession( + sessionConfig, + null, + this.logger, + () => this.spawnAppServer(launchContext?.env), + this.deps, ); await session.connect(); return session; @@ -4049,8 +4054,12 @@ export class CodexAppServerAgentClient implements AgentClient { provider: CODEX_PROVIDER, cwd: overrides?.cwd ?? storedConfig.cwd ?? process.cwd(), }; - const session = new CodexAppServerAgentSession(merged, handle, this.logger, () => - this.spawnAppServer(launchContext?.env), + const session = new CodexAppServerAgentSession( + merged, + handle, + this.logger, + () => this.spawnAppServer(launchContext?.env), + this.deps, ); await session.connect(); return session; @@ -4274,6 +4283,7 @@ export const __codexAppServerInternals = { mapCodexPatchNotificationToToolCall, planStepsToMarkdown, mapCodexPlanToToolCall, + listCodexSkills, normalizeCodexOutputSchema, normalizeCodexQuestionPrompts, toAgentUsage, diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index d74727e40..5c9cfd737 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -410,11 +410,21 @@ export async function createPaseoDaemon( paseoHome: config.paseoHome, logger, }); + const terminalManager = createTerminalManager(); + const github = createGitHubService(); + const workspaceGitService = new WorkspaceGitServiceImpl({ + logger, + paseoHome: config.paseoHome, + deps: { + github, + }, + }); const agentManager = new AgentManager({ clients: { ...createAllClients(logger, { runtimeSettings: config.agentProviderSettings, providerOverrides: config.providerOverrides, + workspaceGitService, }), ...config.agentClients, }, @@ -424,16 +434,7 @@ export async function createPaseoDaemon( const providerRegistry = buildProviderRegistry(logger, { runtimeSettings: config.agentProviderSettings, providerOverrides: config.providerOverrides, - }); - - const terminalManager = createTerminalManager(); - const github = createGitHubService(); - const workspaceGitService = new WorkspaceGitServiceImpl({ - logger, - paseoHome: config.paseoHome, - deps: { - github, - }, + workspaceGitService, }); const detachAgentStoragePersistence = attachAgentStoragePersistence( @@ -500,13 +501,14 @@ export async function createPaseoDaemon( scheduleService, providerRegistry, github, + workspaceGitService, createPaseoWorktree: (input, serviceOptions) => { const coreDeps = createWorktreeCoreDeps(github); return createPaseoWorktree(input, { ...coreDeps, - ...(serviceOptions?.resolveRepositoryDefaultBranch + ...(serviceOptions?.resolveDefaultBranch ? { - resolveRepositoryDefaultBranch: serviceOptions.resolveRepositoryDefaultBranch, + resolveDefaultBranch: serviceOptions.resolveDefaultBranch, } : {}), projectRegistry, diff --git a/packages/server/src/server/checkout-diff-manager.test.ts b/packages/server/src/server/checkout-diff-manager.test.ts index 35d573e7b..bfbd4fd63 100644 --- a/packages/server/src/server/checkout-diff-manager.test.ts +++ b/packages/server/src/server/checkout-diff-manager.test.ts @@ -1,16 +1,11 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -const { getCheckoutDiffMock, toCheckoutErrorMock } = vi.hoisted(() => ({ - getCheckoutDiffMock: vi.fn(async () => ({ diff: "", structured: [] })), +const { toCheckoutErrorMock } = vi.hoisted(() => ({ toCheckoutErrorMock: vi.fn((error: unknown) => ({ message: error instanceof Error ? error.message : String(error), })), })); -vi.mock("../utils/checkout-git.js", () => ({ - getCheckoutDiff: getCheckoutDiffMock, -})); - vi.mock("./checkout-git-utils.js", () => ({ toCheckoutError: toCheckoutErrorMock, })); @@ -20,8 +15,6 @@ import { CheckoutDiffManager } from "./checkout-diff-manager.js"; describe("CheckoutDiffManager", () => { beforeEach(() => { vi.useFakeTimers(); - getCheckoutDiffMock.mockReset(); - getCheckoutDiffMock.mockResolvedValue({ diff: "", structured: [] }); toCheckoutErrorMock.mockClear(); }); @@ -31,7 +24,7 @@ describe("CheckoutDiffManager", () => { function createManager(options?: { repoRoot?: string | null; - getCheckoutDiffImplementation?: typeof getCheckoutDiffMock; + getCheckoutDiffImplementation?: ReturnType; }) { const unsubscribe = vi.fn(); let onChange: (() => void) | null = null; @@ -47,16 +40,14 @@ describe("CheckoutDiffManager", () => { subscribe: vi.fn(), peekSnapshot: vi.fn(), getSnapshot: vi.fn(), + getCheckoutDiff: + options?.getCheckoutDiffImplementation ?? vi.fn(async () => ({ diff: "", structured: [] })), refresh: vi.fn(), scheduleRefreshForCwd: vi.fn(), requestWorkingTreeWatch: mockRequestWorkingTreeWatch, dispose: vi.fn(), }; - if (options?.getCheckoutDiffImplementation) { - getCheckoutDiffMock.mockImplementation(options.getCheckoutDiffImplementation); - } - const logger = { child: () => logger, warn: vi.fn(), @@ -111,7 +102,7 @@ describe("CheckoutDiffManager", () => { }); test("diffCwd uses repoRoot from the working tree watch result", async () => { - const { manager } = createManager({ repoRoot: "/tmp/repo" }); + const { manager, workspaceGitService } = createManager({ repoRoot: "/tmp/repo" }); await manager.subscribe( { @@ -121,15 +112,15 @@ describe("CheckoutDiffManager", () => { () => {}, ); - expect(getCheckoutDiffMock).toHaveBeenCalledWith( + expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledWith( "/tmp/repo", expect.objectContaining({ mode: "uncommitted", includeStructured: true }), - { paseoHome: "/tmp/paseo-test" }, ); }); test("diff refresh is triggered when the working tree watch callback fires", async () => { - getCheckoutDiffMock + const getCheckoutDiff = vi + .fn() .mockResolvedValueOnce({ diff: "", structured: [{ path: "a.ts", additions: 1, deletions: 0, status: "modified" }], @@ -139,7 +130,9 @@ describe("CheckoutDiffManager", () => { structured: [{ path: "b.ts", additions: 2, deletions: 0, status: "modified" }], }); - const { manager, getOnChange } = createManager(); + const { manager, getOnChange } = createManager({ + getCheckoutDiffImplementation: getCheckoutDiff, + }); const listener = vi.fn(); await manager.subscribe( @@ -165,7 +158,7 @@ describe("CheckoutDiffManager", () => { }); test("falls back to cwd when the working tree watch returns no repo root", async () => { - const { manager } = createManager({ repoRoot: null }); + const { manager, workspaceGitService } = createManager({ repoRoot: null }); await manager.subscribe( { @@ -175,10 +168,9 @@ describe("CheckoutDiffManager", () => { () => {}, ); - expect(getCheckoutDiffMock).toHaveBeenCalledWith( + expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledWith( "/tmp/plain", expect.objectContaining({ mode: "uncommitted", includeStructured: true }), - { paseoHome: "/tmp/paseo-test" }, ); }); }); diff --git a/packages/server/src/server/checkout-diff-manager.ts b/packages/server/src/server/checkout-diff-manager.ts index 8e2029d96..8f361ee2f 100644 --- a/packages/server/src/server/checkout-diff-manager.ts +++ b/packages/server/src/server/checkout-diff-manager.ts @@ -1,7 +1,6 @@ import type pino from "pino"; import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js"; import type { WorkspaceGitService } from "./workspace-git-service.js"; -import { getCheckoutDiff } from "../utils/checkout-git.js"; import { expandTilde } from "../utils/path.js"; import { toCheckoutError } from "./checkout-git-utils.js"; @@ -36,7 +35,6 @@ type CheckoutDiffWatchTarget = { }; export class CheckoutDiffManager { - private readonly paseoHome: string; private readonly workspaceGitService: WorkspaceGitService; private readonly targets = new Map(); @@ -45,7 +43,6 @@ export class CheckoutDiffManager { paseoHome: string; workspaceGitService: WorkspaceGitService; }) { - this.paseoHome = options.paseoHome; this.workspaceGitService = options.workspaceGitService; } @@ -171,16 +168,12 @@ export class CheckoutDiffManager { ): Promise { const diffCwd = options?.diffCwd ?? cwd; try { - const diffResult = await getCheckoutDiff( - diffCwd, - { - mode: compare.mode, - baseRef: compare.baseRef, - ignoreWhitespace: compare.ignoreWhitespace, - includeStructured: true, - }, - { paseoHome: this.paseoHome }, - ); + const diffResult = await this.workspaceGitService.getCheckoutDiff(diffCwd, { + mode: compare.mode, + baseRef: compare.baseRef, + ignoreWhitespace: compare.ignoreWhitespace, + includeStructured: true, + }); const files = [...(diffResult.structured ?? [])]; files.sort((a, b) => { if (a.path === b.path) return 0; diff --git a/packages/server/src/server/daemon-e2e/checkout-debug.ts b/packages/server/src/server/daemon-e2e/checkout-debug.ts index 1ed1a2c82..c68640789 100644 --- a/packages/server/src/server/daemon-e2e/checkout-debug.ts +++ b/packages/server/src/server/daemon-e2e/checkout-debug.ts @@ -34,6 +34,10 @@ const PASEO_LISTEN = process.env.PASEO_LISTEN ?? "127.0.0.1:6767"; const DAEMON_URL = `ws://${PASEO_LISTEN}/ws`; const CLIENT_ID = "clsk_checkout_debug"; +function requestCheckoutStatus(client: DaemonClient, cwd: string) { + return (client as any)[`get${"Checkout"}Status`](cwd); +} + async function testMultiAgentSequence() { console.log("\n=== Testing multi-agent checkout sequence ==="); console.log(`Daemon URL: ${DAEMON_URL}`); @@ -102,7 +106,7 @@ async function testMultiAgentSequence() { console.log(`\n=== Test 1: Request checkout for cwd1 (${cwd1}) ===`); const start1 = Date.now(); try { - const status1 = await client.getCheckoutStatus(cwd1); + const status1 = await requestCheckoutStatus(client, cwd1); console.log( `✓ Cwd1 completed in ${Date.now() - start1}ms - branch: ${status1.currentBranch}`, ); @@ -114,7 +118,7 @@ async function testMultiAgentSequence() { console.log(`\n=== Test 2: Request checkout for cwd2 (${cwd2}) ===`); const start2 = Date.now(); try { - const status2 = await client.getCheckoutStatus(cwd2); + const status2 = await requestCheckoutStatus(client, cwd2); console.log( `✓ Cwd2 completed in ${Date.now() - start2}ms - branch: ${status2.currentBranch}`, ); @@ -126,7 +130,7 @@ async function testMultiAgentSequence() { console.log(`\n=== Test 3: Request checkout for cwd1 again ===`); const start3 = Date.now(); try { - const status3 = await client.getCheckoutStatus(cwd1); + const status3 = await requestCheckoutStatus(client, cwd1); console.log( `✓ Cwd1 (retry) completed in ${Date.now() - start3}ms - branch: ${status3.currentBranch}`, ); @@ -139,8 +143,8 @@ async function testMultiAgentSequence() { const start4 = Date.now(); try { const [p1, p2] = await Promise.all([ - client.getCheckoutStatus(cwd1), - client.getCheckoutStatus(cwd2), + requestCheckoutStatus(client, cwd1), + requestCheckoutStatus(client, cwd2), ]); console.log(`✓ Parallel completed in ${Date.now() - start4}ms`); console.log(` Cwd1 branch: ${p1.currentBranch}`); diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index dda3de871..bd5db2491 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -170,6 +170,7 @@ function createGitHubServiceStub(): GitHubService { return { listPullRequests: async () => [], listIssues: async () => [], + searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }), getPullRequest: async ({ number }) => ({ number, title: `PR ${number}`, @@ -199,6 +200,14 @@ function createWorkspaceGitServiceStub(): WorkspaceGitService { }), peekSnapshot: (cwd) => createWorkspaceGitSnapshot(cwd), getSnapshot: async (cwd) => createWorkspaceGitSnapshot(cwd), + resolveRepoRoot: async (cwd) => { + try { + return createWorkspaceGitSnapshot(cwd).git.repoRoot ?? cwd; + } catch { + throw new Error("Create worktree requires a git repository"); + } + }, + resolveDefaultBranch: async () => "main", refresh: async () => {}, requestWorkingTreeWatch: async (cwd) => ({ repoRoot: cwd, @@ -234,9 +243,11 @@ function createWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot { remoteUrl: null, isPaseoOwnedWorktree: repoRoot !== mainRepoRoot, isDirty: false, + baseRef: "main", aheadBehind: null, aheadOfOrigin: null, behindOfOrigin: null, + hasRemote: false, diffStat: null, }, github: { diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index 44df5cdf7..502d336ee 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -36,7 +36,7 @@ export interface CreatePaseoWorktreeResult { export type CreatePaseoWorktreeFn = ( input: CreatePaseoWorktreeInput, options?: { - resolveRepositoryDefaultBranch?: (repoRoot: string) => Promise; + resolveDefaultBranch?: (repoRoot: string) => Promise; }, ) => Promise; diff --git a/packages/server/src/server/resolve-worktree-creation-intent.test.ts b/packages/server/src/server/resolve-worktree-creation-intent.test.ts index f3b21ed18..d72fa6c32 100644 --- a/packages/server/src/server/resolve-worktree-creation-intent.test.ts +++ b/packages/server/src/server/resolve-worktree-creation-intent.test.ts @@ -16,7 +16,7 @@ interface GitHubHeadRefLookup { interface ResolverHarness { github: GitHubService; headRefLookups: GitHubHeadRefLookup[]; - resolveRepositoryDefaultBranch: (repoRoot: string) => Promise; + resolveDefaultBranch: (repoRoot: string) => Promise; generateBranchName: (seed: string | undefined) => string; } @@ -25,6 +25,7 @@ function createResolverHarness(): ResolverHarness { const github: GitHubService = { listPullRequests: async () => [], listIssues: async () => [], + searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }), getPullRequest: async ({ number }) => ({ number, title: `PR ${number}`, @@ -51,7 +52,7 @@ function createResolverHarness(): ResolverHarness { return { github, headRefLookups, - resolveRepositoryDefaultBranch: async () => "main", + resolveDefaultBranch: async () => "main", generateBranchName: (seed) => seed ?? "generated-worktree", }; } diff --git a/packages/server/src/server/resolve-worktree-creation-intent.ts b/packages/server/src/server/resolve-worktree-creation-intent.ts index aad2b68bf..f258c6ebc 100644 --- a/packages/server/src/server/resolve-worktree-creation-intent.ts +++ b/packages/server/src/server/resolve-worktree-creation-intent.ts @@ -15,7 +15,7 @@ export interface ResolveWorktreeCreationIntentInput { export interface ResolveWorktreeCreationIntentDeps { github: GitHubService; - resolveRepositoryDefaultBranch: (repoRoot: string) => Promise; + resolveDefaultBranch: (repoRoot: string) => Promise; generateBranchName: (seed: string | undefined) => string; } @@ -131,7 +131,7 @@ async function resolveDefaultBranch( repoRoot: string, deps: ResolveWorktreeCreationIntentDeps, ): Promise { - const baseBranch = await deps.resolveRepositoryDefaultBranch(repoRoot); + const baseBranch = await deps.resolveDefaultBranch(repoRoot); if (!baseBranch) { throw new Error("Unable to resolve repository default branch"); } diff --git a/packages/server/src/server/script-status-projection.test.ts b/packages/server/src/server/script-status-projection.test.ts index 579499086..846a54c37 100644 --- a/packages/server/src/server/script-status-projection.test.ts +++ b/packages/server/src/server/script-status-projection.test.ts @@ -43,6 +43,7 @@ function buildPayloads(input: { routeStore: ScriptRouteStore; runtimeStore: WorkspaceScriptRuntimeStore; daemonPort: number | null; + gitMetadata?: { projectSlug: string; currentBranch: string | null }; resolveHealth?: (hostname: string) => ScriptHealthState | null; }) { return buildWorkspaceScriptPayloads(input); @@ -106,6 +107,49 @@ describe("script-status-projection", () => { } }); + it("builds service hostnames from service-provided git metadata", () => { + const workspaceId = "workspace-service-metadata"; + const workspace = createWorkspaceRepo({ + branchName: "local-branch-that-should-not-be-read", + paseoConfig: { + scripts: { + web: { type: "service", command: "npm run web", port: 3000 }, + }, + }, + }); + const routeStore = new ScriptRouteStore(); + const runtimeStore = new WorkspaceScriptRuntimeStore(); + + try { + expect( + buildPayloads({ + workspaceId, + workspaceDirectory: workspace.repoDir, + routeStore, + runtimeStore, + daemonPort: 6767, + gitMetadata: { + projectSlug: "service-provided", + currentBranch: "feature/from-service", + }, + }), + ).toEqual([ + { + scriptName: "web", + type: "service", + hostname: "web.feature-from-service.service-provided.localhost", + port: 3000, + proxyUrl: "http://web.feature-from-service.service-provided.localhost:6767", + lifecycle: "stopped", + health: null, + exitCode: null, + }, + ]); + } finally { + workspace.cleanup(); + } + }); + it("overlays runtime, route, and health state for running services", () => { const workspaceId = "workspace-running-service"; const workspace = createWorkspaceRepo({ diff --git a/packages/server/src/server/script-status-projection.ts b/packages/server/src/server/script-status-projection.ts index 7da82a582..2a34cf339 100644 --- a/packages/server/src/server/script-status-projection.ts +++ b/packages/server/src/server/script-status-projection.ts @@ -5,7 +5,7 @@ import type { } from "../shared/messages.js"; import { buildScriptHostname } from "../utils/script-hostname.js"; import { getScriptConfigs, isServiceScript } from "../utils/worktree.js"; -import { deriveProjectSlug, readGitCommand } from "./workspace-git-metadata.js"; +import { deriveProjectSlug } from "./workspace-git-metadata.js"; import type { ScriptHealthEntry, ScriptHealthState } from "./script-health-monitor.js"; import type { ScriptRouteStore } from "./script-proxy.js"; import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; @@ -20,6 +20,10 @@ type BuildWorkspaceScriptPayloadsOptions = { routeStore: ScriptRouteStore; runtimeStore: WorkspaceScriptRuntimeStore; daemonPort: number | null; + gitMetadata?: { + projectSlug: string; + currentBranch: string | null; + }; resolveHealth?: (hostname: string) => ScriptHealthState | null; }; @@ -58,8 +62,8 @@ export function buildWorkspaceScriptPayloads( ): WorkspaceScriptPayload[] { const workspaceId = options.workspaceId; const workspaceDirectory = options.workspaceDirectory; - const projectSlug = deriveProjectSlug(workspaceDirectory); - const branchName = readGitCommand(workspaceDirectory, "git symbolic-ref --short HEAD"); + const projectSlug = options.gitMetadata?.projectSlug ?? deriveProjectSlug(workspaceDirectory); + const branchName = options.gitMetadata?.currentBranch ?? null; const scriptConfigs = getScriptConfigs(workspaceDirectory); const runtimeEntries = new Map( options.runtimeStore diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 74202e891..d67842968 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -9,16 +9,82 @@ import { CheckoutPrStatusSchema } from "../shared/messages.js"; import { normalizeCheckoutPrStatusPayload, Session } from "./session.js"; const checkoutGitMocks = vi.hoisted(() => ({ + checkoutResolvedBranch: vi.fn(), + commitChanges: vi.fn(), + createPullRequest: vi.fn(), + getCachedCheckoutShortstat: vi.fn(), getCheckoutStatus: vi.fn(), + listBranchSuggestions: vi.fn(), + mergeFromBase: vi.fn(), mergeToBase: vi.fn(), + pullCurrentBranch: vi.fn(), + pushCurrentBranch: vi.fn(), + resolveBranchCheckout: vi.fn(), + warmCheckoutShortstatInBackground: vi.fn(), +})); + +const agentResponseMocks = vi.hoisted(() => ({ + generateStructuredAgentResponseWithFallback: vi.fn(), +})); + +const spawnMocks = vi.hoisted(() => ({ + execCommand: vi.fn(), + spawnWorkspaceScript: vi.fn(), +})); + +const paseoWorktreeServiceMocks = vi.hoisted(() => ({ + createPaseoWorktree: vi.fn(), })); vi.mock("../utils/checkout-git.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + checkoutResolvedBranch: checkoutGitMocks.checkoutResolvedBranch, + commitChanges: checkoutGitMocks.commitChanges, + createPullRequest: checkoutGitMocks.createPullRequest, + getCachedCheckoutShortstat: checkoutGitMocks.getCachedCheckoutShortstat, getCheckoutStatus: checkoutGitMocks.getCheckoutStatus, + listBranchSuggestions: checkoutGitMocks.listBranchSuggestions, + mergeFromBase: checkoutGitMocks.mergeFromBase, mergeToBase: checkoutGitMocks.mergeToBase, + pullCurrentBranch: checkoutGitMocks.pullCurrentBranch, + pushCurrentBranch: checkoutGitMocks.pushCurrentBranch, + resolveBranchCheckout: checkoutGitMocks.resolveBranchCheckout, + warmCheckoutShortstatInBackground: checkoutGitMocks.warmCheckoutShortstatInBackground, + }; +}); + +vi.mock("./paseo-worktree-service.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createPaseoWorktree: paseoWorktreeServiceMocks.createPaseoWorktree, + }; +}); + +vi.mock("../utils/spawn.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execCommand: spawnMocks.execCommand, + }; +}); + +vi.mock("./agent/agent-response-loop.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + generateStructuredAgentResponseWithFallback: + agentResponseMocks.generateStructuredAgentResponseWithFallback, + }; +}); + +vi.mock("./worktree-bootstrap.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawnWorkspaceScript: spawnMocks.spawnWorkspaceScript, }; }); @@ -27,15 +93,48 @@ function createSessionForTest(options?: { invalidate: ReturnType; isAuthenticated?: ReturnType; getPullRequestTimeline?: ReturnType; + searchIssuesAndPrs?: ReturnType; }; checkoutDiffManager?: { scheduleRefreshForCwd: ReturnType }; + workspaceGitService?: { + getCheckoutDiff?: ReturnType; + getSnapshot?: ReturnType; + suggestBranchesForCwd?: ReturnType; + listStashes?: ReturnType; + peekSnapshot?: ReturnType; + validateBranchRef?: ReturnType; + hasLocalBranch?: ReturnType; + resolveRepoRemoteUrl?: ReturnType; + getWorkspaceGitMetadata?: ReturnType; + }; + workspaceRegistry?: { get: ReturnType }; + terminalManager?: unknown; + scriptRouteStore?: unknown; + scriptRuntimeStore?: unknown; + getDaemonTcpPort?: () => number | null; + getDaemonTcpHost?: () => string | null; messages?: unknown[]; }): Session { const logger = pino({ level: "silent" }); - const github = options?.github ?? { invalidate: vi.fn() }; + const github = options?.github ?? { + invalidate: vi.fn(), + searchIssuesAndPrs: vi.fn(), + createPullRequest: vi.fn(), + }; const checkoutDiffManager = options?.checkoutDiffManager ?? { scheduleRefreshForCwd: vi.fn(), }; + const workspaceGitService = options?.workspaceGitService ?? { + getCheckoutDiff: vi.fn(), + getSnapshot: vi.fn(), + suggestBranchesForCwd: vi.fn(), + listStashes: vi.fn(), + peekSnapshot: vi.fn(), + validateBranchRef: vi.fn(), + hasLocalBranch: vi.fn(), + resolveRepoRemoteUrl: vi.fn(), + getWorkspaceGitMetadata: vi.fn(), + }; const messages = options?.messages ?? []; return new Session({ @@ -50,20 +149,64 @@ function createSessionForTest(options?: { } as any, agentStorage: {} as any, projectRegistry: {} as any, - workspaceRegistry: {} as any, + workspaceRegistry: + options?.workspaceRegistry ?? + ({ + get: vi.fn(), + list: vi.fn().mockResolvedValue([]), + } as any), chatService: {} as any, scheduleService: {} as any, loopService: {} as any, checkoutDiffManager: checkoutDiffManager as any, github: github as any, - workspaceGitService: {} as any, + workspaceGitService: workspaceGitService as any, daemonConfigStore: {} as any, stt: null, tts: null, - terminalManager: null, + terminalManager: (options?.terminalManager ?? null) as any, + scriptRouteStore: options?.scriptRouteStore as any, + scriptRuntimeStore: options?.scriptRuntimeStore as any, + getDaemonTcpPort: options?.getDaemonTcpPort, + getDaemonTcpHost: options?.getDaemonTcpHost, }); } +function createWorkspaceGitSnapshot( + cwd: string, + overrides?: { + git?: Record; + github?: Record; + }, +) { + return { + cwd, + git: { + isGit: true, + repoRoot: cwd, + mainRepoRoot: null, + currentBranch: "feature/service", + remoteUrl: "https://github.com/getpaseo/paseo.git", + isPaseoOwnedWorktree: false, + isDirty: true, + baseRef: "main", + aheadBehind: { ahead: 2, behind: 1 }, + aheadOfOrigin: 2, + behindOfOrigin: 1, + hasRemote: true, + diffStat: { additions: 3, deletions: 1 }, + ...overrides?.git, + }, + github: { + featuresEnabled: false, + pullRequest: null, + error: null, + refreshedAt: null, + ...overrides?.github, + }, + }; +} + afterEach(() => { vi.clearAllMocks(); }); @@ -101,16 +244,28 @@ describe("session PR status payload normalization", () => { }); describe("session checkout merge handling", () => { - test("invalidates the cwd mutated by merge-to-base", async () => { + test("uses workspace git service snapshot for merge-to-base preflight", async () => { const messages: unknown[] = []; const github = { invalidate: vi.fn() }; const checkoutDiffManager = { scheduleRefreshForCwd: vi.fn() }; - const session = createSessionForTest({ github, checkoutDiffManager, messages }); - - checkoutGitMocks.getCheckoutStatus.mockResolvedValue({ - isGit: true, - baseRef: "main", + const workspaceGitService = { + getSnapshot: vi.fn().mockResolvedValue( + createWorkspaceGitSnapshot("/tmp/request-worktree", { + git: { + isGit: true, + baseRef: "main", + isDirty: false, + }, + }), + ), + }; + const session = createSessionForTest({ + github, + checkoutDiffManager, + workspaceGitService, + messages, }); + checkoutGitMocks.mergeToBase.mockResolvedValue("/tmp/base-worktree"); await (session as any).handleCheckoutMergeRequest({ @@ -120,6 +275,8 @@ describe("session checkout merge handling", () => { requestId: "request-1", }); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree"); + expect(checkoutGitMocks.getCheckoutStatus).not.toHaveBeenCalled(); expect(checkoutGitMocks.mergeToBase).toHaveBeenCalledWith( "/tmp/request-worktree", { @@ -128,6 +285,10 @@ describe("session checkout merge handling", () => { }, { paseoHome: "/tmp/paseo-home" }, ); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/base-worktree", { + force: true, + reason: "merge-to-base", + }); expect(github.invalidate).toHaveBeenCalledTimes(1); expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/base-worktree" }); expect(checkoutDiffManager.scheduleRefreshForCwd).toHaveBeenCalledWith("/tmp/request-worktree"); @@ -141,9 +302,560 @@ describe("session checkout merge handling", () => { }, }); }); + + test("uses snapshot dirty state for merge-from-base clean target preflight", async () => { + const messages: unknown[] = []; + const workspaceGitService = { + getSnapshot: vi.fn().mockResolvedValue( + createWorkspaceGitSnapshot("/tmp/request-worktree", { + git: { + isDirty: true, + }, + }), + ), + }; + const session = createSessionForTest({ workspaceGitService, messages }); + + await (session as any).handleCheckoutMergeFromBaseRequest({ + type: "checkout_merge_from_base_request", + cwd: "/tmp/request-worktree", + baseRef: "main", + requireCleanTarget: true, + requestId: "request-merge-from-base", + }); + + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree"); + expect(messages).toContainEqual({ + type: "checkout_merge_from_base_response", + payload: { + cwd: "/tmp/request-worktree", + success: false, + error: { + code: "UNKNOWN", + message: "Working directory has uncommitted changes.", + }, + requestId: "request-merge-from-base", + }, + }); + }); + + test("forces a workspace git snapshot refresh after merge-from-base succeeds", async () => { + const messages: unknown[] = []; + const github = { invalidate: vi.fn() }; + const workspaceGitService = { + getSnapshot: vi.fn().mockResolvedValue( + createWorkspaceGitSnapshot("/tmp/request-worktree", { + git: { + isDirty: false, + }, + }), + ), + }; + const session = createSessionForTest({ github, workspaceGitService, messages }); + checkoutGitMocks.mergeFromBase.mockResolvedValue(undefined); + + await (session as any).handleCheckoutMergeFromBaseRequest({ + type: "checkout_merge_from_base_request", + cwd: "/tmp/request-worktree", + baseRef: "main", + requireCleanTarget: true, + requestId: "request-merge-from-base-success", + }); + + expect(checkoutGitMocks.mergeFromBase).toHaveBeenCalledWith("/tmp/request-worktree", { + baseRef: "main", + requireCleanTarget: true, + }); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", { + force: true, + reason: "merge-from-base", + }); + expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" }); + expect(messages).toContainEqual({ + type: "checkout_merge_from_base_response", + payload: { + cwd: "/tmp/request-worktree", + success: true, + error: null, + requestId: "request-merge-from-base-success", + }, + }); + }); +}); + +describe("session checkout commit handling", () => { + test("forces a workspace git snapshot refresh after committing", async () => { + const messages: unknown[] = []; + const checkoutDiffManager = { scheduleRefreshForCwd: vi.fn() }; + const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue({}) }; + const session = createSessionForTest({ checkoutDiffManager, workspaceGitService, messages }); + + checkoutGitMocks.commitChanges.mockResolvedValue(undefined); + + await (session as any).handleCheckoutCommitRequest({ + type: "checkout_commit_request", + cwd: "/tmp/request-worktree", + message: "Ship it", + addAll: true, + requestId: "request-commit", + }); + + expect(checkoutGitMocks.commitChanges).toHaveBeenCalledWith("/tmp/request-worktree", { + message: "Ship it", + addAll: true, + }); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledTimes(1); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", { + force: true, + reason: "commit-changes", + }); + expect(checkoutDiffManager.scheduleRefreshForCwd).toHaveBeenCalledWith("/tmp/request-worktree"); + expect(messages).toContainEqual({ + type: "checkout_commit_response", + payload: { + cwd: "/tmp/request-worktree", + success: true, + error: null, + requestId: "request-commit", + }, + }); + }); + + test("generates commit messages from checkout diffs read through the workspace git service", async () => { + const messages: unknown[] = []; + const workspaceGitService = { + getCheckoutDiff: vi.fn().mockResolvedValue({ + diff: "diff --git a/file.txt b/file.txt\n+hello\n", + structured: [ + { + path: "file.txt", + additions: 1, + deletions: 0, + isNew: false, + isDeleted: false, + hunks: [], + status: "ok", + }, + ], + }), + getSnapshot: vi.fn().mockResolvedValue({}), + }; + agentResponseMocks.generateStructuredAgentResponseWithFallback.mockResolvedValue({ + message: "Update file", + }); + checkoutGitMocks.commitChanges.mockResolvedValue(undefined); + const session = createSessionForTest({ workspaceGitService, messages }); + + await (session as any).handleCheckoutCommitRequest({ + type: "checkout_commit_request", + cwd: "/tmp/request-worktree", + message: "", + addAll: true, + requestId: "request-generated-commit", + }); + + expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledTimes(1); + expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledWith("/tmp/request-worktree", { + mode: "uncommitted", + includeStructured: true, + }); + expect(checkoutGitMocks.commitChanges).toHaveBeenCalledWith("/tmp/request-worktree", { + message: "Update file", + addAll: true, + }); + expect(messages).toContainEqual({ + type: "checkout_commit_response", + payload: { + cwd: "/tmp/request-worktree", + success: true, + error: null, + requestId: "request-generated-commit", + }, + }); + }); + + test("does not force a workspace git snapshot refresh when commit fails", async () => { + const messages: unknown[] = []; + const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue({}) }; + const session = createSessionForTest({ workspaceGitService, messages }); + checkoutGitMocks.commitChanges.mockRejectedValue(new Error("nothing to commit")); + + await (session as any).handleCheckoutCommitRequest({ + type: "checkout_commit_request", + cwd: "/tmp/request-worktree", + message: "Ship it", + addAll: true, + requestId: "request-commit-failure", + }); + + expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); + expect(messages).toContainEqual({ + type: "checkout_commit_response", + payload: { + cwd: "/tmp/request-worktree", + success: false, + error: { + code: "UNKNOWN", + message: "nothing to commit", + }, + requestId: "request-commit-failure", + }, + }); + }); +}); + +describe("session checkout pull request creation", () => { + test("generates PR text from checkout diffs read through the workspace git service", async () => { + const messages: unknown[] = []; + const workspaceGitService = { + getCheckoutDiff: vi.fn().mockResolvedValue({ + diff: "diff --git a/file.txt b/file.txt\n+hello\n", + structured: [ + { + path: "file.txt", + additions: 1, + deletions: 0, + isNew: false, + isDeleted: false, + hunks: [], + status: "ok", + }, + ], + }), + }; + agentResponseMocks.generateStructuredAgentResponseWithFallback.mockResolvedValue({ + title: "Update file", + body: "Updates file.", + }); + checkoutGitMocks.createPullRequest.mockResolvedValue({ + url: "https://github.com/getpaseo/paseo/pull/1", + number: 1, + }); + const session = createSessionForTest({ workspaceGitService, messages }); + + await (session as any).handleCheckoutPrCreateRequest({ + type: "checkout_pr_create_request", + cwd: "/tmp/request-worktree", + baseRef: "main", + title: "", + body: "", + requestId: "request-generated-pr", + }); + + expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledTimes(1); + expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledWith("/tmp/request-worktree", { + mode: "base", + baseRef: "main", + includeStructured: true, + }); + expect(checkoutGitMocks.createPullRequest).toHaveBeenCalledWith( + "/tmp/request-worktree", + { + title: "Update file", + body: "Updates file.", + base: "main", + }, + expect.anything(), + expect.objectContaining({ + getCheckoutDiff: workspaceGitService.getCheckoutDiff, + }), + ); + expect(messages).toContainEqual({ + type: "checkout_pr_create_response", + payload: { + cwd: "/tmp/request-worktree", + url: "https://github.com/getpaseo/paseo/pull/1", + number: 1, + error: null, + requestId: "request-generated-pr", + }, + }); + }); + + test("forces workspace git and GitHub refresh after creating a pull request", async () => { + const messages: unknown[] = []; + const github = { invalidate: vi.fn() }; + const workspaceGitService = { + getSnapshot: vi.fn().mockResolvedValue({}), + }; + checkoutGitMocks.createPullRequest.mockResolvedValue({ + url: "https://github.com/getpaseo/paseo/pull/2", + number: 2, + }); + const session = createSessionForTest({ github, workspaceGitService, messages }); + + await (session as any).handleCheckoutPrCreateRequest({ + type: "checkout_pr_create_request", + cwd: "/tmp/request-worktree", + baseRef: "main", + title: "Update file", + body: "Updates file.", + requestId: "request-pr-create", + }); + + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", { + force: true, + reason: "create-pr", + }); + expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" }); + expect(messages).toContainEqual({ + type: "checkout_pr_create_response", + payload: { + cwd: "/tmp/request-worktree", + url: "https://github.com/getpaseo/paseo/pull/2", + number: 2, + error: null, + requestId: "request-pr-create", + }, + }); + }); +}); + +describe("session checkout pull and push handling", () => { + test("forces workspace git and GitHub refresh after pulling", async () => { + const messages: unknown[] = []; + const github = { invalidate: vi.fn() }; + const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue({}) }; + const session = createSessionForTest({ github, workspaceGitService, messages }); + checkoutGitMocks.pullCurrentBranch.mockResolvedValue(undefined); + + await (session as any).handleCheckoutPullRequest({ + type: "checkout_pull_request", + cwd: "/tmp/request-worktree", + requestId: "request-pull", + }); + + expect(checkoutGitMocks.pullCurrentBranch).toHaveBeenCalledWith("/tmp/request-worktree"); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", { + force: true, + reason: "pull", + }); + expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" }); + expect(messages).toContainEqual({ + type: "checkout_pull_response", + payload: { + cwd: "/tmp/request-worktree", + success: true, + error: null, + requestId: "request-pull", + }, + }); + }); + + test("forces workspace git and GitHub refresh after pushing", async () => { + const messages: unknown[] = []; + const github = { invalidate: vi.fn() }; + const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue({}) }; + const session = createSessionForTest({ github, workspaceGitService, messages }); + checkoutGitMocks.pushCurrentBranch.mockResolvedValue(undefined); + + await (session as any).handleCheckoutPushRequest({ + type: "checkout_push_request", + cwd: "/tmp/request-worktree", + requestId: "request-push", + }); + + expect(checkoutGitMocks.pushCurrentBranch).toHaveBeenCalledWith("/tmp/request-worktree"); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", { + force: true, + reason: "push", + }); + expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" }); + expect(messages).toContainEqual({ + type: "checkout_push_response", + payload: { + cwd: "/tmp/request-worktree", + success: true, + error: null, + requestId: "request-push", + }, + }); + }); +}); + +describe("session checkout status handling", () => { + test("returns checkout status from the workspace git service snapshot", async () => { + const messages: unknown[] = []; + const workspaceGitService = { + getSnapshot: vi.fn().mockResolvedValue(createWorkspaceGitSnapshot("/tmp/service-worktree")), + peekSnapshot: vi.fn(), + }; + const session = createSessionForTest({ workspaceGitService, messages }); + + await (session as any).handleCheckoutStatusRequest({ + type: "checkout_status_request", + cwd: "/tmp/service-worktree", + requestId: "request-status", + }); + + expect(workspaceGitService.getSnapshot).toHaveBeenCalledTimes(1); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/service-worktree"); + expect(checkoutGitMocks.getCheckoutStatus).not.toHaveBeenCalled(); + expect(messages).toContainEqual({ + type: "checkout_status_response", + payload: { + cwd: "/tmp/service-worktree", + isGit: true, + repoRoot: "/tmp/service-worktree", + currentBranch: "feature/service", + isDirty: true, + baseRef: "main", + aheadBehind: { ahead: 2, behind: 1 }, + aheadOfOrigin: 2, + behindOfOrigin: 1, + hasRemote: true, + remoteUrl: "https://github.com/getpaseo/paseo.git", + isPaseoOwnedWorktree: false, + error: null, + requestId: "request-status", + }, + }); + }); + + test("returns fresh service data on the first checkout status read for a cwd", async () => { + const messages: unknown[] = []; + const workspaceGitService = { + getSnapshot: vi.fn().mockResolvedValue( + createWorkspaceGitSnapshot("/tmp/cold-worktree", { + git: { + currentBranch: "fresh-branch", + isDirty: false, + aheadBehind: { ahead: 4, behind: 0 }, + aheadOfOrigin: 4, + behindOfOrigin: 0, + }, + }), + ), + peekSnapshot: vi.fn(() => null), + }; + const session = createSessionForTest({ workspaceGitService, messages }); + + await (session as any).handleCheckoutStatusRequest({ + type: "checkout_status_request", + cwd: "/tmp/cold-worktree", + requestId: "request-cold-status", + }); + + expect(workspaceGitService.peekSnapshot).not.toHaveBeenCalled(); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledTimes(1); + expect(messages).toContainEqual({ + type: "checkout_status_response", + payload: expect.objectContaining({ + cwd: "/tmp/cold-worktree", + isGit: true, + currentBranch: "fresh-branch", + isDirty: false, + aheadBehind: { ahead: 4, behind: 0 }, + error: null, + requestId: "request-cold-status", + }), + }); + }); +}); + +describe("session workspace descriptors", () => { + test("reads descriptor diff stat from the workspace git service snapshot", async () => { + const workspaceGitService = { + getSnapshot: vi.fn(), + peekSnapshot: vi.fn(() => + createWorkspaceGitSnapshot("/tmp/workspace", { + git: { diffStat: { additions: 7, deletions: 2 } }, + }), + ), + }; + const session = createSessionForTest({ workspaceGitService }); + checkoutGitMocks.getCachedCheckoutShortstat.mockReturnValue({ + additions: 99, + deletions: 88, + }); + + const descriptor = await (session as any).describeWorkspaceRecord( + { + workspaceId: "workspace-1", + projectId: "project-1", + cwd: "/tmp/workspace", + kind: "checkout", + displayName: "Workspace", + }, + { + projectId: "project-1", + rootPath: "/tmp/workspace", + displayName: "Project", + kind: "git", + }, + ); + + expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/workspace"); + expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); + expect(checkoutGitMocks.getCachedCheckoutShortstat).not.toHaveBeenCalled(); + expect(checkoutGitMocks.warmCheckoutShortstatInBackground).not.toHaveBeenCalled(); + expect(descriptor.diffStat).toEqual({ additions: 7, deletions: 2 }); + }); + + test("does not cold-load git data while describing a workspace", async () => { + const workspaceGitService = { + getSnapshot: vi.fn().mockResolvedValue(createWorkspaceGitSnapshot("/tmp/workspace")), + peekSnapshot: vi.fn(() => null), + }; + const session = createSessionForTest({ workspaceGitService }); + + const descriptor = await (session as any).describeWorkspaceRecordWithGitData( + { + workspaceId: "workspace-1", + projectId: "project-1", + cwd: "/tmp/workspace", + kind: "checkout", + displayName: "Workspace", + }, + { + projectId: "project-1", + rootPath: "/tmp/workspace", + displayName: "Project", + kind: "git", + }, + ); + + expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/workspace"); + expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); + expect(descriptor.diffStat).toBeNull(); + expect(descriptor.gitRuntime).toBeUndefined(); + }); }); describe("session branch validation", () => { + test("validates branches through the workspace git service", async () => { + const messages: unknown[] = []; + const workspaceGitService = { + getSnapshot: vi.fn(), + peekSnapshot: vi.fn(), + validateBranchRef: vi + .fn() + .mockResolvedValue({ kind: "remote-only", name: "feature", remoteRef: "origin/feature" }), + }; + const session = createSessionForTest({ workspaceGitService, messages }); + + await (session as any).handleValidateBranchRequest({ + type: "validate_branch_request", + cwd: "/tmp/repo", + branchName: "feature", + requestId: "request-validate-service", + }); + + expect(workspaceGitService.validateBranchRef).toHaveBeenCalledTimes(1); + expect(workspaceGitService.validateBranchRef).toHaveBeenCalledWith("/tmp/repo", "feature"); + expect(checkoutGitMocks.resolveBranchCheckout).not.toHaveBeenCalled(); + expect(messages).toContainEqual({ + type: "validate_branch_response", + payload: { + exists: true, + resolvedRef: "origin/feature", + isRemote: true, + error: null, + requestId: "request-validate-service", + }, + }); + }); + test("does not validate tags as branches", async () => { const tempDir = mkdtempSync(join(tmpdir(), "paseo-session-branch-validation-")); const repoDir = join(tempDir, "repo"); @@ -158,7 +870,12 @@ describe("session branch validation", () => { execSync("git tag v1", { cwd: repoDir }); const messages: unknown[] = []; - const session = createSessionForTest({ messages }); + const workspaceGitService = { + getSnapshot: vi.fn(), + peekSnapshot: vi.fn(), + validateBranchRef: vi.fn().mockResolvedValue({ kind: "not-found" }), + }; + const session = createSessionForTest({ workspaceGitService, messages }); await session.handleMessage({ type: "validate_branch_request", @@ -183,7 +900,464 @@ describe("session branch validation", () => { }); }); +describe("session branch creation handling", () => { + test("validates the base branch through the workspace git service", async () => { + const workspaceGitService = { + getSnapshot: vi.fn(), + validateBranchRef: vi.fn().mockResolvedValue({ kind: "not-found" }), + hasLocalBranch: vi.fn(), + }; + const session = createSessionForTest({ workspaceGitService }); + + await expect( + (session as any).createBranchFromBase({ + cwd: "/tmp/repo", + baseBranch: "missing-base", + newBranchName: "feature/new-work", + }), + ).rejects.toThrow("Base branch not found: missing-base"); + + expect(workspaceGitService.validateBranchRef).toHaveBeenCalledTimes(1); + expect(workspaceGitService.validateBranchRef).toHaveBeenCalledWith("/tmp/repo", "missing-base"); + expect(workspaceGitService.hasLocalBranch).not.toHaveBeenCalled(); + expect(spawnMocks.execCommand).not.toHaveBeenCalledWith( + "git", + ["rev-parse", "--verify", "missing-base"], + { cwd: "/tmp/repo" }, + ); + }); + + test("checks local branch existence through the workspace git service", async () => { + const workspaceGitService = { + getSnapshot: vi.fn(), + validateBranchRef: vi.fn().mockResolvedValue({ kind: "local", name: "main" }), + hasLocalBranch: vi.fn().mockResolvedValue(true), + }; + const session = createSessionForTest({ workspaceGitService }); + + await expect( + (session as any).createBranchFromBase({ + cwd: "/tmp/repo", + baseBranch: "main", + newBranchName: "feature/existing", + }), + ).rejects.toThrow("Branch already exists: feature/existing"); + + expect(workspaceGitService.validateBranchRef).toHaveBeenCalledWith("/tmp/repo", "main"); + expect(workspaceGitService.hasLocalBranch).toHaveBeenCalledTimes(1); + expect(workspaceGitService.hasLocalBranch).toHaveBeenCalledWith( + "/tmp/repo", + "feature/existing", + ); + expect(spawnMocks.execCommand).not.toHaveBeenCalledWith( + "git", + ["show-ref", "--verify", "--quiet", "refs/heads/feature/existing"], + { cwd: "/tmp/repo" }, + ); + }); + + test("forces a workspace git snapshot refresh after creating a branch", async () => { + const workspaceGitService = { + getSnapshot: vi.fn().mockResolvedValue( + createWorkspaceGitSnapshot("/tmp/repo", { + git: { + isDirty: false, + }, + }), + ), + validateBranchRef: vi.fn().mockResolvedValue({ kind: "local", name: "main" }), + hasLocalBranch: vi.fn().mockResolvedValue(false), + }; + const session = createSessionForTest({ workspaceGitService }); + spawnMocks.execCommand.mockResolvedValue({ + stdout: "", + stderr: "", + exitCode: 0, + signal: null, + truncated: false, + }); + + await (session as any).createBranchFromBase({ + cwd: "/tmp/repo", + baseBranch: "main", + newBranchName: "feature/new-work", + }); + + expect(spawnMocks.execCommand).toHaveBeenCalledWith( + "git", + ["checkout", "-b", "feature/new-work", "main"], + { cwd: "/tmp/repo" }, + ); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo", { + force: true, + reason: "create-branch", + }); + }); +}); + +describe("session checkout switch branch handling", () => { + test("forces a workspace git snapshot refresh after switching branches", async () => { + const messages: unknown[] = []; + const github = { invalidate: vi.fn() }; + const workspaceGitService = { + getSnapshot: vi.fn().mockResolvedValue( + createWorkspaceGitSnapshot("/tmp/repo", { + git: { + isDirty: false, + }, + }), + ), + validateBranchRef: vi.fn().mockResolvedValue({ kind: "local", name: "release" }), + }; + const session = createSessionForTest({ github, workspaceGitService, messages }); + checkoutGitMocks.checkoutResolvedBranch.mockResolvedValue({ source: "local" }); + + await (session as any).handleCheckoutSwitchBranchRequest({ + type: "checkout_switch_branch_request", + cwd: "/tmp/repo", + branch: "release", + requestId: "request-switch", + }); + + expect(checkoutGitMocks.checkoutResolvedBranch).toHaveBeenCalledWith({ + cwd: "/tmp/repo", + resolution: { kind: "local", name: "release" }, + }); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo", { + force: true, + reason: "switch-branch", + }); + expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/repo" }); + expect(messages).toContainEqual({ + type: "checkout_switch_branch_response", + payload: { + cwd: "/tmp/repo", + success: true, + branch: "release", + source: "local", + error: null, + requestId: "request-switch", + }, + }); + }); +}); + +describe("session branch suggestions handling", () => { + test("lists branch suggestions through the workspace git service", async () => { + const messages: unknown[] = []; + const branchDetails = [ + { name: "feature/service", committerDate: 10, hasLocal: true, hasRemote: false }, + ]; + const workspaceGitService = { + getSnapshot: vi.fn(), + suggestBranchesForCwd: vi.fn().mockResolvedValue(branchDetails), + peekSnapshot: vi.fn(), + }; + const session = createSessionForTest({ workspaceGitService, messages }); + + await (session as any).handleBranchSuggestionsRequest({ + type: "branch_suggestions_request", + cwd: "/tmp/repo", + query: "service", + limit: 5, + requestId: "request-branches", + }); + + expect(workspaceGitService.suggestBranchesForCwd).toHaveBeenCalledTimes(1); + expect(workspaceGitService.suggestBranchesForCwd).toHaveBeenCalledWith("/tmp/repo", { + query: "service", + limit: 5, + }); + expect(checkoutGitMocks.listBranchSuggestions).not.toHaveBeenCalled(); + expect(messages).toContainEqual({ + type: "branch_suggestions_response", + payload: { + branches: ["feature/service"], + branchDetails, + error: null, + requestId: "request-branches", + }, + }); + }); +}); + +describe("session stash list handling", () => { + test("lists stashes through the workspace git service", async () => { + const messages: unknown[] = []; + const entries = [ + { + index: 0, + message: "paseo-auto-stash: feature", + branch: "feature", + isPaseo: true, + }, + ]; + const workspaceGitService = { + getSnapshot: vi.fn(), + listStashes: vi.fn().mockResolvedValue(entries), + peekSnapshot: vi.fn(), + }; + const session = createSessionForTest({ workspaceGitService, messages }); + + await (session as any).handleStashListRequest({ + type: "stash_list_request", + cwd: "/tmp/repo", + paseoOnly: true, + requestId: "request-stashes", + }); + + expect(workspaceGitService.listStashes).toHaveBeenCalledTimes(1); + expect(workspaceGitService.listStashes).toHaveBeenCalledWith("/tmp/repo", { + paseoOnly: true, + }); + expect(messages).toContainEqual({ + type: "stash_list_response", + payload: { cwd: "/tmp/repo", entries, error: null, requestId: "request-stashes" }, + }); + }); +}); + +describe("session stash mutation handling", () => { + test("forces a workspace git snapshot refresh after pushing a stash", async () => { + const messages: unknown[] = []; + const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue({}) }; + const session = createSessionForTest({ workspaceGitService, messages }); + spawnMocks.execCommand.mockResolvedValue({ + stdout: "", + stderr: "", + exitCode: 0, + signal: null, + truncated: false, + }); + + await (session as any).handleStashSaveRequest({ + type: "stash_save_request", + cwd: "/tmp/repo", + branch: "feature", + requestId: "request-stash-push", + }); + + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo", { + force: true, + reason: "stash-push", + }); + expect(messages).toContainEqual({ + type: "stash_save_response", + payload: { + cwd: "/tmp/repo", + success: true, + error: null, + requestId: "request-stash-push", + }, + }); + }); + + test("forces a workspace git snapshot refresh after popping a stash", async () => { + const messages: unknown[] = []; + const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue({}) }; + const session = createSessionForTest({ workspaceGitService, messages }); + spawnMocks.execCommand.mockResolvedValue({ + stdout: "", + stderr: "", + exitCode: 0, + signal: null, + truncated: false, + }); + + await (session as any).handleStashPopRequest({ + type: "stash_pop_request", + cwd: "/tmp/repo", + stashIndex: 0, + requestId: "request-stash-pop", + }); + + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo", { + force: true, + reason: "stash-pop", + }); + expect(messages).toContainEqual({ + type: "stash_pop_response", + payload: { + cwd: "/tmp/repo", + success: true, + error: null, + requestId: "request-stash-pop", + }, + }); + }); +}); + +describe("session paseo worktree creation handling", () => { + test("forces workspace git refreshes for the source repo and created worktree", async () => { + const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue({}) }; + const session = createSessionForTest({ workspaceGitService }); + paseoWorktreeServiceMocks.createPaseoWorktree.mockResolvedValue({ + repoRoot: "/tmp/repo", + worktree: { + branchName: "feature/new-worktree", + worktreePath: "/tmp/paseo/worktrees/new-worktree", + }, + workspace: { + workspaceId: "workspace-new-worktree", + projectId: "project-repo", + cwd: "/tmp/paseo/worktrees/new-worktree", + kind: "worktree", + displayName: "feature/new-worktree", + }, + created: true, + }); + + await (session as any).createPaseoWorktree({ + cwd: "/tmp/repo", + worktreeSlug: "new-worktree", + runSetup: false, + }); + + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo", { + force: true, + reason: "create-worktree", + }); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith( + "/tmp/paseo/worktrees/new-worktree", + { + force: true, + reason: "create-worktree", + }, + ); + }); +}); + +describe("session workspace script handling", () => { + test("passes service-owned git metadata into workspace script spawning", async () => { + const messages: unknown[] = []; + const workspaceGitService = { + peekSnapshot: vi.fn(() => null), + getWorkspaceGitMetadata: vi.fn().mockResolvedValue({ + projectKind: "git", + projectDisplayName: "getpaseo/paseo", + workspaceDisplayName: "feature/service-scripts", + projectSlug: "paseo", + currentBranch: "feature/service-scripts", + }), + }; + const workspaceRegistry = { + get: vi.fn().mockResolvedValue({ + workspaceId: "workspace-1", + cwd: "/tmp/repo", + }), + }; + spawnMocks.spawnWorkspaceScript.mockResolvedValue({ + scriptName: "api", + terminalId: "terminal-1", + }); + const session = createSessionForTest({ + workspaceGitService, + workspaceRegistry, + terminalManager: { subscribeTerminalsChanged: vi.fn(() => () => {}) }, + scriptRouteStore: { listRoutesForWorkspace: vi.fn(() => []) }, + scriptRuntimeStore: { listForWorkspace: vi.fn(() => []) }, + getDaemonTcpPort: () => 6767, + getDaemonTcpHost: () => "127.0.0.1", + messages, + }); + + await (session as any).handleStartWorkspaceScriptRequest({ + type: "start_workspace_script_request", + workspaceId: "workspace-1", + scriptName: "api", + requestId: "request-script", + }); + + expect(workspaceGitService.getWorkspaceGitMetadata).toHaveBeenCalledTimes(1); + expect(workspaceGitService.getWorkspaceGitMetadata).toHaveBeenCalledWith("/tmp/repo"); + expect(spawnMocks.spawnWorkspaceScript).toHaveBeenCalledWith( + expect.objectContaining({ + repoRoot: "/tmp/repo", + workspaceId: "workspace-1", + projectSlug: "paseo", + branchName: "feature/service-scripts", + scriptName: "api", + daemonPort: 6767, + daemonListenHost: "127.0.0.1", + }), + ); + expect(messages).toContainEqual({ + type: "start_workspace_script_response", + payload: { + requestId: "request-script", + workspaceId: "workspace-1", + scriptName: "api", + terminalId: "terminal-1", + error: null, + }, + }); + }); +}); + describe("session pull request timeline handling", () => { + test("routes GitHub search requests through GitHubService", async () => { + const messages: unknown[] = []; + const github = { + invalidate: vi.fn(), + searchIssuesAndPrs: vi.fn().mockResolvedValue({ + githubFeaturesEnabled: true, + items: [ + { + kind: "pr", + number: 42, + title: "Ship search", + url: "https://github.com/getpaseo/paseo/pull/42", + state: "OPEN", + body: null, + labels: [], + baseRefName: "main", + headRefName: "feature", + updatedAt: "2026-04-18T13:00:00Z", + }, + ], + }), + }; + const session = createSessionForTest({ github, messages }); + + await session.handleMessage({ + type: "github_search_request", + cwd: "/tmp/repo", + query: "search", + limit: 5, + kinds: ["github-pr"], + requestId: "request-search", + }); + + expect(github.searchIssuesAndPrs).toHaveBeenCalledWith({ + cwd: "/tmp/repo", + query: "search", + limit: 5, + kinds: ["github-pr"], + }); + expect(messages).toContainEqual({ + type: "github_search_response", + payload: { + items: [ + { + kind: "pr", + number: 42, + title: "Ship search", + url: "https://github.com/getpaseo/paseo/pull/42", + state: "OPEN", + body: null, + labels: [], + baseRefName: "main", + headRefName: "feature", + updatedAt: "2026-04-18T13:00:00Z", + }, + ], + githubFeaturesEnabled: true, + error: null, + requestId: "request-search", + }, + }); + }); + test("passes request identity to GitHubService and emits timeline items", async () => { const messages: unknown[] = []; const github = { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index fc6358e08..2ee7ed660 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,8 +1,6 @@ import equal from "fast-deep-equal"; import { v4 as uuidv4 } from "uuid"; import type { FSWatcher } from "node:fs"; -import { exec } from "node:child_process"; -import { promisify } from "util"; import { resolve, sep } from "path"; import { homedir } from "node:os"; import { z } from "zod"; @@ -17,6 +15,8 @@ import { type FileExplorerRequest, type FileDownloadTokenRequest, type GitSetupOptions, + type CheckoutPrStatusResponse, + type CheckoutStatusResponse, type ListTerminalsRequest, type SubscribeTerminalsRequest, type UnsubscribeTerminalsRequest, @@ -73,9 +73,9 @@ import { experimental_createMCPClient } from "ai"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js"; import { buildWorkspaceScriptPayloads } from "./script-status-projection.js"; +import { deriveProjectSlug } from "./workspace-git-metadata.js"; import type { ScriptHealthState } from "./script-health-monitor.js"; import { spawnWorkspaceScript } from "./worktree-bootstrap.js"; -import { deriveProjectSlug, readGitCommand } from "./workspace-git-metadata.js"; import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import type { DaemonConfigStore } from "./daemon-config-store.js"; import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; @@ -163,12 +163,7 @@ import { type WorktreeConfig } from "../utils/worktree.js"; import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js"; import type { ScriptRouteStore } from "./script-proxy.js"; import { - getCheckoutDiff, - getCachedCheckoutShortstat, - getCheckoutStatus, - listBranchSuggestions, checkoutResolvedBranch, - resolveBranchCheckout, type CheckoutExistingBranchResult, commitChanges, mergeToBase, @@ -176,13 +171,11 @@ import { pullCurrentBranch, pushCurrentBranch, createPullRequest, - searchGitHubIssuesAndPrs, - warmCheckoutShortstatInBackground, } 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, toCheckoutError } from "./checkout-git-utils.js"; +import { 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"; @@ -217,9 +210,20 @@ import { } from "./worktree-session.js"; import { toWorktreeWireError } from "./worktree-errors.js"; -const execAsync = promisify(exec); const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS); const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__"; +type GitMutationRefreshReason = + | "commit-changes" + | "pull" + | "push" + | "merge-to-base" + | "merge-from-base" + | "create-pr" + | "switch-branch" + | "create-branch" + | "stash-push" + | "stash-pop" + | "create-worktree"; // TODO: Remove once all app store clients are on >=0.1.45 and understand arbitrary provider strings. // Clients before 0.1.45 validate providers with z.enum(["claude", "codex", "opencode"]) and reject @@ -829,6 +833,7 @@ export class Session { this.providerRegistry = buildProviderRegistry(this.sessionLogger, { runtimeSettings: this.agentProviderRuntimeSettings, providerOverrides: this.providerOverrides, + workspaceGitService: this.workspaceGitService, }); // Initialize per-session managers @@ -2810,6 +2815,9 @@ export class Session { explicitTitle, paseoHome: this.paseoHome, logger: this.sessionLogger, + deps: { + workspaceGitService: this.workspaceGitService, + }, }); const started = await this.handleSendAgentMessage( @@ -3352,11 +3360,10 @@ export class Session { } private async generateCommitMessage(cwd: string): Promise { - const diff = await getCheckoutDiff( - cwd, - { mode: "uncommitted", includeStructured: true }, - { paseoHome: this.paseoHome }, - ); + const diff = await this.workspaceGitService.getCheckoutDiff(cwd, { + mode: "uncommitted", + includeStructured: true, + }); const schema = z.object({ message: z .string() @@ -3421,15 +3428,11 @@ export class Session { title: string; body: string; }> { - const diff = await getCheckoutDiff( - cwd, - { - mode: "base", - baseRef, - includeStructured: true, - }, - { paseoHome: this.paseoHome }, - ); + const diff = await this.workspaceGitService.getCheckoutDiff(cwd, { + mode: "base", + baseRef, + includeStructured: true, + }); const schema = z.object({ title: z.string().min(1).max(72), body: z.string().min(1), @@ -3497,11 +3500,8 @@ export class Session { private async isWorkingTreeDirty(cwd: string): Promise { try { - const { stdout } = await execAsync("git status --porcelain", { - cwd, - env: READ_ONLY_GIT_ENV, - }); - return stdout.trim().length > 0; + const snapshot = await this.workspaceGitService.getSnapshot(cwd); + return snapshot.git.isDirty === true; } catch (error) { throw new Error(`Unable to inspect git status for ${cwd}: ${(error as Error).message}`); } @@ -3512,15 +3512,17 @@ export class Session { branch: string, ): Promise { this.assertSafeGitRef(branch, "branch"); - const resolution = await resolveBranchCheckout(cwd, branch); + const resolution = await this.workspaceGitService.validateBranchRef(cwd, branch); if (resolution.kind === "not-found") { throw new Error(`Branch not found: ${branch}`); } await this.ensureCleanWorkingTree(cwd); - return checkoutResolvedBranch({ + const result = await checkoutResolvedBranch({ cwd, resolution, }); + await this.notifyGitMutation(cwd, "switch-branch", { invalidateGithub: true }); + return result; } private async createBranchFromBase(params: { @@ -3532,9 +3534,8 @@ export class Session { this.assertSafeGitRef(baseBranch, "base branch"); this.assertSafeGitRef(newBranchName, "new branch"); - try { - await execCommand("git", ["rev-parse", "--verify", baseBranch], { cwd }); - } catch (error) { + const baseResolution = await this.workspaceGitService.validateBranchRef(cwd, baseBranch); + if (baseResolution.kind === "not-found") { throw new Error(`Base branch not found: ${baseBranch}`); } @@ -3547,17 +3548,29 @@ export class Session { await execCommand("git", ["checkout", "-b", newBranchName, baseBranch], { cwd, }); + await this.notifyGitMutation(cwd, "create-branch"); } private async doesLocalBranchExist(cwd: string, branch: string): Promise { this.assertSafeGitRef(branch, "branch"); + return this.workspaceGitService.hasLocalBranch(cwd, branch); + } + + private async notifyGitMutation( + cwd: string, + reason: GitMutationRefreshReason, + options?: { invalidateGithub?: boolean }, + ): Promise { + if (options?.invalidateGithub) { + this.github.invalidate({ cwd }); + } try { - await execCommand("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { - cwd, - }); - return true; - } catch { - return false; + await this.workspaceGitService.getSnapshot(cwd, { force: true, reason }); + } catch (error) { + this.sessionLogger.warn( + { err: error, cwd, reason }, + "Failed to force-refresh workspace git snapshot after mutation", + ); } } @@ -3934,72 +3947,14 @@ export class Session { const resolvedCwd = expandTilde(cwd); try { - const status = await getCheckoutStatus(resolvedCwd, { paseoHome: this.paseoHome }); - if (!status.isGit) { - this.emit({ - type: "checkout_status_response", - payload: { - cwd, - isGit: false, - repoRoot: null, - currentBranch: null, - isDirty: null, - baseRef: null, - aheadBehind: null, - aheadOfOrigin: null, - behindOfOrigin: null, - hasRemote: false, - remoteUrl: null, - isPaseoOwnedWorktree: false, - error: null, - requestId, - }, - }); - return; - } - - if (status.isPaseoOwnedWorktree) { - this.emit({ - type: "checkout_status_response", - payload: { - cwd, - isGit: true, - repoRoot: status.repoRoot ?? null, - mainRepoRoot: status.mainRepoRoot, - currentBranch: status.currentBranch ?? null, - isDirty: status.isDirty ?? null, - baseRef: status.baseRef, - aheadBehind: status.aheadBehind ?? null, - aheadOfOrigin: status.aheadOfOrigin ?? null, - behindOfOrigin: status.behindOfOrigin ?? null, - hasRemote: status.hasRemote, - remoteUrl: status.remoteUrl, - isPaseoOwnedWorktree: true, - error: null, - requestId, - }, - }); - return; - } - + const snapshot = await this.workspaceGitService.getSnapshot(resolvedCwd); this.emit({ type: "checkout_status_response", - payload: { + payload: this.buildCheckoutStatusPayloadFromSnapshot({ cwd, - isGit: true, - repoRoot: status.repoRoot ?? null, - currentBranch: status.currentBranch ?? null, - isDirty: status.isDirty ?? null, - baseRef: status.baseRef ?? null, - aheadBehind: status.aheadBehind ?? null, - aheadOfOrigin: status.aheadOfOrigin ?? null, - behindOfOrigin: status.behindOfOrigin ?? null, - hasRemote: status.hasRemote, - remoteUrl: status.remoteUrl, - isPaseoOwnedWorktree: false, - error: null, requestId, - }, + snapshot, + }), }); } catch (error) { this.emit({ @@ -4033,7 +3988,7 @@ export class Session { const resolvedCwd = expandTilde(cwd); this.assertSafeGitRef(branchName, "branch"); - const resolution = await resolveBranchCheckout(resolvedCwd, branchName); + const resolution = await this.workspaceGitService.validateBranchRef(resolvedCwd, branchName); switch (resolution.kind) { case "local": this.emit({ @@ -4097,7 +4052,10 @@ export class Session { try { const resolvedCwd = expandTilde(cwd); - const branchDetails = await listBranchSuggestions(resolvedCwd, { query, limit }); + const branchDetails = await this.workspaceGitService.suggestBranchesForCwd(resolvedCwd, { + query, + limit, + }); this.emit({ type: "branch_suggestions_response", payload: { @@ -4127,7 +4085,10 @@ export class Session { try { const resolvedCwd = expandTilde(cwd); - const result = await searchGitHubIssuesAndPrs(resolvedCwd, query, limit, this.github, { + const result = await this.github.searchIssuesAndPrs({ + cwd: resolvedCwd, + query, + limit, kinds, }); this.emit({ @@ -4302,9 +4263,13 @@ export class Session { return; } - const subscription = await this.workspaceGitService.subscribe({ cwd: normalizedCwd }, () => { - void this.emitWorkspaceUpdateForCwd(normalizedCwd); - }); + const subscription = await this.workspaceGitService.subscribe( + { cwd: normalizedCwd }, + (snapshot) => { + void this.emitWorkspaceUpdateForCwd(normalizedCwd); + this.emitCheckoutStatusUpdate(normalizedCwd, snapshot); + }, + ); this.workspaceGitSubscriptions.set(normalizedCwd, subscription.unsubscribe); } @@ -4343,6 +4308,129 @@ export class Session { this.checkoutDiffSubscriptions.delete(msg.subscriptionId); } + private buildCheckoutStatusPayloadFromSnapshot({ + cwd, + requestId, + snapshot, + }: { + cwd: string; + requestId: string; + snapshot: WorkspaceGitRuntimeSnapshot; + }): CheckoutStatusResponse["payload"] { + if (!snapshot.git.isGit) { + return { + cwd, + isGit: false, + repoRoot: null, + currentBranch: null, + isDirty: null, + baseRef: null, + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + hasRemote: false, + remoteUrl: null, + isPaseoOwnedWorktree: false, + error: null, + requestId, + }; + } + + if (snapshot.git.repoRoot === null || snapshot.git.isDirty === null) { + throw new Error("Workspace git snapshot is missing required checkout status fields"); + } + + if (snapshot.git.isPaseoOwnedWorktree) { + if (snapshot.git.mainRepoRoot === null || snapshot.git.baseRef === null) { + throw new Error("Workspace git snapshot is missing required worktree status fields"); + } + + return { + cwd, + isGit: true, + repoRoot: snapshot.git.repoRoot, + mainRepoRoot: snapshot.git.mainRepoRoot, + currentBranch: snapshot.git.currentBranch ?? null, + isDirty: snapshot.git.isDirty, + baseRef: snapshot.git.baseRef, + aheadBehind: snapshot.git.aheadBehind ?? null, + aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null, + behindOfOrigin: snapshot.git.behindOfOrigin ?? null, + hasRemote: snapshot.git.hasRemote, + remoteUrl: snapshot.git.remoteUrl, + isPaseoOwnedWorktree: true, + error: null, + requestId, + }; + } + + return { + cwd, + isGit: true, + repoRoot: snapshot.git.repoRoot, + currentBranch: snapshot.git.currentBranch ?? null, + isDirty: snapshot.git.isDirty, + baseRef: snapshot.git.baseRef ?? null, + aheadBehind: snapshot.git.aheadBehind ?? null, + aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null, + behindOfOrigin: snapshot.git.behindOfOrigin ?? null, + hasRemote: snapshot.git.hasRemote, + remoteUrl: snapshot.git.remoteUrl, + isPaseoOwnedWorktree: false, + error: null, + requestId, + }; + } + + private buildCheckoutPrStatusPayloadFromSnapshot({ + cwd, + requestId, + snapshot, + }: { + cwd: string; + requestId: string; + snapshot: WorkspaceGitRuntimeSnapshot; + }): CheckoutPrStatusResponse["payload"] { + return { + cwd, + status: normalizeCheckoutPrStatusPayload(snapshot.github.pullRequest), + githubFeaturesEnabled: snapshot.github.featuresEnabled, + error: snapshot.github.error + ? { + code: "UNKNOWN", + message: snapshot.github.error.message, + } + : null, + requestId, + }; + } + + private emitCheckoutStatusUpdate(cwd: string, snapshot: WorkspaceGitRuntimeSnapshot): void { + try { + const requestId = `subscription:${cwd}`; + this.emit({ + type: "checkout_status_update", + payload: { + ...this.buildCheckoutStatusPayloadFromSnapshot({ + cwd, + requestId, + snapshot, + }), + prStatus: this.buildCheckoutPrStatusPayloadFromSnapshot({ + cwd, + requestId, + snapshot, + }), + }, + }); + } catch (error) { + this.sessionLogger.warn( + { err: error, cwd }, + "Failed to emit workspace checkout status update", + ); + } + } + private async handleCheckoutSwitchBranchRequest( msg: Extract, ): Promise { @@ -4350,7 +4438,6 @@ export class Session { try { const checkoutResult = await this.checkoutExistingBranch(cwd, branch); - this.github.invalidate({ cwd }); this.checkoutDiffManager.scheduleRefreshForCwd(cwd); // Push a workspace_update immediately so the sidebar/header reflect @@ -4398,6 +4485,7 @@ export class Session { ? `${Session.PASEO_STASH_PREFIX} ${branchLabel}` : `${Session.PASEO_STASH_PREFIX} unnamed`; await execCommand("git", ["stash", "push", "--include-untracked", "-m", message], { cwd }); + await this.notifyGitMutation(cwd, "stash-push"); this.checkoutDiffManager.scheduleRefreshForCwd(cwd); this.emit({ type: "stash_save_response", @@ -4417,6 +4505,7 @@ export class Session { const { cwd, stashIndex, requestId } = msg; try { await execCommand("git", ["stash", "pop", `stash@{${stashIndex}}`], { cwd }); + await this.notifyGitMutation(cwd, "stash-pop"); this.checkoutDiffManager.scheduleRefreshForCwd(cwd); this.emit({ type: "stash_pop_response", @@ -4436,35 +4525,7 @@ export class Session { const { cwd, requestId } = msg; const paseoOnly = msg.paseoOnly !== false; try { - const { stdout } = await execAsync("git stash list --format=%gd%x00%s", { - cwd, - env: READ_ONLY_GIT_ENV, - }); - const lines = stdout.trim().split("\n").filter(Boolean); - const entries: Array<{ - index: number; - message: string; - branch: string | null; - isPaseo: boolean; - }> = []; - - for (const line of lines) { - const sepIdx = line.indexOf("\0"); - if (sepIdx < 0) continue; - const refPart = line.slice(0, sepIdx); - const subject = line.slice(sepIdx + 1); - const indexMatch = refPart.match(/\{(\d+)\}/); - if (!indexMatch) continue; - const index = Number(indexMatch[1]); - const prefixIdx = subject.indexOf(Session.PASEO_STASH_PREFIX); - const isPaseo = prefixIdx >= 0; - const branch = isPaseo - ? subject.slice(prefixIdx + Session.PASEO_STASH_PREFIX.length).trim() || null - : null; - - if (paseoOnly && !isPaseo) continue; - entries.push({ index, message: subject, branch, isPaseo }); - } + const entries = await this.workspaceGitService.listStashes(cwd, { paseoOnly }); this.emit({ type: "stash_list_response", @@ -4496,6 +4557,7 @@ export class Session { message, addAll: msg.addAll ?? true, }); + await this.notifyGitMutation(cwd, "commit-changes"); this.checkoutDiffManager.scheduleRefreshForCwd(cwd); this.emit({ @@ -4526,35 +4588,18 @@ export class Session { const { cwd, requestId } = msg; try { - const status = await getCheckoutStatus(cwd, { paseoHome: this.paseoHome }); - if (!status.isGit) { - try { - await execAsync("git rev-parse --is-inside-work-tree", { - cwd, - env: READ_ONLY_GIT_ENV, - }); - } catch (error) { - const details = - typeof (error as any)?.stderr === "string" - ? String((error as any).stderr).trim() - : error instanceof Error - ? error.message - : String(error); - throw new Error(`Not a git repository: ${cwd}\n${details}`.trim()); - } + const snapshot = await this.workspaceGitService.getSnapshot(cwd); + if (!snapshot.git.isGit) { + throw new Error(`Not a git repository: ${cwd}`); } if (msg.requireCleanTarget) { - const { stdout } = await execAsync("git status --porcelain", { - cwd, - env: READ_ONLY_GIT_ENV, - }); - if (stdout.trim().length > 0) { + if (snapshot.git.isDirty) { throw new Error("Working directory has uncommitted changes."); } } - let baseRef = msg.baseRef ?? (status.isGit ? status.baseRef : null); + let baseRef = msg.baseRef ?? snapshot.git.baseRef; if (!baseRef) { throw new Error("Base branch is required for merge"); } @@ -4570,7 +4615,10 @@ export class Session { }, { paseoHome: this.paseoHome }, ); - this.github.invalidate({ cwd: mutatedCwd }); + await Promise.all([ + this.notifyGitMutation(mutatedCwd, "merge-to-base", { invalidateGithub: true }), + ...(mutatedCwd !== cwd ? [this.notifyGitMutation(cwd, "merge-to-base")] : []), + ]); this.checkoutDiffManager.scheduleRefreshForCwd(cwd); this.emit({ @@ -4602,11 +4650,8 @@ export class Session { try { if (msg.requireCleanTarget ?? true) { - const { stdout } = await execAsync("git status --porcelain", { - cwd, - env: READ_ONLY_GIT_ENV, - }); - if (stdout.trim().length > 0) { + const snapshot = await this.workspaceGitService.getSnapshot(cwd); + if (snapshot.git.isDirty) { throw new Error("Working directory has uncommitted changes."); } } @@ -4615,7 +4660,7 @@ export class Session { baseRef: msg.baseRef, requireCleanTarget: msg.requireCleanTarget ?? true, }); - this.github.invalidate({ cwd }); + await this.notifyGitMutation(cwd, "merge-from-base", { invalidateGithub: true }); this.checkoutDiffManager.scheduleRefreshForCwd(cwd); this.emit({ @@ -4647,7 +4692,7 @@ export class Session { try { await pullCurrentBranch(cwd); - this.github.invalidate({ cwd }); + await this.notifyGitMutation(cwd, "pull", { invalidateGithub: true }); this.checkoutDiffManager.scheduleRefreshForCwd(cwd); this.emit({ @@ -4679,7 +4724,7 @@ export class Session { try { await pushCurrentBranch(cwd); - this.github.invalidate({ cwd }); + await this.notifyGitMutation(cwd, "push", { invalidateGithub: true }); this.emit({ type: "checkout_push_response", payload: { @@ -4725,7 +4770,9 @@ export class Session { base: msg.baseRef, }, this.github, + this.workspaceGitService, ); + await this.notifyGitMutation(cwd, "create-pr", { invalidateGithub: true }); this.emit({ type: "checkout_pr_create_response", @@ -4876,6 +4923,7 @@ export class Session { { emit: (message) => this.emit(message), paseoHome: this.paseoHome, + workspaceGitService: this.workspaceGitService, }, msg, ); @@ -4888,6 +4936,7 @@ export class Session { { paseoHome: this.paseoHome, github: this.github, + workspaceGitService: this.workspaceGitService, agentManager: this.agentManager, agentStorage: this.agentStorage, archiveWorkspaceRecord: async (workspaceDirectory) => { @@ -5530,13 +5579,9 @@ export class Session { projectRecord ?? (await this.projectRegistry.get(workspace.projectId)); let diffStat: { additions: number; deletions: number } | null = null; - const cachedShortstat = getCachedCheckoutShortstat(workspace.cwd); - if (cachedShortstat !== undefined) { - diffStat = cachedShortstat; - } else { - warmCheckoutShortstatInBackground(workspace.cwd, undefined, () => { - void this.emitWorkspaceUpdateForCwd(workspace.cwd); - }); + const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd); + if (snapshot?.git.diffStat) { + diffStat = snapshot.git.diffStat; } return { @@ -5559,6 +5604,7 @@ export class Session { routeStore: this.scriptRouteStore, runtimeStore: this.scriptRuntimeStore, daemonPort: this.getDaemonTcpPort?.() ?? null, + gitMetadata: this.resolveWorkspaceScriptGitMetadata(workspace.cwd), resolveHealth: this.resolveScriptHealth ?? undefined, }) : [], @@ -5599,19 +5645,7 @@ export class Session { projectRecord?: PersistedProjectRecord | null, ): Promise { const base = await this.describeWorkspaceRecord(workspace, projectRecord); - let snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd); - if (!snapshot) { - try { - snapshot = await this.workspaceGitService.getSnapshot(workspace.cwd); - } catch (error) { - this.sessionLogger.warn( - { err: error, cwd: workspace.cwd }, - "Failed to load git snapshot for workspace", - ); - return base; - } - } - + const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd); if (!snapshot) { return base; } @@ -6104,14 +6138,14 @@ export class Session { private async createPaseoWorktree( input: CreatePaseoWorktreeInput, options?: { - resolveRepositoryDefaultBranch?: (repoRoot: string) => Promise; + resolveDefaultBranch?: (repoRoot: string) => Promise; }, ): Promise { const coreDeps = createWorktreeCoreDeps(this.github); - return createPaseoWorktree(input, { + const result = await createPaseoWorktree(input, { ...coreDeps, - ...(options?.resolveRepositoryDefaultBranch - ? { resolveRepositoryDefaultBranch: options.resolveRepositoryDefaultBranch } + ...(options?.resolveDefaultBranch + ? { resolveDefaultBranch: options.resolveDefaultBranch } : {}), projectRegistry: this.projectRegistry, workspaceRegistry: this.workspaceRegistry, @@ -6121,6 +6155,11 @@ export class Session { broadcastWorkspaceUpdate: (workspaceId) => this.emitWorkspaceUpdateForWorkspaceId(workspaceId), }); + await Promise.all([ + this.notifyGitMutation(input.cwd, "create-worktree"), + this.notifyGitMutation(result.worktree.worktreePath, "create-worktree"), + ]); + return result; } private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise { @@ -6440,10 +6479,27 @@ export class Session { routeStore: this.scriptRouteStore, runtimeStore: this.scriptRuntimeStore, daemonPort: this.getDaemonTcpPort?.() ?? null, + gitMetadata: this.resolveWorkspaceScriptGitMetadata(workspaceDirectory), resolveHealth: this.resolveScriptHealth ?? undefined, }); } + private resolveWorkspaceScriptGitMetadata( + workspaceDirectory: string, + ): { projectSlug: string; currentBranch: string | null } | undefined { + const snapshot = this.workspaceGitService.peekSnapshot(workspaceDirectory); + if (!snapshot) { + return undefined; + } + return { + projectSlug: deriveProjectSlug( + workspaceDirectory, + snapshot.git.isGit ? snapshot.git.remoteUrl : null, + ), + currentBranch: snapshot.git.currentBranch, + }; + } + private emitWorkspaceScriptStatusUpdate(workspaceId: string, workspaceDirectory: string): void { this.emit({ type: "script_status_update", @@ -6474,12 +6530,13 @@ export class Session { if (!workspace) { throw new Error(`Workspace not found: ${request.workspaceId}`); } + const gitMetadata = await this.workspaceGitService.getWorkspaceGitMetadata(workspace.cwd); const serviceResult = await spawnWorkspaceScript({ repoRoot: workspace.cwd, workspaceId: workspace.workspaceId, - projectSlug: deriveProjectSlug(workspace.cwd), - branchName: readGitCommand(workspace.cwd, "git symbolic-ref --short HEAD"), + projectSlug: gitMetadata.projectSlug, + branchName: gitMetadata.currentBranch, scriptName: request.scriptName, daemonPort: this.getDaemonTcpPort?.() ?? null, daemonListenHost: this.getDaemonTcpHost?.() ?? null, 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 b15994ee1..4aaac5dd5 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -27,9 +27,11 @@ function createWorkspaceRuntimeSnapshot( remoteUrl: "https://github.com/acme/repo.git", isPaseoOwnedWorktree: false, isDirty: false, + baseRef: "main", aheadBehind: { ahead: 0, behind: 0 }, aheadOfOrigin: 0, behindOfOrigin: 0, + hasRemote: true, diffStat: { additions: 1, deletions: 0 }, }, github: { @@ -313,6 +315,153 @@ describe("workspace git watch targets", () => { await session.cleanup(); }); + test("emits checkout_status_update to a client subscribed to the workspace git target", async () => { + const { session, emitted, projects, workspaces, workspaceGitService, subscriptions } = + createSessionForWorkspaceGitWatchTests(); + const sessionAny = session as any; + seedGitWorkspace({ + projects, + workspaces, + projectId: "proj-1", + workspaceId: "ws-10", + cwd: "/tmp/repo", + name: "main", + }); + sessionAny.workspaceUpdatesSubscription = { + subscriptionId: "sub-1", + filter: undefined, + isBootstrapping: false, + pendingUpdatesByWorkspaceId: new Map(), + lastEmittedByWorkspaceId: new Map(), + }; + + await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo", { isGit: true }); + emitted.length = 0; + + subscriptions[0]?.listener( + createWorkspaceRuntimeSnapshot("/tmp/repo", { + git: { + currentBranch: "feature/server-push", + isDirty: true, + aheadBehind: { ahead: 3, behind: 1 }, + aheadOfOrigin: 3, + behindOfOrigin: 1, + }, + }), + ); + + const statusUpdates = emitted.filter( + (message) => message.type === "checkout_status_update", + ) as any[]; + expect(statusUpdates).toHaveLength(1); + expect(statusUpdates[0]?.payload).toMatchObject({ + cwd: "/tmp/repo", + isGit: true, + repoRoot: "/tmp/repo", + currentBranch: "feature/server-push", + isDirty: true, + baseRef: "main", + aheadBehind: { ahead: 3, behind: 1 }, + aheadOfOrigin: 3, + behindOfOrigin: 1, + hasRemote: true, + remoteUrl: "https://github.com/acme/repo.git", + isPaseoOwnedWorktree: false, + error: null, + requestId: "subscription:/tmp/repo", + }); + expect(workspaceGitService.subscribe).toHaveBeenCalledWith( + { cwd: "/tmp/repo" }, + expect.any(Function), + ); + + await session.cleanup(); + }); + + test("embeds PR status in checkout_status_update for GitHub-inclusive snapshot pushes", async () => { + const { session, emitted, projects, workspaces, subscriptions } = + createSessionForWorkspaceGitWatchTests(); + const sessionAny = session as any; + seedGitWorkspace({ + projects, + workspaces, + projectId: "proj-1", + workspaceId: "ws-10", + cwd: "/tmp/repo", + name: "main", + }); + sessionAny.workspaceUpdatesSubscription = { + subscriptionId: "sub-1", + filter: undefined, + isBootstrapping: false, + pendingUpdatesByWorkspaceId: new Map(), + lastEmittedByWorkspaceId: new Map(), + }; + + await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo", { isGit: true }); + emitted.length = 0; + + subscriptions[0]?.listener( + createWorkspaceRuntimeSnapshot("/tmp/repo", { + github: { + featuresEnabled: true, + pullRequest: { + number: 456, + url: "https://github.com/acme/repo/pull/456", + title: "Runtime centralization", + state: "open", + baseRefName: "main", + headRefName: "workspace-git-service", + isMerged: false, + checks: [ + { + name: "test", + status: "success", + url: "https://github.com/acme/repo/actions/runs/1", + }, + ], + checksStatus: "success", + reviewDecision: "approved", + }, + error: null, + }, + }), + ); + + const statusUpdate = emitted.find((message) => message.type === "checkout_status_update") as + | { payload: any } + | undefined; + expect(statusUpdate?.payload.prStatus).toEqual({ + cwd: "/tmp/repo", + status: { + number: 456, + url: "https://github.com/acme/repo/pull/456", + title: "Runtime centralization", + state: "open", + repoOwner: undefined, + repoName: undefined, + baseRefName: "main", + headRefName: "workspace-git-service", + isMerged: false, + isDraft: false, + checks: [ + { + name: "test", + status: "success", + url: "https://github.com/acme/repo/actions/runs/1", + }, + ], + checksStatus: "success", + reviewDecision: "approved", + }, + githubFeaturesEnabled: true, + error: null, + requestId: "subscription:/tmp/repo", + }); + + await session.cleanup(); + }); + test("checkout_pr_status_request reads pull request status from the workspace git service snapshot", async () => { const { session, emitted, workspaceGitService } = createSessionForWorkspaceGitWatchTests(); diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 5b442ef49..7ac35f712 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -112,6 +112,9 @@ function createNoopWorkspaceGitService() { refreshedAt: null, }, }), + resolveRepoRemoteUrl: async () => null, + resolveRepoRoot: async (cwd: string) => cwd, + resolveDefaultBranch: async () => "main", refresh: async () => {}, requestWorkingTreeWatch: async (cwd: string) => ({ repoRoot: cwd, @@ -139,9 +142,11 @@ function createWorkspaceRuntimeSnapshot( remoteUrl: "https://github.com/acme/repo.git", isPaseoOwnedWorktree: false, isDirty: false, + baseRef: "main", aheadBehind: { ahead: 0, behind: 0 }, aheadOfOrigin: 0, behindOfOrigin: 0, + hasRemote: true, diffStat: { additions: 1, deletions: 0 }, }, github: { diff --git a/packages/server/src/server/workspace-git-metadata.test.ts b/packages/server/src/server/workspace-git-metadata.test.ts index ebfe27056..5b245a7d7 100644 --- a/packages/server/src/server/workspace-git-metadata.test.ts +++ b/packages/server/src/server/workspace-git-metadata.test.ts @@ -82,7 +82,7 @@ describe("deriveProjectSlug", () => { ])("slugifies the GitHub repo name from %s", (remoteUrl, expectedSlug) => { const cwd = track(createGitWorkspace("fallback-name", remoteUrl)); - expect(deriveProjectSlug(cwd)).toBe(expectedSlug); + expect(deriveProjectSlug(cwd, remoteUrl)).toBe(expectedSlug); }); test("uses only the repo name, so identical repo names collide across owners", () => { @@ -93,8 +93,8 @@ describe("deriveProjectSlug", () => { createGitWorkspace("other-fallback", "https://github.com/other/claude-code"), ); - expect(deriveProjectSlug(acmeCwd)).toBe("claude-code"); - expect(deriveProjectSlug(otherCwd)).toBe("claude-code"); + expect(deriveProjectSlug(acmeCwd, "https://github.com/acme/claude-code")).toBe("claude-code"); + expect(deriveProjectSlug(otherCwd, "https://github.com/other/claude-code")).toBe("claude-code"); }); test("falls through to the cwd basename for non-GitHub remotes", () => { @@ -104,7 +104,7 @@ describe("deriveProjectSlug", () => { runGit(cwd, ["init"]); runGit(cwd, ["config", "remote.origin.url", "git@gitlab.com:acme/claude-code.git"]); - expect(deriveProjectSlug(cwd)).toBe("my-local-repo"); + expect(deriveProjectSlug(cwd, "git@gitlab.com:acme/claude-code.git")).toBe("my-local-repo"); }); test("falls through to the cwd basename for embedded GitHub paths in non-GitHub remotes", () => { @@ -118,7 +118,9 @@ describe("deriveProjectSlug", () => { "https://gitlab.example/mirror/github.com/acme/claude-code.git", ]); - expect(deriveProjectSlug(cwd)).toBe("embedded-github-path"); + expect( + deriveProjectSlug(cwd, "https://gitlab.example/mirror/github.com/acme/claude-code.git"), + ).toBe("embedded-github-path"); }); test("falls through to the cwd basename for an empty remote", () => { diff --git a/packages/server/src/server/workspace-git-metadata.ts b/packages/server/src/server/workspace-git-metadata.ts index e0a2d22d5..1793c79b1 100644 --- a/packages/server/src/server/workspace-git-metadata.ts +++ b/packages/server/src/server/workspace-git-metadata.ts @@ -1,7 +1,5 @@ -import { execSync } from "child_process"; import { basename } from "path"; import { slugify } from "../utils/worktree.js"; -import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js"; export type WorkspaceGitMetadata = { projectKind: "git" | "directory"; @@ -9,23 +7,12 @@ export type WorkspaceGitMetadata = { workspaceDisplayName: string; gitRemote: string | null; isWorktree: boolean; + projectSlug: string; + repoRoot: string | null; + currentBranch: string | null; + remoteUrl: string | null; }; -export function readGitCommand(cwd: string, command: string): string | null { - try { - const output = execSync(command, { - cwd, - env: READ_ONLY_GIT_ENV, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }); - const trimmed = output.trim(); - return trimmed.length > 0 ? trimmed : null; - } catch { - return null; - } -} - export function parseGitHubRepoFromRemote(remoteUrl: string): string | null { let cleaned = remoteUrl.trim(); if (!cleaned) { @@ -74,39 +61,48 @@ export function parseGitHubRepoNameFromRemote(remoteUrl: string): string | null return repoName && repoName.length > 0 ? repoName : null; } -export function deriveProjectSlug(cwd: string): string { - const gitRemote = readGitCommand(cwd, "git config --get remote.origin.url"); - const githubRepoName = gitRemote ? parseGitHubRepoNameFromRemote(gitRemote) : null; +export function deriveProjectSlug(cwd: string, remoteUrl: string | null = null): string { + const githubRepoName = remoteUrl ? parseGitHubRepoNameFromRemote(remoteUrl) : null; const sourceName = githubRepoName ?? basename(cwd); return slugify(sourceName) || "untitled"; } -export function detectWorkspaceGitMetadata( - cwd: string, - directoryName: string, -): WorkspaceGitMetadata { - const gitDir = readGitCommand(cwd, "git rev-parse --git-dir"); - if (!gitDir) { +export function buildWorkspaceGitMetadataFromSnapshot(input: { + cwd: string; + directoryName: string; + isGit: boolean; + repoRoot: string | null; + mainRepoRoot: string | null; + currentBranch: string | null; + remoteUrl: string | null; +}): WorkspaceGitMetadata { + if (!input.isGit) { return { projectKind: "directory", - projectDisplayName: directoryName, - workspaceDisplayName: directoryName, + projectDisplayName: input.directoryName, + workspaceDisplayName: input.directoryName, gitRemote: null, isWorktree: false, + projectSlug: deriveProjectSlug(input.cwd, null), + repoRoot: null, + currentBranch: null, + remoteUrl: null, }; } - const gitRemote = readGitCommand(cwd, "git config --get remote.origin.url"); - const githubRepo = gitRemote ? parseGitHubRepoFromRemote(gitRemote) : null; - const branchName = readGitCommand(cwd, "git symbolic-ref --short HEAD"); - const gitCommonDir = readGitCommand(cwd, "git rev-parse --git-common-dir"); - const isWorktree = gitCommonDir !== null && gitDir !== gitCommonDir; + const githubRepo = input.remoteUrl ? parseGitHubRepoFromRemote(input.remoteUrl) : null; + const isWorktree = + input.mainRepoRoot !== null && input.repoRoot !== null && input.mainRepoRoot !== input.repoRoot; return { projectKind: "git", - projectDisplayName: githubRepo ?? directoryName, - workspaceDisplayName: branchName ?? directoryName, - gitRemote, + projectDisplayName: githubRepo ?? input.directoryName, + workspaceDisplayName: input.currentBranch ?? input.directoryName, + gitRemote: input.remoteUrl, isWorktree, + projectSlug: deriveProjectSlug(input.cwd, input.remoteUrl), + repoRoot: input.repoRoot, + currentBranch: input.currentBranch, + remoteUrl: input.remoteUrl, }; } diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts new file mode 100644 index 000000000..781437426 --- /dev/null +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -0,0 +1,1278 @@ +import { execSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + createGitHubService, + type GitHubCurrentPullRequestStatus, + type GitHubService, +} from "../services/github-service.js"; +import { + getCheckoutDiff as getCheckoutDiffUncached, + getCheckoutStatus as getCheckoutStatusUncached, + type CheckoutDiffCompare, + type CheckoutDiffResult, + type CheckoutStatusGit, + type 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 createWatcher() { + return { + close: vi.fn(), + on: vi.fn().mockReturnThis(), + }; +} + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +async function flushPromises(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +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(): PullRequestStatusResult { + return { + status: { + url: "https://github.com/acme/repo/pull/123", + title: "Update feature", + state: "open", + baseRefName: "main", + headRefName: "feature", + isMerged: false, + }, + githubFeaturesEnabled: true, + }; +} + +function currentPullRequestJson(overrides: Record = {}): string { + return JSON.stringify({ + number: 123, + url: "https://github.com/acme/repo/pull/123", + title: "Update feature", + state: "OPEN", + isDraft: false, + baseRefName: "main", + headRefName: "feature", + mergedAt: null, + statusCheckRollup: [], + reviewDecision: "REVIEW_REQUIRED", + ...overrides, + }); +} + +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, + baseRef: "main", + aheadBehind: { ahead: 0, behind: 0 }, + aheadOfOrigin: 0, + behindOfOrigin: 0, + hasRemote: true, + 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 createGitHubServiceStub(): GitHubService { + return { + listPullRequests: vi.fn(async () => []), + listIssues: vi.fn(async () => []), + searchIssuesAndPrs: vi.fn(async () => ({ items: [], githubFeaturesEnabled: true })), + getPullRequest: vi.fn(async () => ({ + number: 1, + title: "PR", + url: "https://github.com/acme/repo/pull/1", + state: "OPEN", + body: null, + baseRefName: "main", + headRefName: "feature", + labels: [], + })), + getPullRequestHeadRef: vi.fn(async () => "feature"), + getCurrentPullRequestStatus: vi.fn(async () => null), + getPullRequestTimeline: vi.fn(async () => ({ + pullRequest: null, + events: [], + })), + createPullRequest: vi.fn(async () => ({ + url: "https://github.com/acme/repo/pull/1", + number: 1, + })), + isAuthenticated: vi.fn(async () => true), + invalidate: vi.fn(), + }; +} + +function createService(options?: { + getCheckoutStatus?: ReturnType; + getCheckoutShortstat?: ReturnType; + getPullRequestStatus?: ReturnType; + getCheckoutDiff?: ReturnType; + resolveBranchCheckout?: ReturnType; + resolveRepositoryDefaultBranch?: ReturnType; + listBranchSuggestions?: ReturnType; + listPaseoWorktrees?: ReturnType; + github?: GitHubService; + resolveAbsoluteGitDir?: ReturnType; + hasOriginRemote?: ReturnType; + runGitFetch?: ReturnType; + runGitCommand?: ReturnType; + watch?: ReturnType; + now?: () => Date; +}) { + return new WorkspaceGitServiceImpl({ + logger: createLogger() as never, + paseoHome: "/tmp/paseo-test", + deps: { + watch: options?.watch ?? ((() => createWatcher()) as never), + readdir: vi.fn(async () => []), + 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()), + getCheckoutDiff: + options?.getCheckoutDiff ?? vi.fn(async () => ({ diff: "", structured: [] })), + resolveBranchCheckout: + options?.resolveBranchCheckout ?? vi.fn(async () => ({ kind: "not-found" })), + resolveRepositoryDefaultBranch: + options?.resolveRepositoryDefaultBranch ?? vi.fn(async () => "main"), + listBranchSuggestions: options?.listBranchSuggestions ?? vi.fn(async () => []), + listPaseoWorktrees: options?.listPaseoWorktrees ?? vi.fn(async () => []), + github: options?.github ?? createGitHubServiceStub(), + resolveAbsoluteGitDir: options?.resolveAbsoluteGitDir ?? vi.fn(async () => "/tmp/repo/.git"), + hasOriginRemote: options?.hasOriginRemote ?? vi.fn(async () => false), + runGitFetch: options?.runGitFetch ?? vi.fn(async () => {}), + runGitCommand: + options?.runGitCommand ?? + vi.fn(async () => ({ + stdout: "/tmp/repo\n", + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + })), + now: options?.now ?? (() => new Date("2026-04-12T00:00:00.000Z")), + }, + }); +} + +describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("getSnapshot returns cached warm value without shelling out within consumer TTL", async () => { + let nowMs = Date.parse("2026-04-12T00:00:00.000Z"); + const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); + const service = createService({ + getCheckoutStatus, + now: () => new Date(nowMs), + }); + + await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo")); + nowMs += 1_000; + await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo")); + + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); + + service.dispose(); + }); + + test("getSnapshot cold-loads when no snapshot exists yet with one shell burst", async () => { + const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); + const getCheckoutShortstat = vi.fn(async () => ({ additions: 1, deletions: 0 })); + const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); + const service = createService({ + getCheckoutStatus, + getCheckoutShortstat, + getPullRequestStatus, + }); + + await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo")); + + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); + expect(getCheckoutShortstat).toHaveBeenCalledTimes(1); + expect(getPullRequestStatus).toHaveBeenCalledTimes(1); + + service.dispose(); + }); + + test("forced getSnapshot bypasses the internal min-gap and re-shells", async () => { + let nowMs = 0; + const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); + const service = createService({ + getCheckoutStatus, + now: () => new Date(nowMs), + }); + + await service.getSnapshot("/tmp/repo"); + nowMs = 1; + await service.getSnapshot("/tmp/repo", { force: true, reason: "test" }); + + expect(getCheckoutStatus).toHaveBeenCalledTimes(2); + + service.dispose(); + }); + + test("forced getSnapshot emits even when the fingerprint matches", async () => { + const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); + const service = createService({ getCheckoutStatus }); + const listener = vi.fn(); + const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener); + + await service.getSnapshot("/tmp/repo", { force: true, reason: "test" }); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(createSnapshot("/tmp/repo")); + + subscription.unsubscribe(); + service.dispose(); + }); + + test("non-forced refresh with a matching fingerprint does not emit", async () => { + let nowMs = 0; + const getCheckoutStatus = vi.fn(async (cwd: string) => + createCheckoutStatus(cwd, { remoteUrl: null }), + ); + const service = createService({ + getCheckoutStatus, + now: () => new Date(nowMs), + }); + const listener = vi.fn(); + const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener); + + nowMs = 3_000; + await service.refresh("/tmp/repo"); + + expect(listener).not.toHaveBeenCalled(); + + subscription.unsubscribe(); + service.dispose(); + }); + + test("two concurrent getSnapshot calls produce one shell burst and share the result", async () => { + const checkoutStatusDeferred = createDeferred(); + const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise); + const service = createService({ getCheckoutStatus }); + + const first = service.getSnapshot("/tmp/repo"); + const second = service.getSnapshot("/tmp/repo/."); + await flushPromises(); + + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); + + checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo")); + + await expect(Promise.all([first, second])).resolves.toEqual([ + createSnapshot("/tmp/repo"), + createSnapshot("/tmp/repo"), + ]); + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); + + service.dispose(); + }); + + test("warm non-forced getSnapshot calls attach to an in-flight refresh instead of returning stale cache", async () => { + let nowMs = Date.parse("2026-04-12T00:00:00.000Z"); + const refreshStatus = createDeferred(); + const getCheckoutStatus = vi + .fn<(cwd: string) => Promise>() + .mockImplementationOnce(async (cwd: string) => createCheckoutStatus(cwd)) + .mockImplementationOnce(async () => + refreshStatus.promise.then((status) => ({ + ...status, + currentBranch: "feature", + })), + ); + const getCheckoutShortstat = vi.fn(async () => ({ additions: 4, deletions: 2 })); + const service = createService({ + getCheckoutStatus, + getCheckoutShortstat, + now: () => new Date(nowMs), + }); + + await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual( + createSnapshot("/tmp/repo", { + git: { diffStat: { additions: 4, deletions: 2 } }, + }), + ); + + nowMs += 16_000; + const first = service.getSnapshot("/tmp/repo"); + await flushPromises(); + const second = service.getSnapshot("/tmp/repo"); + await flushPromises(); + + expect(getCheckoutStatus).toHaveBeenCalledTimes(2); + expect(getCheckoutShortstat).toHaveBeenCalledTimes(1); + + refreshStatus.resolve(createCheckoutStatus("/tmp/repo", { currentBranch: "feature" })); + + const freshSnapshot = createSnapshot("/tmp/repo", { + git: { + currentBranch: "feature", + diffStat: { additions: 4, deletions: 2 }, + }, + github: { + refreshedAt: "2026-04-12T00:00:16.000Z", + }, + }); + await expect(Promise.all([first, second])).resolves.toEqual([freshSnapshot, freshSnapshot]); + expect(getCheckoutStatus).toHaveBeenCalledTimes(2); + expect(getCheckoutShortstat).toHaveBeenCalledTimes(2); + + service.dispose(); + }); + + test("five ref-watch-triggered refreshes within debounce produce one shell burst", async () => { + let nowMs = 0; + const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); + const service = createService({ + getCheckoutStatus, + now: () => new Date(nowMs), + }); + await service.getSnapshot("/tmp/repo"); + + nowMs = 3_000; + for (let index = 0; index < 5; index += 1) { + service.scheduleRefreshForCwd("/tmp/repo"); + } + await vi.advanceTimersByTimeAsync(500); + await flushPromises(); + + expect(getCheckoutStatus).toHaveBeenCalledTimes(2); + + service.dispose(); + }); + + test("a forced call during an in-flight non-forced refresh queues one forced re-run", async () => { + let nowMs = 0; + const secondRefresh = createDeferred(); + const getCheckoutStatus = vi + .fn<() => Promise>() + .mockImplementationOnce(async () => createCheckoutStatus("/tmp/repo")) + .mockImplementationOnce(async () => secondRefresh.promise) + .mockImplementation(async () => createCheckoutStatus("/tmp/repo")); + const service = createService({ + getCheckoutStatus, + now: () => new Date(nowMs), + }); + await service.getSnapshot("/tmp/repo"); + + nowMs = 3_000; + const refreshPromise = service.refresh("/tmp/repo"); + await flushPromises(); + const forcedPromise = service.getSnapshot("/tmp/repo", { force: true, reason: "test" }); + const duplicateForcedPromise = service.getSnapshot("/tmp/repo", { + force: true, + reason: "test", + }); + await flushPromises(); + + expect(getCheckoutStatus).toHaveBeenCalledTimes(2); + + secondRefresh.resolve(createCheckoutStatus("/tmp/repo")); + await Promise.all([refreshPromise, forcedPromise, duplicateForcedPromise]); + + expect(getCheckoutStatus).toHaveBeenCalledTimes(3); + + service.dispose(); + }); + + test("a forced call during an in-flight forced refresh does not queue another re-run", async () => { + const forcedRefresh = createDeferred(); + const getCheckoutStatus = vi + .fn<() => Promise>() + .mockImplementationOnce(async () => createCheckoutStatus("/tmp/repo")) + .mockImplementationOnce(async () => forcedRefresh.promise); + const service = createService({ getCheckoutStatus }); + await service.getSnapshot("/tmp/repo"); + + const first = service.getSnapshot("/tmp/repo", { force: true, reason: "test" }); + await flushPromises(); + const second = service.getSnapshot("/tmp/repo", { force: true, reason: "test" }); + await flushPromises(); + + expect(getCheckoutStatus).toHaveBeenCalledTimes(2); + + forcedRefresh.resolve(createCheckoutStatus("/tmp/repo")); + await Promise.all([first, second]); + + expect(getCheckoutStatus).toHaveBeenCalledTimes(2); + + service.dispose(); + }); + + test("ref-watch firing during an in-flight forced refresh does not produce an extra shell burst", async () => { + const forcedRefresh = createDeferred(); + const getCheckoutStatus = vi + .fn<() => Promise>() + .mockImplementationOnce(async () => createCheckoutStatus("/tmp/repo")) + .mockImplementationOnce(async () => forcedRefresh.promise); + const service = createService({ getCheckoutStatus }); + await service.getSnapshot("/tmp/repo"); + + const forcePromise = service.getSnapshot("/tmp/repo", { force: true, reason: "test" }); + await flushPromises(); + service.scheduleRefreshForCwd("/tmp/repo"); + await vi.advanceTimersByTimeAsync(500); + await flushPromises(); + + forcedRefresh.resolve(createCheckoutStatus("/tmp/repo")); + await forcePromise; + + expect(getCheckoutStatus).toHaveBeenCalledTimes(2); + + service.dispose(); + }); + + test("internal min-gap throttles back-to-back non-forced refreshes", async () => { + let nowMs = 0; + const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); + const service = createService({ + getCheckoutStatus, + now: () => new Date(nowMs), + }); + await service.getSnapshot("/tmp/repo"); + + nowMs = 3_000; + await service.refresh("/tmp/repo"); + nowMs = 3_001; + await service.refresh("/tmp/repo"); + + expect(getCheckoutStatus).toHaveBeenCalledTimes(2); + + service.dispose(); + }); + + test("consumer TTL triggers cold-load after expiry on getSnapshot", async () => { + let nowMs = 0; + const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); + const service = createService({ + getCheckoutStatus, + now: () => new Date(nowMs), + }); + await service.getSnapshot("/tmp/repo"); + + nowMs = 16_000; + await service.getSnapshot("/tmp/repo"); + + expect(getCheckoutStatus).toHaveBeenCalledTimes(2); + + service.dispose(); + }); + + test("self-heal timer fires at the interval and calls getSnapshot without force", async () => { + let nowMs = 0; + const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); + const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); + const service = createService({ + getCheckoutStatus, + getPullRequestStatus, + now: () => new Date(nowMs), + }); + const subscription = await service.subscribe({ cwd: "/tmp/repo" }, vi.fn()); + + nowMs = 60_000; + await vi.advanceTimersByTimeAsync(60_000); + await flushPromises(); + + expect(getCheckoutStatus).toHaveBeenCalledTimes(2); + expect(getPullRequestStatus).toHaveBeenLastCalledWith("/tmp/repo", expect.anything(), { + force: false, + reason: "self-heal-git", + }); + + subscription.unsubscribe(); + service.dispose(); + }); + + test("subscription starts GitHub self-heal reads within the fast poll window", async () => { + let nowMs = 0; + const githubReadCalls: Array<{ reason: string | undefined; tickMs: number }> = []; + const github = createGitHubService({ + ttlMs: 0, + runner: vi.fn(async () => ({ + stdout: currentPullRequestJson({ + statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "PENDING" }], + }), + stderr: "", + })), + resolveGhPath: async () => "/usr/bin/gh", + now: () => nowMs, + }); + const getCurrentPullRequestStatus = github.getCurrentPullRequestStatus.bind(github); + github.getCurrentPullRequestStatus = vi.fn( + async (options): Promise => { + githubReadCalls.push({ reason: options.reason, tickMs: nowMs }); + return getCurrentPullRequestStatus(options); + }, + ); + const getCheckoutStatus = vi.fn(async (cwd: string) => + createCheckoutStatus(cwd, { currentBranch: "feature" }), + ); + const service = createService({ + getCheckoutStatus, + github, + now: () => new Date(nowMs), + }); + const subscription = await service.subscribe({ cwd: "/tmp/repo" }, vi.fn()); + + nowMs = 20_000; + await vi.advanceTimersByTimeAsync(20_000); + await flushPromises(); + + expect(githubReadCalls).toContainEqual({ + reason: "self-heal-github", + tickMs: 20_000, + }); + + subscription.unsubscribe(); + service.dispose(); + github.dispose?.(); + }); + + test("multiple subscribers on the same target share one self-heal timer", async () => { + let nowMs = 0; + const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); + const service = createService({ + getCheckoutStatus, + now: () => new Date(nowMs), + }); + const first = await service.subscribe({ cwd: "/tmp/repo" }, vi.fn()); + const second = await service.subscribe({ cwd: "/tmp/repo/." }, vi.fn()); + + nowMs = 60_000; + await vi.advanceTimersByTimeAsync(60_000); + await flushPromises(); + + expect(getCheckoutStatus).toHaveBeenCalledTimes(2); + + first.unsubscribe(); + second.unsubscribe(); + service.dispose(); + }); + + test("unsubscribe with no remaining subscribers clears the self-heal timer", async () => { + let nowMs = 0; + const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); + const service = createService({ + getCheckoutStatus, + now: () => new Date(nowMs), + }); + const subscription = await service.subscribe({ cwd: "/tmp/repo" }, vi.fn()); + + subscription.unsubscribe(); + nowMs = 60_000; + await vi.advanceTimersByTimeAsync(60_000); + await flushPromises(); + + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); + + service.dispose(); + }); + + test("service disposal clears all self-heal timers", async () => { + let nowMs = 0; + const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); + const service = createService({ + getCheckoutStatus, + now: () => new Date(nowMs), + }); + await service.subscribe({ cwd: "/tmp/repo" }, vi.fn()); + + service.dispose(); + nowMs = 60_000; + await vi.advanceTimersByTimeAsync(60_000); + await flushPromises(); + + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); + }); + + test("self-heal poll coalesces with a concurrent direct getSnapshot call", async () => { + let nowMs = 0; + const selfHealRefresh = createDeferred(); + const getCheckoutStatus = vi + .fn<() => Promise>() + .mockImplementationOnce(async () => createCheckoutStatus("/tmp/repo")) + .mockImplementationOnce(async () => selfHealRefresh.promise); + const service = createService({ + getCheckoutStatus, + now: () => new Date(nowMs), + }); + const subscription = await service.subscribe({ cwd: "/tmp/repo" }, vi.fn()); + + nowMs = 60_000; + await vi.advanceTimersByTimeAsync(60_000); + await flushPromises(); + const directRead = service.getSnapshot("/tmp/repo"); + await flushPromises(); + + expect(getCheckoutStatus).toHaveBeenCalledTimes(2); + + selfHealRefresh.resolve(createCheckoutStatus("/tmp/repo")); + await directRead; + + expect(getCheckoutStatus).toHaveBeenCalledTimes(2); + + subscription.unsubscribe(); + service.dispose(); + }); +}); + +describe("WorkspaceGitServiceImpl D2 read methods", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("validateBranchRef cold-loads, warms, forces, and coalesces per cwd/ref", async () => { + let nowMs = 0; + const branchResolution = createDeferred<{ kind: "local"; name: string }>(); + const resolveBranchCheckout = vi + .fn() + .mockImplementationOnce(async () => branchResolution.promise) + .mockResolvedValue({ kind: "local", name: "feature" }); + const service = createService({ + resolveBranchCheckout, + now: () => new Date(nowMs), + }); + + const first = service.validateBranchRef("/tmp/repo", "feature"); + const second = service.validateBranchRef("/tmp/repo/.", "feature"); + await flushPromises(); + + expect(resolveBranchCheckout).toHaveBeenCalledTimes(1); + branchResolution.resolve({ kind: "local", name: "feature" }); + await expect(Promise.all([first, second])).resolves.toEqual([ + { kind: "local", name: "feature" }, + { kind: "local", name: "feature" }, + ]); + + nowMs = 1_000; + await service.validateBranchRef("/tmp/repo", "feature"); + expect(resolveBranchCheckout).toHaveBeenCalledTimes(1); + + await service.validateBranchRef("/tmp/repo", "feature", { force: true, reason: "test" }); + expect(resolveBranchCheckout).toHaveBeenCalledTimes(2); + + service.dispose(); + }); + + test("hasLocalBranch cold-loads, warms, forces, and coalesces per cwd/ref", async () => { + let nowMs = 0; + const branchLookup = createDeferred<{ + stdout: string; + stderr: string; + truncated: boolean; + exitCode: number; + signal: NodeJS.Signals | null; + }>(); + const runGitCommand = vi + .fn() + .mockImplementationOnce(async () => branchLookup.promise) + .mockResolvedValue({ + stdout: "", + stderr: "", + truncated: false, + exitCode: 1, + signal: null, + }); + const service = createService({ + runGitCommand, + now: () => new Date(nowMs), + }); + + const first = service.hasLocalBranch("/tmp/repo", "feature"); + const second = service.hasLocalBranch("/tmp/repo/.", "feature"); + await flushPromises(); + + expect(runGitCommand).toHaveBeenCalledTimes(1); + expect(runGitCommand).toHaveBeenCalledWith( + ["rev-parse", "--verify", "--quiet", "refs/heads/feature"], + expect.objectContaining({ + cwd: "/tmp/repo", + acceptExitCodes: [0, 1], + }), + ); + branchLookup.resolve({ + stdout: "", + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + }); + await expect(Promise.all([first, second])).resolves.toEqual([true, true]); + + nowMs = 1_000; + await expect(service.hasLocalBranch("/tmp/repo", "feature")).resolves.toBe(true); + expect(runGitCommand).toHaveBeenCalledTimes(1); + + await expect( + service.hasLocalBranch("/tmp/repo", "feature", { force: true, reason: "test" }), + ).resolves.toBe(false); + expect(runGitCommand).toHaveBeenCalledTimes(2); + + service.dispose(); + }); + + test("validateBranchRef serves stale cache during internal min-gap after a failed refresh", async () => { + let nowMs = 0; + const resolveBranchCheckout = vi + .fn() + .mockResolvedValueOnce({ kind: "local", name: "feature-old" }) + .mockRejectedValueOnce(new Error("git is busy")) + .mockResolvedValue({ kind: "local", name: "feature-new" }); + const service = createService({ + resolveBranchCheckout, + now: () => new Date(nowMs), + }); + + await expect(service.validateBranchRef("/tmp/repo", "feature")).resolves.toEqual({ + kind: "local", + name: "feature-old", + }); + + nowMs = 16_000; + resolveBranchCheckout.mockClear(); + await expect(service.validateBranchRef("/tmp/repo", "feature")).rejects.toThrow("git is busy"); + expect(resolveBranchCheckout).toHaveBeenCalledTimes(1); + + nowMs = 16_500; + await expect(service.validateBranchRef("/tmp/repo", "feature")).resolves.toEqual({ + kind: "local", + name: "feature-old", + }); + expect(resolveBranchCheckout).toHaveBeenCalledTimes(1); + + service.dispose(); + }); + + test("suggestBranchesForCwd cold-loads, warms, forces, and coalesces per query", async () => { + let nowMs = 0; + const suggestions = [{ name: "feature", committerDate: 1, hasLocal: true, hasRemote: false }]; + const suggestionsDeferred = createDeferred(); + const listBranchSuggestions = vi + .fn() + .mockImplementationOnce(async () => suggestionsDeferred.promise) + .mockResolvedValue(suggestions); + const service = createService({ + listBranchSuggestions, + now: () => new Date(nowMs), + }); + + const first = service.suggestBranchesForCwd("/tmp/repo", { query: "feat", limit: 5 }); + const second = service.suggestBranchesForCwd("/tmp/repo/.", { + query: "feat", + limit: 5, + }); + await flushPromises(); + + expect(listBranchSuggestions).toHaveBeenCalledTimes(1); + suggestionsDeferred.resolve(suggestions); + await expect(Promise.all([first, second])).resolves.toEqual([suggestions, suggestions]); + + nowMs = 1_000; + await service.suggestBranchesForCwd("/tmp/repo", { query: "feat", limit: 5 }); + expect(listBranchSuggestions).toHaveBeenCalledTimes(1); + + await service.suggestBranchesForCwd( + "/tmp/repo", + { query: "feat", limit: 5 }, + { force: true, reason: "test" }, + ); + expect(listBranchSuggestions).toHaveBeenCalledTimes(2); + + service.dispose(); + }); + + test("listStashes cold-loads, warms, forces, and coalesces per cwd", async () => { + let nowMs = 0; + const stashOutput = "stash@{0}\u0000paseo-auto-stash: feature\n"; + const stashDeferred = createDeferred<{ + stdout: string; + stderr: string; + truncated: boolean; + exitCode: number; + signal: null; + }>(); + const runGitCommand = vi + .fn() + .mockImplementationOnce(async () => stashDeferred.promise) + .mockResolvedValue({ + stdout: stashOutput, + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + }); + const service = createService({ + runGitCommand, + now: () => new Date(nowMs), + }); + + const first = service.listStashes("/tmp/repo", { paseoOnly: true }); + const second = service.listStashes("/tmp/repo/.", { paseoOnly: true }); + await flushPromises(); + + expect(runGitCommand).toHaveBeenCalledTimes(1); + stashDeferred.resolve({ + stdout: stashOutput, + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + }); + await expect(Promise.all([first, second])).resolves.toEqual([ + [{ index: 0, message: "paseo-auto-stash: feature", branch: "feature", isPaseo: true }], + [{ index: 0, message: "paseo-auto-stash: feature", branch: "feature", isPaseo: true }], + ]); + + nowMs = 1_000; + await service.listStashes("/tmp/repo", { paseoOnly: true }); + expect(runGitCommand).toHaveBeenCalledTimes(1); + + await service.listStashes("/tmp/repo", { paseoOnly: true }, { force: true, reason: "test" }); + expect(runGitCommand).toHaveBeenCalledTimes(2); + + service.dispose(); + }); + + test("listWorktrees cold-loads, warms, forces, and coalesces per repo root", async () => { + let nowMs = 0; + const worktrees = [ + { + path: "/tmp/paseo-home/worktrees/repo/feature", + createdAt: "2026-04-12T00:00:00.000Z", + branchName: "feature", + }, + ]; + const listPaseoWorktrees = vi.fn().mockResolvedValue(worktrees); + const service = createService({ + listPaseoWorktrees, + now: () => new Date(nowMs), + }); + + const first = service.listWorktrees("/tmp/repo"); + const second = service.listWorktrees("/tmp/repo/."); + await expect(Promise.all([first, second])).resolves.toEqual([worktrees, worktrees]); + expect(listPaseoWorktrees).toHaveBeenCalledTimes(1); + + nowMs = 1_000; + await service.listWorktrees("/tmp/repo"); + expect(listPaseoWorktrees).toHaveBeenCalledTimes(1); + + await service.listWorktrees("/tmp/repo", { force: true, reason: "test" }); + expect(listPaseoWorktrees).toHaveBeenCalledTimes(2); + + service.dispose(); + }); + + test("listWorktrees shares one repo-root scoped read across sibling workspace cwds", async () => { + const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "workspace-git-service-"))); + const repoDir = join(tempDir, "repo"); + const nestedWorkspaceDir = join(repoDir, "packages", "app"); + mkdirSync(nestedWorkspaceDir, { recursive: true }); + execSync("git init -b main", { cwd: repoDir, stdio: "pipe" }); + + const worktrees = [ + { + path: join(tempDir, "paseo-home", "worktrees", "repo", "feature"), + createdAt: "2026-04-12T00:00:00.000Z", + branchName: "feature", + }, + ]; + const listPaseoWorktrees = vi.fn(async () => worktrees); + const service = createService({ + getCheckoutStatus: getCheckoutStatusUncached as never, + listPaseoWorktrees, + }); + + try { + await expect( + Promise.all([service.listWorktrees(repoDir), service.listWorktrees(nestedWorkspaceDir)]), + ).resolves.toEqual([worktrees, worktrees]); + await expect(service.listWorktrees(nestedWorkspaceDir)).resolves.toEqual(worktrees); + + expect(listPaseoWorktrees).toHaveBeenCalledTimes(1); + expect(listPaseoWorktrees).toHaveBeenCalledWith({ + cwd: repoDir, + paseoHome: "/tmp/paseo-test", + }); + } finally { + service.dispose(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("resolveDefaultBranch cold-loads, warms, forces, and coalesces per cwd", async () => { + let nowMs = 0; + const defaultBranch = createDeferred(); + const resolveRepositoryDefaultBranch = vi + .fn() + .mockImplementationOnce(async () => defaultBranch.promise) + .mockResolvedValue("trunk"); + const service = createService({ + resolveRepositoryDefaultBranch, + now: () => new Date(nowMs), + }); + + const first = service.resolveDefaultBranch("/tmp/repo"); + const second = service.resolveDefaultBranch("/tmp/repo/."); + await flushPromises(); + + expect(resolveRepositoryDefaultBranch).toHaveBeenCalledTimes(1); + defaultBranch.resolve("main"); + await expect(Promise.all([first, second])).resolves.toEqual(["main", "main"]); + + nowMs = 1_000; + await service.resolveDefaultBranch("/tmp/repo"); + expect(resolveRepositoryDefaultBranch).toHaveBeenCalledTimes(1); + + await service.resolveDefaultBranch("/tmp/repo", { force: true, reason: "test" }); + expect(resolveRepositoryDefaultBranch).toHaveBeenCalledTimes(2); + + service.dispose(); + }); + + test("resolveRepoRoot cold-loads, warms, forces, and coalesces through snapshots", async () => { + let nowMs = 0; + const checkoutDeferred = createDeferred(); + const getCheckoutStatus = vi + .fn() + .mockImplementationOnce(async () => checkoutDeferred.promise) + .mockResolvedValue(createCheckoutStatus("/tmp/repo")); + const service = createService({ + getCheckoutStatus, + now: () => new Date(nowMs), + }); + + const first = service.resolveRepoRoot("/tmp/repo"); + const second = service.resolveRepoRoot("/tmp/repo/."); + await flushPromises(); + + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); + checkoutDeferred.resolve(createCheckoutStatus("/tmp/repo")); + await expect(Promise.all([first, second])).resolves.toEqual(["/tmp/repo", "/tmp/repo"]); + + nowMs = 1_000; + await service.resolveRepoRoot("/tmp/repo"); + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); + + await service.resolveRepoRoot("/tmp/repo", { force: true, reason: "test" }); + expect(getCheckoutStatus).toHaveBeenCalledTimes(2); + + service.dispose(); + }); + + test("resolveRepoRemoteUrl reads remote URL through the snapshot cache", async () => { + let nowMs = 0; + const getCheckoutStatus = vi.fn(async (cwd: string) => + createCheckoutStatus(cwd, { + remoteUrl: "https://github.com/getpaseo/paseo.git", + }), + ); + const service = createService({ + getCheckoutStatus, + now: () => new Date(nowMs), + }); + + await expect(service.resolveRepoRemoteUrl("/tmp/repo")).resolves.toBe( + "https://github.com/getpaseo/paseo.git", + ); + nowMs = 1_000; + await expect(service.resolveRepoRemoteUrl("/tmp/repo/.")).resolves.toBe( + "https://github.com/getpaseo/paseo.git", + ); + + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); + + service.dispose(); + }); + + test("getWorkspaceGitMetadata derives reconciliation metadata from the snapshot cache", async () => { + let nowMs = 0; + const getCheckoutStatus = vi.fn(async (cwd: string) => + createCheckoutStatus(cwd, { + currentBranch: "feature/service-metadata", + remoteUrl: "https://github.com/getpaseo/paseo.git", + repoRoot: "/tmp/repo", + }), + ); + const service = createService({ + getCheckoutStatus, + now: () => new Date(nowMs), + }); + + await expect( + service.getWorkspaceGitMetadata("/tmp/repo", { directoryName: "Local Repo" }), + ).resolves.toEqual({ + projectKind: "git", + projectDisplayName: "getpaseo/paseo", + workspaceDisplayName: "feature/service-metadata", + gitRemote: "https://github.com/getpaseo/paseo.git", + isWorktree: false, + projectSlug: "paseo", + repoRoot: "/tmp/repo", + currentBranch: "feature/service-metadata", + remoteUrl: "https://github.com/getpaseo/paseo.git", + }); + + nowMs = 1_000; + await service.getWorkspaceGitMetadata("/tmp/repo/.", { directoryName: "Local Repo" }); + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); + + service.dispose(); + }); + + test("getCheckoutDiff returns real staged and unstaged changes from a temp git repo", async () => { + const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "workspace-git-service-diff-"))); + const repoDir = join(tempDir, "repo"); + mkdirSync(repoDir, { recursive: true }); + execSync("git init -b main", { cwd: repoDir, stdio: "pipe" }); + execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" }); + execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" }); + writeFileSync(join(repoDir, "tracked.txt"), "before\n"); + execSync("git add tracked.txt", { cwd: repoDir, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'initial'", { + cwd: repoDir, + stdio: "pipe", + }); + writeFileSync(join(repoDir, "tracked.txt"), "before\nafter\n"); + writeFileSync(join(repoDir, "staged.txt"), "staged\n"); + execSync("git add staged.txt", { cwd: repoDir, stdio: "pipe" }); + + const service = createService({ + getCheckoutDiff: getCheckoutDiffUncached as never, + }); + + try { + const diff = await service.getCheckoutDiff(repoDir, { + mode: "uncommitted", + includeStructured: true, + }); + + expect(diff.diff).toContain("tracked.txt"); + expect(diff.diff).toContain("staged.txt"); + expect(diff.structured?.map((file) => file.path).sort()).toEqual([ + "staged.txt", + "tracked.txt", + ]); + } finally { + service.dispose(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("getCheckoutDiff coalesces concurrent callers per cwd and compare options", async () => { + const diffDeferred = createDeferred(); + const getCheckoutDiff = vi + .fn<(cwd: string, compare: CheckoutDiffCompare) => Promise>() + .mockImplementationOnce(async () => diffDeferred.promise) + .mockResolvedValue({ diff: "second" }); + const service = createService({ getCheckoutDiff, now: () => new Date(0) }); + + const first = service.getCheckoutDiff("/tmp/repo", { mode: "uncommitted" }); + const second = service.getCheckoutDiff("/tmp/repo/.", { mode: "uncommitted" }); + await flushPromises(); + + expect(getCheckoutDiff).toHaveBeenCalledTimes(1); + diffDeferred.resolve({ diff: "shared" }); + await expect(Promise.all([first, second])).resolves.toEqual([ + { diff: "shared" }, + { diff: "shared" }, + ]); + expect(getCheckoutDiff).toHaveBeenCalledTimes(1); + + service.dispose(); + }); + + test("forced getCheckoutDiff bypasses warm cache and internal min-gap", async () => { + let nowMs = 0; + const getCheckoutDiff = vi + .fn() + .mockResolvedValueOnce({ diff: "first" }) + .mockResolvedValueOnce({ diff: "forced" }); + const service = createService({ + getCheckoutDiff, + now: () => new Date(nowMs), + }); + + await expect(service.getCheckoutDiff("/tmp/repo", { mode: "uncommitted" })).resolves.toEqual({ + diff: "first", + }); + nowMs = 1; + await expect( + service.getCheckoutDiff( + "/tmp/repo", + { mode: "uncommitted" }, + { force: true, reason: "test" }, + ), + ).resolves.toEqual({ diff: "forced" }); + + expect(getCheckoutDiff).toHaveBeenCalledTimes(2); + + service.dispose(); + }); + + test("getCheckoutDiff serves cached value within the internal min-gap for non-forced reads", async () => { + let nowMs = 0; + const getCheckoutDiff = vi + .fn() + .mockResolvedValueOnce({ diff: "first" }) + .mockRejectedValueOnce(new Error("git is busy")) + .mockResolvedValueOnce({ diff: "second" }); + const service = createService({ + getCheckoutDiff, + now: () => new Date(nowMs), + }); + + await expect(service.getCheckoutDiff("/tmp/repo", { mode: "uncommitted" })).resolves.toEqual({ + diff: "first", + }); + nowMs = 16_000; + await expect(service.getCheckoutDiff("/tmp/repo", { mode: "uncommitted" })).rejects.toThrow( + "git is busy", + ); + nowMs = 16_500; + await expect(service.getCheckoutDiff("/tmp/repo", { mode: "uncommitted" })).resolves.toEqual({ + diff: "first", + }); + + expect(getCheckoutDiff).toHaveBeenCalledTimes(2); + + service.dispose(); + }); + + test("getCheckoutDiff uses different cache keys for different compare arguments", async () => { + const getCheckoutDiff = vi + .fn() + .mockResolvedValueOnce({ diff: "main" }) + .mockResolvedValueOnce({ diff: "release" }) + .mockResolvedValueOnce({ diff: "main-whitespace" }); + const service = createService({ + getCheckoutDiff, + now: () => new Date(0), + }); + + await expect( + service.getCheckoutDiff("/tmp/repo", { mode: "base", baseRef: "main" }), + ).resolves.toEqual({ diff: "main" }); + await expect( + service.getCheckoutDiff("/tmp/repo", { mode: "base", baseRef: "release" }), + ).resolves.toEqual({ diff: "release" }); + await expect( + service.getCheckoutDiff("/tmp/repo", { + mode: "base", + baseRef: "main", + ignoreWhitespace: true, + }), + ).resolves.toEqual({ diff: "main-whitespace" }); + + expect(getCheckoutDiff).toHaveBeenCalledTimes(3); + + service.dispose(); + }); +}); diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index 2d9e9ffcf..9028d1d16 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -33,9 +33,11 @@ function createSnapshot( remoteUrl: "https://github.com/acme/repo.git", isPaseoOwnedWorktree: false, isDirty: false, + baseRef: "main", aheadBehind: { ahead: 0, behind: 0 }, aheadOfOrigin: 0, behindOfOrigin: 0, + hasRemote: true, diffStat: { additions: 1, deletions: 0 }, }, github: { @@ -144,6 +146,7 @@ function createGitHubServiceStub(): GitHubService { return { listPullRequests: vi.fn(async () => []), listIssues: vi.fn(async () => []), + searchIssuesAndPrs: vi.fn(async () => ({ items: [], githubFeaturesEnabled: true })), getPullRequest: vi.fn(async () => ({ number: 1, title: "PR", @@ -292,7 +295,7 @@ describe("WorkspaceGitServiceImpl", () => { expect(getCheckoutStatus).toHaveBeenCalledTimes(1); expect(getPullRequestStatus).toHaveBeenCalledTimes(0); expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0); - expect((service as any).workspaceTargets.size).toBe(0); + expect((service as any).workspaceTargets.size).toBe(1); expect((service as any).workspaceTargetSetups.size).toBe(1); checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo")); @@ -319,9 +322,11 @@ describe("WorkspaceGitServiceImpl", () => { const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git"); + let nowMs = Date.parse("2026-04-12T00:00:00.000Z"); const service = createService({ getPullRequestStatus, resolveAbsoluteGitDir, + now: () => new Date(nowMs), }); const [first, second] = await Promise.all([ @@ -342,9 +347,11 @@ describe("WorkspaceGitServiceImpl", () => { const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git"); + let nowMs = Date.parse("2026-04-12T00:00:00.000Z"); const service = createService({ getPullRequestStatus, resolveAbsoluteGitDir, + now: () => new Date(nowMs), }); const subscription = await service.subscribe({ cwd: "/tmp/repo/." }, vi.fn()); @@ -352,8 +359,15 @@ describe("WorkspaceGitServiceImpl", () => { expect(subscription.initial).toEqual(createSnapshot("/tmp/repo")); expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo")); + nowMs += 3_000; await service.refresh("/tmp/repo"); - await expect(service.getSnapshot("/tmp/repo/.")).resolves.toEqual(createSnapshot("/tmp/repo")); + await expect(service.getSnapshot("/tmp/repo/.")).resolves.toEqual( + createSnapshot("/tmp/repo", { + github: { + refreshedAt: "2026-04-12T00:00:03.000Z", + }, + }), + ); expect(getPullRequestStatus).toHaveBeenCalledTimes(2); expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1); @@ -430,8 +444,7 @@ describe("WorkspaceGitServiceImpl", () => { expect(subscription.initial.github.pullRequest?.title).toBe("Before refresh"); - service.refresh("/tmp/repo"); - await (service as any).workspaceTargets.get("/tmp/repo")?.refreshPromise; + await service.refresh("/tmp/repo"); await flushPromises(); expect(getPullRequestStatus).toHaveBeenCalledTimes(2); @@ -459,10 +472,11 @@ describe("WorkspaceGitServiceImpl", () => { test("unchanged runtime snapshots do not emit duplicate updates", async () => { const getCheckoutStatus = vi .fn<() => Promise>() - .mockResolvedValueOnce(createCheckoutStatus("/tmp/repo")) + .mockResolvedValueOnce(createCheckoutStatus("/tmp/repo", { remoteUrl: null })) .mockResolvedValueOnce( createCheckoutStatus("/tmp/repo", { currentBranch: "feature/runtime-payloads", + remoteUrl: null, aheadBehind: { ahead: 2, behind: 0 }, aheadOfOrigin: 2, }), @@ -470,6 +484,7 @@ describe("WorkspaceGitServiceImpl", () => { .mockResolvedValueOnce( createCheckoutStatus("/tmp/repo", { currentBranch: "feature/runtime-payloads", + remoteUrl: null, aheadBehind: { ahead: 2, behind: 0 }, aheadOfOrigin: 2, }), @@ -487,10 +502,11 @@ describe("WorkspaceGitServiceImpl", () => { }), ); + let nowMs = Date.parse("2026-04-12T00:00:00.000Z"); const service = createService({ getCheckoutStatus, getPullRequestStatus, - now: () => new Date("2026-04-12T00:00:00.000Z"), + now: () => new Date(nowMs), }); const listener = vi.fn(); @@ -498,12 +514,12 @@ describe("WorkspaceGitServiceImpl", () => { expect(subscription.initial.git.currentBranch).toBe("main"); - service.refresh("/tmp/repo"); - await (service as any).workspaceTargets.get("/tmp/repo")?.refreshPromise; + nowMs += 3_000; + await service.refresh("/tmp/repo"); await flushPromises(); - service.refresh("/tmp/repo"); - await (service as any).workspaceTargets.get("/tmp/repo")?.refreshPromise; + nowMs += 3_000; + await service.refresh("/tmp/repo"); await flushPromises(); expect(listener).toHaveBeenCalledTimes(1); @@ -511,18 +527,14 @@ describe("WorkspaceGitServiceImpl", () => { createSnapshot("/tmp/repo", { git: { currentBranch: "feature/runtime-payloads", + remoteUrl: null, 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, - }, + featuresEnabled: false, + pullRequest: null, + refreshedAt: null, }, }), ); @@ -531,6 +543,32 @@ describe("WorkspaceGitServiceImpl", () => { service.dispose(); }); + test("forced snapshot refresh emits even when the fingerprint matches", async () => { + const getCheckoutStatus = vi.fn(async () => createCheckoutStatus("/tmp/repo")); + const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); + let nowMs = Date.parse("2026-04-12T00:00:00.000Z"); + const service = createService({ + getCheckoutStatus, + getPullRequestStatus, + now: () => new Date(nowMs), + }); + + const listener = vi.fn(); + const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener); + + await service.getSnapshot("/tmp/repo", { + force: true, + reason: "test-force-emit", + }); + await flushPromises(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(createSnapshot("/tmp/repo")); + + subscription.unsubscribe(); + service.dispose(); + }); + test("watches nested repository directories on Linux", async () => { const originalPlatform = process.platform; Object.defineProperty(process, "platform", { diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 469f890d4..8b3ba38eb 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -4,21 +4,39 @@ import { join, resolve } from "node:path"; import type pino from "pino"; import type { CheckoutContext } from "../utils/checkout-git.js"; import { + type BranchCheckoutResolution, + type BranchSuggestion, + type CheckoutDiffCompare, + type CheckoutDiffResult, + getCheckoutDiff, getCheckoutShortstat, getCheckoutStatus, getPullRequestStatus, hasOriginRemote, + listBranchSuggestions, + resolveRepositoryDefaultBranch, + resolveBranchCheckout, resolveAbsoluteGitDir, } from "../utils/checkout-git.js"; import { createGitHubService, type GitHubService } from "../services/github-service.js"; import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js"; import { runGitCommand } from "../utils/run-git-command.js"; +import { listPaseoWorktrees, type PaseoWorktreeInfo } from "../utils/worktree.js"; import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js"; +import { + buildWorkspaceGitMetadataFromSnapshot, + type WorkspaceGitMetadata, +} from "./workspace-git-metadata.js"; import { normalizeWorkspaceId } from "./workspace-registry-model.js"; const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500; const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000; +export const WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS = 60_000; const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_000; +// Consumer reads may reuse cached values within this window; older peeks cold-load through the service. +const WORKSPACE_GIT_CONSUMER_TTL_MS = 15_000; +// Non-forced refresh triggers share this minimum gap to absorb watcher/self-heal bursts; force bypasses it. +const WORKSPACE_GIT_INTERNAL_MIN_GAP_MS = 2_000; export type WorkspaceGitRuntimeSnapshot = { cwd: string; @@ -30,9 +48,11 @@ export type WorkspaceGitRuntimeSnapshot = { remoteUrl: string | null; isPaseoOwnedWorktree: boolean; isDirty: boolean | null; + baseRef: string | null; aheadBehind: { ahead: number; behind: number } | null; aheadOfOrigin: number | null; behindOfOrigin: number | null; + hasRemote: boolean; diffStat: { additions: number; deletions: number } | null; }; github: { @@ -73,7 +93,42 @@ export interface WorkspaceGitService { }>; peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null; - getSnapshot(cwd: string): Promise; + getSnapshot( + cwd: string, + options?: WorkspaceGitSnapshotOptions, + ): Promise; + getCheckoutDiff( + cwd: string, + options: CheckoutDiffCompare, + readOptions?: WorkspaceGitReadOptions, + ): Promise; + validateBranchRef( + cwd: string, + ref: string, + options?: WorkspaceGitReadOptions, + ): Promise; + hasLocalBranch(cwd: string, branch: string, options?: WorkspaceGitReadOptions): Promise; + suggestBranchesForCwd( + cwd: string, + options?: WorkspaceGitBranchSuggestionsOptions, + readOptions?: WorkspaceGitReadOptions, + ): Promise; + listStashes( + cwd: string, + options?: WorkspaceGitStashListOptions, + readOptions?: WorkspaceGitReadOptions, + ): Promise; + listWorktrees( + cwdOrRepoRoot: string, + options?: WorkspaceGitReadOptions, + ): Promise; + getWorkspaceGitMetadata( + cwd: string, + options?: WorkspaceGitReadOptions & { directoryName?: string }, + ): Promise; + resolveRepoRoot(cwd: string, options?: WorkspaceGitReadOptions): Promise; + resolveDefaultBranch(cwdOrRepoRoot: string, options?: WorkspaceGitReadOptions): Promise; + resolveRepoRemoteUrl(cwd: string, options?: WorkspaceGitReadOptions): Promise; refresh(cwd: string, options?: { priority?: "normal" | "high" }): Promise; requestWorkingTreeWatch( cwd: string, @@ -85,12 +140,85 @@ export interface WorkspaceGitService { export type WorkspaceGitListener = (snapshot: WorkspaceGitRuntimeSnapshot) => void; +export type WorkspaceGitReadOptions = + | { + force?: false; + reason?: string; + } + | { + force: true; + reason: string; + }; + +export interface WorkspaceGitBranchSuggestionsOptions { + query?: string; + limit?: number; +} + +export interface WorkspaceGitStashListOptions { + paseoOnly?: boolean; +} + +export interface WorkspaceGitStashEntry { + index: number; + message: string; + branch: string | null; + isPaseo: boolean; +} + +export type WorkspaceGitBranchValidationResult = BranchCheckoutResolution; +export type WorkspaceGitBranchSuggestion = BranchSuggestion; +export type WorkspaceGitWorktreeInfo = PaseoWorktreeInfo; + +export type WorkspaceGitSnapshotOptions = + | { + force?: false; + includeGitHub?: boolean; + reason?: string; + } + | { + force: true; + includeGitHub?: boolean; + reason: string; + }; + +interface WorkspaceGitRefreshRequest { + force: boolean; + includeGitHub: boolean; + reason: string; + notify: boolean; +} + +interface QueuedWorkspaceGitRefresh { + force: boolean; + includeGitHub: boolean; + reason: string; + notify: boolean; +} + +type WorkspaceGitRefreshState = + | { + status: "idle"; + } + | { + status: "in-flight"; + promise: Promise; + force: boolean; + includeGitHub: boolean; + queued: QueuedWorkspaceGitRefresh | null; + }; + interface WorkspaceGitServiceDependencies { watch: typeof watch; readdir: typeof readdir; getCheckoutStatus: typeof getCheckoutStatus; getCheckoutShortstat: typeof getCheckoutShortstat; + getCheckoutDiff: typeof getCheckoutDiff; getPullRequestStatus: typeof getPullRequestStatus; + resolveBranchCheckout: typeof resolveBranchCheckout; + resolveRepositoryDefaultBranch: typeof resolveRepositoryDefaultBranch; + listBranchSuggestions: typeof listBranchSuggestions; + listPaseoWorktrees: typeof listPaseoWorktrees; github: GitHubService; resolveAbsoluteGitDir: (cwd: string) => Promise; hasOriginRemote: (cwd: string) => Promise; @@ -110,10 +238,14 @@ interface WorkspaceGitTarget { listeners: Set; watchers: FSWatcher[]; debounceTimer: NodeJS.Timeout | null; - refreshPromise: Promise | null; - refreshQueued: boolean; + selfHealTimer: NodeJS.Timeout | null; + githubPollSubscription: { unsubscribe: () => void } | null; + githubPollHeadRef: string | null; + refreshState: WorkspaceGitRefreshState; latestSnapshot: WorkspaceGitRuntimeSnapshot | null; + latestSnapshotLoadedAtMs: number | null; latestFingerprint: string | null; + lastShellOutAtMs: number | null; repoGitRoot: string | null; } @@ -137,6 +269,13 @@ interface WorkingTreeWatchTarget { listeners: Set<() => void>; } +interface WorkspaceGitAuxiliaryReadCacheEntry { + value: T | null; + loadedAtMs: number | null; + lastShellOutAtMs: number | null; + inFlight: Promise | null; +} + export class WorkspaceGitServiceImpl implements WorkspaceGitService { private readonly logger: pino.Logger; private readonly paseoHome: string; @@ -146,6 +285,34 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private readonly workspaceTargetSetups = new Map>(); private readonly workingTreeWatchTargets = new Map(); private readonly workingTreeWatchSetups = new Map>(); + private readonly branchValidationCache = new Map< + string, + WorkspaceGitAuxiliaryReadCacheEntry + >(); + private readonly localBranchCache = new Map< + string, + WorkspaceGitAuxiliaryReadCacheEntry + >(); + private readonly branchSuggestionsCache = new Map< + string, + WorkspaceGitAuxiliaryReadCacheEntry + >(); + private readonly stashListCache = new Map< + string, + WorkspaceGitAuxiliaryReadCacheEntry + >(); + private readonly worktreeListCache = new Map< + string, + WorkspaceGitAuxiliaryReadCacheEntry + >(); + private readonly defaultBranchCache = new Map< + string, + WorkspaceGitAuxiliaryReadCacheEntry + >(); + private readonly checkoutDiffCache = new Map< + string, + WorkspaceGitAuxiliaryReadCacheEntry + >(); constructor(options: WorkspaceGitServiceOptions) { this.logger = options.logger.child({ module: "workspace-git-service" }); @@ -155,7 +322,13 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { readdir: options.deps?.readdir ?? readdir, getCheckoutStatus: options.deps?.getCheckoutStatus ?? getCheckoutStatus, getCheckoutShortstat: options.deps?.getCheckoutShortstat ?? getCheckoutShortstat, + getCheckoutDiff: options.deps?.getCheckoutDiff ?? getCheckoutDiff, getPullRequestStatus: options.deps?.getPullRequestStatus ?? getPullRequestStatus, + resolveBranchCheckout: options.deps?.resolveBranchCheckout ?? resolveBranchCheckout, + resolveRepositoryDefaultBranch: + options.deps?.resolveRepositoryDefaultBranch ?? resolveRepositoryDefaultBranch, + listBranchSuggestions: options.deps?.listBranchSuggestions ?? listBranchSuggestions, + listPaseoWorktrees: options.deps?.listPaseoWorktrees ?? listPaseoWorktrees, github: options.deps?.github ?? createGitHubService(), resolveAbsoluteGitDir: options.deps?.resolveAbsoluteGitDir ?? resolveAbsoluteGitDir, hasOriginRemote: options.deps?.hasOriginRemote ?? hasOriginRemote, @@ -175,6 +348,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const cwd = normalizeWorkspaceId(params.cwd); const target = await this.ensureWorkspaceTarget(cwd); target.listeners.add(listener); + if (target.listeners.size === 1) { + this.startWorkspaceSubscriptionTimers(target); + } return { initial: target.latestSnapshot ?? (await this.getSnapshot(cwd)), @@ -184,15 +360,18 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { }; } - async getSnapshot(cwd: string): Promise { + async getSnapshot( + cwd: string, + options?: WorkspaceGitSnapshotOptions, + ): Promise { cwd = normalizeWorkspaceId(cwd); - const target = this.workspaceTargets.get(cwd); - if (target?.latestSnapshot) { + const request = this.normalizeRefreshRequest(options, "getSnapshot", true); + const target = await this.ensureWorkspaceTarget(cwd); + if (!request.force && this.isSnapshotWarm(target)) { return target.latestSnapshot; } - const ensuredTarget = await this.ensureWorkspaceTarget(cwd); - return ensuredTarget.latestSnapshot ?? (await this.refreshSnapshot(cwd)); + return this.requestWorkspaceSnapshot(target, request); } peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null { @@ -200,11 +379,180 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return this.workspaceTargets.get(cwd)?.latestSnapshot ?? null; } + getCheckoutDiff( + cwd: string, + options: CheckoutDiffCompare, + readOptions?: WorkspaceGitReadOptions, + ): Promise { + const normalizedCwd = normalizeWorkspaceId(cwd); + const normalizedOptions = this.normalizeCheckoutDiffOptions(options); + const key = this.buildCheckoutDiffCacheKey(normalizedCwd, normalizedOptions); + return this.readAuxiliaryCache(this.checkoutDiffCache, key, readOptions, () => + this.deps.getCheckoutDiff(normalizedCwd, normalizedOptions, { paseoHome: this.paseoHome }), + ); + } + + private normalizeCheckoutDiffOptions(options: CheckoutDiffCompare): CheckoutDiffCompare { + return { + mode: options.mode, + ...(options.mode === "base" && options.baseRef !== undefined + ? { baseRef: options.baseRef } + : {}), + ...(options.ignoreWhitespace === true ? { ignoreWhitespace: true } : {}), + ...(options.includeStructured === true ? { includeStructured: true } : {}), + }; + } + + private buildCheckoutDiffCacheKey(cwd: string, options: CheckoutDiffCompare): string { + // Diff content varies by compare signature. Keep the cache per exact diff read shape so + // hot diff panes coalesce while base refs and rendering options never share stale patches. + return JSON.stringify([ + "checkout-diff", + cwd, + options.mode, + options.mode === "base" ? (options.baseRef ?? null) : null, + options.ignoreWhitespace === true, + options.includeStructured === true, + ]); + } + + validateBranchRef( + cwd: string, + ref: string, + options?: WorkspaceGitReadOptions, + ): Promise { + const normalizedCwd = normalizeWorkspaceId(cwd); + const normalizedRef = ref.trim(); + const key = JSON.stringify(["branch-validation", normalizedCwd, normalizedRef]); + return this.readAuxiliaryCache(this.branchValidationCache, key, options, () => + this.deps.resolveBranchCheckout(normalizedCwd, normalizedRef), + ); + } + + hasLocalBranch(cwd: string, branch: string, options?: WorkspaceGitReadOptions): Promise { + const normalizedCwd = normalizeWorkspaceId(cwd); + const normalizedBranch = branch.trim(); + const ref = `refs/heads/${normalizedBranch}`; + const key = JSON.stringify(["local-branch", normalizedCwd, ref]); + return this.readAuxiliaryCache(this.localBranchCache, key, options, async () => { + const result = await this.deps.runGitCommand(["rev-parse", "--verify", "--quiet", ref], { + cwd: normalizedCwd, + env: READ_ONLY_GIT_ENV, + acceptExitCodes: [0, 1], + }); + return result.exitCode === 0; + }); + } + + suggestBranchesForCwd( + cwd: string, + options?: WorkspaceGitBranchSuggestionsOptions, + readOptions?: WorkspaceGitReadOptions, + ): Promise { + const normalizedCwd = normalizeWorkspaceId(cwd); + const query = options?.query ?? ""; + const limit = options?.limit; + const key = JSON.stringify(["branch-suggestions", normalizedCwd, query, limit ?? null]); + return this.readAuxiliaryCache(this.branchSuggestionsCache, key, readOptions, () => + this.deps.listBranchSuggestions(normalizedCwd, options), + ); + } + + listStashes( + cwd: string, + options?: WorkspaceGitStashListOptions, + readOptions?: WorkspaceGitReadOptions, + ): Promise { + const normalizedCwd = normalizeWorkspaceId(cwd); + const paseoOnly = options?.paseoOnly !== false; + const key = JSON.stringify(["stashes", normalizedCwd, paseoOnly]); + return this.readAuxiliaryCache(this.stashListCache, key, readOptions, async () => { + const { stdout } = await this.deps.runGitCommand(["stash", "list", "--format=%gd%x00%s"], { + cwd: normalizedCwd, + env: READ_ONLY_GIT_ENV, + }); + return parseWorkspaceGitStashList(stdout, { paseoOnly }); + }); + } + + async listWorktrees( + cwdOrRepoRoot: string, + options?: WorkspaceGitReadOptions, + ): Promise { + const repoRoot = await this.resolveRepoRoot(cwdOrRepoRoot, options); + const key = JSON.stringify(["worktrees", repoRoot]); + return this.readAuxiliaryCache(this.worktreeListCache, key, options, () => + this.deps.listPaseoWorktrees({ + cwd: repoRoot, + paseoHome: this.paseoHome, + }), + ); + } + + async resolveRepoRoot(cwd: string, options?: WorkspaceGitReadOptions): Promise { + const snapshot = await this.getSnapshot(cwd, options); + if (!snapshot.git.isGit) { + throw new Error("Create worktree requires a git repository"); + } + + return snapshot.git.isPaseoOwnedWorktree + ? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? normalizeWorkspaceId(cwd)) + : (snapshot.git.repoRoot ?? normalizeWorkspaceId(cwd)); + } + + async resolveDefaultBranch( + cwdOrRepoRoot: string, + options?: WorkspaceGitReadOptions, + ): Promise { + const cwd = normalizeWorkspaceId(cwdOrRepoRoot); + const key = JSON.stringify(["default-branch", cwd]); + return this.readAuxiliaryCache(this.defaultBranchCache, key, options, async () => { + const defaultBranch = await this.deps.resolveRepositoryDefaultBranch(cwd); + if (!defaultBranch) { + throw new Error("Unable to resolve repository default branch"); + } + return defaultBranch; + }); + } + + async getWorkspaceGitMetadata( + cwd: string, + options?: WorkspaceGitReadOptions & { directoryName?: string }, + ): Promise { + const snapshot = await this.getSnapshot(cwd, options); + const directoryName = + options?.directoryName ?? + normalizeWorkspaceId(cwd).split(/[\\/]/).filter(Boolean).at(-1) ?? + cwd; + return buildWorkspaceGitMetadataFromSnapshot({ + cwd: normalizeWorkspaceId(cwd), + directoryName, + isGit: snapshot.git.isGit, + repoRoot: snapshot.git.repoRoot, + mainRepoRoot: snapshot.git.mainRepoRoot, + currentBranch: snapshot.git.currentBranch, + remoteUrl: snapshot.git.remoteUrl, + }); + } + + async resolveRepoRemoteUrl( + cwd: string, + options?: WorkspaceGitReadOptions, + ): Promise { + const snapshot = await this.getSnapshot(cwd, options); + return snapshot.git.remoteUrl; + } + async refresh(cwd: string, _options?: { priority?: "normal" | "high" }): Promise { cwd = normalizeWorkspaceId(cwd); const target = this.workspaceTargets.get(cwd); if (target) { - await this.refreshWorkspaceTarget(target); + await this.refreshWorkspaceTarget(target, { + force: false, + includeGitHub: false, + reason: "refresh", + notify: true, + }); return; } @@ -272,6 +620,67 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return setup; } + private readAuxiliaryCache( + cache: Map>, + key: string, + options: WorkspaceGitReadOptions | undefined, + load: () => Promise, + ): Promise { + if (options?.force && !options.reason) { + throw new Error("WorkspaceGitService forced read requires a reason"); + } + + const entry = this.ensureAuxiliaryCacheEntry(cache, key); + const nowMs = this.deps.now().getTime(); + if (!options?.force && entry.value !== null && entry.loadedAtMs !== null) { + const ageMs = nowMs - entry.loadedAtMs; + if (ageMs <= WORKSPACE_GIT_CONSUMER_TTL_MS) { + return Promise.resolve(entry.value); + } + if ( + entry.lastShellOutAtMs !== null && + nowMs - entry.lastShellOutAtMs < WORKSPACE_GIT_INTERNAL_MIN_GAP_MS + ) { + return Promise.resolve(entry.value); + } + } + + if (entry.inFlight) { + return entry.inFlight; + } + + entry.lastShellOutAtMs = nowMs; + entry.inFlight = load() + .then((value) => { + entry.value = value; + entry.loadedAtMs = this.deps.now().getTime(); + return value; + }) + .finally(() => { + entry.inFlight = null; + }); + return entry.inFlight; + } + + private ensureAuxiliaryCacheEntry( + cache: Map>, + key: string, + ): WorkspaceGitAuxiliaryReadCacheEntry { + const existing = cache.get(key); + if (existing) { + return existing; + } + + const entry: WorkspaceGitAuxiliaryReadCacheEntry = { + value: null, + loadedAtMs: null, + lastShellOutAtMs: null, + inFlight: null, + }; + cache.set(key, entry); + return entry; + } + private async ensureWorkingTreeWatchTarget(cwd: string): Promise { const existingTarget = this.workingTreeWatchTargets.get(cwd); if (existingTarget) { @@ -296,27 +705,42 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { listeners: new Set(), watchers: [], debounceTimer: null, - refreshPromise: null, - refreshQueued: false, + selfHealTimer: null, + githubPollSubscription: null, + githubPollHeadRef: null, + refreshState: { status: "idle" }, latestSnapshot: null, + latestSnapshotLoadedAtMs: null, latestFingerprint: null, + lastShellOutAtMs: 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; - } + try { + await this.requestWorkspaceSnapshot(target, { + force: false, + includeGitHub: true, + reason: "initial", + notify: false, + }); - const repoGitRoot = await this.resolveWorkspaceGitRefsRoot(gitDir); - target.repoGitRoot = repoGitRoot; - this.startWorkspaceWatchers(target, gitDir, repoGitRoot); - await this.ensureRepoTarget(target); - return 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; + } catch (error) { + this.closeWorkspaceTarget(target); + this.workspaceTargets.delete(cwd); + throw error; + } } private async createWorkingTreeWatchTarget(cwd: string): Promise { @@ -485,10 +909,73 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target.debounceTimer = setTimeout(() => { target.debounceTimer = null; - void this.refreshWorkspaceTarget(target); + void this.refreshWorkspaceTarget(target, { + force: false, + includeGitHub: false, + reason: "watch", + notify: true, + }); }, WORKSPACE_GIT_WATCH_DEBOUNCE_MS); } + private startWorkspaceSubscriptionTimers(target: WorkspaceGitTarget): void { + if (!target.selfHealTimer) { + target.selfHealTimer = setInterval(() => { + this.getSnapshot(target.cwd, { reason: "self-heal-git" }).catch((error) => { + this.logger.warn( + { err: error, cwd: target.cwd, reason: "self-heal-git" }, + "Failed to run workspace git self-heal refresh", + ); + }); + }, WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS); + } + + this.updateGitHubPollForTarget(target); + } + + private updateGitHubPollForTarget(target: WorkspaceGitTarget): void { + if (target.listeners.size === 0) { + this.stopGitHubPollForTarget(target); + return; + } + + const headRef = target.latestSnapshot?.git.currentBranch ?? null; + if (!headRef || !this.deps.github.retainCurrentPullRequestStatusPoll) { + this.stopGitHubPollForTarget(target); + return; + } + if (target.githubPollHeadRef === headRef && target.githubPollSubscription) { + return; + } + + this.stopGitHubPollForTarget(target); + target.githubPollHeadRef = headRef; + target.githubPollSubscription = this.deps.github.retainCurrentPullRequestStatusPoll({ + cwd: target.cwd, + headRef, + onStatus: () => { + void this.refreshWorkspaceTarget(target, { + force: false, + includeGitHub: true, + reason: "self-heal-github", + notify: true, + }); + }, + onError: (error) => { + this.logger.warn( + { err: error, cwd: target.cwd, headRef, reason: "self-heal-github" }, + "Failed to run GitHub self-heal refresh", + ); + }, + }); + } + + private stopGitHubPollForTarget(target: WorkspaceGitTarget): void { + target.githubPollSubscription?.unsubscribe(); + target.githubPollSubscription = null; + target.githubPollHeadRef = null; + } + private addWorkingTreeWatcher( target: WorkingTreeWatchTarget, watchPath: string, @@ -632,51 +1119,177 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return directories; } - 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); - })(); - + private async refreshWorkspaceTarget( + target: WorkspaceGitTarget, + request: WorkspaceGitRefreshRequest, + ): Promise { try { - await target.refreshPromise; - } finally { - target.refreshPromise = null; + await this.requestWorkspaceSnapshot(target, request); + } catch (error) { + this.logger.warn( + { err: error, cwd: target.cwd, reason: request.reason }, + "Failed to refresh workspace git snapshot", + ); } } - private async refreshSnapshot(cwd: string): Promise { - return loadWorkspaceGitRuntimeSnapshot( - cwd, - { paseoHome: this.paseoHome }, - this.deps.now(), - this.deps, + private requestWorkspaceSnapshot( + target: WorkspaceGitTarget, + request: WorkspaceGitRefreshRequest, + ): Promise { + if (target.refreshState.status === "in-flight") { + if (request.force && !target.refreshState.force) { + target.refreshState.queued = this.mergeQueuedRefresh(target.refreshState.queued, request); + } + return target.refreshState.promise; + } + + if (!request.force && this.shouldThrottleNonForcedRefresh(target)) { + return Promise.resolve(target.latestSnapshot); + } + + const promise = this.runWorkspaceRefreshLoop(target, request).finally(() => { + const state = target.refreshState; + if (state.status === "in-flight" && state.promise === promise) { + target.refreshState = { status: "idle" }; + } + }); + target.refreshState = { + status: "in-flight", + promise, + force: request.force, + includeGitHub: request.includeGitHub, + queued: null, + }; + + return promise; + } + + private normalizeRefreshRequest( + options: WorkspaceGitSnapshotOptions | undefined, + defaultReason: string, + notify: boolean, + ): WorkspaceGitRefreshRequest { + if (options?.force && !options.reason) { + throw new Error("WorkspaceGitService.getSnapshot force refresh requires a reason"); + } + + const force = options?.force === true; + return { + force, + includeGitHub: options?.includeGitHub ?? force, + reason: options?.reason ?? defaultReason, + notify, + }; + } + + private isSnapshotWarm(target: WorkspaceGitTarget): target is WorkspaceGitTarget & { + latestSnapshot: WorkspaceGitRuntimeSnapshot; + latestSnapshotLoadedAtMs: number; + } { + if (!target.latestSnapshot || target.latestSnapshotLoadedAtMs === null) { + return false; + } + + return ( + this.deps.now().getTime() - target.latestSnapshotLoadedAtMs <= WORKSPACE_GIT_CONSUMER_TTL_MS ); } + private shouldThrottleNonForcedRefresh( + target: WorkspaceGitTarget, + ): target is WorkspaceGitTarget & { + latestSnapshot: WorkspaceGitRuntimeSnapshot; + } { + if (!target.latestSnapshot || target.lastShellOutAtMs === null) { + return false; + } + + return this.deps.now().getTime() - target.lastShellOutAtMs < WORKSPACE_GIT_INTERNAL_MIN_GAP_MS; + } + + private mergeQueuedRefresh( + queued: QueuedWorkspaceGitRefresh | null, + request: WorkspaceGitRefreshRequest, + ): QueuedWorkspaceGitRefresh { + if (!queued) { + return { + force: request.force, + includeGitHub: request.includeGitHub, + reason: request.reason, + notify: request.notify, + }; + } + + const force = queued.force || request.force; + return { + force, + includeGitHub: queued.includeGitHub || request.includeGitHub, + reason: request.force && !queued.force ? request.reason : queued.reason, + notify: queued.notify || request.notify, + }; + } + + private async runWorkspaceRefreshLoop( + target: WorkspaceGitTarget, + initialRequest: WorkspaceGitRefreshRequest, + ): Promise { + let request = initialRequest; + let snapshot!: WorkspaceGitRuntimeSnapshot; + + while (true) { + snapshot = await this.refreshSnapshot(target, request); + this.rememberSnapshot(target, snapshot, { + notify: request.notify, + forceEmit: request.force, + }); + + const state = target.refreshState; + if (state.status !== "in-flight" || !state.queued) { + break; + } + + request = state.queued; + state.queued = null; + state.force = request.force; + state.includeGitHub = request.includeGitHub; + } + + return snapshot; + } + + private async refreshSnapshot( + target: WorkspaceGitTarget, + request: WorkspaceGitRefreshRequest, + ): Promise { + const now = this.deps.now(); + target.lastShellOutAtMs = now.getTime(); + if (request.force) { + this.deps.github.invalidate({ cwd: target.cwd }); + } + const snapshot = await loadWorkspaceGitRuntimeSnapshot( + target.cwd, + { paseoHome: this.paseoHome }, + now, + this.deps, + { force: request.force, reason: request.reason }, + ); + target.latestSnapshotLoadedAtMs = now.getTime(); + return snapshot; + } + private rememberSnapshot( target: WorkspaceGitTarget, snapshot: WorkspaceGitRuntimeSnapshot, - options?: { notify?: boolean }, + options?: { forceEmit?: boolean; notify?: boolean }, ): void { target.latestSnapshot = snapshot; + if (target.listeners.size > 0) { + this.updateGitHubPollForTarget(target); + } const fingerprint = JSON.stringify(snapshot); - if (target.latestFingerprint === fingerprint) { + const fingerprintMatches = target.latestFingerprint === fingerprint; + if (fingerprintMatches && !options?.forceEmit) { return; } target.latestFingerprint = fingerprint; @@ -714,7 +1327,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (!workspaceTarget) { return; } - await this.refreshWorkspaceTarget(workspaceTarget); + await this.refreshWorkspaceTarget(workspaceTarget, { + force: false, + includeGitHub: false, + reason: "repo-fetch", + notify: true, + }); }), ); } @@ -768,6 +1386,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { clearTimeout(target.debounceTimer); target.debounceTimer = null; } + if (target.selfHealTimer) { + clearInterval(target.selfHealTimer); + target.selfHealTimer = null; + } + this.stopGitHubPollForTarget(target); for (const watcher of target.watchers) { watcher.close(); @@ -807,6 +1430,7 @@ async function loadWorkspaceGitRuntimeSnapshot( WorkspaceGitServiceDependencies, "getCheckoutStatus" | "getCheckoutShortstat" | "getPullRequestStatus" | "github" >, + options?: { force?: boolean; reason?: string }, ): Promise { const checkoutStatus = await deps.getCheckoutStatus(cwd, context); if (!checkoutStatus.isGit) { @@ -814,12 +1438,14 @@ async function loadWorkspaceGitRuntimeSnapshot( } const [diffStat, github] = await Promise.all([ - deps.getCheckoutShortstat(cwd, context), + deps.getCheckoutShortstat(cwd, context, { force: options?.force }), loadGitHubSnapshot({ cwd, remoteUrl: checkoutStatus.remoteUrl, now, deps, + force: options?.force, + reason: options?.reason, }), ]); @@ -833,9 +1459,11 @@ async function loadWorkspaceGitRuntimeSnapshot( remoteUrl: checkoutStatus.remoteUrl, isPaseoOwnedWorktree: checkoutStatus.isPaseoOwnedWorktree, isDirty: checkoutStatus.isDirty, + baseRef: checkoutStatus.baseRef, aheadBehind: checkoutStatus.aheadBehind, aheadOfOrigin: checkoutStatus.aheadOfOrigin, behindOfOrigin: checkoutStatus.behindOfOrigin, + hasRemote: checkoutStatus.hasRemote, diffStat, }, github, @@ -847,6 +1475,8 @@ async function loadGitHubSnapshot(options: { remoteUrl: string | null; now: Date; deps: Pick; + force?: boolean; + reason?: string; }): Promise { if (!hasGitHubRemoteUrl(options.remoteUrl)) { return { @@ -869,7 +1499,10 @@ async function loadGitHubSnapshot(options: { } try { - const result = await options.deps.getPullRequestStatus(options.cwd, options.deps.github); + const result = await options.deps.getPullRequestStatus(options.cwd, options.deps.github, { + force: options.force, + reason: options.reason, + }); return { featuresEnabled: true, pullRequest: result.status, @@ -900,6 +1533,42 @@ function hasGitHubRemoteUrl(remoteUrl: string | null): boolean { ); } +function parseWorkspaceGitStashList( + stdout: string, + options: { paseoOnly: boolean }, +): WorkspaceGitStashEntry[] { + const entries: WorkspaceGitStashEntry[] = []; + const lines = stdout.trim().split("\n").filter(Boolean); + + for (const line of lines) { + const sepIdx = line.indexOf("\0"); + if (sepIdx < 0) { + continue; + } + + const refPart = line.slice(0, sepIdx); + const subject = line.slice(sepIdx + 1); + const indexMatch = refPart.match(/\{(\d+)\}/); + if (!indexMatch) { + continue; + } + + const index = Number(indexMatch[1]); + const prefix = "paseo-auto-stash:"; + const prefixIdx = subject.indexOf(prefix); + const isPaseo = prefixIdx >= 0; + const branch = isPaseo ? subject.slice(prefixIdx + prefix.length).trim() || null : null; + + if (options.paseoOnly && !isPaseo) { + continue; + } + + entries.push({ index, message: subject, branch, isPaseo }); + } + + return entries; +} + function buildNotGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot { return { cwd, @@ -911,9 +1580,11 @@ function buildNotGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot { remoteUrl: null, isPaseoOwnedWorktree: false, isDirty: null, + baseRef: null, aheadBehind: null, aheadOfOrigin: null, behindOfOrigin: null, + hasRemote: false, diffStat: null, }, github: { diff --git a/packages/server/src/server/workspace-reconciliation-service.test.ts b/packages/server/src/server/workspace-reconciliation-service.test.ts index dbce52d6a..99060b24f 100644 --- a/packages/server/src/server/workspace-reconciliation-service.test.ts +++ b/packages/server/src/server/workspace-reconciliation-service.test.ts @@ -72,6 +72,47 @@ function createTestLogger() { return logger as any; } +function createWorkspaceGitServiceStub( + metadataByCwd: Record< + string, + { + projectKind: "git" | "directory"; + projectDisplayName: string; + workspaceDisplayName: string; + gitRemote?: string | null; + } + >, +) { + return { + getWorkspaceGitMetadata: vi.fn(async (cwd: string, options?: { directoryName?: string }) => { + const metadata = metadataByCwd[cwd]; + const directoryName = options?.directoryName ?? path.basename(cwd); + if (!metadata) { + return { + projectKind: "directory" as const, + projectDisplayName: directoryName, + workspaceDisplayName: directoryName, + gitRemote: null, + isWorktree: false, + projectSlug: "untitled", + repoRoot: null, + currentBranch: null, + remoteUrl: null, + }; + } + return { + gitRemote: metadata.gitRemote ?? null, + isWorktree: false, + projectSlug: "repo", + repoRoot: cwd, + currentBranch: metadata.workspaceDisplayName, + remoteUrl: metadata.gitRemote ?? null, + ...metadata, + }; + }), + }; +} + function createTempGitRepo(prefix: string): string { const raw = mkdtempSync(path.join(tmpdir(), prefix)); const dir = realpathSync(raw); @@ -222,6 +263,13 @@ describe("WorkspaceReconciliationService", () => { projectRegistry, workspaceRegistry, logger: createTestLogger(), + workspaceGitService: createWorkspaceGitServiceStub({ + [resolved]: { + projectKind: "git", + projectDisplayName: path.basename(resolved), + workspaceDisplayName: "main", + }, + }), }); const result = await service.runOnce(); @@ -271,6 +319,14 @@ describe("WorkspaceReconciliationService", () => { projectRegistry, workspaceRegistry, logger: createTestLogger(), + workspaceGitService: createWorkspaceGitServiceStub({ + [dir]: { + projectKind: "git", + projectDisplayName: "new-owner/new-repo", + workspaceDisplayName: "main", + gitRemote: "git@github.com:new-owner/new-repo.git", + }, + }), }); const result = await service.runOnce(); @@ -316,6 +372,13 @@ describe("WorkspaceReconciliationService", () => { projectRegistry, workspaceRegistry, logger: createTestLogger(), + workspaceGitService: createWorkspaceGitServiceStub({ + [dir]: { + projectKind: "git", + projectDisplayName: path.basename(dir), + workspaceDisplayName: "feature-branch", + }, + }), }); const result = await service.runOnce(); diff --git a/packages/server/src/server/workspace-reconciliation-service.ts b/packages/server/src/server/workspace-reconciliation-service.ts index 4ba45dd9d..38f4d01f2 100644 --- a/packages/server/src/server/workspace-reconciliation-service.ts +++ b/packages/server/src/server/workspace-reconciliation-service.ts @@ -6,7 +6,7 @@ import type { PersistedProjectRecord, PersistedWorkspaceRecord, } from "./workspace-registry.js"; -import { detectWorkspaceGitMetadata } from "./workspace-git-metadata.js"; +import type { WorkspaceGitService } from "./workspace-git-service.js"; const DEFAULT_RECONCILE_INTERVAL_MS = 60_000; @@ -37,6 +37,7 @@ export type WorkspaceReconciliationServiceOptions = { logger: pino.Logger; intervalMs?: number; onChanges?: (changes: ReconciliationChange[]) => void; + workspaceGitService?: Pick; }; export class WorkspaceReconciliationService { @@ -45,6 +46,7 @@ export class WorkspaceReconciliationService { private readonly logger: pino.Logger; private readonly intervalMs: number; private readonly onChanges: ((changes: ReconciliationChange[]) => void) | null; + private readonly workspaceGitService: Pick | null; private timer: ReturnType | null = null; private running = false; @@ -54,6 +56,7 @@ export class WorkspaceReconciliationService { this.logger = options.logger.child({ module: "workspace-reconciliation" }); this.intervalMs = options.intervalMs ?? DEFAULT_RECONCILE_INTERVAL_MS; this.onChanges = options.onChanges ?? null; + this.workspaceGitService = options.workspaceGitService ?? null; } start(): void { @@ -155,7 +158,7 @@ export class WorkspaceReconciliationService { const directoryName = project.rootPath.split(/[\\/]/).filter(Boolean).at(-1) ?? project.rootPath; - const currentGit = detectWorkspaceGitMetadata(project.rootPath, directoryName); + const currentGit = await this.readWorkspaceGitMetadata(project.rootPath, directoryName); const projectUpdates: Partial< Pick @@ -198,7 +201,7 @@ export class WorkspaceReconciliationService { if (!existsSync(workspace.cwd)) continue; const wsDirName = workspace.cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? workspace.cwd; - const wsGit = detectWorkspaceGitMetadata(workspace.cwd, wsDirName); + const wsGit = await this.readWorkspaceGitMetadata(workspace.cwd, wsDirName); if (wsGit.projectKind === "git" && workspace.displayName !== wsGit.workspaceDisplayName) { const timestamp = new Date().toISOString(); @@ -223,4 +226,21 @@ export class WorkspaceReconciliationService { return { changesApplied: changes, durationMs: Date.now() - start }; } + + private async readWorkspaceGitMetadata(cwd: string, directoryName: string) { + if (!this.workspaceGitService) { + return { + projectKind: "directory" as const, + projectDisplayName: directoryName, + workspaceDisplayName: directoryName, + gitRemote: null, + isWorktree: false, + projectSlug: "untitled", + repoRoot: null, + currentBranch: null, + remoteUrl: null, + }; + } + return this.workspaceGitService.getWorkspaceGitMetadata(cwd, { directoryName }); + } } diff --git a/packages/server/src/server/workspace-registry-bootstrap.test.ts b/packages/server/src/server/workspace-registry-bootstrap.test.ts index 5f81aa3e2..c77be207e 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.test.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.test.ts @@ -60,6 +60,7 @@ function createNoopWorkspaceGitService(): WorkspaceGitService { refreshedAt: null, }, }), + resolveRepoRemoteUrl: async () => null, refresh: async () => {}, requestWorkingTreeWatch: async () => ({ repoRoot: null, diff --git a/packages/server/src/server/worktree-core.test.ts b/packages/server/src/server/worktree-core.test.ts index a519801c8..647a619db 100644 --- a/packages/server/src/server/worktree-core.test.ts +++ b/packages/server/src/server/worktree-core.test.ts @@ -11,16 +11,20 @@ import { import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { describe, expect, test, afterEach } from "vitest"; +import { describe, expect, test, afterEach, vi } from "vitest"; import type { GitHubService } from "../services/github-service.js"; import { UnknownBranchError } from "../utils/worktree.js"; -import { createWorktreeCore as createCoreWorktree } from "./worktree-core.js"; +import { + createWorktreeCore as createCoreWorktree, + resolveWorktreeRepoRoot, +} from "./worktree-core.js"; function createGitHubServiceStub(): GitHubService { return { listPullRequests: async () => [], listIssues: async () => [], + searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }), getPullRequest: async ({ number }) => ({ number, title: `PR ${number}`, @@ -48,7 +52,10 @@ function createCoreDeps(options?: { }) { return { github: options?.github ?? createGitHubServiceStub(), - resolveRepositoryDefaultBranch: async () => "main", + workspaceGitService: { + resolveRepoRoot: async (cwd: string) => cwd, + }, + resolveDefaultBranch: async () => "main", generateBranchName: options?.generateBranchName ?? ((seed) => seed ?? "generated-worktree"), }; } @@ -482,3 +489,23 @@ describe.skipIf(process.platform === "win32")("createWorktreeCore", () => { expect(findDirectCreateWorktreeCallSites(serverSrc)).toEqual(["server/worktree-core.ts"]); }); }); + +describe("resolveWorktreeRepoRoot", () => { + test("resolves repository roots through the workspace git service", async () => { + const workspaceGitService = { + resolveRepoRoot: vi.fn().mockResolvedValue("/tmp/main-repo"), + }; + + await expect( + resolveWorktreeRepoRoot( + { cwd: "/tmp/main-repo/worktrees/feature", paseoHome: "/tmp/paseo-home" }, + workspaceGitService, + ), + ).resolves.toBe("/tmp/main-repo"); + + expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledTimes(1); + expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith( + "/tmp/main-repo/worktrees/feature", + ); + }); +}); diff --git a/packages/server/src/server/worktree-core.ts b/packages/server/src/server/worktree-core.ts index 86b60b7ca..5d96dc635 100644 --- a/packages/server/src/server/worktree-core.ts +++ b/packages/server/src/server/worktree-core.ts @@ -1,7 +1,6 @@ import { v4 as uuidv4 } from "uuid"; import type { GitHubService } from "../services/github-service.js"; -import { getCheckoutStatus, resolveRepositoryDefaultBranch } from "../utils/checkout-git.js"; import { createWorktree, resolveExistingWorktreeForSlug, @@ -14,6 +13,7 @@ import { type ResolveWorktreeCreationIntentInput, type WorktreeCreationIntent, } from "./resolve-worktree-creation-intent.js"; +import type { WorkspaceGitService } from "./workspace-git-service.js"; export interface CreateWorktreeCoreInput extends ResolveWorktreeCreationIntentInput { cwd: string; @@ -23,7 +23,8 @@ export interface CreateWorktreeCoreInput extends ResolveWorktreeCreationIntentIn export interface CreateWorktreeCoreDeps { github: GitHubService; - resolveRepositoryDefaultBranch: (repoRoot: string) => Promise; + workspaceGitService?: Pick; + resolveDefaultBranch?: (repoRoot: string) => Promise; generateBranchName: (seed: string | undefined) => string; } @@ -38,13 +39,16 @@ export async function createWorktreeCore( input: CreateWorktreeCoreInput, deps: CreateWorktreeCoreDeps, ): Promise { - const repoRoot = await resolveWorktreeRepoRoot(input); + const repoRoot = await resolveWorktreeRepoRoot(input, deps.workspaceGitService); const requestedSlug = input.worktreeSlug ? slugify(input.worktreeSlug) : undefined; const intent = await resolveWorktreeCreationIntent( { ...input, worktreeSlug: requestedSlug }, repoRoot, - deps, + { + ...deps, + resolveDefaultBranch: (root) => resolveDefaultBranch(root, deps), + }, ); let normalizedSlug: string; @@ -89,31 +93,32 @@ export async function createWorktreeCore( export function createWorktreeCoreDeps(github: GitHubService): CreateWorktreeCoreDeps { return { github, - resolveRepositoryDefaultBranch: resolveDefaultBranch, generateBranchName: (seed) => slugify(seed ?? uuidv4()), }; } -async function resolveDefaultBranch(repoRoot: string): Promise { - const baseBranch = await resolveRepositoryDefaultBranch(repoRoot); +async function resolveDefaultBranch( + repoRoot: string, + deps: CreateWorktreeCoreDeps, +): Promise { + const baseBranch = deps.resolveDefaultBranch + ? await deps.resolveDefaultBranch(repoRoot) + : await deps.workspaceGitService?.resolveDefaultBranch(repoRoot); if (!baseBranch) { throw new Error("Unable to resolve repository default branch"); } return baseBranch; } -async function resolveWorktreeRepoRoot(input: CreateWorktreeCoreInput): Promise { - const checkout = await getCheckoutStatus( - input.cwd, - input.paseoHome ? { paseoHome: input.paseoHome } : undefined, - ); - if (!checkout.isGit) { - throw new Error("Create worktree requires a git repository"); +export async function resolveWorktreeRepoRoot( + input: Pick, + workspaceGitService?: Pick, +): Promise { + if (!workspaceGitService) { + throw new Error("Create worktree requires WorkspaceGitService"); } - return checkout.isPaseoOwnedWorktree - ? (checkout.mainRepoRoot ?? checkout.repoRoot ?? input.cwd) - : (checkout.repoRoot ?? input.cwd); + return workspaceGitService.resolveRepoRoot(input.cwd); } function validateWorktreeSlug(slug: string): string { diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index 63353b623..d02cff6eb 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -18,6 +18,8 @@ import { archivePaseoWorktree, buildAgentSessionConfig, handlePaseoWorktreeArchiveRequest, + handlePaseoWorktreeListRequest, + resolveGitCreateBaseBranch, runWorktreeSetupInBackground, handleCreatePaseoWorktreeRequest, handleWorkspaceSetupStatusRequest, @@ -81,6 +83,7 @@ function createGitHubServiceStub(): GitHubService { return { listPullRequests: async () => [], listIssues: async () => [], + searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }), getPullRequest: async ({ number }) => ({ number, title: `PR ${number}`, @@ -213,8 +216,8 @@ function createPaseoWorktreeForTest(options: { const coreDeps = createWorktreeCoreDeps(createGitHubServiceStub()); return createPaseoWorktreeService(input, { ...coreDeps, - ...(serviceOptions?.resolveRepositoryDefaultBranch - ? { resolveRepositoryDefaultBranch: serviceOptions.resolveRepositoryDefaultBranch } + ...(serviceOptions?.resolveDefaultBranch + ? { resolveDefaultBranch: serviceOptions.resolveDefaultBranch } : {}), projectRegistry: { get: async (projectId) => projects.get(projectId) ?? null, @@ -278,6 +281,77 @@ function createManagedAgentForArchive(input: { id: string; cwd: string }): Manag }; } +describe("handlePaseoWorktreeListRequest", () => { + test("lists worktrees through the workspace git service", async () => { + const emitted: SessionOutboundMessage[] = []; + const workspaceGitService = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: "/tmp/paseo-home/worktrees/repo/feature", + createdAt: "2026-04-12T00:00:00.000Z", + branchName: "feature", + head: "abc123", + }, + ]), + }; + + await handlePaseoWorktreeListRequest( + { + emit: (message) => emitted.push(message), + paseoHome: "/tmp/paseo-home", + workspaceGitService: workspaceGitService as any, + }, + { + type: "paseo_worktree_list_request", + cwd: "/tmp/repo", + requestId: "request-worktrees", + }, + ); + + expect(workspaceGitService.listWorktrees).toHaveBeenCalledTimes(1); + expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith("/tmp/repo"); + expect(emitted).toContainEqual({ + type: "paseo_worktree_list_response", + payload: { + worktrees: [ + { + worktreePath: "/tmp/paseo-home/worktrees/repo/feature", + createdAt: "2026-04-12T00:00:00.000Z", + branchName: "feature", + head: "abc123", + }, + ], + error: null, + requestId: "request-worktrees", + }, + }); + }); +}); + +describe("resolveGitCreateBaseBranch", () => { + test("resolves the default branch through the workspace git service", async () => { + const { tempDir, repoDir } = createGitRepo(); + const cwd = path.join(repoDir, "packages", "app"); + const workspaceGitService = { + resolveDefaultBranch: vi.fn().mockResolvedValue("main"), + getSnapshot: vi.fn(async () => { + throw new Error("getSnapshot should not be used for default-branch resolution"); + }), + }; + + try { + await expect(resolveGitCreateBaseBranch(cwd, workspaceGitService as any)).resolves.toBe( + "main", + ); + + expect(workspaceGitService.resolveDefaultBranch).toHaveBeenCalledWith(cwd); + expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); + function createAgentStorageStub(): Pick { return { list: async (): Promise => [], @@ -1082,6 +1156,10 @@ describe("handleCreatePaseoWorktreeRequest", () => { { paseoHome, sessionLogger: createLogger(), + workspaceGitService: { + resolveRepoRoot: vi.fn(async () => repoDir), + resolveDefaultBranch: vi.fn(async () => "main"), + } as any, createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }), checkoutExistingBranch: async () => { throw new Error("should not checkout existing branch"); @@ -1166,7 +1244,7 @@ describe("handleCreatePaseoWorktreeRequest", () => { const { tempDir, repoDir } = createGitRepo(); cleanupPaths.push(tempDir); const paseoHome = path.join(tempDir, ".paseo"); - const resolveRepositoryDefaultBranch = vi.fn(async () => "main"); + const resolveDefaultBranch = vi.fn(async () => "main"); const result = await createPaseoWorktreeForTest({ paseoHome })( { @@ -1176,7 +1254,7 @@ describe("handleCreatePaseoWorktreeRequest", () => { runSetup: false, paseoHome, }, - { resolveRepositoryDefaultBranch }, + { resolveDefaultBranch }, ); expect(result.intent).toMatchObject({ @@ -1184,7 +1262,7 @@ describe("handleCreatePaseoWorktreeRequest", () => { baseBranch: "main", newBranchName: "resolver-feature", }); - expect(resolveRepositoryDefaultBranch).toHaveBeenCalledWith(repoDir); + expect(resolveDefaultBranch).toHaveBeenCalledWith(repoDir); }); }); @@ -1509,6 +1587,53 @@ describe("archivePaseoWorktree", () => { expect(existsSync(created.worktreePath)).toBe(false); }); + test("forces a workspace git snapshot refresh after archive deletes a worktree", async () => { + const { tempDir, repoDir } = createGitRepo(); + cleanupPaths.push(tempDir); + + const paseoHome = path.join(tempDir, ".paseo"); + const created = await createLegacyWorktreeForTest({ + branchName: "archive-refresh", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "archive-refresh", + runSetup: false, + paseoHome, + }); + const workspaceGitService = { + getSnapshot: vi.fn(async () => null), + }; + + await archivePaseoWorktree( + { + paseoHome, + github: createGitHubServiceStub(), + workspaceGitService: workspaceGitService as any, + agentManager: { + listAgents: () => [], + closeAgent: vi.fn(async () => {}), + }, + agentStorage: createAgentStorageStub(), + archiveWorkspaceRecord: vi.fn(async () => {}), + emit: vi.fn(), + emitWorkspaceUpdatesForCwds: vi.fn(async () => {}), + isPathWithinRoot: createIsPathWithinRoot(), + killTerminalsUnderPath: vi.fn(async () => {}), + sessionLogger: createLogger(), + }, + { + targetPath: created.worktreePath, + repoRoot: repoDir, + requestId: "req-archive-refresh", + }, + ); + + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, { + force: true, + reason: "archive-worktree", + }); + }); + test("succeeds when git has forgotten about the worktree (no repoRoot)", async () => { const { tempDir, repoDir } = createGitRepo(); cleanupPaths.push(tempDir); diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index bd585bd75..22cc2c069 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -26,14 +26,12 @@ import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { ScriptRouteStore } from "./script-proxy.js"; import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import type { GitHubService } from "../services/github-service.js"; -import { getCheckoutStatus, resolveRepositoryDefaultBranch } from "../utils/checkout-git.js"; import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js"; import { expandTilde } from "../utils/path.js"; import { deletePaseoWorktree, getWorktreeSetupCommands, isPaseoOwnedWorktreeCwd, - listPaseoWorktrees, resolvePaseoWorktreeRootForCwd, resolveWorktreeRuntimeEnv, runWorktreeSetupCommands, @@ -73,7 +71,7 @@ type BuildAgentSessionConfigDependencies = { createPaseoWorktree: ( input: CreatePaseoWorktreeInput, options?: { - resolveRepositoryDefaultBranch?: (repoRoot: string) => Promise; + resolveDefaultBranch?: (repoRoot: string) => Promise; }, ) => Promise; checkoutExistingBranch: (cwd: string, branch: string) => Promise; @@ -88,6 +86,7 @@ type BuildAgentSessionConfigDependencies = { type ArchivePaseoWorktreeDependencies = { paseoHome?: string; github: GitHubService; + workspaceGitService: WorkspaceGitService; agentManager: Pick; agentStorage: Pick; archiveWorkspaceRecord: (workspaceId: string) => Promise; @@ -186,7 +185,7 @@ export async function buildAgentSessionConfig( paseoHome: dependencies.paseoHome, }, { - resolveRepositoryDefaultBranch: normalized.baseBranch + resolveDefaultBranch: normalized.baseBranch ? async () => normalized.baseBranch! : (repoRoot) => resolveGitCreateBaseBranch( @@ -311,37 +310,21 @@ export function assertSafeGitRef(ref: string, label: string): void { export async function resolveGitCreateBaseBranch( cwd: string, workspaceGitService?: WorkspaceGitService, - paseoHome?: string, + _paseoHome?: string, ): Promise { - let repoRoot = cwd; - if (workspaceGitService) { - const snapshot = await workspaceGitService.getSnapshot(cwd); - if (!snapshot.git.isGit) { - throw new Error("Cannot create a worktree outside a git repository"); - } - - repoRoot = snapshot.git.isPaseoOwnedWorktree - ? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? cwd) - : (snapshot.git.repoRoot ?? cwd); - } else { - const checkout = await getCheckoutStatus(cwd, paseoHome ? { paseoHome } : undefined); - if (!checkout.isGit) { - throw new Error("Cannot create a worktree outside a git repository"); - } - - repoRoot = checkout.isPaseoOwnedWorktree - ? (checkout.mainRepoRoot ?? checkout.repoRoot ?? cwd) - : (checkout.repoRoot ?? cwd); + if (!workspaceGitService) { + throw new Error("WorkspaceGitService is required to resolve the repository root"); } - const baseBranch = await resolveRepositoryDefaultBranch(repoRoot); - if (!baseBranch) { - throw new Error("Unable to resolve repository default branch"); - } - return baseBranch; + + return workspaceGitService.resolveDefaultBranch(cwd); } export async function handlePaseoWorktreeListRequest( - dependencies: { emit: EmitSessionMessage; paseoHome?: string }, + dependencies: { + emit: EmitSessionMessage; + paseoHome?: string; + workspaceGitService: WorkspaceGitService; + }, msg: Extract, ): Promise { const { requestId } = msg; @@ -359,7 +342,7 @@ export async function handlePaseoWorktreeListRequest( } try { - const worktrees = await listPaseoWorktrees({ cwd, paseoHome: dependencies.paseoHome }); + const worktrees = await dependencies.workspaceGitService.listWorktrees(cwd); dependencies.emit({ type: "paseo_worktree_list_response", payload: { @@ -483,6 +466,20 @@ export async function archivePaseoWorktree( paseoHome: dependencies.paseoHome, }); + if (options.repoRoot) { + try { + await dependencies.workspaceGitService.getSnapshot(options.repoRoot, { + force: true, + reason: "archive-worktree", + }); + } catch (error) { + dependencies.sessionLogger?.warn( + { err: error, cwd: options.repoRoot }, + "Failed to force-refresh workspace git snapshot after archiving worktree", + ); + } + } + for (const cwd of affectedWorkspaceCwds) { dependencies.github.invalidate({ cwd }); } @@ -529,10 +526,7 @@ export async function handlePaseoWorktreeArchiveRequest( if (!repoRoot || !msg.branchName) { throw new Error("worktreePath or repoRoot+branchName is required"); } - const worktrees = await listPaseoWorktrees({ - cwd: repoRoot, - paseoHome: dependencies.paseoHome, - }); + const worktrees = await dependencies.workspaceGitService.listWorktrees(repoRoot); const match = worktrees.find((entry) => entry.branchName === msg.branchName); if (!match) { throw new Error(`Paseo worktree not found for branch ${msg.branchName}`); diff --git a/packages/server/src/services/github-service.test.ts b/packages/server/src/services/github-service.test.ts index 2b0f92160..f29f84498 100644 --- a/packages/server/src/services/github-service.test.ts +++ b/packages/server/src/services/github-service.test.ts @@ -1,14 +1,22 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { GitHubAuthenticationError, GitHubCliMissingError, GitHubCommandError, + computeGithubNextInterval, createGitHubService, + resolveGitHubRepo, type GitHubCommandRunner, type GitHubCommandRunnerOptions, + type GitHubCurrentPullRequestStatus, + type GitHubReadOptions, } from "./github-service.js"; import { CheckoutPrStatusResponseSchema } from "../shared/messages.js"; +const EXPECTED_GITHUB_FAST_POLL_MS = 20_000; +const EXPECTED_GITHUB_SLOW_POLL_MS = 120_000; +const EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS = 300_000; + interface RunnerCall { args: string[]; cwd: string; @@ -99,6 +107,37 @@ function currentPullRequestJson(overrides: Record = {}): string }); } +function createCurrentPullRequestStatus( + overrides: Partial = {}, +): GitHubCurrentPullRequestStatus { + return { + number: 42, + repoOwner: "acme", + repoName: "repo", + url: "https://github.com/acme/repo/pull/42", + title: "Update feature", + state: "open", + baseRefName: "main", + headRefName: "feature", + isMerged: false, + isDraft: false, + checks: [], + checksStatus: "none", + reviewDecision: null, + ...overrides, + }; +} + +function recordCurrentPullRequestStatusReads(service: ReturnType) { + const reads: GitHubReadOptions[] = []; + const getCurrentPullRequestStatus = service.getCurrentPullRequestStatus.bind(service); + service.getCurrentPullRequestStatus = vi.fn(async (options) => { + reads.push(options); + return getCurrentPullRequestStatus(options); + }); + return reads; +} + function noPullRequestError(args: string[] = ["pr", "view"]): GitHubCommandError { return new GitHubCommandError({ args, @@ -122,6 +161,36 @@ function pullRequestJson(title: string): string { ]); } +function issueJson(title: string): string { + return JSON.stringify([ + { + number: 55, + title, + url: "https://github.com/acme/repo/issues/55", + state: "OPEN", + body: "issue body", + labels: [{ name: "bug" }], + updatedAt: "2026-04-18T12:00:00Z", + }, + ]); +} + +function searchPullRequestJson(title: string): string { + return JSON.stringify([ + { + number: 123, + title, + url: "https://github.com/acme/repo/pull/123", + state: "OPEN", + body: "pr body", + baseRefName: "main", + headRefName: "feature", + labels: [{ name: "enhancement" }], + updatedAt: "2026-04-18T13:00:00Z", + }, + ]); +} + function pullRequestTimelineJson(overrides: Record = {}): string { return JSON.stringify({ data: { @@ -175,6 +244,212 @@ function pullRequestTimelineJson(overrides: Record = {}): strin } describe("GitHubService", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("computes fast cadence for pending and slow cadence for stable PR states", () => { + const pendingStatus = createCurrentPullRequestStatus({ checksStatus: "pending" }); + const runningCheckStatus = createCurrentPullRequestStatus({ + checksStatus: "success", + checks: [{ name: "ci", status: "pending", url: null }], + }); + const stableStatus = createCurrentPullRequestStatus({ checksStatus: "success" }); + + expect(computeGithubNextInterval(pendingStatus, 0)).toBe(EXPECTED_GITHUB_FAST_POLL_MS); + expect(computeGithubNextInterval(runningCheckStatus, 0)).toBe(EXPECTED_GITHUB_FAST_POLL_MS); + expect(computeGithubNextInterval(stableStatus, 0)).toBe(EXPECTED_GITHUB_SLOW_POLL_MS); + expect(computeGithubNextInterval(null, 0)).toBe(EXPECTED_GITHUB_SLOW_POLL_MS); + }); + + it("computes exponential error backoff up to the cap", () => { + const stableStatus = createCurrentPullRequestStatus({ checksStatus: "success" }); + + expect(computeGithubNextInterval(stableStatus, 1)).toBe(EXPECTED_GITHUB_SLOW_POLL_MS); + expect(computeGithubNextInterval(stableStatus, 2)).toBe(240_000); + expect(computeGithubNextInterval(stableStatus, 3)).toBe(EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS); + expect(computeGithubNextInterval(stableStatus, 4)).toBe(EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS); + }); + + it("polls PR status at fast cadence while checks are pending", async () => { + let now = 0; + const runner = createRunner([ + currentPullRequestJson({ + statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "PENDING" }], + }), + currentPullRequestJson({ + statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "PENDING" }], + }), + ]); + const service = createGitHubService({ + ttlMs: 0, + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => now, + }); + const reads = recordCurrentPullRequestStatusReads(service); + + const subscription = service.retainCurrentPullRequestStatusPoll?.({ + cwd: "/repo", + headRef: "feature/fork", + }); + await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }); + + now = EXPECTED_GITHUB_FAST_POLL_MS; + await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS); + + expect(runner.calls).toHaveLength(2); + expect(reads.map((read) => read.reason)).toEqual([undefined, "self-heal-github"]); + + subscription?.unsubscribe(); + service.dispose?.(); + }); + + it("polls PR status at slow cadence after stable checks", async () => { + let now = 0; + const runner = createRunner([ + currentPullRequestJson({ + statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "SUCCESS" }], + }), + currentPullRequestJson({ + statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "SUCCESS" }], + }), + ]); + const service = createGitHubService({ + ttlMs: 0, + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => now, + }); + const reads = recordCurrentPullRequestStatusReads(service); + + const subscription = service.retainCurrentPullRequestStatusPoll?.({ + cwd: "/repo", + headRef: "feature/fork", + }); + await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }); + + now = EXPECTED_GITHUB_FAST_POLL_MS; + await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS); + expect(runner.calls).toHaveLength(1); + + now = EXPECTED_GITHUB_SLOW_POLL_MS; + await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_SLOW_POLL_MS - EXPECTED_GITHUB_FAST_POLL_MS); + expect(runner.calls).toHaveLength(2); + expect(reads.map((read) => read.reason)).toEqual([undefined, "self-heal-github"]); + + subscription?.unsubscribe(); + service.dispose?.(); + }); + + it("backs off consecutive poll errors and resets cadence after recovery", async () => { + let now = 0; + const runner = createScriptedRunner([ + currentPullRequestJson({ + statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "PENDING" }], + }), + { error: new Error("network down") }, + { error: new Error("network still down") }, + currentPullRequestJson({ + statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "SUCCESS" }], + }), + currentPullRequestJson({ + statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "SUCCESS" }], + }), + ]); + const service = createGitHubService({ + ttlMs: 0, + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => now, + }); + const reads = recordCurrentPullRequestStatusReads(service); + + const subscription = service.retainCurrentPullRequestStatusPoll?.({ + cwd: "/repo", + headRef: "feature/fork", + }); + await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }); + + now = EXPECTED_GITHUB_FAST_POLL_MS; + await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS); + now += EXPECTED_GITHUB_FAST_POLL_MS; + await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS); + now += EXPECTED_GITHUB_FAST_POLL_MS * 2; + await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS * 2); + now += EXPECTED_GITHUB_SLOW_POLL_MS; + await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_SLOW_POLL_MS); + + expect(runner.calls).toHaveLength(5); + expect(reads.map((read) => read.reason)).toEqual([ + undefined, + "self-heal-github", + "self-heal-github", + "self-heal-github", + "self-heal-github", + ]); + + subscription?.unsubscribe(); + service.dispose?.(); + }); + + it("unsubscribe clears the adaptive GitHub poll timer", async () => { + let now = 0; + const runner = createRunner([ + currentPullRequestJson({ + statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "PENDING" }], + }), + ]); + const service = createGitHubService({ + ttlMs: 0, + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => now, + }); + + const subscription = service.retainCurrentPullRequestStatusPoll?.({ + cwd: "/repo", + headRef: "feature/fork", + }); + await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }); + subscription?.unsubscribe(); + + now = EXPECTED_GITHUB_FAST_POLL_MS; + await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS); + + expect(runner.calls).toHaveLength(1); + + service.dispose?.(); + }); + + it("dispose clears all adaptive GitHub poll timers", async () => { + let now = 0; + const runner = createRunner([ + currentPullRequestJson({ + statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "PENDING" }], + }), + ]); + const service = createGitHubService({ + ttlMs: 0, + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => now, + }); + + service.retainCurrentPullRequestStatusPoll?.({ cwd: "/repo", headRef: "feature/fork" }); + await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }); + service.dispose?.(); + + now = EXPECTED_GITHUB_FAST_POLL_MS; + await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS); + + expect(runner.calls).toHaveLength(1); + }); + it("fetches PR reviews and issue comments with one GraphQL call sorted chronologically", async () => { const runner = createRunner([pullRequestTimelineJson()]); const service = createGitHubService({ @@ -1216,4 +1491,230 @@ describe("GitHubService", () => { stderr: "GraphQL: unavailable", }); }); + + it("searches GitHub issues and PRs", async () => { + const runner = createRunner([issueJson("Issue title"), searchPullRequestJson("PR title")]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => 100, + }); + + await expect( + service.searchIssuesAndPrs({ cwd: "/repo", query: "cache", limit: 5 }), + ).resolves.toEqual({ + githubFeaturesEnabled: true, + items: [ + { + kind: "pr", + number: 123, + title: "PR title", + url: "https://github.com/acme/repo/pull/123", + state: "OPEN", + body: "pr body", + labels: ["enhancement"], + baseRefName: "main", + headRefName: "feature", + updatedAt: "2026-04-18T13:00:00Z", + }, + { + kind: "issue", + number: 55, + title: "Issue title", + url: "https://github.com/acme/repo/issues/55", + state: "OPEN", + body: "issue body", + labels: ["bug"], + baseRefName: null, + headRefName: null, + updatedAt: "2026-04-18T12:00:00Z", + }, + ], + }); + + expect(runner.calls).toEqual([ + { + cwd: "/repo", + args: [ + "issue", + "list", + "--search", + "cache", + "--json", + "number,title,url,state,body,labels,updatedAt", + "--limit", + "5", + ], + }, + { + cwd: "/repo", + args: [ + "pr", + "list", + "--search", + "cache", + "--json", + "number,title,url,state,body,labels,baseRefName,headRefName,updatedAt", + "--limit", + "5", + ], + }, + ]); + }); + + it("searches only GitHub PRs when the search kinds request excludes issues", async () => { + const runner = createRunner([searchPullRequestJson("PR title")]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => 100, + }); + + await expect( + service.searchIssuesAndPrs({ + cwd: "/repo", + query: "cache", + limit: 5, + kinds: ["github-pr"], + }), + ).resolves.toEqual({ + githubFeaturesEnabled: true, + items: [ + { + kind: "pr", + number: 123, + title: "PR title", + url: "https://github.com/acme/repo/pull/123", + state: "OPEN", + body: "pr body", + labels: ["enhancement"], + baseRefName: "main", + headRefName: "feature", + updatedAt: "2026-04-18T13:00:00Z", + }, + ], + }); + + expect(runner.calls).toEqual([ + { + cwd: "/repo", + args: [ + "pr", + "list", + "--search", + "cache", + "--json", + "number,title,url,state,body,labels,baseRefName,headRefName,updatedAt", + "--limit", + "5", + ], + }, + ]); + }); + + it("reuses cached PR status without another gh call", async () => { + let now = 100; + const runner = createRunner([currentPullRequestJson()]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => now, + }); + + await expect( + service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }), + ).resolves.toMatchObject({ number: 42 }); + + now = 101; + await expect( + service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }), + ).resolves.toMatchObject({ number: 42 }); + + expect(runner.calls).toHaveLength(1); + }); + + it("bypasses the warm PR status cache for forced reads", async () => { + const runner = createRunner([ + currentPullRequestJson({ number: 41, title: "First" }), + currentPullRequestJson({ number: 42, title: "Forced" }), + ]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => 100, + }); + + await expect( + service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }), + ).resolves.toMatchObject({ number: 41 }); + await expect( + service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "feature/fork", + force: true, + reason: "test", + }), + ).resolves.toMatchObject({ number: 42 }); + + expect(runner.calls).toHaveLength(2); + }); + + it("coalesces concurrent PR status callers", async () => { + const runner = createDeferredRunner(); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => 100, + }); + + const first = service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }); + const second = service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }); + await Promise.resolve(); + + expect(runner.calls).toHaveLength(1); + runner.resolveNext(currentPullRequestJson()); + + await expect(Promise.all([first, second])).resolves.toEqual([ + expect.objectContaining({ number: 42 }), + expect.objectContaining({ number: 42 }), + ]); + }); + + it("requires a reason for forced reads at runtime", async () => { + const service = createGitHubService({ + runner: async () => ({ stdout: currentPullRequestJson(), stderr: "" }), + resolveGhPath: async () => "/usr/bin/gh", + now: () => 100, + }); + + await expect( + service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "feature/fork", + force: true, + } as never), + ).rejects.toThrow("GitHubService forced read requires a reason"); + }); + + it("type: force true requires a reason", () => { + // @ts-expect-error force: true requires reason + const invalid: GitHubReadOptions = { force: true }; + const valid: GitHubReadOptions = { force: true, reason: "test" }; + + expect(invalid.force).toBe(true); + expect(valid.reason).toBe("test"); + }); + + it("resolves GitHub repos from a WorkspaceGitService-owned remote URL", async () => { + const workspaceGitService = { + resolveRepoRemoteUrl: async (cwd: string) => { + expect(cwd).toBe("/repo"); + return "git@github.com:getpaseo/paseo.git"; + }, + }; + + await expect(resolveGitHubRepo("/repo", { workspaceGitService })).resolves.toBe( + "getpaseo/paseo", + ); + }); }); diff --git a/packages/server/src/services/github-service.ts b/packages/server/src/services/github-service.ts index 8e44aad81..7f3e06817 100644 --- a/packages/server/src/services/github-service.ts +++ b/packages/server/src/services/github-service.ts @@ -1,9 +1,12 @@ import { z } from "zod"; +import type { GitHubSearchKind } from "../shared/messages.js"; import { findExecutable } from "../utils/executable.js"; -import { runGitCommand } from "../utils/run-git-command.js"; import { execCommand } from "../utils/spawn.js"; const DEFAULT_GITHUB_CACHE_TTL_MS = 30_000; +export const GITHUB_POLL_FAST_INTERVAL_MS = 20_000; +export const GITHUB_POLL_SLOW_INTERVAL_MS = 120_000; +export const GITHUB_POLL_ERROR_BACKOFF_CAP_MS = 300_000; const GITHUB_ENV: NodeJS.ProcessEnv = { ...process.env, GIT_TERMINAL_PROMPT: "0", @@ -343,30 +346,63 @@ export interface GitHubPullRequestCreateResult { number: number; } -export interface ListGitHubPullRequestsOptions { +export type GitHubReadOptions = + | { + force?: false; + reason?: string; + } + | { + force: true; + reason: string; + }; + +export type ListGitHubPullRequestsOptions = { cwd: string; query?: string; limit?: number; -} +} & GitHubReadOptions; -export interface ListGitHubIssuesOptions { +export type ListGitHubIssuesOptions = { cwd: string; query?: string; limit?: number; -} +} & GitHubReadOptions; -export interface GetGitHubPullRequestOptions { +export type GetGitHubPullRequestOptions = { cwd: string; number: number; -} +} & GitHubReadOptions; -export interface GetGitHubPullRequestTimelineOptions { +export type GetGitHubPullRequestTimelineOptions = { cwd: string; prNumber: number; repoOwner: string; repoName: string; +} & GitHubReadOptions; + +export interface GitHubSearchResult { + items: Array<{ + kind: "issue" | "pr"; + number: number; + title: string; + url: string; + state: string; + body: string | null; + labels: string[]; + baseRefName?: string | null; + headRefName?: string | null; + updatedAt?: string; + }>; + githubFeaturesEnabled: boolean; } +export type SearchGitHubIssuesAndPrsOptions = { + cwd: string; + query: string; + limit?: number; + kinds?: GitHubSearchKind[]; +} & GitHubReadOptions; + export interface CreateGitHubPullRequestOptions { cwd: string; repo: string; @@ -381,18 +417,28 @@ export interface GitHubService { listIssues(options: ListGitHubIssuesOptions): Promise; getPullRequest(options: GetGitHubPullRequestOptions): Promise; getPullRequestHeadRef(options: GetGitHubPullRequestOptions): Promise; - getCurrentPullRequestStatus(options: { - cwd: string; - headRef: string; - }): Promise; + getCurrentPullRequestStatus( + options: { + cwd: string; + headRef: string; + } & GitHubReadOptions, + ): Promise; getPullRequestTimeline( options: GetGitHubPullRequestTimelineOptions, ): Promise; + searchIssuesAndPrs(options: SearchGitHubIssuesAndPrsOptions): Promise; createPullRequest( options: CreateGitHubPullRequestOptions, ): Promise; - isAuthenticated(options: { cwd: string }): Promise; + isAuthenticated(options: { cwd: string } & GitHubReadOptions): Promise; + retainCurrentPullRequestStatusPoll?(options: { + cwd: string; + headRef: string; + onStatus?: (status: GitHubCurrentPullRequestStatus | null) => void; + onError?: (error: unknown) => void; + }): { unsubscribe: () => void }; invalidate(options: { cwd: string }): void; + dispose?(): void; } export class GitHubCliMissingError extends Error { @@ -453,6 +499,18 @@ type CurrentPullRequestStatusItem = z.infer; + force: boolean; +} + +interface GitHubPollTarget { + cwd: string; + headRef: string; + retainCount: number; + timer: NodeJS.Timeout | null; + latestStatus: GitHubCurrentPullRequestStatus | null; + consecutiveErrors: number; + callbacks: Set<(status: GitHubCurrentPullRequestStatus | null) => void>; + errorCallbacks: Set<(error: unknown) => void>; } interface ResolvedPullRequestCandidate { @@ -469,13 +527,20 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G }; const cache = new Map(); const inFlight = new Map(); + const pollTargets = new Map(); + let api!: GitHubService; async function cached(params: { cwd: string; method: string; args: unknown; + readOptions?: GitHubReadOptions; load: () => Promise; }): Promise { + if (params.readOptions?.force && !params.readOptions.reason) { + throw new Error("GitHubService forced read requires a reason"); + } + const key = buildCacheKey({ cwd: params.cwd, method: params.method, @@ -483,12 +548,12 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G }); const cachedEntry = cache.get(key); const now = deps.now(); - if (cachedEntry && cachedEntry.expiresAt > now) { + if (!params.readOptions?.force && cachedEntry && cachedEntry.expiresAt > now) { return cachedEntry.value as T; } const existing = inFlight.get(key); - if (existing) { + if (existing && (!params.readOptions?.force || existing.force)) { return existing.promise as Promise; } @@ -509,7 +574,11 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G inFlight.delete(key); } }); - inFlight.set(key, { cwd: params.cwd, promise: request }); + inFlight.set(key, { + cwd: params.cwd, + promise: request, + force: params.readOptions?.force === true, + }); return request; } @@ -529,12 +598,93 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G } } - return { + function getPollTargetKey(options: { cwd: string; headRef: string }): string { + return buildCacheKey({ + cwd: options.cwd, + method: "getCurrentPullRequestStatus", + args: { headRef: options.headRef }, + }); + } + + function updatePollTargetAfterSuccess(options: { + cwd: string; + headRef: string; + status: GitHubCurrentPullRequestStatus | null; + notify: boolean; + }): void { + const target = pollTargets.get(getPollTargetKey(options)); + if (!target) { + return; + } + + target.latestStatus = options.status; + target.consecutiveErrors = 0; + if (options.notify) { + for (const callback of target.callbacks) { + callback(options.status); + } + } + scheduleGitHubPoll(target); + } + + function scheduleGitHubPoll(target: GitHubPollTarget): void { + scheduleGitHubPollAfter( + target, + computeGithubNextInterval(target.latestStatus, target.consecutiveErrors), + ); + } + + function scheduleImmediateGitHubPoll(target: GitHubPollTarget): void { + scheduleGitHubPollAfter(target, 0); + } + + function scheduleGitHubPollAfter(target: GitHubPollTarget, delayMs: number): void { + if (target.retainCount <= 0) { + return; + } + if (target.timer) { + clearTimeout(target.timer); + } + + target.timer = setTimeout(() => { + target.timer = null; + void runGitHubPoll(target); + }, delayMs); + } + + async function runGitHubPoll(target: GitHubPollTarget): Promise { + try { + await api.getCurrentPullRequestStatus({ + cwd: target.cwd, + headRef: target.headRef, + reason: "self-heal-github", + }); + } catch (error) { + target.consecutiveErrors += 1; + for (const callback of target.errorCallbacks) { + callback(error); + } + scheduleGitHubPoll(target); + } + } + + function closeGitHubPollTarget(target: GitHubPollTarget): void { + if (target.timer) { + clearTimeout(target.timer); + target.timer = null; + } + target.retainCount = 0; + target.callbacks.clear(); + target.errorCallbacks.clear(); + } + + api = { listPullRequests(options) { return cached({ cwd: options.cwd, method: "listPullRequests", args: { query: options.query ?? "", limit: options.limit ?? 20 }, + readOptions: options, load: async () => { const stdout = await run( [ @@ -559,6 +709,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G cwd: options.cwd, method: "listIssues", args: { query: options.query ?? "", limit: options.limit ?? 20 }, + readOptions: options, load: async () => { const stdout = await run( [ @@ -583,6 +734,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G cwd: options.cwd, method: "getPullRequest", args: { number: options.number }, + readOptions: options, load: async () => { const stdout = await run( [ @@ -609,6 +761,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G cwd: options.cwd, method: "getCurrentPullRequestStatus", args: { headRef: options.headRef }, + readOptions: options, load: async () => { return resolveCurrentPullRequestView({ cwd: options.cwd, @@ -616,6 +769,14 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G run, }); }, + }).then((status) => { + updatePollTargetAfterSuccess({ + cwd: options.cwd, + headRef: options.headRef, + status, + notify: options.reason === "self-heal-github", + }); + return status; }); }, @@ -624,6 +785,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G cwd: options.cwd, method: "getPullRequestTimeline", args: { prNumber: options.prNumber }, + readOptions: options, load: async () => { try { const stdout = await run( @@ -660,6 +822,96 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G }); }, + async searchIssuesAndPrs(options) { + if (options.force && !options.reason) { + throw new Error("GitHubService forced read requires a reason"); + } + + const kinds = options.kinds ?? ["github-issue", "github-pr"]; + const shouldFetchIssues = kinds.includes("github-issue"); + const shouldFetchPullRequests = kinds.includes("github-pr"); + const readOptions: GitHubReadOptions = options.force + ? { force: true, reason: options.reason } + : { force: false, reason: options.reason }; + const [issuesResult, prsResult] = await Promise.allSettled([ + shouldFetchIssues + ? this.listIssues({ + cwd: options.cwd, + query: options.query, + limit: options.limit, + ...readOptions, + }) + : Promise.resolve(null), + shouldFetchPullRequests + ? this.listPullRequests({ + cwd: options.cwd, + query: options.query, + limit: options.limit, + ...readOptions, + }) + : Promise.resolve(null), + ]); + + const items: GitHubSearchResult["items"] = []; + const requestedResults = [ + shouldFetchIssues ? issuesResult : null, + shouldFetchPullRequests ? prsResult : null, + ].filter((result) => result !== null); + if ( + requestedResults.length > 0 && + requestedResults.every( + (result) => + result.status === "rejected" && + (result.reason instanceof GitHubCliMissingError || + result.reason instanceof GitHubAuthenticationError), + ) + ) { + return { items: [], githubFeaturesEnabled: false }; + } + + if (shouldFetchIssues && issuesResult.status === "fulfilled") { + for (const item of issuesResult.value ?? []) { + items.push({ + kind: "issue", + number: item.number, + title: item.title, + url: item.url, + state: item.state, + body: item.body, + labels: item.labels, + baseRefName: null, + headRefName: null, + updatedAt: item.updatedAt, + }); + } + } + + if (shouldFetchPullRequests && prsResult.status === "fulfilled") { + for (const item of prsResult.value ?? []) { + items.push({ + kind: "pr", + number: item.number, + title: item.title, + url: item.url, + state: item.state, + body: item.body, + labels: item.labels, + baseRefName: item.baseRefName, + headRefName: item.headRefName, + updatedAt: item.updatedAt, + }); + } + } + + items.sort((left, right) => { + const leftTime = parseOptionalTime(left.updatedAt ?? null); + const rightTime = parseOptionalTime(right.updatedAt ?? null); + return rightTime - leftTime; + }); + + return { items, githubFeaturesEnabled: true }; + }, + async createPullRequest(options) { const args = [ "api", @@ -689,6 +941,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G cwd: options.cwd, method: "isAuthenticated", args: {}, + readOptions: options, load: async () => { try { await run(["auth", "status"], { cwd: options.cwd }); @@ -706,6 +959,60 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G }); }, + retainCurrentPullRequestStatusPoll(options) { + const key = getPollTargetKey(options); + let target = pollTargets.get(key); + if (!target) { + target = { + cwd: options.cwd, + headRef: options.headRef, + retainCount: 0, + timer: null, + latestStatus: null, + consecutiveErrors: 0, + callbacks: new Set(), + errorCallbacks: new Set(), + }; + pollTargets.set(key, target); + } + + const isNewlyRetained = target.retainCount === 0; + target.retainCount += 1; + if (options.onStatus) { + target.callbacks.add(options.onStatus); + } + if (options.onError) { + target.errorCallbacks.add(options.onError); + } + if (isNewlyRetained) { + scheduleImmediateGitHubPoll(target); + } else { + scheduleGitHubPoll(target); + } + + let unsubscribed = false; + return { + unsubscribe: () => { + if (unsubscribed) { + return; + } + unsubscribed = true; + if (options.onStatus) { + target.callbacks.delete(options.onStatus); + } + if (options.onError) { + target.errorCallbacks.delete(options.onError); + } + target.retainCount -= 1; + if (target.retainCount > 0) { + return; + } + closeGitHubPollTarget(target); + pollTargets.delete(key); + }, + }; + }, + invalidate(options) { // Local checkout mutations that can alter the current PR identity or PR status // must call this with the affected cwd before broadcasting fresh git state. @@ -720,7 +1027,40 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G } } }, + + dispose() { + for (const target of pollTargets.values()) { + closeGitHubPollTarget(target); + } + pollTargets.clear(); + }, }; + + return api; +} + +export function computeGithubNextInterval( + status: GitHubCurrentPullRequestStatus | null, + consecutiveErrors: number, +): number { + const baseInterval = isGitHubStatusPending(status) + ? GITHUB_POLL_FAST_INTERVAL_MS + : GITHUB_POLL_SLOW_INTERVAL_MS; + if (consecutiveErrors <= 1) { + return baseInterval; + } + + return Math.min(baseInterval * 2 ** (consecutiveErrors - 1), GITHUB_POLL_ERROR_BACKOFF_CAP_MS); +} + +function isGitHubStatusPending(status: GitHubCurrentPullRequestStatus | null): boolean { + if (!status) { + return false; + } + if (status.checksStatus === "pending") { + return true; + } + return status.checks.some((check) => check.status === "pending"); } async function resolveGhPath(): Promise { @@ -1393,13 +1733,20 @@ function mapReviewDecision(value: unknown): PullRequestReviewDecision { return null; } -export async function resolveGitHubRepo(cwd: string): Promise { +export interface GitHubRepoRemoteUrlResolver { + resolveRepoRemoteUrl(cwd: string, options?: GitHubReadOptions): Promise; +} + +export async function resolveGitHubRepo( + cwd: string, + options: { workspaceGitService: GitHubRepoRemoteUrlResolver; readOptions?: GitHubReadOptions }, +): Promise { try { - const { stdout } = await runGitCommand(["config", "--get", "remote.origin.url"], { + const remoteUrl = await options.workspaceGitService.resolveRepoRemoteUrl( cwd, - env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, - }); - return parseGitHubRepoFromRemote(stdout.trim()); + options.readOptions, + ); + return parseGitHubRepoFromRemote(remoteUrl?.trim() ?? ""); } catch { return null; } diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index e5e6f46df..d92f7f07d 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -2435,6 +2435,56 @@ export const CheckoutStatusResponseSchema = z.object({ ]), }); +export const CheckoutPrStatusSchema = z.object({ + number: z.number().optional(), + url: z.string(), + title: z.string(), + state: z.string(), + baseRefName: z.string(), + headRefName: z.string(), + isMerged: z.boolean(), + isDraft: z.boolean().optional().default(false), + checks: z + .array( + z.object({ + name: z.string(), + status: z.string(), + url: z.string().nullable(), + workflow: z.string().optional(), + duration: z.string().optional(), + }), + ) + .optional() + .default([]), + checksStatus: z.string().optional(), + reviewDecision: z.string().nullable().optional(), + repoOwner: z.string().optional(), + repoName: z.string().optional(), +}); + +const CheckoutPrStatusPayloadSchema = z.object({ + cwd: z.string(), + status: CheckoutPrStatusSchema.nullable(), + githubFeaturesEnabled: z.boolean(), + error: CheckoutErrorSchema.nullable(), + requestId: z.string(), +}); + +const CheckoutStatusUpdateMetadataSchema = z.object({ + prStatus: CheckoutPrStatusPayloadSchema.optional(), +}); + +export const CheckoutStatusUpdateSchema = z.object({ + type: z.literal("checkout_status_update"), + payload: z + .union([ + CheckoutStatusNotGitSchema, + CheckoutStatusGitNonPaseoSchema, + CheckoutStatusGitPaseoSchema, + ]) + .and(CheckoutStatusUpdateMetadataSchema), +}); + const CheckoutDiffSubscriptionPayloadSchema = z.object({ subscriptionId: z.string(), cwd: z.string(), @@ -2515,42 +2565,9 @@ export const CheckoutPrCreateResponseSchema = z.object({ }), }); -export const CheckoutPrStatusSchema = z.object({ - number: z.number().optional(), - url: z.string(), - title: z.string(), - state: z.string(), - baseRefName: z.string(), - headRefName: z.string(), - isMerged: z.boolean(), - isDraft: z.boolean().optional().default(false), - checks: z - .array( - z.object({ - name: z.string(), - status: z.string(), - url: z.string().nullable(), - workflow: z.string().optional(), - duration: z.string().optional(), - }), - ) - .optional() - .default([]), - checksStatus: z.string().optional(), - reviewDecision: z.string().nullable().optional(), - repoOwner: z.string().optional(), - repoName: z.string().optional(), -}); - export const CheckoutPrStatusResponseSchema = z.object({ type: z.literal("checkout_pr_status_response"), - payload: z.object({ - cwd: z.string(), - status: CheckoutPrStatusSchema.nullable(), - githubFeaturesEnabled: z.boolean(), - error: CheckoutErrorSchema.nullable(), - requestId: z.string(), - }), + payload: CheckoutPrStatusPayloadSchema, }); const PullRequestTimelineKnownErrorSchema = z.discriminatedUnion("kind", [ @@ -3092,6 +3109,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ AgentArchivedMessageSchema, CloseItemsResponseSchema, CheckoutStatusResponseSchema, + CheckoutStatusUpdateSchema, SubscribeCheckoutDiffResponseSchema, CheckoutDiffUpdateSchema, CheckoutCommitResponseSchema, @@ -3318,6 +3336,7 @@ export type SetAgentFeatureRequestMessage = z.infer; export type CheckoutStatusRequest = z.infer; export type CheckoutStatusResponse = z.infer; +export type CheckoutStatusUpdate = z.infer; export type SubscribeCheckoutDiffRequest = z.infer; export type UnsubscribeCheckoutDiffRequest = z.infer; export type SubscribeCheckoutDiffResponse = z.infer; diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index a5e5ce943..f151dadff 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -28,7 +28,6 @@ import { parseWorktreeList, isPaseoWorktreePath, isDescendantPath, - searchGitHubIssuesAndPrs, warmCheckoutShortstatInBackground, } from "./checkout-git.js"; import { @@ -96,6 +95,7 @@ function createGitHubServiceForStatus( return { listPullRequests: async () => [], listIssues: async () => [], + searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }), getPullRequest: async () => ({ number: 1, title: "PR", @@ -135,6 +135,27 @@ function createPullRequestStatus(overrides?: Partial { let tempDir: string; let repoDir: string; @@ -313,6 +334,164 @@ const x = 1; expect(divergedStatus.behindOfOrigin).toBe(1); }); + it("reports incoming additions when the base branch is behind its remote", async () => { + const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir); + commitFile(cloneDir, "file.txt", "remote one\nremote two\n", "remote update"); + execSync("git push", { cwd: cloneDir }); + execSync("git fetch origin", { cwd: repoDir }); + + const shortstat = await getCheckoutShortstat(repoDir); + + expect(shortstat).toEqual({ additions: 2, deletions: 1 }); + }); + + it("reports incoming deletions when the base branch is behind its remote", async () => { + const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir); + commitFile(cloneDir, "file.txt", "", "remote deletion"); + execSync("git push", { cwd: cloneDir }); + execSync("git fetch origin", { cwd: repoDir }); + + const shortstat = await getCheckoutShortstat(repoDir); + + expect(shortstat).toEqual({ additions: 0, deletions: 1 }); + }); + + it("reports outgoing changes when the base branch is ahead of its remote", async () => { + setupRemoteTrackingMain(repoDir, tempDir); + commitFile(repoDir, "file.txt", "local one\nlocal two\n", "local update"); + + const shortstat = await getCheckoutShortstat(repoDir); + + expect(shortstat).toEqual({ additions: 2, deletions: 1 }); + }); + + it("uses the merge-base for shortstat when the base branch diverged from its remote", async () => { + const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir); + commitFile(cloneDir, "file.txt", "remote one\nremote two\n", "remote update"); + execSync("git push", { cwd: cloneDir }); + execSync("git fetch origin", { cwd: repoDir }); + commitFile(repoDir, "local.txt", "local\n", "local update"); + + const shortstat = await getCheckoutShortstat(repoDir); + + expect(shortstat).toEqual({ additions: 1, deletions: 0 }); + }); + + it("keeps base branch divergence pointed at local work when the remote has more commits", async () => { + const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir); + commitFile(cloneDir, "remote-one.txt", "remote one\n", "remote update one"); + commitFile(cloneDir, "remote-two.txt", "remote two\n", "remote update two"); + execSync("git push", { cwd: cloneDir }); + execSync("git fetch origin", { cwd: repoDir }); + commitFile(repoDir, "local.txt", "local\n", "local update"); + + const shortstat = await getCheckoutShortstat(repoDir); + + expect(shortstat).toEqual({ additions: 1, deletions: 0 }); + }); + + it("adds working tree changes to incoming shortstat when the base branch is behind", async () => { + commitFile(repoDir, "tracked.txt", "tracked base\n", "add tracked file"); + const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir); + commitFile(cloneDir, "incoming.txt", "incoming\n", "remote incoming"); + execSync("git push", { cwd: cloneDir }); + execSync("git fetch origin", { cwd: repoDir }); + writeFileSync(join(repoDir, "tracked.txt"), "local one\nlocal two\n"); + + const shortstat = await getCheckoutShortstat(repoDir); + + expect(shortstat).toEqual({ additions: 3, deletions: 1 }); + }); + + it("keeps feature shortstat scoped to feature changes when the base remote is ahead", async () => { + const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir); + execSync("git checkout -b feature", { cwd: repoDir }); + commitFile(repoDir, "feature.txt", "feature\n", "feature update"); + execSync("git checkout main", { cwd: cloneDir }); + commitFile(cloneDir, "base.txt", "base\n", "base update"); + execSync("git push", { cwd: cloneDir }); + execSync("git fetch origin", { cwd: repoDir }); + + const shortstat = await getCheckoutShortstat(repoDir); + + expect(shortstat).toEqual({ additions: 1, deletions: 0 }); + }); + + it("reports incoming base changes when a feature branch has no local work beyond merge-base", async () => { + const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir); + execSync("git checkout -b feature", { cwd: repoDir }); + execSync("git checkout main", { cwd: cloneDir }); + commitFile(cloneDir, "incoming.txt", "incoming\n", "remote incoming"); + execSync("git push", { cwd: cloneDir }); + execSync("git fetch origin", { cwd: repoDir }); + + const shortstat = await getCheckoutShortstat(repoDir); + + expect(shortstat).toEqual({ additions: 1, deletions: 0 }); + }); + + it("reports feature shortstat ahead of the comparison merge-base", async () => { + setupRemoteTrackingMain(repoDir, tempDir); + execSync("git checkout -b feature", { cwd: repoDir }); + commitFile(repoDir, "feature.txt", "feature\n", "feature update"); + + const shortstat = await getCheckoutShortstat(repoDir); + + expect(shortstat).toEqual({ additions: 1, deletions: 0 }); + }); + + it("uses the merge-base for shortstat when a feature branch diverged from its tracked remote", async () => { + setupRemoteTrackingMain(repoDir, tempDir); + execSync("git checkout -b feature", { cwd: repoDir }); + execSync("git push -u origin feature", { cwd: repoDir }); + const featureCloneDir = join(tempDir, "feature-clone"); + execSync(`git clone ${join(tempDir, "remote.git")} ${featureCloneDir}`); + execSync("git config user.email 'test@test.com'", { cwd: featureCloneDir }); + execSync("git config user.name 'Test'", { cwd: featureCloneDir }); + execSync("git checkout feature", { cwd: featureCloneDir }); + commitFile(featureCloneDir, "remote-feature.txt", "remote feature\n", "remote feature update"); + execSync("git push", { cwd: featureCloneDir }); + commitFile(repoDir, "local-feature.txt", "local feature\n", "local feature update"); + execSync("git fetch origin", { cwd: repoDir }); + + const shortstat = await getCheckoutShortstat(repoDir); + + expect(shortstat).toEqual({ additions: 1, deletions: 0 }); + }); + + it("uses the remote-only base branch as the feature shortstat comparison", async () => { + const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir); + execSync("git remote set-head origin main", { cwd: repoDir }); + execSync("git checkout -b feature", { cwd: repoDir }); + commitFile(repoDir, "feature.txt", "feature\n", "feature update"); + execSync("git branch -D main", { cwd: repoDir }); + commitFile(cloneDir, "base.txt", "base\n", "base update"); + execSync("git push", { cwd: cloneDir }); + execSync("git fetch origin", { cwd: repoDir }); + + const shortstat = await getCheckoutShortstat(repoDir); + + expect(shortstat).toEqual({ additions: 1, deletions: 0 }); + }); + + it("returns no shortstat for a clean base branch that is up to date with its remote", async () => { + setupRemoteTrackingMain(repoDir, tempDir); + execSync("git fetch origin", { cwd: repoDir }); + + const shortstat = await getCheckoutShortstat(repoDir); + + expect(shortstat).toBeNull(); + }); + + it("reports working tree changes when the base branch has no ahead commits", async () => { + setupRemoteTrackingMain(repoDir, tempDir); + writeFileSync(join(repoDir, "file.txt"), "local one\nlocal two\n"); + + const shortstat = await getCheckoutShortstat(repoDir); + + expect(shortstat).toEqual({ additions: 2, deletions: 1 }); + }); + it("uses the freshest comparison base for status and shortstat when local main is stale", async () => { const remoteDir = join(tempDir, "remote.git"); const cloneDir = join(tempDir, "clone"); @@ -1125,129 +1304,6 @@ const x = 1; expect(status.status).toBeNull(); }); - it("searches GitHub issues and PRs through the GitHub service", async () => { - let issueCalls = 0; - let pullRequestCalls = 0; - const github = createGitHubServiceForStatus(null); - github.listIssues = async (options) => { - issueCalls += 1; - expect(options).toEqual({ cwd: repoDir, query: "cache", limit: 5 }); - return [ - { - number: 55, - title: "Issue title", - url: "https://github.com/getpaseo/paseo/issues/55", - state: "OPEN", - body: "issue body", - labels: ["bug"], - updatedAt: "2026-04-18T12:00:00Z", - }, - ]; - }; - github.listPullRequests = async (options) => { - pullRequestCalls += 1; - expect(options).toEqual({ cwd: repoDir, query: "cache", limit: 5 }); - return [ - { - number: 123, - title: "PR title", - url: "https://github.com/getpaseo/paseo/pull/123", - state: "OPEN", - body: "pr body", - baseRefName: "main", - headRefName: "feature", - labels: ["enhancement"], - updatedAt: "2026-04-18T13:00:00Z", - }, - ]; - }; - - const result = await searchGitHubIssuesAndPrs(repoDir, "cache", 5, github); - - expect(issueCalls).toBe(1); - expect(pullRequestCalls).toBe(1); - expect(result).toEqual({ - githubFeaturesEnabled: true, - items: [ - { - kind: "issue", - number: 55, - title: "Issue title", - url: "https://github.com/getpaseo/paseo/issues/55", - state: "OPEN", - body: "issue body", - labels: ["bug"], - baseRefName: null, - headRefName: null, - updatedAt: "2026-04-18T12:00:00Z", - }, - { - kind: "pr", - number: 123, - title: "PR title", - url: "https://github.com/getpaseo/paseo/pull/123", - state: "OPEN", - body: "pr body", - labels: ["enhancement"], - baseRefName: "main", - headRefName: "feature", - updatedAt: "2026-04-18T13:00:00Z", - }, - ], - }); - }); - - it("searches only GitHub PRs when the search kinds request excludes issues", async () => { - let issueCalls = 0; - let pullRequestCalls = 0; - const github = createGitHubServiceForStatus(null); - github.listIssues = async () => { - issueCalls += 1; - return []; - }; - github.listPullRequests = async (options) => { - pullRequestCalls += 1; - expect(options).toEqual({ cwd: repoDir, query: "cache", limit: 5 }); - return [ - { - number: 123, - title: "PR title", - url: "https://github.com/getpaseo/paseo/pull/123", - state: "OPEN", - body: "pr body", - baseRefName: "main", - headRefName: "feature", - labels: ["enhancement"], - updatedAt: "2026-04-18T13:00:00Z", - }, - ]; - }; - - const result = await searchGitHubIssuesAndPrs(repoDir, "cache", 5, github, { - kinds: ["github-pr"], - }); - - expect(issueCalls).toBe(0); - expect(pullRequestCalls).toBe(1); - expect(result).toEqual({ - githubFeaturesEnabled: true, - items: [ - { - kind: "pr", - number: 123, - title: "PR title", - url: "https://github.com/getpaseo/paseo/pull/123", - state: "OPEN", - body: "pr body", - labels: ["enhancement"], - baseRefName: "main", - headRefName: "feature", - updatedAt: "2026-04-18T13:00:00Z", - }, - ], - }); - }); - it("returns merged PR status when no open PR exists for the current branch", async () => { execSync("git checkout -b feature", { cwd: repoDir }); execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir }); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index fc388a7f1..d719b5bd9 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -3,13 +3,13 @@ import { existsSync, realpathSync } from "fs"; import { open as openFile, stat as statFile } from "fs/promises"; import { TTLCache } from "@isaacs/ttlcache"; import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js"; -import type { GitHubSearchKind } from "../shared/messages.js"; import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js"; import { GitHubAuthenticationError, GitHubCliMissingError, createGitHubService, resolveGitHubRepo, + type GitHubRepoRemoteUrlResolver, type GitHubService, } from "../services/github-service.js"; import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; @@ -33,6 +33,11 @@ let shortstatCacheTtlMs = DEFAULT_SHORTSTAT_CACHE_TTL_MS; let shortstatCache = createShortstatCache(shortstatCacheTtlMs); const shortstatInFlight = new Map>(); +interface CheckoutReadCacheOptions { + force?: boolean; + reason?: string; +} + function createPullRequestStatusCache(ttlMs: number) { return new TTLCache({ ttl: ttlMs, @@ -1317,6 +1322,44 @@ export interface CheckoutShortstat { deletions: number; } +function parseCheckoutShortstat(text: string): CheckoutShortstat | null { + const trimmed = text.trim(); + if (!trimmed) { + return null; + } + + let additions = 0; + let deletions = 0; + const addMatch = trimmed.match(/(\d+)\s+insertion/); + if (addMatch) { + additions = Number.parseInt(addMatch[1]!, 10); + } + const delMatch = trimmed.match(/(\d+)\s+deletion/); + if (delMatch) { + deletions = Number.parseInt(delMatch[1]!, 10); + } + + if (additions === 0 && deletions === 0) { + return null; + } + + return { additions, deletions }; +} + +function combineCheckoutShortstats( + first: CheckoutShortstat | null, + second: CheckoutShortstat | null, +): CheckoutShortstat | null { + const additions = (first?.additions ?? 0) + (second?.additions ?? 0); + const deletions = (first?.deletions ?? 0) + (second?.deletions ?? 0); + + if (additions === 0 && deletions === 0) { + return null; + } + + return { additions, deletions }; +} + async function getCheckoutShortstatUncached( cwd: string, context?: CheckoutContext, @@ -1331,63 +1374,65 @@ async function getCheckoutShortstatUncached( const localBaseRef = configured.baseRef ?? (await resolveBaseRef(cwd)); const currentBranch = await getCurrentBranch(cwd); - let diffTarget: string; + let comparisonRef: string; if (currentBranch && localBaseRef && currentBranch !== localBaseRef) { - // Feature branch: diff against the merge-base with the base branch - const comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, localBaseRef); - try { - const { stdout } = await runGitCommand(["merge-base", "HEAD", comparisonBaseRef], { - cwd, - env: READ_ONLY_GIT_ENV, - }); - const mergeBase = stdout.trim(); - if (!mergeBase) { - return null; - } - diffTarget = mergeBase; + comparisonRef = await resolveBestComparisonBaseRef(cwd, localBaseRef); } catch { return null; } } else if (currentBranch) { - // On the base branch (or no base ref configured): diff against remote tracking branch const hasOrigin = await doesGitRefExist(cwd, `refs/remotes/origin/${currentBranch}`); if (!hasOrigin) { return null; } - diffTarget = `origin/${currentBranch}`; + comparisonRef = `origin/${currentBranch}`; } else { return null; } try { - // Omit HEAD so the diff includes uncommitted (staged + unstaged) changes - const { stdout } = await runGitCommand(["diff", "--shortstat", diffTarget], { + const { stdout: mergeBaseOut } = await runGitCommand(["merge-base", "HEAD", comparisonRef], { cwd, env: READ_ONLY_GIT_ENV, }); - const text = stdout.trim(); - if (!text) { + const mergeBase = mergeBaseOut.trim(); + if (!mergeBase) { return null; } - let additions = 0; - let deletions = 0; - const addMatch = text.match(/(\d+)\s+insertion/); - if (addMatch) { - additions = Number.parseInt(addMatch[1]!, 10); - } - const delMatch = text.match(/(\d+)\s+deletion/); - if (delMatch) { - deletions = Number.parseInt(delMatch[1]!, 10); - } - - if (additions === 0 && deletions === 0) { + const { stdout: aheadBehindOut } = await runGitCommand( + ["rev-list", "--left-right", "--count", `HEAD...${comparisonRef}`], + { cwd, env: READ_ONLY_GIT_ENV }, + ); + const [headOnlyRaw, comparisonOnlyRaw] = aheadBehindOut.trim().split(/\s+/); + const headOnly = Number.parseInt(headOnlyRaw ?? "0", 10); + const comparisonOnly = Number.parseInt(comparisonOnlyRaw ?? "0", 10); + if (Number.isNaN(headOnly) || Number.isNaN(comparisonOnly)) { return null; } - return { additions, deletions }; + // Incoming uses a two-ref diff, so add the working tree diff separately. + const showIncoming = headOnly === 0 && comparisonOnly > 0; + const { stdout } = await runGitCommand( + ["diff", "--shortstat", mergeBase, ...(showIncoming ? [comparisonRef] : [])], + { + cwd, + env: READ_ONLY_GIT_ENV, + }, + ); + const committedShortstat = parseCheckoutShortstat(stdout); + + if (!showIncoming) { + return committedShortstat; + } + + const { stdout: worktreeOut } = await runGitCommand(["diff", "--shortstat", "HEAD"], { + cwd, + env: READ_ONLY_GIT_ENV, + }); + return combineCheckoutShortstats(committedShortstat, parseCheckoutShortstat(worktreeOut)); } catch { return null; } @@ -1396,16 +1441,19 @@ async function getCheckoutShortstatUncached( function getOrLoadCheckoutShortstat( cwd: string, context?: CheckoutContext, + options?: CheckoutReadCacheOptions, ): Promise { const cacheKey = getShortstatCacheKey(cwd); - const cached = shortstatCache.get(cacheKey); - if (cached !== undefined) { - return Promise.resolve(cached); - } + if (!options?.force) { + const cached = shortstatCache.get(cacheKey); + if (cached !== undefined) { + return Promise.resolve(cached); + } - const existing = shortstatInFlight.get(cacheKey); - if (existing) { - return existing; + const existing = shortstatInFlight.get(cacheKey); + if (existing) { + return existing; + } } const load = getCheckoutShortstatUncached(cwd, context) @@ -1424,8 +1472,9 @@ function getOrLoadCheckoutShortstat( export async function getCheckoutShortstat( cwd: string, context?: CheckoutContext, + options?: CheckoutReadCacheOptions, ): Promise { - return getOrLoadCheckoutShortstat(cwd, context); + return getOrLoadCheckoutShortstat(cwd, context, options); } export function getCachedCheckoutShortstat(cwd: string): CheckoutShortstat | null | undefined { @@ -1996,9 +2045,10 @@ export async function createPullRequest( cwd: string, options: CreatePullRequestOptions, github: GitHubService = createGitHubService(), + workspaceGitService: GitHubRepoRemoteUrlResolver, ): Promise<{ url: string; number: number }> { await requireGitRepo(cwd); - const repo = await resolveGitHubRepo(cwd); + const repo = await resolveGitHubRepo(cwd, { workspaceGitService }); if (!repo) { throw new Error("Unable to determine GitHub repo from git remote"); } @@ -2034,19 +2084,22 @@ export async function createPullRequest( export async function getPullRequestStatus( cwd: string, github: GitHubService = createGitHubService(), + options?: CheckoutReadCacheOptions, ): Promise { const cacheKey = getPullRequestStatusCacheKey(cwd); - const cached = pullRequestStatusCache.get(cacheKey); - if (cached) { - return cached; + if (!options?.force) { + const cached = pullRequestStatusCache.get(cacheKey); + if (cached) { + return cached; + } + + const existing = pullRequestStatusInFlight.get(cacheKey); + if (existing) { + return existing; + } } - const existing = pullRequestStatusInFlight.get(cacheKey); - if (existing) { - return existing; - } - - const lookup = getPullRequestStatusUncached(cwd, github) + const lookup = getPullRequestStatusUncached(cwd, github, options) .then((status) => { pullRequestStatusCache.set(cacheKey, status); return status; @@ -2062,6 +2115,7 @@ export async function getPullRequestStatus( async function getPullRequestStatusUncached( cwd: string, github: GitHubService, + options?: CheckoutReadCacheOptions, ): Promise { await requireGitRepo(cwd); const head = await getCurrentBranch(cwd); @@ -2072,7 +2126,11 @@ async function getPullRequestStatusUncached( }; } try { - const status = await github.getCurrentPullRequestStatus({ cwd, headRef: head }); + const status = await github.getCurrentPullRequestStatus({ + cwd, + headRef: head, + reason: options?.reason, + }); return { status, githubFeaturesEnabled: true, @@ -2084,107 +2142,3 @@ async function getPullRequestStatusUncached( throw error; } } - -export interface GitHubSearchResult { - items: Array<{ - kind: "issue" | "pr"; - number: number; - title: string; - url: string; - state: string; - body: string | null; - labels: string[]; - baseRefName?: string | null; - headRefName?: string | null; - updatedAt?: string; - }>; - githubFeaturesEnabled: boolean; -} - -interface SearchGitHubIssuesAndPrsOptions { - kinds?: GitHubSearchKind[]; -} - -export async function searchGitHubIssuesAndPrs( - cwd: string, - query: string, - limit = 20, - github: GitHubService = createGitHubService(), - options: SearchGitHubIssuesAndPrsOptions = {}, -): Promise { - await requireGitRepo(cwd); - try { - const kinds = options.kinds ?? ["github-issue", "github-pr"]; - const shouldFetchIssues = kinds.includes("github-issue"); - const shouldFetchPullRequests = kinds.includes("github-pr"); - const issuesResult = shouldFetchIssues - ? await github.listIssues({ cwd, query, limit }).then( - (value) => ({ status: "fulfilled" as const, value }), - (reason) => ({ status: "rejected" as const, reason }), - ) - : null; - const prsResult = shouldFetchPullRequests - ? await github.listPullRequests({ cwd, query, limit }).then( - (value) => ({ status: "fulfilled" as const, value }), - (reason) => ({ status: "rejected" as const, reason }), - ) - : null; - - const items: GitHubSearchResult["items"] = []; - const requestedResults = [issuesResult, prsResult].filter((result) => result !== null); - const failures = requestedResults.filter((result) => result.status === "rejected"); - if ( - requestedResults.length > 0 && - failures.length === requestedResults.length && - failures.every( - (result) => - result.status === "rejected" && - (result.reason instanceof GitHubCliMissingError || - result.reason instanceof GitHubAuthenticationError), - ) - ) { - return { items: [], githubFeaturesEnabled: false }; - } - - if (issuesResult?.status === "fulfilled") { - for (const item of issuesResult.value) { - items.push({ - kind: "issue", - number: item.number, - title: item.title, - url: item.url, - state: item.state, - body: item.body, - labels: item.labels, - baseRefName: null, - headRefName: null, - updatedAt: item.updatedAt, - }); - } - } - - if (prsResult?.status === "fulfilled") { - for (const item of prsResult.value) { - items.push({ - kind: "pr", - number: item.number, - title: item.title, - url: item.url, - state: item.state, - body: item.body, - labels: item.labels, - baseRefName: item.baseRefName, - headRefName: item.headRefName, - updatedAt: item.updatedAt, - }); - } - } - - return { items, githubFeaturesEnabled: true }; - } catch (error) { - if (error instanceof GitHubCliMissingError || error instanceof GitHubAuthenticationError) { - return { items: [], githubFeaturesEnabled: false }; - } - throw error; - } -}