Performance improvements and standards updates also.. - staticgit - A git static site generator, the site you are viewing now!
(DIR) Log
(DIR) Files
(DIR) Refs
(DIR) README
---
(DIR) commit 0552fd260809624faa17bd5b9884cd5509db2062
(DIR) parent b173c1cd71dbdf31b74ece626c89911d8fb566bc
(HTM) Author: Jay Scott <me@jay.scot>
Date: Thu, 18 Jul 2024 23:46:56 +0100
Performance improvements and standards updates also..
- Refactoring the file structure to align to GO standards
- Adding cobra to handle flag parsing instead of flag.
- Broke out the HTML templates into files and using embed.
- Adding README
There is still code duplication in regards to git repo functions,
however, when I tried to optimize this by creating functions for git
open, head, commit and hash. I found that performance decreased by a
factor of two, so I have kept in the way I have it for now until I find
out why.
Diffstat:
M Makefile | 4 ++--
A README | 22 ++++++++++++++++++++++
A cmd/staticgit/staticgit.go | 39 +++++++++++++++++++++++++++++++
M go.mod | 5 ++++-
M go.sum | 8 ++++++++
A internal/config/config.go | 19 +++++++++++++++++++
A internal/repo/repo.go | 162 ++++++++++++++++++++++++++++++
A internal/site/build.go | 96 +++++++++++++++++++++++++++++++
A internal/template/template.go | 94 +++++++++++++++++++++++++++++++
A internal/template/templates/base.h… | 37 +++++++++++++++++++++++++++++++
A internal/template/templates/detail… | 36 +++++++++++++++++++++++++++++++
A internal/template/templates/index.… | 20 ++++++++++++++++++++
D main.go | 452 -------------------------------
13 files changed, 539 insertions(+), 455 deletions(-)
---
(DIR) diff --git a/Makefile b/Makefile
@@ -8,11 +8,11 @@ all: run
run:
@echo "Running $(APP_NAME)..."
- @go run . -p ./git -o tmp -i .ssh,jay.scot,internal-docs
+ @go run ./cmd/staticgit -p ./git -o tmp -i .ssh,jay.scot,internal-docs
build:
@echo "Building $(APP_NAME) for local architecture..."
- @go build $(GO_FLAGS) -o $(BUILD_DIR)/$(APP_NAME) .
+ @go build $(GO_FLAGS) -o $(BUILD_DIR)/$(APP_NAME) ./cmd/staticgit
@echo "Binary created at $(BUILD_DIR)/$(APP_NAME)"
fmt:
(DIR) diff --git a/README b/README
@@ -0,0 +1,22 @@
+ __ ___ ___ __ __ ___
+/__` | /\ | | / ` / _` | |
+.__/ | /~~\ | | \__, \__> | |
+
+
+---
+
+StaticGit is a tool that generates static HTML pages from Git repositories. It
+can process multiple repositories and create an index page along with detailed
+pages for each repository. The site you are currently reading this on is
+created via StaticGit.
+
+
+Usage:
+ staticgit [flags]
+
+Flags:
+ -c, --commits int Max commits to display (default 100)
+ -h, --help help for staticgit
+ -i, --ignore strings Dirs to ignore (comma-separated)
+ -o, --out string Root path for output (default ".")
+ -p, --path string Path to git repos (required)
(DIR) diff --git a/cmd/staticgit/staticgit.go b/cmd/staticgit/staticgit.go
@@ -0,0 +1,39 @@
+package main
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/spf13/cobra"
+ "staticgit/internal/config"
+ "staticgit/internal/site"
+)
+
+var (
+ cfg = &config.Config{}
+ rootCmd = &cobra.Command{
+ Use: "staticgit",
+ Short: "StaticGit generates static HTML pages from Git repositories",
+ Long: `StaticGit is a tool that generates static HTML pages from Git repositories.
+It can process multiple repositories and create an index page along with detailed pages for each repository.`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return site.Build(cfg)
+ },
+ }
+)
+
+func init() {
+ rootCmd.Flags().StringVarP(&cfg.RepoDir, "path", "p", "", "Path to git repos (required)")
+ rootCmd.Flags().StringVarP(&cfg.OutDir, "out", "o", ".", "Root path for output")
+ rootCmd.Flags().IntVarP(&cfg.MaxCommits, "commits", "c", 100, "Max commits to display")
+ rootCmd.Flags().StringSliceVarP(&cfg.IgnoreDirs, "ignore", "i", []string{}, "Dirs to ignore (comma-separated)")
+
+ rootCmd.MarkFlagRequired("path")
+}
+
+func main() {
+ if err := rootCmd.Execute(); err != nil {
+ fmt.Println(err)
+ os.Exit(1)
+ }
+}
(DIR) diff --git a/go.mod b/go.mod
@@ -1,4 +1,4 @@
-module main
+module staticgit
go 1.22.5
@@ -14,11 +14,14 @@ require (
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.5.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/pjbgf/sha1cd v0.3.0 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.2.2 // indirect
+ github.com/spf13/cobra v1.8.1 // indirect
+ github.com/spf13/pflag v1.0.5 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/mod v0.12.0 // indirect
(DIR) diff --git a/go.sum b/go.sum
@@ -13,6 +13,7 @@ github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7N
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
+github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg=
github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -36,6 +37,8 @@ github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
@@ -57,11 +60,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A=
github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo=
+github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
+github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
(DIR) diff --git a/internal/config/config.go b/internal/config/config.go
@@ -0,0 +1,19 @@
+package config
+
+import (
+ "strings"
+)
+
+type Config struct {
+ RepoDir string
+ IgnoreDirs []string
+ OutDir string
+ MaxCommits int
+}
+
+func parseIgnoreList(dirs string) []string {
+ if dirs == "" {
+ return nil
+ }
+ return strings.Split(dirs, ",")
+}
(DIR) diff --git a/internal/repo/repo.go b/internal/repo/repo.go
@@ -0,0 +1,162 @@
+package repo
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/go-git/go-git/v5"
+ "github.com/go-git/go-git/v5/plumbing/object"
+)
+
+type Repo struct {
+ Name string
+ Description string
+ LastMod time.Time
+ gitRepo *git.Repository
+}
+
+type Commit struct {
+ Hash string
+ Author string
+ Date string
+ Msg string
+ Added int
+ Removed int
+}
+
+func OpenRepo(path string) (*Repo, error) {
+ gitRepo, err := git.PlainOpen(path)
+ if err != nil {
+ return nil, fmt.Errorf("open repo: %w", err)
+ }
+
+ name := filepath.Base(path)
+ desc, _ := os.ReadFile(filepath.Join(path, "description"))
+
+ head, err := gitRepo.Head()
+ if err != nil {
+ return nil, fmt.Errorf("get HEAD: %w", err)
+ }
+
+ commit, err := gitRepo.CommitObject(head.Hash())
+ if err != nil {
+ return nil, fmt.Errorf("get commit object: %w", err)
+ }
+
+ return &Repo{
+ Name: name,
+ Description: strings.TrimSpace(string(desc)),
+ LastMod: commit.Committer.When,
+ gitRepo: gitRepo,
+ }, nil
+}
+
+func (r *Repo) GetCommits(maxCommits int) ([]Commit, error) {
+ iter, err := r.gitRepo.Log(&git.LogOptions{})
+ if err != nil {
+ return nil, fmt.Errorf("get commit log: %w", err)
+ }
+
+ var cs []Commit
+ err = iter.ForEach(func(c *object.Commit) error {
+ if len(cs) >= maxCommits {
+ return nil
+ }
+
+ stats, err := c.Stats()
+ if err != nil {
+ return fmt.Errorf("get commit stats: %w", err)
+ }
+
+ add, del := 0, 0
+ for _, stat := range stats {
+ add += stat.Addition
+ del += stat.Deletion
+ }
+
+ cs = append(cs, Commit{
+ Hash: c.Hash.String()[:7],
+ Author: c.Author.Name,
+ Date: c.Author.When.Format("02 Jan 2006 15:04:05"),
+ Msg: strings.Split(c.Message, "\n")[0],
+ Added: add,
+ Removed: del,
+ })
+
+ return nil
+ })
+
+ if err != nil {
+ return nil, fmt.Errorf("iterate commits: %w", err)
+ }
+
+ return cs, nil
+}
+
+func (r *Repo) GetFiles() ([]string, error) {
+ head, err := r.gitRepo.Head()
+ if err != nil {
+ return nil, fmt.Errorf("get HEAD: %w", err)
+ }
+
+ commit, err := r.gitRepo.CommitObject(head.Hash())
+ if err != nil {
+ return nil, fmt.Errorf("get commit object: %w", err)
+ }
+
+ tree, err := commit.Tree()
+ if err != nil {
+ return nil, fmt.Errorf("get tree: %w", err)
+ }
+
+ var fs []string
+ err = tree.Files().ForEach(func(f *object.File) error {
+ fs = append(fs, f.Name)
+ return nil
+ })
+ if err != nil {
+ return nil, fmt.Errorf("iterate files: %w", err)
+ }
+
+ sort.Strings(fs)
+ return fs, nil
+}
+
+func (r *Repo) GetReadme() (string, error) {
+ names := []string{"README.md", "README.txt", "README"}
+
+ head, err := r.gitRepo.Head()
+ if err != nil {
+ return "", fmt.Errorf("get HEAD: %w", err)
+ }
+
+ commit, err := r.gitRepo.CommitObject(head.Hash())
+ if err != nil {
+ return "", fmt.Errorf("get commit object: %w", err)
+ }
+
+ tree, err := commit.Tree()
+ if err != nil {
+ return "", fmt.Errorf("get tree: %w", err)
+ }
+
+ for _, name := range names {
+ file, err := tree.File(name)
+ if err != nil {
+ continue
+ }
+
+ content, err := file.Contents()
+ if err != nil {
+ return "", fmt.Errorf("read file contents: %w", err)
+ }
+
+ return content, nil
+ }
+
+ return "No README found!", nil
+}
(DIR) diff --git a/internal/site/build.go b/internal/site/build.go
@@ -0,0 +1,96 @@
+package site
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+
+ "staticgit/internal/config"
+ "staticgit/internal/repo"
+ "staticgit/internal/template"
+)
+
+func Build(cfg *config.Config) error {
+ dirs, err := os.ReadDir(cfg.RepoDir)
+ if err != nil {
+ return fmt.Errorf("read repos dir: %w", err)
+ }
+
+ var wg sync.WaitGroup
+ repoChan := make(chan *repo.Repo, len(dirs))
+ errChan := make(chan error, len(dirs))
+
+ for _, d := range dirs {
+ if d.IsDir() && !contains(cfg.IgnoreDirs, d.Name()) {
+ wg.Add(1)
+ go func(d os.DirEntry) {
+ defer wg.Done()
+ path := filepath.Join(cfg.RepoDir, d.Name())
+ r, err := repo.OpenRepo(path)
+ if err != nil {
+ errChan <- fmt.Errorf("open repo %s: %w", d.Name(), err)
+ return
+ }
+ repoChan <- r
+
+ out := filepath.Join(cfg.OutDir, d.Name())
+ if err := os.MkdirAll(out, 0755); err != nil {
+ errChan <- fmt.Errorf("create dir for %s: %w", d.Name(), err)
+ return
+ }
+
+ if err := generateRepoPage(r, out, cfg.MaxCommits); err != nil {
+ errChan <- fmt.Errorf("process %s: %w", d.Name(), err)
+ }
+ }(d)
+ }
+ }
+
+ go func() {
+ wg.Wait()
+ close(repoChan)
+ close(errChan)
+ }()
+
+ var repos []*repo.Repo
+ for r := range repoChan {
+ repos = append(repos, r)
+ }
+
+ for err := range errChan {
+ fmt.Println(err)
+ }
+
+ return template.GenerateIndex(cfg.OutDir, repos)
+}
+
+func generateRepoPage(r *repo.Repo, out string, maxCommits int) error {
+ readme, err := r.GetReadme()
+ if err != nil {
+ return fmt.Errorf("get README: %w", err)
+ }
+
+ commits, err := r.GetCommits(maxCommits)
+ if err != nil {
+ return fmt.Errorf("get commits: %w", err)
+ }
+
+ files, err := r.GetFiles()
+ if err != nil {
+ return fmt.Errorf("get files: %w", err)
+ }
+
+ outPath := filepath.Join(out, "index.html")
+
+ return template.GenerateRepoPage(r.Name, readme, commits, files, outPath)
+}
+
+func contains(slice []string, item string) bool {
+ for _, a := range slice {
+ if a == item {
+ return true
+ }
+ }
+ return false
+}
(DIR) diff --git a/internal/template/template.go b/internal/template/template.go
@@ -0,0 +1,94 @@
+package template
+
+import (
+ "embed"
+ "fmt"
+ "html/template"
+ "io"
+ "log"
+ "os"
+ "path/filepath"
+ "sort"
+
+ "staticgit/internal/repo"
+)
+
+//go:embed templates/*
+var templateFS embed.FS
+
+var (
+ templates = map[string]*template.Template{}
+)
+
+func init() {
+ for _, name := range []string{"index", "detail"} {
+ baseContent, err := templateFS.ReadFile("templates/base.html")
+ if err != nil {
+ log.Fatalf("Failed to read base.html: %v", err)
+ }
+
+ contentFile := fmt.Sprintf("templates/%s.html", name)
+ contentContent, err := templateFS.ReadFile(contentFile)
+ if err != nil {
+ log.Fatalf("Failed to read %s: %v", contentFile, err)
+ }
+
+ t, err := template.New(name).Parse(string(baseContent) + string(contentContent))
+ if err != nil {
+ log.Fatalf("Failed to parse %s template: %v", name, err)
+ }
+
+ templates[name] = t
+ }
+}
+
+func GenerateIndex(outDir string, repos []*repo.Repo) error {
+ sort.Slice(repos, func(i, j int) bool {
+ return repos[i].LastMod.After(repos[j].LastMod)
+ })
+
+ path := filepath.Join(outDir, "index.html")
+
+ f, err := os.Create(path)
+ if err != nil {
+ return fmt.Errorf("create index HTML: %w", err)
+ }
+ defer f.Close()
+
+ return executeTemplate("index", f, struct {
+ Title string
+ Repos []*repo.Repo
+ }{
+ Title: "Repos for days!",
+ Repos: repos,
+ })
+}
+
+func GenerateRepoPage(name, readmeContent string, commits []repo.Commit, files []string, outPath string) error {
+ f, err := os.Create(outPath)
+ if err != nil {
+ return fmt.Errorf("create details HTML: %w", err)
+ }
+ defer f.Close()
+
+ return executeTemplate("detail", f, struct {
+ Title string
+ ReadmeContent string
+ Files []string
+ Commits []repo.Commit
+ }{
+ Title: "git clone git://jay.scot/" + name,
+ ReadmeContent: readmeContent,
+ Files: files,
+ Commits: commits,
+ })
+}
+
+func executeTemplate(name string, w io.Writer, data interface{}) error {
+ t, ok := templates[name]
+ if !ok {
+ return fmt.Errorf("template %s not found", name)
+ }
+
+ return t.Execute(w, data)
+}
(DIR) diff --git a/internal/template/templates/base.html b/internal/template/templates/base.html
@@ -0,0 +1,37 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>{{.Title}}</title>
+ <style>
+ body {
+ font-family: monospace;
+ line-height: 1.6;
+ margin: 0;
+ padding: 20px;
+ max-width: 1200px;
+ margin: 0 auto;
+ background-color: #f4f4f4;
+ }
+ h1, h2 { margin: 0.5em 0; }
+ table {
+ border-collapse: collapse;
+ width: 100%;
+ }
+ td, th {
+ border-bottom: 1px solid #ccc;
+ padding: 3px;
+ text-align: left;
+ }
+ @media (max-width: 600px) {
+ body { padding: 10px; }
+ table { font-size: 14px; }
+ }
+ </style>
+</head>
+<body>
+ <h1>{{.Title}}</h1>
+ {{block "content" .}}{{end}}
+</body>
+</html>
(DIR) diff --git a/internal/template/templates/detail.html b/internal/template/templates/detail.html
@@ -0,0 +1,36 @@
+{{define "content"}}
+<pre>{{.ReadmeContent}}</pre>
+
+<h2>Commit History</h2>
+<table>
+ <thead>
+ <tr>
+ <th>Date</th>
+ <th>Message</th>
+ <th>Author</th>
+ <th>Hash</th>
+ <th>Added</th>
+ <th>Deleted</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{range .Commits}}
+ <tr>
+ <td>{{.Date}}</td>
+ <td>{{.Msg}}</td>
+ <td>{{.Author}}</td>
+ <td>{{.Hash}}</td>
+ <td>+{{.Added}}</td>
+ <td>-{{.Removed}}</td>
+ </tr>
+ {{end}}
+ </tbody>
+</table>
+
+<h2>Files</h2>
+<ul>
+ {{range .Files}}
+ <li>{{.}}</li>
+ {{end}}
+</ul>
+{{end}}
(DIR) diff --git a/internal/template/templates/index.html b/internal/template/templates/index.html
@@ -0,0 +1,20 @@
+{{define "content"}}
+<table>
+ <thead>
+ <tr>
+ <th>Name</th>
+ <th>Description</th>
+ <th>Last commit</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{range .Repos}}
+ <tr>
+ <td><a href="{{.Name}}/index.html">{{.Name}}</a></td>
+ <td>{{.Description}}</td>
+ <td>{{.LastMod.Format "2006-01-02 15:04:05"}}</td>
+ </tr>
+ {{end}}
+ </tbody>
+</table>
+{{end}}
(DIR) diff --git a/main.go b/main.go
@@ -1,452 +0,0 @@
-package main
-
-import (
- "flag"
- "fmt"
- "html/template"
- "log"
- "os"
- "path/filepath"
- "sort"
- "strings"
- "sync"
- "time"
-
- "github.com/go-git/go-git/v5"
- "github.com/go-git/go-git/v5/plumbing/object"
-)
-
-type Commit struct {
- Hash string
- Author string
- Date string
- Msg string
- Added int
- Removed int
-}
-
-type Repo struct {
- Name string
- Desc string
- LastMod time.Time
-}
-
-const (
- baseHtml = `
-<!DOCTYPE html>
-<html lang="en">
-<head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>{{.Title}}</title>
- <style>
- body {
- font-family: monospace;
- line-height: 1.6;
- margin: 0;
- padding: 20px;
- max-width: 1200px;
- margin: 0 auto;
- background-color: #f4f4f4;
- }
- h1, h2 { margin: 0.5em 0; }
- table {
- border-collapse: collapse;
- width: 100%;
- }
- td, th {
- border-bottom: 1px solid #ccc;
- padding: 3px;
- text-align: left;
- }
- @media (max-width: 600px) {
- body { padding: 10px; }
- table { font-size: 14px; }
- }
- </style>
-</head>
-<body>
- <h1>{{.Title}}</h1>
- {{template "content" .}}
-</body>
-</html>
-`
-
- detailHtml = `
-{{define "content"}}
- <pre>{{.ReadmeContent}}</pre>
-
- <h2>Commit History</h2>
- <table>
- <thead>
- <tr>
- <th>Date</th>
- <th>Message</th>
- <th>Author</th>
- <th>Hash</th>
- <th>Added</th>
- <th>Deleted</th>
- </tr>
- </thead>
- <tbody>
- {{range .Commits}}
- <tr>
- <td>{{.Date}}</td>
- <td>{{.Msg}}</td>
- <td>{{.Author}}</td>
- <td>{{.Hash}}</td>
- <td>+{{.Added}}</td>
- <td>-{{.Removed}}</td>
- </tr>
- {{end}}
- </tbody>
- </table>
-
- <h2>Files</h2>
- <ul>
- {{range .Files}}
- <li>{{.}}</li>
- {{end}}
- </ul>
-{{end}}
-`
-
- indexHtml = `
-{{define "content"}}
- <table>
- <thead>
- <tr>
- <th>Name</th>
- <th>Description</th>
- <th>Last commit</th>
- </tr>
- </thead>
- <tbody>
- {{range .Repos}}
- <tr>
- <td><a href="{{.Name}}/index.html">{{.Name}}</a></td>
- <td>{{.Desc}}</td>
- <td>{{.LastMod.Format "2006-01-02 15:04:05"}}</td>
- </tr>
- {{end}}
- </tbody>
- </table>
-{{end}}
-`
-)
-
-var (
- templates = map[string]*template.Template{
- "index": template.Must(template.New("base").Parse(baseHtml + indexHtml)),
- "details": template.Must(template.New("base").Parse(baseHtml + detailHtml)),
- }
-
- repoDir string
- ignoreDirs map[string]bool
- outDir string
- maxCommits int
-)
-
-func genIndex(repos []Repo) error {
- sort.Slice(repos, func(i, j int) bool {
- return repos[i].LastMod.After(repos[j].LastMod)
- })
-
- path := filepath.Join(outDir, "index.html")
-
- f, err := os.Create(path)
- if err != nil {
- return fmt.Errorf("create index HTML: %w", err)
- }
- defer f.Close()
-
- return templates["index"].Execute(f, struct {
- Title string
- Repos []Repo
- }{
- Title: "Repos for days!",
- Repos: repos,
- })
-}
-
-func genRepo(name, path, out string) error {
- repo, err := git.PlainOpen(path)
- if err != nil {
- return fmt.Errorf("open git repo: %w", err)
- }
-
- readme, err := readme(path)
- if err != nil {
- return fmt.Errorf("get README: %w", err)
- }
-
- cs, err := commits(repo)
- if err != nil {
- return fmt.Errorf("get commits: %w", err)
- }
-
- fs, err := files(repo)
- if err != nil {
- return fmt.Errorf("get files: %w", err)
- }
-
- path = filepath.Join(out, "index.html")
-
- f, err := os.Create(path)
- if err != nil {
- return fmt.Errorf("create details HTML: %w", err)
- }
- defer f.Close()
-
- return templates["details"].Execute(f, struct {
- Title string
- ReadmeContent string
- Files []string
- Commits []Commit
- }{
- Title: "git clone git://jay.scot/" + name,
- ReadmeContent: readme,
- Files: fs,
- Commits: cs,
- })
-}
-
-func commits(repo *git.Repository) ([]Commit, error) {
- iter, err := repo.CommitObjects()
- if err != nil {
- return nil, fmt.Errorf("get commit objects: %w", err)
- }
-
- var cs []Commit
- count := 0
- limit := false
-
- err = iter.ForEach(func(c *object.Commit) error {
- if limit {
- return nil
- }
-
- stats, err := c.Stats()
- if err != nil {
- return fmt.Errorf("get commit stats: %w", err)
- }
-
- add, del := 0, 0
- for _, stat := range stats {
- add += stat.Addition
- del += stat.Deletion
- }
-
- cs = append(cs, Commit{
- Hash: c.Hash.String()[:7],
- Author: c.Author.Name,
- Date: c.Author.When.Format("02 Jan 2006 15:04:05"),
- Msg: strings.Split(c.Message, "\n")[0],
- Added: add,
- Removed: del,
- })
-
- count++
- if count >= maxCommits {
- limit = true
- }
- return nil
- })
-
- if err != nil {
- return nil, fmt.Errorf("iterate commits: %w", err)
- }
-
- sort.Slice(cs, func(i, j int) bool {
- timeI, _ := time.Parse("02 Jan 2006 15:04:05", cs[i].Date)
- timeJ, _ := time.Parse("02 Jan 2006 15:04:05", cs[j].Date)
- return timeI.After(timeJ)
- })
-
- return cs, nil
-}
-
-func files(repo *git.Repository) ([]string, error) {
- ref, err := repo.Head()
- if err != nil {
- return nil, fmt.Errorf("get HEAD: %w", err)
- }
-
- commit, err := repo.CommitObject(ref.Hash())
- if err != nil {
- return nil, fmt.Errorf("get commit object: %w", err)
- }
-
- tree, err := commit.Tree()
- if err != nil {
- return nil, fmt.Errorf("get tree: %w", err)
- }
-
- var fs []string
- err = tree.Files().ForEach(func(f *object.File) error {
- fs = append(fs, f.Name)
- return nil
- })
- if err != nil {
- return nil, fmt.Errorf("iterate files: %w", err)
- }
-
- sort.Strings(fs)
- return fs, nil
-}
-
-func readme(path string) (string, error) {
- names := []string{"README.md", "README.txt", "README"}
- repo, err := git.PlainOpen(path)
- if err != nil {
- return "", fmt.Errorf("open git repo: %w", err)
- }
-
- ref, err := repo.Head()
- if err != nil {
- return "", fmt.Errorf("get HEAD: %w", err)
- }
-
- commit, err := repo.CommitObject(ref.Hash())
- if err != nil {
- return "", fmt.Errorf("get commit object: %w", err)
- }
-
- tree, err := commit.Tree()
- if err != nil {
- return "", fmt.Errorf("get tree: %w", err)
- }
-
- for _, name := range names {
- file, err := tree.File(name)
- if err != nil {
- continue
- }
-
- content, err := file.Contents()
- if err != nil {
- return "", fmt.Errorf("read file contents: %w", err)
- }
-
- return content, nil
- }
-
- return "No README found!", nil
-}
-
-func repoInfo(path string) (Repo, error) {
- repo, err := git.PlainOpen(path)
- if err != nil {
- return Repo{}, fmt.Errorf("open repo: %w", err)
- }
-
- ref, err := repo.Head()
- if err != nil {
- return Repo{}, fmt.Errorf("get HEAD: %w", err)
- }
-
- commit, err := repo.CommitObject(ref.Hash())
- if err != nil {
- return Repo{}, fmt.Errorf("get commit object: %w", err)
- }
-
- content, _ := os.ReadFile(filepath.Join(path, "description"))
-
- return Repo{
- Name: filepath.Base(path),
- Desc: strings.TrimSpace(string(content)),
- LastMod: commit.Committer.When,
- }, nil
-}
-
-func ignoreList(dirs string) map[string]bool {
- ignore := make(map[string]bool)
- for _, dir := range strings.Split(dirs, ",") {
- if d := strings.TrimSpace(dir); d != "" {
- ignore[d] = true
- }
- }
- return ignore
-}
-
-func build() error {
- dirs, err := os.ReadDir(repoDir)
- if err != nil {
- return fmt.Errorf("read repos dir: %w", err)
- }
-
- var wg sync.WaitGroup
- repoChan := make(chan Repo, len(dirs))
- errChan := make(chan error, len(dirs))
-
- for _, d := range dirs {
- if d.IsDir() && !ignoreDirs[d.Name()] {
- wg.Add(1)
- go func(d os.DirEntry) {
- defer wg.Done()
- path := filepath.Join(repoDir, d.Name())
- repo, err := repoInfo(path)
- if err != nil {
- errChan <- fmt.Errorf("get info for %s: %w", d.Name(), err)
- return
- }
- repoChan <- repo
-
- out := filepath.Join(outDir, d.Name())
- if err := os.MkdirAll(out, 0755); err != nil {
- errChan <- fmt.Errorf("create dir for %s: %w", d.Name(), err)
- return
- }
-
- if err := genRepo(d.Name(), path, out); err != nil {
- errChan <- fmt.Errorf("process %s: %w", d.Name(), err)
- }
- }(d)
- }
- }
-
- go func() {
- wg.Wait()
- close(repoChan)
- close(errChan)
- }()
-
- var repos []Repo
- for repo := range repoChan {
- repos = append(repos, repo)
- }
-
- for err := range errChan {
- fmt.Println(err)
- }
-
- return genIndex(repos)
-}
-
-func main() {
- flag.StringVar(&repoDir, "p", "", "Path to git repos (required)")
- flag.StringVar(&outDir, "o", ".", "Root path for output")
- flag.IntVar(&maxCommits, "c", 100, "Max commits to display (default 100)")
- ignore := flag.String("i", "", "Dirs to ignore (comma-separated)")
-
- flag.Usage = func() {
- fmt.Fprintf(os.Stderr, "Usage: %s [options]\n\n", os.Args[0])
- fmt.Fprintf(os.Stderr, "Options:\n")
- flag.PrintDefaults()
- fmt.Fprintf(os.Stderr, "\nExample:\n")
- fmt.Fprintf(os.Stderr, " %s -p /path/to/repos -i dir1,dir2 -o /path/to/output -c50\n", os.Args[0])
- }
-
- flag.Parse()
-
- if repoDir == "" {
- flag.Usage()
- os.Exit(1)
- }
-
- ignoreDirs = ignoreList(*ignore)
-
- if err := build(); err != nil {
- log.Fatalf("Error building site: %v", err)
- }
-}