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