package web import ( "errors" "net/http" "strings" "hearthforge/internal/gitcmd" "hearthforge/internal/util" "hearthforge/internal/web/views" ) // commitLog renders one page of history. Pagination is cursor based, so it // stays O(1) no matter how deep the history is. `after` is the last commit of // the previous page; `prev` is the cursor of the page that linked here. func (s *Server) commitLog(w http.ResponseWriter, r *http.Request) { repo, ok := s.visibleRepo(w, r) if !ok { return } ref := refParam(r, "ref") q := r.URL.Query() after := strings.TrimSpace(q.Get("after")) prev := strings.TrimSpace(q.Get("prev")) var ( commits []gitcmd.Commit err error ) if after != "" { commits, err = s.Git.Log(r.Context(), repo.Name, after, commitsPerPage+1, 1) } else { commits, err = s.Git.Log(r.Context(), repo.Name, ref, commitsPerPage+1, 0) } // A ref that does not resolve (empty repo, unknown branch, stale `after` // cursor) is an empty history, not an error. Only a real git failure is // worth a 500. if err != nil && !errors.Is(err, gitcmd.ErrBadRef) && !errors.Is(err, gitcmd.ErrInvalidRef) { http.Error(w, "Not found", gitStatusCode(err)) return } hasNext := len(commits) > commitsPerPage if hasNext { commits = commits[:commitsPerPage] } base := "/" + repo.Name + "/commits/" + views.EscapePath(ref) olderURL, newerURL := "", "" if hasNext && len(commits) > 0 { olderURL = base + "?after=" + commits[len(commits)-1].Hash + "&prev=" + after } if after != "" { newerURL = base if prev != "" { newerURL = base + "?after=" + prev } } branches, _ := s.Git.Branches(r.Context(), repo.Name) tags, _ := s.Git.Tags(r.Context(), repo.Name) views.Render(w, http.StatusOK, views.CommitLog(s.Cfg, User(r), repo, ref, commits, branches, tags, olderURL, newerURL)) } func (s *Server) commitDetail(w http.ResponseWriter, r *http.Request) { repo, ok := s.visibleRepo(w, r) if !ok { return } sha := refParam(r, "sha") meta, err := s.Git.CommitMeta(r.Context(), repo.Name, sha) if err != nil { http.Error(w, "Commit not found", http.StatusNotFound) return } rawDiff, err := s.Git.Diff(r.Context(), repo.Name, sha, s.Cfg.MaxRenderBytes) if errors.Is(err, gitcmd.ErrTooLarge) { views.Render(w, http.StatusOK, views.CommitDetail(s.Cfg, User(r), repo, sha, meta, nil, true)) return } if err != nil { http.Error(w, "Commit not found", gitStatusCode(err)) return } blobSizes := func(hashes []string) map[string]int64 { sizes, _ := s.Git.BlobSizes(r.Context(), repo.Name, hashes) return sizes } // Keyed on the resolved hash: "sha" may be a ref name, and the same name // points at different commits over time. files := s.HL.PrepareDiff(rawDiff, "commit:"+repo.Name+":"+meta.Hash, blobSizes) views.Render(w, http.StatusOK, views.CommitDetail(s.Cfg, User(r), repo, sha, meta, files, false)) } func (s *Server) branchList(w http.ResponseWriter, r *http.Request) { repo, ok := s.visibleRepo(w, r) if !ok { return } all, err := s.Git.BranchesWithInfo(r.Context(), repo.Name, 0) if err != nil { http.Error(w, "Not found", gitStatusCode(err)) return } q := r.URL.Query() page := util.Paginate(util.ParsePage(q.Get("page")), len(all), branchesPerPage) views.Render(w, http.StatusOK, views.BranchList(s.Cfg, User(r), repo, pageSlice(all, page.Offset, branchesPerPage), views.PageInfo{ Page: page.Page, TotalPages: page.TotalPages, URLTemplate: "/" + repo.Name + "/branches?page={page}", }, q.Get("success"), q.Get("error"))) } func (s *Server) tagList(w http.ResponseWriter, r *http.Request) { repo, ok := s.visibleRepo(w, r) if !ok { return } all, err := s.Git.TagsWithInfo(r.Context(), repo.Name, 0) if err != nil { http.Error(w, "Not found", gitStatusCode(err)) return } releases, err := s.DB.ReleaseTags(r.Context(), repo.ID) if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } tagReleases := map[string]int64{} for _, rel := range releases { tagReleases[rel.TagName] = rel.ID } q := r.URL.Query() page := util.Paginate(util.ParsePage(q.Get("page")), len(all), tagsPerPage) views.Render(w, http.StatusOK, views.TagList(s.Cfg, User(r), repo, pageSlice(all, page.Offset, tagsPerPage), tagReleases, views.PageInfo{ Page: page.Page, TotalPages: page.TotalPages, URLTemplate: "/" + repo.Name + "/tags?page={page}", }, q.Get("success"), q.Get("error"))) } // pageSlice returns one page of an already-loaded list. func pageSlice[T any](all []T, offset, limit int) []T { if offset >= len(all) { return nil } return all[offset:min(offset+limit, len(all))] } // validRefName applies the branch name rules of the create and rename forms. func validRefName(name string) bool { return name != "" && len(name) <= maxBranchName && validBranchName.MatchString(name) && !strings.Contains(name, "..") } func (s *Server) createBranch(w http.ResponseWriter, r *http.Request) { repo, ok := s.adminRepo(w, r) if !ok { return } back := "/" + repo.Name + "/branches" name := strings.TrimSpace(r.FormValue("name")) sourceRef := strings.TrimSpace(r.FormValue("source_ref")) if !validRefName(name) { s.backTo(w, r, back, "error", "Invalid branch name.") return } if sourceRef == "" { s.backTo(w, r, back, "error", "Source ref is required.") return } switch err := s.Git.CreateBranch(r.Context(), repo.Name, name, sourceRef); { case err == nil: s.backTo(w, r, back, "success", `Branch "`+name+`" created.`) case errors.Is(err, gitcmd.ErrExists): s.backTo(w, r, back, "error", `Branch "`+name+`" already exists.`) case errors.Is(err, gitcmd.ErrBadRef), errors.Is(err, gitcmd.ErrInvalidRef): s.backTo(w, r, back, "error", `"`+sourceRef+`" is not a valid ref.`) default: s.backTo(w, r, back, "error", "Failed to create branch.") } } func (s *Server) deleteBranch(w http.ResponseWriter, r *http.Request) { repo, ok := s.adminRepo(w, r) if !ok { return } back := "/" + repo.Name + "/branches" name := strings.TrimSpace(r.FormValue("name")) if name == "" { s.backTo(w, r, back, "error", "Branch name is required.") return } if name == repo.DefaultBranch { s.backTo(w, r, back, "error", "Cannot delete the default branch.") return } switch err := s.Git.DeleteBranch(r.Context(), repo.Name, name); { case err == nil: s.backTo(w, r, back, "success", `Branch "`+name+`" deleted.`) case errors.Is(err, gitcmd.ErrNotFound): s.backTo(w, r, back, "error", `Branch "`+name+`" not found.`) default: s.backTo(w, r, back, "error", "Failed to delete branch.") } } func (s *Server) renameBranch(w http.ResponseWriter, r *http.Request) { repo, ok := s.adminRepo(w, r) if !ok { return } back := "/" + repo.Name + "/branches" oldName := strings.TrimSpace(r.FormValue("old_name")) newName := strings.TrimSpace(r.FormValue("new_name")) if !validRefName(newName) { s.backTo(w, r, back, "error", "Invalid branch name.") return } switch err := s.Git.RenameBranch(r.Context(), repo.Name, oldName, newName); { case err == nil: if oldName == repo.DefaultBranch { if err := s.DB.SetDefaultBranch(r.Context(), repo.ID, newName); err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } // HEAD only affects the default checkout, so a failure here is // not worth aborting the rename for. _ = s.Git.SetHead(r.Context(), repo.Name, newName) } s.backTo(w, r, back, "success", `Branch renamed to "`+newName+`".`) case errors.Is(err, gitcmd.ErrNotFound): s.backTo(w, r, back, "error", `Branch "`+oldName+`" not found.`) case errors.Is(err, gitcmd.ErrExists): s.backTo(w, r, back, "error", `Branch "`+newName+`" already exists.`) default: s.backTo(w, r, back, "error", "Failed to rename branch.") } } func (s *Server) createTag(w http.ResponseWriter, r *http.Request) { repo, ok := s.adminRepo(w, r) if !ok { return } back := "/" + repo.Name + "/tags" tagName := strings.TrimSpace(r.FormValue("name")) ref := strings.TrimSpace(r.FormValue("ref")) message := strings.TrimSpace(r.FormValue("message")) if tagName == "" || len(tagName) > maxTagName || !validTagName.MatchString(tagName) { s.backTo(w, r, back, "error", "Invalid tag name.") return } if ref == "" { s.backTo(w, r, back, "error", "Target ref is required.") return } if tooLong(w, message, maxTagMessage) { return } switch err := s.Git.CreateTag(r.Context(), repo.Name, tagName, ref, message, s.committer()); { case err == nil: s.backTo(w, r, back, "success", `Tag "`+tagName+`" created.`) case errors.Is(err, gitcmd.ErrExists): s.backTo(w, r, back, "error", `Tag "`+tagName+`" already exists.`) case errors.Is(err, gitcmd.ErrBadRef), errors.Is(err, gitcmd.ErrInvalidRef): s.backTo(w, r, back, "error", `"`+ref+`" is not a valid ref.`) default: s.backTo(w, r, back, "error", "Failed to create tag.") } } func (s *Server) deleteTag(w http.ResponseWriter, r *http.Request) { repo, ok := s.adminRepo(w, r) if !ok { return } back := "/" + repo.Name + "/tags" tagName := strings.TrimSpace(r.FormValue("name")) if tagName == "" { s.backTo(w, r, back, "error", "Tag name is required.") return } switch err := s.Git.DeleteTag(r.Context(), repo.Name, tagName); { case err == nil: s.backTo(w, r, back, "success", `Tag "`+tagName+`" deleted.`) case errors.Is(err, gitcmd.ErrNotFound): s.backTo(w, r, back, "error", `Tag "`+tagName+`" not found.`) default: s.backTo(w, r, back, "error", "Failed to delete tag.") } }