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