repos / pgit

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

Eric Bower  ·  2025-08-10

main.go

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