git_handlers.go

  1package server
  2
  3import (
  4	"encoding/json"
  5	"io"
  6	"net/http"
  7	"os"
  8	"os/exec"
  9	"path/filepath"
 10	"sort"
 11	"strconv"
 12	"strings"
 13	"time"
 14)
 15
 16// GitDiffInfo represents a commit or working changes
 17type GitDiffInfo struct {
 18	ID         string    `json:"id"`
 19	Message    string    `json:"message"`
 20	Author     string    `json:"author"`
 21	Timestamp  time.Time `json:"timestamp"`
 22	FilesCount int       `json:"filesCount"`
 23	Additions  int       `json:"additions"`
 24	Deletions  int       `json:"deletions"`
 25}
 26
 27// GitFileInfo represents a file in a diff
 28type GitFileInfo struct {
 29	Path        string `json:"path"`
 30	Status      string `json:"status"` // added, modified, deleted
 31	Additions   int    `json:"additions"`
 32	Deletions   int    `json:"deletions"`
 33	IsGenerated bool   `json:"isGenerated"`
 34}
 35
 36// GitFileDiff represents the content of a file diff
 37type GitFileDiff struct {
 38	Path       string `json:"path"`
 39	OldContent string `json:"oldContent"`
 40	NewContent string `json:"newContent"`
 41}
 42
 43// getGitRoot returns the git repository root for the given directory
 44func getGitRoot(dir string) (string, error) {
 45	cmd := exec.Command("git", "rev-parse", "--show-toplevel")
 46	cmd.Dir = dir
 47	output, err := cmd.Output()
 48	if err != nil {
 49		return "", err
 50	}
 51	return strings.TrimSpace(string(output)), nil
 52}
 53
 54// parseDiffStat parses git diff --numstat output
 55func parseDiffStat(output string) (additions, deletions, filesCount int) {
 56	lines := strings.Split(strings.TrimSpace(output), "\n")
 57	for _, line := range lines {
 58		if line == "" {
 59			continue
 60		}
 61		parts := strings.Fields(line)
 62		if len(parts) >= 2 {
 63			if parts[0] != "-" {
 64				add, _ := strconv.Atoi(parts[0])
 65				additions += add
 66			}
 67			if parts[1] != "-" {
 68				del, _ := strconv.Atoi(parts[1])
 69				deletions += del
 70			}
 71			filesCount++
 72		}
 73	}
 74	return additions, deletions, filesCount
 75}
 76
 77// handleGitDiffs returns available diffs (working changes + recent commits)
 78func (s *Server) handleGitDiffs(w http.ResponseWriter, r *http.Request) {
 79	if r.Method != http.MethodGet {
 80		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
 81		return
 82	}
 83
 84	cwd := r.URL.Query().Get("cwd")
 85	if cwd == "" {
 86		http.Error(w, "cwd parameter required", http.StatusBadRequest)
 87		return
 88	}
 89
 90	// Validate cwd is a directory
 91	fi, err := os.Stat(cwd)
 92	if err != nil || !fi.IsDir() {
 93		http.Error(w, "invalid cwd", http.StatusBadRequest)
 94		return
 95	}
 96
 97	gitRoot, err := getGitRoot(cwd)
 98	if err != nil {
 99		http.Error(w, "not a git repository", http.StatusBadRequest)
100		return
101	}
102
103	var diffs []GitDiffInfo
104
105	// Working changes
106	workingStatCmd := exec.Command("git", "diff", "HEAD", "--numstat")
107	workingStatCmd.Dir = gitRoot
108	workingStatOutput, _ := workingStatCmd.Output()
109	workingAdditions, workingDeletions, workingFilesCount := parseDiffStat(string(workingStatOutput))
110
111	diffs = append(diffs, GitDiffInfo{
112		ID:         "working",
113		Message:    "Working Changes",
114		Author:     "",
115		Timestamp:  time.Now(),
116		FilesCount: workingFilesCount,
117		Additions:  workingAdditions,
118		Deletions:  workingDeletions,
119	})
120
121	// Get commits
122	cmd := exec.Command("git", "log", "--oneline", "-20", "--pretty=format:%H%x00%s%x00%an%x00%at")
123	cmd.Dir = gitRoot
124	output, err := cmd.Output()
125	if err == nil {
126		lines := strings.Split(strings.TrimSpace(string(output)), "\n")
127		for _, line := range lines {
128			if line == "" {
129				continue
130			}
131			parts := strings.Split(line, "\x00")
132			if len(parts) < 4 {
133				continue
134			}
135
136			timestamp, _ := strconv.ParseInt(parts[3], 10, 64)
137
138			// Get diffstat
139			statCmd := exec.Command("git", "diff", parts[0]+"^", parts[0], "--numstat")
140			statCmd.Dir = gitRoot
141			statOutput, _ := statCmd.Output()
142			additions, deletions, filesCount := parseDiffStat(string(statOutput))
143
144			diffs = append(diffs, GitDiffInfo{
145				ID:         parts[0],
146				Message:    parts[1],
147				Author:     parts[2],
148				Timestamp:  time.Unix(timestamp, 0),
149				FilesCount: filesCount,
150				Additions:  additions,
151				Deletions:  deletions,
152			})
153		}
154	}
155
156	w.Header().Set("Content-Type", "application/json")
157	json.NewEncoder(w).Encode(map[string]interface{}{
158		"diffs":   diffs,
159		"gitRoot": gitRoot,
160	})
161}
162
163// handleGitDiffFiles returns the files changed in a specific diff
164func (s *Server) handleGitDiffFiles(w http.ResponseWriter, r *http.Request) {
165	if r.Method != http.MethodGet {
166		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
167		return
168	}
169
170	// Extract diff ID from path: /api/git/diffs/{id}/files
171	path := strings.TrimPrefix(r.URL.Path, "/api/git/diffs/")
172	parts := strings.SplitN(path, "/", 2)
173	if len(parts) < 2 || parts[1] != "files" {
174		http.Error(w, "invalid path", http.StatusBadRequest)
175		return
176	}
177	diffID := parts[0]
178
179	cwd := r.URL.Query().Get("cwd")
180	if cwd == "" {
181		http.Error(w, "cwd parameter required", http.StatusBadRequest)
182		return
183	}
184
185	gitRoot, err := getGitRoot(cwd)
186	if err != nil {
187		http.Error(w, "not a git repository", http.StatusBadRequest)
188		return
189	}
190
191	var cmd *exec.Cmd
192	var statBaseArg string
193
194	if diffID == "working" {
195		cmd = exec.Command("git", "diff", "--name-status", "HEAD")
196		statBaseArg = "HEAD"
197	} else {
198		cmd = exec.Command("git", "diff", "--name-status", diffID+"^")
199		statBaseArg = diffID + "^"
200	}
201	cmd.Dir = gitRoot
202
203	output, err := cmd.Output()
204	if err != nil {
205		http.Error(w, "failed to get diff files", http.StatusInternalServerError)
206		return
207	}
208
209	lines := strings.Split(strings.TrimSpace(string(output)), "\n")
210	var files []GitFileInfo
211
212	for _, line := range lines {
213		if line == "" {
214			continue
215		}
216		parts := strings.Fields(line)
217		if len(parts) < 2 {
218			continue
219		}
220
221		status := "modified"
222		switch parts[0] {
223		case "A":
224			status = "added"
225		case "D":
226			status = "deleted"
227		case "M":
228			status = "modified"
229		}
230
231		// Get additions/deletions for this file
232		statCmd := exec.Command("git", "diff", statBaseArg, "--numstat", "--", parts[1])
233		statCmd.Dir = gitRoot
234		statOutput, _ := statCmd.Output()
235		additions, deletions := 0, 0
236		if statOutput != nil {
237			statParts := strings.Fields(string(statOutput))
238			if len(statParts) >= 2 {
239				additions, _ = strconv.Atoi(statParts[0])
240				deletions, _ = strconv.Atoi(statParts[1])
241			}
242		}
243
244		// Check if file is autogenerated based on path.
245		// For Go files, we could also check content, but that requires reading the file
246		// which is more expensive. Path-based detection covers most cases.
247		isGenerated := IsAutogeneratedPath(parts[1])
248
249		// For Go files that aren't obviously autogenerated by path,
250		// check the file content for autogeneration markers.
251		if !isGenerated && strings.HasSuffix(parts[1], ".go") && status != "deleted" {
252			fullPath := filepath.Join(gitRoot, parts[1])
253			if content, err := os.ReadFile(fullPath); err == nil {
254				isGenerated = isAutogeneratedGoContent(content)
255			}
256		}
257
258		files = append(files, GitFileInfo{
259			Path:        parts[1],
260			Status:      status,
261			Additions:   additions,
262			Deletions:   deletions,
263			IsGenerated: isGenerated,
264		})
265	}
266
267	// Sort files: non-generated first (alphabetically), then generated (alphabetically)
268	sort.Slice(files, func(i, j int) bool {
269		// If one is generated and the other isn't, non-generated comes first
270		if files[i].IsGenerated != files[j].IsGenerated {
271			return !files[i].IsGenerated
272		}
273		// Otherwise, sort alphabetically by path
274		return files[i].Path < files[j].Path
275	})
276
277	w.Header().Set("Content-Type", "application/json")
278	json.NewEncoder(w).Encode(files)
279}
280
281// handleGitFileDiff returns the old and new content for a file
282func (s *Server) handleGitFileDiff(w http.ResponseWriter, r *http.Request) {
283	if r.Method != http.MethodGet {
284		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
285		return
286	}
287
288	// Extract diff ID and file path from: /api/git/file-diff/{id}/*filepath
289	path := strings.TrimPrefix(r.URL.Path, "/api/git/file-diff/")
290	slashIdx := strings.Index(path, "/")
291	if slashIdx < 0 {
292		http.Error(w, "invalid path", http.StatusBadRequest)
293		return
294	}
295	diffID := path[:slashIdx]
296	filePath := path[slashIdx+1:]
297
298	if diffID == "" || filePath == "" {
299		http.Error(w, "invalid path", http.StatusBadRequest)
300		return
301	}
302
303	cwd := r.URL.Query().Get("cwd")
304	if cwd == "" {
305		http.Error(w, "cwd parameter required", http.StatusBadRequest)
306		return
307	}
308
309	gitRoot, err := getGitRoot(cwd)
310	if err != nil {
311		http.Error(w, "not a git repository", http.StatusBadRequest)
312		return
313	}
314
315	// Prevent path traversal
316	cleanPath := filepath.Clean(filePath)
317	if strings.HasPrefix(cleanPath, "..") || filepath.IsAbs(cleanPath) {
318		http.Error(w, "invalid file path", http.StatusBadRequest)
319		return
320	}
321
322	var oldCmd *exec.Cmd
323	if diffID == "working" {
324		oldCmd = exec.Command("git", "show", "HEAD:"+filePath)
325	} else {
326		oldCmd = exec.Command("git", "show", diffID+"^:"+filePath)
327	}
328	oldCmd.Dir = gitRoot
329
330	oldOutput, _ := oldCmd.Output()
331	oldContent := string(oldOutput)
332
333	// Get new version from working tree
334	newContent := ""
335	fullPath := filepath.Join(gitRoot, cleanPath)
336	if file, err := os.Open(fullPath); err == nil {
337		if fileData, err := io.ReadAll(file); err == nil {
338			newContent = string(fileData)
339		}
340		file.Close()
341	}
342
343	fileDiff := GitFileDiff{
344		Path:       filePath,
345		OldContent: oldContent,
346		NewContent: newContent,
347	}
348
349	w.Header().Set("Content-Type", "application/json")
350	json.NewEncoder(w).Encode(fileDiff)
351}