gitssg/gitssg.go
2024-06-30 01:05:12 +02:00

353 lines
9.1 KiB
Go

package main
import (
"bytes"
"embed"
"fmt"
"html/template"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/alecthomas/kong"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
)
type RepoDir struct {
Name string
Description string
Owner string
LastCommit string
}
type RepoInfo struct {
Name string
Description string
Url string
HasReadme bool
HasLicense bool
HasContributing bool
}
type CommitFile struct {
Mode string
Path string
Name string
Lines int
IsBinary bool
Size int64
}
type CommitLog struct {
Hash string
Date string
Msg string
Author string
Files int
Adds int
Dels int
}
//go:embed templates
var embedTmpl embed.FS
var funcMap = template.FuncMap{"inc": func(i int) int { return i + 1 }}
var timeFormat = "2006-01-02 15:04"
// ToDo: add a map function to generate the menu
func executeTemplate(name string, data any) ([]byte, error) {
path := filepath.Join("templates", fmt.Sprintf("%s.html.tmpl", name))
b, err := embedTmpl.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("cannot read embedded file %q: %w", path, err)
}
tmpl, err := template.New("").Funcs(funcMap).Parse(string(b))
if err != nil {
return nil, fmt.Errorf("cannot parse template %q: %w", path, err)
}
var strb bytes.Buffer
if err := tmpl.Execute(&strb, data); err != nil {
return nil, fmt.Errorf("cannot execute template %q: %w", path, err)
}
return strb.Bytes(), nil
}
func readFile(path string) (string, error) {
if fi, err := os.Stat(path); err == nil && !fi.IsDir() {
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("cannot read contents of file %q: %w", path, err)
}
return strings.TrimSpace(string(b)), nil
}
return "", nil
}
func readRepoFile(dirname, name string) (contents string, err error) {
contents, err = readFile(filepath.Join(dirname, ".git", name))
if err != nil {
return
}
if contents == "" {
contents, err = readFile(filepath.Join(dirname, name))
return
}
return
}
func generateIndex(args []string) error {
slog.Debug("Generating index", "args", args)
repoDirs := []*RepoDir{}
for _, dirname := range args {
slog.Debug("Processing directory", "dirname", dirname)
reponame := strings.TrimSuffix(filepath.Base(dirname), ".git")
repoDir := &RepoDir{Name: reponame}
// read description
description, err := readRepoFile(dirname, "description")
if err != nil {
return fmt.Errorf("cannot read description for repository %q: %w", dirname, err)
}
repoDir.Description = description
// read owner
owner, err := readRepoFile(dirname, "owner")
if err != nil {
return fmt.Errorf("cannot read owner for repository %q: %w", dirname, err)
}
repoDir.Owner = owner
// get last commit date
repo, err := git.PlainOpen(dirname)
if err != nil {
return fmt.Errorf("cannot open repository %q: %w", dirname, err)
}
head, err := repo.Head()
if err != nil {
return fmt.Errorf("cannot get repository head for %q: %w", dirname, err)
}
c, err := repo.CommitObject(head.Hash())
if err != nil {
return fmt.Errorf("cannot get commit %q for repository %q: %w", head.Hash(), dirname, err)
}
repoDir.LastCommit = c.Author.When.Format(timeFormat)
repoDirs = append(repoDirs, repoDir)
}
data := map[string]any{"repoDirs": repoDirs}
contents, err := executeTemplate("index", data)
if err != nil {
return fmt.Errorf("cannot execute index template: %w", err)
}
if err := os.WriteFile("index.html", contents, 0755); err != nil {
return fmt.Errorf("cannot write index contents to \"index.html\": %w", err)
}
return nil
}
func generateRepo(path string) error {
slog.Debug("Generating repository", "path", path)
repoInfo := &RepoInfo{Name: strings.TrimSuffix(filepath.Base(path), ".git")}
// create and clean destination directory
fi, err := os.Stat(repoInfo.Name)
if err == nil && !fi.IsDir() {
return fmt.Errorf("path %q is not a directory", repoInfo.Name)
}
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("cannot stat %q: %w", repoInfo.Name, err)
}
if err == nil && fi.IsDir() {
if err := os.RemoveAll(repoInfo.Name); err != nil {
return fmt.Errorf("cannot delete directory for %q: %w", repoInfo.Name, err)
}
}
if err := os.Mkdir(repoInfo.Name, 0755); err != nil {
return fmt.Errorf("cannot create directory for %q: %w", repoInfo.Name, err)
}
// read description
description, err := readRepoFile(path, "description")
if err != nil {
return fmt.Errorf("cannot read description for repository %q: %w", path, err)
}
repoInfo.Description = description
// read url
url, err := readRepoFile(path, "url")
if err != nil {
return fmt.Errorf("cannot read url for repository %q: %w", path, err)
}
repoInfo.Url = url
// open repository
repo, err := git.PlainOpen(path)
if err != nil {
return fmt.Errorf("cannot open repository %q: %w", path, err)
}
head, err := repo.Head()
if err != nil {
return fmt.Errorf("cannot get repo head: %w", err)
}
c, err := repo.CommitObject(head.Hash())
if err != nil {
return fmt.Errorf("cannot get commit for hash %s: %w", head.Hash(), err)
}
// ToDo: populate hasReadme, hasLicense and hasContributing
tree, err := c.Tree()
if err != nil {
return fmt.Errorf("cannot get tree for commit %s: %w", c, err)
}
slog.Debug("Processing repository tree", "head", head.Hash())
files := []*CommitFile{}
iErr := tree.Files().ForEach(func(o *object.File) error {
slog.Debug("Processing tree file", "name", o.Name)
dirpath := filepath.Join(repoInfo.Name, "file", filepath.Dir(o.Name))
filename := filepath.Base(o.Name)
slog.Debug("Creating directory", "dirpath", dirpath)
if err := os.MkdirAll(dirpath, 0755); err != nil {
return fmt.Errorf("cannot create directory %q: %w", dirpath, err)
}
data := map[string]any{
"repoInfo": repoInfo,
"filename": filename,
"filesize": o.Size,
}
isBinary, err := o.IsBinary()
if err != nil {
return fmt.Errorf("cannot get binary state for file %q: %w", o.Name, err)
}
data["isBinary"] = isBinary
var lines []string
if !isBinary {
var err error
lines, err = o.Lines()
if err != nil {
return fmt.Errorf("cannot get lines for %q: %w", o.Name, err)
}
data["filelines"] = lines
}
contents, err := executeTemplate("file", data)
if err != nil {
return fmt.Errorf("cannot execute file template for file %q: %w", o.Name, err)
}
dstpath := filepath.Join(dirpath, fmt.Sprintf("%s.html", filename))
if err := os.WriteFile(dstpath, contents, 0755); err != nil {
return fmt.Errorf("cannot write file contents to %q: %w", dstpath, err)
}
file := &CommitFile{
Mode: o.Mode.String(), // ToDo: correctly calculate the mode string
Path: filepath.Join("file", fmt.Sprintf("%s.html", o.Name)),
Name: o.Name,
Lines: len(lines),
IsBinary: isBinary,
Size: o.Size,
}
files = append(files, file)
return nil
})
if iErr != nil {
return fmt.Errorf("error while processing tree: %w", iErr)
}
// generate the files index file
// ToDo: bundle execute and write into a function
filesData := map[string]any{"repoInfo": repoInfo, "files": files}
filesContents, err := executeTemplate("files", filesData)
if err != nil {
return fmt.Errorf("cannot execute files template for repository %q: %w", repoInfo.Name, err)
}
if err := os.WriteFile(filepath.Join(repoInfo.Name, "files.html"), filesContents, 0755); err != nil {
return fmt.Errorf("cannot write files contents to \"files.html\": %w", err)
}
// generate the log file
cIter, err := repo.Log(&git.LogOptions{All: true})
if err != nil {
return fmt.Errorf("cannot get git log for repository: %w", err)
}
loglines := []*CommitLog{}
ciErr := cIter.ForEach(func(c *object.Commit) error {
slog.Debug("Processing log commit", "hash", c.Hash)
// ToDo: is this the best way to get the number of files?
fstats, err := c.Stats()
if err != nil {
return fmt.Errorf("cannot get commit stats %s: %w", c.Hash, err)
}
commitLog := &CommitLog{
Hash: c.Hash.String(),
Date: c.Author.When.Format(timeFormat), // ToDo: author when vs commiter when?
Msg: strings.Split(c.Message, "\n")[0],
Author: c.Author.Name,
Files: len(fstats),
}
// ToDo: are there not global commit stats?
for _, fstat := range fstats {
commitLog.Adds += fstat.Addition
commitLog.Dels += fstat.Deletion
}
loglines = append(loglines, commitLog)
return nil
})
if ciErr != nil {
return fmt.Errorf("error while processing log: %w", iErr)
}
logData := map[string]any{"repoInfo": repoInfo, "loglines": loglines}
logContents, err := executeTemplate("log", logData)
if err != nil {
return fmt.Errorf("cannot execute logs template for repository %q: %w", repoInfo.Name, err)
}
if err := os.WriteFile(filepath.Join(repoInfo.Name, "log.html"), logContents, 0755); err != nil {
return fmt.Errorf("cannot write log contents to \"log.html\": %w", err)
}
return nil
}
func main() {
ctx := kong.Parse(&cli)
if cli.Debug {
slog.SetLogLoggerLevel(slog.LevelDebug)
}
if err := ctx.Run(); err != nil {
fmt.Fprintf(os.Stderr, "error: %s\n", err)
os.Exit(1)
}
}