diff --git a/packages/protocol/src/binary-frames/diff.test.ts b/packages/protocol/src/binary-frames/diff.test.ts index dc8aea6ff8..fcc2885521 100644 --- a/packages/protocol/src/binary-frames/diff.test.ts +++ b/packages/protocol/src/binary-frames/diff.test.ts @@ -238,3 +238,33 @@ test("roundtrips generated entry lists across varint boundaries", () => { expect(decodeDiffFrame(encodeDiffFrame(begin))).toEqual(begin); } }); + +test.each([true, false])( + "preserves BOM-prefixed paths and source lines with runs=%s", + (highlight) => { + const frame = { + opcode: 0x41 as const, + slot: 0, + revision: 1, + files: [ + { + path: "\uFEFFbom.ts", + hunks: [ + { + oldStart: 1, + oldCount: 1, + newStart: 1, + newCount: 1, + lines: (["add", "remove", "context"] as const).map((type) => ({ + type, + content: "\uFEFFvalue", + runs: highlight ? [{ style: null, length: 6 }] : undefined, + })), + }, + ], + }, + ], + }; + expect(decodeDiffFrame(encodeDiffFrame(frame))).toEqual(frame); + }, +); diff --git a/packages/protocol/src/binary-frames/diff.ts b/packages/protocol/src/binary-frames/diff.ts index 4e2457e29e..9f11450b7d 100644 --- a/packages/protocol/src/binary-frames/diff.ts +++ b/packages/protocol/src/binary-frames/diff.ts @@ -93,7 +93,7 @@ export type DiffFrame = DiffBegin | DiffBodies | DiffEnd; export class InvalidDiffFrameError extends Error {} const textEncoder = new TextEncoder(); -const textDecoder = new TextDecoder("utf-8", { fatal: true }); +const textDecoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); class Writer { private bytes: number[] = []; diff --git a/packages/server/src/server/diff/git.test.ts b/packages/server/src/server/diff/git.test.ts index cd16a0f11e..3bfd447d05 100644 --- a/packages/server/src/server/diff/git.test.ts +++ b/packages/server/src/server/diff/git.test.ts @@ -450,3 +450,72 @@ test.each(["worktree", "index", "rev"] as const)( } }, ); + +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 }); + } + }, +); diff --git a/packages/server/src/server/diff/git.ts b/packages/server/src/server/diff/git.ts index 1359917bd7..e8bda9bdb7 100644 --- a/packages/server/src/server/diff/git.ts +++ b/packages/server/src/server/diff/git.ts @@ -268,10 +268,20 @@ async function readComparisonList( const bytes = Buffer.byteLength(result.stdout); if (result.truncated || bytes > remainingBytes || bytes > maxPatchBytes) return { kind: "too_large" }; - const parsed = parseDiff(result.stdout).find( + // 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 (!parsed) return { kind: "ready", body: { path: file.entry.path, hunks: [] }, bytes }; + 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++; diff --git a/packages/server/src/server/git-filename-diffs.e2e.test.ts b/packages/server/src/server/git-filename-diffs.e2e.test.ts index c32ee040b8..57e7718e1b 100644 --- a/packages/server/src/server/git-filename-diffs.e2e.test.ts +++ b/packages/server/src/server/git-filename-diffs.e2e.test.ts @@ -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,45 @@ 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 }); + } +});