Download
913 lines 26.6 KiBGoModified 2026-10-27 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913
package render

import (
	"cmp"
	"errors"
	"fmt"
	"io/fs"
	"maps"
	"os"
	"path"
	"path/filepath"
	"runtime"
	"slices"
	"strings"
	"sync"
	"time"

	"code.bruce-hill.com/repo-renderer/internal/config"
	"code.bruce-hill.com/repo-renderer/internal/gitx"
)

// ---- tree model ----

type node struct {
	name     string
	dir      bool
	entry    *gitx.Entry // nil for directories
	children map[string]*node
}

type tree struct {
	root   *node
	byPath map[string]*gitx.Entry // blobs, symlinks, submodules (valid paths only)
	dirs   map[string]*node       // "" is the root
}

func (t *tree) dir(p string) *node { return t.dirs[p] }

// sortedPaths returns every entry path in order.
func (t *tree) sortedPaths() []string {
	return slices.Sorted(maps.Keys(t.byPath))
}

// buildTree indexes a recursive listing. Entries with unsafe paths are
// dropped (and reported through warn) so they are never written anywhere.
func buildTree(entries []gitx.Entry, warn func(string)) *tree {
	t := &tree{
		root:   &node{dir: true, children: map[string]*node{}},
		byPath: map[string]*gitx.Entry{},
		dirs:   map[string]*node{},
	}
	t.dirs[""] = t.root
	for i := range entries {
		e := &entries[i]
		if !ValidPath(e.Path) {
			warn(fmt.Sprintf("skipping unsafe path %q", e.Path))
			continue
		}
		parts := strings.Split(e.Path, "/")
		cur := t.root
		curPath := ""
		for _, d := range parts[:len(parts)-1] {
			if curPath == "" {
				curPath = d
			} else {
				curPath += "/" + d
			}
			child, ok := cur.children[d]
			if !ok {
				child = &node{name: d, dir: true, children: map[string]*node{}}
				cur.children[d] = child
				t.dirs[curPath] = child
			} else if !child.dir {
				warn(fmt.Sprintf("skipping %q: %q is both a file and a directory", e.Path, curPath))
				cur = nil
				break
			}
			cur = child
		}
		if cur == nil {
			continue
		}
		leaf := parts[len(parts)-1]
		if _, dup := cur.children[leaf]; dup {
			warn(fmt.Sprintf("skipping %q: duplicate name", e.Path))
			continue
		}
		cur.children[leaf] = &node{name: leaf, entry: e}
		t.byPath[e.Path] = e
	}
	return t
}

// ---- per-repo context ----

type repoCtx struct {
	s         *Site
	repo      string // the repository: its git directory, tags and display name
	isDefault bool
	key       string // output namespace: repo, or repo@branch for a non-default branch
	branch    string
	views     []viewLink // branch picker, shared by every view of the repo
	sha       string
	g         gitx.Repo
	tree      *tree
	batch     *gitx.Batch
	commitAt  time.Time
	subject   string // the commit's subject line
	desc      string // repo description, the default for every page's

	stats      *statsView
	typed      map[string][]typedFile // summary row → its files, in path order
	corpusHash string                 // versions the search corpus's URL

	mtime     map[string]int64  // path (file or directory) → last commit time
	lines     map[string]int    // file path → line count; -1 binary, absent unknown
	lineCache map[string]int    // blob id → line count, carried in State
	prose     map[string]bool   // file path → its lines count as docs, not code
	sizes     map[string]*sizes // file or directory → its lines and bytes
}

func (s *Site) newRepoCtx(repo, branch string, isDefault bool, views []string, sha, desc string) (*repoCtx, error) {
	g := gitx.Repo{Dir: s.Lay.GitDir(repo)}
	entries, err := g.LsTree(sha)
	if err != nil {
		return nil, err
	}
	if err := s.checkTreeLimits(entries); err != nil {
		return nil, fmt.Errorf("not rendering %s: %w", sha[:12], err)
	}
	t := buildTree(entries, func(msg string) { s.Log.Printf("%s: %s", repo, msg) })
	commitAt, subject, err := g.Commit(sha)
	if err != nil {
		return nil, err
	}
	batch, err := g.NewBatch()
	if err != nil {
		return nil, err
	}
	key := repo
	if !isDefault {
		key = config.ViewKey(repo, branch)
	}
	c := &repoCtx{
		s: s, repo: repo, isDefault: isDefault, key: key, branch: branch,
		sha: sha, g: g, tree: t, batch: batch, commitAt: commitAt, subject: subject,
		desc: desc,
	}
	for i, b := range views {
		v := viewLink{Name: b, Current: b == branch}
		if i == 0 {
			v.URL = s.CodeURL(repo, "")
		} else {
			v.URL = s.CodeURL(config.ViewKey(repo, b), "")
		}
		c.views = append(c.views, v)
	}
	if err := c.loadMtimes(); err != nil {
		_ = batch.Close() // the loadMtimes error is the one to report
		return nil, err
	}
	return c, nil
}

// loadMtimes finds each entry's last commit time; directories take the
// newest time found beneath them.
func (c *repoCtx) loadMtimes() error {
	c.mtime = map[string]int64{}
	wanted := make(map[string]bool, len(c.tree.byPath))
	for p := range c.tree.byPath {
		wanted[p] = true
	}
	return c.g.LastModified(c.sha, wanted, func(p string, t int64) {
		c.mtime[p] = t
		for d := p; ; {
			i := strings.LastIndexByte(d, '/')
			if i < 0 {
				d = ""
			} else {
				d = d[:i]
			}
			if c.mtime[d] < t {
				c.mtime[d] = t
			}
			if d == "" {
				break
			}
		}
	})
}

func (c *repoCtx) close() {
	if c.batch != nil {
		_ = c.batch.Close()
	}
}

func (c *repoCtx) logf(format string, args ...any) {
	c.s.Log.Printf("%s: %s", c.key, fmt.Sprintf(format, args...))
}

// branchSuffix is "@branch" on non-default views, for titles.
func (c *repoCtx) branchSuffix() string {
	if c.isDefault {
		return ""
	}
	return "@" + c.branch
}

// viewLink is one entry of the branch picker.
type viewLink struct {
	Name    string
	URL     string
	Current bool
}

// page starts a pageData for this repo with the header extras filled in.
func (c *repoCtx) page() pageData {
	p := c.s.basePage(c.repo)
	p.RepoURL, p.SearchURL = c.s.CodeURL(c.key, ""), c.s.SearchURL(c.key)
	p.Branch, p.NoIndex = c.branch, p.NoIndex || !c.isDefault
	p.Description = c.desc
	p.Tags = c.s.Cfg.Repo(c.repo).Tags
	if len(c.views) > 1 {
		p.Views = c.views
	}
	p.DownloadsURL = c.s.DownloadsURL(c.key)
	return p
}

// pending is an output written to a temporary file that commit moves into
// place; discard drops it. Outputs that pages link to (the search corpus,
// the tip tarball) are prepared before the pages and committed after them,
// so a failed render never leaves them ahead of the pages.
type pending struct {
	commit  func() error
	discard func()
}

// ---- main entry point ----

// RenderRepo brings the repository's pages up to date with the tip of its
// default branch and of every allowlisted branch. It returns whether
// anything was (re)written. A failing branch does not stop the others.
// A private repository is rendered to the private site, or nowhere when
// there is none; what it has on the other site is removed, and counts as
// a change.
func (s *Site) RenderRepo(repo string, force bool) (bool, error) {
	if !config.ValidRepoName(repo) {
		return false, fmt.Errorf("invalid repository name %q", repo)
	}
	site := s.siteFor(repo)
	removed, err := s.removeElsewhere(repo)
	if err != nil || site == nil {
		return removed, err
	}
	changed, err := site.renderRepo(repo, force)
	return changed || removed, err
}

// renderRepo is RenderRepo on this site alone.
func (s *Site) renderRepo(repo string, force bool) (bool, error) {
	g := gitx.Repo{Dir: s.Lay.GitDir(repo)}
	if !g.Exists() {
		return false, fmt.Errorf("%s: not a git repository", g.Dir)
	}
	def, err := s.DefaultBranch(repo)
	if err != nil {
		return false, err
	}
	var errs []error
	if err := s.EnsureArchives(repo); err != nil {
		s.Log.Printf("%s: archives: %v", repo, err)
	}
	views := []string{def}
	tips, missing := s.allowlisted(g, repo, def)
	for _, b := range missing {
		s.Log.Printf("%s: allowlisted branch %s does not exist; skipping", repo, b)
	}
	for _, t := range tips {
		views = append(views, t.name)
	}
	changed, err := s.renderView(repo, def, true, views, force)
	if err != nil {
		errs = append(errs, err)
	}
	keep := map[string]bool{repo: true}
	for _, b := range views[1:] {
		keep[config.ViewKey(repo, b)] = true
		c, err := s.renderView(repo, b, false, views, force)
		if err != nil {
			errs = append(errs, fmt.Errorf("branch %s: %w", b, err))
		}
		changed = changed || c
	}
	if err := s.pruneViews(repo, keep); err != nil {
		errs = append(errs, err)
	}
	// Last, so the lists link only to tarballs already in place.
	if err := s.WriteDownloads(repo); err != nil {
		errs = append(errs, fmt.Errorf("download lists: %w", err))
	}
	return changed, errors.Join(errs...)
}

// allowlisted resolves the repo's allowlisted branches other than def:
// the tips of those that exist, in config order, and the names of those
// that do not.
func (s *Site) allowlisted(g gitx.Repo, repo, def string) (tips []refLine, missing []string) {
	for _, b := range s.Cfg.Repo(repo).Branches {
		if b == def {
			continue
		}
		if sha, err := g.RevParse("refs/heads/" + b); err == nil && sha != "" {
			tips = append(tips, refLine{b, sha})
		} else {
			missing = append(missing, b)
		}
	}
	return tips, missing
}

// DefaultBranch is the branch served at the repo's own URLs: the configured
// override, else the bare repo's HEAD.
func (s *Site) DefaultBranch(repo string) (string, error) {
	if b := s.Cfg.Repo(repo).Branch; b != "" {
		return b, nil
	}
	return gitx.Repo{Dir: s.Lay.GitDir(repo)}.HeadBranch()
}

// pruneViews removes the output of branch views that are no longer rendered
// (dropped from the allowlist, or the branch was deleted).
func (s *Site) pruneViews(repo string, keep map[string]bool) error {
	matches, err := listDir(s.Lay.StateDir(), func(n string) bool { return strings.HasPrefix(n, repo+"@") })
	if err != nil {
		return err
	}
	for _, m := range matches {
		key := filepath.Base(m)
		if keep[key] {
			continue
		}
		s.Log.Printf("%s: removing output of retired branch view %s", repo, key)
		if err := s.removeView(key); err != nil {
			return err
		}
	}
	return nil
}

// removeView deletes everything rendered for one view, and its state.
func (s *Site) removeView(key string) error {
	lay := s.Lay
	if err := removeOutputs(lay.Page(key, ""), lay.StatePath(key)); err != nil {
		return err
	}
	for _, d := range []string{lay.RepoDir(key), lay.RawDir(key), lay.SVGDir(key), lay.ArchiveDir(key), lay.SnapshotDir(key), lay.TypesDir(key), lay.IframesDir(key), lay.SearchDir(key)} {
		if err := os.RemoveAll(d); err != nil {
			return err
		}
	}
	return nil
}

// RemoveOthers deletes the output of every repository not in keep, on
// both sites: each of its views, its clone export, its sitemap and its
// render state. It removes nothing at all when git_dir is missing: a
// wrong path or an unmounted disk must not empty the site.
func (s *Site) RemoveOthers(keep []string) error {
	if st, err := os.Stat(s.Lay.GitBase); err != nil || !st.IsDir() {
		return fmt.Errorf("not removing other repositories' output: git_dir %s is not a directory", s.Lay.GitBase)
	}
	for _, site := range s.sites() {
		if _, err := site.removeRepos(func(repo string) bool { return !slices.Contains(keep, repo) },
			"a repository no longer in "+s.Lay.GitBase); err != nil {
			return err
		}
	}
	return nil
}

// removeElsewhere deletes, as RemoveOthers does, everything a repository
// has on the sites it is not rendered to: the public one, when it is
// private, else the private one. It reports whether there was anything.
func (s *Site) removeElsewhere(repo string) (bool, error) {
	found := false
	for _, site := range s.sites() {
		if site == s.siteFor(repo) {
			continue
		}
		why := "a private repository"
		if site.prefix != "" {
			why = "a repository that is no longer private"
		}
		removed, err := site.removeRepos(func(r string) bool { return r == repo }, why)
		if err != nil {
			return found, err
		}
		found = found || len(removed) > 0
	}
	return found, nil
}

// removeRepos deletes the output of every repository drop picks, and
// returns those it found any of. It finds them by the names in the output
// and the state, not only the state, so a render that stopped before
// saving its state leaves nothing behind. It removes only names a
// repository could have.
func (s *Site) removeRepos(drop func(repo string) bool, why string) ([]string, error) {
	lay := s.Lay
	// Every place a view or a repository leaves its name, the suffix the
	// name carries there, and whether it is a directory there.
	places := []struct {
		dir, suffix string
		isDir       bool
	}{
		{lay.StateDir(), "", false},
		{lay.CodeBase(), "", true}, {lay.CodeBase(), ".html", false}, {lay.CodeBase(), ".html.gz", false},
		{filepath.Dir(lay.RawDir("r")), "", true}, {filepath.Dir(lay.SVGDir("r")), "", true},
		{filepath.Dir(lay.ArchiveDir("r")), "", true}, {filepath.Dir(lay.SnapshotDir("r")), "", true},
		{filepath.Dir(lay.TypesDir("r")), "", true}, {filepath.Dir(lay.IframesDir("r")), "", true},
		{filepath.Dir(lay.SearchDir("r")), "", true}, {lay.PubBase(), ".git", true},
		{filepath.Dir(lay.Sitemap("r")), ".xml", false}, {filepath.Dir(lay.Sitemap("r")), ".xml.gz", false},
	}
	views := map[string][]string{} // repo → its views' keys
	for _, pl := range places {
		ents, err := os.ReadDir(pl.dir)
		if errors.Is(err, fs.ErrNotExist) {
			continue
		}
		if err != nil {
			return nil, err
		}
		for _, e := range ents {
			key, ok := strings.CutSuffix(e.Name(), pl.suffix)
			if !ok || e.IsDir() != pl.isDir {
				continue
			}
			repo, _, _ := strings.Cut(key, "@")
			if !config.ValidRepoName(repo) || !drop(repo) || slices.Contains(views[repo], key) {
				continue
			}
			views[repo] = append(views[repo], key)
		}
	}
	repos := slices.Sorted(maps.Keys(views))
	for _, repo := range repos {
		s.Log.Printf("%s: removing output of %s", repo, why)
		for _, key := range views[repo] {
			if err := s.removeView(key); err != nil {
				return nil, err
			}
		}
		if err := removeOutputs(lay.Sitemap(repo)); err != nil {
			return nil, err
		}
		if err := os.RemoveAll(lay.PubDir(repo)); err != nil {
			return nil, err
		}
	}
	return repos, nil
}

// renderView renders one branch. views lists the repo's rendered branches,
// default first, for the branch picker; a change in that set re-renders
// everything because every page embeds the picker.
func (s *Site) renderView(repo, branch string, isDefault bool, views []string, force bool) (bool, error) {
	g := gitx.Repo{Dir: s.Lay.GitDir(repo)}
	key := repo
	if !isDefault {
		key = config.ViewKey(repo, branch)
	}
	sha, err := g.RevParse("refs/heads/" + branch)
	if err != nil {
		return false, err
	}
	if sha == "" {
		s.Log.Printf("%s: branch %s has no commits; nothing to render", key, branch)
		return false, nil
	}
	prev, err := s.LoadState(key)
	if err != nil {
		return false, err
	}
	desc := g.Description()
	reason := s.fullRenderReason(g, repo, branch, views, sha, desc, prev, force)
	full := reason != ""
	if !full && prev.SHA == sha {
		s.Log.Printf("%s: up to date at %s", key, sha[:12])
		return false, nil
	}
	start := time.Now()
	// newRepoCtx only reads, and refuses a tree over repo_max_bytes or
	// repo_max_files, so such a commit leaves the last render as it was.
	c, err := s.newRepoCtx(repo, branch, isDefault, views, sha, desc)
	if err != nil {
		return false, err
	}
	defer c.close()
	if err := s.markUnfinished(key, prev); err != nil {
		return false, err
	}
	// Line counts carry over only while the binary and config are the same.
	if prev != nil && prev.TmplHash == s.TmplHash {
		c.lineCache = prev.Lines
	}
	changes, err := c.listChanges(prev, reason)
	if err != nil {
		return false, err
	}
	dirs := c.affectedDirs(changes, full)
	tipName, err := c.writeOutputs(changes, dirs, full, prev)
	if err != nil {
		return false, err
	}
	st := &State{
		SHA:         sha,
		TmplHash:    s.TmplHash,
		Branch:      branch,
		Views:       views,
		CommitTime:  c.commitAt,
		Subject:     c.subject,
		Description: desc,
		Tip:         tipName,
		Lines:       c.lineCache,
	}
	if err := s.saveState(key, st); err != nil {
		return false, err
	}
	c.logf("rendered %d change(s), %d listing(s) in %s", len(changes), len(dirs), time.Since(start).Round(time.Millisecond))
	return true, nil
}

// fullRenderReason says why the branch at sha needs a full render rather
// than an incremental one from prev, the saved state of its last render;
// "" when it does not. Anything the pages depend on beyond the commit's
// files, or any doubt about the previous output, calls for a full render.
func (s *Site) fullRenderReason(g gitx.Repo, repo, branch string, views []string, sha, desc string, prev *State, force bool) string {
	switch {
	case prev == nil:
		return "first render"
	case prev.TmplHash != s.TmplHash:
		return "templates or config changed"
	case prev.Unfinished:
		return "previous render did not finish"
	case prev.Branch != branch:
		return "branch changed"
	case !slices.Equal(prev.Views, views):
		return "branch list changed"
	case prev.Description != desc:
		return "description changed"
	case force:
		return "forced"
	}
	ok, err := g.IsAncestor(prev.SHA, sha)
	switch {
	case err != nil:
		// The saved commit may have been pruned by a repack after a
		// force push; a full render is always a correct answer.
		s.Log.Printf("%s: cannot compare with previous render: %v", repo, err)
		return "previous commit unavailable"
	case !ok:
		return "history rewritten"
	}
	return ""
}

// markUnfinished flags the view's saved state as unfinished before anything
// is written: from here until the new state is saved, the output may hold
// pages of neither commit, so a render that dies leaves the next one full.
func (s *Site) markUnfinished(key string, prev *State) error {
	if prev == nil || prev.Unfinished {
		return nil
	}
	marked := *prev
	marked.Unfinished = true
	return s.saveState(key, &marked)
}

// listChanges lists what to render: every path for a full render (reason
// says why it is one), otherwise what changed since prev's commit.
func (c *repoCtx) listChanges(prev *State, reason string) ([]gitx.Change, error) {
	if reason == "" {
		c.logf("incremental render %s..%s", prev.SHA[:12], c.sha[:12])
		return c.g.DiffTree(prev.SHA, c.sha)
	}
	c.logf("full render of %s (%s)", c.sha[:12], reason)
	var changes []gitx.Change
	for _, p := range c.tree.sortedPaths() {
		changes = append(changes, gitx.Change{Status: 'A', Path: p})
	}
	return changes, nil
}

// writeOutputs writes everything for the view's new commit and returns the
// name of its tip tarball, "" when there is none. The search corpus and the
// tarball, which pages link to, are prepared first and moved into place
// only once the pages are, so a failed render never leaves them ahead.
func (c *repoCtx) writeOutputs(changes []gitx.Change, dirs []string, full bool, prev *State) (string, error) {
	// Corpus and line counts first: every page's header shows the counts.
	corpus, err := c.writeSearchData()
	if err != nil {
		return "", err
	}
	defer corpus.discard()
	// A tree git archive refuses (e.g. one with unsafe names) must not
	// block the pages: the download list just has no latest tarball.
	tipName := tipArchiveName(c.repo, c.sha)
	tip, err := prepareArchive(c.g, c.s.Lay.TipArchive(c.key, tipName), c.repo+"/", c.sha, tipArchiveLevel)
	if err != nil {
		c.logf("tip archive: %v", err)
		tipName = ""
	} else {
		defer tip.discard()
	}
	if err := c.writePages(changes, dirs, full); err != nil {
		return "", err
	}
	if err := corpus.commit(); err != nil {
		return "", err
	}
	if tip.commit != nil {
		if err := tip.commit(); err != nil {
			return "", fmt.Errorf("tip archive: %w", err)
		}
	}
	if prev != nil && prev.Tip != "" && prev.Tip != tipName {
		if err := c.s.supersedeTip(c.key, prev.Tip); err != nil {
			return "", err
		}
	}
	return tipName, nil
}

// writePages writes the pages for changes, then every page that summarizes
// them: the type pages, the listings of dirs, the drawer, the sitemap and
// the search page. A full render also sweeps out pages of paths that are
// gone.
func (c *repoCtx) writePages(changes []gitx.Change, dirs []string, full bool) error {
	if err := c.renderChanges(changes); err != nil {
		return err
	}
	if full {
		if err := c.sweepStale(); err != nil {
			return err
		}
	}
	// Every push can move files between types, and the root links to
	// them, so these are all rewritten before it.
	if err := c.renderTypePages(); err != nil {
		return fmt.Errorf("type pages: %w", err)
	}
	for _, d := range dirs {
		if err := c.renderListing(d); err != nil {
			return fmt.Errorf("listing %q: %w", d, err)
		}
	}
	if full || drawerChanged(changes, c.tree) {
		if err := c.writeDrawer(); err != nil {
			return fmt.Errorf("drawer: %w", err)
		}
	}
	if c.isDefault && c.s.prefix == "" { // search engines cannot log in
		if err := c.writeSitemap(); err != nil {
			return fmt.Errorf("sitemap: %w", err)
		}
	}
	return c.renderSearchPage()
}

// renderChanges applies deletions first, in order, then fans the pages to
// write out to a worker pool. Deleting while writing would race: pruning
// a directory that just lost its last file can land between a worker's
// MkdirAll and its CreateTemp in that same directory. Each path is written
// by exactly one worker; blob reads are serialized by the batch.
func (c *repoCtx) renderChanges(changes []gitx.Change) error {
	var writes []gitx.Change
	for _, ch := range changes {
		if c.isDeletion(ch) {
			if err := c.apply(ch); err != nil {
				return fmt.Errorf("%s: %w", ch.Path, err)
			}
			continue
		}
		writes = append(writes, ch)
	}
	workers := c.s.Cfg.Workers
	if workers <= 0 {
		workers = 4 * runtime.NumCPU()
	}
	workers = min(workers, len(writes))
	tasks := make(chan gitx.Change)
	var wg sync.WaitGroup
	var mu sync.Mutex
	var firstErr error
	setErr := func(err error) {
		mu.Lock()
		if firstErr == nil {
			firstErr = err
		}
		mu.Unlock()
	}
	for range workers {
		wg.Go(func() {
			for ch := range tasks {
				if err := c.apply(ch); err != nil {
					setErr(fmt.Errorf("%s: %w", ch.Path, err))
				}
			}
		})
	}
	for _, ch := range writes {
		tasks <- ch
	}
	close(tasks)
	wg.Wait()
	return firstErr
}

// isDeletion reports whether applying ch only removes outputs: the path
// is gone, or is now a symlink or submodule, which is listed but never
// rendered, so no stale page may linger.
func (c *repoCtx) isDeletion(ch gitx.Change) bool {
	if ch.Status == 'D' {
		return true
	}
	e := c.tree.byPath[ch.Path]
	return e == nil || e.IsSymlink() || e.IsSubmodule()
}

func (c *repoCtx) apply(ch gitx.Change) error {
	if !ValidPath(ch.Path) {
		// buildTree never admits such a path, so nothing was ever written
		// for it; joining it below would resolve ".." outside the repo's
		// output and delete someone else's files.
		c.logf("ignoring change to unsafe path %q", ch.Path)
		return nil
	}
	if c.isDeletion(ch) {
		return c.deletePath(ch.Path)
	}
	return c.renderBlob(c.tree.byPath[ch.Path])
}

// deletePath removes p's outputs. p must be a ValidPath.
func (c *repoCtx) deletePath(p string) error {
	lay := c.s.Lay
	page := lay.Page(c.key, p)
	raw := lay.Raw(c.key, p)
	themed := lay.SVG(c.key, p)
	if err := removeOutputs(page, themed); err != nil {
		return err
	}
	if err := c.removeRaw(p); err != nil {
		return err
	}
	pruneEmptyDirs(filepath.Dir(page), lay.RepoDir(c.key))
	pruneEmptyDirs(filepath.Dir(raw), lay.RawDir(c.key))
	pruneEmptyDirs(filepath.Dir(themed), lay.SVGDir(c.key))
	return nil
}

// removeRaw deletes the raw copy of p, if any. Raw copies are served as
// they are, with no gzip twin, so a "p.gz" beside it is a committed
// file's own copy and stays.
func (c *repoCtx) removeRaw(p string) error {
	if err := os.Remove(c.s.Lay.Raw(c.key, p)); err != nil && !errors.Is(err, fs.ErrNotExist) {
		return err
	}
	return nil
}

// affectedDirs returns the directories whose listings must be rebuilt:
// every directory containing a change plus all its ancestors (all
// directories on a full render), deepest first so parents see final state.
func (c *repoCtx) affectedDirs(changes []gitx.Change, full bool) []string {
	set := map[string]bool{"": true}
	if full {
		for d := range c.tree.dirs {
			set[d] = true
		}
	} else {
		retyped := false
		for _, ch := range changes {
			retyped = retyped || ch.Status != 'M'
			d := path.Dir(ch.Path)
			for d != "." && d != "" {
				set[d] = true
				d = path.Dir(d)
			}
		}
		// A symlink's link follows its target, which may have come or
		// gone anywhere in the tree.
		if retyped {
			for p, e := range c.tree.byPath {
				if e.IsSymlink() {
					if d := path.Dir(p); d != "." {
						set[d] = true
					}
				}
			}
		}
	}
	return deepestFirst(set)
}

// deepestFirst orders directories deepest first, the root last, so
// listings see the final state of their subdirectories.
func deepestFirst(set map[string]bool) []string {
	dirs := slices.Collect(maps.Keys(set))
	// The root goes last, below every real depth.
	depth := func(d string) int {
		if d == "" {
			return -1
		}
		return strings.Count(d, "/")
	}
	slices.SortFunc(dirs, func(a, b string) int {
		return cmp.Or(cmp.Compare(depth(b), depth(a)), strings.Compare(a, b))
	})
	return dirs
}

// sweepStale removes pages, raw files and themed SVGs
// that no longer correspond to a tree entry. Only needed on full renders
// (a force push can delete files that no diff ever told us about).
func (c *repoCtx) sweepStale() error {
	lay := c.s.Lay
	// Pages: strip ".html"; valid if it's a file or a directory in the tree.
	codeDir := lay.RepoDir(c.key)
	err := filepath.WalkDir(codeDir, func(p string, d fs.DirEntry, err error) error {
		if err != nil {
			if errors.Is(err, fs.ErrNotExist) {
				return nil
			}
			return err
		}
		if d.IsDir() {
			return nil
		}
		rel, _ := filepath.Rel(codeDir, p)
		rel = filepath.ToSlash(rel)
		rel = strings.TrimSuffix(rel, ".gz")
		if !strings.HasSuffix(rel, ".html") {
			return os.Remove(p)
		}
		key := strings.TrimSuffix(rel, ".html")
		if e := c.tree.byPath[key]; e != nil {
			if e.IsSymlink() || e.IsSubmodule() {
				return os.Remove(p)
			}
			return nil
		}
		if _, isDir := c.tree.dirs[key]; isDir {
			return nil
		}
		return os.Remove(p)
	})
	if err != nil {
		return err
	}
	// Raw files and themed SVGs: compute the expected served names. The
	// themed copy of an SVG too large to serve is removed when its blob is
	// rendered, so here any SVG path counts as expected.
	expected := map[string]bool{}
	expectedSVG := map[string]bool{}
	for p, e := range c.tree.byPath {
		if e.IsSymlink() || e.IsSubmodule() {
			continue
		}
		expected[p] = true
		if strings.EqualFold(path.Ext(p), ".svg") {
			expectedSVG[p], expectedSVG[p+".gz"] = true, true
		}
	}
	rawDir := lay.RawDir(c.key)
	svgDir := lay.SVGDir(c.key)
	sweep := func(base string, keep map[string]bool) error {
		return filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error {
			if err != nil {
				if errors.Is(err, fs.ErrNotExist) {
					return nil
				}
				return err
			}
			if d.IsDir() {
				return nil
			}
			rel, _ := filepath.Rel(base, p)
			rel = filepath.ToSlash(rel)
			if keep[rel] {
				return nil
			}
			return os.Remove(p)
		})
	}
	if err := sweep(rawDir, expected); err != nil {
		return err
	}
	if err := sweep(svgDir, expectedSVG); err != nil {
		return err
	}
	for _, base := range []string{codeDir, rawDir, svgDir} {
		_ = filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error {
			if err == nil && d.IsDir() && p != base {
				pruneEmptyDirs(p, base)
			}
			return nil
		})
	}
	return nil
}