repos / pgit

static site generator for git
git clone https://github.com/picosh/pgit.git

Eric Bower  ·  2026-01-02

main.go

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