detached 27e453c

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	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}

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