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

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.
 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(+)

diff --git a/cmd/gitbay/main.go b/cmd/gitbay/main.go
index 3d6ea97..3844b3f 100644
--- a/cmd/gitbay/main.go
+++ b/cmd/gitbay/main.go
@@ -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"}}),
diff --git a/e2e/org_test.go b/e2e/org_test.go
index 4d3863e..852664c 100644
--- a/e2e/org_test.go
+++ b/e2e/org_test.go
@@ -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")
diff --git a/internal/control/org.go b/internal/control/org.go
index fea2e37..5306716 100644
--- a/internal/control/org.go
+++ b/internal/control/org.go
@@ -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
diff --git a/internal/store/orgs.go b/internal/store/orgs.go
index 6ae94ee..06ba8fa 100644
--- a/internal/store/orgs.go
+++ b/internal/store/orgs.go
@@ -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()
+}