From cd163d404bb094274cb335d48db231eb4f96c3cd Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 17 Mar 2026 17:57:00 +0000 Subject: [PATCH] fix(site): strip SVN-style Index headers from diffs before parsing (#23179) --- .../components/ai-elements/tool/utils.test.ts | 99 ++++++++++++++++++- site/src/components/ai-elements/tool/utils.ts | 17 +++- 2 files changed, 110 insertions(+), 6 deletions(-) diff --git a/site/src/components/ai-elements/tool/utils.test.ts b/site/src/components/ai-elements/tool/utils.test.ts index b899712bcb..aa0b2c6a06 100644 --- a/site/src/components/ai-elements/tool/utils.test.ts +++ b/site/src/components/ai-elements/tool/utils.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { BORDER_BG_STYLE, buildEditDiff, @@ -22,6 +22,7 @@ import { parseArgs, parseEditFilesArgs, shortDurationMs, + stripSvnIndexHeaders, toProviderLabel, } from "./utils"; @@ -420,6 +421,20 @@ describe("buildWriteFileDiff", () => { // That's 1 empty-string line, which is still a valid line. expect(diff).not.toBeNull(); }); + + it("does not emit console errors from SVN-style Index headers", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const diff = buildWriteFileDiff( + "src/components/Example.tsx", + "export default function Example() {\n return
;\n}\n", + ); + expect(diff).not.toBeNull(); + expect(spy).not.toHaveBeenCalled(); + } finally { + spy.mockRestore(); + } + }); }); describe("getWriteFileDiff", () => { @@ -520,6 +535,26 @@ describe("buildEditDiff", () => { expect(diff).not.toBeNull(); }); + it("does not emit console errors for multi-edit diffs", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const diff = buildEditDiff( + "/home/coder/coder/site/src/pages/AgentsPage/AgentsSidebar.tsx", + [ + { search: "const a = 1;", replace: "const a = 2;" }, + { search: "const b = 3;", replace: "const b = 4;" }, + ], + ); + expect(diff).not.toBeNull(); + // Before the fix, @pierre/diffs logged: + // parseLineType: Invalid firstChar: "I" + // processFile: invalid rawLine: Index: ... + expect(spy).not.toHaveBeenCalled(); + } finally { + spy.mockRestore(); + } + }); + it("strips leading slash from path", () => { const diff = buildEditDiff("/src/index.ts", [ { search: "old", replace: "new" }, @@ -570,6 +605,68 @@ describe("buildEditDiff", () => { }); }); +describe("stripSvnIndexHeaders", () => { + it("removes Index: headers from SVN-style patches", () => { + const input = [ + "Index: src/file.ts", + "===================================================================", + "--- src/file.ts", + "+++ src/file.ts", + "@@ -1,1 +1,1 @@", + "-old", + "+new", + "", + ].join("\n"); + const result = stripSvnIndexHeaders(input); + expect(result).not.toContain("Index:"); + expect(result).not.toContain("==="); + expect(result).toContain("--- src/file.ts"); + }); + + it("handles multiple Index: headers in concatenated patches", () => { + const input = [ + "Index: a.ts", + "===================================================================", + "--- a.ts", + "+++ a.ts", + "@@ -1,1 +1,1 @@", + "-old1", + "+new1", + "Index: b.ts", + "===================================================================", + "--- b.ts", + "+++ b.ts", + "@@ -1,1 +1,1 @@", + "-old2", + "+new2", + "", + ].join("\n"); + const result = stripSvnIndexHeaders(input); + // Both Index headers removed. + expect(result.match(/Index:/g)).toBeNull(); + // Diff content preserved. + expect(result).toContain("-old1"); + expect(result).toContain("+new2"); + }); + + it("is a no-op for git-style diffs", () => { + const gitDiff = [ + "diff --git a/file.ts b/file.ts", + "--- a/file.ts", + "+++ b/file.ts", + "@@ -1,1 +1,1 @@", + "-old", + "+new", + "", + ].join("\n"); + expect(stripSvnIndexHeaders(gitDiff)).toBe(gitDiff); + }); + + it("is a no-op for empty strings", () => { + expect(stripSvnIndexHeaders("")).toBe(""); + }); +}); + describe("constants", () => { it("COLLAPSED_OUTPUT_HEIGHT is 54", () => { expect(COLLAPSED_OUTPUT_HEIGHT).toBe(54); diff --git a/site/src/components/ai-elements/tool/utils.ts b/site/src/components/ai-elements/tool/utils.ts index be9d43da7e..90b3962e87 100644 --- a/site/src/components/ai-elements/tool/utils.ts +++ b/site/src/components/ai-elements/tool/utils.ts @@ -189,6 +189,15 @@ export function getFileViewerOptionsMinimal(isDark: boolean) { }; } +/** + * Strips SVN-style "Index:" headers that `Diff.createPatch()` + * emits but `@pierre/diffs` does not recognize as file + * boundaries. Left in place they leak into hunk bodies and + * trigger console errors. + */ +export const stripSvnIndexHeaders = (patch: string): string => + patch.replace(/^Index: .*\n={3,}\n/gm, ""); + export const DIFFS_FONT_STYLE = { "--diffs-font-family": '"Geist Mono Variable", monospace, monospace', "--diffs-header-font-family": '"Geist Variable", system-ui, sans-serif', @@ -261,7 +270,7 @@ export const buildWriteFileDiff = ( ): FileDiffMetadata | null => { if (!content) return null; const patch = Diff.createPatch(path, "", content, "", ""); - const parsed = parsePatchFiles(patch); + const parsed = parsePatchFiles(stripSvnIndexHeaders(patch)); if (!parsed.length || !parsed[0].files.length) return null; return parsed[0].files[0]; }; @@ -338,12 +347,10 @@ export const buildEditDiff = ( // All edits were skipped (empty search). Produce a // header-only patch so the parser still returns a file // entry with zero hunks. - patches.push( - `Index: ${diffPath}\n===================================================================\n--- ${diffPath}\n+++ ${diffPath}\n`, - ); + patches.push(`--- ${diffPath}\n+++ ${diffPath}\n`); } - const parsed = parsePatchFiles(patches.join("")); + const parsed = parsePatchFiles(stripSvnIndexHeaders(patches.join(""))); if (!parsed.length || !parsed[0].files.length) return null; return parsed[0].files[0]; };