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

Decode each string without consuming its leading BOM. File-type replacements emit multiple patch sections for one listed path, so retain all matching hunks. Assert the optional rename metadata in the existing commit-file journey.
This commit is contained in:
Mohamed Boudra
2026-11-06 07:24:19 +01:00
parent cee05e3c6c
commit 0a9e678286
5 changed files with 156 additions and 3 deletions
@@ -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);
},
);
+1 -1
View File
@@ -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[] = [];
@@ -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 });
}
},
);
+12 -2
View File
@@ -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++;
@@ -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 });
}
});