Gitfed
bastien-mrq/gitfed/ Commits/ 6c85471

Add repo stats line: commits, contributors, dominant language

New gitexec.CountContributors (distinct commit-author emails) and DominantLanguage (most common recognized file extension in the default branch's tree, by file count — a lightweight heuristic in the same spirit as detectLicenseType, not a real linguist-style byte-size analysis), threaded through admin.Ops and adminrpc. Shown alongside the existing commit count on the repo page, root of the default branch only (same scope as README/license).

bastien-mrq 2026-07-30 13:40 commit 6c85471168d64cf2fe65726a1418f7901334eb2b parent bae6eb1486b625248042ea5eaec9252629a37cda
11 fichiers modifiés +289 −23
M CHANGELOG.md +4 −0
M ROADMAP.md +0 −3
M cmd/gitfed-web/handlers_repo.go +34 −20
M internal/admin/admin.go +18 −0
M internal/adminrpc/client.go +12 −0
M internal/adminrpc/protocol.go +7 −0
M internal/adminrpc/server.go +16 −0
M internal/gitexec/gitexec.go +71 −0
M internal/gitexec/gitexec_test.go +123 −0
M internal/i18n/strings_en.go +2 −0
M internal/i18n/strings_fr.go +2 −0
CHANGELOG.md
diff --git a/CHANGELOG.md b/CHANGELOG.md index 22adcf4..01938de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ A bullet starting with `**BREAKING:**` flags a change gitfed-ctl's update wizard makes you acknowledge individually before it will let you upgrade past that version. +## 1.2.22 + +- Repo page now shows a small stats line: commit count, contributor count (distinct commit-author emails), and a guessed dominant language (by file extension count in the default branch's tree — a lightweight heuristic, not a real linguist-style byte-size analysis). New `CountContributors`/`DominantLanguage` on `admin.Ops`. + ## 1.2.21 - Download a branch or tag as `.tar.gz`/`.zip` (`git archive` under the hood) — a "Download" dropdown next to the branch picker for the current branch, and a download link on each tag. `?ref=` is validated against the repo's real branches/tags before reaching git, same "unknown value 404s" treatment as the branch dropdown. New `GetRepoArchive` on `admin.Ops`; capped at 100MB since the admin RPC transport buffers the whole response as base64 JSON rather than streaming — a truncated archive would just be a corrupt file, so it's a hard refusal past the cap, not a partial result.
ROADMAP.md
diff --git a/ROADMAP.md b/ROADMAP.md index 91ce385..450855f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -103,9 +103,6 @@ celui-là la prochaine fois qu'on rouvre ce document. ### Collaboration & notifications -- **Statistiques de dépôt** (nombre de commits, contributeurs, langage - dominant) sur la page repo. *(effort faible-moyen, surtout - cosmétique)*. - **Notifications dans l'UI** (pas d'email) sur nouvelle MR ou commentaire — extension du badge/liste de notifications qui existe déjà pour les invitations fédérées, à la même logique. *(effort
cmd/gitfed-web/handlers_repo.go
diff --git a/cmd/gitfed-web/handlers_repo.go b/cmd/gitfed-web/handlers_repo.go index c39d5ca..3b7b077 100644 --- a/cmd/gitfed-web/handlers_repo.go +++ b/cmd/gitfed-web/handlers_repo.go @@ -129,6 +129,11 @@ var repoTpl = newTpl("repo", ` </div> {{if .Repo.Description}}<p class="muted">{{.Repo.Description}}</p>{{end}} <p class="muted">{{t .Lang "repo.owner"}}: {{if localUser .Repo.Owner .Domain}}<a href="/u/{{localUser .Repo.Owner .Domain}}">{{.Repo.Owner}}</a>{{else}}{{.Repo.Owner}}{{end}}</p> +{{if not .Empty}} +<p class="muted" style="font-size:0.84rem;"> + {{t .Lang "repo.stat_commits" .CommitCount}}{{if .ContributorCount}} · {{t .Lang "repo.stat_contributors" .ContributorCount}}{{end}}{{if .DominantLanguage}} · {{.DominantLanguage}}{{end}} +</p> +{{end}} <div class="gf-actions-row"> {{if .Branch}} @@ -301,6 +306,8 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) { var readmeHTML template.HTML var licenseFile, licenseType string var tags []string + var contributorCount int + var dominantLanguage string if path == "" && onDefaultBranch { // README/license previews deliberately stay tied to the default // branch even while browsing another one's tree — there's no @@ -335,34 +342,41 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) { s.serverError(w, r, err) return } + + // Decorative stats line — same non-fatal treatment as commitCount + // above, a failure here shouldn't take down the whole page. + contributorCount, _, _ = s.ops.CountContributors(name) + dominantLanguage, _, _ = s.ops.DominantLanguage(name) } var buf bytes.Buffer _ = repoTpl.Execute(&buf, struct { - Repo store.Repo - Domain string - CloneURL string - HTTPSCloneURL string - Branch string - Branches []branchOptionView - CommitCount int - Lang string - Crumbs []crumb - Entries []treeEntryView - ShowUp bool - ParentPath string - Empty bool - Tags []string - ReadmeHTML template.HTML - LicenseFile string - LicenseType string - CanAdminister bool - Flash template.HTML + Repo store.Repo + Domain string + CloneURL string + HTTPSCloneURL string + Branch string + Branches []branchOptionView + CommitCount int + Lang string + Crumbs []crumb + Entries []treeEntryView + ShowUp bool + ParentPath string + Empty bool + Tags []string + ReadmeHTML template.HTML + LicenseFile string + LicenseType string + ContributorCount int + DominantLanguage string + CanAdminister bool + Flash template.HTML }{ repo, s.domain, "ssh://git@" + s.domain + ":2222/" + name + ".git", "https://" + s.domain + "/" + name + ".git", branch, branchOptions(name, branches, path, branch), commitCount, string(lang), breadcrumbs(path), views, path != "", parentPath(path), path == "" && !found, - tags, readmeHTML, licenseFile, licenseType, canAdminister, flash(r), + tags, readmeHTML, licenseFile, licenseType, contributorCount, dominantLanguage, canAdminister, flash(r), }) title := name
internal/admin/admin.go
diff --git a/internal/admin/admin.go b/internal/admin/admin.go index 634b8c8..bc47df6 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -64,6 +64,8 @@ type Ops interface { GetRepoBranch(name string) (branch string, found bool, err error) ListCommits(name string, limit int) (commits []gitexec.Commit, found bool, err error) CountCommits(name string) (count int, found bool, err error) + CountContributors(name string) (count int, found bool, err error) + DominantLanguage(name string) (language string, found bool, err error) ShowCommit(name, hash string) (detail gitexec.CommitDetail, found bool, err error) CommitDiff(name, hash string) (diff string, truncated bool, err error) ListBranches(name string) (branches []string, err error) @@ -492,6 +494,22 @@ func (a *Admin) CountCommits(name string) (int, bool, error) { return gitexec.CountCommits(repo.Path) } +func (a *Admin) CountContributors(name string) (int, bool, error) { + repo, err := a.Store.GetRepo(name) + if err != nil { + return 0, false, err + } + return gitexec.CountContributors(repo.Path) +} + +func (a *Admin) DominantLanguage(name string) (string, bool, error) { + repo, err := a.Store.GetRepo(name) + if err != nil { + return "", false, err + } + return gitexec.DominantLanguage(repo.Path) +} + func (a *Admin) ShowCommit(name, hash string) (gitexec.CommitDetail, bool, error) { repo, err := a.Store.GetRepo(name) if err != nil {
internal/adminrpc/client.go
diff --git a/internal/adminrpc/client.go b/internal/adminrpc/client.go index 1d81e60..3031a10 100644 --- a/internal/adminrpc/client.go +++ b/internal/adminrpc/client.go @@ -247,6 +247,18 @@ func (c *Client) CountCommits(name string) (int, bool, error) { return out.Count, out.Found, err } +func (c *Client) CountContributors(name string) (int, bool, error) { + var out countFoundResult + err := c.call(methodCountContributors, nameArgs{Name: name}, &out) + return out.Count, out.Found, err +} + +func (c *Client) DominantLanguage(name string) (string, bool, error) { + var out languageResult + err := c.call(methodDominantLanguage, nameArgs{Name: name}, &out) + return out.Language, out.Found, err +} + func (c *Client) GetUser(username string) (store.User, error) { var out userResult err := c.call(methodGetUser, nameArgs{Name: username}, &out)
internal/adminrpc/protocol.go
diff --git a/internal/adminrpc/protocol.go b/internal/adminrpc/protocol.go index c8788fb..80909b3 100644 --- a/internal/adminrpc/protocol.go +++ b/internal/adminrpc/protocol.go @@ -54,6 +54,8 @@ const ( methodCheckAccess = "CheckAccess" methodListCommits = "ListCommits" methodCountCommits = "CountCommits" + methodCountContributors = "CountContributors" + methodDominantLanguage = "DominantLanguage" methodShowCommit = "ShowCommit" methodCommitDiff = "CommitDiff" methodListBranches = "ListBranches" @@ -215,6 +217,11 @@ type countFoundResult struct { Found bool `json:"found"` } +type languageResult struct { + Language string `json:"language"` + Found bool `json:"found"` +} + type userResult struct { User store.User `json:"user"` }
internal/adminrpc/server.go
diff --git a/internal/adminrpc/server.go b/internal/adminrpc/server.go index 1ca7725..4710858 100644 --- a/internal/adminrpc/server.go +++ b/internal/adminrpc/server.go @@ -275,6 +275,22 @@ func (s *Server) dispatch(req wireRequest) (any, error) { count, found, err := s.ops.CountCommits(a.Name) return countFoundResult{Count: count, Found: found}, err + case methodCountContributors: + var a nameArgs + if err := json.Unmarshal(req.Args, &a); err != nil { + return nil, err + } + count, found, err := s.ops.CountContributors(a.Name) + return countFoundResult{Count: count, Found: found}, err + + case methodDominantLanguage: + var a nameArgs + if err := json.Unmarshal(req.Args, &a); err != nil { + return nil, err + } + lang, found, err := s.ops.DominantLanguage(a.Name) + return languageResult{Language: lang, Found: found}, err + case methodGetUser: var a nameArgs if err := json.Unmarshal(req.Args, &a); err != nil {
internal/gitexec/gitexec.go
diff --git a/internal/gitexec/gitexec.go b/internal/gitexec/gitexec.go index 6165c9c..32d1369 100644 --- a/internal/gitexec/gitexec.go +++ b/internal/gitexec/gitexec.go @@ -432,6 +432,77 @@ func CountCommits(repoPath string) (count int, found bool, err error) { return n, true, nil } +// CountContributors returns the number of distinct commit authors (by +// email — the same identity git itself groups by) reachable from the +// repo's default branch. found is false (nil error) for a repo with no +// commits yet, same contract as CountCommits. +func CountContributors(repoPath string) (count int, found bool, err error) { + ref, ok := resolveDefaultRef(repoPath) + if !ok { + return 0, false, nil + } + + out, err := exec.Command("git", "--git-dir="+repoPath, "log", ref, "--format=%ae").Output() + if err != nil { + return 0, false, fmt.Errorf("gitexec: log --format=%%ae %s: %w", ref, err) + } + seen := map[string]bool{} + for _, line := range strings.Split(string(out), "\n") { + if line = strings.TrimSpace(line); line != "" { + seen[line] = true + } + } + return len(seen), true, nil +} + +// languageByExtension is a small, deliberately incomplete file-extension -> +// display-name table for DominantLanguage — same "lightweight heuristic, +// not a full linguist-style analyzer" spirit as detectLicenseType in +// cmd/gitfed-web/license.go, not an exhaustive/authoritative list. +var languageByExtension = map[string]string{ + ".go": "Go", ".py": "Python", ".js": "JavaScript", ".mjs": "JavaScript", ".jsx": "JavaScript", + ".ts": "TypeScript", ".tsx": "TypeScript", ".rb": "Ruby", ".java": "Java", ".c": "C", ".h": "C", + ".cpp": "C++", ".cc": "C++", ".hpp": "C++", ".rs": "Rust", ".php": "PHP", ".sh": "Shell", + ".bash": "Shell", ".css": "CSS", ".scss": "SCSS", ".html": "HTML", ".htm": "HTML", ".sql": "SQL", + ".lua": "Lua", ".swift": "Swift", ".kt": "Kotlin", ".pl": "Perl", ".cs": "C#", ".ex": "Elixir", + ".exs": "Elixir", ".erl": "Erlang", ".hs": "Haskell", ".scala": "Scala", ".clj": "Clojure", + ".r": "R", ".m": "Objective-C", ".dart": "Dart", ".zig": "Zig", +} + +// DominantLanguage returns the most common recognized programming language +// among the files in the repo's default-branch tree, counted by file count +// (not byte size — simpler, and consistent with this being a cosmetic +// estimate, not a real linguist-style analysis). found is false (nil +// error) for an empty repo or one with no recognized-language files (e.g. +// all Markdown/config). +func DominantLanguage(repoPath string) (language string, found bool, err error) { + ref, ok := resolveDefaultRef(repoPath) + if !ok { + return "", false, nil + } + + out, err := exec.Command("git", "--git-dir="+repoPath, "ls-tree", "-r", "--name-only", "--end-of-options", ref).Output() + if err != nil { + return "", false, fmt.Errorf("gitexec: ls-tree -r %s: %w", ref, err) + } + + counts := map[string]int{} + for _, path := range strings.Split(string(out), "\n") { + if lang, ok := languageByExtension[strings.ToLower(filepath.Ext(path))]; ok { + counts[lang]++ + } + } + + var best string + var bestCount int + for lang, n := range counts { + if n > bestCount || (n == bestCount && lang < best) { + best, bestCount = lang, n + } + } + return best, best != "", nil +} + // ListCommits returns up to limit commits reachable from the repo's default // branch (see resolveDefaultRef), most recent first. found is false (nil // error) for a repo with no commits yet.
internal/gitexec/gitexec_test.go
diff --git a/internal/gitexec/gitexec_test.go b/internal/gitexec/gitexec_test.go index adac9ac..b2a1ee2 100644 --- a/internal/gitexec/gitexec_test.go +++ b/internal/gitexec/gitexec_test.go @@ -421,6 +421,129 @@ func TestArchiveUnsupportedFormat(t *testing.T) { } } +func TestCountContributors(t *testing.T) { + tmp := t.TempDir() + barePath := filepath.Join(tmp, "repo.git") + if err := InitBareRepo(barePath); err != nil { + t.Fatalf("init bare repo: %v", err) + } + + count, found, err := CountContributors(barePath) + if err != nil { + t.Fatalf("CountContributors on empty repo: %v", err) + } + if found { + t.Fatalf("empty repo should report found=false, got count=%d", count) + } + + work := filepath.Join(tmp, "work") + if err := os.Mkdir(work, 0755); err != nil { + t.Fatal(err) + } + run(t, work, "git", "init", "-q", "-b", "main") + commitAs := func(name, email, file string) { + if err := os.WriteFile(filepath.Join(work, file), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + cmd := exec.Command("git", "add", file) + cmd.Dir = work + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git add: %v\n%s", err, out) + } + cmd = exec.Command("git", "commit", "-q", "-m", "commit by "+name, + "--author="+name+" <"+email+">") + cmd.Dir = work + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME="+name, "GIT_AUTHOR_EMAIL="+email, + "GIT_COMMITTER_NAME="+name, "GIT_COMMITTER_EMAIL="+email, + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git commit: %v\n%s", err, out) + } + } + commitAs("Alice", "alice@example.com", "a.txt") + commitAs("Bob", "bob@example.com", "b.txt") + commitAs("Alice", "alice@example.com", "a2.txt") // same author again + run(t, work, "git", "remote", "add", "origin", barePath) + run(t, work, "git", "push", "-q", "origin", "main") + + count, found, err = CountContributors(barePath) + if err != nil { + t.Fatalf("CountContributors: %v", err) + } + if !found || count != 2 { + t.Fatalf("CountContributors: found=%v count=%d, want found=true count=2 (Alice x2, Bob x1)", found, count) + } +} + +func TestDominantLanguage(t *testing.T) { + tmp := t.TempDir() + barePath := filepath.Join(tmp, "repo.git") + if err := InitBareRepo(barePath); err != nil { + t.Fatalf("init bare repo: %v", err) + } + + lang, found, err := DominantLanguage(barePath) + if err != nil { + t.Fatalf("DominantLanguage on empty repo: %v", err) + } + if found { + t.Fatalf("empty repo should report found=false, got lang=%q", lang) + } + + work := filepath.Join(tmp, "work") + if err := os.Mkdir(work, 0755); err != nil { + t.Fatal(err) + } + run(t, work, "git", "init", "-q", "-b", "main") + files := []string{"main.go", "util.go", "helper.go", "README.md", "script.py"} + for _, f := range files { + if err := os.WriteFile(filepath.Join(work, f), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + } + run(t, work, "git", "add", ".") + run(t, work, "git", "commit", "-q", "-m", "initial") + run(t, work, "git", "remote", "add", "origin", barePath) + run(t, work, "git", "push", "-q", "origin", "main") + + lang, found, err = DominantLanguage(barePath) + if err != nil { + t.Fatalf("DominantLanguage: %v", err) + } + if !found || lang != "Go" { + t.Fatalf("DominantLanguage: found=%v lang=%q, want found=true lang=\"Go\" (3 .go files vs 1 .py, README.md unrecognized)", found, lang) + } +} + +func TestDominantLanguageNoRecognizedFiles(t *testing.T) { + tmp := t.TempDir() + barePath := filepath.Join(tmp, "repo.git") + if err := InitBareRepo(barePath); err != nil { + t.Fatalf("init bare repo: %v", err) + } + work := filepath.Join(tmp, "work") + if err := os.Mkdir(work, 0755); err != nil { + t.Fatal(err) + } + run(t, work, "git", "init", "-q", "-b", "main") + if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + run(t, work, "git", "add", ".") + run(t, work, "git", "commit", "-q", "-m", "initial") + run(t, work, "git", "remote", "add", "origin", barePath) + run(t, work, "git", "push", "-q", "origin", "main") + + lang, found, err := DominantLanguage(barePath) + if err != nil { + t.Fatalf("DominantLanguage: %v", err) + } + if found { + t.Fatalf("expected no recognized language, got %q", lang) + } +} + func TestDefaultBranchNameViaHEAD(t *testing.T) { tmp := t.TempDir() barePath := filepath.Join(tmp, "repo.git")
internal/i18n/strings_en.go
diff --git a/internal/i18n/strings_en.go b/internal/i18n/strings_en.go index 0cefbb0..c1a29e0 100644 --- a/internal/i18n/strings_en.go +++ b/internal/i18n/strings_en.go @@ -284,6 +284,8 @@ var en = map[string]string{ "repo.nothing_here": "Nothing here.", "repo.tags": "Tags", "repo.download": "Download", + "repo.stat_commits": "%d commits", + "repo.stat_contributors": "%d contributors", "repo.commits_title": "Commits", "repo.commits_empty": "No commits yet.", "repo.commit_label": "commit",
internal/i18n/strings_fr.go
diff --git a/internal/i18n/strings_fr.go b/internal/i18n/strings_fr.go index fefcba1..7b9128d 100644 --- a/internal/i18n/strings_fr.go +++ b/internal/i18n/strings_fr.go @@ -284,6 +284,8 @@ var fr = map[string]string{ "repo.nothing_here": "Rien ici.", "repo.tags": "Tags", "repo.download": "Télécharger", + "repo.stat_commits": "%d commits", + "repo.stat_contributors": "%d contributeurs", "repo.commits_title": "Commits", "repo.commits_empty": "Aucun commit pour le moment.", "repo.commit_label": "commit",