Skip to content

fix: don't treat a literal CR at EOF as a Windows line ending - #701

Merged
ExplodingCabbage merged 11 commits into
kpdecker:masterfrom
maximilliangrand:fix/autoconvert-literal-cr-at-eof
Aug 18, 2026
Merged

fix: don't treat a literal CR at EOF as a Windows line ending#701
ExplodingCabbage merged 11 commits into
kpdecker:masterfrom
maximilliangrand:fix/autoconvert-literal-cr-at-eof

Conversation

@maximilliangrand

Copy link
Copy Markdown
Contributor

Bug

applyPatch corrupts output when a Unix source is patched with a patch whose final line's content ends in a literal carriage return and has no trailing newline. The \r is silently dropped.

import { structuredPatch, applyPatch } from 'diff';
const oldFile = 'line1\nline2\n';
const newFile = 'line1\nline2\nline3\r';
const patch = structuredPatch('f', 'f', oldFile, newFile, undefined, undefined, {context: 0});
applyPatch(oldFile, patch); // => 'line1\nline2\nline3'  (the '\r' is lost)

I found this by fuzzing random source/target pairs through structuredPatch -> applyPatch; 80 of 200k pairs failed to round-trip, all of this shape.

Cause

isWin and winToUnix treat any patch line ending in \r as a Windows CRLF line ending. But a line ending in \r that is immediately followed by a \ No newline at end of file marker is not a CRLF ending (Windows endings are \r\n) - it's a literal carriage return in the final line's content. A genuine Windows no-newline-at-EOF line never ends in \r.

So isWin misclassifies such a patch as Windows, and when applyPatch (with autoConvertLineEndings on) converts it to match the Unix source, winToUnix strips the \r.

unixToWin already guards this exact case (hunk.lines[i + 1]?.startsWith('\\')), so it won't add a \r to a no-newline final line. This change adds the symmetric guard to isWin and winToUnix.

Fix

Ignore a trailing \r that sits immediately before a \ No newline at end of file marker when deciding whether a patch is Windows / when stripping CRs.

Added tests: a winToUnix unit test, an isWin unit test, and an applyPatch round-trip regression test. Full suite passes with 100% coverage.

`isWin` and `winToUnix` treated any patch line ending in `\r` as a Windows
line ending. But a line whose content ends in `\r` and is immediately
followed by a `\ No newline at end of file` marker is not a CRLF ending
(those are `\r\n`) - it's a literal carriage return in the final line's
content. A genuine Windows no-newline-at-EOF line never ends in `\r`.

Because of this, `isWin` misclassified such patches as Windows, and when
`applyPatch` auto-converted them to match a Unix source, `winToUnix`
stripped the `\r`, silently corrupting the output. For example
`applyPatch('line1\nline2\n', structuredPatch('f','f','line1\nline2\n',
'line1\nline2\nline3\r', undefined, undefined, {context: 0}))` returned
`line1\nline2\nline3` instead of `line1\nline2\nline3\r`.

`unixToWin` already guards against this case (it won't add `\r` to a
no-newline final line); this makes `isWin` and `winToUnix` symmetric.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ExplodingCabbage

Copy link
Copy Markdown
Collaborator

when a Unix source is patched with a patch whose final line's content ends in a literal carriage return and has no trailing newline

It's more specific than that, no? Roughly, the patch also needs to not feature any other lines, in order for isWin to return true - i.e. it needs to be a single-line patch. Still a legit bug, though!

@ExplodingCabbage
ExplodingCabbage requested a balanced review from Copilot August 17, 2026 12:35

@ExplodingCabbage ExplodingCabbage left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thank you! Will just see if Copilot offers any useful input before I merge.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes applyPatch preserving literal carriage returns at EOF during line-ending conversion.

Changes:

  • Guards Windows detection/conversion around no-newline markers.
  • Adds unit and regression coverage.
  • Documents the fix in release notes.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
src/patch/line-endings.ts Preserves literal EOF carriage returns.
test/patch/line-endings.js Tests conversion and detection behavior.
test/patch/apply.js Adds an applyPatch regression test.
release-notes.md Documents the bug fix.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/patch/line-endings.ts
// A trailing '\r' before a "\ No newline at end of file" marker is a literal carriage
// return in the final line's content, not a Windows line ending, so it isn't evidence that
// the patch uses Windows line endings.
(line, i) => line.endsWith('\r') && !hunk.lines[i + 1]?.startsWith('\\')
Copilot AI and others added 6 commits August 17, 2026 14:06
Co-authored-by: ExplodingCabbage <2358339+ExplodingCabbage@users.noreply.github.com>
…n CRLF source; update release notes

Co-authored-by: ExplodingCabbage <2358339+ExplodingCabbage@users.noreply.github.com>
…on' into fix/autoconvert-literal-cr-at-eof

Conflicts:
	release-notes.md
@ExplodingCabbage
ExplodingCabbage merged commit 2d923ce into kpdecker:master Aug 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants