A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit f65bfc3c4e

f65bfc3c4e65c08d17178303b42860fafff79a25

parent: 32a583340a

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-26T03:33:16Z

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
README.org +2
@@ -1,6 +1,8 @@
11 #+title: gitbay
22 #+author: Christian Cleberg
33
4[[https://gitbay.org/krz/gitbay/builds][file:https://gitbay.org/krz/gitbay/badge/build.svg]]
5
46 A CLI-first git forge. One binary, SQLite, and the system =git= — designed
57 so the command line is the product and the web UI is a rendering of state
68 the CLI already manages. Runs at [[https://gitbay.org]].
e2e/design_test.go +15
@@ -279,6 +279,10 @@ func TestAuthorNamesResolve(t *testing.T) {
279279 os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644)
280280 mustGit(t, dir, stranger, "add", ".")
281281 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")
282286 mustGit(t, dir, known, "push", "-q", "origin", "main")
283287
284288 // The log shows the account name for the verified address only.
@@ -292,4 +296,15 @@ func TestAuthorNamesResolve(t *testing.T) {
292296 if !strings.Contains(body, "alice") {
293297 t.Fatalf("log missing the account name:\n%s", body)
294298 }
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 }
295310 }
internal/httpd/control.go +23 −12
@@ -108,33 +108,44 @@ func (a *authorNames) name(email, fallback string) string {
108108 return name
109109 }
110110
111// known reports whether the address belongs to an account, so callers can
112// decide to link the name.
113func (a *authorNames) known(email string) bool {
111// account returns the account name behind an address, if any, so callers
112// can link the displayed name to a profile.
113func (a *authorNames) account(email string) (string, bool) {
114114 if email == "" {
115 return false
115 return "", false
116116 }
117117 if got, ok := a.cache[email]; ok {
118 return got != ""
118 return got, got != ""
119119 }
120120 name, _ := a.st.UsernameByVerifiedEmail(email)
121121 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
123130 }
124131
125132 // namedCommits rewrites listing authors to account names where the
126133 // 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 {
128135 names := s.authorNames()
136 out := make(map[string]namedCommit, len(m))
129137 for k, c := range m {
138 user, _ := names.account(c.Email)
130139 c.Author = names.name(c.Email, c.Author)
131 m[k] = c
140 out[k] = namedCommit{EntryCommit: c, User: user}
132141 }
133 return m
142 return out
134143 }
135144
136145 // namedTip does the same for the single commit above a tree listing.
137func (s *Server) namedTip(c gitutil.EntryCommit) gitutil.EntryCommit {
138 c.Author = s.authorNames().name(c.Email, c.Author)
139 return c
146func (s *Server) namedTip(c gitutil.EntryCommit) namedCommit {
147 names := s.authorNames()
148 user, _ := names.account(c.Email)
149 c.Author = names.name(c.Email, c.Author)
150 return namedCommit{EntryCommit: c, User: user}
140151 }
internal/httpd/web.go +14 −10
@@ -435,8 +435,8 @@ func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage,
435435 Branches []gitutil.Ref
436436 ReadmeName string
437437 ReadmeHTML template.HTML
438 LastCommits map[string]gitutil.EntryCommit
439 Tip gitutil.EntryCommit
438 LastCommits map[string]namedCommit
439 Tip namedCommit
440440 }{repoPage: p, RefKind: "tree"})
441441 return
442442 }
@@ -479,8 +479,8 @@ func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage,
479479 Branches []gitutil.Ref
480480 ReadmeName string
481481 ReadmeHTML template.HTML
482 LastCommits map[string]gitutil.EntryCommit
483 Tip gitutil.EntryCommit
482 LastCommits map[string]namedCommit
483 Tip namedCommit
484484 }{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches,
485485 readmeName, readmeHTML,
486486 s.namedCommits(gitutil.LastCommits(p.Dir, p.Ref, dirPath, names)),
@@ -1201,8 +1201,8 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request) {
12011201 shas = shas[:pageSize]
12021202 }
12031203 type row struct {
1204 SHA, ShortSHA, Subject, AuthorName, AuthorEmail, Date string
1205 Sig sigView
1204 SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string
1205 Sig sigView
12061206 }
12071207 names := s.authorNames()
12081208 var rows []row
@@ -1212,6 +1212,7 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request) {
12121212 if parsed != nil {
12131213 rw.Subject = parsed.Subject
12141214 rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
1215 rw.AuthorUser, _ = names.account(parsed.AuthorEmail)
12151216 rw.AuthorEmail = parsed.AuthorEmail
12161217 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
12171218 }
@@ -1249,18 +1250,20 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
12491250 committerEmail = parsed.CommitterEmail
12501251 }
12511252 checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1253 commitNames := s.authorNames()
1254 commitUser, _ := commitNames.account(parsed.AuthorEmail)
12521255 msg := ""
12531256 if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
12541257 msg = string(parsed.Payload[i+2:])
12551258 }
12561259 s.render(w, "commit.html", struct {
12571260 repoPage
1258 SHA, ShortSHA, AuthorName, AuthorEmail, CommitterEmail, Date, Message string
1261 SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string
12591262 Parents []string
12601263 Sig sigView
12611264 Checks []store.CommitStatus
12621265 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,
12641267 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
12651268 gitutil.Parents(p.Dir, full), v, checks, lines})
12661269 }
@@ -1482,8 +1485,8 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
14821485 }
14831486 // The commits this MR carries: base..head, the same range as the diff.
14841487 type commitRow struct {
1485 SHA, ShortSHA, Subject, AuthorName, Date string
1486 Sig sigView
1488 SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
1489 Sig sigView
14871490 }
14881491 mrNames := s.authorNames()
14891492 var commits []commitRow
@@ -1499,6 +1502,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
14991502 if parsed != nil {
15001503 cr.Subject = parsed.Subject
15011504 cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
1505 cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail)
15021506 cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
15031507 }
15041508 commits = append(commits, cr)
internal/web/templates/commit.html +1 −1
@@ -5,7 +5,7 @@
55 <p class="meta"><code class="fullsha">{{.SHA}}</code></p>
66 {{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}}
77 <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}}
99 {{if .CommitterEmail}}<br>committer: &lt;{{.CommitterEmail}}&gt;{{end}}</p>
1010 </div>
1111 <pre class="message">{{.Message}}</pre>
internal/web/templates/layout.html +2
@@ -106,3 +106,5 @@
106106 {{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}}
107107
108108 {{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 @@
66 {{range .Commits}}<li>
77 <div class="commitmain">
88 <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>
1010 </div>
1111 <div class="commitside">
1212 {{template "sigbadge" .Sig}}
internal/web/templates/mr.html +1 −1
@@ -48,7 +48,7 @@
4848 {{range .Commits}}<li>
4949 <div class="commitmain">
5050 <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>
5252 </div>
5353 <div class="commitside">
5454 {{template "sigbadge" .Sig}}
internal/web/templates/tree.html +1 −1
@@ -11,7 +11,7 @@
1111 </div>
1212 {{if .Entries}}<p class="clone">Clone: <code>git clone {{.CloneURL}}</code></p>{{end}}
1313 {{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>
1515 <a class="subject" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a>
1616 <span class="spacer"></span>
1717 <a class="sha" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}"><code>{{short .SHA}}</code></a>