Commit 6a61076

bryfry <bryon@fryer.io>
2026-01-27 21:08:10
init
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="../">&larr; 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.
+