penguin/flatgit

flatgit showcase

cmd/flatgit/main.go

raw ยท 5775 bytes

package main

import (
	"context"
	"errors"
	"flag"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"strings"
	"syscall"

	"github.com/tgckpg/flatgit/internal/buildinfo"
	"github.com/tgckpg/flatgit/internal/config"
	"github.com/tgckpg/flatgit/internal/gitcmd"
	"github.com/tgckpg/flatgit/internal/jobqueue"
	"github.com/tgckpg/flatgit/internal/render"
	"github.com/tgckpg/flatgit/internal/server"
	"github.com/tgckpg/flatgit/internal/webhook"
)

func main() {
	var level slog.LevelVar
	level.Set(slog.LevelInfo)

	log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
		Level: &level,
	}))

	if err := run(log, &level, os.Args); err != nil {
		log.Error("flatgit failed", "err", err)
		os.Exit(1)
	}
}

func applyVerbosity(level *slog.LevelVar, v int) {
	if v >= 1 {
		level.Set(slog.LevelDebug)
		return
	}

	level.Set(slog.LevelInfo)
}

func addCommonFlags(fs *flag.FlagSet) *int {
	return fs.Int("v", 0, "verbosity level")
}

func run(log *slog.Logger, level *slog.LevelVar, args []string) error {
	if len(args) < 2 {
		usage(args[0])
		return errors.New("missing command")
	}

	switch args[1] {
	case "render":
		return renderCmd(log, args[2:])
	case "serve":
		return serveCmd(log, args[2:])
	case "daemon":
		return daemonCmd(log, level, args[2:])
	case "version", "-v", "--version":
		fmt.Printf("%s (%s)\n", buildinfo.Version, buildinfo.Timestamp)
		return nil
	case "help", "-h", "--help":
		usage(args[0])
		return nil
	default:
		usage(args[0])
		return fmt.Errorf("unknown command %q", args[1])
	}
}

func renderCmd(log *slog.Logger, args []string) error {
	fs := flag.NewFlagSet("render", flag.ExitOnError)
	cfgPath := fs.String("c", "flatgit.json", "config file")
	repoName := fs.String("repo", "", "repo name to render; empty renders all repos")
	fetch := fs.Bool("fetch", true, "clone/fetch before rendering")
	if err := fs.Parse(args); err != nil {
		return err
	}
	cfg, err := config.Load(*cfgPath)
	if err != nil {
		return err
	}
	ctx := context.Background()
	return renderConfigured(ctx, log, cfg, *repoName, *fetch)
}

func serveCmd(log *slog.Logger, args []string) error {
	fs := flag.NewFlagSet("serve", flag.ExitOnError)
	cfgPath := fs.String("c", "", "config file; optional if -root is set")
	addr := fs.String("addr", ":8080", "listen address")
	root := fs.String("root", "", "static root")
	if err := fs.Parse(args); err != nil {
		return err
	}
	if *cfgPath != "" {
		cfg, err := config.Load(*cfgPath)
		if err != nil {
			return err
		}
		if *root == "" {
			*root = cfg.WebRoot()
		}
		if *addr == ":8080" && cfg.Addr != "" {
			*addr = cfg.Addr
		}
	}
	if *root == "" {
		return errors.New("serve needs -root or -c")
	}
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	return server.ListenAndServe(ctx, server.Options{Addr: *addr, Root: *root, Logger: log})
}

func daemonCmd(log *slog.Logger, level *slog.LevelVar, args []string) error {
	fs := flag.NewFlagSet("daemon", flag.ExitOnError)
	cfgPath := fs.String("c", "flatgit.json", "config file")
	v := addCommonFlags(fs)
	renderOnStart := fs.Bool("render-on-start", true, "render all repos on startup")
	if err := fs.Parse(args); err != nil {
		return err
	}
	applyVerbosity(level, *v)
	cfg, err := config.Load(*cfgPath)
	if err != nil {
		return err
	}

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	git := gitcmd.New(cfg.Git.Command, log)
	r := render.New(git, cfg.PublicURL, cfg.Render.MaxCommits)
	q := jobqueue.New(1024, log)
	q.Start(ctx, cfg.Render.Workers, func(ctx context.Context, name string) error {
		repo, ok := cfg.RepoByName(name)
		if !ok {
			return fmt.Errorf("repo not found: %s", name)
		}
		if err := git.EnsureMirror(ctx, *repo, cfg.CloneTimeout(), cfg.FetchTimeout()); err != nil {
			return err
		}
		return r.RenderRepo(ctx, *repo)
	})

	if *renderOnStart {
		for _, repo := range cfg.Repos {
			q.Enqueue(repo.Name)
		}
	}

	wh := &webhook.Handler{Config: cfg, Queue: q, Logger: log}
	return server.ListenAndServe(ctx, server.Options{
		Addr:   cfg.Addr,
		Root:   cfg.WebRoot(),
		Logger: log,
		WebhookMux: func(mux *http.ServeMux) {
			wh.Register(mux)
		},
	})
}

func repoMatches(repo config.Repo, name string) bool {
	name = strings.TrimSpace(name)
	name = strings.Trim(name, "/")

	if name == "" {
		return true
	}

	fullName := strings.Trim(repo.FullName(), "/")
	repoBase := strings.Trim(repo.RepoBase(), "/")

	return strings.EqualFold(repo.Name, name) ||
		strings.EqualFold(fullName, name) ||
		strings.EqualFold(repoBase, name)
}

func renderConfigured(ctx context.Context, log *slog.Logger, cfg *config.Config, repoName string, fetch bool) error {
	git := gitcmd.New(cfg.Git.Command, log)
	r := render.New(git, cfg.PublicURL, cfg.Render.MaxCommits)

	matched := false

	for i := range cfg.Repos {
		repo := cfg.Repos[i]

		if repoName != "" && !repoMatches(repo, repoName) {
			continue
		}

		matched = true

		log.Info("rendering repo", "repo", repo.FullName(), "base", repo.RepoBase())

		if fetch {
			if err := git.EnsureMirror(ctx, repo, cfg.CloneTimeout(), cfg.FetchTimeout()); err != nil {
				return err
			}
		}

		if err := r.RenderRepo(ctx, repo); err != nil {
			return err
		}

		log.Info("rendered repo", "repo", repo.FullName(), "base", repo.RepoBase(), "output", repo.OutputDir)
	}

	if repoName != "" && !matched {
		return fmt.Errorf("repo not configured: %s", repoName)
	}

	return nil
}

func usage(name string) {
	fmt.Fprintf(os.Stderr, `flatgit - static Git renderer

Usage:
  %[1]s render [-c flatgit.json] [-repo name] [-fetch=true]
  %[1]s serve  [-c flatgit.json] [-root /var/lib/flatgit/www] [-addr :8080]
  %[1]s daemon [-c flatgit.json] [-render-on-start=true]
  %[1]s version

`, name)
}