Commit 6576de5182

6576de5182d9c1ce005956931d1be475343cac45

parent: a166de255d

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 21:44 UTC

control: repository delete, rename, transfer and org rename wait out a full backup

Ref #259

Layout: unified · split

internal/control/backuplock_test.go added +40
@@ -0,0 +1,40 @@
1package control
2
3import (
4 "strings"
5 "testing"
6
7 "gitbay.org/gitbay/internal/backuplock"
8 "gitbay.org/gitbay/internal/protocol"
9)
10
11func TestRepoDeleteAndRenameRefusedDuringBackup(t *testing.T) {
12 st, repo, uid := newQueueTestRepo(t)
13 owner, err := st.UserByID(uid)
14 if err != nil {
15 t.Fatal(err)
16 }
17 root := t.TempDir()
18 release, err := backuplock.Hold(root)
19 if err != nil {
20 t.Fatal(err)
21 }
22 for _, argv := range [][]string{
23 {"repo", "rename", repo.Path(), "renamed"},
24 {"repo", "delete", repo.Path(), "--yes"},
25 } {
26 c, errOut := pruneCtx(st, root, owner)
27 if code := Dispatch(c, argv); code != protocol.ExitFailure || !strings.Contains(errOut.String(), "a backup is running") {
28 t.Fatalf("%v during a backup: exit %d, %s", argv, code, errOut)
29 }
30 }
31 if got, err := st.RepoByID(repo.ID); err != nil || got.Name != repo.Name {
32 t.Fatalf("repository changed during a backup: %+v, %v", got, err)
33 }
34 release()
35
36 c, errOut := pruneCtx(st, root, owner)
37 if code := Dispatch(c, []string{"repo", "delete", repo.Path(), "--yes"}); code != protocol.ExitOK {
38 t.Fatalf("delete after the backup: exit %d, %s", code, errOut)
39 }
40}
internal/control/org.go +5
@@ -167,6 +167,11 @@ func runOrgRename(c *Ctx, args []string) int {
167 if _, err := os.Stat(newDir); err == nil { 167 if _, err := os.Stat(newDir); err == nil {
168 return c.fail(protocol.ExitFailure, "repository directory %s already exists", newName) 168 return c.fail(protocol.ExitFailure, "repository directory %s already exists", newName)
169 } 169 }
170 release, lockCode := holdOffBackup(c)
171 if lockCode >= 0 {
172 return lockCode
173 }
174 defer release()
170 if err := c.Store.RenameOrg(org.ID, newName); err != nil { 175 if err := c.Store.RenameOrg(org.ID, newName); err != nil {
171 return c.failErr(err) 176 return c.failErr(err)
172 } 177 }
internal/control/repo.go +28
@@ -11,6 +11,7 @@ import (
11 "strconv" 11 "strconv"
12 "strings" 12 "strings"
13 13
14 "gitbay.org/gitbay/internal/backuplock"
14 "gitbay.org/gitbay/internal/gitutil" 15 "gitbay.org/gitbay/internal/gitutil"
15 "gitbay.org/gitbay/internal/policy" 16 "gitbay.org/gitbay/internal/policy"
16 "gitbay.org/gitbay/internal/protocol" 17 "gitbay.org/gitbay/internal/protocol"
@@ -513,6 +514,11 @@ func runRepoTransfer(c *Ctx, args []string) int {
513 if _, err := os.Stat(newDir); err == nil { 514 if _, err := os.Stat(newDir); err == nil {
514 return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", newOwner, repo.Name) 515 return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", newOwner, repo.Name)
515 } 516 }
517 release, lockCode := holdOffBackup(c)
518 if lockCode >= 0 {
519 return lockCode
520 }
521 defer release()
516 // The directory moves before the record changes: a move that fails 522 // The directory moves before the record changes: a move that fails
517 // leaves nothing to undo, whereas the record's change into an org 523 // leaves nothing to undo, whereas the record's change into an org
518 // folds labels and milestones into the org's rows, which a revert 524 // folds labels and milestones into the org's rows, which a revert
@@ -557,6 +563,11 @@ func runRepoRename(c *Ctx, args []string) int {
557 if _, err := os.Stat(newDir); err == nil { 563 if _, err := os.Stat(newDir); err == nil {
558 return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", repo.OwnerName, newName) 564 return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", repo.OwnerName, newName)
559 } 565 }
566 release, lockCode := holdOffBackup(c)
567 if lockCode >= 0 {
568 return lockCode
569 }
570 defer release()
560 if err := c.Store.RenameRepo(repo.ID, newName); err != nil { 571 if err := c.Store.RenameRepo(repo.ID, newName); err != nil {
561 return c.failErr(err) 572 return c.failErr(err)
562 } 573 }
@@ -609,6 +620,11 @@ func runRepoDelete(c *Ctx, args []string) int {
609// webhooks; an instance that needs to hear about it wants the audit log 620// webhooks; an instance that needs to hear about it wants the audit log
610// (#112). 621// (#112).
611func deleteRepo(c *Ctx, repo store.Repo) int { 622func deleteRepo(c *Ctx, repo store.Repo) int {
623 release, lockCode := holdOffBackup(c)
624 if lockCode >= 0 {
625 return lockCode
626 }
627 defer release()
612 // Open MRs sourced from this repo keep working (targets own the 628 // Open MRs sourced from this repo keep working (targets own the
613 // objects) but must show that the source is gone. 629 // objects) but must show that the source is gone.
614 if err := c.Store.MarkSourceGoneForRepo(repo.ID); err != nil { 630 if err := c.Store.MarkSourceGoneForRepo(repo.ID); err != nil {
@@ -625,6 +641,18 @@ func deleteRepo(c *Ctx, repo store.Repo) int {
625 }) 641 })
626} 642}
627 643
644// holdOffBackup keeps a full backup from starting while a repository
645// directory moves or goes, and refuses while one runs: the backup's
646// database snapshot names every repository its walk then archives
647// (#259). The caller defers the returned release.
648func holdOffBackup(c *Ctx) (func(), int) {
649 release, err := backuplock.TryShared(c.Cfg.Server.Root)
650 if err != nil {
651 return nil, c.fail(protocol.ExitFailure, "%v", err)
652 }
653 return release, -1
654}
655
628func runAccessGrant(c *Ctx, args []string) int { 656func runAccessGrant(c *Ctx, args []string) int {
629 if len(args) != 3 || !slices.Contains([]string{"read", "write", "admin"}, args[2]) { 657 if len(args) != 3 || !slices.Contains([]string{"read", "write", "admin"}, args[2]) {
630 return c.usage() 658 return c.usage()