Commit 8828f0b3d0

8828f0b3d0292948fb4950f96a8d311386e0ca06

parent: 0d422fbdaf

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24 02:40 UTC

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.

Layout: unified · split

cmd/gitbay/main.go +1
@@ -205,6 +205,7 @@ func repoCmd() *cobra.Command {
205 pass("list", "list repositories you own or can access", passOpts{server: []string{"repo", "list"}}), 205 pass("list", "list repositories you own or can access", passOpts{server: []string{"repo", "list"}}),
206 pass("show", "show repository details", passOpts{server: []string{"repo", "show"}, needsRepo: true}), 206 pass("show", "show repository details", passOpts{server: []string{"repo", "show"}, needsRepo: true}),
207 pass("log", "commit log with signature states", passOpts{server: []string{"repo", "log"}, needsRepo: true}), 207 pass("log", "commit log with signature states", passOpts{server: []string{"repo", "log"}, needsRepo: true}),
208 pass("transfer", "move a repository to another owner: <new-owner>", passOpts{server: []string{"repo", "transfer"}, needsRepo: true}),
208 pass("delete", "delete a repository (--yes)", passOpts{server: []string{"repo", "delete"}, needsRepo: true}), 209 pass("delete", "delete a repository (--yes)", passOpts{server: []string{"repo", "delete"}, needsRepo: true}),
209 pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}), 210 pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}),
210 local("clone", "clone via ssh: gitbay repo clone <owner/name> [dir]", cmdRepoClone), 211 local("clone", "clone via ssh: gitbay repo clone <owner/name> [dir]", cmdRepoClone),
e2e/org_test.go +27
@@ -113,6 +113,33 @@ func TestOrganizations(t *testing.T) {
113 t.Fatalf("org delete: %s", errOut) 113 t.Fatalf("org delete: %s", errOut)
114 } 114 }
115 115
116 // Transfer: org repo moves to a user; old path gone, new path clones,
117 // target collisions and non-admin transfers are refused.
118 if _, _, code = inst.ssh(t, aliceKey, "", "org", "create", "movers"); code != 0 {
119 t.Fatal("org movers failed")
120 }
121 if _, _, code = inst.ssh(t, aliceKey, "", "repo", "create", "movers/box"); code != 0 {
122 t.Fatal("movers/box failed")
123 }
124 tw := t.TempDir()
125 mustGit(t, tw, inst.gitEnv(aliceKey), "clone", inst.sshURL("movers/box"), "b1")
126 if _, errOut, code = inst.ssh(t, bobKey, "", "repo", "transfer", "movers/box", "bob"); code != 4 {
127 t.Fatalf("non-admin transfer: %d %s", code, errOut)
128 }
129 if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "transfer", "movers/box", "alice"); code != 0 {
130 t.Fatalf("transfer: %s", errOut)
131 }
132 mustGit(t, tw, inst.gitEnv(aliceKey), "clone", inst.sshURL("alice/box"), "b2")
133 if out, code := gitRun(t, t.TempDir(), inst.gitEnv(aliceKey), "clone", inst.sshURL("movers/box")); code == 0 {
134 t.Fatalf("old transfer path still clones:\n%s", out)
135 }
136 if _, _, code = inst.ssh(t, aliceKey, "", "repo", "create", "movers/box"); code != 0 {
137 t.Fatal("recreate movers/box failed")
138 }
139 if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "transfer", "movers/box", "alice"); code == 0 || !strings.Contains(errOut, "already") {
140 t.Fatalf("collision transfer: %d %s", code, errOut)
141 }
142
116 // Rename: clone works at the new path, old path is gone, collisions 143 // Rename: clone works at the new path, old path is gone, collisions
117 // with users and existing orgs are refused. 144 // with users and existing orgs are refused.
118 if _, _, code = inst.ssh(t, aliceKey, "", "org", "create", "oldname"); code != 0 { 145 if _, _, code = inst.ssh(t, aliceKey, "", "org", "create", "oldname"); code != 0 {
internal/control/repo.go +55
@@ -30,6 +30,8 @@ func init() {
30 Summary: "list repositories you own or can access", ReadOnly: true, Run: runRepoList}) 30 Summary: "list repositories you own or can access", ReadOnly: true, Run: runRepoList})
31 register(Command{Path: []string{"repo", "show"}, 31 register(Command{Path: []string{"repo", "show"},
32 Summary: "show repository details: repo show <owner/name>", ReadOnly: true, Run: runRepoShow}) 32 Summary: "show repository details: repo show <owner/name>", ReadOnly: true, Run: runRepoShow})
33 register(Command{Path: []string{"repo", "transfer"},
34 Summary: "move a repository to another owner: repo transfer <owner/name> <new-owner> (clone URLs change)", Run: runRepoTransfer})
33 register(Command{Path: []string{"repo", "delete"}, 35 register(Command{Path: []string{"repo", "delete"},
34 Summary: "delete a repository: repo delete <owner/name> --yes", Run: runRepoDelete}) 36 Summary: "delete a repository: repo delete <owner/name> --yes", Run: runRepoDelete})
35 register(Command{Path: []string{"repo", "access", "grant"}, 37 register(Command{Path: []string{"repo", "access", "grant"},
@@ -178,6 +180,59 @@ func runRepoShow(c *Ctx, args []string) int {
178 }) 180 })
179} 181}
180 182
183func runRepoTransfer(c *Ctx, args []string) int {
184 if len(args) != 2 {
185 return c.fail(protocol.ExitUsage, "usage: repo transfer <owner/name> <new-owner>")
186 }
187 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
188 if code >= 0 {
189 return code
190 }
191 newOwner := args[1]
192 if newOwner == repo.OwnerName {
193 return c.fail(protocol.ExitUsage, "%s already owns this repository", newOwner)
194 }
195
196 // Target: yourself, or an org you admin — same rule as repo create.
197 newKind, newID := "", int64(0)
198 if newOwner == c.User.Username {
199 newKind, newID = "user", c.User.ID
200 } else if org, err := c.Store.OrgByName(newOwner); err == nil {
201 role, err := c.Store.OrgRole(org.ID, c.User.ID)
202 if err != nil {
203 return c.fail(protocol.ExitFailure, "%v", err)
204 }
205 if role != "admin" {
206 return c.fail(protocol.ExitDenied, "only admins of %s can receive repositories there", newOwner)
207 }
208 newKind, newID = "org", org.ID
209 } else {
210 return c.fail(protocol.ExitDenied, "cannot transfer to %q: not you and not an organization you can see", newOwner)
211 }
212
213 oldDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
214 newDir := RepoDir(c.Cfg.Server.Root, newOwner, repo.Name)
215 if _, err := os.Stat(newDir); err == nil {
216 return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", newOwner, repo.Name)
217 }
218 if err := c.Store.TransferRepo(repo.ID, newKind, newID); err != nil {
219 return c.fail(protocol.ExitUsage, "%v", err)
220 }
221 if err := os.MkdirAll(filepath.Dir(newDir), 0o750); err != nil {
222 c.Store.TransferRepo(repo.ID, repo.OwnerKind, repo.OwnerID)
223 return c.fail(protocol.ExitFailure, "%v", err)
224 }
225 if err := os.Rename(oldDir, newDir); err != nil {
226 // Keep name and disk consistent: revert the database change.
227 c.Store.TransferRepo(repo.ID, repo.OwnerKind, repo.OwnerID)
228 return c.fail(protocol.ExitFailure, "moving repository: %v", err)
229 }
230 newPath := newOwner + "/" + repo.Name
231 return c.emit(map[string]string{"repo": newPath, "was": repo.Path()}, func(w io.Writer) {
232 fmt.Fprintf(w, "transferred %s to %s — clone URLs now use %s\n", repo.Path(), newPath, newPath)
233 })
234}
235
181func runRepoDelete(c *Ctx, args []string) int { 236func runRepoDelete(c *Ctx, args []string) int {
182 var path string 237 var path string
183 var yes bool 238 var yes bool
internal/store/repos.go +11
@@ -255,3 +255,14 @@ func (s *Store) ListReposForOwner(ownerKind string, ownerID int64) ([]Repo, erro
255 } 255 }
256 return out, rows.Err() 256 return out, rows.Err()
257} 257}
258
259// TransferRepo moves a repository to a new owner. The unique index on
260// (owner_kind, owner_id, name) refuses collisions in the target namespace.
261func (s *Store) TransferRepo(repoID int64, newKind string, newOwnerID int64) error {
262 _, err := s.DB.Exec("UPDATE repos SET owner_kind = ?, owner_id = ? WHERE id = ?",
263 newKind, newOwnerID, repoID)
264 if isUniqueErr(err) {
265 return fmt.Errorf("the target owner already has a repository by that name")
266 }
267 return err
268}