mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 01:14:52 +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 { getServerId } from "../support/helpers/server-id";
|
||||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
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 {
|
interface DirtyWorkspace {
|
||||||
id: string;
|
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 }) => {
|
test("Changes opens All changes for a clean checkout", async ({ page }) => {
|
||||||
const workspace = await createWorkspaceWithCommittedDiff();
|
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");
|
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 ({
|
test("Changes comparison controls the working diff and tree selection focuses its file", async ({
|
||||||
page,
|
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 ({
|
test("canvas headers keep a many-file diff bounded while scrolling end to end", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
test.setTimeout(180_000);
|
test.setTimeout(300_000);
|
||||||
const workspace = await createWorkspaceWithManyTinyDiffs(2_000);
|
const workspace = await createWorkspaceWithManyTinyDiffs(2_000);
|
||||||
await useUnwrappedDiffLines(page);
|
await useUnwrappedDiffLines(page);
|
||||||
await openWorkspaceChangesSurface(page, workspace, 90_000);
|
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 root = page.getByTestId("git-diff-canvas-root");
|
||||||
const scroller = page.getByTestId("git-diff-scroll");
|
const scroller = page.getByTestId("git-diff-scroll");
|
||||||
await expect(root.locator('[data-testid^="git-diff-sticky-header-"]')).toHaveCount(2);
|
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 };
|
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) => ({
|
const files = Array.from({ length: fileCount }, (_, index) => ({
|
||||||
path: `src/file-${String(index).padStart(4, "0")}.ts`,
|
path: `src/file-${String(index).padStart(4, "0")}.ts`,
|
||||||
content: "export const value = 0;\n",
|
content: "export const value = 0;\n",
|
||||||
}));
|
}));
|
||||||
const repo = await createTempGitRepo("changes-canvas-many-files-", { files });
|
const repo = await createTempGitRepo("changes-canvas-many-files-", { files });
|
||||||
|
if (branch !== "main") execFileSync("git", ["branch", "-m", branch], { cwd: repo.path });
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
files.map((file, index) =>
|
files.map((file, index) =>
|
||||||
writeFile(path.join(repo.path, file.path), `export const value = ${index + 1};\n`),
|
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 };
|
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> {
|
async function expectExplorerWidthSurvivesReload(page: Page, width: number): Promise<void> {
|
||||||
await page.reload();
|
await page.reload();
|
||||||
const explorer = page.getByTestId("workspace-explorer-sidebar");
|
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 runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||||
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
|
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
|
||||||
|
await expectCleanChangesLoaded(page);
|
||||||
// Verify that direct pane renders are being counted before resetting them.
|
// Verify that direct pane renders are being counted before resetting them.
|
||||||
expect(
|
expect(
|
||||||
await page.evaluate(() =>
|
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 { QueryClient, QueryObserver, skipToken } from "@tanstack/react-query";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { MutableDaemonConfig, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
import type { MutableDaemonConfig, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||||
@@ -144,10 +144,21 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
|
|||||||
["status", "providers_snapshot_update"],
|
["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}`;
|
const subscriptionId = `server-diff-${subscribeCheckoutDiffCalls.length + 1}`;
|
||||||
subscribeCheckoutDiffCalls.push({ cwd, compare, subscriptionId });
|
subscribeCheckoutDiffCalls.push({ cwd, compare, subscriptionId });
|
||||||
const handle = observe(
|
const handle = observe<SubscribeCheckoutDiffResponseMessage["payload"]>(
|
||||||
{ subscriptionId, cwd, files: [], error: null, requestId: "subscribe-checkout-diff" },
|
{ subscriptionId, cwd, files: [], error: null, requestId: "subscribe-checkout-diff" },
|
||||||
["checkout_diff_update", "subscribe_checkout_diff_response"],
|
["checkout_diff_update", "subscribe_checkout_diff_response"],
|
||||||
() => unsubscribeCheckoutDiffCalls.push(subscriptionId),
|
() => unsubscribeCheckoutDiffCalls.push(subscriptionId),
|
||||||
@@ -155,9 +166,43 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
|
|||||||
"subscriptionId" in message.payload &&
|
"subscriptionId" in message.payload &&
|
||||||
message.payload.subscriptionId === subscriptionId,
|
message.payload.subscriptionId === subscriptionId,
|
||||||
);
|
);
|
||||||
if (!config.rejectCheckoutDiffSubscribe) return handle;
|
const modern: OwnedSubscription<DiffState> = {
|
||||||
return {
|
|
||||||
...handle,
|
...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) => {
|
subscribe: (observer) => {
|
||||||
queueMicrotask(() => observer.error?.(new Error("subscribe failed")));
|
queueMicrotask(() => observer.error?.(new Error("subscribe failed")));
|
||||||
return () => {};
|
return () => {};
|
||||||
@@ -328,7 +373,7 @@ describe("server data push router", () => {
|
|||||||
const first = publish(files, "first");
|
const first = publish(files, "first");
|
||||||
const same = publish(structuredClone(files), "reopen");
|
const same = publish(structuredClone(files), "reopen");
|
||||||
expect(same.files).toBe(first.files);
|
expect(same.files).toBe(first.files);
|
||||||
expect(same.requestId).toBe("reopen");
|
expect(same.requestId).toBe("diff:1:ready");
|
||||||
const changed = structuredClone(files);
|
const changed = structuredClone(files);
|
||||||
changed[0]!.hunks[0]!.lines[0]!.tokens![0]!.style = "variable";
|
changed[0]!.hunks[0]!.lines[0]!.tokens![0]!.style = "variable";
|
||||||
const next = publish(changed, "edit");
|
const next = publish(changed, "edit");
|
||||||
@@ -430,7 +475,7 @@ describe("server data push router", () => {
|
|||||||
files: [],
|
files: [],
|
||||||
error: null,
|
error: null,
|
||||||
diffTooLarge: true,
|
diffTooLarge: true,
|
||||||
requestId: "diff-1",
|
requestId: "diff:1:ready",
|
||||||
});
|
});
|
||||||
|
|
||||||
fake.emit({
|
fake.emit({
|
||||||
@@ -449,7 +494,7 @@ describe("server data push router", () => {
|
|||||||
files: [],
|
files: [],
|
||||||
error: null,
|
error: null,
|
||||||
diffTooLarge: true,
|
diffTooLarge: true,
|
||||||
requestId: `subscription:${serverSubscriptionId}`,
|
requestId: "diff:2:ready",
|
||||||
});
|
});
|
||||||
|
|
||||||
unsubscribeObserver();
|
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 { OwnedSubscription } from "@getpaseo/client";
|
||||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||||
import type { Query, QueryCacheNotifyEvent, QueryClient, QueryKey } from "@tanstack/react-query";
|
import type { Query, QueryCacheNotifyEvent, QueryClient, QueryKey } from "@tanstack/react-query";
|
||||||
@@ -23,14 +25,9 @@ type ProvidersSnapshotUpdateMessage = Extract<
|
|||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
{ type: "providers_snapshot_update" }
|
{ type: "providers_snapshot_update" }
|
||||||
>;
|
>;
|
||||||
type SubscribeCheckoutDiffResponseMessage = Extract<
|
|
||||||
SessionOutboundMessage,
|
|
||||||
{ type: "subscribe_checkout_diff_response" }
|
|
||||||
>;
|
|
||||||
type StatusMessage = Extract<SessionOutboundMessage, { type: "status" }>;
|
type StatusMessage = Extract<SessionOutboundMessage, { type: "status" }>;
|
||||||
type TerminalsChangedMessage = Extract<SessionOutboundMessage, { type: "terminals_changed" }>;
|
type TerminalsChangedMessage = Extract<SessionOutboundMessage, { type: "terminals_changed" }>;
|
||||||
type CheckoutDiffResponsePayload = SubscribeCheckoutDiffResponseMessage["payload"];
|
type CheckoutDiffCachePayload = CheckoutDiffSnapshot;
|
||||||
type CheckoutDiffCachePayload = Omit<CheckoutDiffResponsePayload, "subscriptionId">;
|
|
||||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||||
|
|
||||||
interface CheckoutDiffCompare {
|
interface CheckoutDiffCompare {
|
||||||
@@ -49,7 +46,7 @@ interface CheckoutDiffRoute {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface CheckoutDiffRegistration extends CheckoutDiffRoute {
|
interface CheckoutDiffRegistration extends CheckoutDiffRoute {
|
||||||
subscription: OwnedSubscription<CheckoutDiffResponsePayload>;
|
subscription: OwnedSubscription<DiffState>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkspaceTerminalsRoute {
|
interface WorkspaceTerminalsRoute {
|
||||||
@@ -75,7 +72,7 @@ export type ProvidersSnapshotUpdate = ProvidersSnapshotUpdateMessage;
|
|||||||
interface ServerDataPushClient {
|
interface ServerDataPushClient {
|
||||||
getProvidersSnapshot: import("@getpaseo/client/internal/daemon-client").DaemonClient["getProvidersSnapshot"];
|
getProvidersSnapshot: import("@getpaseo/client/internal/daemon-client").DaemonClient["getProvidersSnapshot"];
|
||||||
observeEvents: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeEvents"];
|
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"];
|
observeTerminals: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeTerminals"];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,34 +336,34 @@ function reconcileCheckoutDiffSubscriptions(input: {
|
|||||||
}
|
}
|
||||||
for (const [key, desired] of input.desired) {
|
for (const [key, desired] of input.desired) {
|
||||||
if (input.active.has(key)) continue;
|
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 };
|
const registration = { ...desired, subscription };
|
||||||
input.active.set(key, registration);
|
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({
|
subscription.subscribe({
|
||||||
snapshot: apply,
|
snapshot: (state) => {
|
||||||
update: (message) => {
|
if (input.active.get(key) !== registration) return;
|
||||||
if (message.type === "checkout_diff_update") apply(message.payload);
|
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) => {
|
error: (error) => {
|
||||||
if (input.active.get(key) === registration) input.active.delete(key);
|
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,
|
serverId: input.serverId,
|
||||||
cwd: desired.cwd,
|
cwd: desired.cwd,
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ import {
|
|||||||
type DiffTreeRow,
|
type DiffTreeRow,
|
||||||
} from "@/git/diff-tree";
|
} from "@/git/diff-tree";
|
||||||
import { FileHeader } from "@/git/file-header";
|
import { FileHeader } from "@/git/file-header";
|
||||||
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
import type { DiffFileHeader } from "@/git/diff-snapshot";
|
||||||
|
|
||||||
export interface ChangedFilesTreeProps {
|
export interface ChangedFilesTreeProps {
|
||||||
files: ParsedDiffFile[];
|
files: DiffFileHeader[];
|
||||||
mode: WorkingDiffMode;
|
mode: WorkingDiffMode;
|
||||||
onSelectFile: (path: string) => void;
|
onSelectFile: (path: string) => void;
|
||||||
collapsedFolderPaths: string[];
|
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
|
// chains, so it is skipped; the collapsed set is empty because the flat list
|
||||||
// always carries every file, whatever the rail has collapsed.
|
// always carries every file, whatever the rail has collapsed.
|
||||||
return flattenDiffTree(buildDiffTree(files), new Set()).flatMap((row) =>
|
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 { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { floatingActionsClearance } from "@/components/ui/floating-action-button";
|
import { floatingActionsClearance } from "@/components/ui/floating-action-button";
|
||||||
import { useState, useCallback, useMemo, type ReactElement, type ReactNode } from "react";
|
import { useState, useCallback, useMemo, type ReactElement, type ReactNode } from "react";
|
||||||
@@ -1095,6 +1096,7 @@ interface DiffBodyContentProps {
|
|||||||
emptyMessage: string;
|
emptyMessage: string;
|
||||||
emptyAction: ChangesEmptyAction | null;
|
emptyAction: ChangesEmptyAction | null;
|
||||||
children: ReactElement;
|
children: ReactElement;
|
||||||
|
loadingLabel: string;
|
||||||
checkingRepositoryLabel: string;
|
checkingRepositoryLabel: string;
|
||||||
notRepositoryLabel: string;
|
notRepositoryLabel: string;
|
||||||
}
|
}
|
||||||
@@ -1111,11 +1113,16 @@ function DiffBodyContent({
|
|||||||
emptyAction,
|
emptyAction,
|
||||||
children,
|
children,
|
||||||
checkingRepositoryLabel,
|
checkingRepositoryLabel,
|
||||||
|
loadingLabel,
|
||||||
notRepositoryLabel,
|
notRepositoryLabel,
|
||||||
}: DiffBodyContentProps) {
|
}: DiffBodyContentProps) {
|
||||||
if (isStatusLoading) {
|
if (isStatusLoading) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.loadingContainer}>
|
<View
|
||||||
|
style={styles.loadingContainer}
|
||||||
|
accessibilityRole="progressbar"
|
||||||
|
accessibilityLabel={loadingLabel}
|
||||||
|
>
|
||||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedIconColorMapping} />
|
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedIconColorMapping} />
|
||||||
<Text style={styles.loadingText}>{checkingRepositoryLabel}</Text>
|
<Text style={styles.loadingText}>{checkingRepositoryLabel}</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -1137,12 +1144,16 @@ function DiffBodyContent({
|
|||||||
}
|
}
|
||||||
if (isDiffLoading) {
|
if (isDiffLoading) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.loadingContainer}>
|
<View
|
||||||
|
style={styles.loadingContainer}
|
||||||
|
accessibilityRole="progressbar"
|
||||||
|
accessibilityLabel={loadingLabel}
|
||||||
|
>
|
||||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedIconColorMapping} />
|
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedIconColorMapping} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (diffTooLarge) {
|
if (diffTooLarge && !hasChanges) {
|
||||||
return <DiffTooLargeState />;
|
return <DiffTooLargeState />;
|
||||||
}
|
}
|
||||||
if (diffErrorMessage) {
|
if (diffErrorMessage) {
|
||||||
@@ -1321,7 +1332,7 @@ function ChangesTreeRail({
|
|||||||
}: {
|
}: {
|
||||||
shown: boolean;
|
shown: boolean;
|
||||||
children: ReactElement;
|
children: ReactElement;
|
||||||
files: ParsedDiffFile[];
|
files: DiffFileHeader[];
|
||||||
mode: WorkingDiffMode;
|
mode: WorkingDiffMode;
|
||||||
onSelectFile: (path: string) => void;
|
onSelectFile: (path: string) => void;
|
||||||
treeWidth?: number;
|
treeWidth?: number;
|
||||||
@@ -1361,7 +1372,7 @@ function ChangesBody({
|
|||||||
children: ReactElement;
|
children: ReactElement;
|
||||||
desktopTreeVisible: boolean;
|
desktopTreeVisible: boolean;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
files: ParsedDiffFile[];
|
files: DiffFileHeader[];
|
||||||
mode: WorkingDiffMode;
|
mode: WorkingDiffMode;
|
||||||
onSelectFile: (path: string) => void;
|
onSelectFile: (path: string) => void;
|
||||||
treeWidth?: number;
|
treeWidth?: number;
|
||||||
@@ -1586,6 +1597,7 @@ export function ChangesSurface({
|
|||||||
selectBase: handleSelectBase,
|
selectBase: handleSelectBase,
|
||||||
selectAll: handleSelectAll,
|
selectAll: handleSelectAll,
|
||||||
files,
|
files,
|
||||||
|
treeFiles,
|
||||||
diffPayloadError,
|
diffPayloadError,
|
||||||
diffTooLarge,
|
diffTooLarge,
|
||||||
isDiffLoading,
|
isDiffLoading,
|
||||||
@@ -1835,6 +1847,7 @@ export function ChangesSurface({
|
|||||||
hasChanges={hasChanges}
|
hasChanges={hasChanges}
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
emptyAction={emptyAction}
|
emptyAction={emptyAction}
|
||||||
|
loadingLabel={t("common.loading")}
|
||||||
checkingRepositoryLabel={t("workspace.git.diff.checkingRepository")}
|
checkingRepositoryLabel={t("workspace.git.diff.checkingRepository")}
|
||||||
notRepositoryLabel={t("workspace.git.diff.notRepository")}
|
notRepositoryLabel={t("workspace.git.diff.notRepository")}
|
||||||
>
|
>
|
||||||
@@ -1865,7 +1878,7 @@ export function ChangesSurface({
|
|||||||
presentation={presentation}
|
presentation={presentation}
|
||||||
desktopTreeVisible={desktopTreeVisible}
|
desktopTreeVisible={desktopTreeVisible}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
files={files}
|
files={treeFiles}
|
||||||
mode={workingMode}
|
mode={workingMode}
|
||||||
onSelectFile={handleSelectTreeFile}
|
onSelectFile={handleSelectTreeFile}
|
||||||
treeWidth={instanceState.treeWidth}
|
treeWidth={instanceState.treeWidth}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { replaceEqualDeep } from "@tanstack/react-query";
|
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. */
|
/** File identity owns text-model reuse. Never reconstruct the hunk/token graph. */
|
||||||
export function retainDiffFiles(
|
export function retainDiffFiles(
|
||||||
@@ -30,6 +31,7 @@ export function shareCheckoutDiff(previous: unknown, incoming: unknown): DiffPay
|
|||||||
error === old.error &&
|
error === old.error &&
|
||||||
old.cwd === next.cwd &&
|
old.cwd === next.cwd &&
|
||||||
old.requestId === next.requestId &&
|
old.requestId === next.requestId &&
|
||||||
|
old.entries === next.entries &&
|
||||||
old.diffTooLarge === next.diffTooLarge
|
old.diffTooLarge === next.diffTooLarge
|
||||||
? old
|
? old
|
||||||
: { ...next, files, error };
|
: { ...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
|
// renders. The tree renders on every form factor, consistent with the Files
|
||||||
// explorer.
|
// explorer.
|
||||||
//
|
//
|
||||||
@@ -17,7 +17,7 @@ import type { ParsedDiffFile } from "@/git/use-diff-query";
|
|||||||
|
|
||||||
export interface DiffTreeFileNode {
|
export interface DiffTreeFileNode {
|
||||||
kind: "file";
|
kind: "file";
|
||||||
file: ParsedDiffFile;
|
file: DiffFileHeader;
|
||||||
fileIndex: number;
|
fileIndex: number;
|
||||||
/** basename, e.g. "diff-pane.tsx" */
|
/** basename, e.g. "diff-pane.tsx" */
|
||||||
name: string;
|
name: string;
|
||||||
@@ -47,7 +47,7 @@ export interface DiffTreeFolderRow {
|
|||||||
|
|
||||||
export interface DiffTreeFileRow {
|
export interface DiffTreeFileRow {
|
||||||
kind: "file";
|
kind: "file";
|
||||||
file: ParsedDiffFile;
|
file: DiffFileHeader;
|
||||||
fileIndex: number;
|
fileIndex: number;
|
||||||
depth: number;
|
depth: number;
|
||||||
}
|
}
|
||||||
@@ -73,7 +73,7 @@ function sortTree(node: DiffTreeDirNode): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Build the (uncompressed) directory tree. Returns the virtual root (dirPath ""). */
|
/** 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 root: DiffTreeDirNode = { kind: "dir", dirPath: "", name: "", children: [] };
|
||||||
const dirByPath = new Map<string, DiffTreeDirNode>([["", root]]);
|
const dirByPath = new Map<string, DiffTreeDirNode>([["", root]]);
|
||||||
|
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ import {
|
|||||||
fileNameForPath,
|
fileNameForPath,
|
||||||
formatDiffCount,
|
formatDiffCount,
|
||||||
} from "@/git/file-header-presentation";
|
} 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";
|
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||||
|
|
||||||
export interface FileHeaderProps {
|
export interface FileHeaderProps {
|
||||||
file: ParsedDiffFile;
|
file: DiffFileHeader;
|
||||||
workspaceFileDragScope?: { serverId: string; workspaceId: string };
|
workspaceFileDragScope?: { serverId: string; workspaceId: string };
|
||||||
bodyVisible: boolean;
|
bodyVisible: boolean;
|
||||||
showsBodyState?: boolean;
|
showsBodyState?: boolean;
|
||||||
@@ -73,7 +73,7 @@ function expandedAriaValue(showsBodyState: boolean, bodyVisible: boolean): boole
|
|||||||
return showsBodyState ? bodyVisible : undefined;
|
return showsBodyState ? bodyVisible : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fileHeaderAccessibilityLabel(canvasRendered: boolean, file: ParsedDiffFile) {
|
function fileHeaderAccessibilityLabel(canvasRendered: boolean, file: DiffFileHeader) {
|
||||||
if (!canvasRendered) return undefined;
|
if (!canvasRendered) return undefined;
|
||||||
return `${file.path}, +${formatDiffCount(file.additions)}, -${formatDiffCount(file.deletions)}`;
|
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);
|
return diffFileChangeKind(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||||
import { deriveCheckoutDiffResult } from "./use-diff-query";
|
import { deriveCheckoutDiffResult } from "./diff-snapshot";
|
||||||
|
|
||||||
function createParsedDiffFile(path: string): ParsedDiffFile {
|
function createParsedDiffFile(path: string): ParsedDiffFile {
|
||||||
return {
|
return {
|
||||||
@@ -59,6 +59,7 @@ describe("deriveCheckoutDiffResult", () => {
|
|||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
files: [],
|
files: [],
|
||||||
|
entries: null,
|
||||||
payloadError: error,
|
payloadError: error,
|
||||||
diffTooLarge: true,
|
diffTooLarge: true,
|
||||||
isLoading: false,
|
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 { useReplicaQuery } from "@/data/query";
|
||||||
import { checkoutDiffPushRoute } from "@/data/push-router";
|
import { checkoutDiffPushRoute } from "@/data/push-router";
|
||||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
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";
|
import { checkoutDiffQueryKey } from "@/git/query-keys";
|
||||||
|
|
||||||
interface UseCheckoutDiffQueryOptions {
|
interface UseCheckoutDiffQueryOptions {
|
||||||
@@ -18,7 +24,7 @@ interface UseCheckoutDiffQueryOptions {
|
|||||||
queryScope?: string;
|
queryScope?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CheckoutDiffQueryPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
|
type CheckoutDiffQueryPayload = CheckoutDiffSnapshot;
|
||||||
|
|
||||||
// Re-export the canonical protocol type so all consumers share one definition.
|
// Re-export the canonical protocol type so all consumers share one definition.
|
||||||
export type { ParsedDiffFile };
|
export type { ParsedDiffFile };
|
||||||
@@ -77,7 +83,11 @@ export function useCheckoutDiffQuery({
|
|||||||
return normalizedScope ? [...comparisonKey, "scope", normalizedScope] : comparisonKey;
|
return normalizedScope ? [...comparisonKey, "scope", normalizedScope] : comparisonKey;
|
||||||
}, [serverId, cwd, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]);
|
}, [serverId, cwd, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]);
|
||||||
const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]);
|
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>({
|
const query = useReplicaQuery<CheckoutDiffQueryPayload>({
|
||||||
queryKey,
|
queryKey,
|
||||||
@@ -97,46 +107,13 @@ export function useCheckoutDiffQuery({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return deriveCheckoutDiffResult(
|
const boundaryError =
|
||||||
requiresUpdate && isConnected
|
requiresUpdate && isConnected
|
||||||
? {
|
? { code: "UNKNOWN" as const, message: "Update the host to view All changes" }
|
||||||
requestId: "unsupported",
|
: comparisonError;
|
||||||
cwd,
|
return deriveCheckoutDiffResult(
|
||||||
files: [],
|
boundaryError
|
||||||
error: { code: "UNKNOWN", message: "Update the host to view All changes" },
|
? { requestId: "unavailable", cwd, files: [], error: boundaryError }
|
||||||
}
|
|
||||||
: (query.data ?? null),
|
: (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,
|
useReviewAttachmentSnapshot,
|
||||||
useReviewFeedback,
|
useReviewFeedback,
|
||||||
} from "@/review";
|
} from "@/review";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { diffEntryHeaders } from "./diff-snapshot";
|
||||||
import { useCheckoutDiffQuery } from "@/git/use-diff-query";
|
import { useCheckoutDiffQuery } from "@/git/use-diff-query";
|
||||||
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||||
import { useWorkingDiffComparison } from "@/git/working-diff-comparison";
|
import { useWorkingDiffComparison } from "@/git/working-diff-comparison";
|
||||||
@@ -44,7 +46,11 @@ export function useWorkingDiff({
|
|||||||
const statusErrorMessage =
|
const statusErrorMessage =
|
||||||
status?.error?.message ??
|
status?.error?.message ??
|
||||||
(isStatusError && statusError instanceof Error ? statusError.message : null);
|
(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 =
|
const currentBranchName =
|
||||||
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null;
|
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null;
|
||||||
|
|
||||||
@@ -59,6 +65,7 @@ export function useWorkingDiff({
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
files,
|
files,
|
||||||
|
entries,
|
||||||
payloadError: diffPayloadError,
|
payloadError: diffPayloadError,
|
||||||
diffTooLarge,
|
diffTooLarge,
|
||||||
isLoading: isDiffLoading,
|
isLoading: isDiffLoading,
|
||||||
@@ -71,6 +78,7 @@ export function useWorkingDiff({
|
|||||||
enabled: enabled && isGit,
|
enabled: enabled && isGit,
|
||||||
queryScope,
|
queryScope,
|
||||||
});
|
});
|
||||||
|
const treeFiles = useMemo(() => (entries ? diffEntryHeaders(entries) : files), [entries, files]);
|
||||||
const reviewDraftKey = useMemo(
|
const reviewDraftKey = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildReviewDraftKey({
|
buildReviewDraftKey({
|
||||||
@@ -112,6 +120,7 @@ export function useWorkingDiff({
|
|||||||
selectBase,
|
selectBase,
|
||||||
selectAll,
|
selectAll,
|
||||||
files,
|
files,
|
||||||
|
treeFiles,
|
||||||
diffPayloadError,
|
diffPayloadError,
|
||||||
diffTooLarge,
|
diffTooLarge,
|
||||||
isDiffLoading,
|
isDiffLoading,
|
||||||
|
|||||||
@@ -45,7 +45,8 @@
|
|||||||
"prepack": "npm run build:clean",
|
"prepack": "npm run build:clean",
|
||||||
"typecheck": "tsgo --noEmit",
|
"typecheck": "tsgo --noEmit",
|
||||||
"typecheck:examples": "tsgo -p tsconfig.examples.json --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": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.15.2",
|
"@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 {
|
import {
|
||||||
ChunkedTransport,
|
ChunkedTransport,
|
||||||
ChunkedTransportProtocolError,
|
ChunkedTransportProtocolError,
|
||||||
@@ -1411,6 +1417,20 @@ export class DaemonClient {
|
|||||||
private connectionFailure: Error | undefined;
|
private connectionFailure: Error | undefined;
|
||||||
private authFailureReasonValue: DaemonAuthFailureReason | null = null;
|
private authFailureReasonValue: DaemonAuthFailureReason | null = null;
|
||||||
private connectionState: ConnectionState = { status: "idle" };
|
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 readonly terminalStreams = new TerminalStreamRouter();
|
||||||
private pendingBinaryFileReads = new Map<string, PendingBinaryFileRead>();
|
private pendingBinaryFileReads = new Map<string, PendingBinaryFileRead>();
|
||||||
private activeBinaryFileTransfers = new Map<string, BinaryFileTransferState>();
|
private activeBinaryFileTransfers = new Map<string, BinaryFileTransferState>();
|
||||||
@@ -1760,6 +1780,7 @@ export class DaemonClient {
|
|||||||
this.disposeTransport(1000, "Client closed");
|
this.disposeTransport(1000, "Client closed");
|
||||||
this.providerSnapshotUpdates.clear();
|
this.providerSnapshotUpdates.clear();
|
||||||
this.clearWaiters(new Error("Daemon client closed"));
|
this.clearWaiters(new Error("Daemon client closed"));
|
||||||
|
await this.diffStreams.close();
|
||||||
await this.owned.close();
|
await this.owned.close();
|
||||||
this.creations.close();
|
this.creations.close();
|
||||||
this.rejectPendingSendQueue(new Error("Daemon client closed"));
|
this.rejectPendingSendQueue(new Error("Daemon client closed"));
|
||||||
@@ -2663,7 +2684,12 @@ export class DaemonClient {
|
|||||||
private observe<T extends CorrelatedResponseType>(
|
private observe<T extends CorrelatedResponseType>(
|
||||||
responseType: T,
|
responseType: T,
|
||||||
message: { type: SessionInboundMessage["type"] } & Record<string, unknown>,
|
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>> {
|
): OwnedSubscription<CorrelatedResponsePayload<T>> {
|
||||||
let resetSource = false;
|
let resetSource = false;
|
||||||
return this.owned.observeRequest<CorrelatedResponsePayload<T>>(
|
return this.owned.observeRequest<CorrelatedResponsePayload<T>>(
|
||||||
@@ -2689,12 +2715,16 @@ export class DaemonClient {
|
|||||||
requestType: message.type,
|
requestType: message.type,
|
||||||
error: payload.error,
|
error: payload.error,
|
||||||
});
|
});
|
||||||
|
options?.validateSnapshot?.(payload);
|
||||||
accept(payload);
|
accept(payload);
|
||||||
return payload;
|
return payload;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
resetSource = !(error instanceof DaemonRpcError) && this.isConnected;
|
resetSource =
|
||||||
|
!(error instanceof DaemonRpcError) &&
|
||||||
|
!(error instanceof DiffSubscriptionError) &&
|
||||||
|
this.isConnected;
|
||||||
throw error;
|
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(
|
observeCheckoutDiff(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
compare: { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean },
|
compare: { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean },
|
||||||
@@ -6936,6 +6986,7 @@ export class DaemonClient {
|
|||||||
this.chunkedTransport?.close();
|
this.chunkedTransport?.close();
|
||||||
this.chunkedTransport = null;
|
this.chunkedTransport = null;
|
||||||
this.owned.disconnected();
|
this.owned.disconnected();
|
||||||
|
this.diffStreams.disconnected();
|
||||||
this.providerSnapshotUpdates.clear();
|
this.providerSnapshotUpdates.clear();
|
||||||
this.stopLivenessHeartbeat();
|
this.stopLivenessHeartbeat();
|
||||||
this.cleanupTransport();
|
this.cleanupTransport();
|
||||||
@@ -7126,6 +7177,10 @@ export class DaemonClient {
|
|||||||
for (const handler of this.hostFrameListeners) handler(hostFrame);
|
for (const handler of this.hostFrameListeners) handler(hostFrame);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (rawBytes[0] >= 0x40 && rawBytes[0] <= 0x4f) {
|
||||||
|
this.diffStreams.receive(rawBytes);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (isPluginFrameOpcode(rawBytes[0])) {
|
if (isPluginFrameOpcode(rawBytes[0])) {
|
||||||
this.handlePluginFrame(rawBytes);
|
this.handlePluginFrame(rawBytes);
|
||||||
return true;
|
return true;
|
||||||
@@ -7326,6 +7381,7 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.owned.disconnected();
|
this.owned.disconnected();
|
||||||
|
this.diffStreams.disconnected();
|
||||||
this.providerSnapshotUpdates.clear();
|
this.providerSnapshotUpdates.clear();
|
||||||
|
|
||||||
// Clear all pending waiters and queued sends since the connection was lost
|
// Clear all pending waiters and queued sends since the connection was lost
|
||||||
@@ -7485,6 +7541,7 @@ export class DaemonClient {
|
|||||||
this.reconnectAttempt = 0;
|
this.reconnectAttempt = 0;
|
||||||
this.updateConnectionState({ status: "connected" }, { event: "HELLO_SERVER_INFO" });
|
this.updateConnectionState({ status: "connected" }, { event: "HELLO_SERVER_INFO" });
|
||||||
this.startLivenessHeartbeat();
|
this.startLivenessHeartbeat();
|
||||||
|
this.diffStreams.connected(serverInfo.features?.diffSubscription === true);
|
||||||
this.owned.restore(serverInfo, {
|
this.owned.restore(serverInfo, {
|
||||||
...DEFAULT_CLIENT_CAPABILITIES,
|
...DEFAULT_CLIENT_CAPABILITIES,
|
||||||
...this.config.capabilities,
|
...this.config.capabilities,
|
||||||
@@ -7761,3 +7818,5 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon
|
|||||||
cwd: merged.cwd,
|
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);
|
: Math.random().toString(36).slice(2);
|
||||||
return `paseo-sdk-${randomId}`;
|
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 { HOST_ROUTE_OPCODE, decodeHostRouteFrame, type HostRouteFrame } from "./host-route.js";
|
||||||
import {
|
import {
|
||||||
decodeFileTransferFrame,
|
decodeFileTransferFrame,
|
||||||
@@ -12,12 +13,20 @@ import {
|
|||||||
} from "./terminal.js";
|
} from "./terminal.js";
|
||||||
|
|
||||||
export type BinaryFrame =
|
export type BinaryFrame =
|
||||||
|
| { kind: "diff"; frame: DiffFrame }
|
||||||
| { kind: "host_route"; frame: HostRouteFrame }
|
| { kind: "host_route"; frame: HostRouteFrame }
|
||||||
| { kind: "terminal"; frame: TerminalStreamFrame }
|
| { kind: "terminal"; frame: TerminalStreamFrame }
|
||||||
| { kind: "file_transfer"; frame: FileTransferFrame }
|
| { kind: "file_transfer"; frame: FileTransferFrame }
|
||||||
| { kind: "plugin"; frame: PluginFrame };
|
| { kind: "plugin"; frame: PluginFrame };
|
||||||
|
|
||||||
export function decodeBinaryFrame(bytes: Uint8Array): BinaryFrame | null {
|
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]) {
|
switch (bytes[0]) {
|
||||||
case HOST_ROUTE_OPCODE: {
|
case HOST_ROUTE_OPCODE: {
|
||||||
const frame = decodeHostRouteFrame(bytes);
|
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 "./terminal.js";
|
||||||
export * from "./host-route.js";
|
export * from "./host-route.js";
|
||||||
export * from "./plugin.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(),
|
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({
|
export const CheckoutStatusRequestSchema = z.object({
|
||||||
type: z.literal("checkout_status_request"),
|
type: z.literal("checkout_status_request"),
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
@@ -3428,6 +3473,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
AgentDetachRequestMessageSchema,
|
AgentDetachRequestMessageSchema,
|
||||||
AgentRewindRequestMessageSchema,
|
AgentRewindRequestMessageSchema,
|
||||||
AgentPermissionResponseMessageSchema,
|
AgentPermissionResponseMessageSchema,
|
||||||
|
DiffSubscribeRequestSchema,
|
||||||
|
DiffUnsubscribeRequestSchema,
|
||||||
CheckoutStatusRequestSchema,
|
CheckoutStatusRequestSchema,
|
||||||
CheckoutDiffGetRequestSchema,
|
CheckoutDiffGetRequestSchema,
|
||||||
SubscribeCheckoutDiffRequestSchema,
|
SubscribeCheckoutDiffRequestSchema,
|
||||||
@@ -3887,6 +3934,7 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
selectiveAgentTimeline: z.boolean().optional(),
|
selectiveAgentTimeline: z.boolean().optional(),
|
||||||
explicitEventSubscriptions: z.boolean().optional(),
|
explicitEventSubscriptions: z.boolean().optional(),
|
||||||
ownedSubscriptions: z.boolean().optional(),
|
ownedSubscriptions: z.boolean().optional(),
|
||||||
|
diffSubscription: z.boolean().optional(),
|
||||||
binaryChunkingV1: z.boolean().optional(),
|
binaryChunkingV1: z.boolean().optional(),
|
||||||
workspaceSetupObservation: z.boolean().optional(),
|
workspaceSetupObservation: z.boolean().optional(),
|
||||||
workspaceSortTimes: z.boolean().optional(),
|
workspaceSortTimes: z.boolean().optional(),
|
||||||
@@ -5428,6 +5476,7 @@ const CheckoutStatusCommonSchema = z.object({
|
|||||||
error: CheckoutErrorSchema.nullable(),
|
error: CheckoutErrorSchema.nullable(),
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
baseRefExact: z.string().nullable().optional(),
|
baseRefExact: z.string().nullable().optional(),
|
||||||
|
comparisonBaseRef: z.string().nullable().optional(),
|
||||||
// The full ref currentBranch tracks, as git resolves `<branch>@{upstream}`:
|
// The full ref currentBranch tracks, as git resolves `<branch>@{upstream}`:
|
||||||
// "refs/remotes/origin/main", "refs/remotes/upstream/main" on a fork, or a
|
// "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
|
// "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,
|
AgentDeletedMessageSchema,
|
||||||
AgentArchivedMessageSchema,
|
AgentArchivedMessageSchema,
|
||||||
CloseItemsResponseSchema,
|
CloseItemsResponseSchema,
|
||||||
|
DiffSubscribeResponseSchema,
|
||||||
CheckoutStatusResponseSchema,
|
CheckoutStatusResponseSchema,
|
||||||
CheckoutStatusUpdateSchema,
|
CheckoutStatusUpdateSchema,
|
||||||
CheckoutDiffGetResponseSchema,
|
CheckoutDiffGetResponseSchema,
|
||||||
|
|||||||
@@ -102,7 +102,8 @@
|
|||||||
"test:e2e:real": "npm run test:integration:real",
|
"test:e2e:real": "npm run test:integration:real",
|
||||||
"test:e2e:local": "npm run test:integration:local",
|
"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: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": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@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_pop_request: "workspace.write",
|
||||||
stash_save_request: "workspace.write",
|
stash_save_request: "workspace.write",
|
||||||
"checkout.diff.get.request": "workspace.read",
|
"checkout.diff.get.request": "workspace.read",
|
||||||
|
"diff.subscribe.request": "workspace.read",
|
||||||
|
"diff.unsubscribe.request": "workspace.read",
|
||||||
subscribe_checkout_diff_request: "workspace.read",
|
subscribe_checkout_diff_request: "workspace.read",
|
||||||
subscribe_terminal_request: "workspace.read",
|
subscribe_terminal_request: "workspace.read",
|
||||||
subscribe_terminals_request: "workspace.read",
|
subscribe_terminals_request: "workspace.read",
|
||||||
@@ -437,6 +439,7 @@ const OUTBOUND_PERMISSION = {
|
|||||||
stash_save_response: "workspace.read",
|
stash_save_response: "workspace.read",
|
||||||
status: "daemon.read",
|
status: "daemon.read",
|
||||||
"checkout.diff.get.response": "workspace.read",
|
"checkout.diff.get.response": "workspace.read",
|
||||||
|
"diff.subscribe.response": "workspace.read",
|
||||||
subscribe_checkout_diff_response: "workspace.read",
|
subscribe_checkout_diff_response: "workspace.read",
|
||||||
subscribe_terminal_response: "workspace.read",
|
subscribe_terminal_response: "workspace.read",
|
||||||
"terminal.rename.response": "workspace.write",
|
"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 { 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(() => ({
|
async function withCheckout(
|
||||||
toCheckoutErrorMock: vi.fn((error: unknown) => ({
|
run: (input: {
|
||||||
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<{
|
|
||||||
cwd: string;
|
cwd: string;
|
||||||
onChange: () => void;
|
manager: CheckoutDiffManager;
|
||||||
unsubscribeCalls: number;
|
git(...args: string[]): string;
|
||||||
resolve(): void;
|
}) => Promise<void>,
|
||||||
}> = [];
|
): Promise<void> {
|
||||||
const workspaceGitService = {
|
const cwd = await mkdtemp(join(tmpdir(), "legacy-diff-adapter-"));
|
||||||
getCheckoutDiff: async () => ({ diff: "", structured: [] }),
|
const observer = createFileObserver();
|
||||||
getSnapshot: async () => createWorkspaceSnapshot(),
|
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
||||||
|
|
||||||
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: () => {} };
|
|
||||||
const manager = new CheckoutDiffManager({
|
const manager = new CheckoutDiffManager({
|
||||||
|
logger: pino({ level: "silent" }),
|
||||||
|
paseoHome: cwd,
|
||||||
workspaceRuntimeStore: createWorkspaceRuntimeStore(),
|
workspaceRuntimeStore: createWorkspaceRuntimeStore(),
|
||||||
logger: logger as unknown as pino.Logger,
|
workspaceGitService: {
|
||||||
paseoHome: "/tmp/paseo-test",
|
async requestWorkingTreeWatch(root, onChange) {
|
||||||
workspaceGitService,
|
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", () => {
|
test("legacy callers share one live comparison, receive edits, and release it", async () => {
|
||||||
beforeEach(() => {
|
await withCheckout(async ({ cwd, manager }) => {
|
||||||
vi.useFakeTimers();
|
await mkdir(join(cwd, "sub"));
|
||||||
toCheckoutErrorMock.mockClear();
|
await writeFile(join(cwd, "x.ts"), "const value = 1;\n");
|
||||||
});
|
const first: CheckoutDiffSnapshotPayload[] = [];
|
||||||
|
const second: CheckoutDiffSnapshotPayload[] = [];
|
||||||
afterEach(() => {
|
const [a, b] = await Promise.all([
|
||||||
vi.useRealTimers();
|
manager.subscribe({ cwd: join(cwd, "sub"), compare: { mode: "uncommitted" } }, (snapshot) =>
|
||||||
});
|
first.push(snapshot),
|
||||||
|
|
||||||
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 };
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
getSnapshot: vi.fn(async () => createWorkspaceSnapshot()),
|
manager.subscribe({ cwd, compare: { mode: "uncommitted" } }, (snapshot) =>
|
||||||
getCheckoutDiff:
|
second.push(snapshot),
|
||||||
options?.getCheckoutDiffImplementation ?? vi.fn(async () => ({ diff: "", structured: [] })),
|
),
|
||||||
refresh: vi.fn(),
|
]);
|
||||||
scheduleRefreshForCwd: vi.fn(),
|
expect(a.initial.cwd).toBe(join(cwd, "sub"));
|
||||||
requestWorkingTreeWatch: mockRequestWorkingTreeWatch,
|
expect(a.initial.files).toEqual(b.initial.files);
|
||||||
dispose: vi.fn(),
|
expect(a.initial.files.map((file) => file.path)).toEqual(["x.ts"]);
|
||||||
};
|
expect(manager.getMetrics()).toMatchObject({
|
||||||
|
checkoutDiffTargetCount: 1,
|
||||||
const logger = {
|
checkoutDiffSubscriptionCount: 2,
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
|
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 {
|
test("Committed ignores working-tree edits while All and Uncommitted include them", async () => {
|
||||||
manager,
|
await withCheckout(async ({ cwd, manager, git }) => {
|
||||||
workspaceGitService,
|
git("checkout", "-b", "feature");
|
||||||
mockRequestWorkingTreeWatch,
|
await writeFile(join(cwd, "x.ts"), "const value = 1;\n");
|
||||||
unsubscribe,
|
git("add", ".");
|
||||||
getOnChange: () => onChange,
|
git("-c", "commit.gpgsign=false", "commit", "-m", "feature");
|
||||||
getOnWorkspaceSnapshot: () => onWorkspaceSnapshot,
|
await writeFile(join(cwd, "x.ts"), "const value = 2;\n");
|
||||||
workspaceUnsubscribe,
|
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(
|
||||||
test("subscribe requests a working tree watch with the correct cwd", async () => {
|
expect.objectContaining({ type: "add", content: "const value = 1;" }),
|
||||||
const { manager, mockRequestWorkingTreeWatch } = createManager();
|
|
||||||
|
|
||||||
await manager.subscribe(
|
|
||||||
{
|
|
||||||
cwd: "/tmp/repo/packages/server",
|
|
||||||
compare: { mode: "uncommitted" },
|
|
||||||
},
|
|
||||||
() => {},
|
|
||||||
);
|
);
|
||||||
|
expect(all.files[0].hunks[0].lines).toContainEqual(
|
||||||
expect(mockRequestWorkingTreeWatch).toHaveBeenCalledWith(
|
expect.objectContaining({ type: "add", content: "const value = 2;" }),
|
||||||
"/tmp/repo/packages/server",
|
);
|
||||||
expect.any(Function),
|
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 () => {
|
test("cancelling a legacy subscriber during a shared revision does not revive it", async () => {
|
||||||
const { manager, unsubscribe } = createManager();
|
await withCheckout(async ({ cwd, manager }) => {
|
||||||
|
await writeFile(join(cwd, "x.ts"), "export const value = 1;\n");
|
||||||
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();
|
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
|
let cancelled = false;
|
||||||
const pendingSubscription = manager.subscribe(
|
const modern = await manager.streams.subscribe(
|
||||||
{
|
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } },
|
||||||
cwd: "/tmp/repo/packages/server",
|
async (frame) => {
|
||||||
compare: { mode: "uncommitted" },
|
if (frame.opcode !== 0x40 || cancelled) return;
|
||||||
signal: abort.signal,
|
await waitForSubscriberCount(manager, 2);
|
||||||
|
cancelled = true;
|
||||||
|
abort.abort();
|
||||||
},
|
},
|
||||||
() => {},
|
|
||||||
);
|
);
|
||||||
abort.abort();
|
const legacy = manager.subscribe(
|
||||||
watches[0].resolve();
|
{ cwd, compare: { mode: "uncommitted" }, signal: abort.signal },
|
||||||
await pendingSubscription;
|
() => {
|
||||||
|
throw new Error("Cancelled subscriber received an update");
|
||||||
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" },
|
|
||||||
},
|
},
|
||||||
() => {},
|
|
||||||
);
|
);
|
||||||
|
try {
|
||||||
expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledWith(
|
await modern.start();
|
||||||
"/tmp/repo",
|
const result = await legacy;
|
||||||
expect.objectContaining({ mode: "uncommitted", includeStructured: true }),
|
expect(result.initial).toMatchObject({
|
||||||
undefined,
|
files: [],
|
||||||
);
|
error: { message: "Diff subscription cancelled" },
|
||||||
});
|
|
||||||
|
|
||||||
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" }],
|
|
||||||
});
|
});
|
||||||
|
await result.unsubscribe();
|
||||||
const { manager, getOnChange } = createManager({
|
} finally {
|
||||||
getCheckoutDiffImplementation: getCheckoutDiff,
|
await modern.release();
|
||||||
});
|
|
||||||
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 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 () => {
|
async function waitForSubscriberCount(manager: CheckoutDiffManager, count: number): Promise<void> {
|
||||||
const getCheckoutDiff = vi.fn(async () => ({
|
await expect
|
||||||
diff: "",
|
.poll(() => manager.getMetrics().checkoutDiffSubscriptionCount, { timeout: 10_000 })
|
||||||
structured: [{ path: "committed.ts", additions: 1, deletions: 0, status: "modified" }],
|
.toBe(count);
|
||||||
}));
|
}
|
||||||
const { manager, getOnChange, mockRequestWorkingTreeWatch } = createManager({
|
|
||||||
getCheckoutDiffImplementation: getCheckoutDiff,
|
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",
|
||||||
});
|
});
|
||||||
|
git("update-ref", "refs/tags/v1", "HEAD");
|
||||||
await manager.subscribe(
|
await expect.poll(() => updates.at(-1)?.files[0]?.isNew, { timeout: 10000 }).toBe(false);
|
||||||
{ cwd: "/tmp/repo", compare: { mode: "base", baseRef: "main" } },
|
await subscription.unsubscribe();
|
||||||
vi.fn(),
|
|
||||||
);
|
|
||||||
getOnChange()?.();
|
|
||||||
await vi.advanceTimersByTimeAsync(150);
|
|
||||||
|
|
||||||
expect(getCheckoutDiff).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mockRequestWorkingTreeWatch).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
test("base diff subscriptions ignore worktree-only workspace snapshot updates", async () => {
|
test("legacy diffs retain successful bodies when an oversized sibling is removed", async () => {
|
||||||
const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: [] }));
|
await withCheckout(async ({ cwd, manager }) => {
|
||||||
const { manager, getOnWorkspaceSnapshot } = createManager({
|
await writeFile(join(cwd, "a.ts"), "const value = 1;\n");
|
||||||
getCheckoutDiffImplementation: getCheckoutDiff,
|
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;" })]),
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
subscription.unsubscribe();
|
||||||
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,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 pino from "pino";
|
||||||
import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js";
|
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 { expandTilde } from "../utils/path.js";
|
||||||
import { toCheckoutError } from "./checkout-git-utils.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 CheckoutDiffCompareInput = SubscribeCheckoutDiffRequest["compare"];
|
||||||
|
|
||||||
export type CheckoutDiffSnapshotPayload = Omit<
|
export type CheckoutDiffSnapshotPayload = Omit<
|
||||||
Extract<SessionOutboundMessage, { type: "checkout_diff_update" }>["payload"],
|
Extract<SessionOutboundMessage, { type: "checkout_diff_update" }>["payload"],
|
||||||
"subscriptionId"
|
"subscriptionId"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export interface CheckoutDiffMetrics {
|
export interface CheckoutDiffMetrics {
|
||||||
checkoutDiffTargetCount: number;
|
checkoutDiffTargetCount: number;
|
||||||
checkoutDiffSubscriptionCount: number;
|
checkoutDiffSubscriptionCount: number;
|
||||||
checkoutDiffWatcherCount: number;
|
checkoutDiffWatcherCount: number;
|
||||||
checkoutDiffFallbackRefreshTargetCount: 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 {
|
export interface CheckoutDiffSubscriptionRequest {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
compare: CheckoutDiffCompareInput;
|
compare: CheckoutDiffCompareInput;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CheckoutDiffSubscription {
|
export interface CheckoutDiffSubscription {
|
||||||
initial: CheckoutDiffSnapshotPayload;
|
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 {
|
export class CheckoutDiffManager {
|
||||||
private readonly workspaceGitService: CheckoutDiffWorkspace;
|
readonly streams: DiffSubscriptions;
|
||||||
private readonly workspaceRuntimeStore: WorkspaceRuntimeStore;
|
private readonly paseoHome: string;
|
||||||
private readonly targets = new Map<string, CheckoutDiffWatchTarget>();
|
|
||||||
|
|
||||||
constructor(options: {
|
constructor(options: {
|
||||||
logger: pino.Logger;
|
logger: pino.Logger;
|
||||||
paseoHome: string;
|
paseoHome: string;
|
||||||
workspaceGitService: CheckoutDiffWorkspace;
|
workspaceGitService: Pick<WorkspaceGitService, "requestWorkingTreeWatch">;
|
||||||
workspaceRuntimeStore: WorkspaceRuntimeStore;
|
workspaceRuntimeStore: WorkspaceRuntimeStore;
|
||||||
}) {
|
}) {
|
||||||
this.workspaceGitService = options.workspaceGitService;
|
this.streams = new DiffSubscriptions(options.workspaceGitService);
|
||||||
this.workspaceRuntimeStore = options.workspaceRuntimeStore;
|
this.paseoHome = options.paseoHome;
|
||||||
|
}
|
||||||
|
readCommitFile(input: CommitFileRead) {
|
||||||
|
return this.streams.readCommitFile(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
async read(
|
async read(
|
||||||
params: Omit<CheckoutDiffSubscriptionRequest, "signal">,
|
params: Omit<CheckoutDiffSubscriptionRequest, "signal">,
|
||||||
): Promise<CheckoutDiffSnapshotPayload> {
|
): 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(
|
async subscribe(
|
||||||
params: CheckoutDiffSubscriptionRequest,
|
params: CheckoutDiffSubscriptionRequest,
|
||||||
listener: (snapshot: CheckoutDiffSnapshotPayload) => void,
|
listener: (snapshot: CheckoutDiffSnapshotPayload) => void,
|
||||||
): Promise<CheckoutDiffSubscription> {
|
): Promise<CheckoutDiffSubscription> {
|
||||||
const cwd = params.cwd;
|
const cwd = expandTilde(params.cwd);
|
||||||
const compare = this.normalizeCompare(params.compare);
|
let resolveInitial!: (snapshot: CheckoutDiffSnapshotPayload) => void;
|
||||||
const target = this.ensureTarget(cwd, compare);
|
const initial = new Promise<CheckoutDiffSnapshotPayload>((resolve) => {
|
||||||
target.listeners.add(listener);
|
resolveInitial = resolve;
|
||||||
target.openPromise ??= this.openTarget(target);
|
});
|
||||||
|
let delivered = false;
|
||||||
let isSubscribed = true;
|
let pending: DiffBegin | null = null;
|
||||||
const unsubscribe = () => {
|
let files = new Map<string, ParsedDiffFile>();
|
||||||
if (!isSubscribed) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
isSubscribed = false;
|
if (!pending || frame.revision !== pending.revision) return;
|
||||||
params.signal?.removeEventListener("abort", unsubscribe);
|
if (frame.opcode === DiffOpcode.Bodies) {
|
||||||
this.removeListener(target, listener);
|
for (const body of frame.files) bodies.set(body.path, body);
|
||||||
};
|
return;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
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 {
|
getMetrics(): CheckoutDiffMetrics {
|
||||||
let checkoutDiffSubscriptionCount = 0;
|
const metrics = this.streams.getMetrics();
|
||||||
|
|
||||||
for (const target of this.targets.values()) {
|
|
||||||
checkoutDiffSubscriptionCount += target.listeners.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
checkoutDiffTargetCount: this.targets.size,
|
checkoutDiffTargetCount: metrics.targets,
|
||||||
checkoutDiffSubscriptionCount,
|
checkoutDiffSubscriptionCount: metrics.subscriptions,
|
||||||
checkoutDiffWatcherCount: 0,
|
checkoutDiffWatcherCount: metrics.metadataObservers,
|
||||||
checkoutDiffFallbackRefreshTargetCount: 0,
|
checkoutDiffFallbackRefreshTargetCount: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
async dispose(): Promise<void> {
|
||||||
dispose(): void {
|
await this.streams.close();
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
isDirty: snapshot.git.isDirty,
|
||||||
baseRef: snapshot.git.baseRef,
|
baseRef: snapshot.git.baseRef,
|
||||||
baseRefExact: snapshot.git.baseRefExact,
|
baseRefExact: snapshot.git.baseRefExact,
|
||||||
|
comparisonBaseRef: snapshot.git.comparisonBaseRef,
|
||||||
aheadBehind: snapshot.git.aheadBehind ?? null,
|
aheadBehind: snapshot.git.aheadBehind ?? null,
|
||||||
upstreamRef: snapshot.git.upstreamRef ?? null,
|
upstreamRef: snapshot.git.upstreamRef ?? null,
|
||||||
aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null,
|
aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null,
|
||||||
@@ -83,6 +84,7 @@ export function buildCheckoutStatusPayloadFromSnapshot({
|
|||||||
isDirty: snapshot.git.isDirty,
|
isDirty: snapshot.git.isDirty,
|
||||||
baseRef: snapshot.git.baseRef ?? null,
|
baseRef: snapshot.git.baseRef ?? null,
|
||||||
baseRefExact: snapshot.git.baseRefExact,
|
baseRefExact: snapshot.git.baseRefExact,
|
||||||
|
comparisonBaseRef: snapshot.git.comparisonBaseRef,
|
||||||
aheadBehind: snapshot.git.aheadBehind ?? null,
|
aheadBehind: snapshot.git.aheadBehind ?? null,
|
||||||
upstreamRef: snapshot.git.upstreamRef ?? null,
|
upstreamRef: snapshot.git.upstreamRef ?? null,
|
||||||
aheadOfOrigin: snapshot.git.aheadOfOrigin ?? 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 });
|
await rm(cwd, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||||
}
|
}
|
||||||
}, 60_000);
|
}, 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");
|
commitFile(cwd, "large-b.js", "const value = 0;\n");
|
||||||
// Short lines stay eligible for highlighting; a giant single line is skipped
|
// Short lines stay eligible for highlighting; a giant single line is skipped
|
||||||
// before token expansion and cannot exercise the structured-payload limit.
|
// 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-a.js"), denseExpression);
|
||||||
writeFileSync(path.join(cwd, "large-b.js"), denseExpression);
|
writeFileSync(path.join(cwd, "large-b.js"), denseExpression);
|
||||||
|
|
||||||
|
|||||||
@@ -690,6 +690,7 @@ describe("commit file diffs", () => {
|
|||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
file: {
|
file: {
|
||||||
path: filePath,
|
path: filePath,
|
||||||
|
status: "ok",
|
||||||
isNew: false,
|
isNew: false,
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
additions: 1,
|
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({
|
expect(await client.getCommitFileDiff(cwd, sha, "new.txt")).toEqual({
|
||||||
file: {
|
file: {
|
||||||
path: "new.txt",
|
path: "new.txt",
|
||||||
|
oldPath: "old.txt",
|
||||||
|
status: "ok",
|
||||||
isNew: false,
|
isNew: false,
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
additions: 0,
|
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", "hooks/pre-commit", { kind: "ignore" }],
|
||||||
["common", "logs/refs/heads/main", { kind: "ignore" }],
|
["common", "logs/refs/heads/main", { kind: "ignore" }],
|
||||||
["common", "objects/ab/cdef", { 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", "FETCH_HEAD", { kind: "ignore" }],
|
||||||
["common", "packed-refs.lock", { kind: "ignore" }],
|
["common", "packed-refs.lock", { kind: "ignore" }],
|
||||||
] as const)("classifies %s metadata path %s", (scope, path, expected) => {
|
] as const)("classifies %s metadata path %s", (scope, path, expected) => {
|
||||||
|
|||||||
@@ -73,6 +73,14 @@ export const GIT_METADATA_EVENT_RULES = [
|
|||||||
path: "refs/heads/",
|
path: "refs/heads/",
|
||||||
route: "local-ref",
|
route: "local-ref",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "tag",
|
||||||
|
scope: "common",
|
||||||
|
match: "prefix",
|
||||||
|
path: "refs/tags/",
|
||||||
|
route: "all",
|
||||||
|
refreshBase: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "remote-branch",
|
id: "remote-branch",
|
||||||
scope: "common",
|
scope: "common",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DiffSession } from "./diff/index.js";
|
||||||
import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||||
import type { AgentMessageDelivery } from "./agent/agent-messages/index.js";
|
import type { AgentMessageDelivery } from "./agent/agent-messages/index.js";
|
||||||
import { AgentDirectory, type BackgroundVisibility } from "./agent/directory.js";
|
import { AgentDirectory, type BackgroundVisibility } from "./agent/directory.js";
|
||||||
@@ -841,6 +842,7 @@ export class Session {
|
|||||||
private readonly workspaceDirectory: WorkspaceDirectory;
|
private readonly workspaceDirectory: WorkspaceDirectory;
|
||||||
private readonly voiceSessions: VoiceSessions;
|
private readonly voiceSessions: VoiceSessions;
|
||||||
private readonly checkoutSession: CheckoutSession;
|
private readonly checkoutSession: CheckoutSession;
|
||||||
|
private readonly diffSession: DiffSession;
|
||||||
private readonly scheduleSession: ScheduleSession;
|
private readonly scheduleSession: ScheduleSession;
|
||||||
private readonly providerCatalogSession: ProviderCatalogSession;
|
private readonly providerCatalogSession: ProviderCatalogSession;
|
||||||
private readonly usageSession: UsageSession;
|
private readonly usageSession: UsageSession;
|
||||||
@@ -1004,6 +1006,7 @@ export class Session {
|
|||||||
await this.workspaceProvisioning.ensureWorkspaceRecordUnarchived(workspace);
|
await this.workspaceProvisioning.ensureWorkspaceRecordUnarchived(workspace);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
this.diffSession = new DiffSession(checkoutDiffManager.streams, this.sessionLogger);
|
||||||
this.checkoutSession = new CheckoutSession({
|
this.checkoutSession = new CheckoutSession({
|
||||||
host: {
|
host: {
|
||||||
emit: (msg) => this.emit(msg),
|
emit: (msg) => this.emit(msg),
|
||||||
@@ -3128,6 +3131,10 @@ export class Session {
|
|||||||
// eslint-disable-next-line complexity
|
// eslint-disable-next-line complexity
|
||||||
private dispatchCheckoutMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
private dispatchCheckoutMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
||||||
switch (msg.type) {
|
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":
|
case "checkout_status_request":
|
||||||
return this.checkoutSession.handleStatusRequest(msg);
|
return this.checkoutSession.handleStatusRequest(msg);
|
||||||
case "checkout.commits.list.request":
|
case "checkout.commits.list.request":
|
||||||
@@ -3437,7 +3444,9 @@ export class Session {
|
|||||||
await this.workspaceFilesSession.handleFileTransferFrame(binaryFrame.frame, source);
|
await this.workspaceFilesSession.handleFileTransferFrame(binaryFrame.frame, source);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.terminalController.handleBinaryFrame(binaryFrame.frame, source);
|
if (binaryFrame.kind === "terminal") {
|
||||||
|
this.terminalController.handleBinaryFrame(binaryFrame.frame, source);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleRestartServerRequest(
|
private async handleRestartServerRequest(
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ function createFakeDiffSubscriber(initial: CheckoutDiffSnapshotPayload) {
|
|||||||
const subscriptions: FakeDiffSubscription[] = [];
|
const subscriptions: FakeDiffSubscription[] = [];
|
||||||
const refreshedCwds: string[] = [];
|
const refreshedCwds: string[] = [];
|
||||||
const subscriber: CheckoutDiffSubscriber = {
|
const subscriber: CheckoutDiffSubscriber = {
|
||||||
|
readCommitFile: async () => null,
|
||||||
read: async (params) => ({ ...initial, cwd: params.cwd }),
|
read: async (params) => ({ ...initial, cwd: params.cwd }),
|
||||||
subscribe: async (params, listener) => {
|
subscribe: async (params, listener) => {
|
||||||
let isSubscribed = true;
|
let isSubscribed = true;
|
||||||
@@ -77,7 +78,7 @@ function createFakeDiffSubscriber(initial: CheckoutDiffSnapshotPayload) {
|
|||||||
unsubscribe,
|
unsubscribe,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
scheduleRefreshForCwd: (cwd) => {
|
scheduleRefreshForCwd: async (cwd) => {
|
||||||
refreshedCwds.push(cwd);
|
refreshedCwds.push(cwd);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ import {
|
|||||||
pullCurrentBranch,
|
pullCurrentBranch,
|
||||||
pushCurrentBranch,
|
pushCurrentBranch,
|
||||||
listCheckoutCommits,
|
listCheckoutCommits,
|
||||||
getCommitFileDiff,
|
|
||||||
} from "../../../utils/checkout-git.js";
|
} from "../../../utils/checkout-git.js";
|
||||||
import { runGitCommand } from "../../../utils/run-git-command.js";
|
import { runGitCommand } from "../../../utils/run-git-command.js";
|
||||||
import { expandTilde } from "../../../utils/path.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.
|
* real CheckoutDiffManager satisfies this structurally; tests supply a fake.
|
||||||
*/
|
*/
|
||||||
export interface CheckoutDiffSubscriber {
|
export interface CheckoutDiffSubscriber {
|
||||||
|
readCommitFile: import("../../diff/index.js").DiffSubscriptions["readCommitFile"];
|
||||||
read(
|
read(
|
||||||
params: Omit<CheckoutDiffSubscriptionRequest, "signal">,
|
params: Omit<CheckoutDiffSubscriptionRequest, "signal">,
|
||||||
): Promise<CheckoutDiffSnapshotPayload>;
|
): Promise<CheckoutDiffSnapshotPayload>;
|
||||||
@@ -116,7 +116,7 @@ export interface CheckoutDiffSubscriber {
|
|||||||
params: CheckoutDiffSubscriptionRequest,
|
params: CheckoutDiffSubscriptionRequest,
|
||||||
listener: (snapshot: CheckoutDiffSnapshotPayload) => void,
|
listener: (snapshot: CheckoutDiffSnapshotPayload) => void,
|
||||||
): Promise<CheckoutDiffSubscription>;
|
): Promise<CheckoutDiffSubscription>;
|
||||||
scheduleRefreshForCwd(cwd: string): void;
|
scheduleRefreshForCwd(cwd: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CheckoutSessionOptions {
|
export interface CheckoutSessionOptions {
|
||||||
@@ -301,7 +301,12 @@ export class CheckoutSession {
|
|||||||
if (path.length === 0 || isAbsolute(path) || path.split(/[\\/]/).includes("..")) {
|
if (path.length === 0 || isAbsolute(path) || path.split(/[\\/]/).includes("..")) {
|
||||||
throw new Error(`Invalid path: ${path}`);
|
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({
|
this.host.emit({
|
||||||
type: "checkout.commits.file_diff.response",
|
type: "checkout.commits.file_diff.response",
|
||||||
payload: { cwd, sha, path, file, error: null, requestId },
|
payload: { cwd, sha, path, file, error: null, requestId },
|
||||||
@@ -494,7 +499,7 @@ export class CheckoutSession {
|
|||||||
includeForge: true,
|
includeForge: true,
|
||||||
reason: "manual-refresh",
|
reason: "manual-refresh",
|
||||||
});
|
});
|
||||||
this.checkoutDiffManager.scheduleRefreshForCwd(resolvedCwd);
|
await this.checkoutDiffManager.scheduleRefreshForCwd(resolvedCwd);
|
||||||
this.host.emit({
|
this.host.emit({
|
||||||
type: "checkout.refresh.response",
|
type: "checkout.refresh.response",
|
||||||
payload: {
|
payload: {
|
||||||
@@ -548,8 +553,8 @@ export class CheckoutSession {
|
|||||||
* Notify the live diff subscriptions that the working tree at `cwd` changed.
|
* Notify the live diff subscriptions that the working tree at `cwd` changed.
|
||||||
* Called by the command handlers below after they mutate the repository.
|
* Called by the command handlers below after they mutate the repository.
|
||||||
*/
|
*/
|
||||||
private scheduleDiffRefresh(cwd: string): void {
|
private scheduleDiffRefresh(cwd: string): Promise<void> {
|
||||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
return this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -563,7 +568,7 @@ export class CheckoutSession {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const checkoutResult = await this.gitMutation.checkoutExistingBranch(cwd, branch);
|
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
|
// Push a workspace_update immediately so the sidebar/header reflect
|
||||||
// the new branch name without waiting for the background git watcher.
|
// the new branch name without waiting for the background git watcher.
|
||||||
@@ -615,7 +620,7 @@ export class CheckoutSession {
|
|||||||
try {
|
try {
|
||||||
const result = await this.host.renameCurrentBranch(cwd, branch);
|
const result = await this.host.renameCurrentBranch(cwd, branch);
|
||||||
await this.gitMutation.notifyGitMutation(cwd, "rename-branch", { invalidateForge: true });
|
await this.gitMutation.notifyGitMutation(cwd, "rename-branch", { invalidateForge: true });
|
||||||
this.scheduleDiffRefresh(cwd);
|
await this.scheduleDiffRefresh(cwd);
|
||||||
this.host.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch);
|
this.host.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch);
|
||||||
|
|
||||||
// Branch is a git fact derived per-descriptor from each workspace's own
|
// Branch is a git fact derived per-descriptor from each workspace's own
|
||||||
@@ -657,7 +662,7 @@ export class CheckoutSession {
|
|||||||
try {
|
try {
|
||||||
await discardChanges(cwd, paths);
|
await discardChanges(cwd, paths);
|
||||||
await this.gitMutation.notifyGitMutation(cwd, "discard-changes");
|
await this.gitMutation.notifyGitMutation(cwd, "discard-changes");
|
||||||
this.scheduleDiffRefresh(cwd);
|
await this.scheduleDiffRefresh(cwd);
|
||||||
this.host.emit({
|
this.host.emit({
|
||||||
type: "checkout.discard_changes.response",
|
type: "checkout.discard_changes.response",
|
||||||
payload: { cwd, success: true, error: null, requestId },
|
payload: { cwd, success: true, error: null, requestId },
|
||||||
@@ -684,7 +689,7 @@ export class CheckoutSession {
|
|||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
});
|
});
|
||||||
await this.gitMutation.notifyGitMutation(cwd, "stash-push");
|
await this.gitMutation.notifyGitMutation(cwd, "stash-push");
|
||||||
this.scheduleDiffRefresh(cwd);
|
await this.scheduleDiffRefresh(cwd);
|
||||||
this.host.emit({
|
this.host.emit({
|
||||||
type: "stash_save_response",
|
type: "stash_save_response",
|
||||||
payload: { cwd, success: true, error: null, requestId },
|
payload: { cwd, success: true, error: null, requestId },
|
||||||
@@ -707,7 +712,7 @@ export class CheckoutSession {
|
|||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
});
|
});
|
||||||
await this.gitMutation.notifyGitMutation(cwd, "stash-pop");
|
await this.gitMutation.notifyGitMutation(cwd, "stash-pop");
|
||||||
this.scheduleDiffRefresh(cwd);
|
await this.scheduleDiffRefresh(cwd);
|
||||||
this.host.emit({
|
this.host.emit({
|
||||||
type: "stash_pop_response",
|
type: "stash_pop_response",
|
||||||
payload: { cwd, success: true, error: null, requestId },
|
payload: { cwd, success: true, error: null, requestId },
|
||||||
@@ -759,7 +764,7 @@ export class CheckoutSession {
|
|||||||
addAll: msg.addAll ?? true,
|
addAll: msg.addAll ?? true,
|
||||||
});
|
});
|
||||||
await this.gitMutation.notifyGitMutation(cwd, "commit-changes");
|
await this.gitMutation.notifyGitMutation(cwd, "commit-changes");
|
||||||
this.scheduleDiffRefresh(cwd);
|
await this.scheduleDiffRefresh(cwd);
|
||||||
|
|
||||||
this.host.emit({
|
this.host.emit({
|
||||||
type: "checkout_commit_response",
|
type: "checkout_commit_response",
|
||||||
@@ -820,7 +825,7 @@ export class CheckoutSession {
|
|||||||
this.gitMutation.notifyGitMutation(mutatedCwd, "merge-to-base", { invalidateForge: true }),
|
this.gitMutation.notifyGitMutation(mutatedCwd, "merge-to-base", { invalidateForge: true }),
|
||||||
...(mutatedCwd !== cwd ? [this.gitMutation.notifyGitMutation(cwd, "merge-to-base")] : []),
|
...(mutatedCwd !== cwd ? [this.gitMutation.notifyGitMutation(cwd, "merge-to-base")] : []),
|
||||||
]);
|
]);
|
||||||
this.scheduleDiffRefresh(cwd);
|
await this.scheduleDiffRefresh(cwd);
|
||||||
|
|
||||||
this.host.emit({
|
this.host.emit({
|
||||||
type: "checkout_merge_response",
|
type: "checkout_merge_response",
|
||||||
@@ -866,7 +871,7 @@ export class CheckoutSession {
|
|||||||
{ paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot },
|
{ paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot },
|
||||||
);
|
);
|
||||||
await this.gitMutation.notifyGitMutation(cwd, "merge-from-base", { invalidateForge: true });
|
await this.gitMutation.notifyGitMutation(cwd, "merge-from-base", { invalidateForge: true });
|
||||||
this.scheduleDiffRefresh(cwd);
|
await this.scheduleDiffRefresh(cwd);
|
||||||
|
|
||||||
this.host.emit({
|
this.host.emit({
|
||||||
type: "checkout_merge_from_base_response",
|
type: "checkout_merge_from_base_response",
|
||||||
@@ -898,7 +903,7 @@ export class CheckoutSession {
|
|||||||
try {
|
try {
|
||||||
await pullCurrentBranch(cwd);
|
await pullCurrentBranch(cwd);
|
||||||
await this.gitMutation.notifyGitMutation(cwd, "pull", { invalidateForge: true });
|
await this.gitMutation.notifyGitMutation(cwd, "pull", { invalidateForge: true });
|
||||||
this.scheduleDiffRefresh(cwd);
|
await this.scheduleDiffRefresh(cwd);
|
||||||
|
|
||||||
this.host.emit({
|
this.host.emit({
|
||||||
type: "checkout_pull_response",
|
type: "checkout_pull_response",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DiffSubscriptions } from "./diff/index.js";
|
||||||
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||||
import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js";
|
import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js";
|
||||||
import {
|
import {
|
||||||
@@ -105,6 +106,13 @@ describe("snapshot mutation ownership boundary", () => {
|
|||||||
|
|
||||||
const session = asInternals<SessionInternals>(
|
const session = asInternals<SessionInternals>(
|
||||||
new Session({
|
new Session({
|
||||||
|
checkoutDiffManager: createStub<SessionOptions["checkoutDiffManager"]>({
|
||||||
|
streams: new DiffSubscriptions({
|
||||||
|
async requestWorkingTreeWatch() {
|
||||||
|
throw new Error("Unexpected diff observation");
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
workspaceRuntimeStore: createWorkspaceRuntimeStore(),
|
workspaceRuntimeStore: createWorkspaceRuntimeStore(),
|
||||||
messageReceipts: createMessageReceiptsStub(),
|
messageReceipts: createMessageReceiptsStub(),
|
||||||
agentMessages: createAgentMessageDeliveryStub(),
|
agentMessages: createAgentMessageDeliveryStub(),
|
||||||
|
|||||||
@@ -70,11 +70,6 @@ function extractPathFromMetadata(lines: string[], prefix: "--- " | "+++ "): stri
|
|||||||
|
|
||||||
function extractPathFromDiffHeader(lines: string[]): string {
|
function extractPathFromDiffHeader(lines: string[]): string {
|
||||||
const firstLine = lines[0] ?? "";
|
const firstLine = lines[0] ?? "";
|
||||||
const prefixedPathMatch = firstLine.match(/^a\/(.+) b\/(.+)$/);
|
|
||||||
if (prefixedPathMatch) {
|
|
||||||
return prefixedPathMatch[2];
|
|
||||||
}
|
|
||||||
|
|
||||||
const quotedPathMatch =
|
const quotedPathMatch =
|
||||||
firstLine.match(/^("(?:\\.|[^"\\])*") (.+)$/) ?? firstLine.match(/^(.+) ("(?:\\.|[^"\\])*")$/);
|
firstLine.match(/^("(?:\\.|[^"\\])*") (.+)$/) ?? firstLine.match(/^(.+) ("(?:\\.|[^"\\])*")$/);
|
||||||
if (quotedPathMatch) {
|
if (quotedPathMatch) {
|
||||||
@@ -86,10 +81,21 @@ function extractPathFromDiffHeader(lines: string[]): string {
|
|||||||
return newPath;
|
return newPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadataPath =
|
// The ---/+++ records are unambiguous even when a path contains " b/".
|
||||||
extractPathFromMetadata(lines, "+++ ") ?? extractPathFromMetadata(lines, "--- ");
|
const oldMetadata = extractPathFromMetadata(lines, "--- ");
|
||||||
if (metadataPath) {
|
const newMetadata = extractPathFromMetadata(lines, "+++ ");
|
||||||
return metadataPath;
|
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+)$/);
|
const pathMatch = firstLine.match(/^(\S+)\s+(\S+)$/);
|
||||||
@@ -462,7 +468,8 @@ function applyTokensToHunks(
|
|||||||
newLineNum++;
|
newLineNum++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return tokens ? { ...line, tokens } : line;
|
const matchingTokens = tokensForPatchLine(tokens, line.content);
|
||||||
|
return matchingTokens ? { ...line, tokens: matchingTokens } : line;
|
||||||
});
|
});
|
||||||
|
|
||||||
return { ...hunk, lines: highlightedLines };
|
return { ...hunk, lines: highlightedLines };
|
||||||
@@ -471,6 +478,25 @@ function applyTokensToHunks(
|
|||||||
return { ...file, hunks: highlightedHunks };
|
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.
|
* Parse and highlight a complete diff, using actual file content when available.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1223,7 +1223,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
|
|
||||||
await Promise.all(cleanupPromises);
|
await Promise.all(cleanupPromises);
|
||||||
this.providerSnapshotManager.destroy();
|
this.providerSnapshotManager.destroy();
|
||||||
this.checkoutDiffManager.dispose();
|
await this.checkoutDiffManager.dispose();
|
||||||
await this.workspaceGitService.dispose();
|
await this.workspaceGitService.dispose();
|
||||||
this.pendingConnections.clear();
|
this.pendingConnections.clear();
|
||||||
this.sessions.clear();
|
this.sessions.clear();
|
||||||
@@ -2020,6 +2020,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
usageSetting: true,
|
usageSetting: true,
|
||||||
usageSources: true,
|
usageSources: true,
|
||||||
ownedSubscriptions: true,
|
ownedSubscriptions: true,
|
||||||
|
diffSubscription: true,
|
||||||
binaryChunkingV1: true,
|
binaryChunkingV1: true,
|
||||||
agentRequestReceipts: true,
|
agentRequestReceipts: true,
|
||||||
workspaceRequestReceipts: true,
|
workspaceRequestReceipts: true,
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ function createSessionForWireCompatTest(options?: {
|
|||||||
directorySync: options?.directorySync,
|
directorySync: options?.directorySync,
|
||||||
scheduleService: {} as SessionOptions["scheduleService"],
|
scheduleService: {} as SessionOptions["scheduleService"],
|
||||||
checkoutDiffManager: {
|
checkoutDiffManager: {
|
||||||
scheduleRefreshForCwd() {},
|
async scheduleRefreshForCwd() {},
|
||||||
onWorkspaceStateMayHaveChanged() {},
|
onWorkspaceStateMayHaveChanged() {},
|
||||||
} as unknown as SessionOptions["checkoutDiffManager"],
|
} as unknown as SessionOptions["checkoutDiffManager"],
|
||||||
github: {
|
github: {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
type CheckoutSnapshotFacts,
|
type CheckoutSnapshotFacts,
|
||||||
type CheckoutStatusGit,
|
type CheckoutStatusGit,
|
||||||
} from "../utils/checkout-git.js";
|
} from "../utils/checkout-git.js";
|
||||||
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
|
||||||
import { createFileObserver } from "./file-observer/index.js";
|
import { createFileObserver } from "./file-observer/index.js";
|
||||||
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
|
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
|
||||||
import type { FileChange, FileObserver, SubscribeToFileChanges } from "./file-observer/index.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();
|
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
|
||||||
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
|
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 summaryListener = vi.fn();
|
||||||
const diffListener = vi.fn();
|
const diffListener = vi.fn();
|
||||||
const summarySubscription = service.registerWorkspace({ cwd: repoDir }, summaryListener);
|
const summarySubscription = service.registerWorkspace({ cwd: repoDir }, summaryListener);
|
||||||
const diffSubscription = await diffManager.subscribe(
|
const diffSubscription = await service.requestWorkingTreeWatch(repoDir, diffListener);
|
||||||
{ cwd: repoDir, compare: { mode: "uncommitted" } },
|
|
||||||
diffListener,
|
|
||||||
);
|
|
||||||
|
|
||||||
cleanup.push(async () => {
|
cleanup.push(async () => {
|
||||||
diffSubscription.unsubscribe();
|
diffSubscription.unsubscribe();
|
||||||
summarySubscription.unsubscribe();
|
summarySubscription.unsubscribe();
|
||||||
diffManager.dispose();
|
|
||||||
await service.dispose();
|
await service.dispose();
|
||||||
expect(activeWatcherCount).toBe(0);
|
expect(activeWatcherCount).toBe(0);
|
||||||
rmSync(tempDir, { recursive: true, force: true });
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
@@ -312,6 +302,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as
|
|||||||
getCheckoutShortstat.mockClear();
|
getCheckoutShortstat.mockClear();
|
||||||
getCheckoutWorktreeState.mockClear();
|
getCheckoutWorktreeState.mockClear();
|
||||||
getCheckoutDiff.mockClear();
|
getCheckoutDiff.mockClear();
|
||||||
|
diffListener.mockClear();
|
||||||
runGitCommand.mockClear();
|
runGitCommand.mockClear();
|
||||||
deliveredEvents.length = 0;
|
deliveredEvents.length = 0;
|
||||||
|
|
||||||
@@ -344,18 +335,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(diffListener).toHaveBeenLastCalledWith({
|
expect(diffListener).toHaveBeenCalled();
|
||||||
cwd: repoDir,
|
|
||||||
files: [
|
|
||||||
{
|
|
||||||
path: "src/tracked.txt",
|
|
||||||
additions: 2,
|
|
||||||
deletions: 0,
|
|
||||||
status: "modified",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
expect(service.getMetrics().workspaceRefreshQueuedCount).toBe(0);
|
expect(service.getMetrics().workspaceRefreshQueuedCount).toBe(0);
|
||||||
},
|
},
|
||||||
{ timeout: 5_000 },
|
{ timeout: 5_000 },
|
||||||
@@ -371,11 +351,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(diffListener).toHaveBeenLastCalledWith(
|
expect(diffListener).toHaveBeenCalled();
|
||||||
expect.objectContaining({
|
|
||||||
files: [expect.objectContaining({ additions: 3 })],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(service.getMetrics()).toMatchObject({
|
expect(service.getMetrics()).toMatchObject({
|
||||||
workspaceRefreshInFlightCount: 0,
|
workspaceRefreshInFlightCount: 0,
|
||||||
workspaceRefreshQueuedCount: 0,
|
workspaceRefreshQueuedCount: 0,
|
||||||
@@ -409,18 +385,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(diffListener).toHaveBeenLastCalledWith({
|
expect(diffListener).toHaveBeenCalled();
|
||||||
cwd: repoDir,
|
|
||||||
files: [
|
|
||||||
{
|
|
||||||
path: "build/tracked.txt",
|
|
||||||
additions: 2,
|
|
||||||
deletions: 0,
|
|
||||||
status: "modified",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
expect(service.getMetrics()).toMatchObject({
|
expect(service.getMetrics()).toMatchObject({
|
||||||
workspaceRefreshInFlightCount: 0,
|
workspaceRefreshInFlightCount: 0,
|
||||||
workspaceRefreshQueuedCount: 0,
|
workspaceRefreshQueuedCount: 0,
|
||||||
@@ -434,6 +399,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as
|
|||||||
getCheckoutShortstat.mockClear();
|
getCheckoutShortstat.mockClear();
|
||||||
getCheckoutWorktreeState.mockClear();
|
getCheckoutWorktreeState.mockClear();
|
||||||
getCheckoutDiff.mockClear();
|
getCheckoutDiff.mockClear();
|
||||||
|
diffListener.mockClear();
|
||||||
runGitCommand.mockClear();
|
runGitCommand.mockClear();
|
||||||
summaryListener.mockClear();
|
summaryListener.mockClear();
|
||||||
diffListener.mockClear();
|
diffListener.mockClear();
|
||||||
@@ -449,18 +415,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(diffListener).toHaveBeenLastCalledWith({
|
expect(diffListener).toHaveBeenCalled();
|
||||||
cwd: repoDir,
|
|
||||||
files: [
|
|
||||||
{
|
|
||||||
path: "build/tracked.txt",
|
|
||||||
additions: 4,
|
|
||||||
deletions: 0,
|
|
||||||
status: "modified",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
expect(service.getMetrics()).toMatchObject({
|
expect(service.getMetrics()).toMatchObject({
|
||||||
workspaceRefreshInFlightCount: 0,
|
workspaceRefreshInFlightCount: 0,
|
||||||
workspaceRefreshQueuedCount: 0,
|
workspaceRefreshQueuedCount: 0,
|
||||||
@@ -474,6 +429,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as
|
|||||||
getCheckoutShortstat.mockClear();
|
getCheckoutShortstat.mockClear();
|
||||||
getCheckoutWorktreeState.mockClear();
|
getCheckoutWorktreeState.mockClear();
|
||||||
getCheckoutDiff.mockClear();
|
getCheckoutDiff.mockClear();
|
||||||
|
diffListener.mockClear();
|
||||||
runGitCommand.mockClear();
|
runGitCommand.mockClear();
|
||||||
|
|
||||||
for (let index = 0; index < 100; index += 1) {
|
for (let index = 0; index < 100; index += 1) {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { pathToFileURL } from "node:url";
|
|||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import type { CheckoutSnapshotFacts, CheckoutStatusGit } from "../utils/checkout-git.js";
|
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 type { FileObserver } from "./file-observer/index.js";
|
||||||
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
|
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
|
||||||
|
|
||||||
@@ -428,41 +427,30 @@ describe("WorkspaceGitService checkout observation", () => {
|
|||||||
service.dispose();
|
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 watcher = createWatcherHarness();
|
||||||
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => createCheckoutFacts(cwd));
|
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => createCheckoutFacts(cwd));
|
||||||
let diffStat = { additions: 1, deletions: 0 };
|
let diffStat = { additions: 1, deletions: 0 };
|
||||||
let diffFile = { path: "tracked.txt", additions: 1, deletions: 0, status: "modified" };
|
|
||||||
const getCheckoutStatus = vi.fn(async (cwd: string) =>
|
const getCheckoutStatus = vi.fn(async (cwd: string) =>
|
||||||
createCheckoutStatus(cwd, { isDirty: true }),
|
createCheckoutStatus(cwd, { isDirty: true }),
|
||||||
);
|
);
|
||||||
const getCheckoutShortstat = vi.fn(async () => diffStat);
|
const getCheckoutShortstat = vi.fn(async () => diffStat);
|
||||||
const getPullRequestStatus = vi.fn();
|
const getPullRequestStatus = vi.fn();
|
||||||
const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: [diffFile] }));
|
|
||||||
const service = createService(watcher, {
|
const service = createService(watcher, {
|
||||||
getCheckoutSnapshotFacts,
|
getCheckoutSnapshotFacts,
|
||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
getCheckoutShortstat,
|
getCheckoutShortstat,
|
||||||
getPullRequestStatus,
|
getPullRequestStatus,
|
||||||
getCheckoutDiff,
|
|
||||||
});
|
});
|
||||||
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
|
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
|
||||||
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
|
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 summaryListener = vi.fn();
|
||||||
const diffListener = vi.fn();
|
const diffListener = vi.fn();
|
||||||
|
|
||||||
await service.getSnapshot(REPO_CWD);
|
await service.getSnapshot(REPO_CWD);
|
||||||
const summarySubscription = service.registerWorkspace({ cwd: REPO_CWD }, summaryListener);
|
const summarySubscription = service.registerWorkspace({ cwd: REPO_CWD }, summaryListener);
|
||||||
const diffSubscription = await diffManager.subscribe(
|
const diffSubscription = await service.requestWorkingTreeWatch(REPO_CWD, diffListener);
|
||||||
{ cwd: REPO_CWD, compare: { mode: "uncommitted" } },
|
|
||||||
diffListener,
|
|
||||||
);
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(watcher.subscribe).toHaveBeenCalledWith(
|
expect(watcher.subscribe).toHaveBeenCalledWith(
|
||||||
REPO_CWD,
|
REPO_CWD,
|
||||||
@@ -475,7 +463,6 @@ describe("WorkspaceGitService checkout observation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
diffStat = { additions: 4, deletions: 2 };
|
diffStat = { additions: 4, deletions: 2 };
|
||||||
diffFile = { path: "tracked.txt", additions: 4, deletions: 2, status: "modified" };
|
|
||||||
watcher.records[0]?.callback(null, [
|
watcher.records[0]?.callback(null, [
|
||||||
{ path: path.join(REPO_CWD, "tracked.txt"), type: "update" },
|
{ path: path.join(REPO_CWD, "tracked.txt"), type: "update" },
|
||||||
]);
|
]);
|
||||||
@@ -489,11 +476,7 @@ describe("WorkspaceGitService checkout observation", () => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(diffListener).toHaveBeenLastCalledWith({
|
expect(diffListener).toHaveBeenCalledTimes(1);
|
||||||
cwd: REPO_CWD,
|
|
||||||
files: [{ path: "tracked.txt", additions: 4, deletions: 2, status: "modified" }],
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
@@ -502,7 +485,6 @@ describe("WorkspaceGitService checkout observation", () => {
|
|||||||
|
|
||||||
diffSubscription.unsubscribe();
|
diffSubscription.unsubscribe();
|
||||||
summarySubscription.unsubscribe();
|
summarySubscription.unsubscribe();
|
||||||
diffManager.dispose();
|
|
||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1231,7 +1213,7 @@ describe("WorkspaceGitService checkout observation", () => {
|
|||||||
service.dispose();
|
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();
|
const watcher = createWatcherHarness();
|
||||||
let isDirty = true;
|
let isDirty = true;
|
||||||
let diffStat = { additions: 3, deletions: 1 };
|
let diffStat = { additions: 3, deletions: 1 };
|
||||||
@@ -1246,19 +1228,10 @@ describe("WorkspaceGitService checkout observation", () => {
|
|||||||
});
|
});
|
||||||
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
|
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
|
||||||
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
|
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 summaryListener = vi.fn();
|
||||||
const diffListener = vi.fn();
|
|
||||||
const summarySubscription = service.registerWorkspace({ cwd: REPO_CWD }, summaryListener);
|
const summarySubscription = service.registerWorkspace({ cwd: REPO_CWD }, summaryListener);
|
||||||
const diffSubscription = await diffManager.subscribe(
|
await service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" });
|
||||||
{ cwd: REPO_CWD, compare: { mode: "uncommitted" } },
|
|
||||||
diffListener,
|
|
||||||
);
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
|
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();
|
summarySubscription.unsubscribe();
|
||||||
diffManager.dispose();
|
|
||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1304,20 +1274,14 @@ describe("WorkspaceGitService checkout observation", () => {
|
|||||||
const service = createService(watcher, { getCheckoutDiff, getCheckoutWorktreeState });
|
const service = createService(watcher, { getCheckoutDiff, getCheckoutWorktreeState });
|
||||||
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
|
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
|
||||||
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
|
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
|
||||||
const diffManager = new CheckoutDiffManager({
|
|
||||||
workspaceRuntimeStore: workspaceRuntimeStore,
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, () => {});
|
||||||
logger: createLogger(),
|
await service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" });
|
||||||
paseoHome: "/tmp/paseo-home",
|
|
||||||
workspaceGitService: service,
|
|
||||||
});
|
|
||||||
const diffSubscription = await diffManager.subscribe(
|
|
||||||
{ cwd: REPO_CWD, compare: { mode: "base", baseRef: "main" } },
|
|
||||||
vi.fn(),
|
|
||||||
);
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
|
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
|
||||||
});
|
});
|
||||||
|
await service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" });
|
||||||
expect(getCheckoutDiff).toHaveBeenCalledTimes(1);
|
expect(getCheckoutDiff).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
watcher.records
|
watcher.records
|
||||||
@@ -1325,6 +1289,7 @@ describe("WorkspaceGitService checkout observation", () => {
|
|||||||
?.callback(null, [{ path: path.join(REPO_CWD, "tracked.txt"), type: "update" }]);
|
?.callback(null, [{ path: path.join(REPO_CWD, "tracked.txt"), type: "update" }]);
|
||||||
await vi.advanceTimersByTimeAsync(1_000);
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
await service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" });
|
||||||
expect(getCheckoutDiff).toHaveBeenCalledTimes(1);
|
expect(getCheckoutDiff).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
watcher.records
|
watcher.records
|
||||||
@@ -1332,12 +1297,10 @@ describe("WorkspaceGitService checkout observation", () => {
|
|||||||
?.callback(null, [{ path: path.join(GIT_DIR, "packed-refs"), type: "update" }]);
|
?.callback(null, [{ path: path.join(GIT_DIR, "packed-refs"), type: "update" }]);
|
||||||
await vi.advanceTimersByTimeAsync(1_000);
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
await vi.advanceTimersByTimeAsync(150);
|
await vi.advanceTimersByTimeAsync(150);
|
||||||
await vi.waitFor(() => {
|
await service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" });
|
||||||
expect(getCheckoutDiff).toHaveBeenCalledTimes(2);
|
expect(getCheckoutDiff).toHaveBeenCalledTimes(2);
|
||||||
});
|
|
||||||
|
|
||||||
diffSubscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
diffManager.dispose();
|
|
||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2759,39 +2722,23 @@ describe("WorkspaceGitService checkout observation", () => {
|
|||||||
service.dispose();
|
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();
|
const watcher = createWatcherHarness();
|
||||||
let isDirty = false;
|
let isDirty = false;
|
||||||
let diffStat = { additions: 0, deletions: 0 };
|
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 getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd, { isDirty }));
|
||||||
const getCheckoutShortstat = vi.fn(async () => diffStat);
|
const getCheckoutShortstat = vi.fn(async () => diffStat);
|
||||||
const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: diffFiles }));
|
|
||||||
const service = createService(watcher, {
|
const service = createService(watcher, {
|
||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
getCheckoutShortstat,
|
getCheckoutShortstat,
|
||||||
getCheckoutDiff,
|
|
||||||
});
|
});
|
||||||
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
|
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
|
||||||
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
|
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 listener = vi.fn();
|
||||||
const diffListener = vi.fn();
|
const diffListener = vi.fn();
|
||||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
|
||||||
const diffSubscription = await diffManager.subscribe(
|
const diffSubscription = await service.requestWorkingTreeWatch(REPO_CWD, diffListener);
|
||||||
{ cwd: REPO_CWD, compare: { mode: "uncommitted" } },
|
|
||||||
diffListener,
|
|
||||||
);
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull();
|
expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull();
|
||||||
@@ -2799,7 +2746,6 @@ describe("WorkspaceGitService checkout observation", () => {
|
|||||||
});
|
});
|
||||||
isDirty = true;
|
isDirty = true;
|
||||||
diffStat = { additions: 7, deletions: 3 };
|
diffStat = { additions: 7, deletions: 3 };
|
||||||
diffFiles = [{ path: "tracked.txt", additions: 7, deletions: 3, status: "modified" }];
|
|
||||||
|
|
||||||
getWatcherRecordsForDirectory(watcher, REPO_CWD)[0]?.callback(null, [
|
getWatcherRecordsForDirectory(watcher, REPO_CWD)[0]?.callback(null, [
|
||||||
{ path: path.join(REPO_CWD, "tracked.txt"), type: "update" },
|
{ path: path.join(REPO_CWD, "tracked.txt"), type: "update" },
|
||||||
@@ -2814,17 +2760,12 @@ describe("WorkspaceGitService checkout observation", () => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(diffListener).toHaveBeenLastCalledWith({
|
expect(diffListener).toHaveBeenCalledTimes(1);
|
||||||
cwd: REPO_CWD,
|
|
||||||
files: [{ path: "tracked.txt", additions: 7, deletions: 3, status: "modified" }],
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
diffSubscription.unsubscribe();
|
diffSubscription.unsubscribe();
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
diffManager.dispose();
|
|
||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ export interface WorkspaceGitRuntimeSnapshot {
|
|||||||
isDirty: boolean | null;
|
isDirty: boolean | null;
|
||||||
baseRef: string | null;
|
baseRef: string | null;
|
||||||
baseRefExact?: string | null;
|
baseRefExact?: string | null;
|
||||||
|
comparisonBaseRef?: string | null;
|
||||||
aheadBehind: { ahead: number; behind: number } | null;
|
aheadBehind: { ahead: number; behind: number } | null;
|
||||||
upstreamRef: string | null;
|
upstreamRef: string | null;
|
||||||
aheadOfOrigin: number | null;
|
aheadOfOrigin: number | null;
|
||||||
@@ -3626,6 +3627,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
: (target.latestGit?.isDirty ?? checkoutStatus.isDirty),
|
: (target.latestGit?.isDirty ?? checkoutStatus.isDirty),
|
||||||
baseRef: checkoutStatus.baseRef,
|
baseRef: checkoutStatus.baseRef,
|
||||||
baseRefExact: checkoutStatus.baseRefExact,
|
baseRefExact: checkoutStatus.baseRefExact,
|
||||||
|
comparisonBaseRef: checkoutStatus.comparisonBaseRef,
|
||||||
aheadBehind: checkoutStatus.aheadBehind,
|
aheadBehind: checkoutStatus.aheadBehind,
|
||||||
upstreamRef: checkoutStatus.upstreamRef,
|
upstreamRef: checkoutStatus.upstreamRef,
|
||||||
aheadOfOrigin: checkoutStatus.aheadOfOrigin,
|
aheadOfOrigin: checkoutStatus.aheadOfOrigin,
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs"
|
|||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
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";
|
import { writePaseoWorktreeMetadata } from "./worktree-metadata.js";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
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";
|
} from "../services/forge-cli-command.js";
|
||||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||||
import { runGitCommand, type RunGitCommand } from "./run-git-command.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 { unquoteGitPath } from "./git-quoted-path.js";
|
||||||
import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js";
|
import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js";
|
||||||
import {
|
import {
|
||||||
@@ -613,22 +613,6 @@ async function listCheckoutFileChanges(
|
|||||||
return Array.from(byPath.values());
|
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> {
|
async function tryResolveMergeBase(cwd: string, baseRef: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await runGitCommand(["merge-base", baseRef, "HEAD"], {
|
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.
|
// 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[] {
|
function buildGitDiffArgs(args: { ignoreWhitespace?: boolean; extra: string[] }): string[] {
|
||||||
return [
|
return [
|
||||||
@@ -906,6 +895,7 @@ export interface CheckoutStatusGitNonPaseo {
|
|||||||
isDirty: boolean;
|
isDirty: boolean;
|
||||||
baseRef: string | null;
|
baseRef: string | null;
|
||||||
baseRefExact?: string | null;
|
baseRefExact?: string | null;
|
||||||
|
comparisonBaseRef?: string | null;
|
||||||
aheadBehind: AheadBehind | null;
|
aheadBehind: AheadBehind | null;
|
||||||
// Remote-tracking ref for currentBranch, e.g. "refs/remotes/origin/main". Null when the
|
// 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
|
// branch has no upstream or git could not resolve it. aheadOfOrigin/behindOfOrigin are
|
||||||
@@ -926,6 +916,7 @@ export interface CheckoutStatusGitPaseo {
|
|||||||
isDirty: boolean;
|
isDirty: boolean;
|
||||||
baseRef: string;
|
baseRef: string;
|
||||||
baseRefExact?: string | null;
|
baseRefExact?: string | null;
|
||||||
|
comparisonBaseRef?: string | null;
|
||||||
aheadBehind: AheadBehind | null;
|
aheadBehind: AheadBehind | null;
|
||||||
upstreamRef: string | null;
|
upstreamRef: string | null;
|
||||||
aheadOfOrigin: number | null;
|
aheadOfOrigin: number | null;
|
||||||
@@ -1748,6 +1739,24 @@ function isQualifiedBranchRef(ref: string): boolean {
|
|||||||
return ref.startsWith("refs/heads/") || ref.startsWith("refs/remotes/");
|
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(
|
async function resolveBestComparisonBaseRef(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
baseRef: string,
|
baseRef: string,
|
||||||
@@ -1774,11 +1783,17 @@ async function resolveBestComparisonBaseRef(
|
|||||||
return normalized.localName;
|
return normalized.localName;
|
||||||
}
|
}
|
||||||
|
|
||||||
const refName =
|
return resolveCommitComparisonRef(cwd, baseRef, context);
|
||||||
baseRef.startsWith("origin/") || baseRef.startsWith("refs/remotes/origin/")
|
}
|
||||||
? normalized.originRef
|
|
||||||
: normalized.localName;
|
async function resolveQualifiedComparisonBaseRef(
|
||||||
throw new Error(`Base branch not found locally or on origin: ${refName}`);
|
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> {
|
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}`;
|
return `origin/${normalizedBaseRef}`;
|
||||||
}
|
}
|
||||||
if (!hasLocal && !hasOrigin) {
|
if (!hasLocal && !hasOrigin) {
|
||||||
throw new Error(`Base branch not found locally or on origin: ${normalizedBaseRef}`);
|
return resolveCommitComparisonRef(cwd, baseRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { stdout } = await runGitCommand(
|
const { stdout } = await runGitCommand(
|
||||||
@@ -2239,8 +2254,8 @@ export async function getCheckoutSnapshotFacts(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const PER_FILE_DIFF_MAX_BYTES = 1024 * 1024; // 1MB
|
export const PER_FILE_DIFF_MAX_BYTES = 1024 * 1024; // 1MB
|
||||||
const TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB
|
export const TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB
|
||||||
const CHECKOUT_DIFF_FRAME_HEADROOM_BYTES = 1024 * 1024;
|
const CHECKOUT_DIFF_FRAME_HEADROOM_BYTES = 1024 * 1024;
|
||||||
// Temporary until diffs load lazily per file. The Paseo relay's 32 MiB frame limit is
|
// 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
|
// 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,
|
cwd: string,
|
||||||
relativePath: string,
|
relativePath: string,
|
||||||
): Promise<{ stat: FileStat; truncated: boolean }> {
|
): Promise<{ stat: FileStat; truncated: boolean }> {
|
||||||
@@ -2399,6 +2414,10 @@ export async function getCheckoutStatus(
|
|||||||
const baseRef = facts.resolvedBaseRef;
|
const baseRef = facts.resolvedBaseRef;
|
||||||
const mainRepoRoot = facts.mainRepoRoot;
|
const mainRepoRoot = facts.mainRepoRoot;
|
||||||
const factsContext = { ...context, facts };
|
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 =
|
const aheadBehind =
|
||||||
baseRef && currentBranch
|
baseRef && currentBranch
|
||||||
? await getAheadBehind(cwd, baseRef, currentBranch, factsContext)
|
? await getAheadBehind(cwd, baseRef, currentBranch, factsContext)
|
||||||
@@ -2421,6 +2440,7 @@ export async function getCheckoutStatus(
|
|||||||
isDirty,
|
isDirty,
|
||||||
baseRef: displayBaseRef ?? baseRef,
|
baseRef: displayBaseRef ?? baseRef,
|
||||||
baseRefExact: baseRef,
|
baseRefExact: baseRef,
|
||||||
|
comparisonBaseRef,
|
||||||
aheadBehind,
|
aheadBehind,
|
||||||
upstreamRef,
|
upstreamRef,
|
||||||
aheadOfOrigin,
|
aheadOfOrigin,
|
||||||
@@ -2440,6 +2460,7 @@ export async function getCheckoutStatus(
|
|||||||
isDirty,
|
isDirty,
|
||||||
baseRef: displayBaseRef,
|
baseRef: displayBaseRef,
|
||||||
baseRefExact: baseRef,
|
baseRefExact: baseRef,
|
||||||
|
comparisonBaseRef,
|
||||||
aheadBehind,
|
aheadBehind,
|
||||||
upstreamRef,
|
upstreamRef,
|
||||||
aheadOfOrigin,
|
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),
|
* binary-only (no textual hunks). Throws on git failure (e.g. an unknown sha),
|
||||||
* which the caller maps to a typed checkout error.
|
* 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({
|
export async function getCommitFileDiff({
|
||||||
cwd,
|
cwd,
|
||||||
sha,
|
sha,
|
||||||
@@ -3570,6 +3592,21 @@ async function resolveCheckoutDiffRefs(
|
|||||||
if (compare.mode === "uncommitted") {
|
if (compare.mode === "uncommitted") {
|
||||||
return { baseRef: "HEAD", includeUntracked: true };
|
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 { storedBaseRef, resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
|
||||||
const baseRef = resolveOperationBaseRef({
|
const baseRef = resolveOperationBaseRef({
|
||||||
storedBaseRef,
|
storedBaseRef,
|
||||||
@@ -3579,17 +3616,12 @@ async function resolveCheckoutDiffRefs(
|
|||||||
if (!baseRef) {
|
if (!baseRef) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const bestBaseRef = await resolveBestComparisonBaseRef(cwd, baseRef);
|
return resolveBestComparisonBaseRef(cwd, baseRef);
|
||||||
return {
|
|
||||||
baseRef: (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef,
|
|
||||||
targetRef: compare.mode === "base" ? "HEAD" : undefined,
|
|
||||||
includeUntracked: compare.mode === "all",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Git's working-tree diff ignores files absent from the index, even when they
|
// 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.
|
// 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"], {
|
const untracked = await runGitCommand(["ls-files", "-z", "--others", "--exclude-standard"], {
|
||||||
cwd,
|
cwd,
|
||||||
envOverlay: READ_ONLY_GIT_ENV,
|
envOverlay: READ_ONLY_GIT_ENV,
|
||||||
@@ -3612,21 +3644,41 @@ async function prepareAllChangesIndex(cwd: string, baseRef: string) {
|
|||||||
entries += result.stdout;
|
entries += result.stdout;
|
||||||
}
|
}
|
||||||
if (!entries) return null;
|
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");
|
const indexFile = resolve(directory, "index");
|
||||||
try {
|
try {
|
||||||
const index = await runGitCommand(["rev-parse", "--git-path", "index"], {
|
const index = await runGitCommand(["rev-parse", "--git-path", "index"], {
|
||||||
cwd,
|
cwd,
|
||||||
envOverlay: READ_ONLY_GIT_ENV,
|
envOverlay: READ_ONLY_GIT_ENV,
|
||||||
});
|
});
|
||||||
const indexPath = resolveGitRevParsePath(cwd, index.stdout);
|
const source = resolveGitRevParsePath(cwd, index.stdout);
|
||||||
if (!indexPath) throw new Error("Git did not return an index path");
|
if (!source) throw new Error("Git did not return an index path");
|
||||||
await copyFile(indexPath, indexFile);
|
try {
|
||||||
await runGitCommand(["update-index", "-z", "--index-info"], {
|
await copyFile(source, indexFile);
|
||||||
cwd,
|
} catch (error) {
|
||||||
input: entries,
|
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error;
|
||||||
envOverlay: { ...READ_ONLY_GIT_ENV, GIT_INDEX_FILE: indexFile },
|
await runGitCommand(["read-tree", "--empty"], {
|
||||||
});
|
cwd,
|
||||||
|
envOverlay: { ...READ_ONLY_GIT_ENV, GIT_INDEX_FILE: indexFile },
|
||||||
|
});
|
||||||
|
}
|
||||||
return { indexFile, cleanup: () => rm(directory, { recursive: true, force: true }) };
|
return { indexFile, cleanup: () => rm(directory, { recursive: true, force: true }) };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await rm(directory, { recursive: true, force: true });
|
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.
|
// Match the existing git-show ceiling. Oversized batches use bounded per-file reads.
|
||||||
const MAX_CONTENT_BYTES = 20 * 1024 * 1024;
|
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");
|
if (offset !== result.stdout.length) throw new Error("Unexpected git object output");
|
||||||
return contents;
|
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";
|
} from "./git-process-scheduler.js";
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
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;
|
const DEFAULT_STDERR_LIMIT = 2048;
|
||||||
|
|
||||||
let gitProcessScheduler = new GitProcessScheduler(resolveGitProcessPolicy({ env: process.env }));
|
let gitProcessScheduler = new GitProcessScheduler(resolveGitProcessPolicy({ env: process.env }));
|
||||||
|
|||||||
Reference in New Issue
Block a user