Files
forge/modules/git/repo_stats.go
zeekayandHanzo Dev aa87442fdd Record upstream authorship centrally in NOTICE instead of in every file header
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the
Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in
NOTICE, alongside LICENSE. The header lint accepts any copyright line above the
SPDX line, so it no longer demands an upstream one.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 17:50:35 -07:00

141 lines
3.6 KiB
Go

// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"bufio"
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/hanzoai/git/modules/container"
"github.com/hanzoai/git/modules/git/gitcmd"
)
// CodeActivityStats represents git statistics data
type CodeActivityStats struct {
AuthorCount int64
CommitCount int64
ChangedFiles int64
Additions int64
Deletions int64
CommitCountInAllBranches int64
Authors []*CodeActivityAuthor
}
// CodeActivityAuthor represents git statistics data for commit authors
type CodeActivityAuthor struct {
Name string
Email string
Commits int64
}
// GetCodeActivityStats returns code statistics for activity page
func (repo *Repository) GetCodeActivityStats(fromTime time.Time, branch string) (*CodeActivityStats, error) {
stats := &CodeActivityStats{}
since := fromTime.Format(time.RFC3339)
stdout, _, runErr := gitcmd.NewCommand("rev-list", "--count", "--no-merges", "--branches=*", "--date=iso").
AddOptionFormat("--since=%s", since).
WithDir(repo.Path).
RunStdString(repo.Ctx)
if runErr != nil {
return nil, runErr
}
c, err := strconv.ParseInt(strings.TrimSpace(stdout), 10, 64)
if err != nil {
return nil, err
}
stats.CommitCountInAllBranches = c
gitCmd := gitcmd.NewCommand("log", "--numstat", "--no-merges", "--pretty=format:---%n%h%n%aN%n%aE%n", "--date=iso").
AddOptionFormat("--since=%s", since)
if len(branch) == 0 {
gitCmd.AddArguments("--branches=*")
} else {
gitCmd.AddArguments("--first-parent").AddDynamicArguments(branch)
}
stdoutReader, stdoutReaderClose := gitCmd.MakeStdoutPipe()
defer stdoutReaderClose()
err = gitCmd.
WithDir(repo.Path).
WithPipelineFunc(func(ctx gitcmd.Context) error {
scanner := bufio.NewScanner(stdoutReader)
scanner.Split(bufio.ScanLines)
stats.CommitCount = 0
stats.Additions = 0
stats.Deletions = 0
authors := make(map[string]*CodeActivityAuthor)
files := make(container.Set[string])
var author string
p := 0
for scanner.Scan() {
l := strings.TrimSpace(scanner.Text())
if l == "---" {
p = 1
} else if p == 0 {
continue
} else {
p++
}
if p > 4 && len(l) == 0 {
continue
}
switch p {
case 1: // Separator
case 2: // Commit sha-1
stats.CommitCount++
case 3: // Author
author = l
case 4: // E-mail
email := strings.ToLower(l)
if _, ok := authors[email]; !ok {
authors[email] = &CodeActivityAuthor{Name: author, Email: email, Commits: 0}
}
authors[email].Commits++
default: // Changed file
if parts := strings.Fields(l); len(parts) >= 3 {
if parts[0] != "-" {
if c, err := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64); err == nil {
stats.Additions += c
}
}
if parts[1] != "-" {
if c, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64); err == nil {
stats.Deletions += c
}
}
files.Add(parts[2])
}
}
}
if err = scanner.Err(); err != nil {
return fmt.Errorf("GetCodeActivityStats scan: %w", err)
}
a := make([]*CodeActivityAuthor, 0, len(authors))
for _, v := range authors {
a = append(a, v)
}
// Sort authors descending depending on commit count
sort.Slice(a, func(i, j int) bool {
return a[i].Commits > a[j].Commits
})
stats.AuthorCount = int64(len(authors))
stats.ChangedFiles = int64(len(files))
stats.Authors = a
return nil
}).
RunWithStderr(repo.Ctx)
if err != nil {
return nil, fmt.Errorf("GetCodeActivityStats: %w", err)
}
return stats, nil
}