@@ -4,6 +4,8 @@ import (
44 "bytes"
55 "fmt"
66 "io"
7+ "os"
8+ "path/filepath"
79 "regexp"
810 "strconv"
911 "strings"
@@ -15,6 +17,8 @@ import (
1517 "github.com/alecthomas/chroma/v2/styles"
1618 "github.com/charmbracelet/lipgloss"
1719 "github.com/charmbracelet/x/ansi"
20+ "github.com/go-git/go-git/v5"
21+ "github.com/go-git/go-git/v5/plumbing/object"
1822 "github.com/sergi/go-diff/diffmatchpatch"
1923)
2024
@@ -224,21 +228,6 @@ type ParseConfig struct {
224228// ParseOption defines a function that modifies a ParseConfig.
225229type ParseOption func(*ParseConfig)
226230
227−// NewParseConfig creates a ParseConfig with default values and applies any provided options.
228−func NewParseConfig(opts ...ParseOption) ParseConfig {
229− // Set default values
230− config := ParseConfig{
231− ContextSize: 3,
232− }
233−
234− // Apply all provided options
235− for _, opt := range opts {
236− opt(&config)
237− }
238−
239− return config
240−}
241−
242231// WithContextSize sets the number of context lines to include.
243232func WithContextSize(size int) ParseOption {
244233 return func(p *ParseConfig) {
@@ -347,6 +336,10 @@ func ParseUnifiedDiff(diff string) (DiffResult, error) {
347336 continue
348337 }
349338
339+ // ignore the \\ No newline at end of file
340+ if strings.HasPrefix(line, "\\ No newline at end of file") {
341+ continue
342+ }
350343 if currentHunk == nil {
351344 continue
352345 }
@@ -450,32 +443,22 @@ func colorizeSegments(diffs []diffmatchpatch.Diff, isOld bool, style StyleConfig
450443 removedLineStyle := lipgloss.NewStyle().Background(style.RemovedLineBg)
451444 addedLineStyle := lipgloss.NewStyle().Background(style.AddedLineBg)
452445
453− afterBg := false
454−
455446 for _, d := range diffs {
456447 switch d.Type {
457448 case diffmatchpatch.DiffEqual:
458449 // Handle text that's the same in both versions
459− if afterBg {
460− if isOld {
461− buf.WriteString(removedLineStyle.Render(d.Text))
462− } else {
463− buf.WriteString(addedLineStyle.Render(d.Text))
464− }
465− } else {
466− buf.WriteString(d.Text)
467− }
450+ buf.WriteString(d.Text)
468451 case diffmatchpatch.DiffDelete:
469452 // Handle deleted text (only show in old version)
470453 if isOld {
471454 buf.WriteString(removeBg.Render(d.Text))
472− afterBg = true
455+ buf.WriteString(removedLineStyle.Render(""))
473456 }
474457 case diffmatchpatch.DiffInsert:
475458 // Handle inserted text (only show in new version)
476459 if !isOld {
477460 buf.WriteString(addBg.Render(d.Text))
478− afterBg = true
461+ buf.WriteString(addedLineStyle.Render(""))
479462 }
480463 }
481464 }
@@ -621,7 +604,13 @@ func renderLeftColumn(fileName string, dl *DiffLine, colWidth int, styles StyleC
621604 }
622605
623606 lineText := prefix + content
624− return bgStyle.MaxHeight(1).Width(colWidth).Render(ansi.Truncate(lineText, colWidth, "..."))
607+ return bgStyle.MaxHeight(1).Width(colWidth).Render(
608+ ansi.Truncate(
609+ lineText,
610+ colWidth,
611+ lipgloss.NewStyle().Background(styles.HunkLineBg).Foreground(styles.HunkLineFg).Render("..."),
612+ ),
613+ )
625614}
626615
627616// renderRightColumn formats the right side of a side-by-side diff.
@@ -662,7 +651,13 @@ func renderRightColumn(fileName string, dl *DiffLine, colWidth int, styles Style
662651 }
663652
664653 lineText := prefix + content
665− return bgStyle.MaxHeight(1).Width(colWidth).Render(ansi.Truncate(lineText, colWidth, "..."))
654+ return bgStyle.MaxHeight(1).Width(colWidth).Render(
655+ ansi.Truncate(
656+ lineText,
657+ colWidth,
658+ lipgloss.NewStyle().Background(styles.HunkLineBg).Foreground(styles.HunkLineFg).Render("..."),
659+ ),
660+ )
666661}
667662
668663// -------------------------------------------------------------------------
@@ -718,278 +713,87 @@ func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) {
718713}
719714
720715// GenerateDiff creates a unified diff from two file contents.
721−func GenerateDiff(beforeContent, afterContent, beforeFilename, afterFilename string, opts ...ParseOption) (string, int, int) {
722− config := NewParseConfig(opts...)
723−
724− var output strings.Builder
725−
726− // Ensure we handle newlines correctly
727− beforeHasNewline := len(beforeContent) > 0 && beforeContent[len(beforeContent)-1] == '\n'
728− afterHasNewline := len(afterContent) > 0 && afterContent[len(afterContent)-1] == '\n'
729−
730− // Split into lines
731− beforeLines := strings.Split(beforeContent, "\n")
732− afterLines := strings.Split(afterContent, "\n")
733−
734− // Remove empty trailing element from the split if the content ended with a newline
735− if beforeHasNewline && len(beforeLines) > 0 {
736− beforeLines = beforeLines[:len(beforeLines)-1]
737− }
738− if afterHasNewline && len(afterLines) > 0 {
739− afterLines = afterLines[:len(afterLines)-1]
716+func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, int) {
717+ tempDir, err := os.MkdirTemp("", "git-diff-temp")
718+ if err != nil {
719+ return "", 0, 0
740720 }
721+ defer os.RemoveAll(tempDir)
741722
742− dmp := diffmatchpatch.New()
743− dmp.DiffTimeout = 5 * time.Second
723+ repo, err := git.PlainInit(tempDir, false)
724+ if err != nil {
725+ return "", 0, 0
726+ }
744727
745− // Convert lines to characters for efficient diffing
746− lineArray1, lineArray2, lineArrays := dmp.DiffLinesToChars(beforeContent, afterContent)
747− diffs := dmp.DiffMain(lineArray1, lineArray2, false)
748− diffs = dmp.DiffCharsToLines(diffs, lineArrays)
728+ wt, err := repo.Worktree()
729+ if err != nil {
730+ return "", 0, 0
731+ }
749732
750− // Default filenames if not provided
751− if beforeFilename == "" {
752− beforeFilename = "a"
733+ fullPath := filepath.Join(tempDir, fileName)
734+ if err = os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
735+ return "", 0, 0
753736 }
754− if afterFilename == "" {
755− afterFilename = "b"
737+ if err = os.WriteFile(fullPath, []byte(beforeContent), 0o644); err != nil {
738+ return "", 0, 0
756739 }
757740
758− // Write diff header
759− output.WriteString(fmt.Sprintf("diff --git a/%s b/%s\n", beforeFilename, afterFilename))
760− output.WriteString(fmt.Sprintf("--- a/%s\n", beforeFilename))
761− output.WriteString(fmt.Sprintf("+++ b/%s\n", afterFilename))
762−
763− line1 := 0 // Line numbers start from 0 internally
764− line2 := 0
765− additions := 0
766− deletions := 0
767−
768− var hunks []string
769− var currentHunk strings.Builder
770− var hunkStartLine1, hunkStartLine2 int
771− var hunkLines1, hunkLines2 int
772− inHunk := false
773−
774− contextSize := config.ContextSize
775−
776− // startHunk begins recording a new hunk
777− startHunk := func(startLine1, startLine2 int) {
778− inHunk = true
779− hunkStartLine1 = startLine1
780− hunkStartLine2 = startLine2
781− hunkLines1 = 0
782− hunkLines2 = 0
783− currentHunk.Reset()
784− }
785−
786− // writeHunk adds the current hunk to the hunks slice
787− writeHunk := func() {
788− if inHunk {
789− hunkHeader := fmt.Sprintf("@@ -%d,%d +%d,%d @@\n",
790− hunkStartLine1+1, hunkLines1,
791− hunkStartLine2+1, hunkLines2)
792− hunks = append(hunks, hunkHeader+currentHunk.String())
793− inHunk = false
794− }
741+ _, err = wt.Add(fileName)
742+ if err != nil {
743+ return "", 0, 0
795744 }
796745
797− // Process diffs to create hunks
798− pendingContext := make([]string, 0, contextSize*2)
799− var contextLines1, contextLines2 int
800−
801− // Helper function to add context lines to the hunk
802− addContextToHunk := func(lines []string, count int) {
803− for i := 0; i < count; i++ {
804− if i < len(lines) {
805− currentHunk.WriteString(" " + lines[i] + "\n")
806− hunkLines1++
807− hunkLines2++
808− }
809− }
746+ beforeCommit, err := wt.Commit("Before", &git.CommitOptions{
747+ Author: &object.Signature{
748+ Name: "OpenCode",
749+ Email: "coder@opencode.ai",
750+ When: time.Now(),
751+ },
752+ })
753+ if err != nil {
754+ return "", 0, 0
810755 }
811756
812− // Process diffs
813− for _, diff := range diffs {
814− lines := strings.Split(diff.Text, "\n")
815−
816− // Remove empty trailing line that comes from splitting a string that ends with \n
817− if len(lines) > 0 && lines[len(lines)-1] == "" && diff.Text[len(diff.Text)-1] == '\n' {
818− lines = lines[:len(lines)-1]
819− }
820−
821− switch diff.Type {
822− case diffmatchpatch.DiffEqual:
823− // If we have enough equal lines to serve as context, add them to pending
824− pendingContext = append(pendingContext, lines...)
825−
826− // If pending context grows too large, trim it
827− if len(pendingContext) > contextSize*2 {
828− pendingContext = pendingContext[len(pendingContext)-contextSize*2:]
829− }
830−
831− // If we're in a hunk, add the necessary context
832− if inHunk {
833− // Only add the first contextSize lines as trailing context
834− numContextLines := min(contextSize, len(lines))
835− addContextToHunk(lines[:numContextLines], numContextLines)
836−
837− // If we've added enough trailing context, close the hunk
838− if numContextLines >= contextSize {
839− writeHunk()
840− }
841− }
842−
843− line1 += len(lines)
844− line2 += len(lines)
845− contextLines1 += len(lines)
846− contextLines2 += len(lines)
847−
848− case diffmatchpatch.DiffDelete, diffmatchpatch.DiffInsert:
849− // Start a new hunk if needed
850− if !inHunk {
851− // Determine how many context lines we can add before
852− contextBefore := min(contextSize, len(pendingContext))
853− ctxStartIdx := len(pendingContext) - contextBefore
854−
855− // Calculate the correct start lines
856− startLine1 := line1 - contextLines1 + ctxStartIdx
857− startLine2 := line2 - contextLines2 + ctxStartIdx
858−
859− startHunk(startLine1, startLine2)
860−
861− // Add the context lines before
862− addContextToHunk(pendingContext[ctxStartIdx:], contextBefore)
863− }
864−
865− // Reset context tracking when we see a diff
866− pendingContext = pendingContext[:0]
867− contextLines1 = 0
868− contextLines2 = 0
869−
870− // Add the changes
871− if diff.Type == diffmatchpatch.DiffDelete {
872− for _, line := range lines {
873− currentHunk.WriteString("-" + line + "\n")
874− hunkLines1++
875− deletions++
876− }
877− line1 += len(lines)
878− } else { // DiffInsert
879− for _, line := range lines {
880− currentHunk.WriteString("+" + line + "\n")
881− hunkLines2++
882− additions++
883− }
884− line2 += len(lines)
885− }
886− }
757+ if err = os.WriteFile(fullPath, []byte(afterContent), 0o644); err != nil {
887758 }
888759
889− // Write the final hunk if there's one pending
890− if inHunk {
891− writeHunk()
760+ _, err = wt.Add(fileName)
761+ if err != nil {
762+ return "", 0, 0
892763 }
893764
894− // Merge hunks that are close to each other (within 2*contextSize lines)
895− var mergedHunks []string
896− if len(hunks) > 0 {
897− mergedHunks = append(mergedHunks, hunks[0])
898−
899− for i := 1; i < len(hunks); i++ {
900− prevHunk := mergedHunks[len(mergedHunks)-1]
901− currHunk := hunks[i]
902−
903− // Extract line numbers to check proximity
904− var prevStart, prevLen, currStart, currLen int
905− fmt.Sscanf(prevHunk, "@@ -%d,%d", &prevStart, &prevLen)
906− fmt.Sscanf(currHunk, "@@ -%d,%d", &currStart, &currLen)
907−
908− prevEnd := prevStart + prevLen - 1
909−
910− // If hunks are close, merge them
911− if currStart-prevEnd <= contextSize*2 {
912− // Create a merged hunk - this is a simplification, real git has more complex merging logic
913− merged := mergeHunks(prevHunk, currHunk)
914− mergedHunks[len(mergedHunks)-1] = merged
915− } else {
916− mergedHunks = append(mergedHunks, currHunk)
917− }
918− }
765+ afterCommit, err := wt.Commit("After", &git.CommitOptions{
766+ Author: &object.Signature{
767+ Name: "OpenCode",
768+ Email: "coder@opencode.ai",
769+ When: time.Now(),
770+ },
771+ })
772+ if err != nil {
773+ return "", 0, 0
919774 }
920775
921− // Write all hunks to output
922− for _, hunk := range mergedHunks {
923− output.WriteString(hunk)
776+ beforeCommitObj, err := repo.CommitObject(beforeCommit)
777+ if err != nil {
778+ return "", 0, 0
924779 }
925780
926− // Handle "No newline at end of file" notifications
927− if !beforeHasNewline && len(beforeLines) > 0 {
928− // Find the last deletion in the diff and add the notification after it
929− lastPos := strings.LastIndex(output.String(), "\n-")
930− if lastPos != -1 {
931− // Insert the notification after the line
932− str := output.String()
933− output.Reset()
934− output.WriteString(str[:lastPos+1])
935− output.WriteString("\\ No newline at end of file\n")
936− output.WriteString(str[lastPos+1:])
937− }
781+ afterCommitObj, err := repo.CommitObject(afterCommit)
782+ if err != nil {
783+ return "", 0, 0
938784 }
939785
940− if !afterHasNewline && len(afterLines) > 0 {
941− // Find the last insertion in the diff and add the notification after it
942− lastPos := strings.LastIndex(output.String(), "\n+")
943− if lastPos != -1 {
944− // Insert the notification after the line
945− str := output.String()
946− output.Reset()
947− output.WriteString(str[:lastPos+1])
948− output.WriteString("\\ No newline at end of file\n")
949− output.WriteString(str[lastPos+1:])
950− }
786+ patch, err := beforeCommitObj.Patch(afterCommitObj)
787+ if err != nil {
788+ return "", 0, 0
951789 }
952790
953− // Return the diff without the summary line
954− return output.String(), additions, deletions
955−}
956−
957−// Helper function to merge two hunks
958−func mergeHunks(hunk1, hunk2 string) string {
959− // This is a simplified implementation
960− // A full implementation would need to properly recalculate the hunk header
961− // and remove redundant context lines
962−
963− // Extract header info from both hunks
964− var start1, len1, start2, len2 int
965− var startB1, lenB1, startB2, lenB2 int
966−
967− fmt.Sscanf(hunk1, "@@ -%d,%d +%d,%d @@", &start1, &len1, &startB1, &lenB1)
968− fmt.Sscanf(hunk2, "@@ -%d,%d +%d,%d @@", &start2, &len2, &startB2, &lenB2)
969−
970− // Split the hunks to get content
971− parts1 := strings.SplitN(hunk1, "\n", 2)
972− parts2 := strings.SplitN(hunk2, "\n", 2)
973−
974− content1 := ""
975− content2 := ""
976−
977− if len(parts1) > 1 {
978− content1 = parts1[1]
979− }
980− if len(parts2) > 1 {
981− content2 = parts2[1]
791+ additions := 0
792+ removals := 0
793+ for _, fileStat := range patch.Stats() {
794+ additions += fileStat.Addition
795+ removals += fileStat.Deletion
982796 }
983797
984− // Calculate the new header
985− newEnd := max(start1+len1-1, start2+len2-1)
986− newEndB := max(startB1+lenB1-1, startB2+lenB2-1)
987−
988− newLen := newEnd - start1 + 1
989− newLenB := newEndB - startB1 + 1
990−
991− newHeader := fmt.Sprintf("@@ -%d,%d +%d,%d @@", start1, newLen, startB1, newLenB)
992−
993− // Combine the content, potentially with some overlap handling
994− return newHeader + "\n" + content1 + content2
798+ return patch.String(), additions, removals
995799}