1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6 "bytes"
7 "context"
8 "sync"
9 "testing"
10 "time"
11
12 "github.com/bnema/zerowrap"
13
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"
16
17 "bnema.dev/ermite/internal/adapters/gitcli"
18 "bnema.dev/ermite/internal/domain"
19 "bnema.dev/ermite/internal/ports"
20 "bnema.dev/ermite/internal/usecase"
21)
22
23// A merge can delete its source branch; the push event carries both refs.
24func TestMergeDeletesTheSourceBranch(t *testing.T) {
25 t.Parallel()
26 for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} {
27 t.Run(string(mode), func(t *testing.T) {
28 t.Parallel()
29 r := newMRRig(t)
30 main := r.seed()
31 head := r.feature("feat")
32 mr := r.open("feat")
33 r.drain()
34
35 res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{Mode: mode, HeadID: head, DeleteSource: true})
36 require.NoError(t, err)
37 assert.True(t, res.SourceDeleted)
38 assert.Equal(t, domain.MRStateMerged, res.State)
39 r.mrs.Wait(r.ctx) // the deletion happens after Merge returned
40 _, err = r.git.BranchHead(r.ctx, r.repo, "feat")
41 assert.ErrorIs(t, err, domain.ErrNotFound, "the branch is gone")
42
43 evs := r.drain()
44 require.Len(t, evs, 2)
45 assert.Equal(t, []domain.RefUpdate{
46 {Old: main.String(), New: res.MergedCommitID.String(), Ref: "refs/heads/main"},
47 {Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/feat"},
48 }, evs[1].(domain.PushEvent).Updates)
49
50 // The merged request still reads, with its commits and diff.
51 d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{})
52 require.NoError(t, err)
53 assert.Len(t, d.Commits, 2)
54 assert.Len(t, d.Diff.Files, 2)
55 })
56 }
57
58 t.Run("without the option the branch stays", func(t *testing.T) {
59 t.Parallel()
60 r := newMRRig(t)
61 r.seed()
62 head := r.feature("feat")
63 mr := r.open("feat")
64 res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
65 require.NoError(t, err)
66 assert.False(t, res.SourceDeleted)
67 assert.Equal(t, head, r.branchHead("feat"))
68 })
69}
70
71// Deleting the source is refused before the merge when the branch is still
72// needed: nothing changes.
73func TestMergeRefusesToDeleteANeededBranch(t *testing.T) {
74 t.Parallel()
75 t.Run("another open request uses it as its source", func(t *testing.T) {
76 t.Parallel()
77 r := newMRRig(t)
78 main := r.seed()
79 head := r.feature("feat")
80 r.sh("checkout", "-q", "-B", "release", "main")
81 r.push("release")
82 mr := r.open("feat")
83 _, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "to release", Source: "feat", Target: "release"})
84 require.NoError(t, err)
85
86 _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
87 require.ErrorIs(t, err, domain.ErrPreconditionFailed)
88 assert.Contains(t, err.Error(), "!2 still uses")
89 assert.Equal(t, main, r.branchHead("main"), "not merged")
90 assert.Equal(t, head, r.branchHead("feat"))
91 d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{})
92 require.NoError(t, err)
93 assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State)
94 })
95
96 t.Run("another open request targets it", func(t *testing.T) {
97 t.Parallel()
98 r := newMRRig(t)
99 r.seed()
100 head := r.feature("feat")
101 mr := r.open("feat")
102 r.sh("checkout", "-q", "-B", "sub", "feat")
103 r.commit(author, "d.txt", "dee\n", "add d")
104 r.push("sub")
105 _, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "stacked", Source: "sub", Target: "feat"})
106 require.NoError(t, err)
107 _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
108 require.ErrorIs(t, err, domain.ErrPreconditionFailed)
109 assert.Equal(t, head, r.branchHead("feat"))
110 })
111
112 t.Run("the default branch", func(t *testing.T) {
113 t.Parallel()
114 r := newMRRig(t)
115 main := r.seed()
116 r.sh("checkout", "-q", "-B", "old", "main~1")
117 r.push("old")
118 old := r.branchHead("old")
119 mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "back", Source: "main", Target: "old"})
120 require.NoError(t, err)
121 _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: main, DeleteSource: true})
122 require.ErrorIs(t, err, domain.ErrPreconditionFailed)
123 assert.Contains(t, err.Error(), "default branch")
124 assert.Equal(t, old, r.branchHead("old"), "not merged")
125 assert.Equal(t, main, r.branchHead("main"))
126 })
127}
128
129// After a squash with deletion, gc may prune the reviewed commits: the merged
130// request still reads, without commits or diff.
131func TestSquashMergedRequestReadsAfterItsCommitsArePruned(t *testing.T) {
132 t.Parallel()
133 r := newMRRig(t)
134 r.seed()
135 head := r.feature("feat")
136 mr := r.open("feat")
137 res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{Mode: domain.MergeModeSquash, HeadID: head, DeleteSource: true})
138 require.NoError(t, err)
139 require.True(t, res.SourceDeleted)
140 r.mrs.Wait(r.ctx) // the deletion runs after Merge returned
141
142 r.run(r.bare(), r.env(author), "reflog", "expire", "--expire=now", "--all")
143 r.run(r.bare(), r.env(author), "gc", "-q", "--prune=now")
144 ok, err := r.git.HasCommit(r.ctx, r.repo, head)
145 require.NoError(t, err)
146 require.False(t, ok, "the reviewed commits are pruned")
147
148 d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{})
149 require.NoError(t, err)
150 assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State)
151 assert.Equal(t, res.MergedCommitID, d.MergeRequest.MergedCommitID)
152 assert.Empty(t, d.Commits)
153 assert.Empty(t, d.Diff.Files)
154 assert.True(t, d.DiffBaseID.IsZero())
155}
156
157// A request opened on a branch a merge is deleting waits for the merge lock
158// and then sees the branch is gone.
159func TestCreateAfterAMergeDeletedTheBranch(t *testing.T) {
160 t.Parallel()
161 r := newMRRig(t)
162 r.seed()
163 head := r.feature("feat")
164 mr := r.open("feat")
165 _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
166 require.NoError(t, err)
167 r.mrs.Wait(r.ctx) // the deletion runs after Merge returned
168 _, err = r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "late", Source: "feat"})
169 require.ErrorIs(t, err, domain.ErrInvalid)
170 assert.Contains(t, fieldsOf(t, err), "source_branch")
171}
172
173// movingGit pushes to the source branch between the merge and the deletion.
174type movingGit struct {
175 *gitcli.Git
176 move func()
177}
178
179func (g movingGit) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error {
180 g.move()
181 return g.Git.DeleteBranch(ctx, repo, branch, old)
182}
183
184// A branch that moves after the merge is kept: the merge stands.
185func TestMergeKeepsASourceThatMovedAfterTheMerge(t *testing.T) {
186 t.Parallel()
187 r := newMRRig(t)
188 r.seed()
189 head := r.feature("feat")
190 mr := r.open("feat")
191 var moved domain.ObjectID
192 mrs := usecase.NewMergeRequests(usecase.MergeRequestsConfig{
193 Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Bus: r.bus, Clock: fixedClock{}, Maint: r.sched,
194 Git: movingGit{Git: r.git, move: func() {
195 moved = r.commit(author, "late.txt", "late\n", "pushed during the merge")
196 r.push("feat")
197 }},
198 })
199 r.drain()
200
201 res, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
202 require.NoError(t, err, "the merge stands")
203 assert.True(t, res.SourceDeleted, "the deletion was requested")
204 assert.Equal(t, domain.MRStateMerged, res.State)
205 mrs.Wait(r.ctx)
206 assert.Equal(t, head, r.branchHead("main"))
207 assert.Equal(t, moved, r.branchHead("feat"), "the late commit is kept")
208 evs := r.drain()
209 require.Len(t, evs, 2)
210 assert.Len(t, evs[1].(domain.PushEvent).Updates, 1, "only the target moved")
211}
212
213// afterMergeRig builds merge request use cases over the rig with ref-tip
214// records, whose post-merge goroutine waits for the returned release function.
215func (r *mrRig) afterMergeRig(extra func(*usecase.MergeRequestsConfig)) (mrs *usecase.MergeRequests, release func()) {
216 gate := make(chan struct{})
217 cfg := usecase.MergeRequestsConfig{
218 Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Signer: nil,
219 Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"},
220 Bus: r.bus, Clock: fixedClock{}, Maint: r.sched, RefTips: r.store.CI(),
221 }
222 if extra != nil {
223 extra(&cfg)
224 }
225 mrs = usecase.NewMergeRequests(cfg)
226 mrs.SetAfterMergeGate(func() { <-gate })
227 var once sync.Once
228 return mrs, func() { once.Do(func() { close(gate) }) }
229}
230
231// A request opened on the source after the merge, before the deletion runs,
232// keeps the branch (GO-002).
233func TestMergeKeepsTheSourceWhenAnotherRequestOpensOnItMeanwhile(t *testing.T) {
234 t.Parallel()
235 r := newMRRig(t)
236 r.seed()
237 head := r.feature("feat")
238 mr := r.open("feat")
239 r.sh("checkout", "-q", "-B", "release", "main")
240 r.push("release")
241 mrs, release := r.afterMergeRig(nil)
242 defer release()
243
244 _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
245 require.NoError(t, err)
246 _, err = mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "to release", Source: "feat", Target: "release"})
247 require.NoError(t, err, "the merge released the lock; the deletion has not run")
248 release()
249 mrs.Wait(r.ctx)
250 assert.Equal(t, head, r.branchHead("feat"), "the branch is kept")
251}
252
253// A push that moves the source after the merge keeps both the branch and the
254// record of who pushed it (GO-003).
255func TestMergeKeepsTheSourceAndItsRecordWhenAPushMovesItMeanwhile(t *testing.T) {
256 t.Parallel()
257 r := newMRRig(t)
258 r.seed()
259 head := r.feature("feat")
260 mr := r.open("feat")
261 mrs, release := r.afterMergeRig(nil)
262 defer release()
263
264 _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
265 require.NoError(t, err)
266 r.sh("checkout", "-q", "feat")
267 moved := r.commit(author, "later.txt", "later\n", "pushed meanwhile")
268 r.push("feat")
269 require.NoError(t, r.store.CI().PutRefTip(r.ctx, domain.CIRefTip{Repo: r.repo, Ref: "refs/heads/feat", Sha: moved.String(), Actor: "ada"}))
270 release()
271 mrs.Wait(r.ctx)
272
273 assert.Equal(t, moved, r.branchHead("feat"), "the branch is kept")
274 tip, err := r.store.CI().GetRefTip(r.ctx, r.repo, "refs/heads/feat")
275 require.NoError(t, err, "the record of the new push is kept")
276 assert.Equal(t, moved.String(), tip.Sha)
277}
278
279// The record of the source goes with the branch, once it is deleted.
280func TestMergeRemovesTheSourceRecordAfterDeletingIt(t *testing.T) {
281 t.Parallel()
282 r := newMRRig(t)
283 r.seed()
284 head := r.feature("feat")
285 mr := r.open("feat")
286 mrs, release := r.afterMergeRig(nil)
287 release()
288 require.NoError(t, r.store.CI().PutRefTip(r.ctx, domain.CIRefTip{Repo: r.repo, Ref: "refs/heads/feat", Sha: head.String(), Actor: "ada"}))
289
290 _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
291 require.NoError(t, err)
292 mrs.Wait(r.ctx)
293 _, err = r.git.BranchHead(r.ctx, r.repo, "feat")
294 require.ErrorIs(t, err, domain.ErrNotFound)
295 _, err = r.store.CI().GetRefTip(r.ctx, r.repo, "refs/heads/feat")
296 assert.ErrorIs(t, err, domain.ErrNotFound)
297}
298
299// panicHooks is a CI trigger whose merge hook panics.
300type panicHooks struct{}
301
302func (panicHooks) OnPush(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) {}
303func (panicHooks) OnMerge(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) {
304 panic("boom from ci")
305}
306
307func TestMergeLogsAPanickingSideEffectAndWaitReturns(t *testing.T) {
308 t.Parallel()
309 r := newMRRig(t)
310 r.seed()
311 head := r.feature("feat")
312 mr := r.open("feat")
313 mrs, release := r.afterMergeRig(func(c *usecase.MergeRequestsConfig) { c.CI = panicHooks{} })
314 release()
315 var logs bytes.Buffer
316 ctx := zerowrap.WithCtx(r.ctx, zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &logs}))
317
318 _, err := mrs.Merge(ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
319 require.NoError(t, err)
320 waited := make(chan struct{})
321 go func() { mrs.Wait(context.Background()); close(waited) }()
322 select {
323 case <-waited:
324 case <-time.After(10 * time.Second):
325 t.Fatal("Wait did not return after a panicking side effect")
326 }
327 assert.Contains(t, logs.String(), "post-merge side effects panicked")
328 assert.Contains(t, logs.String(), "boom from ci")
329}
330
331// A head behind the target has nothing to merge, whatever the mode (GO-007).
332func TestMergeRefusesAHeadBehindTheTarget(t *testing.T) {
333 t.Parallel()
334 for _, mode := range []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeMerge, domain.MergeModeFastForward} {
335 t.Run(string(mode), func(t *testing.T) {
336 t.Parallel()
337 r := newMRRig(t)
338 main := r.seed()
339 r.sh("checkout", "-q", "-B", "old", "main~1")
340 r.push("old")
341 old := r.branchHead("old")
342 require.NotEqual(t, main, old)
343 mr := r.open("old")
344
345 _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{Mode: mode, HeadID: old})
346 require.ErrorIs(t, err, domain.ErrPreconditionFailed)
347 assert.Contains(t, err.Error(), "nothing to merge")
348 assert.Equal(t, main, r.branchHead("main"), "no commit was made")
349 })
350 }
351}