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