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

internal/modules/repos.go

8d1bb5bfdd2cba82488775b46308efed740132a8 · 5.1 KB · 170 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 // publicRepos returns every publicly-visible repository, newest activity
36 // first, refreshed at most once per reposCacheTTL. This is the expensive,
37 // I/O-bound part (one git log per repo) that the cache above exists for,
38 // and it is deliberately untouched by personalization (see reposMod):
39 // anonymous traffic -- the overwhelming majority of requests -- keeps
40 // paying exactly what it paid before logins or share links existed,
41 // regardless of how many hidden repos a site has.
42 func publicRepos(c *core.Ctx) []RepoSummary {
43 reposCache.mu.Lock()
44 if !reposCache.at.IsZero() && time.Since(reposCache.at) < reposCacheTTL {
45 out := reposCache.val
46 reposCache.mu.Unlock()
47 return out
48 }
49 reposCache.mu.Unlock()
50
51 list := c.Site.RepoList()
52 out := make([]RepoSummary, 0, len(list))
53 for _, h := range list {
54 if h.Info.Hidden {
55 continue
56 }
57 out = append(out, summarize(h))
58 }
59 sort.Slice(out, func(i, j int) bool { return out[i].LastCommit.After(out[j].LastCommit) })
60
61 reposCache.mu.Lock()
62 reposCache.val, reposCache.at = out, time.Now()
63 reposCache.mu.Unlock()
64 return out
65 }
66
67 func summarize(h core.Handle) RepoSummary {
68 s := RepoSummary{RepoInfo: h.Info}
69 if log, err := h.Repo.Log(h.Repo.HeadRef(), 1, 0, false); err == nil && len(log) > 0 {
70 s.LastCommit = log[0].When
71 }
72 return s
73 }
74
75 // repos: every repository visible to the requester, newest activity first
76 // -- every public repo, plus any hidden repo the current identity is
77 // allowed to see, whether as an owner (gsg.visible-to) or via an unrevoked
78 // share grant. Site-scoped -- does not touch c.Repo, so it is safe on the
79 // site Ctx.
80 func reposMod(c *core.Ctx, args ...string) (any, error) {
81 base := publicRepos(c)
82 if c.User == "" && len(c.SharedSlugs) == 0 {
83 return base, nil
84 }
85
86 // Uncached and on-demand, by design: only the handful of hidden repos
87 // this specific identity unlocks, never a walk weighted by total
88 // hidden-repo count. Cost scales with how many repos one person can
89 // see, not with site size -- an owner (or share-link visitor) reloading
90 // "/" pays one git log per repo they can unlock, on every request (not
91 // cross-request-memoized), which is the trade-off for keeping
92 // publicRepos's shared cache above entirely free of personalization.
93 var unlocked []RepoSummary
94 for _, h := range c.Site.RepoList() {
95 if !h.Info.Hidden {
96 continue
97 }
98 if h.Info.CanView(c.User) || h.Info.SharedVisible(c.SharedSlugs, c.SharedIssuedAt) {
99 unlocked = append(unlocked, summarize(h))
100 }
101 }
102 if len(unlocked) == 0 {
103 return base, nil
104 }
105
106 out := make([]RepoSummary, 0, len(base)+len(unlocked))
107 out = append(out, base...)
108 out = append(out, unlocked...)
109 sort.Slice(out, func(i, j int) bool { return out[i].LastCommit.After(out[j].LastCommit) })
110 return out, nil
111 }
112
113 // Section is a named group of repositories, from `gsg.section` in each
114 // repo's config (or `gitweb.category`).
115 type Section struct {
116 Name string // "" means the repo set has no sections at all
117 Repos []RepoSummary
118 }
119
120 func init() { core.Register("sections", sectionsMod) }
121
122 // sections: repos grouped by gsg.section, sections alphabetical, unsectioned
123 // last. Built on top of `repos` rather than re-walking the repo set, so the
124 // index page pays for the log lookups exactly once.
125 //
126 // Returns a single unnamed Section when nothing sets gsg.section, which lets
127 // the template render a flat list without branching on the config.
128 func sectionsMod(c *core.Ctx, args ...string) (any, error) {
129 v, err := c.Mod("repos")
130 if err != nil {
131 return nil, err
132 }
133 list, ok := v.([]RepoSummary)
134 if !ok {
135 return nil, fmt.Errorf("sections: repos returned %T", v)
136 }
137
138 any := false
139 groups := map[string][]RepoSummary{}
140 for _, r := range list {
141 if r.Section != "" {
142 any = true
143 }
144 groups[r.Section] = append(groups[r.Section], r)
145 }
146 if !any {
147 return []Section{{Name: "", Repos: list}}, nil
148 }
149
150 names := make([]string, 0, len(groups))
151 for n := range groups {
152 if n != "" {
153 names = append(names, n)
154 }
155 }
156 sort.Strings(names)
157
158 out := make([]Section, 0, len(groups))
159 for _, n := range names {
160 out = append(out, Section{Name: n, Repos: groups[n]})
161 }
162 if rest, ok := groups[""]; ok {
163 out = append(out, Section{Name: unsectioned, Repos: rest})
164 }
165 return out, nil
166 }
167
168 // unsectioned labels the leftover group. Named rather than inlined so it is
169 // obvious this string is user-facing.
170 const unsectioned = "Other"