v0.18.0 0cea275

mergerequests_delete_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6	"context"
7	"testing"
8
9	"github.com/stretchr/testify/assert"
10	"github.com/stretchr/testify/require"
11
12	"bnema.dev/ermite/internal/adapters/gitcli"
13	"bnema.dev/ermite/internal/domain"
14	"bnema.dev/ermite/internal/ports"
15	"bnema.dev/ermite/internal/usecase"
16)
17
18// A merge can delete its source branch; the push event carries both refs.
19func TestMergeDeletesTheSourceBranch(t *testing.T) {
20	t.Parallel()
21	for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} {
22		t.Run(string(mode), func(t *testing.T) {
23			t.Parallel()
24			r := newMRRig(t)
25			main := r.seed()
26			head := r.feature("feat")
27			mr := r.open("feat")
28			r.drain()
29
30			res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{Mode: mode, HeadID: head, DeleteSource: true})
31			require.NoError(t, err)
32			assert.True(t, res.SourceDeleted)
33			assert.Equal(t, domain.MRStateMerged, res.State)
34			_, err = r.git.BranchHead(r.ctx, r.repo, "feat")
35			assert.ErrorIs(t, err, domain.ErrNotFound, "the branch is gone")
36
37			evs := r.drain()
38			require.Len(t, evs, 2)
39			assert.Equal(t, []domain.RefUpdate{
40				{Old: main.String(), New: res.MergedCommitID.String(), Ref: "refs/heads/main"},
41				{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/feat"},
42			}, evs[1].(domain.PushEvent).Updates)
43
44			// The merged request still reads, with its commits and diff.
45			d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
46			require.NoError(t, err)
47			assert.Len(t, d.Commits, 2)
48			assert.Len(t, d.Diff.Files, 2)
49		})
50	}
51
52	t.Run("without the option the branch stays", func(t *testing.T) {
53		t.Parallel()
54		r := newMRRig(t)
55		r.seed()
56		head := r.feature("feat")
57		mr := r.open("feat")
58		res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
59		require.NoError(t, err)
60		assert.False(t, res.SourceDeleted)
61		assert.Equal(t, head, r.branchHead("feat"))
62	})
63}
64
65// Deleting the source is refused before the merge when the branch is still
66// needed: nothing changes.
67func TestMergeRefusesToDeleteANeededBranch(t *testing.T) {
68	t.Parallel()
69	t.Run("another open request uses it as its source", func(t *testing.T) {
70		t.Parallel()
71		r := newMRRig(t)
72		main := r.seed()
73		head := r.feature("feat")
74		r.sh("checkout", "-q", "-B", "release", "main")
75		r.push("release")
76		mr := r.open("feat")
77		_, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "to release", Source: "feat", Target: "release"})
78		require.NoError(t, err)
79
80		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
81		require.ErrorIs(t, err, domain.ErrPreconditionFailed)
82		assert.Contains(t, err.Error(), "!2 still uses")
83		assert.Equal(t, main, r.branchHead("main"), "not merged")
84		assert.Equal(t, head, r.branchHead("feat"))
85		d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
86		require.NoError(t, err)
87		assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State)
88	})
89
90	t.Run("another open request targets it", func(t *testing.T) {
91		t.Parallel()
92		r := newMRRig(t)
93		r.seed()
94		head := r.feature("feat")
95		mr := r.open("feat")
96		r.sh("checkout", "-q", "-B", "sub", "feat")
97		r.commit(author, "d.txt", "dee\n", "add d")
98		r.push("sub")
99		_, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "stacked", Source: "sub", Target: "feat"})
100		require.NoError(t, err)
101		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
102		require.ErrorIs(t, err, domain.ErrPreconditionFailed)
103		assert.Equal(t, head, r.branchHead("feat"))
104	})
105
106	t.Run("the default branch", func(t *testing.T) {
107		t.Parallel()
108		r := newMRRig(t)
109		main := r.seed()
110		r.sh("checkout", "-q", "-B", "old", "main~1")
111		r.push("old")
112		old := r.branchHead("old")
113		mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "back", Source: "main", Target: "old"})
114		require.NoError(t, err)
115		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: main, DeleteSource: true})
116		require.ErrorIs(t, err, domain.ErrPreconditionFailed)
117		assert.Contains(t, err.Error(), "default branch")
118		assert.Equal(t, old, r.branchHead("old"), "not merged")
119		assert.Equal(t, main, r.branchHead("main"))
120	})
121}
122
123// After a squash with deletion, gc may prune the reviewed commits: the merged
124// request still reads, without commits or diff.
125func TestSquashMergedRequestReadsAfterItsCommitsArePruned(t *testing.T) {
126	t.Parallel()
127	r := newMRRig(t)
128	r.seed()
129	head := r.feature("feat")
130	mr := r.open("feat")
131	res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{Mode: domain.MergeModeSquash, HeadID: head, DeleteSource: true})
132	require.NoError(t, err)
133	require.True(t, res.SourceDeleted)
134
135	r.run(r.bare(), r.env(author), "reflog", "expire", "--expire=now", "--all")
136	r.run(r.bare(), r.env(author), "gc", "-q", "--prune=now")
137	ok, err := r.git.HasCommit(r.ctx, r.repo, head)
138	require.NoError(t, err)
139	require.False(t, ok, "the reviewed commits are pruned")
140
141	d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
142	require.NoError(t, err)
143	assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State)
144	assert.Equal(t, res.MergedCommitID, d.MergeRequest.MergedCommitID)
145	assert.Empty(t, d.Commits)
146	assert.Empty(t, d.Diff.Files)
147	assert.True(t, d.DiffBaseID.IsZero())
148}
149
150// A request opened on a branch a merge is deleting waits for the merge lock
151// and then sees the branch is gone.
152func TestCreateAfterAMergeDeletedTheBranch(t *testing.T) {
153	t.Parallel()
154	r := newMRRig(t)
155	r.seed()
156	head := r.feature("feat")
157	mr := r.open("feat")
158	_, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
159	require.NoError(t, err)
160	_, err = r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "late", Source: "feat"})
161	require.ErrorIs(t, err, domain.ErrInvalid)
162	assert.Contains(t, fieldsOf(t, err), "source_branch")
163}
164
165// movingGit pushes to the source branch between the merge and the deletion.
166type movingGit struct {
167	*gitcli.Git
168	move func()
169}
170
171func (g movingGit) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error {
172	g.move()
173	return g.Git.DeleteBranch(ctx, repo, branch, old)
174}
175
176// A branch that moves after the merge is kept: the merge stands.
177func TestMergeKeepsASourceThatMovedAfterTheMerge(t *testing.T) {
178	t.Parallel()
179	r := newMRRig(t)
180	r.seed()
181	head := r.feature("feat")
182	mr := r.open("feat")
183	var moved domain.ObjectID
184	mrs := usecase.NewMergeRequests(usecase.MergeRequestsConfig{
185		Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Bus: r.bus, Clock: fixedClock{}, Maint: r.sched,
186		Git: movingGit{Git: r.git, move: func() {
187			moved = r.commit(author, "late.txt", "late\n", "pushed during the merge")
188			r.push("feat")
189		}},
190	})
191	r.drain()
192
193	res, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
194	require.NoError(t, err, "the merge stands")
195	assert.False(t, res.SourceDeleted)
196	assert.Equal(t, domain.MRStateMerged, res.State)
197	assert.Equal(t, head, r.branchHead("main"))
198	assert.Equal(t, moved, r.branchHead("feat"), "the late commit is kept")
199	evs := r.drain()
200	require.Len(t, evs, 2)
201	assert.Len(t, evs[1].(domain.PushEvent).Updates, 1, "only the target moved")
202}

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 45

↑↓ moveenter openesc close