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}