v0.25.0 4492065

browse_stats.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6	"context"
7	"slices"
8	"sync"
9	"time"
10
11	"bnema.dev/ermite/internal/domain"
12	"bnema.dev/ermite/internal/ports"
13)
14
15const (
16	// maxStatsEntries bounds the cache of figures; when full it is emptied,
17	// and the entries of the current heads come back on the next read.
18	maxStatsEntries = 1024
19	// statsTTL is how long figures are served: the 30-day window moves
20	// with time even when the repository does not.
21	statsTTL = 10 * time.Minute
22)
23
24// statsKey names the figures of one state of a repository: its life (the
25// creation time), its last push or merge (which every ref change, tags
26// included, moves) and the head counted from.
27type statsKey struct {
28	repo    domain.RepoName
29	created int64
30	pushed  int64
31	head    domain.ObjectID
32}
33
34type statsEntry struct {
35	st  ports.RepoStats
36	at  time.Time
37	err error
38	// done is closed when st and err are set; until then the entry is the
39	// read in flight that the other callers wait for.
40	done chan struct{}
41}
42
43// statsCache keeps the figures of the home page, so that a request reads Git
44// once per repository state and per statsTTL, and concurrent requests share
45// one read.
46type statsCache struct {
47	mu sync.Mutex
48	m  map[statsKey]*statsEntry
49}
50
51// get returns the figures of key, computing them with read when they are
52// missing, failed or older than statsTTL.
53func (c *statsCache) get(ctx context.Context, key statsKey, now time.Time, read func() (ports.RepoStats, error)) (ports.RepoStats, error) {
54	c.mu.Lock()
55	e, ok := c.m[key]
56	if ok {
57		select {
58		case <-e.done:
59			if e.err != nil || now.Sub(e.at) >= statsTTL || now.Before(e.at) {
60				ok = false
61			}
62		default: // in flight
63		}
64	}
65	if !ok {
66		if c.m == nil || len(c.m) >= maxStatsEntries {
67			c.m = make(map[statsKey]*statsEntry)
68		}
69		e = &statsEntry{at: now, done: make(chan struct{})}
70		c.m[key] = e
71		c.mu.Unlock()
72		e.st, e.err = read()
73		close(e.done)
74		return cloneStats(e.st), e.err
75	}
76	c.mu.Unlock()
77	select {
78	case <-e.done:
79		return cloneStats(e.st), e.err
80	case <-ctx.Done():
81		return ports.RepoStats{}, ctx.Err()
82	}
83}
84
85// activity counts the commits of the last ports.RecentDays days, and those
86// of each of the last ports.ActivityDays days, oldest first. A day is the 24
87// hours that end at now minus a whole number of days; a commit dated after
88// now counts in the last day.
89func activity(times []time.Time, now time.Time) (recent int, days []int) {
90	days = make([]int, ports.ActivityDays)
91	recentSince := daysBefore(now, ports.RecentDays)
92	for _, t := range times {
93		if !t.Before(recentSince) {
94			recent++
95		}
96		ago := int(now.Sub(t) / (24 * time.Hour))
97		if ago < 0 {
98			ago = 0
99		}
100		if ago < ports.ActivityDays {
101			days[ports.ActivityDays-1-ago]++
102		}
103	}
104	return recent, days
105}
106
107// daysBefore is now minus n days of 24 hours: the days of activity are
108// elapsed time, not calendar days, so that a change of daylight saving time
109// neither drops nor doubles an hour.
110func daysBefore(now time.Time, n int) time.Time {
111	return now.Add(-time.Duration(n) * 24 * time.Hour)
112}
113
114func cloneStats(st ports.RepoStats) ports.RepoStats {
115	st.Activity = slices.Clone(st.Activity)
116	if st.LatestTag != nil {
117		t := *st.LatestTag
118		st.LatestTag = &t
119	}
120	return st
121}
122
123// RepoStats counts the commits reachable from head, all of them, those of
124// the last ports.RecentDays days and those of each recent day, and names the
125// latest tag. The answer is kept for statsTTL, or until the repository is
126// pushed to: the home page and its live updates read it on every row.
127func (b *Browse) RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (ports.RepoStats, error) {
128	r, err := b.authorize(ctx, p, repo)
129	if err != nil {
130		return ports.RepoStats{}, err
131	}
132	key := statsKey{repo: r.Name, created: repoEpoch(r), pushed: r.PushedAt.UnixNano(), head: head}
133	return b.stats.get(ctx, key, now, func() (ports.RepoStats, error) {
134		// The read outlives a caller that gives up: the others wait for it.
135		rctx := context.WithoutCancel(ctx)
136		var st ports.RepoStats
137		var err error
138		if st.Commits, err = b.reader.CountCommits(rctx, repo, head); err != nil {
139			return ports.RepoStats{}, err
140		}
141		// One walk gives both the recent count and the activity.
142		span := max(ports.RecentDays, ports.ActivityDays)
143		times, truncated, err := b.reader.CommitTimes(rctx, repo, head, daysBefore(now, span))
144		if err != nil {
145			return ports.RepoStats{}, err
146		}
147		st.Recent, st.Activity = activity(times, now)
148		st.Partial = truncated
149		tag, ok, err := b.reader.LatestTag(rctx, repo)
150		if err != nil {
151			return ports.RepoStats{}, err
152		}
153		if ok {
154			st.LatestTag = &tag
155		}
156		return st, nil
157	})
158}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close