Commit db5915fa14

db5915fa14747c66e390825f7ca1cdc3b8a4506e

parent: f591848f8a

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24 01:41 UTC

org rename

DB rename and on-disk repos/ move under the shared owner-namespace
check; the move failing reverts the database so name and disk stay
consistent. Admin-only; clone URLs change. e2e covers new-path clone,
old-path denial, and collision refusals.

Layout: unified · split

cmd/gitbay/main.go +1
@@ -310,6 +310,7 @@ func orgCmd() *cobra.Command {
310310 pass("create", "create an organization", passOpts{server: []string{"org", "create"}}),
311311 pass("list", "list organizations you belong to", passOpts{server: []string{"org", "list"}}),
312312 pass("show", "show an organization and its members", passOpts{server: []string{"org", "show"}}),
313 pass("rename", "rename an organization: <old> <new>", passOpts{server: []string{"org", "rename"}}),
313314 pass("delete", "delete an empty organization (--yes)", passOpts{server: []string{"org", "delete"}}),
314315 group("members", "manage members",
315316 pass("add", "add or update a member: <org> <user> [--role member|admin]", passOpts{server: []string{"org", "members", "add"}}),
e2e/org_test.go +24
@@ -113,6 +113,30 @@ func TestOrganizations(t *testing.T) {
113113 t.Fatalf("org delete: %s", errOut)
114114 }
115115
116 // Rename: clone works at the new path, old path is gone, collisions
117 // with users and existing orgs are refused.
118 if _, _, code = inst.ssh(t, aliceKey, "", "org", "create", "oldname"); code != 0 {
119 t.Fatal("org create oldname failed")
120 }
121 if _, _, code = inst.ssh(t, aliceKey, "", "repo", "create", "oldname/thing"); code != 0 {
122 t.Fatal("repo under oldname failed")
123 }
124 rnWork := t.TempDir()
125 mustGit(t, rnWork, inst.gitEnv(aliceKey), "clone", inst.sshURL("oldname/thing"), "w1")
126 if _, errOut, code = inst.ssh(t, aliceKey, "", "org", "rename", "oldname", "bob"); code != 2 || !strings.Contains(errOut, "taken") {
127 t.Fatalf("rename onto user name: %d %s", code, errOut)
128 }
129 if _, errOut, code = inst.ssh(t, aliceKey, "", "org", "rename", "oldname", "newname"); code != 0 {
130 t.Fatalf("rename: %s", errOut)
131 }
132 mustGit(t, rnWork, inst.gitEnv(aliceKey), "clone", inst.sshURL("newname/thing"), "w2")
133 if out, code := gitRun(t, t.TempDir(), inst.gitEnv(aliceKey), "clone", inst.sshURL("oldname/thing")); code == 0 {
134 t.Fatalf("old org path still clones:\n%s", out)
135 }
136 if out, _, _ := inst.ssh(t, aliceKey, "", "repo", "list"); !strings.Contains(out, "newname/thing") {
137 t.Fatalf("renamed org missing from repo list:\n%s", out)
138 }
139
116140 // Public org repos appear on the anonymous web index.
117141 if _, _, code = inst.ssh(t, aliceKey, "", "org", "create", "puborg"); code != 0 {
118142 t.Fatal("org create failed")
internal/control/org.go +37
@@ -4,6 +4,8 @@ import (
44 "errors"
55 "fmt"
66 "io"
7 "os"
8 "path/filepath"
79
810 "gitbay.org/gitbay/internal/policy"
911 "gitbay.org/gitbay/internal/protocol"
@@ -17,6 +19,8 @@ func init() {
1719 Summary: "list organizations you belong to", ReadOnly: true, Run: runOrgList})
1820 register(Command{Path: []string{"org", "show"},
1921 Summary: "show an organization and its members: org show <name>", ReadOnly: true, Run: runOrgShow})
22 register(Command{Path: []string{"org", "rename"},
23 Summary: "rename an organization: org rename <old> <new> (clone URLs change)", Run: runOrgRename})
2024 register(Command{Path: []string{"org", "delete"},
2125 Summary: "delete an empty organization: org delete <name> --yes", Run: runOrgDelete})
2226 register(Command{Path: []string{"org", "members", "add"},
@@ -116,6 +120,39 @@ func runOrgShow(c *Ctx, args []string) int {
116120 })
117121}
118122
123func runOrgRename(c *Ctx, args []string) int {
124 if len(args) != 2 {
125 return c.fail(protocol.ExitUsage, "usage: org rename <old> <new>")
126 }
127 org, code := orgAdmin(c, args[0])
128 if code >= 0 {
129 return code
130 }
131 newName := args[1]
132 if err := policy.ValidateOwnerName(newName); err != nil {
133 return c.fail(protocol.ExitUsage, "%v", err)
134 }
135 oldDir := filepath.Join(c.Cfg.Server.Root, "repos", org.Name)
136 newDir := filepath.Join(c.Cfg.Server.Root, "repos", newName)
137 if _, err := os.Stat(newDir); err == nil {
138 return c.fail(protocol.ExitFailure, "repository directory %s already exists", newName)
139 }
140 if err := c.Store.RenameOrg(org.ID, newName); err != nil {
141 return c.fail(protocol.ExitUsage, "%v", err)
142 }
143 // Repo paths on disk derive from the owner name; move the tree. If the
144 // move fails, revert the database so name and disk stay consistent.
145 if _, err := os.Stat(oldDir); err == nil {
146 if err := os.Rename(oldDir, newDir); err != nil {
147 c.Store.RenameOrg(org.ID, org.Name)
148 return c.fail(protocol.ExitFailure, "moving repositories: %v", err)
149 }
150 }
151 return c.emit(map[string]string{"org": newName, "was": org.Name}, func(w io.Writer) {
152 fmt.Fprintf(w, "renamed %s to %s — clone URLs now use %s/<repo>\n", org.Name, newName, newName)
153 })
154}
155
119156func runOrgDelete(c *Ctx, args []string) int {
120157 var name string
121158 yes := false
internal/store/orgs.go +25
@@ -190,3 +190,28 @@ func (s *Store) DeleteOrg(orgID int64) error {
190190 _, err := s.DB.Exec("DELETE FROM orgs WHERE id = ?", orgID)
191191 return err
192192}
193
194// RenameOrg changes an org's name, holding the shared owner-namespace
195// invariant. The caller moves the on-disk repos directory afterward.
196func (s *Store) RenameOrg(orgID int64, newName string) error {
197 tx, err := s.DB.Begin()
198 if err != nil {
199 return err
200 }
201 defer tx.Rollback()
202 taken, err := ownerNameTaken(tx, newName)
203 if err != nil {
204 return err
205 }
206 if taken {
207 return fmt.Errorf("the name %q is taken", newName)
208 }
209 res, err := tx.Exec("UPDATE orgs SET name = ? WHERE id = ?", newName, orgID)
210 if err != nil {
211 return err
212 }
213 if n, _ := res.RowsAffected(); n == 0 {
214 return ErrNotFound
215 }
216 return tx.Commit()
217}