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
452type changedFileResponse struct {
453	Path    string `json:"path"`
454	OldPath string `json:"oldPath,omitempty"`
455	Status  string `json:"status"`
456}
457
458type commitDetailResponse struct {
459	*commitMetaResponse
460	FullMessage string                `json:"fullMessage"`
461	Files       []changedFileResponse `json:"files"`
462}
463
464func (h *gitCommitHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
465	sha := mux.Vars(r)["sha"]
466
467	_, br, err := browseRepo(h.mrc, r)
468	if err != nil {
469		http.Error(w, err.Error(), http.StatusInternalServerError)
470		return
471	}
472
473	detail, err := br.CommitDetail(repository.Hash(sha))
474	if err == repository.ErrNotFound {
475		http.Error(w, "commit not found", http.StatusNotFound)
476		return
477	}
478	if err != nil {
479		http.Error(w, err.Error(), http.StatusInternalServerError)
480		return
481	}
482
483	files := make([]changedFileResponse, len(detail.Files))
484	for i, f := range detail.Files {
485		files[i] = changedFileResponse{Path: f.Path, OldPath: f.OldPath, Status: f.Status}
486	}
487
488	writeJSON(w, commitDetailResponse{
489		commitMetaResponse: toCommitMetaResponse(detail.CommitMeta),
490		FullMessage:        detail.FullMessage,
491		Files:              files,
492	})
493}