Commit c000478e03

c000478e0378e2282fcdc6af384481c608d4fed2

parent: 3797906826

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-07 22:22 UTC

control, store, wiki: repo rename

repo rename <owner/name> <new-name> renames a repository under the same
owner: the row and the directory move together, with the same revert on
a failed move that transfer has. Collisions, invalid names, the same
name, and non-admins are refused. Clone URLs change; the output says so.
Parity row, Users line.

Closes #190
.gitbay/wiki/Parity.org +5 −4
@@ -173,7 +173,7 @@ always markdown.
173173| job image (ci.yml) | yes | n/a | n/a |
174174| dependency checks on/off | yes | yes | yes |
175175| dependency status | yes | yes | yes |
176| delete, transfer | yes | no | no |
176| delete, transfer, rename | yes | no | no |
177177| release delete | yes | yes | yes |
178178| release asset add | yes | no | n/a |
179179| release asset remove | yes | no | yes |
@@ -341,9 +341,10 @@ with the same cursors; iOS pages with them too.
341341
342342Build secrets, mirror configuration and tokens, custom domain claims,
343343API token minting, web session listing and revocation, deploy keys,
344account and instance administration. Deleting or transferring a repository is also
345CLI-only, as is deleting an organization: each wants a typed
346confirmation, not a button.
344account and instance administration. Deleting, transferring or renaming
345a repository is also CLI-only, as is deleting an organization: each
346removes or moves what clone URLs point at, and wants a typed command,
347not a button.
347348
348349These are the only rows where a =no= is intended. Everywhere else a
349350=no= is work outstanding, and =n/a= means a surface cannot usefully
.gitbay/wiki/Users.org +1
@@ -173,6 +173,7 @@ gitbay repo list
173173gitbay repo show you/project
174174gitbay repo log you/project --limit 20 # commits with signature states
175175gitbay repo fork other/project [--name mine]
176gitbay repo rename you/project tool # clone URLs change
176177gitbay repo delete you/project --yes
177178#+end_src
178179
cmd/gitbay/main.go +1
@@ -399,6 +399,7 @@ func repoCmd() *cobra.Command {
399399 pass("show", "show repository details", passOpts{server: []string{"repo", "show"}, needsRepo: true}),
400400 pass("log", "commit log with signature states", passOpts{server: []string{"repo", "log"}, needsRepo: true}),
401401 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}),
402403 pass("delete", "delete a repository (--yes)", passOpts{server: []string{"repo", "delete"}, needsRepo: true}),
403404 pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}),
404405 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() {
3636 register(Command{Path: []string{"repo", "transfer"},
3737 Summary: "move a repository to another owner",
3838 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})
3942 register(Command{Path: []string{"repo", "delete"},
4043 Summary: "delete a repository",
4144 Usage: "repo delete <owner/name> --yes", Run: runRepoDelete})
@@ -438,6 +441,43 @@ func runRepoTransfer(c *Ctx, args []string) int {
438441 })
439442}
440443
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
441481func runRepoDelete(c *Ctx, args []string) int {
442482 var path string
443483 var yes bool
internal/store/repos.go +10
@@ -398,6 +398,16 @@ func (s *Store) ListReposForOwner(ownerKind string, ownerID int64) ([]Repo, erro
398398 return out, rows.Err()
399399}
400400
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
401411// TransferRepo moves a repository to a new owner. The unique index on
402412// (owner_kind, owner_id, name) refuses collisions in the target namespace.
403413func (s *Store) TransferRepo(repoID int64, newKind string, newOwnerID int64) error {