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
141 r.run(r.bare(), r.env(author), "reflog", "expire", "--expire=now", "--all")
142 r.run(r.bare(), r.env(author), "gc", "-q", "--prune=now")
143 ok, err := r.git.HasCommit(r.ctx, r.repo, head)
144 require.NoError(t, err)
145 require.False(t, ok, "the reviewed commits are pruned")
146
147 d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{})
148 require.NoError(t, err)
149 assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State)
150 assert.Equal(t, res.MergedCommitID, d.MergeRequest.MergedCommitID)
151 assert.Empty(t, d.Commits)
152 assert.Empty(t, d.Diff.Files)
153 assert.True(t, d.DiffBaseID.IsZero())
154}
155
156// A request opened on a branch a merge is deleting waits for the merge lock
157// and then sees the branch is gone.
158func TestCreateAfterAMergeDeletedTheBranch(t *testing.T) {
159 t.Parallel()
160 r := newMRRig(t)
161 r.seed()
162 head := r.feature("feat")
163 mr := r.open("feat")
164 _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
165 require.NoError(t, err)
166 r.mrs.Wait(r.ctx) // the deletion runs after Merge returned
167 _, err = r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "late", Source: "feat"})
168 require.ErrorIs(t, err, domain.ErrInvalid)
169 assert.Contains(t, fieldsOf(t, err), "source_branch")
170}
171
172// movingGit pushes to the source branch between the merge and the deletion.
173type movingGit struct {
174 *gitcli.Git
175 move func()
176}
177
178func (g movingGit) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error {
179 g.move()
180 return g.Git.DeleteBranch(ctx, repo, branch, old)
181}
182
183// A branch that moves after the merge is kept: the merge stands.
184func TestMergeKeepsASourceThatMovedAfterTheMerge(t *testing.T) {
185 t.Parallel()
186 r := newMRRig(t)
187 r.seed()
188 head := r.feature("feat")
189 mr := r.open("feat")
190 var moved domain.ObjectID
191 mrs := usecase.NewMergeRequests(usecase.MergeRequestsConfig{
192 Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Bus: r.bus, Clock: fixedClock{}, Maint: r.sched,
193 Git: movingGit{Git: r.git, move: func() {
194 moved = r.commit(author, "late.txt", "late\n", "pushed during the merge")
195 r.push("feat")
196 }},
197 })
198 r.drain()
199
200 res, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
201 require.NoError(t, err, "the merge stands")
202 assert.True(t, res.SourceDeleted, "the deletion was requested")
203 assert.Equal(t, domain.MRStateMerged, res.State)
204 mrs.Wait(r.ctx)
205 assert.Equal(t, head, r.branchHead("main"))
206 assert.Equal(t, moved, r.branchHead("feat"), "the late commit is kept")
207 evs := r.drain()
208 require.Len(t, evs, 2)
209 assert.Len(t, evs[1].(domain.PushEvent).Updates, 1, "only the target moved")
210}
211
212// afterMergeRig builds merge request use cases over the rig with ref-tip
213// records, whose post-merge goroutine waits for the returned release function.
214func (r *mrRig) afterMergeRig(extra func(*usecase.MergeRequestsConfig)) (mrs *usecase.MergeRequests, release func()) {
215 gate := make(chan struct{})
216 cfg := usecase.MergeRequestsConfig{
217 Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Signer: nil,
218 Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"},
219 Bus: r.bus, Clock: fixedClock{}, Maint: r.sched, RefTips: r.store.CI(),
220 }
221 if extra != nil {
222 extra(&cfg)
223 }
224 mrs = usecase.NewMergeRequests(cfg)
225 mrs.SetAfterMergeGate(func() { <-gate })
226 var once sync.Once
227 return mrs, func() { once.Do(func() { close(gate) }) }
228}
229
230// A request opened on the source after the merge, before the deletion runs,
231// keeps the branch (GO-002).
232func TestMergeKeepsTheSourceWhenAnotherRequestOpensOnItMeanwhile(t *testing.T) {
233 t.Parallel()
234 r := newMRRig(t)
235 r.seed()
236 head := r.feature("feat")
237 mr := r.open("feat")
238 r.sh("checkout", "-q", "-B", "release", "main")
239 r.push("release")
240 mrs, release := r.afterMergeRig(nil)
241 defer release()
242
243 _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
244 require.NoError(t, err)
245 _, err = mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "to release", Source: "feat", Target: "release"})
246 require.NoError(t, err, "the merge released the lock; the deletion has not run")
247 release()
248 mrs.Wait(r.ctx)
249 assert.Equal(t, head, r.branchHead("feat"), "the branch is kept")
250}
251
252// A push that moves the source after the merge keeps both the branch and the
253// record of who pushed it (GO-003).
254func TestMergeKeepsTheSourceAndItsRecordWhenAPushMovesItMeanwhile(t *testing.T) {
255 t.Parallel()
256 r := newMRRig(t)
257 r.seed()
258 head := r.feature("feat")
259 mr := r.open("feat")
260 mrs, release := r.afterMergeRig(nil)
261 defer release()
262
263 _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
264 require.NoError(t, err)
265 r.sh("checkout", "-q", "feat")
266 moved := r.commit(author, "later.txt", "later\n", "pushed meanwhile")
267 r.push("feat")
268 require.NoError(t, r.store.CI().PutRefTip(r.ctx, domain.CIRefTip{Repo: r.repo, Ref: "refs/heads/feat", Sha: moved.String(), Actor: "ada"}))
269 release()
270 mrs.Wait(r.ctx)
271
272 assert.Equal(t, moved, r.branchHead("feat"), "the branch is kept")
273 tip, err := r.store.CI().GetRefTip(r.ctx, r.repo, "refs/heads/feat")
274 require.NoError(t, err, "the record of the new push is kept")
275 assert.Equal(t, moved.String(), tip.Sha)
276}
277
278// The record of the source goes with the branch, once it is deleted.
279func TestMergeRemovesTheSourceRecordAfterDeletingIt(t *testing.T) {
280 t.Parallel()
281 r := newMRRig(t)
282 r.seed()
283 head := r.feature("feat")
284 mr := r.open("feat")
285 mrs, release := r.afterMergeRig(nil)
286 release()
287 require.NoError(t, r.store.CI().PutRefTip(r.ctx, domain.CIRefTip{Repo: r.repo, Ref: "refs/heads/feat", Sha: head.String(), Actor: "ada"}))
288
289 _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
290 require.NoError(t, err)
291 mrs.Wait(r.ctx)
292 _, err = r.git.BranchHead(r.ctx, r.repo, "feat")
293 require.ErrorIs(t, err, domain.ErrNotFound)
294 _, err = r.store.CI().GetRefTip(r.ctx, r.repo, "refs/heads/feat")
295 assert.ErrorIs(t, err, domain.ErrNotFound)
296}
297
298// panicHooks is a CI trigger whose merge hook panics.
299type panicHooks struct{}
300
301func (panicHooks) OnPush(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) {}
302func (panicHooks) OnMerge(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) {
303 panic("boom from ci")
304}
305
306func TestMergeLogsAPanickingSideEffectAndWaitReturns(t *testing.T) {
307 t.Parallel()
308 r := newMRRig(t)
309 r.seed()
310 head := r.feature("feat")
311 mr := r.open("feat")
312 mrs, release := r.afterMergeRig(func(c *usecase.MergeRequestsConfig) { c.CI = panicHooks{} })
313 release()
314 var logs bytes.Buffer
315 ctx := zerowrap.WithCtx(r.ctx, zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &logs}))
316
317 _, err := mrs.Merge(ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
318 require.NoError(t, err)
319 waited := make(chan struct{})
320 go func() { mrs.Wait(context.Background()); close(waited) }()
321 select {
322 case <-waited:
323 case <-time.After(10 * time.Second):
324 t.Fatal("Wait did not return after a panicking side effect")
325 }
326 assert.Contains(t, logs.String(), "post-merge side effects panicked")
327 assert.Contains(t, logs.String(), "boom from ci")
328}
329
330// A head behind the target has nothing to merge, whatever the mode (GO-007).
331func TestMergeRefusesAHeadBehindTheTarget(t *testing.T) {
332 t.Parallel()
333 for _, mode := range []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeMerge, domain.MergeModeFastForward} {
334 t.Run(string(mode), func(t *testing.T) {
335 t.Parallel()
336 r := newMRRig(t)
337 main := r.seed()
338 r.sh("checkout", "-q", "-B", "old", "main~1")
339 r.push("old")
340 old := r.branchHead("old")
341 require.NotEqual(t, main, old)
342 mr := r.open("old")
343
344 _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{Mode: mode, HeadID: old})
345 require.ErrorIs(t, err, domain.ErrPreconditionFailed)
346 assert.Contains(t, err.Error(), "nothing to merge")
347 assert.Equal(t, main, r.branchHead("main"), "no commit was made")
348 })
349 }
350}