fix(diff): normalize line endings to prevent full-file diffs on CRLF files

Christian Rocha created

CRLF line endings caused diffs to show every line as changed. The root
issues: GenerateDiff never normalized inputs, edit/multiedit stored
CRLF-converted content in metadata and history while old content stayed
LF, and write tool diffed raw file bytes against LF input.

💘 Generated with Crush

Assisted-by: GLM-5 via Crush <crush@charm.land>

Change summary

internal/agent/tools/edit.go      |  10 +-
internal/agent/tools/multiedit.go |   5 
internal/agent/tools/write.go     |   2 
internal/diff/diff.go             |   5 +
internal/diff/diff_test.go        | 131 +++++++++++++++++++++++++++++++++
5 files changed, 145 insertions(+), 8 deletions(-)

Detailed changes

internal/agent/tools/edit.go 🔗

@@ -274,6 +274,7 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
 		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
 	}
 
+	normalizedNewContent := newContent
 	if isCrlf {
 		newContent, _ = fsext.ToWindowsLineEndings(newContent)
 	}
@@ -300,7 +301,7 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
 		}
 	}
 	// Store the new version
-	_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, newContent)
+	_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, normalizedNewContent)
 	if err != nil {
 		slog.Error("Error creating file history version", "error", err)
 	}
@@ -311,7 +312,7 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
 		fantasy.NewTextResponse("Content deleted from file: "+filePath),
 		EditResponseMetadata{
 			OldContent: oldContent,
-			NewContent: newContent,
+			NewContent: normalizedNewContent,
 			Additions:  additions,
 			Removals:   removals,
 		},
@@ -405,6 +406,7 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
 		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
 	}
 
+	normalizedNewContent := newContent
 	if isCrlf {
 		newContent, _ = fsext.ToWindowsLineEndings(newContent)
 	}
@@ -431,7 +433,7 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
 		}
 	}
 	// Store the new version
-	_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, newContent)
+	_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, normalizedNewContent)
 	if err != nil {
 		slog.Error("Error creating file history version", "error", err)
 	}
@@ -442,7 +444,7 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
 		fantasy.NewTextResponse("Content replaced in file: "+filePath),
 		EditResponseMetadata{
 			OldContent: oldContent,
-			NewContent: newContent,
+			NewContent: normalizedNewContent,
 			Additions:  additions,
 			Removals:   removals,
 		}), nil

internal/agent/tools/multiedit.go 🔗

@@ -340,6 +340,7 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
 		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
 	}
 
+	normalizedNewContent := currentContent
 	if isCrlf {
 		currentContent, _ = fsext.ToWindowsLineEndings(currentContent)
 	}
@@ -367,7 +368,7 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
 	}
 
 	// Store the new version
-	_, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, currentContent)
+	_, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, normalizedNewContent)
 	if err != nil {
 		slog.Error("Error creating file history version", "error", err)
 	}
@@ -385,7 +386,7 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
 		fantasy.NewTextResponse(message),
 		MultiEditResponseMetadata{
 			OldContent:   oldContent,
-			NewContent:   currentContent,
+			NewContent:   normalizedNewContent,
 			Additions:    additions,
 			Removals:     removals,
 			EditsApplied: editsApplied,

internal/agent/tools/write.go 🔗

@@ -99,7 +99,7 @@ func NewWriteTool(
 			if fileInfo != nil && !fileInfo.IsDir() {
 				oldBytes, readErr := os.ReadFile(filePath)
 				if readErr == nil {
-					oldContent = string(oldBytes)
+					oldContent, _ = fsext.ToUnixLineEndings(string(oldBytes))
 				}
 			}
 

internal/diff/diff.go 🔗

@@ -6,8 +6,11 @@ import (
 	"github.com/aymanbagabas/go-udiff"
 )
 
-// GenerateDiff creates a unified diff from two file contents
+// GenerateDiff creates a unified diff from two file contents.
 func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, int) {
+	beforeContent = strings.ReplaceAll(beforeContent, "\r\n", "\n")
+	afterContent = strings.ReplaceAll(afterContent, "\r\n", "\n")
+
 	fileName = strings.TrimPrefix(fileName, "/")
 
 	var (

internal/diff/diff_test.go 🔗

@@ -0,0 +1,131 @@
+package diff
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestGenerateDiff(t *testing.T) {
+	t.Parallel()
+
+	before := "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"Hello, World!\")\n\tfmt.Println(\"Line 2\")\n}\n"
+	after := "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"Hello, Go!\")\n\tfmt.Println(\"Line 2\")\n}\n"
+
+	t.Run("LF_before_LF_after", func(t *testing.T) {
+		t.Parallel()
+
+		diff, additions, removals := GenerateDiff(before, after, "main.go")
+
+		require.Equal(t, 1, additions)
+		require.Equal(t, 1, removals)
+		require.Contains(t, diff, "-\tfmt.Println(\"Hello, World!\")")
+		require.Contains(t, diff, "+\tfmt.Println(\"Hello, Go!\")")
+	})
+
+	t.Run("CRLF_before_LF_after", func(t *testing.T) {
+		t.Parallel()
+
+		crlfBefore := "package main\r\n\r\nimport \"fmt\"\r\n\r\nfunc main() {\r\n\tfmt.Println(\"Hello, World!\")\r\n\tfmt.Println(\"Line 2\")\r\n}\r\n"
+		diff, additions, removals := GenerateDiff(crlfBefore, after, "main.go")
+
+		require.Equal(t, 1, additions, "CRLF before should not inflate counts")
+		require.Equal(t, 1, removals, "CRLF before should not inflate counts")
+		require.Contains(t, diff, "-\tfmt.Println(\"Hello, World!\")")
+		require.Contains(t, diff, "+\tfmt.Println(\"Hello, Go!\")")
+	})
+
+	t.Run("LF_before_CRLF_after", func(t *testing.T) {
+		t.Parallel()
+
+		crlfAfter := "package main\r\n\r\nimport \"fmt\"\r\n\r\nfunc main() {\r\n\tfmt.Println(\"Hello, Go!\")\r\n\tfmt.Println(\"Line 2\")\r\n}\r\n"
+		diff, additions, removals := GenerateDiff(before, crlfAfter, "main.go")
+
+		require.Equal(t, 1, additions, "CRLF after should not inflate counts")
+		require.Equal(t, 1, removals, "CRLF after should not inflate counts")
+		require.Contains(t, diff, "-\tfmt.Println(\"Hello, World!\")")
+		require.Contains(t, diff, "+\tfmt.Println(\"Hello, Go!\")")
+	})
+
+	t.Run("CRLF_before_CRLF_after", func(t *testing.T) {
+		t.Parallel()
+
+		crlfBefore := "package main\r\n\r\nimport \"fmt\"\r\n\r\nfunc main() {\r\n\tfmt.Println(\"Hello, World!\")\r\n\tfmt.Println(\"Line 2\")\r\n}\r\n"
+		crlfAfter := "package main\r\n\r\nimport \"fmt\"\r\n\r\nfunc main() {\r\n\tfmt.Println(\"Hello, Go!\")\r\n\tfmt.Println(\"Line 2\")\r\n}\r\n"
+		diff, additions, removals := GenerateDiff(crlfBefore, crlfAfter, "main.go")
+
+		require.Equal(t, 1, additions)
+		require.Equal(t, 1, removals)
+		require.Contains(t, diff, "-\tfmt.Println(\"Hello, World!\")")
+		require.Contains(t, diff, "+\tfmt.Println(\"Hello, Go!\")")
+	})
+
+	t.Run("mixed_line_endings", func(t *testing.T) {
+		t.Parallel()
+
+		mixedBefore := "line1\r\nline2\nline3\r\nline4\n"
+		mixedAfter := "line1\nline2\nchanged\nline4\n"
+		diff, additions, removals := GenerateDiff(mixedBefore, mixedAfter, "test.txt")
+
+		require.Equal(t, 1, additions)
+		require.Equal(t, 1, removals)
+		require.Contains(t, diff, "-line3")
+		require.Contains(t, diff, "+changed")
+	})
+
+	t.Run("identical_content_different_endings", func(t *testing.T) {
+		t.Parallel()
+
+		lfContent := "line1\nline2\nline3\n"
+		crlfContent := "line1\r\nline2\r\nline3\r\n"
+		diff, additions, removals := GenerateDiff(lfContent, crlfContent, "test.txt")
+
+		require.Equal(t, 0, additions, "identical content with different line endings should produce no diff")
+		require.Equal(t, 0, removals, "identical content with different line endings should produce no diff")
+		require.Empty(t, diff)
+	})
+
+	t.Run("tabs_are_not_normalized", func(t *testing.T) {
+		t.Parallel()
+
+		tabContent := "\tfoo\n"
+		spaceContent := "    foo\n"
+		diff, additions, removals := GenerateDiff(tabContent, spaceContent, "test.txt")
+
+		require.Equal(t, 1, additions, "tab vs space should be a real diff")
+		require.Equal(t, 1, removals, "tab vs space should be a real diff")
+		require.NotEmpty(t, diff)
+	})
+
+	t.Run("empty_before", func(t *testing.T) {
+		t.Parallel()
+
+		diff, additions, removals := GenerateDiff("", "line1\nline2\n", "new.txt")
+
+		require.Equal(t, 2, additions)
+		require.Equal(t, 0, removals)
+		require.Contains(t, diff, "+line1")
+		require.Contains(t, diff, "+line2")
+	})
+
+	t.Run("empty_after", func(t *testing.T) {
+		t.Parallel()
+
+		diff, additions, removals := GenerateDiff("line1\nline2\n", "", "deleted.txt")
+
+		require.Equal(t, 0, additions)
+		require.Equal(t, 2, removals)
+		require.Contains(t, diff, "-line1")
+		require.Contains(t, diff, "-line2")
+	})
+
+	t.Run("leading_slash_trimmed", func(t *testing.T) {
+		t.Parallel()
+
+		diff, _, _ := GenerateDiff("a\n", "b\n", "/src/main.go")
+
+		require.Contains(t, diff, "a/src/main.go")
+		require.Contains(t, diff, "b/src/main.go")
+		require.NotContains(t, diff, "a//src/main.go")
+	})
+}