From e1d210e20ca5bea3e5f8b48731d565e2ea2b04e2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 6 Nov 2026 17:18:07 -0800 Subject: [PATCH] fix(core): keep trailing blank lines a patch does not touch derive() split the file into lines, dropped the terminating empty element, and then restored a final newline only when the last line was not already empty. A file ending in a blank line therefore lost that blank line on every update, and a hunk adding an empty line at the end of a file had it silently dropped. Always restoring the terminator preserves the file's own trailing newlines while still adding one to a file that lacked it. --- packages/core/test/patch.test.ts | 9 +++++++++ packages/util/src/patch.ts | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/core/test/patch.test.ts b/packages/core/test/patch.test.ts index 22df2a13271..9c8577f1bf1 100644 --- a/packages/core/test/patch.test.ts +++ b/packages/core/test/patch.test.ts @@ -248,6 +248,15 @@ describe("Patch", () => { ).toBe("line 1\nline 2\nadded 1\nadded 2\n") }) + test("keeps trailing blank lines the patch did not touch", () => { + expect( + Patch.derive("update.txt", [{ oldLines: ["line 2"], newLines: ["LINE 2"] }], "line 1\nline 2\n\n").content, + ).toBe("line 1\nLINE 2\n\n") + expect( + Patch.derive("update.txt", [{ oldLines: ["line 1"], newLines: ["line 1", ""] }], "line 1\n").content, + ).toBe("line 1\n\n") + }) + test.each(["", "original\n"])("preserves equal-offset insertion order and frozen chunks for %j", (original) => { const chunks = Object.freeze([ Object.freeze({ oldLines: Object.freeze([]), newLines: Object.freeze(["first"]) }), diff --git a/packages/util/src/patch.ts b/packages/util/src/patch.ts index cef1c3132da..c0583c66352 100644 --- a/packages/util/src/patch.ts +++ b/packages/util/src/patch.ts @@ -131,7 +131,10 @@ export function derive(path: string, chunks: ReadonlyArray, ori if (lines.at(-1) === "") lines.pop() const replacements = computeReplacements(lines, path, chunks) for (const [start, remove, insert] of replacements.reverse()) lines.splice(start, remove, ...insert) - if (lines.at(-1) !== "") lines.push("") + // The split above lost the final newline as an empty element, and the file must end with one. Always + // restoring it keeps any other trailing blank lines the patch did not touch, which is why this is not + // conditional on the array's last line. + lines.push("") const next = Bom.split(lines.join("\n")) return { content: next.text, bom: source.bom || next.bom } }