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)