hook.go

  1// Package hook provides git hook commands for soft-serve.
  2package hook
  3
  4import (
  5	"bufio"
  6	"bytes"
  7	"context"
  8	"errors"
  9	"fmt"
 10	"io"
 11	"os"
 12	"os/exec"
 13	"path/filepath"
 14	"strings"
 15
 16	"github.com/charmbracelet/log/v2"
 17	"github.com/charmbracelet/soft-serve/cmd"
 18	"github.com/charmbracelet/soft-serve/pkg/backend"
 19	"github.com/charmbracelet/soft-serve/pkg/config"
 20	"github.com/charmbracelet/soft-serve/pkg/hooks"
 21	"github.com/spf13/cobra"
 22)
 23
 24var (
 25	// ErrInternalServerError indicates that an internal server error occurred.
 26	ErrInternalServerError = errors.New("internal server error")
 27
 28	// Deprecated: this flag is ignored.
 29	configPath string
 30
 31	// Command is the hook command.
 32	Command = &cobra.Command{
 33		Use:    "hook",
 34		Short:  "Run git server hooks",
 35		Long:   "Handles Soft Serve git server hooks.",
 36		Hidden: true,
 37		PersistentPreRunE: func(c *cobra.Command, args []string) error {
 38			logger := log.FromContext(c.Context())
 39			if err := cmd.InitBackendContext(c, args); err != nil {
 40				logger.Error("failed to initialize backend context", "err", err)
 41				return ErrInternalServerError
 42			}
 43
 44			return nil
 45		},
 46		PersistentPostRunE: func(c *cobra.Command, args []string) error {
 47			logger := log.FromContext(c.Context())
 48			if err := cmd.CloseDBContext(c, args); err != nil {
 49				logger.Error("failed to close backend", "err", err)
 50				return ErrInternalServerError
 51			}
 52
 53			return nil
 54		},
 55	}
 56
 57	// Git hooks read the config from the environment, based on
 58	// $SOFT_SERVE_DATA_PATH. We already parse the config when the binary
 59	// starts, so we don't need to do it again.
 60	// The --config flag is now deprecated.
 61	hooksRunE = func(cmd *cobra.Command, args []string) error {
 62		ctx := cmd.Context()
 63		hks := backend.FromContext(ctx)
 64		cfg := config.FromContext(ctx)
 65
 66		// This is set in the server before invoking git-receive-pack/git-upload-pack
 67		repoName := os.Getenv("SOFT_SERVE_REPO_NAME")
 68
 69		logger := log.FromContext(ctx).With("repo", repoName)
 70
 71		stdin := cmd.InOrStdin()
 72		stdout := cmd.OutOrStdout()
 73		stderr := cmd.ErrOrStderr()
 74
 75		cmdName := cmd.Name()
 76		customHookPath := filepath.Join(cfg.DataPath, "hooks", cmdName)
 77
 78		var buf bytes.Buffer
 79		opts := make([]hooks.HookArg, 0)
 80
 81		switch cmdName {
 82		case hooks.PreReceiveHook, hooks.PostReceiveHook:
 83			scanner := bufio.NewScanner(stdin)
 84			for scanner.Scan() {
 85				buf.Write(scanner.Bytes())
 86				buf.WriteByte('\n')
 87				fields := strings.Fields(scanner.Text())
 88				if len(fields) != 3 {
 89					logger.Error(fmt.Sprintf("invalid %s hook input", cmdName), "input", scanner.Text())
 90					continue
 91				}
 92				opts = append(opts, hooks.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(ctx, stdout, stderr, repoName, opts)
102			case hooks.PostReceiveHook:
103				hks.PostReceive(ctx, stdout, stderr, repoName, opts)
104			}
105		case hooks.UpdateHook:
106			if len(args) != 3 {
107				logger.Error("invalid update hook input", "input", args)
108				break
109			}
110
111			hks.Update(ctx, stdout, stderr, repoName, hooks.HookArg{
112				RefName: args[0],
113				OldSha:  args[1],
114				NewSha:  args[2],
115			})
116		case hooks.PostUpdateHook:
117			hks.PostUpdate(ctx, stdout, stderr, repoName, args...)
118		}
119
120		// Custom hooks
121		if stat, err := os.Stat(customHookPath); err == nil && !stat.IsDir() && stat.Mode()&0o111 != 0 {
122			// If the custom hook is executable, run it
123			if err := runCommand(ctx, &buf, stdout, stderr, customHookPath, args...); err != nil {
124				logger.Error("failed to run custom hook", "err", err)
125			}
126		}
127
128		return nil
129	}
130
131	preReceiveCmd = &cobra.Command{
132		Use:   "pre-receive",
133		Short: "Run git pre-receive hook",
134		RunE:  hooksRunE,
135	}
136
137	updateCmd = &cobra.Command{
138		Use:   "update",
139		Short: "Run git update hook",
140		Args:  cobra.ExactArgs(3),
141		RunE:  hooksRunE,
142	}
143
144	postReceiveCmd = &cobra.Command{
145		Use:   "post-receive",
146		Short: "Run git post-receive hook",
147		RunE:  hooksRunE,
148	}
149
150	postUpdateCmd = &cobra.Command{
151		Use:   "post-update",
152		Short: "Run git post-update hook",
153		RunE:  hooksRunE,
154	}
155)
156
157func init() {
158	Command.PersistentFlags().StringVar(&configPath, "config", "", "path to config file (deprecated)")
159	Command.AddCommand(
160		preReceiveCmd,
161		updateCmd,
162		postReceiveCmd,
163		postUpdateCmd,
164	)
165}
166
167func runCommand(ctx context.Context, in io.Reader, out io.Writer, err io.Writer, name string, args ...string) error {
168	cmd := exec.CommandContext(ctx, name, args...)
169	cmd.Stdin = in
170	cmd.Stdout = out
171	cmd.Stderr = err
172	return cmd.Run() //nolint:wrapcheck
173}