Download
172 lines 5.1 KiBGoModified 2026-10-27 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
// Command repo-render turns bare git repositories into a static website.
//
//	repo-render --config FILE <command> [args]
//
// $REPO_RENDER_CONFIG names the config when --config does not; there is
// no default.
//
// Commands:
//
//	kick                   drain the job queue (what the post-receive hook runs)
//	render <repo>...       render repositories at their branch tip (--full to redo everything)
//	render --all           render every repository under git_dir, removing any other
//	index                  rebuild the repository index and static assets only
//	enqueue R REF OLD NEW  append a job (what the post-receive hook runs)
//	check                  validate config, lexers and templates; exit non-zero on problems
package main

import (
	"errors"
	"fmt"
	"io"
	"log"
	"log/syslog"
	"os"

	"github.com/alecthomas/kong"

	"code.bruce-hill.com/repo-renderer/internal/config"
	"code.bruce-hill.com/repo-renderer/internal/gitx"
	"code.bruce-hill.com/repo-renderer/internal/queue"
	"code.bruce-hill.com/repo-renderer/internal/render"
)

// cli is the command line. Each command's Run takes what it needs: the
// *config.Config alone, or the *render.Site, which also opens the log.
// enqueue runs on every push, so it must be fast, and takes only the
// config.
type cli struct {
	Config string `default:"${default_config}" placeholder:"FILE" help:"Config file ($$REPO_RENDER_CONFIG sets it)."`

	Kick    kickCmd    `cmd:"" help:"Drain the job queue under the render lock."`
	Render  renderCmd  `cmd:"" help:"Render repositories at their branch tip."`
	Index   indexCmd   `cmd:"" help:"Rebuild the index page and static assets."`
	Enqueue enqueueCmd `cmd:"" help:"Append a job to the queue."`
	Check   checkCmd   `cmd:"" help:"Validate configuration, lexers and templates."`
}

func main() {
	// Not kong's env tag: that takes the variable even when it is empty,
	// and an empty one has always meant none.
	var c cli
	ctx := kong.Parse(&c,
		kong.Name("repo-render"),
		kong.Description("Turn bare git repositories into a static website."),
		kong.Vars{"default_config": os.Getenv("REPO_RENDER_CONFIG")},
		kong.ConfigureHelp(kong.HelpOptions{Compact: true}),
		kong.UsageOnError(),
		// Usage errors exit 2, as they always have; kong's own is 80.
		kong.Exit(func(code int) {
			if code == 80 {
				code = 2
			}
			os.Exit(code)
		}),
	)

	if c.Config == "" {
		// A usage error, which exits 2 like kong's own.
		fmt.Fprintln(os.Stderr, "repo-render: no config: give --config FILE or set REPO_RENDER_CONFIG")
		os.Exit(2)
	}
	cfg, err := config.Load(c.Config)
	if err != nil {
		fatal(err)
	}
	if cfg.Git != "" {
		gitx.Program = cfg.Git
	}
	ctx.Bind(cfg)
	var logFile io.Closer
	if err := ctx.BindSingletonProvider(func() (*render.Site, error) {
		site, lf, err := newSite(cfg)
		logFile = lf
		return site, err
	}); err != nil {
		fatal(err)
	}
	err = ctx.Run()
	if logFile != nil {
		// Nothing is left to report a failed close of the log to.
		_ = logFile.Close()
	}
	if err != nil {
		fatal(err)
	}
}

// newSite loads the site, logging to stderr and to the configured log
// (syslog or a file), if that opens. What is returned to close may be nil.
func newSite(cfg *config.Config) (*render.Site, io.Closer, error) {
	logger := log.New(os.Stderr, "", log.LstdFlags)
	site, err := render.New(cfg, logger)
	if err != nil {
		return nil, nil, err
	}
	var lw io.WriteCloser
	switch cfg.Log {
	case "":
		return site, nil, nil
	case config.LogSyslog:
		lw, err = syslog.New(syslog.LOG_DAEMON|syslog.LOG_INFO, "repo-render")
	default:
		lw, err = os.OpenFile(cfg.Log, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o644)
	}
	if err != nil {
		logger.Printf("logging to stderr only: %v", err)
		return site, nil, nil
	}
	logger.SetOutput(io.MultiWriter(os.Stderr, lw))
	return site, lw, nil
}

func fatal(err error) {
	fmt.Fprintln(os.Stderr, "repo-render:", err)
	os.Exit(1)
}

type kickCmd struct{}

func (kickCmd) Run(site *render.Site) error { return site.Kick() }

type renderCmd struct {
	Full  bool     `help:"Re-render everything, ignoring saved state."`
	All   bool     `help:"Render every repository under git_dir, and remove the output of any other."`
	Repos []string `arg:"" optional:"" name:"repo" help:"Repositories to render."`
}

func (c *renderCmd) Validate() error {
	if c.All == (len(c.Repos) > 0) {
		return errors.New("give either --all or repositories")
	}
	return nil
}

func (c *renderCmd) Run(site *render.Site) error {
	if c.All {
		return site.RenderAll(c.Full)
	}
	return site.Render(c.Repos, c.Full)
}

type indexCmd struct{}

func (indexCmd) Run(site *render.Site) error { return site.RenderIndex() }

type enqueueCmd struct {
	Repo string `arg:"" help:"Repository name."`
	Ref  string `arg:"" help:"Ref that was pushed."`
	Old  string `arg:"" help:"Object id before the push."`
	New  string `arg:"" help:"Object id after the push."`
}

func (c *enqueueCmd) Run(cfg *config.Config) error {
	if err := render.Enqueue(cfg, queue.Job{Repo: c.Repo, Ref: c.Ref, Old: c.Old, New: c.New}); err != nil {
		return fmt.Errorf("enqueue: %w", err)
	}
	return nil
}

type checkCmd struct{}

func (checkCmd) Run(site *render.Site, root *cli) error { return site.Check(os.Stdout, root.Config) }