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