mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 17:34:51 +00:00
Merge remote-tracking branch 'origin/main' into plan-plugin-api-phase7-composers-by-ref
This commit is contained in:
@@ -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 &&
|
||||
|
||||
+28
-1
@@ -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();
|
||||
|
||||
+6
-7
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
} from "../services/forge-cli-command.js";
|
||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||
import { runGitCommand, type RunGitCommand } from "./run-git-command.js";
|
||||
import { readGitFileContents } from "./git-file-contents.js";
|
||||
import { readGitFileContents, readGitFileContentAtRef } from "./git-file-contents.js";
|
||||
import { unquoteGitPath } from "./git-quoted-path.js";
|
||||
import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js";
|
||||
import {
|
||||
@@ -613,22 +613,6 @@ async function listCheckoutFileChanges(
|
||||
return Array.from(byPath.values());
|
||||
}
|
||||
|
||||
async function readGitFileContentAtRef(
|
||||
cwd: string,
|
||||
ref: string,
|
||||
path: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await runGitCommand(["show", `${ref}:${path}`], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
return stdout;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function tryResolveMergeBase(cwd: string, baseRef: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await runGitCommand(["merge-base", baseRef, "HEAD"], {
|
||||
@@ -669,7 +653,12 @@ function normalizeNumstatPath(pathField: string): string {
|
||||
}
|
||||
|
||||
// Parsed patches need a stable wire format regardless of terminal presentation settings.
|
||||
const GIT_PATCH_FORMAT_ARGS = ["--no-ext-diff", "--no-color", "--src-prefix=a/", "--dst-prefix=b/"];
|
||||
export const GIT_PATCH_FORMAT_ARGS = [
|
||||
"--no-ext-diff",
|
||||
"--no-color",
|
||||
"--src-prefix=a/",
|
||||
"--dst-prefix=b/",
|
||||
] as const;
|
||||
|
||||
function buildGitDiffArgs(args: { ignoreWhitespace?: boolean; extra: string[] }): string[] {
|
||||
return [
|
||||
@@ -906,6 +895,7 @@ export interface CheckoutStatusGitNonPaseo {
|
||||
isDirty: boolean;
|
||||
baseRef: string | null;
|
||||
baseRefExact?: string | null;
|
||||
comparisonBaseRef?: string | null;
|
||||
aheadBehind: AheadBehind | null;
|
||||
// Remote-tracking ref for currentBranch, e.g. "refs/remotes/origin/main". Null when the
|
||||
// branch has no upstream or git could not resolve it. aheadOfOrigin/behindOfOrigin are
|
||||
@@ -926,6 +916,7 @@ export interface CheckoutStatusGitPaseo {
|
||||
isDirty: boolean;
|
||||
baseRef: string;
|
||||
baseRefExact?: string | null;
|
||||
comparisonBaseRef?: string | null;
|
||||
aheadBehind: AheadBehind | null;
|
||||
upstreamRef: string | null;
|
||||
aheadOfOrigin: number | null;
|
||||
@@ -1748,6 +1739,24 @@ function isQualifiedBranchRef(ref: string): boolean {
|
||||
return ref.startsWith("refs/heads/") || ref.startsWith("refs/remotes/");
|
||||
}
|
||||
|
||||
async function resolveCommitComparisonRef(
|
||||
cwd: string,
|
||||
name: string,
|
||||
context?: CheckoutContext,
|
||||
): Promise<string> {
|
||||
await getRunGitCommand(context)(["rev-parse", "--verify", `${name}^{commit}`], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
logger: context?.logger,
|
||||
});
|
||||
const result = await getRunGitCommand(context)(["rev-parse", "--symbolic-full-name", name], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
logger: context?.logger,
|
||||
});
|
||||
return result.stdout.trim() || name;
|
||||
}
|
||||
|
||||
async function resolveBestComparisonBaseRef(
|
||||
cwd: string,
|
||||
baseRef: string,
|
||||
@@ -1774,11 +1783,17 @@ async function resolveBestComparisonBaseRef(
|
||||
return normalized.localName;
|
||||
}
|
||||
|
||||
const refName =
|
||||
baseRef.startsWith("origin/") || baseRef.startsWith("refs/remotes/origin/")
|
||||
? normalized.originRef
|
||||
: normalized.localName;
|
||||
throw new Error(`Base branch not found locally or on origin: ${refName}`);
|
||||
return resolveCommitComparisonRef(cwd, baseRef, context);
|
||||
}
|
||||
|
||||
async function resolveQualifiedComparisonBaseRef(
|
||||
cwd: string,
|
||||
baseRef: string,
|
||||
context: CheckoutContext,
|
||||
): Promise<string> {
|
||||
const comparison = context.facts?.isGit ? context.facts.comparisonBaseRef : null;
|
||||
const ref = comparison ?? (await resolveBestComparisonBaseRef(cwd, baseRef, context));
|
||||
return resolveCommitComparisonRef(cwd, ref, context);
|
||||
}
|
||||
|
||||
async function resolveMostAheadBaseRef(cwd: string, baseRef: string): Promise<string> {
|
||||
@@ -1801,7 +1816,7 @@ async function resolveMostAheadBaseRef(cwd: string, baseRef: string): Promise<st
|
||||
return `origin/${normalizedBaseRef}`;
|
||||
}
|
||||
if (!hasLocal && !hasOrigin) {
|
||||
throw new Error(`Base branch not found locally or on origin: ${normalizedBaseRef}`);
|
||||
return resolveCommitComparisonRef(cwd, baseRef);
|
||||
}
|
||||
|
||||
const { stdout } = await runGitCommand(
|
||||
@@ -2239,8 +2254,8 @@ export async function getCheckoutSnapshotFacts(
|
||||
};
|
||||
}
|
||||
|
||||
const PER_FILE_DIFF_MAX_BYTES = 1024 * 1024; // 1MB
|
||||
const TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB
|
||||
export const PER_FILE_DIFF_MAX_BYTES = 1024 * 1024; // 1MB
|
||||
export const TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB
|
||||
const CHECKOUT_DIFF_FRAME_HEADROOM_BYTES = 1024 * 1024;
|
||||
// Temporary until diffs load lazily per file. The Paseo relay's 32 MiB frame limit is
|
||||
// binding: string frames are encrypted and base64-encoded. Reserve 1 MiB plaintext for
|
||||
@@ -2301,7 +2316,7 @@ async function isLikelyBinaryFile(absolutePath: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectUntrackedFile(
|
||||
export async function inspectUntrackedFile(
|
||||
cwd: string,
|
||||
relativePath: string,
|
||||
): Promise<{ stat: FileStat; truncated: boolean }> {
|
||||
@@ -2399,6 +2414,10 @@ export async function getCheckoutStatus(
|
||||
const baseRef = facts.resolvedBaseRef;
|
||||
const mainRepoRoot = facts.mainRepoRoot;
|
||||
const factsContext = { ...context, facts };
|
||||
// Ref-derived counts skip the current base branch; the diff consumer still needs its ref.
|
||||
const comparisonBaseRef = baseRef
|
||||
? await resolveQualifiedComparisonBaseRef(cwd, baseRef, factsContext).catch(() => null)
|
||||
: null;
|
||||
const aheadBehind =
|
||||
baseRef && currentBranch
|
||||
? await getAheadBehind(cwd, baseRef, currentBranch, factsContext)
|
||||
@@ -2421,6 +2440,7 @@ export async function getCheckoutStatus(
|
||||
isDirty,
|
||||
baseRef: displayBaseRef ?? baseRef,
|
||||
baseRefExact: baseRef,
|
||||
comparisonBaseRef,
|
||||
aheadBehind,
|
||||
upstreamRef,
|
||||
aheadOfOrigin,
|
||||
@@ -2440,6 +2460,7 @@ export async function getCheckoutStatus(
|
||||
isDirty,
|
||||
baseRef: displayBaseRef,
|
||||
baseRefExact: baseRef,
|
||||
comparisonBaseRef,
|
||||
aheadBehind,
|
||||
upstreamRef,
|
||||
aheadOfOrigin,
|
||||
@@ -2804,6 +2825,7 @@ async function readCommitRenames(cwd: string, sha: string): Promise<ReadonlyMap<
|
||||
* binary-only (no textual hunks). Throws on git failure (e.g. an unknown sha),
|
||||
* which the caller maps to a typed checkout error.
|
||||
*/
|
||||
// COMPAT(diffSubscription): added in v0.15.0, remove after 2027-05-06.
|
||||
export async function getCommitFileDiff({
|
||||
cwd,
|
||||
sha,
|
||||
@@ -3570,6 +3592,21 @@ async function resolveCheckoutDiffRefs(
|
||||
if (compare.mode === "uncommitted") {
|
||||
return { baseRef: "HEAD", includeUntracked: true };
|
||||
}
|
||||
const bestBaseRef = await resolveCheckoutDiffBase(cwd, compare, context);
|
||||
if (!bestBaseRef) return null;
|
||||
return {
|
||||
baseRef: (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef,
|
||||
targetRef: compare.mode === "base" ? "HEAD" : undefined,
|
||||
includeUntracked: compare.mode === "all",
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveCheckoutDiffBase(
|
||||
cwd: string,
|
||||
compare: CheckoutDiffCompare,
|
||||
context?: CheckoutContext,
|
||||
): Promise<string | null> {
|
||||
if (compare.mode === "uncommitted") return "HEAD";
|
||||
const { storedBaseRef, resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
|
||||
const baseRef = resolveOperationBaseRef({
|
||||
storedBaseRef,
|
||||
@@ -3579,17 +3616,12 @@ async function resolveCheckoutDiffRefs(
|
||||
if (!baseRef) {
|
||||
return null;
|
||||
}
|
||||
const bestBaseRef = await resolveBestComparisonBaseRef(cwd, baseRef);
|
||||
return {
|
||||
baseRef: (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef,
|
||||
targetRef: compare.mode === "base" ? "HEAD" : undefined,
|
||||
includeUntracked: compare.mode === "all",
|
||||
};
|
||||
return resolveBestComparisonBaseRef(cwd, baseRef);
|
||||
}
|
||||
|
||||
// Git's working-tree diff ignores files absent from the index, even when they
|
||||
// exist in the comparison tree. Restore those entries only in a private index.
|
||||
async function prepareAllChangesIndex(cwd: string, baseRef: string) {
|
||||
export async function prepareAllChangesIndex(cwd: string, baseRef: string) {
|
||||
const untracked = await runGitCommand(["ls-files", "-z", "--others", "--exclude-standard"], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
@@ -3612,21 +3644,41 @@ async function prepareAllChangesIndex(cwd: string, baseRef: string) {
|
||||
entries += result.stdout;
|
||||
}
|
||||
if (!entries) return null;
|
||||
const directory = await mkdtemp(resolve(tmpdir(), "paseo-all-changes-"));
|
||||
const snapshot = await snapshotComparisonIndex(cwd);
|
||||
try {
|
||||
await runGitCommand(["update-index", "-z", "--index-info"], {
|
||||
cwd,
|
||||
input: entries,
|
||||
envOverlay: { ...READ_ONLY_GIT_ENV, GIT_INDEX_FILE: snapshot.indexFile },
|
||||
});
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
await snapshot.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// write-tree rewrites its index. A private snapshot prevents an index read from
|
||||
// invalidating the observation that requested it.
|
||||
export async function snapshotComparisonIndex(cwd: string) {
|
||||
const directory = await mkdtemp(resolve(tmpdir(), "paseo-comparison-index-"));
|
||||
const indexFile = resolve(directory, "index");
|
||||
try {
|
||||
const index = await runGitCommand(["rev-parse", "--git-path", "index"], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const indexPath = resolveGitRevParsePath(cwd, index.stdout);
|
||||
if (!indexPath) throw new Error("Git did not return an index path");
|
||||
await copyFile(indexPath, indexFile);
|
||||
await runGitCommand(["update-index", "-z", "--index-info"], {
|
||||
cwd,
|
||||
input: entries,
|
||||
envOverlay: { ...READ_ONLY_GIT_ENV, GIT_INDEX_FILE: indexFile },
|
||||
});
|
||||
const source = resolveGitRevParsePath(cwd, index.stdout);
|
||||
if (!source) throw new Error("Git did not return an index path");
|
||||
try {
|
||||
await copyFile(source, indexFile);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error;
|
||||
await runGitCommand(["read-tree", "--empty"], {
|
||||
cwd,
|
||||
envOverlay: { ...READ_ONLY_GIT_ENV, GIT_INDEX_FILE: indexFile },
|
||||
});
|
||||
}
|
||||
return { indexFile, cleanup: () => rm(directory, { recursive: true, force: true }) };
|
||||
} catch (error) {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { runGitCommandBytes } from "./run-git-command.js";
|
||||
import { runGitCommandBytes, runGitCommand } from "./run-git-command.js";
|
||||
|
||||
// Match the existing git-show ceiling. Oversized batches use bounded per-file reads.
|
||||
const MAX_CONTENT_BYTES = 20 * 1024 * 1024;
|
||||
@@ -46,3 +46,17 @@ export async function readGitFileContents(
|
||||
if (offset !== result.stdout.length) throw new Error("Unexpected git object output");
|
||||
return contents;
|
||||
}
|
||||
|
||||
export async function readGitFileContentAtRef(
|
||||
cwd: string,
|
||||
ref: string,
|
||||
path: string,
|
||||
): Promise<string | null> {
|
||||
const result = await runGitCommand(["show", `${ref}:${path}`], {
|
||||
cwd,
|
||||
envOverlay: { GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C" },
|
||||
acceptExitCodes: [0, 128],
|
||||
});
|
||||
if (result.exitCode !== 0 || result.truncated) return null;
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from "./git-process-scheduler.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_MAX_OUTPUT_BYTES = 20 * 1024 * 1024; // 20MB
|
||||
export const DEFAULT_MAX_OUTPUT_BYTES = 20 * 1024 * 1024; // 20MB
|
||||
const DEFAULT_STDERR_LIMIT = 2048;
|
||||
|
||||
let gitProcessScheduler = new GitProcessScheduler(resolveGitProcessPolicy({ env: process.env }));
|
||||
|
||||
Reference in New Issue
Block a user