internal/control/repo.go

08b325bb1d221743ebfd2d99502f63b59ff6f346
gitbay/internal/control/repo.go history · blame · raw

341 lines · 12032 bytes

  1package control
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"io"
  7	"os"
  8	"path/filepath"
  9	"slices"
 10	"strings"
 11
 12	"github.com/krazywarez/forge/internal/gitutil"
 13	"github.com/krazywarez/forge/internal/policy"
 14	"github.com/krazywarez/forge/internal/protocol"
 15	"github.com/krazywarez/forge/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", Run: runRepoList})
 31	register(Command{Path: []string{"repo", "show"},
 32		Summary: "show repository details: repo show <owner/name>", Run: runRepoShow})
 33	register(Command{Path: []string{"repo", "delete"},
 34		Summary: "delete a repository: repo delete <owner/name> --yes", Run: runRepoDelete})
 35	register(Command{Path: []string{"repo", "access", "grant"},
 36		Summary: "grant access: repo access grant <owner/name> <user> read|write|admin", Run: runAccessGrant})
 37	register(Command{Path: []string{"repo", "access", "revoke"},
 38		Summary: "revoke access: repo access revoke <owner/name> <user>", Run: runAccessRevoke})
 39	register(Command{Path: []string{"repo", "access", "list"},
 40		Summary: "list access grants: repo access list <owner/name>", Run: runAccessList})
 41	register(Command{Path: []string{"repo", "settings", "show"},
 42		Summary: "show settings: repo settings show <owner/name>", Run: runSettingsShow})
 43	register(Command{Path: []string{"repo", "settings", "protect"},
 44		Summary: "protect a branch: repo settings protect <owner/name> <branch>", Run: runProtect})
 45	register(Command{Path: []string{"repo", "settings", "unprotect"},
 46		Summary: "unprotect a branch: repo settings unprotect <owner/name> <branch>", Run: runUnprotect})
 47	register(Command{Path: []string{"repo", "settings", "git-daemon"},
 48		Summary: "expose over git://: repo settings git-daemon <owner/name> on|off", Run: runGitDaemon})
 49}
 50
 51// resolveRepo loads a repo and checks the given permission for c.User.
 52func resolveRepo(c *Ctx, path string, check func(store.User, store.Repo, string) bool) (store.Repo, int) {
 53	repo, err := c.Store.RepoByPath(path)
 54	if err != nil {
 55		if errors.Is(err, store.ErrNotFound) {
 56			// Same message whether it doesn't exist or is invisible.
 57			return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
 58		}
 59		return repo, c.fail(protocol.ExitFailure, "loading repository: %v", err)
 60	}
 61	grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
 62	if err != nil {
 63		return repo, c.fail(protocol.ExitFailure, "checking access: %v", err)
 64	}
 65	if !check(c.User, repo, grant) {
 66		if !policy.CanRead(c.User, repo, grant) {
 67			// Invisible repos 404, per the enumeration rule.
 68			return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
 69		}
 70		return repo, c.fail(protocol.ExitDenied, "permission denied on %s", path)
 71	}
 72	return repo, -1
 73}
 74
 75func runRepoCreate(c *Ctx, args []string) int {
 76	visibility := "public"
 77	var path string
 78	for _, a := range args {
 79		switch a {
 80		case "--private":
 81			visibility = "private"
 82		default:
 83			if path != "" {
 84				return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private]")
 85			}
 86			path = a
 87		}
 88	}
 89	owner, name, ok := strings.Cut(path, "/")
 90	if !ok {
 91		return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private]")
 92	}
 93	if owner != c.User.Username {
 94		return c.fail(protocol.ExitDenied, "cannot create repositories under %q (orgs not yet supported)", owner)
 95	}
 96	if err := policyValidateRepoName(name); err != nil {
 97		return c.fail(protocol.ExitUsage, "%v", err)
 98	}
 99	id, err := c.Store.CreateRepo("user", c.User.ID, name, visibility)
100	if err != nil {
101		return c.fail(protocol.ExitFailure, "%v", err)
102	}
103	dir := RepoDir(c.Cfg.Server.Root, owner, name)
104	if err := gitutil.InitBare(dir, "main", HooksDir(c.Cfg.Server.Root)); err != nil {
105		c.Store.DeleteRepo(id)
106		return c.fail(protocol.ExitFailure, "initializing repository: %v", err)
107	}
108	type out struct {
109		Path       string `json:"path"`
110		Visibility string `json:"visibility"`
111		SSHURL     string `json:"ssh_url"`
112	}
113	d := out{Path: path, Visibility: visibility, SSHURL: "ssh://git@" + hostOf(c.Cfg.Server.SiteURL) + "/" + path + ".git"}
114	return c.emit(d, func(w io.Writer) {
115		fmt.Fprintf(w, "created %s (%s)\nclone: git clone %s\n", d.Path, d.Visibility, d.SSHURL)
116	})
117}
118
119func policyValidateRepoName(name string) error { return policy.ValidateName(name) }
120
121func hostOf(siteURL string) string {
122	s := strings.TrimPrefix(strings.TrimPrefix(siteURL, "https://"), "http://")
123	return strings.TrimSuffix(s, "/")
124}
125
126func runRepoList(c *Ctx, args []string) int {
127	repos, err := c.Store.ListReposForUser(c.User.ID)
128	if err != nil {
129		return c.fail(protocol.ExitFailure, "%v", err)
130	}
131	type out struct {
132		Path       string `json:"path"`
133		Visibility string `json:"visibility"`
134	}
135	var ds []out
136	for _, r := range repos {
137		ds = append(ds, out{r.Path(), r.Visibility})
138	}
139	return c.emit(ds, func(w io.Writer) {
140		for _, d := range ds {
141			fmt.Fprintf(w, "%s\t%s\n", d.Path, d.Visibility)
142		}
143	})
144}
145
146func runRepoShow(c *Ctx, args []string) int {
147	if len(args) != 1 {
148		return c.fail(protocol.ExitUsage, "usage: repo show <owner/name>")
149	}
150	repo, code := resolveRepo(c, args[0], policy.CanRead)
151	if code >= 0 {
152		return code
153	}
154	type out struct {
155		Path              string   `json:"path"`
156		Visibility        string   `json:"visibility"`
157		DefaultBranch     string   `json:"default_branch"`
158		ProtectedBranches []string `json:"protected_branches,omitempty"`
159	}
160	d := out{repo.Path(), repo.Visibility, repo.DefaultBranch, repo.Settings.ProtectedBranches}
161	return c.emit(d, func(w io.Writer) {
162		fmt.Fprintf(w, "%s\t%s\tdefault: %s\n", d.Path, d.Visibility, d.DefaultBranch)
163		if len(d.ProtectedBranches) > 0 {
164			fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", "))
165		}
166	})
167}
168
169func runRepoDelete(c *Ctx, args []string) int {
170	var path string
171	var yes bool
172	for _, a := range args {
173		if a == "--yes" {
174			yes = true
175		} else if path == "" {
176			path = a
177		} else {
178			return c.fail(protocol.ExitUsage, "usage: repo delete <owner/name> --yes")
179		}
180	}
181	if path == "" {
182		return c.fail(protocol.ExitUsage, "usage: repo delete <owner/name> --yes")
183	}
184	repo, code := resolveRepo(c, path, policy.CanAdmin)
185	if code >= 0 {
186		return code
187	}
188	if !yes {
189		return c.fail(protocol.ExitUsage, "repo delete is permanent; re-run with --yes")
190	}
191	// Open MRs sourced from this repo keep working (targets own the
192	// objects) but must show that the source is gone.
193	if err := c.Store.MarkSourceGoneForRepo(repo.ID); err != nil {
194		return c.fail(protocol.ExitFailure, "%v", err)
195	}
196	if err := c.Store.DeleteRepo(repo.ID); err != nil {
197		return c.fail(protocol.ExitFailure, "%v", err)
198	}
199	if err := os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)); err != nil {
200		return c.fail(protocol.ExitFailure, "database row removed but disk cleanup failed: %v", err)
201	}
202	return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) {
203		fmt.Fprintf(w, "deleted %s\n", repo.Path())
204	})
205}
206
207func runAccessGrant(c *Ctx, args []string) int {
208	if len(args) != 3 || !slices.Contains([]string{"read", "write", "admin"}, args[2]) {
209		return c.fail(protocol.ExitUsage, "usage: repo access grant <owner/name> <user> read|write|admin")
210	}
211	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
212	if code >= 0 {
213		return code
214	}
215	target, err := c.Store.UserByUsername(args[1])
216	if err != nil {
217		return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
218	}
219	if err := c.Store.GrantAccess(repo.ID, target.ID, args[2]); err != nil {
220		return c.fail(protocol.ExitFailure, "%v", err)
221	}
222	return c.emit(map[string]string{"granted": args[2], "user": target.Username},
223		func(w io.Writer) { fmt.Fprintf(w, "granted %s to %s on %s\n", args[2], target.Username, repo.Path()) })
224}
225
226func runAccessRevoke(c *Ctx, args []string) int {
227	if len(args) != 2 {
228		return c.fail(protocol.ExitUsage, "usage: repo access revoke <owner/name> <user>")
229	}
230	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
231	if code >= 0 {
232		return code
233	}
234	target, err := c.Store.UserByUsername(args[1])
235	if err != nil {
236		return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
237	}
238	if err := c.Store.RevokeAccess(repo.ID, target.ID); err != nil {
239		if errors.Is(err, store.ErrNotFound) {
240			return c.fail(protocol.ExitNotFound, "%s has no grant on %s", target.Username, repo.Path())
241		}
242		return c.fail(protocol.ExitFailure, "%v", err)
243	}
244	return c.emit(map[string]string{"revoked": target.Username},
245		func(w io.Writer) { fmt.Fprintf(w, "revoked %s on %s\n", target.Username, repo.Path()) })
246}
247
248func runAccessList(c *Ctx, args []string) int {
249	if len(args) != 1 {
250		return c.fail(protocol.ExitUsage, "usage: repo access list <owner/name>")
251	}
252	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
253	if code >= 0 {
254		return code
255	}
256	entries, err := c.Store.ListAccess(repo.ID)
257	if err != nil {
258		return c.fail(protocol.ExitFailure, "%v", err)
259	}
260	type out struct {
261		User string `json:"user"`
262		Role string `json:"role"`
263	}
264	var ds []out
265	for _, e := range entries {
266		ds = append(ds, out{e.Username, e.Role})
267	}
268	return c.emit(ds, func(w io.Writer) {
269		for _, d := range ds {
270			fmt.Fprintf(w, "%s\t%s\n", d.User, d.Role)
271		}
272	})
273}
274
275func runSettingsShow(c *Ctx, args []string) int {
276	if len(args) != 1 {
277		return c.fail(protocol.ExitUsage, "usage: repo settings show <owner/name>")
278	}
279	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
280	if code >= 0 {
281		return code
282	}
283	return c.emit(repo.Settings, func(w io.Writer) {
284		fmt.Fprintf(w, "protected_branches: %s\nrequire_signed_commits: %v\ngit_daemon: %v\n",
285			strings.Join(repo.Settings.ProtectedBranches, ", "), repo.Settings.RequireSignedCommits, repo.Settings.GitDaemon)
286	})
287}
288
289func runGitDaemon(c *Ctx, args []string) int {
290	if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
291		return c.fail(protocol.ExitUsage, "usage: repo settings git-daemon <owner/name> on|off")
292	}
293	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
294	if code >= 0 {
295		return code
296	}
297	on := args[1] == "on"
298	if on && repo.Visibility != "public" {
299		return c.fail(protocol.ExitUsage, "git:// serves only public repositories; %s is private", repo.Path())
300	}
301	if on && !c.Cfg.GitDaemon.Enabled {
302		return c.fail(protocol.ExitUsage, "this instance does not run the git:// daemon ([git_daemon] enabled = false)")
303	}
304	s := repo.Settings
305	s.GitDaemon = on
306	if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
307		return c.fail(protocol.ExitFailure, "%v", err)
308	}
309	return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "git-daemon %s on %s\n", args[1], repo.Path()) })
310}
311
312func runProtect(c *Ctx, args []string) int   { return setProtect(c, args, true) }
313func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) }
314
315func setProtect(c *Ctx, args []string, protect bool) int {
316	if len(args) != 2 {
317		return c.fail(protocol.ExitUsage, "usage: repo settings protect|unprotect <owner/name> <branch>")
318	}
319	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
320	if code >= 0 {
321		return code
322	}
323	branch := args[1]
324	s := repo.Settings
325	has := slices.Contains(s.ProtectedBranches, branch)
326	if protect && !has {
327		s.ProtectedBranches = append(s.ProtectedBranches, branch)
328		slices.Sort(s.ProtectedBranches)
329	}
330	if !protect && has {
331		s.ProtectedBranches = slices.DeleteFunc(s.ProtectedBranches, func(b string) bool { return b == branch })
332	}
333	if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
334		return c.fail(protocol.ExitFailure, "%v", err)
335	}
336	verb := "protected"
337	if !protect {
338		verb = "unprotected"
339	}
340	return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%s %s on %s\n", verb, branch, repo.Path()) })
341}