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

internal/modules/repos.go

cb5a7c7c656de5a381e0234208c552c0856e43e3 · 5.2 KB · 176 lines raw

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