Skip to content
quarry Artifact [da3eacc581]
Login

Artifact [da3eacc581]

Artifact da3eacc581242ae521d6505d451cb294b2aa795645ef7dbc56736d6af7fcb21b:


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
	}
	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 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()
}