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