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).
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",