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