Migration This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs. Contact me

internal/modules/repos.go

c0427d5fc7c0fcf8606c546071085e8af231fb81 · 3.3 KB · 123 lines raw

1 package modules
2
3 import (
4 "fmt"
5 "sort"
6 "sync"
7 "time"
8
9 "gsg/internal/core"
10 )
11
12 type RepoSummary struct {
13 core.RepoInfo
14 LastCommit time.Time
15 }
16
17 func init() { core.Register("repos", reposMod) }
18
19 // reposCacheTTL bounds how stale the index page's "last updated" ordering
20 // and repo list can be. reposMod is site-scoped, so it never gets a
21 // resolved commit and therefore never touches the module cache (see
22 // Ctx.CacheKey) -- without this, every hit to "/" or the JSON index forks
23 // git twice per repo, unconditionally, no matter how often the page is
24 // requested. A few seconds of staleness here is the same trade-off the
25 // fsnotify debounce already makes for repo discovery, just applied to one
26 // more surface.
27 const reposCacheTTL = 5 * time.Second
28
29 var reposCache struct {
30 mu sync.Mutex
31 at time.Time
32 val []RepoSummary
33 }
34
35 // repos: every visible repository, newest activity first.
36 // Site-scoped -- does not touch c.Repo, so it is safe on the site Ctx.
37 func reposMod(c *core.Ctx, args ...string) (any, error) {
38 reposCache.mu.Lock()
39 if !reposCache.at.IsZero() && time.Since(reposCache.at) < reposCacheTTL {
40 out := reposCache.val
41 reposCache.mu.Unlock()
42 return out, nil
43 }
44 reposCache.mu.Unlock()
45
46 list := c.Site.RepoList()
47 out := make([]RepoSummary, 0, len(list))
48 for _, h := range list {
49 if h.Info.Hidden {
50 continue
51 }
52 s := RepoSummary{RepoInfo: h.Info}
53 if log, err := h.Repo.Log(h.Repo.HeadRef(), 1, 0, false); err == nil && len(log) > 0 {
54 s.LastCommit = log[0].When
55 }
56 out = append(out, s)
57 }
58 sort.Slice(out, func(i, j int) bool { return out[i].LastCommit.After(out[j].LastCommit) })
59
60 reposCache.mu.Lock()
61 reposCache.val, reposCache.at = out, time.Now()
62 reposCache.mu.Unlock()
63 return out, nil
64 }
65
66 // Section is a named group of repositories, from `gsg.section` in each
67 // repo's config (or `gitweb.category`).
68 type Section struct {
69 Name string // "" means the repo set has no sections at all
70 Repos []RepoSummary
71 }
72
73 func init() { core.Register("sections", sectionsMod) }
74
75 // sections: repos grouped by gsg.section, sections alphabetical, unsectioned
76 // last. Built on top of `repos` rather than re-walking the repo set, so the
77 // index page pays for the log lookups exactly once.
78 //
79 // Returns a single unnamed Section when nothing sets gsg.section, which lets
80 // the template render a flat list without branching on the config.
81 func sectionsMod(c *core.Ctx, args ...string) (any, error) {
82 v, err := c.Mod("repos")
83 if err != nil {
84 return nil, err
85 }
86 list, ok := v.([]RepoSummary)
87 if !ok {
88 return nil, fmt.Errorf("sections: repos returned %T", v)
89 }
90
91 any := false
92 groups := map[string][]RepoSummary{}
93 for _, r := range list {
94 if r.Section != "" {
95 any = true
96 }
97 groups[r.Section] = append(groups[r.Section], r)
98 }
99 if !any {
100 return []Section{{Name: "", Repos: list}}, nil
101 }
102
103 names := make([]string, 0, len(groups))
104 for n := range groups {
105 if n != "" {
106 names = append(names, n)
107 }
108 }
109 sort.Strings(names)
110
111 out := make([]Section, 0, len(groups))
112 for _, n := range names {
113 out = append(out, Section{Name: n, Repos: groups[n]})
114 }
115 if rest, ok := groups[""]; ok {
116 out = append(out, Section{Name: unsectioned, Repos: rest})
117 }
118 return out, nil
119 }
120
121 // unsectioned labels the leftover group. Named rather than inlined so it is
122 // obvious this string is user-facing.
123 const unsectioned = "Other"