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