control, store, wiki: repo rename !338
6 files changed, +132 −4
Layout: unified · split
.gitbay/wiki/Parity.org +5 −4
| @@ -173,7 +173,7 @@ always markdown. | |||
| 173 | | job image (ci.yml) | yes | n/a | n/a | | 173 | | job image (ci.yml) | yes | n/a | n/a | |
| 174 | | dependency checks on/off | yes | yes | yes | | 174 | | dependency checks on/off | yes | yes | yes | |
| 175 | | dependency status | yes | yes | yes | | 175 | | dependency status | yes | yes | yes | |
| 176 | | delete, transfer | yes | no | no | | 176 | | delete, transfer, rename | yes | no | no | |
| 177 | | release delete | yes | yes | yes | | 177 | | release delete | yes | yes | yes | |
| 178 | | release asset add | yes | no | n/a | | 178 | | release asset add | yes | no | n/a | |
| 179 | | release asset remove | yes | no | yes | | 179 | | release asset remove | yes | no | yes | |
| @@ -341,9 +341,10 @@ with the same cursors; iOS pages with them too. | |||
| 341 | 341 | ||
| 342 | Build secrets, mirror configuration and tokens, custom domain claims, | 342 | Build secrets, mirror configuration and tokens, custom domain claims, |
| 343 | API token minting, web session listing and revocation, deploy keys, | 343 | API token minting, web session listing and revocation, deploy keys, |
| 344 | account and instance administration. Deleting or transferring a repository is also | 344 | account and instance administration. Deleting, transferring or renaming |
| 345 | CLI-only, as is deleting an organization: each wants a typed | 345 | a repository is also CLI-only, as is deleting an organization: each |
| 346 | confirmation, not a button. | 346 | removes or moves what clone URLs point at, and wants a typed command, |
| 347 | not a button. | ||
| 347 | 348 | ||
| 348 | These are the only rows where a =no= is intended. Everywhere else a | 349 | These are the only rows where a =no= is intended. Everywhere else a |
| 349 | =no= is work outstanding, and =n/a= means a surface cannot usefully | 350 | =no= is work outstanding, and =n/a= means a surface cannot usefully |
.gitbay/wiki/Users.org +1
| @@ -173,6 +173,7 @@ gitbay repo list | |||
| 173 | gitbay repo show you/project | 173 | gitbay repo show you/project |
| 174 | gitbay repo log you/project --limit 20 # commits with signature states | 174 | gitbay repo log you/project --limit 20 # commits with signature states |
| 175 | gitbay repo fork other/project [--name mine] | 175 | gitbay repo fork other/project [--name mine] |
| 176 | gitbay repo rename you/project tool # clone URLs change | ||
| 176 | gitbay repo delete you/project --yes | 177 | gitbay repo delete you/project --yes |
| 177 | #+end_src | 178 | #+end_src |
| 178 | 179 | ||
cmd/gitbay/main.go +1
| @@ -399,6 +399,7 @@ func repoCmd() *cobra.Command { | |||
| 399 | pass("show", "show repository details", passOpts{server: []string{"repo", "show"}, needsRepo: true}), | 399 | pass("show", "show repository details", passOpts{server: []string{"repo", "show"}, needsRepo: true}), |
| 400 | pass("log", "commit log with signature states", passOpts{server: []string{"repo", "log"}, needsRepo: true}), | 400 | pass("log", "commit log with signature states", passOpts{server: []string{"repo", "log"}, needsRepo: true}), |
| 401 | pass("transfer", "move a repository to another owner: <new-owner>", passOpts{server: []string{"repo", "transfer"}, needsRepo: true}), | 401 | pass("transfer", "move a repository to another owner: <new-owner>", passOpts{server: []string{"repo", "transfer"}, needsRepo: true}), |
| 402 | pass("rename", "rename a repository: <new-name> (clone URLs change)", passOpts{server: []string{"repo", "rename"}, needsRepo: true}), | ||
| 402 | pass("delete", "delete a repository (--yes)", passOpts{server: []string{"repo", "delete"}, needsRepo: true}), | 403 | pass("delete", "delete a repository (--yes)", passOpts{server: []string{"repo", "delete"}, needsRepo: true}), |
| 403 | pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}), | 404 | pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}), |
| 404 | pass("search", "find repositories by name, description, or topic: <query>", passOpts{server: []string{"repo", "search"}}), | 405 | pass("search", "find repositories by name, description, or topic: <query>", passOpts{server: []string{"repo", "search"}}), |
e2e/reporename_test.go added +75
| @@ -0,0 +1,75 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "os" | ||
| 5 | "path/filepath" | ||
| 6 | "strings" | ||
| 7 | "testing" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // repo rename moves the directory and the row together; what hangs off | ||
| 11 | // the repository by id (issues here) follows it (#190). | ||
| 12 | func TestRepoRename(t *testing.T) { | ||
| 13 | inst := startInstance(t) | ||
| 14 | aliceKey := inst.newKey(t, "alice") | ||
| 15 | bobKey := inst.newKey(t, "bob") | ||
| 16 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | ||
| 17 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | ||
| 18 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/box"); code != 0 { | ||
| 19 | t.Fatalf("repo create: %s", errOut) | ||
| 20 | } | ||
| 21 | if _, errOut, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/box", "--title", "'keep'"); code != 0 { | ||
| 22 | t.Fatalf("issue create: %s", errOut) | ||
| 23 | } | ||
| 24 | env := inst.gitEnv(aliceKey) | ||
| 25 | work := t.TempDir() | ||
| 26 | mustGit(t, work, env, "clone", "-q", inst.sshURL("alice/box"), "box") | ||
| 27 | dir := filepath.Join(work, "box") | ||
| 28 | if err := os.WriteFile(filepath.Join(dir, "README"), []byte("hi\n"), 0o644); err != nil { | ||
| 29 | t.Fatal(err) | ||
| 30 | } | ||
| 31 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | ||
| 32 | mustGit(t, dir, env, "add", "README") | ||
| 33 | mustGit(t, dir, env, "commit", "-q", "-m", "one") | ||
| 34 | mustGit(t, dir, env, "push", "-q", "origin", "main") | ||
| 35 | |||
| 36 | if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "rename", "alice/box", "crate"); code != 4 { | ||
| 37 | t.Fatalf("non-admin rename: %d %s", code, errOut) | ||
| 38 | } | ||
| 39 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "rename", "alice/box", "box"); code != 2 { | ||
| 40 | t.Fatalf("rename to the same name: %d %s", code, errOut) | ||
| 41 | } | ||
| 42 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "rename", "alice/box", "'bad name'"); code == 0 { | ||
| 43 | t.Fatal("invalid name accepted") | ||
| 44 | } | ||
| 45 | out, errOut, code := inst.ssh(t, aliceKey, "", "repo", "rename", "alice/box", "crate", "--json") | ||
| 46 | if code != 0 || !strings.Contains(out, `"repo":"alice/crate"`) { | ||
| 47 | t.Fatalf("rename: %d %s %s", code, out, errOut) | ||
| 48 | } | ||
| 49 | |||
| 50 | clone := filepath.Join(t.TempDir(), "crate") | ||
| 51 | mustGit(t, t.TempDir(), env, "clone", "-q", inst.sshURL("alice/crate"), clone) | ||
| 52 | if _, err := os.Stat(filepath.Join(clone, "README")); err != nil { | ||
| 53 | t.Fatalf("renamed repository lost its content: %v", err) | ||
| 54 | } | ||
| 55 | if out, code := gitRun(t, t.TempDir(), env, "clone", "-q", inst.sshURL("alice/box")); code == 0 { | ||
| 56 | t.Fatalf("old path still clones:\n%s", out) | ||
| 57 | } | ||
| 58 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "show", "alice/box"); code != 3 { | ||
| 59 | t.Fatalf("old path still shows: %d", code) | ||
| 60 | } | ||
| 61 | if _, errOut, code := inst.ssh(t, aliceKey, "", "issue", "show", "alice/crate", "1"); code != 0 { | ||
| 62 | t.Fatalf("issue did not follow the rename: %s", errOut) | ||
| 63 | } | ||
| 64 | |||
| 65 | // A collision with an existing repository is refused and nothing moves. | ||
| 66 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/box"); code != 0 { | ||
| 67 | t.Fatalf("recreate box: %s", errOut) | ||
| 68 | } | ||
| 69 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "rename", "alice/crate", "box"); code == 0 || !strings.Contains(errOut, "already") { | ||
| 70 | t.Fatalf("collision rename: %d %s", code, errOut) | ||
| 71 | } | ||
| 72 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "show", "alice/crate"); code != 0 { | ||
| 73 | t.Fatal("refused rename moved the repository anyway") | ||
| 74 | } | ||
| 75 | } | ||
internal/control/repo.go +40
| @@ -36,6 +36,9 @@ func init() { | |||
| 36 | register(Command{Path: []string{"repo", "transfer"}, | 36 | register(Command{Path: []string{"repo", "transfer"}, |
| 37 | Summary: "move a repository to another owner", | 37 | Summary: "move a repository to another owner", |
| 38 | Usage: "repo transfer <owner/name> <new-owner> (clone URLs change)", Run: runRepoTransfer}) | 38 | Usage: "repo transfer <owner/name> <new-owner> (clone URLs change)", Run: runRepoTransfer}) |
| 39 | register(Command{Path: []string{"repo", "rename"}, | ||
| 40 | Summary: "rename a repository", | ||
| 41 | Usage: "repo rename <owner/name> <new-name> (clone URLs change)", Run: runRepoRename}) | ||
| 39 | register(Command{Path: []string{"repo", "delete"}, | 42 | register(Command{Path: []string{"repo", "delete"}, |
| 40 | Summary: "delete a repository", | 43 | Summary: "delete a repository", |
| 41 | Usage: "repo delete <owner/name> --yes", Run: runRepoDelete}) | 44 | Usage: "repo delete <owner/name> --yes", Run: runRepoDelete}) |
| @@ -438,6 +441,43 @@ func runRepoTransfer(c *Ctx, args []string) int { | |||
| 438 | }) | 441 | }) |
| 439 | } | 442 | } |
| 440 | 443 | ||
| 444 | func runRepoRename(c *Ctx, args []string) int { | ||
| 445 | if len(args) != 2 { | ||
| 446 | return c.fail(protocol.ExitUsage, "usage: repo rename <owner/name> <new-name>") | ||
| 447 | } | ||
| 448 | repo, code := resolveRepo(c, args[0], policy.CanAdmin) | ||
| 449 | if code >= 0 { | ||
| 450 | return code | ||
| 451 | } | ||
| 452 | newName := args[1] | ||
| 453 | if newName == repo.Name { | ||
| 454 | return c.fail(protocol.ExitUsage, "%s is already named %s", repo.Path(), newName) | ||
| 455 | } | ||
| 456 | if err := policyValidateRepoName(newName); err != nil { | ||
| 457 | return c.failErr(err) | ||
| 458 | } | ||
| 459 | oldDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) | ||
| 460 | newDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, newName) | ||
| 461 | if _, err := os.Stat(newDir); err == nil { | ||
| 462 | return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", repo.OwnerName, newName) | ||
| 463 | } | ||
| 464 | if err := c.Store.RenameRepo(repo.ID, newName); err != nil { | ||
| 465 | return c.failErr(err) | ||
| 466 | } | ||
| 467 | if err := os.Rename(oldDir, newDir); err != nil { | ||
| 468 | // Same rule as transfer: keep name and disk consistent, and say so | ||
| 469 | // if even the revert fails. | ||
| 470 | if rerr := c.Store.RenameRepo(repo.ID, repo.Name); rerr != nil { | ||
| 471 | 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()) | ||
| 472 | } | ||
| 473 | return c.fail(protocol.ExitFailure, "moving repository: %v", err) | ||
| 474 | } | ||
| 475 | newPath := repo.OwnerName + "/" + newName | ||
| 476 | return c.emit(map[string]string{"repo": newPath, "was": repo.Path()}, func(w io.Writer) { | ||
| 477 | fmt.Fprintf(w, "renamed %s to %s — clone URLs now use %s\n", repo.Path(), newPath, newPath) | ||
| 478 | }) | ||
| 479 | } | ||
| 480 | |||
| 441 | func runRepoDelete(c *Ctx, args []string) int { | 481 | func runRepoDelete(c *Ctx, args []string) int { |
| 442 | var path string | 482 | var path string |
| 443 | var yes bool | 483 | var yes bool |
internal/store/repos.go +10
| @@ -398,6 +398,16 @@ func (s *Store) ListReposForOwner(ownerKind string, ownerID int64) ([]Repo, erro | |||
| 398 | return out, rows.Err() | 398 | return out, rows.Err() |
| 399 | } | 399 | } |
| 400 | 400 | ||
| 401 | // RenameRepo changes a repository's name under the same owner. The unique | ||
| 402 | // index on (owner_kind, owner_id, name) refuses collisions. | ||
| 403 | func (s *Store) RenameRepo(repoID int64, newName string) error { | ||
| 404 | _, err := s.DB.Exec("UPDATE repos SET name = ? WHERE id = ?", newName, repoID) | ||
| 405 | if isUniqueErr(err) { | ||
| 406 | return fmt.Errorf("the owner already has a repository by that name") | ||
| 407 | } | ||
| 408 | return err | ||
| 409 | } | ||
| 410 | |||
| 401 | // TransferRepo moves a repository to a new owner. The unique index on | 411 | // TransferRepo moves a repository to a new owner. The unique index on |
| 402 | // (owner_kind, owner_id, name) refuses collisions in the target namespace. | 412 | // (owner_kind, owner_id, name) refuses collisions in the target namespace. |
| 403 | func (s *Store) TransferRepo(repoID int64, newKind string, newOwnerID int64) error { | 413 | func (s *Store) TransferRepo(repoID int64, newKind string, newOwnerID int64) error { |