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

internal/core/core.go

c0427d5fc7c0fcf8606c546071085e8af231fb81 · 9.2 KB · 330 lines raw

1 // Package core is the extension point. A module computes one piece of a
2 // view; a route binds modules to an output file. Adding a feature means
3 // adding a module and using it from a template -- nothing here changes.
4 package core
5
6 import (
7 "fmt"
8 "sort"
9 "strings"
10 "sync"
11 "sync/atomic"
12
13 "gsg/internal/gitx"
14 )
15
16 // ------------------------------------------------------------------ config
17
18 type Site struct {
19 Name string
20 BaseURL string
21 Root string // directory containing bare repos
22
23 idx atomic.Pointer[repoIndex]
24 mu sync.Mutex // serializes upsert/remove; RepoList/Lookup never block
25 }
26
27 // repoIndex is an immutable snapshot, swapped in whole by upsert/remove so
28 // readers (RepoList, Lookup) never need a lock.
29 type repoIndex struct {
30 list []Handle // every known repo, hidden or not -- repos.go filters Hidden itself
31 bySlug map[string]Handle // non-hidden only, for URL routing
32 }
33
34 // RepoList is every discovered repository. Site-scoped routes (the index
35 // page, the index endpoint) read it through the `repos` module.
36 func (s *Site) RepoList() []Handle {
37 if idx := s.idx.Load(); idx != nil {
38 return idx.list
39 }
40 return nil
41 }
42
43 // Lookup resolves a repo by slug for per-repo routes. Hidden repos are not
44 // found here, the same way they were excluded from Server's old repos map.
45 func (s *Site) Lookup(slug string) (Handle, bool) {
46 idx := s.idx.Load()
47 if idx == nil {
48 return Handle{}, false
49 }
50 h, ok := idx.bySlug[slug]
51 return h, ok
52 }
53
54 // upsert and remove are the only writers of the repo index, both called
55 // exclusively from watch.go's reconcile -- at startup and at runtime alike,
56 // through the same code path.
57 func (s *Site) upsert(h Handle) {
58 s.mutate(func(list []Handle) []Handle {
59 for i, e := range list {
60 if e.Info.Slug == h.Info.Slug {
61 list[i] = h
62 return list
63 }
64 }
65 return append(list, h)
66 })
67 }
68
69 func (s *Site) remove(slug string) {
70 s.mutate(func(list []Handle) []Handle {
71 for i, e := range list {
72 if e.Info.Slug == slug {
73 return append(list[:i], list[i+1:]...)
74 }
75 }
76 return list
77 })
78 }
79
80 func (s *Site) mutate(f func([]Handle) []Handle) {
81 s.mu.Lock()
82 defer s.mu.Unlock()
83
84 var cur []Handle
85 if idx := s.idx.Load(); idx != nil {
86 cur = append([]Handle(nil), idx.list...)
87 }
88 next := f(cur)
89
90 bySlug := make(map[string]Handle, len(next))
91 for _, h := range next {
92 if !h.Info.Hidden {
93 bySlug[h.Info.Slug] = h
94 }
95 }
96 s.idx.Store(&repoIndex{list: next, bySlug: bySlug})
97 }
98
99 // Handle pairs an open repository with its configuration.
100 type Handle struct {
101 Repo *gitx.Repo
102 Info RepoInfo
103 }
104
105 // RepoInfo is the per-repository configuration, read from the repo's own
106 // git config the way cgit does. Keys, in priority order:
107 //
108 // [gsg] [gitweb]
109 // title = ... owner = ...
110 // description = ... description = ...
111 // owner = ...
112 // clone = git://...
113 // hide = true
114 // section = infra
115 // gpgkeyring = /path/to/keys.asc
116 //
117 // Set them with: git --git-dir=/srv/git/foo.git config gsg.title "Foo"
118 type RepoInfo struct {
119 Slug string
120 Title string
121 Description string
122 Owner string
123 Clone string
124 Section string
125 Hidden bool
126
127 // GPGKeyring is the configured path to a file of armored public keys
128 // (gsg.gpgkeyring), if any. Reading it here is just config; the actual
129 // import into an isolated keyring happens once at startup (see
130 // gitx.Repo.UseKeyring) and, on failure, this is cleared again so
131 // templates don't advertise a verification column that isn't backed
132 // by anything.
133 GPGKeyring string
134 }
135
136 func LoadRepoInfo(r *gitx.Repo) RepoInfo {
137 title := r.ConfigFirst("gsg.title")
138 if title == "" {
139 title = r.Slug
140 }
141 desc := r.ConfigFirst("gsg.description", "gitweb.description")
142 if desc == "" {
143 desc = r.DescriptionFile() // cgit-compatible fallback
144 }
145 return RepoInfo{
146 Slug: r.Slug,
147 Title: title,
148 Description: desc,
149 Owner: r.ConfigFirst("gsg.owner", "gitweb.owner"),
150 Clone: r.ConfigFirst("gsg.clone", "gitweb.url"),
151 Section: r.ConfigFirst("gsg.section", "gitweb.category"),
152 Hidden: strings.EqualFold(r.Config("gsg.hide"), "true"),
153 GPGKeyring: r.Config("gsg.gpgkeyring"),
154 }
155 }
156
157 // ----------------------------------------------------------------- context
158
159 // Ctx is what a module is given: which repo, at which ref, at which path.
160 // The cache makes `mod` idempotent within a single page render, so two
161 // templates asking for the same module do not pay for it twice.
162 type Ctx struct {
163 Site *Site
164 Repo *gitx.Repo
165 Info RepoInfo
166 Ref string // the ref as the user spelled it ("main", "v0.1.0", a sha)
167 Path string
168 Vars map[string]string
169
170 // Nav is the repo-scoped tab bar, built once in Server.serve from the
171 // route table (see nav.go). Empty for site-scoped routes.
172 Nav []NavItem
173
174 // Commit is Ref resolved to a full SHA. Everything a module computes
175 // at a given Commit is immutable, which is what makes the shared
176 // cache and ETags correct rather than merely convenient.
177 Commit string
178
179 // Store is the process-wide cache, nil in tests. Only consulted when
180 // Commit is set.
181 Store Store
182
183 mu sync.Mutex
184 cache map[string]result
185 }
186
187 // Store is the cross-request cache. Implemented by internal/core.LRU.
188 type Store interface {
189 Get(key string) (any, bool)
190 Put(key string, v any)
191 }
192
193 type result struct {
194 val any
195 err error
196 }
197
198 func NewCtx(site *Site, r *gitx.Repo, info RepoInfo, ref string) *Ctx {
199 return &Ctx{Site: site, Repo: r, Info: info, Ref: ref,
200 Vars: map[string]string{}, cache: map[string]result{}}
201 }
202
203 // contentIdentity captures every input that can change what a route
204 // renders at a fixed commit -- the commit itself, plus anything that can
205 // change without the commit moving. Vars are part of it: /compare/a..b and
206 // /compare/a...b resolve to the same head commit, so without the base ref
207 // in here the second request was served the first one's diff. GPGKeyring
208 // is part of it for the same reason: watch.go's reconcile can change a
209 // repo's keyring under commits whose SHAs haven't moved, and a page
210 // fetched before that would otherwise cache "unsigned" forever.
211 //
212 // CacheKey and the response ETag (server.go) are both built from this, so
213 // a new Var or a keyring change can't invalidate one without invalidating
214 // the other -- that used to be two hand-maintained lists that had already
215 // drifted (the ETag was missing GPGKeyring and any Var besides baseSHA).
216 func (c *Ctx) contentIdentity() string {
217 keys := make([]string, 0, len(c.Vars))
218 for k := range c.Vars {
219 keys = append(keys, k)
220 }
221 sort.Strings(keys)
222 var b strings.Builder
223 b.WriteString(c.Commit + "|" + c.Info.GPGKeyring)
224 for _, k := range keys {
225 b.WriteString("|" + k + "=" + c.Vars[k])
226 }
227 return b.String()
228 }
229
230 // CacheKey identifies an immutable module result. Empty when the result
231 // cannot be cached (no resolved commit).
232 func (c *Ctx) CacheKey(name string, args []string) string {
233 if c.Commit == "" || c.Store == nil {
234 return ""
235 }
236 return c.Info.Slug + "|" + c.Path + "|" + name + "|" +
237 strings.Join(args, "\x00") + "|" + c.contentIdentity()
238 }
239
240 // Derive returns a shallow copy for a different path, with a fresh cache.
241 func (c *Ctx) Derive(path string, vars map[string]string) *Ctx {
242 n := NewCtx(c.Site, c.Repo, c.Info, c.Ref)
243 n.Path = path
244 for k, v := range c.Vars {
245 n.Vars[k] = v
246 }
247 for k, v := range vars {
248 n.Vars[k] = v
249 }
250 return n
251 }
252
253 func (c *Ctx) Var(k string) string { return c.Vars[k] }
254
255 // ---------------------------------------------------------------- registry
256
257 // Module computes one piece of a view. Anything a template or an endpoint
258 // might want is a module: readme, tree, log, refs, graph, blame, stats.
259 type Module func(c *Ctx, args ...string) (any, error)
260
261 var (
262 regMu sync.RWMutex
263 registry = map[string]Module{}
264 )
265
266 // Register adds a module. Call it from an init() in the modules package.
267 func Register(name string, m Module) {
268 regMu.Lock()
269 defer regMu.Unlock()
270 if _, dup := registry[name]; dup {
271 panic("core: duplicate module " + name)
272 }
273 registry[name] = m
274 }
275
276 func Names() []string {
277 regMu.RLock()
278 defer regMu.RUnlock()
279 out := make([]string, 0, len(registry))
280 for n := range registry {
281 out = append(out, n)
282 }
283 return out
284 }
285
286 // Mod runs a module, memoized per Ctx. This is the single entry point
287 // used by both templates (via the funcmap) and JSON endpoints.
288 func (c *Ctx) Mod(name string, args ...string) (any, error) {
289 key := name + "\x00" + strings.Join(args, "\x00")
290
291 c.mu.Lock()
292 if c.cache == nil {
293 c.cache = map[string]result{}
294 }
295 if r, ok := c.cache[key]; ok {
296 c.mu.Unlock()
297 return r.val, r.err
298 }
299 c.mu.Unlock()
300
301 regMu.RLock()
302 m, ok := registry[name]
303 regMu.RUnlock()
304 if !ok {
305 return nil, fmt.Errorf("no such module %q (have: %s)", name, strings.Join(Names(), ", "))
306 }
307
308 // Cross-request cache: a module's output at a fixed commit SHA cannot
309 // change, so this is safe without any invalidation logic. Nothing here
310 // is keyed on a ref name -- refs move, SHAs do not.
311 shared := c.CacheKey(name, args)
312 if shared != "" {
313 if v, ok := c.Store.Get(shared); ok {
314 c.mu.Lock()
315 c.cache[key] = result{v, nil}
316 c.mu.Unlock()
317 return v, nil
318 }
319 }
320
321 val, err := m(c, args...)
322 if err == nil && shared != "" {
323 c.Store.Put(shared, val)
324 }
325
326 c.mu.Lock()
327 c.cache[key] = result{val, err}
328 c.mu.Unlock()
329 return val, err
330 }