krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
main: internal/control/repo.go · raw
1package control
2
3import (
4 "errors"
5 "fmt"
6 "io"
7 "os"
8 "path/filepath"
9 "slices"
10 "strings"
11
12 "gitbay.org/gitbay/internal/gitutil"
13 "gitbay.org/gitbay/internal/policy"
14 "gitbay.org/gitbay/internal/protocol"
15 "gitbay.org/gitbay/internal/store"
16)
17
18// RepoDir returns the on-disk path for a repository.
19func RepoDir(root, owner, name string) string {
20 return filepath.Join(root, "repos", owner, name+".git")
21}
22
23// HooksDir is the shared core.hooksPath directory.
24func HooksDir(root string) string { return filepath.Join(root, "hooks") }
25
26func init() {
27 register(Command{Path: []string{"repo", "create"},
28 Summary: "create a repository: repo create <owner/name> [--private]", Run: runRepoCreate})
29 register(Command{Path: []string{"repo", "list"},
30 Summary: "list repositories you own or can access", ReadOnly: true, Run: runRepoList})
31 register(Command{Path: []string{"repo", "show"},
32 Summary: "show repository details: repo show <owner/name>", ReadOnly: true, Run: runRepoShow})
33 register(Command{Path: []string{"repo", "transfer"},
34 Summary: "move a repository to another owner: repo transfer <owner/name> <new-owner> (clone URLs change)", Run: runRepoTransfer})
35 register(Command{Path: []string{"repo", "delete"},
36 Summary: "delete a repository: repo delete <owner/name> --yes", Run: runRepoDelete})
37 register(Command{Path: []string{"repo", "access", "grant"},
38 Summary: "grant access: repo access grant <owner/name> <user> read|write|admin", Run: runAccessGrant})
39 register(Command{Path: []string{"repo", "access", "revoke"},
40 Summary: "revoke access: repo access revoke <owner/name> <user>", Run: runAccessRevoke})
41 register(Command{Path: []string{"repo", "access", "list"},
42 Summary: "list access grants: repo access list <owner/name>", ReadOnly: true, Run: runAccessList})
43 register(Command{Path: []string{"repo", "settings", "show"},
44 Summary: "show settings: repo settings show <owner/name>", ReadOnly: true, Run: runSettingsShow})
45 register(Command{Path: []string{"repo", "settings", "protect"},
46 Summary: "protect a branch: repo settings protect <owner/name> <branch>", Run: runProtect})
47 register(Command{Path: []string{"repo", "settings", "unprotect"},
48 Summary: "unprotect a branch: repo settings unprotect <owner/name> <branch>", Run: runUnprotect})
49 register(Command{Path: []string{"repo", "settings", "description"},
50 Summary: "set the repository description: repo settings description <owner/name> <text> ('' clears)", Run: runSetDescription})
51 register(Command{Path: []string{"repo", "settings", "git-daemon"},
52 Summary: "expose over git://: repo settings git-daemon <owner/name> on|off", Run: runGitDaemon})
53}
54
55// resolveRepo loads a repo and checks the given permission for c.User.
56func resolveRepo(c *Ctx, path string, check func(store.User, store.Repo, string) bool) (store.Repo, int) {
57 repo, err := c.Store.RepoByPath(path)
58 if err != nil {
59 if errors.Is(err, store.ErrNotFound) {
60 // Same message whether it doesn't exist or is invisible.
61 return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
62 }
63 return repo, c.fail(protocol.ExitFailure, "loading repository: %v", err)
64 }
65 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
66 if err != nil {
67 return repo, c.fail(protocol.ExitFailure, "checking access: %v", err)
68 }
69 if !check(c.User, repo, grant) {
70 if !policy.CanRead(c.User, repo, grant) {
71 // Invisible repos 404, per the enumeration rule.
72 return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
73 }
74 return repo, c.fail(protocol.ExitDenied, "permission denied on %s", path)
75 }
76 return repo, -1
77}
78
79func runRepoCreate(c *Ctx, args []string) int {
80 visibility := "public"
81 var path, description string
82 for i := 0; i < len(args); i++ {
83 switch args[i] {
84 case "--private":
85 visibility = "private"
86 case "--description":
87 if i+1 >= len(args) {
88 return c.fail(protocol.ExitUsage, "--description requires a value")
89 }
90 description = args[i+1]
91 i++
92 default:
93 if path != "" {
94 return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private] [--description <text>]")
95 }
96 path = args[i]
97 }
98 }
99 owner, name, ok := strings.Cut(path, "/")
100 if !ok {
101 return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private]")
102 }
103 if err := policyValidateRepoName(name); err != nil {
104 return c.fail(protocol.ExitUsage, "%v", err)
105 }
106 ownerKind, ownerID := "user", c.User.ID
107 if owner != c.User.Username {
108 org, err := c.Store.OrgByName(owner)
109 if err != nil {
110 return c.fail(protocol.ExitDenied, "cannot create repositories under %q: not you and not an organization you can see", owner)
111 }
112 role, err := c.Store.OrgRole(org.ID, c.User.ID)
113 if err != nil {
114 return c.fail(protocol.ExitFailure, "%v", err)
115 }
116 if role != "admin" {
117 return c.fail(protocol.ExitDenied, "only admins of %s can create repositories there", owner)
118 }
119 ownerKind, ownerID = "org", org.ID
120 }
121 id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility)
122 if err != nil {
123 return c.fail(protocol.ExitFailure, "%v", err)
124 }
125 dir := RepoDir(c.Cfg.Server.Root, owner, name)
126 if err := gitutil.InitBare(dir, "main", HooksDir(c.Cfg.Server.Root)); err != nil {
127 c.Store.DeleteRepo(id)
128 return c.fail(protocol.ExitFailure, "initializing repository: %v", err)
129 }
130 if description != "" {
131 if err := gitutil.WriteDescription(dir, description); err != nil {
132 return c.fail(protocol.ExitFailure, "writing description: %v", err)
133 }
134 }
135 type out struct {
136 Path string `json:"path"`
137 Visibility string `json:"visibility"`
138 SSHURL string `json:"ssh_url"`
139 }
140 d := out{Path: path, Visibility: visibility, SSHURL: "ssh://git@" + hostOf(c.Cfg.Server.SiteURL) + "/" + path + ".git"}
141 return c.emit(d, func(w io.Writer) {
142 fmt.Fprintf(w, "created %s (%s)\nclone: git clone %s\n", d.Path, d.Visibility, d.SSHURL)
143 })
144}
145
146func policyValidateRepoName(name string) error { return policy.ValidateName(name) }
147
148func hostOf(siteURL string) string {
149 s := strings.TrimPrefix(strings.TrimPrefix(siteURL, "https://"), "http://")
150 return strings.TrimSuffix(s, "/")
151}
152
153func runRepoList(c *Ctx, args []string) int {
154 repos, err := c.Store.ListReposForUser(c.User.ID)
155 if err != nil {
156 return c.fail(protocol.ExitFailure, "%v", err)
157 }
158 type out struct {
159 Path string `json:"path"`
160 Visibility string `json:"visibility"`
161 Description string `json:"description,omitempty"`
162 }
163 var ds []out
164 for _, r := range repos {
165 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
166 ds = append(ds, out{r.Path(), r.Visibility, desc})
167 }
168 return c.emit(ds, func(w io.Writer) {
169 for _, d := range ds {
170 fmt.Fprintf(w, "%s\t%s\t%s\n", d.Path, d.Visibility, d.Description)
171 }
172 })
173}
174
175func runRepoShow(c *Ctx, args []string) int {
176 if len(args) != 1 {
177 return c.fail(protocol.ExitUsage, "usage: repo show <owner/name>")
178 }
179 repo, code := resolveRepo(c, args[0], policy.CanRead)
180 if code >= 0 {
181 return code
182 }
183 type out struct {
184 Path string `json:"path"`
185 Description string `json:"description,omitempty"`
186 Visibility string `json:"visibility"`
187 DefaultBranch string `json:"default_branch"`
188 ProtectedBranches []string `json:"protected_branches,omitempty"`
189 }
190 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name))
191 d := out{repo.Path(), desc, repo.Visibility, repo.DefaultBranch, repo.Settings.ProtectedBranches}
192 return c.emit(d, func(w io.Writer) {
193 fmt.Fprintf(w, "%s\t%s\tdefault: %s\n", d.Path, d.Visibility, d.DefaultBranch)
194 if d.Description != "" {
195 fmt.Fprintf(w, "%s\n", d.Description)
196 }
197 if len(d.ProtectedBranches) > 0 {
198 fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", "))
199 }
200 })
201}
202
203func runRepoTransfer(c *Ctx, args []string) int {
204 if len(args) != 2 {
205 return c.fail(protocol.ExitUsage, "usage: repo transfer <owner/name> <new-owner>")
206 }
207 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
208 if code >= 0 {
209 return code
210 }
211 newOwner := args[1]
212 if newOwner == repo.OwnerName {
213 return c.fail(protocol.ExitUsage, "%s already owns this repository", newOwner)
214 }
215
216 // Target: yourself, or an org you admin — same rule as repo create.
217 newKind, newID := "", int64(0)
218 if newOwner == c.User.Username {
219 newKind, newID = "user", c.User.ID
220 } else if org, err := c.Store.OrgByName(newOwner); err == nil {
221 role, err := c.Store.OrgRole(org.ID, c.User.ID)
222 if err != nil {
223 return c.fail(protocol.ExitFailure, "%v", err)
224 }
225 if role != "admin" {
226 return c.fail(protocol.ExitDenied, "only admins of %s can receive repositories there", newOwner)
227 }
228 newKind, newID = "org", org.ID
229 } else {
230 return c.fail(protocol.ExitDenied, "cannot transfer to %q: not you and not an organization you can see", newOwner)
231 }
232
233 oldDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
234 newDir := RepoDir(c.Cfg.Server.Root, newOwner, repo.Name)
235 if _, err := os.Stat(newDir); err == nil {
236 return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", newOwner, repo.Name)
237 }
238 if err := c.Store.TransferRepo(repo.ID, newKind, newID); err != nil {
239 return c.fail(protocol.ExitUsage, "%v", err)
240 }
241 if err := os.MkdirAll(filepath.Dir(newDir), 0o750); err != nil {
242 c.Store.TransferRepo(repo.ID, repo.OwnerKind, repo.OwnerID)
243 return c.fail(protocol.ExitFailure, "%v", err)
244 }
245 if err := os.Rename(oldDir, newDir); err != nil {
246 // Keep name and disk consistent: revert the database change.
247 c.Store.TransferRepo(repo.ID, repo.OwnerKind, repo.OwnerID)
248 return c.fail(protocol.ExitFailure, "moving repository: %v", err)
249 }
250 newPath := newOwner + "/" + repo.Name
251 return c.emit(map[string]string{"repo": newPath, "was": repo.Path()}, func(w io.Writer) {
252 fmt.Fprintf(w, "transferred %s to %s — clone URLs now use %s\n", repo.Path(), newPath, newPath)
253 })
254}
255
256func runRepoDelete(c *Ctx, args []string) int {
257 var path string
258 var yes bool
259 for _, a := range args {
260 if a == "--yes" {
261 yes = true
262 } else if path == "" {
263 path = a
264 } else {
265 return c.fail(protocol.ExitUsage, "usage: repo delete <owner/name> --yes")
266 }
267 }
268 if path == "" {
269 return c.fail(protocol.ExitUsage, "usage: repo delete <owner/name> --yes")
270 }
271 repo, code := resolveRepo(c, path, policy.CanAdmin)
272 if code >= 0 {
273 return code
274 }
275 if !yes {
276 return c.fail(protocol.ExitUsage, "repo delete is permanent; re-run with --yes")
277 }
278 // Open MRs sourced from this repo keep working (targets own the
279 // objects) but must show that the source is gone.
280 if err := c.Store.MarkSourceGoneForRepo(repo.ID); err != nil {
281 return c.fail(protocol.ExitFailure, "%v", err)
282 }
283 if err := c.Store.DeleteRepo(repo.ID); err != nil {
284 return c.fail(protocol.ExitFailure, "%v", err)
285 }
286 if err := os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)); err != nil {
287 return c.fail(protocol.ExitFailure, "database row removed but disk cleanup failed: %v", err)
288 }
289 return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) {
290 fmt.Fprintf(w, "deleted %s\n", repo.Path())
291 })
292}
293
294func runAccessGrant(c *Ctx, args []string) int {
295 if len(args) != 3 || !slices.Contains([]string{"read", "write", "admin"}, args[2]) {
296 return c.fail(protocol.ExitUsage, "usage: repo access grant <owner/name> <user> read|write|admin")
297 }
298 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
299 if code >= 0 {
300 return code
301 }
302 target, err := c.Store.UserByUsername(args[1])
303 if err != nil {
304 return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
305 }
306 if err := c.Store.GrantAccess(repo.ID, target.ID, args[2]); err != nil {
307 return c.fail(protocol.ExitFailure, "%v", err)
308 }
309 return c.emit(map[string]string{"granted": args[2], "user": target.Username},
310 func(w io.Writer) { fmt.Fprintf(w, "granted %s to %s on %s\n", args[2], target.Username, repo.Path()) })
311}
312
313func runAccessRevoke(c *Ctx, args []string) int {
314 if len(args) != 2 {
315 return c.fail(protocol.ExitUsage, "usage: repo access revoke <owner/name> <user>")
316 }
317 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
318 if code >= 0 {
319 return code
320 }
321 target, err := c.Store.UserByUsername(args[1])
322 if err != nil {
323 return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
324 }
325 if err := c.Store.RevokeAccess(repo.ID, target.ID); err != nil {
326 if errors.Is(err, store.ErrNotFound) {
327 return c.fail(protocol.ExitNotFound, "%s has no grant on %s", target.Username, repo.Path())
328 }
329 return c.fail(protocol.ExitFailure, "%v", err)
330 }
331 return c.emit(map[string]string{"revoked": target.Username},
332 func(w io.Writer) { fmt.Fprintf(w, "revoked %s on %s\n", target.Username, repo.Path()) })
333}
334
335func runAccessList(c *Ctx, args []string) int {
336 if len(args) != 1 {
337 return c.fail(protocol.ExitUsage, "usage: repo access list <owner/name>")
338 }
339 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
340 if code >= 0 {
341 return code
342 }
343 entries, err := c.Store.ListAccess(repo.ID)
344 if err != nil {
345 return c.fail(protocol.ExitFailure, "%v", err)
346 }
347 type out struct {
348 User string `json:"user"`
349 Role string `json:"role"`
350 }
351 var ds []out
352 for _, e := range entries {
353 ds = append(ds, out{e.Username, e.Role})
354 }
355 return c.emit(ds, func(w io.Writer) {
356 for _, d := range ds {
357 fmt.Fprintf(w, "%s\t%s\n", d.User, d.Role)
358 }
359 })
360}
361
362func runSettingsShow(c *Ctx, args []string) int {
363 if len(args) != 1 {
364 return c.fail(protocol.ExitUsage, "usage: repo settings show <owner/name>")
365 }
366 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
367 if code >= 0 {
368 return code
369 }
370 return c.emit(repo.Settings, func(w io.Writer) {
371 fmt.Fprintf(w, "protected_branches: %s\nrequire_signed_commits: %v\ngit_daemon: %v\n",
372 strings.Join(repo.Settings.ProtectedBranches, ", "), repo.Settings.RequireSignedCommits, repo.Settings.GitDaemon)
373 })
374}
375
376func runSetDescription(c *Ctx, args []string) int {
377 if len(args) != 2 {
378 return c.fail(protocol.ExitUsage, "usage: repo settings description <owner/name> <text>")
379 }
380 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
381 if code >= 0 {
382 return code
383 }
384 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
385 if err := gitutil.WriteDescription(dir, args[1]); err != nil {
386 return c.fail(protocol.ExitFailure, "%v", err)
387 }
388 return c.emit(map[string]string{"description": gitutil.ReadDescription(dir)}, func(w io.Writer) {
389 fmt.Fprintf(w, "description set on %s\n", repo.Path())
390 })
391}
392
393func runGitDaemon(c *Ctx, args []string) int {
394 if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
395 return c.fail(protocol.ExitUsage, "usage: repo settings git-daemon <owner/name> on|off")
396 }
397 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
398 if code >= 0 {
399 return code
400 }
401 on := args[1] == "on"
402 if on && repo.Visibility != "public" {
403 return c.fail(protocol.ExitUsage, "git:// serves only public repositories; %s is private", repo.Path())
404 }
405 if on && !c.Cfg.GitDaemon.Enabled {
406 return c.fail(protocol.ExitUsage, "this instance does not run the git:// daemon ([git_daemon] enabled = false)")
407 }
408 s := repo.Settings
409 s.GitDaemon = on
410 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
411 return c.fail(protocol.ExitFailure, "%v", err)
412 }
413 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "git-daemon %s on %s\n", args[1], repo.Path()) })
414}
415
416func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) }
417func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) }
418
419func setProtect(c *Ctx, args []string, protect bool) int {
420 if len(args) != 2 {
421 return c.fail(protocol.ExitUsage, "usage: repo settings protect|unprotect <owner/name> <branch>")
422 }
423 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
424 if code >= 0 {
425 return code
426 }
427 branch := args[1]
428 s := repo.Settings
429 has := slices.Contains(s.ProtectedBranches, branch)
430 if protect && !has {
431 s.ProtectedBranches = append(s.ProtectedBranches, branch)
432 slices.Sort(s.ProtectedBranches)
433 }
434 if !protect && has {
435 s.ProtectedBranches = slices.DeleteFunc(s.ProtectedBranches, func(b string) bool { return b == branch })
436 }
437 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
438 return c.fail(protocol.ExitFailure, "%v", err)
439 }
440 verb := "protected"
441 if !protect {
442 verb = "unprotected"
443 }
444 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%s %s on %s\n", verb, branch, repo.Path()) })
445}