repos / pgit

Improved static site generator for git repos
git clone https://github.com/xplshn/pgit.git

[CI] pgitBot  ·  2026-02-26

pgit.go

Go
   1//usr/bin/env go run "$0" "$@"; exit "$?"
   2package main
   3
   4import (
   5	"bufio"
   6	"bytes"
   7	"context"
   8	"crypto/aes"
   9	"crypto/cipher"
  10	"crypto/rand"
  11	"embed"
  12	"encoding/hex"
  13	"encoding/json"
  14	"errors"
  15	"fmt"
  16	"html/template"
  17	"io"
  18	"log/slog"
  19	"os"
  20	"path/filepath"
  21	"regexp"
  22	"sort"
  23	"strings"
  24	"sync"
  25	"time"
  26
  27	"github.com/alecthomas/chroma/v2"
  28	"github.com/alecthomas/chroma/v2/formatters/html"
  29	"github.com/alecthomas/chroma/v2/lexers"
  30	"github.com/alecthomas/chroma/v2/styles"
  31	"github.com/dustin/go-humanize"
  32	"github.com/gogs/git-module"
  33	"github.com/urfave/cli/v3"
  34	"github.com/xplshn/tracerr2"
  35	"github.com/yuin/goldmark"
  36	"github.com/yuin/goldmark/extension"
  37	"github.com/yuin/goldmark/parser"
  38	gmdhtml "github.com/yuin/goldmark/renderer/html"
  39	"gopkg.in/yaml.v3"
  40)
  41
  42//go:embed html/*.tmpl
  43var embedFS embed.FS
  44
  45//go:embed static/*
  46var staticFS embed.FS
  47
  48var (
  49	md = goldmark.New(
  50		goldmark.WithExtensions(extension.GFM),
  51		goldmark.WithParserOptions(
  52			parser.WithAutoHeadingID(),
  53		),
  54		goldmark.WithRendererOptions(
  55			gmdhtml.WithHardWraps(),
  56			gmdhtml.WithXHTML(),
  57			gmdhtml.WithUnsafe(),
  58		),
  59	)
  60	errDBEncrypt = errors.New("database encryption error")
  61	errDBDecrypt = errors.New("database decryption error")
  62)
  63
  64type EditRecord struct {
  65	Timestamp time.Time `json:"timestamp"`
  66	Body      string    `json:"body"`
  67}
  68
  69type IssueReaction struct {
  70	Emoji  string `json:"emoji"`
  71	Author string `json:"author"`
  72}
  73
  74type IssueComment struct {
  75	ID        int             `json:"id"`
  76	Author    string          `json:"author"`
  77	Body      string          `json:"body"`
  78	CreatedAt time.Time       `json:"createdAt"`
  79	Reactions []IssueReaction `json:"reactions"`
  80	History   []EditRecord    `json:"history,omitempty"`
  81}
  82
  83type Issue struct {
  84	ID          int             `json:"id"`
  85	Title       string          `json:"title"`
  86	Author      string          `json:"author"`
  87	Body        string          `json:"body"`
  88	CreatedAt   time.Time       `json:"createdAt"`
  89	Comments    []IssueComment  `json:"comments"`
  90	Reactions   []IssueReaction `json:"reactions"`
  91	History     []EditRecord    `json:"history,omitempty"`
  92	IsClosed    bool            `json:"isClosed"`
  93	Status      string          `json:"status"`
  94	StatusClass string          `json:"statusClass"`
  95	URL         template.URL
  96}
  97
  98type GroupedReaction struct {
  99	Emoji        string
 100	Name         string
 101	Count        int
 102	AuthorString string
 103}
 104
 105func groupReactions(reactions []IssueReaction, reactionMap map[string]string) []GroupedReaction {
 106	if len(reactions) == 0 {
 107		return nil
 108	}
 109	emojiToName := make(map[string]string, len(reactionMap))
 110	for name, emoji := range reactionMap {
 111		emojiToName[emoji] = name
 112	}
 113
 114	grouped := make(map[string][]string)
 115	for _, r := range reactions {
 116		grouped[r.Emoji] = append(grouped[r.Emoji], r.Author)
 117	}
 118	result := make([]GroupedReaction, 0, len(grouped))
 119	for emoji, authors := range grouped {
 120		result = append(result, GroupedReaction{
 121			Emoji:        emoji,
 122			Name:         emojiToName[emoji],
 123			Count:        len(authors),
 124			AuthorString: strings.Join(authors, ", "),
 125		})
 126	}
 127	sort.Slice(result, func(i, j int) bool {
 128		if result[i].Count != result[j].Count {
 129			return result[i].Count > result[j].Count
 130		}
 131		return result[i].Emoji < result[j].Emoji
 132	})
 133	return result
 134}
 135
 136func (i *Issue) GroupedReactions(reactionMap map[string]string) []GroupedReaction {
 137	return groupReactions(i.Reactions, reactionMap)
 138}
 139
 140func (c *IssueComment) GroupedReactions(reactionMap map[string]string) []GroupedReaction {
 141	return groupReactions(c.Reactions, reactionMap)
 142}
 143
 144type IssueDatabase struct {
 145	Issues        map[int]*Issue    `json:"issues"`
 146	NextIssueID   int               `json:"nextIssueId"`
 147	NextCommentID int               `json:"nextCommentId"`
 148	RepoName      string            `json:"repoName"`
 149	Reactions     map[string]string `json:"reactions"`
 150	MarkAs        map[string]string `json:"mark_as"`
 151	IssuesEmail   string            `json:"issuesEmail"`
 152	SubjectTag    string            `json:"subjectTag"`
 153}
 154
 155type GlobalIssueDatabase struct {
 156	Repos        map[string]*IssueDatabase `json:"repos"`
 157	EmailToAlias map[string]string         `json:"emailToAlias"`
 158}
 159
 160type LanguageStat struct {
 161	Name       string
 162	Percentage float64
 163	Color      string
 164	URL        template.URL
 165}
 166
 167type GitAttribute struct {
 168	Pattern    string
 169	Attributes map[string]string
 170}
 171
 172type RepoConfig struct {
 173	Outdir             string
 174	RepoPath           string
 175	Revs               []string
 176	Desc               string
 177	MaxCommits         int
 178	Readme             string
 179	HideTreeLastCommit bool
 180	HomeURL            string
 181	CloneURL           string
 182	RootRelative       string
 183	ThemeName          string
 184	Label              string
 185	RenderMarkdown     *bool
 186	IssuesDBPath       string
 187	IssuesKey          string
 188	DisableEncryption  bool
 189	IssuesEnabled      bool
 190	IssueDB            *IssueDatabase
 191	AliasMap           map[string]string
 192	Cache              sync.Map
 193	RepoName           string
 194	Logger             *slog.Logger
 195	ChromaTheme        *chroma.Style
 196	Formatter          *html.Formatter
 197	GitAttributes      []GitAttribute
 198	Whitelist          map[string]bool `yaml:"-"`
 199	Blacklist          map[string]bool `yaml:"-"`
 200}
 201
 202type PgitRepoConfig struct {
 203	Outdir             *string  `yaml:"out"`
 204	RepoPath           string   `yaml:"repo"`
 205	Revs               []string `yaml:"revs"`
 206	Desc               *string  `yaml:"desc"`
 207	MaxCommits         *int     `yaml:"max-commits"`
 208	Readme             *string  `yaml:"readme"`
 209	HideTreeLastCommit *bool    `yaml:"hide-tree-last-commit"`
 210	HomeURL            *string  `yaml:"home-url"`
 211	CloneURL           *string  `yaml:"clone-url"`
 212	RootRelative       *string  `yaml:"root-relative"`
 213	ThemeName          *string  `yaml:"theme"`
 214	Label              *string  `yaml:"label"`
 215	RenderMarkdown     *bool    `yaml:"renderMarkdown"`
 216	IssuesDBPath       *string  `yaml:"issues-db"`
 217	IssuesKey          *string  `yaml:"issues-key"`
 218	DisableEncryption  *bool    `yaml:"disable-encryption"`
 219}
 220
 221type PgitConfig struct {
 222	Global PgitRepoConfig            `yaml:"global"`
 223	Repos  map[string]PgitRepoConfig `yaml:"repos"`
 224}
 225
 226type RevInfo interface {
 227	ID() string
 228	Name() string
 229}
 230
 231type RevData struct {
 232	id     string
 233	name   string
 234	Config *RepoConfig
 235}
 236
 237func (r *RevData) ID() string   { return r.id }
 238func (r *RevData) Name() string { return r.name }
 239func (r *RevData) TreeURL() template.URL {
 240	return r.Config.getTreeURL(r)
 241}
 242func (r *RevData) LogURL() template.URL {
 243	return r.Config.getLogsURL(r)
 244}
 245
 246type CommitData struct {
 247	SummaryStr string
 248	URL        template.URL
 249	WhenStr    string
 250	AuthorStr  string
 251	ShortID    string
 252	ParentID   string
 253	Refs       []*RefInfo
 254	*git.Commit
 255}
 256
 257type TreeItem struct {
 258	IsTextFile bool
 259	IsDir      bool
 260	Size       string
 261	NumLines   int
 262	Name       string
 263	Icon       string
 264	Path       string
 265	Language   string
 266	URL        template.URL
 267	CommitID   string
 268	CommitURL  template.URL
 269	Summary    string
 270	When       string
 271	Author     *git.Signature
 272	Entry      *git.TreeEntry
 273	Crumbs     []*Breadcrumb
 274}
 275
 276type DiffRender struct {
 277	NumFiles       int
 278	TotalAdditions int
 279	TotalDeletions int
 280	Files          []*DiffRenderFile
 281}
 282
 283type DiffRenderFile struct {
 284	FileType     string
 285	OldMode      git.EntryMode
 286	OldName      string
 287	Mode         git.EntryMode
 288	Name         string
 289	Content      template.HTML
 290	NumAdditions int
 291	NumDeletions int
 292}
 293
 294type RefInfo struct {
 295	ID      string       `json:"ID"`
 296	Refspec string       `json:"Refspec"`
 297	URL     template.URL `json:"URL,omitempty"`
 298}
 299
 300type SearchIndexEntry struct {
 301	Name     string `json:"name"`
 302	Path     string `json:"path"`
 303	URL      string `json:"url"`
 304	Language string `json:"language"`
 305}
 306
 307type SiteURLs struct {
 308	HomeURL    template.URL
 309	CloneURL   template.URL
 310	SummaryURL template.URL
 311	RefsURL    template.URL
 312	SearchURL  template.URL
 313	IssuesURL  template.URL
 314}
 315
 316type PageData struct {
 317	Repo     *RepoConfig
 318	SiteURLs *SiteURLs
 319	RevData  *RevData
 320	AliasMap map[string]string
 321}
 322
 323type SummaryPageData struct {
 324	*PageData
 325	Readme        template.HTML
 326	LanguageStats []*LanguageStat
 327	TotalLanguage int64
 328}
 329
 330type LanguagePageData struct {
 331	*PageData
 332	Language string
 333	Files    []*SearchIndexEntry
 334}
 335
 336type TreePageData struct {
 337	*PageData
 338	Tree *TreeRoot
 339}
 340
 341type LogPageData struct {
 342	*PageData
 343	NumCommits int
 344	Logs       []*CommitData
 345}
 346
 347type FilePageData struct {
 348	*PageData
 349	Contents template.HTML
 350	Item     *TreeItem
 351	Language string
 352}
 353
 354type CommitPageData struct {
 355	*PageData
 356	CommitMsg template.HTML
 357	CommitID  string
 358	Commit    *CommitData
 359	Diff      *DiffRender
 360	Parent    string
 361	ParentURL template.URL
 362	CommitURL template.URL
 363}
 364
 365type RefPageData struct {
 366	*PageData
 367	Refs []*RefInfo
 368}
 369
 370type IssuesListPageData struct {
 371	*PageData
 372	Issues []*Issue
 373}
 374
 375type SingleIssuePageData struct {
 376	*PageData
 377	Issue *Issue
 378}
 379
 380type WriteData struct {
 381	Template string
 382	Filename string
 383	Subdir   string
 384	Data     any
 385}
 386
 387func (c *RepoConfig) getFileAttributes(filename string) map[string]string {
 388	var lastMatch *GitAttribute
 389
 390	for i := range c.GitAttributes {
 391		attr := &c.GitAttributes[i]
 392		pattern := attr.Pattern
 393		matched := false
 394
 395		if strings.HasPrefix(pattern, "**/") {
 396			suffixPattern := strings.TrimPrefix(pattern, "**/")
 397			if m, _ := filepath.Match(suffixPattern, filepath.Base(filename)); m {
 398				matched = true
 399			}
 400		} else {
 401			if m, _ := filepath.Match(pattern, filename); m {
 402				matched = true
 403			}
 404		}
 405
 406		if matched {
 407			lastMatch = attr
 408		}
 409	}
 410
 411	if lastMatch != nil {
 412		return lastMatch.Attributes
 413	}
 414
 415	return nil
 416}
 417
 418func (c *RepoConfig) getLanguageInfo(filename string, data []byte) (displayName string, lexer chroma.Lexer, isText bool) {
 419	attrs := c.getFileAttributes(filename)
 420	langOverride := attrs["linguist-language"]
 421	displayOverride := attrs["linguist-display-name"]
 422
 423	lexerNameForLookup := ""
 424
 425	if langOverride != "" {
 426		lexerNameForLookup = langOverride
 427	} else {
 428		detectedLexer := lexers.Match(filename)
 429		if detectedLexer == nil && len(data) > 0 {
 430			detectedLexer = lexers.Analyse(string(data))
 431		}
 432		if detectedLexer != nil {
 433			lexerName := detectedLexer.Config().Name
 434			isWhitelisted := c.Whitelist == nil || c.Whitelist[lexerName]
 435			isBlacklisted := c.Blacklist != nil && c.Blacklist[lexerName]
 436			if isWhitelisted && !isBlacklisted {
 437				lexerNameForLookup = lexerName
 438			}
 439		}
 440	}
 441
 442	if lexerNameForLookup != "" {
 443		lexer = lexers.Get(lexerNameForLookup)
 444	}
 445
 446	if displayOverride != "" {
 447		displayName = displayOverride
 448	} else if lexer != nil {
 449		displayName = lexer.Config().Name
 450	} else if langOverride != "" {
 451		displayName = langOverride
 452	}
 453
 454	if data != nil && bytes.Contains(data, []byte{0}) {
 455		isText = false
 456		if displayName == "" {
 457			displayName = "Binary"
 458		}
 459	} else {
 460		isText = true
 461		if lexer == nil {
 462			lexer = lexers.Get("plaintext")
 463		}
 464		if displayName == "" {
 465			displayName = "Text"
 466		}
 467	}
 468	return
 469}
 470
 471func (c *RepoConfig) highlightSyntax(text, filename string, blob *git.Blob) (template.HTML, string, error) {
 472	if blob != nil && blob.Size() > 800*1024 {
 473		return "file too large to display (>800KB)", "Binary", nil
 474	}
 475
 476	displayName, lexer, isText := c.getLanguageInfo(filename, []byte(text))
 477	if !isText {
 478		return "binary file, cannot display", "Binary", nil
 479	}
 480
 481	iterator, err := lexer.Tokenise(nil, text)
 482	if err != nil {
 483		return template.HTML(text), displayName, tracerr.Wrapf(err, "tokenization failed")
 484	}
 485
 486	var buf bytes.Buffer
 487	if err := c.Formatter.Format(&buf, c.ChromaTheme, iterator); err != nil {
 488		return template.HTML(text), displayName, tracerr.Wrapf(err, "formatting failed")
 489	}
 490	return template.HTML(buf.String()), displayName, nil
 491}
 492
 493func diffFileType(t git.DiffFileType) string {
 494	switch t {
 495	case git.DiffFileAdd:
 496		return "A"
 497	case git.DiffFileChange:
 498		return "M"
 499	case git.DiffFileDelete:
 500		return "D"
 501	case git.DiffFileRename:
 502		return "R"
 503	default:
 504		return ""
 505	}
 506}
 507
 508func toPretty(b int64) string {
 509	return humanize.Bytes(uint64(b))
 510}
 511
 512func readmeFile(repo *RepoConfig) string {
 513	if repo.Readme == "" {
 514		return "readme.md"
 515	}
 516	return strings.ToLower(repo.Readme)
 517}
 518
 519func (c *RepoConfig) executeTemplate(w *os.File, data *WriteData) error {
 520	getPageData := func(data any) *PageData {
 521		switch v := data.(type) {
 522		case *SummaryPageData:
 523			return v.PageData
 524		case *LanguagePageData:
 525			return v.PageData
 526		case *TreePageData:
 527			return v.PageData
 528		case *LogPageData:
 529			return v.PageData
 530		case *FilePageData:
 531			return v.PageData
 532		case *CommitPageData:
 533			return v.PageData
 534		case *RefPageData:
 535			return v.PageData
 536		case *IssuesListPageData:
 537			return v.PageData
 538		case *SingleIssuePageData:
 539			return v.PageData
 540		default:
 541			return nil
 542		}
 543	}
 544
 545	ts, err := template.New(filepath.Base(data.Template)).Funcs(template.FuncMap{
 546		"markdown": func(s string) template.HTML {
 547			var buf bytes.Buffer
 548			if err := md.Convert([]byte(s), &buf); err != nil {
 549				c.Logger.Error("markdown conversion failed", "error", err)
 550				return template.HTML(fmt.Sprintf("<pre>markdown error: %v</pre>", err))
 551			}
 552			return template.HTML(buf.String())
 553		},
 554		"getAuthor": func(email string) string {
 555			pdata := getPageData(data.Data)
 556			if pdata != nil && pdata.AliasMap != nil {
 557				if alias, ok := pdata.AliasMap[email]; ok && alias != "" {
 558					return alias
 559				}
 560			}
 561			user, _, _ := strings.Cut(email, "@")
 562			if user == "" {
 563				return "anonymous"
 564			}
 565			return user
 566		},
 567		"formatTime": func(t time.Time) string {
 568			return t.Format("Jan 2, 2006 at 15:04 MST")
 569		},
 570	}).ParseFS(embedFS, data.Template, "html/*.partial.tmpl", "html/base.layout.tmpl")
 571	if err != nil {
 572		return tracerr.Wrapf(err, "failed to parse template %s", data.Template)
 573	}
 574	return ts.ExecuteTemplate(w, "base", data.Data)
 575}
 576
 577func (c *RepoConfig) writeHTML(data *WriteData) error {
 578	dir := filepath.Join(c.Outdir, data.Subdir)
 579	if err := os.MkdirAll(dir, os.ModePerm); err != nil {
 580		return tracerr.Wrapf(err, "failed to create directory %s", dir)
 581	}
 582
 583	fp := filepath.Join(dir, data.Filename)
 584	c.Logger.Info("writing", "filepath", fp)
 585
 586	w, err := os.Create(fp)
 587	if err != nil {
 588		return tracerr.Wrapf(err, "failed to create file %s", fp)
 589	}
 590	defer w.Close()
 591
 592	if err := c.executeTemplate(w, data); err != nil {
 593		c.Logger.Error("failed to execute template", "filepath", fp, "error", err)
 594		return err
 595	}
 596	return nil
 597}
 598
 599func (c *RepoConfig) copyStaticFiles() error {
 600	files, err := staticFS.ReadDir("static")
 601	if err != nil {
 602		return tracerr.Wrapf(err, "failed to read static dir")
 603	}
 604	for _, file := range files {
 605		if file.IsDir() {
 606			continue
 607		}
 608		srcPath := filepath.Join("static", file.Name())
 609		destPath := filepath.Join(c.Outdir, file.Name())
 610		content, err := staticFS.ReadFile(srcPath)
 611		if err != nil {
 612			return tracerr.Wrapf(err, "failed to read static file %s", srcPath)
 613		}
 614		c.Logger.Info("writing static file", "filepath", destPath)
 615		if err := os.WriteFile(destPath, content, 0644); err != nil {
 616			return tracerr.Wrapf(err, "failed to write static file %s", destPath)
 617		}
 618	}
 619	return nil
 620}
 621
 622func (c *RepoConfig) writePage(template, filename, subdir string, data any) {
 623	if err := c.writeHTML(&WriteData{template, filename, subdir, data}); err != nil {
 624		c.Logger.Error("failed to write page", "template", template, "error", err)
 625	}
 626}
 627
 628func (c *RepoConfig) writeRootSummary(data *PageData, readme template.HTML, langStats []*LanguageStat, totalSize int64) {
 629	c.Logger.Info("writing root summary", "repoPath", c.RepoPath)
 630	pageData := &SummaryPageData{
 631		PageData:      data,
 632		Readme:        readme,
 633		LanguageStats: langStats,
 634		TotalLanguage: totalSize,
 635	}
 636	c.writePage("html/summary.page.tmpl", "index.html", "", pageData)
 637}
 638
 639func (c *RepoConfig) writeTree(data *PageData, tree *TreeRoot) {
 640	c.Logger.Info("writing tree", "treePath", tree.Path)
 641	c.writePage("html/tree.page.tmpl", "index.html", tree.Path, &TreePageData{data, tree})
 642}
 643
 644func (c *RepoConfig) writeLog(data *PageData, logs []*CommitData) {
 645	c.Logger.Info("writing log file", "revision", data.RevData.Name())
 646	c.writePage("html/log.page.tmpl", "index.html", getLogBaseDir(data.RevData), &LogPageData{data, len(logs), logs})
 647}
 648
 649func (c *RepoConfig) writeRefs(data *PageData, refs []*RefInfo) {
 650	c.Logger.Info("writing refs", "repoPath", c.RepoPath)
 651	c.writePage("html/refs.page.tmpl", "refs.html", "", &RefPageData{data, refs})
 652
 653	jsonData, err := json.MarshalIndent(refs, "", "  ")
 654	if err != nil {
 655		c.Logger.Error("failed to marshal refs to json", "error", err)
 656		return
 657	}
 658	fp := filepath.Join(c.Outdir, "refs.json")
 659	if err := os.WriteFile(fp, jsonData, 0644); err != nil {
 660		c.Logger.Error("failed to write refs.json", "error", err)
 661	}
 662}
 663
 664func (c *RepoConfig) writeSearchIndex(searchIndex []*SearchIndexEntry) {
 665	c.Logger.Info("writing search index")
 666	jsonData, err := json.Marshal(searchIndex)
 667	if err != nil {
 668		c.Logger.Error("failed to marshal search index", "error", err)
 669		return
 670	}
 671	fp := filepath.Join(c.Outdir, "search-index.json")
 672	if err := os.WriteFile(fp, jsonData, 0644); err != nil {
 673		c.Logger.Error("failed to write search-index.json", "error", err)
 674	}
 675}
 676
 677func (c *RepoConfig) writeLanguagePage(pageData *PageData, lang string, files []*SearchIndexEntry) {
 678	c.Logger.Info("writing language page", "language", lang)
 679	data := &LanguagePageData{
 680		PageData: pageData,
 681		Language: lang,
 682		Files:    files,
 683	}
 684	c.writePage("html/language.page.tmpl", fmt.Sprintf("lang-%s.html", lang), "", data)
 685}
 686
 687func (c *RepoConfig) writeSearchPage(pageData *PageData) {
 688	c.Logger.Info("writing search page")
 689	c.writePage("html/search.page.tmpl", "search.html", "", pageData)
 690}
 691
 692func (c *RepoConfig) writeHTMLTreeFile(pageData *PageData, treeItem *TreeItem) (string, string, int64, error) {
 693	b, err := treeItem.Entry.Blob().Bytes()
 694	if err != nil {
 695		return "", "", 0, tracerr.Wrapf(err, "failed to get blob bytes for %s", treeItem.Path)
 696	}
 697
 698	var contentsHTML template.HTML
 699	displayName := ""
 700	isMarkdown := (strings.HasSuffix(treeItem.Entry.Name(), ".md") || strings.HasSuffix(treeItem.Entry.Name(), ".markdown"))
 701
 702	if isMarkdown && *pageData.Repo.RenderMarkdown {
 703		var buf bytes.Buffer
 704		if err := md.Convert(b, &buf); err != nil {
 705			c.Logger.Error("failed to render markdown", "file", treeItem.Entry.Name(), "error", err)
 706			contentsHTML = template.HTML("Failed to render markdown")
 707		} else {
 708			contentsHTML = template.HTML(buf.String())
 709		}
 710		displayName = "Markdown"
 711	} else {
 712		contentsHTML, displayName, err = c.highlightSyntax(string(b), treeItem.Path, treeItem.Entry.Blob())
 713		if err != nil {
 714			c.Logger.Error("failed to highlight syntax", "file", treeItem.Entry.Name(), "error", err)
 715		}
 716	}
 717
 718	treeItem.Language = displayName
 719	treeItem.IsTextFile = displayName != "Binary"
 720	if treeItem.IsTextFile {
 721		treeItem.NumLines = len(strings.Split(string(b), "\n"))
 722	}
 723
 724	d := filepath.Dir(treeItem.Path)
 725	readme := ""
 726	if d == "." && strings.EqualFold(treeItem.Entry.Name(), readmeFile(pageData.Repo)) {
 727		readme = string(contentsHTML)
 728	}
 729
 730	c.writePage("html/file.page.tmpl", fmt.Sprintf("%s.html", treeItem.Entry.Name()), getFileDir(pageData.RevData, d), &FilePageData{
 731		PageData: pageData,
 732		Contents: contentsHTML,
 733		Item:     treeItem,
 734		Language: displayName,
 735	})
 736
 737	return readme, displayName, treeItem.Entry.Size(), nil
 738}
 739
 740func (c *RepoConfig) writeLogDiff(repo *git.Repository, pageData *PageData, commit *CommitData) {
 741	commitID := commit.ID.String()
 742	if _, loaded := c.Cache.LoadOrStore(commitID, true); loaded {
 743		return
 744	}
 745
 746	diff, err := repo.Diff(commitID, 0, 0, 0, git.DiffOptions{})
 747	if err != nil {
 748		c.Logger.Error("failed to generate diff", "commitID", getShortID(commitID), "error", err)
 749		return
 750	}
 751
 752	rnd := &DiffRender{
 753		NumFiles:       diff.NumFiles(),
 754		TotalAdditions: diff.TotalAdditions(),
 755		TotalDeletions: diff.TotalDeletions(),
 756	}
 757	for _, file := range diff.Files {
 758		var contentBuilder strings.Builder
 759		for _, section := range file.Sections {
 760			for _, line := range section.Lines {
 761				contentBuilder.WriteString(line.Content)
 762				contentBuilder.WriteByte('\n')
 763			}
 764		}
 765		finContent, _, _ := c.highlightSyntax(contentBuilder.String(), "commit.diff", nil)
 766
 767		rnd.Files = append(rnd.Files, &DiffRenderFile{
 768			FileType:     diffFileType(file.Type),
 769			OldMode:      file.OldMode(),
 770			OldName:      file.OldName(),
 771			Mode:         file.Mode(),
 772			Name:         file.Name,
 773			NumAdditions: file.NumAdditions(),
 774			NumDeletions: file.NumDeletions(),
 775			Content:      finContent,
 776		})
 777	}
 778
 779	c.writePage("html/commit.page.tmpl", fmt.Sprintf("%s.html", commitID), "commits", &CommitPageData{
 780		PageData:  pageData,
 781		Commit:    commit,
 782		CommitID:  getShortID(commitID),
 783		Diff:      rnd,
 784		Parent:    getShortID(commit.ParentID),
 785		CommitURL: c.getCommitURL(commitID),
 786		ParentURL: c.getCommitURL(commit.ParentID),
 787	})
 788}
 789
 790func (c *RepoConfig) writeIssuesPages(pageData *PageData, db *IssueDatabase) {
 791	c.Logger.Info("writing issues pages")
 792	var issuesList []*Issue
 793	for _, issue := range db.Issues {
 794		issue.URL = c.getIssueURL(issue.ID)
 795		issuesList = append(issuesList, issue)
 796	}
 797
 798	sort.Slice(issuesList, func(i, j int) bool {
 799		return issuesList[i].CreatedAt.After(issuesList[j].CreatedAt)
 800	})
 801
 802	c.writePage("html/issues.page.tmpl", "index.html", "issues", &IssuesListPageData{
 803		PageData: pageData,
 804		Issues:   issuesList,
 805	})
 806
 807	for _, issue := range issuesList {
 808		c.writePage("html/issue.page.tmpl", fmt.Sprintf("%d.html", issue.ID), "issues", &SingleIssuePageData{
 809			PageData: pageData,
 810			Issue:    issue,
 811		})
 812	}
 813}
 814
 815func (c *RepoConfig) getSummaryURL() template.URL {
 816	return template.URL(c.RootRelative + "index.html")
 817}
 818func (c *RepoConfig) getRefsURL() template.URL {
 819	return template.URL(c.RootRelative + "refs.html")
 820}
 821func (c *RepoConfig) getSearchURL() template.URL {
 822	return template.URL(c.RootRelative + "search.html")
 823}
 824func (c *RepoConfig) getIssuesURL() template.URL {
 825	return c.compileURL("issues", "index.html")
 826}
 827func getRevIDForURL(info RevInfo) string {
 828	return info.Name()
 829}
 830func getTreeBaseDir(info RevInfo) string {
 831	return filepath.Join("/", "tree", getRevIDForURL(info))
 832}
 833func getLogBaseDir(info RevInfo) string {
 834	return filepath.Join("/", "logs", getRevIDForURL(info))
 835}
 836func getFileBaseDir(info RevInfo) string {
 837	return filepath.Join(getTreeBaseDir(info), "item")
 838}
 839func getFileDir(info RevInfo, fname string) string {
 840	return filepath.Join(getFileBaseDir(info), fname)
 841}
 842func (c *RepoConfig) compileURL(parts ...string) template.URL {
 843	return template.URL(c.RootRelative + strings.TrimPrefix(filepath.Join(parts...), "/"))
 844}
 845func (c *RepoConfig) getFileURL(info RevInfo, fname string) template.URL {
 846	return c.compileURL(getFileBaseDir(info), fmt.Sprintf("%s.html", fname))
 847}
 848func (c *RepoConfig) getTreeURL(info RevInfo) template.URL {
 849	return c.compileURL(getTreeBaseDir(info), "index.html")
 850}
 851func (c *RepoConfig) getLogsURL(info RevInfo) template.URL {
 852	return c.compileURL(getLogBaseDir(info), "index.html")
 853}
 854func (c *RepoConfig) getCommitURL(commitID string) template.URL {
 855	if commitID == "" {
 856		return ""
 857	}
 858	return c.compileURL("commits", fmt.Sprintf("%s.html", commitID))
 859}
 860func (c *RepoConfig) getIssueURL(issueID int) template.URL {
 861	return c.compileURL("issues", fmt.Sprintf("%d.html", issueID))
 862}
 863func (c *RepoConfig) getURLs() *SiteURLs {
 864	return &SiteURLs{
 865		HomeURL:    template.URL(c.HomeURL),
 866		CloneURL:   template.URL(c.CloneURL),
 867		RefsURL:    c.getRefsURL(),
 868		SummaryURL: c.getSummaryURL(),
 869		SearchURL:  c.getSearchURL(),
 870		IssuesURL:  c.getIssuesURL(),
 871	}
 872}
 873
 874func getShortID(id string) string {
 875	if len(id) < 7 {
 876		return id
 877	}
 878	return id[:7]
 879}
 880
 881func parseGitAttributes(path string) (attrs []GitAttribute, whitelist, blacklist map[string]bool, err error) {
 882	file, err := os.Open(path)
 883	if err != nil {
 884		return nil, nil, nil, err
 885	}
 886	defer file.Close()
 887
 888	whitelist = make(map[string]bool)
 889	blacklist = make(map[string]bool)
 890	scanner := bufio.NewScanner(file)
 891
 892	for scanner.Scan() {
 893		line := strings.TrimSpace(scanner.Text())
 894		if line == "" || strings.HasPrefix(line, "#") {
 895			continue
 896		}
 897
 898		processDirective := func(prefix string, store map[string]bool) bool {
 899			if strings.HasPrefix(line, prefix) {
 900				if kv := strings.SplitN(line, "=", 2); len(kv) == 2 {
 901					for _, lang := range strings.Split(strings.Trim(kv[1], `"`), ",") {
 902						store[strings.TrimSpace(lang)] = true
 903					}
 904				}
 905				return true
 906			}
 907			return false
 908		}
 909
 910		if processDirective("pgit-whitelist", whitelist) || processDirective("pgit-blacklist", blacklist) {
 911			continue
 912		}
 913
 914		parts := strings.Fields(line)
 915		if len(parts) < 2 {
 916			continue
 917		}
 918
 919		pattern := parts[0]
 920		attributes := make(map[string]string)
 921		for _, attrStr := range parts[1:] {
 922			if kv := strings.SplitN(attrStr, "=", 2); len(kv) == 2 {
 923				attributes[kv[0]] = strings.Trim(kv[1], `"`)
 924			} else {
 925				attributes[attrStr] = "true"
 926			}
 927		}
 928		attrs = append(attrs, GitAttribute{pattern, attributes})
 929	}
 930
 931	if len(whitelist) == 0 {
 932		whitelist = nil
 933	}
 934	if len(blacklist) == 0 {
 935		blacklist = nil
 936	}
 937	return attrs, whitelist, blacklist, scanner.Err()
 938}
 939
 940func (c *RepoConfig) writeRepo() error {
 941	c.Logger.Info("writing repo", "repoPath", c.RepoPath)
 942	repo, err := git.Open(c.RepoPath)
 943	if err != nil {
 944		return tracerr.Wrapf(err, "failed to open git repo %s", c.RepoPath)
 945	}
 946
 947	gitAttrPath := filepath.Join(c.RepoPath, ".gitattributes")
 948	if _, err := os.Stat(gitAttrPath); err == nil {
 949		var parseErr error
 950		c.GitAttributes, c.Whitelist, c.Blacklist, parseErr = parseGitAttributes(gitAttrPath)
 951		if parseErr != nil {
 952			c.Logger.Warn("failed to parse .gitattributes file", "path", gitAttrPath, "error", parseErr)
 953		}
 954	}
 955
 956	if c.IssuesEnabled {
 957		c.Logger.Info("issues enabled for repo, attempting to load", "path", c.IssuesDBPath)
 958		db, aliasMap, err := loadIssues(c)
 959		if err != nil {
 960			c.Logger.Error("failed to load issues database, proceeding with empty issue list", "path", c.IssuesDBPath, "error", err)
 961			c.IssueDB = &IssueDatabase{Issues: make(map[int]*Issue)}
 962		} else {
 963			c.IssueDB = db
 964			c.AliasMap = aliasMap
 965			c.Logger.Info("successfully loaded issues database", "issues_count", len(c.IssueDB.Issues), "aliases_loaded", len(c.AliasMap))
 966		}
 967	}
 968
 969	refs, err := repo.ShowRef(git.ShowRefOptions{Heads: true, Tags: true})
 970	if err != nil {
 971		return tracerr.Wrapf(err, "failed to get refs")
 972	}
 973
 974	var first *RevData
 975	var revs []*RevData
 976	for _, revStr := range c.Revs {
 977		fullRevID, err := repo.RevParse(revStr)
 978		if err != nil {
 979			c.Logger.Warn("failed to parse revision, skipping", "rev", revStr, "error", err)
 980			continue
 981		}
 982		revName := getShortID(fullRevID)
 983		for _, ref := range refs {
 984			if revStr == git.RefShortName(ref.Refspec) || revStr == ref.Refspec {
 985				revName = revStr
 986				break
 987			}
 988		}
 989		data := &RevData{fullRevID, revName, c}
 990		if first == nil {
 991			first = data
 992		}
 993		revs = append(revs, data)
 994	}
 995
 996	if first == nil {
 997		return tracerr.New("no valid git references found to process")
 998	}
 999
1000	refInfoMap := make(map[string]*RefInfo)
1001	for _, revData := range revs {
1002		refInfoMap[revData.Name()] = &RefInfo{revData.ID(), revData.Name(), revData.TreeURL()}
1003	}
1004	for _, ref := range refs {
1005		refspec := git.RefShortName(ref.Refspec)
1006		if _, exists := refInfoMap[refspec]; !exists {
1007			refInfoMap[refspec] = &RefInfo{ID: ref.ID, Refspec: refspec}
1008		}
1009	}
1010
1011	refInfoList := make([]*RefInfo, 0, len(refInfoMap))
1012	for _, val := range refInfoMap {
1013		refInfoList = append(refInfoList, val)
1014	}
1015	sort.Slice(refInfoList, func(i, j int) bool {
1016		if refInfoList[i].URL != refInfoList[j].URL {
1017			return refInfoList[i].URL > refInfoList[j].URL
1018		}
1019		return refInfoList[i].Refspec < refInfoList[j].Refspec
1020	})
1021
1022	var mainReadme template.HTML
1023	var searchIndex []*SearchIndexEntry
1024	var langFiles = make(map[string][]*SearchIndexEntry)
1025	var searchIndexMutex sync.Mutex
1026	var langFilesMutex sync.Mutex
1027	var wg sync.WaitGroup
1028
1029	for i, revData := range revs {
1030		pageData := &PageData{c, c.getURLs(), revData, c.AliasMap}
1031		isFirst := i == 0
1032		wg.Add(1)
1033		go func(d *PageData, firstRev bool) {
1034			defer wg.Done()
1035			readme, sIndex, lFiles, err := c.writeRevision(repo, d, refInfoList)
1036			if err != nil {
1037				c.Logger.Error("failed to write revision", "rev", d.RevData.Name(), "error", err)
1038				return
1039			}
1040			if firstRev {
1041				mainReadme = readme
1042				searchIndexMutex.Lock()
1043				searchIndex = append(searchIndex, sIndex...)
1044				searchIndexMutex.Unlock()
1045				langFilesMutex.Lock()
1046				for lang, files := range lFiles {
1047					langFiles[lang] = append(langFiles[lang], files...)
1048				}
1049				langFilesMutex.Unlock()
1050			}
1051		}(pageData, isFirst)
1052	}
1053	wg.Wait()
1054
1055	pageData := &PageData{c, c.getURLs(), first, c.AliasMap}
1056	c.writeRefs(pageData, refInfoList)
1057	c.writeSearchIndex(searchIndex)
1058	c.writeSearchPage(pageData)
1059	for lang, files := range langFiles {
1060		c.writeLanguagePage(pageData, lang, files)
1061	}
1062
1063	if c.IssuesEnabled && c.IssueDB != nil {
1064		c.writeIssuesPages(pageData, c.IssueDB)
1065	}
1066
1067	langStats, totalSize := calculateLanguageStats(repo, first.id, c)
1068	c.writeRootSummary(pageData, mainReadme, langStats, totalSize)
1069	return nil
1070}
1071
1072type TreeRoot struct {
1073	Path   string
1074	Items  []*TreeItem
1075	Crumbs []*Breadcrumb
1076}
1077
1078type TreeWalker struct {
1079	treeItemChan chan *TreeItem
1080	treeRootChan chan *TreeRoot
1081	errChan      chan error
1082	wg           sync.WaitGroup
1083	PageData     *PageData
1084	Repo         *git.Repository
1085	Config       *RepoConfig
1086}
1087
1088type Breadcrumb struct {
1089	Text   string
1090	URL    template.URL
1091	IsLast bool
1092}
1093
1094func (tw *TreeWalker) calcBreadcrumbs(curpath string) []*Breadcrumb {
1095	if curpath == "" {
1096		return nil
1097	}
1098	parts := strings.Split(curpath, string(os.PathSeparator))
1099	crumbs := make([]*Breadcrumb, len(parts)+1)
1100	crumbs[0] = &Breadcrumb{
1101		URL:  tw.Config.getTreeURL(tw.PageData.RevData),
1102		Text: tw.PageData.Repo.Label,
1103	}
1104	for i, part := range parts {
1105		currentCrumbPath := filepath.Join(parts[:i+1]...)
1106		crumbs[i+1] = &Breadcrumb{
1107			Text:   part,
1108			URL:    tw.Config.compileURL(getFileBaseDir(tw.PageData.RevData), currentCrumbPath, "index.html"),
1109			IsLast: i == len(parts)-1,
1110		}
1111	}
1112	return crumbs
1113}
1114
1115func (tw *TreeWalker) newTreeItem(entry *git.TreeEntry, curpath string, crumbs []*Breadcrumb) (*TreeItem, error) {
1116	fname := filepath.Join(curpath, entry.Name())
1117	item := &TreeItem{
1118		Size:   toPretty(entry.Size()),
1119		Name:   entry.Name(),
1120		Path:   fname,
1121		Entry:  entry,
1122		Crumbs: crumbs,
1123		IsDir:  entry.Type() == git.ObjectTree,
1124	}
1125
1126	if !tw.Config.HideTreeLastCommit {
1127		lastCommits, err := tw.Repo.RevList([]string{tw.PageData.RevData.ID()}, git.RevListOptions{
1128			Path:           item.Path,
1129			CommandOptions: git.CommandOptions{Args: []string{"-1"}},
1130		})
1131		if err != nil {
1132			return nil, tracerr.Wrapf(err, "failed to get last commit for %s", item.Path)
1133		}
1134		if len(lastCommits) > 0 {
1135			lc := lastCommits[0]
1136			item.CommitURL = tw.Config.getCommitURL(lc.ID.String())
1137			item.CommitID = getShortID(lc.ID.String())
1138			item.Summary = lc.Summary()
1139			item.When = lc.Author.When.Format(time.DateOnly)
1140			item.Author = lc.Author
1141		}
1142	}
1143
1144	if item.IsDir {
1145		item.URL = tw.Config.compileURL(getFileBaseDir(tw.PageData.RevData), fname, "index.html")
1146	} else {
1147		item.URL = tw.Config.getFileURL(tw.PageData.RevData, fname)
1148	}
1149	return item, nil
1150}
1151
1152func (tw *TreeWalker) walk(tree *git.Tree, curpath string) {
1153	defer tw.wg.Done()
1154
1155	entries, err := tree.Entries()
1156	if err != nil {
1157		tw.errChan <- err
1158		return
1159	}
1160
1161	crumbs := tw.calcBreadcrumbs(curpath)
1162	var treeEntries []*TreeItem
1163	for _, entry := range entries {
1164		item, err := tw.newTreeItem(entry, curpath, crumbs)
1165		if err != nil {
1166			tw.errChan <- err
1167			return
1168		}
1169		treeEntries = append(treeEntries, item)
1170
1171		if item.IsDir {
1172			subTree, err := tree.Subtree(entry.Name())
1173			if err != nil {
1174				tw.errChan <- err
1175				continue
1176			}
1177			tw.wg.Add(1)
1178			go tw.walk(subTree, item.Path)
1179		}
1180		tw.treeItemChan <- item
1181	}
1182
1183	sort.Slice(treeEntries, func(i, j int) bool {
1184		if treeEntries[i].IsDir != treeEntries[j].IsDir {
1185			return treeEntries[i].IsDir
1186		}
1187		return treeEntries[i].Name < treeEntries[j].Name
1188	})
1189
1190	fpath := getFileBaseDir(tw.PageData.RevData)
1191	if curpath != "" {
1192		fpath = filepath.Join(fpath, curpath)
1193	} else {
1194		fpath = getTreeBaseDir(tw.PageData.RevData)
1195	}
1196
1197	tw.treeRootChan <- &TreeRoot{Path: fpath, Items: treeEntries, Crumbs: crumbs}
1198}
1199
1200func (c *RepoConfig) writeRevision(repo *git.Repository, pageData *PageData, refs []*RefInfo) (template.HTML, []*SearchIndexEntry, map[string][]*SearchIndexEntry, error) {
1201	c.Logger.Info("compiling revision", "repoName", c.Label, "revision", pageData.RevData.Name())
1202	var wg sync.WaitGroup
1203	errChan := make(chan error, 20)
1204
1205	wg.Add(1)
1206	go func() {
1207		defer wg.Done()
1208		pageSize := c.MaxCommits
1209		if pageSize == 0 {
1210			pageSize = 5000
1211		}
1212		commits, err := repo.CommitsByPage(pageData.RevData.ID(), 0, pageSize)
1213		if err != nil {
1214			errChan <- tracerr.Wrapf(err, "failed to get commits for %s", pageData.RevData.ID())
1215			return
1216		}
1217
1218		var logs []*CommitData
1219		for _, commit := range commits {
1220			var commitRefs []*RefInfo
1221			for _, ref := range refs {
1222				if commit.ID.String() == ref.ID {
1223					commitRefs = append(commitRefs, ref)
1224				}
1225			}
1226			parentSha, _ := commit.ParentID(0)
1227			parentID := ""
1228			if parentSha != nil {
1229				parentID = parentSha.String()
1230			}
1231
1232			logEntry := &CommitData{
1233				SummaryStr: commit.Summary(),
1234				URL:        c.getCommitURL(commit.ID.String()),
1235				ShortID:    getShortID(commit.ID.String()),
1236				AuthorStr:  commit.Author.Name,
1237				WhenStr:    commit.Author.When.Format(time.DateOnly),
1238				Commit:     commit,
1239				Refs:       commitRefs,
1240				ParentID:   parentID,
1241			}
1242			logs = append(logs, logEntry)
1243
1244			wg.Add(1)
1245			go func(cm *CommitData) {
1246				defer wg.Done()
1247				c.writeLogDiff(repo, pageData, cm)
1248			}(logEntry)
1249		}
1250		c.writeLog(pageData, logs)
1251	}()
1252
1253	tree, err := repo.LsTree(pageData.RevData.ID())
1254	if err != nil {
1255		return "", nil, nil, tracerr.Wrapf(err, "failed to list tree for %s", pageData.RevData.ID())
1256	}
1257
1258	var readme template.HTML
1259	var readmeMutex sync.Mutex
1260	var searchIndex []*SearchIndexEntry
1261	var langFiles = make(map[string][]*SearchIndexEntry)
1262	var mu sync.Mutex
1263
1264	treeItemChan := make(chan *TreeItem, 100)
1265	treeRootChan := make(chan *TreeRoot, 20)
1266	tw := &TreeWalker{
1267		Config:       c,
1268		PageData:     pageData,
1269		Repo:         repo,
1270		treeItemChan: treeItemChan,
1271		treeRootChan: treeRootChan,
1272		errChan:      errChan,
1273	}
1274
1275	tw.wg.Add(1)
1276	go tw.walk(tree, "")
1277
1278	wg.Add(1)
1279	go func() {
1280		defer wg.Done()
1281		var fileWg sync.WaitGroup
1282		for item := range treeItemChan {
1283			if item.Entry.Type() != git.ObjectBlob {
1284				continue
1285			}
1286			fileWg.Add(1)
1287			go func(entry *TreeItem) {
1288				defer fileWg.Done()
1289				readmeStr, lang, _, err := c.writeHTMLTreeFile(pageData, entry)
1290				if err != nil {
1291					errChan <- err
1292					return
1293				}
1294				if readmeStr != "" {
1295					readmeMutex.Lock()
1296					readme = template.HTML(readmeStr)
1297					readmeMutex.Unlock()
1298				}
1299				fileInfo := &SearchIndexEntry{
1300					Name:     entry.Name,
1301					Path:     entry.Path,
1302					URL:      string(entry.URL),
1303					Language: lang,
1304				}
1305				mu.Lock()
1306				searchIndex = append(searchIndex, fileInfo)
1307				if lang != "Binary" && lang != "Text" {
1308					langFiles[lang] = append(langFiles[lang], fileInfo)
1309				}
1310				mu.Unlock()
1311			}(item)
1312		}
1313		fileWg.Wait()
1314	}()
1315
1316	wg.Add(1)
1317	go func() {
1318		defer wg.Done()
1319		var treeWg sync.WaitGroup
1320		for t := range treeRootChan {
1321			treeWg.Add(1)
1322			go func(tr *TreeRoot) {
1323				defer treeWg.Done()
1324				c.writeTree(pageData, tr)
1325			}(t)
1326		}
1327		treeWg.Wait()
1328	}()
1329
1330	go func() {
1331		tw.wg.Wait()
1332		close(treeItemChan)
1333		close(treeRootChan)
1334	}()
1335
1336	go func() {
1337		wg.Wait()
1338		close(errChan)
1339	}()
1340
1341	for err := range errChan {
1342		if e, ok := err.(*tracerr.Error); ok {
1343			e.Print()
1344		} else {
1345			c.Logger.Error("an error occurred during revision processing", "error", err)
1346		}
1347		return "", nil, nil, err
1348	}
1349
1350	c.Logger.Info("compilation complete", "repoName", c.Label, "revision", pageData.RevData.Name())
1351	return readme, searchIndex, langFiles, nil
1352}
1353
1354var langColors = map[string]string{
1355	"Go":         "#00ADD8",
1356	"C":          "#555555",
1357	"C++":        "#F34B7D",
1358	"Python":     "#3572A5",
1359	"JavaScript": "#F1E05A",
1360	"TypeScript": "#2B7489",
1361	"HTML":       "#E34F26",
1362	"CSS":        "#563D7C",
1363	"Shell":      "#89E051",
1364	"Makefile":   "#427819",
1365	"Dockerfile": "#384D54",
1366	"Markdown":   "#083FA1",
1367	"JSON":       "#292929",
1368	"YAML":       "#CB171E",
1369	"B":          "#550000",
1370	"Bx":         "#6b0015",
1371	"Abuild":     "#24aae2",
1372	"Nim":        "#ffe953",
1373}
1374
1375func getLanguageColor(lang string) string {
1376	if lang == "Other" {
1377		return "#999999"
1378	}
1379	if color, ok := langColors[lang]; ok {
1380		return color
1381	}
1382	hash := 0
1383	for _, char := range lang {
1384		hash = int(char) + ((hash << 5) - hash)
1385	}
1386	return fmt.Sprintf("#%06x", (hash & 0x00FFFFFF))
1387}
1388
1389func calculateLanguageStats(repo *git.Repository, rev string, config *RepoConfig) ([]*LanguageStat, int64) {
1390	tree, err := repo.LsTree(rev)
1391	if err != nil {
1392		config.Logger.Error("failed to get tree for language stats", "error", err)
1393		return nil, 0
1394	}
1395
1396	langSizes := make(map[string]int64)
1397	langHexColors := make(map[string]string)
1398	var totalSize int64
1399	var mu sync.Mutex
1400	var wg sync.WaitGroup
1401
1402	var walkTree func(*git.Tree, string)
1403	walkTree = func(t *git.Tree, currentPath string) {
1404		defer wg.Done()
1405		entries, _ := t.Entries()
1406		for _, entry := range entries {
1407			fullPath := filepath.Join(currentPath, entry.Name())
1408			if entry.Type() == git.ObjectBlob {
1409				blob := entry.Blob()
1410				data, _ := blob.Bytes()
1411				displayName, _, isText := config.getLanguageInfo(fullPath, data)
1412				if isText && displayName != "Text" && displayName != "Binary" {
1413					attrs := config.getFileAttributes(fullPath)
1414					hexColor := attrs["linguist-hex-color"]
1415
1416					mu.Lock()
1417					langSizes[displayName] += blob.Size()
1418					totalSize += blob.Size()
1419					if hexColor != "" {
1420						langHexColors[displayName] = hexColor
1421					}
1422					mu.Unlock()
1423				}
1424			} else if entry.Type() == git.ObjectTree {
1425				subTree, err := t.Subtree(entry.Name())
1426				if err == nil {
1427					wg.Add(1)
1428					go walkTree(subTree, fullPath)
1429				}
1430			}
1431		}
1432	}
1433
1434	wg.Add(1)
1435	walkTree(tree, "")
1436	wg.Wait()
1437
1438	if totalSize == 0 {
1439		return nil, 0
1440	}
1441
1442	stats := make([]*LanguageStat, 0, len(langSizes))
1443	for lang, size := range langSizes {
1444		color := langHexColors[lang]
1445		if color == "" {
1446			color = getLanguageColor(lang)
1447		}
1448		stats = append(stats, &LanguageStat{
1449			Name:       lang,
1450			Percentage: (float64(size) / float64(totalSize)) * 100,
1451			Color:      color,
1452			URL:        config.compileURL(fmt.Sprintf("lang-%s.html", lang)),
1453		})
1454	}
1455
1456	var finalStats []*LanguageStat
1457	var otherPercentage float64
1458	for _, stat := range stats {
1459		var keep bool
1460		if config.Whitelist == nil {
1461			keep = stat.Percentage >= 5.0
1462		} else {
1463			isWhitelisted := config.Whitelist[stat.Name]
1464			keep = isWhitelisted || stat.Percentage >= 5.0
1465		}
1466
1467		if keep {
1468			finalStats = append(finalStats, stat)
1469		} else {
1470			otherPercentage += stat.Percentage
1471		}
1472	}
1473
1474	if otherPercentage > 0.001 {
1475		finalStats = append(finalStats, &LanguageStat{
1476			Name:       "Other",
1477			Percentage: otherPercentage,
1478			Color:      getLanguageColor("Other"),
1479			URL:        "",
1480		})
1481	}
1482
1483	sort.Slice(finalStats, func(i, j int) bool {
1484		return finalStats[i].Percentage > finalStats[j].Percentage
1485	})
1486
1487	return finalStats, totalSize
1488}
1489
1490func generateThemeCSS(theme *chroma.Style) string {
1491	bg := theme.Get(chroma.Background)
1492	txt := theme.Get(chroma.Text)
1493	kw := theme.Get(chroma.Keyword)
1494	nv := theme.Get(chroma.NameVariable)
1495	cm := theme.Get(chroma.Comment)
1496	ln := theme.Get(chroma.LiteralNumber)
1497	return fmt.Sprintf(`:root {
1498  --bg-color: %s; --text-color: %s; --border: %s;
1499  --link-color: %s; --hover: %s; --visited: %s;
1500}`, bg.Background, txt.Colour, cm.Colour, nv.Colour, kw.Colour, ln.Colour)
1501}
1502
1503func processEnvVars(data []byte) ([]byte, error) {
1504	re := regexp.MustCompile(`\{\{\s*env\.(\w+)\s*\}\}`)
1505	var firstError error
1506
1507	processed := re.ReplaceAllStringFunc(string(data), func(match string) string {
1508		if firstError != nil {
1509			return ""
1510		}
1511
1512		varName := re.FindStringSubmatch(match)[1]
1513		value := os.Getenv(varName)
1514
1515		if value == "" {
1516			firstError = fmt.Errorf("environment variable '%s' not set or is empty", varName)
1517			return ""
1518		}
1519		return value
1520	})
1521
1522	if firstError != nil {
1523		return nil, firstError
1524	}
1525
1526	return []byte(processed), nil
1527}
1528
1529func loadPgitConfig(path string, logger *slog.Logger) (*PgitConfig, error) {
1530	logger.Info("loading configuration file", "path", path)
1531	yamlFile, err := os.ReadFile(path)
1532	if err != nil {
1533		return nil, tracerr.Wrapf(err, "error reading config file %s", path)
1534	}
1535
1536	processedYaml, err := processEnvVars(yamlFile)
1537	if err != nil {
1538		return nil, tracerr.Wrapf(err, "error processing env vars in config")
1539	}
1540
1541	var pgitConfig PgitConfig
1542	if err := yaml.Unmarshal(processedYaml, &pgitConfig); err != nil {
1543		return nil, tracerr.Wrapf(err, "error parsing YAML file")
1544	}
1545	return &pgitConfig, nil
1546}
1547
1548func resolvePgitConfig(repoKey string, g, r PgitRepoConfig) *RepoConfig {
1549	resolveStr := func(repoVal, globalVal *string, defaultVal string) string {
1550		if repoVal != nil {
1551			return *repoVal
1552		}
1553		if globalVal != nil {
1554			return *globalVal
1555		}
1556		return defaultVal
1557	}
1558	resolveInt := func(repoVal, globalVal *int, defaultVal int) int {
1559		if repoVal != nil {
1560			return *repoVal
1561		}
1562		if globalVal != nil {
1563			return *globalVal
1564		}
1565		return defaultVal
1566	}
1567	resolveBool := func(repoVal, globalVal *bool, defaultVal bool) bool {
1568		if repoVal != nil {
1569			return *repoVal
1570		}
1571		if globalVal != nil {
1572			return *globalVal
1573		}
1574		return defaultVal
1575	}
1576
1577	cfg := RepoConfig{
1578		RepoName:           repoKey,
1579		RepoPath:           r.RepoPath,
1580		Revs:               r.Revs,
1581		Outdir:             resolveStr(r.Outdir, g.Outdir, "./pub"),
1582		Desc:               resolveStr(r.Desc, g.Desc, ""),
1583		MaxCommits:         resolveInt(r.MaxCommits, g.MaxCommits, 100),
1584		Readme:             resolveStr(r.Readme, g.Readme, "README.md"),
1585		HideTreeLastCommit: resolveBool(r.HideTreeLastCommit, g.HideTreeLastCommit, false),
1586		HomeURL:            resolveStr(r.HomeURL, g.HomeURL, ""),
1587		CloneURL:           resolveStr(r.CloneURL, g.CloneURL, ""),
1588		RootRelative:       resolveStr(r.RootRelative, g.RootRelative, "/"),
1589		ThemeName:          resolveStr(r.ThemeName, g.ThemeName, "gruvbox-dark"),
1590		Label:              resolveStr(r.Label, g.Label, repoKey),
1591		IssuesDBPath:       resolveStr(r.IssuesDBPath, g.IssuesDBPath, ""),
1592		IssuesKey:          resolveStr(r.IssuesKey, g.IssuesKey, ""),
1593		DisableEncryption:  resolveBool(r.DisableEncryption, g.DisableEncryption, false),
1594	}
1595	if r.RenderMarkdown != nil {
1596		cfg.RenderMarkdown = r.RenderMarkdown
1597	} else if g.RenderMarkdown != nil {
1598		cfg.RenderMarkdown = g.RenderMarkdown
1599	} else {
1600		t := true
1601		cfg.RenderMarkdown = &t
1602	}
1603
1604	if cfg.IssuesDBPath != "" {
1605		cfg.IssuesEnabled = true
1606	}
1607
1608	return &cfg
1609}
1610
1611func main() {
1612	logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
1613	slog.SetDefault(logger)
1614
1615	app := &cli.Command{
1616		Name:  "pgit",
1617		Usage: "A static site generator for git repositories",
1618		Flags: []cli.Flag{
1619			&cli.StringFlag{Name: "config", Value: "pgit.yml", Usage: "Path to config file"},
1620		},
1621		Action: func(_ context.Context, cmd *cli.Command) error {
1622			pgitConfig, err := loadPgitConfig(cmd.String("config"), logger)
1623			if err != nil {
1624				return err
1625			}
1626			if len(pgitConfig.Repos) == 0 {
1627				logger.Warn("no repositories found in configuration")
1628				return nil
1629			}
1630
1631			var wg sync.WaitGroup
1632			formatter := html.New(html.WithLineNumbers(true), html.WithLinkableLineNumbers(true, ""), html.WithClasses(true))
1633
1634			for repoKey, repoCfg := range pgitConfig.Repos {
1635				wg.Add(1)
1636				go func(key string, r PgitRepoConfig) {
1637					defer wg.Done()
1638					config := resolvePgitConfig(key, pgitConfig.Global, r)
1639
1640					if len(config.Revs) == 0 {
1641						logger.Error("you must provide revs for repo", "repo", config.RepoPath)
1642						return
1643					}
1644
1645					config.Logger = logger
1646					config.ChromaTheme = styles.Get(config.ThemeName)
1647					if config.ChromaTheme == nil {
1648						config.ChromaTheme = styles.Fallback
1649					}
1650					config.Formatter = formatter
1651
1652					if err := config.writeRepo(); err != nil {
1653						logger.Error("failed to write repo", "repo", config.RepoPath, "error", err)
1654						if e, ok := err.(*tracerr.Error); ok {
1655							e.Print()
1656						}
1657						return
1658					}
1659
1660					if err := config.copyStaticFiles(); err != nil {
1661						logger.Error("failed to copy static files", "repo", config.RepoPath, "error", err)
1662						return
1663					}
1664
1665					if err := os.WriteFile(filepath.Join(config.Outdir, "vars.css"), []byte(generateThemeCSS(config.ChromaTheme)), 0644); err != nil {
1666						logger.Error("failed to write vars.css", "repo", config.RepoPath, "error", err)
1667					}
1668
1669					fp := filepath.Join(config.Outdir, "syntax.css")
1670					w, err := os.Create(fp)
1671					if err != nil {
1672						logger.Error("failed to create syntax.css", "repo", config.RepoPath, "error", err)
1673						return
1674					}
1675					defer w.Close()
1676					if err = formatter.WriteCSS(w, config.ChromaTheme); err != nil {
1677						logger.Error("failed to write syntax.css", "repo", config.RepoPath, "error", err)
1678					}
1679				}(repoKey, repoCfg)
1680			}
1681			wg.Wait()
1682			logger.Info("all repositories processed successfully")
1683			return nil
1684		},
1685	}
1686
1687	if err := app.Run(context.Background(), os.Args); err != nil {
1688		if e, ok := err.(*tracerr.Error); ok {
1689			e.Print()
1690		} else {
1691			logger.Error("application failed to run", "error", err)
1692		}
1693		os.Exit(1)
1694	}
1695}
1696
1697func loadIssues(config *RepoConfig) (*IssueDatabase, map[string]string, error) {
1698	if config.IssuesDBPath == "" {
1699		return nil, nil, errors.New("issues-db path is not configured")
1700	}
1701
1702	fileData, err := os.ReadFile(config.IssuesDBPath)
1703	if err != nil {
1704		if os.IsNotExist(err) {
1705			config.Logger.Info("issues db file not found, no issues will be displayed", "path", config.IssuesDBPath)
1706			return &IssueDatabase{Issues: make(map[int]*Issue)}, nil, nil
1707		}
1708		return nil, nil, fmt.Errorf("could not read issues file '%s': %w", config.IssuesDBPath, err)
1709	}
1710	if len(fileData) == 0 {
1711		return &IssueDatabase{Issues: make(map[int]*Issue)}, nil, nil
1712	}
1713
1714	var jsonData []byte
1715	if !config.DisableEncryption && config.IssuesKey != "" {
1716		decryptedData, err := decrypt(fileData, config.IssuesKey)
1717		if err != nil {
1718			return nil, nil, fmt.Errorf("failed to decrypt issues file '%s': %w", config.IssuesDBPath, err)
1719		}
1720		jsonData = decryptedData
1721	} else {
1722		jsonData = fileData
1723	}
1724
1725	var globalDB GlobalIssueDatabase
1726	if err := json.Unmarshal(jsonData, &globalDB); err == nil && globalDB.Repos != nil {
1727		config.Logger.Info("detected global issues database", "path", config.IssuesDBPath)
1728		repoDB, ok := globalDB.Repos[config.RepoName]
1729		if !ok {
1730			return nil, nil, fmt.Errorf("repo key '%s' not found in global issues database '%s'", config.RepoName, config.IssuesDBPath)
1731		}
1732		return repoDB, globalDB.EmailToAlias, nil
1733	}
1734
1735	var repoDB IssueDatabase
1736	if err := json.Unmarshal(jsonData, &repoDB); err == nil {
1737		config.Logger.Info("detected per-repo issues database", "path", config.IssuesDBPath)
1738		return &repoDB, nil, nil
1739	}
1740
1741	return nil, nil, fmt.Errorf("failed to parse issues database '%s': not a valid global or per-repo DB format", config.IssuesDBPath)
1742}
1743
1744func encrypt(plaintext []byte, hexKey string) ([]byte, error) {
1745	key, err := hex.DecodeString(hexKey)
1746	if err != nil {
1747		return nil, fmt.Errorf("%w: failed to decode hex key: %v", errDBEncrypt, err)
1748	}
1749	if len(key) != 32 {
1750		return nil, fmt.Errorf("%w: key must be 32 bytes for AES-256", errDBEncrypt)
1751	}
1752
1753	block, err := aes.NewCipher(key)
1754	if err != nil {
1755		return nil, err
1756	}
1757
1758	gcm, err := cipher.NewGCM(block)
1759	if err != nil {
1760		return nil, err
1761	}
1762
1763	nonce := make([]byte, gcm.NonceSize())
1764	if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
1765		return nil, err
1766	}
1767
1768	return gcm.Seal(nonce, nonce, plaintext, nil), nil
1769}
1770
1771func decrypt(ciphertext []byte, hexKey string) ([]byte, error) {
1772	key, err := hex.DecodeString(hexKey)
1773	if err != nil {
1774		return nil, fmt.Errorf("%w: failed to decode hex key: %v", errDBDecrypt, err)
1775	}
1776	if len(key) != 32 {
1777		return nil, fmt.Errorf("%w: key must be 32 bytes for AES-256", errDBDecrypt)
1778	}
1779
1780	block, err := aes.NewCipher(key)
1781	if err != nil {
1782		return nil, err
1783	}
1784
1785	gcm, err := cipher.NewGCM(block)
1786	if err != nil {
1787		return nil, err
1788	}
1789
1790	if len(ciphertext) < gcm.NonceSize() {
1791		return nil, fmt.Errorf("%w: ciphertext too short", errDBDecrypt)
1792	}
1793
1794	nonce, ciphertext := ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():]
1795	return gcm.Open(nil, nonce, ciphertext, nil)
1796}