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