Commit 6b8e159687

6b8e1596871a0ac30dd54009bb4d6f6b0fa86311

parent: 5271aa8b0d

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 04:51 UTC

control, gitutil: post-receive's ref-update work as control.RefsUpdated; CommitWithFile

Server-side writes to a branch can now run the same work a push does.

Ref #288

Layout: unified · split

internal/control/refsupdated.go added +212
@@ -0,0 +1,212 @@
1package control
2
3import (
4 "encoding/json"
5 "fmt"
6 "log/slog"
7 "path"
8 "strings"
9 "time"
10
11 "gitbay.org/gitbay/internal/ci"
12 "gitbay.org/gitbay/internal/config"
13 "gitbay.org/gitbay/internal/gitutil"
14 "gitbay.org/gitbay/internal/policy"
15 "gitbay.org/gitbay/internal/store"
16)
17
18// RefsUpdated is the work that follows a ref update in repoID by userID,
19// with a key or token of scope: post-receive runs it for every push, and
20// a server-side write to a branch (an applied suggestion) runs it after
21// its own ref update, so nothing a push triggers is skipped. A push to a
22// source branch refreshes refs/merge-requests/N/head in every target
23// repo, by fetching — the target owns the objects, so the MR outlives the
24// fork. This is the only place a push writes outside its own repository.
25func RefsUpdated(st *store.Store, cfg config.Config, repoID, userID int64, scope string, updates []policy.RefUpdate) {
26 pushedRepo, pushedRepoErr := st.RepoByID(repoID)
27 if pushedRepoErr == nil {
28 adoptDefaultBranch(st, cfg, &pushedRepo, updates)
29 }
30 for _, u := range updates {
31 // Every ref update is an event webhooks can subscribe to.
32 st.RecordEvent(repoID, userID, "push", fmt.Sprintf(
33 `{"ref":%q,"old":%q,"new":%q,"forced":%v,"deleted":%v}`,
34 u.Ref, u.Old, u.New, u.IsForce, u.IsDelete))
35
36 // Any ref update — branch or tag — schedules the push mirrors.
37 st.MarkMirrorsDirty(repoID, "push")
38
39 // Tag pushes run the tag-triggered CI jobs.
40 if tag, ok := strings.CutPrefix(u.Ref, "refs/tags/"); ok && !u.IsDelete && pushedRepoErr == nil {
41 QueueTagBuilds(st, cfg, pushedRepo, userID, tag, u.New)
42 }
43
44 branch, ok := cutHeads(u.Ref)
45 if !ok {
46 continue
47 }
48 // Commits landing on the default branch act on issue references
49 // in their messages (closes #N, plain #N).
50 if pushedRepoErr == nil && branch == pushedRepo.DefaultBranch && !u.IsDelete {
51 dir := RepoDir(cfg.Server.Root, pushedRepo.OwnerName, pushedRepo.Name)
52 ProcessCommitMessages(st, dir, pushedRepo, userID, scope, u.Old, u.New)
53 RecordLandedCommits(st, dir, pushedRepo, u.Old, u.New)
54 }
55 // A branch push with a .gitbay/ci.yml queues one build per job.
56 if pushedRepoErr == nil && !u.IsDelete {
57 QueueBranchBuilds(st, cfg.Server.Root, cfg.Server.SiteURL,
58 pushedRepo, userID, branch, u.Old, u.New, time.Now())
59 }
60 if u.IsForce {
61 st.Audit(userID, "push.forced", map[string]any{
62 "repo": repoID, "ref": u.Ref, "old": u.Old, "new": u.New})
63 }
64 mrs, err := st.OpenMRsBySource(repoID, branch)
65 if err != nil {
66 slog.Error("post-receive: listing MRs", "err", err)
67 continue
68 }
69 srcRepo, err := st.RepoByID(repoID)
70 if err != nil {
71 continue
72 }
73 srcDir := RepoDir(cfg.Server.Root, srcRepo.OwnerName, srcRepo.Name)
74 for _, mr := range mrs {
75 target, err := st.RepoByID(mr.RepoID)
76 if err != nil {
77 continue
78 }
79 if u.IsDelete {
80 if mr.State == "open" {
81 st.SetMRState(mr.ID, "source_gone")
82 }
83 if mr.QueuedAt != "" {
84 TryQueuedMerge(st, cfg, mr.ID) // dequeues: the source is gone
85 }
86 continue // head ref retained: the diff stays viewable
87 }
88 dstDir := RepoDir(cfg.Server.Root, target.OwnerName, target.Name)
89 headRef := fmt.Sprintf("refs/merge-requests/%d/head", mr.Number)
90 if err := gitutil.FetchInto(dstDir, srcDir, u.New, headRef); err != nil {
91 slog.Error("post-receive: refreshing MR head", "mr", mr.Number, "err", err)
92 continue
93 }
94 // The merge base as it stands now, so a later range-diff
95 // compares each revision against the target it was written
96 // on rather than against today's. Best-effort: a base that
97 // cannot be worked out costs precision, not the record.
98 base, err := gitutil.MergeBase(dstDir, "refs/heads/"+mr.TargetRef, headRef)
99 if err != nil {
100 base = ""
101 }
102 if err := st.UpdateMRHead(mr.ID, u.New, base, sameChange(dstDir, mr, base, u.New)); err != nil {
103 slog.Error("post-receive: recording MR head", "mr", mr.Number, "err", err)
104 }
105 if srcRepo.ID != target.ID {
106 QueueMRBuilds(st, cfg.Server.Root, cfg.Server.SiteURL,
107 target, userID, mr.Number, u.New)
108 }
109 if mr.State == "source_gone" {
110 st.SetMRState(mr.ID, "open") // branch came back
111 }
112 // A queued merge stays queued across a push by someone who can
113 // merge it, and the new head has to pass the gates on its own.
114 if mr.QueuedAt != "" {
115 QueuedMergePushed(st, cfg, mr.ID, userID, scope)
116 }
117 }
118 }
119}
120
121// adoptDefaultBranch moves an unborn HEAD to the first branch a push
122// creates. A repository is initialised with HEAD at the stored default,
123// and a first push of master or trunk left HEAD naming a branch that did
124// not exist: clones checked out nothing and every surface asked git for
125// a branch that was not there (#189). A push that includes the default
126// branch itself needs nothing.
127func adoptDefaultBranch(st *store.Store, cfg config.Config, repo *store.Repo, updates []policy.RefUpdate) {
128 dir := RepoDir(cfg.Server.Root, repo.OwnerName, repo.Name)
129 if _, err := gitutil.ResolveRef(dir, "refs/heads/"+repo.DefaultBranch); err == nil {
130 return
131 }
132 for _, u := range updates {
133 branch, ok := cutHeads(u.Ref)
134 if !ok || u.IsDelete || !gitutil.ZeroSHA(u.Old) {
135 continue
136 }
137 if err := gitutil.SetHead(dir, branch); err != nil {
138 slog.Error("post-receive: moving HEAD", "repo", repo.Path(), "err", err)
139 return
140 }
141 if err := st.UpdateDefaultBranch(repo.ID, branch); err != nil {
142 slog.Error("post-receive: recording default branch", "repo", repo.Path(), "err", err)
143 return
144 }
145 repo.DefaultBranch = branch
146 return
147 }
148}
149
150// sameChange reports whether the new head proposes the diff the old one
151// did: the patch-id of each revision against its own merge base. A
152// rebase onto a moved target changes every sha and nothing about the
153// change, and the reviews of it should not go stale for that (#198).
154// Any doubt answers false, which is the old behaviour.
155func sameChange(dir string, mr store.MR, newBase, newHead string) bool {
156 if mr.HeadSHA == "" || newBase == "" || mr.HeadSHA == newHead {
157 return false
158 }
159 oldBase, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, mr.HeadSHA)
160 if err != nil {
161 return false
162 }
163 oldID, err := gitutil.PatchID(dir, oldBase, mr.HeadSHA)
164 if err != nil || oldID == "" {
165 return false
166 }
167 newID, err := gitutil.PatchID(dir, newBase, newHead)
168 return err == nil && newID == oldID
169}
170
171// QueueTagBuilds runs the jobs whose tag pattern matches a pushed tag.
172// The build records the tag as its ref and the peeled commit as its sha,
173// so statuses land on the commit, not an annotated tag object.
174func QueueTagBuilds(st *store.Store, cfg config.Config, repo store.Repo, userID int64, tag, pushed string) {
175 dir := RepoDir(cfg.Server.Root, repo.OwnerName, repo.Name)
176 sha, err := gitutil.PeelToCommit(dir, pushed)
177 if err != nil {
178 return
179 }
180 raw, err := gitutil.ReadBlob(dir, sha, ci.ConfigPath, 1<<16)
181 if err != nil {
182 return
183 }
184 jobs, err := ci.Parse(raw)
185 if err != nil {
186 return // the branch push already reported ci/config
187 }
188 for _, j := range jobs {
189 if j.Tags == "" {
190 continue
191 }
192 if ok, _ := path.Match(j.Tags, tag); !ok {
193 continue
194 }
195 steps, _ := json.Marshal(j.Steps)
196 n, err := st.CreateBuild(repo.ID, j.Name, sha, tag, string(steps), j.Image, "", true)
197 if err != nil {
198 slog.Error("queueing tag build", "repo", repo.Path(), "job", j.Name, "err", err)
199 continue
200 }
201 url := fmt.Sprintf("%s/%s/builds/%d", cfg.Server.SiteURL, repo.Path(), n)
202 st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "pending", "tag "+tag, url, userID)
203 }
204}
205
206func cutHeads(ref string) (string, bool) {
207 const p = "refs/heads/"
208 if len(ref) > len(p) && ref[:len(p)] == p {
209 return ref[len(p):], true
210 }
211 return "", false
212}
internal/gitutil/merge.go +17 −9
@@ -211,6 +211,21 @@ func CommitFileChange(dir, branch, path string, content []byte, name, email, mes
211211 parent = ""
212212 }
213213
214 sha, err := CommitWithFile(dir, parent, path, "100644", content, name, email, message)
215 if err != nil {
216 return "", err
217 }
218 if err := UpdateRefCAS(dir, branchRef, sha, parent); err != nil {
219 return "", fmt.Errorf("branch moved during edit; reload and retry: %w", err)
220 }
221 return sha, nil
222}
223
224// CommitWithFile writes a commit on parent ("" for a root commit) whose
225// tree is parent's with content at path, as a file of mode (100644 or
226// 100755), authored and committed as name <email>. No ref moves: the
227// caller updates one, and enforces policy, since no hook runs.
228func CommitWithFile(dir, parent, path, mode string, content []byte, name, email, message string) (string, error) {
214229 // Hash the new blob.
215230 hb := exec.Command(toolpath.Look("git"), "-C", dir, "hash-object", "-w", "--stdin")
216231 hb.Stdin = strings.NewReader(string(content))
@@ -239,7 +254,7 @@ func CommitFileChange(dir, branch, path string, content []byte, name, email, mes
239254 if out, err := rt.CombinedOutput(); err != nil {
240255 return "", fmt.Errorf("read-tree: %v\n%s", err, out)
241256 }
242 ui := exec.Command(toolpath.Look("git"), "-C", dir, "update-index", "--add", "--cacheinfo", "100644,"+blob+","+path)
257 ui := exec.Command(toolpath.Look("git"), "-C", dir, "update-index", "--add", "--cacheinfo", mode+","+blob+","+path)
243258 ui.Env = env
244259 if out, err := ui.CombinedOutput(); err != nil {
245260 return "", fmt.Errorf("update-index: %v\n%s", err, out)
@@ -256,14 +271,7 @@ func CommitFileChange(dir, branch, path string, content []byte, name, email, mes
256271 if parent != "" {
257272 parents = []string{parent}
258273 }
259 sha, err := CommitTree(dir, tree, parents, name, email, message)
260 if err != nil {
261 return "", err
262 }
263 if err := UpdateRefCAS(dir, branchRef, sha, parent); err != nil {
264 return "", fmt.Errorf("branch moved during edit; reload and retry: %w", err)
265 }
266 return sha, nil
274 return CommitTree(dir, tree, parents, name, email, message)
267275}
268276
269277// isEmptyRepo reports whether dir has no refs at all — a repository
internal/hookd/hookd.go +4 −196
@@ -15,12 +15,9 @@ import (
1515 "log/slog"
1616 "net"
1717 "os"
18 "path"
1918 "path/filepath"
2019 "strings"
21 "time"
2220
23 "gitbay.org/gitbay/internal/ci"
2421 "gitbay.org/gitbay/internal/config"
2522 "gitbay.org/gitbay/internal/control"
2623 "gitbay.org/gitbay/internal/gitutil"
@@ -275,204 +272,15 @@ func (s *Server) releaseAnchors(repo store.Repo, updates []policy.RefUpdate) str
275272 return ""
276273}
277274
278// postReceive applies the cross-repo MR effect: a push to a source branch
279// refreshes refs/merge-requests/N/head in every target repo, by fetching —
280// the target owns the objects, so the MR outlives the fork. This is the only
281// place a hook writes outside its own repository.
275// postReceive runs the ref-update work for a push; see
276// control.RefsUpdated, which server-side writes to a branch share.
282277func (s *Server) postReceive(req Request) {
283 pushedRepo, pushedRepoErr := s.st.RepoByID(req.RepoID)
284 if pushedRepoErr == nil {
285 s.adoptDefaultBranch(&pushedRepo, req.Updates)
286 }
287 for _, u := range req.Updates {
288 // Every ref update is an event webhooks can subscribe to.
289 s.st.RecordEvent(req.RepoID, req.UserID, "push", fmt.Sprintf(
290 `{"ref":%q,"old":%q,"new":%q,"forced":%v,"deleted":%v}`,
291 u.Ref, u.Old, u.New, u.IsForce, u.IsDelete))
292
293 // Any ref update — branch or tag — schedules the push mirrors.
294 s.st.MarkMirrorsDirty(req.RepoID, "push")
295
296 // Tag pushes run the tag-triggered CI jobs.
297 if tag, ok := strings.CutPrefix(u.Ref, "refs/tags/"); ok && !u.IsDelete && pushedRepoErr == nil {
298 s.queueTagBuilds(pushedRepo, req.UserID, tag, u.New)
299 }
300
301 branch, ok := cutHeads(u.Ref)
302 if !ok {
303 continue
304 }
305 // Commits landing on the default branch act on issue references
306 // in their messages (closes #N, plain #N).
307 if pushedRepoErr == nil && branch == pushedRepo.DefaultBranch && !u.IsDelete {
308 dir := control.RepoDir(s.cfg.Server.Root, pushedRepo.OwnerName, pushedRepo.Name)
309 control.ProcessCommitMessages(s.st, dir, pushedRepo, req.UserID, req.Scope, u.Old, u.New)
310 control.RecordLandedCommits(s.st, dir, pushedRepo, u.Old, u.New)
311 }
312 // A branch push with a .gitbay/ci.yml queues one build per job.
313 if pushedRepoErr == nil && !u.IsDelete {
314 s.queueBuilds(pushedRepo, req.UserID, branch, u.Old, u.New)
315 }
316 if u.IsForce {
317 s.st.Audit(req.UserID, "push.forced", map[string]any{
318 "repo": req.RepoID, "ref": u.Ref, "old": u.Old, "new": u.New})
319 }
320 mrs, err := s.st.OpenMRsBySource(req.RepoID, branch)
321 if err != nil {
322 slog.Error("post-receive: listing MRs", "err", err)
323 continue
324 }
325 srcRepo, err := s.st.RepoByID(req.RepoID)
326 if err != nil {
327 continue
328 }
329 srcDir := control.RepoDir(s.cfg.Server.Root, srcRepo.OwnerName, srcRepo.Name)
330 for _, mr := range mrs {
331 target, err := s.st.RepoByID(mr.RepoID)
332 if err != nil {
333 continue
334 }
335 if u.IsDelete {
336 if mr.State == "open" {
337 s.st.SetMRState(mr.ID, "source_gone")
338 }
339 if mr.QueuedAt != "" {
340 control.TryQueuedMerge(s.st, s.cfg, mr.ID) // dequeues: the source is gone
341 }
342 continue // head ref retained: the diff stays viewable
343 }
344 dstDir := control.RepoDir(s.cfg.Server.Root, target.OwnerName, target.Name)
345 headRef := fmt.Sprintf("refs/merge-requests/%d/head", mr.Number)
346 if err := gitutil.FetchInto(dstDir, srcDir, u.New, headRef); err != nil {
347 slog.Error("post-receive: refreshing MR head", "mr", mr.Number, "err", err)
348 continue
349 }
350 // The merge base as it stands now, so a later range-diff
351 // compares each revision against the target it was written
352 // on rather than against today's. Best-effort: a base that
353 // cannot be worked out costs precision, not the record.
354 base, err := gitutil.MergeBase(dstDir, "refs/heads/"+mr.TargetRef, headRef)
355 if err != nil {
356 base = ""
357 }
358 if err := s.st.UpdateMRHead(mr.ID, u.New, base, sameChange(dstDir, mr, base, u.New)); err != nil {
359 slog.Error("post-receive: recording MR head", "mr", mr.Number, "err", err)
360 }
361 if srcRepo.ID != target.ID {
362 control.QueueMRBuilds(s.st, s.cfg.Server.Root, s.cfg.Server.SiteURL,
363 target, req.UserID, mr.Number, u.New)
364 }
365 if mr.State == "source_gone" {
366 s.st.SetMRState(mr.ID, "open") // branch came back
367 }
368 // A queued merge stays queued across a push by someone who can
369 // merge it, and the new head has to pass the gates on its own.
370 if mr.QueuedAt != "" {
371 control.QueuedMergePushed(s.st, s.cfg, mr.ID, req.UserID, req.Scope)
372 }
373 }
374 }
375}
376
377// adoptDefaultBranch moves an unborn HEAD to the first branch a push
378// creates. A repository is initialised with HEAD at the stored default,
379// and a first push of master or trunk left HEAD naming a branch that did
380// not exist: clones checked out nothing and every surface asked git for
381// a branch that was not there (#189). A push that includes the default
382// branch itself needs nothing.
383func (s *Server) adoptDefaultBranch(repo *store.Repo, updates []policy.RefUpdate) {
384 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
385 if _, err := gitutil.ResolveRef(dir, "refs/heads/"+repo.DefaultBranch); err == nil {
386 return
387 }
388 for _, u := range updates {
389 branch, ok := cutHeads(u.Ref)
390 if !ok || u.IsDelete || !gitutil.ZeroSHA(u.Old) {
391 continue
392 }
393 if err := gitutil.SetHead(dir, branch); err != nil {
394 slog.Error("post-receive: moving HEAD", "repo", repo.Path(), "err", err)
395 return
396 }
397 if err := s.st.UpdateDefaultBranch(repo.ID, branch); err != nil {
398 slog.Error("post-receive: recording default branch", "repo", repo.Path(), "err", err)
399 return
400 }
401 repo.DefaultBranch = branch
402 return
403 }
404}
405
406// sameChange reports whether the new head proposes the diff the old one
407// did: the patch-id of each revision against its own merge base. A
408// rebase onto a moved target changes every sha and nothing about the
409// change, and the reviews of it should not go stale for that (#198).
410// Any doubt answers false, which is the old behaviour.
411func sameChange(dir string, mr store.MR, newBase, newHead string) bool {
412 if mr.HeadSHA == "" || newBase == "" || mr.HeadSHA == newHead {
413 return false
414 }
415 oldBase, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, mr.HeadSHA)
416 if err != nil {
417 return false
418 }
419 oldID, err := gitutil.PatchID(dir, oldBase, mr.HeadSHA)
420 if err != nil || oldID == "" {
421 return false
422 }
423 newID, err := gitutil.PatchID(dir, newBase, newHead)
424 return err == nil && newID == oldID
425}
426
427// queueBuilds queues the push jobs for a branch update. The work is
428// shared with the merge path, which moves a ref without reaching a hook.
429func (s *Server) queueBuilds(repo store.Repo, userID int64, branch, old, sha string) {
430 control.QueueBranchBuilds(
431 s.st, s.cfg.Server.Root, s.cfg.Server.SiteURL,
432 repo, userID, branch, old, sha, time.Now())
278 control.RefsUpdated(s.st, s.cfg, req.RepoID, req.UserID, req.Scope, req.Updates)
433279}
434280
435281// queueTagBuilds runs the jobs whose tag pattern matches a pushed tag.
436// The build records the tag as its ref and the peeled commit as its sha,
437// so statuses land on the commit, not an annotated tag object.
438282func (s *Server) queueTagBuilds(repo store.Repo, userID int64, tag, pushed string) {
439 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
440 sha, err := gitutil.PeelToCommit(dir, pushed)
441 if err != nil {
442 return
443 }
444 raw, err := gitutil.ReadBlob(dir, sha, ci.ConfigPath, 1<<16)
445 if err != nil {
446 return
447 }
448 jobs, err := ci.Parse(raw)
449 if err != nil {
450 return // the branch push already reported ci/config
451 }
452 for _, j := range jobs {
453 if j.Tags == "" {
454 continue
455 }
456 if ok, _ := path.Match(j.Tags, tag); !ok {
457 continue
458 }
459 steps, _ := json.Marshal(j.Steps)
460 n, err := s.st.CreateBuild(repo.ID, j.Name, sha, tag, string(steps), j.Image, "", true)
461 if err != nil {
462 slog.Error("queueing tag build", "repo", repo.Path(), "job", j.Name, "err", err)
463 continue
464 }
465 url := fmt.Sprintf("%s/%s/builds/%d", s.cfg.Server.SiteURL, repo.Path(), n)
466 s.st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "pending", "tag "+tag, url, userID)
467 }
468}
469
470func cutHeads(ref string) (string, bool) {
471 const p = "refs/heads/"
472 if len(ref) > len(p) && ref[:len(p)] == p {
473 return ref[len(p):], true
474 }
475 return "", false
283 control.QueueTagBuilds(s.st, s.cfg, repo, userID, tag, pushed)
476284}
477285
478286// Ask sends one request from the hook process to the daemon. stream is