feat(workspaces): route Git operations through runtimes

Bind Git callers to workspace identity so selected repositories cannot collapse onto cwd-keyed state.

Run commands and observation through runtime capabilities while preserving explicit cwd-bound behavior for legacy records.
This commit is contained in:
Mohamed Boudra
2026-08-11 07:40:20 +02:00
parent b682f56bb9
commit 0520481966
89 changed files with 5343 additions and 2172 deletions
@@ -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<View>(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,
@@ -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<typeof useHostRuntimeClient>,
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<typeof resolveCompactExplorerSidebarHostModel>[0]["selection"];
workspace: Parameters<typeof resolveCompactExplorerSidebarHostModel>[0]["workspace"];
isGit: boolean;
showMobileAgent: () => void;
}): CompactExplorerSidebarHostModel | null {
const retainedModelRef = useRef<CompactExplorerSidebarHostModel | null>(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<CompactExplorerSidebarHostModel | null>(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}
</CompactExplorerOpenGestureSurface>
{enabled && model ? (
<CompactExplorerSidebar
serverId={model.serverId}
workspaceId={model.workspaceId}
workspaceRoot={model.workspaceRoot}
isGit={model.isGit}
onOpenFile={handleOpenFile}
/>
) : null}
<WorkspaceGitBoundary workspaceGit={workspaceGit}>
{enabled && model ? (
<CompactExplorerSidebar
serverId={model.serverId}
workspaceId={model.workspaceId}
workspaceRoot={model.workspaceRoot}
isGit={model.isGit}
onOpenFile={handleOpenFile}
/>
) : null}
</WorkspaceGitBoundary>
</>
);
}
@@ -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" && (
<ChangedFilesPane
serverId={serverId}
workspaceId={workspaceId}
workspaceId={workspaceGit.workspaceId}
workspaceRoot={workspaceRoot}
isOpen={isOpen}
onOpenFile={onOpenFile}
@@ -428,7 +432,7 @@ function ExplorerSidebarContent({
{resolvedTab === "files" && (
<FilesPane
serverId={serverId}
workspaceId={workspaceId}
workspaceId={workspaceGit.workspaceId}
workspaceRoot={workspaceRoot}
onOpenFile={onOpenFile}
/>
@@ -436,6 +440,7 @@ function ExplorerSidebarContent({
{resolvedTab === "pr" && (
<PrTabContent
serverId={serverId}
workspaceId={workspaceGit.workspaceId}
cwd={workspaceRoot}
prPane={prPane}
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
@@ -526,6 +531,7 @@ function FilesPane({
interface PrTabContentProps {
serverId: string;
workspaceId: string;
cwd: string;
prPane: UsePrPaneDataResult;
workspaceAttachmentScopeKey: string;
@@ -534,6 +540,7 @@ interface PrTabContentProps {
function PrTabContent({
serverId,
workspaceId,
cwd,
prPane,
workspaceAttachmentScopeKey,
@@ -543,6 +550,7 @@ function PrTabContent({
return (
<PullRequestPane
serverId={serverId}
workspaceId={workspaceId}
cwd={cwd}
data={prPane.data}
activityLoading={prPane.activityLoading}
@@ -31,6 +31,7 @@ import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import type { MessagePayload } from "@/composer/types";
import { PreWorkspaceComposerGitOwner } from "@/git/workspace-git";
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) {
@@ -429,26 +430,28 @@ export function WorkspaceSetupDialog() {
testID="workspace-setup-dialog"
desktopMaxWidth={640}
>
<FileDropZone style={styles.section}>
<Composer
agentId={`workspace-setup:${serverId}:${sourceDirectory}`}
serverId={serverId}
isPaneFocused={true}
onSubmitMessage={handleCreateChatAgent}
isSubmitLoading={pendingAction === "chat"}
blurOnSubmit={true}
value={chatDraft.text}
onChangeText={chatDraft.setText}
attachments={chatDraft.attachments}
onChangeAttachments={chatDraft.setAttachments}
cwd={sourceDirectory}
clearDraft={chatDraft.clear}
autoFocus
commandDraftConfig={composerState?.commandDraftConfig}
agentControls={agentControlsWithDisabled}
inputWrapperStyle={styles.composerInputWrapper}
/>
</FileDropZone>
<PreWorkspaceComposerGitOwner client={client} cwd={sourceDirectory}>
<FileDropZone style={styles.section}>
<Composer
agentId={`workspace-setup:${serverId}:${sourceDirectory}`}
serverId={serverId}
isPaneFocused={true}
onSubmitMessage={handleCreateChatAgent}
isSubmitLoading={pendingAction === "chat"}
blurOnSubmit={true}
value={chatDraft.text}
onChangeText={chatDraft.setText}
attachments={chatDraft.attachments}
onChangeAttachments={chatDraft.setAttachments}
cwd={sourceDirectory}
clearDraft={chatDraft.clear}
autoFocus
commandDraftConfig={composerState?.commandDraftConfig}
agentControls={agentControlsWithDisabled}
inputWrapperStyle={styles.composerInputWrapper}
/>
</FileDropZone>
</PreWorkspaceComposerGitOwner>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</AdaptiveModalSheet>
@@ -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<ForgeSearchClient, "searchForge" | "searchGitHub">;
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<UserComposerAttachment[]>(
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<ForgeSearchPayload>();
const secondLookup = deferred<ForgeSearchPayload>();
const client: ForgeSearchClient = {
const client: TestSearchClient = {
searchForge: vi
.fn()
.mockReturnValueOnce(firstLookup.promise)
@@ -288,7 +296,7 @@ describe("useComposerGithubAutoAttach", () => {
vi.useFakeTimers();
const firstLookup = deferred<ForgeSearchPayload>();
const secondLookup = deferred<ForgeSearchPayload>();
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<ForgeSearchPayload>();
const secondLookup = deferred<ForgeSearchPayload>();
const client: ForgeSearchClient = {
const client: TestSearchClient = {
searchForge: vi
.fn()
.mockReturnValueOnce(firstLookup.promise)
@@ -419,7 +427,7 @@ describe("useComposerGithubAutoAttach", () => {
vi.useFakeTimers();
const firstLookup = deferred<ForgeSearchPayload>();
const secondLookup = deferred<ForgeSearchPayload>();
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<ForgeSearchPayload>();
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<ForgeSearchPayload>();
const secondLookup = deferred<ForgeSearchPayload>();
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<ForgeSearchPayload>();
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<ForgeSearchPayload>();
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<ForgeSearchPayload>();
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<ForgeSearchPayload>();
const firstClient: ForgeSearchClient = {
const firstClient: TestSearchClient = {
searchForge: vi.fn().mockReturnValue(lookup.promise),
};
const replacementClient = createSearchClient([pr101]);
+32 -13
View File
@@ -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<SetStateAction<UserComposerAttachment[]>>;
onPullRequestDetected?: () => void;
@@ -50,7 +54,7 @@ export function useComposerGithubAutoAttach(
const removedRefKeysRef = useRef(new Set<string>());
const presentPullRequestKeysRef = useRef(new Set<string>());
const activeLookupsRef = useRef(new Set<ActiveGithubLookup>());
const previousTargetRef = useRef({ serverId: params.serverId, cwd: params.cwd });
const previousTargetRef = useRef({ serverId: params.serverId, client: params.client });
const [resolvingRefCounts, setResolvingRefCounts] = useState<ReadonlyMap<string, number>>(
() => 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<string>;
}): 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<string>;
activeLookups: ReadonlySet<ActiveGithubLookup>;
}): 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
);
}
+5 -6
View File
@@ -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),
+49 -27
View File
@@ -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<RouterMessageType, RouterHandler[]> = {
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<WorkspaceGitClient["subscribeDiff"]>[0],
requestOptions: Parameters<WorkspaceGitClient["subscribeDiff"]>[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);
+37 -26
View File
@@ -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<SessionOutboundMessage, { type: TType }>) => void,
): () => void;
subscribeCheckoutDiff(
cwd: string,
compare: CheckoutDiffCompare,
options: { subscriptionId: string; requestId?: string },
): Promise<CheckoutDiffResponsePayload>;
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<string, unknown>): 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<string, unknown>): 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;
}
+44 -227
View File
@@ -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<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
const serverId = "server-1";
function capability(methods: Record<string, unknown>): 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<unknown>();
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();
});
});
+69 -79
View File
@@ -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<Record<CheckoutGitAsyncActionId, CheckoutGitActionStatus>>;
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<string, ReturnType<typeof setTimeout>>();
@@ -96,43 +89,51 @@ interface CheckoutGitActionsStoreState {
getStatus: (params: {
serverId: string;
cwd: string;
target: WorkspaceGitTarget;
actionId: CheckoutGitAsyncActionId;
}) => CheckoutGitActionStatus;
commit: (params: { serverId: string; cwd: string }) => Promise<void>;
pull: (params: { serverId: string; cwd: string }) => Promise<void>;
push: (params: { serverId: string; cwd: string }) => Promise<void>;
pullAndPush: (params: { serverId: string; cwd: string }) => Promise<void>;
refresh: (params: { serverId: string; cwd: string }) => Promise<void>;
createPr: (params: { serverId: string; cwd: string }) => Promise<void>;
commit: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise<void>;
pull: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise<void>;
push: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise<void>;
pullAndPush: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise<void>;
refresh: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise<void>;
createPr: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise<void>;
mergePr: (params: {
serverId: string;
cwd: string;
target: WorkspaceGitTarget;
method: CheckoutPrMergeMethod;
}) => Promise<void>;
enablePrAutoMerge: (params: {
serverId: string;
cwd: string;
target: WorkspaceGitTarget;
method: CheckoutPrMergeMethod;
}) => Promise<void>;
disablePrAutoMerge: (params: { serverId: string; cwd: string }) => Promise<void>;
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
disablePrAutoMerge: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise<void>;
mergeBranch: (params: {
serverId: string;
target: WorkspaceGitTarget;
baseRef: string;
}) => Promise<void>;
mergeFromBase: (params: {
serverId: string;
target: WorkspaceGitTarget;
baseRef: string;
}) => Promise<void>;
}
async function runCheckoutAction({
serverId,
cwd,
target,
actionId,
run,
}: {
serverId: string;
cwd: string;
target: WorkspaceGitTarget;
actionId: CheckoutGitAsyncActionId;
run: () => Promise<void>;
}): Promise<void> {
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<CheckoutGitActionsStoreState>()((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<CheckoutGitActionsStoreState>()
});
},
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<CheckoutGitActionsStoreState>()
});
},
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<CheckoutGitActionsStoreState>()
});
},
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<CheckoutGitActionsStoreState>()
});
},
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<CheckoutGitActionsStoreState>()
});
},
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<CheckoutGitActionsStoreState>()
});
},
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<CheckoutGitActionsStoreState>()
});
},
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<CheckoutGitActionsStoreState>()
});
},
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<CheckoutGitActionsStoreState>()
});
},
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<CheckoutGitActionsStoreState>()
});
},
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,
});
@@ -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"]);
});
});
@@ -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),
};
}
@@ -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> = {}): 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> = {}): 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["payload"]["prStatus"]>,
): 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<CheckoutStatusPayload>(checkoutStatusQueryKey(serverId, targetA))
?.currentBranch,
).toBe("a-next");
expect(
client.getQueryData<CheckoutStatusPayload>(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<CheckoutPrStatusPayload>(checkoutPrStatusQueryKey(serverId, cwd))
?.authState,
).toBe("unauthenticated");
expect(
queryClient.getQueryData<CheckoutStatusUpdate["payload"]>(
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);
});
});
+50 -12
View File
@@ -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<CheckoutStatusPayload>;
}
@@ -24,14 +30,18 @@ export interface CheckoutStatusClient {
export async function fetchCheckoutStatus({
client,
serverId,
cwd,
target,
}: {
client: CheckoutStatusClient;
serverId: string;
cwd: string;
target: WorkspaceGitStatusTarget;
}): Promise<CheckoutStatusPayload> {
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<CheckoutStatusPayload> {
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<CheckoutStatusPayload> {
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<CheckoutPrStatusPayload>(
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 });
}
}
@@ -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;
@@ -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,
});
+14 -10
View File
@@ -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({
<BranchSwitcher
currentBranchName={currentBranchName}
serverId={serverId}
workspaceId={workspaceId ?? cwd}
workspaceDirectory={cwd}
isGitCheckout={isGit}
testID="changes-branch-switcher"
/>
@@ -2942,7 +2946,7 @@ export function GitDiffPane({
<View style={styles.diffContainer}>{bodyContent}</View>
<CommitsSection serverId={serverId} cwd={cwd} onCommitPress={handleCommitPress} />
<CommitsSection serverId={serverId} onCommitPress={handleCommitPress} />
</View>
);
}
@@ -54,7 +54,6 @@ function renderGitlabChecksSection(facts: GitlabMergeFacts, ctx: PaneChecksSlotC
return (
<GitLabPipelineSection
serverId={ctx.serverId}
cwd={ctx.cwd}
changeRequestNumber={ctx.changeRequestNumber}
summary={summary}
open={ctx.open}
@@ -118,7 +117,6 @@ function countPipelineJobs(jobs: CheckoutPipelineJob[]): PipelineJobCounts {
function GitLabPipelineSection({
serverId,
cwd,
changeRequestNumber,
summary,
open,
@@ -126,7 +124,6 @@ function GitLabPipelineSection({
canFetchCheckDetails,
}: {
serverId: string;
cwd: string;
changeRequestNumber: number;
summary: GitlabPipelineSummary;
open: boolean;
@@ -136,7 +133,6 @@ function GitLabPipelineSection({
const { t } = useTranslation();
const { pipeline, isLoading, isPlaceholderData, error } = useGitLabPipeline({
serverId,
cwd,
pipelineId: summary.id,
changeRequestNumber,
enabled: open && canFetchCheckDetails,
@@ -0,0 +1,44 @@
import { expect, test, vi } from "vitest";
import { fetchBoundPullRequestCheckDetails } from "./check-details";
test("selected PR check details keep their bound identity and return runtime logs", async () => {
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,
});
});
@@ -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;
}
@@ -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<string>, 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,
@@ -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;
}
@@ -16,7 +16,8 @@ import {
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"];
type PullRequestTimelineInput = Parameters<PrPaneTimelineClient["pullRequestTimeline"]>[0];
type PullRequestTimelineInput = Parameters<PrPaneTimelineClient["getPullRequestTimeline"]>[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);
});
});
@@ -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<DaemonClient, "pullRequestTimeline">;
export type PrPaneTimelineClient = Pick<WorkspaceGitClient, "getPullRequestTimeline">;
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<PullRequestTimeline> {
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<PullRequestTimeline>({
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,
@@ -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<CheckoutPipeline | null>({
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,
});
+134 -131
View File
@@ -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();
});
});
+47 -17
View File
@@ -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"
);
}
+46 -36
View File
@@ -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<CheckoutPrMergeMethod, CheckoutGitActionStatus> = {
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<CheckoutPrMergeMethod, CheckoutGitActionStatus> = {
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,
+7 -8
View File
@@ -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<CheckoutCommitsData>({
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 };
+9 -10
View File
@@ -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,
+6 -6
View File
@@ -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<CheckoutDiffQueryPayload>({
queryKey,
@@ -80,7 +80,7 @@ export function useCheckoutDiffQuery({
enabled: routeEnabled,
serverId,
subscriptionId,
cwd,
workspaceGit,
compare: {
mode: compareMode,
...(compareBaseRef ? { baseRef: compareBaseRef } : {}),
@@ -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);
}
}
+67 -21
View File
@@ -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<ForgeSearchResponse["payload"]>;
searchGitHub?: (
options: LegacyGitHubSearchOptions,
requestId?: string,
) => Promise<GitHubSearchResponse["payload"]>;
}
type ForgeSearchTarget = Pick<WorkspaceGitReadClient, "cwd" | "queryIdentity">;
export type ForgeSearchClient = ForgeSearchTarget &
Pick<WorkspaceGitClient, "searchForge"> &
Partial<Pick<WorkspaceGitClient, "searchGitHub">>;
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<ForgeSearchQueryInput, "client"> {
client: Pick<DaemonClient, "searchForge" | "searchGitHub"> | 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<ForgeSearchPayload> => {
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<ForgeSearchPayload> => {
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(
+16 -14
View File
@@ -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<CheckoutPrStatusPayload, Error, PrHint | null>({
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.
+47 -19
View File
@@ -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,
};
}
+2 -3
View File
@@ -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,
@@ -4,6 +4,7 @@ import { GIT_ACTION_ICONS } from "@/git/action-icons";
interface WorkspaceActionsProps {
serverId: string;
workspaceId: string;
cwd: string;
hideLabels?: boolean;
}
@@ -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 (
<button type="button" onClick={fetchStatus}>
{workspaceGit?.cwd ?? "Git unavailable"}
</button>
);
}
test.each(["terminal", "chat"])(
"the new-workspace %s Composer production owner supplies its legacy cwd",
async () => {
const port = createLegacyGitPort();
render(
<PreWorkspaceComposerGitOwner client={port.client as never} cwd=" /draft/source ">
<SharedComposerGitConsumer />
</PreWorkspaceComposerGitOwner>,
);
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(
<PreWorkspaceComposerGitOwner client={port.client as never} cwd={null}>
<SharedComposerGitConsumer />
</PreWorkspaceComposerGitOwner>,
);
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(
<PreWorkspaceComposerGitOwner client={port.client as never} cwd="/setup/source">
<SharedComposerGitConsumer />
</PreWorkspaceComposerGitOwner>,
);
fireEvent.click(screen.getByRole("button", { name: "/setup/source" }));
await waitFor(() => expect(port.statusCwds).toEqual(["/setup/source"]));
});
@@ -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);
});
+168
View File
@@ -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<DaemonClient, "bindWorkspaceGit">;
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<typeof bindSelectedWorkspaceGit>;
export type WorkspaceGitReadClient = Readonly<
Pick<WorkspaceGitClient, "cwd" | "getPrStatus" | "getStatus" | "searchForge" | "searchGitHub"> & {
queryIdentity: WorkspaceGitQueryIdentity;
}
>;
export type WorkspaceGitTarget = Readonly<
Pick<WorkspaceGitClient, "cwd" | "queryIdentity" | "workspaceId">
>;
export type WorkspaceGitStatusTarget = Readonly<
Pick<WorkspaceGitReadClient, "cwd" | "queryIdentity">
>;
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<typeof bindLegacyWorkspaceGit>;
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;
}
+8 -20
View File
@@ -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) => {
+9 -4
View File
@@ -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<string[] | null>(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),
});
@@ -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(() => <SidebarMenuToggle />, []);
return (
<FileDropZone style={styles.container}>
<ScreenHeader left={screenHeaderLeft} borderless />
<View style={contentStyle}>
<TitlebarDragRegion />
<ReanimatedAnimated.View style={centeredStyle}>
<View style={styles.composerTitleContainer}>
<Text style={styles.composerTitle}>{t("newWorkspace.title")}</Text>
</View>
{formStack}
{isTerminalLaunch ? (
<Composer
externalKeyboardShift
inputMode="terminal"
readOnly={!terminalTakesPrompt}
placeholder={terminalPlaceholder}
submitLabel={terminalSubmitLabel}
agentId={draftKey}
serverId={selectedServerId}
isPaneFocused={true}
onSubmitMessage={handleSubmitTerminalLaunch}
allowEmptySubmit={true}
submitButtonAccessibilityLabel={t("newWorkspace.launch.submit")}
submitButtonTestID="new-workspace-launch-submit"
isSubmitLoading={isPending}
submitBehavior="preserve-and-lock"
blurOnSubmit={true}
value={terminalComposerValue}
onChangeText={setTerminalPromptText}
attachments={NO_TERMINAL_ATTACHMENTS}
onChangeAttachments={noopChangeAttachments}
cwd={selectedSourceDirectory ?? ""}
clearDraft={noopClearDraft}
autoFocus={terminalTakesPrompt}
autoFocusKey={launchFocusKey}
/>
) : (
<Composer
externalKeyboardShift
agentId={draftKey}
serverId={selectedServerId}
isPaneFocused={true}
onSubmitMessage={handleSubmitNewWorkspace}
allowEmptySubmit={true}
submitButtonAccessibilityLabel={t("newWorkspace.create")}
submitButtonTestID="workspace-create-submit"
submitIcon="return"
isSubmitLoading={isPending}
waitForGithubAutoAttachOnSubmit
submitBehavior="preserve-and-lock"
blurOnSubmit={true}
value={chatDraft.text}
onChangeText={chatDraft.setText}
attachments={chatDraft.attachments}
attachmentScopeKeys={visibleDraftContextScopeKeys}
onChangeAttachments={chatDraft.setAttachments}
onGithubPrDetected={handleGithubPrDetected}
onGithubPrAutoAttach={handleGithubPrAutoAttach}
cwd={selectedSourceDirectory ?? ""}
clearDraft={handleClearDraft}
autoFocus
autoFocusKey={launchFocusKey}
commandDraftConfig={composerState?.commandDraftConfig}
agentControls={agentControlsWithDisabled}
/>
)}
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</ReanimatedAnimated.View>
</View>
</FileDropZone>
<PreWorkspaceComposerGitOwner client={client} cwd={selectedSourceDirectory}>
<FileDropZone style={styles.container}>
<ScreenHeader left={screenHeaderLeft} borderless />
<View style={contentStyle}>
<TitlebarDragRegion />
<ReanimatedAnimated.View style={centeredStyle}>
<View style={styles.composerTitleContainer}>
<Text style={styles.composerTitle}>{t("newWorkspace.title")}</Text>
</View>
{formStack}
{isTerminalLaunch ? (
<Composer
externalKeyboardShift
inputMode="terminal"
readOnly={!terminalTakesPrompt}
placeholder={terminalPlaceholder}
submitLabel={terminalSubmitLabel}
agentId={draftKey}
serverId={selectedServerId}
isPaneFocused={true}
onSubmitMessage={handleSubmitTerminalLaunch}
allowEmptySubmit={true}
submitButtonAccessibilityLabel={t("newWorkspace.launch.submit")}
submitButtonTestID="new-workspace-launch-submit"
isSubmitLoading={isPending}
submitBehavior="preserve-and-lock"
blurOnSubmit={true}
value={terminalComposerValue}
onChangeText={setTerminalPromptText}
attachments={NO_TERMINAL_ATTACHMENTS}
onChangeAttachments={noopChangeAttachments}
cwd={selectedSourceDirectory ?? ""}
clearDraft={noopClearDraft}
autoFocus={terminalTakesPrompt}
autoFocusKey={launchFocusKey}
/>
) : (
<Composer
externalKeyboardShift
agentId={draftKey}
serverId={selectedServerId}
isPaneFocused={true}
onSubmitMessage={handleSubmitNewWorkspace}
allowEmptySubmit={true}
submitButtonAccessibilityLabel={t("newWorkspace.create")}
submitButtonTestID="workspace-create-submit"
submitIcon="return"
isSubmitLoading={isPending}
waitForGithubAutoAttachOnSubmit
submitBehavior="preserve-and-lock"
blurOnSubmit={true}
value={chatDraft.text}
onChangeText={chatDraft.setText}
attachments={chatDraft.attachments}
attachmentScopeKeys={visibleDraftContextScopeKeys}
onChangeAttachments={chatDraft.setAttachments}
onGithubPrDetected={handleGithubPrDetected}
onGithubPrAutoAttach={handleGithubPrAutoAttach}
cwd={selectedSourceDirectory ?? ""}
clearDraft={handleClearDraft}
autoFocus
autoFocusKey={launchFocusKey}
commandDraftConfig={composerState?.commandDraftConfig}
agentControls={agentControlsWithDisabled}
/>
)}
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</ReanimatedAnimated.View>
</View>
</FileDropZone>
</PreWorkspaceComposerGitOwner>
);
}
@@ -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<typeof useHostRuntimeClient>;
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,
@@ -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<OpenTarget[]>(
@@ -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 ? (
<WorkspaceOpenInEditorButton
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
cwd={workspaceDirectory}
activeFile={activeFileLocation}
hideLabels
@@ -3486,6 +3500,7 @@ function WorkspaceScreenContent({
<>
<WorkspaceActions
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
cwd={workspaceDirectory}
hideLabels={showCompactButtonLabels}
/>
@@ -3825,46 +3840,48 @@ function WorkspaceScreenContent({
);
return (
gatedWorkspaceScreen ?? (
<WorkspaceFocusProvider workspaceKey={persistenceKey}>
<RenderProfile id="WorkspaceScreenContent">
<View style={containerStyle}>
<WorkspaceDocumentTitleEffectSlot
tab={activeTabDescriptor}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
isRouteFocused={isRouteFocused}
/>
<WorkspaceChromeRow
portalHostName={workspaceFloatingPanelPortalHostName}
showExplorerSidebar={showExplorerSidebar}
explorerOpen={isExplorerOpen}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
workspaceRoot={workspaceDirectory}
isGit={isGitCheckout}
onOpenFile={handleOpenFileFromExplorer}
>
{workspaceCenterColumn}
</WorkspaceChromeRow>
<ImportSessionSheet
visible={isRouteFocused && isImportSheetVisible}
client={client}
serverId={normalizedServerId}
cwd={workspaceDirectory}
workspaceId={normalizedWorkspaceId}
onClose={closeImportSheet}
onImportedAgent={handleImportedAgent}
/>
<WorkspaceTabRenameModal
renamingTab={isRouteFocused ? renamingTab : null}
onSubmit={handleRenameModalSubmit}
onClose={handleRenameModalClose}
/>
</View>
</RenderProfile>
</WorkspaceFocusProvider>
)
<WorkspaceGitBoundary workspaceGit={workspaceGit}>
{gatedWorkspaceScreen ?? (
<WorkspaceFocusProvider workspaceKey={persistenceKey}>
<RenderProfile id="WorkspaceScreenContent">
<View style={containerStyle}>
<WorkspaceDocumentTitleEffectSlot
tab={activeTabDescriptor}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
isRouteFocused={isRouteFocused}
/>
<WorkspaceChromeRow
portalHostName={workspaceFloatingPanelPortalHostName}
showExplorerSidebar={showExplorerSidebar}
explorerOpen={isExplorerOpen}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
workspaceRoot={workspaceDirectory}
isGit={isGitCheckout}
onOpenFile={handleOpenFileFromExplorer}
>
{workspaceCenterColumn}
</WorkspaceChromeRow>
<ImportSessionSheet
visible={isRouteFocused && isImportSheetVisible}
client={client}
serverId={normalizedServerId}
cwd={workspaceDirectory}
workspaceId={normalizedWorkspaceId}
onClose={closeImportSheet}
onImportedAgent={handleImportedAgent}
/>
<WorkspaceTabRenameModal
renamingTab={isRouteFocused ? renamingTab : null}
onSubmit={handleRenameModalSubmit}
onClose={handleRenameModalClose}
/>
</View>
</RenderProfile>
</WorkspaceFocusProvider>
)}
</WorkspaceGitBoundary>
);
}
+172
View File
@@ -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();
+276 -6
View File
@@ -1135,6 +1135,7 @@ export class DaemonClient {
{
cwd: string;
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
workspaceId?: string;
}
>();
private terminalDirectorySubscriptions = new Map<string, { cwd: string; workspaceId?: string }>();
@@ -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<CheckoutStatusPayload> {
const requestId = options?.requestId;
return this.requestCheckoutStatus(cwd, options);
}
private async requestCheckoutStatus(
cwd: string,
options?: { requestId?: string; workspaceId?: string },
): Promise<CheckoutStatusPayload> {
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<CheckoutDiffPayload> {
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<SubscribeCheckoutDiffPayload> {
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,
});
+59 -26
View File
@@ -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(),
@@ -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" });
});
+7 -1
View File
@@ -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,
@@ -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<T> {
promise: Promise<T>;
@@ -64,6 +65,7 @@ function createPendingManager() {
unsubscribeCalls: number;
resolve(): void;
}> = [];
let boundLegacy: ReturnType<typeof bindWorkspaceGitService> | 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<typeof bindWorkspaceGitService> | 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,
);
@@ -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<string, CheckoutDiffWatchTarget>();
private readonly workspaceTokens = new WeakMap<WorkspaceGitWorkspace, number>();
private nextWorkspaceToken = 1;
constructor(options: {
logger: pino.Logger;
@@ -77,9 +90,22 @@ export class CheckoutDiffManager {
params: CheckoutDiffSubscriptionRequest,
listener: (snapshot: CheckoutDiffSnapshotPayload) => void,
): Promise<CheckoutDiffSubscription> {
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<CheckoutDiffSubscription> {
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<CheckoutDiffSnapshotPayload> {
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<void> {
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;
@@ -193,17 +193,23 @@ async function expectFileEditAndWatch(workspace: CharacterizedWorkspace): Promis
}
async function expectGitObservation(workspace: CharacterizedWorkspace): Promise<void> {
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<void> {
+87 -4
View File
@@ -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<void>;
@@ -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<ForgeService & GitHubService>;
checkoutDiffManager?: { scheduleRefreshForCwd: ReturnType<typeof vi.fn> };
checkoutDiffManager?: {
scheduleRefreshForCwd: ReturnType<typeof vi.fn>;
scheduleRefreshForWorkspace?: ReturnType<typeof vi.fn>;
};
workspaceGitService?: {
getCheckout?: ReturnType<typeof vi.fn>;
getCheckoutDiff?: ReturnType<typeof vi.fn>;
@@ -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<string, ReturnType<typeof bindWorkspaceGitService>>();
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: {
+20 -20
View File
@@ -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<WorkspaceDescriptorPayload> {
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;
}
@@ -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<Map<string, unknown>>;
workspaceGitObserver: WorkspaceGitObserverService;
workspaceGitDirectory: WorkspaceGitDirectory;
listAgentPayloads: () => Promise<unknown[]>;
}
@@ -38,7 +43,9 @@ interface SessionInternals {
// integration tests drive it with a minimal git descriptor for one workspace, then push
// snapshots through the captured WorkspaceGitService listener.
function syncGitObserver(session: Session, cwd: string, workspaceId: string): void {
asInternals<SessionInternals>(session).workspaceGitObserver.syncObservers([
const internals = asInternals<SessionInternals>(session);
internals.workspaceGitDirectory.bindRecord({ workspaceId, cwd, runtime: undefined });
internals.workspaceGitObserver.syncObservers([
{
id: workspaceId,
workspaceDirectory: cwd,
@@ -66,7 +73,7 @@ function getWorkspaceUpdates(
}
const REPO_CWD = path.resolve("/tmp/repo");
const REPO_SUBSCRIPTION_REQUEST_ID = `subscription:${REPO_CWD}`;
const REPO_SUBSCRIPTION_REQUEST_ID = "subscription:ws-10";
function createWorkspaceRuntimeSnapshot(
cwd: string,
@@ -188,6 +195,9 @@ function createSessionForWorkspaceGitWatchTests(options?: {
scheduleRefreshForCwd: vi.fn(),
dispose: vi.fn(),
};
workspaceGitService.bindWorkspace = ({ cwd }) =>
bindWorkspaceGitService(workspaceGitService, cwd);
workspaceGitService.bindLegacy = (cwd) => bindWorkspaceGitService(workspaceGitService, cwd);
const session = new Session({
clientId: "test-client",
@@ -153,6 +153,9 @@ interface SessionTestAccess {
): Promise<unknown>;
upsert(record: unknown): Promise<unknown>;
};
workspaceGitDirectory: {
bindRecord(record: PersistedWorkspaceRecord): unknown;
};
agentUpdates: AgentUpdatesService;
workspaceUpdatesSubscription: unknown;
interruptAgentIfRunning(agentId: string): unknown;
@@ -640,7 +643,9 @@ function createSessionForWorkspaceTests(
archive: async () => {},
remove: async () => {},
};
const workspaceGitService = options.workspaceGitService ?? createNoopWorkspaceGitService();
const workspaceGitService = options.renameCurrentBranch
? createNoopWorkspaceGitService({ renameBranch: options.renameCurrentBranch })
: (options.workspaceGitService ?? createNoopWorkspaceGitService());
const providerSnapshotManager = createProviderSnapshotManagerStub().manager;
const session = asTestSession(
@@ -728,7 +733,12 @@ function createSessionForWorkspaceTests(
initial: { cwd: "/tmp", files: [], error: null },
unsubscribe: () => {},
}),
subscribeWorkspace: async ({ workspaceGit }) => ({
initial: { cwd: workspaceGit.cwd, files: [], error: null },
unsubscribe: () => {},
}),
scheduleRefreshForCwd: () => {},
scheduleRefreshForWorkspace: () => {},
onWorkspaceStateMayHaveChanged: () => {},
invalidateForge: () => {},
getMetrics: () => ({
@@ -753,7 +763,6 @@ function createSessionForWorkspaceTests(
logger: asSessionLogger(logger),
generateWorkspaceName: options.generateWorkspaceName,
}),
renameCurrentBranch: options.renameCurrentBranch,
daemonConfigStore: asDaemonConfigStore({
get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }),
onChange: () => () => {},
@@ -7190,6 +7199,8 @@ test("subscribed fetch_workspaces includes git enrichment in the initial snapsho
const describeWorkspaceRecordWithGitDataSubscribed = vi.fn(async () => enrichedGitDescriptor);
session.describeWorkspaceRecord = describeWorkspaceRecordSubscribed;
session.describeWorkspaceRecordWithGitData = describeWorkspaceRecordWithGitDataSubscribed;
session.workspaceGitDirectory.bindRecord(gitWorkspace);
session.workspaceGitDirectory.bindRecord(directoryWorkspace);
await session.handleMessage({
type: "fetch_workspaces_request",
@@ -7758,6 +7769,17 @@ test("queued workspace updates are dropped when a new workspace subscription rep
}
return new Map([[currentDescriptor.id, currentDescriptor]]);
};
session.workspaceGitDirectory.bindRecord(
createPersistedWorkspaceRecord({
workspaceId: descriptor.id,
projectId: descriptor.projectId,
cwd: descriptor.workspaceDirectory,
kind: descriptor.workspaceKind,
displayName: descriptor.name,
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
}),
);
await session.handleMessage({
type: "fetch_workspaces_request",
@@ -19,6 +19,7 @@ import type {
WorkspaceGitService,
} from "../../workspace-git-service.js";
import {
bindWorkspaceGitService,
createNoGitWorkspaceRuntimeSnapshot,
createNoopWorkspaceGitService,
} from "../../test-utils/workspace-git-service-stub.js";
@@ -44,10 +45,10 @@ function createFakeDiffSubscriber(initial: CheckoutDiffSnapshotPayload) {
const subscriptions: FakeDiffSubscription[] = [];
const refreshedCwds: string[] = [];
const subscriber: CheckoutDiffSubscriber = {
subscribe: async (params, listener) => {
subscribeWorkspace: async (params, listener) => {
let isSubscribed = true;
const subscription: FakeDiffSubscription = {
cwd: params.cwd,
cwd: params.workspaceGit.cwd,
compare: params.compare,
unsubscribeCalls: 0,
emit: (snapshot) => {
@@ -66,12 +67,12 @@ function createFakeDiffSubscriber(initial: CheckoutDiffSnapshotPayload) {
params.signal?.addEventListener("abort", unsubscribe, { once: true });
subscriptions.push(subscription);
return {
initial: { ...initial, cwd: params.cwd },
initial: { ...initial, cwd: params.workspaceGit.cwd },
unsubscribe,
};
},
scheduleRefreshForCwd: (cwd) => {
refreshedCwds.push(cwd);
scheduleRefreshForWorkspace: (workspaceGit) => {
refreshedCwds.push(workspaceGit.cwd);
},
};
return { subscriber, subscriptions, refreshedCwds };
@@ -79,7 +80,12 @@ function createFakeDiffSubscriber(initial: CheckoutDiffSnapshotPayload) {
interface RecordedHostCalls {
emitWorkspaceUpdateForCwd: string[];
handleWorkspaceGitBranchSnapshot: Array<{ cwd: string; branchName: string | null }>;
handleWorkspaceGitBranchSnapshot: Array<{
address:
| { kind: "selected"; cwd: string; workspaceId: string }
| { kind: "legacy"; cwd: string };
branchName: string | null;
}>;
renameCurrentBranch: Array<{ cwd: string; branch: string }>;
}
@@ -126,12 +132,8 @@ function makeCheckoutSession(options?: {
emitWorkspaceUpdateForCwd: async (cwd) => {
hostCalls.emitWorkspaceUpdateForCwd.push(cwd);
},
handleWorkspaceGitBranchSnapshot: (cwd, branchName) => {
hostCalls.handleWorkspaceGitBranchSnapshot.push({ cwd, branchName });
},
renameCurrentBranch: async (cwd, branch) => {
hostCalls.renameCurrentBranch.push({ cwd, branch });
return { previousBranch: null, currentBranch: branch };
handleWorkspaceGitBranchSnapshot: (address, branchName) => {
hostCalls.handleWorkspaceGitBranchSnapshot.push({ address, branchName });
},
...options?.host,
};
@@ -157,16 +159,33 @@ function makeCheckoutSession(options?: {
...options?.gitMetadataGenerator,
};
const github: ForgeService = { ...createGitHubService(), ...options?.github };
const workspaceGitService = createNoopWorkspaceGitService({
renameBranch: async (cwd, branch) => {
hostCalls.renameCurrentBranch.push({ cwd, branch });
return { previousBranch: null, currentBranch: branch };
},
...options?.git,
});
const checkout = new CheckoutSession({
host,
gitMutation,
workspaceGitService: createNoopWorkspaceGitService(options?.git),
gitMutation: {
bind: (workspaceGit) => ({
checkoutExistingBranch: (branch) =>
gitMutation.checkoutExistingBranch(workspaceGit.cwd, branch),
createBranchFromBase: async () => {},
notify: (reason, notifyOptions) =>
gitMutation.notifyGitMutation(workspaceGit.cwd, reason, notifyOptions),
}),
},
workspaceGitDirectory: {
resolve: async ({ cwd }) => bindWorkspaceGitService(workspaceGitService, cwd),
bindRecord: (record) => bindWorkspaceGitService(workspaceGitService, record.cwd),
getBound: (_workspaceId, cwd) => bindWorkspaceGitService(workspaceGitService, cwd),
},
github,
checkoutDiffManager:
options?.diff ?? createFakeDiffSubscriber({ cwd: "", files: [], error: null }).subscriber,
gitMetadataGenerator,
paseoHome: "/tmp/paseo-home",
worktreesRoot: undefined,
logger: pino({ level: "silent" }),
});
return { checkout, emitted, hostCalls, gitMutationCalls, generatorCalls };
@@ -375,8 +394,8 @@ describe("CheckoutSession", () => {
});
describe("refresh", () => {
it("forces a github-inclusive snapshot, nudges diffs, and confirms success", async () => {
const snapshotCalls: Array<{ cwd: string; options: unknown }> = [];
it("refreshes the bound workspace, nudges diffs, and confirms success", async () => {
const refreshCalls: Array<{ cwd: string; options: unknown }> = [];
const { subscriber, refreshedCwds } = createFakeDiffSubscriber({
cwd: "",
files: [],
@@ -384,9 +403,8 @@ describe("CheckoutSession", () => {
});
const { checkout, emitted } = makeCheckoutSession({
git: {
getSnapshot: async (cwd, snapshotOptions) => {
snapshotCalls.push({ cwd, options: snapshotOptions });
return createNoGitWorkspaceRuntimeSnapshot(cwd);
refresh: async (cwd, refreshOptions) => {
refreshCalls.push({ cwd, options: refreshOptions });
},
},
diff: subscriber,
@@ -398,9 +416,7 @@ describe("CheckoutSession", () => {
requestId: "r7",
});
expect(snapshotCalls).toEqual([
{ cwd: "/repo", options: { force: true, includeForge: true, reason: "manual-refresh" } },
]);
expect(refreshCalls).toEqual([{ cwd: "/repo", options: { priority: "high" } }]);
expect(refreshedCwds).toEqual(["/repo"]);
expect(emitted).toEqual([
{
@@ -411,7 +427,7 @@ describe("CheckoutSession", () => {
});
it("expands a tilde cwd before refreshing git and diffs", async () => {
const snapshotCalls: string[] = [];
const refreshCalls: string[] = [];
const { subscriber, refreshedCwds } = createFakeDiffSubscriber({
cwd: "",
files: [],
@@ -419,9 +435,8 @@ describe("CheckoutSession", () => {
});
const { checkout } = makeCheckoutSession({
git: {
getSnapshot: async (cwd) => {
snapshotCalls.push(cwd);
return createNoGitWorkspaceRuntimeSnapshot(cwd);
refresh: async (cwd) => {
refreshCalls.push(cwd);
},
},
diff: subscriber,
@@ -434,7 +449,7 @@ describe("CheckoutSession", () => {
});
const resolvedCwd = expandTilde("~/repo");
expect(snapshotCalls).toEqual([resolvedCwd]);
expect(refreshCalls).toEqual([resolvedCwd]);
expect(refreshedCwds).toEqual([resolvedCwd]);
});
});
@@ -550,12 +565,16 @@ describe("CheckoutSession", () => {
it("emits a checkout status update for a workspace git snapshot", () => {
const { checkout, emitted } = makeCheckoutSession();
checkout.emitStatusUpdate("/repo", createGitSnapshot("/repo", "main"));
checkout.emitStatusUpdate("workspace-1", "/repo", createGitSnapshot("/repo", "main"));
expect(emitted).toEqual([
{
type: "checkout_status_update",
payload: expect.objectContaining({ cwd: "/repo", currentBranch: "main" }),
payload: expect.objectContaining({
workspaceId: "workspace-1",
cwd: "/repo",
currentBranch: "main",
}),
},
]);
});
@@ -672,7 +691,7 @@ describe("CheckoutSession", () => {
]);
expect(refreshedCwds).toEqual(["/repo"]);
expect(hostCalls.handleWorkspaceGitBranchSnapshot).toEqual([
{ cwd: "/repo", branchName: "feature-renamed" },
{ address: { kind: "legacy", cwd: "/repo" }, branchName: "feature-renamed" },
]);
expect(hostCalls.emitWorkspaceUpdateForCwd).toEqual(["/repo"]);
expect(emitted).toEqual([
@@ -19,7 +19,7 @@ import type {
import type {
CheckoutDiffSnapshotPayload,
CheckoutDiffSubscription,
CheckoutDiffSubscriptionRequest,
CheckoutDiffWorkspaceSubscriptionRequest,
} from "../../checkout-diff-manager.js";
import { toCheckoutError } from "../../checkout-git-utils.js";
import {
@@ -28,9 +28,11 @@ import {
} from "../../checkout/status-projection.js";
import type {
WorkspaceGitRuntimeSnapshot,
WorkspaceGitService,
WorkspaceGitSnapshotOptions,
WorkspaceGitWorkspace,
} from "../../workspace-git-service.js";
import type { WorkspaceGitDirectory } from "../../workspace-git-directory.js";
import type { WorkspaceGitAddress } from "../../workspace-git-directory.js";
import { assertSafeGitRef } from "../../worktree-session.js";
import type { GitMutationService } from "../git-mutation/git-mutation-service.js";
import type {
@@ -40,18 +42,10 @@ import type {
SearchResult,
} from "../../../services/forge-service.js";
import {
commitChanges,
createPullRequest,
forgeAuthStateFromError,
isForgeAuthError,
mergeFromBase,
mergeToBase,
pullCurrentBranch,
pushCurrentBranch,
listCheckoutCommits,
getCommitFileDiff,
} from "../../../utils/checkout-git.js";
import { runGitCommand } from "../../../utils/run-git-command.js";
import { expandTilde } from "../../../utils/path.js";
import type { GitMetadataGenerator } from "./git-metadata-generator.js";
@@ -67,11 +61,7 @@ import type { GitMetadataGenerator } from "./git-metadata-generator.js";
export interface CheckoutSessionHost {
emit(msg: SessionOutboundMessage): void;
emitWorkspaceUpdateForCwd(cwd: string): Promise<void>;
handleWorkspaceGitBranchSnapshot(cwd: string, branchName: string | null): void;
renameCurrentBranch(
cwd: string,
branch: string,
): Promise<{ previousBranch: string | null; currentBranch: string | null }>;
handleWorkspaceGitBranchSnapshot(address: WorkspaceGitAddress, branchName: string | null): void;
}
type CurrentWorkspacePullRequest = NonNullable<
@@ -107,22 +97,20 @@ function toLegacyGithubSearchItems(items: ForgeSearchResultItem[]): LegacyGithub
* real CheckoutDiffManager satisfies this structurally; tests supply a fake.
*/
export interface CheckoutDiffSubscriber {
subscribe(
params: CheckoutDiffSubscriptionRequest,
subscribeWorkspace(
params: CheckoutDiffWorkspaceSubscriptionRequest,
listener: (snapshot: CheckoutDiffSnapshotPayload) => void,
): Promise<CheckoutDiffSubscription>;
scheduleRefreshForCwd(cwd: string): void;
scheduleRefreshForWorkspace(workspaceGit: WorkspaceGitWorkspace): void;
}
export interface CheckoutSessionOptions {
host: CheckoutSessionHost;
gitMutation: Pick<GitMutationService, "checkoutExistingBranch" | "notifyGitMutation">;
workspaceGitService: WorkspaceGitService;
gitMutation: Pick<GitMutationService, "bind">;
workspaceGitDirectory: WorkspaceGitDirectory;
github: ForgeService;
checkoutDiffManager: CheckoutDiffSubscriber;
gitMetadataGenerator: GitMetadataGenerator;
paseoHome: string;
worktreesRoot: string | undefined;
logger: pino.Logger;
}
@@ -140,35 +128,40 @@ export class CheckoutSession {
private static readonly PASEO_STASH_PREFIX = "paseo-auto-stash:";
private readonly host: CheckoutSessionHost;
private readonly gitMutation: Pick<
GitMutationService,
"checkoutExistingBranch" | "notifyGitMutation"
>;
private readonly workspaceGitService: WorkspaceGitService;
private readonly gitMutation: Pick<GitMutationService, "bind">;
private readonly workspaceGitDirectory: WorkspaceGitDirectory;
private readonly github: ForgeService;
private readonly checkoutDiffManager: CheckoutDiffSubscriber;
private readonly gitMetadataGenerator: GitMetadataGenerator;
private readonly paseoHome: string;
private readonly worktreesRoot: string | undefined;
private readonly logger: pino.Logger;
private readonly diffSubscriptions = new Map<string, () => void>();
constructor(options: CheckoutSessionOptions) {
this.host = options.host;
this.gitMutation = options.gitMutation;
this.workspaceGitService = options.workspaceGitService;
this.workspaceGitDirectory = options.workspaceGitDirectory;
this.github = options.github;
this.checkoutDiffManager = options.checkoutDiffManager;
this.gitMetadataGenerator = options.gitMetadataGenerator;
this.paseoHome = options.paseoHome;
this.worktreesRoot = options.worktreesRoot;
this.logger = options.logger;
}
private resolveWorkspaceGit(input: {
cwd: string;
workspaceId?: string;
}): Promise<WorkspaceGitWorkspace> {
const cwd = expandTilde(input.cwd);
return this.workspaceGitDirectory.resolve(
input.workspaceId === undefined
? { kind: "legacy", cwd }
: { kind: "selected", workspaceId: input.workspaceId, cwd },
);
}
private async resolveForgeService(
cwd: string,
workspaceGit: WorkspaceGitWorkspace,
): Promise<{ forge: string; service: ForgeService } | null> {
const resolution = await this.workspaceGitService.resolveForge(cwd);
const resolution = await workspaceGit.resolveForge();
if (!resolution) {
return null;
}
@@ -176,29 +169,32 @@ export class CheckoutSession {
}
private async requireForgeService(
cwd: string,
workspaceGit: WorkspaceGitWorkspace,
): Promise<{ forge: string; service: ForgeService }> {
const resolution = await this.resolveForgeService(cwd);
const resolution = await this.resolveForgeService(workspaceGit);
if (!resolution) {
throw new NoResolvedForgeServiceError(cwd);
throw new NoResolvedForgeServiceError(workspaceGit.cwd);
}
return resolution;
}
private async resolveForgeIdForError(cwd: string): Promise<string> {
private async resolveForgeIdForError(workspaceGit: WorkspaceGitWorkspace): Promise<string> {
try {
return (await this.workspaceGitService.resolveForge(cwd))?.forge ?? "github";
return (await workspaceGit.resolveForge())?.forge ?? "github";
} catch {
return "github";
}
}
private async resolveAuthStateForError(cwd: string, error: unknown): Promise<ForgeAuthState> {
private async resolveAuthStateForError(
workspaceGit: WorkspaceGitWorkspace,
error: unknown,
): Promise<ForgeAuthState> {
if (error instanceof NoResolvedForgeServiceError) {
return error.authState;
}
try {
return (await this.workspaceGitService.resolveForge(cwd)) ? "error" : "no_remote";
return (await workspaceGit.resolveForge()) ? "error" : "no_remote";
} catch {
return "error";
}
@@ -211,14 +207,17 @@ export class CheckoutSession {
* independently in the same error path.
*/
private async resolveForgeContextForError(
cwd: string,
workspaceGit: WorkspaceGitWorkspace,
error: unknown,
): Promise<{ forge: string; authState: ForgeAuthState }> {
if (error instanceof NoResolvedForgeServiceError) {
return { forge: await this.resolveForgeIdForError(cwd), authState: error.authState };
return {
forge: await this.resolveForgeIdForError(workspaceGit),
authState: error.authState,
};
}
try {
const resolution = await this.workspaceGitService.resolveForge(cwd);
const resolution = await workspaceGit.resolveForge();
return {
forge: resolution?.forge ?? "github",
authState: resolution ? "error" : "no_remote",
@@ -230,22 +229,21 @@ export class CheckoutSession {
async handleStatusRequest(msg: CheckoutStatusRequest): Promise<void> {
const { cwd, requestId } = msg;
const resolvedCwd = expandTilde(cwd);
try {
const snapshot = await this.workspaceGitService.getSnapshot(resolvedCwd);
const workspaceGit = await this.resolveWorkspaceGit(msg);
const snapshot = await workspaceGit.getSnapshot();
this.host.emit({
type: "checkout_status_response",
payload: buildCheckoutStatusPayloadFromSnapshot({
cwd,
requestId,
snapshot,
}),
payload: {
...(msg.workspaceId ? { workspaceId: msg.workspaceId } : {}),
...buildCheckoutStatusPayloadFromSnapshot({ cwd, requestId, snapshot }),
},
});
} catch (error) {
this.host.emit({
type: "checkout_status_response",
payload: {
...(msg.workspaceId ? { workspaceId: msg.workspaceId } : {}),
cwd,
isGit: false,
repoRoot: null,
@@ -269,7 +267,8 @@ export class CheckoutSession {
const { cwd, requestId } = msg;
try {
const { baseRef, commits } = await listCheckoutCommits({ cwd: expandTilde(cwd) });
const workspaceGit = await this.resolveWorkspaceGit(msg);
const { baseRef, commits } = await workspaceGit.listCommits();
this.host.emit({
type: "checkout.commits.list.response",
payload: { cwd, baseRef, commits, error: null, requestId },
@@ -290,7 +289,11 @@ export class CheckoutSession {
if (path.length === 0 || isAbsolute(path) || path.split(/[\\/]/).includes("..")) {
throw new Error(`Invalid path: ${path}`);
}
const file = await getCommitFileDiff({ cwd: expandTilde(cwd), sha, path });
const workspaceGit = await this.resolveWorkspaceGit(msg);
const file = await workspaceGit.getCommitFileDiff({
sha,
path,
});
this.host.emit({
type: "checkout.commits.file_diff.response",
payload: { cwd, sha, path, file, error: null, requestId },
@@ -304,13 +307,13 @@ export class CheckoutSession {
}
async handleValidateBranchRequest(msg: ValidateBranchRequest): Promise<void> {
const { cwd, branchName, requestId } = msg;
const { branchName, requestId } = msg;
try {
const resolvedCwd = expandTilde(cwd);
assertSafeGitRef(branchName, "branch");
const resolution = await this.workspaceGitService.validateBranchRef(resolvedCwd, branchName);
const workspaceGit = await this.resolveWorkspaceGit(msg);
const resolution = await workspaceGit.validateBranchRef(branchName);
switch (resolution.kind) {
case "local":
this.host.emit({
@@ -368,11 +371,11 @@ export class CheckoutSession {
}
async handleBranchSuggestionsRequest(msg: BranchSuggestionsRequest): Promise<void> {
const { cwd, query, limit, requestId } = msg;
const { query, limit, requestId } = msg;
try {
const resolvedCwd = expandTilde(cwd);
const branchDetails = await this.workspaceGitService.suggestBranchesForCwd(resolvedCwd, {
const workspaceGit = await this.resolveWorkspaceGit(msg);
const branchDetails = await workspaceGit.suggestBranches({
query,
limit,
});
@@ -399,20 +402,21 @@ export class CheckoutSession {
}
async handleSubscribeDiffRequest(msg: SubscribeCheckoutDiffRequest): Promise<void> {
const cwd = expandTilde(msg.cwd);
this.diffSubscriptions.get(msg.subscriptionId)?.();
const abort = new AbortController();
const unsubscribe = () => abort.abort();
this.diffSubscriptions.set(msg.subscriptionId, unsubscribe);
try {
const subscription = await this.checkoutDiffManager.subscribe(
{ cwd, compare: msg.compare, signal: abort.signal },
const workspaceGit = await this.resolveWorkspaceGit(msg);
const subscription = await this.checkoutDiffManager.subscribeWorkspace(
{ workspaceGit, compare: msg.compare, signal: abort.signal },
(snapshot) => {
this.host.emit({
type: "checkout_diff_update",
payload: {
subscriptionId: msg.subscriptionId,
...(msg.workspaceId ? { workspaceId: msg.workspaceId } : {}),
...snapshot,
},
});
@@ -423,6 +427,7 @@ export class CheckoutSession {
type: "subscribe_checkout_diff_response",
payload: {
subscriptionId: msg.subscriptionId,
...(msg.workspaceId ? { workspaceId: msg.workspaceId } : {}),
...subscription.initial,
requestId: msg.requestId,
},
@@ -444,16 +449,11 @@ export class CheckoutSession {
async handleRefreshRequest(msg: CheckoutRefreshRequest): Promise<void> {
const { cwd, requestId } = msg;
const resolvedCwd = expandTilde(cwd);
try {
(await this.resolveForgeService(resolvedCwd))?.service.invalidate({ cwd: resolvedCwd });
await this.workspaceGitService.getSnapshot(resolvedCwd, {
force: true,
includeForge: true,
reason: "manual-refresh",
});
this.checkoutDiffManager.scheduleRefreshForCwd(resolvedCwd);
const workspaceGit = await this.resolveWorkspaceGit(msg);
await workspaceGit.refresh({ priority: "high" });
this.checkoutDiffManager.scheduleRefreshForWorkspace(workspaceGit);
this.host.emit({
type: "checkout.refresh.response",
payload: {
@@ -476,12 +476,13 @@ export class CheckoutSession {
}
}
emitStatusUpdate(cwd: string, snapshot: WorkspaceGitRuntimeSnapshot): void {
emitStatusUpdate(workspaceId: string, cwd: string, snapshot: WorkspaceGitRuntimeSnapshot): void {
try {
const requestId = `subscription:${cwd}`;
const requestId = `subscription:${workspaceId}`;
this.host.emit({
type: "checkout_status_update",
payload: {
workspaceId,
...buildCheckoutStatusPayloadFromSnapshot({
cwd,
requestId,
@@ -503,8 +504,8 @@ export class CheckoutSession {
* Notify the live diff subscriptions that the working tree at `cwd` changed.
* Called by the command handlers below after they mutate the repository.
*/
private scheduleDiffRefresh(cwd: string): void {
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
private scheduleDiffRefresh(workspaceGit: WorkspaceGitWorkspace): void {
this.checkoutDiffManager.scheduleRefreshForWorkspace(workspaceGit);
}
// ---------------------------------------------------------------------------
@@ -517,8 +518,11 @@ export class CheckoutSession {
const { cwd, branch, requestId } = msg;
try {
const checkoutResult = await this.gitMutation.checkoutExistingBranch(cwd, branch);
this.scheduleDiffRefresh(cwd);
const workspaceGit = await this.resolveWorkspaceGit(msg);
const checkoutResult = await this.gitMutation
.bind(workspaceGit)
.checkoutExistingBranch(branch);
this.scheduleDiffRefresh(workspaceGit);
// Push a workspace_update immediately so the sidebar/header reflect
// the new branch name without waiting for the background git watcher.
@@ -568,10 +572,18 @@ export class CheckoutSession {
}
try {
const result = await this.host.renameCurrentBranch(cwd, branch);
await this.gitMutation.notifyGitMutation(cwd, "rename-branch", { invalidateForge: true });
this.scheduleDiffRefresh(cwd);
this.host.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch);
const workspaceGit = await this.resolveWorkspaceGit(msg);
const result = await workspaceGit.renameBranch(branch);
await this.gitMutation.bind(workspaceGit).notify("rename-branch", {
invalidateForge: true,
});
this.scheduleDiffRefresh(workspaceGit);
this.host.handleWorkspaceGitBranchSnapshot(
msg.workspaceId === undefined
? { kind: "legacy", cwd }
: { kind: "selected", workspaceId: msg.workspaceId, cwd },
result.currentBranch,
);
// Branch is a git fact derived per-descriptor from each workspace's own
// live git snapshot (id → cwd); the reconciliation pass re-persists the
@@ -610,16 +622,14 @@ export class CheckoutSession {
): Promise<void> {
const { cwd, requestId } = msg;
try {
const workspaceGit = await this.resolveWorkspaceGit(msg);
const branchLabel = msg.branch?.trim() ?? "";
const message = branchLabel
? `${CheckoutSession.PASEO_STASH_PREFIX} ${branchLabel}`
: `${CheckoutSession.PASEO_STASH_PREFIX} unnamed`;
await runGitCommand(["stash", "push", "--include-untracked", "-m", message], {
cwd,
timeout: 120_000,
});
await this.gitMutation.notifyGitMutation(cwd, "stash-push");
this.scheduleDiffRefresh(cwd);
await workspaceGit.stashPush(message);
await this.gitMutation.bind(workspaceGit).notify("stash-push");
this.scheduleDiffRefresh(workspaceGit);
this.host.emit({
type: "stash_save_response",
payload: { cwd, success: true, error: null, requestId },
@@ -637,12 +647,10 @@ export class CheckoutSession {
): Promise<void> {
const { cwd, stashIndex, requestId } = msg;
try {
await runGitCommand(["stash", "pop", `stash@{${stashIndex}}`], {
cwd,
timeout: 120_000,
});
await this.gitMutation.notifyGitMutation(cwd, "stash-pop");
this.scheduleDiffRefresh(cwd);
const workspaceGit = await this.resolveWorkspaceGit(msg);
await workspaceGit.stashPop(stashIndex);
await this.gitMutation.bind(workspaceGit).notify("stash-pop");
this.scheduleDiffRefresh(workspaceGit);
this.host.emit({
type: "stash_pop_response",
payload: { cwd, success: true, error: null, requestId },
@@ -661,7 +669,8 @@ export class CheckoutSession {
const { cwd, requestId } = msg;
const paseoOnly = msg.paseoOnly !== false;
try {
const entries = await this.workspaceGitService.listStashes(cwd, { paseoOnly });
const workspaceGit = await this.resolveWorkspaceGit(msg);
const entries = await workspaceGit.listStashes({ paseoOnly });
this.host.emit({
type: "stash_list_response",
@@ -681,20 +690,24 @@ export class CheckoutSession {
const { cwd, requestId } = msg;
try {
const workspaceGit = await this.resolveWorkspaceGit(msg);
let message = msg.message?.trim() ?? "";
if (!message) {
if (msg.workspaceId) {
throw new Error("Selected workspace Git requires an explicit commit message");
}
message = await this.gitMetadataGenerator.generateCommitMessage(cwd);
}
if (!message) {
throw new Error("Commit message is required");
}
await commitChanges(cwd, {
await workspaceGit.commit({
message,
addAll: msg.addAll ?? true,
});
await this.gitMutation.notifyGitMutation(cwd, "commit-changes");
this.scheduleDiffRefresh(cwd);
await this.gitMutation.bind(workspaceGit).notify("commit-changes");
this.scheduleDiffRefresh(workspaceGit);
this.host.emit({
type: "checkout_commit_response",
@@ -724,7 +737,8 @@ export class CheckoutSession {
const { cwd, requestId } = msg;
try {
const snapshot = await this.workspaceGitService.getSnapshot(cwd);
const workspaceGit = await this.resolveWorkspaceGit(msg);
const snapshot = await workspaceGit.getSnapshot();
if (!snapshot.git.isGit) {
throw new Error(`Not a git repository: ${cwd}`);
}
@@ -743,19 +757,18 @@ export class CheckoutSession {
baseRef = baseRef.slice("origin/".length);
}
const mutatedCwd = await mergeToBase(
cwd,
{
baseRef,
mode: msg.strategy === "squash" ? "squash" : "merge",
},
{ paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot },
);
await Promise.all([
this.gitMutation.notifyGitMutation(mutatedCwd, "merge-to-base", { invalidateForge: true }),
...(mutatedCwd !== cwd ? [this.gitMutation.notifyGitMutation(cwd, "merge-to-base")] : []),
]);
this.scheduleDiffRefresh(cwd);
const mutatedCwd = await workspaceGit.mergeToBase({
baseRef,
mode: msg.strategy === "squash" ? "squash" : "merge",
});
const mutatedWorkspaceGit =
mutatedCwd === workspaceGit.cwd
? workspaceGit
: await this.workspaceGitDirectory.resolve({ kind: "legacy", cwd: mutatedCwd });
await this.gitMutation.bind(mutatedWorkspaceGit).notify("merge-to-base", {
invalidateForge: true,
});
this.scheduleDiffRefresh(mutatedWorkspaceGit);
this.host.emit({
type: "checkout_merge_response",
@@ -785,19 +798,22 @@ export class CheckoutSession {
const { cwd, requestId } = msg;
try {
const workspaceGit = await this.resolveWorkspaceGit(msg);
if (msg.requireCleanTarget ?? true) {
const snapshot = await this.workspaceGitService.getSnapshot(cwd);
const snapshot = await workspaceGit.getSnapshot();
if (snapshot.git.isDirty) {
throw new Error("Working directory has uncommitted changes.");
}
}
await mergeFromBase(cwd, {
await workspaceGit.mergeFromBase({
baseRef: msg.baseRef,
requireCleanTarget: msg.requireCleanTarget ?? true,
});
await this.gitMutation.notifyGitMutation(cwd, "merge-from-base", { invalidateForge: true });
this.scheduleDiffRefresh(cwd);
await this.gitMutation.bind(workspaceGit).notify("merge-from-base", {
invalidateForge: true,
});
this.scheduleDiffRefresh(workspaceGit);
this.host.emit({
type: "checkout_merge_from_base_response",
@@ -827,9 +843,10 @@ export class CheckoutSession {
const { cwd, requestId } = msg;
try {
await pullCurrentBranch(cwd);
await this.gitMutation.notifyGitMutation(cwd, "pull", { invalidateForge: true });
this.scheduleDiffRefresh(cwd);
const workspaceGit = await this.resolveWorkspaceGit(msg);
await workspaceGit.pull();
await this.gitMutation.bind(workspaceGit).notify("pull", { invalidateForge: true });
this.scheduleDiffRefresh(workspaceGit);
this.host.emit({
type: "checkout_pull_response",
@@ -859,8 +876,9 @@ export class CheckoutSession {
const { cwd, requestId } = msg;
try {
await pushCurrentBranch(cwd);
await this.gitMutation.notifyGitMutation(cwd, "push", { invalidateForge: true });
const workspaceGit = await this.resolveWorkspaceGit(msg);
await workspaceGit.push();
await this.gitMutation.bind(workspaceGit).notify("push", { invalidateForge: true });
this.host.emit({
type: "checkout_push_response",
payload: {
@@ -889,6 +907,10 @@ export class CheckoutSession {
const { cwd, requestId } = msg;
try {
const workspaceGit = await this.resolveWorkspaceGit(msg);
if (msg.workspaceId) {
throw new Error("Selected workspace Git does not support pull requests");
}
let title = msg.title?.trim() ?? "";
let body = msg.body?.trim() ?? "";
@@ -898,7 +920,7 @@ export class CheckoutSession {
if (!body) body = generated.body;
}
const { service } = await this.requireForgeService(cwd);
const { service } = await this.requireForgeService(workspaceGit);
const result = await createPullRequest(
cwd,
{
@@ -908,7 +930,7 @@ export class CheckoutSession {
},
service,
);
await this.gitMutation.notifyGitMutation(cwd, "create-pr", { invalidateForge: true });
await this.gitMutation.bind(workspaceGit).notify("create-pr", { invalidateForge: true });
this.host.emit({
type: "checkout_pr_create_response",
@@ -940,19 +962,20 @@ export class CheckoutSession {
const { cwd, requestId } = msg;
try {
const pullRequest = await this.resolveCurrentPullRequest(cwd, "merge", {
const workspaceGit = await this.resolveWorkspaceGit(msg);
const pullRequest = await this.resolveCurrentPullRequest(workspaceGit, "merge", {
force: true,
includeForge: true,
reason: "merge-pr-validation",
});
const { service } = await this.requireForgeService(cwd);
const { service } = await this.requireForgeService(workspaceGit);
await service.mergePullRequest({
cwd,
prNumber: pullRequest.number,
mergeMethod: msg.mergeMethod,
status: pullRequest,
});
await this.gitMutation.notifyGitMutation(cwd, "merge-pr", { invalidateForge: true });
await this.gitMutation.bind(workspaceGit).notify("merge-pr", { invalidateForge: true });
this.host.emit({
type: "checkout_pr_merge_response",
@@ -991,12 +1014,13 @@ export class CheckoutSession {
: "checkout.github.set_auto_merge.response";
try {
const pullRequest = await this.resolveCurrentPullRequest(cwd, "auto-merge", {
const workspaceGit = await this.resolveWorkspaceGit(msg);
const pullRequest = await this.resolveCurrentPullRequest(workspaceGit, "auto-merge", {
force: true,
includeForge: true,
reason: "auto-merge-validation",
});
const { service } = await this.requireForgeService(cwd);
const { service } = await this.requireForgeService(workspaceGit);
if (msg.enabled) {
const mergeMethod = msg.mergeMethod;
if (!mergeMethod) {
@@ -1018,13 +1042,11 @@ export class CheckoutSession {
status: pullRequest,
});
}
await this.gitMutation.notifyGitMutation(
cwd,
msg.enabled ? "enable-pr-auto-merge" : "disable-pr-auto-merge",
{
await this.gitMutation
.bind(workspaceGit)
.notify(msg.enabled ? "enable-pr-auto-merge" : "disable-pr-auto-merge", {
invalidateForge: true,
},
);
});
this.host.emit({
type: responseType,
@@ -1051,11 +1073,11 @@ export class CheckoutSession {
}
private async resolveCurrentPullRequest(
cwd: string,
workspaceGit: WorkspaceGitWorkspace,
operation: "merge" | "auto-merge",
options?: WorkspaceGitSnapshotOptions,
): Promise<CurrentWorkspacePullRequest> {
const snapshot = await this.workspaceGitService.getSnapshot(cwd, options);
const snapshot = await workspaceGit.getSnapshot(options);
const pullRequest = snapshot.forge.pullRequest;
if (!pullRequest || typeof pullRequest.number !== "number") {
throw new Error(`Unable to determine current change request number for ${operation}`);
@@ -1067,9 +1089,11 @@ export class CheckoutSession {
msg: Extract<SessionInboundMessage, { type: "checkout_pr_status_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
let workspaceGit: WorkspaceGitWorkspace | null = null;
try {
const snapshot = await this.workspaceGitService.getSnapshot(cwd);
workspaceGit = await this.resolveWorkspaceGit(msg);
const snapshot = await workspaceGit.getSnapshot();
this.host.emit({
type: "checkout_pr_status_response",
payload: buildCheckoutPrStatusPayloadFromSnapshot({
@@ -1079,7 +1103,9 @@ export class CheckoutSession {
}),
});
} catch (error) {
const { forge, authState } = await this.resolveForgeContextForError(cwd, error);
const { forge, authState } = workspaceGit
? await this.resolveForgeContextForError(workspaceGit, error)
: { forge: "github", authState: "error" as const };
this.host.emit({
type: "checkout_pr_status_response",
payload: {
@@ -1119,7 +1145,8 @@ export class CheckoutSession {
return;
}
const resolvedForge = await this.resolveForgeService(cwd);
const workspaceGit = await this.resolveWorkspaceGit(msg);
const resolvedForge = await this.resolveForgeService(workspaceGit);
if (!resolvedForge) {
this.host.emit({
type: "pull_request_timeline_response",
@@ -1244,7 +1271,8 @@ export class CheckoutSession {
"Check details request must address a check by checkRunId or workflowRunId",
);
}
const { service } = await this.requireForgeService(cwd);
const workspaceGit = await this.resolveWorkspaceGit(msg);
const { service } = await this.requireForgeService(workspaceGit);
const details = await service.getCheckDetails({
cwd,
repoOwner,
@@ -1287,13 +1315,17 @@ export class CheckoutSession {
try {
const resolvedCwd = expandTilde(cwd);
const workspaceGit = await this.resolveWorkspaceGit(msg);
// COMPAT(githubSearchRpc): added in v0.1.106, remove after 2026-12-28 —
// the legacy github_search RPC is GitHub by definition; the modern
// forge.search RPC resolves the cwd's forge.
const resolvedForge =
msg.type === "github_search_request"
? { forge: "github", service: this.github }
: await this.resolveForgeService(resolvedCwd);
let resolvedForge: { forge: string; service: ForgeService } | null;
if (msg.type === "github_search_request") {
await workspaceGit.resolveForge();
resolvedForge = { forge: "github", service: this.github };
} else {
resolvedForge = await this.resolveForgeService(workspaceGit);
}
if (!resolvedForge) {
if (msg.type === "github_search_request") {
this.host.emit({
@@ -1357,8 +1389,12 @@ export class CheckoutSession {
},
});
} catch (error) {
const resolvedCwd = expandTilde(cwd);
const authState = await this.resolveAuthStateForError(resolvedCwd, error);
let authState: ForgeAuthState = "error";
try {
authState = await this.resolveAuthStateForError(await this.resolveWorkspaceGit(msg), error);
} catch {
authState = "error";
}
if (msg.type === "github_search_request") {
this.host.emit({
type: "github_search_response",
@@ -11,14 +11,19 @@ import type {
} from "../../workspace-git-service.js";
import { createGitMutationService } from "./git-mutation-service.js";
// The production module reads only WorkspaceGitService.{validateBranchRef,getSnapshot,
// hasLocalBranch,invalidateForge}. The fake below implements exactly that slice as an
// The production module reads only the named WorkspaceGitService branch capabilities. The fake
// below implements exactly that slice as an
// in-memory adapter; the happy-path tests cross the real git boundary against a temp repo,
// since that is where checkoutResolvedBranch / `git checkout -b` actually run.
type GitSource = Pick<
WorkspaceGitService,
"validateBranchRef" | "getSnapshot" | "hasLocalBranch" | "invalidateForge"
| "validateBranchRef"
| "getSnapshot"
| "hasLocalBranch"
| "invalidateForge"
| "switchBranch"
| "createBranch"
>;
const logger = pino({ level: "silent" });
@@ -53,6 +58,16 @@ function createFakeGit(opts: FakeGitOptions = {}) {
invalidateForge(cwd) {
invalidateCalls.push({ cwd });
},
async switchBranch(cwd, branch) {
execFileSync("git", ["checkout", branch], { cwd, stdio: "pipe" });
return { source: resolution.kind === "remote" ? "remote" : "local" };
},
async createBranch(cwd, options) {
execFileSync("git", ["checkout", "-b", options.branch, options.baseRef], {
cwd,
stdio: "pipe",
});
},
};
return { git, snapshotCalls, invalidateCalls };
}
@@ -1,12 +1,10 @@
import type pino from "pino";
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
import {
checkoutResolvedBranch,
type CheckoutExistingBranchResult,
type GitMutationRefreshReason,
import type {
CheckoutExistingBranchResult,
GitMutationRefreshReason,
} from "../../../utils/checkout-git.js";
import { runGitCommand } from "../../../utils/run-git-command.js";
import type { WorkspaceGitService } from "../../workspace-git-service.js";
import type { WorkspaceGitService, WorkspaceGitWorkspace } from "../../workspace-git-service.js";
import { assertSafeGitRef as assertWorktreeSafeGitRef } from "../../worktree-session.js";
/**
@@ -21,6 +19,8 @@ import { assertSafeGitRef as assertWorktreeSafeGitRef } from "../../worktree-ses
* session as loose callbacks.
*/
export interface GitMutationService {
bind(workspaceGit: WorkspaceGitWorkspace): BoundGitMutation;
/** Explicit compatibility path for callers that do not own a workspace record yet. */
checkoutExistingBranch(cwd: string, branch: string): Promise<CheckoutExistingBranchResult>;
createBranchFromBase(params: {
cwd: string;
@@ -34,9 +34,20 @@ export interface GitMutationService {
): Promise<void>;
}
export interface BoundGitMutation {
checkoutExistingBranch(branch: string): Promise<CheckoutExistingBranchResult>;
createBranchFromBase(params: { baseBranch: string; newBranchName: string }): Promise<void>;
notify(reason: GitMutationRefreshReason, options?: { invalidateForge?: boolean }): Promise<void>;
}
type GitMutationGitSource = Pick<
WorkspaceGitService,
"validateBranchRef" | "getSnapshot" | "hasLocalBranch" | "invalidateForge"
| "validateBranchRef"
| "getSnapshot"
| "hasLocalBranch"
| "invalidateForge"
| "switchBranch"
| "createBranch"
>;
export function createGitMutationService(deps: {
@@ -45,6 +56,72 @@ export function createGitMutationService(deps: {
}): GitMutationService {
const { workspaceGitService, logger } = deps;
function bind(workspaceGit: WorkspaceGitWorkspace): BoundGitMutation {
async function isBoundWorkingTreeDirty(): Promise<boolean> {
try {
const snapshot = await workspaceGit.getSnapshot();
return snapshot.git.isDirty === true;
} catch (error) {
throw new Error(
`Unable to inspect git status for ${workspaceGit.cwd}: ${getErrorMessage(error)}`,
{ cause: error },
);
}
}
async function ensureBoundWorkingTreeClean(): Promise<void> {
if (await isBoundWorkingTreeDirty()) {
throw new Error(
"Working directory has uncommitted changes. Commit or stash before switching branches.",
);
}
}
async function notify(
reason: GitMutationRefreshReason,
options?: { invalidateForge?: boolean },
): Promise<void> {
if (options?.invalidateForge) {
workspaceGit.invalidateForge();
}
try {
await workspaceGit.getSnapshot({ force: true, reason });
} catch (error) {
logger.warn(
{ err: error, cwd: workspaceGit.cwd, reason },
"Failed to force-refresh workspace git snapshot after mutation",
);
}
}
return {
async checkoutExistingBranch(branch) {
assertSafeGitRef(branch, "branch");
const resolution = await workspaceGit.validateBranchRef(branch);
if (resolution.kind === "not-found") throw new Error(`Branch not found: ${branch}`);
await ensureBoundWorkingTreeClean();
const result = await workspaceGit.switchBranch(branch);
await notify("switch-branch", { invalidateForge: true });
return result;
},
async createBranchFromBase({ baseBranch, newBranchName }) {
assertSafeGitRef(baseBranch, "base branch");
assertSafeGitRef(newBranchName, "new branch");
const baseResolution = await workspaceGit.validateBranchRef(baseBranch);
if (baseResolution.kind === "not-found") {
throw new Error(`Base branch not found: ${baseBranch}`);
}
if (await workspaceGit.hasLocalBranch(newBranchName)) {
throw new Error(`Branch already exists: ${newBranchName}`);
}
await ensureBoundWorkingTreeClean();
await workspaceGit.createBranch({ branch: newBranchName, baseRef: baseBranch });
await notify("create-branch");
},
notify,
};
}
function assertSafeGitRef(ref: string, label: string): void {
if (!/^[A-Za-z0-9._/-]+$/.test(ref)) {
throw new Error(`Invalid ${label}: ${ref}`);
@@ -91,6 +168,7 @@ export function createGitMutationService(deps: {
}
return {
bind,
async checkoutExistingBranch(cwd, branch) {
assertSafeGitRef(branch, "branch");
const resolution = await workspaceGitService.validateBranchRef(cwd, branch);
@@ -98,7 +176,7 @@ export function createGitMutationService(deps: {
throw new Error(`Branch not found: ${branch}`);
}
await ensureCleanWorkingTree(cwd);
const result = await checkoutResolvedBranch({ cwd, resolution });
const result = await workspaceGitService.switchBranch(cwd, branch);
await notifyGitMutation(cwd, "switch-branch", { invalidateForge: true });
return result;
},
@@ -118,9 +196,9 @@ export function createGitMutationService(deps: {
}
await ensureCleanWorkingTree(cwd);
await runGitCommand(["checkout", "-b", newBranchName, baseBranch], {
cwd,
timeout: 120_000,
await workspaceGitService.createBranch(cwd, {
branch: newBranchName,
baseRef: baseBranch,
});
await notifyGitMutation(cwd, "create-branch");
},
@@ -5,7 +5,7 @@ import type { WorkspaceDescriptorPayload } from "../../messages.js";
import type {
WorkspaceGitListener,
WorkspaceGitRuntimeSnapshot,
WorkspaceGitService,
WorkspaceGitWorkspace,
} from "../../workspace-git-service.js";
import type { PersistedWorkspaceRecord } from "../../workspace-registry.js";
import { createWorkspaceGitObserverService } from "./workspace-git-observer-service.js";
@@ -54,33 +54,64 @@ function flushMicrotasks(): Promise<void> {
return new Promise((done) => setImmediate(done));
}
function buildHarness(opts: { emitCwdRejects?: boolean } = {}) {
function buildHarness(
opts: {
emitWorkspaceIdRejects?: boolean;
distinctWorkspaceBindings?: boolean;
selectedWorkspaceIds?: ReadonlySet<string>;
} = {},
) {
const listeners = new Map<string, WorkspaceGitListener>();
const registerCalls: string[] = [];
const unsubscribeCalls: string[] = [];
const emitCwdCalls: string[] = [];
const emitWorkspaceIdCalls: string[] = [];
const statusCalls: Array<{ cwd: string; branch: string | null }> = [];
const statusCalls: Array<{
workspaceId: string | undefined;
cwd: string;
branch: string | null;
}> = [];
const branchChanges: Array<[string, string | null, string | null]> = [];
const warnCalls: unknown[][] = [];
const describeCalls: PersistedWorkspaceRecord[] = [];
let describeResult: WorkspaceDescriptorPayload | null = null;
const workspaceGitService: Pick<WorkspaceGitService, "registerWorkspace"> = {
registerWorkspace({ cwd }, listener) {
registerCalls.push(cwd);
listeners.set(cwd, listener);
const workspaces = new Map<string, WorkspaceGitWorkspace>();
function resolveWorkspaceGit(workspaceId: string, cwd: string) {
const selected = opts.distinctWorkspaceBindings || opts.selectedWorkspaceIds?.has(workspaceId);
const key = selected ? workspaceId : cwd;
let workspaceGit = workspaces.get(key);
if (workspaceGit) {
return {
unsubscribe() {
unsubscribeCalls.push(cwd);
listeners.delete(cwd);
},
address: selected
? { kind: "selected" as const, workspaceId, cwd }
: { kind: "legacy" as const, cwd },
workspaceGit,
};
},
};
}
workspaceGit = {
cwd,
register(listener) {
registerCalls.push(cwd);
listeners.set(key, listener);
return {
unsubscribe() {
unsubscribeCalls.push(cwd);
listeners.delete(key);
},
};
},
} as WorkspaceGitWorkspace;
workspaces.set(key, workspaceGit);
return {
address: selected
? { kind: "selected" as const, workspaceId, cwd }
: { kind: "legacy" as const, cwd },
workspaceGit,
};
}
const service = createWorkspaceGitObserverService({
workspaceGitService,
resolveWorkspaceGit,
describeWorkspaceRecordWithGitData: async (workspace) => {
describeCalls.push(workspace);
if (!describeResult) {
@@ -88,17 +119,12 @@ function buildHarness(opts: { emitCwdRejects?: boolean } = {}) {
}
return describeResult;
},
emitWorkspaceUpdateForCwd: async (cwd) => {
emitCwdCalls.push(cwd);
if (opts.emitCwdRejects) {
throw new Error("emit boom");
}
},
emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => {
emitWorkspaceIdCalls.push(workspaceId);
if (opts.emitWorkspaceIdRejects) throw new Error("emit boom");
},
emitStatusUpdate: (cwd, snapshot) => {
statusCalls.push({ cwd, branch: snapshot.git.currentBranch ?? null });
emitStatusUpdate: (workspaceId, cwd, snapshot) => {
statusCalls.push({ workspaceId, cwd, branch: snapshot.git.currentBranch ?? null });
},
onBranchChanged: (workspaceId, oldBranch, newBranch) => {
branchChanges.push([workspaceId, oldBranch, newBranch]);
@@ -114,12 +140,18 @@ function buildHarness(opts: { emitCwdRejects?: boolean } = {}) {
listener(makeSnapshot(cwd, branch));
}
function emitWorkspaceSnapshot(workspaceId: string, cwd: string, branch: string | null): void {
const listener = listeners.get(workspaceId);
if (!listener) throw new Error(`no listener registered for ${workspaceId}`);
listener(makeSnapshot(cwd, branch));
}
return {
service,
emitSnapshot,
emitWorkspaceSnapshot,
registerCalls,
unsubscribeCalls,
emitCwdCalls,
emitWorkspaceIdCalls,
statusCalls,
branchChanges,
@@ -245,8 +277,8 @@ describe("git snapshot listener", () => {
h.emitSnapshot(WS1, "feature");
await flushMicrotasks();
expect(h.branchChanges).toEqual([["ws1", null, "feature"]]);
expect(h.emitCwdCalls).toEqual([WS1]);
expect(h.statusCalls).toEqual([{ cwd: WS1, branch: "feature" }]);
expect(h.emitWorkspaceIdCalls).toEqual(["ws1"]);
expect(h.statusCalls).toEqual([{ workspaceId: "ws1", cwd: WS1, branch: "feature" }]);
});
test("does not re-fire onBranchChanged when the branch is unchanged", () => {
@@ -258,13 +290,40 @@ describe("git snapshot listener", () => {
});
test("logs and swallows an emit failure without skipping the status update", async () => {
const h = buildHarness({ emitCwdRejects: true });
const h = buildHarness({ emitWorkspaceIdRejects: true });
h.service.syncObservers([makeDescriptor({ id: "ws1", workspaceDirectory: WS1 })]);
expect(() => h.emitSnapshot(WS1, "feature")).not.toThrow();
expect(h.statusCalls).toEqual([{ cwd: WS1, branch: "feature" }]);
expect(h.statusCalls).toEqual([{ workspaceId: "ws1", cwd: WS1, branch: "feature" }]);
await flushMicrotasks();
expect(h.warnCalls).toHaveLength(1);
});
test("a selected snapshot updates only its owning same-cwd workspace", async () => {
const h = buildHarness({ distinctWorkspaceBindings: true });
h.service.syncObservers([
makeDescriptor({ id: "selected-a", workspaceDirectory: WS1 }),
makeDescriptor({ id: "selected-b", workspaceDirectory: WS1 }),
]);
h.emitWorkspaceSnapshot("selected-a", WS1, "branch-a");
await flushMicrotasks();
expect(h.branchChanges).toEqual([["selected-a", null, "branch-a"]]);
expect(h.emitWorkspaceIdCalls).toEqual(["selected-a"]);
expect(h.statusCalls).toEqual([{ workspaceId: "selected-a", cwd: WS1, branch: "branch-a" }]);
});
test("a legacy cwd branch event does not cross into a selected same-cwd workspace", () => {
const h = buildHarness({ selectedWorkspaceIds: new Set(["selected"]) });
h.service.syncObservers([
makeDescriptor({ id: "legacy", workspaceDirectory: WS1 }),
makeDescriptor({ id: "selected", workspaceDirectory: WS1 }),
]);
h.service.handleBranchSnapshot({ cwd: WS1 }, "legacy-next");
expect(h.branchChanges).toEqual([["legacy", null, "legacy-next"]]);
});
});
describe("shouldSkipUpdate", () => {
@@ -3,9 +3,10 @@ import type pino from "pino";
import type { WorkspaceDescriptorPayload } from "../../messages.js";
import type {
WorkspaceGitRuntimeSnapshot,
WorkspaceGitService,
WorkspaceGitWorkspace,
} from "../../workspace-git-service.js";
import type { PersistedWorkspaceRecord } from "../../workspace-registry.js";
import type { WorkspaceGitAddress } from "../../workspace-git-directory.js";
const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__";
@@ -15,6 +16,8 @@ interface WorkspaceGitWatchTarget {
interface WorkspaceGitWatchState {
cwd: string;
address: WorkspaceGitAddress;
workspaceGit: WorkspaceGitWorkspace;
latestDescriptorStateKey: string | null;
lastBranchName: string | null;
}
@@ -29,9 +32,8 @@ export interface WorkspaceGitObserverMetrics {
* Observes a workspace's git state on disk (via WorkspaceGitService) and drives the
* live update fan-out: branch-change notifications, workspace-card refreshes, and
* checkout status updates. It owns the per-cwd watch targets and the WorkspaceGitService
* subscription handles. Filesystem subscriptions are keyed by cwd while descriptor and
* branch state remain keyed by workspace id, so same-directory workspace records share one
* watch without sharing identity or teardown lifetime.
* subscription handles. Selected subscriptions are keyed by their bound capability; only
* explicitly legacy bindings may share cwd fan-out.
*
* Branch changes reach `onBranchChanged` from two paths that share `lastBranchName`: the
* on-disk snapshot listener (handleBranchSnapshot) and the workspace-emit loop's Git runtime
@@ -45,20 +47,26 @@ export interface WorkspaceGitObserverService {
// for this workspace, and otherwise advances the recorded state key as a side effect.
shouldSkipUpdate(workspaceId: string, workspace: WorkspaceDescriptorPayload | null): boolean;
recordDescriptorState(workspaceId: string, workspace: WorkspaceDescriptorPayload | null): void;
handleBranchSnapshot(cwd: string, branchName: string | null): void;
handleBranchSnapshot(address: WorkspaceGitAddress, branchName: string | null): void;
getMetrics(): WorkspaceGitObserverMetrics;
removeForWorkspaceId(workspaceId: string): void;
dispose(): void;
}
export function createWorkspaceGitObserverService(deps: {
workspaceGitService: Pick<WorkspaceGitService, "registerWorkspace">;
resolveWorkspaceGit: (
workspaceId: string,
cwd: string,
) => { address: WorkspaceGitAddress; workspaceGit: WorkspaceGitWorkspace };
describeWorkspaceRecordWithGitData: (
workspace: PersistedWorkspaceRecord,
) => Promise<WorkspaceDescriptorPayload>;
emitWorkspaceUpdateForCwd: (cwd: string) => Promise<void>;
emitWorkspaceUpdateForWorkspaceId: (workspaceId: string) => Promise<void>;
emitStatusUpdate: (cwd: string, snapshot: WorkspaceGitRuntimeSnapshot) => void;
emitStatusUpdate: (
workspaceId: string,
cwd: string,
snapshot: WorkspaceGitRuntimeSnapshot,
) => void;
onBranchChanged?: (
workspaceId: string,
oldBranch: string | null,
@@ -67,18 +75,17 @@ export function createWorkspaceGitObserverService(deps: {
logger: pino.Logger;
}): WorkspaceGitObserverService {
const {
workspaceGitService,
resolveWorkspaceGit,
describeWorkspaceRecordWithGitData,
emitWorkspaceUpdateForCwd,
emitWorkspaceUpdateForWorkspaceId,
emitStatusUpdate,
onBranchChanged,
logger,
} = deps;
const watchTargets = new Map<string, WorkspaceGitWatchTarget>();
const watchTargets = new Map<WorkspaceGitWorkspace, WorkspaceGitWatchTarget>();
const workspaceStates = new Map<string, WorkspaceGitWatchState>();
const subscriptions = new Map<string, () => void>();
const subscriptions = new Map<WorkspaceGitWorkspace, () => void>();
function descriptorStateKey(workspace: WorkspaceDescriptorPayload | null): string {
if (!workspace) {
@@ -105,15 +112,14 @@ export function createWorkspaceGitObserverService(deps: {
}
}
function removeForCwd(cwd: string): void {
const normalizedCwd = resolve(cwd);
const target = watchTargets.get(normalizedCwd);
function removeForWorkspaceGit(workspaceGit: WorkspaceGitWorkspace): void {
const target = watchTargets.get(workspaceGit);
for (const workspaceId of target?.workspaceIds ?? []) {
workspaceStates.delete(workspaceId);
}
watchTargets.delete(normalizedCwd);
subscriptions.get(normalizedCwd)?.();
subscriptions.delete(normalizedCwd);
watchTargets.delete(workspaceGit);
subscriptions.get(workspaceGit)?.();
subscriptions.delete(workspaceGit);
}
function removeForWorkspaceId(workspaceId: string): void {
@@ -122,20 +128,26 @@ export function createWorkspaceGitObserverService(deps: {
return;
}
workspaceStates.delete(workspaceId);
const target = watchTargets.get(state.cwd);
const target = watchTargets.get(state.workspaceGit);
target?.workspaceIds.delete(workspaceId);
if (target?.workspaceIds.size === 0) {
removeForCwd(state.cwd);
removeForWorkspaceGit(state.workspaceGit);
}
}
function handleBranchSnapshot(cwd: string, branchName: string | null): void {
const target = watchTargets.get(resolve(cwd));
if (!target) {
return;
}
for (const workspaceId of target.workspaceIds) {
function handleBranchSnapshot(address: WorkspaceGitAddress, branchName: string | null): void {
const addressedState =
address.kind === "selected" ? workspaceStates.get(address.workspaceId) : null;
const target = addressedState ? watchTargets.get(addressedState.workspaceGit) : null;
const workspaceIds = target
? [...target.workspaceIds]
: [...workspaceStates]
.filter(
([, candidate]) =>
candidate.address.kind === "legacy" && candidate.cwd === resolve(address.cwd),
)
.map(([workspaceId]) => workspaceId);
for (const workspaceId of workspaceIds) {
const state = workspaceStates.get(workspaceId);
if (!state) {
continue;
@@ -151,8 +163,12 @@ export function createWorkspaceGitObserverService(deps: {
function syncObserver(cwd: string, options: { isGit: boolean; workspaceId: string }): void {
const normalizedCwd = resolve(cwd);
const { address, workspaceGit } = resolveWorkspaceGit(options.workspaceId, normalizedCwd);
const currentState = workspaceStates.get(options.workspaceId);
if (currentState && currentState.cwd !== normalizedCwd) {
if (
currentState &&
(currentState.cwd !== normalizedCwd || currentState.workspaceGit !== workspaceGit)
) {
removeForWorkspaceId(options.workspaceId);
}
if (!options.isGit) {
@@ -160,40 +176,44 @@ export function createWorkspaceGitObserverService(deps: {
return;
}
const target = watchTargets.get(normalizedCwd) ?? {
const target = watchTargets.get(workspaceGit) ?? {
workspaceIds: new Set<string>(),
};
watchTargets.set(normalizedCwd, target);
watchTargets.set(workspaceGit, target);
target.workspaceIds.add(options.workspaceId);
if (!workspaceStates.has(options.workspaceId)) {
workspaceStates.set(options.workspaceId, {
cwd: normalizedCwd,
address,
workspaceGit,
latestDescriptorStateKey: null,
lastBranchName: null,
});
}
if (subscriptions.has(normalizedCwd)) {
if (subscriptions.has(workspaceGit)) {
return;
}
let subscription: ReturnType<WorkspaceGitService["registerWorkspace"]>;
let subscription: ReturnType<WorkspaceGitWorkspace["register"]>;
try {
subscription = workspaceGitService.registerWorkspace({ cwd: normalizedCwd }, (snapshot) => {
handleBranchSnapshot(normalizedCwd, snapshot.git.currentBranch ?? null);
void emitWorkspaceUpdateForCwd(normalizedCwd).catch((error) => {
logger.warn(
{ err: error, cwd: normalizedCwd },
"Failed to emit workspace update after git branch snapshot",
);
});
emitStatusUpdate(normalizedCwd, snapshot);
subscription = workspaceGit.register((snapshot) => {
handleBranchSnapshot(address, snapshot.git.currentBranch ?? null);
for (const workspaceId of target.workspaceIds) {
void emitWorkspaceUpdateForWorkspaceId(workspaceId).catch((error) => {
logger.warn(
{ err: error, cwd: normalizedCwd, workspaceId },
"Failed to emit workspace update after git branch snapshot",
);
});
emitStatusUpdate(workspaceId, normalizedCwd, snapshot);
}
});
} catch (error) {
removeForWorkspaceId(options.workspaceId);
throw error;
}
subscriptions.set(normalizedCwd, subscription.unsubscribe);
subscriptions.set(workspaceGit, subscription.unsubscribe);
}
function syncObservers(workspaces: Iterable<WorkspaceDescriptorPayload>): void {
@@ -13,6 +13,7 @@ import {
type WorkspaceRegistry,
} from "../../workspace-registry.js";
import type { WorkspaceGitService } from "../../workspace-git-service.js";
import { createWorkspaceGitDirectory } from "../../workspace-git-directory.js";
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js";
import { deriveProjectKey } from "../../project-key.js";
import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../../../utils/path.js";
@@ -88,10 +89,17 @@ export function createWorkspaceProvisioningService(deps: {
serverId?: string;
workspaceRegistry: WorkspaceRegistry;
projectRegistry: ProjectRegistry;
workspaceGitService: Pick<WorkspaceGitService, "getCheckout" | "getSnapshot" | "peekSnapshot">;
workspaceGitService: Pick<
WorkspaceGitService,
"bindLegacy" | "bindWorkspace" | "getCheckout" | "getSnapshot" | "peekSnapshot"
>;
logger: Logger;
}): WorkspaceProvisioningService {
const { serverId, workspaceRegistry, projectRegistry, workspaceGitService, logger } = deps;
const workspaceGitDirectory = createWorkspaceGitDirectory({
workspaceRegistry,
workspaceGitService,
});
async function runInImportWorkspace<T>(
input: ImportWorkspaceInput,
@@ -330,7 +338,8 @@ export function createWorkspaceProvisioningService(deps: {
if (!workspace.archivedAt) {
return workspace.autoArchivedChangeRequestUrl;
}
const snapshot = await workspaceGitService.getSnapshot(workspace.cwd, {
if (workspace.runtime?.runtimeId) return workspace.autoArchivedChangeRequestUrl;
const snapshot = await workspaceGitDirectory.bindRecord(workspace).getSnapshot({
force: true,
includeForge: true,
reason: "workspace-restore-auto-archive-latch",
@@ -348,7 +357,7 @@ export function createWorkspaceProvisioningService(deps: {
const timestamp = new Date().toISOString();
const checkout =
workspace.archivedAt || project.archivedAt
? await workspaceGitService.getCheckout(workspace.cwd)
? await workspaceGitDirectory.bindRecord(workspace).getCheckout()
: null;
const autoArchivedChangeRequestUrl =
await resolveRestoredAutoArchiveChangeRequestUrl(workspace);
@@ -396,7 +405,7 @@ export function createWorkspaceProvisioningService(deps: {
async function refreshWorkspaceRecord(
workspace: PersistedWorkspaceRecord,
): Promise<PersistedWorkspaceRecord> {
const checkout = await workspaceGitService.getCheckout(workspace.cwd);
const checkout = await workspaceGitDirectory.bindRecord(workspace).getCheckout();
const project = await projectRegistry.get(workspace.projectId);
if (project && !project.archivedAt) {
await refreshProjectKind(project, workspace.cwd, checkout);
@@ -18,7 +18,7 @@ import type {
SpawnWorkspaceScriptOptions,
WorktreeScriptResult,
} from "../../worktree-bootstrap.js";
import type { WorkspaceGitService } from "../../workspace-git-service.js";
import type { WorkspaceGitWorkspace } from "../../workspace-git-service.js";
import { createWorkspaceScriptsService } from "./workspace-scripts-service.js";
import { deriveProjectServiceSlug } from "../../workspace-git-metadata.js";
import {
@@ -80,7 +80,7 @@ interface BuildOptions {
workspace?: PersistedWorkspaceRecord | null;
project?: PersistedProjectRecord | null;
spawnThrows?: string;
gitService?: Pick<WorkspaceGitService, "peekSnapshot">;
gitService?: Pick<WorkspaceGitWorkspace, "peekSnapshot">;
workspaceRuntime?: WorkspaceRuntimeService;
}
@@ -105,7 +105,9 @@ function buildService(options: BuildOptions = {}) {
options.terminalManager === undefined ? availableTerminalManager : options.terminalManager,
workspaceRegistry: fakeWorkspaceRegistry(workspace),
projectRegistry: fakeProjectRegistry(options.project ?? null),
workspaceGitService: options.gitService ?? fakeGitService(),
workspaceGitDirectory: {
bindRecord: () => options.gitService ?? fakeGitService(),
},
workspaceRuntime: options.workspaceRuntime,
getDaemonTcpPort: () => 6767,
getDaemonTcpHost: () => "127.0.0.1",
@@ -9,7 +9,7 @@ import type { TerminalManager } from "../../../terminal/terminal-manager.js";
import type { ServiceProxySubsystem } from "../../service-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "../../workspace-script-runtime-store.js";
import type { ScriptHealthState } from "../../script-health-monitor.js";
import type { WorkspaceGitService } from "../../workspace-git-service.js";
import type { WorkspaceGitDirectory } from "../../workspace-git-directory.js";
import type {
PersistedProjectRecord,
PersistedWorkspaceRecord,
@@ -52,15 +52,13 @@ export interface WorkspaceScriptsService {
start(request: StartWorkspaceScriptRequest): Promise<void>;
}
type WorkspaceScriptsGitSource = Pick<WorkspaceGitService, "peekSnapshot">;
export function createWorkspaceScriptsService(deps: {
serviceProxy: ServiceProxySubsystem | null;
scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
terminalManager: TerminalManager | null;
workspaceRegistry: Pick<WorkspaceRegistry, "get">;
projectRegistry: Pick<ProjectRegistry, "get">;
workspaceGitService: WorkspaceScriptsGitSource;
workspaceGitDirectory: Pick<WorkspaceGitDirectory, "bindRecord">;
workspaceRuntime?: WorkspaceRuntimeService;
getDaemonTcpPort: (() => number | null) | null;
getDaemonTcpHost: (() => string | null) | null;
@@ -77,7 +75,7 @@ export function createWorkspaceScriptsService(deps: {
terminalManager,
workspaceRegistry,
projectRegistry,
workspaceGitService,
workspaceGitDirectory,
workspaceRuntime,
getDaemonTcpPort,
getDaemonTcpHost,
@@ -93,7 +91,7 @@ export function createWorkspaceScriptsService(deps: {
workspace: PersistedWorkspaceRecord,
project: { projectId: string; rootPath: string } | null,
) {
const snapshot = workspaceGitService.peekSnapshot(workspace.cwd);
const snapshot = workspaceGitDirectory.bindRecord(workspace).peekSnapshot();
const currentBranch = snapshot?.git.currentBranch ?? workspace.branch ?? null;
if (project) {
return {
@@ -73,7 +73,7 @@ async function create(id, request) {
const state = {
workspaceId: id,
root,
revision: "fixture:1",
revision: `fixture:${key(`${id}:${Date.now()}:${process.pid}`)}`,
executionDomainId: "fixture",
lifecycle: "ready",
};
@@ -105,10 +105,12 @@ async function execute(id) {
const envelope = JSON.parse(first.value);
const state = await readState(id, envelope.options);
if (!state) throw new Error(`Fixture workspace is missing: ${id}`);
await writeFile(
path.join(state.root, ".runtime-launch.json"),
JSON.stringify({ argv: process.argv, purpose: envelope.purpose }),
);
if (envelope.options.recordLaunchInWorkspace !== false) {
await writeFile(
path.join(state.root, ".runtime-launch.json"),
JSON.stringify({ argv: process.argv, purpose: envelope.purpose }),
);
}
if (envelope.stdio.kind === "pty") {
await executePty(id, envelope, iterator, control);
control.destroy();
@@ -1,6 +1,10 @@
import type { CheckoutDiffResult } from "../../utils/checkout-git.js";
import { deriveProjectSlug } from "../workspace-git-metadata.js";
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "../workspace-git-service.js";
import type {
WorkspaceGitRuntimeSnapshot,
WorkspaceGitService,
WorkspaceGitWorkspace,
} from "../workspace-git-service.js";
export function createNoGitWorkspaceRuntimeSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
return {
@@ -31,7 +35,10 @@ export function createNoGitWorkspaceRuntimeSnapshot(cwd: string): WorkspaceGitRu
export function createNoopWorkspaceGitService(
overrides: Partial<WorkspaceGitService> = {},
): WorkspaceGitService {
const service: WorkspaceGitService = {
let service!: WorkspaceGitService;
service = {
bindWorkspace: ({ cwd }) => bindWorkspaceGitService(service, cwd),
bindLegacy: (cwd) => bindWorkspaceGitService(service, cwd),
registerWorkspace: () => ({
unsubscribe: () => {},
}),
@@ -60,6 +67,19 @@ export function createNoopWorkspaceGitService(
return deriveProjectSlug(cwd, snapshot.git.isGit ? snapshot.git.remoteUrl : null);
},
resolveForge: async () => null,
commit: async () => {},
createBranch: async () => {},
switchBranch: async () => ({ source: "local" }),
fetch: async () => {},
listCommits: async () => ({ baseRef: null, commits: [] }),
getCommitFileDiff: async () => null,
stashPush: async () => {},
stashPop: async () => {},
mergeToBase: async (cwd) => cwd,
mergeFromBase: async () => {},
pull: async () => {},
push: async () => {},
renameBranch: async (_cwd, branch) => ({ previousBranch: null, currentBranch: branch }),
resolveRepoRoot: async (cwd: string) => cwd,
resolveDefaultBranch: async () => "main",
resolveRepoRemoteUrl: async () => null,
@@ -118,3 +138,59 @@ export function createNoopWorkspaceGitService(
return service;
}
export function bindWorkspaceGitService(
service: WorkspaceGitService,
cwd: string,
): WorkspaceGitWorkspace {
return {
cwd,
register: (listener) => service.registerWorkspace({ cwd }, listener),
observe: async (listener) => service.registerWorkspace({ cwd }, listener),
peekSnapshot: () => service.peekSnapshot(cwd),
getCheckout: () => service.getCheckout(cwd),
getSnapshot: (options) =>
options === undefined ? service.getSnapshot(cwd) : service.getSnapshot(cwd, options),
resolveForge: () => service.resolveForge(cwd),
getCheckoutDiff: (options, readOptions) => service.getCheckoutDiff(cwd, options, readOptions),
validateBranchRef: (ref, options) =>
options === undefined
? service.validateBranchRef(cwd, ref)
: service.validateBranchRef(cwd, ref, options),
hasLocalBranch: (branch, options) =>
options === undefined
? service.hasLocalBranch(cwd, branch)
: service.hasLocalBranch(cwd, branch, options),
suggestBranches: (options, readOptions) =>
readOptions === undefined
? service.suggestBranchesForCwd(cwd, options)
: service.suggestBranchesForCwd(cwd, options, readOptions),
listStashes: (options, readOptions) =>
readOptions === undefined
? service.listStashes(cwd, options)
: service.listStashes(cwd, options, readOptions),
listWorktrees: (options) => service.listWorktrees(cwd, options),
getProjectSlug: (options) => service.getProjectSlug(cwd, options),
resolveRepoRoot: (options) => service.resolveRepoRoot(cwd, options),
resolveDefaultBranch: (options) => service.resolveDefaultBranch(cwd, options),
resolveRepoRemoteUrl: (options) => service.resolveRepoRemoteUrl(cwd, options),
commit: (options) => service.commit(cwd, options),
createBranch: (options) => service.createBranch(cwd, options),
switchBranch: (branch) => service.switchBranch(cwd, branch),
fetch: () => service.fetch(cwd),
listCommits: () => service.listCommits(cwd),
getCommitFileDiff: (input) => service.getCommitFileDiff(cwd, input),
stashPush: (message) => service.stashPush(cwd, message),
stashPop: (stashIndex) => service.stashPop(cwd, stashIndex),
mergeToBase: (options) => service.mergeToBase(cwd, options),
mergeFromBase: (options) => service.mergeFromBase(cwd, options),
pull: () => service.pull(cwd),
push: () => service.push(cwd),
renameBranch: (branch) => service.renameBranch(cwd, branch),
refresh: (options) => service.refresh(cwd, options),
requestWorkingTreeWatch: (onChange) => service.requestWorkingTreeWatch(cwd, onChange),
scheduleRefresh: () => service.scheduleRefreshForCwd(cwd),
stateMayHaveChanged: () => service.onWorkspaceStateMayHaveChanged(cwd),
invalidateForge: () => service.invalidateForge(cwd),
};
}
@@ -29,11 +29,13 @@ export interface ParsedDiffFile {
interface HighlightDiffWithFileContentOptions {
oldFileContent?: string | null;
newFileContent?: string | null;
allowFileRead?: boolean;
}
interface ParseAndHighlightDiffOptions {
getOldFileContent?: (file: ParsedDiffFile) => Promise<string | null>;
getNewFileContent?: (file: ParsedDiffFile) => Promise<string | null>;
allowFileRead?: boolean;
}
/**
@@ -319,6 +321,10 @@ export async function highlightDiffWithFileContent(
return applyTokensToHunks(file, newTokensByLine, oldTokensByLine);
}
if (options.allowFileRead === false) {
return applyTokensToHunks(file, newTokensByLine, oldTokensByLine);
}
const filePath = resolve(cwd, file.path);
try {
const fileContent = await readFile(filePath, "utf-8");
@@ -388,6 +394,7 @@ export async function parseAndHighlightDiff(
return highlightDiffWithFileContent(file, cwd, {
oldFileContent: oldFileContent ?? undefined,
newFileContent: newFileContent ?? undefined,
allowFileRead: options.allowFileRead,
});
}),
);
@@ -197,6 +197,12 @@ function createFallbackWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSna
function createFallbackWorkspaceGitService(): WorkspaceGitService {
return {
bindWorkspace: ({ workspaceId }) => {
throw new Error(`Workspace Git runtime is not available: ${workspaceId}`);
},
bindLegacy: () => {
throw new Error("Workspace Git service is not available");
},
registerWorkspace: () => ({
unsubscribe: () => {},
}),
@@ -215,6 +221,19 @@ function createFallbackWorkspaceGitService(): WorkspaceGitService {
}),
getSnapshot: async (cwd: string) => createFallbackWorkspaceGitSnapshot(cwd),
resolveForge: async () => null,
commit: async () => {},
createBranch: async () => {},
switchBranch: async () => ({ source: "local" }),
fetch: async () => {},
listCommits: async () => ({ baseRef: null, commits: [] }),
getCommitFileDiff: async () => null,
stashPush: async () => {},
stashPop: async () => {},
mergeToBase: async (cwd) => cwd,
mergeFromBase: async () => {},
pull: async () => {},
push: async () => {},
renameBranch: async (_cwd, branch) => ({ previousBranch: null, currentBranch: branch }),
getCheckoutDiff: async () => ({ diff: "" }),
validateBranchRef: async () => ({ kind: "not-found" }),
hasLocalBranch: async () => false,
@@ -0,0 +1,78 @@
import { expect, test, vi } from "vitest";
import { createWorkspaceGitDirectory } from "./workspace-git-directory.js";
import type { WorkspaceGitWorkspace } from "./workspace-git-service.js";
test("selected Git addresses cannot become legacy through empty or omitted identity", async () => {
const cwd = "/shared";
const selected = { cwd } as WorkspaceGitWorkspace;
const legacy = { cwd } as WorkspaceGitWorkspace;
const record = {
workspaceId: "workspace-selected",
cwd,
runtime: { runtimeId: "command" },
};
const get = vi.fn(async (workspaceId: string) =>
workspaceId === record.workspaceId ? record : null,
);
const directory = createWorkspaceGitDirectory({
workspaceRegistry: { get, list: async () => [record] },
workspaceGitService: {
bindWorkspace: () => selected,
bindLegacy: () => legacy,
},
});
for (const workspaceId of ["", " "]) {
await expect(directory.resolve({ kind: "selected", workspaceId, cwd })).rejects.toThrow(
"workspaceId is required for selected workspace Git",
);
}
await expect(directory.resolve({ kind: "legacy", cwd })).rejects.toThrow(
"workspaceId is required for a selected workspace Git operation",
);
expect(get).not.toHaveBeenCalled();
});
test.each(["", " "])(
"selected Git addresses reject cwd %j before registry lookup",
async (cwd) => {
const get = vi.fn();
const directory = createWorkspaceGitDirectory({
workspaceRegistry: { get, list: vi.fn() },
workspaceGitService: {
bindWorkspace: vi.fn(),
bindLegacy: vi.fn(),
},
});
await expect(
directory.resolve({ kind: "selected", workspaceId: "workspace-a", cwd }),
).rejects.toThrow("cwd is required for selected workspace Git");
expect(get).not.toHaveBeenCalled();
},
);
test("selected Git addresses normalize both fields before registry lookup", async () => {
const record = {
workspaceId: "workspace-a",
cwd: "/shared",
runtime: { runtimeId: "command" },
};
const get = vi.fn(async () => record);
const selected = { cwd: record.cwd } as WorkspaceGitWorkspace;
const bindWorkspace = vi.fn(() => selected);
const directory = createWorkspaceGitDirectory({
workspaceRegistry: { get, list: vi.fn() },
workspaceGitService: { bindWorkspace, bindLegacy: vi.fn() },
});
await expect(
directory.resolve({
kind: "selected",
workspaceId: " workspace-a ",
cwd: " /shared ",
}),
).resolves.toBe(selected);
expect(get).toHaveBeenCalledWith("workspace-a");
expect(bindWorkspace).toHaveBeenCalledWith({ workspaceId: "workspace-a", cwd: "/shared" });
});
@@ -0,0 +1,112 @@
import { resolve } from "node:path";
import type { WorkspaceGitService, WorkspaceGitWorkspace } from "./workspace-git-service.js";
import {
type PersistedWorkspaceRecord,
resolveSelectedWorkspaceRuntimeId,
type WorkspaceRegistry,
} from "./workspace-registry.js";
export type WorkspaceGitAddress =
| { readonly kind: "selected"; readonly workspaceId: string; readonly cwd: string }
| { readonly kind: "legacy"; readonly cwd: string };
export interface WorkspaceGitDirectory {
resolve(address: WorkspaceGitAddress): Promise<WorkspaceGitWorkspace>;
bindRecord(
record: Pick<PersistedWorkspaceRecord, "workspaceId" | "cwd" | "runtime">,
): WorkspaceGitWorkspace;
getBound(workspaceId: string, cwd: string): WorkspaceGitWorkspace;
getObservationBinding(
workspaceId: string,
cwd: string,
): {
address: WorkspaceGitAddress;
workspaceGit: WorkspaceGitWorkspace;
};
}
/** Commits request compatibility once, before ordinary Git callers receive a capability. */
export function createWorkspaceGitDirectory(options: {
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "list">;
workspaceGitService: Pick<WorkspaceGitService, "bindLegacy" | "bindWorkspace">;
}): WorkspaceGitDirectory {
const { workspaceRegistry, workspaceGitService } = options;
const boundRecords = new Map<
string,
{ cwd: string; selected: boolean; workspaceGit: WorkspaceGitWorkspace }
>();
function bindRecord(
record: Pick<PersistedWorkspaceRecord, "workspaceId" | "cwd" | "runtime">,
): WorkspaceGitWorkspace {
const cwd = resolve(record.cwd);
const existing = boundRecords.get(record.workspaceId);
if (existing) {
if (existing.cwd !== cwd)
throw new Error(`Workspace Git binding changed cwd: ${record.workspaceId}`);
return existing.workspaceGit;
}
const selected = record.runtime?.runtimeId !== undefined;
const workspaceGit = selected
? workspaceGitService.bindWorkspace({
workspaceId: record.workspaceId,
cwd,
})
: workspaceGitService.bindLegacy(cwd);
boundRecords.set(record.workspaceId, { cwd, selected, workspaceGit });
return workspaceGit;
}
return {
bindRecord,
getBound(workspaceId, cwd) {
const bound = boundRecords.get(workspaceId);
if (!bound || bound.cwd !== resolve(cwd)) {
throw new Error(`Workspace Git is not bound: ${workspaceId}`);
}
return bound.workspaceGit;
},
getObservationBinding(workspaceId, cwd) {
const normalizedCwd = resolve(cwd);
const bound = boundRecords.get(workspaceId);
if (!bound || bound.cwd !== normalizedCwd) {
throw new Error(`Workspace Git is not bound: ${workspaceId}`);
}
return {
address: bound.selected
? { kind: "selected", workspaceId, cwd: normalizedCwd }
: { kind: "legacy", cwd: normalizedCwd },
workspaceGit: bound.workspaceGit,
};
},
async resolve(address) {
if (address.kind === "selected") {
const workspaceId = address.workspaceId.trim();
if (workspaceId.length === 0) {
throw new Error("workspaceId is required for selected workspace Git");
}
const selectedCwd = address.cwd.trim();
if (selectedCwd.length === 0) {
throw new Error("cwd is required for selected workspace Git");
}
const cwd = resolve(selectedCwd);
const record = await workspaceRegistry.get(workspaceId);
if (!record) throw new Error(`Workspace not found: ${workspaceId}`);
if (resolve(record.cwd) !== cwd) {
throw new Error(`Workspace cwd does not match ${workspaceId}`);
}
return bindRecord(record);
}
const cwd = resolve(address.cwd);
const selectedAtCwd = (await workspaceRegistry.list()).some(
(record) =>
resolveSelectedWorkspaceRuntimeId(record) !== null && resolve(record.cwd) === cwd,
);
if (selectedAtCwd) {
throw new Error("workspaceId is required for a selected workspace Git operation");
}
return workspaceGitService.bindLegacy(cwd);
},
};
}
@@ -174,6 +174,7 @@ async function measureFetchScenario(
snapshotUpdates: number;
}> {
const fetchStarted = createDeferred<void>();
const fetchCompleted = createDeferred<void>();
const releaseFetch = createDeferred<void>();
let fetchCount = 0;
let fetchChangedNonRemoteRefs: boolean | undefined;
@@ -188,6 +189,7 @@ async function measureFetchScenario(
const result = await fetchWorkspaceGitRemote(cwd, observer);
fetchChangedNonRemoteRefs = result.nonRemoteRefsChanged;
fetchCount += 1;
fetchCompleted.resolve();
return result;
},
},
@@ -217,6 +219,7 @@ async function measureFetchScenario(
startGitCommandMetrics();
releaseFetch.resolve();
await duringFetch?.();
await fetchCompleted.promise;
await waitForGitCommandMetricsIdle({ quietMs, timeoutMs: 120_000 });
const postFetch = stopGitCommandMetrics();
const snapshotUpdatesAfterFetch = [...snapshotCounts].reduce(
@@ -0,0 +1,288 @@
import type { BoundWorkspaceRuntime, WorkspaceProcess } from "./workspace-runtime/index.js";
const MAX_COMMAND_OUTPUT_BYTES = 64 * 1024;
const WATCH_SCRIPT = String.raw`
const fs = require("node:fs");
const watcher = fs.watch(process.argv[1], { recursive: true }, () => process.stdout.write("changed\n"));
watcher.on("error", error => { process.stderr.write(String(error)); process.exit(1); });
process.stdout.write("ready\n");
`;
const STAT_SCRIPT = String.raw`
const fs = require("node:fs");
const stat = fs.statSync(process.argv[1], { bigint: true });
let mountNamespace = "host";
try { mountNamespace = fs.readlinkSync("/proc/self/ns/mnt"); } catch {}
process.stdout.write(mountNamespace + ":" + String(stat.dev) + ":" + String(stat.ino));
`;
interface CommonRefMember {
runtime: BoundWorkspaceRuntime;
listener: () => void;
}
interface CommonRefGroup {
key: string;
commonDir: string;
members: Map<symbol, CommonRefMember>;
process: WorkspaceProcess | null;
owner: symbol | null;
stopping: boolean;
lifecycle: Promise<void>;
}
const commonRefGroups = new Map<string, CommonRefGroup>();
export async function observeWorkspaceGit(
runtime: BoundWorkspaceRuntime,
listener: () => void,
): Promise<{ unsubscribe(): Promise<void> }> {
let ready = false;
const workingTree = await runtime.files.subscribe(
{ paths: ["."], recursive: true, ignoredPaths: [".git"] },
(event) => {
if (ready && event.type !== "error") listener();
},
);
let commonRefs: { unsubscribe(): Promise<void> } | null = null;
try {
const commonDir = await resolveCommonGitDirectory(runtime);
if (commonDir) commonRefs = await subscribeCommonRefs(runtime, commonDir, listener);
ready = true;
} catch (error) {
await workingTree.unsubscribe();
throw error;
}
let closed = false;
return {
async unsubscribe() {
if (closed) return;
closed = true;
await Promise.all([workingTree.unsubscribe(), commonRefs?.unsubscribe()]);
},
};
}
async function subscribeCommonRefs(
runtime: BoundWorkspaceRuntime,
commonDir: string,
listener: () => void,
): Promise<{ unsubscribe(): Promise<void> }> {
const key = await resolveFilesystemIdentity(runtime, commonDir);
let group = commonRefGroups.get(key);
if (!group) {
group = {
key,
commonDir,
members: new Map(),
process: null,
owner: null,
stopping: false,
lifecycle: Promise.resolve(),
};
commonRefGroups.set(key, group);
}
const memberId = Symbol(key);
group.members.set(memberId, { runtime, listener });
try {
await updateGroupLifecycle(group, async () => {
if (!group.process) await startGroup(group);
});
} catch (error) {
await updateGroupLifecycle(group, async () => {
group.members.delete(memberId);
if (group.members.size === 0) {
commonRefGroups.delete(key);
await stopGroup(group);
}
});
throw error;
}
let closed = false;
return {
async unsubscribe() {
if (closed) return;
closed = true;
await updateGroupLifecycle(group, async () => {
const wasOwner = group.owner === memberId;
group.members.delete(memberId);
if (group.members.size === 0) {
commonRefGroups.delete(key);
await stopGroup(group);
} else if (wasOwner) {
await stopGroup(group);
if (group.members.size > 0) await startGroup(group);
}
});
},
};
}
function updateGroupLifecycle<T>(group: CommonRefGroup, update: () => Promise<T>): Promise<T> {
const result = group.lifecycle.then(update, update);
group.lifecycle = result.then(
() => undefined,
() => undefined,
);
return result;
}
async function startGroup(group: CommonRefGroup, excludedOwner?: symbol): Promise<void> {
const candidates = [...group.members].filter(([id]) => id !== excludedOwner);
if (candidates.length === 0 && excludedOwner) {
candidates.push(...group.members);
}
let lastError: unknown = null;
for (const [memberId, member] of candidates) {
try {
const process = await member.runtime.run({
argv: ["node", "-e", WATCH_SCRIPT, group.commonDir],
env: runtimeCommandEnvironment(),
purpose: { kind: "git" },
});
process.stdin.end();
group.process = process;
group.owner = memberId;
group.stopping = false;
await waitForReady(process, () => {
if (group.process !== process) return;
for (const current of group.members.values()) current.listener();
});
void drain(process.stderr);
void process.exited.then(() => handleGroupExit(group, process, memberId));
return;
} catch (error) {
const failedProcess = group.process;
group.process = null;
group.owner = null;
if (failedProcess) {
failedProcess.kill("SIGKILL");
await failedProcess.exited.catch(() => undefined);
}
lastError = error;
}
}
throw lastError ?? new Error("Git common-ref observer has no runtime owner");
}
async function stopGroup(group: CommonRefGroup): Promise<void> {
const process = group.process;
group.process = null;
group.owner = null;
if (!process) return;
group.stopping = true;
process.kill("SIGTERM");
await process.exited.catch(() => undefined);
group.stopping = false;
}
function handleGroupExit(group: CommonRefGroup, process: WorkspaceProcess, owner: symbol): void {
void updateGroupLifecycle(group, async () => {
if (group.process !== process) return;
group.process = null;
group.owner = null;
if (group.stopping || group.members.size === 0 || commonRefGroups.get(group.key) !== group)
return;
await startGroup(group, owner);
if (commonRefGroups.get(group.key) === group) {
for (const member of group.members.values()) member.listener();
}
}).catch(() => undefined);
}
function waitForReady(process: WorkspaceProcess, onChange: () => void): Promise<void> {
return new Promise((resolve, reject) => {
let buffered = "";
let ready = false;
process.stdout.on("data", (chunk) => {
buffered += Buffer.from(chunk).toString("utf8");
const lines = buffered.split("\n");
buffered = lines.pop() ?? "";
for (const line of lines) {
if (line === "ready" && !ready) {
ready = true;
resolve();
} else if (line === "changed" && ready) {
onChange();
}
}
if (!ready && Buffer.byteLength(buffered) > MAX_COMMAND_OUTPUT_BYTES) {
process.kill("SIGKILL");
reject(new Error("Git observer startup output exceeded 64 KiB"));
}
});
void process.exited.then(() => {
if (!ready) reject(new Error("Git common-ref observer exited before ready"));
return undefined;
});
});
}
async function resolveCommonGitDirectory(runtime: BoundWorkspaceRuntime): Promise<string | null> {
const result = await runBoundCommand(runtime, [
"git",
"rev-parse",
"--path-format=absolute",
"--git-common-dir",
]);
if (result.exit.code !== 0 || result.exit.signal !== null) return null;
return result.stdout.trim() || null;
}
async function resolveFilesystemIdentity(
runtime: BoundWorkspaceRuntime,
path: string,
): Promise<string> {
const result = await runBoundCommand(runtime, ["node", "-e", STAT_SCRIPT, path]);
if (result.exit.code !== 0 || result.exit.signal !== null || !result.stdout.trim()) {
throw new Error(`Could not identify Git common directory: ${result.stderr.trim()}`);
}
return result.stdout.trim();
}
async function runBoundCommand(
runtime: BoundWorkspaceRuntime,
argv: readonly [string, ...string[]],
) {
const process = await runtime.run({
argv,
env: runtimeCommandEnvironment(),
purpose: { kind: "git" },
});
process.stdin.end();
const [stdout, stderr, exit] = await Promise.all([
collect(process.stdout, process),
collect(process.stderr, process),
process.exited,
]);
return { stdout, stderr, exit };
}
async function collect(stream: NodeJS.ReadableStream, process: WorkspaceProcess): Promise<string> {
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of stream) {
const bytes = Buffer.from(chunk);
size += bytes.length;
if (size > MAX_COMMAND_OUTPUT_BYTES) {
process.kill("SIGKILL");
throw new Error("Git observation command output exceeded 64 KiB");
}
chunks.push(bytes);
}
return Buffer.concat(chunks).toString("utf8");
}
async function drain(stream: NodeJS.ReadableStream): Promise<void> {
for await (const _chunk of stream) {
// Keep the long-running observer's diagnostic pipe flowing.
}
}
function runtimeCommandEnvironment(): Readonly<Record<string, string>> {
return {
PATH: ["/usr/local/bin", "/usr/bin", "/bin", process.env.PATH].filter(Boolean).join(":"),
};
}
@@ -618,10 +618,12 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
test("non-forced getSnapshot returns the current snapshot during an in-flight refresh", async () => {
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
const refreshStatus = createDeferred<CheckoutStatusGit>();
const refreshStarted = createDeferred<void>();
const getCheckoutStatus = vi
.fn<(cwd: string) => Promise<CheckoutStatusGit>>()
.mockImplementationOnce(async (cwd: string) => createCheckoutStatus(cwd))
.mockImplementationOnce(async () => {
refreshStarted.resolve();
const status = await refreshStatus.promise;
return { ...status, currentBranch: "feature" };
});
@@ -644,7 +646,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
nowMs += 3_000;
const refresh = service.refresh(REPO_CWD);
await flushPromises();
await refreshStarted.promise;
const directRead = service.getSnapshot(REPO_CWD);
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
@@ -0,0 +1,726 @@
import { execFileSync, spawn } from "node:child_process";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, expect, test, vi } from "vitest";
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
import { observeWorkspaceGit } from "./workspace-git-observation.js";
import { createWorkspaceRuntimeService } from "./workspace-runtime/index.js";
const fixtureExecutable = fileURLToPath(
new URL("./test-utils/fixtures/workspace-runtime-command-fixture.mjs", import.meta.url),
);
const helperExecutable = fileURLToPath(
new URL("./workspace-helper/executable.mjs", import.meta.url),
);
const cleanupRoots: string[] = [];
function eventWithin(event: Promise<void>, label: string): Promise<void> {
return Promise.race([
event,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`Timed out waiting for ${label}`)), 5_000),
),
]);
}
afterEach(async () => {
await Promise.all(
cleanupRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
});
test.each(["local", "worktree"] as const)(
"selected %s workspace Git uses the same bound runtime journey",
async (runtimeId) => {
const root = await mkdtemp(path.join(tmpdir(), `paseo-${runtimeId}-git-`));
cleanupRoots.push(root);
const source = await createRepository(path.join(root, "source"));
const publicAddress = path.join(root, "public-workspace-address");
const workspaceId = `${runtimeId}-git-workspace`;
const runtimeIds = new Map<string, string>();
const workspaceRuntime = createWorkspaceRuntimeService({
paseoHome: path.join(root, "paseo-home"),
worktreesRoot: path.join(root, "worktrees"),
resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null,
persistRuntimeId: async (id, selectedRuntimeId) => {
runtimeIds.set(id, selectedRuntimeId);
},
});
await workspaceRuntime.create({
workspaceId,
runtimeId,
project: { id: `${runtimeId}-project`, source: { kind: "host-directory", path: source } },
placement:
runtimeId === "local"
? { kind: "existing" }
: {
kind: "branch",
branchName: "slice-4-worktree",
baseRef: "main",
worktreeSlug: "slice-4-worktree",
},
});
const workspaceGit = new WorkspaceGitServiceImpl({
logger: createLogger(),
paseoHome: path.join(root, "paseo-home"),
worktreesRoot: path.join(root, "worktrees"),
workspaceRuntime,
});
const selectedGit = workspaceGit.bindWorkspace({ workspaceId, cwd: publicAddress });
await workspaceRuntime.files(workspaceId).write({
path: "tracked.txt",
contents: Buffer.from(`${runtimeId} edit\n`),
});
const dirty = await selectedGit.getSnapshot({
force: true,
includeForge: false,
reason: `${runtimeId}-edit`,
});
const diff = await selectedGit.getCheckoutDiff(
{ mode: "uncommitted", includeStructured: true },
{ force: true, reason: `${runtimeId}-edit` },
);
expect(dirty.git).toMatchObject({ isGit: true, isDirty: true });
expect(JSON.stringify(diff)).toContain(`${runtimeId} edit`);
const originalBranch = runtimeId === "local" ? "main" : "slice-4-worktree";
await selectedGit.commit({ message: `${runtimeId} commit`, addAll: true });
await selectedGit.createBranch({
branch: `${runtimeId}-next`,
baseRef: originalBranch,
});
await selectedGit.switchBranch(originalBranch);
const clean = await selectedGit.getSnapshot();
expect(clean.git).toMatchObject({ isDirty: false, currentBranch: originalBranch });
await workspaceGit.dispose();
await workspaceRuntime.destroy(workspaceId);
},
20_000,
);
test("selected sibling worktrees share common-ref fan-out without touching an unrelated workspace", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-git-fanout-"));
cleanupRoots.push(root);
const source = await createRepository(path.join(root, "source"));
const unrelated = await createRepository(path.join(root, "unrelated"));
const runtimeIds = new Map<string, string>();
const service = createWorkspaceRuntimeService({
paseoHome: path.join(root, "paseo-home"),
worktreesRoot: path.join(root, "worktrees"),
resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null,
persistRuntimeId: async (id, runtimeId) => {
runtimeIds.set(id, runtimeId);
},
});
for (const [workspaceId, branchName] of [
["sibling-a", "sibling-a"],
["sibling-b", "sibling-b"],
] as const) {
await service.create({
workspaceId,
runtimeId: "worktree",
project: { id: "fanout-project", source: { kind: "host-directory", path: source } },
placement: { kind: "branch", branchName, baseRef: "main", worktreeSlug: branchName },
});
}
await service.create({
workspaceId: "unrelated",
runtimeId: "local",
project: { id: "unrelated-project", source: { kind: "host-directory", path: unrelated } },
placement: { kind: "existing" },
});
let resolveFirst!: () => void;
let resolveSibling!: () => void;
const firstChanged = new Promise<void>((resolve) => {
resolveFirst = resolve;
});
const siblingChanged = new Promise<void>((resolve) => {
resolveSibling = resolve;
});
let phase: "initial" | "first-change" | "owner-reselected" | "after-pause-change" = "initial";
let resolveSiblingOwnerReselected!: () => void;
const siblingOwnerReselected = new Promise<void>((resolve) => {
resolveSiblingOwnerReselected = resolve;
});
let resolveSiblingAfterOwnerPause!: () => void;
const siblingChangedAfterOwnerPause = new Promise<void>((resolve) => {
resolveSiblingAfterOwnerPause = resolve;
});
let unrelatedChanges = 0;
const siblingA = await service.bind("sibling-a");
const siblingB = await service.bind("sibling-b");
const unrelatedGit = await service.bind("unrelated");
const subscriptions = [
await observeWorkspaceGit(siblingA, () => {
if (phase === "first-change") resolveFirst();
}),
await observeWorkspaceGit(siblingB, () => {
if (phase === "first-change") resolveSibling();
if (phase === "owner-reselected") resolveSiblingOwnerReselected();
if (phase === "after-pause-change") resolveSiblingAfterOwnerPause();
}),
await observeWorkspaceGit(unrelatedGit, () => {
unrelatedChanges += 1;
}),
];
unrelatedChanges = 0;
phase = "first-change";
const branch = await service.run({
workspaceId: "sibling-a",
argv: ["git", "branch", "shared-ref-change"],
env: { PATH: "/usr/local/bin:/usr/bin:/bin" },
purpose: { kind: "git" },
});
branch.stdin.end();
await expect(branch.exited).resolves.toEqual({ code: 0, signal: null });
await Promise.all([
eventWithin(firstChanged, "first sibling ref update"),
eventWithin(siblingChanged, "second sibling ref update"),
]);
expect(unrelatedChanges).toBe(0);
phase = "owner-reselected";
await service.pause("sibling-a");
await eventWithin(siblingOwnerReselected, "common-ref observer owner re-selection");
phase = "after-pause-change";
const branchAfterPause = await service.run({
workspaceId: "sibling-b",
argv: ["git", "branch", "shared-ref-after-owner-pause"],
env: { PATH: "/usr/local/bin:/usr/bin:/bin" },
purpose: { kind: "git" },
});
branchAfterPause.stdin.end();
await expect(branchAfterPause.exited).resolves.toEqual({ code: 0, signal: null });
await eventWithin(siblingChangedAfterOwnerPause, "sibling ref update after owner pause");
expect(unrelatedChanges).toBe(0);
await Promise.all(subscriptions.map((subscription) => subscription.unsubscribe()));
await Promise.all([service.destroy("sibling-a"), service.destroy("sibling-b")]);
await service.destroy("unrelated");
}, 20_000);
test("concurrent sibling subscriptions own exactly one common-ref watcher until final unsubscribe", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-git-watcher-race-"));
cleanupRoots.push(root);
const source = await createRepository(path.join(root, "source"));
const runtimeIds = new Map<string, string>();
const service = createWorkspaceRuntimeService({
paseoHome: path.join(root, "paseo-home"),
worktreesRoot: path.join(root, "worktrees"),
resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null,
persistRuntimeId: async (id, runtimeId) => {
runtimeIds.set(id, runtimeId);
},
});
for (const workspaceId of ["race-a", "race-b"] as const) {
await service.create({
workspaceId,
runtimeId: "worktree",
project: { id: "race-project", source: { kind: "host-directory", path: source } },
placement: {
kind: "branch",
branchName: workspaceId,
baseRef: "main",
worktreeSlug: workspaceId,
},
});
}
let releaseLaunch!: () => void;
const launchReleased = new Promise<void>((resolve) => {
releaseLaunch = resolve;
});
let firstLaunchEntered!: () => void;
const firstLaunch = new Promise<void>((resolve) => {
firstLaunchEntered = resolve;
});
let watcherLaunches = 0;
let activeWatchers = 0;
let maximumActiveWatchers = 0;
const instrument = async (workspaceId: string) => {
const runtime = await service.bind(workspaceId);
return {
files: runtime.files,
run: async (input: Parameters<typeof runtime.run>[0]) => {
const isCommonRefWatcher =
input.argv[0] === "node" &&
input.argv[1] === "-e" &&
input.argv[2]?.includes("fs.watch(process.argv[1]") === true;
if (isCommonRefWatcher) {
watcherLaunches += 1;
firstLaunchEntered();
await launchReleased;
}
const process = await runtime.run(input);
if (isCommonRefWatcher) {
activeWatchers += 1;
maximumActiveWatchers = Math.max(maximumActiveWatchers, activeWatchers);
void process.exited.finally(() => {
activeWatchers -= 1;
});
}
return process;
},
};
};
const [runtimeA, runtimeB] = await Promise.all([instrument("race-a"), instrument("race-b")]);
const subscriptionsPromise = Promise.all([
observeWorkspaceGit(runtimeA, () => undefined),
observeWorkspaceGit(runtimeB, () => undefined),
]);
await firstLaunch;
await new Promise<void>((resolve) => setImmediate(resolve));
expect(watcherLaunches).toBe(1);
releaseLaunch();
const subscriptions = await subscriptionsPromise;
expect(maximumActiveWatchers).toBe(1);
await subscriptions[0].unsubscribe();
expect(activeWatchers).toBe(1);
await subscriptions[1].unsubscribe();
expect(activeWatchers).toBe(0);
await Promise.all([service.destroy("race-a"), service.destroy("race-b")]);
}, 20_000);
test("selected workspace Git reads and mutations stay inside its command runtime", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-git-"));
cleanupRoots.push(root);
const runtimeRepository = await createRepository(path.join(root, "runtime-repository"));
const hostDecoy = await createRepository(path.join(root, "host-decoy"));
const remoteRepository = path.join(root, "remote.git");
await mkdir(remoteRepository);
git(remoteRepository, "init", "--bare", "--initial-branch=main");
git(runtimeRepository, "remote", "add", "origin", remoteRepository);
git(runtimeRepository, "push", "-u", "origin", "main");
const stateDirectory = path.join(root, "runtime-state");
await mkdir(stateDirectory);
const workspaceId = "runtime-git-workspace";
const runtimeIds = new Map<string, string>();
const workspaceRuntime = createWorkspaceRuntimeService({
paseoHome: path.join(root, "paseo-home"),
resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null,
persistRuntimeId: async (id, runtimeId) => {
runtimeIds.set(id, runtimeId);
},
externalRuntimes: {
fixture: {
type: "command",
command: [process.execPath, fixtureExecutable],
helperCommand: [process.execPath, helperExecutable],
options: { stateDirectory, recordLaunchInWorkspace: false },
},
},
});
await workspaceRuntime.create({
workspaceId,
runtimeId: "fixture",
project: {
id: "runtime-git-project",
source: { kind: "host-directory", path: runtimeRepository },
},
placement: { kind: "existing" },
});
const service = new WorkspaceGitServiceImpl({
logger: createLogger(),
paseoHome: path.join(root, "paseo-home"),
workspaceRuntime,
});
const selectedGit = service.bindWorkspace({ workspaceId, cwd: hostDecoy });
await workspaceRuntime.files(workspaceId).write({
path: "tracked.txt",
contents: Buffer.from("runtime edit\n"),
});
await writeFile(path.join(hostDecoy, "tracked.txt"), "host edit\n");
git(hostDecoy, "branch", "host-only");
const dirty = await selectedGit.getSnapshot({
force: true,
includeForge: false,
reason: "runtime-edit",
});
const diff = await selectedGit.getCheckoutDiff(
{ mode: "uncommitted", includeStructured: true },
{ force: true, reason: "runtime-edit" },
);
expect(dirty.git).toMatchObject({ isGit: true, isDirty: true, currentBranch: "main" });
expect(dirty.git.repoRoot).toBe(hostDecoy);
expect(JSON.stringify(dirty)).not.toContain(runtimeRepository);
expect(JSON.stringify(diff)).toContain("runtime edit");
expect(JSON.stringify(diff)).not.toContain("host edit");
await expect(selectedGit.switchBranch("host-only")).rejects.toThrow(
"Branch not found: host-only",
);
await selectedGit.stashPush("paseo-runtime-stash");
expect((await selectedGit.getSnapshot({ force: true, reason: "stash-push" })).git.isDirty).toBe(
false,
);
await selectedGit.stashPop(0);
expect((await selectedGit.getSnapshot({ force: true, reason: "stash-pop" })).git.isDirty).toBe(
true,
);
await expect(selectedGit.mergeToBase({ baseRef: "main" })).rejects.toThrow(
"Selected workspace Git does not support merge to base",
);
await expect(selectedGit.mergeFromBase({ baseRef: "main" })).rejects.toThrow(
"Selected workspace Git does not support merge from base",
);
await expect(selectedGit.renameBranch("selected-rename")).rejects.toThrow(
"Selected workspace Git does not support branch rename",
);
await expect(selectedGit.push()).rejects.toThrow("Selected workspace Git does not support push");
await selectedGit.commit({ message: "runtime commit", addAll: true });
await selectedGit.createBranch({ branch: "runtime-branch", baseRef: "main" });
await selectedGit.switchBranch("main");
const clean = await selectedGit.getSnapshot({
force: true,
includeForge: false,
reason: "runtime-mutations",
});
expect(clean.git).toMatchObject({ isDirty: false, currentBranch: "main" });
expect(await readFile(path.join(runtimeRepository, "tracked.txt"), "utf8")).toBe(
"runtime edit\n",
);
expect(await readFile(path.join(hostDecoy, "tracked.txt"), "utf8")).toBe("host edit\n");
expect(git(runtimeRepository, "log", "-1", "--format=%s")).toBe("runtime commit");
expect(git(runtimeRepository, "branch", "--list", "runtime-branch")).toBe("runtime-branch");
const upstream = path.join(root, "upstream");
execFileSync("git", ["clone", remoteRepository, upstream], { stdio: "pipe" });
git(upstream, "config", "user.email", "test@example.com");
git(upstream, "config", "user.name", "Paseo Test");
await writeFile(path.join(upstream, "upstream.txt"), "upstream\n");
git(upstream, "add", ".");
git(upstream, "commit", "-m", "upstream commit");
git(upstream, "push", "origin", "main");
await selectedGit.fetch();
const fetched = await selectedGit.getSnapshot();
expect(fetched.git.behindOfOrigin).toBe(1);
await workspaceRuntime.destroy(workspaceId);
git(runtimeRepository, "checkout", "runtime-branch");
await workspaceRuntime.create({
workspaceId,
runtimeId: "fixture",
project: {
id: "runtime-git-project",
source: { kind: "host-directory", path: runtimeRepository },
},
placement: { kind: "existing" },
});
const reconstructed = await selectedGit.getSnapshot();
expect(reconstructed.git.currentBranch).toBe("runtime-branch");
await service.dispose();
await workspaceRuntime.destroy(workspaceId);
}, 20_000);
test("selected commit history highlighting never reads a deleted file from the host cwd", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-git-history-"));
cleanupRoots.push(root);
const runtimeRepository = await createRepository(path.join(root, "runtime-repository"));
await writeFile(path.join(runtimeRepository, "deleted.ts"), "export const runtimeOnly = 1;\n");
git(runtimeRepository, "add", "deleted.ts");
git(runtimeRepository, "commit", "-m", "add runtime history file");
await rm(path.join(runtimeRepository, "deleted.ts"));
git(runtimeRepository, "add", "deleted.ts");
git(runtimeRepository, "commit", "-m", "delete runtime history file");
const deletionSha = git(runtimeRepository, "rev-parse", "HEAD");
const hostDecoy = await createRepository(path.join(root, "host-decoy"));
const hostTrap = path.join(hostDecoy, "deleted.ts");
execFileSync("mkfifo", [hostTrap]);
const stateDirectory = path.join(root, "runtime-state");
await mkdir(stateDirectory);
const runtimeIds = new Map<string, string>();
const workspaceRuntime = createWorkspaceRuntimeService({
paseoHome: path.join(root, "paseo-home"),
resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null,
persistRuntimeId: async (id, runtimeId) => {
runtimeIds.set(id, runtimeId);
},
externalRuntimes: {
fixture: {
type: "command",
command: [process.execPath, fixtureExecutable],
helperCommand: [process.execPath, helperExecutable],
options: { stateDirectory, recordLaunchInWorkspace: false },
},
},
});
await workspaceRuntime.create({
workspaceId: "history-workspace",
runtimeId: "fixture",
project: { id: "history-project", source: { kind: "host-directory", path: runtimeRepository } },
placement: { kind: "existing" },
});
const service = new WorkspaceGitServiceImpl({
logger: createLogger(),
paseoHome: path.join(root, "paseo-home"),
workspaceRuntime,
});
const selectedGit = service.bindWorkspace({
workspaceId: "history-workspace",
cwd: hostDecoy,
});
const hostWriter = spawn("/bin/sh", ["-c", "printf 'host trap\\n' > deleted.ts"], {
cwd: hostDecoy,
stdio: "ignore",
});
const file = await selectedGit.getCommitFileDiff({ sha: deletionSha, path: "deleted.ts" });
await new Promise<void>((resolve) => setImmediate(resolve));
expect(file?.isDeleted).toBe(true);
expect(hostWriter.exitCode).toBeNull();
hostWriter.kill("SIGKILL");
await new Promise<void>((resolve) => hostWriter.once("exit", () => resolve()));
await service.dispose();
await workspaceRuntime.destroy("history-workspace");
}, 20_000);
test("selected workspaces with the same public cwd keep Git state, mutations, and caches isolated", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-git-same-cwd-"));
cleanupRoots.push(root);
const runtimeARepository = await createRepository(path.join(root, "runtime-a"));
const runtimeBRepository = await createRepository(path.join(root, "runtime-b"));
git(runtimeBRepository, "branch", "-m", "runtime-b");
const publicCwd = await createRepository(path.join(root, "shared-public-cwd"));
git(publicCwd, "branch", "-m", "host-decoy");
await writeFile(path.join(publicCwd, "selected-proof.txt"), Buffer.alloc(32, 0));
const stateDirectory = path.join(root, "runtime-state");
await mkdir(stateDirectory);
const runtimeIds = new Map<string, string>();
const workspaceRuntime = createWorkspaceRuntimeService({
paseoHome: path.join(root, "paseo-home"),
resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null,
persistRuntimeId: async (id, runtimeId) => {
runtimeIds.set(id, runtimeId);
},
externalRuntimes: {
fixture: {
type: "command",
command: [process.execPath, fixtureExecutable],
helperCommand: [process.execPath, helperExecutable],
options: { stateDirectory, recordLaunchInWorkspace: false },
},
},
});
for (const [workspaceId, source] of [
["same-cwd-a", runtimeARepository],
["same-cwd-b", runtimeBRepository],
] as const) {
await workspaceRuntime.create({
workspaceId,
runtimeId: "fixture",
project: { id: workspaceId, source: { kind: "host-directory", path: source } },
placement: { kind: "existing" },
});
}
const service = new WorkspaceGitServiceImpl({
logger: createLogger(),
paseoHome: path.join(root, "paseo-home"),
workspaceRuntime,
deps: { getWorkspaceGitSelfHealPhaseMs: () => 60_000 },
});
const workspaceA = service.bindWorkspace({ workspaceId: "same-cwd-a", cwd: publicCwd });
const workspaceB = service.bindWorkspace({ workspaceId: "same-cwd-b", cwd: publicCwd });
await workspaceRuntime.files("same-cwd-a").write({
path: "tracked.txt",
contents: Buffer.from("runtime a dirty\n"),
});
const createUntracked = await workspaceRuntime.run({
workspaceId: "same-cwd-a",
argv: ["/bin/sh", "-c", "printf 'runtime a untracked\\n' > selected-proof.txt"],
env: {},
purpose: { kind: "git" },
});
createUntracked.stdin.end();
await expect(createUntracked.exited).resolves.toEqual({ code: 0, signal: null });
const [snapshotA, snapshotB, diffA, diffB] = await Promise.all([
workspaceA.getSnapshot({ force: true, includeForge: false, reason: "same-cwd-a" }),
workspaceB.getSnapshot({ force: true, includeForge: false, reason: "same-cwd-b" }),
workspaceA.getCheckoutDiff(
{ mode: "uncommitted", includeStructured: true },
{ force: true, reason: "same-cwd-a" },
),
workspaceB.getCheckoutDiff(
{ mode: "uncommitted", includeStructured: true },
{ force: true, reason: "same-cwd-b" },
),
]);
expect(snapshotA.git).toMatchObject({ currentBranch: "main", isDirty: true });
expect(snapshotB.git).toMatchObject({ currentBranch: "runtime-b", isDirty: false });
expect(JSON.stringify(diffA)).toContain("runtime a dirty");
expect(JSON.stringify(diffA)).toContain("runtime a untracked");
expect(JSON.stringify(diffB)).not.toContain("runtime a dirty");
const removeUntracked = await workspaceRuntime.run({
workspaceId: "same-cwd-a",
argv: ["/bin/rm", "selected-proof.txt"],
env: {},
purpose: { kind: "git" },
});
removeUntracked.stdin.end();
await expect(removeUntracked.exited).resolves.toEqual({ code: 0, signal: null });
let observedAWaiter: { dirty: boolean; resolve: () => void } | null = null;
let observedBWaiter: { dirty: boolean; resolve: () => void } | null = null;
let workspaceAChanges = 0;
let workspaceBChanges = 0;
const workspaceBStates: Array<{ branch: string | null; dirty: boolean | null }> = [];
const [observationA, observationB] = await Promise.all([
workspaceA.observe((snapshot) => {
workspaceAChanges += 1;
if (observedAWaiter?.dirty === snapshot.git.isDirty) {
observedAWaiter.resolve();
observedAWaiter = null;
}
}),
workspaceB.observe((snapshot) => {
workspaceBChanges += 1;
workspaceBStates.push({
branch: snapshot.git.currentBranch,
dirty: snapshot.git.isDirty,
});
if (observedBWaiter?.dirty === snapshot.git.isDirty) {
observedBWaiter.resolve();
observedBWaiter = null;
}
}),
]);
const waitForObservedA = (dirty: boolean) =>
new Promise<void>((resolve) => {
observedAWaiter = { dirty, resolve };
});
const waitForObservedB = (dirty: boolean) =>
new Promise<void>((resolve) => {
observedBWaiter = { dirty, resolve };
});
const observedAClean = waitForObservedA(false);
await workspaceRuntime.files("same-cwd-a").write({
path: "tracked.txt",
contents: Buffer.from("initial\n"),
});
await observedAClean;
const observedBDirty = waitForObservedB(true);
await workspaceRuntime.files("same-cwd-b").write({
path: "tracked.txt",
contents: Buffer.from("runtime b startup barrier\n"),
});
await observedBDirty;
const observedBClean = waitForObservedB(false);
await workspaceRuntime.files("same-cwd-b").write({
path: "tracked.txt",
contents: Buffer.from("initial\n"),
});
await observedBClean;
workspaceAChanges = 0;
workspaceBChanges = 0;
workspaceBStates.length = 0;
const observedA = waitForObservedA(true);
const observedB = waitForObservedB(true);
await workspaceRuntime.files("same-cwd-a").write({
path: "tracked.txt",
contents: Buffer.from("runtime a watched\n"),
});
await observedA;
await new Promise<void>((resolve) => setImmediate(resolve));
expect(workspaceAChanges).toBeGreaterThan(0);
expect(workspaceBStates).toEqual([]);
await workspaceRuntime.files("same-cwd-b").write({
path: "tracked.txt",
contents: Buffer.from("runtime b watched\n"),
});
await observedB;
expect(workspaceBChanges).toBeGreaterThan(0);
const restoredB = waitForObservedB(false);
await workspaceRuntime.files("same-cwd-b").write({
path: "tracked.txt",
contents: Buffer.from("initial\n"),
});
await restoredB;
await Promise.all([observationA.unsubscribe(), observationB.unsubscribe()]);
await Promise.all([
workspaceA.commit({ message: "runtime a commit", addAll: true }),
workspaceB.createBranch({ branch: "runtime-b-next", baseRef: "runtime-b" }),
]);
const [mutatedA, mutatedB] = await Promise.all([
workspaceA.getSnapshot({ force: true, includeForge: false, reason: "mutated-a" }),
workspaceB.getSnapshot({ force: true, includeForge: false, reason: "mutated-b" }),
]);
expect(mutatedA.git).toMatchObject({ currentBranch: "main", isDirty: false });
expect(mutatedB.git).toMatchObject({ currentBranch: "runtime-b-next", isDirty: false });
expect(git(runtimeARepository, "log", "-1", "--format=%s")).toBe("runtime a commit");
expect(git(runtimeARepository, "branch", "--list", "runtime-b-next")).toBe("");
expect(git(runtimeBRepository, "branch", "--show-current")).toBe("runtime-b-next");
expect(git(publicCwd, "branch", "--show-current")).toBe("host-decoy");
await workspaceRuntime.destroy("same-cwd-a");
git(runtimeARepository, "branch", "-m", "runtime-a-rebuilt");
await workspaceRuntime.create({
workspaceId: "same-cwd-a",
runtimeId: "fixture",
project: {
id: "same-cwd-a",
source: { kind: "host-directory", path: runtimeARepository },
},
placement: { kind: "existing" },
});
const [rebuiltA, cachedB] = await Promise.all([
workspaceA.getSnapshot(),
workspaceB.getSnapshot(),
]);
expect(rebuiltA.git.currentBranch).toBe("runtime-a-rebuilt");
expect(cachedB.git.currentBranch).toBe("runtime-b-next");
await service.dispose();
await Promise.all([
workspaceRuntime.destroy("same-cwd-a"),
workspaceRuntime.destroy("same-cwd-b"),
]);
}, 20_000);
async function createRepository(directory: string): Promise<string> {
await mkdir(directory);
git(directory, "init", "-b", "main");
git(directory, "config", "user.email", "test@example.com");
git(directory, "config", "user.name", "Paseo Test");
await writeFile(path.join(directory, ".git", "info", "exclude"), ".runtime-launch.json\n");
await writeFile(path.join(directory, "tracked.txt"), "initial\n");
git(directory, "add", ".");
git(directory, "commit", "-m", "initial");
return directory;
}
function git(cwd: string, ...args: string[]): string {
return execFileSync("git", ["-c", "commit.gpgsign=false", ...args], {
cwd,
encoding: "utf8",
}).trim();
}
function createLogger() {
const logger = {
child: () => logger,
debug: vi.fn(),
info: vi.fn(),
trace: vi.fn(),
warn: vi.fn(),
};
return logger;
}
File diff suppressed because it is too large Load Diff
@@ -175,10 +175,19 @@ async function runWatcher(workspaceRoot) {
}
if (command.type !== "subscribe") continue;
closeSubscription(subscriptions.get(command.id));
const observation = { watchers: [], timers: new Map() };
const observation = { watchers: new Map(), timers: new Map() };
subscriptions.set(command.id, observation);
const ignoredPaths = new Set(command.ignoredPaths ?? []);
for (const requested of command.paths) {
const scoped = await resolveScoped(workspaceRoot, requested);
const info = await stat(scoped.absolute).catch((error) => {
if (isMissing(error)) return null;
throw error;
});
if (command.recursive === true && info?.isDirectory()) {
await watchDirectoryTree(observation, scoped, command.id, ignoredPaths);
continue;
}
const expectedName = path.basename(scoped.absolute);
const watcher = watch(path.dirname(scoped.absolute), (_event, filename) => {
if (filename != null && filename.toString() !== expectedName) return;
@@ -194,7 +203,7 @@ async function runWatcher(workspaceRoot) {
watcher.on("error", (error) =>
print({ type: "error", subscriptionId: command.id, message: error.message }),
);
observation.watchers.push(watcher);
observation.watchers.set(scoped.absolute, watcher);
}
print({ type: "subscribed", subscriptionId: command.id });
}
@@ -205,10 +214,50 @@ async function runWatcher(workspaceRoot) {
function closeSubscription(observation) {
if (!observation) return;
for (const watcher of observation.watchers) watcher.close();
for (const watcher of observation.watchers.values()) watcher.close();
for (const timer of observation.timers.values()) clearTimeout(timer);
}
async function watchDirectoryTree(observation, scoped, subscriptionId, ignoredPaths) {
if (observation.watchers.has(scoped.absolute)) return;
const watcher = watch(scoped.absolute, (_event, filename) => {
const name = filename?.toString();
const changed = name ? path.join(scoped.absolute, name) : scoped.absolute;
const relative = normalize(path.relative(root, changed));
if (isIgnored(relative, ignoredPaths)) return;
clearTimeout(observation.timers.get(relative));
observation.timers.set(
relative,
setTimeout(() => {
observation.timers.delete(relative);
print({ type: "changed", subscriptionId, paths: [relative] });
void addDiscoveredDirectories(observation, scoped, subscriptionId, ignoredPaths);
}, 50),
);
});
watcher.on("error", (error) => print({ type: "error", subscriptionId, message: error.message }));
observation.watchers.set(scoped.absolute, watcher);
await addDiscoveredDirectories(observation, scoped, subscriptionId, ignoredPaths);
}
async function addDiscoveredDirectories(observation, scoped, subscriptionId, ignoredPaths) {
const entries = await readdir(scoped.absolute, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const absolute = path.join(scoped.absolute, entry.name);
const relative = normalize(path.relative(root, absolute));
if (isIgnored(relative, ignoredPaths)) continue;
await watchDirectoryTree(observation, { absolute, relative }, subscriptionId, ignoredPaths);
}
}
function isIgnored(relativePath, ignoredPaths) {
for (const ignored of ignoredPaths) {
if (relativePath === ignored || relativePath.startsWith(`${ignored}/`)) return true;
}
return false;
}
async function resolveScoped(workspaceRoot, relativePath = ".") {
if (path.isAbsolute(relativePath)) throw new Error(outsideMessage);
const normalizedRoot = path.resolve(workspaceRoot);
@@ -68,7 +68,7 @@ export interface WorkspaceFiles {
expectedRevision?: string;
}): Promise<WorkspaceFileWriteResult>;
subscribe(
input: { paths: readonly string[] },
input: { paths: readonly string[]; recursive?: boolean; ignoredPaths?: readonly string[] },
listener: (event: WorkspaceWatchEvent) => void,
): Promise<WorkspaceFilesSubscription>;
}
@@ -120,7 +120,7 @@ export function createClient(options: {
state.subscriptions.set(id, listener);
const acknowledged = acknowledgement(state, `subscribed:${id}`);
state.process.stdin.write(
`${JSON.stringify({ protocolVersion: 1, type: "subscribe", id, paths: input.paths })}\n`,
`${JSON.stringify({ protocolVersion: 1, type: "subscribe", id, paths: input.paths, recursive: input.recursive === true, ignoredPaths: input.ignoredPaths ?? [] })}\n`,
);
await waitForAcknowledgement(
state,
@@ -78,10 +78,16 @@ export interface WorkspaceTerminal {
kill(signal?: NodeJS.Signals): void;
}
export interface BoundWorkspaceRuntime {
run(input: Omit<WorkspaceProcessInput, "workspaceId">): Promise<WorkspaceProcess>;
readonly files: WorkspaceFiles;
}
export interface WorkspaceRuntimeService {
create(input: CreateWorkspaceInput): Promise<{ workspaceId: string; runtimeId: string }>;
run(input: WorkspaceProcessInput): Promise<WorkspaceProcess>;
openTerminal(input: WorkspaceTerminalInput): Promise<WorkspaceTerminal>;
bind(workspaceId: string): Promise<BoundWorkspaceRuntime>;
files(workspaceId: string): WorkspaceFiles;
pause(workspaceId: string): Promise<void>;
resume(workspaceId: string): Promise<void>;
@@ -1,5 +1,6 @@
import type {
CreateWorkspaceInput,
BoundWorkspaceRuntime,
WorkspaceProcess,
WorkspaceProcessInput,
WorkspaceRuntimeRecordStore,
@@ -34,6 +35,10 @@ export function createService(
>();
const boundFiles = new Map<string, WorkspaceFiles>();
const unavailableFiles = new Map<string, "paused" | "destroyed">();
const boundRuntimes = new Map<
string,
{ runtimeId: string; revision: string; runtime: BoundWorkspaceRuntime }
>();
function requireRegistered(runtimeId: string): WorkspaceRuntimeDriver {
const driver = driversById.get(runtimeId);
@@ -157,6 +162,27 @@ export function createService(
openTerminalWithDriver(await resolve(input.workspaceId), input),
);
},
async bind(workspaceId) {
const driver = await resolve(workspaceId);
const inspection = await driver.inspect(workspaceId);
if (inspection.status !== "ready") {
throw new Error(`Workspace runtime is ${inspection.status}: ${workspaceId}`);
}
const cached = boundRuntimes.get(workspaceId);
if (cached?.runtimeId === driver.id && cached.revision === inspection.state.revision) {
return cached.runtime;
}
const runtime: BoundWorkspaceRuntime = {
run: (input) => runWithDriver(driver, { workspaceId, ...input }),
files: bindFiles(workspaceId),
};
boundRuntimes.set(workspaceId, {
runtimeId: driver.id,
revision: inspection.state.revision,
runtime,
});
return runtime;
},
files(workspaceId) {
let files = boundFiles.get(workspaceId);
if (!files) {
@@ -169,6 +195,7 @@ export function createService(
await sequence(workspaceId, async () => {
const driver = await resolve(workspaceId);
unavailableFiles.set(workspaceId, "paused");
boundRuntimes.delete(workspaceId);
await closeFiles(workspaceId);
await stopProcesses(workspaceId);
await driver.pause(workspaceId);
@@ -184,6 +211,7 @@ export function createService(
await sequence(workspaceId, async () => {
const driver = await resolve(workspaceId);
unavailableFiles.set(workspaceId, "destroyed");
boundRuntimes.delete(workspaceId);
await closeFiles(workspaceId);
await stopProcesses(workspaceId);
await driver.destroy(workspaceId);
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
import { afterAll, beforeAll, expect, test, vi } from "vitest";
import { createWorkspaceRuntimeService } from "./index.js";
import { WorkspaceGitServiceImpl } from "../workspace-git-service.js";
const repositoryRoot = fileURLToPath(new URL("../../../../..", import.meta.url));
const dockerRuntimeRoot = path.join(repositoryRoot, "packages/docker-workspace-runtime");
@@ -71,6 +72,27 @@ test("the command runtime owns Docker workspace materialization, pipes, and life
expect(workspace).toEqual({ workspaceId, runtimeId: "docker" });
expect("cwd" in workspace).toBe(false);
for (const [name, value] of [
["user.email", "test@example.com"],
["user.name", "Paseo Test"],
] as const) {
const configure = await service.run({
workspaceId,
argv: ["git", "config", name, value],
env: { PATH: "/usr/local/bin:/usr/bin:/bin" },
purpose: { kind: "git" },
});
configure.stdin.end();
await expect(configure.exited).resolves.toEqual({ code: 0, signal: null });
}
const workspaceGitService = new WorkspaceGitServiceImpl({
logger: createLogger(),
paseoHome: options.paseoHome,
workspaceRuntime: service,
});
const workspaceGit = workspaceGitService.bindWorkspace({ workspaceId, cwd: repo });
const files = service.files(workspaceId);
const listing = await files.list(".");
expect(listing.entries.map((entry) => entry.name)).toContain("committed.txt");
@@ -92,6 +114,24 @@ test("the command runtime owns Docker workspace materialization, pipes, and life
terminalFileEdit.stdin.end();
await expect(terminalFileEdit.exited).resolves.toEqual({ code: 0, signal: null });
await expect(fileChanged).resolves.toBeUndefined();
const dirtyGit = await workspaceGit.getSnapshot({
force: true,
includeForge: false,
reason: "docker-edit",
});
const dockerDiff = await workspaceGit.getCheckoutDiff(
{ mode: "uncommitted", includeStructured: true },
{ force: true, reason: "docker-edit" },
);
expect(dirtyGit.git).toMatchObject({ isGit: true, isDirty: true, currentBranch: "main" });
expect(JSON.stringify(dockerDiff)).toContain("container-change");
await workspaceGit.commit({ message: "container commit", addAll: true });
await workspaceGit.createBranch({ branch: "container-branch", baseRef: "main" });
await workspaceGit.switchBranch("main");
await workspaceGit.fetch();
const cleanGit = await workspaceGit.getSnapshot();
expect(cleanGit.git).toMatchObject({ isDirty: false, currentBranch: "main" });
expect(await readFileFromHost(repo, "committed.txt")).toBe("committed\n");
const containerFile = await files.read("committed.txt");
await expect(collect(containerFile.chunks)).resolves.toBe("container-change");
const conflict = await files.write({
@@ -278,6 +318,7 @@ test("the command runtime owns Docker workspace materialization, pipes, and life
).trim(),
).toBe("false");
await workspaceGitService.dispose();
await recovered.destroy(workspaceId);
expect(dockerResourceCount("ps", workspaceId)).toBe(0);
expect(dockerResourceCount("volume", workspaceId)).toBe(0);
@@ -314,7 +355,6 @@ test("Docker creation validates roots and rolls back when runtime selection cann
},
},
});
await expect(
service.create({
workspaceId: `${workspaceId}-escape`,
@@ -517,3 +557,14 @@ async function readFileFromHost(root: string, name: string): Promise<string | nu
throw error;
}
}
function createLogger() {
const logger = {
child: () => logger,
debug: vi.fn(),
info: vi.fn(),
trace: vi.fn(),
warn: vi.fn(),
};
return logger;
}
@@ -23,6 +23,16 @@ afterEach(async () => {
});
posixDescribe.each(["local", "worktree"] as const)("%s runtime public contract", (runtimeId) => {
test("bound runtime exposes only process and file primitives", async () => {
const fixture = await createFixture(runtimeId);
await fixture.service.create(fixture.createInput);
const runtime = await fixture.service.bind(fixture.workspaceId);
expect(Object.keys(runtime).sort()).toEqual(["files", "run"]);
await fixture.service.destroy(fixture.workspaceId);
});
test("binds streaming files and live observation to the selected runtime", async () => {
const fixture = await createFixture(runtimeId);
await fixture.service.create(fixture.createInput);
@@ -84,7 +94,7 @@ posixDescribe.each(["local", "worktree"] as const)("%s runtime public contract",
argv: [
process.execPath,
"-e",
"process.stdin.setEncoding('utf8');process.stdout.write(`${process.cwd()}|${process.stdout.isTTY}|${process.stdout.columns}x${process.stdout.rows}|λ`);process.stdin.once('data',data=>{process.stdout.write(`|${data.trim()}|${process.stdout.columns}x${process.stdout.rows}`);process.exit(7)})",
"process.stdin.setEncoding('utf8');process.stdout.write(`${process.cwd()}|${process.stdout.isTTY}|${process.stdout.columns}x${process.stdout.rows}|λ`);process.stdin.once('data',data=>{const finish=()=>{process.stdout.write(`|${data.trim()}|${process.stdout.columns}x${process.stdout.rows}`);process.exit(7)};process.stdout.columns===101?finish():process.stdout.once('resize',finish)})",
],
env: { PATH: process.env.PATH ?? "" },
purpose: { kind: "terminal", terminalId: `${runtimeId}-terminal` },
+66 -115
View File
@@ -1,6 +1,6 @@
import { resolve, dirname, basename } from "path";
import { existsSync, realpathSync } from "fs";
import { open as openFile, readFile, stat as statFile } from "fs/promises";
import { realpathSync } from "fs";
import { readFile } from "fs/promises";
import { TTLCache } from "@isaacs/ttlcache";
import type { CheckoutCommit, CheckoutCommitFile } from "@getpaseo/protocol/messages";
import { parseGitHubRemoteIdentity, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
@@ -148,8 +148,8 @@ function rememberPullRequestStatus(cacheKey: string, status: PullRequestStatusRe
}
}
function getShortstatCacheKey(cwd: string): string {
return resolve(cwd);
function getShortstatCacheKey(cwd: string, context?: CheckoutContext): string {
return context?.cacheIdentity ?? resolve(cwd);
}
export function __resetPullRequestStatusCacheForTests(): void {
@@ -845,6 +845,8 @@ export interface CheckoutContext {
worktreesRoot?: string;
logger?: Pick<Logger, "trace" | "warn">;
facts?: CheckoutSnapshotFacts | null;
allowHostMetadata?: boolean;
cacheIdentity?: string;
}
export type CheckoutSnapshotFacts =
@@ -897,7 +899,10 @@ async function requireGitWorktreeRoot(cwd: string): Promise<string> {
}
}
export async function getCurrentBranch(cwd: string): Promise<string | null> {
export async function getCurrentBranch(
cwd: string,
context?: CheckoutContext,
): Promise<string | null> {
try {
const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], {
cwd,
@@ -905,7 +910,7 @@ export async function getCurrentBranch(cwd: string): Promise<string | null> {
});
const branch = stdout.trim();
if (branch === "HEAD") {
return await getRebaseHeadBranch(cwd);
return context?.allowHostMetadata === false ? null : await getRebaseHeadBranch(cwd);
}
return branch.length > 0 ? branch : null;
} catch {
@@ -1002,7 +1007,8 @@ async function getMainRepoRootFromCommonDir(
if (!commonDir) {
throw new Error("Not in a git repository");
}
const normalized = realpathSync(commonDir);
const normalized =
context?.allowHostMetadata === false ? resolve(commonDir) : realpathSync(commonDir);
if (basename(normalized) === ".git") {
return dirname(normalized);
@@ -1140,6 +1146,9 @@ async function getPaseoWorktreeForCwd(
cwd: string,
options: PaseoWorktreeLookupOptions = {},
): Promise<PaseoWorktreeForCwd> {
if (options.context?.allowHostMetadata === false) {
return { isPaseoOwnedWorktree: false };
}
// Fast-path reject: non-worktree paths do not need expensive ownership checks.
if (!/[\\/]worktrees[\\/]/.test(cwd)) {
return { isPaseoOwnedWorktree: false };
@@ -1406,29 +1415,15 @@ async function resolveGitCommonDir(cwd: string): Promise<string | null> {
}
async function abortGitPullConflictState(cwd: string): Promise<void> {
const gitDir = await resolveAbsoluteGitDir(cwd);
if (!gitDir) {
return;
try {
await runGitCommand(["merge", "--abort"], { cwd, timeout: 120_000 });
} catch {
// No merge was in progress.
}
const mergeHeadPath = resolve(gitDir, "MERGE_HEAD");
const rebaseMergePath = resolve(gitDir, "rebase-merge");
const rebaseApplyPath = resolve(gitDir, "rebase-apply");
if (existsSync(mergeHeadPath)) {
try {
await runGitCommand(["merge", "--abort"], { cwd, timeout: 120_000 });
} catch {
// ignore
}
}
if (existsSync(rebaseMergePath) || existsSync(rebaseApplyPath)) {
try {
await runGitCommand(["rebase", "--abort"], { cwd, timeout: 120_000 });
} catch {
// ignore
}
try {
await runGitCommand(["rebase", "--abort"], { cwd, timeout: 120_000 });
} catch {
// No rebase was in progress.
}
}
@@ -1687,7 +1682,7 @@ async function inspectCheckoutContext(
}
const [currentBranch, remoteUrl, absoluteGitDir, gitCommonDir] = await Promise.all([
getCurrentBranch(cwd),
getCurrentBranch(cwd, context),
getOriginRemoteUrl(cwd),
resolveAbsoluteGitDir(cwd),
resolveGitCommonDir(cwd),
@@ -2018,66 +2013,6 @@ function appendStructuredFile(
structured.serializedBytes = nextBytes;
return true;
}
const UNTRACKED_BINARY_SNIFF_BYTES = 16 * 1024;
async function isLikelyBinaryFile(absolutePath: string): Promise<boolean> {
const handle = await openFile(absolutePath, "r");
try {
const buffer = Buffer.allocUnsafe(UNTRACKED_BINARY_SNIFF_BYTES);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
if (bytesRead === 0) {
return false;
}
let suspicious = 0;
for (let i = 0; i < bytesRead; i += 1) {
const byte = buffer[i];
if (byte === 0) {
return true;
}
// Treat control bytes as suspicious while allowing common whitespace.
if (byte < 7 || (byte > 14 && byte < 32) || byte === 127) {
suspicious += 1;
}
}
return suspicious / bytesRead > 0.3;
} finally {
await handle.close();
}
}
async function inspectUntrackedFile(
cwd: string,
relativePath: string,
): Promise<{ stat: FileStat; truncated: boolean }> {
const absolutePath = resolve(cwd, relativePath);
const metadata = await statFile(absolutePath);
if (!metadata.isFile()) {
return { stat: null, truncated: false };
}
if (await isLikelyBinaryFile(absolutePath)) {
return {
stat: { additions: 0, deletions: 0, isBinary: true },
truncated: false,
};
}
if (metadata.size > PER_FILE_DIFF_MAX_BYTES) {
return {
stat: { additions: 0, deletions: 0, isBinary: false },
truncated: true,
};
}
return {
stat: { additions: 0, deletions: 0, isBinary: false },
truncated: false,
};
}
function buildPlaceholderParsedDiffFile(
change: CheckoutFileChange,
options: { status: "too_large" | "binary"; stat?: FileStat },
@@ -2098,15 +2033,6 @@ async function getUntrackedDiffText(
change: CheckoutFileChange,
ignoreWhitespace = false,
): Promise<{ text: string; truncated: boolean; stat: FileStat }> {
try {
const inspected = await inspectUntrackedFile(cwd, change.path);
if (inspected.stat?.isBinary || inspected.truncated) {
return { text: "", truncated: inspected.truncated, stat: inspected.stat };
}
} catch {
// Fall through to git diff path if metadata probing fails.
}
const result = await runGitCommand(
buildGitDiffArgs({
ignoreWhitespace,
@@ -2122,7 +2048,11 @@ async function getUntrackedDiffText(
return {
text: result.stdout,
truncated: result.truncated,
stat: { additions: 0, deletions: 0, isBinary: false },
stat: {
additions: 0,
deletions: 0,
isBinary: /^Binary files .* differ$/m.test(result.stdout),
},
};
}
@@ -2494,10 +2424,12 @@ export async function getCommitFileDiff({
cwd,
sha,
path,
allowFileRead = true,
}: {
cwd: string;
sha: string;
path: string;
allowFileRead?: boolean;
}): Promise<ParsedDiffFile | null> {
const { stdout } = await runGitCommand(
["show", sha, "--format=", "--diff-merges=first-parent", "--", path],
@@ -2514,6 +2446,7 @@ export async function getCommitFileDiff({
const parsedFiles = await parseAndHighlightDiff(stdout, cwd, {
getOldFileContent: (file) => readGitFileContentAtRef(cwd, `${sha}^`, file.path),
getNewFileContent: (file) => readGitFileContentAtRef(cwd, sha, file.path),
allowFileRead,
});
// `--` scopes the diff to a single pathspec, so there is at most one real
@@ -2576,18 +2509,20 @@ async function countUntrackedAdditions(cwd: string, throwOnGitError = false): Pr
let additions = 0;
for (const file of files.slice(0, UNTRACKED_SHORTSTAT_MAX_FILES)) {
const absolutePath = resolve(cwd, file);
try {
const metadata = await statFile(absolutePath);
if (metadata.size > PER_FILE_DIFF_MAX_BYTES) continue;
if (await isLikelyBinaryFile(absolutePath)) continue;
const content = await readFile(absolutePath, "utf-8");
if (content.length === 0) continue;
const normalized = content.replace(/\r\n/g, "\n");
const lineCount = normalized.split("\n").length;
additions += normalized.endsWith("\n") ? lineCount - 1 : lineCount;
const { stdout: numstat } = await runGitCommand(
["diff", "--no-index", "--numstat", "/dev/null", "--", file],
{
cwd,
envOverlay: READ_ONLY_GIT_ENV,
maxOutputBytes: 8_192,
acceptExitCodes: [0, 1],
},
);
const added = numstat.split("\t", 1)[0];
if (added && added !== "-") additions += Number.parseInt(added, 10) || 0;
} catch {
// Skip unreadable files.
// Skip files Git cannot inspect.
}
}
return additions;
@@ -2699,7 +2634,7 @@ function getOrLoadCheckoutShortstat(
context?: CheckoutContext,
options?: CheckoutReadCacheOptions,
): Promise<CheckoutShortstat | null> {
const cacheKey = getShortstatCacheKey(cwd);
const cacheKey = getShortstatCacheKey(cwd, context);
if (!options?.force) {
const cached = shortstatCache.get(cacheKey);
if (cached !== undefined) {
@@ -2869,7 +2804,7 @@ export function warmCheckoutShortstatInBackground(
context?: CheckoutContext,
onComplete?: () => void,
): void {
const cacheKey = getShortstatCacheKey(cwd);
const cacheKey = getShortstatCacheKey(cwd, context);
if (shortstatCache.get(cacheKey) !== undefined || shortstatInFlight.has(cacheKey)) {
return;
}
@@ -2893,6 +2828,7 @@ interface AppendStructuredTrackedDiffsInput {
refsForDiff: CheckoutDiffRefs;
ignoreWhitespace: boolean;
structured: StructuredDiffAccumulator;
allowHostMetadata: boolean;
appendTrackedPlaceholderComment: (
change: CheckoutFileChange,
status: "binary" | "too_large",
@@ -2904,8 +2840,9 @@ async function buildHighlightedTrackedDiffFile(input: {
change: CheckoutFileChange;
parsedFile: ParsedDiffFile;
refsForDiff: CheckoutDiffRefs;
allowHostMetadata: boolean;
}): Promise<ParsedDiffFile> {
const { cwd, change, parsedFile, refsForDiff } = input;
const { cwd, change, parsedFile, refsForDiff, allowHostMetadata } = input;
const refPath = change.oldPath ?? change.path;
const [oldFileContent, newFileContent] = await Promise.all([
change.isNew ? null : readGitFileContentAtRef(cwd, refsForDiff.baseRef, refPath),
@@ -2914,6 +2851,7 @@ async function buildHighlightedTrackedDiffFile(input: {
const highlightedFile = await highlightDiffWithFileContent(parsedFile, cwd, {
oldFileContent,
newFileContent,
allowFileRead: allowHostMetadata,
});
return {
...highlightedFile,
@@ -2949,6 +2887,7 @@ async function appendStructuredTrackedDiffs(
refsForDiff,
ignoreWhitespace,
structured,
allowHostMetadata,
appendTrackedPlaceholderComment,
} = input;
@@ -2977,6 +2916,7 @@ async function appendStructuredTrackedDiffs(
change,
parsedFile,
refsForDiff,
allowHostMetadata,
});
if (!appendStructuredFile(structured, file)) {
return false;
@@ -3015,10 +2955,19 @@ interface ProcessUntrackedChangeInput {
includeStructured: boolean;
structured: StructuredDiffAccumulator;
appendDiff: (text: string) => void;
allowHostMetadata: boolean;
}
async function processUntrackedChange(input: ProcessUntrackedChangeInput): Promise<boolean> {
const { cwd, change, ignoreWhitespace, includeStructured, structured, appendDiff } = input;
const {
cwd,
change,
ignoreWhitespace,
includeStructured,
structured,
appendDiff,
allowHostMetadata,
} = input;
const { text, truncated, stat } = await getUntrackedDiffText(cwd, change, ignoreWhitespace);
if (!includeStructured) {
@@ -3059,7 +3008,7 @@ async function processUntrackedChange(input: ProcessUntrackedChangeInput): Promi
}
appendDiff(text);
const parsed = await parseAndHighlightDiff(text, cwd);
const parsed = await parseAndHighlightDiff(text, cwd, { allowFileRead: allowHostMetadata });
const parsedFile =
parsed[0] ??
({
@@ -3266,6 +3215,7 @@ export async function getCheckoutDiff(
refsForDiff: effectiveRefsForDiff,
ignoreWhitespace,
structured,
allowHostMetadata: context?.allowHostMetadata !== false,
appendTrackedPlaceholderComment,
});
if (!didAppendTrackedDiffs) {
@@ -3291,6 +3241,7 @@ export async function getCheckoutDiff(
includeStructured: compare.includeStructured === true,
structured,
appendDiff,
allowHostMetadata: context?.allowHostMetadata !== false,
});
if (!didAppendUntrackedDiff) {
return { diff: "", structured: [], diffTooLarge: true };
@@ -0,0 +1,75 @@
import { execFile } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import {
configureGitProcessPolicy,
type GitCommandResult,
runGitCommand,
runWithGitCommandRunner,
} from "./run-git-command.js";
import { resolveGitProcessPolicy } from "./git-process-scheduler.js";
const execFileAsync = promisify(execFile);
const directories: string[] = [];
async function createRepository(branch: string): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), "paseo-git-runtime-context-"));
directories.push(directory);
await execFileAsync("git", ["init", "--initial-branch", branch], { cwd: directory });
return directory;
}
function createRuntimeRunner(
runtimeCwd: string,
waitBeforeReturning?: Promise<void>,
): (args: string[]) => Promise<GitCommandResult> {
return async (args) => {
const { stdout, stderr } = await execFileAsync("git", args, { cwd: runtimeCwd });
await waitBeforeReturning;
return {
stdout,
stderr,
truncated: false,
exitCode: 0,
signal: null,
};
};
}
afterEach(async () => {
configureGitProcessPolicy(resolveGitProcessPolicy({ env: process.env }));
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true })));
});
describe("runGitCommand runtime submission context", () => {
it("keeps queued commands in the selected runtime that submitted them", async () => {
configureGitProcessPolicy({ maxProcessConcurrency: 1, maxProcessesPerSecond: 1_000 });
const [runtimeA, runtimeB, hostDecoyA, hostDecoyB] = await Promise.all([
createRepository("runtime-a"),
createRepository("runtime-b"),
createRepository("host-decoy-a"),
createRepository("host-decoy-b"),
]);
let releaseRuntimeA!: () => void;
const runtimeAHold = new Promise<void>((resolve) => {
releaseRuntimeA = resolve;
});
const first = runWithGitCommandRunner(createRuntimeRunner(runtimeA, runtimeAHold), () =>
runGitCommand(["branch", "--show-current"], { cwd: hostDecoyA }),
);
const second = runWithGitCommandRunner(createRuntimeRunner(runtimeB), () =>
runGitCommand(["branch", "--show-current"], { cwd: hostDecoyB }),
);
releaseRuntimeA();
await expect(Promise.all([first, second])).resolves.toEqual([
expect.objectContaining({ stdout: "runtime-a\n" }),
expect.objectContaining({ stdout: "runtime-b\n" }),
]);
});
});
+64 -3
View File
@@ -27,6 +27,16 @@ const DEFAULT_STDERR_LIMIT = 2048;
let gitProcessScheduler = new GitProcessScheduler(resolveGitProcessPolicy({ env: process.env }));
let gitRuntimeMetrics = createGitCommandRuntimeMetricsWindow(gitProcessScheduler.policy);
const gitCommandPriority = new AsyncLocalStorage<GitProcessPriority>();
const gitCommandRunner = new AsyncLocalStorage<GitCommandRunner>();
export type GitCommandRunner = (
args: string[],
options: GitCommandOptions,
) => Promise<GitCommandResult>;
export function runWithGitCommandRunner<T>(runner: GitCommandRunner, operation: () => T): T {
return gitCommandRunner.run(runner, operation);
}
export function runWithGitCommandPriority<T>(priority: GitProcessPriority, operation: () => T): T {
return gitCommandPriority.run(priority, operation);
@@ -253,6 +263,8 @@ export function runGitCommand(
args: string[],
options: GitCommandOptions,
): Promise<GitCommandResult> {
const selectedRunner = gitCommandRunner.getStore();
const priority = gitCommandPriority.getStore() ?? "normal";
const metricsState = submitGitCommandMetric(args, options.cwd);
const commandTrace = submitGitCommandTrace(args, options.cwd, {
active: gitProcessScheduler.activeCount,
@@ -260,6 +272,57 @@ export function runGitCommand(
});
const runtimeMetric = gitRuntimeMetrics.submit(getGitOperation(args));
const startCommand = () => {
if (selectedRunner) {
const startedAt = Date.now();
beginGitCommandMetric(metricsState);
gitRuntimeMetrics.start(runtimeMetric);
startGitCommandTrace(commandTrace, {
active: gitProcessScheduler.activeCount,
pending: gitProcessScheduler.pendingCount,
});
const result = selectedRunner(args, options).then(
(commandResult) => {
const success =
commandResult.truncated ||
(options.acceptExitCodes ?? [0]).includes(commandResult.exitCode ?? -1);
finishMetricOnce(commandResult, success);
return commandResult;
},
(error) => {
finishMetricOnce(
{ stdout: "", stderr: "", truncated: false, exitCode: null, signal: null },
false,
);
throw error;
},
);
return {
result,
exited: result.then(
() => undefined,
() => undefined,
),
};
function finishMetricOnce(commandResult: GitCommandResult, success: boolean): void {
finishGitCommandMetric(metricsState, {
args,
cwd: options.cwd,
startedAtMs: startedAt,
durationMs: Date.now() - startedAt,
exitCode: commandResult.exitCode,
signal: commandResult.signal,
success,
});
gitRuntimeMetrics.finish(runtimeMetric, { success, timedOut: false });
settleGitCommandTrace(commandTrace, {
outcome: "closed",
exitCode: commandResult.exitCode,
signal: commandResult.signal,
truncated: commandResult.truncated,
});
}
}
let releaseProcessSlot!: () => void;
const exited = new Promise<void>((resolve) => {
releaseProcessSlot = resolve;
@@ -516,9 +579,7 @@ export function runGitCommand(
});
return { result: resultPromise, exited };
};
const promise = gitProcessScheduler.run(startCommand, {
priority: gitCommandPriority.getStore() ?? "normal",
});
const promise = gitProcessScheduler.run(startCommand, { priority });
gitRuntimeMetrics.observeLimiter(
gitProcessScheduler.activeCount,
gitProcessScheduler.pendingCount,