Monorepo for Tangled
tangled.org
1// heavily inspired by gitea's model
2
3package hook
4
5import (
6 "errors"
7 "fmt"
8 "io/fs"
9 "log/slog"
10 "os"
11 "path/filepath"
12 "strings"
13
14 "github.com/go-git/go-git/v5"
15)
16
17var ErrNoGitRepo = errors.New("not a git repo")
18var ErrCreatingHookDir = errors.New("failed to create hooks directory")
19var ErrCreatingHook = errors.New("failed to create hook")
20var ErrCreatingDelegate = errors.New("failed to create delegate hook")
21
22type config struct {
23 scanPath string
24 internalApi string
25}
26
27type setupOpt func(*config)
28
29func WithScanPath(scanPath string) setupOpt {
30 return func(c *config) {
31 c.scanPath = scanPath
32 }
33}
34
35func WithInternalApi(api string) setupOpt {
36 return func(c *config) {
37 c.internalApi = api
38 }
39}
40
41func Config(opts ...setupOpt) config {
42 config := config{}
43 for _, o := range opts {
44 o(&config)
45 }
46 return config
47}
48
49// setup hooks for all users
50//
51// directory structure is typically like so:
52//
53// did:plc:repo1
54// did:plc:repo2
55// did:web:repo1
56func Setup(config config) error {
57 // iterate over all directories in current directory:
58 repoDirs, err := os.ReadDir(config.scanPath)
59 if errors.Is(err, fs.ErrNotExist) {
60 return os.MkdirAll(config.scanPath, 0755)
61 }
62 if err != nil {
63 return err
64 }
65
66 for _, repo := range repoDirs {
67 if !repo.IsDir() {
68 continue
69 }
70
71 did := repo.Name()
72 if !strings.HasPrefix(did, "did:") {
73 continue
74 }
75
76 userPath := filepath.Join(config.scanPath, did)
77 if _, err := os.Stat(userPath); errors.Is(err, fs.ErrPermission) {
78 slog.Warn("hook setup: skipping inaccessible repo", "path", userPath)
79 continue
80 }
81 if err := SetupRepo(config, userPath); err != nil {
82 if errors.Is(err, ErrNoGitRepo) {
83 slog.Warn("hook setup: skipping non-repo entry", "path", userPath, "err", err)
84 continue
85 }
86 return err
87 }
88 }
89
90 return nil
91}
92
93// setup hook in /scanpath/did:plc:repo
94func SetupRepo(config config, path string) error {
95 if _, err := git.PlainOpen(path); err != nil {
96 return fmt.Errorf("%s: %w", path, ErrNoGitRepo)
97 }
98
99 preReceiveD := filepath.Join(path, "hooks", "post-receive.d")
100 if err := os.MkdirAll(preReceiveD, 0755); err != nil {
101 return fmt.Errorf("%s: %w", preReceiveD, ErrCreatingHookDir)
102 }
103
104 notify := filepath.Join(preReceiveD, "40-notify.sh")
105 if err := mkHook(config, notify); err != nil {
106 return fmt.Errorf("%s: %w", notify, ErrCreatingHook)
107 }
108
109 delegate := filepath.Join(path, "hooks", "post-receive")
110 if err := mkDelegate(delegate); err != nil {
111 return fmt.Errorf("%s: %w", delegate, ErrCreatingDelegate)
112 }
113
114 return nil
115}
116
117func mkHook(config config, hookPath string) error {
118 // use the absolute path to the underlying binary rather than a bare
119 // `knot` lookup. on NixOS, bare `knot` resolves to /run/wrappers/bin/knot
120 // which has restrictive perms (only the git group can exec it), so hooks
121 // running as a virtual UID fail with EACCES. the underlying binary in
122 // /nix/store is world-readable. hooks are regenerated on every deploy
123 // so the store path stays fresh.
124 executablePath, err := os.Executable()
125 if err != nil {
126 return err
127 }
128
129 hookContent := fmt.Sprintf(`#!/usr/bin/env bash
130# AUTO GENERATED BY KNOT, DO NOT MODIFY
131push_options=()
132for ((i=0; i<GIT_PUSH_OPTION_COUNT; i++)); do
133 option_var="GIT_PUSH_OPTION_$i"
134 push_options+=(-push-option "${!option_var}")
135done
136%s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-receive
137 `, executablePath, config.internalApi)
138
139 if err := os.WriteFile(hookPath, []byte(hookContent), 0755); err != nil {
140 return err
141 }
142 // os.WriteFile doesn't change the mode on existing files; chmod explicitly.
143 return os.Chmod(hookPath, 0755)
144}
145
146func mkDelegate(path string) error {
147 content := fmt.Sprintf(`#!/usr/bin/env bash
148# AUTO GENERATED BY KNOT, DO NOT MODIFY
149data=$(cat)
150exitcodes=""
151hookname=$(basename $0)
152GIT_DIR="$PWD"
153
154for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
155 test -x "${hook}" && test -f "${hook}" || continue
156 echo "${data}" | "${hook}"
157 exitcodes="${exitcodes} $?"
158done
159
160for i in ${exitcodes}; do
161 [ ${i} -eq 0 ] || exit ${i}
162done
163 `)
164
165 if err := os.WriteFile(path, []byte(content), 0755); err != nil {
166 return err
167 }
168 return os.Chmod(path, 0755)
169}