diff.go

  1package diff
  2
  3import (
  4	"bytes"
  5	"fmt"
  6	"io"
  7	"os"
  8	"path/filepath"
  9	"regexp"
 10	"strconv"
 11	"strings"
 12	"time"
 13
 14	"github.com/alecthomas/chroma/v2"
 15	"github.com/alecthomas/chroma/v2/formatters"
 16	"github.com/alecthomas/chroma/v2/lexers"
 17	"github.com/alecthomas/chroma/v2/styles"
 18	"github.com/charmbracelet/lipgloss"
 19	"github.com/charmbracelet/x/ansi"
 20	"github.com/go-git/go-git/v5"
 21	"github.com/go-git/go-git/v5/plumbing/object"
 22	"github.com/sergi/go-diff/diffmatchpatch"
 23)
 24
 25// LineType represents the kind of line in a diff.
 26type LineType int
 27
 28const (
 29	// LineContext represents a line that exists in both the old and new file.
 30	LineContext LineType = iota
 31	// LineAdded represents a line added in the new file.
 32	LineAdded
 33	// LineRemoved represents a line removed from the old file.
 34	LineRemoved
 35)
 36
 37// DiffLine represents a single line in a diff, either from the old file,
 38// the new file, or a context line.
 39type DiffLine struct {
 40	OldLineNo int      // Line number in the old file (0 for added lines)
 41	NewLineNo int      // Line number in the new file (0 for removed lines)
 42	Kind      LineType // Type of line (added, removed, context)
 43	Content   string   // Content of the line
 44}
 45
 46// Hunk represents a section of changes in a diff.
 47type Hunk struct {
 48	Header string
 49	Lines  []DiffLine
 50}
 51
 52// DiffResult contains the parsed result of a diff.
 53type DiffResult struct {
 54	OldFile string
 55	NewFile string
 56	Hunks   []Hunk
 57}
 58
 59// HunkDelta represents the change statistics for a hunk.
 60type HunkDelta struct {
 61	StartLine1 int
 62	LineCount1 int
 63	StartLine2 int
 64	LineCount2 int
 65}
 66
 67// linePair represents a pair of lines to be displayed side by side.
 68type linePair struct {
 69	left  *DiffLine
 70	right *DiffLine
 71}
 72
 73// -------------------------------------------------------------------------
 74// Style Configuration with Option Pattern
 75// -------------------------------------------------------------------------
 76
 77// StyleConfig defines styling for diff rendering.
 78type StyleConfig struct {
 79	RemovedLineBg       lipgloss.Color
 80	AddedLineBg         lipgloss.Color
 81	ContextLineBg       lipgloss.Color
 82	HunkLineBg          lipgloss.Color
 83	HunkLineFg          lipgloss.Color
 84	RemovedFg           lipgloss.Color
 85	AddedFg             lipgloss.Color
 86	LineNumberFg        lipgloss.Color
 87	HighlightStyle      string
 88	RemovedHighlightBg  lipgloss.Color
 89	AddedHighlightBg    lipgloss.Color
 90	RemovedLineNumberBg lipgloss.Color
 91	AddedLineNamerBg    lipgloss.Color
 92	RemovedHighlightFg  lipgloss.Color
 93	AddedHighlightFg    lipgloss.Color
 94}
 95
 96// StyleOption defines a function that modifies a StyleConfig.
 97type StyleOption func(*StyleConfig)
 98
 99// NewStyleConfig creates a StyleConfig with default values and applies any provided options.
100func NewStyleConfig(opts ...StyleOption) StyleConfig {
101	// Set default values
102	config := StyleConfig{
103		RemovedLineBg:       lipgloss.Color("#3A3030"),
104		AddedLineBg:         lipgloss.Color("#303A30"),
105		ContextLineBg:       lipgloss.Color("#212121"),
106		HunkLineBg:          lipgloss.Color("#2A2822"),
107		HunkLineFg:          lipgloss.Color("#D4AF37"),
108		RemovedFg:           lipgloss.Color("#7C4444"),
109		AddedFg:             lipgloss.Color("#478247"),
110		LineNumberFg:        lipgloss.Color("#888888"),
111		HighlightStyle:      "dracula",
112		RemovedHighlightBg:  lipgloss.Color("#612726"),
113		AddedHighlightBg:    lipgloss.Color("#256125"),
114		RemovedLineNumberBg: lipgloss.Color("#332929"),
115		AddedLineNamerBg:    lipgloss.Color("#293229"),
116		RemovedHighlightFg:  lipgloss.Color("#FADADD"),
117		AddedHighlightFg:    lipgloss.Color("#DAFADA"),
118	}
119
120	// Apply all provided options
121	for _, opt := range opts {
122		opt(&config)
123	}
124
125	return config
126}
127
128// WithRemovedLineBg sets the background color for removed lines.
129func WithRemovedLineBg(color lipgloss.Color) StyleOption {
130	return func(s *StyleConfig) {
131		s.RemovedLineBg = color
132	}
133}
134
135// WithAddedLineBg sets the background color for added lines.
136func WithAddedLineBg(color lipgloss.Color) StyleOption {
137	return func(s *StyleConfig) {
138		s.AddedLineBg = color
139	}
140}
141
142// WithContextLineBg sets the background color for context lines.
143func WithContextLineBg(color lipgloss.Color) StyleOption {
144	return func(s *StyleConfig) {
145		s.ContextLineBg = color
146	}
147}
148
149// WithRemovedFg sets the foreground color for removed line markers.
150func WithRemovedFg(color lipgloss.Color) StyleOption {
151	return func(s *StyleConfig) {
152		s.RemovedFg = color
153	}
154}
155
156// WithAddedFg sets the foreground color for added line markers.
157func WithAddedFg(color lipgloss.Color) StyleOption {
158	return func(s *StyleConfig) {
159		s.AddedFg = color
160	}
161}
162
163// WithLineNumberFg sets the foreground color for line numbers.
164func WithLineNumberFg(color lipgloss.Color) StyleOption {
165	return func(s *StyleConfig) {
166		s.LineNumberFg = color
167	}
168}
169
170// WithHighlightStyle sets the syntax highlighting style.
171func WithHighlightStyle(style string) StyleOption {
172	return func(s *StyleConfig) {
173		s.HighlightStyle = style
174	}
175}
176
177// WithRemovedHighlightColors sets the colors for highlighted parts in removed text.
178func WithRemovedHighlightColors(bg, fg lipgloss.Color) StyleOption {
179	return func(s *StyleConfig) {
180		s.RemovedHighlightBg = bg
181		s.RemovedHighlightFg = fg
182	}
183}
184
185// WithAddedHighlightColors sets the colors for highlighted parts in added text.
186func WithAddedHighlightColors(bg, fg lipgloss.Color) StyleOption {
187	return func(s *StyleConfig) {
188		s.AddedHighlightBg = bg
189		s.AddedHighlightFg = fg
190	}
191}
192
193// WithRemovedLineNumberBg sets the background color for removed line numbers.
194func WithRemovedLineNumberBg(color lipgloss.Color) StyleOption {
195	return func(s *StyleConfig) {
196		s.RemovedLineNumberBg = color
197	}
198}
199
200// WithAddedLineNumberBg sets the background color for added line numbers.
201func WithAddedLineNumberBg(color lipgloss.Color) StyleOption {
202	return func(s *StyleConfig) {
203		s.AddedLineNamerBg = color
204	}
205}
206
207func WithHunkLineBg(color lipgloss.Color) StyleOption {
208	return func(s *StyleConfig) {
209		s.HunkLineBg = color
210	}
211}
212
213func WithHunkLineFg(color lipgloss.Color) StyleOption {
214	return func(s *StyleConfig) {
215		s.HunkLineFg = color
216	}
217}
218
219// -------------------------------------------------------------------------
220// Parse Options with Option Pattern
221// -------------------------------------------------------------------------
222
223// ParseConfig configures the behavior of diff parsing.
224type ParseConfig struct {
225	ContextSize int // Number of context lines to include
226}
227
228// ParseOption defines a function that modifies a ParseConfig.
229type ParseOption func(*ParseConfig)
230
231// WithContextSize sets the number of context lines to include.
232func WithContextSize(size int) ParseOption {
233	return func(p *ParseConfig) {
234		if size >= 0 {
235			p.ContextSize = size
236		}
237	}
238}
239
240// -------------------------------------------------------------------------
241// Side-by-Side Options with Option Pattern
242// -------------------------------------------------------------------------
243
244// SideBySideConfig configures the rendering of side-by-side diffs.
245type SideBySideConfig struct {
246	TotalWidth int
247	Style      StyleConfig
248}
249
250// SideBySideOption defines a function that modifies a SideBySideConfig.
251type SideBySideOption func(*SideBySideConfig)
252
253// NewSideBySideConfig creates a SideBySideConfig with default values and applies any provided options.
254func NewSideBySideConfig(opts ...SideBySideOption) SideBySideConfig {
255	// Set default values
256	config := SideBySideConfig{
257		TotalWidth: 160, // Default width for side-by-side view
258		Style:      NewStyleConfig(),
259	}
260
261	// Apply all provided options
262	for _, opt := range opts {
263		opt(&config)
264	}
265
266	return config
267}
268
269// WithTotalWidth sets the total width for side-by-side view.
270func WithTotalWidth(width int) SideBySideOption {
271	return func(s *SideBySideConfig) {
272		if width > 0 {
273			s.TotalWidth = width
274		}
275	}
276}
277
278// WithStyle sets the styling configuration.
279func WithStyle(style StyleConfig) SideBySideOption {
280	return func(s *SideBySideConfig) {
281		s.Style = style
282	}
283}
284
285// WithStyleOptions applies the specified style options.
286func WithStyleOptions(opts ...StyleOption) SideBySideOption {
287	return func(s *SideBySideConfig) {
288		s.Style = NewStyleConfig(opts...)
289	}
290}
291
292// -------------------------------------------------------------------------
293// Diff Parsing and Generation
294// -------------------------------------------------------------------------
295
296// ParseUnifiedDiff parses a unified diff format string into structured data.
297func ParseUnifiedDiff(diff string) (DiffResult, error) {
298	var result DiffResult
299	var currentHunk *Hunk
300
301	hunkHeaderRe := regexp.MustCompile(`^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@`)
302	lines := strings.Split(diff, "\n")
303
304	var oldLine, newLine int
305	inFileHeader := true
306
307	for _, line := range lines {
308		// Parse the file headers
309		if inFileHeader {
310			if strings.HasPrefix(line, "--- a/") {
311				result.OldFile = strings.TrimPrefix(line, "--- a/")
312				continue
313			}
314			if strings.HasPrefix(line, "+++ b/") {
315				result.NewFile = strings.TrimPrefix(line, "+++ b/")
316				inFileHeader = false
317				continue
318			}
319		}
320
321		// Parse hunk headers
322		if matches := hunkHeaderRe.FindStringSubmatch(line); matches != nil {
323			if currentHunk != nil {
324				result.Hunks = append(result.Hunks, *currentHunk)
325			}
326			currentHunk = &Hunk{
327				Header: line,
328				Lines:  []DiffLine{},
329			}
330
331			oldStart, _ := strconv.Atoi(matches[1])
332			newStart, _ := strconv.Atoi(matches[3])
333			oldLine = oldStart
334			newLine = newStart
335
336			continue
337		}
338
339		// ignore the \\ No newline at end of file
340		if strings.HasPrefix(line, "\\ No newline at end of file") {
341			continue
342		}
343		if currentHunk == nil {
344			continue
345		}
346
347		if len(line) > 0 {
348			// Process the line based on its prefix
349			switch line[0] {
350			case '+':
351				currentHunk.Lines = append(currentHunk.Lines, DiffLine{
352					OldLineNo: 0,
353					NewLineNo: newLine,
354					Kind:      LineAdded,
355					Content:   line[1:], // skip '+'
356				})
357				newLine++
358			case '-':
359				currentHunk.Lines = append(currentHunk.Lines, DiffLine{
360					OldLineNo: oldLine,
361					NewLineNo: 0,
362					Kind:      LineRemoved,
363					Content:   line[1:], // skip '-'
364				})
365				oldLine++
366			default:
367				currentHunk.Lines = append(currentHunk.Lines, DiffLine{
368					OldLineNo: oldLine,
369					NewLineNo: newLine,
370					Kind:      LineContext,
371					Content:   line,
372				})
373				oldLine++
374				newLine++
375			}
376		} else {
377			// Handle empty lines
378			currentHunk.Lines = append(currentHunk.Lines, DiffLine{
379				OldLineNo: oldLine,
380				NewLineNo: newLine,
381				Kind:      LineContext,
382				Content:   "",
383			})
384			oldLine++
385			newLine++
386		}
387	}
388
389	// Add the last hunk if there is one
390	if currentHunk != nil {
391		result.Hunks = append(result.Hunks, *currentHunk)
392	}
393
394	return result, nil
395}
396
397// HighlightIntralineChanges updates the content of lines in a hunk to show
398// character-level differences within lines.
399func HighlightIntralineChanges(h *Hunk, style StyleConfig) {
400	var updated []DiffLine
401	dmp := diffmatchpatch.New()
402
403	for i := 0; i < len(h.Lines); i++ {
404		// Look for removed line followed by added line, which might have similar content
405		if i+1 < len(h.Lines) &&
406			h.Lines[i].Kind == LineRemoved &&
407			h.Lines[i+1].Kind == LineAdded {
408
409			oldLine := h.Lines[i]
410			newLine := h.Lines[i+1]
411
412			// Find character-level differences
413			patches := dmp.DiffMain(oldLine.Content, newLine.Content, false)
414			patches = dmp.DiffCleanupEfficiency(patches)
415			patches = dmp.DiffCleanupSemantic(patches)
416
417			// Apply highlighting to the differences
418			oldLine.Content = colorizeSegments(patches, true, style)
419			newLine.Content = colorizeSegments(patches, false, style)
420
421			updated = append(updated, oldLine, newLine)
422			i++ // Skip the next line as we've already processed it
423		} else {
424			updated = append(updated, h.Lines[i])
425		}
426	}
427
428	h.Lines = updated
429}
430
431// colorizeSegments applies styles to the character-level diff segments.
432func colorizeSegments(diffs []diffmatchpatch.Diff, isOld bool, style StyleConfig) string {
433	var buf strings.Builder
434
435	removeBg := lipgloss.NewStyle().
436		Background(style.RemovedHighlightBg).
437		Foreground(style.RemovedHighlightFg)
438
439	addBg := lipgloss.NewStyle().
440		Background(style.AddedHighlightBg).
441		Foreground(style.AddedHighlightFg)
442
443	removedLineStyle := lipgloss.NewStyle().Background(style.RemovedLineBg)
444	addedLineStyle := lipgloss.NewStyle().Background(style.AddedLineBg)
445
446	for _, d := range diffs {
447		switch d.Type {
448		case diffmatchpatch.DiffEqual:
449			// Handle text that's the same in both versions
450			buf.WriteString(d.Text)
451		case diffmatchpatch.DiffDelete:
452			// Handle deleted text (only show in old version)
453			if isOld {
454				buf.WriteString(removeBg.Render(d.Text))
455				buf.WriteString(removedLineStyle.Render(""))
456			}
457		case diffmatchpatch.DiffInsert:
458			// Handle inserted text (only show in new version)
459			if !isOld {
460				buf.WriteString(addBg.Render(d.Text))
461				buf.WriteString(addedLineStyle.Render(""))
462			}
463		}
464	}
465
466	return buf.String()
467}
468
469// pairLines converts a flat list of diff lines to pairs for side-by-side display.
470func pairLines(lines []DiffLine) []linePair {
471	var pairs []linePair
472	i := 0
473
474	for i < len(lines) {
475		switch lines[i].Kind {
476		case LineRemoved:
477			// Check if the next line is an addition, if so pair them
478			if i+1 < len(lines) && lines[i+1].Kind == LineAdded {
479				pairs = append(pairs, linePair{left: &lines[i], right: &lines[i+1]})
480				i += 2
481			} else {
482				pairs = append(pairs, linePair{left: &lines[i], right: nil})
483				i++
484			}
485		case LineAdded:
486			pairs = append(pairs, linePair{left: nil, right: &lines[i]})
487			i++
488		case LineContext:
489			pairs = append(pairs, linePair{left: &lines[i], right: &lines[i]})
490			i++
491		}
492	}
493
494	return pairs
495}
496
497// -------------------------------------------------------------------------
498// Syntax Highlighting
499// -------------------------------------------------------------------------
500
501// SyntaxHighlight applies syntax highlighting to a string based on the file extension.
502func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipgloss.TerminalColor) error {
503	// Determine the language lexer to use
504	l := lexers.Match(fileName)
505	if l == nil {
506		l = lexers.Analyse(source)
507	}
508	if l == nil {
509		l = lexers.Fallback
510	}
511	l = chroma.Coalesce(l)
512
513	// Get the formatter
514	f := formatters.Get(formatter)
515	if f == nil {
516		f = formatters.Fallback
517	}
518
519	// Get the style
520	s := styles.Get("dracula")
521	if s == nil {
522		s = styles.Fallback
523	}
524
525	// Modify the style to use the provided background
526	s, err := s.Builder().Transform(
527		func(t chroma.StyleEntry) chroma.StyleEntry {
528			r, g, b, _ := bg.RGBA()
529			ru8 := uint8(r >> 8)
530			gu8 := uint8(g >> 8)
531			bu8 := uint8(b >> 8)
532			t.Background = chroma.NewColour(ru8, gu8, bu8)
533			return t
534		},
535	).Build()
536	if err != nil {
537		s = styles.Fallback
538	}
539
540	// Tokenize and format
541	it, err := l.Tokenise(nil, source)
542	if err != nil {
543		return err
544	}
545
546	return f.Format(w, s, it)
547}
548
549// highlightLine applies syntax highlighting to a single line.
550func highlightLine(fileName string, line string, bg lipgloss.TerminalColor) string {
551	var buf bytes.Buffer
552	err := SyntaxHighlight(&buf, line, fileName, "terminal16m", bg)
553	if err != nil {
554		return line
555	}
556	return buf.String()
557}
558
559// createStyles generates the lipgloss styles needed for rendering diffs.
560func createStyles(config StyleConfig) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
561	removedLineStyle = lipgloss.NewStyle().Background(config.RemovedLineBg)
562	addedLineStyle = lipgloss.NewStyle().Background(config.AddedLineBg)
563	contextLineStyle = lipgloss.NewStyle().Background(config.ContextLineBg)
564	lineNumberStyle = lipgloss.NewStyle().Foreground(config.LineNumberFg)
565
566	return
567}
568
569// renderLeftColumn formats the left side of a side-by-side diff.
570func renderLeftColumn(fileName string, dl *DiffLine, colWidth int, styles StyleConfig) string {
571	if dl == nil {
572		contextLineStyle := lipgloss.NewStyle().Background(styles.ContextLineBg)
573		return contextLineStyle.Width(colWidth).Render("")
574	}
575
576	removedLineStyle, _, contextLineStyle, lineNumberStyle := createStyles(styles)
577
578	var marker string
579	var bgStyle lipgloss.Style
580
581	switch dl.Kind {
582	case LineRemoved:
583		marker = removedLineStyle.Foreground(styles.RemovedFg).Render("-")
584		bgStyle = removedLineStyle
585		lineNumberStyle = lineNumberStyle.Foreground(styles.RemovedFg).Background(styles.RemovedLineNumberBg)
586	case LineAdded:
587		marker = "?"
588		bgStyle = contextLineStyle
589	case LineContext:
590		marker = contextLineStyle.Render(" ")
591		bgStyle = contextLineStyle
592	}
593
594	lineNum := ""
595	if dl.OldLineNo > 0 {
596		lineNum = fmt.Sprintf("%6d", dl.OldLineNo)
597	}
598
599	prefix := lineNumberStyle.Render(lineNum + " " + marker)
600	content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
601
602	if dl.Kind == LineRemoved {
603		content = bgStyle.Render(" ") + content
604	}
605
606	lineText := prefix + content
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	)
614}
615
616// renderRightColumn formats the right side of a side-by-side diff.
617func renderRightColumn(fileName string, dl *DiffLine, colWidth int, styles StyleConfig) string {
618	if dl == nil {
619		contextLineStyle := lipgloss.NewStyle().Background(styles.ContextLineBg)
620		return contextLineStyle.Width(colWidth).Render("")
621	}
622
623	_, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(styles)
624
625	var marker string
626	var bgStyle lipgloss.Style
627
628	switch dl.Kind {
629	case LineAdded:
630		marker = addedLineStyle.Foreground(styles.AddedFg).Render("+")
631		bgStyle = addedLineStyle
632		lineNumberStyle = lineNumberStyle.Foreground(styles.AddedFg).Background(styles.AddedLineNamerBg)
633	case LineRemoved:
634		marker = "?"
635		bgStyle = contextLineStyle
636	case LineContext:
637		marker = contextLineStyle.Render(" ")
638		bgStyle = contextLineStyle
639	}
640
641	lineNum := ""
642	if dl.NewLineNo > 0 {
643		lineNum = fmt.Sprintf("%6d", dl.NewLineNo)
644	}
645
646	prefix := lineNumberStyle.Render(lineNum + " " + marker)
647	content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
648
649	if dl.Kind == LineAdded {
650		content = bgStyle.Render(" ") + content
651	}
652
653	lineText := prefix + content
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	)
661}
662
663// -------------------------------------------------------------------------
664// Public API Methods
665// -------------------------------------------------------------------------
666
667// RenderSideBySideHunk formats a hunk for side-by-side display.
668func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string {
669	// Apply options to create the configuration
670	config := NewSideBySideConfig(opts...)
671
672	// Make a copy of the hunk so we don't modify the original
673	hunkCopy := Hunk{Lines: make([]DiffLine, len(h.Lines))}
674	copy(hunkCopy.Lines, h.Lines)
675
676	// Highlight changes within lines
677	HighlightIntralineChanges(&hunkCopy, config.Style)
678
679	// Pair lines for side-by-side display
680	pairs := pairLines(hunkCopy.Lines)
681
682	// Calculate column width
683	colWidth := config.TotalWidth / 2
684
685	var sb strings.Builder
686	for _, p := range pairs {
687		leftStr := renderLeftColumn(fileName, p.left, colWidth, config.Style)
688		rightStr := renderRightColumn(fileName, p.right, colWidth, config.Style)
689		sb.WriteString(leftStr + rightStr + "\n")
690	}
691
692	return sb.String()
693}
694
695// FormatDiff creates a side-by-side formatted view of a diff.
696func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) {
697	diffResult, err := ParseUnifiedDiff(diffText)
698	if err != nil {
699		return "", err
700	}
701
702	var sb strings.Builder
703
704	config := NewSideBySideConfig(opts...)
705	for i, h := range diffResult.Hunks {
706		if i > 0 {
707			sb.WriteString(lipgloss.NewStyle().Background(config.Style.HunkLineBg).Foreground(config.Style.HunkLineFg).Width(config.TotalWidth).Render(h.Header) + "\n")
708		}
709		sb.WriteString(RenderSideBySideHunk(diffResult.OldFile, h, opts...))
710	}
711
712	return sb.String(), nil
713}
714
715// GenerateDiff creates a unified diff from two file contents.
716func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, int) {
717	tempDir, err := os.MkdirTemp("", "git-diff-temp")
718	if err != nil {
719		return "", 0, 0
720	}
721	defer os.RemoveAll(tempDir)
722
723	repo, err := git.PlainInit(tempDir, false)
724	if err != nil {
725		return "", 0, 0
726	}
727
728	wt, err := repo.Worktree()
729	if err != nil {
730		return "", 0, 0
731	}
732
733	fullPath := filepath.Join(tempDir, fileName)
734	if err = os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
735		return "", 0, 0
736	}
737	if err = os.WriteFile(fullPath, []byte(beforeContent), 0o644); err != nil {
738		return "", 0, 0
739	}
740
741	_, err = wt.Add(fileName)
742	if err != nil {
743		return "", 0, 0
744	}
745
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
755	}
756
757	if err = os.WriteFile(fullPath, []byte(afterContent), 0o644); err != nil {
758	}
759
760	_, err = wt.Add(fileName)
761	if err != nil {
762		return "", 0, 0
763	}
764
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
774	}
775
776	beforeCommitObj, err := repo.CommitObject(beforeCommit)
777	if err != nil {
778		return "", 0, 0
779	}
780
781	afterCommitObj, err := repo.CommitObject(afterCommit)
782	if err != nil {
783		return "", 0, 0
784	}
785
786	patch, err := beforeCommitObj.Patch(afterCommitObj)
787	if err != nil {
788		return "", 0, 0
789	}
790
791	additions := 0
792	removals := 0
793	for _, fileStat := range patch.Stats() {
794		additions += fileStat.Addition
795		removals += fileStat.Deletion
796	}
797
798	return patch.String(), additions, removals
799}