package hooks
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/go-git/go-git/v5"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
"sourcecraft.dev/bigbes/sr-ht-spec/gitx"
)
const (
// hooksDirMode and the hook symlinks themselves are owned by the service
// user; every repository under the repos root is.
hooksDirMode = 0o755
// installSuffix names the temporary link Install renames into place, so a
// refresh is atomic and a push arriving mid-upgrade sees either the old
// hook or the new one, never a missing one.
installSuffix = ".specsrht-new"
)
// InstallOptions configures Install.
type InstallOptions struct {
// Binary is the absolute path of the specsrht binary every hook symlinks
// to. The daemon passes os.Executable().
Binary string
}
// InstallSpace installs the receive hooks into a space's repository.
//
// The path comes from gitx.DiskPath rather than from a second copy of the
// layout rule, for the same reason the server re-derives it there: one source
// of truth for where a space lives.
func InstallSpace(reposRoot string, ref core.SpaceRef, opts InstallOptions) error {
if err := core.ValidateOwner(ref.Owner); err != nil {
return fmt.Errorf("hooks: install into %s: %w", ref, err)
}
if err := core.ValidateSpaceName(ref.Name); err != nil {
return fmt.Errorf("hooks: install into %s: %w", ref, err)
}
return Install(gitx.DiskPath(reposRoot, ref), opts)
}
// Install writes — or refreshes — the receive hooks in a bare repository.
//
// Each hook is a symlink to the specsrht binary, which dispatches on the name
// git invoked it as. Nothing is generated, so there is no stale script to find
// after an upgrade: reinstalling is idempotent, and the daemon does it for
// every space at startup.
//
// It also sets receive.advertisePushOptions. Without it git refuses
// `--push-option=...` client-side with "the receiving end does not support
// push options", and the documented escape hatch would not exist.
//
// Any file already occupying a hook's name is replaced. A repository under the
// service's repos root has no hooks but ours, and silently leaving somebody
// else's `update` in place would mean pushes that are never validated — the
// exact failure this whole path exists to prevent.
func Install(repoDir string, opts InstallOptions) error {
if opts.Binary == "" {
return errors.New("hooks: no binary to install hooks from")
}
if !filepath.IsAbs(opts.Binary) {
return fmt.Errorf("hooks: %q is not an absolute path; git runs a hook with the "+
"repository as its working directory, so a relative target would not resolve", opts.Binary)
}
info, err := os.Stat(opts.Binary)
if err != nil {
return fmt.Errorf("hooks: %s: %w", opts.Binary, err)
}
if info.IsDir() || info.Mode().Perm()&0o111 == 0 {
return fmt.Errorf("hooks: %s is not an executable file", opts.Binary)
}
if err := checkBareRepo(repoDir); err != nil {
return err
}
dir := filepath.Join(repoDir, "hooks")
if err := os.MkdirAll(dir, hooksDirMode); err != nil {
return fmt.Errorf("hooks: create %s: %w", dir, err)
}
for _, mode := range Modes() {
if err := linkHook(dir, string(mode), opts.Binary); err != nil {
return err
}
}
return advertisePushOptions(repoDir)
}
// checkBareRepo refuses to scatter symlinks into a directory that is not one of
// our bare repositories. A wrong path here would install hooks nothing runs and
// report success.
func checkBareRepo(repoDir string) error {
if repoDir == "" {
return errors.New("hooks: no repository directory")
}
if !filepath.IsAbs(repoDir) {
return fmt.Errorf("hooks: repository path %q is not absolute", repoDir)
}
for _, name := range []string{"HEAD", "objects", "refs"} {
if _, err := os.Stat(filepath.Join(repoDir, name)); err != nil {
return fmt.Errorf("hooks: %s does not look like a bare repository (%s): %w",
repoDir, name, err)
}
}
return nil
}
// linkHook points one hook at the binary, atomically.
func linkHook(dir, name, binary string) error {
final := filepath.Join(dir, name)
tmp := final + installSuffix
if err := os.Remove(tmp); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("hooks: clear %s: %w", tmp, err)
}
if err := os.Symlink(binary, tmp); err != nil {
return fmt.Errorf("hooks: link %s -> %s: %w", tmp, binary, err)
}
if err := os.Rename(tmp, final); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("hooks: install %s: %w", final, err)
}
return nil
}
// advertisePushOptions turns on receive.advertisePushOptions.
//
// go-git writes the config file rather than this package shelling out to `git
// config`: library code must not depend on a git binary being on the daemon's
// PATH, and this runs on the daemon's side of the socket.
func advertisePushOptions(repoDir string) error {
repo, err := git.PlainOpen(repoDir)
if err != nil {
return fmt.Errorf("hooks: open %s: %w", repoDir, err)
}
cfg, err := repo.Config()
if err != nil {
return fmt.Errorf("hooks: read the config of %s: %w", repoDir, err)
}
section := cfg.Raw.Section("receive")
if section.Option("advertisePushOptions") == "true" {
return nil
}
section.SetOption("advertisePushOptions", "true")
if err := repo.SetConfig(cfg); err != nil {
return fmt.Errorf("hooks: enable push options on %s: %w", repoDir, err)
}
return nil
}