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}