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