// heavily inspired by gitea's model package hook import ( "errors" "fmt" "io/fs" "log/slog" "os" "path/filepath" "strings" "github.com/go-git/go-git/v5" ) var ErrNoGitRepo = errors.New("not a git repo") var ErrCreatingHookDir = errors.New("failed to create hooks directory") var ErrCreatingHook = errors.New("failed to create hook") var ErrCreatingDelegate = errors.New("failed to create delegate hook") type config struct { scanPath string internalApi string } type setupOpt func(*config) func WithScanPath(scanPath string) setupOpt { return func(c *config) { c.scanPath = scanPath } } func WithInternalApi(api string) setupOpt { return func(c *config) { c.internalApi = api } } func Config(opts ...setupOpt) config { config := config{} for _, o := range opts { o(&config) } return config } // setup hooks for all users // // directory structure is typically like so: // // did:plc:repo1 // did:plc:repo2 // did:web:repo1 func Setup(config config) error { // iterate over all directories in current directory: repoDirs, err := os.ReadDir(config.scanPath) if errors.Is(err, fs.ErrNotExist) { return os.MkdirAll(config.scanPath, 0755) } if err != nil { return err } for _, repo := range repoDirs { if !repo.IsDir() { continue } did := repo.Name() if !strings.HasPrefix(did, "did:") { continue } userPath := filepath.Join(config.scanPath, did) if _, err := os.Stat(userPath); errors.Is(err, fs.ErrPermission) { slog.Warn("hook setup: skipping inaccessible repo", "path", userPath) continue } if err := SetupRepo(config, userPath); err != nil { if errors.Is(err, ErrNoGitRepo) { slog.Warn("hook setup: skipping non-repo entry", "path", userPath, "err", err) continue } return err } } return nil } // setup hook in /scanpath/did:plc:repo func SetupRepo(config config, path string) error { if _, err := git.PlainOpen(path); err != nil { return fmt.Errorf("%s: %w", path, ErrNoGitRepo) } preReceiveD := filepath.Join(path, "hooks", "post-receive.d") if err := os.MkdirAll(preReceiveD, 0755); err != nil { return fmt.Errorf("%s: %w", preReceiveD, ErrCreatingHookDir) } notify := filepath.Join(preReceiveD, "40-notify.sh") if err := mkHook(config, notify); err != nil { return fmt.Errorf("%s: %w", notify, ErrCreatingHook) } delegate := filepath.Join(path, "hooks", "post-receive") if err := mkDelegate(delegate); err != nil { return fmt.Errorf("%s: %w", delegate, ErrCreatingDelegate) } return nil } func mkHook(config config, hookPath string) error { // use the absolute path to the underlying binary rather than a bare // `knot` lookup. on NixOS, bare `knot` resolves to /run/wrappers/bin/knot // which has restrictive perms (only the git group can exec it), so hooks // running as a virtual UID fail with EACCES. the underlying binary in // /nix/store is world-readable. hooks are regenerated on every deploy // so the store path stays fresh. executablePath, err := os.Executable() if err != nil { return err } hookContent := fmt.Sprintf(`#!/usr/bin/env bash # AUTO GENERATED BY KNOT, DO NOT MODIFY push_options=() for ((i=0; i