current commit
261026.10 · Stardates that could mislead now say what they name
package main

import (
	"context"
	"maps"
	"os"
	"path/filepath"
	"slices"
	"strconv"
	"strings"
	"time"
)

// A stardate names a commit by its day and its place in that day, eg
// 261024.4, the fourth commit of 24 Oct 2026 on the repo's main line. A
// commit that a merge brought in is named after the merge, eg 261024.4.2
// for the second commit merge 261024.4 brought in. The main line is the
// first-parent line of origin's main or master, else of the local main or
// master, so every clone of one repo names its published commits alike,
// and checking out a branch moves nothing. The line only grows, so a
// stardate keeps naming one commit unless history is rewritten. A commit
// on the local branch of that name but not yet pushed has a day and no
// place yet, eg 261026.x; it gets its number when origin's line reaches
// it, so a rebase before the push renames nothing. Commits on no main
// line, eg on another branch, show their hash.
type stardates struct {
	tip     string            // the main line's head, which the numbers run to
	byHash  map[string]string // full hash to stardate
	byDate  map[string]string // stardate to full hash
	perDay  map[string]int    // main-line commits per day so far
	pending map[string]string // full hash to day.x, for unpushed commits
}

// mainRefs are the refs the main line follows, in order of preference.
// "" stands for the branch origin's HEAD names, for a repo whose main
// branch has another name, eg origin/trunk. It comes after main and
// master, so naming origin's HEAD renumbers no repo that has them.
var mainRefs = []string{"refs/remotes/origin/main", "refs/remotes/origin/master", "", "refs/heads/main", "refs/heads/master"}

// mainTip is the commit at the head of r's main line, and the ref it
// came from.
func (r repo) mainTip(ctx context.Context) (string, string, bool) {
	for _, ref := range mainRefs {
		if ref == "" {
			var ok bool
			if ref, ok = r.originHead(ctx); !ok {
				continue
			}
		}
		if h, ok := r.refTip(ctx, ref); ok {
			return h, ref, true
		}
	}
	return "", "", false
}

// originHead is the branch origin's HEAD points at, eg
// refs/remotes/origin/trunk, as git clone set it.
func (r repo) originHead(ctx context.Context) (string, bool) {
	var ref string
	if exists(filepath.Join(r.common, "reftable")) {
		out, err := git(ctx, r.gitDir, "symbolic-ref", "-q", "refs/remotes/origin/HEAD")
		if err != nil {
			return "", false
		}
		ref = strings.TrimSpace(string(out))
	} else {
		b, err := os.ReadFile(filepath.Join(r.common, "refs", "remotes", "origin", "HEAD"))
		if err != nil {
			return "", false
		}
		ref, _ = strings.CutPrefix(strings.TrimSpace(string(b)), "ref: ")
	}
	return ref, strings.HasPrefix(ref, "refs/remotes/origin/") && ref != "refs/remotes/origin/HEAD"
}

// refTip is the commit ref points at.
func (r repo) refTip(ctx context.Context, ref string) (string, bool) {
	if !exists(filepath.Join(r.common, "reftable")) {
		h, ok := r.readRef(ref)
		return h, ok && isHash(h)
	}
	out, err := git(ctx, r.gitDir, "rev-parse", "--verify", "-q", ref)
	return strings.TrimSpace(string(out)), err == nil
}

// starFormat is how git prints a commit for numbering: hash, parents and
// committer date.
const starFormat = "--format=%H %P %cI"

// stardatesIn numbers the commits of the repo dir lies in, or returns nil
// when it has no main line. The numbers are kept under the line's tip. A
// fetch or push that moves the tip numbers only the new commits, as long
// as the new line runs through the old tip; otherwise, eg after a force
// push, it numbers all again. Numbering node's 49,203 commits took 657 ms
// on 2026-10-24. When the line is origin's, the local branch of the same
// name adds its unpushed commits as pending, kept under both tips.
func stardatesIn(ctx context.Context, dir string) *stardates {
	r := repoAt(ctx, dir)
	if r.complaint != "" {
		return nil
	}
	tip, ref, ok := r.mainTip(ctx)
	if !ok {
		return nil
	}
	s := numbered(ctx, dir, r, tip)
	if s == nil || !strings.HasPrefix(ref, "refs/remotes/origin/") {
		return s
	}
	local, ok := r.refTip(ctx, "refs/heads/"+strings.TrimPrefix(ref, "refs/remotes/origin/"))
	if !ok || local == tip {
		return s
	}
	key := "stardates local\x00" + r.common + "\x00" + tip + "\x00" + local
	if v, ok := gitCache.get(key); ok {
		return v.(*stardates)
	}
	out, err := git(ctx, dir, "log", "--no-show-signature", "-z", starFormat, local, "^"+tip)
	if err != nil {
		return s
	}
	p := s.withPending(local, out)
	gitCache.put(key, p, 64+int64(len(p.pending))*2*(40+16+32))
	return p
}

// numbered is the stardates of r's main line up to tip, kept under tip.
func numbered(ctx context.Context, dir string, r repo, tip string) *stardates {
	key, last := "stardates\x00"+r.common+"\x00"+tip, "stardates last\x00"+r.common
	if v, ok := gitCache.get(key); ok {
		return v.(*stardates)
	}
	var s *stardates
	if v, ok := gitCache.get(last); ok {
		if out, err := git(ctx, dir, "log", "--no-show-signature", "-z", starFormat, tip, "^"+v.(*stardates).tip); err == nil {
			s = v.(*stardates).extend(tip, out)
		}
	}
	if s == nil {
		out, err := git(ctx, dir, "log", "--no-show-signature", "-z", starFormat, tip)
		if err != nil {
			return nil
		}
		s = (&stardates{byHash: map[string]string{}, byDate: map[string]string{}, perDay: map[string]int{}}).extend(tip, out)
	}
	size := int64(0)
	for h, d := range s.byHash {
		size += 2 * int64(len(h)+len(d)+32)
	}
	gitCache.put(key, s, size)
	gitCache.put(last, s, 64)
	return s
}

// starNode is a commit as numbering needs it: its parents and the day it
// was committed, in the zone the commit records, so every reader sees the
// same day.
type starNode struct {
	parents []string
	day     string
}

// extend numbers out, git's records of the commits up to tip that s does
// not hold, along tip's first-parent line, and returns the result as a
// new set; s stays as it was. It returns nil when tip's line does not run
// through s's tip, since the old numbers would then be wrong.
func (s *stardates) extend(tip string, out []byte) *stardates {
	nodes := starNodes(out)
	if s.tip != "" {
		h := tip
		for n, ok := nodes[h]; ok && len(n.parents) > 0; n, ok = nodes[h] {
			h = n.parents[0]
		}
		if h != s.tip {
			return nil
		}
	}
	n := &stardates{tip: tip, byHash: maps.Clone(s.byHash), byDate: maps.Clone(s.byDate), perDay: maps.Clone(s.perDay)}
	n.number(tip, nodes)
	return n
}

// withPending is s with the commits of out, git's records of a local
// line up to local beyond s's tip, named pending by their day and x. s
// stays as it was; the result shares its numbers.
func (s *stardates) withPending(local string, out []byte) *stardates {
	n := *s
	n.pending = map[string]string{}
	n.number(local, starNodes(out))
	return &n
}

// starNodes reads out, git's records in starFormat, by hash.
func starNodes(out []byte) map[string]starNode {
	nodes := map[string]starNode{}
	for _, rec := range strings.Split(string(out), "\x00") {
		f := strings.Fields(rec)
		if len(f) < 2 {
			continue
		}
		when, err := time.Parse(time.RFC3339, f[len(f)-1])
		if err != nil {
			continue
		}
		nodes[f[0]] = starNode{parents: f[1 : len(f)-1], day: when.Format("060102")}
	}
	return nodes
}

// number names the commits of nodes along tip's first-parent line, the
// main line, by day and place in the day. With s.pending set, it names
// them pending instead: by day and x, with no place and no address.
func (s *stardates) number(tip string, nodes map[string]starNode) {
	name := func(h, d string) {
		if s.pending != nil {
			s.pending[h] = d
			return
		}
		s.byHash[h], s.byDate[d] = d, h
	}
	known := func(h string) string {
		if d := s.byHash[h]; d != "" {
			return d
		}
		return s.pending[h]
	}
	// chain is the first-parent line back from h to the first commit
	// already named, oldest first.
	chain := func(h string) []string {
		var c []string
		for h != "" && known(h) == "" {
			n, ok := nodes[h]
			if !ok {
				break
			}
			c = append(c, h)
			h = ""
			if len(n.parents) > 0 {
				h = n.parents[0]
			}
		}
		slices.Reverse(c)
		return c
	}
	// mergedIn names what merge m brought in: each further parent's line
	// back to what m's first parent already holds, oldest first, and
	// within it what its own merges brought in. Every commit the first
	// parent reaches is named by then, so each is named once.
	var mergedIn func(m string)
	mergedIn = func(m string) {
		n := 0
		for _, p := range nodes[m].parents[min(1, len(nodes[m].parents)):] {
			for _, h := range chain(p) {
				n++
				name(h, known(m)+"."+strconv.Itoa(n))
				mergedIn(h)
			}
		}
	}
	for _, h := range chain(tip) {
		d := nodes[h].day
		if s.pending != nil {
			name(h, d+".x")
			mergedIn(h)
			continue
		}
		s.perDay[d]++
		name(h, d+"."+strconv.Itoa(s.perDay[d]))
		mergedIn(h)
	}
}

// rowLabel is label for a row in a list of commits. A day and x, which
// several pending commits share, adds the short hash, eg 261026.x 8392cf3,
// so each row names its own commit.
func (s *stardates) rowLabel(c *commit) string {
	if s != nil && s.pending[c.hash] != "" {
		return s.pending[c.hash] + " " + c.short
	}
	return s.label(c)
}

// label is what a reader sees for c: its stardate in s, its day and x
// while it waits for a push, or its short hash when it has neither.
func (s *stardates) label(c *commit) string {
	if s != nil && s.pending[c.hash] != "" {
		return s.pending[c.hash]
	}
	return s.addr(c)
}

// addr is what names c in a version's address: its stardate in s, or its
// short hash when it has none. A pending day and x names no one commit,
// so it never addresses one.
func (s *stardates) addr(c *commit) string {
	if s != nil {
		if d := s.byHash[c.hash]; d != "" {
			return d
		}
	}
	return c.short
}