git_browse_handler.go

  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}