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:
Mohamed Boudra
2026-04-19 22:35:26 +07:00
parent 128d6668ae
commit 5a2708653c
50 changed files with 6320 additions and 1073 deletions
@@ -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,
};
}
+194 -3
View File
@@ -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;
+4 -5
View File
@@ -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;
+3 -6
View File
@@ -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 });
+63 -14
View File
@@ -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,
+14 -12
View File
@@ -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
+254 -197
View File
@@ -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",
);
});
});
+22 -17
View File
@@ -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);
+29 -35
View File
@@ -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",
);
});
});
+369 -22
View File
@@ -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;
}
+53 -34
View File
@@ -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>;
+180 -124
View File
@@ -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 });
+110 -156
View File
@@ -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;
}
}