diff.go

  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}