Migration
This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs.
Contact me
internal/modules/repos.go
79436f09f5539aac9b6f836b5b9049bbbece48aa
· 5.2 KB · 176 lines
raw
| 1 | package modules |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "sort" |
| 6 | "sync" |
| 7 | "time" |
| 8 | |
| 9 | "gsg/internal/access" |
| 10 | "gsg/internal/core" |
| 11 | ) |
| 12 | |
| 13 | type RepoSummary struct { |
| 14 | core.RepoInfo |
| 15 | LastCommit time.Time |
| 16 | } |
| 17 | |
| 18 | func init() { core.Register("repos", reposMod) } |
| 19 | |
| 20 | // reposCacheTTL bounds how stale the index page's "last updated" ordering |
| 21 | // and repo list can be. reposMod is site-scoped, so it never gets a |
| 22 | // resolved commit and therefore never touches the module cache (see |
| 23 | // Ctx.CacheKey) -- without this, every hit to "/" or the JSON index forks |
| 24 | // git twice per repo, unconditionally, no matter how often the page is |
| 25 | // requested. A few seconds of staleness here is the same trade-off the |
| 26 | // fsnotify debounce already makes for repo discovery, just applied to one |
| 27 | // more surface. |
| 28 | const reposCacheTTL = 5 * time.Second |
| 29 | |
| 30 | var reposCache struct { |
| 31 | mu sync.Mutex |
| 32 | at time.Time |
| 33 | val []RepoSummary |
| 34 | } |
| 35 | |
| 36 | // publicRepos returns every publicly-visible repository, newest activity |
| 37 | // first, refreshed at most once per reposCacheTTL. This is the expensive, |
| 38 | // I/O-bound part (one git log per repo) that the cache above exists for, |
| 39 | // and it is deliberately untouched by personalization (see reposMod): |
| 40 | // anonymous traffic -- the overwhelming majority of requests -- keeps |
| 41 | // paying exactly what it paid before logins or share links existed, |
| 42 | // regardless of how many hidden repos a site has. |
| 43 | func publicRepos(c *core.Ctx) []RepoSummary { |
| 44 | reposCache.mu.Lock() |
| 45 | if !reposCache.at.IsZero() && time.Since(reposCache.at) < reposCacheTTL { |
| 46 | out := reposCache.val |
| 47 | reposCache.mu.Unlock() |
| 48 | return out |
| 49 | } |
| 50 | reposCache.mu.Unlock() |
| 51 | |
| 52 | list := c.Site.RepoList() |
| 53 | out := make([]RepoSummary, 0, len(list)) |
| 54 | for _, h := range list { |
| 55 | if h.Info.Hidden { |
| 56 | continue |
| 57 | } |
| 58 | out = append(out, summarize(h)) |
| 59 | } |
| 60 | sort.Slice(out, func(i, j int) bool { return out[i].LastCommit.After(out[j].LastCommit) }) |
| 61 | |
| 62 | reposCache.mu.Lock() |
| 63 | reposCache.val, reposCache.at = out, time.Now() |
| 64 | reposCache.mu.Unlock() |
| 65 | return out |
| 66 | } |
| 67 | |
| 68 | func summarize(h core.Handle) RepoSummary { |
| 69 | s := RepoSummary{RepoInfo: h.Info} |
| 70 | if log, err := h.Repo.Log(h.Repo.HeadRef(), 1, 0, false); err == nil && len(log) > 0 { |
| 71 | s.LastCommit = log[0].When |
| 72 | } |
| 73 | return s |
| 74 | } |
| 75 | |
| 76 | // repos: every repository visible to the requester, newest activity first |
| 77 | // -- every public repo, plus any hidden repo the current identity is |
| 78 | // allowed to see, whether as an owner (gsg.visible-to) or via an unrevoked |
| 79 | // share grant. Site-scoped -- does not touch c.Repo, so it is safe on the |
| 80 | // site Ctx. |
| 81 | func reposMod(c *core.Ctx, args ...string) (any, error) { |
| 82 | base := publicRepos(c) |
| 83 | if c.User == "" && len(c.SharedSlugs) == 0 { |
| 84 | return base, nil |
| 85 | } |
| 86 | |
| 87 | // Uncached and on-demand, by design: only the handful of hidden repos |
| 88 | // this specific identity unlocks, never a walk weighted by total |
| 89 | // hidden-repo count. Cost scales with how many repos one person can |
| 90 | // see, not with site size -- an owner (or share-link visitor) reloading |
| 91 | // "/" pays one git log per repo they can unlock, on every request (not |
| 92 | // cross-request-memoized), which is the trade-off for keeping |
| 93 | // publicRepos's shared cache above entirely free of personalization. |
| 94 | identity := access.Identity{ |
| 95 | User: c.User, |
| 96 | SharedSlugs: c.SharedSlugs, |
| 97 | SharedIssuedAt: c.SharedIssuedAt, |
| 98 | } |
| 99 | var unlocked []RepoSummary |
| 100 | for _, h := range c.Site.RepoList() { |
| 101 | if !h.Info.Hidden { |
| 102 | continue |
| 103 | } |
| 104 | if h.Info.AccessPolicy().CanView(identity) { |
| 105 | unlocked = append(unlocked, summarize(h)) |
| 106 | } |
| 107 | } |
| 108 | if len(unlocked) == 0 { |
| 109 | return base, nil |
| 110 | } |
| 111 | |
| 112 | out := make([]RepoSummary, 0, len(base)+len(unlocked)) |
| 113 | out = append(out, base...) |
| 114 | out = append(out, unlocked...) |
| 115 | sort.Slice(out, func(i, j int) bool { return out[i].LastCommit.After(out[j].LastCommit) }) |
| 116 | return out, nil |
| 117 | } |
| 118 | |
| 119 | // Section is a named group of repositories, from `gsg.section` in each |
| 120 | // repo's config (or `gitweb.category`). |
| 121 | type Section struct { |
| 122 | Name string // "" means the repo set has no sections at all |
| 123 | Repos []RepoSummary |
| 124 | } |
| 125 | |
| 126 | func init() { core.Register("sections", sectionsMod) } |
| 127 | |
| 128 | // sections: repos grouped by gsg.section, sections alphabetical, unsectioned |
| 129 | // last. Built on top of `repos` rather than re-walking the repo set, so the |
| 130 | // index page pays for the log lookups exactly once. |
| 131 | // |
| 132 | // Returns a single unnamed Section when nothing sets gsg.section, which lets |
| 133 | // the template render a flat list without branching on the config. |
| 134 | func sectionsMod(c *core.Ctx, args ...string) (any, error) { |
| 135 | v, err := c.Mod("repos") |
| 136 | if err != nil { |
| 137 | return nil, err |
| 138 | } |
| 139 | list, ok := v.([]RepoSummary) |
| 140 | if !ok { |
| 141 | return nil, fmt.Errorf("sections: repos returned %T", v) |
| 142 | } |
| 143 | |
| 144 | any := false |
| 145 | groups := map[string][]RepoSummary{} |
| 146 | for _, r := range list { |
| 147 | if r.Section != "" { |
| 148 | any = true |
| 149 | } |
| 150 | groups[r.Section] = append(groups[r.Section], r) |
| 151 | } |
| 152 | if !any { |
| 153 | return []Section{{Name: "", Repos: list}}, nil |
| 154 | } |
| 155 | |
| 156 | names := make([]string, 0, len(groups)) |
| 157 | for n := range groups { |
| 158 | if n != "" { |
| 159 | names = append(names, n) |
| 160 | } |
| 161 | } |
| 162 | sort.Strings(names) |
| 163 | |
| 164 | out := make([]Section, 0, len(groups)) |
| 165 | for _, n := range names { |
| 166 | out = append(out, Section{Name: n, Repos: groups[n]}) |
| 167 | } |
| 168 | if rest, ok := groups[""]; ok { |
| 169 | out = append(out, Section{Name: unsectioned, Repos: rest}) |
| 170 | } |
| 171 | return out, nil |
| 172 | } |
| 173 | |
| 174 | // unsectioned labels the leftover group. Named rather than inlined so it is |
| 175 | // obvious this string is user-facing. |
| 176 | const unsectioned = "Other" |