store: one admin's settings change no longer overwrites another's !231

merged merged by cmc on 2026-09-04 15:58 UTC · krz/gitbay:settings-json-set into main

5 files changed, +209 −43

Layout: unified · split

internal/control/migrate.go +3 −1
@@ -215,8 +215,10 @@ func runAccountImportBundle(c *Ctx, args []string) int {
215215 if len(cmds) > 0 {
216216 deferrals = append(deferrals, deferred{path, cmds})
217217 }
218 // An import writes the whole blob: the repository was created a
219 // few lines up and nobody else holds settings on it yet.
218220 s.GitDaemon = false // instance-dependent; opt back in explicitly
219 c.Store.SetRepoSettings(repo.ID, s)
221 c.Store.UpdateRepoSettings(repo.ID, func(cur *store.RepoSettings) { *cur = s })
220222
221223 for _, bi := range br.Issues {
222224 key := fmt.Sprintf("mig-issue:%d", bi.Number)
internal/control/mr.go +10 −15
@@ -133,9 +133,8 @@ func runRequireApprovals(c *Ctx, args []string) int {
133133 if code >= 0 {
134134 return code
135135 }
136 s := repo.Settings
137 s.RequireApprovals = n
138 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
136 s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.RequireApprovals = n })
137 if err != nil {
139138 return c.fail(protocol.ExitFailure, "%v", err)
140139 }
141140 return c.emit(s, func(w io.Writer) {
@@ -151,9 +150,8 @@ func runRequireResolved(c *Ctx, args []string) int {
151150 if code >= 0 {
152151 return code
153152 }
154 s := repo.Settings
155 s.RequireResolved = args[1] == "on"
156 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
153 s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.RequireResolved = args[1] == "on" })
154 if err != nil {
157155 return c.fail(protocol.ExitFailure, "%v", err)
158156 }
159157 return c.emit(s, func(w io.Writer) {
@@ -169,9 +167,8 @@ func runRequireCodeowners(c *Ctx, args []string) int {
169167 if code >= 0 {
170168 return code
171169 }
172 s := repo.Settings
173 s.RequireCodeowners = args[1] == "on"
174 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
170 s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.RequireCodeowners = args[1] == "on" })
171 if err != nil {
175172 return c.fail(protocol.ExitFailure, "%v", err)
176173 }
177174 return c.emit(s, func(w io.Writer) {
@@ -187,9 +184,8 @@ func runRequireChecks(c *Ctx, args []string) int {
187184 if code >= 0 {
188185 return code
189186 }
190 s := repo.Settings
191 s.RequireChecks = args[1] == "on"
192 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
187 s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.RequireChecks = args[1] == "on" })
188 if err != nil {
193189 return c.fail(protocol.ExitFailure, "%v", err)
194190 }
195191 return c.emit(s, func(w io.Writer) {
@@ -205,9 +201,8 @@ func runRequireSigned(c *Ctx, args []string) int {
205201 if code >= 0 {
206202 return code
207203 }
208 s := repo.Settings
209 s.RequireSignedCommits = args[1] == "on"
210 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
204 s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.RequireSignedCommits = args[1] == "on" })
205 if err != nil {
211206 return c.fail(protocol.ExitFailure, "%v", err)
212207 }
213208 return c.emit(s, func(w io.Writer) {
internal/control/repo.go +19 −22
@@ -562,9 +562,7 @@ func runSetWebsite(c *Ctx, args []string) int {
562562 if code >= 0 {
563563 return code
564564 }
565 s := repo.Settings
566 s.Website = site
567 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
565 if _, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.Website = site }); err != nil {
568566 return c.fail(protocol.ExitFailure, "%v", err)
569567 }
570568 return c.emit(map[string]string{"website": site}, func(w io.Writer) {
@@ -601,9 +599,7 @@ func setRepoVisibility(c *Ctx, repo store.Repo, visibility string) int {
601599 // Going private takes the repository off every anonymous surface, so
602600 // git:// exposure cannot outlive the change.
603601 if visibility == "private" && repo.Settings.GitDaemon {
604 s := repo.Settings
605 s.GitDaemon = false
606 c.Store.SetRepoSettings(repo.ID, s)
602 c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.GitDaemon = false })
607603 }
608604 c.Store.Audit(c.User.ID, "repo.visibility", map[string]any{"repo": repo.ID, "visibility": visibility})
609605 return c.emit(map[string]string{"visibility": visibility}, func(w io.Writer) {
@@ -626,9 +622,8 @@ func runGitDaemon(c *Ctx, args []string) int {
626622 if on && !c.Cfg.GitDaemon.Enabled {
627623 return c.fail(protocol.ExitUsage, "this instance does not run the git:// daemon ([git_daemon] enabled = false)")
628624 }
629 s := repo.Settings
630 s.GitDaemon = on
631 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
625 s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.GitDaemon = on })
626 if err != nil {
632627 return c.fail(protocol.ExitFailure, "%v", err)
633628 }
634629 return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "git-daemon %s on %s\n", args[1], repo.Path()) })
@@ -662,9 +657,8 @@ func archiveRepo(c *Ctx, repo store.Repo, archived bool) int {
662657 if repo.Settings.Archived == archived {
663658 return c.fail(protocol.ExitUsage, "%s is already %sd", repo.Path(), verb)
664659 }
665 s := repo.Settings
666 s.Archived = archived
667 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
660 s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.Archived = archived })
661 if err != nil {
668662 return c.fail(protocol.ExitFailure, "%v", err)
669663 }
670664 c.Store.RecordEvent(repo.ID, c.User.ID, "repo."+verb+"d", "{}")
@@ -898,16 +892,19 @@ func setProtect(c *Ctx, args []string, protect bool) int {
898892 return code
899893 }
900894 branch := args[1]
901 s := repo.Settings
902 has := slices.Contains(s.ProtectedBranches, branch)
903 if protect && !has {
904 s.ProtectedBranches = append(s.ProtectedBranches, branch)
905 slices.Sort(s.ProtectedBranches)
906 }
907 if !protect && has {
908 s.ProtectedBranches = slices.DeleteFunc(s.ProtectedBranches, func(b string) bool { return b == branch })
909 }
910 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
895 // The list is read and rewritten inside the update, so two admins
896 // protecting different branches at once both land.
897 s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) {
898 has := slices.Contains(s.ProtectedBranches, branch)
899 if protect && !has {
900 s.ProtectedBranches = append(s.ProtectedBranches, branch)
901 slices.Sort(s.ProtectedBranches)
902 }
903 if !protect && has {
904 s.ProtectedBranches = slices.DeleteFunc(s.ProtectedBranches, func(b string) bool { return b == branch })
905 }
906 })
907 if err != nil {
911908 return c.fail(protocol.ExitFailure, "%v", err)
912909 }
913910 verb := "protected"
internal/store/repos.go +57 −5
@@ -1,6 +1,7 @@
11package store
22
33import (
4 "context"
45 "database/sql"
56 "encoding/json"
67 "errors"
@@ -92,13 +93,64 @@ func (s *Store) SetRepoVisibility(repoID int64, visibility string) error {
9293 return err
9394}
9495
95func (s *Store) SetRepoSettings(repoID int64, settings RepoSettings) error {
96 raw, err := json.Marshal(settings)
96// UpdateRepoSettings applies mutate to the repository's settings and
97// stores the result, returning what was stored.
98//
99// settings_json is one blob, so changing one field means writing all of
100// them. Callers used to read the struct off a Repo they had loaded
101// earlier, change a field and write the whole blob back, which loses the
102// other admin's change whenever two ran at once — last write wins over a
103// value it never read. The read and the write happen here instead, inside
104// one transaction, and BEGIN IMMEDIATE takes the write lock up front: a
105// second updater waits at the start rather than discovering the conflict
106// after it has already read a stale blob.
107func (s *Store) UpdateRepoSettings(repoID int64, mutate func(*RepoSettings)) (RepoSettings, error) {
108 ctx := context.Background()
109 var out RepoSettings
110 // The whole exchange must run on one connection for BEGIN to bracket
111 // it; the pool would otherwise be free to hand the statements out
112 // separately.
113 conn, err := s.DB.Conn(ctx)
97114 if err != nil {
98 return err
115 return out, err
99116 }
100 _, err = s.DB.Exec("UPDATE repos SET settings_json = ? WHERE id = ?", string(raw), repoID)
101 return err
117 defer conn.Close()
118 if _, err := conn.ExecContext(ctx, "BEGIN IMMEDIATE"); err != nil {
119 return out, err
120 }
121 committed := false
122 defer func() {
123 if !committed {
124 conn.ExecContext(ctx, "ROLLBACK")
125 }
126 }()
127 var raw string
128 if err := conn.QueryRowContext(ctx,
129 "SELECT settings_json FROM repos WHERE id = ?", repoID).Scan(&raw); err != nil {
130 if errors.Is(err, sql.ErrNoRows) {
131 return out, ErrNotFound
132 }
133 return out, err
134 }
135 if raw != "" {
136 if err := json.Unmarshal([]byte(raw), &out); err != nil {
137 return out, err
138 }
139 }
140 mutate(&out)
141 next, err := json.Marshal(out)
142 if err != nil {
143 return out, err
144 }
145 if _, err := conn.ExecContext(ctx,
146 "UPDATE repos SET settings_json = ? WHERE id = ?", string(next), repoID); err != nil {
147 return out, err
148 }
149 if _, err := conn.ExecContext(ctx, "COMMIT"); err != nil {
150 return out, err
151 }
152 committed = true
153 return out, nil
102154}
103155
104156// CreateFork is CreateRepo with fork_of set in the same insert, so a fork
internal/store/reposettings_test.go added +120
@@ -0,0 +1,120 @@
1package store
2
3import (
4 "sync"
5 "testing"
6)
7
8func settingsFixture(t *testing.T) (*Store, int64) {
9 t.Helper()
10 s := open(t)
11 if err := s.MigrateUp(); err != nil {
12 t.Fatal(err)
13 }
14 uid, err := s.CreateUser("cmc", true)
15 if err != nil {
16 t.Fatal(err)
17 }
18 repoID, err := s.CreateRepo("user", uid, "lib", "public")
19 if err != nil {
20 t.Fatal(err)
21 }
22 return s, repoID
23}
24
25func TestUpdateRepoSettingsRoundTrip(t *testing.T) {
26 s, repoID := settingsFixture(t)
27 got, err := s.UpdateRepoSettings(repoID, func(set *RepoSettings) { set.RequireApprovals = 2 })
28 if err != nil {
29 t.Fatal(err)
30 }
31 if got.RequireApprovals != 2 {
32 t.Fatalf("returned %+v", got)
33 }
34 repo, err := s.RepoByID(repoID)
35 if err != nil {
36 t.Fatal(err)
37 }
38 if repo.Settings.RequireApprovals != 2 {
39 t.Fatalf("stored %+v", repo.Settings)
40 }
41
42 // Turning a flag back off must persist: the JSON tags are omitempty,
43 // so "false" is an absent key, and a patch-style write would drop it.
44 if _, err := s.UpdateRepoSettings(repoID, func(set *RepoSettings) { set.RequireChecks = true }); err != nil {
45 t.Fatal(err)
46 }
47 got, err = s.UpdateRepoSettings(repoID, func(set *RepoSettings) { set.RequireChecks = false })
48 if err != nil {
49 t.Fatal(err)
50 }
51 if got.RequireChecks {
52 t.Fatal("require_checks stayed on")
53 }
54 repo, _ = s.RepoByID(repoID)
55 if repo.Settings.RequireChecks || repo.Settings.RequireApprovals != 2 {
56 t.Fatalf("stored %+v", repo.Settings)
57 }
58}
59
60func TestUpdateRepoSettingsMissingRepo(t *testing.T) {
61 s, _ := settingsFixture(t)
62 if _, err := s.UpdateRepoSettings(9999, func(*RepoSettings) {}); err != ErrNotFound {
63 t.Fatalf("err = %v, want ErrNotFound", err)
64 }
65}
66
67// Concurrent updates to different fields both survive. Read-modify-write
68// through the caller lost one of them: each read the same blob and the
69// later write put back what it had read for the other's field.
70func TestUpdateRepoSettingsConcurrent(t *testing.T) {
71 s, repoID := settingsFixture(t)
72 var wg sync.WaitGroup
73 errs := make(chan error, 2)
74 wg.Add(2)
75 go func() {
76 defer wg.Done()
77 _, err := s.UpdateRepoSettings(repoID, func(set *RepoSettings) { set.RequireApprovals = 3 })
78 errs <- err
79 }()
80 go func() {
81 defer wg.Done()
82 _, err := s.UpdateRepoSettings(repoID, func(set *RepoSettings) { set.RequireResolved = true })
83 errs <- err
84 }()
85 wg.Wait()
86 close(errs)
87 for err := range errs {
88 if err != nil {
89 t.Fatal(err)
90 }
91 }
92 repo, err := s.RepoByID(repoID)
93 if err != nil {
94 t.Fatal(err)
95 }
96 if repo.Settings.RequireApprovals != 3 || !repo.Settings.RequireResolved {
97 t.Fatalf("one update was lost: %+v", repo.Settings)
98 }
99}
100
101// The protected-branch list is read and rewritten inside the update, so
102// two admins protecting different branches at once both land.
103func TestProtectedBranchesConcurrent(t *testing.T) {
104 s, repoID := settingsFixture(t)
105 var wg sync.WaitGroup
106 for _, branch := range []string{"main", "release"} {
107 wg.Add(1)
108 go func(b string) {
109 defer wg.Done()
110 s.UpdateRepoSettings(repoID, func(set *RepoSettings) {
111 set.ProtectedBranches = append(set.ProtectedBranches, b)
112 })
113 }(branch)
114 }
115 wg.Wait()
116 repo, _ := s.RepoByID(repoID)
117 if len(repo.Settings.ProtectedBranches) != 2 {
118 t.Fatalf("protected branches = %v, want both", repo.Settings.ProtectedBranches)
119 }
120}