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.
Phase 7 slice 2: a release is a tag the web can read
c56911e2c3ede0f70c7cde560042806dbf4b4ab0 · Keenan Nemetz · 17d ago · parent f76c635b
Phase 7 slice 2: a release is a tag the web can read A release is an annotated tag, so nothing new is stored: the tag message is the notes, the tree is the tarball, and both are already in every clone. - internal/gitx/tags.go lists refs/tags peeled to their commits and tells an annotated tag from a lightweight one; a lightweight tag's %(contents) is the commit message, so reading it as notes would invent a release description nobody wrote - internal/gitx/archive.go streams `git archive`; tarballs are generated per request and never stored, so they cannot disagree with the tag - internal/web/releases.go renders the list (lightweight tags labelled), the release page (notes through the same sanitiser as every pushed body, commits since the previous release, two downloads) and the archive handlers: ETag is the tag object id plus the format, conditional requests get 304, always Content-Disposition: attachment with nosniff, compressed streams queue behind a semaphore of 4 while plain tar does not - "since the previous release" is `git describe --abbrev=0 <tag>^`, not the next row of the tag list: two tags made in the same second sort arbitrarily, and a page that changes its baseline between requests is worse than one without one - the page warns when the tagged tree tracks LFS, because git archive copies pointer files verbatim (measured in spike D) - cmd/vole release create writes the annotated tag and refuses an empty message or an existing name: re-tagging is how a download link comes to mean different bytes Measured end to end against the binary with an 11.6 MiB, 2,566-file repository: release list 6.1 ms, release page 12.6 ms, .tar 53.7 ms / 13.7 MiB, .tar.gz 218 ms / 1.8 MiB, conditional request 304. Every file in the served tarball hashed to its ls-tree blob id (2,565 of 2,565).
internal/web/web.go +46−30
| @@ -34,6 +34,9 @@ type Web struct { | |||
| 34 | 34 | Cfg *config.Config | |
| 35 | 35 | Tmpl *template.Template | |
| 36 | 36 | Logger func(format string, args ...any) | |
| 37 | |||
| 38 | // sem bounds compressed archive streams; see releases.go. | ||
| 39 | sem chan struct{} | ||
| 37 | 40 | } | |
| 38 | 41 | ||
| 39 | 42 | func New(repos *repo.Store, ix *index.Store, ident *identity.Store, rs *runs.Store, cfg *config.Config) (*Web, error) { | |
| @@ -45,7 +48,8 @@ func New(repos *repo.Store, ix *index.Store, ident *identity.Store, rs *runs.Sto | |||
| 45 | 48 | if err != nil { | |
| 46 | 49 | return nil, err | |
| 47 | 50 | } | |
| 48 | w := &Web{Repos: repos, Index: ix, Ident: ident, Runs: rs, Cfg: cfg, Tmpl: t} | ||
| 51 | w := &Web{Repos: repos, Index: ix, Ident: ident, Runs: rs, Cfg: cfg, Tmpl: t, | ||
| 52 | sem: make(chan struct{}, archiveConc)} | ||
| 49 | 53 | w.Logger = func(string, ...any) {} | |
| 50 | 54 | return w, nil | |
| 51 | 55 | } | |
| @@ -157,6 +161,16 @@ func (w *Web) Repo(rw http.ResponseWriter, r *http.Request) { | |||
| 157 | 161 | if w.Cfg.PagesDomain != "" { | |
| 158 | 162 | site = "http://" + m.Name + "." + w.Cfg.PagesDomain | |
| 159 | 163 | } | |
| 164 | // A release is an annotated tag, so the count comes from the tag list rather | ||
| 165 | // than from anything the web has to maintain. | ||
| 166 | releaseCount := 0 | ||
| 167 | if tags, err := g.Tags(); err == nil { | ||
| 168 | for _, t := range tags { | ||
| 169 | if t.Annotated { | ||
| 170 | releaseCount++ | ||
| 171 | } | ||
| 172 | } | ||
| 173 | } | ||
| 160 | 174 | branches, _ := g.ListRefs("refs/heads/") | |
| 161 | 175 | names := make([]string, 0, len(branches)) | |
| 162 | 176 | for k := range branches { | |
| @@ -164,36 +178,38 @@ func (w *Web) Repo(rw http.ResponseWriter, r *http.Request) { | |||
| 164 | 178 | } | |
| 165 | 179 | w.render(rw, "repo", struct { | |
| 166 | 180 | Page | |
| 167 | Name string | ||
| 168 | Description string | ||
| 169 | Ref string | ||
| 170 | CloneURL string | ||
| 171 | SSHURL string | ||
| 172 | Entries []gitx.Entry | ||
| 173 | Commits []gitx.Commit | ||
| 174 | Commit string | ||
| 175 | Count int | ||
| 176 | Branches []string | ||
| 177 | IssueCount int | ||
| 178 | Site string | ||
| 179 | Readme template.HTML | ||
| 180 | Runs []*runs.Run | ||
| 181 | Name string | ||
| 182 | Description string | ||
| 183 | Ref string | ||
| 184 | CloneURL string | ||
| 185 | SSHURL string | ||
| 186 | Entries []gitx.Entry | ||
| 187 | Commits []gitx.Commit | ||
| 188 | Commit string | ||
| 189 | Count int | ||
| 190 | Branches []string | ||
| 191 | IssueCount int | ||
| 192 | ReleaseCount int | ||
| 193 | Site string | ||
| 194 | Readme template.HTML | ||
| 195 | Runs []*runs.Run | ||
| 181 | 196 | }{ | |
| 182 | Page: w.base(m.Name), | ||
| 183 | Name: m.Name, | ||
| 184 | Description: m.Description, | ||
| 185 | Ref: ref, | ||
| 186 | CloneURL: cloneURL(r, m.Name), | ||
| 187 | SSHURL: sshURL(w.Cfg.SSHAddr, m.Name), | ||
| 188 | Entries: entries, | ||
| 189 | Commits: commits, | ||
| 190 | Commit: commit, | ||
| 191 | Count: count, | ||
| 192 | Branches: names, | ||
| 193 | IssueCount: issueCount, | ||
| 194 | Site: site, | ||
| 195 | Readme: readme, | ||
| 196 | Runs: recentRuns, | ||
| 197 | Page: w.base(m.Name), | ||
| 198 | Name: m.Name, | ||
| 199 | Description: m.Description, | ||
| 200 | Ref: ref, | ||
| 201 | CloneURL: cloneURL(r, m.Name), | ||
| 202 | SSHURL: sshURL(w.Cfg.SSHAddr, m.Name), | ||
| 203 | Entries: entries, | ||
| 204 | Commits: commits, | ||
| 205 | Commit: commit, | ||
| 206 | Count: count, | ||
| 207 | Branches: names, | ||
| 208 | IssueCount: issueCount, | ||
| 209 | ReleaseCount: releaseCount, | ||
| 210 | Site: site, | ||
| 211 | Readme: readme, | ||
| 212 | Runs: recentRuns, | ||
| 197 | 213 | }, 0) | |
| 198 | 214 | } | |
| 199 | 215 | ||