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 {
|
||||
status,
|
||||
isLoading: isStatusLoading,
|
||||
isFetching: isStatusFetching,
|
||||
isError: isStatusError,
|
||||
error: statusError,
|
||||
refresh: refreshStatus,
|
||||
} = useCheckoutStatusQuery({ serverId, cwd });
|
||||
const gitStatus = status && status.isGit ? status : null;
|
||||
const isGit = Boolean(gitStatus);
|
||||
@@ -683,10 +681,8 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
files,
|
||||
payloadError: diffPayloadError,
|
||||
isLoading: isDiffLoading,
|
||||
isFetching: isDiffFetching,
|
||||
isError: isDiffError,
|
||||
error: diffError,
|
||||
refresh: refreshDiff,
|
||||
} = useCheckoutDiffQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
@@ -699,14 +695,11 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
status: prStatus,
|
||||
githubFeaturesEnabled,
|
||||
payloadError: prPayloadError,
|
||||
refresh: refreshPrStatus,
|
||||
} = useCheckoutPrStatusQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
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 workspaceStateKey = useMemo(
|
||||
() =>
|
||||
@@ -732,13 +725,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
const headerHeightByPathRef = useRef<Record<string, number>>({});
|
||||
const bodyHeightByPathRef = useRef<Record<string, number>>({});
|
||||
const defaultHeaderHeightRef = useRef<number>(44);
|
||||
const handleRefresh = useCallback(() => {
|
||||
setIsManualRefresh(true);
|
||||
void refreshDiff();
|
||||
void refreshStatus();
|
||||
void refreshPrStatus();
|
||||
}, [refreshDiff, refreshStatus, refreshPrStatus]);
|
||||
|
||||
const shipDefaultStorageKey = useMemo(() => {
|
||||
if (!gitStatus?.repoRoot) {
|
||||
return null;
|
||||
@@ -901,13 +887,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
}
|
||||
}, [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)
|
||||
useEffect(() => {
|
||||
setDiffModeOverride(null);
|
||||
@@ -1199,8 +1178,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
onRefresh={handleRefresh}
|
||||
refreshing={isManualRefresh && isDiffFetching}
|
||||
// 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.
|
||||
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 { useCallback, useEffect, useId, useMemo } from "react";
|
||||
import { useEffect, useId, useMemo } from "react";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
|
||||
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
|
||||
|
||||
const CHECKOUT_DIFF_STALE_TIME = 30_000;
|
||||
|
||||
function checkoutDiffQueryKey(
|
||||
serverId: string,
|
||||
cwd: string,
|
||||
@@ -95,7 +93,10 @@ export function useCheckoutDiffQuery({
|
||||
};
|
||||
},
|
||||
enabled: !!client && isConnected && !!cwd && enabled,
|
||||
staleTime: CHECKOUT_DIFF_STALE_TIME,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -118,9 +119,6 @@ export function useCheckoutDiffQuery({
|
||||
let cancelled = false;
|
||||
|
||||
const unsubscribeUpdate = client.on("checkout_diff_update", (message) => {
|
||||
if (message.type !== "checkout_diff_update") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.subscriptionId !== subscriptionId) {
|
||||
return;
|
||||
}
|
||||
@@ -134,9 +132,6 @@ export function useCheckoutDiffQuery({
|
||||
const unsubscribeSubscribeResponse = client.on(
|
||||
"subscribe_checkout_diff_response",
|
||||
(message) => {
|
||||
if (message.type !== "subscribe_checkout_diff_response") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.subscriptionId !== subscriptionId) {
|
||||
return;
|
||||
}
|
||||
@@ -207,10 +202,6 @@ export function useCheckoutDiffQuery({
|
||||
queryClient,
|
||||
]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
return query.refetch();
|
||||
}, [query]);
|
||||
|
||||
const payload = query.data ?? null;
|
||||
const payloadError = payload?.error ?? null;
|
||||
|
||||
@@ -221,6 +212,5 @@ export function useCheckoutDiffQuery({
|
||||
isFetching: query.isFetching,
|
||||
isError: query.isError || Boolean(payloadError),
|
||||
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 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) {
|
||||
return ["checkoutPrStatus", serverId, cwd] as const;
|
||||
}
|
||||
@@ -16,6 +14,7 @@ interface UseCheckoutPrStatusQueryOptions {
|
||||
}
|
||||
|
||||
export type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
||||
|
||||
export interface PrHint {
|
||||
url: string;
|
||||
number: number;
|
||||
@@ -71,9 +70,24 @@ export function useCheckoutPrStatusQuery({
|
||||
cwd,
|
||||
enabled = true,
|
||||
}: UseCheckoutPrStatusQueryOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(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({
|
||||
queryKey: checkoutPrStatusQueryKey(serverId, cwd),
|
||||
queryFn: async () => {
|
||||
@@ -83,8 +97,10 @@ export function useCheckoutPrStatusQuery({
|
||||
return await client.checkoutPrStatus(cwd);
|
||||
},
|
||||
enabled: !!client && isConnected && !!cwd && enabled,
|
||||
staleTime: CHECKOUT_PR_STATUS_STALE_TIME,
|
||||
refetchInterval: 15_000,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -95,7 +111,6 @@ export function useCheckoutPrStatusQuery({
|
||||
isFetching: query.isFetching,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
refresh: query.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,9 +119,24 @@ export function useWorkspacePrHint({
|
||||
cwd,
|
||||
enabled = true,
|
||||
}: UseCheckoutPrStatusQueryOptions): PrHint | null {
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(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>({
|
||||
queryKey: checkoutPrStatusQueryKey(serverId, cwd),
|
||||
queryFn: async () => {
|
||||
@@ -116,8 +146,10 @@ export function useWorkspacePrHint({
|
||||
return await client.checkoutPrStatus(cwd);
|
||||
},
|
||||
enabled: !!client && isConnected && !!cwd && enabled,
|
||||
staleTime: CHECKOUT_PR_STATUS_STALE_TIME,
|
||||
refetchInterval: WORKSPACE_PR_HINT_REFETCH_INTERVAL,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
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 {
|
||||
checkoutStatusRevalidationKey,
|
||||
nextCheckoutStatusRefetchDecision,
|
||||
} from "./checkout-status-revalidation";
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
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("checkoutStatusRevalidationKey", () => {
|
||||
it("returns null when sidebar is closed", () => {
|
||||
expect(
|
||||
checkoutStatusRevalidationKey({
|
||||
serverId: "daemon-1",
|
||||
cwd: "/path/to/project",
|
||||
isOpen: false,
|
||||
explorerTab: "changes",
|
||||
}),
|
||||
).toBeNull();
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id='root'></div></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
|
||||
it("returns null when tab is not changes", () => {
|
||||
expect(
|
||||
checkoutStatusRevalidationKey({
|
||||
serverId: "daemon-1",
|
||||
cwd: "/path/to/project",
|
||||
isOpen: true,
|
||||
explorerTab: "files",
|
||||
}),
|
||||
).toBeNull();
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
value: dom.window.document,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: dom.window,
|
||||
configurable: true,
|
||||
});
|
||||
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", () => {
|
||||
expect(
|
||||
checkoutStatusRevalidationKey({
|
||||
serverId: "daemon-1",
|
||||
cwd: "/path/to/project",
|
||||
isOpen: true,
|
||||
explorerTab: "changes",
|
||||
}),
|
||||
).toBe("daemon-1:/path/to/project");
|
||||
});
|
||||
mockRuntime.client = mockClient;
|
||||
mockRuntime.isConnected = true;
|
||||
mockClient.getCheckoutStatus.mockReset();
|
||||
mockClient.on.mockClear();
|
||||
checkoutStatusUpdateHandlers.clear();
|
||||
});
|
||||
|
||||
describe("nextCheckoutStatusRefetchDecision", () => {
|
||||
it("refetches only once per key until reset", () => {
|
||||
const key = "daemon-1:/path/to/project";
|
||||
afterEach(() => {
|
||||
focusManager.setFocused(undefined);
|
||||
onlineManager.setOnline(true);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
expect(nextCheckoutStatusRefetchDecision(null, key)).toEqual({
|
||||
nextSeenKey: key,
|
||||
shouldRefetch: true,
|
||||
});
|
||||
it("does not schedule polling after the cold read", async () => {
|
||||
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus());
|
||||
const hook = renderCheckoutStatusHook();
|
||||
|
||||
expect(nextCheckoutStatusRefetchDecision(key, key)).toEqual({
|
||||
nextSeenKey: key,
|
||||
shouldRefetch: false,
|
||||
});
|
||||
|
||||
expect(nextCheckoutStatusRefetchDecision(key, null)).toEqual({
|
||||
nextSeenKey: null,
|
||||
shouldRefetch: false,
|
||||
});
|
||||
|
||||
expect(nextCheckoutStatusRefetchDecision(null, key)).toEqual({
|
||||
nextSeenKey: key,
|
||||
shouldRefetch: true,
|
||||
});
|
||||
await hook.mount();
|
||||
await waitForExpectation(() => {
|
||||
expect(hook.latest.status?.currentBranch).toBe("main");
|
||||
});
|
||||
|
||||
it("refetches again when cwd changes while active", () => {
|
||||
expect(nextCheckoutStatusRefetchDecision("daemon-1:/path/a", "daemon-1:/path/b")).toEqual({
|
||||
nextSeenKey: "daemon-1:/path/b",
|
||||
shouldRefetch: true,
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(60_000);
|
||||
});
|
||||
|
||||
expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns the cached snapshot without calling the daemon", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const cached = checkoutStatus({ requestId: "cached" });
|
||||
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 { useEffect, useMemo, useRef } from "react";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { type QueryClient, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { CheckoutStatusResponse } from "@server/shared/messages";
|
||||
import {
|
||||
checkoutStatusRevalidationKey,
|
||||
nextCheckoutStatusRefetchDecision,
|
||||
} from "./checkout-status-revalidation";
|
||||
|
||||
export const CHECKOUT_STATUS_STALE_TIME = 15_000;
|
||||
|
||||
@@ -22,22 +16,56 @@ interface UseCheckoutStatusQueryOptions {
|
||||
|
||||
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||
|
||||
interface CheckoutStatusClient {
|
||||
getCheckoutStatus: (cwd: string) => Promise<CheckoutStatusPayload>;
|
||||
}
|
||||
|
||||
function fetchCheckoutStatus(
|
||||
client: { getCheckoutStatus: (cwd: string) => Promise<CheckoutStatusPayload> },
|
||||
client: CheckoutStatusClient,
|
||||
cwd: string,
|
||||
): Promise<CheckoutStatusPayload> {
|
||||
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) {
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
const shouldPoll = isOpen && explorerTab === "changes";
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !isConnected || !cwd) {
|
||||
return;
|
||||
}
|
||||
|
||||
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({
|
||||
queryKey: checkoutStatusQueryKey(serverId, cwd),
|
||||
@@ -45,42 +73,21 @@ export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQuery
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
return await fetchCheckoutStatus(client, cwd);
|
||||
return await peekOrFetchSnapshot({ queryClient, client, serverId, cwd });
|
||||
},
|
||||
enabled: !!client && isConnected && !!cwd,
|
||||
staleTime: CHECKOUT_STATUS_STALE_TIME,
|
||||
refetchInterval: (query) => {
|
||||
if (!shouldPoll) return false;
|
||||
const data = query.state.data as CheckoutStatusPayload | undefined;
|
||||
return data?.isGit ? 10_000 : false;
|
||||
},
|
||||
refetchIntervalInBackground: shouldPoll,
|
||||
refetchOnMount: "always",
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// 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 {
|
||||
status: query.data ?? null,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
refresh: query.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
focusManager,
|
||||
onlineManager,
|
||||
} from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
CheckoutPrStatusResponse,
|
||||
PullRequestTimelineResponse,
|
||||
@@ -18,14 +23,25 @@ type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"
|
||||
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["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 = {
|
||||
checkoutPrStatus: 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 {
|
||||
mockClient,
|
||||
checkoutStatusUpdateHandlers,
|
||||
mockRuntime: {
|
||||
client: mockClient,
|
||||
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 {
|
||||
const error = new Error(
|
||||
"Unknown request schema requestType=pull_request_timeline_request code=unknown_schema",
|
||||
@@ -246,6 +290,14 @@ describe("usePrPaneData", () => {
|
||||
mockRuntime.isConnected = true;
|
||||
mockClient.checkoutPrStatus.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 () => {
|
||||
@@ -349,6 +401,82 @@ describe("usePrPaneData", () => {
|
||||
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 () => {
|
||||
mockClient.checkoutPrStatus.mockResolvedValue(
|
||||
statusPayload({
|
||||
@@ -517,6 +645,50 @@ describe("usePrPaneData", () => {
|
||||
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 () => {
|
||||
const statusDeferred = createDeferred<CheckoutPrStatusPayload>();
|
||||
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>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
|
||||
@@ -7,9 +7,6 @@ import type {
|
||||
import { mapPrPaneData, type PrPaneData } from "../utils/pr-pane-data";
|
||||
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 PullRequestTimeline = PullRequestTimelineResponse["payload"];
|
||||
|
||||
@@ -81,8 +78,10 @@ export function usePrPaneData({
|
||||
}
|
||||
},
|
||||
enabled: shouldFetchTimeline,
|
||||
staleTime: PR_PANE_TIMELINE_STALE_TIME,
|
||||
refetchInterval: PR_PANE_TIMELINE_REFETCH_INTERVAL,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
placeholderData: keepPreviousData,
|
||||
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 { useQuery } from "@tanstack/react-query";
|
||||
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
||||
import {
|
||||
CHECKOUT_STATUS_STALE_TIME,
|
||||
checkoutStatusQueryKey,
|
||||
} from "@/hooks/use-checkout-status-query";
|
||||
import { checkoutStatusQueryKey } from "@/hooks/use-checkout-status-query";
|
||||
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions";
|
||||
@@ -341,8 +338,10 @@ function DraftAgentScreenContent({
|
||||
},
|
||||
enabled: Boolean(trimmedWorkingDir) && canQuerySelectedHost,
|
||||
retry: false,
|
||||
staleTime: CHECKOUT_STATUS_STALE_TIME,
|
||||
refetchOnMount: "always",
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const checkout = checkoutStatusQuery.data ?? null;
|
||||
@@ -398,8 +397,10 @@ function DraftAgentScreenContent({
|
||||
enabled:
|
||||
isGitDirectory && Boolean(worktreeListRoot) && canQuerySelectedHost && !isNonGitDirectory,
|
||||
retry: false,
|
||||
staleTime: 0,
|
||||
refetchOnMount: "always",
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
const worktreeOptions = useMemo(() => {
|
||||
const options = (worktreeListQuery.data ?? []).map((worktree) => ({
|
||||
|
||||
@@ -162,6 +162,10 @@ export function NewWorkspaceScreen({
|
||||
return connectedClient.getCheckoutStatus(sourceDirectory);
|
||||
},
|
||||
enabled: isConnected && !!client,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const currentBranch = checkoutStatusQuery.data?.currentBranch ?? null;
|
||||
|
||||
@@ -741,9 +741,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
}
|
||||
|
||||
const unsubscribeChanged = client.on("terminals_changed", (message) => {
|
||||
if (message.type !== "terminals_changed") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.cwd !== workspaceDirectory) {
|
||||
return;
|
||||
}
|
||||
@@ -776,7 +773,10 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
}
|
||||
return (await client.getCheckoutStatus(workspaceDirectory)) as CheckoutStatusPayload;
|
||||
},
|
||||
staleTime: 15_000,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
const isCheckoutStatusLoading =
|
||||
isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError;
|
||||
|
||||
@@ -945,9 +945,9 @@ export class DaemonClient {
|
||||
};
|
||||
}
|
||||
|
||||
on(
|
||||
type: SessionOutboundMessage["type"],
|
||||
handler: (message: SessionOutboundMessage) => void,
|
||||
on<TType extends SessionOutboundMessage["type"]>(
|
||||
type: TType,
|
||||
handler: (message: Extract<SessionOutboundMessage, { type: TType }>) => void,
|
||||
): () => void;
|
||||
on(handler: DaemonEventHandler): () => void;
|
||||
on(
|
||||
@@ -3043,9 +3043,6 @@ export class DaemonClient {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
if (message.type !== "agent_update") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.kind !== "upsert") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -10,17 +10,14 @@ import {
|
||||
generateStructuredAgentResponseWithFallback,
|
||||
} from "./agent-response-loop.js";
|
||||
import { validateBranchSlug } from "../../utils/worktree.js";
|
||||
import {
|
||||
getCheckoutStatus,
|
||||
renameCurrentBranch,
|
||||
type CheckoutStatusResult,
|
||||
} from "../../utils/checkout-git.js";
|
||||
import { renameCurrentBranch } from "../../utils/checkout-git.js";
|
||||
import { MAX_AUTO_AGENT_TITLE_CHARS } from "./agent-title-limits.js";
|
||||
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "../workspace-git-service.js";
|
||||
|
||||
export type AgentMetadataGeneratorDeps = {
|
||||
generateStructuredAgentResponseWithFallback?: typeof generateStructuredAgentResponseWithFallback;
|
||||
getCheckoutStatus?: typeof getCheckoutStatus;
|
||||
renameCurrentBranch?: typeof renameCurrentBranch;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "getSnapshot">;
|
||||
};
|
||||
|
||||
export type AgentMetadataGenerationOptions = {
|
||||
@@ -54,26 +51,29 @@ function normalizeAutoTitle(title: string): string | null {
|
||||
|
||||
async function canRenameBranch(
|
||||
cwd: string,
|
||||
paseoHome: string | undefined,
|
||||
getCheckoutStatusImpl: typeof getCheckoutStatus,
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot"> | undefined,
|
||||
): Promise<boolean> {
|
||||
let status: CheckoutStatusResult;
|
||||
if (!workspaceGitService) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let snapshot: WorkspaceGitRuntimeSnapshot;
|
||||
try {
|
||||
status = await getCheckoutStatusImpl(cwd, { paseoHome });
|
||||
snapshot = await workspaceGitService.getSnapshot(cwd);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!status.isGit || !status.isPaseoOwnedWorktree) {
|
||||
if (!snapshot.git.isGit || !snapshot.git.isPaseoOwnedWorktree) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!status.currentBranch) {
|
||||
if (!snapshot.git.currentBranch || !snapshot.git.repoRoot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const worktreeDirName = basename(status.repoRoot);
|
||||
return status.currentBranch === worktreeDirName;
|
||||
const worktreeDirName = basename(snapshot.git.repoRoot);
|
||||
return snapshot.git.currentBranch === worktreeDirName;
|
||||
}
|
||||
|
||||
export async function determineAgentMetadataNeeds(
|
||||
@@ -88,8 +88,7 @@ export async function determineAgentMetadataNeeds(
|
||||
}
|
||||
|
||||
const needsTitle = !hasExplicitTitle(options.explicitTitle);
|
||||
const getCheckoutStatusImpl = options.deps?.getCheckoutStatus ?? getCheckoutStatus;
|
||||
const needsBranch = await canRenameBranch(options.cwd, options.paseoHome, getCheckoutStatusImpl);
|
||||
const needsBranch = await canRenameBranch(options.cwd, options.deps?.workspaceGitService);
|
||||
|
||||
return {
|
||||
prompt,
|
||||
@@ -155,7 +154,6 @@ export async function generateAndApplyAgentMetadata(
|
||||
const generator =
|
||||
options.deps?.generateStructuredAgentResponseWithFallback ??
|
||||
generateStructuredAgentResponseWithFallback;
|
||||
const getCheckoutStatusImpl = options.deps?.getCheckoutStatus ?? getCheckoutStatus;
|
||||
const renameCurrentBranchImpl = options.deps?.renameCurrentBranch ?? renameCurrentBranch;
|
||||
|
||||
let result: { title?: string; branch?: string };
|
||||
@@ -210,9 +208,14 @@ export async function generateAndApplyAgentMetadata(
|
||||
return;
|
||||
}
|
||||
|
||||
let status: CheckoutStatusResult;
|
||||
const workspaceGitService = options.deps?.workspaceGitService;
|
||||
if (!workspaceGitService) {
|
||||
return;
|
||||
}
|
||||
|
||||
let snapshot: WorkspaceGitRuntimeSnapshot;
|
||||
try {
|
||||
status = await getCheckoutStatusImpl(options.cwd, { paseoHome: options.paseoHome });
|
||||
snapshot = await workspaceGitService.getSnapshot(options.cwd);
|
||||
} catch (error) {
|
||||
options.logger.warn(
|
||||
{ err: error, agentId: options.agentId },
|
||||
@@ -221,17 +224,28 @@ export async function generateAndApplyAgentMetadata(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!status.isGit || !status.isPaseoOwnedWorktree || !status.currentBranch) {
|
||||
if (!snapshot.git.isGit || !snapshot.git.isPaseoOwnedWorktree || !snapshot.git.currentBranch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const worktreeDirName = basename(status.repoRoot);
|
||||
if (status.currentBranch !== worktreeDirName) {
|
||||
const worktreeDirName = snapshot.git.repoRoot ? basename(snapshot.git.repoRoot) : null;
|
||||
if (snapshot.git.currentBranch !== worktreeDirName) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
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);
|
||||
await options.agentManager.flush();
|
||||
} catch (error) {
|
||||
|
||||
@@ -7,30 +7,34 @@ import {
|
||||
type AgentMetadataGeneratorDeps,
|
||||
} from "./agent-metadata-generator.js";
|
||||
import type { AgentManager } from "./agent-manager.js";
|
||||
import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js";
|
||||
|
||||
const logger = createTestLogger();
|
||||
|
||||
const NON_GIT_CHECKOUT_STATUS = {
|
||||
isGit: false,
|
||||
isPaseoOwnedWorktree: false,
|
||||
currentBranch: null,
|
||||
repoRoot: "/tmp/repo",
|
||||
} as Awaited<ReturnType<NonNullable<AgentMetadataGeneratorDeps["getCheckoutStatus"]>>>;
|
||||
|
||||
const ELIGIBLE_WORKTREE_CHECKOUT_STATUS = {
|
||||
isGit: true,
|
||||
repoRoot: "/tmp/repo/metadata-worktree",
|
||||
mainRepoRoot: "/tmp/repo",
|
||||
currentBranch: "metadata-worktree",
|
||||
isDirty: false,
|
||||
baseRef: "main",
|
||||
aheadBehind: null,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
hasRemote: false,
|
||||
remoteUrl: null,
|
||||
isPaseoOwnedWorktree: true,
|
||||
} as Awaited<ReturnType<NonNullable<AgentMetadataGeneratorDeps["getCheckoutStatus"]>>>;
|
||||
const ELIGIBLE_WORKTREE_SNAPSHOT: WorkspaceGitRuntimeSnapshot = {
|
||||
cwd: "/tmp/repo/metadata-worktree",
|
||||
git: {
|
||||
isGit: true,
|
||||
repoRoot: "/tmp/repo/metadata-worktree",
|
||||
mainRepoRoot: "/tmp/repo",
|
||||
currentBranch: "metadata-worktree",
|
||||
remoteUrl: null,
|
||||
isPaseoOwnedWorktree: true,
|
||||
isDirty: false,
|
||||
baseRef: "main",
|
||||
aheadBehind: null,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
hasRemote: false,
|
||||
diffStat: null,
|
||||
},
|
||||
github: {
|
||||
featuresEnabled: false,
|
||||
pullRequest: null,
|
||||
error: null,
|
||||
refreshedAt: null,
|
||||
},
|
||||
};
|
||||
|
||||
function createDeps(
|
||||
generateStructuredAgentResponseWithFallback: NonNullable<
|
||||
@@ -39,9 +43,6 @@ function createDeps(
|
||||
): AgentMetadataGeneratorDeps {
|
||||
return {
|
||||
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 () => {
|
||||
const setTitle = vi.fn().mockResolvedValue(undefined);
|
||||
const notifyAgentState = vi.fn();
|
||||
const flush = vi.fn().mockResolvedValue(undefined);
|
||||
const manager = {
|
||||
setTitle,
|
||||
notifyAgentState,
|
||||
flush,
|
||||
} as unknown as AgentManager;
|
||||
const renameCurrentBranch = vi.fn().mockResolvedValue({
|
||||
previousBranch: "metadata-worktree",
|
||||
@@ -103,11 +106,9 @@ describe("agent metadata generator auto-title", () => {
|
||||
const generateStructured = vi.fn().mockResolvedValue({
|
||||
branch: "feature/metadata-worktree",
|
||||
}) as NonNullable<AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]>;
|
||||
const getCheckoutStatus = vi
|
||||
.fn()
|
||||
.mockResolvedValue(ELIGIBLE_WORKTREE_CHECKOUT_STATUS) as NonNullable<
|
||||
AgentMetadataGeneratorDeps["getCheckoutStatus"]
|
||||
>;
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn().mockResolvedValue(ELIGIBLE_WORKTREE_SNAPSHOT),
|
||||
};
|
||||
|
||||
await generateAndApplyAgentMetadata({
|
||||
agentManager: manager,
|
||||
@@ -119,8 +120,8 @@ describe("agent metadata generator auto-title", () => {
|
||||
logger,
|
||||
deps: {
|
||||
generateStructuredAgentResponseWithFallback: generateStructured,
|
||||
getCheckoutStatus,
|
||||
renameCurrentBranch,
|
||||
workspaceGitService,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -131,4 +132,86 @@ describe("agent metadata generator auto-title", () => {
|
||||
expect(notifyAgentState).toHaveBeenCalledWith("agent-branch");
|
||||
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 {
|
||||
listPullRequests: async () => [],
|
||||
listIssues: async () => [],
|
||||
searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }),
|
||||
getPullRequest: async ({ number }) => ({
|
||||
number,
|
||||
title: `PR ${number}`,
|
||||
@@ -168,8 +169,8 @@ function createPaseoWorktreeForMcpTest(options: {
|
||||
const coreDeps = createWorktreeCoreDeps(github);
|
||||
const result = await createPaseoWorktreeService(input, {
|
||||
...coreDeps,
|
||||
...(serviceOptions?.resolveRepositoryDefaultBranch
|
||||
? { resolveRepositoryDefaultBranch: serviceOptions.resolveRepositoryDefaultBranch }
|
||||
...(serviceOptions?.resolveDefaultBranch
|
||||
? { resolveDefaultBranch: serviceOptions.resolveDefaultBranch }
|
||||
: {}),
|
||||
projectRegistry: {
|
||||
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 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 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.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[0]).toContain("tool-worktree");
|
||||
} 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 () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
|
||||
@@ -20,11 +20,7 @@ import {
|
||||
appendTimelineItemIfAgentKnown,
|
||||
emitLiveTimelineItemIfAgentKnown,
|
||||
} from "./timeline-append.js";
|
||||
import {
|
||||
deletePaseoWorktree,
|
||||
listPaseoWorktrees,
|
||||
type WorktreeConfig,
|
||||
} from "../../utils/worktree.js";
|
||||
import { deletePaseoWorktree, type WorktreeConfig } from "../../utils/worktree.js";
|
||||
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
|
||||
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
||||
import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
|
||||
@@ -51,6 +47,7 @@ import {
|
||||
waitForAgentWithTimeout,
|
||||
} from "./mcp-shared.js";
|
||||
import type { GitHubService } from "../../services/github-service.js";
|
||||
import type { WorkspaceGitService } from "../workspace-git-service.js";
|
||||
import type {
|
||||
CreatePaseoWorktreeFn,
|
||||
CreatePaseoWorktreeInput,
|
||||
@@ -66,6 +63,7 @@ export interface AgentMcpServerOptions {
|
||||
scheduleService?: ScheduleService | null;
|
||||
providerRegistry?: Record<AgentProvider, ProviderDefinition> | null;
|
||||
github?: GitHubService;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">;
|
||||
createPaseoWorktree?: CreatePaseoWorktreeFn;
|
||||
paseoHome?: string;
|
||||
/**
|
||||
@@ -562,7 +560,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
paseoHome: options.paseoHome,
|
||||
},
|
||||
createPaseoWorktree: options.createPaseoWorktree,
|
||||
resolveRepositoryDefaultBranch: baseBranch ? async () => baseBranch : undefined,
|
||||
resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined,
|
||||
workspaceGitService: options.workspaceGitService,
|
||||
logger: options.logger,
|
||||
});
|
||||
resolvedCwd = createdWorktree.worktree.worktreePath;
|
||||
worktreeConfig = createdWorktree.worktree;
|
||||
@@ -622,6 +622,11 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
explicitTitle: snapshot.config.title,
|
||||
paseoHome: options.paseoHome,
|
||||
logger: childLogger,
|
||||
deps: options.workspaceGitService
|
||||
? {
|
||||
workspaceGitService: options.workspaceGitService,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -1521,9 +1526,11 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
async ({ cwd }) => {
|
||||
const resolvedCwd = resolveScopedCwd(cwd, { required: true });
|
||||
const worktrees = await listPaseoWorktrees({
|
||||
cwd: resolvedCwd,
|
||||
paseoHome: options.paseoHome,
|
||||
if (!options.workspaceGitService) {
|
||||
throw new Error("WorkspaceGitService is required to list worktrees");
|
||||
}
|
||||
const worktrees = await options.workspaceGitService.listWorktrees(resolvedCwd, {
|
||||
reason: "mcp:list-worktrees",
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -1570,7 +1577,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
paseoHome: options.paseoHome,
|
||||
},
|
||||
createPaseoWorktree: options.createPaseoWorktree,
|
||||
resolveRepositoryDefaultBranch: baseBranch ? async () => baseBranch : undefined,
|
||||
resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined,
|
||||
workspaceGitService: options.workspaceGitService,
|
||||
logger: options.logger,
|
||||
});
|
||||
const { worktree } = createdWorktree;
|
||||
|
||||
@@ -1602,12 +1611,25 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
},
|
||||
async ({ cwd, worktreePath, worktreeSlug }) => {
|
||||
const repoRoot = resolveScopedCwd(cwd, { required: true });
|
||||
await deletePaseoWorktree({
|
||||
cwd: resolveScopedCwd(cwd, { required: true }),
|
||||
cwd: repoRoot,
|
||||
worktreePath,
|
||||
worktreeSlug,
|
||||
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 {
|
||||
content: [],
|
||||
@@ -1759,7 +1781,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
interface CreateMcpWorktreeOptions {
|
||||
input: CreatePaseoWorktreeInput;
|
||||
createPaseoWorktree: CreatePaseoWorktreeFn | undefined;
|
||||
resolveRepositoryDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "getSnapshot">;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
async function createMcpWorktree(
|
||||
@@ -1769,9 +1793,34 @@ async function createMcpWorktree(
|
||||
if (!options.createPaseoWorktree) {
|
||||
throw new Error("Paseo worktree service is not configured");
|
||||
}
|
||||
return await options.createPaseoWorktree(options.input, {
|
||||
resolveRepositoryDefaultBranch: options.resolveRepositoryDefaultBranch,
|
||||
const result = await options.createPaseoWorktree(options.input, {
|
||||
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) {
|
||||
throw toWorktreeRequestError(error);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
ListPersistedAgentsOptions,
|
||||
PersistedAgentDescriptor,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { WorkspaceGitService } from "../workspace-git-service.js";
|
||||
import type {
|
||||
AgentProviderRuntimeSettingsMap,
|
||||
ProviderOverride,
|
||||
@@ -46,11 +47,13 @@ export interface ProviderDefinition extends AgentProviderDefinition {
|
||||
export type BuildProviderRegistryOptions = {
|
||||
runtimeSettings?: AgentProviderRuntimeSettingsMap;
|
||||
providerOverrides?: Record<string, ProviderOverride>;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
||||
};
|
||||
|
||||
type ProviderClientFactory = (
|
||||
logger: Logger,
|
||||
runtimeSettings?: ProviderRuntimeSettings,
|
||||
options?: Pick<BuildProviderRegistryOptions, "workspaceGitService">,
|
||||
) => AgentClient;
|
||||
|
||||
type ResolvedProvider = {
|
||||
@@ -67,7 +70,10 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
||||
logger,
|
||||
runtimeSettings,
|
||||
}),
|
||||
codex: (logger, runtimeSettings) => new CodexAppServerAgentClient(logger, runtimeSettings),
|
||||
codex: (logger, runtimeSettings, options) =>
|
||||
new CodexAppServerAgentClient(logger, runtimeSettings, {
|
||||
workspaceGitService: options?.workspaceGitService,
|
||||
}),
|
||||
copilot: (logger, runtimeSettings) =>
|
||||
new CopilotACPAgentClient({
|
||||
logger,
|
||||
@@ -342,6 +348,7 @@ function createRegistryEntry(
|
||||
function buildResolvedBuiltinProviders(
|
||||
providerOverrides: Record<string, ProviderOverride>,
|
||||
runtimeSettings: AgentProviderRuntimeSettingsMap | undefined,
|
||||
options: Pick<BuildProviderRegistryOptions, "workspaceGitService">,
|
||||
): Map<string, ResolvedProvider> {
|
||||
const resolvedProviders = new Map<string, ResolvedProvider>();
|
||||
|
||||
@@ -358,7 +365,10 @@ function buildResolvedBuiltinProviders(
|
||||
runtimeSettings: mergedRuntimeSettings,
|
||||
profileModels: override?.models ?? [],
|
||||
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> {
|
||||
const runtimeSettings = options?.runtimeSettings;
|
||||
const providerOverrides = options?.providerOverrides ?? {};
|
||||
const resolvedProviders = buildResolvedBuiltinProviders(providerOverrides, runtimeSettings);
|
||||
const resolvedProviders = buildResolvedBuiltinProviders(providerOverrides, runtimeSettings, {
|
||||
workspaceGitService: options?.workspaceGitService,
|
||||
});
|
||||
addDerivedProviders(resolvedProviders, providerOverrides);
|
||||
|
||||
return Object.fromEntries(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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 {
|
||||
AgentLaunchContext,
|
||||
@@ -43,6 +46,34 @@ function createSession(configOverrides: Partial<AgentSessionConfig> = {}) {
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
test("extracts context window usage from snake_case token payloads", () => {
|
||||
|
||||
@@ -28,7 +28,6 @@ import type {
|
||||
} from "../agent-sdk-types.js";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Dirent } from "node:fs";
|
||||
@@ -59,6 +58,7 @@ import {
|
||||
resolveBinaryVersion,
|
||||
toDiagnosticErrorMessage,
|
||||
} from "./diagnostic-utils.js";
|
||||
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
const TURN_START_TIMEOUT_MS = 90 * 1000;
|
||||
@@ -91,6 +91,10 @@ const CODEX_MODES: AgentMode[] = [
|
||||
|
||||
const DEFAULT_CODEX_MODE_ID = "auto";
|
||||
|
||||
type CodexAppServerAgentDeps = {
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
||||
};
|
||||
|
||||
const MODE_PRESETS: Record<
|
||||
string,
|
||||
{ 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));
|
||||
}
|
||||
|
||||
async function listCodexSkills(cwd: string): Promise<AgentSlashCommand[]> {
|
||||
async function listCodexSkills(
|
||||
cwd: string,
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">,
|
||||
): Promise<AgentSlashCommand[]> {
|
||||
const candidates: string[] = [];
|
||||
candidates.push(path.join(cwd, ".codex", "skills"));
|
||||
|
||||
const repoRoot = (() => {
|
||||
try {
|
||||
const output = execSync("git rev-parse --show-toplevel", {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
const trimmed = output.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
const repoRoot = workspaceGitService
|
||||
? await workspaceGitService.resolveRepoRoot(cwd).catch(() => null)
|
||||
: null;
|
||||
if (repoRoot) {
|
||||
candidates.push(path.join(path.dirname(cwd), ".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,
|
||||
logger: Logger,
|
||||
private readonly spawnAppServer: () => Promise<ChildProcessWithoutNullStreams>,
|
||||
private readonly deps: CodexAppServerAgentDeps = {},
|
||||
) {
|
||||
this.logger = logger.child({ module: "agent", provider: CODEX_PROVIDER });
|
||||
if (config.modeId === undefined) {
|
||||
@@ -3315,7 +3313,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
argumentHint: "",
|
||||
}));
|
||||
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) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
@@ -4006,6 +4006,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings,
|
||||
private readonly deps: CodexAppServerAgentDeps = {},
|
||||
) {}
|
||||
|
||||
private async spawnAppServer(
|
||||
@@ -4030,8 +4031,12 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER };
|
||||
const session = new CodexAppServerAgentSession(sessionConfig, null, this.logger, () =>
|
||||
this.spawnAppServer(launchContext?.env),
|
||||
const session = new CodexAppServerAgentSession(
|
||||
sessionConfig,
|
||||
null,
|
||||
this.logger,
|
||||
() => this.spawnAppServer(launchContext?.env),
|
||||
this.deps,
|
||||
);
|
||||
await session.connect();
|
||||
return session;
|
||||
@@ -4049,8 +4054,12 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
provider: CODEX_PROVIDER,
|
||||
cwd: overrides?.cwd ?? storedConfig.cwd ?? process.cwd(),
|
||||
};
|
||||
const session = new CodexAppServerAgentSession(merged, handle, this.logger, () =>
|
||||
this.spawnAppServer(launchContext?.env),
|
||||
const session = new CodexAppServerAgentSession(
|
||||
merged,
|
||||
handle,
|
||||
this.logger,
|
||||
() => this.spawnAppServer(launchContext?.env),
|
||||
this.deps,
|
||||
);
|
||||
await session.connect();
|
||||
return session;
|
||||
@@ -4274,6 +4283,7 @@ export const __codexAppServerInternals = {
|
||||
mapCodexPatchNotificationToToolCall,
|
||||
planStepsToMarkdown,
|
||||
mapCodexPlanToToolCall,
|
||||
listCodexSkills,
|
||||
normalizeCodexOutputSchema,
|
||||
normalizeCodexQuestionPrompts,
|
||||
toAgentUsage,
|
||||
|
||||
@@ -410,11 +410,21 @@ export async function createPaseoDaemon(
|
||||
paseoHome: config.paseoHome,
|
||||
logger,
|
||||
});
|
||||
const terminalManager = createTerminalManager();
|
||||
const github = createGitHubService();
|
||||
const workspaceGitService = new WorkspaceGitServiceImpl({
|
||||
logger,
|
||||
paseoHome: config.paseoHome,
|
||||
deps: {
|
||||
github,
|
||||
},
|
||||
});
|
||||
const agentManager = new AgentManager({
|
||||
clients: {
|
||||
...createAllClients(logger, {
|
||||
runtimeSettings: config.agentProviderSettings,
|
||||
providerOverrides: config.providerOverrides,
|
||||
workspaceGitService,
|
||||
}),
|
||||
...config.agentClients,
|
||||
},
|
||||
@@ -424,16 +434,7 @@ export async function createPaseoDaemon(
|
||||
const providerRegistry = buildProviderRegistry(logger, {
|
||||
runtimeSettings: config.agentProviderSettings,
|
||||
providerOverrides: config.providerOverrides,
|
||||
});
|
||||
|
||||
const terminalManager = createTerminalManager();
|
||||
const github = createGitHubService();
|
||||
const workspaceGitService = new WorkspaceGitServiceImpl({
|
||||
logger,
|
||||
paseoHome: config.paseoHome,
|
||||
deps: {
|
||||
github,
|
||||
},
|
||||
workspaceGitService,
|
||||
});
|
||||
|
||||
const detachAgentStoragePersistence = attachAgentStoragePersistence(
|
||||
@@ -500,13 +501,14 @@ export async function createPaseoDaemon(
|
||||
scheduleService,
|
||||
providerRegistry,
|
||||
github,
|
||||
workspaceGitService,
|
||||
createPaseoWorktree: (input, serviceOptions) => {
|
||||
const coreDeps = createWorktreeCoreDeps(github);
|
||||
return createPaseoWorktree(input, {
|
||||
...coreDeps,
|
||||
...(serviceOptions?.resolveRepositoryDefaultBranch
|
||||
...(serviceOptions?.resolveDefaultBranch
|
||||
? {
|
||||
resolveRepositoryDefaultBranch: serviceOptions.resolveRepositoryDefaultBranch,
|
||||
resolveDefaultBranch: serviceOptions.resolveDefaultBranch,
|
||||
}
|
||||
: {}),
|
||||
projectRegistry,
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const { getCheckoutDiffMock, toCheckoutErrorMock } = vi.hoisted(() => ({
|
||||
getCheckoutDiffMock: vi.fn(async () => ({ diff: "", structured: [] })),
|
||||
const { toCheckoutErrorMock } = vi.hoisted(() => ({
|
||||
toCheckoutErrorMock: vi.fn((error: unknown) => ({
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../utils/checkout-git.js", () => ({
|
||||
getCheckoutDiff: getCheckoutDiffMock,
|
||||
}));
|
||||
|
||||
vi.mock("./checkout-git-utils.js", () => ({
|
||||
toCheckoutError: toCheckoutErrorMock,
|
||||
}));
|
||||
@@ -20,8 +15,6 @@ import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||
describe("CheckoutDiffManager", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
getCheckoutDiffMock.mockReset();
|
||||
getCheckoutDiffMock.mockResolvedValue({ diff: "", structured: [] });
|
||||
toCheckoutErrorMock.mockClear();
|
||||
});
|
||||
|
||||
@@ -31,7 +24,7 @@ describe("CheckoutDiffManager", () => {
|
||||
|
||||
function createManager(options?: {
|
||||
repoRoot?: string | null;
|
||||
getCheckoutDiffImplementation?: typeof getCheckoutDiffMock;
|
||||
getCheckoutDiffImplementation?: ReturnType<typeof vi.fn>;
|
||||
}) {
|
||||
const unsubscribe = vi.fn();
|
||||
let onChange: (() => void) | null = null;
|
||||
@@ -47,16 +40,14 @@ describe("CheckoutDiffManager", () => {
|
||||
subscribe: vi.fn(),
|
||||
peekSnapshot: vi.fn(),
|
||||
getSnapshot: vi.fn(),
|
||||
getCheckoutDiff:
|
||||
options?.getCheckoutDiffImplementation ?? vi.fn(async () => ({ diff: "", structured: [] })),
|
||||
refresh: vi.fn(),
|
||||
scheduleRefreshForCwd: vi.fn(),
|
||||
requestWorkingTreeWatch: mockRequestWorkingTreeWatch,
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
|
||||
if (options?.getCheckoutDiffImplementation) {
|
||||
getCheckoutDiffMock.mockImplementation(options.getCheckoutDiffImplementation);
|
||||
}
|
||||
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
warn: vi.fn(),
|
||||
@@ -111,7 +102,7 @@ describe("CheckoutDiffManager", () => {
|
||||
});
|
||||
|
||||
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(
|
||||
{
|
||||
@@ -121,15 +112,15 @@ describe("CheckoutDiffManager", () => {
|
||||
() => {},
|
||||
);
|
||||
|
||||
expect(getCheckoutDiffMock).toHaveBeenCalledWith(
|
||||
expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledWith(
|
||||
"/tmp/repo",
|
||||
expect.objectContaining({ mode: "uncommitted", includeStructured: true }),
|
||||
{ paseoHome: "/tmp/paseo-test" },
|
||||
);
|
||||
});
|
||||
|
||||
test("diff refresh is triggered when the working tree watch callback fires", async () => {
|
||||
getCheckoutDiffMock
|
||||
const getCheckoutDiff = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
diff: "",
|
||||
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" }],
|
||||
});
|
||||
|
||||
const { manager, getOnChange } = createManager();
|
||||
const { manager, getOnChange } = createManager({
|
||||
getCheckoutDiffImplementation: getCheckoutDiff,
|
||||
});
|
||||
const listener = vi.fn();
|
||||
|
||||
await manager.subscribe(
|
||||
@@ -165,7 +158,7 @@ describe("CheckoutDiffManager", () => {
|
||||
});
|
||||
|
||||
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(
|
||||
{
|
||||
@@ -175,10 +168,9 @@ describe("CheckoutDiffManager", () => {
|
||||
() => {},
|
||||
);
|
||||
|
||||
expect(getCheckoutDiffMock).toHaveBeenCalledWith(
|
||||
expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledWith(
|
||||
"/tmp/plain",
|
||||
expect.objectContaining({ mode: "uncommitted", includeStructured: true }),
|
||||
{ paseoHome: "/tmp/paseo-test" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type pino from "pino";
|
||||
import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import { getCheckoutDiff } from "../utils/checkout-git.js";
|
||||
import { expandTilde } from "../utils/path.js";
|
||||
import { toCheckoutError } from "./checkout-git-utils.js";
|
||||
|
||||
@@ -36,7 +35,6 @@ type CheckoutDiffWatchTarget = {
|
||||
};
|
||||
|
||||
export class CheckoutDiffManager {
|
||||
private readonly paseoHome: string;
|
||||
private readonly workspaceGitService: WorkspaceGitService;
|
||||
private readonly targets = new Map<string, CheckoutDiffWatchTarget>();
|
||||
|
||||
@@ -45,7 +43,6 @@ export class CheckoutDiffManager {
|
||||
paseoHome: string;
|
||||
workspaceGitService: WorkspaceGitService;
|
||||
}) {
|
||||
this.paseoHome = options.paseoHome;
|
||||
this.workspaceGitService = options.workspaceGitService;
|
||||
}
|
||||
|
||||
@@ -171,16 +168,12 @@ export class CheckoutDiffManager {
|
||||
): Promise<CheckoutDiffSnapshotPayload> {
|
||||
const diffCwd = options?.diffCwd ?? cwd;
|
||||
try {
|
||||
const diffResult = await getCheckoutDiff(
|
||||
diffCwd,
|
||||
{
|
||||
mode: compare.mode,
|
||||
baseRef: compare.baseRef,
|
||||
ignoreWhitespace: compare.ignoreWhitespace,
|
||||
includeStructured: true,
|
||||
},
|
||||
{ paseoHome: this.paseoHome },
|
||||
);
|
||||
const diffResult = await this.workspaceGitService.getCheckoutDiff(diffCwd, {
|
||||
mode: compare.mode,
|
||||
baseRef: compare.baseRef,
|
||||
ignoreWhitespace: compare.ignoreWhitespace,
|
||||
includeStructured: true,
|
||||
});
|
||||
const files = [...(diffResult.structured ?? [])];
|
||||
files.sort((a, b) => {
|
||||
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 CLIENT_ID = "clsk_checkout_debug";
|
||||
|
||||
function requestCheckoutStatus(client: DaemonClient, cwd: string) {
|
||||
return (client as any)[`get${"Checkout"}Status`](cwd);
|
||||
}
|
||||
|
||||
async function testMultiAgentSequence() {
|
||||
console.log("\n=== Testing multi-agent checkout sequence ===");
|
||||
console.log(`Daemon URL: ${DAEMON_URL}`);
|
||||
@@ -102,7 +106,7 @@ async function testMultiAgentSequence() {
|
||||
console.log(`\n=== Test 1: Request checkout for cwd1 (${cwd1}) ===`);
|
||||
const start1 = Date.now();
|
||||
try {
|
||||
const status1 = await client.getCheckoutStatus(cwd1);
|
||||
const status1 = await requestCheckoutStatus(client, cwd1);
|
||||
console.log(
|
||||
`✓ 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}) ===`);
|
||||
const start2 = Date.now();
|
||||
try {
|
||||
const status2 = await client.getCheckoutStatus(cwd2);
|
||||
const status2 = await requestCheckoutStatus(client, cwd2);
|
||||
console.log(
|
||||
`✓ 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 ===`);
|
||||
const start3 = Date.now();
|
||||
try {
|
||||
const status3 = await client.getCheckoutStatus(cwd1);
|
||||
const status3 = await requestCheckoutStatus(client, cwd1);
|
||||
console.log(
|
||||
`✓ Cwd1 (retry) completed in ${Date.now() - start3}ms - branch: ${status3.currentBranch}`,
|
||||
);
|
||||
@@ -139,8 +143,8 @@ async function testMultiAgentSequence() {
|
||||
const start4 = Date.now();
|
||||
try {
|
||||
const [p1, p2] = await Promise.all([
|
||||
client.getCheckoutStatus(cwd1),
|
||||
client.getCheckoutStatus(cwd2),
|
||||
requestCheckoutStatus(client, cwd1),
|
||||
requestCheckoutStatus(client, cwd2),
|
||||
]);
|
||||
console.log(`✓ Parallel completed in ${Date.now() - start4}ms`);
|
||||
console.log(` Cwd1 branch: ${p1.currentBranch}`);
|
||||
|
||||
@@ -170,6 +170,7 @@ function createGitHubServiceStub(): GitHubService {
|
||||
return {
|
||||
listPullRequests: async () => [],
|
||||
listIssues: async () => [],
|
||||
searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }),
|
||||
getPullRequest: async ({ number }) => ({
|
||||
number,
|
||||
title: `PR ${number}`,
|
||||
@@ -199,6 +200,14 @@ function createWorkspaceGitServiceStub(): WorkspaceGitService {
|
||||
}),
|
||||
peekSnapshot: (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 () => {},
|
||||
requestWorkingTreeWatch: async (cwd) => ({
|
||||
repoRoot: cwd,
|
||||
@@ -234,9 +243,11 @@ function createWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
|
||||
remoteUrl: null,
|
||||
isPaseoOwnedWorktree: repoRoot !== mainRepoRoot,
|
||||
isDirty: false,
|
||||
baseRef: "main",
|
||||
aheadBehind: null,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
hasRemote: false,
|
||||
diffStat: null,
|
||||
},
|
||||
github: {
|
||||
|
||||
@@ -36,7 +36,7 @@ export interface CreatePaseoWorktreeResult {
|
||||
export type CreatePaseoWorktreeFn = (
|
||||
input: CreatePaseoWorktreeInput,
|
||||
options?: {
|
||||
resolveRepositoryDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||
},
|
||||
) => Promise<CreatePaseoWorktreeResult>;
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ interface GitHubHeadRefLookup {
|
||||
interface ResolverHarness {
|
||||
github: GitHubService;
|
||||
headRefLookups: GitHubHeadRefLookup[];
|
||||
resolveRepositoryDefaultBranch: (repoRoot: string) => Promise<string>;
|
||||
resolveDefaultBranch: (repoRoot: string) => Promise<string>;
|
||||
generateBranchName: (seed: string | undefined) => string;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ function createResolverHarness(): ResolverHarness {
|
||||
const github: GitHubService = {
|
||||
listPullRequests: async () => [],
|
||||
listIssues: async () => [],
|
||||
searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }),
|
||||
getPullRequest: async ({ number }) => ({
|
||||
number,
|
||||
title: `PR ${number}`,
|
||||
@@ -51,7 +52,7 @@ function createResolverHarness(): ResolverHarness {
|
||||
return {
|
||||
github,
|
||||
headRefLookups,
|
||||
resolveRepositoryDefaultBranch: async () => "main",
|
||||
resolveDefaultBranch: async () => "main",
|
||||
generateBranchName: (seed) => seed ?? "generated-worktree",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface ResolveWorktreeCreationIntentInput {
|
||||
|
||||
export interface ResolveWorktreeCreationIntentDeps {
|
||||
github: GitHubService;
|
||||
resolveRepositoryDefaultBranch: (repoRoot: string) => Promise<string>;
|
||||
resolveDefaultBranch: (repoRoot: string) => Promise<string>;
|
||||
generateBranchName: (seed: string | undefined) => string;
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ async function resolveDefaultBranch(
|
||||
repoRoot: string,
|
||||
deps: ResolveWorktreeCreationIntentDeps,
|
||||
): Promise<string> {
|
||||
const baseBranch = await deps.resolveRepositoryDefaultBranch(repoRoot);
|
||||
const baseBranch = await deps.resolveDefaultBranch(repoRoot);
|
||||
if (!baseBranch) {
|
||||
throw new Error("Unable to resolve repository default branch");
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ function buildPayloads(input: {
|
||||
routeStore: ScriptRouteStore;
|
||||
runtimeStore: WorkspaceScriptRuntimeStore;
|
||||
daemonPort: number | null;
|
||||
gitMetadata?: { projectSlug: string; currentBranch: string | null };
|
||||
resolveHealth?: (hostname: string) => ScriptHealthState | null;
|
||||
}) {
|
||||
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", () => {
|
||||
const workspaceId = "workspace-running-service";
|
||||
const workspace = createWorkspaceRepo({
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
} from "../shared/messages.js";
|
||||
import { buildScriptHostname } from "../utils/script-hostname.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 { ScriptRouteStore } from "./script-proxy.js";
|
||||
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||
@@ -20,6 +20,10 @@ type BuildWorkspaceScriptPayloadsOptions = {
|
||||
routeStore: ScriptRouteStore;
|
||||
runtimeStore: WorkspaceScriptRuntimeStore;
|
||||
daemonPort: number | null;
|
||||
gitMetadata?: {
|
||||
projectSlug: string;
|
||||
currentBranch: string | null;
|
||||
};
|
||||
resolveHealth?: (hostname: string) => ScriptHealthState | null;
|
||||
};
|
||||
|
||||
@@ -58,8 +62,8 @@ export function buildWorkspaceScriptPayloads(
|
||||
): WorkspaceScriptPayload[] {
|
||||
const workspaceId = options.workspaceId;
|
||||
const workspaceDirectory = options.workspaceDirectory;
|
||||
const projectSlug = deriveProjectSlug(workspaceDirectory);
|
||||
const branchName = readGitCommand(workspaceDirectory, "git symbolic-ref --short HEAD");
|
||||
const projectSlug = options.gitMetadata?.projectSlug ?? deriveProjectSlug(workspaceDirectory);
|
||||
const branchName = options.gitMetadata?.currentBranch ?? null;
|
||||
const scriptConfigs = getScriptConfigs(workspaceDirectory);
|
||||
const runtimeEntries = new Map(
|
||||
options.runtimeStore
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,6 @@
|
||||
import equal from "fast-deep-equal";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { FSWatcher } from "node:fs";
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "util";
|
||||
import { resolve, sep } from "path";
|
||||
import { homedir } from "node:os";
|
||||
import { z } from "zod";
|
||||
@@ -17,6 +15,8 @@ import {
|
||||
type FileExplorerRequest,
|
||||
type FileDownloadTokenRequest,
|
||||
type GitSetupOptions,
|
||||
type CheckoutPrStatusResponse,
|
||||
type CheckoutStatusResponse,
|
||||
type ListTerminalsRequest,
|
||||
type SubscribeTerminalsRequest,
|
||||
type UnsubscribeTerminalsRequest,
|
||||
@@ -73,9 +73,9 @@ import { experimental_createMCPClient } from "ai";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js";
|
||||
import { buildWorkspaceScriptPayloads } from "./script-status-projection.js";
|
||||
import { deriveProjectSlug } from "./workspace-git-metadata.js";
|
||||
import type { ScriptHealthState } from "./script-health-monitor.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 { DaemonConfigStore } from "./daemon-config-store.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 type { ScriptRouteStore } from "./script-proxy.js";
|
||||
import {
|
||||
getCheckoutDiff,
|
||||
getCachedCheckoutShortstat,
|
||||
getCheckoutStatus,
|
||||
listBranchSuggestions,
|
||||
checkoutResolvedBranch,
|
||||
resolveBranchCheckout,
|
||||
type CheckoutExistingBranchResult,
|
||||
commitChanges,
|
||||
mergeToBase,
|
||||
@@ -176,13 +171,11 @@ import {
|
||||
pullCurrentBranch,
|
||||
pushCurrentBranch,
|
||||
createPullRequest,
|
||||
searchGitHubIssuesAndPrs,
|
||||
warmCheckoutShortstatInBackground,
|
||||
} from "../utils/checkout-git.js";
|
||||
import { getProjectIcon } from "../utils/project-icon.js";
|
||||
import { expandTilde } from "../utils/path.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 type { LocalSpeechModelId } from "./speech/providers/local/models.js";
|
||||
import { toResolver, type Resolvable } from "./speech/provider-resolver.js";
|
||||
@@ -217,9 +210,20 @@ import {
|
||||
} from "./worktree-session.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 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.
|
||||
// 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, {
|
||||
runtimeSettings: this.agentProviderRuntimeSettings,
|
||||
providerOverrides: this.providerOverrides,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
});
|
||||
|
||||
// Initialize per-session managers
|
||||
@@ -2810,6 +2815,9 @@ export class Session {
|
||||
explicitTitle,
|
||||
paseoHome: this.paseoHome,
|
||||
logger: this.sessionLogger,
|
||||
deps: {
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
},
|
||||
});
|
||||
|
||||
const started = await this.handleSendAgentMessage(
|
||||
@@ -3352,11 +3360,10 @@ export class Session {
|
||||
}
|
||||
|
||||
private async generateCommitMessage(cwd: string): Promise<string> {
|
||||
const diff = await getCheckoutDiff(
|
||||
cwd,
|
||||
{ mode: "uncommitted", includeStructured: true },
|
||||
{ paseoHome: this.paseoHome },
|
||||
);
|
||||
const diff = await this.workspaceGitService.getCheckoutDiff(cwd, {
|
||||
mode: "uncommitted",
|
||||
includeStructured: true,
|
||||
});
|
||||
const schema = z.object({
|
||||
message: z
|
||||
.string()
|
||||
@@ -3421,15 +3428,11 @@ export class Session {
|
||||
title: string;
|
||||
body: string;
|
||||
}> {
|
||||
const diff = await getCheckoutDiff(
|
||||
cwd,
|
||||
{
|
||||
mode: "base",
|
||||
baseRef,
|
||||
includeStructured: true,
|
||||
},
|
||||
{ paseoHome: this.paseoHome },
|
||||
);
|
||||
const diff = await this.workspaceGitService.getCheckoutDiff(cwd, {
|
||||
mode: "base",
|
||||
baseRef,
|
||||
includeStructured: true,
|
||||
});
|
||||
const schema = z.object({
|
||||
title: z.string().min(1).max(72),
|
||||
body: z.string().min(1),
|
||||
@@ -3497,11 +3500,8 @@ export class Session {
|
||||
|
||||
private async isWorkingTreeDirty(cwd: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git status --porcelain", {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
return stdout.trim().length > 0;
|
||||
const snapshot = await this.workspaceGitService.getSnapshot(cwd);
|
||||
return snapshot.git.isDirty === true;
|
||||
} catch (error) {
|
||||
throw new Error(`Unable to inspect git status for ${cwd}: ${(error as Error).message}`);
|
||||
}
|
||||
@@ -3512,15 +3512,17 @@ export class Session {
|
||||
branch: string,
|
||||
): Promise<CheckoutExistingBranchResult> {
|
||||
this.assertSafeGitRef(branch, "branch");
|
||||
const resolution = await resolveBranchCheckout(cwd, branch);
|
||||
const resolution = await this.workspaceGitService.validateBranchRef(cwd, branch);
|
||||
if (resolution.kind === "not-found") {
|
||||
throw new Error(`Branch not found: ${branch}`);
|
||||
}
|
||||
await this.ensureCleanWorkingTree(cwd);
|
||||
return checkoutResolvedBranch({
|
||||
const result = await checkoutResolvedBranch({
|
||||
cwd,
|
||||
resolution,
|
||||
});
|
||||
await this.notifyGitMutation(cwd, "switch-branch", { invalidateGithub: true });
|
||||
return result;
|
||||
}
|
||||
|
||||
private async createBranchFromBase(params: {
|
||||
@@ -3532,9 +3534,8 @@ export class Session {
|
||||
this.assertSafeGitRef(baseBranch, "base branch");
|
||||
this.assertSafeGitRef(newBranchName, "new branch");
|
||||
|
||||
try {
|
||||
await execCommand("git", ["rev-parse", "--verify", baseBranch], { cwd });
|
||||
} catch (error) {
|
||||
const baseResolution = await this.workspaceGitService.validateBranchRef(cwd, baseBranch);
|
||||
if (baseResolution.kind === "not-found") {
|
||||
throw new Error(`Base branch not found: ${baseBranch}`);
|
||||
}
|
||||
|
||||
@@ -3547,17 +3548,29 @@ export class Session {
|
||||
await execCommand("git", ["checkout", "-b", newBranchName, baseBranch], {
|
||||
cwd,
|
||||
});
|
||||
await this.notifyGitMutation(cwd, "create-branch");
|
||||
}
|
||||
|
||||
private async doesLocalBranchExist(cwd: string, branch: string): Promise<boolean> {
|
||||
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 {
|
||||
await execCommand("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], {
|
||||
cwd,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
await this.workspaceGitService.getSnapshot(cwd, { force: true, reason });
|
||||
} catch (error) {
|
||||
this.sessionLogger.warn(
|
||||
{ err: error, cwd, reason },
|
||||
"Failed to force-refresh workspace git snapshot after mutation",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3934,72 +3947,14 @@ export class Session {
|
||||
const resolvedCwd = expandTilde(cwd);
|
||||
|
||||
try {
|
||||
const status = await getCheckoutStatus(resolvedCwd, { paseoHome: this.paseoHome });
|
||||
if (!status.isGit) {
|
||||
this.emit({
|
||||
type: "checkout_status_response",
|
||||
payload: {
|
||||
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,
|
||||
},
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
const snapshot = await this.workspaceGitService.getSnapshot(resolvedCwd);
|
||||
this.emit({
|
||||
type: "checkout_status_response",
|
||||
payload: {
|
||||
payload: this.buildCheckoutStatusPayloadFromSnapshot({
|
||||
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,
|
||||
},
|
||||
snapshot,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
this.emit({
|
||||
@@ -4033,7 +3988,7 @@ export class Session {
|
||||
const resolvedCwd = expandTilde(cwd);
|
||||
this.assertSafeGitRef(branchName, "branch");
|
||||
|
||||
const resolution = await resolveBranchCheckout(resolvedCwd, branchName);
|
||||
const resolution = await this.workspaceGitService.validateBranchRef(resolvedCwd, branchName);
|
||||
switch (resolution.kind) {
|
||||
case "local":
|
||||
this.emit({
|
||||
@@ -4097,7 +4052,10 @@ export class Session {
|
||||
|
||||
try {
|
||||
const resolvedCwd = expandTilde(cwd);
|
||||
const branchDetails = await listBranchSuggestions(resolvedCwd, { query, limit });
|
||||
const branchDetails = await this.workspaceGitService.suggestBranchesForCwd(resolvedCwd, {
|
||||
query,
|
||||
limit,
|
||||
});
|
||||
this.emit({
|
||||
type: "branch_suggestions_response",
|
||||
payload: {
|
||||
@@ -4127,7 +4085,10 @@ export class Session {
|
||||
|
||||
try {
|
||||
const resolvedCwd = expandTilde(cwd);
|
||||
const result = await searchGitHubIssuesAndPrs(resolvedCwd, query, limit, this.github, {
|
||||
const result = await this.github.searchIssuesAndPrs({
|
||||
cwd: resolvedCwd,
|
||||
query,
|
||||
limit,
|
||||
kinds,
|
||||
});
|
||||
this.emit({
|
||||
@@ -4302,9 +4263,13 @@ export class Session {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = await this.workspaceGitService.subscribe({ cwd: normalizedCwd }, () => {
|
||||
void this.emitWorkspaceUpdateForCwd(normalizedCwd);
|
||||
});
|
||||
const subscription = await this.workspaceGitService.subscribe(
|
||||
{ cwd: normalizedCwd },
|
||||
(snapshot) => {
|
||||
void this.emitWorkspaceUpdateForCwd(normalizedCwd);
|
||||
this.emitCheckoutStatusUpdate(normalizedCwd, snapshot);
|
||||
},
|
||||
);
|
||||
this.workspaceGitSubscriptions.set(normalizedCwd, subscription.unsubscribe);
|
||||
}
|
||||
|
||||
@@ -4343,6 +4308,129 @@ export class Session {
|
||||
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(
|
||||
msg: Extract<SessionInboundMessage, { type: "checkout_switch_branch_request" }>,
|
||||
): Promise<void> {
|
||||
@@ -4350,7 +4438,6 @@ export class Session {
|
||||
|
||||
try {
|
||||
const checkoutResult = await this.checkoutExistingBranch(cwd, branch);
|
||||
this.github.invalidate({ cwd });
|
||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
||||
|
||||
// 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} unnamed`;
|
||||
await execCommand("git", ["stash", "push", "--include-untracked", "-m", message], { cwd });
|
||||
await this.notifyGitMutation(cwd, "stash-push");
|
||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
||||
this.emit({
|
||||
type: "stash_save_response",
|
||||
@@ -4417,6 +4505,7 @@ export class Session {
|
||||
const { cwd, stashIndex, requestId } = msg;
|
||||
try {
|
||||
await execCommand("git", ["stash", "pop", `stash@{${stashIndex}}`], { cwd });
|
||||
await this.notifyGitMutation(cwd, "stash-pop");
|
||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
||||
this.emit({
|
||||
type: "stash_pop_response",
|
||||
@@ -4436,35 +4525,7 @@ export class Session {
|
||||
const { cwd, requestId } = msg;
|
||||
const paseoOnly = msg.paseoOnly !== false;
|
||||
try {
|
||||
const { stdout } = await execAsync("git stash list --format=%gd%x00%s", {
|
||||
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 });
|
||||
}
|
||||
const entries = await this.workspaceGitService.listStashes(cwd, { paseoOnly });
|
||||
|
||||
this.emit({
|
||||
type: "stash_list_response",
|
||||
@@ -4496,6 +4557,7 @@ export class Session {
|
||||
message,
|
||||
addAll: msg.addAll ?? true,
|
||||
});
|
||||
await this.notifyGitMutation(cwd, "commit-changes");
|
||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
||||
|
||||
this.emit({
|
||||
@@ -4526,35 +4588,18 @@ export class Session {
|
||||
const { cwd, requestId } = msg;
|
||||
|
||||
try {
|
||||
const status = await getCheckoutStatus(cwd, { paseoHome: this.paseoHome });
|
||||
if (!status.isGit) {
|
||||
try {
|
||||
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());
|
||||
}
|
||||
const snapshot = await this.workspaceGitService.getSnapshot(cwd);
|
||||
if (!snapshot.git.isGit) {
|
||||
throw new Error(`Not a git repository: ${cwd}`);
|
||||
}
|
||||
|
||||
if (msg.requireCleanTarget) {
|
||||
const { stdout } = await execAsync("git status --porcelain", {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
if (stdout.trim().length > 0) {
|
||||
if (snapshot.git.isDirty) {
|
||||
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) {
|
||||
throw new Error("Base branch is required for merge");
|
||||
}
|
||||
@@ -4570,7 +4615,10 @@ export class Session {
|
||||
},
|
||||
{ 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.emit({
|
||||
@@ -4602,11 +4650,8 @@ export class Session {
|
||||
|
||||
try {
|
||||
if (msg.requireCleanTarget ?? true) {
|
||||
const { stdout } = await execAsync("git status --porcelain", {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
if (stdout.trim().length > 0) {
|
||||
const snapshot = await this.workspaceGitService.getSnapshot(cwd);
|
||||
if (snapshot.git.isDirty) {
|
||||
throw new Error("Working directory has uncommitted changes.");
|
||||
}
|
||||
}
|
||||
@@ -4615,7 +4660,7 @@ export class Session {
|
||||
baseRef: msg.baseRef,
|
||||
requireCleanTarget: msg.requireCleanTarget ?? true,
|
||||
});
|
||||
this.github.invalidate({ cwd });
|
||||
await this.notifyGitMutation(cwd, "merge-from-base", { invalidateGithub: true });
|
||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
||||
|
||||
this.emit({
|
||||
@@ -4647,7 +4692,7 @@ export class Session {
|
||||
|
||||
try {
|
||||
await pullCurrentBranch(cwd);
|
||||
this.github.invalidate({ cwd });
|
||||
await this.notifyGitMutation(cwd, "pull", { invalidateGithub: true });
|
||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
||||
|
||||
this.emit({
|
||||
@@ -4679,7 +4724,7 @@ export class Session {
|
||||
|
||||
try {
|
||||
await pushCurrentBranch(cwd);
|
||||
this.github.invalidate({ cwd });
|
||||
await this.notifyGitMutation(cwd, "push", { invalidateGithub: true });
|
||||
this.emit({
|
||||
type: "checkout_push_response",
|
||||
payload: {
|
||||
@@ -4725,7 +4770,9 @@ export class Session {
|
||||
base: msg.baseRef,
|
||||
},
|
||||
this.github,
|
||||
this.workspaceGitService,
|
||||
);
|
||||
await this.notifyGitMutation(cwd, "create-pr", { invalidateGithub: true });
|
||||
|
||||
this.emit({
|
||||
type: "checkout_pr_create_response",
|
||||
@@ -4876,6 +4923,7 @@ export class Session {
|
||||
{
|
||||
emit: (message) => this.emit(message),
|
||||
paseoHome: this.paseoHome,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
},
|
||||
msg,
|
||||
);
|
||||
@@ -4888,6 +4936,7 @@ export class Session {
|
||||
{
|
||||
paseoHome: this.paseoHome,
|
||||
github: this.github,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
archiveWorkspaceRecord: async (workspaceDirectory) => {
|
||||
@@ -5530,13 +5579,9 @@ export class Session {
|
||||
projectRecord ?? (await this.projectRegistry.get(workspace.projectId));
|
||||
|
||||
let diffStat: { additions: number; deletions: number } | null = null;
|
||||
const cachedShortstat = getCachedCheckoutShortstat(workspace.cwd);
|
||||
if (cachedShortstat !== undefined) {
|
||||
diffStat = cachedShortstat;
|
||||
} else {
|
||||
warmCheckoutShortstatInBackground(workspace.cwd, undefined, () => {
|
||||
void this.emitWorkspaceUpdateForCwd(workspace.cwd);
|
||||
});
|
||||
const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd);
|
||||
if (snapshot?.git.diffStat) {
|
||||
diffStat = snapshot.git.diffStat;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -5559,6 +5604,7 @@ export class Session {
|
||||
routeStore: this.scriptRouteStore,
|
||||
runtimeStore: this.scriptRuntimeStore,
|
||||
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
||||
gitMetadata: this.resolveWorkspaceScriptGitMetadata(workspace.cwd),
|
||||
resolveHealth: this.resolveScriptHealth ?? undefined,
|
||||
})
|
||||
: [],
|
||||
@@ -5599,19 +5645,7 @@ export class Session {
|
||||
projectRecord?: PersistedProjectRecord | null,
|
||||
): Promise<WorkspaceDescriptorPayload> {
|
||||
const base = await this.describeWorkspaceRecord(workspace, projectRecord);
|
||||
let 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;
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd);
|
||||
if (!snapshot) {
|
||||
return base;
|
||||
}
|
||||
@@ -6104,14 +6138,14 @@ export class Session {
|
||||
private async createPaseoWorktree(
|
||||
input: CreatePaseoWorktreeInput,
|
||||
options?: {
|
||||
resolveRepositoryDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||
},
|
||||
): Promise<CreatePaseoWorktreeResult> {
|
||||
const coreDeps = createWorktreeCoreDeps(this.github);
|
||||
return createPaseoWorktree(input, {
|
||||
const result = await createPaseoWorktree(input, {
|
||||
...coreDeps,
|
||||
...(options?.resolveRepositoryDefaultBranch
|
||||
? { resolveRepositoryDefaultBranch: options.resolveRepositoryDefaultBranch }
|
||||
...(options?.resolveDefaultBranch
|
||||
? { resolveDefaultBranch: options.resolveDefaultBranch }
|
||||
: {}),
|
||||
projectRegistry: this.projectRegistry,
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
@@ -6121,6 +6155,11 @@ export class Session {
|
||||
broadcastWorkspaceUpdate: (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> {
|
||||
@@ -6440,10 +6479,27 @@ export class Session {
|
||||
routeStore: this.scriptRouteStore,
|
||||
runtimeStore: this.scriptRuntimeStore,
|
||||
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
||||
gitMetadata: this.resolveWorkspaceScriptGitMetadata(workspaceDirectory),
|
||||
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 {
|
||||
this.emit({
|
||||
type: "script_status_update",
|
||||
@@ -6474,12 +6530,13 @@ export class Session {
|
||||
if (!workspace) {
|
||||
throw new Error(`Workspace not found: ${request.workspaceId}`);
|
||||
}
|
||||
const gitMetadata = await this.workspaceGitService.getWorkspaceGitMetadata(workspace.cwd);
|
||||
|
||||
const serviceResult = await spawnWorkspaceScript({
|
||||
repoRoot: workspace.cwd,
|
||||
workspaceId: workspace.workspaceId,
|
||||
projectSlug: deriveProjectSlug(workspace.cwd),
|
||||
branchName: readGitCommand(workspace.cwd, "git symbolic-ref --short HEAD"),
|
||||
projectSlug: gitMetadata.projectSlug,
|
||||
branchName: gitMetadata.currentBranch,
|
||||
scriptName: request.scriptName,
|
||||
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
||||
daemonListenHost: this.getDaemonTcpHost?.() ?? null,
|
||||
|
||||
@@ -27,9 +27,11 @@ function createWorkspaceRuntimeSnapshot(
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
isPaseoOwnedWorktree: false,
|
||||
isDirty: false,
|
||||
baseRef: "main",
|
||||
aheadBehind: { ahead: 0, behind: 0 },
|
||||
aheadOfOrigin: 0,
|
||||
behindOfOrigin: 0,
|
||||
hasRemote: true,
|
||||
diffStat: { additions: 1, deletions: 0 },
|
||||
},
|
||||
github: {
|
||||
@@ -313,6 +315,153 @@ describe("workspace git watch targets", () => {
|
||||
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 () => {
|
||||
const { session, emitted, workspaceGitService } = createSessionForWorkspaceGitWatchTests();
|
||||
|
||||
|
||||
@@ -112,6 +112,9 @@ function createNoopWorkspaceGitService() {
|
||||
refreshedAt: null,
|
||||
},
|
||||
}),
|
||||
resolveRepoRemoteUrl: async () => null,
|
||||
resolveRepoRoot: async (cwd: string) => cwd,
|
||||
resolveDefaultBranch: async () => "main",
|
||||
refresh: async () => {},
|
||||
requestWorkingTreeWatch: async (cwd: string) => ({
|
||||
repoRoot: cwd,
|
||||
@@ -139,9 +142,11 @@ function createWorkspaceRuntimeSnapshot(
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
isPaseoOwnedWorktree: false,
|
||||
isDirty: false,
|
||||
baseRef: "main",
|
||||
aheadBehind: { ahead: 0, behind: 0 },
|
||||
aheadOfOrigin: 0,
|
||||
behindOfOrigin: 0,
|
||||
hasRemote: true,
|
||||
diffStat: { additions: 1, deletions: 0 },
|
||||
},
|
||||
github: {
|
||||
|
||||
@@ -82,7 +82,7 @@ describe("deriveProjectSlug", () => {
|
||||
])("slugifies the GitHub repo name from %s", (remoteUrl, expectedSlug) => {
|
||||
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", () => {
|
||||
@@ -93,8 +93,8 @@ describe("deriveProjectSlug", () => {
|
||||
createGitWorkspace("other-fallback", "https://github.com/other/claude-code"),
|
||||
);
|
||||
|
||||
expect(deriveProjectSlug(acmeCwd)).toBe("claude-code");
|
||||
expect(deriveProjectSlug(otherCwd)).toBe("claude-code");
|
||||
expect(deriveProjectSlug(acmeCwd, "https://github.com/acme/claude-code")).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", () => {
|
||||
@@ -104,7 +104,7 @@ describe("deriveProjectSlug", () => {
|
||||
runGit(cwd, ["init"]);
|
||||
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", () => {
|
||||
@@ -118,7 +118,9 @@ describe("deriveProjectSlug", () => {
|
||||
"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", () => {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { execSync } from "child_process";
|
||||
import { basename } from "path";
|
||||
import { slugify } from "../utils/worktree.js";
|
||||
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
|
||||
|
||||
export type WorkspaceGitMetadata = {
|
||||
projectKind: "git" | "directory";
|
||||
@@ -9,23 +7,12 @@ export type WorkspaceGitMetadata = {
|
||||
workspaceDisplayName: string;
|
||||
gitRemote: string | null;
|
||||
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 {
|
||||
let cleaned = remoteUrl.trim();
|
||||
if (!cleaned) {
|
||||
@@ -74,39 +61,48 @@ export function parseGitHubRepoNameFromRemote(remoteUrl: string): string | null
|
||||
return repoName && repoName.length > 0 ? repoName : null;
|
||||
}
|
||||
|
||||
export function deriveProjectSlug(cwd: string): string {
|
||||
const gitRemote = readGitCommand(cwd, "git config --get remote.origin.url");
|
||||
const githubRepoName = gitRemote ? parseGitHubRepoNameFromRemote(gitRemote) : null;
|
||||
export function deriveProjectSlug(cwd: string, remoteUrl: string | null = null): string {
|
||||
const githubRepoName = remoteUrl ? parseGitHubRepoNameFromRemote(remoteUrl) : null;
|
||||
const sourceName = githubRepoName ?? basename(cwd);
|
||||
return slugify(sourceName) || "untitled";
|
||||
}
|
||||
|
||||
export function detectWorkspaceGitMetadata(
|
||||
cwd: string,
|
||||
directoryName: string,
|
||||
): WorkspaceGitMetadata {
|
||||
const gitDir = readGitCommand(cwd, "git rev-parse --git-dir");
|
||||
if (!gitDir) {
|
||||
export function buildWorkspaceGitMetadataFromSnapshot(input: {
|
||||
cwd: string;
|
||||
directoryName: string;
|
||||
isGit: boolean;
|
||||
repoRoot: string | null;
|
||||
mainRepoRoot: string | null;
|
||||
currentBranch: string | null;
|
||||
remoteUrl: string | null;
|
||||
}): WorkspaceGitMetadata {
|
||||
if (!input.isGit) {
|
||||
return {
|
||||
projectKind: "directory",
|
||||
projectDisplayName: directoryName,
|
||||
workspaceDisplayName: directoryName,
|
||||
projectDisplayName: input.directoryName,
|
||||
workspaceDisplayName: input.directoryName,
|
||||
gitRemote: null,
|
||||
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 = gitRemote ? parseGitHubRepoFromRemote(gitRemote) : null;
|
||||
const branchName = readGitCommand(cwd, "git symbolic-ref --short HEAD");
|
||||
const gitCommonDir = readGitCommand(cwd, "git rev-parse --git-common-dir");
|
||||
const isWorktree = gitCommonDir !== null && gitDir !== gitCommonDir;
|
||||
const githubRepo = input.remoteUrl ? parseGitHubRepoFromRemote(input.remoteUrl) : null;
|
||||
const isWorktree =
|
||||
input.mainRepoRoot !== null && input.repoRoot !== null && input.mainRepoRoot !== input.repoRoot;
|
||||
|
||||
return {
|
||||
projectKind: "git",
|
||||
projectDisplayName: githubRepo ?? directoryName,
|
||||
workspaceDisplayName: branchName ?? directoryName,
|
||||
gitRemote,
|
||||
projectDisplayName: githubRepo ?? input.directoryName,
|
||||
workspaceDisplayName: input.currentBranch ?? input.directoryName,
|
||||
gitRemote: input.remoteUrl,
|
||||
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",
|
||||
isPaseoOwnedWorktree: false,
|
||||
isDirty: false,
|
||||
baseRef: "main",
|
||||
aheadBehind: { ahead: 0, behind: 0 },
|
||||
aheadOfOrigin: 0,
|
||||
behindOfOrigin: 0,
|
||||
hasRemote: true,
|
||||
diffStat: { additions: 1, deletions: 0 },
|
||||
},
|
||||
github: {
|
||||
@@ -144,6 +146,7 @@ function createGitHubServiceStub(): GitHubService {
|
||||
return {
|
||||
listPullRequests: vi.fn(async () => []),
|
||||
listIssues: vi.fn(async () => []),
|
||||
searchIssuesAndPrs: vi.fn(async () => ({ items: [], githubFeaturesEnabled: true })),
|
||||
getPullRequest: vi.fn(async () => ({
|
||||
number: 1,
|
||||
title: "PR",
|
||||
@@ -292,7 +295,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
expect(getPullRequestStatus).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);
|
||||
|
||||
checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo"));
|
||||
@@ -319,9 +322,11 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
||||
const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git");
|
||||
|
||||
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
|
||||
const service = createService({
|
||||
getPullRequestStatus,
|
||||
resolveAbsoluteGitDir,
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
@@ -342,9 +347,11 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
||||
const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git");
|
||||
|
||||
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
|
||||
const service = createService({
|
||||
getPullRequestStatus,
|
||||
resolveAbsoluteGitDir,
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
|
||||
const subscription = await service.subscribe({ cwd: "/tmp/repo/." }, vi.fn());
|
||||
@@ -352,8 +359,15 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
expect(subscription.initial).toEqual(createSnapshot("/tmp/repo"));
|
||||
expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo"));
|
||||
|
||||
nowMs += 3_000;
|
||||
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(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
|
||||
@@ -430,8 +444,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
|
||||
expect(subscription.initial.github.pullRequest?.title).toBe("Before refresh");
|
||||
|
||||
service.refresh("/tmp/repo");
|
||||
await (service as any).workspaceTargets.get("/tmp/repo")?.refreshPromise;
|
||||
await service.refresh("/tmp/repo");
|
||||
await flushPromises();
|
||||
|
||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
||||
@@ -459,10 +472,11 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
test("unchanged runtime snapshots do not emit duplicate updates", async () => {
|
||||
const getCheckoutStatus = vi
|
||||
.fn<() => Promise<CheckoutStatusGit>>()
|
||||
.mockResolvedValueOnce(createCheckoutStatus("/tmp/repo"))
|
||||
.mockResolvedValueOnce(createCheckoutStatus("/tmp/repo", { remoteUrl: null }))
|
||||
.mockResolvedValueOnce(
|
||||
createCheckoutStatus("/tmp/repo", {
|
||||
currentBranch: "feature/runtime-payloads",
|
||||
remoteUrl: null,
|
||||
aheadBehind: { ahead: 2, behind: 0 },
|
||||
aheadOfOrigin: 2,
|
||||
}),
|
||||
@@ -470,6 +484,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
.mockResolvedValueOnce(
|
||||
createCheckoutStatus("/tmp/repo", {
|
||||
currentBranch: "feature/runtime-payloads",
|
||||
remoteUrl: null,
|
||||
aheadBehind: { ahead: 2, behind: 0 },
|
||||
aheadOfOrigin: 2,
|
||||
}),
|
||||
@@ -487,10 +502,11 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
|
||||
const service = createService({
|
||||
getCheckoutStatus,
|
||||
getPullRequestStatus,
|
||||
now: () => new Date("2026-04-12T00:00:00.000Z"),
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
|
||||
const listener = vi.fn();
|
||||
@@ -498,12 +514,12 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
|
||||
expect(subscription.initial.git.currentBranch).toBe("main");
|
||||
|
||||
service.refresh("/tmp/repo");
|
||||
await (service as any).workspaceTargets.get("/tmp/repo")?.refreshPromise;
|
||||
nowMs += 3_000;
|
||||
await service.refresh("/tmp/repo");
|
||||
await flushPromises();
|
||||
|
||||
service.refresh("/tmp/repo");
|
||||
await (service as any).workspaceTargets.get("/tmp/repo")?.refreshPromise;
|
||||
nowMs += 3_000;
|
||||
await service.refresh("/tmp/repo");
|
||||
await flushPromises();
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
@@ -511,18 +527,14 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
createSnapshot("/tmp/repo", {
|
||||
git: {
|
||||
currentBranch: "feature/runtime-payloads",
|
||||
remoteUrl: null,
|
||||
aheadBehind: { ahead: 2, behind: 0 },
|
||||
aheadOfOrigin: 2,
|
||||
},
|
||||
github: {
|
||||
pullRequest: {
|
||||
url: "https://github.com/acme/repo/pull/123",
|
||||
title: "Runtime payloads",
|
||||
state: "open",
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/runtime-payloads",
|
||||
isMerged: false,
|
||||
},
|
||||
featuresEnabled: false,
|
||||
pullRequest: null,
|
||||
refreshedAt: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -531,6 +543,32 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
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 () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, "platform", {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -72,6 +72,47 @@ function createTestLogger() {
|
||||
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 {
|
||||
const raw = mkdtempSync(path.join(tmpdir(), prefix));
|
||||
const dir = realpathSync(raw);
|
||||
@@ -222,6 +263,13 @@ describe("WorkspaceReconciliationService", () => {
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
workspaceGitService: createWorkspaceGitServiceStub({
|
||||
[resolved]: {
|
||||
projectKind: "git",
|
||||
projectDisplayName: path.basename(resolved),
|
||||
workspaceDisplayName: "main",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await service.runOnce();
|
||||
@@ -271,6 +319,14 @@ describe("WorkspaceReconciliationService", () => {
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
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();
|
||||
@@ -316,6 +372,13 @@ describe("WorkspaceReconciliationService", () => {
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
workspaceGitService: createWorkspaceGitServiceStub({
|
||||
[dir]: {
|
||||
projectKind: "git",
|
||||
projectDisplayName: path.basename(dir),
|
||||
workspaceDisplayName: "feature-branch",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await service.runOnce();
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
PersistedProjectRecord,
|
||||
PersistedWorkspaceRecord,
|
||||
} 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;
|
||||
|
||||
@@ -37,6 +37,7 @@ export type WorkspaceReconciliationServiceOptions = {
|
||||
logger: pino.Logger;
|
||||
intervalMs?: number;
|
||||
onChanges?: (changes: ReconciliationChange[]) => void;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "getWorkspaceGitMetadata">;
|
||||
};
|
||||
|
||||
export class WorkspaceReconciliationService {
|
||||
@@ -45,6 +46,7 @@ export class WorkspaceReconciliationService {
|
||||
private readonly logger: pino.Logger;
|
||||
private readonly intervalMs: number;
|
||||
private readonly onChanges: ((changes: ReconciliationChange[]) => void) | null;
|
||||
private readonly workspaceGitService: Pick<WorkspaceGitService, "getWorkspaceGitMetadata"> | null;
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private running = false;
|
||||
|
||||
@@ -54,6 +56,7 @@ export class WorkspaceReconciliationService {
|
||||
this.logger = options.logger.child({ module: "workspace-reconciliation" });
|
||||
this.intervalMs = options.intervalMs ?? DEFAULT_RECONCILE_INTERVAL_MS;
|
||||
this.onChanges = options.onChanges ?? null;
|
||||
this.workspaceGitService = options.workspaceGitService ?? null;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
@@ -155,7 +158,7 @@ export class WorkspaceReconciliationService {
|
||||
|
||||
const directoryName =
|
||||
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<
|
||||
Pick<PersistedProjectRecord, "kind" | "displayName" | "rootPath">
|
||||
@@ -198,7 +201,7 @@ export class WorkspaceReconciliationService {
|
||||
if (!existsSync(workspace.cwd)) continue;
|
||||
|
||||
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) {
|
||||
const timestamp = new Date().toISOString();
|
||||
@@ -223,4 +226,21 @@ export class WorkspaceReconciliationService {
|
||||
|
||||
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,
|
||||
},
|
||||
}),
|
||||
resolveRepoRemoteUrl: async () => null,
|
||||
refresh: async () => {},
|
||||
requestWorkingTreeWatch: async () => ({
|
||||
repoRoot: null,
|
||||
|
||||
@@ -11,16 +11,20 @@ import {
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
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 { 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 {
|
||||
return {
|
||||
listPullRequests: async () => [],
|
||||
listIssues: async () => [],
|
||||
searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }),
|
||||
getPullRequest: async ({ number }) => ({
|
||||
number,
|
||||
title: `PR ${number}`,
|
||||
@@ -48,7 +52,10 @@ function createCoreDeps(options?: {
|
||||
}) {
|
||||
return {
|
||||
github: options?.github ?? createGitHubServiceStub(),
|
||||
resolveRepositoryDefaultBranch: async () => "main",
|
||||
workspaceGitService: {
|
||||
resolveRepoRoot: async (cwd: string) => cwd,
|
||||
},
|
||||
resolveDefaultBranch: async () => "main",
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
|
||||
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 type { GitHubService } from "../services/github-service.js";
|
||||
import { getCheckoutStatus, resolveRepositoryDefaultBranch } from "../utils/checkout-git.js";
|
||||
import {
|
||||
createWorktree,
|
||||
resolveExistingWorktreeForSlug,
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
type ResolveWorktreeCreationIntentInput,
|
||||
type WorktreeCreationIntent,
|
||||
} from "./resolve-worktree-creation-intent.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
|
||||
export interface CreateWorktreeCoreInput extends ResolveWorktreeCreationIntentInput {
|
||||
cwd: string;
|
||||
@@ -23,7 +23,8 @@ export interface CreateWorktreeCoreInput extends ResolveWorktreeCreationIntentIn
|
||||
|
||||
export interface CreateWorktreeCoreDeps {
|
||||
github: GitHubService;
|
||||
resolveRepositoryDefaultBranch: (repoRoot: string) => Promise<string>;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot" | "resolveDefaultBranch">;
|
||||
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||
generateBranchName: (seed: string | undefined) => string;
|
||||
}
|
||||
|
||||
@@ -38,13 +39,16 @@ export async function createWorktreeCore(
|
||||
input: CreateWorktreeCoreInput,
|
||||
deps: CreateWorktreeCoreDeps,
|
||||
): Promise<CreateWorktreeCoreResult> {
|
||||
const repoRoot = await resolveWorktreeRepoRoot(input);
|
||||
const repoRoot = await resolveWorktreeRepoRoot(input, deps.workspaceGitService);
|
||||
const requestedSlug = input.worktreeSlug ? slugify(input.worktreeSlug) : undefined;
|
||||
|
||||
const intent = await resolveWorktreeCreationIntent(
|
||||
{ ...input, worktreeSlug: requestedSlug },
|
||||
repoRoot,
|
||||
deps,
|
||||
{
|
||||
...deps,
|
||||
resolveDefaultBranch: (root) => resolveDefaultBranch(root, deps),
|
||||
},
|
||||
);
|
||||
let normalizedSlug: string;
|
||||
|
||||
@@ -89,31 +93,32 @@ export async function createWorktreeCore(
|
||||
export function createWorktreeCoreDeps(github: GitHubService): CreateWorktreeCoreDeps {
|
||||
return {
|
||||
github,
|
||||
resolveRepositoryDefaultBranch: resolveDefaultBranch,
|
||||
generateBranchName: (seed) => slugify(seed ?? uuidv4()),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveDefaultBranch(repoRoot: string): Promise<string> {
|
||||
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot);
|
||||
async function resolveDefaultBranch(
|
||||
repoRoot: string,
|
||||
deps: CreateWorktreeCoreDeps,
|
||||
): Promise<string> {
|
||||
const baseBranch = deps.resolveDefaultBranch
|
||||
? await deps.resolveDefaultBranch(repoRoot)
|
||||
: await deps.workspaceGitService?.resolveDefaultBranch(repoRoot);
|
||||
if (!baseBranch) {
|
||||
throw new Error("Unable to resolve repository default branch");
|
||||
}
|
||||
return baseBranch;
|
||||
}
|
||||
|
||||
async function resolveWorktreeRepoRoot(input: CreateWorktreeCoreInput): Promise<string> {
|
||||
const checkout = await getCheckoutStatus(
|
||||
input.cwd,
|
||||
input.paseoHome ? { paseoHome: input.paseoHome } : undefined,
|
||||
);
|
||||
if (!checkout.isGit) {
|
||||
throw new Error("Create worktree requires a git repository");
|
||||
export async function resolveWorktreeRepoRoot(
|
||||
input: Pick<CreateWorktreeCoreInput, "cwd" | "paseoHome">,
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">,
|
||||
): Promise<string> {
|
||||
if (!workspaceGitService) {
|
||||
throw new Error("Create worktree requires WorkspaceGitService");
|
||||
}
|
||||
|
||||
return checkout.isPaseoOwnedWorktree
|
||||
? (checkout.mainRepoRoot ?? checkout.repoRoot ?? input.cwd)
|
||||
: (checkout.repoRoot ?? input.cwd);
|
||||
return workspaceGitService.resolveRepoRoot(input.cwd);
|
||||
}
|
||||
|
||||
function validateWorktreeSlug(slug: string): string {
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
archivePaseoWorktree,
|
||||
buildAgentSessionConfig,
|
||||
handlePaseoWorktreeArchiveRequest,
|
||||
handlePaseoWorktreeListRequest,
|
||||
resolveGitCreateBaseBranch,
|
||||
runWorktreeSetupInBackground,
|
||||
handleCreatePaseoWorktreeRequest,
|
||||
handleWorkspaceSetupStatusRequest,
|
||||
@@ -81,6 +83,7 @@ function createGitHubServiceStub(): GitHubService {
|
||||
return {
|
||||
listPullRequests: async () => [],
|
||||
listIssues: async () => [],
|
||||
searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }),
|
||||
getPullRequest: async ({ number }) => ({
|
||||
number,
|
||||
title: `PR ${number}`,
|
||||
@@ -213,8 +216,8 @@ function createPaseoWorktreeForTest(options: {
|
||||
const coreDeps = createWorktreeCoreDeps(createGitHubServiceStub());
|
||||
return createPaseoWorktreeService(input, {
|
||||
...coreDeps,
|
||||
...(serviceOptions?.resolveRepositoryDefaultBranch
|
||||
? { resolveRepositoryDefaultBranch: serviceOptions.resolveRepositoryDefaultBranch }
|
||||
...(serviceOptions?.resolveDefaultBranch
|
||||
? { resolveDefaultBranch: serviceOptions.resolveDefaultBranch }
|
||||
: {}),
|
||||
projectRegistry: {
|
||||
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"> {
|
||||
return {
|
||||
list: async (): Promise<StoredAgentRecord[]> => [],
|
||||
@@ -1082,6 +1156,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
{
|
||||
paseoHome,
|
||||
sessionLogger: createLogger(),
|
||||
workspaceGitService: {
|
||||
resolveRepoRoot: vi.fn(async () => repoDir),
|
||||
resolveDefaultBranch: vi.fn(async () => "main"),
|
||||
} as any,
|
||||
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
|
||||
checkoutExistingBranch: async () => {
|
||||
throw new Error("should not checkout existing branch");
|
||||
@@ -1166,7 +1244,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const resolveRepositoryDefaultBranch = vi.fn(async () => "main");
|
||||
const resolveDefaultBranch = vi.fn(async () => "main");
|
||||
|
||||
const result = await createPaseoWorktreeForTest({ paseoHome })(
|
||||
{
|
||||
@@ -1176,7 +1254,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
},
|
||||
{ resolveRepositoryDefaultBranch },
|
||||
{ resolveDefaultBranch },
|
||||
);
|
||||
|
||||
expect(result.intent).toMatchObject({
|
||||
@@ -1184,7 +1262,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
baseBranch: "main",
|
||||
newBranchName: "resolver-feature",
|
||||
});
|
||||
expect(resolveRepositoryDefaultBranch).toHaveBeenCalledWith(repoDir);
|
||||
expect(resolveDefaultBranch).toHaveBeenCalledWith(repoDir);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1509,6 +1587,53 @@ describe("archivePaseoWorktree", () => {
|
||||
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 () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
@@ -26,14 +26,12 @@ import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import type { ScriptRouteStore } from "./script-proxy.js";
|
||||
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.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 { expandTilde } from "../utils/path.js";
|
||||
import {
|
||||
deletePaseoWorktree,
|
||||
getWorktreeSetupCommands,
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
listPaseoWorktrees,
|
||||
resolvePaseoWorktreeRootForCwd,
|
||||
resolveWorktreeRuntimeEnv,
|
||||
runWorktreeSetupCommands,
|
||||
@@ -73,7 +71,7 @@ type BuildAgentSessionConfigDependencies = {
|
||||
createPaseoWorktree: (
|
||||
input: CreatePaseoWorktreeInput,
|
||||
options?: {
|
||||
resolveRepositoryDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||
},
|
||||
) => Promise<CreatePaseoWorktreeResult>;
|
||||
checkoutExistingBranch: (cwd: string, branch: string) => Promise<CheckoutExistingBranchResult>;
|
||||
@@ -88,6 +86,7 @@ type BuildAgentSessionConfigDependencies = {
|
||||
type ArchivePaseoWorktreeDependencies = {
|
||||
paseoHome?: string;
|
||||
github: GitHubService;
|
||||
workspaceGitService: WorkspaceGitService;
|
||||
agentManager: Pick<AgentManager, "listAgents" | "closeAgent">;
|
||||
agentStorage: Pick<AgentStorage, "list" | "remove">;
|
||||
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
|
||||
@@ -186,7 +185,7 @@ export async function buildAgentSessionConfig(
|
||||
paseoHome: dependencies.paseoHome,
|
||||
},
|
||||
{
|
||||
resolveRepositoryDefaultBranch: normalized.baseBranch
|
||||
resolveDefaultBranch: normalized.baseBranch
|
||||
? async () => normalized.baseBranch!
|
||||
: (repoRoot) =>
|
||||
resolveGitCreateBaseBranch(
|
||||
@@ -311,37 +310,21 @@ export function assertSafeGitRef(ref: string, label: string): void {
|
||||
export async function resolveGitCreateBaseBranch(
|
||||
cwd: string,
|
||||
workspaceGitService?: WorkspaceGitService,
|
||||
paseoHome?: string,
|
||||
_paseoHome?: string,
|
||||
): Promise<string> {
|
||||
let repoRoot = cwd;
|
||||
if (workspaceGitService) {
|
||||
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
|
||||
? (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);
|
||||
if (!workspaceGitService) {
|
||||
throw new Error("WorkspaceGitService is required to resolve the repository root");
|
||||
}
|
||||
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot);
|
||||
if (!baseBranch) {
|
||||
throw new Error("Unable to resolve repository default branch");
|
||||
}
|
||||
return baseBranch;
|
||||
|
||||
return workspaceGitService.resolveDefaultBranch(cwd);
|
||||
}
|
||||
|
||||
export async function handlePaseoWorktreeListRequest(
|
||||
dependencies: { emit: EmitSessionMessage; paseoHome?: string },
|
||||
dependencies: {
|
||||
emit: EmitSessionMessage;
|
||||
paseoHome?: string;
|
||||
workspaceGitService: WorkspaceGitService;
|
||||
},
|
||||
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_list_request" }>,
|
||||
): Promise<void> {
|
||||
const { requestId } = msg;
|
||||
@@ -359,7 +342,7 @@ export async function handlePaseoWorktreeListRequest(
|
||||
}
|
||||
|
||||
try {
|
||||
const worktrees = await listPaseoWorktrees({ cwd, paseoHome: dependencies.paseoHome });
|
||||
const worktrees = await dependencies.workspaceGitService.listWorktrees(cwd);
|
||||
dependencies.emit({
|
||||
type: "paseo_worktree_list_response",
|
||||
payload: {
|
||||
@@ -483,6 +466,20 @@ export async function archivePaseoWorktree(
|
||||
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) {
|
||||
dependencies.github.invalidate({ cwd });
|
||||
}
|
||||
@@ -529,10 +526,7 @@ export async function handlePaseoWorktreeArchiveRequest(
|
||||
if (!repoRoot || !msg.branchName) {
|
||||
throw new Error("worktreePath or repoRoot+branchName is required");
|
||||
}
|
||||
const worktrees = await listPaseoWorktrees({
|
||||
cwd: repoRoot,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
const worktrees = await dependencies.workspaceGitService.listWorktrees(repoRoot);
|
||||
const match = worktrees.find((entry) => entry.branchName === msg.branchName);
|
||||
if (!match) {
|
||||
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 {
|
||||
GitHubAuthenticationError,
|
||||
GitHubCliMissingError,
|
||||
GitHubCommandError,
|
||||
computeGithubNextInterval,
|
||||
createGitHubService,
|
||||
resolveGitHubRepo,
|
||||
type GitHubCommandRunner,
|
||||
type GitHubCommandRunnerOptions,
|
||||
type GitHubCurrentPullRequestStatus,
|
||||
type GitHubReadOptions,
|
||||
} from "./github-service.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 {
|
||||
args: 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 {
|
||||
return new GitHubCommandError({
|
||||
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 {
|
||||
return JSON.stringify({
|
||||
data: {
|
||||
@@ -175,6 +244,212 @@ function pullRequestTimelineJson(overrides: Record<string, unknown> = {}): strin
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const runner = createRunner([pullRequestTimelineJson()]);
|
||||
const service = createGitHubService({
|
||||
@@ -1216,4 +1491,230 @@ describe("GitHubService", () => {
|
||||
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 type { GitHubSearchKind } from "../shared/messages.js";
|
||||
import { findExecutable } from "../utils/executable.js";
|
||||
import { runGitCommand } from "../utils/run-git-command.js";
|
||||
import { execCommand } from "../utils/spawn.js";
|
||||
|
||||
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 = {
|
||||
...process.env,
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
@@ -343,30 +346,63 @@ export interface GitHubPullRequestCreateResult {
|
||||
number: number;
|
||||
}
|
||||
|
||||
export interface ListGitHubPullRequestsOptions {
|
||||
export type GitHubReadOptions =
|
||||
| {
|
||||
force?: false;
|
||||
reason?: string;
|
||||
}
|
||||
| {
|
||||
force: true;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type ListGitHubPullRequestsOptions = {
|
||||
cwd: string;
|
||||
query?: string;
|
||||
limit?: number;
|
||||
}
|
||||
} & GitHubReadOptions;
|
||||
|
||||
export interface ListGitHubIssuesOptions {
|
||||
export type ListGitHubIssuesOptions = {
|
||||
cwd: string;
|
||||
query?: string;
|
||||
limit?: number;
|
||||
}
|
||||
} & GitHubReadOptions;
|
||||
|
||||
export interface GetGitHubPullRequestOptions {
|
||||
export type GetGitHubPullRequestOptions = {
|
||||
cwd: string;
|
||||
number: number;
|
||||
}
|
||||
} & GitHubReadOptions;
|
||||
|
||||
export interface GetGitHubPullRequestTimelineOptions {
|
||||
export type GetGitHubPullRequestTimelineOptions = {
|
||||
cwd: string;
|
||||
prNumber: number;
|
||||
repoOwner: 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 {
|
||||
cwd: string;
|
||||
repo: string;
|
||||
@@ -381,18 +417,28 @@ export interface GitHubService {
|
||||
listIssues(options: ListGitHubIssuesOptions): Promise<GitHubIssueSummary[]>;
|
||||
getPullRequest(options: GetGitHubPullRequestOptions): Promise<GitHubPullRequestSummary>;
|
||||
getPullRequestHeadRef(options: GetGitHubPullRequestOptions): Promise<string>;
|
||||
getCurrentPullRequestStatus(options: {
|
||||
cwd: string;
|
||||
headRef: string;
|
||||
}): Promise<GitHubCurrentPullRequestStatus | null>;
|
||||
getCurrentPullRequestStatus(
|
||||
options: {
|
||||
cwd: string;
|
||||
headRef: string;
|
||||
} & GitHubReadOptions,
|
||||
): Promise<GitHubCurrentPullRequestStatus | null>;
|
||||
getPullRequestTimeline(
|
||||
options: GetGitHubPullRequestTimelineOptions,
|
||||
): Promise<GitHubPullRequestTimeline>;
|
||||
searchIssuesAndPrs(options: SearchGitHubIssuesAndPrsOptions): Promise<GitHubSearchResult>;
|
||||
createPullRequest(
|
||||
options: CreateGitHubPullRequestOptions,
|
||||
): 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;
|
||||
dispose?(): void;
|
||||
}
|
||||
|
||||
export class GitHubCliMissingError extends Error {
|
||||
@@ -453,6 +499,18 @@ type CurrentPullRequestStatusItem = z.infer<typeof CurrentPullRequestStatusSchem
|
||||
interface InFlightCacheEntry {
|
||||
cwd: string;
|
||||
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 {
|
||||
@@ -469,13 +527,20 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
};
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
const inFlight = new Map<string, InFlightCacheEntry>();
|
||||
const pollTargets = new Map<string, GitHubPollTarget>();
|
||||
let api!: GitHubService;
|
||||
|
||||
async function cached<T>(params: {
|
||||
cwd: string;
|
||||
method: string;
|
||||
args: unknown;
|
||||
readOptions?: GitHubReadOptions;
|
||||
load: () => Promise<T>;
|
||||
}): Promise<T> {
|
||||
if (params.readOptions?.force && !params.readOptions.reason) {
|
||||
throw new Error("GitHubService forced read requires a reason");
|
||||
}
|
||||
|
||||
const key = buildCacheKey({
|
||||
cwd: params.cwd,
|
||||
method: params.method,
|
||||
@@ -483,12 +548,12 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
});
|
||||
const cachedEntry = cache.get(key);
|
||||
const now = deps.now();
|
||||
if (cachedEntry && cachedEntry.expiresAt > now) {
|
||||
if (!params.readOptions?.force && cachedEntry && cachedEntry.expiresAt > now) {
|
||||
return cachedEntry.value as T;
|
||||
}
|
||||
|
||||
const existing = inFlight.get(key);
|
||||
if (existing) {
|
||||
if (existing && (!params.readOptions?.force || existing.force)) {
|
||||
return existing.promise as Promise<T>;
|
||||
}
|
||||
|
||||
@@ -509,7 +574,11 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
return cached({
|
||||
cwd: options.cwd,
|
||||
method: "listPullRequests",
|
||||
args: { query: options.query ?? "", limit: options.limit ?? 20 },
|
||||
readOptions: options,
|
||||
load: async () => {
|
||||
const stdout = await run(
|
||||
[
|
||||
@@ -559,6 +709,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
cwd: options.cwd,
|
||||
method: "listIssues",
|
||||
args: { query: options.query ?? "", limit: options.limit ?? 20 },
|
||||
readOptions: options,
|
||||
load: async () => {
|
||||
const stdout = await run(
|
||||
[
|
||||
@@ -583,6 +734,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
cwd: options.cwd,
|
||||
method: "getPullRequest",
|
||||
args: { number: options.number },
|
||||
readOptions: options,
|
||||
load: async () => {
|
||||
const stdout = await run(
|
||||
[
|
||||
@@ -609,6 +761,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
cwd: options.cwd,
|
||||
method: "getCurrentPullRequestStatus",
|
||||
args: { headRef: options.headRef },
|
||||
readOptions: options,
|
||||
load: async () => {
|
||||
return resolveCurrentPullRequestView({
|
||||
cwd: options.cwd,
|
||||
@@ -616,6 +769,14 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
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,
|
||||
method: "getPullRequestTimeline",
|
||||
args: { prNumber: options.prNumber },
|
||||
readOptions: options,
|
||||
load: async () => {
|
||||
try {
|
||||
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) {
|
||||
const args = [
|
||||
"api",
|
||||
@@ -689,6 +941,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
cwd: options.cwd,
|
||||
method: "isAuthenticated",
|
||||
args: {},
|
||||
readOptions: options,
|
||||
load: async () => {
|
||||
try {
|
||||
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) {
|
||||
// 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.
|
||||
@@ -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> {
|
||||
@@ -1393,13 +1733,20 @@ function mapReviewDecision(value: unknown): PullRequestReviewDecision {
|
||||
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 {
|
||||
const { stdout } = await runGitCommand(["config", "--get", "remote.origin.url"], {
|
||||
const remoteUrl = await options.workspaceGitService.resolveRepoRemoteUrl(
|
||||
cwd,
|
||||
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
|
||||
});
|
||||
return parseGitHubRepoFromRemote(stdout.trim());
|
||||
options.readOptions,
|
||||
);
|
||||
return parseGitHubRepoFromRemote(remoteUrl?.trim() ?? "");
|
||||
} catch {
|
||||
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({
|
||||
subscriptionId: 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({
|
||||
type: z.literal("checkout_pr_status_response"),
|
||||
payload: z.object({
|
||||
cwd: z.string(),
|
||||
status: CheckoutPrStatusSchema.nullable(),
|
||||
githubFeaturesEnabled: z.boolean(),
|
||||
error: CheckoutErrorSchema.nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
payload: CheckoutPrStatusPayloadSchema,
|
||||
});
|
||||
|
||||
const PullRequestTimelineKnownErrorSchema = z.discriminatedUnion("kind", [
|
||||
@@ -3092,6 +3109,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
AgentArchivedMessageSchema,
|
||||
CloseItemsResponseSchema,
|
||||
CheckoutStatusResponseSchema,
|
||||
CheckoutStatusUpdateSchema,
|
||||
SubscribeCheckoutDiffResponseSchema,
|
||||
CheckoutDiffUpdateSchema,
|
||||
CheckoutCommitResponseSchema,
|
||||
@@ -3318,6 +3336,7 @@ export type SetAgentFeatureRequestMessage = z.infer<typeof SetAgentFeatureReques
|
||||
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
|
||||
export type CheckoutStatusRequest = z.infer<typeof CheckoutStatusRequestSchema>;
|
||||
export type CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>;
|
||||
export type CheckoutStatusUpdate = z.infer<typeof CheckoutStatusUpdateSchema>;
|
||||
export type SubscribeCheckoutDiffRequest = z.infer<typeof SubscribeCheckoutDiffRequestSchema>;
|
||||
export type UnsubscribeCheckoutDiffRequest = z.infer<typeof UnsubscribeCheckoutDiffRequestSchema>;
|
||||
export type SubscribeCheckoutDiffResponse = z.infer<typeof SubscribeCheckoutDiffResponseSchema>;
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
parseWorktreeList,
|
||||
isPaseoWorktreePath,
|
||||
isDescendantPath,
|
||||
searchGitHubIssuesAndPrs,
|
||||
warmCheckoutShortstatInBackground,
|
||||
} from "./checkout-git.js";
|
||||
import {
|
||||
@@ -96,6 +95,7 @@ function createGitHubServiceForStatus(
|
||||
return {
|
||||
listPullRequests: async () => [],
|
||||
listIssues: async () => [],
|
||||
searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }),
|
||||
getPullRequest: async () => ({
|
||||
number: 1,
|
||||
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", () => {
|
||||
let tempDir: string;
|
||||
let repoDir: string;
|
||||
@@ -313,6 +334,164 @@ const x = 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 () => {
|
||||
const remoteDir = join(tempDir, "remote.git");
|
||||
const cloneDir = join(tempDir, "clone");
|
||||
@@ -1125,129 +1304,6 @@ const x = 1;
|
||||
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 () => {
|
||||
execSync("git checkout -b feature", { 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 { TTLCache } from "@isaacs/ttlcache";
|
||||
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 {
|
||||
GitHubAuthenticationError,
|
||||
GitHubCliMissingError,
|
||||
createGitHubService,
|
||||
resolveGitHubRepo,
|
||||
type GitHubRepoRemoteUrlResolver,
|
||||
type GitHubService,
|
||||
} from "../services/github-service.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);
|
||||
const shortstatInFlight = new Map<string, Promise<CheckoutShortstat | null>>();
|
||||
|
||||
interface CheckoutReadCacheOptions {
|
||||
force?: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
function createPullRequestStatusCache(ttlMs: number) {
|
||||
return new TTLCache<string, PullRequestStatusResult>({
|
||||
ttl: ttlMs,
|
||||
@@ -1317,6 +1322,44 @@ export interface CheckoutShortstat {
|
||||
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(
|
||||
cwd: string,
|
||||
context?: CheckoutContext,
|
||||
@@ -1331,63 +1374,65 @@ async function getCheckoutShortstatUncached(
|
||||
const localBaseRef = configured.baseRef ?? (await resolveBaseRef(cwd));
|
||||
const currentBranch = await getCurrentBranch(cwd);
|
||||
|
||||
let diffTarget: string;
|
||||
let comparisonRef: string;
|
||||
|
||||
if (currentBranch && localBaseRef && currentBranch !== localBaseRef) {
|
||||
// Feature branch: diff against the merge-base with the base branch
|
||||
const comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, localBaseRef);
|
||||
|
||||
try {
|
||||
const { stdout } = await runGitCommand(["merge-base", "HEAD", comparisonBaseRef], {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const mergeBase = stdout.trim();
|
||||
if (!mergeBase) {
|
||||
return null;
|
||||
}
|
||||
diffTarget = mergeBase;
|
||||
comparisonRef = await resolveBestComparisonBaseRef(cwd, localBaseRef);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
} 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}`);
|
||||
if (!hasOrigin) {
|
||||
return null;
|
||||
}
|
||||
diffTarget = `origin/${currentBranch}`;
|
||||
comparisonRef = `origin/${currentBranch}`;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Omit HEAD so the diff includes uncommitted (staged + unstaged) changes
|
||||
const { stdout } = await runGitCommand(["diff", "--shortstat", diffTarget], {
|
||||
const { stdout: mergeBaseOut } = await runGitCommand(["merge-base", "HEAD", comparisonRef], {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const text = stdout.trim();
|
||||
if (!text) {
|
||||
const mergeBase = mergeBaseOut.trim();
|
||||
if (!mergeBase) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
const addMatch = text.match(/(\d+)\s+insertion/);
|
||||
if (addMatch) {
|
||||
additions = Number.parseInt(addMatch[1]!, 10);
|
||||
}
|
||||
const delMatch = text.match(/(\d+)\s+deletion/);
|
||||
if (delMatch) {
|
||||
deletions = Number.parseInt(delMatch[1]!, 10);
|
||||
}
|
||||
|
||||
if (additions === 0 && deletions === 0) {
|
||||
const { stdout: aheadBehindOut } = await runGitCommand(
|
||||
["rev-list", "--left-right", "--count", `HEAD...${comparisonRef}`],
|
||||
{ cwd, env: READ_ONLY_GIT_ENV },
|
||||
);
|
||||
const [headOnlyRaw, comparisonOnlyRaw] = aheadBehindOut.trim().split(/\s+/);
|
||||
const headOnly = Number.parseInt(headOnlyRaw ?? "0", 10);
|
||||
const comparisonOnly = Number.parseInt(comparisonOnlyRaw ?? "0", 10);
|
||||
if (Number.isNaN(headOnly) || Number.isNaN(comparisonOnly)) {
|
||||
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 {
|
||||
return null;
|
||||
}
|
||||
@@ -1396,16 +1441,19 @@ async function getCheckoutShortstatUncached(
|
||||
function getOrLoadCheckoutShortstat(
|
||||
cwd: string,
|
||||
context?: CheckoutContext,
|
||||
options?: CheckoutReadCacheOptions,
|
||||
): Promise<CheckoutShortstat | null> {
|
||||
const cacheKey = getShortstatCacheKey(cwd);
|
||||
const cached = shortstatCache.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return Promise.resolve(cached);
|
||||
}
|
||||
if (!options?.force) {
|
||||
const cached = shortstatCache.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return Promise.resolve(cached);
|
||||
}
|
||||
|
||||
const existing = shortstatInFlight.get(cacheKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
const existing = shortstatInFlight.get(cacheKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
const load = getCheckoutShortstatUncached(cwd, context)
|
||||
@@ -1424,8 +1472,9 @@ function getOrLoadCheckoutShortstat(
|
||||
export async function getCheckoutShortstat(
|
||||
cwd: string,
|
||||
context?: CheckoutContext,
|
||||
options?: CheckoutReadCacheOptions,
|
||||
): Promise<CheckoutShortstat | null> {
|
||||
return getOrLoadCheckoutShortstat(cwd, context);
|
||||
return getOrLoadCheckoutShortstat(cwd, context, options);
|
||||
}
|
||||
|
||||
export function getCachedCheckoutShortstat(cwd: string): CheckoutShortstat | null | undefined {
|
||||
@@ -1996,9 +2045,10 @@ export async function createPullRequest(
|
||||
cwd: string,
|
||||
options: CreatePullRequestOptions,
|
||||
github: GitHubService = createGitHubService(),
|
||||
workspaceGitService: GitHubRepoRemoteUrlResolver,
|
||||
): Promise<{ url: string; number: number }> {
|
||||
await requireGitRepo(cwd);
|
||||
const repo = await resolveGitHubRepo(cwd);
|
||||
const repo = await resolveGitHubRepo(cwd, { workspaceGitService });
|
||||
if (!repo) {
|
||||
throw new Error("Unable to determine GitHub repo from git remote");
|
||||
}
|
||||
@@ -2034,19 +2084,22 @@ export async function createPullRequest(
|
||||
export async function getPullRequestStatus(
|
||||
cwd: string,
|
||||
github: GitHubService = createGitHubService(),
|
||||
options?: CheckoutReadCacheOptions,
|
||||
): Promise<PullRequestStatusResult> {
|
||||
const cacheKey = getPullRequestStatusCacheKey(cwd);
|
||||
const cached = pullRequestStatusCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
if (!options?.force) {
|
||||
const cached = pullRequestStatusCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const existing = pullRequestStatusInFlight.get(cacheKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
const existing = pullRequestStatusInFlight.get(cacheKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const lookup = getPullRequestStatusUncached(cwd, github)
|
||||
const lookup = getPullRequestStatusUncached(cwd, github, options)
|
||||
.then((status) => {
|
||||
pullRequestStatusCache.set(cacheKey, status);
|
||||
return status;
|
||||
@@ -2062,6 +2115,7 @@ export async function getPullRequestStatus(
|
||||
async function getPullRequestStatusUncached(
|
||||
cwd: string,
|
||||
github: GitHubService,
|
||||
options?: CheckoutReadCacheOptions,
|
||||
): Promise<PullRequestStatusResult> {
|
||||
await requireGitRepo(cwd);
|
||||
const head = await getCurrentBranch(cwd);
|
||||
@@ -2072,7 +2126,11 @@ async function getPullRequestStatusUncached(
|
||||
};
|
||||
}
|
||||
try {
|
||||
const status = await github.getCurrentPullRequestStatus({ cwd, headRef: head });
|
||||
const status = await github.getCurrentPullRequestStatus({
|
||||
cwd,
|
||||
headRef: head,
|
||||
reason: options?.reason,
|
||||
});
|
||||
return {
|
||||
status,
|
||||
githubFeaturesEnabled: true,
|
||||
@@ -2084,107 +2142,3 @@ async function getPullRequestStatusUncached(
|
||||
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