[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}