internal/control/repo.go
1282 lines · 44333 bytes
1package control
2
3import (
4 "errors"
5 "fmt"
6 "io"
7 "os"
8 "path"
9 "path/filepath"
10 "slices"
11 "strconv"
12 "strings"
13
14 "gitbay.org/gitbay/internal/gitutil"
15 "gitbay.org/gitbay/internal/policy"
16 "gitbay.org/gitbay/internal/protocol"
17 "gitbay.org/gitbay/internal/store"
18)
19
20// RepoDir returns the on-disk path for a repository.
21func RepoDir(root, owner, name string) string {
22 return filepath.Join(root, "repos", owner, name+".git")
23}
24
25// HooksDir is the shared core.hooksPath directory.
26func HooksDir(root string) string { return filepath.Join(root, "hooks") }
27
28func init() {
29 register(Command{Path: []string{"repo", "create"},
30 Summary: "create a repository",
31 Usage: "repo create <owner/name> [--private]",
32 Flags: []Flag{
33 {"--private", "", "create it private", ""},
34 },
35 Examples: []string{"repo create krz/newthing --private"},
36 Run: runRepoCreate})
37 register(Command{Path: []string{"repo", "list"},
38 Summary: "list repositories you own or can access",
39 Usage: "repo list [--limit <n>] [--cursor <c>]",
40 Flags: []Flag{
41 {"--limit", "<n>", "rows per page", ""},
42 {"--cursor", "<c>", "continue from the previous page", ""},
43 },
44 Examples: []string{"repo list --limit 20"},
45 ReadOnly: true, Run: runRepoList})
46 register(Command{Path: []string{"repo", "show"},
47 Summary: "show repository details",
48 Usage: "repo show <owner/name>",
49 Examples: []string{"repo show krz/gitbay"},
50 ReadOnly: true, Run: runRepoShow})
51 register(Command{Path: []string{"repo", "transfer"},
52 Summary: "move a repository to another owner",
53 Usage: "repo transfer <owner/name> <new-owner> (clone URLs change)",
54 Examples: []string{"repo transfer krz/gitbay krazywarez"},
55 Run: runRepoTransfer})
56 register(Command{Path: []string{"repo", "rename"},
57 Summary: "rename a repository",
58 Usage: "repo rename <owner/name> <new-name> (clone URLs change)",
59 Examples: []string{"repo rename krz/gitbay forge"},
60 Run: runRepoRename})
61 register(Command{Path: []string{"repo", "delete"},
62 Summary: "delete a repository",
63 Usage: "repo delete <owner/name> --yes",
64 Flags: []Flag{
65 {"--yes", "", "confirm the permanent delete", ""},
66 },
67 Examples: []string{"repo delete cmc/scratch --yes"},
68 Run: runRepoDelete})
69 register(Command{Path: []string{"repo", "access", "grant"},
70 Summary: "grant access",
71 Usage: "repo access grant <owner/name> <user> read|write|admin",
72 Examples: []string{"repo access grant krz/gitbay cmc write"},
73 Run: runAccessGrant})
74 register(Command{Path: []string{"repo", "access", "revoke"},
75 Summary: "revoke access",
76 Usage: "repo access revoke <owner/name> <user>",
77 Examples: []string{"repo access revoke krz/gitbay cmc"},
78 Run: runAccessRevoke})
79 register(Command{Path: []string{"repo", "access", "list"},
80 Summary: "list who can reach the repository, with the role and where it comes from",
81 Usage: "repo access list <owner/name>",
82 Examples: []string{"repo access list krz/gitbay"},
83 ReadOnly: true, Run: runAccessList})
84 register(Command{Path: []string{"repo", "settings", "show"},
85 Summary: "show settings",
86 Usage: "repo settings show <owner/name>",
87 Examples: []string{"repo settings show krz/gitbay"},
88 ReadOnly: true, Run: runSettingsShow})
89 register(Command{Path: []string{"repo", "settings", "protect"},
90 Summary: "protect a branch",
91 Usage: "repo settings protect <owner/name> <branch>",
92 Examples: []string{"repo settings protect krz/gitbay main"},
93 Run: runProtect})
94 register(Command{Path: []string{"repo", "settings", "unprotect"},
95 Summary: "unprotect a branch",
96 Usage: "repo settings unprotect <owner/name> <branch>",
97 Examples: []string{"repo settings unprotect krz/gitbay main"},
98 Run: runUnprotect})
99 register(Command{Path: []string{"repo", "settings", "protect-tag"},
100 Summary: "protect tags matching a glob (created once, never moved or deleted)",
101 Usage: "repo settings protect-tag <owner/name> <glob>",
102 Examples: []string{"repo settings protect-tag krz/gitbay 'v*'"},
103 Run: runProtectTag})
104 register(Command{Path: []string{"repo", "settings", "unprotect-tag"},
105 Summary: "drop a protected-tag glob",
106 Usage: "repo settings unprotect-tag <owner/name> <glob>",
107 Examples: []string{"repo settings unprotect-tag krz/gitbay 'v*'"},
108 Run: runUnprotectTag})
109 register(Command{Path: []string{"repo", "settings", "description"},
110 Summary: "set the repository description",
111 Usage: "repo settings description <owner/name> <text> ('' clears)",
112 Examples: []string{`repo settings description krz/gitbay "a CLI-first git forge"`},
113 Run: runSetDescription})
114 register(Command{Path: []string{"repo", "settings", "visibility"},
115 Summary: "set repository visibility",
116 Usage: "repo settings visibility <owner/name> public|private",
117 Examples: []string{"repo settings visibility krz/gitbay public"},
118 Run: runSetVisibility})
119 register(Command{Path: []string{"repo", "settings", "website"},
120 Summary: "set the repository website",
121 Usage: "repo settings website <owner/name> <url> ('' clears)",
122 Examples: []string{"repo settings website krz/gitbay https://gitbay.org"},
123 Run: runSetWebsite})
124 register(Command{Path: []string{"repo", "settings", "default-branch"},
125 Summary: "set the default branch",
126 Usage: "repo settings default-branch <owner/name> <branch>",
127 Examples: []string{"repo settings default-branch krz/gitbay main"},
128 Run: runSetDefaultBranch})
129 register(Command{Path: []string{"repo", "settings", "git-daemon"},
130 Summary: "expose over git://",
131 Usage: "repo settings git-daemon <owner/name> on|off",
132 Examples: []string{"repo settings git-daemon krz/gitbay on"},
133 Run: runGitDaemon})
134 register(Command{Path: []string{"repo", "archive"},
135 Summary: "archive a repository (read-only: pushes and issue/MR writes refused)",
136 Usage: "repo archive <owner/name>",
137 Examples: []string{"repo archive krz/gitbay"},
138 Run: runArchive})
139 register(Command{Path: []string{"repo", "unarchive"},
140 Summary: "unarchive a repository",
141 Usage: "repo unarchive <owner/name>",
142 Examples: []string{"repo unarchive krz/gitbay"},
143 Run: runUnarchive})
144 register(Command{Path: []string{"repo", "topics"},
145 Summary: "list topics",
146 Usage: "repo topics <owner/name>",
147 Examples: []string{"repo topics krz/gitbay"},
148 ReadOnly: true, Run: runTopicsList})
149 register(Command{Path: []string{"repo", "topics", "add"},
150 Summary: "add topics",
151 Usage: "repo topics add <owner/name> <topic>...",
152 Examples: []string{"repo topics add krz/gitbay git forge cli"},
153 Run: runTopicsAdd})
154 register(Command{Path: []string{"repo", "topics", "remove"},
155 Summary: "remove topics",
156 Usage: "repo topics remove <owner/name> <topic>...",
157 Examples: []string{"repo topics remove krz/gitbay cli"},
158 Run: runTopicsRemove})
159 register(Command{Path: []string{"repo", "search"},
160 Summary: "find repositories by name, description, or topic",
161 Usage: "repo search <query>",
162 Examples: []string{"repo search forge"},
163 ReadOnly: true, Run: runRepoSearch})
164 register(Command{Path: []string{"repo", "grep"},
165 Summary: "search file contents",
166 Usage: "repo grep <owner/name> <query> [--ref <ref>]",
167 Flags: []Flag{
168 {"--ref", "<ref>", "branch, tag or commit to search", "the default branch"},
169 },
170 Examples: []string{"repo grep krz/gitbay TODO"},
171 ReadOnly: true, Run: runRepoGrep})
172 register(Command{Path: []string{"repo", "diff"},
173 Summary: "the patch between two refs, from their merge base",
174 Usage: "repo diff <owner/name> <base> <head>",
175 Examples: []string{"repo diff krz/gitbay main cli-output-help"},
176 ReadOnly: true, Run: runRepoDiff})
177 register(Command{Path: []string{"repo", "pin"},
178 Summary: "pin a repository to your dashboard",
179 Usage: "repo pin <owner/name>",
180 Examples: []string{"repo pin krz/gitbay"},
181 Run: runRepoPin})
182 register(Command{Path: []string{"repo", "unpin"},
183 Summary: "unpin a repository",
184 Usage: "repo unpin <owner/name>",
185 Examples: []string{"repo unpin krz/gitbay"},
186 Run: runRepoUnpin})
187 register(Command{Path: []string{"repo", "bookmark"},
188 Summary: "bookmark a repository to come back to",
189 Usage: "repo bookmark <owner/name>",
190 Examples: []string{"repo bookmark krz/gitbay"},
191 Run: runRepoBookmark})
192 register(Command{Path: []string{"repo", "unbookmark"},
193 Summary: "remove a bookmark",
194 Usage: "repo unbookmark <owner/name>",
195 Examples: []string{"repo unbookmark krz/gitbay"},
196 Run: runRepoUnbookmark})
197 register(Command{Path: []string{"repo", "bookmarks"},
198 Summary: "list the repositories you have bookmarked",
199 Usage: "repo bookmarks",
200 Examples: []string{"repo bookmarks"},
201 ReadOnly: true, Run: runRepoBookmarks})
202}
203
204const (
205 minQueryLen = 2
206 maxQueryLen = 200
207 maxGrepMatches = 200
208)
209
210func validQuery(q string) error {
211 if len(q) < minQueryLen || len(q) > maxQueryLen {
212 return fmt.Errorf("query must be %d to %d characters", minQueryLen, maxQueryLen)
213 }
214 return nil
215}
216
217// refuseArchived blocks content writes (pushes are refused in the transport
218// layer) on archived repositories. Settings, access, and lifecycle commands
219// stay available so an archived repo can be managed and unarchived.
220func refuseArchived(c *Ctx, repo store.Repo) int {
221 if repo.Settings.Archived {
222 return c.fail(protocol.ExitDenied, "%s is archived and read-only; unarchive it first", repo.Path())
223 }
224 return -1
225}
226
227// resolveRepo loads a repo and checks the given permission for c.User.
228func resolveRepo(c *Ctx, path string, check func(store.User, store.Repo, string) bool) (store.Repo, int) {
229 repo, err := c.Store.RepoByPath(path)
230 if err != nil {
231 if errors.Is(err, store.ErrNotFound) {
232 // Same message whether it doesn't exist or is invisible.
233 return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
234 }
235 return repo, c.fail(protocol.ExitFailure, "loading repository: %v", err)
236 }
237 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
238 if err != nil {
239 return repo, c.fail(protocol.ExitFailure, "checking access: %v", err)
240 }
241 if !check(c.User, repo, grant) {
242 if !policy.CanRead(c.User, repo, grant) {
243 // Invisible repos 404, per the enumeration rule.
244 return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
245 }
246 return repo, c.fail(protocol.ExitDenied, "permission denied on %s; ask its owner for access", path)
247 }
248 return repo, -1
249}
250
251func runRepoCreate(c *Ctx, args []string) int {
252 f, err := parseFlags(args, flagSpec{Values: []string{"--description"}, Bools: []string{"--private"}, MaxPos: 1, Usage: "repo create <owner/name> [--private] [--description <text>]"})
253 if err != nil {
254 return c.fail(protocol.ExitUsage, "%v", err)
255 }
256 visibility, path, description := "public", f.pos(0), f.Value("--description")
257 if f.Has("--private") {
258 visibility = "private"
259 }
260 owner, name, ok := strings.Cut(path, "/")
261 if !ok {
262 return c.usage()
263 }
264 if err := policyValidateRepoName(name); err != nil {
265 return c.failInput(err)
266 }
267 ownerKind, ownerID, code := resolveNewRepoOwner(c, owner)
268 if code >= 0 {
269 return code
270 }
271 repoCreateMu.Lock()
272 if ownerKind == "user" {
273 if code := checkRepoQuota(c); code >= 0 {
274 repoCreateMu.Unlock()
275 return code
276 }
277 }
278 id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility)
279 repoCreateMu.Unlock()
280 if err != nil {
281 return c.fail(protocol.ExitFailure, "%v", err)
282 }
283 dir := RepoDir(c.Cfg.Server.Root, owner, name)
284 if err := gitutil.InitBare(dir, "main", HooksDir(c.Cfg.Server.Root)); err != nil {
285 c.Store.DeleteRepo(id)
286 return c.fail(protocol.ExitFailure, "initializing repository: %v", err)
287 }
288 if description != "" {
289 if err := gitutil.WriteDescription(dir, description); err != nil {
290 return c.fail(protocol.ExitFailure, "writing description: %v", err)
291 }
292 }
293 type out struct {
294 Path string `json:"path"`
295 Visibility string `json:"visibility"`
296 SSHURL string `json:"ssh_url"`
297 }
298 d := out{Path: path, Visibility: visibility, SSHURL: "ssh://git@" + hostOf(c.Cfg.Server.SiteURL) + "/" + path + ".git"}
299 return c.emit(d, func(w io.Writer) {
300 fmt.Fprintf(w, "created %s (%s)\nclone: git clone %s\n", d.Path, d.Visibility, d.SSHURL)
301 })
302}
303
304// resolveNewRepoOwner answers who a new repository belongs to: the
305// caller, or an organization they administer. The returned code is -1
306// when the owner is good, and the exit code to return otherwise.
307func resolveNewRepoOwner(c *Ctx, owner string) (kind string, id int64, code int) {
308 if owner == c.User.Username {
309 return "user", c.User.ID, -1
310 }
311 org, err := c.Store.OrgByName(owner)
312 if err != nil {
313 return "", 0, c.fail(protocol.ExitDenied, "cannot create repositories under %q: not you and not an organization you can see", owner)
314 }
315 role, err := c.Store.OrgRole(org.ID, c.User.ID)
316 if err != nil {
317 return "", 0, c.fail(protocol.ExitFailure, "%v", err)
318 }
319 if role != "admin" {
320 return "", 0, c.fail(protocol.ExitDenied, "only admins of %s can create repositories there", owner)
321 }
322 return "org", org.ID, -1
323}
324
325func policyValidateRepoName(name string) error { return policy.ValidateName(name) }
326
327func hostOf(siteURL string) string {
328 s := strings.TrimPrefix(strings.TrimPrefix(siteURL, "https://"), "http://")
329 return strings.TrimSuffix(s, "/")
330}
331
332func runRepoList(c *Ctx, args []string) int {
333 args, p, code := parsePageFlags(c, args, "repo", false)
334 if code >= 0 {
335 return code
336 }
337 if len(args) != 0 {
338 return c.usage()
339 }
340 repos, err := c.Store.ListReposForUser(c.User.ID, p.queryLimit(), p.key)
341 if err != nil {
342 return c.fail(protocol.ExitFailure, "%v", err)
343 }
344 repos, next := trimPage(p, repos, "repo", store.Repo.Path)
345 type out struct {
346 Path string `json:"path"`
347 Visibility string `json:"visibility"`
348 Description string `json:"description,omitempty"`
349 Archived bool `json:"archived,omitempty"`
350 }
351 var ds []out
352 for _, r := range repos {
353 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
354 ds = append(ds, out{r.Path(), r.Visibility, desc, r.Settings.Archived})
355 }
356 return c.emitPage(p, ds, next, func(w io.Writer) {
357 tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION")
358 for _, d := range ds {
359 cells := []cell{cRef(d.Path), cState(d.Visibility), cFlex(d.Description)}
360 if d.Archived {
361 cells = append(cells, cText("[archived]"))
362 }
363 tb.row(cells...)
364 }
365 tb.flush()
366 })
367}
368
369func runRepoShow(c *Ctx, args []string) int {
370 if len(args) != 1 {
371 return c.usage()
372 }
373 repo, code := resolveRepo(c, args[0], policy.CanRead)
374 if code >= 0 {
375 return code
376 }
377 type mirrorOut struct {
378 Direction string `json:"direction"`
379 URL string `json:"url"`
380 Pending bool `json:"pending"`
381 LastSync string `json:"last_sync,omitempty"`
382 LastError string `json:"last_error,omitempty"`
383 }
384 type out struct {
385 Path string `json:"path"`
386 Description string `json:"description,omitempty"`
387 Website string `json:"website,omitempty"`
388 Visibility string `json:"visibility"`
389 DefaultBranch string `json:"default_branch"`
390 ProtectedBranches []string `json:"protected_branches,omitempty"`
391 Archived bool `json:"archived,omitempty"`
392 Topics []string `json:"topics,omitempty"`
393 Domains []string `json:"domains,omitempty"`
394 Mirrors []mirrorOut `json:"mirrors,omitempty"`
395 // ForkOf names the parent only when the caller can read it: a
396 // private parent is not confirmed to exist, here as anywhere.
397 ForkOf string `json:"fork_of,omitempty"`
398 // Watch and Bookmarked are the caller's own state, so a client
399 // can draw a toggle rather than two stateless buttons (#178).
400 Watch string `json:"watch,omitempty"` // watching, muted, or absent
401 Bookmarked bool `json:"bookmarked,omitempty"`
402 }
403 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name))
404 topics, err := c.Store.ListTopics(repo.ID)
405 if err != nil {
406 return c.fail(protocol.ExitFailure, "%v", err)
407 }
408 var domains []string
409 if ds, err := c.Store.ListPageDomains(repo.ID); err == nil {
410 for _, pd := range ds {
411 if pd.Verified() {
412 domains = append(domains, pd.Domain)
413 }
414 }
415 }
416 d := out{Path: repo.Path(), Description: desc, Website: repo.Settings.Website, Visibility: repo.Visibility,
417 DefaultBranch: repo.DefaultBranch, ProtectedBranches: repo.Settings.ProtectedBranches,
418 Archived: repo.Settings.Archived, Topics: topics, Domains: domains}
419 if repo.ForkOf != 0 {
420 if parent, err := c.Store.RepoByID(repo.ForkOf); err == nil {
421 if grant, err := c.Store.AccessRole(parent.ID, c.User.ID); err == nil && policy.CanRead(c.User, parent, grant) {
422 d.ForkOf = parent.Path()
423 }
424 }
425 }
426 if c.User.ID != 0 {
427 d.Watch = c.Store.RepoWatchState(repo.ID, c.User.ID)
428 d.Bookmarked = c.Store.IsBookmarked(c.User.ID, repo.ID)
429 }
430 // Mirror status is admin-only, like repo mirror list. The token never
431 // leaves the server.
432 if grant, err := c.Store.AccessRole(repo.ID, c.User.ID); err == nil && policy.CanAdmin(c.User, repo, grant) {
433 ms, err := c.Store.ListMirrors(repo.ID)
434 if err != nil {
435 return c.fail(protocol.ExitFailure, "%v", err)
436 }
437 for _, m := range ms {
438 d.Mirrors = append(d.Mirrors, mirrorOut{m.Direction, m.URL, m.Dirty, m.LastSync, m.LastError})
439 }
440 }
441 return c.emit(d, func(w io.Writer) {
442 bookmarked, archived := "", ""
443 if d.Bookmarked {
444 bookmarked = "yes"
445 }
446 if d.Archived {
447 archived = "yes"
448 }
449 v := c.view(w)
450 v.title(d.Path, d.Description, d.Visibility)
451 v.fields(
452 "default branch", d.DefaultBranch,
453 "website", d.Website,
454 "topics", strings.Join(d.Topics, ", "),
455 "protected", strings.Join(d.ProtectedBranches, ", "),
456 "pages domains", strings.Join(d.Domains, ", "),
457 "fork of", d.ForkOf,
458 "watch", d.Watch,
459 "bookmarked", bookmarked,
460 "archived", archived,
461 "url", c.siteURL(d.Path),
462 )
463 if len(d.Mirrors) > 0 {
464 v.section("mirror")
465 tb := c.table(w, "DIRECTION", "URL", "LAST SYNC", "STATUS")
466 for _, m := range d.Mirrors {
467 status := "ok"
468 if m.Pending {
469 status = "pending"
470 }
471 if m.LastError != "" {
472 status = "error: " + m.LastError
473 }
474 tb.row(cText(m.Direction), cFlex(m.URL), cText(orDash(m.LastSync)), cState(status))
475 }
476 tb.flush()
477 }
478 })
479}
480
481func runRepoTransfer(c *Ctx, args []string) int {
482 if len(args) != 2 {
483 return c.usage()
484 }
485 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
486 if code >= 0 {
487 return code
488 }
489 newOwner := args[1]
490 if newOwner == repo.OwnerName {
491 return c.fail(protocol.ExitUsage, "%s already owns this repository", newOwner)
492 }
493
494 // Target: yourself, or an org you admin — same rule as repo create.
495 newKind, newID := "", int64(0)
496 if newOwner == c.User.Username {
497 newKind, newID = "user", c.User.ID
498 } else if org, err := c.Store.OrgByName(newOwner); err == nil {
499 role, err := c.Store.OrgRole(org.ID, c.User.ID)
500 if err != nil {
501 return c.fail(protocol.ExitFailure, "%v", err)
502 }
503 if role != "admin" {
504 return c.fail(protocol.ExitDenied, "only admins of %s can receive repositories there", newOwner)
505 }
506 newKind, newID = "org", org.ID
507 } else {
508 return c.fail(protocol.ExitDenied, "cannot transfer to %q: not you and not an organization you can see", newOwner)
509 }
510
511 oldDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
512 newDir := RepoDir(c.Cfg.Server.Root, newOwner, repo.Name)
513 if _, err := os.Stat(newDir); err == nil {
514 return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", newOwner, repo.Name)
515 }
516 // The directory moves before the record changes: a move that fails
517 // leaves nothing to undo, whereas the record's change into an org
518 // folds labels and milestones into the org's rows, which a revert
519 // cannot unfold (#212). A record that then fails moves the directory
520 // back, and says so if even that fails, since the operator then has
521 // a row pointing at a directory that is not there.
522 if err := os.MkdirAll(filepath.Dir(newDir), 0o750); err != nil {
523 return c.fail(protocol.ExitFailure, "%v", err)
524 }
525 if err := os.Rename(oldDir, newDir); err != nil {
526 return c.fail(protocol.ExitFailure, "moving repository: %v", err)
527 }
528 if err := c.Store.TransferRepo(repo.ID, newKind, newID); err != nil {
529 if rerr := os.Rename(newDir, oldDir); rerr != nil {
530 return c.fail(protocol.ExitFailure, "%v; and moving the directory back failed: %v (the record still names %s but the directory is now %s)", err, rerr, repo.Path(), newOwner+"/"+repo.Name)
531 }
532 return c.failErr(err)
533 }
534 newPath := newOwner + "/" + repo.Name
535 return c.emit(map[string]string{"repo": newPath, "was": repo.Path()}, func(w io.Writer) {
536 fmt.Fprintf(w, "transferred %s to %s — clone URLs now use %s\n", repo.Path(), newPath, newPath)
537 })
538}
539
540func runRepoRename(c *Ctx, args []string) int {
541 if len(args) != 2 {
542 return c.usage()
543 }
544 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
545 if code >= 0 {
546 return code
547 }
548 newName := args[1]
549 if newName == repo.Name {
550 return c.fail(protocol.ExitUsage, "%s is already named %s", repo.Path(), newName)
551 }
552 if err := policyValidateRepoName(newName); err != nil {
553 return c.failInput(err)
554 }
555 oldDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
556 newDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, newName)
557 if _, err := os.Stat(newDir); err == nil {
558 return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", repo.OwnerName, newName)
559 }
560 if err := c.Store.RenameRepo(repo.ID, newName); err != nil {
561 return c.failErr(err)
562 }
563 if err := os.Rename(oldDir, newDir); err != nil {
564 // Same rule as transfer: keep name and disk consistent, and say so
565 // if even the revert fails.
566 if rerr := c.Store.RenameRepo(repo.ID, repo.Name); rerr != nil {
567 return c.fail(protocol.ExitFailure, "moving repository: %v; and reverting the record failed: %v (the record now names %s/%s but the directory is still %s)", err, rerr, repo.OwnerName, newName, repo.Path())
568 }
569 return c.fail(protocol.ExitFailure, "moving repository: %v", err)
570 }
571 newPath := repo.OwnerName + "/" + newName
572 return c.emit(map[string]string{"repo": newPath, "was": repo.Path()}, func(w io.Writer) {
573 fmt.Fprintf(w, "renamed %s to %s — clone URLs now use %s\n", repo.Path(), newPath, newPath)
574 })
575}
576
577func runRepoDelete(c *Ctx, args []string) int {
578 var path string
579 var yes bool
580 for _, a := range args {
581 if a == "--yes" {
582 yes = true
583 } else if path == "" {
584 path = a
585 } else {
586 return c.usage()
587 }
588 }
589 if path == "" {
590 return c.usage()
591 }
592 repo, code := resolveRepo(c, path, policy.CanAdmin)
593 if code >= 0 {
594 return code
595 }
596 if !yes {
597 return c.fail(protocol.ExitUsage, "repo delete is permanent; re-run with --yes")
598 }
599 return deleteRepo(c, repo)
600}
601
602// deleteRepo removes a repository the caller has already been cleared to
603// delete: the database row, then the directory.
604//
605// There is deliberately no repo.deleted event. events.repo_id and
606// webhooks.repo_id both cascade from repos, so recording one would delete
607// it, and every webhook that could have subscribed, in the same
608// statement. A repository's deletion is not observable through its own
609// webhooks; an instance that needs to hear about it wants the audit log
610// (#112).
611func deleteRepo(c *Ctx, repo store.Repo) int {
612 // Open MRs sourced from this repo keep working (targets own the
613 // objects) but must show that the source is gone.
614 if err := c.Store.MarkSourceGoneForRepo(repo.ID); err != nil {
615 return c.fail(protocol.ExitFailure, "%v", err)
616 }
617 if err := c.Store.DeleteRepo(repo.ID); err != nil {
618 return c.fail(protocol.ExitFailure, "%v", err)
619 }
620 if err := os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)); err != nil {
621 return c.fail(protocol.ExitFailure, "database row removed but disk cleanup failed: %v", err)
622 }
623 return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) {
624 fmt.Fprintf(w, "deleted %s\n", repo.Path())
625 })
626}
627
628func runAccessGrant(c *Ctx, args []string) int {
629 if len(args) != 3 || !slices.Contains([]string{"read", "write", "admin"}, args[2]) {
630 return c.usage()
631 }
632 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
633 if code >= 0 {
634 return code
635 }
636 target, err := c.Store.UserByUsername(args[1])
637 if err != nil {
638 return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
639 }
640 if err := c.Store.GrantAccess(repo.ID, target.ID, args[2]); err != nil {
641 return c.fail(protocol.ExitFailure, "%v", err)
642 }
643 return c.emit(map[string]string{"granted": args[2], "user": target.Username},
644 func(w io.Writer) { fmt.Fprintf(w, "granted %s to %s on %s\n", args[2], target.Username, repo.Path()) })
645}
646
647func runAccessRevoke(c *Ctx, args []string) int {
648 if len(args) != 2 {
649 return c.usage()
650 }
651 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
652 if code >= 0 {
653 return code
654 }
655 target, err := c.Store.UserByUsername(args[1])
656 if err != nil {
657 return c.fail(protocol.ExitNotFound, "no such user %q", args[1])
658 }
659 if err := c.Store.RevokeAccess(repo.ID, target.ID); err != nil {
660 if errors.Is(err, store.ErrNotFound) {
661 return c.fail(protocol.ExitNotFound, "%s has no grant on %s", target.Username, repo.Path())
662 }
663 return c.fail(protocol.ExitFailure, "%v", err)
664 }
665 return c.emit(map[string]string{"revoked": target.Username},
666 func(w io.Writer) { fmt.Fprintf(w, "revoked %s on %s\n", target.Username, repo.Path()) })
667}
668
669func runAccessList(c *Ctx, args []string) int {
670 if len(args) != 1 {
671 return c.usage()
672 }
673 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
674 if code >= 0 {
675 return code
676 }
677 entries, err := c.Store.EffectiveAccess(repo.ID)
678 if err != nil {
679 return c.fail(protocol.ExitFailure, "%v", err)
680 }
681 type out struct {
682 User string `json:"user"`
683 Role string `json:"role"`
684 Source string `json:"source"`
685 }
686 var ds []out
687 for _, e := range entries {
688 ds = append(ds, out{e.Username, e.Role, e.Source})
689 }
690 return c.emit(ds, func(w io.Writer) {
691 tb := c.table(w, "USER", "ROLE", "SOURCE")
692 for _, d := range ds {
693 tb.row(cRef(d.User), cState(d.Role), cText("via "+d.Source))
694 }
695 tb.flush()
696 })
697}
698
699func runSettingsShow(c *Ctx, args []string) int {
700 if len(args) != 1 {
701 return c.usage()
702 }
703 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
704 if code >= 0 {
705 return code
706 }
707 return c.emit(repo.Settings, func(w io.Writer) {
708 v := c.view(w)
709 v.title(repo.Path(), "settings", "")
710 v.fields(
711 "protected branches", strings.Join(repo.Settings.ProtectedBranches, ", "),
712 "protected tags", strings.Join(repo.Settings.ProtectedTags, ", "),
713 "require mr", strconv.FormatBool(repo.Settings.RequireMR),
714 "require signed commits", strconv.FormatBool(repo.Settings.RequireSignedCommits),
715 "git daemon", strconv.FormatBool(repo.Settings.GitDaemon),
716 "archived", strconv.FormatBool(repo.Settings.Archived),
717 )
718 })
719}
720
721func runSetDescription(c *Ctx, args []string) int {
722 if len(args) != 2 {
723 return c.usage()
724 }
725 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
726 if code >= 0 {
727 return code
728 }
729 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
730 if err := gitutil.WriteDescription(dir, args[1]); err != nil {
731 return c.fail(protocol.ExitFailure, "%v", err)
732 }
733 return c.emit(map[string]string{"description": gitutil.ReadDescription(dir)}, func(w io.Writer) {
734 fmt.Fprintf(w, "description set on %s\n", repo.Path())
735 })
736}
737
738func runSetDefaultBranch(c *Ctx, args []string) int {
739 if len(args) != 2 {
740 return c.usage()
741 }
742 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
743 if code >= 0 {
744 return code
745 }
746 branch := args[1]
747 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
748 if _, err := gitutil.ResolveRef(dir, "refs/heads/"+branch); err != nil {
749 return c.fail(protocol.ExitFailure, "no branch named %q on %s", branch, repo.Path())
750 }
751 if err := gitutil.SetHead(dir, branch); err != nil {
752 return c.fail(protocol.ExitFailure, "%v", err)
753 }
754 if err := c.Store.UpdateDefaultBranch(repo.ID, branch); err != nil {
755 return c.fail(protocol.ExitFailure, "%v", err)
756 }
757 return c.emit(map[string]string{"default_branch": branch}, func(w io.Writer) {
758 fmt.Fprintf(w, "default branch of %s is now %s\n", repo.Path(), branch)
759 })
760}
761
762func runSetWebsite(c *Ctx, args []string) int {
763 if len(args) != 2 {
764 return c.usage()
765 }
766 site := strings.TrimSpace(args[1])
767 if err := validateWebsite(site); err != nil {
768 return c.failInput(err)
769 }
770 if len(site) > 256 {
771 return c.fail(protocol.ExitUsage, "website URL too long (max 256)")
772 }
773 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
774 if code >= 0 {
775 return code
776 }
777 if _, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.Website = site }); err != nil {
778 return c.fail(protocol.ExitFailure, "%v", err)
779 }
780 return c.emit(map[string]string{"website": site}, func(w io.Writer) {
781 if site == "" {
782 fmt.Fprintf(w, "website cleared on %s\n", repo.Path())
783 } else {
784 fmt.Fprintf(w, "website set on %s\n", repo.Path())
785 }
786 })
787}
788
789func runSetVisibility(c *Ctx, args []string) int {
790 if len(args) != 2 || (args[1] != "public" && args[1] != "private") {
791 return c.usage()
792 }
793 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
794 if code >= 0 {
795 return code
796 }
797 return setRepoVisibility(c, repo, args[1])
798}
799
800// setRepoVisibility applies a visibility change the caller has already
801// been cleared to make.
802func setRepoVisibility(c *Ctx, repo store.Repo, visibility string) int {
803 if repo.Visibility == visibility {
804 return c.emit(map[string]string{"visibility": visibility}, func(w io.Writer) {
805 fmt.Fprintf(w, "%s is already %s\n", repo.Path(), visibility)
806 })
807 }
808 if err := c.Store.SetRepoVisibility(repo.ID, visibility); err != nil {
809 return c.fail(protocol.ExitFailure, "%v", err)
810 }
811 // Going private takes the repository off every anonymous surface, so
812 // git:// exposure cannot outlive the change.
813 if visibility == "private" && repo.Settings.GitDaemon {
814 c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.GitDaemon = false })
815 }
816 c.Store.Audit(c.User.ID, "repo.visibility", map[string]any{"repo": repo.ID, "visibility": visibility})
817 return c.emit(map[string]string{"visibility": visibility}, func(w io.Writer) {
818 fmt.Fprintf(w, "%s is now %s\n", repo.Path(), visibility)
819 })
820}
821
822func runGitDaemon(c *Ctx, args []string) int {
823 if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
824 return c.usage()
825 }
826 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
827 if code >= 0 {
828 return code
829 }
830 on := args[1] == "on"
831 if on && repo.Visibility != "public" {
832 return c.fail(protocol.ExitUsage, "git:// serves only public repositories; %s is private", repo.Path())
833 }
834 if on && !c.Cfg.GitDaemon.Enabled {
835 return c.fail(protocol.ExitUsage, "this instance does not run the git:// daemon ([git_daemon] enabled = false)")
836 }
837 s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.GitDaemon = on })
838 if err != nil {
839 return c.fail(protocol.ExitFailure, "%v", err)
840 }
841 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "git-daemon %s on %s\n", args[1], repo.Path()) })
842}
843
844func runArchive(c *Ctx, args []string) int { return setArchived(c, args, true) }
845func runUnarchive(c *Ctx, args []string) int { return setArchived(c, args, false) }
846
847func setArchived(c *Ctx, args []string, archived bool) int {
848 if len(args) != 1 {
849 return c.usage()
850 }
851 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
852 if code >= 0 {
853 return code
854 }
855 return archiveRepo(c, repo, archived)
856}
857
858// archiveRepo flips the archived flag on a repository the caller has
859// already been cleared to manage.
860func archiveRepo(c *Ctx, repo store.Repo, archived bool) int {
861 verb := "archive"
862 if !archived {
863 verb = "unarchive"
864 }
865 if repo.Settings.Archived == archived {
866 return c.fail(protocol.ExitUsage, "%s is already %sd", repo.Path(), verb)
867 }
868 s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.Archived = archived })
869 if err != nil {
870 return c.fail(protocol.ExitFailure, "%v", err)
871 }
872 c.Store.RecordEvent(repo.ID, c.User.ID, "repo."+verb+"d", "{}")
873 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%sd %s\n", verb, repo.Path()) })
874}
875
876func runTopicsList(c *Ctx, args []string) int {
877 if len(args) != 1 {
878 return c.usage()
879 }
880 repo, code := resolveRepo(c, args[0], policy.CanRead)
881 if code >= 0 {
882 return code
883 }
884 topics, err := c.Store.ListTopics(repo.ID)
885 if err != nil {
886 return c.fail(protocol.ExitFailure, "%v", err)
887 }
888 return c.emit(topics, func(w io.Writer) {
889 tb := c.table(w, "TOPIC")
890 for _, t := range topics {
891 tb.row(cRef(t))
892 }
893 tb.flush()
894 })
895}
896
897func runTopicsAdd(c *Ctx, args []string) int { return editTopics(c, args, true) }
898func runTopicsRemove(c *Ctx, args []string) int { return editTopics(c, args, false) }
899
900func editTopics(c *Ctx, args []string, add bool) int {
901 if len(args) < 2 {
902 return c.usage()
903 }
904 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
905 if code >= 0 {
906 return code
907 }
908 topics := args[1:]
909 if add {
910 for _, t := range topics {
911 if err := policy.ValidateTopic(t); err != nil {
912 return c.failInput(err)
913 }
914 }
915 have, err := c.Store.ListTopics(repo.ID)
916 if err != nil {
917 return c.fail(protocol.ExitFailure, "%v", err)
918 }
919 added := 0
920 for _, t := range topics {
921 if !slices.Contains(have, t) {
922 added++
923 }
924 }
925 if len(have)+added > policy.MaxTopics {
926 return c.fail(protocol.ExitUsage, "a repository can have at most %d topics", policy.MaxTopics)
927 }
928 for _, t := range topics {
929 if err := c.Store.AddTopic(repo.ID, t); err != nil {
930 return c.fail(protocol.ExitFailure, "%v", err)
931 }
932 }
933 } else {
934 for _, t := range topics {
935 if err := c.Store.RemoveTopic(repo.ID, t); err != nil {
936 if errors.Is(err, store.ErrNotFound) {
937 return c.fail(protocol.ExitNotFound, "%s has no topic %q", repo.Path(), t)
938 }
939 return c.fail(protocol.ExitFailure, "%v", err)
940 }
941 }
942 }
943 now, err := c.Store.ListTopics(repo.ID)
944 if err != nil {
945 return c.fail(protocol.ExitFailure, "%v", err)
946 }
947 return c.emit(now, func(w io.Writer) {
948 tb := c.table(w, "TOPIC")
949 for _, t := range now {
950 tb.row(cRef(t))
951 }
952 tb.flush()
953 })
954}
955
956// runRepoSearch matches the query against name, owner/name, description,
957// and topics of every repository the caller can see.
958func runRepoSearch(c *Ctx, args []string) int {
959 if len(args) != 1 {
960 return c.usage()
961 }
962 if err := validQuery(args[0]); err != nil {
963 return c.failInput(err)
964 }
965 q := strings.ToLower(args[0])
966
967 public, err := c.Store.ListPublicRepos()
968 if err != nil {
969 return c.fail(protocol.ExitFailure, "%v", err)
970 }
971 own, err := c.Store.ListReposForUser(c.User.ID, 0, "")
972 if err != nil {
973 return c.fail(protocol.ExitFailure, "%v", err)
974 }
975 seen := map[int64]bool{}
976 type out struct {
977 Path string `json:"path"`
978 Visibility string `json:"visibility"`
979 Description string `json:"description,omitempty"`
980 Topics []string `json:"topics,omitempty"`
981 }
982 var ds []out
983 for _, r := range append(public, own...) {
984 if seen[r.ID] {
985 continue
986 }
987 seen[r.ID] = true
988 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
989 topics, _ := c.Store.ListTopics(r.ID)
990 if !MatchesRepo(q, r.Path(), desc, topics) {
991 continue
992 }
993 ds = append(ds, out{r.Path(), r.Visibility, desc, topics})
994 }
995 return c.emit(ds, func(w io.Writer) {
996 tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION")
997 for _, d := range ds {
998 tb.row(cRef(d.Path), cState(d.Visibility), cFlex(d.Description))
999 }
1000 tb.flush()
1001 })
1002}
1003
1004// MatchesRepo is the one rule for matching a repository against a text
1005// query: its path, its description, or any of its topics. The web's
1006// /explore filter and /search page call it too, so the three surfaces
1007// cannot answer the same query differently.
1008func MatchesRepo(q, path, desc string, topics []string) bool {
1009 q = strings.ToLower(q)
1010 if strings.Contains(strings.ToLower(path), q) ||
1011 strings.Contains(strings.ToLower(desc), q) {
1012 return true
1013 }
1014 for _, t := range topics {
1015 if strings.Contains(strings.ToLower(t), q) {
1016 return true
1017 }
1018 }
1019 return false
1020}
1021
1022func runRepoGrep(c *Ctx, args []string) int {
1023 f, err := parseFlags(args, flagSpec{Values: []string{"--ref"}, MaxPos: 2, Usage: "repo grep <owner/name> <query> [--ref <ref>]"})
1024 if err != nil {
1025 return c.fail(protocol.ExitUsage, "%v", err)
1026 }
1027 path, query, ref := f.pos(0), f.pos(1), f.Value("--ref")
1028 if path == "" || query == "" {
1029 return c.usage()
1030 }
1031 if err := validQuery(query); err != nil {
1032 return c.failInput(err)
1033 }
1034 repo, code := resolveRepo(c, path, policy.CanRead)
1035 if code >= 0 {
1036 return code
1037 }
1038 if ref == "" {
1039 ref = repo.DefaultBranch
1040 }
1041 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
1042 if _, err := gitutil.ResolveRef(dir, ref); err != nil {
1043 return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
1044 }
1045 matches, err := gitutil.Grep(dir, ref, query, maxGrepMatches)
1046 if err != nil {
1047 return c.fail(protocol.ExitFailure, "%v", err)
1048 }
1049 type out struct {
1050 Path string `json:"path"`
1051 Line int `json:"line"`
1052 Text string `json:"text"`
1053 }
1054 var ds []out
1055 for _, m := range matches {
1056 ds = append(ds, out{m.Path, m.Line, m.Text})
1057 }
1058 return c.emit(ds, func(w io.Writer) {
1059 for _, d := range ds {
1060 fmt.Fprintf(w, "%s:%d:%s\n", d.Path, d.Line, d.Text)
1061 }
1062 })
1063}
1064
1065func runRepoPin(c *Ctx, args []string) int { return setPinned(c, args, true) }
1066func runRepoUnpin(c *Ctx, args []string) int { return setPinned(c, args, false) }
1067
1068func setPinned(c *Ctx, args []string, pin bool) int {
1069 verb := "pin"
1070 if !pin {
1071 verb = "unpin"
1072 }
1073 if len(args) != 1 {
1074 return c.usage()
1075 }
1076 repo, code := resolveRepo(c, args[0], policy.CanRead)
1077 if code >= 0 {
1078 return code
1079 }
1080 if pin {
1081 if err := c.Store.PinRepo(c.User.ID, repo.ID); err != nil {
1082 return c.fail(protocol.ExitFailure, "%v", err)
1083 }
1084 } else if err := c.Store.UnpinRepo(c.User.ID, repo.ID); err != nil {
1085 if errors.Is(err, store.ErrNotFound) {
1086 return c.fail(protocol.ExitNotFound, "%s is not pinned", repo.Path())
1087 }
1088 return c.fail(protocol.ExitFailure, "%v", err)
1089 }
1090 return c.emit(map[string]string{verb + "ned": repo.Path()}, func(w io.Writer) {
1091 fmt.Fprintf(w, "%sned %s\n", verb, repo.Path())
1092 })
1093}
1094
1095func runRepoBookmark(c *Ctx, args []string) int { return setBookmarked(c, args, true) }
1096func runRepoUnbookmark(c *Ctx, args []string) int { return setBookmarked(c, args, false) }
1097
1098// setBookmarked mirrors setPinned. A bookmark needs only read access —
1099// bookmarking is something you do to someone else's repository, which is
1100// the whole point of it — and a private repository you cannot read is
1101// not found, as everywhere.
1102func setBookmarked(c *Ctx, args []string, on bool) int {
1103 verb := "bookmark"
1104 if !on {
1105 verb = "unbookmark"
1106 }
1107 if len(args) != 1 {
1108 return c.usage()
1109 }
1110 repo, code := resolveRepo(c, args[0], policy.CanRead)
1111 if code >= 0 {
1112 return code
1113 }
1114 if on {
1115 if err := c.Store.BookmarkRepo(c.User.ID, repo.ID); err != nil {
1116 return c.fail(protocol.ExitFailure, "%v", err)
1117 }
1118 } else if err := c.Store.UnbookmarkRepo(c.User.ID, repo.ID); err != nil {
1119 if errors.Is(err, store.ErrNotFound) {
1120 return c.fail(protocol.ExitNotFound, "%s is not bookmarked", repo.Path())
1121 }
1122 return c.fail(protocol.ExitFailure, "%v", err)
1123 }
1124 return c.emit(map[string]string{verb + "ed": repo.Path()}, func(w io.Writer) {
1125 fmt.Fprintf(w, "%sed %s\n", verb, repo.Path())
1126 })
1127}
1128
1129// BookmarkOut is one row of `repo bookmarks`: the repository and how many
1130// people have bookmarked it.
1131type BookmarkOut struct {
1132 Path string `json:"path"`
1133 Description string `json:"description,omitempty"`
1134 Visibility string `json:"visibility"`
1135 Bookmarks int `json:"bookmarks"`
1136}
1137
1138func runRepoBookmarks(c *Ctx, args []string) int {
1139 if len(args) != 0 {
1140 return c.usage()
1141 }
1142 repos, err := c.Store.ListBookmarks(c.User.ID)
1143 if err != nil {
1144 return c.fail(protocol.ExitFailure, "%v", err)
1145 }
1146 out := []BookmarkOut{}
1147 for _, r := range repos {
1148 // A repository bookmarked while public and since made private
1149 // stays in the table and drops out of the listing, the same way
1150 // it disappears from every other surface.
1151 grant, err := c.Store.AccessRole(r.ID, c.User.ID)
1152 if err != nil {
1153 return c.fail(protocol.ExitFailure, "%v", err)
1154 }
1155 if !policy.CanRead(c.User, r, grant) {
1156 continue
1157 }
1158 out = append(out, BookmarkOut{
1159 Path: r.Path(),
1160 Description: gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name)),
1161 Visibility: r.Visibility,
1162 Bookmarks: c.Store.BookmarkCount(r.ID),
1163 })
1164 }
1165 return c.emit(out, func(w io.Writer) {
1166 tb := c.table(w, "PATH", "COUNT", "DESCRIPTION")
1167 for _, b := range out {
1168 tb.row(cRef(b.Path), cNum(int64(b.Bookmarks)), cFlex(b.Description))
1169 }
1170 tb.flush()
1171 })
1172}
1173
1174func runProtectTag(c *Ctx, args []string) int { return setProtectTag(c, args, true) }
1175func runUnprotectTag(c *Ctx, args []string) int { return setProtectTag(c, args, false) }
1176
1177func setProtectTag(c *Ctx, args []string, protect bool) int {
1178 if len(args) != 2 {
1179 return c.usage()
1180 }
1181 glob := args[1]
1182 if _, err := path.Match(glob, "x"); err != nil || glob == "" {
1183 return c.fail(protocol.ExitUsage, "bad glob %q", glob)
1184 }
1185 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
1186 if code >= 0 {
1187 return code
1188 }
1189 s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) {
1190 has := slices.Contains(s.ProtectedTags, glob)
1191 if protect && !has {
1192 s.ProtectedTags = append(s.ProtectedTags, glob)
1193 slices.Sort(s.ProtectedTags)
1194 }
1195 if !protect && has {
1196 s.ProtectedTags = slices.DeleteFunc(s.ProtectedTags, func(g string) bool { return g == glob })
1197 }
1198 })
1199 if err != nil {
1200 return c.fail(protocol.ExitFailure, "%v", err)
1201 }
1202 verb := "protected"
1203 if !protect {
1204 verb = "unprotected"
1205 }
1206 return c.emit(s, func(w io.Writer) {
1207 fmt.Fprintf(w, "tags %s %s on %s\n", glob, verb, repo.Path())
1208 })
1209}
1210
1211func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) }
1212func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) }
1213
1214func setProtect(c *Ctx, args []string, protect bool) int {
1215 if len(args) != 2 {
1216 return c.usage()
1217 }
1218 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
1219 if code >= 0 {
1220 return code
1221 }
1222 branch := args[1]
1223 // The list is read and rewritten inside the update, so two admins
1224 // protecting different branches at once both land.
1225 s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) {
1226 has := slices.Contains(s.ProtectedBranches, branch)
1227 if protect && !has {
1228 s.ProtectedBranches = append(s.ProtectedBranches, branch)
1229 slices.Sort(s.ProtectedBranches)
1230 }
1231 if !protect && has {
1232 s.ProtectedBranches = slices.DeleteFunc(s.ProtectedBranches, func(b string) bool { return b == branch })
1233 }
1234 })
1235 if err != nil {
1236 return c.fail(protocol.ExitFailure, "%v", err)
1237 }
1238 verb := "protected"
1239 if !protect {
1240 verb = "unprotected"
1241 }
1242 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%s %s on %s\n", verb, branch, repo.Path()) })
1243}
1244
1245// runRepoDiff is the compare view's command: what head adds on top of
1246// base, measured from their merge base the way a merge request diff is,
1247// so a base that moved on does not show up as removals (#118).
1248func runRepoDiff(c *Ctx, args []string) int {
1249 f, err := parseFlags(args, flagSpec{MaxPos: 3, Usage: "repo diff <owner/name> <base> <head>"})
1250 if err != nil || len(f.Pos) != 3 {
1251 return c.usage()
1252 }
1253 repo, code := resolveRepo(c, f.pos(0), policy.CanRead)
1254 if code >= 0 {
1255 return code
1256 }
1257 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
1258 base, err := gitutil.ResolveRef(dir, f.pos(1))
1259 if err != nil {
1260 return c.fail(protocol.ExitNotFound, "no ref %q in %s", f.pos(1), repo.Path())
1261 }
1262 head, err := gitutil.ResolveRef(dir, f.pos(2))
1263 if err != nil {
1264 return c.fail(protocol.ExitNotFound, "no ref %q in %s", f.pos(2), repo.Path())
1265 }
1266 mergeBase, err := gitutil.MergeBase(dir, base, head)
1267 if err != nil {
1268 return c.fail(protocol.ExitUsage, "%v", err)
1269 }
1270 patch, truncated, err := gitutil.Diff(dir, mergeBase, head, 4<<20)
1271 if err != nil {
1272 return c.fail(protocol.ExitFailure, "%v", err)
1273 }
1274 if c.JSON {
1275 return c.emit(map[string]any{"base": base, "head": head, "merge_base": mergeBase, "patch": patch, "truncated": truncated}, nil)
1276 }
1277 fmt.Fprint(c.Stdout, patch)
1278 if truncated {
1279 fmt.Fprintln(c.Stderr, "diff truncated at 4 MiB")
1280 }
1281 return protocol.ExitOK
1282}