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

repo transfer

Moves a repository to another owner (yourself or an org you admin) —
database owner swap plus disk move, reverting the database if the move
fails; target-namespace collisions refused by the unique index. e2e
covers org-to-user transfer, old-path denial, collision, and non-admin
refusal.
 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(+)

diff --git a/cmd/gitbay/main.go b/cmd/gitbay/main.go
index 3844b3f..19e9da3 100644
--- a/cmd/gitbay/main.go
+++ b/cmd/gitbay/main.go
@@ -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),
diff --git a/e2e/org_test.go b/e2e/org_test.go
index bddf8c1..57b15a0 100644
--- a/e2e/org_test.go
+++ b/e2e/org_test.go
@@ -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 {
diff --git a/internal/control/repo.go b/internal/control/repo.go
index c7f02d6..8fb1005 100644
--- a/internal/control/repo.go
+++ b/internal/control/repo.go
@@ -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
diff --git a/internal/store/repos.go b/internal/store/repos.go
index aa63b23..0cb5cbf 100644
--- a/internal/store/repos.go
+++ b/internal/store/repos.go
@@ -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
+}