repos_refs.go
⎇
Raw
1package web
2
3import (
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.
16func (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
65func (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, s.Cfg.MaxRenderBytes)
77 if errors.Is(err, gitcmd.ErrTooLarge) {
78 views.Render(w, http.StatusOK, views.CommitDetail(s.Cfg, User(r), repo, sha, meta,
79 nil, true))
80 return
81 }
82 if err != nil {
83 http.Error(w, "Commit not found", gitStatusCode(err))
84 return
85 }
86 blobSizes := func(hashes []string) map[string]int64 {
87 sizes, _ := s.Git.BlobSizes(r.Context(), repo.Name, hashes)
88 return sizes
89 }
90 // Keyed on the resolved hash: "sha" may be a ref name, and the same name
91 // points at different commits over time.
92 files := s.HL.PrepareDiff(rawDiff, "commit:"+repo.Name+":"+meta.Hash, blobSizes)
93 views.Render(w, http.StatusOK, views.CommitDetail(s.Cfg, User(r), repo, sha, meta, files, false))
94}
95
96func (s *Server) branchList(w http.ResponseWriter, r *http.Request) {
97 repo, ok := s.visibleRepo(w, r)
98 if !ok {
99 return
100 }
101 all, err := s.Git.BranchesWithInfo(r.Context(), repo.Name, 0)
102 if err != nil {
103 http.Error(w, "Not found", gitStatusCode(err))
104 return
105 }
106 q := r.URL.Query()
107 page := util.Paginate(util.ParsePage(q.Get("page")), len(all), branchesPerPage)
108 views.Render(w, http.StatusOK, views.BranchList(s.Cfg, User(r), repo,
109 pageSlice(all, page.Offset, branchesPerPage),
110 views.PageInfo{
111 Page: page.Page, TotalPages: page.TotalPages,
112 URLTemplate: "/" + repo.Name + "/branches?page={page}",
113 },
114 q.Get("success"), q.Get("error")))
115}
116
117func (s *Server) tagList(w http.ResponseWriter, r *http.Request) {
118 repo, ok := s.visibleRepo(w, r)
119 if !ok {
120 return
121 }
122 all, err := s.Git.TagsWithInfo(r.Context(), repo.Name, 0)
123 if err != nil {
124 http.Error(w, "Not found", gitStatusCode(err))
125 return
126 }
127 releases, err := s.DB.ReleaseTags(r.Context(), repo.ID)
128 if err != nil {
129 http.Error(w, "Database error", http.StatusInternalServerError)
130 return
131 }
132 tagReleases := map[string]int64{}
133 for _, rel := range releases {
134 tagReleases[rel.TagName] = rel.ID
135 }
136 q := r.URL.Query()
137 page := util.Paginate(util.ParsePage(q.Get("page")), len(all), tagsPerPage)
138 views.Render(w, http.StatusOK, views.TagList(s.Cfg, User(r), repo,
139 pageSlice(all, page.Offset, tagsPerPage), tagReleases,
140 views.PageInfo{
141 Page: page.Page, TotalPages: page.TotalPages,
142 URLTemplate: "/" + repo.Name + "/tags?page={page}",
143 },
144 q.Get("success"), q.Get("error")))
145}
146
147// pageSlice returns one page of an already-loaded list.
148func pageSlice[T any](all []T, offset, limit int) []T {
149 if offset >= len(all) {
150 return nil
151 }
152 return all[offset:min(offset+limit, len(all))]
153}
154
155// validRefName applies the branch name rules of the create and rename forms.
156func validRefName(name string) bool {
157 return name != "" && len(name) <= maxBranchName &&
158 validBranchName.MatchString(name) && !strings.Contains(name, "..")
159}
160
161func (s *Server) createBranch(w http.ResponseWriter, r *http.Request) {
162 repo, ok := s.adminRepo(w, r)
163 if !ok {
164 return
165 }
166 back := "/" + repo.Name + "/branches"
167 name := strings.TrimSpace(r.FormValue("name"))
168 sourceRef := strings.TrimSpace(r.FormValue("source_ref"))
169 if !validRefName(name) {
170 s.backTo(w, r, back, "error", "Invalid branch name.")
171 return
172 }
173 if sourceRef == "" {
174 s.backTo(w, r, back, "error", "Source ref is required.")
175 return
176 }
177 switch err := s.Git.CreateBranch(r.Context(), repo.Name, name, sourceRef); {
178 case err == nil:
179 s.backTo(w, r, back, "success", `Branch "`+name+`" created.`)
180 case errors.Is(err, gitcmd.ErrExists):
181 s.backTo(w, r, back, "error", `Branch "`+name+`" already exists.`)
182 case errors.Is(err, gitcmd.ErrBadRef), errors.Is(err, gitcmd.ErrInvalidRef):
183 s.backTo(w, r, back, "error", `"`+sourceRef+`" is not a valid ref.`)
184 default:
185 s.backTo(w, r, back, "error", "Failed to create branch.")
186 }
187}
188
189func (s *Server) deleteBranch(w http.ResponseWriter, r *http.Request) {
190 repo, ok := s.adminRepo(w, r)
191 if !ok {
192 return
193 }
194 back := "/" + repo.Name + "/branches"
195 name := strings.TrimSpace(r.FormValue("name"))
196 if name == "" {
197 s.backTo(w, r, back, "error", "Branch name is required.")
198 return
199 }
200 if name == repo.DefaultBranch {
201 s.backTo(w, r, back, "error", "Cannot delete the default branch.")
202 return
203 }
204 switch err := s.Git.DeleteBranch(r.Context(), repo.Name, name); {
205 case err == nil:
206 s.backTo(w, r, back, "success", `Branch "`+name+`" deleted.`)
207 case errors.Is(err, gitcmd.ErrNotFound):
208 s.backTo(w, r, back, "error", `Branch "`+name+`" not found.`)
209 default:
210 s.backTo(w, r, back, "error", "Failed to delete branch.")
211 }
212}
213
214func (s *Server) renameBranch(w http.ResponseWriter, r *http.Request) {
215 repo, ok := s.adminRepo(w, r)
216 if !ok {
217 return
218 }
219 back := "/" + repo.Name + "/branches"
220 oldName := strings.TrimSpace(r.FormValue("old_name"))
221 newName := strings.TrimSpace(r.FormValue("new_name"))
222 if !validRefName(newName) {
223 s.backTo(w, r, back, "error", "Invalid branch name.")
224 return
225 }
226 switch err := s.Git.RenameBranch(r.Context(), repo.Name, oldName, newName); {
227 case err == nil:
228 if oldName == repo.DefaultBranch {
229 if err := s.DB.SetDefaultBranch(r.Context(), repo.ID, newName); err != nil {
230 http.Error(w, "Database error", http.StatusInternalServerError)
231 return
232 }
233 // HEAD only affects the default checkout, so a failure here is
234 // not worth aborting the rename for.
235 _ = s.Git.SetHead(r.Context(), repo.Name, newName)
236 }
237 s.backTo(w, r, back, "success", `Branch renamed to "`+newName+`".`)
238 case errors.Is(err, gitcmd.ErrNotFound):
239 s.backTo(w, r, back, "error", `Branch "`+oldName+`" not found.`)
240 case errors.Is(err, gitcmd.ErrExists):
241 s.backTo(w, r, back, "error", `Branch "`+newName+`" already exists.`)
242 default:
243 s.backTo(w, r, back, "error", "Failed to rename branch.")
244 }
245}
246
247func (s *Server) createTag(w http.ResponseWriter, r *http.Request) {
248 repo, ok := s.adminRepo(w, r)
249 if !ok {
250 return
251 }
252 back := "/" + repo.Name + "/tags"
253 tagName := strings.TrimSpace(r.FormValue("name"))
254 ref := strings.TrimSpace(r.FormValue("ref"))
255 message := strings.TrimSpace(r.FormValue("message"))
256 if tagName == "" || len(tagName) > maxTagName || !validTagName.MatchString(tagName) {
257 s.backTo(w, r, back, "error", "Invalid tag name.")
258 return
259 }
260 if ref == "" {
261 s.backTo(w, r, back, "error", "Target ref is required.")
262 return
263 }
264 if tooLong(w, message, maxTagMessage) {
265 return
266 }
267 switch err := s.Git.CreateTag(r.Context(), repo.Name, tagName, ref, message, s.committer()); {
268 case err == nil:
269 s.backTo(w, r, back, "success", `Tag "`+tagName+`" created.`)
270 case errors.Is(err, gitcmd.ErrExists):
271 s.backTo(w, r, back, "error", `Tag "`+tagName+`" already exists.`)
272 case errors.Is(err, gitcmd.ErrBadRef), errors.Is(err, gitcmd.ErrInvalidRef):
273 s.backTo(w, r, back, "error", `"`+ref+`" is not a valid ref.`)
274 default:
275 s.backTo(w, r, back, "error", "Failed to create tag.")
276 }
277}
278
279func (s *Server) deleteTag(w http.ResponseWriter, r *http.Request) {
280 repo, ok := s.adminRepo(w, r)
281 if !ok {
282 return
283 }
284 back := "/" + repo.Name + "/tags"
285 tagName := strings.TrimSpace(r.FormValue("name"))
286 if tagName == "" {
287 s.backTo(w, r, back, "error", "Tag name is required.")
288 return
289 }
290 switch err := s.Git.DeleteTag(r.Context(), repo.Name, tagName); {
291 case err == nil:
292 s.backTo(w, r, back, "success", `Tag "`+tagName+`" deleted.`)
293 case errors.Is(err, gitcmd.ErrNotFound):
294 s.backTo(w, r, back, "error", `Tag "`+tagName+`" not found.`)
295 default:
296 s.backTo(w, r, back, "error", "Failed to delete tag.")
297 }
298}
299