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