package control import ( "errors" "fmt" "io" "os" "path" "path/filepath" "slices" "strconv" "strings" "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"}, 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"}, 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"}, 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"}, 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"}, 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{cRef(d.Path), cState(d.Visibility), cFlex(d.Description)} if d.Archived { cells = append(cells, cText("[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}) } } return c.emit(d, func(w io.Writer) { bookmarked, archived := "", "" if d.Bookmarked { bookmarked = "yes" } if d.Archived { archived = "yes" } v := c.view(w) 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() } }) } 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) } // 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) } 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 { // 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()) }) } 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) } 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(cRef(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, patch) if truncated { fmt.Fprintln(c.Stderr, "diff truncated at 4 MiB") } return protocol.ExitOK }