package control import ( "errors" "fmt" "io" "os" "path" "path/filepath" "slices" "strconv" "strings" "gitbay.org/gitbay/internal/backuplock" "gitbay.org/gitbay/internal/gitutil" "gitbay.org/gitbay/internal/policy" "gitbay.org/gitbay/internal/protocol" "gitbay.org/gitbay/internal/store" ) // RepoDir returns the on-disk path for a repository. func RepoDir(root, owner, name string) string { return filepath.Join(root, "repos", owner, name+".git") } // HooksDir is the shared core.hooksPath directory. func HooksDir(root string) string { return filepath.Join(root, "hooks") } func init() { register(Command{Path: []string{"repo", "create"}, Summary: "create a repository", Usage: "repo create [--private]", Flags: []Flag{ {"--private", "", "create it private", ""}, }, Examples: []string{"repo create krz/newthing --private"}, Run: runRepoCreate}) register(Command{Path: []string{"repo", "list"}, Summary: "list repositories you own or can access", Usage: "repo list [--limit ] [--cursor ]", Flags: []Flag{ {"--limit", "", "rows per page", ""}, {"--cursor", "", "continue from the previous page", ""}, }, Examples: []string{"repo list --limit 20"}, ReadOnly: true, Run: runRepoList}) register(Command{Path: []string{"repo", "show"}, Summary: "show repository details", Usage: "repo show ", Examples: []string{"repo show krz/gitbay"}, ReadOnly: true, Run: runRepoShow}) register(Command{Path: []string{"repo", "transfer"}, NeedsRecentSignIn: true, Summary: "move a repository to another owner", Usage: "repo transfer (clone URLs change)", Examples: []string{"repo transfer krz/gitbay krazywarez"}, Run: runRepoTransfer}) register(Command{Path: []string{"repo", "rename"}, NeedsRecentSignIn: true, Summary: "rename a repository", Usage: "repo rename (clone URLs change)", Examples: []string{"repo rename krz/gitbay forge"}, Run: runRepoRename}) register(Command{Path: []string{"repo", "delete"}, NeedsRecentSignIn: true, Summary: "delete a repository", Usage: "repo delete --yes", Flags: []Flag{ {"--yes", "", "confirm the permanent delete", ""}, }, Examples: []string{"repo delete cmc/scratch --yes"}, Run: runRepoDelete}) register(Command{Path: []string{"repo", "access", "grant"}, NeedsRecentSignIn: true, Summary: "grant access", Usage: "repo access grant read|write|admin", Examples: []string{"repo access grant krz/gitbay cmc write"}, Run: runAccessGrant}) register(Command{Path: []string{"repo", "access", "revoke"}, Summary: "revoke access", Usage: "repo access revoke ", Examples: []string{"repo access revoke krz/gitbay cmc"}, Run: runAccessRevoke}) register(Command{Path: []string{"repo", "access", "list"}, Summary: "list who can reach the repository, with the role and where it comes from", Usage: "repo access list ", Examples: []string{"repo access list krz/gitbay"}, ReadOnly: true, Run: runAccessList}) register(Command{Path: []string{"repo", "settings", "show"}, Summary: "show settings", Usage: "repo settings show ", Examples: []string{"repo settings show krz/gitbay"}, ReadOnly: true, Run: runSettingsShow}) register(Command{Path: []string{"repo", "settings", "protect"}, Summary: "protect a branch", Usage: "repo settings protect ", Examples: []string{"repo settings protect krz/gitbay main"}, Run: runProtect}) register(Command{Path: []string{"repo", "settings", "unprotect"}, Summary: "unprotect a branch", Usage: "repo settings unprotect ", Examples: []string{"repo settings unprotect krz/gitbay main"}, Run: runUnprotect}) register(Command{Path: []string{"repo", "settings", "protect-tag"}, Summary: "protect tags matching a glob (created once, never moved or deleted)", Usage: "repo settings protect-tag ", Examples: []string{"repo settings protect-tag krz/gitbay 'v*'"}, Run: runProtectTag}) register(Command{Path: []string{"repo", "settings", "unprotect-tag"}, Summary: "drop a protected-tag glob", Usage: "repo settings unprotect-tag ", Examples: []string{"repo settings unprotect-tag krz/gitbay 'v*'"}, Run: runUnprotectTag}) register(Command{Path: []string{"repo", "settings", "description"}, Summary: "set the repository description", Usage: "repo settings description ('' clears)", Examples: []string{`repo settings description krz/gitbay "a CLI-first git forge"`}, Run: runSetDescription}) register(Command{Path: []string{"repo", "settings", "visibility"}, Summary: "set repository visibility", Usage: "repo settings visibility public|private", Examples: []string{"repo settings visibility krz/gitbay public"}, // Making a repository public shows it to everyone. NeedsRecentSignIn: true, Run: runSetVisibility}) register(Command{Path: []string{"repo", "settings", "website"}, Summary: "set the repository website", Usage: "repo settings website ('' clears)", Examples: []string{"repo settings website krz/gitbay https://gitbay.org"}, Run: runSetWebsite}) register(Command{Path: []string{"repo", "settings", "default-branch"}, Summary: "set the default branch", Usage: "repo settings default-branch ", Examples: []string{"repo settings default-branch krz/gitbay main"}, Run: runSetDefaultBranch}) register(Command{Path: []string{"repo", "settings", "git-daemon"}, Summary: "expose over git://", Usage: "repo settings git-daemon on|off", Examples: []string{"repo settings git-daemon krz/gitbay on"}, Run: runGitDaemon}) register(Command{Path: []string{"repo", "archive"}, Summary: "archive a repository (read-only: pushes and issue/MR writes refused)", Usage: "repo archive ", Examples: []string{"repo archive krz/gitbay"}, Run: runArchive}) register(Command{Path: []string{"repo", "unarchive"}, Summary: "unarchive a repository", Usage: "repo unarchive ", Examples: []string{"repo unarchive krz/gitbay"}, Run: runUnarchive}) register(Command{Path: []string{"repo", "topics"}, Summary: "list topics", Usage: "repo topics ", Examples: []string{"repo topics krz/gitbay"}, ReadOnly: true, Run: runTopicsList}) register(Command{Path: []string{"repo", "topics", "add"}, Summary: "add topics", Usage: "repo topics add ...", Examples: []string{"repo topics add krz/gitbay git forge cli"}, Run: runTopicsAdd}) register(Command{Path: []string{"repo", "topics", "remove"}, Summary: "remove topics", Usage: "repo topics remove ...", Examples: []string{"repo topics remove krz/gitbay cli"}, Run: runTopicsRemove}) register(Command{Path: []string{"repo", "search"}, Summary: "find repositories by name, description, or topic", Usage: "repo search ", Examples: []string{"repo search forge"}, ReadOnly: true, Run: runRepoSearch}) register(Command{Path: []string{"repo", "grep"}, Summary: "search file contents", Usage: "repo grep [--ref ]", Flags: []Flag{ {"--ref", "", "branch, tag or commit to search", "the default branch"}, }, Examples: []string{"repo grep krz/gitbay TODO"}, ReadOnly: true, Run: runRepoGrep}) register(Command{Path: []string{"repo", "diff"}, Summary: "the patch between two refs, from their merge base", Usage: "repo diff ", Examples: []string{"repo diff krz/gitbay main cli-output-help"}, ReadOnly: true, Run: runRepoDiff}) register(Command{Path: []string{"repo", "pin"}, Summary: "pin a repository to your dashboard", Usage: "repo pin ", Examples: []string{"repo pin krz/gitbay"}, Run: runRepoPin}) register(Command{Path: []string{"repo", "unpin"}, Summary: "unpin a repository", Usage: "repo unpin ", Examples: []string{"repo unpin krz/gitbay"}, Run: runRepoUnpin}) register(Command{Path: []string{"repo", "bookmark"}, Summary: "bookmark a repository to come back to", Usage: "repo bookmark ", Examples: []string{"repo bookmark krz/gitbay"}, Run: runRepoBookmark}) register(Command{Path: []string{"repo", "unbookmark"}, Summary: "remove a bookmark", Usage: "repo unbookmark ", Examples: []string{"repo unbookmark krz/gitbay"}, Run: runRepoUnbookmark}) register(Command{Path: []string{"repo", "bookmarks"}, Summary: "list the repositories you have bookmarked", Usage: "repo bookmarks", Examples: []string{"repo bookmarks"}, ReadOnly: true, Run: runRepoBookmarks}) } const ( minQueryLen = 2 maxQueryLen = 200 maxGrepMatches = 200 ) func validQuery(q string) error { if len(q) < minQueryLen || len(q) > maxQueryLen { return fmt.Errorf("query must be %d to %d characters", minQueryLen, maxQueryLen) } return nil } // refuseArchived blocks content writes (pushes are refused in the transport // layer) on archived repositories. Settings, access, and lifecycle commands // stay available so an archived repo can be managed and unarchived. func refuseArchived(c *Ctx, repo store.Repo) int { if repo.Settings.Archived { return c.fail(protocol.ExitDenied, "%s is archived and read-only; unarchive it first", repo.Path()) } return -1 } // resolveRepo loads a repo and checks the given permission for c.User. func resolveRepo(c *Ctx, path string, check func(store.User, store.Repo, string) bool) (store.Repo, int) { repo, err := c.Store.RepoByPath(path) if err != nil { if errors.Is(err, store.ErrNotFound) { // Same message whether it doesn't exist or is invisible. return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path) } return repo, c.fail(protocol.ExitFailure, "loading repository: %v", err) } grant, err := c.Store.AccessRole(repo.ID, c.User.ID) if err != nil { return repo, c.fail(protocol.ExitFailure, "checking access: %v", err) } if !check(c.User, repo, grant) { if !policy.CanRead(c.User, repo, grant) { // Invisible repos 404, per the enumeration rule. return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path) } return repo, c.fail(protocol.ExitDenied, "permission denied on %s; ask its owner for access", path) } return repo, -1 } func runRepoCreate(c *Ctx, args []string) int { f, err := c.parseArgs(args, flagSpec{Values: []string{"--description"}, Bools: []string{"--private"}, MaxPos: 1, Usage: "repo create [--private] [--description ]"}) if err != nil { return c.fail(protocol.ExitUsage, "%v", err) } visibility, path, description := "public", f.pos(0), f.Value("--description") if f.Has("--private") { visibility = "private" } owner, name, ok := strings.Cut(path, "/") if !ok { return c.usage() } if err := policyValidateRepoName(name); err != nil { return c.failInput(err) } ownerKind, ownerID, code := resolveNewRepoOwner(c, owner) if code >= 0 { return code } repoCreateMu.Lock() if ownerKind == "user" { if code := checkRepoQuota(c); code >= 0 { repoCreateMu.Unlock() return code } } id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility) repoCreateMu.Unlock() if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } dir := RepoDir(c.Cfg.Server.Root, owner, name) if err := gitutil.InitBare(dir, "main", HooksDir(c.Cfg.Server.Root)); err != nil { c.Store.DeleteRepo(id) return c.fail(protocol.ExitFailure, "initializing repository: %v", err) } if description != "" { if err := gitutil.WriteDescription(dir, description); err != nil { return c.fail(protocol.ExitFailure, "writing description: %v", err) } } type out struct { Path string `json:"path"` Visibility string `json:"visibility"` SSHURL string `json:"ssh_url"` } d := out{Path: path, Visibility: visibility, SSHURL: "ssh://git@" + hostOf(c.Cfg.Server.SiteURL) + "/" + path + ".git"} return c.emit(d, func(w io.Writer) { fmt.Fprintf(w, "created %s (%s)\nclone: git clone %s\n", d.Path, d.Visibility, d.SSHURL) }) } // resolveNewRepoOwner answers who a new repository belongs to: the // caller, or an organization they administer. The returned code is -1 // when the owner is good, and the exit code to return otherwise. func resolveNewRepoOwner(c *Ctx, owner string) (kind string, id int64, code int) { if owner == c.User.Username { return "user", c.User.ID, -1 } org, err := c.Store.OrgByName(owner) if err != nil { return "", 0, c.fail(protocol.ExitDenied, "cannot create repositories under %q: not you and not an organization you can see", owner) } role, err := c.Store.OrgRole(org.ID, c.User.ID) if err != nil { return "", 0, c.fail(protocol.ExitFailure, "%v", err) } if role != "admin" { return "", 0, c.fail(protocol.ExitDenied, "only admins of %s can create repositories there", owner) } return "org", org.ID, -1 } func policyValidateRepoName(name string) error { return policy.ValidateName(name) } func hostOf(siteURL string) string { s := strings.TrimPrefix(strings.TrimPrefix(siteURL, "https://"), "http://") return strings.TrimSuffix(s, "/") } func runRepoList(c *Ctx, args []string) int { args, p, code := parsePageFlags(c, args, "repo", false) if code >= 0 { return code } if len(args) != 0 { return c.usage() } repos, err := c.Store.ListReposForUser(c.User.ID, p.queryLimit(), p.key) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } repos, next := trimPage(p, repos, "repo", store.Repo.Path) type out struct { Path string `json:"path"` Visibility string `json:"visibility"` Description string `json:"description,omitempty"` Archived bool `json:"archived,omitempty"` } var ds []out for _, r := range repos { desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name)) ds = append(ds, out{r.Path(), r.Visibility, desc, r.Settings.Archived}) } return c.emitPage(p, ds, next, func(w io.Writer) { tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION") for _, d := range ds { cells := []cell{cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cFlex(d.Description)} if d.Archived { cells = c.note(cells, 1, "[archived]", "archived") } tb.row(cells...) } tb.flush() }) } func runRepoShow(c *Ctx, args []string) int { if len(args) != 1 { return c.usage() } repo, code := resolveRepo(c, args[0], policy.CanRead) if code >= 0 { return code } type mirrorOut struct { Direction string `json:"direction"` URL string `json:"url"` Pending bool `json:"pending"` LastSync string `json:"last_sync,omitempty"` LastError string `json:"last_error,omitempty"` } type out struct { Path string `json:"path"` Description string `json:"description,omitempty"` Website string `json:"website,omitempty"` Visibility string `json:"visibility"` DefaultBranch string `json:"default_branch"` ProtectedBranches []string `json:"protected_branches,omitempty"` Archived bool `json:"archived,omitempty"` Topics []string `json:"topics,omitempty"` Domains []string `json:"domains,omitempty"` Mirrors []mirrorOut `json:"mirrors,omitempty"` // ForkOf names the parent only when the caller can read it: a // private parent is not confirmed to exist, here as anywhere. ForkOf string `json:"fork_of,omitempty"` // Watch and Bookmarked are the caller's own state, so a client // can draw a toggle rather than two stateless buttons (#178). Watch string `json:"watch,omitempty"` // watching, muted, or absent Bookmarked bool `json:"bookmarked,omitempty"` } desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)) topics, err := c.Store.ListTopics(repo.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } var domains []string if ds, err := c.Store.ListPageDomains(repo.ID); err == nil { for _, pd := range ds { if pd.Verified() { domains = append(domains, pd.Domain) } } } d := out{Path: repo.Path(), Description: desc, Website: repo.Settings.Website, Visibility: repo.Visibility, DefaultBranch: repo.DefaultBranch, ProtectedBranches: repo.Settings.ProtectedBranches, Archived: repo.Settings.Archived, Topics: topics, Domains: domains} if repo.ForkOf != 0 { if parent, err := c.Store.RepoByID(repo.ForkOf); err == nil { if grant, err := c.Store.AccessRole(parent.ID, c.User.ID); err == nil && policy.CanRead(c.User, parent, grant) { d.ForkOf = parent.Path() } } } if c.User.ID != 0 { d.Watch = c.Store.RepoWatchState(repo.ID, c.User.ID) d.Bookmarked = c.Store.IsBookmarked(c.User.ID, repo.ID) } // Mirror status is admin-only, like repo mirror list. The token never // leaves the server. if grant, err := c.Store.AccessRole(repo.ID, c.User.ID); err == nil && policy.CanAdmin(c.User, repo, grant) { ms, err := c.Store.ListMirrors(repo.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } for _, m := range ms { d.Mirrors = append(d.Mirrors, mirrorOut{m.Direction, m.URL, m.Dirty, m.LastSync, m.LastError}) } } var glance repoGlance if c.Term.Cols > 0 && !c.JSON { glance = repoAtAGlance(c, repo) } return c.emit(d, func(w io.Writer) { bookmarked, archived := "", "" if d.Bookmarked { bookmarked = "yes" } if d.Archived { archived = "yes" } v := c.view(w) if c.Term.Cols > 0 { v.title(d.Path, "", d.Visibility) v.text(d.Description) v.fields( "clone", glance.clone, "issues", glance.issues, "merge requests", glance.mrs, "release", glance.release, "checks", glance.checks, "default branch", d.DefaultBranch, "website", d.Website, "topics", strings.Join(d.Topics, ", "), "protected", strings.Join(d.ProtectedBranches, ", "), "pages domains", strings.Join(d.Domains, ", "), "fork of", d.ForkOf, "watch", d.Watch, "bookmarked", bookmarked, "archived", archived, "url", c.siteURL(d.Path), ) } else { v.title(d.Path, d.Description, d.Visibility) v.fields( "default branch", d.DefaultBranch, "website", d.Website, "topics", strings.Join(d.Topics, ", "), "protected", strings.Join(d.ProtectedBranches, ", "), "pages domains", strings.Join(d.Domains, ", "), "fork of", d.ForkOf, "watch", d.Watch, "bookmarked", bookmarked, "archived", archived, "url", c.siteURL(d.Path), ) } if len(d.Mirrors) > 0 { v.section("mirror") tb := c.table(w, "DIRECTION", "URL", "LAST SYNC", "STATUS") for _, m := range d.Mirrors { status := "ok" if m.Pending { status = "pending" } if m.LastError != "" { status = "error: " + m.LastError } tb.row(cText(m.Direction), cFlex(m.URL), cText(orDash(c.when(m.LastSync))), cState(status)) } tb.flush() } }) } // repoGlance is what repo show adds at a terminal: how to clone it and // what is going on in it. type repoGlance struct { clone, issues, mrs, release, checks string } // repoAtAGlance reads the glance fields. Each is left blank when it // cannot be read: they are a summary, not the command's result. func repoAtAGlance(c *Ctx, repo store.Repo) repoGlance { host := c.Cfg.SiteHost() if c.Cfg.SSH.Port != 22 { host += ":" + strconv.Itoa(c.Cfg.SSH.Port) } g := repoGlance{clone: "ssh://git@" + host + "/" + repo.Path() + ".git"} issues, mrs := c.Store.OpenCounts(repo.ID) g.issues = fmt.Sprintf("%d open", issues) g.mrs = fmt.Sprintf("%d open", mrs) if rs, err := c.Store.ListReleasesPage(repo.ID, 1, "", 0); err == nil && len(rs) > 0 { g.release = rs[0].Tag + ", " + relAge(rs[0].CreatedAt, termNow()) } dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) if tip, err := gitutil.ResolveRef(dir, "refs/heads/"+repo.DefaultBranch); err == nil { if sts, err := c.Store.ListCommitStatuses(repo.ID, tip); err == nil { if m := checksMark(sts); m.s != "" { g.checks = m.s + " on " + repo.DefaultBranch } } } return g } func runRepoTransfer(c *Ctx, args []string) int { if len(args) != 2 { return c.usage() } repo, code := resolveRepo(c, args[0], policy.CanAdmin) if code >= 0 { return code } newOwner := args[1] if newOwner == repo.OwnerName { return c.fail(protocol.ExitUsage, "%s already owns this repository", newOwner) } // Target: yourself, or an org you admin — same rule as repo create. newKind, newID := "", int64(0) if newOwner == c.User.Username { newKind, newID = "user", c.User.ID } else if org, err := c.Store.OrgByName(newOwner); err == nil { role, err := c.Store.OrgRole(org.ID, c.User.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } if role != "admin" { return c.fail(protocol.ExitDenied, "only admins of %s can receive repositories there", newOwner) } newKind, newID = "org", org.ID } else { return c.fail(protocol.ExitDenied, "cannot transfer to %q: not you and not an organization you can see", newOwner) } oldDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) newDir := RepoDir(c.Cfg.Server.Root, newOwner, repo.Name) if _, err := os.Stat(newDir); err == nil { return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", newOwner, repo.Name) } release, lockCode := holdOffBackup(c) if lockCode >= 0 { return lockCode } defer release() // The directory moves before the record changes: a move that fails // leaves nothing to undo, whereas the record's change into an org // folds labels and milestones into the org's rows, which a revert // cannot unfold (#212). A record that then fails moves the directory // back, and says so if even that fails, since the operator then has // a row pointing at a directory that is not there. if err := os.MkdirAll(filepath.Dir(newDir), 0o750); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } if err := os.Rename(oldDir, newDir); err != nil { return c.fail(protocol.ExitFailure, "moving repository: %v", err) } if err := c.Store.TransferRepo(repo.ID, newKind, newID); err != nil { if rerr := os.Rename(newDir, oldDir); rerr != nil { 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) } return c.failErr(err) } newPath := newOwner + "/" + repo.Name return c.emit(map[string]string{"repo": newPath, "was": repo.Path()}, func(w io.Writer) { fmt.Fprintf(w, "transferred %s to %s — clone URLs now use %s\n", repo.Path(), newPath, newPath) }) } func runRepoRename(c *Ctx, args []string) int { if len(args) != 2 { return c.usage() } repo, code := resolveRepo(c, args[0], policy.CanAdmin) if code >= 0 { return code } newName := args[1] if newName == repo.Name { return c.fail(protocol.ExitUsage, "%s is already named %s", repo.Path(), newName) } if err := policyValidateRepoName(newName); err != nil { return c.failInput(err) } oldDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) newDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, newName) if _, err := os.Stat(newDir); err == nil { return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", repo.OwnerName, newName) } release, lockCode := holdOffBackup(c) if lockCode >= 0 { return lockCode } defer release() if err := c.Store.RenameRepo(repo.ID, newName); err != nil { return c.failErr(err) } if err := os.Rename(oldDir, newDir); err != nil { // Same rule as transfer: keep name and disk consistent, and say so // if even the revert fails. if rerr := c.Store.RenameRepo(repo.ID, repo.Name); rerr != nil { 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()) } return c.fail(protocol.ExitFailure, "moving repository: %v", err) } newPath := repo.OwnerName + "/" + newName return c.emit(map[string]string{"repo": newPath, "was": repo.Path()}, func(w io.Writer) { fmt.Fprintf(w, "renamed %s to %s — clone URLs now use %s\n", repo.Path(), newPath, newPath) }) } func runRepoDelete(c *Ctx, args []string) int { var path string var yes bool for _, a := range args { if a == "--yes" { yes = true } else if path == "" { path = a } else { return c.usage() } } if path == "" { return c.usage() } repo, code := resolveRepo(c, path, policy.CanAdmin) if code >= 0 { return code } if !yes { return c.fail(protocol.ExitUsage, "repo delete is permanent; re-run with --yes") } return deleteRepo(c, repo) } // deleteRepo removes a repository the caller has already been cleared to // delete: the database row, then the directory. // // There is deliberately no repo.deleted event. events.repo_id and // webhooks.repo_id both cascade from repos, so recording one would delete // it, and every webhook that could have subscribed, in the same // statement. A repository's deletion is not observable through its own // webhooks; an instance that needs to hear about it wants the audit log // (#112). func deleteRepo(c *Ctx, repo store.Repo) int { release, lockCode := holdOffBackup(c) if lockCode >= 0 { return lockCode } defer release() // Open MRs sourced from this repo keep working (targets own the // objects) but must show that the source is gone. if err := c.Store.MarkSourceGoneForRepo(repo.ID); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } if err := c.Store.DeleteRepo(repo.ID); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } if err := os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)); err != nil { return c.fail(protocol.ExitFailure, "database row removed but disk cleanup failed: %v", err) } return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) { fmt.Fprintf(w, "deleted %s\n", repo.Path()) }) } // holdOffBackup keeps a full backup from starting while a repository // directory moves or goes, and refuses while one runs: the backup's // database snapshot names every repository its walk then archives // (#259). The caller defers the returned release. func holdOffBackup(c *Ctx) (func(), int) { release, err := backuplock.TryShared(c.Cfg.Server.Root) if err != nil { return nil, c.fail(protocol.ExitFailure, "%v", err) } return release, -1 } func runAccessGrant(c *Ctx, args []string) int { if len(args) != 3 || !slices.Contains([]string{"read", "write", "admin"}, args[2]) { return c.usage() } repo, code := resolveRepo(c, args[0], policy.CanAdmin) if code >= 0 { return code } target, err := c.Store.UserByUsername(args[1]) if err != nil { return c.fail(protocol.ExitNotFound, "no such user %q", args[1]) } if err := c.Store.GrantAccess(repo.ID, target.ID, args[2]); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } return c.emit(map[string]string{"granted": args[2], "user": target.Username}, func(w io.Writer) { fmt.Fprintf(w, "granted %s to %s on %s\n", args[2], target.Username, repo.Path()) }) } func runAccessRevoke(c *Ctx, args []string) int { if len(args) != 2 { return c.usage() } repo, code := resolveRepo(c, args[0], policy.CanAdmin) if code >= 0 { return code } target, err := c.Store.UserByUsername(args[1]) if err != nil { return c.fail(protocol.ExitNotFound, "no such user %q", args[1]) } if err := c.Store.RevokeAccess(repo.ID, target.ID); err != nil { if errors.Is(err, store.ErrNotFound) { return c.fail(protocol.ExitNotFound, "%s has no grant on %s", target.Username, repo.Path()) } return c.fail(protocol.ExitFailure, "%v", err) } return c.emit(map[string]string{"revoked": target.Username}, func(w io.Writer) { fmt.Fprintf(w, "revoked %s on %s\n", target.Username, repo.Path()) }) } func runAccessList(c *Ctx, args []string) int { if len(args) != 1 { return c.usage() } repo, code := resolveRepo(c, args[0], policy.CanAdmin) if code >= 0 { return code } entries, err := c.Store.EffectiveAccess(repo.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } type out struct { User string `json:"user"` Role string `json:"role"` Source string `json:"source"` } var ds []out for _, e := range entries { ds = append(ds, out{e.Username, e.Role, e.Source}) } return c.emit(ds, func(w io.Writer) { tb := c.table(w, "USER", "ROLE", "SOURCE") for _, d := range ds { tb.row(cRef(d.User), cState(d.Role), cText("via "+d.Source)) } tb.flush() }) } func runSettingsShow(c *Ctx, args []string) int { if len(args) != 1 { return c.usage() } repo, code := resolveRepo(c, args[0], policy.CanAdmin) if code >= 0 { return code } return c.emit(repo.Settings, func(w io.Writer) { v := c.view(w) v.title(repo.Path(), "settings", "") v.fields( "protected branches", strings.Join(repo.Settings.ProtectedBranches, ", "), "protected tags", strings.Join(repo.Settings.ProtectedTags, ", "), "require mr", strconv.FormatBool(repo.Settings.RequireMR), "require checks", strconv.FormatBool(repo.Settings.RequireChecks), "required contexts", strings.Join(repo.Settings.RequiredContexts, ", "), "require signed commits", strconv.FormatBool(repo.Settings.RequireSignedCommits), "git daemon", strconv.FormatBool(repo.Settings.GitDaemon), "archived", strconv.FormatBool(repo.Settings.Archived), ) }) } func runSetDescription(c *Ctx, args []string) int { if len(args) != 2 { return c.usage() } repo, code := resolveRepo(c, args[0], policy.CanAdmin) if code >= 0 { return code } dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) if err := gitutil.WriteDescription(dir, args[1]); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } return c.emit(map[string]string{"description": gitutil.ReadDescription(dir)}, func(w io.Writer) { fmt.Fprintf(w, "description set on %s\n", repo.Path()) }) } func runSetDefaultBranch(c *Ctx, args []string) int { if len(args) != 2 { return c.usage() } repo, code := resolveRepo(c, args[0], policy.CanAdmin) if code >= 0 { return code } branch := args[1] dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) if _, err := gitutil.ResolveRef(dir, "refs/heads/"+branch); err != nil { return c.fail(protocol.ExitFailure, "no branch named %q on %s", branch, repo.Path()) } if err := gitutil.SetHead(dir, branch); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } if err := c.Store.UpdateDefaultBranch(repo.ID, branch); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } c.Store.RequestSymbolIndex(repo.ID, false) return c.emit(map[string]string{"default_branch": branch}, func(w io.Writer) { fmt.Fprintf(w, "default branch of %s is now %s\n", repo.Path(), branch) }) } func runSetWebsite(c *Ctx, args []string) int { if len(args) != 2 { return c.usage() } site := strings.TrimSpace(args[1]) if err := validateWebsite(site); err != nil { return c.failInput(err) } if len(site) > 256 { return c.fail(protocol.ExitUsage, "website URL too long (max 256)") } repo, code := resolveRepo(c, args[0], policy.CanAdmin) if code >= 0 { return code } if _, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.Website = site }); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } return c.emit(map[string]string{"website": site}, func(w io.Writer) { if site == "" { fmt.Fprintf(w, "website cleared on %s\n", repo.Path()) } else { fmt.Fprintf(w, "website set on %s\n", repo.Path()) } }) } func runSetVisibility(c *Ctx, args []string) int { if len(args) != 2 || (args[1] != "public" && args[1] != "private") { return c.usage() } repo, code := resolveRepo(c, args[0], policy.CanAdmin) if code >= 0 { return code } return setRepoVisibility(c, repo, args[1]) } // setRepoVisibility applies a visibility change the caller has already // been cleared to make. func setRepoVisibility(c *Ctx, repo store.Repo, visibility string) int { if repo.Visibility == visibility { return c.emit(map[string]string{"visibility": visibility}, func(w io.Writer) { fmt.Fprintf(w, "%s is already %s\n", repo.Path(), visibility) }) } if err := c.Store.SetRepoVisibility(repo.ID, visibility); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } // Going private takes the repository off every anonymous surface, so // git:// exposure cannot outlive the change. if visibility == "private" && repo.Settings.GitDaemon { c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.GitDaemon = false }) } c.Store.Audit(c.User.ID, "repo.visibility", map[string]any{"repo": repo.ID, "visibility": visibility}) return c.emit(map[string]string{"visibility": visibility}, func(w io.Writer) { fmt.Fprintf(w, "%s is now %s\n", repo.Path(), visibility) }) } func runGitDaemon(c *Ctx, args []string) int { if len(args) != 2 || (args[1] != "on" && args[1] != "off") { return c.usage() } repo, code := resolveRepo(c, args[0], policy.CanAdmin) if code >= 0 { return code } on := args[1] == "on" if on && repo.Visibility != "public" { return c.fail(protocol.ExitUsage, "git:// serves only public repositories; %s is private", repo.Path()) } if on && !c.Cfg.GitDaemon.Enabled { return c.fail(protocol.ExitUsage, "this instance does not run the git:// daemon ([git_daemon] enabled = false)") } s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.GitDaemon = on }) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "git-daemon %s on %s\n", args[1], repo.Path()) }) } func runArchive(c *Ctx, args []string) int { return setArchived(c, args, true) } func runUnarchive(c *Ctx, args []string) int { return setArchived(c, args, false) } func setArchived(c *Ctx, args []string, archived bool) int { if len(args) != 1 { return c.usage() } repo, code := resolveRepo(c, args[0], policy.CanAdmin) if code >= 0 { return code } return archiveRepo(c, repo, archived) } // archiveRepo flips the archived flag on a repository the caller has // already been cleared to manage. func archiveRepo(c *Ctx, repo store.Repo, archived bool) int { verb := "archive" if !archived { verb = "unarchive" } if repo.Settings.Archived == archived { return c.fail(protocol.ExitUsage, "%s is already %sd", repo.Path(), verb) } s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.Archived = archived }) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } c.Store.RecordEvent(repo.ID, c.User.ID, "repo."+verb+"d", "{}") return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%sd %s\n", verb, repo.Path()) }) } func runTopicsList(c *Ctx, args []string) int { if len(args) != 1 { return c.usage() } repo, code := resolveRepo(c, args[0], policy.CanRead) if code >= 0 { return code } topics, err := c.Store.ListTopics(repo.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } return c.emit(topics, func(w io.Writer) { tb := c.table(w, "TOPIC") for _, t := range topics { tb.row(cRef(t)) } tb.flush() }) } func runTopicsAdd(c *Ctx, args []string) int { return editTopics(c, args, true) } func runTopicsRemove(c *Ctx, args []string) int { return editTopics(c, args, false) } func editTopics(c *Ctx, args []string, add bool) int { if len(args) < 2 { return c.usage() } repo, code := resolveRepo(c, args[0], policy.CanAdmin) if code >= 0 { return code } topics := args[1:] if add { for _, t := range topics { if err := policy.ValidateTopic(t); err != nil { return c.failInput(err) } } have, err := c.Store.ListTopics(repo.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } added := 0 for _, t := range topics { if !slices.Contains(have, t) { added++ } } if len(have)+added > policy.MaxTopics { return c.fail(protocol.ExitUsage, "a repository can have at most %d topics", policy.MaxTopics) } for _, t := range topics { if err := c.Store.AddTopic(repo.ID, t); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } } } else { for _, t := range topics { if err := c.Store.RemoveTopic(repo.ID, t); err != nil { if errors.Is(err, store.ErrNotFound) { return c.fail(protocol.ExitNotFound, "%s has no topic %q", repo.Path(), t) } return c.fail(protocol.ExitFailure, "%v", err) } } } now, err := c.Store.ListTopics(repo.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } return c.emit(now, func(w io.Writer) { tb := c.table(w, "TOPIC") for _, t := range now { tb.row(cRef(t)) } tb.flush() }) } // runRepoSearch matches the query against name, owner/name, description, // and topics of every repository the caller can see. func runRepoSearch(c *Ctx, args []string) int { if len(args) != 1 { return c.usage() } if err := validQuery(args[0]); err != nil { return c.failInput(err) } q := strings.ToLower(args[0]) public, err := c.Store.ListPublicRepos() if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } own, err := c.Store.ListReposForUser(c.User.ID, 0, "") if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } seen := map[int64]bool{} type out struct { Path string `json:"path"` Visibility string `json:"visibility"` Description string `json:"description,omitempty"` Topics []string `json:"topics,omitempty"` } var ds []out for _, r := range append(public, own...) { if seen[r.ID] { continue } seen[r.ID] = true desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name)) topics, _ := c.Store.ListTopics(r.ID) if !MatchesRepo(q, r.Path(), desc, topics) { continue } ds = append(ds, out{r.Path(), r.Visibility, desc, topics}) } return c.emit(ds, func(w io.Writer) { tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION") for _, d := range ds { tb.row(cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cFlex(d.Description)) } tb.flush() }) } // MatchesRepo is the one rule for matching a repository against a text // query: its path, its description, or any of its topics. The web's // /explore filter and /search page call it too, so the three surfaces // cannot answer the same query differently. func MatchesRepo(q, path, desc string, topics []string) bool { q = strings.ToLower(q) if strings.Contains(strings.ToLower(path), q) || strings.Contains(strings.ToLower(desc), q) { return true } for _, t := range topics { if strings.Contains(strings.ToLower(t), q) { return true } } return false } func runRepoGrep(c *Ctx, args []string) int { f, err := c.parseArgs(args, flagSpec{Values: []string{"--ref"}, MaxPos: 2, Usage: "repo grep [--ref ]"}) if err != nil { return c.fail(protocol.ExitUsage, "%v", err) } path, query, ref := f.pos(0), f.pos(1), f.Value("--ref") if path == "" || query == "" { return c.usage() } if err := validQuery(query); err != nil { return c.failInput(err) } repo, code := resolveRepo(c, path, policy.CanRead) if code >= 0 { return code } if ref == "" { ref = repo.DefaultBranch } dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) if _, err := gitutil.ResolveRef(dir, ref); err != nil { return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path()) } matches, err := gitutil.Grep(dir, ref, query, maxGrepMatches) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } type out struct { Path string `json:"path"` Line int `json:"line"` Text string `json:"text"` } var ds []out for _, m := range matches { ds = append(ds, out{m.Path, m.Line, m.Text}) } return c.emit(ds, func(w io.Writer) { for _, d := range ds { fmt.Fprintf(w, "%s:%d:%s\n", d.Path, d.Line, d.Text) } }) } func runRepoPin(c *Ctx, args []string) int { return setPinned(c, args, true) } func runRepoUnpin(c *Ctx, args []string) int { return setPinned(c, args, false) } func setPinned(c *Ctx, args []string, pin bool) int { verb := "pin" if !pin { verb = "unpin" } if len(args) != 1 { return c.usage() } repo, code := resolveRepo(c, args[0], policy.CanRead) if code >= 0 { return code } if pin { if err := c.Store.PinRepo(c.User.ID, repo.ID); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } } else if err := c.Store.UnpinRepo(c.User.ID, repo.ID); err != nil { if errors.Is(err, store.ErrNotFound) { return c.fail(protocol.ExitNotFound, "%s is not pinned", repo.Path()) } return c.fail(protocol.ExitFailure, "%v", err) } return c.emit(map[string]string{verb + "ned": repo.Path()}, func(w io.Writer) { fmt.Fprintf(w, "%sned %s\n", verb, repo.Path()) }) } func runRepoBookmark(c *Ctx, args []string) int { return setBookmarked(c, args, true) } func runRepoUnbookmark(c *Ctx, args []string) int { return setBookmarked(c, args, false) } // setBookmarked mirrors setPinned. A bookmark needs only read access — // bookmarking is something you do to someone else's repository, which is // the whole point of it — and a private repository you cannot read is // not found, as everywhere. func setBookmarked(c *Ctx, args []string, on bool) int { verb := "bookmark" if !on { verb = "unbookmark" } if len(args) != 1 { return c.usage() } repo, code := resolveRepo(c, args[0], policy.CanRead) if code >= 0 { return code } if on { if err := c.Store.BookmarkRepo(c.User.ID, repo.ID); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } } else if err := c.Store.UnbookmarkRepo(c.User.ID, repo.ID); err != nil { if errors.Is(err, store.ErrNotFound) { return c.fail(protocol.ExitNotFound, "%s is not bookmarked", repo.Path()) } return c.fail(protocol.ExitFailure, "%v", err) } return c.emit(map[string]string{verb + "ed": repo.Path()}, func(w io.Writer) { fmt.Fprintf(w, "%sed %s\n", verb, repo.Path()) }) } // BookmarkOut is one row of `repo bookmarks`: the repository and how many // people have bookmarked it. type BookmarkOut struct { Path string `json:"path"` Description string `json:"description,omitempty"` Visibility string `json:"visibility"` Bookmarks int `json:"bookmarks"` } func runRepoBookmarks(c *Ctx, args []string) int { if len(args) != 0 { return c.usage() } repos, err := c.Store.ListBookmarks(c.User.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } out := []BookmarkOut{} for _, r := range repos { // A repository bookmarked while public and since made private // stays in the table and drops out of the listing, the same way // it disappears from every other surface. grant, err := c.Store.AccessRole(r.ID, c.User.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } if !policy.CanRead(c.User, r, grant) { continue } out = append(out, BookmarkOut{ Path: r.Path(), Description: gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name)), Visibility: r.Visibility, Bookmarks: c.Store.BookmarkCount(r.ID), }) } return c.emit(out, func(w io.Writer) { tb := c.table(w, "PATH", "COUNT", "DESCRIPTION") for _, b := range out { tb.row(cRef(b.Path), cNum(int64(b.Bookmarks)), cFlex(b.Description)) } tb.flush() }) } func runProtectTag(c *Ctx, args []string) int { return setProtectTag(c, args, true) } func runUnprotectTag(c *Ctx, args []string) int { return setProtectTag(c, args, false) } func setProtectTag(c *Ctx, args []string, protect bool) int { if len(args) != 2 { return c.usage() } glob := args[1] if _, err := path.Match(glob, "x"); err != nil || glob == "" { return c.fail(protocol.ExitUsage, "bad glob %q", glob) } repo, code := resolveRepo(c, args[0], policy.CanAdmin) if code >= 0 { return code } s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { has := slices.Contains(s.ProtectedTags, glob) if protect && !has { s.ProtectedTags = append(s.ProtectedTags, glob) slices.Sort(s.ProtectedTags) } if !protect && has { s.ProtectedTags = slices.DeleteFunc(s.ProtectedTags, func(g string) bool { return g == glob }) } }) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } verb := "protected" if !protect { verb = "unprotected" } return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "tags %s %s on %s\n", glob, verb, repo.Path()) }) } func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) } func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) } func setProtect(c *Ctx, args []string, protect bool) int { if len(args) != 2 { return c.usage() } repo, code := resolveRepo(c, args[0], policy.CanAdmin) if code >= 0 { return code } branch := args[1] // The list is read and rewritten inside the update, so two admins // protecting different branches at once both land. s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { has := slices.Contains(s.ProtectedBranches, branch) if protect && !has { s.ProtectedBranches = append(s.ProtectedBranches, branch) slices.Sort(s.ProtectedBranches) } if !protect && has { s.ProtectedBranches = slices.DeleteFunc(s.ProtectedBranches, func(b string) bool { return b == branch }) } }) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } verb := "protected" if !protect { verb = "unprotected" } return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%s %s on %s\n", verb, branch, repo.Path()) }) } // runRepoDiff is the compare view's command: what head adds on top of // base, measured from their merge base the way a merge request diff is, // so a base that moved on does not show up as removals (#118). func runRepoDiff(c *Ctx, args []string) int { f, err := c.parseArgs(args, flagSpec{MaxPos: 3, Usage: "repo diff "}) if err != nil || len(f.Pos) != 3 { return c.usage() } repo, code := resolveRepo(c, f.pos(0), policy.CanRead) if code >= 0 { return code } dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) base, err := gitutil.ResolveRef(dir, f.pos(1)) if err != nil { return c.fail(protocol.ExitNotFound, "no ref %q in %s", f.pos(1), repo.Path()) } head, err := gitutil.ResolveRef(dir, f.pos(2)) if err != nil { return c.fail(protocol.ExitNotFound, "no ref %q in %s", f.pos(2), repo.Path()) } mergeBase, err := gitutil.MergeBase(dir, base, head) if err != nil { return c.fail(protocol.ExitUsage, "%v", err) } patch, truncated, err := gitutil.Diff(dir, mergeBase, head, 4<<20) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } if c.JSON { return c.emit(map[string]any{"base": base, "head": head, "merge_base": mergeBase, "patch": patch, "truncated": truncated}, nil) } fmt.Fprint(c.Stdout, c.Term.diff(patch)) if truncated { fmt.Fprintln(c.Stderr, "diff truncated at 4 MiB") } return protocol.ExitOK }