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/pkg/config"
 13	"github.com/charmbracelet/soft-serve/pkg/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	// Convert to forward slashes for Windows.
 50	ex = filepath.ToSlash(ex)
 51
 52	for _, hook := range []string{
 53		PreReceiveHook,
 54		UpdateHook,
 55		PostReceiveHook,
 56		PostUpdateHook,
 57	} {
 58		var data bytes.Buffer
 59		var args string
 60
 61		// Hooks script/directory path
 62		hp := filepath.Join(hooksPath, hook)
 63
 64		// Write the hooks primary script
 65		if err := os.WriteFile(hp, []byte(hookTemplate), os.ModePerm); err != nil {
 66			return err
 67		}
 68
 69		// Create ${hook}.d directory.
 70		hp += ".d"
 71		if err := os.MkdirAll(hp, os.ModePerm); err != nil {
 72			return err
 73		}
 74
 75		switch hook {
 76		case UpdateHook:
 77			args = "$1 $2 $3"
 78		case PostUpdateHook:
 79			args = "$@"
 80		}
 81
 82		if err := hooksTmpl.Execute(&data, struct {
 83			Executable string
 84			Hook       string
 85			Args       string
 86		}{
 87			Executable: ex,
 88			Hook:       hook,
 89			Args:       args,
 90		}); err != nil {
 91			log.WithPrefix("hooks").Error("failed to execute hook template", "err", err)
 92			continue
 93		}
 94
 95		// Write the soft-serve hook inside ${hook}.d directory.
 96		hp = filepath.Join(hp, "soft-serve")
 97		err = os.WriteFile(hp, data.Bytes(), os.ModePerm) //nolint:gosec
 98		if err != nil {
 99			log.WithPrefix("hooks").Error("failed to write hook", "err", err)
100			continue
101		}
102	}
103
104	return nil
105}
106
107const (
108	// hookTemplate allows us to run multiple hooks from a directory. It should
109	// support every type of git hook, as it proxies both stdin and arguments.
110	hookTemplate = `#!/usr/bin/env bash
111# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
112data=$(cat)
113exitcodes=""
114hookname=$(basename $0)
115GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
116for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
117  # Avoid running non-executable hooks
118  test -x "${hook}" && test -f "${hook}" || continue
119
120  # Run the actual hook
121  echo "${data}" | "${hook}" "$@"
122
123  # Store the exit code for later use
124  exitcodes="${exitcodes} $?"
125done
126
127# Exit on the first non-zero exit code.
128for i in ${exitcodes}; do
129  [ ${i} -eq 0 ] || exit ${i}
130done
131`
132)
133
134// hooksTmpl is the soft-serve hook that will be run by the git hooks
135// inside the hooks directory.
136var hooksTmpl = template.Must(template.New("hooks").Parse(`#!/usr/bin/env bash
137# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
138if [ -z "$SOFT_SERVE_REPO_NAME" ]; then
139	echo "Warning: SOFT_SERVE_REPO_NAME not defined. Skipping hooks."
140	exit 0
141fi
142{{ .Executable }} hook {{ .Hook }} {{ .Args }}
143`))