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	"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}