1package main
2
3import (
4 "bufio"
5 "bytes"
6 "context"
7 "fmt"
8 "io"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "strings"
13
14 "github.com/charmbracelet/log"
15 "github.com/charmbracelet/soft-serve/server/backend"
16 "github.com/charmbracelet/soft-serve/server/backend/sqlite"
17 "github.com/charmbracelet/soft-serve/server/config"
18 "github.com/charmbracelet/soft-serve/server/hooks"
19 "github.com/spf13/cobra"
20)
21
22var (
23 configPath string
24
25 logFileCtxKey = struct{}{}
26
27 hookCmd = &cobra.Command{
28 Use: "hook",
29 Short: "Run git server hooks",
30 Long: "Handles Soft Serve git server hooks.",
31 Hidden: true,
32 PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
33 ctx := cmd.Context()
34 cfg, err := config.NewConfig(configPath)
35 if err != nil {
36 return fmt.Errorf("could not parse config: %w", err)
37 }
38
39 ctx = config.WithContext(ctx, cfg)
40
41 logPath := filepath.Join(cfg.DataPath, "log", "hooks.log")
42 f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
43 if err != nil {
44 return fmt.Errorf("opening file: %w", err)
45 }
46
47 ctx = context.WithValue(ctx, logFileCtxKey, f)
48 logger := log.FromContext(ctx)
49 logger.SetOutput(f)
50 ctx = log.WithContext(ctx, logger)
51 cmd.SetContext(ctx)
52
53 // Set up the backend
54 // TODO: support other backends
55 sb, err := sqlite.NewSqliteBackend(ctx)
56 if err != nil {
57 return fmt.Errorf("failed to create sqlite backend: %w", err)
58 }
59
60 cfg = cfg.WithBackend(sb)
61
62 return nil
63 },
64 PersistentPostRunE: func(cmd *cobra.Command, _ []string) error {
65 f := cmd.Context().Value(logFileCtxKey).(*os.File)
66 return f.Close()
67 },
68 }
69
70 hooksRunE = func(cmd *cobra.Command, args []string) error {
71 ctx := cmd.Context()
72 cfg := config.FromContext(ctx)
73 hks := cfg.Backend.(backend.Hooks)
74
75 // This is set in the server before invoking git-receive-pack/git-upload-pack
76 repoName := os.Getenv("SOFT_SERVE_REPO_NAME")
77
78 stdin := cmd.InOrStdin()
79 stdout := cmd.OutOrStdout()
80 stderr := cmd.ErrOrStderr()
81
82 cmdName := cmd.Name()
83 customHookPath := filepath.Join(filepath.Dir(configPath), "hooks", cmdName)
84
85 var buf bytes.Buffer
86 opts := make([]backend.HookArg, 0)
87
88 switch cmdName {
89 case hooks.PreReceiveHook, hooks.PostReceiveHook:
90 scanner := bufio.NewScanner(stdin)
91 for scanner.Scan() {
92 buf.Write(scanner.Bytes())
93 fields := strings.Fields(scanner.Text())
94 if len(fields) != 3 {
95 return fmt.Errorf("invalid hook input: %s", scanner.Text())
96 }
97 opts = append(opts, backend.HookArg{
98 OldSha: fields[0],
99 NewSha: fields[1],
100 RefName: fields[2],
101 })
102 }
103
104 switch cmdName {
105 case hooks.PreReceiveHook:
106 hks.PreReceive(stdout, stderr, repoName, opts)
107 case hooks.PostReceiveHook:
108 hks.PostReceive(stdout, stderr, repoName, opts)
109 }
110 case hooks.UpdateHook:
111 if len(args) != 3 {
112 return fmt.Errorf("invalid update hook input: %s", args)
113 }
114
115 hks.Update(stdout, stderr, repoName, backend.HookArg{
116 OldSha: args[0],
117 NewSha: args[1],
118 RefName: args[2],
119 })
120 case hooks.PostUpdateHook:
121 hks.PostUpdate(stdout, stderr, repoName, args...)
122 }
123
124 // Custom hooks
125 if stat, err := os.Stat(customHookPath); err == nil && !stat.IsDir() && stat.Mode()&0o111 != 0 {
126 // If the custom hook is executable, run it
127 if err := runCommand(ctx, &buf, stdout, stderr, customHookPath, args...); err != nil {
128 return fmt.Errorf("failed to run custom hook: %w", err)
129 }
130 }
131
132 return nil
133 }
134
135 preReceiveCmd = &cobra.Command{
136 Use: "pre-receive",
137 Short: "Run git pre-receive hook",
138 RunE: hooksRunE,
139 }
140
141 updateCmd = &cobra.Command{
142 Use: "update",
143 Short: "Run git update hook",
144 Args: cobra.ExactArgs(3),
145 RunE: hooksRunE,
146 }
147
148 postReceiveCmd = &cobra.Command{
149 Use: "post-receive",
150 Short: "Run git post-receive hook",
151 RunE: hooksRunE,
152 }
153
154 postUpdateCmd = &cobra.Command{
155 Use: "post-update",
156 Short: "Run git post-update hook",
157 RunE: hooksRunE,
158 }
159)
160
161func init() {
162 hookCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "path to config file")
163 hookCmd.AddCommand(
164 preReceiveCmd,
165 updateCmd,
166 postReceiveCmd,
167 postUpdateCmd,
168 )
169}
170
171func runCommand(ctx context.Context, in io.Reader, out io.Writer, err io.Writer, name string, args ...string) error {
172 cmd := exec.CommandContext(ctx, name, args...)
173 cmd.Stdin = in
174 cmd.Stdout = out
175 cmd.Stderr = err
176 return cmd.Run()
177}
178
179const updateHookExample = `#!/bin/sh
180#
181# An example hook script to echo information about the push
182# and send it to the client.
183#
184# To enable this hook, rename this file to "update" and make it executable.
185
186refname="$1"
187oldrev="$2"
188newrev="$3"
189
190# Safety check
191if [ -z "$GIT_DIR" ]; then
192 echo "Don't run this script from the command line." >&2
193 echo " (if you want, you could supply GIT_DIR then run" >&2
194 echo " $0 <ref> <oldrev> <newrev>)" >&2
195 exit 1
196fi
197
198if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then
199 echo "usage: $0 <ref> <oldrev> <newrev>" >&2
200 exit 1
201fi
202
203# Check types
204# if $newrev is 0000...0000, it's a commit to delete a ref.
205zero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')
206if [ "$newrev" = "$zero" ]; then
207 newrev_type=delete
208else
209 newrev_type=$(git cat-file -t $newrev)
210fi
211
212echo "Hi from Soft Serve update hook!"
213echo
214echo "RefName: $refname"
215echo "Change Type: $newrev_type"
216echo "Old SHA1: $oldrev"
217echo "New SHA1: $newrev"
218
219exit 0
220`