From 1e55ff6ed922a23b4e423040e07240a2544f0b2a Mon Sep 17 00:00:00 2001 From: breken-ai <312387581+breken-ai@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:24:40 -0700 Subject: [PATCH] Keep a CRLF file's line endings when an edit lands in it Hunks are written with plain newlines, so in a CRLF file every line matched only through locate's whitespace-insensitive pass, and the lines written back in its place came from the hunk without their CR. Each edit left the lines it touched ending in LF inside an otherwise CRLF file, which is every file in a Git for Windows checkout with core.autocrlf. A file whose every line ends in CRLF is now edited as LF and converted back; LF, mixed and new files are applied exactly as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/repo/compact_test.go | 35 +++++++++++++++++++++++++++++++++++ internal/repo/patch.go | 32 +++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/internal/repo/compact_test.go b/internal/repo/compact_test.go index 69124e1..2374434 100644 --- a/internal/repo/compact_test.go +++ b/internal/repo/compact_test.go @@ -777,3 +777,38 @@ func TestAPureDeletionIsNeverTruncated(t *testing.T) { t.Fatalf("the tail was dropped: %q", got) } } + +func TestCompactKeepsAFilesCRLFLineEndings(t *testing.T) { + // A Windows checkout with core.autocrlf (Git for Windows' default) + // has CRLF on every line. The patch is written with plain newlines, + // and matching already tolerates that; what it wrote back did not, + // leaving every line the hunk touched with LF in an otherwise CRLF + // file. + repository := project(t, map[string]string{ + "app.py": "import os\r\n\r\ndef start():\r\n server.run(config)\r\n return server\r\n", + }) + err := repository.Apply("@app.py\n def start():\n- server.run(config)\n+ server.run(config, debug=True)\n return server\n") + if err != nil { + t.Fatal(err) + } + want := "import os\r\n\r\ndef start():\r\n server.run(config, debug=True)\r\n return server\r\n" + if got := read(t, repository, "app.py"); got != want { + t.Fatalf("line endings not kept:\n got %q\nwant %q", got, want) + } +} + +func TestApplyPatchFormatKeepsAFilesCRLFLineEndings(t *testing.T) { + // The same kind of file edited through the apply_patch format, whose + // lines may carry the CR themselves when the model copied them from + // the file byte for byte. + repository := project(t, map[string]string{ + "a.txt": "one\r\ntwo\r\nthree\r\n", + }) + err := repository.Apply("*** Begin Patch\n*** Update File: a.txt\n@@\n one\r\n-two\r\n+TWO\r\n three\n*** End Patch\n") + if err != nil { + t.Fatal(err) + } + if got, want := read(t, repository, "a.txt"), "one\r\nTWO\r\nthree\r\n"; got != want { + t.Fatalf("line endings not kept:\n got %q\nwant %q", got, want) + } +} diff --git a/internal/repo/patch.go b/internal/repo/patch.go index 32e324b..7c82ba9 100644 --- a/internal/repo/patch.go +++ b/internal/repo/patch.go @@ -422,7 +422,7 @@ func (repository *Repository) applyByContent(patch string) error { current = read originals[file.path] = read } - content, hunkFailures := applyHunks(current, file.hunks) + content, hunkFailures := applyKeepingLineEndings(current, file.hunks) for _, failure := range hunkFailures { // The file is known here and not where the failure was // raised, so it is filled in on the way past. @@ -483,6 +483,36 @@ func changesNothing(current hunk) bool { return true } +// applyKeepingLineEndings is applyHunks for a file that may use CRLF. +// Hunks are written with plain newlines, so in a CRLF file every line +// only matched through locate's whitespace-insensitive pass, and the +// lines put back in its place came from the hunk without their CR: each +// edit left the lines it touched with LF inside an otherwise CRLF file. +// A file whose every line ends in CRLF is edited as LF and converted +// back; anything else (LF, mixed, a new file) is applied as it stands. +func applyKeepingLineEndings(content string, hunks []hunk) (string, []*HunkError) { + breaks := strings.Count(content, "\n") + if breaks == 0 || strings.Count(content, "\r\n") != breaks { + return applyHunks(content, hunks) + } + withoutCR := func(lines []string) []string { + trimmed := make([]string, len(lines)) + for index, line := range lines { + trimmed[index] = strings.TrimSuffix(line, "\r") + } + return trimmed + } + trimmed := make([]hunk, len(hunks)) + for index, current := range hunks { + trimmed[index] = hunk{before: withoutCR(current.before), after: withoutCR(current.after)} + for _, op := range current.ops { + trimmed[index].ops = append(trimmed[index].ops, patchOp{kind: op.kind, text: strings.TrimSuffix(op.text, "\r")}) + } + } + placed, failures := applyHunks(strings.ReplaceAll(content, "\r\n", "\n"), trimmed) + return strings.ReplaceAll(placed, "\n", "\r\n"), failures +} + func applyHunks(content string, hunks []hunk) (string, []*HunkError) { lines := strings.Split(content, "\n") var failures []*HunkError