gitview/internal/git/languages.go
internal/git/languages.goRaw
1package git
2
3import (
4 "fmt"
5 "io"
6 "slices"
7 "sort"
8 "sync"
9
10 enry "github.com/go-enry/go-enry/v2"
11 "github.com/go-git/go-git/v5/plumbing"
12 "github.com/go-git/go-git/v5/plumbing/filemode"
13 "github.com/go-git/go-git/v5/plumbing/object"
14)
15
16// LanguageStat describes a language's share of the repository's source bytes.
17type LanguageStat struct {
18 Name string
19 Color string
20 Bytes int64
21 Percentage float64
22}
23
24// Immutable Git tree IDs keep cached statistics valid across mirror updates.
25var languageStatsCache = struct {
26 sync.Mutex
27 entries map[plumbing.Hash][]LanguageStat
28 order []plumbing.Hash
29}{entries: make(map[plumbing.Hash][]LanguageStat)}
30
31// Languages classifies committed source files without requiring a working tree.
32func (g *GitRepo) Languages() ([]LanguageStat, error) {
33 commit, err := g.r.CommitObject(g.h)
34 if err != nil {
35 return nil, fmt.Errorf("language commit: %w", err)
36 }
37 tree, err := commit.Tree()
38 if err != nil {
39 return nil, fmt.Errorf("language tree: %w", err)
40 }
41 languageStatsCache.Lock()
42 cached, ok := languageStatsCache.entries[tree.Hash]
43 languageStatsCache.Unlock()
44 if ok {
45 return slices.Clone(cached), nil
46 }
47
48 sizes := make(map[string]int64)
49 err = tree.Files().ForEach(func(file *object.File) error {
50 if file.Size == 0 || file.Mode == filemode.Symlink || enry.IsVendor(file.Name) || enry.IsDocumentation(file.Name) {
51 return nil
52 }
53 reader, err := file.Reader()
54 if err != nil {
55 return err
56 }
57 // Bound content inspection while weighing every included file by its full size.
58 content, readErr := io.ReadAll(io.LimitReader(reader, 64*1024))
59 closeErr := reader.Close()
60 if readErr != nil {
61 return readErr
62 }
63 if closeErr != nil {
64 return closeErr
65 }
66 if enry.IsBinary(content) || enry.IsGenerated(file.Name, content) {
67 return nil
68 }
69 name := enry.GetLanguage(file.Name, content)
70 kind := enry.GetLanguageType(name)
71 if kind != enry.Programming && kind != enry.Markup {
72 return nil
73 }
74 if group := enry.GetLanguageGroup(name); group != "" {
75 name = group
76 }
77 sizes[name] += file.Size
78 return nil
79 })
80 if err != nil {
81 return nil, fmt.Errorf("classifying languages: %w", err)
82 }
83 stats := make([]LanguageStat, 0, len(sizes))
84 var total int64
85 for name, size := range sizes {
86 stats = append(stats, LanguageStat{Name: name, Color: enry.GetColor(name), Bytes: size})
87 total += size
88 }
89 sort.Slice(stats, func(i, j int) bool {
90 if stats[i].Bytes == stats[j].Bytes {
91 return stats[i].Name < stats[j].Name
92 }
93 return stats[i].Bytes > stats[j].Bytes
94 })
95 for i := range stats {
96 stats[i].Percentage = 100 * float64(stats[i].Bytes) / float64(total)
97 }
98 languageStatsCache.Lock()
99 defer languageStatsCache.Unlock()
100 if _, exists := languageStatsCache.entries[tree.Hash]; !exists {
101 const maxCachedTrees = 128
102 if len(languageStatsCache.order) == maxCachedTrees {
103 delete(languageStatsCache.entries, languageStatsCache.order[0])
104 languageStatsCache.order = languageStatsCache.order[1:]
105 }
106 languageStatsCache.order = append(languageStatsCache.order, tree.Hash)
107 languageStatsCache.entries[tree.Hash] = slices.Clone(stats)
108 }
109 return stats, nil
110}