Commit 6a61076
2026-01-27 21:08:10
Changed files (12)
internal/cli/root.go
@@ -0,0 +1,32 @@
+package cli
+
+import (
+ "errors"
+ "fmt"
+ "os"
+)
+
+// ErrUsage indicates incorrect command-line usage.
+var ErrUsage = errors.New("usage error")
+
+const usage = `usage: forge <command> [arguments]
+
+commands:
+ static generate static site from bare git repos
+`
+
+// Run dispatches to the appropriate subcommand based on args.
+func Run(args []string) error {
+ if len(args) == 0 {
+ fmt.Fprint(os.Stderr, usage)
+ return ErrUsage
+ }
+
+ switch args[0] {
+ case "static":
+ return runStatic(args[1:])
+ default:
+ fmt.Fprint(os.Stderr, usage)
+ return fmt.Errorf("unknown command %q: %w", args[0], ErrUsage)
+ }
+}
internal/cli/static.go
@@ -0,0 +1,111 @@
+package cli
+
+import (
+ "flag"
+ "fmt"
+ "log/slog"
+ "os"
+ "path/filepath"
+
+ "forge/internal/gitmal"
+ "forge/internal/index"
+ "forge/internal/repo"
+)
+
+func runStatic(args []string) error {
+ fs := flag.NewFlagSet("forge static", flag.ContinueOnError)
+ fs.SetOutput(os.Stderr)
+
+ root := fs.String("root", "/opt/git", "path to bare repo root")
+ output := fs.String("output", "/var/www/git", "path to output directory")
+ all := fs.Bool("all", false, "discover and process all repos")
+
+ err := fs.Parse(args)
+ if err != nil {
+ return err
+ }
+
+ positional := fs.Args()
+
+ if *all && len(positional) > 0 {
+ fmt.Fprintln(os.Stderr, "error: --all cannot be combined with positional arguments")
+ return ErrUsage
+ }
+
+ if !*all && len(positional) == 0 {
+ fmt.Fprintln(os.Stderr, "error: provide a repo path or use --all")
+ fs.Usage()
+ return ErrUsage
+ }
+
+ if *all {
+ return staticAll(*root, *output)
+ }
+
+ return staticOne(*root, *output, positional[0])
+}
+
+func staticAll(root, output string) error {
+ repos, err := repo.Discover(root)
+ if err != nil {
+ return err
+ }
+
+ slog.Info("discovered repos", slog.Int("count", len(repos)))
+
+ var failed int
+ for _, r := range repos {
+ slog.Info("processing", slog.String("repo", r.RelPath))
+ outDir := filepath.Join(output, r.RelPath)
+ err := gitmal.Run(r.FullPath, r.Name, outDir)
+ if err != nil {
+ slog.Warn("skipping repo",
+ slog.String("repo", r.RelPath),
+ slog.String("error", err.Error()))
+ failed++
+ }
+ }
+
+ err = index.GenerateAll(output, repos)
+ if err != nil {
+ return err
+ }
+
+ slog.Info("indexes generated")
+
+ if failed > 0 {
+ return fmt.Errorf("gitmal failed for %d repo(s)", failed)
+ }
+ return nil
+}
+
+func staticOne(root, output, relPath string) error {
+ if filepath.IsAbs(relPath) {
+ fmt.Fprintln(os.Stderr, "error: repo path must be relative")
+ return ErrUsage
+ }
+
+ fullPath := filepath.Join(root, relPath)
+
+ bare, err := repo.IsBare(fullPath)
+ if err != nil {
+ return err
+ }
+ if !bare {
+ return fmt.Errorf("not a bare repo (path=%s): %w", fullPath, ErrUsage)
+ }
+
+ name := filepath.Base(relPath)
+ outDir := filepath.Join(output, relPath)
+
+ slog.Info("processing",
+ slog.String("repo", relPath),
+ slog.String("output", outDir))
+
+ err = gitmal.Run(fullPath, name, outDir)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
internal/gitmal/gitmal.go
@@ -0,0 +1,20 @@
+package gitmal
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+)
+
+// Run executes the gitmal binary against a bare repo.
+func Run(repoPath, name, outputDir string) error {
+ cmd := exec.Command("gitmal", "--name", name, "--output", outputDir, repoPath)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+
+ err := cmd.Run()
+ if err != nil {
+ return fmt.Errorf("running gitmal (repo=%s): %w", repoPath, err)
+ }
+ return nil
+}
internal/index/templates/category.html
@@ -0,0 +1,50 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{.Category}}</title>
+<style>
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
+ color: #24292e;
+ margin: 0;
+ padding: 2rem 1rem;
+}
+.container {
+ max-width: 800px;
+ margin: 0 auto;
+}
+h1 {
+ border-bottom: 1px solid #e1e4e8;
+ padding-bottom: 0.5rem;
+}
+ul {
+ list-style: none;
+ padding: 0;
+}
+li {
+ padding: 0.4rem 0;
+}
+a {
+ color: #0366d6;
+ text-decoration: none;
+}
+a:hover {
+ text-decoration: underline;
+}
+.back {
+ margin-bottom: 1rem;
+}
+</style>
+</head>
+<body>
+<div class="container">
+<div class="back"><a href="../">← back</a></div>
+<h1>{{.Category}}</h1>
+<ul>
+{{range .Repos}}<li><a href="{{.Name}}/">{{.Name}}</a></li>
+{{end}}</ul>
+</div>
+</body>
+</html>
internal/index/templates/root.html
@@ -0,0 +1,46 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>git</title>
+<style>
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
+ color: #24292e;
+ margin: 0;
+ padding: 2rem 1rem;
+}
+.container {
+ max-width: 800px;
+ margin: 0 auto;
+}
+h1 {
+ border-bottom: 1px solid #e1e4e8;
+ padding-bottom: 0.5rem;
+}
+ul {
+ list-style: none;
+ padding: 0;
+}
+li {
+ padding: 0.4rem 0;
+}
+a {
+ color: #0366d6;
+ text-decoration: none;
+}
+a:hover {
+ text-decoration: underline;
+}
+</style>
+</head>
+<body>
+<div class="container">
+<h1>repositories</h1>
+<ul>
+{{range .Categories}}<li><a href="{{.Name}}/">{{.Name}}/</a></li>
+{{end}}</ul>
+</div>
+</body>
+</html>
internal/index/index.go
@@ -0,0 +1,103 @@
+package index
+
+import (
+ "embed"
+ "fmt"
+ "html/template"
+ "os"
+ "path/filepath"
+
+ "forge/internal/repo"
+)
+
+//go:embed templates/root.html templates/category.html
+var templateFS embed.FS
+
+var (
+ rootTmpl = template.Must(template.ParseFS(templateFS, "templates/root.html"))
+ categoryTmpl = template.Must(template.ParseFS(templateFS, "templates/category.html"))
+)
+
+type rootData struct {
+ Categories []categoryEntry
+}
+
+type categoryEntry struct {
+ Name string
+}
+
+type categoryData struct {
+ Category string
+ Repos []repoEntry
+}
+
+type repoEntry struct {
+ Name string
+}
+
+// GenerateAll creates index.html files for the root and each category
+// directory under outputRoot.
+func GenerateAll(outputRoot string, repos []repo.Repo) error {
+ grouped := make(map[string][]repo.Repo)
+ var categoryOrder []string
+ seen := make(map[string]bool)
+
+ for _, r := range repos {
+ if !seen[r.Category] {
+ seen[r.Category] = true
+ categoryOrder = append(categoryOrder, r.Category)
+ }
+ grouped[r.Category] = append(grouped[r.Category], r)
+ }
+
+ // Generate root index.
+ rd := rootData{}
+ for _, cat := range categoryOrder {
+ rd.Categories = append(rd.Categories, categoryEntry{Name: cat})
+ }
+
+ err := writeTemplate(rootTmpl, filepath.Join(outputRoot, "index.html"), rd)
+ if err != nil {
+ return fmt.Errorf("generating root index: %w", err)
+ }
+
+ // Generate per-category indexes.
+ for _, cat := range categoryOrder {
+ cd := categoryData{Category: cat}
+ for _, r := range grouped[cat] {
+ cd.Repos = append(cd.Repos, repoEntry{Name: r.Name})
+ }
+
+ catDir := filepath.Join(outputRoot, cat)
+ err := os.MkdirAll(catDir, 0o755)
+ if err != nil {
+ return fmt.Errorf("creating category dir (category=%s): %w", cat, err)
+ }
+
+ err = writeTemplate(categoryTmpl, filepath.Join(catDir, "index.html"), cd)
+ if err != nil {
+ return fmt.Errorf("generating category index (category=%s): %w", cat, err)
+ }
+ }
+
+ return nil
+}
+
+func writeTemplate(tmpl *template.Template, path string, data any) error {
+ err := os.MkdirAll(filepath.Dir(path), 0o755)
+ if err != nil {
+ return fmt.Errorf("creating output dir (path=%s): %w", path, err)
+ }
+
+ f, err := os.Create(path)
+ if err != nil {
+ return fmt.Errorf("creating file (path=%s): %w", path, err)
+ }
+ defer f.Close()
+
+ err = tmpl.Execute(f, data)
+ if err != nil {
+ return fmt.Errorf("executing template (path=%s): %w", path, err)
+ }
+ return nil
+}
internal/repo/discover.go
@@ -0,0 +1,74 @@
+package repo
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+)
+
+// Repo represents a bare git repository discovered under the root.
+type Repo struct {
+ FullPath string
+ RelPath string
+ Name string
+ Category string
+}
+
+// IsBare reports whether path looks like a bare git repo by checking
+// for a HEAD file.
+func IsBare(path string) (bool, error) {
+ info, err := os.Stat(filepath.Join(path, "HEAD"))
+ if os.IsNotExist(err) {
+ return false, nil
+ }
+ if err != nil {
+ return false, fmt.Errorf("checking HEAD in %s: %w", path, err)
+ }
+ return !info.IsDir(), nil
+}
+
+// Discover walks a two-level directory structure (category/repo) under root
+// and returns all bare git repos found, sorted by RelPath.
+func Discover(root string) ([]Repo, error) {
+ categories, err := os.ReadDir(root)
+ if err != nil {
+ return nil, fmt.Errorf("reading root dir (root=%s): %w", root, err)
+ }
+
+ var repos []Repo
+ for _, cat := range categories {
+ if !cat.IsDir() {
+ continue
+ }
+ catPath := filepath.Join(root, cat.Name())
+ entries, err := os.ReadDir(catPath)
+ if err != nil {
+ return nil, fmt.Errorf("reading category dir (category=%s): %w", cat.Name(), err)
+ }
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+ repoPath := filepath.Join(catPath, entry.Name())
+ bare, err := IsBare(repoPath)
+ if err != nil {
+ return nil, err
+ }
+ if !bare {
+ continue
+ }
+ repos = append(repos, Repo{
+ FullPath: repoPath,
+ RelPath: filepath.Join(cat.Name(), entry.Name()),
+ Name: entry.Name(),
+ Category: cat.Name(),
+ })
+ }
+ }
+
+ sort.Slice(repos, func(i, j int) bool {
+ return repos[i].RelPath < repos[j].RelPath
+ })
+ return repos, nil
+}
forge
Binary file
go.mod
@@ -0,0 +1,3 @@
+module forge
+
+go 1.24
main.go
@@ -0,0 +1,21 @@
+package main
+
+import (
+ "errors"
+ "flag"
+ "log/slog"
+ "os"
+
+ "forge/internal/cli"
+)
+
+func main() {
+ err := cli.Run(os.Args[1:])
+ if err != nil {
+ if errors.Is(err, flag.ErrHelp) {
+ return
+ }
+ slog.Error(err.Error())
+ os.Exit(1)
+ }
+}
README.md
@@ -0,0 +1,9 @@
+# `forge`
+
+The `forge` cli tool is a management tool for a self hosted git server.
+
+Features:
+ - `forge static`: run `gitmal` on a `forge` repo or `--all` and generate an index.html for each folder
+ - `forge new`: create new bare git repos by path
+ - `forge backup`: create a full forge backup tarball
+
STYLE.md
@@ -0,0 +1,68 @@
+Go Style
+========
+
+This document captures local Go conventions. It is *normative* and *minimal*.
+For everything not covered here, follow `uber-go/guide`. :contentReference[oaicite:1]{index=1}
+
+Errors
+------
+
+### Err Checks
+
+- Do not write `if err := f(); err != nil { ... }`.
+- Separate call and check into two statements.
+
+### Wrapping
+
+- Wrap errors only when adding *new actionable context*.
+- Do not wrap with repetitive or duplicate information.
+- Use `%w` for wrapping.
+- Do not restate underlying identifiers the callee already logs.
+
+### Format
+
+- Wrapping message format:
+ `doing X (id=..., name=...): %w`
+- Keep lowercase, no punctuation, no “failed to”.
+
+### Inspection
+
+- Use `errors.Is` and `errors.As` for inspection in our own code.
+- Define sentinel errors for inspectable cases: `var ErrFoo = errors.New(...)`.
+
+Logging
+-------
+
+- Use only `log/slog`.
+- Do not log an error then return it unchanged.
+- Use structured logs (attributes, not formatted strings).
+- Libraries may accept loggers via options.
+
+### Formatting
+
+When a log call has multiple attributes, place each on its own line:
+
+```go
+// Good
+slog.Info("recv",
+ slog.String("type", "SecurityTypes"),
+ slog.Int("count", int(b[0])),
+ slog.String("raw", fmt.Sprintf("%x", b)))
+
+// Bad - line too long
+slog.Info("recv", slog.String("type", "SecurityTypes"), slog.Int("count", int(b[0])), slog.String("raw", fmt.Sprintf("%x", b)))
+```
+
+Single-attribute calls may stay on one line if short:
+
+```go
+slog.Info("bell received")
+slog.Warn("watcher error", slog.String("error", err.Error()))
+```
+
+HTTP
+----
+
+- Do not use HTTP frameworks.
+- Use `net/http` directly.
+