internal/control/repo.go

73c8e291f76ca4383165230e62a311bab3d4fa74
gitbay/internal/control/repo.go history · blame · raw

869 lines · 30265 bytes

  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", "visibility"},
 52		Summary: "set repository visibility: repo settings visibility <owner/name> public|private", Run: runSetVisibility})
 53	register(Command{Path: []string{"repo", "settings", "website"},
 54		Summary: "set the repository website: repo settings website <owner/name> <url> ('' clears)", Run: runSetWebsite})
 55	register(Command{Path: []string{"repo", "settings", "git-daemon"},
 56		Summary: "expose over git://: repo settings git-daemon <owner/name> on|off", Run: runGitDaemon})
 57	register(Command{Path: []string{"repo", "archive"},
 58		Summary: "archive a repository (read-only: pushes and issue/MR writes refused): repo archive <owner/name>", Run: runArchive})
 59	register(Command{Path: []string{"repo", "unarchive"},
 60		Summary: "unarchive a repository: repo unarchive <owner/name>", Run: runUnarchive})
 61	register(Command{Path: []string{"repo", "topics"},
 62		Summary: "list topics: repo topics <owner/name>", ReadOnly: true, Run: runTopicsList})
 63	register(Command{Path: []string{"repo", "topics", "add"},
 64		Summary: "add topics: repo topics add <owner/name> <topic>...", Run: runTopicsAdd})
 65	register(Command{Path: []string{"repo", "topics", "remove"},
 66		Summary: "remove topics: repo topics remove <owner/name> <topic>...", Run: runTopicsRemove})
 67	register(Command{Path: []string{"repo", "search"},
 68		Summary: "find repositories by name, description, or topic: repo search <query>", ReadOnly: true, Run: runRepoSearch})
 69	register(Command{Path: []string{"repo", "grep"},
 70		Summary: "search file contents: repo grep <owner/name> <query> [--ref <ref>]", ReadOnly: true, Run: runRepoGrep})
 71	register(Command{Path: []string{"repo", "pin"},
 72		Summary: "pin a repository to your dashboard: repo pin <owner/name>", Run: runRepoPin})
 73	register(Command{Path: []string{"repo", "unpin"},
 74		Summary: "unpin a repository: repo unpin <owner/name>", Run: runRepoUnpin})
 75}
 76
 77const (
 78	minQueryLen    = 2
 79	maxQueryLen    = 200
 80	maxGrepMatches = 200
 81)
 82
 83func validQuery(q string) error {
 84	if len(q) < minQueryLen || len(q) > maxQueryLen {
 85		return fmt.Errorf("query must be %d to %d characters", minQueryLen, maxQueryLen)
 86	}
 87	return nil
 88}
 89
 90// refuseArchived blocks content writes (pushes are refused in the transport
 91// layer) on archived repositories. Settings, access, and lifecycle commands
 92// stay available so an archived repo can be managed and unarchived.
 93func refuseArchived(c *Ctx, repo store.Repo) int {
 94	if repo.Settings.Archived {
 95		return c.fail(protocol.ExitDenied, "%s is archived and read-only", repo.Path())
 96	}
 97	return -1
 98}
 99
100// resolveRepo loads a repo and checks the given permission for c.User.
101func resolveRepo(c *Ctx, path string, check func(store.User, store.Repo, string) bool) (store.Repo, int) {
102	repo, err := c.Store.RepoByPath(path)
103	if err != nil {
104		if errors.Is(err, store.ErrNotFound) {
105			// Same message whether it doesn't exist or is invisible.
106			return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
107		}
108		return repo, c.fail(protocol.ExitFailure, "loading repository: %v", err)
109	}
110	grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
111	if err != nil {
112		return repo, c.fail(protocol.ExitFailure, "checking access: %v", err)
113	}
114	if !check(c.User, repo, grant) {
115		if !policy.CanRead(c.User, repo, grant) {
116			// Invisible repos 404, per the enumeration rule.
117			return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
118		}
119		return repo, c.fail(protocol.ExitDenied, "permission denied on %s", path)
120	}
121	return repo, -1
122}
123
124func runRepoCreate(c *Ctx, args []string) int {
125	visibility := "public"
126	var path, description string
127	for i := 0; i < len(args); i++ {
128		switch args[i] {
129		case "--private":
130			visibility = "private"
131		case "--description":
132			if i+1 >= len(args) {
133				return c.fail(protocol.ExitUsage, "--description requires a value")
134			}
135			description = args[i+1]
136			i++
137		default:
138			if path != "" {
139				return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private] [--description <text>]")
140			}
141			path = args[i]
142		}
143	}
144	owner, name, ok := strings.Cut(path, "/")
145	if !ok {
146		return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private]")
147	}
148	if err := policyValidateRepoName(name); err != nil {
149		return c.fail(protocol.ExitUsage, "%v", err)
150	}
151	ownerKind, ownerID := "user", c.User.ID
152	if owner != c.User.Username {
153		org, err := c.Store.OrgByName(owner)
154		if err != nil {
155			return c.fail(protocol.ExitDenied, "cannot create repositories under %q: not you and not an organization you can see", owner)
156		}
157		role, err := c.Store.OrgRole(org.ID, c.User.ID)
158		if err != nil {
159			return c.fail(protocol.ExitFailure, "%v", err)
160		}
161		if role != "admin" {
162			return c.fail(protocol.ExitDenied, "only admins of %s can create repositories there", owner)
163		}
164		ownerKind, ownerID = "org", org.ID
165	}
166	id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility)
167	if err != nil {
168		return c.fail(protocol.ExitFailure, "%v", err)
169	}
170	dir := RepoDir(c.Cfg.Server.Root, owner, name)
171	if err := gitutil.InitBare(dir, "main", HooksDir(c.Cfg.Server.Root)); err != nil {
172		c.Store.DeleteRepo(id)
173		return c.fail(protocol.ExitFailure, "initializing repository: %v", err)
174	}
175	if description != "" {
176		if err := gitutil.WriteDescription(dir, description); err != nil {
177			return c.fail(protocol.ExitFailure, "writing description: %v", err)
178		}
179	}
180	type out struct {
181		Path       string `json:"path"`
182		Visibility string `json:"visibility"`
183		SSHURL     string `json:"ssh_url"`
184	}
185	d := out{Path: path, Visibility: visibility, SSHURL: "ssh://git@" + hostOf(c.Cfg.Server.SiteURL) + "/" + path + ".git"}
186	return c.emit(d, func(w io.Writer) {
187		fmt.Fprintf(w, "created %s (%s)\nclone: git clone %s\n", d.Path, d.Visibility, d.SSHURL)
188	})
189}
190
191func policyValidateRepoName(name string) error { return policy.ValidateName(name) }
192
193func hostOf(siteURL string) string {
194	s := strings.TrimPrefix(strings.TrimPrefix(siteURL, "https://"), "http://")
195	return strings.TrimSuffix(s, "/")
196}
197
198func runRepoList(c *Ctx, args []string) int {
199	repos, err := c.Store.ListReposForUser(c.User.ID)
200	if err != nil {
201		return c.fail(protocol.ExitFailure, "%v", err)
202	}
203	type out struct {
204		Path        string `json:"path"`
205		Visibility  string `json:"visibility"`
206		Description string `json:"description,omitempty"`
207		Archived    bool   `json:"archived,omitempty"`
208	}
209	var ds []out
210	for _, r := range repos {
211		desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
212		ds = append(ds, out{r.Path(), r.Visibility, desc, r.Settings.Archived})
213	}
214	return c.emit(ds, func(w io.Writer) {
215		for _, d := range ds {
216			mark := ""
217			if d.Archived {
218				mark = "\t[archived]"
219			}
220			fmt.Fprintf(w, "%s\t%s\t%s%s\n", d.Path, d.Visibility, d.Description, mark)
221		}
222	})
223}
224
225func runRepoShow(c *Ctx, args []string) int {
226	if len(args) != 1 {
227		return c.fail(protocol.ExitUsage, "usage: repo show <owner/name>")
228	}
229	repo, code := resolveRepo(c, args[0], policy.CanRead)
230	if code >= 0 {
231		return code
232	}
233	type mirrorOut struct {
234		Direction string `json:"direction"`
235		URL       string `json:"url"`
236		Pending   bool   `json:"pending"`
237		LastSync  string `json:"last_sync,omitempty"`
238		LastError string `json:"last_error,omitempty"`
239	}
240	type out struct {
241		Path              string      `json:"path"`
242		Description       string      `json:"description,omitempty"`
243		Website           string      `json:"website,omitempty"`
244		Visibility        string      `json:"visibility"`
245		DefaultBranch     string      `json:"default_branch"`
246		ProtectedBranches []string    `json:"protected_branches,omitempty"`
247		Archived          bool        `json:"archived,omitempty"`
248		Topics            []string    `json:"topics,omitempty"`
249		Domains           []string    `json:"domains,omitempty"`
250		Mirrors           []mirrorOut `json:"mirrors,omitempty"`
251	}
252	desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name))
253	topics, err := c.Store.ListTopics(repo.ID)
254	if err != nil {
255		return c.fail(protocol.ExitFailure, "%v", err)
256	}
257	var domains []string
258	if ds, err := c.Store.ListPageDomains(repo.ID); err == nil {
259		for _, pd := range ds {
260			if pd.Verified() {
261				domains = append(domains, pd.Domain)
262			}
263		}
264	}
265	d := out{repo.Path(), desc, repo.Settings.Website, repo.Visibility, repo.DefaultBranch,
266		repo.Settings.ProtectedBranches, repo.Settings.Archived, topics, domains, nil}
267	// Mirror status is admin-only, like repo mirror list. The token never
268	// leaves the server.
269	if grant, err := c.Store.AccessRole(repo.ID, c.User.ID); err == nil && policy.CanAdmin(c.User, repo, grant) {
270		ms, err := c.Store.ListMirrors(repo.ID)
271		if err != nil {
272			return c.fail(protocol.ExitFailure, "%v", err)
273		}
274		for _, m := range ms {
275			d.Mirrors = append(d.Mirrors, mirrorOut{m.Direction, m.URL, m.Dirty, m.LastSync, m.LastError})
276		}
277	}
278	return c.emit(d, func(w io.Writer) {
279		line := fmt.Sprintf("%s\t%s\tdefault: %s", d.Path, d.Visibility, d.DefaultBranch)
280		if d.Archived {
281			line += "\t[archived]"
282		}
283		fmt.Fprintln(w, line)
284		if d.Description != "" {
285			fmt.Fprintf(w, "%s\n", d.Description)
286		}
287		if d.Website != "" {
288			fmt.Fprintf(w, "website: %s\n", d.Website)
289		}
290		if len(d.Topics) > 0 {
291			fmt.Fprintf(w, "topics: %s\n", strings.Join(d.Topics, ", "))
292		}
293		if len(d.ProtectedBranches) > 0 {
294			fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", "))
295		}
296		if len(d.Domains) > 0 {
297			fmt.Fprintf(w, "pages domains: %s\n", strings.Join(d.Domains, ", "))
298		}
299		for _, m := range d.Mirrors {
300			status := "ok"
301			if m.Pending {
302				status = "pending"
303			}
304			if m.LastError != "" {
305				status = "error: " + m.LastError
306			}
307			fmt.Fprintf(w, "mirror: %s %s\tlast %s\t%s\n", m.Direction, m.URL, orDash(m.LastSync), status)
308		}
309	})
310}
311
312func runRepoTransfer(c *Ctx, args []string) int {
313	if len(args) != 2 {
314		return c.fail(protocol.ExitUsage, "usage: repo transfer <owner/name> <new-owner>")
315	}
316	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
317	if code >= 0 {
318		return code
319	}
320	newOwner := args[1]
321	if newOwner == repo.OwnerName {
322		return c.fail(protocol.ExitUsage, "%s already owns this repository", newOwner)
323	}
324
325	// Target: yourself, or an org you admin — same rule as repo create.
326	newKind, newID := "", int64(0)
327	if newOwner == c.User.Username {
328		newKind, newID = "user", c.User.ID
329	} else if org, err := c.Store.OrgByName(newOwner); err == nil {
330		role, err := c.Store.OrgRole(org.ID, c.User.ID)
331		if err != nil {
332			return c.fail(protocol.ExitFailure, "%v", err)
333		}
334		if role != "admin" {
335			return c.fail(protocol.ExitDenied, "only admins of %s can receive repositories there", newOwner)
336		}
337		newKind, newID = "org", org.ID
338	} else {
339		return c.fail(protocol.ExitDenied, "cannot transfer to %q: not you and not an organization you can see", newOwner)
340	}
341
342	oldDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
343	newDir := RepoDir(c.Cfg.Server.Root, newOwner, repo.Name)
344	if _, err := os.Stat(newDir); err == nil {
345		return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", newOwner, repo.Name)
346	}
347	if err := c.Store.TransferRepo(repo.ID, newKind, newID); err != nil {
348		return c.fail(protocol.ExitUsage, "%v", err)
349	}
350	if err := os.MkdirAll(filepath.Dir(newDir), 0o750); err != nil {
351		c.Store.TransferRepo(repo.ID, repo.OwnerKind, repo.OwnerID)
352		return c.fail(protocol.ExitFailure, "%v", err)
353	}
354	if err := os.Rename(oldDir, newDir); err != nil {
355		// Keep name and disk consistent: revert the database change.
356		c.Store.TransferRepo(repo.ID, repo.OwnerKind, repo.OwnerID)
357		return c.fail(protocol.ExitFailure, "moving repository: %v", err)
358	}
359	// The wiki companion follows its repo.
360	oldWiki := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name+".wiki")
361	if _, err := os.Stat(oldWiki); err == nil {
362		os.Rename(oldWiki, RepoDir(c.Cfg.Server.Root, newOwner, repo.Name+".wiki"))
363	}
364	newPath := newOwner + "/" + repo.Name
365	return c.emit(map[string]string{"repo": newPath, "was": repo.Path()}, func(w io.Writer) {
366		fmt.Fprintf(w, "transferred %s to %s — clone URLs now use %s\n", repo.Path(), newPath, newPath)
367	})
368}
369
370func runRepoDelete(c *Ctx, args []string) int {
371	var path string
372	var yes bool
373	for _, a := range args {
374		if a == "--yes" {
375			yes = true
376		} else if path == "" {
377			path = a
378		} else {
379			return c.fail(protocol.ExitUsage, "usage: repo delete <owner/name> --yes")
380		}
381	}
382	if path == "" {
383		return c.fail(protocol.ExitUsage, "usage: repo delete <owner/name> --yes")
384	}
385	repo, code := resolveRepo(c, path, policy.CanAdmin)
386	if code >= 0 {
387		return code
388	}
389	if !yes {
390		return c.fail(protocol.ExitUsage, "repo delete is permanent; re-run with --yes")
391	}
392	// Open MRs sourced from this repo keep working (targets own the
393	// objects) but must show that the source is gone.
394	if err := c.Store.MarkSourceGoneForRepo(repo.ID); err != nil {
395		return c.fail(protocol.ExitFailure, "%v", err)
396	}
397	if err := c.Store.DeleteRepo(repo.ID); err != nil {
398		return c.fail(protocol.ExitFailure, "%v", err)
399	}
400	if err := os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)); err != nil {
401		return c.fail(protocol.ExitFailure, "database row removed but disk cleanup failed: %v", err)
402	}
403	os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name+".wiki"))
404	return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) {
405		fmt.Fprintf(w, "deleted %s\n", repo.Path())
406	})
407}
408
409func runAccessGrant(c *Ctx, args []string) int {
410	if len(args) != 3 || !slices.Contains([]string{"read", "write", "admin"}, args[2]) {
411		return c.fail(protocol.ExitUsage, "usage: repo access grant <owner/name> <user> read|write|admin")
412	}
413	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
414	if code >= 0 {
415		return code
416	}
417	target, err := c.Store.UserByUsername(args[1])
418	if err != nil {
419		return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
420	}
421	if err := c.Store.GrantAccess(repo.ID, target.ID, args[2]); err != nil {
422		return c.fail(protocol.ExitFailure, "%v", err)
423	}
424	return c.emit(map[string]string{"granted": args[2], "user": target.Username},
425		func(w io.Writer) { fmt.Fprintf(w, "granted %s to %s on %s\n", args[2], target.Username, repo.Path()) })
426}
427
428func runAccessRevoke(c *Ctx, args []string) int {
429	if len(args) != 2 {
430		return c.fail(protocol.ExitUsage, "usage: repo access revoke <owner/name> <user>")
431	}
432	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
433	if code >= 0 {
434		return code
435	}
436	target, err := c.Store.UserByUsername(args[1])
437	if err != nil {
438		return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
439	}
440	if err := c.Store.RevokeAccess(repo.ID, target.ID); err != nil {
441		if errors.Is(err, store.ErrNotFound) {
442			return c.fail(protocol.ExitNotFound, "%s has no grant on %s", target.Username, repo.Path())
443		}
444		return c.fail(protocol.ExitFailure, "%v", err)
445	}
446	return c.emit(map[string]string{"revoked": target.Username},
447		func(w io.Writer) { fmt.Fprintf(w, "revoked %s on %s\n", target.Username, repo.Path()) })
448}
449
450func runAccessList(c *Ctx, args []string) int {
451	if len(args) != 1 {
452		return c.fail(protocol.ExitUsage, "usage: repo access list <owner/name>")
453	}
454	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
455	if code >= 0 {
456		return code
457	}
458	entries, err := c.Store.ListAccess(repo.ID)
459	if err != nil {
460		return c.fail(protocol.ExitFailure, "%v", err)
461	}
462	type out struct {
463		User string `json:"user"`
464		Role string `json:"role"`
465	}
466	var ds []out
467	for _, e := range entries {
468		ds = append(ds, out{e.Username, e.Role})
469	}
470	return c.emit(ds, func(w io.Writer) {
471		for _, d := range ds {
472			fmt.Fprintf(w, "%s\t%s\n", d.User, d.Role)
473		}
474	})
475}
476
477func runSettingsShow(c *Ctx, args []string) int {
478	if len(args) != 1 {
479		return c.fail(protocol.ExitUsage, "usage: repo settings show <owner/name>")
480	}
481	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
482	if code >= 0 {
483		return code
484	}
485	return c.emit(repo.Settings, func(w io.Writer) {
486		fmt.Fprintf(w, "protected_branches: %s\nrequire_signed_commits: %v\ngit_daemon: %v\narchived: %v\n",
487			strings.Join(repo.Settings.ProtectedBranches, ", "), repo.Settings.RequireSignedCommits, repo.Settings.GitDaemon, repo.Settings.Archived)
488	})
489}
490
491func runSetDescription(c *Ctx, args []string) int {
492	if len(args) != 2 {
493		return c.fail(protocol.ExitUsage, "usage: repo settings description <owner/name> <text>")
494	}
495	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
496	if code >= 0 {
497		return code
498	}
499	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
500	if err := gitutil.WriteDescription(dir, args[1]); err != nil {
501		return c.fail(protocol.ExitFailure, "%v", err)
502	}
503	return c.emit(map[string]string{"description": gitutil.ReadDescription(dir)}, func(w io.Writer) {
504		fmt.Fprintf(w, "description set on %s\n", repo.Path())
505	})
506}
507
508func runSetWebsite(c *Ctx, args []string) int {
509	if len(args) != 2 {
510		return c.fail(protocol.ExitUsage, "usage: repo settings website <owner/name> <url>")
511	}
512	site := strings.TrimSpace(args[1])
513	if err := validateWebsite(site); err != nil {
514		return c.fail(protocol.ExitUsage, "%v", err)
515	}
516	if len(site) > 256 {
517		return c.fail(protocol.ExitUsage, "website URL too long (max 256)")
518	}
519	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
520	if code >= 0 {
521		return code
522	}
523	s := repo.Settings
524	s.Website = site
525	if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
526		return c.fail(protocol.ExitFailure, "%v", err)
527	}
528	return c.emit(map[string]string{"website": site}, func(w io.Writer) {
529		if site == "" {
530			fmt.Fprintf(w, "website cleared on %s\n", repo.Path())
531		} else {
532			fmt.Fprintf(w, "website set on %s\n", repo.Path())
533		}
534	})
535}
536
537func runSetVisibility(c *Ctx, args []string) int {
538	if len(args) != 2 || (args[1] != "public" && args[1] != "private") {
539		return c.fail(protocol.ExitUsage, "usage: repo settings visibility <owner/name> public|private")
540	}
541	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
542	if code >= 0 {
543		return code
544	}
545	if repo.Visibility == args[1] {
546		return c.emit(map[string]string{"visibility": args[1]}, func(w io.Writer) {
547			fmt.Fprintf(w, "%s is already %s\n", repo.Path(), args[1])
548		})
549	}
550	if err := c.Store.SetRepoVisibility(repo.ID, args[1]); err != nil {
551		return c.fail(protocol.ExitFailure, "%v", err)
552	}
553	// Going private takes the repository off every anonymous surface, so
554	// git:// exposure cannot outlive the change.
555	if args[1] == "private" && repo.Settings.GitDaemon {
556		s := repo.Settings
557		s.GitDaemon = false
558		c.Store.SetRepoSettings(repo.ID, s)
559	}
560	c.Store.Audit(c.User.ID, "repo.visibility", map[string]any{"repo": repo.ID, "visibility": args[1]})
561	return c.emit(map[string]string{"visibility": args[1]}, func(w io.Writer) {
562		fmt.Fprintf(w, "%s is now %s\n", repo.Path(), args[1])
563	})
564}
565
566func runGitDaemon(c *Ctx, args []string) int {
567	if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
568		return c.fail(protocol.ExitUsage, "usage: repo settings git-daemon <owner/name> on|off")
569	}
570	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
571	if code >= 0 {
572		return code
573	}
574	on := args[1] == "on"
575	if on && repo.Visibility != "public" {
576		return c.fail(protocol.ExitUsage, "git:// serves only public repositories; %s is private", repo.Path())
577	}
578	if on && !c.Cfg.GitDaemon.Enabled {
579		return c.fail(protocol.ExitUsage, "this instance does not run the git:// daemon ([git_daemon] enabled = false)")
580	}
581	s := repo.Settings
582	s.GitDaemon = on
583	if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
584		return c.fail(protocol.ExitFailure, "%v", err)
585	}
586	return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "git-daemon %s on %s\n", args[1], repo.Path()) })
587}
588
589func runArchive(c *Ctx, args []string) int   { return setArchived(c, args, true) }
590func runUnarchive(c *Ctx, args []string) int { return setArchived(c, args, false) }
591
592func setArchived(c *Ctx, args []string, archived bool) int {
593	verb := "archive"
594	if !archived {
595		verb = "unarchive"
596	}
597	if len(args) != 1 {
598		return c.fail(protocol.ExitUsage, "usage: repo %s <owner/name>", verb)
599	}
600	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
601	if code >= 0 {
602		return code
603	}
604	if repo.Settings.Archived == archived {
605		return c.fail(protocol.ExitUsage, "%s is already %sd", repo.Path(), verb)
606	}
607	s := repo.Settings
608	s.Archived = archived
609	if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
610		return c.fail(protocol.ExitFailure, "%v", err)
611	}
612	c.Store.RecordEvent(repo.ID, c.User.ID, "repo."+verb+"d", "{}")
613	return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%sd %s\n", verb, repo.Path()) })
614}
615
616func runTopicsList(c *Ctx, args []string) int {
617	if len(args) != 1 {
618		return c.fail(protocol.ExitUsage, "usage: repo topics <owner/name>")
619	}
620	repo, code := resolveRepo(c, args[0], policy.CanRead)
621	if code >= 0 {
622		return code
623	}
624	topics, err := c.Store.ListTopics(repo.ID)
625	if err != nil {
626		return c.fail(protocol.ExitFailure, "%v", err)
627	}
628	return c.emit(topics, func(w io.Writer) {
629		for _, t := range topics {
630			fmt.Fprintln(w, t)
631		}
632	})
633}
634
635func runTopicsAdd(c *Ctx, args []string) int    { return editTopics(c, args, true) }
636func runTopicsRemove(c *Ctx, args []string) int { return editTopics(c, args, false) }
637
638func editTopics(c *Ctx, args []string, add bool) int {
639	verb := "add"
640	if !add {
641		verb = "remove"
642	}
643	if len(args) < 2 {
644		return c.fail(protocol.ExitUsage, "usage: repo topics %s <owner/name> <topic>...", verb)
645	}
646	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
647	if code >= 0 {
648		return code
649	}
650	topics := args[1:]
651	if add {
652		for _, t := range topics {
653			if err := policy.ValidateTopic(t); err != nil {
654				return c.fail(protocol.ExitUsage, "%v", err)
655			}
656		}
657		have, err := c.Store.ListTopics(repo.ID)
658		if err != nil {
659			return c.fail(protocol.ExitFailure, "%v", err)
660		}
661		added := 0
662		for _, t := range topics {
663			if !slices.Contains(have, t) {
664				added++
665			}
666		}
667		if len(have)+added > policy.MaxTopics {
668			return c.fail(protocol.ExitUsage, "a repository can have at most %d topics", policy.MaxTopics)
669		}
670		for _, t := range topics {
671			if err := c.Store.AddTopic(repo.ID, t); err != nil {
672				return c.fail(protocol.ExitFailure, "%v", err)
673			}
674		}
675	} else {
676		for _, t := range topics {
677			if err := c.Store.RemoveTopic(repo.ID, t); err != nil {
678				if errors.Is(err, store.ErrNotFound) {
679					return c.fail(protocol.ExitNotFound, "%s has no topic %q", repo.Path(), t)
680				}
681				return c.fail(protocol.ExitFailure, "%v", err)
682			}
683		}
684	}
685	now, err := c.Store.ListTopics(repo.ID)
686	if err != nil {
687		return c.fail(protocol.ExitFailure, "%v", err)
688	}
689	return c.emit(now, func(w io.Writer) {
690		fmt.Fprintf(w, "topics on %s: %s\n", repo.Path(), strings.Join(now, ", "))
691	})
692}
693
694// runRepoSearch matches the query against name, owner/name, description,
695// and topics of every repository the caller can see.
696func runRepoSearch(c *Ctx, args []string) int {
697	if len(args) != 1 {
698		return c.fail(protocol.ExitUsage, "usage: repo search <query>")
699	}
700	if err := validQuery(args[0]); err != nil {
701		return c.fail(protocol.ExitUsage, "%v", err)
702	}
703	q := strings.ToLower(args[0])
704
705	public, err := c.Store.ListPublicRepos()
706	if err != nil {
707		return c.fail(protocol.ExitFailure, "%v", err)
708	}
709	own, err := c.Store.ListReposForUser(c.User.ID)
710	if err != nil {
711		return c.fail(protocol.ExitFailure, "%v", err)
712	}
713	seen := map[int64]bool{}
714	type out struct {
715		Path        string   `json:"path"`
716		Visibility  string   `json:"visibility"`
717		Description string   `json:"description,omitempty"`
718		Topics      []string `json:"topics,omitempty"`
719	}
720	var ds []out
721	for _, r := range append(public, own...) {
722		if seen[r.ID] {
723			continue
724		}
725		seen[r.ID] = true
726		desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
727		topics, _ := c.Store.ListTopics(r.ID)
728		if !matchesRepo(q, r, desc, topics) {
729			continue
730		}
731		ds = append(ds, out{r.Path(), r.Visibility, desc, topics})
732	}
733	return c.emit(ds, func(w io.Writer) {
734		for _, d := range ds {
735			fmt.Fprintf(w, "%s\t%s\t%s\n", d.Path, d.Visibility, d.Description)
736		}
737	})
738}
739
740func matchesRepo(q string, r store.Repo, desc string, topics []string) bool {
741	if strings.Contains(strings.ToLower(r.Path()), q) ||
742		strings.Contains(strings.ToLower(desc), q) {
743		return true
744	}
745	for _, t := range topics {
746		if strings.Contains(t, q) {
747			return true
748		}
749	}
750	return false
751}
752
753func runRepoGrep(c *Ctx, args []string) int {
754	var path, query, ref string
755	for i := 0; i < len(args); i++ {
756		switch args[i] {
757		case "--ref":
758			if i+1 >= len(args) {
759				return c.fail(protocol.ExitUsage, "--ref requires a value")
760			}
761			ref = args[i+1]
762			i++
763		default:
764			if path == "" {
765				path = args[i]
766			} else if query == "" {
767				query = args[i]
768			} else {
769				return c.fail(protocol.ExitUsage, "usage: repo grep <owner/name> <query> [--ref <ref>]")
770			}
771		}
772	}
773	if path == "" || query == "" {
774		return c.fail(protocol.ExitUsage, "usage: repo grep <owner/name> <query> [--ref <ref>]")
775	}
776	if err := validQuery(query); err != nil {
777		return c.fail(protocol.ExitUsage, "%v", err)
778	}
779	repo, code := resolveRepo(c, path, policy.CanRead)
780	if code >= 0 {
781		return code
782	}
783	if ref == "" {
784		ref = repo.DefaultBranch
785	}
786	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
787	if _, err := gitutil.ResolveRef(dir, ref); err != nil {
788		return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
789	}
790	matches, err := gitutil.Grep(dir, ref, query, maxGrepMatches)
791	if err != nil {
792		return c.fail(protocol.ExitFailure, "%v", err)
793	}
794	type out struct {
795		Path string `json:"path"`
796		Line int    `json:"line"`
797		Text string `json:"text"`
798	}
799	var ds []out
800	for _, m := range matches {
801		ds = append(ds, out{m.Path, m.Line, m.Text})
802	}
803	return c.emit(ds, func(w io.Writer) {
804		for _, d := range ds {
805			fmt.Fprintf(w, "%s:%d:%s\n", d.Path, d.Line, d.Text)
806		}
807	})
808}
809
810func runRepoPin(c *Ctx, args []string) int   { return setPinned(c, args, true) }
811func runRepoUnpin(c *Ctx, args []string) int { return setPinned(c, args, false) }
812
813func setPinned(c *Ctx, args []string, pin bool) int {
814	verb := "pin"
815	if !pin {
816		verb = "unpin"
817	}
818	if len(args) != 1 {
819		return c.fail(protocol.ExitUsage, "usage: repo %s <owner/name>", verb)
820	}
821	repo, code := resolveRepo(c, args[0], policy.CanRead)
822	if code >= 0 {
823		return code
824	}
825	if pin {
826		if err := c.Store.PinRepo(c.User.ID, repo.ID); err != nil {
827			return c.fail(protocol.ExitFailure, "%v", err)
828		}
829	} else if err := c.Store.UnpinRepo(c.User.ID, repo.ID); err != nil {
830		if errors.Is(err, store.ErrNotFound) {
831			return c.fail(protocol.ExitNotFound, "%s is not pinned", repo.Path())
832		}
833		return c.fail(protocol.ExitFailure, "%v", err)
834	}
835	return c.emit(map[string]string{verb + "ned": repo.Path()}, func(w io.Writer) {
836		fmt.Fprintf(w, "%sned %s\n", verb, repo.Path())
837	})
838}
839
840func runProtect(c *Ctx, args []string) int   { return setProtect(c, args, true) }
841func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) }
842
843func setProtect(c *Ctx, args []string, protect bool) int {
844	if len(args) != 2 {
845		return c.fail(protocol.ExitUsage, "usage: repo settings protect|unprotect <owner/name> <branch>")
846	}
847	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
848	if code >= 0 {
849		return code
850	}
851	branch := args[1]
852	s := repo.Settings
853	has := slices.Contains(s.ProtectedBranches, branch)
854	if protect && !has {
855		s.ProtectedBranches = append(s.ProtectedBranches, branch)
856		slices.Sort(s.ProtectedBranches)
857	}
858	if !protect && has {
859		s.ProtectedBranches = slices.DeleteFunc(s.ProtectedBranches, func(b string) bool { return b == branch })
860	}
861	if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
862		return c.fail(protocol.ExitFailure, "%v", err)
863	}
864	verb := "protected"
865	if !protect {
866		verb = "unprotected"
867	}
868	return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%s %s on %s\n", verb, branch, repo.Path()) })
869}