vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
|
653e0cfc
Keenan Nemetz
18d ago
|
1 | // Package repo owns the on-disk bare-repo store and the metadata refs that live |
| 2 | // inside each repository. | |
| 3 | package repo | |
| 4 | ||
| 5 | import ( | |
| 6 | "crypto/rand" | |
| 7 | "encoding/hex" | |
| 8 | "fmt" | |
| 9 | "os" | |
| 10 | "path/filepath" | |
| 11 | "regexp" | |
| 12 | "sort" | |
|
a5f19b3c
Keenan Nemetz
18d ago
|
13 | "strconv" |
|
653e0cfc
Keenan Nemetz
18d ago
|
14 | "strings" |
| 15 | "time" | |
| 16 | ||
|
d37d301f
Keenan Nemetz
23h ago
|
17 | "rodent.software/vole/core/git" |
| 18 | "rodent.software/vole/forge/access" | |
| 19 | "rodent.software/vole/forge/secret" | |
|
653e0cfc
Keenan Nemetz
18d ago
|
20 | ) |
| 21 | ||
| 22 | const ( | |
|
ec5c519c
Keenan Nemetz
17d ago
|
23 | ACLRef = "refs/vole/acl" |
|
c5902451
Keenan Nemetz
17d ago
|
24 | ACLPath = "access.json" |
|
ec5c519c
Keenan Nemetz
17d ago
|
25 | idFile = "vole-id" |
| 26 | descFile = "description" | |
|
653e0cfc
Keenan Nemetz
18d ago
|
27 | ) |
| 28 | ||
| 29 | var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`) | |
| 30 | ||
|
ed03b3d1
Keenan Nemetz
10h ago
|
31 | // reservedNames are the root paths the forge itself must keep. A repository owns |
| 32 | // the root of the URL space otherwise (/<name> is its page and /<name>.git its | |
| 33 | // transport), so these are the only names it cannot take. The forge's own pages | |
| 34 | // are all below /-/ and cannot collide with a name at all: a repository may be | |
| 35 | // called "admin" or "u". | |
| 36 | var reservedNames = map[string]string{ | |
| 37 | "static": "the asset route /-/static/ serves from the root", | |
| 38 | "robots.txt": "crawlers only look for /robots.txt at the root", | |
| 39 | "sitemap.xml": "crawlers only look for /sitemap.xml at the root", | |
| 40 | "sitemap-pages.xml": "crawlers only look for /sitemap-pages.xml at the root", | |
| 41 | } | |
| 42 | ||
|
653e0cfc
Keenan Nemetz
18d ago
|
43 | // ValidateName enforces a repo naming scheme that is safe as a directory name |
| 44 | // and as a URL path segment. | |
| 45 | func ValidateName(name string) error { | |
| 46 | if !nameRE.MatchString(name) { | |
| 47 | return fmt.Errorf("invalid repo name %q: use lowercase letters, digits, dot, dash or underscore, starting with a letter or digit (max 63)", name) | |
| 48 | } | |
| 49 | if strings.HasSuffix(name, ".git") { | |
| 50 | return fmt.Errorf("invalid repo name %q: drop the .git suffix", name) | |
| 51 | } | |
| 52 | if strings.Contains(name, "..") { | |
| 53 | return fmt.Errorf("invalid repo name %q", name) | |
| 54 | } | |
|
ed03b3d1
Keenan Nemetz
10h ago
|
55 | if why, ok := reservedNames[name]; ok { |
| 56 | return fmt.Errorf("invalid repo name %q: %s; choose another name", name, why) | |
| 57 | } | |
|
653e0cfc
Keenan Nemetz
18d ago
|
58 | return nil |
| 59 | } | |
| 60 | ||
| 61 | type Store struct { | |
| 62 | Root string | |
| 63 | BinPath string | |
|
a5f19b3c
Keenan Nemetz
18d ago
|
64 | // MaxInputBytes is written into each repo as git's receive.maxInputSize, so |
| 65 | // git enforces the push cap on every transport instead of the proxy | |
| 66 | // buffering to count bytes. | |
| 67 | MaxInputBytes int64 | |
|
ec5c519c
Keenan Nemetz
17d ago
|
68 | // ACLs is where access rules live. They are not in the repository: they are |
|
62576fa8
Keenan Nemetz
15d ago
|
69 | // configuration the instance owns, keyed by account name, read on every push and |
|
ec5c519c
Keenan Nemetz
17d ago
|
70 | // every request (see the access package). |
| 71 | ACLs *access.Store | |
|
0c40a1d3
Keenan Nemetz
7d ago
|
72 | // Secrets is where the repository's sealed values live, keyed by the same stable |
| 73 | // id the rules use. A deleted repository takes its secrets with it. | |
| 74 | Secrets *secret.Store | |
|
653e0cfc
Keenan Nemetz
18d ago
|
75 | } |
| 76 | ||
|
ec5c519c
Keenan Nemetz
17d ago
|
77 | func New(root, binPath string, acls *access.Store) *Store { |
| 78 | return &Store{Root: root, BinPath: binPath, ACLs: acls} | |
| 79 | } | |
|
653e0cfc
Keenan Nemetz
18d ago
|
80 | |
| 81 | // Meta is a repository's identity plus the facts the web layer needs. | |
| 82 | type Meta struct { | |
| 83 | ID string | |
| 84 | Name string | |
| 85 | Dir string | |
| 86 | Created time.Time | |
| 87 | Description string | |
| 88 | DefaultBranch string | |
|
ec5c519c
Keenan Nemetz
17d ago
|
89 | // ACLs is the store this repository's rules are read from. It is copied in from |
| 90 | // the Store so a Meta always knows where its rules live. | |
| 91 | ACLs *access.Store | |
|
653e0cfc
Keenan Nemetz
18d ago
|
92 | } |
| 93 | ||
| 94 | func (s *Store) path(name string) string { return filepath.Join(s.Root, name+".git") } | |
| 95 | ||
| 96 | // Create initialises a bare repo, stamps it with a stable id and seeds its ACL | |
| 97 | // so the creator is admin. | |
| 98 | func (s *Store) Create(name, owner, description string) (*Meta, error) { | |
| 99 | if err := ValidateName(name); err != nil { | |
| 100 | return nil, err | |
| 101 | } | |
| 102 | dir := s.path(name) | |
| 103 | if _, err := os.Stat(dir); err == nil { | |
| 104 | return nil, fmt.Errorf("repo %q already exists", name) | |
| 105 | } | |
|
c5902451
Keenan Nemetz
17d ago
|
106 | if err := git.InitBare(dir, "main"); err != nil { |
|
653e0cfc
Keenan Nemetz
18d ago
|
107 | return nil, err |
| 108 | } | |
|
c5902451
Keenan Nemetz
17d ago
|
109 | g := git.Open(dir) |
|
a5f19b3c
Keenan Nemetz
18d ago
|
110 | if s.MaxInputBytes > 0 { |
| 111 | if err := g.SetConfig("receive.maxInputSize", strconv.FormatInt(s.MaxInputBytes, 10)); err != nil { | |
| 112 | return nil, err | |
| 113 | } | |
| 114 | } | |
| 115 | // http-backend refuses receive-pack unless told otherwise; our own hook is | |
| 116 | // the actual gate, and HTTP writes are refused in the transport anyway. | |
| 117 | if err := g.SetConfig("http.receivepack", "false"); err != nil { | |
| 118 | return nil, err | |
| 119 | } | |
|
653e0cfc
Keenan Nemetz
18d ago
|
120 | id, err := newID() |
| 121 | if err != nil { | |
| 122 | return nil, err | |
| 123 | } | |
| 124 | if err := os.WriteFile(filepath.Join(dir, idFile), []byte(id+"\n"), 0o644); err != nil { | |
| 125 | return nil, err | |
| 126 | } | |
| 127 | if description != "" { | |
| 128 | if err := os.WriteFile(filepath.Join(dir, descFile), []byte(description+"\n"), 0o644); err != nil { | |
| 129 | return nil, err | |
| 130 | } | |
| 131 | } | |
|
ec5c519c
Keenan Nemetz
17d ago
|
132 | m := &Meta{ID: id, Name: name, Dir: dir, Created: time.Now().UTC(), Description: description, |
| 133 | DefaultBranch: "main", ACLs: s.ACLs} | |
|
c5902451
Keenan Nemetz
17d ago
|
134 | if err := m.SetACL(access.Empty(owner), owner); err != nil { |
|
653e0cfc
Keenan Nemetz
18d ago
|
135 | return nil, err |
| 136 | } | |
| 137 | if err := s.InstallHooks(m.Dir); err != nil { | |
| 138 | return nil, err | |
| 139 | } | |
| 140 | return m, nil | |
| 141 | } | |
| 142 | ||
|
642e5994
Keenan Nemetz
15d ago
|
143 | // Public reports whether anonymous readers may read the repository. It avoids |
| 144 | // git: certificate host policy calls it on a handshake, and a private | |
| 145 | // repository's name should not end up in a public certificate. | |
| 146 | func (s *Store) Public(name string) bool { | |
| 147 | if ValidateName(name) != nil { | |
| 148 | return false | |
| 149 | } | |
| 150 | idb, err := os.ReadFile(filepath.Join(s.path(name), idFile)) | |
| 151 | if err != nil { | |
| 152 | return false | |
| 153 | } | |
| 154 | id := strings.TrimSpace(string(idb)) | |
| 155 | if id == "" || s.ACLs == nil { | |
| 156 | return false | |
| 157 | } | |
| 158 | a, err := s.ACLs.Get(id) | |
| 159 | return err == nil && a != nil && a.Can("", access.Read) | |
| 160 | } | |
| 161 | ||
|
653e0cfc
Keenan Nemetz
18d ago
|
162 | // Open loads a repo by name, returning an error if it is not a Vole repo. |
| 163 | func (s *Store) Open(name string) (*Meta, error) { | |
|
dced06da
Keenan Nemetz
10d ago
|
164 | m, err := s.Meta(name) |
| 165 | if err != nil { | |
| 166 | return nil, err | |
| 167 | } | |
| 168 | if br := git.Open(m.Dir).Symref(); br != "" { | |
| 169 | m.DefaultBranch = br | |
| 170 | } | |
| 171 | return m, nil | |
| 172 | } | |
| 173 | ||
| 174 | // Meta loads a repo's instance-side facts without running git. The id, name, | |
| 175 | // directory, description and creation time are files and a stat; the default | |
| 176 | // branch is what Open adds, and that costs a git call. A caller that only needs | |
| 177 | // the ACL or the git directory should use this. | |
| 178 | func (s *Store) Meta(name string) (*Meta, error) { | |
|
653e0cfc
Keenan Nemetz
18d ago
|
179 | if err := ValidateName(name); err != nil { |
| 180 | return nil, err | |
| 181 | } | |
| 182 | dir := s.path(name) | |
| 183 | st, err := os.Stat(dir) | |
| 184 | if err != nil || !st.IsDir() { | |
| 185 | return nil, os.ErrNotExist | |
| 186 | } | |
| 187 | idb, err := os.ReadFile(filepath.Join(dir, idFile)) | |
| 188 | if err != nil { | |
| 189 | return nil, fmt.Errorf("repo %q is not a Vole repository (no %s)", name, idFile) | |
| 190 | } | |
|
ec5c519c
Keenan Nemetz
17d ago
|
191 | m := &Meta{ID: strings.TrimSpace(string(idb)), Name: name, Dir: dir, DefaultBranch: "main", ACLs: s.ACLs} |
|
dced06da
Keenan Nemetz
10d ago
|
192 | m.Created = st.ModTime().UTC() |
|
653e0cfc
Keenan Nemetz
18d ago
|
193 | if b, err := os.ReadFile(filepath.Join(dir, descFile)); err == nil { |
| 194 | m.Description = strings.TrimSpace(string(b)) | |
| 195 | } | |
| 196 | return m, nil | |
| 197 | } | |
| 198 | ||
|
1948358f
Keenan Nemetz
15d ago
|
199 | // SetDescription writes the repository's description beside the bare repo. An |
| 200 | // empty description removes the file, so a later Open cannot resurrect a value | |
| 201 | // the operator cleared. | |
| 202 | func (m *Meta) SetDescription(description string) error { | |
| 203 | description = strings.TrimSpace(description) | |
| 204 | path := filepath.Join(m.Dir, descFile) | |
| 205 | if description == "" { | |
| 206 | if err := os.Remove(path); err != nil && !os.IsNotExist(err) { | |
| 207 | return err | |
| 208 | } | |
| 209 | } else if err := os.WriteFile(path, []byte(description+"\n"), 0o644); err != nil { | |
| 210 | return err | |
| 211 | } | |
| 212 | m.Description = description | |
| 213 | return nil | |
| 214 | } | |
| 215 | ||
| 216 | // SetDefaultBranch points HEAD at a branch that already exists. It validates the | |
| 217 | // name and the ref before touching HEAD, so a rejected branch changes nothing. | |
| 218 | func (m *Meta) SetDefaultBranch(branch string) error { | |
| 219 | branch = strings.TrimSpace(branch) | |
| 220 | if branch == "" { | |
| 221 | return fmt.Errorf("default branch is required") | |
| 222 | } | |
| 223 | g := m.Git() | |
| 224 | if !g.ValidBranch(branch) { | |
| 225 | return fmt.Errorf("%q is not a valid branch name", branch) | |
| 226 | } | |
| 227 | if sha, err := g.RevParse("refs/heads/" + branch); err != nil || sha == "" { | |
| 228 | return fmt.Errorf("branch %q does not exist", branch) | |
| 229 | } | |
| 230 | if err := g.SetHEAD(branch); err != nil { | |
| 231 | return err | |
| 232 | } | |
| 233 | m.DefaultBranch = branch | |
| 234 | return nil | |
| 235 | } | |
| 236 | ||
|
653e0cfc
Keenan Nemetz
18d ago
|
237 | // List returns every repo in the store, sorted by name. |
| 238 | func (s *Store) List() ([]*Meta, error) { | |
| 239 | ents, err := os.ReadDir(s.Root) | |
| 240 | if err != nil { | |
| 241 | if os.IsNotExist(err) { | |
| 242 | return nil, nil | |
| 243 | } | |
| 244 | return nil, err | |
| 245 | } | |
| 246 | var out []*Meta | |
| 247 | for _, e := range ents { | |
| 248 | if !e.IsDir() || !strings.HasSuffix(e.Name(), ".git") { | |
| 249 | continue | |
| 250 | } | |
| 251 | name := strings.TrimSuffix(e.Name(), ".git") | |
| 252 | m, err := s.Open(name) | |
| 253 | if err != nil { | |
| 254 | continue | |
| 255 | } | |
| 256 | out = append(out, m) | |
| 257 | } | |
| 258 | sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) | |
| 259 | return out, nil | |
| 260 | } | |
| 261 | ||
| 262 | func (s *Store) Rename(oldName, newName string) error { | |
| 263 | if err := ValidateName(newName); err != nil { | |
| 264 | return err | |
| 265 | } | |
| 266 | oldDir, newDir := s.path(oldName), s.path(newName) | |
| 267 | if _, err := os.Stat(newDir); err == nil { | |
| 268 | return fmt.Errorf("repo %q already exists", newName) | |
| 269 | } | |
| 270 | if _, err := os.Stat(oldDir); err != nil { | |
| 271 | return fmt.Errorf("repo %q not found", oldName) | |
| 272 | } | |
| 273 | if err := os.Rename(oldDir, newDir); err != nil { | |
| 274 | return err | |
| 275 | } | |
| 276 | // The clone URL changed, so the hooks must still point at a valid binary. | |
| 277 | return s.InstallHooks(newDir) | |
| 278 | } | |
| 279 | ||
|
b1499c72
Keenan Nemetz
13d ago
|
280 | func (s *Store) Delete(name, actor string) error { |
|
ec5c519c
Keenan Nemetz
17d ago
|
281 | m, err := s.Open(name) |
| 282 | if err != nil { | |
|
653e0cfc
Keenan Nemetz
18d ago
|
283 | return fmt.Errorf("repo %q not found", name) |
| 284 | } | |
|
b1499c72
Keenan Nemetz
13d ago
|
285 | // The deletion is recorded before anything is removed: a repository that |
| 286 | // vanishes without a line is exactly the event the audit log exists for. The | |
| 287 | // rules themselves go only after the content is gone, so a removal that fails | |
| 288 | // leaves a repository that can still be administered. | |
| 289 | if s.ACLs != nil { | |
| 290 | if err := s.ACLs.RecordDelete(m.ID, actor); err != nil { | |
| 291 | return err | |
| 292 | } | |
| 293 | } | |
|
0c40a1d3
Keenan Nemetz
7d ago
|
294 | if s.Secrets != nil { |
| 295 | if err := s.Secrets.RecordRepoDelete(m.ID, actor); err != nil { | |
| 296 | return err | |
| 297 | } | |
| 298 | } | |
|
ec5c519c
Keenan Nemetz
17d ago
|
299 | if err := os.RemoveAll(m.Dir); err != nil { |
| 300 | return err | |
| 301 | } | |
|
0c40a1d3
Keenan Nemetz
7d ago
|
302 | if s.Secrets != nil { |
| 303 | if err := s.Secrets.DeleteRepo(m.ID); err != nil { | |
| 304 | return err | |
| 305 | } | |
| 306 | } | |
|
ec5c519c
Keenan Nemetz
17d ago
|
307 | if s.ACLs != nil { |
| 308 | return s.ACLs.Delete(m.ID) | |
| 309 | } | |
| 310 | return nil | |
|
653e0cfc
Keenan Nemetz
18d ago
|
311 | } |
| 312 | ||
|
c5902451
Keenan Nemetz
17d ago
|
313 | func (m *Meta) Git() *git.Repo { return git.Open(m.Dir) } |
|
653e0cfc
Keenan Nemetz
18d ago
|
314 | |
|
ec5c519c
Keenan Nemetz
17d ago
|
315 | // ACL reads the repo's access rules from the instance database. A repository with no |
| 316 | // row gets a deny-by-default ACL with no principals, which is also what a repository | |
| 317 | // whose rules were never written looks like. | |
|
c5902451
Keenan Nemetz
17d ago
|
318 | func (m *Meta) ACL() (*access.ACL, error) { |
|
ec5c519c
Keenan Nemetz
17d ago
|
319 | if m.ACLs == nil { |
| 320 | return nil, fmt.Errorf("repo %q has no access store wired, so its rules cannot be read", m.Name) | |
| 321 | } | |
| 322 | a, err := m.ACLs.Get(m.ID) | |
|
653e0cfc
Keenan Nemetz
18d ago
|
323 | if err != nil { |
| 324 | return nil, err | |
| 325 | } | |
|
ec5c519c
Keenan Nemetz
17d ago
|
326 | if a == nil { |
|
5b814e99
Keenan Nemetz
14d ago
|
327 | return m.ACLs.Attach(access.Empty("")), nil |
|
653e0cfc
Keenan Nemetz
18d ago
|
328 | } |
|
ec5c519c
Keenan Nemetz
17d ago
|
329 | return a, nil |
|
653e0cfc
Keenan Nemetz
18d ago
|
330 | } |
| 331 | ||
|
62b59a76
Keenan Nemetz
13d ago
|
332 | // SetACL replaces the repo's access rules. A rule set read from the store carries its |
| 333 | // revision, so a write against a stale read is refused instead of undoing whatever the | |
| 334 | // other writer did; the old copy's commit message (who changed what) is kept in the row | |
| 335 | // and the audit log. | |
|
c5902451
Keenan Nemetz
17d ago
|
336 | func (m *Meta) SetACL(a *access.ACL, actor string) error { |
|
ec5c519c
Keenan Nemetz
17d ago
|
337 | if m.ACLs == nil { |
| 338 | return fmt.Errorf("repo %q has no access store wired, so its rules cannot be written", m.Name) | |
|
653e0cfc
Keenan Nemetz
18d ago
|
339 | } |
|
ec5c519c
Keenan Nemetz
17d ago
|
340 | return m.ACLs.Set(m.ID, a, actor, time.Now().UTC()) |
| 341 | } | |
| 342 | ||
|
62b59a76
Keenan Nemetz
13d ago
|
343 | // UpdateACL changes the repo's current rules by applying fn to them, and returns the |
| 344 | // result. Use it for a change that owns one field — the visibility, one account's role, | |
| 345 | // one runners entry — so a concurrent change to another field is kept rather than | |
| 346 | // overwritten by a map somebody loaded a page ago. | |
| 347 | func (m *Meta) UpdateACL(actor string, fn func(*access.ACL) error) (*access.ACL, error) { | |
| 348 | if m.ACLs == nil { | |
| 349 | return nil, fmt.Errorf("repo %q has no access store wired, so its rules cannot be written", m.Name) | |
| 350 | } | |
| 351 | return m.ACLs.Update(m.ID, actor, fn) | |
| 352 | } | |
| 353 | ||
|
ec5c519c
Keenan Nemetz
17d ago
|
354 | // ImportACLs reads rules from refs/vole/acl in every repository that has them and no |
| 355 | // row yet, which is how an instance that stored them in git keeps them across the | |
| 356 | // upgrade. It is idempotent and reports how many repositories it took rules from. | |
| 357 | func (s *Store) ImportACLs() (int, error) { | |
| 358 | if s.ACLs == nil { | |
| 359 | return 0, fmt.Errorf("no access store is wired") | |
| 360 | } | |
| 361 | repos, err := s.List() | |
|
653e0cfc
Keenan Nemetz
18d ago
|
362 | if err != nil { |
|
ec5c519c
Keenan Nemetz
17d ago
|
363 | return 0, err |
|
653e0cfc
Keenan Nemetz
18d ago
|
364 | } |
|
ec5c519c
Keenan Nemetz
17d ago
|
365 | imported := 0 |
| 366 | for _, m := range repos { | |
| 367 | have, err := s.ACLs.Get(m.ID) | |
|
653e0cfc
Keenan Nemetz
18d ago
|
368 | if err != nil { |
|
ec5c519c
Keenan Nemetz
17d ago
|
369 | return imported, err |
|
653e0cfc
Keenan Nemetz
18d ago
|
370 | } |
|
ec5c519c
Keenan Nemetz
17d ago
|
371 | if have != nil { |
| 372 | continue | |
|
653e0cfc
Keenan Nemetz
18d ago
|
373 | } |
|
ec5c519c
Keenan Nemetz
17d ago
|
374 | g := m.Git() |
| 375 | commit, err := g.RevParse(ACLRef) | |
| 376 | if err != nil || commit == "" { | |
| 377 | continue // nothing to import: a repository with no rules is deny-all | |
|
653e0cfc
Keenan Nemetz
18d ago
|
378 | } |
|
ec5c519c
Keenan Nemetz
17d ago
|
379 | data, err := g.BlobAt(commit, ACLPath) |
|
653e0cfc
Keenan Nemetz
18d ago
|
380 | if err != nil { |
|
ec5c519c
Keenan Nemetz
17d ago
|
381 | return imported, fmt.Errorf("%s: read %s: %w", m.Name, ACLRef, err) |
|
653e0cfc
Keenan Nemetz
18d ago
|
382 | } |
|
c5902451
Keenan Nemetz
17d ago
|
383 | a, err := access.Parse(data) |
|
653e0cfc
Keenan Nemetz
18d ago
|
384 | if err != nil { |
|
ec5c519c
Keenan Nemetz
17d ago
|
385 | return imported, fmt.Errorf("%s: %w", m.Name, err) |
|
653e0cfc
Keenan Nemetz
18d ago
|
386 | } |
|
ec5c519c
Keenan Nemetz
17d ago
|
387 | if err := s.ACLs.Set(m.ID, a, "import from "+ACLRef, time.Now().UTC()); err != nil { |
| 388 | return imported, err | |
|
653e0cfc
Keenan Nemetz
18d ago
|
389 | } |
|
ec5c519c
Keenan Nemetz
17d ago
|
390 | imported++ |
|
653e0cfc
Keenan Nemetz
18d ago
|
391 | } |
|
ec5c519c
Keenan Nemetz
17d ago
|
392 | return imported, nil |
|
653e0cfc
Keenan Nemetz
18d ago
|
393 | } |
| 394 | ||
| 395 | // InstallHooks writes the server-side hook that enforces policy. It is | |
| 396 | // regenerated on serve start and after rename so the binary path stays correct. | |
| 397 | func (s *Store) InstallHooks(dir string) error { | |
| 398 | hookDir := filepath.Join(dir, "hooks") | |
| 399 | if err := os.MkdirAll(hookDir, 0o755); err != nil { | |
| 400 | return err | |
| 401 | } | |
|
b44cbfaf
Keenan Nemetz
18d ago
|
402 | // pre-receive is the policy gate; post-receive only queues CI, so it runs after |
| 403 | // the refs have moved and can never reject a push. | |
| 404 | for _, hook := range []string{"pre-receive", "post-receive"} { | |
| 405 | body := "#!/bin/sh\n# Generated by Vole. Do not edit: rewritten on startup.\nexec " + | |
| 406 | shellQuote(s.BinPath) + " hook " + hook + "\n" | |
| 407 | p := filepath.Join(hookDir, hook) | |
| 408 | if err := os.WriteFile(p, []byte(body), 0o755); err != nil { | |
| 409 | return err | |
| 410 | } | |
| 411 | } | |
| 412 | return nil | |
|
653e0cfc
Keenan Nemetz
18d ago
|
413 | } |
| 414 | ||
| 415 | func (s *Store) InstallAllHooks() (int, error) { | |
| 416 | repos, err := s.List() | |
| 417 | if err != nil { | |
| 418 | return 0, err | |
| 419 | } | |
| 420 | n := 0 | |
| 421 | for _, m := range repos { | |
| 422 | if err := s.InstallHooks(m.Dir); err != nil { | |
| 423 | return n, err | |
| 424 | } | |
| 425 | n++ | |
| 426 | } | |
| 427 | return n, nil | |
| 428 | } | |
| 429 | ||
| 430 | func shellQuote(s string) string { | |
| 431 | return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" | |
| 432 | } | |
| 433 | ||
| 434 | func newID() (string, error) { | |
| 435 | b := make([]byte, 12) | |
| 436 | if _, err := rand.Read(b); err != nil { | |
| 437 | return "", err | |
| 438 | } | |
| 439 | return hex.EncodeToString(b), nil | |
| 440 | } |