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)
       -        }
       -}