package modules import ( "fmt" "sort" "sync" "time" "gsg/internal/access" "gsg/internal/core" ) type RepoSummary struct { core.RepoInfo LastCommit time.Time } func init() { core.Register("repos", reposMod) } // reposCacheTTL bounds how stale the index page's "last updated" ordering // and repo list can be. reposMod is site-scoped, so it never gets a // resolved commit and therefore never touches the module cache (see // Ctx.CacheKey) -- without this, every hit to "/" or the JSON index forks // git twice per repo, unconditionally, no matter how often the page is // requested. A few seconds of staleness here is the same trade-off the // fsnotify debounce already makes for repo discovery, just applied to one // more surface. const reposCacheTTL = 5 * time.Second var reposCache struct { mu sync.Mutex at time.Time val []RepoSummary } // publicRepos returns every publicly-visible repository, newest activity // first, refreshed at most once per reposCacheTTL. This is the expensive, // I/O-bound part (one git log per repo) that the cache above exists for, // and it is deliberately untouched by personalization (see reposMod): // anonymous traffic -- the overwhelming majority of requests -- keeps // paying exactly what it paid before logins or share links existed, // regardless of how many hidden repos a site has. func publicRepos(c *core.Ctx) []RepoSummary { reposCache.mu.Lock() if !reposCache.at.IsZero() && time.Since(reposCache.at) < reposCacheTTL { out := reposCache.val reposCache.mu.Unlock() return out } reposCache.mu.Unlock() list := c.Site.RepoList() out := make([]RepoSummary, 0, len(list)) for _, h := range list { if h.Info.Hidden { continue } out = append(out, summarize(h)) } sort.Slice(out, func(i, j int) bool { return out[i].LastCommit.After(out[j].LastCommit) }) reposCache.mu.Lock() reposCache.val, reposCache.at = out, time.Now() reposCache.mu.Unlock() return out } func summarize(h core.Handle) RepoSummary { s := RepoSummary{RepoInfo: h.Info} if log, err := h.Repo.Log(h.Repo.HeadRef(), 1, 0, false); err == nil && len(log) > 0 { s.LastCommit = log[0].When } return s } // repos: every repository visible to the requester, newest activity first // -- every public repo, plus any hidden repo the current identity is // allowed to see, whether as an owner (gsg.visible-to) or via an unrevoked // share grant. Site-scoped -- does not touch c.Repo, so it is safe on the // site Ctx. func reposMod(c *core.Ctx, args ...string) (any, error) { base := publicRepos(c) if c.User == "" && len(c.SharedSlugs) == 0 { return base, nil } // Uncached and on-demand, by design: only the handful of hidden repos // this specific identity unlocks, never a walk weighted by total // hidden-repo count. Cost scales with how many repos one person can // see, not with site size -- an owner (or share-link visitor) reloading // "/" pays one git log per repo they can unlock, on every request (not // cross-request-memoized), which is the trade-off for keeping // publicRepos's shared cache above entirely free of personalization. identity := access.Identity{ User: c.User, SharedSlugs: c.SharedSlugs, SharedIssuedAt: c.SharedIssuedAt, } var unlocked []RepoSummary for _, h := range c.Site.RepoList() { if !h.Info.Hidden { continue } if h.Info.AccessPolicy().CanView(identity) { unlocked = append(unlocked, summarize(h)) } } if len(unlocked) == 0 { return base, nil } out := make([]RepoSummary, 0, len(base)+len(unlocked)) out = append(out, base...) out = append(out, unlocked...) sort.Slice(out, func(i, j int) bool { return out[i].LastCommit.After(out[j].LastCommit) }) return out, nil } // Section is a named group of repositories, from `gsg.section` in each // repo's config (or `gitweb.category`). type Section struct { Name string // "" means the repo set has no sections at all Repos []RepoSummary } func init() { core.Register("sections", sectionsMod) } // sections: repos grouped by gsg.section, sections alphabetical, unsectioned // last. Built on top of `repos` rather than re-walking the repo set, so the // index page pays for the log lookups exactly once. // // Returns a single unnamed Section when nothing sets gsg.section, which lets // the template render a flat list without branching on the config. func sectionsMod(c *core.Ctx, args ...string) (any, error) { v, err := c.Mod("repos") if err != nil { return nil, err } list, ok := v.([]RepoSummary) if !ok { return nil, fmt.Errorf("sections: repos returned %T", v) } any := false groups := map[string][]RepoSummary{} for _, r := range list { if r.Section != "" { any = true } groups[r.Section] = append(groups[r.Section], r) } if !any { return []Section{{Name: "", Repos: list}}, nil } names := make([]string, 0, len(groups)) for n := range groups { if n != "" { names = append(names, n) } } sort.Strings(names) out := make([]Section, 0, len(groups)) for _, n := range names { out = append(out, Section{Name: n, Repos: groups[n]}) } if rest, ok := groups[""]; ok { out = append(out, Section{Name: unsectioned, Repos: rest}) } return out, nil } // unsectioned labels the leftover group. Named rather than inlined so it is // obvious this string is user-facing. const unsectioned = "Other"