detached aa7340c

mergerequests_delete_test.go RawHistoryFolder

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}

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 4

Tags 45

↑↓ moveenter openesc close