// Package core is the extension point. A module computes one piece of a // view; a route binds modules to an output file. Adding a feature means // adding a module and using it from a template -- nothing here changes. package core import ( "fmt" "sort" "strings" "sync" "gsg/internal/gitx" ) // ------------------------------------------------------------------ config type Site struct { Name string BaseURL string Root string // directory containing bare repos // Repos is every discovered repository. Site-scoped routes (the // index page, the index endpoint) read it through the `repos` module. Repos []Handle } // Handle pairs an open repository with its configuration. type Handle struct { Repo *gitx.Repo Info RepoInfo } // RepoInfo is the per-repository configuration, read from the repo's own // git config the way cgit does. Keys, in priority order: // // [gsg] [gitweb] // title = ... owner = ... // description = ... description = ... // owner = ... // clone = git://... // hide = true // section = infra // // Set them with: git --git-dir=/srv/git/foo.git config gsg.title "Foo" type RepoInfo struct { Slug string Title string Description string Owner string Clone string Section string Hidden bool } func LoadRepoInfo(r *gitx.Repo) RepoInfo { title := r.ConfigFirst("gsg.title") if title == "" { title = r.Slug } desc := r.ConfigFirst("gsg.description", "gitweb.description") if desc == "" { desc = r.DescriptionFile() // cgit-compatible fallback } return RepoInfo{ Slug: r.Slug, Title: title, Description: desc, Owner: r.ConfigFirst("gsg.owner", "gitweb.owner"), Clone: r.ConfigFirst("gsg.clone", "gitweb.url"), Section: r.ConfigFirst("gsg.section", "gitweb.category"), Hidden: strings.EqualFold(r.Config("gsg.hide"), "true"), } } // ----------------------------------------------------------------- context // Ctx is what a module is given: which repo, at which ref, at which path. // The cache makes `mod` idempotent within a single page render, so two // templates asking for the same module do not pay for it twice. type Ctx struct { Site *Site Repo *gitx.Repo Info RepoInfo Ref string // the ref as the user spelled it ("main", "v0.1.0", a sha) Path string Vars map[string]string // Commit is Ref resolved to a full SHA. Everything a module computes // at a given Commit is immutable, which is what makes the shared // cache and ETags correct rather than merely convenient. Commit string // Store is the process-wide cache, nil in tests. Only consulted when // Commit is set. Store Store mu sync.Mutex cache map[string]result } // Store is the cross-request cache. Implemented by internal/core.LRU. type Store interface { Get(key string) (any, bool) Put(key string, v any) } type result struct { val any err error } func NewCtx(site *Site, r *gitx.Repo, info RepoInfo, ref string) *Ctx { return &Ctx{Site: site, Repo: r, Info: info, Ref: ref, Vars: map[string]string{}, cache: map[string]result{}} } // CacheKey identifies an immutable module result. Empty when the result // cannot be cached (no resolved commit). // // Vars are part of the key. They were not, originally, and the bug was // exactly the kind this cache is supposed to be immune to: /compare/a..b // and /compare/a...b resolve to the same head commit, so the second // request was served the first one's diff. Anything that changes what a // module computes has to be in the key, not just the commit it hangs off. func (c *Ctx) CacheKey(name string, args []string) string { if c.Commit == "" || c.Store == nil { return "" } var b strings.Builder b.WriteString(c.Info.Slug + "|" + c.Commit + "|" + c.Path + "|" + name + "|") b.WriteString(strings.Join(args, "\x00")) keys := make([]string, 0, len(c.Vars)) for k := range c.Vars { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { b.WriteString("|" + k + "=" + c.Vars[k]) } return b.String() } // Derive returns a shallow copy for a different path, with a fresh cache. func (c *Ctx) Derive(path string, vars map[string]string) *Ctx { n := NewCtx(c.Site, c.Repo, c.Info, c.Ref) n.Path = path for k, v := range c.Vars { n.Vars[k] = v } for k, v := range vars { n.Vars[k] = v } return n } func (c *Ctx) Var(k string) string { return c.Vars[k] } // ---------------------------------------------------------------- registry // Module computes one piece of a view. Anything a template or an endpoint // might want is a module: readme, tree, log, refs, graph, blame, stats. type Module func(c *Ctx, args ...string) (any, error) var ( regMu sync.RWMutex registry = map[string]Module{} ) // Register adds a module. Call it from an init() in the modules package. func Register(name string, m Module) { regMu.Lock() defer regMu.Unlock() if _, dup := registry[name]; dup { panic("core: duplicate module " + name) } registry[name] = m } func Names() []string { regMu.RLock() defer regMu.RUnlock() out := make([]string, 0, len(registry)) for n := range registry { out = append(out, n) } return out } // Mod runs a module, memoized per Ctx. This is the single entry point // used by both templates (via the funcmap) and JSON endpoints. func (c *Ctx) Mod(name string, args ...string) (any, error) { key := name + "\x00" + strings.Join(args, "\x00") c.mu.Lock() if c.cache == nil { c.cache = map[string]result{} } if r, ok := c.cache[key]; ok { c.mu.Unlock() return r.val, r.err } c.mu.Unlock() regMu.RLock() m, ok := registry[name] regMu.RUnlock() if !ok { return nil, fmt.Errorf("no such module %q (have: %s)", name, strings.Join(Names(), ", ")) } // Cross-request cache: a module's output at a fixed commit SHA cannot // change, so this is safe without any invalidation logic. Nothing here // is keyed on a ref name -- refs move, SHAs do not. shared := c.CacheKey(name, args) if shared != "" { if v, ok := c.Store.Get(shared); ok { c.mu.Lock() c.cache[key] = result{v, nil} c.mu.Unlock() return v, nil } } val, err := m(c, args...) if err == nil && shared != "" { c.Store.Put(shared, val) } c.mu.Lock() c.cache[key] = result{val, err} c.mu.Unlock() return val, err }