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/gitx/tags.go +141
| @@ -0,0 +1,141 @@ | |||
| 1 | package gitx | ||
| 2 | |||
| 3 | // Tags, because a release is an annotated tag and nothing else. | ||
| 4 | // | ||
| 5 | // The roadmap deliberately does not give releases their own store: the tag is | ||
| 6 | // authoritative (immutable, present in every clone, `git show v1.2.0` works), so | ||
| 7 | // a release page is a view over refs/tags plus the commits the tag ranges over. | ||
| 8 | // The one thing a tag cannot say — draft versus published, or an artifact list — | ||
| 9 | // belongs on the metadata branch, and that is a later slice. | ||
| 10 | |||
| 11 | import ( | ||
| 12 | "fmt" | ||
| 13 | "strconv" | ||
| 14 | "strings" | ||
| 15 | ) | ||
| 16 | |||
| 17 | // Tag is one entry of refs/tags, peeled to the commit it names. | ||
| 18 | type Tag struct { | ||
| 19 | Name string | ||
| 20 | OID string // the tag object for an annotated tag, the commit for a lightweight one | ||
| 21 | Commit string // the commit the tag ultimately names | ||
| 22 | Annotated bool // false for a lightweight tag: no tagger, no message | ||
| 23 | Tagger string // "Name <email>", empty for a lightweight tag | ||
| 24 | When int64 // tagger date when annotated, commit date otherwise | ||
| 25 | Subject string // first line of the message | ||
| 26 | Message string // full message; only read for an annotated tag | ||
| 27 | } | ||
| 28 | |||
| 29 | // tagFormat is for-each-ref's view of a tag. %(*objectname) dereferences the tag | ||
| 30 | // object, which is how a release page finds the commit without a second call; | ||
| 31 | // %(subject) is a single line, so the field separator is safe. | ||
| 32 | const tagFormat = "%(refname:short)%1f%(objectname)%1f%(objecttype)%1f%(*objectname)%1f%(taggername)%1f%(taggerdate:unix)%1f%(creatordate:unix)%1f%(subject)" | ||
| 33 | |||
| 34 | // Tags lists every tag, newest first by creation date. A lightweight tag's | ||
| 35 | // creation date is its commit date, so mixed histories still sort sensibly. | ||
| 36 | func (r *Repo) Tags() ([]Tag, error) { | ||
| 37 | out, errs, err := r.run(nil, "for-each-ref", "--sort=-creatordate", | ||
| 38 | "--format="+tagFormat, "refs/tags") | ||
| 39 | if err != nil { | ||
| 40 | return nil, fmt.Errorf("for-each-ref refs/tags: %w: %s", err, errs) | ||
| 41 | } | ||
| 42 | var tags []Tag | ||
| 43 | for _, line := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") { | ||
| 44 | if line == "" { | ||
| 45 | continue | ||
| 46 | } | ||
| 47 | f := strings.Split(line, "\x1f") | ||
| 48 | if len(f) != 8 { | ||
| 49 | continue | ||
| 50 | } | ||
| 51 | t := Tag{ | ||
| 52 | Name: f[0], | ||
| 53 | OID: f[1], | ||
| 54 | Annotated: f[2] == "tag", | ||
| 55 | Commit: f[1], | ||
| 56 | Tagger: f[4], | ||
| 57 | Subject: f[7], | ||
| 58 | } | ||
| 59 | if f[3] != "" { | ||
| 60 | t.Commit = f[3] // annotated: the peeled commit | ||
| 61 | } | ||
| 62 | when := f[5] | ||
| 63 | if !t.Annotated { | ||
| 64 | when = f[6] // creatordate is the commit date for a lightweight tag | ||
| 65 | } | ||
| 66 | t.When, _ = strconv.ParseInt(when, 10, 64) | ||
| 67 | tags = append(tags, t) | ||
| 68 | } | ||
| 69 | return tags, nil | ||
| 70 | } | ||
| 71 | |||
| 72 | // Tag resolves one tag by name. It reads the whole ref list rather than | ||
| 73 | // interpolating the name into a ref pattern: a pattern is a glob, and a name | ||
| 74 | // from a URL must not select somebody else's tag. | ||
| 75 | func (r *Repo) Tag(name string) (Tag, error) { | ||
| 76 | tags, err := r.Tags() | ||
| 77 | if err != nil { | ||
| 78 | return Tag{}, err | ||
| 79 | } | ||
| 80 | for _, t := range tags { | ||
| 81 | if t.Name != name { | ||
| 82 | continue | ||
| 83 | } | ||
| 84 | if t.Annotated { | ||
| 85 | // Only an annotated tag has a message of its own. For a lightweight | ||
| 86 | // tag %(contents) is the commit message, which is not release notes. | ||
| 87 | out, errs, err := r.run(nil, "for-each-ref", "--format=%(contents)", | ||
| 88 | "refs/tags/"+name) | ||
| 89 | if err != nil { | ||
| 90 | return Tag{}, fmt.Errorf("read tag %q: %w: %s", name, err, errs) | ||
| 91 | } | ||
| 92 | t.Message = strings.TrimRight(string(out), "\n") + "\n" | ||
| 93 | } | ||
| 94 | return t, nil | ||
| 95 | } | ||
| 96 | return Tag{}, fmt.Errorf("no tag %q", name) | ||
| 97 | } | ||
| 98 | |||
| 99 | // CommitsBetween lists up to n commits reachable from `to` and not from `from`, | ||
| 100 | // which is what a release page's "since the previous release" section shows. An | ||
| 101 | // empty `from` means the tag's whole history. | ||
| 102 | func (r *Repo) CommitsBetween(from, to string, n int) ([]Commit, error) { | ||
| 103 | rng := to | ||
| 104 | if from != "" { | ||
| 105 | rng = from + ".." + to | ||
| 106 | } | ||
| 107 | out, errs, err := r.run(nil, "log", "--format="+commitFormat, "-n", strconv.Itoa(n), rng, "--") | ||
| 108 | if err != nil { | ||
| 109 | return nil, fmt.Errorf("log %q: %w: %s", rng, err, errs) | ||
| 110 | } | ||
| 111 | return parseCommits(out), nil | ||
| 112 | } | ||
| 113 | |||
| 114 | // PreviousTag names the release a tag follows: the nearest annotated tag | ||
| 115 | // reachable from the tagged commit's first parent, or "" when there is none. | ||
| 116 | // | ||
| 117 | // It is ancestry, not date order, that decides this. Two tags created in the | ||
| 118 | // same second sort arbitrarily, and a release page whose "since" line changes | ||
| 119 | // between requests would be worse than one with no line at all. | ||
| 120 | func (r *Repo) PreviousTag(rev string) string { | ||
| 121 | out, _, err := r.run(nil, "describe", "--abbrev=0", rev+"^") | ||
| 122 | if err != nil { | ||
| 123 | return "" // no tag reachable, or a root commit | ||
| 124 | } | ||
| 125 | return strings.TrimSpace(string(out)) | ||
| 126 | } | ||
| 127 | |||
| 128 | // CountBetween counts the commits in `from..to`, for the heading a release page | ||
| 129 | // puts over its list. An empty `from` counts the tag's whole history. | ||
| 130 | func (r *Repo) CountBetween(from, to string) (int, error) { | ||
| 131 | rng := to | ||
| 132 | if from != "" { | ||
| 133 | rng = from + ".." + to | ||
| 134 | } | ||
| 135 | out, _, err := r.run(nil, "rev-list", "--count", rng) | ||
| 136 | if err != nil { | ||
| 137 | return 0, nil // an unborn or unreadable range is not worth a 500 | ||
| 138 | } | ||
| 139 | n, _ := strconv.Atoi(strings.TrimSpace(string(out))) | ||
| 140 | return n, nil | ||
| 141 | } | ||