v0.29.0 55fcc8f

mergerequests_merge_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 44

↑↓ moveenter openesc close