1package http
2
3import (
4 "encoding/json"
5 "fmt"
6 "net/http"
7 "strconv"
8 "strings"
9
10 "github.com/gorilla/mux"
11
12 "github.com/git-bug/git-bug/cache"
13 "github.com/git-bug/git-bug/repository"
14)
15
16// ── shared helpers ────────────────────────────────────────────────────────────
17
18func writeJSON(w http.ResponseWriter, v any) {
19 w.Header().Set("Content-Type", "application/json")
20 if err := json.NewEncoder(w).Encode(v); err != nil {
21 http.Error(w, err.Error(), http.StatusInternalServerError)
22 }
23}
24
25// repoFromPath resolves the repository from the {owner} and {repo} mux path
26// variables. "_" is the wildcard value: owner is always ignored (single-owner
27// for now), and repo "_" resolves to the default repository.
28func repoFromPath(mrc *cache.MultiRepoCache, r *http.Request) (*cache.RepoCache, error) {
29 repoVar := mux.Vars(r)["repo"]
30 if repoVar == "_" {
31 return mrc.DefaultRepo()
32 }
33 return mrc.ResolveRepo(repoVar)
34}
35
36// browseRepo resolves the repository and asserts it implements RepoBrowse.
37func browseRepo(mrc *cache.MultiRepoCache, r *http.Request) (repository.ClockedRepo, repository.RepoBrowse, error) {
38 rc, err := repoFromPath(mrc, r)
39 if err != nil {
40 return nil, nil, err
41 }
42 underlying := rc.GetRepo()
43 br, ok := underlying.(repository.RepoBrowse)
44 if !ok {
45 return nil, nil, fmt.Errorf("repository does not support code browsing")
46 }
47 return underlying, br, nil
48}
49
50// resolveRef tries refs/heads/<ref>, refs/tags/<ref>, then a raw hash.
51func resolveRef(repo repository.ClockedRepo, ref string) (repository.Hash, error) {
52 for _, prefix := range []string{"refs/heads/", "refs/tags/", ""} {
53 h, err := repo.ResolveRef(prefix + ref)
54 if err == nil {
55 return h, nil
56 }
57 }
58 return "", repository.ErrNotFound
59}
60
61// resolveTreeAtPath walks the git tree of a commit down to the given path.
62func resolveTreeAtPath(repo repository.ClockedRepo, commitHash repository.Hash, path string) ([]repository.TreeEntry, error) {
63 commit, err := repo.ReadCommit(commitHash)
64 if err != nil {
65 return nil, err
66 }
67
68 entries, err := repo.ReadTree(commit.TreeHash)
69 if err != nil {
70 return nil, err
71 }
72
73 if path == "" {
74 return entries, nil
75 }
76
77 for _, segment := range strings.Split(path, "/") {
78 if segment == "" {
79 continue
80 }
81 entry, ok := repository.SearchTreeEntry(entries, segment)
82 if !ok {
83 return nil, repository.ErrNotFound
84 }
85 if entry.ObjectType != repository.Tree {
86 return nil, repository.ErrNotFound
87 }
88 entries, err = repo.ReadTree(entry.Hash)
89 if err != nil {
90 return nil, err
91 }
92 }
93 return entries, nil
94}
95
96// resolveBlobAtPath walks the tree to the given file path and returns its hash.
97func resolveBlobAtPath(repo repository.ClockedRepo, commitHash repository.Hash, path string) (repository.Hash, error) {
98 parts := strings.Split(path, "/")
99 dirPath := strings.Join(parts[:len(parts)-1], "/")
100 fileName := parts[len(parts)-1]
101
102 entries, err := resolveTreeAtPath(repo, commitHash, dirPath)
103 if err != nil {
104 return "", err
105 }
106
107 entry, ok := repository.SearchTreeEntry(entries, fileName)
108 if !ok {
109 return "", repository.ErrNotFound
110 }
111 if entry.ObjectType != repository.Blob {
112 return "", repository.ErrNotFound
113 }
114 return entry.Hash, nil
115}
116
117// isBinaryContent returns true if data contains a null byte (simple heuristic).
118func isBinaryContent(data []byte) bool {
119 for _, b := range data {
120 if b == 0 {
121 return true
122 }
123 }
124 return false
125}
126
127// ── GET /api/repos/{owner}/{repo}/git/refs ────────────────────────────────────
128
129type gitRefsHandler struct{ mrc *cache.MultiRepoCache }
130
131func NewGitRefsHandler(mrc *cache.MultiRepoCache) http.Handler {
132 return &gitRefsHandler{mrc: mrc}
133}
134
135type refResponse struct {
136 Name string `json:"name"`
137 ShortName string `json:"shortName"`
138 Type string `json:"type"` // "branch" | "tag"
139 Hash string `json:"hash"`
140 IsDefault bool `json:"isDefault"`
141}
142
143func (h *gitRefsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
144 repo, br, err := browseRepo(h.mrc, r)
145 if err != nil {
146 http.Error(w, err.Error(), http.StatusInternalServerError)
147 return
148 }
149
150 defaultBranch, _ := br.GetDefaultBranch()
151
152 var refs []refResponse
153 for _, prefix := range []string{"refs/heads/", "refs/tags/"} {
154 names, err := repo.ListRefs(prefix)
155 if err != nil {
156 http.Error(w, err.Error(), http.StatusInternalServerError)
157 return
158 }
159 for _, name := range names {
160 hash, err := repo.ResolveRef(name)
161 if err != nil {
162 continue
163 }
164 refType := "branch"
165 if prefix == "refs/tags/" {
166 refType = "tag"
167 }
168 short := strings.TrimPrefix(name, prefix)
169 refs = append(refs, refResponse{
170 Name: name,
171 ShortName: short,
172 Type: refType,
173 Hash: hash.String(),
174 IsDefault: short == defaultBranch,
175 })
176 }
177 }
178
179 writeJSON(w, refs)
180}
181
182// ── GET /api/repos/{owner}/{repo}/git/trees/{ref}?path= ──────────────────────
183
184type gitTreeHandler struct{ mrc *cache.MultiRepoCache }
185
186func NewGitTreeHandler(mrc *cache.MultiRepoCache) http.Handler {
187 return &gitTreeHandler{mrc: mrc}
188}
189
190type treeEntryResponse struct {
191 Name string `json:"name"`
192 Type string `json:"type"` // "tree" | "blob"
193 Hash string `json:"hash"`
194 Mode string `json:"mode"`
195 LastCommit *commitMetaResponse `json:"lastCommit,omitempty"`
196}
197
198func (h *gitTreeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
199 ref := mux.Vars(r)["ref"]
200 path := r.URL.Query().Get("path")
201
202 repo, br, err := browseRepo(h.mrc, r)
203 if err != nil {
204 http.Error(w, err.Error(), http.StatusInternalServerError)
205 return
206 }
207
208 commitHash, err := resolveRef(repo, ref)
209 if err != nil {
210 http.Error(w, "ref not found", http.StatusNotFound)
211 return
212 }
213
214 entries, err := resolveTreeAtPath(repo, commitHash, path)
215 if err == repository.ErrNotFound {
216 http.Error(w, "path not found", http.StatusNotFound)
217 return
218 }
219 if err != nil {
220 http.Error(w, err.Error(), http.StatusInternalServerError)
221 return
222 }
223
224 names := make([]string, len(entries))
225 for i, e := range entries {
226 names[i] = e.Name
227 }
228 lastCommits, _ := br.LastCommitForEntries(ref, path, names)
229
230 resp := make([]treeEntryResponse, 0, len(entries))
231 for _, e := range entries {
232 objType := "blob"
233 mode := "100644"
234 if e.ObjectType == repository.Tree {
235 objType = "tree"
236 mode = "040000"
237 }
238 item := treeEntryResponse{
239 Name: e.Name,
240 Type: objType,
241 Hash: e.Hash.String(),
242 Mode: mode,
243 }
244 if cm, ok := lastCommits[e.Name]; ok {
245 item.LastCommit = toCommitMetaResponse(cm)
246 }
247 resp = append(resp, item)
248 }
249
250 writeJSON(w, resp)
251}
252
253// ── GET /api/repos/{owner}/{repo}/git/blobs/{ref}?path= ──────────────────────
254
255type gitBlobHandler struct{ mrc *cache.MultiRepoCache }
256
257func NewGitBlobHandler(mrc *cache.MultiRepoCache) http.Handler {
258 return &gitBlobHandler{mrc: mrc}
259}
260
261type blobResponse struct {
262 Path string `json:"path"`
263 Content string `json:"content"`
264 Size int `json:"size"`
265 IsBinary bool `json:"isBinary"`
266}
267
268func (h *gitBlobHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
269 ref := mux.Vars(r)["ref"]
270 path := r.URL.Query().Get("path")
271
272 if path == "" {
273 http.Error(w, "missing path", http.StatusBadRequest)
274 return
275 }
276
277 repo, _, err := browseRepo(h.mrc, r)
278 if err != nil {
279 http.Error(w, err.Error(), http.StatusInternalServerError)
280 return
281 }
282
283 commitHash, err := resolveRef(repo, ref)
284 if err != nil {
285 http.Error(w, "ref not found", http.StatusNotFound)
286 return
287 }
288
289 blobHash, err := resolveBlobAtPath(repo, commitHash, path)
290 if err == repository.ErrNotFound {
291 http.Error(w, "path not found", http.StatusNotFound)
292 return
293 }
294 if err != nil {
295 http.Error(w, err.Error(), http.StatusInternalServerError)
296 return
297 }
298
299 data, err := repo.ReadData(blobHash)
300 if err != nil {
301 http.Error(w, err.Error(), http.StatusInternalServerError)
302 return
303 }
304
305 isBinary := isBinaryContent(data)
306 content := ""
307 if !isBinary {
308 content = string(data)
309 }
310
311 writeJSON(w, blobResponse{
312 Path: path,
313 Content: content,
314 Size: len(data),
315 IsBinary: isBinary,
316 })
317}
318
319// ── GET /api/repos/{owner}/{repo}/git/raw/{ref}/{path} ───────────────────────
320// Serves the raw file content for download. ref and path are both in the URL
321// path, producing human-readable download URLs like:
322//
323// /api/repos/_/_/git/raw/main/src/foo/bar.go
324
325type gitRawHandler struct{ mrc *cache.MultiRepoCache }
326
327func NewGitRawHandler(mrc *cache.MultiRepoCache) http.Handler {
328 return &gitRawHandler{mrc: mrc}
329}
330
331func (h *gitRawHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
332 ref := mux.Vars(r)["ref"]
333 path := mux.Vars(r)["path"]
334
335 if path == "" {
336 http.Error(w, "missing path", http.StatusBadRequest)
337 return
338 }
339
340 repo, _, err := browseRepo(h.mrc, r)
341 if err != nil {
342 http.Error(w, err.Error(), http.StatusInternalServerError)
343 return
344 }
345
346 commitHash, err := resolveRef(repo, ref)
347 if err != nil {
348 http.Error(w, "ref not found", http.StatusNotFound)
349 return
350 }
351
352 blobHash, err := resolveBlobAtPath(repo, commitHash, path)
353 if err == repository.ErrNotFound {
354 http.Error(w, "path not found", http.StatusNotFound)
355 return
356 }
357 if err != nil {
358 http.Error(w, err.Error(), http.StatusInternalServerError)
359 return
360 }
361
362 data, err := repo.ReadData(blobHash)
363 if err != nil {
364 http.Error(w, err.Error(), http.StatusInternalServerError)
365 return
366 }
367
368 fileName := path[strings.LastIndex(path, "/")+1:]
369 w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, fileName))
370 w.Header().Set("Content-Type", "application/octet-stream")
371 w.Write(data)
372}
373
374// ── GET /api/repos/{owner}/{repo}/git/commits?ref=&path=&limit=&after= ───────
375
376type gitCommitsHandler struct{ mrc *cache.MultiRepoCache }
377
378func NewGitCommitsHandler(mrc *cache.MultiRepoCache) http.Handler {
379 return &gitCommitsHandler{mrc: mrc}
380}
381
382type commitMetaResponse struct {
383 Hash string `json:"hash"`
384 ShortHash string `json:"shortHash"`
385 Message string `json:"message"`
386 AuthorName string `json:"authorName"`
387 AuthorEmail string `json:"authorEmail"`
388 Date string `json:"date"` // RFC3339
389 Parents []string `json:"parents"`
390}
391
392func toCommitMetaResponse(m repository.CommitMeta) *commitMetaResponse {
393 parents := make([]string, len(m.Parents))
394 for i, p := range m.Parents {
395 parents[i] = p.String()
396 }
397 return &commitMetaResponse{
398 Hash: m.Hash.String(),
399 ShortHash: m.ShortHash,
400 Message: m.Message,
401 AuthorName: m.AuthorName,
402 AuthorEmail: m.AuthorEmail,
403 Date: m.Date.UTC().Format("2006-01-02T15:04:05Z"),
404 Parents: parents,
405 }
406}
407
408func (h *gitCommitsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
409 ref := r.URL.Query().Get("ref")
410 path := r.URL.Query().Get("path")
411 after := repository.Hash(r.URL.Query().Get("after"))
412
413 limit := 20
414 if l := r.URL.Query().Get("limit"); l != "" {
415 if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 100 {
416 limit = n
417 }
418 }
419
420 if ref == "" {
421 http.Error(w, "missing ref", http.StatusBadRequest)
422 return
423 }
424
425 _, br, err := browseRepo(h.mrc, r)
426 if err != nil {
427 http.Error(w, err.Error(), http.StatusInternalServerError)
428 return
429 }
430
431 commits, err := br.CommitLog(ref, path, limit, after)
432 if err != nil {
433 http.Error(w, err.Error(), http.StatusInternalServerError)
434 return
435 }
436
437 resp := make([]*commitMetaResponse, len(commits))
438 for i, c := range commits {
439 resp[i] = toCommitMetaResponse(c)
440 }
441 writeJSON(w, resp)
442}
443
444// ── GET /api/repos/{owner}/{repo}/git/commits/{sha} ──────────────────────────
445
446type gitCommitHandler struct{ mrc *cache.MultiRepoCache }
447
448func NewGitCommitHandler(mrc *cache.MultiRepoCache) http.Handler {
449 return &gitCommitHandler{mrc: mrc}
450}
451
452// ── GET /api/repos/{owner}/{repo}/git/commits/{sha}/diff?path= ───────────────
453
454type gitCommitDiffHandler struct{ mrc *cache.MultiRepoCache }
455
456func NewGitCommitDiffHandler(mrc *cache.MultiRepoCache) http.Handler {
457 return &gitCommitDiffHandler{mrc: mrc}
458}
459
460func (h *gitCommitDiffHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
461 sha := mux.Vars(r)["sha"]
462 filePath := r.URL.Query().Get("path")
463 if filePath == "" {
464 http.Error(w, "missing path", http.StatusBadRequest)
465 return
466 }
467
468 _, br, err := browseRepo(h.mrc, r)
469 if err != nil {
470 http.Error(w, err.Error(), http.StatusInternalServerError)
471 return
472 }
473
474 fd, err := br.CommitFileDiff(repository.Hash(sha), filePath)
475 if err == repository.ErrNotFound {
476 http.Error(w, "not found", http.StatusNotFound)
477 return
478 }
479 if err != nil {
480 http.Error(w, err.Error(), http.StatusInternalServerError)
481 return
482 }
483
484 type diffLineResp struct {
485 Type string `json:"type"`
486 Content string `json:"content"`
487 OldLine int `json:"oldLine,omitempty"`
488 NewLine int `json:"newLine,omitempty"`
489 }
490 type diffHunkResp struct {
491 OldStart int `json:"oldStart"`
492 OldLines int `json:"oldLines"`
493 NewStart int `json:"newStart"`
494 NewLines int `json:"newLines"`
495 Lines []diffLineResp `json:"lines"`
496 }
497 type fileDiffResp struct {
498 Path string `json:"path"`
499 OldPath string `json:"oldPath,omitempty"`
500 IsBinary bool `json:"isBinary"`
501 IsNew bool `json:"isNew"`
502 IsDelete bool `json:"isDelete"`
503 Hunks []diffHunkResp `json:"hunks"`
504 }
505
506 hunks := make([]diffHunkResp, len(fd.Hunks))
507 for i, h := range fd.Hunks {
508 lines := make([]diffLineResp, len(h.Lines))
509 for j, l := range h.Lines {
510 lines[j] = diffLineResp{Type: l.Type, Content: l.Content, OldLine: l.OldLine, NewLine: l.NewLine}
511 }
512 hunks[i] = diffHunkResp{OldStart: h.OldStart, OldLines: h.OldLines, NewStart: h.NewStart, NewLines: h.NewLines, Lines: lines}
513 }
514
515 writeJSON(w, fileDiffResp{
516 Path: fd.Path,
517 OldPath: fd.OldPath,
518 IsBinary: fd.IsBinary,
519 IsNew: fd.IsNew,
520 IsDelete: fd.IsDelete,
521 Hunks: hunks,
522 })
523}
524
525type changedFileResponse struct {
526 Path string `json:"path"`
527 OldPath string `json:"oldPath,omitempty"`
528 Status string `json:"status"`
529}
530
531type commitDetailResponse struct {
532 *commitMetaResponse
533 FullMessage string `json:"fullMessage"`
534 Files []changedFileResponse `json:"files"`
535}
536
537func (h *gitCommitHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
538 sha := mux.Vars(r)["sha"]
539
540 _, br, err := browseRepo(h.mrc, r)
541 if err != nil {
542 http.Error(w, err.Error(), http.StatusInternalServerError)
543 return
544 }
545
546 detail, err := br.CommitDetail(repository.Hash(sha))
547 if err == repository.ErrNotFound {
548 http.Error(w, "commit not found", http.StatusNotFound)
549 return
550 }
551 if err != nil {
552 http.Error(w, err.Error(), http.StatusInternalServerError)
553 return
554 }
555
556 files := make([]changedFileResponse, len(detail.Files))
557 for i, f := range detail.Files {
558 files[i] = changedFileResponse{Path: f.Path, OldPath: f.OldPath, Status: f.Status}
559 }
560
561 writeJSON(w, commitDetailResponse{
562 commitMetaResponse: toCommitMetaResponse(detail.CommitMeta),
563 FullMessage: detail.FullMessage,
564 Files: files,
565 })
566}