feat(diff): stream Changes revisions without blocking the socket (#8907)

* feat(protocol): define streamed diff revisions and resolve tag bases

* feat(server): stream shared diff revisions with paced file bodies

* feat(client): observe streamed diffs with atomic revision updates

* feat(app): show the Changes tree while diff bodies stream

* fix(server): retain the legacy structured diff payload budget

* fix(diff): preserve revision ordering and readable files

* fix(diff): select renamed patches and read dangling symlinks

* fix(diff): preserve nested checkouts and ambiguous patch paths

Only regular files and symlinks are readable untracked entries. Prefer patch metadata to ambiguous unquoted headers so paths containing a prefix delimiter keep their hunks.

* fix(diff): preserve BOM text and complete file replacements

Decode each string without consuming its leading BOM. File-type replacements emit multiple patch sections for one listed path, so retain all matching hunks. Assert the optional rename metadata in the existing commit-file journey.

* fix(diff): highlight symlinks from their stored target text

Working tree source context must match the symlink blob emitted by Git rather than the contents reached by following the link. Otherwise highlight runs do not cover the patch line and encoding fails the revision.

* fix(diff): tolerate disappearing highlight sources

Source context is optional after Git has produced the patch. Missing worktree link metadata must preserve the existing hunk-based highlighting fallback instead of failing the revision.

* fix(diff): batch cold reads and refresh logical comparison inputs

Acquire bounded pages of tracked patches lazily after Begin while retaining per-file highlighting and awaited frame delivery. Keep legacy preferred-base selection live across revisions and include submodule Git state in worktree cache identity.

* fix(diff): distinguish gitlink directories from replacements

A raw old gitlink mode also occurs when a submodule becomes a regular file. Query nested Git state only for a current directory, preserving normal file identity for replacements.

* fix(diff): invalidate timestamp-preserving file replacements

Size and modification time do not identify live file contents after a preserving copy. Include filesystem change time and file identity for tracked and untracked reuse, and verify live revisions agree with fresh reads.

* fix(diff): align highlight tokens with normalized patch lines

Git can normalize CRLF worktrees while highlighting reads their original line endings. Match tokens to the emitted patch text and trim only a normalization CR before encoding runs; mismatched source tokens are not attached.

* fix(diff): refresh equivalent worktree paths across platforms

Explicit refreshes must match filesystem-equivalent roots, including Windows path aliases. Use the existing realpath-aware matcher, budget real Git completion waits consistently, and assert optional status metadata in the filesystem commit-file journey.

* fix(diff): validate Git inputs and resolve refresh roots

Reuse now accounts for effective attributes, configuration, and canonical worktree blobs as well as filesystem state. Explicit refresh resolves repository identity like subscription creation, and callers await that resolution before acknowledging the command.

* fix(diff): keep non-Git workspace refresh successful

Diff invalidation has no target repository for an ordinary folder. Treat that expected absence as a no-op while preserving other root-resolution errors.

* fix(diff): observe scoped Git interpretation settings

Classify each metadata root with its worktree and common scopes, retaining both when the directories coincide. Shared attributes affect all checkouts, while config.worktree belongs to the owning checkout. Real subscriptions verify automatic binary and hunk-context refreshes in main and linked worktrees.

* fix(diff): observe interpretation inputs for pinned comparisons

Immutable commit objects still depend on mutable Git configuration and attributes. Declare those metadata inputs independently of ref, index, and worktree mutability, while keeping unrelated endpoint events out of pinned comparisons.

* fix(diff): observe interpretation independently of comparison endpoints

Commit comparisons still read worktree attributes. Watch those inputs independently, checking effective Git interpretation before invalidating fixed contents. Retain paths hidden by whitespace filtering so attribute changes can expose them. Forward binary frames through the branch-loading test interceptor.
This commit is contained in:
Mohamed Boudra
2026-11-08 11:00:21 +07:00
committed by GitHub
parent 2071bfda7f
commit fcde2072ab
66 changed files with 6240 additions and 1138 deletions
@@ -196,6 +196,8 @@ const INBOUND_PERMISSION = {
stash_pop_request: "workspace.write",
stash_save_request: "workspace.write",
"checkout.diff.get.request": "workspace.read",
"diff.subscribe.request": "workspace.read",
"diff.unsubscribe.request": "workspace.read",
subscribe_checkout_diff_request: "workspace.read",
subscribe_terminal_request: "workspace.read",
subscribe_terminals_request: "workspace.read",
@@ -443,6 +445,7 @@ const OUTBOUND_PERMISSION = {
stash_save_response: "workspace.read",
status: "daemon.read",
"checkout.diff.get.response": "workspace.read",
"diff.subscribe.response": "workspace.read",
subscribe_checkout_diff_response: "workspace.read",
subscribe_terminal_response: "workspace.read",
"terminal.rename.response": "workspace.write",
@@ -1,488 +1,253 @@
import { execFileSync } from "node:child_process";
import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import pino from "pino";
import { expect, test } from "vitest";
import { CheckoutDiffManager, type CheckoutDiffSnapshotPayload } from "./checkout-diff-manager.js";
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createFileObserver } from "./file-observer/index.js";
const { toCheckoutErrorMock } = vi.hoisted(() => ({
toCheckoutErrorMock: vi.fn((error: unknown) => ({
message: error instanceof Error ? error.message : String(error),
})),
}));
vi.mock("./checkout-git-utils.js", () => ({
toCheckoutError: toCheckoutErrorMock,
}));
import type pino from "pino";
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
interface Deferred<T> {
promise: Promise<T>;
resolve(value: T): void;
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function createWorkspaceSnapshot(
overrides?: Partial<WorkspaceGitRuntimeSnapshot["git"]>,
): WorkspaceGitRuntimeSnapshot {
return {
cwd: "/tmp/repo",
git: {
isGit: true,
repoRoot: "/tmp/repo",
mainRepoRoot: null,
currentBranch: "feature",
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: false,
isDirty: false,
baseRef: "main",
aheadBehind: { ahead: 1, behind: 0 },
aheadOfOrigin: 1,
behindOfOrigin: 0,
hasRemote: true,
diffStat: { additions: 1, deletions: 0 },
...overrides,
},
forge: {
featuresEnabled: false,
authState: "no_remote",
pullRequest: null,
error: null,
},
};
}
function createPendingManager() {
const watches: Array<{
async function withCheckout(
run: (input: {
cwd: string;
onChange: () => void;
unsubscribeCalls: number;
resolve(): void;
}> = [];
const workspaceGitService = {
getCheckoutDiff: async () => ({ diff: "", structured: [] }),
getSnapshot: async () => createWorkspaceSnapshot(),
registerWorkspace: () => ({ unsubscribe: () => {} }),
requestWorkingTreeWatch: (cwd: string, onChange: () => void) => {
const pending = createDeferred<{ repoRoot: string | null; unsubscribe: () => void }>();
const watch = {
cwd,
onChange,
unsubscribeCalls: 0,
resolve: () => {
pending.resolve({
repoRoot: "/tmp/repo",
unsubscribe: () => {
watch.unsubscribeCalls += 1;
},
});
},
};
watches.push(watch);
return pending.promise;
},
};
const logger = { child: () => logger, warn: () => {} };
manager: CheckoutDiffManager;
git(...args: string[]): string;
}) => Promise<void>,
): Promise<void> {
const cwd = await mkdtemp(join(tmpdir(), "legacy-diff-adapter-"));
const observer = createFileObserver();
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const manager = new CheckoutDiffManager({
logger: pino({ level: "silent" }),
paseoHome: cwd,
workspaceRuntimeStore: createWorkspaceRuntimeStore(),
logger: logger as unknown as pino.Logger,
paseoHome: "/tmp/paseo-test",
workspaceGitService,
workspaceGitService: {
async requestWorkingTreeWatch(root, onChange) {
const subscription = await observer.subscribe(root, () => onChange(), {
ignore: [join(root, ".git")],
});
return {
repoRoot: root,
unsubscribe: async () => {
await subscription.unsubscribe();
},
};
},
},
});
return { manager, watches };
try {
git("init", "-b", "main");
git("config", "user.email", "test@example.com");
git("config", "user.name", "Test");
await writeFile(join(cwd, "README"), "base\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await run({ cwd, manager, git });
} finally {
await manager.dispose();
await observer.close();
await rm(cwd, { recursive: true, force: true });
}
}
describe("CheckoutDiffManager", () => {
beforeEach(() => {
vi.useFakeTimers();
toCheckoutErrorMock.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
function createManager(options?: {
repoRoot?: string | null;
getCheckoutDiffImplementation?: ReturnType<typeof vi.fn>;
}) {
const unsubscribe = vi.fn();
const workspaceUnsubscribe = vi.fn();
let onChange: (() => void) | null = null;
let onWorkspaceSnapshot: ((snapshot: WorkspaceGitRuntimeSnapshot) => void) | null = null;
const mockRequestWorkingTreeWatch = vi.fn(async (_cwd: string, listener: () => void) => {
onChange = listener;
return {
repoRoot: options?.repoRoot === undefined ? "/tmp/repo" : options.repoRoot,
unsubscribe,
};
});
const workspaceGitService = {
subscribe: vi.fn(),
registerWorkspace: vi.fn(
(_params: { cwd: string }, listener: (snapshot: WorkspaceGitRuntimeSnapshot) => void) => {
onWorkspaceSnapshot = listener;
return { unsubscribe: workspaceUnsubscribe };
},
test("legacy callers share one live comparison, receive edits, and release it", async () => {
await withCheckout(async ({ cwd, manager }) => {
await mkdir(join(cwd, "sub"));
await writeFile(join(cwd, "x.ts"), "const value = 1;\n");
const first: CheckoutDiffSnapshotPayload[] = [];
const second: CheckoutDiffSnapshotPayload[] = [];
const [a, b] = await Promise.all([
manager.subscribe({ cwd: join(cwd, "sub"), compare: { mode: "uncommitted" } }, (snapshot) =>
first.push(snapshot),
),
getSnapshot: vi.fn(async () => createWorkspaceSnapshot()),
getCheckoutDiff:
options?.getCheckoutDiffImplementation ?? vi.fn(async () => ({ diff: "", structured: [] })),
refresh: vi.fn(),
scheduleRefreshForCwd: vi.fn(),
requestWorkingTreeWatch: mockRequestWorkingTreeWatch,
dispose: vi.fn(),
};
const logger = {
child: () => logger,
warn: vi.fn(),
};
const manager = new CheckoutDiffManager({
workspaceRuntimeStore: createWorkspaceRuntimeStore(),
logger: logger as unknown as pino.Logger,
paseoHome: "/tmp/paseo-test",
workspaceGitService: workspaceGitService as unknown as WorkspaceGitService,
manager.subscribe({ cwd, compare: { mode: "uncommitted" } }, (snapshot) =>
second.push(snapshot),
),
]);
expect(a.initial.cwd).toBe(join(cwd, "sub"));
expect(a.initial.files).toEqual(b.initial.files);
expect(a.initial.files.map((file) => file.path)).toEqual(["x.ts"]);
expect(manager.getMetrics()).toMatchObject({
checkoutDiffTargetCount: 1,
checkoutDiffSubscriptionCount: 2,
});
await writeFile(join(cwd, "x.ts"), "const value = 2;\n");
await expect.poll(() => first.length, { timeout: 10000 }).toBeGreaterThan(0);
expect(first.at(-1)?.files).toEqual(second.at(-1)?.files);
await a.unsubscribe();
await b.unsubscribe();
expect(manager.getMetrics().checkoutDiffTargetCount).toBe(0);
});
}, 30_000);
return {
manager,
workspaceGitService,
mockRequestWorkingTreeWatch,
unsubscribe,
getOnChange: () => onChange,
getOnWorkspaceSnapshot: () => onWorkspaceSnapshot,
workspaceUnsubscribe,
};
}
test("subscribe requests a working tree watch with the correct cwd", async () => {
const { manager, mockRequestWorkingTreeWatch } = createManager();
await manager.subscribe(
{
cwd: "/tmp/repo/packages/server",
compare: { mode: "uncommitted" },
},
() => {},
test("Committed ignores working-tree edits while All and Uncommitted include them", async () => {
await withCheckout(async ({ cwd, manager, git }) => {
git("checkout", "-b", "feature");
await writeFile(join(cwd, "x.ts"), "const value = 1;\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "feature");
await writeFile(join(cwd, "x.ts"), "const value = 2;\n");
const committed = await manager.read({ cwd, compare: { mode: "base", baseRef: "main" } });
const all = await manager.read({ cwd, compare: { mode: "all", baseRef: "main" } });
const uncommitted = await manager.read({ cwd, compare: { mode: "uncommitted" } });
expect(committed.files[0].hunks[0].lines).toContainEqual(
expect.objectContaining({ type: "add", content: "const value = 1;" }),
);
expect(mockRequestWorkingTreeWatch).toHaveBeenCalledWith(
"/tmp/repo/packages/server",
expect.any(Function),
expect(all.files[0].hunks[0].lines).toContainEqual(
expect.objectContaining({ type: "add", content: "const value = 2;" }),
);
expect(uncommitted.files[0].hunks[0].lines).toContainEqual(
expect.objectContaining({ type: "remove", content: "const value = 1;" }),
);
});
}, 30_000);
test("unsubscribe calls the working tree watch unsubscribe", async () => {
const { manager, unsubscribe } = createManager();
const subscription = await manager.subscribe(
{
cwd: "/tmp/repo/packages/server",
compare: { mode: "uncommitted" },
},
() => {},
);
subscription.unsubscribe();
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
test("cancels a subscription while its working tree watch is still opening", async () => {
const { manager, watches } = createPendingManager();
test("cancelling a legacy subscriber during a shared revision does not revive it", async () => {
await withCheckout(async ({ cwd, manager }) => {
await writeFile(join(cwd, "x.ts"), "export const value = 1;\n");
const abort = new AbortController();
const pendingSubscription = manager.subscribe(
{
cwd: "/tmp/repo/packages/server",
compare: { mode: "uncommitted" },
signal: abort.signal,
let cancelled = false;
const modern = await manager.streams.subscribe(
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } },
async (frame) => {
if (frame.opcode !== 0x40 || cancelled) return;
await waitForSubscriberCount(manager, 2);
cancelled = true;
abort.abort();
},
() => {},
);
abort.abort();
watches[0].resolve();
await pendingSubscription;
expect(watches[0].unsubscribeCalls).toBe(1);
expect(manager.getMetrics()).toEqual({
checkoutDiffTargetCount: 0,
checkoutDiffSubscriptionCount: 0,
checkoutDiffWatcherCount: 0,
checkoutDiffFallbackRefreshTargetCount: 0,
});
});
test("shares one opening target between concurrent subscriptions", async () => {
const { manager, watches } = createPendingManager();
const firstSubscription = manager.subscribe(
{ cwd: "/tmp/repo/packages/server", compare: { mode: "uncommitted" } },
() => {},
);
const secondSubscription = manager.subscribe(
{ cwd: "/tmp/repo/packages/server", compare: { mode: "uncommitted" } },
() => {},
);
expect(watches).toHaveLength(1);
watches[0].resolve();
const [first, second] = await Promise.all([firstSubscription, secondSubscription]);
expect(manager.getMetrics().checkoutDiffSubscriptionCount).toBe(2);
first.unsubscribe();
expect(watches[0].unsubscribeCalls).toBe(0);
second.unsubscribe();
expect(watches[0].unsubscribeCalls).toBe(1);
});
test("diffCwd uses repoRoot from the working tree watch result", async () => {
const { manager, workspaceGitService } = createManager({ repoRoot: "/tmp/repo" });
await manager.subscribe(
{
cwd: "/tmp/repo/packages/server",
compare: { mode: "uncommitted" },
const legacy = manager.subscribe(
{ cwd, compare: { mode: "uncommitted" }, signal: abort.signal },
() => {
throw new Error("Cancelled subscriber received an update");
},
() => {},
);
expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledWith(
"/tmp/repo",
expect.objectContaining({ mode: "uncommitted", includeStructured: true }),
undefined,
);
});
test("diff refresh is triggered when the working tree watch callback fires", async () => {
const getCheckoutDiff = vi
.fn()
.mockResolvedValueOnce({
diff: "",
structured: [{ path: "a.ts", additions: 1, deletions: 0, status: "modified" }],
})
.mockResolvedValueOnce({
diff: "",
structured: [{ path: "b.ts", additions: 2, deletions: 0, status: "modified" }],
try {
await modern.start();
const result = await legacy;
expect(result.initial).toMatchObject({
files: [],
error: { message: "Diff subscription cancelled" },
});
const { manager, getOnChange } = createManager({
getCheckoutDiffImplementation: getCheckoutDiff,
});
const listener = vi.fn();
await manager.subscribe(
{
cwd: "/tmp/repo/packages/server",
compare: { mode: "uncommitted" },
},
listener,
);
const onChange = getOnChange();
expect(onChange).toBeTypeOf("function");
onChange?.();
await vi.advanceTimersByTimeAsync(150);
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith({
cwd: "/tmp/repo/packages/server",
files: [{ path: "b.ts", additions: 2, deletions: 0, status: "modified" }],
error: null,
});
});
test("watch-triggered refresh forces a cache bypass on getCheckoutDiff", async () => {
const getCheckoutDiff = vi
.fn()
.mockResolvedValueOnce({
diff: "",
structured: [{ path: "a.ts", additions: 1, deletions: 0, status: "modified" }],
})
.mockResolvedValueOnce({
diff: "",
structured: [{ path: "b.ts", additions: 2, deletions: 0, status: "modified" }],
});
const { manager, getOnChange } = createManager({
getCheckoutDiffImplementation: getCheckoutDiff,
});
await manager.subscribe(
{
cwd: "/tmp/repo/packages/server",
compare: { mode: "uncommitted" },
},
vi.fn(),
);
expect(getCheckoutDiff).toHaveBeenNthCalledWith(
1,
"/tmp/repo",
expect.objectContaining({ mode: "uncommitted" }),
undefined,
);
const onChange = getOnChange();
onChange?.();
await vi.advanceTimersByTimeAsync(150);
expect(getCheckoutDiff).toHaveBeenCalledTimes(2);
const watchFiredCall = getCheckoutDiff.mock.calls[1];
expect(watchFiredCall[2]).toEqual({
force: true,
reason: expect.stringContaining("working-tree"),
});
});
test("an edit during an in-flight diff refresh produces one final follow-up", async () => {
const inFlightDiff = createDeferred<{
diff: string;
structured: Array<{
path: string;
additions: number;
deletions: number;
status: "modified";
}>;
}>();
const getCheckoutDiff = vi
.fn()
.mockResolvedValueOnce({
diff: "",
structured: [{ path: "tracked.ts", additions: 1, deletions: 0, status: "modified" }],
})
.mockImplementationOnce(() => inFlightDiff.promise)
.mockResolvedValue({
diff: "",
structured: [{ path: "tracked.ts", additions: 100, deletions: 25, status: "modified" }],
});
const { manager, getOnChange } = createManager({
getCheckoutDiffImplementation: getCheckoutDiff,
});
const listener = vi.fn();
await manager.subscribe({ cwd: "/tmp/repo", compare: { mode: "uncommitted" } }, listener);
getOnChange()?.();
await vi.advanceTimersByTimeAsync(150);
expect(getCheckoutDiff).toHaveBeenCalledTimes(2);
for (let event = 0; event < 100; event += 1) {
getOnChange()?.();
await result.unsubscribe();
} finally {
await modern.release();
}
await vi.advanceTimersByTimeAsync(150);
expect(getCheckoutDiff).toHaveBeenCalledTimes(2);
inFlightDiff.resolve({
diff: "",
structured: [{ path: "tracked.ts", additions: 2, deletions: 1, status: "modified" }],
});
await vi.waitFor(() => {
expect(getCheckoutDiff).toHaveBeenCalledTimes(3);
expect(listener).toHaveBeenLastCalledWith({
cwd: "/tmp/repo",
files: [{ path: "tracked.ts", additions: 100, deletions: 25, status: "modified" }],
error: null,
});
});
});
}, 30_000);
test("base diff subscriptions ignore ordinary working tree edits", async () => {
const getCheckoutDiff = vi.fn(async () => ({
diff: "",
structured: [{ path: "committed.ts", additions: 1, deletions: 0, status: "modified" }],
}));
const { manager, getOnChange, mockRequestWorkingTreeWatch } = createManager({
getCheckoutDiffImplementation: getCheckoutDiff,
async function waitForSubscriberCount(manager: CheckoutDiffManager, count: number): Promise<void> {
await expect
.poll(() => manager.getMetrics().checkoutDiffSubscriptionCount, { timeout: 10_000 })
.toBe(count);
}
test("moving a tag refreshes binary file metadata even though binary files have no bodies", async () => {
await withCheckout(async ({ cwd, manager, git }) => {
git("tag", "v1");
await writeFile(join(cwd, "image.bin"), Buffer.from([0, 1]));
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "binary");
await writeFile(join(cwd, "image.bin"), Buffer.from([0, 2]));
const updates: CheckoutDiffSnapshotPayload[] = [];
const subscription = await manager.subscribe(
{ cwd, compare: { mode: "all", baseRef: "v1" } },
(snapshot) => updates.push(snapshot),
);
expect(subscription.initial.files[0]).toMatchObject({
path: "image.bin",
isNew: true,
status: "binary",
});
await manager.subscribe(
{ cwd: "/tmp/repo", compare: { mode: "base", baseRef: "main" } },
vi.fn(),
);
getOnChange()?.();
await vi.advanceTimersByTimeAsync(150);
expect(getCheckoutDiff).toHaveBeenCalledTimes(1);
expect(mockRequestWorkingTreeWatch).not.toHaveBeenCalled();
git("update-ref", "refs/tags/v1", "HEAD");
await expect.poll(() => updates.at(-1)?.files[0]?.isNew, { timeout: 10000 }).toBe(false);
await subscription.unsubscribe();
});
}, 30000);
test("base diff subscriptions ignore worktree-only workspace snapshot updates", async () => {
const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: [] }));
const { manager, getOnWorkspaceSnapshot } = createManager({
getCheckoutDiffImplementation: getCheckoutDiff,
test("legacy diffs retain successful bodies when an oversized sibling is removed", async () => {
await withCheckout(async ({ cwd, manager }) => {
await writeFile(join(cwd, "a.ts"), "const value = 1;\n");
await writeFile(join(cwd, "b.txt"), "x".repeat(1024 * 1024 + 1));
const updates: CheckoutDiffSnapshotPayload[] = [];
const subscription = await manager.subscribe(
{ cwd, compare: { mode: "uncommitted" } },
(snapshot) => updates.push(snapshot),
);
expect(subscription.initial.files).toMatchObject([
{ path: "a.ts", status: "ok" },
{ path: "b.txt", status: "too_large" },
]);
await rm(join(cwd, "b.txt"));
await manager.scheduleRefreshForCwd(cwd);
await expect.poll(() => updates.at(-1)?.error).toBeNull();
expect(updates.at(-1)?.files[0]).toMatchObject({
path: "a.ts",
hunks: [
{
lines: expect.arrayContaining([expect.objectContaining({ content: "const value = 1;" })]),
},
],
});
await manager.subscribe(
{ cwd: "/tmp/repo", compare: { mode: "base", baseRef: "main" } },
vi.fn(),
);
getOnWorkspaceSnapshot()?.(
createWorkspaceSnapshot({
isDirty: true,
diffStat: { additions: 5, deletions: 2 },
}),
);
await vi.advanceTimersByTimeAsync(150);
expect(getCheckoutDiff).toHaveBeenCalledTimes(1);
});
test("base diff subscriptions refresh for structural workspace changes", async () => {
const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: [] }));
const { manager, getOnWorkspaceSnapshot, workspaceGitService } = createManager({
getCheckoutDiffImplementation: getCheckoutDiff,
});
await manager.subscribe(
{ cwd: "/tmp/repo", compare: { mode: "base", baseRef: "main" } },
vi.fn(),
);
expect(workspaceGitService.registerWorkspace).toHaveBeenCalledTimes(1);
getOnWorkspaceSnapshot()?.(
createWorkspaceSnapshot({
currentBranch: "feature-2",
isDirty: true,
diffStat: { additions: 5, deletions: 2 },
}),
);
await vi.advanceTimersByTimeAsync(150);
expect(getCheckoutDiff).toHaveBeenCalledTimes(2);
expect(getCheckoutDiff.mock.calls[1]?.[2]).toBeUndefined();
getOnWorkspaceSnapshot()?.(createWorkspaceSnapshot({ aheadBehind: { ahead: 2, behind: 0 } }));
await vi.advanceTimersByTimeAsync(150);
expect(getCheckoutDiff).toHaveBeenCalledTimes(3);
});
test("falls back to cwd when the working tree watch returns no repo root", async () => {
const { manager, workspaceGitService } = createManager({ repoRoot: null });
await manager.subscribe(
{
cwd: "/tmp/plain",
compare: { mode: "uncommitted" },
},
() => {},
);
expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledWith(
"/tmp/plain",
expect.objectContaining({ mode: "uncommitted", includeStructured: true }),
undefined,
);
subscription.unsubscribe();
});
});
test("legacy subscriptions reselect the preferred base when a remote ref appears", async () => {
await withCheckout(async ({ cwd, manager, git }) => {
git("checkout", "-b", "feature");
await writeFile(join(cwd, "b.ts"), "export const b = 1;\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "feature");
const updates: CheckoutDiffSnapshotPayload[] = [];
const params = { cwd, compare: { mode: "base" as const, baseRef: "main" } };
const subscription = await manager.subscribe(params, (snapshot) => updates.push(snapshot));
try {
expect(subscription.initial.files.map((file) => file.path)).toEqual(["b.ts"]);
git("update-ref", "refs/remotes/origin/main", "HEAD");
await manager.scheduleRefreshForCwd(cwd);
await expect.poll(() => updates.at(-1)?.files.length, { timeout: 5000 }).toBe(0);
expect(updates.at(-1)).toEqual(await manager.read(params));
} finally {
await subscription.unsubscribe();
}
});
});
test("cold many-file reads batch Git patches while matching the legacy reader", async () => {
const { getCheckoutDiff } = await import("../utils/checkout-git.js");
const { startGitCommandMetrics, stopGitCommandMetrics } =
await import("../utils/run-git-command.js");
await withCheckout(async ({ cwd, manager, git }) => {
const count = 1000;
for (let index = 0; index < count; index++)
await writeFile(join(cwd, `file-${index}.ts`), "export const value = 0;\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "many files");
for (let index = 0; index < count; index++)
await writeFile(join(cwd, `file-${index}.ts`), "export const value = 1;\n");
const legacyStart = performance.now();
const legacy = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true });
const legacyMs = performance.now() - legacyStart;
startGitCommandMetrics();
const streamedStart = performance.now();
const streamed = await manager.read({ cwd, compare: { mode: "uncommitted" } });
const streamedMs = performance.now() - streamedStart;
const metrics = stopGitCommandMetrics();
const patches = metrics.commands.filter((command) => command.args.includes("--patch"));
const artifactDirectory = join(import.meta.dirname, "../../../../.artifacts/diff-subscription");
await mkdir(artifactDirectory, { recursive: true });
await writeFile(
join(artifactDirectory, "cold-many-file-metrics.json"),
JSON.stringify({ count, legacyMs, streamedMs, patchCommands: patches.length }, null, 2),
);
expect(streamed.error).toBeNull();
expect(streamed.files).toHaveLength(count);
expect(legacy.structured).toHaveLength(count);
expect(streamed.files.map((file) => [file.path, file.hunks])).toEqual(
legacy.structured?.map((file) => [file.path, file.hunks]),
);
expect(patches.length).toBeLessThanOrEqual(Math.ceil(count / 64));
});
}, 60000);
@@ -1,400 +1,201 @@
import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
import type pino from "pino";
import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js";
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import type { WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
import { DiffSubscriptions, type CommitFileRead } from "./diff/index.js";
import {
DiffOpcode,
materializeDiffFile,
type DiffFrame,
type DiffBegin,
type DiffBody,
} from "@getpaseo/protocol/binary-frames/index";
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
import {
CHECKOUT_DIFF_MAX_STRUCTURED_BYTES,
resolveCheckoutDiffBase,
} from "../utils/checkout-git.js";
import { expandTilde } from "../utils/path.js";
import { toCheckoutError } from "./checkout-git-utils.js";
const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150;
type CheckoutDiffWorkspace = Pick<
WorkspaceGitService,
"getCheckoutDiff" | "getSnapshot" | "registerWorkspace" | "requestWorkingTreeWatch"
>;
export type CheckoutDiffCompareInput = SubscribeCheckoutDiffRequest["compare"];
export type CheckoutDiffSnapshotPayload = Omit<
Extract<SessionOutboundMessage, { type: "checkout_diff_update" }>["payload"],
"subscriptionId"
>;
export interface CheckoutDiffMetrics {
checkoutDiffTargetCount: number;
checkoutDiffSubscriptionCount: number;
checkoutDiffWatcherCount: number;
checkoutDiffFallbackRefreshTargetCount: number;
}
interface CheckoutDiffWatchTarget {
key: string;
cwd: string;
diffCwd: string;
compare: CheckoutDiffCompareInput;
listeners: Set<(snapshot: CheckoutDiffSnapshotPayload) => void>;
workingTreeWatchUnsubscribe: (() => void) | null;
workspaceGitUnsubscribe: (() => void) | null;
latestWorkspaceStructureFingerprint: string | null;
latestWorkspaceWorktreeFingerprint: string | null;
latestWorkspaceForgeFingerprint: string | null;
debounceTimer: NodeJS.Timeout | null;
pendingDebounceForce: boolean;
refreshPromise: Promise<void> | null;
refreshQueued: boolean;
refreshQueuedForce: boolean;
latestPayload: CheckoutDiffSnapshotPayload | null;
latestFingerprint: string | null;
openPromise: Promise<void> | null;
}
export interface CheckoutDiffSubscriptionRequest {
cwd: string;
compare: CheckoutDiffCompareInput;
signal?: AbortSignal;
}
export interface CheckoutDiffSubscription {
initial: CheckoutDiffSnapshotPayload;
unsubscribe: () => void;
unsubscribe(): void;
}
// COMPAT(diffSubscription): added in v0.15.0, remove after 2027-05-06.
// Old clients receive the same JSON snapshot while the shared engine owns reads and observation.
export class CheckoutDiffManager {
private readonly workspaceGitService: CheckoutDiffWorkspace;
private readonly workspaceRuntimeStore: WorkspaceRuntimeStore;
private readonly targets = new Map<string, CheckoutDiffWatchTarget>();
readonly streams: DiffSubscriptions;
private readonly paseoHome: string;
constructor(options: {
logger: pino.Logger;
paseoHome: string;
workspaceGitService: CheckoutDiffWorkspace;
workspaceGitService: Pick<WorkspaceGitService, "requestWorkingTreeWatch">;
workspaceRuntimeStore: WorkspaceRuntimeStore;
}) {
this.workspaceGitService = options.workspaceGitService;
this.workspaceRuntimeStore = options.workspaceRuntimeStore;
this.streams = new DiffSubscriptions(options.workspaceGitService);
this.paseoHome = options.paseoHome;
}
readCommitFile(input: CommitFileRead) {
return this.streams.readCommitFile(input);
}
async read(
params: Omit<CheckoutDiffSubscriptionRequest, "signal">,
): Promise<CheckoutDiffSnapshotPayload> {
return this.computeCheckoutDiffSnapshot(params.cwd, this.normalizeCompare(params.compare));
const subscription = await this.subscribe(params, () => {});
await subscription.unsubscribe();
return subscription.initial;
}
async subscribe(
params: CheckoutDiffSubscriptionRequest,
listener: (snapshot: CheckoutDiffSnapshotPayload) => void,
): Promise<CheckoutDiffSubscription> {
const cwd = params.cwd;
const compare = this.normalizeCompare(params.compare);
const target = this.ensureTarget(cwd, compare);
target.listeners.add(listener);
target.openPromise ??= this.openTarget(target);
let isSubscribed = true;
const unsubscribe = () => {
if (!isSubscribed) {
const cwd = expandTilde(params.cwd);
let resolveInitial!: (snapshot: CheckoutDiffSnapshotPayload) => void;
const initial = new Promise<CheckoutDiffSnapshotPayload>((resolve) => {
resolveInitial = resolve;
});
let delivered = false;
let pending: DiffBegin | null = null;
let files = new Map<string, ParsedDiffFile>();
let bodies = new Map<string, DiffBody>();
const receive = async (frame: DiffFrame) => {
if (params.signal?.aborted) return;
if (frame.opcode === DiffOpcode.Begin) {
pending = frame;
bodies = new Map();
return;
}
isSubscribed = false;
params.signal?.removeEventListener("abort", unsubscribe);
this.removeListener(target, listener);
};
params.signal?.addEventListener("abort", unsubscribe, { once: true });
if (params.signal?.aborted) {
unsubscribe();
}
try {
await target.openPromise;
const initial =
target.latestPayload ??
(await this.computeCheckoutDiffSnapshot(target.cwd, target.compare, {
diffCwd: target.diffCwd,
}));
target.latestPayload = initial;
target.latestFingerprint = JSON.stringify(initial);
return { initial, unsubscribe };
} catch (error) {
unsubscribe();
throw error;
}
}
scheduleRefreshForCwd(cwd: string): void {
const resolvedCwd = expandTilde(cwd);
for (const target of this.targets.values()) {
if (target.cwd !== resolvedCwd && target.diffCwd !== resolvedCwd) {
continue;
if (!pending || frame.revision !== pending.revision) return;
if (frame.opcode === DiffOpcode.Bodies) {
for (const body of frame.files) bodies.set(body.path, body);
return;
}
this.scheduleTargetRefresh(target);
let payload: CheckoutDiffSnapshotPayload;
if (frame.error) {
const message = frame.error.message;
payload = {
cwd,
files: [],
error: toCheckoutError(new Error(message)),
diffTooLarge: frame.diffTooLarge,
};
files.clear();
} else {
const next = new Map<string, ParsedDiffFile>();
const omitted = new Set(frame.omitted);
for (const entry of pending.entries) {
const previous = pending.baseRevision ? files.get(entry.path) : undefined;
const body = bodies.get(entry.path) ?? null;
const file = materializeDiffFile(entry, body, omitted.has(entry.path));
if (!entry.body && previous && entry.content === "text" && !omitted.has(entry.path))
file.hunks = previous.hunks;
next.set(entry.path, file);
}
files = next;
// Stream omissions remain file placeholders; the JSON budget rejects a legacy snapshot.
payload = legacySnapshot(cwd, [...next.values()]);
}
pending = null;
if (delivered) listener(payload);
else {
delivered = true;
resolveInitial(payload);
}
};
try {
const baseRef = await resolveCheckoutDiffBase(cwd, params.compare, {
paseoHome: this.paseoHome,
});
if (!baseRef) return { initial: { cwd, files: [], error: null }, unsubscribe() {} };
const subscription = await this.streams.subscribe(
{
cwd,
base: { kind: "rev", rev: baseRef },
checkoutBase:
params.compare.mode === "uncommitted"
? undefined
: { compare: params.compare, paseoHome: this.paseoHome },
head:
params.compare.mode === "base" ? { kind: "rev", rev: "HEAD" } : { kind: "worktree" },
mergeBase: params.compare.mode !== "uncommitted",
ignoreWhitespace: params.compare.ignoreWhitespace,
},
receive,
);
const unsubscribe = async () => {
if (!delivered) {
delivered = true;
resolveInitial({
cwd,
files: [],
error: toCheckoutError(new Error("Diff subscription cancelled")),
});
}
params.signal?.removeEventListener("abort", unsubscribe);
await subscription.release();
};
params.signal?.addEventListener("abort", unsubscribe, { once: true });
if (params.signal?.aborted) {
await subscription.release();
throw new Error("Diff subscription cancelled");
}
await subscription.start();
return { initial: await initial, unsubscribe };
} catch (error) {
return { initial: { cwd, files: [], error: toCheckoutError(error) }, unsubscribe() {} };
}
}
scheduleRefreshForCwd(cwd: string): Promise<void> {
return this.streams.invalidateForCwd(expandTilde(cwd));
}
getMetrics(): CheckoutDiffMetrics {
let checkoutDiffSubscriptionCount = 0;
for (const target of this.targets.values()) {
checkoutDiffSubscriptionCount += target.listeners.size;
}
const metrics = this.streams.getMetrics();
return {
checkoutDiffTargetCount: this.targets.size,
checkoutDiffSubscriptionCount,
checkoutDiffWatcherCount: 0,
checkoutDiffTargetCount: metrics.targets,
checkoutDiffSubscriptionCount: metrics.subscriptions,
checkoutDiffWatcherCount: metrics.metadataObservers,
checkoutDiffFallbackRefreshTargetCount: 0,
};
}
dispose(): void {
for (const target of this.targets.values()) {
this.closeTarget(target);
}
this.targets.clear();
}
private normalizeCompare(compare: CheckoutDiffCompareInput): CheckoutDiffCompareInput {
const ignoreWhitespace = compare.ignoreWhitespace === true;
if (compare.mode === "uncommitted") {
return { mode: "uncommitted", ignoreWhitespace };
}
const trimmedBaseRef = compare.baseRef?.trim();
return trimmedBaseRef
? { mode: compare.mode, baseRef: trimmedBaseRef, ignoreWhitespace }
: { mode: compare.mode, ignoreWhitespace };
}
private buildTargetKey(cwd: string, compare: CheckoutDiffCompareInput): string {
return JSON.stringify([
cwd,
compare.mode,
compare.mode !== "uncommitted" ? (compare.baseRef ?? "") : "",
compare.ignoreWhitespace === true,
]);
}
private closeTarget(target: CheckoutDiffWatchTarget): void {
if (target.debounceTimer) {
clearTimeout(target.debounceTimer);
target.debounceTimer = null;
}
target.workingTreeWatchUnsubscribe?.();
target.workingTreeWatchUnsubscribe = null;
target.workspaceGitUnsubscribe?.();
target.workspaceGitUnsubscribe = null;
target.listeners.clear();
}
private rememberWorkspaceSnapshot(
target: CheckoutDiffWatchTarget,
snapshot: WorkspaceGitRuntimeSnapshot,
): void {
const structureFingerprint = JSON.stringify({
isGit: snapshot.git.isGit,
repoRoot: snapshot.git.repoRoot,
mainRepoRoot: snapshot.git.mainRepoRoot,
currentBranch: snapshot.git.currentBranch,
remoteUrl: snapshot.git.remoteUrl,
isPaseoOwnedWorktree: snapshot.git.isPaseoOwnedWorktree,
baseRef: snapshot.git.baseRef,
baseRefExact: snapshot.git.baseRefExact,
aheadBehind: snapshot.git.aheadBehind,
aheadOfOrigin: snapshot.git.aheadOfOrigin,
behindOfOrigin: snapshot.git.behindOfOrigin,
hasRemote: snapshot.git.hasRemote,
});
const worktreeFingerprint = JSON.stringify({
isDirty: snapshot.git.isDirty,
diffStat: snapshot.git.diffStat,
});
const forgeFingerprint = JSON.stringify(snapshot.forge);
const previousStructureFingerprint = target.latestWorkspaceStructureFingerprint;
const previousWorktreeFingerprint = target.latestWorkspaceWorktreeFingerprint;
const previousForgeFingerprint = target.latestWorkspaceForgeFingerprint;
target.latestWorkspaceStructureFingerprint = structureFingerprint;
target.latestWorkspaceWorktreeFingerprint = worktreeFingerprint;
target.latestWorkspaceForgeFingerprint = forgeFingerprint;
if (previousStructureFingerprint === null) {
return;
}
const structureChanged = structureFingerprint !== previousStructureFingerprint;
const worktreeChanged = worktreeFingerprint !== previousWorktreeFingerprint;
const forgeChanged = forgeFingerprint !== previousForgeFingerprint;
if (structureChanged || (!worktreeChanged && !forgeChanged)) {
this.scheduleTargetRefresh(target, false);
}
}
private removeListener(
target: CheckoutDiffWatchTarget,
listener: (snapshot: CheckoutDiffSnapshotPayload) => void,
): void {
target.listeners.delete(listener);
if (target.listeners.size > 0) {
return;
}
this.closeTarget(target);
if (this.targets.get(target.key) === target) {
this.targets.delete(target.key);
}
}
private scheduleTargetRefresh(target: CheckoutDiffWatchTarget, force = true): void {
target.pendingDebounceForce ||= force;
if (target.debounceTimer) {
clearTimeout(target.debounceTimer);
}
target.debounceTimer = setTimeout(() => {
target.debounceTimer = null;
const pendingForce = target.pendingDebounceForce;
target.pendingDebounceForce = false;
void this.refreshTarget(target, pendingForce);
}, CHECKOUT_DIFF_WATCH_DEBOUNCE_MS);
}
private async computeCheckoutDiffSnapshot(
cwd: string,
compare: CheckoutDiffCompareInput,
options?: { diffCwd?: string; force?: boolean; reason?: string },
): Promise<CheckoutDiffSnapshotPayload> {
const diffCwd = options?.diffCwd ?? cwd;
try {
const diffResult = await this.workspaceGitService.getCheckoutDiff(
diffCwd,
{
mode: compare.mode,
baseRef: compare.baseRef,
ignoreWhitespace: compare.ignoreWhitespace,
includeStructured: true,
},
options?.force
? { force: true, reason: options.reason ?? "checkout-diff-refresh" }
: undefined,
);
if (diffResult.diffTooLarge) {
return {
cwd,
files: [],
diffTooLarge: true,
error: toCheckoutError(new Error("Diff too large to display")),
};
}
const files = [...(diffResult.structured ?? [])];
files.sort((a, b) => {
if (a.path === b.path) return 0;
return a.path < b.path ? -1 : 1;
});
return {
cwd,
files,
error: null,
};
} catch (error) {
return {
cwd,
files: [],
error: toCheckoutError(error),
};
}
}
private async refreshTarget(target: CheckoutDiffWatchTarget, force: boolean): Promise<void> {
if (target.refreshPromise) {
target.refreshQueued = true;
target.refreshQueuedForce ||= force;
return;
}
target.refreshPromise = (async () => {
let currentForce = force;
do {
target.refreshQueued = false;
target.refreshQueuedForce = false;
const snapshot = await this.computeCheckoutDiffSnapshot(target.cwd, target.compare, {
diffCwd: target.diffCwd,
force: currentForce,
...(currentForce ? { reason: "working-tree-watch" } : {}),
});
target.latestPayload = snapshot;
const fingerprint = JSON.stringify(snapshot);
if (fingerprint !== target.latestFingerprint) {
target.latestFingerprint = fingerprint;
for (const listener of target.listeners) {
listener(snapshot);
}
}
currentForce = target.refreshQueuedForce;
} while (target.refreshQueued);
})();
try {
await target.refreshPromise;
} finally {
target.refreshPromise = null;
}
}
private ensureTarget(cwd: string, compare: CheckoutDiffCompareInput): CheckoutDiffWatchTarget {
const targetKey = this.buildTargetKey(cwd, compare);
const existing = this.targets.get(targetKey);
if (existing) {
return existing;
}
const target: CheckoutDiffWatchTarget = {
key: targetKey,
cwd,
diffCwd: cwd,
compare,
listeners: new Set(),
workingTreeWatchUnsubscribe: null,
workspaceGitUnsubscribe: null,
latestWorkspaceStructureFingerprint: null,
latestWorkspaceWorktreeFingerprint: null,
latestWorkspaceForgeFingerprint: null,
debounceTimer: null,
pendingDebounceForce: false,
refreshPromise: null,
refreshQueued: false,
refreshQueuedForce: false,
latestPayload: null,
latestFingerprint: null,
openPromise: null,
};
this.targets.set(targetKey, target);
return target;
}
private async openTarget(target: CheckoutDiffWatchTarget): Promise<void> {
if (target.compare.mode !== "uncommitted") {
const snapshot =
this.workspaceRuntimeStore.get(target.cwd) ??
(await this.workspaceGitService.getSnapshot(target.cwd, { includeForge: false }));
target.diffCwd = snapshot.git.repoRoot ?? target.cwd;
if (this.targets.get(target.key) !== target || target.listeners.size === 0) {
return;
}
this.rememberWorkspaceSnapshot(target, snapshot);
const workspaceSubscription = this.workspaceGitService.registerWorkspace(
{ cwd: target.cwd },
(nextSnapshot) => this.rememberWorkspaceSnapshot(target, nextSnapshot),
);
if (this.targets.get(target.key) !== target || target.listeners.size === 0) {
workspaceSubscription.unsubscribe();
return;
}
target.workspaceGitUnsubscribe = workspaceSubscription.unsubscribe;
if (target.compare.mode === "base") return;
}
const { repoRoot, unsubscribe } = await this.workspaceGitService.requestWorkingTreeWatch(
target.cwd,
() => this.scheduleTargetRefresh(target),
);
target.diffCwd = repoRoot ?? target.cwd;
if (this.targets.get(target.key) !== target || target.listeners.size === 0) {
unsubscribe();
return;
}
target.workingTreeWatchUnsubscribe = unsubscribe;
async dispose(): Promise<void> {
await this.streams.close();
}
}
function legacySnapshot(cwd: string, files: ParsedDiffFile[]): CheckoutDiffSnapshotPayload {
let bytes = 2;
for (let index = 0; index < files.length; index++) {
bytes += Buffer.byteLength(JSON.stringify(files[index])) + Number(index > 0);
if (bytes > CHECKOUT_DIFF_MAX_STRUCTURED_BYTES) {
return legacyTooLarge(cwd);
}
}
return { cwd, files, error: null };
}
function legacyTooLarge(cwd: string): CheckoutDiffSnapshotPayload {
return {
cwd,
files: [],
diffTooLarge: true,
error: toCheckoutError(new Error("Diff too large to display")),
};
}
@@ -62,6 +62,7 @@ export function buildCheckoutStatusPayloadFromSnapshot({
isDirty: snapshot.git.isDirty,
baseRef: snapshot.git.baseRef,
baseRefExact: snapshot.git.baseRefExact,
comparisonBaseRef: snapshot.git.comparisonBaseRef,
aheadBehind: snapshot.git.aheadBehind ?? null,
upstreamRef: snapshot.git.upstreamRef ?? null,
aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null,
@@ -83,6 +84,7 @@ export function buildCheckoutStatusPayloadFromSnapshot({
isDirty: snapshot.git.isDirty,
baseRef: snapshot.git.baseRef ?? null,
baseRefExact: snapshot.git.baseRefExact,
comparisonBaseRef: snapshot.git.comparisonBaseRef,
aheadBehind: snapshot.git.aheadBehind ?? null,
upstreamRef: snapshot.git.upstreamRef ?? null,
aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null,
@@ -293,3 +293,263 @@ test("large source diffs stay renderable for reads, subscriptions, and commit fi
await rm(cwd, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
}
}, 60_000);
test("streams whole-file batches and answers a same-socket ping before the revision ends", async () => {
const ctx = await createDaemonTestContext();
const cwd = mkdtempSync(join(tmpdir(), "diff-stream-wire-"));
let probe: Awaited<ReturnType<typeof openDiffWireProbe>> | null = null;
try {
git(cwd, "init", "-b", "main");
git(cwd, "config", "user.name", "Test");
git(cwd, "config", "user.email", "test@example.com");
writeFileSync(join(cwd, "README"), "base\n");
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "base");
for (let file = 0; file < 252; file++) {
const content =
Array.from(
{ length: 80 },
(_, line) =>
`export const fn${line} = (param: string) => ({ key: param, value: ${file} });`,
).join("\n") + "\n";
writeFileSync(join(cwd, `${file}.ts`), content);
}
expect(ctx.client.getLastServerInfoMessage()).toMatchObject({
features: { diffSubscription: true },
});
const legacy = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
const legacyJsonBytes = Buffer.byteLength(JSON.stringify(legacy.files));
expect(legacyJsonBytes).toBeGreaterThanOrEqual(10 * 1024 * 1024);
probe = await openDiffWireProbe(ctx.daemon.port, cwd);
const { encodedBytes, bodyFrames, pongDuringRevision, entryCount } = await probe.done;
expect(entryCount).toBe(252);
expect(bodyFrames).toBeGreaterThan(1);
expect(pongDuringRevision).toBe(true);
const artifactDirectory = join(
import.meta.dirname,
"../../../../../.artifacts/diff-subscription",
);
mkdirSync(artifactDirectory, { recursive: true });
writeFileSync(
join(artifactDirectory, "wire-metrics.json"),
JSON.stringify({ legacyJsonBytes, encodedBytes, bodyFrames, pongDuringRevision }, null, 2),
);
} finally {
probe?.socket.terminate();
await ctx.cleanup();
rmSync(cwd, { recursive: true, force: true });
}
}, 60_000);
interface DiffWireResult {
encodedBytes: number;
bodyFrames: number;
pongDuringRevision: boolean;
entryCount: number;
}
async function openDiffWireProbe(port: number, cwd: string) {
const { WebSocket } = await import("ws");
const { decodeDiffFrame, DiffOpcode } = await import("@getpaseo/protocol/binary-frames/index");
const activeSocket = new WebSocket(`ws://127.0.0.1:${port}/ws`);
await new Promise<void>((resolve, reject) => {
activeSocket.once("open", resolve);
activeSocket.once("error", reject);
});
let encodedBytes = 0;
let endReceived = false;
let pingSent = false;
let pongDuringRevision = false;
let bodyFrames = 0;
let entryCount = 0;
const done = new Promise<DiffWireResult>((resolve, reject) => {
activeSocket.on("message", (data, binary) => {
try {
if (binary) {
if (!Buffer.isBuffer(data)) throw new Error("Expected buffer");
encodedBytes += data.byteLength;
const frame = decodeDiffFrame(new Uint8Array(data));
if (frame.opcode === DiffOpcode.Begin) entryCount = frame.entries.length;
if (frame.opcode === DiffOpcode.Bodies) {
bodyFrames++;
if (!pingSent) {
pingSent = true;
activeSocket.send(
JSON.stringify({
type: "session",
message: { type: "ping", requestId: "mid-revision", clientSentAt: 1 },
}),
);
}
}
if (frame.opcode === DiffOpcode.End) {
expect(frame.error).toBeNull();
endReceived = true;
resolve({ encodedBytes, bodyFrames, pongDuringRevision, entryCount });
}
} else {
const envelope = JSON.parse(data.toString());
if (
envelope.message?.type === "pong" &&
envelope.message.payload.requestId === "mid-revision"
)
pongDuringRevision = !endReceived;
if (envelope.message?.payload?.status === "server_info")
activeSocket.send(
JSON.stringify({
type: "session",
message: {
type: "diff.subscribe.request",
requestId: "stream-diff",
cwd,
base: { kind: "rev", rev: "HEAD" },
head: { kind: "worktree" },
},
}),
);
if (envelope.message?.type === "diff.subscribe.response")
expect(envelope.message.payload.error).toBeNull();
}
} catch (error) {
reject(error);
}
});
activeSocket.once("error", reject);
});
activeSocket.send(
JSON.stringify({
type: "hello",
clientId: "diff-wire-test",
clientType: "browser",
protocolVersion: 1,
capabilities: { owned_subscriptions: true },
}),
);
return { socket: activeSocket, done };
}
test("SDK diff journey publishes a tag tree, commits a one-file refresh and shares two sockets", async () => {
const ctx = await createDaemonTestContext();
const cwd = mkdtempSync(join(tmpdir(), "sdk-stream-diff-"));
const { DaemonClient } = await import("../test-utils/daemon-client.js");
const { startGitCommandMetrics, stopGitCommandMetrics } =
await import("../../utils/run-git-command.js");
const second = new DaemonClient({ url: `ws://127.0.0.1:${ctx.daemon.port}/ws` });
try {
git(cwd, "init", "-b", "main");
git(cwd, "config", "user.name", "Test");
git(cwd, "config", "user.email", "test@example.com");
writeFileSync(join(cwd, "a.ts"), "export const a = 1;\n");
writeFileSync(join(cwd, "b.ts"), "export const b = 1;\n");
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "base");
git(cwd, "tag", "v1");
writeFileSync(join(cwd, "a.ts"), "export const a = 2;\n");
writeFileSync(join(cwd, "b.ts"), "export const b = 2;\n");
const compare = {
cwd,
base: { kind: "rev", rev: "refs/tags/v1" },
head: { kind: "worktree" },
mergeBase: true,
} as const;
const states: import("@getpaseo/client").DiffState[] = [];
const observation = ctx.client.observeDiff(compare);
observation.subscribe({ snapshot: (state) => states.push(state), update: () => {} });
await expect.poll(() => states.at(-1)?.phase, { timeout: 15000 }).toBe("ready");
expect(states[0]).toMatchObject({
phase: "loading",
entries: [{ path: "a.ts" }, { path: "b.ts" }],
});
const initial = states.at(-1);
if (initial?.phase !== "ready") throw new Error("Expected ready");
const initialStateCount = states.length;
await second.connect();
startGitCommandMetrics();
const duplicateStates: import("@getpaseo/client").DiffState[] = [];
const duplicate = second.observeDiff(compare);
duplicate.subscribe({ snapshot: (state) => duplicateStates.push(state), update: () => {} });
await expect.poll(() => duplicateStates.at(-1)?.phase).toBe("ready");
const metrics = stopGitCommandMetrics();
expect(duplicateStates.at(-1)).toMatchObject({
phase: "ready",
files: [{ path: "a.ts" }, { path: "b.ts" }],
});
expect(metrics.commands.filter((command) => command.args.includes("--raw"))).toHaveLength(0);
startGitCommandMetrics();
writeFileSync(join(cwd, "a.ts"), "export const a = 3;\n");
await expect
.poll(() => states.at(-1)?.revision, { timeout: 15000 })
.toBeGreaterThan(initial.revision);
await expect.poll(() => duplicateStates.at(-1)?.revision).toBeGreaterThan(initial.revision);
const refreshMetrics = stopGitCommandMetrics();
expect(
refreshMetrics.commands.filter((command) => command.args.includes("--raw")),
).toHaveLength(1);
const refreshed = states.at(-1);
if (refreshed?.phase !== "ready") throw new Error("Expected ready");
expect(refreshed.files[0]).not.toBe(initial.files[0]);
expect(refreshed.files[1]).toBe(initial.files[1]);
expect(states.slice(initialStateCount).map((state) => state.phase)).not.toContain("loading");
expect(states.map((state) => state.phase)).not.toContain("error");
await duplicate.release();
await observation.release();
} finally {
await second.close();
await ctx.cleanup();
rmSync(cwd, { recursive: true, force: true });
}
}, 45000);
test("commit-file RPC keeps the existing read ceiling for patches larger than one MiB", async () => {
const ctx = await createDaemonTestContext();
const cwd = mkdtempSync(join(tmpdir(), "commit-file-budget-"));
try {
git(cwd, "init", "-b", "main");
git(cwd, "config", "user.name", "Test");
git(cwd, "config", "user.email", "test@example.com");
writeFileSync(join(cwd, "large.txt"), "base\n");
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "base");
writeFileSync(join(cwd, "large.txt"), ("x".repeat(100) + "\n").repeat(11000));
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "large change");
const sha = git(cwd, "rev-parse", "HEAD").trim();
const { getCommitFileDiff } = await import("../../utils/checkout-git.js");
const expected = await getCommitFileDiff({ cwd, sha, path: "large.txt" });
const actual = await ctx.client.getCommitFileDiff(cwd, sha, "large.txt");
expect(expected?.hunks.length).toBeGreaterThan(0);
expect(actual.file?.hunks).toEqual(expected?.hunks);
} finally {
await ctx.cleanup();
rmSync(cwd, { recursive: true, force: true });
}
}, 30000);
test("commit-file RPC selects renamed content when the source path becomes a directory", async () => {
const ctx = await createDaemonTestContext();
const cwd = mkdtempSync(join(tmpdir(), "commit-rename-directory-"));
try {
git(cwd, "init", "-b", "main");
git(cwd, "config", "user.name", "Test");
git(cwd, "config", "user.email", "test@example.com");
writeFileSync(join(cwd, "a.txt"), "original line\n".repeat(16));
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "base");
git(cwd, "mv", "a.txt", "z.txt");
writeFileSync(join(cwd, "z.txt"), "original line\n".repeat(16) + "renamed line\n");
mkdirSync(join(cwd, "a.txt"));
writeFileSync(join(cwd, "a.txt", "child.txt"), "replacement line\n");
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "rename");
const sha = git(cwd, "rev-parse", "HEAD").trim();
const { getCommitFileDiff } = await import("../../utils/checkout-git.js");
const expected = await getCommitFileDiff({ cwd, sha, path: "z.txt" });
const actual = await ctx.client.getCommitFileDiff(cwd, sha, "z.txt");
expect(expected?.hunks.flatMap((hunk) => hunk.lines.map((line) => line.content))).toContain(
"renamed line",
);
expect(actual.file?.hunks).toEqual(expected?.hunks);
} finally {
await ctx.cleanup();
rmSync(cwd, { recursive: true, force: true });
}
}, 30000);
@@ -142,7 +142,8 @@ test("keeps the socket usable after rejecting an oversized structured diff", asy
commitFile(cwd, "large-b.js", "const value = 0;\n");
// Short lines stay eligible for highlighting; a giant single line is skipped
// before token expansion and cannot exercise the structured-payload limit.
const denseExpression = `const value = ${"a+".repeat(100)}a;\n`.repeat(4_000);
// Stay below the full-source highlighting budget as well.
const denseExpression = `const value = ${"a+".repeat(100)}a;\n`.repeat(3_500);
writeFileSync(path.join(cwd, "large-a.js"), denseExpression);
writeFileSync(path.join(cwd, "large-b.js"), denseExpression);
@@ -690,6 +690,7 @@ describe("commit file diffs", () => {
expect(result).toEqual({
file: {
path: filePath,
status: "ok",
isNew: false,
isDeleted: false,
additions: 1,
@@ -0,0 +1,30 @@
import { encodeDiffFrame, DiffOpcode, type DiffBody } from "@getpaseo/protocol/binary-frames/index";
export async function* packBodies(
files: AsyncIterable<DiffBody> | Iterable<DiffBody>,
revision: number,
): AsyncGenerator<DiffBody[]> {
let batch: DiffBody[] = [];
let size = 0;
for await (const file of files) {
const bytes = encodeDiffFrame({
opcode: DiffOpcode.Bodies,
slot: 0,
revision,
files: [file],
}).length;
if (batch.length && size + bytes > 256 * 1024) {
yield batch;
batch = [];
size = 0;
}
batch.push(file);
size += bytes;
if (size >= 256 * 1024) {
yield batch;
batch = [];
size = 0;
}
}
if (batch.length) yield batch;
}
@@ -0,0 +1,92 @@
import {
affectsGitDiffInterpretation,
classifyGitMetadataPath,
type GitMetadataScope,
} from "../git-metadata-event-rules.js";
import { resolve } from "node:path";
import { runGitCommand } from "../../utils/run-git-command.js";
import type { WorkspaceGitService } from "../workspace-git-service.js";
import { createFileObserver, type FileObserverSubscription } from "../file-observer/index.js";
export interface DiffDependencies {
cwd: string;
refs: string[];
index: boolean;
worktree: boolean;
}
// Own metadata observations here; comparison managers declare inputs rather than watching paths.
export class DiffDependencyObserver {
private observer = createFileObserver();
constructor(private workspaceGit: Pick<WorkspaceGitService, "requestWorkingTreeWatch">) {}
async declareDependencies(
input: DiffDependencies,
changed: (kind: "endpoints" | "interpretation") => void,
): Promise<() => Promise<void>> {
const hasMutableEndpoints = input.refs.length > 0 || input.index || input.worktree;
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 [gitDir, commonDir] = paths.stdout.split(/\r?\n/);
if (!gitDir || !commonDir) throw new Error("Git returned incomplete metadata roots");
const roots = new Map<string, GitMetadataScope[]>();
for (const [directory, scope] of [
[gitDir, "worktree"],
[commonDir, "common"],
] as const) {
const root = resolve(input.cwd, directory);
const scopes = roots.get(root) ?? [];
scopes.push(scope);
roots.set(root, scopes);
}
for (const [root, scopes] of roots) {
const subscription = await this.observer.subscribe(
root,
(error, events) => {
if (error) {
changed("endpoints");
polling ??= setInterval(() => changed("endpoints"), 5000);
return;
}
let interpretation = false;
const endpoints = events.some((event) => {
const path = event.path.slice(root.length + 1).replaceAll("\\", "/");
if (scopes.some((scope) => affectsGitDiffInterpretation(scope, path)))
interpretation = true;
if (!hasMutableEndpoints) return false;
if (path === "index") return input.index;
return scopes.some((scope) => classifyGitMetadataPath(scope, path).kind !== "ignore");
});
if (endpoints) changed("endpoints");
else if (interpretation) changed("interpretation");
},
{ ignore: [resolve(root, "objects"), resolve(root, "logs"), resolve(root, "hooks")] },
);
subscriptions.push(subscription);
}
workingTree = await this.workspaceGit.requestWorkingTreeWatch(input.cwd, () =>
changed(input.worktree ? "endpoints" : "interpretation"),
);
return async () => {
if (polling) clearInterval(polling);
await workingTree?.unsubscribe();
await Promise.all(subscriptions.map((subscription) => subscription.unsubscribe()));
};
} catch (error) {
if (polling) clearInterval(polling);
await workingTree?.unsubscribe();
await Promise.all(subscriptions.map((subscription) => subscription.unsubscribe()));
throw error;
}
}
getObservationCount(): number {
return this.observer.getDiagnostics().activeObservationCount;
}
async close(): Promise<void> {
await this.observer.close();
}
}
+719
View File
@@ -0,0 +1,719 @@
import { execFileSync } from "node:child_process";
import { mkdtemp, writeFile, rm, readFile, lstat } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { expect, test } from "vitest";
import { listComparison, type Comparison } from "./git.js";
test("working tree comparisons preserve staged deletions whose file remains on disk", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-comparison-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
try {
git("init", "-b", "main");
git("config", "user.email", "test@example.com");
git("config", "user.name", "Test");
await writeFile(join(cwd, "x.ts"), "const value = 1;\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
git("rm", "--cached", "x.ts");
await writeFile(join(cwd, "x.ts"), "const value = 2;\n");
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: "HEAD" },
head: { kind: "worktree" },
highlight: false,
});
expect(comparison.files.map((file) => file.entry)).toEqual([
{
path: "x.ts",
oldPath: null,
change: "modified",
content: "text",
additions: 1,
deletions: 1,
body: true,
},
]);
const body = await readBody(comparison);
expect(body.body.hunks[0].lines).toEqual([
{ type: "header", content: "@@ -1 +1 @@" },
{ type: "remove", content: "const value = 1;" },
{ type: "add", content: "const value = 2;" },
]);
expect(comparison.metrics.highlightCalls).toBe(0);
await comparison.close();
expect(git("diff", "--cached", "--name-status")).toBe("D\tx.ts");
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
test("supports empty trees, unborn HEAD, staged and unstaged comparisons", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-endpoints-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
try {
git("init", "-b", "main");
git("config", "user.email", "test@example.com");
git("config", "user.name", "Test");
await writeFile(join(cwd, "x.ts"), "const value = 1;\n");
const unborn = await listComparison({
cwd,
base: { kind: "rev", rev: "HEAD" },
head: { kind: "worktree" },
});
expect(unborn.files.map((file) => file.entry.path)).toEqual(["x.ts"]);
await unborn.close();
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
const empty = await listComparison({
cwd,
base: { kind: "empty" },
head: { kind: "rev", rev: "HEAD" },
});
expect(empty.files[0].entry.change).toBe("added");
await empty.close();
await writeFile(join(cwd, "x.ts"), "const value = 2;\n");
git("add", ".");
const indexPath = join(cwd, ".git", "index");
const originalIndex = await readFile(indexPath);
const originalMtime = (await lstat(indexPath, { bigint: true })).mtimeNs;
const staged = await listComparison({
cwd,
base: { kind: "rev", rev: "HEAD" },
head: { kind: "index" },
highlight: false,
});
expect(await readFile(indexPath)).toEqual(originalIndex);
expect((await lstat(indexPath, { bigint: true })).mtimeNs).toBe(originalMtime);
const stagedSha = staged.headSha;
expect(staged.files[0].entry).toMatchObject({ additions: 1, deletions: 1 });
await staged.close();
await writeFile(join(cwd, "x.ts"), "const value = 3;\n");
const unstaged = await listComparison({
cwd,
base: { kind: "index" },
head: { kind: "worktree" },
highlight: false,
});
expect(unstaged.baseSha).toBe(stagedSha);
const record = await readBody(unstaged);
expect(record.body.hunks[0].lines).toContainEqual({
type: "remove",
content: "const value = 2;",
});
await unstaged.close();
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
test("lists oversized untracked files without treating their truncated contents as additions", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-large-file-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
try {
git("init", "-b", "main");
git("config", "user.email", "test@example.com");
git("config", "user.name", "Test");
await writeFile(join(cwd, "README"), "base\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await writeFile(join(cwd, "huge.ts"), Buffer.alloc(2 * 1024 * 1024, 120));
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: "HEAD" },
head: { kind: "worktree" },
});
expect(comparison.files[0].entry).toMatchObject({
path: "huge.ts",
additions: 0,
deletions: 0,
content: "text",
});
expect(await comparison.read(comparison.files[0])).toEqual({ kind: "too_large" });
await comparison.close();
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
test("preserves highlighting context outside the emitted hunks", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-highlight-context-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
try {
git("init", "-b", "main");
git("config", "user.email", "test@example.com");
git("config", "user.name", "Test");
const lines = [
"/*",
...Array.from({ length: 30 }, (_, index) => `comment ${index}`),
"*/",
"export const value = 1;",
];
await writeFile(join(cwd, "x.ts"), lines.join("\n") + "\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
lines[15] = "new comment";
await writeFile(join(cwd, "x.ts"), lines.join("\n") + "\n");
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: "HEAD" },
head: { kind: "worktree" },
});
const record = await readBody(comparison);
expect(record.body.hunks[0].lines.find((line) => line.type === "add")).toMatchObject({
content: "new comment",
runs: [{ style: "comment", length: 11 }],
});
await comparison.close();
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
test("reads patches independently of repository color and prefix presentation settings", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-presentation-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
try {
git("init", "-b", "main");
git("config", "user.email", "test@example.com");
git("config", "user.name", "Test");
await writeFile(join(cwd, "x.ts"), "before\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
git("config", "color.ui", "always");
git("config", "diff.noprefix", "true");
await writeFile(join(cwd, "x.ts"), "after\n");
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: "HEAD" },
head: { kind: "worktree" },
highlight: false,
});
const record = await readBody(comparison);
expect(record.body.hunks).toHaveLength(1);
expect(record.body.hunks[0].lines).toContainEqual({ type: "add", content: "after" });
await comparison.close();
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
test("merge-base index comparisons resolve against HEAD while reporting both endpoint SHAs", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-index-merge-base-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
try {
git("init", "-b", "main");
git("config", "user.email", "test@example.com");
git("config", "user.name", "Test");
await writeFile(join(cwd, "README"), "base\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
const common = git("rev-parse", "HEAD");
git("checkout", "-b", "side");
await writeFile(join(cwd, "side.txt"), "side\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "side");
const side = git("rev-parse", "HEAD");
git("checkout", "main");
await writeFile(join(cwd, "main.txt"), "main\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "main");
await writeFile(join(cwd, "staged.txt"), "staged\n");
git("add", "staged.txt");
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: "side" },
head: { kind: "index" },
mergeBase: true,
});
expect(comparison.baseSha).toBe(side);
expect(comparison.mergeBaseSha).toBe(common);
expect(comparison.files.map((file) => file.entry.path)).toEqual(["main.txt", "staged.txt"]);
await comparison.close();
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
async function readBody(comparison: Comparison) {
const record = await comparison.read(comparison.files[0]);
if (record.kind !== "ready") throw new Error("Expected a readable file body");
return record;
}
test.each(["worktree", "index", "rev"] as const)(
"%s whitespace filtering omits spacing-only edits and retains meaningful edits",
async (headKind) => {
const cwd = await mkdtemp(join(tmpdir(), "diff-whitespace-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" });
try {
git("init", "-b", "main");
git("config", "user.email", "test@example.com");
git("config", "user.name", "Test");
await writeFile(join(cwd, "a.ts"), "const x = 1;\n");
await writeFile(join(cwd, "b.txt"), "before\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
const base = git("rev-parse", "HEAD").trim();
await writeFile(join(cwd, "a.ts"), "const x = 1;\n");
await writeFile(join(cwd, "b.txt"), "after\n");
if (headKind !== "worktree") git("add", ".");
if (headKind === "rev") git("-c", "commit.gpgsign=false", "commit", "-m", "edit");
const head = headKind === "rev" ? { kind: "rev" as const, rev: "HEAD" } : { kind: headKind };
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: base },
head,
ignoreWhitespace: true,
highlight: false,
});
expect(comparison.files.map((file) => file.entry.path)).toEqual(["b.txt"]);
await comparison.close();
} finally {
await rm(cwd, { recursive: true, force: true });
}
},
);
test.skipIf(process.platform === "win32")(
"comparison roots preserve trailing folder spaces",
async () => {
const root = await mkdtemp(join(tmpdir(), "diff-space-"));
const cwd = join(root, "repo ");
const { mkdir } = await import("node:fs/promises");
await mkdir(cwd);
try {
execFileSync("git", ["init", "-b", "main"], { cwd });
const { worktreeRoot } = await import("./git.js");
expect(await worktreeRoot(cwd)).toBe(cwd);
} finally {
await rm(root, { recursive: true, force: true });
}
},
);
test.each(["worktree", "index", "rev"] as const)(
"%s rename reads select the destination when the old path becomes a directory",
async (headKind) => {
const cwd = await mkdtemp(join(tmpdir(), "diff-rename-directory-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const { mkdir } = await import("node:fs/promises");
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(join(cwd, "a.txt"), "original line\n".repeat(16));
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
const base = git("rev-parse", "HEAD");
git("mv", "a.txt", "z.txt");
await writeFile(join(cwd, "z.txt"), "original line\n".repeat(16) + "renamed line\n");
await mkdir(join(cwd, "a.txt"));
await writeFile(join(cwd, "a.txt", "child.txt"), "replacement line\n");
git("add", ".");
if (headKind === "rev") git("-c", "commit.gpgsign=false", "commit", "-m", "rename");
const head = headKind === "rev" ? { kind: "rev" as const, rev: "HEAD" } : { kind: headKind };
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: base },
head,
highlight: false,
});
try {
const file = comparison.files.find((candidate) => candidate.entry.path === "z.txt");
if (!file) throw new Error("Missing rename");
expect(file.entry.oldPath).toBe("a.txt");
const record = await comparison.read(file);
if (record.kind !== "ready") throw new Error("Expected readable rename");
const lines = record.body.hunks.flatMap((hunk) => hunk.lines.map((line) => line.content));
expect(lines).toContain("renamed line");
expect(lines).not.toContain("replacement line");
} finally {
await comparison.close();
}
} finally {
await rm(cwd, { recursive: true, force: true });
}
},
);
test.skipIf(process.platform === "win32")(
"a dangling untracked symlink exposes its target text alongside tracked changes",
async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-dangling-link-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" });
const { symlink } = await import("node:fs/promises");
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(join(cwd, "a.txt"), "before\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await writeFile(join(cwd, "a.txt"), "after\n");
await symlink("not-created-yet", join(cwd, "link"));
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: "HEAD" },
head: { kind: "worktree" },
highlight: false,
});
try {
expect(comparison.files.map((file) => file.entry.path)).toEqual(["a.txt", "link"]);
const record = await comparison.read(comparison.files[1]);
if (record.kind !== "ready") throw new Error("Expected link body");
expect(record.body.hunks.flatMap((hunk) => hunk.lines)).toContainEqual({
type: "add",
content: "not-created-yet",
});
} finally {
await comparison.close();
}
} finally {
await rm(cwd, { recursive: true, force: true });
}
},
);
test("untracked nested repositories do not hide tracked edits", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-nested-repository-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" });
const { mkdir } = await import("node:fs/promises");
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(join(cwd, "a.txt"), "before\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await writeFile(join(cwd, "a.txt"), "after\n");
await mkdir(join(cwd, "nested"));
git("-C", "nested", "init");
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: "HEAD" },
head: { kind: "worktree" },
highlight: false,
});
try {
expect(comparison.files.map((file) => file.entry.path)).toEqual(["a.txt"]);
const record = await readBody(comparison);
expect(record.body.hunks.flatMap((hunk) => hunk.lines)).toContainEqual({
type: "add",
content: "after",
});
} finally {
await comparison.close();
}
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
test.each(["worktree", "index", "rev"] as const)(
"%s preserves hunks for paths containing a patch prefix delimiter",
async (headKind) => {
const cwd = await mkdtemp(join(tmpdir(), "diff-path-delimiter-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const { mkdir } = await import("node:fs/promises");
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await mkdir(join(cwd, "dir b"));
await writeFile(join(cwd, "dir b", "file.txt"), "before\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
const base = git("rev-parse", "HEAD");
await writeFile(join(cwd, "dir b", "file.txt"), "after\n");
if (headKind !== "worktree") git("add", ".");
if (headKind === "rev") git("-c", "commit.gpgsign=false", "commit", "-m", "edit");
const head = headKind === "rev" ? { kind: "rev" as const, rev: "HEAD" } : { kind: headKind };
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: base },
head,
highlight: false,
});
try {
expect(comparison.files[0].entry.path).toBe("dir b/file.txt");
const record = await readBody(comparison);
expect(record.body.hunks.flatMap((hunk) => hunk.lines)).toContainEqual({
type: "add",
content: "after",
});
} finally {
await comparison.close();
}
} finally {
await rm(cwd, { recursive: true, force: true });
}
},
);
test("highlighted BOM source content survives the production frame codec", async () => {
const { encodeDiffFrame, decodeDiffFrame } =
await import("@getpaseo/protocol/binary-frames/index");
const cwd = await mkdtemp(join(tmpdir(), "diff-bom-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" });
try {
git("init", "-b", "main");
await writeFile(join(cwd, "bom.ts"), "\uFEFFexport const value = 1;\n");
const comparison = await listComparison({
cwd,
base: { kind: "empty" },
head: { kind: "worktree" },
});
try {
const record = await readBody(comparison);
const frame = { opcode: 0x41 as const, slot: 0, revision: 1, files: [record.body] };
expect(decodeDiffFrame(encodeDiffFrame(frame))).toEqual(frame);
expect(
record.body.hunks
.flatMap((hunk) => hunk.lines)
.some((line) => line.content.startsWith("\uFEFF")),
).toBe(true);
} finally {
await comparison.close();
}
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
test.skipIf(process.platform === "win32")(
"symlink replacement retains both patch sections",
async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-type-change-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" });
const { symlink, unlink } = await import("node:fs/promises");
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await symlink("old-target", join(cwd, "file.txt"));
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await unlink(join(cwd, "file.txt"));
await writeFile(join(cwd, "file.txt"), "replacement contents\n");
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: "HEAD" },
head: { kind: "worktree" },
highlight: false,
});
try {
expect(comparison.files[0].entry).toMatchObject({ additions: 1, deletions: 1 });
const record = await readBody(comparison);
expect(record.body.hunks.flatMap((hunk) => hunk.lines)).toEqual(
expect.arrayContaining([
{ type: "remove", content: "old-target" },
{ type: "add", content: "replacement contents" },
]),
);
} finally {
await comparison.close();
}
} finally {
await rm(cwd, { recursive: true, force: true });
}
},
);
test.skipIf(process.platform === "win32")(
"highlighted symlink replacements use stored link text instead of target contents",
async () => {
const { encodeDiffFrame, decodeDiffFrame } =
await import("@getpaseo/protocol/binary-frames/index");
const cwd = await mkdtemp(join(tmpdir(), "diff-highlighted-link-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" });
const { symlink, unlink } = await import("node:fs/promises");
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(join(cwd, "file.ts"), "export const old = 1;\n");
await writeFile(join(cwd, "target.ts"), "export const entirelyDifferentLength = 2;\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await unlink(join(cwd, "file.ts"));
await symlink("target.ts", join(cwd, "file.ts"));
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: "HEAD" },
head: { kind: "worktree" },
});
try {
const record = await readBody(comparison);
expect(record.body.hunks.flatMap((hunk) => hunk.lines)).toContainEqual(
expect.objectContaining({ type: "add", content: "target.ts" }),
);
const frame = { opcode: 0x41 as const, slot: 0, revision: 1, files: [record.body] };
expect(decodeDiffFrame(encodeDiffFrame(frame))).toEqual(frame);
} finally {
await comparison.close();
}
} finally {
await rm(cwd, { recursive: true, force: true });
}
},
);
test("files removed between listing and highlighting retain their deletion patch", async () => {
const { encodeDiffFrame, decodeDiffFrame } =
await import("@getpaseo/protocol/binary-frames/index");
const cwd = await mkdtemp(join(tmpdir(), "diff-source-disappeared-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" });
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(join(cwd, "a.ts"), "export const old = 1;\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await writeFile(join(cwd, "a.ts"), "export const old = 2;\n");
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: "HEAD" },
head: { kind: "worktree" },
});
try {
expect(comparison.files[0].entry.change).toBe("modified");
await rm(join(cwd, "a.ts"));
const record = await readBody(comparison);
expect(record.body.hunks.flatMap((hunk) => hunk.lines)).toContainEqual(
expect.objectContaining({ type: "remove", content: "export const old = 1;" }),
);
const frame = { opcode: 0x41 as const, slot: 0, revision: 1, files: [record.body] };
expect(decodeDiffFrame(encodeDiffFrame(frame))).toEqual(frame);
} finally {
await comparison.close();
}
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
test("a batched oversized tracked patch does not hide its small sibling", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-batch-budget-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" });
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(join(cwd, "a.txt"), "before\n");
await writeFile(join(cwd, "b.txt"), "before\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await writeFile(join(cwd, "a.txt"), "oversized\n".repeat(1024 * 1024));
await writeFile(join(cwd, "b.txt"), "after\n");
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: "HEAD" },
head: { kind: "worktree" },
highlight: false,
});
try {
expect(await comparison.read(comparison.files[0])).toEqual({ kind: "too_large" });
const sibling = await comparison.read(comparison.files[1]);
if (sibling.kind !== "ready") throw new Error("Expected readable sibling");
expect(sibling.body.hunks.flatMap((hunk) => hunk.lines)).toContainEqual({
type: "add",
content: "after",
});
} finally {
await comparison.close();
}
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
test("replacing a submodule with a regular file preserves its replacement body", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-gitlink-replacement-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" });
const { mkdir } = await import("node:fs/promises");
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await mkdir(join(cwd, "sub"));
git("-C", "sub", "init", "-b", "main");
git("-C", "sub", "config", "user.name", "Test");
git("-C", "sub", "config", "user.email", "test@example.com");
await writeFile(join(cwd, "sub", "a.txt"), "base\n");
git("-C", "sub", "add", ".");
git("-C", "sub", "-c", "commit.gpgsign=false", "commit", "-m", "base");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "record submodule");
await rm(join(cwd, "sub"), { recursive: true, force: true });
await writeFile(join(cwd, "sub"), "replacement contents\n");
const comparison = await listComparison({
cwd,
base: { kind: "rev", rev: "HEAD" },
head: { kind: "worktree" },
});
try {
const record = await readBody(comparison);
expect(record.body.hunks.flatMap((hunk) => hunk.lines)).toContainEqual(
expect.objectContaining({ type: "add", content: "replacement contents" }),
);
} finally {
await comparison.close();
}
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
test.each([
{ ignoreWhitespace: false, after: "export const value = 2;\n", files: ["a.ts"] },
{ ignoreWhitespace: true, after: "export const value = 1;\n", files: [] },
])(
"interpretation checks ignore content edits but detect new binary attributes, whitespace=$ignoreWhitespace",
async ({ ignoreWhitespace, after, files }) => {
const cwd = await mkdtemp(join(tmpdir(), "diff-interpretation-check-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
git("config", "core.autocrlf", "false");
await writeFile(join(cwd, "a.ts"), "export const value = 1;\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
const base = git("rev-parse", "HEAD");
await writeFile(join(cwd, "a.ts"), after);
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "edit");
const compare = {
cwd,
base: { kind: "rev" as const, rev: base },
head: { kind: "rev" as const, rev: git("rev-parse", "HEAD") },
ignoreWhitespace,
highlight: false,
};
const comparison = await listComparison(compare);
try {
expect(comparison.files.map((file) => file.entry.path)).toEqual(files);
expect(await comparison.hasInterpretationChanged()).toBe(false);
await writeFile(join(cwd, "a.ts"), "uncommitted content\n");
expect(await comparison.hasInterpretationChanged()).toBe(false);
await writeFile(join(cwd, ".gitattributes"), "a.ts -diff\n");
expect(await comparison.hasInterpretationChanged()).toBe(true);
const fresh = await listComparison(compare);
try {
expect(fresh.files.map((file) => file.entry)).toEqual([
expect.objectContaining({ path: "a.ts", content: "binary" }),
]);
} finally {
await fresh.close();
}
} finally {
await comparison.close();
}
} finally {
await rm(cwd, { recursive: true, force: true });
}
},
);
+431
View File
@@ -0,0 +1,431 @@
import { parseGitRevParsePath } from "../../utils/git-rev-parse-path.js";
import { readGitInputs } from "./inputs.js";
import { Patches } from "./patches.js";
import { HighlightSources } from "./sources.js";
import {
prepareAllChangesIndex,
PER_FILE_DIFF_MAX_BYTES,
GIT_PATCH_FORMAT_ARGS,
inspectUntrackedFile,
snapshotComparisonIndex,
resolveCheckoutDiffBase,
type CheckoutDiffCompare,
} from "../../utils/checkout-git.js";
import { lstat, readFile, readlink } from "node:fs/promises";
import type { BigIntStats } from "node:fs";
import { resolve } from "node:path";
import type { DiffEndpoint, DiffSubscribeRequest } from "@getpaseo/protocol/messages";
import type { DiffEntry, DiffBody } from "@getpaseo/protocol/binary-frames/index";
import { runGitCommand, type GitCommandOptions } from "../../utils/run-git-command.js";
import { parseDiff, highlightDiffWithFileContent } from "../utils/diff-highlighter.js";
export type Compare = Omit<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;
hasInterpretationChanged(): Promise<boolean>;
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, interpretationPaths } = await readTrackedFiles(
compare,
args,
options,
);
if (compare.head.kind === "worktree") files.push(...(await listUntrackedFiles(options)));
files.sort((a, b) => a.entry.path.localeCompare(b.entry.path));
for (const file of files) interpretationPaths.add(file.entry.path);
const gitInputs = await readGitInputs([...interpretationPaths].sort(), worktreeFiles, options);
for (const file of files)
file.identity += JSON.stringify([
gitInputs.identities.get(file.entry.path),
gitInputs.identities.get(file.entry.oldPath ?? file.entry.path),
]);
const metrics = { highlightCalls: 0 };
const patches = new Patches(args, options, files);
const sources = new HighlightSources({
cwd,
baseSha: comparisonBaseSha,
headSha: compare.head.kind === "worktree" ? null : headSha,
files,
});
return {
baseSha,
headSha,
mergeBaseSha,
files,
metrics,
selectBodies: (paths) => patches.select(paths),
hasInterpretationChanged: gitInputs.hasInterpretationChanged,
close: async () => {
await privateIndex?.cleanup();
},
async read(file, budget = {}) {
const maxPatchBytes = budget.maxPatchBytes ?? PER_FILE_DIFF_MAX_BYTES;
const remainingBytes = budget.remainingBytes ?? maxPatchBytes;
if (file.source.kind === "untracked" && file.source.size > maxPatchBytes) {
return { kind: "too_large" };
}
const result =
file.source.kind === "untracked"
? await runGitCommand(
["diff", "--no-index", ...GIT_PATCH_FORMAT_ARGS, "--", "/dev/null", file.entry.path],
{
...options,
acceptExitCodes: [0, 1],
maxOutputBytes: maxPatchBytes + 1,
},
)
: await patches.read(file, maxPatchBytes);
const bytes = Buffer.byteLength(result.stdout);
if (result.truncated || bytes > remainingBytes || bytes > maxPatchBytes)
return { kind: "too_large" };
// File-type changes have a deletion and addition section for the same path.
const sections = parseDiff(result.stdout).filter(
(candidate) => candidate.path === file.entry.path,
);
if (!sections.length)
return { kind: "ready", body: { path: file.entry.path, hunks: [] }, bytes };
const parsed = {
path: file.entry.path,
isNew: file.entry.change === "added",
isDeleted: file.entry.change === "deleted",
additions: file.entry.additions,
deletions: file.entry.deletions,
hunks: sections.flatMap((section) => section.hunks),
};
let highlighted = parsed;
if (compare.highlight !== false) {
metrics.highlightCalls++;
const context = await sources.read(file);
highlighted = await highlightDiffWithFileContent(parsed, cwd, context);
}
const hunks = highlighted.hunks.map((hunk) => ({
oldStart: hunk.oldStart,
oldCount: hunk.oldCount,
newStart: hunk.newStart,
newCount: hunk.newCount,
lines: hunk.lines.map((line) => {
if (!line.tokens) return { type: line.type, content: line.content };
return {
type: line.type,
content: line.content,
runs: line.tokens
.filter((token) => token.text.length > 0)
.map((token) => ({ style: token.style, length: token.text.length })),
};
}),
}));
return { kind: "ready", body: { path: file.entry.path, hunks }, bytes };
},
};
}
async function readTrackedFiles(
compare: Compare,
args: string[],
options: GitCommandOptions,
): Promise<{ files: ListedFile[]; worktreeFiles: Set<string>; interpretationPaths: Set<string> }> {
const [raw, stat] = await Promise.all([
runGitCommand([...args.filter((arg) => arg !== "-w"), "--raw", "--no-abbrev", "-z"], options),
runGitCommand([...args, "--numstat", "-z"], options),
]);
if (raw.truncated || stat.truncated)
throw new Error("Diff entry list exceeded the git output budget");
const stats = parseStats(stat.stdout);
const fields = raw.stdout.split("\0");
const files: ListedFile[] = [];
const worktreeFiles = new Set<string>();
const interpretationPaths = 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];
}
interpretationPaths.add(path);
if (oldPath !== null) interpretationPaths.add(oldPath);
const counts = stats.get(path);
// With -w, a raw metadata entry need not have a numstat record.
if (!counts && compare.ignoreWhitespace) continue;
if (!counts) throw new Error(`Missing diff statistics for ${path}`);
let change: DiffEntry["change"] = "modified";
if (code === "A") change = "added";
else if (code === "D") change = "deleted";
else if (oldPath !== null) change = "renamed";
let identity = header.join(" ");
if (compare.head.kind === "worktree" && code !== "D") {
const worktree = await readWorktreeIdentity(path, header, options);
identity += `:${worktree.identity}`;
if (worktree.regular && !counts.binary) worktreeFiles.add(path);
}
files.push({
entry: {
path,
oldPath,
change,
content: counts.binary ? "binary" : "text",
additions: counts.additions,
deletions: counts.deletions,
body: !counts.binary,
},
identity,
source: { kind: "tracked" },
});
}
return { files, worktreeFiles, interpretationPaths };
}
async function readWorktreeIdentity(
path: string,
header: string[],
options: GitCommandOptions,
): Promise<{ identity: string; regular: boolean }> {
const value = await lstat(resolve(options.cwd, path), { bigint: true });
let identity = fileStatIdentity(value);
if (value.isDirectory() && (header[0] === ":160000" || header[1] === "160000")) {
const submodule = await runGitCommand(
["status", "--porcelain=v2", "--branch", "--untracked-files=normal"],
{ cwd: resolve(options.cwd, path), envOverlay: { GIT_OPTIONAL_LOCKS: "0" } },
);
identity += `:${submodule.stdout}`;
}
return { identity, regular: value.isFile() };
}
function diffArguments(compare: Compare, baseSha: string, headSha: string): string[] {
const revisions: string[] = [];
if (compare.base.kind !== "index") revisions.push(baseSha);
if (compare.head.kind === "rev") revisions.push(headSha);
if (compare.head.kind === "index") revisions.unshift("--cached");
const args = ["diff", ...GIT_PATCH_FORMAT_ARGS, "--no-textconv", "-M", ...revisions];
if (compare.ignoreWhitespace) args.push("-w");
return args;
}
async function readUntrackedText(
path: string,
symlink: boolean,
omitted: boolean,
): Promise<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}`;
}
+487
View File
@@ -0,0 +1,487 @@
import { TOTAL_DIFF_MAX_BYTES } from "../../utils/checkout-git.js";
import { DEFAULT_MAX_OUTPUT_BYTES, runGitCommand } from "../../utils/run-git-command.js";
import { expandTilde, createRealpathAwarePathMatcher } from "../../utils/path.js";
import { packBodies } from "./batches.js";
import { setImmediate } from "node:timers/promises";
import {
DiffOpcode,
materializeDiffFile,
type DiffFrame,
type DiffEntry,
type DiffBody,
} from "@getpaseo/protocol/binary-frames/index";
import type {
DiffError,
DiffEndpoint,
ParsedDiffFile,
CheckoutCommitFileDiffRequest,
} from "@getpaseo/protocol/messages";
import type { WorkspaceGitService } from "../workspace-git-service.js";
import {
listComparison,
validateCompare,
worktreeRoot,
DiffReadError,
type Compare,
type Comparison,
} from "./git.js";
import { DiffDependencyObserver } from "./dependencies.js";
interface RecordCache {
identity: string;
body: DiffBody;
bytes: number;
}
interface Listener {
emit(frame: DiffFrame): Promise<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;
checkInterpretation: (() => Promise<boolean>) | null;
interpretationCheck: Promise<void> | null;
interpretationDirty: 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,
checkInterpretation: null,
interpretationCheck: null,
interpretationDirty: false,
release: null,
opening: null,
closed: false,
timer: null,
};
this.targets.set(key, target);
const captured = target;
try {
target.opening = this.dependencies
.declareDependencies(
{
cwd: compare.cwd,
refs: [compare.base, compare.head].flatMap((endpoint) =>
endpoint.kind === "rev" && !/^[a-f0-9]{40,64}$/.test(endpoint.rev)
? [endpoint.rev]
: [],
),
index: compare.base.kind === "index" || compare.head.kind !== "rev",
worktree: compare.head.kind === "worktree",
},
(kind) => {
if (kind === "interpretation") this.invalidateInterpretation(captured);
else this.invalidate(captured);
},
)
.then((release) => {
captured.release = release;
return undefined;
});
} catch (error) {
this.targets.delete(key);
throw error;
}
}
try {
await target.opening;
} catch (error) {
target.closed = true;
this.targets.delete(key);
throw error;
}
if (target.closed) throw new Error("Diff subscription cancelled");
const current = target;
const listener: Listener = { emit, active: false, delivery: Promise.resolve() };
current.listeners.add(listener);
let released = false;
return {
start: async () => {
while (current.refresh) await current.refresh;
if (current.closed || released) return;
listener.active = true;
if (!current.committed) await this.refresh(current);
else {
const replay = this.replay(current, listener);
listener.delivery = replay.catch(() => {});
await replay;
}
},
release: async () => {
if (released) return;
released = true;
listener.active = false;
current.listeners.delete(listener);
if (current.listeners.size) return;
current.closed = true;
if (current.timer) clearTimeout(current.timer);
this.targets.delete(key);
await current.release?.();
await current.refresh;
await current.interpretationCheck;
},
};
}
async invalidateForCwd(cwd: string): Promise<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 invalidateInterpretation(target: Target): void {
if (target.closed) return;
target.interpretationDirty = true;
target.interpretationCheck ??= this.checkInterpretation(target).finally(() => {
target.interpretationCheck = null;
});
}
private async checkInterpretation(target: Target): Promise<void> {
do {
target.interpretationDirty = false;
try {
if (!target.checkInterpretation || (await target.checkInterpretation())) {
this.invalidate(target);
return;
}
} catch {
// A normal revision owns error delivery and recovery.
this.invalidate(target);
return;
}
} while (target.interpretationDirty && !target.closed);
}
private invalidate(target: Target): void {
if (target.closed) return;
target.dirty = true;
if (target.listeners.size === 0) return;
if (target.timer) clearTimeout(target.timer);
target.timer = setTimeout(() => {
target.timer = null;
void this.refresh(target);
}, 150);
}
private async emit(target: Target, frame: DiffFrame): Promise<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);
target.checkInterpretation = comparison.hasInterpretationChanged;
const resolved = {
baseSha: comparison.baseSha,
headSha: comparison.headSha,
mergeBaseSha: comparison.mergeBaseSha,
};
const tooManyFiles = comparison.files.length > 10000;
const entries = comparison.files.map((file) => ({
...file.entry,
body: tooManyFiles
? file.entry.content === "text"
: needsBody(target, file.entry, file.identity),
}));
await this.emit(target, {
opcode: DiffOpcode.Begin,
slot: 0,
revision,
baseRevision: target.error ? 0 : target.committed,
...resolved,
entries,
});
began = true;
const cache = new Map<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;
await target.interpretationCheck;
}
this.targets.clear();
await this.dependencies.close();
}
}
function isInterrupted(target: Target): boolean {
return target.closed || target.dirty;
}
function exceedsBudget(total: number, bytes: number): boolean {
return total + bytes > TOTAL_DIFF_MAX_BYTES;
}
function needsBody(target: Target, entry: DiffEntry, identity: string): boolean {
return (
entry.content === "text" &&
(target.error !== null || target.cache.get(entry.path)?.identity !== identity)
);
}
function diffError(error: unknown): DiffError {
return {
code: error instanceof DiffReadError ? error.code : "UNKNOWN",
message: error instanceof Error ? error.message : String(error),
};
}
export { DiffSession } from "./session.js";
function endpointKey(endpoint: DiffEndpoint): string[] {
if (endpoint.kind === "rev") return [endpoint.kind, endpoint.rev];
return [endpoint.kind];
}
+110
View File
@@ -0,0 +1,110 @@
import { createHash } from "node:crypto";
import { runGitCommand, type GitCommandOptions } from "../../utils/run-git-command.js";
interface Interpretation {
config: string;
attributes: Map<string, string[]>;
fingerprint: string;
}
// Reuse depends on Git's interpretation, not just filesystem metadata. Ask Git
// for effective attributes/config and filtered blob identities without writing objects.
export async function readGitInputs(
paths: string[],
worktreeFiles: Set<string>,
options: GitCommandOptions,
) {
const regular = paths.filter((path) => worktreeFiles.has(path));
const [interpretation, blobs] = await Promise.all([
readInterpretation(paths, options),
regular.length
? runGitCommand(["hash-object", "--stdin-paths"], {
...options,
input: regular.map(quotePath).join("\n") + "\n",
})
: null,
]);
if (blobs?.truncated) throw new Error("Git input identity exceeded the output budget");
const hashes = blobs?.stdout.trim().split(/\r?\n/) ?? [];
if (hashes.length !== regular.length) throw new Error("Git returned incomplete blob identities");
const blobByPath = new Map(regular.map((path, index) => [path, hashes[index]]));
const identities = new Map(
paths.map((path) => [
path,
fingerprint([
interpretation.config,
interpretation.attributes.get(path) ?? [],
blobByPath.get(path) ?? null,
]),
]),
);
return {
identities,
hasInterpretationChanged: interpretationCheck(paths, options.cwd, interpretation.fingerprint),
};
}
function interpretationCheck(
paths: string[],
cwd: string,
baseline: string,
): () => Promise<boolean> {
// Retain only the input description, never a comparison's patch/source pages.
return async () => {
const current = await readInterpretation(paths, {
cwd,
envOverlay: { GIT_OPTIONAL_LOCKS: "0" },
});
return current.fingerprint !== baseline;
};
}
async function readInterpretation(
paths: string[],
options: GitCommandOptions,
): Promise<Interpretation> {
if (!paths.length) return { config: "", attributes: new Map(), fingerprint: "" };
const [config, result] = await Promise.all([
runGitCommand(["config", "--null", "--list"], options),
runGitCommand(["check-attr", "--all", "-z", "--stdin"], {
...options,
input: paths.join("\0") + "\0",
}),
]);
if (config.truncated || result.truncated)
throw new Error("Git input identity exceeded the output budget");
const attributes = new Map<string, string[]>();
const fields = result.stdout.split("\0");
if (fields.at(-1) === "") fields.pop();
if (fields.length % 3) throw new Error("Git returned invalid attributes");
for (let index = 0; index < fields.length; index += 3) {
const values = attributes.get(fields[index]) ?? [];
values.push(JSON.stringify([fields[index + 1], fields[index + 2]]));
attributes.set(fields[index], values);
}
for (const values of attributes.values()) values.sort();
const configIdentity = createHash("sha256").update(config.stdout).digest("hex");
return {
config: configIdentity,
attributes,
fingerprint: fingerprint([
configIdentity,
paths.map((path) => [path, attributes.get(path) ?? []]),
]),
};
}
function fingerprint(value: unknown): string {
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
}
function quotePath(path: string): string {
let quoted = '"';
for (const byte of Buffer.from(path)) {
quoted +=
byte >= 32 && byte <= 126 && byte !== 34 && byte !== 92
? String.fromCharCode(byte)
: `\\${byte.toString(8).padStart(3, "0")}`;
}
return quoted + '"';
}
@@ -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,936 @@
import { execFileSync } from "node:child_process";
import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { expect, test } from "vitest";
import { DiffOpcode, type DiffFrame } from "@getpaseo/protocol/binary-frames/index";
import { DiffSubscriptions } from "./index.js";
import { createFileObserver } from "../file-observer/index.js";
test("shares computation, highlights only the changed file, recovers refs and releases observations", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-subscriptions-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const observer = createFileObserver();
const subscriptions = new DiffSubscriptions({
async requestWorkingTreeWatch(root, onChange) {
const subscription = await observer.subscribe(root, () => onChange(), {
ignore: [join(root, ".git")],
});
return {
repoRoot: root,
unsubscribe: () => {
void subscription.unsubscribe();
},
};
},
});
const first: DiffFrame[] = [];
const second: DiffFrame[] = [];
try {
git("init", "-b", "main");
git("config", "user.email", "test@example.com");
git("config", "user.name", "Test");
await writeFile(join(cwd, "README"), "base\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
for (let index = 0; index < 252; index++)
await writeFile(join(cwd, `${index}.ts`), `const value = ${index};\n`);
const compare = {
cwd,
base: { kind: "rev" as const, rev: "HEAD" },
head: { kind: "worktree" as const },
highlight: true,
};
const coldStarted = performance.now();
let lastEndAt = coldStarted;
const a = await subscriptions.subscribe(compare, async (frame) => {
first.push(frame);
if (frame.opcode === DiffOpcode.End) lastEndAt = performance.now();
});
await a.start();
const coldMs = lastEndAt - coldStarted;
const before = subscriptions.getMetrics().computations;
const previousHighlightCalls = subscriptions.getMetrics().highlightCalls;
const b = await subscriptions.subscribe(compare, async (frame) => {
second.push(frame);
});
await b.start();
expect(subscriptions.getMetrics()).toEqual({
targets: 1,
subscriptions: 2,
computations: before,
highlightCalls: previousHighlightCalls,
metadataObservers: 1,
});
first.length = 0;
second.length = 0;
const refreshStarted = performance.now();
await writeFile(join(cwd, "9.ts"), "const value = 999;\n");
await expect
.poll(() => first.filter((frame) => frame.opcode === DiffOpcode.End).length, {
timeout: 10000,
})
.toBeGreaterThan(0);
const begin = first.find((frame) => frame.opcode === DiffOpcode.Begin);
if (!begin || begin.opcode !== DiffOpcode.Begin) throw new Error("Missing begin");
expect(begin.entries.filter((entry) => entry.body).map((entry) => entry.path)).toEqual([
"9.ts",
]);
const bodies = first.filter((frame) => frame.opcode === DiffOpcode.Bodies);
expect(bodies).toHaveLength(1);
expect(second).toEqual(first);
await expect
.poll(() => subscriptions.getMetrics().highlightCalls, { timeout: 10000 })
.toBe(previousHighlightCalls + 1);
const artifactDirectory = join(
import.meta.dirname,
"../../../../../.artifacts/diff-subscription",
);
await mkdir(artifactDirectory, { recursive: true });
await writeFile(
join(artifactDirectory, "refresh-metrics.json"),
JSON.stringify(
{
files: 252,
coldMs,
oneFileRefreshMs: lastEndAt - refreshStarted,
changedBodies: bodies.length,
highlightCalls: subscriptions.getMetrics().highlightCalls - previousHighlightCalls,
},
null,
2,
),
);
await a.release();
await b.release();
expect(subscriptions.getMetrics().targets).toBe(0);
expect(subscriptions.getMetrics().metadataObservers).toBe(0);
const missing: DiffFrame[] = [];
const recovery = await subscriptions.subscribe(
{
...compare,
base: { kind: "rev", rev: "refs/tags/later" },
head: { kind: "rev", rev: "HEAD" },
},
async (frame) => {
missing.push(frame);
},
);
await recovery.start();
expect(missing.at(-1)).toMatchObject({
opcode: DiffOpcode.End,
error: { code: "REF_NOT_FOUND" },
});
missing.length = 0;
git("update-ref", "refs/tags/later", "HEAD");
await expect
.poll(
() => missing.some((frame) => frame.opcode === DiffOpcode.End && frame.error === null),
{ timeout: 10_000 },
)
.toBe(true);
await recovery.release();
} finally {
await subscriptions.close();
await observer.close();
await rm(cwd, { recursive: true, force: true });
}
}, 60_000);
test("supersedes an in-flight revision at a batch boundary and ends the replacement", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-supersede-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const observer = createFileObserver();
const subscriptions = new DiffSubscriptions({
async requestWorkingTreeWatch(root, onChange) {
const watch = await observer.subscribe(root, () => onChange(), {
ignore: [join(root, ".git")],
});
return {
repoRoot: root,
unsubscribe: async () => {
await watch.unsubscribe();
},
};
},
});
const frames: DiffFrame[] = [];
let interrupted = false;
try {
git("init", "-b", "main");
git("config", "user.email", "test@example.com");
git("config", "user.name", "Test");
await writeFile(join(cwd, "README"), "base\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
const content =
Array.from(
{ length: 100 },
(_, index) => `export const fn${index} = (value: string) => ({ value, count: ${index} });`,
).join("\n") + "\n";
for (let index = 0; index < 80; index++) await writeFile(join(cwd, `${index}.ts`), content);
const subscription = await subscriptions.subscribe(
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } },
async (frame) => {
frames.push(frame);
if (frame.opcode === DiffOpcode.Bodies && !interrupted) {
interrupted = true;
await writeFile(join(cwd, "0.ts"), content + "export const added = true;\n");
await subscriptions.invalidateForCwd(cwd);
}
},
);
await subscription.start();
const begins = frames.filter((frame) => frame.opcode === DiffOpcode.Begin);
expect(interrupted).toBe(true);
expect(begins.length).toBeGreaterThan(1);
for (let index = 0; index < begins.length - 1; index++) {
const revision = begins[index].revision;
const ended = frames.some(
(frame) => frame.opcode === DiffOpcode.End && frame.revision === revision,
);
const superseded = begins[index + 1].revision > revision;
expect(ended || superseded).toBe(true);
}
expect(frames.at(-1)).toMatchObject({
opcode: DiffOpcode.End,
revision: begins.at(-1)?.revision,
error: null,
});
await subscription.release();
} finally {
await subscriptions.close();
await observer.close();
await rm(cwd, { recursive: true, force: true });
}
}, 30_000);
test("moving, deleting and recreating a branch recovers, and staging changes the resolved index SHA", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-ref-index-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const observer = createFileObserver();
const subscriptions = new DiffSubscriptions({
async requestWorkingTreeWatch(root, changed) {
const watch = await observer.subscribe(root, () => changed(), {
ignore: [join(root, ".git")],
});
return {
repoRoot: root,
unsubscribe: async () => {
await watch.unsubscribe();
},
};
},
});
const frames: DiffFrame[] = [];
try {
git("init", "-b", "main");
git("config", "user.email", "test@example.com");
git("config", "user.name", "Test");
await writeFile(join(cwd, "README"), "base\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
git("branch", "compare");
await writeFile(join(cwd, "x.ts"), "export const value = 1;\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "feature");
const head = git("rev-parse", "HEAD");
const branch = await subscriptions.subscribe(
{ cwd, base: { kind: "rev", rev: "refs/heads/compare" }, head: { kind: "rev", rev: "HEAD" } },
async (frame) => {
frames.push(frame);
},
);
await branch.start();
frames.length = 0;
git("update-ref", "refs/heads/compare", head);
await expect
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 })
.toBe(true);
expect(frames.find((frame) => frame.opcode === DiffOpcode.Begin)).toMatchObject({
baseSha: head,
entries: [],
});
frames.length = 0;
git("update-ref", "-d", "refs/heads/compare");
await expect
.poll(
() =>
frames.some(
(frame) => frame.opcode === DiffOpcode.End && frame.error?.code === "REF_NOT_FOUND",
),
{ timeout: 10000 },
)
.toBe(true);
frames.length = 0;
git("update-ref", "refs/heads/compare", head);
await expect
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End && frame.error === null), {
timeout: 10000,
})
.toBe(true);
await branch.release();
frames.length = 0;
const staged = await subscriptions.subscribe(
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "index" } },
async (frame) => {
frames.push(frame);
},
);
await staged.start();
const old = frames.find((frame) => frame.opcode === DiffOpcode.Begin);
if (!old || old.opcode !== DiffOpcode.Begin) throw new Error("Missing initial index revision");
frames.length = 0;
await writeFile(join(cwd, "staged.ts"), "export const staged = true;\n");
git("add", "staged.ts");
await expect
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 })
.toBe(true);
const changed = frames.find((frame) => frame.opcode === DiffOpcode.Begin);
if (!changed || changed.opcode !== DiffOpcode.Begin) throw new Error("Missing staged revision");
expect(changed.headSha).not.toBe(old.headSha);
expect(changed.entries.map((entry) => entry.path)).toEqual(["staged.ts"]);
await staged.release();
} finally {
await subscriptions.close();
await observer.close();
await rm(cwd, { recursive: true, force: true });
}
}, 30000);
test("a cached replay completes before a live refresh can depend on it", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-replay-order-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" });
const subscriptions = new DiffSubscriptions({
async requestWorkingTreeWatch() {
return { repoRoot: cwd, unsubscribe: async () => {} };
},
});
let resume!: () => void;
let began!: () => void;
const paused = new Promise<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 });
}
});
test.each(["main", "linked"] as const)(
"%s checkout observes worktree configuration and shared attributes",
async (checkout) => {
const { resolve } = await import("node:path");
const { readFile } = await import("node:fs/promises");
const root = await mkdtemp(join(tmpdir(), "diff-interpretation-watch-"));
let cwd = join(root, "repo");
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const streams = new DiffSubscriptions({
async requestWorkingTreeWatch(at) {
return { repoRoot: at, unsubscribe: async () => {} };
},
});
const frames: DiffFrame[] = [];
try {
await mkdir(cwd);
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
const lines = Array.from(
{ length: 20 },
(_, index) => `export const value${index} = ${index};`,
);
await writeFile(join(cwd, "a.ts"), lines.join("\n") + "\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
const base = git("rev-parse", "HEAD");
lines[10] = "export const value10 = 999;";
await writeFile(join(cwd, "a.ts"), lines.join("\n") + "\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "edit");
git("config", "extensions.worktreeConfig", "true");
if (checkout === "linked") {
const linked = join(root, "linked");
git("worktree", "add", "-b", "side", linked);
cwd = linked;
}
git("config", "--worktree", "diff.context", "3");
const configPath = resolve(cwd, git("rev-parse", "--git-path", "config.worktree"));
const configText = await readFile(configPath, "utf8");
const subscription = await streams.subscribe(
{ cwd, base: { kind: "rev", rev: base }, head: { kind: "rev", rev: "HEAD" } },
async (frame) => {
frames.push(frame);
},
);
await subscription.start();
const initialFiles = frames.flatMap((frame) =>
frame.opcode === DiffOpcode.Bodies ? frame.files : [],
);
expect(initialFiles[0].hunks[0].lines.filter((line) => line.type === "context")).toHaveLength(
6,
);
const attributesPath = resolve(cwd, git("rev-parse", "--git-path", "info/attributes"));
frames.length = 0;
await writeFile(attributesPath, "a.ts -diff\n");
await expect
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 })
.toBe(true);
expect(frames.find((frame) => frame.opcode === DiffOpcode.Begin)).toMatchObject({
entries: [{ path: "a.ts", content: "binary" }],
});
expect(frames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null });
frames.length = 0;
await writeFile(attributesPath, "");
await expect
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 })
.toBe(true);
const restoredFiles = frames.flatMap((frame) =>
frame.opcode === DiffOpcode.Bodies ? frame.files : [],
);
expect(
restoredFiles[0].hunks[0].lines.filter((line) => line.type === "context"),
).toHaveLength(6);
frames.length = 0;
const changedConfig = configText.replace("context = 3", "context = 0");
expect(changedConfig).not.toBe(configText);
await writeFile(configPath, changedConfig);
await expect
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 })
.toBe(true);
expect(frames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null });
const updatedFiles = frames.flatMap((frame) =>
frame.opcode === DiffOpcode.Bodies ? frame.files : [],
);
expect(updatedFiles[0].hunks[0].lines.filter((line) => line.type === "context")).toHaveLength(
0,
);
await subscription.release();
} finally {
await streams.close();
await rm(root, { recursive: true, force: true });
}
},
30000,
);
test("pinned comparisons refresh Git interpretation before replaying to a joining subscriber", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-pinned-interpretation-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const streams = new DiffSubscriptions({
async requestWorkingTreeWatch(root) {
return { repoRoot: root, unsubscribe: async () => {} };
},
});
const frames: DiffFrame[] = [];
const joined: DiffFrame[] = [];
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(join(cwd, "a.ts"), "export const value = 1;\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
const base = git("rev-parse", "HEAD");
await writeFile(join(cwd, "a.ts"), "export const value = 2;\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "edit");
const compare = {
cwd,
base: { kind: "rev" as const, rev: base },
head: { kind: "rev" as const, rev: git("rev-parse", "HEAD") },
};
const first = await streams.subscribe(compare, async (frame) => {
frames.push(frame);
});
await first.start();
expect(frames.find((frame) => frame.opcode === DiffOpcode.Begin)).toMatchObject({
entries: [{ path: "a.ts", content: "text" }],
});
frames.length = 0;
await writeFile(join(cwd, ".git", "info", "attributes"), "a.ts -diff\n");
await expect
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 })
.toBe(true);
expect(frames.find((frame) => frame.opcode === DiffOpcode.Begin)).toMatchObject({
entries: [{ path: "a.ts", content: "binary" }],
});
expect(frames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null });
const computations = streams.getMetrics().computations;
const second = await streams.subscribe(compare, async (frame) => {
joined.push(frame);
});
await second.start();
expect(joined.find((frame) => frame.opcode === DiffOpcode.Begin)).toMatchObject({
entries: [{ path: "a.ts", content: "binary" }],
});
expect(joined.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null });
expect(streams.getMetrics().computations).toBe(computations);
await second.release();
await first.release();
} finally {
await streams.close();
await rm(cwd, { recursive: true, force: true });
}
}, 30000);
test.each(["pinned", "HEAD"] as const)(
"%s revision comparisons observe worktree attributes and replay their current result",
async (endpoint) => {
const cwd = await mkdtemp(join(tmpdir(), "diff-worktree-attributes-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const observer = createFileObserver();
const streams = new DiffSubscriptions({
async requestWorkingTreeWatch(root, changed) {
const watch = await observer.subscribe(root, () => changed(), {
ignore: [join(root, ".git")],
});
return { repoRoot: root, unsubscribe: () => watch.unsubscribe() };
},
});
const frames: DiffFrame[] = [];
const joined: DiffFrame[] = [];
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(join(cwd, "a.ts"), "export const value = 1;\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
const base = git("rev-parse", "HEAD");
await writeFile(join(cwd, "a.ts"), "export const value = 2;\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "edit");
const compare = {
cwd,
base: { kind: "rev" as const, rev: base },
head: {
kind: "rev" as const,
rev: endpoint === "HEAD" ? "HEAD" : git("rev-parse", "HEAD"),
},
};
const first = await streams.subscribe(compare, async (frame) => {
frames.push(frame);
});
await first.start();
frames.length = 0;
await writeFile(join(cwd, ".gitattributes"), "a.ts -diff\n");
await expect
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 10000 })
.toBe(true);
expect(frames.find((frame) => frame.opcode === DiffOpcode.Begin)).toMatchObject({
entries: [{ path: "a.ts", content: "binary" }],
});
expect(frames.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null });
const computations = streams.getMetrics().computations;
const second = await streams.subscribe(compare, async (frame) => {
joined.push(frame);
});
await second.start();
expect(joined.find((frame) => frame.opcode === DiffOpcode.Begin)).toMatchObject({
entries: [{ path: "a.ts", content: "binary" }],
});
expect(joined.at(-1)).toMatchObject({ opcode: DiffOpcode.End, error: null });
expect(streams.getMetrics().computations).toBe(computations);
await second.release();
await first.release();
} finally {
await streams.close();
await observer.close();
await rm(cwd, { recursive: true, force: true });
}
},
30000,
);
@@ -29,6 +29,8 @@ test("a rename-only commit returns zero added or deleted lines through the clien
expect(await client.getCommitFileDiff(cwd, sha, "new.txt")).toEqual({
file: {
path: "new.txt",
oldPath: "old.txt",
status: "ok",
isNew: false,
isDeleted: false,
additions: 0,
@@ -98,3 +100,144 @@ test.skipIf(process.platform === "win32")(
}
},
);
test("streamed Changes preserves BOM-prefixed paths and highlighted content", async () => {
const cwd = mkdtempSync(join(tmpdir(), "stream-bom-diff-"));
const daemon = await createTestPaseoDaemon({ agentClients: {} });
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
execFileSync("git", ["init", "-b", "main"], { cwd });
writeFileSync(join(cwd, "\uFEFFbom.ts"), "\uFEFFexport const value = 1;\n");
writeFileSync(join(cwd, "other.txt"), "other edit\n");
await client.connect();
const states: import("@getpaseo/client").DiffState[] = [];
const observation = client.observeDiff({
cwd,
base: { kind: "empty" },
head: { kind: "worktree" },
});
const unsubscribe = observation.subscribe({
snapshot: (state) => states.push(state),
update: () => {},
});
try {
await expect.poll(() => states.at(-1)?.phase, { timeout: 15000 }).toBe("ready");
const readyState = states.at(-1);
if (readyState?.phase !== "ready") throw new Error("Expected ready diff");
expect(readyState.files.map((file) => file.path)).toContain("other.txt");
const bomFile = readyState.files.find((file) => file.path === "\uFEFFbom.ts");
const addedLine = bomFile?.hunks
.flatMap((hunk) => hunk.lines)
.find((line) => line.type === "add");
expect(addedLine?.content).toBe("\uFEFFexport const value = 1;");
expect(addedLine?.tokens?.map((token) => token.text).join("")).toBe(addedLine?.content);
expect(states.some((state) => state.phase === "error")).toBe(false);
} finally {
unsubscribe();
await observation.release();
}
} finally {
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
test("checkout refresh from a subdirectory updates a pinned streamed comparison", async () => {
const cwd = mkdtempSync(join(tmpdir(), "stream-subdirectory-refresh-"));
const { mkdirSync } = await import("node:fs");
const workspace = join(cwd, "workspace");
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const daemon = await createTestPaseoDaemon({ agentClients: {} });
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
mkdirSync(workspace);
writeFileSync(join(cwd, "file.txt"), "committed content\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
const head = git("rev-parse", "HEAD");
await client.connect();
const states: import("@getpaseo/client").DiffState[] = [];
const observation = client.observeDiff({
cwd: workspace,
base: { kind: "empty" },
head: { kind: "rev", rev: head },
});
const unsubscribe = observation.subscribe({
snapshot: (state) => states.push(state),
update: () => {},
});
try {
await expect.poll(() => states.at(-1)?.phase, { timeout: 15000 }).toBe("ready");
const initial = states.at(-1);
if (initial?.phase !== "ready") throw new Error("Expected initial diff");
expect(initial.files[0]).toMatchObject({ path: "file.txt", status: "ok" });
expect(await client.checkoutRefresh(workspace)).toMatchObject({ success: true, error: null });
await expect
.poll(() => states.at(-1)?.revision, { timeout: 15000 })
.toBeGreaterThan(initial.revision);
expect(states.at(-1)).toMatchObject({
phase: "ready",
files: [{ path: "file.txt", status: "ok" }],
});
writeFileSync(join(cwd, ".git", "info", "attributes"), "file.txt -diff\n");
await expect
.poll(() => states.at(-1), { timeout: 15000 })
.toMatchObject({
phase: "ready",
files: [{ path: "file.txt", status: "binary", hunks: [] }],
});
} finally {
unsubscribe();
await observation.release();
}
} finally {
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
test("refreshing a non-Git workspace succeeds while a Git comparison is open", async () => {
const cwd = mkdtempSync(join(tmpdir(), "stream-refresh-git-"));
const plain = mkdtempSync(join(tmpdir(), "stream-refresh-plain-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const daemon = await createTestPaseoDaemon({ agentClients: {} });
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
writeFileSync(join(cwd, "file.txt"), "committed content\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await client.connect();
const states: import("@getpaseo/client").DiffState[] = [];
const observation = client.observeDiff({
cwd,
base: { kind: "empty" },
head: { kind: "rev", rev: git("rev-parse", "HEAD") },
});
const unsubscribe = observation.subscribe({
snapshot: (state) => states.push(state),
update: () => {},
});
try {
await expect.poll(() => states.at(-1)?.phase, { timeout: 15000 }).toBe("ready");
const revision = states.at(-1)?.revision;
expect(await client.checkoutRefresh(plain)).toMatchObject({ success: true, error: null });
expect(states.at(-1)).toMatchObject({ phase: "ready", revision });
} finally {
unsubscribe();
await observation.release();
}
} finally {
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
rmSync(plain, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
@@ -1,6 +1,7 @@
import { describe, expect, test } from "vitest";
import {
classifyGitMetadataPath,
affectsGitDiffInterpretation,
GIT_METADATA_EVENT_RULES,
getPrunedGitMetadataPaths,
} from "./git-metadata-event-rules.js";
@@ -35,14 +36,28 @@ describe("Git metadata event rules", () => {
["common", "packed-refs", { kind: "all", refreshBase: true }],
["common", "reftable/tables.list", { kind: "all", refreshBase: true }],
["common", "config", { kind: "all", refreshBase: true }],
["common", "info/attributes", { kind: "all", refreshBase: false }],
["common", "worktrees/new/index", { kind: "ignore" }],
["common", "hooks/pre-commit", { kind: "ignore" }],
["common", "logs/refs/heads/main", { kind: "ignore" }],
["common", "objects/ab/cdef", { kind: "ignore" }],
["common", "refs/tags/v1", { kind: "ignore" }],
["common", "refs/tags/v1", { kind: "all", refreshBase: true }],
["common", "FETCH_HEAD", { kind: "ignore" }],
["common", "packed-refs.lock", { kind: "ignore" }],
] as const)("classifies %s metadata path %s", (scope, path, expected) => {
expect(classifyGitMetadataPath(scope, path)).toEqual(expected);
});
});
test.each([
["worktree", "config.worktree", true],
["common", "config.worktree", false],
["common", "config", true],
["common", "info/attributes", true],
["worktree", "info/attributes", false],
["common", "refs/heads/main", false],
["worktree", "index", false],
["common", "config.lock", false],
] as const)("diff interpretation dependency %s/%s is %s", (scope, path, expected) => {
expect(affectsGitDiffInterpretation(scope, path)).toBe(expected);
});
@@ -5,6 +5,7 @@ type GitMetadataEventRule = {
scope: GitMetadataScope | "both";
path: string;
prune?: true;
diffInput?: true;
} & (
| { match: "exact" | "prefix" | "suffix"; route: "ignore" }
| { match: "exact" | "prefix" | "suffix"; route: "owner" | "all"; refreshBase: boolean }
@@ -36,6 +37,7 @@ export const GIT_METADATA_EVENT_RULES = [
},
{
id: "worktree-config",
diffInput: true,
scope: "worktree",
match: "exact",
path: "config.worktree",
@@ -73,6 +75,14 @@ export const GIT_METADATA_EVENT_RULES = [
path: "refs/heads/",
route: "local-ref",
},
{
id: "tag",
scope: "common",
match: "prefix",
path: "refs/tags/",
route: "all",
refreshBase: true,
},
{
id: "remote-branch",
scope: "common",
@@ -98,12 +108,22 @@ export const GIT_METADATA_EVENT_RULES = [
},
{
id: "shared-config",
diffInput: true,
scope: "common",
match: "exact",
path: "config",
route: "all",
refreshBase: true,
},
{
id: "shared-attributes",
diffInput: true,
scope: "common",
match: "exact",
path: "info/attributes",
route: "all",
refreshBase: false,
},
{
id: "worktree-administration",
scope: "common",
@@ -150,11 +170,7 @@ export function classifyGitMetadataPath(
inputPath: string,
): GitMetadataEffect {
const path = inputPath.replaceAll("\\", "/");
const rule = GIT_METADATA_EVENT_RULES.find(
(candidate) =>
(candidate.scope === "both" || candidate.scope === scope) &&
matchesGitMetadataPath(candidate.match, candidate.path, path),
);
const rule = findGitMetadataRule(scope, path);
if (!rule || rule.route === "ignore") return { kind: "ignore" };
if (rule.route === "owner" || rule.route === "all") {
return { kind: rule.route, refreshBase: rule.refreshBase };
@@ -166,6 +182,19 @@ export function classifyGitMetadataPath(
};
}
export function affectsGitDiffInterpretation(scope: GitMetadataScope, inputPath: string): boolean {
const rule = findGitMetadataRule(scope, inputPath.replaceAll("\\", "/"));
return rule !== undefined && "diffInput" in rule && rule.diffInput;
}
function findGitMetadataRule(scope: GitMetadataScope, path: string) {
return GIT_METADATA_EVENT_RULES.find(
(candidate) =>
(candidate.scope === "both" || candidate.scope === scope) &&
matchesGitMetadataPath(candidate.match, candidate.path, path),
);
}
function matchesGitMetadataPath(
match: GitMetadataEventRule["match"],
pattern: string,
+10 -1
View File
@@ -1,3 +1,4 @@
import { DiffSession } from "./diff/index.js";
import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
import type { AgentMessageDelivery } from "./agent/agent-messages/index.js";
import { AgentDirectory, type BackgroundVisibility } from "./agent/directory.js";
@@ -855,6 +856,7 @@ export class Session {
private readonly workspaceDirectory: WorkspaceDirectory;
private readonly voiceSessions: VoiceSessions;
private readonly checkoutSession: CheckoutSession;
private readonly diffSession: DiffSession;
private readonly scheduleSession: ScheduleSession;
private readonly providerCatalogSession: ProviderCatalogSession;
private readonly usageSession: UsageSession;
@@ -1019,6 +1021,7 @@ export class Session {
await this.workspaceProvisioning.ensureWorkspaceRecordUnarchived(workspace);
},
});
this.diffSession = new DiffSession(checkoutDiffManager.streams, this.sessionLogger);
this.checkoutSession = new CheckoutSession({
host: {
emit: (msg) => this.emit(msg),
@@ -3174,6 +3177,10 @@ export class Session {
// eslint-disable-next-line complexity
private dispatchCheckoutMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "diff.subscribe.request":
return this.diffSession.subscribe(msg, this.delivery, (reply) => this.emit(reply));
case "diff.unsubscribe.request":
return this.delivery.release(msg.subscriptionId);
case "checkout_status_request":
return this.checkoutSession.handleStatusRequest(msg);
case "checkout.commits.list.request":
@@ -3485,7 +3492,9 @@ export class Session {
await this.workspaceFilesSession.handleFileTransferFrame(binaryFrame.frame, source);
return;
}
this.terminalController.handleBinaryFrame(binaryFrame.frame, source);
if (binaryFrame.kind === "terminal") {
this.terminalController.handleBinaryFrame(binaryFrame.frame, source);
}
}
private async handleRestartServerRequest(
@@ -50,6 +50,7 @@ function createFakeDiffSubscriber(initial: CheckoutDiffSnapshotPayload) {
const subscriptions: FakeDiffSubscription[] = [];
const refreshedCwds: string[] = [];
const subscriber: CheckoutDiffSubscriber = {
readCommitFile: async () => null,
read: async (params) => ({ ...initial, cwd: params.cwd }),
subscribe: async (params, listener) => {
let isSubscribed = true;
@@ -77,7 +78,7 @@ function createFakeDiffSubscriber(initial: CheckoutDiffSnapshotPayload) {
unsubscribe,
};
},
scheduleRefreshForCwd: (cwd) => {
scheduleRefreshForCwd: async (cwd) => {
refreshedCwds.push(cwd);
},
};
@@ -51,7 +51,6 @@ import {
pullCurrentBranch,
pushCurrentBranch,
listCheckoutCommits,
getCommitFileDiff,
} from "../../../utils/checkout-git.js";
import { runGitCommand } from "../../../utils/run-git-command.js";
import { expandTilde } from "../../../utils/path.js";
@@ -109,6 +108,7 @@ function toLegacyGithubSearchItems(items: ForgeSearchResultItem[]): LegacyGithub
* real CheckoutDiffManager satisfies this structurally; tests supply a fake.
*/
export interface CheckoutDiffSubscriber {
readCommitFile: import("../../diff/index.js").DiffSubscriptions["readCommitFile"];
read(
params: Omit<CheckoutDiffSubscriptionRequest, "signal">,
): Promise<CheckoutDiffSnapshotPayload>;
@@ -116,7 +116,7 @@ export interface CheckoutDiffSubscriber {
params: CheckoutDiffSubscriptionRequest,
listener: (snapshot: CheckoutDiffSnapshotPayload) => void,
): Promise<CheckoutDiffSubscription>;
scheduleRefreshForCwd(cwd: string): void;
scheduleRefreshForCwd(cwd: string): Promise<void>;
}
export interface CheckoutSessionOptions {
@@ -301,7 +301,12 @@ export class CheckoutSession {
if (path.length === 0 || isAbsolute(path) || path.split(/[\\/]/).includes("..")) {
throw new Error(`Invalid path: ${path}`);
}
const file = await getCommitFileDiff({ cwd: expandTilde(cwd), sha, path, signal });
const file = await this.checkoutDiffManager.readCommitFile({
cwd: expandTilde(cwd),
sha,
path,
signal,
});
this.host.emit({
type: "checkout.commits.file_diff.response",
payload: { cwd, sha, path, file, error: null, requestId },
@@ -494,7 +499,7 @@ export class CheckoutSession {
includeForge: true,
reason: "manual-refresh",
});
this.checkoutDiffManager.scheduleRefreshForCwd(resolvedCwd);
await this.checkoutDiffManager.scheduleRefreshForCwd(resolvedCwd);
this.host.emit({
type: "checkout.refresh.response",
payload: {
@@ -548,8 +553,8 @@ export class CheckoutSession {
* Notify the live diff subscriptions that the working tree at `cwd` changed.
* Called by the command handlers below after they mutate the repository.
*/
private scheduleDiffRefresh(cwd: string): void {
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
private scheduleDiffRefresh(cwd: string): Promise<void> {
return this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
}
// ---------------------------------------------------------------------------
@@ -563,7 +568,7 @@ export class CheckoutSession {
try {
const checkoutResult = await this.gitMutation.checkoutExistingBranch(cwd, branch);
this.scheduleDiffRefresh(cwd);
await this.scheduleDiffRefresh(cwd);
// Push a workspace_update immediately so the sidebar/header reflect
// the new branch name without waiting for the background git watcher.
@@ -615,7 +620,7 @@ export class CheckoutSession {
try {
const result = await this.host.renameCurrentBranch(cwd, branch);
await this.gitMutation.notifyGitMutation(cwd, "rename-branch", { invalidateForge: true });
this.scheduleDiffRefresh(cwd);
await this.scheduleDiffRefresh(cwd);
this.host.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch);
// Branch is a git fact derived per-descriptor from each workspace's own
@@ -657,7 +662,7 @@ export class CheckoutSession {
try {
await discardChanges(cwd, paths);
await this.gitMutation.notifyGitMutation(cwd, "discard-changes");
this.scheduleDiffRefresh(cwd);
await this.scheduleDiffRefresh(cwd);
this.host.emit({
type: "checkout.discard_changes.response",
payload: { cwd, success: true, error: null, requestId },
@@ -684,7 +689,7 @@ export class CheckoutSession {
timeout: 120_000,
});
await this.gitMutation.notifyGitMutation(cwd, "stash-push");
this.scheduleDiffRefresh(cwd);
await this.scheduleDiffRefresh(cwd);
this.host.emit({
type: "stash_save_response",
payload: { cwd, success: true, error: null, requestId },
@@ -707,7 +712,7 @@ export class CheckoutSession {
timeout: 120_000,
});
await this.gitMutation.notifyGitMutation(cwd, "stash-pop");
this.scheduleDiffRefresh(cwd);
await this.scheduleDiffRefresh(cwd);
this.host.emit({
type: "stash_pop_response",
payload: { cwd, success: true, error: null, requestId },
@@ -759,7 +764,7 @@ export class CheckoutSession {
addAll: msg.addAll ?? true,
});
await this.gitMutation.notifyGitMutation(cwd, "commit-changes");
this.scheduleDiffRefresh(cwd);
await this.scheduleDiffRefresh(cwd);
this.host.emit({
type: "checkout_commit_response",
@@ -820,7 +825,7 @@ export class CheckoutSession {
this.gitMutation.notifyGitMutation(mutatedCwd, "merge-to-base", { invalidateForge: true }),
...(mutatedCwd !== cwd ? [this.gitMutation.notifyGitMutation(cwd, "merge-to-base")] : []),
]);
this.scheduleDiffRefresh(cwd);
await this.scheduleDiffRefresh(cwd);
this.host.emit({
type: "checkout_merge_response",
@@ -866,7 +871,7 @@ export class CheckoutSession {
{ paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot },
);
await this.gitMutation.notifyGitMutation(cwd, "merge-from-base", { invalidateForge: true });
this.scheduleDiffRefresh(cwd);
await this.scheduleDiffRefresh(cwd);
this.host.emit({
type: "checkout_merge_from_base_response",
@@ -898,7 +903,7 @@ export class CheckoutSession {
try {
await pullCurrentBranch(cwd);
await this.gitMutation.notifyGitMutation(cwd, "pull", { invalidateForge: true });
this.scheduleDiffRefresh(cwd);
await this.scheduleDiffRefresh(cwd);
this.host.emit({
type: "checkout_pull_response",
@@ -1,3 +1,4 @@
import { DiffSubscriptions } from "./diff/index.js";
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js";
import {
@@ -105,6 +106,13 @@ describe("snapshot mutation ownership boundary", () => {
const session = asInternals<SessionInternals>(
new Session({
checkoutDiffManager: createStub<SessionOptions["checkoutDiffManager"]>({
streams: new DiffSubscriptions({
async requestWorkingTreeWatch() {
throw new Error("Unexpected diff observation");
},
}),
}),
workspaceRuntimeStore: createWorkspaceRuntimeStore(),
messageReceipts: createMessageReceiptsStub(),
agentMessages: createAgentMessageDeliveryStub(),
@@ -70,11 +70,6 @@ function extractPathFromMetadata(lines: string[], prefix: "--- " | "+++ "): stri
function extractPathFromDiffHeader(lines: string[]): string {
const firstLine = lines[0] ?? "";
const prefixedPathMatch = firstLine.match(/^a\/(.+) b\/(.+)$/);
if (prefixedPathMatch) {
return prefixedPathMatch[2];
}
const quotedPathMatch =
firstLine.match(/^("(?:\\.|[^"\\])*") (.+)$/) ?? firstLine.match(/^(.+) ("(?:\\.|[^"\\])*")$/);
if (quotedPathMatch) {
@@ -86,10 +81,21 @@ function extractPathFromDiffHeader(lines: string[]): string {
return newPath;
}
const metadataPath =
extractPathFromMetadata(lines, "+++ ") ?? extractPathFromMetadata(lines, "--- ");
if (metadataPath) {
return metadataPath;
// The ---/+++ records are unambiguous even when a path contains " b/".
const oldMetadata = extractPathFromMetadata(lines, "--- ");
const newMetadata = extractPathFromMetadata(lines, "+++ ");
const metadata = newMetadata ?? oldMetadata;
if (metadata) {
const prefixed =
(oldMetadata !== null &&
newMetadata !== null &&
usesDiffPathPrefixes(oldMetadata, newMetadata)) ||
firstLine === `a/${metadata.slice(2)} b/${metadata.slice(2)}`;
return prefixed ? metadata.slice(2) : metadata;
}
const prefixedPathMatch = firstLine.match(/^a\/(.+) b\/(.+)$/);
if (prefixedPathMatch) {
return prefixedPathMatch[2];
}
const pathMatch = firstLine.match(/^(\S+)\s+(\S+)$/);
@@ -462,7 +468,8 @@ function applyTokensToHunks(
newLineNum++;
}
return tokens ? { ...line, tokens } : line;
const matchingTokens = tokensForPatchLine(tokens, line.content);
return matchingTokens ? { ...line, tokens: matchingTokens } : line;
});
return { ...hunk, lines: highlightedLines };
@@ -471,6 +478,25 @@ function applyTokensToHunks(
return { ...file, hunks: highlightedHunks };
}
function tokensForPatchLine(
tokens: HighlightToken[] | undefined,
content: string,
): HighlightToken[] | undefined {
if (!tokens) return undefined;
const source = tokens.map((token) => token.text).join("");
if (source === content) return tokens;
// Git can normalize a CRLF worktree while source highlighting retains its CR.
if (source !== `${content}\r`) return undefined;
const aligned: HighlightToken[] = [];
let remaining = content.length;
for (const token of tokens) {
const text = token.text.slice(0, remaining);
if (text) aligned.push({ text, style: token.style });
remaining -= text.length;
}
return aligned;
}
/**
* Parse and highlight a complete diff, using actual file content when available.
*/
@@ -1227,7 +1227,7 @@ export class VoiceAssistantWebSocketServer {
await Promise.all(cleanupPromises);
this.providerSnapshotManager.destroy();
this.checkoutDiffManager.dispose();
await this.checkoutDiffManager.dispose();
await this.workspaceGitService.dispose();
this.pendingConnections.clear();
this.sessions.clear();
@@ -2025,6 +2025,7 @@ export class VoiceAssistantWebSocketServer {
usageSetting: true,
usageSources: true,
ownedSubscriptions: true,
diffSubscription: true,
binaryChunkingV1: true,
agentRequestReceipts: true,
workspaceRequestReceipts: true,
@@ -258,7 +258,7 @@ function createSessionForWireCompatTest(options?: {
directorySync: options?.directorySync,
scheduleService: {} as SessionOptions["scheduleService"],
checkoutDiffManager: {
scheduleRefreshForCwd() {},
async scheduleRefreshForCwd() {},
onWorkspaceStateMayHaveChanged() {},
} as unknown as SessionOptions["checkoutDiffManager"],
github: {
@@ -11,7 +11,6 @@ import {
type CheckoutSnapshotFacts,
type CheckoutStatusGit,
} from "../utils/checkout-git.js";
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { createFileObserver } from "./file-observer/index.js";
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
import type { FileChange, FileObserver, SubscribeToFileChanges } from "./file-observer/index.js";
@@ -250,24 +249,15 @@ test("recursive observation updates tracked state and prunes ignored storms", as
});
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
const diffManager = new CheckoutDiffManager({
workspaceRuntimeStore: workspaceRuntimeStore,
logger: createLogger(),
paseoHome: path.join(tempDir, "paseo-home"),
workspaceGitService: service,
});
const summaryListener = vi.fn();
const diffListener = vi.fn();
const summarySubscription = service.registerWorkspace({ cwd: repoDir }, summaryListener);
const diffSubscription = await diffManager.subscribe(
{ cwd: repoDir, compare: { mode: "uncommitted" } },
diffListener,
);
const diffSubscription = await service.requestWorkingTreeWatch(repoDir, diffListener);
cleanup.push(async () => {
diffSubscription.unsubscribe();
summarySubscription.unsubscribe();
diffManager.dispose();
await service.dispose();
expect(activeWatcherCount).toBe(0);
rmSync(tempDir, { recursive: true, force: true });
@@ -312,6 +302,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as
getCheckoutShortstat.mockClear();
getCheckoutWorktreeState.mockClear();
getCheckoutDiff.mockClear();
diffListener.mockClear();
runGitCommand.mockClear();
deliveredEvents.length = 0;
@@ -344,18 +335,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as
}),
}),
);
expect(diffListener).toHaveBeenLastCalledWith({
cwd: repoDir,
files: [
{
path: "src/tracked.txt",
additions: 2,
deletions: 0,
status: "modified",
},
],
error: null,
});
expect(diffListener).toHaveBeenCalled();
expect(service.getMetrics().workspaceRefreshQueuedCount).toBe(0);
},
{ timeout: 5_000 },
@@ -371,11 +351,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as
}),
}),
);
expect(diffListener).toHaveBeenLastCalledWith(
expect.objectContaining({
files: [expect.objectContaining({ additions: 3 })],
}),
);
expect(diffListener).toHaveBeenCalled();
expect(service.getMetrics()).toMatchObject({
workspaceRefreshInFlightCount: 0,
workspaceRefreshQueuedCount: 0,
@@ -409,18 +385,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as
}),
}),
);
expect(diffListener).toHaveBeenLastCalledWith({
cwd: repoDir,
files: [
{
path: "build/tracked.txt",
additions: 2,
deletions: 0,
status: "modified",
},
],
error: null,
});
expect(diffListener).toHaveBeenCalled();
expect(service.getMetrics()).toMatchObject({
workspaceRefreshInFlightCount: 0,
workspaceRefreshQueuedCount: 0,
@@ -434,6 +399,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as
getCheckoutShortstat.mockClear();
getCheckoutWorktreeState.mockClear();
getCheckoutDiff.mockClear();
diffListener.mockClear();
runGitCommand.mockClear();
summaryListener.mockClear();
diffListener.mockClear();
@@ -449,18 +415,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as
}),
}),
);
expect(diffListener).toHaveBeenLastCalledWith({
cwd: repoDir,
files: [
{
path: "build/tracked.txt",
additions: 4,
deletions: 0,
status: "modified",
},
],
error: null,
});
expect(diffListener).toHaveBeenCalled();
expect(service.getMetrics()).toMatchObject({
workspaceRefreshInFlightCount: 0,
workspaceRefreshQueuedCount: 0,
@@ -474,6 +429,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as
getCheckoutShortstat.mockClear();
getCheckoutWorktreeState.mockClear();
getCheckoutDiff.mockClear();
diffListener.mockClear();
runGitCommand.mockClear();
for (let index = 0; index < 100; index += 1) {
@@ -4,7 +4,6 @@ import { pathToFileURL } from "node:url";
import type pino from "pino";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { CheckoutSnapshotFacts, CheckoutStatusGit } from "../utils/checkout-git.js";
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import type { FileObserver } from "./file-observer/index.js";
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
@@ -428,41 +427,30 @@ describe("WorkspaceGitService checkout observation", () => {
service.dispose();
});
test("a tracked edit refreshes summary and active uncommitted diff without structural or forge work", async () => {
test("a tracked edit refreshes summary and notifies worktree consumers without structural or forge work", async () => {
const watcher = createWatcherHarness();
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => createCheckoutFacts(cwd));
let diffStat = { additions: 1, deletions: 0 };
let diffFile = { path: "tracked.txt", additions: 1, deletions: 0, status: "modified" };
const getCheckoutStatus = vi.fn(async (cwd: string) =>
createCheckoutStatus(cwd, { isDirty: true }),
);
const getCheckoutShortstat = vi.fn(async () => diffStat);
const getPullRequestStatus = vi.fn();
const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: [diffFile] }));
const service = createService(watcher, {
getCheckoutSnapshotFacts,
getCheckoutStatus,
getCheckoutShortstat,
getPullRequestStatus,
getCheckoutDiff,
});
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
const diffManager = new CheckoutDiffManager({
workspaceRuntimeStore: workspaceRuntimeStore,
logger: createLogger(),
paseoHome: "/tmp/paseo-home",
workspaceGitService: service,
});
const summaryListener = vi.fn();
const diffListener = vi.fn();
await service.getSnapshot(REPO_CWD);
const summarySubscription = service.registerWorkspace({ cwd: REPO_CWD }, summaryListener);
const diffSubscription = await diffManager.subscribe(
{ cwd: REPO_CWD, compare: { mode: "uncommitted" } },
diffListener,
);
const diffSubscription = await service.requestWorkingTreeWatch(REPO_CWD, diffListener);
await vi.waitFor(() => {
expect(watcher.subscribe).toHaveBeenCalledWith(
REPO_CWD,
@@ -475,7 +463,6 @@ describe("WorkspaceGitService checkout observation", () => {
});
diffStat = { additions: 4, deletions: 2 };
diffFile = { path: "tracked.txt", additions: 4, deletions: 2, status: "modified" };
watcher.records[0]?.callback(null, [
{ path: path.join(REPO_CWD, "tracked.txt"), type: "update" },
]);
@@ -489,11 +476,7 @@ describe("WorkspaceGitService checkout observation", () => {
}),
}),
);
expect(diffListener).toHaveBeenLastCalledWith({
cwd: REPO_CWD,
files: [{ path: "tracked.txt", additions: 4, deletions: 2, status: "modified" }],
error: null,
});
expect(diffListener).toHaveBeenCalledTimes(1);
});
await flushPromises();
@@ -502,7 +485,6 @@ describe("WorkspaceGitService checkout observation", () => {
diffSubscription.unsubscribe();
summarySubscription.unsubscribe();
diffManager.dispose();
service.dispose();
});
@@ -1245,7 +1227,7 @@ describe("WorkspaceGitService checkout observation", () => {
service.dispose();
});
test("metadata-only changes refresh worktree summary and active uncommitted diff", async () => {
test("metadata-only changes refresh worktree summary and invalidate the uncommitted projection", async () => {
const watcher = createWatcherHarness();
let isDirty = true;
let diffStat = { additions: 3, deletions: 1 };
@@ -1260,19 +1242,10 @@ describe("WorkspaceGitService checkout observation", () => {
});
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
const diffManager = new CheckoutDiffManager({
workspaceRuntimeStore: workspaceRuntimeStore,
logger: createLogger(),
paseoHome: "/tmp/paseo-home",
workspaceGitService: service,
});
const summaryListener = vi.fn();
const diffListener = vi.fn();
const summarySubscription = service.registerWorkspace({ cwd: REPO_CWD }, summaryListener);
const diffSubscription = await diffManager.subscribe(
{ cwd: REPO_CWD, compare: { mode: "uncommitted" } },
diffListener,
);
await service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" });
await vi.waitFor(() => {
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
@@ -1295,16 +1268,13 @@ describe("WorkspaceGitService checkout observation", () => {
}),
}),
);
expect(diffListener).toHaveBeenLastCalledWith({
cwd: REPO_CWD,
files: [],
error: null,
});
});
diffSubscription.unsubscribe();
expect(await service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" })).toMatchObject({
structured: [],
});
expect(getCheckoutDiff).toHaveBeenCalledTimes(2);
summarySubscription.unsubscribe();
diffManager.dispose();
service.dispose();
});
@@ -1318,20 +1288,14 @@ describe("WorkspaceGitService checkout observation", () => {
const service = createService(watcher, { getCheckoutDiff, getCheckoutWorktreeState });
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
const diffManager = new CheckoutDiffManager({
workspaceRuntimeStore: workspaceRuntimeStore,
logger: createLogger(),
paseoHome: "/tmp/paseo-home",
workspaceGitService: service,
});
const diffSubscription = await diffManager.subscribe(
{ cwd: REPO_CWD, compare: { mode: "base", baseRef: "main" } },
vi.fn(),
);
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, () => {});
await service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" });
await vi.waitFor(() => {
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
});
await service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" });
expect(getCheckoutDiff).toHaveBeenCalledTimes(1);
watcher.records
@@ -1339,6 +1303,7 @@ describe("WorkspaceGitService checkout observation", () => {
?.callback(null, [{ path: path.join(REPO_CWD, "tracked.txt"), type: "update" }]);
await vi.advanceTimersByTimeAsync(1_000);
await flushPromises();
await service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" });
expect(getCheckoutDiff).toHaveBeenCalledTimes(1);
watcher.records
@@ -1346,12 +1311,10 @@ describe("WorkspaceGitService checkout observation", () => {
?.callback(null, [{ path: path.join(GIT_DIR, "packed-refs"), type: "update" }]);
await vi.advanceTimersByTimeAsync(1_000);
await vi.advanceTimersByTimeAsync(150);
await vi.waitFor(() => {
expect(getCheckoutDiff).toHaveBeenCalledTimes(2);
});
await service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" });
expect(getCheckoutDiff).toHaveBeenCalledTimes(2);
diffSubscription.unsubscribe();
diffManager.dispose();
subscription.unsubscribe();
service.dispose();
});
@@ -2773,39 +2736,23 @@ describe("WorkspaceGitService checkout observation", () => {
service.dispose();
});
test("a working-tree watcher event refreshes summary and diff subscribers", async () => {
test("a working-tree watcher event refreshes summary and notifies worktree consumers", async () => {
const watcher = createWatcherHarness();
let isDirty = false;
let diffStat = { additions: 0, deletions: 0 };
let diffFiles: Array<{
path: string;
additions: number;
deletions: number;
status: string;
}> = [];
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd, { isDirty }));
const getCheckoutShortstat = vi.fn(async () => diffStat);
const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: diffFiles }));
const service = createService(watcher, {
getCheckoutStatus,
getCheckoutShortstat,
getCheckoutDiff,
});
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
const diffManager = new CheckoutDiffManager({
workspaceRuntimeStore: workspaceRuntimeStore,
logger: createLogger(),
paseoHome: "/tmp/paseo-home",
workspaceGitService: service,
});
const listener = vi.fn();
const diffListener = vi.fn();
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
const diffSubscription = await diffManager.subscribe(
{ cwd: REPO_CWD, compare: { mode: "uncommitted" } },
diffListener,
);
const diffSubscription = await service.requestWorkingTreeWatch(REPO_CWD, diffListener);
await vi.waitFor(() => {
expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull();
@@ -2813,7 +2760,6 @@ describe("WorkspaceGitService checkout observation", () => {
});
isDirty = true;
diffStat = { additions: 7, deletions: 3 };
diffFiles = [{ path: "tracked.txt", additions: 7, deletions: 3, status: "modified" }];
getWatcherRecordsForDirectory(watcher, REPO_CWD)[0]?.callback(null, [
{ path: path.join(REPO_CWD, "tracked.txt"), type: "update" },
@@ -2828,17 +2774,12 @@ describe("WorkspaceGitService checkout observation", () => {
}),
}),
);
expect(diffListener).toHaveBeenLastCalledWith({
cwd: REPO_CWD,
files: [{ path: "tracked.txt", additions: 7, deletions: 3, status: "modified" }],
error: null,
});
expect(diffListener).toHaveBeenCalledTimes(1);
});
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
diffSubscription.unsubscribe();
subscription.unsubscribe();
diffManager.dispose();
service.dispose();
});
});
@@ -161,6 +161,7 @@ export interface WorkspaceGitRuntimeSnapshot {
isDirty: boolean | null;
baseRef: string | null;
baseRefExact?: string | null;
comparisonBaseRef?: string | null;
aheadBehind: { ahead: number; behind: number } | null;
upstreamRef: string | null;
aheadOfOrigin: number | null;
@@ -3626,6 +3627,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
: (target.latestGit?.isDirty ?? checkoutStatus.isDirty),
baseRef: checkoutStatus.baseRef,
baseRefExact: checkoutStatus.baseRefExact,
comparisonBaseRef: checkoutStatus.comparisonBaseRef,
aheadBehind: checkoutStatus.aheadBehind,
upstreamRef: checkoutStatus.upstreamRef,
aheadOfOrigin: checkoutStatus.aheadOfOrigin,
@@ -3,7 +3,12 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs"
import { tmpdir } from "os";
import { join } from "path";
import { afterEach, describe, expect, it } from "vitest";
import { getCommitFileDiff, listCheckoutCommits } from "./checkout-git.js";
import {
getCommitFileDiff,
listCheckoutCommits,
getCheckoutStatus,
getCheckoutDiff,
} from "./checkout-git.js";
import { writePaseoWorktreeMetadata } from "./worktree-metadata.js";
const tempDirs: string[] = [];
@@ -393,3 +398,45 @@ describe("listCheckoutCommits", () => {
]);
});
});
it("compares tag-based worktrees in status, history and legacy diff modes", async () => {
const { repoDir: mainRepoDir, tempDir } = initRepoOnMain();
const worktreesRoot = join(tempDir, "worktrees");
const repoDir = join(worktreesRoot, "repo-hash", "tag-feature");
mkdirSync(join(worktreesRoot, "repo-hash"), { recursive: true });
const context = { worktreesRoot };
git(["tag", "v1"], mainRepoDir);
git(["worktree", "add", "-b", "tag-feature", repoDir, "v1"], mainRepoDir);
await writePaseoWorktreeMetadata(repoDir, { baseRefName: "refs/tags/v1" });
commitFile(repoDir, "tag.txt", "committed\n", "tag feature");
writeFileSync(join(repoDir, "tag.txt"), "edited\n");
const status = await getCheckoutStatus(repoDir, context);
expect(status).toMatchObject({
isGit: true,
comparisonBaseRef: "refs/tags/v1",
aheadBehind: { ahead: 1, behind: 0 },
});
const history = await listCheckoutCommits({ cwd: repoDir, context });
expect(history.baseRef).toBe("refs/tags/v1");
expect(history.commits[0].subject).toBe("tag feature");
const committed = await getCheckoutDiff(repoDir, { mode: "base", baseRef: "refs/tags/v1" });
expect(committed.diff).toContain("+committed");
const all = await getCheckoutDiff(repoDir, { mode: "all", baseRef: "refs/tags/v1" });
expect(all.diff).toContain("+edited");
});
it("announces a usable comparison base while checked out on the base branch", async () => {
const cwd = makeTempDir();
git(["init", "-b", "main"], cwd);
git(["config", "user.name", "Test"], cwd);
git(["config", "user.email", "test@example.com"], cwd);
writeFileSync(join(cwd, "a.txt"), "before\n");
git(["add", "."], cwd);
commit(cwd, "base");
writeFileSync(join(cwd, "a.txt"), "after\n");
const status = await getCheckoutStatus(cwd);
expect(status).toMatchObject({ isGit: true, comparisonBaseRef: "refs/heads/main" });
if (!status.isGit || !status.comparisonBaseRef) throw new Error("Missing comparison base");
const all = await getCheckoutDiff(cwd, { mode: "all", baseRef: status.comparisonBaseRef });
expect(all.diff).toContain("+after");
});
+95 -43
View File
@@ -40,7 +40,7 @@ import {
} from "../services/forge-cli-command.js";
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
import { runGitCommand, type RunGitCommand } from "./run-git-command.js";
import { readGitFileContents } from "./git-file-contents.js";
import { readGitFileContents, readGitFileContentAtRef } from "./git-file-contents.js";
import { unquoteGitPath } from "./git-quoted-path.js";
import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js";
import {
@@ -613,22 +613,6 @@ async function listCheckoutFileChanges(
return Array.from(byPath.values());
}
async function readGitFileContentAtRef(
cwd: string,
ref: string,
path: string,
): Promise<string | null> {
try {
const { stdout } = await runGitCommand(["show", `${ref}:${path}`], {
cwd,
envOverlay: READ_ONLY_GIT_ENV,
});
return stdout;
} catch {
return null;
}
}
async function tryResolveMergeBase(cwd: string, baseRef: string): Promise<string | null> {
try {
const { stdout } = await runGitCommand(["merge-base", baseRef, "HEAD"], {
@@ -669,7 +653,12 @@ function normalizeNumstatPath(pathField: string): string {
}
// Parsed patches need a stable wire format regardless of terminal presentation settings.
const GIT_PATCH_FORMAT_ARGS = ["--no-ext-diff", "--no-color", "--src-prefix=a/", "--dst-prefix=b/"];
export const GIT_PATCH_FORMAT_ARGS = [
"--no-ext-diff",
"--no-color",
"--src-prefix=a/",
"--dst-prefix=b/",
] as const;
function buildGitDiffArgs(args: { ignoreWhitespace?: boolean; extra: string[] }): string[] {
return [
@@ -906,6 +895,7 @@ export interface CheckoutStatusGitNonPaseo {
isDirty: boolean;
baseRef: string | null;
baseRefExact?: string | null;
comparisonBaseRef?: string | null;
aheadBehind: AheadBehind | null;
// Remote-tracking ref for currentBranch, e.g. "refs/remotes/origin/main". Null when the
// branch has no upstream or git could not resolve it. aheadOfOrigin/behindOfOrigin are
@@ -926,6 +916,7 @@ export interface CheckoutStatusGitPaseo {
isDirty: boolean;
baseRef: string;
baseRefExact?: string | null;
comparisonBaseRef?: string | null;
aheadBehind: AheadBehind | null;
upstreamRef: string | null;
aheadOfOrigin: number | null;
@@ -1748,6 +1739,24 @@ function isQualifiedBranchRef(ref: string): boolean {
return ref.startsWith("refs/heads/") || ref.startsWith("refs/remotes/");
}
async function resolveCommitComparisonRef(
cwd: string,
name: string,
context?: CheckoutContext,
): Promise<string> {
await getRunGitCommand(context)(["rev-parse", "--verify", `${name}^{commit}`], {
cwd,
envOverlay: READ_ONLY_GIT_ENV,
logger: context?.logger,
});
const result = await getRunGitCommand(context)(["rev-parse", "--symbolic-full-name", name], {
cwd,
envOverlay: READ_ONLY_GIT_ENV,
logger: context?.logger,
});
return result.stdout.trim() || name;
}
async function resolveBestComparisonBaseRef(
cwd: string,
baseRef: string,
@@ -1774,11 +1783,17 @@ async function resolveBestComparisonBaseRef(
return normalized.localName;
}
const refName =
baseRef.startsWith("origin/") || baseRef.startsWith("refs/remotes/origin/")
? normalized.originRef
: normalized.localName;
throw new Error(`Base branch not found locally or on origin: ${refName}`);
return resolveCommitComparisonRef(cwd, baseRef, context);
}
async function resolveQualifiedComparisonBaseRef(
cwd: string,
baseRef: string,
context: CheckoutContext,
): Promise<string> {
const comparison = context.facts?.isGit ? context.facts.comparisonBaseRef : null;
const ref = comparison ?? (await resolveBestComparisonBaseRef(cwd, baseRef, context));
return resolveCommitComparisonRef(cwd, ref, context);
}
async function resolveMostAheadBaseRef(cwd: string, baseRef: string): Promise<string> {
@@ -1801,7 +1816,7 @@ async function resolveMostAheadBaseRef(cwd: string, baseRef: string): Promise<st
return `origin/${normalizedBaseRef}`;
}
if (!hasLocal && !hasOrigin) {
throw new Error(`Base branch not found locally or on origin: ${normalizedBaseRef}`);
return resolveCommitComparisonRef(cwd, baseRef);
}
const { stdout } = await runGitCommand(
@@ -2239,8 +2254,8 @@ export async function getCheckoutSnapshotFacts(
};
}
const PER_FILE_DIFF_MAX_BYTES = 1024 * 1024; // 1MB
const TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB
export const PER_FILE_DIFF_MAX_BYTES = 1024 * 1024; // 1MB
export const TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB
const CHECKOUT_DIFF_FRAME_HEADROOM_BYTES = 1024 * 1024;
// Temporary until diffs load lazily per file. The Paseo relay's 32 MiB frame limit is
// binding: string frames are encrypted and base64-encoded. Reserve 1 MiB plaintext for
@@ -2301,7 +2316,7 @@ async function isLikelyBinaryFile(absolutePath: string): Promise<boolean> {
}
}
async function inspectUntrackedFile(
export async function inspectUntrackedFile(
cwd: string,
relativePath: string,
): Promise<{ stat: FileStat; truncated: boolean }> {
@@ -2399,6 +2414,10 @@ export async function getCheckoutStatus(
const baseRef = facts.resolvedBaseRef;
const mainRepoRoot = facts.mainRepoRoot;
const factsContext = { ...context, facts };
// Ref-derived counts skip the current base branch; the diff consumer still needs its ref.
const comparisonBaseRef = baseRef
? await resolveQualifiedComparisonBaseRef(cwd, baseRef, factsContext).catch(() => null)
: null;
const aheadBehind =
baseRef && currentBranch
? await getAheadBehind(cwd, baseRef, currentBranch, factsContext)
@@ -2421,6 +2440,7 @@ export async function getCheckoutStatus(
isDirty,
baseRef: displayBaseRef ?? baseRef,
baseRefExact: baseRef,
comparisonBaseRef,
aheadBehind,
upstreamRef,
aheadOfOrigin,
@@ -2440,6 +2460,7 @@ export async function getCheckoutStatus(
isDirty,
baseRef: displayBaseRef,
baseRefExact: baseRef,
comparisonBaseRef,
aheadBehind,
upstreamRef,
aheadOfOrigin,
@@ -2804,6 +2825,7 @@ async function readCommitRenames(cwd: string, sha: string): Promise<ReadonlyMap<
* binary-only (no textual hunks). Throws on git failure (e.g. an unknown sha),
* which the caller maps to a typed checkout error.
*/
// COMPAT(diffSubscription): added in v0.15.0, remove after 2027-05-06.
export async function getCommitFileDiff({
cwd,
sha,
@@ -3570,6 +3592,21 @@ async function resolveCheckoutDiffRefs(
if (compare.mode === "uncommitted") {
return { baseRef: "HEAD", includeUntracked: true };
}
const bestBaseRef = await resolveCheckoutDiffBase(cwd, compare, context);
if (!bestBaseRef) return null;
return {
baseRef: (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef,
targetRef: compare.mode === "base" ? "HEAD" : undefined,
includeUntracked: compare.mode === "all",
};
}
export async function resolveCheckoutDiffBase(
cwd: string,
compare: CheckoutDiffCompare,
context?: CheckoutContext,
): Promise<string | null> {
if (compare.mode === "uncommitted") return "HEAD";
const { storedBaseRef, resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
const baseRef = resolveOperationBaseRef({
storedBaseRef,
@@ -3579,17 +3616,12 @@ async function resolveCheckoutDiffRefs(
if (!baseRef) {
return null;
}
const bestBaseRef = await resolveBestComparisonBaseRef(cwd, baseRef);
return {
baseRef: (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef,
targetRef: compare.mode === "base" ? "HEAD" : undefined,
includeUntracked: compare.mode === "all",
};
return resolveBestComparisonBaseRef(cwd, baseRef);
}
// Git's working-tree diff ignores files absent from the index, even when they
// exist in the comparison tree. Restore those entries only in a private index.
async function prepareAllChangesIndex(cwd: string, baseRef: string) {
export async function prepareAllChangesIndex(cwd: string, baseRef: string) {
const untracked = await runGitCommand(["ls-files", "-z", "--others", "--exclude-standard"], {
cwd,
envOverlay: READ_ONLY_GIT_ENV,
@@ -3612,21 +3644,41 @@ async function prepareAllChangesIndex(cwd: string, baseRef: string) {
entries += result.stdout;
}
if (!entries) return null;
const directory = await mkdtemp(resolve(tmpdir(), "paseo-all-changes-"));
const snapshot = await snapshotComparisonIndex(cwd);
try {
await runGitCommand(["update-index", "-z", "--index-info"], {
cwd,
input: entries,
envOverlay: { ...READ_ONLY_GIT_ENV, GIT_INDEX_FILE: snapshot.indexFile },
});
return snapshot;
} catch (error) {
await snapshot.cleanup();
throw error;
}
}
// write-tree rewrites its index. A private snapshot prevents an index read from
// invalidating the observation that requested it.
export async function snapshotComparisonIndex(cwd: string) {
const directory = await mkdtemp(resolve(tmpdir(), "paseo-comparison-index-"));
const indexFile = resolve(directory, "index");
try {
const index = await runGitCommand(["rev-parse", "--git-path", "index"], {
cwd,
envOverlay: READ_ONLY_GIT_ENV,
});
const indexPath = resolveGitRevParsePath(cwd, index.stdout);
if (!indexPath) throw new Error("Git did not return an index path");
await copyFile(indexPath, indexFile);
await runGitCommand(["update-index", "-z", "--index-info"], {
cwd,
input: entries,
envOverlay: { ...READ_ONLY_GIT_ENV, GIT_INDEX_FILE: indexFile },
});
const source = resolveGitRevParsePath(cwd, index.stdout);
if (!source) throw new Error("Git did not return an index path");
try {
await copyFile(source, indexFile);
} catch (error) {
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error;
await runGitCommand(["read-tree", "--empty"], {
cwd,
envOverlay: { ...READ_ONLY_GIT_ENV, GIT_INDEX_FILE: indexFile },
});
}
return { indexFile, cleanup: () => rm(directory, { recursive: true, force: true }) };
} catch (error) {
await rm(directory, { recursive: true, force: true });
+15 -1
View File
@@ -1,4 +1,4 @@
import { runGitCommandBytes } from "./run-git-command.js";
import { runGitCommandBytes, runGitCommand } from "./run-git-command.js";
// Match the existing git-show ceiling. Oversized batches use bounded per-file reads.
const MAX_CONTENT_BYTES = 20 * 1024 * 1024;
@@ -46,3 +46,17 @@ export async function readGitFileContents(
if (offset !== result.stdout.length) throw new Error("Unexpected git object output");
return contents;
}
export async function readGitFileContentAtRef(
cwd: string,
ref: string,
path: string,
): Promise<string | null> {
const result = await runGitCommand(["show", `${ref}:${path}`], {
cwd,
envOverlay: { GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C" },
acceptExitCodes: [0, 128],
});
if (result.exitCode !== 0 || result.truncated) return null;
return result.stdout;
}
+1 -1
View File
@@ -20,7 +20,7 @@ import {
} from "./git-process-scheduler.js";
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_OUTPUT_BYTES = 20 * 1024 * 1024; // 20MB
export const DEFAULT_MAX_OUTPUT_BYTES = 20 * 1024 * 1024; // 20MB
const DEFAULT_STDERR_LIMIT = 2048;
let gitProcessScheduler = new GitProcessScheduler(resolveGitProcessPolicy({ env: process.env }));