Commit 8828f0b3d0
Verified · cmc
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 | ||
| 183 | func 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 | |||
| 181 | func runRepoDelete(c *Ctx, args []string) int { | 236 | func 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. | ||
| 261 | func (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 | } | ||