v0.5.0 bfab294

mergerequests_delete_test.go RawHistoryFolder

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

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