Merge remote-tracking branch 'origin/main' into plan-plugin-api-phase7-composers-by-ref

This commit is contained in:
Mohamed Boudra
2026-11-08 13:34:30 +01:00
184 changed files with 11730 additions and 2777 deletions
+2 -1
View File
@@ -102,7 +102,8 @@
"test:e2e:real": "npm run test:integration:real",
"test:e2e:local": "npm run test:integration:local",
"test:e2e:relay:live": "cross-env RUN_LIVE_RELAY_E2E=1 vitest run src/server/daemon-e2e/live-relay.real.e2e.test.ts --maxWorkers=1",
"test:e2e:ui": "vitest --ui e2e.test.ts"
"test:e2e:ui": "vitest --ui e2e.test.ts",
"measure:diff-subscriptions": "tsx scripts/measure-diff-subscriptions.ts"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
@@ -0,0 +1,111 @@
import { performance } from "node:perf_hooks";
import { mkdir, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import {
encodeDiffFrame,
DiffOpcode,
type DiffFrame,
} from "@getpaseo/protocol/binary-frames/index";
import { DiffSubscriptions } from "../src/server/diff/index.js";
import { createFileObserver } from "../src/server/file-observer/index.js";
import { runGitCommand } from "../src/utils/run-git-command.js";
import { parseAndHighlightDiff } from "../src/server/utils/diff-highlighter.js";
import { validateWSOutboundMessage } from "@getpaseo/protocol/validation/ws-outbound";
const cwd = resolve(import.meta.dirname, "../../..");
const artifacts = resolve(cwd, ".artifacts/diff-subscription");
await mkdir(artifacts, { recursive: true });
const pinnedHead = "3ed57e9dd";
const observer = createFileObserver();
const engine = new DiffSubscriptions({
async requestWorkingTreeWatch(root, changed) {
const watch = await observer.subscribe(root, () => changed(), {
ignore: [resolve(root, ".git")],
});
return {
repoRoot: root,
unsubscribe: async () => {
await watch.unsubscribe();
},
};
},
});
try {
for (const count of [1, 30]) {
const [base, head] = await Promise.all([
runGitCommand(["rev-parse", `${pinnedHead}~${count}`], { cwd }),
runGitCommand(["rev-parse", pinnedHead], { cwd }),
]);
const started = performance.now();
const frames: DiffFrame[] = [];
const encoded: Uint8Array[] = [];
const subscription = await engine.subscribe(
{
cwd,
base: { kind: "rev", rev: base.stdout.trim() },
head: { kind: "rev", rev: head.stdout.trim() },
},
async (frame) => {
frames.push(frame);
encoded.push(encodeDiffFrame(frame));
},
);
await subscription.start();
const coldMs = performance.now() - started;
await subscription.release();
const raw = await runGitCommand(["diff", base.stdout.trim(), head.stdout.trim()], {
cwd,
maxOutputBytes: 50 * 1024 * 1024,
});
const contentAt = async (sha: string, path: string) => {
const result = await runGitCommand(["show", `${sha}:${path}`], {
cwd,
acceptExitCodes: [0, 128],
});
return result.exitCode === 0 ? result.stdout : null;
};
const legacyFiles = await parseAndHighlightDiff(raw.stdout, cwd, {
getOldFileContent: (file) => contentAt(base.stdout.trim(), file.path),
getNewFileContent: (file) => contentAt(head.stdout.trim(), file.path),
});
const legacy = JSON.stringify({
type: "session",
message: {
type: "checkout_diff_update",
payload: { cwd, subscriptionId: "benchmark", files: legacyFiles, error: null },
},
});
const parseStart = performance.now();
const validation = validateWSOutboundMessage(JSON.parse(legacy));
if (!validation.success) throw validation.error;
const jsonParseValidationMs = performance.now() - parseStart;
const begin = frames.find((frame) => frame.opcode === DiffOpcode.Begin);
const end = frames.findLast((frame) => frame.opcode === DiffOpcode.End);
if (!begin || begin.opcode !== DiffOpcode.Begin || !end || end.opcode !== DiffOpcode.End)
throw new Error("Revision was not delimited");
if (end.error) throw new Error(end.error.message);
const metrics = {
pinnedHead: head.stdout.trim(),
count,
files: begin.entries.length,
rawPatchBytes: Buffer.byteLength(raw.stdout),
encodedBytes: encoded.reduce((sum, bytes) => sum + bytes.length, 0),
legacyJsonBytes: Buffer.byteLength(legacy),
coldMs,
jsonParseValidationMs,
omitted: end.omitted,
};
console.log(JSON.stringify(metrics));
await writeFile(
resolve(artifacts, `corpus-${count}.json`),
JSON.stringify({
metrics,
legacy,
frames: encoded.map((bytes) => Buffer.from(bytes).toString("base64")),
}),
);
}
} finally {
await engine.close();
await observer.close();
}
@@ -469,7 +469,7 @@ class TestAgentSession implements AgentSession {
async startTurn(): Promise<{ turnId: string }> {
this.interrupted = false;
const turnId = `turn-${++this.turnIdCounter}`;
const turnId = `turn-${this.id}-${++this.turnIdCounter}`;
// Use setTimeout so events arrive after the caller sets up the foreground waiter
setTimeout(() => {
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
@@ -14151,3 +14151,35 @@ test("stops only a currently owned public background item with its current token
expect(stopped).toEqual(["one"]);
await manager.closeAgent(agent.id);
});
test("turns keep distinct identities across an idle runtime collection and resume", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-turn-identity-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new TestAgentClient();
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
const collect = runtimeCleanup(manager);
try {
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
const consume = async (prompt: string, clientMessageId: string) => {
for await (const _event of manager.streamAgent(agent.id, prompt, { clientMessageId })) {
}
};
await consume("first", "first");
const later = new Date(Date.now() + 61 * 60_000);
expect((await collect({ now: later })).collected).toHaveLength(1);
await ensureAgentLoaded(agent.id, { agentManager: manager, agentStorage: storage, logger });
await consume("second", "second");
const rows = manager.fetchTimeline(agent.id, { limit: 0 }).rows;
const userTurnIds = rows
.filter((row) => row.item.type === "user_message")
.map((row) => row.turnId);
expect(userTurnIds).toHaveLength(2);
expect(new Set(userTurnIds).size).toBe(2);
} finally {
await manager.closeAgent(manager.listAgents()[0]?.id ?? "missing").catch(() => undefined);
await storage.flush();
rmSync(workdir, { recursive: true, force: true });
}
});
@@ -2721,12 +2721,30 @@ export class AgentManager {
}
async clearAgentAttention(agentId: string): Promise<void> {
const agent = this.requireAgent(agentId);
if (agent.attention.requiresAttention) {
agent.attention = { requiresAttention: false };
await this.persistSnapshot(agent);
this.emitState(agent, { persist: false });
const agent = this.agents.get(agentId);
if (agent) {
if (agent.attention.requiresAttention) {
agent.attention = { requiresAttention: false };
await this.persistSnapshot(agent);
this.emitState(agent, { persist: false });
}
return;
}
const registry = this.requireRegistry();
const record = await registry.get(agentId);
if (!record || record.internal || record.archivedAt || record.requiresAttention !== true) {
return;
}
const nextRecord: StoredAgentRecord = {
...record,
updatedAt: this.nextStoredUpdatedAt(record),
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
};
await registry.upsert(nextRecord);
this.dispatchStoredAgentState(nextRecord);
}
async markAgentUnread(agentId: string): Promise<void> {
@@ -704,6 +704,7 @@ export interface AgentSession {
* replay them at their original timestamps; restored sessions omit old rows. */
readonly initialTimeline?: ImportedTimelineEntry[];
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
/** Returns an ID unique across the agent lifetime, including resumed sessions and daemon restarts. */
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
@@ -806,6 +806,57 @@ test("emits inventory only when relevant observations change it", () => {
).toEqual([{ id: "m", kind: "shell", description: null, canStop: true }]);
});
test("Claude runtime safety releases a process that has not been sent a turn", () => {
const safety = new ClaudeRuntimeSafety();
// A resumed process that only answered control requests never sends init.
expect(safety.canStopRuntime()).toBe(true);
safety.notePromptSent();
expect(safety.canStopRuntime()).toBe(false);
safety.observe({ type: "system", subtype: "init", claude_code_version: "2.1.292" } as SDKMessage);
expect(safety.canStopRuntime()).toBe(true);
safety.reset();
expect(safety.canStopRuntime()).toBe(true);
// A CLI without a roster that ran a turn may own background work.
safety.observe({ type: "system", subtype: "init", claude_code_version: "2.1.220" } as SDKMessage);
expect(safety.canStopRuntime()).toBe(false);
safety.retire();
expect(safety.canStopRuntime()).toBe(false);
});
test("Claude session started by a control request without a turn can stop its runtime", async () => {
let launched = 0;
const query = {
next: () => new Promise<IteratorResult<SDKMessage>>(() => {}),
return: async () => ({ done: true, value: undefined }),
close: () => {},
interrupt: async () => {},
setPermissionMode: async () => {},
setModel: async () => {},
supportedModels: async () => [],
supportedCommands: async () => [],
[Symbol.asyncIterator]() {
return this;
},
} as unknown as Query;
const session = await new ClaudeAgentClient({
logger: createTestLogger(),
resolveBinary: async () => "/test/claude/bin",
queryFactory: ({ prompt }) => {
if (typeof prompt === "string") throw new Error("Expected streaming input");
launched++;
return query;
},
}).createSession({ provider: "claude", cwd: process.cwd() });
try {
// Reopening a chat lists its commands, which launches Claude with no prompt.
await session.listCommands!();
expect(launched).toBe(1);
expect(await session.canStopRuntime!()).toBe(true);
} finally {
await session.close();
}
});
test("Claude session runtime safety observes pumped background work and autonomous replies", async () => {
let wake: (() => void) | undefined;
const channel = { closed: false };
@@ -1206,7 +1206,7 @@ test("reuses one autonomous run for unbound stream_event bursts with no foregrou
);
const firstRunId = internal.autonomousTurn?.id ?? null;
expect(firstRunId).toBe("autonomous-turn-1");
expect(firstRunId).toEqual(expect.stringMatching(/^autonomous-turn-[0-9a-f-]{36}-1$/));
expect(internal.nextTurnOrdinal).toBe(2);
await internal.routeSdkMessageFromPump(
@@ -1360,7 +1360,9 @@ test("a main-session frame opens no turn until Claude starts one", async () => {
// session start a turn of its own, with no prompt from the user.
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
await internal.routeSdkMessageFromPump(unboundTextDelta("picking this up"), queryMock);
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
expect(internal.autonomousTurn?.id).toEqual(
expect.stringMatching(/^autonomous-turn-[0-9a-f-]{36}-1$/),
);
await internal.routeSdkMessageFromPump(successResult(), queryMock);
expect(internal.autonomousTurn).toBeNull();
@@ -1410,7 +1412,9 @@ test("Claude waking up after a helper finishes gets an autonomous turn that its
expect(internal.autonomousTurn).toBeNull();
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
expect(internal.autonomousTurn?.id).toEqual(
expect.stringMatching(/^autonomous-turn-[0-9a-f-]{36}-1$/),
);
expect(internal.turnState).toBe("autonomous");
await internal.routeSdkMessageFromPump(unboundTextDelta("the helper is done"), queryMock);
@@ -1446,7 +1450,9 @@ test("a steer folded into a turn keeps one turn, and a turn Claude starts after
// A message that arrived while Claude wrote its final answer runs as a turn of its own.
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-2");
expect(internal.autonomousTurn?.id).toEqual(
expect.stringMatching(/^autonomous-turn-[0-9a-f-]{36}-2$/),
);
await internal.routeSdkMessageFromPump(successResult(), queryMock);
expect(internal.autonomousTurn).toBeNull();
expect(internal.turnState).toBe("idle");
@@ -1483,7 +1489,9 @@ test("an idle session state closes an autonomous turn that no result closed", as
session.subscribe((event) => events.push(event));
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
expect(internal.autonomousTurn?.id).toEqual(
expect.stringMatching(/^autonomous-turn-[0-9a-f-]{36}-1$/),
);
// Safety net: Claude says nothing at all is running any more.
await internal.routeSdkMessageFromPump(sessionState("idle"), queryMock);
@@ -1685,7 +1693,9 @@ test("a new turn clears an interrupt flag no result will consume", async () => {
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
expect(internal.pendingInterruptAbort).toBe(false);
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
expect(internal.autonomousTurn?.id).toEqual(
expect.stringMatching(/^autonomous-turn-[0-9a-f-]{36}-1$/),
);
// This turn's own failure is reported, not swallowed as the interrupted turn's leftover.
await internal.routeSdkMessageFromPump(
@@ -1381,6 +1381,30 @@ describe("ClaudeAgentSession features", () => {
await session.close();
});
test("keeps turn identities distinct across resumed session objects", async () => {
const { queryFactory } = createQueryMock();
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const handle = {
provider: "claude" as const,
sessionId: "saved-session",
metadata: { cwd: process.cwd() },
};
const first = await client.resumeSession(handle);
const firstTurn = await first.startTurn("first");
await first.close();
const resumed = await client.resumeSession(handle);
try {
const nextTurn = await resumed.startTurn("second");
expect(nextTurn.turnId).not.toBe(firstTurn.turnId);
} finally {
await resumed.close();
}
});
test("disables Claude hooks for internal agents only", async () => {
const { queryFactory } = createQueryMock();
const client = new ClaudeAgentClient({
@@ -2380,6 +2380,7 @@ class ClaudeAgentSession implements AgentSession {
private historySessionId: string | null = null;
private replayingHistory = false;
private turnState: TurnState = "idle";
private readonly turnNamespace = randomUUID();
private nextTurnOrdinal = 1;
private cancelCurrentTurn: (() => void) | null = null;
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
@@ -2600,6 +2601,7 @@ class ClaudeAgentSession implements AgentSession {
this.activeForegroundQuery = this.query;
this.activeForegroundInput = this.input;
this.startQueryPump();
this.runtimeSafety.notePromptSent();
this.input.push(sdkMessage);
setTimeout(() => {
if (this.activeForegroundTurnId === turnId) {
@@ -4016,7 +4018,7 @@ class ClaudeAgentSession implements AgentSession {
}
private createTurnId(owner: "foreground" | "autonomous"): string {
return `${owner}-turn-${this.nextTurnOrdinal++}`;
return `${owner}-turn-${this.turnNamespace}-${this.nextTurnOrdinal++}`;
}
private isTerminalTurnEvent(event: AgentStreamEvent): boolean {
@@ -6,6 +6,10 @@ import { toBackgroundWorkInputs, type ClaudeBackgroundTask } from "./background-
/** Process-local background ownership. Completion delivery can follow an empty roster. */
export class ClaudeRuntimeSafety {
private inventoryState: "unknown" | "supported" | "retired" = "unknown";
// Background work starts inside a turn, and init only arrives with a turn. A process that
// has neither been sent a prompt nor reported init (a resumed chat that only answered
// control requests) owns no background work.
private turnSeen = false;
private liveTasks: readonly ClaudeBackgroundTask[] = [];
private published: AgentBackgroundWorkInput[] | undefined;
private readonly awaitingNotifications = new Set<string>();
@@ -15,6 +19,7 @@ export class ClaudeRuntimeSafety {
reset(): void {
this.inventoryState = "unknown";
this.turnSeen = false;
this.liveTasks = [];
this.published = undefined;
this.awaitingNotifications.clear();
@@ -28,6 +33,11 @@ export class ClaudeRuntimeSafety {
this.inventoryState = "retired";
}
/** Called before a prompt is written to this process. */
notePromptSent(): void {
this.turnSeen = true;
}
/** Returns a full replacement only when the visible inventory changes. */
observe(message: SDKMessage): AgentBackgroundWorkInput[] | undefined {
if (this.inventoryState === "retired") return undefined;
@@ -93,6 +103,7 @@ export class ClaudeRuntimeSafety {
);
}
if (message.subtype === "init") {
this.turnSeen = true;
const version = parseClaudeCodeVersion(message.claude_code_version ?? "");
if (
version &&
@@ -115,6 +126,7 @@ export class ClaudeRuntimeSafety {
}
canStopRuntime(): boolean {
if (this.inventoryState === "unknown" && !this.turnSeen) return true;
return (
this.inventoryState === "supported" &&
this.liveTasks.length === 0 &&
@@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { createServer } from "node:http";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { once } from "node:events";
import { AgentManager } from "../agent-manager.js";
@@ -217,6 +217,33 @@ function waitForEvent<TEvent extends AgentStreamEvent>(params: {
}
describe("Codex app-server provider (local e2e)", () => {
test.runIf(isCodexInstalled())(
"concurrent import scans initialize successfully in a fresh shared Codex home",
async () => {
const artifacts = path.resolve(".artifacts");
mkdirSync(artifacts, { recursive: true });
const root = mkdtempSync(path.join(artifacts, "codex-concurrent-startup-"));
try {
for (let round = 0; round < 4; round++) {
const codexHome = path.join(root, String(round));
mkdirSync(codexHome);
const provider = new CodexAppServerAgentClient(createTestLogger(), {
env: { CODEX_HOME: codexHome },
});
const results = await Promise.allSettled(
Array.from({ length: 24 }, () => provider.listImportableSessions({ limit: 1 })),
);
expect(results).toEqual(
Array.from({ length: 24 }, () => ({ status: "fulfilled", value: [] })),
);
}
} finally {
rmSync(root, { recursive: true, force: true });
}
},
60_000,
);
test.runIf(isCodexInstalled())(
"reloads a persisted idle thread repeatedly without overlapping writers",
async () => {
@@ -587,6 +587,10 @@ test("concurrent session APIs share one reconnect after an idle provider exit",
createFakeCodexAppServer(),
];
let spawnCount = 0;
let markReconnectStarted!: () => void;
const reconnectStarted = new Promise<void>((resolve) => {
markReconnectStarted = resolve;
});
let releaseReconnect: (() => void) | undefined;
const reconnectGate = new Promise<void>((resolve) => {
releaseReconnect = resolve;
@@ -602,6 +606,7 @@ test("concurrent session APIs share one reconnect after an idle provider exit",
}
spawnCount += 1;
if (spawnCount > 1) {
markReconnectStarted();
await reconnectGate;
}
return appServer.child;
@@ -614,12 +619,14 @@ test("concurrent session APIs share one reconnect after an idle provider exit",
const runtimeInfo = session.getRuntimeInfo();
const turnStart = session.startTurn("continue after reconnect");
await reconnectStarted;
const reconnectSpawnCount = spawnCount - 1;
releaseReconnect?.();
await expect(runtimeInfo).resolves.toMatchObject({ provider: "codex" });
await expect(turnStart).resolves.toMatchObject({ turnId: expect.any(String) });
expect(reconnectSpawnCount).toBe(1);
expect(spawnCount).toBe(2);
} finally {
releaseReconnect?.();
await session.close();
@@ -35,15 +35,14 @@ describe("CodexAppServerAgentClient spawn error handling", () => {
});
test("fetchCatalog settles when the refresh deadline aborts an unanswered initialize", async () => {
const client = new CodexAppServerAgentClient(logger, {
command: {
mode: "replace",
argv: ["/nonexistent/codex-binary-that-does-not-exist"],
},
});
const child = createCodexAppServerChildProcess();
const kill = vi.spyOn(child, "kill");
Object.assign(client, { spawnAppServer: async () => child });
const client = new CodexAppServerAgentClient(logger, undefined, {
appServerProcess: {
resolvePrefix: async () => ({ command: "codex", args: [] }),
spawn: () => child,
},
});
await expect(
runProviderRefreshWithDeadline({
@@ -87,6 +87,7 @@ describe("Codex executable discovery", () => {
import { CodexAppServerClient } from "./codex/app-server-transport.js";
import {
createCodexAppServerChildProcess,
createFakeCodexAppServer,
fakeCodexThreadHistory,
type FakeCodexAppServer,
@@ -185,18 +186,24 @@ function createProviderWithFakeAppServer(
): CodexAppServerAgentClient {
const provider = new CodexAppServerAgentClient(createTestLogger(), options?.runtimeSettings, {
customProvider: options?.customProvider,
appServerProcess: createFakeCodexAppServerProcess(() => appServer.child),
});
const internals = castInternals<{
goalsEnabledPromise: Promise<boolean> | null;
autoReviewEnabledPromise: Promise<boolean> | null;
spawnAppServer: () => Promise<ChildProcessWithoutNullStreams>;
}>(provider);
internals.goalsEnabledPromise = Promise.resolve(false);
internals.autoReviewEnabledPromise = Promise.resolve(false);
internals.spawnAppServer = async () => appServer.child;
return provider;
}
function createFakeCodexAppServerProcess(spawn: () => ChildProcessWithoutNullStreams) {
return {
resolvePrefix: async () => ({ command: "codex", args: [] }),
spawn: () => spawn(),
};
}
async function startPublicSteeringSession(
appServer: FakeCodexAppServer,
resolveSlashCommandInvocation?: (prompt: AgentPromptInput) => Promise<{
@@ -626,24 +633,25 @@ function codexAgentItem(text: string) {
// app-server serving `threads` from thread/read.
function createReopenableCodexAgent(threads: Record<string, unknown[]>) {
const appServers: FakeCodexAppServer[] = [];
const provider = new CodexAppServerAgentClient(createTestLogger());
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
appServerProcess: createFakeCodexAppServerProcess(() => {
const appServer = createFakeCodexAppServer({
"thread/read": (params) => {
const { threadId } = params as { threadId: string };
return { thread: { id: threadId, turns: threads[threadId] ?? [] } };
},
});
appServers.push(appServer);
return appServer.child;
}),
});
const internals = castInternals<{
goalsEnabledPromise: Promise<boolean> | null;
autoReviewEnabledPromise: Promise<boolean> | null;
spawnAppServer: () => Promise<ChildProcessWithoutNullStreams>;
}>(provider);
internals.goalsEnabledPromise = Promise.resolve(false);
internals.autoReviewEnabledPromise = Promise.resolve(false);
internals.spawnAppServer = async () => {
const appServer = createFakeCodexAppServer({
"thread/read": (params) => {
const { threadId } = params as { threadId: string };
return { thread: { id: threadId, turns: threads[threadId] ?? [] } };
},
});
appServers.push(appServer);
return appServer.child;
};
// The fake app-server stands in for the Codex CLI, which test machines may not have.
const client: AgentClient = {
provider: "codex",
@@ -1351,7 +1359,7 @@ describe("Codex app-server provider", () => {
const firstSession = await createProviderWithFakeAppServer(beforeRestart).createSession(
createConfig({ thinkingOptionId: undefined }),
);
await firstSession.startTurn("first turn");
const firstTurn = await firstSession.startTurn("first turn");
await expect(beforeRestart.waitForTurnStart()).resolves.toMatchObject({ effort: "medium" });
const saved = waitForNextEvent(firstSession, "thread_started");
beforeRestart.startsTurn({ threadId: "thread-1" });
@@ -1370,7 +1378,8 @@ describe("Codex app-server provider", () => {
await expect(resumed.getRuntimeInfo()).resolves.toMatchObject({
thinkingOptionId: "medium",
});
await resumed.startTurn("turn after restart");
const resumedTurn = await resumed.startTurn("turn after restart");
expect(resumedTurn.turnId).not.toBe(firstTurn.turnId);
await expect(afterRestart.waitForTurnStart()).resolves.toMatchObject({ effort: "medium" });
beforeRestart.assertNoErrors();
afterRestart.assertNoErrors();
@@ -2373,10 +2382,9 @@ describe("Codex app-server provider", () => {
return { thread: { id: "native-thread-id" } };
},
});
const provider = new CodexAppServerAgentClient(createTestLogger());
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => appServer.child;
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
appServerProcess: createFakeCodexAppServerProcess(() => appServer.child),
});
await provider.unarchiveNativeSession({
provider: "codex",
@@ -2398,10 +2406,9 @@ describe("Codex app-server provider", () => {
return { thread: { id: "native-thread-id" } };
},
});
const provider = new CodexAppServerAgentClient(createTestLogger());
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => appServer.child;
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
appServerProcess: createFakeCodexAppServerProcess(() => appServer.child),
});
await provider.archiveNativeSession({
provider: "codex",
@@ -2423,10 +2430,9 @@ describe("Codex app-server provider", () => {
return { thread: { id: "persisted-thread-id" } };
},
});
const provider = new CodexAppServerAgentClient(createTestLogger());
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => appServer.child;
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
appServerProcess: createFakeCodexAppServerProcess(() => appServer.child),
});
await provider.unarchiveNativeSession({
provider: "codex",
@@ -2455,10 +2461,9 @@ describe("Codex app-server provider", () => {
return { thread: { id: "active-thread-id", turns: [] } };
},
});
const provider = new CodexAppServerAgentClient(createTestLogger());
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => appServer.child;
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
appServerProcess: createFakeCodexAppServerProcess(() => appServer.child),
});
await provider.unarchiveNativeSession({
provider: "codex",
@@ -2488,10 +2493,9 @@ describe("Codex app-server provider", () => {
return Promise.reject(new Error("thread not found"));
},
});
const provider = new CodexAppServerAgentClient(createTestLogger());
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => appServer.child;
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
appServerProcess: createFakeCodexAppServerProcess(() => appServer.child),
});
await expect(
provider.unarchiveNativeSession({
@@ -4850,6 +4854,7 @@ describe("Codex app-server provider", () => {
);
try {
const started = waitForNextEvent(session, "turn_started");
const resultPromise = session.run("Delegate the investigation.");
await appServer.waitForTurnStart();
const child = waitForProviderSubagent(session, "legacy-only-child-thread");
@@ -4867,7 +4872,7 @@ describe("Codex app-server provider", () => {
await expect(child).resolves.toMatchObject({
type: "provider_subagent",
provider: "codex",
turnId: "codex-turn-0",
turnId: (await started).turnId,
event: {
type: "upsert",
id: "legacy-only-child-thread",
@@ -4877,7 +4882,7 @@ describe("Codex app-server provider", () => {
await expect(spawn).resolves.toMatchObject({
type: "timeline",
provider: "codex",
turnId: "codex-turn-0",
turnId: (await started).turnId,
item: {
type: "tool_call",
callId: "spawn-legacy-only-child",
@@ -7532,6 +7537,52 @@ describe("Codex app-server provider", () => {
});
describe("Codex importable sessions", () => {
test("serializes concurrent app-server startups while allowing all scans to complete", async () => {
const firstEntered = deferred<void>();
const releaseFirst = deferred<void>();
let spawned = 0;
let activeInitializations = 0;
let maxActiveInitializations = 0;
let disposed = 0;
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
appServerProcess: createFakeCodexAppServerProcess(() => {
spawned++;
return createCodexAppServerChildProcess();
}),
_createCodexClient: () => ({
request: async (method) => {
if (method === "initialize") {
activeInitializations++;
maxActiveInitializations = Math.max(maxActiveInitializations, activeInitializations);
if (spawned === 1) {
firstEntered.resolve();
await releaseFirst.promise;
}
activeInitializations--;
return {};
}
if (method === "thread/list") return { data: [] };
throw new Error(`Unexpected Codex method: ${method}`);
},
notify: () => {},
dispose: async () => {
disposed++;
},
}),
});
const scans = Array.from({ length: 24 }, () => provider.listImportableSessions({ limit: 1 }));
await firstEntered.promise;
expect(spawned).toBe(1);
releaseFirst.resolve();
const results = await Promise.all(scans);
expect(results).toEqual(Array.from({ length: 24 }, () => []));
expect(spawned).toBe(24);
expect(maxActiveInitializations).toBe(1);
expect(disposed).toBe(24);
});
const CODEX_THREAD_PAGE_CAP = 100;
// Codex answers thread/list with at most 100 rows per response whatever limit
@@ -7794,19 +7845,8 @@ describe("Codex importable sessions", () => {
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
_createCodexClient: () => fakeClient,
appServerProcess: createFakeCodexAppServerProcess(createCodexAppServerChildProcess),
});
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => {
const child = new EventEmitter() as ChildProcessWithoutNullStreams;
child.exitCode = 0;
child.signalCode = null;
child.stdin = new PassThrough();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams["kill"];
return child;
};
const sessions = await provider.listImportableSessions({ cwd: "/workspace/project-a" });
@@ -77,7 +77,7 @@ import {
probeExecutable,
} from "../../../executable-resolution/executable-resolution.js";
import { createPathEquivalenceMatcher } from "../../../utils/path.js";
import { spawnProcess } from "../../../utils/spawn.js";
import { spawnProcess, type SpawnProcessOptions } from "../../../utils/spawn.js";
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
import {
buildCodexControls,
@@ -96,6 +96,7 @@ import {
type CodexThreadRollbackResponse,
type CodexAppServerTraceContext,
} from "./codex/app-server-transport.js";
import { codexAppServerStartup } from "./codex/app-server-startup.js";
import { type CodexUserMessageTurnIndex, revertCodexConversation } from "./codex/rewind.js";
import {
type CodexHistoryRow,
@@ -293,14 +294,31 @@ const CODEX_MODES: AgentMode[] = [
const DEFAULT_CODEX_MODE_ID = "auto";
interface CodexAppServerClientLike {
request(method: string, params?: unknown): Promise<unknown>;
request(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;
forkThread?(params: CodexThreadForkParams): Promise<CodexThreadForkResponse>;
rollbackThread?(params: CodexThreadRollbackParams): Promise<CodexThreadRollbackResponse>;
notify(method: string, params?: unknown): void;
dispose(): Promise<void>;
}
interface CodexAppServerLaunchPrefix {
command: string;
args: string[];
}
interface CodexAppServerProcessSpawnInput extends CodexAppServerLaunchPrefix {
options: SpawnProcessOptions;
}
interface CodexAppServerProcessPort {
resolvePrefix(options: {
runtimeSettings?: ProviderRuntimeSettings;
}): Promise<CodexAppServerLaunchPrefix>;
spawn(input: CodexAppServerProcessSpawnInput): ChildProcess;
}
interface CodexAppServerAgentDeps {
appServerProcess?: CodexAppServerProcessPort;
environment?: Record<string, string>;
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
customProvider?: {
@@ -612,10 +630,9 @@ export async function findDefaultCodexBinary(): Promise<string | null> {
return await findCodexMicrosoftStoreBinary();
}
async function resolveCodexLaunchPrefix(runtimeSettings?: ProviderRuntimeSettings): Promise<{
command: string;
args: string[];
}> {
async function resolveCodexLaunchPrefix(
runtimeSettings?: ProviderRuntimeSettings,
): Promise<CodexAppServerLaunchPrefix> {
const launch = await resolveCodexLaunch(runtimeSettings);
const availability = await checkCodexLaunchAvailable(launch);
if (!availability.available) {
@@ -630,6 +647,11 @@ async function resolveCodexLaunchPrefix(runtimeSettings?: ProviderRuntimeSetting
};
}
const defaultCodexAppServerProcess: CodexAppServerProcessPort = {
resolvePrefix: ({ runtimeSettings }) => resolveCodexLaunchPrefix(runtimeSettings),
spawn: ({ command, args, options }) => spawnProcess(command, args, options),
};
async function resolveCodexLaunch(
runtimeSettings?: ProviderRuntimeSettings,
): Promise<ResolvedProviderLaunch> {
@@ -3371,6 +3393,14 @@ export function buildCodexAppServerEnv(
});
}
function codexStartupStateDirKey(environment: NodeJS.ProcessEnv): string {
const home =
process.platform === "win32"
? environment.USERPROFILE || os.homedir()
: environment.HOME || os.homedir();
return path.resolve(environment.CODEX_HOME || path.join(home, ".codex"));
}
function buildCodexAppServerInitializeParams(): {
clientInfo: { name: string; title: string; version: string };
capabilities: { experimentalApi: true; mcpServerOpenaiFormElicitation: true };
@@ -3503,6 +3533,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private startupEvents: AgentStreamEvent[] | null = null;
/** How the opened session differs from the options it was given; reported at first subscribe. */
openOptionsPatch: Record<string, unknown> = {};
private readonly turnNamespace = randomUUID();
private nextTurnOrdinal = 0;
private activeForegroundTurnId: string | null = null;
private activeClientMessageId: string | null = null;
@@ -3571,6 +3602,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private unpairedCompactionItemCompletions = 0;
private connectionState: "disconnected" | "history-ready" | "connected" = "disconnected";
private connectionPromise: Promise<void> | null = null;
private readonly startupAbort = new AbortController();
private closed = false;
private collaborationModes: Array<{
name: string;
@@ -3684,8 +3716,15 @@ export class CodexAppServerAgentSession implements AgentSession {
this.connectionState = "history-ready";
return;
}
const child = await this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger, () => this.traceContext());
const { client, initialized } = await codexAppServerStartup.start({
stateDirKey: codexStartupStateDirKey(this.harnessEnvironment),
spawn: this.spawnAppServer,
createClient: (child) =>
new CodexAppServerClient(child, this.logger, () => this.traceContext()),
initializeParams: buildCodexAppServerInitializeParams(),
logger: this.logger,
signal: this.startupAbort.signal,
});
if (this.closed) {
await client.dispose();
throw this.createClosedError();
@@ -3698,11 +3737,9 @@ export class CodexAppServerAgentSession implements AgentSession {
this.registerRequestHandlers();
try {
const initialized = toObjectRecord(
await client.request("initialize", buildCodexAppServerInitializeParams()),
this.threadRollbackAvailable = codexServerHasThreadRollback(
toObjectRecord(initialized)?.userAgent,
);
this.threadRollbackAvailable = codexServerHasThreadRollback(initialized?.userAgent);
client.notify("initialized", {});
this.speedModels =
CodexModelListResponseSchema.parse(await client.request("model/list", {})).data ?? [];
@@ -4046,11 +4083,16 @@ export class CodexAppServerAgentSession implements AgentSession {
}
private async readArchivedHistory(): Promise<CodexThreadHistory> {
const child = await this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger, () => this.traceContext());
const { client } = await codexAppServerStartup.start({
stateDirKey: codexStartupStateDirKey(this.harnessEnvironment),
spawn: this.spawnAppServer,
createClient: (child) =>
new CodexAppServerClient(child, this.logger, () => this.traceContext()),
initializeParams: buildCodexAppServerInitializeParams(),
logger: this.logger,
signal: this.startupAbort.signal,
});
try {
await client.request("initialize", buildCodexAppServerInitializeParams());
client.notify("initialized", {});
return await this.readThreadHistory(client);
} finally {
await client.dispose();
@@ -5349,6 +5391,7 @@ export class CodexAppServerAgentSession implements AgentSession {
async close(): Promise<void> {
this.closed = true;
this.startupAbort.abort(this.createClosedError());
this.background.stop();
this.clearPendingPermissions();
this.pendingToolCallDetails.clear();
@@ -5730,7 +5773,7 @@ export class CodexAppServerAgentSession implements AgentSession {
}
private createTurnId(): string {
return `codex-turn-${this.nextTurnOrdinal++}`;
return `codex-turn-${this.turnNamespace}-${this.nextTurnOrdinal++}`;
}
private pendingNotifications: Promise<void> = Promise.resolve();
@@ -7587,31 +7630,55 @@ export class CodexAppServerAgentClient implements AgentClient {
}
}
private async spawnAppServer(
launchEnv?: Record<string, string>,
options?: { goalsEnabled?: boolean; agentId?: string; environment?: Record<string, string> },
): Promise<ChildProcessWithoutNullStreams> {
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
private async prepareAppServerSpawn(options: {
environment: Record<string, string>;
goalsEnabled?: boolean;
agentId?: string;
}): Promise<() => ChildProcessWithoutNullStreams> {
const processPort = this.deps.appServerProcess ?? defaultCodexAppServerProcess;
const launchPrefix = await processPort.resolvePrefix({ runtimeSettings: this.runtimeSettings });
const args = [...launchPrefix.args, "app-server"];
if (options?.goalsEnabled) {
if (options.goalsEnabled) {
args.push("--enable", "goals");
}
this.logger.trace(
{
agentId: options?.agentId,
provider: CODEX_PROVIDER,
launchPrefix,
goalsEnabled: options?.goalsEnabled === true,
},
"provider.codex.spawn",
);
const child = spawnProcess(launchPrefix.command, args, {
detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"],
baseEnv: options?.environment ?? buildCodexAppServerEnv(this.runtimeSettings, launchEnv),
return () => {
this.logger.trace(
{
agentId: options.agentId,
provider: CODEX_PROVIDER,
launchPrefix,
goalsEnabled: options.goalsEnabled === true,
},
"provider.codex.spawn",
);
const child = processPort.spawn({
command: launchPrefix.command,
args,
options: {
detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"],
baseEnv: options.environment,
},
});
assertChildWithPipes(child);
return child;
};
}
private async startAppServer(
options: { signal?: AbortSignal } = {},
): Promise<CodexAppServerClient> {
const environment = buildCodexAppServerEnv(this.runtimeSettings);
const spawn = await this.prepareAppServerSpawn({ environment });
const { client } = await codexAppServerStartup.start({
stateDirKey: codexStartupStateDirKey(environment),
spawn,
createClient: (child) => new CodexAppServerClient(child, this.logger),
initializeParams: buildCodexAppServerInitializeParams(),
logger: this.logger,
signal: options.signal,
});
assertChildWithPipes(child);
return child;
return client;
}
async createSession(
@@ -7630,16 +7697,16 @@ export class CodexAppServerAgentClient implements AgentClient {
const goalsEnabled = await this.resolveGoalsEnabled();
const autoReviewEnabled = await this.resolveAutoReviewEnabled();
const environment = buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env);
const spawn = await this.prepareAppServerSpawn({
environment,
goalsEnabled,
agentId: launchContext?.agentId,
});
const session = new CodexAppServerAgentSession(
sessionConfig,
null,
this.logger,
() =>
this.spawnAppServer(launchContext?.env, {
goalsEnabled,
agentId: launchContext?.agentId,
environment,
}),
async () => spawn(),
{ ...this.sessionDeps(launchContext?.env), environment },
options?.persistSession === false,
goalsEnabled,
@@ -7667,16 +7734,16 @@ export class CodexAppServerAgentClient implements AgentClient {
const goalsEnabled = await this.resolveGoalsEnabled();
const autoReviewEnabled = await this.resolveAutoReviewEnabled();
const environment = buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env);
const spawn = await this.prepareAppServerSpawn({
environment,
goalsEnabled,
agentId: launchContext?.agentId,
});
const session = new CodexAppServerAgentSession(
merged,
handle,
this.logger,
() =>
this.spawnAppServer(launchContext?.env, {
goalsEnabled,
agentId: launchContext?.agentId,
environment,
}),
async () => spawn(),
{ ...this.sessionDeps(launchContext?.env), environment },
false,
goalsEnabled,
@@ -7691,15 +7758,19 @@ export class CodexAppServerAgentClient implements AgentClient {
async listImportableSessions(
options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]> {
const child = await this.spawnAppServer();
const client =
this.deps._createCodexClient?.(child, this.logger, () => ({})) ??
new CodexAppServerClient(child, this.logger);
const environment = buildCodexAppServerEnv(this.runtimeSettings);
const spawn = await this.prepareAppServerSpawn({ environment });
const { client } = await codexAppServerStartup.start({
stateDirKey: codexStartupStateDirKey(environment),
spawn,
createClient: (child) =>
this.deps._createCodexClient?.(child, this.logger, () => ({})) ??
new CodexAppServerClient(child, this.logger),
initializeParams: buildCodexAppServerInitializeParams(),
logger: this.logger,
});
try {
await client.request("initialize", buildCodexAppServerInitializeParams());
client.notify("initialized", {});
const limit = options?.limit ?? 20;
const scanLimit = Math.min(options?.scanLimit ?? limit, 500);
// thread/list returns the cheap `cwd` field. Fetch a wider window when
@@ -7802,15 +7873,10 @@ export class CodexAppServerAgentClient implements AgentClient {
try {
await runProviderRefreshActivity(context, "app-server.start", async () => {
const child = await this.spawnAppServer();
client = new CodexAppServerClient(child, this.logger);
client = await this.startAppServer({ signal: context?.signal });
if (context?.signal.aborted) await dispose();
});
if (!client) throw new Error("Codex app-server did not start");
await runProviderRefreshActivity(context, "initialize", () =>
client!.request("initialize", buildCodexAppServerInitializeParams()),
);
client.notify("initialized", {});
const rawResponse = await runProviderRefreshActivity(context, "model/list", () =>
client!.request("model/list", {}),
@@ -7854,12 +7920,9 @@ export class CodexAppServerAgentClient implements AgentClient {
const threadId = handle.nativeHandle ?? handle.sessionId;
if (!threadId) return;
const child = await this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger);
const client = await this.startAppServer();
try {
await client.request("initialize", buildCodexAppServerInitializeParams());
client.notify("initialized", {});
if (state === "archive") {
await client.request("thread/archive", { threadId });
return;
@@ -0,0 +1,347 @@
import { describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { createCodexAppServerChildProcess } from "./test-utils/fake-app-server.js";
import { CodexAppServerStartupCoordinator } from "./app-server-startup.js";
import { CodexAppServerExitError } from "./app-server-transport.js";
function sqliteStartupError(): CodexAppServerExitError {
return new CodexAppServerExitError({
exitCode: 1,
exitSignal: null,
stderr: "failed to initialize sqlite state runtime",
});
}
function deferred<T = void>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
describe("Codex app-server startup coordinator", () => {
test("returns initialize metadata so sessions retain server capabilities", async () => {
const coordinator = new CodexAppServerStartupCoordinator();
const client = {
request: async () => ({ userAgent: "codex/0.164.0" }),
notify: () => {},
dispose: async () => {},
};
const started = await coordinator.start({
stateDirKey: "/codex/metadata",
spawn: createCodexAppServerChildProcess,
createClient: () => client,
initializeParams: {},
logger: createTestLogger(),
});
expect(started).toEqual({ client, initialized: { userAgent: "codex/0.164.0" } });
});
test("serializes a burst through initialize but retains all initialized clients", async () => {
const coordinator = new CodexAppServerStartupCoordinator();
const firstEntered = deferred();
const releaseFirst = deferred();
let spawned = 0;
let activeInitializations = 0;
let maxActiveInitializations = 0;
let disposed = 0;
const starts = Array.from({ length: 24 }, () =>
coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
spawned++;
return createCodexAppServerChildProcess();
},
createClient: () => ({
request: async (method: string) => {
expect(method).toBe("initialize");
activeInitializations++;
maxActiveInitializations = Math.max(maxActiveInitializations, activeInitializations);
if (spawned === 1) {
firstEntered.resolve();
await releaseFirst.promise;
}
activeInitializations--;
return {};
},
notify: vi.fn(),
dispose: async () => {
disposed++;
},
}),
initializeParams: {},
logger: createTestLogger(),
}),
);
await firstEntered.promise;
expect(spawned).toBe(1);
releaseFirst.resolve();
const clients = await Promise.all(starts);
expect(clients).toHaveLength(24);
expect(spawned).toBe(24);
expect(maxActiveInitializations).toBe(1);
expect(disposed).toBe(0);
});
test("allows different Codex homes to initialize concurrently", async () => {
const coordinator = new CodexAppServerStartupCoordinator();
const bothEntered = deferred();
const release = deferred();
let entered = 0;
const start = (stateDirKey: string) =>
coordinator.start({
stateDirKey,
spawn: createCodexAppServerChildProcess,
createClient: () => ({
request: async () => {
entered++;
if (entered === 2) bothEntered.resolve();
await release.promise;
return {};
},
notify: vi.fn(),
dispose: vi.fn(async () => {}),
}),
initializeParams: {},
logger: createTestLogger(),
});
const first = start("/codex/one");
const second = start("/codex/two");
await bothEntered.promise;
expect(entered).toBe(2);
release.resolve();
await Promise.all([first, second]);
});
test("retries only SQLite startup failures after disposing the failed child", async () => {
const coordinator = new CodexAppServerStartupCoordinator();
const events: string[] = [];
let attempt = 0;
const client = await coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
attempt++;
events.push(`spawn ${attempt}`);
return createCodexAppServerChildProcess();
},
createClient: () => {
const number = attempt;
return {
request: async () => {
events.push(`initialize ${number}`);
if (number === 1) throw sqliteStartupError();
return {};
},
notify: () => events.push(`initialized ${number}`),
dispose: async () => {
events.push(`dispose ${number}`);
},
};
},
initializeParams: {},
logger: createTestLogger(),
});
expect(client.client).toEqual(expect.objectContaining({ request: expect.any(Function) }));
expect(events).toEqual([
"spawn 1",
"initialize 1",
"dispose 1",
"spawn 2",
"initialize 2",
"initialized 2",
]);
});
test("does not retry unrelated startup failures", async () => {
const coordinator = new CodexAppServerStartupCoordinator();
let spawned = 0;
let disposed = 0;
await expect(
coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
spawned++;
return createCodexAppServerChildProcess();
},
createClient: () => ({
request: async () => {
throw new Error("database is locked while reading unrelated configuration");
},
notify: vi.fn(),
dispose: async () => {
disposed++;
},
}),
initializeParams: {},
logger: createTestLogger(),
}),
).rejects.toThrow("database is locked while reading unrelated configuration");
expect(spawned).toBe(1);
expect(disposed).toBe(1);
});
test("bounds persistent SQLite failures and leaves the lane usable", async () => {
const coordinator = new CodexAppServerStartupCoordinator();
let spawned = 0;
let disposed = 0;
let locked = true;
const start = () =>
coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
spawned++;
return createCodexAppServerChildProcess();
},
createClient: () => ({
request: async () => {
if (locked) throw sqliteStartupError();
return {};
},
notify: vi.fn(),
dispose: async () => {
disposed++;
},
}),
initializeParams: {},
logger: createTestLogger(),
});
await expect(start()).rejects.toThrow("failed to initialize sqlite state runtime");
expect(spawned).toBe(3);
expect(disposed).toBe(3);
locked = false;
await expect(start()).resolves.toEqual(
expect.objectContaining({
client: expect.objectContaining({ request: expect.any(Function) }),
}),
);
expect(spawned).toBe(4);
expect(disposed).toBe(3);
});
test("a cancelled waiter never spawns or overtakes the active startup", async () => {
const coordinator = new CodexAppServerStartupCoordinator();
const firstEntered = deferred();
const releaseFirst = deferred();
const events: string[] = [];
const first = coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
events.push("spawn first");
return createCodexAppServerChildProcess();
},
createClient: () => ({
request: async () => {
firstEntered.resolve();
await releaseFirst.promise;
return {};
},
notify: vi.fn(),
dispose: vi.fn(async () => {}),
}),
initializeParams: {},
logger: createTestLogger(),
});
await firstEntered.promise;
const controller = new AbortController();
const queued = coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
events.push("spawn cancelled");
return createCodexAppServerChildProcess();
},
createClient: () => ({
request: async () => ({}),
notify: vi.fn(),
dispose: vi.fn(async () => {}),
}),
initializeParams: {},
logger: createTestLogger(),
signal: controller.signal,
});
controller.abort(new Error("cancelled"));
await expect(queued).rejects.toThrow("cancelled");
const next = coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
events.push("spawn next");
return createCodexAppServerChildProcess();
},
createClient: () => ({
request: async () => ({}),
notify: vi.fn(),
dispose: vi.fn(async () => {}),
}),
initializeParams: {},
logger: createTestLogger(),
});
expect(events).toEqual(["spawn first"]);
releaseFirst.resolve();
await Promise.all([first, next]);
expect(events).toEqual(["spawn first", "spawn next"]);
});
test("a timed-out initialize disposes its child and releases the next waiter", async () => {
vi.useFakeTimers();
try {
const coordinator = new CodexAppServerStartupCoordinator({
attemptTimeoutMs: 10,
queueTimeoutMs: 100,
});
const firstEntered = deferred();
const events: string[] = [];
const first = coordinator.start({
stateDirKey: "/codex/shared",
spawn: createCodexAppServerChildProcess,
createClient: () => ({
request: () => {
firstEntered.resolve();
return new Promise<never>(() => {});
},
notify: vi.fn(),
dispose: async () => {
events.push("dispose first");
},
}),
initializeParams: {},
logger: createTestLogger(),
});
await firstEntered.promise;
const next = coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
events.push("spawn next");
return createCodexAppServerChildProcess();
},
createClient: () => ({
request: async () => ({}),
notify: vi.fn(),
dispose: vi.fn(async () => {}),
}),
initializeParams: {},
logger: createTestLogger(),
});
const firstResult = expect(first).rejects.toThrow("Timed out initializing Codex app-server");
await vi.advanceTimersByTimeAsync(10);
await firstResult;
await expect(next).resolves.toEqual(
expect.objectContaining({
client: expect.objectContaining({ request: expect.any(Function) }),
}),
);
expect(events).toEqual(["dispose first", "spawn next"]);
} finally {
vi.useRealTimers();
}
});
});
@@ -0,0 +1,189 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import type { Logger } from "pino";
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
import { CodexAppServerExitError } from "./app-server-transport.js";
const DEFAULT_QUEUE_TIMEOUT_MS = 60_000;
const DEFAULT_ATTEMPT_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_ATTEMPTS = 3;
interface InitializableAppServerClient {
request(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;
notify(method: string, params?: unknown): void;
dispose(): Promise<void>;
}
interface StartupOptions<TClient extends InitializableAppServerClient> {
stateDirKey: string;
spawn: () => ChildProcessWithoutNullStreams | Promise<ChildProcessWithoutNullStreams>;
createClient: (child: ChildProcessWithoutNullStreams) => TClient;
initializeParams: unknown;
logger: Pick<Logger, "warn" | "debug">;
signal?: AbortSignal;
}
interface StartedAppServer<TClient> {
client: TClient;
initialized: unknown;
}
interface CoordinatorOptions {
queueTimeoutMs?: number;
attemptTimeoutMs?: number;
maxAttempts?: number;
}
async function waitForSignal<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
signal.throwIfAborted();
let onAbort: (() => void) | undefined;
const aborted = new Promise<never>((_resolve, reject) => {
onAbort = () => reject(signal.reason);
signal.addEventListener("abort", onAbort, { once: true });
});
try {
return await Promise.race([operation, aborted]);
} finally {
if (onAbort) signal.removeEventListener("abort", onAbort);
}
}
async function disposeUnownedChild(child: ChildProcessWithoutNullStreams): Promise<void> {
const result = await terminateWithTreeKill(child, {
gracefulTimeoutMs: 2_000,
forceTimeoutMs: 1_000,
});
if (result === "kill-timeout") {
throw new Error("Codex app-server did not report exit after SIGKILL");
}
}
export class CodexAppServerStartupCoordinator {
private readonly tails = new Map<string, Promise<void>>();
private readonly queueTimeoutMs: number;
private readonly attemptTimeoutMs: number;
private readonly maxAttempts: number;
constructor(options: CoordinatorOptions = {}) {
this.queueTimeoutMs = options.queueTimeoutMs ?? DEFAULT_QUEUE_TIMEOUT_MS;
this.attemptTimeoutMs = options.attemptTimeoutMs ?? DEFAULT_ATTEMPT_TIMEOUT_MS;
this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
}
async start<TClient extends InitializableAppServerClient>(
options: StartupOptions<TClient>,
): Promise<StartedAppServer<TClient>> {
options.signal?.throwIfAborted();
const previous = this.tails.get(options.stateDirKey) ?? Promise.resolve();
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
// An aborted waiter must not let later waiters pass the current owner.
const tail = previous.then(() => gate);
this.tails.set(options.stateDirKey, tail);
void tail.then(
() => this.tails.get(options.stateDirKey) === tail && this.tails.delete(options.stateDirKey),
);
const queueAbort = new AbortController();
const onAbort = () => queueAbort.abort(options.signal?.reason);
options.signal?.addEventListener("abort", onAbort, { once: true });
const queueTimer = setTimeout(
() => queueAbort.abort(new Error("Timed out waiting to start Codex app-server")),
this.queueTimeoutMs,
);
try {
await waitForSignal(previous, queueAbort.signal);
clearTimeout(queueTimer);
options.signal?.throwIfAborted();
for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
try {
return await this.startAttempt(options);
} catch (error) {
const isRetryableStartupFailure =
error instanceof CodexAppServerExitError &&
error.reason === "sqlite_state_initialization";
if (
options.signal?.aborted ||
attempt === this.maxAttempts ||
!isRetryableStartupFailure
) {
throw error;
}
options.logger.warn(
{ err: error, attempt, maxAttempts: this.maxAttempts },
"Codex app-server SQLite startup failed; retrying with a fresh process",
);
}
}
throw new Error("Codex app-server startup attempts exhausted");
} finally {
clearTimeout(queueTimer);
options.signal?.removeEventListener("abort", onAbort);
release();
}
}
private async startAttempt<TClient extends InitializableAppServerClient>(
options: StartupOptions<TClient>,
): Promise<StartedAppServer<TClient>> {
const attemptAbort = new AbortController();
const onAbort = () => attemptAbort.abort(options.signal?.reason);
options.signal?.addEventListener("abort", onAbort, { once: true });
const attemptTimer = setTimeout(
() => attemptAbort.abort(new Error("Timed out initializing Codex app-server")),
this.attemptTimeoutMs,
);
let child: ChildProcessWithoutNullStreams | undefined;
let client: TClient | undefined;
try {
attemptAbort.signal.throwIfAborted();
// Production hands in a synchronous spawn closure. Resolve the binary
// before entering the lane, then create the child while holding it.
const spawnPromise = Promise.resolve(options.spawn());
try {
child = await waitForSignal(spawnPromise, attemptAbort.signal);
} catch (error) {
if (attemptAbort.signal.aborted) {
// A future asynchronous spawner may finish after cancellation.
void spawnPromise
.then((lateChild) => disposeUnownedChild(lateChild))
.catch((cleanupError) =>
options.logger.warn({ err: cleanupError }, "Failed to clean up late Codex spawn"),
);
}
throw error;
}
attemptAbort.signal.throwIfAborted();
client = options.createClient(child);
const initialized = await waitForSignal(
client.request("initialize", options.initializeParams, this.attemptTimeoutMs),
attemptAbort.signal,
);
attemptAbort.signal.throwIfAborted();
client.notify("initialized", {});
return { client, initialized };
} catch (error) {
try {
if (client) {
await client.dispose();
} else if (child) {
await disposeUnownedChild(child);
}
} catch (cleanupError) {
options.logger.warn(
{ err: cleanupError, startupError: error },
"Failed to clean up Codex app-server after startup failure",
);
throw new Error("Codex app-server startup cleanup failed", { cause: cleanupError });
}
throw error;
} finally {
clearTimeout(attemptTimer);
options.signal?.removeEventListener("abort", onAbort);
}
}
}
export const codexAppServerStartup = new CodexAppServerStartupCoordinator();
@@ -5,9 +5,57 @@ import {
createCodexAppServerChildProcess,
createFakeCodexAppServer,
} from "./test-utils/fake-app-server.js";
import { CodexAppServerClient } from "./app-server-transport.js";
import { CodexAppServerClient, CodexAppServerExitError } from "./app-server-transport.js";
describe("Codex app-server transport", () => {
test("classifies startup diagnostics delivered after the child exits", async () => {
const child = createCodexAppServerChildProcess();
const client = new CodexAppServerClient(child, createTestLogger());
const request = client.request("initialize", {});
const result = expect(request).rejects.toMatchObject({
name: "CodexAppServerExitError",
reason: "sqlite_state_initialization",
stderr: "failed to initialize sqlite state runtime",
});
child.exitCode = 1;
child.emit("exit", 1, null);
child.stderr.write("failed to initialize sqlite state runtime");
child.emit("close", 1, null);
try {
await result;
} finally {
child.stdout.end();
child.stderr.end();
child.stdin.end();
}
});
test.each([
{
stderr: "Error: failed to initialize sqlite state runtime under /isolated/.codex",
reason: "sqlite_state_initialization",
},
{ stderr: "database is locked while loading configuration", reason: "other" },
] as const)("classifies a failed startup from child stderr as $reason", async (entry) => {
const child = createCodexAppServerChildProcess();
const client = new CodexAppServerClient(child, createTestLogger());
const request = client.request("initialize", {});
child.stderr.write(entry.stderr);
child.exitCode = 1;
child.emit("exit", 1, null);
await expect(request).rejects.toMatchObject({
name: "CodexAppServerExitError",
reason: entry.reason,
exitCode: 1,
exitSignal: null,
stderr: entry.stderr,
} satisfies Partial<CodexAppServerExitError>);
child.stdout.end();
child.stderr.end();
child.stdin.end();
});
test("ignores non-JSON stdout lines without dropping pending requests", async () => {
const child = createCodexAppServerChildProcess();
const client = new CodexAppServerClient(child, createTestLogger());
@@ -64,6 +64,39 @@ export class CodexAppServerRpcError extends Error {
}
}
interface CodexAppServerExitDetails {
exitCode: number | null;
exitSignal: NodeJS.Signals | null;
stderr: string;
}
export class CodexAppServerExitError extends Error {
readonly reason: "sqlite_state_initialization" | "other";
readonly exitCode: number | null;
readonly exitSignal: NodeJS.Signals | null;
readonly stderr: string;
constructor(details: CodexAppServerExitDetails) {
const message =
details.exitCode === 0 && !details.exitSignal
? "Codex app-server exited"
: `Codex app-server exited with code ${details.exitCode ?? "null"} and signal ${details.exitSignal ?? "null"}`;
super(`${message}\n${details.stderr}`.trim());
this.name = "CodexAppServerExitError";
this.exitCode = details.exitCode;
this.exitSignal = details.exitSignal;
this.stderr = details.stderr;
// Codex reports this pre-initialize failure only on stderr, not as a
// JSON-RPC error. Classify it here so callers branch on a typed reason.
this.reason =
details.exitCode === 1 &&
!details.exitSignal &&
details.stderr.includes("failed to initialize sqlite state runtime")
? "sqlite_state_initialization"
: "other";
}
}
type RequestHandler = (params: unknown, requestId: number) => unknown;
type NotificationHandler = (method: string, params: unknown) => void;
type UnexpectedTerminationHandler = (error: Error) => void;
@@ -234,14 +267,24 @@ export class CodexAppServerClient {
this.handleUnexpectedTermination(err);
});
const reportExit = (code: number | null, signal: NodeJS.Signals | null) => {
this.handleUnexpectedTermination(
new CodexAppServerExitError({
exitCode: code,
exitSignal: signal,
stderr: this.stderrBuffer,
}),
);
};
child.on("exit", (code, signal) => {
const message =
code === 0 && !signal
? "Codex app-server exited"
: `Codex app-server exited with code ${code ?? "null"} and signal ${signal ?? "null"}`;
const error = new Error(`${message}\n${this.stderrBuffer}`.trim());
this.handleUnexpectedTermination(error);
// Startup retries need complete diagnostics. Node drains the child's
// stdio before close, but exit can precede the final stderr data.
const initializing = Array.from(this.pending.values()).some(
(request) => request.method === "initialize",
);
if (!initializing) reportExit(code, signal);
});
child.on("close", reportExit);
}
setUnexpectedTerminationHandler(handler: UnexpectedTerminationHandler): void {
@@ -146,6 +146,10 @@ export function createCodexAppServerChildProcess(): CodexAppServerChildProcess {
exitCode: null,
signalCode: null,
}) as CodexAppServerChildProcess;
// A real ChildProcess closes its stdio after exit, then emits close.
child.once("exit", (code, signal) => {
queueMicrotask(() => child.emit("close", code, signal));
});
child.kill = ((signal?: NodeJS.Signals | number) => {
queueMicrotask(() => child.emit("exit", null, signal ?? null));
return true;
@@ -1,4 +1,3 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
@@ -27,15 +26,24 @@ async function openCodexThread(
const appServer = createFakeCodexAppServer(
fakeCodexThreadHistory({ "saved-thread": thread }, options),
);
const provider = new CodexAppServerAgentClient(createTestLogger());
const provider = new CodexAppServerAgentClient(
createTestLogger(),
{
command: { mode: "replace", argv: ["codex-must-not-run-in-this-test"] },
},
{
appServerProcess: {
resolvePrefix: async () => ({ command: "codex", args: [] }),
spawn: () => appServer.child,
},
},
);
const internals = provider as unknown as {
goalsEnabledPromise: Promise<boolean> | null;
autoReviewEnabledPromise: Promise<boolean> | null;
spawnAppServer: () => Promise<ChildProcessWithoutNullStreams>;
};
internals.goalsEnabledPromise = Promise.resolve(false);
internals.autoReviewEnabledPromise = Promise.resolve(false);
internals.spawnAppServer = async () => appServer.child;
const session = await provider.resumeSession({ sessionId: "saved-thread" });
return { appServer, session };
}
@@ -32,6 +32,16 @@ import type {
const EXPECTED_STARTUP_BUDGET_MS = 30_000;
const EXPECTED_EVENT_READINESS_BUDGET_MS = 45_000;
// Keep event correlation and turn ordering assertions exact while accepting a fresh namespace.
function turnIdAt(events: AgentStreamEvent[], ordinal: number): string {
const started = events.find((event) => event.type === "turn_started");
if (!started || started.type !== "turn_started" || typeof started.turnId !== "string") {
throw new Error("Expected a turn_started event with a turn ID");
}
const namespace = started.turnId.slice(0, started.turnId.lastIndexOf("-"));
return `${namespace}-${ordinal}`;
}
function tmpCwd(): string {
const dir = mkdtempSync(path.join(os.tmpdir(), "opencode-agent-test-"));
try {
@@ -757,6 +767,19 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
}
});
test("keeps turn identities distinct for the same persisted session", async () => {
const first = await createParentSession("ses_persisted");
const firstTurn = await first.parent.startTurn("first");
await first.parent.close();
const resumed = await createParentSession("ses_persisted");
try {
const nextTurn = await resumed.parent.startTurn("second");
expect(nextTurn.turnId).not.toBe(firstTurn.turnId);
} finally {
await resumed.parent.close();
}
});
test.each([
{ saved: "default", variant: undefined },
{ saved: "high", variant: "high" },
@@ -1638,10 +1661,10 @@ describe("OpenCode adapter startTurn error handling", () => {
item: { type: "user_message", clientMessageId: "client-message-1" },
});
expect(turn.events.map((event) => ("turnId" in event ? event.turnId : undefined))).toEqual([
"opencode-turn-0",
"opencode-turn-0",
"opencode-turn-0",
"opencode-turn-0",
turnIdAt(turn.events, 0),
turnIdAt(turn.events, 0),
turnIdAt(turn.events, 0),
turnIdAt(turn.events, 0),
]);
});
@@ -1833,7 +1856,7 @@ describe("OpenCode adapter startTurn error handling", () => {
type: "error",
message: "Provider retry (attempt 1): model does not exist",
},
turnId: "opencode-turn-0",
turnId: turnIdAt(events, 0),
});
expect(events.some((event) => event.type === "turn_failed")).toBe(false);
await session.close();
@@ -2641,11 +2664,11 @@ describe("OpenCode adapter startTurn error handling", () => {
expect(
turnEventSignatures(events.filter((event) => event.type !== "provider_subagent")),
).toEqual([
["turn_started", "opencode-turn-0"],
["turn_canceled", "opencode-turn-0"],
["turn_started", "opencode-turn-1"],
["timeline", "opencode-turn-1"],
["turn_completed", "opencode-turn-1"],
["turn_started", turnIdAt(events, 0)],
["turn_canceled", turnIdAt(events, 0)],
["turn_started", turnIdAt(events, 1)],
["timeline", turnIdAt(events, 1)],
["turn_completed", turnIdAt(events, 1)],
]);
} finally {
settleRetry.resolve();
@@ -2706,7 +2729,7 @@ describe("OpenCode adapter startTurn error handling", () => {
expect(events).toContainEqual(
expect.objectContaining({
type: "timeline",
turnId: "opencode-turn-1",
turnId: turnIdAt(events, 1),
item: expect.objectContaining({ text: "Wake output streams live." }),
}),
),
@@ -2726,11 +2749,11 @@ describe("OpenCode adapter startTurn error handling", () => {
});
await vi.waitFor(() =>
expect(events).toContainEqual(
expect.objectContaining({ type: "turn_completed", turnId: "opencode-turn-1" }),
expect.objectContaining({ type: "turn_completed", turnId: turnIdAt(events, 1) }),
),
);
await expect(session.startTurn("after the wake")).resolves.toEqual({
turnId: "opencode-turn-2",
turnId: turnIdAt(events, 2),
});
} finally {
settleAbort.resolve();
@@ -2843,13 +2866,13 @@ describe("OpenCode adapter startTurn error handling", () => {
expect(events).toContainEqual({
type: "turn_started",
provider: "opencode",
turnId: "opencode-turn-1",
turnId: turnIdAt(events, 1),
});
await vi.waitFor(() =>
expect(events).toContainEqual({
type: "turn_completed",
provider: "opencode",
turnId: "opencode-turn-1",
turnId: turnIdAt(events, 1),
usage: expect.objectContaining({
inputTokens: 7,
outputTokens: 3,
@@ -2858,7 +2881,7 @@ describe("OpenCode adapter startTurn error handling", () => {
}),
);
const autonomousCompletion = events.find(
(event) => event.type === "turn_completed" && event.turnId === "opencode-turn-1",
(event) => event.type === "turn_completed" && event.turnId === turnIdAt(events, 1),
);
expect(autonomousCompletion?.type).toBe("turn_completed");
if (autonomousCompletion?.type === "turn_completed") {
@@ -2867,7 +2890,7 @@ describe("OpenCode adapter startTurn error handling", () => {
expect(events).toContainEqual(
expect.objectContaining({
type: "timeline",
turnId: "opencode-turn-1",
turnId: turnIdAt(events, 1),
item: expect.objectContaining({
type: "assistant_message",
text: "Autonomous wake completed.",
@@ -2875,7 +2898,7 @@ describe("OpenCode adapter startTurn error handling", () => {
}),
);
await expect(session.startTurn("after autonomous wake")).resolves.toEqual({
turnId: "opencode-turn-2",
turnId: turnIdAt(events, 2),
});
} finally {
settleAbort.resolve();
@@ -2954,7 +2977,7 @@ describe("OpenCode adapter startTurn error handling", () => {
type: "turn_canceled",
provider: "opencode",
reason: "interrupted",
turnId: "opencode-turn-0",
turnId: turnIdAt(events, 0),
});
openCode.emitEvent({
@@ -2962,7 +2985,7 @@ describe("OpenCode adapter startTurn error handling", () => {
properties: { sessionID: "ses_retried_stop" },
});
await expect(session.startTurn("replacement")).resolves.toEqual({
turnId: "opencode-turn-1",
turnId: turnIdAt(events, 1),
});
expect(openCode.calls.sessionPromptAsync).toHaveLength(2);
} finally {
@@ -3058,7 +3081,7 @@ describe("OpenCode adapter startTurn error handling", () => {
expect(events).toContainEqual({
type: "turn_started",
provider: "opencode",
turnId: "opencode-turn-1",
turnId: turnIdAt(events, 1),
}),
);
@@ -3767,7 +3790,7 @@ describe("OpenCode adapter startTurn error handling", () => {
];
const replacement = session.startTurn("second");
await vi.advanceTimersByTimeAsync(10_000);
await expect(replacement).resolves.toEqual({ turnId: "opencode-turn-1" });
await expect(replacement).resolves.toEqual({ turnId: turnIdAt(events, 1) });
expect(countEvents(events, "turn_canceled")).toBe(1);
} finally {
vi.useRealTimers();
@@ -4009,6 +4032,9 @@ describe("OpenCode adapter startTurn error handling", () => {
return { data: true };
};
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
await session.startTurn("first");
const interrupt = session.interrupt();
@@ -4022,7 +4048,7 @@ describe("OpenCode adapter startTurn error handling", () => {
expect(openCode.calls.sessionStatus).toHaveLength(1);
releaseIdle.resolve();
await expect(replacement).resolves.toEqual({ turnId: "opencode-turn-1" });
await expect(replacement).resolves.toEqual({ turnId: turnIdAt(events, 1) });
expect(openCode.calls.sessionPromptAsync).toHaveLength(2);
expect(openCode.calls.sessionStatus.length).toBeGreaterThanOrEqual(2);
} finally {
@@ -4722,9 +4748,9 @@ describe("OpenCode provider subagent contract", () => {
"turn_completed",
]);
expect(events.map((event) => ("turnId" in event ? event.turnId : undefined))).toEqual([
"opencode-turn-0",
"opencode-turn-0",
"opencode-turn-0",
turnIdAt(events, 0),
turnIdAt(events, 0),
turnIdAt(events, 0),
]);
expect(events).toContainEqual(
expect.objectContaining({
@@ -4760,7 +4786,7 @@ describe("OpenCode provider subagent contract", () => {
expect(events).toContainEqual({
type: "turn_started",
provider: "opencode",
turnId: "opencode-turn-0",
turnId: turnIdAt(events, 0),
});
});
for (const event of assistantTurnEvents({
@@ -4774,9 +4800,9 @@ describe("OpenCode provider subagent contract", () => {
expect(childClient.calls.sessionStatus).toEqual([{ directory: "/workspace/repo" }]);
expect(turnEventSignatures(events)).toEqual([
["turn_started", "opencode-turn-0"],
["timeline", "opencode-turn-0"],
["turn_completed", "opencode-turn-0"],
["turn_started", turnIdAt(events, 0)],
["timeline", turnIdAt(events, 0)],
["turn_completed", turnIdAt(events, 0)],
]);
expect(events).toContainEqual(
expect.objectContaining({
@@ -4881,9 +4907,9 @@ describe("OpenCode provider subagent contract", () => {
await vi.waitFor(() => {
expect(turnEventSignatures(events)).toEqual([
["turn_started", "opencode-turn-0"],
["timeline", "opencode-turn-0"],
["turn_completed", "opencode-turn-0"],
["turn_started", turnIdAt(events, 0)],
["timeline", turnIdAt(events, 0)],
["turn_completed", turnIdAt(events, 0)],
]);
});
expect(events).toContainEqual(
@@ -4927,9 +4953,9 @@ describe("OpenCode provider subagent contract", () => {
await vi.waitFor(() => {
expect(turnEventSignatures(events)).toEqual([
["turn_started", "opencode-turn-0"],
["timeline", "opencode-turn-0"],
["turn_completed", "opencode-turn-0"],
["turn_started", turnIdAt(events, 0)],
["timeline", turnIdAt(events, 0)],
["turn_completed", turnIdAt(events, 0)],
]);
});
} finally {
@@ -5003,7 +5029,7 @@ describe("OpenCode provider subagent contract", () => {
});
await vi.waitFor(() => {
expect(events).toEqual([
{ type: "turn_started", provider: "opencode", turnId: "opencode-turn-0" },
{ type: "turn_started", provider: "opencode", turnId: turnIdAt(events, 0) },
]);
});
@@ -5118,12 +5144,12 @@ describe("OpenCode provider subagent contract", () => {
await streamDrained.promise;
expect(events.filter((event) => event.type !== "provider_subagent")).toEqual([
{ type: "turn_started", provider: "opencode", turnId: "opencode-turn-0" },
{ type: "turn_started", provider: "opencode", turnId: turnIdAt(events, 0) },
{
type: "turn_canceled",
provider: "opencode",
reason: "interrupted",
turnId: "opencode-turn-0",
turnId: turnIdAt(events, 0),
},
]);
} finally {
@@ -5174,7 +5200,7 @@ describe("OpenCode provider subagent contract", () => {
expect.objectContaining({
type: "permission_requested",
request: expect.objectContaining({ id: "perm_child_external" }),
turnId: "opencode-turn-0",
turnId: turnIdAt(events, 0),
}),
);
});
@@ -3415,6 +3415,7 @@ class OpenCodeAgentSession implements AgentSession {
private partTypes = new Map<string, string>();
private availableModesCache: AgentMode[] | null = null;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private readonly turnNamespace = randomUUID();
private nextTurnOrdinal = 0;
private turnState: OpenCodeTurnState = { status: "idle" };
/**
@@ -4894,7 +4895,7 @@ class OpenCodeAgentSession implements AgentSession {
private createTurnId(): string {
this.gapRepairRevision += 1;
return `opencode-turn-${this.nextTurnOrdinal++}`;
return `opencode-turn-${this.turnNamespace}-${this.nextTurnOrdinal++}`;
}
private traceOpenCode(msg: OpenCodeTraceMessage, data: OpenCodeTraceData = {}): void {
@@ -197,6 +197,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",
@@ -447,6 +449,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,
);
@@ -165,7 +165,11 @@ test("classifies a removed file as deleted", async () => {
});
await rm(target);
await expect.poll(() => events.find((event) => event.path === target)?.type).toBe("delete");
// Native recursive delivery can be coalesced before the backend's deletion audit.
// Assert eventual classification without imposing Vitest's one-second default.
await expect
.poll(() => events.find((event) => event.path === target)?.type, { timeout: 10_000 })
.toBe("delete");
await subscription.unsubscribe();
});
@@ -60,6 +60,7 @@ test.each(["existing", "new"])(
await Promise.all(removed.map((path) => rm(path)));
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] });
for (const path of removed) notifications.emit("change", "change", path);
// The file-only event uses the eight-second optional audit debounce.
await vi.advanceTimersByTimeAsync(8_000);
vi.useRealTimers();
await expect
@@ -309,79 +310,98 @@ test("a classification shed onto the scoped audit still recovers the file", asyn
// re-link `directory` itself back into its parent -- walkSubtree only
// descends through that link, so without the re-link a later ancestor
// removal can no longer reach anything below the reconciled directory.
test("a recursive-scope reconcile re-links its directory so an ancestor removal still finds nested files", async () => {
const root = await mkdtemp(join(tmpdir(), "native-relink-"));
const paths = createObserverPaths(process.platform);
const deepDirectory = join(root, "a", "b", "c");
const deepFile = join(deepDirectory, "deep.txt");
await mkdir(deepDirectory, { recursive: true });
await writeFile(deepFile, "content");
const events: FileChange[] = [];
const notifications = new EventEmitter();
const observer = createFileObserver();
let active = true;
const backend = createNativeRecursiveBackend(
{
root,
metrics: observer.getDiagnostics(),
isActive: () => active,
isIgnored: () => false,
isPathInside: paths.isInside,
queueEvent: (type, path) => events.push({ type, path }),
fail: (error) => {
throw error;
},
},
paths,
(_root, listener) => {
notifications.on("change", listener);
return {
close: () => notifications.removeAllListeners(),
on: (event, onError) => notifications.on(event, onError),
};
},
);
const settled = () =>
expect
.poll(
() => {
const diagnostics = backend.getDiagnostics();
return (
diagnostics.pendingReconciliationWorkCount === 0 && !diagnostics.reconciliationInFlight
);
test.each([
{ notification: "ancestor", deletedPath: ["a"] },
{ notification: "file", deletedPath: ["a", "b", "c", "deep.txt"] },
])(
"a recursive-scope reconcile preserves nested deletions announced by $notification",
async ({ deletedPath }) => {
const root = await mkdtemp(join(tmpdir(), "native-relink-"));
const paths = createObserverPaths(process.platform);
const deepDirectory = join(root, "a", "b", "c");
const deepFile = join(deepDirectory, "deep.txt");
await mkdir(deepDirectory, { recursive: true });
await writeFile(deepFile, "content");
const events: FileChange[] = [];
const notifications = new EventEmitter();
const observer = createFileObserver();
let active = true;
const backend = createNativeRecursiveBackend(
{
root,
metrics: observer.getDiagnostics(),
isActive: () => active,
isIgnored: () => false,
isPathInside: paths.isInside,
queueEvent: (type, path) => events.push({ type, path }),
fail: (error) => {
throw error;
},
{ timeout: 10_000 },
)
.toBe(true);
try {
await backend.start(); // Tracks a, a/b, a/b/c, and deep.txt.
// A rename event landing on the already-known directory a/b produces a
// local scope on its parent "a" (rebuilding a's DirectoryEntry from a
// fresh shallow scan) and, once classify()'s real stat() confirms a/b is
// still a directory, a recursive scope on a/b itself -- the same "known
// directory changes in place" shape the directory-churn benchmark
// exercises via atomic replace.
notifications.emit("change", "rename", join(root, "a", "b"));
await settled();
// Remove the ancestor while the native event names only a deleted file.
// The scoped scan must report its missing directory as well as that file.
await rm(join(root, "a"), { recursive: true, force: true });
notifications.emit("change", "rename", deepFile);
await settled();
expect(events.filter((event) => event.type === "delete").map((event) => event.path)).toContain(
deepFile,
},
paths,
(_root, listener) => {
notifications.on("change", listener);
return {
close: () => notifications.removeAllListeners(),
on: (event, onError) => notifications.on(event, onError),
};
},
);
expect(events).toContainEqual({ type: "delete", path: deepDirectory });
} finally {
active = false;
await backend.close();
await observer.close();
await rm(root, { recursive: true, force: true });
}
}, 15_000);
const settled = async () => {
// Classification uses real filesystem I/O and can request another audit.
// Drain it before advancing the debounce clock so no later classification
// leaves a fresh timer behind.
await expect
.poll(() => backend.getDiagnostics().pendingClassificationCount, { timeout: 10_000 })
.toBe(0);
// The file-only event uses the eight-second optional audit debounce.
await vi.advanceTimersByTimeAsync(8_000);
await expect
.poll(
() => {
const diagnostics = backend.getDiagnostics();
return (
diagnostics.pendingReconciliationWorkCount === 0 &&
!diagnostics.reconciliationInFlight
);
},
{ timeout: 10_000 },
)
.toBe(true);
};
try {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] });
await backend.start(); // Tracks a, a/b, a/b/c, and deep.txt.
// A rename event landing on the already-known directory a/b produces a
// local scope on its parent "a" (rebuilding a's DirectoryEntry from a
// fresh shallow scan) and, once classify()'s real stat() confirms a/b is
// still a directory, a recursive scope on a/b itself -- the same "known
// directory changes in place" shape the directory-churn benchmark
// exercises via atomic replace.
notifications.emit("change", "rename", join(root, "a", "b"));
await settled();
// An ancestor notification must walk the re-linked subtree. A file-only
// notification must also recover the missing directory from its scoped scan.
await rm(join(root, "a"), { recursive: true, force: true });
notifications.emit("change", "rename", join(root, ...deletedPath));
await settled();
expect(
events.filter((event) => event.type === "delete").map((event) => event.path),
).toContain(deepFile);
expect(events).toContainEqual({ type: "delete", path: deepDirectory });
} finally {
vi.useRealTimers();
active = false;
await backend.close();
await observer.close();
await rm(root, { recursive: true, force: true });
}
},
15_000,
);
test("a full native inventory audit reports a removed directory", async () => {
const root = await mkdtemp(join(tmpdir(), "native-full-audit-"));
@@ -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,
@@ -1912,3 +1912,115 @@ test("public project subscriptions request updates and release their producer de
await daemon.close();
}
});
test.each([
{ closed: false, batch: false },
{ closed: true, batch: false },
{ closed: false, batch: true },
{ closed: true, batch: true },
])(
"clearing workspace attention reaches independent subscribers (closed=$closed, batch=$batch)",
async ({ closed, batch }) => {
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
const clients = Array.from(
{ length: 4 },
() => new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }),
);
const [actor, observer, unreadObserver, quiet] = clients;
const updates: SessionOutboundMessage[] = [];
const unreadUpdates: SessionOutboundMessage[] = [];
const quietUpdates: SessionOutboundMessage[] = [];
const responses: SessionOutboundMessage[] = [];
try {
await Promise.all(clients.map((client) => client.connect()));
const agentIds: string[] = [];
for (let index = 0; index < (batch ? 2 : 1); index += 1) {
const cwd = path.join(daemon.staticDir, `workspace-${index}`);
await mkdir(cwd);
const agent = await actor.createAgent({ provider: "codex", cwd });
await actor.sendMessage(agent.id, "Reply with OK.");
await actor.waitForFinish(agent.id, 5000);
await daemon.daemon.agentManager.clearAgentAttention(agent.id);
if (closed) await daemon.daemon.agentManager.closeAgent(agent.id);
agentIds.push(agent.id);
}
const snapshot = await observer.fetchAgents({
filter: { includeArchived: true },
subscribe: {},
});
const workspaceIds = agentIds.map(
(id) => snapshot.entries.find((entry) => entry.agent.id === id)!.agent.workspaceId!,
);
expect(new Set(workspaceIds).size).toBe(agentIds.length);
await unreadObserver.fetchAgents({ filter: { requiresAttention: true }, subscribe: {} });
actor.subscribeRawMessages((message) => responses.push(message));
observer.subscribeRawMessages((message) => updates.push(message));
unreadObserver.subscribeRawMessages((message) => unreadUpdates.push(message));
quiet.subscribeRawMessages((message) => quietUpdates.push(message));
const attention = (id: string) =>
updates
.flatMap((message) =>
message.type === "agent_update" &&
message.payload.kind === "upsert" &&
message.payload.agent.id === id
? [message.payload.agent.requiresAttention]
: [],
)
.at(-1);
for (const [index, id] of agentIds.entries()) {
await actor.markWorkspaceUnread(workspaceIds[index]);
await expect.poll(() => attention(id)).toBe(true);
await expect
.poll(() =>
unreadUpdates.some(
(message) =>
message.type === "agent_update" &&
message.payload.kind === "upsert" &&
message.payload.agent.id === id,
),
)
.toBe(true);
}
updates.length = unreadUpdates.length = quietUpdates.length = 0;
await actor.clearWorkspaceAttention(batch ? workspaceIds : workspaceIds[0]);
const response = responses.findLast(
(message) => message.type === "workspace.clear_attention.response",
);
expect(response?.payload).toEqual({
requestId: expect.any(String),
workspaceId: batch ? workspaceIds : workspaceIds[0],
clearedAgentIds: agentIds,
results: workspaceIds.map((workspaceId, index) => ({
workspaceId,
clearedAgentIds: [agentIds[index]],
success: true,
error: null,
})),
success: true,
error: null,
});
const fresh = await actor.fetchAgents();
for (const id of agentIds) {
expect(fresh.entries.find((entry) => entry.agent.id === id)?.agent.requiresAttention).toBe(
false,
);
expect(Boolean(daemon.daemon.agentManager.getAgent(id))).toBe(!closed);
await expect.poll(() => attention(id)).toBe(false);
await expect
.poll(() =>
unreadUpdates.some(
(message) =>
message.type === "agent_update" &&
message.payload.kind === "remove" &&
message.payload.agentId === id,
),
)
.toBe(true);
}
expect(quietUpdates.filter((message) => message.type === "agent_update")).toEqual([]);
} finally {
await Promise.all(clients.map((client) => client.close()));
await daemon.close();
}
},
);
+11 -19
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";
@@ -856,6 +857,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;
@@ -1020,6 +1022,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),
@@ -3191,6 +3194,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":
@@ -3504,7 +3511,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(
@@ -8164,24 +8173,7 @@ export class Session {
) {
continue;
}
const nextRecord: StoredAgentRecord = {
...record,
updatedAt: new Date().toISOString(),
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
};
await this.agentStorage.upsert(nextRecord);
const agent = this.buildStoredAgentPayload(nextRecord);
const project = await this.buildProjectPlacementForWorkspace(workspace);
this.emit({
type: "agent_update",
payload: {
kind: "upsert",
agent,
project,
},
});
await this.agentManager.clearAgentAttention(agentId);
clearedAgentIds.push(agentId);
}
@@ -1847,81 +1847,6 @@ test("archive emits an authoritative agent_update upsert for subscribed clients"
});
});
test("workspace clear attention clears stored-only agents and responds", async () => {
const emitted: SessionOutboundMessage[] = [];
const workspace = createPersistedWorkspaceRecord({
workspaceId: REPO_CWD,
projectId: REPO_CWD,
cwd: REPO_CWD,
kind: "directory",
displayName: "repo",
createdAt: "2026-03-30T15:00:00.000Z",
updatedAt: "2026-03-30T15:00:00.000Z",
});
const project = createPersistedProjectRecord({
projectId: REPO_CWD,
rootPath: REPO_CWD,
kind: "non_git",
displayName: "repo",
createdAt: "2026-03-30T15:00:00.000Z",
updatedAt: "2026-03-30T15:00:00.000Z",
});
let storedRecord = makeStoredAgent({
id: "stored-agent-1",
cwd: REPO_CWD,
updatedAt: "2026-03-30T15:00:00.000Z",
requiresAttention: true,
attentionReason: "finished",
});
const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) });
session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [
workspace,
];
session.workspaceRegistry.get = async (id: string) =>
id === workspace.workspaceId ? workspace : null;
session.projectRegistry.list = async () => [project];
session.projectRegistry.get = async (id: string) => (id === project.projectId ? project : null);
session.agentStorage.get = async (agentId: string) =>
agentId === storedRecord.id ? storedRecord : null;
session.agentStorage.upsert = async (record: unknown) => {
storedRecord = record as StoredAgentRecord;
};
session.listAgentPayloads = async () => [
makeAgent({
id: storedRecord.id,
cwd: storedRecord.cwd,
workspaceId: workspace.workspaceId,
status: "closed",
updatedAt: storedRecord.updatedAt,
requiresAttention: true,
attentionReason: "finished",
}),
];
await session.handleMessage({
type: "workspace.clear_attention.request",
workspaceId: workspace.workspaceId,
requestId: "req-1",
});
expect(storedRecord.requiresAttention).toBe(false);
expect(storedRecord.attentionReason).toBeNull();
expect(storedRecord.attentionTimestamp).toBeNull();
expect(findByType(emitted, "workspace.clear_attention.response").payload).toMatchObject({
requestId: "req-1",
workspaceId: workspace.workspaceId,
clearedAgentIds: [storedRecord.id],
success: true,
error: null,
});
const agentUpdate = findByType(emitted, "agent_update");
expect(agentUpdate.payload.kind).toBe("upsert");
if (agentUpdate.payload.kind === "upsert") {
expect(agentUpdate.payload.agent.requiresAttention).toBe(false);
}
});
test("workspace clear attention responds with an error instead of timing out", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) });
@@ -2201,110 +2126,6 @@ test("workspace mark unread rejects workspaces without a read root agent", async
});
});
test("workspace clear attention can clear multiple workspaces in one request", async () => {
const emitted: SessionOutboundMessage[] = [];
const workspaces = [
createPersistedWorkspaceRecord({
workspaceId: "ws-repo-a",
projectId: "/tmp/repo-a",
cwd: "/tmp/repo-a",
kind: "directory",
displayName: "repo-a",
createdAt: "2026-03-30T15:00:00.000Z",
updatedAt: "2026-03-30T15:00:00.000Z",
}),
createPersistedWorkspaceRecord({
workspaceId: "ws-repo-b",
projectId: "/tmp/repo-b",
cwd: "/tmp/repo-b",
kind: "directory",
displayName: "repo-b",
createdAt: "2026-03-30T15:00:00.000Z",
updatedAt: "2026-03-30T15:00:00.000Z",
}),
];
const projects = workspaces.map((workspace) =>
createPersistedProjectRecord({
projectId: workspace.projectId,
rootPath: workspace.cwd,
kind: "non_git",
displayName: workspace.displayName,
createdAt: "2026-03-30T15:00:00.000Z",
updatedAt: "2026-03-30T15:00:00.000Z",
}),
);
const storedRecords = new Map(
workspaces.map((workspace, index) => [
`stored-agent-${index + 1}`,
makeStoredAgent({
id: `stored-agent-${index + 1}`,
cwd: workspace.cwd,
updatedAt: "2026-03-30T15:00:00.000Z",
requiresAttention: true,
attentionReason: "finished",
}),
]),
);
const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) });
session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => workspaces;
session.workspaceRegistry.get = async (id: string) =>
workspaces.find((workspace) => workspace.workspaceId === id) ?? null;
session.projectRegistry.list = async () => projects;
session.projectRegistry.get = async (id: string) =>
projects.find((project) => project.projectId === id) ?? null;
session.agentStorage.get = async (agentId: string) => storedRecords.get(agentId) ?? null;
session.agentStorage.upsert = async (record: unknown) => {
const storedRecord = record as StoredAgentRecord;
storedRecords.set(storedRecord.id, storedRecord);
};
session.listAgentPayloads = async () =>
Array.from(storedRecords.values()).map((record) => {
const owner = workspaces.find((workspace) => workspace.cwd === record.cwd);
return makeAgent({
id: record.id,
cwd: record.cwd,
...(owner ? { workspaceId: owner.workspaceId } : {}),
status: "closed",
updatedAt: record.updatedAt,
requiresAttention: record.requiresAttention,
attentionReason: record.attentionReason,
});
});
await session.handleMessage({
type: "workspace.clear_attention.request",
workspaceId: workspaces.map((workspace) => workspace.workspaceId),
requestId: "req-1",
});
expect(Array.from(storedRecords.values()).map((record) => record.requiresAttention)).toEqual([
false,
false,
]);
expect(findByType(emitted, "workspace.clear_attention.response").payload).toMatchObject({
requestId: "req-1",
workspaceId: workspaces.map((workspace) => workspace.workspaceId),
clearedAgentIds: ["stored-agent-1", "stored-agent-2"],
results: [
{
workspaceId: workspaces[0].workspaceId,
clearedAgentIds: ["stored-agent-1"],
success: true,
error: null,
},
{
workspaceId: workspaces[1].workspaceId,
clearedAgentIds: ["stored-agent-2"],
success: true,
error: null,
},
],
success: true,
error: null,
});
});
test("close_items_request archives agents and kills terminals in one batch", async () => {
const emitted: SessionOutboundMessage[] = [];
const archivedAt = "2026-04-01T00:00:00.000Z";
@@ -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.
*/
@@ -1231,7 +1231,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();
@@ -2031,6 +2031,7 @@ export class VoiceAssistantWebSocketServer {
ownedSubscriptions: true,
providerOptionControls: true,
entityMetadata: 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,
@@ -16,13 +16,13 @@ export function createTerminalSerializer(
// absolutely; an x==cols cursor must keep its pending wrap for the next byte.
const position =
buffer.cursorX < terminal.cols ? `\x1b[${buffer.cursorY + 1};${buffer.cursorX + 1}H` : "";
return Buffer.from(data + position + serializeCursorPresentation(terminal), "utf8");
return Buffer.from(data + position + serializeAdditionalModes(terminal), "utf8");
};
}
// Stable addon 0.14 serializes buffer/modes but omits DECSCUSR and cursor visibility.
// xterm 6 keeps the program-selected presentation in coreService, not public options.
function serializeCursorPresentation(terminal: TerminalType): string {
// Addon 0.14 omits mouse encoding, DECSCUSR and cursor visibility. xterm 6
// retains these in internal services rather than the public modes/options.
function serializeAdditionalModes(terminal: TerminalType): string {
const core = (
terminal as unknown as {
_core: {
@@ -30,13 +30,17 @@ function serializeCursorPresentation(terminal: TerminalType): string {
isCursorHidden: boolean;
decPrivateModes: { cursorStyle?: "block" | "underline" | "bar"; cursorBlink?: boolean };
};
mouseStateService: { activeEncoding: string };
};
}
)._core.coreService;
const visibility = `\x1b[?25${core.isCursorHidden ? "l" : "h"}`;
const cursorStyle = core.decPrivateModes.cursorStyle;
)._core;
let mouseEncoding = "";
if (core.mouseStateService.activeEncoding === "SGR") mouseEncoding = "\x1b[?1006h";
else if (core.mouseStateService.activeEncoding === "SGR_PIXELS") mouseEncoding = "\x1b[?1016h";
const visibility = `${mouseEncoding}\x1b[?25${core.coreService.isCursorHidden ? "l" : "h"}`;
const cursorStyle = core.coreService.decPrivateModes.cursorStyle;
// Unset presentation belongs to each renderer's configured defaults.
if (cursorStyle === undefined) return visibility;
const style = { block: 2, underline: 4, bar: 6 }[cursorStyle];
return `${visibility}\x1b[${style - (core.decPrivateModes.cursorBlink ? 1 : 0)} q`;
return `${visibility}\x1b[${style - (core.coreService.decPrivateModes.cursorBlink ? 1 : 0)} q`;
}
@@ -1702,6 +1702,52 @@ it.each([false, true])(
},
);
it.each([
{ name: "SGR", modes: "\x1b[?1006h", wheel: "\x1b[<64;10;5M" },
{ name: "SGR pixels", modes: "\x1b[?1016h", wheel: "\x1b[<64;80;90M" },
{ name: "disabled SGR", modes: "\x1b[?1006h\x1b[?1006l", wheel: "\x1b[M`*%" },
])("preserves $name wheel reports across repeated terminal replays", async (input) => {
const source = new xterm.Terminal({ rows: 10, cols: 80, allowProposedApi: true });
const receiver = new xterm.Terminal({ rows: 10, cols: 80, allowProposedApi: true });
const serialize = createTerminalSerializer(source);
const wheel = {
button: 4,
action: 0,
col: 10,
row: 5,
x: 80,
y: 90,
ctrl: false,
alt: false,
shift: false,
};
// Mouse encoding is not exposed by xterm's public modes API.
const mouse = (
receiver as unknown as {
_core: {
mouseStateService: {
encodeMouseEvent(event: typeof wheel): string;
};
};
}
)._core.mouseStateService;
try {
await new Promise<void>((resolve) =>
source.write("\x1b[?1049h\x1b[?1002h" + input.modes + "TRANSCRIPT", resolve),
);
for (let replay = 0; replay < 2; replay++) {
receiver.reset();
await new Promise<void>((resolve) => receiver.write(serialize(), resolve));
expect(receiver.buffer.active.type).toBe("alternate");
expect(receiver.modes.mouseTrackingMode).toBe("drag");
expect(mouse.encodeMouseEvent(wheel)).toBe(input.wheel);
}
} finally {
receiver.dispose();
source.dispose();
}
});
it.each([
{ name: "unset", presentation: "", style: undefined, visible: true },
{
@@ -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 }));