Commit f65bfc3c4e

f65bfc3c4e65c08d17178303b42860fafff79a25

parent: 32a583340a

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-26 03:33 UTC

web: link resolved author names to their profiles

A commit author whose address an account has verified here now links to
that account, through one shared partial so the linked and plain forms
cannot drift between the tipbar, the log, a commit page, and a merge
request's commit list. Addresses nobody has proven stay plain text.

The README carries the build badge.

Ref #35

Layout: unified · split

README.org +2
@@ -1,6 +1,8 @@
1#+title: gitbay 1#+title: gitbay
2#+author: Christian Cleberg 2#+author: Christian Cleberg
3 3
4[[https://gitbay.org/krz/gitbay/builds][file:https://gitbay.org/krz/gitbay/badge/build.svg]]
5
4A CLI-first git forge. One binary, SQLite, and the system =git= — designed 6A CLI-first git forge. One binary, SQLite, and the system =git= — designed
5so the command line is the product and the web UI is a rendering of state 7so the command line is the product and the web UI is a rendering of state
6the CLI already manages. Runs at [[https://gitbay.org]]. 8the CLI already manages. Runs at [[https://gitbay.org]].
e2e/design_test.go +15
@@ -279,6 +279,10 @@ func TestAuthorNamesResolve(t *testing.T) {
279 os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644) 279 os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644)
280 mustGit(t, dir, stranger, "add", ".") 280 mustGit(t, dir, stranger, "add", ".")
281 mustGit(t, dir, stranger, "commit", "-q", "-m", "from a stranger") 281 mustGit(t, dir, stranger, "commit", "-q", "-m", "from a stranger")
282 // The tip is the account's, so the bar above the listing shows a link.
283 os.WriteFile(filepath.Join(dir, "c.txt"), []byte("c\n"), 0o644)
284 mustGit(t, dir, known, "add", ".")
285 mustGit(t, dir, known, "commit", "-q", "-m", "back to the account")
282 mustGit(t, dir, known, "push", "-q", "origin", "main") 286 mustGit(t, dir, known, "push", "-q", "origin", "main")
283 287
284 // The log shows the account name for the verified address only. 288 // The log shows the account name for the verified address only.
@@ -292,4 +296,15 @@ func TestAuthorNamesResolve(t *testing.T) {
292 if !strings.Contains(body, "alice") { 296 if !strings.Contains(body, "alice") {
293 t.Fatalf("log missing the account name:\n%s", body) 297 t.Fatalf("log missing the account name:\n%s", body)
294 } 298 }
299 // A resolved name links to the profile; an unknown one stays text.
300 if !strings.Contains(body, `class="authorlink" href="/alice"`) {
301 t.Fatalf("account name is not a link:\n%s", body)
302 }
303 if strings.Contains(body, `href="/Outside Person"`) {
304 t.Fatalf("unknown author was linked:\n%s", body)
305 }
306 // The tipbar resolves and links the same way when the tip is an account's.
307 if _, tree := inst.get(t, "/alice/app"); !strings.Contains(tree, `class="authorlink" href="/alice"`) {
308 t.Fatalf("tipbar name is not a link:\n%s", tree)
309 }
295} 310}
internal/httpd/control.go +23 −12
@@ -108,33 +108,44 @@ func (a *authorNames) name(email, fallback string) string {
108 return name 108 return name
109} 109}
110 110
111// known reports whether the address belongs to an account, so callers can 111// account returns the account name behind an address, if any, so callers
112// decide to link the name. 112// can link the displayed name to a profile.
113func (a *authorNames) known(email string) bool { 113func (a *authorNames) account(email string) (string, bool) {
114 if email == "" { 114 if email == "" {
115 return false 115 return "", false
116 } 116 }
117 if got, ok := a.cache[email]; ok { 117 if got, ok := a.cache[email]; ok {
118 return got != "" 118 return got, got != ""
119 } 119 }
120 name, _ := a.st.UsernameByVerifiedEmail(email) 120 name, _ := a.st.UsernameByVerifiedEmail(email)
121 a.cache[email] = name 121 a.cache[email] = name
122 return name != "" 122 return name, name != ""
123}
124
125// namedCommit is a listing commit plus the account behind its author
126// address, when there is one, so the name can link to a profile.
127type namedCommit struct {
128 gitutil.EntryCommit
129 User string
123} 130}
124 131
125// namedCommits rewrites listing authors to account names where the 132// namedCommits rewrites listing authors to account names where the
126// address is verified here. 133// address is verified here.
127func (s *Server) namedCommits(m map[string]gitutil.EntryCommit) map[string]gitutil.EntryCommit { 134func (s *Server) namedCommits(m map[string]gitutil.EntryCommit) map[string]namedCommit {
128 names := s.authorNames() 135 names := s.authorNames()
136 out := make(map[string]namedCommit, len(m))
129 for k, c := range m { 137 for k, c := range m {
138 user, _ := names.account(c.Email)
130 c.Author = names.name(c.Email, c.Author) 139 c.Author = names.name(c.Email, c.Author)
131 m[k] = c 140 out[k] = namedCommit{EntryCommit: c, User: user}
132 } 141 }
133 return m 142 return out
134} 143}
135 144
136// namedTip does the same for the single commit above a tree listing. 145// namedTip does the same for the single commit above a tree listing.
137func (s *Server) namedTip(c gitutil.EntryCommit) gitutil.EntryCommit { 146func (s *Server) namedTip(c gitutil.EntryCommit) namedCommit {
138 c.Author = s.authorNames().name(c.Email, c.Author) 147 names := s.authorNames()
139 return c 148 user, _ := names.account(c.Email)
149 c.Author = names.name(c.Email, c.Author)
150 return namedCommit{EntryCommit: c, User: user}
140} 151}
internal/httpd/web.go +14 −10
@@ -435,8 +435,8 @@ func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage,
435 Branches []gitutil.Ref 435 Branches []gitutil.Ref
436 ReadmeName string 436 ReadmeName string
437 ReadmeHTML template.HTML 437 ReadmeHTML template.HTML
438 LastCommits map[string]gitutil.EntryCommit 438 LastCommits map[string]namedCommit
439 Tip gitutil.EntryCommit 439 Tip namedCommit
440 }{repoPage: p, RefKind: "tree"}) 440 }{repoPage: p, RefKind: "tree"})
441 return 441 return
442 } 442 }
@@ -479,8 +479,8 @@ func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage,
479 Branches []gitutil.Ref 479 Branches []gitutil.Ref
480 ReadmeName string 480 ReadmeName string
481 ReadmeHTML template.HTML 481 ReadmeHTML template.HTML
482 LastCommits map[string]gitutil.EntryCommit 482 LastCommits map[string]namedCommit
483 Tip gitutil.EntryCommit 483 Tip namedCommit
484 }{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches, 484 }{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches,
485 readmeName, readmeHTML, 485 readmeName, readmeHTML,
486 s.namedCommits(gitutil.LastCommits(p.Dir, p.Ref, dirPath, names)), 486 s.namedCommits(gitutil.LastCommits(p.Dir, p.Ref, dirPath, names)),
@@ -1201,8 +1201,8 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1201 shas = shas[:pageSize] 1201 shas = shas[:pageSize]
1202 } 1202 }
1203 type row struct { 1203 type row struct {
1204 SHA, ShortSHA, Subject, AuthorName, AuthorEmail, Date string 1204 SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string
1205 Sig sigView 1205 Sig sigView
1206 } 1206 }
1207 names := s.authorNames() 1207 names := s.authorNames()
1208 var rows []row 1208 var rows []row
@@ -1212,6 +1212,7 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1212 if parsed != nil { 1212 if parsed != nil {
1213 rw.Subject = parsed.Subject 1213 rw.Subject = parsed.Subject
1214 rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName) 1214 rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
1215 rw.AuthorUser, _ = names.account(parsed.AuthorEmail)
1215 rw.AuthorEmail = parsed.AuthorEmail 1216 rw.AuthorEmail = parsed.AuthorEmail
1216 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02") 1217 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1217 } 1218 }
@@ -1249,18 +1250,20 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1249 committerEmail = parsed.CommitterEmail 1250 committerEmail = parsed.CommitterEmail
1250 } 1251 }
1251 checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full) 1252 checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1253 commitNames := s.authorNames()
1254 commitUser, _ := commitNames.account(parsed.AuthorEmail)
1252 msg := "" 1255 msg := ""
1253 if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 { 1256 if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1254 msg = string(parsed.Payload[i+2:]) 1257 msg = string(parsed.Payload[i+2:])
1255 } 1258 }
1256 s.render(w, "commit.html", struct { 1259 s.render(w, "commit.html", struct {
1257 repoPage 1260 repoPage
1258 SHA, ShortSHA, AuthorName, AuthorEmail, CommitterEmail, Date, Message string 1261 SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string
1259 Parents []string 1262 Parents []string
1260 Sig sigView 1263 Sig sigView
1261 Checks []store.CommitStatus 1264 Checks []store.CommitStatus
1262 DiffLines []diffLine 1265 DiffLines []diffLine
1263 }{p, full, full[:10], s.authorNames().name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, committerEmail, 1266 }{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
1264 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, 1267 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1265 gitutil.Parents(p.Dir, full), v, checks, lines}) 1268 gitutil.Parents(p.Dir, full), v, checks, lines})
1266} 1269}
@@ -1482,8 +1485,8 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1482 } 1485 }
1483 // The commits this MR carries: base..head, the same range as the diff. 1486 // The commits this MR carries: base..head, the same range as the diff.
1484 type commitRow struct { 1487 type commitRow struct {
1485 SHA, ShortSHA, Subject, AuthorName, Date string 1488 SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
1486 Sig sigView 1489 Sig sigView
1487 } 1490 }
1488 mrNames := s.authorNames() 1491 mrNames := s.authorNames()
1489 var commits []commitRow 1492 var commits []commitRow
@@ -1499,6 +1502,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1499 if parsed != nil { 1502 if parsed != nil {
1500 cr.Subject = parsed.Subject 1503 cr.Subject = parsed.Subject
1501 cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName) 1504 cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
1505 cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail)
1502 cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02") 1506 cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1503 } 1507 }
1504 commits = append(commits, cr) 1508 commits = append(commits, cr)
internal/web/templates/commit.html +1 −1
@@ -5,7 +5,7 @@
5 <p class="meta"><code class="fullsha">{{.SHA}}</code></p> 5 <p class="meta"><code class="fullsha">{{.SHA}}</code></p>
6 {{if .Parents}}<p class="meta">parent{{if gt (len .Parents) 1}}s{{end}}:{{range .Parents}} <code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.}}">{{short .}}</a></code>{{end}}</p>{{end}} 6 {{if .Parents}}<p class="meta">parent{{if gt (len .Parents) 1}}s{{end}}:{{range .Parents}} <code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.}}">{{short .}}</a></code>{{end}}</p>{{end}}
7 <p>{{template "sigbadge" .Sig}}{{range .Checks}} <span class="badge check-{{.State}}">{{.Context}}: {{.State}}</span>{{end}}</p> 7 <p>{{template "sigbadge" .Sig}}{{range .Checks}} <span class="badge check-{{.State}}">{{.Context}}: {{.State}}</span>{{end}}</p>
8 <p class="meta"><span title="{{.AuthorEmail}}">{{.AuthorName}}</span> &lt;{{.AuthorEmail}}&gt; · {{.Date}} 8 <p class="meta">{{template "authorname" dict "Name" .AuthorName "User" .AuthorUser "Email" .AuthorEmail}} &lt;{{.AuthorEmail}}&gt; · {{.Date}}
9 {{if .CommitterEmail}}<br>committer: &lt;{{.CommitterEmail}}&gt;{{end}}</p> 9 {{if .CommitterEmail}}<br>committer: &lt;{{.CommitterEmail}}&gt;{{end}}</p>
10</div> 10</div>
11<pre class="message">{{.Message}}</pre> 11<pre class="message">{{.Message}}</pre>
internal/web/templates/layout.html +2
@@ -106,3 +106,5 @@
106{{define "branchicon"}}<svg class="icon" width="12" height="12" viewBox="0 0 16 16" aria-hidden="true" fill="currentColor"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628a2.25 2.25 0 0 1-1.5-2.122ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM3.5 3.25a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/></svg>{{end}} 106{{define "branchicon"}}<svg class="icon" width="12" height="12" viewBox="0 0 16 16" aria-hidden="true" fill="currentColor"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628a2.25 2.25 0 0 1-1.5-2.122ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM3.5 3.25a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/></svg>{{end}}
107 107
108{{define "sigbadge"}}<span class="badge badge-{{.State}}" title="{{.Fingerprint}}">{{sigLabel .State}}{{if .Signer}} · {{.Signer}}{{end}}</span>{{end}} 108{{define "sigbadge"}}<span class="badge badge-{{.State}}" title="{{.Fingerprint}}">{{sigLabel .State}}{{if .Signer}} · {{.Signer}}{{end}}</span>{{end}}
109
110{{define "authorname"}}{{if .User}}<a class="authorlink" href="/{{.User}}" title="{{.Email}}">{{.Name}}</a>{{else}}<span title="{{.Email}}">{{.Name}}</span>{{end}}{{end}}
internal/web/templates/log.html +1 −1
@@ -6,7 +6,7 @@
6{{range .Commits}}<li> 6{{range .Commits}}<li>
7 <div class="commitmain"> 7 <div class="commitmain">
8 <p class="subject"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a></p> 8 <p class="subject"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a></p>
9 <p class="meta"><span title="{{.AuthorEmail}}">{{.AuthorName}}</span> · {{.Date}}</p> 9 <p class="meta">{{template "authorname" dict "Name" .AuthorName "User" .AuthorUser "Email" .AuthorEmail}} · {{.Date}}</p>
10 </div> 10 </div>
11 <div class="commitside"> 11 <div class="commitside">
12 {{template "sigbadge" .Sig}} 12 {{template "sigbadge" .Sig}}
internal/web/templates/mr.html +1 −1
@@ -48,7 +48,7 @@
48{{range .Commits}}<li> 48{{range .Commits}}<li>
49 <div class="commitmain"> 49 <div class="commitmain">
50 <p class="subject"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a></p> 50 <p class="subject"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a></p>
51 <p class="meta">{{.AuthorName}} · {{.Date}}</p> 51 <p class="meta">{{template "authorname" dict "Name" .AuthorName "User" .AuthorUser "Email" ""}} · {{.Date}}</p>
52 </div> 52 </div>
53 <div class="commitside"> 53 <div class="commitside">
54 {{template "sigbadge" .Sig}} 54 {{template "sigbadge" .Sig}}
internal/web/templates/tree.html +1 −1
@@ -11,7 +11,7 @@
11</div> 11</div>
12{{if .Entries}}<p class="clone">Clone: <code>git clone {{.CloneURL}}</code></p>{{end}} 12{{if .Entries}}<p class="clone">Clone: <code>git clone {{.CloneURL}}</code></p>{{end}}
13{{with .Tip}}{{if .SHA}}<div class="tipbar"> 13{{with .Tip}}{{if .SHA}}<div class="tipbar">
14 <span class="who">{{.Author}}</span> 14 <span class="who">{{template "authorname" dict "Name" .Author "User" .User "Email" .Email}}</span>
15 <a class="subject" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a> 15 <a class="subject" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a>
16 <span class="spacer"></span> 16 <span class="spacer"></span>
17 <a class="sha" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}"><code>{{short .SHA}}</code></a> 17 <a class="sha" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}"><code>{{short .SHA}}</code></a>