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