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