Skip to content

fix(core): keep trailing blank lines and CRLF endings when applying a patch - #62477

Open
kitlangton wants to merge 2 commits into
devfrom
patch-keep-line-endings
Open

kitlangton wants to merge 2 commits into
devfrom
patch-keep-line-endings

Conversation

@kitlangton

@kitlangton kitlangton commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Editing a file with the patch tool no longer deletes its trailing blank line or turns its Windows (CRLF) line endings into a mix of CRLF and LF.
Both bugs were in the step that applies a patch's hunks to the file's current text. Files that end in a single newline and use LF throughout are not affected.

❌ Before

  1. A file ends in a blank line (line 1\nline 2\n\n); a patch changes line 2.
  2. ❌ The result is line 1\nLINE 2\n: the blank line is gone. A hunk that adds a blank line at the end is dropped the same way.
  3. A file uses CRLF on every line (a\r\nb\r\nc\r\n); a patch changes b.
  4. ❌ Every line the hunk writes, including the context lines it echoes back, comes out with LF, so the file ends up with mixed endings.

✅ After

  1. The final newline is always restored on its own, so blank lines at the end that the patch didn't touch stay, and a blank line a hunk adds is kept.
  2. When every line in the file ends in CRLF, the lines a hunk writes get CRLF back. Files with mixed endings are left exactly as they were.
  apply hunks to lines
- if last line != "": add final newline   # an existing trailing "" swallowed it
+ if every line in the file ended in CRLF: put "\r" back on lines that lack it
+ always add the final newline

📊 Proof

The patch… Before After
changes a line in a file ending in a blank line ❌ blank line deleted ✅ kept
adds a blank line at the end of a file ❌ dropped ✅ kept
changes a line in an all-CRLF file ❌ edited lines become LF ✅ stay CRLF
changes a line in a mixed-ending file ✅ left as is ✅ left as is

Both new tests in patch.test.ts fail on dev and pass here.

👀 Read: the end of derive in packages/util/src/patch.ts. The final newline is now added unconditionally; that's safe because the split at the top already removed the real terminator, and after the CRLF pass the last line is never empty. This differs on purpose from upstream apply_patch, which has the same conditional. The rest is routine.

🔍 Found in a code audit · no user reports

Checks run
  • bun test test/patch.test.ts test/tool-patch.test.ts (packages/core): 90 pass
  • Same patch.test.ts with dev's patch.ts: 2 fail (the two new tests)
  • Pre-push typecheck: 32 packages pass

Added by the opencode bot (AI-generated).

Fixes #45926

Related: #45912

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.
parse() strips one carriage return from CRLF patch lines, so every hunk
produces LF-only lines. On a file whose lines all end with CRLF, derive()
therefore rewrote the edited lines, and any context lines it echoed back,
with LF endings, leaving the file with mixed endings and changing lines the
hunk only meant to anchor. Restore the file's own terminators on the lines a
hunk wrote, and leave files with mixed endings untouched.

@opencodejr opencodejr Bot 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.

The fix looks good. I reproduced both bugs on dev by having the simulated model patch an all-CRLF file and a file ending in a blank line, then printing both with cat -A. On dev, the lines the hunk wrote turned LF while the rest of the file stayed CRLF, and the trailing blank line was deleted. With this PR, the CRLF file stays CRLF throughout, the blank line stays, and the TUI diffs show only the changed lines. I also checked some edge cases directly in Patch.derive: a CRLF file with no final newline, a file that is only \n, deleting a whole CRLF file, and a CRLF file with a BOM. All behave sensibly. The ACP permission previews also use Patch.derive and pick up the same fix.

The patch and tool-patch tests pass (90). The typecheck script fails with "Script not found" on dev too, so that isn't caused by this PR; typecheck:tsc in packages/util passes.

Public API: unchanged. Only what the patch tool writes to files changes.

No blocking concerns.

Fixes #45926
Related: #45912

Before

After

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

apply_patch turns CRLF files into mixed line endings

1 participant