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