repos / pgit

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

Eric Bower · 25 Apr 24

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