package modules import ( "fmt" "sort" "sync" "time" "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 } // repos: every visible repository, newest activity first. // Site-scoped -- does not touch c.Repo, so it is safe on the site Ctx. func reposMod(c *core.Ctx, args ...string) (any, error) { reposCache.mu.Lock() if !reposCache.at.IsZero() && time.Since(reposCache.at) < reposCacheTTL { out := reposCache.val reposCache.mu.Unlock() return out, nil } reposCache.mu.Unlock() list := c.Site.RepoList() out := make([]RepoSummary, 0, len(list)) for _, h := range list { if h.Info.Hidden { continue } 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 } out = append(out, s) } 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, 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"