krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
db5915fa14747c66e390825f7ca1cdc3b8a4506e
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-24T01:41:59Z
cmd/gitbay/main.go | 1 + e2e/org_test.go | 24 ++++++++++++++++++++++++ internal/control/org.go | 37 +++++++++++++++++++++++++++++++++++++ internal/store/orgs.go | 25 +++++++++++++++++++++++++ 4 files changed, 87 insertions(+) @@ -310,6 +310,7 @@ func orgCmd() *cobra.Command { pass("create", "create an organization", passOpts{server: []string{"org", "create"}}), pass("list", "list organizations you belong to", passOpts{server: []string{"org", "list"}}), pass("show", "show an organization and its members", passOpts{server: []string{"org", "show"}}), + pass("rename", "rename an organization: <old> <new>", passOpts{server: []string{"org", "rename"}}), pass("delete", "delete an empty organization (--yes)", passOpts{server: []string{"org", "delete"}}), group("members", "manage members", pass("add", "add or update a member: <org> <user> [--role member|admin]", passOpts{server: []string{"org", "members", "add"}}), @@ -113,6 +113,30 @@ func TestOrganizations(t *testing.T) { t.Fatalf("org delete: %s", errOut) } + // Rename: clone works at the new path, old path is gone, collisions + // with users and existing orgs are refused. + if _, _, code = inst.ssh(t, aliceKey, "", "org", "create", "oldname"); code != 0 { + t.Fatal("org create oldname failed") + } + if _, _, code = inst.ssh(t, aliceKey, "", "repo", "create", "oldname/thing"); code != 0 { + t.Fatal("repo under oldname failed") + } + rnWork := t.TempDir() + mustGit(t, rnWork, inst.gitEnv(aliceKey), "clone", inst.sshURL("oldname/thing"), "w1") + if _, errOut, code = inst.ssh(t, aliceKey, "", "org", "rename", "oldname", "bob"); code != 2 || !strings.Contains(errOut, "taken") { + t.Fatalf("rename onto user name: %d %s", code, errOut) + } + if _, errOut, code = inst.ssh(t, aliceKey, "", "org", "rename", "oldname", "newname"); code != 0 { + t.Fatalf("rename: %s", errOut) + } + mustGit(t, rnWork, inst.gitEnv(aliceKey), "clone", inst.sshURL("newname/thing"), "w2") + if out, code := gitRun(t, t.TempDir(), inst.gitEnv(aliceKey), "clone", inst.sshURL("oldname/thing")); code == 0 { + t.Fatalf("old org path still clones:\n%s", out) + } + if out, _, _ := inst.ssh(t, aliceKey, "", "repo", "list"); !strings.Contains(out, "newname/thing") { + t.Fatalf("renamed org missing from repo list:\n%s", out) + } + // Public org repos appear on the anonymous web index. if _, _, code = inst.ssh(t, aliceKey, "", "org", "create", "puborg"); code != 0 { t.Fatal("org create failed") @@ -4,6 +4,8 @@ import ( "errors" "fmt" "io" + "os" + "path/filepath" "gitbay.org/gitbay/internal/policy" "gitbay.org/gitbay/internal/protocol" @@ -17,6 +19,8 @@ func init() { Summary: "list organizations you belong to", ReadOnly: true, Run: runOrgList}) register(Command{Path: []string{"org", "show"}, Summary: "show an organization and its members: org show <name>", ReadOnly: true, Run: runOrgShow}) + register(Command{Path: []string{"org", "rename"}, + Summary: "rename an organization: org rename <old> <new> (clone URLs change)", Run: runOrgRename}) register(Command{Path: []string{"org", "delete"}, Summary: "delete an empty organization: org delete <name> --yes", Run: runOrgDelete}) register(Command{Path: []string{"org", "members", "add"}, @@ -116,6 +120,39 @@ func runOrgShow(c *Ctx, args []string) int { }) } +func runOrgRename(c *Ctx, args []string) int { + if len(args) != 2 { + return c.fail(protocol.ExitUsage, "usage: org rename <old> <new>") + } + org, code := orgAdmin(c, args[0]) + if code >= 0 { + return code + } + newName := args[1] + if err := policy.ValidateOwnerName(newName); err != nil { + return c.fail(protocol.ExitUsage, "%v", err) + } + oldDir := filepath.Join(c.Cfg.Server.Root, "repos", org.Name) + newDir := filepath.Join(c.Cfg.Server.Root, "repos", newName) + if _, err := os.Stat(newDir); err == nil { + return c.fail(protocol.ExitFailure, "repository directory %s already exists", newName) + } + if err := c.Store.RenameOrg(org.ID, newName); err != nil { + return c.fail(protocol.ExitUsage, "%v", err) + } + // Repo paths on disk derive from the owner name; move the tree. If the + // move fails, revert the database so name and disk stay consistent. + if _, err := os.Stat(oldDir); err == nil { + if err := os.Rename(oldDir, newDir); err != nil { + c.Store.RenameOrg(org.ID, org.Name) + return c.fail(protocol.ExitFailure, "moving repositories: %v", err) + } + } + return c.emit(map[string]string{"org": newName, "was": org.Name}, func(w io.Writer) { + fmt.Fprintf(w, "renamed %s to %s — clone URLs now use %s/<repo>\n", org.Name, newName, newName) + }) +} + func runOrgDelete(c *Ctx, args []string) int { var name string yes := false @@ -190,3 +190,28 @@ func (s *Store) DeleteOrg(orgID int64) error { _, err := s.DB.Exec("DELETE FROM orgs WHERE id = ?", orgID) return err } + +// RenameOrg changes an org's name, holding the shared owner-namespace +// invariant. The caller moves the on-disk repos directory afterward. +func (s *Store) RenameOrg(orgID int64, newName string) error { + tx, err := s.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + taken, err := ownerNameTaken(tx, newName) + if err != nil { + return err + } + if taken { + return fmt.Errorf("the name %q is taken", newName) + } + res, err := tx.Exec("UPDATE orgs SET name = ? WHERE id = ?", newName, orgID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return tx.Commit() +}