| Performance improvements and standards updates also.. - staticgit - A git stati… | |
| Log | |
| Files | |
| Refs | |
| README | |
| --- | |
| commit 0552fd260809624faa17bd5b9884cd5509db2062 | |
| parent b173c1cd71dbdf31b74ece626c89911d8fb566bc | |
| Author: Jay Scott <[email protected]> | |
| 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(-) | |
| --- | |
| 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: | |
| 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) | |
| 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 reposit… | |
| + Long: `StaticGit is a tool that generates static HTML pages fr… | |
| +It can process multiple repositories and create an index page along with detai… | |
| + RunE: func(cmd *cobra.Command, args []string) error { | |
| + return site.Build(cfg) | |
| + }, | |
| + } | |
| +) | |
| + | |
| +func init() { | |
| + rootCmd.Flags().StringVarP(&cfg.RepoDir, "path", "p", "", "Path to git… | |
| + rootCmd.Flags().StringVarP(&cfg.OutDir, "out", "o", ".", "Root path fo… | |
| + rootCmd.Flags().IntVarP(&cfg.MaxCommits, "commits", "c", 100, "Max com… | |
| + rootCmd.Flags().StringSliceVarP(&cfg.IgnoreDirs, "ignore", "i", []stri… | |
| + | |
| + rootCmd.MarkFlagRequired("path") | |
| +} | |
| + | |
| +func main() { | |
| + if err := rootCmd.Execute(); err != nil { | |
| + fmt.Println(err) | |
| + os.Exit(1) | |
| + } | |
| +} | |
| 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 // ind… | |
| + github.com/inconshreveable/mousetrap v1.1.0 // indirect | |
| github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // ind… | |
| 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 // indir… | |
| 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 | |
| diff --git a/go.sum b/go.sum | |
| @@ -13,6 +13,7 @@ github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73p… | |
| github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nn… | |
| github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5… | |
| github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0k… | |
| +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa4… | |
| github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH5… | |
| github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aR… | |
| github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHj… | |
| @@ -36,6 +37,8 @@ github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9… | |
| github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4… | |
| github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= | |
| github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elm… | |
| +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13… | |
| +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2Fq… | |
| github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJ… | |
| github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo… | |
| github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaARe… | |
| @@ -57,11 +60,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYP… | |
| github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7g… | |
| github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISo… | |
| github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvd… | |
| +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiy… | |
| github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPL… | |
| github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQc… | |
| github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONW… | |
| github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj4… | |
| github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nT… | |
| +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= | |
| +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN… | |
| +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= | |
| +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3… | |
| github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GF… | |
| github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7p… | |
| github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+8… | |
| 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, ",") | |
| +} | |
| 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 | |
| +} | |
| 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: %… | |
| + return | |
| + } | |
| + repoChan <- r | |
| + | |
| + out := filepath.Join(cfg.OutDir, d.Name()) | |
| + if err := os.MkdirAll(out, 0755); err != nil { | |
| + errChan <- fmt.Errorf("create dir for … | |
| + return | |
| + } | |
| + | |
| + if err := generateRepoPage(r, out, cfg.MaxComm… | |
| + errChan <- fmt.Errorf("process %s: %w"… | |
| + } | |
| + }(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, outPa… | |
| +} | |
| + | |
| +func contains(slice []string, item string) bool { | |
| + for _, a := range slice { | |
| + if a == item { | |
| + return true | |
| + } | |
| + } | |
| + return false | |
| +} | |
| 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) + strin… | |
| + if err != nil { | |
| + log.Fatalf("Failed to parse %s template: %v", name, er… | |
| + } | |
| + | |
| + 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… | |
| + 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) | |
| +} | |
| diff --git a/internal/template/templates/base.html b/internal/template/template… | |
| @@ -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> | |
| diff --git a/internal/template/templates/detail.html b/internal/template/templa… | |
| @@ -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}} | |
| diff --git a/internal/template/templates/index.html b/internal/template/templat… | |
| @@ -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}} | |
| 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 +… | |
| - "details": template.Must(template.New("base").Parse(baseHtml +… | |
| - } | |
| - | |
| - 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… | |
| - return | |
| - } | |
| - repoChan <- repo | |
| - | |
| - out := filepath.Join(outDir, d.Name()) | |
| - if err := os.MkdirAll(out, 0755); err != nil { | |
| - errChan <- fmt.Errorf("create dir for … | |
| - return | |
| - } | |
| - | |
| - if err := genRepo(d.Name(), path, out); err !=… | |
| - errChan <- fmt.Errorf("process %s: %w"… | |
| - } | |
| - }(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 10… | |
| - 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… | |
| - } | |
| - | |
| - flag.Parse() | |
| - | |
| - if repoDir == "" { | |
| - flag.Usage() | |
| - os.Exit(1) | |
| - } | |
| - | |
| - ignoreDirs = ignoreList(*ignore) | |
| - | |
| - if err := build(); err != nil { | |
| - log.Fatalf("Error building site: %v", err) | |
| - } | |
| -} |