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`))