mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 08:54:42 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
850471276a | ||
|
|
a5c5ad8e1d | ||
|
|
ad21f9eb93 | ||
|
|
8b5527e7e6 | ||
|
|
04a7d1b3a7 | ||
|
|
e044c788b5 | ||
|
|
986ce285b0 | ||
|
|
e516961a09 | ||
|
|
33db25cf5c | ||
|
|
0a9e678286 | ||
|
|
cee05e3c6c | ||
|
|
92be3e9dff | ||
|
|
8e1e377120 | ||
|
|
870180cbe5 | ||
|
|
567c1617f5 | ||
|
|
236270768d | ||
|
|
1d8eacd38a | ||
|
|
8bc533279a |
@@ -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<void> {
|
||||
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<DirtyWorkspace> {
|
||||
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<void> {
|
||||
await writeFile(path.join(workspace.repoPath, "zz-too-large.txt"), "x".repeat(1024 * 1024 + 1));
|
||||
}
|
||||
async function addOneLineToFirstFile(workspace: DirtyWorkspace): Promise<void> {
|
||||
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<number, number>();
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
|
||||
@@ -490,6 +614,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<void> {
|
||||
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,
|
||||
}) => {
|
||||
@@ -1302,11 +1455,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);
|
||||
@@ -1885,12 +2040,16 @@ async function createWorkspaceWithTwoSelectionDiffs(): Promise<DirtyWorkspace> {
|
||||
return { id: created.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function createWorkspaceWithManyTinyDiffs(fileCount: number): Promise<DirtyWorkspace> {
|
||||
async function createWorkspaceWithManyTinyDiffs(
|
||||
fileCount: number,
|
||||
branch = "main",
|
||||
): Promise<DirtyWorkspace> {
|
||||
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`),
|
||||
|
||||
@@ -79,6 +79,12 @@ async function previewExplorerResize(page: Page, distance: number) {
|
||||
return { ...measurement, width: after };
|
||||
}
|
||||
|
||||
async function expectCleanChangesLoaded(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("changes-tree-panel").getByText("No changes to display", { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectExplorerWidthSurvivesReload(page: Page, width: number): Promise<void> {
|
||||
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(() =>
|
||||
|
||||
@@ -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<SubscribeCheckoutDiffResponseMessage["payload"]>(
|
||||
{ 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<DiffState> = {
|
||||
...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();
|
||||
|
||||
@@ -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<SessionOutboundMessage, { type: "status" }>;
|
||||
type TerminalsChangedMessage = Extract<SessionOutboundMessage, { type: "terminals_changed" }>;
|
||||
type CheckoutDiffResponsePayload = SubscribeCheckoutDiffResponseMessage["payload"];
|
||||
type CheckoutDiffCachePayload = Omit<CheckoutDiffResponsePayload, "subscriptionId">;
|
||||
type CheckoutDiffCachePayload = CheckoutDiffSnapshot;
|
||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
|
||||
interface CheckoutDiffCompare {
|
||||
@@ -49,7 +46,7 @@ interface CheckoutDiffRoute {
|
||||
}
|
||||
|
||||
interface CheckoutDiffRegistration extends CheckoutDiffRoute {
|
||||
subscription: OwnedSubscription<CheckoutDiffResponsePayload>;
|
||||
subscription: OwnedSubscription<DiffState>;
|
||||
}
|
||||
|
||||
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<CheckoutDiffResponsePayload, "requestId"> & { 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,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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]] : [],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -1095,6 +1096,7 @@ interface DiffBodyContentProps {
|
||||
emptyMessage: string;
|
||||
emptyAction: ChangesEmptyAction | null;
|
||||
children: ReactElement;
|
||||
loadingLabel: string;
|
||||
checkingRepositoryLabel: string;
|
||||
notRepositoryLabel: string;
|
||||
}
|
||||
@@ -1111,11 +1113,16 @@ function DiffBodyContent({
|
||||
emptyAction,
|
||||
children,
|
||||
checkingRepositoryLabel,
|
||||
loadingLabel,
|
||||
notRepositoryLabel,
|
||||
}: DiffBodyContentProps) {
|
||||
if (isStatusLoading) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<View
|
||||
style={styles.loadingContainer}
|
||||
accessibilityRole="progressbar"
|
||||
accessibilityLabel={loadingLabel}
|
||||
>
|
||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedIconColorMapping} />
|
||||
<Text style={styles.loadingText}>{checkingRepositoryLabel}</Text>
|
||||
</View>
|
||||
@@ -1137,12 +1144,16 @@ function DiffBodyContent({
|
||||
}
|
||||
if (isDiffLoading) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<View
|
||||
style={styles.loadingContainer}
|
||||
accessibilityRole="progressbar"
|
||||
accessibilityLabel={loadingLabel}
|
||||
>
|
||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedIconColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (diffTooLarge) {
|
||||
if (diffTooLarge && !hasChanges) {
|
||||
return <DiffTooLargeState />;
|
||||
}
|
||||
if (diffErrorMessage) {
|
||||
@@ -1321,7 +1332,7 @@ function ChangesTreeRail({
|
||||
}: {
|
||||
shown: boolean;
|
||||
children: ReactElement;
|
||||
files: ParsedDiffFile[];
|
||||
files: DiffFileHeader[];
|
||||
mode: WorkingDiffMode;
|
||||
onSelectFile: (path: string) => void;
|
||||
treeWidth?: number;
|
||||
@@ -1361,7 +1372,7 @@ function ChangesBody({
|
||||
children: ReactElement;
|
||||
desktopTreeVisible: boolean;
|
||||
isMobile: boolean;
|
||||
files: ParsedDiffFile[];
|
||||
files: DiffFileHeader[];
|
||||
mode: WorkingDiffMode;
|
||||
onSelectFile: (path: string) => void;
|
||||
treeWidth?: number;
|
||||
@@ -1586,6 +1597,7 @@ export function ChangesSurface({
|
||||
selectBase: handleSelectBase,
|
||||
selectAll: handleSelectAll,
|
||||
files,
|
||||
treeFiles,
|
||||
diffPayloadError,
|
||||
diffTooLarge,
|
||||
isDiffLoading,
|
||||
@@ -1835,6 +1847,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")}
|
||||
>
|
||||
@@ -1865,7 +1878,7 @@ export function ChangesSurface({
|
||||
presentation={presentation}
|
||||
desktopTreeVisible={desktopTreeVisible}
|
||||
isMobile={isMobile}
|
||||
files={files}
|
||||
files={treeFiles}
|
||||
mode={workingMode}
|
||||
onSelectFile={handleSelectTreeFile}
|
||||
treeWidth={instanceState.treeWidth}
|
||||
|
||||
@@ -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<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
|
||||
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 };
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { DiffState, DiffEntry } from "@getpaseo/client";
|
||||
import type {
|
||||
DiffError,
|
||||
ParsedDiffFile,
|
||||
SubscribeCheckoutDiffRequest,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
export type DiffFileHeader = Omit<ParsedDiffFile, "hunks">;
|
||||
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<SubscribeCheckoutDiffRequest["compare"], "mode" | "baseRef">,
|
||||
): 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.",
|
||||
};
|
||||
}
|
||||
@@ -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<string, DiffTreeDirNode>([["", root]]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
|
||||
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<CheckoutDiffQueryPayload>({
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.2",
|
||||
|
||||
@@ -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;
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
DiffStreamRouter,
|
||||
DiffSubscriptionError,
|
||||
type DiffCompare,
|
||||
type DiffState,
|
||||
} from "./diff/index.js";
|
||||
import {
|
||||
ChunkedTransport,
|
||||
ChunkedTransportProtocolError,
|
||||
@@ -1411,6 +1417,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<string, PendingBinaryFileRead>();
|
||||
private activeBinaryFileTransfers = new Map<string, BinaryFileTransferState>();
|
||||
@@ -1760,6 +1780,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"));
|
||||
@@ -2663,7 +2684,12 @@ export class DaemonClient {
|
||||
private observe<T extends CorrelatedResponseType>(
|
||||
responseType: T,
|
||||
message: { type: SessionInboundMessage["type"] } & Record<string, unknown>,
|
||||
options?: { requestId?: string; timeout?: number; signal?: AbortSignal },
|
||||
options?: {
|
||||
requestId?: string;
|
||||
timeout?: number;
|
||||
signal?: AbortSignal;
|
||||
validateSnapshot?: (payload: CorrelatedResponsePayload<T>) => void;
|
||||
},
|
||||
): OwnedSubscription<CorrelatedResponsePayload<T>> {
|
||||
let resetSource = false;
|
||||
return this.owned.observeRequest<CorrelatedResponsePayload<T>>(
|
||||
@@ -2689,12 +2715,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;
|
||||
}
|
||||
},
|
||||
@@ -4660,6 +4690,26 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
observeDiff(compare: DiffCompare): OwnedSubscription<DiffState> {
|
||||
return this.diffStreams.observe(compare);
|
||||
}
|
||||
|
||||
async readDiff(compare: DiffCompare): Promise<DiffState> {
|
||||
const observation = this.observeDiff(compare);
|
||||
try {
|
||||
return await new Promise<DiffState>((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 },
|
||||
@@ -6936,6 +6986,7 @@ export class DaemonClient {
|
||||
this.chunkedTransport?.close();
|
||||
this.chunkedTransport = null;
|
||||
this.owned.disconnected();
|
||||
this.diffStreams.disconnected();
|
||||
this.providerSnapshotUpdates.clear();
|
||||
this.stopLivenessHeartbeat();
|
||||
this.cleanupTransport();
|
||||
@@ -7126,6 +7177,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;
|
||||
@@ -7326,6 +7381,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
|
||||
@@ -7485,6 +7541,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,
|
||||
@@ -7761,3 +7818,5 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon
|
||||
cwd: merged.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
export type { DiffCompare, DiffState, DiffEntry, ResolvedShas } from "./diff/index.js";
|
||||
|
||||
@@ -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<string, unknown>[] = [];
|
||||
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<typeof socket>, 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();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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<DiffSubscribeRequest, "type" | "requestId">;
|
||||
|
||||
export type DiffEntry = Omit<WireEntry, "body">;
|
||||
export type ResolvedShas = Pick<DiffBegin, "baseSha" | "headSha" | "mergeBaseSha">;
|
||||
|
||||
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<DiffSubscribeResponse["payload"]>;
|
||||
legacy(
|
||||
cwd: string,
|
||||
compare: LegacyCompare,
|
||||
): OwnedSubscription<SubscribeCheckoutDiffResponse["payload"]>;
|
||||
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<void>;
|
||||
}
|
||||
/** Capability selection, slot ownership and reconnect belong to this domain. */
|
||||
export class DiffStreamRouter {
|
||||
private registrations = new Set<Registration>();
|
||||
private slots = new Map<number, { receive(bytes: Uint8Array): void }>();
|
||||
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<void> {
|
||||
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<unknown> | null): void {
|
||||
void backend?.release().catch(this.port.failed);
|
||||
}
|
||||
observe(compare: DiffCompare): OwnedSubscription<DiffState> {
|
||||
let backend: OwnedSubscription<unknown> | 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<SubscriptionObserver<DiffState & { subscriptionId: string }>>();
|
||||
let resolve!: (state: DiffState & { subscriptionId: string }) => void;
|
||||
const ready = new Promise<DiffState & { subscriptionId: string }>((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;
|
||||
}
|
||||
@@ -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: [] });
|
||||
});
|
||||
@@ -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<string, DiffBody> } | null = null;
|
||||
private committed: Extract<DiffState, { phase: "ready" }> | 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);
|
||||
}
|
||||
@@ -1212,3 +1212,5 @@ function createGeneratedClientId(): string {
|
||||
: Math.random().toString(36).slice(2);
|
||||
return `paseo-sdk-${randomId}`;
|
||||
}
|
||||
|
||||
export type { DiffCompare, DiffState, DiffEntry, ResolvedShas } from "./diff/index.js";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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<T>(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<ParsedDiffFile["hunks"][number]["lines"][number]["tokens"]> {
|
||||
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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -2288,6 +2288,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<typeof DiffEndpointSchema>;
|
||||
export type DiffError = z.infer<typeof DiffErrorSchema>;
|
||||
export type DiffSubscribeRequest = z.infer<typeof DiffSubscribeRequestSchema>;
|
||||
export type DiffSubscribeResponse = z.infer<typeof DiffSubscribeResponseSchema>;
|
||||
|
||||
export const CheckoutStatusRequestSchema = z.object({
|
||||
type: z.literal("checkout_status_request"),
|
||||
cwd: z.string(),
|
||||
@@ -3428,6 +3473,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
AgentDetachRequestMessageSchema,
|
||||
AgentRewindRequestMessageSchema,
|
||||
AgentPermissionResponseMessageSchema,
|
||||
DiffSubscribeRequestSchema,
|
||||
DiffUnsubscribeRequestSchema,
|
||||
CheckoutStatusRequestSchema,
|
||||
CheckoutDiffGetRequestSchema,
|
||||
SubscribeCheckoutDiffRequestSchema,
|
||||
@@ -3887,6 +3934,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(),
|
||||
@@ -5428,6 +5476,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 `<branch>@{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
|
||||
@@ -7235,6 +7284,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
AgentDeletedMessageSchema,
|
||||
AgentArchivedMessageSchema,
|
||||
CloseItemsResponseSchema,
|
||||
DiffSubscribeResponseSchema,
|
||||
CheckoutStatusResponseSchema,
|
||||
CheckoutStatusUpdateSchema,
|
||||
CheckoutDiffGetResponseSchema,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -194,6 +194,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",
|
||||
@@ -437,6 +439,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",
|
||||
|
||||
@@ -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<T> {
|
||||
promise: Promise<T>;
|
||||
resolve(value: T): void;
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function createWorkspaceSnapshot(
|
||||
overrides?: Partial<WorkspaceGitRuntimeSnapshot["git"]>,
|
||||
): 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<void>,
|
||||
): Promise<void> {
|
||||
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<typeof vi.fn>;
|
||||
}) {
|
||||
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<void> {
|
||||
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);
|
||||
|
||||
@@ -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<SessionOutboundMessage, { type: "checkout_diff_update" }>["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<void> | null;
|
||||
refreshQueued: boolean;
|
||||
refreshQueuedForce: boolean;
|
||||
latestPayload: CheckoutDiffSnapshotPayload | null;
|
||||
latestFingerprint: string | null;
|
||||
openPromise: Promise<void> | 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<string, CheckoutDiffWatchTarget>();
|
||||
|
||||
readonly streams: DiffSubscriptions;
|
||||
private readonly paseoHome: string;
|
||||
constructor(options: {
|
||||
logger: pino.Logger;
|
||||
paseoHome: string;
|
||||
workspaceGitService: CheckoutDiffWorkspace;
|
||||
workspaceGitService: Pick<WorkspaceGitService, "requestWorkingTreeWatch">;
|
||||
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<CheckoutDiffSubscriptionRequest, "signal">,
|
||||
): Promise<CheckoutDiffSnapshotPayload> {
|
||||
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<CheckoutDiffSubscription> {
|
||||
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<CheckoutDiffSnapshotPayload>((resolve) => {
|
||||
resolveInitial = resolve;
|
||||
});
|
||||
let delivered = false;
|
||||
let pending: DiffBegin | null = null;
|
||||
let files = new Map<string, ParsedDiffFile>();
|
||||
let bodies = new Map<string, DiffBody>();
|
||||
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<string, ParsedDiffFile>();
|
||||
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<void> {
|
||||
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<CheckoutDiffSnapshotPayload> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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")),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ReturnType<typeof openDiffWireProbe>> | 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<void>((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<DiffWireResult>((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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -690,6 +690,7 @@ describe("commit file diffs", () => {
|
||||
expect(result).toEqual({
|
||||
file: {
|
||||
path: filePath,
|
||||
status: "ok",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { encodeDiffFrame, DiffOpcode, type DiffBody } from "@getpaseo/protocol/binary-frames/index";
|
||||
|
||||
export async function* packBodies(
|
||||
files: AsyncIterable<DiffBody> | Iterable<DiffBody>,
|
||||
revision: number,
|
||||
): AsyncGenerator<DiffBody[]> {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { classifyGitMetadataPath } 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<WorkspaceGitService, "requestWorkingTreeWatch">) {}
|
||||
async declareDependencies(
|
||||
input: DiffDependencies,
|
||||
changed: () => void,
|
||||
): Promise<() => Promise<void>> {
|
||||
if (input.refs.length === 0 && !input.index && !input.worktree) return async () => {};
|
||||
let polling: ReturnType<typeof setInterval> | null = null;
|
||||
const subscriptions: FileObserverSubscription[] = [];
|
||||
let workingTree: Awaited<ReturnType<WorkspaceGitService["requestWorkingTreeWatch"]>> | null =
|
||||
null;
|
||||
try {
|
||||
const paths = await runGitCommand(["rev-parse", "--absolute-git-dir", "--git-common-dir"], {
|
||||
cwd: input.cwd,
|
||||
});
|
||||
const roots = new Set(
|
||||
paths.stdout
|
||||
.split(/\r?\n/)
|
||||
.filter((path) => path.length > 0)
|
||||
.map((path) => resolve(input.cwd, path)),
|
||||
);
|
||||
for (const root of roots) {
|
||||
const subscription = await this.observer.subscribe(
|
||||
root,
|
||||
(error, events) => {
|
||||
if (error) {
|
||||
changed();
|
||||
polling ??= setInterval(changed, 5000);
|
||||
return;
|
||||
}
|
||||
const affectsInput = events.some((event) => {
|
||||
const path = event.path.slice(root.length + 1).replaceAll("\\", "/");
|
||||
if (path === "index") return input.index;
|
||||
return classifyGitMetadataPath("common", path).kind !== "ignore";
|
||||
});
|
||||
if (affectsInput) changed();
|
||||
},
|
||||
{ ignore: [resolve(root, "objects"), resolve(root, "logs"), resolve(root, "hooks")] },
|
||||
);
|
||||
subscriptions.push(subscription);
|
||||
}
|
||||
if (input.worktree)
|
||||
workingTree = await this.workspaceGit.requestWorkingTreeWatch(input.cwd, changed);
|
||||
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<void> {
|
||||
await this.observer.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
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 });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,417 @@
|
||||
import { parseGitRevParsePath } from "../../utils/git-rev-parse-path.js";
|
||||
import { readGitInputIdentities } 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<DiffSubscribeRequest, "type" | "requestId"> & {
|
||||
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<void>;
|
||||
selectBodies(paths: ReadonlySet<string>): void;
|
||||
read(
|
||||
file: ListedFile,
|
||||
budget?: { remainingBytes?: number; maxPatchBytes?: number },
|
||||
): Promise<DiffFileRead>;
|
||||
}
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string, { additions: number; deletions: number; binary: boolean }> {
|
||||
const fields = text.split("\0");
|
||||
const stats = new Map<string, { additions: number; deletions: number; binary: boolean }>();
|
||||
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<Comparison> {
|
||||
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<ReturnType<typeof prepareAllChangesIndex>> = 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<ReturnType<typeof prepareAllChangesIndex>>,
|
||||
): Promise<Comparison> {
|
||||
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 } = 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));
|
||||
const gitInputs = await readGitInputIdentities(files, worktreeFiles, options);
|
||||
for (const file of files) file.identity += `:${gitInputs.get(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),
|
||||
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<string> }> {
|
||||
const [raw, stat] = await Promise.all([
|
||||
runGitCommand([...args, "--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<string>();
|
||||
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];
|
||||
}
|
||||
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) 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 };
|
||||
}
|
||||
|
||||
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<Buffer> {
|
||||
if (symlink) return Buffer.from(await readlink(path));
|
||||
if (omitted) return Buffer.alloc(0);
|
||||
return readFile(path);
|
||||
}
|
||||
|
||||
async function listUntrackedFiles(options: GitCommandOptions): Promise<ListedFile[]> {
|
||||
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}`;
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
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<void>;
|
||||
active: boolean;
|
||||
delivery: Promise<void>;
|
||||
}
|
||||
interface Target {
|
||||
key: string;
|
||||
compare: Compare;
|
||||
listeners: Set<Listener>;
|
||||
revision: number;
|
||||
committed: number;
|
||||
cache: Map<string, RecordCache>;
|
||||
entries: DiffEntry[];
|
||||
resolved: Pick<Comparison, "baseSha" | "headSha" | "mergeBaseSha">;
|
||||
error: DiffError | null;
|
||||
omitted: string[];
|
||||
refresh: Promise<void> | null;
|
||||
dirty: boolean;
|
||||
release: (() => Promise<void>) | null;
|
||||
opening: Promise<void> | null;
|
||||
closed: boolean;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
export interface CommitFileRead extends Pick<
|
||||
CheckoutCommitFileDiffRequest,
|
||||
"cwd" | "sha" | "path"
|
||||
> {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
export interface DiffSubscription {
|
||||
start(): Promise<void>;
|
||||
release(): Promise<void>;
|
||||
}
|
||||
export class DiffSubscriptions {
|
||||
private targets = new Map<string, Target>();
|
||||
private dependencies: DiffDependencyObserver;
|
||||
private computations = 0;
|
||||
private highlightCalls = 0;
|
||||
constructor(workspaceGit: Pick<WorkspaceGitService, "requestWorkingTreeWatch">) {
|
||||
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<ParsedDiffFile | null> {
|
||||
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<DiffSubscription> {
|
||||
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,
|
||||
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",
|
||||
},
|
||||
() => 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
async invalidateForCwd(cwd: string): Promise<void> {
|
||||
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 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
do {
|
||||
target.dirty = false;
|
||||
const revision = ++target.revision;
|
||||
let comparison: Comparison | null = null;
|
||||
let began = false;
|
||||
try {
|
||||
this.computations++;
|
||||
comparison = await listComparison(target.compare);
|
||||
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<string, RecordCache>();
|
||||
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<string, RecordCache>; omitted: string[]; total: number },
|
||||
): AsyncGenerator<DiffBody> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
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];
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { runGitCommand, type GitCommandOptions } from "../../utils/run-git-command.js";
|
||||
import type { ListedFile } from "./git.js";
|
||||
|
||||
// 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 readGitInputIdentities(
|
||||
files: ListedFile[],
|
||||
worktreeFiles: Set<string>,
|
||||
options: GitCommandOptions,
|
||||
): Promise<Map<string, string>> {
|
||||
const paths = files
|
||||
.filter((file) => file.entry.content === "text")
|
||||
.map((file) => file.entry.path);
|
||||
if (!paths.length) return new Map();
|
||||
const regular = paths.filter((path) => worktreeFiles.has(path));
|
||||
const [config, attributes, blobs] = await Promise.all([
|
||||
runGitCommand(["config", "--null", "--list"], options),
|
||||
runGitCommand(["check-attr", "--all", "-z", "--stdin"], {
|
||||
...options,
|
||||
input: paths.join("\0") + "\0",
|
||||
}),
|
||||
regular.length
|
||||
? runGitCommand(["hash-object", "--stdin-paths"], {
|
||||
...options,
|
||||
input: regular.map(quotePath).join("\n") + "\n",
|
||||
})
|
||||
: null,
|
||||
]);
|
||||
if (config.truncated || attributes.truncated || blobs?.truncated)
|
||||
throw new Error("Git input identity exceeded the output budget");
|
||||
const byPath = new Map<string, string[]>();
|
||||
const fields = attributes.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 = byPath.get(fields[index]) ?? [];
|
||||
values.push(JSON.stringify([fields[index + 1], fields[index + 2]]));
|
||||
byPath.set(fields[index], values);
|
||||
}
|
||||
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 configIdentity = createHash("sha256").update(config.stdout).digest("hex");
|
||||
return new Map(
|
||||
paths.map((path) => [
|
||||
path,
|
||||
createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify([
|
||||
configIdentity,
|
||||
byPath.get(path)?.sort() ?? [],
|
||||
blobByPath.get(path) ?? null,
|
||||
]),
|
||||
)
|
||||
.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 + '"';
|
||||
}
|
||||
@@ -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<string, Promise<Map<string, Patch>>>();
|
||||
constructor(
|
||||
private args: string[],
|
||||
private options: GitCommandOptions,
|
||||
files: ListedFile[],
|
||||
) {
|
||||
this.files = files.filter((file) => file.source.kind === "tracked" && file.entry.body);
|
||||
}
|
||||
select(paths: ReadonlySet<string>): void {
|
||||
this.files = this.files.filter((file) => paths.has(file.entry.path));
|
||||
}
|
||||
async read(file: ListedFile, maxBytes: number): Promise<Patch> {
|
||||
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<Map<string, Patch>> {
|
||||
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<string, Patch>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
await expect
|
||||
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 })
|
||||
.toBe(true);
|
||||
}
|
||||
@@ -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<number>();
|
||||
constructor(
|
||||
private subscriptions: DiffSubscriptions,
|
||||
private logger: Pick<Logger, "debug" | "error">,
|
||||
) {}
|
||||
async subscribe(
|
||||
msg: DiffSubscribeRequest,
|
||||
ownership: SessionDelivery,
|
||||
emit: (message: SessionOutboundMessage) => void,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<string, number>;
|
||||
private page = -1;
|
||||
private pending: Promise<Map<string, string | null> | 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<HighlightContext> {
|
||||
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<Map<string, string | null> | 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<string, string | null> | null,
|
||||
ref: string,
|
||||
path: string,
|
||||
): Promise<string | null> {
|
||||
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<string | undefined> {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
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<void>((resolve) => {
|
||||
resume = resolve;
|
||||
});
|
||||
const replayBegan = new Promise<void>((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 });
|
||||
}
|
||||
});
|
||||
@@ -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,138 @@ 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" });
|
||||
writeFileSync(join(cwd, ".git", "info", "attributes"), "file.txt -diff\n");
|
||||
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: "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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("Git metadata event rules", () => {
|
||||
["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) => {
|
||||
|
||||
@@ -73,6 +73,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",
|
||||
|
||||
@@ -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";
|
||||
@@ -841,6 +842,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;
|
||||
@@ -1004,6 +1006,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),
|
||||
@@ -3128,6 +3131,10 @@ export class Session {
|
||||
// eslint-disable-next-line complexity
|
||||
private dispatchCheckoutMessage(msg: SessionInboundMessage): Promise<void> | 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":
|
||||
@@ -3437,7 +3444,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(
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<CheckoutDiffSubscriptionRequest, "signal">,
|
||||
): Promise<CheckoutDiffSnapshotPayload>;
|
||||
@@ -116,7 +116,7 @@ export interface CheckoutDiffSubscriber {
|
||||
params: CheckoutDiffSubscriptionRequest,
|
||||
listener: (snapshot: CheckoutDiffSnapshotPayload) => void,
|
||||
): Promise<CheckoutDiffSubscription>;
|
||||
scheduleRefreshForCwd(cwd: string): void;
|
||||
scheduleRefreshForCwd(cwd: string): Promise<void>;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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",
|
||||
|
||||
@@ -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<SessionInternals>(
|
||||
new Session({
|
||||
checkoutDiffManager: createStub<SessionOptions["checkoutDiffManager"]>({
|
||||
streams: new DiffSubscriptions({
|
||||
async requestWorkingTreeWatch() {
|
||||
throw new Error("Unexpected diff observation");
|
||||
},
|
||||
}),
|
||||
}),
|
||||
workspaceRuntimeStore: createWorkspaceRuntimeStore(),
|
||||
messageReceipts: createMessageReceiptsStub(),
|
||||
agentMessages: createAgentMessageDeliveryStub(),
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -1223,7 +1223,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();
|
||||
@@ -2020,6 +2020,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
usageSetting: true,
|
||||
usageSources: true,
|
||||
ownedSubscriptions: true,
|
||||
diffSubscription: true,
|
||||
binaryChunkingV1: true,
|
||||
agentRequestReceipts: true,
|
||||
workspaceRequestReceipts: true,
|
||||
|
||||
@@ -254,7 +254,7 @@ function createSessionForWireCompatTest(options?: {
|
||||
directorySync: options?.directorySync,
|
||||
scheduleService: {} as SessionOptions["scheduleService"],
|
||||
checkoutDiffManager: {
|
||||
scheduleRefreshForCwd() {},
|
||||
async scheduleRefreshForCwd() {},
|
||||
onWorkspaceStateMayHaveChanged() {},
|
||||
} as unknown as SessionOptions["checkoutDiffManager"],
|
||||
github: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -1231,7 +1213,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 };
|
||||
@@ -1246,19 +1228,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);
|
||||
@@ -1281,16 +1254,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();
|
||||
});
|
||||
|
||||
@@ -1304,20 +1274,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
|
||||
@@ -1325,6 +1289,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
|
||||
@@ -1332,12 +1297,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();
|
||||
});
|
||||
|
||||
@@ -2759,39 +2722,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();
|
||||
@@ -2799,7 +2746,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" },
|
||||
@@ -2814,17 +2760,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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<string | null> {
|
||||
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<string | null> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
@@ -1801,7 +1816,7 @@ async function resolveMostAheadBaseRef(cwd: string, baseRef: string): Promise<st
|
||||
return `origin/${normalizedBaseRef}`;
|
||||
}
|
||||
if (!hasLocal && !hasOrigin) {
|
||||
throw new Error(`Base branch not found locally or on origin: ${normalizedBaseRef}`);
|
||||
return resolveCommitComparisonRef(cwd, baseRef);
|
||||
}
|
||||
|
||||
const { stdout } = await runGitCommand(
|
||||
@@ -2239,8 +2254,8 @@ export async function getCheckoutSnapshotFacts(
|
||||
};
|
||||
}
|
||||
|
||||
const PER_FILE_DIFF_MAX_BYTES = 1024 * 1024; // 1MB
|
||||
const TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB
|
||||
export const PER_FILE_DIFF_MAX_BYTES = 1024 * 1024; // 1MB
|
||||
export const TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB
|
||||
const CHECKOUT_DIFF_FRAME_HEADROOM_BYTES = 1024 * 1024;
|
||||
// Temporary until diffs load lazily per file. The Paseo relay's 32 MiB frame limit is
|
||||
// binding: string frames are encrypted and base64-encoded. Reserve 1 MiB plaintext for
|
||||
@@ -2301,7 +2316,7 @@ async function isLikelyBinaryFile(absolutePath: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
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<ReadonlyMap<
|
||||
* binary-only (no textual hunks). Throws on git failure (e.g. an unknown sha),
|
||||
* which the caller maps to a typed checkout error.
|
||||
*/
|
||||
// COMPAT(diffSubscription): added in v0.15.0, remove after 2027-05-06.
|
||||
export async function getCommitFileDiff({
|
||||
cwd,
|
||||
sha,
|
||||
@@ -3570,6 +3592,21 @@ async function resolveCheckoutDiffRefs(
|
||||
if (compare.mode === "uncommitted") {
|
||||
return { baseRef: "HEAD", includeUntracked: true };
|
||||
}
|
||||
const bestBaseRef = await resolveCheckoutDiffBase(cwd, compare, context);
|
||||
if (!bestBaseRef) return null;
|
||||
return {
|
||||
baseRef: (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef,
|
||||
targetRef: compare.mode === "base" ? "HEAD" : undefined,
|
||||
includeUntracked: compare.mode === "all",
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveCheckoutDiffBase(
|
||||
cwd: string,
|
||||
compare: CheckoutDiffCompare,
|
||||
context?: CheckoutContext,
|
||||
): Promise<string | null> {
|
||||
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 });
|
||||
|
||||
@@ -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<string | null> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
Reference in New Issue
Block a user