control, store, wiki: repo rename !338

merged merged by cmc on 2026-09-08 02:35 UTC · krz/gitbay:repo-rename into main

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
342Build secrets, mirror configuration and tokens, custom domain claims, 342Build secrets, mirror configuration and tokens, custom domain claims,
343API token minting, web session listing and revocation, deploy keys, 343API token minting, web session listing and revocation, deploy keys,
344account and instance administration. Deleting or transferring a repository is also 344account and instance administration. Deleting, transferring or renaming
345CLI-only, as is deleting an organization: each wants a typed 345a repository is also CLI-only, as is deleting an organization: each
346confirmation, not a button. 346removes or moves what clone URLs point at, and wants a typed command,
347not a button.
347 348
348These are the only rows where a =no= is intended. Everywhere else a 349These 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
173gitbay repo show you/project 173gitbay repo show you/project
174gitbay repo log you/project --limit 20 # commits with signature states 174gitbay repo log you/project --limit 20 # commits with signature states
175gitbay repo fork other/project [--name mine] 175gitbay repo fork other/project [--name mine]
176gitbay repo rename you/project tool # clone URLs change
176gitbay repo delete you/project --yes 177gitbay 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 @@
1package e2e
2
3import (
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).
12func 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
444func 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
441func runRepoDelete(c *Ctx, args []string) int { 481func 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.
403func (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.
403func (s *Store) TransferRepo(repoID int64, newKind string, newOwnerID int64) error { 413func (s *Store) TransferRepo(repoID int64, newKind string, newOwnerID int64) error {