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}