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)
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
100func (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
121func (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.
152func 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.
160func validRefName(name string) bool {
161 return name != "" && len(name) <= maxBranchName &&
162 validBranchName.MatchString(name) && !strings.Contains(name, "..")
163}
164
165func (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
193func (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
218func (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
251func (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
283func (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