package main import ( "bytes" "embed" "fmt" "html/template" "log/slog" "os" "path/filepath" "strings" "time" "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 time.Time } // ToDo: replace has* with the filename, as it can be bare or .md 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 CommitInfo struct { Hash string ParentHash string Date time.Time Msg string AuthorName string AuthorEmail string Files int Adds int Dels int } //go:embed templates var embedTmpl embed.FS var timeShortFormatStr = "2006-01-02 15:04" var timeLongFormatStr = time.RFC1123 var funcMap = template.FuncMap{ "inc": func(i int) int { return i + 1 }, "menu": func(repoInfo *RepoInfo, relpath string) template.HTML { menu := "Log" menu += " | Files" menu += " | Refs" if repoInfo.HasReadme { menu += "| README" } if repoInfo.HasLicense { menu += "| LICENSE" } if repoInfo.HasContributing { menu += "| CONTRIBUTING" } return template.HTML(menu) }, "firstLine": func(msg string) string { return strings.Split(msg, "\n")[0] }, "timeShortFormat": func(t time.Time) string { return t.Format(timeShortFormatStr) }, "timeLongFormat": func(t time.Time) string { return t.Format(timeLongFormatStr) }, } 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 executeTemplateToFile(tmplName string, data any, filename string) error { contents, err := executeTemplate(tmplName, data) if err != nil { return fmt.Errorf("cannot execute %q template: %w", tmplName, err) } if err := os.WriteFile(filename, contents, 0755); err != nil { return fmt.Errorf("cannot write %q contents to %q: %w", tmplName, filename, err) } return 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 getRelpath(name string) string { return strings.Repeat("../", strings.Count(name, "/")) } 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 repoDirs = append(repoDirs, repoDir) } data := map[string]any{"repoDirs": repoDirs} if err := executeTemplateToFile("index", data, "index.html"); err != nil { return fmt.Errorf("cannot execute template %q to file %q: %w", "index", "index.html", 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.MkdirAll(filepath.Join(repoInfo.Name, "commit"), 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{ "relpath": getRelpath(dirpath), "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 } dstpath := filepath.Join(dirpath, fmt.Sprintf("%s.html", filename)) if err := executeTemplateToFile("file", data, dstpath); err != nil { return fmt.Errorf("cannot execute template %q to file %q: %w", "file", 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, "relpath": ""} filesDstpath := filepath.Join(repoInfo.Name, "files.html") if err := executeTemplateToFile("files", filesData, filesDstpath); err != nil { return fmt.Errorf("cannot execute template %q to file %q: %w", "file", filesDstpath, 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) } commits := []*CommitInfo{} ciErr := cIter.ForEach(func(c *object.Commit) error { slog.Debug("Processing 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) } commit := &CommitInfo{ Hash: c.Hash.String(), Date: c.Author.When, // ToDo: author when vs commiter when? Msg: c.Message, AuthorName: c.Author.Name, AuthorEmail: c.Author.Email, Files: len(fstats), } if parent, _ := c.Parent(0); parent != nil { commit.ParentHash = parent.Hash.String() } // ToDo: are there not global commit stats? for _, fstat := range fstats { commit.Adds += fstat.Addition commit.Dels += fstat.Deletion } commits = append(commits, commit) // generate commit file data := map[string]any{"repoInfo": repoInfo, "commit": commit, "relpath": "../"} dstpath := filepath.Join(repoInfo.Name, "commit", fmt.Sprintf("%s.html", commit.Hash)) if err := executeTemplateToFile("commit", data, dstpath); err != nil { return fmt.Errorf("cannot execute template %q to file %q: %w", "commit", dstpath, err) } return nil }) if ciErr != nil { return fmt.Errorf("error while processing log: %w", ciErr) } logData := map[string]any{"repoInfo": repoInfo, "commits": commits, "relpath": ""} logDstpath := filepath.Join(repoInfo.Name, "log.html") if err := executeTemplateToFile("log", logData, logDstpath); err != nil { return fmt.Errorf("cannot execute template %q to file %q: %w", "file", logDstpath, 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) } }