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