Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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 }