repos_refs.go
| 1 | package web |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "net/http" |
| 6 | "strings" |
| 7 | |
| 8 | "hearthforge/internal/gitcmd" |
| 9 | "hearthforge/internal/util" |
| 10 | "hearthforge/internal/web/views" |
| 11 | ) |
| 12 | |
| 13 | // commitLog renders one page of history. Pagination is cursor based, so it |
| 14 | // stays O(1) no matter how deep the history is. `after` is the last commit of |
| 15 | // the previous page; `prev` is the cursor of the page that linked here. |
| 16 | func (s *Server) commitLog(w http.ResponseWriter, r *http.Request) { |
| 17 | repo, ok := s.visibleRepo(w, r) |
| 18 | if !ok { |
| 19 | return |
| 20 | } |
| 21 | ref := refParam(r, "ref") |
| 22 | q := r.URL.Query() |
| 23 | after := strings.TrimSpace(q.Get("after")) |
| 24 | prev := strings.TrimSpace(q.Get("prev")) |
| 25 | |
| 26 | var ( |
| 27 | commits []gitcmd.Commit |
| 28 | err error |
| 29 | ) |
| 30 | if after != "" { |
| 31 | commits, err = s.Git.Log(r.Context(), repo.Name, after, commitsPerPage+1, 1) |
| 32 | } else { |
| 33 | commits, err = s.Git.Log(r.Context(), repo.Name, ref, commitsPerPage+1, 0) |
| 34 | } |
| 35 | // A ref that does not resolve (empty repo, unknown branch, stale `after` |
| 36 | // cursor) is an empty history, not an error. Only a real git failure is |
| 37 | // worth a 500. |
| 38 | if err != nil && !errors.Is(err, gitcmd.ErrBadRef) && !errors.Is(err, gitcmd.ErrInvalidRef) { |
| 39 | http.Error(w, "Not found", gitStatusCode(err)) |
| 40 | return |
| 41 | } |
| 42 | hasNext := len(commits) > commitsPerPage |
| 43 | if hasNext { |
| 44 | commits = commits[:commitsPerPage] |
| 45 | } |
| 46 | |
| 47 | base := "/" + repo.Name + "/commits/" + views.EscapePath(ref) |
| 48 | olderURL, newerURL := "", "" |
| 49 | if hasNext && len(commits) > 0 { |
| 50 | olderURL = base + "?after=" + commits[len(commits)-1].Hash + "&prev=" + after |
| 51 | } |
| 52 | if after != "" { |
| 53 | newerURL = base |
| 54 | if prev != "" { |
| 55 | newerURL = base + "?after=" + prev |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | branches, _ := s.Git.Branches(r.Context(), repo.Name) |
| 60 | tags, _ := s.Git.Tags(r.Context(), repo.Name) |
| 61 | views.Render(w, http.StatusOK, views.CommitLog(s.Cfg, User(r), repo, ref, commits, |
| 62 | branches, tags, olderURL, newerURL)) |
| 63 | } |
| 64 | |
| 65 | func (s *Server) commitDetail(w http.ResponseWriter, r *http.Request) { |
| 66 | repo, ok := s.visibleRepo(w, r) |
| 67 | if !ok { |
| 68 | return |
| 69 | } |
| 70 | sha := refParam(r, "sha") |
| 71 | meta, err := s.Git.CommitMeta(r.Context(), repo.Name, sha) |
| 72 | if err != nil { |
| 73 | http.Error(w, "Commit not found", http.StatusNotFound) |
| 74 | return |
| 75 | } |
| 76 | rawDiff, err := s.Git.Diff(r.Context(), repo.Name, sha) |
| 77 | if err != nil { |
| 78 | http.Error(w, "Commit not found", gitStatusCode(err)) |
| 79 | return |
| 80 | } |
| 81 | // Reject an oversized diff after generating it but before highlighting. |
| 82 | if int64(len(rawDiff)) > s.Cfg.MaxRenderBytes { |
| 83 | views.Render(w, http.StatusOK, views.CommitDetail(s.Cfg, User(r), repo, sha, meta, |
| 84 | nil, len(rawDiff))) |
| 85 | return |
| 86 | } |
| 87 | blobSize := func(hash string) int64 { |
| 88 | n, err := s.Git.BlobSize(r.Context(), repo.Name, hash) |
| 89 | if err != nil { |
| 90 | return 0 |
| 91 | } |
| 92 | return n |
| 93 | } |
| 94 | // Keyed on the resolved hash: "sha" may be a ref name, and the same name |
| 95 | // points at different commits over time. |
| 96 | files := s.HL.PrepareDiff(rawDiff, "commit:"+repo.Name+":"+meta.Hash, blobSize) |
| 97 | views.Render(w, http.StatusOK, views.CommitDetail(s.Cfg, User(r), repo, sha, meta, files, 0)) |
| 98 | } |
| 99 | |
| 100 | func (s *Server) branchList(w http.ResponseWriter, r *http.Request) { |
| 101 | repo, ok := s.visibleRepo(w, r) |
| 102 | if !ok { |
| 103 | return |
| 104 | } |
| 105 | all, err := s.Git.BranchesWithInfo(r.Context(), repo.Name, 0) |
| 106 | if err != nil { |
| 107 | http.Error(w, "Not found", gitStatusCode(err)) |
| 108 | return |
| 109 | } |
| 110 | q := r.URL.Query() |
| 111 | page := util.Paginate(util.ParsePage(q.Get("page")), len(all), branchesPerPage) |
| 112 | views.Render(w, http.StatusOK, views.BranchList(s.Cfg, User(r), repo, |
| 113 | pageSlice(all, page.Offset, branchesPerPage), |
| 114 | views.PageInfo{ |
| 115 | Page: page.Page, TotalPages: page.TotalPages, |
| 116 | URLTemplate: "/" + repo.Name + "/branches?page={page}", |
| 117 | }, |
| 118 | q.Get("success"), q.Get("error"))) |
| 119 | } |
| 120 | |
| 121 | func (s *Server) tagList(w http.ResponseWriter, r *http.Request) { |
| 122 | repo, ok := s.visibleRepo(w, r) |
| 123 | if !ok { |
| 124 | return |
| 125 | } |
| 126 | all, err := s.Git.TagsWithInfo(r.Context(), repo.Name, 0) |
| 127 | if err != nil { |
| 128 | http.Error(w, "Not found", gitStatusCode(err)) |
| 129 | return |
| 130 | } |
| 131 | releases, err := s.DB.ReleaseTags(r.Context(), repo.ID) |
| 132 | if err != nil { |
| 133 | http.Error(w, "Database error", http.StatusInternalServerError) |
| 134 | return |
| 135 | } |
| 136 | tagReleases := map[string]int64{} |
| 137 | for _, rel := range releases { |
| 138 | tagReleases[rel.TagName] = rel.ID |
| 139 | } |
| 140 | q := r.URL.Query() |
| 141 | page := util.Paginate(util.ParsePage(q.Get("page")), len(all), tagsPerPage) |
| 142 | views.Render(w, http.StatusOK, views.TagList(s.Cfg, User(r), repo, |
| 143 | pageSlice(all, page.Offset, tagsPerPage), tagReleases, |
| 144 | views.PageInfo{ |
| 145 | Page: page.Page, TotalPages: page.TotalPages, |
| 146 | URLTemplate: "/" + repo.Name + "/tags?page={page}", |
| 147 | }, |
| 148 | q.Get("success"), q.Get("error"))) |
| 149 | } |
| 150 | |
| 151 | // pageSlice returns one page of an already-loaded list. |
| 152 | func pageSlice[T any](all []T, offset, limit int) []T { |
| 153 | if offset >= len(all) { |
| 154 | return nil |
| 155 | } |
| 156 | return all[offset:min(offset+limit, len(all))] |
| 157 | } |
| 158 | |
| 159 | // validRefName applies the branch name rules of the create and rename forms. |
| 160 | func validRefName(name string) bool { |
| 161 | return name != "" && len(name) <= maxBranchName && |
| 162 | validBranchName.MatchString(name) && !strings.Contains(name, "..") |
| 163 | } |
| 164 | |
| 165 | func (s *Server) createBranch(w http.ResponseWriter, r *http.Request) { |
| 166 | repo, ok := s.adminRepo(w, r) |
| 167 | if !ok { |
| 168 | return |
| 169 | } |
| 170 | back := "/" + repo.Name + "/branches" |
| 171 | name := strings.TrimSpace(r.FormValue("name")) |
| 172 | sourceRef := strings.TrimSpace(r.FormValue("source_ref")) |
| 173 | if !validRefName(name) { |
| 174 | s.backTo(w, r, back, "error", "Invalid branch name.") |
| 175 | return |
| 176 | } |
| 177 | if sourceRef == "" { |
| 178 | s.backTo(w, r, back, "error", "Source ref is required.") |
| 179 | return |
| 180 | } |
| 181 | switch err := s.Git.CreateBranch(r.Context(), repo.Name, name, sourceRef); { |
| 182 | case err == nil: |
| 183 | s.backTo(w, r, back, "success", `Branch "`+name+`" created.`) |
| 184 | case errors.Is(err, gitcmd.ErrExists): |
| 185 | s.backTo(w, r, back, "error", `Branch "`+name+`" already exists.`) |
| 186 | case errors.Is(err, gitcmd.ErrBadRef), errors.Is(err, gitcmd.ErrInvalidRef): |
| 187 | s.backTo(w, r, back, "error", `"`+sourceRef+`" is not a valid ref.`) |
| 188 | default: |
| 189 | s.backTo(w, r, back, "error", "Failed to create branch.") |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | func (s *Server) deleteBranch(w http.ResponseWriter, r *http.Request) { |
| 194 | repo, ok := s.adminRepo(w, r) |
| 195 | if !ok { |
| 196 | return |
| 197 | } |
| 198 | back := "/" + repo.Name + "/branches" |
| 199 | name := strings.TrimSpace(r.FormValue("name")) |
| 200 | if name == "" { |
| 201 | s.backTo(w, r, back, "error", "Branch name is required.") |
| 202 | return |
| 203 | } |
| 204 | if name == repo.DefaultBranch { |
| 205 | s.backTo(w, r, back, "error", "Cannot delete the default branch.") |
| 206 | return |
| 207 | } |
| 208 | switch err := s.Git.DeleteBranch(r.Context(), repo.Name, name); { |
| 209 | case err == nil: |
| 210 | s.backTo(w, r, back, "success", `Branch "`+name+`" deleted.`) |
| 211 | case errors.Is(err, gitcmd.ErrNotFound): |
| 212 | s.backTo(w, r, back, "error", `Branch "`+name+`" not found.`) |
| 213 | default: |
| 214 | s.backTo(w, r, back, "error", "Failed to delete branch.") |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | func (s *Server) renameBranch(w http.ResponseWriter, r *http.Request) { |
| 219 | repo, ok := s.adminRepo(w, r) |
| 220 | if !ok { |
| 221 | return |
| 222 | } |
| 223 | back := "/" + repo.Name + "/branches" |
| 224 | oldName := strings.TrimSpace(r.FormValue("old_name")) |
| 225 | newName := strings.TrimSpace(r.FormValue("new_name")) |
| 226 | if !validRefName(newName) { |
| 227 | s.backTo(w, r, back, "error", "Invalid branch name.") |
| 228 | return |
| 229 | } |
| 230 | switch err := s.Git.RenameBranch(r.Context(), repo.Name, oldName, newName); { |
| 231 | case err == nil: |
| 232 | if oldName == repo.DefaultBranch { |
| 233 | if err := s.DB.SetDefaultBranch(r.Context(), repo.ID, newName); err != nil { |
| 234 | http.Error(w, "Database error", http.StatusInternalServerError) |
| 235 | return |
| 236 | } |
| 237 | // HEAD only affects the default checkout, so a failure here is |
| 238 | // not worth aborting the rename for. |
| 239 | _ = s.Git.SetHead(r.Context(), repo.Name, newName) |
| 240 | } |
| 241 | s.backTo(w, r, back, "success", `Branch renamed to "`+newName+`".`) |
| 242 | case errors.Is(err, gitcmd.ErrNotFound): |
| 243 | s.backTo(w, r, back, "error", `Branch "`+oldName+`" not found.`) |
| 244 | case errors.Is(err, gitcmd.ErrExists): |
| 245 | s.backTo(w, r, back, "error", `Branch "`+newName+`" already exists.`) |
| 246 | default: |
| 247 | s.backTo(w, r, back, "error", "Failed to rename branch.") |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | func (s *Server) createTag(w http.ResponseWriter, r *http.Request) { |
| 252 | repo, ok := s.adminRepo(w, r) |
| 253 | if !ok { |
| 254 | return |
| 255 | } |
| 256 | back := "/" + repo.Name + "/tags" |
| 257 | tagName := strings.TrimSpace(r.FormValue("name")) |
| 258 | ref := strings.TrimSpace(r.FormValue("ref")) |
| 259 | message := strings.TrimSpace(r.FormValue("message")) |
| 260 | if tagName == "" || len(tagName) > maxTagName || !validTagName.MatchString(tagName) { |
| 261 | s.backTo(w, r, back, "error", "Invalid tag name.") |
| 262 | return |
| 263 | } |
| 264 | if ref == "" { |
| 265 | s.backTo(w, r, back, "error", "Target ref is required.") |
| 266 | return |
| 267 | } |
| 268 | if tooLong(w, message, maxTagMessage) { |
| 269 | return |
| 270 | } |
| 271 | switch err := s.Git.CreateTag(r.Context(), repo.Name, tagName, ref, message, s.committer()); { |
| 272 | case err == nil: |
| 273 | s.backTo(w, r, back, "success", `Tag "`+tagName+`" created.`) |
| 274 | case errors.Is(err, gitcmd.ErrExists): |
| 275 | s.backTo(w, r, back, "error", `Tag "`+tagName+`" already exists.`) |
| 276 | case errors.Is(err, gitcmd.ErrBadRef), errors.Is(err, gitcmd.ErrInvalidRef): |
| 277 | s.backTo(w, r, back, "error", `"`+ref+`" is not a valid ref.`) |
| 278 | default: |
| 279 | s.backTo(w, r, back, "error", "Failed to create tag.") |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | func (s *Server) deleteTag(w http.ResponseWriter, r *http.Request) { |
| 284 | repo, ok := s.adminRepo(w, r) |
| 285 | if !ok { |
| 286 | return |
| 287 | } |
| 288 | back := "/" + repo.Name + "/tags" |
| 289 | tagName := strings.TrimSpace(r.FormValue("name")) |
| 290 | if tagName == "" { |
| 291 | s.backTo(w, r, back, "error", "Tag name is required.") |
| 292 | return |
| 293 | } |
| 294 | switch err := s.Git.DeleteTag(r.Context(), repo.Name, tagName); { |
| 295 | case err == nil: |
| 296 | s.backTo(w, r, back, "success", `Tag "`+tagName+`" deleted.`) |
| 297 | case errors.Is(err, gitcmd.ErrNotFound): |
| 298 | s.backTo(w, r, back, "error", `Tag "`+tagName+`" not found.`) |
| 299 | default: |
| 300 | s.backTo(w, r, back, "error", "Failed to delete tag.") |
| 301 | } |
| 302 | } |
| 303 |