15 files changed,
+1680,
-1722
+0,
-1
1@@ -1,4 +1,3 @@
2-pgit
3 *.swp
4 *.log
5 public/
M
Makefile
+2,
-2
1@@ -10,7 +10,7 @@ clean:
2 .PHONY: clean
3
4 build:
5- go build -o pgit .
6+ go build -o pgit ./cmd/pgit
7 .PHONY: build
8
9 img:
10@@ -32,7 +32,7 @@ test:
11 test-site:
12 mkdir -p testdata.site
13 rm -rf testdata.site/*
14- go run . \
15+ go run ./cmd/pgit \
16 --repo ./testdata \
17 --out testdata.site \
18 --clone-url "https://test.com/test/test2" \
+171,
-0
1@@ -0,0 +1,171 @@
2+package main
3+
4+import (
5+ "fmt"
6+ "log/slog"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+
11+ formatterHtml "github.com/alecthomas/chroma/v2/formatters/html"
12+ "github.com/alecthomas/chroma/v2/styles"
13+ "github.com/picosh/pgit"
14+ "github.com/spf13/cobra"
15+ "html/template"
16+)
17+
18+func main() {
19+ var rootCmd = &cobra.Command{
20+ Use: "pgit",
21+ Short: "Static site generator for git repositories",
22+ Long: `pgit generates a static website from a git repository,
23+including commit history, file browser, and syntax-highlighted code views.`,
24+ RunE: runGenerate,
25+ }
26+
27+ rootCmd.Flags().String("repo", ".", "path to git repo")
28+ rootCmd.Flags().String("out", "./public", "output directory")
29+ rootCmd.Flags().String("revs", "HEAD", "list of revs to generate logs and tree (e.g. main,v1,c69f86f,HEAD)")
30+ rootCmd.Flags().String("theme", "dracula", "theme to use for site")
31+ rootCmd.Flags().String("label", "", "pretty name for the subdir where we create the repo, default is last folder in --repo")
32+ rootCmd.Flags().String("clone-url", "", "git clone URL for upstream")
33+ rootCmd.Flags().String("home-url", "", "URL for breadcrumbs to go to root page, hidden if empty")
34+ rootCmd.Flags().String("desc", "", "description for repo")
35+ rootCmd.Flags().String("root-relative", "/", "html root relative")
36+ rootCmd.Flags().Int("max-commits", 0, "maximum number of commits to generate")
37+ rootCmd.Flags().Bool("hide-tree-last-commit", false, "don't calculate last commit for each file in the tree")
38+ rootCmd.Flags().Bool("issues", false, "enable git-bug issue generation")
39+
40+ var hookInstallCmd = &cobra.Command{
41+ Use: "hook-install",
42+ Short: "Install the post-commit hook",
43+ Long: "Installs the pgit post-commit hook to automatically close git-bug issues",
44+ RunE: func(cmd *cobra.Command, args []string) error {
45+ repoPath, _ := cmd.Flags().GetString("repo")
46+ if err := pgit.InstallHook(repoPath); err != nil {
47+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
48+ os.Exit(1)
49+ }
50+ return nil
51+ },
52+ }
53+ hookInstallCmd.Flags().String("repo", ".", "path to git repo")
54+
55+ var hookUninstallCmd = &cobra.Command{
56+ Use: "hook-uninstall",
57+ Short: "Uninstall the post-commit hook",
58+ Long: "Removes the pgit post-commit hook and restores any backup",
59+ RunE: func(cmd *cobra.Command, args []string) error {
60+ repoPath, _ := cmd.Flags().GetString("repo")
61+ if err := pgit.UninstallHook(repoPath); err != nil {
62+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
63+ os.Exit(1)
64+ }
65+ return nil
66+ },
67+ }
68+ hookUninstallCmd.Flags().String("repo", ".", "path to git repo")
69+
70+ rootCmd.AddCommand(hookInstallCmd)
71+ rootCmd.AddCommand(hookUninstallCmd)
72+
73+ if err := rootCmd.Execute(); err != nil {
74+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
75+ os.Exit(1)
76+ }
77+}
78+
79+func runGenerate(cmd *cobra.Command, args []string) error {
80+ outdir, _ := cmd.Flags().GetString("out")
81+ rpath, _ := cmd.Flags().GetString("repo")
82+ revsFlag, _ := cmd.Flags().GetString("revs")
83+ themeFlag, _ := cmd.Flags().GetString("theme")
84+ labelFlag, _ := cmd.Flags().GetString("label")
85+ cloneFlag, _ := cmd.Flags().GetString("clone-url")
86+ homeFlag, _ := cmd.Flags().GetString("home-url")
87+ descFlag, _ := cmd.Flags().GetString("desc")
88+ rootRelativeFlag, _ := cmd.Flags().GetString("root-relative")
89+ maxCommitsFlag, _ := cmd.Flags().GetInt("max-commits")
90+ hideTreeLastCommitFlag, _ := cmd.Flags().GetBool("hide-tree-last-commit")
91+ issuesFlag, _ := cmd.Flags().GetBool("issues")
92+
93+ out, err := filepath.Abs(outdir)
94+ if err != nil {
95+ return err
96+ }
97+ repoPath, err := filepath.Abs(rpath)
98+ if err != nil {
99+ return err
100+ }
101+
102+ theme := styles.Get(themeFlag)
103+ logger := slog.Default()
104+
105+ label := pgit.RepoName(repoPath)
106+ if labelFlag != "" {
107+ label = labelFlag
108+ }
109+
110+ revs := strings.Split(revsFlag, ",")
111+ if len(revs) == 1 && revs[0] == "" {
112+ revs = []string{}
113+ }
114+
115+ formatter := formatterHtml.New(
116+ formatterHtml.WithLineNumbers(true),
117+ formatterHtml.WithLinkableLineNumbers(true, ""),
118+ formatterHtml.WithClasses(true),
119+ )
120+
121+ tempConfig := &pgit.Config{
122+ Theme: theme,
123+ Logger: logger,
124+ RepoPath: repoPath,
125+ }
126+
127+ var descHTML template.HTML
128+ if descFlag != "" {
129+ descHTML = tempConfig.RenderMarkdown(descFlag)
130+ } else {
131+ gitDesc := pgit.ReadDescription(repoPath)
132+ if gitDesc != "" {
133+ descHTML = tempConfig.RenderMarkdown(gitDesc)
134+ }
135+ }
136+
137+ config := &pgit.Config{
138+ Outdir: out,
139+ RepoPath: repoPath,
140+ RepoName: label,
141+ Cache: make(map[string]bool),
142+ Revs: revs,
143+ Theme: theme,
144+ Logger: logger,
145+ CloneURL: template.URL(cloneFlag),
146+ HomeURL: template.URL(homeFlag),
147+ Desc: descHTML,
148+ MaxCommits: maxCommitsFlag,
149+ HideTreeLastCommit: hideTreeLastCommitFlag,
150+ Issues: issuesFlag,
151+ RootRelative: rootRelativeFlag,
152+ Formatter: formatter,
153+ }
154+ config.Logger.Info("config", "config", config)
155+
156+ if len(revs) == 0 {
157+ return fmt.Errorf("you must provide --revs")
158+ }
159+
160+ cssFile, err := config.BundleCSS()
161+ if err != nil {
162+ return err
163+ }
164+ config.CSSFile = cssFile
165+
166+ config.WriteRepo()
167+
168+ url := filepath.Join("/", "index.html")
169+ config.Logger.Info("root url", "url", url)
170+
171+ return nil
172+}
+367,
-0
1@@ -0,0 +1,367 @@
2+package pgit
3+
4+import (
5+ "embed"
6+ "html/template"
7+ "log"
8+ "log/slog"
9+ "path/filepath"
10+ "regexp"
11+ "strings"
12+ "sync"
13+ "time"
14+ "unicode/utf8"
15+
16+ "github.com/alecthomas/chroma/v2"
17+ formatterHtml "github.com/alecthomas/chroma/v2/formatters/html"
18+ "github.com/dustin/go-humanize"
19+ git "github.com/gogs/git-module"
20+)
21+
22+//go:embed html/*.tmpl
23+//go:embed static/*
24+//go:embed hooks/*.bash
25+var EmbedFS embed.FS
26+
27+//go:embed static/*
28+var StaticFS embed.FS
29+
30+type Config struct {
31+ Outdir string
32+ RepoPath string
33+ Revs []string
34+ Desc template.HTML
35+
36+ MaxCommits int
37+ Readme string
38+ HideTreeLastCommit bool
39+ Issues bool
40+
41+ HomeURL template.URL
42+ CloneURL template.URL
43+
44+ RootRelative string
45+
46+ Cache map[string]bool
47+ Mutex sync.RWMutex
48+ RepoName string
49+ Logger *slog.Logger
50+
51+ Theme *chroma.Style
52+ Formatter *formatterHtml.Formatter
53+ CSSFile string
54+}
55+
56+type RevInfo interface {
57+ ID() string
58+ Name() string
59+}
60+
61+type RevData struct {
62+ id string
63+ name string
64+ Config *Config
65+}
66+
67+func (r *RevData) ID() string {
68+ return r.id
69+}
70+
71+func (r *RevData) Name() string {
72+ return r.name
73+}
74+
75+func (r *RevData) TreeURL() template.URL {
76+ return r.Config.GetTreeURL(r)
77+}
78+
79+func (r *RevData) LogURL() template.URL {
80+ return r.Config.GetLogsURL(r)
81+}
82+
83+type TagData struct {
84+ Name string
85+ URL template.URL
86+}
87+
88+type Trailer struct {
89+ Key string
90+ Value string
91+}
92+
93+type CommitData struct {
94+ SummaryStr string
95+ URL template.URL
96+ WhenStr string
97+ WhenISO string
98+ WhenDisplay string
99+ AuthorStr string
100+ ShortID string
101+ ParentID string
102+ Refs []*RefInfo
103+ Trailers []Trailer
104+ MessageBodyOnly string
105+ *git.Commit
106+}
107+
108+type TreeItem struct {
109+ IsTextFile bool
110+ IsDir bool
111+ Size string
112+ NumLines int
113+ Name string
114+ Icon string
115+ Path string
116+ URL template.URL
117+ CommitID string
118+ CommitURL template.URL
119+ Summary string
120+ When string
121+ WhenISO string
122+ WhenDisplay string
123+ Author *git.Signature
124+ Entry *git.TreeEntry
125+ Crumbs []*Breadcrumb
126+}
127+
128+type DiffRender struct {
129+ NumFiles int
130+ TotalAdditions int
131+ TotalDeletions int
132+ Files []*DiffRenderFile
133+}
134+
135+type DiffRenderFile struct {
136+ FileType string
137+ OldMode git.EntryMode
138+ OldName string
139+ Mode git.EntryMode
140+ Name string
141+ Content template.HTML
142+ NumAdditions int
143+ NumDeletions int
144+}
145+
146+type RefInfo struct {
147+ ID string
148+ Refspec string
149+ URL template.URL
150+}
151+
152+type BranchOutput struct {
153+ Readme string
154+ LastCommit *CommitData
155+}
156+
157+type SiteURLs struct {
158+ HomeURL template.URL
159+ CloneURL template.URL
160+ SummaryURL template.URL
161+ RefsURL template.URL
162+ IssuesURL template.URL
163+}
164+
165+type PageData struct {
166+ Repo *Config
167+ SiteURLs *SiteURLs
168+ RevData *RevInfo
169+ Refs []*RefInfo
170+}
171+
172+type SummaryPageData struct {
173+ *PageData
174+ Readme template.HTML
175+ LastCommit *CommitData
176+}
177+
178+type TreePageData struct {
179+ *PageData
180+ Tree *TreeRoot
181+}
182+
183+type LogPageData struct {
184+ *PageData
185+ NumCommits int
186+ Logs []*CommitData
187+}
188+
189+type FilePageData struct {
190+ *PageData
191+ Contents template.HTML
192+ Item *TreeItem
193+}
194+
195+type CommitPageData struct {
196+ *PageData
197+ CommitMsg template.HTML
198+ CommitID string
199+ Commit *CommitData
200+ Diff *DiffRender
201+ Parent string
202+ ParentURL template.URL
203+ CommitURL template.URL
204+}
205+
206+type RefPageData struct {
207+ *PageData
208+ Refs []*RefInfo
209+}
210+
211+type WriteData struct {
212+ Template string
213+ Filename string
214+ Subdir string
215+ Data any
216+}
217+
218+func (s *SummaryPageData) Active() string { return "summary" }
219+func (s *TreePageData) Active() string { return "code" }
220+func (s *FilePageData) Active() string { return "code" }
221+func (s *LogPageData) Active() string { return "commits" }
222+func (s *CommitPageData) Active() string { return "commits" }
223+func (s *RefPageData) Active() string { return "refs" }
224+
225+func Bail(err error) {
226+ if err != nil {
227+ log.Fatalf("Error: %v", err)
228+ }
229+}
230+
231+func DiffFileType(_type git.DiffFileType) string {
232+ switch _type {
233+ case git.DiffFileAdd:
234+ return "A"
235+ case git.DiffFileChange:
236+ return "M"
237+ case git.DiffFileDelete:
238+ return "D"
239+ case git.DiffFileRename:
240+ return "R"
241+ default:
242+ return ""
243+ }
244+}
245+
246+func ToPretty(b int64) string {
247+ return humanize.Bytes(uint64(b))
248+}
249+
250+func FormatDateForDisplay(t time.Time) string {
251+ if time.Since(t).Hours() > 365*24 {
252+ return t.Format("Jan 2006")
253+ }
254+ return t.Format("Jan 2")
255+}
256+
257+func RepoName(root string) string {
258+ _, file := filepath.Split(root)
259+ return file
260+}
261+
262+func ReadmeFile(repo *Config) string {
263+ if repo.Readme == "" {
264+ return "readme.md"
265+ }
266+ return strings.ToLower(repo.Readme)
267+}
268+
269+func ReadDescription(repoPath string) string {
270+ descPath := filepath.Join(repoPath, ".git", "description")
271+ data, err := EmbedFS.ReadFile(descPath)
272+ if err != nil {
273+ descPath = filepath.Join(repoPath, "description")
274+ data, err = EmbedFS.ReadFile(descPath)
275+ if err != nil {
276+ return ""
277+ }
278+ }
279+
280+ desc := strings.TrimSpace(string(data))
281+
282+ if desc == "Unnamed repository; edit this file 'description' to name the repository." {
283+ return ""
284+ }
285+
286+ return desc
287+}
288+
289+func GetShortID(id string) string {
290+ return id[:7]
291+}
292+
293+func ParseCommitMessage(message string) ([]Trailer, string) {
294+ var trailers []Trailer
295+
296+ trailerRe := regexp.MustCompile(`^([A-Za-z0-9-]+): (.+)$`)
297+
298+ message = strings.TrimRight(message, "\n")
299+ lines := strings.Split(message, "\n")
300+
301+ trailerStartIdx := -1
302+
303+ for i := len(lines) - 1; i >= 0; i-- {
304+ line := strings.TrimSpace(lines[i])
305+
306+ if line == "" {
307+ trailerStartIdx = i + 1
308+ break
309+ }
310+
311+ matches := trailerRe.FindStringSubmatch(line)
312+ if matches != nil {
313+ trailers = append([]Trailer{
314+ {Key: matches[1], Value: matches[2]},
315+ }, trailers...)
316+ } else {
317+ trailerStartIdx = i + 1
318+ break
319+ }
320+ }
321+
322+ bodyLines := lines
323+ if len(trailers) > 0 && trailerStartIdx > 0 {
324+ bodyLines = lines[:trailerStartIdx]
325+ }
326+
327+ bodyEndIdx := len(bodyLines)
328+ for i := len(bodyLines) - 1; i >= 0; i-- {
329+ if strings.TrimSpace(bodyLines[i]) != "" {
330+ bodyEndIdx = i + 1
331+ break
332+ }
333+ }
334+ bodyLines = bodyLines[:bodyEndIdx]
335+
336+ messageBodyOnly := ""
337+ if len(bodyLines) > 1 {
338+ bodyOnlyLines := bodyLines[1:]
339+ startIdx := 0
340+ for i, line := range bodyOnlyLines {
341+ if strings.TrimSpace(line) != "" {
342+ startIdx = i
343+ break
344+ }
345+ }
346+ if startIdx < len(bodyOnlyLines) {
347+ messageBodyOnly = strings.Join(bodyOnlyLines[startIdx:], "\n")
348+ }
349+ }
350+
351+ return trailers, messageBodyOnly
352+}
353+
354+func IsText(s string) bool {
355+ const max = 1024
356+ if len(s) > max {
357+ s = s[0:max]
358+ }
359+ for i, c := range s {
360+ if i+utf8.UTFMax > len(s) {
361+ break
362+ }
363+ if c == 0xFFFD || c < ' ' && c != '\n' && c != '\t' && c != '\f' && c != '\r' {
364+ return false
365+ }
366+ }
367+ return true
368+}
A
css.go
+84,
-0
1@@ -0,0 +1,84 @@
2+package pgit
3+
4+import (
5+ "bytes"
6+ "crypto/sha256"
7+ "encoding/hex"
8+ "fmt"
9+ "os"
10+ "path/filepath"
11+
12+ "github.com/alecthomas/chroma/v2"
13+ "github.com/tdewolff/minify/v2"
14+ "github.com/tdewolff/minify/v2/css"
15+)
16+
17+func Style(theme chroma.Style) string {
18+ bg := theme.Get(chroma.Background)
19+ txt := theme.Get(chroma.Text)
20+ kw := theme.Get(chroma.Keyword)
21+ nv := theme.Get(chroma.NameVariable)
22+ cm := theme.Get(chroma.Comment)
23+ return fmt.Sprintf(`:root {
24+ --bg-color: %s;
25+ --text-color: %s;
26+ --border: %s;
27+ --link-color: %s;
28+ --hover: %s;
29+ --visited: %s;
30+}`,
31+ bg.Background.String(),
32+ txt.Colour.String(),
33+ cm.Colour.String(),
34+ nv.Colour.String(),
35+ kw.Colour.String(),
36+ nv.Colour.String(),
37+ )
38+}
39+
40+func (c *Config) BundleCSS() (string, error) {
41+ c.Logger.Info("bundling CSS files")
42+
43+ m := minify.New()
44+ m.AddFunc("text/css", css.Minify)
45+
46+ var buf bytes.Buffer
47+
48+ pgitCSS, err := StaticFS.ReadFile("static/pgit.css")
49+ if err != nil {
50+ return "", fmt.Errorf("failed to read pgit.css: %w", err)
51+ }
52+ buf.Write(pgitCSS)
53+ buf.WriteString("\n")
54+
55+ varsCSS := Style(*c.Theme)
56+ buf.WriteString(varsCSS)
57+ buf.WriteString("\n")
58+
59+ var syntaxBuf bytes.Buffer
60+ err = c.Formatter.WriteCSS(&syntaxBuf, c.Theme)
61+ if err != nil {
62+ return "", fmt.Errorf("failed to generate syntax.css: %w", err)
63+ }
64+ buf.Write(syntaxBuf.Bytes())
65+
66+ minified, err := m.Bytes("text/css", buf.Bytes())
67+ if err != nil {
68+ return "", fmt.Errorf("failed to minify CSS: %w", err)
69+ }
70+
71+ hash := sha256.Sum256(minified)
72+ hashStr := hex.EncodeToString(hash[:8])
73+
74+ filename := fmt.Sprintf("styles.%s.css", hashStr)
75+
76+ outPath := filepath.Join(c.Outdir, filename)
77+ err = os.WriteFile(outPath, minified, 0644)
78+ if err != nil {
79+ return "", fmt.Errorf("failed to write CSS bundle: %w", err)
80+ }
81+
82+ c.Logger.Info("CSS bundle created", "filename", filename, "size", len(minified))
83+
84+ return filename, nil
85+}
+443,
-0
1@@ -0,0 +1,443 @@
2+package pgit
3+
4+import (
5+ "fmt"
6+ "html/template"
7+ "path/filepath"
8+ "sort"
9+ "strings"
10+ "sync"
11+ "time"
12+
13+ git "github.com/gogs/git-module"
14+)
15+
16+func (c *Config) WriteRootSummary(data *PageData, readme template.HTML, lastCommit *CommitData) {
17+ c.Logger.Info("writing root html", "repoPath", c.RepoPath)
18+ c.WriteHTML(&WriteData{
19+ Filename: "index.html",
20+ Template: "html/summary.page.tmpl",
21+ Data: &SummaryPageData{
22+ PageData: data,
23+ Readme: readme,
24+ LastCommit: lastCommit,
25+ },
26+ })
27+}
28+
29+func (c *Config) WriteTree(data *PageData, tree *TreeRoot) {
30+ c.Logger.Info("writing tree", "treePath", tree.Path)
31+ c.WriteHTML(&WriteData{
32+ Filename: "index.html",
33+ Subdir: tree.Path,
34+ Template: "html/tree.page.tmpl",
35+ Data: &TreePageData{
36+ PageData: data,
37+ Tree: tree,
38+ },
39+ })
40+}
41+
42+func (c *Config) WriteLog(data *PageData, logs []*CommitData) {
43+ c.Logger.Info("writing log file", "revision", (*data.RevData).Name())
44+ c.WriteHTML(&WriteData{
45+ Filename: "index.html",
46+ Subdir: GetLogBaseDir(*data.RevData),
47+ Template: "html/log.page.tmpl",
48+ Data: &LogPageData{
49+ PageData: data,
50+ NumCommits: len(logs),
51+ Logs: logs,
52+ },
53+ })
54+}
55+
56+func (c *Config) WriteRefs(data *PageData, refs []*RefInfo) {
57+ c.Logger.Info("writing refs", "repoPath", c.RepoPath)
58+ c.WriteHTML(&WriteData{
59+ Filename: "refs.html",
60+ Template: "html/refs.page.tmpl",
61+ Data: &RefPageData{
62+ PageData: data,
63+ Refs: refs,
64+ },
65+ })
66+}
67+
68+func (c *Config) WriteHTMLTreeFile(pageData *PageData, treeItem *TreeItem) string {
69+ readme := ""
70+ b, err := treeItem.Entry.Blob().Bytes()
71+ Bail(err)
72+ str := string(b)
73+
74+ treeItem.IsTextFile = IsText(str)
75+
76+ contents := "binary file, cannot display"
77+ if treeItem.IsTextFile {
78+ treeItem.NumLines = len(strings.Split(str, "\n"))
79+ if IsMarkdownFile(treeItem.Entry.Name()) {
80+ contents = string(c.RenderMarkdown(str))
81+ } else {
82+ contents, err = c.ParseText(treeItem.Entry.Name(), string(b))
83+ Bail(err)
84+ }
85+ }
86+
87+ d := filepath.Dir(treeItem.Path)
88+
89+ nameLower := strings.ToLower(treeItem.Entry.Name())
90+ summary := ReadmeFile(pageData.Repo)
91+ if d == "." && nameLower == summary {
92+ readme = str
93+ }
94+
95+ c.WriteHTML(&WriteData{
96+ Filename: fmt.Sprintf("%s.html", treeItem.Entry.Name()),
97+ Template: "html/file.page.tmpl",
98+ Data: &FilePageData{
99+ PageData: pageData,
100+ Contents: template.HTML(contents),
101+ Item: treeItem,
102+ },
103+ Subdir: GetFileDir(*pageData.RevData, d),
104+ })
105+ return readme
106+}
107+
108+func (c *Config) WriteLogDiff(repo *git.Repository, pageData *PageData, commit *CommitData) {
109+ commitID := commit.ID.String()
110+
111+ c.Mutex.RLock()
112+ hasCommit := c.Cache[commitID]
113+ c.Mutex.RUnlock()
114+
115+ if hasCommit {
116+ c.Logger.Info("commit file already generated, skipping", "commitID", GetShortID(commitID))
117+ return
118+ }
119+ c.Mutex.Lock()
120+ c.Cache[commitID] = true
121+ c.Mutex.Unlock()
122+
123+ diff, err := repo.Diff(commitID, 0, 0, 0, git.DiffOptions{})
124+ Bail(err)
125+
126+ rnd := &DiffRender{
127+ NumFiles: diff.NumFiles(),
128+ TotalAdditions: diff.TotalAdditions(),
129+ TotalDeletions: diff.TotalDeletions(),
130+ }
131+ fls := []*DiffRenderFile{}
132+ for _, file := range diff.Files {
133+ fl := &DiffRenderFile{
134+ FileType: DiffFileType(file.Type),
135+ OldMode: file.OldMode(),
136+ OldName: file.OldName(),
137+ Mode: file.Mode(),
138+ Name: file.Name,
139+ NumAdditions: file.NumAdditions(),
140+ NumDeletions: file.NumDeletions(),
141+ }
142+ content := ""
143+ for _, section := range file.Sections {
144+ for _, line := range section.Lines {
145+ content += fmt.Sprintf("%s\n", line.Content)
146+ }
147+ }
148+ finContent, err := c.ParseText("commit.diff", content)
149+ Bail(err)
150+
151+ fl.Content = template.HTML(finContent)
152+ fls = append(fls, fl)
153+ }
154+ rnd.Files = fls
155+
156+ parentSha, _ := commit.Commit.ParentID(0)
157+ parentID := ""
158+ if parentSha == nil {
159+ parentID = commit.ID.String()
160+ } else {
161+ parentID = parentSha.String()
162+ }
163+
164+ commitData := &CommitPageData{
165+ PageData: pageData,
166+ Commit: commit,
167+ CommitID: GetShortID(commitID),
168+ Diff: rnd,
169+ Parent: GetShortID(parentID),
170+ CommitURL: c.GetCommitURL(commitID),
171+ ParentURL: c.GetCommitURL(parentID),
172+ }
173+
174+ c.WriteHTML(&WriteData{
175+ Filename: fmt.Sprintf("%s.html", commitID),
176+ Template: "html/commit.page.tmpl",
177+ Subdir: "commits",
178+ Data: commitData,
179+ })
180+}
181+
182+func (c *Config) WriteRepo() *BranchOutput {
183+ c.Logger.Info("writing repo", "repoPath", c.RepoPath)
184+ repo, err := git.Open(c.RepoPath)
185+ Bail(err)
186+
187+ refs, err := repo.ShowRef(git.ShowRefOptions{Heads: true, Tags: true})
188+ Bail(err)
189+
190+ var first *RevData
191+ revs := []*RevData{}
192+ for _, revStr := range c.Revs {
193+ fullRevID, err := repo.RevParse(revStr)
194+ Bail(err)
195+
196+ revID := GetShortID(fullRevID)
197+ revName := revID
198+ for _, ref := range refs {
199+ if revStr == git.RefShortName(ref.Refspec) || revStr == ref.Refspec {
200+ revName = revStr
201+ break
202+ }
203+ }
204+
205+ data := &RevData{
206+ id: fullRevID,
207+ name: revName,
208+ Config: c,
209+ }
210+
211+ if first == nil {
212+ first = data
213+ }
214+ revs = append(revs, data)
215+ }
216+
217+ if first == nil {
218+ Bail(fmt.Errorf("could not find a git reference that matches criteria"))
219+ }
220+
221+ refInfoMap := map[string]*RefInfo{}
222+ for _, revData := range revs {
223+ refInfoMap[revData.Name()] = &RefInfo{
224+ ID: revData.ID(),
225+ Refspec: revData.Name(),
226+ URL: revData.TreeURL(),
227+ }
228+ }
229+
230+ for _, ref := range refs {
231+ refspec := git.RefShortName(ref.Refspec)
232+ if refInfoMap[refspec] != nil {
233+ continue
234+ }
235+
236+ refInfoMap[refspec] = &RefInfo{
237+ ID: ref.ID,
238+ Refspec: refspec,
239+ }
240+ }
241+
242+ refInfoList := []*RefInfo{}
243+ for _, val := range refInfoMap {
244+ refInfoList = append(refInfoList, val)
245+ }
246+ sort.Slice(refInfoList, func(i, j int) bool {
247+ urlI := refInfoList[i].URL
248+ urlJ := refInfoList[j].URL
249+ refI := refInfoList[i].Refspec
250+ refJ := refInfoList[j].Refspec
251+ if urlI == urlJ {
252+ return refI < refJ
253+ }
254+ return urlI > urlJ
255+ })
256+
257+ mainOutput := &BranchOutput{}
258+ var wg sync.WaitGroup
259+ for i, revData := range revs {
260+ c.Logger.Info("writing revision", "revision", revData.Name())
261+ revInfo := RevInfo(revData)
262+ data := &PageData{
263+ Repo: c,
264+ RevData: &revInfo,
265+ SiteURLs: c.GetURLs(),
266+ Refs: refInfoList,
267+ }
268+
269+ if i == 0 {
270+ branchOutput := c.WriteRevision(repo, data, refInfoList)
271+ mainOutput = branchOutput
272+ } else {
273+ wg.Add(1)
274+ go func() {
275+ defer wg.Done()
276+ c.WriteRevision(repo, data, refInfoList)
277+ }()
278+ }
279+ }
280+ wg.Wait()
281+
282+ revData := &RevData{
283+ id: first.ID(),
284+ name: first.Name(),
285+ Config: c,
286+ }
287+
288+ revInfo := RevInfo(revData)
289+ data := &PageData{
290+ RevData: &revInfo,
291+ Repo: c,
292+ SiteURLs: c.GetURLs(),
293+ Refs: refInfoList,
294+ }
295+
296+ if c.Issues {
297+ err := c.WriteIssues(data)
298+ if err != nil {
299+ c.Logger.Warn("failed to write issues", "error", err)
300+ }
301+ }
302+
303+ c.WriteRefs(data, refInfoList)
304+ var readmeHTML template.HTML
305+ if IsMarkdownFile(ReadmeFile(c)) {
306+ readmeHTML = c.RenderMarkdown(mainOutput.Readme)
307+ } else {
308+ readmeHTML = template.HTML(mainOutput.Readme)
309+ }
310+ readmeHTML = template.HTML(`<div class="readme">` + string(readmeHTML) + `</div>`)
311+ c.WriteRootSummary(data, readmeHTML, mainOutput.LastCommit)
312+ return mainOutput
313+}
314+
315+func (c *Config) WriteRevision(repo *git.Repository, pageData *PageData, refs []*RefInfo) *BranchOutput {
316+ c.Logger.Info(
317+ "compiling revision",
318+ "repoName", c.RepoName,
319+ "revision", (*pageData.RevData).Name(),
320+ )
321+
322+ output := &BranchOutput{}
323+ var wg sync.WaitGroup
324+
325+ wg.Add(1)
326+ go func() {
327+ defer wg.Done()
328+
329+ pageSize := pageData.Repo.MaxCommits
330+ if pageSize == 0 {
331+ pageSize = 5000
332+ }
333+ commits, err := repo.CommitsByPage((*pageData.RevData).ID(), 0, pageSize)
334+ Bail(err)
335+
336+ logs := []*CommitData{}
337+ for i, commit := range commits {
338+ tags := []*RefInfo{}
339+ for _, ref := range refs {
340+ if commit.ID.String() == ref.ID {
341+ tags = append(tags, ref)
342+ }
343+ }
344+
345+ parentSha, _ := commit.ParentID(0)
346+ parentID := ""
347+ if parentSha == nil {
348+ parentID = commit.ID.String()
349+ } else {
350+ parentID = parentSha.String()
351+ }
352+ trailers, messageBodyOnly := ParseCommitMessage(commit.Message)
353+ cd := &CommitData{
354+ ParentID: parentID,
355+ URL: c.GetCommitURL(commit.ID.String()),
356+ ShortID: GetShortID(commit.ID.String()),
357+ SummaryStr: commit.Summary(),
358+ AuthorStr: commit.Author.Name,
359+ WhenStr: commit.Author.When.Format(time.DateOnly),
360+ WhenISO: commit.Author.When.UTC().Format(time.RFC3339),
361+ WhenDisplay: FormatDateForDisplay(commit.Author.When),
362+ Commit: commit,
363+ Refs: tags,
364+ Trailers: trailers,
365+ MessageBodyOnly: messageBodyOnly,
366+ }
367+ logs = append(logs, cd)
368+ if i == 0 {
369+ output.LastCommit = cd
370+ }
371+ }
372+
373+ c.WriteLog(pageData, logs)
374+
375+ for _, cm := range logs {
376+ wg.Add(1)
377+ go func(commit *CommitData) {
378+ defer wg.Done()
379+ c.WriteLogDiff(repo, pageData, commit)
380+ }(cm)
381+ }
382+ }()
383+
384+ tree, err := repo.LsTree((*pageData.RevData).ID())
385+ Bail(err)
386+
387+ readme := ""
388+ entries := make(chan *TreeItem)
389+ subtrees := make(chan *TreeRoot)
390+ tw := &TreeWalker{
391+ Config: c,
392+ PageData: pageData,
393+ Repo: repo,
394+ treeItem: entries,
395+ tree: subtrees,
396+ }
397+ wg.Add(1)
398+ go func() {
399+ defer wg.Done()
400+ tw.Walk(tree, "")
401+ }()
402+
403+ wg.Add(1)
404+ go func() {
405+ defer wg.Done()
406+ for e := range entries {
407+ wg.Add(1)
408+ go func(entry *TreeItem) {
409+ defer wg.Done()
410+ if entry.IsDir {
411+ return
412+ }
413+
414+ readmeStr := c.WriteHTMLTreeFile(pageData, entry)
415+ if readmeStr != "" {
416+ readme = readmeStr
417+ }
418+ }(e)
419+ }
420+ }()
421+
422+ wg.Add(1)
423+ go func() {
424+ defer wg.Done()
425+ for t := range subtrees {
426+ wg.Add(1)
427+ go func(tree *TreeRoot) {
428+ defer wg.Done()
429+ c.WriteTree(pageData, tree)
430+ }(t)
431+ }
432+ }()
433+
434+ wg.Wait()
435+
436+ c.Logger.Info(
437+ "compilation complete",
438+ "repoName", c.RepoName,
439+ "revision", (*pageData.RevData).Name(),
440+ )
441+
442+ output.Readme = readme
443+ return output
444+}
M
go.mod
+3,
-0
1@@ -46,6 +46,7 @@ require (
2 github.com/golang/protobuf v1.5.4 // indirect
3 github.com/golang/snappy v1.0.0 // indirect
4 github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
5+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
6 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
7 github.com/kevinburke/ssh_config v1.6.0 // indirect
8 github.com/klauspost/cpuid/v2 v2.3.0 // indirect
9@@ -59,6 +60,8 @@ require (
10 github.com/pmezard/go-difflib v1.0.0 // indirect
11 github.com/sergi/go-diff v1.4.0 // indirect
12 github.com/skeema/knownhosts v1.3.2 // indirect
13+ github.com/spf13/cobra v1.10.2 // indirect
14+ github.com/spf13/pflag v1.0.9 // indirect
15 github.com/steveyen/gtreap v0.1.0 // indirect
16 github.com/stretchr/testify v1.11.1 // indirect
17 github.com/tdewolff/parse/v2 v2.8.11 // indirect
M
go.sum
+9,
-0
1@@ -62,6 +62,7 @@ github.com/couchbase/moss v0.1.0/go.mod h1:9MaHIaRuy9pvLPUJxB8sh8OrLfyDczECVL37g
2 github.com/couchbase/vellum v1.0.2 h1:BrbP0NKiyDdndMPec8Jjhy0U47CZ0Lgx3xUC2r9rZqw=
3 github.com/couchbase/vellum v1.0.2/go.mod h1:FcwrEivFpNi24R3jLOs3n+fs5RnuQnQqCLBJ1uAg1W4=
4 github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
5+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
6 github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
7 github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
8 github.com/cznic/b v0.0.0-20181122101859-a26611c4d92d h1:SwD98825d6bdB+pEuTxWOXiSjBrHdOl/UVp75eI7JT8=
9@@ -130,6 +131,8 @@ github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSo
10 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
11 github.com/ikawaha/kagome.ipadic v1.1.2/go.mod h1:DPSBbU0czaJhAb/5uKQZHMc9MTVRpDugJfX+HddPHHg=
12 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
13+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
14+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
15 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
16 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
17 github.com/jmhodges/levigo v1.0.0 h1:q5EC36kV79HWeTBWsod3mG11EgStG3qArTKcvlksN1U=
18@@ -180,6 +183,7 @@ github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qq
19 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
20 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
21 github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
22+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
23 github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
24 github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
25 github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
26@@ -188,8 +192,12 @@ github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqR
27 github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
28 github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
29 github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
30+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
31+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
32 github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
33 github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
34+github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
35+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
36 github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
37 github.com/steveyen/gtreap v0.1.0 h1:CjhzTa274PyJLJuMZwIzCO1PfC00oRa8d1Kc78bFXJM=
38 github.com/steveyen/gtreap v0.1.0/go.mod h1:kl/5J7XbrOmlIbYIXdRHDDE5QxHqpk0cmkT7Z4dM9/Y=
39@@ -223,6 +231,7 @@ github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:
40 go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
41 go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
42 go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
43+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
44 golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
45 golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
46 golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
A
hooks.go
+96,
-0
1@@ -0,0 +1,96 @@
2+package pgit
3+
4+import (
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+)
10+
11+func InstallHook(repoPath string) error {
12+ absRepoPath, err := filepath.Abs(repoPath)
13+ if err != nil {
14+ return fmt.Errorf("could not resolve repo path: %w", err)
15+ }
16+
17+ hookContent, err := EmbedFS.ReadFile("hooks/post-commit.bash")
18+ if err != nil {
19+ return fmt.Errorf("could not read embedded hook: %w", err)
20+ }
21+
22+ hooksDir := filepath.Join(absRepoPath, ".git", "hooks")
23+ if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
24+ bareHooksDir := filepath.Join(repoPath, "hooks")
25+ if _, err := os.Stat(bareHooksDir); err == nil {
26+ hooksDir = bareHooksDir
27+ fmt.Printf("Detected bare repository, installing to %s\n", hooksDir)
28+ }
29+ }
30+
31+ postCommitHook := filepath.Join(hooksDir, "post-commit")
32+
33+ if err := os.MkdirAll(hooksDir, 0755); err != nil {
34+ return fmt.Errorf("could not create hooks directory: %w", err)
35+ }
36+
37+ if _, err := os.Stat(postCommitHook); err == nil {
38+ backupPath := postCommitHook + ".backup"
39+ if err := os.Rename(postCommitHook, backupPath); err != nil {
40+ return fmt.Errorf("failed to backup existing hook: %w", err)
41+ }
42+ fmt.Printf("Backed up existing post-commit hook to %s\n", backupPath)
43+ }
44+
45+ if err := os.WriteFile(postCommitHook, hookContent, 0755); err != nil {
46+ return fmt.Errorf("failed to write hook: %w", err)
47+ }
48+
49+ fmt.Printf("Installed post-commit hook to %s\n", postCommitHook)
50+ fmt.Println("Commits with issue references (e.g., 'fixes #872a52d') will now automatically close git-bug issues")
51+ return nil
52+}
53+
54+func UninstallHook(repoPath string) error {
55+ absRepoPath, err := filepath.Abs(repoPath)
56+ if err != nil {
57+ return fmt.Errorf("could not resolve repo path: %w", err)
58+ }
59+
60+ hooksDir := filepath.Join(absRepoPath, ".git", "hooks")
61+ if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
62+ bareHooksDir := filepath.Join(repoPath, "hooks")
63+ if _, err := os.Stat(bareHooksDir); err == nil {
64+ hooksDir = bareHooksDir
65+ }
66+ }
67+
68+ postCommitHook := filepath.Join(hooksDir, "post-commit")
69+
70+ data, err := os.ReadFile(postCommitHook)
71+ if err != nil {
72+ if os.IsNotExist(err) {
73+ fmt.Println("No post-commit hook found")
74+ return nil
75+ }
76+ return fmt.Errorf("failed to read hook: %w", err)
77+ }
78+
79+ if !strings.Contains(string(data), "pgit post-commit hook") {
80+ return fmt.Errorf("post-commit hook is not a pgit hook (not removing)")
81+ }
82+
83+ if err := os.Remove(postCommitHook); err != nil {
84+ return fmt.Errorf("failed to remove hook: %w", err)
85+ }
86+
87+ backupPath := postCommitHook + ".backup"
88+ if _, err := os.Stat(backupPath); err == nil {
89+ if err := os.Rename(backupPath, postCommitHook); err != nil {
90+ return fmt.Errorf("failed to restore backup: %w", err)
91+ }
92+ fmt.Printf("Restored backup hook from %s\n", backupPath)
93+ }
94+
95+ fmt.Println("Uninstalled pgit post-commit hook")
96+ return nil
97+}
A
html.go
+52,
-0
1@@ -0,0 +1,52 @@
2+package pgit
3+
4+import (
5+ "html/template"
6+ "os"
7+ "path/filepath"
8+)
9+
10+func (c *Config) WriteHTML(writeData *WriteData) {
11+ ts, err := template.ParseFS(
12+ EmbedFS,
13+ writeData.Template,
14+ "html/header.partial.tmpl",
15+ "html/footer.partial.tmpl",
16+ "html/base.layout.tmpl",
17+ )
18+ Bail(err)
19+
20+ dir := filepath.Join(c.Outdir, writeData.Subdir)
21+ err = os.MkdirAll(dir, os.ModePerm)
22+ Bail(err)
23+
24+ fp := filepath.Join(dir, writeData.Filename)
25+ c.Logger.Info("writing", "filepath", fp)
26+
27+ w, err := os.OpenFile(fp, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
28+ Bail(err)
29+
30+ err = ts.Execute(w, writeData.Data)
31+ Bail(err)
32+}
33+
34+func (c *Config) CopyStatic(dir string) error {
35+ entries, err := StaticFS.ReadDir(dir)
36+ Bail(err)
37+
38+ for _, e := range entries {
39+ infp := filepath.Join(dir, e.Name())
40+ if e.IsDir() {
41+ continue
42+ }
43+
44+ w, err := StaticFS.ReadFile(infp)
45+ Bail(err)
46+ fp := filepath.Join(c.Outdir, e.Name())
47+ c.Logger.Info("writing", "filepath", fp)
48+ err = os.WriteFile(fp, w, 0644)
49+ Bail(err)
50+ }
51+
52+ return nil
53+}
+23,
-64
1@@ -1,4 +1,4 @@
2-package main
3+package pgit
4
5 import (
6 "fmt"
7@@ -12,52 +12,42 @@ import (
8 "github.com/git-bug/git-bug/repository"
9 )
10
11-// commitRefRegex matches "Fixed in commit [40-char-hash]" or similar patterns
12-// Captures the full 40-character commit hash
13-// (?i) makes it case-insensitive, \b ensures word boundaries
14 var commitRefRegex = regexp.MustCompile(`(?i)\bFixed in commit ([a-f0-9]{40})\b`)
15
16-// transformCommitRefs converts plain text commit references in issue comments
17-// into clickable links using pgit's URL scheme
18 func (c *Config) transformCommitRefs(content template.HTML) template.HTML {
19 contentStr := string(content)
20
21 result := commitRefRegex.ReplaceAllStringFunc(contentStr, func(match string) string {
22- // Extract hash by position: the hash is always exactly 40 hex chars at the end
23- // This avoids the double regex execution (FindStringSubmatch inside ReplaceAllStringFunc)
24 if len(match) < 40 {
25 return match
26 }
27 hash := match[len(match)-40:]
28 prefix := match[:len(match)-40]
29- shortHash := getShortID(hash)
30- commitURL := c.getCommitURL(hash)
31+ shortHash := GetShortID(hash)
32+ commitURL := c.GetCommitURL(hash)
33
34- // Return the linked version preserving original casing
35 return fmt.Sprintf(`%s<a href="%s">%s</a>`, prefix, commitURL, shortHash)
36 })
37
38 return template.HTML(result)
39 }
40
41-// IssueData represents a git-bug issue for templates
42 type IssueData struct {
43 ID string
44 FullID string
45 Title string
46- Status string // "open" or "closed"
47+ Status string
48 Author string
49 CreatedAt string
50 CreatedAtISO string
51 CreatedAtDisp string
52 Labels []string
53- CommentCount int // excludes original description
54+ CommentCount int
55 Description template.HTML
56 Comments []CommentData
57 URL template.URL
58 }
59
60-// CommentData represents a comment on an issue
61 type CommentData struct {
62 Author string
63 CreatedAt string
64@@ -66,40 +56,34 @@ type CommentData struct {
65 Body template.HTML
66 }
67
68-// IssuesListPageData for list pages (open, closed, by label)
69 type IssuesListPageData struct {
70 *PageData
71- Filter string // "open", "closed", or "label"
72+ Filter string
73 OpenCount int
74 ClosedCount int
75 Issues []*IssueData
76- Label string // set when Filter == "label"
77- AllLabels []string // all unique labels from open issues
78- OpenIssuesURL template.URL // URL to open issues list
79- ClosedIssuesURL template.URL // URL to closed issues list
80+ Label string
81+ AllLabels []string
82+ OpenIssuesURL template.URL
83+ ClosedIssuesURL template.URL
84 }
85
86-// IssueDetailPageData for individual issue pages
87 type IssueDetailPageData struct {
88 *PageData
89 Issue *IssueData
90 }
91
92-// Active returns the active navigation item
93 func (i *IssuesListPageData) Active() string { return "issues" }
94 func (i *IssueDetailPageData) Active() string { return "issues" }
95
96-// loadIssues reads all issues from git-bug in the repository
97 func (c *Config) loadIssues() ([]*IssueData, error) {
98 c.Logger.Info("loading issues from git-bug", "repoPath", c.RepoPath)
99
100- // Open the repository with git-bug's repo opener
101 repo, err := repository.OpenGoGitRepo(c.RepoPath, "", nil)
102 if err != nil {
103 return nil, fmt.Errorf("failed to open repository: %w", err)
104 }
105
106- // Read all bugs directly from the repository (without cache)
107 var issues []*IssueData
108
109 for streamedBug := range bug.ReadAll(repo) {
110@@ -111,58 +95,52 @@ func (c *Config) loadIssues() ([]*IssueData, error) {
111 b := streamedBug.Entity
112 snap := b.Compile()
113
114- // Count comments (excluding the original description)
115 commentCount := len(snap.Comments) - 1
116 if commentCount < 0 {
117 commentCount = 0
118 }
119
120- // Build labels slice
121 labels := make([]string, len(snap.Labels))
122 for i, label := range snap.Labels {
123 labels[i] = string(label)
124 }
125
126- // Build comments (skip first comment which is the description)
127 var comments []CommentData
128 for i, comment := range snap.Comments {
129 if i == 0 {
130- // Skip the original description
131 continue
132 }
133- // Parse RFC1123 format and convert to ISO 8601 (UTC) for JavaScript
134 createdAtTime, _ := time.Parse("Mon Jan 2 15:04:05 2006 -0700", comment.FormatTime())
135 comments = append(comments, CommentData{
136 Author: comment.Author.Name(),
137 CreatedAt: comment.FormatTime(),
138 CreatedAtISO: createdAtTime.UTC().Format(time.RFC3339),
139- CreatedAtDisp: formatDateForDisplay(createdAtTime),
140- Body: c.transformCommitRefs(c.renderMarkdown(comment.Message)),
141+ CreatedAtDisp: FormatDateForDisplay(createdAtTime),
142+ Body: c.transformCommitRefs(c.RenderMarkdown(comment.Message)),
143 })
144 }
145
146- // Get description from first comment
147 var description template.HTML
148 if len(snap.Comments) > 0 {
149- description = c.renderMarkdown(snap.Comments[0].Message)
150+ description = c.RenderMarkdown(snap.Comments[0].Message)
151 description = c.transformCommitRefs(description)
152 }
153
154 fullID := b.Id().String()
155 issue := &IssueData{
156- ID: getShortID(fullID),
157+ ID: GetShortID(fullID),
158 FullID: fullID,
159 Title: snap.Title,
160 Status: snap.Status.String(),
161 Author: snap.Author.Name(),
162 CreatedAt: snap.CreateTime.Format("Mon Jan 2 15:04:05 2006 -0700"),
163 CreatedAtISO: snap.CreateTime.UTC().Format(time.RFC3339),
164- CreatedAtDisp: formatDateForDisplay(snap.CreateTime),
165+ CreatedAtDisp: FormatDateForDisplay(snap.CreateTime),
166 Labels: labels,
167 CommentCount: commentCount,
168 Description: description,
169 Comments: comments,
170- URL: c.getIssueURL(fullID),
171+ URL: c.GetIssueURL(fullID),
172 }
173 issues = append(issues, issue)
174 }
175@@ -170,29 +148,20 @@ func (c *Config) loadIssues() ([]*IssueData, error) {
176 return issues, nil
177 }
178
179-// getIssueURL generates the URL for an issue detail page
180-func (c *Config) getIssueURL(issueID string) template.URL {
181- url := fmt.Sprintf("%sissues/%s.html", c.RootRelative, issueID)
182- return template.URL(url)
183-}
184-
185-// getIssuesListURL generates URL for issues list pages
186-func (c *Config) getIssuesListURL(filter string, label string) template.URL {
187+func (c *Config) GetIssuesListURL(filter string, label string) template.URL {
188 var path string
189 switch filter {
190 case "open", "closed":
191 path = filepath.Join("issues", filter, "index.html")
192 case "label":
193- // URL-encode the label
194 encodedLabel := url.PathEscape(label)
195 path = filepath.Join("issues", "label", encodedLabel, "index.html")
196 default:
197 path = filepath.Join("issues", "open", "index.html")
198 }
199- return c.compileURL("/", path)
200+ return c.CompileURL("/", path)
201 }
202
203-// writeIssueListPage generates an issues list page
204 func (c *Config) writeIssueListPage(data *PageData, filter string, label string, issues []*IssueData, openCount, closedCount int, allLabels []string) {
205 c.Logger.Info("writing issues list", "filter", filter, "label", label, "count", len(issues))
206
207@@ -204,8 +173,8 @@ func (c *Config) writeIssueListPage(data *PageData, filter string, label string,
208 Issues: issues,
209 Label: label,
210 AllLabels: allLabels,
211- OpenIssuesURL: c.getIssuesListURL("open", ""),
212- ClosedIssuesURL: c.getIssuesListURL("closed", ""),
213+ OpenIssuesURL: c.GetIssuesListURL("open", ""),
214+ ClosedIssuesURL: c.GetIssuesListURL("closed", ""),
215 }
216
217 var subdir string
218@@ -219,7 +188,7 @@ func (c *Config) writeIssueListPage(data *PageData, filter string, label string,
219 subdir = filepath.Join("issues/label", encodedLabel)
220 }
221
222- c.writeHtml(&WriteData{
223+ c.WriteHTML(&WriteData{
224 Filename: "index.html",
225 Subdir: subdir,
226 Template: "html/issues_list.page.tmpl",
227@@ -227,7 +196,6 @@ func (c *Config) writeIssueListPage(data *PageData, filter string, label string,
228 })
229 }
230
231-// writeIssueDetailPage generates an individual issue page
232 func (c *Config) writeIssueDetailPage(data *PageData, issue *IssueData) {
233 c.Logger.Info("writing issue detail", "id", issue.ID, "title", issue.Title)
234
235@@ -236,7 +204,7 @@ func (c *Config) writeIssueDetailPage(data *PageData, issue *IssueData) {
236 Issue: issue,
237 }
238
239- c.writeHtml(&WriteData{
240+ c.WriteHTML(&WriteData{
241 Filename: fmt.Sprintf("%s.html", issue.FullID),
242 Subdir: "issues",
243 Template: "html/issue_detail.page.tmpl",
244@@ -244,15 +212,12 @@ func (c *Config) writeIssueDetailPage(data *PageData, issue *IssueData) {
245 })
246 }
247
248-// writeIssues generates all issue-related pages
249-func (c *Config) writeIssues(pageData *PageData) error {
250- // Load all issues from git-bug
251+func (c *Config) WriteIssues(pageData *PageData) error {
252 issues, err := c.loadIssues()
253 if err != nil {
254 return fmt.Errorf("failed to load issues: %w", err)
255 }
256
257- // If no issues, skip generation
258 if len(issues) == 0 {
259 c.Logger.Info("no git-bug issues found, skipping issue generation")
260 return nil
261@@ -260,7 +225,6 @@ func (c *Config) writeIssues(pageData *PageData) error {
262
263 c.Logger.Info("loaded issues", "count", len(issues))
264
265- // Categorize issues
266 var openIssues, closedIssues []*IssueData
267 labelIssues := make(map[string][]*IssueData)
268 allLabels := make(map[string]bool)
269@@ -268,7 +232,6 @@ func (c *Config) writeIssues(pageData *PageData) error {
270 for _, issue := range issues {
271 if issue.Status == "open" {
272 openIssues = append(openIssues, issue)
273- // Collect labels from open issues
274 for _, label := range issue.Labels {
275 allLabels[label] = true
276 labelIssues[label] = append(labelIssues[label], issue)
277@@ -281,22 +244,18 @@ func (c *Config) writeIssues(pageData *PageData) error {
278 openCount := len(openIssues)
279 closedCount := len(closedIssues)
280
281- // Build sorted label list
282 var sortedLabels []string
283 for label := range allLabels {
284 sortedLabels = append(sortedLabels, label)
285 }
286
287- // Generate individual issue pages for all issues
288 for _, issue := range issues {
289 c.writeIssueDetailPage(pageData, issue)
290 }
291
292- // Generate list pages
293 c.writeIssueListPage(pageData, "open", "", openIssues, openCount, closedCount, sortedLabels)
294 c.writeIssueListPage(pageData, "closed", "", closedIssues, openCount, closedCount, sortedLabels)
295
296- // Generate label filter pages (only for open issues)
297 for label, issues := range labelIssues {
298 c.writeIssueListPage(pageData, "label", label, issues, openCount, closedCount, sortedLabels)
299 }
D
main.go
+0,
-1655
1@@ -1,1655 +0,0 @@
2-package main
3-
4-import (
5- "bytes"
6- "crypto/sha256"
7- "embed"
8- "encoding/hex"
9- "flag"
10- "fmt"
11- "html/template"
12- "io"
13- "log/slog"
14- "math"
15- "os"
16- "path/filepath"
17- "regexp"
18- "sort"
19- "strings"
20- "sync"
21- "time"
22- "unicode/utf8"
23-
24- "github.com/alecthomas/chroma/v2"
25- "github.com/alecthomas/chroma/v2/formatters"
26- formatterHtml "github.com/alecthomas/chroma/v2/formatters/html"
27- "github.com/alecthomas/chroma/v2/lexers"
28- "github.com/alecthomas/chroma/v2/styles"
29- "github.com/dustin/go-humanize"
30- git "github.com/gogs/git-module"
31- "github.com/gomarkdown/markdown"
32- "github.com/gomarkdown/markdown/ast"
33- "github.com/gomarkdown/markdown/html"
34- "github.com/gomarkdown/markdown/parser"
35- "github.com/tdewolff/minify/v2"
36- "github.com/tdewolff/minify/v2/css"
37-)
38-
39-//go:embed html/*.tmpl
40-//go:embed static/*
41-//go:embed hooks/*.bash
42-var embedFS embed.FS
43-
44-//go:embed static/*
45-var staticFS embed.FS
46-
47-type Config struct {
48- // required params
49- Outdir string
50- // abs path to git repo
51- RepoPath string
52-
53- // optional params
54- // generate logs anad tree based on the git revisions provided
55- Revs []string
56- // description of repo used in the header of site (HTML)
57- Desc template.HTML
58- // maximum number of commits that we will process in descending order
59- MaxCommits int
60- // name of the readme file
61- Readme string
62- // In order to get the latest commit per file we do a `git rev-list {ref} {file}`
63- // which is n+1 where n is a file in the tree.
64- // We offer a way to disable showing the latest commit in the output
65- // for those who want a faster build time
66- HideTreeLastCommit bool
67- // enable git-bug issue generation
68- Issues bool
69-
70- // user-defined urls
71- HomeURL template.URL
72- CloneURL template.URL
73-
74- // https://developer.mozilla.org/en-US/docs/Web/API/URL_API/Resolving_relative_references#root_relative
75- RootRelative string
76-
77- // computed
78- // cache for skipping commits, trees, etc.
79- Cache map[string]bool
80- // mutex for Cache
81- Mutex sync.RWMutex
82- // pretty name for the repo
83- RepoName string
84- // logger
85- Logger *slog.Logger
86- // chroma style
87- Theme *chroma.Style
88- Formatter *formatterHtml.Formatter
89- // CSS bundle filename (with content hash)
90- CSSFile string
91-}
92-
93-type RevInfo interface {
94- ID() string
95- Name() string
96-}
97-
98-type RevData struct {
99- id string
100- name string
101- Config *Config
102-}
103-
104-func (r *RevData) ID() string {
105- return r.id
106-}
107-
108-func (r *RevData) Name() string {
109- return r.name
110-}
111-
112-func (r *RevData) TreeURL() template.URL {
113- return r.Config.getTreeURL(r)
114-}
115-
116-func (r *RevData) LogURL() template.URL {
117- return r.Config.getLogsURL(r)
118-}
119-
120-type TagData struct {
121- Name string
122- URL template.URL
123-}
124-
125-type Trailer struct {
126- Key string
127- Value string
128-}
129-
130-type CommitData struct {
131- SummaryStr string
132- URL template.URL
133- WhenStr string
134- WhenISO string
135- WhenDisplay string
136- AuthorStr string
137- ShortID string
138- ParentID string
139- Refs []*RefInfo
140- Trailers []Trailer
141- // MessageBodyOnly is the commit message body without summary line and trailers
142- MessageBodyOnly string
143- *git.Commit
144-}
145-
146-type TreeItem struct {
147- IsTextFile bool
148- IsDir bool
149- Size string
150- NumLines int
151- Name string
152- Icon string
153- Path string
154- URL template.URL
155- CommitID string
156- CommitURL template.URL
157- Summary string
158- When string
159- WhenISO string
160- WhenDisplay string
161- Author *git.Signature
162- Entry *git.TreeEntry
163- Crumbs []*Breadcrumb
164-}
165-
166-type DiffRender struct {
167- NumFiles int
168- TotalAdditions int
169- TotalDeletions int
170- Files []*DiffRenderFile
171-}
172-
173-type DiffRenderFile struct {
174- FileType string
175- OldMode git.EntryMode
176- OldName string
177- Mode git.EntryMode
178- Name string
179- Content template.HTML
180- NumAdditions int
181- NumDeletions int
182-}
183-
184-type RefInfo struct {
185- ID string
186- Refspec string
187- URL template.URL
188-}
189-
190-type BranchOutput struct {
191- Readme string
192- LastCommit *CommitData
193-}
194-
195-type SiteURLs struct {
196- HomeURL template.URL
197- CloneURL template.URL
198- SummaryURL template.URL
199- RefsURL template.URL
200- IssuesURL template.URL
201-}
202-
203-type PageData struct {
204- Repo *Config
205- SiteURLs *SiteURLs
206- RevData *RevData
207- Refs []*RefInfo
208-}
209-
210-type SummaryPageData struct {
211- *PageData
212- Readme template.HTML
213- LastCommit *CommitData
214-}
215-
216-type TreePageData struct {
217- *PageData
218- Tree *TreeRoot
219-}
220-
221-type LogPageData struct {
222- *PageData
223- NumCommits int
224- Logs []*CommitData
225-}
226-
227-type FilePageData struct {
228- *PageData
229- Contents template.HTML
230- Item *TreeItem
231-}
232-
233-type CommitPageData struct {
234- *PageData
235- CommitMsg template.HTML
236- CommitID string
237- Commit *CommitData
238- Diff *DiffRender
239- Parent string
240- ParentURL template.URL
241- CommitURL template.URL
242-}
243-
244-type RefPageData struct {
245- *PageData
246- Refs []*RefInfo
247-}
248-
249-func (s *SummaryPageData) Active() string { return "summary" }
250-func (s *TreePageData) Active() string { return "code" }
251-func (s *FilePageData) Active() string { return "code" }
252-func (s *LogPageData) Active() string { return "commits" }
253-func (s *CommitPageData) Active() string { return "commits" }
254-func (s *RefPageData) Active() string { return "refs" }
255-
256-type WriteData struct {
257- Template string
258- Filename string
259- Subdir string
260- Data any
261-}
262-
263-func bail(err error) {
264- if err != nil {
265- panic(err)
266- }
267-}
268-
269-func diffFileType(_type git.DiffFileType) string {
270- switch _type {
271- case git.DiffFileAdd:
272- return "A"
273- case git.DiffFileChange:
274- return "M"
275- case git.DiffFileDelete:
276- return "D"
277- case git.DiffFileRename:
278- return "R"
279- default:
280- return ""
281- }
282-}
283-
284-// converts contents of files in git tree to pretty formatted code.
285-func (c *Config) parseText(filename string, text string) (string, error) {
286- lexer := lexers.Match(filename)
287- if lexer == nil {
288- lexer = lexers.Analyse(text)
289- }
290- if lexer == nil {
291- lexer = lexers.Get("plaintext")
292- }
293- iterator, err := lexer.Tokenise(nil, text)
294- if err != nil {
295- return text, err
296- }
297- var buf bytes.Buffer
298- err = c.Formatter.Format(&buf, c.Theme, iterator)
299- if err != nil {
300- return text, err
301- }
302- return buf.String(), nil
303-}
304-
305-// isText reports whether a significant prefix of s looks like correct UTF-8;
306-// that is, if it is likely that s is human-readable text.
307-func isText(s string) bool {
308- const max = 1024 // at least utf8.UTFMax
309- if len(s) > max {
310- s = s[0:max]
311- }
312- for i, c := range s {
313- if i+utf8.UTFMax > len(s) {
314- // last char may be incomplete - ignore
315- break
316- }
317- if c == 0xFFFD || c < ' ' && c != '\n' && c != '\t' && c != '\f' && c != '\r' {
318- // decoding error or control character - not a text file
319- return false
320- }
321- }
322- return true
323-}
324-
325-// isTextFile reports whether the file has a known extension indicating
326-// a text file, or if a significant chunk of the specified file looks like
327-// correct UTF-8; that is, if it is likely that the file contains human-
328-// readable text.
329-func isTextFile(text string) bool {
330- num := math.Min(float64(len(text)), 1024)
331- return isText(text[0:int(num)])
332-}
333-
334-// isMarkdownFile reports whether the file has a .md extension (case-insensitive).
335-func isMarkdownFile(filename string) bool {
336- ext := strings.ToLower(filepath.Ext(filename))
337- return ext == ".md"
338-}
339-
340-// chromaMarkdownRenderer is a custom HTML renderer that applies Chroma syntax highlighting to code blocks
341-type chromaMarkdownRenderer struct {
342- defaultRenderer *html.Renderer
343- theme *chroma.Style
344-}
345-
346-func (r *chromaMarkdownRenderer) RenderNode(w io.Writer, node ast.Node, entering bool) ast.WalkStatus {
347- // Only process code blocks when entering (not when exiting)
348- if entering {
349- if codeBlock, ok := node.(*ast.CodeBlock); ok {
350- // Extract language from Info field (e.g., "go" from "```go")
351- lang := extractLanguage(codeBlock.Info)
352-
353- // Get the code content from Literal
354- code := string(codeBlock.Literal)
355-
356- // Use Chroma to highlight
357- highlighted, err := r.highlightCode(lang, code)
358- if err == nil {
359- w.Write([]byte(highlighted))
360- return ast.SkipChildren
361- }
362- // Fall back to default rendering if highlighting fails
363- }
364- }
365-
366- // Use default renderer for all other nodes
367- return r.defaultRenderer.RenderNode(w, node, entering)
368-}
369-
370-func (r *chromaMarkdownRenderer) RenderHeader(w io.Writer, ast ast.Node) {
371- r.defaultRenderer.RenderHeader(w, ast)
372-}
373-
374-func (r *chromaMarkdownRenderer) RenderFooter(w io.Writer, ast ast.Node) {
375- r.defaultRenderer.RenderFooter(w, ast)
376-}
377-
378-func (r *chromaMarkdownRenderer) highlightCode(lang, code string) (string, error) {
379- // Get appropriate lexer
380- var lexer chroma.Lexer
381- if lang != "" {
382- lexer = lexers.Get(lang)
383- }
384- if lexer == nil {
385- lexer = lexers.Analyse(code)
386- }
387- if lexer == nil {
388- lexer = lexers.Get("plaintext")
389- }
390-
391- // Tokenize
392- iterator, err := lexer.Tokenise(nil, code)
393- if err != nil {
394- return "", err
395- }
396-
397- // Create formatter WITHOUT line numbers for markdown code blocks
398- formatter := formatters.Get("html")
399- if formatter == nil {
400- return "", fmt.Errorf("failed to get HTML formatter")
401- }
402-
403- // Format with Chroma
404- var buf bytes.Buffer
405- err = formatter.Format(&buf, r.theme, iterator)
406- if err != nil {
407- return "", err
408- }
409-
410- // Chroma's HTML formatter wraps output in <pre class="chroma"><code>...</code></pre>
411- // We need to strip the outer <pre> wrapper since gomarkdown already adds it,
412- // but keep the <code class="chroma"> tag for styling
413- highlighted := buf.String()
414-
415- // Remove Chroma's <pre class="chroma"> prefix and </pre> suffix
416- // This leaves us with <code class="chroma">...</code> which is what we want
417- highlighted = strings.TrimPrefix(highlighted, "<pre class=\"chroma\">")
418- highlighted = strings.TrimSuffix(highlighted, "</pre>")
419-
420- return highlighted, nil
421-}
422-
423-func extractLanguage(info []byte) string {
424- if len(info) == 0 {
425- return "" // No language specified
426- }
427- // Info may contain additional text after the language (e.g., "go filename.go")
428- parts := strings.Fields(string(info))
429- if len(parts) > 0 {
430- return parts[0] // First part is the language
431- }
432- return ""
433-}
434-
435-// renderMarkdown converts markdown text to HTML with Chroma syntax highlighting for code blocks.
436-func (c *Config) renderMarkdown(mdText string) template.HTML {
437- // Parse markdown with fenced code block support
438- extensions := parser.CommonExtensions | parser.FencedCode
439- p := parser.NewWithExtensions(extensions)
440- doc := p.Parse([]byte(mdText))
441-
442- // Create default HTML renderer
443- htmlFlags := html.CommonFlags
444- opts := html.RendererOptions{Flags: htmlFlags}
445- defaultRenderer := html.NewRenderer(opts)
446-
447- // Create custom renderer with Chroma support
448- customRenderer := &chromaMarkdownRenderer{
449- defaultRenderer: defaultRenderer,
450- theme: c.Theme,
451- }
452-
453- // Render to HTML
454- htmlBytes := markdown.Render(doc, customRenderer)
455- return template.HTML(htmlBytes)
456-}
457-
458-func toPretty(b int64) string {
459- return humanize.Bytes(uint64(b))
460-}
461-
462-// formatDateForDisplay returns a static date format for non-JS display:
463-// "Jan 2" for dates less than a year ago, "Jan 2006" for older dates
464-func formatDateForDisplay(t time.Time) string {
465- if time.Since(t).Hours() > 365*24 {
466- return t.Format("Jan 2006")
467- }
468- return t.Format("Jan 2")
469-}
470-
471-func repoName(root string) string {
472- _, file := filepath.Split(root)
473- return file
474-}
475-
476-func readmeFile(repo *Config) string {
477- if repo.Readme == "" {
478- return "readme.md"
479- }
480-
481- return strings.ToLower(repo.Readme)
482-}
483-
484-// readDescription reads the repository description from .git/description (cloned repo)
485-// or ./description (bare repo). Returns empty string if not found.
486-func readDescription(repoPath string) string {
487- // Try .git/description first (cloned repo)
488- descPath := filepath.Join(repoPath, ".git", "description")
489- data, err := os.ReadFile(descPath)
490- if err != nil {
491- // Try ./description (bare repo)
492- descPath = filepath.Join(repoPath, "description")
493- data, err = os.ReadFile(descPath)
494- if err != nil {
495- return ""
496- }
497- }
498-
499- desc := strings.TrimSpace(string(data))
500-
501- // Filter out the default git description
502- if desc == "Unnamed repository; edit this file 'description' to name the repository." {
503- return ""
504- }
505-
506- return desc
507-}
508-
509-func (c *Config) writeHtml(writeData *WriteData) {
510- ts, err := template.ParseFS(
511- embedFS,
512- writeData.Template,
513- "html/header.partial.tmpl",
514- "html/footer.partial.tmpl",
515- "html/base.layout.tmpl",
516- )
517- bail(err)
518-
519- dir := filepath.Join(c.Outdir, writeData.Subdir)
520- err = os.MkdirAll(dir, os.ModePerm)
521- bail(err)
522-
523- fp := filepath.Join(dir, writeData.Filename)
524- c.Logger.Info("writing", "filepath", fp)
525-
526- w, err := os.OpenFile(fp, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
527- bail(err)
528-
529- err = ts.Execute(w, writeData.Data)
530- bail(err)
531-}
532-
533-func (c *Config) copyStatic(dir string) error {
534- entries, err := staticFS.ReadDir(dir)
535- bail(err)
536-
537- for _, e := range entries {
538- infp := filepath.Join(dir, e.Name())
539- if e.IsDir() {
540- continue
541- }
542-
543- w, err := staticFS.ReadFile(infp)
544- bail(err)
545- fp := filepath.Join(c.Outdir, e.Name())
546- c.Logger.Info("writing", "filepath", fp)
547- err = os.WriteFile(fp, w, 0644)
548- bail(err)
549- }
550-
551- return nil
552-}
553-
554-func (c *Config) writeRootSummary(data *PageData, readme template.HTML, lastCommit *CommitData) {
555- c.Logger.Info("writing root html", "repoPath", c.RepoPath)
556- c.writeHtml(&WriteData{
557- Filename: "index.html",
558- Template: "html/summary.page.tmpl",
559- Data: &SummaryPageData{
560- PageData: data,
561- Readme: readme,
562- LastCommit: lastCommit,
563- },
564- })
565-}
566-
567-func (c *Config) writeTree(data *PageData, tree *TreeRoot) {
568- c.Logger.Info("writing tree", "treePath", tree.Path)
569- c.writeHtml(&WriteData{
570- Filename: "index.html",
571- Subdir: tree.Path,
572- Template: "html/tree.page.tmpl",
573- Data: &TreePageData{
574- PageData: data,
575- Tree: tree,
576- },
577- })
578-}
579-
580-func (c *Config) writeLog(data *PageData, logs []*CommitData) {
581- c.Logger.Info("writing log file", "revision", data.RevData.Name())
582- c.writeHtml(&WriteData{
583- Filename: "index.html",
584- Subdir: getLogBaseDir(data.RevData),
585- Template: "html/log.page.tmpl",
586- Data: &LogPageData{
587- PageData: data,
588- NumCommits: len(logs),
589- Logs: logs,
590- },
591- })
592-}
593-
594-func (c *Config) writeRefs(data *PageData, refs []*RefInfo) {
595- c.Logger.Info("writing refs", "repoPath", c.RepoPath)
596- c.writeHtml(&WriteData{
597- Filename: "refs.html",
598- Template: "html/refs.page.tmpl",
599- Data: &RefPageData{
600- PageData: data,
601- Refs: refs,
602- },
603- })
604-}
605-
606-func (c *Config) writeHTMLTreeFile(pageData *PageData, treeItem *TreeItem) string {
607- readme := ""
608- b, err := treeItem.Entry.Blob().Bytes()
609- bail(err)
610- str := string(b)
611-
612- treeItem.IsTextFile = isTextFile(str)
613-
614- contents := "binary file, cannot display"
615- if treeItem.IsTextFile {
616- treeItem.NumLines = len(strings.Split(str, "\n"))
617- // Use markdown rendering for .md files, syntax highlighting for others
618- if isMarkdownFile(treeItem.Entry.Name()) {
619- contents = string(c.renderMarkdown(str))
620- } else {
621- contents, err = c.parseText(treeItem.Entry.Name(), string(b))
622- bail(err)
623- }
624- }
625-
626- d := filepath.Dir(treeItem.Path)
627-
628- nameLower := strings.ToLower(treeItem.Entry.Name())
629- summary := readmeFile(pageData.Repo)
630- if d == "." && nameLower == summary {
631- // Capture raw markdown content for summary page
632- readme = str
633- }
634-
635- c.writeHtml(&WriteData{
636- Filename: fmt.Sprintf("%s.html", treeItem.Entry.Name()),
637- Template: "html/file.page.tmpl",
638- Data: &FilePageData{
639- PageData: pageData,
640- Contents: template.HTML(contents),
641- Item: treeItem,
642- },
643- Subdir: getFileDir(pageData.RevData, d),
644- })
645- return readme
646-}
647-
648-func (c *Config) writeLogDiff(repo *git.Repository, pageData *PageData, commit *CommitData) {
649- commitID := commit.ID.String()
650-
651- c.Mutex.RLock()
652- hasCommit := c.Cache[commitID]
653- c.Mutex.RUnlock()
654-
655- if hasCommit {
656- c.Logger.Info("commit file already generated, skipping", "commitID", getShortID(commitID))
657- return
658- } else {
659- c.Mutex.Lock()
660- c.Cache[commitID] = true
661- c.Mutex.Unlock()
662- }
663-
664- diff, err := repo.Diff(commitID, 0, 0, 0, git.DiffOptions{})
665- bail(err)
666-
667- rnd := &DiffRender{
668- NumFiles: diff.NumFiles(),
669- TotalAdditions: diff.TotalAdditions(),
670- TotalDeletions: diff.TotalDeletions(),
671- }
672- fls := []*DiffRenderFile{}
673- for _, file := range diff.Files {
674- fl := &DiffRenderFile{
675- FileType: diffFileType(file.Type),
676- OldMode: file.OldMode(),
677- OldName: file.OldName(),
678- Mode: file.Mode(),
679- Name: file.Name,
680- NumAdditions: file.NumAdditions(),
681- NumDeletions: file.NumDeletions(),
682- }
683- content := ""
684- for _, section := range file.Sections {
685- for _, line := range section.Lines {
686- content += fmt.Sprintf("%s\n", line.Content)
687- }
688- }
689- // set filename to something our `ParseText` recognizes (e.g. `.diff`)
690- finContent, err := c.parseText("commit.diff", content)
691- bail(err)
692-
693- fl.Content = template.HTML(finContent)
694- fls = append(fls, fl)
695- }
696- rnd.Files = fls
697-
698- commitData := &CommitPageData{
699- PageData: pageData,
700- Commit: commit,
701- CommitID: getShortID(commitID),
702- Diff: rnd,
703- Parent: getShortID(commit.ParentID),
704- CommitURL: c.getCommitURL(commitID),
705- ParentURL: c.getCommitURL(commit.ParentID),
706- }
707-
708- c.writeHtml(&WriteData{
709- Filename: fmt.Sprintf("%s.html", commitID),
710- Template: "html/commit.page.tmpl",
711- Subdir: "commits",
712- Data: commitData,
713- })
714-}
715-
716-func (c *Config) getSummaryURL() template.URL {
717- url := c.RootRelative + "index.html"
718- return template.URL(url)
719-}
720-
721-func (c *Config) getRefsURL() template.URL {
722- url := c.RootRelative + "refs.html"
723- return template.URL(url)
724-}
725-
726-// controls the url for trees and logs
727-// - /logs/getRevIDForURL()/index.html
728-// - /tree/getRevIDForURL()/item/file.x.html.
729-func getRevIDForURL(info RevInfo) string {
730- return info.Name()
731-}
732-
733-func getTreeBaseDir(info RevInfo) string {
734- subdir := getRevIDForURL(info)
735- return filepath.Join("/", "tree", subdir)
736-}
737-
738-func getLogBaseDir(info RevInfo) string {
739- subdir := getRevIDForURL(info)
740- return filepath.Join("/", "logs", subdir)
741-}
742-
743-func getFileBaseDir(info RevInfo) string {
744- return filepath.Join(getTreeBaseDir(info), "item")
745-}
746-
747-func getFileDir(info RevInfo, fname string) string {
748- return filepath.Join(getFileBaseDir(info), fname)
749-}
750-
751-func (c *Config) getFileURL(info RevInfo, fname string) template.URL {
752- return c.compileURL(getFileBaseDir(info), fname)
753-}
754-
755-func (c *Config) compileURL(dir, fname string) template.URL {
756- purl := c.RootRelative + strings.TrimPrefix(dir, "/")
757- url := filepath.Join(purl, fname)
758- return template.URL(url)
759-}
760-
761-func (c *Config) getTreeURL(info RevInfo) template.URL {
762- dir := getTreeBaseDir(info)
763- return c.compileURL(dir, "index.html")
764-}
765-
766-func (c *Config) getLogsURL(info RevInfo) template.URL {
767- dir := getLogBaseDir(info)
768- return c.compileURL(dir, "index.html")
769-}
770-
771-func (c *Config) getCommitURL(commitID string) template.URL {
772- url := fmt.Sprintf("%scommits/%s.html", c.RootRelative, commitID)
773- return template.URL(url)
774-}
775-
776-// parseCommitMessage extracts git trailer lines from a commit message and returns
777-// the trailers and the message body without summary line and trailers.
778-// Trailers are lines at the end of the message in "Key: value" format.
779-func parseCommitMessage(message string) ([]Trailer, string) {
780- var trailers []Trailer
781-
782- // Trailer pattern: key with alphanumeric/hyphens, colon, space, value
783- // Examples: "Signed-off-by: John Doe", "Co-authored-by: Jane Smith"
784- trailerRe := regexp.MustCompile(`^([A-Za-z0-9-]+): (.+)$`)
785-
786- // Trim trailing newlines to avoid empty last line
787- message = strings.TrimRight(message, "\n")
788- lines := strings.Split(message, "\n")
789-
790- // Find where trailers start (index of first trailer line)
791- trailerStartIdx := -1
792-
793- // Collect trailer lines from the end of the message
794- for i := len(lines) - 1; i >= 0; i-- {
795- line := strings.TrimSpace(lines[i])
796-
797- // Stop at empty line (separator between message body and trailers)
798- if line == "" {
799- trailerStartIdx = i + 1
800- break
801- }
802-
803- matches := trailerRe.FindStringSubmatch(line)
804- if matches != nil {
805- trailers = append([]Trailer{
806- {Key: matches[1], Value: matches[2]},
807- }, trailers...)
808- } else {
809- // Not a trailer line, stop collecting
810- trailerStartIdx = i + 1
811- break
812- }
813- }
814-
815- // If no trailers found, treat entire message as body
816- bodyLines := lines
817- if len(trailers) > 0 && trailerStartIdx > 0 {
818- // trailerStartIdx is the first line of trailers, so body is everything before it
819- bodyLines = lines[:trailerStartIdx]
820- }
821-
822- // Remove trailing empty lines from body
823- bodyEndIdx := len(bodyLines)
824- for i := len(bodyLines) - 1; i >= 0; i-- {
825- if strings.TrimSpace(bodyLines[i]) != "" {
826- bodyEndIdx = i + 1
827- break
828- }
829- }
830- bodyLines = bodyLines[:bodyEndIdx]
831-
832- // Extract body without summary (everything after first line)
833- messageBodyOnly := ""
834- if len(bodyLines) > 1 {
835- bodyOnlyLines := bodyLines[1:]
836- // Remove leading empty lines
837- startIdx := 0
838- for i, line := range bodyOnlyLines {
839- if strings.TrimSpace(line) != "" {
840- startIdx = i
841- break
842- }
843- }
844- if startIdx < len(bodyOnlyLines) {
845- messageBodyOnly = strings.Join(bodyOnlyLines[startIdx:], "\n")
846- }
847- }
848-
849- return trailers, messageBodyOnly
850-}
851-
852-func (c *Config) getURLs() *SiteURLs {
853- return &SiteURLs{
854- HomeURL: c.HomeURL,
855- CloneURL: c.CloneURL,
856- RefsURL: c.getRefsURL(),
857- SummaryURL: c.getSummaryURL(),
858- IssuesURL: c.getIssuesURL(),
859- }
860-}
861-
862-func (c *Config) getIssuesURL() template.URL {
863- url := c.RootRelative + "issues/open/index.html"
864- return template.URL(url)
865-}
866-
867-func getShortID(id string) string {
868- return id[:7]
869-}
870-
871-func (c *Config) writeRepo() *BranchOutput {
872- c.Logger.Info("writing repo", "repoPath", c.RepoPath)
873- repo, err := git.Open(c.RepoPath)
874- bail(err)
875-
876- refs, err := repo.ShowRef(git.ShowRefOptions{Heads: true, Tags: true})
877- bail(err)
878-
879- var first *RevData
880- revs := []*RevData{}
881- for _, revStr := range c.Revs {
882- fullRevID, err := repo.RevParse(revStr)
883- bail(err)
884-
885- revID := getShortID(fullRevID)
886- revName := revID
887- // if it's a reference then label it as such
888- for _, ref := range refs {
889- if revStr == git.RefShortName(ref.Refspec) || revStr == ref.Refspec {
890- revName = revStr
891- break
892- }
893- }
894-
895- data := &RevData{
896- id: fullRevID,
897- name: revName,
898- Config: c,
899- }
900-
901- if first == nil {
902- first = data
903- }
904- revs = append(revs, data)
905- }
906-
907- if first == nil {
908- bail(fmt.Errorf("could find find a git reference that matches criteria"))
909- }
910-
911- refInfoMap := map[string]*RefInfo{}
912- for _, revData := range revs {
913- refInfoMap[revData.Name()] = &RefInfo{
914- ID: revData.ID(),
915- Refspec: revData.Name(),
916- URL: revData.TreeURL(),
917- }
918- }
919-
920- // loop through ALL refs that don't have URLs
921- // and add them to the map
922- for _, ref := range refs {
923- refspec := git.RefShortName(ref.Refspec)
924- if refInfoMap[refspec] != nil {
925- continue
926- }
927-
928- refInfoMap[refspec] = &RefInfo{
929- ID: ref.ID,
930- Refspec: refspec,
931- }
932- }
933-
934- // gather lists of refs to display on refs.html page
935- refInfoList := []*RefInfo{}
936- for _, val := range refInfoMap {
937- refInfoList = append(refInfoList, val)
938- }
939- sort.Slice(refInfoList, func(i, j int) bool {
940- urlI := refInfoList[i].URL
941- urlJ := refInfoList[j].URL
942- refI := refInfoList[i].Refspec
943- refJ := refInfoList[j].Refspec
944- if urlI == urlJ {
945- return refI < refJ
946- }
947- return urlI > urlJ
948- })
949-
950- // we assume the first revision in the list is the "main" revision which mostly
951- // means that's the README we use for the default summary page.
952- mainOutput := &BranchOutput{}
953- var wg sync.WaitGroup
954- for i, revData := range revs {
955- c.Logger.Info("writing revision", "revision", revData.Name())
956- data := &PageData{
957- Repo: c,
958- RevData: revData,
959- SiteURLs: c.getURLs(),
960- Refs: refInfoList,
961- }
962-
963- if i == 0 {
964- branchOutput := c.writeRevision(repo, data, refInfoList)
965- mainOutput = branchOutput
966- } else {
967- wg.Add(1)
968- go func() {
969- defer wg.Done()
970- c.writeRevision(repo, data, refInfoList)
971- }()
972- }
973- }
974- wg.Wait()
975-
976- // use the first revision in our list to generate
977- // the root summary, logs, and tree the user can click
978- revData := &RevData{
979- id: first.ID(),
980- name: first.Name(),
981- Config: c,
982- }
983-
984- data := &PageData{
985- RevData: revData,
986- Repo: c,
987- SiteURLs: c.getURLs(),
988- Refs: refInfoList,
989- }
990-
991- // Generate issue pages if enabled
992- if c.Issues {
993- err := c.writeIssues(data)
994- if err != nil {
995- c.Logger.Warn("failed to write issues", "error", err)
996- }
997- }
998-
999- c.writeRefs(data, refInfoList)
1000- // Convert README markdown to HTML for summary page
1001- var readmeHTML template.HTML
1002- if isMarkdownFile(readmeFile(c)) {
1003- readmeHTML = c.renderMarkdown(mainOutput.Readme)
1004- } else {
1005- readmeHTML = template.HTML(mainOutput.Readme)
1006- }
1007- // Wrap README in a div with class for CSS styling
1008- readmeHTML = template.HTML(`<div class="readme">` + string(readmeHTML) + `</div>`)
1009- c.writeRootSummary(data, readmeHTML, mainOutput.LastCommit)
1010- return mainOutput
1011-}
1012-
1013-type TreeRoot struct {
1014- Path string
1015- Items []*TreeItem
1016- Crumbs []*Breadcrumb
1017-}
1018-
1019-type TreeWalker struct {
1020- treeItem chan *TreeItem
1021- tree chan *TreeRoot
1022- HideTreeLastCommit bool
1023- PageData *PageData
1024- Repo *git.Repository
1025- Config *Config
1026-}
1027-
1028-type Breadcrumb struct {
1029- Text string
1030- URL template.URL
1031- IsLast bool
1032-}
1033-
1034-func (tw *TreeWalker) calcBreadcrumbs(curpath string) []*Breadcrumb {
1035- if curpath == "" {
1036- return []*Breadcrumb{}
1037- }
1038- parts := strings.Split(curpath, string(os.PathSeparator))
1039- rootURL := tw.Config.compileURL(
1040- getTreeBaseDir(tw.PageData.RevData),
1041- "index.html",
1042- )
1043-
1044- crumbs := make([]*Breadcrumb, len(parts)+1)
1045- crumbs[0] = &Breadcrumb{
1046- URL: rootURL,
1047- Text: tw.PageData.Repo.RepoName,
1048- }
1049-
1050- cur := ""
1051- for idx, d := range parts {
1052- crumb := filepath.Join(getFileBaseDir(tw.PageData.RevData), cur, d)
1053- crumbUrl := tw.Config.compileURL(crumb, "index.html")
1054- crumbs[idx+1] = &Breadcrumb{
1055- Text: d,
1056- URL: crumbUrl,
1057- }
1058- if idx == len(parts)-1 {
1059- crumbs[idx+1].IsLast = true
1060- }
1061- cur = filepath.Join(cur, d)
1062- }
1063-
1064- return crumbs
1065-}
1066-
1067-func filenameToDevIcon(filename string) string {
1068- ext := filepath.Ext(filename)
1069- extMappr := map[string]string{
1070- ".html": "html5",
1071- ".go": "go",
1072- ".py": "python",
1073- ".css": "css3",
1074- ".js": "javascript",
1075- ".md": "markdown",
1076- ".ts": "typescript",
1077- ".tsx": "react",
1078- ".jsx": "react",
1079- }
1080-
1081- nameMappr := map[string]string{
1082- "Makefile": "cmake",
1083- "Dockerfile": "docker",
1084- }
1085-
1086- icon := extMappr[ext]
1087- if icon == "" {
1088- icon = nameMappr[filename]
1089- }
1090-
1091- return fmt.Sprintf("devicon-%s-original", icon)
1092-}
1093-
1094-func (tw *TreeWalker) NewTreeItem(entry *git.TreeEntry, curpath string, crumbs []*Breadcrumb) *TreeItem {
1095- typ := entry.Type()
1096- fname := filepath.Join(curpath, entry.Name())
1097- item := &TreeItem{
1098- Size: toPretty(entry.Size()),
1099- Name: entry.Name(),
1100- Path: fname,
1101- Entry: entry,
1102- URL: tw.Config.getFileURL(tw.PageData.RevData, fname),
1103- Crumbs: crumbs,
1104- Author: &git.Signature{
1105- Name: "unknown",
1106- },
1107- }
1108-
1109- // `git rev-list` is pretty expensive here, so we have a flag to disable
1110- if tw.HideTreeLastCommit {
1111- // c.Logger.Info("skipping the process of finding the last commit for each file")
1112- } else {
1113- id := tw.PageData.RevData.ID()
1114- lastCommits, err := tw.Repo.RevList([]string{id}, git.RevListOptions{
1115- Path: item.Path,
1116- CommandOptions: git.CommandOptions{Args: []string{"-1"}},
1117- })
1118- bail(err)
1119-
1120- if len(lastCommits) > 0 {
1121- lc := lastCommits[0]
1122- item.CommitURL = tw.Config.getCommitURL(lc.ID.String())
1123- item.CommitID = getShortID(lc.ID.String())
1124- item.Summary = lc.Summary()
1125- item.When = lc.Author.When.Format(time.DateOnly)
1126- item.WhenISO = lc.Author.When.UTC().Format(time.RFC3339)
1127- item.WhenDisplay = formatDateForDisplay(lc.Author.When)
1128- item.Author = lc.Author
1129- }
1130- }
1131-
1132- fpath := tw.Config.getFileURL(tw.PageData.RevData, fmt.Sprintf("%s.html", fname))
1133- switch typ {
1134- case git.ObjectTree:
1135- item.IsDir = true
1136- fpath = tw.Config.compileURL(
1137- filepath.Join(
1138- getFileBaseDir(tw.PageData.RevData),
1139- curpath,
1140- entry.Name(),
1141- ),
1142- "index.html",
1143- )
1144- case git.ObjectBlob:
1145- item.Icon = filenameToDevIcon(item.Name)
1146- }
1147- item.URL = fpath
1148-
1149- return item
1150-}
1151-
1152-func (tw *TreeWalker) walk(tree *git.Tree, curpath string) {
1153- entries, err := tree.Entries()
1154- bail(err)
1155-
1156- crumbs := tw.calcBreadcrumbs(curpath)
1157- treeEntries := []*TreeItem{}
1158- for _, entry := range entries {
1159- typ := entry.Type()
1160- item := tw.NewTreeItem(entry, curpath, crumbs)
1161-
1162- switch typ {
1163- case git.ObjectTree:
1164- item.IsDir = true
1165- re, _ := tree.Subtree(entry.Name())
1166- tw.walk(re, item.Path)
1167- treeEntries = append(treeEntries, item)
1168- tw.treeItem <- item
1169- case git.ObjectBlob:
1170- treeEntries = append(treeEntries, item)
1171- tw.treeItem <- item
1172- }
1173- }
1174-
1175- sort.Slice(treeEntries, func(i, j int) bool {
1176- nameI := treeEntries[i].Name
1177- nameJ := treeEntries[j].Name
1178- if treeEntries[i].IsDir && treeEntries[j].IsDir {
1179- return nameI < nameJ
1180- }
1181-
1182- if treeEntries[i].IsDir && !treeEntries[j].IsDir {
1183- return true
1184- }
1185-
1186- if !treeEntries[i].IsDir && treeEntries[j].IsDir {
1187- return false
1188- }
1189-
1190- return nameI < nameJ
1191- })
1192-
1193- fpath := filepath.Join(
1194- getFileBaseDir(tw.PageData.RevData),
1195- curpath,
1196- )
1197- // root gets a special spot outside of `item` subdir
1198- if curpath == "" {
1199- fpath = getTreeBaseDir(tw.PageData.RevData)
1200- }
1201-
1202- tw.tree <- &TreeRoot{
1203- Path: fpath,
1204- Items: treeEntries,
1205- Crumbs: crumbs,
1206- }
1207-
1208- if curpath == "" {
1209- close(tw.tree)
1210- close(tw.treeItem)
1211- }
1212-}
1213-
1214-func (c *Config) writeRevision(repo *git.Repository, pageData *PageData, refs []*RefInfo) *BranchOutput {
1215- c.Logger.Info(
1216- "compiling revision",
1217- "repoName", c.RepoName,
1218- "revision", pageData.RevData.Name(),
1219- )
1220-
1221- output := &BranchOutput{}
1222-
1223- var wg sync.WaitGroup
1224-
1225- wg.Add(1)
1226- go func() {
1227- defer wg.Done()
1228-
1229- pageSize := pageData.Repo.MaxCommits
1230- if pageSize == 0 {
1231- pageSize = 5000
1232- }
1233- commits, err := repo.CommitsByPage(pageData.RevData.ID(), 0, pageSize)
1234- bail(err)
1235-
1236- logs := []*CommitData{}
1237- for i, commit := range commits {
1238- tags := []*RefInfo{}
1239- for _, ref := range refs {
1240- if commit.ID.String() == ref.ID {
1241- tags = append(tags, ref)
1242- }
1243- }
1244-
1245- parentSha, _ := commit.ParentID(0)
1246- parentID := ""
1247- if parentSha == nil {
1248- parentID = commit.ID.String()
1249- } else {
1250- parentID = parentSha.String()
1251- }
1252- trailers, messageBodyOnly := parseCommitMessage(commit.Message)
1253- cd := &CommitData{
1254- ParentID: parentID,
1255- URL: c.getCommitURL(commit.ID.String()),
1256- ShortID: getShortID(commit.ID.String()),
1257- SummaryStr: commit.Summary(),
1258- AuthorStr: commit.Author.Name,
1259- WhenStr: commit.Author.When.Format(time.DateOnly),
1260- WhenISO: commit.Author.When.UTC().Format(time.RFC3339),
1261- WhenDisplay: formatDateForDisplay(commit.Author.When),
1262- Commit: commit,
1263- Refs: tags,
1264- Trailers: trailers,
1265- MessageBodyOnly: messageBodyOnly,
1266- }
1267- logs = append(logs, cd)
1268- if i == 0 {
1269- output.LastCommit = cd
1270- }
1271- }
1272-
1273- c.writeLog(pageData, logs)
1274-
1275- for _, cm := range logs {
1276- wg.Add(1)
1277- go func(commit *CommitData) {
1278- defer wg.Done()
1279- c.writeLogDiff(repo, pageData, commit)
1280- }(cm)
1281- }
1282- }()
1283-
1284- tree, err := repo.LsTree(pageData.RevData.ID())
1285- bail(err)
1286-
1287- readme := ""
1288- entries := make(chan *TreeItem)
1289- subtrees := make(chan *TreeRoot)
1290- tw := &TreeWalker{
1291- Config: c,
1292- PageData: pageData,
1293- Repo: repo,
1294- treeItem: entries,
1295- tree: subtrees,
1296- }
1297- wg.Add(1)
1298- go func() {
1299- defer wg.Done()
1300- tw.walk(tree, "")
1301- }()
1302-
1303- wg.Add(1)
1304- go func() {
1305- defer wg.Done()
1306- for e := range entries {
1307- wg.Add(1)
1308- go func(entry *TreeItem) {
1309- defer wg.Done()
1310- if entry.IsDir {
1311- return
1312- }
1313-
1314- readmeStr := c.writeHTMLTreeFile(pageData, entry)
1315- if readmeStr != "" {
1316- readme = readmeStr
1317- }
1318- }(e)
1319- }
1320- }()
1321-
1322- wg.Add(1)
1323- go func() {
1324- defer wg.Done()
1325- for t := range subtrees {
1326- wg.Add(1)
1327- go func(tree *TreeRoot) {
1328- defer wg.Done()
1329- c.writeTree(pageData, tree)
1330- }(t)
1331- }
1332- }()
1333-
1334- wg.Wait()
1335-
1336- c.Logger.Info(
1337- "compilation complete",
1338- "repoName", c.RepoName,
1339- "revision", pageData.RevData.Name(),
1340- )
1341-
1342- output.Readme = readme
1343- return output
1344-}
1345-
1346-func style(theme chroma.Style) string {
1347- bg := theme.Get(chroma.Background)
1348- txt := theme.Get(chroma.Text)
1349- kw := theme.Get(chroma.Keyword)
1350- nv := theme.Get(chroma.NameVariable)
1351- cm := theme.Get(chroma.Comment)
1352- return fmt.Sprintf(`:root {
1353- --bg-color: %s;
1354- --text-color: %s;
1355- --border: %s;
1356- --link-color: %s;
1357- --hover: %s;
1358- --visited: %s;
1359-}`,
1360- bg.Background.String(),
1361- txt.Colour.String(),
1362- cm.Colour.String(),
1363- nv.Colour.String(),
1364- kw.Colour.String(),
1365- nv.Colour.String(),
1366- )
1367-}
1368-
1369-// bundleCSS concatenates, minifies, and hashes all CSS files
1370-// Returns the filename of the bundled CSS (e.g., "styles.a3f7b2c1.css")
1371-func (c *Config) bundleCSS() (string, error) {
1372- c.Logger.Info("bundling CSS files")
1373-
1374- // Initialize minifier
1375- m := minify.New()
1376- m.AddFunc("text/css", css.Minify)
1377-
1378- var buf bytes.Buffer
1379-
1380- // 1. Read pgit.css from embedded static FS
1381- pgitCSS, err := staticFS.ReadFile("static/pgit.css")
1382- if err != nil {
1383- return "", fmt.Errorf("failed to read pgit.css: %w", err)
1384- }
1385- buf.Write(pgitCSS)
1386- buf.WriteString("\n")
1387-
1388- // 2. Generate vars.css content
1389- varsCSS := style(*c.Theme)
1390- buf.WriteString(varsCSS)
1391- buf.WriteString("\n")
1392-
1393- // 3. Generate syntax.css content
1394- var syntaxBuf bytes.Buffer
1395- err = c.Formatter.WriteCSS(&syntaxBuf, c.Theme)
1396- if err != nil {
1397- return "", fmt.Errorf("failed to generate syntax.css: %w", err)
1398- }
1399- buf.Write(syntaxBuf.Bytes())
1400-
1401- // 4. Minify the concatenated CSS
1402- minified, err := m.Bytes("text/css", buf.Bytes())
1403- if err != nil {
1404- return "", fmt.Errorf("failed to minify CSS: %w", err)
1405- }
1406-
1407- // 5. Generate content hash (first 8 chars of SHA256)
1408- hash := sha256.Sum256(minified)
1409- hashStr := hex.EncodeToString(hash[:8])
1410-
1411- // 6. Create filename with hash
1412- filename := fmt.Sprintf("styles.%s.css", hashStr)
1413-
1414- // 7. Write to output directory
1415- outPath := filepath.Join(c.Outdir, filename)
1416- err = os.WriteFile(outPath, minified, 0644)
1417- if err != nil {
1418- return "", fmt.Errorf("failed to write CSS bundle: %w", err)
1419- }
1420-
1421- c.Logger.Info("CSS bundle created", "filename", filename, "size", len(minified))
1422-
1423- return filename, nil
1424-}
1425-
1426-func main() {
1427- var outdir = flag.String("out", "./public", "output directory")
1428- var rpath = flag.String("repo", ".", "path to git repo")
1429- var revsFlag = flag.String("revs", "HEAD", "list of revs to generate logs and tree (e.g. main,v1,c69f86f,HEAD)")
1430- var themeFlag = flag.String("theme", "dracula", "theme to use for site")
1431- var labelFlag = flag.String("label", "", "pretty name for the subdir where we create the repo, default is last folder in --repo")
1432- var cloneFlag = flag.String("clone-url", "", "git clone URL for upstream")
1433- var homeFlag = flag.String("home-url", "", "URL for breadcumbs to go to root page, hidden if empty")
1434- var descFlag = flag.String("desc", "", "description for repo")
1435- var rootRelativeFlag = flag.String("root-relative", "/", "html root relative")
1436- var maxCommitsFlag = flag.Int("max-commits", 0, "maximum number of commits to generate")
1437- var hideTreeLastCommitFlag = flag.Bool("hide-tree-last-commit", false, "dont calculate last commit for each file in the tree")
1438- var issuesFlag = flag.Bool("issues", false, "enable git-bug issue generation")
1439-
1440- // Pre-parse for hook commands and their --repo flag
1441- // This is needed because flag.Parse() stops at non-flag args
1442- repoPathForHook := "."
1443- for i, arg := range os.Args[1:] {
1444- if arg == "install-hook" || arg == "uninstall-hook" {
1445- // Look for --repo flag in remaining args (after the command)
1446- // i is the index in os.Args[1:], so actual index in os.Args is i+1
1447- // We need to check os.Args[i+2:] for flags after the command
1448- for j := i + 2; j < len(os.Args); j++ {
1449- if strings.HasPrefix(os.Args[j], "--repo=") {
1450- repoPathForHook = strings.TrimPrefix(os.Args[j], "--repo=")
1451- break
1452- }
1453- }
1454- // Execute hook command
1455- if arg == "install-hook" {
1456- installHook(repoPathForHook)
1457- } else {
1458- uninstallHook(repoPathForHook)
1459- }
1460- return
1461- }
1462- }
1463-
1464- flag.Parse()
1465-
1466- out, err := filepath.Abs(*outdir)
1467- bail(err)
1468- repoPath, err := filepath.Abs(*rpath)
1469- bail(err)
1470-
1471- theme := styles.Get(*themeFlag)
1472-
1473- logger := slog.Default()
1474-
1475- label := repoName(repoPath)
1476- if *labelFlag != "" {
1477- label = *labelFlag
1478- }
1479-
1480- revs := strings.Split(*revsFlag, ",")
1481- if len(revs) == 1 && revs[0] == "" {
1482- revs = []string{}
1483- }
1484-
1485- formatter := formatterHtml.New(
1486- formatterHtml.WithLineNumbers(true),
1487- formatterHtml.WithLinkableLineNumbers(true, ""),
1488- formatterHtml.WithClasses(true),
1489- )
1490-
1491- // Create a temporary config to use for rendering markdown
1492- tempConfig := &Config{
1493- Theme: theme,
1494- Logger: logger,
1495- RepoPath: repoPath,
1496- }
1497-
1498- // Determine description: --desc flag overrides git description
1499- var descHTML template.HTML
1500- if *descFlag != "" {
1501- // Use --desc flag value, process as markdown
1502- descHTML = tempConfig.renderMarkdown(*descFlag)
1503- } else {
1504- // Try to read from git description file
1505- gitDesc := readDescription(repoPath)
1506- if gitDesc != "" {
1507- descHTML = tempConfig.renderMarkdown(gitDesc)
1508- }
1509- }
1510-
1511- config := &Config{
1512- Outdir: out,
1513- RepoPath: repoPath,
1514- RepoName: label,
1515- Cache: make(map[string]bool),
1516- Revs: revs,
1517- Theme: theme,
1518- Logger: logger,
1519- CloneURL: template.URL(*cloneFlag),
1520- HomeURL: template.URL(*homeFlag),
1521- Desc: descHTML,
1522- MaxCommits: *maxCommitsFlag,
1523- HideTreeLastCommit: *hideTreeLastCommitFlag,
1524- Issues: *issuesFlag,
1525- RootRelative: *rootRelativeFlag,
1526- Formatter: formatter,
1527- }
1528- config.Logger.Info("config", "config", config)
1529-
1530- if len(revs) == 0 {
1531- bail(fmt.Errorf("you must provide --revs"))
1532- }
1533-
1534- // Bundle CSS files before generating site
1535- cssFile, err := config.bundleCSS()
1536- bail(err)
1537- config.CSSFile = cssFile
1538-
1539- config.writeRepo()
1540-
1541- url := filepath.Join("/", "index.html")
1542- config.Logger.Info("root url", "url", url)
1543-}
1544-
1545-// installHook installs the post-commit hook into the repository
1546-func installHook(repoPath string) {
1547- // Convert to absolute path
1548- absRepoPath, err := filepath.Abs(repoPath)
1549- if err != nil {
1550- fmt.Fprintf(os.Stderr, "Error: Could not resolve repo path: %v\n", err)
1551- os.Exit(1)
1552- }
1553-
1554- // Find the embedded hook script
1555- hookContent, err := embedFS.ReadFile("hooks/post-commit.bash")
1556- if err != nil {
1557- fmt.Fprintf(os.Stderr, "Error: Could not read embedded hook: %v\n", err)
1558- os.Exit(1)
1559- }
1560-
1561- // Determine hooks directory: .git/hooks for normal repos, hooks/ for bare repos
1562- hooksDir := filepath.Join(absRepoPath, ".git", "hooks")
1563- if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1564- // Check for bare repo (hooks/ at root)
1565- bareHooksDir := filepath.Join(repoPath, "hooks")
1566- if _, err := os.Stat(bareHooksDir); err == nil {
1567- hooksDir = bareHooksDir
1568- fmt.Printf("Detected bare repository, installing to %s\n", hooksDir)
1569- }
1570- }
1571-
1572- postCommitHook := filepath.Join(hooksDir, "post-commit")
1573-
1574- // Ensure hooks directory exists
1575- if err := os.MkdirAll(hooksDir, 0755); err != nil {
1576- fmt.Fprintf(os.Stderr, "Error: Could not create hooks directory: %v\n", err)
1577- os.Exit(1)
1578- }
1579-
1580- // Check if a post-commit hook already exists
1581- if _, err := os.Stat(postCommitHook); err == nil {
1582- // Backup existing hook
1583- backupPath := postCommitHook + ".backup"
1584- if err := os.Rename(postCommitHook, backupPath); err != nil {
1585- fmt.Fprintf(os.Stderr, "Error: Failed to backup existing hook: %v\n", err)
1586- os.Exit(1)
1587- }
1588- fmt.Printf("Backed up existing post-commit hook to %s\n", backupPath)
1589- }
1590-
1591- // Write the hook
1592- if err := os.WriteFile(postCommitHook, hookContent, 0755); err != nil {
1593- fmt.Fprintf(os.Stderr, "Error: Failed to write hook: %v\n", err)
1594- os.Exit(1)
1595- }
1596-
1597- fmt.Printf("Installed post-commit hook to %s\n", postCommitHook)
1598- fmt.Println("Commits with issue references (e.g., 'fixes #872a52d') will now automatically close git-bug issues")
1599-}
1600-
1601-// uninstallHook removes the pgit post-commit hook
1602-func uninstallHook(repoPath string) {
1603- // Convert to absolute path
1604- absRepoPath, err := filepath.Abs(repoPath)
1605- if err != nil {
1606- fmt.Fprintf(os.Stderr, "Error: Could not resolve repo path: %v\n", err)
1607- os.Exit(1)
1608- }
1609-
1610- // Determine hooks directory: .git/hooks for normal repos, hooks/ for bare repos
1611- hooksDir := filepath.Join(absRepoPath, ".git", "hooks")
1612- if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1613- // Check for bare repo (hooks/ at root)
1614- bareHooksDir := filepath.Join(repoPath, "hooks")
1615- if _, err := os.Stat(bareHooksDir); err == nil {
1616- hooksDir = bareHooksDir
1617- }
1618- }
1619-
1620- postCommitHook := filepath.Join(hooksDir, "post-commit")
1621-
1622- // Check if hook exists
1623- data, err := os.ReadFile(postCommitHook)
1624- if err != nil {
1625- if os.IsNotExist(err) {
1626- fmt.Println("No post-commit hook found")
1627- return
1628- }
1629- fmt.Fprintf(os.Stderr, "Error: Failed to read hook: %v\n", err)
1630- os.Exit(1)
1631- }
1632-
1633- // Check if it's our hook
1634- if !strings.Contains(string(data), "pgit post-commit hook") {
1635- fmt.Fprintf(os.Stderr, "Error: post-commit hook is not a pgit hook (not removing)\n")
1636- os.Exit(1)
1637- }
1638-
1639- // Remove the hook
1640- if err := os.Remove(postCommitHook); err != nil {
1641- fmt.Fprintf(os.Stderr, "Error: Failed to remove hook: %v\n", err)
1642- os.Exit(1)
1643- }
1644-
1645- // Restore backup if exists
1646- backupPath := postCommitHook + ".backup"
1647- if _, err := os.Stat(backupPath); err == nil {
1648- if err := os.Rename(backupPath, postCommitHook); err != nil {
1649- fmt.Fprintf(os.Stderr, "Error: Failed to restore backup: %v\n", err)
1650- os.Exit(1)
1651- }
1652- fmt.Printf("Restored backup hook from %s\n", backupPath)
1653- }
1654-
1655- fmt.Println("Uninstalled pgit post-commit hook")
1656-}
+135,
-0
1@@ -0,0 +1,135 @@
2+package pgit
3+
4+import (
5+ "bytes"
6+ "fmt"
7+ "html/template"
8+ "io"
9+ "path/filepath"
10+ "strings"
11+
12+ "github.com/alecthomas/chroma/v2"
13+ "github.com/alecthomas/chroma/v2/formatters"
14+ "github.com/alecthomas/chroma/v2/lexers"
15+ "github.com/gomarkdown/markdown"
16+ "github.com/gomarkdown/markdown/ast"
17+ "github.com/gomarkdown/markdown/html"
18+ "github.com/gomarkdown/markdown/parser"
19+)
20+
21+func IsMarkdownFile(filename string) bool {
22+ ext := strings.ToLower(filepath.Ext(filename))
23+ return ext == ".md"
24+}
25+
26+func (c *Config) ParseText(filename string, text string) (string, error) {
27+ lexer := lexers.Match(filename)
28+ if lexer == nil {
29+ lexer = lexers.Analyse(text)
30+ }
31+ if lexer == nil {
32+ lexer = lexers.Get("plaintext")
33+ }
34+ iterator, err := lexer.Tokenise(nil, text)
35+ if err != nil {
36+ return text, err
37+ }
38+ var buf bytes.Buffer
39+ err = c.Formatter.Format(&buf, c.Theme, iterator)
40+ if err != nil {
41+ return text, err
42+ }
43+ return buf.String(), nil
44+}
45+
46+type chromaMarkdownRenderer struct {
47+ defaultRenderer *html.Renderer
48+ theme *chroma.Style
49+}
50+
51+func (r *chromaMarkdownRenderer) RenderNode(w io.Writer, node ast.Node, entering bool) ast.WalkStatus {
52+ if entering {
53+ if codeBlock, ok := node.(*ast.CodeBlock); ok {
54+ lang := extractLanguage(codeBlock.Info)
55+ code := string(codeBlock.Literal)
56+ highlighted, err := r.highlightCode(lang, code)
57+ if err == nil {
58+ w.Write([]byte(highlighted))
59+ return ast.SkipChildren
60+ }
61+ }
62+ }
63+ return r.defaultRenderer.RenderNode(w, node, entering)
64+}
65+
66+func (r *chromaMarkdownRenderer) RenderHeader(w io.Writer, ast ast.Node) {
67+ r.defaultRenderer.RenderHeader(w, ast)
68+}
69+
70+func (r *chromaMarkdownRenderer) RenderFooter(w io.Writer, ast ast.Node) {
71+ r.defaultRenderer.RenderFooter(w, ast)
72+}
73+
74+func (r *chromaMarkdownRenderer) highlightCode(lang, code string) (string, error) {
75+ var lexer chroma.Lexer
76+ if lang != "" {
77+ lexer = lexers.Get(lang)
78+ }
79+ if lexer == nil {
80+ lexer = lexers.Analyse(code)
81+ }
82+ if lexer == nil {
83+ lexer = lexers.Get("plaintext")
84+ }
85+
86+ iterator, err := lexer.Tokenise(nil, code)
87+ if err != nil {
88+ return "", err
89+ }
90+
91+ formatter := formatters.Get("html")
92+ if formatter == nil {
93+ return "", fmt.Errorf("failed to get HTML formatter")
94+ }
95+
96+ var buf bytes.Buffer
97+ err = formatter.Format(&buf, r.theme, iterator)
98+ if err != nil {
99+ return "", err
100+ }
101+
102+ highlighted := buf.String()
103+ highlighted = strings.TrimPrefix(highlighted, "<pre class=\"chroma\">")
104+ highlighted = strings.TrimSuffix(highlighted, "</pre>")
105+
106+ return highlighted, nil
107+}
108+
109+func extractLanguage(info []byte) string {
110+ if len(info) == 0 {
111+ return ""
112+ }
113+ parts := strings.Fields(string(info))
114+ if len(parts) > 0 {
115+ return parts[0]
116+ }
117+ return ""
118+}
119+
120+func (c *Config) RenderMarkdown(mdText string) template.HTML {
121+ extensions := parser.CommonExtensions | parser.FencedCode
122+ p := parser.NewWithExtensions(extensions)
123+ doc := p.Parse([]byte(mdText))
124+
125+ htmlFlags := html.CommonFlags
126+ opts := html.RendererOptions{Flags: htmlFlags}
127+ defaultRenderer := html.NewRenderer(opts)
128+
129+ customRenderer := &chromaMarkdownRenderer{
130+ defaultRenderer: defaultRenderer,
131+ theme: c.Theme,
132+ }
133+
134+ htmlBytes := markdown.Render(doc, customRenderer)
135+ return template.HTML(htmlBytes)
136+}
A
tree.go
+210,
-0
1@@ -0,0 +1,210 @@
2+package pgit
3+
4+import (
5+ "fmt"
6+ "html/template"
7+ "os"
8+ "path/filepath"
9+ "sort"
10+ "strings"
11+ "time"
12+
13+ git "github.com/gogs/git-module"
14+)
15+
16+type TreeRoot struct {
17+ Path string
18+ Items []*TreeItem
19+ Crumbs []*Breadcrumb
20+}
21+
22+type TreeWalker struct {
23+ treeItem chan *TreeItem
24+ tree chan *TreeRoot
25+ HideTreeLastCommit bool
26+ PageData *PageData
27+ Repo *git.Repository
28+ Config *Config
29+}
30+
31+type Breadcrumb struct {
32+ Text string
33+ URL template.URL
34+ IsLast bool
35+}
36+
37+func (tw *TreeWalker) CalcBreadcrumbs(curpath string) []*Breadcrumb {
38+ if curpath == "" {
39+ return []*Breadcrumb{}
40+ }
41+ parts := strings.Split(curpath, string(os.PathSeparator))
42+ rootURL := tw.Config.CompileURL(
43+ GetTreeBaseDir(*tw.PageData.RevData),
44+ "index.html",
45+ )
46+
47+ crumbs := make([]*Breadcrumb, len(parts)+1)
48+ crumbs[0] = &Breadcrumb{
49+ URL: rootURL,
50+ Text: tw.PageData.Repo.RepoName,
51+ }
52+
53+ cur := ""
54+ for idx, d := range parts {
55+ crumb := filepath.Join(GetFileBaseDir(*tw.PageData.RevData), cur, d)
56+ crumbUrl := tw.Config.CompileURL(crumb, "index.html")
57+ crumbs[idx+1] = &Breadcrumb{
58+ Text: d,
59+ URL: crumbUrl,
60+ }
61+ if idx == len(parts)-1 {
62+ crumbs[idx+1].IsLast = true
63+ }
64+ cur = filepath.Join(cur, d)
65+ }
66+
67+ return crumbs
68+}
69+
70+func FilenameToDevIcon(filename string) string {
71+ ext := filepath.Ext(filename)
72+ extMapper := map[string]string{
73+ ".html": "html5",
74+ ".go": "go",
75+ ".py": "python",
76+ ".css": "css3",
77+ ".js": "javascript",
78+ ".md": "markdown",
79+ ".ts": "typescript",
80+ ".tsx": "react",
81+ ".jsx": "react",
82+ }
83+
84+ nameMapper := map[string]string{
85+ "Makefile": "cmake",
86+ "Dockerfile": "docker",
87+ }
88+
89+ icon := extMapper[ext]
90+ if icon == "" {
91+ icon = nameMapper[filename]
92+ }
93+
94+ return fmt.Sprintf("devicon-%s-original", icon)
95+}
96+
97+func (tw *TreeWalker) NewTreeItem(entry *git.TreeEntry, curpath string, crumbs []*Breadcrumb) *TreeItem {
98+ typ := entry.Type()
99+ fname := filepath.Join(curpath, entry.Name())
100+ item := &TreeItem{
101+ Size: ToPretty(entry.Size()),
102+ Name: entry.Name(),
103+ Path: fname,
104+ Entry: entry,
105+ URL: tw.Config.GetFileURL(*tw.PageData.RevData, fname),
106+ Crumbs: crumbs,
107+ Author: &git.Signature{
108+ Name: "unknown",
109+ },
110+ }
111+
112+ if !tw.HideTreeLastCommit {
113+ id := (*tw.PageData.RevData).ID()
114+ lastCommits, err := tw.Repo.RevList([]string{id}, git.RevListOptions{
115+ Path: item.Path,
116+ CommandOptions: git.CommandOptions{Args: []string{"-1"}},
117+ })
118+ Bail(err)
119+
120+ if len(lastCommits) > 0 {
121+ lc := lastCommits[0]
122+ item.CommitURL = tw.Config.GetCommitURL(lc.ID.String())
123+ item.CommitID = GetShortID(lc.ID.String())
124+ item.Summary = lc.Summary()
125+ item.When = lc.Author.When.Format(time.DateOnly)
126+ item.WhenISO = lc.Author.When.UTC().Format(time.RFC3339)
127+ item.WhenDisplay = FormatDateForDisplay(lc.Author.When)
128+ item.Author = lc.Author
129+ }
130+ }
131+
132+ fpath := tw.Config.GetFileURL(*tw.PageData.RevData, fmt.Sprintf("%s.html", fname))
133+ switch typ {
134+ case git.ObjectTree:
135+ item.IsDir = true
136+ fpath = tw.Config.CompileURL(
137+ filepath.Join(
138+ GetFileBaseDir(*tw.PageData.RevData),
139+ curpath,
140+ entry.Name(),
141+ ),
142+ "index.html",
143+ )
144+ case git.ObjectBlob:
145+ item.Icon = FilenameToDevIcon(item.Name)
146+ }
147+ item.URL = fpath
148+
149+ return item
150+}
151+
152+func (tw *TreeWalker) Walk(tree *git.Tree, curpath string) {
153+ entries, err := tree.Entries()
154+ Bail(err)
155+
156+ crumbs := tw.CalcBreadcrumbs(curpath)
157+ treeEntries := []*TreeItem{}
158+ for _, entry := range entries {
159+ typ := entry.Type()
160+ item := tw.NewTreeItem(entry, curpath, crumbs)
161+
162+ switch typ {
163+ case git.ObjectTree:
164+ item.IsDir = true
165+ re, _ := tree.Subtree(entry.Name())
166+ tw.Walk(re, item.Path)
167+ treeEntries = append(treeEntries, item)
168+ tw.treeItem <- item
169+ case git.ObjectBlob:
170+ treeEntries = append(treeEntries, item)
171+ tw.treeItem <- item
172+ }
173+ }
174+
175+ sort.Slice(treeEntries, func(i, j int) bool {
176+ nameI := treeEntries[i].Name
177+ nameJ := treeEntries[j].Name
178+ if treeEntries[i].IsDir && treeEntries[j].IsDir {
179+ return nameI < nameJ
180+ }
181+
182+ if treeEntries[i].IsDir && !treeEntries[j].IsDir {
183+ return true
184+ }
185+
186+ if !treeEntries[i].IsDir && treeEntries[j].IsDir {
187+ return false
188+ }
189+
190+ return nameI < nameJ
191+ })
192+
193+ fpath := filepath.Join(
194+ GetFileBaseDir(*tw.PageData.RevData),
195+ curpath,
196+ )
197+ if curpath == "" {
198+ fpath = GetTreeBaseDir(*tw.PageData.RevData)
199+ }
200+
201+ tw.tree <- &TreeRoot{
202+ Path: fpath,
203+ Items: treeEntries,
204+ Crumbs: crumbs,
205+ }
206+
207+ if curpath == "" {
208+ close(tw.tree)
209+ close(tw.treeItem)
210+ }
211+}
A
urls.go
+85,
-0
1@@ -0,0 +1,85 @@
2+package pgit
3+
4+import (
5+ "fmt"
6+ "html/template"
7+ "path/filepath"
8+ "strings"
9+)
10+
11+func (c *Config) GetSummaryURL() template.URL {
12+ url := c.RootRelative + "index.html"
13+ return template.URL(url)
14+}
15+
16+func (c *Config) GetRefsURL() template.URL {
17+ url := c.RootRelative + "refs.html"
18+ return template.URL(url)
19+}
20+
21+func GetRevIDForURL(info RevInfo) string {
22+ return info.Name()
23+}
24+
25+func GetTreeBaseDir(info RevInfo) string {
26+ subdir := GetRevIDForURL(info)
27+ return filepath.Join("/", "tree", subdir)
28+}
29+
30+func GetLogBaseDir(info RevInfo) string {
31+ subdir := GetRevIDForURL(info)
32+ return filepath.Join("/", "logs", subdir)
33+}
34+
35+func GetFileBaseDir(info RevInfo) string {
36+ return filepath.Join(GetTreeBaseDir(info), "item")
37+}
38+
39+func GetFileDir(info RevInfo, fname string) string {
40+ return filepath.Join(GetFileBaseDir(info), fname)
41+}
42+
43+func (c *Config) GetFileURL(info RevInfo, fname string) template.URL {
44+ return c.CompileURL(GetFileBaseDir(info), fname)
45+}
46+
47+func (c *Config) CompileURL(dir, fname string) template.URL {
48+ purl := c.RootRelative + strings.TrimPrefix(dir, "/")
49+ url := filepath.Join(purl, fname)
50+ return template.URL(url)
51+}
52+
53+func (c *Config) GetTreeURL(info RevInfo) template.URL {
54+ dir := GetTreeBaseDir(info)
55+ return c.CompileURL(dir, "index.html")
56+}
57+
58+func (c *Config) GetLogsURL(info RevInfo) template.URL {
59+ dir := GetLogBaseDir(info)
60+ return c.CompileURL(dir, "index.html")
61+}
62+
63+func (c *Config) GetCommitURL(commitID string) template.URL {
64+ url := fmt.Sprintf("%scommits/%s.html", c.RootRelative, commitID)
65+ return template.URL(url)
66+}
67+
68+func (c *Config) GetURLs() *SiteURLs {
69+ return &SiteURLs{
70+ HomeURL: c.HomeURL,
71+ CloneURL: c.CloneURL,
72+ RefsURL: c.GetRefsURL(),
73+ SummaryURL: c.GetSummaryURL(),
74+ IssuesURL: c.GetIssuesURL(),
75+ }
76+}
77+
78+func (c *Config) GetIssuesURL() template.URL {
79+ url := c.RootRelative + "issues/open/index.html"
80+ return template.URL(url)
81+}
82+
83+func (c *Config) GetIssueURL(issueID string) template.URL {
84+ url := fmt.Sprintf("%sissues/%s.html", c.RootRelative, issueID)
85+ return template.URL(url)
86+}