Compare commits

...

2 Commits

Author SHA1 Message Date
Mohamed Boudra
6a298b8f70 fix: always use V2 lazy diff RPCs in new clients
Back-compat is old clients talking to new daemons, not the other way
around. New clients should always use the V2 lazy diff path — there's
no need to fall back to V1 based on a feature flag.

- Remove useLazyDiff feature gate from git-diff-pane
- Always use useCheckoutDiffMetadataQuery (V2)
- Remove useCheckoutDiffQuery (V1) usage from the component
- Remove supportsLazyDiffRpcs utility (no longer needed client-side)
- Server still advertises the flag and serves V1 for old clients
2026-04-14 09:36:38 +07:00
Mohamed Boudra
bf1d5d2ab2 feat: lazy-load diff RPCs for mobile performance
Opening a workspace with a large diff blocked the JS thread because the
full structured diff (hunks, lines, syntax tokens) was sent over the
wire and parsed via JSON.parse + Zod safeParse on the main thread.

Add namespaced checkout RPCs that split diff delivery into two phases:

1. **Metadata-only snapshots** (`checkout/subscribe_diff` →
   `checkout/diff_snapshot`) — lightweight file list with status, path,
   insertions/deletions, and a fingerprint. Sent on every file-system
   change. No hunks, no tokens.

2. **On-demand hunk fetching** (`checkout/get_file_hunks` →
   `checkout/file_hunks_response`) — full structured diff for a single
   file, fetched when the user expands it. Served from an in-memory
   cache with a single-file git fallback.

Feature detection via `server_info.features.lazyDiffRpcs`. New clients
fall back to the V1 full-diff path when talking to older daemons. Old
clients are completely unaffected — all new schema fields are optional
and new message types are additive.

Per-file fingerprints (hash of hunks excluding tokens) drive React Query
cache invalidation so expanded files auto-refresh only when their actual
content changes.
2026-04-14 08:59:36 +07:00
17 changed files with 1966 additions and 22 deletions

View File

@@ -32,11 +32,15 @@ import {
} from "lucide-react-native";
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
import {
useCheckoutDiffQuery,
type ParsedDiffFile,
type DiffLine,
type HighlightToken,
} from "@/hooks/use-checkout-diff-query";
import {
useCheckoutDiffMetadataQuery,
type DiffFileMetadata,
} from "@/hooks/use-checkout-diff-metadata-query";
import { useFileHunks } from "@/hooks/use-file-hunks";
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
@@ -130,8 +134,16 @@ function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) {
);
}
interface DiffFileHeaderFields {
path: string;
isNew: boolean;
isDeleted: boolean;
additions: number;
deletions: number;
}
interface DiffFileSectionProps {
file: ParsedDiffFile;
file: DiffFileHeaderFields;
isExpanded: boolean;
onToggle: (path: string) => void;
onHeaderHeightChange?: (path: string, height: number) => void;
@@ -617,6 +629,109 @@ function DiffFileBody({
);
}
interface LazyDiffFileBodyProps {
file: DiffFileMetadata;
serverId: string;
cwd: string;
mode: "uncommitted" | "base";
baseRef?: string;
ignoreWhitespace?: boolean;
layout: "unified" | "split";
wrapLines: boolean;
onBodyHeightChange?: (path: string, height: number) => void;
testID?: string;
}
function LazyDiffFileBody({
file,
serverId,
cwd,
mode,
baseRef,
ignoreWhitespace,
layout,
wrapLines,
onBodyHeightChange,
testID,
}: LazyDiffFileBodyProps) {
const {
file: resolvedFile,
isLoading,
isError,
error,
} = useFileHunks({
serverId,
cwd,
mode,
baseRef,
ignoreWhitespace,
file,
enabled: true,
});
if (file.status === "binary" || file.status === "too_large") {
return (
<View
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
onLayout={(event) => {
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
}}
testID={testID}
>
<View style={styles.statusMessageContainer}>
<Text style={styles.statusMessageText}>
{file.status === "binary" ? "Binary file" : "Diff too large to display"}
</Text>
</View>
</View>
);
}
if (isLoading) {
return (
<View
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
onLayout={(event) => {
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
}}
testID={testID}
>
<View style={styles.statusMessageContainer}>
<ActivityIndicator size="small" />
</View>
</View>
);
}
if (isError || !resolvedFile) {
return (
<View
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
onLayout={(event) => {
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
}}
testID={testID}
>
<View style={styles.statusMessageContainer}>
<Text style={styles.statusMessageText}>
{error instanceof Error ? error.message : "Failed to load diff"}
</Text>
</View>
</View>
);
}
return (
<DiffFileBody
file={resolvedFile}
layout={layout}
wrapLines={wrapLines}
onBodyHeightChange={onBodyHeightChange}
testID={testID}
/>
);
}
interface GitDiffPaneProps {
serverId: string;
workspaceId?: string | null;
@@ -625,8 +740,8 @@ interface GitDiffPaneProps {
}
type DiffFlatItem =
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean }
| { type: "body"; file: ParsedDiffFile; fileIndex: number };
| { type: "header"; file: DiffFileHeaderFields; fileIndex: number; isExpanded: boolean }
| { type: "lazy-body"; file: DiffFileMetadata; fileIndex: number };
export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) {
const { theme } = useUnistyles();
@@ -687,7 +802,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
isError: isDiffError,
error: diffError,
refresh: refreshDiff,
} = useCheckoutDiffQuery({
} = useCheckoutDiffMetadataQuery({
serverId,
cwd,
mode: diffMode,
@@ -788,7 +903,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
stickyIndices.push(items.length - 1);
}
if (isExpanded) {
items.push({ type: "body", file, fileIndex: i });
items.push({ type: "lazy-body", file: file as DiffFileMetadata, fileIndex: i });
}
}
return { flatItems: items, stickyHeaderIndices: stickyIndices };
@@ -1068,8 +1183,13 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
);
}
return (
<DiffFileBody
<LazyDiffFileBody
file={item.file}
serverId={serverId}
cwd={cwd}
mode={diffMode}
baseRef={baseRef}
ignoreWhitespace={changesPreferences.hideWhitespace}
layout={effectiveLayout}
wrapLines={wrapLines}
onBodyHeightChange={handleBodyHeightChange}
@@ -1078,10 +1198,15 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
);
},
[
baseRef,
changesPreferences.hideWhitespace,
cwd,
diffMode,
effectiveLayout,
handleBodyHeightChange,
handleHeaderHeightChange,
handleToggleExpanded,
serverId,
wrapLines,
],
);

View File

@@ -1,6 +1,6 @@
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
type ParsedDiffFile = SubscribeCheckoutDiffResponse["payload"]["files"][number];
interface DiffFileWithPath {
path: string;
}
export function compareCheckoutDiffPaths(left: string, right: string): number {
if (left === right) {
@@ -9,7 +9,7 @@ export function compareCheckoutDiffPaths(left: string, right: string): number {
return left < right ? -1 : 1;
}
export function orderCheckoutDiffFiles(files: ParsedDiffFile[]): ParsedDiffFile[] {
export function orderCheckoutDiffFiles<T extends DiffFileWithPath>(files: T[]): T[] {
if (files.length < 2) {
return files;
}

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import type { DiffFileMetadata } from "./use-checkout-diff-metadata-query";
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
function createMetadata(path: string, fingerprint = "fp1"): DiffFileMetadata {
return {
path,
isNew: false,
isDeleted: false,
additions: 1,
deletions: 0,
fingerprint,
};
}
describe("useCheckoutDiffMetadataQuery — cache replacement behavior", () => {
it("orderCheckoutDiffFiles works with DiffFileMetadata arrays", () => {
const files: DiffFileMetadata[] = [
createMetadata("zeta.ts"),
createMetadata("alpha.ts"),
createMetadata("beta.ts"),
];
const ordered = orderCheckoutDiffFiles(files);
expect(ordered.map((f) => f.path)).toEqual(["alpha.ts", "beta.ts", "zeta.ts"]);
});
it("replaces full list on snapshot — does not merge with previous", () => {
const initial: DiffFileMetadata[] = [
createMetadata("a.ts", "fp1"),
createMetadata("b.ts", "fp2"),
createMetadata("c.ts", "fp3"),
];
// Simulate a new snapshot that removes b.ts and changes c.ts fingerprint
const updated: DiffFileMetadata[] = [
createMetadata("a.ts", "fp1"),
createMetadata("c.ts", "fp4"),
];
// Full replacement: the result is exactly the updated list, not a merge
const result = orderCheckoutDiffFiles(updated);
expect(result).toHaveLength(2);
expect(result.map((f) => f.path)).toEqual(["a.ts", "c.ts"]);
expect(result.find((f) => f.path === "c.ts")?.fingerprint).toBe("fp4");
// b.ts is gone — no remnant from initial
expect(result.find((f) => f.path === "b.ts")).toBeUndefined();
// Initial is unchanged (no mutation)
expect(initial).toHaveLength(3);
});
it("preserves fingerprint and status on metadata items", () => {
const file: DiffFileMetadata = {
path: "large.bin",
isNew: false,
isDeleted: false,
additions: 0,
deletions: 0,
status: "binary",
fingerprint: "abc123",
};
const ordered = orderCheckoutDiffFiles([file]);
expect(ordered[0].fingerprint).toBe("abc123");
expect(ordered[0].status).toBe("binary");
});
});

View File

@@ -0,0 +1,214 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, 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 { CheckoutDiffSnapshot } from "@server/shared/messages";
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
const CHECKOUT_DIFF_METADATA_STALE_TIME = 30_000;
export type DiffFileMetadata = CheckoutDiffSnapshot["payload"]["files"][number];
type CheckoutDiffMetadataPayload = Omit<CheckoutDiffSnapshot["payload"], "subscriptionId">;
function checkoutDiffMetadataQueryKey(
serverId: string,
cwd: string,
mode: "uncommitted" | "base",
baseRef?: string,
ignoreWhitespace?: boolean,
) {
return [
"checkoutDiffMetadata",
serverId,
cwd,
mode,
baseRef ?? "",
ignoreWhitespace === true,
] as const;
}
interface UseCheckoutDiffMetadataQueryOptions {
serverId: string;
cwd: string;
mode: "uncommitted" | "base";
baseRef?: string;
ignoreWhitespace?: boolean;
enabled?: boolean;
}
function normalizeCheckoutDiffCompare(compare: {
mode: "uncommitted" | "base";
baseRef?: string;
ignoreWhitespace?: boolean;
}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } {
const ignoreWhitespace = compare.ignoreWhitespace === true;
if (compare.mode === "uncommitted") {
return { mode: "uncommitted", ignoreWhitespace };
}
const trimmedBaseRef = compare.baseRef?.trim();
return trimmedBaseRef
? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace }
: { mode: "base", ignoreWhitespace };
}
export function useCheckoutDiffMetadataQuery({
serverId,
cwd,
mode,
baseRef,
ignoreWhitespace,
enabled = true,
}: UseCheckoutDiffMetadataQueryOptions) {
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 hookInstanceId = useId();
const normalizedCompare = useMemo(
() => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
[mode, baseRef, ignoreWhitespace],
);
const compareMode = normalizedCompare.mode;
const compareBaseRef = normalizedCompare.baseRef;
const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace;
const queryKey = useMemo(
() => checkoutDiffMetadataQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace),
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
);
const query = useQuery({
queryKey,
queryFn: async () => {
if (!client) {
throw new Error("Daemon client not available");
}
const payload = await client.getCheckoutDiffMetadata(cwd, {
mode: compareMode,
baseRef: compareBaseRef,
ignoreWhitespace: compareIgnoreWhitespace,
});
return {
cwd: payload.cwd,
files: orderCheckoutDiffFiles(payload.files),
error: payload.error,
requestId: payload.requestId,
};
},
enabled: !!client && isConnected && !!cwd && enabled,
staleTime: CHECKOUT_DIFF_METADATA_STALE_TIME,
});
useEffect(() => {
if (!client || !isConnected || !cwd || !enabled) {
return;
}
if (!isOpen || explorerTab !== "changes") {
return;
}
const subscriptionId = [
"checkoutDiffMetadata",
hookInstanceId,
serverId,
cwd,
compareMode,
compareBaseRef ?? "",
compareIgnoreWhitespace ? "ignore-ws" : "keep-ws",
].join(":");
let cancelled = false;
const unsubscribeSnapshot = client.on("checkout/diff_snapshot", (message) => {
if (message.type !== "checkout/diff_snapshot") {
return;
}
if (message.payload.subscriptionId !== subscriptionId) {
return;
}
queryClient.setQueryData<CheckoutDiffMetadataPayload>(queryKey, {
cwd: message.payload.cwd,
files: orderCheckoutDiffFiles(message.payload.files),
error: message.payload.error,
requestId: message.payload.requestId,
});
});
void client
.subscribeCheckoutDiffLazy(
cwd,
{
mode: compareMode,
baseRef: compareBaseRef,
ignoreWhitespace: compareIgnoreWhitespace,
},
{ subscriptionId },
)
.then((payload) => {
if (cancelled) {
return;
}
queryClient.setQueryData<CheckoutDiffMetadataPayload>(queryKey, {
cwd: payload.cwd,
files: orderCheckoutDiffFiles(payload.files),
error: payload.error,
requestId: payload.requestId,
});
})
.catch((error) => {
if (cancelled) {
return;
}
console.error("[useCheckoutDiffMetadataQuery] subscribeCheckoutDiffLazy failed", {
serverId,
cwd,
error,
});
});
return () => {
cancelled = true;
unsubscribeSnapshot();
try {
client.unsubscribeCheckoutDiffLazy(subscriptionId);
} catch {
// Ignore disconnect race during effect cleanup.
}
};
}, [
client,
isConnected,
cwd,
enabled,
isOpen,
explorerTab,
hookInstanceId,
serverId,
compareMode,
compareBaseRef,
compareIgnoreWhitespace,
queryKey,
queryClient,
]);
const refresh = useCallback(() => {
return query.refetch();
}, [query]);
const payload = query.data ?? null;
const payloadError = payload?.error ?? null;
return {
files: payload?.files ?? [],
payloadError,
isLoading: query.isLoading,
isFetching: query.isFetching,
isError: query.isError || Boolean(payloadError),
error: query.error,
refresh,
};
}

View File

@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import type { DiffFileMetadata } from "./use-checkout-diff-metadata-query";
/**
* Tests for use-file-hunks hook logic.
*
* The hook itself is a thin React Query wrapper, so we test the key properties:
* 1. Query key includes fingerprint — fingerprint change = automatic refetch
* 2. Metadata fields are passed through to the merged result
*/
function fileHunksQueryKey(
serverId: string,
cwd: string,
mode: "uncommitted" | "base",
baseRef: string | undefined,
ignoreWhitespace: boolean | undefined,
path: string,
fingerprint: string,
) {
return [
"checkoutFileHunks",
serverId,
cwd,
mode,
baseRef ?? "",
ignoreWhitespace === true,
path,
fingerprint,
] as const;
}
describe("use-file-hunks query key", () => {
it("includes fingerprint in query key", () => {
const key = fileHunksQueryKey("s1", "/repo", "base", "main", false, "foo.ts", "fp-abc");
expect(key).toContain("fp-abc");
expect(key).toContain("foo.ts");
});
it("different fingerprints produce different query keys", () => {
const key1 = fileHunksQueryKey("s1", "/repo", "base", "main", false, "foo.ts", "fp-1");
const key2 = fileHunksQueryKey("s1", "/repo", "base", "main", false, "foo.ts", "fp-2");
expect(key1).not.toEqual(key2);
// Only the fingerprint element differs
expect(key1.slice(0, -1)).toEqual(key2.slice(0, -1));
});
it("same fingerprint + same params produces identical key", () => {
const key1 = fileHunksQueryKey("s1", "/repo", "uncommitted", undefined, true, "bar.ts", "fp-x");
const key2 = fileHunksQueryKey("s1", "/repo", "uncommitted", undefined, true, "bar.ts", "fp-x");
expect(key1).toEqual(key2);
});
it("normalizes missing baseRef to empty string", () => {
const key = fileHunksQueryKey("s1", "/repo", "uncommitted", undefined, false, "a.ts", "fp");
expect(key[4]).toBe("");
});
it("normalizes ignoreWhitespace to boolean", () => {
const key = fileHunksQueryKey("s1", "/repo", "base", "main", undefined, "a.ts", "fp");
expect(key[5]).toBe(false);
});
});
describe("use-file-hunks metadata merge", () => {
it("metadata fields carry through to merged ParsedDiffFile shape", () => {
const meta: DiffFileMetadata = {
path: "src/index.ts",
isNew: true,
isDeleted: false,
additions: 10,
deletions: 2,
status: "ok",
fingerprint: "abc",
};
// Simulates merging metadata with fetched hunks
const merged = {
path: meta.path,
isNew: meta.isNew,
isDeleted: meta.isDeleted,
additions: meta.additions,
deletions: meta.deletions,
status: meta.status,
hunks: [],
};
expect(merged.path).toBe("src/index.ts");
expect(merged.isNew).toBe(true);
expect(merged.additions).toBe(10);
expect(merged.hunks).toEqual([]);
});
});

View File

@@ -0,0 +1,99 @@
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
import type { DiffFileMetadata } from "@/hooks/use-checkout-diff-metadata-query";
function fileHunksQueryKey(
serverId: string,
cwd: string,
mode: "uncommitted" | "base",
baseRef: string | undefined,
ignoreWhitespace: boolean | undefined,
path: string,
fingerprint: string,
) {
return [
"checkoutFileHunks",
serverId,
cwd,
mode,
baseRef ?? "",
ignoreWhitespace === true,
path,
fingerprint,
] as const;
}
interface UseFileHunksOptions {
serverId: string;
cwd: string;
mode: "uncommitted" | "base";
baseRef?: string;
ignoreWhitespace?: boolean;
file: DiffFileMetadata;
enabled?: boolean;
}
export function useFileHunks({
serverId,
cwd,
mode,
baseRef,
ignoreWhitespace,
file,
enabled = true,
}: UseFileHunksOptions) {
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const queryKey = useMemo(
() =>
fileHunksQueryKey(
serverId,
cwd,
mode,
baseRef,
ignoreWhitespace,
file.path,
file.fingerprint,
),
[serverId, cwd, mode, baseRef, ignoreWhitespace, file.path, file.fingerprint],
);
const query = useQuery({
queryKey,
queryFn: async (): Promise<ParsedDiffFile> => {
if (!client) {
throw new Error("Daemon client not available");
}
const payload = await client.getCheckoutFileHunks(
cwd,
{ mode, baseRef, ignoreWhitespace },
file.path,
);
if (payload.error) {
throw new Error(payload.error.message);
}
return {
path: file.path,
isNew: file.isNew,
isDeleted: file.isDeleted,
additions: file.additions,
deletions: file.deletions,
status: file.status,
hunks: payload.hunks,
};
},
enabled: !!client && isConnected && enabled,
staleTime: 60_000,
});
return {
file: query.data ?? null,
isLoading: query.isLoading,
isFetching: query.isFetching,
isError: query.isError,
error: query.error,
};
}

View File

@@ -516,6 +516,70 @@ describe("DaemonClient", () => {
});
});
test("subscribes to lazy checkout diff updates via RPC handshake", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.subscribeCheckoutDiffLazy(
"/tmp/project",
{ mode: "uncommitted" },
{ subscriptionId: "checkout-lazy-sub-1" },
);
expect(mock.sent).toHaveLength(1);
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: "checkout/subscribe_diff";
subscriptionId: string;
cwd: string;
compare: { mode: "uncommitted" | "base"; baseRef?: string };
requestId: string;
};
};
expect(request.message.type).toBe("checkout/subscribe_diff");
expect(request.message.subscriptionId).toBe("checkout-lazy-sub-1");
expect(request.message.cwd).toBe("/tmp/project");
expect(request.message.compare).toEqual({ mode: "uncommitted", ignoreWhitespace: false });
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "checkout/diff_snapshot",
payload: {
subscriptionId: "checkout-lazy-sub-1",
cwd: "/tmp/project",
files: [],
error: null,
requestId: request.message.requestId,
},
},
}),
);
await expect(promise).resolves.toEqual({
subscriptionId: "checkout-lazy-sub-1",
cwd: "/tmp/project",
files: [],
error: null,
requestId: request.message.requestId,
});
});
test("getCheckoutDiff uses one-shot subscription protocol", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -589,6 +653,105 @@ describe("DaemonClient", () => {
expect(unsubscribeRequest.message.subscriptionId).toBe(subscribeRequest.message.subscriptionId);
});
test("getCheckoutFileHunks requests hunks via RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.getCheckoutFileHunks(
"/tmp/project",
{ mode: "base", baseRef: "main" },
"src/app.ts",
);
expect(mock.sent).toHaveLength(1);
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: "checkout/get_file_hunks";
cwd: string;
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
path: string;
requestId: string;
};
};
expect(request.message.type).toBe("checkout/get_file_hunks");
expect(request.message.cwd).toBe("/tmp/project");
expect(request.message.compare).toEqual({
mode: "base",
baseRef: "main",
ignoreWhitespace: false,
});
expect(request.message.path).toBe("src/app.ts");
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "checkout/file_hunks_response",
payload: {
requestId: request.message.requestId,
path: "src/app.ts",
hunks: [],
error: null,
},
},
}),
);
await expect(promise).resolves.toEqual({
requestId: request.message.requestId,
path: "src/app.ts",
hunks: [],
error: null,
});
});
test("unsubscribeCheckoutDiffLazy sends unsubscribe request", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
client.unsubscribeCheckoutDiffLazy("checkout-lazy-sub-1");
expect(mock.sent).toHaveLength(1);
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: "checkout/unsubscribe_diff";
subscriptionId: string;
};
};
expect(request.message).toEqual({
type: "checkout/unsubscribe_diff",
subscriptionId: "checkout-lazy-sub-1",
});
});
test("requests branch suggestions via RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -886,6 +1049,60 @@ describe("DaemonClient", () => {
expect(request.message.requestId.length).toBeGreaterThan(0);
});
test("resubscribes lazy checkout diff streams after reconnect", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const internal = client as unknown as {
checkoutDiffLazySubscriptions: Map<
string,
{
cwd: string;
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
}
>;
};
internal.checkoutDiffLazySubscriptions.set("checkout-lazy-sub-1", {
cwd: "/tmp/project",
compare: { mode: "base", baseRef: "main", ignoreWhitespace: false },
});
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
expect(mock.sent).toHaveLength(1);
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: "checkout/subscribe_diff";
subscriptionId: string;
cwd: string;
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
requestId: string;
};
};
expect(request.message.type).toBe("checkout/subscribe_diff");
expect(request.message.subscriptionId).toBe("checkout-lazy-sub-1");
expect(request.message.cwd).toBe("/tmp/project");
expect(request.message.compare).toEqual({
mode: "base",
baseRef: "main",
ignoreWhitespace: false,
});
expect(typeof request.message.requestId).toBe("string");
expect(request.message.requestId.length).toBeGreaterThan(0);
});
test("fetches agents via RPC with filters, sort, and pagination", async () => {
const logger = createMockLogger();
const mock = createMockTransport();

View File

@@ -216,6 +216,14 @@ type SubscribeCheckoutDiffPayload = Extract<
{ type: "subscribe_checkout_diff_response" }
>["payload"];
type CheckoutDiffPayload = Omit<SubscribeCheckoutDiffPayload, "subscriptionId">;
type CheckoutDiffMetadataSnapshotPayload = Extract<
SessionOutboundMessage,
{ type: "checkout/diff_snapshot" }
>["payload"];
type CheckoutFileHunksPayload = Extract<
SessionOutboundMessage,
{ type: "checkout/file_hunks_response" }
>["payload"];
type CheckoutCommitPayload = CheckoutCommitResponse["payload"];
type CheckoutMergePayload = CheckoutMergeResponse["payload"];
type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"];
@@ -500,8 +508,13 @@ type SetDaemonConfigResponse = Extract<
SessionOutboundMessage,
{ type: "set_daemon_config_response" }
>;
type CheckoutDiffSnapshotResponse = Extract<
SessionOutboundMessage,
{ type: "checkout/diff_snapshot" }
>;
type CorrelatedResponseMessage =
| Extract<SessionOutboundMessage, { payload: { requestId: string } }>
| CheckoutDiffSnapshotResponse
| GetDaemonConfigResponse
| SetDaemonConfigResponse;
type CorrelatedResponseType = CorrelatedResponseMessage["type"];
@@ -616,6 +629,13 @@ export class DaemonClient {
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
}
>();
private checkoutDiffLazySubscriptions = new Map<
string,
{
cwd: string;
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
}
>();
private terminalDirectorySubscriptions = new Set<string>();
private terminalSlots = new Map<string, number>();
private slotTerminals = new Map<number, string>();
@@ -1408,6 +1428,22 @@ export class DaemonClient {
}
}
private resubscribeCheckoutDiffLazySubscriptions(): void {
if (this.checkoutDiffLazySubscriptions.size === 0) {
return;
}
for (const [subscriptionId, subscription] of this.checkoutDiffLazySubscriptions) {
const message = SessionInboundMessageSchema.parse({
type: "checkout/subscribe_diff",
subscriptionId,
cwd: subscription.cwd,
compare: subscription.compare,
requestId: this.createRequestId(),
});
this.sendSessionMessage(message);
}
}
private resubscribeTerminalDirectorySubscriptions(): void {
if (this.terminalDirectorySubscriptions.size === 0) {
return;
@@ -2270,6 +2306,105 @@ export class DaemonClient {
});
}
async getCheckoutDiffMetadata(
cwd: string,
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
requestId?: string,
): Promise<Omit<CheckoutDiffMetadataSnapshotPayload, "subscriptionId">> {
const oneShotSubscriptionId = `oneshot-checkout-diff-lazy:${crypto.randomUUID()}`;
try {
const payload = await this.subscribeCheckoutDiffLazy(cwd, compare, {
subscriptionId: oneShotSubscriptionId,
requestId,
});
return {
cwd: payload.cwd,
files: payload.files,
error: payload.error,
requestId: payload.requestId,
};
} finally {
try {
this.unsubscribeCheckoutDiffLazy(oneShotSubscriptionId);
} catch {
// Ignore disconnect races during one-shot cleanup.
}
}
}
async subscribeCheckoutDiffLazy(
cwd: string,
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
options?: { subscriptionId?: string; requestId?: string },
): Promise<CheckoutDiffMetadataSnapshotPayload> {
const subscriptionId = options?.subscriptionId ?? crypto.randomUUID();
const normalizedCompare = this.normalizeCheckoutDiffCompare(compare);
const previousSubscription = this.checkoutDiffLazySubscriptions.get(subscriptionId) ?? null;
this.checkoutDiffLazySubscriptions.set(subscriptionId, {
cwd,
compare: normalizedCompare,
});
const resolvedRequestId = this.createRequestId(options?.requestId);
const message = SessionInboundMessageSchema.parse({
type: "checkout/subscribe_diff",
subscriptionId,
cwd,
compare: normalizedCompare,
requestId: resolvedRequestId,
});
try {
return await this.sendCorrelatedRequest({
requestId: resolvedRequestId,
message,
responseType: "checkout/diff_snapshot",
timeout: 60000,
options: { skipQueue: true },
selectPayload: (payload) => {
if (payload.subscriptionId !== subscriptionId) {
return null;
}
return payload;
},
});
} catch (error) {
if (previousSubscription) {
this.checkoutDiffLazySubscriptions.set(subscriptionId, previousSubscription);
} else {
this.checkoutDiffLazySubscriptions.delete(subscriptionId);
}
throw error;
}
}
unsubscribeCheckoutDiffLazy(subscriptionId: string): void {
this.checkoutDiffLazySubscriptions.delete(subscriptionId);
this.sendSessionMessage({
type: "checkout/unsubscribe_diff",
subscriptionId,
});
}
async getCheckoutFileHunks(
cwd: string,
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
path: string,
requestId?: string,
): Promise<CheckoutFileHunksPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "checkout/get_file_hunks",
cwd,
compare: this.normalizeCheckoutDiffCompare(compare),
path,
},
responseType: "checkout/file_hunks_response",
timeout: 60000,
});
}
async checkoutCommit(
cwd: string,
input: { message?: string; addAll?: boolean },
@@ -3723,6 +3858,7 @@ export class DaemonClient {
this.reconnectAttempt = 0;
this.updateConnectionState({ status: "connected" }, { event: "HELLO_SERVER_INFO" });
this.resubscribeCheckoutDiffSubscriptions();
this.resubscribeCheckoutDiffLazySubscriptions();
this.resubscribeTerminalDirectorySubscriptions();
this.flushPendingSendQueue();
this.resolveConnect();

View File

@@ -81,6 +81,53 @@ vi.mock("./checkout-git-utils.js", () => ({
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
function createStructuredFile(options?: {
path?: string;
addContent?: string;
tokenStyle?: string;
}): {
path: string;
isNew: boolean;
isDeleted: boolean;
additions: number;
deletions: number;
hunks: Array<{
oldStart: number;
oldCount: number;
newStart: number;
newCount: number;
lines: Array<{ type: "header" | "context" | "add"; content: string; tokens?: unknown[] }>;
}>;
status: "ok";
} {
const addContent = options?.addContent ?? "const value = 2;";
return {
path: options?.path ?? "src/example.ts",
isNew: false,
isDeleted: false,
additions: 1,
deletions: 0,
hunks: [
{
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 2,
lines: [
{ type: "header", content: "@@ -1,1 +1,2 @@" },
{ type: "context", content: "const value = 1;" },
{
type: "add",
content: addContent,
tokens: [{ text: "const", style: options?.tokenStyle ?? "keyword" }],
},
],
},
],
status: "ok",
};
}
describe("CheckoutDiffManager Linux watchers", () => {
const originalPlatform = process.platform;
@@ -134,4 +181,102 @@ describe("CheckoutDiffManager Linux watchers", () => {
subscription.unsubscribe();
manager.dispose();
});
test("subscribeLazy returns metadata snapshots and caches file hunks", async () => {
getCheckoutDiffMock.mockResolvedValueOnce({
diff: "",
structured: [createStructuredFile()],
});
const logger = {
child: () => logger,
warn: vi.fn(),
};
const manager = new CheckoutDiffManager({
logger: logger as any,
paseoHome: "/tmp/paseo-test",
});
const listener = vi.fn();
const subscription = await manager.subscribeLazy(
{
cwd: path.join("/tmp/repo", "packages", "server"),
compare: { mode: "uncommitted" },
},
listener,
);
expect(subscription.initial.error).toBeNull();
expect(subscription.initial.files).toEqual([
expect.objectContaining({
path: "src/example.ts",
additions: 1,
deletions: 0,
fingerprint: expect.any(String),
}),
]);
expect((subscription.initial.files[0] as any).hunks).toBeUndefined();
expect(
manager.getFileHunks(
path.join("/tmp/repo", "packages", "server"),
{ mode: "uncommitted" },
"src/example.ts",
),
).toEqual(createStructuredFile().hunks);
subscription.unsubscribe();
manager.dispose();
});
test("lazy metadata fingerprint ignores token-only changes but emits on hunk changes", async () => {
getCheckoutDiffMock.mockResolvedValueOnce({
diff: "",
structured: [createStructuredFile({ tokenStyle: "keyword" })],
});
getCheckoutDiffMock.mockResolvedValueOnce({
diff: "",
structured: [createStructuredFile({ tokenStyle: "variableName" })],
});
getCheckoutDiffMock.mockResolvedValueOnce({
diff: "",
structured: [createStructuredFile({ addContent: "const value = 3;" })],
});
const logger = {
child: () => logger,
warn: vi.fn(),
};
const manager = new CheckoutDiffManager({
logger: logger as any,
paseoHome: "/tmp/paseo-test",
});
const listener = vi.fn();
await manager.subscribeLazy(
{
cwd: path.join("/tmp/repo", "packages", "server"),
compare: { mode: "uncommitted" },
},
listener,
);
const target = Array.from((manager as any).targets.values())[0];
await (manager as any).refreshTarget(target);
expect(listener).not.toHaveBeenCalled();
await (manager as any).refreshTarget(target);
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenLastCalledWith(
expect.objectContaining({
files: [
expect.objectContaining({
path: "src/example.ts",
fingerprint: expect.any(String),
}),
],
}),
);
manager.dispose();
});
});

View File

@@ -1,8 +1,10 @@
import { createHash } from "node:crypto";
import { watch, type FSWatcher } from "node:fs";
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import type pino from "pino";
import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js";
import type { DiffFileMetadata } from "./checkout/rpc-schemas.js";
import { getCheckoutDiff } from "../utils/checkout-git.js";
import { expandTilde } from "../utils/path.js";
import { runGitCommand } from "../utils/run-git-command.js";
@@ -18,6 +20,16 @@ export type CheckoutDiffSnapshotPayload = Omit<
"subscriptionId"
>;
type ParsedDiffFile = CheckoutDiffSnapshotPayload["files"][number];
export type { DiffFileMetadata };
export type CheckoutLazyDiffSnapshotPayload = {
cwd: string;
files: DiffFileMetadata[];
error: CheckoutDiffSnapshotPayload["error"];
};
export type CheckoutDiffMetrics = {
checkoutDiffTargetCount: number;
checkoutDiffSubscriptionCount: number;
@@ -31,13 +43,17 @@ type CheckoutDiffWatchTarget = {
diffCwd: string;
compare: CheckoutDiffCompareInput;
listeners: Set<(snapshot: CheckoutDiffSnapshotPayload) => void>;
lazyListeners: Set<(snapshot: CheckoutLazyDiffSnapshotPayload) => void>;
watchers: FSWatcher[];
fallbackRefreshInterval: NodeJS.Timeout | null;
debounceTimer: NodeJS.Timeout | null;
refreshPromise: Promise<void> | null;
refreshQueued: boolean;
latestPayload: CheckoutDiffSnapshotPayload | null;
latestLazyPayload: CheckoutLazyDiffSnapshotPayload | null;
latestFingerprint: string | null;
latestFilesByPath: Map<string, ParsedDiffFile>;
latestMetadataFingerprints: Map<string, string>;
watchedPaths: Set<string>;
repoWatchPath: string | null;
linuxTreeRefreshPromise: Promise<void> | null;
@@ -72,6 +88,7 @@ export class CheckoutDiffManager {
diffCwd: target.diffCwd,
}));
target.latestPayload = initial;
target.latestFilesByPath = new Map(initial.files.map((file) => [file.path, file]));
target.latestFingerprint = JSON.stringify(initial);
return {
initial,
@@ -81,6 +98,36 @@ export class CheckoutDiffManager {
};
}
async subscribeLazy(
params: {
cwd: string;
compare: CheckoutDiffCompareInput;
},
listener: (snapshot: CheckoutLazyDiffSnapshotPayload) => void,
): Promise<{ initial: CheckoutLazyDiffSnapshotPayload; unsubscribe: () => void }> {
const cwd = params.cwd;
const compare = this.normalizeCompare(params.compare);
const target = await this.ensureTarget(cwd, compare);
target.lazyListeners.add(listener);
const baseSnapshot =
target.latestPayload ??
(await this.computeCheckoutDiffSnapshot(target.cwd, target.compare, {
diffCwd: target.diffCwd,
}));
target.latestPayload = baseSnapshot;
target.latestFilesByPath = new Map(baseSnapshot.files.map((file) => [file.path, file]));
const initial = target.latestLazyPayload ?? this.buildLazySnapshot(baseSnapshot);
target.latestLazyPayload = initial;
target.latestMetadataFingerprints = this.buildMetadataFingerprintMap(initial.files);
return {
initial,
unsubscribe: () => {
this.removeLazyListener(target.key, listener);
},
};
}
scheduleRefreshForCwd(cwd: string): void {
const resolvedCwd = expandTilde(cwd);
for (const target of this.targets.values()) {
@@ -98,6 +145,7 @@ export class CheckoutDiffManager {
for (const target of this.targets.values()) {
checkoutDiffSubscriptionCount += target.listeners.size;
checkoutDiffSubscriptionCount += target.lazyListeners.size;
checkoutDiffWatcherCount += target.watchers.length;
if (target.fallbackRefreshInterval) {
checkoutDiffFallbackRefreshTargetCount += 1;
@@ -154,6 +202,9 @@ export class CheckoutDiffManager {
target.watchers = [];
target.watchedPaths.clear();
target.listeners.clear();
target.lazyListeners.clear();
target.latestFilesByPath.clear();
target.latestMetadataFingerprints.clear();
}
private removeListener(
@@ -165,7 +216,23 @@ export class CheckoutDiffManager {
return;
}
target.listeners.delete(listener);
if (target.listeners.size > 0) {
if (target.listeners.size > 0 || target.lazyListeners.size > 0) {
return;
}
this.closeTarget(target);
this.targets.delete(targetKey);
}
private removeLazyListener(
targetKey: string,
listener: (snapshot: CheckoutLazyDiffSnapshotPayload) => void,
): void {
const target = this.targets.get(targetKey);
if (!target) {
return;
}
target.lazyListeners.delete(listener);
if (target.listeners.size > 0 || target.lazyListeners.size > 0) {
return;
}
this.closeTarget(target);
@@ -188,6 +255,15 @@ export class CheckoutDiffManager {
}
}
getFileHunks(
cwd: string,
compare: CheckoutDiffCompareInput,
path: string,
): ParsedDiffFile["hunks"] | null {
const target = this.targets.get(this.buildTargetKey(cwd, this.normalizeCompare(compare)));
return target?.latestFilesByPath.get(path)?.hunks ?? null;
}
private scheduleTargetRefresh(target: CheckoutDiffWatchTarget): void {
if (target.debounceTimer) {
clearTimeout(target.debounceTimer);
@@ -198,6 +274,81 @@ export class CheckoutDiffManager {
}, CHECKOUT_DIFF_WATCH_DEBOUNCE_MS);
}
private buildLazySnapshot(
snapshot: CheckoutDiffSnapshotPayload,
): CheckoutLazyDiffSnapshotPayload {
return {
cwd: snapshot.cwd,
files: this.deriveMetadata(snapshot.files),
error: snapshot.error,
};
}
private deriveMetadata(files: ParsedDiffFile[]): DiffFileMetadata[] {
return files.map((file) => ({
path: file.path,
isNew: file.isNew,
isDeleted: file.isDeleted,
additions: file.additions,
deletions: file.deletions,
...(file.status ? { status: file.status } : {}),
fingerprint: this.buildFileFingerprint(file),
}));
}
private buildFileFingerprint(file: ParsedDiffFile): string {
const serialized = JSON.stringify({
hunks: file.hunks.map((hunk) => ({
oldStart: hunk.oldStart,
oldCount: hunk.oldCount,
newStart: hunk.newStart,
newCount: hunk.newCount,
lines: hunk.lines.map((line) => ({
type: line.type,
content: line.content,
})),
})),
});
return createHash("sha256").update(serialized).digest("hex");
}
private buildMetadataFingerprintMap(files: DiffFileMetadata[]): Map<string, string> {
return new Map(
files.map((file) => [
file.path,
createHash("sha256").update(JSON.stringify(file)).digest("hex"),
]),
);
}
private metadataSnapshotChanged(
target: CheckoutDiffWatchTarget,
snapshot: CheckoutLazyDiffSnapshotPayload,
): boolean {
if (!target.latestLazyPayload) {
return true;
}
if (Boolean(target.latestLazyPayload.error) !== Boolean(snapshot.error)) {
return true;
}
if (
target.latestLazyPayload.error?.code !== snapshot.error?.code ||
target.latestLazyPayload.error?.message !== snapshot.error?.message
) {
return true;
}
const nextFingerprints = this.buildMetadataFingerprintMap(snapshot.files);
if (nextFingerprints.size !== target.latestMetadataFingerprints.size) {
return true;
}
for (const [path, fingerprint] of nextFingerprints) {
if (target.latestMetadataFingerprints.get(path) !== fingerprint) {
return true;
}
}
return false;
}
private async computeCheckoutDiffSnapshot(
cwd: string,
compare: CheckoutDiffCompareInput,
@@ -247,6 +398,7 @@ export class CheckoutDiffManager {
diffCwd: target.diffCwd,
});
target.latestPayload = snapshot;
target.latestFilesByPath = new Map(snapshot.files.map((file) => [file.path, file]));
const fingerprint = JSON.stringify(snapshot);
if (fingerprint !== target.latestFingerprint) {
target.latestFingerprint = fingerprint;
@@ -254,6 +406,17 @@ export class CheckoutDiffManager {
listener(snapshot);
}
}
const lazySnapshot = this.buildLazySnapshot(snapshot);
if (this.metadataSnapshotChanged(target, lazySnapshot)) {
target.latestLazyPayload = lazySnapshot;
target.latestMetadataFingerprints = this.buildMetadataFingerprintMap(lazySnapshot.files);
for (const listener of target.lazyListeners) {
listener(lazySnapshot);
}
} else {
target.latestLazyPayload = lazySnapshot;
}
} while (target.refreshQueued);
})();
@@ -281,13 +444,17 @@ export class CheckoutDiffManager {
diffCwd: watchRoot ?? cwd,
compare,
listeners: new Set(),
lazyListeners: new Set(),
watchers: [],
fallbackRefreshInterval: null,
debounceTimer: null,
refreshPromise: null,
refreshQueued: false,
latestPayload: null,
latestLazyPayload: null,
latestFingerprint: null,
latestFilesByPath: new Map(),
latestMetadataFingerprints: new Map(),
watchedPaths: new Set<string>(),
repoWatchPath: null,
linuxTreeRefreshPromise: null,

View File

@@ -0,0 +1,122 @@
import { describe, expect, test } from "vitest";
import {
ServerInfoStatusPayloadSchema,
SessionInboundMessageSchema,
SessionOutboundMessageSchema,
} from "../../shared/messages.js";
import { DiffFileMetadataSchema } from "./rpc-schemas.js";
describe("checkout lazy RPC schemas", () => {
test("parses checkout/subscribe_diff inbound messages", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "checkout/subscribe_diff",
requestId: "req-1",
subscriptionId: "sub-1",
cwd: "/tmp/repo",
compare: { mode: "uncommitted" },
});
expect(parsed.type).toBe("checkout/subscribe_diff");
});
test("parses checkout/unsubscribe_diff inbound messages", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "checkout/unsubscribe_diff",
subscriptionId: "sub-1",
});
expect(parsed.type).toBe("checkout/unsubscribe_diff");
});
test("parses checkout/get_file_hunks inbound messages", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "checkout/get_file_hunks",
requestId: "req-2",
cwd: "/tmp/repo",
path: "src/index.ts",
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
});
expect(parsed.type).toBe("checkout/get_file_hunks");
});
test("parses checkout/diff_snapshot outbound messages", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "checkout/diff_snapshot",
payload: {
subscriptionId: "sub-1",
requestId: "req-1",
cwd: "/tmp/repo",
files: [
{
path: "src/index.ts",
isNew: false,
isDeleted: false,
additions: 3,
deletions: 1,
status: "ok",
fingerprint: "fp-1",
},
],
error: null,
},
});
expect(parsed.type).toBe("checkout/diff_snapshot");
});
test("parses checkout/file_hunks_response outbound messages", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "checkout/file_hunks_response",
payload: {
requestId: "req-3",
path: "src/index.ts",
hunks: [
{
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 2,
lines: [
{ type: "header", content: "@@ -1,1 +1,2 @@" },
{ type: "context", content: "const a = 1;" },
{ type: "add", content: "const b = 2;" },
],
},
],
error: null,
},
});
expect(parsed.type).toBe("checkout/file_hunks_response");
});
test("requires fingerprint on diff metadata", () => {
const result = DiffFileMetadataSchema.safeParse({
path: "src/index.ts",
isNew: false,
isDeleted: false,
additions: 3,
deletions: 1,
status: "ok",
});
expect(result.success).toBe(false);
});
test("accepts lazyDiffRpcs in server info features", () => {
const parsed = ServerInfoStatusPayloadSchema.parse({
status: "server_info",
serverId: "server-1",
hostname: null,
version: null,
capabilities: {},
features: {
providersSnapshot: true,
lazyDiffRpcs: true,
},
});
expect(parsed.features?.lazyDiffRpcs).toBe(true);
});
});

View File

@@ -0,0 +1,60 @@
import { z } from "zod";
import {
CheckoutDiffCompareSchema,
CheckoutErrorSchema,
DiffHunkSchema,
} from "../../shared/messages.js";
export const DiffFileMetadataSchema = z.object({
path: z.string(),
isNew: z.boolean(),
isDeleted: z.boolean(),
additions: z.number(),
deletions: z.number(),
status: z.enum(["ok", "too_large", "binary"]).optional(),
fingerprint: z.string(),
});
export type DiffFileMetadata = z.infer<typeof DiffFileMetadataSchema>;
export const CheckoutSubscribeDiffRequestSchema = z.object({
type: z.literal("checkout/subscribe_diff"),
requestId: z.string(),
subscriptionId: z.string(),
cwd: z.string(),
compare: z.lazy(() => CheckoutDiffCompareSchema),
});
export const CheckoutUnsubscribeDiffRequestSchema = z.object({
type: z.literal("checkout/unsubscribe_diff"),
subscriptionId: z.string(),
});
export const CheckoutGetFileHunksRequestSchema = z.object({
type: z.literal("checkout/get_file_hunks"),
requestId: z.string(),
cwd: z.string(),
path: z.string(),
compare: z.lazy(() => CheckoutDiffCompareSchema),
});
export const CheckoutDiffSnapshotSchema = z.object({
type: z.literal("checkout/diff_snapshot"),
payload: z.object({
subscriptionId: z.string(),
requestId: z.string().optional(),
cwd: z.string(),
files: z.array(DiffFileMetadataSchema),
error: z.lazy(() => CheckoutErrorSchema).nullable(),
}),
});
export const CheckoutFileHunksResponseSchema = z.object({
type: z.literal("checkout/file_hunks_response"),
payload: z.object({
requestId: z.string(),
path: z.string(),
hunks: z.array(z.lazy(() => DiffHunkSchema)),
error: z.lazy(() => CheckoutErrorSchema).nullable(),
}),
});

View File

@@ -0,0 +1,353 @@
import { homedir } from "node:os";
import { beforeEach, describe, expect, test, vi } from "vitest";
const { getCheckoutDiffFileMock } = vi.hoisted(() => ({
getCheckoutDiffFileMock: vi.fn(),
}));
vi.mock("../utils/checkout-git.js", async () => {
const actual = await vi.importActual<typeof import("../utils/checkout-git.js")>(
"../utils/checkout-git.js",
);
return {
...actual,
getCheckoutDiffFile: getCheckoutDiffFileMock,
};
});
import { Session } from "./session.js";
function createSessionForCheckoutDiffTests(options?: {
checkoutDiffManager?: {
subscribe: ReturnType<typeof vi.fn>;
subscribeLazy: ReturnType<typeof vi.fn>;
getFileHunks: ReturnType<typeof vi.fn>;
scheduleRefreshForCwd: ReturnType<typeof vi.fn>;
getMetrics: ReturnType<typeof vi.fn>;
dispose: ReturnType<typeof vi.fn>;
};
}) {
const emitted: any[] = [];
const logger = {
child: () => logger,
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const checkoutDiffManager =
options?.checkoutDiffManager ??
({
subscribe: vi.fn(async () => ({
initial: { cwd: "/tmp/repo", files: [], error: null },
unsubscribe: vi.fn(),
})),
subscribeLazy: vi.fn(async () => ({
initial: { cwd: "/tmp/repo", files: [], error: null },
unsubscribe: vi.fn(),
})),
getFileHunks: vi.fn(() => null),
scheduleRefreshForCwd: vi.fn(),
getMetrics: vi.fn(() => ({
checkoutDiffTargetCount: 0,
checkoutDiffSubscriptionCount: 0,
checkoutDiffWatcherCount: 0,
checkoutDiffFallbackRefreshTargetCount: 0,
})),
dispose: vi.fn(),
} as const);
const session = new Session({
clientId: "test-client",
appVersion: null,
onMessage: vi.fn(),
logger: logger as any,
downloadTokenStore: {} as any,
pushTokenStore: {} as any,
paseoHome: "/tmp/paseo-test",
agentManager: {
subscribe: () => () => {},
listAgents: () => [],
getAgent: () => null,
archiveAgent: async () => ({ archivedAt: new Date().toISOString() }),
clearAgentAttention: async () => {},
notifyAgentState: () => {},
} as any,
agentStorage: {
list: async () => [],
get: async () => null,
} as any,
projectRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
} as any,
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
} as any,
checkoutDiffManager: checkoutDiffManager as any,
workspaceGitService: {
subscribe: async () => ({
initial: {
cwd: "/tmp/repo",
git: {
isGit: false,
repoRoot: null,
mainRepoRoot: null,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
isDirty: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
diffStat: null,
},
github: {
featuresEnabled: false,
pullRequest: null,
error: null,
refreshedAt: null,
},
},
unsubscribe: vi.fn(),
}),
peekSnapshot: () => null,
getSnapshot: async () => null,
refresh: async () => {},
dispose: () => {},
} as any,
mcpBaseUrl: null,
stt: null,
tts: null,
terminalManager: null,
}) as any;
session.emit = (message: any) => emitted.push(message);
return { session, emitted, checkoutDiffManager };
}
describe("Session checkout lazy diff handlers", () => {
beforeEach(() => {
getCheckoutDiffFileMock.mockReset();
});
test("checkout/subscribe_diff emits initial metadata snapshot and replaces existing subscription", async () => {
const firstUnsubscribe = vi.fn();
const secondUnsubscribe = vi.fn();
const subscribeLazy = vi
.fn()
.mockResolvedValueOnce({
initial: {
cwd: "/tmp/repo",
files: [
{
path: "src/example.ts",
isNew: false,
isDeleted: false,
additions: 1,
deletions: 0,
fingerprint: "fp-1",
},
],
error: null,
},
unsubscribe: firstUnsubscribe,
})
.mockResolvedValueOnce({
initial: { cwd: "/tmp/repo", files: [], error: null },
unsubscribe: secondUnsubscribe,
});
const { session, emitted } = createSessionForCheckoutDiffTests({
checkoutDiffManager: {
subscribe: vi.fn(),
subscribeLazy,
getFileHunks: vi.fn(),
scheduleRefreshForCwd: vi.fn(),
getMetrics: vi.fn(() => ({
checkoutDiffTargetCount: 0,
checkoutDiffSubscriptionCount: 0,
checkoutDiffWatcherCount: 0,
checkoutDiffFallbackRefreshTargetCount: 0,
})),
dispose: vi.fn(),
},
});
await session.handleMessage({
type: "checkout/subscribe_diff",
requestId: "req-1",
subscriptionId: "sub-1",
cwd: "~/repo",
compare: { mode: "uncommitted" },
});
await session.handleMessage({
type: "checkout/subscribe_diff",
requestId: "req-2",
subscriptionId: "sub-1",
cwd: "~/repo",
compare: { mode: "uncommitted" },
});
expect(subscribeLazy).toHaveBeenNthCalledWith(
1,
{ cwd: `${homedir()}/repo`, compare: { mode: "uncommitted" } },
expect.any(Function),
);
expect(firstUnsubscribe).toHaveBeenCalledTimes(1);
expect(emitted[0]).toMatchObject({
type: "checkout/diff_snapshot",
payload: {
subscriptionId: "sub-1",
requestId: "req-1",
cwd: "/tmp/repo",
},
});
});
test("checkout/get_file_hunks serves cached hunks before falling back to git", async () => {
const cachedHunks = [
{
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 2,
lines: [{ type: "header", content: "@@ -1,1 +1,2 @@" }],
},
];
const getFileHunks = vi.fn(() => cachedHunks);
const { session, emitted } = createSessionForCheckoutDiffTests({
checkoutDiffManager: {
subscribe: vi.fn(),
subscribeLazy: vi.fn(),
getFileHunks,
scheduleRefreshForCwd: vi.fn(),
getMetrics: vi.fn(() => ({
checkoutDiffTargetCount: 0,
checkoutDiffSubscriptionCount: 0,
checkoutDiffWatcherCount: 0,
checkoutDiffFallbackRefreshTargetCount: 0,
})),
dispose: vi.fn(),
},
});
await session.handleMessage({
type: "checkout/get_file_hunks",
requestId: "req-hunks",
cwd: "/tmp/repo",
path: "src/example.ts",
compare: { mode: "uncommitted" },
});
expect(getFileHunks).toHaveBeenCalledWith(
"/tmp/repo",
{ mode: "uncommitted" },
"src/example.ts",
);
expect(getCheckoutDiffFileMock).not.toHaveBeenCalled();
expect(emitted[0]).toMatchObject({
type: "checkout/file_hunks_response",
payload: {
requestId: "req-hunks",
path: "src/example.ts",
hunks: cachedHunks,
error: null,
},
});
});
test("checkout/get_file_hunks falls back to single-file diff and unsubscribe cleans up", async () => {
const unsubscribe = vi.fn();
getCheckoutDiffFileMock.mockResolvedValueOnce({
path: "src/example.ts",
isNew: false,
isDeleted: false,
additions: 1,
deletions: 0,
hunks: [
{
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 2,
lines: [{ type: "header", content: "@@ -1,1 +1,2 @@" }],
},
],
status: "ok",
});
const { session, emitted } = createSessionForCheckoutDiffTests({
checkoutDiffManager: {
subscribe: vi.fn(),
subscribeLazy: vi.fn(async () => ({
initial: { cwd: "/tmp/repo", files: [], error: null },
unsubscribe,
})),
getFileHunks: vi.fn(() => null),
scheduleRefreshForCwd: vi.fn(),
getMetrics: vi.fn(() => ({
checkoutDiffTargetCount: 0,
checkoutDiffSubscriptionCount: 0,
checkoutDiffWatcherCount: 0,
checkoutDiffFallbackRefreshTargetCount: 0,
})),
dispose: vi.fn(),
},
});
await session.handleMessage({
type: "checkout/subscribe_diff",
requestId: "req-sub",
subscriptionId: "sub-1",
cwd: "/tmp/repo",
compare: { mode: "base", baseRef: "main" },
});
await session.handleMessage({
type: "checkout/get_file_hunks",
requestId: "req-hunks",
cwd: "/tmp/repo",
path: "src/example.ts",
compare: { mode: "base", baseRef: "main" },
});
await session.handleMessage({
type: "checkout/unsubscribe_diff",
subscriptionId: "sub-1",
});
expect(getCheckoutDiffFileMock).toHaveBeenCalledWith(
"/tmp/repo",
{
mode: "base",
baseRef: "main",
ignoreWhitespace: undefined,
includeStructured: true,
},
"src/example.ts",
{ paseoHome: "/tmp/paseo-test" },
);
expect(emitted[1]).toMatchObject({
type: "checkout/file_hunks_response",
payload: {
requestId: "req-hunks",
path: "src/example.ts",
error: null,
},
});
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
});

View File

@@ -28,6 +28,9 @@ import {
type CaptureTerminalRequest,
type SubscribeCheckoutDiffRequest,
type UnsubscribeCheckoutDiffRequest,
type CheckoutSubscribeDiffRequest,
type CheckoutUnsubscribeDiffRequest,
type CheckoutGetFileHunksRequest,
type DirectorySuggestionsRequest,
type EditorTargetDescriptorPayload,
type EditorTargetId,
@@ -149,6 +152,7 @@ import { type WorktreeConfig } from "../utils/worktree.js";
import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
import {
getCheckoutDiff,
getCheckoutDiffFile,
getCheckoutStatus,
listBranchSuggestions,
commitChanges,
@@ -622,6 +626,7 @@ export class Session {
private inflightRequests = 0;
private peakInflightRequests = 0;
private readonly checkoutDiffSubscriptions = new Map<string, () => void>();
private readonly checkoutDiffLazySubscriptions = new Map<string, () => void>();
private readonly workspaceGitSubscriptions = new Map<string, () => void>();
private readonly registerVoiceSpeakHandler?: (
agentId: string,
@@ -1783,6 +1788,18 @@ export class Session {
this.handleUnsubscribeCheckoutDiffRequest(msg);
break;
case "checkout/subscribe_diff":
await this.handleCheckoutSubscribeDiffRequest(msg);
break;
case "checkout/get_file_hunks":
await this.handleCheckoutGetFileHunksRequest(msg);
break;
case "checkout/unsubscribe_diff":
this.handleCheckoutUnsubscribeDiffRequest(msg);
break;
case "checkout_switch_branch_request":
await this.handleCheckoutSwitchBranchRequest(msg);
break;
@@ -4277,6 +4294,89 @@ export class Session {
this.checkoutDiffSubscriptions.delete(msg.subscriptionId);
}
private async handleCheckoutSubscribeDiffRequest(
msg: CheckoutSubscribeDiffRequest,
): Promise<void> {
const cwd = expandTilde(msg.cwd);
this.checkoutDiffLazySubscriptions.get(msg.subscriptionId)?.();
this.checkoutDiffLazySubscriptions.delete(msg.subscriptionId);
const subscription = await this.checkoutDiffManager.subscribeLazy(
{ cwd, compare: msg.compare },
(snapshot) => {
this.emit({
type: "checkout/diff_snapshot",
payload: {
subscriptionId: msg.subscriptionId,
...snapshot,
},
});
},
);
this.checkoutDiffLazySubscriptions.set(msg.subscriptionId, subscription.unsubscribe);
this.emit({
type: "checkout/diff_snapshot",
payload: {
subscriptionId: msg.subscriptionId,
...subscription.initial,
requestId: msg.requestId,
},
});
}
private async handleCheckoutGetFileHunksRequest(msg: CheckoutGetFileHunksRequest): Promise<void> {
const cwd = expandTilde(msg.cwd);
let hunks = this.checkoutDiffManager.getFileHunks(cwd, msg.compare, msg.path);
let error: {
code: "UNKNOWN" | "NOT_ALLOWED" | "NOT_GIT_REPO" | "MERGE_CONFLICT";
message: string;
} | null = null;
if (hunks === null) {
try {
const file = await getCheckoutDiffFile(
cwd,
{
mode: msg.compare.mode,
baseRef: msg.compare.baseRef,
ignoreWhitespace: msg.compare.ignoreWhitespace,
includeStructured: true,
},
msg.path,
{ paseoHome: this.paseoHome },
);
if (file) {
hunks = file.hunks;
} else {
hunks = [];
error = {
code: "UNKNOWN",
message: "File no longer present in diff",
};
}
} catch (caughtError) {
hunks = [];
error = toCheckoutError(caughtError);
}
}
this.emit({
type: "checkout/file_hunks_response",
payload: {
requestId: msg.requestId,
path: msg.path,
hunks,
error,
},
});
}
private handleCheckoutUnsubscribeDiffRequest(msg: CheckoutUnsubscribeDiffRequest): void {
this.checkoutDiffLazySubscriptions.get(msg.subscriptionId)?.();
this.checkoutDiffLazySubscriptions.delete(msg.subscriptionId);
}
private async handleCheckoutSwitchBranchRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_switch_branch_request" }>,
): Promise<void> {
@@ -7387,6 +7487,11 @@ export class Session {
}
this.checkoutDiffSubscriptions.clear();
for (const unsubscribe of this.checkoutDiffLazySubscriptions.values()) {
unsubscribe();
}
this.checkoutDiffLazySubscriptions.clear();
for (const unsubscribe of this.workspaceGitSubscriptions.values()) {
unsubscribe();
}

View File

@@ -800,6 +800,7 @@ export class VoiceAssistantWebSocketServer {
features: {
// COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow.
providersSnapshot: true,
lazyDiffRpcs: true,
},
};
}

View File

@@ -47,6 +47,13 @@ import {
LoopLogsResponseSchema,
LoopStopResponseSchema,
} from "../server/loop/rpc-schemas.js";
import {
CheckoutDiffSnapshotSchema,
CheckoutFileHunksResponseSchema,
CheckoutGetFileHunksRequestSchema,
CheckoutSubscribeDiffRequestSchema,
CheckoutUnsubscribeDiffRequestSchema,
} from "../server/checkout/rpc-schemas.js";
// ---------------------------------------------------------------------------
// Mutable daemon config schemas (shared between server store and client)
// ---------------------------------------------------------------------------
@@ -1013,12 +1020,12 @@ const CheckoutErrorCodeSchema = z.enum([
"UNKNOWN",
]);
const CheckoutErrorSchema = z.object({
export const CheckoutErrorSchema = z.object({
code: CheckoutErrorCodeSchema,
message: z.string(),
});
const CheckoutDiffCompareSchema = z.object({
export const CheckoutDiffCompareSchema = z.object({
mode: z.enum(["uncommitted", "base"]),
baseRef: z.string().optional(),
ignoreWhitespace: z.boolean().optional(),
@@ -1231,18 +1238,18 @@ export const ArchiveWorkspaceRequestSchema = z.object({
// Highlighted diff token schema
// Note: style can be a compound class name (e.g., "heading meta") from the syntax highlighter
const HighlightTokenSchema = z.object({
export const HighlightTokenSchema = z.object({
text: z.string(),
style: z.string().nullable(),
});
const DiffLineSchema = z.object({
export const DiffLineSchema = z.object({
type: z.enum(["add", "remove", "context", "header"]),
content: z.string(),
tokens: z.array(HighlightTokenSchema).optional(),
});
const DiffHunkSchema = z.object({
export const DiffHunkSchema = z.object({
oldStart: z.number(),
oldCount: z.number(),
newStart: z.number(),
@@ -1250,7 +1257,7 @@ const DiffHunkSchema = z.object({
lines: z.array(DiffLineSchema),
});
const ParsedDiffFileSchema = z.object({
export const ParsedDiffFileSchema = z.object({
path: z.string(),
isNew: z.boolean(),
isDeleted: z.boolean(),
@@ -1464,6 +1471,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CheckoutStatusRequestSchema,
SubscribeCheckoutDiffRequestSchema,
UnsubscribeCheckoutDiffRequestSchema,
CheckoutSubscribeDiffRequestSchema,
CheckoutUnsubscribeDiffRequestSchema,
CheckoutGetFileHunksRequestSchema,
CheckoutCommitRequestSchema,
CheckoutMergeRequestSchema,
CheckoutMergeFromBaseRequestSchema,
@@ -1686,6 +1696,7 @@ export const ServerInfoStatusPayloadSchema = z
features: z
.object({
providersSnapshot: z.boolean().optional(),
lazyDiffRpcs: z.boolean().optional(),
})
.optional(),
})
@@ -2789,6 +2800,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CheckoutStatusResponseSchema,
SubscribeCheckoutDiffResponseSchema,
CheckoutDiffUpdateSchema,
CheckoutDiffSnapshotSchema,
CheckoutFileHunksResponseSchema,
CheckoutCommitResponseSchema,
CheckoutMergeResponseSchema,
CheckoutMergeFromBaseResponseSchema,
@@ -2999,8 +3012,13 @@ export type CheckoutStatusRequest = z.infer<typeof CheckoutStatusRequestSchema>;
export type CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>;
export type SubscribeCheckoutDiffRequest = z.infer<typeof SubscribeCheckoutDiffRequestSchema>;
export type UnsubscribeCheckoutDiffRequest = z.infer<typeof UnsubscribeCheckoutDiffRequestSchema>;
export type CheckoutSubscribeDiffRequest = z.infer<typeof CheckoutSubscribeDiffRequestSchema>;
export type CheckoutUnsubscribeDiffRequest = z.infer<typeof CheckoutUnsubscribeDiffRequestSchema>;
export type CheckoutGetFileHunksRequest = z.infer<typeof CheckoutGetFileHunksRequestSchema>;
export type SubscribeCheckoutDiffResponse = z.infer<typeof SubscribeCheckoutDiffResponseSchema>;
export type CheckoutDiffUpdate = z.infer<typeof CheckoutDiffUpdateSchema>;
export type CheckoutDiffSnapshot = z.infer<typeof CheckoutDiffSnapshotSchema>;
export type CheckoutFileHunksResponse = z.infer<typeof CheckoutFileHunksResponseSchema>;
export type CheckoutCommitRequest = z.infer<typeof CheckoutCommitRequestSchema>;
export type CheckoutCommitResponse = z.infer<typeof CheckoutCommitResponseSchema>;
export type CheckoutMergeRequest = z.infer<typeof CheckoutMergeRequestSchema>;

View File

@@ -1277,9 +1277,10 @@ export async function getCheckoutShortstat(
}
}
export async function getCheckoutDiff(
async function getCheckoutDiffInternal(
cwd: string,
compare: CheckoutDiffCompare,
options?: { path?: string },
context?: CheckoutContext,
): Promise<CheckoutDiffResult> {
await requireGitRepo(cwd);
@@ -1305,7 +1306,12 @@ export async function getCheckoutDiff(
const ignoreWhitespace = compare.ignoreWhitespace === true;
const changes = await listCheckoutFileChanges(cwd, refForDiff, ignoreWhitespace);
changes.sort((a, b) => {
const targetPath = options?.path;
const filteredChanges =
typeof targetPath === "string"
? changes.filter((change) => change.path === targetPath)
: changes;
filteredChanges.sort((a, b) => {
if (a.path === b.path) return 0;
return a.path < b.path ? -1 : 1;
});
@@ -1329,8 +1335,8 @@ export async function getCheckoutDiff(
}
};
const trackedChanges = changes.filter((change) => !change.isUntracked);
const untrackedChanges = changes.filter((change) => change.isUntracked === true);
const trackedChanges = filteredChanges.filter((change) => !change.isUntracked);
const untrackedChanges = filteredChanges.filter((change) => change.isUntracked === true);
const trackedChangeByPath = new Map(trackedChanges.map((change) => [change.path, change]));
const trackedNumstatByPath =
@@ -1514,6 +1520,24 @@ export async function getCheckoutDiff(
return { diff: diffText };
}
export async function getCheckoutDiff(
cwd: string,
compare: CheckoutDiffCompare,
context?: CheckoutContext,
): Promise<CheckoutDiffResult> {
return getCheckoutDiffInternal(cwd, compare, undefined, context);
}
export async function getCheckoutDiffFile(
cwd: string,
compare: CheckoutDiffCompare,
path: string,
context?: CheckoutContext,
): Promise<ParsedDiffFile | null> {
const result = await getCheckoutDiffInternal(cwd, compare, { path }, context);
return result.structured?.[0] ?? null;
}
export async function commitChanges(
cwd: string,
options: { message: string; addAll?: boolean },