Go · 3074 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 // Package hooks owns the bare-repo hook installation contract used by
4 // the push pipeline (S14). pre-receive and post-receive are tiny shell
5 // shims that re-invoke shithubd. They're symlinks so a binary upgrade
6 // doesn't require touching every repo's hooks directory.
7 //
8 // Why shell shims and not direct symlinks to the binary: git invokes
9 // the hook with stdin piped and a particular cwd, and the shim lets us
10 // preserve the env (the SHITHUB_* vars set by S12/S13) while routing
11 // to a stable subcommand interface. The shim is generated once at
12 // install time; we don't depend on its file path matching the binary.
13 package hooks
14
15 import (
16 "fmt"
17 "os"
18 "path/filepath"
19 )
20
21 // SupportedHooks is the canonical list of hooks the push pipeline owns.
22 // Add new hook names here and they'll be installed on every repo init.
23 var SupportedHooks = []string{"pre-receive", "post-receive"}
24
25 // Install (re)installs every supported hook on the bare repo at gitDir.
26 // shithubdPath is the absolute path to the shithubd binary the shim
27 // should invoke. Permissions are forced to 0o755 (owner: rwx; group +
28 // other: rx) — git refuses to run hooks lacking the executable bit.
29 //
30 // Install is idempotent: re-running on a repo that already has up-to-
31 // date hooks is a no-op. If a hook file exists but doesn't match the
32 // expected shim, it's overwritten.
33 func Install(gitDir, shithubdPath string) error {
34 hooksDir := filepath.Join(gitDir, "hooks")
35 if err := os.MkdirAll(hooksDir, 0o755); err != nil {
36 return fmt.Errorf("hooks: mkdir %s: %w", hooksDir, err)
37 }
38 for _, name := range SupportedHooks {
39 path := filepath.Join(hooksDir, name)
40 body := shim(shithubdPath, name)
41 if err := writeAtomic(path, body, 0o755); err != nil {
42 return fmt.Errorf("hooks: write %s: %w", path, err)
43 }
44 }
45 return nil
46 }
47
48 // shim is the body of the wrapper script git executes. It exec's into
49 // shithubd, replacing itself so signals (and the exit code) propagate
50 // cleanly. Stdin is the pkt-line stream git passes the hook; we forward
51 // it untouched.
52 func shim(shithubdPath, hookName string) string {
53 return fmt.Sprintf(`#!/bin/sh
54 # Generated by shithubd hooks.Install. Do not edit by hand — re-run
55 # 'shithubd hooks reinstall' if the binary path changes.
56 exec %q hook %s "$@"
57 `, shithubdPath, hookName)
58 }
59
60 // writeAtomic writes body to path via a same-directory tmpfile + rename
61 // so a partial write can never expose an unbootable hook to git.
62 func writeAtomic(path string, body string, mode os.FileMode) error {
63 dir := filepath.Dir(path)
64 tmp, err := os.CreateTemp(dir, ".shithub-hook-*")
65 if err != nil {
66 return err
67 }
68 tmpName := tmp.Name()
69 defer func() {
70 // If rename succeeded the file is gone and Remove is a no-op error
71 // we can swallow.
72 _ = os.Remove(tmpName)
73 }()
74 if _, err := tmp.WriteString(body); err != nil {
75 _ = tmp.Close()
76 return err
77 }
78 if err := tmp.Chmod(mode); err != nil {
79 _ = tmp.Close()
80 return err
81 }
82 if err := tmp.Close(); err != nil {
83 return err
84 }
85 return os.Rename(tmpName, path)
86 }
87