1package http
2
3import (
4 "encoding/json"
5 "fmt"
6 "net/http"
7 "strconv"
8 "strings"
9
10 "github.com/git-bug/git-bug/cache"
11 "github.com/git-bug/git-bug/repository"
12)
13
14// ── shared helpers ────────────────────────────────────────────────────────────
15
16func writeJSON(w http.ResponseWriter, v any) {
17 w.Header().Set("Content-Type", "application/json")
18 if err := json.NewEncoder(w).Encode(v); err != nil {
19 http.Error(w, err.Error(), http.StatusInternalServerError)
20 }
21}
22
23// browseRepo resolves the default repo from the cache and type-asserts to
24// RepoBrowse. All four handlers use this helper.
25func browseRepo(mrc *cache.MultiRepoCache) (repository.ClockedRepo, repository.RepoBrowse, error) {
26 rc, err := mrc.DefaultRepo()
27 if err != nil {
28 return nil, nil, err
29 }
30 underlying := rc.GetRepo()
31 br, ok := underlying.(repository.RepoBrowse)
32 if !ok {
33 return nil, nil, fmt.Errorf("repository does not support code browsing")
34 }
35 return underlying, br, nil
36}
37
38// resolveTreeAtPath walks the git tree of a commit down to the given path.
39// path may be empty (returns root tree entries) or a slash-separated directory path.
40func resolveTreeAtPath(repo repository.ClockedRepo, commitHash repository.Hash, path string) ([]repository.TreeEntry, error) {
41 commit, err := repo.ReadCommit(commitHash)
42 if err != nil {
43 return nil, err
44 }
45
46 entries, err := repo.ReadTree(commit.TreeHash)
47 if err != nil {
48 return nil, err
49 }
50
51 if path == "" {
52 return entries, nil
53 }
54
55 for _, segment := range strings.Split(path, "/") {
56 if segment == "" {
57 continue
58 }
59 entry, ok := repository.SearchTreeEntry(entries, segment)
60 if !ok {
61 return nil, repository.ErrNotFound
62 }
63 if entry.ObjectType != repository.Tree {
64 return nil, repository.ErrNotFound
65 }
66 entries, err = repo.ReadTree(entry.Hash)
67 if err != nil {
68 return nil, err
69 }
70 }
71 return entries, nil
72}
73
74// resolveBlobAtPath walks the tree to the given file path and returns its hash.
75func resolveBlobAtPath(repo repository.ClockedRepo, commitHash repository.Hash, path string) (repository.Hash, error) {
76 parts := strings.Split(path, "/")
77 dirPath := strings.Join(parts[:len(parts)-1], "/")
78 fileName := parts[len(parts)-1]
79
80 entries, err := resolveTreeAtPath(repo, commitHash, dirPath)
81 if err != nil {
82 return "", err
83 }
84
85 entry, ok := repository.SearchTreeEntry(entries, fileName)
86 if !ok {
87 return "", repository.ErrNotFound
88 }
89 if entry.ObjectType != repository.Blob {
90 return "", repository.ErrNotFound
91 }
92 return entry.Hash, nil
93}
94
95// ── GET /api/git/refs ─────────────────────────────────────────────────────────
96
97type gitRefsHandler struct{ mrc *cache.MultiRepoCache }
98
99func NewGitRefsHandler(mrc *cache.MultiRepoCache) http.Handler {
100 return &gitRefsHandler{mrc: mrc}
101}
102
103type refResponse struct {
104 Name string `json:"name"`
105 ShortName string `json:"shortName"`
106 Type string `json:"type"` // "branch" | "tag"
107 Hash string `json:"hash"`
108 IsDefault bool `json:"isDefault"`
109}
110
111func (h *gitRefsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
112 repo, br, err := browseRepo(h.mrc)
113 if err != nil {
114 http.Error(w, err.Error(), http.StatusInternalServerError)
115 return
116 }
117
118 defaultBranch, _ := br.GetDefaultBranch()
119
120 var refs []refResponse
121
122 for _, prefix := range []string{"refs/heads/", "refs/tags/"} {
123 names, err := repo.ListRefs(prefix)
124 if err != nil {
125 http.Error(w, err.Error(), http.StatusInternalServerError)
126 return
127 }
128 for _, name := range names {
129 hash, err := repo.ResolveRef(name)
130 if err != nil {
131 continue
132 }
133 refType := "branch"
134 if prefix == "refs/tags/" {
135 refType = "tag"
136 }
137 short := strings.TrimPrefix(name, prefix)
138 refs = append(refs, refResponse{
139 Name: name,
140 ShortName: short,
141 Type: refType,
142 Hash: hash.String(),
143 IsDefault: short == defaultBranch,
144 })
145 }
146 }
147
148 writeJSON(w, refs)
149}
150
151// ── GET /api/git/tree ─────────────────────────────────────────────────────────
152
153type gitTreeHandler struct{ mrc *cache.MultiRepoCache }
154
155func NewGitTreeHandler(mrc *cache.MultiRepoCache) http.Handler {
156 return &gitTreeHandler{mrc: mrc}
157}
158
159type treeEntryResponse struct {
160 Name string `json:"name"`
161 Type string `json:"type"` // "tree" | "blob"
162 Hash string `json:"hash"`
163 Mode string `json:"mode"`
164 LastCommit *commitMetaResponse `json:"lastCommit,omitempty"`
165}
166
167func (h *gitTreeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
168 ref := r.URL.Query().Get("ref")
169 path := r.URL.Query().Get("path")
170
171 if ref == "" {
172 http.Error(w, "missing ref", http.StatusBadRequest)
173 return
174 }
175
176 repo, br, err := browseRepo(h.mrc)
177 if err != nil {
178 http.Error(w, err.Error(), http.StatusInternalServerError)
179 return
180 }
181
182 commitHash, err := resolveRef(repo, ref)
183 if err != nil {
184 http.Error(w, "ref not found: "+ref, http.StatusNotFound)
185 return
186 }
187
188 entries, err := resolveTreeAtPath(repo, commitHash, path)
189 if err == repository.ErrNotFound {
190 http.Error(w, "path not found", http.StatusNotFound)
191 return
192 }
193 if err != nil {
194 http.Error(w, err.Error(), http.StatusInternalServerError)
195 return
196 }
197
198 // Collect entry names and fetch last commits in one shallow history pass.
199 names := make([]string, len(entries))
200 for i, e := range entries {
201 names[i] = e.Name
202 }
203 lastCommits, _ := br.LastCommitForEntries(ref, path, names) // best-effort
204
205 resp := make([]treeEntryResponse, 0, len(entries))
206 for _, e := range entries {
207 objType := "blob"
208 mode := "100644"
209 if e.ObjectType == repository.Tree {
210 objType = "tree"
211 mode = "040000"
212 }
213
214 item := treeEntryResponse{
215 Name: e.Name,
216 Type: objType,
217 Hash: e.Hash.String(),
218 Mode: mode,
219 }
220 if cm, ok := lastCommits[e.Name]; ok {
221 item.LastCommit = toCommitMetaResponse(cm)
222 }
223
224 resp = append(resp, item)
225 }
226
227 writeJSON(w, resp)
228}
229
230// ── GET /api/git/blob ─────────────────────────────────────────────────────────
231
232type gitBlobHandler struct{ mrc *cache.MultiRepoCache }
233
234func NewGitBlobHandler(mrc *cache.MultiRepoCache) http.Handler {
235 return &gitBlobHandler{mrc: mrc}
236}
237
238type blobResponse struct {
239 Path string `json:"path"`
240 Content string `json:"content"`
241 Size int `json:"size"`
242 IsBinary bool `json:"isBinary"`
243}
244
245func (h *gitBlobHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
246 ref := r.URL.Query().Get("ref")
247 path := r.URL.Query().Get("path")
248
249 if ref == "" || path == "" {
250 http.Error(w, "missing ref or path", http.StatusBadRequest)
251 return
252 }
253
254 repo, _, err := browseRepo(h.mrc)
255 if err != nil {
256 http.Error(w, err.Error(), http.StatusInternalServerError)
257 return
258 }
259
260 commitHash, err := resolveRef(repo, ref)
261 if err != nil {
262 http.Error(w, "ref not found: "+ref, http.StatusNotFound)
263 return
264 }
265
266 blobHash, err := resolveBlobAtPath(repo, commitHash, path)
267 if err == repository.ErrNotFound {
268 http.Error(w, "path not found", http.StatusNotFound)
269 return
270 }
271 if err != nil {
272 http.Error(w, err.Error(), http.StatusInternalServerError)
273 return
274 }
275
276 data, err := repo.ReadData(blobHash)
277 if err != nil {
278 http.Error(w, err.Error(), http.StatusInternalServerError)
279 return
280 }
281
282 isBinary := isBinaryContent(data)
283 content := ""
284 if !isBinary {
285 content = string(data)
286 }
287
288 writeJSON(w, blobResponse{
289 Path: path,
290 Content: content,
291 Size: len(data),
292 IsBinary: isBinary,
293 })
294}
295
296// ── GET /api/git/commits ──────────────────────────────────────────────────────
297
298type gitCommitsHandler struct{ mrc *cache.MultiRepoCache }
299
300func NewGitCommitsHandler(mrc *cache.MultiRepoCache) http.Handler {
301 return &gitCommitsHandler{mrc: mrc}
302}
303
304type commitMetaResponse struct {
305 Hash string `json:"hash"`
306 ShortHash string `json:"shortHash"`
307 Message string `json:"message"`
308 AuthorName string `json:"authorName"`
309 AuthorEmail string `json:"authorEmail"`
310 Date string `json:"date"` // RFC3339
311 Parents []string `json:"parents"`
312}
313
314func toCommitMetaResponse(m repository.CommitMeta) *commitMetaResponse {
315 parents := make([]string, len(m.Parents))
316 for i, p := range m.Parents {
317 parents[i] = p.String()
318 }
319 return &commitMetaResponse{
320 Hash: m.Hash.String(),
321 ShortHash: m.ShortHash,
322 Message: m.Message,
323 AuthorName: m.AuthorName,
324 AuthorEmail: m.AuthorEmail,
325 Date: m.Date.UTC().Format("2006-01-02T15:04:05Z"),
326 Parents: parents,
327 }
328}
329
330func (h *gitCommitsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
331 ref := r.URL.Query().Get("ref")
332 path := r.URL.Query().Get("path")
333 after := repository.Hash(r.URL.Query().Get("after"))
334
335 limit := 20
336 if l := r.URL.Query().Get("limit"); l != "" {
337 if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 100 {
338 limit = n
339 }
340 }
341
342 if ref == "" {
343 http.Error(w, "missing ref", http.StatusBadRequest)
344 return
345 }
346
347 _, br, err := browseRepo(h.mrc)
348 if err != nil {
349 http.Error(w, err.Error(), http.StatusInternalServerError)
350 return
351 }
352
353 commits, err := br.CommitLog(ref, path, limit, after)
354 if err != nil {
355 http.Error(w, err.Error(), http.StatusInternalServerError)
356 return
357 }
358
359 resp := make([]*commitMetaResponse, len(commits))
360 for i, c := range commits {
361 resp[i] = toCommitMetaResponse(c)
362 }
363 writeJSON(w, resp)
364}
365
366// ── GET /api/git/commit ───────────────────────────────────────────────────────
367
368type gitCommitHandler struct{ mrc *cache.MultiRepoCache }
369
370func NewGitCommitHandler(mrc *cache.MultiRepoCache) http.Handler {
371 return &gitCommitHandler{mrc: mrc}
372}
373
374type changedFileResponse struct {
375 Path string `json:"path"`
376 OldPath string `json:"oldPath,omitempty"`
377 Status string `json:"status"`
378}
379
380type commitDetailResponse struct {
381 *commitMetaResponse
382 FullMessage string `json:"fullMessage"`
383 Files []changedFileResponse `json:"files"`
384}
385
386func (h *gitCommitHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
387 hash := r.URL.Query().Get("hash")
388 if hash == "" {
389 http.Error(w, "missing hash", http.StatusBadRequest)
390 return
391 }
392
393 _, br, err := browseRepo(h.mrc)
394 if err != nil {
395 http.Error(w, err.Error(), http.StatusInternalServerError)
396 return
397 }
398
399 detail, err := br.CommitDetail(repository.Hash(hash))
400 if err == repository.ErrNotFound {
401 http.Error(w, "commit not found", http.StatusNotFound)
402 return
403 }
404 if err != nil {
405 http.Error(w, err.Error(), http.StatusInternalServerError)
406 return
407 }
408
409 files := make([]changedFileResponse, len(detail.Files))
410 for i, f := range detail.Files {
411 files[i] = changedFileResponse{Path: f.Path, OldPath: f.OldPath, Status: f.Status}
412 }
413
414 writeJSON(w, commitDetailResponse{
415 commitMetaResponse: toCommitMetaResponse(detail.CommitMeta),
416 FullMessage: detail.FullMessage,
417 Files: files,
418 })
419}
420
421// ── utilities ─────────────────────────────────────────────────────────────────
422
423// resolveRef tries refs/heads/<ref>, refs/tags/<ref>, then raw hash.
424func resolveRef(repo repository.ClockedRepo, ref string) (repository.Hash, error) {
425 for _, prefix := range []string{"refs/heads/", "refs/tags/", ""} {
426 h, err := repo.ResolveRef(prefix + ref)
427 if err == nil {
428 return h, nil
429 }
430 }
431 return "", repository.ErrNotFound
432}
433
434// isBinaryContent returns true if data contains a null byte (simple heuristic).
435func isBinaryContent(data []byte) bool {
436 for _, b := range data {
437 if b == 0 {
438 return true
439 }
440 }
441 return false
442}