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