feat(server,app): redesign git/GitHub refresh architecture with server authority
Centralizes refresh orchestration in WorkspaceGitService and GitHubService: server coalesces, deduplicates, and throttles git/GitHub reads; clients peek snapshots (cold reads fall through to a one-shot fetch) and never drive refreshes. Introduces a two-TTL model (consumer vs internal), force-emit bypass so fingerprint-matched refreshes still propagate, notifyGitMutation wiring at ~13 action handlers, adaptive GitHub polling based on check state, and checkout_status_update / prStatus push-to-cache wire messages. Fixes the stale diff-stat bug and the inverted base-branch diff direction, and flips next-action affordances promptly after commit / PR merge.
This commit is contained in:
@@ -661,10 +661,8 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
const {
|
const {
|
||||||
status,
|
status,
|
||||||
isLoading: isStatusLoading,
|
isLoading: isStatusLoading,
|
||||||
isFetching: isStatusFetching,
|
|
||||||
isError: isStatusError,
|
isError: isStatusError,
|
||||||
error: statusError,
|
error: statusError,
|
||||||
refresh: refreshStatus,
|
|
||||||
} = useCheckoutStatusQuery({ serverId, cwd });
|
} = useCheckoutStatusQuery({ serverId, cwd });
|
||||||
const gitStatus = status && status.isGit ? status : null;
|
const gitStatus = status && status.isGit ? status : null;
|
||||||
const isGit = Boolean(gitStatus);
|
const isGit = Boolean(gitStatus);
|
||||||
@@ -683,10 +681,8 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
files,
|
files,
|
||||||
payloadError: diffPayloadError,
|
payloadError: diffPayloadError,
|
||||||
isLoading: isDiffLoading,
|
isLoading: isDiffLoading,
|
||||||
isFetching: isDiffFetching,
|
|
||||||
isError: isDiffError,
|
isError: isDiffError,
|
||||||
error: diffError,
|
error: diffError,
|
||||||
refresh: refreshDiff,
|
|
||||||
} = useCheckoutDiffQuery({
|
} = useCheckoutDiffQuery({
|
||||||
serverId,
|
serverId,
|
||||||
cwd,
|
cwd,
|
||||||
@@ -699,14 +695,11 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
status: prStatus,
|
status: prStatus,
|
||||||
githubFeaturesEnabled,
|
githubFeaturesEnabled,
|
||||||
payloadError: prPayloadError,
|
payloadError: prPayloadError,
|
||||||
refresh: refreshPrStatus,
|
|
||||||
} = useCheckoutPrStatusQuery({
|
} = useCheckoutPrStatusQuery({
|
||||||
serverId,
|
serverId,
|
||||||
cwd,
|
cwd,
|
||||||
enabled: isGit,
|
enabled: isGit,
|
||||||
});
|
});
|
||||||
// Track user-initiated refresh to avoid iOS RefreshControl animation on background fetches
|
|
||||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
|
||||||
const normalizedWorkspaceRoot = useMemo(() => cwd.trim(), [cwd]);
|
const normalizedWorkspaceRoot = useMemo(() => cwd.trim(), [cwd]);
|
||||||
const workspaceStateKey = useMemo(
|
const workspaceStateKey = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -732,13 +725,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
const headerHeightByPathRef = useRef<Record<string, number>>({});
|
const headerHeightByPathRef = useRef<Record<string, number>>({});
|
||||||
const bodyHeightByPathRef = useRef<Record<string, number>>({});
|
const bodyHeightByPathRef = useRef<Record<string, number>>({});
|
||||||
const defaultHeaderHeightRef = useRef<number>(44);
|
const defaultHeaderHeightRef = useRef<number>(44);
|
||||||
const handleRefresh = useCallback(() => {
|
|
||||||
setIsManualRefresh(true);
|
|
||||||
void refreshDiff();
|
|
||||||
void refreshStatus();
|
|
||||||
void refreshPrStatus();
|
|
||||||
}, [refreshDiff, refreshStatus, refreshPrStatus]);
|
|
||||||
|
|
||||||
const shipDefaultStorageKey = useMemo(() => {
|
const shipDefaultStorageKey = useMemo(() => {
|
||||||
if (!gitStatus?.repoRoot) {
|
if (!gitStatus?.repoRoot) {
|
||||||
return null;
|
return null;
|
||||||
@@ -901,13 +887,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
}
|
}
|
||||||
}, [allExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]);
|
}, [allExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]);
|
||||||
|
|
||||||
// Reset manual refresh flag when fetch completes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!(isDiffFetching || isStatusFetching) && isManualRefresh) {
|
|
||||||
setIsManualRefresh(false);
|
|
||||||
}
|
|
||||||
}, [isDiffFetching, isStatusFetching, isManualRefresh]);
|
|
||||||
|
|
||||||
// Clear diff mode override when auto mode changes (e.g., after commit)
|
// Clear diff mode override when auto mode changes (e.g., after commit)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDiffModeOverride(null);
|
setDiffModeOverride(null);
|
||||||
@@ -1199,8 +1178,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||||
onRefresh={handleRefresh}
|
|
||||||
refreshing={isManualRefresh && isDiffFetching}
|
|
||||||
// Mixed-height rows (header + potentially very large body) are prone to clipping artifacts.
|
// Mixed-height rows (header + potentially very large body) are prone to clipping artifacts.
|
||||||
// Keep a larger render window and disable clipping to avoid bodies disappearing mid-scroll.
|
// Keep a larger render window and disable clipping to avoid bodies disappearing mid-scroll.
|
||||||
removeClippedSubviews={false}
|
removeClippedSubviews={false}
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
export type CheckoutStatusRevalidationParams = {
|
|
||||||
serverId: string;
|
|
||||||
cwd: string;
|
|
||||||
isOpen: boolean;
|
|
||||||
explorerTab: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function checkoutStatusRevalidationKey(
|
|
||||||
params: CheckoutStatusRevalidationParams,
|
|
||||||
): string | null {
|
|
||||||
if (!params.cwd) return null;
|
|
||||||
if (!params.isOpen) return null;
|
|
||||||
if (params.explorerTab !== "changes") return null;
|
|
||||||
return `${params.serverId}:${params.cwd}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function nextCheckoutStatusRefetchDecision(
|
|
||||||
prevKey: string | null,
|
|
||||||
nextKey: string | null,
|
|
||||||
): { nextSeenKey: string | null; shouldRefetch: boolean } {
|
|
||||||
if (!nextKey) return { nextSeenKey: null, shouldRefetch: false };
|
|
||||||
if (prevKey === nextKey) return { nextSeenKey: prevKey, shouldRefetch: false };
|
|
||||||
return { nextSeenKey: nextKey, shouldRefetch: true };
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useCallback, useEffect, useId, useMemo } from "react";
|
import { useEffect, useId, useMemo } from "react";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
|
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
|
||||||
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
|
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
|
||||||
|
|
||||||
const CHECKOUT_DIFF_STALE_TIME = 30_000;
|
|
||||||
|
|
||||||
function checkoutDiffQueryKey(
|
function checkoutDiffQueryKey(
|
||||||
serverId: string,
|
serverId: string,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
@@ -95,7 +93,10 @@ export function useCheckoutDiffQuery({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
enabled: !!client && isConnected && !!cwd && enabled,
|
enabled: !!client && isConnected && !!cwd && enabled,
|
||||||
staleTime: CHECKOUT_DIFF_STALE_TIME,
|
staleTime: Infinity,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -118,9 +119,6 @@ export function useCheckoutDiffQuery({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const unsubscribeUpdate = client.on("checkout_diff_update", (message) => {
|
const unsubscribeUpdate = client.on("checkout_diff_update", (message) => {
|
||||||
if (message.type !== "checkout_diff_update") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (message.payload.subscriptionId !== subscriptionId) {
|
if (message.payload.subscriptionId !== subscriptionId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -134,9 +132,6 @@ export function useCheckoutDiffQuery({
|
|||||||
const unsubscribeSubscribeResponse = client.on(
|
const unsubscribeSubscribeResponse = client.on(
|
||||||
"subscribe_checkout_diff_response",
|
"subscribe_checkout_diff_response",
|
||||||
(message) => {
|
(message) => {
|
||||||
if (message.type !== "subscribe_checkout_diff_response") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (message.payload.subscriptionId !== subscriptionId) {
|
if (message.payload.subscriptionId !== subscriptionId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -207,10 +202,6 @@ export function useCheckoutDiffQuery({
|
|||||||
queryClient,
|
queryClient,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
|
||||||
return query.refetch();
|
|
||||||
}, [query]);
|
|
||||||
|
|
||||||
const payload = query.data ?? null;
|
const payload = query.data ?? null;
|
||||||
const payloadError = payload?.error ?? null;
|
const payloadError = payload?.error ?? null;
|
||||||
|
|
||||||
@@ -221,6 +212,5 @@ export function useCheckoutDiffQuery({
|
|||||||
isFetching: query.isFetching,
|
isFetching: query.isFetching,
|
||||||
isError: query.isError || Boolean(payloadError),
|
isError: query.isError || Boolean(payloadError),
|
||||||
error: query.error,
|
error: query.error,
|
||||||
refresh,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect } from "react";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import type { CheckoutPrStatusResponse } from "@server/shared/messages";
|
import type { CheckoutPrStatusResponse } from "@server/shared/messages";
|
||||||
|
|
||||||
const CHECKOUT_PR_STATUS_STALE_TIME = 20_000;
|
|
||||||
const WORKSPACE_PR_HINT_REFETCH_INTERVAL = 60_000;
|
|
||||||
|
|
||||||
export function checkoutPrStatusQueryKey(serverId: string, cwd: string) {
|
export function checkoutPrStatusQueryKey(serverId: string, cwd: string) {
|
||||||
return ["checkoutPrStatus", serverId, cwd] as const;
|
return ["checkoutPrStatus", serverId, cwd] as const;
|
||||||
}
|
}
|
||||||
@@ -16,6 +14,7 @@ interface UseCheckoutPrStatusQueryOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
export type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
||||||
|
|
||||||
export interface PrHint {
|
export interface PrHint {
|
||||||
url: string;
|
url: string;
|
||||||
number: number;
|
number: number;
|
||||||
@@ -71,9 +70,24 @@ export function useCheckoutPrStatusQuery({
|
|||||||
cwd,
|
cwd,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
}: UseCheckoutPrStatusQueryOptions) {
|
}: UseCheckoutPrStatusQueryOptions) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!client || !isConnected || !cwd) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.on("checkout_status_update", (message) => {
|
||||||
|
const prStatus = message.payload.prStatus;
|
||||||
|
if (!prStatus || prStatus.cwd !== cwd) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), prStatus);
|
||||||
|
});
|
||||||
|
}, [client, isConnected, cwd, queryClient, serverId]);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: checkoutPrStatusQueryKey(serverId, cwd),
|
queryKey: checkoutPrStatusQueryKey(serverId, cwd),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -83,8 +97,10 @@ export function useCheckoutPrStatusQuery({
|
|||||||
return await client.checkoutPrStatus(cwd);
|
return await client.checkoutPrStatus(cwd);
|
||||||
},
|
},
|
||||||
enabled: !!client && isConnected && !!cwd && enabled,
|
enabled: !!client && isConnected && !!cwd && enabled,
|
||||||
staleTime: CHECKOUT_PR_STATUS_STALE_TIME,
|
staleTime: Infinity,
|
||||||
refetchInterval: 15_000,
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -95,7 +111,6 @@ export function useCheckoutPrStatusQuery({
|
|||||||
isFetching: query.isFetching,
|
isFetching: query.isFetching,
|
||||||
isError: query.isError,
|
isError: query.isError,
|
||||||
error: query.error,
|
error: query.error,
|
||||||
refresh: query.refetch,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,9 +119,24 @@ export function useWorkspacePrHint({
|
|||||||
cwd,
|
cwd,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
}: UseCheckoutPrStatusQueryOptions): PrHint | null {
|
}: UseCheckoutPrStatusQueryOptions): PrHint | null {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!client || !isConnected || !cwd) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.on("checkout_status_update", (message) => {
|
||||||
|
const prStatus = message.payload.prStatus;
|
||||||
|
if (!prStatus || prStatus.cwd !== cwd) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), prStatus);
|
||||||
|
});
|
||||||
|
}, [client, isConnected, cwd, queryClient, serverId]);
|
||||||
|
|
||||||
const query = useQuery<CheckoutPrStatusPayload, Error, PrHint | null>({
|
const query = useQuery<CheckoutPrStatusPayload, Error, PrHint | null>({
|
||||||
queryKey: checkoutPrStatusQueryKey(serverId, cwd),
|
queryKey: checkoutPrStatusQueryKey(serverId, cwd),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -116,8 +146,10 @@ export function useWorkspacePrHint({
|
|||||||
return await client.checkoutPrStatus(cwd);
|
return await client.checkoutPrStatus(cwd);
|
||||||
},
|
},
|
||||||
enabled: !!client && isConnected && !!cwd && enabled,
|
enabled: !!client && isConnected && !!cwd && enabled,
|
||||||
staleTime: CHECKOUT_PR_STATUS_STALE_TIME,
|
staleTime: Infinity,
|
||||||
refetchInterval: WORKSPACE_PR_HINT_REFETCH_INTERVAL,
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
select: selectWorkspacePrHint,
|
select: selectWorkspacePrHint,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,75 +1,317 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import React, { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { JSDOM } from "jsdom";
|
||||||
import {
|
import {
|
||||||
checkoutStatusRevalidationKey,
|
QueryClient,
|
||||||
nextCheckoutStatusRefetchDecision,
|
QueryClientProvider,
|
||||||
} from "./checkout-status-revalidation";
|
focusManager,
|
||||||
|
onlineManager,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { CheckoutStatusResponse } from "@server/shared/messages";
|
||||||
|
import { checkoutStatusQueryKey, useCheckoutStatusQuery } from "./use-checkout-status-query";
|
||||||
|
|
||||||
|
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||||
|
|
||||||
|
const { mockRuntime, mockClient, checkoutStatusUpdateHandlers } = vi.hoisted(() => {
|
||||||
|
const checkoutStatusUpdateHandlers = new Set<(message: unknown) => void>();
|
||||||
|
const mockClient = {
|
||||||
|
getCheckoutStatus: vi.fn(),
|
||||||
|
on: vi.fn((type: string, handler: (message: unknown) => void) => {
|
||||||
|
if (type !== "checkout_status_update") {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
checkoutStatusUpdateHandlers.add(handler);
|
||||||
|
return () => {
|
||||||
|
checkoutStatusUpdateHandlers.delete(handler);
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
mockClient,
|
||||||
|
checkoutStatusUpdateHandlers,
|
||||||
|
mockRuntime: {
|
||||||
|
client: mockClient,
|
||||||
|
isConnected: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/runtime/host-runtime", () => ({
|
||||||
|
useHostRuntimeClient: () => mockRuntime.client,
|
||||||
|
useHostRuntimeIsConnected: () => mockRuntime.isConnected,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const serverId = "server-1";
|
||||||
|
const cwd = "/repo";
|
||||||
|
|
||||||
|
function checkoutStatus(overrides: Partial<CheckoutStatusPayload> = {}): CheckoutStatusPayload {
|
||||||
|
return {
|
||||||
|
cwd,
|
||||||
|
error: null,
|
||||||
|
requestId: "checkout-status-1",
|
||||||
|
isGit: true,
|
||||||
|
isPaseoOwnedWorktree: false,
|
||||||
|
repoRoot: cwd,
|
||||||
|
currentBranch: "main",
|
||||||
|
isDirty: false,
|
||||||
|
baseRef: "origin/main",
|
||||||
|
aheadBehind: { ahead: 0, behind: 0 },
|
||||||
|
aheadOfOrigin: 0,
|
||||||
|
behindOfOrigin: 0,
|
||||||
|
hasRemote: true,
|
||||||
|
remoteUrl: "git@github.com:getpaseo/paseo.git",
|
||||||
|
...overrides,
|
||||||
|
} as CheckoutStatusPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestQueryClient(): QueryClient {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCheckoutStatusHook({
|
||||||
|
queryClient = createTestQueryClient(),
|
||||||
|
hookCwd = cwd,
|
||||||
|
}: {
|
||||||
|
queryClient?: QueryClient;
|
||||||
|
hookCwd?: string;
|
||||||
|
} = {}) {
|
||||||
|
let latest: ReturnType<typeof useCheckoutStatusQuery> | null = null;
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
latest = useCheckoutStatusQuery({ serverId, cwd: hookCwd });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.getElementById("root");
|
||||||
|
if (!container) {
|
||||||
|
throw new Error("Missing root container");
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
return {
|
||||||
|
get latest() {
|
||||||
|
if (!latest) {
|
||||||
|
throw new Error("Expected hook result");
|
||||||
|
}
|
||||||
|
return latest;
|
||||||
|
},
|
||||||
|
async mount() {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(
|
||||||
|
QueryClientProvider,
|
||||||
|
{ client: queryClient },
|
||||||
|
React.createElement(Probe),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async unmount() {
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForExpectation(assertion: () => void): Promise<void> {
|
||||||
|
let lastError: unknown;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||||
|
try {
|
||||||
|
assertion();
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(10);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emitCheckoutStatusUpdate(payload: CheckoutStatusPayload): Promise<void> {
|
||||||
|
await act(async () => {
|
||||||
|
for (const handler of checkoutStatusUpdateHandlers) {
|
||||||
|
handler({
|
||||||
|
type: "checkout_status_update",
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("useCheckoutStatusQuery", () => {
|
describe("useCheckoutStatusQuery", () => {
|
||||||
describe("checkoutStatusRevalidationKey", () => {
|
beforeEach(() => {
|
||||||
it("returns null when sidebar is closed", () => {
|
vi.useFakeTimers();
|
||||||
expect(
|
|
||||||
checkoutStatusRevalidationKey({
|
const dom = new JSDOM("<!doctype html><html><body><div id='root'></div></body></html>", {
|
||||||
serverId: "daemon-1",
|
url: "http://localhost",
|
||||||
cwd: "/path/to/project",
|
|
||||||
isOpen: false,
|
|
||||||
explorerTab: "changes",
|
|
||||||
}),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when tab is not changes", () => {
|
Object.defineProperty(globalThis, "document", {
|
||||||
expect(
|
value: dom.window.document,
|
||||||
checkoutStatusRevalidationKey({
|
configurable: true,
|
||||||
serverId: "daemon-1",
|
});
|
||||||
cwd: "/path/to/project",
|
Object.defineProperty(globalThis, "window", {
|
||||||
isOpen: true,
|
value: dom.window,
|
||||||
explorerTab: "files",
|
configurable: true,
|
||||||
}),
|
});
|
||||||
).toBeNull();
|
Object.defineProperty(globalThis, "navigator", {
|
||||||
|
value: dom.window.navigator,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||||
|
value: true,
|
||||||
|
configurable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns a stable key when open and on changes tab", () => {
|
mockRuntime.client = mockClient;
|
||||||
expect(
|
mockRuntime.isConnected = true;
|
||||||
checkoutStatusRevalidationKey({
|
mockClient.getCheckoutStatus.mockReset();
|
||||||
serverId: "daemon-1",
|
mockClient.on.mockClear();
|
||||||
cwd: "/path/to/project",
|
checkoutStatusUpdateHandlers.clear();
|
||||||
isOpen: true,
|
|
||||||
explorerTab: "changes",
|
|
||||||
}),
|
|
||||||
).toBe("daemon-1:/path/to/project");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("nextCheckoutStatusRefetchDecision", () => {
|
afterEach(() => {
|
||||||
it("refetches only once per key until reset", () => {
|
focusManager.setFocused(undefined);
|
||||||
const key = "daemon-1:/path/to/project";
|
onlineManager.setOnline(true);
|
||||||
|
vi.useRealTimers();
|
||||||
expect(nextCheckoutStatusRefetchDecision(null, key)).toEqual({
|
|
||||||
nextSeenKey: key,
|
|
||||||
shouldRefetch: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(nextCheckoutStatusRefetchDecision(key, key)).toEqual({
|
it("does not schedule polling after the cold read", async () => {
|
||||||
nextSeenKey: key,
|
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus());
|
||||||
shouldRefetch: false,
|
const hook = renderCheckoutStatusHook();
|
||||||
|
|
||||||
|
await hook.mount();
|
||||||
|
await waitForExpectation(() => {
|
||||||
|
expect(hook.latest.status?.currentBranch).toBe("main");
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(nextCheckoutStatusRefetchDecision(key, null)).toEqual({
|
await act(async () => {
|
||||||
nextSeenKey: null,
|
vi.advanceTimersByTime(60_000);
|
||||||
shouldRefetch: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(nextCheckoutStatusRefetchDecision(null, key)).toEqual({
|
expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||||
nextSeenKey: key,
|
|
||||||
shouldRefetch: true,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refetches again when cwd changes while active", () => {
|
it("returns the cached snapshot without calling the daemon", async () => {
|
||||||
expect(nextCheckoutStatusRefetchDecision("daemon-1:/path/a", "daemon-1:/path/b")).toEqual({
|
const queryClient = createTestQueryClient();
|
||||||
nextSeenKey: "daemon-1:/path/b",
|
const cached = checkoutStatus({ requestId: "cached" });
|
||||||
shouldRefetch: true,
|
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), cached);
|
||||||
|
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus({ requestId: "uncached" }));
|
||||||
|
const hook = renderCheckoutStatusHook({ queryClient });
|
||||||
|
|
||||||
|
await hook.mount();
|
||||||
|
|
||||||
|
expect(hook.latest.status).toEqual(cached);
|
||||||
|
expect(mockClient.getCheckoutStatus).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fetches once on a cold read and uses cache on remount", async () => {
|
||||||
|
const queryClient = createTestQueryClient();
|
||||||
|
const fetched = checkoutStatus({ requestId: "cold-read" });
|
||||||
|
mockClient.getCheckoutStatus.mockResolvedValue(fetched);
|
||||||
|
const firstHook = renderCheckoutStatusHook({ queryClient });
|
||||||
|
|
||||||
|
await firstHook.mount();
|
||||||
|
await waitForExpectation(() => {
|
||||||
|
expect(firstHook.latest.status).toEqual(fetched);
|
||||||
});
|
});
|
||||||
|
await firstHook.unmount();
|
||||||
|
|
||||||
|
const secondHook = renderCheckoutStatusHook({ queryClient });
|
||||||
|
await secondHook.mount();
|
||||||
|
|
||||||
|
expect(secondHook.latest.status).toEqual(fetched);
|
||||||
|
expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not refetch checkout status on focus or reconnect", async () => {
|
||||||
|
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus());
|
||||||
|
const hook = renderCheckoutStatusHook();
|
||||||
|
|
||||||
|
await hook.mount();
|
||||||
|
await waitForExpectation(() => {
|
||||||
|
expect(hook.latest.status?.currentBranch).toBe("main");
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
focusManager.setFocused(false);
|
||||||
|
focusManager.setFocused(true);
|
||||||
|
onlineManager.setOnline(false);
|
||||||
|
onlineManager.setOnline(true);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("server-pushed checkout status update writes into the React Query cache at checkoutStatusQueryKey(serverId, cwd)", async () => {
|
||||||
|
const queryClient = createTestQueryClient();
|
||||||
|
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus({ requestId: "initial" }));
|
||||||
|
const hook = renderCheckoutStatusHook({ queryClient });
|
||||||
|
|
||||||
|
await hook.mount();
|
||||||
|
await waitForExpectation(() => {
|
||||||
|
expect(hook.latest.status?.requestId).toBe("initial");
|
||||||
|
});
|
||||||
|
|
||||||
|
mockClient.getCheckoutStatus.mockClear();
|
||||||
|
const pushed = checkoutStatus({
|
||||||
|
requestId: "server-push",
|
||||||
|
currentBranch: "pushed-branch",
|
||||||
|
isDirty: true,
|
||||||
|
aheadBehind: { ahead: 2, behind: 1 },
|
||||||
|
aheadOfOrigin: 2,
|
||||||
|
behindOfOrigin: 1,
|
||||||
|
});
|
||||||
|
await emitCheckoutStatusUpdate(pushed);
|
||||||
|
|
||||||
|
expect(mockClient.getCheckoutStatus).not.toHaveBeenCalled();
|
||||||
|
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("server push only updates the matching cwd's cache key, not other workspaces", async () => {
|
||||||
|
const queryClient = createTestQueryClient();
|
||||||
|
const otherCwd = "/other-repo";
|
||||||
|
const otherCached = checkoutStatus({
|
||||||
|
cwd: otherCwd,
|
||||||
|
repoRoot: otherCwd,
|
||||||
|
requestId: "other-cached",
|
||||||
|
currentBranch: "other-main",
|
||||||
|
});
|
||||||
|
queryClient.setQueryData(checkoutStatusQueryKey(serverId, otherCwd), otherCached);
|
||||||
|
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus({ requestId: "initial" }));
|
||||||
|
const hook = renderCheckoutStatusHook({ queryClient });
|
||||||
|
|
||||||
|
await hook.mount();
|
||||||
|
await waitForExpectation(() => {
|
||||||
|
expect(hook.latest.status?.requestId).toBe("initial");
|
||||||
|
});
|
||||||
|
|
||||||
|
const pushed = checkoutStatus({
|
||||||
|
requestId: "server-push",
|
||||||
|
currentBranch: "pushed-branch",
|
||||||
|
});
|
||||||
|
await emitCheckoutStatusUpdate(pushed);
|
||||||
|
|
||||||
|
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed);
|
||||||
|
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, otherCwd))).toEqual(
|
||||||
|
otherCached,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { type QueryClient, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect } from "react";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import type { CheckoutStatusResponse } from "@server/shared/messages";
|
import type { CheckoutStatusResponse } from "@server/shared/messages";
|
||||||
import {
|
|
||||||
checkoutStatusRevalidationKey,
|
|
||||||
nextCheckoutStatusRefetchDecision,
|
|
||||||
} from "./checkout-status-revalidation";
|
|
||||||
|
|
||||||
export const CHECKOUT_STATUS_STALE_TIME = 15_000;
|
export const CHECKOUT_STATUS_STALE_TIME = 15_000;
|
||||||
|
|
||||||
@@ -22,22 +16,56 @@ interface UseCheckoutStatusQueryOptions {
|
|||||||
|
|
||||||
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||||
|
|
||||||
|
interface CheckoutStatusClient {
|
||||||
|
getCheckoutStatus: (cwd: string) => Promise<CheckoutStatusPayload>;
|
||||||
|
}
|
||||||
|
|
||||||
function fetchCheckoutStatus(
|
function fetchCheckoutStatus(
|
||||||
client: { getCheckoutStatus: (cwd: string) => Promise<CheckoutStatusPayload> },
|
client: CheckoutStatusClient,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
): Promise<CheckoutStatusPayload> {
|
): Promise<CheckoutStatusPayload> {
|
||||||
return client.getCheckoutStatus(cwd);
|
return client.getCheckoutStatus(cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function peekOrFetchSnapshot({
|
||||||
|
queryClient,
|
||||||
|
client,
|
||||||
|
serverId,
|
||||||
|
cwd,
|
||||||
|
}: {
|
||||||
|
queryClient: QueryClient;
|
||||||
|
client: CheckoutStatusClient;
|
||||||
|
serverId: string;
|
||||||
|
cwd: string;
|
||||||
|
}): Promise<CheckoutStatusPayload> {
|
||||||
|
const queryKey = checkoutStatusQueryKey(serverId, cwd);
|
||||||
|
const cached = queryClient.getQueryData<CheckoutStatusPayload>(queryKey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = await fetchCheckoutStatus(client, cwd);
|
||||||
|
queryClient.setQueryData(queryKey, snapshot);
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) {
|
export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
const isMobile = useIsCompactFormFactor();
|
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
useEffect(() => {
|
||||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
if (!client || !isConnected || !cwd) {
|
||||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
return;
|
||||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
}
|
||||||
const shouldPoll = isOpen && explorerTab === "changes";
|
|
||||||
|
return client.on("checkout_status_update", (message) => {
|
||||||
|
if (message.payload.cwd !== cwd) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), message.payload);
|
||||||
|
});
|
||||||
|
}, [client, isConnected, cwd, queryClient, serverId]);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: checkoutStatusQueryKey(serverId, cwd),
|
queryKey: checkoutStatusQueryKey(serverId, cwd),
|
||||||
@@ -45,42 +73,21 @@ export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQuery
|
|||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error("Daemon client not available");
|
throw new Error("Daemon client not available");
|
||||||
}
|
}
|
||||||
return await fetchCheckoutStatus(client, cwd);
|
return await peekOrFetchSnapshot({ queryClient, client, serverId, cwd });
|
||||||
},
|
},
|
||||||
enabled: !!client && isConnected && !!cwd,
|
enabled: !!client && isConnected && !!cwd,
|
||||||
staleTime: CHECKOUT_STATUS_STALE_TIME,
|
staleTime: Infinity,
|
||||||
refetchInterval: (query) => {
|
refetchOnMount: false,
|
||||||
if (!shouldPoll) return false;
|
refetchOnReconnect: false,
|
||||||
const data = query.state.data as CheckoutStatusPayload | undefined;
|
refetchOnWindowFocus: false,
|
||||||
return data?.isGit ? 10_000 : false;
|
|
||||||
},
|
|
||||||
refetchIntervalInBackground: shouldPoll,
|
|
||||||
refetchOnMount: "always",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Revalidate when sidebar is open with "changes" tab active.
|
|
||||||
const revalidationKey = useMemo(
|
|
||||||
() => checkoutStatusRevalidationKey({ serverId, cwd, isOpen, explorerTab }),
|
|
||||||
[serverId, cwd, isOpen, explorerTab],
|
|
||||||
);
|
|
||||||
const lastRevalidationKey = useRef<string | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
const decision = nextCheckoutStatusRefetchDecision(
|
|
||||||
lastRevalidationKey.current,
|
|
||||||
revalidationKey,
|
|
||||||
);
|
|
||||||
lastRevalidationKey.current = decision.nextSeenKey;
|
|
||||||
if (!decision.shouldRefetch) return;
|
|
||||||
void query.refetch();
|
|
||||||
}, [revalidationKey, query.refetch]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: query.data ?? null,
|
status: query.data ?? null,
|
||||||
isLoading: query.isLoading,
|
isLoading: query.isLoading,
|
||||||
isFetching: query.isFetching,
|
isFetching: query.isFetching,
|
||||||
isError: query.isError,
|
isError: query.isError,
|
||||||
error: query.error,
|
error: query.error,
|
||||||
refresh: query.refetch,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import React, { act } from "react";
|
import React, { act } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { JSDOM } from "jsdom";
|
import { JSDOM } from "jsdom";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import {
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
QueryClient,
|
||||||
|
QueryClientProvider,
|
||||||
|
focusManager,
|
||||||
|
onlineManager,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type {
|
import type {
|
||||||
CheckoutPrStatusResponse,
|
CheckoutPrStatusResponse,
|
||||||
PullRequestTimelineResponse,
|
PullRequestTimelineResponse,
|
||||||
@@ -18,14 +23,25 @@ type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"
|
|||||||
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
||||||
type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"];
|
type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"];
|
||||||
|
|
||||||
const { mockRuntime, mockClient } = vi.hoisted(() => {
|
const { mockRuntime, mockClient, checkoutStatusUpdateHandlers } = vi.hoisted(() => {
|
||||||
|
const checkoutStatusUpdateHandlers = new Set<(message: unknown) => void>();
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
checkoutPrStatus: vi.fn(),
|
checkoutPrStatus: vi.fn(),
|
||||||
pullRequestTimeline: vi.fn(),
|
pullRequestTimeline: vi.fn(),
|
||||||
|
on: vi.fn((type: string, handler: (message: unknown) => void) => {
|
||||||
|
if (type !== "checkout_status_update") {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
checkoutStatusUpdateHandlers.add(handler);
|
||||||
|
return () => {
|
||||||
|
checkoutStatusUpdateHandlers.delete(handler);
|
||||||
|
};
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mockClient,
|
mockClient,
|
||||||
|
checkoutStatusUpdateHandlers,
|
||||||
mockRuntime: {
|
mockRuntime: {
|
||||||
client: mockClient,
|
client: mockClient,
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
@@ -85,6 +101,34 @@ function timelinePayload(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function emitCheckoutStatusUpdatePrStatus(payload: CheckoutPrStatusPayload): Promise<void> {
|
||||||
|
await act(async () => {
|
||||||
|
for (const handler of checkoutStatusUpdateHandlers) {
|
||||||
|
handler({
|
||||||
|
type: "checkout_status_update",
|
||||||
|
payload: {
|
||||||
|
cwd: payload.cwd,
|
||||||
|
requestId: `subscription:${payload.cwd}`,
|
||||||
|
isGit: true,
|
||||||
|
isPaseoOwnedWorktree: false,
|
||||||
|
repoRoot: payload.cwd,
|
||||||
|
currentBranch: "main",
|
||||||
|
isDirty: false,
|
||||||
|
baseRef: "main",
|
||||||
|
aheadBehind: { ahead: 0, behind: 0 },
|
||||||
|
aheadOfOrigin: 0,
|
||||||
|
behindOfOrigin: 0,
|
||||||
|
hasRemote: true,
|
||||||
|
remoteUrl: "https://github.com/getpaseo/paseo.git",
|
||||||
|
error: null,
|
||||||
|
prStatus: payload,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function unsupportedTimelineError(): Error {
|
function unsupportedTimelineError(): Error {
|
||||||
const error = new Error(
|
const error = new Error(
|
||||||
"Unknown request schema requestType=pull_request_timeline_request code=unknown_schema",
|
"Unknown request schema requestType=pull_request_timeline_request code=unknown_schema",
|
||||||
@@ -246,6 +290,14 @@ describe("usePrPaneData", () => {
|
|||||||
mockRuntime.isConnected = true;
|
mockRuntime.isConnected = true;
|
||||||
mockClient.checkoutPrStatus.mockReset();
|
mockClient.checkoutPrStatus.mockReset();
|
||||||
mockClient.pullRequestTimeline.mockReset();
|
mockClient.pullRequestTimeline.mockReset();
|
||||||
|
mockClient.on.mockClear();
|
||||||
|
checkoutStatusUpdateHandlers.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
focusManager.setFocused(undefined);
|
||||||
|
onlineManager.setOnline(true);
|
||||||
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when status has no PR number", async () => {
|
it("returns null when status has no PR number", async () => {
|
||||||
@@ -349,6 +401,82 @@ describe("usePrPaneData", () => {
|
|||||||
expect(mockClient.checkoutPrStatus).toHaveBeenCalledTimes(1);
|
expect(mockClient.checkoutPrStatus).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("server-pushed PR status update writes into the checkout PR status cache", async () => {
|
||||||
|
const queryClient = createTestQueryClient();
|
||||||
|
mockClient.checkoutPrStatus.mockResolvedValue(
|
||||||
|
statusPayload({
|
||||||
|
status: status({
|
||||||
|
checksStatus: "pending",
|
||||||
|
checks: [{ name: "test", status: "pending", url: "https://github.com/checks/1" }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload());
|
||||||
|
|
||||||
|
const hook = renderPrPaneHook({ queryClient });
|
||||||
|
await hook.mount();
|
||||||
|
await waitForExpectation(() => {
|
||||||
|
expect(hook.latest.data?.checks[0]?.status).toBe("pending");
|
||||||
|
});
|
||||||
|
|
||||||
|
mockClient.checkoutPrStatus.mockClear();
|
||||||
|
await emitCheckoutStatusUpdatePrStatus(
|
||||||
|
statusPayload({
|
||||||
|
requestId: "server-push",
|
||||||
|
status: status({
|
||||||
|
checksStatus: "success",
|
||||||
|
checks: [{ name: "test", status: "success", url: "https://github.com/checks/1" }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitForExpectation(() => {
|
||||||
|
expect(hook.latest.data?.checks[0]?.status).toBe("success");
|
||||||
|
});
|
||||||
|
expect(queryClient.getQueryData(["checkoutPrStatus", serverId, cwd])).toEqual(
|
||||||
|
statusPayload({
|
||||||
|
requestId: "server-push",
|
||||||
|
status: status({
|
||||||
|
checksStatus: "success",
|
||||||
|
checks: [{ name: "test", status: "success", url: "https://github.com/checks/1" }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockClient.checkoutPrStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("server-pushed PR status for a different cwd does not overwrite the current cache", async () => {
|
||||||
|
const queryClient = createTestQueryClient();
|
||||||
|
const initial = statusPayload({
|
||||||
|
status: status({
|
||||||
|
checksStatus: "pending",
|
||||||
|
checks: [{ name: "test", status: "pending", url: "https://github.com/checks/1" }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
mockClient.checkoutPrStatus.mockResolvedValue(initial);
|
||||||
|
mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload());
|
||||||
|
|
||||||
|
const hook = renderPrPaneHook({ queryClient });
|
||||||
|
await hook.mount();
|
||||||
|
await waitForExpectation(() => {
|
||||||
|
expect(hook.latest.data?.checks[0]?.status).toBe("pending");
|
||||||
|
});
|
||||||
|
|
||||||
|
await emitCheckoutStatusUpdatePrStatus(
|
||||||
|
statusPayload({
|
||||||
|
cwd: "/other-repo",
|
||||||
|
requestId: "other-server-push",
|
||||||
|
status: status({
|
||||||
|
checksStatus: "success",
|
||||||
|
checks: [{ name: "test", status: "success", url: "https://github.com/checks/1" }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(queryClient.getQueryData(["checkoutPrStatus", serverId, cwd])).toEqual(initial);
|
||||||
|
expect(queryClient.getQueryData(["checkoutPrStatus", serverId, "/other-repo"])).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("passes repoOwner and repoName to the timeline request when present", async () => {
|
it("passes repoOwner and repoName to the timeline request when present", async () => {
|
||||||
mockClient.checkoutPrStatus.mockResolvedValue(
|
mockClient.checkoutPrStatus.mockResolvedValue(
|
||||||
statusPayload({
|
statusPayload({
|
||||||
@@ -517,6 +645,50 @@ describe("usePrPaneData", () => {
|
|||||||
expect(mockClient.pullRequestTimeline).not.toHaveBeenCalled();
|
expect(mockClient.pullRequestTimeline).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not poll PR status or timeline after the initial load", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
mockClient.checkoutPrStatus.mockResolvedValue(statusPayload());
|
||||||
|
mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload());
|
||||||
|
|
||||||
|
const hook = renderPrPaneHook();
|
||||||
|
await hook.mount();
|
||||||
|
|
||||||
|
await waitForHookResult(() => {
|
||||||
|
expect(hook.latest.data?.number).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(120_000);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockClient.checkoutPrStatus).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockClient.pullRequestTimeline).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not refetch PR data on focus or reconnect", async () => {
|
||||||
|
mockClient.checkoutPrStatus.mockResolvedValue(statusPayload());
|
||||||
|
mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload());
|
||||||
|
|
||||||
|
const hook = renderPrPaneHook();
|
||||||
|
await hook.mount();
|
||||||
|
|
||||||
|
await waitForExpectation(() => {
|
||||||
|
expect(hook.latest.data?.number).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
focusManager.setFocused(false);
|
||||||
|
focusManager.setFocused(true);
|
||||||
|
onlineManager.setOnline(false);
|
||||||
|
onlineManager.setOnline(true);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockClient.checkoutPrStatus).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockClient.pullRequestTimeline).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("reports first-load, background refresh, and non-suppressed errors", async () => {
|
it("reports first-load, background refresh, and non-suppressed errors", async () => {
|
||||||
const statusDeferred = createDeferred<CheckoutPrStatusPayload>();
|
const statusDeferred = createDeferred<CheckoutPrStatusPayload>();
|
||||||
mockClient.checkoutPrStatus.mockReturnValue(statusDeferred.promise);
|
mockClient.checkoutPrStatus.mockReturnValue(statusDeferred.promise);
|
||||||
@@ -563,6 +735,25 @@ describe("usePrPaneData", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function waitForHookResult(assertion: () => void): Promise<void> {
|
||||||
|
let lastError: unknown;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||||
|
try {
|
||||||
|
assertion();
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(10);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
function createDeferred<T>(): {
|
function createDeferred<T>(): {
|
||||||
promise: Promise<T>;
|
promise: Promise<T>;
|
||||||
resolve: (value: T) => void;
|
resolve: (value: T) => void;
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ import type {
|
|||||||
import { mapPrPaneData, type PrPaneData } from "../utils/pr-pane-data";
|
import { mapPrPaneData, type PrPaneData } from "../utils/pr-pane-data";
|
||||||
import { useCheckoutPrStatusQuery } from "./use-checkout-pr-status-query";
|
import { useCheckoutPrStatusQuery } from "./use-checkout-pr-status-query";
|
||||||
|
|
||||||
const PR_PANE_TIMELINE_STALE_TIME = 15_000;
|
|
||||||
const PR_PANE_TIMELINE_REFETCH_INTERVAL = 30_000;
|
|
||||||
|
|
||||||
type CheckoutPrStatusPayloadError = CheckoutPrStatusResponse["payload"]["error"];
|
type CheckoutPrStatusPayloadError = CheckoutPrStatusResponse["payload"]["error"];
|
||||||
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
|
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
|
||||||
|
|
||||||
@@ -81,8 +78,10 @@ export function usePrPaneData({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled: shouldFetchTimeline,
|
enabled: shouldFetchTimeline,
|
||||||
staleTime: PR_PANE_TIMELINE_STALE_TIME,
|
staleTime: Infinity,
|
||||||
refetchInterval: PR_PANE_TIMELINE_REFETCH_INTERVAL,
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
retry: (failureCount, error) => !isUnsupportedTimelineError(error) && failureCount < 3,
|
retry: (failureCount, error) => !isUnsupportedTimelineError(error) && failureCount < 3,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,10 +19,7 @@ import { Combobox } from "@/components/ui/combobox";
|
|||||||
import { FileDropZone } from "@/components/file-drop-zone";
|
import { FileDropZone } from "@/components/file-drop-zone";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
||||||
import {
|
import { checkoutStatusQueryKey } from "@/hooks/use-checkout-status-query";
|
||||||
CHECKOUT_STATUS_STALE_TIME,
|
|
||||||
checkoutStatusQueryKey,
|
|
||||||
} from "@/hooks/use-checkout-status-query";
|
|
||||||
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions";
|
import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions";
|
||||||
@@ -341,8 +338,10 @@ function DraftAgentScreenContent({
|
|||||||
},
|
},
|
||||||
enabled: Boolean(trimmedWorkingDir) && canQuerySelectedHost,
|
enabled: Boolean(trimmedWorkingDir) && canQuerySelectedHost,
|
||||||
retry: false,
|
retry: false,
|
||||||
staleTime: CHECKOUT_STATUS_STALE_TIME,
|
staleTime: Infinity,
|
||||||
refetchOnMount: "always",
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const checkout = checkoutStatusQuery.data ?? null;
|
const checkout = checkoutStatusQuery.data ?? null;
|
||||||
@@ -398,8 +397,10 @@ function DraftAgentScreenContent({
|
|||||||
enabled:
|
enabled:
|
||||||
isGitDirectory && Boolean(worktreeListRoot) && canQuerySelectedHost && !isNonGitDirectory,
|
isGitDirectory && Boolean(worktreeListRoot) && canQuerySelectedHost && !isNonGitDirectory,
|
||||||
retry: false,
|
retry: false,
|
||||||
staleTime: 0,
|
staleTime: Infinity,
|
||||||
refetchOnMount: "always",
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
const worktreeOptions = useMemo(() => {
|
const worktreeOptions = useMemo(() => {
|
||||||
const options = (worktreeListQuery.data ?? []).map((worktree) => ({
|
const options = (worktreeListQuery.data ?? []).map((worktree) => ({
|
||||||
|
|||||||
@@ -162,6 +162,10 @@ export function NewWorkspaceScreen({
|
|||||||
return connectedClient.getCheckoutStatus(sourceDirectory);
|
return connectedClient.getCheckoutStatus(sourceDirectory);
|
||||||
},
|
},
|
||||||
enabled: isConnected && !!client,
|
enabled: isConnected && !!client,
|
||||||
|
staleTime: Infinity,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentBranch = checkoutStatusQuery.data?.currentBranch ?? null;
|
const currentBranch = checkoutStatusQuery.data?.currentBranch ?? null;
|
||||||
|
|||||||
@@ -741,9 +741,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const unsubscribeChanged = client.on("terminals_changed", (message) => {
|
const unsubscribeChanged = client.on("terminals_changed", (message) => {
|
||||||
if (message.type !== "terminals_changed") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (message.payload.cwd !== workspaceDirectory) {
|
if (message.payload.cwd !== workspaceDirectory) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -776,7 +773,10 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
}
|
}
|
||||||
return (await client.getCheckoutStatus(workspaceDirectory)) as CheckoutStatusPayload;
|
return (await client.getCheckoutStatus(workspaceDirectory)) as CheckoutStatusPayload;
|
||||||
},
|
},
|
||||||
staleTime: 15_000,
|
staleTime: Infinity,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
const isCheckoutStatusLoading =
|
const isCheckoutStatusLoading =
|
||||||
isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError;
|
isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError;
|
||||||
|
|||||||
@@ -945,9 +945,9 @@ export class DaemonClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
on(
|
on<TType extends SessionOutboundMessage["type"]>(
|
||||||
type: SessionOutboundMessage["type"],
|
type: TType,
|
||||||
handler: (message: SessionOutboundMessage) => void,
|
handler: (message: Extract<SessionOutboundMessage, { type: TType }>) => void,
|
||||||
): () => void;
|
): () => void;
|
||||||
on(handler: DaemonEventHandler): () => void;
|
on(handler: DaemonEventHandler): () => void;
|
||||||
on(
|
on(
|
||||||
@@ -3043,9 +3043,6 @@ export class DaemonClient {
|
|||||||
if (settled) {
|
if (settled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message.type !== "agent_update") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (message.payload.kind !== "upsert") {
|
if (message.payload.kind !== "upsert") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,17 +10,14 @@ import {
|
|||||||
generateStructuredAgentResponseWithFallback,
|
generateStructuredAgentResponseWithFallback,
|
||||||
} from "./agent-response-loop.js";
|
} from "./agent-response-loop.js";
|
||||||
import { validateBranchSlug } from "../../utils/worktree.js";
|
import { validateBranchSlug } from "../../utils/worktree.js";
|
||||||
import {
|
import { renameCurrentBranch } from "../../utils/checkout-git.js";
|
||||||
getCheckoutStatus,
|
|
||||||
renameCurrentBranch,
|
|
||||||
type CheckoutStatusResult,
|
|
||||||
} from "../../utils/checkout-git.js";
|
|
||||||
import { MAX_AUTO_AGENT_TITLE_CHARS } from "./agent-title-limits.js";
|
import { MAX_AUTO_AGENT_TITLE_CHARS } from "./agent-title-limits.js";
|
||||||
|
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "../workspace-git-service.js";
|
||||||
|
|
||||||
export type AgentMetadataGeneratorDeps = {
|
export type AgentMetadataGeneratorDeps = {
|
||||||
generateStructuredAgentResponseWithFallback?: typeof generateStructuredAgentResponseWithFallback;
|
generateStructuredAgentResponseWithFallback?: typeof generateStructuredAgentResponseWithFallback;
|
||||||
getCheckoutStatus?: typeof getCheckoutStatus;
|
|
||||||
renameCurrentBranch?: typeof renameCurrentBranch;
|
renameCurrentBranch?: typeof renameCurrentBranch;
|
||||||
|
workspaceGitService?: Pick<WorkspaceGitService, "getSnapshot">;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AgentMetadataGenerationOptions = {
|
export type AgentMetadataGenerationOptions = {
|
||||||
@@ -54,26 +51,29 @@ function normalizeAutoTitle(title: string): string | null {
|
|||||||
|
|
||||||
async function canRenameBranch(
|
async function canRenameBranch(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
paseoHome: string | undefined,
|
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot"> | undefined,
|
||||||
getCheckoutStatusImpl: typeof getCheckoutStatus,
|
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
let status: CheckoutStatusResult;
|
if (!workspaceGitService) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshot: WorkspaceGitRuntimeSnapshot;
|
||||||
try {
|
try {
|
||||||
status = await getCheckoutStatusImpl(cwd, { paseoHome });
|
snapshot = await workspaceGitService.getSnapshot(cwd);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!status.isGit || !status.isPaseoOwnedWorktree) {
|
if (!snapshot.git.isGit || !snapshot.git.isPaseoOwnedWorktree) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!status.currentBranch) {
|
if (!snapshot.git.currentBranch || !snapshot.git.repoRoot) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const worktreeDirName = basename(status.repoRoot);
|
const worktreeDirName = basename(snapshot.git.repoRoot);
|
||||||
return status.currentBranch === worktreeDirName;
|
return snapshot.git.currentBranch === worktreeDirName;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function determineAgentMetadataNeeds(
|
export async function determineAgentMetadataNeeds(
|
||||||
@@ -88,8 +88,7 @@ export async function determineAgentMetadataNeeds(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const needsTitle = !hasExplicitTitle(options.explicitTitle);
|
const needsTitle = !hasExplicitTitle(options.explicitTitle);
|
||||||
const getCheckoutStatusImpl = options.deps?.getCheckoutStatus ?? getCheckoutStatus;
|
const needsBranch = await canRenameBranch(options.cwd, options.deps?.workspaceGitService);
|
||||||
const needsBranch = await canRenameBranch(options.cwd, options.paseoHome, getCheckoutStatusImpl);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prompt,
|
prompt,
|
||||||
@@ -155,7 +154,6 @@ export async function generateAndApplyAgentMetadata(
|
|||||||
const generator =
|
const generator =
|
||||||
options.deps?.generateStructuredAgentResponseWithFallback ??
|
options.deps?.generateStructuredAgentResponseWithFallback ??
|
||||||
generateStructuredAgentResponseWithFallback;
|
generateStructuredAgentResponseWithFallback;
|
||||||
const getCheckoutStatusImpl = options.deps?.getCheckoutStatus ?? getCheckoutStatus;
|
|
||||||
const renameCurrentBranchImpl = options.deps?.renameCurrentBranch ?? renameCurrentBranch;
|
const renameCurrentBranchImpl = options.deps?.renameCurrentBranch ?? renameCurrentBranch;
|
||||||
|
|
||||||
let result: { title?: string; branch?: string };
|
let result: { title?: string; branch?: string };
|
||||||
@@ -210,9 +208,14 @@ export async function generateAndApplyAgentMetadata(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let status: CheckoutStatusResult;
|
const workspaceGitService = options.deps?.workspaceGitService;
|
||||||
|
if (!workspaceGitService) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshot: WorkspaceGitRuntimeSnapshot;
|
||||||
try {
|
try {
|
||||||
status = await getCheckoutStatusImpl(options.cwd, { paseoHome: options.paseoHome });
|
snapshot = await workspaceGitService.getSnapshot(options.cwd);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
options.logger.warn(
|
options.logger.warn(
|
||||||
{ err: error, agentId: options.agentId },
|
{ err: error, agentId: options.agentId },
|
||||||
@@ -221,17 +224,28 @@ export async function generateAndApplyAgentMetadata(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!status.isGit || !status.isPaseoOwnedWorktree || !status.currentBranch) {
|
if (!snapshot.git.isGit || !snapshot.git.isPaseoOwnedWorktree || !snapshot.git.currentBranch) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const worktreeDirName = basename(status.repoRoot);
|
const worktreeDirName = snapshot.git.repoRoot ? basename(snapshot.git.repoRoot) : null;
|
||||||
if (status.currentBranch !== worktreeDirName) {
|
if (snapshot.git.currentBranch !== worktreeDirName) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await renameCurrentBranchImpl(options.cwd, normalizedBranch);
|
await renameCurrentBranchImpl(options.cwd, normalizedBranch);
|
||||||
|
try {
|
||||||
|
await workspaceGitService.getSnapshot(options.cwd, {
|
||||||
|
force: true,
|
||||||
|
reason: "rename-branch",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
options.logger.warn(
|
||||||
|
{ err: error, agentId: options.agentId, cwd: options.cwd },
|
||||||
|
"Failed to force-refresh workspace git snapshot after branch rename",
|
||||||
|
);
|
||||||
|
}
|
||||||
options.agentManager.notifyAgentState(options.agentId);
|
options.agentManager.notifyAgentState(options.agentId);
|
||||||
await options.agentManager.flush();
|
await options.agentManager.flush();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -7,30 +7,34 @@ import {
|
|||||||
type AgentMetadataGeneratorDeps,
|
type AgentMetadataGeneratorDeps,
|
||||||
} from "./agent-metadata-generator.js";
|
} from "./agent-metadata-generator.js";
|
||||||
import type { AgentManager } from "./agent-manager.js";
|
import type { AgentManager } from "./agent-manager.js";
|
||||||
|
import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js";
|
||||||
|
|
||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
|
|
||||||
const NON_GIT_CHECKOUT_STATUS = {
|
const ELIGIBLE_WORKTREE_SNAPSHOT: WorkspaceGitRuntimeSnapshot = {
|
||||||
isGit: false,
|
cwd: "/tmp/repo/metadata-worktree",
|
||||||
isPaseoOwnedWorktree: false,
|
git: {
|
||||||
currentBranch: null,
|
|
||||||
repoRoot: "/tmp/repo",
|
|
||||||
} as Awaited<ReturnType<NonNullable<AgentMetadataGeneratorDeps["getCheckoutStatus"]>>>;
|
|
||||||
|
|
||||||
const ELIGIBLE_WORKTREE_CHECKOUT_STATUS = {
|
|
||||||
isGit: true,
|
isGit: true,
|
||||||
repoRoot: "/tmp/repo/metadata-worktree",
|
repoRoot: "/tmp/repo/metadata-worktree",
|
||||||
mainRepoRoot: "/tmp/repo",
|
mainRepoRoot: "/tmp/repo",
|
||||||
currentBranch: "metadata-worktree",
|
currentBranch: "metadata-worktree",
|
||||||
|
remoteUrl: null,
|
||||||
|
isPaseoOwnedWorktree: true,
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
baseRef: "main",
|
baseRef: "main",
|
||||||
aheadBehind: null,
|
aheadBehind: null,
|
||||||
aheadOfOrigin: null,
|
aheadOfOrigin: null,
|
||||||
behindOfOrigin: null,
|
behindOfOrigin: null,
|
||||||
hasRemote: false,
|
hasRemote: false,
|
||||||
remoteUrl: null,
|
diffStat: null,
|
||||||
isPaseoOwnedWorktree: true,
|
},
|
||||||
} as Awaited<ReturnType<NonNullable<AgentMetadataGeneratorDeps["getCheckoutStatus"]>>>;
|
github: {
|
||||||
|
featuresEnabled: false,
|
||||||
|
pullRequest: null,
|
||||||
|
error: null,
|
||||||
|
refreshedAt: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
function createDeps(
|
function createDeps(
|
||||||
generateStructuredAgentResponseWithFallback: NonNullable<
|
generateStructuredAgentResponseWithFallback: NonNullable<
|
||||||
@@ -39,9 +43,6 @@ function createDeps(
|
|||||||
): AgentMetadataGeneratorDeps {
|
): AgentMetadataGeneratorDeps {
|
||||||
return {
|
return {
|
||||||
generateStructuredAgentResponseWithFallback,
|
generateStructuredAgentResponseWithFallback,
|
||||||
getCheckoutStatus: vi.fn().mockResolvedValue(NON_GIT_CHECKOUT_STATUS) as NonNullable<
|
|
||||||
AgentMetadataGeneratorDeps["getCheckoutStatus"]
|
|
||||||
>,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,9 +93,11 @@ describe("agent metadata generator auto-title", () => {
|
|||||||
it("notifies agent state after successfully renaming a generated branch", async () => {
|
it("notifies agent state after successfully renaming a generated branch", async () => {
|
||||||
const setTitle = vi.fn().mockResolvedValue(undefined);
|
const setTitle = vi.fn().mockResolvedValue(undefined);
|
||||||
const notifyAgentState = vi.fn();
|
const notifyAgentState = vi.fn();
|
||||||
|
const flush = vi.fn().mockResolvedValue(undefined);
|
||||||
const manager = {
|
const manager = {
|
||||||
setTitle,
|
setTitle,
|
||||||
notifyAgentState,
|
notifyAgentState,
|
||||||
|
flush,
|
||||||
} as unknown as AgentManager;
|
} as unknown as AgentManager;
|
||||||
const renameCurrentBranch = vi.fn().mockResolvedValue({
|
const renameCurrentBranch = vi.fn().mockResolvedValue({
|
||||||
previousBranch: "metadata-worktree",
|
previousBranch: "metadata-worktree",
|
||||||
@@ -103,11 +106,9 @@ describe("agent metadata generator auto-title", () => {
|
|||||||
const generateStructured = vi.fn().mockResolvedValue({
|
const generateStructured = vi.fn().mockResolvedValue({
|
||||||
branch: "feature/metadata-worktree",
|
branch: "feature/metadata-worktree",
|
||||||
}) as NonNullable<AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]>;
|
}) as NonNullable<AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]>;
|
||||||
const getCheckoutStatus = vi
|
const workspaceGitService = {
|
||||||
.fn()
|
getSnapshot: vi.fn().mockResolvedValue(ELIGIBLE_WORKTREE_SNAPSHOT),
|
||||||
.mockResolvedValue(ELIGIBLE_WORKTREE_CHECKOUT_STATUS) as NonNullable<
|
};
|
||||||
AgentMetadataGeneratorDeps["getCheckoutStatus"]
|
|
||||||
>;
|
|
||||||
|
|
||||||
await generateAndApplyAgentMetadata({
|
await generateAndApplyAgentMetadata({
|
||||||
agentManager: manager,
|
agentManager: manager,
|
||||||
@@ -119,8 +120,8 @@ describe("agent metadata generator auto-title", () => {
|
|||||||
logger,
|
logger,
|
||||||
deps: {
|
deps: {
|
||||||
generateStructuredAgentResponseWithFallback: generateStructured,
|
generateStructuredAgentResponseWithFallback: generateStructured,
|
||||||
getCheckoutStatus,
|
|
||||||
renameCurrentBranch,
|
renameCurrentBranch,
|
||||||
|
workspaceGitService,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -131,4 +132,86 @@ describe("agent metadata generator auto-title", () => {
|
|||||||
expect(notifyAgentState).toHaveBeenCalledWith("agent-branch");
|
expect(notifyAgentState).toHaveBeenCalledWith("agent-branch");
|
||||||
expect(setTitle).not.toHaveBeenCalled();
|
expect(setTitle).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forces a workspace git snapshot refresh after renaming a generated branch", async () => {
|
||||||
|
const manager = {
|
||||||
|
setTitle: vi.fn().mockResolvedValue(undefined),
|
||||||
|
notifyAgentState: vi.fn(),
|
||||||
|
flush: vi.fn().mockResolvedValue(undefined),
|
||||||
|
} as unknown as AgentManager;
|
||||||
|
const renameCurrentBranch = vi.fn().mockResolvedValue({
|
||||||
|
previousBranch: "metadata-worktree",
|
||||||
|
currentBranch: "feature/metadata-worktree",
|
||||||
|
}) as NonNullable<AgentMetadataGeneratorDeps["renameCurrentBranch"]>;
|
||||||
|
const generateStructured = vi.fn().mockResolvedValue({
|
||||||
|
branch: "feature/metadata-worktree",
|
||||||
|
}) as NonNullable<AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]>;
|
||||||
|
const workspaceGitService = {
|
||||||
|
getSnapshot: vi.fn().mockResolvedValue(ELIGIBLE_WORKTREE_SNAPSHOT),
|
||||||
|
};
|
||||||
|
|
||||||
|
await generateAndApplyAgentMetadata({
|
||||||
|
agentManager: manager,
|
||||||
|
agentId: "agent-branch-refresh",
|
||||||
|
cwd: "/tmp/repo/metadata-worktree",
|
||||||
|
initialPrompt: "Rename this worktree branch.",
|
||||||
|
explicitTitle: "Keep explicit title",
|
||||||
|
logger,
|
||||||
|
deps: {
|
||||||
|
generateStructuredAgentResponseWithFallback: generateStructured,
|
||||||
|
renameCurrentBranch,
|
||||||
|
workspaceGitService: workspaceGitService as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo/metadata-worktree", {
|
||||||
|
force: true,
|
||||||
|
reason: "rename-branch",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the workspace git service snapshot for branch rename eligibility checks", async () => {
|
||||||
|
const setTitle = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const notifyAgentState = vi.fn();
|
||||||
|
const manager = {
|
||||||
|
setTitle,
|
||||||
|
notifyAgentState,
|
||||||
|
flush: vi.fn().mockResolvedValue(undefined),
|
||||||
|
} as unknown as AgentManager;
|
||||||
|
const workspaceGitService = {
|
||||||
|
getSnapshot: vi.fn().mockResolvedValue(ELIGIBLE_WORKTREE_SNAPSHOT),
|
||||||
|
};
|
||||||
|
const renameCurrentBranch = vi.fn().mockResolvedValue({
|
||||||
|
previousBranch: "metadata-worktree",
|
||||||
|
currentBranch: "feature/metadata-worktree",
|
||||||
|
}) as NonNullable<AgentMetadataGeneratorDeps["renameCurrentBranch"]>;
|
||||||
|
const generateStructured = vi.fn().mockResolvedValue({
|
||||||
|
branch: "feature/metadata-worktree",
|
||||||
|
}) as NonNullable<AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]>;
|
||||||
|
|
||||||
|
await generateAndApplyAgentMetadata({
|
||||||
|
agentManager: manager,
|
||||||
|
agentId: "agent-service-branch",
|
||||||
|
cwd: "/tmp/repo/metadata-worktree",
|
||||||
|
initialPrompt: "Rename this worktree branch.",
|
||||||
|
explicitTitle: "Keep explicit title",
|
||||||
|
logger,
|
||||||
|
deps: {
|
||||||
|
generateStructuredAgentResponseWithFallback: generateStructured,
|
||||||
|
renameCurrentBranch,
|
||||||
|
workspaceGitService: workspaceGitService as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledTimes(3);
|
||||||
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo/metadata-worktree");
|
||||||
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo/metadata-worktree", {
|
||||||
|
force: true,
|
||||||
|
reason: "rename-branch",
|
||||||
|
});
|
||||||
|
expect(renameCurrentBranch).toHaveBeenCalledWith(
|
||||||
|
"/tmp/repo/metadata-worktree",
|
||||||
|
"feature/metadata-worktree",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ function createGitHubServiceStub(): GitHubService {
|
|||||||
return {
|
return {
|
||||||
listPullRequests: async () => [],
|
listPullRequests: async () => [],
|
||||||
listIssues: async () => [],
|
listIssues: async () => [],
|
||||||
|
searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }),
|
||||||
getPullRequest: async ({ number }) => ({
|
getPullRequest: async ({ number }) => ({
|
||||||
number,
|
number,
|
||||||
title: `PR ${number}`,
|
title: `PR ${number}`,
|
||||||
@@ -168,8 +169,8 @@ function createPaseoWorktreeForMcpTest(options: {
|
|||||||
const coreDeps = createWorktreeCoreDeps(github);
|
const coreDeps = createWorktreeCoreDeps(github);
|
||||||
const result = await createPaseoWorktreeService(input, {
|
const result = await createPaseoWorktreeService(input, {
|
||||||
...coreDeps,
|
...coreDeps,
|
||||||
...(serviceOptions?.resolveRepositoryDefaultBranch
|
...(serviceOptions?.resolveDefaultBranch
|
||||||
? { resolveRepositoryDefaultBranch: serviceOptions.resolveRepositoryDefaultBranch }
|
? { resolveDefaultBranch: serviceOptions.resolveDefaultBranch }
|
||||||
: {}),
|
: {}),
|
||||||
projectRegistry: {
|
projectRegistry: {
|
||||||
get: async (projectId) => projects.get(projectId) ?? null,
|
get: async (projectId) => projects.get(projectId) ?? null,
|
||||||
@@ -475,12 +476,16 @@ describe("create_agent MCP tool", () => {
|
|||||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
||||||
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
||||||
|
const workspaceGitService = {
|
||||||
|
getSnapshot: vi.fn(async () => null),
|
||||||
|
};
|
||||||
|
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
paseoHome,
|
paseoHome,
|
||||||
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }),
|
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }),
|
||||||
|
workspaceGitService: workspaceGitService as any,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = (server as any)._registeredTools["create_worktree"];
|
const tool = (server as any)._registeredTools["create_worktree"];
|
||||||
@@ -492,6 +497,17 @@ describe("create_agent MCP tool", () => {
|
|||||||
|
|
||||||
expect(response.structuredContent.branchName).toBe("tool-worktree");
|
expect(response.structuredContent.branchName).toBe("tool-worktree");
|
||||||
expect(response.structuredContent.worktreePath).toContain("tool-worktree");
|
expect(response.structuredContent.worktreePath).toContain("tool-worktree");
|
||||||
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, {
|
||||||
|
force: true,
|
||||||
|
reason: "create-worktree",
|
||||||
|
});
|
||||||
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(
|
||||||
|
response.structuredContent.worktreePath,
|
||||||
|
{
|
||||||
|
force: true,
|
||||||
|
reason: "create-worktree",
|
||||||
|
},
|
||||||
|
);
|
||||||
expect(broadcasts).toHaveLength(1);
|
expect(broadcasts).toHaveLength(1);
|
||||||
expect(broadcasts[0]).toContain("tool-worktree");
|
expect(broadcasts[0]).toContain("tool-worktree");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -499,6 +515,89 @@ describe("create_agent MCP tool", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forces a workspace git snapshot refresh when archive_worktree deletes a worktree", async () => {
|
||||||
|
const { agentManager, agentStorage } = createTestDeps();
|
||||||
|
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-"));
|
||||||
|
const repoDir = join(tempDir, "repo");
|
||||||
|
const paseoHome = join(tempDir, ".paseo");
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
|
||||||
|
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
|
||||||
|
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
|
||||||
|
await writeFile(join(repoDir, "README.md"), "hello\n");
|
||||||
|
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||||
|
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
|
||||||
|
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
|
||||||
|
|
||||||
|
const workspaceGitService = {
|
||||||
|
getSnapshot: vi.fn(async () => null),
|
||||||
|
};
|
||||||
|
const server = await createAgentMcpServer({
|
||||||
|
agentManager,
|
||||||
|
agentStorage,
|
||||||
|
paseoHome,
|
||||||
|
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts: [] }),
|
||||||
|
workspaceGitService: workspaceGitService as any,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
const createTool = (server as any)._registeredTools["create_worktree"];
|
||||||
|
const archiveTool = (server as any)._registeredTools["archive_worktree"];
|
||||||
|
const created = await createTool.callback({
|
||||||
|
cwd: repoDir,
|
||||||
|
branchName: "archive-tool-worktree",
|
||||||
|
baseBranch: "main",
|
||||||
|
});
|
||||||
|
workspaceGitService.getSnapshot.mockClear();
|
||||||
|
|
||||||
|
await archiveTool.callback({
|
||||||
|
cwd: repoDir,
|
||||||
|
worktreePath: created.structuredContent.worktreePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, {
|
||||||
|
force: true,
|
||||||
|
reason: "archive-worktree",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes list_worktrees through WorkspaceGitService", async () => {
|
||||||
|
const { agentManager, agentStorage } = createTestDeps();
|
||||||
|
const workspaceGitService = {
|
||||||
|
getSnapshot: vi.fn(async () => null),
|
||||||
|
listWorktrees: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
path: "/tmp/paseo/worktrees/repo/feature",
|
||||||
|
branchName: "feature",
|
||||||
|
createdAt: "2026-04-12T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
const server = await createAgentMcpServer({
|
||||||
|
agentManager,
|
||||||
|
agentStorage,
|
||||||
|
workspaceGitService: workspaceGitService as any,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
const tool = (server as any)._registeredTools["list_worktrees"];
|
||||||
|
|
||||||
|
const response = await tool.callback({ cwd: "/tmp/repo" });
|
||||||
|
|
||||||
|
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith("/tmp/repo", {
|
||||||
|
reason: "mcp:list-worktrees",
|
||||||
|
});
|
||||||
|
expect(response.structuredContent.worktrees).toEqual([
|
||||||
|
{
|
||||||
|
path: "/tmp/paseo/worktrees/repo/feature",
|
||||||
|
branchName: "feature",
|
||||||
|
createdAt: "2026-04-12T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("accepts custom provider IDs in create_agent input validation", async () => {
|
it("accepts custom provider IDs in create_agent input validation", async () => {
|
||||||
const { agentManager, agentStorage } = createTestDeps();
|
const { agentManager, agentStorage } = createTestDeps();
|
||||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||||
|
|||||||
@@ -20,11 +20,7 @@ import {
|
|||||||
appendTimelineItemIfAgentKnown,
|
appendTimelineItemIfAgentKnown,
|
||||||
emitLiveTimelineItemIfAgentKnown,
|
emitLiveTimelineItemIfAgentKnown,
|
||||||
} from "./timeline-append.js";
|
} from "./timeline-append.js";
|
||||||
import {
|
import { deletePaseoWorktree, type WorktreeConfig } from "../../utils/worktree.js";
|
||||||
deletePaseoWorktree,
|
|
||||||
listPaseoWorktrees,
|
|
||||||
type WorktreeConfig,
|
|
||||||
} from "../../utils/worktree.js";
|
|
||||||
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
|
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
|
||||||
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
||||||
import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
|
import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
|
||||||
@@ -51,6 +47,7 @@ import {
|
|||||||
waitForAgentWithTimeout,
|
waitForAgentWithTimeout,
|
||||||
} from "./mcp-shared.js";
|
} from "./mcp-shared.js";
|
||||||
import type { GitHubService } from "../../services/github-service.js";
|
import type { GitHubService } from "../../services/github-service.js";
|
||||||
|
import type { WorkspaceGitService } from "../workspace-git-service.js";
|
||||||
import type {
|
import type {
|
||||||
CreatePaseoWorktreeFn,
|
CreatePaseoWorktreeFn,
|
||||||
CreatePaseoWorktreeInput,
|
CreatePaseoWorktreeInput,
|
||||||
@@ -66,6 +63,7 @@ export interface AgentMcpServerOptions {
|
|||||||
scheduleService?: ScheduleService | null;
|
scheduleService?: ScheduleService | null;
|
||||||
providerRegistry?: Record<AgentProvider, ProviderDefinition> | null;
|
providerRegistry?: Record<AgentProvider, ProviderDefinition> | null;
|
||||||
github?: GitHubService;
|
github?: GitHubService;
|
||||||
|
workspaceGitService?: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">;
|
||||||
createPaseoWorktree?: CreatePaseoWorktreeFn;
|
createPaseoWorktree?: CreatePaseoWorktreeFn;
|
||||||
paseoHome?: string;
|
paseoHome?: string;
|
||||||
/**
|
/**
|
||||||
@@ -562,7 +560,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
paseoHome: options.paseoHome,
|
paseoHome: options.paseoHome,
|
||||||
},
|
},
|
||||||
createPaseoWorktree: options.createPaseoWorktree,
|
createPaseoWorktree: options.createPaseoWorktree,
|
||||||
resolveRepositoryDefaultBranch: baseBranch ? async () => baseBranch : undefined,
|
resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined,
|
||||||
|
workspaceGitService: options.workspaceGitService,
|
||||||
|
logger: options.logger,
|
||||||
});
|
});
|
||||||
resolvedCwd = createdWorktree.worktree.worktreePath;
|
resolvedCwd = createdWorktree.worktree.worktreePath;
|
||||||
worktreeConfig = createdWorktree.worktree;
|
worktreeConfig = createdWorktree.worktree;
|
||||||
@@ -622,6 +622,11 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
explicitTitle: snapshot.config.title,
|
explicitTitle: snapshot.config.title,
|
||||||
paseoHome: options.paseoHome,
|
paseoHome: options.paseoHome,
|
||||||
logger: childLogger,
|
logger: childLogger,
|
||||||
|
deps: options.workspaceGitService
|
||||||
|
? {
|
||||||
|
workspaceGitService: options.workspaceGitService,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1521,9 +1526,11 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
async ({ cwd }) => {
|
async ({ cwd }) => {
|
||||||
const resolvedCwd = resolveScopedCwd(cwd, { required: true });
|
const resolvedCwd = resolveScopedCwd(cwd, { required: true });
|
||||||
const worktrees = await listPaseoWorktrees({
|
if (!options.workspaceGitService) {
|
||||||
cwd: resolvedCwd,
|
throw new Error("WorkspaceGitService is required to list worktrees");
|
||||||
paseoHome: options.paseoHome,
|
}
|
||||||
|
const worktrees = await options.workspaceGitService.listWorktrees(resolvedCwd, {
|
||||||
|
reason: "mcp:list-worktrees",
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1570,7 +1577,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
paseoHome: options.paseoHome,
|
paseoHome: options.paseoHome,
|
||||||
},
|
},
|
||||||
createPaseoWorktree: options.createPaseoWorktree,
|
createPaseoWorktree: options.createPaseoWorktree,
|
||||||
resolveRepositoryDefaultBranch: baseBranch ? async () => baseBranch : undefined,
|
resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined,
|
||||||
|
workspaceGitService: options.workspaceGitService,
|
||||||
|
logger: options.logger,
|
||||||
});
|
});
|
||||||
const { worktree } = createdWorktree;
|
const { worktree } = createdWorktree;
|
||||||
|
|
||||||
@@ -1602,12 +1611,25 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({ cwd, worktreePath, worktreeSlug }) => {
|
async ({ cwd, worktreePath, worktreeSlug }) => {
|
||||||
|
const repoRoot = resolveScopedCwd(cwd, { required: true });
|
||||||
await deletePaseoWorktree({
|
await deletePaseoWorktree({
|
||||||
cwd: resolveScopedCwd(cwd, { required: true }),
|
cwd: repoRoot,
|
||||||
worktreePath,
|
worktreePath,
|
||||||
worktreeSlug,
|
worktreeSlug,
|
||||||
paseoHome: options.paseoHome,
|
paseoHome: options.paseoHome,
|
||||||
});
|
});
|
||||||
|
options.github?.invalidate({ cwd: repoRoot });
|
||||||
|
try {
|
||||||
|
await options.workspaceGitService?.getSnapshot(repoRoot, {
|
||||||
|
force: true,
|
||||||
|
reason: "archive-worktree",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
options.logger.warn(
|
||||||
|
{ err: error, cwd: repoRoot },
|
||||||
|
"Failed to force-refresh workspace git snapshot after archiving worktree",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [],
|
content: [],
|
||||||
@@ -1759,7 +1781,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
interface CreateMcpWorktreeOptions {
|
interface CreateMcpWorktreeOptions {
|
||||||
input: CreatePaseoWorktreeInput;
|
input: CreatePaseoWorktreeInput;
|
||||||
createPaseoWorktree: CreatePaseoWorktreeFn | undefined;
|
createPaseoWorktree: CreatePaseoWorktreeFn | undefined;
|
||||||
resolveRepositoryDefaultBranch?: (repoRoot: string) => Promise<string>;
|
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||||
|
workspaceGitService?: Pick<WorkspaceGitService, "getSnapshot">;
|
||||||
|
logger: Logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createMcpWorktree(
|
async function createMcpWorktree(
|
||||||
@@ -1769,9 +1793,34 @@ async function createMcpWorktree(
|
|||||||
if (!options.createPaseoWorktree) {
|
if (!options.createPaseoWorktree) {
|
||||||
throw new Error("Paseo worktree service is not configured");
|
throw new Error("Paseo worktree service is not configured");
|
||||||
}
|
}
|
||||||
return await options.createPaseoWorktree(options.input, {
|
const result = await options.createPaseoWorktree(options.input, {
|
||||||
resolveRepositoryDefaultBranch: options.resolveRepositoryDefaultBranch,
|
resolveDefaultBranch: options.resolveDefaultBranch,
|
||||||
});
|
});
|
||||||
|
if (options.workspaceGitService) {
|
||||||
|
const refreshResults = await Promise.allSettled([
|
||||||
|
options.workspaceGitService.getSnapshot(options.input.cwd, {
|
||||||
|
force: true,
|
||||||
|
reason: "create-worktree",
|
||||||
|
}),
|
||||||
|
options.workspaceGitService.getSnapshot(result.worktree.worktreePath, {
|
||||||
|
force: true,
|
||||||
|
reason: "create-worktree",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
for (const [index, refreshResult] of refreshResults.entries()) {
|
||||||
|
if (refreshResult.status === "fulfilled") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
options.logger.warn(
|
||||||
|
{
|
||||||
|
err: refreshResult.reason,
|
||||||
|
cwd: index === 0 ? options.input.cwd : result.worktree.worktreePath,
|
||||||
|
},
|
||||||
|
"Failed to force-refresh workspace git snapshot after creating worktree",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw toWorktreeRequestError(error);
|
throw toWorktreeRequestError(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
ListPersistedAgentsOptions,
|
ListPersistedAgentsOptions,
|
||||||
PersistedAgentDescriptor,
|
PersistedAgentDescriptor,
|
||||||
} from "./agent-sdk-types.js";
|
} from "./agent-sdk-types.js";
|
||||||
|
import type { WorkspaceGitService } from "../workspace-git-service.js";
|
||||||
import type {
|
import type {
|
||||||
AgentProviderRuntimeSettingsMap,
|
AgentProviderRuntimeSettingsMap,
|
||||||
ProviderOverride,
|
ProviderOverride,
|
||||||
@@ -46,11 +47,13 @@ export interface ProviderDefinition extends AgentProviderDefinition {
|
|||||||
export type BuildProviderRegistryOptions = {
|
export type BuildProviderRegistryOptions = {
|
||||||
runtimeSettings?: AgentProviderRuntimeSettingsMap;
|
runtimeSettings?: AgentProviderRuntimeSettingsMap;
|
||||||
providerOverrides?: Record<string, ProviderOverride>;
|
providerOverrides?: Record<string, ProviderOverride>;
|
||||||
|
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProviderClientFactory = (
|
type ProviderClientFactory = (
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
runtimeSettings?: ProviderRuntimeSettings,
|
runtimeSettings?: ProviderRuntimeSettings,
|
||||||
|
options?: Pick<BuildProviderRegistryOptions, "workspaceGitService">,
|
||||||
) => AgentClient;
|
) => AgentClient;
|
||||||
|
|
||||||
type ResolvedProvider = {
|
type ResolvedProvider = {
|
||||||
@@ -67,7 +70,10 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
|||||||
logger,
|
logger,
|
||||||
runtimeSettings,
|
runtimeSettings,
|
||||||
}),
|
}),
|
||||||
codex: (logger, runtimeSettings) => new CodexAppServerAgentClient(logger, runtimeSettings),
|
codex: (logger, runtimeSettings, options) =>
|
||||||
|
new CodexAppServerAgentClient(logger, runtimeSettings, {
|
||||||
|
workspaceGitService: options?.workspaceGitService,
|
||||||
|
}),
|
||||||
copilot: (logger, runtimeSettings) =>
|
copilot: (logger, runtimeSettings) =>
|
||||||
new CopilotACPAgentClient({
|
new CopilotACPAgentClient({
|
||||||
logger,
|
logger,
|
||||||
@@ -342,6 +348,7 @@ function createRegistryEntry(
|
|||||||
function buildResolvedBuiltinProviders(
|
function buildResolvedBuiltinProviders(
|
||||||
providerOverrides: Record<string, ProviderOverride>,
|
providerOverrides: Record<string, ProviderOverride>,
|
||||||
runtimeSettings: AgentProviderRuntimeSettingsMap | undefined,
|
runtimeSettings: AgentProviderRuntimeSettingsMap | undefined,
|
||||||
|
options: Pick<BuildProviderRegistryOptions, "workspaceGitService">,
|
||||||
): Map<string, ResolvedProvider> {
|
): Map<string, ResolvedProvider> {
|
||||||
const resolvedProviders = new Map<string, ResolvedProvider>();
|
const resolvedProviders = new Map<string, ResolvedProvider>();
|
||||||
|
|
||||||
@@ -358,7 +365,10 @@ function buildResolvedBuiltinProviders(
|
|||||||
runtimeSettings: mergedRuntimeSettings,
|
runtimeSettings: mergedRuntimeSettings,
|
||||||
profileModels: override?.models ?? [],
|
profileModels: override?.models ?? [],
|
||||||
enabled: override?.enabled !== false,
|
enabled: override?.enabled !== false,
|
||||||
createBaseClient: (logger) => factory(logger, mergedRuntimeSettings),
|
createBaseClient: (logger) =>
|
||||||
|
factory(logger, mergedRuntimeSettings, {
|
||||||
|
workspaceGitService: options.workspaceGitService,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,7 +448,9 @@ export function buildProviderRegistry(
|
|||||||
): Record<AgentProvider, ProviderDefinition> {
|
): Record<AgentProvider, ProviderDefinition> {
|
||||||
const runtimeSettings = options?.runtimeSettings;
|
const runtimeSettings = options?.runtimeSettings;
|
||||||
const providerOverrides = options?.providerOverrides ?? {};
|
const providerOverrides = options?.providerOverrides ?? {};
|
||||||
const resolvedProviders = buildResolvedBuiltinProviders(providerOverrides, runtimeSettings);
|
const resolvedProviders = buildResolvedBuiltinProviders(providerOverrides, runtimeSettings, {
|
||||||
|
workspaceGitService: options?.workspaceGitService,
|
||||||
|
});
|
||||||
addDerivedProviders(resolvedProviders, providerOverrides);
|
addDerivedProviders(resolvedProviders, providerOverrides);
|
||||||
|
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import { existsSync, rmSync } from "node:fs";
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { mkdtemp } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AgentLaunchContext,
|
AgentLaunchContext,
|
||||||
@@ -43,6 +46,34 @@ function createSession(configOverrides: Partial<AgentSessionConfig> = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Codex app-server provider", () => {
|
describe("Codex app-server provider", () => {
|
||||||
|
test("lists repo skills using WorkspaceGitService repo-root resolution", async () => {
|
||||||
|
const tempDir = await mkdtemp(path.join(tmpdir(), "codex-skills-"));
|
||||||
|
const cwd = path.join(tempDir, "repo", "packages", "app");
|
||||||
|
const repoSkillDir = path.join(tempDir, "repo", ".codex", "skills", "shipper");
|
||||||
|
mkdirSync(cwd, { recursive: true });
|
||||||
|
mkdirSync(repoSkillDir, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
path.join(repoSkillDir, "SKILL.md"),
|
||||||
|
"---\nname: shipper\ndescription: Ship changes carefully.\n---\n",
|
||||||
|
);
|
||||||
|
const workspaceGitService = {
|
||||||
|
resolveRepoRoot: vi.fn().mockResolvedValue(path.join(tempDir, "repo")),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(
|
||||||
|
__codexAppServerInternals.listCodexSkills(cwd, workspaceGitService),
|
||||||
|
).resolves.toContainEqual({
|
||||||
|
name: "shipper",
|
||||||
|
description: "Ship changes carefully.",
|
||||||
|
argumentHint: "",
|
||||||
|
});
|
||||||
|
expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(cwd);
|
||||||
|
} finally {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
|
|
||||||
test("extracts context window usage from snake_case token payloads", () => {
|
test("extracts context window usage from snake_case token payloads", () => {
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import type {
|
|||||||
} from "../agent-sdk-types.js";
|
} from "../agent-sdk-types.js";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
import { execSync } from "node:child_process";
|
|
||||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { Dirent } from "node:fs";
|
import { Dirent } from "node:fs";
|
||||||
@@ -59,6 +58,7 @@ import {
|
|||||||
resolveBinaryVersion,
|
resolveBinaryVersion,
|
||||||
toDiagnosticErrorMessage,
|
toDiagnosticErrorMessage,
|
||||||
} from "./diagnostic-utils.js";
|
} from "./diagnostic-utils.js";
|
||||||
|
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
|
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
|
||||||
const TURN_START_TIMEOUT_MS = 90 * 1000;
|
const TURN_START_TIMEOUT_MS = 90 * 1000;
|
||||||
@@ -91,6 +91,10 @@ const CODEX_MODES: AgentMode[] = [
|
|||||||
|
|
||||||
const DEFAULT_CODEX_MODE_ID = "auto";
|
const DEFAULT_CODEX_MODE_ID = "auto";
|
||||||
|
|
||||||
|
type CodexAppServerAgentDeps = {
|
||||||
|
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
||||||
|
};
|
||||||
|
|
||||||
const MODE_PRESETS: Record<
|
const MODE_PRESETS: Record<
|
||||||
string,
|
string,
|
||||||
{ approvalPolicy: string; sandbox: string; networkAccess?: boolean }
|
{ approvalPolicy: string; sandbox: string; networkAccess?: boolean }
|
||||||
@@ -370,23 +374,16 @@ async function listCodexCustomPrompts(): Promise<AgentSlashCommand[]> {
|
|||||||
return commands.sort((a, b) => a.name.localeCompare(b.name));
|
return commands.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listCodexSkills(cwd: string): Promise<AgentSlashCommand[]> {
|
async function listCodexSkills(
|
||||||
|
cwd: string,
|
||||||
|
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">,
|
||||||
|
): Promise<AgentSlashCommand[]> {
|
||||||
const candidates: string[] = [];
|
const candidates: string[] = [];
|
||||||
candidates.push(path.join(cwd, ".codex", "skills"));
|
candidates.push(path.join(cwd, ".codex", "skills"));
|
||||||
|
|
||||||
const repoRoot = (() => {
|
const repoRoot = workspaceGitService
|
||||||
try {
|
? await workspaceGitService.resolveRepoRoot(cwd).catch(() => null)
|
||||||
const output = execSync("git rev-parse --show-toplevel", {
|
: null;
|
||||||
cwd,
|
|
||||||
encoding: "utf8",
|
|
||||||
stdio: ["ignore", "pipe", "ignore"],
|
|
||||||
});
|
|
||||||
const trimmed = output.trim();
|
|
||||||
return trimmed ? trimmed : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
if (repoRoot) {
|
if (repoRoot) {
|
||||||
candidates.push(path.join(path.dirname(cwd), ".codex", "skills"));
|
candidates.push(path.join(path.dirname(cwd), ".codex", "skills"));
|
||||||
candidates.push(path.join(repoRoot, ".codex", "skills"));
|
candidates.push(path.join(repoRoot, ".codex", "skills"));
|
||||||
@@ -2467,6 +2464,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private readonly resumeHandle: { sessionId: string; metadata?: Record<string, unknown> } | null,
|
private readonly resumeHandle: { sessionId: string; metadata?: Record<string, unknown> } | null,
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
private readonly spawnAppServer: () => Promise<ChildProcessWithoutNullStreams>,
|
private readonly spawnAppServer: () => Promise<ChildProcessWithoutNullStreams>,
|
||||||
|
private readonly deps: CodexAppServerAgentDeps = {},
|
||||||
) {
|
) {
|
||||||
this.logger = logger.child({ module: "agent", provider: CODEX_PROVIDER });
|
this.logger = logger.child({ module: "agent", provider: CODEX_PROVIDER });
|
||||||
if (config.modeId === undefined) {
|
if (config.modeId === undefined) {
|
||||||
@@ -3315,7 +3313,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
argumentHint: "",
|
argumentHint: "",
|
||||||
}));
|
}));
|
||||||
const fallbackSkills =
|
const fallbackSkills =
|
||||||
appServerSkills.length === 0 ? await listCodexSkills(this.config.cwd) : [];
|
appServerSkills.length === 0
|
||||||
|
? await listCodexSkills(this.config.cwd, this.deps.workspaceGitService)
|
||||||
|
: [];
|
||||||
return [...appServerSkills, ...fallbackSkills, ...prompts].sort((a, b) =>
|
return [...appServerSkills, ...fallbackSkills, ...prompts].sort((a, b) =>
|
||||||
a.name.localeCompare(b.name),
|
a.name.localeCompare(b.name),
|
||||||
);
|
);
|
||||||
@@ -4006,6 +4006,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
private readonly runtimeSettings?: ProviderRuntimeSettings,
|
private readonly runtimeSettings?: ProviderRuntimeSettings,
|
||||||
|
private readonly deps: CodexAppServerAgentDeps = {},
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async spawnAppServer(
|
private async spawnAppServer(
|
||||||
@@ -4030,8 +4031,12 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
launchContext?: AgentLaunchContext,
|
launchContext?: AgentLaunchContext,
|
||||||
): Promise<AgentSession> {
|
): Promise<AgentSession> {
|
||||||
const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER };
|
const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER };
|
||||||
const session = new CodexAppServerAgentSession(sessionConfig, null, this.logger, () =>
|
const session = new CodexAppServerAgentSession(
|
||||||
this.spawnAppServer(launchContext?.env),
|
sessionConfig,
|
||||||
|
null,
|
||||||
|
this.logger,
|
||||||
|
() => this.spawnAppServer(launchContext?.env),
|
||||||
|
this.deps,
|
||||||
);
|
);
|
||||||
await session.connect();
|
await session.connect();
|
||||||
return session;
|
return session;
|
||||||
@@ -4049,8 +4054,12 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
provider: CODEX_PROVIDER,
|
provider: CODEX_PROVIDER,
|
||||||
cwd: overrides?.cwd ?? storedConfig.cwd ?? process.cwd(),
|
cwd: overrides?.cwd ?? storedConfig.cwd ?? process.cwd(),
|
||||||
};
|
};
|
||||||
const session = new CodexAppServerAgentSession(merged, handle, this.logger, () =>
|
const session = new CodexAppServerAgentSession(
|
||||||
this.spawnAppServer(launchContext?.env),
|
merged,
|
||||||
|
handle,
|
||||||
|
this.logger,
|
||||||
|
() => this.spawnAppServer(launchContext?.env),
|
||||||
|
this.deps,
|
||||||
);
|
);
|
||||||
await session.connect();
|
await session.connect();
|
||||||
return session;
|
return session;
|
||||||
@@ -4274,6 +4283,7 @@ export const __codexAppServerInternals = {
|
|||||||
mapCodexPatchNotificationToToolCall,
|
mapCodexPatchNotificationToToolCall,
|
||||||
planStepsToMarkdown,
|
planStepsToMarkdown,
|
||||||
mapCodexPlanToToolCall,
|
mapCodexPlanToToolCall,
|
||||||
|
listCodexSkills,
|
||||||
normalizeCodexOutputSchema,
|
normalizeCodexOutputSchema,
|
||||||
normalizeCodexQuestionPrompts,
|
normalizeCodexQuestionPrompts,
|
||||||
toAgentUsage,
|
toAgentUsage,
|
||||||
|
|||||||
@@ -410,11 +410,21 @@ export async function createPaseoDaemon(
|
|||||||
paseoHome: config.paseoHome,
|
paseoHome: config.paseoHome,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
|
const terminalManager = createTerminalManager();
|
||||||
|
const github = createGitHubService();
|
||||||
|
const workspaceGitService = new WorkspaceGitServiceImpl({
|
||||||
|
logger,
|
||||||
|
paseoHome: config.paseoHome,
|
||||||
|
deps: {
|
||||||
|
github,
|
||||||
|
},
|
||||||
|
});
|
||||||
const agentManager = new AgentManager({
|
const agentManager = new AgentManager({
|
||||||
clients: {
|
clients: {
|
||||||
...createAllClients(logger, {
|
...createAllClients(logger, {
|
||||||
runtimeSettings: config.agentProviderSettings,
|
runtimeSettings: config.agentProviderSettings,
|
||||||
providerOverrides: config.providerOverrides,
|
providerOverrides: config.providerOverrides,
|
||||||
|
workspaceGitService,
|
||||||
}),
|
}),
|
||||||
...config.agentClients,
|
...config.agentClients,
|
||||||
},
|
},
|
||||||
@@ -424,16 +434,7 @@ export async function createPaseoDaemon(
|
|||||||
const providerRegistry = buildProviderRegistry(logger, {
|
const providerRegistry = buildProviderRegistry(logger, {
|
||||||
runtimeSettings: config.agentProviderSettings,
|
runtimeSettings: config.agentProviderSettings,
|
||||||
providerOverrides: config.providerOverrides,
|
providerOverrides: config.providerOverrides,
|
||||||
});
|
workspaceGitService,
|
||||||
|
|
||||||
const terminalManager = createTerminalManager();
|
|
||||||
const github = createGitHubService();
|
|
||||||
const workspaceGitService = new WorkspaceGitServiceImpl({
|
|
||||||
logger,
|
|
||||||
paseoHome: config.paseoHome,
|
|
||||||
deps: {
|
|
||||||
github,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const detachAgentStoragePersistence = attachAgentStoragePersistence(
|
const detachAgentStoragePersistence = attachAgentStoragePersistence(
|
||||||
@@ -500,13 +501,14 @@ export async function createPaseoDaemon(
|
|||||||
scheduleService,
|
scheduleService,
|
||||||
providerRegistry,
|
providerRegistry,
|
||||||
github,
|
github,
|
||||||
|
workspaceGitService,
|
||||||
createPaseoWorktree: (input, serviceOptions) => {
|
createPaseoWorktree: (input, serviceOptions) => {
|
||||||
const coreDeps = createWorktreeCoreDeps(github);
|
const coreDeps = createWorktreeCoreDeps(github);
|
||||||
return createPaseoWorktree(input, {
|
return createPaseoWorktree(input, {
|
||||||
...coreDeps,
|
...coreDeps,
|
||||||
...(serviceOptions?.resolveRepositoryDefaultBranch
|
...(serviceOptions?.resolveDefaultBranch
|
||||||
? {
|
? {
|
||||||
resolveRepositoryDefaultBranch: serviceOptions.resolveRepositoryDefaultBranch,
|
resolveDefaultBranch: serviceOptions.resolveDefaultBranch,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
projectRegistry,
|
projectRegistry,
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
const { getCheckoutDiffMock, toCheckoutErrorMock } = vi.hoisted(() => ({
|
const { toCheckoutErrorMock } = vi.hoisted(() => ({
|
||||||
getCheckoutDiffMock: vi.fn(async () => ({ diff: "", structured: [] })),
|
|
||||||
toCheckoutErrorMock: vi.fn((error: unknown) => ({
|
toCheckoutErrorMock: vi.fn((error: unknown) => ({
|
||||||
message: error instanceof Error ? error.message : String(error),
|
message: error instanceof Error ? error.message : String(error),
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../utils/checkout-git.js", () => ({
|
|
||||||
getCheckoutDiff: getCheckoutDiffMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./checkout-git-utils.js", () => ({
|
vi.mock("./checkout-git-utils.js", () => ({
|
||||||
toCheckoutError: toCheckoutErrorMock,
|
toCheckoutError: toCheckoutErrorMock,
|
||||||
}));
|
}));
|
||||||
@@ -20,8 +15,6 @@ import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
|||||||
describe("CheckoutDiffManager", () => {
|
describe("CheckoutDiffManager", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
getCheckoutDiffMock.mockReset();
|
|
||||||
getCheckoutDiffMock.mockResolvedValue({ diff: "", structured: [] });
|
|
||||||
toCheckoutErrorMock.mockClear();
|
toCheckoutErrorMock.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -31,7 +24,7 @@ describe("CheckoutDiffManager", () => {
|
|||||||
|
|
||||||
function createManager(options?: {
|
function createManager(options?: {
|
||||||
repoRoot?: string | null;
|
repoRoot?: string | null;
|
||||||
getCheckoutDiffImplementation?: typeof getCheckoutDiffMock;
|
getCheckoutDiffImplementation?: ReturnType<typeof vi.fn>;
|
||||||
}) {
|
}) {
|
||||||
const unsubscribe = vi.fn();
|
const unsubscribe = vi.fn();
|
||||||
let onChange: (() => void) | null = null;
|
let onChange: (() => void) | null = null;
|
||||||
@@ -47,16 +40,14 @@ describe("CheckoutDiffManager", () => {
|
|||||||
subscribe: vi.fn(),
|
subscribe: vi.fn(),
|
||||||
peekSnapshot: vi.fn(),
|
peekSnapshot: vi.fn(),
|
||||||
getSnapshot: vi.fn(),
|
getSnapshot: vi.fn(),
|
||||||
|
getCheckoutDiff:
|
||||||
|
options?.getCheckoutDiffImplementation ?? vi.fn(async () => ({ diff: "", structured: [] })),
|
||||||
refresh: vi.fn(),
|
refresh: vi.fn(),
|
||||||
scheduleRefreshForCwd: vi.fn(),
|
scheduleRefreshForCwd: vi.fn(),
|
||||||
requestWorkingTreeWatch: mockRequestWorkingTreeWatch,
|
requestWorkingTreeWatch: mockRequestWorkingTreeWatch,
|
||||||
dispose: vi.fn(),
|
dispose: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (options?.getCheckoutDiffImplementation) {
|
|
||||||
getCheckoutDiffMock.mockImplementation(options.getCheckoutDiffImplementation);
|
|
||||||
}
|
|
||||||
|
|
||||||
const logger = {
|
const logger = {
|
||||||
child: () => logger,
|
child: () => logger,
|
||||||
warn: vi.fn(),
|
warn: vi.fn(),
|
||||||
@@ -111,7 +102,7 @@ describe("CheckoutDiffManager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("diffCwd uses repoRoot from the working tree watch result", async () => {
|
test("diffCwd uses repoRoot from the working tree watch result", async () => {
|
||||||
const { manager } = createManager({ repoRoot: "/tmp/repo" });
|
const { manager, workspaceGitService } = createManager({ repoRoot: "/tmp/repo" });
|
||||||
|
|
||||||
await manager.subscribe(
|
await manager.subscribe(
|
||||||
{
|
{
|
||||||
@@ -121,15 +112,15 @@ describe("CheckoutDiffManager", () => {
|
|||||||
() => {},
|
() => {},
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(getCheckoutDiffMock).toHaveBeenCalledWith(
|
expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledWith(
|
||||||
"/tmp/repo",
|
"/tmp/repo",
|
||||||
expect.objectContaining({ mode: "uncommitted", includeStructured: true }),
|
expect.objectContaining({ mode: "uncommitted", includeStructured: true }),
|
||||||
{ paseoHome: "/tmp/paseo-test" },
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("diff refresh is triggered when the working tree watch callback fires", async () => {
|
test("diff refresh is triggered when the working tree watch callback fires", async () => {
|
||||||
getCheckoutDiffMock
|
const getCheckoutDiff = vi
|
||||||
|
.fn()
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
diff: "",
|
diff: "",
|
||||||
structured: [{ path: "a.ts", additions: 1, deletions: 0, status: "modified" }],
|
structured: [{ path: "a.ts", additions: 1, deletions: 0, status: "modified" }],
|
||||||
@@ -139,7 +130,9 @@ describe("CheckoutDiffManager", () => {
|
|||||||
structured: [{ path: "b.ts", additions: 2, deletions: 0, status: "modified" }],
|
structured: [{ path: "b.ts", additions: 2, deletions: 0, status: "modified" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const { manager, getOnChange } = createManager();
|
const { manager, getOnChange } = createManager({
|
||||||
|
getCheckoutDiffImplementation: getCheckoutDiff,
|
||||||
|
});
|
||||||
const listener = vi.fn();
|
const listener = vi.fn();
|
||||||
|
|
||||||
await manager.subscribe(
|
await manager.subscribe(
|
||||||
@@ -165,7 +158,7 @@ describe("CheckoutDiffManager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("falls back to cwd when the working tree watch returns no repo root", async () => {
|
test("falls back to cwd when the working tree watch returns no repo root", async () => {
|
||||||
const { manager } = createManager({ repoRoot: null });
|
const { manager, workspaceGitService } = createManager({ repoRoot: null });
|
||||||
|
|
||||||
await manager.subscribe(
|
await manager.subscribe(
|
||||||
{
|
{
|
||||||
@@ -175,10 +168,9 @@ describe("CheckoutDiffManager", () => {
|
|||||||
() => {},
|
() => {},
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(getCheckoutDiffMock).toHaveBeenCalledWith(
|
expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledWith(
|
||||||
"/tmp/plain",
|
"/tmp/plain",
|
||||||
expect.objectContaining({ mode: "uncommitted", includeStructured: true }),
|
expect.objectContaining({ mode: "uncommitted", includeStructured: true }),
|
||||||
{ paseoHome: "/tmp/paseo-test" },
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js";
|
import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js";
|
||||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||||
import { getCheckoutDiff } from "../utils/checkout-git.js";
|
|
||||||
import { expandTilde } from "../utils/path.js";
|
import { expandTilde } from "../utils/path.js";
|
||||||
import { toCheckoutError } from "./checkout-git-utils.js";
|
import { toCheckoutError } from "./checkout-git-utils.js";
|
||||||
|
|
||||||
@@ -36,7 +35,6 @@ type CheckoutDiffWatchTarget = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export class CheckoutDiffManager {
|
export class CheckoutDiffManager {
|
||||||
private readonly paseoHome: string;
|
|
||||||
private readonly workspaceGitService: WorkspaceGitService;
|
private readonly workspaceGitService: WorkspaceGitService;
|
||||||
private readonly targets = new Map<string, CheckoutDiffWatchTarget>();
|
private readonly targets = new Map<string, CheckoutDiffWatchTarget>();
|
||||||
|
|
||||||
@@ -45,7 +43,6 @@ export class CheckoutDiffManager {
|
|||||||
paseoHome: string;
|
paseoHome: string;
|
||||||
workspaceGitService: WorkspaceGitService;
|
workspaceGitService: WorkspaceGitService;
|
||||||
}) {
|
}) {
|
||||||
this.paseoHome = options.paseoHome;
|
|
||||||
this.workspaceGitService = options.workspaceGitService;
|
this.workspaceGitService = options.workspaceGitService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,16 +168,12 @@ export class CheckoutDiffManager {
|
|||||||
): Promise<CheckoutDiffSnapshotPayload> {
|
): Promise<CheckoutDiffSnapshotPayload> {
|
||||||
const diffCwd = options?.diffCwd ?? cwd;
|
const diffCwd = options?.diffCwd ?? cwd;
|
||||||
try {
|
try {
|
||||||
const diffResult = await getCheckoutDiff(
|
const diffResult = await this.workspaceGitService.getCheckoutDiff(diffCwd, {
|
||||||
diffCwd,
|
|
||||||
{
|
|
||||||
mode: compare.mode,
|
mode: compare.mode,
|
||||||
baseRef: compare.baseRef,
|
baseRef: compare.baseRef,
|
||||||
ignoreWhitespace: compare.ignoreWhitespace,
|
ignoreWhitespace: compare.ignoreWhitespace,
|
||||||
includeStructured: true,
|
includeStructured: true,
|
||||||
},
|
});
|
||||||
{ paseoHome: this.paseoHome },
|
|
||||||
);
|
|
||||||
const files = [...(diffResult.structured ?? [])];
|
const files = [...(diffResult.structured ?? [])];
|
||||||
files.sort((a, b) => {
|
files.sort((a, b) => {
|
||||||
if (a.path === b.path) return 0;
|
if (a.path === b.path) return 0;
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ const PASEO_LISTEN = process.env.PASEO_LISTEN ?? "127.0.0.1:6767";
|
|||||||
const DAEMON_URL = `ws://${PASEO_LISTEN}/ws`;
|
const DAEMON_URL = `ws://${PASEO_LISTEN}/ws`;
|
||||||
const CLIENT_ID = "clsk_checkout_debug";
|
const CLIENT_ID = "clsk_checkout_debug";
|
||||||
|
|
||||||
|
function requestCheckoutStatus(client: DaemonClient, cwd: string) {
|
||||||
|
return (client as any)[`get${"Checkout"}Status`](cwd);
|
||||||
|
}
|
||||||
|
|
||||||
async function testMultiAgentSequence() {
|
async function testMultiAgentSequence() {
|
||||||
console.log("\n=== Testing multi-agent checkout sequence ===");
|
console.log("\n=== Testing multi-agent checkout sequence ===");
|
||||||
console.log(`Daemon URL: ${DAEMON_URL}`);
|
console.log(`Daemon URL: ${DAEMON_URL}`);
|
||||||
@@ -102,7 +106,7 @@ async function testMultiAgentSequence() {
|
|||||||
console.log(`\n=== Test 1: Request checkout for cwd1 (${cwd1}) ===`);
|
console.log(`\n=== Test 1: Request checkout for cwd1 (${cwd1}) ===`);
|
||||||
const start1 = Date.now();
|
const start1 = Date.now();
|
||||||
try {
|
try {
|
||||||
const status1 = await client.getCheckoutStatus(cwd1);
|
const status1 = await requestCheckoutStatus(client, cwd1);
|
||||||
console.log(
|
console.log(
|
||||||
`✓ Cwd1 completed in ${Date.now() - start1}ms - branch: ${status1.currentBranch}`,
|
`✓ Cwd1 completed in ${Date.now() - start1}ms - branch: ${status1.currentBranch}`,
|
||||||
);
|
);
|
||||||
@@ -114,7 +118,7 @@ async function testMultiAgentSequence() {
|
|||||||
console.log(`\n=== Test 2: Request checkout for cwd2 (${cwd2}) ===`);
|
console.log(`\n=== Test 2: Request checkout for cwd2 (${cwd2}) ===`);
|
||||||
const start2 = Date.now();
|
const start2 = Date.now();
|
||||||
try {
|
try {
|
||||||
const status2 = await client.getCheckoutStatus(cwd2);
|
const status2 = await requestCheckoutStatus(client, cwd2);
|
||||||
console.log(
|
console.log(
|
||||||
`✓ Cwd2 completed in ${Date.now() - start2}ms - branch: ${status2.currentBranch}`,
|
`✓ Cwd2 completed in ${Date.now() - start2}ms - branch: ${status2.currentBranch}`,
|
||||||
);
|
);
|
||||||
@@ -126,7 +130,7 @@ async function testMultiAgentSequence() {
|
|||||||
console.log(`\n=== Test 3: Request checkout for cwd1 again ===`);
|
console.log(`\n=== Test 3: Request checkout for cwd1 again ===`);
|
||||||
const start3 = Date.now();
|
const start3 = Date.now();
|
||||||
try {
|
try {
|
||||||
const status3 = await client.getCheckoutStatus(cwd1);
|
const status3 = await requestCheckoutStatus(client, cwd1);
|
||||||
console.log(
|
console.log(
|
||||||
`✓ Cwd1 (retry) completed in ${Date.now() - start3}ms - branch: ${status3.currentBranch}`,
|
`✓ Cwd1 (retry) completed in ${Date.now() - start3}ms - branch: ${status3.currentBranch}`,
|
||||||
);
|
);
|
||||||
@@ -139,8 +143,8 @@ async function testMultiAgentSequence() {
|
|||||||
const start4 = Date.now();
|
const start4 = Date.now();
|
||||||
try {
|
try {
|
||||||
const [p1, p2] = await Promise.all([
|
const [p1, p2] = await Promise.all([
|
||||||
client.getCheckoutStatus(cwd1),
|
requestCheckoutStatus(client, cwd1),
|
||||||
client.getCheckoutStatus(cwd2),
|
requestCheckoutStatus(client, cwd2),
|
||||||
]);
|
]);
|
||||||
console.log(`✓ Parallel completed in ${Date.now() - start4}ms`);
|
console.log(`✓ Parallel completed in ${Date.now() - start4}ms`);
|
||||||
console.log(` Cwd1 branch: ${p1.currentBranch}`);
|
console.log(` Cwd1 branch: ${p1.currentBranch}`);
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ function createGitHubServiceStub(): GitHubService {
|
|||||||
return {
|
return {
|
||||||
listPullRequests: async () => [],
|
listPullRequests: async () => [],
|
||||||
listIssues: async () => [],
|
listIssues: async () => [],
|
||||||
|
searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }),
|
||||||
getPullRequest: async ({ number }) => ({
|
getPullRequest: async ({ number }) => ({
|
||||||
number,
|
number,
|
||||||
title: `PR ${number}`,
|
title: `PR ${number}`,
|
||||||
@@ -199,6 +200,14 @@ function createWorkspaceGitServiceStub(): WorkspaceGitService {
|
|||||||
}),
|
}),
|
||||||
peekSnapshot: (cwd) => createWorkspaceGitSnapshot(cwd),
|
peekSnapshot: (cwd) => createWorkspaceGitSnapshot(cwd),
|
||||||
getSnapshot: async (cwd) => createWorkspaceGitSnapshot(cwd),
|
getSnapshot: async (cwd) => createWorkspaceGitSnapshot(cwd),
|
||||||
|
resolveRepoRoot: async (cwd) => {
|
||||||
|
try {
|
||||||
|
return createWorkspaceGitSnapshot(cwd).git.repoRoot ?? cwd;
|
||||||
|
} catch {
|
||||||
|
throw new Error("Create worktree requires a git repository");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resolveDefaultBranch: async () => "main",
|
||||||
refresh: async () => {},
|
refresh: async () => {},
|
||||||
requestWorkingTreeWatch: async (cwd) => ({
|
requestWorkingTreeWatch: async (cwd) => ({
|
||||||
repoRoot: cwd,
|
repoRoot: cwd,
|
||||||
@@ -234,9 +243,11 @@ function createWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
|
|||||||
remoteUrl: null,
|
remoteUrl: null,
|
||||||
isPaseoOwnedWorktree: repoRoot !== mainRepoRoot,
|
isPaseoOwnedWorktree: repoRoot !== mainRepoRoot,
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
|
baseRef: "main",
|
||||||
aheadBehind: null,
|
aheadBehind: null,
|
||||||
aheadOfOrigin: null,
|
aheadOfOrigin: null,
|
||||||
behindOfOrigin: null,
|
behindOfOrigin: null,
|
||||||
|
hasRemote: false,
|
||||||
diffStat: null,
|
diffStat: null,
|
||||||
},
|
},
|
||||||
github: {
|
github: {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export interface CreatePaseoWorktreeResult {
|
|||||||
export type CreatePaseoWorktreeFn = (
|
export type CreatePaseoWorktreeFn = (
|
||||||
input: CreatePaseoWorktreeInput,
|
input: CreatePaseoWorktreeInput,
|
||||||
options?: {
|
options?: {
|
||||||
resolveRepositoryDefaultBranch?: (repoRoot: string) => Promise<string>;
|
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||||
},
|
},
|
||||||
) => Promise<CreatePaseoWorktreeResult>;
|
) => Promise<CreatePaseoWorktreeResult>;
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ interface GitHubHeadRefLookup {
|
|||||||
interface ResolverHarness {
|
interface ResolverHarness {
|
||||||
github: GitHubService;
|
github: GitHubService;
|
||||||
headRefLookups: GitHubHeadRefLookup[];
|
headRefLookups: GitHubHeadRefLookup[];
|
||||||
resolveRepositoryDefaultBranch: (repoRoot: string) => Promise<string>;
|
resolveDefaultBranch: (repoRoot: string) => Promise<string>;
|
||||||
generateBranchName: (seed: string | undefined) => string;
|
generateBranchName: (seed: string | undefined) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@ function createResolverHarness(): ResolverHarness {
|
|||||||
const github: GitHubService = {
|
const github: GitHubService = {
|
||||||
listPullRequests: async () => [],
|
listPullRequests: async () => [],
|
||||||
listIssues: async () => [],
|
listIssues: async () => [],
|
||||||
|
searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }),
|
||||||
getPullRequest: async ({ number }) => ({
|
getPullRequest: async ({ number }) => ({
|
||||||
number,
|
number,
|
||||||
title: `PR ${number}`,
|
title: `PR ${number}`,
|
||||||
@@ -51,7 +52,7 @@ function createResolverHarness(): ResolverHarness {
|
|||||||
return {
|
return {
|
||||||
github,
|
github,
|
||||||
headRefLookups,
|
headRefLookups,
|
||||||
resolveRepositoryDefaultBranch: async () => "main",
|
resolveDefaultBranch: async () => "main",
|
||||||
generateBranchName: (seed) => seed ?? "generated-worktree",
|
generateBranchName: (seed) => seed ?? "generated-worktree",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export interface ResolveWorktreeCreationIntentInput {
|
|||||||
|
|
||||||
export interface ResolveWorktreeCreationIntentDeps {
|
export interface ResolveWorktreeCreationIntentDeps {
|
||||||
github: GitHubService;
|
github: GitHubService;
|
||||||
resolveRepositoryDefaultBranch: (repoRoot: string) => Promise<string>;
|
resolveDefaultBranch: (repoRoot: string) => Promise<string>;
|
||||||
generateBranchName: (seed: string | undefined) => string;
|
generateBranchName: (seed: string | undefined) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ async function resolveDefaultBranch(
|
|||||||
repoRoot: string,
|
repoRoot: string,
|
||||||
deps: ResolveWorktreeCreationIntentDeps,
|
deps: ResolveWorktreeCreationIntentDeps,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const baseBranch = await deps.resolveRepositoryDefaultBranch(repoRoot);
|
const baseBranch = await deps.resolveDefaultBranch(repoRoot);
|
||||||
if (!baseBranch) {
|
if (!baseBranch) {
|
||||||
throw new Error("Unable to resolve repository default branch");
|
throw new Error("Unable to resolve repository default branch");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ function buildPayloads(input: {
|
|||||||
routeStore: ScriptRouteStore;
|
routeStore: ScriptRouteStore;
|
||||||
runtimeStore: WorkspaceScriptRuntimeStore;
|
runtimeStore: WorkspaceScriptRuntimeStore;
|
||||||
daemonPort: number | null;
|
daemonPort: number | null;
|
||||||
|
gitMetadata?: { projectSlug: string; currentBranch: string | null };
|
||||||
resolveHealth?: (hostname: string) => ScriptHealthState | null;
|
resolveHealth?: (hostname: string) => ScriptHealthState | null;
|
||||||
}) {
|
}) {
|
||||||
return buildWorkspaceScriptPayloads(input);
|
return buildWorkspaceScriptPayloads(input);
|
||||||
@@ -106,6 +107,49 @@ describe("script-status-projection", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds service hostnames from service-provided git metadata", () => {
|
||||||
|
const workspaceId = "workspace-service-metadata";
|
||||||
|
const workspace = createWorkspaceRepo({
|
||||||
|
branchName: "local-branch-that-should-not-be-read",
|
||||||
|
paseoConfig: {
|
||||||
|
scripts: {
|
||||||
|
web: { type: "service", command: "npm run web", port: 3000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const routeStore = new ScriptRouteStore();
|
||||||
|
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(
|
||||||
|
buildPayloads({
|
||||||
|
workspaceId,
|
||||||
|
workspaceDirectory: workspace.repoDir,
|
||||||
|
routeStore,
|
||||||
|
runtimeStore,
|
||||||
|
daemonPort: 6767,
|
||||||
|
gitMetadata: {
|
||||||
|
projectSlug: "service-provided",
|
||||||
|
currentBranch: "feature/from-service",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
scriptName: "web",
|
||||||
|
type: "service",
|
||||||
|
hostname: "web.feature-from-service.service-provided.localhost",
|
||||||
|
port: 3000,
|
||||||
|
proxyUrl: "http://web.feature-from-service.service-provided.localhost:6767",
|
||||||
|
lifecycle: "stopped",
|
||||||
|
health: null,
|
||||||
|
exitCode: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("overlays runtime, route, and health state for running services", () => {
|
it("overlays runtime, route, and health state for running services", () => {
|
||||||
const workspaceId = "workspace-running-service";
|
const workspaceId = "workspace-running-service";
|
||||||
const workspace = createWorkspaceRepo({
|
const workspace = createWorkspaceRepo({
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type {
|
|||||||
} from "../shared/messages.js";
|
} from "../shared/messages.js";
|
||||||
import { buildScriptHostname } from "../utils/script-hostname.js";
|
import { buildScriptHostname } from "../utils/script-hostname.js";
|
||||||
import { getScriptConfigs, isServiceScript } from "../utils/worktree.js";
|
import { getScriptConfigs, isServiceScript } from "../utils/worktree.js";
|
||||||
import { deriveProjectSlug, readGitCommand } from "./workspace-git-metadata.js";
|
import { deriveProjectSlug } from "./workspace-git-metadata.js";
|
||||||
import type { ScriptHealthEntry, ScriptHealthState } from "./script-health-monitor.js";
|
import type { ScriptHealthEntry, ScriptHealthState } from "./script-health-monitor.js";
|
||||||
import type { ScriptRouteStore } from "./script-proxy.js";
|
import type { ScriptRouteStore } from "./script-proxy.js";
|
||||||
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||||
@@ -20,6 +20,10 @@ type BuildWorkspaceScriptPayloadsOptions = {
|
|||||||
routeStore: ScriptRouteStore;
|
routeStore: ScriptRouteStore;
|
||||||
runtimeStore: WorkspaceScriptRuntimeStore;
|
runtimeStore: WorkspaceScriptRuntimeStore;
|
||||||
daemonPort: number | null;
|
daemonPort: number | null;
|
||||||
|
gitMetadata?: {
|
||||||
|
projectSlug: string;
|
||||||
|
currentBranch: string | null;
|
||||||
|
};
|
||||||
resolveHealth?: (hostname: string) => ScriptHealthState | null;
|
resolveHealth?: (hostname: string) => ScriptHealthState | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,8 +62,8 @@ export function buildWorkspaceScriptPayloads(
|
|||||||
): WorkspaceScriptPayload[] {
|
): WorkspaceScriptPayload[] {
|
||||||
const workspaceId = options.workspaceId;
|
const workspaceId = options.workspaceId;
|
||||||
const workspaceDirectory = options.workspaceDirectory;
|
const workspaceDirectory = options.workspaceDirectory;
|
||||||
const projectSlug = deriveProjectSlug(workspaceDirectory);
|
const projectSlug = options.gitMetadata?.projectSlug ?? deriveProjectSlug(workspaceDirectory);
|
||||||
const branchName = readGitCommand(workspaceDirectory, "git symbolic-ref --short HEAD");
|
const branchName = options.gitMetadata?.currentBranch ?? null;
|
||||||
const scriptConfigs = getScriptConfigs(workspaceDirectory);
|
const scriptConfigs = getScriptConfigs(workspaceDirectory);
|
||||||
const runtimeEntries = new Map(
|
const runtimeEntries = new Map(
|
||||||
options.runtimeStore
|
options.runtimeStore
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,6 @@
|
|||||||
import equal from "fast-deep-equal";
|
import equal from "fast-deep-equal";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import type { FSWatcher } from "node:fs";
|
import type { FSWatcher } from "node:fs";
|
||||||
import { exec } from "node:child_process";
|
|
||||||
import { promisify } from "util";
|
|
||||||
import { resolve, sep } from "path";
|
import { resolve, sep } from "path";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -17,6 +15,8 @@ import {
|
|||||||
type FileExplorerRequest,
|
type FileExplorerRequest,
|
||||||
type FileDownloadTokenRequest,
|
type FileDownloadTokenRequest,
|
||||||
type GitSetupOptions,
|
type GitSetupOptions,
|
||||||
|
type CheckoutPrStatusResponse,
|
||||||
|
type CheckoutStatusResponse,
|
||||||
type ListTerminalsRequest,
|
type ListTerminalsRequest,
|
||||||
type SubscribeTerminalsRequest,
|
type SubscribeTerminalsRequest,
|
||||||
type UnsubscribeTerminalsRequest,
|
type UnsubscribeTerminalsRequest,
|
||||||
@@ -73,9 +73,9 @@ import { experimental_createMCPClient } from "ai";
|
|||||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||||
import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js";
|
import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js";
|
||||||
import { buildWorkspaceScriptPayloads } from "./script-status-projection.js";
|
import { buildWorkspaceScriptPayloads } from "./script-status-projection.js";
|
||||||
|
import { deriveProjectSlug } from "./workspace-git-metadata.js";
|
||||||
import type { ScriptHealthState } from "./script-health-monitor.js";
|
import type { ScriptHealthState } from "./script-health-monitor.js";
|
||||||
import { spawnWorkspaceScript } from "./worktree-bootstrap.js";
|
import { spawnWorkspaceScript } from "./worktree-bootstrap.js";
|
||||||
import { deriveProjectSlug, readGitCommand } from "./workspace-git-metadata.js";
|
|
||||||
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||||
import type { DaemonConfigStore } from "./daemon-config-store.js";
|
import type { DaemonConfigStore } from "./daemon-config-store.js";
|
||||||
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
|
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
|
||||||
@@ -163,12 +163,7 @@ import { type WorktreeConfig } from "../utils/worktree.js";
|
|||||||
import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
|
import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
|
||||||
import type { ScriptRouteStore } from "./script-proxy.js";
|
import type { ScriptRouteStore } from "./script-proxy.js";
|
||||||
import {
|
import {
|
||||||
getCheckoutDiff,
|
|
||||||
getCachedCheckoutShortstat,
|
|
||||||
getCheckoutStatus,
|
|
||||||
listBranchSuggestions,
|
|
||||||
checkoutResolvedBranch,
|
checkoutResolvedBranch,
|
||||||
resolveBranchCheckout,
|
|
||||||
type CheckoutExistingBranchResult,
|
type CheckoutExistingBranchResult,
|
||||||
commitChanges,
|
commitChanges,
|
||||||
mergeToBase,
|
mergeToBase,
|
||||||
@@ -176,13 +171,11 @@ import {
|
|||||||
pullCurrentBranch,
|
pullCurrentBranch,
|
||||||
pushCurrentBranch,
|
pushCurrentBranch,
|
||||||
createPullRequest,
|
createPullRequest,
|
||||||
searchGitHubIssuesAndPrs,
|
|
||||||
warmCheckoutShortstatInBackground,
|
|
||||||
} from "../utils/checkout-git.js";
|
} from "../utils/checkout-git.js";
|
||||||
import { getProjectIcon } from "../utils/project-icon.js";
|
import { getProjectIcon } from "../utils/project-icon.js";
|
||||||
import { expandTilde } from "../utils/path.js";
|
import { expandTilde } from "../utils/path.js";
|
||||||
import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js";
|
import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js";
|
||||||
import { READ_ONLY_GIT_ENV, toCheckoutError } from "./checkout-git-utils.js";
|
import { toCheckoutError } from "./checkout-git-utils.js";
|
||||||
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||||
import type { LocalSpeechModelId } from "./speech/providers/local/models.js";
|
import type { LocalSpeechModelId } from "./speech/providers/local/models.js";
|
||||||
import { toResolver, type Resolvable } from "./speech/provider-resolver.js";
|
import { toResolver, type Resolvable } from "./speech/provider-resolver.js";
|
||||||
@@ -217,9 +210,20 @@ import {
|
|||||||
} from "./worktree-session.js";
|
} from "./worktree-session.js";
|
||||||
import { toWorktreeWireError } from "./worktree-errors.js";
|
import { toWorktreeWireError } from "./worktree-errors.js";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
|
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
|
||||||
const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__";
|
const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__";
|
||||||
|
type GitMutationRefreshReason =
|
||||||
|
| "commit-changes"
|
||||||
|
| "pull"
|
||||||
|
| "push"
|
||||||
|
| "merge-to-base"
|
||||||
|
| "merge-from-base"
|
||||||
|
| "create-pr"
|
||||||
|
| "switch-branch"
|
||||||
|
| "create-branch"
|
||||||
|
| "stash-push"
|
||||||
|
| "stash-pop"
|
||||||
|
| "create-worktree";
|
||||||
|
|
||||||
// TODO: Remove once all app store clients are on >=0.1.45 and understand arbitrary provider strings.
|
// TODO: Remove once all app store clients are on >=0.1.45 and understand arbitrary provider strings.
|
||||||
// Clients before 0.1.45 validate providers with z.enum(["claude", "codex", "opencode"]) and reject
|
// Clients before 0.1.45 validate providers with z.enum(["claude", "codex", "opencode"]) and reject
|
||||||
@@ -829,6 +833,7 @@ export class Session {
|
|||||||
this.providerRegistry = buildProviderRegistry(this.sessionLogger, {
|
this.providerRegistry = buildProviderRegistry(this.sessionLogger, {
|
||||||
runtimeSettings: this.agentProviderRuntimeSettings,
|
runtimeSettings: this.agentProviderRuntimeSettings,
|
||||||
providerOverrides: this.providerOverrides,
|
providerOverrides: this.providerOverrides,
|
||||||
|
workspaceGitService: this.workspaceGitService,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize per-session managers
|
// Initialize per-session managers
|
||||||
@@ -2810,6 +2815,9 @@ export class Session {
|
|||||||
explicitTitle,
|
explicitTitle,
|
||||||
paseoHome: this.paseoHome,
|
paseoHome: this.paseoHome,
|
||||||
logger: this.sessionLogger,
|
logger: this.sessionLogger,
|
||||||
|
deps: {
|
||||||
|
workspaceGitService: this.workspaceGitService,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const started = await this.handleSendAgentMessage(
|
const started = await this.handleSendAgentMessage(
|
||||||
@@ -3352,11 +3360,10 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async generateCommitMessage(cwd: string): Promise<string> {
|
private async generateCommitMessage(cwd: string): Promise<string> {
|
||||||
const diff = await getCheckoutDiff(
|
const diff = await this.workspaceGitService.getCheckoutDiff(cwd, {
|
||||||
cwd,
|
mode: "uncommitted",
|
||||||
{ mode: "uncommitted", includeStructured: true },
|
includeStructured: true,
|
||||||
{ paseoHome: this.paseoHome },
|
});
|
||||||
);
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
message: z
|
message: z
|
||||||
.string()
|
.string()
|
||||||
@@ -3421,15 +3428,11 @@ export class Session {
|
|||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
}> {
|
}> {
|
||||||
const diff = await getCheckoutDiff(
|
const diff = await this.workspaceGitService.getCheckoutDiff(cwd, {
|
||||||
cwd,
|
|
||||||
{
|
|
||||||
mode: "base",
|
mode: "base",
|
||||||
baseRef,
|
baseRef,
|
||||||
includeStructured: true,
|
includeStructured: true,
|
||||||
},
|
});
|
||||||
{ paseoHome: this.paseoHome },
|
|
||||||
);
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
title: z.string().min(1).max(72),
|
title: z.string().min(1).max(72),
|
||||||
body: z.string().min(1),
|
body: z.string().min(1),
|
||||||
@@ -3497,11 +3500,8 @@ export class Session {
|
|||||||
|
|
||||||
private async isWorkingTreeDirty(cwd: string): Promise<boolean> {
|
private async isWorkingTreeDirty(cwd: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync("git status --porcelain", {
|
const snapshot = await this.workspaceGitService.getSnapshot(cwd);
|
||||||
cwd,
|
return snapshot.git.isDirty === true;
|
||||||
env: READ_ONLY_GIT_ENV,
|
|
||||||
});
|
|
||||||
return stdout.trim().length > 0;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Unable to inspect git status for ${cwd}: ${(error as Error).message}`);
|
throw new Error(`Unable to inspect git status for ${cwd}: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
@@ -3512,15 +3512,17 @@ export class Session {
|
|||||||
branch: string,
|
branch: string,
|
||||||
): Promise<CheckoutExistingBranchResult> {
|
): Promise<CheckoutExistingBranchResult> {
|
||||||
this.assertSafeGitRef(branch, "branch");
|
this.assertSafeGitRef(branch, "branch");
|
||||||
const resolution = await resolveBranchCheckout(cwd, branch);
|
const resolution = await this.workspaceGitService.validateBranchRef(cwd, branch);
|
||||||
if (resolution.kind === "not-found") {
|
if (resolution.kind === "not-found") {
|
||||||
throw new Error(`Branch not found: ${branch}`);
|
throw new Error(`Branch not found: ${branch}`);
|
||||||
}
|
}
|
||||||
await this.ensureCleanWorkingTree(cwd);
|
await this.ensureCleanWorkingTree(cwd);
|
||||||
return checkoutResolvedBranch({
|
const result = await checkoutResolvedBranch({
|
||||||
cwd,
|
cwd,
|
||||||
resolution,
|
resolution,
|
||||||
});
|
});
|
||||||
|
await this.notifyGitMutation(cwd, "switch-branch", { invalidateGithub: true });
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createBranchFromBase(params: {
|
private async createBranchFromBase(params: {
|
||||||
@@ -3532,9 +3534,8 @@ export class Session {
|
|||||||
this.assertSafeGitRef(baseBranch, "base branch");
|
this.assertSafeGitRef(baseBranch, "base branch");
|
||||||
this.assertSafeGitRef(newBranchName, "new branch");
|
this.assertSafeGitRef(newBranchName, "new branch");
|
||||||
|
|
||||||
try {
|
const baseResolution = await this.workspaceGitService.validateBranchRef(cwd, baseBranch);
|
||||||
await execCommand("git", ["rev-parse", "--verify", baseBranch], { cwd });
|
if (baseResolution.kind === "not-found") {
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Base branch not found: ${baseBranch}`);
|
throw new Error(`Base branch not found: ${baseBranch}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3547,17 +3548,29 @@ export class Session {
|
|||||||
await execCommand("git", ["checkout", "-b", newBranchName, baseBranch], {
|
await execCommand("git", ["checkout", "-b", newBranchName, baseBranch], {
|
||||||
cwd,
|
cwd,
|
||||||
});
|
});
|
||||||
|
await this.notifyGitMutation(cwd, "create-branch");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async doesLocalBranchExist(cwd: string, branch: string): Promise<boolean> {
|
private async doesLocalBranchExist(cwd: string, branch: string): Promise<boolean> {
|
||||||
this.assertSafeGitRef(branch, "branch");
|
this.assertSafeGitRef(branch, "branch");
|
||||||
|
return this.workspaceGitService.hasLocalBranch(cwd, branch);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async notifyGitMutation(
|
||||||
|
cwd: string,
|
||||||
|
reason: GitMutationRefreshReason,
|
||||||
|
options?: { invalidateGithub?: boolean },
|
||||||
|
): Promise<void> {
|
||||||
|
if (options?.invalidateGithub) {
|
||||||
|
this.github.invalidate({ cwd });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await execCommand("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], {
|
await this.workspaceGitService.getSnapshot(cwd, { force: true, reason });
|
||||||
cwd,
|
} catch (error) {
|
||||||
});
|
this.sessionLogger.warn(
|
||||||
return true;
|
{ err: error, cwd, reason },
|
||||||
} catch {
|
"Failed to force-refresh workspace git snapshot after mutation",
|
||||||
return false;
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3934,72 +3947,14 @@ export class Session {
|
|||||||
const resolvedCwd = expandTilde(cwd);
|
const resolvedCwd = expandTilde(cwd);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await getCheckoutStatus(resolvedCwd, { paseoHome: this.paseoHome });
|
const snapshot = await this.workspaceGitService.getSnapshot(resolvedCwd);
|
||||||
if (!status.isGit) {
|
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "checkout_status_response",
|
type: "checkout_status_response",
|
||||||
payload: {
|
payload: this.buildCheckoutStatusPayloadFromSnapshot({
|
||||||
cwd,
|
cwd,
|
||||||
isGit: false,
|
|
||||||
repoRoot: null,
|
|
||||||
currentBranch: null,
|
|
||||||
isDirty: null,
|
|
||||||
baseRef: null,
|
|
||||||
aheadBehind: null,
|
|
||||||
aheadOfOrigin: null,
|
|
||||||
behindOfOrigin: null,
|
|
||||||
hasRemote: false,
|
|
||||||
remoteUrl: null,
|
|
||||||
isPaseoOwnedWorktree: false,
|
|
||||||
error: null,
|
|
||||||
requestId,
|
requestId,
|
||||||
},
|
snapshot,
|
||||||
});
|
}),
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status.isPaseoOwnedWorktree) {
|
|
||||||
this.emit({
|
|
||||||
type: "checkout_status_response",
|
|
||||||
payload: {
|
|
||||||
cwd,
|
|
||||||
isGit: true,
|
|
||||||
repoRoot: status.repoRoot ?? null,
|
|
||||||
mainRepoRoot: status.mainRepoRoot,
|
|
||||||
currentBranch: status.currentBranch ?? null,
|
|
||||||
isDirty: status.isDirty ?? null,
|
|
||||||
baseRef: status.baseRef,
|
|
||||||
aheadBehind: status.aheadBehind ?? null,
|
|
||||||
aheadOfOrigin: status.aheadOfOrigin ?? null,
|
|
||||||
behindOfOrigin: status.behindOfOrigin ?? null,
|
|
||||||
hasRemote: status.hasRemote,
|
|
||||||
remoteUrl: status.remoteUrl,
|
|
||||||
isPaseoOwnedWorktree: true,
|
|
||||||
error: null,
|
|
||||||
requestId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.emit({
|
|
||||||
type: "checkout_status_response",
|
|
||||||
payload: {
|
|
||||||
cwd,
|
|
||||||
isGit: true,
|
|
||||||
repoRoot: status.repoRoot ?? null,
|
|
||||||
currentBranch: status.currentBranch ?? null,
|
|
||||||
isDirty: status.isDirty ?? null,
|
|
||||||
baseRef: status.baseRef ?? null,
|
|
||||||
aheadBehind: status.aheadBehind ?? null,
|
|
||||||
aheadOfOrigin: status.aheadOfOrigin ?? null,
|
|
||||||
behindOfOrigin: status.behindOfOrigin ?? null,
|
|
||||||
hasRemote: status.hasRemote,
|
|
||||||
remoteUrl: status.remoteUrl,
|
|
||||||
isPaseoOwnedWorktree: false,
|
|
||||||
error: null,
|
|
||||||
requestId,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.emit({
|
this.emit({
|
||||||
@@ -4033,7 +3988,7 @@ export class Session {
|
|||||||
const resolvedCwd = expandTilde(cwd);
|
const resolvedCwd = expandTilde(cwd);
|
||||||
this.assertSafeGitRef(branchName, "branch");
|
this.assertSafeGitRef(branchName, "branch");
|
||||||
|
|
||||||
const resolution = await resolveBranchCheckout(resolvedCwd, branchName);
|
const resolution = await this.workspaceGitService.validateBranchRef(resolvedCwd, branchName);
|
||||||
switch (resolution.kind) {
|
switch (resolution.kind) {
|
||||||
case "local":
|
case "local":
|
||||||
this.emit({
|
this.emit({
|
||||||
@@ -4097,7 +4052,10 @@ export class Session {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const resolvedCwd = expandTilde(cwd);
|
const resolvedCwd = expandTilde(cwd);
|
||||||
const branchDetails = await listBranchSuggestions(resolvedCwd, { query, limit });
|
const branchDetails = await this.workspaceGitService.suggestBranchesForCwd(resolvedCwd, {
|
||||||
|
query,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "branch_suggestions_response",
|
type: "branch_suggestions_response",
|
||||||
payload: {
|
payload: {
|
||||||
@@ -4127,7 +4085,10 @@ export class Session {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const resolvedCwd = expandTilde(cwd);
|
const resolvedCwd = expandTilde(cwd);
|
||||||
const result = await searchGitHubIssuesAndPrs(resolvedCwd, query, limit, this.github, {
|
const result = await this.github.searchIssuesAndPrs({
|
||||||
|
cwd: resolvedCwd,
|
||||||
|
query,
|
||||||
|
limit,
|
||||||
kinds,
|
kinds,
|
||||||
});
|
});
|
||||||
this.emit({
|
this.emit({
|
||||||
@@ -4302,9 +4263,13 @@ export class Session {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscription = await this.workspaceGitService.subscribe({ cwd: normalizedCwd }, () => {
|
const subscription = await this.workspaceGitService.subscribe(
|
||||||
|
{ cwd: normalizedCwd },
|
||||||
|
(snapshot) => {
|
||||||
void this.emitWorkspaceUpdateForCwd(normalizedCwd);
|
void this.emitWorkspaceUpdateForCwd(normalizedCwd);
|
||||||
});
|
this.emitCheckoutStatusUpdate(normalizedCwd, snapshot);
|
||||||
|
},
|
||||||
|
);
|
||||||
this.workspaceGitSubscriptions.set(normalizedCwd, subscription.unsubscribe);
|
this.workspaceGitSubscriptions.set(normalizedCwd, subscription.unsubscribe);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4343,6 +4308,129 @@ export class Session {
|
|||||||
this.checkoutDiffSubscriptions.delete(msg.subscriptionId);
|
this.checkoutDiffSubscriptions.delete(msg.subscriptionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildCheckoutStatusPayloadFromSnapshot({
|
||||||
|
cwd,
|
||||||
|
requestId,
|
||||||
|
snapshot,
|
||||||
|
}: {
|
||||||
|
cwd: string;
|
||||||
|
requestId: string;
|
||||||
|
snapshot: WorkspaceGitRuntimeSnapshot;
|
||||||
|
}): CheckoutStatusResponse["payload"] {
|
||||||
|
if (!snapshot.git.isGit) {
|
||||||
|
return {
|
||||||
|
cwd,
|
||||||
|
isGit: false,
|
||||||
|
repoRoot: null,
|
||||||
|
currentBranch: null,
|
||||||
|
isDirty: null,
|
||||||
|
baseRef: null,
|
||||||
|
aheadBehind: null,
|
||||||
|
aheadOfOrigin: null,
|
||||||
|
behindOfOrigin: null,
|
||||||
|
hasRemote: false,
|
||||||
|
remoteUrl: null,
|
||||||
|
isPaseoOwnedWorktree: false,
|
||||||
|
error: null,
|
||||||
|
requestId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.git.repoRoot === null || snapshot.git.isDirty === null) {
|
||||||
|
throw new Error("Workspace git snapshot is missing required checkout status fields");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.git.isPaseoOwnedWorktree) {
|
||||||
|
if (snapshot.git.mainRepoRoot === null || snapshot.git.baseRef === null) {
|
||||||
|
throw new Error("Workspace git snapshot is missing required worktree status fields");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cwd,
|
||||||
|
isGit: true,
|
||||||
|
repoRoot: snapshot.git.repoRoot,
|
||||||
|
mainRepoRoot: snapshot.git.mainRepoRoot,
|
||||||
|
currentBranch: snapshot.git.currentBranch ?? null,
|
||||||
|
isDirty: snapshot.git.isDirty,
|
||||||
|
baseRef: snapshot.git.baseRef,
|
||||||
|
aheadBehind: snapshot.git.aheadBehind ?? null,
|
||||||
|
aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null,
|
||||||
|
behindOfOrigin: snapshot.git.behindOfOrigin ?? null,
|
||||||
|
hasRemote: snapshot.git.hasRemote,
|
||||||
|
remoteUrl: snapshot.git.remoteUrl,
|
||||||
|
isPaseoOwnedWorktree: true,
|
||||||
|
error: null,
|
||||||
|
requestId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cwd,
|
||||||
|
isGit: true,
|
||||||
|
repoRoot: snapshot.git.repoRoot,
|
||||||
|
currentBranch: snapshot.git.currentBranch ?? null,
|
||||||
|
isDirty: snapshot.git.isDirty,
|
||||||
|
baseRef: snapshot.git.baseRef ?? null,
|
||||||
|
aheadBehind: snapshot.git.aheadBehind ?? null,
|
||||||
|
aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null,
|
||||||
|
behindOfOrigin: snapshot.git.behindOfOrigin ?? null,
|
||||||
|
hasRemote: snapshot.git.hasRemote,
|
||||||
|
remoteUrl: snapshot.git.remoteUrl,
|
||||||
|
isPaseoOwnedWorktree: false,
|
||||||
|
error: null,
|
||||||
|
requestId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildCheckoutPrStatusPayloadFromSnapshot({
|
||||||
|
cwd,
|
||||||
|
requestId,
|
||||||
|
snapshot,
|
||||||
|
}: {
|
||||||
|
cwd: string;
|
||||||
|
requestId: string;
|
||||||
|
snapshot: WorkspaceGitRuntimeSnapshot;
|
||||||
|
}): CheckoutPrStatusResponse["payload"] {
|
||||||
|
return {
|
||||||
|
cwd,
|
||||||
|
status: normalizeCheckoutPrStatusPayload(snapshot.github.pullRequest),
|
||||||
|
githubFeaturesEnabled: snapshot.github.featuresEnabled,
|
||||||
|
error: snapshot.github.error
|
||||||
|
? {
|
||||||
|
code: "UNKNOWN",
|
||||||
|
message: snapshot.github.error.message,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
requestId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitCheckoutStatusUpdate(cwd: string, snapshot: WorkspaceGitRuntimeSnapshot): void {
|
||||||
|
try {
|
||||||
|
const requestId = `subscription:${cwd}`;
|
||||||
|
this.emit({
|
||||||
|
type: "checkout_status_update",
|
||||||
|
payload: {
|
||||||
|
...this.buildCheckoutStatusPayloadFromSnapshot({
|
||||||
|
cwd,
|
||||||
|
requestId,
|
||||||
|
snapshot,
|
||||||
|
}),
|
||||||
|
prStatus: this.buildCheckoutPrStatusPayloadFromSnapshot({
|
||||||
|
cwd,
|
||||||
|
requestId,
|
||||||
|
snapshot,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.sessionLogger.warn(
|
||||||
|
{ err: error, cwd },
|
||||||
|
"Failed to emit workspace checkout status update",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async handleCheckoutSwitchBranchRequest(
|
private async handleCheckoutSwitchBranchRequest(
|
||||||
msg: Extract<SessionInboundMessage, { type: "checkout_switch_branch_request" }>,
|
msg: Extract<SessionInboundMessage, { type: "checkout_switch_branch_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -4350,7 +4438,6 @@ export class Session {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const checkoutResult = await this.checkoutExistingBranch(cwd, branch);
|
const checkoutResult = await this.checkoutExistingBranch(cwd, branch);
|
||||||
this.github.invalidate({ cwd });
|
|
||||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
||||||
|
|
||||||
// Push a workspace_update immediately so the sidebar/header reflect
|
// Push a workspace_update immediately so the sidebar/header reflect
|
||||||
@@ -4398,6 +4485,7 @@ export class Session {
|
|||||||
? `${Session.PASEO_STASH_PREFIX} ${branchLabel}`
|
? `${Session.PASEO_STASH_PREFIX} ${branchLabel}`
|
||||||
: `${Session.PASEO_STASH_PREFIX} unnamed`;
|
: `${Session.PASEO_STASH_PREFIX} unnamed`;
|
||||||
await execCommand("git", ["stash", "push", "--include-untracked", "-m", message], { cwd });
|
await execCommand("git", ["stash", "push", "--include-untracked", "-m", message], { cwd });
|
||||||
|
await this.notifyGitMutation(cwd, "stash-push");
|
||||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "stash_save_response",
|
type: "stash_save_response",
|
||||||
@@ -4417,6 +4505,7 @@ export class Session {
|
|||||||
const { cwd, stashIndex, requestId } = msg;
|
const { cwd, stashIndex, requestId } = msg;
|
||||||
try {
|
try {
|
||||||
await execCommand("git", ["stash", "pop", `stash@{${stashIndex}}`], { cwd });
|
await execCommand("git", ["stash", "pop", `stash@{${stashIndex}}`], { cwd });
|
||||||
|
await this.notifyGitMutation(cwd, "stash-pop");
|
||||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "stash_pop_response",
|
type: "stash_pop_response",
|
||||||
@@ -4436,35 +4525,7 @@ export class Session {
|
|||||||
const { cwd, requestId } = msg;
|
const { cwd, requestId } = msg;
|
||||||
const paseoOnly = msg.paseoOnly !== false;
|
const paseoOnly = msg.paseoOnly !== false;
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync("git stash list --format=%gd%x00%s", {
|
const entries = await this.workspaceGitService.listStashes(cwd, { paseoOnly });
|
||||||
cwd,
|
|
||||||
env: READ_ONLY_GIT_ENV,
|
|
||||||
});
|
|
||||||
const lines = stdout.trim().split("\n").filter(Boolean);
|
|
||||||
const entries: Array<{
|
|
||||||
index: number;
|
|
||||||
message: string;
|
|
||||||
branch: string | null;
|
|
||||||
isPaseo: boolean;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
const sepIdx = line.indexOf("\0");
|
|
||||||
if (sepIdx < 0) continue;
|
|
||||||
const refPart = line.slice(0, sepIdx);
|
|
||||||
const subject = line.slice(sepIdx + 1);
|
|
||||||
const indexMatch = refPart.match(/\{(\d+)\}/);
|
|
||||||
if (!indexMatch) continue;
|
|
||||||
const index = Number(indexMatch[1]);
|
|
||||||
const prefixIdx = subject.indexOf(Session.PASEO_STASH_PREFIX);
|
|
||||||
const isPaseo = prefixIdx >= 0;
|
|
||||||
const branch = isPaseo
|
|
||||||
? subject.slice(prefixIdx + Session.PASEO_STASH_PREFIX.length).trim() || null
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (paseoOnly && !isPaseo) continue;
|
|
||||||
entries.push({ index, message: subject, branch, isPaseo });
|
|
||||||
}
|
|
||||||
|
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "stash_list_response",
|
type: "stash_list_response",
|
||||||
@@ -4496,6 +4557,7 @@ export class Session {
|
|||||||
message,
|
message,
|
||||||
addAll: msg.addAll ?? true,
|
addAll: msg.addAll ?? true,
|
||||||
});
|
});
|
||||||
|
await this.notifyGitMutation(cwd, "commit-changes");
|
||||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
||||||
|
|
||||||
this.emit({
|
this.emit({
|
||||||
@@ -4526,35 +4588,18 @@ export class Session {
|
|||||||
const { cwd, requestId } = msg;
|
const { cwd, requestId } = msg;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await getCheckoutStatus(cwd, { paseoHome: this.paseoHome });
|
const snapshot = await this.workspaceGitService.getSnapshot(cwd);
|
||||||
if (!status.isGit) {
|
if (!snapshot.git.isGit) {
|
||||||
try {
|
throw new Error(`Not a git repository: ${cwd}`);
|
||||||
await execAsync("git rev-parse --is-inside-work-tree", {
|
|
||||||
cwd,
|
|
||||||
env: READ_ONLY_GIT_ENV,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const details =
|
|
||||||
typeof (error as any)?.stderr === "string"
|
|
||||||
? String((error as any).stderr).trim()
|
|
||||||
: error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: String(error);
|
|
||||||
throw new Error(`Not a git repository: ${cwd}\n${details}`.trim());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.requireCleanTarget) {
|
if (msg.requireCleanTarget) {
|
||||||
const { stdout } = await execAsync("git status --porcelain", {
|
if (snapshot.git.isDirty) {
|
||||||
cwd,
|
|
||||||
env: READ_ONLY_GIT_ENV,
|
|
||||||
});
|
|
||||||
if (stdout.trim().length > 0) {
|
|
||||||
throw new Error("Working directory has uncommitted changes.");
|
throw new Error("Working directory has uncommitted changes.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let baseRef = msg.baseRef ?? (status.isGit ? status.baseRef : null);
|
let baseRef = msg.baseRef ?? snapshot.git.baseRef;
|
||||||
if (!baseRef) {
|
if (!baseRef) {
|
||||||
throw new Error("Base branch is required for merge");
|
throw new Error("Base branch is required for merge");
|
||||||
}
|
}
|
||||||
@@ -4570,7 +4615,10 @@ export class Session {
|
|||||||
},
|
},
|
||||||
{ paseoHome: this.paseoHome },
|
{ paseoHome: this.paseoHome },
|
||||||
);
|
);
|
||||||
this.github.invalidate({ cwd: mutatedCwd });
|
await Promise.all([
|
||||||
|
this.notifyGitMutation(mutatedCwd, "merge-to-base", { invalidateGithub: true }),
|
||||||
|
...(mutatedCwd !== cwd ? [this.notifyGitMutation(cwd, "merge-to-base")] : []),
|
||||||
|
]);
|
||||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
||||||
|
|
||||||
this.emit({
|
this.emit({
|
||||||
@@ -4602,11 +4650,8 @@ export class Session {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (msg.requireCleanTarget ?? true) {
|
if (msg.requireCleanTarget ?? true) {
|
||||||
const { stdout } = await execAsync("git status --porcelain", {
|
const snapshot = await this.workspaceGitService.getSnapshot(cwd);
|
||||||
cwd,
|
if (snapshot.git.isDirty) {
|
||||||
env: READ_ONLY_GIT_ENV,
|
|
||||||
});
|
|
||||||
if (stdout.trim().length > 0) {
|
|
||||||
throw new Error("Working directory has uncommitted changes.");
|
throw new Error("Working directory has uncommitted changes.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4615,7 +4660,7 @@ export class Session {
|
|||||||
baseRef: msg.baseRef,
|
baseRef: msg.baseRef,
|
||||||
requireCleanTarget: msg.requireCleanTarget ?? true,
|
requireCleanTarget: msg.requireCleanTarget ?? true,
|
||||||
});
|
});
|
||||||
this.github.invalidate({ cwd });
|
await this.notifyGitMutation(cwd, "merge-from-base", { invalidateGithub: true });
|
||||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
||||||
|
|
||||||
this.emit({
|
this.emit({
|
||||||
@@ -4647,7 +4692,7 @@ export class Session {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await pullCurrentBranch(cwd);
|
await pullCurrentBranch(cwd);
|
||||||
this.github.invalidate({ cwd });
|
await this.notifyGitMutation(cwd, "pull", { invalidateGithub: true });
|
||||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
||||||
|
|
||||||
this.emit({
|
this.emit({
|
||||||
@@ -4679,7 +4724,7 @@ export class Session {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await pushCurrentBranch(cwd);
|
await pushCurrentBranch(cwd);
|
||||||
this.github.invalidate({ cwd });
|
await this.notifyGitMutation(cwd, "push", { invalidateGithub: true });
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "checkout_push_response",
|
type: "checkout_push_response",
|
||||||
payload: {
|
payload: {
|
||||||
@@ -4725,7 +4770,9 @@ export class Session {
|
|||||||
base: msg.baseRef,
|
base: msg.baseRef,
|
||||||
},
|
},
|
||||||
this.github,
|
this.github,
|
||||||
|
this.workspaceGitService,
|
||||||
);
|
);
|
||||||
|
await this.notifyGitMutation(cwd, "create-pr", { invalidateGithub: true });
|
||||||
|
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "checkout_pr_create_response",
|
type: "checkout_pr_create_response",
|
||||||
@@ -4876,6 +4923,7 @@ export class Session {
|
|||||||
{
|
{
|
||||||
emit: (message) => this.emit(message),
|
emit: (message) => this.emit(message),
|
||||||
paseoHome: this.paseoHome,
|
paseoHome: this.paseoHome,
|
||||||
|
workspaceGitService: this.workspaceGitService,
|
||||||
},
|
},
|
||||||
msg,
|
msg,
|
||||||
);
|
);
|
||||||
@@ -4888,6 +4936,7 @@ export class Session {
|
|||||||
{
|
{
|
||||||
paseoHome: this.paseoHome,
|
paseoHome: this.paseoHome,
|
||||||
github: this.github,
|
github: this.github,
|
||||||
|
workspaceGitService: this.workspaceGitService,
|
||||||
agentManager: this.agentManager,
|
agentManager: this.agentManager,
|
||||||
agentStorage: this.agentStorage,
|
agentStorage: this.agentStorage,
|
||||||
archiveWorkspaceRecord: async (workspaceDirectory) => {
|
archiveWorkspaceRecord: async (workspaceDirectory) => {
|
||||||
@@ -5530,13 +5579,9 @@ export class Session {
|
|||||||
projectRecord ?? (await this.projectRegistry.get(workspace.projectId));
|
projectRecord ?? (await this.projectRegistry.get(workspace.projectId));
|
||||||
|
|
||||||
let diffStat: { additions: number; deletions: number } | null = null;
|
let diffStat: { additions: number; deletions: number } | null = null;
|
||||||
const cachedShortstat = getCachedCheckoutShortstat(workspace.cwd);
|
const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd);
|
||||||
if (cachedShortstat !== undefined) {
|
if (snapshot?.git.diffStat) {
|
||||||
diffStat = cachedShortstat;
|
diffStat = snapshot.git.diffStat;
|
||||||
} else {
|
|
||||||
warmCheckoutShortstatInBackground(workspace.cwd, undefined, () => {
|
|
||||||
void this.emitWorkspaceUpdateForCwd(workspace.cwd);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -5559,6 +5604,7 @@ export class Session {
|
|||||||
routeStore: this.scriptRouteStore,
|
routeStore: this.scriptRouteStore,
|
||||||
runtimeStore: this.scriptRuntimeStore,
|
runtimeStore: this.scriptRuntimeStore,
|
||||||
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
||||||
|
gitMetadata: this.resolveWorkspaceScriptGitMetadata(workspace.cwd),
|
||||||
resolveHealth: this.resolveScriptHealth ?? undefined,
|
resolveHealth: this.resolveScriptHealth ?? undefined,
|
||||||
})
|
})
|
||||||
: [],
|
: [],
|
||||||
@@ -5599,19 +5645,7 @@ export class Session {
|
|||||||
projectRecord?: PersistedProjectRecord | null,
|
projectRecord?: PersistedProjectRecord | null,
|
||||||
): Promise<WorkspaceDescriptorPayload> {
|
): Promise<WorkspaceDescriptorPayload> {
|
||||||
const base = await this.describeWorkspaceRecord(workspace, projectRecord);
|
const base = await this.describeWorkspaceRecord(workspace, projectRecord);
|
||||||
let snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd);
|
const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd);
|
||||||
if (!snapshot) {
|
|
||||||
try {
|
|
||||||
snapshot = await this.workspaceGitService.getSnapshot(workspace.cwd);
|
|
||||||
} catch (error) {
|
|
||||||
this.sessionLogger.warn(
|
|
||||||
{ err: error, cwd: workspace.cwd },
|
|
||||||
"Failed to load git snapshot for workspace",
|
|
||||||
);
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
@@ -6104,14 +6138,14 @@ export class Session {
|
|||||||
private async createPaseoWorktree(
|
private async createPaseoWorktree(
|
||||||
input: CreatePaseoWorktreeInput,
|
input: CreatePaseoWorktreeInput,
|
||||||
options?: {
|
options?: {
|
||||||
resolveRepositoryDefaultBranch?: (repoRoot: string) => Promise<string>;
|
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||||
},
|
},
|
||||||
): Promise<CreatePaseoWorktreeResult> {
|
): Promise<CreatePaseoWorktreeResult> {
|
||||||
const coreDeps = createWorktreeCoreDeps(this.github);
|
const coreDeps = createWorktreeCoreDeps(this.github);
|
||||||
return createPaseoWorktree(input, {
|
const result = await createPaseoWorktree(input, {
|
||||||
...coreDeps,
|
...coreDeps,
|
||||||
...(options?.resolveRepositoryDefaultBranch
|
...(options?.resolveDefaultBranch
|
||||||
? { resolveRepositoryDefaultBranch: options.resolveRepositoryDefaultBranch }
|
? { resolveDefaultBranch: options.resolveDefaultBranch }
|
||||||
: {}),
|
: {}),
|
||||||
projectRegistry: this.projectRegistry,
|
projectRegistry: this.projectRegistry,
|
||||||
workspaceRegistry: this.workspaceRegistry,
|
workspaceRegistry: this.workspaceRegistry,
|
||||||
@@ -6121,6 +6155,11 @@ export class Session {
|
|||||||
broadcastWorkspaceUpdate: (workspaceId) =>
|
broadcastWorkspaceUpdate: (workspaceId) =>
|
||||||
this.emitWorkspaceUpdateForWorkspaceId(workspaceId),
|
this.emitWorkspaceUpdateForWorkspaceId(workspaceId),
|
||||||
});
|
});
|
||||||
|
await Promise.all([
|
||||||
|
this.notifyGitMutation(input.cwd, "create-worktree"),
|
||||||
|
this.notifyGitMutation(result.worktree.worktreePath, "create-worktree"),
|
||||||
|
]);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
|
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
|
||||||
@@ -6440,10 +6479,27 @@ export class Session {
|
|||||||
routeStore: this.scriptRouteStore,
|
routeStore: this.scriptRouteStore,
|
||||||
runtimeStore: this.scriptRuntimeStore,
|
runtimeStore: this.scriptRuntimeStore,
|
||||||
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
||||||
|
gitMetadata: this.resolveWorkspaceScriptGitMetadata(workspaceDirectory),
|
||||||
resolveHealth: this.resolveScriptHealth ?? undefined,
|
resolveHealth: this.resolveScriptHealth ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveWorkspaceScriptGitMetadata(
|
||||||
|
workspaceDirectory: string,
|
||||||
|
): { projectSlug: string; currentBranch: string | null } | undefined {
|
||||||
|
const snapshot = this.workspaceGitService.peekSnapshot(workspaceDirectory);
|
||||||
|
if (!snapshot) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
projectSlug: deriveProjectSlug(
|
||||||
|
workspaceDirectory,
|
||||||
|
snapshot.git.isGit ? snapshot.git.remoteUrl : null,
|
||||||
|
),
|
||||||
|
currentBranch: snapshot.git.currentBranch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private emitWorkspaceScriptStatusUpdate(workspaceId: string, workspaceDirectory: string): void {
|
private emitWorkspaceScriptStatusUpdate(workspaceId: string, workspaceDirectory: string): void {
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "script_status_update",
|
type: "script_status_update",
|
||||||
@@ -6474,12 +6530,13 @@ export class Session {
|
|||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
throw new Error(`Workspace not found: ${request.workspaceId}`);
|
throw new Error(`Workspace not found: ${request.workspaceId}`);
|
||||||
}
|
}
|
||||||
|
const gitMetadata = await this.workspaceGitService.getWorkspaceGitMetadata(workspace.cwd);
|
||||||
|
|
||||||
const serviceResult = await spawnWorkspaceScript({
|
const serviceResult = await spawnWorkspaceScript({
|
||||||
repoRoot: workspace.cwd,
|
repoRoot: workspace.cwd,
|
||||||
workspaceId: workspace.workspaceId,
|
workspaceId: workspace.workspaceId,
|
||||||
projectSlug: deriveProjectSlug(workspace.cwd),
|
projectSlug: gitMetadata.projectSlug,
|
||||||
branchName: readGitCommand(workspace.cwd, "git symbolic-ref --short HEAD"),
|
branchName: gitMetadata.currentBranch,
|
||||||
scriptName: request.scriptName,
|
scriptName: request.scriptName,
|
||||||
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
||||||
daemonListenHost: this.getDaemonTcpHost?.() ?? null,
|
daemonListenHost: this.getDaemonTcpHost?.() ?? null,
|
||||||
|
|||||||
@@ -27,9 +27,11 @@ function createWorkspaceRuntimeSnapshot(
|
|||||||
remoteUrl: "https://github.com/acme/repo.git",
|
remoteUrl: "https://github.com/acme/repo.git",
|
||||||
isPaseoOwnedWorktree: false,
|
isPaseoOwnedWorktree: false,
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
|
baseRef: "main",
|
||||||
aheadBehind: { ahead: 0, behind: 0 },
|
aheadBehind: { ahead: 0, behind: 0 },
|
||||||
aheadOfOrigin: 0,
|
aheadOfOrigin: 0,
|
||||||
behindOfOrigin: 0,
|
behindOfOrigin: 0,
|
||||||
|
hasRemote: true,
|
||||||
diffStat: { additions: 1, deletions: 0 },
|
diffStat: { additions: 1, deletions: 0 },
|
||||||
},
|
},
|
||||||
github: {
|
github: {
|
||||||
@@ -313,6 +315,153 @@ describe("workspace git watch targets", () => {
|
|||||||
await session.cleanup();
|
await session.cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("emits checkout_status_update to a client subscribed to the workspace git target", async () => {
|
||||||
|
const { session, emitted, projects, workspaces, workspaceGitService, subscriptions } =
|
||||||
|
createSessionForWorkspaceGitWatchTests();
|
||||||
|
const sessionAny = session as any;
|
||||||
|
seedGitWorkspace({
|
||||||
|
projects,
|
||||||
|
workspaces,
|
||||||
|
projectId: "proj-1",
|
||||||
|
workspaceId: "ws-10",
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
name: "main",
|
||||||
|
});
|
||||||
|
sessionAny.workspaceUpdatesSubscription = {
|
||||||
|
subscriptionId: "sub-1",
|
||||||
|
filter: undefined,
|
||||||
|
isBootstrapping: false,
|
||||||
|
pendingUpdatesByWorkspaceId: new Map(),
|
||||||
|
lastEmittedByWorkspaceId: new Map(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo", { isGit: true });
|
||||||
|
emitted.length = 0;
|
||||||
|
|
||||||
|
subscriptions[0]?.listener(
|
||||||
|
createWorkspaceRuntimeSnapshot("/tmp/repo", {
|
||||||
|
git: {
|
||||||
|
currentBranch: "feature/server-push",
|
||||||
|
isDirty: true,
|
||||||
|
aheadBehind: { ahead: 3, behind: 1 },
|
||||||
|
aheadOfOrigin: 3,
|
||||||
|
behindOfOrigin: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusUpdates = emitted.filter(
|
||||||
|
(message) => message.type === "checkout_status_update",
|
||||||
|
) as any[];
|
||||||
|
expect(statusUpdates).toHaveLength(1);
|
||||||
|
expect(statusUpdates[0]?.payload).toMatchObject({
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
isGit: true,
|
||||||
|
repoRoot: "/tmp/repo",
|
||||||
|
currentBranch: "feature/server-push",
|
||||||
|
isDirty: true,
|
||||||
|
baseRef: "main",
|
||||||
|
aheadBehind: { ahead: 3, behind: 1 },
|
||||||
|
aheadOfOrigin: 3,
|
||||||
|
behindOfOrigin: 1,
|
||||||
|
hasRemote: true,
|
||||||
|
remoteUrl: "https://github.com/acme/repo.git",
|
||||||
|
isPaseoOwnedWorktree: false,
|
||||||
|
error: null,
|
||||||
|
requestId: "subscription:/tmp/repo",
|
||||||
|
});
|
||||||
|
expect(workspaceGitService.subscribe).toHaveBeenCalledWith(
|
||||||
|
{ cwd: "/tmp/repo" },
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
|
||||||
|
await session.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("embeds PR status in checkout_status_update for GitHub-inclusive snapshot pushes", async () => {
|
||||||
|
const { session, emitted, projects, workspaces, subscriptions } =
|
||||||
|
createSessionForWorkspaceGitWatchTests();
|
||||||
|
const sessionAny = session as any;
|
||||||
|
seedGitWorkspace({
|
||||||
|
projects,
|
||||||
|
workspaces,
|
||||||
|
projectId: "proj-1",
|
||||||
|
workspaceId: "ws-10",
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
name: "main",
|
||||||
|
});
|
||||||
|
sessionAny.workspaceUpdatesSubscription = {
|
||||||
|
subscriptionId: "sub-1",
|
||||||
|
filter: undefined,
|
||||||
|
isBootstrapping: false,
|
||||||
|
pendingUpdatesByWorkspaceId: new Map(),
|
||||||
|
lastEmittedByWorkspaceId: new Map(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo", { isGit: true });
|
||||||
|
emitted.length = 0;
|
||||||
|
|
||||||
|
subscriptions[0]?.listener(
|
||||||
|
createWorkspaceRuntimeSnapshot("/tmp/repo", {
|
||||||
|
github: {
|
||||||
|
featuresEnabled: true,
|
||||||
|
pullRequest: {
|
||||||
|
number: 456,
|
||||||
|
url: "https://github.com/acme/repo/pull/456",
|
||||||
|
title: "Runtime centralization",
|
||||||
|
state: "open",
|
||||||
|
baseRefName: "main",
|
||||||
|
headRefName: "workspace-git-service",
|
||||||
|
isMerged: false,
|
||||||
|
checks: [
|
||||||
|
{
|
||||||
|
name: "test",
|
||||||
|
status: "success",
|
||||||
|
url: "https://github.com/acme/repo/actions/runs/1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
checksStatus: "success",
|
||||||
|
reviewDecision: "approved",
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusUpdate = emitted.find((message) => message.type === "checkout_status_update") as
|
||||||
|
| { payload: any }
|
||||||
|
| undefined;
|
||||||
|
expect(statusUpdate?.payload.prStatus).toEqual({
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
status: {
|
||||||
|
number: 456,
|
||||||
|
url: "https://github.com/acme/repo/pull/456",
|
||||||
|
title: "Runtime centralization",
|
||||||
|
state: "open",
|
||||||
|
repoOwner: undefined,
|
||||||
|
repoName: undefined,
|
||||||
|
baseRefName: "main",
|
||||||
|
headRefName: "workspace-git-service",
|
||||||
|
isMerged: false,
|
||||||
|
isDraft: false,
|
||||||
|
checks: [
|
||||||
|
{
|
||||||
|
name: "test",
|
||||||
|
status: "success",
|
||||||
|
url: "https://github.com/acme/repo/actions/runs/1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
checksStatus: "success",
|
||||||
|
reviewDecision: "approved",
|
||||||
|
},
|
||||||
|
githubFeaturesEnabled: true,
|
||||||
|
error: null,
|
||||||
|
requestId: "subscription:/tmp/repo",
|
||||||
|
});
|
||||||
|
|
||||||
|
await session.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
test("checkout_pr_status_request reads pull request status from the workspace git service snapshot", async () => {
|
test("checkout_pr_status_request reads pull request status from the workspace git service snapshot", async () => {
|
||||||
const { session, emitted, workspaceGitService } = createSessionForWorkspaceGitWatchTests();
|
const { session, emitted, workspaceGitService } = createSessionForWorkspaceGitWatchTests();
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,9 @@ function createNoopWorkspaceGitService() {
|
|||||||
refreshedAt: null,
|
refreshedAt: null,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
resolveRepoRemoteUrl: async () => null,
|
||||||
|
resolveRepoRoot: async (cwd: string) => cwd,
|
||||||
|
resolveDefaultBranch: async () => "main",
|
||||||
refresh: async () => {},
|
refresh: async () => {},
|
||||||
requestWorkingTreeWatch: async (cwd: string) => ({
|
requestWorkingTreeWatch: async (cwd: string) => ({
|
||||||
repoRoot: cwd,
|
repoRoot: cwd,
|
||||||
@@ -139,9 +142,11 @@ function createWorkspaceRuntimeSnapshot(
|
|||||||
remoteUrl: "https://github.com/acme/repo.git",
|
remoteUrl: "https://github.com/acme/repo.git",
|
||||||
isPaseoOwnedWorktree: false,
|
isPaseoOwnedWorktree: false,
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
|
baseRef: "main",
|
||||||
aheadBehind: { ahead: 0, behind: 0 },
|
aheadBehind: { ahead: 0, behind: 0 },
|
||||||
aheadOfOrigin: 0,
|
aheadOfOrigin: 0,
|
||||||
behindOfOrigin: 0,
|
behindOfOrigin: 0,
|
||||||
|
hasRemote: true,
|
||||||
diffStat: { additions: 1, deletions: 0 },
|
diffStat: { additions: 1, deletions: 0 },
|
||||||
},
|
},
|
||||||
github: {
|
github: {
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ describe("deriveProjectSlug", () => {
|
|||||||
])("slugifies the GitHub repo name from %s", (remoteUrl, expectedSlug) => {
|
])("slugifies the GitHub repo name from %s", (remoteUrl, expectedSlug) => {
|
||||||
const cwd = track(createGitWorkspace("fallback-name", remoteUrl));
|
const cwd = track(createGitWorkspace("fallback-name", remoteUrl));
|
||||||
|
|
||||||
expect(deriveProjectSlug(cwd)).toBe(expectedSlug);
|
expect(deriveProjectSlug(cwd, remoteUrl)).toBe(expectedSlug);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses only the repo name, so identical repo names collide across owners", () => {
|
test("uses only the repo name, so identical repo names collide across owners", () => {
|
||||||
@@ -93,8 +93,8 @@ describe("deriveProjectSlug", () => {
|
|||||||
createGitWorkspace("other-fallback", "https://github.com/other/claude-code"),
|
createGitWorkspace("other-fallback", "https://github.com/other/claude-code"),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(deriveProjectSlug(acmeCwd)).toBe("claude-code");
|
expect(deriveProjectSlug(acmeCwd, "https://github.com/acme/claude-code")).toBe("claude-code");
|
||||||
expect(deriveProjectSlug(otherCwd)).toBe("claude-code");
|
expect(deriveProjectSlug(otherCwd, "https://github.com/other/claude-code")).toBe("claude-code");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("falls through to the cwd basename for non-GitHub remotes", () => {
|
test("falls through to the cwd basename for non-GitHub remotes", () => {
|
||||||
@@ -104,7 +104,7 @@ describe("deriveProjectSlug", () => {
|
|||||||
runGit(cwd, ["init"]);
|
runGit(cwd, ["init"]);
|
||||||
runGit(cwd, ["config", "remote.origin.url", "git@gitlab.com:acme/claude-code.git"]);
|
runGit(cwd, ["config", "remote.origin.url", "git@gitlab.com:acme/claude-code.git"]);
|
||||||
|
|
||||||
expect(deriveProjectSlug(cwd)).toBe("my-local-repo");
|
expect(deriveProjectSlug(cwd, "git@gitlab.com:acme/claude-code.git")).toBe("my-local-repo");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("falls through to the cwd basename for embedded GitHub paths in non-GitHub remotes", () => {
|
test("falls through to the cwd basename for embedded GitHub paths in non-GitHub remotes", () => {
|
||||||
@@ -118,7 +118,9 @@ describe("deriveProjectSlug", () => {
|
|||||||
"https://gitlab.example/mirror/github.com/acme/claude-code.git",
|
"https://gitlab.example/mirror/github.com/acme/claude-code.git",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(deriveProjectSlug(cwd)).toBe("embedded-github-path");
|
expect(
|
||||||
|
deriveProjectSlug(cwd, "https://gitlab.example/mirror/github.com/acme/claude-code.git"),
|
||||||
|
).toBe("embedded-github-path");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("falls through to the cwd basename for an empty remote", () => {
|
test("falls through to the cwd basename for an empty remote", () => {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { execSync } from "child_process";
|
|
||||||
import { basename } from "path";
|
import { basename } from "path";
|
||||||
import { slugify } from "../utils/worktree.js";
|
import { slugify } from "../utils/worktree.js";
|
||||||
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
|
|
||||||
|
|
||||||
export type WorkspaceGitMetadata = {
|
export type WorkspaceGitMetadata = {
|
||||||
projectKind: "git" | "directory";
|
projectKind: "git" | "directory";
|
||||||
@@ -9,23 +7,12 @@ export type WorkspaceGitMetadata = {
|
|||||||
workspaceDisplayName: string;
|
workspaceDisplayName: string;
|
||||||
gitRemote: string | null;
|
gitRemote: string | null;
|
||||||
isWorktree: boolean;
|
isWorktree: boolean;
|
||||||
|
projectSlug: string;
|
||||||
|
repoRoot: string | null;
|
||||||
|
currentBranch: string | null;
|
||||||
|
remoteUrl: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function readGitCommand(cwd: string, command: string): string | null {
|
|
||||||
try {
|
|
||||||
const output = execSync(command, {
|
|
||||||
cwd,
|
|
||||||
env: READ_ONLY_GIT_ENV,
|
|
||||||
encoding: "utf8",
|
|
||||||
stdio: ["ignore", "pipe", "ignore"],
|
|
||||||
});
|
|
||||||
const trimmed = output.trim();
|
|
||||||
return trimmed.length > 0 ? trimmed : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseGitHubRepoFromRemote(remoteUrl: string): string | null {
|
export function parseGitHubRepoFromRemote(remoteUrl: string): string | null {
|
||||||
let cleaned = remoteUrl.trim();
|
let cleaned = remoteUrl.trim();
|
||||||
if (!cleaned) {
|
if (!cleaned) {
|
||||||
@@ -74,39 +61,48 @@ export function parseGitHubRepoNameFromRemote(remoteUrl: string): string | null
|
|||||||
return repoName && repoName.length > 0 ? repoName : null;
|
return repoName && repoName.length > 0 ? repoName : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deriveProjectSlug(cwd: string): string {
|
export function deriveProjectSlug(cwd: string, remoteUrl: string | null = null): string {
|
||||||
const gitRemote = readGitCommand(cwd, "git config --get remote.origin.url");
|
const githubRepoName = remoteUrl ? parseGitHubRepoNameFromRemote(remoteUrl) : null;
|
||||||
const githubRepoName = gitRemote ? parseGitHubRepoNameFromRemote(gitRemote) : null;
|
|
||||||
const sourceName = githubRepoName ?? basename(cwd);
|
const sourceName = githubRepoName ?? basename(cwd);
|
||||||
return slugify(sourceName) || "untitled";
|
return slugify(sourceName) || "untitled";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function detectWorkspaceGitMetadata(
|
export function buildWorkspaceGitMetadataFromSnapshot(input: {
|
||||||
cwd: string,
|
cwd: string;
|
||||||
directoryName: string,
|
directoryName: string;
|
||||||
): WorkspaceGitMetadata {
|
isGit: boolean;
|
||||||
const gitDir = readGitCommand(cwd, "git rev-parse --git-dir");
|
repoRoot: string | null;
|
||||||
if (!gitDir) {
|
mainRepoRoot: string | null;
|
||||||
|
currentBranch: string | null;
|
||||||
|
remoteUrl: string | null;
|
||||||
|
}): WorkspaceGitMetadata {
|
||||||
|
if (!input.isGit) {
|
||||||
return {
|
return {
|
||||||
projectKind: "directory",
|
projectKind: "directory",
|
||||||
projectDisplayName: directoryName,
|
projectDisplayName: input.directoryName,
|
||||||
workspaceDisplayName: directoryName,
|
workspaceDisplayName: input.directoryName,
|
||||||
gitRemote: null,
|
gitRemote: null,
|
||||||
isWorktree: false,
|
isWorktree: false,
|
||||||
|
projectSlug: deriveProjectSlug(input.cwd, null),
|
||||||
|
repoRoot: null,
|
||||||
|
currentBranch: null,
|
||||||
|
remoteUrl: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const gitRemote = readGitCommand(cwd, "git config --get remote.origin.url");
|
const githubRepo = input.remoteUrl ? parseGitHubRepoFromRemote(input.remoteUrl) : null;
|
||||||
const githubRepo = gitRemote ? parseGitHubRepoFromRemote(gitRemote) : null;
|
const isWorktree =
|
||||||
const branchName = readGitCommand(cwd, "git symbolic-ref --short HEAD");
|
input.mainRepoRoot !== null && input.repoRoot !== null && input.mainRepoRoot !== input.repoRoot;
|
||||||
const gitCommonDir = readGitCommand(cwd, "git rev-parse --git-common-dir");
|
|
||||||
const isWorktree = gitCommonDir !== null && gitDir !== gitCommonDir;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
projectKind: "git",
|
projectKind: "git",
|
||||||
projectDisplayName: githubRepo ?? directoryName,
|
projectDisplayName: githubRepo ?? input.directoryName,
|
||||||
workspaceDisplayName: branchName ?? directoryName,
|
workspaceDisplayName: input.currentBranch ?? input.directoryName,
|
||||||
gitRemote,
|
gitRemote: input.remoteUrl,
|
||||||
isWorktree,
|
isWorktree,
|
||||||
|
projectSlug: deriveProjectSlug(input.cwd, input.remoteUrl),
|
||||||
|
repoRoot: input.repoRoot,
|
||||||
|
currentBranch: input.currentBranch,
|
||||||
|
remoteUrl: input.remoteUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -33,9 +33,11 @@ function createSnapshot(
|
|||||||
remoteUrl: "https://github.com/acme/repo.git",
|
remoteUrl: "https://github.com/acme/repo.git",
|
||||||
isPaseoOwnedWorktree: false,
|
isPaseoOwnedWorktree: false,
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
|
baseRef: "main",
|
||||||
aheadBehind: { ahead: 0, behind: 0 },
|
aheadBehind: { ahead: 0, behind: 0 },
|
||||||
aheadOfOrigin: 0,
|
aheadOfOrigin: 0,
|
||||||
behindOfOrigin: 0,
|
behindOfOrigin: 0,
|
||||||
|
hasRemote: true,
|
||||||
diffStat: { additions: 1, deletions: 0 },
|
diffStat: { additions: 1, deletions: 0 },
|
||||||
},
|
},
|
||||||
github: {
|
github: {
|
||||||
@@ -144,6 +146,7 @@ function createGitHubServiceStub(): GitHubService {
|
|||||||
return {
|
return {
|
||||||
listPullRequests: vi.fn(async () => []),
|
listPullRequests: vi.fn(async () => []),
|
||||||
listIssues: vi.fn(async () => []),
|
listIssues: vi.fn(async () => []),
|
||||||
|
searchIssuesAndPrs: vi.fn(async () => ({ items: [], githubFeaturesEnabled: true })),
|
||||||
getPullRequest: vi.fn(async () => ({
|
getPullRequest: vi.fn(async () => ({
|
||||||
number: 1,
|
number: 1,
|
||||||
title: "PR",
|
title: "PR",
|
||||||
@@ -292,7 +295,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(0);
|
expect(getPullRequestStatus).toHaveBeenCalledTimes(0);
|
||||||
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0);
|
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0);
|
||||||
expect((service as any).workspaceTargets.size).toBe(0);
|
expect((service as any).workspaceTargets.size).toBe(1);
|
||||||
expect((service as any).workspaceTargetSetups.size).toBe(1);
|
expect((service as any).workspaceTargetSetups.size).toBe(1);
|
||||||
|
|
||||||
checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo"));
|
checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo"));
|
||||||
@@ -319,9 +322,11 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
||||||
const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git");
|
const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git");
|
||||||
|
|
||||||
|
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
|
||||||
const service = createService({
|
const service = createService({
|
||||||
getPullRequestStatus,
|
getPullRequestStatus,
|
||||||
resolveAbsoluteGitDir,
|
resolveAbsoluteGitDir,
|
||||||
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
|
|
||||||
const [first, second] = await Promise.all([
|
const [first, second] = await Promise.all([
|
||||||
@@ -342,9 +347,11 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
||||||
const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git");
|
const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git");
|
||||||
|
|
||||||
|
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
|
||||||
const service = createService({
|
const service = createService({
|
||||||
getPullRequestStatus,
|
getPullRequestStatus,
|
||||||
resolveAbsoluteGitDir,
|
resolveAbsoluteGitDir,
|
||||||
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
|
|
||||||
const subscription = await service.subscribe({ cwd: "/tmp/repo/." }, vi.fn());
|
const subscription = await service.subscribe({ cwd: "/tmp/repo/." }, vi.fn());
|
||||||
@@ -352,8 +359,15 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
expect(subscription.initial).toEqual(createSnapshot("/tmp/repo"));
|
expect(subscription.initial).toEqual(createSnapshot("/tmp/repo"));
|
||||||
expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo"));
|
expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo"));
|
||||||
|
|
||||||
|
nowMs += 3_000;
|
||||||
await service.refresh("/tmp/repo");
|
await service.refresh("/tmp/repo");
|
||||||
await expect(service.getSnapshot("/tmp/repo/.")).resolves.toEqual(createSnapshot("/tmp/repo"));
|
await expect(service.getSnapshot("/tmp/repo/.")).resolves.toEqual(
|
||||||
|
createSnapshot("/tmp/repo", {
|
||||||
|
github: {
|
||||||
|
refreshedAt: "2026-04-12T00:00:03.000Z",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
||||||
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
|
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
|
||||||
@@ -430,8 +444,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
|
|
||||||
expect(subscription.initial.github.pullRequest?.title).toBe("Before refresh");
|
expect(subscription.initial.github.pullRequest?.title).toBe("Before refresh");
|
||||||
|
|
||||||
service.refresh("/tmp/repo");
|
await service.refresh("/tmp/repo");
|
||||||
await (service as any).workspaceTargets.get("/tmp/repo")?.refreshPromise;
|
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
||||||
@@ -459,10 +472,11 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
test("unchanged runtime snapshots do not emit duplicate updates", async () => {
|
test("unchanged runtime snapshots do not emit duplicate updates", async () => {
|
||||||
const getCheckoutStatus = vi
|
const getCheckoutStatus = vi
|
||||||
.fn<() => Promise<CheckoutStatusGit>>()
|
.fn<() => Promise<CheckoutStatusGit>>()
|
||||||
.mockResolvedValueOnce(createCheckoutStatus("/tmp/repo"))
|
.mockResolvedValueOnce(createCheckoutStatus("/tmp/repo", { remoteUrl: null }))
|
||||||
.mockResolvedValueOnce(
|
.mockResolvedValueOnce(
|
||||||
createCheckoutStatus("/tmp/repo", {
|
createCheckoutStatus("/tmp/repo", {
|
||||||
currentBranch: "feature/runtime-payloads",
|
currentBranch: "feature/runtime-payloads",
|
||||||
|
remoteUrl: null,
|
||||||
aheadBehind: { ahead: 2, behind: 0 },
|
aheadBehind: { ahead: 2, behind: 0 },
|
||||||
aheadOfOrigin: 2,
|
aheadOfOrigin: 2,
|
||||||
}),
|
}),
|
||||||
@@ -470,6 +484,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
.mockResolvedValueOnce(
|
.mockResolvedValueOnce(
|
||||||
createCheckoutStatus("/tmp/repo", {
|
createCheckoutStatus("/tmp/repo", {
|
||||||
currentBranch: "feature/runtime-payloads",
|
currentBranch: "feature/runtime-payloads",
|
||||||
|
remoteUrl: null,
|
||||||
aheadBehind: { ahead: 2, behind: 0 },
|
aheadBehind: { ahead: 2, behind: 0 },
|
||||||
aheadOfOrigin: 2,
|
aheadOfOrigin: 2,
|
||||||
}),
|
}),
|
||||||
@@ -487,10 +502,11 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
|
||||||
const service = createService({
|
const service = createService({
|
||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
getPullRequestStatus,
|
getPullRequestStatus,
|
||||||
now: () => new Date("2026-04-12T00:00:00.000Z"),
|
now: () => new Date(nowMs),
|
||||||
});
|
});
|
||||||
|
|
||||||
const listener = vi.fn();
|
const listener = vi.fn();
|
||||||
@@ -498,12 +514,12 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
|
|
||||||
expect(subscription.initial.git.currentBranch).toBe("main");
|
expect(subscription.initial.git.currentBranch).toBe("main");
|
||||||
|
|
||||||
service.refresh("/tmp/repo");
|
nowMs += 3_000;
|
||||||
await (service as any).workspaceTargets.get("/tmp/repo")?.refreshPromise;
|
await service.refresh("/tmp/repo");
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
service.refresh("/tmp/repo");
|
nowMs += 3_000;
|
||||||
await (service as any).workspaceTargets.get("/tmp/repo")?.refreshPromise;
|
await service.refresh("/tmp/repo");
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(listener).toHaveBeenCalledTimes(1);
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
@@ -511,18 +527,14 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
createSnapshot("/tmp/repo", {
|
createSnapshot("/tmp/repo", {
|
||||||
git: {
|
git: {
|
||||||
currentBranch: "feature/runtime-payloads",
|
currentBranch: "feature/runtime-payloads",
|
||||||
|
remoteUrl: null,
|
||||||
aheadBehind: { ahead: 2, behind: 0 },
|
aheadBehind: { ahead: 2, behind: 0 },
|
||||||
aheadOfOrigin: 2,
|
aheadOfOrigin: 2,
|
||||||
},
|
},
|
||||||
github: {
|
github: {
|
||||||
pullRequest: {
|
featuresEnabled: false,
|
||||||
url: "https://github.com/acme/repo/pull/123",
|
pullRequest: null,
|
||||||
title: "Runtime payloads",
|
refreshedAt: null,
|
||||||
state: "open",
|
|
||||||
baseRefName: "main",
|
|
||||||
headRefName: "feature/runtime-payloads",
|
|
||||||
isMerged: false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -531,6 +543,32 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("forced snapshot refresh emits even when the fingerprint matches", async () => {
|
||||||
|
const getCheckoutStatus = vi.fn(async () => createCheckoutStatus("/tmp/repo"));
|
||||||
|
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
||||||
|
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
|
||||||
|
const service = createService({
|
||||||
|
getCheckoutStatus,
|
||||||
|
getPullRequestStatus,
|
||||||
|
now: () => new Date(nowMs),
|
||||||
|
});
|
||||||
|
|
||||||
|
const listener = vi.fn();
|
||||||
|
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener);
|
||||||
|
|
||||||
|
await service.getSnapshot("/tmp/repo", {
|
||||||
|
force: true,
|
||||||
|
reason: "test-force-emit",
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
expect(listener).toHaveBeenCalledWith(createSnapshot("/tmp/repo"));
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
test("watches nested repository directories on Linux", async () => {
|
test("watches nested repository directories on Linux", async () => {
|
||||||
const originalPlatform = process.platform;
|
const originalPlatform = process.platform;
|
||||||
Object.defineProperty(process, "platform", {
|
Object.defineProperty(process, "platform", {
|
||||||
|
|||||||
@@ -4,21 +4,39 @@ import { join, resolve } from "node:path";
|
|||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
import type { CheckoutContext } from "../utils/checkout-git.js";
|
import type { CheckoutContext } from "../utils/checkout-git.js";
|
||||||
import {
|
import {
|
||||||
|
type BranchCheckoutResolution,
|
||||||
|
type BranchSuggestion,
|
||||||
|
type CheckoutDiffCompare,
|
||||||
|
type CheckoutDiffResult,
|
||||||
|
getCheckoutDiff,
|
||||||
getCheckoutShortstat,
|
getCheckoutShortstat,
|
||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
getPullRequestStatus,
|
getPullRequestStatus,
|
||||||
hasOriginRemote,
|
hasOriginRemote,
|
||||||
|
listBranchSuggestions,
|
||||||
|
resolveRepositoryDefaultBranch,
|
||||||
|
resolveBranchCheckout,
|
||||||
resolveAbsoluteGitDir,
|
resolveAbsoluteGitDir,
|
||||||
} from "../utils/checkout-git.js";
|
} from "../utils/checkout-git.js";
|
||||||
import { createGitHubService, type GitHubService } from "../services/github-service.js";
|
import { createGitHubService, type GitHubService } from "../services/github-service.js";
|
||||||
import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js";
|
import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js";
|
||||||
import { runGitCommand } from "../utils/run-git-command.js";
|
import { runGitCommand } from "../utils/run-git-command.js";
|
||||||
|
import { listPaseoWorktrees, type PaseoWorktreeInfo } from "../utils/worktree.js";
|
||||||
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
|
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
|
||||||
|
import {
|
||||||
|
buildWorkspaceGitMetadataFromSnapshot,
|
||||||
|
type WorkspaceGitMetadata,
|
||||||
|
} from "./workspace-git-metadata.js";
|
||||||
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
|
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
|
||||||
|
|
||||||
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
|
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
|
||||||
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
|
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
|
||||||
|
export const WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS = 60_000;
|
||||||
const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_000;
|
const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_000;
|
||||||
|
// Consumer reads may reuse cached values within this window; older peeks cold-load through the service.
|
||||||
|
const WORKSPACE_GIT_CONSUMER_TTL_MS = 15_000;
|
||||||
|
// Non-forced refresh triggers share this minimum gap to absorb watcher/self-heal bursts; force bypasses it.
|
||||||
|
const WORKSPACE_GIT_INTERNAL_MIN_GAP_MS = 2_000;
|
||||||
|
|
||||||
export type WorkspaceGitRuntimeSnapshot = {
|
export type WorkspaceGitRuntimeSnapshot = {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
@@ -30,9 +48,11 @@ export type WorkspaceGitRuntimeSnapshot = {
|
|||||||
remoteUrl: string | null;
|
remoteUrl: string | null;
|
||||||
isPaseoOwnedWorktree: boolean;
|
isPaseoOwnedWorktree: boolean;
|
||||||
isDirty: boolean | null;
|
isDirty: boolean | null;
|
||||||
|
baseRef: string | null;
|
||||||
aheadBehind: { ahead: number; behind: number } | null;
|
aheadBehind: { ahead: number; behind: number } | null;
|
||||||
aheadOfOrigin: number | null;
|
aheadOfOrigin: number | null;
|
||||||
behindOfOrigin: number | null;
|
behindOfOrigin: number | null;
|
||||||
|
hasRemote: boolean;
|
||||||
diffStat: { additions: number; deletions: number } | null;
|
diffStat: { additions: number; deletions: number } | null;
|
||||||
};
|
};
|
||||||
github: {
|
github: {
|
||||||
@@ -73,7 +93,42 @@ export interface WorkspaceGitService {
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null;
|
peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null;
|
||||||
getSnapshot(cwd: string): Promise<WorkspaceGitRuntimeSnapshot>;
|
getSnapshot(
|
||||||
|
cwd: string,
|
||||||
|
options?: WorkspaceGitSnapshotOptions,
|
||||||
|
): Promise<WorkspaceGitRuntimeSnapshot>;
|
||||||
|
getCheckoutDiff(
|
||||||
|
cwd: string,
|
||||||
|
options: CheckoutDiffCompare,
|
||||||
|
readOptions?: WorkspaceGitReadOptions,
|
||||||
|
): Promise<CheckoutDiffResult>;
|
||||||
|
validateBranchRef(
|
||||||
|
cwd: string,
|
||||||
|
ref: string,
|
||||||
|
options?: WorkspaceGitReadOptions,
|
||||||
|
): Promise<WorkspaceGitBranchValidationResult>;
|
||||||
|
hasLocalBranch(cwd: string, branch: string, options?: WorkspaceGitReadOptions): Promise<boolean>;
|
||||||
|
suggestBranchesForCwd(
|
||||||
|
cwd: string,
|
||||||
|
options?: WorkspaceGitBranchSuggestionsOptions,
|
||||||
|
readOptions?: WorkspaceGitReadOptions,
|
||||||
|
): Promise<WorkspaceGitBranchSuggestion[]>;
|
||||||
|
listStashes(
|
||||||
|
cwd: string,
|
||||||
|
options?: WorkspaceGitStashListOptions,
|
||||||
|
readOptions?: WorkspaceGitReadOptions,
|
||||||
|
): Promise<WorkspaceGitStashEntry[]>;
|
||||||
|
listWorktrees(
|
||||||
|
cwdOrRepoRoot: string,
|
||||||
|
options?: WorkspaceGitReadOptions,
|
||||||
|
): Promise<WorkspaceGitWorktreeInfo[]>;
|
||||||
|
getWorkspaceGitMetadata(
|
||||||
|
cwd: string,
|
||||||
|
options?: WorkspaceGitReadOptions & { directoryName?: string },
|
||||||
|
): Promise<WorkspaceGitMetadata>;
|
||||||
|
resolveRepoRoot(cwd: string, options?: WorkspaceGitReadOptions): Promise<string>;
|
||||||
|
resolveDefaultBranch(cwdOrRepoRoot: string, options?: WorkspaceGitReadOptions): Promise<string>;
|
||||||
|
resolveRepoRemoteUrl(cwd: string, options?: WorkspaceGitReadOptions): Promise<string | null>;
|
||||||
refresh(cwd: string, options?: { priority?: "normal" | "high" }): Promise<void>;
|
refresh(cwd: string, options?: { priority?: "normal" | "high" }): Promise<void>;
|
||||||
requestWorkingTreeWatch(
|
requestWorkingTreeWatch(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
@@ -85,12 +140,85 @@ export interface WorkspaceGitService {
|
|||||||
|
|
||||||
export type WorkspaceGitListener = (snapshot: WorkspaceGitRuntimeSnapshot) => void;
|
export type WorkspaceGitListener = (snapshot: WorkspaceGitRuntimeSnapshot) => void;
|
||||||
|
|
||||||
|
export type WorkspaceGitReadOptions =
|
||||||
|
| {
|
||||||
|
force?: false;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
force: true;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface WorkspaceGitBranchSuggestionsOptions {
|
||||||
|
query?: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceGitStashListOptions {
|
||||||
|
paseoOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceGitStashEntry {
|
||||||
|
index: number;
|
||||||
|
message: string;
|
||||||
|
branch: string | null;
|
||||||
|
isPaseo: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkspaceGitBranchValidationResult = BranchCheckoutResolution;
|
||||||
|
export type WorkspaceGitBranchSuggestion = BranchSuggestion;
|
||||||
|
export type WorkspaceGitWorktreeInfo = PaseoWorktreeInfo;
|
||||||
|
|
||||||
|
export type WorkspaceGitSnapshotOptions =
|
||||||
|
| {
|
||||||
|
force?: false;
|
||||||
|
includeGitHub?: boolean;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
force: true;
|
||||||
|
includeGitHub?: boolean;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface WorkspaceGitRefreshRequest {
|
||||||
|
force: boolean;
|
||||||
|
includeGitHub: boolean;
|
||||||
|
reason: string;
|
||||||
|
notify: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QueuedWorkspaceGitRefresh {
|
||||||
|
force: boolean;
|
||||||
|
includeGitHub: boolean;
|
||||||
|
reason: string;
|
||||||
|
notify: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkspaceGitRefreshState =
|
||||||
|
| {
|
||||||
|
status: "idle";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "in-flight";
|
||||||
|
promise: Promise<WorkspaceGitRuntimeSnapshot>;
|
||||||
|
force: boolean;
|
||||||
|
includeGitHub: boolean;
|
||||||
|
queued: QueuedWorkspaceGitRefresh | null;
|
||||||
|
};
|
||||||
|
|
||||||
interface WorkspaceGitServiceDependencies {
|
interface WorkspaceGitServiceDependencies {
|
||||||
watch: typeof watch;
|
watch: typeof watch;
|
||||||
readdir: typeof readdir;
|
readdir: typeof readdir;
|
||||||
getCheckoutStatus: typeof getCheckoutStatus;
|
getCheckoutStatus: typeof getCheckoutStatus;
|
||||||
getCheckoutShortstat: typeof getCheckoutShortstat;
|
getCheckoutShortstat: typeof getCheckoutShortstat;
|
||||||
|
getCheckoutDiff: typeof getCheckoutDiff;
|
||||||
getPullRequestStatus: typeof getPullRequestStatus;
|
getPullRequestStatus: typeof getPullRequestStatus;
|
||||||
|
resolveBranchCheckout: typeof resolveBranchCheckout;
|
||||||
|
resolveRepositoryDefaultBranch: typeof resolveRepositoryDefaultBranch;
|
||||||
|
listBranchSuggestions: typeof listBranchSuggestions;
|
||||||
|
listPaseoWorktrees: typeof listPaseoWorktrees;
|
||||||
github: GitHubService;
|
github: GitHubService;
|
||||||
resolveAbsoluteGitDir: (cwd: string) => Promise<string | null>;
|
resolveAbsoluteGitDir: (cwd: string) => Promise<string | null>;
|
||||||
hasOriginRemote: (cwd: string) => Promise<boolean>;
|
hasOriginRemote: (cwd: string) => Promise<boolean>;
|
||||||
@@ -110,10 +238,14 @@ interface WorkspaceGitTarget {
|
|||||||
listeners: Set<WorkspaceGitListener>;
|
listeners: Set<WorkspaceGitListener>;
|
||||||
watchers: FSWatcher[];
|
watchers: FSWatcher[];
|
||||||
debounceTimer: NodeJS.Timeout | null;
|
debounceTimer: NodeJS.Timeout | null;
|
||||||
refreshPromise: Promise<void> | null;
|
selfHealTimer: NodeJS.Timeout | null;
|
||||||
refreshQueued: boolean;
|
githubPollSubscription: { unsubscribe: () => void } | null;
|
||||||
|
githubPollHeadRef: string | null;
|
||||||
|
refreshState: WorkspaceGitRefreshState;
|
||||||
latestSnapshot: WorkspaceGitRuntimeSnapshot | null;
|
latestSnapshot: WorkspaceGitRuntimeSnapshot | null;
|
||||||
|
latestSnapshotLoadedAtMs: number | null;
|
||||||
latestFingerprint: string | null;
|
latestFingerprint: string | null;
|
||||||
|
lastShellOutAtMs: number | null;
|
||||||
repoGitRoot: string | null;
|
repoGitRoot: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +269,13 @@ interface WorkingTreeWatchTarget {
|
|||||||
listeners: Set<() => void>;
|
listeners: Set<() => void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WorkspaceGitAuxiliaryReadCacheEntry<T> {
|
||||||
|
value: T | null;
|
||||||
|
loadedAtMs: number | null;
|
||||||
|
lastShellOutAtMs: number | null;
|
||||||
|
inFlight: Promise<T> | null;
|
||||||
|
}
|
||||||
|
|
||||||
export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||||
private readonly logger: pino.Logger;
|
private readonly logger: pino.Logger;
|
||||||
private readonly paseoHome: string;
|
private readonly paseoHome: string;
|
||||||
@@ -146,6 +285,34 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
private readonly workspaceTargetSetups = new Map<string, Promise<WorkspaceGitTarget>>();
|
private readonly workspaceTargetSetups = new Map<string, Promise<WorkspaceGitTarget>>();
|
||||||
private readonly workingTreeWatchTargets = new Map<string, WorkingTreeWatchTarget>();
|
private readonly workingTreeWatchTargets = new Map<string, WorkingTreeWatchTarget>();
|
||||||
private readonly workingTreeWatchSetups = new Map<string, Promise<WorkingTreeWatchTarget>>();
|
private readonly workingTreeWatchSetups = new Map<string, Promise<WorkingTreeWatchTarget>>();
|
||||||
|
private readonly branchValidationCache = new Map<
|
||||||
|
string,
|
||||||
|
WorkspaceGitAuxiliaryReadCacheEntry<WorkspaceGitBranchValidationResult>
|
||||||
|
>();
|
||||||
|
private readonly localBranchCache = new Map<
|
||||||
|
string,
|
||||||
|
WorkspaceGitAuxiliaryReadCacheEntry<boolean>
|
||||||
|
>();
|
||||||
|
private readonly branchSuggestionsCache = new Map<
|
||||||
|
string,
|
||||||
|
WorkspaceGitAuxiliaryReadCacheEntry<WorkspaceGitBranchSuggestion[]>
|
||||||
|
>();
|
||||||
|
private readonly stashListCache = new Map<
|
||||||
|
string,
|
||||||
|
WorkspaceGitAuxiliaryReadCacheEntry<WorkspaceGitStashEntry[]>
|
||||||
|
>();
|
||||||
|
private readonly worktreeListCache = new Map<
|
||||||
|
string,
|
||||||
|
WorkspaceGitAuxiliaryReadCacheEntry<WorkspaceGitWorktreeInfo[]>
|
||||||
|
>();
|
||||||
|
private readonly defaultBranchCache = new Map<
|
||||||
|
string,
|
||||||
|
WorkspaceGitAuxiliaryReadCacheEntry<string>
|
||||||
|
>();
|
||||||
|
private readonly checkoutDiffCache = new Map<
|
||||||
|
string,
|
||||||
|
WorkspaceGitAuxiliaryReadCacheEntry<CheckoutDiffResult>
|
||||||
|
>();
|
||||||
|
|
||||||
constructor(options: WorkspaceGitServiceOptions) {
|
constructor(options: WorkspaceGitServiceOptions) {
|
||||||
this.logger = options.logger.child({ module: "workspace-git-service" });
|
this.logger = options.logger.child({ module: "workspace-git-service" });
|
||||||
@@ -155,7 +322,13 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
readdir: options.deps?.readdir ?? readdir,
|
readdir: options.deps?.readdir ?? readdir,
|
||||||
getCheckoutStatus: options.deps?.getCheckoutStatus ?? getCheckoutStatus,
|
getCheckoutStatus: options.deps?.getCheckoutStatus ?? getCheckoutStatus,
|
||||||
getCheckoutShortstat: options.deps?.getCheckoutShortstat ?? getCheckoutShortstat,
|
getCheckoutShortstat: options.deps?.getCheckoutShortstat ?? getCheckoutShortstat,
|
||||||
|
getCheckoutDiff: options.deps?.getCheckoutDiff ?? getCheckoutDiff,
|
||||||
getPullRequestStatus: options.deps?.getPullRequestStatus ?? getPullRequestStatus,
|
getPullRequestStatus: options.deps?.getPullRequestStatus ?? getPullRequestStatus,
|
||||||
|
resolveBranchCheckout: options.deps?.resolveBranchCheckout ?? resolveBranchCheckout,
|
||||||
|
resolveRepositoryDefaultBranch:
|
||||||
|
options.deps?.resolveRepositoryDefaultBranch ?? resolveRepositoryDefaultBranch,
|
||||||
|
listBranchSuggestions: options.deps?.listBranchSuggestions ?? listBranchSuggestions,
|
||||||
|
listPaseoWorktrees: options.deps?.listPaseoWorktrees ?? listPaseoWorktrees,
|
||||||
github: options.deps?.github ?? createGitHubService(),
|
github: options.deps?.github ?? createGitHubService(),
|
||||||
resolveAbsoluteGitDir: options.deps?.resolveAbsoluteGitDir ?? resolveAbsoluteGitDir,
|
resolveAbsoluteGitDir: options.deps?.resolveAbsoluteGitDir ?? resolveAbsoluteGitDir,
|
||||||
hasOriginRemote: options.deps?.hasOriginRemote ?? hasOriginRemote,
|
hasOriginRemote: options.deps?.hasOriginRemote ?? hasOriginRemote,
|
||||||
@@ -175,6 +348,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
const cwd = normalizeWorkspaceId(params.cwd);
|
const cwd = normalizeWorkspaceId(params.cwd);
|
||||||
const target = await this.ensureWorkspaceTarget(cwd);
|
const target = await this.ensureWorkspaceTarget(cwd);
|
||||||
target.listeners.add(listener);
|
target.listeners.add(listener);
|
||||||
|
if (target.listeners.size === 1) {
|
||||||
|
this.startWorkspaceSubscriptionTimers(target);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
initial: target.latestSnapshot ?? (await this.getSnapshot(cwd)),
|
initial: target.latestSnapshot ?? (await this.getSnapshot(cwd)),
|
||||||
@@ -184,15 +360,18 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSnapshot(cwd: string): Promise<WorkspaceGitRuntimeSnapshot> {
|
async getSnapshot(
|
||||||
|
cwd: string,
|
||||||
|
options?: WorkspaceGitSnapshotOptions,
|
||||||
|
): Promise<WorkspaceGitRuntimeSnapshot> {
|
||||||
cwd = normalizeWorkspaceId(cwd);
|
cwd = normalizeWorkspaceId(cwd);
|
||||||
const target = this.workspaceTargets.get(cwd);
|
const request = this.normalizeRefreshRequest(options, "getSnapshot", true);
|
||||||
if (target?.latestSnapshot) {
|
const target = await this.ensureWorkspaceTarget(cwd);
|
||||||
|
if (!request.force && this.isSnapshotWarm(target)) {
|
||||||
return target.latestSnapshot;
|
return target.latestSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ensuredTarget = await this.ensureWorkspaceTarget(cwd);
|
return this.requestWorkspaceSnapshot(target, request);
|
||||||
return ensuredTarget.latestSnapshot ?? (await this.refreshSnapshot(cwd));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null {
|
peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null {
|
||||||
@@ -200,11 +379,180 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
return this.workspaceTargets.get(cwd)?.latestSnapshot ?? null;
|
return this.workspaceTargets.get(cwd)?.latestSnapshot ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getCheckoutDiff(
|
||||||
|
cwd: string,
|
||||||
|
options: CheckoutDiffCompare,
|
||||||
|
readOptions?: WorkspaceGitReadOptions,
|
||||||
|
): Promise<CheckoutDiffResult> {
|
||||||
|
const normalizedCwd = normalizeWorkspaceId(cwd);
|
||||||
|
const normalizedOptions = this.normalizeCheckoutDiffOptions(options);
|
||||||
|
const key = this.buildCheckoutDiffCacheKey(normalizedCwd, normalizedOptions);
|
||||||
|
return this.readAuxiliaryCache(this.checkoutDiffCache, key, readOptions, () =>
|
||||||
|
this.deps.getCheckoutDiff(normalizedCwd, normalizedOptions, { paseoHome: this.paseoHome }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeCheckoutDiffOptions(options: CheckoutDiffCompare): CheckoutDiffCompare {
|
||||||
|
return {
|
||||||
|
mode: options.mode,
|
||||||
|
...(options.mode === "base" && options.baseRef !== undefined
|
||||||
|
? { baseRef: options.baseRef }
|
||||||
|
: {}),
|
||||||
|
...(options.ignoreWhitespace === true ? { ignoreWhitespace: true } : {}),
|
||||||
|
...(options.includeStructured === true ? { includeStructured: true } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildCheckoutDiffCacheKey(cwd: string, options: CheckoutDiffCompare): string {
|
||||||
|
// Diff content varies by compare signature. Keep the cache per exact diff read shape so
|
||||||
|
// hot diff panes coalesce while base refs and rendering options never share stale patches.
|
||||||
|
return JSON.stringify([
|
||||||
|
"checkout-diff",
|
||||||
|
cwd,
|
||||||
|
options.mode,
|
||||||
|
options.mode === "base" ? (options.baseRef ?? null) : null,
|
||||||
|
options.ignoreWhitespace === true,
|
||||||
|
options.includeStructured === true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
validateBranchRef(
|
||||||
|
cwd: string,
|
||||||
|
ref: string,
|
||||||
|
options?: WorkspaceGitReadOptions,
|
||||||
|
): Promise<WorkspaceGitBranchValidationResult> {
|
||||||
|
const normalizedCwd = normalizeWorkspaceId(cwd);
|
||||||
|
const normalizedRef = ref.trim();
|
||||||
|
const key = JSON.stringify(["branch-validation", normalizedCwd, normalizedRef]);
|
||||||
|
return this.readAuxiliaryCache(this.branchValidationCache, key, options, () =>
|
||||||
|
this.deps.resolveBranchCheckout(normalizedCwd, normalizedRef),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasLocalBranch(cwd: string, branch: string, options?: WorkspaceGitReadOptions): Promise<boolean> {
|
||||||
|
const normalizedCwd = normalizeWorkspaceId(cwd);
|
||||||
|
const normalizedBranch = branch.trim();
|
||||||
|
const ref = `refs/heads/${normalizedBranch}`;
|
||||||
|
const key = JSON.stringify(["local-branch", normalizedCwd, ref]);
|
||||||
|
return this.readAuxiliaryCache(this.localBranchCache, key, options, async () => {
|
||||||
|
const result = await this.deps.runGitCommand(["rev-parse", "--verify", "--quiet", ref], {
|
||||||
|
cwd: normalizedCwd,
|
||||||
|
env: READ_ONLY_GIT_ENV,
|
||||||
|
acceptExitCodes: [0, 1],
|
||||||
|
});
|
||||||
|
return result.exitCode === 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestBranchesForCwd(
|
||||||
|
cwd: string,
|
||||||
|
options?: WorkspaceGitBranchSuggestionsOptions,
|
||||||
|
readOptions?: WorkspaceGitReadOptions,
|
||||||
|
): Promise<WorkspaceGitBranchSuggestion[]> {
|
||||||
|
const normalizedCwd = normalizeWorkspaceId(cwd);
|
||||||
|
const query = options?.query ?? "";
|
||||||
|
const limit = options?.limit;
|
||||||
|
const key = JSON.stringify(["branch-suggestions", normalizedCwd, query, limit ?? null]);
|
||||||
|
return this.readAuxiliaryCache(this.branchSuggestionsCache, key, readOptions, () =>
|
||||||
|
this.deps.listBranchSuggestions(normalizedCwd, options),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
listStashes(
|
||||||
|
cwd: string,
|
||||||
|
options?: WorkspaceGitStashListOptions,
|
||||||
|
readOptions?: WorkspaceGitReadOptions,
|
||||||
|
): Promise<WorkspaceGitStashEntry[]> {
|
||||||
|
const normalizedCwd = normalizeWorkspaceId(cwd);
|
||||||
|
const paseoOnly = options?.paseoOnly !== false;
|
||||||
|
const key = JSON.stringify(["stashes", normalizedCwd, paseoOnly]);
|
||||||
|
return this.readAuxiliaryCache(this.stashListCache, key, readOptions, async () => {
|
||||||
|
const { stdout } = await this.deps.runGitCommand(["stash", "list", "--format=%gd%x00%s"], {
|
||||||
|
cwd: normalizedCwd,
|
||||||
|
env: READ_ONLY_GIT_ENV,
|
||||||
|
});
|
||||||
|
return parseWorkspaceGitStashList(stdout, { paseoOnly });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listWorktrees(
|
||||||
|
cwdOrRepoRoot: string,
|
||||||
|
options?: WorkspaceGitReadOptions,
|
||||||
|
): Promise<WorkspaceGitWorktreeInfo[]> {
|
||||||
|
const repoRoot = await this.resolveRepoRoot(cwdOrRepoRoot, options);
|
||||||
|
const key = JSON.stringify(["worktrees", repoRoot]);
|
||||||
|
return this.readAuxiliaryCache(this.worktreeListCache, key, options, () =>
|
||||||
|
this.deps.listPaseoWorktrees({
|
||||||
|
cwd: repoRoot,
|
||||||
|
paseoHome: this.paseoHome,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveRepoRoot(cwd: string, options?: WorkspaceGitReadOptions): Promise<string> {
|
||||||
|
const snapshot = await this.getSnapshot(cwd, options);
|
||||||
|
if (!snapshot.git.isGit) {
|
||||||
|
throw new Error("Create worktree requires a git repository");
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot.git.isPaseoOwnedWorktree
|
||||||
|
? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? normalizeWorkspaceId(cwd))
|
||||||
|
: (snapshot.git.repoRoot ?? normalizeWorkspaceId(cwd));
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveDefaultBranch(
|
||||||
|
cwdOrRepoRoot: string,
|
||||||
|
options?: WorkspaceGitReadOptions,
|
||||||
|
): Promise<string> {
|
||||||
|
const cwd = normalizeWorkspaceId(cwdOrRepoRoot);
|
||||||
|
const key = JSON.stringify(["default-branch", cwd]);
|
||||||
|
return this.readAuxiliaryCache(this.defaultBranchCache, key, options, async () => {
|
||||||
|
const defaultBranch = await this.deps.resolveRepositoryDefaultBranch(cwd);
|
||||||
|
if (!defaultBranch) {
|
||||||
|
throw new Error("Unable to resolve repository default branch");
|
||||||
|
}
|
||||||
|
return defaultBranch;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWorkspaceGitMetadata(
|
||||||
|
cwd: string,
|
||||||
|
options?: WorkspaceGitReadOptions & { directoryName?: string },
|
||||||
|
): Promise<WorkspaceGitMetadata> {
|
||||||
|
const snapshot = await this.getSnapshot(cwd, options);
|
||||||
|
const directoryName =
|
||||||
|
options?.directoryName ??
|
||||||
|
normalizeWorkspaceId(cwd).split(/[\\/]/).filter(Boolean).at(-1) ??
|
||||||
|
cwd;
|
||||||
|
return buildWorkspaceGitMetadataFromSnapshot({
|
||||||
|
cwd: normalizeWorkspaceId(cwd),
|
||||||
|
directoryName,
|
||||||
|
isGit: snapshot.git.isGit,
|
||||||
|
repoRoot: snapshot.git.repoRoot,
|
||||||
|
mainRepoRoot: snapshot.git.mainRepoRoot,
|
||||||
|
currentBranch: snapshot.git.currentBranch,
|
||||||
|
remoteUrl: snapshot.git.remoteUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveRepoRemoteUrl(
|
||||||
|
cwd: string,
|
||||||
|
options?: WorkspaceGitReadOptions,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const snapshot = await this.getSnapshot(cwd, options);
|
||||||
|
return snapshot.git.remoteUrl;
|
||||||
|
}
|
||||||
|
|
||||||
async refresh(cwd: string, _options?: { priority?: "normal" | "high" }): Promise<void> {
|
async refresh(cwd: string, _options?: { priority?: "normal" | "high" }): Promise<void> {
|
||||||
cwd = normalizeWorkspaceId(cwd);
|
cwd = normalizeWorkspaceId(cwd);
|
||||||
const target = this.workspaceTargets.get(cwd);
|
const target = this.workspaceTargets.get(cwd);
|
||||||
if (target) {
|
if (target) {
|
||||||
await this.refreshWorkspaceTarget(target);
|
await this.refreshWorkspaceTarget(target, {
|
||||||
|
force: false,
|
||||||
|
includeGitHub: false,
|
||||||
|
reason: "refresh",
|
||||||
|
notify: true,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,6 +620,67 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
return setup;
|
return setup;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readAuxiliaryCache<T>(
|
||||||
|
cache: Map<string, WorkspaceGitAuxiliaryReadCacheEntry<T>>,
|
||||||
|
key: string,
|
||||||
|
options: WorkspaceGitReadOptions | undefined,
|
||||||
|
load: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
if (options?.force && !options.reason) {
|
||||||
|
throw new Error("WorkspaceGitService forced read requires a reason");
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = this.ensureAuxiliaryCacheEntry(cache, key);
|
||||||
|
const nowMs = this.deps.now().getTime();
|
||||||
|
if (!options?.force && entry.value !== null && entry.loadedAtMs !== null) {
|
||||||
|
const ageMs = nowMs - entry.loadedAtMs;
|
||||||
|
if (ageMs <= WORKSPACE_GIT_CONSUMER_TTL_MS) {
|
||||||
|
return Promise.resolve(entry.value);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
entry.lastShellOutAtMs !== null &&
|
||||||
|
nowMs - entry.lastShellOutAtMs < WORKSPACE_GIT_INTERNAL_MIN_GAP_MS
|
||||||
|
) {
|
||||||
|
return Promise.resolve(entry.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.inFlight) {
|
||||||
|
return entry.inFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.lastShellOutAtMs = nowMs;
|
||||||
|
entry.inFlight = load()
|
||||||
|
.then((value) => {
|
||||||
|
entry.value = value;
|
||||||
|
entry.loadedAtMs = this.deps.now().getTime();
|
||||||
|
return value;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
entry.inFlight = null;
|
||||||
|
});
|
||||||
|
return entry.inFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureAuxiliaryCacheEntry<T>(
|
||||||
|
cache: Map<string, WorkspaceGitAuxiliaryReadCacheEntry<T>>,
|
||||||
|
key: string,
|
||||||
|
): WorkspaceGitAuxiliaryReadCacheEntry<T> {
|
||||||
|
const existing = cache.get(key);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry: WorkspaceGitAuxiliaryReadCacheEntry<T> = {
|
||||||
|
value: null,
|
||||||
|
loadedAtMs: null,
|
||||||
|
lastShellOutAtMs: null,
|
||||||
|
inFlight: null,
|
||||||
|
};
|
||||||
|
cache.set(key, entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
private async ensureWorkingTreeWatchTarget(cwd: string): Promise<WorkingTreeWatchTarget> {
|
private async ensureWorkingTreeWatchTarget(cwd: string): Promise<WorkingTreeWatchTarget> {
|
||||||
const existingTarget = this.workingTreeWatchTargets.get(cwd);
|
const existingTarget = this.workingTreeWatchTargets.get(cwd);
|
||||||
if (existingTarget) {
|
if (existingTarget) {
|
||||||
@@ -296,17 +705,27 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
listeners: new Set(),
|
listeners: new Set(),
|
||||||
watchers: [],
|
watchers: [],
|
||||||
debounceTimer: null,
|
debounceTimer: null,
|
||||||
refreshPromise: null,
|
selfHealTimer: null,
|
||||||
refreshQueued: false,
|
githubPollSubscription: null,
|
||||||
|
githubPollHeadRef: null,
|
||||||
|
refreshState: { status: "idle" },
|
||||||
latestSnapshot: null,
|
latestSnapshot: null,
|
||||||
|
latestSnapshotLoadedAtMs: null,
|
||||||
latestFingerprint: null,
|
latestFingerprint: null,
|
||||||
|
lastShellOutAtMs: null,
|
||||||
repoGitRoot: null,
|
repoGitRoot: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const initial = await this.refreshSnapshot(cwd);
|
|
||||||
this.rememberSnapshot(target, initial);
|
|
||||||
this.workspaceTargets.set(cwd, target);
|
this.workspaceTargets.set(cwd, target);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.requestWorkspaceSnapshot(target, {
|
||||||
|
force: false,
|
||||||
|
includeGitHub: true,
|
||||||
|
reason: "initial",
|
||||||
|
notify: false,
|
||||||
|
});
|
||||||
|
|
||||||
const gitDir = await this.deps.resolveAbsoluteGitDir(cwd);
|
const gitDir = await this.deps.resolveAbsoluteGitDir(cwd);
|
||||||
if (!gitDir) {
|
if (!gitDir) {
|
||||||
return target;
|
return target;
|
||||||
@@ -317,6 +736,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
this.startWorkspaceWatchers(target, gitDir, repoGitRoot);
|
this.startWorkspaceWatchers(target, gitDir, repoGitRoot);
|
||||||
await this.ensureRepoTarget(target);
|
await this.ensureRepoTarget(target);
|
||||||
return target;
|
return target;
|
||||||
|
} catch (error) {
|
||||||
|
this.closeWorkspaceTarget(target);
|
||||||
|
this.workspaceTargets.delete(cwd);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createWorkingTreeWatchTarget(cwd: string): Promise<WorkingTreeWatchTarget> {
|
private async createWorkingTreeWatchTarget(cwd: string): Promise<WorkingTreeWatchTarget> {
|
||||||
@@ -485,10 +909,73 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
|
|
||||||
target.debounceTimer = setTimeout(() => {
|
target.debounceTimer = setTimeout(() => {
|
||||||
target.debounceTimer = null;
|
target.debounceTimer = null;
|
||||||
void this.refreshWorkspaceTarget(target);
|
void this.refreshWorkspaceTarget(target, {
|
||||||
|
force: false,
|
||||||
|
includeGitHub: false,
|
||||||
|
reason: "watch",
|
||||||
|
notify: true,
|
||||||
|
});
|
||||||
}, WORKSPACE_GIT_WATCH_DEBOUNCE_MS);
|
}, WORKSPACE_GIT_WATCH_DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private startWorkspaceSubscriptionTimers(target: WorkspaceGitTarget): void {
|
||||||
|
if (!target.selfHealTimer) {
|
||||||
|
target.selfHealTimer = setInterval(() => {
|
||||||
|
this.getSnapshot(target.cwd, { reason: "self-heal-git" }).catch((error) => {
|
||||||
|
this.logger.warn(
|
||||||
|
{ err: error, cwd: target.cwd, reason: "self-heal-git" },
|
||||||
|
"Failed to run workspace git self-heal refresh",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateGitHubPollForTarget(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateGitHubPollForTarget(target: WorkspaceGitTarget): void {
|
||||||
|
if (target.listeners.size === 0) {
|
||||||
|
this.stopGitHubPollForTarget(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headRef = target.latestSnapshot?.git.currentBranch ?? null;
|
||||||
|
if (!headRef || !this.deps.github.retainCurrentPullRequestStatusPoll) {
|
||||||
|
this.stopGitHubPollForTarget(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target.githubPollHeadRef === headRef && target.githubPollSubscription) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stopGitHubPollForTarget(target);
|
||||||
|
target.githubPollHeadRef = headRef;
|
||||||
|
target.githubPollSubscription = this.deps.github.retainCurrentPullRequestStatusPoll({
|
||||||
|
cwd: target.cwd,
|
||||||
|
headRef,
|
||||||
|
onStatus: () => {
|
||||||
|
void this.refreshWorkspaceTarget(target, {
|
||||||
|
force: false,
|
||||||
|
includeGitHub: true,
|
||||||
|
reason: "self-heal-github",
|
||||||
|
notify: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
this.logger.warn(
|
||||||
|
{ err: error, cwd: target.cwd, headRef, reason: "self-heal-github" },
|
||||||
|
"Failed to run GitHub self-heal refresh",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopGitHubPollForTarget(target: WorkspaceGitTarget): void {
|
||||||
|
target.githubPollSubscription?.unsubscribe();
|
||||||
|
target.githubPollSubscription = null;
|
||||||
|
target.githubPollHeadRef = null;
|
||||||
|
}
|
||||||
|
|
||||||
private addWorkingTreeWatcher(
|
private addWorkingTreeWatcher(
|
||||||
target: WorkingTreeWatchTarget,
|
target: WorkingTreeWatchTarget,
|
||||||
watchPath: string,
|
watchPath: string,
|
||||||
@@ -632,51 +1119,177 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
return directories;
|
return directories;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async refreshWorkspaceTarget(target: WorkspaceGitTarget): Promise<void> {
|
private async refreshWorkspaceTarget(
|
||||||
if (target.refreshPromise) {
|
target: WorkspaceGitTarget,
|
||||||
target.refreshQueued = true;
|
request: WorkspaceGitRefreshRequest,
|
||||||
return;
|
): Promise<void> {
|
||||||
}
|
|
||||||
|
|
||||||
target.refreshPromise = (async () => {
|
|
||||||
do {
|
|
||||||
target.refreshQueued = false;
|
|
||||||
try {
|
try {
|
||||||
const snapshot = await this.refreshSnapshot(target.cwd);
|
await this.requestWorkspaceSnapshot(target, request);
|
||||||
this.rememberSnapshot(target, snapshot, { notify: true });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
{ err: error, cwd: target.cwd },
|
{ err: error, cwd: target.cwd, reason: request.reason },
|
||||||
"Failed to refresh workspace git snapshot",
|
"Failed to refresh workspace git snapshot",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} while (target.refreshQueued);
|
|
||||||
})();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await target.refreshPromise;
|
|
||||||
} finally {
|
|
||||||
target.refreshPromise = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async refreshSnapshot(cwd: string): Promise<WorkspaceGitRuntimeSnapshot> {
|
private requestWorkspaceSnapshot(
|
||||||
return loadWorkspaceGitRuntimeSnapshot(
|
target: WorkspaceGitTarget,
|
||||||
cwd,
|
request: WorkspaceGitRefreshRequest,
|
||||||
{ paseoHome: this.paseoHome },
|
): Promise<WorkspaceGitRuntimeSnapshot> {
|
||||||
this.deps.now(),
|
if (target.refreshState.status === "in-flight") {
|
||||||
this.deps,
|
if (request.force && !target.refreshState.force) {
|
||||||
|
target.refreshState.queued = this.mergeQueuedRefresh(target.refreshState.queued, request);
|
||||||
|
}
|
||||||
|
return target.refreshState.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.force && this.shouldThrottleNonForcedRefresh(target)) {
|
||||||
|
return Promise.resolve(target.latestSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = this.runWorkspaceRefreshLoop(target, request).finally(() => {
|
||||||
|
const state = target.refreshState;
|
||||||
|
if (state.status === "in-flight" && state.promise === promise) {
|
||||||
|
target.refreshState = { status: "idle" };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
target.refreshState = {
|
||||||
|
status: "in-flight",
|
||||||
|
promise,
|
||||||
|
force: request.force,
|
||||||
|
includeGitHub: request.includeGitHub,
|
||||||
|
queued: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeRefreshRequest(
|
||||||
|
options: WorkspaceGitSnapshotOptions | undefined,
|
||||||
|
defaultReason: string,
|
||||||
|
notify: boolean,
|
||||||
|
): WorkspaceGitRefreshRequest {
|
||||||
|
if (options?.force && !options.reason) {
|
||||||
|
throw new Error("WorkspaceGitService.getSnapshot force refresh requires a reason");
|
||||||
|
}
|
||||||
|
|
||||||
|
const force = options?.force === true;
|
||||||
|
return {
|
||||||
|
force,
|
||||||
|
includeGitHub: options?.includeGitHub ?? force,
|
||||||
|
reason: options?.reason ?? defaultReason,
|
||||||
|
notify,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSnapshotWarm(target: WorkspaceGitTarget): target is WorkspaceGitTarget & {
|
||||||
|
latestSnapshot: WorkspaceGitRuntimeSnapshot;
|
||||||
|
latestSnapshotLoadedAtMs: number;
|
||||||
|
} {
|
||||||
|
if (!target.latestSnapshot || target.latestSnapshotLoadedAtMs === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
this.deps.now().getTime() - target.latestSnapshotLoadedAtMs <= WORKSPACE_GIT_CONSUMER_TTL_MS
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private shouldThrottleNonForcedRefresh(
|
||||||
|
target: WorkspaceGitTarget,
|
||||||
|
): target is WorkspaceGitTarget & {
|
||||||
|
latestSnapshot: WorkspaceGitRuntimeSnapshot;
|
||||||
|
} {
|
||||||
|
if (!target.latestSnapshot || target.lastShellOutAtMs === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.deps.now().getTime() - target.lastShellOutAtMs < WORKSPACE_GIT_INTERNAL_MIN_GAP_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergeQueuedRefresh(
|
||||||
|
queued: QueuedWorkspaceGitRefresh | null,
|
||||||
|
request: WorkspaceGitRefreshRequest,
|
||||||
|
): QueuedWorkspaceGitRefresh {
|
||||||
|
if (!queued) {
|
||||||
|
return {
|
||||||
|
force: request.force,
|
||||||
|
includeGitHub: request.includeGitHub,
|
||||||
|
reason: request.reason,
|
||||||
|
notify: request.notify,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const force = queued.force || request.force;
|
||||||
|
return {
|
||||||
|
force,
|
||||||
|
includeGitHub: queued.includeGitHub || request.includeGitHub,
|
||||||
|
reason: request.force && !queued.force ? request.reason : queued.reason,
|
||||||
|
notify: queued.notify || request.notify,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runWorkspaceRefreshLoop(
|
||||||
|
target: WorkspaceGitTarget,
|
||||||
|
initialRequest: WorkspaceGitRefreshRequest,
|
||||||
|
): Promise<WorkspaceGitRuntimeSnapshot> {
|
||||||
|
let request = initialRequest;
|
||||||
|
let snapshot!: WorkspaceGitRuntimeSnapshot;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
snapshot = await this.refreshSnapshot(target, request);
|
||||||
|
this.rememberSnapshot(target, snapshot, {
|
||||||
|
notify: request.notify,
|
||||||
|
forceEmit: request.force,
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = target.refreshState;
|
||||||
|
if (state.status !== "in-flight" || !state.queued) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
request = state.queued;
|
||||||
|
state.queued = null;
|
||||||
|
state.force = request.force;
|
||||||
|
state.includeGitHub = request.includeGitHub;
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshSnapshot(
|
||||||
|
target: WorkspaceGitTarget,
|
||||||
|
request: WorkspaceGitRefreshRequest,
|
||||||
|
): Promise<WorkspaceGitRuntimeSnapshot> {
|
||||||
|
const now = this.deps.now();
|
||||||
|
target.lastShellOutAtMs = now.getTime();
|
||||||
|
if (request.force) {
|
||||||
|
this.deps.github.invalidate({ cwd: target.cwd });
|
||||||
|
}
|
||||||
|
const snapshot = await loadWorkspaceGitRuntimeSnapshot(
|
||||||
|
target.cwd,
|
||||||
|
{ paseoHome: this.paseoHome },
|
||||||
|
now,
|
||||||
|
this.deps,
|
||||||
|
{ force: request.force, reason: request.reason },
|
||||||
|
);
|
||||||
|
target.latestSnapshotLoadedAtMs = now.getTime();
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
private rememberSnapshot(
|
private rememberSnapshot(
|
||||||
target: WorkspaceGitTarget,
|
target: WorkspaceGitTarget,
|
||||||
snapshot: WorkspaceGitRuntimeSnapshot,
|
snapshot: WorkspaceGitRuntimeSnapshot,
|
||||||
options?: { notify?: boolean },
|
options?: { forceEmit?: boolean; notify?: boolean },
|
||||||
): void {
|
): void {
|
||||||
target.latestSnapshot = snapshot;
|
target.latestSnapshot = snapshot;
|
||||||
|
if (target.listeners.size > 0) {
|
||||||
|
this.updateGitHubPollForTarget(target);
|
||||||
|
}
|
||||||
const fingerprint = JSON.stringify(snapshot);
|
const fingerprint = JSON.stringify(snapshot);
|
||||||
if (target.latestFingerprint === fingerprint) {
|
const fingerprintMatches = target.latestFingerprint === fingerprint;
|
||||||
|
if (fingerprintMatches && !options?.forceEmit) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
target.latestFingerprint = fingerprint;
|
target.latestFingerprint = fingerprint;
|
||||||
@@ -714,7 +1327,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
if (!workspaceTarget) {
|
if (!workspaceTarget) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.refreshWorkspaceTarget(workspaceTarget);
|
await this.refreshWorkspaceTarget(workspaceTarget, {
|
||||||
|
force: false,
|
||||||
|
includeGitHub: false,
|
||||||
|
reason: "repo-fetch",
|
||||||
|
notify: true,
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -768,6 +1386,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
clearTimeout(target.debounceTimer);
|
clearTimeout(target.debounceTimer);
|
||||||
target.debounceTimer = null;
|
target.debounceTimer = null;
|
||||||
}
|
}
|
||||||
|
if (target.selfHealTimer) {
|
||||||
|
clearInterval(target.selfHealTimer);
|
||||||
|
target.selfHealTimer = null;
|
||||||
|
}
|
||||||
|
this.stopGitHubPollForTarget(target);
|
||||||
|
|
||||||
for (const watcher of target.watchers) {
|
for (const watcher of target.watchers) {
|
||||||
watcher.close();
|
watcher.close();
|
||||||
@@ -807,6 +1430,7 @@ async function loadWorkspaceGitRuntimeSnapshot(
|
|||||||
WorkspaceGitServiceDependencies,
|
WorkspaceGitServiceDependencies,
|
||||||
"getCheckoutStatus" | "getCheckoutShortstat" | "getPullRequestStatus" | "github"
|
"getCheckoutStatus" | "getCheckoutShortstat" | "getPullRequestStatus" | "github"
|
||||||
>,
|
>,
|
||||||
|
options?: { force?: boolean; reason?: string },
|
||||||
): Promise<WorkspaceGitRuntimeSnapshot> {
|
): Promise<WorkspaceGitRuntimeSnapshot> {
|
||||||
const checkoutStatus = await deps.getCheckoutStatus(cwd, context);
|
const checkoutStatus = await deps.getCheckoutStatus(cwd, context);
|
||||||
if (!checkoutStatus.isGit) {
|
if (!checkoutStatus.isGit) {
|
||||||
@@ -814,12 +1438,14 @@ async function loadWorkspaceGitRuntimeSnapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [diffStat, github] = await Promise.all([
|
const [diffStat, github] = await Promise.all([
|
||||||
deps.getCheckoutShortstat(cwd, context),
|
deps.getCheckoutShortstat(cwd, context, { force: options?.force }),
|
||||||
loadGitHubSnapshot({
|
loadGitHubSnapshot({
|
||||||
cwd,
|
cwd,
|
||||||
remoteUrl: checkoutStatus.remoteUrl,
|
remoteUrl: checkoutStatus.remoteUrl,
|
||||||
now,
|
now,
|
||||||
deps,
|
deps,
|
||||||
|
force: options?.force,
|
||||||
|
reason: options?.reason,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -833,9 +1459,11 @@ async function loadWorkspaceGitRuntimeSnapshot(
|
|||||||
remoteUrl: checkoutStatus.remoteUrl,
|
remoteUrl: checkoutStatus.remoteUrl,
|
||||||
isPaseoOwnedWorktree: checkoutStatus.isPaseoOwnedWorktree,
|
isPaseoOwnedWorktree: checkoutStatus.isPaseoOwnedWorktree,
|
||||||
isDirty: checkoutStatus.isDirty,
|
isDirty: checkoutStatus.isDirty,
|
||||||
|
baseRef: checkoutStatus.baseRef,
|
||||||
aheadBehind: checkoutStatus.aheadBehind,
|
aheadBehind: checkoutStatus.aheadBehind,
|
||||||
aheadOfOrigin: checkoutStatus.aheadOfOrigin,
|
aheadOfOrigin: checkoutStatus.aheadOfOrigin,
|
||||||
behindOfOrigin: checkoutStatus.behindOfOrigin,
|
behindOfOrigin: checkoutStatus.behindOfOrigin,
|
||||||
|
hasRemote: checkoutStatus.hasRemote,
|
||||||
diffStat,
|
diffStat,
|
||||||
},
|
},
|
||||||
github,
|
github,
|
||||||
@@ -847,6 +1475,8 @@ async function loadGitHubSnapshot(options: {
|
|||||||
remoteUrl: string | null;
|
remoteUrl: string | null;
|
||||||
now: Date;
|
now: Date;
|
||||||
deps: Pick<WorkspaceGitServiceDependencies, "getPullRequestStatus" | "github">;
|
deps: Pick<WorkspaceGitServiceDependencies, "getPullRequestStatus" | "github">;
|
||||||
|
force?: boolean;
|
||||||
|
reason?: string;
|
||||||
}): Promise<WorkspaceGitRuntimeSnapshot["github"]> {
|
}): Promise<WorkspaceGitRuntimeSnapshot["github"]> {
|
||||||
if (!hasGitHubRemoteUrl(options.remoteUrl)) {
|
if (!hasGitHubRemoteUrl(options.remoteUrl)) {
|
||||||
return {
|
return {
|
||||||
@@ -869,7 +1499,10 @@ async function loadGitHubSnapshot(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await options.deps.getPullRequestStatus(options.cwd, options.deps.github);
|
const result = await options.deps.getPullRequestStatus(options.cwd, options.deps.github, {
|
||||||
|
force: options.force,
|
||||||
|
reason: options.reason,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
featuresEnabled: true,
|
featuresEnabled: true,
|
||||||
pullRequest: result.status,
|
pullRequest: result.status,
|
||||||
@@ -900,6 +1533,42 @@ function hasGitHubRemoteUrl(remoteUrl: string | null): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseWorkspaceGitStashList(
|
||||||
|
stdout: string,
|
||||||
|
options: { paseoOnly: boolean },
|
||||||
|
): WorkspaceGitStashEntry[] {
|
||||||
|
const entries: WorkspaceGitStashEntry[] = [];
|
||||||
|
const lines = stdout.trim().split("\n").filter(Boolean);
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const sepIdx = line.indexOf("\0");
|
||||||
|
if (sepIdx < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refPart = line.slice(0, sepIdx);
|
||||||
|
const subject = line.slice(sepIdx + 1);
|
||||||
|
const indexMatch = refPart.match(/\{(\d+)\}/);
|
||||||
|
if (!indexMatch) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = Number(indexMatch[1]);
|
||||||
|
const prefix = "paseo-auto-stash:";
|
||||||
|
const prefixIdx = subject.indexOf(prefix);
|
||||||
|
const isPaseo = prefixIdx >= 0;
|
||||||
|
const branch = isPaseo ? subject.slice(prefixIdx + prefix.length).trim() || null : null;
|
||||||
|
|
||||||
|
if (options.paseoOnly && !isPaseo) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push({ index, message: subject, branch, isPaseo });
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
function buildNotGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
|
function buildNotGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
|
||||||
return {
|
return {
|
||||||
cwd,
|
cwd,
|
||||||
@@ -911,9 +1580,11 @@ function buildNotGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
|
|||||||
remoteUrl: null,
|
remoteUrl: null,
|
||||||
isPaseoOwnedWorktree: false,
|
isPaseoOwnedWorktree: false,
|
||||||
isDirty: null,
|
isDirty: null,
|
||||||
|
baseRef: null,
|
||||||
aheadBehind: null,
|
aheadBehind: null,
|
||||||
aheadOfOrigin: null,
|
aheadOfOrigin: null,
|
||||||
behindOfOrigin: null,
|
behindOfOrigin: null,
|
||||||
|
hasRemote: false,
|
||||||
diffStat: null,
|
diffStat: null,
|
||||||
},
|
},
|
||||||
github: {
|
github: {
|
||||||
|
|||||||
@@ -72,6 +72,47 @@ function createTestLogger() {
|
|||||||
return logger as any;
|
return logger as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createWorkspaceGitServiceStub(
|
||||||
|
metadataByCwd: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
projectKind: "git" | "directory";
|
||||||
|
projectDisplayName: string;
|
||||||
|
workspaceDisplayName: string;
|
||||||
|
gitRemote?: string | null;
|
||||||
|
}
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
getWorkspaceGitMetadata: vi.fn(async (cwd: string, options?: { directoryName?: string }) => {
|
||||||
|
const metadata = metadataByCwd[cwd];
|
||||||
|
const directoryName = options?.directoryName ?? path.basename(cwd);
|
||||||
|
if (!metadata) {
|
||||||
|
return {
|
||||||
|
projectKind: "directory" as const,
|
||||||
|
projectDisplayName: directoryName,
|
||||||
|
workspaceDisplayName: directoryName,
|
||||||
|
gitRemote: null,
|
||||||
|
isWorktree: false,
|
||||||
|
projectSlug: "untitled",
|
||||||
|
repoRoot: null,
|
||||||
|
currentBranch: null,
|
||||||
|
remoteUrl: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
gitRemote: metadata.gitRemote ?? null,
|
||||||
|
isWorktree: false,
|
||||||
|
projectSlug: "repo",
|
||||||
|
repoRoot: cwd,
|
||||||
|
currentBranch: metadata.workspaceDisplayName,
|
||||||
|
remoteUrl: metadata.gitRemote ?? null,
|
||||||
|
...metadata,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function createTempGitRepo(prefix: string): string {
|
function createTempGitRepo(prefix: string): string {
|
||||||
const raw = mkdtempSync(path.join(tmpdir(), prefix));
|
const raw = mkdtempSync(path.join(tmpdir(), prefix));
|
||||||
const dir = realpathSync(raw);
|
const dir = realpathSync(raw);
|
||||||
@@ -222,6 +263,13 @@ describe("WorkspaceReconciliationService", () => {
|
|||||||
projectRegistry,
|
projectRegistry,
|
||||||
workspaceRegistry,
|
workspaceRegistry,
|
||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
|
workspaceGitService: createWorkspaceGitServiceStub({
|
||||||
|
[resolved]: {
|
||||||
|
projectKind: "git",
|
||||||
|
projectDisplayName: path.basename(resolved),
|
||||||
|
workspaceDisplayName: "main",
|
||||||
|
},
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.runOnce();
|
const result = await service.runOnce();
|
||||||
@@ -271,6 +319,14 @@ describe("WorkspaceReconciliationService", () => {
|
|||||||
projectRegistry,
|
projectRegistry,
|
||||||
workspaceRegistry,
|
workspaceRegistry,
|
||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
|
workspaceGitService: createWorkspaceGitServiceStub({
|
||||||
|
[dir]: {
|
||||||
|
projectKind: "git",
|
||||||
|
projectDisplayName: "new-owner/new-repo",
|
||||||
|
workspaceDisplayName: "main",
|
||||||
|
gitRemote: "git@github.com:new-owner/new-repo.git",
|
||||||
|
},
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.runOnce();
|
const result = await service.runOnce();
|
||||||
@@ -316,6 +372,13 @@ describe("WorkspaceReconciliationService", () => {
|
|||||||
projectRegistry,
|
projectRegistry,
|
||||||
workspaceRegistry,
|
workspaceRegistry,
|
||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
|
workspaceGitService: createWorkspaceGitServiceStub({
|
||||||
|
[dir]: {
|
||||||
|
projectKind: "git",
|
||||||
|
projectDisplayName: path.basename(dir),
|
||||||
|
workspaceDisplayName: "feature-branch",
|
||||||
|
},
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.runOnce();
|
const result = await service.runOnce();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
PersistedProjectRecord,
|
PersistedProjectRecord,
|
||||||
PersistedWorkspaceRecord,
|
PersistedWorkspaceRecord,
|
||||||
} from "./workspace-registry.js";
|
} from "./workspace-registry.js";
|
||||||
import { detectWorkspaceGitMetadata } from "./workspace-git-metadata.js";
|
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||||
|
|
||||||
const DEFAULT_RECONCILE_INTERVAL_MS = 60_000;
|
const DEFAULT_RECONCILE_INTERVAL_MS = 60_000;
|
||||||
|
|
||||||
@@ -37,6 +37,7 @@ export type WorkspaceReconciliationServiceOptions = {
|
|||||||
logger: pino.Logger;
|
logger: pino.Logger;
|
||||||
intervalMs?: number;
|
intervalMs?: number;
|
||||||
onChanges?: (changes: ReconciliationChange[]) => void;
|
onChanges?: (changes: ReconciliationChange[]) => void;
|
||||||
|
workspaceGitService?: Pick<WorkspaceGitService, "getWorkspaceGitMetadata">;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class WorkspaceReconciliationService {
|
export class WorkspaceReconciliationService {
|
||||||
@@ -45,6 +46,7 @@ export class WorkspaceReconciliationService {
|
|||||||
private readonly logger: pino.Logger;
|
private readonly logger: pino.Logger;
|
||||||
private readonly intervalMs: number;
|
private readonly intervalMs: number;
|
||||||
private readonly onChanges: ((changes: ReconciliationChange[]) => void) | null;
|
private readonly onChanges: ((changes: ReconciliationChange[]) => void) | null;
|
||||||
|
private readonly workspaceGitService: Pick<WorkspaceGitService, "getWorkspaceGitMetadata"> | null;
|
||||||
private timer: ReturnType<typeof setInterval> | null = null;
|
private timer: ReturnType<typeof setInterval> | null = null;
|
||||||
private running = false;
|
private running = false;
|
||||||
|
|
||||||
@@ -54,6 +56,7 @@ export class WorkspaceReconciliationService {
|
|||||||
this.logger = options.logger.child({ module: "workspace-reconciliation" });
|
this.logger = options.logger.child({ module: "workspace-reconciliation" });
|
||||||
this.intervalMs = options.intervalMs ?? DEFAULT_RECONCILE_INTERVAL_MS;
|
this.intervalMs = options.intervalMs ?? DEFAULT_RECONCILE_INTERVAL_MS;
|
||||||
this.onChanges = options.onChanges ?? null;
|
this.onChanges = options.onChanges ?? null;
|
||||||
|
this.workspaceGitService = options.workspaceGitService ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
@@ -155,7 +158,7 @@ export class WorkspaceReconciliationService {
|
|||||||
|
|
||||||
const directoryName =
|
const directoryName =
|
||||||
project.rootPath.split(/[\\/]/).filter(Boolean).at(-1) ?? project.rootPath;
|
project.rootPath.split(/[\\/]/).filter(Boolean).at(-1) ?? project.rootPath;
|
||||||
const currentGit = detectWorkspaceGitMetadata(project.rootPath, directoryName);
|
const currentGit = await this.readWorkspaceGitMetadata(project.rootPath, directoryName);
|
||||||
|
|
||||||
const projectUpdates: Partial<
|
const projectUpdates: Partial<
|
||||||
Pick<PersistedProjectRecord, "kind" | "displayName" | "rootPath">
|
Pick<PersistedProjectRecord, "kind" | "displayName" | "rootPath">
|
||||||
@@ -198,7 +201,7 @@ export class WorkspaceReconciliationService {
|
|||||||
if (!existsSync(workspace.cwd)) continue;
|
if (!existsSync(workspace.cwd)) continue;
|
||||||
|
|
||||||
const wsDirName = workspace.cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? workspace.cwd;
|
const wsDirName = workspace.cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? workspace.cwd;
|
||||||
const wsGit = detectWorkspaceGitMetadata(workspace.cwd, wsDirName);
|
const wsGit = await this.readWorkspaceGitMetadata(workspace.cwd, wsDirName);
|
||||||
|
|
||||||
if (wsGit.projectKind === "git" && workspace.displayName !== wsGit.workspaceDisplayName) {
|
if (wsGit.projectKind === "git" && workspace.displayName !== wsGit.workspaceDisplayName) {
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
@@ -223,4 +226,21 @@ export class WorkspaceReconciliationService {
|
|||||||
|
|
||||||
return { changesApplied: changes, durationMs: Date.now() - start };
|
return { changesApplied: changes, durationMs: Date.now() - start };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async readWorkspaceGitMetadata(cwd: string, directoryName: string) {
|
||||||
|
if (!this.workspaceGitService) {
|
||||||
|
return {
|
||||||
|
projectKind: "directory" as const,
|
||||||
|
projectDisplayName: directoryName,
|
||||||
|
workspaceDisplayName: directoryName,
|
||||||
|
gitRemote: null,
|
||||||
|
isWorktree: false,
|
||||||
|
projectSlug: "untitled",
|
||||||
|
repoRoot: null,
|
||||||
|
currentBranch: null,
|
||||||
|
remoteUrl: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return this.workspaceGitService.getWorkspaceGitMetadata(cwd, { directoryName });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ function createNoopWorkspaceGitService(): WorkspaceGitService {
|
|||||||
refreshedAt: null,
|
refreshedAt: null,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
resolveRepoRemoteUrl: async () => null,
|
||||||
refresh: async () => {},
|
refresh: async () => {},
|
||||||
requestWorkingTreeWatch: async () => ({
|
requestWorkingTreeWatch: async () => ({
|
||||||
repoRoot: null,
|
repoRoot: null,
|
||||||
|
|||||||
@@ -11,16 +11,20 @@ import {
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { describe, expect, test, afterEach } from "vitest";
|
import { describe, expect, test, afterEach, vi } from "vitest";
|
||||||
|
|
||||||
import type { GitHubService } from "../services/github-service.js";
|
import type { GitHubService } from "../services/github-service.js";
|
||||||
import { UnknownBranchError } from "../utils/worktree.js";
|
import { UnknownBranchError } from "../utils/worktree.js";
|
||||||
import { createWorktreeCore as createCoreWorktree } from "./worktree-core.js";
|
import {
|
||||||
|
createWorktreeCore as createCoreWorktree,
|
||||||
|
resolveWorktreeRepoRoot,
|
||||||
|
} from "./worktree-core.js";
|
||||||
|
|
||||||
function createGitHubServiceStub(): GitHubService {
|
function createGitHubServiceStub(): GitHubService {
|
||||||
return {
|
return {
|
||||||
listPullRequests: async () => [],
|
listPullRequests: async () => [],
|
||||||
listIssues: async () => [],
|
listIssues: async () => [],
|
||||||
|
searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }),
|
||||||
getPullRequest: async ({ number }) => ({
|
getPullRequest: async ({ number }) => ({
|
||||||
number,
|
number,
|
||||||
title: `PR ${number}`,
|
title: `PR ${number}`,
|
||||||
@@ -48,7 +52,10 @@ function createCoreDeps(options?: {
|
|||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
github: options?.github ?? createGitHubServiceStub(),
|
github: options?.github ?? createGitHubServiceStub(),
|
||||||
resolveRepositoryDefaultBranch: async () => "main",
|
workspaceGitService: {
|
||||||
|
resolveRepoRoot: async (cwd: string) => cwd,
|
||||||
|
},
|
||||||
|
resolveDefaultBranch: async () => "main",
|
||||||
generateBranchName: options?.generateBranchName ?? ((seed) => seed ?? "generated-worktree"),
|
generateBranchName: options?.generateBranchName ?? ((seed) => seed ?? "generated-worktree"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -482,3 +489,23 @@ describe.skipIf(process.platform === "win32")("createWorktreeCore", () => {
|
|||||||
expect(findDirectCreateWorktreeCallSites(serverSrc)).toEqual(["server/worktree-core.ts"]);
|
expect(findDirectCreateWorktreeCallSites(serverSrc)).toEqual(["server/worktree-core.ts"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveWorktreeRepoRoot", () => {
|
||||||
|
test("resolves repository roots through the workspace git service", async () => {
|
||||||
|
const workspaceGitService = {
|
||||||
|
resolveRepoRoot: vi.fn().mockResolvedValue("/tmp/main-repo"),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolveWorktreeRepoRoot(
|
||||||
|
{ cwd: "/tmp/main-repo/worktrees/feature", paseoHome: "/tmp/paseo-home" },
|
||||||
|
workspaceGitService,
|
||||||
|
),
|
||||||
|
).resolves.toBe("/tmp/main-repo");
|
||||||
|
|
||||||
|
expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledTimes(1);
|
||||||
|
expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(
|
||||||
|
"/tmp/main-repo/worktrees/feature",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
import type { GitHubService } from "../services/github-service.js";
|
import type { GitHubService } from "../services/github-service.js";
|
||||||
import { getCheckoutStatus, resolveRepositoryDefaultBranch } from "../utils/checkout-git.js";
|
|
||||||
import {
|
import {
|
||||||
createWorktree,
|
createWorktree,
|
||||||
resolveExistingWorktreeForSlug,
|
resolveExistingWorktreeForSlug,
|
||||||
@@ -14,6 +13,7 @@ import {
|
|||||||
type ResolveWorktreeCreationIntentInput,
|
type ResolveWorktreeCreationIntentInput,
|
||||||
type WorktreeCreationIntent,
|
type WorktreeCreationIntent,
|
||||||
} from "./resolve-worktree-creation-intent.js";
|
} from "./resolve-worktree-creation-intent.js";
|
||||||
|
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||||
|
|
||||||
export interface CreateWorktreeCoreInput extends ResolveWorktreeCreationIntentInput {
|
export interface CreateWorktreeCoreInput extends ResolveWorktreeCreationIntentInput {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
@@ -23,7 +23,8 @@ export interface CreateWorktreeCoreInput extends ResolveWorktreeCreationIntentIn
|
|||||||
|
|
||||||
export interface CreateWorktreeCoreDeps {
|
export interface CreateWorktreeCoreDeps {
|
||||||
github: GitHubService;
|
github: GitHubService;
|
||||||
resolveRepositoryDefaultBranch: (repoRoot: string) => Promise<string>;
|
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot" | "resolveDefaultBranch">;
|
||||||
|
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||||
generateBranchName: (seed: string | undefined) => string;
|
generateBranchName: (seed: string | undefined) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,13 +39,16 @@ export async function createWorktreeCore(
|
|||||||
input: CreateWorktreeCoreInput,
|
input: CreateWorktreeCoreInput,
|
||||||
deps: CreateWorktreeCoreDeps,
|
deps: CreateWorktreeCoreDeps,
|
||||||
): Promise<CreateWorktreeCoreResult> {
|
): Promise<CreateWorktreeCoreResult> {
|
||||||
const repoRoot = await resolveWorktreeRepoRoot(input);
|
const repoRoot = await resolveWorktreeRepoRoot(input, deps.workspaceGitService);
|
||||||
const requestedSlug = input.worktreeSlug ? slugify(input.worktreeSlug) : undefined;
|
const requestedSlug = input.worktreeSlug ? slugify(input.worktreeSlug) : undefined;
|
||||||
|
|
||||||
const intent = await resolveWorktreeCreationIntent(
|
const intent = await resolveWorktreeCreationIntent(
|
||||||
{ ...input, worktreeSlug: requestedSlug },
|
{ ...input, worktreeSlug: requestedSlug },
|
||||||
repoRoot,
|
repoRoot,
|
||||||
deps,
|
{
|
||||||
|
...deps,
|
||||||
|
resolveDefaultBranch: (root) => resolveDefaultBranch(root, deps),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
let normalizedSlug: string;
|
let normalizedSlug: string;
|
||||||
|
|
||||||
@@ -89,31 +93,32 @@ export async function createWorktreeCore(
|
|||||||
export function createWorktreeCoreDeps(github: GitHubService): CreateWorktreeCoreDeps {
|
export function createWorktreeCoreDeps(github: GitHubService): CreateWorktreeCoreDeps {
|
||||||
return {
|
return {
|
||||||
github,
|
github,
|
||||||
resolveRepositoryDefaultBranch: resolveDefaultBranch,
|
|
||||||
generateBranchName: (seed) => slugify(seed ?? uuidv4()),
|
generateBranchName: (seed) => slugify(seed ?? uuidv4()),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveDefaultBranch(repoRoot: string): Promise<string> {
|
async function resolveDefaultBranch(
|
||||||
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot);
|
repoRoot: string,
|
||||||
|
deps: CreateWorktreeCoreDeps,
|
||||||
|
): Promise<string> {
|
||||||
|
const baseBranch = deps.resolveDefaultBranch
|
||||||
|
? await deps.resolveDefaultBranch(repoRoot)
|
||||||
|
: await deps.workspaceGitService?.resolveDefaultBranch(repoRoot);
|
||||||
if (!baseBranch) {
|
if (!baseBranch) {
|
||||||
throw new Error("Unable to resolve repository default branch");
|
throw new Error("Unable to resolve repository default branch");
|
||||||
}
|
}
|
||||||
return baseBranch;
|
return baseBranch;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveWorktreeRepoRoot(input: CreateWorktreeCoreInput): Promise<string> {
|
export async function resolveWorktreeRepoRoot(
|
||||||
const checkout = await getCheckoutStatus(
|
input: Pick<CreateWorktreeCoreInput, "cwd" | "paseoHome">,
|
||||||
input.cwd,
|
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">,
|
||||||
input.paseoHome ? { paseoHome: input.paseoHome } : undefined,
|
): Promise<string> {
|
||||||
);
|
if (!workspaceGitService) {
|
||||||
if (!checkout.isGit) {
|
throw new Error("Create worktree requires WorkspaceGitService");
|
||||||
throw new Error("Create worktree requires a git repository");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return checkout.isPaseoOwnedWorktree
|
return workspaceGitService.resolveRepoRoot(input.cwd);
|
||||||
? (checkout.mainRepoRoot ?? checkout.repoRoot ?? input.cwd)
|
|
||||||
: (checkout.repoRoot ?? input.cwd);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateWorktreeSlug(slug: string): string {
|
function validateWorktreeSlug(slug: string): string {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
archivePaseoWorktree,
|
archivePaseoWorktree,
|
||||||
buildAgentSessionConfig,
|
buildAgentSessionConfig,
|
||||||
handlePaseoWorktreeArchiveRequest,
|
handlePaseoWorktreeArchiveRequest,
|
||||||
|
handlePaseoWorktreeListRequest,
|
||||||
|
resolveGitCreateBaseBranch,
|
||||||
runWorktreeSetupInBackground,
|
runWorktreeSetupInBackground,
|
||||||
handleCreatePaseoWorktreeRequest,
|
handleCreatePaseoWorktreeRequest,
|
||||||
handleWorkspaceSetupStatusRequest,
|
handleWorkspaceSetupStatusRequest,
|
||||||
@@ -81,6 +83,7 @@ function createGitHubServiceStub(): GitHubService {
|
|||||||
return {
|
return {
|
||||||
listPullRequests: async () => [],
|
listPullRequests: async () => [],
|
||||||
listIssues: async () => [],
|
listIssues: async () => [],
|
||||||
|
searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }),
|
||||||
getPullRequest: async ({ number }) => ({
|
getPullRequest: async ({ number }) => ({
|
||||||
number,
|
number,
|
||||||
title: `PR ${number}`,
|
title: `PR ${number}`,
|
||||||
@@ -213,8 +216,8 @@ function createPaseoWorktreeForTest(options: {
|
|||||||
const coreDeps = createWorktreeCoreDeps(createGitHubServiceStub());
|
const coreDeps = createWorktreeCoreDeps(createGitHubServiceStub());
|
||||||
return createPaseoWorktreeService(input, {
|
return createPaseoWorktreeService(input, {
|
||||||
...coreDeps,
|
...coreDeps,
|
||||||
...(serviceOptions?.resolveRepositoryDefaultBranch
|
...(serviceOptions?.resolveDefaultBranch
|
||||||
? { resolveRepositoryDefaultBranch: serviceOptions.resolveRepositoryDefaultBranch }
|
? { resolveDefaultBranch: serviceOptions.resolveDefaultBranch }
|
||||||
: {}),
|
: {}),
|
||||||
projectRegistry: {
|
projectRegistry: {
|
||||||
get: async (projectId) => projects.get(projectId) ?? null,
|
get: async (projectId) => projects.get(projectId) ?? null,
|
||||||
@@ -278,6 +281,77 @@ function createManagedAgentForArchive(input: { id: string; cwd: string }): Manag
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe("handlePaseoWorktreeListRequest", () => {
|
||||||
|
test("lists worktrees through the workspace git service", async () => {
|
||||||
|
const emitted: SessionOutboundMessage[] = [];
|
||||||
|
const workspaceGitService = {
|
||||||
|
listWorktrees: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
path: "/tmp/paseo-home/worktrees/repo/feature",
|
||||||
|
createdAt: "2026-04-12T00:00:00.000Z",
|
||||||
|
branchName: "feature",
|
||||||
|
head: "abc123",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
await handlePaseoWorktreeListRequest(
|
||||||
|
{
|
||||||
|
emit: (message) => emitted.push(message),
|
||||||
|
paseoHome: "/tmp/paseo-home",
|
||||||
|
workspaceGitService: workspaceGitService as any,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paseo_worktree_list_request",
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
requestId: "request-worktrees",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(workspaceGitService.listWorktrees).toHaveBeenCalledTimes(1);
|
||||||
|
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith("/tmp/repo");
|
||||||
|
expect(emitted).toContainEqual({
|
||||||
|
type: "paseo_worktree_list_response",
|
||||||
|
payload: {
|
||||||
|
worktrees: [
|
||||||
|
{
|
||||||
|
worktreePath: "/tmp/paseo-home/worktrees/repo/feature",
|
||||||
|
createdAt: "2026-04-12T00:00:00.000Z",
|
||||||
|
branchName: "feature",
|
||||||
|
head: "abc123",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
error: null,
|
||||||
|
requestId: "request-worktrees",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveGitCreateBaseBranch", () => {
|
||||||
|
test("resolves the default branch through the workspace git service", async () => {
|
||||||
|
const { tempDir, repoDir } = createGitRepo();
|
||||||
|
const cwd = path.join(repoDir, "packages", "app");
|
||||||
|
const workspaceGitService = {
|
||||||
|
resolveDefaultBranch: vi.fn().mockResolvedValue("main"),
|
||||||
|
getSnapshot: vi.fn(async () => {
|
||||||
|
throw new Error("getSnapshot should not be used for default-branch resolution");
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(resolveGitCreateBaseBranch(cwd, workspaceGitService as any)).resolves.toBe(
|
||||||
|
"main",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(workspaceGitService.resolveDefaultBranch).toHaveBeenCalledWith(cwd);
|
||||||
|
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function createAgentStorageStub(): Pick<AgentStorage, "list" | "remove"> {
|
function createAgentStorageStub(): Pick<AgentStorage, "list" | "remove"> {
|
||||||
return {
|
return {
|
||||||
list: async (): Promise<StoredAgentRecord[]> => [],
|
list: async (): Promise<StoredAgentRecord[]> => [],
|
||||||
@@ -1082,6 +1156,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
{
|
{
|
||||||
paseoHome,
|
paseoHome,
|
||||||
sessionLogger: createLogger(),
|
sessionLogger: createLogger(),
|
||||||
|
workspaceGitService: {
|
||||||
|
resolveRepoRoot: vi.fn(async () => repoDir),
|
||||||
|
resolveDefaultBranch: vi.fn(async () => "main"),
|
||||||
|
} as any,
|
||||||
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
|
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
|
||||||
checkoutExistingBranch: async () => {
|
checkoutExistingBranch: async () => {
|
||||||
throw new Error("should not checkout existing branch");
|
throw new Error("should not checkout existing branch");
|
||||||
@@ -1166,7 +1244,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
const { tempDir, repoDir } = createGitRepo();
|
const { tempDir, repoDir } = createGitRepo();
|
||||||
cleanupPaths.push(tempDir);
|
cleanupPaths.push(tempDir);
|
||||||
const paseoHome = path.join(tempDir, ".paseo");
|
const paseoHome = path.join(tempDir, ".paseo");
|
||||||
const resolveRepositoryDefaultBranch = vi.fn(async () => "main");
|
const resolveDefaultBranch = vi.fn(async () => "main");
|
||||||
|
|
||||||
const result = await createPaseoWorktreeForTest({ paseoHome })(
|
const result = await createPaseoWorktreeForTest({ paseoHome })(
|
||||||
{
|
{
|
||||||
@@ -1176,7 +1254,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
runSetup: false,
|
runSetup: false,
|
||||||
paseoHome,
|
paseoHome,
|
||||||
},
|
},
|
||||||
{ resolveRepositoryDefaultBranch },
|
{ resolveDefaultBranch },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.intent).toMatchObject({
|
expect(result.intent).toMatchObject({
|
||||||
@@ -1184,7 +1262,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
baseBranch: "main",
|
baseBranch: "main",
|
||||||
newBranchName: "resolver-feature",
|
newBranchName: "resolver-feature",
|
||||||
});
|
});
|
||||||
expect(resolveRepositoryDefaultBranch).toHaveBeenCalledWith(repoDir);
|
expect(resolveDefaultBranch).toHaveBeenCalledWith(repoDir);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1509,6 +1587,53 @@ describe("archivePaseoWorktree", () => {
|
|||||||
expect(existsSync(created.worktreePath)).toBe(false);
|
expect(existsSync(created.worktreePath)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("forces a workspace git snapshot refresh after archive deletes a worktree", async () => {
|
||||||
|
const { tempDir, repoDir } = createGitRepo();
|
||||||
|
cleanupPaths.push(tempDir);
|
||||||
|
|
||||||
|
const paseoHome = path.join(tempDir, ".paseo");
|
||||||
|
const created = await createLegacyWorktreeForTest({
|
||||||
|
branchName: "archive-refresh",
|
||||||
|
cwd: repoDir,
|
||||||
|
baseBranch: "main",
|
||||||
|
worktreeSlug: "archive-refresh",
|
||||||
|
runSetup: false,
|
||||||
|
paseoHome,
|
||||||
|
});
|
||||||
|
const workspaceGitService = {
|
||||||
|
getSnapshot: vi.fn(async () => null),
|
||||||
|
};
|
||||||
|
|
||||||
|
await archivePaseoWorktree(
|
||||||
|
{
|
||||||
|
paseoHome,
|
||||||
|
github: createGitHubServiceStub(),
|
||||||
|
workspaceGitService: workspaceGitService as any,
|
||||||
|
agentManager: {
|
||||||
|
listAgents: () => [],
|
||||||
|
closeAgent: vi.fn(async () => {}),
|
||||||
|
},
|
||||||
|
agentStorage: createAgentStorageStub(),
|
||||||
|
archiveWorkspaceRecord: vi.fn(async () => {}),
|
||||||
|
emit: vi.fn(),
|
||||||
|
emitWorkspaceUpdatesForCwds: vi.fn(async () => {}),
|
||||||
|
isPathWithinRoot: createIsPathWithinRoot(),
|
||||||
|
killTerminalsUnderPath: vi.fn(async () => {}),
|
||||||
|
sessionLogger: createLogger(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetPath: created.worktreePath,
|
||||||
|
repoRoot: repoDir,
|
||||||
|
requestId: "req-archive-refresh",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, {
|
||||||
|
force: true,
|
||||||
|
reason: "archive-worktree",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("succeeds when git has forgotten about the worktree (no repoRoot)", async () => {
|
test("succeeds when git has forgotten about the worktree (no repoRoot)", async () => {
|
||||||
const { tempDir, repoDir } = createGitRepo();
|
const { tempDir, repoDir } = createGitRepo();
|
||||||
cleanupPaths.push(tempDir);
|
cleanupPaths.push(tempDir);
|
||||||
|
|||||||
@@ -26,14 +26,12 @@ import type { TerminalManager } from "../terminal/terminal-manager.js";
|
|||||||
import type { ScriptRouteStore } from "./script-proxy.js";
|
import type { ScriptRouteStore } from "./script-proxy.js";
|
||||||
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||||
import type { GitHubService } from "../services/github-service.js";
|
import type { GitHubService } from "../services/github-service.js";
|
||||||
import { getCheckoutStatus, resolveRepositoryDefaultBranch } from "../utils/checkout-git.js";
|
|
||||||
import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js";
|
import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js";
|
||||||
import { expandTilde } from "../utils/path.js";
|
import { expandTilde } from "../utils/path.js";
|
||||||
import {
|
import {
|
||||||
deletePaseoWorktree,
|
deletePaseoWorktree,
|
||||||
getWorktreeSetupCommands,
|
getWorktreeSetupCommands,
|
||||||
isPaseoOwnedWorktreeCwd,
|
isPaseoOwnedWorktreeCwd,
|
||||||
listPaseoWorktrees,
|
|
||||||
resolvePaseoWorktreeRootForCwd,
|
resolvePaseoWorktreeRootForCwd,
|
||||||
resolveWorktreeRuntimeEnv,
|
resolveWorktreeRuntimeEnv,
|
||||||
runWorktreeSetupCommands,
|
runWorktreeSetupCommands,
|
||||||
@@ -73,7 +71,7 @@ type BuildAgentSessionConfigDependencies = {
|
|||||||
createPaseoWorktree: (
|
createPaseoWorktree: (
|
||||||
input: CreatePaseoWorktreeInput,
|
input: CreatePaseoWorktreeInput,
|
||||||
options?: {
|
options?: {
|
||||||
resolveRepositoryDefaultBranch?: (repoRoot: string) => Promise<string>;
|
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||||
},
|
},
|
||||||
) => Promise<CreatePaseoWorktreeResult>;
|
) => Promise<CreatePaseoWorktreeResult>;
|
||||||
checkoutExistingBranch: (cwd: string, branch: string) => Promise<CheckoutExistingBranchResult>;
|
checkoutExistingBranch: (cwd: string, branch: string) => Promise<CheckoutExistingBranchResult>;
|
||||||
@@ -88,6 +86,7 @@ type BuildAgentSessionConfigDependencies = {
|
|||||||
type ArchivePaseoWorktreeDependencies = {
|
type ArchivePaseoWorktreeDependencies = {
|
||||||
paseoHome?: string;
|
paseoHome?: string;
|
||||||
github: GitHubService;
|
github: GitHubService;
|
||||||
|
workspaceGitService: WorkspaceGitService;
|
||||||
agentManager: Pick<AgentManager, "listAgents" | "closeAgent">;
|
agentManager: Pick<AgentManager, "listAgents" | "closeAgent">;
|
||||||
agentStorage: Pick<AgentStorage, "list" | "remove">;
|
agentStorage: Pick<AgentStorage, "list" | "remove">;
|
||||||
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
|
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
|
||||||
@@ -186,7 +185,7 @@ export async function buildAgentSessionConfig(
|
|||||||
paseoHome: dependencies.paseoHome,
|
paseoHome: dependencies.paseoHome,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
resolveRepositoryDefaultBranch: normalized.baseBranch
|
resolveDefaultBranch: normalized.baseBranch
|
||||||
? async () => normalized.baseBranch!
|
? async () => normalized.baseBranch!
|
||||||
: (repoRoot) =>
|
: (repoRoot) =>
|
||||||
resolveGitCreateBaseBranch(
|
resolveGitCreateBaseBranch(
|
||||||
@@ -311,37 +310,21 @@ export function assertSafeGitRef(ref: string, label: string): void {
|
|||||||
export async function resolveGitCreateBaseBranch(
|
export async function resolveGitCreateBaseBranch(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
workspaceGitService?: WorkspaceGitService,
|
workspaceGitService?: WorkspaceGitService,
|
||||||
paseoHome?: string,
|
_paseoHome?: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
let repoRoot = cwd;
|
if (!workspaceGitService) {
|
||||||
if (workspaceGitService) {
|
throw new Error("WorkspaceGitService is required to resolve the repository root");
|
||||||
const snapshot = await workspaceGitService.getSnapshot(cwd);
|
|
||||||
if (!snapshot.git.isGit) {
|
|
||||||
throw new Error("Cannot create a worktree outside a git repository");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
repoRoot = snapshot.git.isPaseoOwnedWorktree
|
return workspaceGitService.resolveDefaultBranch(cwd);
|
||||||
? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? cwd)
|
|
||||||
: (snapshot.git.repoRoot ?? cwd);
|
|
||||||
} else {
|
|
||||||
const checkout = await getCheckoutStatus(cwd, paseoHome ? { paseoHome } : undefined);
|
|
||||||
if (!checkout.isGit) {
|
|
||||||
throw new Error("Cannot create a worktree outside a git repository");
|
|
||||||
}
|
|
||||||
|
|
||||||
repoRoot = checkout.isPaseoOwnedWorktree
|
|
||||||
? (checkout.mainRepoRoot ?? checkout.repoRoot ?? cwd)
|
|
||||||
: (checkout.repoRoot ?? cwd);
|
|
||||||
}
|
|
||||||
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot);
|
|
||||||
if (!baseBranch) {
|
|
||||||
throw new Error("Unable to resolve repository default branch");
|
|
||||||
}
|
|
||||||
return baseBranch;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handlePaseoWorktreeListRequest(
|
export async function handlePaseoWorktreeListRequest(
|
||||||
dependencies: { emit: EmitSessionMessage; paseoHome?: string },
|
dependencies: {
|
||||||
|
emit: EmitSessionMessage;
|
||||||
|
paseoHome?: string;
|
||||||
|
workspaceGitService: WorkspaceGitService;
|
||||||
|
},
|
||||||
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_list_request" }>,
|
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_list_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { requestId } = msg;
|
const { requestId } = msg;
|
||||||
@@ -359,7 +342,7 @@ export async function handlePaseoWorktreeListRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const worktrees = await listPaseoWorktrees({ cwd, paseoHome: dependencies.paseoHome });
|
const worktrees = await dependencies.workspaceGitService.listWorktrees(cwd);
|
||||||
dependencies.emit({
|
dependencies.emit({
|
||||||
type: "paseo_worktree_list_response",
|
type: "paseo_worktree_list_response",
|
||||||
payload: {
|
payload: {
|
||||||
@@ -483,6 +466,20 @@ export async function archivePaseoWorktree(
|
|||||||
paseoHome: dependencies.paseoHome,
|
paseoHome: dependencies.paseoHome,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (options.repoRoot) {
|
||||||
|
try {
|
||||||
|
await dependencies.workspaceGitService.getSnapshot(options.repoRoot, {
|
||||||
|
force: true,
|
||||||
|
reason: "archive-worktree",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
dependencies.sessionLogger?.warn(
|
||||||
|
{ err: error, cwd: options.repoRoot },
|
||||||
|
"Failed to force-refresh workspace git snapshot after archiving worktree",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const cwd of affectedWorkspaceCwds) {
|
for (const cwd of affectedWorkspaceCwds) {
|
||||||
dependencies.github.invalidate({ cwd });
|
dependencies.github.invalidate({ cwd });
|
||||||
}
|
}
|
||||||
@@ -529,10 +526,7 @@ export async function handlePaseoWorktreeArchiveRequest(
|
|||||||
if (!repoRoot || !msg.branchName) {
|
if (!repoRoot || !msg.branchName) {
|
||||||
throw new Error("worktreePath or repoRoot+branchName is required");
|
throw new Error("worktreePath or repoRoot+branchName is required");
|
||||||
}
|
}
|
||||||
const worktrees = await listPaseoWorktrees({
|
const worktrees = await dependencies.workspaceGitService.listWorktrees(repoRoot);
|
||||||
cwd: repoRoot,
|
|
||||||
paseoHome: dependencies.paseoHome,
|
|
||||||
});
|
|
||||||
const match = worktrees.find((entry) => entry.branchName === msg.branchName);
|
const match = worktrees.find((entry) => entry.branchName === msg.branchName);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new Error(`Paseo worktree not found for branch ${msg.branchName}`);
|
throw new Error(`Paseo worktree not found for branch ${msg.branchName}`);
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
GitHubAuthenticationError,
|
GitHubAuthenticationError,
|
||||||
GitHubCliMissingError,
|
GitHubCliMissingError,
|
||||||
GitHubCommandError,
|
GitHubCommandError,
|
||||||
|
computeGithubNextInterval,
|
||||||
createGitHubService,
|
createGitHubService,
|
||||||
|
resolveGitHubRepo,
|
||||||
type GitHubCommandRunner,
|
type GitHubCommandRunner,
|
||||||
type GitHubCommandRunnerOptions,
|
type GitHubCommandRunnerOptions,
|
||||||
|
type GitHubCurrentPullRequestStatus,
|
||||||
|
type GitHubReadOptions,
|
||||||
} from "./github-service.js";
|
} from "./github-service.js";
|
||||||
import { CheckoutPrStatusResponseSchema } from "../shared/messages.js";
|
import { CheckoutPrStatusResponseSchema } from "../shared/messages.js";
|
||||||
|
|
||||||
|
const EXPECTED_GITHUB_FAST_POLL_MS = 20_000;
|
||||||
|
const EXPECTED_GITHUB_SLOW_POLL_MS = 120_000;
|
||||||
|
const EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS = 300_000;
|
||||||
|
|
||||||
interface RunnerCall {
|
interface RunnerCall {
|
||||||
args: string[];
|
args: string[];
|
||||||
cwd: string;
|
cwd: string;
|
||||||
@@ -99,6 +107,37 @@ function currentPullRequestJson(overrides: Record<string, unknown> = {}): string
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createCurrentPullRequestStatus(
|
||||||
|
overrides: Partial<GitHubCurrentPullRequestStatus> = {},
|
||||||
|
): GitHubCurrentPullRequestStatus {
|
||||||
|
return {
|
||||||
|
number: 42,
|
||||||
|
repoOwner: "acme",
|
||||||
|
repoName: "repo",
|
||||||
|
url: "https://github.com/acme/repo/pull/42",
|
||||||
|
title: "Update feature",
|
||||||
|
state: "open",
|
||||||
|
baseRefName: "main",
|
||||||
|
headRefName: "feature",
|
||||||
|
isMerged: false,
|
||||||
|
isDraft: false,
|
||||||
|
checks: [],
|
||||||
|
checksStatus: "none",
|
||||||
|
reviewDecision: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordCurrentPullRequestStatusReads(service: ReturnType<typeof createGitHubService>) {
|
||||||
|
const reads: GitHubReadOptions[] = [];
|
||||||
|
const getCurrentPullRequestStatus = service.getCurrentPullRequestStatus.bind(service);
|
||||||
|
service.getCurrentPullRequestStatus = vi.fn(async (options) => {
|
||||||
|
reads.push(options);
|
||||||
|
return getCurrentPullRequestStatus(options);
|
||||||
|
});
|
||||||
|
return reads;
|
||||||
|
}
|
||||||
|
|
||||||
function noPullRequestError(args: string[] = ["pr", "view"]): GitHubCommandError {
|
function noPullRequestError(args: string[] = ["pr", "view"]): GitHubCommandError {
|
||||||
return new GitHubCommandError({
|
return new GitHubCommandError({
|
||||||
args,
|
args,
|
||||||
@@ -122,6 +161,36 @@ function pullRequestJson(title: string): string {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function issueJson(title: string): string {
|
||||||
|
return JSON.stringify([
|
||||||
|
{
|
||||||
|
number: 55,
|
||||||
|
title,
|
||||||
|
url: "https://github.com/acme/repo/issues/55",
|
||||||
|
state: "OPEN",
|
||||||
|
body: "issue body",
|
||||||
|
labels: [{ name: "bug" }],
|
||||||
|
updatedAt: "2026-04-18T12:00:00Z",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchPullRequestJson(title: string): string {
|
||||||
|
return JSON.stringify([
|
||||||
|
{
|
||||||
|
number: 123,
|
||||||
|
title,
|
||||||
|
url: "https://github.com/acme/repo/pull/123",
|
||||||
|
state: "OPEN",
|
||||||
|
body: "pr body",
|
||||||
|
baseRefName: "main",
|
||||||
|
headRefName: "feature",
|
||||||
|
labels: [{ name: "enhancement" }],
|
||||||
|
updatedAt: "2026-04-18T13:00:00Z",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
function pullRequestTimelineJson(overrides: Record<string, unknown> = {}): string {
|
function pullRequestTimelineJson(overrides: Record<string, unknown> = {}): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
data: {
|
data: {
|
||||||
@@ -175,6 +244,212 @@ function pullRequestTimelineJson(overrides: Record<string, unknown> = {}): strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("GitHubService", () => {
|
describe("GitHubService", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes fast cadence for pending and slow cadence for stable PR states", () => {
|
||||||
|
const pendingStatus = createCurrentPullRequestStatus({ checksStatus: "pending" });
|
||||||
|
const runningCheckStatus = createCurrentPullRequestStatus({
|
||||||
|
checksStatus: "success",
|
||||||
|
checks: [{ name: "ci", status: "pending", url: null }],
|
||||||
|
});
|
||||||
|
const stableStatus = createCurrentPullRequestStatus({ checksStatus: "success" });
|
||||||
|
|
||||||
|
expect(computeGithubNextInterval(pendingStatus, 0)).toBe(EXPECTED_GITHUB_FAST_POLL_MS);
|
||||||
|
expect(computeGithubNextInterval(runningCheckStatus, 0)).toBe(EXPECTED_GITHUB_FAST_POLL_MS);
|
||||||
|
expect(computeGithubNextInterval(stableStatus, 0)).toBe(EXPECTED_GITHUB_SLOW_POLL_MS);
|
||||||
|
expect(computeGithubNextInterval(null, 0)).toBe(EXPECTED_GITHUB_SLOW_POLL_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes exponential error backoff up to the cap", () => {
|
||||||
|
const stableStatus = createCurrentPullRequestStatus({ checksStatus: "success" });
|
||||||
|
|
||||||
|
expect(computeGithubNextInterval(stableStatus, 1)).toBe(EXPECTED_GITHUB_SLOW_POLL_MS);
|
||||||
|
expect(computeGithubNextInterval(stableStatus, 2)).toBe(240_000);
|
||||||
|
expect(computeGithubNextInterval(stableStatus, 3)).toBe(EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS);
|
||||||
|
expect(computeGithubNextInterval(stableStatus, 4)).toBe(EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("polls PR status at fast cadence while checks are pending", async () => {
|
||||||
|
let now = 0;
|
||||||
|
const runner = createRunner([
|
||||||
|
currentPullRequestJson({
|
||||||
|
statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "PENDING" }],
|
||||||
|
}),
|
||||||
|
currentPullRequestJson({
|
||||||
|
statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "PENDING" }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const service = createGitHubService({
|
||||||
|
ttlMs: 0,
|
||||||
|
runner: runner.runner,
|
||||||
|
resolveGhPath: async () => "/usr/bin/gh",
|
||||||
|
now: () => now,
|
||||||
|
});
|
||||||
|
const reads = recordCurrentPullRequestStatusReads(service);
|
||||||
|
|
||||||
|
const subscription = service.retainCurrentPullRequestStatusPoll?.({
|
||||||
|
cwd: "/repo",
|
||||||
|
headRef: "feature/fork",
|
||||||
|
});
|
||||||
|
await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" });
|
||||||
|
|
||||||
|
now = EXPECTED_GITHUB_FAST_POLL_MS;
|
||||||
|
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS);
|
||||||
|
|
||||||
|
expect(runner.calls).toHaveLength(2);
|
||||||
|
expect(reads.map((read) => read.reason)).toEqual([undefined, "self-heal-github"]);
|
||||||
|
|
||||||
|
subscription?.unsubscribe();
|
||||||
|
service.dispose?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("polls PR status at slow cadence after stable checks", async () => {
|
||||||
|
let now = 0;
|
||||||
|
const runner = createRunner([
|
||||||
|
currentPullRequestJson({
|
||||||
|
statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "SUCCESS" }],
|
||||||
|
}),
|
||||||
|
currentPullRequestJson({
|
||||||
|
statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "SUCCESS" }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const service = createGitHubService({
|
||||||
|
ttlMs: 0,
|
||||||
|
runner: runner.runner,
|
||||||
|
resolveGhPath: async () => "/usr/bin/gh",
|
||||||
|
now: () => now,
|
||||||
|
});
|
||||||
|
const reads = recordCurrentPullRequestStatusReads(service);
|
||||||
|
|
||||||
|
const subscription = service.retainCurrentPullRequestStatusPoll?.({
|
||||||
|
cwd: "/repo",
|
||||||
|
headRef: "feature/fork",
|
||||||
|
});
|
||||||
|
await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" });
|
||||||
|
|
||||||
|
now = EXPECTED_GITHUB_FAST_POLL_MS;
|
||||||
|
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS);
|
||||||
|
expect(runner.calls).toHaveLength(1);
|
||||||
|
|
||||||
|
now = EXPECTED_GITHUB_SLOW_POLL_MS;
|
||||||
|
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_SLOW_POLL_MS - EXPECTED_GITHUB_FAST_POLL_MS);
|
||||||
|
expect(runner.calls).toHaveLength(2);
|
||||||
|
expect(reads.map((read) => read.reason)).toEqual([undefined, "self-heal-github"]);
|
||||||
|
|
||||||
|
subscription?.unsubscribe();
|
||||||
|
service.dispose?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("backs off consecutive poll errors and resets cadence after recovery", async () => {
|
||||||
|
let now = 0;
|
||||||
|
const runner = createScriptedRunner([
|
||||||
|
currentPullRequestJson({
|
||||||
|
statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "PENDING" }],
|
||||||
|
}),
|
||||||
|
{ error: new Error("network down") },
|
||||||
|
{ error: new Error("network still down") },
|
||||||
|
currentPullRequestJson({
|
||||||
|
statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "SUCCESS" }],
|
||||||
|
}),
|
||||||
|
currentPullRequestJson({
|
||||||
|
statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "SUCCESS" }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const service = createGitHubService({
|
||||||
|
ttlMs: 0,
|
||||||
|
runner: runner.runner,
|
||||||
|
resolveGhPath: async () => "/usr/bin/gh",
|
||||||
|
now: () => now,
|
||||||
|
});
|
||||||
|
const reads = recordCurrentPullRequestStatusReads(service);
|
||||||
|
|
||||||
|
const subscription = service.retainCurrentPullRequestStatusPoll?.({
|
||||||
|
cwd: "/repo",
|
||||||
|
headRef: "feature/fork",
|
||||||
|
});
|
||||||
|
await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" });
|
||||||
|
|
||||||
|
now = EXPECTED_GITHUB_FAST_POLL_MS;
|
||||||
|
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS);
|
||||||
|
now += EXPECTED_GITHUB_FAST_POLL_MS;
|
||||||
|
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS);
|
||||||
|
now += EXPECTED_GITHUB_FAST_POLL_MS * 2;
|
||||||
|
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS * 2);
|
||||||
|
now += EXPECTED_GITHUB_SLOW_POLL_MS;
|
||||||
|
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_SLOW_POLL_MS);
|
||||||
|
|
||||||
|
expect(runner.calls).toHaveLength(5);
|
||||||
|
expect(reads.map((read) => read.reason)).toEqual([
|
||||||
|
undefined,
|
||||||
|
"self-heal-github",
|
||||||
|
"self-heal-github",
|
||||||
|
"self-heal-github",
|
||||||
|
"self-heal-github",
|
||||||
|
]);
|
||||||
|
|
||||||
|
subscription?.unsubscribe();
|
||||||
|
service.dispose?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unsubscribe clears the adaptive GitHub poll timer", async () => {
|
||||||
|
let now = 0;
|
||||||
|
const runner = createRunner([
|
||||||
|
currentPullRequestJson({
|
||||||
|
statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "PENDING" }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const service = createGitHubService({
|
||||||
|
ttlMs: 0,
|
||||||
|
runner: runner.runner,
|
||||||
|
resolveGhPath: async () => "/usr/bin/gh",
|
||||||
|
now: () => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
const subscription = service.retainCurrentPullRequestStatusPoll?.({
|
||||||
|
cwd: "/repo",
|
||||||
|
headRef: "feature/fork",
|
||||||
|
});
|
||||||
|
await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" });
|
||||||
|
subscription?.unsubscribe();
|
||||||
|
|
||||||
|
now = EXPECTED_GITHUB_FAST_POLL_MS;
|
||||||
|
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS);
|
||||||
|
|
||||||
|
expect(runner.calls).toHaveLength(1);
|
||||||
|
|
||||||
|
service.dispose?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispose clears all adaptive GitHub poll timers", async () => {
|
||||||
|
let now = 0;
|
||||||
|
const runner = createRunner([
|
||||||
|
currentPullRequestJson({
|
||||||
|
statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "PENDING" }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const service = createGitHubService({
|
||||||
|
ttlMs: 0,
|
||||||
|
runner: runner.runner,
|
||||||
|
resolveGhPath: async () => "/usr/bin/gh",
|
||||||
|
now: () => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
service.retainCurrentPullRequestStatusPoll?.({ cwd: "/repo", headRef: "feature/fork" });
|
||||||
|
await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" });
|
||||||
|
service.dispose?.();
|
||||||
|
|
||||||
|
now = EXPECTED_GITHUB_FAST_POLL_MS;
|
||||||
|
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS);
|
||||||
|
|
||||||
|
expect(runner.calls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("fetches PR reviews and issue comments with one GraphQL call sorted chronologically", async () => {
|
it("fetches PR reviews and issue comments with one GraphQL call sorted chronologically", async () => {
|
||||||
const runner = createRunner([pullRequestTimelineJson()]);
|
const runner = createRunner([pullRequestTimelineJson()]);
|
||||||
const service = createGitHubService({
|
const service = createGitHubService({
|
||||||
@@ -1216,4 +1491,230 @@ describe("GitHubService", () => {
|
|||||||
stderr: "GraphQL: unavailable",
|
stderr: "GraphQL: unavailable",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("searches GitHub issues and PRs", async () => {
|
||||||
|
const runner = createRunner([issueJson("Issue title"), searchPullRequestJson("PR title")]);
|
||||||
|
const service = createGitHubService({
|
||||||
|
runner: runner.runner,
|
||||||
|
resolveGhPath: async () => "/usr/bin/gh",
|
||||||
|
now: () => 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.searchIssuesAndPrs({ cwd: "/repo", query: "cache", limit: 5 }),
|
||||||
|
).resolves.toEqual({
|
||||||
|
githubFeaturesEnabled: true,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
kind: "pr",
|
||||||
|
number: 123,
|
||||||
|
title: "PR title",
|
||||||
|
url: "https://github.com/acme/repo/pull/123",
|
||||||
|
state: "OPEN",
|
||||||
|
body: "pr body",
|
||||||
|
labels: ["enhancement"],
|
||||||
|
baseRefName: "main",
|
||||||
|
headRefName: "feature",
|
||||||
|
updatedAt: "2026-04-18T13:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "issue",
|
||||||
|
number: 55,
|
||||||
|
title: "Issue title",
|
||||||
|
url: "https://github.com/acme/repo/issues/55",
|
||||||
|
state: "OPEN",
|
||||||
|
body: "issue body",
|
||||||
|
labels: ["bug"],
|
||||||
|
baseRefName: null,
|
||||||
|
headRefName: null,
|
||||||
|
updatedAt: "2026-04-18T12:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runner.calls).toEqual([
|
||||||
|
{
|
||||||
|
cwd: "/repo",
|
||||||
|
args: [
|
||||||
|
"issue",
|
||||||
|
"list",
|
||||||
|
"--search",
|
||||||
|
"cache",
|
||||||
|
"--json",
|
||||||
|
"number,title,url,state,body,labels,updatedAt",
|
||||||
|
"--limit",
|
||||||
|
"5",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cwd: "/repo",
|
||||||
|
args: [
|
||||||
|
"pr",
|
||||||
|
"list",
|
||||||
|
"--search",
|
||||||
|
"cache",
|
||||||
|
"--json",
|
||||||
|
"number,title,url,state,body,labels,baseRefName,headRefName,updatedAt",
|
||||||
|
"--limit",
|
||||||
|
"5",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("searches only GitHub PRs when the search kinds request excludes issues", async () => {
|
||||||
|
const runner = createRunner([searchPullRequestJson("PR title")]);
|
||||||
|
const service = createGitHubService({
|
||||||
|
runner: runner.runner,
|
||||||
|
resolveGhPath: async () => "/usr/bin/gh",
|
||||||
|
now: () => 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.searchIssuesAndPrs({
|
||||||
|
cwd: "/repo",
|
||||||
|
query: "cache",
|
||||||
|
limit: 5,
|
||||||
|
kinds: ["github-pr"],
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
githubFeaturesEnabled: true,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
kind: "pr",
|
||||||
|
number: 123,
|
||||||
|
title: "PR title",
|
||||||
|
url: "https://github.com/acme/repo/pull/123",
|
||||||
|
state: "OPEN",
|
||||||
|
body: "pr body",
|
||||||
|
labels: ["enhancement"],
|
||||||
|
baseRefName: "main",
|
||||||
|
headRefName: "feature",
|
||||||
|
updatedAt: "2026-04-18T13:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runner.calls).toEqual([
|
||||||
|
{
|
||||||
|
cwd: "/repo",
|
||||||
|
args: [
|
||||||
|
"pr",
|
||||||
|
"list",
|
||||||
|
"--search",
|
||||||
|
"cache",
|
||||||
|
"--json",
|
||||||
|
"number,title,url,state,body,labels,baseRefName,headRefName,updatedAt",
|
||||||
|
"--limit",
|
||||||
|
"5",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses cached PR status without another gh call", async () => {
|
||||||
|
let now = 100;
|
||||||
|
const runner = createRunner([currentPullRequestJson()]);
|
||||||
|
const service = createGitHubService({
|
||||||
|
runner: runner.runner,
|
||||||
|
resolveGhPath: async () => "/usr/bin/gh",
|
||||||
|
now: () => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }),
|
||||||
|
).resolves.toMatchObject({ number: 42 });
|
||||||
|
|
||||||
|
now = 101;
|
||||||
|
await expect(
|
||||||
|
service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }),
|
||||||
|
).resolves.toMatchObject({ number: 42 });
|
||||||
|
|
||||||
|
expect(runner.calls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bypasses the warm PR status cache for forced reads", async () => {
|
||||||
|
const runner = createRunner([
|
||||||
|
currentPullRequestJson({ number: 41, title: "First" }),
|
||||||
|
currentPullRequestJson({ number: 42, title: "Forced" }),
|
||||||
|
]);
|
||||||
|
const service = createGitHubService({
|
||||||
|
runner: runner.runner,
|
||||||
|
resolveGhPath: async () => "/usr/bin/gh",
|
||||||
|
now: () => 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }),
|
||||||
|
).resolves.toMatchObject({ number: 41 });
|
||||||
|
await expect(
|
||||||
|
service.getCurrentPullRequestStatus({
|
||||||
|
cwd: "/repo",
|
||||||
|
headRef: "feature/fork",
|
||||||
|
force: true,
|
||||||
|
reason: "test",
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({ number: 42 });
|
||||||
|
|
||||||
|
expect(runner.calls).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("coalesces concurrent PR status callers", async () => {
|
||||||
|
const runner = createDeferredRunner();
|
||||||
|
const service = createGitHubService({
|
||||||
|
runner: runner.runner,
|
||||||
|
resolveGhPath: async () => "/usr/bin/gh",
|
||||||
|
now: () => 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" });
|
||||||
|
const second = service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" });
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(runner.calls).toHaveLength(1);
|
||||||
|
runner.resolveNext(currentPullRequestJson());
|
||||||
|
|
||||||
|
await expect(Promise.all([first, second])).resolves.toEqual([
|
||||||
|
expect.objectContaining({ number: 42 }),
|
||||||
|
expect.objectContaining({ number: 42 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a reason for forced reads at runtime", async () => {
|
||||||
|
const service = createGitHubService({
|
||||||
|
runner: async () => ({ stdout: currentPullRequestJson(), stderr: "" }),
|
||||||
|
resolveGhPath: async () => "/usr/bin/gh",
|
||||||
|
now: () => 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.getCurrentPullRequestStatus({
|
||||||
|
cwd: "/repo",
|
||||||
|
headRef: "feature/fork",
|
||||||
|
force: true,
|
||||||
|
} as never),
|
||||||
|
).rejects.toThrow("GitHubService forced read requires a reason");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("type: force true requires a reason", () => {
|
||||||
|
// @ts-expect-error force: true requires reason
|
||||||
|
const invalid: GitHubReadOptions = { force: true };
|
||||||
|
const valid: GitHubReadOptions = { force: true, reason: "test" };
|
||||||
|
|
||||||
|
expect(invalid.force).toBe(true);
|
||||||
|
expect(valid.reason).toBe("test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves GitHub repos from a WorkspaceGitService-owned remote URL", async () => {
|
||||||
|
const workspaceGitService = {
|
||||||
|
resolveRepoRemoteUrl: async (cwd: string) => {
|
||||||
|
expect(cwd).toBe("/repo");
|
||||||
|
return "git@github.com:getpaseo/paseo.git";
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(resolveGitHubRepo("/repo", { workspaceGitService })).resolves.toBe(
|
||||||
|
"getpaseo/paseo",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import type { GitHubSearchKind } from "../shared/messages.js";
|
||||||
import { findExecutable } from "../utils/executable.js";
|
import { findExecutable } from "../utils/executable.js";
|
||||||
import { runGitCommand } from "../utils/run-git-command.js";
|
|
||||||
import { execCommand } from "../utils/spawn.js";
|
import { execCommand } from "../utils/spawn.js";
|
||||||
|
|
||||||
const DEFAULT_GITHUB_CACHE_TTL_MS = 30_000;
|
const DEFAULT_GITHUB_CACHE_TTL_MS = 30_000;
|
||||||
|
export const GITHUB_POLL_FAST_INTERVAL_MS = 20_000;
|
||||||
|
export const GITHUB_POLL_SLOW_INTERVAL_MS = 120_000;
|
||||||
|
export const GITHUB_POLL_ERROR_BACKOFF_CAP_MS = 300_000;
|
||||||
const GITHUB_ENV: NodeJS.ProcessEnv = {
|
const GITHUB_ENV: NodeJS.ProcessEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
GIT_TERMINAL_PROMPT: "0",
|
GIT_TERMINAL_PROMPT: "0",
|
||||||
@@ -343,30 +346,63 @@ export interface GitHubPullRequestCreateResult {
|
|||||||
number: number;
|
number: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListGitHubPullRequestsOptions {
|
export type GitHubReadOptions =
|
||||||
|
| {
|
||||||
|
force?: false;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
force: true;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListGitHubPullRequestsOptions = {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
query?: string;
|
query?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
} & GitHubReadOptions;
|
||||||
|
|
||||||
export interface ListGitHubIssuesOptions {
|
export type ListGitHubIssuesOptions = {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
query?: string;
|
query?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
} & GitHubReadOptions;
|
||||||
|
|
||||||
export interface GetGitHubPullRequestOptions {
|
export type GetGitHubPullRequestOptions = {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
number: number;
|
number: number;
|
||||||
}
|
} & GitHubReadOptions;
|
||||||
|
|
||||||
export interface GetGitHubPullRequestTimelineOptions {
|
export type GetGitHubPullRequestTimelineOptions = {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
prNumber: number;
|
prNumber: number;
|
||||||
repoOwner: string;
|
repoOwner: string;
|
||||||
repoName: string;
|
repoName: string;
|
||||||
|
} & GitHubReadOptions;
|
||||||
|
|
||||||
|
export interface GitHubSearchResult {
|
||||||
|
items: Array<{
|
||||||
|
kind: "issue" | "pr";
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
state: string;
|
||||||
|
body: string | null;
|
||||||
|
labels: string[];
|
||||||
|
baseRefName?: string | null;
|
||||||
|
headRefName?: string | null;
|
||||||
|
updatedAt?: string;
|
||||||
|
}>;
|
||||||
|
githubFeaturesEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SearchGitHubIssuesAndPrsOptions = {
|
||||||
|
cwd: string;
|
||||||
|
query: string;
|
||||||
|
limit?: number;
|
||||||
|
kinds?: GitHubSearchKind[];
|
||||||
|
} & GitHubReadOptions;
|
||||||
|
|
||||||
export interface CreateGitHubPullRequestOptions {
|
export interface CreateGitHubPullRequestOptions {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
repo: string;
|
repo: string;
|
||||||
@@ -381,18 +417,28 @@ export interface GitHubService {
|
|||||||
listIssues(options: ListGitHubIssuesOptions): Promise<GitHubIssueSummary[]>;
|
listIssues(options: ListGitHubIssuesOptions): Promise<GitHubIssueSummary[]>;
|
||||||
getPullRequest(options: GetGitHubPullRequestOptions): Promise<GitHubPullRequestSummary>;
|
getPullRequest(options: GetGitHubPullRequestOptions): Promise<GitHubPullRequestSummary>;
|
||||||
getPullRequestHeadRef(options: GetGitHubPullRequestOptions): Promise<string>;
|
getPullRequestHeadRef(options: GetGitHubPullRequestOptions): Promise<string>;
|
||||||
getCurrentPullRequestStatus(options: {
|
getCurrentPullRequestStatus(
|
||||||
|
options: {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
headRef: string;
|
headRef: string;
|
||||||
}): Promise<GitHubCurrentPullRequestStatus | null>;
|
} & GitHubReadOptions,
|
||||||
|
): Promise<GitHubCurrentPullRequestStatus | null>;
|
||||||
getPullRequestTimeline(
|
getPullRequestTimeline(
|
||||||
options: GetGitHubPullRequestTimelineOptions,
|
options: GetGitHubPullRequestTimelineOptions,
|
||||||
): Promise<GitHubPullRequestTimeline>;
|
): Promise<GitHubPullRequestTimeline>;
|
||||||
|
searchIssuesAndPrs(options: SearchGitHubIssuesAndPrsOptions): Promise<GitHubSearchResult>;
|
||||||
createPullRequest(
|
createPullRequest(
|
||||||
options: CreateGitHubPullRequestOptions,
|
options: CreateGitHubPullRequestOptions,
|
||||||
): Promise<GitHubPullRequestCreateResult>;
|
): Promise<GitHubPullRequestCreateResult>;
|
||||||
isAuthenticated(options: { cwd: string }): Promise<boolean>;
|
isAuthenticated(options: { cwd: string } & GitHubReadOptions): Promise<boolean>;
|
||||||
|
retainCurrentPullRequestStatusPoll?(options: {
|
||||||
|
cwd: string;
|
||||||
|
headRef: string;
|
||||||
|
onStatus?: (status: GitHubCurrentPullRequestStatus | null) => void;
|
||||||
|
onError?: (error: unknown) => void;
|
||||||
|
}): { unsubscribe: () => void };
|
||||||
invalidate(options: { cwd: string }): void;
|
invalidate(options: { cwd: string }): void;
|
||||||
|
dispose?(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GitHubCliMissingError extends Error {
|
export class GitHubCliMissingError extends Error {
|
||||||
@@ -453,6 +499,18 @@ type CurrentPullRequestStatusItem = z.infer<typeof CurrentPullRequestStatusSchem
|
|||||||
interface InFlightCacheEntry {
|
interface InFlightCacheEntry {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
promise: Promise<unknown>;
|
promise: Promise<unknown>;
|
||||||
|
force: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitHubPollTarget {
|
||||||
|
cwd: string;
|
||||||
|
headRef: string;
|
||||||
|
retainCount: number;
|
||||||
|
timer: NodeJS.Timeout | null;
|
||||||
|
latestStatus: GitHubCurrentPullRequestStatus | null;
|
||||||
|
consecutiveErrors: number;
|
||||||
|
callbacks: Set<(status: GitHubCurrentPullRequestStatus | null) => void>;
|
||||||
|
errorCallbacks: Set<(error: unknown) => void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResolvedPullRequestCandidate {
|
interface ResolvedPullRequestCandidate {
|
||||||
@@ -469,13 +527,20 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
};
|
};
|
||||||
const cache = new Map<string, CacheEntry>();
|
const cache = new Map<string, CacheEntry>();
|
||||||
const inFlight = new Map<string, InFlightCacheEntry>();
|
const inFlight = new Map<string, InFlightCacheEntry>();
|
||||||
|
const pollTargets = new Map<string, GitHubPollTarget>();
|
||||||
|
let api!: GitHubService;
|
||||||
|
|
||||||
async function cached<T>(params: {
|
async function cached<T>(params: {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
method: string;
|
method: string;
|
||||||
args: unknown;
|
args: unknown;
|
||||||
|
readOptions?: GitHubReadOptions;
|
||||||
load: () => Promise<T>;
|
load: () => Promise<T>;
|
||||||
}): Promise<T> {
|
}): Promise<T> {
|
||||||
|
if (params.readOptions?.force && !params.readOptions.reason) {
|
||||||
|
throw new Error("GitHubService forced read requires a reason");
|
||||||
|
}
|
||||||
|
|
||||||
const key = buildCacheKey({
|
const key = buildCacheKey({
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
method: params.method,
|
method: params.method,
|
||||||
@@ -483,12 +548,12 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
});
|
});
|
||||||
const cachedEntry = cache.get(key);
|
const cachedEntry = cache.get(key);
|
||||||
const now = deps.now();
|
const now = deps.now();
|
||||||
if (cachedEntry && cachedEntry.expiresAt > now) {
|
if (!params.readOptions?.force && cachedEntry && cachedEntry.expiresAt > now) {
|
||||||
return cachedEntry.value as T;
|
return cachedEntry.value as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = inFlight.get(key);
|
const existing = inFlight.get(key);
|
||||||
if (existing) {
|
if (existing && (!params.readOptions?.force || existing.force)) {
|
||||||
return existing.promise as Promise<T>;
|
return existing.promise as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,7 +574,11 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
inFlight.delete(key);
|
inFlight.delete(key);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
inFlight.set(key, { cwd: params.cwd, promise: request });
|
inFlight.set(key, {
|
||||||
|
cwd: params.cwd,
|
||||||
|
promise: request,
|
||||||
|
force: params.readOptions?.force === true,
|
||||||
|
});
|
||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,12 +598,93 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
function getPollTargetKey(options: { cwd: string; headRef: string }): string {
|
||||||
|
return buildCacheKey({
|
||||||
|
cwd: options.cwd,
|
||||||
|
method: "getCurrentPullRequestStatus",
|
||||||
|
args: { headRef: options.headRef },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePollTargetAfterSuccess(options: {
|
||||||
|
cwd: string;
|
||||||
|
headRef: string;
|
||||||
|
status: GitHubCurrentPullRequestStatus | null;
|
||||||
|
notify: boolean;
|
||||||
|
}): void {
|
||||||
|
const target = pollTargets.get(getPollTargetKey(options));
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
target.latestStatus = options.status;
|
||||||
|
target.consecutiveErrors = 0;
|
||||||
|
if (options.notify) {
|
||||||
|
for (const callback of target.callbacks) {
|
||||||
|
callback(options.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scheduleGitHubPoll(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleGitHubPoll(target: GitHubPollTarget): void {
|
||||||
|
scheduleGitHubPollAfter(
|
||||||
|
target,
|
||||||
|
computeGithubNextInterval(target.latestStatus, target.consecutiveErrors),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleImmediateGitHubPoll(target: GitHubPollTarget): void {
|
||||||
|
scheduleGitHubPollAfter(target, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleGitHubPollAfter(target: GitHubPollTarget, delayMs: number): void {
|
||||||
|
if (target.retainCount <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target.timer) {
|
||||||
|
clearTimeout(target.timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
target.timer = setTimeout(() => {
|
||||||
|
target.timer = null;
|
||||||
|
void runGitHubPoll(target);
|
||||||
|
}, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runGitHubPoll(target: GitHubPollTarget): Promise<void> {
|
||||||
|
try {
|
||||||
|
await api.getCurrentPullRequestStatus({
|
||||||
|
cwd: target.cwd,
|
||||||
|
headRef: target.headRef,
|
||||||
|
reason: "self-heal-github",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
target.consecutiveErrors += 1;
|
||||||
|
for (const callback of target.errorCallbacks) {
|
||||||
|
callback(error);
|
||||||
|
}
|
||||||
|
scheduleGitHubPoll(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeGitHubPollTarget(target: GitHubPollTarget): void {
|
||||||
|
if (target.timer) {
|
||||||
|
clearTimeout(target.timer);
|
||||||
|
target.timer = null;
|
||||||
|
}
|
||||||
|
target.retainCount = 0;
|
||||||
|
target.callbacks.clear();
|
||||||
|
target.errorCallbacks.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
api = {
|
||||||
listPullRequests(options) {
|
listPullRequests(options) {
|
||||||
return cached({
|
return cached({
|
||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
method: "listPullRequests",
|
method: "listPullRequests",
|
||||||
args: { query: options.query ?? "", limit: options.limit ?? 20 },
|
args: { query: options.query ?? "", limit: options.limit ?? 20 },
|
||||||
|
readOptions: options,
|
||||||
load: async () => {
|
load: async () => {
|
||||||
const stdout = await run(
|
const stdout = await run(
|
||||||
[
|
[
|
||||||
@@ -559,6 +709,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
method: "listIssues",
|
method: "listIssues",
|
||||||
args: { query: options.query ?? "", limit: options.limit ?? 20 },
|
args: { query: options.query ?? "", limit: options.limit ?? 20 },
|
||||||
|
readOptions: options,
|
||||||
load: async () => {
|
load: async () => {
|
||||||
const stdout = await run(
|
const stdout = await run(
|
||||||
[
|
[
|
||||||
@@ -583,6 +734,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
method: "getPullRequest",
|
method: "getPullRequest",
|
||||||
args: { number: options.number },
|
args: { number: options.number },
|
||||||
|
readOptions: options,
|
||||||
load: async () => {
|
load: async () => {
|
||||||
const stdout = await run(
|
const stdout = await run(
|
||||||
[
|
[
|
||||||
@@ -609,6 +761,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
method: "getCurrentPullRequestStatus",
|
method: "getCurrentPullRequestStatus",
|
||||||
args: { headRef: options.headRef },
|
args: { headRef: options.headRef },
|
||||||
|
readOptions: options,
|
||||||
load: async () => {
|
load: async () => {
|
||||||
return resolveCurrentPullRequestView({
|
return resolveCurrentPullRequestView({
|
||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
@@ -616,6 +769,14 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
run,
|
run,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
}).then((status) => {
|
||||||
|
updatePollTargetAfterSuccess({
|
||||||
|
cwd: options.cwd,
|
||||||
|
headRef: options.headRef,
|
||||||
|
status,
|
||||||
|
notify: options.reason === "self-heal-github",
|
||||||
|
});
|
||||||
|
return status;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -624,6 +785,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
method: "getPullRequestTimeline",
|
method: "getPullRequestTimeline",
|
||||||
args: { prNumber: options.prNumber },
|
args: { prNumber: options.prNumber },
|
||||||
|
readOptions: options,
|
||||||
load: async () => {
|
load: async () => {
|
||||||
try {
|
try {
|
||||||
const stdout = await run(
|
const stdout = await run(
|
||||||
@@ -660,6 +822,96 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async searchIssuesAndPrs(options) {
|
||||||
|
if (options.force && !options.reason) {
|
||||||
|
throw new Error("GitHubService forced read requires a reason");
|
||||||
|
}
|
||||||
|
|
||||||
|
const kinds = options.kinds ?? ["github-issue", "github-pr"];
|
||||||
|
const shouldFetchIssues = kinds.includes("github-issue");
|
||||||
|
const shouldFetchPullRequests = kinds.includes("github-pr");
|
||||||
|
const readOptions: GitHubReadOptions = options.force
|
||||||
|
? { force: true, reason: options.reason }
|
||||||
|
: { force: false, reason: options.reason };
|
||||||
|
const [issuesResult, prsResult] = await Promise.allSettled([
|
||||||
|
shouldFetchIssues
|
||||||
|
? this.listIssues({
|
||||||
|
cwd: options.cwd,
|
||||||
|
query: options.query,
|
||||||
|
limit: options.limit,
|
||||||
|
...readOptions,
|
||||||
|
})
|
||||||
|
: Promise.resolve(null),
|
||||||
|
shouldFetchPullRequests
|
||||||
|
? this.listPullRequests({
|
||||||
|
cwd: options.cwd,
|
||||||
|
query: options.query,
|
||||||
|
limit: options.limit,
|
||||||
|
...readOptions,
|
||||||
|
})
|
||||||
|
: Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const items: GitHubSearchResult["items"] = [];
|
||||||
|
const requestedResults = [
|
||||||
|
shouldFetchIssues ? issuesResult : null,
|
||||||
|
shouldFetchPullRequests ? prsResult : null,
|
||||||
|
].filter((result) => result !== null);
|
||||||
|
if (
|
||||||
|
requestedResults.length > 0 &&
|
||||||
|
requestedResults.every(
|
||||||
|
(result) =>
|
||||||
|
result.status === "rejected" &&
|
||||||
|
(result.reason instanceof GitHubCliMissingError ||
|
||||||
|
result.reason instanceof GitHubAuthenticationError),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return { items: [], githubFeaturesEnabled: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldFetchIssues && issuesResult.status === "fulfilled") {
|
||||||
|
for (const item of issuesResult.value ?? []) {
|
||||||
|
items.push({
|
||||||
|
kind: "issue",
|
||||||
|
number: item.number,
|
||||||
|
title: item.title,
|
||||||
|
url: item.url,
|
||||||
|
state: item.state,
|
||||||
|
body: item.body,
|
||||||
|
labels: item.labels,
|
||||||
|
baseRefName: null,
|
||||||
|
headRefName: null,
|
||||||
|
updatedAt: item.updatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldFetchPullRequests && prsResult.status === "fulfilled") {
|
||||||
|
for (const item of prsResult.value ?? []) {
|
||||||
|
items.push({
|
||||||
|
kind: "pr",
|
||||||
|
number: item.number,
|
||||||
|
title: item.title,
|
||||||
|
url: item.url,
|
||||||
|
state: item.state,
|
||||||
|
body: item.body,
|
||||||
|
labels: item.labels,
|
||||||
|
baseRefName: item.baseRefName,
|
||||||
|
headRefName: item.headRefName,
|
||||||
|
updatedAt: item.updatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items.sort((left, right) => {
|
||||||
|
const leftTime = parseOptionalTime(left.updatedAt ?? null);
|
||||||
|
const rightTime = parseOptionalTime(right.updatedAt ?? null);
|
||||||
|
return rightTime - leftTime;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { items, githubFeaturesEnabled: true };
|
||||||
|
},
|
||||||
|
|
||||||
async createPullRequest(options) {
|
async createPullRequest(options) {
|
||||||
const args = [
|
const args = [
|
||||||
"api",
|
"api",
|
||||||
@@ -689,6 +941,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
method: "isAuthenticated",
|
method: "isAuthenticated",
|
||||||
args: {},
|
args: {},
|
||||||
|
readOptions: options,
|
||||||
load: async () => {
|
load: async () => {
|
||||||
try {
|
try {
|
||||||
await run(["auth", "status"], { cwd: options.cwd });
|
await run(["auth", "status"], { cwd: options.cwd });
|
||||||
@@ -706,6 +959,60 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
retainCurrentPullRequestStatusPoll(options) {
|
||||||
|
const key = getPollTargetKey(options);
|
||||||
|
let target = pollTargets.get(key);
|
||||||
|
if (!target) {
|
||||||
|
target = {
|
||||||
|
cwd: options.cwd,
|
||||||
|
headRef: options.headRef,
|
||||||
|
retainCount: 0,
|
||||||
|
timer: null,
|
||||||
|
latestStatus: null,
|
||||||
|
consecutiveErrors: 0,
|
||||||
|
callbacks: new Set(),
|
||||||
|
errorCallbacks: new Set(),
|
||||||
|
};
|
||||||
|
pollTargets.set(key, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNewlyRetained = target.retainCount === 0;
|
||||||
|
target.retainCount += 1;
|
||||||
|
if (options.onStatus) {
|
||||||
|
target.callbacks.add(options.onStatus);
|
||||||
|
}
|
||||||
|
if (options.onError) {
|
||||||
|
target.errorCallbacks.add(options.onError);
|
||||||
|
}
|
||||||
|
if (isNewlyRetained) {
|
||||||
|
scheduleImmediateGitHubPoll(target);
|
||||||
|
} else {
|
||||||
|
scheduleGitHubPoll(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
let unsubscribed = false;
|
||||||
|
return {
|
||||||
|
unsubscribe: () => {
|
||||||
|
if (unsubscribed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unsubscribed = true;
|
||||||
|
if (options.onStatus) {
|
||||||
|
target.callbacks.delete(options.onStatus);
|
||||||
|
}
|
||||||
|
if (options.onError) {
|
||||||
|
target.errorCallbacks.delete(options.onError);
|
||||||
|
}
|
||||||
|
target.retainCount -= 1;
|
||||||
|
if (target.retainCount > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeGitHubPollTarget(target);
|
||||||
|
pollTargets.delete(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
invalidate(options) {
|
invalidate(options) {
|
||||||
// Local checkout mutations that can alter the current PR identity or PR status
|
// Local checkout mutations that can alter the current PR identity or PR status
|
||||||
// must call this with the affected cwd before broadcasting fresh git state.
|
// must call this with the affected cwd before broadcasting fresh git state.
|
||||||
@@ -720,7 +1027,40 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
for (const target of pollTargets.values()) {
|
||||||
|
closeGitHubPollTarget(target);
|
||||||
|
}
|
||||||
|
pollTargets.clear();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeGithubNextInterval(
|
||||||
|
status: GitHubCurrentPullRequestStatus | null,
|
||||||
|
consecutiveErrors: number,
|
||||||
|
): number {
|
||||||
|
const baseInterval = isGitHubStatusPending(status)
|
||||||
|
? GITHUB_POLL_FAST_INTERVAL_MS
|
||||||
|
: GITHUB_POLL_SLOW_INTERVAL_MS;
|
||||||
|
if (consecutiveErrors <= 1) {
|
||||||
|
return baseInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(baseInterval * 2 ** (consecutiveErrors - 1), GITHUB_POLL_ERROR_BACKOFF_CAP_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGitHubStatusPending(status: GitHubCurrentPullRequestStatus | null): boolean {
|
||||||
|
if (!status) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (status.checksStatus === "pending") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return status.checks.some((check) => check.status === "pending");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveGhPath(): Promise<string | null> {
|
async function resolveGhPath(): Promise<string | null> {
|
||||||
@@ -1393,13 +1733,20 @@ function mapReviewDecision(value: unknown): PullRequestReviewDecision {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveGitHubRepo(cwd: string): Promise<string | null> {
|
export interface GitHubRepoRemoteUrlResolver {
|
||||||
|
resolveRepoRemoteUrl(cwd: string, options?: GitHubReadOptions): Promise<string | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveGitHubRepo(
|
||||||
|
cwd: string,
|
||||||
|
options: { workspaceGitService: GitHubRepoRemoteUrlResolver; readOptions?: GitHubReadOptions },
|
||||||
|
): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await runGitCommand(["config", "--get", "remote.origin.url"], {
|
const remoteUrl = await options.workspaceGitService.resolveRepoRemoteUrl(
|
||||||
cwd,
|
cwd,
|
||||||
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
|
options.readOptions,
|
||||||
});
|
);
|
||||||
return parseGitHubRepoFromRemote(stdout.trim());
|
return parseGitHubRepoFromRemote(remoteUrl?.trim() ?? "");
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2435,6 +2435,56 @@ export const CheckoutStatusResponseSchema = z.object({
|
|||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const CheckoutPrStatusSchema = z.object({
|
||||||
|
number: z.number().optional(),
|
||||||
|
url: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
state: z.string(),
|
||||||
|
baseRefName: z.string(),
|
||||||
|
headRefName: z.string(),
|
||||||
|
isMerged: z.boolean(),
|
||||||
|
isDraft: z.boolean().optional().default(false),
|
||||||
|
checks: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
status: z.string(),
|
||||||
|
url: z.string().nullable(),
|
||||||
|
workflow: z.string().optional(),
|
||||||
|
duration: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.default([]),
|
||||||
|
checksStatus: z.string().optional(),
|
||||||
|
reviewDecision: z.string().nullable().optional(),
|
||||||
|
repoOwner: z.string().optional(),
|
||||||
|
repoName: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const CheckoutPrStatusPayloadSchema = z.object({
|
||||||
|
cwd: z.string(),
|
||||||
|
status: CheckoutPrStatusSchema.nullable(),
|
||||||
|
githubFeaturesEnabled: z.boolean(),
|
||||||
|
error: CheckoutErrorSchema.nullable(),
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const CheckoutStatusUpdateMetadataSchema = z.object({
|
||||||
|
prStatus: CheckoutPrStatusPayloadSchema.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CheckoutStatusUpdateSchema = z.object({
|
||||||
|
type: z.literal("checkout_status_update"),
|
||||||
|
payload: z
|
||||||
|
.union([
|
||||||
|
CheckoutStatusNotGitSchema,
|
||||||
|
CheckoutStatusGitNonPaseoSchema,
|
||||||
|
CheckoutStatusGitPaseoSchema,
|
||||||
|
])
|
||||||
|
.and(CheckoutStatusUpdateMetadataSchema),
|
||||||
|
});
|
||||||
|
|
||||||
const CheckoutDiffSubscriptionPayloadSchema = z.object({
|
const CheckoutDiffSubscriptionPayloadSchema = z.object({
|
||||||
subscriptionId: z.string(),
|
subscriptionId: z.string(),
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
@@ -2515,42 +2565,9 @@ export const CheckoutPrCreateResponseSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CheckoutPrStatusSchema = z.object({
|
|
||||||
number: z.number().optional(),
|
|
||||||
url: z.string(),
|
|
||||||
title: z.string(),
|
|
||||||
state: z.string(),
|
|
||||||
baseRefName: z.string(),
|
|
||||||
headRefName: z.string(),
|
|
||||||
isMerged: z.boolean(),
|
|
||||||
isDraft: z.boolean().optional().default(false),
|
|
||||||
checks: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
name: z.string(),
|
|
||||||
status: z.string(),
|
|
||||||
url: z.string().nullable(),
|
|
||||||
workflow: z.string().optional(),
|
|
||||||
duration: z.string().optional(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.optional()
|
|
||||||
.default([]),
|
|
||||||
checksStatus: z.string().optional(),
|
|
||||||
reviewDecision: z.string().nullable().optional(),
|
|
||||||
repoOwner: z.string().optional(),
|
|
||||||
repoName: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const CheckoutPrStatusResponseSchema = z.object({
|
export const CheckoutPrStatusResponseSchema = z.object({
|
||||||
type: z.literal("checkout_pr_status_response"),
|
type: z.literal("checkout_pr_status_response"),
|
||||||
payload: z.object({
|
payload: CheckoutPrStatusPayloadSchema,
|
||||||
cwd: z.string(),
|
|
||||||
status: CheckoutPrStatusSchema.nullable(),
|
|
||||||
githubFeaturesEnabled: z.boolean(),
|
|
||||||
error: CheckoutErrorSchema.nullable(),
|
|
||||||
requestId: z.string(),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const PullRequestTimelineKnownErrorSchema = z.discriminatedUnion("kind", [
|
const PullRequestTimelineKnownErrorSchema = z.discriminatedUnion("kind", [
|
||||||
@@ -3092,6 +3109,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
AgentArchivedMessageSchema,
|
AgentArchivedMessageSchema,
|
||||||
CloseItemsResponseSchema,
|
CloseItemsResponseSchema,
|
||||||
CheckoutStatusResponseSchema,
|
CheckoutStatusResponseSchema,
|
||||||
|
CheckoutStatusUpdateSchema,
|
||||||
SubscribeCheckoutDiffResponseSchema,
|
SubscribeCheckoutDiffResponseSchema,
|
||||||
CheckoutDiffUpdateSchema,
|
CheckoutDiffUpdateSchema,
|
||||||
CheckoutCommitResponseSchema,
|
CheckoutCommitResponseSchema,
|
||||||
@@ -3318,6 +3336,7 @@ export type SetAgentFeatureRequestMessage = z.infer<typeof SetAgentFeatureReques
|
|||||||
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
|
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
|
||||||
export type CheckoutStatusRequest = z.infer<typeof CheckoutStatusRequestSchema>;
|
export type CheckoutStatusRequest = z.infer<typeof CheckoutStatusRequestSchema>;
|
||||||
export type CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>;
|
export type CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>;
|
||||||
|
export type CheckoutStatusUpdate = z.infer<typeof CheckoutStatusUpdateSchema>;
|
||||||
export type SubscribeCheckoutDiffRequest = z.infer<typeof SubscribeCheckoutDiffRequestSchema>;
|
export type SubscribeCheckoutDiffRequest = z.infer<typeof SubscribeCheckoutDiffRequestSchema>;
|
||||||
export type UnsubscribeCheckoutDiffRequest = z.infer<typeof UnsubscribeCheckoutDiffRequestSchema>;
|
export type UnsubscribeCheckoutDiffRequest = z.infer<typeof UnsubscribeCheckoutDiffRequestSchema>;
|
||||||
export type SubscribeCheckoutDiffResponse = z.infer<typeof SubscribeCheckoutDiffResponseSchema>;
|
export type SubscribeCheckoutDiffResponse = z.infer<typeof SubscribeCheckoutDiffResponseSchema>;
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
parseWorktreeList,
|
parseWorktreeList,
|
||||||
isPaseoWorktreePath,
|
isPaseoWorktreePath,
|
||||||
isDescendantPath,
|
isDescendantPath,
|
||||||
searchGitHubIssuesAndPrs,
|
|
||||||
warmCheckoutShortstatInBackground,
|
warmCheckoutShortstatInBackground,
|
||||||
} from "./checkout-git.js";
|
} from "./checkout-git.js";
|
||||||
import {
|
import {
|
||||||
@@ -96,6 +95,7 @@ function createGitHubServiceForStatus(
|
|||||||
return {
|
return {
|
||||||
listPullRequests: async () => [],
|
listPullRequests: async () => [],
|
||||||
listIssues: async () => [],
|
listIssues: async () => [],
|
||||||
|
searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }),
|
||||||
getPullRequest: async () => ({
|
getPullRequest: async () => ({
|
||||||
number: 1,
|
number: 1,
|
||||||
title: "PR",
|
title: "PR",
|
||||||
@@ -135,6 +135,27 @@ function createPullRequestStatus(overrides?: Partial<GitHubCurrentPullRequestSta
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupRemoteTrackingMain(
|
||||||
|
repoDir: string,
|
||||||
|
tempDir: string,
|
||||||
|
): { remoteDir: string; cloneDir: string } {
|
||||||
|
const remoteDir = join(tempDir, "remote.git");
|
||||||
|
const cloneDir = join(tempDir, "upstream-clone");
|
||||||
|
execSync(`git init --bare -b main ${remoteDir}`);
|
||||||
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
||||||
|
execSync("git push -u origin main", { cwd: repoDir });
|
||||||
|
execSync(`git clone ${remoteDir} ${cloneDir}`);
|
||||||
|
execSync("git config user.email 'test@test.com'", { cwd: cloneDir });
|
||||||
|
execSync("git config user.name 'Test'", { cwd: cloneDir });
|
||||||
|
return { remoteDir, cloneDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitFile(cwd: string, path: string, content: string, message: string): void {
|
||||||
|
writeFileSync(join(cwd, path), content);
|
||||||
|
execSync(`git add ${path}`, { cwd });
|
||||||
|
execSync(`git -c commit.gpgsign=false commit -m '${message}'`, { cwd });
|
||||||
|
}
|
||||||
|
|
||||||
describe("checkout git utilities", () => {
|
describe("checkout git utilities", () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
let repoDir: string;
|
let repoDir: string;
|
||||||
@@ -313,6 +334,164 @@ const x = 1;
|
|||||||
expect(divergedStatus.behindOfOrigin).toBe(1);
|
expect(divergedStatus.behindOfOrigin).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports incoming additions when the base branch is behind its remote", async () => {
|
||||||
|
const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir);
|
||||||
|
commitFile(cloneDir, "file.txt", "remote one\nremote two\n", "remote update");
|
||||||
|
execSync("git push", { cwd: cloneDir });
|
||||||
|
execSync("git fetch origin", { cwd: repoDir });
|
||||||
|
|
||||||
|
const shortstat = await getCheckoutShortstat(repoDir);
|
||||||
|
|
||||||
|
expect(shortstat).toEqual({ additions: 2, deletions: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports incoming deletions when the base branch is behind its remote", async () => {
|
||||||
|
const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir);
|
||||||
|
commitFile(cloneDir, "file.txt", "", "remote deletion");
|
||||||
|
execSync("git push", { cwd: cloneDir });
|
||||||
|
execSync("git fetch origin", { cwd: repoDir });
|
||||||
|
|
||||||
|
const shortstat = await getCheckoutShortstat(repoDir);
|
||||||
|
|
||||||
|
expect(shortstat).toEqual({ additions: 0, deletions: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports outgoing changes when the base branch is ahead of its remote", async () => {
|
||||||
|
setupRemoteTrackingMain(repoDir, tempDir);
|
||||||
|
commitFile(repoDir, "file.txt", "local one\nlocal two\n", "local update");
|
||||||
|
|
||||||
|
const shortstat = await getCheckoutShortstat(repoDir);
|
||||||
|
|
||||||
|
expect(shortstat).toEqual({ additions: 2, deletions: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the merge-base for shortstat when the base branch diverged from its remote", async () => {
|
||||||
|
const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir);
|
||||||
|
commitFile(cloneDir, "file.txt", "remote one\nremote two\n", "remote update");
|
||||||
|
execSync("git push", { cwd: cloneDir });
|
||||||
|
execSync("git fetch origin", { cwd: repoDir });
|
||||||
|
commitFile(repoDir, "local.txt", "local\n", "local update");
|
||||||
|
|
||||||
|
const shortstat = await getCheckoutShortstat(repoDir);
|
||||||
|
|
||||||
|
expect(shortstat).toEqual({ additions: 1, deletions: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps base branch divergence pointed at local work when the remote has more commits", async () => {
|
||||||
|
const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir);
|
||||||
|
commitFile(cloneDir, "remote-one.txt", "remote one\n", "remote update one");
|
||||||
|
commitFile(cloneDir, "remote-two.txt", "remote two\n", "remote update two");
|
||||||
|
execSync("git push", { cwd: cloneDir });
|
||||||
|
execSync("git fetch origin", { cwd: repoDir });
|
||||||
|
commitFile(repoDir, "local.txt", "local\n", "local update");
|
||||||
|
|
||||||
|
const shortstat = await getCheckoutShortstat(repoDir);
|
||||||
|
|
||||||
|
expect(shortstat).toEqual({ additions: 1, deletions: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds working tree changes to incoming shortstat when the base branch is behind", async () => {
|
||||||
|
commitFile(repoDir, "tracked.txt", "tracked base\n", "add tracked file");
|
||||||
|
const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir);
|
||||||
|
commitFile(cloneDir, "incoming.txt", "incoming\n", "remote incoming");
|
||||||
|
execSync("git push", { cwd: cloneDir });
|
||||||
|
execSync("git fetch origin", { cwd: repoDir });
|
||||||
|
writeFileSync(join(repoDir, "tracked.txt"), "local one\nlocal two\n");
|
||||||
|
|
||||||
|
const shortstat = await getCheckoutShortstat(repoDir);
|
||||||
|
|
||||||
|
expect(shortstat).toEqual({ additions: 3, deletions: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps feature shortstat scoped to feature changes when the base remote is ahead", async () => {
|
||||||
|
const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir);
|
||||||
|
execSync("git checkout -b feature", { cwd: repoDir });
|
||||||
|
commitFile(repoDir, "feature.txt", "feature\n", "feature update");
|
||||||
|
execSync("git checkout main", { cwd: cloneDir });
|
||||||
|
commitFile(cloneDir, "base.txt", "base\n", "base update");
|
||||||
|
execSync("git push", { cwd: cloneDir });
|
||||||
|
execSync("git fetch origin", { cwd: repoDir });
|
||||||
|
|
||||||
|
const shortstat = await getCheckoutShortstat(repoDir);
|
||||||
|
|
||||||
|
expect(shortstat).toEqual({ additions: 1, deletions: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports incoming base changes when a feature branch has no local work beyond merge-base", async () => {
|
||||||
|
const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir);
|
||||||
|
execSync("git checkout -b feature", { cwd: repoDir });
|
||||||
|
execSync("git checkout main", { cwd: cloneDir });
|
||||||
|
commitFile(cloneDir, "incoming.txt", "incoming\n", "remote incoming");
|
||||||
|
execSync("git push", { cwd: cloneDir });
|
||||||
|
execSync("git fetch origin", { cwd: repoDir });
|
||||||
|
|
||||||
|
const shortstat = await getCheckoutShortstat(repoDir);
|
||||||
|
|
||||||
|
expect(shortstat).toEqual({ additions: 1, deletions: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports feature shortstat ahead of the comparison merge-base", async () => {
|
||||||
|
setupRemoteTrackingMain(repoDir, tempDir);
|
||||||
|
execSync("git checkout -b feature", { cwd: repoDir });
|
||||||
|
commitFile(repoDir, "feature.txt", "feature\n", "feature update");
|
||||||
|
|
||||||
|
const shortstat = await getCheckoutShortstat(repoDir);
|
||||||
|
|
||||||
|
expect(shortstat).toEqual({ additions: 1, deletions: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the merge-base for shortstat when a feature branch diverged from its tracked remote", async () => {
|
||||||
|
setupRemoteTrackingMain(repoDir, tempDir);
|
||||||
|
execSync("git checkout -b feature", { cwd: repoDir });
|
||||||
|
execSync("git push -u origin feature", { cwd: repoDir });
|
||||||
|
const featureCloneDir = join(tempDir, "feature-clone");
|
||||||
|
execSync(`git clone ${join(tempDir, "remote.git")} ${featureCloneDir}`);
|
||||||
|
execSync("git config user.email 'test@test.com'", { cwd: featureCloneDir });
|
||||||
|
execSync("git config user.name 'Test'", { cwd: featureCloneDir });
|
||||||
|
execSync("git checkout feature", { cwd: featureCloneDir });
|
||||||
|
commitFile(featureCloneDir, "remote-feature.txt", "remote feature\n", "remote feature update");
|
||||||
|
execSync("git push", { cwd: featureCloneDir });
|
||||||
|
commitFile(repoDir, "local-feature.txt", "local feature\n", "local feature update");
|
||||||
|
execSync("git fetch origin", { cwd: repoDir });
|
||||||
|
|
||||||
|
const shortstat = await getCheckoutShortstat(repoDir);
|
||||||
|
|
||||||
|
expect(shortstat).toEqual({ additions: 1, deletions: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the remote-only base branch as the feature shortstat comparison", async () => {
|
||||||
|
const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir);
|
||||||
|
execSync("git remote set-head origin main", { cwd: repoDir });
|
||||||
|
execSync("git checkout -b feature", { cwd: repoDir });
|
||||||
|
commitFile(repoDir, "feature.txt", "feature\n", "feature update");
|
||||||
|
execSync("git branch -D main", { cwd: repoDir });
|
||||||
|
commitFile(cloneDir, "base.txt", "base\n", "base update");
|
||||||
|
execSync("git push", { cwd: cloneDir });
|
||||||
|
execSync("git fetch origin", { cwd: repoDir });
|
||||||
|
|
||||||
|
const shortstat = await getCheckoutShortstat(repoDir);
|
||||||
|
|
||||||
|
expect(shortstat).toEqual({ additions: 1, deletions: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns no shortstat for a clean base branch that is up to date with its remote", async () => {
|
||||||
|
setupRemoteTrackingMain(repoDir, tempDir);
|
||||||
|
execSync("git fetch origin", { cwd: repoDir });
|
||||||
|
|
||||||
|
const shortstat = await getCheckoutShortstat(repoDir);
|
||||||
|
|
||||||
|
expect(shortstat).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports working tree changes when the base branch has no ahead commits", async () => {
|
||||||
|
setupRemoteTrackingMain(repoDir, tempDir);
|
||||||
|
writeFileSync(join(repoDir, "file.txt"), "local one\nlocal two\n");
|
||||||
|
|
||||||
|
const shortstat = await getCheckoutShortstat(repoDir);
|
||||||
|
|
||||||
|
expect(shortstat).toEqual({ additions: 2, deletions: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
it("uses the freshest comparison base for status and shortstat when local main is stale", async () => {
|
it("uses the freshest comparison base for status and shortstat when local main is stale", async () => {
|
||||||
const remoteDir = join(tempDir, "remote.git");
|
const remoteDir = join(tempDir, "remote.git");
|
||||||
const cloneDir = join(tempDir, "clone");
|
const cloneDir = join(tempDir, "clone");
|
||||||
@@ -1125,129 +1304,6 @@ const x = 1;
|
|||||||
expect(status.status).toBeNull();
|
expect(status.status).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("searches GitHub issues and PRs through the GitHub service", async () => {
|
|
||||||
let issueCalls = 0;
|
|
||||||
let pullRequestCalls = 0;
|
|
||||||
const github = createGitHubServiceForStatus(null);
|
|
||||||
github.listIssues = async (options) => {
|
|
||||||
issueCalls += 1;
|
|
||||||
expect(options).toEqual({ cwd: repoDir, query: "cache", limit: 5 });
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
number: 55,
|
|
||||||
title: "Issue title",
|
|
||||||
url: "https://github.com/getpaseo/paseo/issues/55",
|
|
||||||
state: "OPEN",
|
|
||||||
body: "issue body",
|
|
||||||
labels: ["bug"],
|
|
||||||
updatedAt: "2026-04-18T12:00:00Z",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
github.listPullRequests = async (options) => {
|
|
||||||
pullRequestCalls += 1;
|
|
||||||
expect(options).toEqual({ cwd: repoDir, query: "cache", limit: 5 });
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
number: 123,
|
|
||||||
title: "PR title",
|
|
||||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
|
||||||
state: "OPEN",
|
|
||||||
body: "pr body",
|
|
||||||
baseRefName: "main",
|
|
||||||
headRefName: "feature",
|
|
||||||
labels: ["enhancement"],
|
|
||||||
updatedAt: "2026-04-18T13:00:00Z",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await searchGitHubIssuesAndPrs(repoDir, "cache", 5, github);
|
|
||||||
|
|
||||||
expect(issueCalls).toBe(1);
|
|
||||||
expect(pullRequestCalls).toBe(1);
|
|
||||||
expect(result).toEqual({
|
|
||||||
githubFeaturesEnabled: true,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
kind: "issue",
|
|
||||||
number: 55,
|
|
||||||
title: "Issue title",
|
|
||||||
url: "https://github.com/getpaseo/paseo/issues/55",
|
|
||||||
state: "OPEN",
|
|
||||||
body: "issue body",
|
|
||||||
labels: ["bug"],
|
|
||||||
baseRefName: null,
|
|
||||||
headRefName: null,
|
|
||||||
updatedAt: "2026-04-18T12:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "pr",
|
|
||||||
number: 123,
|
|
||||||
title: "PR title",
|
|
||||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
|
||||||
state: "OPEN",
|
|
||||||
body: "pr body",
|
|
||||||
labels: ["enhancement"],
|
|
||||||
baseRefName: "main",
|
|
||||||
headRefName: "feature",
|
|
||||||
updatedAt: "2026-04-18T13:00:00Z",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("searches only GitHub PRs when the search kinds request excludes issues", async () => {
|
|
||||||
let issueCalls = 0;
|
|
||||||
let pullRequestCalls = 0;
|
|
||||||
const github = createGitHubServiceForStatus(null);
|
|
||||||
github.listIssues = async () => {
|
|
||||||
issueCalls += 1;
|
|
||||||
return [];
|
|
||||||
};
|
|
||||||
github.listPullRequests = async (options) => {
|
|
||||||
pullRequestCalls += 1;
|
|
||||||
expect(options).toEqual({ cwd: repoDir, query: "cache", limit: 5 });
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
number: 123,
|
|
||||||
title: "PR title",
|
|
||||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
|
||||||
state: "OPEN",
|
|
||||||
body: "pr body",
|
|
||||||
baseRefName: "main",
|
|
||||||
headRefName: "feature",
|
|
||||||
labels: ["enhancement"],
|
|
||||||
updatedAt: "2026-04-18T13:00:00Z",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await searchGitHubIssuesAndPrs(repoDir, "cache", 5, github, {
|
|
||||||
kinds: ["github-pr"],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(issueCalls).toBe(0);
|
|
||||||
expect(pullRequestCalls).toBe(1);
|
|
||||||
expect(result).toEqual({
|
|
||||||
githubFeaturesEnabled: true,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
kind: "pr",
|
|
||||||
number: 123,
|
|
||||||
title: "PR title",
|
|
||||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
|
||||||
state: "OPEN",
|
|
||||||
body: "pr body",
|
|
||||||
labels: ["enhancement"],
|
|
||||||
baseRefName: "main",
|
|
||||||
headRefName: "feature",
|
|
||||||
updatedAt: "2026-04-18T13:00:00Z",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns merged PR status when no open PR exists for the current branch", async () => {
|
it("returns merged PR status when no open PR exists for the current branch", async () => {
|
||||||
execSync("git checkout -b feature", { cwd: repoDir });
|
execSync("git checkout -b feature", { cwd: repoDir });
|
||||||
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
|
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import { existsSync, realpathSync } from "fs";
|
|||||||
import { open as openFile, stat as statFile } from "fs/promises";
|
import { open as openFile, stat as statFile } from "fs/promises";
|
||||||
import { TTLCache } from "@isaacs/ttlcache";
|
import { TTLCache } from "@isaacs/ttlcache";
|
||||||
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
|
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
|
||||||
import type { GitHubSearchKind } from "../shared/messages.js";
|
|
||||||
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
|
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
|
||||||
import {
|
import {
|
||||||
GitHubAuthenticationError,
|
GitHubAuthenticationError,
|
||||||
GitHubCliMissingError,
|
GitHubCliMissingError,
|
||||||
createGitHubService,
|
createGitHubService,
|
||||||
resolveGitHubRepo,
|
resolveGitHubRepo,
|
||||||
|
type GitHubRepoRemoteUrlResolver,
|
||||||
type GitHubService,
|
type GitHubService,
|
||||||
} from "../services/github-service.js";
|
} from "../services/github-service.js";
|
||||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||||
@@ -33,6 +33,11 @@ let shortstatCacheTtlMs = DEFAULT_SHORTSTAT_CACHE_TTL_MS;
|
|||||||
let shortstatCache = createShortstatCache(shortstatCacheTtlMs);
|
let shortstatCache = createShortstatCache(shortstatCacheTtlMs);
|
||||||
const shortstatInFlight = new Map<string, Promise<CheckoutShortstat | null>>();
|
const shortstatInFlight = new Map<string, Promise<CheckoutShortstat | null>>();
|
||||||
|
|
||||||
|
interface CheckoutReadCacheOptions {
|
||||||
|
force?: boolean;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
function createPullRequestStatusCache(ttlMs: number) {
|
function createPullRequestStatusCache(ttlMs: number) {
|
||||||
return new TTLCache<string, PullRequestStatusResult>({
|
return new TTLCache<string, PullRequestStatusResult>({
|
||||||
ttl: ttlMs,
|
ttl: ttlMs,
|
||||||
@@ -1317,6 +1322,44 @@ export interface CheckoutShortstat {
|
|||||||
deletions: number;
|
deletions: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseCheckoutShortstat(text: string): CheckoutShortstat | null {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let additions = 0;
|
||||||
|
let deletions = 0;
|
||||||
|
const addMatch = trimmed.match(/(\d+)\s+insertion/);
|
||||||
|
if (addMatch) {
|
||||||
|
additions = Number.parseInt(addMatch[1]!, 10);
|
||||||
|
}
|
||||||
|
const delMatch = trimmed.match(/(\d+)\s+deletion/);
|
||||||
|
if (delMatch) {
|
||||||
|
deletions = Number.parseInt(delMatch[1]!, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (additions === 0 && deletions === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { additions, deletions };
|
||||||
|
}
|
||||||
|
|
||||||
|
function combineCheckoutShortstats(
|
||||||
|
first: CheckoutShortstat | null,
|
||||||
|
second: CheckoutShortstat | null,
|
||||||
|
): CheckoutShortstat | null {
|
||||||
|
const additions = (first?.additions ?? 0) + (second?.additions ?? 0);
|
||||||
|
const deletions = (first?.deletions ?? 0) + (second?.deletions ?? 0);
|
||||||
|
|
||||||
|
if (additions === 0 && deletions === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { additions, deletions };
|
||||||
|
}
|
||||||
|
|
||||||
async function getCheckoutShortstatUncached(
|
async function getCheckoutShortstatUncached(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
context?: CheckoutContext,
|
context?: CheckoutContext,
|
||||||
@@ -1331,63 +1374,65 @@ async function getCheckoutShortstatUncached(
|
|||||||
const localBaseRef = configured.baseRef ?? (await resolveBaseRef(cwd));
|
const localBaseRef = configured.baseRef ?? (await resolveBaseRef(cwd));
|
||||||
const currentBranch = await getCurrentBranch(cwd);
|
const currentBranch = await getCurrentBranch(cwd);
|
||||||
|
|
||||||
let diffTarget: string;
|
let comparisonRef: string;
|
||||||
|
|
||||||
if (currentBranch && localBaseRef && currentBranch !== localBaseRef) {
|
if (currentBranch && localBaseRef && currentBranch !== localBaseRef) {
|
||||||
// Feature branch: diff against the merge-base with the base branch
|
|
||||||
const comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, localBaseRef);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { stdout } = await runGitCommand(["merge-base", "HEAD", comparisonBaseRef], {
|
comparisonRef = await resolveBestComparisonBaseRef(cwd, localBaseRef);
|
||||||
cwd,
|
|
||||||
env: READ_ONLY_GIT_ENV,
|
|
||||||
});
|
|
||||||
const mergeBase = stdout.trim();
|
|
||||||
if (!mergeBase) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
diffTarget = mergeBase;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
} else if (currentBranch) {
|
} else if (currentBranch) {
|
||||||
// On the base branch (or no base ref configured): diff against remote tracking branch
|
|
||||||
const hasOrigin = await doesGitRefExist(cwd, `refs/remotes/origin/${currentBranch}`);
|
const hasOrigin = await doesGitRefExist(cwd, `refs/remotes/origin/${currentBranch}`);
|
||||||
if (!hasOrigin) {
|
if (!hasOrigin) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
diffTarget = `origin/${currentBranch}`;
|
comparisonRef = `origin/${currentBranch}`;
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Omit HEAD so the diff includes uncommitted (staged + unstaged) changes
|
const { stdout: mergeBaseOut } = await runGitCommand(["merge-base", "HEAD", comparisonRef], {
|
||||||
const { stdout } = await runGitCommand(["diff", "--shortstat", diffTarget], {
|
|
||||||
cwd,
|
cwd,
|
||||||
env: READ_ONLY_GIT_ENV,
|
env: READ_ONLY_GIT_ENV,
|
||||||
});
|
});
|
||||||
const text = stdout.trim();
|
const mergeBase = mergeBaseOut.trim();
|
||||||
if (!text) {
|
if (!mergeBase) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let additions = 0;
|
const { stdout: aheadBehindOut } = await runGitCommand(
|
||||||
let deletions = 0;
|
["rev-list", "--left-right", "--count", `HEAD...${comparisonRef}`],
|
||||||
const addMatch = text.match(/(\d+)\s+insertion/);
|
{ cwd, env: READ_ONLY_GIT_ENV },
|
||||||
if (addMatch) {
|
);
|
||||||
additions = Number.parseInt(addMatch[1]!, 10);
|
const [headOnlyRaw, comparisonOnlyRaw] = aheadBehindOut.trim().split(/\s+/);
|
||||||
}
|
const headOnly = Number.parseInt(headOnlyRaw ?? "0", 10);
|
||||||
const delMatch = text.match(/(\d+)\s+deletion/);
|
const comparisonOnly = Number.parseInt(comparisonOnlyRaw ?? "0", 10);
|
||||||
if (delMatch) {
|
if (Number.isNaN(headOnly) || Number.isNaN(comparisonOnly)) {
|
||||||
deletions = Number.parseInt(delMatch[1]!, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (additions === 0 && deletions === 0) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { additions, deletions };
|
// Incoming uses a two-ref diff, so add the working tree diff separately.
|
||||||
|
const showIncoming = headOnly === 0 && comparisonOnly > 0;
|
||||||
|
const { stdout } = await runGitCommand(
|
||||||
|
["diff", "--shortstat", mergeBase, ...(showIncoming ? [comparisonRef] : [])],
|
||||||
|
{
|
||||||
|
cwd,
|
||||||
|
env: READ_ONLY_GIT_ENV,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const committedShortstat = parseCheckoutShortstat(stdout);
|
||||||
|
|
||||||
|
if (!showIncoming) {
|
||||||
|
return committedShortstat;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stdout: worktreeOut } = await runGitCommand(["diff", "--shortstat", "HEAD"], {
|
||||||
|
cwd,
|
||||||
|
env: READ_ONLY_GIT_ENV,
|
||||||
|
});
|
||||||
|
return combineCheckoutShortstats(committedShortstat, parseCheckoutShortstat(worktreeOut));
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1396,8 +1441,10 @@ async function getCheckoutShortstatUncached(
|
|||||||
function getOrLoadCheckoutShortstat(
|
function getOrLoadCheckoutShortstat(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
context?: CheckoutContext,
|
context?: CheckoutContext,
|
||||||
|
options?: CheckoutReadCacheOptions,
|
||||||
): Promise<CheckoutShortstat | null> {
|
): Promise<CheckoutShortstat | null> {
|
||||||
const cacheKey = getShortstatCacheKey(cwd);
|
const cacheKey = getShortstatCacheKey(cwd);
|
||||||
|
if (!options?.force) {
|
||||||
const cached = shortstatCache.get(cacheKey);
|
const cached = shortstatCache.get(cacheKey);
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
return Promise.resolve(cached);
|
return Promise.resolve(cached);
|
||||||
@@ -1407,6 +1454,7 @@ function getOrLoadCheckoutShortstat(
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const load = getCheckoutShortstatUncached(cwd, context)
|
const load = getCheckoutShortstatUncached(cwd, context)
|
||||||
.then((shortstat) => {
|
.then((shortstat) => {
|
||||||
@@ -1424,8 +1472,9 @@ function getOrLoadCheckoutShortstat(
|
|||||||
export async function getCheckoutShortstat(
|
export async function getCheckoutShortstat(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
context?: CheckoutContext,
|
context?: CheckoutContext,
|
||||||
|
options?: CheckoutReadCacheOptions,
|
||||||
): Promise<CheckoutShortstat | null> {
|
): Promise<CheckoutShortstat | null> {
|
||||||
return getOrLoadCheckoutShortstat(cwd, context);
|
return getOrLoadCheckoutShortstat(cwd, context, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCachedCheckoutShortstat(cwd: string): CheckoutShortstat | null | undefined {
|
export function getCachedCheckoutShortstat(cwd: string): CheckoutShortstat | null | undefined {
|
||||||
@@ -1996,9 +2045,10 @@ export async function createPullRequest(
|
|||||||
cwd: string,
|
cwd: string,
|
||||||
options: CreatePullRequestOptions,
|
options: CreatePullRequestOptions,
|
||||||
github: GitHubService = createGitHubService(),
|
github: GitHubService = createGitHubService(),
|
||||||
|
workspaceGitService: GitHubRepoRemoteUrlResolver,
|
||||||
): Promise<{ url: string; number: number }> {
|
): Promise<{ url: string; number: number }> {
|
||||||
await requireGitRepo(cwd);
|
await requireGitRepo(cwd);
|
||||||
const repo = await resolveGitHubRepo(cwd);
|
const repo = await resolveGitHubRepo(cwd, { workspaceGitService });
|
||||||
if (!repo) {
|
if (!repo) {
|
||||||
throw new Error("Unable to determine GitHub repo from git remote");
|
throw new Error("Unable to determine GitHub repo from git remote");
|
||||||
}
|
}
|
||||||
@@ -2034,8 +2084,10 @@ export async function createPullRequest(
|
|||||||
export async function getPullRequestStatus(
|
export async function getPullRequestStatus(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
github: GitHubService = createGitHubService(),
|
github: GitHubService = createGitHubService(),
|
||||||
|
options?: CheckoutReadCacheOptions,
|
||||||
): Promise<PullRequestStatusResult> {
|
): Promise<PullRequestStatusResult> {
|
||||||
const cacheKey = getPullRequestStatusCacheKey(cwd);
|
const cacheKey = getPullRequestStatusCacheKey(cwd);
|
||||||
|
if (!options?.force) {
|
||||||
const cached = pullRequestStatusCache.get(cacheKey);
|
const cached = pullRequestStatusCache.get(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return cached;
|
return cached;
|
||||||
@@ -2045,8 +2097,9 @@ export async function getPullRequestStatus(
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const lookup = getPullRequestStatusUncached(cwd, github)
|
const lookup = getPullRequestStatusUncached(cwd, github, options)
|
||||||
.then((status) => {
|
.then((status) => {
|
||||||
pullRequestStatusCache.set(cacheKey, status);
|
pullRequestStatusCache.set(cacheKey, status);
|
||||||
return status;
|
return status;
|
||||||
@@ -2062,6 +2115,7 @@ export async function getPullRequestStatus(
|
|||||||
async function getPullRequestStatusUncached(
|
async function getPullRequestStatusUncached(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
github: GitHubService,
|
github: GitHubService,
|
||||||
|
options?: CheckoutReadCacheOptions,
|
||||||
): Promise<PullRequestStatusResult> {
|
): Promise<PullRequestStatusResult> {
|
||||||
await requireGitRepo(cwd);
|
await requireGitRepo(cwd);
|
||||||
const head = await getCurrentBranch(cwd);
|
const head = await getCurrentBranch(cwd);
|
||||||
@@ -2072,7 +2126,11 @@ async function getPullRequestStatusUncached(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const status = await github.getCurrentPullRequestStatus({ cwd, headRef: head });
|
const status = await github.getCurrentPullRequestStatus({
|
||||||
|
cwd,
|
||||||
|
headRef: head,
|
||||||
|
reason: options?.reason,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
githubFeaturesEnabled: true,
|
githubFeaturesEnabled: true,
|
||||||
@@ -2084,107 +2142,3 @@ async function getPullRequestStatusUncached(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitHubSearchResult {
|
|
||||||
items: Array<{
|
|
||||||
kind: "issue" | "pr";
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
state: string;
|
|
||||||
body: string | null;
|
|
||||||
labels: string[];
|
|
||||||
baseRefName?: string | null;
|
|
||||||
headRefName?: string | null;
|
|
||||||
updatedAt?: string;
|
|
||||||
}>;
|
|
||||||
githubFeaturesEnabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SearchGitHubIssuesAndPrsOptions {
|
|
||||||
kinds?: GitHubSearchKind[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function searchGitHubIssuesAndPrs(
|
|
||||||
cwd: string,
|
|
||||||
query: string,
|
|
||||||
limit = 20,
|
|
||||||
github: GitHubService = createGitHubService(),
|
|
||||||
options: SearchGitHubIssuesAndPrsOptions = {},
|
|
||||||
): Promise<GitHubSearchResult> {
|
|
||||||
await requireGitRepo(cwd);
|
|
||||||
try {
|
|
||||||
const kinds = options.kinds ?? ["github-issue", "github-pr"];
|
|
||||||
const shouldFetchIssues = kinds.includes("github-issue");
|
|
||||||
const shouldFetchPullRequests = kinds.includes("github-pr");
|
|
||||||
const issuesResult = shouldFetchIssues
|
|
||||||
? await github.listIssues({ cwd, query, limit }).then(
|
|
||||||
(value) => ({ status: "fulfilled" as const, value }),
|
|
||||||
(reason) => ({ status: "rejected" as const, reason }),
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const prsResult = shouldFetchPullRequests
|
|
||||||
? await github.listPullRequests({ cwd, query, limit }).then(
|
|
||||||
(value) => ({ status: "fulfilled" as const, value }),
|
|
||||||
(reason) => ({ status: "rejected" as const, reason }),
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const items: GitHubSearchResult["items"] = [];
|
|
||||||
const requestedResults = [issuesResult, prsResult].filter((result) => result !== null);
|
|
||||||
const failures = requestedResults.filter((result) => result.status === "rejected");
|
|
||||||
if (
|
|
||||||
requestedResults.length > 0 &&
|
|
||||||
failures.length === requestedResults.length &&
|
|
||||||
failures.every(
|
|
||||||
(result) =>
|
|
||||||
result.status === "rejected" &&
|
|
||||||
(result.reason instanceof GitHubCliMissingError ||
|
|
||||||
result.reason instanceof GitHubAuthenticationError),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return { items: [], githubFeaturesEnabled: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (issuesResult?.status === "fulfilled") {
|
|
||||||
for (const item of issuesResult.value) {
|
|
||||||
items.push({
|
|
||||||
kind: "issue",
|
|
||||||
number: item.number,
|
|
||||||
title: item.title,
|
|
||||||
url: item.url,
|
|
||||||
state: item.state,
|
|
||||||
body: item.body,
|
|
||||||
labels: item.labels,
|
|
||||||
baseRefName: null,
|
|
||||||
headRefName: null,
|
|
||||||
updatedAt: item.updatedAt,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prsResult?.status === "fulfilled") {
|
|
||||||
for (const item of prsResult.value) {
|
|
||||||
items.push({
|
|
||||||
kind: "pr",
|
|
||||||
number: item.number,
|
|
||||||
title: item.title,
|
|
||||||
url: item.url,
|
|
||||||
state: item.state,
|
|
||||||
body: item.body,
|
|
||||||
labels: item.labels,
|
|
||||||
baseRefName: item.baseRefName,
|
|
||||||
headRefName: item.headRefName,
|
|
||||||
updatedAt: item.updatedAt,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { items, githubFeaturesEnabled: true };
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof GitHubCliMissingError || error instanceof GitHubAuthenticationError) {
|
|
||||||
return { items: [], githubFeaturesEnabled: false };
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user