main main.go
Eric Bower  ·  2026-05-16
   1package main
   2
   3import (
   4	"bytes"
   5	"crypto/sha256"
   6	"embed"
   7	"flag"
   8	"fmt"
   9	"html/template"
  10	"io"
  11	"log/slog"
  12	"os"
  13	"os/exec"
  14	"path/filepath"
  15	"sort"
  16	"strconv"
  17	"strings"
  18	"sync"
  19	"time"
  20	"unicode/utf8"
  21
  22	"github.com/alecthomas/chroma/v2"
  23	formatterHtml "github.com/alecthomas/chroma/v2/formatters/html"
  24	"github.com/alecthomas/chroma/v2/lexers"
  25	"github.com/alecthomas/chroma/v2/styles"
  26	"github.com/dustin/go-humanize"
  27	git "github.com/gogs/git-module"
  28)
  29
  30//go:embed html/*.tmpl
  31var embedFS embed.FS
  32
  33//go:embed static/*
  34var staticFS embed.FS
  35
  36var funcMap = template.FuncMap{
  37	"add": func(a, b int) int { return a + b },
  38}
  39
  40type Config struct {
  41	// required params
  42	Outdir string
  43	// abs path to git repo
  44	RepoPath string
  45
  46	// optional params
  47	// generate logs anad tree based on the git revisions provided
  48	Revs []string
  49	// maximum number of commits that we will process in descending order
  50	MaxCommits int
  51	// name of the readme file
  52	Readme string
  53	// In order to get the latest commit per file we do a `git rev-list {ref} {file}`
  54	// which is n+1 where n is a file in the tree.
  55	// We offer a way to disable showing the latest commit in the output
  56	// for those who want a faster build time
  57	HideTreeLastCommit bool
  58
  59	// user-defined urls
  60	CloneURL template.URL
  61	// repo bug tracking url
  62	IssuesURL template.URL
  63	// repo code contribution url (e.g. pull requests, patches)
  64	ContribURL template.URL
  65
  66	// https://developer.mozilla.org/en-US/docs/Web/API/URL_API/Resolving_relative_references#root_relative
  67	RootRelative string
  68
  69	// computed
  70	// cache for skipping commits, trees, etc.
  71	Cache map[string]bool
  72	// mutex for Cache
  73	Mutex sync.RWMutex
  74	// pretty name for the repo
  75	RepoName string
  76	// logger
  77	Logger *slog.Logger
  78	// chroma style
  79	Theme     *chroma.Style
  80	Formatter *formatterHtml.Formatter
  81}
  82
  83type RevInfo interface {
  84	ID() string
  85	Name() string
  86}
  87
  88type RevData struct {
  89	id     string
  90	name   string
  91	Config *Config
  92}
  93
  94func (r *RevData) ID() string {
  95	return r.id
  96}
  97
  98func (r *RevData) Name() string {
  99	return r.name
 100}
 101
 102func (r *RevData) TreeURL() template.URL {
 103	return r.Config.getTreeURL(r)
 104}
 105
 106func (r *RevData) LogURL() template.URL {
 107	return r.Config.getLogsURL(r)
 108}
 109
 110type TagData struct {
 111	Name string
 112	URL  template.URL
 113}
 114
 115type CommitData struct {
 116	SummaryStr string
 117	URL        template.URL
 118	WhenStr    string
 119	AuthorStr  string
 120	ShortID    string
 121	ParentIDs  []string
 122	Refs       []*RefInfo
 123	*git.Commit
 124}
 125
 126type TreeItem struct {
 127	IsTextFile bool
 128	IsDir      bool
 129	Size       string
 130	NumLines   int
 131	Name       string
 132	Icon       string
 133	Path       string
 134	URL        template.URL
 135	CommitID   string
 136	CommitURL  template.URL
 137	Summary    string
 138	When       string
 139	Author     *git.Signature
 140	Entry      *git.TreeEntry
 141	Crumbs     []*Breadcrumb
 142}
 143
 144type DiffRender struct {
 145	NumFiles       int
 146	TotalAdditions int
 147	TotalDeletions int
 148	Files          []*DiffRenderFile
 149}
 150
 151type DiffRenderFile struct {
 152	FileType     string
 153	OldMode      git.EntryMode
 154	OldName      string
 155	Mode         git.EntryMode
 156	Name         string
 157	Content      template.HTML
 158	NumAdditions int
 159	NumDeletions int
 160}
 161
 162type RefInfo struct {
 163	ID      string
 164	Refspec string
 165	URL     template.URL
 166}
 167
 168type BranchOutput struct {
 169	Readme     string
 170	LastCommit *git.Commit
 171}
 172
 173type SiteURLs struct {
 174	IssuesURL  template.URL
 175	ContribURL template.URL
 176	CloneURL   template.URL
 177	SummaryURL template.URL
 178	RefsURL    template.URL
 179}
 180
 181type PageData struct {
 182	Repo       *Config
 183	SiteURLs   *SiteURLs
 184	RevData    *RevData
 185	ActivePage string
 186}
 187
 188type SummaryPageData struct {
 189	*PageData
 190	Readme template.HTML
 191}
 192
 193type TreePageData struct {
 194	*PageData
 195	Tree *TreeRoot
 196}
 197
 198type LogPageData struct {
 199	*PageData
 200	NumCommits   int
 201	TotalCommits int
 202	Logs         []*CommitData
 203}
 204
 205type FilePageData struct {
 206	*PageData
 207	Contents template.HTML
 208	Item     *TreeItem
 209}
 210
 211type CommitPageData struct {
 212	*PageData
 213	CommitMsg  template.HTML
 214	CommitID   string
 215	Commit     *CommitData
 216	Diff       *DiffRender
 217	Parents    []string
 218	ParentURLs []template.URL
 219	CommitURL  template.URL
 220}
 221
 222type RefPageData struct {
 223	*PageData
 224	Refs []*RefInfo
 225}
 226
 227type WriteData struct {
 228	Template string
 229	Filename string
 230	Subdir   string
 231	Data     any
 232}
 233
 234func bail(err error) {
 235	if err != nil {
 236		panic(err)
 237	}
 238}
 239
 240func diffFileType(_type git.DiffFileType) string {
 241	switch _type {
 242	case git.DiffFileAdd:
 243		return "A"
 244	case git.DiffFileChange:
 245		return "M"
 246	case git.DiffFileDelete:
 247		return "D"
 248	case git.DiffFileRename:
 249		return "R"
 250	default:
 251		return ""
 252	}
 253}
 254
 255// converts contents of files in git tree to pretty formatted code.
 256func (c *Config) parseText(filename string, text string) (string, error) {
 257	lexer := lexers.Match(filename)
 258	if lexer == nil {
 259		lexer = lexers.Analyse(text)
 260	}
 261	if lexer == nil {
 262		lexer = lexers.Get("plaintext")
 263	}
 264	iterator, err := lexer.Tokenise(nil, text)
 265	if err != nil {
 266		return text, err
 267	}
 268	var buf bytes.Buffer
 269	err = c.Formatter.Format(&buf, c.Theme, iterator)
 270	if err != nil {
 271		return text, err
 272	}
 273	return buf.String(), nil
 274}
 275
 276// isText reports whether a significant prefix of s looks like correct UTF-8;
 277// that is, if it is likely that s is human-readable text.
 278func isText(s string) bool {
 279	const max = 1024 // at least utf8.UTFMax
 280	if len(s) > max {
 281		s = s[0:max]
 282	}
 283	for i, c := range s {
 284		if i+utf8.UTFMax > len(s) {
 285			// last char may be incomplete - ignore
 286			break
 287		}
 288		if c == 0xFFFD || c < ' ' && c != '\n' && c != '\t' && c != '\f' && c != '\r' {
 289			// decoding error or control character - not a text file
 290			return false
 291		}
 292	}
 293	return true
 294}
 295
 296// newSet builds a string set from a list of values for O(1) lookup.
 297func newSet(vals ...string) map[string]struct{} {
 298	m := make(map[string]struct{}, len(vals))
 299	for _, v := range vals {
 300		m[v] = struct{}{}
 301	}
 302	return m
 303}
 304
 305// knownBinaryExts lists extensions that are always binary regardless of content.
 306var knownBinaryExts = newSet(
 307	// images
 308	".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".svg", ".tiff", ".tif", ".psd",
 309	// archives / compressed
 310	".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar", ".tgz", ".war", ".jar",
 311	// documents
 312	".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
 313	// executables / libraries
 314	".exe", ".dll", ".so", ".dylib", ".a", ".o",
 315	// fonts
 316	".ttf", ".otf", ".woff", ".woff2", ".eot",
 317	// audio / video
 318	".mp3", ".mp4", ".avi", ".mov", ".wav", ".flac", ".ogg", ".webm",
 319	// data / serialized
 320	".pb", ".msgpack", ".parquet", ".avro",
 321	// other
 322	".class", ".pyc", ".pyo", ".wasm", ".db", ".sqlite", ".sqlite3",
 323)
 324
 325// knownTextExts lists extensions that are always text regardless of content.
 326var knownTextExts = newSet(
 327	// code
 328	".go", ".py", ".js", ".ts", ".tsx", ".jsx", ".java", ".c", ".h", ".cpp", ".hpp", ".rs", ".rb", ".php", ".pl", ".sh", ".bash", ".zsh", ".fish", ".ps1",
 329	// markup / data
 330	".html", ".htm", ".css", ".scss", ".less", ".xml", ".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf",
 331	// docs
 332	".md", ".markdown", ".txt", ".rst", ".tex", ".bib", ".csv", ".tsv",
 333	// config / build
 334	".Dockerfile", ".dockerignore", ".gitignore", ".gitattributes", ".editorconfig",
 335	// other text
 336	".diff", ".patch", ".log", ".sql", ".graphql", ".proto", ".makefile", ".cmake",
 337)
 338
 339// isTextFile reports whether the file has a known extension indicating
 340// a text file, or if a significant chunk of the specified file looks like
 341// correct UTF-8; that is, if it is likely that the file contains human-
 342// readable text. Extension check takes priority as a fast path.
 343func isTextFile(filename, text string) bool {
 344	ext := strings.ToLower(filepath.Ext(filename))
 345
 346	// fast path: known binary extension
 347	if _, ok := knownBinaryExts[ext]; ok {
 348		return false
 349	}
 350	// fast path: known text extension
 351	if _, ok := knownTextExts[ext]; ok {
 352		return true
 353	}
 354	// also check the full filename for extensionless known text files
 355	nameLower := strings.ToLower(filename)
 356	if _, ok := knownTextExts["."+nameLower]; ok {
 357		return true
 358	}
 359
 360	// fallback: inspect bytes
 361	return isText(text)
 362}
 363
 364func toPretty(b int64) string {
 365	return humanize.Bytes(uint64(b))
 366}
 367
 368func repoName(root string) string {
 369	_, file := filepath.Split(root)
 370	return file
 371}
 372
 373func readmeFile(repo *Config) string {
 374	if repo.Readme == "" {
 375		return "readme.md"
 376	}
 377
 378	return strings.ToLower(repo.Readme)
 379}
 380
 381func (c *Config) writeHtml(writeData *WriteData) {
 382	ts, err := template.New("").Funcs(funcMap).ParseFS(
 383		embedFS,
 384		writeData.Template,
 385		"html/header.partial.tmpl",
 386		"html/footer.partial.tmpl",
 387		"html/base.layout.tmpl",
 388	)
 389	bail(err)
 390
 391	dir := filepath.Join(c.Outdir, writeData.Subdir)
 392	err = os.MkdirAll(dir, os.ModePerm)
 393	bail(err)
 394
 395	fp := filepath.Join(dir, writeData.Filename)
 396	c.Logger.Info("writing", "filepath", fp)
 397
 398	w, err := os.OpenFile(fp, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
 399	bail(err)
 400
 401	tplName := filepath.Base(writeData.Template)
 402	err = ts.ExecuteTemplate(w, tplName, writeData.Data)
 403	bail(err)
 404}
 405
 406// writeHtmlIfChanged renders a template and only writes if content hash differs from existing file
 407func (c *Config) writeHtmlIfChanged(writeData *WriteData) {
 408	ts, err := template.New("").Funcs(funcMap).ParseFS(
 409		embedFS,
 410		writeData.Template,
 411		"html/header.partial.tmpl",
 412		"html/footer.partial.tmpl",
 413		"html/base.layout.tmpl",
 414	)
 415	bail(err)
 416
 417	// render to buffer first
 418	var buf bytes.Buffer
 419	tplName := filepath.Base(writeData.Template)
 420	err = ts.ExecuteTemplate(&buf, tplName, writeData.Data)
 421	bail(err)
 422
 423	dir := filepath.Join(c.Outdir, writeData.Subdir)
 424	err = os.MkdirAll(dir, os.ModePerm)
 425	bail(err)
 426
 427	fp := filepath.Join(dir, writeData.Filename)
 428
 429	// compute hash of new content
 430	newHash := sha256.Sum256(buf.Bytes())
 431
 432	// compare with existing file
 433	if f, err := os.Open(fp); err == nil {
 434		defer f.Close()
 435		h := sha256.New()
 436		if _, err := io.Copy(h, f); err == nil {
 437			if bytes.Equal(h.Sum(nil), newHash[:]) {
 438				return // unchanged, skip
 439			}
 440		}
 441	}
 442
 443	c.Logger.Info("writing", "filepath", fp)
 444	err = os.WriteFile(fp, buf.Bytes(), 0644)
 445	bail(err)
 446}
 447
 448func (c *Config) copyStatic(dir string) error {
 449	entries, err := staticFS.ReadDir(dir)
 450	bail(err)
 451
 452	for _, e := range entries {
 453		infp := filepath.Join(dir, e.Name())
 454		if e.IsDir() {
 455			continue
 456		}
 457
 458		w, err := staticFS.ReadFile(infp)
 459		bail(err)
 460		fp := filepath.Join(c.Outdir, e.Name())
 461		c.Logger.Info("writing", "filepath", fp)
 462		err = os.WriteFile(fp, w, 0644)
 463		bail(err)
 464	}
 465
 466	return nil
 467}
 468
 469func (c *Config) writeRootSummary(data PageData, readme template.HTML) {
 470	data.ActivePage = "readme"
 471	c.Logger.Info("writing root html", "repoPath", c.RepoPath)
 472	c.writeHtml(&WriteData{
 473		Filename: "index.html",
 474		Template: "html/summary.page.tmpl",
 475		Data: &SummaryPageData{
 476			PageData: &data,
 477			Readme:   readme,
 478		},
 479	})
 480}
 481
 482func (c *Config) writeTree(data PageData, tree *TreeRoot) {
 483	data.ActivePage = "code"
 484	c.Logger.Info("writing tree", "treePath", tree.Path)
 485	c.writeHtml(&WriteData{
 486		Filename: "index.html",
 487		Subdir:   tree.Path,
 488		Template: "html/tree.page.tmpl",
 489		Data: &TreePageData{
 490			PageData: &data,
 491			Tree:     tree,
 492		},
 493	})
 494}
 495
 496func (c *Config) writeLog(data PageData, logs []*CommitData, totalCommits int) {
 497	data.ActivePage = "commits"
 498	c.Logger.Info("writing log file", "revision", data.RevData.Name())
 499	c.writeHtml(&WriteData{
 500		Filename: "index.html",
 501		Subdir:   getLogBaseDir(data.RevData),
 502		Template: "html/log.page.tmpl",
 503		Data: &LogPageData{
 504			PageData:     &data,
 505			NumCommits:   len(logs),
 506			TotalCommits: totalCommits,
 507			Logs:         logs,
 508		},
 509	})
 510}
 511
 512func (c *Config) writeRefs(data PageData, refs []*RefInfo) {
 513	data.ActivePage = "refs"
 514	c.Logger.Info("writing refs", "repoPath", c.RepoPath)
 515	c.writeHtml(&WriteData{
 516		Filename: "refs.html",
 517		Template: "html/refs.page.tmpl",
 518		Data: &RefPageData{
 519			PageData: &data,
 520			Refs:     refs,
 521		},
 522	})
 523}
 524
 525func (c *Config) writeHTMLTreeFile(pageData PageData, treeItem *TreeItem) string {
 526	pageData.ActivePage = "code"
 527	d := filepath.Dir(treeItem.Path)
 528	readme := ""
 529	b, err := treeItem.Entry.Blob().Bytes()
 530	bail(err)
 531	str := string(b)
 532
 533	treeItem.IsTextFile = isTextFile(treeItem.Entry.Name(), str)
 534
 535	contents := "binary file, cannot display"
 536	if treeItem.IsTextFile {
 537		treeItem.NumLines = len(strings.Split(str, "\n"))
 538		contents, err = c.parseText(treeItem.Entry.Name(), string(b))
 539		bail(err)
 540	}
 541
 542	nameLower := strings.ToLower(treeItem.Entry.Name())
 543	summary := readmeFile(pageData.Repo)
 544	if d == "." && nameLower == summary {
 545		readme = contents
 546	}
 547
 548	c.writeHtmlIfChanged(&WriteData{
 549		Filename: fmt.Sprintf("%s.html", treeItem.Entry.Name()),
 550		Template: "html/file.page.tmpl",
 551		Data: &FilePageData{
 552			PageData: &pageData,
 553			Contents: template.HTML(contents),
 554			Item:     treeItem,
 555		},
 556		Subdir: getFileDir(pageData.RevData, d),
 557	})
 558	return readme
 559}
 560
 561func (c *Config) writeLogDiff(repo *git.Repository, pageData PageData, commit *CommitData) {
 562	pageData.ActivePage = "commits"
 563	commitID := commit.ID.String()
 564
 565	c.Mutex.RLock()
 566	hasCommit := c.Cache[commitID]
 567	c.Mutex.RUnlock()
 568
 569	if hasCommit {
 570		c.Logger.Info("commit file already generated, skipping", "commitID", getShortID(commitID))
 571		return
 572	}
 573
 574	// skip if output file already exists from a previous run
 575	commitPath := filepath.Join(c.Outdir, "commits", commitID+".html")
 576	if _, err := os.Stat(commitPath); err == nil {
 577		c.Logger.Info("commit file exists, skipping", "commitID", getShortID(commitID))
 578		c.Mutex.Lock()
 579		c.Cache[commitID] = true
 580		c.Mutex.Unlock()
 581		return
 582	}
 583
 584	c.Mutex.Lock()
 585	c.Cache[commitID] = true
 586	c.Mutex.Unlock()
 587
 588	diff, err := repo.Diff(commitID, 0, 0, 0, git.DiffOptions{})
 589	bail(err)
 590
 591	rnd := &DiffRender{
 592		NumFiles:       diff.NumFiles(),
 593		TotalAdditions: diff.TotalAdditions(),
 594		TotalDeletions: diff.TotalDeletions(),
 595	}
 596	fls := []*DiffRenderFile{}
 597	for _, file := range diff.Files {
 598		fl := &DiffRenderFile{
 599			FileType:     diffFileType(file.Type),
 600			OldMode:      file.OldMode(),
 601			OldName:      file.OldName(),
 602			Mode:         file.Mode(),
 603			Name:         file.Name,
 604			NumAdditions: file.NumAdditions(),
 605			NumDeletions: file.NumDeletions(),
 606		}
 607		content := ""
 608		for _, section := range file.Sections {
 609			for _, line := range section.Lines {
 610				content += fmt.Sprintf("%s\n", line.Content)
 611			}
 612		}
 613		// set filename to something our `ParseText` recognizes (e.g. `.diff`)
 614		finContent, err := c.parseText("commit.diff", content)
 615		bail(err)
 616
 617		fl.Content = template.HTML(finContent)
 618		fls = append(fls, fl)
 619	}
 620	rnd.Files = fls
 621
 622	shortParents := make([]string, len(commit.ParentIDs))
 623	parentURLs := make([]template.URL, len(commit.ParentIDs))
 624	for i, pid := range commit.ParentIDs {
 625		shortParents[i] = getShortID(pid)
 626		parentURLs[i] = c.getCommitURL(pid)
 627	}
 628
 629	commitData := &CommitPageData{
 630		PageData:   &pageData,
 631		Commit:     commit,
 632		CommitID:   getShortID(commitID),
 633		Diff:       rnd,
 634		Parents:    shortParents,
 635		CommitURL:  c.getCommitURL(commitID),
 636		ParentURLs: parentURLs,
 637	}
 638
 639	c.writeHtml(&WriteData{
 640		Filename: fmt.Sprintf("%s.html", commitID),
 641		Template: "html/commit.page.tmpl",
 642		Subdir:   "commits",
 643		Data:     commitData,
 644	})
 645}
 646
 647func (c *Config) getSummaryURL() template.URL {
 648	url := c.RootRelative + "index.html"
 649	return template.URL(url)
 650}
 651
 652func (c *Config) getRefsURL() template.URL {
 653	url := c.RootRelative + "refs.html"
 654	return template.URL(url)
 655}
 656
 657// controls the url for trees and logs
 658// - /logs/getRevIDForURL()/index.html
 659// - /tree/getRevIDForURL()/item/file.x.html.
 660func getRevIDForURL(info RevInfo) string {
 661	return info.Name()
 662}
 663
 664func getTreeBaseDir(info RevInfo) string {
 665	subdir := getRevIDForURL(info)
 666	return filepath.Join("/", "tree", subdir)
 667}
 668
 669func getLogBaseDir(info RevInfo) string {
 670	subdir := getRevIDForURL(info)
 671	return filepath.Join("/", "logs", subdir)
 672}
 673
 674func getFileBaseDir(info RevInfo) string {
 675	return filepath.Join(getTreeBaseDir(info), "item")
 676}
 677
 678func getFileDir(info RevInfo, fname string) string {
 679	return filepath.Join(getFileBaseDir(info), fname)
 680}
 681
 682func (c *Config) getFileURL(info RevInfo, fname string) template.URL {
 683	return c.compileURL(getFileBaseDir(info), fname)
 684}
 685
 686func (c *Config) compileURL(dir, fname string) template.URL {
 687	purl := c.RootRelative + strings.TrimPrefix(dir, "/")
 688	url := filepath.Join(purl, fname)
 689	return template.URL(url)
 690}
 691
 692func (c *Config) getTreeURL(info RevInfo) template.URL {
 693	dir := getTreeBaseDir(info)
 694	return c.compileURL(dir, "index.html")
 695}
 696
 697func (c *Config) getLogsURL(info RevInfo) template.URL {
 698	dir := getLogBaseDir(info)
 699	return c.compileURL(dir, "index.html")
 700}
 701
 702func (c *Config) getCommitURL(commitID string) template.URL {
 703	url := fmt.Sprintf("%scommits/%s.html", c.RootRelative, commitID)
 704	return template.URL(url)
 705}
 706
 707func (c *Config) getURLs() *SiteURLs {
 708	return &SiteURLs{
 709		IssuesURL:  c.IssuesURL,
 710		ContribURL: c.ContribURL,
 711		CloneURL:   c.CloneURL,
 712		RefsURL:    c.getRefsURL(),
 713		SummaryURL: c.getSummaryURL(),
 714	}
 715}
 716
 717func getShortID(id string) string {
 718	return id[:7]
 719}
 720
 721// countCommits returns the total number of commits reachable from the given ref
 722func countCommits(repo *git.Repository, ref string) int {
 723	// git rev-list --count is the most efficient way to get total commit count
 724	cmd := exec.Command("git", "rev-list", "--count", ref)
 725	cmd.Dir = repo.Path() // run in the repo directory
 726	out, err := cmd.CombinedOutput()
 727	if err != nil {
 728		// fallback: if git command fails, return 0
 729		return 0
 730	}
 731	count, err := strconv.Atoi(strings.TrimSpace(string(out)))
 732	if err != nil {
 733		return 0
 734	}
 735	return count
 736}
 737
 738func (c *Config) writeRepo() *BranchOutput {
 739	c.Logger.Info("writing repo", "repoPath", c.RepoPath)
 740	repo, err := git.Open(c.RepoPath)
 741	bail(err)
 742
 743	refs, err := repo.ShowRef(git.ShowRefOptions{Heads: true, Tags: true})
 744	bail(err)
 745
 746	var first *RevData
 747	revs := []*RevData{}
 748	for _, revStr := range c.Revs {
 749		fullRevID, err := repo.RevParse(revStr)
 750		if err != nil {
 751			c.Logger.Warn("revision not found, skipping", "revision", revStr, "error", err)
 752			continue
 753		}
 754
 755		revID := getShortID(fullRevID)
 756		revName := revID
 757		// if it's a reference then label it as such
 758		for _, ref := range refs {
 759			if revStr == git.RefShortName(ref.Refspec) || revStr == ref.Refspec {
 760				revName = revStr
 761				break
 762			}
 763		}
 764
 765		data := &RevData{
 766			id:     fullRevID,
 767			name:   revName,
 768			Config: c,
 769		}
 770
 771		if first == nil {
 772			first = data
 773		}
 774		revs = append(revs, data)
 775	}
 776
 777	if first == nil {
 778		bail(fmt.Errorf("could find find a git reference that matches criteria"))
 779	}
 780
 781	refInfoMap := map[string]*RefInfo{}
 782	for _, revData := range revs {
 783		refInfoMap[revData.Name()] = &RefInfo{
 784			ID:      revData.ID(),
 785			Refspec: revData.Name(),
 786			URL:     revData.TreeURL(),
 787		}
 788	}
 789
 790	// loop through ALL refs that don't have URLs
 791	// and add them to the map
 792	for _, ref := range refs {
 793		refspec := git.RefShortName(ref.Refspec)
 794		if refInfoMap[refspec] != nil {
 795			continue
 796		}
 797
 798		refInfoMap[refspec] = &RefInfo{
 799			ID:      ref.ID,
 800			Refspec: refspec,
 801		}
 802	}
 803
 804	// gather lists of refs to display on refs.html page
 805	refInfoList := []*RefInfo{}
 806	for _, val := range refInfoMap {
 807		refInfoList = append(refInfoList, val)
 808	}
 809	sort.Slice(refInfoList, func(i, j int) bool {
 810		urlI := refInfoList[i].URL
 811		urlJ := refInfoList[j].URL
 812		refI := refInfoList[i].Refspec
 813		refJ := refInfoList[j].Refspec
 814		if urlI == urlJ {
 815			return refI < refJ
 816		}
 817		return urlI > urlJ
 818	})
 819
 820	// we assume the first revision in the list is the "main" revision which mostly
 821	// means that's the README we use for the default summary page.
 822	mainOutput := &BranchOutput{}
 823	var wg sync.WaitGroup
 824	for i, revData := range revs {
 825		c.Logger.Info("writing revision", "revision", revData.Name())
 826		data := &PageData{
 827			Repo:     c,
 828			RevData:  revData,
 829			SiteURLs: c.getURLs(),
 830		}
 831
 832		if i == 0 {
 833			branchOutput := c.writeRevision(repo, data, refInfoList)
 834			mainOutput = branchOutput
 835		} else {
 836			wg.Add(1)
 837			go func() {
 838				defer wg.Done()
 839				c.writeRevision(repo, data, refInfoList)
 840			}()
 841		}
 842	}
 843	wg.Wait()
 844
 845	// use the first revision in our list to generate
 846	// the root summary, logs, and tree the user can click
 847	revData := &RevData{
 848		id:     first.ID(),
 849		name:   first.Name(),
 850		Config: c,
 851	}
 852
 853	data := &PageData{
 854		RevData:  revData,
 855		Repo:     c,
 856		SiteURLs: c.getURLs(),
 857	}
 858	c.writeRefs(*data, refInfoList)
 859	c.writeRootSummary(*data, template.HTML(mainOutput.Readme))
 860	return mainOutput
 861}
 862
 863type TreeRoot struct {
 864	Path   string
 865	Items  []*TreeItem
 866	Crumbs []*Breadcrumb
 867}
 868
 869type TreeWalker struct {
 870	treeItem           chan *TreeItem
 871	tree               chan *TreeRoot
 872	HideTreeLastCommit bool
 873	PageData           *PageData
 874	Repo               *git.Repository
 875	Config             *Config
 876}
 877
 878type Breadcrumb struct {
 879	Text   string
 880	URL    template.URL
 881	IsLast bool
 882}
 883
 884func (tw *TreeWalker) calcBreadcrumbs(curpath string) []*Breadcrumb {
 885	if curpath == "" {
 886		return []*Breadcrumb{}
 887	}
 888	parts := strings.Split(curpath, string(os.PathSeparator))
 889	rootURL := tw.Config.compileURL(
 890		getTreeBaseDir(tw.PageData.RevData),
 891		"index.html",
 892	)
 893
 894	crumbs := make([]*Breadcrumb, len(parts)+1)
 895	crumbs[0] = &Breadcrumb{
 896		URL:  rootURL,
 897		Text: tw.PageData.Repo.RepoName,
 898	}
 899
 900	cur := ""
 901	for idx, d := range parts {
 902		crumb := filepath.Join(getFileBaseDir(tw.PageData.RevData), cur, d)
 903		crumbUrl := tw.Config.compileURL(crumb, "index.html")
 904		crumbs[idx+1] = &Breadcrumb{
 905			Text: d,
 906			URL:  crumbUrl,
 907		}
 908		if idx == len(parts)-1 {
 909			crumbs[idx+1].IsLast = true
 910		}
 911		cur = filepath.Join(cur, d)
 912	}
 913
 914	return crumbs
 915}
 916
 917func filenameToDevIcon(filename string) string {
 918	ext := filepath.Ext(filename)
 919	extMappr := map[string]string{
 920		".html": "html5",
 921		".go":   "go",
 922		".py":   "python",
 923		".css":  "css3",
 924		".js":   "javascript",
 925		".md":   "markdown",
 926		".ts":   "typescript",
 927		".tsx":  "react",
 928		".jsx":  "react",
 929	}
 930
 931	nameMappr := map[string]string{
 932		"Makefile":   "cmake",
 933		"Dockerfile": "docker",
 934	}
 935
 936	icon := extMappr[ext]
 937	if icon == "" {
 938		icon = nameMappr[filename]
 939	}
 940
 941	return fmt.Sprintf("devicon-%s-original", icon)
 942}
 943
 944func (tw *TreeWalker) NewTreeItem(entry *git.TreeEntry, curpath string, crumbs []*Breadcrumb) *TreeItem {
 945	typ := entry.Type()
 946	fname := filepath.Join(curpath, entry.Name())
 947	item := &TreeItem{
 948		Size:   toPretty(entry.Size()),
 949		Name:   entry.Name(),
 950		Path:   fname,
 951		Entry:  entry,
 952		URL:    tw.Config.getFileURL(tw.PageData.RevData, fname),
 953		Crumbs: crumbs,
 954		Author: &git.Signature{
 955			Name: "unknown",
 956		},
 957	}
 958
 959	// `git rev-list` is pretty expensive here, so we have a flag to disable
 960	if tw.HideTreeLastCommit {
 961		// c.Logger.Info("skipping the process of finding the last commit for each file")
 962	} else {
 963		id := tw.PageData.RevData.ID()
 964		lastCommits, err := tw.Repo.RevList([]string{id}, git.RevListOptions{
 965			Path:           item.Path,
 966			CommandOptions: git.CommandOptions{Args: []string{"-1"}},
 967		})
 968		bail(err)
 969
 970		if len(lastCommits) > 0 {
 971			lc := lastCommits[0]
 972			item.CommitURL = tw.Config.getCommitURL(lc.ID.String())
 973			item.CommitID = getShortID(lc.ID.String())
 974			item.Summary = lc.Summary()
 975			item.When = lc.Author.When.Format(time.DateOnly)
 976			item.Author = lc.Author
 977		}
 978	}
 979
 980	fpath := tw.Config.getFileURL(tw.PageData.RevData, fmt.Sprintf("%s.html", fname))
 981	switch typ {
 982	case git.ObjectTree:
 983		item.IsDir = true
 984		fpath = tw.Config.compileURL(
 985			filepath.Join(
 986				getFileBaseDir(tw.PageData.RevData),
 987				curpath,
 988				entry.Name(),
 989			),
 990			"index.html",
 991		)
 992	case git.ObjectBlob:
 993		item.Icon = filenameToDevIcon(item.Name)
 994	}
 995	item.URL = fpath
 996
 997	return item
 998}
 999
1000func (tw *TreeWalker) walk(tree *git.Tree, curpath string) {
1001	entries, err := tree.Entries()
1002	bail(err)
1003
1004	crumbs := tw.calcBreadcrumbs(curpath)
1005	treeEntries := []*TreeItem{}
1006	for _, entry := range entries {
1007		typ := entry.Type()
1008		item := tw.NewTreeItem(entry, curpath, crumbs)
1009
1010		switch typ {
1011		case git.ObjectTree:
1012			item.IsDir = true
1013			re, _ := tree.Subtree(entry.Name())
1014			tw.walk(re, item.Path)
1015			treeEntries = append(treeEntries, item)
1016			tw.treeItem <- item
1017		case git.ObjectBlob:
1018			treeEntries = append(treeEntries, item)
1019			tw.treeItem <- item
1020		}
1021	}
1022
1023	sort.Slice(treeEntries, func(i, j int) bool {
1024		nameI := treeEntries[i].Name
1025		nameJ := treeEntries[j].Name
1026		if treeEntries[i].IsDir && treeEntries[j].IsDir {
1027			return nameI < nameJ
1028		}
1029
1030		if treeEntries[i].IsDir && !treeEntries[j].IsDir {
1031			return true
1032		}
1033
1034		if !treeEntries[i].IsDir && treeEntries[j].IsDir {
1035			return false
1036		}
1037
1038		return nameI < nameJ
1039	})
1040
1041	fpath := filepath.Join(
1042		getFileBaseDir(tw.PageData.RevData),
1043		curpath,
1044	)
1045	// root gets a special spot outside of `item` subdir
1046	if curpath == "" {
1047		fpath = getTreeBaseDir(tw.PageData.RevData)
1048	}
1049
1050	tw.tree <- &TreeRoot{
1051		Path:   fpath,
1052		Items:  treeEntries,
1053		Crumbs: crumbs,
1054	}
1055
1056	if curpath == "" {
1057		close(tw.tree)
1058		close(tw.treeItem)
1059	}
1060}
1061
1062func (c *Config) writeRevision(repo *git.Repository, pageData *PageData, refs []*RefInfo) *BranchOutput {
1063	c.Logger.Info(
1064		"compiling revision",
1065		"repoName", c.RepoName,
1066		"revision", pageData.RevData.Name(),
1067	)
1068
1069	output := &BranchOutput{}
1070
1071	var wg sync.WaitGroup
1072
1073	wg.Add(1)
1074	go func() {
1075		defer wg.Done()
1076
1077		pageSize := pageData.Repo.MaxCommits
1078		if pageSize == 0 {
1079			pageSize = 5000
1080		}
1081		// get total commit count (unlimited) for display
1082		totalCommits := countCommits(repo, pageData.RevData.ID())
1083
1084		commits, err := repo.CommitsByPage(pageData.RevData.ID(), 0, pageSize)
1085		bail(err)
1086
1087		logs := []*CommitData{}
1088		for i, commit := range commits {
1089			if i == 0 {
1090				output.LastCommit = commit
1091			}
1092
1093			tags := []*RefInfo{}
1094			for _, ref := range refs {
1095				if commit.ID.String() == ref.ID {
1096					tags = append(tags, ref)
1097				}
1098			}
1099
1100			parentIDs := []string{}
1101			for i := 0; ; i++ {
1102				sha, _ := commit.ParentID(i)
1103				if sha == nil {
1104					if len(parentIDs) == 0 {
1105						parentIDs = append(parentIDs, commit.ID.String())
1106					}
1107					break
1108				}
1109				parentIDs = append(parentIDs, sha.String())
1110			}
1111			logs = append(logs, &CommitData{
1112				ParentIDs:  parentIDs,
1113				URL:        c.getCommitURL(commit.ID.String()),
1114				ShortID:    getShortID(commit.ID.String()),
1115				SummaryStr: commit.Summary(),
1116				AuthorStr:  commit.Author.Name,
1117				WhenStr:    commit.Author.When.Format(time.DateOnly),
1118				Commit:     commit,
1119				Refs:       tags,
1120			})
1121		}
1122
1123		c.writeLog(*pageData, logs, totalCommits)
1124
1125		for _, cm := range logs {
1126			wg.Add(1)
1127			go func(commit *CommitData) {
1128				defer wg.Done()
1129				c.writeLogDiff(repo, *pageData, commit)
1130			}(cm)
1131		}
1132	}()
1133
1134	tree, err := repo.LsTree(pageData.RevData.ID())
1135	bail(err)
1136
1137	readme := ""
1138	entries := make(chan *TreeItem)
1139	subtrees := make(chan *TreeRoot)
1140	tw := &TreeWalker{
1141		Config:   c,
1142		PageData: pageData,
1143		Repo:     repo,
1144		treeItem: entries,
1145		tree:     subtrees,
1146	}
1147	wg.Add(1)
1148	go func() {
1149		defer wg.Done()
1150		tw.walk(tree, "")
1151	}()
1152
1153	wg.Add(1)
1154	go func() {
1155		defer wg.Done()
1156		for e := range entries {
1157			wg.Add(1)
1158			go func(entry *TreeItem) {
1159				defer wg.Done()
1160				if entry.IsDir {
1161					return
1162				}
1163
1164				readmeStr := c.writeHTMLTreeFile(*pageData, entry)
1165				if readmeStr != "" {
1166					readme = readmeStr
1167				}
1168			}(e)
1169		}
1170	}()
1171
1172	wg.Add(1)
1173	go func() {
1174		defer wg.Done()
1175		for t := range subtrees {
1176			wg.Add(1)
1177			go func(tree *TreeRoot) {
1178				defer wg.Done()
1179				c.writeTree(*pageData, tree)
1180			}(t)
1181		}
1182	}()
1183
1184	wg.Wait()
1185
1186	c.Logger.Info(
1187		"compilation complete",
1188		"repoName", c.RepoName,
1189		"revision", pageData.RevData.Name(),
1190	)
1191
1192	output.Readme = readme
1193	return output
1194}
1195
1196func style(theme chroma.Style) string {
1197	bg := theme.Get(chroma.Background)
1198	txt := theme.Get(chroma.Text)
1199	kw := theme.Get(chroma.Keyword)
1200	nv := theme.Get(chroma.NameVariable)
1201	cm := theme.Get(chroma.Comment)
1202	ln := theme.Get(chroma.LiteralNumber)
1203	return fmt.Sprintf(`:root {
1204  --bg-color: %s;
1205  --text-color: %s;
1206  --border: %s;
1207  --link-color: %s;
1208  --hover: %s;
1209  --visited: %s;
1210}`,
1211		bg.Background.String(),
1212		txt.Colour.String(),
1213		cm.Colour.String(),
1214		nv.Colour.String(),
1215		kw.Colour.String(),
1216		ln.Colour.String(),
1217	)
1218}
1219
1220func main() {
1221	var outdir = flag.String("out", "./public", "output directory")
1222	var rpath = flag.String("repo", ".", "path to git repo")
1223	var revsFlag = flag.String("revs", "main,master", "list of revs to generate logs and tree (e.g. main,v1,c69f86f,HEAD)")
1224	var themeFlag = flag.String("theme", "dracula", "theme to use for site")
1225	var labelFlag = flag.String("label", "", "pretty name for the subdir where we create the repo, default is last folder in --repo")
1226	var cloneFlag = flag.String("clone-url", "", "git clone URL for upstream")
1227	var issuesFlag = flag.String("issues-url", "", "where the repo tracks bug reports")
1228	var contribFlag = flag.String("contrib-url", "", "where the repo tracks code contributions")
1229	var rootRelativeFlag = flag.String("root-relative", "/", "html root relative")
1230	var maxCommitsFlag = flag.Int("max-commits", 0, "maximum number of commits to generate")
1231	var hideTreeLastCommitFlag = flag.Bool("hide-tree-last-commit", false, "dont calculate last commit for each file in the tree")
1232
1233	flag.Parse()
1234
1235	out, err := filepath.Abs(*outdir)
1236	bail(err)
1237	repoPath, err := filepath.Abs(*rpath)
1238	bail(err)
1239
1240	theme := styles.Get(*themeFlag)
1241
1242	logger := slog.Default()
1243
1244	label := repoName(repoPath)
1245	if *labelFlag != "" {
1246		label = *labelFlag
1247	}
1248
1249	revs := strings.Split(*revsFlag, ",")
1250	if len(revs) == 1 && revs[0] == "" {
1251		revs = []string{}
1252	}
1253
1254	formatter := formatterHtml.New(
1255		formatterHtml.WithLineNumbers(true),
1256		formatterHtml.WithLinkableLineNumbers(true, ""),
1257		formatterHtml.WithClasses(true),
1258	)
1259
1260	config := &Config{
1261		Outdir:             out,
1262		RepoPath:           repoPath,
1263		RepoName:           label,
1264		Cache:              make(map[string]bool),
1265		Revs:               revs,
1266		Theme:              theme,
1267		Logger:             logger,
1268		CloneURL:           template.URL(*cloneFlag),
1269		IssuesURL:          template.URL(*issuesFlag),
1270		ContribURL:         template.URL(*contribFlag),
1271		MaxCommits:         *maxCommitsFlag,
1272		HideTreeLastCommit: *hideTreeLastCommitFlag,
1273		RootRelative:       *rootRelativeFlag,
1274		Formatter:          formatter,
1275	}
1276	config.Logger.Info("config", "config", config)
1277
1278	if len(revs) == 0 {
1279		bail(fmt.Errorf("you must provide --revs"))
1280	}
1281
1282	config.writeRepo()
1283	err = config.copyStatic("static")
1284	bail(err)
1285
1286	styles := style(*theme)
1287	err = os.WriteFile(filepath.Join(out, "vars.css"), []byte(styles), 0644)
1288	if err != nil {
1289		panic(err)
1290	}
1291
1292	fp := filepath.Join(out, "syntax.css")
1293	w, err := os.OpenFile(fp, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
1294	if err != nil {
1295		bail(err)
1296	}
1297	err = formatter.WriteCSS(w, theme)
1298	if err != nil {
1299		bail(err)
1300	}
1301
1302	url := filepath.Join("/", "index.html")
1303	config.Logger.Info("root url", "url", url)
1304}
1305
1306// modified