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