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, ports.GetMergeRequestOptions{})
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, ports.GetMergeRequestOptions{})
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, ports.GetMergeRequestOptions{})
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}