1package hooks
  2
  3import (
  4	"bytes"
  5	"context"
  6	"flag"
  7	"os"
  8	"path/filepath"
  9	"text/template"
 10
 11	"github.com/charmbracelet/log"
 12	"github.com/charmbracelet/soft-serve/server/config"
 13	"github.com/charmbracelet/soft-serve/server/utils"
 14)
 15
 16// The names of git server-side hooks.
 17const (
 18	PreReceiveHook  = "pre-receive"
 19	UpdateHook      = "update"
 20	PostReceiveHook = "post-receive"
 21	PostUpdateHook  = "post-update"
 22)
 23
 24// GenerateHooks generates git server-side hooks for a repository. Currently, it supports the following hooks:
 25// - pre-receive
 26// - update
 27// - post-receive
 28// - post-update
 29//
 30// This function should be called by the backend when a repository is created.
 31// TODO: support context.
 32func GenerateHooks(_ context.Context, cfg *config.Config, repo string) error {
 33	// TODO: support git hook tests.
 34	if flag.Lookup("test.v") != nil {
 35		log.WithPrefix("backend.hooks").Warn("refusing to set up hooks when in test")
 36		return nil
 37	}
 38	repo = utils.SanitizeRepo(repo) + ".git"
 39	hooksPath := filepath.Join(cfg.DataPath, "repos", repo, "hooks")
 40	if err := os.MkdirAll(hooksPath, os.ModePerm); err != nil {
 41		return err
 42	}
 43
 44	ex, err := os.Executable()
 45	if err != nil {
 46		return err
 47	}
 48
 49	for _, hook := range []string{
 50		PreReceiveHook,
 51		UpdateHook,
 52		PostReceiveHook,
 53		PostUpdateHook,
 54	} {
 55		var data bytes.Buffer
 56		var args string
 57
 58		// Hooks script/directory path
 59		hp := filepath.Join(hooksPath, hook)
 60
 61		// Write the hooks primary script
 62		if err := os.WriteFile(hp, []byte(hookTemplate), os.ModePerm); err != nil {
 63			return err
 64		}
 65
 66		// Create ${hook}.d directory.
 67		hp += ".d"
 68		if err := os.MkdirAll(hp, os.ModePerm); err != nil {
 69			return err
 70		}
 71
 72		switch hook {
 73		case UpdateHook:
 74			args = "$1 $2 $3"
 75		case PostUpdateHook:
 76			args = "$@"
 77		}
 78
 79		if err := hooksTmpl.Execute(&data, struct {
 80			Executable string
 81			Hook       string
 82			Args       string
 83		}{
 84			Executable: ex,
 85			Hook:       hook,
 86			Args:       args,
 87		}); err != nil {
 88			log.WithPrefix("hooks").Error("failed to execute hook template", "err", err)
 89			continue
 90		}
 91
 92		// Write the soft-serve hook inside ${hook}.d directory.
 93		hp = filepath.Join(hp, "soft-serve")
 94		err = os.WriteFile(hp, data.Bytes(), os.ModePerm) //nolint:gosec
 95		if err != nil {
 96			log.WithPrefix("hooks").Error("failed to write hook", "err", err)
 97			continue
 98		}
 99	}
100
101	return nil
102}
103
104const (
105	// hookTemplate allows us to run multiple hooks from a directory. It should
106	// support every type of git hook, as it proxies both stdin and arguments.
107	hookTemplate = `#!/usr/bin/env bash
108# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
109data=$(cat)
110exitcodes=""
111hookname=$(basename $0)
112GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
113for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
114  # Avoid running non-executable hooks
115  test -x "${hook}" && test -f "${hook}" || continue
116
117  # Run the actual hook
118  echo "${data}" | "${hook}" "$@"
119
120  # Store the exit code for later use
121  exitcodes="${exitcodes} $?"
122done
123
124# Exit on the first non-zero exit code.
125for i in ${exitcodes}; do
126  [ ${i} -eq 0 ] || exit ${i}
127done
128`
129)
130
131// hooksTmpl is the soft-serve hook that will be run by the git hooks
132// inside the hooks directory.
133var hooksTmpl = template.Must(template.New("hooks").Parse(`#!/usr/bin/env bash
134# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
135if [ -z "$SOFT_SERVE_REPO_NAME" ]; then
136	echo "Warning: SOFT_SERVE_REPO_NAME not defined. Skipping hooks."
137	exit 0
138fi
139{{ .Executable }} hook {{ .Hook }} {{ .Args }}
140`))