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