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}