1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6 "bytes"
7 "context"
8 "crypto/ed25519"
9 "strings"
10 "sync"
11 "testing"
12 "time"
13
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"
16
17 "bnema.dev/ermite/internal/adapters/sshsig"
18 "bnema.dev/ermite/internal/domain"
19 "bnema.dev/ermite/internal/ports"
20 "bnema.dev/ermite/internal/usecase"
21)
22
23// gateHooks is a CI and a pull_request trigger that block on a channel, to
24// tell what runs before Merge returns.
25type gateHooks struct {
26 release chan struct{}
27 mu sync.Mutex
28 calls []string
29}
30
31func newGateHooks() *gateHooks { return &gateHooks{release: make(chan struct{})} }
32
33func (g *gateHooks) note(s string) {
34 <-g.release
35 g.mu.Lock()
36 defer g.mu.Unlock()
37 g.calls = append(g.calls, s)
38}
39
40func (g *gateHooks) ran() []string {
41 g.mu.Lock()
42 defer g.mu.Unlock()
43 return append([]string(nil), g.calls...)
44}
45
46func (g *gateHooks) OnPush(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) {}
47
48func (g *gateHooks) OnMerge(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) {
49 g.note("ci")
50}
51
52// OnPullRequest blocks for the closing of a request only: Create reports the
53// opening on the request path.
54func (g *gateHooks) OnPullRequest(_ context.Context, _ domain.RepoName, ev ports.PullRequestEvent, _ domain.Principal) {
55 if ev.Action == domain.PullClosed {
56 g.note("pulls")
57 }
58}
59
60// gated builds merge request use cases over the rig whose CI and pull hooks
61// block until g.release is closed.
62func (r *mrRig) gated(g *gateHooks) *usecase.MergeRequests {
63 signer, err := sshsig.New(bytes.Repeat([]byte{9}, ed25519.SeedSize))
64 require.NoError(r.t, err)
65 return usecase.NewMergeRequests(usecase.MergeRequestsConfig{
66 Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Signer: signer,
67 Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"},
68 Bus: r.bus, Clock: fixedClock{}, Maint: r.sched, CI: g, PullRequests: g,
69 })
70}
71
72// behind moves main on with a commit of its own (a file the feature does not
73// touch), and returns the new tip.
74func (r *mrRig) behind() domain.ObjectID {
75 r.sh("checkout", "-q", "main")
76 r.commit(author, "news.txt", "n\n", "main moved")
77 r.push("main")
78 return r.branchHead("main")
79}
80
81func TestMergeModesWhenBehind(t *testing.T) {
82 t.Parallel()
83 t.Run("fast-forward is refused", func(t *testing.T) {
84 t.Parallel()
85 r := newMRRig(t)
86 r.seed()
87 head := r.feature("feat")
88 mr := r.open("feat")
89 tip := r.behind()
90 _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
91 require.ErrorIs(t, err, domain.ErrPreconditionFailed)
92 assert.Contains(t, err.Error(), "the target has moved: rebase and push again, or merge with squash or a merge commit")
93 assert.Equal(t, tip, r.branchHead("main"))
94 })
95
96 t.Run("squash merges the merged tree", 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 tip := r.behind()
103 res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash})
104 require.NoError(t, err)
105 sq := res.MergedCommitID.String()
106 g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) }
107 assert.Equal(t, tip.String(), g("rev-parse", sq+"^"), "the current tip is the parent")
108 files := g("ls-tree", "-r", "--name-only", sq)
109 assert.Contains(t, files, "news.txt", "the target's own change is kept")
110 assert.Contains(t, files, "c.txt", "the branch's change is there")
111 r.verifyCommit(sq)
112 })
113
114 t.Run("merge commit has two parents, the merge tree and a good signature", func(t *testing.T) {
115 t.Parallel()
116 r := newMRRig(t)
117 r.seed()
118 head := r.feature("feat")
119 mr := r.open("feat")
120 tip := r.behind()
121 want := r.run(r.bare(), r.env(author), "merge-tree", "--write-tree", tip.String(), head.String())
122 res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeMerge})
123 require.NoError(t, err)
124 assert.Equal(t, domain.MergeModeMerge, res.MergeMode)
125 assert.Equal(t, tip, res.BaseID)
126 id := res.MergedCommitID.String()
127 assert.Equal(t, id, r.branchHead("main").String())
128 g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) }
129 assert.Equal(t, tip.String()+" "+head.String(), g("log", "-1", "--format=%P", id), "parents: target, head")
130 assert.Equal(t, strings.TrimSpace(want), g("rev-parse", id+"^{tree}"))
131 assert.Equal(t, "Merge branch 'feat' into main (!1)\n\nAdd feat", g("log", "-1", "--format=%B", id))
132 assert.Equal(t, "ermite <ermite@git.example.org>", g("log", "-1", "--format=%an <%ae>", id))
133 assert.Equal(t, "ermite <ermite@git.example.org>", g("log", "-1", "--format=%cn <%ce>", id))
134 r.verifyCommit(id)
135 })
136
137 t.Run("merge commit when up to date", func(t *testing.T) {
138 t.Parallel()
139 r := newMRRig(t)
140 main := r.seed()
141 head := r.feature("feat")
142 mr := r.open("feat")
143 res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeMerge})
144 require.NoError(t, err)
145 id := res.MergedCommitID.String()
146 g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) }
147 assert.Equal(t, main.String()+" "+head.String(), g("log", "-1", "--format=%P", id))
148 assert.Equal(t, g("rev-parse", head.String()+"^{tree}"), g("rev-parse", id+"^{tree}"))
149 })
150}
151
152func TestMergeRefusedOnConflictAndNoCommonHistory(t *testing.T) {
153 t.Parallel()
154 conflict := func(t *testing.T) (*mrRig, domain.MergeRequest, domain.ObjectID, domain.ObjectID) {
155 r := newMRRig(t)
156 r.seed()
157 head := r.feature("feat") // changes line 2 of a.txt
158 mr := r.open("feat")
159 r.sh("checkout", "-q", "main")
160 r.commit(author, "a.txt", "one\nTWO\nthree\n", "main changes a too")
161 r.push("main")
162 return r, mr, head, r.branchHead("main")
163 }
164 for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge} {
165 t.Run("conflict "+string(mode), func(t *testing.T) {
166 t.Parallel()
167 r, mr, head, tip := conflict(t)
168 _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: mode})
169 require.ErrorIs(t, err, domain.ErrPreconditionFailed)
170 if mode != domain.MergeModeFastForward {
171 assert.Contains(t, err.Error(), "conflicts with main: a.txt: rebase and push again")
172 }
173 assert.Equal(t, tip, r.branchHead("main"))
174 d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{})
175 require.NoError(t, err)
176 assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State)
177 })
178 }
179
180 for _, mode := range []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeMerge} {
181 t.Run("no common history "+string(mode), func(t *testing.T) {
182 t.Parallel()
183 r := newMRRig(t)
184 r.seed()
185 r.sh("checkout", "-q", "--orphan", "island")
186 r.commit(author, "island.txt", "i\n", "an unrelated root")
187 r.push("island")
188 head := r.branchHead("island")
189 mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "island", Source: "island"})
190 if err != nil {
191 t.Skipf("a request without common history cannot be opened: %v", err)
192 }
193 tip := r.branchHead("main")
194 _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: mode})
195 require.ErrorIs(t, err, domain.ErrPreconditionFailed)
196 assert.Contains(t, err.Error(), "no common history")
197 assert.Equal(t, tip, r.branchHead("main"))
198 })
199 }
200}
201
202func TestMergeReturnsBeforeTheSideEffects(t *testing.T) {
203 t.Parallel()
204 r := newMRRig(t)
205 r.seed()
206 head := r.feature("feat")
207 mr := r.open("feat")
208 r.drain()
209 g := newGateHooks()
210 mrs := r.gated(g)
211
212 done := make(chan error, 1)
213 go func() {
214 _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
215 done <- err
216 }()
217 select {
218 case err := <-done:
219 require.NoError(t, err, "Merge does not wait for the blocked hooks")
220 case <-time.After(10 * time.Second):
221 t.Fatal("Merge waited for the side effects")
222 }
223 // The merge is complete and recorded though the hooks have not run.
224 d, err := mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{})
225 require.NoError(t, err)
226 assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State)
227 assert.Equal(t, d.MergeRequest.MergedCommitID, r.branchHead("main"))
228 assert.Empty(t, g.ran())
229
230 // Wait waits for them: it times out while they are blocked, then returns.
231 ctx, cancel := context.WithTimeout(r.ctx, 50*time.Millisecond)
232 start := time.Now()
233 mrs.Wait(ctx)
234 cancel()
235 assert.GreaterOrEqual(t, time.Since(start), 40*time.Millisecond, "Wait waits for the pending side effects")
236
237 waited := make(chan struct{})
238 go func() { mrs.Wait(context.Background()); close(waited) }()
239 select {
240 case <-waited:
241 t.Fatal("Wait returned with side effects pending")
242 case <-time.After(50 * time.Millisecond):
243 }
244 close(g.release)
245 select {
246 case <-waited:
247 case <-time.After(10 * time.Second):
248 t.Fatal("Wait did not return after the side effects ended")
249 }
250 assert.ElementsMatch(t, []string{"ci", "pulls"}, g.ran())
251 _, err = r.git.BranchHead(r.ctx, r.repo, "feat")
252 assert.ErrorIs(t, err, domain.ErrNotFound, "the source was deleted")
253
254 // Merged, then pushed, with the deletion in the push.
255 evs := r.drain()
256 require.Len(t, evs, 2)
257 assert.IsType(t, domain.MergeRequestMerged{}, evs[0])
258 push, ok := evs[1].(domain.PushEvent)
259 require.True(t, ok)
260 assert.Len(t, push.Updates, 2)
261}
262
263func TestMergeReleasesTheLockBeforeTheSideEffects(t *testing.T) {
264 t.Parallel()
265 r := newMRRig(t)
266 r.seed()
267 head := r.feature("feat")
268 mr := r.open("feat")
269 r.sh("checkout", "-q", "main")
270 other := r.openFeature("other")
271 g := newGateHooks()
272 defer func() {
273 close(g.release)
274 }()
275 mrs := r.gated(g)
276
277 _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
278 require.NoError(t, err)
279 t.Cleanup(func() { mrs.Wait(context.Background()) })
280 require.Empty(t, g.ran(), "the side effects are still blocked")
281
282 // Create and another Merge need the repository lock: they must finish.
283 ctx, cancel := context.WithTimeout(r.ctx, 10*time.Second)
284 defer cancel()
285 r.sh("checkout", "-q", "-B", "third", "main")
286 r.commit(author, "third.txt", "t\n", "third")
287 r.push("third")
288 _, err = mrs.Create(ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "third", Source: "third"})
289 require.NoError(t, err, "Create is not blocked by the side effects of a merge")
290 _, err = mrs.Merge(ctx, mrM, r.repo, other.Number, ports.MergeParams{HeadID: r.branchHead("other"), Mode: domain.MergeModeMerge})
291 require.NoError(t, err, "a second merge is not blocked either")
292 assert.Empty(t, g.ran())
293}