main main.go
Eric Bower  ·  2026-09-15
   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	truncated := false
 281	if len(s) > max {
 282		s = s[0:max]
 283		truncated = true
 284	}
 285	for i, c := range s {
 286		if truncated && i+utf8.UTFMax > len(s) {
 287			// last char may be incomplete due to truncation - ignore
 288			break
 289		}
 290		if c == 0xFFFD || c < ' ' && c != '\n' && c != '\t' && c != '\f' && c != '\r' {
 291			// decoding error or control character - not a text file
 292			return false
 293		}
 294	}
 295	return true
 296}
 297
 298// newSet builds a string set from a list of values for O(1) lookup.
 299func newSet(vals ...string) map[string]struct{} {
 300	m := make(map[string]struct{}, len(vals))
 301	for _, v := range vals {
 302		m[v] = struct{}{}
 303	}
 304	return m
 305}
 306
 307// knownBinaryExts lists extensions that are always binary regardless of content.
 308var knownBinaryExts = newSet(
 309	// images
 310	".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".svg", ".tiff", ".tif", ".psd",
 311	// archives / compressed
 312	".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar", ".tgz", ".war", ".jar",
 313	// documents
 314	".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
 315	// executables / libraries
 316	".exe", ".dll", ".so", ".dylib", ".a", ".o",
 317	// fonts
 318	".ttf", ".otf", ".woff", ".woff2", ".eot",
 319	// audio / video
 320	".mp3", ".mp4", ".avi", ".mov", ".wav", ".flac", ".ogg", ".webm",
 321	// data / serialized
 322	".pb", ".msgpack", ".parquet", ".avro",
 323	// other
 324	".class", ".pyc", ".pyo", ".wasm", ".db", ".sqlite", ".sqlite3",
 325)
 326
 327// knownTextExts lists extensions that are always text regardless of content.
 328var knownTextExts = newSet(
 329	// code
 330	".go", ".py", ".js", ".ts", ".tsx", ".jsx", ".java", ".c", ".h", ".cpp", ".hpp", ".rs", ".rb", ".php", ".pl", ".sh", ".bash", ".zsh", ".fish", ".ps1",
 331	".lua", ".swift", ".kt", ".kts", ".dart", ".zig", ".scala", ".ex", ".exs", ".erl", ".hs", ".clj", ".cljs", ".el", ".lisp", ".r", ".jl",
 332	// markup / data
 333	".html", ".htm", ".css", ".scss", ".less", ".xml", ".json", ".jsonl", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf",
 334	// docs
 335	".md", ".markdown", ".txt", ".rst", ".tex", ".bib", ".csv", ".tsv",
 336	// config / build
 337	".dockerfile", ".dockerignore", ".gitignore", ".gitattributes", ".gitmodules", ".editorconfig",
 338	// templates / meta
 339	".tmpl", ".template", ".mod", ".sum", ".env", ".lock", ".license", ".licence", ".copying",
 340	// other text
 341	".diff", ".patch", ".log", ".sql", ".graphql", ".proto", ".makefile", ".cmake",
 342)
 343
 344// isTextFile reports whether the file has a known extension indicating
 345// a text file, or if a significant chunk of the specified file looks like
 346// correct UTF-8; that is, if it is likely that the file contains human-
 347// readable text. Extension check takes priority as a fast path.
 348func isTextFile(filename, text string) bool {
 349	ext := strings.ToLower(filepath.Ext(filename))
 350
 351	// fast path: known binary extension
 352	if _, ok := knownBinaryExts[ext]; ok {
 353		return false
 354	}
 355	// fast path: known text extension
 356	if _, ok := knownTextExts[ext]; ok {
 357		return true
 358	}
 359	// also check the full filename for extensionless known text files
 360	nameLower := strings.ToLower(filename)
 361	if _, ok := knownTextExts["."+nameLower]; ok {
 362		return true
 363	}
 364
 365	// fallback: inspect bytes
 366	return isText(text)
 367}
 368
 369func toPretty(b int64) string {
 370	return humanize.Bytes(uint64(b))
 371}
 372
 373func repoName(root string) string {
 374	_, file := filepath.Split(root)
 375	return file
 376}
 377
 378func readmeFile(repo *Config) string {
 379	if repo.Readme == "" {
 380		return "readme.md"
 381	}
 382
 383	return strings.ToLower(repo.Readme)
 384}
 385
 386func (c *Config) writeHtml(writeData *WriteData) {
 387	ts, err := template.New("").Funcs(funcMap).ParseFS(
 388		embedFS,
 389		writeData.Template,
 390		"html/header.partial.tmpl",
 391		"html/footer.partial.tmpl",
 392		"html/base.layout.tmpl",
 393	)
 394	bail(err)
 395
 396	dir := filepath.Join(c.Outdir, writeData.Subdir)
 397	err = os.MkdirAll(dir, os.ModePerm)
 398	bail(err)
 399
 400	fp := filepath.Join(dir, writeData.Filename)
 401	c.Logger.Info("writing", "filepath", fp)
 402
 403	w, err := os.OpenFile(fp, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
 404	bail(err)
 405
 406	tplName := filepath.Base(writeData.Template)
 407	err = ts.ExecuteTemplate(w, tplName, writeData.Data)
 408	bail(err)
 409}
 410
 411// writeHtmlIfChanged renders a template and only writes if content hash differs from existing file.
 412func (c *Config) writeHtmlIfChanged(writeData *WriteData) {
 413	ts, err := template.New("").Funcs(funcMap).ParseFS(
 414		embedFS,
 415		writeData.Template,
 416		"html/header.partial.tmpl",
 417		"html/footer.partial.tmpl",
 418		"html/base.layout.tmpl",
 419	)
 420	bail(err)
 421
 422	// render to buffer first
 423	var buf bytes.Buffer
 424	tplName := filepath.Base(writeData.Template)
 425	err = ts.ExecuteTemplate(&buf, tplName, writeData.Data)
 426	bail(err)
 427
 428	dir := filepath.Join(c.Outdir, writeData.Subdir)
 429	err = os.MkdirAll(dir, os.ModePerm)
 430	bail(err)
 431
 432	fp := filepath.Join(dir, writeData.Filename)
 433
 434	// compute hash of new content
 435	newHash := sha256.Sum256(buf.Bytes())
 436
 437	// compare with existing file
 438	if f, err := os.Open(fp); err == nil {
 439		defer func() {
 440			_ = f.Close()
 441		}()
 442		h := sha256.New()
 443		if _, err := io.Copy(h, f); err == nil {
 444			if bytes.Equal(h.Sum(nil), newHash[:]) {
 445				return // unchanged, skip
 446			}
 447		}
 448	}
 449
 450	c.Logger.Info("writing", "filepath", fp)
 451	err = os.WriteFile(fp, buf.Bytes(), 0644)
 452	bail(err)
 453}
 454
 455func (c *Config) copyStatic(dir string) error {
 456	entries, err := staticFS.ReadDir(dir)
 457	bail(err)
 458
 459	for _, e := range entries {
 460		infp := filepath.Join(dir, e.Name())
 461		if e.IsDir() {
 462			continue
 463		}
 464
 465		w, err := staticFS.ReadFile(infp)
 466		bail(err)
 467		fp := filepath.Join(c.Outdir, e.Name())
 468		c.Logger.Info("writing", "filepath", fp)
 469		err = os.WriteFile(fp, w, 0644)
 470		bail(err)
 471	}
 472
 473	return nil
 474}
 475
 476func (c *Config) writeRootSummary(data PageData, readme template.HTML) {
 477	data.ActivePage = "readme"
 478	c.Logger.Info("writing root html", "repoPath", c.RepoPath)
 479	c.writeHtml(&WriteData{
 480		Filename: "index.html",
 481		Template: "html/summary.page.tmpl",
 482		Data: &SummaryPageData{
 483			PageData: &data,
 484			Readme:   readme,
 485		},
 486	})
 487}
 488
 489func (c *Config) writeTree(data PageData, tree *TreeRoot) {
 490	data.ActivePage = "code"
 491	c.Logger.Info("writing tree", "treePath", tree.Path)
 492	c.writeHtml(&WriteData{
 493		Filename: "index.html",
 494		Subdir:   tree.Path,
 495		Template: "html/tree.page.tmpl",
 496		Data: &TreePageData{
 497			PageData: &data,
 498			Tree:     tree,
 499		},
 500	})
 501}
 502
 503func (c *Config) writeLog(data PageData, logs []*CommitData, totalCommits int) {
 504	data.ActivePage = "commits"
 505	c.Logger.Info("writing log file", "revision", data.RevData.Name())
 506	c.writeHtml(&WriteData{
 507		Filename: "index.html",
 508		Subdir:   getLogBaseDir(data.RevData),
 509		Template: "html/log.page.tmpl",
 510		Data: &LogPageData{
 511			PageData:     &data,
 512			NumCommits:   len(logs),
 513			TotalCommits: totalCommits,
 514			Logs:         logs,
 515		},
 516	})
 517}
 518
 519func (c *Config) writeRefs(data PageData, refs []*RefInfo) {
 520	data.ActivePage = "refs"
 521	c.Logger.Info("writing refs", "repoPath", c.RepoPath)
 522	c.writeHtml(&WriteData{
 523		Filename: "refs.html",
 524		Template: "html/refs.page.tmpl",
 525		Data: &RefPageData{
 526			PageData: &data,
 527			Refs:     refs,
 528		},
 529	})
 530}
 531
 532func isMarkdown(filename string) bool {
 533	ext := strings.ToLower(filepath.Ext(filename))
 534	return ext == ".md" || ext == ".markdown"
 535}
 536
 537func (c *Config) writeHTMLTreeFile(pageData PageData, treeItem *TreeItem) string {
 538	pageData.ActivePage = "code"
 539	d := filepath.Dir(treeItem.Path)
 540	readme := ""
 541	b, err := treeItem.Entry.Blob().Bytes()
 542	bail(err)
 543	str := string(b)
 544
 545	treeItem.IsTextFile = isTextFile(treeItem.Entry.Name(), str)
 546
 547	contents := "binary file, cannot display"
 548	if treeItem.IsTextFile {
 549		if len(str) == 0 {
 550			treeItem.NumLines = 0
 551		} else {
 552			treeItem.NumLines = len(strings.Split(strings.TrimSuffix(str, "\n"), "\n"))
 553		}
 554		if isMarkdown(treeItem.Entry.Name()) {
 555			html, err := ParseMarkdown(str)
 556			bail(err)
 557			contents = html
 558		} else {
 559			contents, err = c.parseText(treeItem.Entry.Name(), str)
 560			bail(err)
 561		}
 562	}
 563
 564	nameLower := strings.ToLower(treeItem.Entry.Name())
 565	summary := readmeFile(pageData.Repo)
 566	if d == "." && nameLower == summary {
 567		readme = contents
 568	}
 569
 570	c.writeHtmlIfChanged(&WriteData{
 571		Filename: fmt.Sprintf("%s.html", treeItem.Entry.Name()),
 572		Template: "html/file.page.tmpl",
 573		Data: &FilePageData{
 574			PageData: &pageData,
 575			Contents: template.HTML(contents),
 576			Item:     treeItem,
 577		},
 578		Subdir: getFileDir(pageData.RevData, d),
 579	})
 580	return readme
 581}
 582
 583func (c *Config) writeLogDiff(repo *git.Repository, pageData PageData, commit *CommitData) {
 584	pageData.ActivePage = "commits"
 585	commitID := commit.ID.String()
 586
 587	c.Mutex.RLock()
 588	hasCommit := c.Cache[commitID]
 589	c.Mutex.RUnlock()
 590
 591	if hasCommit {
 592		c.Logger.Info("commit file already generated, skipping", "commitID", getShortID(commitID))
 593		return
 594	}
 595
 596	// skip if output file already exists from a previous run
 597	commitPath := filepath.Join(c.Outdir, "commits", commitID+".html")
 598	if _, err := os.Stat(commitPath); err == nil {
 599		c.Logger.Info("commit file exists, skipping", "commitID", getShortID(commitID))
 600		c.Mutex.Lock()
 601		c.Cache[commitID] = true
 602		c.Mutex.Unlock()
 603		return
 604	}
 605
 606	c.Mutex.Lock()
 607	c.Cache[commitID] = true
 608	c.Mutex.Unlock()
 609
 610	diff, err := repo.Diff(commitID, 0, 0, 0, git.DiffOptions{})
 611	bail(err)
 612
 613	rnd := &DiffRender{
 614		NumFiles:       diff.NumFiles(),
 615		TotalAdditions: diff.TotalAdditions(),
 616		TotalDeletions: diff.TotalDeletions(),
 617	}
 618	fls := []*DiffRenderFile{}
 619	for _, file := range diff.Files {
 620		fl := &DiffRenderFile{
 621			FileType:     diffFileType(file.Type),
 622			OldMode:      file.OldMode(),
 623			OldName:      file.OldName(),
 624			Mode:         file.Mode(),
 625			Name:         file.Name,
 626			NumAdditions: file.NumAdditions(),
 627			NumDeletions: file.NumDeletions(),
 628		}
 629		finContent, err := FormatDiffFile(c.Theme, file)
 630		bail(err)
 631
 632		fl.Content = template.HTML(finContent)
 633		fls = append(fls, fl)
 634	}
 635	rnd.Files = fls
 636
 637	shortParents := make([]string, len(commit.ParentIDs))
 638	parentURLs := make([]template.URL, len(commit.ParentIDs))
 639	for i, pid := range commit.ParentIDs {
 640		shortParents[i] = getShortID(pid)
 641		parentURLs[i] = c.getCommitURL(pid)
 642	}
 643
 644	commitData := &CommitPageData{
 645		PageData:   &pageData,
 646		Commit:     commit,
 647		CommitID:   getShortID(commitID),
 648		Diff:       rnd,
 649		Parents:    shortParents,
 650		CommitURL:  c.getCommitURL(commitID),
 651		ParentURLs: parentURLs,
 652	}
 653
 654	c.writeHtml(&WriteData{
 655		Filename: fmt.Sprintf("%s.html", commitID),
 656		Template: "html/commit.page.tmpl",
 657		Subdir:   "commits",
 658		Data:     commitData,
 659	})
 660}
 661
 662func (c *Config) getSummaryURL() template.URL {
 663	url := c.RootRelative + "index.html"
 664	return template.URL(url)
 665}
 666
 667func (c *Config) getRefsURL() template.URL {
 668	url := c.RootRelative + "refs.html"
 669	return template.URL(url)
 670}
 671
 672// controls the url for trees and logs
 673// - /logs/getRevIDForURL()/index.html
 674// - /tree/getRevIDForURL()/item/file.x.html.
 675func getRevIDForURL(info RevInfo) string {
 676	return info.Name()
 677}
 678
 679func getTreeBaseDir(info RevInfo) string {
 680	subdir := getRevIDForURL(info)
 681	return filepath.Join("/", "tree", subdir)
 682}
 683
 684func getLogBaseDir(info RevInfo) string {
 685	subdir := getRevIDForURL(info)
 686	return filepath.Join("/", "logs", subdir)
 687}
 688
 689func getFileBaseDir(info RevInfo) string {
 690	return filepath.Join(getTreeBaseDir(info), "item")
 691}
 692
 693func getFileDir(info RevInfo, fname string) string {
 694	return filepath.Join(getFileBaseDir(info), fname)
 695}
 696
 697func (c *Config) getFileURL(info RevInfo, fname string) template.URL {
 698	return c.compileURL(getFileBaseDir(info), fname)
 699}
 700
 701func (c *Config) compileURL(dir, fname string) template.URL {
 702	purl := c.RootRelative + strings.TrimPrefix(dir, "/")
 703	url := filepath.Join(purl, fname)
 704	return template.URL(url)
 705}
 706
 707func (c *Config) getTreeURL(info RevInfo) template.URL {
 708	dir := getTreeBaseDir(info)
 709	return c.compileURL(dir, "index.html")
 710}
 711
 712func (c *Config) getLogsURL(info RevInfo) template.URL {
 713	dir := getLogBaseDir(info)
 714	return c.compileURL(dir, "index.html")
 715}
 716
 717func (c *Config) getCommitURL(commitID string) template.URL {
 718	url := fmt.Sprintf("%scommits/%s.html", c.RootRelative, commitID)
 719	return template.URL(url)
 720}
 721
 722func (c *Config) getURLs() *SiteURLs {
 723	return &SiteURLs{
 724		IssuesURL:  c.IssuesURL,
 725		ContribURL: c.ContribURL,
 726		CloneURL:   c.CloneURL,
 727		RefsURL:    c.getRefsURL(),
 728		SummaryURL: c.getSummaryURL(),
 729	}
 730}
 731
 732func getShortID(id string) string {
 733	return id[:7]
 734}
 735
 736// countCommits returns the total number of commits reachable from the given ref.
 737func countCommits(repo *git.Repository, ref string) int {
 738	// git rev-list --count is the most efficient way to get total commit count
 739	cmd := exec.Command("git", "rev-list", "--count", ref)
 740	cmd.Dir = repo.Path() // run in the repo directory
 741	out, err := cmd.CombinedOutput()
 742	if err != nil {
 743		// fallback: if git command fails, return 0
 744		return 0
 745	}
 746	count, err := strconv.Atoi(strings.TrimSpace(string(out)))
 747	if err != nil {
 748		return 0
 749	}
 750	return count
 751}
 752
 753func (c *Config) writeRepo() *BranchOutput {
 754	c.Logger.Info("writing repo", "repoPath", c.RepoPath)
 755	repo, err := git.Open(c.RepoPath)
 756	bail(err)
 757
 758	refs, err := repo.ShowRef(git.ShowRefOptions{Heads: true, Tags: true})
 759	bail(err)
 760
 761	var first *RevData
 762	revs := []*RevData{}
 763	for _, revStr := range c.Revs {
 764		fullRevID, err := repo.RevParse(revStr)
 765		if err != nil {
 766			c.Logger.Warn("revision not found, skipping", "revision", revStr, "error", err)
 767			continue
 768		}
 769
 770		revID := getShortID(fullRevID)
 771		revName := revID
 772		// if it's a reference then label it as such
 773		for _, ref := range refs {
 774			if revStr == git.RefShortName(ref.Refspec) || revStr == ref.Refspec {
 775				revName = revStr
 776				break
 777			}
 778		}
 779
 780		data := &RevData{
 781			id:     fullRevID,
 782			name:   revName,
 783			Config: c,
 784		}
 785
 786		if first == nil {
 787			first = data
 788		}
 789		revs = append(revs, data)
 790	}
 791
 792	if first == nil {
 793		bail(fmt.Errorf("could find find a git reference that matches criteria"))
 794	}
 795
 796	refInfoMap := map[string]*RefInfo{}
 797	for _, revData := range revs {
 798		refInfoMap[revData.Name()] = &RefInfo{
 799			ID:      revData.ID(),
 800			Refspec: revData.Name(),
 801			URL:     revData.TreeURL(),
 802		}
 803	}
 804
 805	// loop through ALL refs that don't have URLs
 806	// and add them to the map
 807	for _, ref := range refs {
 808		refspec := git.RefShortName(ref.Refspec)
 809		if refInfoMap[refspec] != nil {
 810			continue
 811		}
 812
 813		refInfoMap[refspec] = &RefInfo{
 814			ID:      ref.ID,
 815			Refspec: refspec,
 816		}
 817	}
 818
 819	// gather lists of refs to display on refs.html page
 820	refInfoList := []*RefInfo{}
 821	for _, val := range refInfoMap {
 822		refInfoList = append(refInfoList, val)
 823	}
 824	sort.Slice(refInfoList, func(i, j int) bool {
 825		urlI := refInfoList[i].URL
 826		urlJ := refInfoList[j].URL
 827		refI := refInfoList[i].Refspec
 828		refJ := refInfoList[j].Refspec
 829		if urlI == urlJ {
 830			return refI < refJ
 831		}
 832		return urlI > urlJ
 833	})
 834
 835	// we assume the first revision in the list is the "main" revision which mostly
 836	// means that's the README we use for the default summary page.
 837	mainOutput := &BranchOutput{}
 838	var wg sync.WaitGroup
 839	for i, revData := range revs {
 840		c.Logger.Info("writing revision", "revision", revData.Name())
 841		data := &PageData{
 842			Repo:     c,
 843			RevData:  revData,
 844			SiteURLs: c.getURLs(),
 845		}
 846
 847		if i == 0 {
 848			branchOutput := c.writeRevision(repo, data, refInfoList)
 849			mainOutput = branchOutput
 850		} else {
 851			wg.Add(1)
 852			go func() {
 853				defer wg.Done()
 854				c.writeRevision(repo, data, refInfoList)
 855			}()
 856		}
 857	}
 858	wg.Wait()
 859
 860	// use the first revision in our list to generate
 861	// the root summary, logs, and tree the user can click
 862	revData := &RevData{
 863		id:     first.ID(),
 864		name:   first.Name(),
 865		Config: c,
 866	}
 867
 868	data := &PageData{
 869		RevData:  revData,
 870		Repo:     c,
 871		SiteURLs: c.getURLs(),
 872	}
 873	c.writeRefs(*data, refInfoList)
 874	c.writeRootSummary(*data, template.HTML(mainOutput.Readme))
 875	return mainOutput
 876}
 877
 878type TreeRoot struct {
 879	Path   string
 880	Items  []*TreeItem
 881	Crumbs []*Breadcrumb
 882}
 883
 884type TreeWalker struct {
 885	treeItem           chan *TreeItem
 886	tree               chan *TreeRoot
 887	HideTreeLastCommit bool
 888	PageData           *PageData
 889	Repo               *git.Repository
 890	Config             *Config
 891}
 892
 893type Breadcrumb struct {
 894	Text   string
 895	URL    template.URL
 896	IsLast bool
 897}
 898
 899func (tw *TreeWalker) calcBreadcrumbs(curpath string) []*Breadcrumb {
 900	if curpath == "" {
 901		return []*Breadcrumb{}
 902	}
 903	parts := strings.Split(curpath, string(os.PathSeparator))
 904	rootURL := tw.Config.compileURL(
 905		getTreeBaseDir(tw.PageData.RevData),
 906		"index.html",
 907	)
 908
 909	crumbs := make([]*Breadcrumb, len(parts)+1)
 910	crumbs[0] = &Breadcrumb{
 911		URL:  rootURL,
 912		Text: tw.PageData.Repo.RepoName,
 913	}
 914
 915	cur := ""
 916	for idx, d := range parts {
 917		crumb := filepath.Join(getFileBaseDir(tw.PageData.RevData), cur, d)
 918		crumbUrl := tw.Config.compileURL(crumb, "index.html")
 919		crumbs[idx+1] = &Breadcrumb{
 920			Text: d,
 921			URL:  crumbUrl,
 922		}
 923		if idx == len(parts)-1 {
 924			crumbs[idx+1].IsLast = true
 925		}
 926		cur = filepath.Join(cur, d)
 927	}
 928
 929	return crumbs
 930}
 931
 932func filenameToDevIcon(filename string) string {
 933	ext := filepath.Ext(filename)
 934	extMappr := map[string]string{
 935		".html": "html5",
 936		".go":   "go",
 937		".py":   "python",
 938		".css":  "css3",
 939		".js":   "javascript",
 940		".md":   "markdown",
 941		".ts":   "typescript",
 942		".tsx":  "react",
 943		".jsx":  "react",
 944	}
 945
 946	nameMappr := map[string]string{
 947		"Makefile":   "cmake",
 948		"Dockerfile": "docker",
 949	}
 950
 951	icon := extMappr[ext]
 952	if icon == "" {
 953		icon = nameMappr[filename]
 954	}
 955
 956	return fmt.Sprintf("devicon-%s-original", icon)
 957}
 958
 959func (tw *TreeWalker) NewTreeItem(entry *git.TreeEntry, curpath string, crumbs []*Breadcrumb) *TreeItem {
 960	typ := entry.Type()
 961	fname := filepath.Join(curpath, entry.Name())
 962	item := &TreeItem{
 963		Size:   toPretty(entry.Size()),
 964		Name:   entry.Name(),
 965		Path:   fname,
 966		Entry:  entry,
 967		URL:    tw.Config.getFileURL(tw.PageData.RevData, fname),
 968		Crumbs: crumbs,
 969		Author: &git.Signature{
 970			Name: "unknown",
 971		},
 972	}
 973
 974	// `git rev-list` is pretty expensive here, so we have a flag to disable
 975	if tw.HideTreeLastCommit {
 976		// c.Logger.Info("skipping the process of finding the last commit for each file")
 977	} else {
 978		id := tw.PageData.RevData.ID()
 979		lastCommits, err := tw.Repo.RevList([]string{id}, git.RevListOptions{
 980			Path:           item.Path,
 981			CommandOptions: git.CommandOptions{Args: []string{"-1"}},
 982		})
 983		bail(err)
 984
 985		if len(lastCommits) > 0 {
 986			lc := lastCommits[0]
 987			item.CommitURL = tw.Config.getCommitURL(lc.ID.String())
 988			item.CommitID = getShortID(lc.ID.String())
 989			item.Summary = lc.Summary()
 990			item.When = lc.Author.When.Format(time.DateOnly)
 991			item.Author = lc.Author
 992		}
 993	}
 994
 995	fpath := tw.Config.getFileURL(tw.PageData.RevData, fmt.Sprintf("%s.html", fname))
 996	switch typ {
 997	case git.ObjectTree:
 998		item.IsDir = true
 999		fpath = tw.Config.compileURL(
1000			filepath.Join(
1001				getFileBaseDir(tw.PageData.RevData),
1002				curpath,
1003				entry.Name(),
1004			),
1005			"index.html",
1006		)
1007	case git.ObjectBlob:
1008		item.Icon = filenameToDevIcon(item.Name)
1009	}
1010	item.URL = fpath
1011
1012	return item
1013}
1014
1015func (tw *TreeWalker) walk(tree *git.Tree, curpath string) {
1016	entries, err := tree.Entries()
1017	bail(err)
1018
1019	crumbs := tw.calcBreadcrumbs(curpath)
1020	treeEntries := []*TreeItem{}
1021	for _, entry := range entries {
1022		typ := entry.Type()
1023		item := tw.NewTreeItem(entry, curpath, crumbs)
1024
1025		switch typ {
1026		case git.ObjectTree:
1027			item.IsDir = true
1028			re, _ := tree.Subtree(entry.Name())
1029			tw.walk(re, item.Path)
1030			treeEntries = append(treeEntries, item)
1031			tw.treeItem <- item
1032		case git.ObjectBlob:
1033			treeEntries = append(treeEntries, item)
1034			tw.treeItem <- item
1035		}
1036	}
1037
1038	sort.Slice(treeEntries, func(i, j int) bool {
1039		nameI := treeEntries[i].Name
1040		nameJ := treeEntries[j].Name
1041		if treeEntries[i].IsDir && treeEntries[j].IsDir {
1042			return nameI < nameJ
1043		}
1044
1045		if treeEntries[i].IsDir && !treeEntries[j].IsDir {
1046			return true
1047		}
1048
1049		if !treeEntries[i].IsDir && treeEntries[j].IsDir {
1050			return false
1051		}
1052
1053		return nameI < nameJ
1054	})
1055
1056	fpath := filepath.Join(
1057		getFileBaseDir(tw.PageData.RevData),
1058		curpath,
1059	)
1060	// root gets a special spot outside of `item` subdir
1061	if curpath == "" {
1062		fpath = getTreeBaseDir(tw.PageData.RevData)
1063	}
1064
1065	tw.tree <- &TreeRoot{
1066		Path:   fpath,
1067		Items:  treeEntries,
1068		Crumbs: crumbs,
1069	}
1070
1071	if curpath == "" {
1072		close(tw.tree)
1073		close(tw.treeItem)
1074	}
1075}
1076
1077func (c *Config) writeRevision(repo *git.Repository, pageData *PageData, refs []*RefInfo) *BranchOutput {
1078	c.Logger.Info(
1079		"compiling revision",
1080		"repoName", c.RepoName,
1081		"revision", pageData.RevData.Name(),
1082	)
1083
1084	output := &BranchOutput{}
1085
1086	var wg sync.WaitGroup
1087
1088	wg.Add(1)
1089	go func() {
1090		defer wg.Done()
1091
1092		pageSize := pageData.Repo.MaxCommits
1093		if pageSize == 0 {
1094			pageSize = 5000
1095		}
1096		// get total commit count (unlimited) for display
1097		totalCommits := countCommits(repo, pageData.RevData.ID())
1098
1099		commits, err := repo.CommitsByPage(pageData.RevData.ID(), 0, pageSize)
1100		bail(err)
1101
1102		logs := []*CommitData{}
1103		for i, commit := range commits {
1104			if i == 0 {
1105				output.LastCommit = commit
1106			}
1107
1108			tags := []*RefInfo{}
1109			for _, ref := range refs {
1110				if commit.ID.String() == ref.ID {
1111					tags = append(tags, ref)
1112				}
1113			}
1114
1115			parentIDs := []string{}
1116			for i := 0; ; i++ {
1117				sha, _ := commit.ParentID(i)
1118				if sha == nil {
1119					if len(parentIDs) == 0 {
1120						parentIDs = append(parentIDs, commit.ID.String())
1121					}
1122					break
1123				}
1124				parentIDs = append(parentIDs, sha.String())
1125			}
1126			logs = append(logs, &CommitData{
1127				ParentIDs:  parentIDs,
1128				URL:        c.getCommitURL(commit.ID.String()),
1129				ShortID:    getShortID(commit.ID.String()),
1130				SummaryStr: commit.Summary(),
1131				AuthorStr:  commit.Author.Name,
1132				WhenStr:    commit.Author.When.Format(time.DateOnly),
1133				Commit:     commit,
1134				Refs:       tags,
1135			})
1136		}
1137
1138		c.writeLog(*pageData, logs, totalCommits)
1139
1140		for _, cm := range logs {
1141			wg.Add(1)
1142			go func(commit *CommitData) {
1143				defer wg.Done()
1144				c.writeLogDiff(repo, *pageData, commit)
1145			}(cm)
1146		}
1147	}()
1148
1149	tree, err := repo.LsTree(pageData.RevData.ID())
1150	bail(err)
1151
1152	readme := ""
1153	var readmeMu sync.Mutex
1154	entries := make(chan *TreeItem)
1155	subtrees := make(chan *TreeRoot)
1156	tw := &TreeWalker{
1157		Config:   c,
1158		PageData: pageData,
1159		Repo:     repo,
1160		treeItem: entries,
1161		tree:     subtrees,
1162	}
1163
1164	var twWg sync.WaitGroup
1165	twWg.Add(1)
1166	go func() {
1167		defer twWg.Done()
1168		tw.walk(tree, "")
1169	}()
1170
1171	var roots []*TreeRoot
1172	var rootsMu sync.Mutex
1173	var subtreeWg sync.WaitGroup
1174	subtreeWg.Add(1)
1175	go func() {
1176		defer subtreeWg.Done()
1177		for t := range subtrees {
1178			rootsMu.Lock()
1179			roots = append(roots, t)
1180			rootsMu.Unlock()
1181		}
1182	}()
1183
1184	var fileWg sync.WaitGroup
1185	var entryWg sync.WaitGroup
1186	entryWg.Add(1)
1187	go func() {
1188		defer entryWg.Done()
1189		for e := range entries {
1190			if e.IsDir {
1191				continue
1192			}
1193			fileWg.Add(1)
1194			go func(entry *TreeItem) {
1195				defer fileWg.Done()
1196				readmeStr := c.writeHTMLTreeFile(*pageData, entry)
1197				if readmeStr != "" {
1198					readmeMu.Lock()
1199					readme = readmeStr
1200					readmeMu.Unlock()
1201				}
1202			}(e)
1203		}
1204	}()
1205
1206	twWg.Wait()
1207	subtreeWg.Wait()
1208	entryWg.Wait()
1209	fileWg.Wait()
1210
1211	var treeWg sync.WaitGroup
1212	for _, t := range roots {
1213		treeWg.Add(1)
1214		go func(tree *TreeRoot) {
1215			defer treeWg.Done()
1216			c.writeTree(*pageData, tree)
1217		}(t)
1218	}
1219	treeWg.Wait()
1220
1221	wg.Wait()
1222
1223	c.Logger.Info(
1224		"compilation complete",
1225		"repoName", c.RepoName,
1226		"revision", pageData.RevData.Name(),
1227	)
1228
1229	output.Readme = readme
1230	return output
1231}
1232
1233func style(theme chroma.Style) string {
1234	bg := theme.Get(chroma.Background)
1235	txt := theme.Get(chroma.Text)
1236	kw := theme.Get(chroma.Keyword)
1237	nv := theme.Get(chroma.NameVariable)
1238	cm := theme.Get(chroma.Comment)
1239	ln := theme.Get(chroma.LiteralNumber)
1240	return fmt.Sprintf(`:root {
1241  --bg-color: %s;
1242  --text-color: %s;
1243  --border: %s;
1244  --link-color: %s;
1245  --hover: %s;
1246  --visited: %s;
1247  --grey-light: %s;
1248  --admin: #f38ba8;
1249  --success: #66f859;
1250  --diff-add-bg: rgba(102, 248, 89, 0.12);
1251  --diff-add-gutter: var(--success);
1252  --diff-del-bg: rgba(243, 139, 168, 0.12);
1253  --diff-del-gutter: var(--admin);
1254  --diff-hunk-bg: rgba(139, 233, 253, 0.08);
1255  --diff-hunk-text: var(--link-color);
1256  --diff-num-text: var(--grey-light);
1257}`,
1258		bg.Background.String(),
1259		txt.Colour.String(),
1260		cm.Colour.String(),
1261		nv.Colour.String(),
1262		kw.Colour.String(),
1263		ln.Colour.String(),
1264		cm.Colour.String(),
1265	)
1266}
1267
1268func main() {
1269	var outdir = flag.String("out", "./public", "output directory")
1270	var rpath = flag.String("repo", ".", "path to git repo")
1271	var revsFlag = flag.String("revs", "main,master", "list of revs to generate logs and tree (e.g. main,v1,c69f86f,HEAD)")
1272	var themeFlag = flag.String("theme", "dracula", "theme to use for site")
1273	var labelFlag = flag.String("label", "", "pretty name for the subdir where we create the repo, default is last folder in --repo")
1274	var cloneFlag = flag.String("clone-url", "", "git clone URL for upstream")
1275	var issuesFlag = flag.String("issues-url", "", "where the repo tracks bug reports")
1276	var contribFlag = flag.String("contrib-url", "", "where the repo tracks code contributions")
1277	var rootRelativeFlag = flag.String("root-relative", "/", "html root relative")
1278	var maxCommitsFlag = flag.Int("max-commits", 0, "maximum number of commits to generate")
1279	var hideTreeLastCommitFlag = flag.Bool("hide-tree-last-commit", false, "dont calculate last commit for each file in the tree")
1280
1281	flag.Parse()
1282
1283	out, err := filepath.Abs(*outdir)
1284	bail(err)
1285	repoPath, err := filepath.Abs(*rpath)
1286	bail(err)
1287
1288	theme := styles.Get(*themeFlag)
1289
1290	logger := slog.Default()
1291
1292	label := repoName(repoPath)
1293	if *labelFlag != "" {
1294		label = *labelFlag
1295	}
1296
1297	revs := strings.Split(*revsFlag, ",")
1298	if len(revs) == 1 && revs[0] == "" {
1299		revs = []string{}
1300	}
1301
1302	formatter := formatterHtml.New(
1303		formatterHtml.WithLineNumbers(true),
1304		formatterHtml.WithLinkableLineNumbers(true, ""),
1305		formatterHtml.WithClasses(true),
1306	)
1307
1308	config := &Config{
1309		Outdir:             out,
1310		RepoPath:           repoPath,
1311		RepoName:           label,
1312		Cache:              make(map[string]bool),
1313		Revs:               revs,
1314		Theme:              theme,
1315		Logger:             logger,
1316		CloneURL:           template.URL(*cloneFlag),
1317		IssuesURL:          template.URL(*issuesFlag),
1318		ContribURL:         template.URL(*contribFlag),
1319		MaxCommits:         *maxCommitsFlag,
1320		HideTreeLastCommit: *hideTreeLastCommitFlag,
1321		RootRelative:       *rootRelativeFlag,
1322		Formatter:          formatter,
1323	}
1324	config.Logger.Info("config", "config", config)
1325
1326	if len(revs) == 0 {
1327		bail(fmt.Errorf("you must provide --revs"))
1328	}
1329
1330	config.writeRepo()
1331	err = config.copyStatic("static")
1332	bail(err)
1333
1334	styles := style(*theme)
1335	err = os.WriteFile(filepath.Join(out, "vars.css"), []byte(styles), 0644)
1336	if err != nil {
1337		panic(err)
1338	}
1339
1340	fp := filepath.Join(out, "syntax.css")
1341	w, err := os.OpenFile(fp, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
1342	if err != nil {
1343		bail(err)
1344	}
1345	err = formatter.WriteCSS(w, theme)
1346	if err != nil {
1347		bail(err)
1348	}
1349
1350	url := filepath.Join("/", "index.html")
1351	config.Logger.Info("root url", "url", url)
1352}
1353
1354// modified