krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
8828f0b3d0292948fb4950f96a8d311386e0ca06
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-24T02:40:41Z
cmd/gitbay/main.go | 1 + e2e/org_test.go | 27 ++++++++++++++++++++++++ internal/control/repo.go | 55 ++++++++++++++++++++++++++++++++++++++++++++++++ internal/store/repos.go | 11 ++++++++++ 4 files changed, 94 insertions(+) @@ -205,6 +205,7 @@ func repoCmd() *cobra.Command { pass("list", "list repositories you own or can access", passOpts{server: []string{"repo", "list"}}), pass("show", "show repository details", passOpts{server: []string{"repo", "show"}, needsRepo: true}), pass("log", "commit log with signature states", passOpts{server: []string{"repo", "log"}, needsRepo: true}), + pass("transfer", "move a repository to another owner: <new-owner>", passOpts{server: []string{"repo", "transfer"}, needsRepo: true}), pass("delete", "delete a repository (--yes)", passOpts{server: []string{"repo", "delete"}, needsRepo: true}), pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}), local("clone", "clone via ssh: gitbay repo clone <owner/name> [dir]", cmdRepoClone), @@ -113,6 +113,33 @@ func TestOrganizations(t *testing.T) { t.Fatalf("org delete: %s", errOut) } + // Transfer: org repo moves to a user; old path gone, new path clones, + // target collisions and non-admin transfers are refused. + if _, _, code = inst.ssh(t, aliceKey, "", "org", "create", "movers"); code != 0 { + t.Fatal("org movers failed") + } + if _, _, code = inst.ssh(t, aliceKey, "", "repo", "create", "movers/box"); code != 0 { + t.Fatal("movers/box failed") + } + tw := t.TempDir() + mustGit(t, tw, inst.gitEnv(aliceKey), "clone", inst.sshURL("movers/box"), "b1") + if _, errOut, code = inst.ssh(t, bobKey, "", "repo", "transfer", "movers/box", "bob"); code != 4 { + t.Fatalf("non-admin transfer: %d %s", code, errOut) + } + if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "transfer", "movers/box", "alice"); code != 0 { + t.Fatalf("transfer: %s", errOut) + } + mustGit(t, tw, inst.gitEnv(aliceKey), "clone", inst.sshURL("alice/box"), "b2") + if out, code := gitRun(t, t.TempDir(), inst.gitEnv(aliceKey), "clone", inst.sshURL("movers/box")); code == 0 { + t.Fatalf("old transfer path still clones:\n%s", out) + } + if _, _, code = inst.ssh(t, aliceKey, "", "repo", "create", "movers/box"); code != 0 { + t.Fatal("recreate movers/box failed") + } + if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "transfer", "movers/box", "alice"); code == 0 || !strings.Contains(errOut, "already") { + t.Fatalf("collision transfer: %d %s", code, errOut) + } + // Rename: clone works at the new path, old path is gone, collisions // with users and existing orgs are refused. if _, _, code = inst.ssh(t, aliceKey, "", "org", "create", "oldname"); code != 0 { @@ -30,6 +30,8 @@ func init() { Summary: "list repositories you own or can access", ReadOnly: true, Run: runRepoList}) register(Command{Path: []string{"repo", "show"}, Summary: "show repository details: repo show <owner/name>", ReadOnly: true, Run: runRepoShow}) + register(Command{Path: []string{"repo", "transfer"}, + Summary: "move a repository to another owner: repo transfer <owner/name> <new-owner> (clone URLs change)", Run: runRepoTransfer}) register(Command{Path: []string{"repo", "delete"}, Summary: "delete a repository: repo delete <owner/name> --yes", Run: runRepoDelete}) register(Command{Path: []string{"repo", "access", "grant"}, @@ -178,6 +180,59 @@ func runRepoShow(c *Ctx, args []string) int { }) } +func runRepoTransfer(c *Ctx, args []string) int { + if len(args) != 2 { + return c.fail(protocol.ExitUsage, "usage: repo transfer <owner/name> <new-owner>") + } + 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) + } + if err := c.Store.TransferRepo(repo.ID, newKind, newID); err != nil { + return c.fail(protocol.ExitUsage, "%v", err) + } + if err := os.MkdirAll(filepath.Dir(newDir), 0o750); err != nil { + c.Store.TransferRepo(repo.ID, repo.OwnerKind, repo.OwnerID) + return c.fail(protocol.ExitFailure, "%v", err) + } + if err := os.Rename(oldDir, newDir); err != nil { + // Keep name and disk consistent: revert the database change. + c.Store.TransferRepo(repo.ID, repo.OwnerKind, repo.OwnerID) + return c.fail(protocol.ExitFailure, "moving repository: %v", 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 runRepoDelete(c *Ctx, args []string) int { var path string var yes bool @@ -255,3 +255,14 @@ func (s *Store) ListReposForOwner(ownerKind string, ownerID int64) ([]Repo, erro } return out, rows.Err() } + +// TransferRepo moves a repository to a new owner. The unique index on +// (owner_kind, owner_id, name) refuses collisions in the target namespace. +func (s *Store) TransferRepo(repoID int64, newKind string, newOwnerID int64) error { + _, err := s.DB.Exec("UPDATE repos SET owner_kind = ?, owner_id = ? WHERE id = ?", + newKind, newOwnerID, repoID) + if isUniqueErr(err) { + return fmt.Errorf("the target owner already has a repository by that name") + } + return err +}