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