From 05204819663e10bcb56439148b2fdc66f4608738 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 11 Aug 2026 07:40:20 +0200 Subject: [PATCH] feat(workspaces): route Git operations through runtimes Bind Git callers to workspace identity so selected repositories cannot collapse onto cwd-keyed state. Run commands and observation through runtime capabilities while preserving explicit cwd-bound behavior for legacy records. --- .../app/src/components/branch-switcher.tsx | 10 +- .../compact-explorer-sidebar-host.tsx | 138 ++-- .../app/src/components/explorer-sidebar.tsx | 18 +- .../src/components/workspace-setup-dialog.tsx | 43 +- .../src/composer/github/auto-attach.test.tsx | 50 +- .../app/src/composer/github/auto-attach.ts | 45 +- packages/app/src/composer/index.tsx | 11 +- packages/app/src/data/push-router.test.ts | 76 +- packages/app/src/data/push-router.ts | 63 +- packages/app/src/git/actions-store.test.ts | 271 ++----- packages/app/src/git/actions-store.ts | 148 ++-- .../git/branch-switcher-operations.test.ts | 52 +- .../app/src/git/branch-switcher-operations.ts | 14 +- .../app/src/git/checkout-status-cache.test.ts | 361 ++------- packages/app/src/git/checkout-status-cache.ts | 62 +- packages/app/src/git/client-forge-module.ts | 1 + .../git/commits-section/commits-section.tsx | 4 +- packages/app/src/git/diff-pane.tsx | 24 +- packages/app/src/git/forges/gitlab.view.tsx | 4 - .../pull-request-panel/check-details.test.ts | 44 ++ .../git/pull-request-panel/check-details.ts | 32 + .../app/src/git/pull-request-panel/pane.tsx | 47 +- .../src/git/pull-request-panel/query-keys.ts | 15 +- .../git/pull-request-panel/use-data.test.ts | 60 +- .../src/git/pull-request-panel/use-data.ts | 49 +- .../git/pull-request-panel/use-pipeline.ts | 25 +- packages/app/src/git/query-keys.test.ts | 265 +++---- packages/app/src/git/query-keys.ts | 64 +- packages/app/src/git/use-actions.tsx | 82 +- packages/app/src/git/use-commits-query.ts | 15 +- packages/app/src/git/use-diff-files.ts | 19 +- packages/app/src/git/use-diff-query.ts | 12 +- .../src/git/use-forge-search-query.test.ts | 331 ++------ .../app/src/git/use-forge-search-query.ts | 88 ++- packages/app/src/git/use-pr-status-query.ts | 30 +- packages/app/src/git/use-status-query.ts | 66 +- packages/app/src/git/use-working-diff.ts | 5 +- packages/app/src/git/workspace-actions.tsx | 1 + .../git/workspace-git-owner.browser.test.tsx | 79 ++ packages/app/src/git/workspace-git.test.ts | 71 ++ packages/app/src/git/workspace-git.ts | 168 ++++ packages/app/src/hooks/use-branch-switcher.ts | 28 +- packages/app/src/panels/diff-panel.tsx | 13 +- .../app/src/screens/new-workspace-screen.tsx | 153 ++-- .../use-workspace-checkout-status.ts | 21 +- .../workspace-open-in-editor-button.tsx | 7 +- .../screens/workspace/workspace-screen.tsx | 99 ++- packages/client/src/daemon-client.test.ts | 172 +++++ packages/client/src/daemon-client.ts | 282 ++++++- packages/protocol/src/messages.ts | 85 +- .../messages.workspace-git-address.test.ts | 54 ++ packages/server/src/server/bootstrap.ts | 8 +- .../src/server/checkout-diff-manager.test.ts | 18 +- .../src/server/checkout-diff-manager.ts | 111 ++- ...space-runtime-characterization.e2e.test.ts | 10 +- packages/server/src/server/session.test.ts | 91 ++- packages/server/src/server/session.ts | 40 +- .../session.workspace-git-watch.test.ts | 16 +- .../src/server/session.workspaces.test.ts | 26 +- .../session/checkout/checkout-session.test.ts | 83 +- .../session/checkout/checkout-session.ts | 326 ++++---- .../git-mutation/git-mutation-service.test.ts | 21 +- .../git-mutation/git-mutation-service.ts | 100 ++- .../workspace-git-observer-service.test.ts | 115 ++- .../workspace-git-observer-service.ts | 104 ++- .../workspace-provisioning-service.ts | 17 +- .../workspace-scripts-service.test.ts | 8 +- .../workspace-scripts-service.ts | 10 +- .../workspace-runtime-command-fixture.mjs | 12 +- .../test-utils/workspace-git-service-stub.ts | 80 +- .../src/server/utils/diff-highlighter.ts | 7 + .../server/src/server/websocket-server.ts | 19 + .../server/workspace-git-directory.test.ts | 78 ++ .../src/server/workspace-git-directory.ts | 112 +++ ...workspace-git-noop-fetch.local.e2e.test.ts | 3 + .../src/server/workspace-git-observation.ts | 288 +++++++ .../workspace-git-service.primitive.test.ts | 4 +- ...ce-git-service.runtime.integration.test.ts | 726 ++++++++++++++++++ .../src/server/workspace-git-service.ts | 724 +++++++++++++++-- .../server/workspace-helper/executable.mjs | 55 +- .../src/server/workspace-helper/index.ts | 2 +- .../workspace-helper/internal/client.ts | 2 +- .../src/server/workspace-runtime/index.ts | 6 + .../workspace-runtime/internal/service.ts | 28 + .../workspace-runtime.docker.e2e.test.ts | 53 +- .../workspace-runtime.posix.test.ts | 12 +- packages/server/src/utils/checkout-git.ts | 181 ++--- .../run-git-command.runtime-context.test.ts | 75 ++ packages/server/src/utils/run-git-command.ts | 67 +- 89 files changed, 5343 insertions(+), 2172 deletions(-) create mode 100644 packages/app/src/git/pull-request-panel/check-details.test.ts create mode 100644 packages/app/src/git/pull-request-panel/check-details.ts create mode 100644 packages/app/src/git/workspace-git-owner.browser.test.tsx create mode 100644 packages/app/src/git/workspace-git.test.ts create mode 100644 packages/app/src/git/workspace-git.ts create mode 100644 packages/protocol/src/messages.workspace-git-address.test.ts create mode 100644 packages/server/src/server/workspace-git-directory.test.ts create mode 100644 packages/server/src/server/workspace-git-directory.ts create mode 100644 packages/server/src/server/workspace-git-observation.ts create mode 100644 packages/server/src/server/workspace-git-service.runtime.integration.test.ts create mode 100644 packages/server/src/utils/run-git-command.runtime-context.test.ts diff --git a/packages/app/src/components/branch-switcher.tsx b/packages/app/src/components/branch-switcher.tsx index 13a17b9999..b4f6a42e97 100644 --- a/packages/app/src/components/branch-switcher.tsx +++ b/packages/app/src/components/branch-switcher.tsx @@ -6,15 +6,13 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import type { Theme } from "@/styles/theme"; import { Combobox, ComboboxItem, type ComboboxProps } from "@/components/ui/combobox"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useToast } from "@/contexts/toast-context"; import { useBranchSwitcher } from "@/hooks/use-branch-switcher"; interface BranchSwitcherProps { currentBranchName: string | null; serverId: string; - workspaceId: string; - workspaceDirectory: string | null; isGitCheckout: boolean; testID?: string; } @@ -29,23 +27,17 @@ const ThemedChevronDown = withUnistyles(ChevronDown); export function BranchSwitcher({ currentBranchName, serverId, - workspaceId, - workspaceDirectory, isGitCheckout, testID = "workspace-header-branch-switcher", }: BranchSwitcherProps) { const { t } = useTranslation(); const anchorRef = useRef(null); - const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); const toast = useToast(); const queryClient = useQueryClient(); const { branchOptions, isOpen, setIsOpen, handleBranchSelect } = useBranchSwitcher({ - client, normalizedServerId: serverId, - normalizedWorkspaceId: workspaceId, - workspaceDirectory, currentBranchName, isGitCheckout, isConnected, diff --git a/packages/app/src/components/compact-explorer-sidebar-host.tsx b/packages/app/src/components/compact-explorer-sidebar-host.tsx index f51f827fa0..dfbe7ee269 100644 --- a/packages/app/src/components/compact-explorer-sidebar-host.tsx +++ b/packages/app/src/components/compact-explorer-sidebar-host.tsx @@ -11,6 +11,11 @@ import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status"; import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command"; import { isWeb } from "@/constants/platform"; +import { + bindSelectedWorkspaceGit, + WorkspaceGitBoundary, + type WorkspaceGitClient, +} from "@/git/workspace-git"; import { resolveCompactExplorerSidebarHostModel, type CompactExplorerSidebarHostModel, @@ -24,6 +29,62 @@ interface CompactExplorerOpenGestureSurfaceProps { const COMPACT_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y"; +function useCompactWorkspaceGit( + client: ReturnType, + workspaceId: string | null | undefined, + cwd: string | null | undefined, +): WorkspaceGitClient | null { + return useMemo(() => { + if (!client || !workspaceId || !cwd) { + return null; + } + return bindSelectedWorkspaceGit(client, { kind: "selected", workspaceId, cwd }); + }, [client, cwd, workspaceId]); +} + +function useCompactExplorerModel({ + enabled, + isExplorerOpen, + selection, + workspace, + isGit, + showMobileAgent, +}: { + enabled: boolean; + isExplorerOpen: boolean; + selection: Parameters[0]["selection"]; + workspace: Parameters[0]["workspace"]; + isGit: boolean; + showMobileAgent: () => void; +}): CompactExplorerSidebarHostModel | null { + const retainedModelRef = useRef(null); + const resolvedModel = useMemo( + () => + resolveCompactExplorerSidebarHostModel({ + previous: isExplorerOpen ? retainedModelRef.current : null, + selection, + workspace, + isGit, + }), + [isExplorerOpen, isGit, selection, workspace], + ); + useEffect(() => { + if (!selection) { + retainedModelRef.current = null; + if (enabled && isExplorerOpen) showMobileAgent(); + return; + } + if (!isExplorerOpen) { + retainedModelRef.current = null; + return; + } + if (resolvedModel) retainedModelRef.current = resolvedModel; + }, [enabled, isExplorerOpen, resolvedModel, selection, showMobileAgent]); + + if (!selection) return null; + return resolvedModel ?? (isExplorerOpen ? retainedModelRef.current : null); +} + function CompactExplorerOpenGestureSurface({ children, enabled, @@ -41,9 +102,10 @@ function CompactExplorerOpenGestureSurface({ ); } -function useActiveCompactExplorerSidebarModel( - enabled: boolean, -): CompactExplorerSidebarHostModel | null { +function useActiveCompactExplorerSidebarModel(enabled: boolean): { + model: CompactExplorerSidebarHostModel | null; + workspaceGit: WorkspaceGitClient | null; +} { const selection = useActiveWorkspaceSelection(); const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null); const isExplorerOpen = usePanelStore((state) => @@ -52,44 +114,32 @@ function useActiveCompactExplorerSidebarModel( const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const client = useHostRuntimeClient(selection?.serverId ?? ""); const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? ""); - const retainedModelRef = useRef(null); - const { checkoutQuery } = useWorkspaceCheckoutStatus({ + const workspaceGit = useCompactWorkspaceGit( client, + selection?.workspaceId, + workspace?.workspaceDirectory, + ); + const { checkoutQuery } = useWorkspaceCheckoutStatus({ + workspaceGit, isConnected, isRouteFocused: enabled && selection !== null, normalizedServerId: selection?.serverId ?? "", normalizedWorkspaceId: selection?.workspaceId ?? "", workspaceDirectory: workspace?.workspaceDirectory || null, }); - const resolvedModel = useMemo( - () => - resolveCompactExplorerSidebarHostModel({ - previous: isExplorerOpen ? retainedModelRef.current : null, - selection, - workspace, - isGit: checkoutQuery.data?.isGit ?? false, - }), - [checkoutQuery.data?.isGit, isExplorerOpen, selection, workspace], - ); + const model = useCompactExplorerModel({ + enabled, + isExplorerOpen, + selection, + workspace, + isGit: checkoutQuery.data?.isGit ?? false, + showMobileAgent, + }); - useEffect(() => { - if (!selection) { - retainedModelRef.current = null; - if (enabled && isExplorerOpen) { - showMobileAgent(); - } - return; - } - if (!isExplorerOpen) { - retainedModelRef.current = null; - return; - } - if (resolvedModel) { - retainedModelRef.current = resolvedModel; - } - }, [enabled, isExplorerOpen, resolvedModel, selection, showMobileAgent]); - - return selection ? (resolvedModel ?? (isExplorerOpen ? retainedModelRef.current : null)) : null; + return { + model, + workspaceGit, + }; } interface CompactExplorerSidebarHostProps { @@ -98,7 +148,7 @@ interface CompactExplorerSidebarHostProps { } export function CompactExplorerSidebarHost({ children, enabled }: CompactExplorerSidebarHostProps) { - const model = useActiveCompactExplorerSidebarModel(enabled); + const { model, workspaceGit } = useActiveCompactExplorerSidebarModel(enabled); const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout); const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused); @@ -142,15 +192,17 @@ export function CompactExplorerSidebarHost({ children, enabled }: CompactExplore > {children} - {enabled && model ? ( - - ) : null} + + {enabled && model ? ( + + ) : null} + ); } diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 9788776a2f..c5f874f52e 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -22,6 +22,7 @@ import { usePrPaneData, } from "@/git/pull-request-panel"; import { useCheckoutGitActionsStore } from "@/git/actions-store"; +import { useRequiredWorkspaceGit } from "@/git/workspace-git"; import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data"; import { usePanelStore, selectIsFileExplorerOpen, type ExplorerTab } from "@/stores/panel-store"; import { useToast } from "@/contexts/toast-context"; @@ -320,10 +321,10 @@ function ExplorerSidebarContent({ const { t } = useTranslation(); const toast = useToast(); const hasRightWindowControls = useHasOwnedWindowChromeObstruction("top-right"); + const workspaceGit = useRequiredWorkspaceGit(); const canQueryPullRequest = isGit && Boolean(workspaceRoot); const prPane = usePrPaneData({ serverId, - cwd: workspaceRoot, enabled: canQueryPullRequest && isOpen, timelineEnabled: activeTab === "pr" && canQueryPullRequest && isOpen, }); @@ -335,10 +336,13 @@ function ExplorerSidebarContent({ const prTabLabel = formatPrTabLabel(prPane.prNumber); const refreshGitActions = useCheckoutGitActionsStore((s) => s.refresh); const handlePrRetry = useCallback(() => { - refreshGitActions({ serverId, cwd: workspaceRoot }).catch((error) => { + refreshGitActions({ + serverId, + target: workspaceGit, + }).catch((error) => { toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh")); }); - }, [refreshGitActions, serverId, t, toast, workspaceRoot]); + }, [refreshGitActions, serverId, t, toast, workspaceGit]); const workspaceAttachmentScopeKey = useMemo( () => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd: workspaceRoot }), [serverId, workspaceId, workspaceRoot], @@ -419,7 +423,7 @@ function ExplorerSidebarContent({ {resolvedTab === "changes" && ( @@ -436,6 +440,7 @@ function ExplorerSidebarContent({ {resolvedTab === "pr" && ( - - - + + + + + {errorMessage ? {errorMessage} : null} diff --git a/packages/app/src/composer/github/auto-attach.test.tsx b/packages/app/src/composer/github/auto-attach.test.tsx index 2abb55ba36..ad1158ae4b 100644 --- a/packages/app/src/composer/github/auto-attach.test.tsx +++ b/packages/app/src/composer/github/auto-attach.test.tsx @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, renderHook } from "@testing-library/react"; import React, { type ReactNode } from "react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { describe, expect, it, vi } from "vitest"; import type { UserComposerAttachment } from "@/attachments/types"; import type { ForgeSearchClient } from "@/git/use-forge-search-query"; @@ -49,11 +49,12 @@ const issue202: ForgeSearchItem = { }; interface SearchCall { - cwd: string; query: string; limit?: number; } +type TestSearchClient = Pick; + interface HarnessInput { initialAttachments?: UserComposerAttachment[]; initialCwd?: string; @@ -72,7 +73,7 @@ function githubPayload(items: ForgeSearchItem[], requestId: string): ForgeSearch }; } -function createSearchClient(items: ForgeSearchItem[]): ForgeSearchClient & { calls: SearchCall[] } { +function createSearchClient(items: ForgeSearchItem[]): TestSearchClient & { calls: SearchCall[] } { const calls: SearchCall[] = []; return { calls, @@ -104,21 +105,28 @@ function createWrapper() { }; } -function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) { +function useHarness(client: TestSearchClient, input: HarnessInput = {}) { const [text, setText] = useState(input.initialText ?? ""); const [searchClient, setSearchClient] = useState(client); const [workingDirectory, setWorkingDirectory] = useState(input.initialCwd ?? cwd); const [attachments, setAttachments] = useState( input.initialAttachments ?? [], ); + const boundSearchClient = useMemo( + () => ({ + ...searchClient, + queryIdentity: ["selected", "workspace-1"] as const, + cwd: workingDirectory, + }), + [searchClient, workingDirectory], + ); const autoAttach = useComposerGithubAutoAttach({ text, remoteUrl: input.remote ?? remoteUrl, attachments, - client: searchClient, + client: boundSearchClient, isConnected: true, serverId: "server-1", - cwd: workingDirectory, setAttachments, onPullRequestDetected: input.onPullRequestDetected, onPullRequestAdded: input.onPullRequestAdded, @@ -161,7 +169,7 @@ describe("useComposerGithubAutoAttach", () => { expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]); expect(result.current.isResolving).toBe(false); - expect(client.calls).toEqual([{ cwd, query: "101", limit: 20 }]); + expect(client.calls).toEqual([{ query: "101", limit: 20 }]); vi.useRealTimers(); }); @@ -235,8 +243,8 @@ describe("useComposerGithubAutoAttach", () => { { kind: "forge_issue", item: issue202 }, ]); expect(client.calls).toEqual([ - { cwd, query: "101", limit: 20 }, - { cwd, query: "202", limit: 20 }, + { query: "101", limit: 20 }, + { query: "202", limit: 20 }, ]); vi.useRealTimers(); }); @@ -245,7 +253,7 @@ describe("useComposerGithubAutoAttach", () => { vi.useFakeTimers(); const firstLookup = deferred(); const secondLookup = deferred(); - const client: ForgeSearchClient = { + const client: TestSearchClient = { searchForge: vi .fn() .mockReturnValueOnce(firstLookup.promise) @@ -288,7 +296,7 @@ describe("useComposerGithubAutoAttach", () => { vi.useFakeTimers(); const firstLookup = deferred(); const secondLookup = deferred(); - const client: ForgeSearchClient = { + const client: TestSearchClient = { searchForge: vi .fn() .mockReturnValueOnce(firstLookup.promise) @@ -357,8 +365,8 @@ describe("useComposerGithubAutoAttach", () => { expect(onPullRequestAdded.mock.calls).toEqual([[pr202], [pr101]]); expect(client.calls).toEqual([ - { cwd, query: "202", limit: 20 }, - { cwd, query: "101", limit: 20 }, + { query: "202", limit: 20 }, + { query: "101", limit: 20 }, ]); vi.useRealTimers(); }); @@ -367,7 +375,7 @@ describe("useComposerGithubAutoAttach", () => { vi.useFakeTimers(); const firstLookup = deferred(); const secondLookup = deferred(); - const client: ForgeSearchClient = { + const client: TestSearchClient = { searchForge: vi .fn() .mockReturnValueOnce(firstLookup.promise) @@ -419,7 +427,7 @@ describe("useComposerGithubAutoAttach", () => { vi.useFakeTimers(); const firstLookup = deferred(); const secondLookup = deferred(); - const client: ForgeSearchClient = { + const client: TestSearchClient = { searchForge: vi .fn() .mockReturnValueOnce(firstLookup.promise) @@ -459,7 +467,7 @@ describe("useComposerGithubAutoAttach", () => { it("keeps resolving when a pending pull request URL is removed and re-added", async () => { vi.useFakeTimers(); const lookup = deferred(); - const client: ForgeSearchClient = { + const client: TestSearchClient = { searchForge: vi.fn().mockReturnValue(lookup.promise), }; const onPullRequestAdded = vi.fn(); @@ -500,7 +508,7 @@ describe("useComposerGithubAutoAttach", () => { vi.useFakeTimers(); const firstLookup = deferred(); const secondLookup = deferred(); - const client: ForgeSearchClient = { + const client: TestSearchClient = { searchForge: vi .fn() .mockReturnValueOnce(firstLookup.promise) @@ -545,7 +553,7 @@ describe("useComposerGithubAutoAttach", () => { it("stays resolving when an unrelated attachment is added during lookup", async () => { vi.useFakeTimers(); const lookup = deferred(); - const client: ForgeSearchClient = { + const client: TestSearchClient = { searchForge: vi.fn().mockReturnValue(lookup.promise), }; const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() }); @@ -578,7 +586,7 @@ describe("useComposerGithubAutoAttach", () => { it("stops resolving when an in-flight PR URL is removed", async () => { vi.useFakeTimers(); const lookup = deferred(); - const client: ForgeSearchClient = { + const client: TestSearchClient = { searchForge: vi.fn().mockReturnValue(lookup.promise), }; const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() }); @@ -598,7 +606,7 @@ describe("useComposerGithubAutoAttach", () => { it("ignores a lookup that finishes after the target changes", async () => { vi.useFakeTimers(); const lookup = deferred(); - const client: ForgeSearchClient = { + const client: TestSearchClient = { searchForge: vi.fn().mockReturnValue(lookup.promise), }; const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() }); @@ -625,7 +633,7 @@ describe("useComposerGithubAutoAttach", () => { it("accepts a lookup after the transport client is replaced for the same target", async () => { vi.useFakeTimers(); const lookup = deferred(); - const firstClient: ForgeSearchClient = { + const firstClient: TestSearchClient = { searchForge: vi.fn().mockReturnValue(lookup.promise), }; const replacementClient = createSearchClient([pr101]); diff --git a/packages/app/src/composer/github/auto-attach.ts b/packages/app/src/composer/github/auto-attach.ts index 5cb19e47f2..6b1d6f5706 100644 --- a/packages/app/src/composer/github/auto-attach.ts +++ b/packages/app/src/composer/github/auto-attach.ts @@ -10,7 +10,12 @@ import { type SetStateAction, } from "react"; import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types"; -import { buildForgeSearchQueryOptions, type ForgeSearchClient } from "@/git/use-forge-search-query"; +import { + buildForgeSearchQueryOptions, + forgeSearchClientIdentity, + type ForgeSearchClient, +} from "@/git/use-forge-search-query"; +import { workspaceGitQueryIdentitiesEqual } from "@/git/workspace-git"; import { extractGithubRefs, type GithubRef } from "@/utils/github-refs"; import type { ForgeSearchItem } from "@getpaseo/protocol/messages"; import { isAttachmentSelectedForGithubItem, toggleGithubAttachment } from "../actions"; @@ -24,7 +29,6 @@ interface ComposerGithubAutoAttachInput { client: ForgeSearchClient | null; isConnected: boolean; serverId: string; - cwd: string; supportsForgeSearch?: boolean; setAttachments: Dispatch>; onPullRequestDetected?: () => void; @@ -50,7 +54,7 @@ export function useComposerGithubAutoAttach( const removedRefKeysRef = useRef(new Set()); const presentPullRequestKeysRef = useRef(new Set()); const activeLookupsRef = useRef(new Set()); - const previousTargetRef = useRef({ serverId: params.serverId, cwd: params.cwd }); + const previousTargetRef = useRef({ serverId: params.serverId, client: params.client }); const [resolvingRefCounts, setResolvingRefCounts] = useState>( () => new Map(), ); @@ -79,7 +83,7 @@ export function useComposerGithubAutoAttach( hasClient, params.isConnected, params.serverId, - params.cwd, + params.client, ]); useEffect(() => { @@ -143,7 +147,7 @@ export function useComposerGithubAutoAttach( hasClient, params.isConnected, params.serverId, - params.cwd, + params.client, queryClient, ]); @@ -236,15 +240,25 @@ function suppressRefsCarriedAcrossTargets({ removedRefKeys, }: { params: ComposerGithubAutoAttachInput; - previousTargetRef: RefObject<{ serverId: string; cwd: string }>; + previousTargetRef: RefObject<{ + serverId: string; + client: ForgeSearchClient | null; + }>; removedRefKeys: Set; }): void { const previous = previousTargetRef.current; const targetChanged = - previous.cwd.trim().length > 0 && - params.cwd.trim().length > 0 && - (previous.serverId !== params.serverId || previous.cwd !== params.cwd); - previousTargetRef.current = { serverId: params.serverId, cwd: params.cwd }; + previous.client !== null && + params.client !== null && + previous.client.cwd.trim().length > 0 && + params.client.cwd.trim().length > 0 && + (previous.serverId !== params.serverId || + !workspaceGitQueryIdentitiesEqual( + forgeSearchClientIdentity(previous.client), + forgeSearchClientIdentity(params.client), + ) || + previous.client.cwd !== params.client.cwd); + previousTargetRef.current = { serverId: params.serverId, client: params.client }; if (!targetChanged) return; for (const ref of extractGithubRefs(params.text, params.remoteUrl)) { @@ -444,7 +458,7 @@ function refsReadyForLookup({ removedRefKeys: Set; activeLookups: ReadonlySet; }): GithubRef[] { - if (!params.client || !params.isConnected || params.cwd.trim().length === 0) { + if (!params.client || !params.isConnected || params.client.cwd.trim().length === 0) { return []; } @@ -476,7 +490,6 @@ async function fetchGithubRefSearch({ buildForgeSearchQueryOptions({ client: snapshot.client, serverId: snapshot.serverId, - cwd: snapshot.cwd, query: String(ref.number), supportsForgeSearch: snapshot.supportsForgeSearch, enabled: true, @@ -499,7 +512,13 @@ function isSameLookupTarget( ): boolean { return ( initial.serverId === current.serverId && - initial.cwd === current.cwd && + (initial.client === null || current.client === null + ? initial.client === current.client + : workspaceGitQueryIdentitiesEqual( + forgeSearchClientIdentity(initial.client), + forgeSearchClientIdentity(current.client), + )) && + initial.client?.cwd === current.client?.cwd && initial.remoteUrl === current.remoteUrl ); } diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 02e8d6d1be..ea706565b4 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -121,6 +121,7 @@ import { useIsDictationReady } from "@/hooks/use-is-dictation-ready"; import { useForgeSearchQuery } from "@/git/use-forge-search-query"; import { useCheckoutStatusQuery } from "@/git/use-status-query"; import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; +import { useWorkspaceGit } from "@/git/workspace-git"; import { getForgePresentation } from "@/git/forge"; import { ForgeBrandIcon } from "@/git/forge-icon"; import { useComposerGithubAutoAttach } from "./github/auto-attach"; @@ -1131,7 +1132,8 @@ export function Composer({ onOpenWorkspaceAttachment, }); const setSelectedAttachments = onChangeAttachments; - const checkoutStatusQuery = useCheckoutStatusQuery({ serverId, cwd }); + const checkoutStatusQuery = useCheckoutStatusQuery({ serverId }); + const workspaceGitClient = useWorkspaceGit(); const supportsForgeSearch = useSessionStore( (state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true, ); @@ -1139,10 +1141,9 @@ export function Composer({ text: userInput, remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status), attachments, - client, + client: workspaceGitClient, isConnected, serverId, - cwd, supportsForgeSearch, setAttachments: setSelectedAttachments, onPullRequestDetected: onGithubPrDetected, @@ -1856,16 +1857,14 @@ export function Composer({ // until the forge-specific picker or attachment presentation is visible. const { forge } = useCheckoutPrStatusQuery({ serverId, - cwd, enabled: isConnected && cwd.trim().length > 0 && (isGithubPickerOpen || hasGithubAttachment), }); const forgePresentation = useMemo(() => getForgePresentation(forge), [forge]); const githubSearchQueryTrimmed = githubSearchQuery.trim(); const githubSearchResultsQuery = useForgeSearchQuery({ - client, + client: workspaceGitClient, serverId, - cwd, query: githubSearchQueryTrimmed, supportsForgeSearch, enabled: resolveGithubSearchEnabled(isGithubPickerOpen, isConnected, cwd), diff --git a/packages/app/src/data/push-router.test.ts b/packages/app/src/data/push-router.test.ts index f468a3f928..1e5eedd263 100644 --- a/packages/app/src/data/push-router.test.ts +++ b/packages/app/src/data/push-router.test.ts @@ -12,6 +12,7 @@ import { mountServerDataPushRouter, workspaceTerminalsPushRoute, } from "@/data/push-router"; +import type { WorkspaceGitClient } from "@/git/workspace-git"; type ProvidersSnapshotUpdateMessage = Extract< SessionOutboundMessage, @@ -56,6 +57,7 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {} unsubscribeCheckoutDiffCalls: string[]; subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>; unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>; + workspaceGit: (target: { workspaceId: string; cwd: string }) => WorkspaceGitClient; } { const handlers: Record = { providers_snapshot_update: [], @@ -94,29 +96,35 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {} } } + function workspaceGit(target: { workspaceId: string; cwd: string }): WorkspaceGitClient { + return { + ...target, + queryIdentity: ["selected", target.workspaceId], + async subscribeDiff( + compare: Parameters[0], + requestOptions: Parameters[1], + ) { + const subscriptionId = requestOptions?.subscriptionId ?? "subscription"; + subscribeCheckoutDiffCalls.push({ cwd: target.cwd, compare, subscriptionId }); + if (config.rejectCheckoutDiffSubscribe) throw new Error("subscribe failed"); + return { + workspaceId: target.workspaceId, + subscriptionId, + cwd: target.cwd, + files: [], + error: null, + requestId: requestOptions?.requestId ?? "subscribe-checkout-diff", + }; + }, + unsubscribeDiff(subscriptionId: string) { + unsubscribeCheckoutDiffCalls.push(subscriptionId); + }, + } as unknown as WorkspaceGitClient; + } + return { client: { on, - async subscribeCheckoutDiff(cwd, compare, requestOptions) { - subscribeCheckoutDiffCalls.push({ - cwd, - compare, - subscriptionId: requestOptions.subscriptionId, - }); - if (config.rejectCheckoutDiffSubscribe) { - throw new Error("subscribe failed"); - } - return { - subscriptionId: requestOptions.subscriptionId, - cwd, - files: [], - error: null, - requestId: requestOptions.requestId ?? "subscribe-checkout-diff", - }; - }, - unsubscribeCheckoutDiff(subscriptionId) { - unsubscribeCheckoutDiffCalls.push(subscriptionId); - }, subscribeTerminals(subscription) { subscribeTerminalCalls.push(subscription); }, @@ -129,6 +137,7 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {} unsubscribeCheckoutDiffCalls, subscribeTerminalCalls, unsubscribeTerminalCalls, + workspaceGit, }; } @@ -180,7 +189,8 @@ describe("server data push router", () => { const fake = createFakeClient(); const serverId = "server-1"; const cwd = "/repo"; - const queryKey = checkoutDiffQueryKey(serverId, cwd, "base", "main", true); + const target = fake.workspaceGit({ workspaceId: "workspace-a", cwd }); + const queryKey = checkoutDiffQueryKey(serverId, target, "base", "main", true); const subscriptionId = `checkoutDiff:${JSON.stringify(queryKey)}`; const observer = new QueryObserver(queryClient, { queryKey, @@ -192,7 +202,7 @@ describe("server data push router", () => { enabled: true, serverId, subscriptionId, - cwd, + workspaceGit: target, compare: { mode: "base", baseRef: "main", ignoreWhitespace: true }, }), }); @@ -252,7 +262,8 @@ describe("server data push router", () => { const fake = createFakeClient({ rejectCheckoutDiffSubscribe: true }); const serverId = "server-1"; const cwd = "/repo"; - const queryKey = checkoutDiffQueryKey(serverId, cwd, "base", "main", true); + const target = fake.workspaceGit({ workspaceId: "workspace-a", cwd }); + const queryKey = checkoutDiffQueryKey(serverId, target, "base", "main", true); const subscriptionId = `checkoutDiff:${JSON.stringify(queryKey)}`; const observer = new QueryObserver(queryClient, { queryKey, @@ -264,7 +275,7 @@ describe("server data push router", () => { enabled: true, serverId, subscriptionId, - cwd, + workspaceGit: target, compare: { mode: "base", baseRef: "main", ignoreWhitespace: true }, }), }); @@ -339,7 +350,8 @@ describe("server data push router", () => { const serverId = "server-1"; const cwd = "/repo"; const workspaceId = "workspace-a"; - const checkoutDiffKey = checkoutDiffQueryKey(serverId, cwd, "base", "main", true); + const target = fake.workspaceGit({ workspaceId, cwd }); + const checkoutDiffKey = checkoutDiffQueryKey(serverId, target, "base", "main", true); const checkoutDiffSubscriptionId = `checkoutDiff:${JSON.stringify(checkoutDiffKey)}`; const terminalKey = buildTerminalsQueryKey(serverId, cwd, workspaceId); const checkoutDiffObserver = new QueryObserver(queryClient, { @@ -352,7 +364,7 @@ describe("server data push router", () => { enabled: true, serverId, subscriptionId: checkoutDiffSubscriptionId, - cwd, + workspaceGit: target, compare: { mode: "base", baseRef: "main", ignoreWhitespace: true }, }), }); @@ -508,7 +520,17 @@ describe("server data push router", () => { const providerKey = providersSnapshotQueryKey(serverId); const daemonConfigKey = daemonConfigQueryKey(serverId); const pairingOfferKey = daemonPairingOfferQueryKey(serverId); - const diffKey = checkoutDiffQueryKey(serverId, "/repo", "uncommitted", undefined, false); + const diffKey = checkoutDiffQueryKey( + serverId, + { + workspaceId: "workspace-a", + cwd: "/repo", + queryIdentity: ["selected", "workspace-a"], + }, + "uncommitted", + undefined, + false, + ); const terminalKey = buildTerminalsQueryKey(serverId, "/repo", "workspace-a"); const otherProviderKey = providersSnapshotQueryKey(otherServerId); diff --git a/packages/app/src/data/push-router.ts b/packages/app/src/data/push-router.ts index 0af2e8e684..d3fb4b49cf 100644 --- a/packages/app/src/data/push-router.ts +++ b/packages/app/src/data/push-router.ts @@ -14,6 +14,7 @@ import { providersSnapshotQueryKey, providersSnapshotQueryRoot, } from "@/data/providers-snapshot"; +import type { WorkspaceGitClient } from "@/git/workspace-git"; type ProvidersSnapshotUpdateMessage = Extract< SessionOutboundMessage, @@ -47,7 +48,7 @@ interface CheckoutDiffRoute { enabled: boolean; serverId: string; subscriptionId: string; - cwd: string; + workspaceGit: WorkspaceGitClient; compare: CheckoutDiffCompare; } @@ -72,12 +73,6 @@ interface ServerDataPushClient { type: TType, handler: (message: Extract) => void, ): () => void; - subscribeCheckoutDiff( - cwd: string, - compare: CheckoutDiffCompare, - options: { subscriptionId: string; requestId?: string }, - ): Promise; - unsubscribeCheckoutDiff(subscriptionId: string): void; subscribeTerminals(input: { cwd: string; workspaceId?: string }): void; unsubscribeTerminals(input: { cwd: string; workspaceId?: string }): void; } @@ -140,7 +135,7 @@ export function checkoutDiffPushRoute(input: { enabled: boolean; serverId: string; subscriptionId: string; - cwd: string; + workspaceGit: WorkspaceGitClient; compare: CheckoutDiffCompare; }): ServerDataQueryMeta { return { @@ -149,7 +144,7 @@ export function checkoutDiffPushRoute(input: { enabled: input.enabled, serverId: input.serverId, subscriptionId: input.subscriptionId, - cwd: input.cwd, + workspaceGit: input.workspaceGit, compare: input.compare, }, }; @@ -341,8 +336,8 @@ export function mountServerDataPushRouter(input: PushRouterInput): () => void { unsubscribeCheckoutDiffUpdate(); unsubscribeCheckoutDiffResponse(); unsubscribeTerminalsChanged(); - for (const subscriptionId of activeCheckoutDiffSubscriptions.keys()) { - unsubscribeCheckoutDiff(input.client, subscriptionId); + for (const route of activeCheckoutDiffSubscriptions.values()) { + unsubscribeCheckoutDiff(route); } activeCheckoutDiffSubscriptions.clear(); for (const route of activeTerminalSubscriptions.values()) { @@ -363,7 +358,7 @@ function reconcileCheckoutDiffSubscriptions(input: { if (desired && areCheckoutDiffRoutesEqual(current, desired)) { continue; } - unsubscribeCheckoutDiff(input.client, subscriptionId); + unsubscribeCheckoutDiff(current); input.active.delete(subscriptionId); } @@ -372,8 +367,8 @@ function reconcileCheckoutDiffSubscriptions(input: { continue; } input.active.set(subscriptionId, desired); - void input.client - .subscribeCheckoutDiff(desired.cwd, desired.compare, { + void desired.workspaceGit + .subscribeDiff(desired.compare, { subscriptionId, requestId: `push-router:${input.serverId}:${subscriptionId}`, }) @@ -383,7 +378,7 @@ function reconcileCheckoutDiffSubscriptions(input: { } console.error("[server-data] subscribeCheckoutDiff failed", { serverId: input.serverId, - cwd: desired.cwd, + cwd: desired.workspaceGit.cwd, error, }); }); @@ -652,15 +647,15 @@ function readServerDataRoute(value: Record): ServerDataRoute | const domain = value.domain; const enabled = value.enabled; const serverId = value.serverId; - const cwd = value.cwd; - if (typeof enabled !== "boolean" || typeof serverId !== "string" || typeof cwd !== "string") { + if (typeof enabled !== "boolean" || typeof serverId !== "string") { return null; } if (domain === "checkoutDiff") { const subscriptionId = value.subscriptionId; const compare = readCheckoutDiffCompare(value.compare); - if (typeof subscriptionId !== "string" || !compare) { + const workspaceGit = readWorkspaceGitClient(value.workspaceGit); + if (typeof subscriptionId !== "string" || !compare || !workspaceGit) { return null; } return { @@ -668,12 +663,14 @@ function readServerDataRoute(value: Record): ServerDataRoute | enabled, serverId, subscriptionId, - cwd, + workspaceGit, compare, }; } if (domain === "workspaceTerminals") { + const cwd = value.cwd; + if (typeof cwd !== "string") return null; const workspaceId = value.workspaceId; if (workspaceId !== undefined && typeof workspaceId !== "string") { return null; @@ -720,7 +717,7 @@ function areCheckoutDiffRoutesEqual( return ( left?.serverId === right.serverId && left.subscriptionId === right.subscriptionId && - left.cwd === right.cwd && + left.workspaceGit === right.workspaceGit && left.compare.mode === right.compare.mode && left.compare.baseRef === right.compare.baseRef && left.compare.ignoreWhitespace === right.compare.ignoreWhitespace @@ -731,10 +728,13 @@ function isCheckoutDiffQueryKeyForRoute(queryKey: QueryKey, route: CheckoutDiffR return ( queryKey[0] === "checkoutDiff" && queryKey[1] === route.serverId && - queryKey[2] === route.cwd && - queryKey[3] === route.compare.mode && - queryKey[4] === (route.compare.baseRef ?? "") && - queryKey[5] === (route.compare.ignoreWhitespace === true) + Array.isArray(queryKey[2]) && + queryKey[2][0] === route.workspaceGit.queryIdentity[0] && + queryKey[2][1] === route.workspaceGit.queryIdentity[1] && + queryKey[3] === route.workspaceGit.cwd && + queryKey[4] === route.compare.mode && + queryKey[5] === (route.compare.baseRef ?? "") && + queryKey[6] === (route.compare.ignoreWhitespace === true) ); } @@ -763,14 +763,25 @@ function workspaceTerminalSubscriptionInput(route: WorkspaceTerminalsRoute): { }; } -function unsubscribeCheckoutDiff(client: ServerDataPushClient, subscriptionId: string): void { +function unsubscribeCheckoutDiff(route: CheckoutDiffRoute): void { try { - client.unsubscribeCheckoutDiff(subscriptionId); + route.workspaceGit.unsubscribeDiff(route.subscriptionId); } catch { // Disconnect cleanup can race with explicit subscription teardown. } } +function readWorkspaceGitClient(value: unknown): WorkspaceGitClient | null { + if (!isRecord(value)) return null; + return typeof value.workspaceId === "string" && + value.workspaceId.length > 0 && + typeof value.cwd === "string" && + typeof value.subscribeDiff === "function" && + typeof value.unsubscribeDiff === "function" + ? (value as unknown as WorkspaceGitClient) + : null; +} + function isQueryForServer(queryKey: QueryKey, kind: string, serverId: string): boolean { return queryKey.length >= 2 && queryKey[0] === kind && queryKey[1] === serverId; } diff --git a/packages/app/src/git/actions-store.test.ts b/packages/app/src/git/actions-store.test.ts index 75a3977758..dd25ddf56b 100644 --- a/packages/app/src/git/actions-store.test.ts +++ b/packages/app/src/git/actions-store.test.ts @@ -1,34 +1,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { queryClient as appQueryClient } from "@/data/query-client"; import { useSessionStore } from "@/stores/session-store"; import { __resetCheckoutGitActionsStoreForTests, useCheckoutGitActionsStore, } from "@/git/actions-store"; +import type { WorkspaceGitClient } from "@/git/workspace-git"; vi.mock("@react-native-async-storage/async-storage", () => ({ - default: { - getItem: vi.fn(async () => null), - setItem: vi.fn(async () => undefined), - removeItem: vi.fn(async () => undefined), - }, + default: { getItem: vi.fn(async () => null), setItem: vi.fn(), removeItem: vi.fn() }, })); -function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; +const serverId = "server-1"; +function capability(methods: Record): WorkspaceGitClient { + return { + workspaceId: "workspace-1", + cwd: "/tmp/repo/worktrees/feature", + ...methods, + } as unknown as WorkspaceGitClient; } describe("checkout-git-actions-store", () => { - const serverId = "server-1"; - const cwd = "/tmp/repo/worktrees/feature"; - beforeEach(() => { vi.useFakeTimers(); __resetCheckoutGitActionsStoreForTests(); @@ -40,242 +32,67 @@ describe("checkout-git-actions-store", () => { vi.useRealTimers(); __resetCheckoutGitActionsStoreForTests(); appQueryClient.clear(); - useSessionStore.setState((state) => ({ ...state, sessions: {} })); }); - it("shares pending state per checkout and de-dupes in-flight calls", async () => { - const deferred = createDeferred(); - const client = { - checkoutCommit: vi.fn(() => deferred.promise), - }; - - useSessionStore.setState((state) => ({ - ...state, - sessions: { - ...state.sessions, - [serverId]: { client } as unknown as (typeof state.sessions)[string], - }, - })); - + it("binds once to workspace identity and de-duplicates an in-flight commit", async () => { + let resolve!: (value: { error: null }) => void; + const commit = vi.fn(() => new Promise<{ error: null }>((done) => (resolve = done))); + const target = capability({ commit }); const store = useCheckoutGitActionsStore.getState(); - const first = store.commit({ serverId, cwd }); - const second = store.commit({ serverId, cwd }); - - expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("pending"); - - deferred.resolve({}); + const first = store.commit({ serverId, target }); + const second = store.commit({ serverId, target }); + expect(store.getStatus({ serverId, target, actionId: "commit" })).toBe("pending"); + resolve({ error: null }); await Promise.all([first, second]); - expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("success"); - - vi.advanceTimersByTime(1000); - expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("idle"); + expect(commit).toHaveBeenCalledTimes(1); + expect(store.getStatus({ serverId, target, actionId: "commit" })).toBe("success"); }); - it("runs pull then push sequentially for pull-and-push", async () => { + it("runs pull then push sequentially through the same bound capability", async () => { const order: string[] = []; - const client = { - checkoutPull: vi.fn(async () => { + const target = capability({ + pull: vi.fn(async () => { order.push("pull"); - return {}; + return { error: null }; }), - checkoutPush: vi.fn(async () => { + push: vi.fn(async () => { order.push("push"); - return {}; + return { error: null }; }), - }; - useSessionStore.setState((state) => ({ - ...state, - sessions: { - ...state.sessions, - [serverId]: { client } as unknown as (typeof state.sessions)[string], - }, - })); - - await useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd }); - + }); + await useCheckoutGitActionsStore.getState().pullAndPush({ serverId, target }); expect(order).toEqual(["pull", "push"]); - expect( - useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "pull-and-push" }), - ).toBe("success"); }); - it("does not push when pull fails for pull-and-push", async () => { - const client = { - checkoutPull: vi.fn(async () => ({ error: { message: "pull conflict" } })), - checkoutPush: vi.fn(async () => ({})), - }; - useSessionStore.setState((state) => ({ - ...state, - sessions: { - ...state.sessions, - [serverId]: { client } as unknown as (typeof state.sessions)[string], - }, - })); - - await expect( - useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd }), - ).rejects.toThrow("pull conflict"); - expect( - useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "pull-and-push" }), - ).toBe("idle"); - }); - - it("surfaces push errors from pull-and-push after a successful pull", async () => { - const client = { - checkoutPull: vi.fn(async () => ({})), - checkoutPush: vi.fn(async () => ({ error: { message: "push rejected" } })), - }; - useSessionStore.setState((state) => ({ - ...state, - sessions: { - ...state.sessions, - [serverId]: { client } as unknown as (typeof state.sessions)[string], - }, - })); - - await expect( - useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd }), - ).rejects.toThrow("push rejected"); - expect( - useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "pull-and-push" }), - ).toBe("idle"); - }); - - it("refreshes git and GitHub state and reports success", async () => { - const client = { - checkoutRefresh: vi.fn(async () => ({ success: true, error: null })), - }; - useSessionStore.setState((state) => ({ - ...state, - sessions: { - ...state.sessions, - [serverId]: { client } as unknown as (typeof state.sessions)[string], - }, - })); - - await useCheckoutGitActionsStore.getState().refresh({ serverId, cwd }); - - expect(client.checkoutRefresh).toHaveBeenCalledWith(cwd); - expect( - useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "refresh" }), - ).toBe("success"); - }); - - it("surfaces a refresh error and returns to idle", async () => { - const client = { - checkoutRefresh: vi.fn(async () => ({ error: { message: "not a git repository" } })), - }; - useSessionStore.setState((state) => ({ - ...state, - sessions: { - ...state.sessions, - [serverId]: { client } as unknown as (typeof state.sessions)[string], - }, - })); - - await expect(useCheckoutGitActionsStore.getState().refresh({ serverId, cwd })).rejects.toThrow( - "not a git repository", - ); - expect( - useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "refresh" }), - ).toBe("idle"); - }); - - for (const rpc of [ - { - label: "forge", - method: "checkoutForgeSetAutoMerge", - feature: "checkoutForgeSetAutoMerge", - }, - { - label: "legacy GitHub", - method: "checkoutGithubSetAutoMerge", - feature: "checkoutGithubSetAutoMerge", - }, - ] as const) { - it(`enables PR auto-merge through the ${rpc.label} RPC`, async () => { - const setAutoMerge = vi.fn(async () => ({ - enabled: true, - success: true, - error: null, - })); - const client = { [rpc.method]: setAutoMerge }; - useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient); - useSessionStore.getState().updateSessionServerInfo(serverId, { - serverId, - hostname: null, - version: null, - features: { [rpc.feature]: true }, - }); - - await useCheckoutGitActionsStore - .getState() - .enablePrAutoMerge({ serverId, cwd, method: "squash" }); - - expect(setAutoMerge).toHaveBeenCalledWith(cwd, { - enabled: true, - method: "squash", - }); - expect( - useCheckoutGitActionsStore - .getState() - .getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-squash" }), - ).toBe("success"); + it("returns to idle when a bound mutation reports an error", async () => { + const target = capability({ + refresh: vi.fn(async () => ({ error: { message: "not git" } })), }); + await expect( + useCheckoutGitActionsStore.getState().refresh({ serverId, target }), + ).rejects.toThrow("not git"); + expect( + useCheckoutGitActionsStore.getState().getStatus({ serverId, target, actionId: "refresh" }), + ).toBe("idle"); + }); - it(`disables PR auto-merge through the ${rpc.label} RPC`, async () => { - const setAutoMerge = vi.fn(async () => ({ - enabled: false, - success: true, - error: null, - })); - const client = { [rpc.method]: setAutoMerge }; - useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient); - useSessionStore.getState().updateSessionServerInfo(serverId, { - serverId, - hostname: null, - version: null, - features: { [rpc.feature]: true }, - }); - - await useCheckoutGitActionsStore.getState().disablePrAutoMerge({ serverId, cwd }); - - expect(setAutoMerge).toHaveBeenCalledWith(cwd, { enabled: false }); - expect( - useCheckoutGitActionsStore - .getState() - .getStatus({ serverId, cwd, actionId: "disable-pr-auto-merge" }), - ).toBe("success"); - }); - } - - it("does not call PR auto-merge RPCs when the daemon lacks the feature flag", async () => { - const client = { - checkoutForgeSetAutoMerge: vi.fn(async () => ({ - enabled: true, - success: true, - error: null, - })), - }; - useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient); + it("fails auto-merge before invoking Git when the daemon lacks the feature", async () => { + const setForgeAutoMerge = vi.fn(); + const target = capability({ setForgeAutoMerge }); + useSessionStore.getState().initializeSession(serverId, {} as never); useSessionStore.getState().updateSessionServerInfo(serverId, { serverId, hostname: null, version: null, features: {}, }); - await expect( - useCheckoutGitActionsStore.getState().enablePrAutoMerge({ serverId, cwd, method: "merge" }), - ).rejects.toThrow("Update the host to use auto-merge actions."); - - expect(client.checkoutForgeSetAutoMerge).not.toHaveBeenCalled(); - expect( useCheckoutGitActionsStore .getState() - .getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-merge" }), - ).toBe("idle"); + .enablePrAutoMerge({ serverId, target, method: "merge" }), + ).rejects.toThrow("Update the host"); + expect(setForgeAutoMerge).not.toHaveBeenCalled(); }); }); diff --git a/packages/app/src/git/actions-store.ts b/packages/app/src/git/actions-store.ts index 34e7b54430..b8f2fa7704 100644 --- a/packages/app/src/git/actions-store.ts +++ b/packages/app/src/git/actions-store.ts @@ -3,7 +3,9 @@ import { create } from "zustand"; import { queryClient as appQueryClient } from "@/data/query-client"; import { useSessionStore } from "@/stores/session-store"; import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys"; -import { i18n } from "@/i18n/i18next"; +import type { WorkspaceGitClient } from "@/git/workspace-git"; + +type WorkspaceGitTarget = WorkspaceGitClient; const SUCCESS_DISPLAY_MS = 1000; @@ -29,17 +31,8 @@ export type CheckoutGitAsyncActionId = type CheckoutKey = string; type StatusMap = Partial>; -function checkoutKey(serverId: string, cwd: string): CheckoutKey { - return `${serverId}::${cwd}`; -} - -function resolveClient(serverId: string) { - const session = useSessionStore.getState().sessions[serverId]; - const client = session?.client ?? null; - if (!client) { - throw new Error(i18n.t("common.errors.daemonClientUnavailable")); - } - return client; +function checkoutKey(serverId: string, target: WorkspaceGitTarget): CheckoutKey { + return `${serverId}::${target.workspaceId}::${target.cwd}`; } type AutoMergeActionsRpc = "forge" | "github"; @@ -80,8 +73,8 @@ function setStatus( }); } -function invalidateCheckoutGitQueries(serverId: string, cwd: string) { - return invalidateCheckoutGitQueriesForClient(appQueryClient, { serverId, cwd }); +function invalidateCheckoutGitQueries(serverId: string, target: WorkspaceGitTarget) { + return invalidateCheckoutGitQueriesForClient(appQueryClient, { serverId, target }); } const successTimers = new Map>(); @@ -96,43 +89,51 @@ interface CheckoutGitActionsStoreState { getStatus: (params: { serverId: string; - cwd: string; + target: WorkspaceGitTarget; actionId: CheckoutGitAsyncActionId; }) => CheckoutGitActionStatus; - commit: (params: { serverId: string; cwd: string }) => Promise; - pull: (params: { serverId: string; cwd: string }) => Promise; - push: (params: { serverId: string; cwd: string }) => Promise; - pullAndPush: (params: { serverId: string; cwd: string }) => Promise; - refresh: (params: { serverId: string; cwd: string }) => Promise; - createPr: (params: { serverId: string; cwd: string }) => Promise; + commit: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise; + pull: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise; + push: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise; + pullAndPush: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise; + refresh: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise; + createPr: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise; mergePr: (params: { serverId: string; - cwd: string; + target: WorkspaceGitTarget; method: CheckoutPrMergeMethod; }) => Promise; enablePrAutoMerge: (params: { serverId: string; - cwd: string; + target: WorkspaceGitTarget; method: CheckoutPrMergeMethod; }) => Promise; - disablePrAutoMerge: (params: { serverId: string; cwd: string }) => Promise; - mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise; - mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise; + disablePrAutoMerge: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise; + mergeBranch: (params: { + serverId: string; + target: WorkspaceGitTarget; + baseRef: string; + }) => Promise; + mergeFromBase: (params: { + serverId: string; + target: WorkspaceGitTarget; + baseRef: string; + }) => Promise; } async function runCheckoutAction({ serverId, - cwd, + target, actionId, run, }: { serverId: string; - cwd: string; + target: WorkspaceGitTarget; actionId: CheckoutGitAsyncActionId; run: () => Promise; }): Promise { - const key = checkoutKey(serverId, cwd); + const key = checkoutKey(serverId, target); const inflightId = inFlightKey(key, actionId); const existing = inFlight.get(inflightId); @@ -152,7 +153,7 @@ async function runCheckoutAction({ const promise = (async () => { try { await run(); - await invalidateCheckoutGitQueries(serverId, cwd); + await invalidateCheckoutGitQueries(serverId, target); setStatus(key, actionId, "success"); const timer = setTimeout(() => { setStatus(key, actionId, "idle"); @@ -174,19 +175,18 @@ async function runCheckoutAction({ export const useCheckoutGitActionsStore = create()((set, get) => ({ statusByCheckout: {}, - getStatus: ({ serverId, cwd, actionId }) => { - const key = checkoutKey(serverId, cwd); + getStatus: ({ serverId, target, actionId }) => { + const key = checkoutKey(serverId, target); return get().statusByCheckout[key]?.[actionId] ?? "idle"; }, - commit: async ({ serverId, cwd }) => { + commit: async ({ serverId, target }) => { await runCheckoutAction({ serverId, - cwd, + target, actionId: "commit", run: async () => { - const client = resolveClient(serverId); - const payload = await client.checkoutCommit(cwd, { addAll: true }); + const payload = await target.commit({ addAll: true }); if (payload.error) { throw new Error(payload.error.message); } @@ -194,14 +194,13 @@ export const useCheckoutGitActionsStore = create() }); }, - pull: async ({ serverId, cwd }) => { + pull: async ({ serverId, target }) => { await runCheckoutAction({ serverId, - cwd, + target, actionId: "pull", run: async () => { - const client = resolveClient(serverId); - const payload = await client.checkoutPull(cwd); + const payload = await target.pull(); if (payload.error) { throw new Error(payload.error.message); } @@ -209,14 +208,13 @@ export const useCheckoutGitActionsStore = create() }); }, - push: async ({ serverId, cwd }) => { + push: async ({ serverId, target }) => { await runCheckoutAction({ serverId, - cwd, + target, actionId: "push", run: async () => { - const client = resolveClient(serverId); - const payload = await client.checkoutPush(cwd); + const payload = await target.push(); if (payload.error) { throw new Error(payload.error.message); } @@ -224,14 +222,13 @@ export const useCheckoutGitActionsStore = create() }); }, - refresh: async ({ serverId, cwd }) => { + refresh: async ({ serverId, target }) => { await runCheckoutAction({ serverId, - cwd, + target, actionId: "refresh", run: async () => { - const client = resolveClient(serverId); - const payload = await client.checkoutRefresh(cwd); + const payload = await target.refresh(); if (payload.error) { throw new Error(payload.error.message); } @@ -239,18 +236,17 @@ export const useCheckoutGitActionsStore = create() }); }, - pullAndPush: async ({ serverId, cwd }) => { + pullAndPush: async ({ serverId, target }) => { await runCheckoutAction({ serverId, - cwd, + target, actionId: "pull-and-push", run: async () => { - const client = resolveClient(serverId); - const pullPayload = await client.checkoutPull(cwd); + const pullPayload = await target.pull(); if (pullPayload.error) { throw new Error(pullPayload.error.message); } - const pushPayload = await client.checkoutPush(cwd); + const pushPayload = await target.push(); if (pushPayload.error) { throw new Error(pushPayload.error.message); } @@ -258,14 +254,13 @@ export const useCheckoutGitActionsStore = create() }); }, - createPr: async ({ serverId, cwd }) => { + createPr: async ({ serverId, target }) => { await runCheckoutAction({ serverId, - cwd, + target, actionId: "create-pr", run: async () => { - const client = resolveClient(serverId); - const payload = await client.checkoutPrCreate(cwd, {}); + const payload = await target.createPr({}); if (payload.error) { throw new Error(payload.error.message); } @@ -273,14 +268,13 @@ export const useCheckoutGitActionsStore = create() }); }, - mergePr: async ({ serverId, cwd, method }) => { + mergePr: async ({ serverId, target, method }) => { await runCheckoutAction({ serverId, - cwd, + target, actionId: `merge-pr-${method}`, run: async () => { - const client = resolveClient(serverId); - const payload = await client.checkoutPrMerge(cwd, { method }); + const payload = await target.mergePr(method); if (payload.error) { throw new Error(payload.error.message); } @@ -288,20 +282,19 @@ export const useCheckoutGitActionsStore = create() }); }, - enablePrAutoMerge: async ({ serverId, cwd, method }) => { + enablePrAutoMerge: async ({ serverId, target, method }) => { const rpc = resolveAutoMergeActionsRpc(serverId); await runCheckoutAction({ serverId, - cwd, + target, actionId: `enable-pr-auto-merge-${method}`, run: async () => { - const client = resolveClient(serverId); // COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once // all supported clients use checkout.forge.set_auto_merge.*. const payload = rpc === "forge" - ? await client.checkoutForgeSetAutoMerge(cwd, { enabled: true, method }) - : await client.checkoutGithubSetAutoMerge(cwd, { enabled: true, method }); + ? await target.setForgeAutoMerge({ enabled: true, method }) + : await target.setGithubAutoMerge({ enabled: true, method }); if (payload.error) { throw new Error(payload.error.message); } @@ -309,20 +302,19 @@ export const useCheckoutGitActionsStore = create() }); }, - disablePrAutoMerge: async ({ serverId, cwd }) => { + disablePrAutoMerge: async ({ serverId, target }) => { const rpc = resolveAutoMergeActionsRpc(serverId); await runCheckoutAction({ serverId, - cwd, + target, actionId: "disable-pr-auto-merge", run: async () => { - const client = resolveClient(serverId); // COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once // all supported clients use checkout.forge.set_auto_merge.*. const payload = rpc === "forge" - ? await client.checkoutForgeSetAutoMerge(cwd, { enabled: false }) - : await client.checkoutGithubSetAutoMerge(cwd, { enabled: false }); + ? await target.setForgeAutoMerge({ enabled: false }) + : await target.setGithubAutoMerge({ enabled: false }); if (payload.error) { throw new Error(payload.error.message); } @@ -330,14 +322,13 @@ export const useCheckoutGitActionsStore = create() }); }, - mergeBranch: async ({ serverId, cwd, baseRef }) => { + mergeBranch: async ({ serverId, target, baseRef }) => { await runCheckoutAction({ serverId, - cwd, + target, actionId: "merge-branch", run: async () => { - const client = resolveClient(serverId); - const payload = await client.checkoutMerge(cwd, { + const payload = await target.merge({ baseRef, strategy: "merge", requireCleanTarget: true, @@ -349,14 +340,13 @@ export const useCheckoutGitActionsStore = create() }); }, - mergeFromBase: async ({ serverId, cwd, baseRef }) => { + mergeFromBase: async ({ serverId, target, baseRef }) => { await runCheckoutAction({ serverId, - cwd, + target, actionId: "merge-from-base", run: async () => { - const client = resolveClient(serverId); - const payload = await client.checkoutMergeFromBase(cwd, { + const payload = await target.mergeFromBase({ baseRef, requireCleanTarget: true, }); diff --git a/packages/app/src/git/branch-switcher-operations.test.ts b/packages/app/src/git/branch-switcher-operations.test.ts index 618550d625..86b039f061 100644 --- a/packages/app/src/git/branch-switcher-operations.test.ts +++ b/packages/app/src/git/branch-switcher-operations.test.ts @@ -1,54 +1,32 @@ import { describe, expect, it } from "vitest"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { WorkspaceGitClient } from "./workspace-git"; import { createBranchSwitcherOperations } from "./branch-switcher-operations"; function createRecordingClient() { - const cwds: string[] = []; + const calls: string[] = []; const client = { - getBranchSuggestions: async (options: { cwd: string; limit?: number }) => { - cwds.push(options.cwd); - return { branches: [], error: null }; - }, - stashList: async (cwd: string) => { - cwds.push(cwd); - return { entries: [] }; - }, - stashSave: async (cwd: string) => { - cwds.push(cwd); - return { error: null }; - }, - stashPop: async (cwd: string) => { - cwds.push(cwd); - return { error: null }; - }, - checkoutSwitchBranch: async (cwd: string) => { - cwds.push(cwd); - return { error: null }; - }, - } as unknown as DaemonClient; - return { client, cwds }; + workspaceId: "wks_3f9a2b1c", + cwd: "/Users/dev/project", + getBranchSuggestions: async () => (calls.push("suggest"), { branches: [], error: null }), + stashList: async () => (calls.push("list"), { entries: [] }), + stashSave: async () => (calls.push("save"), { error: null }), + stashPop: async () => (calls.push("pop"), { error: null }), + switchBranch: async () => (calls.push("switch"), { error: null }), + } as unknown as WorkspaceGitClient; + return { client, calls }; } describe("createBranchSwitcherOperations", () => { - it("sends the workspace directory as cwd to every git operation, never the workspace id", async () => { - const workspaceDirectory = "/Users/dev/project"; - const workspaceId = "wks_3f9a2b1c"; - const { client, cwds } = createRecordingClient(); + it("binds every operation to the selected workspace identity", async () => { + const { client, calls } = createRecordingClient(); - const operations = createBranchSwitcherOperations(client, workspaceDirectory); + const operations = createBranchSwitcherOperations(client); await operations.getBranchSuggestions(200); await operations.listPaseoStashes(); await operations.saveStash("main"); await operations.popStash(0); await operations.switchBranch("feature"); - expect(cwds).toEqual([ - workspaceDirectory, - workspaceDirectory, - workspaceDirectory, - workspaceDirectory, - workspaceDirectory, - ]); - expect(cwds).not.toContain(workspaceId); + expect(calls).toEqual(["suggest", "list", "save", "pop", "switch"]); }); }); diff --git a/packages/app/src/git/branch-switcher-operations.ts b/packages/app/src/git/branch-switcher-operations.ts index ad3ba21543..6daff00530 100644 --- a/packages/app/src/git/branch-switcher-operations.ts +++ b/packages/app/src/git/branch-switcher-operations.ts @@ -1,15 +1,15 @@ -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { WorkspaceGitClient } from "./workspace-git"; // Binds the branch switcher's git operations to a single workspace directory, so a // workspace id can never be passed where a cwd is expected. `cwd` is set once here; // callers choose the operation, never the directory. -export function createBranchSwitcherOperations(client: DaemonClient, cwd: string) { +export function createBranchSwitcherOperations(workspaceGit: WorkspaceGitClient) { return { - getBranchSuggestions: (limit: number) => client.getBranchSuggestions({ cwd, limit }), - listPaseoStashes: () => client.stashList(cwd, { paseoOnly: true }), - saveStash: (branch: string | undefined) => client.stashSave(cwd, { branch }), - popStash: (stashIndex: number) => client.stashPop(cwd, stashIndex), - switchBranch: (branch: string) => client.checkoutSwitchBranch(cwd, branch), + getBranchSuggestions: (limit: number) => workspaceGit.getBranchSuggestions({ limit }), + listPaseoStashes: () => workspaceGit.stashList({ paseoOnly: true }), + saveStash: (branch: string | undefined) => workspaceGit.stashSave({ branch }), + popStash: (stashIndex: number) => workspaceGit.stashPop(stashIndex), + switchBranch: (branch: string) => workspaceGit.switchBranch(branch), }; } diff --git a/packages/app/src/git/checkout-status-cache.test.ts b/packages/app/src/git/checkout-status-cache.test.ts index df8952749c..78980fe0d7 100644 --- a/packages/app/src/git/checkout-status-cache.test.ts +++ b/packages/app/src/git/checkout-status-cache.test.ts @@ -2,332 +2,129 @@ import { QueryClient } from "@tanstack/react-query"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { CheckoutStatusUpdate } from "@getpaseo/protocol/messages"; -import { - checkoutCommitsQueryKey, - checkoutPrStatusQueryKey, - checkoutStatusQueryKey, -} from "@/git/query-keys"; -import { - prPanePipelineQueryKey, - prPaneTimelineQueryKey, -} from "@/git/pull-request-panel/query-keys"; +import { checkoutCommitsQueryKey, checkoutStatusQueryKey } from "@/git/query-keys"; import { resetReviewDraftStore, useReviewDraftStore } from "@/review/store"; import { applyCheckoutStatusUpdateFromEvent, ensureCheckoutStatus, - type CheckoutPrStatusPayload, - type CheckoutStatusPayload, fetchCheckoutStatus, + type CheckoutStatusPayload, } from "./checkout-status-cache"; vi.mock("@react-native-async-storage/async-storage", () => ({ - default: { - getItem: vi.fn(async () => null), - setItem: vi.fn(async () => undefined), - removeItem: vi.fn(async () => undefined), - }, + default: { getItem: vi.fn(async () => null), setItem: vi.fn(), removeItem: vi.fn() }, })); const serverId = "server-1"; -const cwd = "/repo"; +const targetA = { + workspaceId: "workspace-a", + cwd: "/workspace", + queryIdentity: ["selected", "workspace-a"] as const, +}; +const targetB = { + workspaceId: "workspace-b", + cwd: "/workspace", + queryIdentity: ["selected", "workspace-b"] as const, +}; -function checkoutStatus(overrides: Partial = {}): CheckoutStatusPayload { +function status( + target: { workspaceId: string; cwd: string } = targetA, + branch = "main", + isDirty = false, +): CheckoutStatusPayload { return { - cwd, + workspaceId: target.workspaceId, + cwd: target.cwd, error: null, - requestId: "checkout-status-1", + requestId: `status-${target.workspaceId}`, isGit: true, isPaseoOwnedWorktree: false, - repoRoot: cwd, - currentBranch: "main", - isDirty: false, + repoRoot: target.cwd, + mainRepoRoot: null, + currentBranch: branch, + isDirty, 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 prStatus(overrides: Partial = {}): CheckoutPrStatusPayload { - return { - cwd, - status: { - forge: "github", - url: "https://github.com/getpaseo/paseo/pull/42", - title: "My PR", - state: "open", - baseRefName: "main", - headRefName: "feature", - isMerged: false, - isDraft: false, - mergeable: "MERGEABLE", - checks: [], - checksStatus: "success", - reviewDecision: null, - }, - githubFeaturesEnabled: true, - authState: "authenticated", - forge: "github", - error: null, - requestId: "pr-status-1", - ...overrides, + remoteUrl: null, }; } -function checkoutStatusUpdate( - payload: CheckoutStatusPayload, - extraPrStatus?: NonNullable, -): CheckoutStatusUpdate { - return { - type: "checkout_status_update", - payload: extraPrStatus ? { ...payload, prStatus: extraPrStatus } : payload, - }; +function update(payload: CheckoutStatusPayload): CheckoutStatusUpdate { + return { type: "checkout_status_update", payload }; } -function setDiffModeOverride(isDirtyAtSelection: boolean): void { - useReviewDraftStore.getState().setDiffModeOverride({ - scopeKey: "review:scope", - override: { serverId, cwd, mode: "base", isDirtyAtSelection }, - }); -} +beforeEach(resetReviewDraftStore); -function createQueryClient(): QueryClient { - return new QueryClient({ defaultOptions: { queries: { retry: false } } }); -} - -beforeEach(() => { - resetReviewDraftStore(); -}); - -describe("fetchCheckoutStatus", () => { - it("fetches from the client and returns the payload", async () => { - const fetched = checkoutStatus({ requestId: "fetch-1" }); - const client = { getCheckoutStatus: vi.fn(async () => fetched) }; - - const result = await fetchCheckoutStatus({ client, serverId, cwd }); - - expect(result).toEqual(fetched); - expect(client.getCheckoutStatus).toHaveBeenCalledExactlyOnceWith(cwd); +describe("selected checkout status cache", () => { + it("fetches through the bound workspace capability", async () => { + const getStatus = vi.fn(async () => status()); + await expect( + fetchCheckoutStatus({ client: { getStatus }, serverId, target: targetA }), + ).resolves.toEqual(status()); + expect(getStatus).toHaveBeenCalledWith(); }); - it("expires a manual diff-mode override when the fetched dirty state flipped", async () => { - setDiffModeOverride(true); - const client = { getCheckoutStatus: vi.fn(async () => checkoutStatus({ isDirty: false })) }; - - await fetchCheckoutStatus({ client, serverId, cwd }); - - expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeUndefined(); - }); -}); - -describe("ensureCheckoutStatus", () => { - it("awaits the canonical checkout-status query and reuses its cached result", async () => { - const queryClient = createQueryClient(); - const fetched = checkoutStatus({ currentBranch: "feature/current" }); - const client = { getCheckoutStatus: vi.fn(async () => fetched) }; - - const first = await ensureCheckoutStatus({ queryClient, client, serverId, cwd }); - const second = await ensureCheckoutStatus({ queryClient, client, serverId, cwd }); - - expect(first).toEqual(fetched); - expect(second).toEqual(fetched); - expect(client.getCheckoutStatus).toHaveBeenCalledExactlyOnceWith(cwd); - }); - - it("awaits a refetch when the canonical cached status was invalidated", async () => { - const queryClient = createQueryClient(); - queryClient.setQueryData( - checkoutStatusQueryKey(serverId, cwd), - checkoutStatus({ currentBranch: "feature/stale" }), - ); - await queryClient.invalidateQueries({ - queryKey: checkoutStatusQueryKey(serverId, cwd), - refetchType: "none", + it("deduplicates a bound status fetch by workspace identity", async () => { + const client = new QueryClient(); + const getStatus = vi.fn(async () => status()); + const first = ensureCheckoutStatus({ + queryClient: client, + client: { getStatus }, + serverId, + target: targetA, }); - const fetched = checkoutStatus({ currentBranch: "feature/current" }); - const client = { getCheckoutStatus: vi.fn(async () => fetched) }; - - const result = await ensureCheckoutStatus({ queryClient, client, serverId, cwd }); - - expect(result.currentBranch).toBe("feature/current"); - expect(client.getCheckoutStatus).toHaveBeenCalledExactlyOnceWith(cwd); + const second = ensureCheckoutStatus({ + queryClient: client, + client: { getStatus }, + serverId, + target: targetA, + }); + await Promise.all([first, second]); + expect(getStatus).toHaveBeenCalledTimes(1); }); -}); -describe("applyCheckoutStatusUpdateFromEvent", () => { - it("writes the checkout status to the cache using the cwd from the payload", () => { - const queryClient = createQueryClient(); - const pushed = checkoutStatus({ requestId: "push-1", isDirty: true }); + it("keeps same-cwd selected status and invalidation isolated", () => { + const client = new QueryClient(); + client.setQueryData(checkoutStatusQueryKey(serverId, targetA), status(targetA, "a")); + client.setQueryData(checkoutStatusQueryKey(serverId, targetB), status(targetB, "b")); + client.setQueryData(checkoutCommitsQueryKey(serverId, targetA), { commits: [] }); + client.setQueryData(checkoutCommitsQueryKey(serverId, targetB), { commits: [] }); applyCheckoutStatusUpdateFromEvent({ - queryClient, + queryClient: client, serverId, - message: checkoutStatusUpdate(pushed), + message: update(status(targetA, "a-next", true)), }); - expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed); - }); - - it("invalidates recent commits when checkout status is pushed", () => { - const queryClient = createQueryClient(); - queryClient.setQueryData(checkoutCommitsQueryKey(serverId, cwd), { commits: [] }); - queryClient.setQueryData(checkoutCommitsQueryKey(serverId, "/repo2"), { commits: [] }); - - applyCheckoutStatusUpdateFromEvent({ - queryClient, - serverId, - message: checkoutStatusUpdate(checkoutStatus()), - }); - - expect(queryClient.getQueryState(checkoutCommitsQueryKey(serverId, cwd))?.isInvalidated).toBe( + expect( + client.getQueryData(checkoutStatusQueryKey(serverId, targetA)) + ?.currentBranch, + ).toBe("a-next"); + expect( + client.getQueryData(checkoutStatusQueryKey(serverId, targetB)) + ?.currentBranch, + ).toBe("b"); + expect(client.getQueryState(checkoutCommitsQueryKey(serverId, targetA))?.isInvalidated).toBe( true, ); - expect( - queryClient.getQueryState(checkoutCommitsQueryKey(serverId, "/repo2"))?.isInvalidated, - ).toBe(false); + expect(client.getQueryState(checkoutCommitsQueryKey(serverId, targetB))?.isInvalidated).toBe( + false, + ); }); - it("writes the PR status cache when prStatus is present, and skips it otherwise", () => { - const queryClient = createQueryClient(); - const pushedPr = prStatus({ requestId: "pr-1" }); - + it("expires a diff-mode override when a bound update changes dirty state", () => { + useReviewDraftStore.getState().setDiffModeOverride({ + scopeKey: "review:scope", + override: { serverId, cwd: targetA.cwd, mode: "base", isDirtyAtSelection: false }, + }); applyCheckoutStatusUpdateFromEvent({ - queryClient, + queryClient: new QueryClient(), serverId, - message: checkoutStatusUpdate(checkoutStatus(), pushedPr), + message: update(status(targetA, "main", true)), }); - expect(queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, cwd))).toEqual(pushedPr); - - const otherCwd = "/repo2"; - applyCheckoutStatusUpdateFromEvent({ - queryClient, - serverId, - message: checkoutStatusUpdate(checkoutStatus({ cwd: otherCwd, repoRoot: otherCwd })), - }); - expect(queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, otherCwd))).toBeUndefined(); - }); - - it("normalizes legacy PR auth state at the pushed-cache boundary", () => { - const queryClient = createQueryClient(); - const { authState: _authState, ...legacyPrStatus } = prStatus({ - githubFeaturesEnabled: false, - }); - - applyCheckoutStatusUpdateFromEvent({ - queryClient, - serverId, - message: checkoutStatusUpdate(checkoutStatus(), legacyPrStatus), - }); - - expect( - queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, cwd)) - ?.authState, - ).toBe("unauthenticated"); - expect( - queryClient.getQueryData( - checkoutStatusQueryKey(serverId, cwd), - )?.prStatus?.authState, - ).toBe("unauthenticated"); - }); - - it("expires a manual diff-mode override when the pushed dirty state flipped", () => { - const queryClient = createQueryClient(); - setDiffModeOverride(false); - - applyCheckoutStatusUpdateFromEvent({ - queryClient, - serverId, - message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })), - }); - expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeUndefined(); }); - - it("keeps a manual diff-mode override while the pushed dirty state still matches", () => { - const queryClient = createQueryClient(); - setDiffModeOverride(true); - - applyCheckoutStatusUpdateFromEvent({ - queryClient, - serverId, - message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })), - }); - - expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeDefined(); - }); - - it("invalidates PR detail queries when the prStatus changes, ignoring the volatile requestId", () => { - const queryClient = createQueryClient(); - queryClient.setQueryData( - checkoutPrStatusQueryKey(serverId, cwd), - prStatus({ requestId: "pr-v1" }), - ); - const timelineKey = prPaneTimelineQueryKey({ serverId, cwd, prNumber: 42 }); - const pipelineKey = prPanePipelineQueryKey({ - serverId, - cwd, - pipelineId: 9001, - changeRequestNumber: 1, - }); - queryClient.setQueryData(timelineKey, { items: [] }); - queryClient.setQueryData(pipelineKey, { stages: [] }); - - applyCheckoutStatusUpdateFromEvent({ - queryClient, - serverId, - message: checkoutStatusUpdate(checkoutStatus(), prStatus({ requestId: "pr-v2" })), - }); - expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(false); - expect(queryClient.getQueryState(pipelineKey)?.isInvalidated).toBe(false); - - applyCheckoutStatusUpdateFromEvent({ - queryClient, - serverId, - message: checkoutStatusUpdate( - checkoutStatus(), - prStatus({ - requestId: "pr-v3", - status: { ...prStatus().status!, state: "closed" }, - }), - ), - }); - expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(true); - expect(queryClient.getQueryState(pipelineKey)?.isInvalidated).toBe(true); - }); - - it("invalidates PR detail queries on the first prStatus emission, scoped to its cwd", () => { - const queryClient = createQueryClient(); - const timelineKey = prPaneTimelineQueryKey({ serverId, cwd, prNumber: 42 }); - const otherTimelineKey = prPaneTimelineQueryKey({ serverId, cwd: "/repo2", prNumber: 42 }); - const pipelineKey = prPanePipelineQueryKey({ - serverId, - cwd, - pipelineId: 9001, - changeRequestNumber: 1, - }); - const otherPipelineKey = prPanePipelineQueryKey({ - serverId, - cwd: "/repo2", - pipelineId: 9001, - changeRequestNumber: 1, - }); - queryClient.setQueryData(timelineKey, { items: [] }); - queryClient.setQueryData(otherTimelineKey, { items: [] }); - queryClient.setQueryData(pipelineKey, { stages: [] }); - queryClient.setQueryData(otherPipelineKey, { stages: [] }); - - applyCheckoutStatusUpdateFromEvent({ - queryClient, - serverId, - message: checkoutStatusUpdate(checkoutStatus(), prStatus()), - }); - - expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(true); - expect(queryClient.getQueryState(otherTimelineKey)?.isInvalidated).toBe(false); - expect(queryClient.getQueryState(pipelineKey)?.isInvalidated).toBe(true); - expect(queryClient.getQueryState(otherPipelineKey)?.isInvalidated).toBe(false); - }); }); diff --git a/packages/app/src/git/checkout-status-cache.ts b/packages/app/src/git/checkout-status-cache.ts index 1f76dc67b1..ed831eb151 100644 --- a/packages/app/src/git/checkout-status-cache.ts +++ b/packages/app/src/git/checkout-status-cache.ts @@ -5,15 +5,21 @@ import { checkoutCommitsQueryKey, checkoutPrStatusQueryKey, checkoutStatusQueryKey, + legacyCheckoutStatusQueryKey, invalidatePrPaneTimelineForCheckout, } from "@/git/query-keys"; import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status"; import { expireStaleDiffModeOverrides } from "@/review/store"; +import type { WorkspaceGitClient, WorkspaceGitStatusTarget } from "./workspace-git"; export type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; export type { CheckoutPrStatusPayload } from "@/git/pr-status"; export interface CheckoutStatusClient { + getStatus: WorkspaceGitClient["getStatus"]; +} + +export interface LegacyCheckoutStatusClient { getCheckoutStatus: (cwd: string) => Promise; } @@ -24,14 +30,18 @@ export interface CheckoutStatusClient { export async function fetchCheckoutStatus({ client, serverId, - cwd, + target, }: { client: CheckoutStatusClient; serverId: string; - cwd: string; + target: WorkspaceGitStatusTarget; }): Promise { - const payload = await client.getCheckoutStatus(cwd); - expireStaleDiffModeOverrides({ serverId, cwd, isDirty: payload.isGit && payload.isDirty }); + const payload = await client.getStatus(); + expireStaleDiffModeOverrides({ + serverId, + cwd: target.cwd, + isDirty: payload.isGit && payload.isDirty, + }); return payload; } @@ -39,16 +49,38 @@ export async function ensureCheckoutStatus({ queryClient, client, serverId, - cwd, + target, }: { queryClient: QueryClient; client: CheckoutStatusClient; serverId: string; + target: WorkspaceGitStatusTarget; +}): Promise { + return await queryClient.fetchQuery({ + queryKey: checkoutStatusQueryKey(serverId, target), + queryFn: () => fetchCheckoutStatus({ client, serverId, target }), + staleTime: Infinity, + }); +} + +export async function ensureLegacyCheckoutStatus({ + queryClient, + client, + serverId, + cwd, +}: { + queryClient: QueryClient; + client: LegacyCheckoutStatusClient; + serverId: string; cwd: string; }): Promise { return await queryClient.fetchQuery({ - queryKey: checkoutStatusQueryKey(serverId, cwd), - queryFn: () => fetchCheckoutStatus({ client, serverId, cwd }), + queryKey: legacyCheckoutStatusQueryKey(serverId, cwd), + queryFn: async () => { + const payload = await client.getCheckoutStatus(cwd); + expireStaleDiffModeOverrides({ serverId, cwd, isDirty: payload.isGit && payload.isDirty }); + return payload; + }, staleTime: Infinity, }); } @@ -67,9 +99,15 @@ export function applyCheckoutStatusUpdateFromEvent({ ? normalizeCheckoutPrStatusPayload(payload.prStatus) : undefined; const cachePayload = prStatus ? { ...payload, prStatus } : payload; - queryClient.setQueryData(checkoutStatusQueryKey(serverId, payload.cwd), cachePayload); + if (payload.workspaceId === undefined) return; + const target = { + workspaceId: payload.workspaceId, + cwd: payload.cwd, + queryIdentity: ["selected", payload.workspaceId] as const, + }; + queryClient.setQueryData(checkoutStatusQueryKey(serverId, target), cachePayload); void queryClient.invalidateQueries({ - queryKey: checkoutCommitsQueryKey(serverId, payload.cwd), + queryKey: checkoutCommitsQueryKey(serverId, target), }); expireStaleDiffModeOverrides({ serverId, @@ -82,14 +120,14 @@ export function applyCheckoutStatusUpdateFromEvent({ } const previous = queryClient.getQueryData( - checkoutPrStatusQueryKey(serverId, prStatus.cwd), + checkoutPrStatusQueryKey(serverId, target), ); - queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, prStatus.cwd), prStatus); + queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, target), prStatus); // The PR activity timeline has no push channel; mark it stale when the pushed PR status // meaningfully changed. Active panes refetch immediately, evicted ones on next mount. if (hasPrStatusChanged(previous, prStatus)) { - void invalidatePrPaneTimelineForCheckout(queryClient, { serverId, cwd: prStatus.cwd }); + void invalidatePrPaneTimelineForCheckout(queryClient, { serverId, target }); } } diff --git a/packages/app/src/git/client-forge-module.ts b/packages/app/src/git/client-forge-module.ts index cbc71da571..8a003ff8df 100644 --- a/packages/app/src/git/client-forge-module.ts +++ b/packages/app/src/git/client-forge-module.ts @@ -66,6 +66,7 @@ export interface MergeCapability { export interface PaneChecksSlotContext { serverId: string; + workspaceId: string; cwd: string; /** Change request (PR/MR) number, so a section can address its head pipeline. */ changeRequestNumber: number; diff --git a/packages/app/src/git/commits-section/commits-section.tsx b/packages/app/src/git/commits-section/commits-section.tsx index bd95a3db47..f067663697 100644 --- a/packages/app/src/git/commits-section/commits-section.tsx +++ b/packages/app/src/git/commits-section/commits-section.tsx @@ -11,7 +11,6 @@ import { CommitRow } from "./commit-row"; interface CommitsSectionProps { serverId: string; - cwd: string; onCommitPress: (sha: string) => void; } @@ -82,7 +81,7 @@ function CommitsSectionContent({ ); } -export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionProps) { +export function CommitsSection({ serverId, onCommitPress }: CommitsSectionProps) { const { t } = useTranslation(); const { preferences, updatePreferences } = useChangesPreferences(); const isPanelActive = useRetainedPanelActive(); @@ -91,7 +90,6 @@ export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionP const displayNow = useMemo(() => (isPanelActive ? new Date() : now), [isPanelActive, now]); const query = useCheckoutCommitsQuery({ serverId, - cwd, enabled: !collapsed, }); diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 53bc9894b4..59ca827fb2 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -92,6 +92,7 @@ import { buildForgeSignInCommand, getForgePresentation, type Forge } from "@/git import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; import type { ForgeAuthState } from "@getpaseo/protocol/messages"; import { useCheckoutGitActionsStore } from "@/git/actions-store"; +import { useRequiredWorkspaceGit } from "@/git/workspace-git"; import { useToast } from "@/contexts/toast-context"; import { useSessionStore } from "@/stores/session-store"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; @@ -2619,6 +2620,7 @@ export function GitDiffPane({ onOpenFile, onAddToChat, }: GitDiffPaneProps) { + const target = useRequiredWorkspaceGit(); const { settings: appSettings } = useAppSettings(); const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); @@ -2670,17 +2672,22 @@ export function GitDiffPane({ ); const runRefresh = useCheckoutGitActionsStore((s) => s.refresh); const isRefreshing = - useCheckoutGitActionsStore((s) => s.getStatus({ serverId, cwd, actionId: "refresh" })) === - "pending"; + useCheckoutGitActionsStore((s) => + s.getStatus({ + serverId, + target, + actionId: "refresh", + }), + ) === "pending"; const handleRefresh = useCallback(() => { if (isRefreshing) { return; } - void runRefresh({ serverId, cwd }).catch((error) => { + void runRefresh({ serverId, target }).catch((error) => { toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh")); }); - }, [cwd, isRefreshing, runRefresh, serverId, t, toast]); + }, [isRefreshing, runRefresh, serverId, t, target, toast]); const { status, @@ -2701,7 +2708,7 @@ export function GitDiffPane({ reviewAttachment, } = useWorkingDiff({ serverId, - workspaceId: workspaceId ?? undefined, + workspaceId: target.workspaceId, cwd, ignoreWhitespace: changesPreferences.hideWhitespace, enabled: enabled !== false, @@ -2720,7 +2727,6 @@ export function GitDiffPane({ payloadError: prPayloadError, } = useCheckoutPrStatusQuery({ serverId, - cwd, enabled: isGit, }); const forgeProvidersSupported = useSessionStore( @@ -2748,7 +2754,7 @@ export function GitDiffPane({ [updateChangesPreferences], ); const changesTree = useChangesTreeState({ - workspaceId, + workspaceId: target.workspaceId, cwd, files, viewMode, @@ -2867,8 +2873,6 @@ export function GitDiffPane({ @@ -2942,7 +2946,7 @@ export function GitDiffPane({ {bodyContent} - + ); } diff --git a/packages/app/src/git/forges/gitlab.view.tsx b/packages/app/src/git/forges/gitlab.view.tsx index d88ade1f95..2c0a5c84dd 100644 --- a/packages/app/src/git/forges/gitlab.view.tsx +++ b/packages/app/src/git/forges/gitlab.view.tsx @@ -54,7 +54,6 @@ function renderGitlabChecksSection(facts: GitlabMergeFacts, ctx: PaneChecksSlotC return ( { + const getForgeCheckDetails = vi.fn(async () => ({ + cwd: "/shared", + requestId: "check-details", + success: true, + details: { + checkRunId: 42, + name: "Runtime check", + output: { title: "Runtime check", summary: "failed", text: "runtime log attachment" }, + annotations: [], + failedJobs: [], + truncated: false, + }, + error: null, + })); + const workspaceGit = { + workspaceId: "workspace-selected", + cwd: "/shared", + getForgeCheckDetails, + getGithubCheckDetails: vi.fn(), + }; + + await expect( + fetchBoundPullRequestCheckDetails({ + workspaceGit, + transport: "forge", + repoOwner: "getpaseo", + repoName: "paseo", + checkRunId: 42, + changeRequestNumber: 99, + }), + ).resolves.toMatchObject({ output: { text: "runtime log attachment" } }); + expect(workspaceGit).toMatchObject({ workspaceId: "workspace-selected", cwd: "/shared" }); + expect(getForgeCheckDetails).toHaveBeenCalledWith({ + repoOwner: "getpaseo", + repoName: "paseo", + checkRunId: 42, + workflowRunId: undefined, + changeRequestNumber: 99, + }); +}); diff --git a/packages/app/src/git/pull-request-panel/check-details.ts b/packages/app/src/git/pull-request-panel/check-details.ts new file mode 100644 index 0000000000..4f5ec07569 --- /dev/null +++ b/packages/app/src/git/pull-request-panel/check-details.ts @@ -0,0 +1,32 @@ +import type { WorkspaceGitClient } from "@/git/workspace-git"; + +type CheckDetailsClient = Pick< + WorkspaceGitClient, + "workspaceId" | "cwd" | "getForgeCheckDetails" | "getGithubCheckDetails" +>; + +export async function fetchBoundPullRequestCheckDetails(input: { + workspaceGit: CheckDetailsClient; + transport: "forge" | "github"; + repoOwner: string; + repoName: string; + checkRunId?: number; + workflowRunId?: number; + changeRequestNumber: number; +}) { + const request = { + repoOwner: input.repoOwner, + repoName: input.repoName, + checkRunId: input.checkRunId, + workflowRunId: input.workflowRunId, + changeRequestNumber: input.changeRequestNumber, + }; + const payload = + input.transport === "forge" + ? await input.workspaceGit.getForgeCheckDetails(request) + : await input.workspaceGit.getGithubCheckDetails(request); + if (!payload.success) { + throw new Error(payload.error?.message ?? "Could not load check details"); + } + return payload.details ?? null; +} diff --git a/packages/app/src/git/pull-request-panel/pane.tsx b/packages/app/src/git/pull-request-panel/pane.tsx index 582d9543a2..6563e0b4d4 100644 --- a/packages/app/src/git/pull-request-panel/pane.tsx +++ b/packages/app/src/git/pull-request-panel/pane.tsx @@ -38,11 +38,11 @@ import { import { MarkdownRenderer } from "@/components/markdown/renderer"; import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment"; import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard"; -import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store"; import { useToast } from "@/contexts/toast-context"; import { useCheckoutGitActionsStore } from "@/git/actions-store"; +import { useRequiredWorkspaceGit } from "@/git/workspace-git"; import { isNative } from "@/constants/platform"; import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { ICON_SIZE, type Theme } from "@/styles/theme"; @@ -67,6 +67,7 @@ import { canAddPullRequestCheckLogsToChat, } from "./context-attachment"; import { getActivityVerb, getStateLabel } from "./data"; +import { fetchBoundPullRequestCheckDetails } from "./check-details"; import type { PrPaneActivity, PrPaneCheck, PrPaneData, PrState } from "./data"; import type { ForgeSpecificStatusFacts } from "@/git/merge-capability"; import { @@ -205,12 +206,14 @@ function removeLoadingCheck(current: ReadonlySet, checkKey: string): Rea export function PullRequestPane({ serverId, + workspaceId, cwd, data, activityLoading, workspaceAttachmentScopeKey, }: { serverId: string; + workspaceId: string; cwd: string; data: PrPaneData; activityLoading: boolean; @@ -218,7 +221,7 @@ export function PullRequestPane({ }) { const { t } = useTranslation(); const toast = useToast(); - const daemonClient = useHostRuntimeClient(serverId); + const workspaceGit = useRequiredWorkspaceGit(); // COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once // all supported clients use checkout.forge.get_check_details.*. const canFetchGitHubCheckDetails = useSessionStore( @@ -248,17 +251,17 @@ export function PullRequestPane({ const runRefresh = useCheckoutGitActionsStore((state) => state.refresh); const isRefreshing = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "refresh" }), + state.getStatus({ serverId, target: workspaceGit, actionId: "refresh" }), ) === "pending"; const handleRefresh = useCallback(() => { if (isRefreshing) { return; } - void runRefresh({ serverId, cwd }).catch((error) => { + void runRefresh({ serverId, target: workspaceGit }).catch((error) => { toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh")); }); - }, [cwd, isRefreshing, runRefresh, serverId, t, toast]); + }, [isRefreshing, runRefresh, serverId, t, toast, workspaceGit]); const handleToggleChecks = useCallback(() => { setChecksOpen((open) => !open); @@ -399,29 +402,21 @@ export function PullRequestPane({ canFetchForgeCheckDetails || (check.provider === "github" && canFetchGitHubCheckDetails); if ( canFetchDetail && - daemonClient && (ref?.checkRunId !== undefined || ref?.workflowRunId !== undefined) && data.repoOwner && data.repoName ) { - try { - const request = { - cwd, - repoOwner: data.repoOwner, - repoName: data.repoName, - checkRunId: ref.checkRunId, - workflowRunId: ref.workflowRunId, - changeRequestNumber: data.number, - }; - // COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once - // all supported clients use checkout.forge.get_check_details.*. - const payload = canFetchForgeCheckDetails - ? await daemonClient.checkoutForgeGetCheckDetails(request) - : await daemonClient.checkoutGithubGetCheckDetails(request); - details = payload.success ? payload.details : null; - } catch { - details = null; - } + // COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once + // all supported clients use checkout.forge.get_check_details.*. + details = await fetchBoundPullRequestCheckDetails({ + workspaceGit, + transport: canFetchForgeCheckDetails ? "forge" : "github", + repoOwner: data.repoOwner, + repoName: data.repoName, + checkRunId: ref.checkRunId, + workflowRunId: ref.workflowRunId, + changeRequestNumber: data.number, + }); } const attachment = buildPullRequestCheckContextAttachment({ provider: data.provider, @@ -444,8 +439,6 @@ export function PullRequestPane({ addWorkspaceAttachment, canFetchForgeCheckDetails, canFetchGitHubCheckDetails, - cwd, - daemonClient, data.forge, data.number, data.provider, @@ -454,6 +447,7 @@ export function PullRequestPane({ data.title, data.url, workspaceAttachmentScopeKey, + workspaceGit, ], ); @@ -483,6 +477,7 @@ export function PullRequestPane({ const nativeChecksSection = data.forgeSpecific ? nativeContribution?.renderChecksSection(data.forgeSpecific, { serverId, + workspaceId, cwd, changeRequestNumber: data.number, open: checksOpen, diff --git a/packages/app/src/git/pull-request-panel/query-keys.ts b/packages/app/src/git/pull-request-panel/query-keys.ts index 75bc3ef5ba..b204196dca 100644 --- a/packages/app/src/git/pull-request-panel/query-keys.ts +++ b/packages/app/src/git/pull-request-panel/query-keys.ts @@ -2,29 +2,40 @@ export const prPaneTimelineQueryKind = "prPaneTimeline"; export function prPaneTimelineQueryKey({ serverId, + queryIdentity, cwd, prNumber, }: { serverId: string; + queryIdentity: readonly ["selected", string]; cwd: string; prNumber: number | null; }) { - return [prPaneTimelineQueryKind, serverId, cwd, prNumber] as const; + return [prPaneTimelineQueryKind, serverId, queryIdentity, cwd, prNumber] as const; } export const prPanePipelineQueryKind = "prPanePipeline"; export function prPanePipelineQueryKey({ serverId, + queryIdentity, cwd, pipelineId, changeRequestNumber, }: { serverId: string; + queryIdentity: readonly ["selected", string]; cwd: string; pipelineId: number | null; /** MR iid the pipeline is fetched by; part of the key since the fetch routes by it. */ changeRequestNumber: number; }) { - return [prPanePipelineQueryKind, serverId, cwd, pipelineId, changeRequestNumber] as const; + return [ + prPanePipelineQueryKind, + serverId, + queryIdentity, + cwd, + pipelineId, + changeRequestNumber, + ] as const; } diff --git a/packages/app/src/git/pull-request-panel/use-data.test.ts b/packages/app/src/git/pull-request-panel/use-data.test.ts index 0e8a7edd97..644a1ebce9 100644 --- a/packages/app/src/git/pull-request-panel/use-data.test.ts +++ b/packages/app/src/git/pull-request-panel/use-data.test.ts @@ -16,7 +16,8 @@ import { type CheckoutPrStatus = NonNullable; type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"]; -type PullRequestTimelineInput = Parameters[0]; +type PullRequestTimelineInput = Parameters[0]; +const workspaceId = "workspace-1"; const githubStatus: CheckoutPrStatus["github"] = { mergeStateStatus: null, @@ -82,7 +83,7 @@ function createTimelineClient( const calls: PullRequestTimelineInput[] = []; return { calls, - pullRequestTimeline: async (input) => { + getPullRequestTimeline: async (input) => { calls.push(input); return respond(input); }, @@ -181,7 +182,12 @@ describe("shouldFetchTimelineFrom", () => { describe("createInMemoryUnsupportedTimelineRegistry", () => { it("remembers added keys and answers has() against them", () => { const registry = createInMemoryUnsupportedTimelineRegistry(); - const key = unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 99 }); + const key = unsupportedTimelineKey({ + serverId: "host", + workspaceId, + cwd: "/repo", + prNumber: 99, + }); expect(registry.has(key)).toBe(false); registry.add(key); @@ -189,14 +195,20 @@ describe("createInMemoryUnsupportedTimelineRegistry", () => { }); it("uses serverId, cwd, and prNumber to scope each key", () => { - expect(unsupportedTimelineKey({ serverId: "host-a", cwd: "/repo", prNumber: 1 })).not.toEqual( - unsupportedTimelineKey({ serverId: "host-b", cwd: "/repo", prNumber: 1 }), + expect( + unsupportedTimelineKey({ serverId: "host-a", workspaceId, cwd: "/repo", prNumber: 1 }), + ).not.toEqual( + unsupportedTimelineKey({ serverId: "host-b", workspaceId, cwd: "/repo", prNumber: 1 }), ); - expect(unsupportedTimelineKey({ serverId: "host", cwd: "/repo-a", prNumber: 1 })).not.toEqual( - unsupportedTimelineKey({ serverId: "host", cwd: "/repo-b", prNumber: 1 }), + expect( + unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo-a", prNumber: 1 }), + ).not.toEqual( + unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo-b", prNumber: 1 }), ); - expect(unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 1 })).not.toEqual( - unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 2 }), + expect( + unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo", prNumber: 1 }), + ).not.toEqual( + unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo", prNumber: 2 }), ); }); }); @@ -210,15 +222,14 @@ describe("fetchPrPaneTimelinePage", () => { client, registry, serverId: "host", + workspaceId, cwd: "/repo", prNumber: 42, repoOwner: "getpaseo", repoName: "paseo", }); - expect(client.calls).toEqual([ - { cwd: "/repo", prNumber: 42, repoOwner: "getpaseo", repoName: "paseo" }, - ]); + expect(client.calls).toEqual([{ prNumber: 42, repoOwner: "getpaseo", repoName: "paseo" }]); }); it("returns the daemon's timeline payload on success", async () => { @@ -241,6 +252,7 @@ describe("fetchPrPaneTimelinePage", () => { client, registry, serverId: "host", + workspaceId, cwd: "/repo", prNumber: 42, repoOwner: "getpaseo", @@ -262,6 +274,7 @@ describe("fetchPrPaneTimelinePage", () => { client, registry, serverId: "host", + workspaceId, cwd: "/repo", prNumber: 99, repoOwner: "getpaseo", @@ -270,7 +283,9 @@ describe("fetchPrPaneTimelinePage", () => { ).rejects.toBe(error); expect( - registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 99 })), + registry.has( + unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo", prNumber: 99 }), + ), ).toBe(true); }); @@ -286,6 +301,7 @@ describe("fetchPrPaneTimelinePage", () => { client, registry, serverId: "host", + workspaceId, cwd: "/repo", prNumber: 99, repoOwner: "getpaseo", @@ -294,16 +310,18 @@ describe("fetchPrPaneTimelinePage", () => { ).rejects.toBe(error); expect( - registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 99 })), + registry.has( + unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo", prNumber: 99 }), + ), ).toBe(false); }); it("scopes recorded tuples per serverId+cwd+prNumber so other PRs can still be tried", async () => { const client = createTimelineClient(async (input) => { - if (input.cwd === "/repo-a") { + if (input.prNumber === 1) { throw unsupportedTimelineError(); } - return timelinePayload({ cwd: input.cwd, prNumber: input.prNumber }); + return timelinePayload({ cwd: "/repo-b", prNumber: input.prNumber }); }); const registry = createInMemoryUnsupportedTimelineRegistry(); @@ -312,6 +330,7 @@ describe("fetchPrPaneTimelinePage", () => { client, registry, serverId: "host", + workspaceId, cwd: "/repo-a", prNumber: 1, repoOwner: "getpaseo", @@ -323,6 +342,7 @@ describe("fetchPrPaneTimelinePage", () => { client, registry, serverId: "host", + workspaceId, cwd: "/repo-b", prNumber: 2, repoOwner: "getpaseo", @@ -331,10 +351,14 @@ describe("fetchPrPaneTimelinePage", () => { expect(result.prNumber).toBe(2); expect( - registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo-a", prNumber: 1 })), + registry.has( + unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo-a", prNumber: 1 }), + ), ).toBe(true); expect( - registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo-b", prNumber: 2 })), + registry.has( + unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo-b", prNumber: 2 }), + ), ).toBe(false); }); }); diff --git a/packages/app/src/git/pull-request-panel/use-data.ts b/packages/app/src/git/pull-request-panel/use-data.ts index f43394cf7e..78fb84941e 100644 --- a/packages/app/src/git/pull-request-panel/use-data.ts +++ b/packages/app/src/git/pull-request-panel/use-data.ts @@ -1,12 +1,12 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { CheckoutPrStatusResponse, PullRequestTimelineResponse, } from "@getpaseo/protocol/messages"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { type WorkspaceGitClient, useRequiredWorkspaceGit } from "@/git/workspace-git"; import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; import type { Forge } from "@/git/forge"; import { i18n } from "@/i18n/i18next"; @@ -19,7 +19,6 @@ type PullRequestTimeline = PullRequestTimelineResponse["payload"]; export interface UsePrPaneDataOptions { serverId: string; - cwd: string; enabled?: boolean; timelineEnabled?: boolean; } @@ -80,7 +79,7 @@ export function shouldFetchTimelineFrom({ ); } -export type PrPaneTimelineClient = Pick; +export type PrPaneTimelineClient = Pick; export interface UnsupportedTimelineRegistry { has(key: string): boolean; @@ -89,14 +88,16 @@ export interface UnsupportedTimelineRegistry { export function unsupportedTimelineKey({ serverId, + workspaceId, cwd, prNumber, }: { serverId: string; + workspaceId: string; cwd: string; prNumber: number; }): string { - return `${serverId}\0${cwd}\0${prNumber}`; + return `${serverId}\0${workspaceId}\0${cwd}\0${prNumber}`; } export function createInMemoryUnsupportedTimelineRegistry(): UnsupportedTimelineRegistry { @@ -115,6 +116,7 @@ export interface FetchPrPaneTimelinePageInput { client: PrPaneTimelineClient; registry: UnsupportedTimelineRegistry; serverId: string; + workspaceId: string; cwd: string; prNumber: number; repoOwner: string; @@ -125,8 +127,7 @@ export async function fetchPrPaneTimelinePage( input: FetchPrPaneTimelinePageInput, ): Promise { try { - return await input.client.pullRequestTimeline({ - cwd: input.cwd, + return await input.client.getPullRequestTimeline({ prNumber: input.prNumber, repoOwner: input.repoOwner, repoName: input.repoName, @@ -136,6 +137,7 @@ export async function fetchPrPaneTimelinePage( input.registry.add( unsupportedTimelineKey({ serverId: input.serverId, + workspaceId: input.workspaceId, cwd: input.cwd, prNumber: input.prNumber, }), @@ -197,14 +199,13 @@ export function selectPrPaneState(input: SelectPrPaneStateInput): UsePrPaneDataR export function usePrPaneData({ serverId, - cwd, enabled = true, timelineEnabled = enabled, }: UsePrPaneDataOptions): UsePrPaneDataResult { const { t } = useTranslation(); - const daemonClient = useHostRuntimeClient(serverId); + const workspaceGit = useRequiredWorkspaceGit(); const isConnected = useHostRuntimeIsConnected(serverId); - const checkoutPrStatus = useCheckoutPrStatusQuery({ serverId, cwd, enabled }); + const checkoutPrStatus = useCheckoutPrStatusQuery({ serverId, enabled }); const status = checkoutPrStatus.status; const identity = extractPrRepoIdentity(status); const githubFeaturesEnabled = checkoutPrStatus.githubFeaturesEnabled; @@ -212,26 +213,37 @@ export function usePrPaneData({ const unsupportedKey = identity.prNumber === null ? null - : unsupportedTimelineKey({ serverId, cwd, prNumber: identity.prNumber }); + : unsupportedTimelineKey({ + serverId, + workspaceId: workspaceGit.workspaceId, + cwd: workspaceGit.cwd, + prNumber: identity.prNumber, + }); const timelineUnsupported = unsupportedKey ? registry.has(unsupportedKey) : false; const shouldFetchTimeline = shouldFetchTimelineFrom({ - hasClient: !!daemonClient, + hasClient: !!workspaceGit, isConnected, timelineEnabled, githubFeaturesEnabled, - cwd, + cwd: workspaceGit.cwd, identity, timelineUnsupported, }); const timelineQuery = useQuery({ queryKey: useMemo( - () => prPaneTimelineQueryKey({ serverId, cwd, prNumber: identity.prNumber }), - [serverId, cwd, identity.prNumber], + () => + prPaneTimelineQueryKey({ + serverId, + queryIdentity: workspaceGit.queryIdentity, + cwd: workspaceGit.cwd, + prNumber: identity.prNumber, + }), + [serverId, workspaceGit, identity.prNumber], ), queryFn: async () => { if ( - !daemonClient || + !workspaceGit || identity.prNumber === null || identity.repoOwner === null || identity.repoName === null @@ -239,10 +251,11 @@ export function usePrPaneData({ throw new Error(t("common.errors.daemonClientUnavailable")); } return fetchPrPaneTimelinePage({ - client: daemonClient, + client: workspaceGit, registry, serverId, - cwd, + workspaceId: workspaceGit.workspaceId, + cwd: workspaceGit.cwd, prNumber: identity.prNumber, repoOwner: identity.repoOwner, repoName: identity.repoName, diff --git a/packages/app/src/git/pull-request-panel/use-pipeline.ts b/packages/app/src/git/pull-request-panel/use-pipeline.ts index 91b020a0ea..66e49792b9 100644 --- a/packages/app/src/git/pull-request-panel/use-pipeline.ts +++ b/packages/app/src/git/pull-request-panel/use-pipeline.ts @@ -1,8 +1,9 @@ import { useMemo } from "react"; import type { CheckoutPipeline } from "@getpaseo/protocol/messages"; import { useFetchQuery } from "@/data/query"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { prPanePipelineQueryKey } from "./query-keys"; +import { useRequiredWorkspaceGit } from "@/git/workspace-git"; /** Poll cadence for an in-progress pipeline; finished pipelines are immutable. */ const LIVE_PIPELINE_REFETCH_MS = 15_000; @@ -10,7 +11,6 @@ const FINISHED_PIPELINE_STALE_MS = 24 * 60 * 60 * 1_000; export interface UseGitLabPipelineOptions { serverId: string; - cwd: string; pipelineId: number | null; /** MR iid, so the fetch resolves a fork/detached head pipeline correctly. */ changeRequestNumber: number; @@ -38,27 +38,32 @@ export interface UseGitLabPipelineResult { */ export function useGitLabPipeline({ serverId, - cwd, pipelineId, changeRequestNumber, enabled, live, }: UseGitLabPipelineOptions): UseGitLabPipelineResult { - const daemonClient = useHostRuntimeClient(serverId); + const workspaceGit = useRequiredWorkspaceGit(); const isConnected = useHostRuntimeIsConnected(serverId); - const shouldFetch = enabled && !!daemonClient && isConnected && !!cwd && pipelineId !== null; + const shouldFetch = enabled && !!workspaceGit && isConnected && pipelineId !== null; const query = useFetchQuery({ queryKey: useMemo( - () => prPanePipelineQueryKey({ serverId, cwd, pipelineId, changeRequestNumber }), - [serverId, cwd, pipelineId, changeRequestNumber], + () => + prPanePipelineQueryKey({ + serverId, + queryIdentity: workspaceGit.queryIdentity, + cwd: workspaceGit.cwd, + pipelineId, + changeRequestNumber, + }), + [serverId, workspaceGit, pipelineId, changeRequestNumber], ), queryFn: async () => { - if (!daemonClient || pipelineId === null) { + if (!workspaceGit || pipelineId === null) { return null; } - const payload = await daemonClient.checkoutForgeGetCheckDetails({ - cwd, + const payload = await workspaceGit.getForgeCheckDetails({ checkRunId: pipelineId, changeRequestNumber, }); diff --git a/packages/app/src/git/query-keys.test.ts b/packages/app/src/git/query-keys.test.ts index e6caa3cc30..7033409c9b 100644 --- a/packages/app/src/git/query-keys.test.ts +++ b/packages/app/src/git/query-keys.test.ts @@ -1,8 +1,8 @@ import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import { - checkoutDiffQueryKey, checkoutCommitsQueryKey, + checkoutDiffQueryKey, checkoutPrStatusQueryKey, checkoutStatusQueryKey, invalidateCheckoutGitQueriesForClient, @@ -15,153 +15,156 @@ import { describe("checkout query keys", () => { const serverId = "server-1"; - const cwd = "/tmp/repo"; + const target = { + workspaceId: "workspace-a", + cwd: "/tmp/repo", + queryIdentity: ["selected", "workspace-a"] as const, + }; + const sibling = { + workspaceId: "workspace-b", + cwd: target.cwd, + queryIdentity: ["selected", "workspace-b"] as const, + }; - it("invalidates every query for a checkout without touching other checkouts", async () => { - const queryClient = new QueryClient(); - - queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), { isGit: true }); - queryClient.setQueryData(checkoutDiffQueryKey(serverId, cwd, "base", "main", true), { - files: [], - }); - queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), { status: { number: 12 } }); - queryClient.setQueryData(checkoutCommitsQueryKey(serverId, cwd), { commits: [] }); - queryClient.setQueryData(checkoutCommitsQueryKey(serverId, "/tmp/other"), { commits: [] }); - queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }), { - items: [], - }); - queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 13 }), { - items: [], - }); - queryClient.setQueryData( - prPanePipelineQueryKey({ serverId, cwd, pipelineId: 9001, changeRequestNumber: 1 }), - { - stages: [], - }, + it("uses workspace identity before the compatibility cwd", () => { + expect(checkoutStatusQueryKey(serverId, target)).toEqual([ + "checkoutStatus", + serverId, + target.queryIdentity, + target.cwd, + ]); + expect(checkoutStatusQueryKey(serverId, sibling)).not.toEqual( + checkoutStatusQueryKey(serverId, target), ); - queryClient.setQueryData( - prPaneTimelineQueryKey({ serverId, cwd: "/tmp/other", prNumber: 12 }), - { items: [] }, - ); - queryClient.setQueryData( - prPanePipelineQueryKey({ - serverId, - cwd: "/tmp/other", - pipelineId: 9001, - changeRequestNumber: 1, - }), - { stages: [] }, - ); - - await invalidateCheckoutGitQueriesForClient(queryClient, { serverId, cwd }); - - expect(queryClient.getQueryState(checkoutStatusQueryKey(serverId, cwd))?.isInvalidated).toBe( - true, - ); - expect( - queryClient.getQueryState(checkoutDiffQueryKey(serverId, cwd, "base", "main", true)) - ?.isInvalidated, - ).toBe(true); - expect(queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, cwd))?.isInvalidated).toBe( - true, - ); - expect(queryClient.getQueryState(checkoutCommitsQueryKey(serverId, cwd))?.isInvalidated).toBe( - true, - ); - expect( - queryClient.getQueryState(checkoutCommitsQueryKey(serverId, "/tmp/other"))?.isInvalidated, - ).toBe(false); - expect( - queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 })) - ?.isInvalidated, - ).toBe(true); - expect( - queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 13 })) - ?.isInvalidated, - ).toBe(true); - expect( - queryClient.getQueryState( - prPanePipelineQueryKey({ serverId, cwd, pipelineId: 9001, changeRequestNumber: 1 }), - )?.isInvalidated, - ).toBe(true); - expect( - queryClient.getQueryState( - prPaneTimelineQueryKey({ serverId, cwd: "/tmp/other", prNumber: 12 }), - )?.isInvalidated, - ).toBe(false); - expect( - queryClient.getQueryState( - prPanePipelineQueryKey({ - serverId, - cwd: "/tmp/other", - pipelineId: 9001, - changeRequestNumber: 1, - }), - )?.isInvalidated, - ).toBe(false); - - queryClient.clear(); }); - it("invalidates fetch-based checkout queries server-wide without touching other servers", async () => { - const queryClient = new QueryClient(); - const otherServerId = "server-2"; - const otherCwd = "/tmp/repo-2"; + it("keeps selected and legacy identity distinct across keys and invalidation", async () => { + const selected = { + ...target, + workspaceId: "legacy:/shared", + cwd: "/shared", + queryIdentity: ["selected", "legacy:/shared"] as const, + }; + const legacy = { + cwd: "/shared", + queryIdentity: ["legacy", "/shared"] as const, + }; - queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), { isGit: true }); - queryClient.setQueryData(checkoutStatusQueryKey(serverId, otherCwd), { isGit: true }); - queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), { status: { number: 12 } }); - queryClient.setQueryData(checkoutCommitsQueryKey(serverId, cwd), { commits: [] }); - queryClient.setQueryData(checkoutCommitsQueryKey(otherServerId, cwd), { commits: [] }); - queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }), { - items: [], - }); - queryClient.setQueryData( - prPanePipelineQueryKey({ serverId, cwd, pipelineId: 9001, changeRequestNumber: 1 }), - { - stages: [], - }, + expect(checkoutStatusQueryKey(serverId, selected)).not.toEqual( + checkoutStatusQueryKey(serverId, legacy), ); - // Subscription-fed diff queries are deliberately not part of the server-wide sweep. - queryClient.setQueryData(checkoutDiffQueryKey(serverId, cwd, "base", "main", true), { + expect(checkoutPrStatusQueryKey(serverId, selected)).not.toEqual( + checkoutPrStatusQueryKey(serverId, legacy), + ); + expect(checkoutDiffQueryKey(serverId, selected, "uncommitted")).toContainEqual( + selected.queryIdentity, + ); + expect(checkoutCommitsQueryKey(serverId, selected)).toContainEqual(selected.queryIdentity); + + const queryClient = new QueryClient(); + queryClient.setQueryData(checkoutStatusQueryKey(serverId, selected), { branch: "selected" }); + queryClient.setQueryData(checkoutStatusQueryKey(serverId, legacy), { branch: "legacy" }); + queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, selected), { status: "selected" }); + queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, legacy), { status: "legacy" }); + queryClient.setQueryData(checkoutDiffQueryKey(serverId, selected, "uncommitted"), { + files: ["selected"], + }); + queryClient.setQueryData(checkoutCommitsQueryKey(serverId, selected), { + commits: ["selected"], + }); + + await invalidateCheckoutGitQueriesForClient(queryClient, { serverId, target: selected }); + + expect( + queryClient.getQueryState(checkoutStatusQueryKey(serverId, selected))?.isInvalidated, + ).toBe(true); + expect(queryClient.getQueryState(checkoutStatusQueryKey(serverId, legacy))?.isInvalidated).toBe( + false, + ); + expect( + queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, selected))?.isInvalidated, + ).toBe(true); + expect( + queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, legacy))?.isInvalidated, + ).toBe(false); + expect( + queryClient.getQueryState(checkoutDiffQueryKey(serverId, selected, "uncommitted")) + ?.isInvalidated, + ).toBe(true); + expect( + queryClient.getQueryState(checkoutCommitsQueryKey(serverId, selected))?.isInvalidated, + ).toBe(true); + }); + + it("invalidates one selected workspace without touching a same-cwd sibling", async () => { + const queryClient = new QueryClient(); + for (const selected of [target, sibling]) { + queryClient.setQueryData(checkoutStatusQueryKey(serverId, selected), { isGit: true }); + queryClient.setQueryData(checkoutDiffQueryKey(serverId, selected, "base", "main", true), { + files: [], + }); + queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, selected), { status: null }); + queryClient.setQueryData(checkoutCommitsQueryKey(serverId, selected), { commits: [] }); + queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, ...selected, prNumber: 12 }), { + items: [], + }); + queryClient.setQueryData( + prPanePipelineQueryKey({ serverId, ...selected, pipelineId: 9, changeRequestNumber: 12 }), + { stages: [] }, + ); + } + + await invalidateCheckoutGitQueriesForClient(queryClient, { serverId, target }); + + expect(queryClient.getQueryState(checkoutStatusQueryKey(serverId, target))?.isInvalidated).toBe( + true, + ); + expect( + queryClient.getQueryState(checkoutDiffQueryKey(serverId, target, "base", "main", true)) + ?.isInvalidated, + ).toBe(true); + expect( + queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, target))?.isInvalidated, + ).toBe(true); + expect( + queryClient.getQueryState(checkoutCommitsQueryKey(serverId, target))?.isInvalidated, + ).toBe(true); + expect( + queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, ...target, prNumber: 12 })) + ?.isInvalidated, + ).toBe(true); + expect( + queryClient.getQueryState( + prPanePipelineQueryKey({ serverId, ...target, pipelineId: 9, changeRequestNumber: 12 }), + )?.isInvalidated, + ).toBe(true); + expect( + queryClient.getQueryState(checkoutStatusQueryKey(serverId, sibling))?.isInvalidated, + ).toBe(false); + expect( + queryClient.getQueryState(checkoutCommitsQueryKey(serverId, sibling))?.isInvalidated, + ).toBe(false); + }); + + it("invalidates fetch-based selected queries server-wide but leaves diffs subscription-fed", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(checkoutStatusQueryKey(serverId, target), { isGit: true }); + queryClient.setQueryData(checkoutDiffQueryKey(serverId, target, "base", "main", true), { files: [], }); - queryClient.setQueryData(checkoutStatusQueryKey(otherServerId, cwd), { isGit: true }); + queryClient.setQueryData(checkoutStatusQueryKey("server-2", target), { isGit: true }); await invalidateCheckoutGitQueriesForServer(queryClient, serverId); - expect(queryClient.getQueryState(checkoutStatusQueryKey(serverId, cwd))?.isInvalidated).toBe( + expect(queryClient.getQueryState(checkoutStatusQueryKey(serverId, target))?.isInvalidated).toBe( true, ); expect( - queryClient.getQueryState(checkoutStatusQueryKey(serverId, otherCwd))?.isInvalidated, - ).toBe(true); - expect(queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, cwd))?.isInvalidated).toBe( - true, - ); - expect(queryClient.getQueryState(checkoutCommitsQueryKey(serverId, cwd))?.isInvalidated).toBe( - true, - ); - expect( - queryClient.getQueryState(checkoutCommitsQueryKey(otherServerId, cwd))?.isInvalidated, - ).toBe(false); - expect( - queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 })) - ?.isInvalidated, - ).toBe(true); - expect( - queryClient.getQueryState( - prPanePipelineQueryKey({ serverId, cwd, pipelineId: 9001, changeRequestNumber: 1 }), - )?.isInvalidated, - ).toBe(true); - expect( - queryClient.getQueryState(checkoutDiffQueryKey(serverId, cwd, "base", "main", true)) + queryClient.getQueryState(checkoutDiffQueryKey(serverId, target, "base", "main", true)) ?.isInvalidated, ).toBe(false); expect( - queryClient.getQueryState(checkoutStatusQueryKey(otherServerId, cwd))?.isInvalidated, + queryClient.getQueryState(checkoutStatusQueryKey("server-2", target))?.isInvalidated, ).toBe(false); - - queryClient.clear(); }); }); diff --git a/packages/app/src/git/query-keys.ts b/packages/app/src/git/query-keys.ts index cfd804e459..f925740846 100644 --- a/packages/app/src/git/query-keys.ts +++ b/packages/app/src/git/query-keys.ts @@ -1,14 +1,20 @@ import type { Query, QueryClient } from "@tanstack/react-query"; +import { + workspaceGitQueryIdentitiesEqual, + type WorkspaceGitQueryIdentity, + type WorkspaceGitStatusTarget, + type WorkspaceGitTarget, +} from "./workspace-git"; import { prPanePipelineQueryKind, prPaneTimelineQueryKind } from "./pull-request-panel/query-keys"; interface CheckoutQueryIdentity { serverId: string; - cwd: string; + target: WorkspaceGitTarget; } interface CheckoutQueryScope { serverId: string; - cwd?: string; + target?: WorkspaceGitTarget; } type CheckoutQueryKey = readonly unknown[]; @@ -17,35 +23,47 @@ type CheckoutQueryKey = readonly unknown[]; // can share the same long-lived cache policy. export const COMMIT_FILE_DIFF_STALE_TIME = 5 * 60_000; -export function checkoutStatusQueryKey(serverId: string, cwd: string) { - return ["checkoutStatus", serverId, cwd] as const; +export function checkoutStatusQueryKey(serverId: string, target: WorkspaceGitStatusTarget) { + return ["checkoutStatus", serverId, target.queryIdentity, target.cwd] as const; +} + +export function legacyCheckoutStatusQueryKey(serverId: string, cwd: string) { + return ["legacyCheckoutStatus", serverId, cwd] as const; } export function checkoutDiffQueryKey( serverId: string, - cwd: string, + target: WorkspaceGitTarget, mode: "uncommitted" | "base", baseRef?: string, ignoreWhitespace?: boolean, ) { - return ["checkoutDiff", serverId, cwd, mode, baseRef ?? "", ignoreWhitespace === true] as const; + return [ + "checkoutDiff", + serverId, + target.queryIdentity, + target.cwd, + mode, + baseRef ?? "", + ignoreWhitespace === true, + ] as const; } -export function checkoutPrStatusQueryKey(serverId: string, cwd: string) { - return ["checkoutPrStatus", serverId, cwd] as const; +export function checkoutPrStatusQueryKey(serverId: string, target: WorkspaceGitStatusTarget) { + return ["checkoutPrStatus", serverId, target.queryIdentity, target.cwd] as const; } -export function checkoutCommitsQueryKey(serverId: string, cwd: string) { - return ["checkoutCommits", serverId, cwd] as const; +export function checkoutCommitsQueryKey(serverId: string, target: WorkspaceGitTarget) { + return ["checkoutCommits", serverId, target.queryIdentity, target.cwd] as const; } export function checkoutCommitFileDiffQueryKey( serverId: string, - cwd: string, + target: WorkspaceGitTarget, sha: string, path: string, ) { - return ["checkoutCommitFileDiff", serverId, cwd, sha, path] as const; + return ["checkoutCommitFileDiff", serverId, target.queryIdentity, target.cwd, sha, path] as const; } export async function invalidateCheckoutGitQueriesForClient( @@ -54,7 +72,7 @@ export async function invalidateCheckoutGitQueriesForClient( ) { await Promise.all([ queryClient.invalidateQueries({ - queryKey: checkoutStatusQueryKey(identity.serverId, identity.cwd), + queryKey: checkoutStatusQueryKey(identity.serverId, identity.target), }), queryClient.invalidateQueries({ predicate: checkoutQueryPredicate("checkoutDiff", identity), @@ -63,7 +81,7 @@ export async function invalidateCheckoutGitQueriesForClient( predicate: checkoutQueryPredicate("checkoutPrStatus", identity), }), queryClient.invalidateQueries({ - queryKey: checkoutCommitsQueryKey(identity.serverId, identity.cwd), + queryKey: checkoutCommitsQueryKey(identity.serverId, identity.target), }), queryClient.invalidateQueries({ predicate: checkoutQueryPredicate(prPaneTimelineQueryKind, identity), @@ -119,16 +137,28 @@ function checkoutQueryPredicate( isCheckoutQueryKey(key) && key[0] === queryKind && key[1] === scope.serverId && - (scope.cwd === undefined || key[2] === scope.cwd) + (scope.target === undefined || + (isWorkspaceGitQueryIdentity(key[2]) && + workspaceGitQueryIdentitiesEqual(key[2], scope.target.queryIdentity) && + key[3] === scope.target.cwd)) ); }; } function isCheckoutQueryKey(key: readonly unknown[]): key is CheckoutQueryKey { return ( - key.length >= 3 && + key.length >= 4 && typeof key[0] === "string" && typeof key[1] === "string" && - typeof key[2] === "string" + isWorkspaceGitQueryIdentity(key[2]) && + typeof key[3] === "string" + ); +} + +function isWorkspaceGitQueryIdentity(value: unknown): value is WorkspaceGitQueryIdentity { + return ( + Array.isArray(value) && + (value[0] === "selected" || value[0] === "legacy") && + typeof value[1] === "string" ); } diff --git a/packages/app/src/git/use-actions.tsx b/packages/app/src/git/use-actions.tsx index 63f6c7425f..4bd4186f82 100644 --- a/packages/app/src/git/use-actions.tsx +++ b/packages/app/src/git/use-actions.tsx @@ -29,6 +29,7 @@ import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-ar import { type WorktreeArchiveWarningLabels } from "@/git/worktree-archive-warning"; import { useWorkspaceArchive } from "@/workspace/use-workspace-archive"; import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; +import { useRequiredWorkspaceGit } from "@/git/workspace-git"; export type { GitActionId, GitAction, GitActions } from "@/git/policy"; @@ -317,7 +318,8 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); const [shipDefault, setShipDefault] = useState<"merge" | "pr">("pr"); - const { status, isLoading: isStatusLoading } = useCheckoutStatusQuery({ serverId, cwd }); + const target = useRequiredWorkspaceGit(); + const { status, isLoading: isStatusLoading } = useCheckoutStatusQuery({ serverId }); const gitStatus = status && status.isGit ? status : null; const isGit = Boolean(gitStatus); const notGit = status !== null && !status.isGit && !status.error; @@ -331,7 +333,6 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use forge, } = useCheckoutPrStatusQuery({ serverId, - cwd, enabled: isGit, }); const prIcon = useMemo(() => renderForgePrIcon(forge), [forge]); @@ -395,50 +396,50 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use }, [cwd]); const commitStatus = useCheckoutGitActionsStore((s) => - s.getStatus({ serverId, cwd, actionId: "commit" }), + s.getStatus({ serverId, target, actionId: "commit" }), ); const pullStatus = useCheckoutGitActionsStore((s) => - s.getStatus({ serverId, cwd, actionId: "pull" }), + s.getStatus({ serverId, target, actionId: "pull" }), ); const pushStatus = useCheckoutGitActionsStore((s) => - s.getStatus({ serverId, cwd, actionId: "push" }), + s.getStatus({ serverId, target, actionId: "push" }), ); const pullAndPushStatus = useCheckoutGitActionsStore((s) => - s.getStatus({ serverId, cwd, actionId: "pull-and-push" }), + s.getStatus({ serverId, target, actionId: "pull-and-push" }), ); const prCreateStatus = useCheckoutGitActionsStore((s) => - s.getStatus({ serverId, cwd, actionId: "create-pr" }), + s.getStatus({ serverId, target, actionId: "create-pr" }), ); const mergePrStatuses: Record = { squash: useCheckoutGitActionsStore((s) => - s.getStatus({ serverId, cwd, actionId: "merge-pr-squash" }), + s.getStatus({ serverId, target, actionId: "merge-pr-squash" }), ), merge: useCheckoutGitActionsStore((s) => - s.getStatus({ serverId, cwd, actionId: "merge-pr-merge" }), + s.getStatus({ serverId, target, actionId: "merge-pr-merge" }), ), rebase: useCheckoutGitActionsStore((s) => - s.getStatus({ serverId, cwd, actionId: "merge-pr-rebase" }), + s.getStatus({ serverId, target, actionId: "merge-pr-rebase" }), ), }; const enablePrAutoMergeStatuses: Record = { squash: useCheckoutGitActionsStore((s) => - s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-squash" }), + s.getStatus({ serverId, target, actionId: "enable-pr-auto-merge-squash" }), ), merge: useCheckoutGitActionsStore((s) => - s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-merge" }), + s.getStatus({ serverId, target, actionId: "enable-pr-auto-merge-merge" }), ), rebase: useCheckoutGitActionsStore((s) => - s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-rebase" }), + s.getStatus({ serverId, target, actionId: "enable-pr-auto-merge-rebase" }), ), }; const disablePrAutoMergeStatus = useCheckoutGitActionsStore((s) => - s.getStatus({ serverId, cwd, actionId: "disable-pr-auto-merge" }), + s.getStatus({ serverId, target, actionId: "disable-pr-auto-merge" }), ); const mergeStatus = useCheckoutGitActionsStore((s) => - s.getStatus({ serverId, cwd, actionId: "merge-branch" }), + s.getStatus({ serverId, target, actionId: "merge-branch" }), ); const mergeFromBaseStatus = useCheckoutGitActionsStore((s) => - s.getStatus({ serverId, cwd, actionId: "merge-from-base" }), + s.getStatus({ serverId, target, actionId: "merge-from-base" }), ); const runCommit = useCheckoutGitActionsStore((s) => s.commit); @@ -474,7 +475,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use // Handlers const handleCommit = useCallback(() => { - void runCommit({ serverId, cwd }) + void runCommit({ serverId, target }) .then(() => { toastActionSuccess(t("workspace.git.actions.commit.success")); return; @@ -482,10 +483,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use .catch((err) => { toastActionError(err, t("workspace.git.actions.toasts.failedCommit")); }); - }, [cwd, runCommit, serverId, t, toastActionError, toastActionSuccess]); + }, [runCommit, serverId, t, target, toastActionError, toastActionSuccess]); const handlePull = useCallback(() => { - void runPull({ serverId, cwd }) + void runPull({ serverId, target }) .then(() => { toastActionSuccess(t("workspace.git.actions.pull.success")); return; @@ -493,10 +494,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use .catch((err) => { toastActionError(err, t("workspace.git.actions.toasts.failedPull")); }); - }, [cwd, runPull, serverId, t, toastActionError, toastActionSuccess]); + }, [runPull, serverId, t, target, toastActionError, toastActionSuccess]); const handlePush = useCallback(() => { - void runPush({ serverId, cwd }) + void runPush({ serverId, target }) .then(() => { toastActionSuccess(t("workspace.git.actions.push.success")); return; @@ -504,10 +505,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use .catch((err) => { toastActionError(err, t("workspace.git.actions.toasts.failedPush")); }); - }, [cwd, runPush, serverId, t, toastActionError, toastActionSuccess]); + }, [runPush, serverId, t, target, toastActionError, toastActionSuccess]); const handlePullAndPush = useCallback(() => { - void runPullAndPush({ serverId, cwd }) + void runPullAndPush({ serverId, target }) .then(() => { toastActionSuccess(t("workspace.git.actions.pullAndPush.success")); return; @@ -515,11 +516,11 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use .catch((err) => { toastActionError(err, t("workspace.git.actions.toasts.failedPullAndPush")); }); - }, [cwd, runPullAndPush, serverId, t, toastActionError, toastActionSuccess]); + }, [runPullAndPush, serverId, t, target, toastActionError, toastActionSuccess]); const handleCreatePr = useCallback(() => { void persistShipDefault("pr"); - void runCreatePr({ serverId, cwd }) + void runCreatePr({ serverId, target }) .then(() => { toastActionSuccess(t("workspace.git.actions.createPr.success", forgeVocabulary(forge))); return; @@ -528,12 +529,12 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use toastActionError(err, t("workspace.git.actions.toasts.failedCreatePr")); }); }, [ - cwd, forge, persistShipDefault, runCreatePr, serverId, t, + target, toastActionError, toastActionSuccess, ]); @@ -541,7 +542,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use const handleMergePr = useCallback( (method: CheckoutPrMergeMethod) => { void persistShipDefault("pr"); - void runMergePr({ serverId, cwd, method }) + void runMergePr({ serverId, target, method }) .then(() => { setPostShipArchiveSuggested(true); toastActionSuccess(t("workspace.git.actions.mergePr.success", forgeVocabulary(forge))); @@ -551,13 +552,22 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use toastActionError(err, t("workspace.git.actions.toasts.failedMergePr")); }); }, - [cwd, forge, persistShipDefault, runMergePr, serverId, t, toastActionError, toastActionSuccess], + [ + forge, + persistShipDefault, + runMergePr, + serverId, + t, + target, + toastActionError, + toastActionSuccess, + ], ); const handleEnablePrAutoMerge = useCallback( (method: CheckoutPrMergeMethod) => { void persistShipDefault("pr"); - void runEnablePrAutoMerge({ serverId, cwd, method }) + void runEnablePrAutoMerge({ serverId, target, method }) .then(() => { toastActionSuccess(t("workspace.git.actions.autoMerge.enabled")); return; @@ -567,18 +577,18 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use }); }, [ - cwd, persistShipDefault, runEnablePrAutoMerge, serverId, t, + target, toastActionError, toastActionSuccess, ], ); const handleDisablePrAutoMerge = useCallback(() => { - void runDisablePrAutoMerge({ serverId, cwd }) + void runDisablePrAutoMerge({ serverId, target }) .then(() => { toastActionSuccess(t("workspace.git.actions.autoMerge.disabled")); return; @@ -586,7 +596,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use .catch((err) => { toastActionError(err, t("workspace.git.actions.toasts.failedDisableAutoMerge")); }); - }, [cwd, runDisablePrAutoMerge, serverId, t, toastActionError, toastActionSuccess]); + }, [runDisablePrAutoMerge, serverId, t, target, toastActionError, toastActionSuccess]); const handleMergeBranch = useCallback(() => { if (!baseRef) { @@ -594,7 +604,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use return; } void persistShipDefault("merge"); - void runMergeBranch({ serverId, cwd, baseRef }) + void runMergeBranch({ serverId, target, baseRef }) .then(() => { setPostShipArchiveSuggested(true); toastActionSuccess(t("workspace.git.actions.mergeBranch.success")); @@ -605,11 +615,11 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use }); }, [ baseRef, - cwd, persistShipDefault, runMergeBranch, serverId, t, + target, toast, toastActionError, toastActionSuccess, @@ -620,7 +630,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use toast.error(t("workspace.git.actions.toasts.baseRefUnavailable")); return; } - void runMergeFromBase({ serverId, cwd, baseRef }) + void runMergeFromBase({ serverId, target, baseRef }) .then(() => { toastActionSuccess(t("workspace.git.actions.mergeFromBase.success")); return; @@ -628,7 +638,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use .catch((err) => { toastActionError(err, t("workspace.git.actions.toasts.failedMergeFromBase")); }); - }, [baseRef, cwd, runMergeFromBase, serverId, t, toast, toastActionError, toastActionSuccess]); + }, [baseRef, runMergeFromBase, serverId, t, target, toast, toastActionError, toastActionSuccess]); const archiveController = useWorkspaceScreenArchiveController({ serverId, diff --git a/packages/app/src/git/use-commits-query.ts b/packages/app/src/git/use-commits-query.ts index bb77f50942..188db6909e 100644 --- a/packages/app/src/git/use-commits-query.ts +++ b/packages/app/src/git/use-commits-query.ts @@ -3,8 +3,9 @@ import invariant from "tiny-invariant"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { useFetchQuery } from "@/data/query"; import { checkoutCommitsQueryKey } from "@/git/query-keys"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; +import { useRequiredWorkspaceGit } from "@/git/workspace-git"; // Commits ahead of base change rarely while the section is open; this keeps a // collapse/re-expand cycle warm without leaving the fetch result stale for long. @@ -12,7 +13,6 @@ const CHECKOUT_COMMITS_STALE_TIME = 30_000; interface UseCheckoutCommitsQueryOptions { serverId: string; - cwd: string; enabled?: boolean; } @@ -70,12 +70,11 @@ export function resolveCheckoutCommitsQueryResult({ export function useCheckoutCommitsQuery({ serverId, - cwd, enabled = true, }: UseCheckoutCommitsQueryOptions): CheckoutCommitsQueryResult { const retainedPanelActive = useRetainedPanelActive(); const queryEnabledByCaller = enabled && retainedPanelActive; - const client = useHostRuntimeClient(serverId); + const workspaceGit = useRequiredWorkspaceGit(); const isConnected = useHostRuntimeIsConnected(serverId); // COMPAT(commitsList): added in v0.1.110, remove after 2027-01-16. // COMPAT(commitBaseClassification): added in v0.2.0, remove after 2027-01-23. @@ -86,16 +85,16 @@ export function useCheckoutCommitsQuery({ state.sessions[serverId]?.serverInfo?.features?.commitBaseClassification === true, ); - const canFetch = Boolean(cwd) && Boolean(client) && isConnected; + const canFetch = Boolean(workspaceGit) && isConnected; const queryEnabled = queryEnabledByCaller && capabilityPresent && canFetch; const query = useFetchQuery({ - queryKey: checkoutCommitsQueryKey(serverId, cwd), + queryKey: checkoutCommitsQueryKey(serverId, workspaceGit), queryFn: async () => { - if (!client) { + if (!workspaceGit) { throw new Error("Host disconnected"); } - const data = await client.listCheckoutCommits(cwd); + const data = await workspaceGit.listCommits(); const commits = data.commits.map((commit) => { invariant(commit.isOnBase !== undefined, "Host omitted commit base classification"); return { ...commit, isOnBase: commit.isOnBase }; diff --git a/packages/app/src/git/use-diff-files.ts b/packages/app/src/git/use-diff-files.ts index 14a17edcb1..7151002496 100644 --- a/packages/app/src/git/use-diff-files.ts +++ b/packages/app/src/git/use-diff-files.ts @@ -4,7 +4,8 @@ import { useRetainedPanelActive } from "@/components/retained-panel"; import { useFetchQueries } from "@/data/query"; import { checkoutCommitFileDiffQueryKey, COMMIT_FILE_DIFF_STALE_TIME } from "@/git/query-keys"; import { useCheckoutCommitsQuery } from "@/git/use-commits-query"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useRequiredWorkspaceGit } from "@/git/workspace-git"; /** * Context needed to resolve a commit diff against a host: which daemon @@ -13,7 +14,6 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host- */ export interface CommitDiffFilesContext { serverId: string; - cwd: string; sha: string; enabled?: boolean; } @@ -57,12 +57,12 @@ export function resolveCommitDiffFiles( } export function useCommitDiffFiles(ctx: CommitDiffFilesContext): CommitDiffFilesResult { - const { serverId, cwd, sha, enabled = true } = ctx; + const { serverId, sha, enabled = true } = ctx; + const workspaceGit = useRequiredWorkspaceGit(); const retainedPanelActive = useRetainedPanelActive(); const queryEnabled = enabled && retainedPanelActive; - const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); - const commitsQuery = useCheckoutCommitsQuery({ serverId, cwd, enabled: queryEnabled }); + const commitsQuery = useCheckoutCommitsQuery({ serverId, enabled: queryEnabled }); const commitsData = commitsQuery.status === "loaded" ? commitsQuery.data : null; const commitFiles = useMemo(() => { if (!sha || !commitsData) { @@ -74,18 +74,17 @@ export function useCommitDiffFiles(ctx: CommitDiffFilesContext): CommitDiffFiles const fileDiffsEnabled = queryEnabled && commitsQuery.status === "loaded" && - Boolean(cwd) && + Boolean(workspaceGit) && Boolean(sha) && - Boolean(client) && isConnected; const fileDiffResults = useFetchQueries( commitFiles.map((file) => ({ - queryKey: checkoutCommitFileDiffQueryKey(serverId, cwd, sha, file.path), + queryKey: checkoutCommitFileDiffQueryKey(serverId, workspaceGit, sha, file.path), queryFn: async (): Promise<{ file: ParsedDiffFile | null }> => { - if (!client) { + if (!workspaceGit) { throw new Error("Host disconnected"); } - return client.getCommitFileDiff(cwd, sha, file.path); + return workspaceGit.getCommitFileDiff(sha, file.path); }, enabled: fileDiffsEnabled, staleTimeMs: COMMIT_FILE_DIFF_STALE_TIME, diff --git a/packages/app/src/git/use-diff-query.ts b/packages/app/src/git/use-diff-query.ts index 7002bbfc55..47a99a97c0 100644 --- a/packages/app/src/git/use-diff-query.ts +++ b/packages/app/src/git/use-diff-query.ts @@ -5,10 +5,10 @@ import { checkoutDiffPushRoute } from "@/data/push-router"; import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages"; import { checkoutDiffQueryKey } from "@/git/query-keys"; +import { useRequiredWorkspaceGit } from "@/git/workspace-git"; interface UseCheckoutDiffQueryOptions { serverId: string; - cwd: string; mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean; @@ -41,13 +41,13 @@ function normalizeCheckoutDiffCompare(compare: { export function useCheckoutDiffQuery({ serverId, - cwd, mode, baseRef, ignoreWhitespace, enabled = true, queryScope, }: UseCheckoutDiffQueryOptions) { + const workspaceGit = useRequiredWorkspaceGit(); const retainedPanelActive = useRetainedPanelActive(); const queryEnabled = enabled && retainedPanelActive; const isConnected = useHostRuntimeIsConnected(serverId); @@ -61,16 +61,16 @@ export function useCheckoutDiffQuery({ const queryKey = useMemo(() => { const comparisonKey = checkoutDiffQueryKey( serverId, - cwd, + workspaceGit, compareMode, compareBaseRef, compareIgnoreWhitespace, ); const normalizedScope = queryScope?.trim(); return normalizedScope ? [...comparisonKey, "scope", normalizedScope] : comparisonKey; - }, [serverId, cwd, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]); + }, [serverId, workspaceGit, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]); const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]); - const routeEnabled = Boolean(queryEnabled && isConnected && cwd); + const routeEnabled = Boolean(queryEnabled && isConnected && workspaceGit); const query = useReplicaQuery({ queryKey, @@ -80,7 +80,7 @@ export function useCheckoutDiffQuery({ enabled: routeEnabled, serverId, subscriptionId, - cwd, + workspaceGit, compare: { mode: compareMode, ...(compareBaseRef ? { baseRef: compareBaseRef } : {}), diff --git a/packages/app/src/git/use-forge-search-query.test.ts b/packages/app/src/git/use-forge-search-query.test.ts index 5cc800a9b4..d7119e11ff 100644 --- a/packages/app/src/git/use-forge-search-query.test.ts +++ b/packages/app/src/git/use-forge-search-query.test.ts @@ -1,282 +1,75 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { buildForgeSearchQueryOptions, forgeSearchQueryKey } from "./use-forge-search-query"; -describe("forgeSearchQueryKey", () => { - it("keeps the shared cache key shape for no-kinds searches", () => { - expect(forgeSearchQueryKey("server-1", "/repo", " 123 ")).toEqual([ - "forge-search", - "server-1", - "/repo", - "forge", - "123", - ]); +const target = { + cwd: "/workspace", + queryIdentity: ["selected", "workspace-a"] as const, +}; + +describe("forge search query", () => { + it("keys same-cwd workspaces independently", () => { + expect(forgeSearchQueryKey("server", target, " issue ")).not.toEqual( + forgeSearchQueryKey( + "server", + { ...target, queryIdentity: ["selected", "workspace-b"] }, + "issue", + ), + ); }); - it("adds a deterministic kinds key when kinds are specified", () => { - expect(forgeSearchQueryKey("server-1", "/repo", "123", ["change_request", "issue"])).toEqual([ - "forge-search", - "server-1", - "/repo", - "forge", - "123", - "change_request,issue", - ]); + it("does not alias a selected id that resembles a legacy address", () => { + expect( + forgeSearchQueryKey( + "server", + { cwd: "/shared", queryIdentity: ["selected", "legacy:/shared"] }, + "issue", + ), + ).not.toEqual( + forgeSearchQueryKey( + "server", + { cwd: "/shared", queryIdentity: ["legacy", "/shared"] }, + "issue", + ), + ); }); - it("separates legacy GitHub fallback results from forge search results", () => { - expect(forgeSearchQueryKey("server-1", "/repo", "123", undefined, "github")).toEqual([ - "forge-search", - "server-1", - "/repo", - "github", - "123", - ]); + it("searches through the already-bound workspace capability", async () => { + const searchForge = vi.fn(async () => ({ + items: [], + authState: "authenticated" as const, + error: null, + requestId: "search-1", + })); + const options = buildForgeSearchQueryOptions({ + client: { ...target, searchForge }, + serverId: "server", + query: " issue ", + supportsForgeSearch: true, + enabled: true, + }); + await expect(options.queryFn()).resolves.toMatchObject({ authState: "authenticated" }); + expect(searchForge).toHaveBeenCalledWith({ query: "issue", limit: 20 }); }); -}); -describe("buildForgeSearchQueryOptions", () => { - it("forwards kinds to the forge search request when specified", async () => { - const requests: unknown[] = []; - const query = buildForgeSearchQueryOptions({ - client: { - async searchForge(options) { - requests.push(options); - return { - items: [], - authState: "authenticated", - error: null, - requestId: "request-1", - }; - }, - }, - serverId: "server-1", - cwd: "/repo", - query: " 123 ", + it("uses the bound legacy GitHub method when the forge RPC is unavailable", async () => { + const searchForge = vi.fn(); + const searchGitHub = vi.fn(async () => ({ + items: [], + authState: "authenticated" as const, + featuresEnabled: true, + githubFeaturesEnabled: true, + error: null, + requestId: "search-legacy", + })); + const options = buildForgeSearchQueryOptions({ + client: { ...target, searchForge, searchGitHub }, + serverId: "server", + query: "42", kinds: ["change_request"], enabled: true, }); - - await query.queryFn(); - - expect(requests).toEqual([ - { cwd: "/repo", query: "123", limit: 20, kinds: ["change_request"] }, - ]); - }); - - it("uses the legacy GitHub search request when forge search is unsupported", async () => { - const forgeRequests: unknown[] = []; - const githubRequests: unknown[] = []; - const query = buildForgeSearchQueryOptions({ - client: { - async searchForge(options) { - forgeRequests.push(options); - return { - items: [], - authState: "authenticated", - error: null, - requestId: "forge-request", - }; - }, - async searchGitHub(options) { - githubRequests.push(options); - return { - items: [], - featuresEnabled: true, - authState: "authenticated", - githubFeaturesEnabled: true, - error: null, - requestId: "github-request", - }; - }, - }, - serverId: "server-1", - cwd: "/repo", - query: " 456 ", - kinds: ["issue"], - enabled: true, - supportsForgeSearch: false, - }); - - await query.queryFn(); - - expect(forgeRequests).toEqual([]); - expect(githubRequests).toEqual([ - { cwd: "/repo", query: "456", limit: 20, kinds: ["github-issue"] }, - ]); - }); - - it("normalizes legacy GitHub PR items into neutral change-request items", async () => { - const query = buildForgeSearchQueryOptions({ - client: { - async searchForge() { - throw new Error("unexpected forge search"); - }, - async searchGitHub() { - return { - items: [ - { - kind: "pr" as const, - number: 17, - title: "Fix search", - url: "https://github.com/acme/repo/pull/17", - state: "open", - body: null, - labels: ["bug"], - baseRefName: "main", - headRefName: "fix-search", - }, - ], - featuresEnabled: true, - authState: "authenticated" as const, - githubFeaturesEnabled: true, - error: null, - requestId: "github-request", - }; - }, - }, - serverId: "server-1", - cwd: "/repo", - query: " 456 ", - enabled: true, - supportsForgeSearch: false, - }); - - const result = await query.queryFn(); - - expect(result.items).toEqual([ - { - kind: "change_request", - number: 17, - title: "Fix search", - url: "https://github.com/acme/repo/pull/17", - state: "open", - body: null, - labels: ["bug"], - baseRefName: "main", - headRefName: "fix-search", - }, - ]); - }); - - it("interprets modern search payloads at the query boundary", async () => { - const query = buildForgeSearchQueryOptions({ - client: { - async searchForge() { - return { - items: [ - { - kind: "issue", - number: 23, - title: "Keep this", - url: "https://gitlab.com/acme/repo/-/issues/23", - state: "open", - body: null, - labels: [], - }, - { kind: "future_kind", futureField: true }, - ], - authState: "future_auth_state", - error: null, - requestId: "forge-request", - }; - }, - }, - serverId: "server-1", - cwd: "/repo", - query: "23", - enabled: true, - supportsForgeSearch: true, - }); - - const result = await query.queryFn(); - - expect(result.items).toHaveLength(1); - expect(result.items[0]?.kind).toBe("issue"); - expect(result.authState).toBe("unauthenticated"); - }); - - it("derives legacy search auth from legacy feature flags", async () => { - const query = buildForgeSearchQueryOptions({ - client: { - async searchForge() { - throw new Error("unexpected forge search"); - }, - async searchGitHub() { - return { - items: [], - githubFeaturesEnabled: false, - error: null, - requestId: "github-request", - }; - }, - }, - serverId: "server-1", - cwd: "/repo", - query: "23", - enabled: true, - supportsForgeSearch: false, - }); - - expect((await query.queryFn()).authState).toBe("unauthenticated"); - }); - - it("invokes forge search bound to the client so this-dependent methods work", async () => { - const client = new ThisDependentSearchClient(); - - const query = buildForgeSearchQueryOptions({ - client, - serverId: "server-1", - cwd: "/repo", - query: " 789 ", - enabled: true, - supportsForgeSearch: true, - }); - - const result = await query.queryFn(); - - expect(result.requestId).toBe("forge.search.request"); - expect(client.requests).toEqual([{ cwd: "/repo", query: "789", limit: 20 }]); - }); - - it("invokes the legacy GitHub search bound to the client", async () => { - const client = new ThisDependentSearchClient(); - - const query = buildForgeSearchQueryOptions({ - client, - serverId: "server-1", - cwd: "/repo", - query: " 789 ", - enabled: true, - supportsForgeSearch: false, - }); - - const result = await query.queryFn(); - - expect(result.requestId).toBe("github_search_request"); - expect(client.requests).toEqual([{ cwd: "/repo", query: "789", limit: 20 }]); + await options.queryFn(); + expect(searchGitHub).toHaveBeenCalledWith({ query: "42", limit: 20, kinds: ["github-pr"] }); + expect(searchForge).not.toHaveBeenCalled(); }); }); - -class ThisDependentSearchClient { - readonly requests: unknown[] = []; - - private send(requestId: string, options: { cwd: string; query: string; limit?: number }) { - this.requests.push(options); - return Promise.resolve({ - items: [], - featuresEnabled: true, - authState: "authenticated" as const, - githubFeaturesEnabled: true, - error: null, - requestId, - }); - } - - async searchForge(options: { cwd: string; query: string; limit?: number }) { - return this.send("forge.search.request", options); - } - - async searchGitHub(options: { cwd: string; query: string; limit?: number }) { - return this.send("github_search_request", options); - } -} diff --git a/packages/app/src/git/use-forge-search-query.ts b/packages/app/src/git/use-forge-search-query.ts index 747dd033f8..7a6b6923b0 100644 --- a/packages/app/src/git/use-forge-search-query.ts +++ b/packages/app/src/git/use-forge-search-query.ts @@ -11,6 +11,8 @@ import { import { i18n } from "@/i18n/i18next"; import { useFetchQuery } from "@/data/query"; import { parseForgeAuthState } from "@/git/forge"; +import type { WorkspaceGitClient, WorkspaceGitReadClient } from "@/git/workspace-git"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; export const FORGE_SEARCH_STALE_TIME = 30_000; @@ -22,36 +24,27 @@ export interface ForgeSearchPayload { } interface ForgeSearchOptions { - cwd: string; query: string; limit?: number; kinds?: ForgeSearchKind[]; } interface LegacyGitHubSearchOptions { - cwd: string; query: string; limit?: number; kinds?: LegacyGitHubSearchKind[]; } -export interface ForgeSearchClient { - searchForge: ( - options: ForgeSearchOptions, - requestId?: string, - ) => Promise; - searchGitHub?: ( - options: LegacyGitHubSearchOptions, - requestId?: string, - ) => Promise; -} +type ForgeSearchTarget = Pick; +export type ForgeSearchClient = ForgeSearchTarget & + Pick & + Partial>; type LegacyGitHubSearchKind = "github-issue" | "github-pr"; interface ForgeSearchQueryInput { client: ForgeSearchClient | null; serverId: string; - cwd: string; query: string; kinds?: ForgeSearchKind[]; enabled: boolean; @@ -59,42 +52,53 @@ interface ForgeSearchQueryInput { hostDisconnectedMessage?: string; } +interface LegacyForgeSearchQueryInput extends Omit { + client: Pick | null; + cwd: string; +} + export function forgeSearchQueryKey( serverId: string, - cwd: string, + target: ForgeSearchTarget, query: string, kinds?: ForgeSearchKind[], transport: "forge" | "github" = "forge", ) { const trimmedQuery = query.trim(); + const queryIdentity = forgeSearchClientIdentity(target); if (!kinds) { - return ["forge-search", serverId, cwd, transport, trimmedQuery] as const; + return ["forge-search", serverId, queryIdentity, target.cwd, transport, trimmedQuery] as const; } return [ "forge-search", serverId, - cwd, + queryIdentity, + target.cwd, transport, trimmedQuery, [...kinds].sort().join(","), ] as const; } +export function forgeSearchClientIdentity(client: ForgeSearchTarget) { + return client.queryIdentity; +} + export function buildForgeSearchQueryOptions(input: ForgeSearchQueryInput) { const query = input.query.trim(); const transport = input.supportsForgeSearch === true ? "forge" : "github"; return { - queryKey: forgeSearchQueryKey(input.serverId, input.cwd, query, input.kinds, transport), + queryKey: input.client + ? forgeSearchQueryKey(input.serverId, input.client, query, input.kinds, transport) + : (["forge-search", input.serverId, "unbound", transport, query] as const), queryFn: async (): Promise => { if (!input.client) { throw new Error( input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"), ); } - const request = input.kinds - ? { cwd: input.cwd, query, limit: 20, kinds: input.kinds } - : { cwd: input.cwd, query, limit: 20 }; + const request = input.kinds ? { query, limit: 20, kinds: input.kinds } : { query, limit: 20 }; // COMPAT(githubSearchRpc): added in v0.1.106, remove after 2026-12-28 once // clients use forge.search.*. if (transport === "github" && input.client.searchGitHub) { @@ -153,7 +157,7 @@ function toLegacyGitHubSearchKind(kind: ForgeSearchKind): LegacyGitHubSearchKind function toLegacyGitHubSearchRequest(request: ForgeSearchOptions): LegacyGitHubSearchOptions { if (!request.kinds) { - return { cwd: request.cwd, query: request.query, limit: request.limit }; + return { query: request.query, limit: request.limit }; } return { ...request, @@ -161,6 +165,48 @@ function toLegacyGitHubSearchRequest(request: ForgeSearchOptions): LegacyGitHubS }; } +export function buildLegacyForgeSearchQueryOptions(input: LegacyForgeSearchQueryInput) { + const query = input.query.trim(); + const transport = input.supportsForgeSearch === true ? "forge" : "github"; + return { + queryKey: ["legacy-forge-search", input.serverId, input.cwd, transport, query] as const, + queryFn: async (): Promise => { + if (!input.client) { + throw new Error( + input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"), + ); + } + const request = input.kinds + ? { cwd: input.cwd, query, limit: 20, kinds: input.kinds } + : { cwd: input.cwd, query, limit: 20 }; + if (transport === "github") { + return normalizeLegacyGitHubSearchPayload( + await input.client.searchGitHub({ + cwd: input.cwd, + query, + limit: 20, + kinds: input.kinds?.map(toLegacyGitHubSearchKind), + }), + ); + } + return normalizeForgeSearchPayload(await input.client.searchForge(request)); + }, + enabled: input.enabled && Boolean(input.client), + dataShape: "list" as const, + staleTimeMs: FORGE_SEARCH_STALE_TIME, + }; +} + +export function useLegacyForgeSearchQuery(input: LegacyForgeSearchQueryInput) { + const { t } = useTranslation(); + return useFetchQuery( + buildLegacyForgeSearchQueryOptions({ + ...input, + hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), + }), + ); +} + export function useForgeSearchQuery(input: ForgeSearchQueryInput) { const { t } = useTranslation(); return useFetchQuery( diff --git a/packages/app/src/git/use-pr-status-query.ts b/packages/app/src/git/use-pr-status-query.ts index e8e4bf2b14..f1256bc771 100644 --- a/packages/app/src/git/use-pr-status-query.ts +++ b/packages/app/src/git/use-pr-status-query.ts @@ -1,14 +1,14 @@ import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { checkoutPrStatusQueryKey } from "@/git/query-keys"; import { normalizeForge } from "@/git/forge"; import { selectPrHintFromStatus, type PrHint } from "@/git/pr-hint"; import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status"; +import { useWorkspaceGit } from "@/git/workspace-git"; interface UseCheckoutPrStatusQueryOptions { serverId: string; - cwd: string; enabled?: boolean; } @@ -21,22 +21,23 @@ function selectWorkspacePrHint(payload: CheckoutPrStatusPayload): PrHint | null export function useCheckoutPrStatusQuery({ serverId, - cwd, enabled = true, }: UseCheckoutPrStatusQueryOptions) { const { t } = useTranslation(); - const client = useHostRuntimeClient(serverId); + const workspaceGit = useWorkspaceGit(); const isConnected = useHostRuntimeIsConnected(serverId); const query = useQuery({ - queryKey: checkoutPrStatusQueryKey(serverId, cwd), + queryKey: workspaceGit + ? checkoutPrStatusQueryKey(serverId, workspaceGit) + : (["checkoutPrStatus", serverId, "unavailable"] as const), queryFn: async () => { - if (!client) { + if (!workspaceGit) { throw new Error(t("common.errors.daemonClientUnavailable")); } - return normalizeCheckoutPrStatusPayload(await client.checkoutPrStatus(cwd)); + return normalizeCheckoutPrStatusPayload(await workspaceGit.getPrStatus()); }, - enabled: !!client && isConnected && !!cwd && enabled, + enabled: isConnected && enabled && workspaceGit !== null, staleTime: Infinity, // Refetch on mount only after explicit invalidation (e.g. reconnect) — see // useCheckoutStatusQuery for the rationale. @@ -63,22 +64,23 @@ export function useCheckoutPrStatusQuery({ export function useWorkspacePrHint({ serverId, - cwd, enabled = true, }: UseCheckoutPrStatusQueryOptions): PrHint | null { const { t } = useTranslation(); - const client = useHostRuntimeClient(serverId); + const workspaceGit = useWorkspaceGit(); const isConnected = useHostRuntimeIsConnected(serverId); const query = useQuery({ - queryKey: checkoutPrStatusQueryKey(serverId, cwd), + queryKey: workspaceGit + ? checkoutPrStatusQueryKey(serverId, workspaceGit) + : (["checkoutPrStatus", serverId, "unavailable"] as const), queryFn: async () => { - if (!client) { + if (!workspaceGit) { throw new Error(t("common.errors.daemonClientUnavailable")); } - return normalizeCheckoutPrStatusPayload(await client.checkoutPrStatus(cwd)); + return normalizeCheckoutPrStatusPayload(await workspaceGit.getPrStatus()); }, - enabled: !!client && isConnected && !!cwd && enabled, + enabled: isConnected && enabled && workspaceGit !== null, staleTime: Infinity, // Refetch on mount only after explicit invalidation (e.g. reconnect) — see // useCheckoutStatusQuery for the rationale. diff --git a/packages/app/src/git/use-status-query.ts b/packages/app/src/git/use-status-query.ts index 0cc0d7f93c..ac84e96da0 100644 --- a/packages/app/src/git/use-status-query.ts +++ b/packages/app/src/git/use-status-query.ts @@ -1,8 +1,9 @@ import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { checkoutStatusQueryKey } from "@/git/query-keys"; +import { checkoutStatusQueryKey, legacyCheckoutStatusQueryKey } from "@/git/query-keys"; import { fetchCheckoutStatus } from "./checkout-status-cache"; +import { useWorkspaceGit } from "./workspace-git"; export type { CheckoutStatusPayload } from "./checkout-status-cache"; @@ -10,23 +11,25 @@ export const CHECKOUT_STATUS_STALE_TIME = 15_000; interface UseCheckoutStatusQueryOptions { serverId: string; - cwd: string; } -export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) { - const { t } = useTranslation(); - const client = useHostRuntimeClient(serverId); +export function useCheckoutStatusQuery({ serverId }: UseCheckoutStatusQueryOptions) { + const workspaceGit = useWorkspaceGit(); const isConnected = useHostRuntimeIsConnected(serverId); const query = useQuery({ - queryKey: checkoutStatusQueryKey(serverId, cwd), + queryKey: workspaceGit + ? checkoutStatusQueryKey(serverId, workspaceGit) + : (["checkoutStatus", serverId, "unavailable"] as const), queryFn: async () => { - if (!client) { - throw new Error(t("common.errors.daemonClientUnavailable")); - } - return await fetchCheckoutStatus({ client, serverId, cwd }); + if (!workspaceGit) throw new Error("Workspace Git is unavailable"); + return await fetchCheckoutStatus({ + client: workspaceGit, + serverId, + target: workspaceGit, + }); }, - enabled: !!client && isConnected && !!cwd, + enabled: isConnected && workspaceGit !== null, staleTime: Infinity, // Freshness is push-driven (checkout_status_update applied globally); with // staleTime: Infinity, refetchOnMount only fires after an explicit invalidation @@ -50,19 +53,44 @@ export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQuery * initiating a fetch. Useful for list rows where a parent component prefetches * only the visible agents. */ -export function useCheckoutStatusCacheOnly({ serverId, cwd }: UseCheckoutStatusQueryOptions) { - const { t } = useTranslation(); - const client = useHostRuntimeClient(serverId); +export function useCheckoutStatusCacheOnly({ serverId }: UseCheckoutStatusQueryOptions) { + const workspaceGit = useWorkspaceGit(); return useQuery({ - queryKey: checkoutStatusQueryKey(serverId, cwd), + queryKey: workspaceGit + ? checkoutStatusQueryKey(serverId, workspaceGit) + : (["checkoutStatus", serverId, "unavailable"] as const), queryFn: async () => { - if (!client) { - throw new Error(t("common.errors.daemonClientUnavailable")); - } - return await fetchCheckoutStatus({ client, serverId, cwd }); + if (!workspaceGit) throw new Error("Workspace Git is unavailable"); + return await fetchCheckoutStatus({ + client: workspaceGit, + serverId, + target: workspaceGit, + }); }, enabled: false, staleTime: CHECKOUT_STATUS_STALE_TIME, }); } + +export function useLegacyCheckoutStatusQuery({ serverId, cwd }: { serverId: string; cwd: string }) { + const { t } = useTranslation(); + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); + const query = useQuery({ + queryKey: legacyCheckoutStatusQueryKey(serverId, cwd), + queryFn: async () => { + if (!client) throw new Error(t("common.errors.daemonClientUnavailable")); + return await client.getCheckoutStatus(cwd); + }, + enabled: !!client && isConnected && !!cwd, + staleTime: Infinity, + }); + return { + status: query.data ?? null, + isLoading: query.isLoading, + isFetching: query.isFetching, + isError: query.isError, + error: query.error, + }; +} diff --git a/packages/app/src/git/use-working-diff.ts b/packages/app/src/git/use-working-diff.ts index 494a0f95a2..cf6f99896c 100644 --- a/packages/app/src/git/use-working-diff.ts +++ b/packages/app/src/git/use-working-diff.ts @@ -16,7 +16,7 @@ import { useCheckoutStatusQuery } from "@/git/use-status-query"; interface UseWorkingDiffOptions { serverId: string; - workspaceId?: string; + workspaceId: string; cwd: string; ignoreWhitespace: boolean; enabled: boolean; @@ -36,7 +36,7 @@ export function useWorkingDiff({ isLoading: isStatusLoading, isError: isStatusError, error: statusError, - } = useCheckoutStatusQuery({ serverId, cwd }); + } = useCheckoutStatusQuery({ serverId }); const gitStatus = status && status.isGit ? status : null; const isGit = Boolean(gitStatus); const notGit = status !== null && !status.isGit && !status.error; @@ -83,7 +83,6 @@ export function useWorkingDiff({ isLoading: isDiffLoading, } = useCheckoutDiffQuery({ serverId, - cwd, mode: diffMode, baseRef, ignoreWhitespace, diff --git a/packages/app/src/git/workspace-actions.tsx b/packages/app/src/git/workspace-actions.tsx index f5241b19a2..6e87283781 100644 --- a/packages/app/src/git/workspace-actions.tsx +++ b/packages/app/src/git/workspace-actions.tsx @@ -4,6 +4,7 @@ import { GIT_ACTION_ICONS } from "@/git/action-icons"; interface WorkspaceActionsProps { serverId: string; + workspaceId: string; cwd: string; hideLabels?: boolean; } diff --git a/packages/app/src/git/workspace-git-owner.browser.test.tsx b/packages/app/src/git/workspace-git-owner.browser.test.tsx new file mode 100644 index 0000000000..9d5a3d0fdd --- /dev/null +++ b/packages/app/src/git/workspace-git-owner.browser.test.tsx @@ -0,0 +1,79 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import React, { useCallback } from "react"; +import { afterEach, expect, test } from "vitest"; +import { PreWorkspaceComposerGitOwner, useWorkspaceGit } from "./workspace-git"; + +afterEach(cleanup); + +function createLegacyGitPort() { + const statusCwds: string[] = []; + return { + client: { + async getCheckoutStatus(cwd: string) { + statusCwds.push(cwd); + return { cwd }; + }, + async checkoutPrStatus(cwd: string) { + return { cwd }; + }, + async searchForge() { + return { items: [], authState: "unknown", error: null }; + }, + async searchGitHub() { + return { items: [], error: null }; + }, + }, + statusCwds, + }; +} + +function SharedComposerGitConsumer() { + const workspaceGit = useWorkspaceGit(); + const fetchStatus = useCallback(() => { + void workspaceGit?.getStatus(); + }, [workspaceGit]); + return ( + + ); +} + +test.each(["terminal", "chat"])( + "the new-workspace %s Composer production owner supplies its legacy cwd", + async () => { + const port = createLegacyGitPort(); + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "/draft/source" })); + await waitFor(() => expect(port.statusCwds).toEqual(["/draft/source"])); + }, +); + +test("the new-workspace Composer production owner represents no cwd without crashing", () => { + const port = createLegacyGitPort(); + render( + + + , + ); + + expect(screen.getByRole("button", { name: "Git unavailable" })).toBeVisible(); + expect(port.statusCwds).toEqual([]); +}); + +test("the workspace-setup Composer production owner supplies its source directory", async () => { + const port = createLegacyGitPort(); + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "/setup/source" })); + await waitFor(() => expect(port.statusCwds).toEqual(["/setup/source"])); +}); diff --git a/packages/app/src/git/workspace-git.test.ts b/packages/app/src/git/workspace-git.test.ts new file mode 100644 index 0000000000..62cd1145bc --- /dev/null +++ b/packages/app/src/git/workspace-git.test.ts @@ -0,0 +1,71 @@ +import { expect, test, vi } from "vitest"; +import { bindLegacyWorkspaceGit, bindSelectedWorkspaceGit } from "./workspace-git"; + +test.each(["", " "])( + "the app selected Git boundary rejects workspaceId %j before binding a daemon client", + (workspaceId) => { + const bindWorkspaceGit = vi.fn(); + + expect(() => + bindSelectedWorkspaceGit( + { bindWorkspaceGit }, + { kind: "selected", workspaceId, cwd: "/shared" }, + ), + ).toThrow("workspaceId is required for selected workspace Git"); + expect(bindWorkspaceGit).not.toHaveBeenCalled(); + }, +); + +test.each(["", " "])( + "the app selected Git boundary rejects cwd %j before binding a daemon client", + (cwd) => { + const bindWorkspaceGit = vi.fn(); + + expect(() => + bindSelectedWorkspaceGit( + { bindWorkspaceGit }, + { kind: "selected", workspaceId: "workspace-a", cwd }, + ), + ).toThrow("cwd is required for selected workspace Git"); + expect(bindWorkspaceGit).not.toHaveBeenCalled(); + }, +); + +test("the app selected Git boundary normalizes its complete address once", () => { + const bindWorkspaceGit = vi.fn(() => ({ workspaceId: "workspace-a", cwd: "/shared" })); + + const workspaceGit = bindSelectedWorkspaceGit({ bindWorkspaceGit } as never, { + kind: "selected", + workspaceId: " workspace-a ", + cwd: " /shared ", + }); + + expect(bindWorkspaceGit).toHaveBeenCalledWith({ workspaceId: "workspace-a", cwd: "/shared" }); + expect(workspaceGit).toMatchObject({ + workspaceId: "workspace-a", + cwd: "/shared", + queryIdentity: ["selected", "workspace-a"], + }); +}); + +test("selected and legacy capabilities own structurally distinct query identities", () => { + const selected = bindSelectedWorkspaceGit( + { + bindWorkspaceGit: () => ({ workspaceId: "legacy:/shared", cwd: "/shared" }), + } as never, + { kind: "selected", workspaceId: "legacy:/shared", cwd: "/shared" }, + ); + const legacy = bindLegacyWorkspaceGit( + { + checkoutPrStatus: vi.fn(), + getCheckoutStatus: vi.fn(), + searchForge: vi.fn(), + searchGitHub: vi.fn(), + }, + { kind: "legacy", cwd: "/shared" }, + ); + + expect(selected.queryIdentity).toEqual(["selected", "legacy:/shared"]); + expect(legacy.queryIdentity).toEqual(["legacy", "/shared"]); + expect(selected.queryIdentity).not.toEqual(legacy.queryIdentity); +}); diff --git a/packages/app/src/git/workspace-git.ts b/packages/app/src/git/workspace-git.ts new file mode 100644 index 0000000000..0ec9304165 --- /dev/null +++ b/packages/app/src/git/workspace-git.ts @@ -0,0 +1,168 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { createContext, createElement, type ReactNode, useContext, useMemo } from "react"; + +export interface SelectedWorkspaceGitAddress { + readonly kind: "selected"; + workspaceId: string; + cwd: string; +} + +export interface LegacyWorkspaceGitAddress { + readonly kind: "legacy"; + cwd: string; +} + +export type WorkspaceGitQueryIdentity = + | readonly [kind: "selected", workspaceId: string] + | readonly [kind: "legacy", cwd: string]; + +export function workspaceGitQueryIdentitiesEqual( + left: WorkspaceGitQueryIdentity, + right: WorkspaceGitQueryIdentity, +): boolean { + return left[0] === right[0] && left[1] === right[1]; +} + +type WorkspaceGitBinder = Pick; +type LegacyWorkspaceGitBinder = Pick< + DaemonClient, + "checkoutPrStatus" | "getCheckoutStatus" | "searchForge" | "searchGitHub" +>; + +export function bindSelectedWorkspaceGit( + client: WorkspaceGitBinder, + address: SelectedWorkspaceGitAddress, +) { + const workspaceId = address.workspaceId.trim(); + if (workspaceId.length === 0) { + throw new Error("workspaceId is required for selected workspace Git"); + } + const cwd = address.cwd.trim(); + if (cwd.length === 0) { + throw new Error("cwd is required for selected workspace Git"); + } + return Object.assign(client.bindWorkspaceGit({ workspaceId, cwd }), { + kind: "selected" as const, + workspaceId, + cwd, + queryIdentity: ["selected", workspaceId] as const, + }); +} + +export type WorkspaceGitClient = ReturnType; +export type WorkspaceGitReadClient = Readonly< + Pick & { + queryIdentity: WorkspaceGitQueryIdentity; + } +>; +export type WorkspaceGitTarget = Readonly< + Pick +>; +export type WorkspaceGitStatusTarget = Readonly< + Pick +>; + +export function bindLegacyWorkspaceGit( + client: LegacyWorkspaceGitBinder, + address: LegacyWorkspaceGitAddress, +): WorkspaceGitReadClient & { readonly kind: "legacy" } { + const cwd = address.cwd.trim(); + if (cwd.length === 0) { + throw new Error("cwd is required for legacy workspace Git"); + } + return { + kind: "legacy", + cwd, + queryIdentity: ["legacy", cwd], + getPrStatus: () => client.checkoutPrStatus(cwd), + getStatus: () => client.getCheckoutStatus(cwd), + searchForge: (options, requestId) => client.searchForge({ cwd, ...options }, requestId), + searchGitHub: (options, requestId) => client.searchGitHub({ cwd, ...options }, requestId), + }; +} + +export function PreWorkspaceComposerGitOwner({ + client, + cwd, + children, +}: { + client: (WorkspaceGitBinder & LegacyWorkspaceGitBinder) | null; + cwd: string | null; + children?: ReactNode; +}) { + const address = useMemo(() => (cwd === null ? null : ({ kind: "legacy", cwd } as const)), [cwd]); + return createElement(WorkspaceGitOwner, { client, address }, children); +} + +type LegacyWorkspaceGitClient = ReturnType; +type WorkspaceGitCapability = WorkspaceGitClient | LegacyWorkspaceGitClient | null; +const MISSING_WORKSPACE_GIT_OWNER = Symbol("missing-workspace-git-owner"); +const WorkspaceGitContext = createContext< + WorkspaceGitCapability | typeof MISSING_WORKSPACE_GIT_OWNER +>(MISSING_WORKSPACE_GIT_OWNER); + +export function useBoundWorkspaceGit( + client: (WorkspaceGitBinder & LegacyWorkspaceGitBinder) | null, + address: SelectedWorkspaceGitAddress | null, +): WorkspaceGitClient | null; +export function useBoundWorkspaceGit( + client: (WorkspaceGitBinder & LegacyWorkspaceGitBinder) | null, + address: LegacyWorkspaceGitAddress | null, +): LegacyWorkspaceGitClient | null; +export function useBoundWorkspaceGit( + client: (WorkspaceGitBinder & LegacyWorkspaceGitBinder) | null, + address: SelectedWorkspaceGitAddress | LegacyWorkspaceGitAddress | null, +): WorkspaceGitCapability; +export function useBoundWorkspaceGit( + client: (WorkspaceGitBinder & LegacyWorkspaceGitBinder) | null, + address: SelectedWorkspaceGitAddress | LegacyWorkspaceGitAddress | null, +): WorkspaceGitCapability { + return useMemo(() => { + if (!client || !address) return null; + return address.kind === "selected" + ? bindSelectedWorkspaceGit(client, address) + : bindLegacyWorkspaceGit(client, address); + }, [address, client]); +} + +export function WorkspaceGitBoundary({ + workspaceGit, + children, +}: { + workspaceGit: WorkspaceGitCapability; + children?: ReactNode; +}) { + return createElement(WorkspaceGitContext.Provider, { value: workspaceGit }, children); +} + +export function WorkspaceGitOwner({ + client, + address, + children, +}: { + client: (WorkspaceGitBinder & LegacyWorkspaceGitBinder) | null; + address: SelectedWorkspaceGitAddress | LegacyWorkspaceGitAddress | null; + children?: ReactNode; +}) { + const workspaceGit = useBoundWorkspaceGit(client, address); + return createElement(WorkspaceGitBoundary, { workspaceGit }, children); +} + +export function useWorkspaceGit(): WorkspaceGitReadClient | null { + const workspaceGit = useContext(WorkspaceGitContext); + if (workspaceGit === MISSING_WORKSPACE_GIT_OWNER) { + throw new Error("Workspace Git owner is missing"); + } + return workspaceGit; +} + +export function useRequiredWorkspaceGit(): WorkspaceGitClient { + const workspaceGit = useContext(WorkspaceGitContext); + if (workspaceGit === MISSING_WORKSPACE_GIT_OWNER) { + throw new Error("Workspace Git owner is missing"); + } + if (!workspaceGit || workspaceGit.kind !== "selected") { + throw new Error("Selected workspace Git is not bound"); + } + return workspaceGit; +} diff --git a/packages/app/src/hooks/use-branch-switcher.ts b/packages/app/src/hooks/use-branch-switcher.ts index e8e33b7f25..a73f6a793e 100644 --- a/packages/app/src/hooks/use-branch-switcher.ts +++ b/packages/app/src/hooks/use-branch-switcher.ts @@ -1,18 +1,15 @@ import { useState, useCallback, useMemo } from "react"; import { useQuery, type QueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { ComboboxOption } from "@/components/ui/combobox"; import type { ToastApi } from "@/components/toast-host"; import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys"; import { createBranchSwitcherOperations } from "@/git/branch-switcher-operations"; import { confirmDialog } from "@/utils/confirm-dialog"; +import { useRequiredWorkspaceGit } from "@/git/workspace-git"; interface UseBranchSwitcherInput { - client: DaemonClient | null; normalizedServerId: string; - normalizedWorkspaceId: string; - workspaceDirectory: string | null; currentBranchName: string | null; isGitCheckout: boolean; isConnected: boolean; @@ -29,10 +26,7 @@ interface UseBranchSwitcherResult { } export function useBranchSwitcher({ - client, normalizedServerId, - normalizedWorkspaceId, - workspaceDirectory, currentBranchName, isGitCheckout, isConnected, @@ -40,20 +34,15 @@ export function useBranchSwitcher({ queryClient, }: UseBranchSwitcherInput): UseBranchSwitcherResult { const { t } = useTranslation(); + const workspaceGit = useRequiredWorkspaceGit(); const [isOpen, setIsOpen] = useState(false); // Git operations are bound to the workspace directory; the opaque workspace id is // used only for query cache identity below, never as a cwd. - const operations = useMemo( - () => - client && workspaceDirectory - ? createBranchSwitcherOperations(client, workspaceDirectory) - : null, - [client, workspaceDirectory], - ); + const operations = useMemo(() => createBranchSwitcherOperations(workspaceGit), [workspaceGit]); const branchSuggestionsQuery = useQuery({ - queryKey: ["branchSuggestions", normalizedServerId, normalizedWorkspaceId], + queryKey: ["branchSuggestions", normalizedServerId, workspaceGit.workspaceId], queryFn: async () => { if (!operations) { throw new Error(t("common.errors.daemonClientUnavailable")); @@ -75,20 +64,19 @@ export function useBranchSwitcher({ }, [branchSuggestionsQuery.data]); const stashListQueryKey = useMemo( - () => ["stashList", normalizedServerId, normalizedWorkspaceId] as const, - [normalizedServerId, normalizedWorkspaceId], + () => ["stashList", normalizedServerId, workspaceGit.workspaceId] as const, + [normalizedServerId, workspaceGit.workspaceId], ); const invalidateStashAndCheckout = useCallback(async () => { - if (!workspaceDirectory) return; await Promise.all([ queryClient.invalidateQueries({ queryKey: stashListQueryKey }), invalidateCheckoutGitQueriesForClient(queryClient, { serverId: normalizedServerId, - cwd: workspaceDirectory, + target: workspaceGit, }), ]); - }, [queryClient, stashListQueryKey, normalizedServerId, workspaceDirectory]); + }, [queryClient, stashListQueryKey, normalizedServerId, workspaceGit]); const maybeRestoreStashForBranch = useCallback( async (branchId: string) => { diff --git a/packages/app/src/panels/diff-panel.tsx b/packages/app/src/panels/diff-panel.tsx index f315dd5bca..42cdb79f27 100644 --- a/packages/app/src/panels/diff-panel.tsx +++ b/packages/app/src/panels/diff-panel.tsx @@ -20,6 +20,7 @@ import { import { DiffTooLargeState } from "@/git/diff-too-large-state"; import { useCommitDiffFiles } from "@/git/use-diff-files"; import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff"; +import { useRequiredWorkspaceGit } from "@/git/workspace-git"; import { useChangesPreferences } from "@/hooks/use-changes-preferences"; import { useAppSettings } from "@/hooks/use-settings"; import { usePaneContext } from "@/panels/pane-context"; @@ -155,6 +156,7 @@ function WorkingDiffPanel() { const isConnected = useHostRuntimeIsConnected(serverId); const isActive = useRetainedPanelActive(); const panelPreferences = useDiffPanelPreferences(); + const workspaceGit = useRequiredWorkspaceGit(); const [expandedPaths, setExpandedPaths] = useState(null); invariant(target.kind === "working_diff", "WorkingDiffPanel requires working_diff target"); @@ -180,16 +182,20 @@ function WorkingDiffPanel() { const runRefresh = useCheckoutGitActionsStore((state) => state.refresh); const isRefreshing = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd: cwd ?? "", actionId: "refresh" }), + state.getStatus({ + serverId, + target: workspaceGit, + actionId: "refresh", + }), ) === "pending"; const refresh = useCallback(() => { if (!cwd || isRefreshing) { return; } - void runRefresh({ serverId, cwd }).catch((error) => { + void runRefresh({ serverId, target: workspaceGit }).catch((error) => { toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh")); }); - }, [cwd, isRefreshing, runRefresh, serverId, t, toast]); + }, [cwd, isRefreshing, runRefresh, serverId, t, toast, workspaceGit]); const expandedPathSet = useMemo( () => (expandedPaths === null ? null : new Set(expandedPaths)), @@ -276,7 +282,6 @@ function CommitDiffPanel() { invariant(target.kind === "commit_diff", "CommitDiffPanel requires commit_diff target"); const { files, isLoading, error, capabilityMissing } = useCommitDiffFiles({ serverId, - cwd: cwd ?? "", sha: target.sha, enabled: Boolean(cwd), }); diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index d887fa1287..f31de70f7c 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -30,9 +30,10 @@ import { ScreenHeader } from "@/components/headers/screen-header"; import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { useToast } from "@/contexts/toast-context"; import { useAgentInputDraft } from "@/composer/draft/input-draft"; -import { useForgeSearchQuery } from "@/git/use-forge-search-query"; -import { useCheckoutStatusQuery } from "@/git/use-status-query"; -import { ensureCheckoutStatus } from "@/git/checkout-status-cache"; +import { useLegacyForgeSearchQuery } from "@/git/use-forge-search-query"; +import { useLegacyCheckoutStatusQuery } from "@/git/use-status-query"; +import { ensureLegacyCheckoutStatus } from "@/git/checkout-status-cache"; +import { PreWorkspaceComposerGitOwner } from "@/git/workspace-git"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { resolveTerminalProfiles } from "@getpaseo/protocol/terminal-profiles"; import type { TerminalProfile } from "@getpaseo/protocol/messages"; @@ -1717,7 +1718,7 @@ export function NewWorkspaceScreen({ const hasSelectedSourceDirectory = selectedSourceDirectory !== null; const pickerQueryEnabled = pickerOpen && clientReady && hasSelectedSourceDirectory; - const { status: checkoutStatus } = useCheckoutStatusQuery({ + const { status: checkoutStatus } = useLegacyCheckoutStatusQuery({ serverId: selectedServerId, cwd: selectedSourceDirectory ?? "", }); @@ -1754,7 +1755,7 @@ export function NewWorkspaceScreen({ staleTime: 15_000, }); - const githubPrSearchQuery = useForgeSearchQuery({ + const githubPrSearchQuery = useLegacyForgeSearchQuery({ client, serverId: selectedServerId, cwd: selectedSourceDirectory ?? "", @@ -1994,7 +1995,7 @@ export function NewWorkspaceScreen({ const connectedClient = withConnectedClient(); const createsWorktree = !supportsWorkspaceMultiplicity || effectiveIsolation === "worktree"; const checkoutStatusForCreate = createsWorktree - ? await ensureCheckoutStatus({ + ? await ensureLegacyCheckoutStatus({ queryClient, client: connectedClient, serverId: selectedServerId, @@ -2281,75 +2282,77 @@ export function NewWorkspaceScreen({ const screenHeaderLeft = useMemo(() => , []); return ( - - - - - - - {t("newWorkspace.title")} - - {formStack} - {isTerminalLaunch ? ( - - ) : ( - - )} - {errorMessage ? {errorMessage} : null} - - - + + + + + + + + {t("newWorkspace.title")} + + {formStack} + {isTerminalLaunch ? ( + + ) : ( + + )} + {errorMessage ? {errorMessage} : null} + + + + ); } diff --git a/packages/app/src/screens/workspace/use-workspace-checkout-status.ts b/packages/app/src/screens/workspace/use-workspace-checkout-status.ts index e51f5eef23..668ba0a9fa 100644 --- a/packages/app/src/screens/workspace/use-workspace-checkout-status.ts +++ b/packages/app/src/screens/workspace/use-workspace-checkout-status.ts @@ -4,10 +4,10 @@ import { useTranslation } from "react-i18next"; import { checkoutStatusQueryKey } from "@/git/query-keys"; import { fetchCheckoutStatus } from "@/git/checkout-status-cache"; import { canCreateWorkspaceTerminal } from "@/screens/workspace/terminals/state"; -import { useHostRuntimeClient } from "@/runtime/host-runtime"; +import type { WorkspaceGitClient } from "@/git/workspace-git"; interface UseWorkspaceCheckoutStatusInput { - client: ReturnType; + workspaceGit: WorkspaceGitClient | null; isConnected: boolean; isRouteFocused: boolean; normalizedServerId: string; @@ -21,26 +21,25 @@ export function useWorkspaceCheckoutStatus(input: UseWorkspaceCheckoutStatusInpu () => canCreateWorkspaceTerminal({ isRouteFocused: input.isRouteFocused, - client: input.client, + client: input.workspaceGit, isConnected: input.isConnected, workspaceDirectory: input.workspaceDirectory, }), - [input.client, input.isConnected, input.isRouteFocused, input.workspaceDirectory], + [input.isConnected, input.isRouteFocused, input.workspaceDirectory, input.workspaceGit], ); const checkoutQuery = useQuery({ - queryKey: checkoutStatusQueryKey( - input.normalizedServerId, - input.workspaceDirectory ?? `missing-workspace-directory:${input.normalizedWorkspaceId}`, - ), + queryKey: input.workspaceGit + ? checkoutStatusQueryKey(input.normalizedServerId, input.workspaceGit) + : (["checkoutStatus", input.normalizedServerId, "unbound"] as const), enabled: isCheckoutQueryEnabled, queryFn: async () => { - if (!input.client || !input.workspaceDirectory) { + if (!input.workspaceGit) { throw new Error(t("workspace.terminal.hostDisconnected")); } return await fetchCheckoutStatus({ - client: input.client, + client: input.workspaceGit, serverId: input.normalizedServerId, - cwd: input.workspaceDirectory, + target: input.workspaceGit, }); }, staleTime: Infinity, diff --git a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx index 15e9ef09fd..0042e89aa1 100644 --- a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx +++ b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx @@ -16,7 +16,6 @@ import { useToast } from "@/contexts/toast-context"; import { useCheckoutStatusQuery } from "@/git/use-status-query"; import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; -import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor"; import { openExternalUrl } from "@/utils/open-external-url"; import { isAbsolutePath } from "@/utils/path"; @@ -30,6 +29,7 @@ import { getForgePresentation } from "@/git/forge"; interface WorkspaceOpenInEditorButtonProps { serverId: string; + workspaceId: string; cwd: string; activeFile?: WorkspaceFileLocation | null; hideLabels?: boolean; @@ -86,7 +86,6 @@ export function WorkspaceOpenInEditorButton({ }: WorkspaceOpenInEditorButtonProps) { const { t } = useTranslation(); const toast = useToast(); - const isConnected = useHostRuntimeIsConnected(serverId); const isLocalDaemon = useIsLocalDaemon(serverId); const { preferredEditorId, updatePreferredEditor } = usePreferredEditor(); const { targets: desktopOpenTargets, isAvailable: isDesktopOpenAvailable } = @@ -105,15 +104,11 @@ export function WorkspaceOpenInEditorButton({ ); const canResolveWorkspace = isWeb && cwd.trim().length > 0 && isAbsolutePath(cwd); - const shouldQueryCheckout = canResolveWorkspace && isConnected; - const { status: checkoutStatus } = useCheckoutStatusQuery({ serverId, - cwd: shouldQueryCheckout ? cwd : "", }); const { resolvedForge } = useCheckoutPrStatusQuery({ serverId, - cwd: shouldQueryCheckout ? cwd : "", }); const targets = useMemo( diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 9b0e718b35..db4391b06b 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -62,6 +62,7 @@ import { RetainedPanel } from "@/components/retained-panel"; import { WindowChromeRegion } from "@/utils/desktop-window"; import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon"; import { WorkspaceActions } from "@/git/workspace-actions"; +import { useBoundWorkspaceGit, WorkspaceGitBoundary } from "@/git/workspace-git"; import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button"; import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button"; import { ImportSessionSheet } from "@/components/import-session-sheet"; @@ -1735,6 +1736,18 @@ function WorkspaceScreenContent({ (state) => state.sessions[normalizedServerId]?.serverInfo?.features?.providersSnapshot === true, ); const workspaceDirectory = workspaceDescriptor?.workspaceDirectory || null; + const workspaceGitAddress = useMemo( + () => + normalizedWorkspaceId && workspaceDirectory + ? ({ + kind: "selected", + workspaceId: normalizedWorkspaceId, + cwd: workspaceDirectory, + } as const) + : null, + [normalizedWorkspaceId, workspaceDirectory], + ); + const workspaceGit = useBoundWorkspaceGit(client, workspaceGitAddress); const isMissingWorkspaceDirectory = Boolean(workspaceDescriptor) && !workspaceDirectory; const [isImportSheetVisible, setIsImportSheetVisible] = useState(false); const canOpenImportSheet = [client, isConnected, workspaceDirectory].every(Boolean); @@ -1865,7 +1878,7 @@ function WorkspaceScreenContent({ const { archiveAgent } = useArchiveAgent(); const { checkoutQuery, isCheckoutStatusLoading } = useWorkspaceCheckoutStatus({ - client, + workspaceGit, isConnected, isRouteFocused, normalizedServerId, @@ -3477,6 +3490,7 @@ function WorkspaceScreenContent({ {!isMobile && workspaceDirectory ? ( @@ -3825,46 +3840,48 @@ function WorkspaceScreenContent({ ); return ( - gatedWorkspaceScreen ?? ( - - - - - - {workspaceCenterColumn} - - - - - - - ) + + {gatedWorkspaceScreen ?? ( + + + + + + {workspaceCenterColumn} + + + + + + + )} + ); } diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index dbef388afb..355383516b 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -3728,6 +3728,178 @@ test("requests checkout pull via RPC", async () => { }); }); +test("bound workspace Git carries identity for the normal mutation surface", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + const workspaceGit = client.bindWorkspaceGit({ workspaceId: "workspace-a", cwd: "/shared" }); + + const pull = workspaceGit.pull("bound-pull"); + const request = parseSentFrame(mock.sent[0]); + expect(request).toMatchObject({ + type: "checkout_pull_request", + workspaceId: "workspace-a", + cwd: "/shared", + requestId: "bound-pull", + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "checkout_pull_response", + payload: { cwd: "/shared", requestId: "bound-pull", success: true, error: null }, + }), + ); + await expect(pull).resolves.toMatchObject({ success: true }); + + const checkDetails = workspaceGit.getForgeCheckDetails( + { repoOwner: "getpaseo", repoName: "paseo", checkRunId: 42 }, + "bound-check-details", + ); + expect(parseSentFrame(mock.sent[1])).toMatchObject({ + type: "checkout.forge.get_check_details.request", + workspaceId: "workspace-a", + cwd: "/shared", + repoOwner: "getpaseo", + repoName: "paseo", + checkRunId: 42, + requestId: "bound-check-details", + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "checkout.forge.get_check_details.response", + payload: { + cwd: "/shared", + requestId: "bound-check-details", + success: true, + details: { + checkRunId: 42, + name: "Runtime check", + output: { title: "Runtime check", summary: "runtime logs", text: "runtime output" }, + annotations: [], + failedJobs: [], + truncated: false, + }, + error: null, + }, + }), + ); + await expect(checkDetails).resolves.toMatchObject({ + success: true, + details: { output: { text: "runtime output" } }, + }); + + expect(Object.keys(workspaceGit)).toEqual( + expect.arrayContaining([ + "getStatus", + "getDiff", + "subscribeDiff", + "commit", + "listCommits", + "getCommitFileDiff", + "getBranchSuggestions", + "switchBranch", + "stashSave", + "stashPop", + "stashList", + "pull", + "refresh", + ]), + ); +}); + +test.each(["", " "])( + "selected workspace Git rejects identity %j before it can become a legacy request", + (workspaceId) => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + expect(() => client.bindWorkspaceGit({ workspaceId, cwd: "/shared" })).toThrow( + "workspaceId is required for selected workspace Git", + ); + expect(mock.sent).toEqual([]); + }, +); + +test.each(["", " "])( + "selected workspace Git rejects cwd %j before sending a daemon request", + (cwd) => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + expect(() => client.bindWorkspaceGit({ workspaceId: "workspace-a", cwd })).toThrow( + "cwd is required for selected workspace Git", + ); + expect(mock.sent).toEqual([]); + }, +); + +test("selected workspace Git normalizes its complete address before sending", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const workspaceGit = client.bindWorkspaceGit({ + workspaceId: " workspace-a ", + cwd: " /shared ", + }); + const status = workspaceGit.getStatus({ requestId: "normalized-status" }); + + expect(parseSentFrame(mock.sent[0])).toMatchObject({ + type: "checkout_status_request", + workspaceId: "workspace-a", + cwd: "/shared", + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "checkout_status_response", + payload: { + cwd: "/shared", + requestId: "normalized-status", + isGit: false, + isPaseoOwnedWorktree: false, + repoRoot: null, + currentBranch: null, + isDirty: null, + baseRef: null, + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + hasRemote: false, + remoteUrl: null, + error: null, + }, + }), + ); + await status; +}); + test("renames a branch via RPC", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 793873f03f..dbbdf0f5de 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1135,6 +1135,7 @@ export class DaemonClient { { cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }; + workspaceId?: string; } >(); private terminalDirectorySubscriptions = new Map(); @@ -2399,6 +2400,7 @@ export class DaemonClient { type: "subscribe_checkout_diff_request", subscriptionId, cwd: subscription.cwd, + ...(subscription.workspaceId ? { workspaceId: subscription.workspaceId } : {}), compare: subscription.compare, requestId: this.createRequestId(), }); @@ -3547,14 +3549,254 @@ export class DaemonClient { // Git Operations // ============================================================================ + bindWorkspaceGit(target: { workspaceId: string; cwd: string }) { + const workspaceId = target.workspaceId.trim(); + if (workspaceId.length === 0) { + throw new Error("workspaceId is required for selected workspace Git"); + } + const cwd = target.cwd.trim(); + if (cwd.length === 0) { + throw new Error("cwd is required for selected workspace Git"); + } + const address = { workspaceId, cwd } as const; + return { + workspaceId, + cwd, + getStatus: (options?: { requestId?: string }) => + this.requestCheckoutStatus(cwd, { ...options, workspaceId }), + getDiff: ( + compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }, + requestId?: string, + ) => this.getBoundCheckoutDiff({ workspaceId, cwd, compare, requestId }), + subscribeDiff: ( + compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }, + options?: { subscriptionId?: string; requestId?: string }, + ) => this.subscribeCheckoutDiff(cwd, compare, { ...options, workspaceId }), + unsubscribeDiff: (subscriptionId: string) => this.unsubscribeCheckoutDiff(subscriptionId), + commit: (input: { message?: string; addAll?: boolean }, requestId?: string) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "checkout_commit_request", workspaceId, cwd, ...input }, + responseType: "checkout_commit_response", + }), + merge: ( + input: { baseRef?: string; strategy?: "merge" | "squash"; requireCleanTarget?: boolean }, + requestId?: string, + ) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "checkout_merge_request", ...address, ...input }, + responseType: "checkout_merge_response", + }), + mergeFromBase: ( + input: { baseRef?: string; requireCleanTarget?: boolean }, + requestId?: string, + ) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "checkout_merge_from_base_request", ...address, ...input }, + responseType: "checkout_merge_from_base_response", + }), + pull: (requestId?: string) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "checkout_pull_request", ...address }, + responseType: "checkout_pull_response", + }), + push: (requestId?: string) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "checkout_push_request", ...address }, + responseType: "checkout_push_response", + }), + refresh: (requestId?: string) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "checkout.refresh.request", workspaceId, cwd }, + responseType: "checkout.refresh.response", + }), + listCommits: async (requestId?: string) => { + const payload = + await this.sendNamespacedCorrelatedSessionRequest<"checkout.commits.list.response">({ + requestId, + message: { type: "checkout.commits.list.request", ...address }, + timeout: 60000, + }); + if (payload.error) throw new Error(payload.error.message); + return { baseRef: payload.baseRef, commits: payload.commits }; + }, + getCommitFileDiff: async (sha: string, path: string, requestId?: string) => { + const payload = + await this.sendNamespacedCorrelatedSessionRequest<"checkout.commits.file_diff.response">({ + requestId, + message: { type: "checkout.commits.file_diff.request", ...address, sha, path }, + timeout: 60000, + }); + if (payload.error) throw new Error(payload.error.message); + return { file: payload.file }; + }, + createPr: (input: { title?: string; body?: string; baseRef?: string }, requestId?: string) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "checkout_pr_create_request", ...address, ...input }, + responseType: "checkout_pr_create_response", + }), + mergePr: (method: CheckoutPrMergeMethod, requestId?: string) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "checkout_pr_merge_request", ...address, mergeMethod: method }, + responseType: "checkout_pr_merge_response", + }), + setForgeAutoMerge: ( + input: { enabled: true; method: CheckoutPrMergeMethod } | { enabled: false }, + requestId?: string, + ) => + this.sendNamespacedCorrelatedSessionRequest<"checkout.forge.set_auto_merge.response">({ + requestId, + message: { + type: "checkout.forge.set_auto_merge.request", + ...address, + enabled: input.enabled, + ...(input.enabled ? { mergeMethod: input.method } : {}), + }, + timeout: 60000, + }), + setGithubAutoMerge: ( + input: { enabled: true; method: CheckoutPrMergeMethod } | { enabled: false }, + requestId?: string, + ) => + this.sendNamespacedCorrelatedSessionRequest<"checkout.github.set_auto_merge.response">({ + requestId, + message: { + type: "checkout.github.set_auto_merge.request", + ...address, + enabled: input.enabled, + ...(input.enabled ? { mergeMethod: input.method } : {}), + }, + }), + getPrStatus: (requestId?: string) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "checkout_pr_status_request", ...address }, + responseType: "checkout_pr_status_response", + }), + getPullRequestTimeline: ( + input: { prNumber: number; repoOwner: string; repoName: string }, + requestId?: string, + ) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "pull_request_timeline_request", ...address, ...input }, + responseType: "pull_request_timeline_response", + }), + validateBranch: (branchName: string, requestId?: string) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "validate_branch_request", ...address, branchName }, + responseType: "validate_branch_response", + }), + switchBranch: (branch: string, requestId?: string) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "checkout_switch_branch_request", workspaceId, cwd, branch }, + responseType: "checkout_switch_branch_response", + }), + renameBranch: (branch: string, requestId?: string) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "checkout.rename_branch.request", ...address, branch }, + responseType: "checkout.rename_branch.response", + }), + stashSave: (options?: { branch?: string }, requestId?: string) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "stash_save_request", ...address, branch: options?.branch }, + responseType: "stash_save_response", + }), + stashPop: (stashIndex: number, requestId?: string) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "stash_pop_request", ...address, stashIndex }, + responseType: "stash_pop_response", + }), + stashList: (options?: { paseoOnly?: boolean }, requestId?: string) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "stash_list_request", ...address, paseoOnly: options?.paseoOnly }, + responseType: "stash_list_response", + }), + getBranchSuggestions: (options?: { query?: string; limit?: number }, requestId?: string) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "branch_suggestions_request", ...address, ...options }, + responseType: "branch_suggestions_response", + }), + searchForge: ( + options: { query: string; limit?: number; kinds?: ForgeSearchRequest["kinds"] }, + requestId?: string, + ) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "forge.search.request", ...address, ...options }, + responseType: "forge.search.response", + timeout: 15000, + }), + searchGitHub: ( + options: { query: string; limit?: number; kinds?: GitHubSearchRequest["kinds"] }, + requestId?: string, + ) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "github_search_request", ...address, ...options }, + responseType: "github_search_response", + }), + getForgeCheckDetails: ( + input: { + repoOwner?: string; + repoName?: string; + checkRunId?: number; + workflowRunId?: number; + changeRequestNumber?: number; + }, + requestId?: string, + ) => + this.sendNamespacedCorrelatedSessionRequest<"checkout.forge.get_check_details.response">({ + requestId, + message: { type: "checkout.forge.get_check_details.request", ...address, ...input }, + timeout: 60000, + }), + getGithubCheckDetails: ( + input: { + repoOwner?: string; + repoName?: string; + checkRunId?: number; + workflowRunId?: number; + }, + requestId?: string, + ) => + this.sendNamespacedCorrelatedSessionRequest<"checkout.github.get_check_details.response">({ + requestId, + message: { type: "checkout.github.get_check_details.request", ...address, ...input }, + }), + }; + } + async getCheckoutStatus( cwd: string, options?: { requestId?: string }, ): Promise { - const requestId = options?.requestId; + return this.requestCheckoutStatus(cwd, options); + } + + private async requestCheckoutStatus( + cwd: string, + options?: { requestId?: string; workspaceId?: string }, + ): Promise { + const { requestId, workspaceId } = options ?? {}; + const cacheKey = workspaceId ? `${workspaceId}\0${cwd}` : cwd; if (!requestId) { - const existing = this.checkoutStatusInFlight.get(cwd); + const existing = this.checkoutStatusInFlight.get(cacheKey); if (existing) { return existing; } @@ -3564,6 +3806,7 @@ export class DaemonClient { const message = SessionInboundMessageSchema.parse({ type: "checkout_status_request", cwd, + ...(workspaceId ? { workspaceId } : {}), requestId: resolvedRequestId, }); @@ -3583,11 +3826,11 @@ export class DaemonClient { }); if (!requestId) { - this.checkoutStatusInFlight.set(cwd, responsePromise); + this.checkoutStatusInFlight.set(cacheKey, responsePromise); void responsePromise .finally(() => { - if (this.checkoutStatusInFlight.get(cwd) === responsePromise) { - this.checkoutStatusInFlight.delete(cwd); + if (this.checkoutStatusInFlight.get(cacheKey) === responsePromise) { + this.checkoutStatusInFlight.delete(cacheKey); } }) .catch(() => undefined); @@ -3596,6 +3839,31 @@ export class DaemonClient { return responsePromise; } + private async getBoundCheckoutDiff(input: { + workspaceId: string; + cwd: string; + compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }; + requestId?: string; + }): Promise { + const subscriptionId = `oneshot-checkout-diff:${crypto.randomUUID()}`; + try { + const payload = await this.subscribeCheckoutDiff(input.cwd, input.compare, { + subscriptionId, + requestId: input.requestId, + workspaceId: input.workspaceId, + }); + return { + cwd: payload.cwd, + files: payload.files, + error: payload.error, + diffTooLarge: payload.diffTooLarge, + requestId: payload.requestId, + }; + } finally { + this.unsubscribeCheckoutDiff(subscriptionId); + } + } + private normalizeCheckoutDiffCompare(compare: { mode: "uncommitted" | "base"; baseRef?: string; @@ -3647,7 +3915,7 @@ export class DaemonClient { async subscribeCheckoutDiff( cwd: string, compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }, - options?: { subscriptionId?: string; requestId?: string }, + options?: { subscriptionId?: string; requestId?: string; workspaceId?: string }, ): Promise { const subscriptionId = options?.subscriptionId ?? crypto.randomUUID(); const normalizedCompare = this.normalizeCheckoutDiffCompare(compare); @@ -3655,6 +3923,7 @@ export class DaemonClient { this.checkoutDiffSubscriptions.set(subscriptionId, { cwd, compare: normalizedCompare, + workspaceId: options?.workspaceId, }); const resolvedRequestId = this.createRequestId(options?.requestId); @@ -3662,6 +3931,7 @@ export class DaemonClient { type: "subscribe_checkout_diff_request", subscriptionId, cwd, + ...(options?.workspaceId ? { workspaceId: options.workspaceId } : {}), compare: normalizedCompare, requestId: resolvedRequestId, }); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 3a8385d36a..d6177b4faa 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1,4 +1,6 @@ import { z } from "zod"; + +const OptionalNonEmptyWorkspaceIdSchema = z.string().min(1).optional(); import { TerminalActivitySchema } from "./terminal-activity.js"; import { CLIENT_CAPS } from "./client-capabilities.js"; import { AGENT_LIFECYCLE_STATUSES } from "./agent-lifecycle.js"; @@ -1715,16 +1717,21 @@ const CheckoutDiffCompareSchema = z.object({ ignoreWhitespace: z.boolean().optional(), }); +const OptionalWorkspaceGitIdSchema = z.string().trim().min(1).optional(); +const WorkspaceGitCwdSchema = z.string().trim().min(1); + export const CheckoutStatusRequestSchema = z.object({ type: z.literal("checkout_status_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, requestId: z.string(), }); export const SubscribeCheckoutDiffRequestSchema = z.object({ type: z.literal("subscribe_checkout_diff_request"), subscriptionId: z.string(), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, compare: CheckoutDiffCompareSchema, requestId: z.string(), }); @@ -1736,7 +1743,8 @@ export const UnsubscribeCheckoutDiffRequestSchema = z.object({ export const CheckoutCommitRequestSchema = z.object({ type: z.literal("checkout_commit_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, message: z.string().optional(), addAll: z.boolean().optional(), requestId: z.string(), @@ -1744,7 +1752,8 @@ export const CheckoutCommitRequestSchema = z.object({ export const CheckoutMergeRequestSchema = z.object({ type: z.literal("checkout_merge_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, baseRef: z.string().optional(), strategy: z.enum(["merge", "squash"]).optional(), requireCleanTarget: z.boolean().optional(), @@ -1753,7 +1762,8 @@ export const CheckoutMergeRequestSchema = z.object({ export const CheckoutMergeFromBaseRequestSchema = z.object({ type: z.literal("checkout_merge_from_base_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, baseRef: z.string().optional(), requireCleanTarget: z.boolean().optional(), requestId: z.string(), @@ -1761,25 +1771,29 @@ export const CheckoutMergeFromBaseRequestSchema = z.object({ export const CheckoutPullRequestSchema = z.object({ type: z.literal("checkout_pull_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, requestId: z.string(), }); export const CheckoutPushRequestSchema = z.object({ type: z.literal("checkout_push_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, requestId: z.string(), }); export const CheckoutRefreshRequestSchema = z.object({ type: z.literal("checkout.refresh.request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, requestId: z.string(), }); export const CheckoutPrCreateRequestSchema = z.object({ type: z.literal("checkout_pr_create_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, title: z.string().optional(), body: z.string().optional(), baseRef: z.string().optional(), @@ -1788,14 +1802,16 @@ export const CheckoutPrCreateRequestSchema = z.object({ export const CheckoutPrMergeRequestSchema = z.object({ type: z.literal("checkout_pr_merge_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, mergeMethod: z.enum(["merge", "squash", "rebase"]), requestId: z.string(), }); export const CheckoutForgeSetAutoMergeRequestSchema = z.object({ type: z.literal("checkout.forge.set_auto_merge.request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, enabled: z.boolean(), mergeMethod: z.enum(["merge", "squash", "rebase"]).optional(), requestId: z.string(), @@ -1805,7 +1821,8 @@ export const CheckoutForgeSetAutoMergeRequestSchema = z.object({ // all supported clients use checkout.forge.set_auto_merge.*. export const CheckoutGithubSetAutoMergeRequestSchema = z.object({ type: z.literal("checkout.github.set_auto_merge.request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, enabled: z.boolean(), mergeMethod: z.enum(["merge", "squash", "rebase"]).optional(), requestId: z.string(), @@ -1832,13 +1849,15 @@ const CheckoutCommitSchema = z.object({ export const CheckoutCommitsListRequestSchema = z.object({ type: z.literal("checkout.commits.list.request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, requestId: z.string(), }); export const CheckoutCommitFileDiffRequestSchema = z.object({ type: z.literal("checkout.commits.file_diff.request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, sha: z.string(), path: z.string(), requestId: z.string(), @@ -1847,7 +1866,8 @@ export const CheckoutCommitFileDiffRequestSchema = z.object({ const GitHubRepoSegmentSchema = z.string().regex(/^[A-Za-z0-9._-]+$/); const CheckoutCheckDetailsRequestPayloadSchema = z.object({ - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, // GitHub addresses check runs by owner/name. GitLab resolves the project from // cwd and omits these GitHub-only single-segment fields. repoOwner: GitHubRepoSegmentSchema.optional(), @@ -1880,13 +1900,15 @@ export const CheckoutGithubGetCheckDetailsRequestSchema = export const CheckoutPrStatusRequestSchema = z.object({ type: z.literal("checkout_pr_status_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, requestId: z.string(), }); export const PullRequestTimelineRequestSchema = z.object({ type: z.literal("pull_request_timeline_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, prNumber: z.number(), repoOwner: z.string(), repoName: z.string(), @@ -1895,28 +1917,32 @@ export const PullRequestTimelineRequestSchema = z.object({ export const ValidateBranchRequestSchema = z.object({ type: z.literal("validate_branch_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, branchName: z.string(), requestId: z.string(), }); export const CheckoutSwitchBranchRequestSchema = z.object({ type: z.literal("checkout_switch_branch_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, branch: z.string(), requestId: z.string(), }); export const CheckoutRenameBranchRequestSchema = z.object({ type: z.literal("checkout.rename_branch.request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, branch: z.string(), requestId: z.string(), }); export const StashSaveRequestSchema = z.object({ type: z.literal("stash_save_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, /** Branch name to tag the stash with for later identification. */ branch: z.string().optional(), requestId: z.string(), @@ -1924,7 +1950,8 @@ export const StashSaveRequestSchema = z.object({ export const StashPopRequestSchema = z.object({ type: z.literal("stash_pop_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, /** Zero-based index from stash_list_response. */ stashIndex: z.number().int().min(0), requestId: z.string(), @@ -1932,7 +1959,8 @@ export const StashPopRequestSchema = z.object({ export const StashListRequestSchema = z.object({ type: z.literal("stash_list_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, /** If true, only return paseo-created stashes. Default true. */ paseoOnly: z.boolean().optional(), requestId: z.string(), @@ -1940,7 +1968,8 @@ export const StashListRequestSchema = z.object({ export const BranchSuggestionsRequestSchema = z.object({ type: z.literal("branch_suggestions_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, query: z.string().optional(), limit: z.number().int().min(1).max(200).optional(), requestId: z.string(), @@ -1979,7 +2008,8 @@ export const GitHubSearchKindSchema = ForgeSearchKindSchema; export const ForgeSearchRequestSchema = z.object({ type: z.literal("forge.search.request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, query: z.string(), limit: z.number().int().min(1).max(50).optional(), kinds: z.array(ForgeSearchKindSchema).optional(), @@ -1990,7 +2020,8 @@ export const ForgeSearchRequestSchema = z.object({ // clients use forge.search.*. export const GitHubSearchRequestSchema = z.object({ type: z.literal("github_search_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, query: z.string(), limit: z.number().int().min(1).max(50).optional(), kinds: z.array(GitHubSearchKindSchema).optional(), @@ -4173,6 +4204,7 @@ const AheadBehindSchema = z.object({ }); const CheckoutStatusCommonSchema = z.object({ + workspaceId: OptionalNonEmptyWorkspaceIdSchema, cwd: z.string(), error: CheckoutErrorSchema.nullable(), requestId: z.string(), @@ -4374,6 +4406,7 @@ export const CheckoutStatusUpdateSchema = z.object({ const CheckoutDiffSubscriptionPayloadSchema = z.object({ subscriptionId: z.string(), + workspaceId: OptionalNonEmptyWorkspaceIdSchema, cwd: z.string(), files: z.array(ParsedDiffFileSchema), error: CheckoutErrorSchema.nullable(), diff --git a/packages/protocol/src/messages.workspace-git-address.test.ts b/packages/protocol/src/messages.workspace-git-address.test.ts new file mode 100644 index 0000000000..8cdd50d0f0 --- /dev/null +++ b/packages/protocol/src/messages.workspace-git-address.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from "vitest"; +import { CheckoutForgeGetCheckDetailsRequestSchema, CheckoutStatusRequestSchema } from "./messages"; + +test.each(["", " "])( + "Git request schemas accept omitted legacy identity but reject selected identity %j", + (workspaceId) => { + expect( + CheckoutStatusRequestSchema.safeParse({ + type: "checkout_status_request", + cwd: "/legacy", + requestId: "legacy", + }).success, + ).toBe(true); + expect( + CheckoutStatusRequestSchema.safeParse({ + type: "checkout_status_request", + workspaceId, + cwd: "/shared", + requestId: "selected", + }).success, + ).toBe(false); + expect( + CheckoutForgeGetCheckDetailsRequestSchema.safeParse({ + type: "checkout.forge.get_check_details.request", + workspaceId, + cwd: "/shared", + checkRunId: 1, + requestId: "check", + }).success, + ).toBe(false); + }, +); + +test.each(["", " "])("selected Git request schemas reject cwd %j", (cwd) => { + expect( + CheckoutStatusRequestSchema.safeParse({ + type: "checkout_status_request", + workspaceId: "workspace-a", + cwd, + requestId: "selected", + }).success, + ).toBe(false); +}); + +test("selected Git request schemas normalize a valid cwd", () => { + expect( + CheckoutStatusRequestSchema.parse({ + type: "checkout_status_request", + workspaceId: " workspace-a ", + cwd: " /shared ", + requestId: "selected", + }), + ).toMatchObject({ workspaceId: "workspace-a", cwd: "/shared" }); +}); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 63d70f61c7..642b21b854 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -159,6 +159,7 @@ import { DaemonConfigStore, type MutableDaemonConfig } from "./daemon-config-sto import { BrowserToolsBroker } from "./browser-tools/broker.js"; import { DaemonConfigBrowserToolsPolicy } from "./browser-tools/policy.js"; import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; +import { createWorkspaceGitDirectory } from "./workspace-git-directory.js"; import { resolveWorkspaceIdForPath } from "./resolve-workspace-id-for-path.js"; import { archiveByScope, @@ -885,10 +886,15 @@ export async function createPaseoDaemon( logger, paseoHome: config.paseoHome, worktreesRoot: config.worktreesRoot, + workspaceRuntime, deps: { forgeOverrides: { github }, }, }); + const workspaceGitDirectory = createWorkspaceGitDirectory({ + workspaceRegistry, + workspaceGitService, + }); const workspaceProvisioning = createWorkspaceProvisioningService({ serverId, projectRegistry, @@ -1361,7 +1367,7 @@ export async function createPaseoDaemon( terminalManager, workspaceRegistry, projectRegistry, - workspaceGitService, + workspaceGitDirectory, getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), getDaemonTcpHost: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null), serviceProxyPublicBaseUrl, diff --git a/packages/server/src/server/checkout-diff-manager.test.ts b/packages/server/src/server/checkout-diff-manager.test.ts index 1d3f2e598d..fec50f33f6 100644 --- a/packages/server/src/server/checkout-diff-manager.test.ts +++ b/packages/server/src/server/checkout-diff-manager.test.ts @@ -13,6 +13,7 @@ vi.mock("./checkout-git-utils.js", () => ({ import type pino from "pino"; import { CheckoutDiffManager } from "./checkout-diff-manager.js"; import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; +import { bindWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js"; interface Deferred { promise: Promise; @@ -64,6 +65,7 @@ function createPendingManager() { unsubscribeCalls: number; resolve(): void; }> = []; + let boundLegacy: ReturnType | null = null; const workspaceGitService = { getCheckoutDiff: async () => ({ diff: "", structured: [] }), getSnapshot: async () => createWorkspaceSnapshot(), @@ -87,6 +89,9 @@ function createPendingManager() { watches.push(watch); return pending.promise; }, + bindLegacy(cwd: string) { + return (boundLegacy ??= bindWorkspaceGitService(this as unknown as WorkspaceGitService, cwd)); + }, }; const logger = { child: () => logger, warn: () => {} }; const manager = new CheckoutDiffManager({ @@ -123,6 +128,7 @@ describe("CheckoutDiffManager", () => { }; }); + let boundLegacy: ReturnType | null = null; const workspaceGitService = { subscribe: vi.fn(), peekSnapshot: vi.fn(), @@ -139,6 +145,12 @@ describe("CheckoutDiffManager", () => { scheduleRefreshForCwd: vi.fn(), requestWorkingTreeWatch: mockRequestWorkingTreeWatch, dispose: vi.fn(), + bindLegacy(cwd: string) { + return (boundLegacy ??= bindWorkspaceGitService( + this as unknown as WorkspaceGitService, + cwd, + )); + }, }; const logger = { @@ -244,7 +256,7 @@ describe("CheckoutDiffManager", () => { expect(watches[0].unsubscribeCalls).toBe(1); }); - test("diffCwd uses repoRoot from the working tree watch result", async () => { + test("the bound workspace owns the diff cwd", async () => { const { manager, workspaceGitService } = createManager({ repoRoot: "/tmp/repo" }); await manager.subscribe( @@ -256,7 +268,7 @@ describe("CheckoutDiffManager", () => { ); expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledWith( - "/tmp/repo", + "/tmp/repo/packages/server", expect.objectContaining({ mode: "uncommitted", includeStructured: true }), undefined, ); @@ -327,7 +339,7 @@ describe("CheckoutDiffManager", () => { expect(getCheckoutDiff).toHaveBeenNthCalledWith( 1, - "/tmp/repo", + "/tmp/repo/packages/server", expect.objectContaining({ mode: "uncommitted" }), undefined, ); diff --git a/packages/server/src/server/checkout-diff-manager.ts b/packages/server/src/server/checkout-diff-manager.ts index 9bf0439aff..337c7f478c 100644 --- a/packages/server/src/server/checkout-diff-manager.ts +++ b/packages/server/src/server/checkout-diff-manager.ts @@ -1,6 +1,10 @@ import type pino from "pino"; import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js"; -import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; +import type { + WorkspaceGitRuntimeSnapshot, + WorkspaceGitService, + WorkspaceGitWorkspace, +} from "./workspace-git-service.js"; import { expandTilde } from "../utils/path.js"; import { toCheckoutError } from "./checkout-git-utils.js"; @@ -8,6 +12,7 @@ const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150; type CheckoutDiffWorkspace = Pick< WorkspaceGitService, + | "bindLegacy" | "getCheckoutDiff" | "getSnapshot" | "peekSnapshot" @@ -32,7 +37,7 @@ export interface CheckoutDiffMetrics { interface CheckoutDiffWatchTarget { key: string; cwd: string; - diffCwd: string; + workspaceGit: WorkspaceGitWorkspace; compare: CheckoutDiffCompareInput; listeners: Set<(snapshot: CheckoutDiffSnapshotPayload) => void>; workingTreeWatchUnsubscribe: (() => void) | null; @@ -56,6 +61,12 @@ export interface CheckoutDiffSubscriptionRequest { signal?: AbortSignal; } +export interface CheckoutDiffWorkspaceSubscriptionRequest { + workspaceGit: WorkspaceGitWorkspace; + compare: CheckoutDiffCompareInput; + signal?: AbortSignal; +} + export interface CheckoutDiffSubscription { initial: CheckoutDiffSnapshotPayload; unsubscribe: () => void; @@ -64,6 +75,8 @@ export interface CheckoutDiffSubscription { export class CheckoutDiffManager { private readonly workspaceGitService: CheckoutDiffWorkspace; private readonly targets = new Map(); + private readonly workspaceTokens = new WeakMap(); + private nextWorkspaceToken = 1; constructor(options: { logger: pino.Logger; @@ -77,9 +90,22 @@ export class CheckoutDiffManager { params: CheckoutDiffSubscriptionRequest, listener: (snapshot: CheckoutDiffSnapshotPayload) => void, ): Promise { - const cwd = params.cwd; + return this.subscribeWorkspace( + { + workspaceGit: this.workspaceGitService.bindLegacy(params.cwd), + compare: params.compare, + signal: params.signal, + }, + listener, + ); + } + + async subscribeWorkspace( + params: CheckoutDiffWorkspaceSubscriptionRequest, + listener: (snapshot: CheckoutDiffSnapshotPayload) => void, + ): Promise { const compare = this.normalizeCompare(params.compare); - const target = this.ensureTarget(cwd, compare); + const target = this.ensureTarget(params.workspaceGit, compare); target.listeners.add(listener); target.openPromise ??= this.openTarget(target); @@ -100,10 +126,7 @@ export class CheckoutDiffManager { try { await target.openPromise; const initial = - target.latestPayload ?? - (await this.computeCheckoutDiffSnapshot(target.cwd, target.compare, { - diffCwd: target.diffCwd, - })); + target.latestPayload ?? (await this.computeCheckoutDiffSnapshot(target, false)); target.latestPayload = initial; target.latestFingerprint = JSON.stringify(initial); return { initial, unsubscribe }; @@ -114,11 +137,12 @@ export class CheckoutDiffManager { } scheduleRefreshForCwd(cwd: string): void { - const resolvedCwd = expandTilde(cwd); + this.scheduleRefreshForWorkspace(this.workspaceGitService.bindLegacy(expandTilde(cwd))); + } + + scheduleRefreshForWorkspace(workspaceGit: WorkspaceGitWorkspace): void { for (const target of this.targets.values()) { - if (target.cwd !== resolvedCwd && target.diffCwd !== resolvedCwd) { - continue; - } + if (target.workspaceGit !== workspaceGit) continue; this.scheduleTargetRefresh(target); } } @@ -156,9 +180,17 @@ export class CheckoutDiffManager { : { mode: "base", ignoreWhitespace }; } - private buildTargetKey(cwd: string, compare: CheckoutDiffCompareInput): string { + private buildTargetKey( + workspaceGit: WorkspaceGitWorkspace, + compare: CheckoutDiffCompareInput, + ): string { + let workspaceToken = this.workspaceTokens.get(workspaceGit); + if (workspaceToken === undefined) { + workspaceToken = this.nextWorkspaceToken++; + this.workspaceTokens.set(workspaceGit, workspaceToken); + } return JSON.stringify([ - cwd, + workspaceToken, compare.mode, compare.mode === "base" ? (compare.baseRef ?? "") : "", compare.ignoreWhitespace === true, @@ -245,23 +277,20 @@ export class CheckoutDiffManager { } private async computeCheckoutDiffSnapshot( - cwd: string, - compare: CheckoutDiffCompareInput, - options?: { diffCwd?: string; force?: boolean; reason?: string }, + target: CheckoutDiffWatchTarget, + force: boolean, + reason = "checkout-diff-refresh", ): Promise { - const diffCwd = options?.diffCwd ?? cwd; + const { cwd, compare, workspaceGit } = target; try { - const diffResult = await this.workspaceGitService.getCheckoutDiff( - diffCwd, + const diffResult = await workspaceGit.getCheckoutDiff( { mode: compare.mode, baseRef: compare.baseRef, ignoreWhitespace: compare.ignoreWhitespace, includeStructured: true, }, - options?.force - ? { force: true, reason: options.reason ?? "checkout-diff-refresh" } - : undefined, + force ? { force: true, reason } : undefined, ); if (diffResult.diffTooLarge) { return { @@ -302,11 +331,11 @@ export class CheckoutDiffManager { do { target.refreshQueued = false; target.refreshQueuedForce = false; - const snapshot = await this.computeCheckoutDiffSnapshot(target.cwd, target.compare, { - diffCwd: target.diffCwd, - force: currentForce, - ...(currentForce ? { reason: "working-tree-watch" } : {}), - }); + const snapshot = await this.computeCheckoutDiffSnapshot( + target, + currentForce, + currentForce ? "working-tree-watch" : "checkout-diff-refresh", + ); target.latestPayload = snapshot; const fingerprint = JSON.stringify(snapshot); if (fingerprint !== target.latestFingerprint) { @@ -326,8 +355,12 @@ export class CheckoutDiffManager { } } - private ensureTarget(cwd: string, compare: CheckoutDiffCompareInput): CheckoutDiffWatchTarget { - const targetKey = this.buildTargetKey(cwd, compare); + private ensureTarget( + workspaceGit: WorkspaceGitWorkspace, + compare: CheckoutDiffCompareInput, + ): CheckoutDiffWatchTarget { + const cwd = workspaceGit.cwd; + const targetKey = this.buildTargetKey(workspaceGit, compare); const existing = this.targets.get(targetKey); if (existing) { return existing; @@ -336,7 +369,7 @@ export class CheckoutDiffManager { const target: CheckoutDiffWatchTarget = { key: targetKey, cwd, - diffCwd: cwd, + workspaceGit, compare, listeners: new Set(), workingTreeWatchUnsubscribe: null, @@ -360,16 +393,14 @@ export class CheckoutDiffManager { private async openTarget(target: CheckoutDiffWatchTarget): Promise { if (target.compare.mode === "base") { const snapshot = - this.workspaceGitService.peekSnapshot(target.cwd) ?? - (await this.workspaceGitService.getSnapshot(target.cwd, { includeForge: false })); - target.diffCwd = snapshot.git.repoRoot ?? target.cwd; + target.workspaceGit.peekSnapshot() ?? + (await target.workspaceGit.getSnapshot({ includeForge: false })); if (this.targets.get(target.key) !== target || target.listeners.size === 0) { return; } this.rememberWorkspaceSnapshot(target, snapshot); - const workspaceSubscription = this.workspaceGitService.registerWorkspace( - { cwd: target.cwd }, - (nextSnapshot) => this.rememberWorkspaceSnapshot(target, nextSnapshot), + const workspaceSubscription = target.workspaceGit.register((nextSnapshot) => + this.rememberWorkspaceSnapshot(target, nextSnapshot), ); if (this.targets.get(target.key) !== target || target.listeners.size === 0) { workspaceSubscription.unsubscribe(); @@ -379,11 +410,9 @@ export class CheckoutDiffManager { return; } - const { repoRoot, unsubscribe } = await this.workspaceGitService.requestWorkingTreeWatch( - target.cwd, - () => this.scheduleTargetRefresh(target), + const { unsubscribe } = await target.workspaceGit.requestWorkingTreeWatch(() => + this.scheduleTargetRefresh(target), ); - target.diffCwd = repoRoot ?? target.cwd; if (this.targets.get(target.key) !== target || target.listeners.size === 0) { unsubscribe(); return; diff --git a/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts b/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts index 1d589428dd..38854d551b 100644 --- a/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts @@ -193,17 +193,23 @@ async function expectFileEditAndWatch(workspace: CharacterizedWorkspace): Promis } async function expectGitObservation(workspace: CharacterizedWorkspace): Promise { + const workspaceGit = client.bindWorkspaceGit({ workspaceId: workspace.id, cwd: workspace.cwd }); await expect - .poll(() => client.getCheckoutStatus(workspace.cwd), { timeout: 15_000 }) + .poll(() => workspaceGit.getStatus(), { timeout: 15_000 }) .toMatchObject({ isGit: true, isDirty: true, }); - const diff = await client.getCheckoutDiff(workspace.cwd, { mode: "uncommitted" }); + const diff = await workspaceGit.getDiff({ mode: "uncommitted" }); expect(diff.error).toBeNull(); expect(diff.files).toContainEqual( expect.objectContaining({ path: "characterized.txt", status: "ok" }), ); + await expect( + workspaceGit.commit({ message: "characterize runtime Git", addAll: true }), + ).resolves.toMatchObject({ success: true, error: null }); + await expect(workspaceGit.refresh()).resolves.toMatchObject({ success: true, error: null }); + await expect(workspaceGit.getStatus()).resolves.toMatchObject({ isGit: true, isDirty: false }); } async function expectTerminalCommand(workspace: CharacterizedWorkspace): Promise { diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index e1544b1b85..f0c3a1a712 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -52,6 +52,7 @@ import { } from "../services/github-service.js"; import type { CheckDetails, ForgeService } from "../services/forge-service.js"; import type { GitHubPullRequestStatusFacts } from "../services/github-facts.js"; +import { bindWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js"; interface SessionHandlerInternals { interruptAgentIfRunning(agentId: string): Promise; @@ -242,7 +243,6 @@ vi.mock("../utils/checkout-git.js", async (importOriginal) => { mergeToBase: checkoutGitMocks.mergeToBase, pullCurrentBranch: checkoutGitMocks.pullCurrentBranch, pushCurrentBranch: checkoutGitMocks.pushCurrentBranch, - renameCurrentBranch: checkoutGitMocks.renameCurrentBranch, resolveBranchCheckout: checkoutGitMocks.resolveBranchCheckout, warmCheckoutShortstatInBackground: checkoutGitMocks.warmCheckoutShortstatInBackground, }; @@ -286,7 +286,10 @@ interface SessionForTestOptions { agentManager?: { [K in keyof SessionOptions["agentManager"]]?: unknown }; agentStorage?: { [K in keyof SessionOptions["agentStorage"]]?: unknown }; github?: Partial; - checkoutDiffManager?: { scheduleRefreshForCwd: ReturnType }; + checkoutDiffManager?: { + scheduleRefreshForCwd: ReturnType; + scheduleRefreshForWorkspace?: ReturnType; + }; workspaceGitService?: { getCheckout?: ReturnType; getCheckoutDiff?: ReturnType; @@ -331,10 +334,41 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { createPullRequest: vi.fn(), mergePullRequest: vi.fn(), }; - const checkoutDiffManager = options.checkoutDiffManager ?? { + const legacyCheckoutDiffManager = options.checkoutDiffManager ?? { scheduleRefreshForCwd: vi.fn(), }; + const checkoutDiffManager = { + ...legacyCheckoutDiffManager, + scheduleRefreshForWorkspace: + legacyCheckoutDiffManager.scheduleRefreshForWorkspace ?? + vi.fn((workspaceGit: { cwd: string }) => + legacyCheckoutDiffManager.scheduleRefreshForCwd(workspaceGit.cwd), + ), + }; + const boundWorkspaceGit = new Map>(); const workspaceGitService = { + bindLegacy(cwd: string) { + let bound = boundWorkspaceGit.get(`legacy:${cwd}`); + if (!bound) { + bound = bindWorkspaceGitService( + workspaceGitService as unknown as SessionOptions["workspaceGitService"], + cwd, + ); + boundWorkspaceGit.set(`legacy:${cwd}`, bound); + } + return bound; + }, + bindWorkspace({ workspaceId, cwd }: { workspaceId: string; cwd: string }) { + let bound = boundWorkspaceGit.get(workspaceId); + if (!bound) { + bound = bindWorkspaceGitService( + workspaceGitService as unknown as SessionOptions["workspaceGitService"], + cwd, + ); + boundWorkspaceGit.set(workspaceId, bound); + } + return bound; + }, getCheckout: vi.fn(), getCheckoutDiff: vi.fn(), getSnapshot: vi.fn(), @@ -350,7 +384,56 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { // Mirror production: invalidateForge resolves the forge and busts the // adapter's cache. The resolved forge here is github, so delegate to it. invalidateForge: vi.fn((cwd: string) => github.invalidate({ cwd })), + refresh: vi.fn(async (cwd: string) => { + workspaceGitService.invalidateForge(cwd); + await workspaceGitService.getSnapshot(cwd, { + force: true, + includeForge: true, + reason: "manual-refresh", + }); + }), getProjectSlug: vi.fn(), + commit: vi.fn((cwd: string, input: { message: string; addAll: boolean }) => + checkoutGitMocks.commitChanges(cwd, input), + ), + mergeToBase: vi.fn((cwd: string, input: { baseRef?: string; mode?: "merge" | "squash" }) => + checkoutGitMocks.mergeToBase(cwd, input, { + paseoHome: options.paseoHome ?? "/tmp/paseo-home", + }), + ), + mergeFromBase: vi.fn((cwd: string, input: { baseRef?: string }) => + checkoutGitMocks.mergeFromBase(cwd, input), + ), + pull: vi.fn((cwd: string) => checkoutGitMocks.pullCurrentBranch(cwd)), + push: vi.fn((cwd: string) => checkoutGitMocks.pushCurrentBranch(cwd)), + renameBranch: vi.fn((cwd: string, branch: string) => + checkoutGitMocks.renameCurrentBranch(cwd, branch), + ), + switchBranch: vi.fn((cwd: string, branch: string) => + checkoutGitMocks.checkoutResolvedBranch({ + cwd, + resolution: { kind: "local", name: branch }, + }), + ), + createBranch: vi.fn((cwd: string, input: { branch: string; baseRef: string }) => + gitCommandMocks.runGitCommand(["checkout", "-b", input.branch, input.baseRef], { + cwd, + timeout: 120_000, + }), + ), + fetch: vi.fn(), + stashPush: vi.fn((cwd: string, message: string) => + gitCommandMocks.runGitCommand(["stash", "push", "--include-untracked", "-m", message], { + cwd, + timeout: 120_000, + }), + ), + stashPop: vi.fn((cwd: string, index: number) => + gitCommandMocks.runGitCommand(["stash", "pop", `stash@{${index}}`], { + cwd, + timeout: 120_000, + }), + ), ...options.workspaceGitService, }; const messages = options.messages ?? []; @@ -1885,7 +1968,7 @@ describe("session checkout merge handling", () => { }); expect(github.invalidate).toHaveBeenCalledTimes(1); expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/base-worktree" }); - expect(checkoutDiffManager.scheduleRefreshForCwd).toHaveBeenCalledWith("/tmp/request-worktree"); + expect(checkoutDiffManager.scheduleRefreshForCwd).toHaveBeenCalledWith("/tmp/base-worktree"); expect(messages).toContainEqual({ type: "checkout_merge_response", payload: { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index d89b38cef7..f98b37d2a2 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -66,6 +66,10 @@ import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-uti import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket"; import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels"; import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; +import { + createWorkspaceGitDirectory, + type WorkspaceGitDirectory, +} from "./workspace-git-directory.js"; import type { ProjectUpdate } from "./workspace-reconciliation-service.js"; import { CLIENT_SHUTDOWN_RPC_REASON, @@ -177,7 +181,6 @@ import { archiveWorkspaceContents, } from "./workspace-archive-service.js"; import type { ServiceProxySubsystem } from "./service-proxy.js"; -import { renameCurrentBranch as renameCurrentBranchDefault } from "../utils/checkout-git.js"; import { createGitMutationService, type GitMutationService, @@ -458,9 +461,6 @@ export interface SessionOptions { checkoutDiffManager: CheckoutDiffManager; github?: ForgeService; createAgentMcpTransport?: AgentMcpTransportFactory; - // Injected so tests can substitute the git branch rename without module mocks; - // defaults to the real checkout-git implementation. - renameCurrentBranch?: typeof renameCurrentBranchDefault; workspaceGitService: WorkspaceGitService; workspaceAutoName: WorkspaceAutoName; daemonConfigStore: DaemonConfigStore; @@ -628,8 +628,8 @@ export class Session { private readonly workspaceRuntime: WorkspaceRuntimeService | undefined; private readonly filesystem: SessionFileSystem; private readonly github: ForgeService; - private readonly renameCurrentBranch: typeof renameCurrentBranchDefault; private readonly workspaceGitService: WorkspaceGitService; + private readonly workspaceGitDirectory: WorkspaceGitDirectory; private readonly workspaceAutoName: WorkspaceAutoName; private readonly gitMutation: GitMutationService; private readonly workspaceProvisioning: WorkspaceProvisioningService; @@ -713,7 +713,6 @@ export class Session { loopService, checkoutDiffManager, github, - renameCurrentBranch, workspaceGitService, workspaceAutoName, daemonConfigStore, @@ -779,8 +778,11 @@ export class Session { this.workspaceRuntime = workspaceRuntime; this.filesystem = filesystem ?? nodeSessionFileSystem; this.github = github ?? createGitHubService(); - this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault; this.workspaceGitService = workspaceGitService; + this.workspaceGitDirectory = createWorkspaceGitDirectory({ + workspaceRegistry: this.workspaceRegistry, + workspaceGitService: this.workspaceGitService, + }); this.gitMutation = createGitMutationService({ workspaceGitService: this.workspaceGitService, logger: this.sessionLogger, @@ -807,12 +809,11 @@ export class Session { host: { emit: (msg) => this.emit(msg), emitWorkspaceUpdateForCwd: (cwd) => this.emitWorkspaceUpdateForCwd(cwd), - handleWorkspaceGitBranchSnapshot: (cwd, branchName) => - this.workspaceGitObserver.handleBranchSnapshot(cwd, branchName), - renameCurrentBranch: (cwd, branch) => this.renameCurrentBranch(cwd, branch), + handleWorkspaceGitBranchSnapshot: (address, branchName) => + this.workspaceGitObserver.handleBranchSnapshot(address, branchName), }, gitMutation: this.gitMutation, - workspaceGitService: this.workspaceGitService, + workspaceGitDirectory: this.workspaceGitDirectory, github: this.github, checkoutDiffManager, gitMetadataGenerator: createGitMetadataGenerator({ @@ -824,18 +825,17 @@ export class Session { getFocusedSelection: (cwd) => this.getFocusedAgentSelectionForCwd(cwd), }), }), - paseoHome: this.paseoHome, - worktreesRoot: this.worktreesRoot, logger: this.sessionLogger, }); this.workspaceGitObserver = createWorkspaceGitObserverService({ - workspaceGitService: this.workspaceGitService, + resolveWorkspaceGit: (workspaceId, cwd) => + this.workspaceGitDirectory.getObservationBinding(workspaceId, cwd), describeWorkspaceRecordWithGitData: (workspace) => this.describeWorkspaceRecordWithGitData(workspace), - emitWorkspaceUpdateForCwd: (cwd) => this.emitWorkspaceUpdateForCwd(cwd), emitWorkspaceUpdateForWorkspaceId: (workspaceId) => this.emitWorkspaceUpdateForWorkspaceId(workspaceId), - emitStatusUpdate: (cwd, snapshot) => this.checkoutSession.emitStatusUpdate(cwd, snapshot), + emitStatusUpdate: (workspaceId, cwd, snapshot) => + this.checkoutSession.emitStatusUpdate(workspaceId, cwd, snapshot), onBranchChanged, logger: this.sessionLogger, }); @@ -995,7 +995,7 @@ export class Session { workspaceRuntime: this.workspaceRuntime, workspaceRegistry: this.workspaceRegistry, projectRegistry: this.projectRegistry, - workspaceGitService: this.workspaceGitService, + workspaceGitDirectory: this.workspaceGitDirectory, getDaemonTcpPort: this.getDaemonTcpPort, getDaemonTcpHost: this.getDaemonTcpHost, serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl, @@ -1747,7 +1747,7 @@ export class Session { if (!project) { throw new Error(`Project not found for workspace ${workspace.workspaceId}`); } - const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd); + const snapshot = this.workspaceGitDirectory.bindRecord(workspace).peekSnapshot(); const checkout = checkoutFromPersistedWorkspacePlacement({ workspace, // COMPAT(workspacePlacementBackfill): added in v0.1.107, remove after 2027-01-15. @@ -4447,7 +4447,7 @@ export class Session { projectRecord ?? (await this.projectRegistry.get(workspace.projectId)); let diffStat: { additions: number; deletions: number } | null = null; - const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd); + const snapshot = this.workspaceGitDirectory.bindRecord(workspace).peekSnapshot(); if (snapshot?.git.diffStat) { diffStat = snapshot.git.diffStat; } @@ -4520,7 +4520,7 @@ export class Session { projectRecord?: PersistedProjectRecord | null, ): Promise { const base = await this.describeWorkspaceRecord(workspace, projectRecord); - const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd); + const snapshot = this.workspaceGitDirectory.bindRecord(workspace).peekSnapshot(); if (!snapshot) { return base; } 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 c038559fce..9f92e6f228 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -18,8 +18,12 @@ import { createPersistedProjectRecord, createPersistedWorkspaceRecord, } from "./workspace-registry.js"; -import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js"; +import { + bindWorkspaceGitService, + createNoopWorkspaceGitService, +} from "./test-utils/workspace-git-service-stub.js"; import type { WorkspaceGitObserverService } from "./session/workspace-git-observer/workspace-git-observer-service.js"; +import type { WorkspaceGitDirectory } from "./workspace-git-directory.js"; interface SessionInternals { workspaceUpdatesSubscription: { @@ -31,6 +35,7 @@ interface SessionInternals { }; buildWorkspaceDescriptorMap: () => Promise>; workspaceGitObserver: WorkspaceGitObserverService; + workspaceGitDirectory: WorkspaceGitDirectory; listAgentPayloads: () => Promise; } @@ -38,7 +43,9 @@ interface SessionInternals { // integration tests drive it with a minimal git descriptor for one workspace, then push // snapshots through the captured WorkspaceGitService listener. function syncGitObserver(session: Session, cwd: string, workspaceId: string): void { - asInternals(session).workspaceGitObserver.syncObservers([ + const internals = asInternals(session); + internals.workspaceGitDirectory.bindRecord({ workspaceId, cwd, runtime: undefined }); + internals.workspaceGitObserver.syncObservers([ { id: workspaceId, workspaceDirectory: cwd, @@ -66,7 +73,7 @@ function getWorkspaceUpdates( } const REPO_CWD = path.resolve("/tmp/repo"); -const REPO_SUBSCRIPTION_REQUEST_ID = `subscription:${REPO_CWD}`; +const REPO_SUBSCRIPTION_REQUEST_ID = "subscription:ws-10"; function createWorkspaceRuntimeSnapshot( cwd: string, @@ -188,6 +195,9 @@ function createSessionForWorkspaceGitWatchTests(options?: { scheduleRefreshForCwd: vi.fn(), dispose: vi.fn(), }; + workspaceGitService.bindWorkspace = ({ cwd }) => + bindWorkspaceGitService(workspaceGitService, cwd); + workspaceGitService.bindLegacy = (cwd) => bindWorkspaceGitService(workspaceGitService, cwd); const session = new Session({ clientId: "test-client", diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 6f07b4131a..0e15421e74 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -153,6 +153,9 @@ interface SessionTestAccess { ): Promise; upsert(record: unknown): Promise; }; + workspaceGitDirectory: { + bindRecord(record: PersistedWorkspaceRecord): unknown; + }; agentUpdates: AgentUpdatesService; workspaceUpdatesSubscription: unknown; interruptAgentIfRunning(agentId: string): unknown; @@ -640,7 +643,9 @@ function createSessionForWorkspaceTests( archive: async () => {}, remove: async () => {}, }; - const workspaceGitService = options.workspaceGitService ?? createNoopWorkspaceGitService(); + const workspaceGitService = options.renameCurrentBranch + ? createNoopWorkspaceGitService({ renameBranch: options.renameCurrentBranch }) + : (options.workspaceGitService ?? createNoopWorkspaceGitService()); const providerSnapshotManager = createProviderSnapshotManagerStub().manager; const session = asTestSession( @@ -728,7 +733,12 @@ function createSessionForWorkspaceTests( initial: { cwd: "/tmp", files: [], error: null }, unsubscribe: () => {}, }), + subscribeWorkspace: async ({ workspaceGit }) => ({ + initial: { cwd: workspaceGit.cwd, files: [], error: null }, + unsubscribe: () => {}, + }), scheduleRefreshForCwd: () => {}, + scheduleRefreshForWorkspace: () => {}, onWorkspaceStateMayHaveChanged: () => {}, invalidateForge: () => {}, getMetrics: () => ({ @@ -753,7 +763,6 @@ function createSessionForWorkspaceTests( logger: asSessionLogger(logger), generateWorkspaceName: options.generateWorkspaceName, }), - renameCurrentBranch: options.renameCurrentBranch, daemonConfigStore: asDaemonConfigStore({ get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }), onChange: () => () => {}, @@ -7190,6 +7199,8 @@ test("subscribed fetch_workspaces includes git enrichment in the initial snapsho const describeWorkspaceRecordWithGitDataSubscribed = vi.fn(async () => enrichedGitDescriptor); session.describeWorkspaceRecord = describeWorkspaceRecordSubscribed; session.describeWorkspaceRecordWithGitData = describeWorkspaceRecordWithGitDataSubscribed; + session.workspaceGitDirectory.bindRecord(gitWorkspace); + session.workspaceGitDirectory.bindRecord(directoryWorkspace); await session.handleMessage({ type: "fetch_workspaces_request", @@ -7758,6 +7769,17 @@ test("queued workspace updates are dropped when a new workspace subscription rep } return new Map([[currentDescriptor.id, currentDescriptor]]); }; + session.workspaceGitDirectory.bindRecord( + createPersistedWorkspaceRecord({ + workspaceId: descriptor.id, + projectId: descriptor.projectId, + cwd: descriptor.workspaceDirectory, + kind: descriptor.workspaceKind, + displayName: descriptor.name, + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ); await session.handleMessage({ type: "fetch_workspaces_request", diff --git a/packages/server/src/server/session/checkout/checkout-session.test.ts b/packages/server/src/server/session/checkout/checkout-session.test.ts index bebc5e7a99..5631f7df22 100644 --- a/packages/server/src/server/session/checkout/checkout-session.test.ts +++ b/packages/server/src/server/session/checkout/checkout-session.test.ts @@ -19,6 +19,7 @@ import type { WorkspaceGitService, } from "../../workspace-git-service.js"; import { + bindWorkspaceGitService, createNoGitWorkspaceRuntimeSnapshot, createNoopWorkspaceGitService, } from "../../test-utils/workspace-git-service-stub.js"; @@ -44,10 +45,10 @@ function createFakeDiffSubscriber(initial: CheckoutDiffSnapshotPayload) { const subscriptions: FakeDiffSubscription[] = []; const refreshedCwds: string[] = []; const subscriber: CheckoutDiffSubscriber = { - subscribe: async (params, listener) => { + subscribeWorkspace: async (params, listener) => { let isSubscribed = true; const subscription: FakeDiffSubscription = { - cwd: params.cwd, + cwd: params.workspaceGit.cwd, compare: params.compare, unsubscribeCalls: 0, emit: (snapshot) => { @@ -66,12 +67,12 @@ function createFakeDiffSubscriber(initial: CheckoutDiffSnapshotPayload) { params.signal?.addEventListener("abort", unsubscribe, { once: true }); subscriptions.push(subscription); return { - initial: { ...initial, cwd: params.cwd }, + initial: { ...initial, cwd: params.workspaceGit.cwd }, unsubscribe, }; }, - scheduleRefreshForCwd: (cwd) => { - refreshedCwds.push(cwd); + scheduleRefreshForWorkspace: (workspaceGit) => { + refreshedCwds.push(workspaceGit.cwd); }, }; return { subscriber, subscriptions, refreshedCwds }; @@ -79,7 +80,12 @@ function createFakeDiffSubscriber(initial: CheckoutDiffSnapshotPayload) { interface RecordedHostCalls { emitWorkspaceUpdateForCwd: string[]; - handleWorkspaceGitBranchSnapshot: Array<{ cwd: string; branchName: string | null }>; + handleWorkspaceGitBranchSnapshot: Array<{ + address: + | { kind: "selected"; cwd: string; workspaceId: string } + | { kind: "legacy"; cwd: string }; + branchName: string | null; + }>; renameCurrentBranch: Array<{ cwd: string; branch: string }>; } @@ -126,12 +132,8 @@ function makeCheckoutSession(options?: { emitWorkspaceUpdateForCwd: async (cwd) => { hostCalls.emitWorkspaceUpdateForCwd.push(cwd); }, - handleWorkspaceGitBranchSnapshot: (cwd, branchName) => { - hostCalls.handleWorkspaceGitBranchSnapshot.push({ cwd, branchName }); - }, - renameCurrentBranch: async (cwd, branch) => { - hostCalls.renameCurrentBranch.push({ cwd, branch }); - return { previousBranch: null, currentBranch: branch }; + handleWorkspaceGitBranchSnapshot: (address, branchName) => { + hostCalls.handleWorkspaceGitBranchSnapshot.push({ address, branchName }); }, ...options?.host, }; @@ -157,16 +159,33 @@ function makeCheckoutSession(options?: { ...options?.gitMetadataGenerator, }; const github: ForgeService = { ...createGitHubService(), ...options?.github }; + const workspaceGitService = createNoopWorkspaceGitService({ + renameBranch: async (cwd, branch) => { + hostCalls.renameCurrentBranch.push({ cwd, branch }); + return { previousBranch: null, currentBranch: branch }; + }, + ...options?.git, + }); const checkout = new CheckoutSession({ host, - gitMutation, - workspaceGitService: createNoopWorkspaceGitService(options?.git), + gitMutation: { + bind: (workspaceGit) => ({ + checkoutExistingBranch: (branch) => + gitMutation.checkoutExistingBranch(workspaceGit.cwd, branch), + createBranchFromBase: async () => {}, + notify: (reason, notifyOptions) => + gitMutation.notifyGitMutation(workspaceGit.cwd, reason, notifyOptions), + }), + }, + workspaceGitDirectory: { + resolve: async ({ cwd }) => bindWorkspaceGitService(workspaceGitService, cwd), + bindRecord: (record) => bindWorkspaceGitService(workspaceGitService, record.cwd), + getBound: (_workspaceId, cwd) => bindWorkspaceGitService(workspaceGitService, cwd), + }, github, checkoutDiffManager: options?.diff ?? createFakeDiffSubscriber({ cwd: "", files: [], error: null }).subscriber, gitMetadataGenerator, - paseoHome: "/tmp/paseo-home", - worktreesRoot: undefined, logger: pino({ level: "silent" }), }); return { checkout, emitted, hostCalls, gitMutationCalls, generatorCalls }; @@ -375,8 +394,8 @@ describe("CheckoutSession", () => { }); describe("refresh", () => { - it("forces a github-inclusive snapshot, nudges diffs, and confirms success", async () => { - const snapshotCalls: Array<{ cwd: string; options: unknown }> = []; + it("refreshes the bound workspace, nudges diffs, and confirms success", async () => { + const refreshCalls: Array<{ cwd: string; options: unknown }> = []; const { subscriber, refreshedCwds } = createFakeDiffSubscriber({ cwd: "", files: [], @@ -384,9 +403,8 @@ describe("CheckoutSession", () => { }); const { checkout, emitted } = makeCheckoutSession({ git: { - getSnapshot: async (cwd, snapshotOptions) => { - snapshotCalls.push({ cwd, options: snapshotOptions }); - return createNoGitWorkspaceRuntimeSnapshot(cwd); + refresh: async (cwd, refreshOptions) => { + refreshCalls.push({ cwd, options: refreshOptions }); }, }, diff: subscriber, @@ -398,9 +416,7 @@ describe("CheckoutSession", () => { requestId: "r7", }); - expect(snapshotCalls).toEqual([ - { cwd: "/repo", options: { force: true, includeForge: true, reason: "manual-refresh" } }, - ]); + expect(refreshCalls).toEqual([{ cwd: "/repo", options: { priority: "high" } }]); expect(refreshedCwds).toEqual(["/repo"]); expect(emitted).toEqual([ { @@ -411,7 +427,7 @@ describe("CheckoutSession", () => { }); it("expands a tilde cwd before refreshing git and diffs", async () => { - const snapshotCalls: string[] = []; + const refreshCalls: string[] = []; const { subscriber, refreshedCwds } = createFakeDiffSubscriber({ cwd: "", files: [], @@ -419,9 +435,8 @@ describe("CheckoutSession", () => { }); const { checkout } = makeCheckoutSession({ git: { - getSnapshot: async (cwd) => { - snapshotCalls.push(cwd); - return createNoGitWorkspaceRuntimeSnapshot(cwd); + refresh: async (cwd) => { + refreshCalls.push(cwd); }, }, diff: subscriber, @@ -434,7 +449,7 @@ describe("CheckoutSession", () => { }); const resolvedCwd = expandTilde("~/repo"); - expect(snapshotCalls).toEqual([resolvedCwd]); + expect(refreshCalls).toEqual([resolvedCwd]); expect(refreshedCwds).toEqual([resolvedCwd]); }); }); @@ -550,12 +565,16 @@ describe("CheckoutSession", () => { it("emits a checkout status update for a workspace git snapshot", () => { const { checkout, emitted } = makeCheckoutSession(); - checkout.emitStatusUpdate("/repo", createGitSnapshot("/repo", "main")); + checkout.emitStatusUpdate("workspace-1", "/repo", createGitSnapshot("/repo", "main")); expect(emitted).toEqual([ { type: "checkout_status_update", - payload: expect.objectContaining({ cwd: "/repo", currentBranch: "main" }), + payload: expect.objectContaining({ + workspaceId: "workspace-1", + cwd: "/repo", + currentBranch: "main", + }), }, ]); }); @@ -672,7 +691,7 @@ describe("CheckoutSession", () => { ]); expect(refreshedCwds).toEqual(["/repo"]); expect(hostCalls.handleWorkspaceGitBranchSnapshot).toEqual([ - { cwd: "/repo", branchName: "feature-renamed" }, + { address: { kind: "legacy", cwd: "/repo" }, branchName: "feature-renamed" }, ]); expect(hostCalls.emitWorkspaceUpdateForCwd).toEqual(["/repo"]); expect(emitted).toEqual([ diff --git a/packages/server/src/server/session/checkout/checkout-session.ts b/packages/server/src/server/session/checkout/checkout-session.ts index 091ee620f7..058570e24d 100644 --- a/packages/server/src/server/session/checkout/checkout-session.ts +++ b/packages/server/src/server/session/checkout/checkout-session.ts @@ -19,7 +19,7 @@ import type { import type { CheckoutDiffSnapshotPayload, CheckoutDiffSubscription, - CheckoutDiffSubscriptionRequest, + CheckoutDiffWorkspaceSubscriptionRequest, } from "../../checkout-diff-manager.js"; import { toCheckoutError } from "../../checkout-git-utils.js"; import { @@ -28,9 +28,11 @@ import { } from "../../checkout/status-projection.js"; import type { WorkspaceGitRuntimeSnapshot, - WorkspaceGitService, WorkspaceGitSnapshotOptions, + WorkspaceGitWorkspace, } from "../../workspace-git-service.js"; +import type { WorkspaceGitDirectory } from "../../workspace-git-directory.js"; +import type { WorkspaceGitAddress } from "../../workspace-git-directory.js"; import { assertSafeGitRef } from "../../worktree-session.js"; import type { GitMutationService } from "../git-mutation/git-mutation-service.js"; import type { @@ -40,18 +42,10 @@ import type { SearchResult, } from "../../../services/forge-service.js"; import { - commitChanges, createPullRequest, forgeAuthStateFromError, isForgeAuthError, - mergeFromBase, - mergeToBase, - pullCurrentBranch, - pushCurrentBranch, - listCheckoutCommits, - getCommitFileDiff, } from "../../../utils/checkout-git.js"; -import { runGitCommand } from "../../../utils/run-git-command.js"; import { expandTilde } from "../../../utils/path.js"; import type { GitMetadataGenerator } from "./git-metadata-generator.js"; @@ -67,11 +61,7 @@ import type { GitMetadataGenerator } from "./git-metadata-generator.js"; export interface CheckoutSessionHost { emit(msg: SessionOutboundMessage): void; emitWorkspaceUpdateForCwd(cwd: string): Promise; - handleWorkspaceGitBranchSnapshot(cwd: string, branchName: string | null): void; - renameCurrentBranch( - cwd: string, - branch: string, - ): Promise<{ previousBranch: string | null; currentBranch: string | null }>; + handleWorkspaceGitBranchSnapshot(address: WorkspaceGitAddress, branchName: string | null): void; } type CurrentWorkspacePullRequest = NonNullable< @@ -107,22 +97,20 @@ function toLegacyGithubSearchItems(items: ForgeSearchResultItem[]): LegacyGithub * real CheckoutDiffManager satisfies this structurally; tests supply a fake. */ export interface CheckoutDiffSubscriber { - subscribe( - params: CheckoutDiffSubscriptionRequest, + subscribeWorkspace( + params: CheckoutDiffWorkspaceSubscriptionRequest, listener: (snapshot: CheckoutDiffSnapshotPayload) => void, ): Promise; - scheduleRefreshForCwd(cwd: string): void; + scheduleRefreshForWorkspace(workspaceGit: WorkspaceGitWorkspace): void; } export interface CheckoutSessionOptions { host: CheckoutSessionHost; - gitMutation: Pick; - workspaceGitService: WorkspaceGitService; + gitMutation: Pick; + workspaceGitDirectory: WorkspaceGitDirectory; github: ForgeService; checkoutDiffManager: CheckoutDiffSubscriber; gitMetadataGenerator: GitMetadataGenerator; - paseoHome: string; - worktreesRoot: string | undefined; logger: pino.Logger; } @@ -140,35 +128,40 @@ export class CheckoutSession { private static readonly PASEO_STASH_PREFIX = "paseo-auto-stash:"; private readonly host: CheckoutSessionHost; - private readonly gitMutation: Pick< - GitMutationService, - "checkoutExistingBranch" | "notifyGitMutation" - >; - private readonly workspaceGitService: WorkspaceGitService; + private readonly gitMutation: Pick; + private readonly workspaceGitDirectory: WorkspaceGitDirectory; private readonly github: ForgeService; private readonly checkoutDiffManager: CheckoutDiffSubscriber; private readonly gitMetadataGenerator: GitMetadataGenerator; - private readonly paseoHome: string; - private readonly worktreesRoot: string | undefined; private readonly logger: pino.Logger; private readonly diffSubscriptions = new Map void>(); constructor(options: CheckoutSessionOptions) { this.host = options.host; this.gitMutation = options.gitMutation; - this.workspaceGitService = options.workspaceGitService; + this.workspaceGitDirectory = options.workspaceGitDirectory; this.github = options.github; this.checkoutDiffManager = options.checkoutDiffManager; this.gitMetadataGenerator = options.gitMetadataGenerator; - this.paseoHome = options.paseoHome; - this.worktreesRoot = options.worktreesRoot; this.logger = options.logger; } + private resolveWorkspaceGit(input: { + cwd: string; + workspaceId?: string; + }): Promise { + const cwd = expandTilde(input.cwd); + return this.workspaceGitDirectory.resolve( + input.workspaceId === undefined + ? { kind: "legacy", cwd } + : { kind: "selected", workspaceId: input.workspaceId, cwd }, + ); + } + private async resolveForgeService( - cwd: string, + workspaceGit: WorkspaceGitWorkspace, ): Promise<{ forge: string; service: ForgeService } | null> { - const resolution = await this.workspaceGitService.resolveForge(cwd); + const resolution = await workspaceGit.resolveForge(); if (!resolution) { return null; } @@ -176,29 +169,32 @@ export class CheckoutSession { } private async requireForgeService( - cwd: string, + workspaceGit: WorkspaceGitWorkspace, ): Promise<{ forge: string; service: ForgeService }> { - const resolution = await this.resolveForgeService(cwd); + const resolution = await this.resolveForgeService(workspaceGit); if (!resolution) { - throw new NoResolvedForgeServiceError(cwd); + throw new NoResolvedForgeServiceError(workspaceGit.cwd); } return resolution; } - private async resolveForgeIdForError(cwd: string): Promise { + private async resolveForgeIdForError(workspaceGit: WorkspaceGitWorkspace): Promise { try { - return (await this.workspaceGitService.resolveForge(cwd))?.forge ?? "github"; + return (await workspaceGit.resolveForge())?.forge ?? "github"; } catch { return "github"; } } - private async resolveAuthStateForError(cwd: string, error: unknown): Promise { + private async resolveAuthStateForError( + workspaceGit: WorkspaceGitWorkspace, + error: unknown, + ): Promise { if (error instanceof NoResolvedForgeServiceError) { return error.authState; } try { - return (await this.workspaceGitService.resolveForge(cwd)) ? "error" : "no_remote"; + return (await workspaceGit.resolveForge()) ? "error" : "no_remote"; } catch { return "error"; } @@ -211,14 +207,17 @@ export class CheckoutSession { * independently in the same error path. */ private async resolveForgeContextForError( - cwd: string, + workspaceGit: WorkspaceGitWorkspace, error: unknown, ): Promise<{ forge: string; authState: ForgeAuthState }> { if (error instanceof NoResolvedForgeServiceError) { - return { forge: await this.resolveForgeIdForError(cwd), authState: error.authState }; + return { + forge: await this.resolveForgeIdForError(workspaceGit), + authState: error.authState, + }; } try { - const resolution = await this.workspaceGitService.resolveForge(cwd); + const resolution = await workspaceGit.resolveForge(); return { forge: resolution?.forge ?? "github", authState: resolution ? "error" : "no_remote", @@ -230,22 +229,21 @@ export class CheckoutSession { async handleStatusRequest(msg: CheckoutStatusRequest): Promise { const { cwd, requestId } = msg; - const resolvedCwd = expandTilde(cwd); - try { - const snapshot = await this.workspaceGitService.getSnapshot(resolvedCwd); + const workspaceGit = await this.resolveWorkspaceGit(msg); + const snapshot = await workspaceGit.getSnapshot(); this.host.emit({ type: "checkout_status_response", - payload: buildCheckoutStatusPayloadFromSnapshot({ - cwd, - requestId, - snapshot, - }), + payload: { + ...(msg.workspaceId ? { workspaceId: msg.workspaceId } : {}), + ...buildCheckoutStatusPayloadFromSnapshot({ cwd, requestId, snapshot }), + }, }); } catch (error) { this.host.emit({ type: "checkout_status_response", payload: { + ...(msg.workspaceId ? { workspaceId: msg.workspaceId } : {}), cwd, isGit: false, repoRoot: null, @@ -269,7 +267,8 @@ export class CheckoutSession { const { cwd, requestId } = msg; try { - const { baseRef, commits } = await listCheckoutCommits({ cwd: expandTilde(cwd) }); + const workspaceGit = await this.resolveWorkspaceGit(msg); + const { baseRef, commits } = await workspaceGit.listCommits(); this.host.emit({ type: "checkout.commits.list.response", payload: { cwd, baseRef, commits, error: null, requestId }, @@ -290,7 +289,11 @@ export class CheckoutSession { if (path.length === 0 || isAbsolute(path) || path.split(/[\\/]/).includes("..")) { throw new Error(`Invalid path: ${path}`); } - const file = await getCommitFileDiff({ cwd: expandTilde(cwd), sha, path }); + const workspaceGit = await this.resolveWorkspaceGit(msg); + const file = await workspaceGit.getCommitFileDiff({ + sha, + path, + }); this.host.emit({ type: "checkout.commits.file_diff.response", payload: { cwd, sha, path, file, error: null, requestId }, @@ -304,13 +307,13 @@ export class CheckoutSession { } async handleValidateBranchRequest(msg: ValidateBranchRequest): Promise { - const { cwd, branchName, requestId } = msg; + const { branchName, requestId } = msg; try { - const resolvedCwd = expandTilde(cwd); assertSafeGitRef(branchName, "branch"); - const resolution = await this.workspaceGitService.validateBranchRef(resolvedCwd, branchName); + const workspaceGit = await this.resolveWorkspaceGit(msg); + const resolution = await workspaceGit.validateBranchRef(branchName); switch (resolution.kind) { case "local": this.host.emit({ @@ -368,11 +371,11 @@ export class CheckoutSession { } async handleBranchSuggestionsRequest(msg: BranchSuggestionsRequest): Promise { - const { cwd, query, limit, requestId } = msg; + const { query, limit, requestId } = msg; try { - const resolvedCwd = expandTilde(cwd); - const branchDetails = await this.workspaceGitService.suggestBranchesForCwd(resolvedCwd, { + const workspaceGit = await this.resolveWorkspaceGit(msg); + const branchDetails = await workspaceGit.suggestBranches({ query, limit, }); @@ -399,20 +402,21 @@ export class CheckoutSession { } async handleSubscribeDiffRequest(msg: SubscribeCheckoutDiffRequest): Promise { - const cwd = expandTilde(msg.cwd); this.diffSubscriptions.get(msg.subscriptionId)?.(); const abort = new AbortController(); const unsubscribe = () => abort.abort(); this.diffSubscriptions.set(msg.subscriptionId, unsubscribe); try { - const subscription = await this.checkoutDiffManager.subscribe( - { cwd, compare: msg.compare, signal: abort.signal }, + const workspaceGit = await this.resolveWorkspaceGit(msg); + const subscription = await this.checkoutDiffManager.subscribeWorkspace( + { workspaceGit, compare: msg.compare, signal: abort.signal }, (snapshot) => { this.host.emit({ type: "checkout_diff_update", payload: { subscriptionId: msg.subscriptionId, + ...(msg.workspaceId ? { workspaceId: msg.workspaceId } : {}), ...snapshot, }, }); @@ -423,6 +427,7 @@ export class CheckoutSession { type: "subscribe_checkout_diff_response", payload: { subscriptionId: msg.subscriptionId, + ...(msg.workspaceId ? { workspaceId: msg.workspaceId } : {}), ...subscription.initial, requestId: msg.requestId, }, @@ -444,16 +449,11 @@ export class CheckoutSession { async handleRefreshRequest(msg: CheckoutRefreshRequest): Promise { const { cwd, requestId } = msg; - const resolvedCwd = expandTilde(cwd); try { - (await this.resolveForgeService(resolvedCwd))?.service.invalidate({ cwd: resolvedCwd }); - await this.workspaceGitService.getSnapshot(resolvedCwd, { - force: true, - includeForge: true, - reason: "manual-refresh", - }); - this.checkoutDiffManager.scheduleRefreshForCwd(resolvedCwd); + const workspaceGit = await this.resolveWorkspaceGit(msg); + await workspaceGit.refresh({ priority: "high" }); + this.checkoutDiffManager.scheduleRefreshForWorkspace(workspaceGit); this.host.emit({ type: "checkout.refresh.response", payload: { @@ -476,12 +476,13 @@ export class CheckoutSession { } } - emitStatusUpdate(cwd: string, snapshot: WorkspaceGitRuntimeSnapshot): void { + emitStatusUpdate(workspaceId: string, cwd: string, snapshot: WorkspaceGitRuntimeSnapshot): void { try { - const requestId = `subscription:${cwd}`; + const requestId = `subscription:${workspaceId}`; this.host.emit({ type: "checkout_status_update", payload: { + workspaceId, ...buildCheckoutStatusPayloadFromSnapshot({ cwd, requestId, @@ -503,8 +504,8 @@ export class CheckoutSession { * Notify the live diff subscriptions that the working tree at `cwd` changed. * Called by the command handlers below after they mutate the repository. */ - private scheduleDiffRefresh(cwd: string): void { - this.checkoutDiffManager.scheduleRefreshForCwd(cwd); + private scheduleDiffRefresh(workspaceGit: WorkspaceGitWorkspace): void { + this.checkoutDiffManager.scheduleRefreshForWorkspace(workspaceGit); } // --------------------------------------------------------------------------- @@ -517,8 +518,11 @@ export class CheckoutSession { const { cwd, branch, requestId } = msg; try { - const checkoutResult = await this.gitMutation.checkoutExistingBranch(cwd, branch); - this.scheduleDiffRefresh(cwd); + const workspaceGit = await this.resolveWorkspaceGit(msg); + const checkoutResult = await this.gitMutation + .bind(workspaceGit) + .checkoutExistingBranch(branch); + this.scheduleDiffRefresh(workspaceGit); // Push a workspace_update immediately so the sidebar/header reflect // the new branch name without waiting for the background git watcher. @@ -568,10 +572,18 @@ export class CheckoutSession { } try { - const result = await this.host.renameCurrentBranch(cwd, branch); - await this.gitMutation.notifyGitMutation(cwd, "rename-branch", { invalidateForge: true }); - this.scheduleDiffRefresh(cwd); - this.host.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch); + const workspaceGit = await this.resolveWorkspaceGit(msg); + const result = await workspaceGit.renameBranch(branch); + await this.gitMutation.bind(workspaceGit).notify("rename-branch", { + invalidateForge: true, + }); + this.scheduleDiffRefresh(workspaceGit); + this.host.handleWorkspaceGitBranchSnapshot( + msg.workspaceId === undefined + ? { kind: "legacy", cwd } + : { kind: "selected", workspaceId: msg.workspaceId, cwd }, + result.currentBranch, + ); // Branch is a git fact derived per-descriptor from each workspace's own // live git snapshot (id → cwd); the reconciliation pass re-persists the @@ -610,16 +622,14 @@ export class CheckoutSession { ): Promise { const { cwd, requestId } = msg; try { + const workspaceGit = await this.resolveWorkspaceGit(msg); const branchLabel = msg.branch?.trim() ?? ""; const message = branchLabel ? `${CheckoutSession.PASEO_STASH_PREFIX} ${branchLabel}` : `${CheckoutSession.PASEO_STASH_PREFIX} unnamed`; - await runGitCommand(["stash", "push", "--include-untracked", "-m", message], { - cwd, - timeout: 120_000, - }); - await this.gitMutation.notifyGitMutation(cwd, "stash-push"); - this.scheduleDiffRefresh(cwd); + await workspaceGit.stashPush(message); + await this.gitMutation.bind(workspaceGit).notify("stash-push"); + this.scheduleDiffRefresh(workspaceGit); this.host.emit({ type: "stash_save_response", payload: { cwd, success: true, error: null, requestId }, @@ -637,12 +647,10 @@ export class CheckoutSession { ): Promise { const { cwd, stashIndex, requestId } = msg; try { - await runGitCommand(["stash", "pop", `stash@{${stashIndex}}`], { - cwd, - timeout: 120_000, - }); - await this.gitMutation.notifyGitMutation(cwd, "stash-pop"); - this.scheduleDiffRefresh(cwd); + const workspaceGit = await this.resolveWorkspaceGit(msg); + await workspaceGit.stashPop(stashIndex); + await this.gitMutation.bind(workspaceGit).notify("stash-pop"); + this.scheduleDiffRefresh(workspaceGit); this.host.emit({ type: "stash_pop_response", payload: { cwd, success: true, error: null, requestId }, @@ -661,7 +669,8 @@ export class CheckoutSession { const { cwd, requestId } = msg; const paseoOnly = msg.paseoOnly !== false; try { - const entries = await this.workspaceGitService.listStashes(cwd, { paseoOnly }); + const workspaceGit = await this.resolveWorkspaceGit(msg); + const entries = await workspaceGit.listStashes({ paseoOnly }); this.host.emit({ type: "stash_list_response", @@ -681,20 +690,24 @@ export class CheckoutSession { const { cwd, requestId } = msg; try { + const workspaceGit = await this.resolveWorkspaceGit(msg); let message = msg.message?.trim() ?? ""; if (!message) { + if (msg.workspaceId) { + throw new Error("Selected workspace Git requires an explicit commit message"); + } message = await this.gitMetadataGenerator.generateCommitMessage(cwd); } if (!message) { throw new Error("Commit message is required"); } - await commitChanges(cwd, { + await workspaceGit.commit({ message, addAll: msg.addAll ?? true, }); - await this.gitMutation.notifyGitMutation(cwd, "commit-changes"); - this.scheduleDiffRefresh(cwd); + await this.gitMutation.bind(workspaceGit).notify("commit-changes"); + this.scheduleDiffRefresh(workspaceGit); this.host.emit({ type: "checkout_commit_response", @@ -724,7 +737,8 @@ export class CheckoutSession { const { cwd, requestId } = msg; try { - const snapshot = await this.workspaceGitService.getSnapshot(cwd); + const workspaceGit = await this.resolveWorkspaceGit(msg); + const snapshot = await workspaceGit.getSnapshot(); if (!snapshot.git.isGit) { throw new Error(`Not a git repository: ${cwd}`); } @@ -743,19 +757,18 @@ export class CheckoutSession { baseRef = baseRef.slice("origin/".length); } - const mutatedCwd = await mergeToBase( - cwd, - { - baseRef, - mode: msg.strategy === "squash" ? "squash" : "merge", - }, - { paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot }, - ); - await Promise.all([ - this.gitMutation.notifyGitMutation(mutatedCwd, "merge-to-base", { invalidateForge: true }), - ...(mutatedCwd !== cwd ? [this.gitMutation.notifyGitMutation(cwd, "merge-to-base")] : []), - ]); - this.scheduleDiffRefresh(cwd); + const mutatedCwd = await workspaceGit.mergeToBase({ + baseRef, + mode: msg.strategy === "squash" ? "squash" : "merge", + }); + const mutatedWorkspaceGit = + mutatedCwd === workspaceGit.cwd + ? workspaceGit + : await this.workspaceGitDirectory.resolve({ kind: "legacy", cwd: mutatedCwd }); + await this.gitMutation.bind(mutatedWorkspaceGit).notify("merge-to-base", { + invalidateForge: true, + }); + this.scheduleDiffRefresh(mutatedWorkspaceGit); this.host.emit({ type: "checkout_merge_response", @@ -785,19 +798,22 @@ export class CheckoutSession { const { cwd, requestId } = msg; try { + const workspaceGit = await this.resolveWorkspaceGit(msg); if (msg.requireCleanTarget ?? true) { - const snapshot = await this.workspaceGitService.getSnapshot(cwd); + const snapshot = await workspaceGit.getSnapshot(); if (snapshot.git.isDirty) { throw new Error("Working directory has uncommitted changes."); } } - await mergeFromBase(cwd, { + await workspaceGit.mergeFromBase({ baseRef: msg.baseRef, requireCleanTarget: msg.requireCleanTarget ?? true, }); - await this.gitMutation.notifyGitMutation(cwd, "merge-from-base", { invalidateForge: true }); - this.scheduleDiffRefresh(cwd); + await this.gitMutation.bind(workspaceGit).notify("merge-from-base", { + invalidateForge: true, + }); + this.scheduleDiffRefresh(workspaceGit); this.host.emit({ type: "checkout_merge_from_base_response", @@ -827,9 +843,10 @@ export class CheckoutSession { const { cwd, requestId } = msg; try { - await pullCurrentBranch(cwd); - await this.gitMutation.notifyGitMutation(cwd, "pull", { invalidateForge: true }); - this.scheduleDiffRefresh(cwd); + const workspaceGit = await this.resolveWorkspaceGit(msg); + await workspaceGit.pull(); + await this.gitMutation.bind(workspaceGit).notify("pull", { invalidateForge: true }); + this.scheduleDiffRefresh(workspaceGit); this.host.emit({ type: "checkout_pull_response", @@ -859,8 +876,9 @@ export class CheckoutSession { const { cwd, requestId } = msg; try { - await pushCurrentBranch(cwd); - await this.gitMutation.notifyGitMutation(cwd, "push", { invalidateForge: true }); + const workspaceGit = await this.resolveWorkspaceGit(msg); + await workspaceGit.push(); + await this.gitMutation.bind(workspaceGit).notify("push", { invalidateForge: true }); this.host.emit({ type: "checkout_push_response", payload: { @@ -889,6 +907,10 @@ export class CheckoutSession { const { cwd, requestId } = msg; try { + const workspaceGit = await this.resolveWorkspaceGit(msg); + if (msg.workspaceId) { + throw new Error("Selected workspace Git does not support pull requests"); + } let title = msg.title?.trim() ?? ""; let body = msg.body?.trim() ?? ""; @@ -898,7 +920,7 @@ export class CheckoutSession { if (!body) body = generated.body; } - const { service } = await this.requireForgeService(cwd); + const { service } = await this.requireForgeService(workspaceGit); const result = await createPullRequest( cwd, { @@ -908,7 +930,7 @@ export class CheckoutSession { }, service, ); - await this.gitMutation.notifyGitMutation(cwd, "create-pr", { invalidateForge: true }); + await this.gitMutation.bind(workspaceGit).notify("create-pr", { invalidateForge: true }); this.host.emit({ type: "checkout_pr_create_response", @@ -940,19 +962,20 @@ export class CheckoutSession { const { cwd, requestId } = msg; try { - const pullRequest = await this.resolveCurrentPullRequest(cwd, "merge", { + const workspaceGit = await this.resolveWorkspaceGit(msg); + const pullRequest = await this.resolveCurrentPullRequest(workspaceGit, "merge", { force: true, includeForge: true, reason: "merge-pr-validation", }); - const { service } = await this.requireForgeService(cwd); + const { service } = await this.requireForgeService(workspaceGit); await service.mergePullRequest({ cwd, prNumber: pullRequest.number, mergeMethod: msg.mergeMethod, status: pullRequest, }); - await this.gitMutation.notifyGitMutation(cwd, "merge-pr", { invalidateForge: true }); + await this.gitMutation.bind(workspaceGit).notify("merge-pr", { invalidateForge: true }); this.host.emit({ type: "checkout_pr_merge_response", @@ -991,12 +1014,13 @@ export class CheckoutSession { : "checkout.github.set_auto_merge.response"; try { - const pullRequest = await this.resolveCurrentPullRequest(cwd, "auto-merge", { + const workspaceGit = await this.resolveWorkspaceGit(msg); + const pullRequest = await this.resolveCurrentPullRequest(workspaceGit, "auto-merge", { force: true, includeForge: true, reason: "auto-merge-validation", }); - const { service } = await this.requireForgeService(cwd); + const { service } = await this.requireForgeService(workspaceGit); if (msg.enabled) { const mergeMethod = msg.mergeMethod; if (!mergeMethod) { @@ -1018,13 +1042,11 @@ export class CheckoutSession { status: pullRequest, }); } - await this.gitMutation.notifyGitMutation( - cwd, - msg.enabled ? "enable-pr-auto-merge" : "disable-pr-auto-merge", - { + await this.gitMutation + .bind(workspaceGit) + .notify(msg.enabled ? "enable-pr-auto-merge" : "disable-pr-auto-merge", { invalidateForge: true, - }, - ); + }); this.host.emit({ type: responseType, @@ -1051,11 +1073,11 @@ export class CheckoutSession { } private async resolveCurrentPullRequest( - cwd: string, + workspaceGit: WorkspaceGitWorkspace, operation: "merge" | "auto-merge", options?: WorkspaceGitSnapshotOptions, ): Promise { - const snapshot = await this.workspaceGitService.getSnapshot(cwd, options); + const snapshot = await workspaceGit.getSnapshot(options); const pullRequest = snapshot.forge.pullRequest; if (!pullRequest || typeof pullRequest.number !== "number") { throw new Error(`Unable to determine current change request number for ${operation}`); @@ -1067,9 +1089,11 @@ export class CheckoutSession { msg: Extract, ): Promise { const { cwd, requestId } = msg; + let workspaceGit: WorkspaceGitWorkspace | null = null; try { - const snapshot = await this.workspaceGitService.getSnapshot(cwd); + workspaceGit = await this.resolveWorkspaceGit(msg); + const snapshot = await workspaceGit.getSnapshot(); this.host.emit({ type: "checkout_pr_status_response", payload: buildCheckoutPrStatusPayloadFromSnapshot({ @@ -1079,7 +1103,9 @@ export class CheckoutSession { }), }); } catch (error) { - const { forge, authState } = await this.resolveForgeContextForError(cwd, error); + const { forge, authState } = workspaceGit + ? await this.resolveForgeContextForError(workspaceGit, error) + : { forge: "github", authState: "error" as const }; this.host.emit({ type: "checkout_pr_status_response", payload: { @@ -1119,7 +1145,8 @@ export class CheckoutSession { return; } - const resolvedForge = await this.resolveForgeService(cwd); + const workspaceGit = await this.resolveWorkspaceGit(msg); + const resolvedForge = await this.resolveForgeService(workspaceGit); if (!resolvedForge) { this.host.emit({ type: "pull_request_timeline_response", @@ -1244,7 +1271,8 @@ export class CheckoutSession { "Check details request must address a check by checkRunId or workflowRunId", ); } - const { service } = await this.requireForgeService(cwd); + const workspaceGit = await this.resolveWorkspaceGit(msg); + const { service } = await this.requireForgeService(workspaceGit); const details = await service.getCheckDetails({ cwd, repoOwner, @@ -1287,13 +1315,17 @@ export class CheckoutSession { try { const resolvedCwd = expandTilde(cwd); + const workspaceGit = await this.resolveWorkspaceGit(msg); // COMPAT(githubSearchRpc): added in v0.1.106, remove after 2026-12-28 — // the legacy github_search RPC is GitHub by definition; the modern // forge.search RPC resolves the cwd's forge. - const resolvedForge = - msg.type === "github_search_request" - ? { forge: "github", service: this.github } - : await this.resolveForgeService(resolvedCwd); + let resolvedForge: { forge: string; service: ForgeService } | null; + if (msg.type === "github_search_request") { + await workspaceGit.resolveForge(); + resolvedForge = { forge: "github", service: this.github }; + } else { + resolvedForge = await this.resolveForgeService(workspaceGit); + } if (!resolvedForge) { if (msg.type === "github_search_request") { this.host.emit({ @@ -1357,8 +1389,12 @@ export class CheckoutSession { }, }); } catch (error) { - const resolvedCwd = expandTilde(cwd); - const authState = await this.resolveAuthStateForError(resolvedCwd, error); + let authState: ForgeAuthState = "error"; + try { + authState = await this.resolveAuthStateForError(await this.resolveWorkspaceGit(msg), error); + } catch { + authState = "error"; + } if (msg.type === "github_search_request") { this.host.emit({ type: "github_search_response", diff --git a/packages/server/src/server/session/git-mutation/git-mutation-service.test.ts b/packages/server/src/server/session/git-mutation/git-mutation-service.test.ts index 1910722765..a37d7bf800 100644 --- a/packages/server/src/server/session/git-mutation/git-mutation-service.test.ts +++ b/packages/server/src/server/session/git-mutation/git-mutation-service.test.ts @@ -11,14 +11,19 @@ import type { } from "../../workspace-git-service.js"; import { createGitMutationService } from "./git-mutation-service.js"; -// The production module reads only WorkspaceGitService.{validateBranchRef,getSnapshot, -// hasLocalBranch,invalidateForge}. The fake below implements exactly that slice as an +// The production module reads only the named WorkspaceGitService branch capabilities. The fake +// below implements exactly that slice as an // in-memory adapter; the happy-path tests cross the real git boundary against a temp repo, // since that is where checkoutResolvedBranch / `git checkout -b` actually run. type GitSource = Pick< WorkspaceGitService, - "validateBranchRef" | "getSnapshot" | "hasLocalBranch" | "invalidateForge" + | "validateBranchRef" + | "getSnapshot" + | "hasLocalBranch" + | "invalidateForge" + | "switchBranch" + | "createBranch" >; const logger = pino({ level: "silent" }); @@ -53,6 +58,16 @@ function createFakeGit(opts: FakeGitOptions = {}) { invalidateForge(cwd) { invalidateCalls.push({ cwd }); }, + async switchBranch(cwd, branch) { + execFileSync("git", ["checkout", branch], { cwd, stdio: "pipe" }); + return { source: resolution.kind === "remote" ? "remote" : "local" }; + }, + async createBranch(cwd, options) { + execFileSync("git", ["checkout", "-b", options.branch, options.baseRef], { + cwd, + stdio: "pipe", + }); + }, }; return { git, snapshotCalls, invalidateCalls }; } diff --git a/packages/server/src/server/session/git-mutation/git-mutation-service.ts b/packages/server/src/server/session/git-mutation/git-mutation-service.ts index 7611ba89bc..20d2f66213 100644 --- a/packages/server/src/server/session/git-mutation/git-mutation-service.ts +++ b/packages/server/src/server/session/git-mutation/git-mutation-service.ts @@ -1,12 +1,10 @@ import type pino from "pino"; import { getErrorMessage } from "@getpaseo/protocol/error-utils"; -import { - checkoutResolvedBranch, - type CheckoutExistingBranchResult, - type GitMutationRefreshReason, +import type { + CheckoutExistingBranchResult, + GitMutationRefreshReason, } from "../../../utils/checkout-git.js"; -import { runGitCommand } from "../../../utils/run-git-command.js"; -import type { WorkspaceGitService } from "../../workspace-git-service.js"; +import type { WorkspaceGitService, WorkspaceGitWorkspace } from "../../workspace-git-service.js"; import { assertSafeGitRef as assertWorktreeSafeGitRef } from "../../worktree-session.js"; /** @@ -21,6 +19,8 @@ import { assertSafeGitRef as assertWorktreeSafeGitRef } from "../../worktree-ses * session as loose callbacks. */ export interface GitMutationService { + bind(workspaceGit: WorkspaceGitWorkspace): BoundGitMutation; + /** Explicit compatibility path for callers that do not own a workspace record yet. */ checkoutExistingBranch(cwd: string, branch: string): Promise; createBranchFromBase(params: { cwd: string; @@ -34,9 +34,20 @@ export interface GitMutationService { ): Promise; } +export interface BoundGitMutation { + checkoutExistingBranch(branch: string): Promise; + createBranchFromBase(params: { baseBranch: string; newBranchName: string }): Promise; + notify(reason: GitMutationRefreshReason, options?: { invalidateForge?: boolean }): Promise; +} + type GitMutationGitSource = Pick< WorkspaceGitService, - "validateBranchRef" | "getSnapshot" | "hasLocalBranch" | "invalidateForge" + | "validateBranchRef" + | "getSnapshot" + | "hasLocalBranch" + | "invalidateForge" + | "switchBranch" + | "createBranch" >; export function createGitMutationService(deps: { @@ -45,6 +56,72 @@ export function createGitMutationService(deps: { }): GitMutationService { const { workspaceGitService, logger } = deps; + function bind(workspaceGit: WorkspaceGitWorkspace): BoundGitMutation { + async function isBoundWorkingTreeDirty(): Promise { + try { + const snapshot = await workspaceGit.getSnapshot(); + return snapshot.git.isDirty === true; + } catch (error) { + throw new Error( + `Unable to inspect git status for ${workspaceGit.cwd}: ${getErrorMessage(error)}`, + { cause: error }, + ); + } + } + + async function ensureBoundWorkingTreeClean(): Promise { + if (await isBoundWorkingTreeDirty()) { + throw new Error( + "Working directory has uncommitted changes. Commit or stash before switching branches.", + ); + } + } + + async function notify( + reason: GitMutationRefreshReason, + options?: { invalidateForge?: boolean }, + ): Promise { + if (options?.invalidateForge) { + workspaceGit.invalidateForge(); + } + try { + await workspaceGit.getSnapshot({ force: true, reason }); + } catch (error) { + logger.warn( + { err: error, cwd: workspaceGit.cwd, reason }, + "Failed to force-refresh workspace git snapshot after mutation", + ); + } + } + + return { + async checkoutExistingBranch(branch) { + assertSafeGitRef(branch, "branch"); + const resolution = await workspaceGit.validateBranchRef(branch); + if (resolution.kind === "not-found") throw new Error(`Branch not found: ${branch}`); + await ensureBoundWorkingTreeClean(); + const result = await workspaceGit.switchBranch(branch); + await notify("switch-branch", { invalidateForge: true }); + return result; + }, + async createBranchFromBase({ baseBranch, newBranchName }) { + assertSafeGitRef(baseBranch, "base branch"); + assertSafeGitRef(newBranchName, "new branch"); + const baseResolution = await workspaceGit.validateBranchRef(baseBranch); + if (baseResolution.kind === "not-found") { + throw new Error(`Base branch not found: ${baseBranch}`); + } + if (await workspaceGit.hasLocalBranch(newBranchName)) { + throw new Error(`Branch already exists: ${newBranchName}`); + } + await ensureBoundWorkingTreeClean(); + await workspaceGit.createBranch({ branch: newBranchName, baseRef: baseBranch }); + await notify("create-branch"); + }, + notify, + }; + } + function assertSafeGitRef(ref: string, label: string): void { if (!/^[A-Za-z0-9._/-]+$/.test(ref)) { throw new Error(`Invalid ${label}: ${ref}`); @@ -91,6 +168,7 @@ export function createGitMutationService(deps: { } return { + bind, async checkoutExistingBranch(cwd, branch) { assertSafeGitRef(branch, "branch"); const resolution = await workspaceGitService.validateBranchRef(cwd, branch); @@ -98,7 +176,7 @@ export function createGitMutationService(deps: { throw new Error(`Branch not found: ${branch}`); } await ensureCleanWorkingTree(cwd); - const result = await checkoutResolvedBranch({ cwd, resolution }); + const result = await workspaceGitService.switchBranch(cwd, branch); await notifyGitMutation(cwd, "switch-branch", { invalidateForge: true }); return result; }, @@ -118,9 +196,9 @@ export function createGitMutationService(deps: { } await ensureCleanWorkingTree(cwd); - await runGitCommand(["checkout", "-b", newBranchName, baseBranch], { - cwd, - timeout: 120_000, + await workspaceGitService.createBranch(cwd, { + branch: newBranchName, + baseRef: baseBranch, }); await notifyGitMutation(cwd, "create-branch"); }, diff --git a/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.test.ts b/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.test.ts index 1eb08a7022..eeda4bbeb5 100644 --- a/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.test.ts +++ b/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.test.ts @@ -5,7 +5,7 @@ import type { WorkspaceDescriptorPayload } from "../../messages.js"; import type { WorkspaceGitListener, WorkspaceGitRuntimeSnapshot, - WorkspaceGitService, + WorkspaceGitWorkspace, } from "../../workspace-git-service.js"; import type { PersistedWorkspaceRecord } from "../../workspace-registry.js"; import { createWorkspaceGitObserverService } from "./workspace-git-observer-service.js"; @@ -54,33 +54,64 @@ function flushMicrotasks(): Promise { return new Promise((done) => setImmediate(done)); } -function buildHarness(opts: { emitCwdRejects?: boolean } = {}) { +function buildHarness( + opts: { + emitWorkspaceIdRejects?: boolean; + distinctWorkspaceBindings?: boolean; + selectedWorkspaceIds?: ReadonlySet; + } = {}, +) { const listeners = new Map(); const registerCalls: string[] = []; const unsubscribeCalls: string[] = []; - const emitCwdCalls: string[] = []; const emitWorkspaceIdCalls: string[] = []; - const statusCalls: Array<{ cwd: string; branch: string | null }> = []; + const statusCalls: Array<{ + workspaceId: string | undefined; + cwd: string; + branch: string | null; + }> = []; const branchChanges: Array<[string, string | null, string | null]> = []; const warnCalls: unknown[][] = []; const describeCalls: PersistedWorkspaceRecord[] = []; let describeResult: WorkspaceDescriptorPayload | null = null; - const workspaceGitService: Pick = { - registerWorkspace({ cwd }, listener) { - registerCalls.push(cwd); - listeners.set(cwd, listener); + const workspaces = new Map(); + function resolveWorkspaceGit(workspaceId: string, cwd: string) { + const selected = opts.distinctWorkspaceBindings || opts.selectedWorkspaceIds?.has(workspaceId); + const key = selected ? workspaceId : cwd; + let workspaceGit = workspaces.get(key); + if (workspaceGit) { return { - unsubscribe() { - unsubscribeCalls.push(cwd); - listeners.delete(cwd); - }, + address: selected + ? { kind: "selected" as const, workspaceId, cwd } + : { kind: "legacy" as const, cwd }, + workspaceGit, }; - }, - }; + } + workspaceGit = { + cwd, + register(listener) { + registerCalls.push(cwd); + listeners.set(key, listener); + return { + unsubscribe() { + unsubscribeCalls.push(cwd); + listeners.delete(key); + }, + }; + }, + } as WorkspaceGitWorkspace; + workspaces.set(key, workspaceGit); + return { + address: selected + ? { kind: "selected" as const, workspaceId, cwd } + : { kind: "legacy" as const, cwd }, + workspaceGit, + }; + } const service = createWorkspaceGitObserverService({ - workspaceGitService, + resolveWorkspaceGit, describeWorkspaceRecordWithGitData: async (workspace) => { describeCalls.push(workspace); if (!describeResult) { @@ -88,17 +119,12 @@ function buildHarness(opts: { emitCwdRejects?: boolean } = {}) { } return describeResult; }, - emitWorkspaceUpdateForCwd: async (cwd) => { - emitCwdCalls.push(cwd); - if (opts.emitCwdRejects) { - throw new Error("emit boom"); - } - }, emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => { emitWorkspaceIdCalls.push(workspaceId); + if (opts.emitWorkspaceIdRejects) throw new Error("emit boom"); }, - emitStatusUpdate: (cwd, snapshot) => { - statusCalls.push({ cwd, branch: snapshot.git.currentBranch ?? null }); + emitStatusUpdate: (workspaceId, cwd, snapshot) => { + statusCalls.push({ workspaceId, cwd, branch: snapshot.git.currentBranch ?? null }); }, onBranchChanged: (workspaceId, oldBranch, newBranch) => { branchChanges.push([workspaceId, oldBranch, newBranch]); @@ -114,12 +140,18 @@ function buildHarness(opts: { emitCwdRejects?: boolean } = {}) { listener(makeSnapshot(cwd, branch)); } + function emitWorkspaceSnapshot(workspaceId: string, cwd: string, branch: string | null): void { + const listener = listeners.get(workspaceId); + if (!listener) throw new Error(`no listener registered for ${workspaceId}`); + listener(makeSnapshot(cwd, branch)); + } + return { service, emitSnapshot, + emitWorkspaceSnapshot, registerCalls, unsubscribeCalls, - emitCwdCalls, emitWorkspaceIdCalls, statusCalls, branchChanges, @@ -245,8 +277,8 @@ describe("git snapshot listener", () => { h.emitSnapshot(WS1, "feature"); await flushMicrotasks(); expect(h.branchChanges).toEqual([["ws1", null, "feature"]]); - expect(h.emitCwdCalls).toEqual([WS1]); - expect(h.statusCalls).toEqual([{ cwd: WS1, branch: "feature" }]); + expect(h.emitWorkspaceIdCalls).toEqual(["ws1"]); + expect(h.statusCalls).toEqual([{ workspaceId: "ws1", cwd: WS1, branch: "feature" }]); }); test("does not re-fire onBranchChanged when the branch is unchanged", () => { @@ -258,13 +290,40 @@ describe("git snapshot listener", () => { }); test("logs and swallows an emit failure without skipping the status update", async () => { - const h = buildHarness({ emitCwdRejects: true }); + const h = buildHarness({ emitWorkspaceIdRejects: true }); h.service.syncObservers([makeDescriptor({ id: "ws1", workspaceDirectory: WS1 })]); expect(() => h.emitSnapshot(WS1, "feature")).not.toThrow(); - expect(h.statusCalls).toEqual([{ cwd: WS1, branch: "feature" }]); + expect(h.statusCalls).toEqual([{ workspaceId: "ws1", cwd: WS1, branch: "feature" }]); await flushMicrotasks(); expect(h.warnCalls).toHaveLength(1); }); + + test("a selected snapshot updates only its owning same-cwd workspace", async () => { + const h = buildHarness({ distinctWorkspaceBindings: true }); + h.service.syncObservers([ + makeDescriptor({ id: "selected-a", workspaceDirectory: WS1 }), + makeDescriptor({ id: "selected-b", workspaceDirectory: WS1 }), + ]); + + h.emitWorkspaceSnapshot("selected-a", WS1, "branch-a"); + await flushMicrotasks(); + + expect(h.branchChanges).toEqual([["selected-a", null, "branch-a"]]); + expect(h.emitWorkspaceIdCalls).toEqual(["selected-a"]); + expect(h.statusCalls).toEqual([{ workspaceId: "selected-a", cwd: WS1, branch: "branch-a" }]); + }); + + test("a legacy cwd branch event does not cross into a selected same-cwd workspace", () => { + const h = buildHarness({ selectedWorkspaceIds: new Set(["selected"]) }); + h.service.syncObservers([ + makeDescriptor({ id: "legacy", workspaceDirectory: WS1 }), + makeDescriptor({ id: "selected", workspaceDirectory: WS1 }), + ]); + + h.service.handleBranchSnapshot({ cwd: WS1 }, "legacy-next"); + + expect(h.branchChanges).toEqual([["legacy", null, "legacy-next"]]); + }); }); describe("shouldSkipUpdate", () => { diff --git a/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.ts b/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.ts index dfc87b312b..c1c75338e7 100644 --- a/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.ts +++ b/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.ts @@ -3,9 +3,10 @@ import type pino from "pino"; import type { WorkspaceDescriptorPayload } from "../../messages.js"; import type { WorkspaceGitRuntimeSnapshot, - WorkspaceGitService, + WorkspaceGitWorkspace, } from "../../workspace-git-service.js"; import type { PersistedWorkspaceRecord } from "../../workspace-registry.js"; +import type { WorkspaceGitAddress } from "../../workspace-git-directory.js"; const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__"; @@ -15,6 +16,8 @@ interface WorkspaceGitWatchTarget { interface WorkspaceGitWatchState { cwd: string; + address: WorkspaceGitAddress; + workspaceGit: WorkspaceGitWorkspace; latestDescriptorStateKey: string | null; lastBranchName: string | null; } @@ -29,9 +32,8 @@ export interface WorkspaceGitObserverMetrics { * Observes a workspace's git state on disk (via WorkspaceGitService) and drives the * live update fan-out: branch-change notifications, workspace-card refreshes, and * checkout status updates. It owns the per-cwd watch targets and the WorkspaceGitService - * subscription handles. Filesystem subscriptions are keyed by cwd while descriptor and - * branch state remain keyed by workspace id, so same-directory workspace records share one - * watch without sharing identity or teardown lifetime. + * subscription handles. Selected subscriptions are keyed by their bound capability; only + * explicitly legacy bindings may share cwd fan-out. * * Branch changes reach `onBranchChanged` from two paths that share `lastBranchName`: the * on-disk snapshot listener (handleBranchSnapshot) and the workspace-emit loop's Git runtime @@ -45,20 +47,26 @@ export interface WorkspaceGitObserverService { // for this workspace, and otherwise advances the recorded state key as a side effect. shouldSkipUpdate(workspaceId: string, workspace: WorkspaceDescriptorPayload | null): boolean; recordDescriptorState(workspaceId: string, workspace: WorkspaceDescriptorPayload | null): void; - handleBranchSnapshot(cwd: string, branchName: string | null): void; + handleBranchSnapshot(address: WorkspaceGitAddress, branchName: string | null): void; getMetrics(): WorkspaceGitObserverMetrics; removeForWorkspaceId(workspaceId: string): void; dispose(): void; } export function createWorkspaceGitObserverService(deps: { - workspaceGitService: Pick; + resolveWorkspaceGit: ( + workspaceId: string, + cwd: string, + ) => { address: WorkspaceGitAddress; workspaceGit: WorkspaceGitWorkspace }; describeWorkspaceRecordWithGitData: ( workspace: PersistedWorkspaceRecord, ) => Promise; - emitWorkspaceUpdateForCwd: (cwd: string) => Promise; emitWorkspaceUpdateForWorkspaceId: (workspaceId: string) => Promise; - emitStatusUpdate: (cwd: string, snapshot: WorkspaceGitRuntimeSnapshot) => void; + emitStatusUpdate: ( + workspaceId: string, + cwd: string, + snapshot: WorkspaceGitRuntimeSnapshot, + ) => void; onBranchChanged?: ( workspaceId: string, oldBranch: string | null, @@ -67,18 +75,17 @@ export function createWorkspaceGitObserverService(deps: { logger: pino.Logger; }): WorkspaceGitObserverService { const { - workspaceGitService, + resolveWorkspaceGit, describeWorkspaceRecordWithGitData, - emitWorkspaceUpdateForCwd, emitWorkspaceUpdateForWorkspaceId, emitStatusUpdate, onBranchChanged, logger, } = deps; - const watchTargets = new Map(); + const watchTargets = new Map(); const workspaceStates = new Map(); - const subscriptions = new Map void>(); + const subscriptions = new Map void>(); function descriptorStateKey(workspace: WorkspaceDescriptorPayload | null): string { if (!workspace) { @@ -105,15 +112,14 @@ export function createWorkspaceGitObserverService(deps: { } } - function removeForCwd(cwd: string): void { - const normalizedCwd = resolve(cwd); - const target = watchTargets.get(normalizedCwd); + function removeForWorkspaceGit(workspaceGit: WorkspaceGitWorkspace): void { + const target = watchTargets.get(workspaceGit); for (const workspaceId of target?.workspaceIds ?? []) { workspaceStates.delete(workspaceId); } - watchTargets.delete(normalizedCwd); - subscriptions.get(normalizedCwd)?.(); - subscriptions.delete(normalizedCwd); + watchTargets.delete(workspaceGit); + subscriptions.get(workspaceGit)?.(); + subscriptions.delete(workspaceGit); } function removeForWorkspaceId(workspaceId: string): void { @@ -122,20 +128,26 @@ export function createWorkspaceGitObserverService(deps: { return; } workspaceStates.delete(workspaceId); - const target = watchTargets.get(state.cwd); + const target = watchTargets.get(state.workspaceGit); target?.workspaceIds.delete(workspaceId); if (target?.workspaceIds.size === 0) { - removeForCwd(state.cwd); + removeForWorkspaceGit(state.workspaceGit); } } - function handleBranchSnapshot(cwd: string, branchName: string | null): void { - const target = watchTargets.get(resolve(cwd)); - if (!target) { - return; - } - - for (const workspaceId of target.workspaceIds) { + function handleBranchSnapshot(address: WorkspaceGitAddress, branchName: string | null): void { + const addressedState = + address.kind === "selected" ? workspaceStates.get(address.workspaceId) : null; + const target = addressedState ? watchTargets.get(addressedState.workspaceGit) : null; + const workspaceIds = target + ? [...target.workspaceIds] + : [...workspaceStates] + .filter( + ([, candidate]) => + candidate.address.kind === "legacy" && candidate.cwd === resolve(address.cwd), + ) + .map(([workspaceId]) => workspaceId); + for (const workspaceId of workspaceIds) { const state = workspaceStates.get(workspaceId); if (!state) { continue; @@ -151,8 +163,12 @@ export function createWorkspaceGitObserverService(deps: { function syncObserver(cwd: string, options: { isGit: boolean; workspaceId: string }): void { const normalizedCwd = resolve(cwd); + const { address, workspaceGit } = resolveWorkspaceGit(options.workspaceId, normalizedCwd); const currentState = workspaceStates.get(options.workspaceId); - if (currentState && currentState.cwd !== normalizedCwd) { + if ( + currentState && + (currentState.cwd !== normalizedCwd || currentState.workspaceGit !== workspaceGit) + ) { removeForWorkspaceId(options.workspaceId); } if (!options.isGit) { @@ -160,40 +176,44 @@ export function createWorkspaceGitObserverService(deps: { return; } - const target = watchTargets.get(normalizedCwd) ?? { + const target = watchTargets.get(workspaceGit) ?? { workspaceIds: new Set(), }; - watchTargets.set(normalizedCwd, target); + watchTargets.set(workspaceGit, target); target.workspaceIds.add(options.workspaceId); if (!workspaceStates.has(options.workspaceId)) { workspaceStates.set(options.workspaceId, { cwd: normalizedCwd, + address, + workspaceGit, latestDescriptorStateKey: null, lastBranchName: null, }); } - if (subscriptions.has(normalizedCwd)) { + if (subscriptions.has(workspaceGit)) { return; } - let subscription: ReturnType; + let subscription: ReturnType; try { - subscription = workspaceGitService.registerWorkspace({ cwd: normalizedCwd }, (snapshot) => { - handleBranchSnapshot(normalizedCwd, snapshot.git.currentBranch ?? null); - void emitWorkspaceUpdateForCwd(normalizedCwd).catch((error) => { - logger.warn( - { err: error, cwd: normalizedCwd }, - "Failed to emit workspace update after git branch snapshot", - ); - }); - emitStatusUpdate(normalizedCwd, snapshot); + subscription = workspaceGit.register((snapshot) => { + handleBranchSnapshot(address, snapshot.git.currentBranch ?? null); + for (const workspaceId of target.workspaceIds) { + void emitWorkspaceUpdateForWorkspaceId(workspaceId).catch((error) => { + logger.warn( + { err: error, cwd: normalizedCwd, workspaceId }, + "Failed to emit workspace update after git branch snapshot", + ); + }); + emitStatusUpdate(workspaceId, normalizedCwd, snapshot); + } }); } catch (error) { removeForWorkspaceId(options.workspaceId); throw error; } - subscriptions.set(normalizedCwd, subscription.unsubscribe); + subscriptions.set(workspaceGit, subscription.unsubscribe); } function syncObservers(workspaces: Iterable): void { diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index 770d4d79e8..4a543dd5da 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -13,6 +13,7 @@ import { type WorkspaceRegistry, } from "../../workspace-registry.js"; import type { WorkspaceGitService } from "../../workspace-git-service.js"; +import { createWorkspaceGitDirectory } from "../../workspace-git-directory.js"; import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js"; import { deriveProjectKey } from "../../project-key.js"; import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../../../utils/path.js"; @@ -88,10 +89,17 @@ export function createWorkspaceProvisioningService(deps: { serverId?: string; workspaceRegistry: WorkspaceRegistry; projectRegistry: ProjectRegistry; - workspaceGitService: Pick; + workspaceGitService: Pick< + WorkspaceGitService, + "bindLegacy" | "bindWorkspace" | "getCheckout" | "getSnapshot" | "peekSnapshot" + >; logger: Logger; }): WorkspaceProvisioningService { const { serverId, workspaceRegistry, projectRegistry, workspaceGitService, logger } = deps; + const workspaceGitDirectory = createWorkspaceGitDirectory({ + workspaceRegistry, + workspaceGitService, + }); async function runInImportWorkspace( input: ImportWorkspaceInput, @@ -330,7 +338,8 @@ export function createWorkspaceProvisioningService(deps: { if (!workspace.archivedAt) { return workspace.autoArchivedChangeRequestUrl; } - const snapshot = await workspaceGitService.getSnapshot(workspace.cwd, { + if (workspace.runtime?.runtimeId) return workspace.autoArchivedChangeRequestUrl; + const snapshot = await workspaceGitDirectory.bindRecord(workspace).getSnapshot({ force: true, includeForge: true, reason: "workspace-restore-auto-archive-latch", @@ -348,7 +357,7 @@ export function createWorkspaceProvisioningService(deps: { const timestamp = new Date().toISOString(); const checkout = workspace.archivedAt || project.archivedAt - ? await workspaceGitService.getCheckout(workspace.cwd) + ? await workspaceGitDirectory.bindRecord(workspace).getCheckout() : null; const autoArchivedChangeRequestUrl = await resolveRestoredAutoArchiveChangeRequestUrl(workspace); @@ -396,7 +405,7 @@ export function createWorkspaceProvisioningService(deps: { async function refreshWorkspaceRecord( workspace: PersistedWorkspaceRecord, ): Promise { - const checkout = await workspaceGitService.getCheckout(workspace.cwd); + const checkout = await workspaceGitDirectory.bindRecord(workspace).getCheckout(); const project = await projectRegistry.get(workspace.projectId); if (project && !project.archivedAt) { await refreshProjectKind(project, workspace.cwd, checkout); diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts index c35022e413..78675e03cf 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts @@ -18,7 +18,7 @@ import type { SpawnWorkspaceScriptOptions, WorktreeScriptResult, } from "../../worktree-bootstrap.js"; -import type { WorkspaceGitService } from "../../workspace-git-service.js"; +import type { WorkspaceGitWorkspace } from "../../workspace-git-service.js"; import { createWorkspaceScriptsService } from "./workspace-scripts-service.js"; import { deriveProjectServiceSlug } from "../../workspace-git-metadata.js"; import { @@ -80,7 +80,7 @@ interface BuildOptions { workspace?: PersistedWorkspaceRecord | null; project?: PersistedProjectRecord | null; spawnThrows?: string; - gitService?: Pick; + gitService?: Pick; workspaceRuntime?: WorkspaceRuntimeService; } @@ -105,7 +105,9 @@ function buildService(options: BuildOptions = {}) { options.terminalManager === undefined ? availableTerminalManager : options.terminalManager, workspaceRegistry: fakeWorkspaceRegistry(workspace), projectRegistry: fakeProjectRegistry(options.project ?? null), - workspaceGitService: options.gitService ?? fakeGitService(), + workspaceGitDirectory: { + bindRecord: () => options.gitService ?? fakeGitService(), + }, workspaceRuntime: options.workspaceRuntime, getDaemonTcpPort: () => 6767, getDaemonTcpHost: () => "127.0.0.1", diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts index 03a760c1bb..964c73f978 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts @@ -9,7 +9,7 @@ import type { TerminalManager } from "../../../terminal/terminal-manager.js"; import type { ServiceProxySubsystem } from "../../service-proxy.js"; import type { WorkspaceScriptRuntimeStore } from "../../workspace-script-runtime-store.js"; import type { ScriptHealthState } from "../../script-health-monitor.js"; -import type { WorkspaceGitService } from "../../workspace-git-service.js"; +import type { WorkspaceGitDirectory } from "../../workspace-git-directory.js"; import type { PersistedProjectRecord, PersistedWorkspaceRecord, @@ -52,15 +52,13 @@ export interface WorkspaceScriptsService { start(request: StartWorkspaceScriptRequest): Promise; } -type WorkspaceScriptsGitSource = Pick; - export function createWorkspaceScriptsService(deps: { serviceProxy: ServiceProxySubsystem | null; scriptRuntimeStore: WorkspaceScriptRuntimeStore | null; terminalManager: TerminalManager | null; workspaceRegistry: Pick; projectRegistry: Pick; - workspaceGitService: WorkspaceScriptsGitSource; + workspaceGitDirectory: Pick; workspaceRuntime?: WorkspaceRuntimeService; getDaemonTcpPort: (() => number | null) | null; getDaemonTcpHost: (() => string | null) | null; @@ -77,7 +75,7 @@ export function createWorkspaceScriptsService(deps: { terminalManager, workspaceRegistry, projectRegistry, - workspaceGitService, + workspaceGitDirectory, workspaceRuntime, getDaemonTcpPort, getDaemonTcpHost, @@ -93,7 +91,7 @@ export function createWorkspaceScriptsService(deps: { workspace: PersistedWorkspaceRecord, project: { projectId: string; rootPath: string } | null, ) { - const snapshot = workspaceGitService.peekSnapshot(workspace.cwd); + const snapshot = workspaceGitDirectory.bindRecord(workspace).peekSnapshot(); const currentBranch = snapshot?.git.currentBranch ?? workspace.branch ?? null; if (project) { return { diff --git a/packages/server/src/server/test-utils/fixtures/workspace-runtime-command-fixture.mjs b/packages/server/src/server/test-utils/fixtures/workspace-runtime-command-fixture.mjs index 5ecd2582e5..020daea96e 100644 --- a/packages/server/src/server/test-utils/fixtures/workspace-runtime-command-fixture.mjs +++ b/packages/server/src/server/test-utils/fixtures/workspace-runtime-command-fixture.mjs @@ -73,7 +73,7 @@ async function create(id, request) { const state = { workspaceId: id, root, - revision: "fixture:1", + revision: `fixture:${key(`${id}:${Date.now()}:${process.pid}`)}`, executionDomainId: "fixture", lifecycle: "ready", }; @@ -105,10 +105,12 @@ async function execute(id) { const envelope = JSON.parse(first.value); const state = await readState(id, envelope.options); if (!state) throw new Error(`Fixture workspace is missing: ${id}`); - await writeFile( - path.join(state.root, ".runtime-launch.json"), - JSON.stringify({ argv: process.argv, purpose: envelope.purpose }), - ); + if (envelope.options.recordLaunchInWorkspace !== false) { + await writeFile( + path.join(state.root, ".runtime-launch.json"), + JSON.stringify({ argv: process.argv, purpose: envelope.purpose }), + ); + } if (envelope.stdio.kind === "pty") { await executePty(id, envelope, iterator, control); control.destroy(); diff --git a/packages/server/src/server/test-utils/workspace-git-service-stub.ts b/packages/server/src/server/test-utils/workspace-git-service-stub.ts index eca511c72e..95712a88d2 100644 --- a/packages/server/src/server/test-utils/workspace-git-service-stub.ts +++ b/packages/server/src/server/test-utils/workspace-git-service-stub.ts @@ -1,6 +1,10 @@ import type { CheckoutDiffResult } from "../../utils/checkout-git.js"; import { deriveProjectSlug } from "../workspace-git-metadata.js"; -import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "../workspace-git-service.js"; +import type { + WorkspaceGitRuntimeSnapshot, + WorkspaceGitService, + WorkspaceGitWorkspace, +} from "../workspace-git-service.js"; export function createNoGitWorkspaceRuntimeSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot { return { @@ -31,7 +35,10 @@ export function createNoGitWorkspaceRuntimeSnapshot(cwd: string): WorkspaceGitRu export function createNoopWorkspaceGitService( overrides: Partial = {}, ): WorkspaceGitService { - const service: WorkspaceGitService = { + let service!: WorkspaceGitService; + service = { + bindWorkspace: ({ cwd }) => bindWorkspaceGitService(service, cwd), + bindLegacy: (cwd) => bindWorkspaceGitService(service, cwd), registerWorkspace: () => ({ unsubscribe: () => {}, }), @@ -60,6 +67,19 @@ export function createNoopWorkspaceGitService( return deriveProjectSlug(cwd, snapshot.git.isGit ? snapshot.git.remoteUrl : null); }, resolveForge: async () => null, + commit: async () => {}, + createBranch: async () => {}, + switchBranch: async () => ({ source: "local" }), + fetch: async () => {}, + listCommits: async () => ({ baseRef: null, commits: [] }), + getCommitFileDiff: async () => null, + stashPush: async () => {}, + stashPop: async () => {}, + mergeToBase: async (cwd) => cwd, + mergeFromBase: async () => {}, + pull: async () => {}, + push: async () => {}, + renameBranch: async (_cwd, branch) => ({ previousBranch: null, currentBranch: branch }), resolveRepoRoot: async (cwd: string) => cwd, resolveDefaultBranch: async () => "main", resolveRepoRemoteUrl: async () => null, @@ -118,3 +138,59 @@ export function createNoopWorkspaceGitService( return service; } + +export function bindWorkspaceGitService( + service: WorkspaceGitService, + cwd: string, +): WorkspaceGitWorkspace { + return { + cwd, + register: (listener) => service.registerWorkspace({ cwd }, listener), + observe: async (listener) => service.registerWorkspace({ cwd }, listener), + peekSnapshot: () => service.peekSnapshot(cwd), + getCheckout: () => service.getCheckout(cwd), + getSnapshot: (options) => + options === undefined ? service.getSnapshot(cwd) : service.getSnapshot(cwd, options), + resolveForge: () => service.resolveForge(cwd), + getCheckoutDiff: (options, readOptions) => service.getCheckoutDiff(cwd, options, readOptions), + validateBranchRef: (ref, options) => + options === undefined + ? service.validateBranchRef(cwd, ref) + : service.validateBranchRef(cwd, ref, options), + hasLocalBranch: (branch, options) => + options === undefined + ? service.hasLocalBranch(cwd, branch) + : service.hasLocalBranch(cwd, branch, options), + suggestBranches: (options, readOptions) => + readOptions === undefined + ? service.suggestBranchesForCwd(cwd, options) + : service.suggestBranchesForCwd(cwd, options, readOptions), + listStashes: (options, readOptions) => + readOptions === undefined + ? service.listStashes(cwd, options) + : service.listStashes(cwd, options, readOptions), + listWorktrees: (options) => service.listWorktrees(cwd, options), + getProjectSlug: (options) => service.getProjectSlug(cwd, options), + resolveRepoRoot: (options) => service.resolveRepoRoot(cwd, options), + resolveDefaultBranch: (options) => service.resolveDefaultBranch(cwd, options), + resolveRepoRemoteUrl: (options) => service.resolveRepoRemoteUrl(cwd, options), + commit: (options) => service.commit(cwd, options), + createBranch: (options) => service.createBranch(cwd, options), + switchBranch: (branch) => service.switchBranch(cwd, branch), + fetch: () => service.fetch(cwd), + listCommits: () => service.listCommits(cwd), + getCommitFileDiff: (input) => service.getCommitFileDiff(cwd, input), + stashPush: (message) => service.stashPush(cwd, message), + stashPop: (stashIndex) => service.stashPop(cwd, stashIndex), + mergeToBase: (options) => service.mergeToBase(cwd, options), + mergeFromBase: (options) => service.mergeFromBase(cwd, options), + pull: () => service.pull(cwd), + push: () => service.push(cwd), + renameBranch: (branch) => service.renameBranch(cwd, branch), + refresh: (options) => service.refresh(cwd, options), + requestWorkingTreeWatch: (onChange) => service.requestWorkingTreeWatch(cwd, onChange), + scheduleRefresh: () => service.scheduleRefreshForCwd(cwd), + stateMayHaveChanged: () => service.onWorkspaceStateMayHaveChanged(cwd), + invalidateForge: () => service.invalidateForge(cwd), + }; +} diff --git a/packages/server/src/server/utils/diff-highlighter.ts b/packages/server/src/server/utils/diff-highlighter.ts index c016318e47..bbbb8c4e32 100644 --- a/packages/server/src/server/utils/diff-highlighter.ts +++ b/packages/server/src/server/utils/diff-highlighter.ts @@ -29,11 +29,13 @@ export interface ParsedDiffFile { interface HighlightDiffWithFileContentOptions { oldFileContent?: string | null; newFileContent?: string | null; + allowFileRead?: boolean; } interface ParseAndHighlightDiffOptions { getOldFileContent?: (file: ParsedDiffFile) => Promise; getNewFileContent?: (file: ParsedDiffFile) => Promise; + allowFileRead?: boolean; } /** @@ -319,6 +321,10 @@ export async function highlightDiffWithFileContent( return applyTokensToHunks(file, newTokensByLine, oldTokensByLine); } + if (options.allowFileRead === false) { + return applyTokensToHunks(file, newTokensByLine, oldTokensByLine); + } + const filePath = resolve(cwd, file.path); try { const fileContent = await readFile(filePath, "utf-8"); @@ -388,6 +394,7 @@ export async function parseAndHighlightDiff( return highlightDiffWithFileContent(file, cwd, { oldFileContent: oldFileContent ?? undefined, newFileContent: newFileContent ?? undefined, + allowFileRead: options.allowFileRead, }); }), ); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index a563a0eba4..f0b5165031 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -197,6 +197,12 @@ function createFallbackWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSna function createFallbackWorkspaceGitService(): WorkspaceGitService { return { + bindWorkspace: ({ workspaceId }) => { + throw new Error(`Workspace Git runtime is not available: ${workspaceId}`); + }, + bindLegacy: () => { + throw new Error("Workspace Git service is not available"); + }, registerWorkspace: () => ({ unsubscribe: () => {}, }), @@ -215,6 +221,19 @@ function createFallbackWorkspaceGitService(): WorkspaceGitService { }), getSnapshot: async (cwd: string) => createFallbackWorkspaceGitSnapshot(cwd), resolveForge: async () => null, + commit: async () => {}, + createBranch: async () => {}, + switchBranch: async () => ({ source: "local" }), + fetch: async () => {}, + listCommits: async () => ({ baseRef: null, commits: [] }), + getCommitFileDiff: async () => null, + stashPush: async () => {}, + stashPop: async () => {}, + mergeToBase: async (cwd) => cwd, + mergeFromBase: async () => {}, + pull: async () => {}, + push: async () => {}, + renameBranch: async (_cwd, branch) => ({ previousBranch: null, currentBranch: branch }), getCheckoutDiff: async () => ({ diff: "" }), validateBranchRef: async () => ({ kind: "not-found" }), hasLocalBranch: async () => false, diff --git a/packages/server/src/server/workspace-git-directory.test.ts b/packages/server/src/server/workspace-git-directory.test.ts new file mode 100644 index 0000000000..edda78196f --- /dev/null +++ b/packages/server/src/server/workspace-git-directory.test.ts @@ -0,0 +1,78 @@ +import { expect, test, vi } from "vitest"; +import { createWorkspaceGitDirectory } from "./workspace-git-directory.js"; +import type { WorkspaceGitWorkspace } from "./workspace-git-service.js"; + +test("selected Git addresses cannot become legacy through empty or omitted identity", async () => { + const cwd = "/shared"; + const selected = { cwd } as WorkspaceGitWorkspace; + const legacy = { cwd } as WorkspaceGitWorkspace; + const record = { + workspaceId: "workspace-selected", + cwd, + runtime: { runtimeId: "command" }, + }; + const get = vi.fn(async (workspaceId: string) => + workspaceId === record.workspaceId ? record : null, + ); + const directory = createWorkspaceGitDirectory({ + workspaceRegistry: { get, list: async () => [record] }, + workspaceGitService: { + bindWorkspace: () => selected, + bindLegacy: () => legacy, + }, + }); + + for (const workspaceId of ["", " "]) { + await expect(directory.resolve({ kind: "selected", workspaceId, cwd })).rejects.toThrow( + "workspaceId is required for selected workspace Git", + ); + } + await expect(directory.resolve({ kind: "legacy", cwd })).rejects.toThrow( + "workspaceId is required for a selected workspace Git operation", + ); + expect(get).not.toHaveBeenCalled(); +}); + +test.each(["", " "])( + "selected Git addresses reject cwd %j before registry lookup", + async (cwd) => { + const get = vi.fn(); + const directory = createWorkspaceGitDirectory({ + workspaceRegistry: { get, list: vi.fn() }, + workspaceGitService: { + bindWorkspace: vi.fn(), + bindLegacy: vi.fn(), + }, + }); + + await expect( + directory.resolve({ kind: "selected", workspaceId: "workspace-a", cwd }), + ).rejects.toThrow("cwd is required for selected workspace Git"); + expect(get).not.toHaveBeenCalled(); + }, +); + +test("selected Git addresses normalize both fields before registry lookup", async () => { + const record = { + workspaceId: "workspace-a", + cwd: "/shared", + runtime: { runtimeId: "command" }, + }; + const get = vi.fn(async () => record); + const selected = { cwd: record.cwd } as WorkspaceGitWorkspace; + const bindWorkspace = vi.fn(() => selected); + const directory = createWorkspaceGitDirectory({ + workspaceRegistry: { get, list: vi.fn() }, + workspaceGitService: { bindWorkspace, bindLegacy: vi.fn() }, + }); + + await expect( + directory.resolve({ + kind: "selected", + workspaceId: " workspace-a ", + cwd: " /shared ", + }), + ).resolves.toBe(selected); + expect(get).toHaveBeenCalledWith("workspace-a"); + expect(bindWorkspace).toHaveBeenCalledWith({ workspaceId: "workspace-a", cwd: "/shared" }); +}); diff --git a/packages/server/src/server/workspace-git-directory.ts b/packages/server/src/server/workspace-git-directory.ts new file mode 100644 index 0000000000..e6de490283 --- /dev/null +++ b/packages/server/src/server/workspace-git-directory.ts @@ -0,0 +1,112 @@ +import { resolve } from "node:path"; +import type { WorkspaceGitService, WorkspaceGitWorkspace } from "./workspace-git-service.js"; +import { + type PersistedWorkspaceRecord, + resolveSelectedWorkspaceRuntimeId, + type WorkspaceRegistry, +} from "./workspace-registry.js"; + +export type WorkspaceGitAddress = + | { readonly kind: "selected"; readonly workspaceId: string; readonly cwd: string } + | { readonly kind: "legacy"; readonly cwd: string }; + +export interface WorkspaceGitDirectory { + resolve(address: WorkspaceGitAddress): Promise; + bindRecord( + record: Pick, + ): WorkspaceGitWorkspace; + getBound(workspaceId: string, cwd: string): WorkspaceGitWorkspace; + getObservationBinding( + workspaceId: string, + cwd: string, + ): { + address: WorkspaceGitAddress; + workspaceGit: WorkspaceGitWorkspace; + }; +} + +/** Commits request compatibility once, before ordinary Git callers receive a capability. */ +export function createWorkspaceGitDirectory(options: { + workspaceRegistry: Pick; + workspaceGitService: Pick; +}): WorkspaceGitDirectory { + const { workspaceRegistry, workspaceGitService } = options; + const boundRecords = new Map< + string, + { cwd: string; selected: boolean; workspaceGit: WorkspaceGitWorkspace } + >(); + + function bindRecord( + record: Pick, + ): WorkspaceGitWorkspace { + const cwd = resolve(record.cwd); + const existing = boundRecords.get(record.workspaceId); + if (existing) { + if (existing.cwd !== cwd) + throw new Error(`Workspace Git binding changed cwd: ${record.workspaceId}`); + return existing.workspaceGit; + } + const selected = record.runtime?.runtimeId !== undefined; + const workspaceGit = selected + ? workspaceGitService.bindWorkspace({ + workspaceId: record.workspaceId, + cwd, + }) + : workspaceGitService.bindLegacy(cwd); + boundRecords.set(record.workspaceId, { cwd, selected, workspaceGit }); + return workspaceGit; + } + + return { + bindRecord, + getBound(workspaceId, cwd) { + const bound = boundRecords.get(workspaceId); + if (!bound || bound.cwd !== resolve(cwd)) { + throw new Error(`Workspace Git is not bound: ${workspaceId}`); + } + return bound.workspaceGit; + }, + getObservationBinding(workspaceId, cwd) { + const normalizedCwd = resolve(cwd); + const bound = boundRecords.get(workspaceId); + if (!bound || bound.cwd !== normalizedCwd) { + throw new Error(`Workspace Git is not bound: ${workspaceId}`); + } + return { + address: bound.selected + ? { kind: "selected", workspaceId, cwd: normalizedCwd } + : { kind: "legacy", cwd: normalizedCwd }, + workspaceGit: bound.workspaceGit, + }; + }, + async resolve(address) { + if (address.kind === "selected") { + const workspaceId = address.workspaceId.trim(); + if (workspaceId.length === 0) { + throw new Error("workspaceId is required for selected workspace Git"); + } + const selectedCwd = address.cwd.trim(); + if (selectedCwd.length === 0) { + throw new Error("cwd is required for selected workspace Git"); + } + const cwd = resolve(selectedCwd); + const record = await workspaceRegistry.get(workspaceId); + if (!record) throw new Error(`Workspace not found: ${workspaceId}`); + if (resolve(record.cwd) !== cwd) { + throw new Error(`Workspace cwd does not match ${workspaceId}`); + } + return bindRecord(record); + } + + const cwd = resolve(address.cwd); + const selectedAtCwd = (await workspaceRegistry.list()).some( + (record) => + resolveSelectedWorkspaceRuntimeId(record) !== null && resolve(record.cwd) === cwd, + ); + if (selectedAtCwd) { + throw new Error("workspaceId is required for a selected workspace Git operation"); + } + return workspaceGitService.bindLegacy(cwd); + }, + }; +} diff --git a/packages/server/src/server/workspace-git-noop-fetch.local.e2e.test.ts b/packages/server/src/server/workspace-git-noop-fetch.local.e2e.test.ts index 4634daa8ce..54b6ebd1a3 100644 --- a/packages/server/src/server/workspace-git-noop-fetch.local.e2e.test.ts +++ b/packages/server/src/server/workspace-git-noop-fetch.local.e2e.test.ts @@ -174,6 +174,7 @@ async function measureFetchScenario( snapshotUpdates: number; }> { const fetchStarted = createDeferred(); + const fetchCompleted = createDeferred(); const releaseFetch = createDeferred(); let fetchCount = 0; let fetchChangedNonRemoteRefs: boolean | undefined; @@ -188,6 +189,7 @@ async function measureFetchScenario( const result = await fetchWorkspaceGitRemote(cwd, observer); fetchChangedNonRemoteRefs = result.nonRemoteRefsChanged; fetchCount += 1; + fetchCompleted.resolve(); return result; }, }, @@ -217,6 +219,7 @@ async function measureFetchScenario( startGitCommandMetrics(); releaseFetch.resolve(); await duringFetch?.(); + await fetchCompleted.promise; await waitForGitCommandMetricsIdle({ quietMs, timeoutMs: 120_000 }); const postFetch = stopGitCommandMetrics(); const snapshotUpdatesAfterFetch = [...snapshotCounts].reduce( diff --git a/packages/server/src/server/workspace-git-observation.ts b/packages/server/src/server/workspace-git-observation.ts new file mode 100644 index 0000000000..aadc5488e9 --- /dev/null +++ b/packages/server/src/server/workspace-git-observation.ts @@ -0,0 +1,288 @@ +import type { BoundWorkspaceRuntime, WorkspaceProcess } from "./workspace-runtime/index.js"; + +const MAX_COMMAND_OUTPUT_BYTES = 64 * 1024; +const WATCH_SCRIPT = String.raw` +const fs = require("node:fs"); +const watcher = fs.watch(process.argv[1], { recursive: true }, () => process.stdout.write("changed\n")); +watcher.on("error", error => { process.stderr.write(String(error)); process.exit(1); }); +process.stdout.write("ready\n"); +`; +const STAT_SCRIPT = String.raw` +const fs = require("node:fs"); +const stat = fs.statSync(process.argv[1], { bigint: true }); +let mountNamespace = "host"; +try { mountNamespace = fs.readlinkSync("/proc/self/ns/mnt"); } catch {} +process.stdout.write(mountNamespace + ":" + String(stat.dev) + ":" + String(stat.ino)); +`; + +interface CommonRefMember { + runtime: BoundWorkspaceRuntime; + listener: () => void; +} + +interface CommonRefGroup { + key: string; + commonDir: string; + members: Map; + process: WorkspaceProcess | null; + owner: symbol | null; + stopping: boolean; + lifecycle: Promise; +} + +const commonRefGroups = new Map(); + +export async function observeWorkspaceGit( + runtime: BoundWorkspaceRuntime, + listener: () => void, +): Promise<{ unsubscribe(): Promise }> { + let ready = false; + const workingTree = await runtime.files.subscribe( + { paths: ["."], recursive: true, ignoredPaths: [".git"] }, + (event) => { + if (ready && event.type !== "error") listener(); + }, + ); + + let commonRefs: { unsubscribe(): Promise } | null = null; + try { + const commonDir = await resolveCommonGitDirectory(runtime); + if (commonDir) commonRefs = await subscribeCommonRefs(runtime, commonDir, listener); + ready = true; + } catch (error) { + await workingTree.unsubscribe(); + throw error; + } + + let closed = false; + return { + async unsubscribe() { + if (closed) return; + closed = true; + await Promise.all([workingTree.unsubscribe(), commonRefs?.unsubscribe()]); + }, + }; +} + +async function subscribeCommonRefs( + runtime: BoundWorkspaceRuntime, + commonDir: string, + listener: () => void, +): Promise<{ unsubscribe(): Promise }> { + const key = await resolveFilesystemIdentity(runtime, commonDir); + let group = commonRefGroups.get(key); + if (!group) { + group = { + key, + commonDir, + members: new Map(), + process: null, + owner: null, + stopping: false, + lifecycle: Promise.resolve(), + }; + commonRefGroups.set(key, group); + } + const memberId = Symbol(key); + group.members.set(memberId, { runtime, listener }); + try { + await updateGroupLifecycle(group, async () => { + if (!group.process) await startGroup(group); + }); + } catch (error) { + await updateGroupLifecycle(group, async () => { + group.members.delete(memberId); + if (group.members.size === 0) { + commonRefGroups.delete(key); + await stopGroup(group); + } + }); + throw error; + } + + let closed = false; + return { + async unsubscribe() { + if (closed) return; + closed = true; + await updateGroupLifecycle(group, async () => { + const wasOwner = group.owner === memberId; + group.members.delete(memberId); + if (group.members.size === 0) { + commonRefGroups.delete(key); + await stopGroup(group); + } else if (wasOwner) { + await stopGroup(group); + if (group.members.size > 0) await startGroup(group); + } + }); + }, + }; +} + +function updateGroupLifecycle(group: CommonRefGroup, update: () => Promise): Promise { + const result = group.lifecycle.then(update, update); + group.lifecycle = result.then( + () => undefined, + () => undefined, + ); + return result; +} + +async function startGroup(group: CommonRefGroup, excludedOwner?: symbol): Promise { + const candidates = [...group.members].filter(([id]) => id !== excludedOwner); + if (candidates.length === 0 && excludedOwner) { + candidates.push(...group.members); + } + let lastError: unknown = null; + for (const [memberId, member] of candidates) { + try { + const process = await member.runtime.run({ + argv: ["node", "-e", WATCH_SCRIPT, group.commonDir], + env: runtimeCommandEnvironment(), + purpose: { kind: "git" }, + }); + process.stdin.end(); + group.process = process; + group.owner = memberId; + group.stopping = false; + await waitForReady(process, () => { + if (group.process !== process) return; + for (const current of group.members.values()) current.listener(); + }); + void drain(process.stderr); + void process.exited.then(() => handleGroupExit(group, process, memberId)); + return; + } catch (error) { + const failedProcess = group.process; + group.process = null; + group.owner = null; + if (failedProcess) { + failedProcess.kill("SIGKILL"); + await failedProcess.exited.catch(() => undefined); + } + lastError = error; + } + } + throw lastError ?? new Error("Git common-ref observer has no runtime owner"); +} + +async function stopGroup(group: CommonRefGroup): Promise { + const process = group.process; + group.process = null; + group.owner = null; + if (!process) return; + group.stopping = true; + process.kill("SIGTERM"); + await process.exited.catch(() => undefined); + group.stopping = false; +} + +function handleGroupExit(group: CommonRefGroup, process: WorkspaceProcess, owner: symbol): void { + void updateGroupLifecycle(group, async () => { + if (group.process !== process) return; + group.process = null; + group.owner = null; + if (group.stopping || group.members.size === 0 || commonRefGroups.get(group.key) !== group) + return; + await startGroup(group, owner); + if (commonRefGroups.get(group.key) === group) { + for (const member of group.members.values()) member.listener(); + } + }).catch(() => undefined); +} + +function waitForReady(process: WorkspaceProcess, onChange: () => void): Promise { + return new Promise((resolve, reject) => { + let buffered = ""; + let ready = false; + process.stdout.on("data", (chunk) => { + buffered += Buffer.from(chunk).toString("utf8"); + const lines = buffered.split("\n"); + buffered = lines.pop() ?? ""; + for (const line of lines) { + if (line === "ready" && !ready) { + ready = true; + resolve(); + } else if (line === "changed" && ready) { + onChange(); + } + } + if (!ready && Buffer.byteLength(buffered) > MAX_COMMAND_OUTPUT_BYTES) { + process.kill("SIGKILL"); + reject(new Error("Git observer startup output exceeded 64 KiB")); + } + }); + void process.exited.then(() => { + if (!ready) reject(new Error("Git common-ref observer exited before ready")); + return undefined; + }); + }); +} + +async function resolveCommonGitDirectory(runtime: BoundWorkspaceRuntime): Promise { + const result = await runBoundCommand(runtime, [ + "git", + "rev-parse", + "--path-format=absolute", + "--git-common-dir", + ]); + if (result.exit.code !== 0 || result.exit.signal !== null) return null; + return result.stdout.trim() || null; +} + +async function resolveFilesystemIdentity( + runtime: BoundWorkspaceRuntime, + path: string, +): Promise { + const result = await runBoundCommand(runtime, ["node", "-e", STAT_SCRIPT, path]); + if (result.exit.code !== 0 || result.exit.signal !== null || !result.stdout.trim()) { + throw new Error(`Could not identify Git common directory: ${result.stderr.trim()}`); + } + return result.stdout.trim(); +} + +async function runBoundCommand( + runtime: BoundWorkspaceRuntime, + argv: readonly [string, ...string[]], +) { + const process = await runtime.run({ + argv, + env: runtimeCommandEnvironment(), + purpose: { kind: "git" }, + }); + process.stdin.end(); + const [stdout, stderr, exit] = await Promise.all([ + collect(process.stdout, process), + collect(process.stderr, process), + process.exited, + ]); + return { stdout, stderr, exit }; +} + +async function collect(stream: NodeJS.ReadableStream, process: WorkspaceProcess): Promise { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of stream) { + const bytes = Buffer.from(chunk); + size += bytes.length; + if (size > MAX_COMMAND_OUTPUT_BYTES) { + process.kill("SIGKILL"); + throw new Error("Git observation command output exceeded 64 KiB"); + } + chunks.push(bytes); + } + return Buffer.concat(chunks).toString("utf8"); +} + +async function drain(stream: NodeJS.ReadableStream): Promise { + for await (const _chunk of stream) { + // Keep the long-running observer's diagnostic pipe flowing. + } +} + +function runtimeCommandEnvironment(): Readonly> { + return { + PATH: ["/usr/local/bin", "/usr/bin", "/bin", process.env.PATH].filter(Boolean).join(":"), + }; +} diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts index 017ed831df..34478784c1 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -618,10 +618,12 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { test("non-forced getSnapshot returns the current snapshot during an in-flight refresh", async () => { let nowMs = Date.parse("2026-04-12T00:00:00.000Z"); const refreshStatus = createDeferred(); + const refreshStarted = createDeferred(); const getCheckoutStatus = vi .fn<(cwd: string) => Promise>() .mockImplementationOnce(async (cwd: string) => createCheckoutStatus(cwd)) .mockImplementationOnce(async () => { + refreshStarted.resolve(); const status = await refreshStatus.promise; return { ...status, currentBranch: "feature" }; }); @@ -644,7 +646,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { nowMs += 3_000; const refresh = service.refresh(REPO_CWD); - await flushPromises(); + await refreshStarted.promise; const directRead = service.getSnapshot(REPO_CWD); expect(getCheckoutStatus).toHaveBeenCalledTimes(2); diff --git a/packages/server/src/server/workspace-git-service.runtime.integration.test.ts b/packages/server/src/server/workspace-git-service.runtime.integration.test.ts new file mode 100644 index 0000000000..566e7635c6 --- /dev/null +++ b/packages/server/src/server/workspace-git-service.runtime.integration.test.ts @@ -0,0 +1,726 @@ +import { execFileSync, spawn } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { afterEach, expect, test, vi } from "vitest"; + +import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; +import { observeWorkspaceGit } from "./workspace-git-observation.js"; +import { createWorkspaceRuntimeService } from "./workspace-runtime/index.js"; + +const fixtureExecutable = fileURLToPath( + new URL("./test-utils/fixtures/workspace-runtime-command-fixture.mjs", import.meta.url), +); +const helperExecutable = fileURLToPath( + new URL("./workspace-helper/executable.mjs", import.meta.url), +); +const cleanupRoots: string[] = []; + +function eventWithin(event: Promise, label: string): Promise { + return Promise.race([ + event, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Timed out waiting for ${label}`)), 5_000), + ), + ]); +} + +afterEach(async () => { + await Promise.all( + cleanupRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +test.each(["local", "worktree"] as const)( + "selected %s workspace Git uses the same bound runtime journey", + async (runtimeId) => { + const root = await mkdtemp(path.join(tmpdir(), `paseo-${runtimeId}-git-`)); + cleanupRoots.push(root); + const source = await createRepository(path.join(root, "source")); + const publicAddress = path.join(root, "public-workspace-address"); + const workspaceId = `${runtimeId}-git-workspace`; + const runtimeIds = new Map(); + const workspaceRuntime = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + worktreesRoot: path.join(root, "worktrees"), + resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null, + persistRuntimeId: async (id, selectedRuntimeId) => { + runtimeIds.set(id, selectedRuntimeId); + }, + }); + await workspaceRuntime.create({ + workspaceId, + runtimeId, + project: { id: `${runtimeId}-project`, source: { kind: "host-directory", path: source } }, + placement: + runtimeId === "local" + ? { kind: "existing" } + : { + kind: "branch", + branchName: "slice-4-worktree", + baseRef: "main", + worktreeSlug: "slice-4-worktree", + }, + }); + const workspaceGit = new WorkspaceGitServiceImpl({ + logger: createLogger(), + paseoHome: path.join(root, "paseo-home"), + worktreesRoot: path.join(root, "worktrees"), + workspaceRuntime, + }); + const selectedGit = workspaceGit.bindWorkspace({ workspaceId, cwd: publicAddress }); + + await workspaceRuntime.files(workspaceId).write({ + path: "tracked.txt", + contents: Buffer.from(`${runtimeId} edit\n`), + }); + const dirty = await selectedGit.getSnapshot({ + force: true, + includeForge: false, + reason: `${runtimeId}-edit`, + }); + const diff = await selectedGit.getCheckoutDiff( + { mode: "uncommitted", includeStructured: true }, + { force: true, reason: `${runtimeId}-edit` }, + ); + expect(dirty.git).toMatchObject({ isGit: true, isDirty: true }); + expect(JSON.stringify(diff)).toContain(`${runtimeId} edit`); + + const originalBranch = runtimeId === "local" ? "main" : "slice-4-worktree"; + await selectedGit.commit({ message: `${runtimeId} commit`, addAll: true }); + await selectedGit.createBranch({ + branch: `${runtimeId}-next`, + baseRef: originalBranch, + }); + await selectedGit.switchBranch(originalBranch); + const clean = await selectedGit.getSnapshot(); + expect(clean.git).toMatchObject({ isDirty: false, currentBranch: originalBranch }); + + await workspaceGit.dispose(); + await workspaceRuntime.destroy(workspaceId); + }, + 20_000, +); + +test("selected sibling worktrees share common-ref fan-out without touching an unrelated workspace", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-git-fanout-")); + cleanupRoots.push(root); + const source = await createRepository(path.join(root, "source")); + const unrelated = await createRepository(path.join(root, "unrelated")); + const runtimeIds = new Map(); + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + worktreesRoot: path.join(root, "worktrees"), + resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null, + persistRuntimeId: async (id, runtimeId) => { + runtimeIds.set(id, runtimeId); + }, + }); + for (const [workspaceId, branchName] of [ + ["sibling-a", "sibling-a"], + ["sibling-b", "sibling-b"], + ] as const) { + await service.create({ + workspaceId, + runtimeId: "worktree", + project: { id: "fanout-project", source: { kind: "host-directory", path: source } }, + placement: { kind: "branch", branchName, baseRef: "main", worktreeSlug: branchName }, + }); + } + await service.create({ + workspaceId: "unrelated", + runtimeId: "local", + project: { id: "unrelated-project", source: { kind: "host-directory", path: unrelated } }, + placement: { kind: "existing" }, + }); + + let resolveFirst!: () => void; + let resolveSibling!: () => void; + const firstChanged = new Promise((resolve) => { + resolveFirst = resolve; + }); + const siblingChanged = new Promise((resolve) => { + resolveSibling = resolve; + }); + let phase: "initial" | "first-change" | "owner-reselected" | "after-pause-change" = "initial"; + let resolveSiblingOwnerReselected!: () => void; + const siblingOwnerReselected = new Promise((resolve) => { + resolveSiblingOwnerReselected = resolve; + }); + let resolveSiblingAfterOwnerPause!: () => void; + const siblingChangedAfterOwnerPause = new Promise((resolve) => { + resolveSiblingAfterOwnerPause = resolve; + }); + let unrelatedChanges = 0; + const siblingA = await service.bind("sibling-a"); + const siblingB = await service.bind("sibling-b"); + const unrelatedGit = await service.bind("unrelated"); + const subscriptions = [ + await observeWorkspaceGit(siblingA, () => { + if (phase === "first-change") resolveFirst(); + }), + await observeWorkspaceGit(siblingB, () => { + if (phase === "first-change") resolveSibling(); + if (phase === "owner-reselected") resolveSiblingOwnerReselected(); + if (phase === "after-pause-change") resolveSiblingAfterOwnerPause(); + }), + await observeWorkspaceGit(unrelatedGit, () => { + unrelatedChanges += 1; + }), + ]; + unrelatedChanges = 0; + phase = "first-change"; + const branch = await service.run({ + workspaceId: "sibling-a", + argv: ["git", "branch", "shared-ref-change"], + env: { PATH: "/usr/local/bin:/usr/bin:/bin" }, + purpose: { kind: "git" }, + }); + branch.stdin.end(); + await expect(branch.exited).resolves.toEqual({ code: 0, signal: null }); + await Promise.all([ + eventWithin(firstChanged, "first sibling ref update"), + eventWithin(siblingChanged, "second sibling ref update"), + ]); + expect(unrelatedChanges).toBe(0); + + phase = "owner-reselected"; + await service.pause("sibling-a"); + await eventWithin(siblingOwnerReselected, "common-ref observer owner re-selection"); + phase = "after-pause-change"; + const branchAfterPause = await service.run({ + workspaceId: "sibling-b", + argv: ["git", "branch", "shared-ref-after-owner-pause"], + env: { PATH: "/usr/local/bin:/usr/bin:/bin" }, + purpose: { kind: "git" }, + }); + branchAfterPause.stdin.end(); + await expect(branchAfterPause.exited).resolves.toEqual({ code: 0, signal: null }); + await eventWithin(siblingChangedAfterOwnerPause, "sibling ref update after owner pause"); + expect(unrelatedChanges).toBe(0); + + await Promise.all(subscriptions.map((subscription) => subscription.unsubscribe())); + await Promise.all([service.destroy("sibling-a"), service.destroy("sibling-b")]); + await service.destroy("unrelated"); +}, 20_000); + +test("concurrent sibling subscriptions own exactly one common-ref watcher until final unsubscribe", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-git-watcher-race-")); + cleanupRoots.push(root); + const source = await createRepository(path.join(root, "source")); + const runtimeIds = new Map(); + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + worktreesRoot: path.join(root, "worktrees"), + resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null, + persistRuntimeId: async (id, runtimeId) => { + runtimeIds.set(id, runtimeId); + }, + }); + for (const workspaceId of ["race-a", "race-b"] as const) { + await service.create({ + workspaceId, + runtimeId: "worktree", + project: { id: "race-project", source: { kind: "host-directory", path: source } }, + placement: { + kind: "branch", + branchName: workspaceId, + baseRef: "main", + worktreeSlug: workspaceId, + }, + }); + } + + let releaseLaunch!: () => void; + const launchReleased = new Promise((resolve) => { + releaseLaunch = resolve; + }); + let firstLaunchEntered!: () => void; + const firstLaunch = new Promise((resolve) => { + firstLaunchEntered = resolve; + }); + let watcherLaunches = 0; + let activeWatchers = 0; + let maximumActiveWatchers = 0; + const instrument = async (workspaceId: string) => { + const runtime = await service.bind(workspaceId); + return { + files: runtime.files, + run: async (input: Parameters[0]) => { + const isCommonRefWatcher = + input.argv[0] === "node" && + input.argv[1] === "-e" && + input.argv[2]?.includes("fs.watch(process.argv[1]") === true; + if (isCommonRefWatcher) { + watcherLaunches += 1; + firstLaunchEntered(); + await launchReleased; + } + const process = await runtime.run(input); + if (isCommonRefWatcher) { + activeWatchers += 1; + maximumActiveWatchers = Math.max(maximumActiveWatchers, activeWatchers); + void process.exited.finally(() => { + activeWatchers -= 1; + }); + } + return process; + }, + }; + }; + const [runtimeA, runtimeB] = await Promise.all([instrument("race-a"), instrument("race-b")]); + const subscriptionsPromise = Promise.all([ + observeWorkspaceGit(runtimeA, () => undefined), + observeWorkspaceGit(runtimeB, () => undefined), + ]); + + await firstLaunch; + await new Promise((resolve) => setImmediate(resolve)); + expect(watcherLaunches).toBe(1); + releaseLaunch(); + const subscriptions = await subscriptionsPromise; + expect(maximumActiveWatchers).toBe(1); + + await subscriptions[0].unsubscribe(); + expect(activeWatchers).toBe(1); + await subscriptions[1].unsubscribe(); + expect(activeWatchers).toBe(0); + + await Promise.all([service.destroy("race-a"), service.destroy("race-b")]); +}, 20_000); + +test("selected workspace Git reads and mutations stay inside its command runtime", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-git-")); + cleanupRoots.push(root); + const runtimeRepository = await createRepository(path.join(root, "runtime-repository")); + const hostDecoy = await createRepository(path.join(root, "host-decoy")); + const remoteRepository = path.join(root, "remote.git"); + await mkdir(remoteRepository); + git(remoteRepository, "init", "--bare", "--initial-branch=main"); + git(runtimeRepository, "remote", "add", "origin", remoteRepository); + git(runtimeRepository, "push", "-u", "origin", "main"); + const stateDirectory = path.join(root, "runtime-state"); + await mkdir(stateDirectory); + const workspaceId = "runtime-git-workspace"; + const runtimeIds = new Map(); + const workspaceRuntime = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null, + persistRuntimeId: async (id, runtimeId) => { + runtimeIds.set(id, runtimeId); + }, + externalRuntimes: { + fixture: { + type: "command", + command: [process.execPath, fixtureExecutable], + helperCommand: [process.execPath, helperExecutable], + options: { stateDirectory, recordLaunchInWorkspace: false }, + }, + }, + }); + await workspaceRuntime.create({ + workspaceId, + runtimeId: "fixture", + project: { + id: "runtime-git-project", + source: { kind: "host-directory", path: runtimeRepository }, + }, + placement: { kind: "existing" }, + }); + const service = new WorkspaceGitServiceImpl({ + logger: createLogger(), + paseoHome: path.join(root, "paseo-home"), + workspaceRuntime, + }); + const selectedGit = service.bindWorkspace({ workspaceId, cwd: hostDecoy }); + + await workspaceRuntime.files(workspaceId).write({ + path: "tracked.txt", + contents: Buffer.from("runtime edit\n"), + }); + await writeFile(path.join(hostDecoy, "tracked.txt"), "host edit\n"); + git(hostDecoy, "branch", "host-only"); + + const dirty = await selectedGit.getSnapshot({ + force: true, + includeForge: false, + reason: "runtime-edit", + }); + const diff = await selectedGit.getCheckoutDiff( + { mode: "uncommitted", includeStructured: true }, + { force: true, reason: "runtime-edit" }, + ); + expect(dirty.git).toMatchObject({ isGit: true, isDirty: true, currentBranch: "main" }); + expect(dirty.git.repoRoot).toBe(hostDecoy); + expect(JSON.stringify(dirty)).not.toContain(runtimeRepository); + expect(JSON.stringify(diff)).toContain("runtime edit"); + expect(JSON.stringify(diff)).not.toContain("host edit"); + await expect(selectedGit.switchBranch("host-only")).rejects.toThrow( + "Branch not found: host-only", + ); + await selectedGit.stashPush("paseo-runtime-stash"); + expect((await selectedGit.getSnapshot({ force: true, reason: "stash-push" })).git.isDirty).toBe( + false, + ); + await selectedGit.stashPop(0); + expect((await selectedGit.getSnapshot({ force: true, reason: "stash-pop" })).git.isDirty).toBe( + true, + ); + + await expect(selectedGit.mergeToBase({ baseRef: "main" })).rejects.toThrow( + "Selected workspace Git does not support merge to base", + ); + await expect(selectedGit.mergeFromBase({ baseRef: "main" })).rejects.toThrow( + "Selected workspace Git does not support merge from base", + ); + await expect(selectedGit.renameBranch("selected-rename")).rejects.toThrow( + "Selected workspace Git does not support branch rename", + ); + await expect(selectedGit.push()).rejects.toThrow("Selected workspace Git does not support push"); + + await selectedGit.commit({ message: "runtime commit", addAll: true }); + await selectedGit.createBranch({ branch: "runtime-branch", baseRef: "main" }); + await selectedGit.switchBranch("main"); + const clean = await selectedGit.getSnapshot({ + force: true, + includeForge: false, + reason: "runtime-mutations", + }); + + expect(clean.git).toMatchObject({ isDirty: false, currentBranch: "main" }); + expect(await readFile(path.join(runtimeRepository, "tracked.txt"), "utf8")).toBe( + "runtime edit\n", + ); + expect(await readFile(path.join(hostDecoy, "tracked.txt"), "utf8")).toBe("host edit\n"); + expect(git(runtimeRepository, "log", "-1", "--format=%s")).toBe("runtime commit"); + expect(git(runtimeRepository, "branch", "--list", "runtime-branch")).toBe("runtime-branch"); + + const upstream = path.join(root, "upstream"); + execFileSync("git", ["clone", remoteRepository, upstream], { stdio: "pipe" }); + git(upstream, "config", "user.email", "test@example.com"); + git(upstream, "config", "user.name", "Paseo Test"); + await writeFile(path.join(upstream, "upstream.txt"), "upstream\n"); + git(upstream, "add", "."); + git(upstream, "commit", "-m", "upstream commit"); + git(upstream, "push", "origin", "main"); + await selectedGit.fetch(); + const fetched = await selectedGit.getSnapshot(); + expect(fetched.git.behindOfOrigin).toBe(1); + + await workspaceRuntime.destroy(workspaceId); + git(runtimeRepository, "checkout", "runtime-branch"); + await workspaceRuntime.create({ + workspaceId, + runtimeId: "fixture", + project: { + id: "runtime-git-project", + source: { kind: "host-directory", path: runtimeRepository }, + }, + placement: { kind: "existing" }, + }); + const reconstructed = await selectedGit.getSnapshot(); + expect(reconstructed.git.currentBranch).toBe("runtime-branch"); + + await service.dispose(); + await workspaceRuntime.destroy(workspaceId); +}, 20_000); + +test("selected commit history highlighting never reads a deleted file from the host cwd", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-git-history-")); + cleanupRoots.push(root); + const runtimeRepository = await createRepository(path.join(root, "runtime-repository")); + await writeFile(path.join(runtimeRepository, "deleted.ts"), "export const runtimeOnly = 1;\n"); + git(runtimeRepository, "add", "deleted.ts"); + git(runtimeRepository, "commit", "-m", "add runtime history file"); + await rm(path.join(runtimeRepository, "deleted.ts")); + git(runtimeRepository, "add", "deleted.ts"); + git(runtimeRepository, "commit", "-m", "delete runtime history file"); + const deletionSha = git(runtimeRepository, "rev-parse", "HEAD"); + const hostDecoy = await createRepository(path.join(root, "host-decoy")); + const hostTrap = path.join(hostDecoy, "deleted.ts"); + execFileSync("mkfifo", [hostTrap]); + + const stateDirectory = path.join(root, "runtime-state"); + await mkdir(stateDirectory); + const runtimeIds = new Map(); + const workspaceRuntime = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null, + persistRuntimeId: async (id, runtimeId) => { + runtimeIds.set(id, runtimeId); + }, + externalRuntimes: { + fixture: { + type: "command", + command: [process.execPath, fixtureExecutable], + helperCommand: [process.execPath, helperExecutable], + options: { stateDirectory, recordLaunchInWorkspace: false }, + }, + }, + }); + await workspaceRuntime.create({ + workspaceId: "history-workspace", + runtimeId: "fixture", + project: { id: "history-project", source: { kind: "host-directory", path: runtimeRepository } }, + placement: { kind: "existing" }, + }); + const service = new WorkspaceGitServiceImpl({ + logger: createLogger(), + paseoHome: path.join(root, "paseo-home"), + workspaceRuntime, + }); + const selectedGit = service.bindWorkspace({ + workspaceId: "history-workspace", + cwd: hostDecoy, + }); + const hostWriter = spawn("/bin/sh", ["-c", "printf 'host trap\\n' > deleted.ts"], { + cwd: hostDecoy, + stdio: "ignore", + }); + + const file = await selectedGit.getCommitFileDiff({ sha: deletionSha, path: "deleted.ts" }); + await new Promise((resolve) => setImmediate(resolve)); + expect(file?.isDeleted).toBe(true); + expect(hostWriter.exitCode).toBeNull(); + + hostWriter.kill("SIGKILL"); + await new Promise((resolve) => hostWriter.once("exit", () => resolve())); + await service.dispose(); + await workspaceRuntime.destroy("history-workspace"); +}, 20_000); + +test("selected workspaces with the same public cwd keep Git state, mutations, and caches isolated", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-git-same-cwd-")); + cleanupRoots.push(root); + const runtimeARepository = await createRepository(path.join(root, "runtime-a")); + const runtimeBRepository = await createRepository(path.join(root, "runtime-b")); + git(runtimeBRepository, "branch", "-m", "runtime-b"); + const publicCwd = await createRepository(path.join(root, "shared-public-cwd")); + git(publicCwd, "branch", "-m", "host-decoy"); + await writeFile(path.join(publicCwd, "selected-proof.txt"), Buffer.alloc(32, 0)); + const stateDirectory = path.join(root, "runtime-state"); + await mkdir(stateDirectory); + const runtimeIds = new Map(); + const workspaceRuntime = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null, + persistRuntimeId: async (id, runtimeId) => { + runtimeIds.set(id, runtimeId); + }, + externalRuntimes: { + fixture: { + type: "command", + command: [process.execPath, fixtureExecutable], + helperCommand: [process.execPath, helperExecutable], + options: { stateDirectory, recordLaunchInWorkspace: false }, + }, + }, + }); + for (const [workspaceId, source] of [ + ["same-cwd-a", runtimeARepository], + ["same-cwd-b", runtimeBRepository], + ] as const) { + await workspaceRuntime.create({ + workspaceId, + runtimeId: "fixture", + project: { id: workspaceId, source: { kind: "host-directory", path: source } }, + placement: { kind: "existing" }, + }); + } + const service = new WorkspaceGitServiceImpl({ + logger: createLogger(), + paseoHome: path.join(root, "paseo-home"), + workspaceRuntime, + deps: { getWorkspaceGitSelfHealPhaseMs: () => 60_000 }, + }); + const workspaceA = service.bindWorkspace({ workspaceId: "same-cwd-a", cwd: publicCwd }); + const workspaceB = service.bindWorkspace({ workspaceId: "same-cwd-b", cwd: publicCwd }); + + await workspaceRuntime.files("same-cwd-a").write({ + path: "tracked.txt", + contents: Buffer.from("runtime a dirty\n"), + }); + const createUntracked = await workspaceRuntime.run({ + workspaceId: "same-cwd-a", + argv: ["/bin/sh", "-c", "printf 'runtime a untracked\\n' > selected-proof.txt"], + env: {}, + purpose: { kind: "git" }, + }); + createUntracked.stdin.end(); + await expect(createUntracked.exited).resolves.toEqual({ code: 0, signal: null }); + const [snapshotA, snapshotB, diffA, diffB] = await Promise.all([ + workspaceA.getSnapshot({ force: true, includeForge: false, reason: "same-cwd-a" }), + workspaceB.getSnapshot({ force: true, includeForge: false, reason: "same-cwd-b" }), + workspaceA.getCheckoutDiff( + { mode: "uncommitted", includeStructured: true }, + { force: true, reason: "same-cwd-a" }, + ), + workspaceB.getCheckoutDiff( + { mode: "uncommitted", includeStructured: true }, + { force: true, reason: "same-cwd-b" }, + ), + ]); + expect(snapshotA.git).toMatchObject({ currentBranch: "main", isDirty: true }); + expect(snapshotB.git).toMatchObject({ currentBranch: "runtime-b", isDirty: false }); + expect(JSON.stringify(diffA)).toContain("runtime a dirty"); + expect(JSON.stringify(diffA)).toContain("runtime a untracked"); + expect(JSON.stringify(diffB)).not.toContain("runtime a dirty"); + const removeUntracked = await workspaceRuntime.run({ + workspaceId: "same-cwd-a", + argv: ["/bin/rm", "selected-proof.txt"], + env: {}, + purpose: { kind: "git" }, + }); + removeUntracked.stdin.end(); + await expect(removeUntracked.exited).resolves.toEqual({ code: 0, signal: null }); + + let observedAWaiter: { dirty: boolean; resolve: () => void } | null = null; + let observedBWaiter: { dirty: boolean; resolve: () => void } | null = null; + let workspaceAChanges = 0; + let workspaceBChanges = 0; + const workspaceBStates: Array<{ branch: string | null; dirty: boolean | null }> = []; + const [observationA, observationB] = await Promise.all([ + workspaceA.observe((snapshot) => { + workspaceAChanges += 1; + if (observedAWaiter?.dirty === snapshot.git.isDirty) { + observedAWaiter.resolve(); + observedAWaiter = null; + } + }), + workspaceB.observe((snapshot) => { + workspaceBChanges += 1; + workspaceBStates.push({ + branch: snapshot.git.currentBranch, + dirty: snapshot.git.isDirty, + }); + if (observedBWaiter?.dirty === snapshot.git.isDirty) { + observedBWaiter.resolve(); + observedBWaiter = null; + } + }), + ]); + + const waitForObservedA = (dirty: boolean) => + new Promise((resolve) => { + observedAWaiter = { dirty, resolve }; + }); + const waitForObservedB = (dirty: boolean) => + new Promise((resolve) => { + observedBWaiter = { dirty, resolve }; + }); + const observedAClean = waitForObservedA(false); + await workspaceRuntime.files("same-cwd-a").write({ + path: "tracked.txt", + contents: Buffer.from("initial\n"), + }); + await observedAClean; + const observedBDirty = waitForObservedB(true); + await workspaceRuntime.files("same-cwd-b").write({ + path: "tracked.txt", + contents: Buffer.from("runtime b startup barrier\n"), + }); + await observedBDirty; + const observedBClean = waitForObservedB(false); + await workspaceRuntime.files("same-cwd-b").write({ + path: "tracked.txt", + contents: Buffer.from("initial\n"), + }); + await observedBClean; + workspaceAChanges = 0; + workspaceBChanges = 0; + workspaceBStates.length = 0; + const observedA = waitForObservedA(true); + const observedB = waitForObservedB(true); + await workspaceRuntime.files("same-cwd-a").write({ + path: "tracked.txt", + contents: Buffer.from("runtime a watched\n"), + }); + await observedA; + await new Promise((resolve) => setImmediate(resolve)); + expect(workspaceAChanges).toBeGreaterThan(0); + expect(workspaceBStates).toEqual([]); + await workspaceRuntime.files("same-cwd-b").write({ + path: "tracked.txt", + contents: Buffer.from("runtime b watched\n"), + }); + await observedB; + expect(workspaceBChanges).toBeGreaterThan(0); + const restoredB = waitForObservedB(false); + await workspaceRuntime.files("same-cwd-b").write({ + path: "tracked.txt", + contents: Buffer.from("initial\n"), + }); + await restoredB; + await Promise.all([observationA.unsubscribe(), observationB.unsubscribe()]); + + await Promise.all([ + workspaceA.commit({ message: "runtime a commit", addAll: true }), + workspaceB.createBranch({ branch: "runtime-b-next", baseRef: "runtime-b" }), + ]); + const [mutatedA, mutatedB] = await Promise.all([ + workspaceA.getSnapshot({ force: true, includeForge: false, reason: "mutated-a" }), + workspaceB.getSnapshot({ force: true, includeForge: false, reason: "mutated-b" }), + ]); + expect(mutatedA.git).toMatchObject({ currentBranch: "main", isDirty: false }); + expect(mutatedB.git).toMatchObject({ currentBranch: "runtime-b-next", isDirty: false }); + expect(git(runtimeARepository, "log", "-1", "--format=%s")).toBe("runtime a commit"); + expect(git(runtimeARepository, "branch", "--list", "runtime-b-next")).toBe(""); + expect(git(runtimeBRepository, "branch", "--show-current")).toBe("runtime-b-next"); + expect(git(publicCwd, "branch", "--show-current")).toBe("host-decoy"); + + await workspaceRuntime.destroy("same-cwd-a"); + git(runtimeARepository, "branch", "-m", "runtime-a-rebuilt"); + await workspaceRuntime.create({ + workspaceId: "same-cwd-a", + runtimeId: "fixture", + project: { + id: "same-cwd-a", + source: { kind: "host-directory", path: runtimeARepository }, + }, + placement: { kind: "existing" }, + }); + const [rebuiltA, cachedB] = await Promise.all([ + workspaceA.getSnapshot(), + workspaceB.getSnapshot(), + ]); + expect(rebuiltA.git.currentBranch).toBe("runtime-a-rebuilt"); + expect(cachedB.git.currentBranch).toBe("runtime-b-next"); + + await service.dispose(); + await Promise.all([ + workspaceRuntime.destroy("same-cwd-a"), + workspaceRuntime.destroy("same-cwd-b"), + ]); +}, 20_000); + +async function createRepository(directory: string): Promise { + await mkdir(directory); + git(directory, "init", "-b", "main"); + git(directory, "config", "user.email", "test@example.com"); + git(directory, "config", "user.name", "Paseo Test"); + await writeFile(path.join(directory, ".git", "info", "exclude"), ".runtime-launch.json\n"); + await writeFile(path.join(directory, "tracked.txt"), "initial\n"); + git(directory, "add", "."); + git(directory, "commit", "-m", "initial"); + return directory; +} + +function git(cwd: string, ...args: string[]): string { + return execFileSync("git", ["-c", "commit.gpgsign=false", ...args], { + cwd, + encoding: "utf8", + }).trim(); +} + +function createLogger() { + const logger = { + child: () => logger, + debug: vi.fn(), + info: vi.fn(), + trace: vi.fn(), + warn: vi.fn(), + }; + return logger; +} diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 33fddb1d8b..74596ef6c0 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -10,6 +10,7 @@ import type { CheckoutContext } from "../utils/checkout-git.js"; import { type BranchCheckoutResolution, type BranchSuggestion, + type CheckoutExistingBranchResult, type CheckoutSnapshotFacts, type CheckoutDiffCompare, type CheckoutDiffResult, @@ -26,6 +27,15 @@ import { resolveRepositoryDefaultBranch, resolveBranchCheckout, resolveAbsoluteGitDir, + checkoutResolvedBranch, + commitChanges, + getCommitFileDiff, + listCheckoutCommits, + mergeFromBase, + mergeToBase, + pullCurrentBranch, + pushCurrentBranch, + renameCurrentBranch, } from "../utils/checkout-git.js"; import type { ForgeAuthState, @@ -45,7 +55,12 @@ import { getRealpathAwareRelativePath, isRealpathInsideRoot, } from "../utils/path.js"; -import { runGitCommand } from "../utils/run-git-command.js"; +import { + runGitCommand, + runWithGitCommandRunner, + type GitCommandOptions, + type GitCommandResult, +} from "../utils/run-git-command.js"; import { branchNameFromRef } from "../utils/worktree-metadata.js"; import { listPaseoWorktrees, type PaseoWorktreeInfo } from "../utils/worktree.js"; import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js"; @@ -68,6 +83,8 @@ import { createFileObserver, } from "./file-observer/index.js"; import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js"; +import type { BoundWorkspaceRuntime, WorkspaceRuntimeService } from "./workspace-runtime/index.js"; +import { observeWorkspaceGit } from "./workspace-git-observation.js"; const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000; const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000; @@ -170,6 +187,8 @@ export interface WorkspaceGitRuntimeSnapshot { } export interface WorkspaceGitService { + bindWorkspace(input: { workspaceId: string; cwd: string }): WorkspaceGitWorkspace; + bindLegacy(cwd: string): WorkspaceGitWorkspace; registerWorkspace( params: { cwd: string }, listener: WorkspaceGitListener, @@ -212,6 +231,25 @@ export interface WorkspaceGitService { resolveRepoRoot(cwd: string, options?: WorkspaceGitReadOptions): Promise; resolveDefaultBranch(cwdOrRepoRoot: string, options?: WorkspaceGitReadOptions): Promise; resolveRepoRemoteUrl(cwd: string, options?: WorkspaceGitReadOptions): Promise; + commit(cwd: string, options: { message: string; addAll: boolean }): Promise; + createBranch(cwd: string, options: { branch: string; baseRef: string }): Promise; + switchBranch(cwd: string, branch: string): Promise; + fetch(cwd: string): Promise; + listCommits(cwd: string): ReturnType; + getCommitFileDiff( + cwd: string, + input: { sha: string; path: string }, + ): ReturnType; + stashPush(cwd: string, message: string): Promise; + stashPop(cwd: string, stashIndex: number): Promise; + mergeToBase(cwd: string, options: Parameters[1]): Promise; + mergeFromBase(cwd: string, options: Parameters[1]): Promise; + pull(cwd: string): Promise; + push(cwd: string): Promise; + renameBranch( + cwd: string, + branch: string, + ): Promise<{ previousBranch: string | null; currentBranch: string | null }>; refresh(cwd: string, options?: { priority?: "normal" | "high" }): Promise; requestWorkingTreeWatch( cwd: string, @@ -224,6 +262,61 @@ export interface WorkspaceGitService { dispose(): Promise; } +/** Git capability bound once to a selected workspace's durable identity. */ +export interface WorkspaceGitWorkspace { + readonly cwd: string; + register(listener: WorkspaceGitListener): WorkspaceGitSubscription; + observe(listener: WorkspaceGitListener): Promise; + peekSnapshot(): WorkspaceGitRuntimeSnapshot | null; + getCheckout(): Promise; + getSnapshot(options?: WorkspaceGitSnapshotOptions): Promise; + resolveForge(): Promise; + getCheckoutDiff( + options: CheckoutDiffCompare, + readOptions?: WorkspaceGitReadOptions, + ): Promise; + validateBranchRef( + ref: string, + options?: WorkspaceGitReadOptions, + ): Promise; + hasLocalBranch(branch: string, options?: WorkspaceGitReadOptions): Promise; + suggestBranches( + options?: WorkspaceGitBranchSuggestionsOptions, + readOptions?: WorkspaceGitReadOptions, + ): Promise; + listStashes( + options?: WorkspaceGitStashListOptions, + readOptions?: WorkspaceGitReadOptions, + ): Promise; + listWorktrees(options?: WorkspaceGitReadOptions): Promise; + getProjectSlug(options?: WorkspaceGitReadOptions): Promise; + resolveRepoRoot(options?: WorkspaceGitReadOptions): Promise; + resolveDefaultBranch(options?: WorkspaceGitReadOptions): Promise; + resolveRepoRemoteUrl(options?: WorkspaceGitReadOptions): Promise; + commit(options: { message: string; addAll: boolean }): Promise; + createBranch(options: { branch: string; baseRef: string }): Promise; + switchBranch(branch: string): Promise; + fetch(): Promise; + listCommits(): ReturnType; + getCommitFileDiff(input: { sha: string; path: string }): ReturnType; + stashPush(message: string): Promise; + stashPop(stashIndex: number): Promise; + mergeToBase(options: Parameters[1]): Promise; + mergeFromBase(options: Parameters[1]): Promise; + pull(): Promise; + push(): Promise; + renameBranch( + branch: string, + ): Promise<{ previousBranch: string | null; currentBranch: string | null }>; + refresh(options?: { priority?: "normal" | "high" }): Promise; + requestWorkingTreeWatch( + onChange: () => void, + ): Promise<{ repoRoot: string | null; unsubscribe: () => void }>; + scheduleRefresh(): void; + stateMayHaveChanged(): void; + invalidateForge(): void; +} + export interface WorkspaceGitServiceMetrics { workspaceTargetCount: number; workspaceListenerCount: number; @@ -357,14 +450,20 @@ interface WorkspaceGitServiceDependencies { now: () => Date; } -interface WorkspaceGitServiceOptions { +export interface WorkspaceGitServiceOptions { logger: pino.Logger; paseoHome: string; worktreesRoot?: string; fileObserver?: FileObserver; + workspaceRuntime?: WorkspaceRuntimeService; deps?: Partial; } +interface WorkspaceGitServiceBinding { + workspaceId: string; + snapshotUpdatedListeners: Set; +} + class WorkspaceGitServiceDisposedError extends Error { constructor() { super("WorkspaceGitService is disposed"); @@ -383,6 +482,8 @@ class WorkspaceGitWatcherSubscriptionTimeoutError extends Error { interface WorkspaceGitTarget { cwd: string; + runtimeIdentity: BoundWorkspaceRuntime | null; + runtimeObservation: { unsubscribe(): Promise } | null; listeners: Set; workingTreeWatchTarget: WorkingTreeWatchTarget | null; debounceTimer: NodeJS.Timeout | null; @@ -519,6 +620,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private readonly fileObserver: FileObserver; private readonly deps: WorkspaceGitServiceDependencies; private readonly forgeResolver: ForgeResolver; + private readonly workspaceRuntime: WorkspaceRuntimeService | null; + private readonly selectedWorkspaceId: string | null; + private readonly selectedWorkspaces = new Map< + string, + { cwd: string; service: WorkspaceGitServiceImpl; capability: WorkspaceGitWorkspace } + >(); + private readonly legacyWorkspaces = new Map(); + private readonly runtimeCacheTokens = new WeakMap(); + private nextRuntimeCacheToken = 1; private readonly workspaceRefreshLimit = pLimit({ concurrency: WORKSPACE_GIT_REFRESH_CONCURRENCY, rejectOnClear: true, @@ -530,7 +640,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private readonly disposeController = new AbortController(); private disposed = false; private disposePromise: Promise | null = null; - private readonly snapshotUpdatedListeners = new Set(); + private readonly snapshotUpdatedListeners: Set; + private readonly ownsSnapshotUpdatedListeners: boolean; private readonly workspaceTargets = new Map(); private readonly repoTargets = new Map(); private readonly workingTreeWatchTargets = new Map(); @@ -566,11 +677,16 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { WorkspaceGitAuxiliaryReadCacheEntry >({ max: WORKSPACE_GIT_CHECKOUT_DIFF_CACHE_MAX }); private watcherErrorCallbackCount = 0; - constructor(options: WorkspaceGitServiceOptions) { + constructor(options: WorkspaceGitServiceOptions, binding?: WorkspaceGitServiceBinding) { this.logger = options.logger.child({ module: "workspace-git-service" }); this.paseoHome = options.paseoHome; this.worktreesRoot = options.worktreesRoot; this.fileObserver = options.fileObserver ?? createFileObserver(); + this.workspaceRuntime = options.workspaceRuntime ?? null; + this.selectedWorkspaceId = binding?.workspaceId ?? null; + this.snapshotUpdatedListeners = + binding?.snapshotUpdatedListeners ?? new Set(); + this.ownsSnapshotUpdatedListeners = binding === undefined; this.deps = resolveWorkspaceGitServiceDeps( this.fileObserver.subscribe.bind(this.fileObserver), options.deps, @@ -580,6 +696,111 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { }); } + bindWorkspace(input: { workspaceId: string; cwd: string }): WorkspaceGitWorkspace { + this.assertNotDisposed(); + if (!this.workspaceRuntime) { + throw new Error("Workspace runtime is not available"); + } + const cwd = resolve(input.cwd); + const existing = this.selectedWorkspaces.get(input.workspaceId); + if (existing) { + if (existing.cwd !== cwd) { + throw new Error(`Workspace Git binding changed cwd: ${input.workspaceId}`); + } + return existing.capability; + } + const service = new WorkspaceGitServiceImpl( + { + logger: this.logger, + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, + workspaceRuntime: this.workspaceRuntime, + deps: this.deps, + }, + { + workspaceId: input.workspaceId, + snapshotUpdatedListeners: this.snapshotUpdatedListeners, + }, + ); + const capability = this.createBoundWorkspaceCapability(service, cwd, true); + this.selectedWorkspaces.set(input.workspaceId, { cwd, service, capability }); + return capability; + } + + bindLegacy(cwd: string): WorkspaceGitWorkspace { + this.assertNotDisposed(); + const normalizedCwd = resolve(cwd); + let capability = this.legacyWorkspaces.get(normalizedCwd); + if (!capability) { + capability = this.createBoundWorkspaceCapability(this, normalizedCwd, false); + this.legacyWorkspaces.set(normalizedCwd, capability); + } + return capability; + } + + private createBoundWorkspaceCapability( + service: WorkspaceGitServiceImpl, + cwd: string, + selected: boolean, + ): WorkspaceGitWorkspace { + const unsupported = (operation: string) => + Promise.reject(new Error(`Selected workspace Git does not support ${operation}`)); + return { + cwd, + register: (listener) => service.registerWorkspace({ cwd }, listener), + observe: (listener) => service.observeWorkspace(cwd, listener), + peekSnapshot: () => service.peekSnapshot(cwd), + getCheckout: () => service.getCheckout(cwd), + getSnapshot: (options) => service.getSnapshot(cwd, options), + resolveForge: () => (selected ? unsupported("forge resolution") : service.resolveForge(cwd)), + getCheckoutDiff: (options, readOptions) => service.getCheckoutDiff(cwd, options, readOptions), + validateBranchRef: (ref, options) => service.validateBranchRef(cwd, ref, options), + hasLocalBranch: (branch, options) => service.hasLocalBranch(cwd, branch, options), + suggestBranches: (options, readOptions) => + service.suggestBranchesForCwd(cwd, options, readOptions), + listStashes: (options, readOptions) => service.listStashes(cwd, options, readOptions), + listWorktrees: (options) => + selected ? unsupported("worktree listing") : service.listWorktrees(cwd, options), + getProjectSlug: (options) => service.getProjectSlug(cwd, options), + resolveRepoRoot: (options) => service.resolveRepoRoot(cwd, options), + resolveDefaultBranch: (options) => service.resolveDefaultBranch(cwd, options), + resolveRepoRemoteUrl: (options) => service.resolveRepoRemoteUrl(cwd, options), + commit: (options) => service.commit(cwd, options), + createBranch: (options) => service.createBranch(cwd, options), + switchBranch: (branch) => service.switchBranch(cwd, branch), + fetch: () => service.fetch(cwd), + listCommits: () => service.listCommits(cwd), + getCommitFileDiff: (input) => service.getCommitFileDiff(cwd, input), + stashPush: (message) => service.stashPush(cwd, message), + stashPop: (stashIndex) => service.stashPop(cwd, stashIndex), + mergeToBase: (options) => + selected ? unsupported("merge to base") : service.mergeToBase(cwd, options), + mergeFromBase: (options) => + selected ? unsupported("merge from base") : service.mergeFromBase(cwd, options), + pull: () => service.pull(cwd), + push: () => (selected ? unsupported("push") : service.push(cwd)), + renameBranch: (branch) => + selected ? unsupported("branch rename") : service.renameBranch(cwd, branch), + refresh: async (options) => { + if (!selected) { + (await service.resolveForge(cwd))?.service.invalidate({ cwd }); + } + await service.fetch(cwd); + await service.getSnapshot(cwd, { + force: true, + includeForge: !selected, + reason: options?.priority === "high" ? "manual-refresh-high" : "manual-refresh", + }); + }, + requestWorkingTreeWatch: (onChange) => service.requestWorkingTreeWatch(cwd, onChange), + scheduleRefresh: () => service.scheduleRefreshForCwd(cwd), + stateMayHaveChanged: () => service.onWorkspaceStateMayHaveChanged(cwd), + invalidateForge: () => { + if (!selected) service.invalidateForge(cwd); + }, + }; + } + resolveForge(cwd: string): Promise { this.assertNotDisposed(); return this.forgeResolver.resolve(resolve(cwd)); @@ -608,6 +829,21 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { }; } + private async observeWorkspace( + cwd: string, + listener: WorkspaceGitListener, + ): Promise { + const subscription = this.registerWorkspace({ cwd }, listener); + const target = this.workspaceTargets.get(resolve(cwd)); + try { + await target?.observationSetupPromise; + return subscription; + } catch (error) { + subscription.unsubscribe(); + throw error; + } + } + onSnapshotUpdated(listener: WorkspaceGitSnapshotUpdatedListener): WorkspaceGitSubscription { this.assertNotDisposed(); this.snapshotUpdatedListeners.add(listener); @@ -678,9 +914,26 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { options?: WorkspaceGitSnapshotOptions, ): Promise { this.assertNotDisposed(); + if (this.selectedWorkspaceId && options?.includeForge === true) { + throw new Error("Selected workspace Git does not support forge refresh"); + } cwd = resolve(cwd); + const runtime = this.selectedWorkspaceId ? await this.resolveBoundRuntime() : null; + const runtimeIdentity = runtime?.runtime ?? null; const request = this.normalizeRefreshRequest(options, "getSnapshot", true); const target = this.ensureWorkspaceTarget(cwd); + if (target.runtimeIdentity !== runtimeIdentity) { + void target.runtimeObservation?.unsubscribe(); + target.runtimeObservation = null; + target.observationSetupComplete = false; + target.runtimeIdentity = runtimeIdentity; + target.latestGit = null; + target.latestForge = null; + target.latestSnapshot = null; + target.latestFacts = null; + target.factsPromise = null; + target.latestFingerprint = null; + } if (!request.force && target.latestSnapshot) { return target.latestSnapshot; } @@ -691,11 +944,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { async getCheckout(cwd: string): Promise { this.assertNotDisposed(); const normalizedCwd = resolve(cwd); - const status = await this.deps.getCheckoutStatus(normalizedCwd, { - paseoHome: this.paseoHome, - worktreesRoot: this.worktreesRoot, - logger: this.logger, - }); + const status = await this.withWorkspaceRuntime(normalizedCwd, () => + this.deps.getCheckoutStatus(normalizedCwd, { + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, + logger: this.logger, + allowHostMetadata: this.selectedWorkspaceId === null, + }), + ); if (!status.isGit) { return checkoutLiteFromGitSnapshot(normalizedCwd, { isGit: false, @@ -706,13 +962,17 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { mainRepoRoot: null, }); } + const selected = this.selectedWorkspaceId !== null; + const repoRoot = selected ? normalizedCwd : status.repoRoot; + let mainRepoRoot = status.mainRepoRoot; + if (selected && mainRepoRoot !== null) mainRepoRoot = normalizedCwd; return checkoutLiteFromGitSnapshot(normalizedCwd, { isGit: true, currentBranch: status.currentBranch, remoteUrl: status.remoteUrl, - repoRoot: status.repoRoot, + repoRoot, isPaseoOwnedWorktree: status.isPaseoOwnedWorktree, - mainRepoRoot: status.mainRepoRoot, + mainRepoRoot, }); } @@ -721,7 +981,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return this.workspaceTargets.get(cwd)?.latestSnapshot ?? null; } - getCheckoutDiff( + async getCheckoutDiff( cwd: string, options: CheckoutDiffCompare, readOptions?: WorkspaceGitReadOptions, @@ -729,13 +989,161 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { this.assertNotDisposed(); const normalizedCwd = resolve(cwd); const normalizedOptions = this.normalizeCheckoutDiffOptions(options); - const key = this.buildCheckoutDiffCacheKey(normalizedCwd, normalizedOptions); + const key = this.buildCheckoutDiffCacheKey( + await this.runtimeCacheKey(normalizedCwd), + normalizedCwd, + normalizedOptions, + ); return this.readAuxiliaryCache(this.checkoutDiffCache, key, readOptions, () => - this.deps.getCheckoutDiff(normalizedCwd, normalizedOptions, { + this.withWorkspaceRuntime(normalizedCwd, () => + this.deps.getCheckoutDiff(normalizedCwd, normalizedOptions, { + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, + allowHostMetadata: this.selectedWorkspaceId === null, + }), + ), + ); + } + + async commit(cwd: string, options: { message: string; addAll: boolean }): Promise { + const normalizedCwd = resolve(cwd); + await this.withWorkspaceRuntime(normalizedCwd, () => commitChanges(normalizedCwd, options)); + await this.getSnapshot(normalizedCwd, { + force: true, + includeForge: false, + reason: "commit-changes", + }); + } + + async createBranch(cwd: string, options: { branch: string; baseRef: string }): Promise { + const normalizedCwd = resolve(cwd); + await this.withWorkspaceRuntime(normalizedCwd, () => + runGitCommand(["checkout", "-b", options.branch, options.baseRef], { + cwd: normalizedCwd, + timeout: 120_000, + }).then(() => undefined), + ); + await this.getSnapshot(normalizedCwd, { + force: true, + includeForge: false, + reason: "create-branch", + }); + } + + async switchBranch(cwd: string, branch: string): Promise { + const normalizedCwd = resolve(cwd); + const resolution = await this.validateBranchRef(normalizedCwd, branch, { + force: true, + reason: "switch-branch", + }); + if (resolution.kind === "not-found") throw new Error(`Branch not found: ${branch}`); + const result = await this.withWorkspaceRuntime(normalizedCwd, () => + checkoutResolvedBranch({ cwd: normalizedCwd, resolution }), + ); + await this.getSnapshot(normalizedCwd, { + force: true, + includeForge: false, + reason: "switch-branch", + }); + return result; + } + + async fetch(cwd: string): Promise { + const normalizedCwd = resolve(cwd); + await this.withWorkspaceRuntime(normalizedCwd, () => + this.deps + .runGitFetch(normalizedCwd, { onRefSnapshot: () => undefined }) + .then(() => undefined), + ); + await this.getSnapshot(normalizedCwd, { + force: true, + includeForge: false, + reason: "fetch", + }); + } + + async listCommits(cwd: string): ReturnType { + const normalizedCwd = resolve(cwd); + return this.withWorkspaceRuntime(normalizedCwd, () => + listCheckoutCommits({ cwd: normalizedCwd }), + ); + } + + async getCommitFileDiff( + cwd: string, + input: { sha: string; path: string }, + ): ReturnType { + const normalizedCwd = resolve(cwd); + return this.withWorkspaceRuntime(normalizedCwd, () => + getCommitFileDiff({ + cwd: normalizedCwd, + sha: input.sha, + path: input.path, + allowFileRead: this.selectedWorkspaceId === null, + }), + ); + } + + async stashPush(cwd: string, message: string): Promise { + const normalizedCwd = resolve(cwd); + await this.withWorkspaceRuntime(normalizedCwd, () => + runGitCommand(["stash", "push", "--include-untracked", "-m", message], { + cwd: normalizedCwd, + timeout: 120_000, + }).then(() => undefined), + ); + } + + async stashPop(cwd: string, stashIndex: number): Promise { + const normalizedCwd = resolve(cwd); + await this.withWorkspaceRuntime(normalizedCwd, () => + runGitCommand(["stash", "pop", `stash@{${stashIndex}}`], { + cwd: normalizedCwd, + timeout: 120_000, + }).then(() => undefined), + ); + } + + async mergeToBase(cwd: string, options: Parameters[1]): Promise { + const normalizedCwd = resolve(cwd); + const selected = this.selectedWorkspaceId !== null; + const mutatedCwd = await this.withWorkspaceRuntime(normalizedCwd, () => + mergeToBase(normalizedCwd, options, { paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot, }), ); + return selected ? normalizedCwd : mutatedCwd; + } + + async mergeFromBase(cwd: string, options: Parameters[1]): Promise { + const normalizedCwd = resolve(cwd); + await this.withWorkspaceRuntime(normalizedCwd, () => + mergeFromBase(normalizedCwd, options, { + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, + }), + ); + } + + async pull(cwd: string): Promise { + const normalizedCwd = resolve(cwd); + await this.withWorkspaceRuntime(normalizedCwd, () => pullCurrentBranch(normalizedCwd)); + } + + async push(cwd: string): Promise { + const normalizedCwd = resolve(cwd); + await this.withWorkspaceRuntime(normalizedCwd, () => pushCurrentBranch(normalizedCwd)); + } + + async renameBranch( + cwd: string, + branch: string, + ): Promise<{ previousBranch: string | null; currentBranch: string | null }> { + const normalizedCwd = resolve(cwd); + return this.withWorkspaceRuntime(normalizedCwd, () => + renameCurrentBranch(normalizedCwd, branch), + ); } private normalizeCheckoutDiffOptions(options: CheckoutDiffCompare): CheckoutDiffCompare { @@ -749,11 +1157,16 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { }; } - private buildCheckoutDiffCacheKey(cwd: string, options: CheckoutDiffCompare): string { + private buildCheckoutDiffCacheKey( + cacheIdentity: string, + 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", + cacheIdentity, cwd, options.mode, options.mode === "base" ? (options.baseRef ?? null) : null, @@ -764,14 +1177,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private invalidateCheckoutDiffCache(cwd: string, mode: CheckoutDiffCompare["mode"]): void { for (const key of this.checkoutDiffCache.keys()) { - const [kind, cachedCwd, cachedMode] = JSON.parse(key) as unknown[]; + const [kind, , cachedCwd, cachedMode] = JSON.parse(key) as unknown[]; if (kind === "checkout-diff" && cachedCwd === cwd && cachedMode === mode) { this.checkoutDiffCache.delete(key); } } } - validateBranchRef( + async validateBranchRef( cwd: string, ref: string, options?: WorkspaceGitReadOptions, @@ -779,29 +1192,41 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { this.assertNotDisposed(); const normalizedCwd = resolve(cwd); const normalizedRef = ref.trim(); - const key = JSON.stringify(["branch-validation", normalizedCwd, normalizedRef]); + const key = JSON.stringify([ + "branch-validation", + await this.runtimeCacheKey(normalizedCwd), + normalizedRef, + ]); return this.readAuxiliaryCache(this.branchValidationCache, key, options, () => - this.deps.resolveBranchCheckout(normalizedCwd, normalizedRef), + this.withWorkspaceRuntime(normalizedCwd, () => + this.deps.resolveBranchCheckout(normalizedCwd, normalizedRef), + ), ); } - hasLocalBranch(cwd: string, branch: string, options?: WorkspaceGitReadOptions): Promise { + async hasLocalBranch( + cwd: string, + branch: string, + options?: WorkspaceGitReadOptions, + ): Promise { this.assertNotDisposed(); const normalizedCwd = resolve(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, - envOverlay: READ_ONLY_GIT_ENV, - acceptExitCodes: [0, 1], - }); - return result.exitCode === 0; - }); + const key = JSON.stringify(["local-branch", await this.runtimeCacheKey(normalizedCwd), ref]); + return this.readAuxiliaryCache(this.localBranchCache, key, options, () => + this.withWorkspaceRuntime(normalizedCwd, async () => { + const result = await this.deps.runGitCommand(["rev-parse", "--verify", "--quiet", ref], { + cwd: normalizedCwd, + envOverlay: READ_ONLY_GIT_ENV, + acceptExitCodes: [0, 1], + }); + return result.exitCode === 0; + }), + ); } - suggestBranchesForCwd( + async suggestBranchesForCwd( cwd: string, options?: WorkspaceGitBranchSuggestionsOptions, readOptions?: WorkspaceGitReadOptions, @@ -810,13 +1235,20 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const normalizedCwd = resolve(cwd); const query = options?.query ?? ""; const limit = options?.limit; - const key = JSON.stringify(["branch-suggestions", normalizedCwd, query, limit ?? null]); + const key = JSON.stringify([ + "branch-suggestions", + await this.runtimeCacheKey(normalizedCwd), + query, + limit ?? null, + ]); return this.readAuxiliaryCache(this.branchSuggestionsCache, key, readOptions, () => - this.deps.listBranchSuggestions(normalizedCwd, options), + this.withWorkspaceRuntime(normalizedCwd, () => + this.deps.listBranchSuggestions(normalizedCwd, options), + ), ); } - listStashes( + async listStashes( cwd: string, options?: WorkspaceGitStashListOptions, readOptions?: WorkspaceGitReadOptions, @@ -824,14 +1256,16 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { this.assertNotDisposed(); const normalizedCwd = resolve(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, - envOverlay: READ_ONLY_GIT_ENV, - }); - return parseWorkspaceGitStashList(stdout, { paseoOnly }); - }); + const key = JSON.stringify(["stashes", await this.runtimeCacheKey(normalizedCwd), paseoOnly]); + return this.readAuxiliaryCache(this.stashListCache, key, readOptions, () => + this.withWorkspaceRuntime(normalizedCwd, async () => { + const { stdout } = await this.deps.runGitCommand(["stash", "list", "--format=%gd%x00%s"], { + cwd: normalizedCwd, + envOverlay: READ_ONLY_GIT_ENV, + }); + return parseWorkspaceGitStashList(stdout, { paseoOnly }); + }), + ); } async listWorktrees( @@ -840,13 +1274,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { ): Promise { this.assertNotDisposed(); const repoRoot = await this.resolveRepoRoot(cwdOrRepoRoot, options); - const key = JSON.stringify(["worktrees", repoRoot]); + const key = JSON.stringify(["worktrees", await this.runtimeCacheKey(cwdOrRepoRoot, repoRoot)]); return this.readAuxiliaryCache(this.worktreeListCache, key, options, () => - this.deps.listPaseoWorktrees({ - cwd: repoRoot, - paseoHome: this.paseoHome, - worktreesRoot: this.worktreesRoot, - }), + this.withWorkspaceRuntime(cwdOrRepoRoot, () => + this.deps.listPaseoWorktrees({ + cwd: repoRoot, + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, + }), + ), ); } @@ -867,14 +1303,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { ): Promise { this.assertNotDisposed(); const cwd = resolve(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; - }); + const key = JSON.stringify(["default-branch", await this.runtimeCacheKey(cwd)]); + return this.readAuxiliaryCache(this.defaultBranchCache, key, options, () => + this.withWorkspaceRuntime(cwd, async () => { + const defaultBranch = await this.deps.resolveRepositoryDefaultBranch(cwd); + if (!defaultBranch) throw new Error("Unable to resolve repository default branch"); + return defaultBranch; + }), + ); } async getProjectSlug(cwd: string, options?: WorkspaceGitReadOptions): Promise { @@ -964,6 +1400,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { this.disposeController.abort(new WorkspaceGitServiceDisposedError()); this.workspaceRefreshLimit.clearQueue(); this.workspaceObservationSetupLimit.clearQueue(); + const selectedWorkspaceDisposals = [...this.selectedWorkspaces.values()].map(({ service }) => + service.dispose(), + ); + this.selectedWorkspaces.clear(); + this.legacyWorkspaces.clear(); for (const target of this.workspaceTargets.values()) { this.closeWorkspaceTarget(target); @@ -982,8 +1423,13 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { this.workingTreeWatchSetups.clear(); this.workingTreeWatchResolutions.clear(); this.workingTreeWatchAliases.clear(); - this.snapshotUpdatedListeners.clear(); - this.disposePromise = this.fileObserver.close(); + if (this.ownsSnapshotUpdatedListeners) { + this.snapshotUpdatedListeners.clear(); + } + this.disposePromise = Promise.all([ + this.fileObserver.close(), + ...selectedWorkspaceDisposals, + ]).then(() => undefined); return this.disposePromise; } @@ -1126,6 +1572,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private createWorkspaceTarget(cwd: string): WorkspaceGitTarget { const target: WorkspaceGitTarget = { cwd, + runtimeIdentity: null, + runtimeObservation: null, listeners: new Set(), workingTreeWatchTarget: null, debounceTimer: null, @@ -1166,7 +1614,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { force: false, refreshStructure: true, refreshWorktree: true, - includeForge: true, + includeForge: this.selectedWorkspaceId === null, reason: "initial", notify: true, queueIfBusy: false, @@ -1205,6 +1653,18 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } private async setupWorkspaceObservation(target: WorkspaceGitTarget): Promise { + const selected = await this.resolveBoundRuntime(); + if (selected) { + target.runtimeObservation = await observeWorkspaceGit(selected.runtime, () => { + if (!this.isActiveObservedWorkspaceTarget(target)) return; + this.scheduleWorkspaceRefresh(target, { + force: true, + reason: "runtime-git-observation", + }); + }); + target.observationSetupComplete = true; + return; + } const facts = await this.getFactsForObservation(target); if (!this.isActiveObservedWorkspaceTarget(target)) { return; @@ -1248,6 +1708,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return this.loadCheckoutFacts(target, { paseoHome: this.paseoHome, logger: this.logger, + allowHostMetadata: this.selectedWorkspaceId === null, }); } @@ -2660,7 +3121,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { force, refreshStructure: true, refreshWorktree: true, - includeForge: options?.includeForge ?? true, + includeForge: options?.includeForge ?? this.selectedWorkspaceId === null, reason: options?.reason ?? defaultReason, notify, queueIfBusy: false, @@ -2760,7 +3221,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (target.closed || this.workspaceTargets.get(target.cwd) !== target) { return null; } - return this.refreshSnapshot(target, request); + return this.withWorkspaceRuntime(target.cwd, () => this.refreshSnapshot(target, request)); }); if (!admittedSnapshot) { break; @@ -2837,6 +3298,42 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return snapshot; } + private async withWorkspaceRuntime(_cwd: string, operation: () => Promise): Promise { + const resolved = await this.resolveBoundRuntime(); + if (!resolved) return operation(); + return runWithGitCommandRunner( + (args, options) => runRuntimeGitCommand(resolved.runtime, args, options), + operation, + ); + } + + private async resolveBoundRuntime(): Promise<{ + workspaceId: string; + runtime: BoundWorkspaceRuntime; + } | null> { + if (!this.workspaceRuntime || !this.selectedWorkspaceId) return null; + return { + workspaceId: this.selectedWorkspaceId, + runtime: await this.workspaceRuntime.bind(this.selectedWorkspaceId), + }; + } + + private async runtimeCacheKey(cwd: string, legacyIdentity = cwd): Promise { + const resolved = await this.resolveBoundRuntime(); + if (!resolved) return `legacy:${resolve(legacyIdentity)}`; + return `workspace:${resolved.workspaceId}:${this.getRuntimeCacheToken(resolved.runtime)}`; + } + + private getRuntimeCacheToken(runtime: BoundWorkspaceRuntime): number { + let token = this.runtimeCacheTokens.get(runtime); + if (token === undefined) { + token = this.nextRuntimeCacheToken; + this.nextRuntimeCacheToken += 1; + this.runtimeCacheTokens.set(runtime, token); + } + return token; + } + private async refreshRefDerivedSnapshot( target: WorkspaceGitTarget, facts: Extract, @@ -2907,6 +3404,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot, logger: this.logger, + allowHostMetadata: this.selectedWorkspaceId === null, + cacheIdentity: + this.selectedWorkspaceId && target.runtimeIdentity + ? `workspace:${this.selectedWorkspaceId}:${this.getRuntimeCacheToken(target.runtimeIdentity)}` + : `legacy:${resolve(target.cwd)}`, }; const facts = await this.loadCheckoutFacts(target, baseContext); const context: CheckoutContext = { ...baseContext, facts }; @@ -3005,11 +3507,20 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return target.latestSnapshot ?? buildNotGitSnapshot(target.cwd); } - return { + const snapshot = { cwd: target.cwd, git: target.latestGit, forge: target.latestForge ?? buildForgeUnavailableSnapshot(), }; + if (!target.runtimeIdentity || !snapshot.git.isGit) return snapshot; + return { + ...snapshot, + git: { + ...snapshot.git, + repoRoot: target.cwd, + mainRepoRoot: snapshot.git.mainRepoRoot === null ? null : target.cwd, + }, + }; } private getForgePrStatusPollKey(target: WorkspaceGitTarget): string | null { @@ -3258,6 +3769,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private closeWorkspaceTarget(target: WorkspaceGitTarget): void { target.closed = true; + void target.runtimeObservation?.unsubscribe(); + target.runtimeObservation = null; if (target.workingTreeWatchTarget) { this.removeWorkspaceWorkingTreeLink(target.workingTreeWatchTarget, target.cwd); target.workingTreeWatchTarget = null; @@ -3332,6 +3845,95 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } } +async function runRuntimeGitCommand( + runtime: BoundWorkspaceRuntime, + args: string[], + options: GitCommandOptions, +): Promise { + const process = await runtime.run({ + argv: ["git", "-c", "core.quotepath=false", ...args], + env: buildRuntimeGitEnvironment(options), + purpose: { kind: "git" }, + }); + process.stdin.end(); + const timeout = options.timeout ?? 30_000; + const maxOutputBytes = options.maxOutputBytes ?? 20 * 1024 * 1024; + let timer: ReturnType | undefined; + const timedOut = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + process.kill("SIGKILL"); + reject(new Error(`Git command timed out after ${timeout}ms: git ${args.join(" ")}`)); + }, timeout); + }); + try { + const completed = Promise.all([ + collectRuntimeGitOutput(process.stdout, maxOutputBytes, () => process.kill("SIGKILL")), + collectRuntimeGitOutput(process.stderr, 2_048, () => undefined), + process.exited, + ]).then(([stdout, stderr, exit]) => ({ stdout, stderr, exit })); + const { stdout, stderr, exit } = await Promise.race([completed, timedOut]); + const result: GitCommandResult = { + stdout: stdout.contents, + stderr: stderr.contents, + truncated: stdout.truncated, + exitCode: exit.code, + signal: exit.signal, + }; + const accepted = options.acceptExitCodes ?? [0]; + if (!result.truncated && !accepted.includes(result.exitCode ?? -1)) { + const stderrPreview = result.stderr.trim() || "(no stderr)"; + throw new Error( + `Git command failed: git ${args.join(" ")} (exit code: ${String(result.exitCode)}, signal: ${result.signal ?? "none"})\n${stderrPreview}`, + ); + } + return result; + } finally { + if (timer) clearTimeout(timer); + } +} + +function buildRuntimeGitEnvironment(options: GitCommandOptions): Readonly> { + const environment: Record = {}; + for (const name of ["HOME", "LANG", "LC_ALL", "TMPDIR", "XDG_CONFIG_HOME"] as const) { + const value = process.env[name]; + if (value !== undefined) environment[name] = value; + } + for (const [name, value] of Object.entries(options.env ?? {})) { + if (value !== undefined) environment[name] = value; + } + for (const [name, value] of Object.entries(options.envOverlay ?? {})) { + if (value !== undefined) environment[name] = value; + } + environment.PATH = ["/usr/local/bin", "/usr/bin", "/bin", environment.PATH] + .filter(Boolean) + .join(":"); + return environment; +} + +async function collectRuntimeGitOutput( + stream: NodeJS.ReadableStream, + limit: number, + onLimit: () => void, +): Promise<{ contents: string; truncated: boolean }> { + const chunks: Buffer[] = []; + let size = 0; + let truncated = false; + for await (const chunk of stream) { + if (truncated) continue; + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const remaining = limit - size; + if (bytes.length > remaining) { + if (remaining > 0) chunks.push(bytes.subarray(0, remaining)); + truncated = true; + onLimit(); + continue; + } + chunks.push(bytes); + size += bytes.length; + } + return { contents: Buffer.concat(chunks).toString("utf8"), truncated }; +} + async function loadForgeSnapshot(options: { cwd: string; forgeService: ForgeService | null; diff --git a/packages/server/src/server/workspace-helper/executable.mjs b/packages/server/src/server/workspace-helper/executable.mjs index 18d4cc263d..194bad3e5a 100644 --- a/packages/server/src/server/workspace-helper/executable.mjs +++ b/packages/server/src/server/workspace-helper/executable.mjs @@ -175,10 +175,19 @@ async function runWatcher(workspaceRoot) { } if (command.type !== "subscribe") continue; closeSubscription(subscriptions.get(command.id)); - const observation = { watchers: [], timers: new Map() }; + const observation = { watchers: new Map(), timers: new Map() }; subscriptions.set(command.id, observation); + const ignoredPaths = new Set(command.ignoredPaths ?? []); for (const requested of command.paths) { const scoped = await resolveScoped(workspaceRoot, requested); + const info = await stat(scoped.absolute).catch((error) => { + if (isMissing(error)) return null; + throw error; + }); + if (command.recursive === true && info?.isDirectory()) { + await watchDirectoryTree(observation, scoped, command.id, ignoredPaths); + continue; + } const expectedName = path.basename(scoped.absolute); const watcher = watch(path.dirname(scoped.absolute), (_event, filename) => { if (filename != null && filename.toString() !== expectedName) return; @@ -194,7 +203,7 @@ async function runWatcher(workspaceRoot) { watcher.on("error", (error) => print({ type: "error", subscriptionId: command.id, message: error.message }), ); - observation.watchers.push(watcher); + observation.watchers.set(scoped.absolute, watcher); } print({ type: "subscribed", subscriptionId: command.id }); } @@ -205,10 +214,50 @@ async function runWatcher(workspaceRoot) { function closeSubscription(observation) { if (!observation) return; - for (const watcher of observation.watchers) watcher.close(); + for (const watcher of observation.watchers.values()) watcher.close(); for (const timer of observation.timers.values()) clearTimeout(timer); } +async function watchDirectoryTree(observation, scoped, subscriptionId, ignoredPaths) { + if (observation.watchers.has(scoped.absolute)) return; + const watcher = watch(scoped.absolute, (_event, filename) => { + const name = filename?.toString(); + const changed = name ? path.join(scoped.absolute, name) : scoped.absolute; + const relative = normalize(path.relative(root, changed)); + if (isIgnored(relative, ignoredPaths)) return; + clearTimeout(observation.timers.get(relative)); + observation.timers.set( + relative, + setTimeout(() => { + observation.timers.delete(relative); + print({ type: "changed", subscriptionId, paths: [relative] }); + void addDiscoveredDirectories(observation, scoped, subscriptionId, ignoredPaths); + }, 50), + ); + }); + watcher.on("error", (error) => print({ type: "error", subscriptionId, message: error.message })); + observation.watchers.set(scoped.absolute, watcher); + await addDiscoveredDirectories(observation, scoped, subscriptionId, ignoredPaths); +} + +async function addDiscoveredDirectories(observation, scoped, subscriptionId, ignoredPaths) { + const entries = await readdir(scoped.absolute, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const absolute = path.join(scoped.absolute, entry.name); + const relative = normalize(path.relative(root, absolute)); + if (isIgnored(relative, ignoredPaths)) continue; + await watchDirectoryTree(observation, { absolute, relative }, subscriptionId, ignoredPaths); + } +} + +function isIgnored(relativePath, ignoredPaths) { + for (const ignored of ignoredPaths) { + if (relativePath === ignored || relativePath.startsWith(`${ignored}/`)) return true; + } + return false; +} + async function resolveScoped(workspaceRoot, relativePath = ".") { if (path.isAbsolute(relativePath)) throw new Error(outsideMessage); const normalizedRoot = path.resolve(workspaceRoot); diff --git a/packages/server/src/server/workspace-helper/index.ts b/packages/server/src/server/workspace-helper/index.ts index 150c4b07ff..6ab61899be 100644 --- a/packages/server/src/server/workspace-helper/index.ts +++ b/packages/server/src/server/workspace-helper/index.ts @@ -68,7 +68,7 @@ export interface WorkspaceFiles { expectedRevision?: string; }): Promise; subscribe( - input: { paths: readonly string[] }, + input: { paths: readonly string[]; recursive?: boolean; ignoredPaths?: readonly string[] }, listener: (event: WorkspaceWatchEvent) => void, ): Promise; } diff --git a/packages/server/src/server/workspace-helper/internal/client.ts b/packages/server/src/server/workspace-helper/internal/client.ts index 337674eec0..067497a700 100644 --- a/packages/server/src/server/workspace-helper/internal/client.ts +++ b/packages/server/src/server/workspace-helper/internal/client.ts @@ -120,7 +120,7 @@ export function createClient(options: { state.subscriptions.set(id, listener); const acknowledged = acknowledgement(state, `subscribed:${id}`); state.process.stdin.write( - `${JSON.stringify({ protocolVersion: 1, type: "subscribe", id, paths: input.paths })}\n`, + `${JSON.stringify({ protocolVersion: 1, type: "subscribe", id, paths: input.paths, recursive: input.recursive === true, ignoredPaths: input.ignoredPaths ?? [] })}\n`, ); await waitForAcknowledgement( state, diff --git a/packages/server/src/server/workspace-runtime/index.ts b/packages/server/src/server/workspace-runtime/index.ts index 1eb8b51da4..c733bd168b 100644 --- a/packages/server/src/server/workspace-runtime/index.ts +++ b/packages/server/src/server/workspace-runtime/index.ts @@ -78,10 +78,16 @@ export interface WorkspaceTerminal { kill(signal?: NodeJS.Signals): void; } +export interface BoundWorkspaceRuntime { + run(input: Omit): Promise; + readonly files: WorkspaceFiles; +} + export interface WorkspaceRuntimeService { create(input: CreateWorkspaceInput): Promise<{ workspaceId: string; runtimeId: string }>; run(input: WorkspaceProcessInput): Promise; openTerminal(input: WorkspaceTerminalInput): Promise; + bind(workspaceId: string): Promise; files(workspaceId: string): WorkspaceFiles; pause(workspaceId: string): Promise; resume(workspaceId: string): Promise; diff --git a/packages/server/src/server/workspace-runtime/internal/service.ts b/packages/server/src/server/workspace-runtime/internal/service.ts index 71bdc37491..1320f957cd 100644 --- a/packages/server/src/server/workspace-runtime/internal/service.ts +++ b/packages/server/src/server/workspace-runtime/internal/service.ts @@ -1,5 +1,6 @@ import type { CreateWorkspaceInput, + BoundWorkspaceRuntime, WorkspaceProcess, WorkspaceProcessInput, WorkspaceRuntimeRecordStore, @@ -34,6 +35,10 @@ export function createService( >(); const boundFiles = new Map(); const unavailableFiles = new Map(); + const boundRuntimes = new Map< + string, + { runtimeId: string; revision: string; runtime: BoundWorkspaceRuntime } + >(); function requireRegistered(runtimeId: string): WorkspaceRuntimeDriver { const driver = driversById.get(runtimeId); @@ -157,6 +162,27 @@ export function createService( openTerminalWithDriver(await resolve(input.workspaceId), input), ); }, + async bind(workspaceId) { + const driver = await resolve(workspaceId); + const inspection = await driver.inspect(workspaceId); + if (inspection.status !== "ready") { + throw new Error(`Workspace runtime is ${inspection.status}: ${workspaceId}`); + } + const cached = boundRuntimes.get(workspaceId); + if (cached?.runtimeId === driver.id && cached.revision === inspection.state.revision) { + return cached.runtime; + } + const runtime: BoundWorkspaceRuntime = { + run: (input) => runWithDriver(driver, { workspaceId, ...input }), + files: bindFiles(workspaceId), + }; + boundRuntimes.set(workspaceId, { + runtimeId: driver.id, + revision: inspection.state.revision, + runtime, + }); + return runtime; + }, files(workspaceId) { let files = boundFiles.get(workspaceId); if (!files) { @@ -169,6 +195,7 @@ export function createService( await sequence(workspaceId, async () => { const driver = await resolve(workspaceId); unavailableFiles.set(workspaceId, "paused"); + boundRuntimes.delete(workspaceId); await closeFiles(workspaceId); await stopProcesses(workspaceId); await driver.pause(workspaceId); @@ -184,6 +211,7 @@ export function createService( await sequence(workspaceId, async () => { const driver = await resolve(workspaceId); unavailableFiles.set(workspaceId, "destroyed"); + boundRuntimes.delete(workspaceId); await closeFiles(workspaceId); await stopProcesses(workspaceId); await driver.destroy(workspaceId); diff --git a/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts b/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts index a1f580ff8a..986de9aa9d 100644 --- a/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts +++ b/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts @@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url"; import { afterAll, beforeAll, expect, test, vi } from "vitest"; import { createWorkspaceRuntimeService } from "./index.js"; +import { WorkspaceGitServiceImpl } from "../workspace-git-service.js"; const repositoryRoot = fileURLToPath(new URL("../../../../..", import.meta.url)); const dockerRuntimeRoot = path.join(repositoryRoot, "packages/docker-workspace-runtime"); @@ -71,6 +72,27 @@ test("the command runtime owns Docker workspace materialization, pipes, and life expect(workspace).toEqual({ workspaceId, runtimeId: "docker" }); expect("cwd" in workspace).toBe(false); + for (const [name, value] of [ + ["user.email", "test@example.com"], + ["user.name", "Paseo Test"], + ] as const) { + const configure = await service.run({ + workspaceId, + argv: ["git", "config", name, value], + env: { PATH: "/usr/local/bin:/usr/bin:/bin" }, + purpose: { kind: "git" }, + }); + configure.stdin.end(); + await expect(configure.exited).resolves.toEqual({ code: 0, signal: null }); + } + + const workspaceGitService = new WorkspaceGitServiceImpl({ + logger: createLogger(), + paseoHome: options.paseoHome, + workspaceRuntime: service, + }); + const workspaceGit = workspaceGitService.bindWorkspace({ workspaceId, cwd: repo }); + const files = service.files(workspaceId); const listing = await files.list("."); expect(listing.entries.map((entry) => entry.name)).toContain("committed.txt"); @@ -92,6 +114,24 @@ test("the command runtime owns Docker workspace materialization, pipes, and life terminalFileEdit.stdin.end(); await expect(terminalFileEdit.exited).resolves.toEqual({ code: 0, signal: null }); await expect(fileChanged).resolves.toBeUndefined(); + const dirtyGit = await workspaceGit.getSnapshot({ + force: true, + includeForge: false, + reason: "docker-edit", + }); + const dockerDiff = await workspaceGit.getCheckoutDiff( + { mode: "uncommitted", includeStructured: true }, + { force: true, reason: "docker-edit" }, + ); + expect(dirtyGit.git).toMatchObject({ isGit: true, isDirty: true, currentBranch: "main" }); + expect(JSON.stringify(dockerDiff)).toContain("container-change"); + await workspaceGit.commit({ message: "container commit", addAll: true }); + await workspaceGit.createBranch({ branch: "container-branch", baseRef: "main" }); + await workspaceGit.switchBranch("main"); + await workspaceGit.fetch(); + const cleanGit = await workspaceGit.getSnapshot(); + expect(cleanGit.git).toMatchObject({ isDirty: false, currentBranch: "main" }); + expect(await readFileFromHost(repo, "committed.txt")).toBe("committed\n"); const containerFile = await files.read("committed.txt"); await expect(collect(containerFile.chunks)).resolves.toBe("container-change"); const conflict = await files.write({ @@ -278,6 +318,7 @@ test("the command runtime owns Docker workspace materialization, pipes, and life ).trim(), ).toBe("false"); + await workspaceGitService.dispose(); await recovered.destroy(workspaceId); expect(dockerResourceCount("ps", workspaceId)).toBe(0); expect(dockerResourceCount("volume", workspaceId)).toBe(0); @@ -314,7 +355,6 @@ test("Docker creation validates roots and rolls back when runtime selection cann }, }, }); - await expect( service.create({ workspaceId: `${workspaceId}-escape`, @@ -517,3 +557,14 @@ async function readFileFromHost(root: string, name: string): Promise logger, + debug: vi.fn(), + info: vi.fn(), + trace: vi.fn(), + warn: vi.fn(), + }; + return logger; +} diff --git a/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts b/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts index 03e43ddd34..5c60db9275 100644 --- a/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts +++ b/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts @@ -23,6 +23,16 @@ afterEach(async () => { }); posixDescribe.each(["local", "worktree"] as const)("%s runtime public contract", (runtimeId) => { + test("bound runtime exposes only process and file primitives", async () => { + const fixture = await createFixture(runtimeId); + await fixture.service.create(fixture.createInput); + + const runtime = await fixture.service.bind(fixture.workspaceId); + + expect(Object.keys(runtime).sort()).toEqual(["files", "run"]); + await fixture.service.destroy(fixture.workspaceId); + }); + test("binds streaming files and live observation to the selected runtime", async () => { const fixture = await createFixture(runtimeId); await fixture.service.create(fixture.createInput); @@ -84,7 +94,7 @@ posixDescribe.each(["local", "worktree"] as const)("%s runtime public contract", argv: [ process.execPath, "-e", - "process.stdin.setEncoding('utf8');process.stdout.write(`${process.cwd()}|${process.stdout.isTTY}|${process.stdout.columns}x${process.stdout.rows}|λ`);process.stdin.once('data',data=>{process.stdout.write(`|${data.trim()}|${process.stdout.columns}x${process.stdout.rows}`);process.exit(7)})", + "process.stdin.setEncoding('utf8');process.stdout.write(`${process.cwd()}|${process.stdout.isTTY}|${process.stdout.columns}x${process.stdout.rows}|λ`);process.stdin.once('data',data=>{const finish=()=>{process.stdout.write(`|${data.trim()}|${process.stdout.columns}x${process.stdout.rows}`);process.exit(7)};process.stdout.columns===101?finish():process.stdout.once('resize',finish)})", ], env: { PATH: process.env.PATH ?? "" }, purpose: { kind: "terminal", terminalId: `${runtimeId}-terminal` }, diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 468600de72..c0cdc54cf5 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1,6 +1,6 @@ import { resolve, dirname, basename } from "path"; -import { existsSync, realpathSync } from "fs"; -import { open as openFile, readFile, stat as statFile } from "fs/promises"; +import { realpathSync } from "fs"; +import { readFile } from "fs/promises"; import { TTLCache } from "@isaacs/ttlcache"; import type { CheckoutCommit, CheckoutCommitFile } from "@getpaseo/protocol/messages"; import { parseGitHubRemoteIdentity, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; @@ -148,8 +148,8 @@ function rememberPullRequestStatus(cacheKey: string, status: PullRequestStatusRe } } -function getShortstatCacheKey(cwd: string): string { - return resolve(cwd); +function getShortstatCacheKey(cwd: string, context?: CheckoutContext): string { + return context?.cacheIdentity ?? resolve(cwd); } export function __resetPullRequestStatusCacheForTests(): void { @@ -845,6 +845,8 @@ export interface CheckoutContext { worktreesRoot?: string; logger?: Pick; facts?: CheckoutSnapshotFacts | null; + allowHostMetadata?: boolean; + cacheIdentity?: string; } export type CheckoutSnapshotFacts = @@ -897,7 +899,10 @@ async function requireGitWorktreeRoot(cwd: string): Promise { } } -export async function getCurrentBranch(cwd: string): Promise { +export async function getCurrentBranch( + cwd: string, + context?: CheckoutContext, +): Promise { try { const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], { cwd, @@ -905,7 +910,7 @@ export async function getCurrentBranch(cwd: string): Promise { }); const branch = stdout.trim(); if (branch === "HEAD") { - return await getRebaseHeadBranch(cwd); + return context?.allowHostMetadata === false ? null : await getRebaseHeadBranch(cwd); } return branch.length > 0 ? branch : null; } catch { @@ -1002,7 +1007,8 @@ async function getMainRepoRootFromCommonDir( if (!commonDir) { throw new Error("Not in a git repository"); } - const normalized = realpathSync(commonDir); + const normalized = + context?.allowHostMetadata === false ? resolve(commonDir) : realpathSync(commonDir); if (basename(normalized) === ".git") { return dirname(normalized); @@ -1140,6 +1146,9 @@ async function getPaseoWorktreeForCwd( cwd: string, options: PaseoWorktreeLookupOptions = {}, ): Promise { + if (options.context?.allowHostMetadata === false) { + return { isPaseoOwnedWorktree: false }; + } // Fast-path reject: non-worktree paths do not need expensive ownership checks. if (!/[\\/]worktrees[\\/]/.test(cwd)) { return { isPaseoOwnedWorktree: false }; @@ -1406,29 +1415,15 @@ async function resolveGitCommonDir(cwd: string): Promise { } async function abortGitPullConflictState(cwd: string): Promise { - const gitDir = await resolveAbsoluteGitDir(cwd); - if (!gitDir) { - return; + try { + await runGitCommand(["merge", "--abort"], { cwd, timeout: 120_000 }); + } catch { + // No merge was in progress. } - - const mergeHeadPath = resolve(gitDir, "MERGE_HEAD"); - const rebaseMergePath = resolve(gitDir, "rebase-merge"); - const rebaseApplyPath = resolve(gitDir, "rebase-apply"); - - if (existsSync(mergeHeadPath)) { - try { - await runGitCommand(["merge", "--abort"], { cwd, timeout: 120_000 }); - } catch { - // ignore - } - } - - if (existsSync(rebaseMergePath) || existsSync(rebaseApplyPath)) { - try { - await runGitCommand(["rebase", "--abort"], { cwd, timeout: 120_000 }); - } catch { - // ignore - } + try { + await runGitCommand(["rebase", "--abort"], { cwd, timeout: 120_000 }); + } catch { + // No rebase was in progress. } } @@ -1687,7 +1682,7 @@ async function inspectCheckoutContext( } const [currentBranch, remoteUrl, absoluteGitDir, gitCommonDir] = await Promise.all([ - getCurrentBranch(cwd), + getCurrentBranch(cwd, context), getOriginRemoteUrl(cwd), resolveAbsoluteGitDir(cwd), resolveGitCommonDir(cwd), @@ -2018,66 +2013,6 @@ function appendStructuredFile( structured.serializedBytes = nextBytes; return true; } -const UNTRACKED_BINARY_SNIFF_BYTES = 16 * 1024; - -async function isLikelyBinaryFile(absolutePath: string): Promise { - const handle = await openFile(absolutePath, "r"); - try { - const buffer = Buffer.allocUnsafe(UNTRACKED_BINARY_SNIFF_BYTES); - const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); - if (bytesRead === 0) { - return false; - } - - let suspicious = 0; - for (let i = 0; i < bytesRead; i += 1) { - const byte = buffer[i]; - if (byte === 0) { - return true; - } - // Treat control bytes as suspicious while allowing common whitespace. - if (byte < 7 || (byte > 14 && byte < 32) || byte === 127) { - suspicious += 1; - } - } - - return suspicious / bytesRead > 0.3; - } finally { - await handle.close(); - } -} - -async function inspectUntrackedFile( - cwd: string, - relativePath: string, -): Promise<{ stat: FileStat; truncated: boolean }> { - const absolutePath = resolve(cwd, relativePath); - const metadata = await statFile(absolutePath); - - if (!metadata.isFile()) { - return { stat: null, truncated: false }; - } - - if (await isLikelyBinaryFile(absolutePath)) { - return { - stat: { additions: 0, deletions: 0, isBinary: true }, - truncated: false, - }; - } - - if (metadata.size > PER_FILE_DIFF_MAX_BYTES) { - return { - stat: { additions: 0, deletions: 0, isBinary: false }, - truncated: true, - }; - } - - return { - stat: { additions: 0, deletions: 0, isBinary: false }, - truncated: false, - }; -} - function buildPlaceholderParsedDiffFile( change: CheckoutFileChange, options: { status: "too_large" | "binary"; stat?: FileStat }, @@ -2098,15 +2033,6 @@ async function getUntrackedDiffText( change: CheckoutFileChange, ignoreWhitespace = false, ): Promise<{ text: string; truncated: boolean; stat: FileStat }> { - try { - const inspected = await inspectUntrackedFile(cwd, change.path); - if (inspected.stat?.isBinary || inspected.truncated) { - return { text: "", truncated: inspected.truncated, stat: inspected.stat }; - } - } catch { - // Fall through to git diff path if metadata probing fails. - } - const result = await runGitCommand( buildGitDiffArgs({ ignoreWhitespace, @@ -2122,7 +2048,11 @@ async function getUntrackedDiffText( return { text: result.stdout, truncated: result.truncated, - stat: { additions: 0, deletions: 0, isBinary: false }, + stat: { + additions: 0, + deletions: 0, + isBinary: /^Binary files .* differ$/m.test(result.stdout), + }, }; } @@ -2494,10 +2424,12 @@ export async function getCommitFileDiff({ cwd, sha, path, + allowFileRead = true, }: { cwd: string; sha: string; path: string; + allowFileRead?: boolean; }): Promise { const { stdout } = await runGitCommand( ["show", sha, "--format=", "--diff-merges=first-parent", "--", path], @@ -2514,6 +2446,7 @@ export async function getCommitFileDiff({ const parsedFiles = await parseAndHighlightDiff(stdout, cwd, { getOldFileContent: (file) => readGitFileContentAtRef(cwd, `${sha}^`, file.path), getNewFileContent: (file) => readGitFileContentAtRef(cwd, sha, file.path), + allowFileRead, }); // `--` scopes the diff to a single pathspec, so there is at most one real @@ -2576,18 +2509,20 @@ async function countUntrackedAdditions(cwd: string, throwOnGitError = false): Pr let additions = 0; for (const file of files.slice(0, UNTRACKED_SHORTSTAT_MAX_FILES)) { - const absolutePath = resolve(cwd, file); try { - const metadata = await statFile(absolutePath); - if (metadata.size > PER_FILE_DIFF_MAX_BYTES) continue; - if (await isLikelyBinaryFile(absolutePath)) continue; - const content = await readFile(absolutePath, "utf-8"); - if (content.length === 0) continue; - const normalized = content.replace(/\r\n/g, "\n"); - const lineCount = normalized.split("\n").length; - additions += normalized.endsWith("\n") ? lineCount - 1 : lineCount; + const { stdout: numstat } = await runGitCommand( + ["diff", "--no-index", "--numstat", "/dev/null", "--", file], + { + cwd, + envOverlay: READ_ONLY_GIT_ENV, + maxOutputBytes: 8_192, + acceptExitCodes: [0, 1], + }, + ); + const added = numstat.split("\t", 1)[0]; + if (added && added !== "-") additions += Number.parseInt(added, 10) || 0; } catch { - // Skip unreadable files. + // Skip files Git cannot inspect. } } return additions; @@ -2699,7 +2634,7 @@ function getOrLoadCheckoutShortstat( context?: CheckoutContext, options?: CheckoutReadCacheOptions, ): Promise { - const cacheKey = getShortstatCacheKey(cwd); + const cacheKey = getShortstatCacheKey(cwd, context); if (!options?.force) { const cached = shortstatCache.get(cacheKey); if (cached !== undefined) { @@ -2869,7 +2804,7 @@ export function warmCheckoutShortstatInBackground( context?: CheckoutContext, onComplete?: () => void, ): void { - const cacheKey = getShortstatCacheKey(cwd); + const cacheKey = getShortstatCacheKey(cwd, context); if (shortstatCache.get(cacheKey) !== undefined || shortstatInFlight.has(cacheKey)) { return; } @@ -2893,6 +2828,7 @@ interface AppendStructuredTrackedDiffsInput { refsForDiff: CheckoutDiffRefs; ignoreWhitespace: boolean; structured: StructuredDiffAccumulator; + allowHostMetadata: boolean; appendTrackedPlaceholderComment: ( change: CheckoutFileChange, status: "binary" | "too_large", @@ -2904,8 +2840,9 @@ async function buildHighlightedTrackedDiffFile(input: { change: CheckoutFileChange; parsedFile: ParsedDiffFile; refsForDiff: CheckoutDiffRefs; + allowHostMetadata: boolean; }): Promise { - const { cwd, change, parsedFile, refsForDiff } = input; + const { cwd, change, parsedFile, refsForDiff, allowHostMetadata } = input; const refPath = change.oldPath ?? change.path; const [oldFileContent, newFileContent] = await Promise.all([ change.isNew ? null : readGitFileContentAtRef(cwd, refsForDiff.baseRef, refPath), @@ -2914,6 +2851,7 @@ async function buildHighlightedTrackedDiffFile(input: { const highlightedFile = await highlightDiffWithFileContent(parsedFile, cwd, { oldFileContent, newFileContent, + allowFileRead: allowHostMetadata, }); return { ...highlightedFile, @@ -2949,6 +2887,7 @@ async function appendStructuredTrackedDiffs( refsForDiff, ignoreWhitespace, structured, + allowHostMetadata, appendTrackedPlaceholderComment, } = input; @@ -2977,6 +2916,7 @@ async function appendStructuredTrackedDiffs( change, parsedFile, refsForDiff, + allowHostMetadata, }); if (!appendStructuredFile(structured, file)) { return false; @@ -3015,10 +2955,19 @@ interface ProcessUntrackedChangeInput { includeStructured: boolean; structured: StructuredDiffAccumulator; appendDiff: (text: string) => void; + allowHostMetadata: boolean; } async function processUntrackedChange(input: ProcessUntrackedChangeInput): Promise { - const { cwd, change, ignoreWhitespace, includeStructured, structured, appendDiff } = input; + const { + cwd, + change, + ignoreWhitespace, + includeStructured, + structured, + appendDiff, + allowHostMetadata, + } = input; const { text, truncated, stat } = await getUntrackedDiffText(cwd, change, ignoreWhitespace); if (!includeStructured) { @@ -3059,7 +3008,7 @@ async function processUntrackedChange(input: ProcessUntrackedChangeInput): Promi } appendDiff(text); - const parsed = await parseAndHighlightDiff(text, cwd); + const parsed = await parseAndHighlightDiff(text, cwd, { allowFileRead: allowHostMetadata }); const parsedFile = parsed[0] ?? ({ @@ -3266,6 +3215,7 @@ export async function getCheckoutDiff( refsForDiff: effectiveRefsForDiff, ignoreWhitespace, structured, + allowHostMetadata: context?.allowHostMetadata !== false, appendTrackedPlaceholderComment, }); if (!didAppendTrackedDiffs) { @@ -3291,6 +3241,7 @@ export async function getCheckoutDiff( includeStructured: compare.includeStructured === true, structured, appendDiff, + allowHostMetadata: context?.allowHostMetadata !== false, }); if (!didAppendUntrackedDiff) { return { diff: "", structured: [], diffTooLarge: true }; diff --git a/packages/server/src/utils/run-git-command.runtime-context.test.ts b/packages/server/src/utils/run-git-command.runtime-context.test.ts new file mode 100644 index 0000000000..ecbe07e261 --- /dev/null +++ b/packages/server/src/utils/run-git-command.runtime-context.test.ts @@ -0,0 +1,75 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, it } from "vitest"; +import { + configureGitProcessPolicy, + type GitCommandResult, + runGitCommand, + runWithGitCommandRunner, +} from "./run-git-command.js"; +import { resolveGitProcessPolicy } from "./git-process-scheduler.js"; + +const execFileAsync = promisify(execFile); +const directories: string[] = []; + +async function createRepository(branch: string): Promise { + const directory = await mkdtemp(join(tmpdir(), "paseo-git-runtime-context-")); + directories.push(directory); + await execFileAsync("git", ["init", "--initial-branch", branch], { cwd: directory }); + return directory; +} + +function createRuntimeRunner( + runtimeCwd: string, + waitBeforeReturning?: Promise, +): (args: string[]) => Promise { + return async (args) => { + const { stdout, stderr } = await execFileAsync("git", args, { cwd: runtimeCwd }); + await waitBeforeReturning; + return { + stdout, + stderr, + truncated: false, + exitCode: 0, + signal: null, + }; + }; +} + +afterEach(async () => { + configureGitProcessPolicy(resolveGitProcessPolicy({ env: process.env })); + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))); +}); + +describe("runGitCommand runtime submission context", () => { + it("keeps queued commands in the selected runtime that submitted them", async () => { + configureGitProcessPolicy({ maxProcessConcurrency: 1, maxProcessesPerSecond: 1_000 }); + const [runtimeA, runtimeB, hostDecoyA, hostDecoyB] = await Promise.all([ + createRepository("runtime-a"), + createRepository("runtime-b"), + createRepository("host-decoy-a"), + createRepository("host-decoy-b"), + ]); + let releaseRuntimeA!: () => void; + const runtimeAHold = new Promise((resolve) => { + releaseRuntimeA = resolve; + }); + + const first = runWithGitCommandRunner(createRuntimeRunner(runtimeA, runtimeAHold), () => + runGitCommand(["branch", "--show-current"], { cwd: hostDecoyA }), + ); + const second = runWithGitCommandRunner(createRuntimeRunner(runtimeB), () => + runGitCommand(["branch", "--show-current"], { cwd: hostDecoyB }), + ); + + releaseRuntimeA(); + + await expect(Promise.all([first, second])).resolves.toEqual([ + expect.objectContaining({ stdout: "runtime-a\n" }), + expect.objectContaining({ stdout: "runtime-b\n" }), + ]); + }); +}); diff --git a/packages/server/src/utils/run-git-command.ts b/packages/server/src/utils/run-git-command.ts index 71c9e95f60..c25f39f442 100644 --- a/packages/server/src/utils/run-git-command.ts +++ b/packages/server/src/utils/run-git-command.ts @@ -27,6 +27,16 @@ const DEFAULT_STDERR_LIMIT = 2048; let gitProcessScheduler = new GitProcessScheduler(resolveGitProcessPolicy({ env: process.env })); let gitRuntimeMetrics = createGitCommandRuntimeMetricsWindow(gitProcessScheduler.policy); const gitCommandPriority = new AsyncLocalStorage(); +const gitCommandRunner = new AsyncLocalStorage(); + +export type GitCommandRunner = ( + args: string[], + options: GitCommandOptions, +) => Promise; + +export function runWithGitCommandRunner(runner: GitCommandRunner, operation: () => T): T { + return gitCommandRunner.run(runner, operation); +} export function runWithGitCommandPriority(priority: GitProcessPriority, operation: () => T): T { return gitCommandPriority.run(priority, operation); @@ -253,6 +263,8 @@ export function runGitCommand( args: string[], options: GitCommandOptions, ): Promise { + const selectedRunner = gitCommandRunner.getStore(); + const priority = gitCommandPriority.getStore() ?? "normal"; const metricsState = submitGitCommandMetric(args, options.cwd); const commandTrace = submitGitCommandTrace(args, options.cwd, { active: gitProcessScheduler.activeCount, @@ -260,6 +272,57 @@ export function runGitCommand( }); const runtimeMetric = gitRuntimeMetrics.submit(getGitOperation(args)); const startCommand = () => { + if (selectedRunner) { + const startedAt = Date.now(); + beginGitCommandMetric(metricsState); + gitRuntimeMetrics.start(runtimeMetric); + startGitCommandTrace(commandTrace, { + active: gitProcessScheduler.activeCount, + pending: gitProcessScheduler.pendingCount, + }); + const result = selectedRunner(args, options).then( + (commandResult) => { + const success = + commandResult.truncated || + (options.acceptExitCodes ?? [0]).includes(commandResult.exitCode ?? -1); + finishMetricOnce(commandResult, success); + return commandResult; + }, + (error) => { + finishMetricOnce( + { stdout: "", stderr: "", truncated: false, exitCode: null, signal: null }, + false, + ); + throw error; + }, + ); + return { + result, + exited: result.then( + () => undefined, + () => undefined, + ), + }; + + function finishMetricOnce(commandResult: GitCommandResult, success: boolean): void { + finishGitCommandMetric(metricsState, { + args, + cwd: options.cwd, + startedAtMs: startedAt, + durationMs: Date.now() - startedAt, + exitCode: commandResult.exitCode, + signal: commandResult.signal, + success, + }); + gitRuntimeMetrics.finish(runtimeMetric, { success, timedOut: false }); + settleGitCommandTrace(commandTrace, { + outcome: "closed", + exitCode: commandResult.exitCode, + signal: commandResult.signal, + truncated: commandResult.truncated, + }); + } + } let releaseProcessSlot!: () => void; const exited = new Promise((resolve) => { releaseProcessSlot = resolve; @@ -516,9 +579,7 @@ export function runGitCommand( }); return { result: resultPromise, exited }; }; - const promise = gitProcessScheduler.run(startCommand, { - priority: gitCommandPriority.getStore() ?? "normal", - }); + const promise = gitProcessScheduler.run(startCommand, { priority }); gitRuntimeMetrics.observeLimiter( gitProcessScheduler.activeCount, gitProcessScheduler.pendingCount,