1package git
2
3import (
4 "bytes"
5 "fmt"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
10 "time"
11
12 "github.com/go-git/go-git/v5"
13 "github.com/go-git/go-git/v5/plumbing/object"
14)
15
16type DiffStats struct {
17 Additions int
18 Removals int
19}
20
21func GenerateGitDiff(filePath string, contentBefore string, contentAfter string) (string, error) {
22 tempDir, err := os.MkdirTemp("", "git-diff-temp")
23 if err != nil {
24 return "", fmt.Errorf("failed to create temp dir: %w", err)
25 }
26 defer os.RemoveAll(tempDir)
27
28 repo, err := git.PlainInit(tempDir, false)
29 if err != nil {
30 return "", fmt.Errorf("failed to initialize git repo: %w", err)
31 }
32
33 wt, err := repo.Worktree()
34 if err != nil {
35 return "", fmt.Errorf("failed to get worktree: %w", err)
36 }
37
38 fullPath := filepath.Join(tempDir, filePath)
39 if err = os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
40 return "", fmt.Errorf("failed to create directories: %w", err)
41 }
42 if err = os.WriteFile(fullPath, []byte(contentBefore), 0o644); err != nil {
43 return "", fmt.Errorf("failed to write 'before' content: %w", err)
44 }
45
46 _, err = wt.Add(filePath)
47 if err != nil {
48 return "", fmt.Errorf("failed to add file to git: %w", err)
49 }
50
51 beforeCommit, err := wt.Commit("Before", &git.CommitOptions{
52 Author: &object.Signature{
53 Name: "OpenCode",
54 Email: "coder@opencode.ai",
55 When: time.Now(),
56 },
57 })
58 if err != nil {
59 return "", fmt.Errorf("failed to commit 'before' version: %w", err)
60 }
61
62 if err = os.WriteFile(fullPath, []byte(contentAfter), 0o644); err != nil {
63 return "", fmt.Errorf("failed to write 'after' content: %w", err)
64 }
65
66 _, err = wt.Add(filePath)
67 if err != nil {
68 return "", fmt.Errorf("failed to add updated file to git: %w", err)
69 }
70
71 afterCommit, err := wt.Commit("After", &git.CommitOptions{
72 Author: &object.Signature{
73 Name: "OpenCode",
74 Email: "coder@opencode.ai",
75 When: time.Now(),
76 },
77 })
78 if err != nil {
79 return "", fmt.Errorf("failed to commit 'after' version: %w", err)
80 }
81
82 beforeCommitObj, err := repo.CommitObject(beforeCommit)
83 if err != nil {
84 return "", fmt.Errorf("failed to get 'before' commit: %w", err)
85 }
86
87 afterCommitObj, err := repo.CommitObject(afterCommit)
88 if err != nil {
89 return "", fmt.Errorf("failed to get 'after' commit: %w", err)
90 }
91
92 patch, err := beforeCommitObj.Patch(afterCommitObj)
93 if err != nil {
94 return "", fmt.Errorf("failed to generate patch: %w", err)
95 }
96
97 return patch.String(), nil
98}
99
100func GenerateGitDiffWithStats(filePath string, contentBefore string, contentAfter string) (string, DiffStats, error) {
101 tempDir, err := os.MkdirTemp("", "git-diff-temp")
102 if err != nil {
103 return "", DiffStats{}, fmt.Errorf("failed to create temp dir: %w", err)
104 }
105 defer os.RemoveAll(tempDir)
106
107 repo, err := git.PlainInit(tempDir, false)
108 if err != nil {
109 return "", DiffStats{}, fmt.Errorf("failed to initialize git repo: %w", err)
110 }
111
112 wt, err := repo.Worktree()
113 if err != nil {
114 return "", DiffStats{}, fmt.Errorf("failed to get worktree: %w", err)
115 }
116
117 fullPath := filepath.Join(tempDir, filePath)
118 if err = os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
119 return "", DiffStats{}, fmt.Errorf("failed to create directories: %w", err)
120 }
121 if err = os.WriteFile(fullPath, []byte(contentBefore), 0o644); err != nil {
122 return "", DiffStats{}, fmt.Errorf("failed to write 'before' content: %w", err)
123 }
124
125 _, err = wt.Add(filePath)
126 if err != nil {
127 return "", DiffStats{}, fmt.Errorf("failed to add file to git: %w", err)
128 }
129
130 beforeCommit, err := wt.Commit("Before", &git.CommitOptions{
131 Author: &object.Signature{
132 Name: "OpenCode",
133 Email: "coder@opencode.ai",
134 When: time.Now(),
135 },
136 })
137 if err != nil {
138 return "", DiffStats{}, fmt.Errorf("failed to commit 'before' version: %w", err)
139 }
140
141 if err = os.WriteFile(fullPath, []byte(contentAfter), 0o644); err != nil {
142 return "", DiffStats{}, fmt.Errorf("failed to write 'after' content: %w", err)
143 }
144
145 _, err = wt.Add(filePath)
146 if err != nil {
147 return "", DiffStats{}, fmt.Errorf("failed to add updated file to git: %w", err)
148 }
149
150 afterCommit, err := wt.Commit("After", &git.CommitOptions{
151 Author: &object.Signature{
152 Name: "OpenCode",
153 Email: "coder@opencode.ai",
154 When: time.Now(),
155 },
156 })
157 if err != nil {
158 return "", DiffStats{}, fmt.Errorf("failed to commit 'after' version: %w", err)
159 }
160
161 beforeCommitObj, err := repo.CommitObject(beforeCommit)
162 if err != nil {
163 return "", DiffStats{}, fmt.Errorf("failed to get 'before' commit: %w", err)
164 }
165
166 afterCommitObj, err := repo.CommitObject(afterCommit)
167 if err != nil {
168 return "", DiffStats{}, fmt.Errorf("failed to get 'after' commit: %w", err)
169 }
170
171 patch, err := beforeCommitObj.Patch(afterCommitObj)
172 if err != nil {
173 return "", DiffStats{}, fmt.Errorf("failed to generate patch: %w", err)
174 }
175
176 stats := DiffStats{}
177 for _, fileStat := range patch.Stats() {
178 stats.Additions += fileStat.Addition
179 stats.Removals += fileStat.Deletion
180 }
181
182 return patch.String(), stats, nil
183}
184
185func FormatDiff(diffText string, width int) (string, error) {
186 if isSplitDiffsAvailable() {
187 return formatWithSplitDiffs(diffText, width)
188 }
189
190 return formatSimple(diffText), nil
191}
192
193func isSplitDiffsAvailable() bool {
194 _, err := exec.LookPath("node")
195 return err == nil
196}
197
198func formatWithSplitDiffs(diffText string, width int) (string, error) {
199 args := []string{
200 "--color",
201 }
202
203 var diffCmd *exec.Cmd
204
205 if _, err := exec.LookPath("git-split-diffs-opencode"); err == nil {
206 fullArgs := append([]string{"git-split-diffs-opencode"}, args...)
207 diffCmd = exec.Command(fullArgs[0], fullArgs[1:]...)
208 } else {
209 npxArgs := append([]string{"git-split-diffs-opencode"}, args...)
210 diffCmd = exec.Command("npx", npxArgs...)
211 }
212
213 diffCmd.Env = append(os.Environ(), fmt.Sprintf("DIFF_COLUMNS=%d", width))
214
215 diffCmd.Stdin = strings.NewReader(diffText)
216
217 var out bytes.Buffer
218 diffCmd.Stdout = &out
219
220 var stderr bytes.Buffer
221 diffCmd.Stderr = &stderr
222
223 if err := diffCmd.Run(); err != nil {
224 return "", fmt.Errorf("git-split-diffs-opencode error: %w, stderr: %s", err, stderr.String())
225 }
226
227 return out.String(), nil
228}
229
230func formatSimple(diffText string) string {
231 lines := strings.Split(diffText, "\n")
232 var result strings.Builder
233
234 for _, line := range lines {
235 if len(line) == 0 {
236 result.WriteString("\n")
237 continue
238 }
239
240 switch line[0] {
241 case '+':
242 result.WriteString("\033[32m" + line + "\033[0m\n")
243 case '-':
244 result.WriteString("\033[31m" + line + "\033[0m\n")
245 case '@':
246 result.WriteString("\033[36m" + line + "\033[0m\n")
247 case 'd':
248 if strings.HasPrefix(line, "diff --git") {
249 result.WriteString("\033[1m" + line + "\033[0m\n")
250 } else {
251 result.WriteString(line + "\n")
252 }
253 default:
254 result.WriteString(line + "\n")
255 }
256 }
257
258 if !strings.HasSuffix(diffText, "\n") {
259 output := result.String()
260 return output[:len(output)-1]
261 }
262
263 return result.String()
264}