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