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

internal/core/core.go

3a50036d96eb14462e96598fd169d1c79264ac4b · 6.2 KB · 233 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
12 "gsg/internal/gitx"
13 )
14
15 // ------------------------------------------------------------------ config
16
17 type Site struct {
18 Name string
19 BaseURL string
20 Root string // directory containing bare repos
21
22 // Repos is every discovered repository. Site-scoped routes (the
23 // index page, the index endpoint) read it through the `repos` module.
24 Repos []Handle
25 }
26
27 // Handle pairs an open repository with its configuration.
28 type Handle struct {
29 Repo *gitx.Repo
30 Info RepoInfo
31 }
32
33 // RepoInfo is the per-repository configuration, read from the repo's own
34 // git config the way cgit does. Keys, in priority order:
35 //
36 // [gsg] [gitweb]
37 // title = ... owner = ...
38 // description = ... description = ...
39 // owner = ...
40 // clone = git://...
41 // hide = true
42 // section = infra
43 //
44 // Set them with: git --git-dir=/srv/git/foo.git config gsg.title "Foo"
45 type RepoInfo struct {
46 Slug string
47 Title string
48 Description string
49 Owner string
50 Clone string
51 Section string
52 Hidden bool
53 }
54
55 func LoadRepoInfo(r *gitx.Repo) RepoInfo {
56 title := r.ConfigFirst("gsg.title")
57 if title == "" {
58 title = r.Slug
59 }
60 desc := r.ConfigFirst("gsg.description", "gitweb.description")
61 if desc == "" {
62 desc = r.DescriptionFile() // cgit-compatible fallback
63 }
64 return RepoInfo{
65 Slug: r.Slug,
66 Title: title,
67 Description: desc,
68 Owner: r.ConfigFirst("gsg.owner", "gitweb.owner"),
69 Clone: r.ConfigFirst("gsg.clone", "gitweb.url"),
70 Section: r.ConfigFirst("gsg.section", "gitweb.category"),
71 Hidden: strings.EqualFold(r.Config("gsg.hide"), "true"),
72 }
73 }
74
75 // ----------------------------------------------------------------- context
76
77 // Ctx is what a module is given: which repo, at which ref, at which path.
78 // The cache makes `mod` idempotent within a single page render, so two
79 // templates asking for the same module do not pay for it twice.
80 type Ctx struct {
81 Site *Site
82 Repo *gitx.Repo
83 Info RepoInfo
84 Ref string // the ref as the user spelled it ("main", "v0.1.0", a sha)
85 Path string
86 Vars map[string]string
87
88 // Commit is Ref resolved to a full SHA. Everything a module computes
89 // at a given Commit is immutable, which is what makes the shared
90 // cache and ETags correct rather than merely convenient.
91 Commit string
92
93 // Store is the process-wide cache, nil in tests. Only consulted when
94 // Commit is set.
95 Store Store
96
97 mu sync.Mutex
98 cache map[string]result
99 }
100
101 // Store is the cross-request cache. Implemented by internal/core.LRU.
102 type Store interface {
103 Get(key string) (any, bool)
104 Put(key string, v any)
105 }
106
107 type result struct {
108 val any
109 err error
110 }
111
112 func NewCtx(site *Site, r *gitx.Repo, info RepoInfo, ref string) *Ctx {
113 return &Ctx{Site: site, Repo: r, Info: info, Ref: ref,
114 Vars: map[string]string{}, cache: map[string]result{}}
115 }
116
117 // CacheKey identifies an immutable module result. Empty when the result
118 // cannot be cached (no resolved commit).
119 //
120 // Vars are part of the key. They were not, originally, and the bug was
121 // exactly the kind this cache is supposed to be immune to: /compare/a..b
122 // and /compare/a...b resolve to the same head commit, so the second
123 // request was served the first one's diff. Anything that changes what a
124 // module computes has to be in the key, not just the commit it hangs off.
125 func (c *Ctx) CacheKey(name string, args []string) string {
126 if c.Commit == "" || c.Store == nil {
127 return ""
128 }
129 var b strings.Builder
130 b.WriteString(c.Info.Slug + "|" + c.Commit + "|" + c.Path + "|" + name + "|")
131 b.WriteString(strings.Join(args, "\x00"))
132 keys := make([]string, 0, len(c.Vars))
133 for k := range c.Vars {
134 keys = append(keys, k)
135 }
136 sort.Strings(keys)
137 for _, k := range keys {
138 b.WriteString("|" + k + "=" + c.Vars[k])
139 }
140 return b.String()
141 }
142
143 // Derive returns a shallow copy for a different path, with a fresh cache.
144 func (c *Ctx) Derive(path string, vars map[string]string) *Ctx {
145 n := NewCtx(c.Site, c.Repo, c.Info, c.Ref)
146 n.Path = path
147 for k, v := range c.Vars {
148 n.Vars[k] = v
149 }
150 for k, v := range vars {
151 n.Vars[k] = v
152 }
153 return n
154 }
155
156 func (c *Ctx) Var(k string) string { return c.Vars[k] }
157
158 // ---------------------------------------------------------------- registry
159
160 // Module computes one piece of a view. Anything a template or an endpoint
161 // might want is a module: readme, tree, log, refs, graph, blame, stats.
162 type Module func(c *Ctx, args ...string) (any, error)
163
164 var (
165 regMu sync.RWMutex
166 registry = map[string]Module{}
167 )
168
169 // Register adds a module. Call it from an init() in the modules package.
170 func Register(name string, m Module) {
171 regMu.Lock()
172 defer regMu.Unlock()
173 if _, dup := registry[name]; dup {
174 panic("core: duplicate module " + name)
175 }
176 registry[name] = m
177 }
178
179 func Names() []string {
180 regMu.RLock()
181 defer regMu.RUnlock()
182 out := make([]string, 0, len(registry))
183 for n := range registry {
184 out = append(out, n)
185 }
186 return out
187 }
188
189 // Mod runs a module, memoized per Ctx. This is the single entry point
190 // used by both templates (via the funcmap) and JSON endpoints.
191 func (c *Ctx) Mod(name string, args ...string) (any, error) {
192 key := name + "\x00" + strings.Join(args, "\x00")
193
194 c.mu.Lock()
195 if c.cache == nil {
196 c.cache = map[string]result{}
197 }
198 if r, ok := c.cache[key]; ok {
199 c.mu.Unlock()
200 return r.val, r.err
201 }
202 c.mu.Unlock()
203
204 regMu.RLock()
205 m, ok := registry[name]
206 regMu.RUnlock()
207 if !ok {
208 return nil, fmt.Errorf("no such module %q (have: %s)", name, strings.Join(Names(), ", "))
209 }
210
211 // Cross-request cache: a module's output at a fixed commit SHA cannot
212 // change, so this is safe without any invalidation logic. Nothing here
213 // is keyed on a ref name -- refs move, SHAs do not.
214 shared := c.CacheKey(name, args)
215 if shared != "" {
216 if v, ok := c.Store.Get(shared); ok {
217 c.mu.Lock()
218 c.cache[key] = result{v, nil}
219 c.mu.Unlock()
220 return v, nil
221 }
222 }
223
224 val, err := m(c, args...)
225 if err == nil && shared != "" {
226 c.Store.Put(shared, val)
227 }
228
229 c.mu.Lock()
230 c.cache[key] = result{val, err}
231 c.mu.Unlock()
232 return val, err
233 }