package index
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
)
// RFC 0013. Who touched what, when — drawn from the folds the index
// already keeps, under one rule that the renderers do not get a vote on:
//
// A check-in with a non-primary parent (checkin_parent.ord > 0) is a
// merge, and its filechange rows are the ABSORBED BRANCH'S work, not
// the merger's. On fossil-family stores merges are ~10% of check-ins
// and carry ~60% of all file touches; counted naively they inflate some
// people's footprint by 2-20x. Here they are marked inherited: they
// ride along in Touches so a client can ghost them, and they count in
// nothing — not the row's file count, not the author's, not a day's
// lead directory. The merge itself is still a check-in, because it
// happened.
//
// The rule lives here rather than in a renderer so the SVG, the canvas
// and a third-party client cannot disagree about the numbers.
// activityWindowDays is the recent window the server picks when the
// caller names no Since: long enough to show a rhythm, short enough
// that the tree axis stays drawable.
const activityWindowDays = 900
// activityWindowFloor is the volume that window is widened to reach. A
// quiet repository's 900 days can be a handful of dots on an empty
// field; extending backwards costs a cheap indexed scan and turns it
// into a picture.
const activityWindowFloor = 2000
// ActivityQuery narrows the window and the population. Empty Branch or
// User means all of them; empty Since asks the server to pick (see
// Activity's Window). A non-empty Since is clamped to the repository's
// first check-in (RFC 0013 §4) — asking for a window that starts before
// the history began still gets a window, just not an empty stretch of
// one.
type ActivityQuery struct {
Branch string
User string
Since string
Until string
}
// ActivityAuthor is one person with their footprint and their colour.
// Checkins counts every check-in they made, merges included. Files
// counts only paths they authored — see the merge rule above.
type ActivityAuthor struct {
Name string
Checkins int
Files int
Hue int
Shade int
}
// ActivityRow is one check-in. Day is days since the window's Since;
// Hour is the check-in's own UTC hour, never a guessed zone. Author
// indexes Activity.Authors. Files is the AUTHORED file count, so a pure
// merge reports zero.
type ActivityRow struct {
Day int
Hour int
Author int
Merge int
Files int
Hash string
Branch string
}
// ActivityTouch is one (check-in, leaf path) pair. Path indexes
// Activity.Paths; Inherited is 1 when the touch arrived through a merge.
type ActivityTouch struct {
Day int
Path int
Author int
Inherited int
}
// Activity is the windowed sample two views are drawn from. Window is
// "given" when the caller named Since and "picked" when the server
// chose it.
type Activity struct {
Since string
Until string
Authors []ActivityAuthor
Paths []string
Rows []ActivityRow
Touches []ActivityTouch
Window string
}
// ActivityDay is one day of the slice. N is check-ins, Merges how many
// of them were merges, Authors how many distinct people. Top is up to
// three [author, n] pairs ranked by check-ins. Path indexes
// ActivityDays.Paths and is -1 on a day whose only touches were
// inherited.
type ActivityDay struct {
N int
Merges int
Top [][2]int
Authors int
Path int
}
// ActivityDays is the whole history folded to days — the slice's
// source. Paths is the top-level directories ActivityDay.Path indexes
// ("" for files at the repository root).
type ActivityDays struct {
First string
Last string
Authors []ActivityAuthor
Days map[string]ActivityDay
Paths []string
Releases map[string]string
}
// hue maps a login to a colour. Deliberately a pure function of the
// name and nothing else: hue is who, never what kind or what state, so
// every renderer that knows the login draws the same person the same
// way without asking anybody.
func hue(name string) int {
var h uint32
for i := 0; i < len(name); i++ {
h = h*31 + uint32(name[i])
}
return int(h % 360)
}
// hueGap is how close two hues may be before a reader stops telling
// them apart on a scatter of small dots.
const hueGap = 14
// shadeRanks is how far down the ranking the collision check runs.
// Below it the dots are too few to confuse anybody, and stepping every
// tail author's lightness would spend the whole range on people nobody
// is looking for.
const shadeRanks = 24
// assignShades steps the lightness of authors whose hue collides with a
// higher-ranked one, so neighbours in the legend stay distinguishable.
// as must already be in rank order.
func assignShades(as []ActivityAuthor) {
n := min(len(as), shadeRanks)
for i := range n {
collisions := 0
for j := range i {
d := as[i].Hue - as[j].Hue
if d < 0 {
d = -d
}
if d > 180 {
d = 360 - d
}
if d <= hueGap {
collisions++
}
}
as[i].Shade = min(collisions, 3)
}
}
// day is the ISO date an index timestamp falls on. Dates are stored as
// the artifact's own string ("2026-06-01T09:00:00.000"), so the day is
// its first ten bytes and no parse is needed.
func day(date string) string {
if len(date) < 10 {
return date
}
return date[:10]
}
// hourOf is the UTC hour of an index timestamp, positions 12-13 of
// either the T-separated or space-separated form.
func hourOf(date string) int {
if len(date) < 13 {
return 0
}
h, err := strconv.Atoi(date[11:13])
if err != nil || h < 0 || h > 23 {
return 0
}
return h
}
// dayBounds turns two ISO days into the half-open string range a date
// comparison can use against the stored timestamps. A bare day sorts
// before every timestamp on it, which is what makes ">= since" and
// "< the day after until" exact without a substr() that no index can
// serve.
func dayBounds(since, until string) (string, string) {
hi := until
if t, err := time.Parse("2006-01-02", until); err == nil {
hi = t.AddDate(0, 0, 1).Format("2006-01-02")
}
return since, hi
}
// daysBetween counts calendar days from since to date. Both are ISO
// days; a malformed one lands on day zero rather than failing the
// whole query.
func daysBetween(since, date string) int {
a, err := time.Parse("2006-01-02", since)
if err != nil {
return 0
}
b, err := time.Parse("2006-01-02", date)
if err != nil {
return 0
}
return int(b.Sub(a) / (24 * time.Hour))
}
// topLevel is the directory a day is attributed to: the first path
// segment, or "" for a file at the root.
func topLevel(path string) string {
if i := strings.IndexByte(path, '/'); i >= 0 {
return path[:i]
}
return ""
}
// dirOf is a path's directory, used for the tree axis order.
func dirOf(path string) string {
if i := strings.LastIndexByte(path, '/'); i >= 0 {
return path[:i]
}
return ""
}
// mergeSet is every check-in that has a non-primary parent. Loaded once
// and tested in Go rather than asked as a correlated EXISTS: the touch
// scan visits a filechange row per (check-in, path), and on a 40k-
// check-in store re-asking the parent table 200,000 times is most of
// the query's cost. Merges are ~10% of check-ins, so the set is small.
func (ix *Index) mergeSet() (map[string]bool, error) {
rows, err := ix.db.Query(`SELECT DISTINCT child FROM checkin_parent WHERE ord > 0`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]bool{}
for rows.Next() {
var child string
if err := rows.Scan(&child); err != nil {
return nil, err
}
out[child] = true
}
return out, rows.Err()
}
// rankAuthors orders people by check-ins (name breaks ties, so two
// equal counts do not swap places between two requests), colours them
// and returns the name -> index map the rows carry instead of strings.
func rankAuthors(checkins, files map[string]int) ([]ActivityAuthor, map[string]int) {
out := make([]ActivityAuthor, 0, len(checkins))
for name, n := range checkins {
out = append(out, ActivityAuthor{Name: name, Checkins: n, Files: files[name], Hue: hue(name)})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Checkins != out[j].Checkins {
return out[i].Checkins > out[j].Checkins
}
return out[i].Name < out[j].Name
})
assignShades(out)
idx := make(map[string]int, len(out))
for i, a := range out {
idx[a.Name] = i
}
return out, idx
}
// filters builds the shared branch/user predicate. c is the checkin
// table's alias.
func (q ActivityQuery) filters(c string) (string, []any) {
var sb strings.Builder
var args []any
if q.Branch != "" {
sb.WriteString(" AND " + c + ".branch = ?")
args = append(args, q.Branch)
}
if q.User != "" {
sb.WriteString(" AND " + c + ".user = ?")
args = append(args, q.User)
}
return sb.String(), args
}
// pickWindow decides the dates when the caller named none. Two rules,
// in this order: the last activityWindowDays days of the repository's
// OWN history (not of the wall clock — a store that went quiet a year
// ago still has a story), widened backwards if that holds fewer than
// activityWindowFloor check-ins, and clamped to the first check-in so
// day zero is a day something happened. The clamp applies whether the
// window was picked or the caller named a Since: an explicit Since
// earlier than the repository's first check-in still lands on it,
// rather than opening a window with nothing in the first stretch of it.
//
// A non-empty Since or Until that fails to parse as "2006-01-02" is an
// error, not a silent fallback — callers that already validated the
// query (the op layer) will never trip this, but pickWindow does not
// rely on that being true.
func (ix *Index) pickWindow(q ActivityQuery) (since, until string, err error) {
if q.Since != "" {
if _, perr := time.Parse("2006-01-02", q.Since); perr != nil {
return "", "", fmt.Errorf("activity: malformed since %q: %w", q.Since, perr)
}
}
if q.Until != "" {
if _, perr := time.Parse("2006-01-02", q.Until); perr != nil {
return "", "", fmt.Errorf("activity: malformed until %q: %w", q.Until, perr)
}
}
cond, args := q.filters("c")
var first, last string
err = ix.db.QueryRow(`SELECT COALESCE(min(c.date), ''), COALESCE(max(c.date), '')
FROM checkin c WHERE 1=1`+cond, args...).Scan(&first, &last)
if err != nil || last == "" {
return "", "", err
}
first, last = day(first), day(last)
if q.Until != "" {
last = q.Until
}
if q.Since != "" {
since = q.Since
if since < first {
since = first
}
return since, last, nil
}
since = first
if t, perr := time.Parse("2006-01-02", last); perr == nil {
since = t.AddDate(0, 0, -(activityWindowDays - 1)).Format("2006-01-02")
}
// The date of the activityWindowFloor'th newest check-in: the point
// the window has to reach back to for the chart to have a shape.
// Absent (fewer check-ins than the floor) means "show everything".
_, hi := dayBounds(first, last)
pivotArgs := append([]any{hi}, args...)
var pivot string
perr := ix.db.QueryRow(`SELECT c.date FROM checkin c WHERE c.date < ?`+cond+
` ORDER BY c.date DESC LIMIT 1 OFFSET ?`,
append(pivotArgs, activityWindowFloor-1)...).Scan(&pivot)
if perr != nil || pivot == "" {
since = first
} else if p := day(pivot); p < since {
since = p
}
if since < first {
since = first
}
return since, last, nil
}
// Activity is the windowed sample behind the core sample chart: one row
// per check-in, one touch per (check-in, leaf path).
func (ix *Index) Activity(q ActivityQuery) (Activity, error) {
out := Activity{Window: "picked"}
if q.Since != "" {
out.Window = "given"
}
since, until, err := ix.pickWindow(q)
if err != nil {
return Activity{}, err
}
if since == "" || until == "" {
// An empty repository still answers with a shape, so a caller
// renders an empty chart rather than an error page.
out.Since, out.Until = q.Since, q.Until
return out, nil
}
out.Since, out.Until = since, until
cond, fargs := q.filters("c")
lo, hi := dayBounds(since, until)
args := append([]any{lo, hi}, fargs...)
isMerge, err := ix.mergeSet()
if err != nil {
return Activity{}, err
}
rows, err := ix.db.Query(`SELECT c.hash, c.date, c.user, c.branch
FROM checkin c WHERE c.date >= ? AND c.date < ?`+cond+
` ORDER BY c.date, c.hash`, args...)
if err != nil {
return Activity{}, err
}
defer rows.Close()
type row struct {
user string
merge bool
}
meta := map[string]row{}
checkins := map[string]int{}
for rows.Next() {
var hash, date, user, branch string
if err := rows.Scan(&hash, &date, &user, &branch); err != nil {
return Activity{}, err
}
merge := isMerge[hash]
m := 0
if merge {
m = 1
}
out.Rows = append(out.Rows, ActivityRow{
Day: daysBetween(since, day(date)), Hour: hourOf(date),
Merge: m, Hash: hash, Branch: branch,
})
meta[hash] = row{user: user, merge: merge}
checkins[user]++
}
if err := rows.Err(); err != nil {
return Activity{}, err
}
// Leaves only: the directory rows exist so a directory's last
// change is one lookup, and a directory is not a file.
trows, err := ix.db.Query(`SELECT f.checkin, f.path
FROM filechange f JOIN checkin c ON c.hash = f.checkin
WHERE f.dir = 0 AND c.date >= ? AND c.date < ?`+cond, args...)
if err != nil {
return Activity{}, err
}
defer trows.Close()
touched := map[string][]string{}
files := map[string]int{}
paths := map[string]bool{}
for trows.Next() {
var hash, path string
if err := trows.Scan(&hash, &path); err != nil {
return Activity{}, err
}
m, ok := meta[hash]
if !ok {
continue
}
touched[hash] = append(touched[hash], path)
paths[path] = true
if !m.merge {
files[m.user]++
}
}
if err := trows.Err(); err != nil {
return Activity{}, err
}
var authorIdx map[string]int
out.Authors, authorIdx = rankAuthors(checkins, files)
// Tree-axis order: by directory, then by the path folded to lower
// case, so a directory's files are contiguous and case does not
// scatter them. Decided here, not in a renderer, so both views and
// every client agree about what is next to what.
out.Paths = make([]string, 0, len(paths))
for p := range paths {
out.Paths = append(out.Paths, p)
}
sort.Slice(out.Paths, func(i, j int) bool {
di, dj := dirOf(out.Paths[i]), dirOf(out.Paths[j])
if di != dj {
return di < dj
}
li, lj := strings.ToLower(out.Paths[i]), strings.ToLower(out.Paths[j])
if li != lj {
return li < lj
}
return out.Paths[i] < out.Paths[j]
})
pathIdx := make(map[string]int, len(out.Paths))
for i, p := range out.Paths {
pathIdx[p] = i
}
for i := range out.Rows {
r := &out.Rows[i]
m := meta[r.Hash]
r.Author = authorIdx[m.user]
ps := touched[r.Hash]
sort.Strings(ps)
inherited := 0
if m.merge {
inherited = 1
} else {
r.Files = len(ps)
}
for _, p := range ps {
out.Touches = append(out.Touches, ActivityTouch{
Day: r.Day, Path: pathIdx[p], Author: r.Author, Inherited: inherited,
})
}
}
return out, nil
}
// ActivityDays is the whole history folded to one entry per day — the
// slice's source, and the one thing here worth memoizing: it is a scan
// of every check-in and every filechange row, it is asked for on the
// repository home page, and it only moves when a fold does.
func (ix *Index) ActivityDays(branch string) (ActivityDays, error) {
ix.countMu.Lock()
if got, ok := ix.activityDays[branch]; ok {
out := copyActivityDays(*got)
ix.countMu.Unlock()
return out, nil
}
gen := ix.foldGen
ix.countMu.Unlock()
// The scan runs with the lock released: it is the expensive part —
// every check-in, every filechange row — and holding countMu across
// it would block Counts, Summary and EventKinds behind one branch's
// fold. A second caller racing on the same branch just recomputes
// too; the memo below takes whichever finishes last, which is fine
// because both computed the same answer from the same store.
got, err := ix.computeActivityDays(branch)
if err != nil {
return ActivityDays{}, err
}
ix.countMu.Lock()
if gen == ix.foldGen {
// No fold landed during the scan; the answer is current.
if ix.activityDays == nil {
ix.activityDays = map[string]*ActivityDays{}
}
ix.activityDays[branch] = &got
}
ix.countMu.Unlock()
// A copy, not the memoized value itself: the memo must survive
// whatever the caller does to the map/slice headers it got back.
return copyActivityDays(got), nil
}
// copyActivityDays deep-copies the maps and slices an ActivityDays
// carries, so a value handed to a caller shares no mutable state with
// the memo ActivityDays keeps for the next request.
func copyActivityDays(a ActivityDays) ActivityDays {
out := a
if a.Authors != nil {
out.Authors = append([]ActivityAuthor(nil), a.Authors...)
}
if a.Paths != nil {
out.Paths = append([]string(nil), a.Paths...)
}
if a.Releases != nil {
out.Releases = make(map[string]string, len(a.Releases))
for k, v := range a.Releases {
out.Releases[k] = v
}
}
if a.Days != nil {
out.Days = make(map[string]ActivityDay, len(a.Days))
for k, v := range a.Days {
d := v
if v.Top != nil {
d.Top = append([][2]int(nil), v.Top...)
}
out.Days[k] = d
}
}
return out
}
func (ix *Index) computeActivityDays(branch string) (ActivityDays, error) {
out := ActivityDays{
Days: map[string]ActivityDay{},
Releases: map[string]string{},
}
q := ActivityQuery{Branch: branch}
cond, args := q.filters("c")
isMerge, err := ix.mergeSet()
if err != nil {
return ActivityDays{}, err
}
rows, err := ix.db.Query(`SELECT c.hash, c.date, c.user
FROM checkin c WHERE 1=1`+cond, args...)
if err != nil {
return ActivityDays{}, err
}
defer rows.Close()
// Per day: how many check-ins each person made, and how many of the
// day's check-ins were merges.
perDay := map[string]map[string]int{}
merges := map[string]int{}
checkins := map[string]int{}
for rows.Next() {
var hash, date, user string
if err := rows.Scan(&hash, &date, &user); err != nil {
return ActivityDays{}, err
}
d := day(date)
if out.First == "" || d < out.First {
out.First = d
}
if d > out.Last {
out.Last = d
}
if perDay[d] == nil {
perDay[d] = map[string]int{}
}
perDay[d][user]++
checkins[user]++
if isMerge[hash] {
merges[d]++
}
}
if err := rows.Err(); err != nil {
return ActivityDays{}, err
}
// Authored touches only. A merge absorbs whole directories at once,
// so counting them would make the day's directory "wherever the
// branch that landed had been working", every time.
trows, err := ix.db.Query(`SELECT f.checkin, c.date, c.user, f.path
FROM filechange f JOIN checkin c ON c.hash = f.checkin
WHERE f.dir = 0`+cond, args...)
if err != nil {
return ActivityDays{}, err
}
defer trows.Close()
dirDay := map[string]map[string]int{}
files := map[string]int{}
for trows.Next() {
var hash, date, user, path string
if err := trows.Scan(&hash, &date, &user, &path); err != nil {
return ActivityDays{}, err
}
if isMerge[hash] {
continue
}
d := day(date)
if dirDay[d] == nil {
dirDay[d] = map[string]int{}
}
dirDay[d][topLevel(path)]++
files[user]++
}
if err := trows.Err(); err != nil {
return ActivityDays{}, err
}
var authorIdx map[string]int
out.Authors, authorIdx = rankAuthors(checkins, files)
// Paths is every top-level directory the history touched, named
// once and referenced by index; a slice with six thousand cells
// should not carry six thousand copies of "src".
dirs := map[string]bool{}
for _, m := range dirDay {
for d := range m {
dirs[d] = true
}
}
out.Paths = make([]string, 0, len(dirs))
for d := range dirs {
out.Paths = append(out.Paths, d)
}
sort.Strings(out.Paths)
dirIdx := make(map[string]int, len(out.Paths))
for i, d := range out.Paths {
dirIdx[d] = i
}
for d, byUser := range perDay {
entry := ActivityDay{Merges: merges[d], Authors: len(byUser), Path: -1}
type pair struct {
name string
n int
}
ranked := make([]pair, 0, len(byUser))
for name, n := range byUser {
entry.N += n
ranked = append(ranked, pair{name, n})
}
sort.Slice(ranked, func(i, j int) bool {
if ranked[i].n != ranked[j].n {
return ranked[i].n > ranked[j].n
}
return ranked[i].name < ranked[j].name
})
for _, p := range ranked[:min(len(ranked), 3)] {
entry.Top = append(entry.Top, [2]int{authorIdx[p.name], p.n})
}
best, bestN := "", 0
for dir, n := range dirDay[d] {
if n > bestN || (n == bestN && best != "" && dir < best) {
best, bestN = dir, n
}
}
if bestN > 0 {
entry.Path = dirIdx[best]
}
out.Days[d] = entry
}
// Releases: the symbolic tags a reader recognises as a version,
// applied (sign '+') rather than cancelled.
rrows, err := ix.db.Query(`SELECT c.date, t.name
FROM checkin_tag t JOIN checkin c ON c.hash = t.checkin
WHERE t.sign = '+' AND (t.name = 'sym-release' OR t.name LIKE 'sym-version-%')`+
cond+` ORDER BY c.date`, args...)
if err != nil {
return ActivityDays{}, err
}
defer rrows.Close()
for rrows.Next() {
var date, name string
if err := rrows.Scan(&date, &name); err != nil {
return ActivityDays{}, err
}
d := day(date)
if _, seen := out.Releases[d]; !seen {
out.Releases[d] = strings.TrimPrefix(name, "sym-")
}
}
return out, rrows.Err()
}