vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Store the overview's summaries when a push lands
2a8f7cef47240564179a45126863d1ccd8d9d362 · Keenan Nemetz · 10d ago · parent 8dc56e4e
Store the overview's summaries when a push lands The repository overview walked git on every read: `rev-parse` for the tip, `log` for the last five commits, `for-each-ref` for tags, `ls-tree` plus `shortlog` for languages and contributors, and a `cat-file` for the README. All of those describe a ref except the README, and a ref moves only when a push lands, so the push now records them. - Schema v10 adds `repo_languages`, `repo_commits`, `repo_contributors` and `repo_tags`. - `RefreshHead` stores the tip's newest commits and its language breakdown from one `ls-tree -r -l`, and returns whether the tip moved. - `RefreshContributors` stores the top authors; the hook calls it only when the tip changed, never on a metadata or tag push. - `RefreshTags` stores the tag rows beside the release events it already rewrote, and `IndexRepo` now refreshes tags and contributors too, so a rebuild restores everything a push would have written. - The overview reads those rows when the recorded head is the branch tip (`RefCommit` is a loose-ref file read) and falls back to git when it is not, so a missed hook shows the correct page rather than a stale one. If one stored section is empty on a repository with commits, that section alone is re-read from git. - The sidebar asks SQL for five open issues and changes instead of sorting the whole open queue in memory. - The rendered README is cached by repository, commit and ref under a byte budget, so the overview's last git spawn is gone after the first read of a commit. - The language fallback reads one `ls-tree -r -l` instead of `ls-tree` followed by a `cat-file --batch-check`. - `Remove` deletes every derived row for a repository, not only its row. Load harness at concurrency 4: overview goes from 473 to about 740 req/s, p50 from 8 ms to 5 ms, and the server runs no git child process for the warm requests.
server/web/overview.go +108−45
| @@ -62,27 +62,11 @@ func (w *Web) Overview(rw http.ResponseWriter, r *http.Request) { | |||
| 62 | 62 | } | |
| 63 | 63 | g := m.Git() | |
| 64 | 64 | ref := m.DefaultBranch | |
| 65 | commit, _ := g.RevParse(ref) | ||
| 65 | // A loose branch is a file read. RefCommit still falls back to rev-parse for | ||
| 66 | // a packed ref, and the answer is what the index's summary is checked against. | ||
| 67 | commit, _ := g.RefCommit("refs/heads/" + ref) | ||
| 66 | 68 | ||
| 67 | var commits []git.Commit | ||
| 68 | var languages []languageShare | ||
| 69 | if commit != "" { | ||
| 70 | commits, _ = g.Log(commit, 5) | ||
| 71 | languages = w.languages(g, commit) | ||
| 72 | } | ||
| 73 | |||
| 74 | var tags []git.Tag | ||
| 75 | if list, err := g.Tags(); err == nil { | ||
| 76 | if len(list) > 5 { | ||
| 77 | list = list[:5] | ||
| 78 | } | ||
| 79 | tags = list | ||
| 80 | } | ||
| 81 | |||
| 82 | contributors := w.contributors(g, commit) | ||
| 83 | if len(contributors) > 8 { | ||
| 84 | contributors = contributors[:8] | ||
| 85 | } | ||
| 69 | commits, languages, contributors, tags := w.overviewSummaries(memoOf(r), m, commit) | ||
| 86 | 70 | ||
| 87 | 71 | var recentRuns []*runs.Run | |
| 88 | 72 | if w.Runs != nil { | |
| @@ -124,6 +108,82 @@ func (w *Web) Overview(rw http.ResponseWriter, r *http.Request) { | |||
| 124 | 108 | }, 0) | |
| 125 | 109 | } | |
| 126 | 110 | ||
| 111 | // overviewSummaries returns the parts of the overview that describe the default | ||
| 112 | // branch: its newest commits, its languages, its authors, and the repository's | ||
| 113 | // tags. When the index has the summary for this exact tip they are rows; when it | ||
| 114 | // does not (no index, or a tip the push hook has not recorded) they are read from | ||
| 115 | // git, which is the fallback that keeps the page correct. | ||
| 116 | func (w *Web) overviewSummaries(memo *requestMemo, m *repo.Meta, commit string) ( | ||
| 117 | commits []git.Commit, languages []languageShare, contributors []contributorView, tags []git.Tag) { | ||
| 118 | g := m.Git() | ||
| 119 | if w.Index != nil { | ||
| 120 | if err := w.ensureIndex(memo, m); err == nil { | ||
| 121 | if e, err := w.Index.Get(m.Name); err == nil && e.HeadSHA == commit { | ||
| 122 | if list, err := w.Index.RecentCommits(e.ID, 5); err == nil { | ||
| 123 | commits = make([]git.Commit, 0, len(list)) | ||
| 124 | for _, c := range list { | ||
| 125 | commits = append(commits, git.Commit{SHA: c.SHA, Subject: c.Subject, Author: c.Author, When: c.When}) | ||
| 126 | } | ||
| 127 | } | ||
| 128 | if shares, err := w.Index.Languages(e.ID); err == nil { | ||
| 129 | languages = languageShares(shares) | ||
| 130 | } | ||
| 131 | if list, err := w.Index.Contributors(e.ID, 8); err == nil { | ||
| 132 | contributors = make([]contributorView, 0, len(list)) | ||
| 133 | for _, c := range list { | ||
| 134 | contributors = append(contributors, contributorView{ | ||
| 135 | Name: c.Name, Commits: c.Commits, Initial: initial(c.Name), Tone: tone(c.Name), | ||
| 136 | }) | ||
| 137 | } | ||
| 138 | } | ||
| 139 | if list, err := w.Index.Tags(e.ID, 5); err == nil { | ||
| 140 | tags = make([]git.Tag, 0, len(list)) | ||
| 141 | for _, t := range list { | ||
| 142 | tags = append(tags, git.Tag{ | ||
| 143 | Name: t.Name, OID: t.OID, Commit: t.Commit, Annotated: t.Annotated, | ||
| 144 | Tagger: t.Tagger, When: t.When, Subject: t.Subject, | ||
| 145 | }) | ||
| 146 | } | ||
| 147 | } | ||
| 148 | // A repository with commits has at least one of each; an empty | ||
| 149 | // section means the summary was written without it (a tree walk or | ||
| 150 | // a shortlog that failed), so read that part from git rather than | ||
| 151 | // leave the page blank until the next push. | ||
| 152 | if commit != "" { | ||
| 153 | if len(commits) == 0 { | ||
| 154 | commits, _ = g.Log(commit, 5) | ||
| 155 | } | ||
| 156 | if len(languages) == 0 { | ||
| 157 | languages = w.languages(g, commit) | ||
| 158 | } | ||
| 159 | if len(contributors) == 0 { | ||
| 160 | contributors = w.contributors(g, commit) | ||
| 161 | if len(contributors) > 8 { | ||
| 162 | contributors = contributors[:8] | ||
| 163 | } | ||
| 164 | } | ||
| 165 | } | ||
| 166 | return commits, languages, contributors, tags | ||
| 167 | } | ||
| 168 | } | ||
| 169 | } | ||
| 170 | if commit != "" { | ||
| 171 | commits, _ = g.Log(commit, 5) | ||
| 172 | languages = w.languages(g, commit) | ||
| 173 | } | ||
| 174 | if list, err := g.Tags(); err == nil { | ||
| 175 | if len(list) > 5 { | ||
| 176 | list = list[:5] | ||
| 177 | } | ||
| 178 | tags = list | ||
| 179 | } | ||
| 180 | contributors = w.contributors(g, commit) | ||
| 181 | if len(contributors) > 8 { | ||
| 182 | contributors = contributors[:8] | ||
| 183 | } | ||
| 184 | return commits, languages, contributors, tags | ||
| 185 | } | ||
| 186 | |||
| 127 | 187 | // overviewRecent returns the newest open issues and change requests for the | |
| 128 | 188 | // overview sidebar. Closed and merged records are history and already have a | |
| 129 | 189 | // home in their own lists; the overview is a map of what is still being worked | |
| @@ -131,7 +191,7 @@ func (w *Web) Overview(rw http.ResponseWriter, r *http.Request) { | |||
| 131 | 191 | func (w *Web) overviewRecent(memo *requestMemo, m *repo.Meta) (issues, requests []overviewEntity) { | |
| 132 | 192 | if w.Index != nil { | |
| 133 | 193 | if err := w.ensureIndex(memo, m); err == nil { | |
| 134 | if rows, err := w.Index.Issues(m.ID, index.Filter{State: "open"}); err == nil { | ||
| 194 | if rows, err := w.Index.Issues(m.ID, index.Filter{State: "open", Limit: overviewSideLimit}); err == nil { | ||
| 135 | 195 | for _, row := range rows { | |
| 136 | 196 | issues = append(issues, overviewEntity{ | |
| 137 | 197 | ID: row.ID, Title: row.Title, Author: row.Author, | |
| @@ -139,7 +199,7 @@ func (w *Web) overviewRecent(memo *requestMemo, m *repo.Meta) (issues, requests | |||
| 139 | 199 | }) | |
| 140 | 200 | } | |
| 141 | 201 | } | |
| 142 | if rows, err := w.Index.Requests(m.ID, index.RequestFilter{State: "open"}); err == nil { | ||
| 202 | if rows, err := w.Index.Requests(m.ID, index.RequestFilter{State: "open", Limit: overviewSideLimit}); err == nil { | ||
| 143 | 203 | for _, row := range rows { | |
| 144 | 204 | requests = append(requests, overviewEntity{ | |
| 145 | 205 | ID: row.ID, Title: row.Title, Author: row.Author, | |
| @@ -187,6 +247,10 @@ func (w *Web) readmeAt(m *repo.Meta, ref, commit string) (template.HTML, string) | |||
| 187 | 247 | if commit == "" { | |
| 188 | 248 | return "", "" | |
| 189 | 249 | } | |
| 250 | key := commitKey(m.Dir, commit) + "\x00" + ref | ||
| 251 | if v, ok := w.readmes.get(key); ok { | ||
| 252 | return v.html, v.name | ||
| 253 | } | ||
| 190 | 254 | for _, name := range []string{"README.md", "readme.md", "Readme.md", "README.markdown"} { | |
| 191 | 255 | stream, err := m.Git().OpenBlobAt(commit, name) | |
| 192 | 256 | if err != nil { | |
| @@ -197,11 +261,13 @@ func (w *Web) readmeAt(m *repo.Meta, ref, commit string) (template.HTML, string) | |||
| 197 | 261 | stream.Close() | |
| 198 | 262 | // Name the file and the size: a README block that is silently absent | |
| 199 | 263 | // looks like a bug in the forge rather than a decision about the file. | |
| 200 | return template.HTML(fmt.Sprintf( | ||
| 264 | note := template.HTML(fmt.Sprintf( | ||
| 201 | 265 | `<p class="muted">%s is %s, past the %s this page renders. <a href="/r/%s/blob/%s/%s">View the source</a>.</p>`, | |
| 202 | 266 | template.HTMLEscapeString(name), format.Bytes(size), format.Bytes(maxReadmeBytes), | |
| 203 | 267 | template.HTMLEscapeString(m.Name), template.HTMLEscapeString(ref), | |
| 204 | template.HTMLEscapeString(name))), name | ||
| 268 | template.HTMLEscapeString(name))) | ||
| 269 | w.readmes.put(key, readmeAnswer{html: note, name: name}) | ||
| 270 | return note, name | ||
| 205 | 271 | } | |
| 206 | 272 | data, err := io.ReadAll(io.LimitReader(stream, maxReadmeBytes)) | |
| 207 | 273 | stream.Close() | |
| @@ -209,39 +275,36 @@ func (w *Web) readmeAt(m *repo.Meta, ref, commit string) (template.HTML, string) | |||
| 209 | 275 | continue | |
| 210 | 276 | } | |
| 211 | 277 | doc := treeDoc{Repo: m.Name, Ref: ref, Path: name} | |
| 212 | return w.renderTreeMarkdown(doc, string(data)), name | ||
| 278 | rendered := w.renderTreeMarkdown(doc, string(data)) | ||
| 279 | w.readmes.put(key, readmeAnswer{html: rendered, name: name}) | ||
| 280 | return rendered, name | ||
| 213 | 281 | } | |
| 282 | // A repository without a README would otherwise look for four names on every | ||
| 283 | // visit; remember that the answer is none. | ||
| 284 | w.readmes.put(key, readmeAnswer{}) | ||
| 214 | 285 | return "", "" | |
| 215 | 286 | } | |
| 216 | 287 | ||
| 217 | // languageShares groups the current tree by file extension and returns the | ||
| 218 | // largest groups. It is a rough map, not a linguist: the point is to show what a | ||
| 219 | // repository is mostly made of, not to classify every generated file. | ||
| 220 | func languageShares(blobs map[string]git.Blob) []languageShare { | ||
| 221 | byName := map[string]int64{} | ||
| 288 | // languageShares turns stored or freshly read language groups into the bar the | ||
| 289 | // overview draws: the six largest by bytes and everything else folded together. | ||
| 290 | // It is a rough map, not a linguist: the point is to show what a repository is | ||
| 291 | // mostly made of, not to classify every generated file. | ||
| 292 | func languageShares(groups []lang.Share) []languageShare { | ||
| 222 | 293 | var total int64 | |
| 223 | for _, b := range blobs { | ||
| 224 | name := lang.For(b.Path) | ||
| 225 | byName[name] += b.Size | ||
| 226 | total += b.Size | ||
| 294 | for _, g := range groups { | ||
| 295 | total += g.Bytes | ||
| 227 | 296 | } | |
| 228 | 297 | if total == 0 { | |
| 229 | 298 | return nil | |
| 230 | 299 | } | |
| 231 | list := make([]languageShare, 0, len(byName)) | ||
| 232 | for name, n := range byName { | ||
| 233 | pct := int(n * 100 / total) | ||
| 234 | if pct == 0 && n > 0 { | ||
| 300 | list := make([]languageShare, 0, len(groups)) | ||
| 301 | for _, g := range groups { | ||
| 302 | pct := int(g.Bytes * 100 / total) | ||
| 303 | if pct == 0 && g.Bytes > 0 { | ||
| 235 | 304 | pct = 1 | |
| 236 | 305 | } | |
| 237 | list = append(list, languageShare{Name: name, Bytes: n, Percent: pct}) | ||
| 306 | list = append(list, languageShare{Name: g.Name, Bytes: g.Bytes, Percent: pct}) | ||
| 238 | 307 | } | |
| 239 | sort.Slice(list, func(i, j int) bool { | ||
| 240 | if list[i].Bytes != list[j].Bytes { | ||
| 241 | return list[i].Bytes > list[j].Bytes | ||
| 242 | } | ||
| 243 | return list[i].Name < list[j].Name | ||
| 244 | }) | ||
| 245 | 308 | if len(list) > 6 { | |
| 246 | 309 | other := languageShare{Name: "Other"} | |
| 247 | 310 | for _, l := range list[6:] { | |