Commit c000478e03
Verified · cmc ci/build: success ci/test: success
.gitbay/wiki/Parity.org +5 −4
| @@ -173,7 +173,7 @@ always markdown. | ||
| 173 | 173 | | job image (ci.yml) | yes | n/a | n/a | |
| 174 | 174 | | dependency checks on/off | yes | yes | yes | |
| 175 | 175 | | dependency status | yes | yes | yes | |
| 176 | | delete, transfer | yes | no | no | | |
| 176 | | delete, transfer, rename | yes | no | no | | |
| 177 | 177 | | release delete | yes | yes | yes | |
| 178 | 178 | | release asset add | yes | no | n/a | |
| 179 | 179 | | release asset remove | yes | no | yes | |
| @@ -341,9 +341,10 @@ with the same cursors; iOS pages with them too. | ||
| 341 | 341 | |
| 342 | 342 | Build secrets, mirror configuration and tokens, custom domain claims, |
| 343 | 343 | API token minting, web session listing and revocation, deploy keys, |
| 344 | account and instance administration. Deleting or transferring a repository is also | |
| 345 | CLI-only, as is deleting an organization: each wants a typed | |
| 346 | confirmation, not a button. | |
| 344 | account and instance administration. Deleting, transferring or renaming | |
| 345 | a repository is also CLI-only, as is deleting an organization: each | |
| 346 | removes or moves what clone URLs point at, and wants a typed command, | |
| 347 | not a button. | |
| 347 | 348 | |
| 348 | 349 | These are the only rows where a =no= is intended. Everywhere else a |
| 349 | 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 | 173 | gitbay repo show you/project |
| 174 | 174 | gitbay repo log you/project --limit 20 # commits with signature states |
| 175 | 175 | gitbay repo fork other/project [--name mine] |
| 176 | gitbay repo rename you/project tool # clone URLs change | |
| 176 | 177 | gitbay repo delete you/project --yes |
| 177 | 178 | #+end_src |
| 178 | 179 | |
cmd/gitbay/main.go +1
| @@ -399,6 +399,7 @@ func repoCmd() *cobra.Command { | ||
| 399 | 399 | pass("show", "show repository details", passOpts{server: []string{"repo", "show"}, needsRepo: true}), |
| 400 | 400 | pass("log", "commit log with signature states", passOpts{server: []string{"repo", "log"}, needsRepo: true}), |
| 401 | 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 | 403 | pass("delete", "delete a repository (--yes)", passOpts{server: []string{"repo", "delete"}, needsRepo: true}), |
| 403 | 404 | pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}), |
| 404 | 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 | 36 | register(Command{Path: []string{"repo", "transfer"}, |
| 37 | 37 | Summary: "move a repository to another owner", |
| 38 | 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 | 42 | register(Command{Path: []string{"repo", "delete"}, |
| 40 | 43 | Summary: "delete a repository", |
| 41 | 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 | 481 | func runRepoDelete(c *Ctx, args []string) int { |
| 442 | 482 | var path string |
| 443 | 483 | var yes bool |
internal/store/repos.go +10
| @@ -398,6 +398,16 @@ func (s *Store) ListReposForOwner(ownerKind string, ownerID int64) ([]Repo, erro | ||
| 398 | 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 | 411 | // TransferRepo moves a repository to a new owner. The unique index on |
| 402 | 412 | // (owner_kind, owner_id, name) refuses collisions in the target namespace. |
| 403 | 413 | func (s *Store) TransferRepo(repoID int64, newKind string, newOwnerID int64) error { |