git_browse_handler.go

  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}