mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +00:00
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:
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
+8
-2
@@ -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> {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
|
||||
+87
-28
@@ -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", () => {
|
||||
|
||||
+62
-42
@@ -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
-4
@@ -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);
|
||||
|
||||
+5
-3
@@ -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 {
|
||||
|
||||
+7
-5
@@ -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` },
|
||||
|
||||
@@ -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" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user