A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 7b6134a766

7b6134a766399b5b256ee9c2d7ab1c4fb5b810e7

parent: d122636fc9

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-26T04:51:17Z

web: repository facts on the code page

Commit, branch and tag counts, detected license, latest release and build
status, a language census by tracked bytes, and contributors resolved to
accounts by verified email. All derived from git at render time — no new
schema, nothing to backfill.

Ref #35
e2e/facts_test.go added +96
@@ -0,0 +1,96 @@
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10// TestRepoFacts covers the repository summary: counts, license, languages,
11// and contributors resolved to accounts where the email is verified.
12func TestRepoFacts(t *testing.T) {
13 inst := startInstance(t)
14 aliceKey := inst.newKey(t, "alice")
15 inst.admin(t, "admin", "user", "create", "alice",
16 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
17 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
18 t.Fatalf("repo create: %s", errOut)
19 }
20
21 work := t.TempDir()
22 env := inst.gitEnv(aliceKey)
23 aliceEnv := append(append([]string{}, env...),
24 "GIT_AUTHOR_NAME=Alice", "GIT_AUTHOR_EMAIL=alice@example.test")
25 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
26 dir := filepath.Join(work, "w")
27
28 os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o644)
29 os.WriteFile(filepath.Join(dir, "run.sh"), []byte("#!/bin/sh\necho hi\n"), 0o644)
30 os.WriteFile(filepath.Join(dir, "LICENSE"), []byte(zeroBSD), 0o644)
31 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
32 mustGit(t, dir, env, "add", ".")
33 mustGit(t, dir, aliceEnv, "commit", "-q", "-m", "one")
34 // A second commit by someone with no account here.
35 os.WriteFile(filepath.Join(dir, "extra.go"), []byte("package main\n"), 0o644)
36 mustGit(t, dir, env, "add", ".")
37 mustGit(t, dir, env, "commit", "-q", "-m", "two")
38 mustGit(t, dir, env, "push", "-q", "origin", "main")
39 mustGit(t, dir, env, "tag", "v0.1.0")
40 mustGit(t, dir, env, "push", "-q", "origin", "v0.1.0")
41
42 status, body := inst.get(t, "/alice/app")
43 if status != 200 {
44 t.Fatalf("repo home: %d", status)
45 }
46 for _, want := range []string{
47 "<strong>2</strong> commit", // both commits counted
48 "<strong>1</strong> branch",
49 "<strong>1</strong> tag",
50 "0BSD", // license detected and surfaced
51 "Go", // language census
52 "Shell", // and it is not single-language
53 "2 contributors",
54 } {
55 if !strings.Contains(body, want) {
56 t.Errorf("repo home missing %q", want)
57 }
58 }
59 // The contributor with a verified email links to their profile; the
60 // other is named without inventing an account for them.
61 if !strings.Contains(body, `href="/alice"`) {
62 t.Error("verified contributor not linked to their profile")
63 }
64 if strings.Contains(body, `href="/t"`) {
65 t.Error("unknown contributor linked to a profile that does not exist")
66 }
67
68 // Subdirectory listings are about the directory, not the repository.
69 mustGit(t, dir, env, "rm", "-q", "extra.go")
70 os.MkdirAll(filepath.Join(dir, "sub"), 0o755)
71 os.WriteFile(filepath.Join(dir, "sub", "x.go"), []byte("package sub\n"), 0o644)
72 mustGit(t, dir, env, "add", "-A")
73 mustGit(t, dir, env, "commit", "-q", "-m", "sub")
74 mustGit(t, dir, env, "push", "-q", "origin", "main")
75 if _, body := inst.get(t, "/alice/app/tree/main/sub"); strings.Contains(body, "contributor") {
76 t.Error("facts bar rendered on a subdirectory listing")
77 }
78
79 // A repository with no commits has no facts to state, and must still
80 // render — the empty case renders through the same page type.
81 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/empty"); code != 0 {
82 t.Fatal("repo create failed")
83 }
84 if status, body := inst.get(t, "/alice/empty"); status != 200 {
85 t.Fatalf("empty repo home: %d", status)
86 } else if strings.Contains(body, "contributor") {
87 t.Error("empty repository claims contributors")
88 }
89}
90
91const zeroBSD = `Permission to use, copy, modify, and/or distribute this software for any
92purpose with or without fee is hereby granted.
93
94THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
95WITH REGARD TO THIS SOFTWARE.
96`
internal/gitutil/facts.go added +114
@@ -0,0 +1,114 @@
1package gitutil
2
3import (
4 "os/exec"
5 "sort"
6 "strconv"
7 "strings"
8)
9
10// CountCommits returns the number of commits reachable from ref, or 0 when
11// the ref does not resolve (an empty repository).
12func CountCommits(dir, ref string) int {
13 out, err := exec.Command("git", "-C", dir, "rev-list", "--count", ref).Output()
14 if err != nil {
15 return 0
16 }
17 n, _ := strconv.Atoi(strings.TrimSpace(string(out)))
18 return n
19}
20
21// Contributor is one author of a repository's history.
22type Contributor struct {
23 Name string
24 Email string
25 Commits int
26}
27
28// Contributors summarises authorship reachable from ref, most commits
29// first. Identities are keyed by email, since that is what the forge can
30// tie back to an account.
31func Contributors(dir, ref string, max int) []Contributor {
32 out, err := exec.Command("git", "-C", dir, "log", "--format=%an%x01%ae", ref).Output()
33 if err != nil {
34 return nil
35 }
36 byEmail := map[string]*Contributor{}
37 var order []string
38 for _, line := range strings.Split(string(out), "\n") {
39 name, email, ok := strings.Cut(line, "\x01")
40 if !ok || email == "" {
41 continue
42 }
43 c, seen := byEmail[email]
44 if !seen {
45 byEmail[email] = &Contributor{Name: name, Email: email, Commits: 1}
46 order = append(order, email)
47 continue
48 }
49 c.Commits++
50 }
51 list := make([]Contributor, 0, len(order))
52 for _, e := range order {
53 list = append(list, *byEmail[e])
54 }
55 sort.SliceStable(list, func(i, j int) bool { return list[i].Commits > list[j].Commits })
56 if max > 0 && len(list) > max {
57 list = list[:max]
58 }
59 return list
60}
61
62// Languages reports the byte share of each language in the tree at ref,
63// largest first, keyed by the extension map the caller supplies. Only
64// blobs count; git's own metadata does not.
65func Languages(dir, ref string, lang func(path string) string) []Language {
66 out, err := exec.Command("git", "-C", dir, "ls-tree", "-r", "-l", "--full-name", ref).Output()
67 if err != nil {
68 return nil
69 }
70 bytesBy := map[string]int64{}
71 var total int64
72 for _, line := range strings.Split(string(out), "\n") {
73 // <mode> blob <sha> <size>\t<path>
74 meta, path, ok := strings.Cut(line, "\t")
75 if !ok {
76 continue
77 }
78 f := strings.Fields(meta)
79 if len(f) < 4 || f[1] != "blob" {
80 continue
81 }
82 size, err := strconv.ParseInt(f[3], 10, 64)
83 if err != nil {
84 continue
85 }
86 name := lang(path)
87 if name == "" {
88 continue
89 }
90 bytesBy[name] += size
91 total += size
92 }
93 if total == 0 {
94 return nil
95 }
96 langs := make([]Language, 0, len(bytesBy))
97 for name, b := range bytesBy {
98 langs = append(langs, Language{Name: name, Bytes: b,
99 Percent: float64(b) * 100 / float64(total)})
100 }
101 sort.Slice(langs, func(i, j int) bool {
102 if langs[i].Bytes != langs[j].Bytes {
103 return langs[i].Bytes > langs[j].Bytes
104 }
105 return langs[i].Name < langs[j].Name
106 })
107 return langs
108}
109
110type Language struct {
111 Name string
112 Bytes int64
113 Percent float64
114}
internal/httpd/facts.go added +57
@@ -0,0 +1,57 @@
1package httpd
2
3import (
4 "gitbay.org/gitbay/internal/gitutil"
5)
6
7// repoFacts is the "what is this repository" summary on a repo home: the
8// counts a visitor uses to size up a project before reading any code.
9type repoFacts struct {
10 Commits int
11 Branches int
12 Tags int
13 Contributors []factContributor
14 Languages []gitutil.Language
15 License string
16 Release string // newest release tag, if any
17 Build string // latest build status on the default branch
18}
19
20type factContributor struct {
21 Name string
22 User string // account, when the email is verified here
23 Email string
24 Commits int
25}
26
27const maxContributors = 12
28
29func (s *Server) factsFor(p repoPage) repoFacts {
30 f := repoFacts{
31 Commits: gitutil.CountCommits(p.Dir, p.Ref),
32 License: detectLicense(p.Dir, p.Ref),
33 }
34 if heads, err := gitutil.Refs(p.Dir, "heads"); err == nil {
35 f.Branches = len(heads)
36 }
37 if tags, err := gitutil.Refs(p.Dir, "tags"); err == nil {
38 f.Tags = len(tags)
39 }
40 f.Languages = gitutil.Languages(p.Dir, p.Ref, langOf)
41
42 names := s.authorNames()
43 for _, c := range gitutil.Contributors(p.Dir, p.Ref, maxContributors) {
44 user, _ := names.account(c.Email)
45 f.Contributors = append(f.Contributors, factContributor{
46 Name: names.name(c.Email, c.Name), User: user, Email: c.Email, Commits: c.Commits,
47 })
48 }
49
50 if rels, err := s.st.ListReleases(p.Repo.ID); err == nil && len(rels) > 0 {
51 f.Release = rels[0].Tag
52 }
53 if builds, err := s.st.ListBuilds(p.Repo.ID, 1); err == nil && len(builds) > 0 {
54 f.Build = builds[0].Status
55 }
56 return f
57}
internal/httpd/langs.go added +42
@@ -0,0 +1,42 @@
1package httpd
2
3import (
4 "path"
5 "strings"
6)
7
8// langByExt names the languages worth reporting on a repository home. It is
9// deliberately short: the point is "what is this written in", not a
10// linguist-grade census, and an unlisted extension simply does not count.
11var langByExt = map[string]string{
12 ".go": "Go", ".rs": "Rust", ".c": "C", ".h": "C", ".cc": "C++",
13 ".cpp": "C++", ".hpp": "C++", ".cs": "C#", ".java": "Java",
14 ".kt": "Kotlin", ".swift": "Swift", ".m": "Objective-C",
15 ".py": "Python", ".rb": "Ruby", ".pl": "Perl", ".php": "PHP",
16 ".js": "JavaScript", ".mjs": "JavaScript", ".jsx": "JavaScript",
17 ".ts": "TypeScript", ".tsx": "TypeScript",
18 ".sh": "Shell", ".bash": "Shell", ".zsh": "Shell", ".fish": "Shell",
19 ".lua": "Lua", ".el": "Emacs Lisp", ".lisp": "Common Lisp",
20 ".clj": "Clojure", ".ex": "Elixir", ".exs": "Elixir", ".erl": "Erlang",
21 ".hs": "Haskell", ".ml": "OCaml", ".scala": "Scala", ".dart": "Dart",
22 ".zig": "Zig", ".nim": "Nim", ".jl": "Julia", ".r": "R",
23 ".sql": "SQL", ".html": "HTML", ".css": "CSS", ".scss": "SCSS",
24 ".vim": "Vim script", ".tf": "HCL", ".nix": "Nix",
25 ".org": "Org", ".md": "Markdown", ".tex": "TeX",
26}
27
28// langOf maps a path to a language name, or "" when it is not code we
29// count. Vendored and generated trees are excluded: they describe someone
30// else's work, and including them makes the bar meaningless.
31func langOf(p string) string {
32 for _, seg := range strings.Split(p, "/") {
33 switch seg {
34 case "vendor", "node_modules", "third_party", "testdata", "dist":
35 return ""
36 }
37 }
38 if strings.HasSuffix(p, ".min.js") || strings.HasSuffix(p, ".min.css") {
39 return ""
40 }
41 return langByExt[strings.ToLower(path.Ext(p))]
42}
internal/httpd/web.go +26 −27
@@ -425,22 +425,27 @@ func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
425425 s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/"))
426426 }
427427
428// treePage is shared by the populated and empty-repository renders: two
429// anonymous structs drifted apart once already.
430type treePage struct {
431 repoPage
432 Crumbs []crumb
433 Prefix string
434 DirPath string
435 RefKind string
436 Entries []gitutil.TreeEntry
437 Branches []gitutil.Ref
438 ReadmeName string
439 ReadmeHTML template.HTML
440 LastCommits map[string]namedCommit
441 Tip namedCommit
442 Facts repoFacts
443}
444
428445 func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) {
429446 if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil {
430447 // Empty repo: render the page with no entries rather than 404.
431 s.render(w, "tree.html", struct {
432 repoPage
433 Crumbs []crumb
434 Prefix string
435 DirPath string
436 RefKind string
437 Entries []gitutil.TreeEntry
438 Branches []gitutil.Ref
439 ReadmeName string
440 ReadmeHTML template.HTML
441 LastCommits map[string]namedCommit
442 Tip namedCommit
443 }{repoPage: p, RefKind: "tree"})
448 s.render(w, "tree.html", treePage{repoPage: p, RefKind: "tree"})
444449 return
445450 }
446451 entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath)
@@ -472,22 +477,16 @@ func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage,
472477 for _, e := range entries {
473478 names = append(names, e.Name)
474479 }
475 s.render(w, "tree.html", struct {
476 repoPage
477 Crumbs []crumb
478 Prefix string
479 DirPath string
480 RefKind string
481 Entries []gitutil.TreeEntry
482 Branches []gitutil.Ref
483 ReadmeName string
484 ReadmeHTML template.HTML
485 LastCommits map[string]namedCommit
486 Tip namedCommit
487 }{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches,
480 // The facts bar is about the repository, not this directory, so it is
481 // computed once at the root and left off subdirectory listings.
482 var facts repoFacts
483 if dirPath == "" {
484 facts = s.factsFor(p)
485 }
486 s.render(w, "tree.html", treePage{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches,
488487 readmeName, readmeHTML,
489488 s.namedCommits(gitutil.LastCommits(p.Dir, p.Ref, dirPath, names)),
490 s.namedTip(gitutil.TipCommit(p.Dir, p.Ref))})
489 s.namedTip(gitutil.TipCommit(p.Dir, p.Ref)), facts})
491490 }
492491
493492 func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
internal/web/static/style.css +49
@@ -794,6 +794,55 @@ pre.message {
794794 overflow-wrap: anywhere;
795795 }
796796
797/* repo facts: the counts a visitor sizes a project up with */
798.facts { margin: 0 0 var(--sp-4); }
799.facts .counts {
800 display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-4);
801 margin: 0 0 var(--sp-3); font-size: var(--fs-2);
802}
803.facts .counts a { color: var(--fg); text-decoration: none; }
804.facts .counts a:hover { color: var(--accent); text-decoration: underline; }
805.facts .counts .fact { color: var(--muted); }
806.facts .counts strong { font-weight: 600; }
807
808.langbar {
809 display: flex; height: 0.5rem; margin: 0 0 var(--sp-2);
810 border-radius: var(--r-sm); overflow: hidden; background: var(--faint);
811}
812.langbar .lang { display: block; min-width: 2px; }
813.langs {
814 display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-4);
815 margin: 0 0 var(--sp-3); font-size: var(--fs-1);
816}
817.langs .lang-name { display: inline-flex; align-items: center; gap: var(--sp-1); }
818.langs .muted { color: var(--muted); }
819.langs .dot { width: 0.6rem; height: 0.6rem; border-radius: 50%; display: inline-block; }
820/* named where it reads as the language's own color, neutral otherwise */
821.lang { background: var(--neutral); }
822.lang-go { background: #00add8; }
823.lang-rust { background: #dea584; }
824.lang-c, .lang-c- { background: #555555; }
825.lang-python { background: #3572a5; }
826.lang-javascript { background: #f1e05a; }
827.lang-typescript { background: #3178c6; }
828.lang-shell { background: #89e051; }
829.lang-ruby { background: #701516; }
830.lang-java { background: #b07219; }
831.lang-swift { background: #f05138; }
832.lang-html { background: #e34c26; }
833.lang-css { background: #563d7c; }
834.lang-org { background: #77aa99; }
835.lang-markdown { background: #083fa1; }
836.lang-emacs-lisp { background: #c065db; }
837.lang-nix { background: #7e7eff; }
838.lang-sql { background: #e38c00; }
839
840.contribs {
841 display: flex; flex-wrap: wrap; align-items: baseline;
842 gap: var(--sp-1) var(--sp-3); margin: 0; font-size: var(--fs-1);
843}
844.contribs .label { color: var(--muted); }
845
797846 /* diffs: one foldable section per file, a table so the line-number
798847 gutters stay put while the code scrolls */
799848 details.difffold {
internal/web/templates/tree.html +13
@@ -10,6 +10,19 @@
1010 <a class="act" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">Download</a>
1111 </div>
1212 {{if .Entries}}<p class="clone">Clone: <code>git clone {{.CloneURL}}</code></p>{{end}}
13{{if .Facts.Commits}}{{$r := printf "/%s/%s" .Repo.OwnerName .Repo.Name}}<div class="facts">
14 <p class="counts">
15 <a href="{{$r}}/log/{{.Ref}}"><strong>{{.Facts.Commits}}</strong> commit{{if ne .Facts.Commits 1}}s{{end}}</a>
16 <a href="{{$r}}/refs"><strong>{{.Facts.Branches}}</strong> branch{{if ne .Facts.Branches 1}}es{{end}}</a>
17 <a href="{{$r}}/refs"><strong>{{.Facts.Tags}}</strong> tag{{if ne .Facts.Tags 1}}s{{end}}</a>
18 {{with .Facts.License}}<span class="fact">{{.}}</span>{{end}}
19 {{with .Facts.Release}}<a href="{{$r}}/releases">latest <strong>{{.}}</strong></a>{{end}}
20 {{with .Facts.Build}}<a href="{{$r}}/builds">build <span class="badge badge-{{.}}">{{.}}</span></a>{{end}}
21 </p>
22 {{with .Facts.Languages}}<p class="langbar" aria-hidden="true">{{range .}}<span class="lang lang-{{slug .Name}}" style="width:{{pct .Percent}}%"></span>{{end}}</p>
23 <p class="langs">{{range .}}<span class="lang-name"><span class="dot lang-{{slug .Name}}"></span>{{.Name}} <span class="muted">{{pct .Percent}}%</span></span>{{end}}</p>{{end}}
24 {{with .Facts.Contributors}}<p class="contribs"><span class="label">{{len .}} contributor{{if ne (len .) 1}}s{{end}}</span>{{range .}}{{template "authorname" dict "Name" .Name "User" .User "Email" (printf "%s · %d commits" .Email .Commits)}}{{end}}</p>{{end}}
25</div>{{end}}
1326 {{with .Tip}}{{if .SHA}}<div class="tipbar">
1427 <span class="who">{{template "authorname" dict "Name" .Author "User" .User "Email" .Email}}</span>
1528 <a class="subject" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a>
internal/web/web.go +20
@@ -166,6 +166,26 @@ var funcs = template.FuncMap{
166166 },
167167 // ago renders a time as a coarse relative age, which is what a
168168 // listing column is actually read for. The zero time yields "".
169 // slug turns a language name into a CSS class suffix, so the palette
170 // lives in the stylesheet rather than in inline styles the CSP would
171 // have to allow.
172 "slug": func(s string) string {
173 var b strings.Builder
174 for _, r := range strings.ToLower(s) {
175 switch {
176 case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
177 b.WriteRune(r)
178 default:
179 b.WriteByte('-')
180 }
181 }
182 return b.String()
183 },
184 // pct renders a share to one decimal, dropping a trailing ".0".
185 "pct": func(f float64) string {
186 s := strconv.FormatFloat(f, 'f', 1, 64)
187 return strings.TrimSuffix(s, ".0")
188 },
169189 "ago": func(t time.Time) string {
170190 if t.IsZero() {
171191 return ""