hook.go

  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`