diff --git a/packages/app/e2e/browser/branch-switcher.spec.ts b/packages/app/e2e/browser/branch-switcher.spec.ts index 9799242390..0f37f83b10 100644 --- a/packages/app/e2e/browser/branch-switcher.spec.ts +++ b/packages/app/e2e/browser/branch-switcher.spec.ts @@ -27,15 +27,22 @@ async function holdBranchSuggestions(page: Page) { await page.routeWebSocket(daemonWsRoutePattern(), (browser) => { const server = browser.connectToServer(); browser.onMessage((message) => { - const envelope = JSON.parse(message.toString()); - if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_request") - requestCount++; + if (typeof message === "string") { + const envelope = JSON.parse(message); + if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_request") + requestCount++; + } server.send(message); }); server.onMessage(async (message) => { - const envelope = JSON.parse(message.toString()); - if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_response") { - await gate; + if (typeof message === "string") { + const envelope = JSON.parse(message); + if ( + envelope.type === "session" && + envelope.message?.type === "branch_suggestions_response" + ) { + await gate; + } } browser.send(message); }); diff --git a/packages/app/e2e/browser/changes-pane.spec.ts b/packages/app/e2e/browser/changes-pane.spec.ts index 7137c903ff..026e2791c9 100644 --- a/packages/app/e2e/browser/changes-pane.spec.ts +++ b/packages/app/e2e/browser/changes-pane.spec.ts @@ -8,7 +8,11 @@ import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { getServerId } from "../support/helpers/server-id"; import { connectSeedClient } from "../support/helpers/seed-client"; import { createTempGitRepo } from "../support/helpers/workspace"; -import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; +import { + openChangesPanel, + openChangesTreePanel, + waitForWorkspaceTabsVisible, +} from "../support/helpers/workspace-tabs"; interface DirtyWorkspace { id: string; @@ -262,6 +266,126 @@ test.afterEach(async () => { } }); +test("Changes without a comparison base explains how to view pending changes", async ({ page }) => { + const workspace = await createWorkspaceWithManyTinyDiffs(2, "dev"); + await openWorkspaceChangesTree(page, workspace); + await expect( + page.getByText("Comparison base is unavailable. Select Uncommitted to view pending changes."), + ).toBeVisible(); + await selectChangesComparison(page, "Uncommitted"); + await expect(changesTree(page).getByText("file-0000.ts", { exact: true })).toBeVisible(); +}); +async function openWorkspaceChangesTree(page: Page, workspace: DirtyWorkspace): Promise { + await page.setViewportSize({ width: 1400, height: 900 }); + await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id)); + await waitForWorkspaceTabsVisible(page); + await openChangesTreePanel(page); +} + +test("streamed Changes shows the tree before completion and applies a one-file refresh", async ({ + page, +}, testInfo) => { + test.setTimeout(120_000); + const workspace = await createLargeStreamedWorkspace(); + await addOversizedSibling(workspace); + const stream = await holdDiffCompletion(page); + await openWorkspaceChangesSurface(page, workspace, 90_000); + await expectAnnouncedTreeAndLoader(page); + await page.screenshot({ path: testInfo.outputPath("tree-loading.png") }); + stream.finish(); + await expect.poll(() => stream.completed, { timeout: 60_000 }).toBeGreaterThan(0); + await expect(page.getByRole("progressbar", { name: "Loading..." })).toHaveCount(0); + const connections = stream.connections; + const panel = page.getByTestId("working-diff-panel").filter({ visible: true }); + await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("src/file-0000.ts, +90, -1"); + await addOneLineToFirstFile(workspace); + await expect.poll(() => stream.changedBodies.at(-1), { timeout: 30_000 }).toBe(1); + await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("src/file-0000.ts, +91, -1"); + await expect(page.getByRole("progressbar", { name: "Loading..." })).toHaveCount(0); + expect(stream.connections).toBe(connections); + await page.screenshot({ path: testInfo.outputPath("refreshed.png") }); +}); + +async function createLargeStreamedWorkspace(): Promise { + const workspace = await createWorkspaceWithManyTinyDiffs(252); + await Promise.all( + Array.from({ length: 252 }, (_, file) => + writeFile( + path.join(workspace.repoPath, `src/file-${String(file).padStart(4, "0")}.ts`), + Array.from( + { length: 90 }, + (_unused, line) => + `export const fn${line} = (param: string) => ({ key: param, value: ${file} });`, + ).join("\n") + "\n", + ), + ), + ); + return workspace; +} +async function addOversizedSibling(workspace: DirtyWorkspace): Promise { + await writeFile(path.join(workspace.repoPath, "zz-too-large.txt"), "x".repeat(1024 * 1024 + 1)); +} +async function addOneLineToFirstFile(workspace: DirtyWorkspace): Promise { + const file = path.join(workspace.repoPath, "src/file-0000.ts"); + await writeFile(file, (await readFile(file, "utf8")) + "export const refreshed = true;\n"); +} +async function holdDiffCompletion(page: Page) { + const { decodeDiffFrame, DiffOpcode } = await import("@getpaseo/protocol/binary-frames/index"); + let holding = true; + let connections = 0; + let completed = 0; + const completions: Array<() => void> = []; + const changedBodies: number[] = []; + const bodyCounts = new Map(); + await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => { + connections++; + const serverSocket = browserSocket.connectToServer(); + browserSocket.onMessage((message) => serverSocket.send(message)); + serverSocket.onMessage((message) => { + if ( + typeof message !== "string" && + message[0] >= DiffOpcode.Begin && + message[0] <= DiffOpcode.End + ) { + const frame = decodeDiffFrame(message); + if (frame.opcode === DiffOpcode.Begin) bodyCounts.set(frame.slot, 0); + else if (frame.opcode === DiffOpcode.Bodies) + bodyCounts.set(frame.slot, (bodyCounts.get(frame.slot) ?? 0) + frame.files.length); + else { + completed++; + changedBodies.push(bodyCounts.get(frame.slot) ?? 0); + } + } + if (holding && typeof message !== "string" && message[0] === DiffOpcode.End) { + completions.push(() => browserSocket.send(message)); + return; + } + browserSocket.send(message); + }); + }); + return { + changedBodies, + get connections() { + return connections; + }, + get completed() { + return completed; + }, + finish: () => { + holding = false; + completions.splice(0).forEach((send) => send()); + }, + }; +} +async function expectWorkingDiffLoaded(page: Page, timeout: number): Promise { + const panel = page.getByTestId("working-diff-panel").filter({ visible: true }); + await expect(panel.getByTestId("diff-file-0")).toBeAttached({ timeout }); +} +async function expectAnnouncedTreeAndLoader(page: Page): Promise { + await expect(changesTree(page).first()).toBeVisible(); + await expect(page.getByRole("progressbar", { name: "Loading..." }).first()).toBeVisible(); +} + test("Changes opens All changes for a clean checkout", async ({ page }) => { const workspace = await createWorkspaceWithCommittedDiff(); @@ -499,6 +623,35 @@ test("an empty Changes comparison links to the populated comparison", async ({ p await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0"); }); +test("legacy diff hosts keep All, Committed and Uncommitted comparisons", async ({ page }) => { + await useLegacyDiffHost(page); + const workspace = await createWorkspaceWithScrollableComparisons(); + await openWorkspaceChangesSurface(page, workspace); + await expect(changesTree(page).getByText("committed", { exact: true })).toBeVisible(); + await expect(changesTree(page).getByText("uncommitted", { exact: true })).toBeVisible(); + await selectChangesComparison(page, "Committed"); + await expectWorkingComparisonFiles(page, "committed"); + await selectChangesComparison(page, "Uncommitted"); + await expectWorkingComparisonFiles(page, "uncommitted"); +}); +async function useLegacyDiffHost(page: Page): Promise { + await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => { + const serverSocket = browserSocket.connectToServer(); + browserSocket.onMessage((message) => serverSocket.send(message)); + serverSocket.onMessage((message) => { + if (typeof message === "string") { + const envelope = JSON.parse(message); + if (envelope.message?.payload?.status === "server_info") { + delete envelope.message.payload.features.diffSubscription; + browserSocket.send(JSON.stringify(envelope)); + return; + } + } + browserSocket.send(message); + }); + }); +} + test("Changes comparison controls the working diff and tree selection focuses its file", async ({ page, }) => { @@ -1311,11 +1464,13 @@ test("canvas diff uses the overlay scrollbar and its thumb controls vertical scr test("canvas headers keep a many-file diff bounded while scrolling end to end", async ({ page, }) => { - test.setTimeout(180_000); + test.setTimeout(300_000); const workspace = await createWorkspaceWithManyTinyDiffs(2_000); await useUnwrappedDiffLines(page); await openWorkspaceChangesSurface(page, workspace, 90_000); + // Measure document bounds after delivery, independently of the cold Git read. + await expectWorkingDiffLoaded(page, 240_000); const root = page.getByTestId("git-diff-canvas-root"); const scroller = page.getByTestId("git-diff-scroll"); await expect(root.locator('[data-testid^="git-diff-sticky-header-"]')).toHaveCount(2); @@ -1894,12 +2049,16 @@ async function createWorkspaceWithTwoSelectionDiffs(): Promise { return { id: created.workspace.id, repoPath: repo.path }; } -async function createWorkspaceWithManyTinyDiffs(fileCount: number): Promise { +async function createWorkspaceWithManyTinyDiffs( + fileCount: number, + branch = "main", +): Promise { const files = Array.from({ length: fileCount }, (_, index) => ({ path: `src/file-${String(index).padStart(4, "0")}.ts`, content: "export const value = 0;\n", })); const repo = await createTempGitRepo("changes-canvas-many-files-", { files }); + if (branch !== "main") execFileSync("git", ["branch", "-m", branch], { cwd: repo.path }); await Promise.all( files.map((file, index) => writeFile(path.join(repo.path, file.path), `export const value = ${index + 1};\n`), diff --git a/packages/app/e2e/browser/explorer-resize-performance.spec.ts b/packages/app/e2e/browser/explorer-resize-performance.spec.ts index 33cf48c94c..ca2ec5bf13 100644 --- a/packages/app/e2e/browser/explorer-resize-performance.spec.ts +++ b/packages/app/e2e/browser/explorer-resize-performance.spec.ts @@ -79,6 +79,12 @@ async function previewExplorerResize(page: Page, distance: number) { return { ...measurement, width: after }; } +async function expectCleanChangesLoaded(page: Page): Promise { + await expect( + page.getByTestId("changes-tree-panel").getByText("No changes to display", { exact: true }), + ).toBeVisible(); +} + async function expectExplorerWidthSurvivesReload(page: Page, width: number): Promise { await page.reload(); const explorer = page.getByTestId("workspace-explorer-sidebar"); @@ -100,6 +106,7 @@ test("Explorer drag previews without rerendering panes or rewriting the shared s await runWorkspaceActionFromCommandCenter(page, "Split pane right"); await page.getByTestId("workspace-explorer-toggle").first().click(); await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible(); + await expectCleanChangesLoaded(page); // Verify that direct pane renders are being counted before resetting them. expect( await page.evaluate(() => diff --git a/packages/app/src/data/push-router.test.ts b/packages/app/src/data/push-router.test.ts index d6d9011259..3590cfe30c 100644 --- a/packages/app/src/data/push-router.test.ts +++ b/packages/app/src/data/push-router.test.ts @@ -1,4 +1,4 @@ -import type { OwnedSubscription, SubscriptionObserver } from "@getpaseo/client"; +import type { DiffState, OwnedSubscription, SubscriptionObserver } from "@getpaseo/client"; import { QueryClient, QueryObserver, skipToken } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import type { MutableDaemonConfig, SessionOutboundMessage } from "@getpaseo/protocol/messages"; @@ -144,10 +144,21 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {} ["status", "providers_snapshot_update"], () => {}, ), - observeCheckoutDiff(cwd, compare) { + observeDiff(input) { + const cwd = input.cwd; + let mode: "uncommitted" | "base" | "all" = "uncommitted"; + if (input.head.kind === "rev") mode = "base"; + else if (input.mergeBase) mode = "all"; + const compare = { + mode, + ...(input.base.kind === "rev" && input.base.rev !== "HEAD" + ? { baseRef: input.base.rev } + : {}), + ignoreWhitespace: input.ignoreWhitespace, + }; const subscriptionId = `server-diff-${subscribeCheckoutDiffCalls.length + 1}`; subscribeCheckoutDiffCalls.push({ cwd, compare, subscriptionId }); - const handle = observe( + const handle = observe( { subscriptionId, cwd, files: [], error: null, requestId: "subscribe-checkout-diff" }, ["checkout_diff_update", "subscribe_checkout_diff_response"], () => unsubscribeCheckoutDiffCalls.push(subscriptionId), @@ -155,9 +166,43 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {} "subscriptionId" in message.payload && message.payload.subscriptionId === subscriptionId, ); - if (!config.rejectCheckoutDiffSubscribe) return handle; - return { + const modern: OwnedSubscription = { ...handle, + ready: Promise.resolve({ + phase: "ready", + revision: 1, + resolved: { baseSha: "", headSha: "", mergeBaseSha: "" }, + files: [], + diffTooLarge: false, + subscriptionId, + }), + subscribe: (observer) => + handle.subscribe({ + snapshot: (payload) => + observer.snapshot({ + phase: "ready", + revision: 1, + resolved: { baseSha: "", headSha: "", mergeBaseSha: "" }, + files: payload.files, + diffTooLarge: payload.diffTooLarge === true, + subscriptionId, + }), + update: (message) => { + if (message.type === "checkout_diff_update") + observer.snapshot({ + phase: "ready", + revision: 2, + resolved: { baseSha: "", headSha: "", mergeBaseSha: "" }, + files: message.payload.files, + diffTooLarge: message.payload.diffTooLarge === true, + subscriptionId, + }); + }, + }), + }; + if (!config.rejectCheckoutDiffSubscribe) return modern; + return { + ...modern, subscribe: (observer) => { queueMicrotask(() => observer.error?.(new Error("subscribe failed"))); return () => {}; @@ -328,7 +373,7 @@ describe("server data push router", () => { const first = publish(files, "first"); const same = publish(structuredClone(files), "reopen"); expect(same.files).toBe(first.files); - expect(same.requestId).toBe("reopen"); + expect(same.requestId).toBe("diff:1:ready"); const changed = structuredClone(files); changed[0]!.hunks[0]!.lines[0]!.tokens![0]!.style = "variable"; const next = publish(changed, "edit"); @@ -430,7 +475,7 @@ describe("server data push router", () => { files: [], error: null, diffTooLarge: true, - requestId: "diff-1", + requestId: "diff:1:ready", }); fake.emit({ @@ -449,7 +494,7 @@ describe("server data push router", () => { files: [], error: null, diffTooLarge: true, - requestId: `subscription:${serverSubscriptionId}`, + requestId: "diff:2:ready", }); unsubscribeObserver(); diff --git a/packages/app/src/data/push-router.ts b/packages/app/src/data/push-router.ts index cc32f8f8e3..5e51ded0d4 100644 --- a/packages/app/src/data/push-router.ts +++ b/packages/app/src/data/push-router.ts @@ -1,3 +1,5 @@ +import { checkoutDiffSnapshot, type CheckoutDiffSnapshot } from "@/git/diff-snapshot"; +import type { DiffState } from "@getpaseo/client"; import type { OwnedSubscription } from "@getpaseo/client"; import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import type { Query, QueryCacheNotifyEvent, QueryClient, QueryKey } from "@tanstack/react-query"; @@ -23,14 +25,9 @@ type ProvidersSnapshotUpdateMessage = Extract< SessionOutboundMessage, { type: "providers_snapshot_update" } >; -type SubscribeCheckoutDiffResponseMessage = Extract< - SessionOutboundMessage, - { type: "subscribe_checkout_diff_response" } ->; type StatusMessage = Extract; type TerminalsChangedMessage = Extract; -type CheckoutDiffResponsePayload = SubscribeCheckoutDiffResponseMessage["payload"]; -type CheckoutDiffCachePayload = Omit; +type CheckoutDiffCachePayload = CheckoutDiffSnapshot; type ListTerminalsPayload = ListTerminalsResponse["payload"]; interface CheckoutDiffCompare { @@ -49,7 +46,7 @@ interface CheckoutDiffRoute { } interface CheckoutDiffRegistration extends CheckoutDiffRoute { - subscription: OwnedSubscription; + subscription: OwnedSubscription; } interface WorkspaceTerminalsRoute { @@ -75,7 +72,7 @@ export type ProvidersSnapshotUpdate = ProvidersSnapshotUpdateMessage; interface ServerDataPushClient { getProvidersSnapshot: import("@getpaseo/client/internal/daemon-client").DaemonClient["getProvidersSnapshot"]; observeEvents: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeEvents"]; - observeCheckoutDiff: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeCheckoutDiff"]; + observeDiff: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeDiff"]; observeTerminals: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeTerminals"]; } @@ -339,34 +336,34 @@ function reconcileCheckoutDiffSubscriptions(input: { } for (const [key, desired] of input.desired) { if (input.active.has(key)) continue; - const subscription = input.client.observeCheckoutDiff(desired.cwd, desired.compare); + const { mode, baseRef, ignoreWhitespace } = desired.compare; + const revision = mode === "uncommitted" ? "HEAD" : baseRef; + if (!revision) continue; + const subscription = input.client.observeDiff({ + cwd: desired.cwd, + base: { kind: "rev", rev: revision }, + head: mode === "base" ? { kind: "rev", rev: "HEAD" } : { kind: "worktree" }, + mergeBase: mode !== "uncommitted", + ignoreWhitespace, + }); const registration = { ...desired, subscription }; input.active.set(key, registration); - const apply = ( - payload: Omit & { requestId?: string }, - ) => { - if (input.active.get(key) !== registration) return; - const { subscriptionId, ...snapshot } = payload; - setCheckoutDiffPayload({ - activeCheckoutDiffSubscriptions: input.active, - queryClient: input.queryClient, - serverId: input.serverId, - subscriptionId: key, - payload: { - ...snapshot, - files: orderCheckoutDiffFiles(payload.files), - requestId: payload.requestId ?? `subscription:${subscriptionId}`, - }, - }); - }; subscription.subscribe({ - snapshot: apply, - update: (message) => { - if (message.type === "checkout_diff_update") apply(message.payload); + snapshot: (state) => { + if (input.active.get(key) !== registration) return; + const snapshot = checkoutDiffSnapshot(desired.cwd, state); + setCheckoutDiffPayload({ + activeCheckoutDiffSubscriptions: input.active, + queryClient: input.queryClient, + serverId: input.serverId, + subscriptionId: key, + payload: { ...snapshot, files: orderCheckoutDiffFiles(snapshot.files) }, + }); }, + update: () => {}, error: (error) => { if (input.active.get(key) === registration) input.active.delete(key); - console.error("[server-data] observeCheckoutDiff failed", { + console.error("[server-data] observeDiff failed", { serverId: input.serverId, cwd: desired.cwd, error, diff --git a/packages/app/src/git/changed-files-tree.tsx b/packages/app/src/git/changed-files-tree.tsx index 0b88d33e96..f020cfa101 100644 --- a/packages/app/src/git/changed-files-tree.tsx +++ b/packages/app/src/git/changed-files-tree.tsx @@ -13,10 +13,10 @@ import { type DiffTreeRow, } from "@/git/diff-tree"; import { FileHeader } from "@/git/file-header"; -import type { ParsedDiffFile } from "@/git/use-diff-query"; +import type { DiffFileHeader } from "@/git/diff-snapshot"; export interface ChangedFilesTreeProps { - files: ParsedDiffFile[]; + files: DiffFileHeader[]; mode: WorkingDiffMode; onSelectFile: (path: string) => void; collapsedFolderPaths: string[]; diff --git a/packages/app/src/git/diff-order.ts b/packages/app/src/git/diff-order.ts index d57bb0ef29..0ad572866f 100644 --- a/packages/app/src/git/diff-order.ts +++ b/packages/app/src/git/diff-order.ts @@ -13,6 +13,6 @@ export function orderCheckoutDiffFiles(files: ParsedDiffFile[]): ParsedDiffFile[ // chains, so it is skipped; the collapsed set is empty because the flat list // always carries every file, whatever the rail has collapsed. return flattenDiffTree(buildDiffTree(files), new Set()).flatMap((row) => - row.kind === "file" ? [row.file] : [], + row.kind === "file" ? [files[row.fileIndex]] : [], ); } diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 59d3b49797..3581166980 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -1,3 +1,4 @@ +import type { DiffFileHeader } from "@/git/diff-snapshot"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { floatingActionsClearance } from "@/components/ui/floating-action-button"; import { useState, useCallback, useMemo, type ReactElement, type ReactNode } from "react"; @@ -1094,6 +1095,7 @@ interface DiffBodyContentProps { emptyMessage: string; emptyAction: ChangesEmptyAction | null; children: ReactElement; + loadingLabel: string; checkingRepositoryLabel: string; notRepositoryLabel: string; } @@ -1110,11 +1112,16 @@ function DiffBodyContent({ emptyAction, children, checkingRepositoryLabel, + loadingLabel, notRepositoryLabel, }: DiffBodyContentProps) { if (isStatusLoading) { return ( - + {checkingRepositoryLabel} @@ -1136,12 +1143,16 @@ function DiffBodyContent({ } if (isDiffLoading) { return ( - + ); } - if (diffTooLarge) { + if (diffTooLarge && !hasChanges) { return ; } if (diffErrorMessage) { @@ -1320,7 +1331,7 @@ function ChangesTreeRail({ }: { shown: boolean; children: ReactElement; - files: ParsedDiffFile[]; + files: DiffFileHeader[]; mode: WorkingDiffMode; onSelectFile: (path: string) => void; treeWidth?: number; @@ -1360,7 +1371,7 @@ function ChangesBody({ children: ReactElement; desktopTreeVisible: boolean; isMobile: boolean; - files: ParsedDiffFile[]; + files: DiffFileHeader[]; mode: WorkingDiffMode; onSelectFile: (path: string) => void; treeWidth?: number; @@ -1585,6 +1596,7 @@ export function ChangesSurface({ selectBase: handleSelectBase, selectAll: handleSelectAll, files, + treeFiles, diffPayloadError, diffTooLarge, isDiffLoading, @@ -1834,6 +1846,7 @@ export function ChangesSurface({ hasChanges={hasChanges} emptyMessage={emptyMessage} emptyAction={emptyAction} + loadingLabel={t("common.loading")} checkingRepositoryLabel={t("workspace.git.diff.checkingRepository")} notRepositoryLabel={t("workspace.git.diff.notRepository")} > @@ -1864,7 +1877,7 @@ export function ChangesSurface({ presentation={presentation} desktopTreeVisible={desktopTreeVisible} isMobile={isMobile} - files={files} + files={treeFiles} mode={workingMode} onSelectFile={handleSelectTreeFile} treeWidth={instanceState.treeWidth} diff --git a/packages/app/src/git/diff-sharing.ts b/packages/app/src/git/diff-sharing.ts index 6bdac10ddf..d426960d9f 100644 --- a/packages/app/src/git/diff-sharing.ts +++ b/packages/app/src/git/diff-sharing.ts @@ -1,7 +1,8 @@ import { replaceEqualDeep } from "@tanstack/react-query"; -import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages"; +import type { CheckoutDiffSnapshot } from "./diff-snapshot"; +import type { ParsedDiffFile } from "@getpaseo/protocol/messages"; -type DiffPayload = Omit; +type DiffPayload = CheckoutDiffSnapshot; /** File identity owns text-model reuse. Never reconstruct the hunk/token graph. */ export function retainDiffFiles( @@ -30,6 +31,7 @@ export function shareCheckoutDiff(previous: unknown, incoming: unknown): DiffPay error === old.error && old.cwd === next.cwd && old.requestId === next.requestId && + old.entries === next.entries && old.diffTooLarge === next.diffTooLarge ? old : { ...next, files, error }; diff --git a/packages/app/src/git/diff-snapshot.ts b/packages/app/src/git/diff-snapshot.ts new file mode 100644 index 0000000000..84f650b588 --- /dev/null +++ b/packages/app/src/git/diff-snapshot.ts @@ -0,0 +1,74 @@ +import type { DiffState, DiffEntry } from "@getpaseo/client"; +import type { + DiffError, + ParsedDiffFile, + SubscribeCheckoutDiffRequest, +} from "@getpaseo/protocol/messages"; +export type DiffFileHeader = Omit; +export interface CheckoutDiffSnapshot { + requestId: string; + cwd: string; + files: ParsedDiffFile[]; + entries?: DiffEntry[]; + error: DiffError | null; + diffTooLarge?: boolean; +} +export function checkoutDiffSnapshot(cwd: string, state: DiffState): CheckoutDiffSnapshot { + const common = { cwd, requestId: `diff:${state.revision}:${state.phase}` }; + if (state.phase === "loading") + return { ...common, files: [], entries: state.entries, error: null }; + if (state.phase === "error") return { ...common, files: [], error: state.error }; + return { ...common, files: state.files, error: null, diffTooLarge: state.diffTooLarge }; +} +export function diffEntryHeaders(entries: DiffEntry[]): DiffFileHeader[] { + return entries.map((entry) => ({ + path: entry.path, + ...(entry.oldPath === null ? {} : { oldPath: entry.oldPath }), + isNew: entry.change === "added", + isDeleted: entry.change === "deleted", + additions: entry.additions, + deletions: entry.deletions, + status: entry.content === "binary" ? "binary" : "ok", + })); +} +export interface CheckoutDiffResult { + files: ParsedDiffFile[]; + entries: DiffEntry[] | null; + payloadError: CheckoutDiffSnapshot["error"]; + diffTooLarge: boolean; + isLoading: boolean; + isFetching: boolean; + isError: boolean; + error: null; +} + +/** + * The result derives from the payload alone. Until a payload arrives there is no + * diff to describe, so the boundary reports loading rather than an empty diff — + * whether the query is idle (an inactive retained panel), in flight, or the host + * is disconnected. Reporting `files: []` as settled made callers render "+0 -0" + * and "No changes" for a diff nobody has fetched yet. + */ +export function deriveCheckoutDiffResult(payload: CheckoutDiffSnapshot | null): CheckoutDiffResult { + const payloadError = payload?.error ?? null; + return { + files: payload?.files ?? [], + entries: payload?.entries ?? null, + payloadError, + diffTooLarge: payload?.diffTooLarge === true, + isLoading: payload === null || payload.entries !== undefined, + isFetching: false, + isError: Boolean(payloadError), + error: null, + }; +} + +export function checkoutDiffComparisonError( + compare: Pick, +): DiffError | null { + if (compare.mode === "uncommitted" || compare.baseRef?.trim()) return null; + return { + code: "INVALID_COMPARE", + message: "Comparison base is unavailable. Select Uncommitted to view pending changes.", + }; +} diff --git a/packages/app/src/git/diff-tree.ts b/packages/app/src/git/diff-tree.ts index 49709cd5e2..c3130b1cae 100644 --- a/packages/app/src/git/diff-tree.ts +++ b/packages/app/src/git/diff-tree.ts @@ -1,6 +1,6 @@ -import type { ParsedDiffFile } from "@/git/use-diff-query"; +import type { DiffFileHeader } from "@/git/diff-snapshot"; -// Builds a directory hierarchy from the `ParsedDiffFile[]` the Changes view +// Builds a directory hierarchy from the `DiffFileHeader[]` the Changes view // renders. The tree renders on every form factor, consistent with the Files // explorer. // @@ -17,7 +17,7 @@ import type { ParsedDiffFile } from "@/git/use-diff-query"; export interface DiffTreeFileNode { kind: "file"; - file: ParsedDiffFile; + file: DiffFileHeader; fileIndex: number; /** basename, e.g. "diff-pane.tsx" */ name: string; @@ -47,7 +47,7 @@ export interface DiffTreeFolderRow { export interface DiffTreeFileRow { kind: "file"; - file: ParsedDiffFile; + file: DiffFileHeader; fileIndex: number; depth: number; } @@ -73,7 +73,7 @@ function sortTree(node: DiffTreeDirNode): void { } /** Build the (uncompressed) directory tree. Returns the virtual root (dirPath ""). */ -export function buildDiffTree(files: ParsedDiffFile[]): DiffTreeDirNode { +export function buildDiffTree(files: DiffFileHeader[]): DiffTreeDirNode { const root: DiffTreeDirNode = { kind: "dir", dirPath: "", name: "", children: [] }; const dirByPath = new Map([["", root]]); diff --git a/packages/app/src/git/file-header.tsx b/packages/app/src/git/file-header.tsx index d19d58cbf2..39427a612b 100644 --- a/packages/app/src/git/file-header.tsx +++ b/packages/app/src/git/file-header.tsx @@ -28,11 +28,11 @@ import { fileNameForPath, formatDiffCount, } from "@/git/file-header-presentation"; -import type { ParsedDiffFile } from "@/git/use-diff-query"; +import type { DiffFileHeader } from "@/git/diff-snapshot"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; export interface FileHeaderProps { - file: ParsedDiffFile; + file: DiffFileHeader; workspaceFileDragScope?: { serverId: string; workspaceId: string }; bodyVisible: boolean; showsBodyState?: boolean; @@ -73,7 +73,7 @@ function expandedAriaValue(showsBodyState: boolean, bodyVisible: boolean): boole return showsBodyState ? bodyVisible : undefined; } -function fileHeaderAccessibilityLabel(canvasRendered: boolean, file: ParsedDiffFile) { +function fileHeaderAccessibilityLabel(canvasRendered: boolean, file: DiffFileHeader) { if (!canvasRendered) return undefined; return `${file.path}, +${formatDiffCount(file.additions)}, -${formatDiffCount(file.deletions)}`; } @@ -130,7 +130,7 @@ function fileHeaderNameStyle(showsBodyState: boolean, isHovered: boolean) { ]; } -function fileChange(file: ParsedDiffFile): "added" | "deleted" | "modified" { +function fileChange(file: DiffFileHeader): "added" | "deleted" | "modified" { return diffFileChangeKind(file); } diff --git a/packages/app/src/git/use-diff-query.test.ts b/packages/app/src/git/use-diff-query.test.ts index 94b18a7d16..809319bca4 100644 --- a/packages/app/src/git/use-diff-query.test.ts +++ b/packages/app/src/git/use-diff-query.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ParsedDiffFile } from "@getpaseo/protocol/messages"; -import { deriveCheckoutDiffResult } from "./use-diff-query"; +import { deriveCheckoutDiffResult } from "./diff-snapshot"; function createParsedDiffFile(path: string): ParsedDiffFile { return { @@ -59,6 +59,7 @@ describe("deriveCheckoutDiffResult", () => { expect(result).toEqual({ files: [], + entries: null, payloadError: error, diffTooLarge: true, isLoading: false, @@ -68,3 +69,27 @@ describe("deriveCheckoutDiffResult", () => { }); }); }); + +it("shows the announced tree while file bodies are still loading", () => { + const entries = [ + { + path: "src/app.ts", + oldPath: null, + change: "modified", + content: "text", + additions: 3, + deletions: 1, + }, + ] as const; + const result = deriveCheckoutDiffResult({ ...createPayload([]), entries: [...entries] }); + expect(result.isLoading).toBe(true); + expect(result.entries).toEqual(entries); + expect(result.files).toEqual([]); +}); + +it("settles a comparison without a base and leaves Uncommitted available", async () => { + const { checkoutDiffComparisonError } = await import("./diff-snapshot"); + expect(checkoutDiffComparisonError({ mode: "all" })).toMatchObject({ code: "INVALID_COMPARE" }); + expect(checkoutDiffComparisonError({ mode: "base" })).toMatchObject({ code: "INVALID_COMPARE" }); + expect(checkoutDiffComparisonError({ mode: "uncommitted" })).toBeNull(); +}); diff --git a/packages/app/src/git/use-diff-query.ts b/packages/app/src/git/use-diff-query.ts index ebfcd4c354..6ba1202bb7 100644 --- a/packages/app/src/git/use-diff-query.ts +++ b/packages/app/src/git/use-diff-query.ts @@ -5,7 +5,13 @@ import { useRetainedPanelActive } from "@/components/retained-panel"; import { useReplicaQuery } from "@/data/query"; import { checkoutDiffPushRoute } from "@/data/push-router"; import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages"; +import { + checkoutDiffComparisonError, + deriveCheckoutDiffResult, + type CheckoutDiffSnapshot, +} from "./diff-snapshot"; +export { deriveCheckoutDiffResult } from "./diff-snapshot"; +import type { ParsedDiffFile } from "@getpaseo/protocol/messages"; import { checkoutDiffQueryKey } from "@/git/query-keys"; interface UseCheckoutDiffQueryOptions { @@ -18,7 +24,7 @@ interface UseCheckoutDiffQueryOptions { queryScope?: string; } -type CheckoutDiffQueryPayload = Omit; +type CheckoutDiffQueryPayload = CheckoutDiffSnapshot; // Re-export the canonical protocol type so all consumers share one definition. export type { ParsedDiffFile }; @@ -77,7 +83,11 @@ export function useCheckoutDiffQuery({ return normalizedScope ? [...comparisonKey, "scope", normalizedScope] : comparisonKey; }, [serverId, cwd, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]); const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]); - const routeEnabled = Boolean(queryEnabled && isConnected && cwd && !requiresUpdate); + const comparisonError = + queryEnabled && isConnected ? checkoutDiffComparisonError(normalizedCompare) : null; + const routeEnabled = Boolean( + queryEnabled && isConnected && cwd && !requiresUpdate && !comparisonError, + ); const query = useReplicaQuery({ queryKey, @@ -97,46 +107,13 @@ export function useCheckoutDiffQuery({ }), }); - return deriveCheckoutDiffResult( + const boundaryError = requiresUpdate && isConnected - ? { - requestId: "unsupported", - cwd, - files: [], - error: { code: "UNKNOWN", message: "Update the host to view All changes" }, - } + ? { code: "UNKNOWN" as const, message: "Update the host to view All changes" } + : comparisonError; + return deriveCheckoutDiffResult( + boundaryError + ? { requestId: "unavailable", cwd, files: [], error: boundaryError } : (query.data ?? null), ); } - -export interface CheckoutDiffResult { - files: ParsedDiffFile[]; - payloadError: CheckoutDiffQueryPayload["error"]; - diffTooLarge: boolean; - isLoading: boolean; - isFetching: boolean; - isError: boolean; - error: null; -} - -/** - * The result derives from the payload alone. Until a payload arrives there is no - * diff to describe, so the boundary reports loading rather than an empty diff — - * whether the query is idle (an inactive retained panel), in flight, or the host - * is disconnected. Reporting `files: []` as settled made callers render "+0 -0" - * and "No changes" for a diff nobody has fetched yet. - */ -export function deriveCheckoutDiffResult( - payload: CheckoutDiffQueryPayload | null, -): CheckoutDiffResult { - const payloadError = payload?.error ?? null; - return { - files: payload?.files ?? [], - payloadError, - diffTooLarge: payload?.diffTooLarge === true, - isLoading: payload === null, - isFetching: false, - isError: Boolean(payloadError), - error: null, - }; -} diff --git a/packages/app/src/git/use-working-diff.ts b/packages/app/src/git/use-working-diff.ts index f835d44f53..9f32ce8819 100644 --- a/packages/app/src/git/use-working-diff.ts +++ b/packages/app/src/git/use-working-diff.ts @@ -9,6 +9,8 @@ import { useReviewAttachmentSnapshot, useReviewFeedback, } from "@/review"; +import { useSessionStore } from "@/stores/session-store"; +import { diffEntryHeaders } from "./diff-snapshot"; import { useCheckoutDiffQuery } from "@/git/use-diff-query"; import { useCheckoutStatusQuery } from "@/git/use-status-query"; import { useWorkingDiffComparison } from "@/git/working-diff-comparison"; @@ -44,7 +46,11 @@ export function useWorkingDiff({ const statusErrorMessage = status?.error?.message ?? (isStatusError && statusError instanceof Error ? statusError.message : null); - const baseRef = gitStatus?.baseRef ?? undefined; + const modernDiff = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.diffSubscription === true, + ); + // COMPAT(diffSubscription): added in v0.15.0, remove after 2027-05-06. + const baseRef = (modernDiff ? gitStatus?.comparisonBaseRef : gitStatus?.baseRef) ?? undefined; const currentBranchName = gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null; @@ -59,6 +65,7 @@ export function useWorkingDiff({ const { files, + entries, payloadError: diffPayloadError, diffTooLarge, isLoading: isDiffLoading, @@ -71,6 +78,7 @@ export function useWorkingDiff({ enabled: enabled && isGit, queryScope, }); + const treeFiles = useMemo(() => (entries ? diffEntryHeaders(entries) : files), [entries, files]); const reviewDraftKey = useMemo( () => buildReviewDraftKey({ @@ -112,6 +120,7 @@ export function useWorkingDiff({ selectBase, selectAll, files, + treeFiles, diffPayloadError, diffTooLarge, isDiffLoading, diff --git a/packages/client/package.json b/packages/client/package.json index 810cb8b0c9..d128a3f007 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -45,7 +45,8 @@ "prepack": "npm run build:clean", "typecheck": "tsgo --noEmit", "typecheck:examples": "tsgo -p tsconfig.examples.json --noEmit", - "test": "vitest run" + "test": "vitest run", + "measure:diff-materialization": "tsx scripts/measure-diff-materialization.ts" }, "dependencies": { "@getpaseo/protocol": "0.15.3", diff --git a/packages/client/scripts/measure-diff-materialization.ts b/packages/client/scripts/measure-diff-materialization.ts new file mode 100644 index 0000000000..54f7b1f53a --- /dev/null +++ b/packages/client/scripts/measure-diff-materialization.ts @@ -0,0 +1,38 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { performance } from "node:perf_hooks"; +import { decodeDiffFrame } from "@getpaseo/protocol/binary-frames/index"; +import { validateWSOutboundMessage } from "@getpaseo/protocol/validation/ws-outbound"; +import { DiffStream } from "../src/diff/stream.js"; +const artifacts = resolve(import.meta.dirname, "../../../.artifacts/diff-subscription"); +const results = []; +for (const count of [1, 30]) { + const corpus = JSON.parse(await readFile(resolve(artifacts, `corpus-${count}.json`), "utf8")) as { + frames: string[]; + legacy: string; + }; + const frames = corpus.frames.map((value) => Buffer.from(value, "base64")); + const binary: number[] = []; + const json: number[] = []; + for (let iteration = 0; iteration < 15; iteration++) { + let files = 0; + const stream = new DiffStream((state) => { + if (state.phase === "ready") files = state.files.length; + }); + let start = performance.now(); + for (const frame of frames) stream.receive(decodeDiffFrame(frame)); + binary.push(performance.now() - start); + start = performance.now(); + validateWSOutboundMessage(JSON.parse(corpus.legacy)); + json.push(performance.now() - start); + if (!files) throw new Error("No materialized files"); + } + const median = (values: number[]) => + values.slice(3).sort((a, b) => a - b)[Math.floor((values.length - 3) / 2)]; + const result = { count, materializationMs: median(binary), jsonParseValidationMs: median(json) }; + results.push(result); + console.log(JSON.stringify(result)); +} +await writeFile(resolve(artifacts, "client-metrics.json"), JSON.stringify(results, null, 2)); +if (results.some((result) => result.materializationMs > result.jsonParseValidationMs)) + process.exitCode = 1; diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index c8fbff81e1..b70a0e991f 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1,3 +1,9 @@ +import { + DiffStreamRouter, + DiffSubscriptionError, + type DiffCompare, + type DiffState, +} from "./diff/index.js"; import { ChunkedTransport, ChunkedTransportProtocolError, @@ -1414,6 +1420,20 @@ export class DaemonClient { private connectionFailure: Error | undefined; private authFailureReasonValue: DaemonAuthFailureReason | null = null; private connectionState: ConnectionState = { status: "idle" }; + private readonly diffStreams = new DiffStreamRouter({ + modern: (compare) => + this.observe( + "diff.subscribe.response", + { type: "diff.subscribe.request", ...compare }, + { + validateSnapshot: (payload) => { + if (payload.error) throw new DiffSubscriptionError(payload.error); + }, + }, + ), + legacy: (cwd, compare) => this.observeCheckoutDiff(cwd, compare), + failed: (error) => this.logger.error({ err: error }, "Diff observer failed"), + }); private readonly terminalStreams = new TerminalStreamRouter(); private pendingBinaryFileReads = new Map(); private activeBinaryFileTransfers = new Map(); @@ -1763,6 +1783,7 @@ export class DaemonClient { this.disposeTransport(1000, "Client closed"); this.providerSnapshotUpdates.clear(); this.clearWaiters(new Error("Daemon client closed")); + await this.diffStreams.close(); await this.owned.close(); this.creations.close(); this.rejectPendingSendQueue(new Error("Daemon client closed")); @@ -2666,7 +2687,12 @@ export class DaemonClient { private observe( responseType: T, message: { type: SessionInboundMessage["type"] } & Record, - options?: { requestId?: string; timeout?: number; signal?: AbortSignal }, + options?: { + requestId?: string; + timeout?: number; + signal?: AbortSignal; + validateSnapshot?: (payload: CorrelatedResponsePayload) => void; + }, ): OwnedSubscription> { let resetSource = false; return this.owned.observeRequest>( @@ -2692,12 +2718,16 @@ export class DaemonClient { requestType: message.type, error: payload.error, }); + options?.validateSnapshot?.(payload); accept(payload); return payload; }, }); } catch (error) { - resetSource = !(error instanceof DaemonRpcError) && this.isConnected; + resetSource = + !(error instanceof DaemonRpcError) && + !(error instanceof DiffSubscriptionError) && + this.isConnected; throw error; } }, @@ -4732,6 +4762,26 @@ export class DaemonClient { }); } + observeDiff(compare: DiffCompare): OwnedSubscription { + return this.diffStreams.observe(compare); + } + + async readDiff(compare: DiffCompare): Promise { + const observation = this.observeDiff(compare); + try { + return await new Promise((resolve) => { + observation.subscribe({ + snapshot: (state) => { + if (state.phase !== "loading") resolve(state); + }, + update: () => {}, + }); + }); + } finally { + await observation.release(); + } + } + observeCheckoutDiff( cwd: string, compare: { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean }, @@ -7014,6 +7064,7 @@ export class DaemonClient { this.chunkedTransport?.close(); this.chunkedTransport = null; this.owned.disconnected(); + this.diffStreams.disconnected(); this.providerSnapshotUpdates.clear(); this.stopLivenessHeartbeat(); this.cleanupTransport(); @@ -7204,6 +7255,10 @@ export class DaemonClient { for (const handler of this.hostFrameListeners) handler(hostFrame); return true; } + if (rawBytes[0] >= 0x40 && rawBytes[0] <= 0x4f) { + this.diffStreams.receive(rawBytes); + return true; + } if (isPluginFrameOpcode(rawBytes[0])) { this.handlePluginFrame(rawBytes); return true; @@ -7404,6 +7459,7 @@ export class DaemonClient { } this.owned.disconnected(); + this.diffStreams.disconnected(); this.providerSnapshotUpdates.clear(); // Clear all pending waiters and queued sends since the connection was lost @@ -7563,6 +7619,7 @@ export class DaemonClient { this.reconnectAttempt = 0; this.updateConnectionState({ status: "connected" }, { event: "HELLO_SERVER_INFO" }); this.startLivenessHeartbeat(); + this.diffStreams.connected(serverInfo.features?.diffSubscription === true); this.owned.restore(serverInfo, { ...DEFAULT_CLIENT_CAPABILITIES, ...this.config.capabilities, @@ -7839,3 +7896,5 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon cwd: merged.cwd, }; } + +export type { DiffCompare, DiffState, DiffEntry, ResolvedShas } from "./diff/index.js"; diff --git a/packages/client/src/diff/client.test.ts b/packages/client/src/diff/client.test.ts new file mode 100644 index 0000000000..04f06d227f --- /dev/null +++ b/packages/client/src/diff/client.test.ts @@ -0,0 +1,210 @@ +import { expect, test } from "vitest"; +import { DaemonClient, type DaemonTransport } from "../daemon-client.js"; +import { + encodeDiffFrame, + DiffOpcode, + type DiffFrame, +} from "@getpaseo/protocol/binary-frames/index"; +import type { DiffState } from "./index.js"; +function socket() { + let message: (data: unknown, binary?: boolean) => void = () => {}; + let open: () => void = () => {}; + let close: (event?: unknown) => void = () => {}; + const sent: Record[] = []; + const transport: DaemonTransport = { + send: (data) => { + if (typeof data === "string") { + const value = JSON.parse(data); + if (value.type === "session") { + sent.push(value.message); + if (value.message.type === "subscription.release.request") + queueMicrotask(() => + receive({ + type: "subscription.release.response", + payload: { + requestId: value.message.requestId, + subscriptionId: value.message.subscriptionId, + released: true, + }, + }), + ); + } + } + }, + close: () => {}, + onMessage: (handler) => { + message = handler; + return () => {}; + }, + onOpen: (handler) => { + open = handler; + return () => {}; + }, + onClose: (handler) => { + close = handler; + return () => {}; + }, + onError: () => () => {}, + }; + const receive = (value: unknown) => + message(JSON.stringify({ type: "session", message: value }), false); + return { + transport, + sent, + receive, + raw: (bytes: Uint8Array) => message(bytes, true), + frame: (frame: DiffFrame) => message(encodeDiffFrame(frame), true), + disconnect: () => close({ code: 1001 }), + open: (modern = true) => { + open(); + receive({ + type: "status", + payload: { + status: "server_info", + serverId: "srv_test", + hostname: null, + version: null, + features: { ownedSubscriptions: true, diffSubscription: modern, checkoutDiffAll: true }, + }, + }); + }, + }; +} +const compare = { + cwd: "/repo", + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "worktree" }, +} as const; +const begin: DiffFrame = { + opcode: DiffOpcode.Begin, + slot: 3, + revision: 1, + baseRevision: 0, + baseSha: "a", + headSha: "b", + mergeBaseSha: "", + entries: [], +}; +const end: DiffFrame = { + opcode: DiffOpcode.End, + slot: 3, + revision: 1, + diffTooLarge: false, + omitted: [], + error: null, +}; +async function setup(modern = true) { + const wire = socket(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "diff-test", + transportFactory: () => wire.transport, + reconnect: { enabled: false }, + }); + const connecting = client.connect(); + wire.open(modern); + await connecting; + return { wire, client }; +} +function acknowledge(wire: ReturnType, ordinal = 1) { + const request = wire.sent.findLast((value) => value.type === "diff.subscribe.request"); + wire.receive({ + type: "diff.subscribe.response", + payload: { + requestId: request?.requestId, + subscriptionId: `diff-${ordinal}`, + slot: 3, + error: null, + }, + }); +} +test("attaches binary routing during handshake and resubscribes with a full state on reconnect", async () => { + const { wire, client } = await setup(); + try { + const states: DiffState[] = []; + const observation = client.observeDiff(compare); + observation.subscribe({ snapshot: (state) => states.push(state), update: () => {} }); + acknowledge(wire); + wire.frame(begin); + wire.frame(end); + expect(states.map((state) => state.phase)).toEqual(["loading", "ready"]); + wire.disconnect(); + const connected = client.connect(); + wire.open(); + await connected; + acknowledge(wire, 2); + wire.frame(begin); + wire.frame(end); + expect(states.map((state) => state.phase)).toEqual(["loading", "ready", "loading", "ready"]); + await observation.release(); + } finally { + await client.close(); + } +}); +test("an unknown version fails only its subscription and readDiff resolves typed subscribe errors", async () => { + const { wire, client } = await setup(); + try { + const observation = client.observeDiff(compare); + const states: DiffState[] = []; + observation.subscribe({ snapshot: (state) => states.push(state), update: () => {} }); + acknowledge(wire); + const malformed = { ...begin, slot: 3 }; + const bytes = encodeDiffFrame(malformed); + bytes[2] = 255; + wire.raw(bytes); + expect(states.at(-1)).toMatchObject({ phase: "error", error: { code: "UNKNOWN" } }); + const next = client.readDiff(compare); + const request = wire.sent.findLast((value) => value.type === "diff.subscribe.request"); + wire.receive({ + type: "diff.subscribe.response", + payload: { + requestId: request?.requestId, + subscriptionId: "", + slot: 0, + error: { code: "INVALID_COMPARE", message: "invalid" }, + }, + }); + expect(await next).toMatchObject({ phase: "error", error: { code: "INVALID_COMPARE" } }); + expect(client.isConnected).toBe(true); + await observation.release(); + } finally { + await client.close(); + } +}); +test.each(["uncommitted", "base", "all"] as const)( + "old hosts serve the %s comparison through the tagged compatibility route", + async (mode) => { + const { wire, client } = await setup(false); + try { + const comparison = + mode === "uncommitted" + ? compare + : { + ...compare, + base: { kind: "rev", rev: "refs/tags/v1" } as const, + head: mode === "base" ? ({ kind: "rev", rev: "HEAD" } as const) : compare.head, + mergeBase: true, + }; + const result = client.readDiff(comparison); + const request = wire.sent.find((value) => value.type === "subscribe_checkout_diff_request"); + expect(request?.compare).toMatchObject({ mode }); + wire.receive({ + type: "subscribe_checkout_diff_response", + payload: { + requestId: request?.requestId, + subscriptionId: "legacy", + cwd: "/repo", + files: [], + error: null, + }, + }); + wire.receive({ + type: "checkout_diff_update", + payload: { subscriptionId: "legacy", cwd: "/repo", files: [], error: null }, + }); + expect(await result).toMatchObject({ phase: "ready", files: [] }); + } finally { + await client.close(); + } + }, +); diff --git a/packages/client/src/diff/index.ts b/packages/client/src/diff/index.ts new file mode 100644 index 0000000000..31fe4394a6 --- /dev/null +++ b/packages/client/src/diff/index.ts @@ -0,0 +1,246 @@ +import { + decodeDiffFrame, + type DiffEntry as WireEntry, + type DiffBegin, +} from "@getpaseo/protocol/binary-frames/index"; +import type { + DiffSubscribeRequest, + SubscribeCheckoutDiffRequest, + DiffError, + DiffSubscribeResponse, + SubscribeCheckoutDiffResponse, + ParsedDiffFile, +} from "@getpaseo/protocol/messages"; +import type { OwnedSubscription, SubscriptionObserver } from "../connection/index.js"; +import { DiffStream } from "./stream.js"; +export type { DiffEndpoint } from "@getpaseo/protocol/messages"; +export type DiffCompare = Omit; + +export type DiffEntry = Omit; +export type ResolvedShas = Pick; + +export type DiffState = + | { phase: "loading"; revision: number; entries: DiffEntry[] } + | { + phase: "ready"; + revision: number; + resolved: ResolvedShas; + diffTooLarge: boolean; + files: ParsedDiffFile[]; + } + | { phase: "error"; revision: number; error: DiffError }; +type LegacyCompare = SubscribeCheckoutDiffRequest["compare"]; + +interface Port { + modern(compare: DiffCompare): OwnedSubscription; + legacy( + cwd: string, + compare: LegacyCompare, + ): OwnedSubscription; + failed(error: unknown): void; +} +export class DiffSubscriptionError extends Error { + constructor(readonly detail: DiffError) { + super(detail.message); + } +} +interface Registration { + start(modern: boolean): void; + disconnected(): void; + release(): Promise; +} +/** Capability selection, slot ownership and reconnect belong to this domain. */ +export class DiffStreamRouter { + private registrations = new Set(); + private slots = new Map(); + private mode: boolean | null = null; + constructor(private port: Port) {} + connected(modern: boolean): void { + this.mode = modern; + for (const registration of this.registrations) registration.start(modern); + } + disconnected(): void { + this.mode = null; + this.slots.clear(); + for (const registration of this.registrations) registration.disconnected(); + } + async close(): Promise { + await Promise.all([...this.registrations].map((registration) => registration.release())); + } + receive(bytes: Uint8Array): void { + const owner = this.slots.get(bytes[1]); + owner?.receive(bytes); + } + private releaseBackend(backend: OwnedSubscription | null): void { + void backend?.release().catch(this.port.failed); + } + observe(compare: DiffCompare): OwnedSubscription { + let backend: OwnedSubscription | null = null; + let state: (DiffState & { subscriptionId: string }) | null = null; + let slot: number | null = null; + let released = false; + let generation = 0; + let stream: DiffStream; + const observers = new Set>(); + let resolve!: (state: DiffState & { subscriptionId: string }) => void; + const ready = new Promise((accept) => { + resolve = accept; + }); + const publish = (value: DiffState) => { + if (released) return; + state = { ...value, subscriptionId: backend?.subscriptionId ?? "" }; + resolve(state); + for (const observer of observers) { + try { + observer.snapshot(state); + } catch (error) { + this.port.failed(error); + } + } + }; + const detach = () => { + if (slot !== null) this.slots.delete(slot); + slot = null; + }; + const fail = (error: unknown) => { + detach(); + publish({ + phase: "error", + revision: 0, + error: + error instanceof DiffSubscriptionError + ? error.detail + : { code: "UNKNOWN", message: error instanceof Error ? error.message : String(error) }, + }); + this.releaseBackend(backend); + }; + const registration: Registration = { + start: (modern) => { + if (released || backend) return; + const current = ++generation; + const failed = (error: unknown) => { + if (current === generation) fail(error); + }; + stream = new DiffStream(publish); + if (modern) { + const observation = this.port.modern(compare); + backend = observation; + observation.subscribe({ + snapshot: (payload) => { + if (current !== generation) return; + detach(); + if (payload.error) { + fail(new DiffSubscriptionError(payload.error)); + return; + } + slot = payload.slot; + const owner = { + receive: (bytes: Uint8Array) => { + try { + stream.receive(decodeDiffFrame(bytes)); + } catch (error) { + fail(error); + } + }, + }; + this.slots.set(slot, owner); + }, + update: () => {}, + error: failed, + }); + void observation.ready.catch(failed); + } else { + // COMPAT(diffSubscription): added in v0.15.0, remove after 2027-05-06. + const legacy = legacyCompare(compare); + if (!legacy) { + fail( + new DiffSubscriptionError({ + code: "INVALID_COMPARE", + message: "Update the host to use this comparison", + }), + ); + return; + } + const observation = this.port.legacy(compare.cwd, legacy); + backend = observation; + const apply = ( + payload: Pick< + SubscribeCheckoutDiffResponse["payload"], + "files" | "error" | "diffTooLarge" + >, + ) => { + if (current !== generation) return; + if (payload.error) + publish({ + phase: "error", + revision: 0, + error: { + code: payload.error.code === "NOT_GIT_REPO" ? "NOT_GIT_REPO" : "UNKNOWN", + message: payload.error.message, + }, + }); + else + publish({ + phase: "ready", + revision: (state?.revision ?? 0) + 1, + resolved: { baseSha: "", headSha: "", mergeBaseSha: "" }, + diffTooLarge: payload.diffTooLarge === true, + files: payload.files, + }); + }; + observation.subscribe({ + snapshot: apply, + update: (message) => { + if (message.type === "checkout_diff_update") apply(message.payload); + }, + error: failed, + }); + void observation.ready.catch(failed); + } + }, + disconnected: () => { + generation++; + detach(); + state = null; + const old = backend; + backend = null; + void old?.release().catch(this.port.failed); + }, + release: async () => { + if (released) return; + released = true; + generation++; + detach(); + this.registrations.delete(registration); + await backend?.release(); + observers.clear(); + }, + }; + this.registrations.add(registration); + if (this.mode !== null) registration.start(this.mode); + return { + get subscriptionId() { + return backend?.subscriptionId ?? null; + }, + ready, + subscribe: (observer) => { + observers.add(observer); + if (state) observer.snapshot(state); + return () => observers.delete(observer); + }, + release: () => registration.release(), + }; + } +} +function legacyCompare(compare: DiffCompare): LegacyCompare | null { + if (compare.highlight === false) return null; + const { base, head } = compare; + if (base.kind !== "rev") return null; + if (base.rev === "HEAD" && head.kind === "worktree") + return { mode: "uncommitted", ignoreWhitespace: compare.ignoreWhitespace }; + if (compare.mergeBase && head.kind === "rev" && head.rev === "HEAD") + return { mode: "base", baseRef: base.rev, ignoreWhitespace: compare.ignoreWhitespace }; + if (compare.mergeBase && head.kind === "worktree") + return { mode: "all", baseRef: base.rev, ignoreWhitespace: compare.ignoreWhitespace }; + return null; +} diff --git a/packages/client/src/diff/stream.test.ts b/packages/client/src/diff/stream.test.ts new file mode 100644 index 0000000000..b740469029 --- /dev/null +++ b/packages/client/src/diff/stream.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from "vitest"; +import { DiffStream } from "./stream.js"; +import { DiffOpcode, type DiffBegin } from "@getpaseo/protocol/binary-frames/index"; +import type { DiffState } from "./index.js"; +const entry = { + path: "a.ts", + oldPath: null, + change: "modified" as const, + content: "text" as const, + additions: 1, + deletions: 0, + body: true, +}; +const begin = (revision: number, baseRevision = 0): DiffBegin => ({ + opcode: DiffOpcode.Begin, + slot: 0, + revision, + baseRevision, + baseSha: "a", + headSha: "b", + mergeBaseSha: "", + entries: [entry], +}); +const end = (revision: number) => + ({ + opcode: DiffOpcode.End, + slot: 0, + revision, + diffTooLarge: false, + omitted: [], + error: null, + }) as const; +test("publishes the initial tree, commits on End and buffers refreshes with stable equal files", () => { + const states: DiffState[] = []; + const stream = new DiffStream((state) => states.push(state)); + stream.receive(begin(1)); + expect(states[0]).toMatchObject({ phase: "loading", entries: [{ path: "a.ts" }] }); + stream.receive({ + opcode: DiffOpcode.Bodies, + slot: 0, + revision: 1, + files: [{ path: "a.ts", hunks: [] }], + }); + expect(states).toHaveLength(1); + stream.receive(end(1)); + const ready = states[1]; + if (ready.phase !== "ready") throw new Error("Expected ready"); + stream.receive(begin(2, 1)); + stream.receive({ + opcode: DiffOpcode.Bodies, + slot: 0, + revision: 2, + files: [{ path: "a.ts", hunks: [] }], + }); + expect(states).toHaveLength(2); + stream.receive(end(2)); + const refreshed = states[2]; + if (refreshed.phase !== "ready") throw new Error("Expected ready"); + expect(refreshed.files[0]).toBe(ready.files[0]); +}); +test("superseded and stale frames cannot leave the client loading; End is authoritative", () => { + const states: DiffState[] = []; + const stream = new DiffStream((s) => states.push(s)); + stream.receive(begin(1)); + stream.receive(begin(2)); + stream.receive(end(1)); + expect(() => stream.receive(end(2))).toThrow(/Missing/); + stream.receive(begin(3)); + stream.receive({ ...end(3), error: { code: "REF_NOT_FOUND", message: "gone" } }); + expect(states.at(-1)).toMatchObject({ phase: "error", error: { code: "REF_NOT_FOUND" } }); + stream.receive({ ...begin(4), entries: [] }); + stream.receive(end(4)); + expect(states.at(-1)).toMatchObject({ phase: "ready", files: [] }); +}); +test("unchanged entries retain hunks, omissions override cache, removed paths disappear", () => { + const states: DiffState[] = []; + const stream = new DiffStream((s) => states.push(s)); + stream.receive(begin(1)); + stream.receive({ ...end(1), omitted: ["a.ts"], diffTooLarge: true }); + stream.receive({ ...begin(2, 1), entries: [{ ...entry, body: false }] }); + stream.receive(end(2)); + expect(states.at(-1)).toMatchObject({ phase: "ready", files: [{ status: "too_large" }] }); + stream.receive({ ...begin(3, 2), entries: [] }); + stream.receive(end(3)); + expect(states.at(-1)).toMatchObject({ phase: "ready", files: [] }); +}); diff --git a/packages/client/src/diff/stream.ts b/packages/client/src/diff/stream.ts new file mode 100644 index 0000000000..ac55f681ec --- /dev/null +++ b/packages/client/src/diff/stream.ts @@ -0,0 +1,94 @@ +import { + DiffOpcode, + materializeDiffFile, + type DiffBegin, + type DiffBody, + type DiffFrame, +} from "@getpaseo/protocol/binary-frames/index"; +import type { ParsedDiffFile } from "@getpaseo/protocol/messages"; +import type { DiffState } from "./index.js"; + +/** Owns revision assembly. End, rather than a local body count, commits the snapshot. */ +export class DiffStream { + private pending: { begin: DiffBegin; bodies: Map } | null = null; + private committed: Extract | null = null; + constructor(private publish: (state: DiffState) => void) {} + receive(frame: DiffFrame): void { + if (frame.opcode === DiffOpcode.Begin) { + if (frame.baseRevision !== 0 && frame.baseRevision !== this.committed?.revision) + throw new Error("Unknown diff base revision"); + if (new Set(frame.entries.map((entry) => entry.path)).size !== frame.entries.length) + throw new Error("Duplicate diff entry"); + this.pending = { begin: frame, bodies: new Map() }; + if (!this.committed) + this.publish({ + phase: "loading", + revision: frame.revision, + entries: frame.entries.map(({ body: _body, ...entry }) => entry), + }); + return; + } + const pending = this.pending; + if (!pending || frame.revision !== pending.begin.revision) return; + if (frame.opcode === DiffOpcode.Bodies) { + for (const body of frame.files) { + if (pending.bodies.has(body.path)) throw new Error("Duplicate diff body"); + const entry = pending.begin.entries.find((candidate) => candidate.path === body.path); + if (!entry?.body) throw new Error("Unexpected diff body"); + pending.bodies.set(body.path, body); + } + return; + } + this.pending = null; + if (frame.error) { + this.committed = null; + this.publish({ phase: "error", revision: frame.revision, error: frame.error }); + return; + } + const previous = new Map(this.committed?.files.map((file) => [file.path, file])); + const omitted = new Set(frame.omitted); + for (const path of omitted) + if (!pending.begin.entries.some((entry) => entry.path === path)) + throw new Error("Unknown omitted diff path"); + const files = pending.begin.entries.map((entry) => { + const old = previous.get(entry.path); + const body = pending.bodies.get(entry.path); + if (entry.content === "text" && !omitted.has(entry.path) && entry.body && !body) + throw new Error(`Missing diff body: ${entry.path}`); + if ( + entry.content === "text" && + !entry.body && + !omitted.has(entry.path) && + (!old || pending.begin.baseRevision === 0) + ) + throw new Error(`Missing inherited diff: ${entry.path}`); + let file = materializeDiffFile(entry, body ?? null, omitted.has(entry.path)); + if (entry.content === "text" && !entry.body && !omitted.has(entry.path) && old) + file = { ...file, hunks: old.hunks, status: old.status }; + return old && sameFile(old, file) ? old : file; + }); + const { revision, baseSha, headSha, mergeBaseSha } = pending.begin; + this.committed = { + phase: "ready", + revision, + resolved: { baseSha, headSha, mergeBaseSha }, + files, + diffTooLarge: frame.diffTooLarge, + }; + this.publish(this.committed); + } +} +function sameFile(a: ParsedDiffFile, b: ParsedDiffFile): boolean { + if ( + a.path !== b.path || + a.oldPath !== b.oldPath || + a.isNew !== b.isNew || + a.isDeleted !== b.isDeleted || + a.additions !== b.additions || + a.deletions !== b.deletions || + a.status !== b.status + ) + return false; + if (a.hunks === b.hunks) return true; + return JSON.stringify(a.hunks) === JSON.stringify(b.hunks); +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 390c9310f3..356613cb2b 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1300,6 +1300,8 @@ function createGeneratedClientId(): string { return `paseo-sdk-${randomId}`; } +export type { DiffCompare, DiffState, DiffEntry, ResolvedShas } from "./diff/index.js"; + interface WorkspaceLabelAssignment { client: DaemonClient; workspaceId: string; diff --git a/packages/protocol/src/binary-frames/demux.ts b/packages/protocol/src/binary-frames/demux.ts index 1a7617e35b..b21118d301 100644 --- a/packages/protocol/src/binary-frames/demux.ts +++ b/packages/protocol/src/binary-frames/demux.ts @@ -1,3 +1,4 @@ +import { decodeDiffFrame, DiffOpcode, type DiffFrame } from "./diff.js"; import { HOST_ROUTE_OPCODE, decodeHostRouteFrame, type HostRouteFrame } from "./host-route.js"; import { decodeFileTransferFrame, @@ -12,12 +13,20 @@ import { } from "./terminal.js"; export type BinaryFrame = + | { kind: "diff"; frame: DiffFrame } | { kind: "host_route"; frame: HostRouteFrame } | { kind: "terminal"; frame: TerminalStreamFrame } | { kind: "file_transfer"; frame: FileTransferFrame } | { kind: "plugin"; frame: PluginFrame }; export function decodeBinaryFrame(bytes: Uint8Array): BinaryFrame | null { + if (Object.values(DiffOpcode).some((opcode) => opcode === bytes[0])) { + return { kind: "diff", frame: decodeDiffFrame(bytes) }; + } + return decodeExistingBinaryFrame(bytes); +} + +function decodeExistingBinaryFrame(bytes: Uint8Array): BinaryFrame | null { switch (bytes[0]) { case HOST_ROUTE_OPCODE: { const frame = decodeHostRouteFrame(bytes); diff --git a/packages/protocol/src/binary-frames/diff.test.ts b/packages/protocol/src/binary-frames/diff.test.ts new file mode 100644 index 0000000000..fcc2885521 --- /dev/null +++ b/packages/protocol/src/binary-frames/diff.test.ts @@ -0,0 +1,270 @@ +import { expect, test } from "vitest"; +import { decodeDiffFrame, encodeDiffFrame } from "./diff.js"; +import { decodeBinaryFrame } from "./demux.js"; +import { decodePluginFrame } from "./plugin.js"; + +test("routes diff revisions independently of plugin frames", () => { + const frame = { + opcode: 0x40 as const, + slot: 0, + revision: 1, + baseRevision: 0, + baseSha: "", + headSha: "", + mergeBaseSha: "", + entries: [], + }; + const bytes = encodeDiffFrame(frame); + expect([...bytes]).toEqual([64, 0, 1, 1, 0, 0, 0, 0, 0]); + expect(decodeDiffFrame(bytes)).toEqual(frame); + expect(decodeBinaryFrame(bytes)).toEqual({ kind: "diff", frame }); + expect(decodePluginFrame(bytes)).toBeNull(); +}); + +test("roundtrips Unicode runs and materializes the existing file shape", async () => { + const { materializeDiffFile } = await import("./diff.js"); + const frame = { + opcode: 0x41 as const, + slot: 255, + revision: 128, + files: [ + { + path: "é.ts", + hunks: [ + { + oldStart: 1, + oldCount: 1, + newStart: 1, + newCount: 1, + lines: [ + { + type: "add" as const, + content: "😀é", + runs: [ + { style: "string", length: 2 }, + { style: null, length: 2 }, + ], + }, + ], + }, + ], + }, + ], + }; + expect(decodeDiffFrame(encodeDiffFrame(frame))).toEqual(frame); + const file = materializeDiffFile( + { + path: "é.ts", + oldPath: null, + change: "modified", + content: "text", + additions: 1, + deletions: 1, + body: true, + }, + frame.files[0], + false, + ); + expect(file.hunks[0].lines[0].tokens).toEqual([ + { text: "😀", style: "string" }, + { text: "é", style: null }, + ]); +}); + +test("preserves binary additions, modifications, deletions and mode-only text", async () => { + const { materializeDiffFile } = await import("./diff.js"); + for (const change of ["added", "modified", "deleted"] as const) { + const entry = { + path: "image.png", + oldPath: null, + change, + content: "binary" as const, + additions: 0, + deletions: 0, + body: false, + }; + const begin = { + opcode: 0x40 as const, + slot: 1, + revision: 1, + baseRevision: 0, + baseSha: "", + headSha: "", + mergeBaseSha: "", + entries: [entry], + }; + expect(decodeDiffFrame(encodeDiffFrame(begin))).toEqual(begin); + expect(materializeDiffFile(entry, null, false)).toEqual({ + path: "image.png", + isNew: change === "added", + isDeleted: change === "deleted", + additions: 0, + deletions: 0, + status: "binary", + hunks: [], + }); + } + expect( + materializeDiffFile( + { + path: "script", + oldPath: null, + change: "modified", + content: "text", + additions: 0, + deletions: 0, + body: true, + }, + { path: "script", hunks: [] }, + false, + ), + ).toEqual({ + path: "script", + isNew: false, + isDeleted: false, + additions: 0, + deletions: 0, + status: "ok", + hunks: [], + }); +}); + +test("rejects every truncated prefix, unknown version, trailing data, and invalid coverage", () => { + const end = { + opcode: 0x42 as const, + slot: 1, + revision: 9007199254740991, + diffTooLarge: true, + omitted: ["a.ts"], + error: { code: "REF_NOT_FOUND" as const, message: "missing" }, + }; + const bytes = encodeDiffFrame(end); + expect(decodeDiffFrame(bytes)).toEqual(end); + for (let length = 0; length < bytes.length; length++) + expect(() => decodeDiffFrame(bytes.slice(0, length))).toThrow(); + const version = bytes.slice(); + version[2] = 2; + expect(() => decodeDiffFrame(version)).toThrow("version"); + expect(() => decodeDiffFrame(Uint8Array.from([...bytes, 0]))).toThrow("Trailing"); + expect(() => + encodeDiffFrame({ + opcode: 0x41, + slot: 1, + revision: 1, + files: [ + { + path: "x", + hunks: [ + { + oldStart: 0, + oldCount: 0, + newStart: 1, + newCount: 1, + lines: [{ type: "add", content: "😀", runs: [{ style: null, length: 1 }] }], + }, + ], + }, + ], + }), + ).toThrow("cover"); +}); + +test("uses fixed UTF-8 byte vectors while runs count UTF-16 units", () => { + const bytes = Uint8Array.from([ + 65, 0, 1, 1, 1, 1, 120, 1, 0, 0, 1, 1, 1, 129, 7, 240, 159, 152, 128, 101, 204, 129, 1, 3, 4, + ]); + const frame = { + opcode: 0x41 as const, + slot: 0, + revision: 1, + files: [ + { + path: "x", + hunks: [ + { + oldStart: 0, + oldCount: 0, + newStart: 1, + newCount: 1, + lines: [ + { type: "add" as const, content: "😀é", runs: [{ style: "string", length: 4 }] }, + ], + }, + ], + }, + ], + }; + expect(encodeDiffFrame(frame)).toEqual(bytes); + expect(decodeDiffFrame(bytes)).toEqual(frame); + const unknownStyle = bytes.slice(); + unknownStyle[23] = 255; + expect(decodeDiffFrame(unknownStyle)).toEqual({ + ...frame, + files: [ + { + ...frame.files[0], + hunks: [ + { + ...frame.files[0].hunks[0], + lines: [{ type: "add", content: "😀é", runs: [{ style: null, length: 4 }] }], + }, + ], + }, + ], + }); +}); + +test("roundtrips generated entry lists across varint boundaries", () => { + for (const revision of [0, 1, 127, 128, 16383, 16384, 4294967296, Number.MAX_SAFE_INTEGER]) { + const entries = Array.from({ length: 130 }, (_, index) => ({ + path: `src/${index}-😀.ts`, + oldPath: `old/${index}.ts`, + change: "renamed" as const, + content: "text" as const, + additions: index * 128, + deletions: index, + body: true, + })); + const begin = { + opcode: 0x40 as const, + slot: 255, + revision, + baseRevision: 0, + baseSha: "base", + headSha: "head", + mergeBaseSha: "merge", + entries, + }; + expect(decodeDiffFrame(encodeDiffFrame(begin))).toEqual(begin); + } +}); + +test.each([true, false])( + "preserves BOM-prefixed paths and source lines with runs=%s", + (highlight) => { + const frame = { + opcode: 0x41 as const, + slot: 0, + revision: 1, + files: [ + { + path: "\uFEFFbom.ts", + hunks: [ + { + oldStart: 1, + oldCount: 1, + newStart: 1, + newCount: 1, + lines: (["add", "remove", "context"] as const).map((type) => ({ + type, + content: "\uFEFFvalue", + runs: highlight ? [{ style: null, length: 6 }] : undefined, + })), + }, + ], + }, + ], + }; + expect(decodeDiffFrame(encodeDiffFrame(frame))).toEqual(frame); + }, +); diff --git a/packages/protocol/src/binary-frames/diff.ts b/packages/protocol/src/binary-frames/diff.ts new file mode 100644 index 0000000000..9f11450b7d --- /dev/null +++ b/packages/protocol/src/binary-frames/diff.ts @@ -0,0 +1,360 @@ +import type { ParsedDiffFile } from "../messages.js"; + +// Opcodes 0x40–0x4f are reserved for diff revisions. Plugins own 0x20–0x2f. +export const DiffOpcode = { Begin: 0x40, Bodies: 0x41, End: 0x42 } as const; +export const DIFF_HIGHLIGHT_STYLES = [ + null, + "keyword", + "comment", + "string", + "number", + "literal", + "function", + "definition", + "class", + "type", + "tag", + "attribute", + "property", + "variable", + "operator", + "punctuation", + "regexp", + "escape", + "meta", + "heading", + "link", +] as const; +const CHANGES = ["added", "modified", "deleted", "renamed"] as const; +const CONTENTS = ["text", "binary"] as const; +const KINDS = ["context", "add", "remove", "header"] as const; +const ERRORS = [ + null, + "NOT_GIT_REPO", + "REF_NOT_FOUND", + "INVALID_COMPARE", + "TOO_MANY_SUBSCRIPTIONS", + "UNKNOWN", +] as const; +export interface DiffEntry { + path: string; + oldPath: string | null; + change: (typeof CHANGES)[number]; + content: (typeof CONTENTS)[number]; + additions: number; + deletions: number; + body: boolean; +} +export interface DiffRun { + style: string | null; + length: number; +} +export interface DiffStreamLine { + type: (typeof KINDS)[number]; + content: string; + runs?: DiffRun[]; +} +export interface DiffStreamHunk { + oldStart: number; + oldCount: number; + newStart: number; + newCount: number; + lines: DiffStreamLine[]; +} +export interface DiffBody { + path: string; + hunks: DiffStreamHunk[]; +} +export interface DiffBegin { + opcode: typeof DiffOpcode.Begin; + slot: number; + revision: number; + baseRevision: number; + baseSha: string; + headSha: string; + mergeBaseSha: string; + entries: DiffEntry[]; +} +export interface DiffBodies { + opcode: typeof DiffOpcode.Bodies; + slot: number; + revision: number; + files: DiffBody[]; +} +export interface DiffEnd { + opcode: typeof DiffOpcode.End; + slot: number; + revision: number; + diffTooLarge: boolean; + omitted: string[]; + error: { code: NonNullable<(typeof ERRORS)[number]>; message: string } | null; +} +export type DiffFrame = DiffBegin | DiffBodies | DiffEnd; +export class InvalidDiffFrameError extends Error {} + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + +class Writer { + private bytes: number[] = []; + byte(value: number): void { + if (!Number.isInteger(value) || value < 0 || value > 255) + throw new InvalidDiffFrameError("Invalid byte"); + this.bytes.push(value); + } + uint(value: number): void { + if (!Number.isSafeInteger(value) || value < 0) + throw new InvalidDiffFrameError("Invalid unsigned integer"); + do { + const next = value % 128; + value = Math.floor(value / 128); + this.byte(next + (value ? 128 : 0)); + } while (value); + } + str(value: string): void { + const bytes = textEncoder.encode(value); + this.uint(bytes.length); + for (const byte of bytes) this.byte(byte); + } + finish(): Uint8Array { + return Uint8Array.from(this.bytes); + } +} +class Reader { + private offset = 0; + constructor(private bytes: Uint8Array) {} + byte(): number { + if (this.offset >= this.bytes.length) throw new InvalidDiffFrameError("Truncated diff frame"); + return this.bytes[this.offset++]; + } + uint(): number { + let value = 0; + let factor = 1; + for (let index = 0; index < 8; index++) { + const byte = this.byte(); + value += (byte & 127) * factor; + if (!Number.isSafeInteger(value)) throw new InvalidDiffFrameError("Integer overflow"); + if (byte < 128) return value; + factor *= 128; + } + throw new InvalidDiffFrameError("Invalid varint"); + } + count(): number { + const value = this.uint(); + if (value > this.bytes.length - this.offset) + throw new InvalidDiffFrameError("Count exceeds frame"); + return value; + } + str(): string { + const size = this.count(); + const start = this.offset; + this.offset += size; + try { + return textDecoder.decode(this.bytes.subarray(start, this.offset)); + } catch { + throw new InvalidDiffFrameError("Invalid UTF-8"); + } + } + done(): void { + if (this.offset !== this.bytes.length) throw new InvalidDiffFrameError("Trailing bytes"); + } +} +function enumAt(values: readonly T[], index: number): T { + if (index >= values.length) throw new InvalidDiffFrameError("Unknown enum value"); + return values[index]; +} +function validateRuns(line: DiffStreamLine): void { + if (!line.runs) return; + let length = 0; + for (const run of line.runs) { + if (!Number.isSafeInteger(run.length) || run.length <= 0) + throw new InvalidDiffFrameError("Invalid run length"); + length += run.length; + } + if (length !== line.content.length) throw new InvalidDiffFrameError("Runs do not cover line"); +} +function writeLine(writer: Writer, line: DiffStreamLine): void { + validateRuns(line); + writer.byte(KINDS.indexOf(line.type) | (line.runs ? 128 : 0)); + writer.str(line.content); + writer.uint(line.runs ? line.runs.length : 0); + for (const run of line.runs ?? []) { + const style = DIFF_HIGHLIGHT_STYLES.findIndex((value) => value === run.style); + writer.byte(Math.max(0, style)); + writer.uint(run.length); + } +} +function readLine(reader: Reader): DiffStreamLine { + const flags = reader.byte(); + const type = enumAt(KINDS, flags & 127); + const content = reader.str(); + const count = reader.count(); + const runs: DiffRun[] = []; + for (let index = 0; index < count; index++) { + const styleIndex = reader.byte(); + const style = DIFF_HIGHLIGHT_STYLES[styleIndex] ?? null; + runs.push({ style, length: reader.uint() }); + } + if (!(flags & 128)) { + if (count) throw new InvalidDiffFrameError("Runs without flag"); + return { type, content }; + } + const line = { type, content, runs }; + validateRuns(line); + return line; +} +export function encodeDiffFrame(frame: DiffFrame): Uint8Array { + const writer = new Writer(); + writer.byte(frame.opcode); + writer.byte(frame.slot); + writer.byte(1); + writer.uint(frame.revision); + if (frame.opcode === DiffOpcode.Begin) { + writer.uint(frame.baseRevision); + writer.str(frame.baseSha); + writer.str(frame.headSha); + writer.str(frame.mergeBaseSha); + writer.uint(frame.entries.length); + for (const entry of frame.entries) { + if (entry.content === "binary" && entry.body) + throw new InvalidDiffFrameError("Binary entry has a body"); + writer.str(entry.path); + writer.str(entry.oldPath ?? ""); + writer.byte(CHANGES.indexOf(entry.change)); + writer.byte(CONTENTS.indexOf(entry.content)); + writer.uint(entry.additions); + writer.uint(entry.deletions); + writer.byte(Number(entry.body)); + } + } else if (frame.opcode === DiffOpcode.Bodies) { + writer.uint(frame.files.length); + for (const file of frame.files) { + writer.str(file.path); + writer.uint(file.hunks.length); + for (const hunk of file.hunks) { + writer.uint(hunk.oldStart); + writer.uint(hunk.oldCount); + writer.uint(hunk.newStart); + writer.uint(hunk.newCount); + writer.uint(hunk.lines.length); + for (const line of hunk.lines) writeLine(writer, line); + } + } + } else { + writer.byte(Number(frame.diffTooLarge)); + writer.uint(frame.omitted.length); + for (const path of frame.omitted) writer.str(path); + writer.byte(frame.error ? ERRORS.indexOf(frame.error.code) : 0); + writer.str(frame.error ? frame.error.message : ""); + } + return writer.finish(); +} +export function decodeDiffFrame(bytes: Uint8Array): DiffFrame { + const reader = new Reader(bytes); + const opcode = reader.byte(); + const slot = reader.byte(); + if (reader.byte() !== 1) throw new InvalidDiffFrameError("Unknown diff version"); + const revision = reader.uint(); + let frame: DiffFrame; + if (opcode === DiffOpcode.Begin) { + const baseRevision = reader.uint(); + const baseSha = reader.str(); + const headSha = reader.str(); + const mergeBaseSha = reader.str(); + const count = reader.count(); + const entries: DiffEntry[] = []; + for (let index = 0; index < count; index++) { + const path = reader.str(); + const oldPath = reader.str() || null; + const change = enumAt(CHANGES, reader.byte()); + const content = enumAt(CONTENTS, reader.byte()); + const additions = reader.uint(); + const deletions = reader.uint(); + const bodyByte = reader.byte(); + if (bodyByte > 1 || (content === "binary" && bodyByte)) + throw new InvalidDiffFrameError("Invalid entry body"); + entries.push({ path, oldPath, change, content, additions, deletions, body: bodyByte === 1 }); + } + frame = { opcode, slot, revision, baseRevision, baseSha, headSha, mergeBaseSha, entries }; + } else if (opcode === DiffOpcode.Bodies) { + const count = reader.count(); + const files: DiffBody[] = []; + for (let index = 0; index < count; index++) { + const path = reader.str(); + const hunkCount = reader.count(); + const hunks: DiffStreamHunk[] = []; + for (let h = 0; h < hunkCount; h++) { + const oldStart = reader.uint(); + const oldCount = reader.uint(); + const newStart = reader.uint(); + const newCount = reader.uint(); + const lineCount = reader.count(); + const lines: DiffStreamLine[] = []; + for (let l = 0; l < lineCount; l++) lines.push(readLine(reader)); + hunks.push({ oldStart, oldCount, newStart, newCount, lines }); + } + files.push({ path, hunks }); + } + frame = { opcode, slot, revision, files }; + } else if (opcode === DiffOpcode.End) { + const flags = reader.byte(); + if (flags > 1) throw new InvalidDiffFrameError("Unknown flags"); + const count = reader.count(); + const omitted: string[] = []; + for (let index = 0; index < count; index++) omitted.push(reader.str()); + const code = enumAt(ERRORS, reader.byte()); + const message = reader.str(); + frame = { + opcode, + slot, + revision, + diffTooLarge: flags === 1, + omitted, + error: code ? { code, message } : null, + }; + } else { + throw new InvalidDiffFrameError("Unknown diff opcode"); + } + reader.done(); + return frame; +} +export function runsToTokens( + line: DiffStreamLine, +): NonNullable { + let offset = 0; + return (line.runs ?? []).map((run) => { + const text = line.content.slice(offset, offset + run.length); + offset += run.length; + return { text, style: run.style }; + }); +} +export function materializeDiffFile( + entry: DiffEntry, + body: DiffBody | null, + omitted: boolean, +): ParsedDiffFile { + let status: "ok" | "binary" | "too_large" = "ok"; + if (entry.content === "binary") status = "binary"; + else if (omitted) status = "too_large"; + const hunks = body + ? body.hunks.map((hunk) => ({ + ...hunk, + lines: hunk.lines.map((line) => { + if (line.runs) + return { type: line.type, content: line.content, tokens: runsToTokens(line) }; + return { type: line.type, content: line.content }; + }), + })) + : []; + const file: ParsedDiffFile = { + path: entry.path, + isNew: entry.change === "added", + isDeleted: entry.change === "deleted", + additions: entry.additions, + deletions: entry.deletions, + hunks, + status, + }; + if (entry.oldPath !== null) file.oldPath = entry.oldPath; + return file; +} diff --git a/packages/protocol/src/binary-frames/index.ts b/packages/protocol/src/binary-frames/index.ts index bb2e7e002d..946a1c7051 100644 --- a/packages/protocol/src/binary-frames/index.ts +++ b/packages/protocol/src/binary-frames/index.ts @@ -3,3 +3,4 @@ export * from "./file-transfer.js"; export * from "./terminal.js"; export * from "./host-route.js"; export * from "./plugin.js"; +export * from "./diff.js"; diff --git a/packages/protocol/src/messages.diff.test.ts b/packages/protocol/src/messages.diff.test.ts new file mode 100644 index 0000000000..f2831ab147 --- /dev/null +++ b/packages/protocol/src/messages.diff.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from "vitest"; +import { DiffSubscribeRequestSchema, DiffSubscribeResponseSchema } from "./messages.js"; + +test("accepts explicit endpoints without changing legacy schemas", () => { + const request = { + type: "diff.subscribe.request", + requestId: "r", + cwd: "/repo", + base: { kind: "rev", rev: "v1" }, + head: { kind: "worktree" }, + mergeBase: true, + highlight: false, + }; + expect(DiffSubscribeRequestSchema.parse(request)).toEqual(request); + expect( + DiffSubscribeRequestSchema.safeParse({ ...request, base: { kind: "unknown" } }).success, + ).toBe(false); + expect( + DiffSubscribeResponseSchema.safeParse({ + type: "diff.subscribe.response", + payload: { requestId: "r", subscriptionId: "s", slot: 256, error: null }, + }).success, + ).toBe(false); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index a6d05ca509..c7d14f5ce8 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2322,6 +2322,51 @@ const CheckoutDiffCompareSchema = z.object({ ignoreWhitespace: z.boolean().optional(), }); +export const DiffEndpointSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("rev"), rev: z.string() }), + z.object({ kind: z.literal("index") }), + z.object({ kind: z.literal("worktree") }), + z.object({ kind: z.literal("empty") }), +]); +export const DiffErrorSchema = z.object({ + code: z.enum([ + "NOT_GIT_REPO", + "REF_NOT_FOUND", + "INVALID_COMPARE", + "TOO_MANY_SUBSCRIPTIONS", + "UNKNOWN", + ]), + message: z.string(), +}); +export const DiffSubscribeRequestSchema = z.object({ + type: z.literal("diff.subscribe.request"), + requestId: z.string(), + cwd: z.string(), + base: DiffEndpointSchema, + head: DiffEndpointSchema, + mergeBase: z.boolean().optional(), + ignoreWhitespace: z.boolean().optional(), + highlight: z.boolean().optional(), +}); +// Unsubscribe is fire-and-forget; release has no correlated response. +export const DiffUnsubscribeRequestSchema = z.object({ + type: z.literal("diff.unsubscribe.request"), + subscriptionId: z.string(), +}); +export const DiffSubscribeResponseSchema = z.object({ + type: z.literal("diff.subscribe.response"), + payload: z.object({ + requestId: z.string(), + subscriptionId: z.string(), + slot: z.number().int().min(0).max(255), + error: DiffErrorSchema.nullable(), + }), +}); +export type DiffEndpoint = z.infer; +export type DiffError = z.infer; +export type DiffSubscribeRequest = z.infer; +export type DiffSubscribeResponse = z.infer; + export const CheckoutStatusRequestSchema = z.object({ type: z.literal("checkout_status_request"), cwd: z.string(), @@ -3466,6 +3511,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ AgentDetachRequestMessageSchema, AgentRewindRequestMessageSchema, AgentPermissionResponseMessageSchema, + DiffSubscribeRequestSchema, + DiffUnsubscribeRequestSchema, CheckoutStatusRequestSchema, CheckoutDiffGetRequestSchema, SubscribeCheckoutDiffRequestSchema, @@ -3932,6 +3979,7 @@ export const ServerInfoStatusPayloadSchema = z selectiveAgentTimeline: z.boolean().optional(), explicitEventSubscriptions: z.boolean().optional(), ownedSubscriptions: z.boolean().optional(), + diffSubscription: z.boolean().optional(), binaryChunkingV1: z.boolean().optional(), workspaceSetupObservation: z.boolean().optional(), workspaceSortTimes: z.boolean().optional(), @@ -5522,6 +5570,7 @@ const CheckoutStatusCommonSchema = z.object({ error: CheckoutErrorSchema.nullable(), requestId: z.string(), baseRefExact: z.string().nullable().optional(), + comparisonBaseRef: z.string().nullable().optional(), // The full ref currentBranch tracks, as git resolves `@{upstream}`: // "refs/remotes/origin/main", "refs/remotes/upstream/main" on a fork, or a // "refs/heads/..." ref for a branch tracking a local branch. Null when there is no @@ -7335,6 +7384,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ AgentDeletedMessageSchema, AgentArchivedMessageSchema, CloseItemsResponseSchema, + DiffSubscribeResponseSchema, CheckoutStatusResponseSchema, CheckoutStatusUpdateSchema, CheckoutDiffGetResponseSchema, diff --git a/packages/server/package.json b/packages/server/package.json index 0733102d65..922e611733 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -102,7 +102,8 @@ "test:e2e:real": "npm run test:integration:real", "test:e2e:local": "npm run test:integration:local", "test:e2e:relay:live": "cross-env RUN_LIVE_RELAY_E2E=1 vitest run src/server/daemon-e2e/live-relay.real.e2e.test.ts --maxWorkers=1", - "test:e2e:ui": "vitest --ui e2e.test.ts" + "test:e2e:ui": "vitest --ui e2e.test.ts", + "measure:diff-subscriptions": "tsx scripts/measure-diff-subscriptions.ts" }, "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", diff --git a/packages/server/scripts/measure-diff-subscriptions.ts b/packages/server/scripts/measure-diff-subscriptions.ts new file mode 100644 index 0000000000..e8e058708e --- /dev/null +++ b/packages/server/scripts/measure-diff-subscriptions.ts @@ -0,0 +1,111 @@ +import { performance } from "node:perf_hooks"; +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { + encodeDiffFrame, + DiffOpcode, + type DiffFrame, +} from "@getpaseo/protocol/binary-frames/index"; +import { DiffSubscriptions } from "../src/server/diff/index.js"; +import { createFileObserver } from "../src/server/file-observer/index.js"; +import { runGitCommand } from "../src/utils/run-git-command.js"; +import { parseAndHighlightDiff } from "../src/server/utils/diff-highlighter.js"; +import { validateWSOutboundMessage } from "@getpaseo/protocol/validation/ws-outbound"; + +const cwd = resolve(import.meta.dirname, "../../.."); +const artifacts = resolve(cwd, ".artifacts/diff-subscription"); +await mkdir(artifacts, { recursive: true }); +const pinnedHead = "3ed57e9dd"; +const observer = createFileObserver(); +const engine = new DiffSubscriptions({ + async requestWorkingTreeWatch(root, changed) { + const watch = await observer.subscribe(root, () => changed(), { + ignore: [resolve(root, ".git")], + }); + return { + repoRoot: root, + unsubscribe: async () => { + await watch.unsubscribe(); + }, + }; + }, +}); +try { + for (const count of [1, 30]) { + const [base, head] = await Promise.all([ + runGitCommand(["rev-parse", `${pinnedHead}~${count}`], { cwd }), + runGitCommand(["rev-parse", pinnedHead], { cwd }), + ]); + const started = performance.now(); + const frames: DiffFrame[] = []; + const encoded: Uint8Array[] = []; + const subscription = await engine.subscribe( + { + cwd, + base: { kind: "rev", rev: base.stdout.trim() }, + head: { kind: "rev", rev: head.stdout.trim() }, + }, + async (frame) => { + frames.push(frame); + encoded.push(encodeDiffFrame(frame)); + }, + ); + await subscription.start(); + const coldMs = performance.now() - started; + await subscription.release(); + const raw = await runGitCommand(["diff", base.stdout.trim(), head.stdout.trim()], { + cwd, + maxOutputBytes: 50 * 1024 * 1024, + }); + const contentAt = async (sha: string, path: string) => { + const result = await runGitCommand(["show", `${sha}:${path}`], { + cwd, + acceptExitCodes: [0, 128], + }); + return result.exitCode === 0 ? result.stdout : null; + }; + const legacyFiles = await parseAndHighlightDiff(raw.stdout, cwd, { + getOldFileContent: (file) => contentAt(base.stdout.trim(), file.path), + getNewFileContent: (file) => contentAt(head.stdout.trim(), file.path), + }); + const legacy = JSON.stringify({ + type: "session", + message: { + type: "checkout_diff_update", + payload: { cwd, subscriptionId: "benchmark", files: legacyFiles, error: null }, + }, + }); + const parseStart = performance.now(); + const validation = validateWSOutboundMessage(JSON.parse(legacy)); + if (!validation.success) throw validation.error; + const jsonParseValidationMs = performance.now() - parseStart; + const begin = frames.find((frame) => frame.opcode === DiffOpcode.Begin); + const end = frames.findLast((frame) => frame.opcode === DiffOpcode.End); + if (!begin || begin.opcode !== DiffOpcode.Begin || !end || end.opcode !== DiffOpcode.End) + throw new Error("Revision was not delimited"); + if (end.error) throw new Error(end.error.message); + const metrics = { + pinnedHead: head.stdout.trim(), + count, + files: begin.entries.length, + rawPatchBytes: Buffer.byteLength(raw.stdout), + encodedBytes: encoded.reduce((sum, bytes) => sum + bytes.length, 0), + legacyJsonBytes: Buffer.byteLength(legacy), + coldMs, + jsonParseValidationMs, + omitted: end.omitted, + }; + console.log(JSON.stringify(metrics)); + await writeFile( + resolve(artifacts, `corpus-${count}.json`), + JSON.stringify({ + metrics, + legacy, + frames: encoded.map((bytes) => Buffer.from(bytes).toString("base64")), + }), + ); + } +} finally { + await engine.close(); + await observer.close(); +} diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index 728057e6b7..dfe7393ee7 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -196,6 +196,8 @@ const INBOUND_PERMISSION = { stash_pop_request: "workspace.write", stash_save_request: "workspace.write", "checkout.diff.get.request": "workspace.read", + "diff.subscribe.request": "workspace.read", + "diff.unsubscribe.request": "workspace.read", subscribe_checkout_diff_request: "workspace.read", subscribe_terminal_request: "workspace.read", subscribe_terminals_request: "workspace.read", @@ -443,6 +445,7 @@ const OUTBOUND_PERMISSION = { stash_save_response: "workspace.read", status: "daemon.read", "checkout.diff.get.response": "workspace.read", + "diff.subscribe.response": "workspace.read", subscribe_checkout_diff_response: "workspace.read", subscribe_terminal_response: "workspace.read", "terminal.rename.response": "workspace.write", diff --git a/packages/server/src/server/checkout-diff-manager.test.ts b/packages/server/src/server/checkout-diff-manager.test.ts index d55171831b..da2947c24d 100644 --- a/packages/server/src/server/checkout-diff-manager.test.ts +++ b/packages/server/src/server/checkout-diff-manager.test.ts @@ -1,488 +1,253 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import pino from "pino"; +import { expect, test } from "vitest"; +import { CheckoutDiffManager, type CheckoutDiffSnapshotPayload } from "./checkout-diff-manager.js"; import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { createFileObserver } from "./file-observer/index.js"; -const { toCheckoutErrorMock } = vi.hoisted(() => ({ - toCheckoutErrorMock: vi.fn((error: unknown) => ({ - message: error instanceof Error ? error.message : String(error), - })), -})); - -vi.mock("./checkout-git-utils.js", () => ({ - toCheckoutError: toCheckoutErrorMock, -})); - -import type pino from "pino"; -import { CheckoutDiffManager } from "./checkout-diff-manager.js"; -import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; - -interface Deferred { - promise: Promise; - resolve(value: T): void; -} - -function createDeferred(): Deferred { - let resolve!: (value: T) => void; - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise; - }); - return { promise, resolve }; -} - -function createWorkspaceSnapshot( - overrides?: Partial, -): WorkspaceGitRuntimeSnapshot { - return { - cwd: "/tmp/repo", - git: { - isGit: true, - repoRoot: "/tmp/repo", - mainRepoRoot: null, - currentBranch: "feature", - remoteUrl: "https://github.com/acme/repo.git", - isPaseoOwnedWorktree: false, - isDirty: false, - baseRef: "main", - aheadBehind: { ahead: 1, behind: 0 }, - aheadOfOrigin: 1, - behindOfOrigin: 0, - hasRemote: true, - diffStat: { additions: 1, deletions: 0 }, - ...overrides, - }, - forge: { - featuresEnabled: false, - authState: "no_remote", - pullRequest: null, - error: null, - }, - }; -} - -function createPendingManager() { - const watches: Array<{ +async function withCheckout( + run: (input: { cwd: string; - onChange: () => void; - unsubscribeCalls: number; - resolve(): void; - }> = []; - const workspaceGitService = { - getCheckoutDiff: async () => ({ diff: "", structured: [] }), - getSnapshot: async () => createWorkspaceSnapshot(), - - registerWorkspace: () => ({ unsubscribe: () => {} }), - requestWorkingTreeWatch: (cwd: string, onChange: () => void) => { - const pending = createDeferred<{ repoRoot: string | null; unsubscribe: () => void }>(); - const watch = { - cwd, - onChange, - unsubscribeCalls: 0, - resolve: () => { - pending.resolve({ - repoRoot: "/tmp/repo", - unsubscribe: () => { - watch.unsubscribeCalls += 1; - }, - }); - }, - }; - watches.push(watch); - return pending.promise; - }, - }; - const logger = { child: () => logger, warn: () => {} }; + manager: CheckoutDiffManager; + git(...args: string[]): string; + }) => Promise, +): Promise { + const cwd = await mkdtemp(join(tmpdir(), "legacy-diff-adapter-")); + const observer = createFileObserver(); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); const manager = new CheckoutDiffManager({ + logger: pino({ level: "silent" }), + paseoHome: cwd, workspaceRuntimeStore: createWorkspaceRuntimeStore(), - logger: logger as unknown as pino.Logger, - paseoHome: "/tmp/paseo-test", - workspaceGitService, + workspaceGitService: { + async requestWorkingTreeWatch(root, onChange) { + const subscription = await observer.subscribe(root, () => onChange(), { + ignore: [join(root, ".git")], + }); + return { + repoRoot: root, + unsubscribe: async () => { + await subscription.unsubscribe(); + }, + }; + }, + }, }); - return { manager, watches }; + try { + git("init", "-b", "main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + await writeFile(join(cwd, "README"), "base\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + await run({ cwd, manager, git }); + } finally { + await manager.dispose(); + await observer.close(); + await rm(cwd, { recursive: true, force: true }); + } } -describe("CheckoutDiffManager", () => { - beforeEach(() => { - vi.useFakeTimers(); - toCheckoutErrorMock.mockClear(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - function createManager(options?: { - repoRoot?: string | null; - getCheckoutDiffImplementation?: ReturnType; - }) { - const unsubscribe = vi.fn(); - const workspaceUnsubscribe = vi.fn(); - let onChange: (() => void) | null = null; - let onWorkspaceSnapshot: ((snapshot: WorkspaceGitRuntimeSnapshot) => void) | null = null; - const mockRequestWorkingTreeWatch = vi.fn(async (_cwd: string, listener: () => void) => { - onChange = listener; - return { - repoRoot: options?.repoRoot === undefined ? "/tmp/repo" : options.repoRoot, - unsubscribe, - }; - }); - - const workspaceGitService = { - subscribe: vi.fn(), - - registerWorkspace: vi.fn( - (_params: { cwd: string }, listener: (snapshot: WorkspaceGitRuntimeSnapshot) => void) => { - onWorkspaceSnapshot = listener; - return { unsubscribe: workspaceUnsubscribe }; - }, +test("legacy callers share one live comparison, receive edits, and release it", async () => { + await withCheckout(async ({ cwd, manager }) => { + await mkdir(join(cwd, "sub")); + await writeFile(join(cwd, "x.ts"), "const value = 1;\n"); + const first: CheckoutDiffSnapshotPayload[] = []; + const second: CheckoutDiffSnapshotPayload[] = []; + const [a, b] = await Promise.all([ + manager.subscribe({ cwd: join(cwd, "sub"), compare: { mode: "uncommitted" } }, (snapshot) => + first.push(snapshot), ), - getSnapshot: vi.fn(async () => createWorkspaceSnapshot()), - getCheckoutDiff: - options?.getCheckoutDiffImplementation ?? vi.fn(async () => ({ diff: "", structured: [] })), - refresh: vi.fn(), - scheduleRefreshForCwd: vi.fn(), - requestWorkingTreeWatch: mockRequestWorkingTreeWatch, - dispose: vi.fn(), - }; - - const logger = { - child: () => logger, - warn: vi.fn(), - }; - - const manager = new CheckoutDiffManager({ - workspaceRuntimeStore: createWorkspaceRuntimeStore(), - logger: logger as unknown as pino.Logger, - paseoHome: "/tmp/paseo-test", - workspaceGitService: workspaceGitService as unknown as WorkspaceGitService, + manager.subscribe({ cwd, compare: { mode: "uncommitted" } }, (snapshot) => + second.push(snapshot), + ), + ]); + expect(a.initial.cwd).toBe(join(cwd, "sub")); + expect(a.initial.files).toEqual(b.initial.files); + expect(a.initial.files.map((file) => file.path)).toEqual(["x.ts"]); + expect(manager.getMetrics()).toMatchObject({ + checkoutDiffTargetCount: 1, + checkoutDiffSubscriptionCount: 2, }); + await writeFile(join(cwd, "x.ts"), "const value = 2;\n"); + await expect.poll(() => first.length, { timeout: 10000 }).toBeGreaterThan(0); + expect(first.at(-1)?.files).toEqual(second.at(-1)?.files); + await a.unsubscribe(); + await b.unsubscribe(); + expect(manager.getMetrics().checkoutDiffTargetCount).toBe(0); + }); +}, 30_000); - return { - manager, - workspaceGitService, - mockRequestWorkingTreeWatch, - unsubscribe, - getOnChange: () => onChange, - getOnWorkspaceSnapshot: () => onWorkspaceSnapshot, - workspaceUnsubscribe, - }; - } - - test("subscribe requests a working tree watch with the correct cwd", async () => { - const { manager, mockRequestWorkingTreeWatch } = createManager(); - - await manager.subscribe( - { - cwd: "/tmp/repo/packages/server", - compare: { mode: "uncommitted" }, - }, - () => {}, +test("Committed ignores working-tree edits while All and Uncommitted include them", async () => { + await withCheckout(async ({ cwd, manager, git }) => { + git("checkout", "-b", "feature"); + await writeFile(join(cwd, "x.ts"), "const value = 1;\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "feature"); + await writeFile(join(cwd, "x.ts"), "const value = 2;\n"); + const committed = await manager.read({ cwd, compare: { mode: "base", baseRef: "main" } }); + const all = await manager.read({ cwd, compare: { mode: "all", baseRef: "main" } }); + const uncommitted = await manager.read({ cwd, compare: { mode: "uncommitted" } }); + expect(committed.files[0].hunks[0].lines).toContainEqual( + expect.objectContaining({ type: "add", content: "const value = 1;" }), ); - - expect(mockRequestWorkingTreeWatch).toHaveBeenCalledWith( - "/tmp/repo/packages/server", - expect.any(Function), + expect(all.files[0].hunks[0].lines).toContainEqual( + expect.objectContaining({ type: "add", content: "const value = 2;" }), + ); + expect(uncommitted.files[0].hunks[0].lines).toContainEqual( + expect.objectContaining({ type: "remove", content: "const value = 1;" }), ); }); +}, 30_000); - test("unsubscribe calls the working tree watch unsubscribe", async () => { - const { manager, unsubscribe } = createManager(); - - const subscription = await manager.subscribe( - { - cwd: "/tmp/repo/packages/server", - compare: { mode: "uncommitted" }, - }, - () => {}, - ); - - subscription.unsubscribe(); - - expect(unsubscribe).toHaveBeenCalledTimes(1); - }); - - test("cancels a subscription while its working tree watch is still opening", async () => { - const { manager, watches } = createPendingManager(); +test("cancelling a legacy subscriber during a shared revision does not revive it", async () => { + await withCheckout(async ({ cwd, manager }) => { + await writeFile(join(cwd, "x.ts"), "export const value = 1;\n"); const abort = new AbortController(); - - const pendingSubscription = manager.subscribe( - { - cwd: "/tmp/repo/packages/server", - compare: { mode: "uncommitted" }, - signal: abort.signal, + let cancelled = false; + const modern = await manager.streams.subscribe( + { cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } }, + async (frame) => { + if (frame.opcode !== 0x40 || cancelled) return; + await waitForSubscriberCount(manager, 2); + cancelled = true; + abort.abort(); }, - () => {}, ); - abort.abort(); - watches[0].resolve(); - await pendingSubscription; - - expect(watches[0].unsubscribeCalls).toBe(1); - expect(manager.getMetrics()).toEqual({ - checkoutDiffTargetCount: 0, - checkoutDiffSubscriptionCount: 0, - checkoutDiffWatcherCount: 0, - checkoutDiffFallbackRefreshTargetCount: 0, - }); - }); - - test("shares one opening target between concurrent subscriptions", async () => { - const { manager, watches } = createPendingManager(); - - const firstSubscription = manager.subscribe( - { cwd: "/tmp/repo/packages/server", compare: { mode: "uncommitted" } }, - () => {}, - ); - const secondSubscription = manager.subscribe( - { cwd: "/tmp/repo/packages/server", compare: { mode: "uncommitted" } }, - () => {}, - ); - - expect(watches).toHaveLength(1); - watches[0].resolve(); - const [first, second] = await Promise.all([firstSubscription, secondSubscription]); - expect(manager.getMetrics().checkoutDiffSubscriptionCount).toBe(2); - - first.unsubscribe(); - expect(watches[0].unsubscribeCalls).toBe(0); - second.unsubscribe(); - expect(watches[0].unsubscribeCalls).toBe(1); - }); - - test("diffCwd uses repoRoot from the working tree watch result", async () => { - const { manager, workspaceGitService } = createManager({ repoRoot: "/tmp/repo" }); - - await manager.subscribe( - { - cwd: "/tmp/repo/packages/server", - compare: { mode: "uncommitted" }, + const legacy = manager.subscribe( + { cwd, compare: { mode: "uncommitted" }, signal: abort.signal }, + () => { + throw new Error("Cancelled subscriber received an update"); }, - () => {}, ); - - expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledWith( - "/tmp/repo", - expect.objectContaining({ mode: "uncommitted", includeStructured: true }), - undefined, - ); - }); - - test("diff refresh is triggered when the working tree watch callback fires", async () => { - const getCheckoutDiff = vi - .fn() - .mockResolvedValueOnce({ - diff: "", - structured: [{ path: "a.ts", additions: 1, deletions: 0, status: "modified" }], - }) - .mockResolvedValueOnce({ - diff: "", - structured: [{ path: "b.ts", additions: 2, deletions: 0, status: "modified" }], + try { + await modern.start(); + const result = await legacy; + expect(result.initial).toMatchObject({ + files: [], + error: { message: "Diff subscription cancelled" }, }); - - const { manager, getOnChange } = createManager({ - getCheckoutDiffImplementation: getCheckoutDiff, - }); - const listener = vi.fn(); - - await manager.subscribe( - { - cwd: "/tmp/repo/packages/server", - compare: { mode: "uncommitted" }, - }, - listener, - ); - - const onChange = getOnChange(); - expect(onChange).toBeTypeOf("function"); - - onChange?.(); - await vi.advanceTimersByTimeAsync(150); - - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith({ - cwd: "/tmp/repo/packages/server", - files: [{ path: "b.ts", additions: 2, deletions: 0, status: "modified" }], - error: null, - }); - }); - - test("watch-triggered refresh forces a cache bypass on getCheckoutDiff", async () => { - const getCheckoutDiff = vi - .fn() - .mockResolvedValueOnce({ - diff: "", - structured: [{ path: "a.ts", additions: 1, deletions: 0, status: "modified" }], - }) - .mockResolvedValueOnce({ - diff: "", - structured: [{ path: "b.ts", additions: 2, deletions: 0, status: "modified" }], - }); - - const { manager, getOnChange } = createManager({ - getCheckoutDiffImplementation: getCheckoutDiff, - }); - - await manager.subscribe( - { - cwd: "/tmp/repo/packages/server", - compare: { mode: "uncommitted" }, - }, - vi.fn(), - ); - - expect(getCheckoutDiff).toHaveBeenNthCalledWith( - 1, - "/tmp/repo", - expect.objectContaining({ mode: "uncommitted" }), - undefined, - ); - - const onChange = getOnChange(); - onChange?.(); - await vi.advanceTimersByTimeAsync(150); - - expect(getCheckoutDiff).toHaveBeenCalledTimes(2); - const watchFiredCall = getCheckoutDiff.mock.calls[1]; - expect(watchFiredCall[2]).toEqual({ - force: true, - reason: expect.stringContaining("working-tree"), - }); - }); - - test("an edit during an in-flight diff refresh produces one final follow-up", async () => { - const inFlightDiff = createDeferred<{ - diff: string; - structured: Array<{ - path: string; - additions: number; - deletions: number; - status: "modified"; - }>; - }>(); - const getCheckoutDiff = vi - .fn() - .mockResolvedValueOnce({ - diff: "", - structured: [{ path: "tracked.ts", additions: 1, deletions: 0, status: "modified" }], - }) - .mockImplementationOnce(() => inFlightDiff.promise) - .mockResolvedValue({ - diff: "", - structured: [{ path: "tracked.ts", additions: 100, deletions: 25, status: "modified" }], - }); - const { manager, getOnChange } = createManager({ - getCheckoutDiffImplementation: getCheckoutDiff, - }); - const listener = vi.fn(); - - await manager.subscribe({ cwd: "/tmp/repo", compare: { mode: "uncommitted" } }, listener); - getOnChange()?.(); - await vi.advanceTimersByTimeAsync(150); - expect(getCheckoutDiff).toHaveBeenCalledTimes(2); - - for (let event = 0; event < 100; event += 1) { - getOnChange()?.(); + await result.unsubscribe(); + } finally { + await modern.release(); } - await vi.advanceTimersByTimeAsync(150); - expect(getCheckoutDiff).toHaveBeenCalledTimes(2); - - inFlightDiff.resolve({ - diff: "", - structured: [{ path: "tracked.ts", additions: 2, deletions: 1, status: "modified" }], - }); - await vi.waitFor(() => { - expect(getCheckoutDiff).toHaveBeenCalledTimes(3); - expect(listener).toHaveBeenLastCalledWith({ - cwd: "/tmp/repo", - files: [{ path: "tracked.ts", additions: 100, deletions: 25, status: "modified" }], - error: null, - }); - }); }); +}, 30_000); - test("base diff subscriptions ignore ordinary working tree edits", async () => { - const getCheckoutDiff = vi.fn(async () => ({ - diff: "", - structured: [{ path: "committed.ts", additions: 1, deletions: 0, status: "modified" }], - })); - const { manager, getOnChange, mockRequestWorkingTreeWatch } = createManager({ - getCheckoutDiffImplementation: getCheckoutDiff, +async function waitForSubscriberCount(manager: CheckoutDiffManager, count: number): Promise { + await expect + .poll(() => manager.getMetrics().checkoutDiffSubscriptionCount, { timeout: 10_000 }) + .toBe(count); +} + +test("moving a tag refreshes binary file metadata even though binary files have no bodies", async () => { + await withCheckout(async ({ cwd, manager, git }) => { + git("tag", "v1"); + await writeFile(join(cwd, "image.bin"), Buffer.from([0, 1])); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "binary"); + await writeFile(join(cwd, "image.bin"), Buffer.from([0, 2])); + const updates: CheckoutDiffSnapshotPayload[] = []; + const subscription = await manager.subscribe( + { cwd, compare: { mode: "all", baseRef: "v1" } }, + (snapshot) => updates.push(snapshot), + ); + expect(subscription.initial.files[0]).toMatchObject({ + path: "image.bin", + isNew: true, + status: "binary", }); - - await manager.subscribe( - { cwd: "/tmp/repo", compare: { mode: "base", baseRef: "main" } }, - vi.fn(), - ); - getOnChange()?.(); - await vi.advanceTimersByTimeAsync(150); - - expect(getCheckoutDiff).toHaveBeenCalledTimes(1); - expect(mockRequestWorkingTreeWatch).not.toHaveBeenCalled(); + git("update-ref", "refs/tags/v1", "HEAD"); + await expect.poll(() => updates.at(-1)?.files[0]?.isNew, { timeout: 10000 }).toBe(false); + await subscription.unsubscribe(); }); +}, 30000); - test("base diff subscriptions ignore worktree-only workspace snapshot updates", async () => { - const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: [] })); - const { manager, getOnWorkspaceSnapshot } = createManager({ - getCheckoutDiffImplementation: getCheckoutDiff, +test("legacy diffs retain successful bodies when an oversized sibling is removed", async () => { + await withCheckout(async ({ cwd, manager }) => { + await writeFile(join(cwd, "a.ts"), "const value = 1;\n"); + await writeFile(join(cwd, "b.txt"), "x".repeat(1024 * 1024 + 1)); + const updates: CheckoutDiffSnapshotPayload[] = []; + const subscription = await manager.subscribe( + { cwd, compare: { mode: "uncommitted" } }, + (snapshot) => updates.push(snapshot), + ); + expect(subscription.initial.files).toMatchObject([ + { path: "a.ts", status: "ok" }, + { path: "b.txt", status: "too_large" }, + ]); + await rm(join(cwd, "b.txt")); + await manager.scheduleRefreshForCwd(cwd); + await expect.poll(() => updates.at(-1)?.error).toBeNull(); + expect(updates.at(-1)?.files[0]).toMatchObject({ + path: "a.ts", + hunks: [ + { + lines: expect.arrayContaining([expect.objectContaining({ content: "const value = 1;" })]), + }, + ], }); - - await manager.subscribe( - { cwd: "/tmp/repo", compare: { mode: "base", baseRef: "main" } }, - vi.fn(), - ); - - getOnWorkspaceSnapshot()?.( - createWorkspaceSnapshot({ - isDirty: true, - diffStat: { additions: 5, deletions: 2 }, - }), - ); - await vi.advanceTimersByTimeAsync(150); - - expect(getCheckoutDiff).toHaveBeenCalledTimes(1); - }); - - test("base diff subscriptions refresh for structural workspace changes", async () => { - const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: [] })); - const { manager, getOnWorkspaceSnapshot, workspaceGitService } = createManager({ - getCheckoutDiffImplementation: getCheckoutDiff, - }); - - await manager.subscribe( - { cwd: "/tmp/repo", compare: { mode: "base", baseRef: "main" } }, - vi.fn(), - ); - - expect(workspaceGitService.registerWorkspace).toHaveBeenCalledTimes(1); - getOnWorkspaceSnapshot()?.( - createWorkspaceSnapshot({ - currentBranch: "feature-2", - isDirty: true, - diffStat: { additions: 5, deletions: 2 }, - }), - ); - await vi.advanceTimersByTimeAsync(150); - expect(getCheckoutDiff).toHaveBeenCalledTimes(2); - expect(getCheckoutDiff.mock.calls[1]?.[2]).toBeUndefined(); - - getOnWorkspaceSnapshot()?.(createWorkspaceSnapshot({ aheadBehind: { ahead: 2, behind: 0 } })); - await vi.advanceTimersByTimeAsync(150); - expect(getCheckoutDiff).toHaveBeenCalledTimes(3); - }); - - test("falls back to cwd when the working tree watch returns no repo root", async () => { - const { manager, workspaceGitService } = createManager({ repoRoot: null }); - - await manager.subscribe( - { - cwd: "/tmp/plain", - compare: { mode: "uncommitted" }, - }, - () => {}, - ); - - expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledWith( - "/tmp/plain", - expect.objectContaining({ mode: "uncommitted", includeStructured: true }), - undefined, - ); + subscription.unsubscribe(); }); }); + +test("legacy subscriptions reselect the preferred base when a remote ref appears", async () => { + await withCheckout(async ({ cwd, manager, git }) => { + git("checkout", "-b", "feature"); + await writeFile(join(cwd, "b.ts"), "export const b = 1;\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "feature"); + const updates: CheckoutDiffSnapshotPayload[] = []; + const params = { cwd, compare: { mode: "base" as const, baseRef: "main" } }; + const subscription = await manager.subscribe(params, (snapshot) => updates.push(snapshot)); + try { + expect(subscription.initial.files.map((file) => file.path)).toEqual(["b.ts"]); + git("update-ref", "refs/remotes/origin/main", "HEAD"); + await manager.scheduleRefreshForCwd(cwd); + await expect.poll(() => updates.at(-1)?.files.length, { timeout: 5000 }).toBe(0); + expect(updates.at(-1)).toEqual(await manager.read(params)); + } finally { + await subscription.unsubscribe(); + } + }); +}); + +test("cold many-file reads batch Git patches while matching the legacy reader", async () => { + const { getCheckoutDiff } = await import("../utils/checkout-git.js"); + const { startGitCommandMetrics, stopGitCommandMetrics } = + await import("../utils/run-git-command.js"); + await withCheckout(async ({ cwd, manager, git }) => { + const count = 1000; + for (let index = 0; index < count; index++) + await writeFile(join(cwd, `file-${index}.ts`), "export const value = 0;\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "many files"); + for (let index = 0; index < count; index++) + await writeFile(join(cwd, `file-${index}.ts`), "export const value = 1;\n"); + const legacyStart = performance.now(); + const legacy = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true }); + const legacyMs = performance.now() - legacyStart; + startGitCommandMetrics(); + const streamedStart = performance.now(); + const streamed = await manager.read({ cwd, compare: { mode: "uncommitted" } }); + const streamedMs = performance.now() - streamedStart; + const metrics = stopGitCommandMetrics(); + const patches = metrics.commands.filter((command) => command.args.includes("--patch")); + const artifactDirectory = join(import.meta.dirname, "../../../../.artifacts/diff-subscription"); + await mkdir(artifactDirectory, { recursive: true }); + await writeFile( + join(artifactDirectory, "cold-many-file-metrics.json"), + JSON.stringify({ count, legacyMs, streamedMs, patchCommands: patches.length }, null, 2), + ); + expect(streamed.error).toBeNull(); + expect(streamed.files).toHaveLength(count); + expect(legacy.structured).toHaveLength(count); + expect(streamed.files.map((file) => [file.path, file.hunks])).toEqual( + legacy.structured?.map((file) => [file.path, file.hunks]), + ); + expect(patches.length).toBeLessThanOrEqual(Math.ceil(count / 64)); + }); +}, 60000); diff --git a/packages/server/src/server/checkout-diff-manager.ts b/packages/server/src/server/checkout-diff-manager.ts index 8293ce965f..921934e70e 100644 --- a/packages/server/src/server/checkout-diff-manager.ts +++ b/packages/server/src/server/checkout-diff-manager.ts @@ -1,400 +1,201 @@ -import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import type pino from "pino"; import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js"; -import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; +import type { WorkspaceGitService } from "./workspace-git-service.js"; +import type { WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; +import { DiffSubscriptions, type CommitFileRead } from "./diff/index.js"; +import { + DiffOpcode, + materializeDiffFile, + type DiffFrame, + type DiffBegin, + type DiffBody, +} from "@getpaseo/protocol/binary-frames/index"; +import type { ParsedDiffFile } from "@getpaseo/protocol/messages"; +import { + CHECKOUT_DIFF_MAX_STRUCTURED_BYTES, + resolveCheckoutDiffBase, +} from "../utils/checkout-git.js"; import { expandTilde } from "../utils/path.js"; import { toCheckoutError } from "./checkout-git-utils.js"; -const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150; - -type CheckoutDiffWorkspace = Pick< - WorkspaceGitService, - "getCheckoutDiff" | "getSnapshot" | "registerWorkspace" | "requestWorkingTreeWatch" ->; - export type CheckoutDiffCompareInput = SubscribeCheckoutDiffRequest["compare"]; - export type CheckoutDiffSnapshotPayload = Omit< Extract["payload"], "subscriptionId" >; - export interface CheckoutDiffMetrics { checkoutDiffTargetCount: number; checkoutDiffSubscriptionCount: number; checkoutDiffWatcherCount: number; checkoutDiffFallbackRefreshTargetCount: number; } - -interface CheckoutDiffWatchTarget { - key: string; - cwd: string; - diffCwd: string; - compare: CheckoutDiffCompareInput; - listeners: Set<(snapshot: CheckoutDiffSnapshotPayload) => void>; - workingTreeWatchUnsubscribe: (() => void) | null; - workspaceGitUnsubscribe: (() => void) | null; - latestWorkspaceStructureFingerprint: string | null; - latestWorkspaceWorktreeFingerprint: string | null; - latestWorkspaceForgeFingerprint: string | null; - debounceTimer: NodeJS.Timeout | null; - pendingDebounceForce: boolean; - refreshPromise: Promise | null; - refreshQueued: boolean; - refreshQueuedForce: boolean; - latestPayload: CheckoutDiffSnapshotPayload | null; - latestFingerprint: string | null; - openPromise: Promise | null; -} - export interface CheckoutDiffSubscriptionRequest { cwd: string; compare: CheckoutDiffCompareInput; signal?: AbortSignal; } - export interface CheckoutDiffSubscription { initial: CheckoutDiffSnapshotPayload; - unsubscribe: () => void; + unsubscribe(): void; } +// COMPAT(diffSubscription): added in v0.15.0, remove after 2027-05-06. +// Old clients receive the same JSON snapshot while the shared engine owns reads and observation. export class CheckoutDiffManager { - private readonly workspaceGitService: CheckoutDiffWorkspace; - private readonly workspaceRuntimeStore: WorkspaceRuntimeStore; - private readonly targets = new Map(); - + readonly streams: DiffSubscriptions; + private readonly paseoHome: string; constructor(options: { logger: pino.Logger; paseoHome: string; - workspaceGitService: CheckoutDiffWorkspace; + workspaceGitService: Pick; workspaceRuntimeStore: WorkspaceRuntimeStore; }) { - this.workspaceGitService = options.workspaceGitService; - this.workspaceRuntimeStore = options.workspaceRuntimeStore; + this.streams = new DiffSubscriptions(options.workspaceGitService); + this.paseoHome = options.paseoHome; + } + readCommitFile(input: CommitFileRead) { + return this.streams.readCommitFile(input); } - async read( params: Omit, ): Promise { - return this.computeCheckoutDiffSnapshot(params.cwd, this.normalizeCompare(params.compare)); + const subscription = await this.subscribe(params, () => {}); + await subscription.unsubscribe(); + return subscription.initial; } - async subscribe( params: CheckoutDiffSubscriptionRequest, listener: (snapshot: CheckoutDiffSnapshotPayload) => void, ): Promise { - const cwd = params.cwd; - const compare = this.normalizeCompare(params.compare); - const target = this.ensureTarget(cwd, compare); - target.listeners.add(listener); - target.openPromise ??= this.openTarget(target); - - let isSubscribed = true; - const unsubscribe = () => { - if (!isSubscribed) { + const cwd = expandTilde(params.cwd); + let resolveInitial!: (snapshot: CheckoutDiffSnapshotPayload) => void; + const initial = new Promise((resolve) => { + resolveInitial = resolve; + }); + let delivered = false; + let pending: DiffBegin | null = null; + let files = new Map(); + let bodies = new Map(); + const receive = async (frame: DiffFrame) => { + if (params.signal?.aborted) return; + if (frame.opcode === DiffOpcode.Begin) { + pending = frame; + bodies = new Map(); return; } - isSubscribed = false; - params.signal?.removeEventListener("abort", unsubscribe); - this.removeListener(target, listener); - }; - params.signal?.addEventListener("abort", unsubscribe, { once: true }); - if (params.signal?.aborted) { - unsubscribe(); - } - - try { - await target.openPromise; - const initial = - target.latestPayload ?? - (await this.computeCheckoutDiffSnapshot(target.cwd, target.compare, { - diffCwd: target.diffCwd, - })); - target.latestPayload = initial; - target.latestFingerprint = JSON.stringify(initial); - return { initial, unsubscribe }; - } catch (error) { - unsubscribe(); - throw error; - } - } - - scheduleRefreshForCwd(cwd: string): void { - const resolvedCwd = expandTilde(cwd); - for (const target of this.targets.values()) { - if (target.cwd !== resolvedCwd && target.diffCwd !== resolvedCwd) { - continue; + if (!pending || frame.revision !== pending.revision) return; + if (frame.opcode === DiffOpcode.Bodies) { + for (const body of frame.files) bodies.set(body.path, body); + return; } - this.scheduleTargetRefresh(target); + let payload: CheckoutDiffSnapshotPayload; + if (frame.error) { + const message = frame.error.message; + payload = { + cwd, + files: [], + error: toCheckoutError(new Error(message)), + diffTooLarge: frame.diffTooLarge, + }; + files.clear(); + } else { + const next = new Map(); + const omitted = new Set(frame.omitted); + for (const entry of pending.entries) { + const previous = pending.baseRevision ? files.get(entry.path) : undefined; + const body = bodies.get(entry.path) ?? null; + const file = materializeDiffFile(entry, body, omitted.has(entry.path)); + if (!entry.body && previous && entry.content === "text" && !omitted.has(entry.path)) + file.hunks = previous.hunks; + next.set(entry.path, file); + } + files = next; + // Stream omissions remain file placeholders; the JSON budget rejects a legacy snapshot. + payload = legacySnapshot(cwd, [...next.values()]); + } + pending = null; + if (delivered) listener(payload); + else { + delivered = true; + resolveInitial(payload); + } + }; + try { + const baseRef = await resolveCheckoutDiffBase(cwd, params.compare, { + paseoHome: this.paseoHome, + }); + if (!baseRef) return { initial: { cwd, files: [], error: null }, unsubscribe() {} }; + const subscription = await this.streams.subscribe( + { + cwd, + base: { kind: "rev", rev: baseRef }, + checkoutBase: + params.compare.mode === "uncommitted" + ? undefined + : { compare: params.compare, paseoHome: this.paseoHome }, + head: + params.compare.mode === "base" ? { kind: "rev", rev: "HEAD" } : { kind: "worktree" }, + mergeBase: params.compare.mode !== "uncommitted", + ignoreWhitespace: params.compare.ignoreWhitespace, + }, + receive, + ); + const unsubscribe = async () => { + if (!delivered) { + delivered = true; + resolveInitial({ + cwd, + files: [], + error: toCheckoutError(new Error("Diff subscription cancelled")), + }); + } + params.signal?.removeEventListener("abort", unsubscribe); + await subscription.release(); + }; + params.signal?.addEventListener("abort", unsubscribe, { once: true }); + if (params.signal?.aborted) { + await subscription.release(); + throw new Error("Diff subscription cancelled"); + } + await subscription.start(); + return { initial: await initial, unsubscribe }; + } catch (error) { + return { initial: { cwd, files: [], error: toCheckoutError(error) }, unsubscribe() {} }; } } - + scheduleRefreshForCwd(cwd: string): Promise { + return this.streams.invalidateForCwd(expandTilde(cwd)); + } getMetrics(): CheckoutDiffMetrics { - let checkoutDiffSubscriptionCount = 0; - - for (const target of this.targets.values()) { - checkoutDiffSubscriptionCount += target.listeners.size; - } - + const metrics = this.streams.getMetrics(); return { - checkoutDiffTargetCount: this.targets.size, - checkoutDiffSubscriptionCount, - checkoutDiffWatcherCount: 0, + checkoutDiffTargetCount: metrics.targets, + checkoutDiffSubscriptionCount: metrics.subscriptions, + checkoutDiffWatcherCount: metrics.metadataObservers, checkoutDiffFallbackRefreshTargetCount: 0, }; } - - dispose(): void { - for (const target of this.targets.values()) { - this.closeTarget(target); - } - this.targets.clear(); - } - - private normalizeCompare(compare: CheckoutDiffCompareInput): CheckoutDiffCompareInput { - const ignoreWhitespace = compare.ignoreWhitespace === true; - if (compare.mode === "uncommitted") { - return { mode: "uncommitted", ignoreWhitespace }; - } - const trimmedBaseRef = compare.baseRef?.trim(); - return trimmedBaseRef - ? { mode: compare.mode, baseRef: trimmedBaseRef, ignoreWhitespace } - : { mode: compare.mode, ignoreWhitespace }; - } - - private buildTargetKey(cwd: string, compare: CheckoutDiffCompareInput): string { - return JSON.stringify([ - cwd, - compare.mode, - compare.mode !== "uncommitted" ? (compare.baseRef ?? "") : "", - compare.ignoreWhitespace === true, - ]); - } - - private closeTarget(target: CheckoutDiffWatchTarget): void { - if (target.debounceTimer) { - clearTimeout(target.debounceTimer); - target.debounceTimer = null; - } - target.workingTreeWatchUnsubscribe?.(); - target.workingTreeWatchUnsubscribe = null; - target.workspaceGitUnsubscribe?.(); - target.workspaceGitUnsubscribe = null; - target.listeners.clear(); - } - - private rememberWorkspaceSnapshot( - target: CheckoutDiffWatchTarget, - snapshot: WorkspaceGitRuntimeSnapshot, - ): void { - const structureFingerprint = JSON.stringify({ - isGit: snapshot.git.isGit, - repoRoot: snapshot.git.repoRoot, - mainRepoRoot: snapshot.git.mainRepoRoot, - currentBranch: snapshot.git.currentBranch, - remoteUrl: snapshot.git.remoteUrl, - isPaseoOwnedWorktree: snapshot.git.isPaseoOwnedWorktree, - baseRef: snapshot.git.baseRef, - baseRefExact: snapshot.git.baseRefExact, - aheadBehind: snapshot.git.aheadBehind, - aheadOfOrigin: snapshot.git.aheadOfOrigin, - behindOfOrigin: snapshot.git.behindOfOrigin, - hasRemote: snapshot.git.hasRemote, - }); - const worktreeFingerprint = JSON.stringify({ - isDirty: snapshot.git.isDirty, - diffStat: snapshot.git.diffStat, - }); - const forgeFingerprint = JSON.stringify(snapshot.forge); - const previousStructureFingerprint = target.latestWorkspaceStructureFingerprint; - const previousWorktreeFingerprint = target.latestWorkspaceWorktreeFingerprint; - const previousForgeFingerprint = target.latestWorkspaceForgeFingerprint; - target.latestWorkspaceStructureFingerprint = structureFingerprint; - target.latestWorkspaceWorktreeFingerprint = worktreeFingerprint; - target.latestWorkspaceForgeFingerprint = forgeFingerprint; - - if (previousStructureFingerprint === null) { - return; - } - const structureChanged = structureFingerprint !== previousStructureFingerprint; - const worktreeChanged = worktreeFingerprint !== previousWorktreeFingerprint; - const forgeChanged = forgeFingerprint !== previousForgeFingerprint; - if (structureChanged || (!worktreeChanged && !forgeChanged)) { - this.scheduleTargetRefresh(target, false); - } - } - - private removeListener( - target: CheckoutDiffWatchTarget, - listener: (snapshot: CheckoutDiffSnapshotPayload) => void, - ): void { - target.listeners.delete(listener); - if (target.listeners.size > 0) { - return; - } - this.closeTarget(target); - if (this.targets.get(target.key) === target) { - this.targets.delete(target.key); - } - } - - private scheduleTargetRefresh(target: CheckoutDiffWatchTarget, force = true): void { - target.pendingDebounceForce ||= force; - if (target.debounceTimer) { - clearTimeout(target.debounceTimer); - } - target.debounceTimer = setTimeout(() => { - target.debounceTimer = null; - const pendingForce = target.pendingDebounceForce; - target.pendingDebounceForce = false; - void this.refreshTarget(target, pendingForce); - }, CHECKOUT_DIFF_WATCH_DEBOUNCE_MS); - } - - private async computeCheckoutDiffSnapshot( - cwd: string, - compare: CheckoutDiffCompareInput, - options?: { diffCwd?: string; force?: boolean; reason?: string }, - ): Promise { - const diffCwd = options?.diffCwd ?? cwd; - try { - const diffResult = await this.workspaceGitService.getCheckoutDiff( - diffCwd, - { - mode: compare.mode, - baseRef: compare.baseRef, - ignoreWhitespace: compare.ignoreWhitespace, - includeStructured: true, - }, - options?.force - ? { force: true, reason: options.reason ?? "checkout-diff-refresh" } - : undefined, - ); - if (diffResult.diffTooLarge) { - return { - cwd, - files: [], - diffTooLarge: true, - error: toCheckoutError(new Error("Diff too large to display")), - }; - } - const files = [...(diffResult.structured ?? [])]; - files.sort((a, b) => { - if (a.path === b.path) return 0; - return a.path < b.path ? -1 : 1; - }); - return { - cwd, - files, - error: null, - }; - } catch (error) { - return { - cwd, - files: [], - error: toCheckoutError(error), - }; - } - } - - private async refreshTarget(target: CheckoutDiffWatchTarget, force: boolean): Promise { - if (target.refreshPromise) { - target.refreshQueued = true; - target.refreshQueuedForce ||= force; - return; - } - - target.refreshPromise = (async () => { - let currentForce = force; - do { - target.refreshQueued = false; - target.refreshQueuedForce = false; - const snapshot = await this.computeCheckoutDiffSnapshot(target.cwd, target.compare, { - diffCwd: target.diffCwd, - force: currentForce, - ...(currentForce ? { reason: "working-tree-watch" } : {}), - }); - target.latestPayload = snapshot; - const fingerprint = JSON.stringify(snapshot); - if (fingerprint !== target.latestFingerprint) { - target.latestFingerprint = fingerprint; - for (const listener of target.listeners) { - listener(snapshot); - } - } - currentForce = target.refreshQueuedForce; - } while (target.refreshQueued); - })(); - - try { - await target.refreshPromise; - } finally { - target.refreshPromise = null; - } - } - - private ensureTarget(cwd: string, compare: CheckoutDiffCompareInput): CheckoutDiffWatchTarget { - const targetKey = this.buildTargetKey(cwd, compare); - const existing = this.targets.get(targetKey); - if (existing) { - return existing; - } - - const target: CheckoutDiffWatchTarget = { - key: targetKey, - cwd, - diffCwd: cwd, - compare, - listeners: new Set(), - workingTreeWatchUnsubscribe: null, - workspaceGitUnsubscribe: null, - latestWorkspaceStructureFingerprint: null, - latestWorkspaceWorktreeFingerprint: null, - latestWorkspaceForgeFingerprint: null, - debounceTimer: null, - pendingDebounceForce: false, - refreshPromise: null, - refreshQueued: false, - refreshQueuedForce: false, - latestPayload: null, - latestFingerprint: null, - openPromise: null, - }; - this.targets.set(targetKey, target); - return target; - } - - private async openTarget(target: CheckoutDiffWatchTarget): Promise { - if (target.compare.mode !== "uncommitted") { - const snapshot = - this.workspaceRuntimeStore.get(target.cwd) ?? - (await this.workspaceGitService.getSnapshot(target.cwd, { includeForge: false })); - target.diffCwd = snapshot.git.repoRoot ?? target.cwd; - if (this.targets.get(target.key) !== target || target.listeners.size === 0) { - return; - } - this.rememberWorkspaceSnapshot(target, snapshot); - const workspaceSubscription = this.workspaceGitService.registerWorkspace( - { cwd: target.cwd }, - (nextSnapshot) => this.rememberWorkspaceSnapshot(target, nextSnapshot), - ); - if (this.targets.get(target.key) !== target || target.listeners.size === 0) { - workspaceSubscription.unsubscribe(); - return; - } - target.workspaceGitUnsubscribe = workspaceSubscription.unsubscribe; - if (target.compare.mode === "base") return; - } - - const { repoRoot, unsubscribe } = await this.workspaceGitService.requestWorkingTreeWatch( - target.cwd, - () => this.scheduleTargetRefresh(target), - ); - target.diffCwd = repoRoot ?? target.cwd; - if (this.targets.get(target.key) !== target || target.listeners.size === 0) { - unsubscribe(); - return; - } - target.workingTreeWatchUnsubscribe = unsubscribe; + async dispose(): Promise { + await this.streams.close(); } } + +function legacySnapshot(cwd: string, files: ParsedDiffFile[]): CheckoutDiffSnapshotPayload { + let bytes = 2; + for (let index = 0; index < files.length; index++) { + bytes += Buffer.byteLength(JSON.stringify(files[index])) + Number(index > 0); + if (bytes > CHECKOUT_DIFF_MAX_STRUCTURED_BYTES) { + return legacyTooLarge(cwd); + } + } + return { cwd, files, error: null }; +} + +function legacyTooLarge(cwd: string): CheckoutDiffSnapshotPayload { + return { + cwd, + files: [], + diffTooLarge: true, + error: toCheckoutError(new Error("Diff too large to display")), + }; +} diff --git a/packages/server/src/server/checkout/status-projection.ts b/packages/server/src/server/checkout/status-projection.ts index 90c900435c..93242cb941 100644 --- a/packages/server/src/server/checkout/status-projection.ts +++ b/packages/server/src/server/checkout/status-projection.ts @@ -62,6 +62,7 @@ export function buildCheckoutStatusPayloadFromSnapshot({ isDirty: snapshot.git.isDirty, baseRef: snapshot.git.baseRef, baseRefExact: snapshot.git.baseRefExact, + comparisonBaseRef: snapshot.git.comparisonBaseRef, aheadBehind: snapshot.git.aheadBehind ?? null, upstreamRef: snapshot.git.upstreamRef ?? null, aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null, @@ -83,6 +84,7 @@ export function buildCheckoutStatusPayloadFromSnapshot({ isDirty: snapshot.git.isDirty, baseRef: snapshot.git.baseRef ?? null, baseRefExact: snapshot.git.baseRefExact, + comparisonBaseRef: snapshot.git.comparisonBaseRef, aheadBehind: snapshot.git.aheadBehind ?? null, upstreamRef: snapshot.git.upstreamRef ?? null, aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null, diff --git a/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts index 56e7cdefb8..b297d07b5a 100644 --- a/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts @@ -293,3 +293,263 @@ test("large source diffs stay renderable for reads, subscriptions, and commit fi await rm(cwd, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } }, 60_000); + +test("streams whole-file batches and answers a same-socket ping before the revision ends", async () => { + const ctx = await createDaemonTestContext(); + const cwd = mkdtempSync(join(tmpdir(), "diff-stream-wire-")); + let probe: Awaited> | null = null; + try { + git(cwd, "init", "-b", "main"); + git(cwd, "config", "user.name", "Test"); + git(cwd, "config", "user.email", "test@example.com"); + writeFileSync(join(cwd, "README"), "base\n"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "base"); + for (let file = 0; file < 252; file++) { + const content = + Array.from( + { length: 80 }, + (_, line) => + `export const fn${line} = (param: string) => ({ key: param, value: ${file} });`, + ).join("\n") + "\n"; + writeFileSync(join(cwd, `${file}.ts`), content); + } + expect(ctx.client.getLastServerInfoMessage()).toMatchObject({ + features: { diffSubscription: true }, + }); + const legacy = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" }); + const legacyJsonBytes = Buffer.byteLength(JSON.stringify(legacy.files)); + expect(legacyJsonBytes).toBeGreaterThanOrEqual(10 * 1024 * 1024); + probe = await openDiffWireProbe(ctx.daemon.port, cwd); + const { encodedBytes, bodyFrames, pongDuringRevision, entryCount } = await probe.done; + expect(entryCount).toBe(252); + expect(bodyFrames).toBeGreaterThan(1); + expect(pongDuringRevision).toBe(true); + const artifactDirectory = join( + import.meta.dirname, + "../../../../../.artifacts/diff-subscription", + ); + mkdirSync(artifactDirectory, { recursive: true }); + writeFileSync( + join(artifactDirectory, "wire-metrics.json"), + JSON.stringify({ legacyJsonBytes, encodedBytes, bodyFrames, pongDuringRevision }, null, 2), + ); + } finally { + probe?.socket.terminate(); + await ctx.cleanup(); + rmSync(cwd, { recursive: true, force: true }); + } +}, 60_000); + +interface DiffWireResult { + encodedBytes: number; + bodyFrames: number; + pongDuringRevision: boolean; + entryCount: number; +} +async function openDiffWireProbe(port: number, cwd: string) { + const { WebSocket } = await import("ws"); + const { decodeDiffFrame, DiffOpcode } = await import("@getpaseo/protocol/binary-frames/index"); + const activeSocket = new WebSocket(`ws://127.0.0.1:${port}/ws`); + await new Promise((resolve, reject) => { + activeSocket.once("open", resolve); + activeSocket.once("error", reject); + }); + let encodedBytes = 0; + let endReceived = false; + let pingSent = false; + let pongDuringRevision = false; + let bodyFrames = 0; + let entryCount = 0; + const done = new Promise((resolve, reject) => { + activeSocket.on("message", (data, binary) => { + try { + if (binary) { + if (!Buffer.isBuffer(data)) throw new Error("Expected buffer"); + encodedBytes += data.byteLength; + const frame = decodeDiffFrame(new Uint8Array(data)); + if (frame.opcode === DiffOpcode.Begin) entryCount = frame.entries.length; + if (frame.opcode === DiffOpcode.Bodies) { + bodyFrames++; + if (!pingSent) { + pingSent = true; + activeSocket.send( + JSON.stringify({ + type: "session", + message: { type: "ping", requestId: "mid-revision", clientSentAt: 1 }, + }), + ); + } + } + if (frame.opcode === DiffOpcode.End) { + expect(frame.error).toBeNull(); + endReceived = true; + resolve({ encodedBytes, bodyFrames, pongDuringRevision, entryCount }); + } + } else { + const envelope = JSON.parse(data.toString()); + if ( + envelope.message?.type === "pong" && + envelope.message.payload.requestId === "mid-revision" + ) + pongDuringRevision = !endReceived; + if (envelope.message?.payload?.status === "server_info") + activeSocket.send( + JSON.stringify({ + type: "session", + message: { + type: "diff.subscribe.request", + requestId: "stream-diff", + cwd, + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "worktree" }, + }, + }), + ); + if (envelope.message?.type === "diff.subscribe.response") + expect(envelope.message.payload.error).toBeNull(); + } + } catch (error) { + reject(error); + } + }); + activeSocket.once("error", reject); + }); + activeSocket.send( + JSON.stringify({ + type: "hello", + clientId: "diff-wire-test", + clientType: "browser", + protocolVersion: 1, + capabilities: { owned_subscriptions: true }, + }), + ); + return { socket: activeSocket, done }; +} + +test("SDK diff journey publishes a tag tree, commits a one-file refresh and shares two sockets", async () => { + const ctx = await createDaemonTestContext(); + const cwd = mkdtempSync(join(tmpdir(), "sdk-stream-diff-")); + const { DaemonClient } = await import("../test-utils/daemon-client.js"); + const { startGitCommandMetrics, stopGitCommandMetrics } = + await import("../../utils/run-git-command.js"); + const second = new DaemonClient({ url: `ws://127.0.0.1:${ctx.daemon.port}/ws` }); + try { + git(cwd, "init", "-b", "main"); + git(cwd, "config", "user.name", "Test"); + git(cwd, "config", "user.email", "test@example.com"); + writeFileSync(join(cwd, "a.ts"), "export const a = 1;\n"); + writeFileSync(join(cwd, "b.ts"), "export const b = 1;\n"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "base"); + git(cwd, "tag", "v1"); + writeFileSync(join(cwd, "a.ts"), "export const a = 2;\n"); + writeFileSync(join(cwd, "b.ts"), "export const b = 2;\n"); + const compare = { + cwd, + base: { kind: "rev", rev: "refs/tags/v1" }, + head: { kind: "worktree" }, + mergeBase: true, + } as const; + const states: import("@getpaseo/client").DiffState[] = []; + const observation = ctx.client.observeDiff(compare); + observation.subscribe({ snapshot: (state) => states.push(state), update: () => {} }); + await expect.poll(() => states.at(-1)?.phase, { timeout: 15000 }).toBe("ready"); + expect(states[0]).toMatchObject({ + phase: "loading", + entries: [{ path: "a.ts" }, { path: "b.ts" }], + }); + const initial = states.at(-1); + if (initial?.phase !== "ready") throw new Error("Expected ready"); + const initialStateCount = states.length; + await second.connect(); + startGitCommandMetrics(); + const duplicateStates: import("@getpaseo/client").DiffState[] = []; + const duplicate = second.observeDiff(compare); + duplicate.subscribe({ snapshot: (state) => duplicateStates.push(state), update: () => {} }); + await expect.poll(() => duplicateStates.at(-1)?.phase).toBe("ready"); + const metrics = stopGitCommandMetrics(); + expect(duplicateStates.at(-1)).toMatchObject({ + phase: "ready", + files: [{ path: "a.ts" }, { path: "b.ts" }], + }); + expect(metrics.commands.filter((command) => command.args.includes("--raw"))).toHaveLength(0); + startGitCommandMetrics(); + writeFileSync(join(cwd, "a.ts"), "export const a = 3;\n"); + await expect + .poll(() => states.at(-1)?.revision, { timeout: 15000 }) + .toBeGreaterThan(initial.revision); + await expect.poll(() => duplicateStates.at(-1)?.revision).toBeGreaterThan(initial.revision); + const refreshMetrics = stopGitCommandMetrics(); + expect( + refreshMetrics.commands.filter((command) => command.args.includes("--raw")), + ).toHaveLength(1); + const refreshed = states.at(-1); + if (refreshed?.phase !== "ready") throw new Error("Expected ready"); + expect(refreshed.files[0]).not.toBe(initial.files[0]); + expect(refreshed.files[1]).toBe(initial.files[1]); + expect(states.slice(initialStateCount).map((state) => state.phase)).not.toContain("loading"); + expect(states.map((state) => state.phase)).not.toContain("error"); + await duplicate.release(); + await observation.release(); + } finally { + await second.close(); + await ctx.cleanup(); + rmSync(cwd, { recursive: true, force: true }); + } +}, 45000); + +test("commit-file RPC keeps the existing read ceiling for patches larger than one MiB", async () => { + const ctx = await createDaemonTestContext(); + const cwd = mkdtempSync(join(tmpdir(), "commit-file-budget-")); + try { + git(cwd, "init", "-b", "main"); + git(cwd, "config", "user.name", "Test"); + git(cwd, "config", "user.email", "test@example.com"); + writeFileSync(join(cwd, "large.txt"), "base\n"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "base"); + writeFileSync(join(cwd, "large.txt"), ("x".repeat(100) + "\n").repeat(11000)); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "large change"); + const sha = git(cwd, "rev-parse", "HEAD").trim(); + const { getCommitFileDiff } = await import("../../utils/checkout-git.js"); + const expected = await getCommitFileDiff({ cwd, sha, path: "large.txt" }); + const actual = await ctx.client.getCommitFileDiff(cwd, sha, "large.txt"); + expect(expected?.hunks.length).toBeGreaterThan(0); + expect(actual.file?.hunks).toEqual(expected?.hunks); + } finally { + await ctx.cleanup(); + rmSync(cwd, { recursive: true, force: true }); + } +}, 30000); + +test("commit-file RPC selects renamed content when the source path becomes a directory", async () => { + const ctx = await createDaemonTestContext(); + const cwd = mkdtempSync(join(tmpdir(), "commit-rename-directory-")); + try { + git(cwd, "init", "-b", "main"); + git(cwd, "config", "user.name", "Test"); + git(cwd, "config", "user.email", "test@example.com"); + writeFileSync(join(cwd, "a.txt"), "original line\n".repeat(16)); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "base"); + git(cwd, "mv", "a.txt", "z.txt"); + writeFileSync(join(cwd, "z.txt"), "original line\n".repeat(16) + "renamed line\n"); + mkdirSync(join(cwd, "a.txt")); + writeFileSync(join(cwd, "a.txt", "child.txt"), "replacement line\n"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "rename"); + const sha = git(cwd, "rev-parse", "HEAD").trim(); + const { getCommitFileDiff } = await import("../../utils/checkout-git.js"); + const expected = await getCommitFileDiff({ cwd, sha, path: "z.txt" }); + const actual = await ctx.client.getCommitFileDiff(cwd, sha, "z.txt"); + expect(expected?.hunks.flatMap((hunk) => hunk.lines.map((line) => line.content))).toContain( + "renamed line", + ); + expect(actual.file?.hunks).toEqual(expected?.hunks); + } finally { + await ctx.cleanup(); + rmSync(cwd, { recursive: true, force: true }); + } +}, 30000); diff --git a/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts index 3af53a9133..40d410f6c1 100644 --- a/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts @@ -142,7 +142,8 @@ test("keeps the socket usable after rejecting an oversized structured diff", asy commitFile(cwd, "large-b.js", "const value = 0;\n"); // Short lines stay eligible for highlighting; a giant single line is skipped // before token expansion and cannot exercise the structured-payload limit. - const denseExpression = `const value = ${"a+".repeat(100)}a;\n`.repeat(4_000); + // Stay below the full-source highlighting budget as well. + const denseExpression = `const value = ${"a+".repeat(100)}a;\n`.repeat(3_500); writeFileSync(path.join(cwd, "large-a.js"), denseExpression); writeFileSync(path.join(cwd, "large-b.js"), denseExpression); diff --git a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts index da52c4dac4..068e0628d0 100644 --- a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts @@ -690,6 +690,7 @@ describe("commit file diffs", () => { expect(result).toEqual({ file: { path: filePath, + status: "ok", isNew: false, isDeleted: false, additions: 1, diff --git a/packages/server/src/server/diff/batches.ts b/packages/server/src/server/diff/batches.ts new file mode 100644 index 0000000000..25628eed57 --- /dev/null +++ b/packages/server/src/server/diff/batches.ts @@ -0,0 +1,30 @@ +import { encodeDiffFrame, DiffOpcode, type DiffBody } from "@getpaseo/protocol/binary-frames/index"; + +export async function* packBodies( + files: AsyncIterable | Iterable, + revision: number, +): AsyncGenerator { + let batch: DiffBody[] = []; + let size = 0; + for await (const file of files) { + const bytes = encodeDiffFrame({ + opcode: DiffOpcode.Bodies, + slot: 0, + revision, + files: [file], + }).length; + if (batch.length && size + bytes > 256 * 1024) { + yield batch; + batch = []; + size = 0; + } + batch.push(file); + size += bytes; + if (size >= 256 * 1024) { + yield batch; + batch = []; + size = 0; + } + } + if (batch.length) yield batch; +} diff --git a/packages/server/src/server/diff/dependencies.ts b/packages/server/src/server/diff/dependencies.ts new file mode 100644 index 0000000000..81b9cd194b --- /dev/null +++ b/packages/server/src/server/diff/dependencies.ts @@ -0,0 +1,92 @@ +import { + affectsGitDiffInterpretation, + classifyGitMetadataPath, + type GitMetadataScope, +} from "../git-metadata-event-rules.js"; +import { resolve } from "node:path"; +import { runGitCommand } from "../../utils/run-git-command.js"; +import type { WorkspaceGitService } from "../workspace-git-service.js"; +import { createFileObserver, type FileObserverSubscription } from "../file-observer/index.js"; + +export interface DiffDependencies { + cwd: string; + refs: string[]; + index: boolean; + worktree: boolean; +} +// Own metadata observations here; comparison managers declare inputs rather than watching paths. +export class DiffDependencyObserver { + private observer = createFileObserver(); + constructor(private workspaceGit: Pick) {} + async declareDependencies( + input: DiffDependencies, + changed: (kind: "endpoints" | "interpretation") => void, + ): Promise<() => Promise> { + const hasMutableEndpoints = input.refs.length > 0 || input.index || input.worktree; + let polling: ReturnType | null = null; + const subscriptions: FileObserverSubscription[] = []; + let workingTree: Awaited> | null = + null; + try { + const paths = await runGitCommand(["rev-parse", "--absolute-git-dir", "--git-common-dir"], { + cwd: input.cwd, + }); + const [gitDir, commonDir] = paths.stdout.split(/\r?\n/); + if (!gitDir || !commonDir) throw new Error("Git returned incomplete metadata roots"); + const roots = new Map(); + for (const [directory, scope] of [ + [gitDir, "worktree"], + [commonDir, "common"], + ] as const) { + const root = resolve(input.cwd, directory); + const scopes = roots.get(root) ?? []; + scopes.push(scope); + roots.set(root, scopes); + } + for (const [root, scopes] of roots) { + const subscription = await this.observer.subscribe( + root, + (error, events) => { + if (error) { + changed("endpoints"); + polling ??= setInterval(() => changed("endpoints"), 5000); + return; + } + let interpretation = false; + const endpoints = events.some((event) => { + const path = event.path.slice(root.length + 1).replaceAll("\\", "/"); + if (scopes.some((scope) => affectsGitDiffInterpretation(scope, path))) + interpretation = true; + if (!hasMutableEndpoints) return false; + if (path === "index") return input.index; + return scopes.some((scope) => classifyGitMetadataPath(scope, path).kind !== "ignore"); + }); + if (endpoints) changed("endpoints"); + else if (interpretation) changed("interpretation"); + }, + { ignore: [resolve(root, "objects"), resolve(root, "logs"), resolve(root, "hooks")] }, + ); + subscriptions.push(subscription); + } + workingTree = await this.workspaceGit.requestWorkingTreeWatch(input.cwd, () => + changed(input.worktree ? "endpoints" : "interpretation"), + ); + return async () => { + if (polling) clearInterval(polling); + await workingTree?.unsubscribe(); + await Promise.all(subscriptions.map((subscription) => subscription.unsubscribe())); + }; + } catch (error) { + if (polling) clearInterval(polling); + await workingTree?.unsubscribe(); + await Promise.all(subscriptions.map((subscription) => subscription.unsubscribe())); + throw error; + } + } + getObservationCount(): number { + return this.observer.getDiagnostics().activeObservationCount; + } + async close(): Promise { + await this.observer.close(); + } +} diff --git a/packages/server/src/server/diff/git.test.ts b/packages/server/src/server/diff/git.test.ts new file mode 100644 index 0000000000..5c77f80e45 --- /dev/null +++ b/packages/server/src/server/diff/git.test.ts @@ -0,0 +1,719 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, writeFile, rm, readFile, lstat } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { expect, test } from "vitest"; +import { listComparison, type Comparison } from "./git.js"; + +test("working tree comparisons preserve staged deletions whose file remains on disk", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-comparison-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + try { + git("init", "-b", "main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + await writeFile(join(cwd, "x.ts"), "const value = 1;\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + git("rm", "--cached", "x.ts"); + await writeFile(join(cwd, "x.ts"), "const value = 2;\n"); + const comparison = await listComparison({ + cwd, + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "worktree" }, + highlight: false, + }); + expect(comparison.files.map((file) => file.entry)).toEqual([ + { + path: "x.ts", + oldPath: null, + change: "modified", + content: "text", + additions: 1, + deletions: 1, + body: true, + }, + ]); + const body = await readBody(comparison); + expect(body.body.hunks[0].lines).toEqual([ + { type: "header", content: "@@ -1 +1 @@" }, + { type: "remove", content: "const value = 1;" }, + { type: "add", content: "const value = 2;" }, + ]); + expect(comparison.metrics.highlightCalls).toBe(0); + await comparison.close(); + expect(git("diff", "--cached", "--name-status")).toBe("D\tx.ts"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("supports empty trees, unborn HEAD, staged and unstaged comparisons", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-endpoints-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + try { + git("init", "-b", "main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + await writeFile(join(cwd, "x.ts"), "const value = 1;\n"); + const unborn = await listComparison({ + cwd, + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "worktree" }, + }); + expect(unborn.files.map((file) => file.entry.path)).toEqual(["x.ts"]); + await unborn.close(); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + const empty = await listComparison({ + cwd, + base: { kind: "empty" }, + head: { kind: "rev", rev: "HEAD" }, + }); + expect(empty.files[0].entry.change).toBe("added"); + await empty.close(); + await writeFile(join(cwd, "x.ts"), "const value = 2;\n"); + git("add", "."); + const indexPath = join(cwd, ".git", "index"); + const originalIndex = await readFile(indexPath); + const originalMtime = (await lstat(indexPath, { bigint: true })).mtimeNs; + const staged = await listComparison({ + cwd, + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "index" }, + highlight: false, + }); + expect(await readFile(indexPath)).toEqual(originalIndex); + expect((await lstat(indexPath, { bigint: true })).mtimeNs).toBe(originalMtime); + const stagedSha = staged.headSha; + expect(staged.files[0].entry).toMatchObject({ additions: 1, deletions: 1 }); + await staged.close(); + await writeFile(join(cwd, "x.ts"), "const value = 3;\n"); + const unstaged = await listComparison({ + cwd, + base: { kind: "index" }, + head: { kind: "worktree" }, + highlight: false, + }); + expect(unstaged.baseSha).toBe(stagedSha); + const record = await readBody(unstaged); + expect(record.body.hunks[0].lines).toContainEqual({ + type: "remove", + content: "const value = 2;", + }); + await unstaged.close(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("lists oversized untracked files without treating their truncated contents as additions", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-large-file-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + try { + git("init", "-b", "main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + await writeFile(join(cwd, "README"), "base\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + await writeFile(join(cwd, "huge.ts"), Buffer.alloc(2 * 1024 * 1024, 120)); + const comparison = await listComparison({ + cwd, + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "worktree" }, + }); + expect(comparison.files[0].entry).toMatchObject({ + path: "huge.ts", + additions: 0, + deletions: 0, + content: "text", + }); + expect(await comparison.read(comparison.files[0])).toEqual({ kind: "too_large" }); + await comparison.close(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("preserves highlighting context outside the emitted hunks", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-highlight-context-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + try { + git("init", "-b", "main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + const lines = [ + "/*", + ...Array.from({ length: 30 }, (_, index) => `comment ${index}`), + "*/", + "export const value = 1;", + ]; + await writeFile(join(cwd, "x.ts"), lines.join("\n") + "\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + lines[15] = "new comment"; + await writeFile(join(cwd, "x.ts"), lines.join("\n") + "\n"); + const comparison = await listComparison({ + cwd, + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "worktree" }, + }); + const record = await readBody(comparison); + expect(record.body.hunks[0].lines.find((line) => line.type === "add")).toMatchObject({ + content: "new comment", + runs: [{ style: "comment", length: 11 }], + }); + await comparison.close(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("reads patches independently of repository color and prefix presentation settings", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-presentation-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + try { + git("init", "-b", "main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + await writeFile(join(cwd, "x.ts"), "before\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + git("config", "color.ui", "always"); + git("config", "diff.noprefix", "true"); + await writeFile(join(cwd, "x.ts"), "after\n"); + const comparison = await listComparison({ + cwd, + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "worktree" }, + highlight: false, + }); + const record = await readBody(comparison); + expect(record.body.hunks).toHaveLength(1); + expect(record.body.hunks[0].lines).toContainEqual({ type: "add", content: "after" }); + await comparison.close(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("merge-base index comparisons resolve against HEAD while reporting both endpoint SHAs", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-index-merge-base-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + try { + git("init", "-b", "main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + await writeFile(join(cwd, "README"), "base\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + const common = git("rev-parse", "HEAD"); + git("checkout", "-b", "side"); + await writeFile(join(cwd, "side.txt"), "side\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "side"); + const side = git("rev-parse", "HEAD"); + git("checkout", "main"); + await writeFile(join(cwd, "main.txt"), "main\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "main"); + await writeFile(join(cwd, "staged.txt"), "staged\n"); + git("add", "staged.txt"); + const comparison = await listComparison({ + cwd, + base: { kind: "rev", rev: "side" }, + head: { kind: "index" }, + mergeBase: true, + }); + expect(comparison.baseSha).toBe(side); + expect(comparison.mergeBaseSha).toBe(common); + expect(comparison.files.map((file) => file.entry.path)).toEqual(["main.txt", "staged.txt"]); + await comparison.close(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +async function readBody(comparison: Comparison) { + const record = await comparison.read(comparison.files[0]); + if (record.kind !== "ready") throw new Error("Expected a readable file body"); + return record; +} + +test.each(["worktree", "index", "rev"] as const)( + "%s whitespace filtering omits spacing-only edits and retains meaningful edits", + async (headKind) => { + const cwd = await mkdtemp(join(tmpdir(), "diff-whitespace-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }); + try { + git("init", "-b", "main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + await writeFile(join(cwd, "a.ts"), "const x = 1;\n"); + await writeFile(join(cwd, "b.txt"), "before\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + const base = git("rev-parse", "HEAD").trim(); + await writeFile(join(cwd, "a.ts"), "const x = 1;\n"); + await writeFile(join(cwd, "b.txt"), "after\n"); + if (headKind !== "worktree") git("add", "."); + if (headKind === "rev") git("-c", "commit.gpgsign=false", "commit", "-m", "edit"); + const head = headKind === "rev" ? { kind: "rev" as const, rev: "HEAD" } : { kind: headKind }; + const comparison = await listComparison({ + cwd, + base: { kind: "rev", rev: base }, + head, + ignoreWhitespace: true, + highlight: false, + }); + expect(comparison.files.map((file) => file.entry.path)).toEqual(["b.txt"]); + await comparison.close(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }, +); + +test.skipIf(process.platform === "win32")( + "comparison roots preserve trailing folder spaces", + async () => { + const root = await mkdtemp(join(tmpdir(), "diff-space-")); + const cwd = join(root, "repo "); + const { mkdir } = await import("node:fs/promises"); + await mkdir(cwd); + try { + execFileSync("git", ["init", "-b", "main"], { cwd }); + const { worktreeRoot } = await import("./git.js"); + expect(await worktreeRoot(cwd)).toBe(cwd); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, +); + +test.each(["worktree", "index", "rev"] as const)( + "%s rename reads select the destination when the old path becomes a directory", + async (headKind) => { + const cwd = await mkdtemp(join(tmpdir(), "diff-rename-directory-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const { mkdir } = await import("node:fs/promises"); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + await writeFile(join(cwd, "a.txt"), "original line\n".repeat(16)); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + const base = git("rev-parse", "HEAD"); + git("mv", "a.txt", "z.txt"); + await writeFile(join(cwd, "z.txt"), "original line\n".repeat(16) + "renamed line\n"); + await mkdir(join(cwd, "a.txt")); + await writeFile(join(cwd, "a.txt", "child.txt"), "replacement line\n"); + git("add", "."); + if (headKind === "rev") git("-c", "commit.gpgsign=false", "commit", "-m", "rename"); + const head = headKind === "rev" ? { kind: "rev" as const, rev: "HEAD" } : { kind: headKind }; + const comparison = await listComparison({ + cwd, + base: { kind: "rev", rev: base }, + head, + highlight: false, + }); + try { + const file = comparison.files.find((candidate) => candidate.entry.path === "z.txt"); + if (!file) throw new Error("Missing rename"); + expect(file.entry.oldPath).toBe("a.txt"); + const record = await comparison.read(file); + if (record.kind !== "ready") throw new Error("Expected readable rename"); + const lines = record.body.hunks.flatMap((hunk) => hunk.lines.map((line) => line.content)); + expect(lines).toContain("renamed line"); + expect(lines).not.toContain("replacement line"); + } finally { + await comparison.close(); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }, +); + +test.skipIf(process.platform === "win32")( + "a dangling untracked symlink exposes its target text alongside tracked changes", + async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-dangling-link-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }); + const { symlink } = await import("node:fs/promises"); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + await writeFile(join(cwd, "a.txt"), "before\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + await writeFile(join(cwd, "a.txt"), "after\n"); + await symlink("not-created-yet", join(cwd, "link")); + const comparison = await listComparison({ + cwd, + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "worktree" }, + highlight: false, + }); + try { + expect(comparison.files.map((file) => file.entry.path)).toEqual(["a.txt", "link"]); + const record = await comparison.read(comparison.files[1]); + if (record.kind !== "ready") throw new Error("Expected link body"); + expect(record.body.hunks.flatMap((hunk) => hunk.lines)).toContainEqual({ + type: "add", + content: "not-created-yet", + }); + } finally { + await comparison.close(); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }, +); + +test("untracked nested repositories do not hide tracked edits", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-nested-repository-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }); + const { mkdir } = await import("node:fs/promises"); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + await writeFile(join(cwd, "a.txt"), "before\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + await writeFile(join(cwd, "a.txt"), "after\n"); + await mkdir(join(cwd, "nested")); + git("-C", "nested", "init"); + const comparison = await listComparison({ + cwd, + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "worktree" }, + highlight: false, + }); + try { + expect(comparison.files.map((file) => file.entry.path)).toEqual(["a.txt"]); + const record = await readBody(comparison); + expect(record.body.hunks.flatMap((hunk) => hunk.lines)).toContainEqual({ + type: "add", + content: "after", + }); + } finally { + await comparison.close(); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test.each(["worktree", "index", "rev"] as const)( + "%s preserves hunks for paths containing a patch prefix delimiter", + async (headKind) => { + const cwd = await mkdtemp(join(tmpdir(), "diff-path-delimiter-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const { mkdir } = await import("node:fs/promises"); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + await mkdir(join(cwd, "dir b")); + await writeFile(join(cwd, "dir b", "file.txt"), "before\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + const base = git("rev-parse", "HEAD"); + await writeFile(join(cwd, "dir b", "file.txt"), "after\n"); + if (headKind !== "worktree") git("add", "."); + if (headKind === "rev") git("-c", "commit.gpgsign=false", "commit", "-m", "edit"); + const head = headKind === "rev" ? { kind: "rev" as const, rev: "HEAD" } : { kind: headKind }; + const comparison = await listComparison({ + cwd, + base: { kind: "rev", rev: base }, + head, + highlight: false, + }); + try { + expect(comparison.files[0].entry.path).toBe("dir b/file.txt"); + const record = await readBody(comparison); + expect(record.body.hunks.flatMap((hunk) => hunk.lines)).toContainEqual({ + type: "add", + content: "after", + }); + } finally { + await comparison.close(); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }, +); + +test("highlighted BOM source content survives the production frame codec", async () => { + const { encodeDiffFrame, decodeDiffFrame } = + await import("@getpaseo/protocol/binary-frames/index"); + const cwd = await mkdtemp(join(tmpdir(), "diff-bom-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }); + try { + git("init", "-b", "main"); + await writeFile(join(cwd, "bom.ts"), "\uFEFFexport const value = 1;\n"); + const comparison = await listComparison({ + cwd, + base: { kind: "empty" }, + head: { kind: "worktree" }, + }); + try { + const record = await readBody(comparison); + const frame = { opcode: 0x41 as const, slot: 0, revision: 1, files: [record.body] }; + expect(decodeDiffFrame(encodeDiffFrame(frame))).toEqual(frame); + expect( + record.body.hunks + .flatMap((hunk) => hunk.lines) + .some((line) => line.content.startsWith("\uFEFF")), + ).toBe(true); + } finally { + await comparison.close(); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test.skipIf(process.platform === "win32")( + "symlink replacement retains both patch sections", + async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-type-change-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }); + const { symlink, unlink } = await import("node:fs/promises"); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + await symlink("old-target", join(cwd, "file.txt")); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + await unlink(join(cwd, "file.txt")); + await writeFile(join(cwd, "file.txt"), "replacement contents\n"); + const comparison = await listComparison({ + cwd, + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "worktree" }, + highlight: false, + }); + try { + expect(comparison.files[0].entry).toMatchObject({ additions: 1, deletions: 1 }); + const record = await readBody(comparison); + expect(record.body.hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([ + { type: "remove", content: "old-target" }, + { type: "add", content: "replacement contents" }, + ]), + ); + } finally { + await comparison.close(); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }, +); + +test.skipIf(process.platform === "win32")( + "highlighted symlink replacements use stored link text instead of target contents", + async () => { + const { encodeDiffFrame, decodeDiffFrame } = + await import("@getpaseo/protocol/binary-frames/index"); + const cwd = await mkdtemp(join(tmpdir(), "diff-highlighted-link-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }); + const { symlink, unlink } = await import("node:fs/promises"); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + await writeFile(join(cwd, "file.ts"), "export const old = 1;\n"); + await writeFile(join(cwd, "target.ts"), "export const entirelyDifferentLength = 2;\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + await unlink(join(cwd, "file.ts")); + await symlink("target.ts", join(cwd, "file.ts")); + const comparison = await listComparison({ + cwd, + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "worktree" }, + }); + try { + const record = await readBody(comparison); + expect(record.body.hunks.flatMap((hunk) => hunk.lines)).toContainEqual( + expect.objectContaining({ type: "add", content: "target.ts" }), + ); + const frame = { opcode: 0x41 as const, slot: 0, revision: 1, files: [record.body] }; + expect(decodeDiffFrame(encodeDiffFrame(frame))).toEqual(frame); + } finally { + await comparison.close(); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }, +); + +test("files removed between listing and highlighting retain their deletion patch", async () => { + const { encodeDiffFrame, decodeDiffFrame } = + await import("@getpaseo/protocol/binary-frames/index"); + const cwd = await mkdtemp(join(tmpdir(), "diff-source-disappeared-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + await writeFile(join(cwd, "a.ts"), "export const old = 1;\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + await writeFile(join(cwd, "a.ts"), "export const old = 2;\n"); + const comparison = await listComparison({ + cwd, + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "worktree" }, + }); + try { + expect(comparison.files[0].entry.change).toBe("modified"); + await rm(join(cwd, "a.ts")); + const record = await readBody(comparison); + expect(record.body.hunks.flatMap((hunk) => hunk.lines)).toContainEqual( + expect.objectContaining({ type: "remove", content: "export const old = 1;" }), + ); + const frame = { opcode: 0x41 as const, slot: 0, revision: 1, files: [record.body] }; + expect(decodeDiffFrame(encodeDiffFrame(frame))).toEqual(frame); + } finally { + await comparison.close(); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("a batched oversized tracked patch does not hide its small sibling", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-batch-budget-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + await writeFile(join(cwd, "a.txt"), "before\n"); + await writeFile(join(cwd, "b.txt"), "before\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + await writeFile(join(cwd, "a.txt"), "oversized\n".repeat(1024 * 1024)); + await writeFile(join(cwd, "b.txt"), "after\n"); + const comparison = await listComparison({ + cwd, + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "worktree" }, + highlight: false, + }); + try { + expect(await comparison.read(comparison.files[0])).toEqual({ kind: "too_large" }); + const sibling = await comparison.read(comparison.files[1]); + if (sibling.kind !== "ready") throw new Error("Expected readable sibling"); + expect(sibling.body.hunks.flatMap((hunk) => hunk.lines)).toContainEqual({ + type: "add", + content: "after", + }); + } finally { + await comparison.close(); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("replacing a submodule with a regular file preserves its replacement body", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-gitlink-replacement-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }); + const { mkdir } = await import("node:fs/promises"); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + await mkdir(join(cwd, "sub")); + git("-C", "sub", "init", "-b", "main"); + git("-C", "sub", "config", "user.name", "Test"); + git("-C", "sub", "config", "user.email", "test@example.com"); + await writeFile(join(cwd, "sub", "a.txt"), "base\n"); + git("-C", "sub", "add", "."); + git("-C", "sub", "-c", "commit.gpgsign=false", "commit", "-m", "base"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "record submodule"); + await rm(join(cwd, "sub"), { recursive: true, force: true }); + await writeFile(join(cwd, "sub"), "replacement contents\n"); + const comparison = await listComparison({ + cwd, + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "worktree" }, + }); + try { + const record = await readBody(comparison); + expect(record.body.hunks.flatMap((hunk) => hunk.lines)).toContainEqual( + expect.objectContaining({ type: "add", content: "replacement contents" }), + ); + } finally { + await comparison.close(); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test.each([ + { ignoreWhitespace: false, after: "export const value = 2;\n", files: ["a.ts"] }, + { ignoreWhitespace: true, after: "export const value = 1;\n", files: [] }, +])( + "interpretation checks ignore content edits but detect new binary attributes, whitespace=$ignoreWhitespace", + async ({ ignoreWhitespace, after, files }) => { + const cwd = await mkdtemp(join(tmpdir(), "diff-interpretation-check-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + git("config", "core.autocrlf", "false"); + await writeFile(join(cwd, "a.ts"), "export const value = 1;\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + const base = git("rev-parse", "HEAD"); + await writeFile(join(cwd, "a.ts"), after); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "edit"); + const compare = { + cwd, + base: { kind: "rev" as const, rev: base }, + head: { kind: "rev" as const, rev: git("rev-parse", "HEAD") }, + ignoreWhitespace, + highlight: false, + }; + const comparison = await listComparison(compare); + try { + expect(comparison.files.map((file) => file.entry.path)).toEqual(files); + expect(await comparison.hasInterpretationChanged()).toBe(false); + await writeFile(join(cwd, "a.ts"), "uncommitted content\n"); + expect(await comparison.hasInterpretationChanged()).toBe(false); + await writeFile(join(cwd, ".gitattributes"), "a.ts -diff\n"); + expect(await comparison.hasInterpretationChanged()).toBe(true); + const fresh = await listComparison(compare); + try { + expect(fresh.files.map((file) => file.entry)).toEqual([ + expect.objectContaining({ path: "a.ts", content: "binary" }), + ]); + } finally { + await fresh.close(); + } + } finally { + await comparison.close(); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }, +); diff --git a/packages/server/src/server/diff/git.ts b/packages/server/src/server/diff/git.ts new file mode 100644 index 0000000000..c58c1de181 --- /dev/null +++ b/packages/server/src/server/diff/git.ts @@ -0,0 +1,431 @@ +import { parseGitRevParsePath } from "../../utils/git-rev-parse-path.js"; +import { readGitInputs } from "./inputs.js"; +import { Patches } from "./patches.js"; +import { HighlightSources } from "./sources.js"; +import { + prepareAllChangesIndex, + PER_FILE_DIFF_MAX_BYTES, + GIT_PATCH_FORMAT_ARGS, + inspectUntrackedFile, + snapshotComparisonIndex, + resolveCheckoutDiffBase, + type CheckoutDiffCompare, +} from "../../utils/checkout-git.js"; +import { lstat, readFile, readlink } from "node:fs/promises"; +import type { BigIntStats } from "node:fs"; +import { resolve } from "node:path"; +import type { DiffEndpoint, DiffSubscribeRequest } from "@getpaseo/protocol/messages"; +import type { DiffEntry, DiffBody } from "@getpaseo/protocol/binary-frames/index"; +import { runGitCommand, type GitCommandOptions } from "../../utils/run-git-command.js"; +import { parseDiff, highlightDiffWithFileContent } from "../utils/diff-highlighter.js"; + +export type Compare = Omit & { + checkoutBase?: { compare: CheckoutDiffCompare; paseoHome: string }; +}; +export interface ListedFile { + entry: DiffEntry; + identity: string; + source: { kind: "tracked" } | { kind: "untracked"; size: number }; +} +interface ResolvedComparison { + baseSha: string; + headSha: string; + mergeBaseSha: string; + comparisonBaseSha: string; +} +export type DiffFileRead = { kind: "ready"; body: DiffBody; bytes: number } | { kind: "too_large" }; +export interface Comparison extends Pick< + ResolvedComparison, + "baseSha" | "headSha" | "mergeBaseSha" +> { + files: ListedFile[]; + metrics: { highlightCalls: number }; + close(): Promise; + selectBodies(paths: ReadonlySet): void; + hasInterpretationChanged(): Promise; + read( + file: ListedFile, + budget?: { remainingBytes?: number; maxPatchBytes?: number }, + ): Promise; +} +export class DiffReadError extends Error { + constructor( + readonly code: "INVALID_COMPARE" | "NOT_GIT_REPO" | "REF_NOT_FOUND", + message: string, + ) { + super(message); + } +} +const EMPTY = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; +export function validateCompare(compare: Compare): void { + if (compare.head.kind === "empty") + throw new DiffReadError("INVALID_COMPARE", "The head cannot be empty"); + if (compare.base.kind === "worktree") + throw new DiffReadError("INVALID_COMPARE", "The working tree cannot be the base"); + if (compare.base.kind === "index" && compare.head.kind !== "worktree") + throw new DiffReadError("INVALID_COMPARE", "An index base requires a working tree head"); + if (compare.mergeBase && compare.base.kind !== "rev") + throw new DiffReadError("INVALID_COMPARE", "Merge base requires a revision base"); +} +export async function worktreeRoot(cwd: string): Promise { + const result = await runGitCommand(["rev-parse", "--show-toplevel"], { + cwd, + acceptExitCodes: [0, 128], + }); + if (result.exitCode !== 0) throw new DiffReadError("NOT_GIT_REPO", "Not a git repository"); + const root = parseGitRevParsePath(result.stdout); + if (root === null) throw new DiffReadError("NOT_GIT_REPO", "Git returned no working tree root"); + return resolve(root); +} +async function resolveEndpoint( + cwd: string, + endpoint: DiffEndpoint, + indexFile: string | null, +): Promise { + if (endpoint.kind === "empty") return EMPTY; + if (endpoint.kind === "index") { + if (indexFile === null) throw new Error("Index comparisons require an index snapshot"); + const result = await runGitCommand(["write-tree"], { + cwd, + envOverlay: { GIT_INDEX_FILE: indexFile }, + }); + return result.stdout.trim(); + } + const ref = endpoint.kind === "rev" ? endpoint.rev : "HEAD"; + const result = await runGitCommand( + ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`], + { + cwd, + acceptExitCodes: [0, 128], + }, + ); + if (result.exitCode === 0) return result.stdout.trim(); + if (ref === "HEAD") { + const unborn = await runGitCommand(["rev-parse", "--verify", "HEAD"], { + cwd, + acceptExitCodes: [0, 128], + }); + if (unborn.exitCode !== 0) return EMPTY; + } + throw new DiffReadError("REF_NOT_FOUND", `Revision not found: ${ref}`); +} +function parseStats( + text: string, +): Map { + const fields = text.split("\0"); + const stats = new Map(); + for (let index = 0; index < fields.length - 1; index++) { + const line = fields[index]; + const tab = line.indexOf("\t"); + const second = line.indexOf("\t", tab + 1); + const add = line.slice(0, tab); + const del = line.slice(tab + 1, second); + let path = line.slice(second + 1); + if (!path) { + index++; + path = fields[++index]; + } + stats.set(path, { + additions: Number(add) || 0, + deletions: Number(del) || 0, + binary: add === "-", + }); + } + return stats; +} +export async function listComparison(compare: Compare): Promise { + if (compare.checkoutBase) { + const selected = await resolveCheckoutDiffBase(compare.cwd, compare.checkoutBase.compare, { + paseoHome: compare.checkoutBase.paseoHome, + }); + if (!selected) throw new DiffReadError("REF_NOT_FOUND", "Comparison base is unavailable"); + compare = { ...compare, base: { kind: "rev", rev: selected } }; + } + validateCompare(compare); + const cwd = compare.cwd; + let privateIndex: Awaited> = null; + try { + if (compare.base.kind === "index" || compare.head.kind === "index") + privateIndex = await snapshotComparisonIndex(cwd); + const indexFile = privateIndex ? privateIndex.indexFile : null; + const baseSha = await resolveEndpoint(cwd, compare.base, indexFile); + const headSha = await resolveEndpoint(cwd, compare.head, indexFile); + let mergeBaseSha = ""; + let comparisonBaseSha = baseSha; + if (compare.mergeBase) { + const headCommit = + compare.head.kind === "index" + ? await resolveEndpoint(cwd, { kind: "rev", rev: "HEAD" }, indexFile) + : headSha; + if (headCommit === EMPTY) comparisonBaseSha = EMPTY; + else if (baseSha !== EMPTY) { + const result = await runGitCommand(["merge-base", baseSha, headCommit], { + cwd, + acceptExitCodes: [0, 1], + }); + mergeBaseSha = result.stdout.trim(); + if (mergeBaseSha) comparisonBaseSha = mergeBaseSha; + } + } + if (compare.head.kind === "worktree" && compare.base.kind !== "index") + privateIndex = await prepareAllChangesIndex(cwd, comparisonBaseSha); + return await readComparisonList( + compare, + { baseSha, headSha, mergeBaseSha, comparisonBaseSha }, + privateIndex, + ); + } catch (error) { + await privateIndex?.cleanup(); + throw error; + } +} + +async function readComparisonList( + compare: Compare, + resolved: ResolvedComparison, + privateIndex: Awaited>, +): Promise { + const { baseSha, headSha, mergeBaseSha, comparisonBaseSha } = resolved; + const cwd = compare.cwd; + const args = diffArguments(compare, comparisonBaseSha, headSha); + const options: GitCommandOptions = { + cwd, + envOverlay: { + GIT_OPTIONAL_LOCKS: "0", + ...(privateIndex ? { GIT_INDEX_FILE: privateIndex.indexFile } : {}), + }, + }; + const { files, worktreeFiles, interpretationPaths } = await readTrackedFiles( + compare, + args, + options, + ); + if (compare.head.kind === "worktree") files.push(...(await listUntrackedFiles(options))); + files.sort((a, b) => a.entry.path.localeCompare(b.entry.path)); + for (const file of files) interpretationPaths.add(file.entry.path); + const gitInputs = await readGitInputs([...interpretationPaths].sort(), worktreeFiles, options); + for (const file of files) + file.identity += JSON.stringify([ + gitInputs.identities.get(file.entry.path), + gitInputs.identities.get(file.entry.oldPath ?? file.entry.path), + ]); + const metrics = { highlightCalls: 0 }; + const patches = new Patches(args, options, files); + const sources = new HighlightSources({ + cwd, + baseSha: comparisonBaseSha, + headSha: compare.head.kind === "worktree" ? null : headSha, + files, + }); + return { + baseSha, + headSha, + mergeBaseSha, + files, + metrics, + selectBodies: (paths) => patches.select(paths), + hasInterpretationChanged: gitInputs.hasInterpretationChanged, + close: async () => { + await privateIndex?.cleanup(); + }, + async read(file, budget = {}) { + const maxPatchBytes = budget.maxPatchBytes ?? PER_FILE_DIFF_MAX_BYTES; + const remainingBytes = budget.remainingBytes ?? maxPatchBytes; + if (file.source.kind === "untracked" && file.source.size > maxPatchBytes) { + return { kind: "too_large" }; + } + const result = + file.source.kind === "untracked" + ? await runGitCommand( + ["diff", "--no-index", ...GIT_PATCH_FORMAT_ARGS, "--", "/dev/null", file.entry.path], + { + ...options, + acceptExitCodes: [0, 1], + maxOutputBytes: maxPatchBytes + 1, + }, + ) + : await patches.read(file, maxPatchBytes); + const bytes = Buffer.byteLength(result.stdout); + if (result.truncated || bytes > remainingBytes || bytes > maxPatchBytes) + return { kind: "too_large" }; + // File-type changes have a deletion and addition section for the same path. + const sections = parseDiff(result.stdout).filter( + (candidate) => candidate.path === file.entry.path, + ); + if (!sections.length) + return { kind: "ready", body: { path: file.entry.path, hunks: [] }, bytes }; + const parsed = { + path: file.entry.path, + isNew: file.entry.change === "added", + isDeleted: file.entry.change === "deleted", + additions: file.entry.additions, + deletions: file.entry.deletions, + hunks: sections.flatMap((section) => section.hunks), + }; + let highlighted = parsed; + if (compare.highlight !== false) { + metrics.highlightCalls++; + const context = await sources.read(file); + highlighted = await highlightDiffWithFileContent(parsed, cwd, context); + } + const hunks = highlighted.hunks.map((hunk) => ({ + oldStart: hunk.oldStart, + oldCount: hunk.oldCount, + newStart: hunk.newStart, + newCount: hunk.newCount, + lines: hunk.lines.map((line) => { + if (!line.tokens) return { type: line.type, content: line.content }; + return { + type: line.type, + content: line.content, + runs: line.tokens + .filter((token) => token.text.length > 0) + .map((token) => ({ style: token.style, length: token.text.length })), + }; + }), + })); + return { kind: "ready", body: { path: file.entry.path, hunks }, bytes }; + }, + }; +} + +async function readTrackedFiles( + compare: Compare, + args: string[], + options: GitCommandOptions, +): Promise<{ files: ListedFile[]; worktreeFiles: Set; interpretationPaths: Set }> { + const [raw, stat] = await Promise.all([ + runGitCommand([...args.filter((arg) => arg !== "-w"), "--raw", "--no-abbrev", "-z"], options), + runGitCommand([...args, "--numstat", "-z"], options), + ]); + if (raw.truncated || stat.truncated) + throw new Error("Diff entry list exceeded the git output budget"); + const stats = parseStats(stat.stdout); + const fields = raw.stdout.split("\0"); + const files: ListedFile[] = []; + const worktreeFiles = new Set(); + const interpretationPaths = new Set(); + for (let index = 0; index < fields.length - 1; index++) { + const header = fields[index].split(" "); + const code = header[4]?.[0]; + let path = fields[++index]; + let oldPath: string | null = null; + if (code === "R" || code === "C") { + oldPath = path; + path = fields[++index]; + } + interpretationPaths.add(path); + if (oldPath !== null) interpretationPaths.add(oldPath); + const counts = stats.get(path); + // With -w, a raw metadata entry need not have a numstat record. + if (!counts && compare.ignoreWhitespace) continue; + if (!counts) throw new Error(`Missing diff statistics for ${path}`); + let change: DiffEntry["change"] = "modified"; + if (code === "A") change = "added"; + else if (code === "D") change = "deleted"; + else if (oldPath !== null) change = "renamed"; + let identity = header.join(" "); + if (compare.head.kind === "worktree" && code !== "D") { + const worktree = await readWorktreeIdentity(path, header, options); + identity += `:${worktree.identity}`; + if (worktree.regular && !counts.binary) worktreeFiles.add(path); + } + + files.push({ + entry: { + path, + oldPath, + change, + content: counts.binary ? "binary" : "text", + additions: counts.additions, + deletions: counts.deletions, + body: !counts.binary, + }, + identity, + source: { kind: "tracked" }, + }); + } + return { files, worktreeFiles, interpretationPaths }; +} + +async function readWorktreeIdentity( + path: string, + header: string[], + options: GitCommandOptions, +): Promise<{ identity: string; regular: boolean }> { + const value = await lstat(resolve(options.cwd, path), { bigint: true }); + let identity = fileStatIdentity(value); + if (value.isDirectory() && (header[0] === ":160000" || header[1] === "160000")) { + const submodule = await runGitCommand( + ["status", "--porcelain=v2", "--branch", "--untracked-files=normal"], + { cwd: resolve(options.cwd, path), envOverlay: { GIT_OPTIONAL_LOCKS: "0" } }, + ); + identity += `:${submodule.stdout}`; + } + return { identity, regular: value.isFile() }; +} + +function diffArguments(compare: Compare, baseSha: string, headSha: string): string[] { + const revisions: string[] = []; + if (compare.base.kind !== "index") revisions.push(baseSha); + if (compare.head.kind === "rev") revisions.push(headSha); + if (compare.head.kind === "index") revisions.unshift("--cached"); + const args = ["diff", ...GIT_PATCH_FORMAT_ARGS, "--no-textconv", "-M", ...revisions]; + if (compare.ignoreWhitespace) args.push("-w"); + return args; +} + +async function readUntrackedText( + path: string, + symlink: boolean, + omitted: boolean, +): Promise { + if (symlink) return Buffer.from(await readlink(path)); + if (omitted) return Buffer.alloc(0); + return readFile(path); +} + +async function listUntrackedFiles(options: GitCommandOptions): Promise { + const cwd = options.cwd; + const files: ListedFile[] = []; + const untracked = await runGitCommand( + ["ls-files", "--others", "--exclude-standard", "-z"], + options, + ); + for (const path of untracked.stdout.split("\0").filter(Boolean)) { + const value = await lstat(resolve(cwd, path), { bigint: true }); + if (!value.isFile() && !value.isSymbolicLink()) continue; + const symlink = value.isSymbolicLink(); + let binary = false; + let omitted = false; + if (!symlink) { + const inspected = await inspectUntrackedFile(cwd, path); + binary = inspected.stat?.isBinary === true; + omitted = inspected.truncated; + } + const content = await readUntrackedText(resolve(cwd, path), symlink, binary || omitted); + const additions = + binary || content.length === 0 + ? 0 + : content.toString("utf8").split("\n").length - Number(content.at(-1) === 10); + files.push({ + entry: { + path, + oldPath: null, + change: "added", + content: binary ? "binary" : "text", + additions, + deletions: 0, + body: !binary, + }, + identity: fileStatIdentity(value), + source: { kind: "untracked", size: Number(value.size) }, + }); + } + return files; +} + +function fileStatIdentity(value: BigIntStats): string { + // Timestamp-preserving copies still change file status or replace the inode. + return `${value.dev}:${value.ino}:${value.size}:${value.mtimeNs}:${value.ctimeNs}`; +} diff --git a/packages/server/src/server/diff/index.ts b/packages/server/src/server/diff/index.ts new file mode 100644 index 0000000000..6c9a537f86 --- /dev/null +++ b/packages/server/src/server/diff/index.ts @@ -0,0 +1,487 @@ +import { TOTAL_DIFF_MAX_BYTES } from "../../utils/checkout-git.js"; +import { DEFAULT_MAX_OUTPUT_BYTES, runGitCommand } from "../../utils/run-git-command.js"; +import { expandTilde, createRealpathAwarePathMatcher } from "../../utils/path.js"; +import { packBodies } from "./batches.js"; +import { setImmediate } from "node:timers/promises"; +import { + DiffOpcode, + materializeDiffFile, + type DiffFrame, + type DiffEntry, + type DiffBody, +} from "@getpaseo/protocol/binary-frames/index"; +import type { + DiffError, + DiffEndpoint, + ParsedDiffFile, + CheckoutCommitFileDiffRequest, +} from "@getpaseo/protocol/messages"; +import type { WorkspaceGitService } from "../workspace-git-service.js"; +import { + listComparison, + validateCompare, + worktreeRoot, + DiffReadError, + type Compare, + type Comparison, +} from "./git.js"; +import { DiffDependencyObserver } from "./dependencies.js"; + +interface RecordCache { + identity: string; + body: DiffBody; + bytes: number; +} +interface Listener { + emit(frame: DiffFrame): Promise; + active: boolean; + delivery: Promise; +} +interface Target { + key: string; + compare: Compare; + listeners: Set; + revision: number; + committed: number; + cache: Map; + entries: DiffEntry[]; + resolved: Pick; + error: DiffError | null; + omitted: string[]; + refresh: Promise | null; + dirty: boolean; + checkInterpretation: (() => Promise) | null; + interpretationCheck: Promise | null; + interpretationDirty: boolean; + release: (() => Promise) | null; + opening: Promise | null; + closed: boolean; + timer: ReturnType | null; +} +export interface CommitFileRead extends Pick< + CheckoutCommitFileDiffRequest, + "cwd" | "sha" | "path" +> { + signal?: AbortSignal; +} +export interface DiffSubscription { + start(): Promise; + release(): Promise; +} +export class DiffSubscriptions { + private targets = new Map(); + private dependencies: DiffDependencyObserver; + private computations = 0; + private highlightCalls = 0; + constructor(workspaceGit: Pick) { + this.dependencies = new DiffDependencyObserver(workspaceGit); + } + getMetrics() { + return { + targets: this.targets.size, + subscriptions: [...this.targets.values()].reduce( + (sum, target) => sum + target.listeners.size, + 0, + ), + computations: this.computations, + highlightCalls: this.highlightCalls, + metadataObservers: this.dependencies.getObservationCount(), + }; + } + async readCommitFile(input: CommitFileRead): Promise { + input.signal?.throwIfAborted(); + const cwd = await worktreeRoot(expandTilde(input.cwd)); + const result = await runGitCommand( + ["rev-list", "--parents", "--max-count=1", "--end-of-options", input.sha], + { cwd }, + ); + const [head, parent] = result.stdout.trim().split(" "); + if (!head) throw new DiffReadError("REF_NOT_FOUND", `Revision not found: ${input.sha}`); + const base: DiffEndpoint = parent ? { kind: "rev", rev: parent } : { kind: "empty" }; + const comparison = await listComparison({ cwd, base, head: { kind: "rev", rev: head } }); + try { + input.signal?.throwIfAborted(); + const file = comparison.files.find((candidate) => candidate.entry.path === input.path); + if (!file || file.entry.content === "binary") return null; + comparison.selectBodies(new Set([file.entry.path])); + const record = await comparison.read(file, { maxPatchBytes: DEFAULT_MAX_OUTPUT_BYTES }); + input.signal?.throwIfAborted(); + return materializeDiffFile( + file.entry, + record.kind === "ready" ? record.body : null, + record.kind === "too_large", + ); + } finally { + await comparison.close(); + } + } + async subscribe(compare: Compare, emit: Listener["emit"]): Promise { + validateCompare(compare); + compare = { + ...compare, + cwd: await worktreeRoot(expandTilde(compare.cwd)), + mergeBase: compare.mergeBase === true, + ignoreWhitespace: compare.ignoreWhitespace === true, + highlight: compare.highlight !== false, + }; + const key = JSON.stringify([ + compare.cwd, + compare.checkoutBase + ? ["checkout", JSON.stringify(compare.checkoutBase)] + : endpointKey(compare.base), + endpointKey(compare.head), + compare.mergeBase, + compare.ignoreWhitespace, + compare.highlight, + ]); + let target = this.targets.get(key); + if (!target) { + target = { + key, + compare, + listeners: new Set(), + revision: 0, + committed: 0, + cache: new Map(), + entries: [], + resolved: { baseSha: "", headSha: "", mergeBaseSha: "" }, + error: null, + omitted: [], + refresh: null, + dirty: false, + checkInterpretation: null, + interpretationCheck: null, + interpretationDirty: false, + release: null, + opening: null, + closed: false, + timer: null, + }; + this.targets.set(key, target); + const captured = target; + try { + target.opening = this.dependencies + .declareDependencies( + { + cwd: compare.cwd, + refs: [compare.base, compare.head].flatMap((endpoint) => + endpoint.kind === "rev" && !/^[a-f0-9]{40,64}$/.test(endpoint.rev) + ? [endpoint.rev] + : [], + ), + index: compare.base.kind === "index" || compare.head.kind !== "rev", + worktree: compare.head.kind === "worktree", + }, + (kind) => { + if (kind === "interpretation") this.invalidateInterpretation(captured); + else this.invalidate(captured); + }, + ) + .then((release) => { + captured.release = release; + return undefined; + }); + } catch (error) { + this.targets.delete(key); + throw error; + } + } + try { + await target.opening; + } catch (error) { + target.closed = true; + this.targets.delete(key); + throw error; + } + if (target.closed) throw new Error("Diff subscription cancelled"); + const current = target; + const listener: Listener = { emit, active: false, delivery: Promise.resolve() }; + current.listeners.add(listener); + let released = false; + return { + start: async () => { + while (current.refresh) await current.refresh; + if (current.closed || released) return; + listener.active = true; + if (!current.committed) await this.refresh(current); + else { + const replay = this.replay(current, listener); + listener.delivery = replay.catch(() => {}); + await replay; + } + }, + release: async () => { + if (released) return; + released = true; + listener.active = false; + current.listeners.delete(listener); + if (current.listeners.size) return; + current.closed = true; + if (current.timer) clearTimeout(current.timer); + this.targets.delete(key); + await current.release?.(); + await current.refresh; + await current.interpretationCheck; + }, + }; + } + async invalidateForCwd(cwd: string): Promise { + let root: string; + try { + root = await worktreeRoot(expandTilde(cwd)); + } catch (error) { + if (error instanceof DiffReadError && error.code === "NOT_GIT_REPO") return; + throw error; + } + const matches = createRealpathAwarePathMatcher(root); + for (const target of this.targets.values()) + if (matches(target.compare.cwd)) this.invalidate(target); + } + private invalidateInterpretation(target: Target): void { + if (target.closed) return; + target.interpretationDirty = true; + target.interpretationCheck ??= this.checkInterpretation(target).finally(() => { + target.interpretationCheck = null; + }); + } + private async checkInterpretation(target: Target): Promise { + do { + target.interpretationDirty = false; + try { + if (!target.checkInterpretation || (await target.checkInterpretation())) { + this.invalidate(target); + return; + } + } catch { + // A normal revision owns error delivery and recovery. + this.invalidate(target); + return; + } + } while (target.interpretationDirty && !target.closed); + } + private invalidate(target: Target): void { + if (target.closed) return; + target.dirty = true; + if (target.listeners.size === 0) return; + if (target.timer) clearTimeout(target.timer); + target.timer = setTimeout(() => { + target.timer = null; + void this.refresh(target); + }, 150); + } + private async emit(target: Target, frame: DiffFrame): Promise { + await Promise.all( + [...target.listeners] + .filter((listener) => listener.active) + .map((listener) => { + const sent = listener.delivery.then(() => listener.emit(frame)); + listener.delivery = sent.catch(() => {}); + return sent; + }), + ); + await setImmediate(); + } + private async replay(target: Target, listener: Listener): Promise { + const entries = target.entries.map((entry) => ({ ...entry, body: entry.content !== "binary" })); + await listener.emit({ + opcode: DiffOpcode.Begin, + slot: 0, + revision: target.committed, + baseRevision: 0, + ...target.resolved, + entries, + }); + const bodies: DiffBody[] = []; + for (const entry of entries) { + const cached = target.cache.get(entry.path); + if (entry.body && cached) bodies.push(cached.body); + } + for await (const files of packBodies(bodies, target.committed)) { + await listener.emit({ + opcode: DiffOpcode.Bodies, + slot: 0, + revision: target.committed, + files, + }); + await setImmediate(); + } + await listener.emit({ + opcode: DiffOpcode.End, + slot: 0, + revision: target.committed, + diffTooLarge: target.omitted.length > 0, + omitted: target.omitted, + error: target.error, + }); + } + private async refresh(target: Target): Promise { + if (target.refresh || target.closed) return target.refresh ?? undefined; + target.refresh = this.run(target).finally(() => { + target.refresh = null; + }); + return target.refresh; + } + private async run(target: Target): Promise { + do { + target.dirty = false; + const revision = ++target.revision; + let comparison: Comparison | null = null; + let began = false; + try { + this.computations++; + comparison = await listComparison(target.compare); + target.checkInterpretation = comparison.hasInterpretationChanged; + const resolved = { + baseSha: comparison.baseSha, + headSha: comparison.headSha, + mergeBaseSha: comparison.mergeBaseSha, + }; + const tooManyFiles = comparison.files.length > 10000; + const entries = comparison.files.map((file) => ({ + ...file.entry, + body: tooManyFiles + ? file.entry.content === "text" + : needsBody(target, file.entry, file.identity), + })); + await this.emit(target, { + opcode: DiffOpcode.Begin, + slot: 0, + revision, + baseRevision: target.error ? 0 : target.committed, + ...resolved, + entries, + }); + began = true; + const cache = new Map(); + const omitted: string[] = []; + const state = { cache, omitted, total: 0 }; + const records = this.readBodies(target, comparison, entries, state); + for await (const files of packBodies(records, revision)) { + if (isInterrupted(target)) break; + await this.emit(target, { opcode: DiffOpcode.Bodies, slot: 0, revision, files }); + } + if (isInterrupted(target)) continue; + await this.emit(target, { + opcode: DiffOpcode.End, + slot: 0, + revision, + omitted, + diffTooLarge: omitted.length > 0, + error: null, + }); + target.committed = revision; + target.resolved = resolved; + target.entries = entries; + target.cache = cache; + target.omitted = omitted; + target.error = null; + } catch (error) { + const failure = diffError(error); + if (!began) + await this.emit(target, { + opcode: DiffOpcode.Begin, + slot: 0, + revision, + baseRevision: 0, + baseSha: "", + headSha: "", + mergeBaseSha: "", + entries: [], + }); + await this.emit(target, { + opcode: DiffOpcode.End, + slot: 0, + revision, + diffTooLarge: false, + omitted: [], + error: failure, + }); + target.error = failure; + target.committed = revision; + target.entries = []; + target.cache.clear(); + target.omitted = []; + } finally { + if (comparison) this.highlightCalls += comparison.metrics.highlightCalls; + await comparison?.close(); + } + } while (target.dirty && !target.closed); + } + private async *readBodies( + target: Target, + comparison: Comparison, + entries: DiffEntry[], + state: { cache: Map; omitted: string[]; total: number }, + ): AsyncGenerator { + if (entries.length > 10000) { + for (const entry of entries) if (entry.content === "text") state.omitted.push(entry.path); + return; + } + comparison.selectBodies( + new Set(entries.filter((entry) => entry.body).map((entry) => entry.path)), + ); + for (let index = 0; index < comparison.files.length; index++) { + if (isInterrupted(target)) return; + const file = comparison.files[index]; + const entry = entries[index]; + if (entry.content === "binary") continue; + let record = target.cache.get(entry.path); + if (entry.body || !record) { + const read = await comparison.read(file, { + remainingBytes: TOTAL_DIFF_MAX_BYTES - state.total, + }); + if (read.kind === "too_large") { + state.omitted.push(entry.path); + continue; + } + record = { identity: file.identity, body: read.body, bytes: read.bytes }; + } + if (exceedsBudget(state.total, record.bytes)) { + state.omitted.push(entry.path); + continue; + } + state.total += record.bytes; + state.cache.set(entry.path, record); + if (entry.body) yield record.body; + } + } + async close(): Promise { + for (const target of this.targets.values()) { + target.closed = true; + if (target.timer) clearTimeout(target.timer); + await target.opening; + await target.release?.(); + await target.refresh; + await target.interpretationCheck; + } + this.targets.clear(); + await this.dependencies.close(); + } +} + +function isInterrupted(target: Target): boolean { + return target.closed || target.dirty; +} +function exceedsBudget(total: number, bytes: number): boolean { + return total + bytes > TOTAL_DIFF_MAX_BYTES; +} +function needsBody(target: Target, entry: DiffEntry, identity: string): boolean { + return ( + entry.content === "text" && + (target.error !== null || target.cache.get(entry.path)?.identity !== identity) + ); +} + +function diffError(error: unknown): DiffError { + return { + code: error instanceof DiffReadError ? error.code : "UNKNOWN", + message: error instanceof Error ? error.message : String(error), + }; +} + +export { DiffSession } from "./session.js"; + +function endpointKey(endpoint: DiffEndpoint): string[] { + if (endpoint.kind === "rev") return [endpoint.kind, endpoint.rev]; + return [endpoint.kind]; +} diff --git a/packages/server/src/server/diff/inputs.ts b/packages/server/src/server/diff/inputs.ts new file mode 100644 index 0000000000..84a5a7f446 --- /dev/null +++ b/packages/server/src/server/diff/inputs.ts @@ -0,0 +1,110 @@ +import { createHash } from "node:crypto"; +import { runGitCommand, type GitCommandOptions } from "../../utils/run-git-command.js"; + +interface Interpretation { + config: string; + attributes: Map; + fingerprint: string; +} + +// Reuse depends on Git's interpretation, not just filesystem metadata. Ask Git +// for effective attributes/config and filtered blob identities without writing objects. +export async function readGitInputs( + paths: string[], + worktreeFiles: Set, + options: GitCommandOptions, +) { + const regular = paths.filter((path) => worktreeFiles.has(path)); + const [interpretation, blobs] = await Promise.all([ + readInterpretation(paths, options), + regular.length + ? runGitCommand(["hash-object", "--stdin-paths"], { + ...options, + input: regular.map(quotePath).join("\n") + "\n", + }) + : null, + ]); + if (blobs?.truncated) throw new Error("Git input identity exceeded the output budget"); + const hashes = blobs?.stdout.trim().split(/\r?\n/) ?? []; + if (hashes.length !== regular.length) throw new Error("Git returned incomplete blob identities"); + const blobByPath = new Map(regular.map((path, index) => [path, hashes[index]])); + const identities = new Map( + paths.map((path) => [ + path, + fingerprint([ + interpretation.config, + interpretation.attributes.get(path) ?? [], + blobByPath.get(path) ?? null, + ]), + ]), + ); + return { + identities, + hasInterpretationChanged: interpretationCheck(paths, options.cwd, interpretation.fingerprint), + }; +} + +function interpretationCheck( + paths: string[], + cwd: string, + baseline: string, +): () => Promise { + // Retain only the input description, never a comparison's patch/source pages. + return async () => { + const current = await readInterpretation(paths, { + cwd, + envOverlay: { GIT_OPTIONAL_LOCKS: "0" }, + }); + return current.fingerprint !== baseline; + }; +} + +async function readInterpretation( + paths: string[], + options: GitCommandOptions, +): Promise { + if (!paths.length) return { config: "", attributes: new Map(), fingerprint: "" }; + const [config, result] = await Promise.all([ + runGitCommand(["config", "--null", "--list"], options), + runGitCommand(["check-attr", "--all", "-z", "--stdin"], { + ...options, + input: paths.join("\0") + "\0", + }), + ]); + if (config.truncated || result.truncated) + throw new Error("Git input identity exceeded the output budget"); + const attributes = new Map(); + const fields = result.stdout.split("\0"); + if (fields.at(-1) === "") fields.pop(); + if (fields.length % 3) throw new Error("Git returned invalid attributes"); + for (let index = 0; index < fields.length; index += 3) { + const values = attributes.get(fields[index]) ?? []; + values.push(JSON.stringify([fields[index + 1], fields[index + 2]])); + attributes.set(fields[index], values); + } + for (const values of attributes.values()) values.sort(); + const configIdentity = createHash("sha256").update(config.stdout).digest("hex"); + return { + config: configIdentity, + attributes, + fingerprint: fingerprint([ + configIdentity, + paths.map((path) => [path, attributes.get(path) ?? []]), + ]), + }; +} + +function fingerprint(value: unknown): string { + return createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +function quotePath(path: string): string { + let quoted = '"'; + for (const byte of Buffer.from(path)) { + quoted += + byte >= 32 && byte <= 126 && byte !== 34 && byte !== 92 + ? String.fromCharCode(byte) + : `\\${byte.toString(8).padStart(3, "0")}`; + } + return quoted + '"'; +} diff --git a/packages/server/src/server/diff/patches.ts b/packages/server/src/server/diff/patches.ts new file mode 100644 index 0000000000..ef5dbc08c1 --- /dev/null +++ b/packages/server/src/server/diff/patches.ts @@ -0,0 +1,80 @@ +import { runGitCommand, type GitCommandOptions } from "../../utils/run-git-command.js"; +import { parseDiff } from "../utils/diff-highlighter.js"; +import type { ListedFile } from "./git.js"; + +interface Patch { + stdout: string; + truncated: boolean; +} + +// Acquire one bounded page lazily. Highlighting and delivery remain per file. +export class Patches { + private files: ListedFile[]; + private pending = new Map>>(); + constructor( + private args: string[], + private options: GitCommandOptions, + files: ListedFile[], + ) { + this.files = files.filter((file) => file.source.kind === "tracked" && file.entry.body); + } + select(paths: ReadonlySet): void { + this.files = this.files.filter((file) => paths.has(file.entry.path)); + } + async read(file: ListedFile, maxBytes: number): Promise { + const existing = this.pending.get(file.entry.path); + if (existing) return (await existing).get(file.entry.path) ?? { stdout: "", truncated: false }; + const renamed = file.entry.oldPath !== null; + const candidates = this.files.filter( + (candidate) => (candidate.entry.oldPath !== null) === renamed, + ); + const index = candidates.indexOf(file); + const page = renamed || index < 0 ? [file] : candidates.slice(index, index + 64); + this.pending.clear(); + const reading = this.load(page, maxBytes); + for (const candidate of page) this.pending.set(candidate.entry.path, reading); + return (await reading).get(file.entry.path) ?? { stdout: "", truncated: false }; + } + + private async load(files: ListedFile[], maxBytes: number): Promise> { + const paths = [ + ...new Set( + files.flatMap((file) => + file.entry.oldPath === null ? [file.entry.path] : [file.entry.oldPath, file.entry.path], + ), + ), + ]; + const result = await runGitCommand( + [ + ...this.args, + ...(files[0].entry.oldPath !== null ? ["--diff-filter=RC"] : []), + "--patch", + "--", + ...paths.map((path) => `:(literal)${path}`), + ], + { + ...this.options, + acceptExitCodes: [0, 1], + maxOutputBytes: (files.length === 1 ? maxBytes : 8 * maxBytes) + 1, + }, + ); + if (files.length === 1) return new Map([[files[0].entry.path, result]]); + if (result.truncated) { + const middle = Math.ceil(files.length / 2); + return new Map([ + ...(await this.load(files.slice(0, middle), maxBytes)), + ...(await this.load(files.slice(middle), maxBytes)), + ]); + } + const patches = new Map(); + for (const section of result.stdout.split(/(?=^diff --git )/m).filter(Boolean)) { + const hunkStart = section.indexOf("\n@@ "); + const parsed = parseDiff(hunkStart < 0 ? section : section.slice(0, hunkStart))[0]; + if (!parsed) throw new Error("Git returned an invalid patch header"); + const previous = patches.get(parsed.path)?.stdout ?? ""; + const stdout = previous + section; + patches.set(parsed.path, { stdout, truncated: Buffer.byteLength(stdout) > maxBytes }); + } + return patches; + } +} diff --git a/packages/server/src/server/diff/session.test.ts b/packages/server/src/server/diff/session.test.ts new file mode 100644 index 0000000000..3ffc0a40e5 --- /dev/null +++ b/packages/server/src/server/diff/session.test.ts @@ -0,0 +1,96 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import pino from "pino"; +import { expect, test } from "vitest"; +import { + decodeDiffFrame, + DiffOpcode, + type DiffFrame, +} from "@getpaseo/protocol/binary-frames/index"; +import { DiffSession, DiffSubscriptions } from "./index.js"; +import { createFileObserver } from "../file-observer/index.js"; +import { SessionDelivery } from "../session/owned-subscriptions/index.js"; + +test("a failed physical send releases that subscriber while its sibling completes the revision", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-send-failure-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const observer = createFileObserver(); + const subscriptions = new DiffSubscriptions({ + async requestWorkingTreeWatch(root, changed) { + const watch = await observer.subscribe(root, () => changed(), { + ignore: [join(root, ".git")], + }); + return { + repoRoot: root, + unsubscribe: async () => { + await watch.unsubscribe(); + }, + }; + }, + }); + const first = {}; + const second = {}; + let failFirst = false; + const firstFrames: DiffFrame[] = []; + const secondFrames: DiffFrame[] = []; + const delivery = new SessionDelivery( + () => {}, + async (source, bytes) => { + if (source === first && failFirst) throw new Error("Physical send failed"); + const frame = decodeDiffFrame(bytes); + const received = source === first ? firstFrames : secondFrames; + received.push(frame); + }, + ); + delivery.attach(first, true); + delivery.attach(second, true); + const session = new DiffSession(subscriptions, pino({ level: "silent" })); + try { + git("init", "-b", "main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + await writeFile(join(cwd, "README"), "base\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + await writeFile(join(cwd, "x.ts"), "export const value = 1;\n"); + for (const [source, requestId] of [ + [first, "first"], + [second, "second"], + ] as const) { + const message = { + type: "diff.subscribe.request" as const, + requestId, + cwd, + base: { kind: "rev" as const, rev: "HEAD" }, + head: { kind: "worktree" as const }, + }; + await delivery.request(source, message, () => session.subscribe(message, delivery, () => {})); + const received = source === first ? firstFrames : secondFrames; + await waitForEnd(received); + } + secondFrames.length = 0; + failFirst = true; + await writeFile(join(cwd, "x.ts"), "export const value = 2;\n"); + await waitForEnd(secondFrames); + const begins = secondFrames + .filter((frame) => frame.opcode === DiffOpcode.Begin) + .map((frame) => frame.revision); + expect(new Set(begins).size).toBe(begins.length); + expect(secondFrames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null }); + expect(delivery.subscriptionIds(first, "diffs")).toEqual([]); + expect(delivery.subscriptionIds(second, "diffs")).toHaveLength(1); + } finally { + await delivery.close(); + await subscriptions.close(); + await observer.close(); + await rm(cwd, { recursive: true, force: true }); + } +}, 30000); + +async function waitForEnd(frames: DiffFrame[]): Promise { + await expect + .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 }) + .toBe(true); +} diff --git a/packages/server/src/server/diff/session.ts b/packages/server/src/server/diff/session.ts new file mode 100644 index 0000000000..a965b2fa91 --- /dev/null +++ b/packages/server/src/server/diff/session.ts @@ -0,0 +1,86 @@ +import type { Logger } from "pino"; +import { encodeDiffFrame } from "@getpaseo/protocol/binary-frames/index"; +import type { + DiffSubscribeRequest, + SessionOutboundMessage, + DiffError, +} from "@getpaseo/protocol/messages"; +import type { SessionDelivery, OwnedSubscription } from "../session/owned-subscriptions/index.js"; +import { DiffReadError } from "./git.js"; +import type { DiffSubscriptions, DiffSubscription } from "./index.js"; + +export class DiffSession { + private slots = new Set(); + constructor( + private subscriptions: DiffSubscriptions, + private logger: Pick, + ) {} + async subscribe( + msg: DiffSubscribeRequest, + ownership: SessionDelivery, + emit: (message: SessionOutboundMessage) => void, + ): Promise { + const slot = Array.from({ length: 256 }, (_, value) => value).find( + (value) => !this.slots.has(value), + ); + if (slot === undefined) { + this.reject(msg, emit, { + code: "TOO_MANY_SUBSCRIPTIONS", + message: "Too many diff subscriptions", + }); + return; + } + this.slots.add(slot); + let subscription: DiffSubscription | null = null; + const owner = ownership.begin("diffs", undefined, async () => { + this.slots.delete(slot); + await subscription?.release(); + }); + try { + subscription = await this.subscriptions.subscribe(msg, async (frame) => { + if (owner.signal.aborted) return; + const bytes = encodeDiffFrame({ ...frame, slot }); + try { + await owner.emitBinaryAndWait(bytes); + } catch (error) { + void this.stopFailedSource(owner, error); + } + }); + if (owner.signal.aborted) { + await subscription.release(); + return; + } + emit({ + type: "diff.subscribe.response", + payload: { requestId: msg.requestId, subscriptionId: owner.responseId, slot, error: null }, + }); + // Start outside the request context: the owned subscription carries delivery authority. + void subscription.start().catch((error) => this.stopFailedSource(owner, error)); + } catch (error) { + await owner.release(); + const code = error instanceof DiffReadError ? error.code : "UNKNOWN"; + this.reject(msg, emit, { + code, + message: error instanceof Error ? error.message : String(error), + }); + } + } + private async stopFailedSource(owner: OwnedSubscription, error: unknown): Promise { + if (!owner.signal.aborted) this.logger.debug({ err: error }, "Diff frame delivery failed"); + try { + await owner.release(); + } catch (failure) { + this.logger.error({ err: failure }, "Failed to release diff subscription"); + } + } + private reject( + msg: DiffSubscribeRequest, + emit: (message: SessionOutboundMessage) => void, + error: DiffError, + ): void { + emit({ + type: "diff.subscribe.response", + payload: { requestId: msg.requestId, subscriptionId: "", slot: 0, error }, + }); + } +} diff --git a/packages/server/src/server/diff/sources.ts b/packages/server/src/server/diff/sources.ts new file mode 100644 index 0000000000..776303a9eb --- /dev/null +++ b/packages/server/src/server/diff/sources.ts @@ -0,0 +1,76 @@ +import { lstat, readlink } from "node:fs/promises"; +import { resolve } from "node:path"; +import { readGitFileContents, readGitFileContentAtRef } from "../../utils/git-file-contents.js"; +import type { ListedFile } from "./git.js"; + +interface SourceOptions { + cwd: string; + baseSha: string; + headSha: string | null; + files: ListedFile[]; +} +interface HighlightContext { + oldFileContent: string | null; + newFileContent: string | null | undefined; +} + +// Keep one bounded page of source text; bodies are still highlighted only when requested. +export class HighlightSources { + private indices: Map; + private page = -1; + private pending: Promise | null> | null = null; + constructor(private options: SourceOptions) { + this.indices = new Map(options.files.map((file, index) => [file.entry.path, index])); + } + async read(file: ListedFile): Promise { + const index = this.indices.get(file.entry.path); + if (index === undefined) throw new Error("Unknown comparison file"); + const page = Math.floor(index / 64) * 64; + if (this.pending === null || this.page !== page) { + this.page = page; + this.pending = this.load(page); + } + const contents = await this.pending; + const oldFileContent = + file.entry.change === "added" + ? null + : await this.content(contents, this.options.baseSha, file.entry.oldPath ?? file.entry.path); + let newFileContent: string | null | undefined; + if (this.options.headSha !== null) { + newFileContent = await this.content(contents, this.options.headSha, file.entry.path); + } else if (file.entry.change !== "deleted") { + const path = resolve(this.options.cwd, file.entry.path); + newFileContent = await readWorktreeLink(path); + } + return { oldFileContent, newFileContent }; + } + private async load(start: number): Promise | null> { + const specs: string[] = []; + for (const file of this.options.files.slice(start, start + 64)) { + if (file.entry.content === "binary") continue; + if (file.entry.change !== "added") + specs.push(`${this.options.baseSha}:${file.entry.oldPath ?? file.entry.path}`); + if (this.options.headSha !== null) specs.push(`${this.options.headSha}:${file.entry.path}`); + } + return readGitFileContents(this.options.cwd, specs); + } + private async content( + contents: Map | null, + ref: string, + path: string, + ): Promise { + if (contents === null) return readGitFileContentAtRef(this.options.cwd, ref, path); + const content = contents.get(`${ref}:${path}`); + if (content === undefined) throw new Error("Missing comparison source"); + return content; + } +} + +async function readWorktreeLink(path: string): Promise { + try { + if ((await lstat(path)).isSymbolicLink()) return await readlink(path); + } catch { + // Source context is optional; files can disappear after Git reads the patch. + } + return undefined; +} diff --git a/packages/server/src/server/diff/subscriptions.test.ts b/packages/server/src/server/diff/subscriptions.test.ts new file mode 100644 index 0000000000..69457d2be7 --- /dev/null +++ b/packages/server/src/server/diff/subscriptions.test.ts @@ -0,0 +1,936 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { expect, test } from "vitest"; +import { DiffOpcode, type DiffFrame } from "@getpaseo/protocol/binary-frames/index"; +import { DiffSubscriptions } from "./index.js"; +import { createFileObserver } from "../file-observer/index.js"; + +test("shares computation, highlights only the changed file, recovers refs and releases observations", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-subscriptions-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const observer = createFileObserver(); + const subscriptions = new DiffSubscriptions({ + async requestWorkingTreeWatch(root, onChange) { + const subscription = await observer.subscribe(root, () => onChange(), { + ignore: [join(root, ".git")], + }); + return { + repoRoot: root, + unsubscribe: () => { + void subscription.unsubscribe(); + }, + }; + }, + }); + const first: DiffFrame[] = []; + const second: DiffFrame[] = []; + try { + git("init", "-b", "main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + await writeFile(join(cwd, "README"), "base\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + for (let index = 0; index < 252; index++) + await writeFile(join(cwd, `${index}.ts`), `const value = ${index};\n`); + const compare = { + cwd, + base: { kind: "rev" as const, rev: "HEAD" }, + head: { kind: "worktree" as const }, + highlight: true, + }; + const coldStarted = performance.now(); + let lastEndAt = coldStarted; + const a = await subscriptions.subscribe(compare, async (frame) => { + first.push(frame); + if (frame.opcode === DiffOpcode.End) lastEndAt = performance.now(); + }); + await a.start(); + const coldMs = lastEndAt - coldStarted; + const before = subscriptions.getMetrics().computations; + const previousHighlightCalls = subscriptions.getMetrics().highlightCalls; + const b = await subscriptions.subscribe(compare, async (frame) => { + second.push(frame); + }); + await b.start(); + expect(subscriptions.getMetrics()).toEqual({ + targets: 1, + subscriptions: 2, + computations: before, + highlightCalls: previousHighlightCalls, + metadataObservers: 1, + }); + first.length = 0; + second.length = 0; + const refreshStarted = performance.now(); + await writeFile(join(cwd, "9.ts"), "const value = 999;\n"); + await expect + .poll(() => first.filter((frame) => frame.opcode === DiffOpcode.End).length, { + timeout: 10000, + }) + .toBeGreaterThan(0); + const begin = first.find((frame) => frame.opcode === DiffOpcode.Begin); + if (!begin || begin.opcode !== DiffOpcode.Begin) throw new Error("Missing begin"); + expect(begin.entries.filter((entry) => entry.body).map((entry) => entry.path)).toEqual([ + "9.ts", + ]); + const bodies = first.filter((frame) => frame.opcode === DiffOpcode.Bodies); + expect(bodies).toHaveLength(1); + expect(second).toEqual(first); + await expect + .poll(() => subscriptions.getMetrics().highlightCalls, { timeout: 10000 }) + .toBe(previousHighlightCalls + 1); + const artifactDirectory = join( + import.meta.dirname, + "../../../../../.artifacts/diff-subscription", + ); + await mkdir(artifactDirectory, { recursive: true }); + await writeFile( + join(artifactDirectory, "refresh-metrics.json"), + JSON.stringify( + { + files: 252, + coldMs, + oneFileRefreshMs: lastEndAt - refreshStarted, + changedBodies: bodies.length, + highlightCalls: subscriptions.getMetrics().highlightCalls - previousHighlightCalls, + }, + null, + 2, + ), + ); + await a.release(); + await b.release(); + expect(subscriptions.getMetrics().targets).toBe(0); + expect(subscriptions.getMetrics().metadataObservers).toBe(0); + const missing: DiffFrame[] = []; + const recovery = await subscriptions.subscribe( + { + ...compare, + base: { kind: "rev", rev: "refs/tags/later" }, + head: { kind: "rev", rev: "HEAD" }, + }, + async (frame) => { + missing.push(frame); + }, + ); + await recovery.start(); + expect(missing.at(-1)).toMatchObject({ + opcode: DiffOpcode.End, + error: { code: "REF_NOT_FOUND" }, + }); + missing.length = 0; + git("update-ref", "refs/tags/later", "HEAD"); + await expect + .poll( + () => missing.some((frame) => frame.opcode === DiffOpcode.End && frame.error === null), + { timeout: 10_000 }, + ) + .toBe(true); + await recovery.release(); + } finally { + await subscriptions.close(); + await observer.close(); + await rm(cwd, { recursive: true, force: true }); + } +}, 60_000); + +test("supersedes an in-flight revision at a batch boundary and ends the replacement", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-supersede-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const observer = createFileObserver(); + const subscriptions = new DiffSubscriptions({ + async requestWorkingTreeWatch(root, onChange) { + const watch = await observer.subscribe(root, () => onChange(), { + ignore: [join(root, ".git")], + }); + return { + repoRoot: root, + unsubscribe: async () => { + await watch.unsubscribe(); + }, + }; + }, + }); + const frames: DiffFrame[] = []; + let interrupted = false; + try { + git("init", "-b", "main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + await writeFile(join(cwd, "README"), "base\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + const content = + Array.from( + { length: 100 }, + (_, index) => `export const fn${index} = (value: string) => ({ value, count: ${index} });`, + ).join("\n") + "\n"; + for (let index = 0; index < 80; index++) await writeFile(join(cwd, `${index}.ts`), content); + const subscription = await subscriptions.subscribe( + { cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } }, + async (frame) => { + frames.push(frame); + if (frame.opcode === DiffOpcode.Bodies && !interrupted) { + interrupted = true; + await writeFile(join(cwd, "0.ts"), content + "export const added = true;\n"); + await subscriptions.invalidateForCwd(cwd); + } + }, + ); + await subscription.start(); + const begins = frames.filter((frame) => frame.opcode === DiffOpcode.Begin); + expect(interrupted).toBe(true); + expect(begins.length).toBeGreaterThan(1); + for (let index = 0; index < begins.length - 1; index++) { + const revision = begins[index].revision; + const ended = frames.some( + (frame) => frame.opcode === DiffOpcode.End && frame.revision === revision, + ); + const superseded = begins[index + 1].revision > revision; + expect(ended || superseded).toBe(true); + } + expect(frames.at(-1)).toMatchObject({ + opcode: DiffOpcode.End, + revision: begins.at(-1)?.revision, + error: null, + }); + await subscription.release(); + } finally { + await subscriptions.close(); + await observer.close(); + await rm(cwd, { recursive: true, force: true }); + } +}, 30_000); + +test("moving, deleting and recreating a branch recovers, and staging changes the resolved index SHA", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-ref-index-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const observer = createFileObserver(); + const subscriptions = new DiffSubscriptions({ + async requestWorkingTreeWatch(root, changed) { + const watch = await observer.subscribe(root, () => changed(), { + ignore: [join(root, ".git")], + }); + return { + repoRoot: root, + unsubscribe: async () => { + await watch.unsubscribe(); + }, + }; + }, + }); + const frames: DiffFrame[] = []; + try { + git("init", "-b", "main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + await writeFile(join(cwd, "README"), "base\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + git("branch", "compare"); + await writeFile(join(cwd, "x.ts"), "export const value = 1;\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "feature"); + const head = git("rev-parse", "HEAD"); + const branch = await subscriptions.subscribe( + { cwd, base: { kind: "rev", rev: "refs/heads/compare" }, head: { kind: "rev", rev: "HEAD" } }, + async (frame) => { + frames.push(frame); + }, + ); + await branch.start(); + frames.length = 0; + git("update-ref", "refs/heads/compare", head); + await expect + .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 }) + .toBe(true); + expect(frames.find((frame) => frame.opcode === DiffOpcode.Begin)).toMatchObject({ + baseSha: head, + entries: [], + }); + frames.length = 0; + git("update-ref", "-d", "refs/heads/compare"); + await expect + .poll( + () => + frames.some( + (frame) => frame.opcode === DiffOpcode.End && frame.error?.code === "REF_NOT_FOUND", + ), + { timeout: 10000 }, + ) + .toBe(true); + frames.length = 0; + git("update-ref", "refs/heads/compare", head); + await expect + .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End && frame.error === null), { + timeout: 10000, + }) + .toBe(true); + await branch.release(); + frames.length = 0; + const staged = await subscriptions.subscribe( + { cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "index" } }, + async (frame) => { + frames.push(frame); + }, + ); + await staged.start(); + const old = frames.find((frame) => frame.opcode === DiffOpcode.Begin); + if (!old || old.opcode !== DiffOpcode.Begin) throw new Error("Missing initial index revision"); + frames.length = 0; + await writeFile(join(cwd, "staged.ts"), "export const staged = true;\n"); + git("add", "staged.ts"); + await expect + .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 }) + .toBe(true); + const changed = frames.find((frame) => frame.opcode === DiffOpcode.Begin); + if (!changed || changed.opcode !== DiffOpcode.Begin) throw new Error("Missing staged revision"); + expect(changed.headSha).not.toBe(old.headSha); + expect(changed.entries.map((entry) => entry.path)).toEqual(["staged.ts"]); + await staged.release(); + } finally { + await subscriptions.close(); + await observer.close(); + await rm(cwd, { recursive: true, force: true }); + } +}, 30000); + +test("a cached replay completes before a live refresh can depend on it", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-replay-order-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }); + const subscriptions = new DiffSubscriptions({ + async requestWorkingTreeWatch() { + return { repoRoot: cwd, unsubscribe: async () => {} }; + }, + }); + let resume!: () => void; + let began!: () => void; + const paused = new Promise((resolve) => { + resume = resolve; + }); + const replayBegan = new Promise((resolve) => { + began = resolve; + }); + const frames: DiffFrame[] = []; + try { + git("init", "-b", "main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + await writeFile(join(cwd, "a.txt"), "base\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + await writeFile(join(cwd, "a.txt"), "first\n"); + const compare = { + cwd, + base: { kind: "rev", rev: "HEAD" }, + head: { kind: "worktree" }, + highlight: false, + } as const; + const first = await subscriptions.subscribe(compare, async () => {}); + await first.start(); + const second = await subscriptions.subscribe(compare, async (frame) => { + frames.push(frame); + if (frames.length === 1) { + began(); + await paused; + } + }); + const replay = second.start(); + await replayBegan; + await writeFile(join(cwd, "a.txt"), "second\n"); + await subscriptions.invalidateForCwd(cwd); + await new Promise((resolve) => setTimeout(resolve, 250)); + resume(); + await replay; + await expect + .poll(() => frames.filter((frame) => frame.opcode === DiffOpcode.End).length, { + timeout: 10000, + }) + .toBe(2); + let committed = 0; + for (const frame of frames) { + if (frame.opcode === DiffOpcode.Begin) expect([0, committed]).toContain(frame.baseRevision); + if (frame.opcode === DiffOpcode.End) committed = frame.revision; + } + await second.release(); + await first.release(); + } finally { + resume(); + await subscriptions.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("submodule commits invalidate cached worktree gitlink bodies", async () => { + const { lstat } = await import("node:fs/promises"); + const cwd = await mkdtemp(join(tmpdir(), "diff-gitlink-cache-")); + const sub = join(cwd, "sub"); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const streams = new DiffSubscriptions({ + async requestWorkingTreeWatch(root) { + return { repoRoot: root, unsubscribe: async () => {} }; + }, + }); + const frames: DiffFrame[] = []; + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + await mkdir(sub); + git("-C", "sub", "init", "-b", "main"); + git("-C", "sub", "config", "user.name", "Test"); + git("-C", "sub", "config", "user.email", "test@example.com"); + await writeFile(join(sub, "a.txt"), "A\n"); + git("-C", "sub", "add", "."); + git("-C", "sub", "-c", "commit.gpgsign=false", "commit", "-m", "A"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "record A"); + await writeFile(join(sub, "a.txt"), "B\n"); + git("-C", "sub", "-c", "commit.gpgsign=false", "commit", "-am", "B"); + const before = await lstat(sub, { bigint: true }); + const subscription = await streams.subscribe( + { cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } }, + async (frame) => { + frames.push(frame); + }, + ); + await subscription.start(); + expect(frames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null }); + frames.length = 0; + await writeFile(join(sub, "a.txt"), "C\n"); + git("-C", "sub", "-c", "commit.gpgsign=false", "commit", "-am", "C"); + const sha = git("-C", "sub", "rev-parse", "HEAD"); + expect((await lstat(sub, { bigint: true })).mtimeNs).toBe(before.mtimeNs); + await streams.invalidateForCwd(cwd); + await expect + .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 5000 }) + .toBe(true); + const bodies = frames.flatMap((frame) => + frame.opcode === DiffOpcode.Bodies ? frame.files : [], + ); + expect(bodies.flatMap((file) => file.hunks.flatMap((hunk) => hunk.lines))).toContainEqual( + expect.objectContaining({ type: "add", content: `Subproject commit ${sha}` }), + ); + await subscription.release(); + } finally { + await streams.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test.each([true, false])( + "tracked=%s timestamp-preserving replacements refresh cached bodies", + async (tracked) => { + const { lstat, utimes } = await import("node:fs/promises"); + const cwd = await mkdtemp(join(tmpdir(), "diff-preserved-time-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const streams = new DiffSubscriptions({ + async requestWorkingTreeWatch(root) { + return { repoRoot: root, unsubscribe: async () => {} }; + }, + }); + const frames: DiffFrame[] = []; + const path = join(cwd, "file.txt"); + const timestamp = new Date("2000-01-01T00:00:00Z"); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + await writeFile(join(cwd, "README"), "base\n"); + if (tracked) await writeFile(path, "base\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + await writeFile(path, "first\n"); + await utimes(path, timestamp, timestamp); + const compare = { + cwd, + base: { kind: "rev" as const, rev: "HEAD" }, + head: { kind: "worktree" as const }, + }; + const subscription = await streams.subscribe(compare, async (frame) => { + frames.push(frame); + }); + await subscription.start(); + expect(frames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null }); + const before = await lstat(path, { bigint: true }); + frames.length = 0; + await writeFile(path, "later\n"); + await utimes(path, timestamp, timestamp); + const after = await lstat(path, { bigint: true }); + expect(after.mtimeNs).toBe(before.mtimeNs); + expect(after.size).toBe(before.size); + expect(after.ctimeNs).not.toBe(before.ctimeNs); + await streams.invalidateForCwd(cwd); + await expect + .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 5000 }) + .toBe(true); + const files = frames.flatMap((frame) => + frame.opcode === DiffOpcode.Bodies ? frame.files : [], + ); + expect(files.flatMap((file) => file.hunks.flatMap((hunk) => hunk.lines))).toContainEqual( + expect.objectContaining({ type: "add", content: "later" }), + ); + const { listComparison } = await import("./git.js"); + const fresh = await listComparison(compare); + try { + const record = await fresh.read(fresh.files[0]); + if (record.kind !== "ready") throw new Error("Expected readable replacement"); + expect(files).toEqual([record.body]); + } finally { + await fresh.close(); + } + await subscription.release(); + } finally { + await streams.close(); + await rm(cwd, { recursive: true, force: true }); + } + }, +); + +test.each([true, false])( + "autocrlf=%s highlighted CRLF worktrees keep the revision readable", + async (autocrlf) => { + const cwd = await mkdtemp(join(tmpdir(), "diff-crlf-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const streams = new DiffSubscriptions({ + async requestWorkingTreeWatch(root) { + return { repoRoot: root, unsubscribe: async () => {} }; + }, + }); + const frames: DiffFrame[] = []; + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + git("config", "core.autocrlf", String(autocrlf)); + await writeFile(join(cwd, "a.ts"), "export const value = 1;\r\n"); + await writeFile(join(cwd, "b.txt"), "before\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + await writeFile(join(cwd, "a.ts"), "export const value = 2;\r\n"); + await writeFile(join(cwd, "b.txt"), "after\n"); + const subscription = await streams.subscribe( + { cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } }, + async (frame) => { + frames.push(frame); + }, + ); + await subscription.start(); + expect(frames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null }); + const files = frames.flatMap((frame) => + frame.opcode === DiffOpcode.Bodies ? frame.files : [], + ); + expect(files.map((file) => file.path)).toEqual(["a.ts", "b.txt"]); + const line = files[0].hunks + .flatMap((hunk) => hunk.lines) + .find((candidate) => candidate.type === "add"); + expect(line?.content).toBe(`export const value = 2;${autocrlf ? "" : "\r"}`); + expect(line?.runs?.reduce((length, run) => length + run.length, 0)).toBe( + line?.content.length, + ); + await subscription.release(); + } finally { + await streams.close(); + await rm(cwd, { recursive: true, force: true }); + } + }, +); + +test("explicit refresh accepts an equivalent worktree path alias", async () => { + const { symlink } = await import("node:fs/promises"); + const root = await mkdtemp(join(tmpdir(), "diff-refresh-alias-")); + const cwd = join(root, "repo"); + const alias = join(root, "alias"); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const streams = new DiffSubscriptions({ + async requestWorkingTreeWatch(directory) { + return { repoRoot: directory, unsubscribe: async () => {} }; + }, + }); + const frames: DiffFrame[] = []; + try { + await mkdir(cwd); + await symlink(cwd, alias, "junction"); + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + await writeFile(join(cwd, "a.txt"), "base\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + const base = git("rev-parse", "HEAD"); + await writeFile(join(cwd, "a.txt"), "first\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "edit"); + const head = git("rev-parse", "HEAD"); + const subscription = await streams.subscribe( + { cwd, base: { kind: "rev", rev: base }, head: { kind: "rev", rev: head } }, + async (frame) => { + frames.push(frame); + }, + ); + await subscription.start(); + frames.length = 0; + await streams.invalidateForCwd(alias); + await expect + .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 }) + .toBe(true); + expect(frames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, revision: 2, error: null }); + await subscription.release(); + } finally { + await streams.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +test("attribute changes refresh interpreted content without rewriting the tracked file", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-attribute-input-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const streams = new DiffSubscriptions({ + async requestWorkingTreeWatch(root) { + return { repoRoot: root, unsubscribe: async () => {} }; + }, + }); + const frames: DiffFrame[] = []; + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + git("config", "core.autocrlf", "false"); + await writeFile(join(cwd, "file.txt"), "base\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + await writeFile(join(cwd, "file.txt"), "first\r\n"); + const compare = { + cwd, + base: { kind: "rev" as const, rev: "HEAD" }, + head: { kind: "worktree" as const }, + }; + const subscription = await streams.subscribe(compare, async (frame) => { + frames.push(frame); + }); + await subscription.start(); + frames.length = 0; + await writeFile(join(cwd, ".git", "info", "attributes"), "file.txt text eol=lf\n"); + await streams.invalidateForCwd(cwd); + await expect + .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 }) + .toBe(true); + const files = frames.flatMap((frame) => + frame.opcode === DiffOpcode.Bodies ? frame.files : [], + ); + expect(files.flatMap((file) => file.hunks.flatMap((hunk) => hunk.lines))).toContainEqual( + expect.objectContaining({ type: "add", content: "first" }), + ); + const { listComparison } = await import("./git.js"); + const fresh = await listComparison(compare); + try { + const record = await fresh.read(fresh.files[0]); + if (record.kind !== "ready") throw new Error("Expected interpreted body"); + expect(files).toEqual([record.body]); + } finally { + await fresh.close(); + } + await subscription.release(); + } finally { + await streams.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("explicit refresh resolves subdirectories without refreshing a nested repository's parent", async () => { + const root = await mkdtemp(join(tmpdir(), "diff-refresh-directory-")); + const cwd = join(root, "repo"); + const directory = join(cwd, "workspace"); + const nested = join(directory, "nested"); + const git = (at: string, ...args: string[]) => + execFileSync("git", args, { cwd: at, encoding: "utf8" }).trim(); + const streams = new DiffSubscriptions({ + async requestWorkingTreeWatch(at) { + return { repoRoot: at, unsubscribe: async () => {} }; + }, + }); + const frames: DiffFrame[] = []; + try { + await mkdir(nested, { recursive: true }); + git(cwd, "init", "-b", "main"); + git(cwd, "config", "user.name", "Test"); + git(cwd, "config", "user.email", "test@example.com"); + await writeFile(join(cwd, "file.txt"), "before\n"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "base"); + const base = git(cwd, "rev-parse", "HEAD"); + await writeFile(join(cwd, "file.txt"), "after\n"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit"); + const head = git(cwd, "rev-parse", "HEAD"); + git(nested, "init", "-b", "main"); + git(nested, "config", "user.name", "Test"); + git(nested, "config", "user.email", "test@example.com"); + await writeFile(join(nested, "nested.txt"), "nested content\n"); + git(nested, "add", "."); + git(nested, "-c", "commit.gpgsign=false", "commit", "-m", "nested base"); + const nestedHead = git(nested, "rev-parse", "HEAD"); + const subscription = await streams.subscribe( + { cwd: directory, base: { kind: "rev", rev: base }, head: { kind: "rev", rev: head } }, + async (frame) => { + frames.push(frame); + }, + ); + await subscription.start(); + frames.length = 0; + await streams.invalidateForCwd(directory); + await expect + .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 }) + .toBe(true); + expect(frames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null, revision: 2 }); + const nestedFrames: DiffFrame[] = []; + const sibling = await streams.subscribe( + { cwd: nested, base: { kind: "empty" }, head: { kind: "rev", rev: nestedHead } }, + async (frame) => { + nestedFrames.push(frame); + }, + ); + await sibling.start(); + nestedFrames.length = 0; + const before = streams.getMetrics().computations; + await streams.invalidateForCwd(nested); + await expect + .poll(() => nestedFrames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 }) + .toBe(true); + expect(streams.getMetrics().computations).toBe(before + 1); + await sibling.release(); + await subscription.release(); + } finally { + await streams.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +test.each(["main", "linked"] as const)( + "%s checkout observes worktree configuration and shared attributes", + async (checkout) => { + const { resolve } = await import("node:path"); + const { readFile } = await import("node:fs/promises"); + const root = await mkdtemp(join(tmpdir(), "diff-interpretation-watch-")); + let cwd = join(root, "repo"); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const streams = new DiffSubscriptions({ + async requestWorkingTreeWatch(at) { + return { repoRoot: at, unsubscribe: async () => {} }; + }, + }); + const frames: DiffFrame[] = []; + try { + await mkdir(cwd); + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + const lines = Array.from( + { length: 20 }, + (_, index) => `export const value${index} = ${index};`, + ); + await writeFile(join(cwd, "a.ts"), lines.join("\n") + "\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + const base = git("rev-parse", "HEAD"); + lines[10] = "export const value10 = 999;"; + await writeFile(join(cwd, "a.ts"), lines.join("\n") + "\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "edit"); + git("config", "extensions.worktreeConfig", "true"); + if (checkout === "linked") { + const linked = join(root, "linked"); + git("worktree", "add", "-b", "side", linked); + cwd = linked; + } + git("config", "--worktree", "diff.context", "3"); + const configPath = resolve(cwd, git("rev-parse", "--git-path", "config.worktree")); + const configText = await readFile(configPath, "utf8"); + const subscription = await streams.subscribe( + { cwd, base: { kind: "rev", rev: base }, head: { kind: "rev", rev: "HEAD" } }, + async (frame) => { + frames.push(frame); + }, + ); + await subscription.start(); + const initialFiles = frames.flatMap((frame) => + frame.opcode === DiffOpcode.Bodies ? frame.files : [], + ); + expect(initialFiles[0].hunks[0].lines.filter((line) => line.type === "context")).toHaveLength( + 6, + ); + const attributesPath = resolve(cwd, git("rev-parse", "--git-path", "info/attributes")); + frames.length = 0; + await writeFile(attributesPath, "a.ts -diff\n"); + await expect + .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 }) + .toBe(true); + expect(frames.find((frame) => frame.opcode === DiffOpcode.Begin)).toMatchObject({ + entries: [{ path: "a.ts", content: "binary" }], + }); + expect(frames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null }); + frames.length = 0; + await writeFile(attributesPath, ""); + await expect + .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 }) + .toBe(true); + const restoredFiles = frames.flatMap((frame) => + frame.opcode === DiffOpcode.Bodies ? frame.files : [], + ); + expect( + restoredFiles[0].hunks[0].lines.filter((line) => line.type === "context"), + ).toHaveLength(6); + frames.length = 0; + const changedConfig = configText.replace("context = 3", "context = 0"); + expect(changedConfig).not.toBe(configText); + await writeFile(configPath, changedConfig); + await expect + .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 }) + .toBe(true); + expect(frames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null }); + const updatedFiles = frames.flatMap((frame) => + frame.opcode === DiffOpcode.Bodies ? frame.files : [], + ); + expect(updatedFiles[0].hunks[0].lines.filter((line) => line.type === "context")).toHaveLength( + 0, + ); + await subscription.release(); + } finally { + await streams.close(); + await rm(root, { recursive: true, force: true }); + } + }, + 30000, +); + +test("pinned comparisons refresh Git interpretation before replaying to a joining subscriber", async () => { + const cwd = await mkdtemp(join(tmpdir(), "diff-pinned-interpretation-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const streams = new DiffSubscriptions({ + async requestWorkingTreeWatch(root) { + return { repoRoot: root, unsubscribe: async () => {} }; + }, + }); + const frames: DiffFrame[] = []; + const joined: DiffFrame[] = []; + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + await writeFile(join(cwd, "a.ts"), "export const value = 1;\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + const base = git("rev-parse", "HEAD"); + await writeFile(join(cwd, "a.ts"), "export const value = 2;\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "edit"); + const compare = { + cwd, + base: { kind: "rev" as const, rev: base }, + head: { kind: "rev" as const, rev: git("rev-parse", "HEAD") }, + }; + const first = await streams.subscribe(compare, async (frame) => { + frames.push(frame); + }); + await first.start(); + expect(frames.find((frame) => frame.opcode === DiffOpcode.Begin)).toMatchObject({ + entries: [{ path: "a.ts", content: "text" }], + }); + frames.length = 0; + await writeFile(join(cwd, ".git", "info", "attributes"), "a.ts -diff\n"); + await expect + .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 }) + .toBe(true); + expect(frames.find((frame) => frame.opcode === DiffOpcode.Begin)).toMatchObject({ + entries: [{ path: "a.ts", content: "binary" }], + }); + expect(frames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null }); + const computations = streams.getMetrics().computations; + const second = await streams.subscribe(compare, async (frame) => { + joined.push(frame); + }); + await second.start(); + expect(joined.find((frame) => frame.opcode === DiffOpcode.Begin)).toMatchObject({ + entries: [{ path: "a.ts", content: "binary" }], + }); + expect(joined.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null }); + expect(streams.getMetrics().computations).toBe(computations); + await second.release(); + await first.release(); + } finally { + await streams.close(); + await rm(cwd, { recursive: true, force: true }); + } +}, 30000); + +test.each(["pinned", "HEAD"] as const)( + "%s revision comparisons observe worktree attributes and replay their current result", + async (endpoint) => { + const cwd = await mkdtemp(join(tmpdir(), "diff-worktree-attributes-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const observer = createFileObserver(); + const streams = new DiffSubscriptions({ + async requestWorkingTreeWatch(root, changed) { + const watch = await observer.subscribe(root, () => changed(), { + ignore: [join(root, ".git")], + }); + return { repoRoot: root, unsubscribe: () => watch.unsubscribe() }; + }, + }); + const frames: DiffFrame[] = []; + const joined: DiffFrame[] = []; + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + await writeFile(join(cwd, "a.ts"), "export const value = 1;\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + const base = git("rev-parse", "HEAD"); + await writeFile(join(cwd, "a.ts"), "export const value = 2;\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "edit"); + const compare = { + cwd, + base: { kind: "rev" as const, rev: base }, + head: { + kind: "rev" as const, + rev: endpoint === "HEAD" ? "HEAD" : git("rev-parse", "HEAD"), + }, + }; + const first = await streams.subscribe(compare, async (frame) => { + frames.push(frame); + }); + await first.start(); + frames.length = 0; + await writeFile(join(cwd, ".gitattributes"), "a.ts -diff\n"); + await expect + .poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 }) + .toBe(true); + expect(frames.find((frame) => frame.opcode === DiffOpcode.Begin)).toMatchObject({ + entries: [{ path: "a.ts", content: "binary" }], + }); + expect(frames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null }); + const computations = streams.getMetrics().computations; + const second = await streams.subscribe(compare, async (frame) => { + joined.push(frame); + }); + await second.start(); + expect(joined.find((frame) => frame.opcode === DiffOpcode.Begin)).toMatchObject({ + entries: [{ path: "a.ts", content: "binary" }], + }); + expect(joined.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null }); + expect(streams.getMetrics().computations).toBe(computations); + await second.release(); + await first.release(); + } finally { + await streams.close(); + await observer.close(); + await rm(cwd, { recursive: true, force: true }); + } + }, + 30000, +); diff --git a/packages/server/src/server/git-filename-diffs.e2e.test.ts b/packages/server/src/server/git-filename-diffs.e2e.test.ts index c32ee040b8..4b8bcb1566 100644 --- a/packages/server/src/server/git-filename-diffs.e2e.test.ts +++ b/packages/server/src/server/git-filename-diffs.e2e.test.ts @@ -29,6 +29,8 @@ test("a rename-only commit returns zero added or deleted lines through the clien expect(await client.getCommitFileDiff(cwd, sha, "new.txt")).toEqual({ file: { path: "new.txt", + oldPath: "old.txt", + status: "ok", isNew: false, isDeleted: false, additions: 0, @@ -98,3 +100,144 @@ test.skipIf(process.platform === "win32")( } }, ); + +test("streamed Changes preserves BOM-prefixed paths and highlighted content", async () => { + const cwd = mkdtempSync(join(tmpdir(), "stream-bom-diff-")); + const daemon = await createTestPaseoDaemon({ agentClients: {} }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + execFileSync("git", ["init", "-b", "main"], { cwd }); + writeFileSync(join(cwd, "\uFEFFbom.ts"), "\uFEFFexport const value = 1;\n"); + writeFileSync(join(cwd, "other.txt"), "other edit\n"); + await client.connect(); + const states: import("@getpaseo/client").DiffState[] = []; + const observation = client.observeDiff({ + cwd, + base: { kind: "empty" }, + head: { kind: "worktree" }, + }); + const unsubscribe = observation.subscribe({ + snapshot: (state) => states.push(state), + update: () => {}, + }); + try { + await expect.poll(() => states.at(-1)?.phase, { timeout: 15000 }).toBe("ready"); + const readyState = states.at(-1); + if (readyState?.phase !== "ready") throw new Error("Expected ready diff"); + expect(readyState.files.map((file) => file.path)).toContain("other.txt"); + const bomFile = readyState.files.find((file) => file.path === "\uFEFFbom.ts"); + const addedLine = bomFile?.hunks + .flatMap((hunk) => hunk.lines) + .find((line) => line.type === "add"); + expect(addedLine?.content).toBe("\uFEFFexport const value = 1;"); + expect(addedLine?.tokens?.map((token) => token.text).join("")).toBe(addedLine?.content); + expect(states.some((state) => state.phase === "error")).toBe(false); + } finally { + unsubscribe(); + await observation.release(); + } + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +}); + +test("checkout refresh from a subdirectory updates a pinned streamed comparison", async () => { + const cwd = mkdtempSync(join(tmpdir(), "stream-subdirectory-refresh-")); + const { mkdirSync } = await import("node:fs"); + const workspace = join(cwd, "workspace"); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const daemon = await createTestPaseoDaemon({ agentClients: {} }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + mkdirSync(workspace); + writeFileSync(join(cwd, "file.txt"), "committed content\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + const head = git("rev-parse", "HEAD"); + await client.connect(); + const states: import("@getpaseo/client").DiffState[] = []; + const observation = client.observeDiff({ + cwd: workspace, + base: { kind: "empty" }, + head: { kind: "rev", rev: head }, + }); + const unsubscribe = observation.subscribe({ + snapshot: (state) => states.push(state), + update: () => {}, + }); + try { + await expect.poll(() => states.at(-1)?.phase, { timeout: 15000 }).toBe("ready"); + const initial = states.at(-1); + if (initial?.phase !== "ready") throw new Error("Expected initial diff"); + expect(initial.files[0]).toMatchObject({ path: "file.txt", status: "ok" }); + expect(await client.checkoutRefresh(workspace)).toMatchObject({ success: true, error: null }); + await expect + .poll(() => states.at(-1)?.revision, { timeout: 15000 }) + .toBeGreaterThan(initial.revision); + expect(states.at(-1)).toMatchObject({ + phase: "ready", + files: [{ path: "file.txt", status: "ok" }], + }); + writeFileSync(join(cwd, ".git", "info", "attributes"), "file.txt -diff\n"); + await expect + .poll(() => states.at(-1), { timeout: 15000 }) + .toMatchObject({ + phase: "ready", + files: [{ path: "file.txt", status: "binary", hunks: [] }], + }); + } finally { + unsubscribe(); + await observation.release(); + } + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +}); + +test("refreshing a non-Git workspace succeeds while a Git comparison is open", async () => { + const cwd = mkdtempSync(join(tmpdir(), "stream-refresh-git-")); + const plain = mkdtempSync(join(tmpdir(), "stream-refresh-plain-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const daemon = await createTestPaseoDaemon({ agentClients: {} }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + writeFileSync(join(cwd, "file.txt"), "committed content\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "base"); + await client.connect(); + const states: import("@getpaseo/client").DiffState[] = []; + const observation = client.observeDiff({ + cwd, + base: { kind: "empty" }, + head: { kind: "rev", rev: git("rev-parse", "HEAD") }, + }); + const unsubscribe = observation.subscribe({ + snapshot: (state) => states.push(state), + update: () => {}, + }); + try { + await expect.poll(() => states.at(-1)?.phase, { timeout: 15000 }).toBe("ready"); + const revision = states.at(-1)?.revision; + expect(await client.checkoutRefresh(plain)).toMatchObject({ success: true, error: null }); + expect(states.at(-1)).toMatchObject({ phase: "ready", revision }); + } finally { + unsubscribe(); + await observation.release(); + } + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + rmSync(plain, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +}); diff --git a/packages/server/src/server/git-metadata-event-rules.test.ts b/packages/server/src/server/git-metadata-event-rules.test.ts index 4456573624..43edc861d5 100644 --- a/packages/server/src/server/git-metadata-event-rules.test.ts +++ b/packages/server/src/server/git-metadata-event-rules.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import { classifyGitMetadataPath, + affectsGitDiffInterpretation, GIT_METADATA_EVENT_RULES, getPrunedGitMetadataPaths, } from "./git-metadata-event-rules.js"; @@ -35,14 +36,28 @@ describe("Git metadata event rules", () => { ["common", "packed-refs", { kind: "all", refreshBase: true }], ["common", "reftable/tables.list", { kind: "all", refreshBase: true }], ["common", "config", { kind: "all", refreshBase: true }], + ["common", "info/attributes", { kind: "all", refreshBase: false }], ["common", "worktrees/new/index", { kind: "ignore" }], ["common", "hooks/pre-commit", { kind: "ignore" }], ["common", "logs/refs/heads/main", { kind: "ignore" }], ["common", "objects/ab/cdef", { kind: "ignore" }], - ["common", "refs/tags/v1", { kind: "ignore" }], + ["common", "refs/tags/v1", { kind: "all", refreshBase: true }], ["common", "FETCH_HEAD", { kind: "ignore" }], ["common", "packed-refs.lock", { kind: "ignore" }], ] as const)("classifies %s metadata path %s", (scope, path, expected) => { expect(classifyGitMetadataPath(scope, path)).toEqual(expected); }); }); + +test.each([ + ["worktree", "config.worktree", true], + ["common", "config.worktree", false], + ["common", "config", true], + ["common", "info/attributes", true], + ["worktree", "info/attributes", false], + ["common", "refs/heads/main", false], + ["worktree", "index", false], + ["common", "config.lock", false], +] as const)("diff interpretation dependency %s/%s is %s", (scope, path, expected) => { + expect(affectsGitDiffInterpretation(scope, path)).toBe(expected); +}); diff --git a/packages/server/src/server/git-metadata-event-rules.ts b/packages/server/src/server/git-metadata-event-rules.ts index ff82747ba0..2557639f16 100644 --- a/packages/server/src/server/git-metadata-event-rules.ts +++ b/packages/server/src/server/git-metadata-event-rules.ts @@ -5,6 +5,7 @@ type GitMetadataEventRule = { scope: GitMetadataScope | "both"; path: string; prune?: true; + diffInput?: true; } & ( | { match: "exact" | "prefix" | "suffix"; route: "ignore" } | { match: "exact" | "prefix" | "suffix"; route: "owner" | "all"; refreshBase: boolean } @@ -36,6 +37,7 @@ export const GIT_METADATA_EVENT_RULES = [ }, { id: "worktree-config", + diffInput: true, scope: "worktree", match: "exact", path: "config.worktree", @@ -73,6 +75,14 @@ export const GIT_METADATA_EVENT_RULES = [ path: "refs/heads/", route: "local-ref", }, + { + id: "tag", + scope: "common", + match: "prefix", + path: "refs/tags/", + route: "all", + refreshBase: true, + }, { id: "remote-branch", scope: "common", @@ -98,12 +108,22 @@ export const GIT_METADATA_EVENT_RULES = [ }, { id: "shared-config", + diffInput: true, scope: "common", match: "exact", path: "config", route: "all", refreshBase: true, }, + { + id: "shared-attributes", + diffInput: true, + scope: "common", + match: "exact", + path: "info/attributes", + route: "all", + refreshBase: false, + }, { id: "worktree-administration", scope: "common", @@ -150,11 +170,7 @@ export function classifyGitMetadataPath( inputPath: string, ): GitMetadataEffect { const path = inputPath.replaceAll("\\", "/"); - const rule = GIT_METADATA_EVENT_RULES.find( - (candidate) => - (candidate.scope === "both" || candidate.scope === scope) && - matchesGitMetadataPath(candidate.match, candidate.path, path), - ); + const rule = findGitMetadataRule(scope, path); if (!rule || rule.route === "ignore") return { kind: "ignore" }; if (rule.route === "owner" || rule.route === "all") { return { kind: rule.route, refreshBase: rule.refreshBase }; @@ -166,6 +182,19 @@ export function classifyGitMetadataPath( }; } +export function affectsGitDiffInterpretation(scope: GitMetadataScope, inputPath: string): boolean { + const rule = findGitMetadataRule(scope, inputPath.replaceAll("\\", "/")); + return rule !== undefined && "diffInput" in rule && rule.diffInput; +} + +function findGitMetadataRule(scope: GitMetadataScope, path: string) { + return GIT_METADATA_EVENT_RULES.find( + (candidate) => + (candidate.scope === "both" || candidate.scope === scope) && + matchesGitMetadataPath(candidate.match, candidate.path, path), + ); +} + function matchesGitMetadataPath( match: GitMetadataEventRule["match"], pattern: string, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 43710fa35d..0de1d64687 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,3 +1,4 @@ +import { DiffSession } from "./diff/index.js"; import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import type { AgentMessageDelivery } from "./agent/agent-messages/index.js"; import { AgentDirectory, type BackgroundVisibility } from "./agent/directory.js"; @@ -855,6 +856,7 @@ export class Session { private readonly workspaceDirectory: WorkspaceDirectory; private readonly voiceSessions: VoiceSessions; private readonly checkoutSession: CheckoutSession; + private readonly diffSession: DiffSession; private readonly scheduleSession: ScheduleSession; private readonly providerCatalogSession: ProviderCatalogSession; private readonly usageSession: UsageSession; @@ -1019,6 +1021,7 @@ export class Session { await this.workspaceProvisioning.ensureWorkspaceRecordUnarchived(workspace); }, }); + this.diffSession = new DiffSession(checkoutDiffManager.streams, this.sessionLogger); this.checkoutSession = new CheckoutSession({ host: { emit: (msg) => this.emit(msg), @@ -3174,6 +3177,10 @@ export class Session { // eslint-disable-next-line complexity private dispatchCheckoutMessage(msg: SessionInboundMessage): Promise | undefined { switch (msg.type) { + case "diff.subscribe.request": + return this.diffSession.subscribe(msg, this.delivery, (reply) => this.emit(reply)); + case "diff.unsubscribe.request": + return this.delivery.release(msg.subscriptionId); case "checkout_status_request": return this.checkoutSession.handleStatusRequest(msg); case "checkout.commits.list.request": @@ -3485,7 +3492,9 @@ export class Session { await this.workspaceFilesSession.handleFileTransferFrame(binaryFrame.frame, source); return; } - this.terminalController.handleBinaryFrame(binaryFrame.frame, source); + if (binaryFrame.kind === "terminal") { + this.terminalController.handleBinaryFrame(binaryFrame.frame, source); + } } private async handleRestartServerRequest( diff --git a/packages/server/src/server/session/checkout/checkout-session.test.ts b/packages/server/src/server/session/checkout/checkout-session.test.ts index aaa79e3da1..61c9328e48 100644 --- a/packages/server/src/server/session/checkout/checkout-session.test.ts +++ b/packages/server/src/server/session/checkout/checkout-session.test.ts @@ -50,6 +50,7 @@ function createFakeDiffSubscriber(initial: CheckoutDiffSnapshotPayload) { const subscriptions: FakeDiffSubscription[] = []; const refreshedCwds: string[] = []; const subscriber: CheckoutDiffSubscriber = { + readCommitFile: async () => null, read: async (params) => ({ ...initial, cwd: params.cwd }), subscribe: async (params, listener) => { let isSubscribed = true; @@ -77,7 +78,7 @@ function createFakeDiffSubscriber(initial: CheckoutDiffSnapshotPayload) { unsubscribe, }; }, - scheduleRefreshForCwd: (cwd) => { + scheduleRefreshForCwd: async (cwd) => { refreshedCwds.push(cwd); }, }; diff --git a/packages/server/src/server/session/checkout/checkout-session.ts b/packages/server/src/server/session/checkout/checkout-session.ts index 4deb28a56b..1611356bc8 100644 --- a/packages/server/src/server/session/checkout/checkout-session.ts +++ b/packages/server/src/server/session/checkout/checkout-session.ts @@ -51,7 +51,6 @@ import { pullCurrentBranch, pushCurrentBranch, listCheckoutCommits, - getCommitFileDiff, } from "../../../utils/checkout-git.js"; import { runGitCommand } from "../../../utils/run-git-command.js"; import { expandTilde } from "../../../utils/path.js"; @@ -109,6 +108,7 @@ function toLegacyGithubSearchItems(items: ForgeSearchResultItem[]): LegacyGithub * real CheckoutDiffManager satisfies this structurally; tests supply a fake. */ export interface CheckoutDiffSubscriber { + readCommitFile: import("../../diff/index.js").DiffSubscriptions["readCommitFile"]; read( params: Omit, ): Promise; @@ -116,7 +116,7 @@ export interface CheckoutDiffSubscriber { params: CheckoutDiffSubscriptionRequest, listener: (snapshot: CheckoutDiffSnapshotPayload) => void, ): Promise; - scheduleRefreshForCwd(cwd: string): void; + scheduleRefreshForCwd(cwd: string): Promise; } export interface CheckoutSessionOptions { @@ -301,7 +301,12 @@ export class CheckoutSession { if (path.length === 0 || isAbsolute(path) || path.split(/[\\/]/).includes("..")) { throw new Error(`Invalid path: ${path}`); } - const file = await getCommitFileDiff({ cwd: expandTilde(cwd), sha, path, signal }); + const file = await this.checkoutDiffManager.readCommitFile({ + cwd: expandTilde(cwd), + sha, + path, + signal, + }); this.host.emit({ type: "checkout.commits.file_diff.response", payload: { cwd, sha, path, file, error: null, requestId }, @@ -494,7 +499,7 @@ export class CheckoutSession { includeForge: true, reason: "manual-refresh", }); - this.checkoutDiffManager.scheduleRefreshForCwd(resolvedCwd); + await this.checkoutDiffManager.scheduleRefreshForCwd(resolvedCwd); this.host.emit({ type: "checkout.refresh.response", payload: { @@ -548,8 +553,8 @@ export class CheckoutSession { * Notify the live diff subscriptions that the working tree at `cwd` changed. * Called by the command handlers below after they mutate the repository. */ - private scheduleDiffRefresh(cwd: string): void { - this.checkoutDiffManager.scheduleRefreshForCwd(cwd); + private scheduleDiffRefresh(cwd: string): Promise { + return this.checkoutDiffManager.scheduleRefreshForCwd(cwd); } // --------------------------------------------------------------------------- @@ -563,7 +568,7 @@ export class CheckoutSession { try { const checkoutResult = await this.gitMutation.checkoutExistingBranch(cwd, branch); - this.scheduleDiffRefresh(cwd); + await this.scheduleDiffRefresh(cwd); // Push a workspace_update immediately so the sidebar/header reflect // the new branch name without waiting for the background git watcher. @@ -615,7 +620,7 @@ export class CheckoutSession { try { const result = await this.host.renameCurrentBranch(cwd, branch); await this.gitMutation.notifyGitMutation(cwd, "rename-branch", { invalidateForge: true }); - this.scheduleDiffRefresh(cwd); + await this.scheduleDiffRefresh(cwd); this.host.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch); // Branch is a git fact derived per-descriptor from each workspace's own @@ -657,7 +662,7 @@ export class CheckoutSession { try { await discardChanges(cwd, paths); await this.gitMutation.notifyGitMutation(cwd, "discard-changes"); - this.scheduleDiffRefresh(cwd); + await this.scheduleDiffRefresh(cwd); this.host.emit({ type: "checkout.discard_changes.response", payload: { cwd, success: true, error: null, requestId }, @@ -684,7 +689,7 @@ export class CheckoutSession { timeout: 120_000, }); await this.gitMutation.notifyGitMutation(cwd, "stash-push"); - this.scheduleDiffRefresh(cwd); + await this.scheduleDiffRefresh(cwd); this.host.emit({ type: "stash_save_response", payload: { cwd, success: true, error: null, requestId }, @@ -707,7 +712,7 @@ export class CheckoutSession { timeout: 120_000, }); await this.gitMutation.notifyGitMutation(cwd, "stash-pop"); - this.scheduleDiffRefresh(cwd); + await this.scheduleDiffRefresh(cwd); this.host.emit({ type: "stash_pop_response", payload: { cwd, success: true, error: null, requestId }, @@ -759,7 +764,7 @@ export class CheckoutSession { addAll: msg.addAll ?? true, }); await this.gitMutation.notifyGitMutation(cwd, "commit-changes"); - this.scheduleDiffRefresh(cwd); + await this.scheduleDiffRefresh(cwd); this.host.emit({ type: "checkout_commit_response", @@ -820,7 +825,7 @@ export class CheckoutSession { this.gitMutation.notifyGitMutation(mutatedCwd, "merge-to-base", { invalidateForge: true }), ...(mutatedCwd !== cwd ? [this.gitMutation.notifyGitMutation(cwd, "merge-to-base")] : []), ]); - this.scheduleDiffRefresh(cwd); + await this.scheduleDiffRefresh(cwd); this.host.emit({ type: "checkout_merge_response", @@ -866,7 +871,7 @@ export class CheckoutSession { { paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot }, ); await this.gitMutation.notifyGitMutation(cwd, "merge-from-base", { invalidateForge: true }); - this.scheduleDiffRefresh(cwd); + await this.scheduleDiffRefresh(cwd); this.host.emit({ type: "checkout_merge_from_base_response", @@ -898,7 +903,7 @@ export class CheckoutSession { try { await pullCurrentBranch(cwd); await this.gitMutation.notifyGitMutation(cwd, "pull", { invalidateForge: true }); - this.scheduleDiffRefresh(cwd); + await this.scheduleDiffRefresh(cwd); this.host.emit({ type: "checkout_pull_response", diff --git a/packages/server/src/server/snapshot-mutation-ownership.test.ts b/packages/server/src/server/snapshot-mutation-ownership.test.ts index 2917d0fec9..2c44347bef 100644 --- a/packages/server/src/server/snapshot-mutation-ownership.test.ts +++ b/packages/server/src/server/snapshot-mutation-ownership.test.ts @@ -1,3 +1,4 @@ +import { DiffSubscriptions } from "./diff/index.js"; import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js"; import { @@ -105,6 +106,13 @@ describe("snapshot mutation ownership boundary", () => { const session = asInternals( new Session({ + checkoutDiffManager: createStub({ + streams: new DiffSubscriptions({ + async requestWorkingTreeWatch() { + throw new Error("Unexpected diff observation"); + }, + }), + }), workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), diff --git a/packages/server/src/server/utils/diff-highlighter.ts b/packages/server/src/server/utils/diff-highlighter.ts index e3a0460322..6404ce2381 100644 --- a/packages/server/src/server/utils/diff-highlighter.ts +++ b/packages/server/src/server/utils/diff-highlighter.ts @@ -70,11 +70,6 @@ function extractPathFromMetadata(lines: string[], prefix: "--- " | "+++ "): stri function extractPathFromDiffHeader(lines: string[]): string { const firstLine = lines[0] ?? ""; - const prefixedPathMatch = firstLine.match(/^a\/(.+) b\/(.+)$/); - if (prefixedPathMatch) { - return prefixedPathMatch[2]; - } - const quotedPathMatch = firstLine.match(/^("(?:\\.|[^"\\])*") (.+)$/) ?? firstLine.match(/^(.+) ("(?:\\.|[^"\\])*")$/); if (quotedPathMatch) { @@ -86,10 +81,21 @@ function extractPathFromDiffHeader(lines: string[]): string { return newPath; } - const metadataPath = - extractPathFromMetadata(lines, "+++ ") ?? extractPathFromMetadata(lines, "--- "); - if (metadataPath) { - return metadataPath; + // The ---/+++ records are unambiguous even when a path contains " b/". + const oldMetadata = extractPathFromMetadata(lines, "--- "); + const newMetadata = extractPathFromMetadata(lines, "+++ "); + const metadata = newMetadata ?? oldMetadata; + if (metadata) { + const prefixed = + (oldMetadata !== null && + newMetadata !== null && + usesDiffPathPrefixes(oldMetadata, newMetadata)) || + firstLine === `a/${metadata.slice(2)} b/${metadata.slice(2)}`; + return prefixed ? metadata.slice(2) : metadata; + } + const prefixedPathMatch = firstLine.match(/^a\/(.+) b\/(.+)$/); + if (prefixedPathMatch) { + return prefixedPathMatch[2]; } const pathMatch = firstLine.match(/^(\S+)\s+(\S+)$/); @@ -462,7 +468,8 @@ function applyTokensToHunks( newLineNum++; } - return tokens ? { ...line, tokens } : line; + const matchingTokens = tokensForPatchLine(tokens, line.content); + return matchingTokens ? { ...line, tokens: matchingTokens } : line; }); return { ...hunk, lines: highlightedLines }; @@ -471,6 +478,25 @@ function applyTokensToHunks( return { ...file, hunks: highlightedHunks }; } +function tokensForPatchLine( + tokens: HighlightToken[] | undefined, + content: string, +): HighlightToken[] | undefined { + if (!tokens) return undefined; + const source = tokens.map((token) => token.text).join(""); + if (source === content) return tokens; + // Git can normalize a CRLF worktree while source highlighting retains its CR. + if (source !== `${content}\r`) return undefined; + const aligned: HighlightToken[] = []; + let remaining = content.length; + for (const token of tokens) { + const text = token.text.slice(0, remaining); + if (text) aligned.push({ text, style: token.style }); + remaining -= text.length; + } + return aligned; +} + /** * Parse and highlight a complete diff, using actual file content when available. */ diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index c5f1303f77..17d0b6d109 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1227,7 +1227,7 @@ export class VoiceAssistantWebSocketServer { await Promise.all(cleanupPromises); this.providerSnapshotManager.destroy(); - this.checkoutDiffManager.dispose(); + await this.checkoutDiffManager.dispose(); await this.workspaceGitService.dispose(); this.pendingConnections.clear(); this.sessions.clear(); @@ -2025,6 +2025,7 @@ export class VoiceAssistantWebSocketServer { usageSetting: true, usageSources: true, ownedSubscriptions: true, + diffSubscription: true, binaryChunkingV1: true, agentRequestReceipts: true, workspaceRequestReceipts: true, diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index a0de895810..a4f6fbe9dd 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -258,7 +258,7 @@ function createSessionForWireCompatTest(options?: { directorySync: options?.directorySync, scheduleService: {} as SessionOptions["scheduleService"], checkoutDiffManager: { - scheduleRefreshForCwd() {}, + async scheduleRefreshForCwd() {}, onWorkspaceStateMayHaveChanged() {}, } as unknown as SessionOptions["checkoutDiffManager"], github: { diff --git a/packages/server/src/server/workspace-git-service.observation.integration.test.ts b/packages/server/src/server/workspace-git-service.observation.integration.test.ts index 17e3a4a2af..0a5152e5d4 100644 --- a/packages/server/src/server/workspace-git-service.observation.integration.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.integration.test.ts @@ -11,7 +11,6 @@ import { type CheckoutSnapshotFacts, type CheckoutStatusGit, } from "../utils/checkout-git.js"; -import { CheckoutDiffManager } from "./checkout-diff-manager.js"; import { createFileObserver } from "./file-observer/index.js"; import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; import type { FileChange, FileObserver, SubscribeToFileChanges } from "./file-observer/index.js"; @@ -250,24 +249,15 @@ test("recursive observation updates tracked state and prunes ignored storms", as }); const workspaceRuntimeStore = createWorkspaceRuntimeStore(); service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); - const diffManager = new CheckoutDiffManager({ - workspaceRuntimeStore: workspaceRuntimeStore, - logger: createLogger(), - paseoHome: path.join(tempDir, "paseo-home"), - workspaceGitService: service, - }); + const summaryListener = vi.fn(); const diffListener = vi.fn(); const summarySubscription = service.registerWorkspace({ cwd: repoDir }, summaryListener); - const diffSubscription = await diffManager.subscribe( - { cwd: repoDir, compare: { mode: "uncommitted" } }, - diffListener, - ); + const diffSubscription = await service.requestWorkingTreeWatch(repoDir, diffListener); cleanup.push(async () => { diffSubscription.unsubscribe(); summarySubscription.unsubscribe(); - diffManager.dispose(); await service.dispose(); expect(activeWatcherCount).toBe(0); rmSync(tempDir, { recursive: true, force: true }); @@ -312,6 +302,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as getCheckoutShortstat.mockClear(); getCheckoutWorktreeState.mockClear(); getCheckoutDiff.mockClear(); + diffListener.mockClear(); runGitCommand.mockClear(); deliveredEvents.length = 0; @@ -344,18 +335,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as }), }), ); - expect(diffListener).toHaveBeenLastCalledWith({ - cwd: repoDir, - files: [ - { - path: "src/tracked.txt", - additions: 2, - deletions: 0, - status: "modified", - }, - ], - error: null, - }); + expect(diffListener).toHaveBeenCalled(); expect(service.getMetrics().workspaceRefreshQueuedCount).toBe(0); }, { timeout: 5_000 }, @@ -371,11 +351,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as }), }), ); - expect(diffListener).toHaveBeenLastCalledWith( - expect.objectContaining({ - files: [expect.objectContaining({ additions: 3 })], - }), - ); + expect(diffListener).toHaveBeenCalled(); expect(service.getMetrics()).toMatchObject({ workspaceRefreshInFlightCount: 0, workspaceRefreshQueuedCount: 0, @@ -409,18 +385,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as }), }), ); - expect(diffListener).toHaveBeenLastCalledWith({ - cwd: repoDir, - files: [ - { - path: "build/tracked.txt", - additions: 2, - deletions: 0, - status: "modified", - }, - ], - error: null, - }); + expect(diffListener).toHaveBeenCalled(); expect(service.getMetrics()).toMatchObject({ workspaceRefreshInFlightCount: 0, workspaceRefreshQueuedCount: 0, @@ -434,6 +399,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as getCheckoutShortstat.mockClear(); getCheckoutWorktreeState.mockClear(); getCheckoutDiff.mockClear(); + diffListener.mockClear(); runGitCommand.mockClear(); summaryListener.mockClear(); diffListener.mockClear(); @@ -449,18 +415,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as }), }), ); - expect(diffListener).toHaveBeenLastCalledWith({ - cwd: repoDir, - files: [ - { - path: "build/tracked.txt", - additions: 4, - deletions: 0, - status: "modified", - }, - ], - error: null, - }); + expect(diffListener).toHaveBeenCalled(); expect(service.getMetrics()).toMatchObject({ workspaceRefreshInFlightCount: 0, workspaceRefreshQueuedCount: 0, @@ -474,6 +429,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as getCheckoutShortstat.mockClear(); getCheckoutWorktreeState.mockClear(); getCheckoutDiff.mockClear(); + diffListener.mockClear(); runGitCommand.mockClear(); for (let index = 0; index < 100; index += 1) { diff --git a/packages/server/src/server/workspace-git-service.observation.test.ts b/packages/server/src/server/workspace-git-service.observation.test.ts index 86a49b55ac..f866aa8dc6 100644 --- a/packages/server/src/server/workspace-git-service.observation.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.test.ts @@ -4,7 +4,6 @@ import { pathToFileURL } from "node:url"; import type pino from "pino"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { CheckoutSnapshotFacts, CheckoutStatusGit } from "../utils/checkout-git.js"; -import { CheckoutDiffManager } from "./checkout-diff-manager.js"; import type { FileObserver } from "./file-observer/index.js"; import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; @@ -428,41 +427,30 @@ describe("WorkspaceGitService checkout observation", () => { service.dispose(); }); - test("a tracked edit refreshes summary and active uncommitted diff without structural or forge work", async () => { + test("a tracked edit refreshes summary and notifies worktree consumers without structural or forge work", async () => { const watcher = createWatcherHarness(); const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => createCheckoutFacts(cwd)); let diffStat = { additions: 1, deletions: 0 }; - let diffFile = { path: "tracked.txt", additions: 1, deletions: 0, status: "modified" }; const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd, { isDirty: true }), ); const getCheckoutShortstat = vi.fn(async () => diffStat); const getPullRequestStatus = vi.fn(); - const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: [diffFile] })); const service = createService(watcher, { getCheckoutSnapshotFacts, getCheckoutStatus, getCheckoutShortstat, getPullRequestStatus, - getCheckoutDiff, }); const workspaceRuntimeStore = createWorkspaceRuntimeStore(); service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); - const diffManager = new CheckoutDiffManager({ - workspaceRuntimeStore: workspaceRuntimeStore, - logger: createLogger(), - paseoHome: "/tmp/paseo-home", - workspaceGitService: service, - }); + const summaryListener = vi.fn(); const diffListener = vi.fn(); await service.getSnapshot(REPO_CWD); const summarySubscription = service.registerWorkspace({ cwd: REPO_CWD }, summaryListener); - const diffSubscription = await diffManager.subscribe( - { cwd: REPO_CWD, compare: { mode: "uncommitted" } }, - diffListener, - ); + const diffSubscription = await service.requestWorkingTreeWatch(REPO_CWD, diffListener); await vi.waitFor(() => { expect(watcher.subscribe).toHaveBeenCalledWith( REPO_CWD, @@ -475,7 +463,6 @@ describe("WorkspaceGitService checkout observation", () => { }); diffStat = { additions: 4, deletions: 2 }; - diffFile = { path: "tracked.txt", additions: 4, deletions: 2, status: "modified" }; watcher.records[0]?.callback(null, [ { path: path.join(REPO_CWD, "tracked.txt"), type: "update" }, ]); @@ -489,11 +476,7 @@ describe("WorkspaceGitService checkout observation", () => { }), }), ); - expect(diffListener).toHaveBeenLastCalledWith({ - cwd: REPO_CWD, - files: [{ path: "tracked.txt", additions: 4, deletions: 2, status: "modified" }], - error: null, - }); + expect(diffListener).toHaveBeenCalledTimes(1); }); await flushPromises(); @@ -502,7 +485,6 @@ describe("WorkspaceGitService checkout observation", () => { diffSubscription.unsubscribe(); summarySubscription.unsubscribe(); - diffManager.dispose(); service.dispose(); }); @@ -1245,7 +1227,7 @@ describe("WorkspaceGitService checkout observation", () => { service.dispose(); }); - test("metadata-only changes refresh worktree summary and active uncommitted diff", async () => { + test("metadata-only changes refresh worktree summary and invalidate the uncommitted projection", async () => { const watcher = createWatcherHarness(); let isDirty = true; let diffStat = { additions: 3, deletions: 1 }; @@ -1260,19 +1242,10 @@ describe("WorkspaceGitService checkout observation", () => { }); const workspaceRuntimeStore = createWorkspaceRuntimeStore(); service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); - const diffManager = new CheckoutDiffManager({ - workspaceRuntimeStore: workspaceRuntimeStore, - logger: createLogger(), - paseoHome: "/tmp/paseo-home", - workspaceGitService: service, - }); + const summaryListener = vi.fn(); - const diffListener = vi.fn(); const summarySubscription = service.registerWorkspace({ cwd: REPO_CWD }, summaryListener); - const diffSubscription = await diffManager.subscribe( - { cwd: REPO_CWD, compare: { mode: "uncommitted" } }, - diffListener, - ); + await service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" }); await vi.waitFor(() => { expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); @@ -1295,16 +1268,13 @@ describe("WorkspaceGitService checkout observation", () => { }), }), ); - expect(diffListener).toHaveBeenLastCalledWith({ - cwd: REPO_CWD, - files: [], - error: null, - }); }); - diffSubscription.unsubscribe(); + expect(await service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" })).toMatchObject({ + structured: [], + }); + expect(getCheckoutDiff).toHaveBeenCalledTimes(2); summarySubscription.unsubscribe(); - diffManager.dispose(); service.dispose(); }); @@ -1318,20 +1288,14 @@ describe("WorkspaceGitService checkout observation", () => { const service = createService(watcher, { getCheckoutDiff, getCheckoutWorktreeState }); const workspaceRuntimeStore = createWorkspaceRuntimeStore(); service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); - const diffManager = new CheckoutDiffManager({ - workspaceRuntimeStore: workspaceRuntimeStore, - logger: createLogger(), - paseoHome: "/tmp/paseo-home", - workspaceGitService: service, - }); - const diffSubscription = await diffManager.subscribe( - { cwd: REPO_CWD, compare: { mode: "base", baseRef: "main" } }, - vi.fn(), - ); + + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, () => {}); + await service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" }); await vi.waitFor(() => { expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); }); + await service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" }); expect(getCheckoutDiff).toHaveBeenCalledTimes(1); watcher.records @@ -1339,6 +1303,7 @@ describe("WorkspaceGitService checkout observation", () => { ?.callback(null, [{ path: path.join(REPO_CWD, "tracked.txt"), type: "update" }]); await vi.advanceTimersByTimeAsync(1_000); await flushPromises(); + await service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" }); expect(getCheckoutDiff).toHaveBeenCalledTimes(1); watcher.records @@ -1346,12 +1311,10 @@ describe("WorkspaceGitService checkout observation", () => { ?.callback(null, [{ path: path.join(GIT_DIR, "packed-refs"), type: "update" }]); await vi.advanceTimersByTimeAsync(1_000); await vi.advanceTimersByTimeAsync(150); - await vi.waitFor(() => { - expect(getCheckoutDiff).toHaveBeenCalledTimes(2); - }); + await service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" }); + expect(getCheckoutDiff).toHaveBeenCalledTimes(2); - diffSubscription.unsubscribe(); - diffManager.dispose(); + subscription.unsubscribe(); service.dispose(); }); @@ -2773,39 +2736,23 @@ describe("WorkspaceGitService checkout observation", () => { service.dispose(); }); - test("a working-tree watcher event refreshes summary and diff subscribers", async () => { + test("a working-tree watcher event refreshes summary and notifies worktree consumers", async () => { const watcher = createWatcherHarness(); let isDirty = false; let diffStat = { additions: 0, deletions: 0 }; - let diffFiles: Array<{ - path: string; - additions: number; - deletions: number; - status: string; - }> = []; const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd, { isDirty })); const getCheckoutShortstat = vi.fn(async () => diffStat); - const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: diffFiles })); const service = createService(watcher, { getCheckoutStatus, getCheckoutShortstat, - getCheckoutDiff, }); const workspaceRuntimeStore = createWorkspaceRuntimeStore(); service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); - const diffManager = new CheckoutDiffManager({ - workspaceRuntimeStore: workspaceRuntimeStore, - logger: createLogger(), - paseoHome: "/tmp/paseo-home", - workspaceGitService: service, - }); + const listener = vi.fn(); const diffListener = vi.fn(); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener); - const diffSubscription = await diffManager.subscribe( - { cwd: REPO_CWD, compare: { mode: "uncommitted" } }, - diffListener, - ); + const diffSubscription = await service.requestWorkingTreeWatch(REPO_CWD, diffListener); await vi.waitFor(() => { expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull(); @@ -2813,7 +2760,6 @@ describe("WorkspaceGitService checkout observation", () => { }); isDirty = true; diffStat = { additions: 7, deletions: 3 }; - diffFiles = [{ path: "tracked.txt", additions: 7, deletions: 3, status: "modified" }]; getWatcherRecordsForDirectory(watcher, REPO_CWD)[0]?.callback(null, [ { path: path.join(REPO_CWD, "tracked.txt"), type: "update" }, @@ -2828,17 +2774,12 @@ describe("WorkspaceGitService checkout observation", () => { }), }), ); - expect(diffListener).toHaveBeenLastCalledWith({ - cwd: REPO_CWD, - files: [{ path: "tracked.txt", additions: 7, deletions: 3, status: "modified" }], - error: null, - }); + expect(diffListener).toHaveBeenCalledTimes(1); }); expect(getCheckoutStatus).toHaveBeenCalledTimes(2); diffSubscription.unsubscribe(); subscription.unsubscribe(); - diffManager.dispose(); service.dispose(); }); }); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index ea91186e9b..00c7084c7b 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -161,6 +161,7 @@ export interface WorkspaceGitRuntimeSnapshot { isDirty: boolean | null; baseRef: string | null; baseRefExact?: string | null; + comparisonBaseRef?: string | null; aheadBehind: { ahead: number; behind: number } | null; upstreamRef: string | null; aheadOfOrigin: number | null; @@ -3626,6 +3627,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { : (target.latestGit?.isDirty ?? checkoutStatus.isDirty), baseRef: checkoutStatus.baseRef, baseRefExact: checkoutStatus.baseRefExact, + comparisonBaseRef: checkoutStatus.comparisonBaseRef, aheadBehind: checkoutStatus.aheadBehind, upstreamRef: checkoutStatus.upstreamRef, aheadOfOrigin: checkoutStatus.aheadOfOrigin, diff --git a/packages/server/src/utils/checkout-git.commits.test.ts b/packages/server/src/utils/checkout-git.commits.test.ts index 7baac91c23..a19b81ded8 100644 --- a/packages/server/src/utils/checkout-git.commits.test.ts +++ b/packages/server/src/utils/checkout-git.commits.test.ts @@ -3,7 +3,12 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs" import { tmpdir } from "os"; import { join } from "path"; import { afterEach, describe, expect, it } from "vitest"; -import { getCommitFileDiff, listCheckoutCommits } from "./checkout-git.js"; +import { + getCommitFileDiff, + listCheckoutCommits, + getCheckoutStatus, + getCheckoutDiff, +} from "./checkout-git.js"; import { writePaseoWorktreeMetadata } from "./worktree-metadata.js"; const tempDirs: string[] = []; @@ -393,3 +398,45 @@ describe("listCheckoutCommits", () => { ]); }); }); + +it("compares tag-based worktrees in status, history and legacy diff modes", async () => { + const { repoDir: mainRepoDir, tempDir } = initRepoOnMain(); + const worktreesRoot = join(tempDir, "worktrees"); + const repoDir = join(worktreesRoot, "repo-hash", "tag-feature"); + mkdirSync(join(worktreesRoot, "repo-hash"), { recursive: true }); + const context = { worktreesRoot }; + git(["tag", "v1"], mainRepoDir); + git(["worktree", "add", "-b", "tag-feature", repoDir, "v1"], mainRepoDir); + await writePaseoWorktreeMetadata(repoDir, { baseRefName: "refs/tags/v1" }); + commitFile(repoDir, "tag.txt", "committed\n", "tag feature"); + writeFileSync(join(repoDir, "tag.txt"), "edited\n"); + const status = await getCheckoutStatus(repoDir, context); + expect(status).toMatchObject({ + isGit: true, + comparisonBaseRef: "refs/tags/v1", + aheadBehind: { ahead: 1, behind: 0 }, + }); + const history = await listCheckoutCommits({ cwd: repoDir, context }); + expect(history.baseRef).toBe("refs/tags/v1"); + expect(history.commits[0].subject).toBe("tag feature"); + const committed = await getCheckoutDiff(repoDir, { mode: "base", baseRef: "refs/tags/v1" }); + expect(committed.diff).toContain("+committed"); + const all = await getCheckoutDiff(repoDir, { mode: "all", baseRef: "refs/tags/v1" }); + expect(all.diff).toContain("+edited"); +}); + +it("announces a usable comparison base while checked out on the base branch", async () => { + const cwd = makeTempDir(); + git(["init", "-b", "main"], cwd); + git(["config", "user.name", "Test"], cwd); + git(["config", "user.email", "test@example.com"], cwd); + writeFileSync(join(cwd, "a.txt"), "before\n"); + git(["add", "."], cwd); + commit(cwd, "base"); + writeFileSync(join(cwd, "a.txt"), "after\n"); + const status = await getCheckoutStatus(cwd); + expect(status).toMatchObject({ isGit: true, comparisonBaseRef: "refs/heads/main" }); + if (!status.isGit || !status.comparisonBaseRef) throw new Error("Missing comparison base"); + const all = await getCheckoutDiff(cwd, { mode: "all", baseRef: status.comparisonBaseRef }); + expect(all.diff).toContain("+after"); +}); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index d793e2c683..2bb295b371 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -40,7 +40,7 @@ import { } from "../services/forge-cli-command.js"; import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; import { runGitCommand, type RunGitCommand } from "./run-git-command.js"; -import { readGitFileContents } from "./git-file-contents.js"; +import { readGitFileContents, readGitFileContentAtRef } from "./git-file-contents.js"; import { unquoteGitPath } from "./git-quoted-path.js"; import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js"; import { @@ -613,22 +613,6 @@ async function listCheckoutFileChanges( return Array.from(byPath.values()); } -async function readGitFileContentAtRef( - cwd: string, - ref: string, - path: string, -): Promise { - try { - const { stdout } = await runGitCommand(["show", `${ref}:${path}`], { - cwd, - envOverlay: READ_ONLY_GIT_ENV, - }); - return stdout; - } catch { - return null; - } -} - async function tryResolveMergeBase(cwd: string, baseRef: string): Promise { try { const { stdout } = await runGitCommand(["merge-base", baseRef, "HEAD"], { @@ -669,7 +653,12 @@ function normalizeNumstatPath(pathField: string): string { } // Parsed patches need a stable wire format regardless of terminal presentation settings. -const GIT_PATCH_FORMAT_ARGS = ["--no-ext-diff", "--no-color", "--src-prefix=a/", "--dst-prefix=b/"]; +export const GIT_PATCH_FORMAT_ARGS = [ + "--no-ext-diff", + "--no-color", + "--src-prefix=a/", + "--dst-prefix=b/", +] as const; function buildGitDiffArgs(args: { ignoreWhitespace?: boolean; extra: string[] }): string[] { return [ @@ -906,6 +895,7 @@ export interface CheckoutStatusGitNonPaseo { isDirty: boolean; baseRef: string | null; baseRefExact?: string | null; + comparisonBaseRef?: string | null; aheadBehind: AheadBehind | null; // Remote-tracking ref for currentBranch, e.g. "refs/remotes/origin/main". Null when the // branch has no upstream or git could not resolve it. aheadOfOrigin/behindOfOrigin are @@ -926,6 +916,7 @@ export interface CheckoutStatusGitPaseo { isDirty: boolean; baseRef: string; baseRefExact?: string | null; + comparisonBaseRef?: string | null; aheadBehind: AheadBehind | null; upstreamRef: string | null; aheadOfOrigin: number | null; @@ -1748,6 +1739,24 @@ function isQualifiedBranchRef(ref: string): boolean { return ref.startsWith("refs/heads/") || ref.startsWith("refs/remotes/"); } +async function resolveCommitComparisonRef( + cwd: string, + name: string, + context?: CheckoutContext, +): Promise { + await getRunGitCommand(context)(["rev-parse", "--verify", `${name}^{commit}`], { + cwd, + envOverlay: READ_ONLY_GIT_ENV, + logger: context?.logger, + }); + const result = await getRunGitCommand(context)(["rev-parse", "--symbolic-full-name", name], { + cwd, + envOverlay: READ_ONLY_GIT_ENV, + logger: context?.logger, + }); + return result.stdout.trim() || name; +} + async function resolveBestComparisonBaseRef( cwd: string, baseRef: string, @@ -1774,11 +1783,17 @@ async function resolveBestComparisonBaseRef( return normalized.localName; } - const refName = - baseRef.startsWith("origin/") || baseRef.startsWith("refs/remotes/origin/") - ? normalized.originRef - : normalized.localName; - throw new Error(`Base branch not found locally or on origin: ${refName}`); + return resolveCommitComparisonRef(cwd, baseRef, context); +} + +async function resolveQualifiedComparisonBaseRef( + cwd: string, + baseRef: string, + context: CheckoutContext, +): Promise { + const comparison = context.facts?.isGit ? context.facts.comparisonBaseRef : null; + const ref = comparison ?? (await resolveBestComparisonBaseRef(cwd, baseRef, context)); + return resolveCommitComparisonRef(cwd, ref, context); } async function resolveMostAheadBaseRef(cwd: string, baseRef: string): Promise { @@ -1801,7 +1816,7 @@ async function resolveMostAheadBaseRef(cwd: string, baseRef: string): Promise { } } -async function inspectUntrackedFile( +export async function inspectUntrackedFile( cwd: string, relativePath: string, ): Promise<{ stat: FileStat; truncated: boolean }> { @@ -2399,6 +2414,10 @@ export async function getCheckoutStatus( const baseRef = facts.resolvedBaseRef; const mainRepoRoot = facts.mainRepoRoot; const factsContext = { ...context, facts }; + // Ref-derived counts skip the current base branch; the diff consumer still needs its ref. + const comparisonBaseRef = baseRef + ? await resolveQualifiedComparisonBaseRef(cwd, baseRef, factsContext).catch(() => null) + : null; const aheadBehind = baseRef && currentBranch ? await getAheadBehind(cwd, baseRef, currentBranch, factsContext) @@ -2421,6 +2440,7 @@ export async function getCheckoutStatus( isDirty, baseRef: displayBaseRef ?? baseRef, baseRefExact: baseRef, + comparisonBaseRef, aheadBehind, upstreamRef, aheadOfOrigin, @@ -2440,6 +2460,7 @@ export async function getCheckoutStatus( isDirty, baseRef: displayBaseRef, baseRefExact: baseRef, + comparisonBaseRef, aheadBehind, upstreamRef, aheadOfOrigin, @@ -2804,6 +2825,7 @@ async function readCommitRenames(cwd: string, sha: string): Promise { + if (compare.mode === "uncommitted") return "HEAD"; const { storedBaseRef, resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context); const baseRef = resolveOperationBaseRef({ storedBaseRef, @@ -3579,17 +3616,12 @@ async function resolveCheckoutDiffRefs( if (!baseRef) { return null; } - const bestBaseRef = await resolveBestComparisonBaseRef(cwd, baseRef); - return { - baseRef: (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef, - targetRef: compare.mode === "base" ? "HEAD" : undefined, - includeUntracked: compare.mode === "all", - }; + return resolveBestComparisonBaseRef(cwd, baseRef); } // Git's working-tree diff ignores files absent from the index, even when they // exist in the comparison tree. Restore those entries only in a private index. -async function prepareAllChangesIndex(cwd: string, baseRef: string) { +export async function prepareAllChangesIndex(cwd: string, baseRef: string) { const untracked = await runGitCommand(["ls-files", "-z", "--others", "--exclude-standard"], { cwd, envOverlay: READ_ONLY_GIT_ENV, @@ -3612,21 +3644,41 @@ async function prepareAllChangesIndex(cwd: string, baseRef: string) { entries += result.stdout; } if (!entries) return null; - const directory = await mkdtemp(resolve(tmpdir(), "paseo-all-changes-")); + const snapshot = await snapshotComparisonIndex(cwd); + try { + await runGitCommand(["update-index", "-z", "--index-info"], { + cwd, + input: entries, + envOverlay: { ...READ_ONLY_GIT_ENV, GIT_INDEX_FILE: snapshot.indexFile }, + }); + return snapshot; + } catch (error) { + await snapshot.cleanup(); + throw error; + } +} + +// write-tree rewrites its index. A private snapshot prevents an index read from +// invalidating the observation that requested it. +export async function snapshotComparisonIndex(cwd: string) { + const directory = await mkdtemp(resolve(tmpdir(), "paseo-comparison-index-")); const indexFile = resolve(directory, "index"); try { const index = await runGitCommand(["rev-parse", "--git-path", "index"], { cwd, envOverlay: READ_ONLY_GIT_ENV, }); - const indexPath = resolveGitRevParsePath(cwd, index.stdout); - if (!indexPath) throw new Error("Git did not return an index path"); - await copyFile(indexPath, indexFile); - await runGitCommand(["update-index", "-z", "--index-info"], { - cwd, - input: entries, - envOverlay: { ...READ_ONLY_GIT_ENV, GIT_INDEX_FILE: indexFile }, - }); + const source = resolveGitRevParsePath(cwd, index.stdout); + if (!source) throw new Error("Git did not return an index path"); + try { + await copyFile(source, indexFile); + } catch (error) { + if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error; + await runGitCommand(["read-tree", "--empty"], { + cwd, + envOverlay: { ...READ_ONLY_GIT_ENV, GIT_INDEX_FILE: indexFile }, + }); + } return { indexFile, cleanup: () => rm(directory, { recursive: true, force: true }) }; } catch (error) { await rm(directory, { recursive: true, force: true }); diff --git a/packages/server/src/utils/git-file-contents.ts b/packages/server/src/utils/git-file-contents.ts index e42d3c6c83..04a314f17c 100644 --- a/packages/server/src/utils/git-file-contents.ts +++ b/packages/server/src/utils/git-file-contents.ts @@ -1,4 +1,4 @@ -import { runGitCommandBytes } from "./run-git-command.js"; +import { runGitCommandBytes, runGitCommand } from "./run-git-command.js"; // Match the existing git-show ceiling. Oversized batches use bounded per-file reads. const MAX_CONTENT_BYTES = 20 * 1024 * 1024; @@ -46,3 +46,17 @@ export async function readGitFileContents( if (offset !== result.stdout.length) throw new Error("Unexpected git object output"); return contents; } + +export async function readGitFileContentAtRef( + cwd: string, + ref: string, + path: string, +): Promise { + const result = await runGitCommand(["show", `${ref}:${path}`], { + cwd, + envOverlay: { GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C" }, + acceptExitCodes: [0, 128], + }); + if (result.exitCode !== 0 || result.truncated) return null; + return result.stdout; +} diff --git a/packages/server/src/utils/run-git-command.ts b/packages/server/src/utils/run-git-command.ts index 3a3cbf5b4c..e65416878f 100644 --- a/packages/server/src/utils/run-git-command.ts +++ b/packages/server/src/utils/run-git-command.ts @@ -20,7 +20,7 @@ import { } from "./git-process-scheduler.js"; const DEFAULT_TIMEOUT_MS = 30_000; -const DEFAULT_MAX_OUTPUT_BYTES = 20 * 1024 * 1024; // 20MB +export const DEFAULT_MAX_OUTPUT_BYTES = 20 * 1024 * 1024; // 20MB const DEFAULT_STDERR_LIMIT = 2048; let gitProcessScheduler = new GitProcessScheduler(resolveGitProcessPolicy({ env: process.env }));