detached 8f36a2f

feat(mr): mergeability types, merge-tree checks, storage record and merge commit mode

commit
8f36a2f65cb5bad59a525d3e7f4a50d544525a6c
author
brice · · 2026-10-19 12:50 UTC
committer
brice · · 2026-10-19 18:30 UTC
parent
4ce8534
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
22 files · +1541 -54
M gen/ermite/storage/merge_request.pb.go +216 -24 generated file: collapsed
M go.mod +1 -0
@@ -172,3 +172,4 @@ require (
172172 mvdan.cc/xurls/v2 v2.6.0 // indirect
173173 pluginrpc.com/pluginrpc v0.5.0 // indirect
174174 )
175+replace bnema.dev/ermite-api => /home/brice/dev/projects/ermite-workspace/ermite-api/.worktrees/mr-mergeability
@@ -172,3 +172,4 @@ require (
172 mvdan.cc/xurls/v2 v2.6.0 // indirect172 mvdan.cc/xurls/v2 v2.6.0 // indirect
173 pluginrpc.com/pluginrpc v0.5.0 // indirect173 pluginrpc.com/pluginrpc v0.5.0 // indirect
174 )174 )
175+replace bnema.dev/ermite-api => /home/brice/dev/projects/ermite-workspace/ermite-api/.worktrees/mr-mergeability
M internal/adapters/gitcli/git.go +12 -2
@@ -131,6 +131,16 @@ func (g *Git) run(ctx context.Context, args ...string) (string, error) {
131131
132132 // runInput is run with the given standard input; nil means none.
133133 func (g *Git) runInput(ctx context.Context, stdin io.Reader, args ...string) (string, error) {
134+ out, err := g.runCapture(ctx, stdin, args...)
135+ if err != nil {
136+ return "", err
137+ }
138+ return out, nil
139+}
140+
141+// runCapture is runInput that also returns the standard output of a failed
142+// run.
143+func (g *Git) runCapture(ctx context.Context, stdin io.Reader, args ...string) (string, error) {
134144 if _, ok := ctx.Deadline(); !ok {
135145 var cancel context.CancelFunc
136146 ctx, cancel = context.WithTimeout(ctx, commandTimeout)
@@ -156,9 +166,9 @@ func (g *Git) runInput(ctx context.Context, stdin io.Reader, args ...string) (st
156166 }
157167 var exit *exec.ExitError
158168 if errors.As(err, &exit) && msg != "" {
159- return "", fmt.Errorf("git %s: %w: %s", subcommand(args), err, msg)
169+ return stdout.String(), fmt.Errorf("git %s: %w: %s", subcommand(args), err, msg)
160170 }
161- return "", fmt.Errorf("git %s: %w", subcommand(args), err)
171+ return stdout.String(), fmt.Errorf("git %s: %w", subcommand(args), err)
162172 }
163173 return stdout.String(), nil
164174 }
@@ -131,6 +131,16 @@ func (g *Git) run(ctx context.Context, args ...string) (string, error) {
131 131
132 // runInput is run with the given standard input; nil means none.132 // runInput is run with the given standard input; nil means none.
133 func (g *Git) runInput(ctx context.Context, stdin io.Reader, args ...string) (string, error) {133 func (g *Git) runInput(ctx context.Context, stdin io.Reader, args ...string) (string, error) {
134+ out, err := g.runCapture(ctx, stdin, args...)
135+ if err != nil {
136+ return "", err
137+ }
138+ return out, nil
139+}
140+
141+// runCapture is runInput that also returns the standard output of a failed
142+// run.
143+func (g *Git) runCapture(ctx context.Context, stdin io.Reader, args ...string) (string, error) {
134 if _, ok := ctx.Deadline(); !ok {144 if _, ok := ctx.Deadline(); !ok {
135 var cancel context.CancelFunc145 var cancel context.CancelFunc
136 ctx, cancel = context.WithTimeout(ctx, commandTimeout)146 ctx, cancel = context.WithTimeout(ctx, commandTimeout)
@@ -156,9 +166,9 @@ func (g *Git) runInput(ctx context.Context, stdin io.Reader, args ...string) (st
156 }166 }
157 var exit *exec.ExitError167 var exit *exec.ExitError
158 if errors.As(err, &exit) && msg != "" {168 if errors.As(err, &exit) && msg != "" {
159- return "", fmt.Errorf("git %s: %w: %s", subcommand(args), err, msg)169+ return stdout.String(), fmt.Errorf("git %s: %w: %s", subcommand(args), err, msg)
160 }170 }
161- return "", fmt.Errorf("git %s: %w", subcommand(args), err)171+ return stdout.String(), fmt.Errorf("git %s: %w", subcommand(args), err)
162 }172 }
163 return stdout.String(), nil173 return stdout.String(), nil
164 }174 }
M internal/adapters/gitcli/merge_test.go +87 -0
@@ -4,6 +4,7 @@ package gitcli_test
44
55 import (
66 "context"
7+ "fmt"
78 "strings"
89 "testing"
910
@@ -231,3 +232,89 @@ func TestDeleteBranch(t *testing.T) {
231232 err = g.DeleteBranch(ctx, name(t, "missing"), "main", m.mainTip)
232233 assert.ErrorIs(t, err, domain.ErrNotFound)
233234 }
235+
236+func TestMergeTree(t *testing.T) {
237+ g, data := newGit(t)
238+ ctx := context.Background()
239+ m := newMergeFixture(t, data)
240+ t.Cleanup(func() { _ = g.Close(ctx) })
241+
242+ // Clean: main is an ancestor of feat.
243+ res, err := g.MergeTree(ctx, m.f.Name, m.mainTip, m.featTip)
244+ require.NoError(t, err)
245+ assert.True(t, res.Clean)
246+ assert.False(t, res.Tree.IsZero())
247+ assert.Empty(t, res.ConflictPaths)
248+
249+ // Conflict: both sides change a.txt and c.txt differently.
250+ m.f.Git("checkout", "--quiet", "-b", "clash", m.mainTip.String())
251+ m.f.Write("a.txt", "one\nTWO\nthree\n")
252+ m.f.Write("c.txt", "other\n")
253+ clash := oidOf(t, m.f.Commit("clash"))
254+ res, err = g.MergeTree(ctx, m.f.Name, clash, m.featTip)
255+ require.NoError(t, err)
256+ assert.False(t, res.Clean)
257+ assert.True(t, res.Tree.IsZero())
258+ assert.ElementsMatch(t, []string{"a.txt", "c.txt"}, res.ConflictPaths)
259+ assert.False(t, res.Truncated)
260+
261+ // No common history: not clean, no paths.
262+ m.f.Git("checkout", "--quiet", "--orphan", "other")
263+ m.f.Git("rm", "-rf", "--quiet", ".")
264+ m.f.Write("o.txt", "other\n")
265+ other := oidOf(t, m.f.Commit("other root"))
266+ res, err = g.MergeTree(ctx, m.f.Name, other, m.featTip)
267+ require.NoError(t, err)
268+ assert.False(t, res.Clean)
269+ assert.Empty(t, res.ConflictPaths)
270+
271+ // More conflicts than the cap.
272+ m.f.Git("checkout", "--quiet", "-b", "wide-a", m.a.String())
273+ for i := 0; i < domain.MaxConflictPaths+5; i++ {
274+ m.f.Write(fmt.Sprintf("f%02d.txt", i), "base\n")
275+ }
276+ base := oidOf(t, m.f.Commit("many"))
277+ m.f.Git("checkout", "--quiet", "-b", "wide-b", base.String())
278+ for i := 0; i < domain.MaxConflictPaths+5; i++ {
279+ m.f.Write(fmt.Sprintf("f%02d.txt", i), "b\n")
280+ }
281+ wb := oidOf(t, m.f.Commit("b side"))
282+ m.f.Git("checkout", "--quiet", "wide-a")
283+ for i := 0; i < domain.MaxConflictPaths+5; i++ {
284+ m.f.Write(fmt.Sprintf("f%02d.txt", i), "a\n")
285+ }
286+ wa := oidOf(t, m.f.Commit("a side"))
287+ res, err = g.MergeTree(ctx, m.f.Name, wa, wb)
288+ require.NoError(t, err)
289+ assert.False(t, res.Clean)
290+ assert.Len(t, res.ConflictPaths, domain.MaxConflictPaths)
291+ assert.True(t, res.Truncated)
292+}
293+
294+func TestCountBehind(t *testing.T) {
295+ g, data := newGit(t)
296+ ctx := context.Background()
297+ m := newMergeFixture(t, data)
298+ t.Cleanup(func() { _ = g.Close(ctx) })
299+
300+ // main has b that a-based head lacks.
301+ n, capped, err := g.CountBehind(ctx, m.f.Name, m.a, m.mainTip, 10)
302+ require.NoError(t, err)
303+ assert.Equal(t, 1, n)
304+ assert.False(t, capped)
305+
306+ n, capped, err = g.CountBehind(ctx, m.f.Name, m.featTip, m.mainTip, 10)
307+ require.NoError(t, err)
308+ assert.Equal(t, 0, n)
309+ assert.False(t, capped)
310+
311+ // feat is 3 commits ahead of a: counting target=feat from head=a with cap 2.
312+ n, capped, err = g.CountBehind(ctx, m.f.Name, m.a, m.featTip, 2)
313+ require.NoError(t, err)
314+ assert.Equal(t, 2, n)
315+ assert.True(t, capped)
316+ n, capped, err = g.CountBehind(ctx, m.f.Name, m.a, m.featTip, 3)
317+ require.NoError(t, err)
318+ assert.Equal(t, 3, n)
319+ assert.False(t, capped)
320+}
@@ -4,6 +4,7 @@ package gitcli_test
4 4
5 import (5 import (
6 "context"6 "context"
7+ "fmt"
7 "strings"8 "strings"
8 "testing"9 "testing"
9 10
@@ -231,3 +232,89 @@ func TestDeleteBranch(t *testing.T) {
231 err = g.DeleteBranch(ctx, name(t, "missing"), "main", m.mainTip)232 err = g.DeleteBranch(ctx, name(t, "missing"), "main", m.mainTip)
232 assert.ErrorIs(t, err, domain.ErrNotFound)233 assert.ErrorIs(t, err, domain.ErrNotFound)
233 }234 }
235+
236+func TestMergeTree(t *testing.T) {
237+ g, data := newGit(t)
238+ ctx := context.Background()
239+ m := newMergeFixture(t, data)
240+ t.Cleanup(func() { _ = g.Close(ctx) })
241+
242+ // Clean: main is an ancestor of feat.
243+ res, err := g.MergeTree(ctx, m.f.Name, m.mainTip, m.featTip)
244+ require.NoError(t, err)
245+ assert.True(t, res.Clean)
246+ assert.False(t, res.Tree.IsZero())
247+ assert.Empty(t, res.ConflictPaths)
248+
249+ // Conflict: both sides change a.txt and c.txt differently.
250+ m.f.Git("checkout", "--quiet", "-b", "clash", m.mainTip.String())
251+ m.f.Write("a.txt", "one\nTWO\nthree\n")
252+ m.f.Write("c.txt", "other\n")
253+ clash := oidOf(t, m.f.Commit("clash"))
254+ res, err = g.MergeTree(ctx, m.f.Name, clash, m.featTip)
255+ require.NoError(t, err)
256+ assert.False(t, res.Clean)
257+ assert.True(t, res.Tree.IsZero())
258+ assert.ElementsMatch(t, []string{"a.txt", "c.txt"}, res.ConflictPaths)
259+ assert.False(t, res.Truncated)
260+
261+ // No common history: not clean, no paths.
262+ m.f.Git("checkout", "--quiet", "--orphan", "other")
263+ m.f.Git("rm", "-rf", "--quiet", ".")
264+ m.f.Write("o.txt", "other\n")
265+ other := oidOf(t, m.f.Commit("other root"))
266+ res, err = g.MergeTree(ctx, m.f.Name, other, m.featTip)
267+ require.NoError(t, err)
268+ assert.False(t, res.Clean)
269+ assert.Empty(t, res.ConflictPaths)
270+
271+ // More conflicts than the cap.
272+ m.f.Git("checkout", "--quiet", "-b", "wide-a", m.a.String())
273+ for i := 0; i < domain.MaxConflictPaths+5; i++ {
274+ m.f.Write(fmt.Sprintf("f%02d.txt", i), "base\n")
275+ }
276+ base := oidOf(t, m.f.Commit("many"))
277+ m.f.Git("checkout", "--quiet", "-b", "wide-b", base.String())
278+ for i := 0; i < domain.MaxConflictPaths+5; i++ {
279+ m.f.Write(fmt.Sprintf("f%02d.txt", i), "b\n")
280+ }
281+ wb := oidOf(t, m.f.Commit("b side"))
282+ m.f.Git("checkout", "--quiet", "wide-a")
283+ for i := 0; i < domain.MaxConflictPaths+5; i++ {
284+ m.f.Write(fmt.Sprintf("f%02d.txt", i), "a\n")
285+ }
286+ wa := oidOf(t, m.f.Commit("a side"))
287+ res, err = g.MergeTree(ctx, m.f.Name, wa, wb)
288+ require.NoError(t, err)
289+ assert.False(t, res.Clean)
290+ assert.Len(t, res.ConflictPaths, domain.MaxConflictPaths)
291+ assert.True(t, res.Truncated)
292+}
293+
294+func TestCountBehind(t *testing.T) {
295+ g, data := newGit(t)
296+ ctx := context.Background()
297+ m := newMergeFixture(t, data)
298+ t.Cleanup(func() { _ = g.Close(ctx) })
299+
300+ // main has b that a-based head lacks.
301+ n, capped, err := g.CountBehind(ctx, m.f.Name, m.a, m.mainTip, 10)
302+ require.NoError(t, err)
303+ assert.Equal(t, 1, n)
304+ assert.False(t, capped)
305+
306+ n, capped, err = g.CountBehind(ctx, m.f.Name, m.featTip, m.mainTip, 10)
307+ require.NoError(t, err)
308+ assert.Equal(t, 0, n)
309+ assert.False(t, capped)
310+
311+ // feat is 3 commits ahead of a: counting target=feat from head=a with cap 2.
312+ n, capped, err = g.CountBehind(ctx, m.f.Name, m.a, m.featTip, 2)
313+ require.NoError(t, err)
314+ assert.Equal(t, 2, n)
315+ assert.True(t, capped)
316+ n, capped, err = g.CountBehind(ctx, m.f.Name, m.a, m.featTip, 3)
317+ require.NoError(t, err)
318+ assert.Equal(t, 3, n)
319+ assert.False(t, capped)
320+}
A internal/adapters/gitcli/mergeability.go +81 -0
@@ -0,0 +1,81 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "strconv"
10+ "strings"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+// MergeTree runs `git merge-tree --write-tree -z --name-only`, which merges in
16+// the object database and touches no ref or index. It prints the tree of the
17+// result, then on a conflict (exit 1) the NUL-separated conflicted paths.
18+// Commits with no common history do not merge: not clean, without paths.
19+//
20+//hexcheck:ignore suspicious-business-logic-in-adapter maps git exit codes and output to the clean/conflict answer of the port
21+func (g *Git) MergeTree(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID) (domain.MergeTreeResult, error) {
22+ dir, err := g.repoDir(repo)
23+ if err != nil {
24+ return domain.MergeTreeResult{}, err
25+ }
26+ if _, err := g.MergeBase(ctx, repo, base, head); errors.Is(err, domain.ErrNotFound) {
27+ return domain.MergeTreeResult{}, nil
28+ } else if err != nil {
29+ return domain.MergeTreeResult{}, err
30+ }
31+ out, err := g.runCapture(ctx, nil, readArgs(dir, "merge-tree", "--write-tree", "-z", "--name-only", "--no-messages", "--end-of-options", base.String(), head.String())...)
32+ conflict := exitCode(err) == 1
33+ if err != nil && !conflict {
34+ return domain.MergeTreeResult{}, fmt.Errorf("merge %s into %s in %q: %w", head, base, repo, err)
35+ }
36+ fields := strings.Split(out, "\x00")
37+ tree, perr := parseID(fields[0])
38+ if perr != nil && !conflict {
39+ return domain.MergeTreeResult{}, perr
40+ }
41+ if !conflict {
42+ return domain.MergeTreeResult{Tree: tree, Clean: true}, nil
43+ }
44+ res := domain.MergeTreeResult{}
45+ for _, p := range fields[1:] {
46+ if p == "" {
47+ break
48+ }
49+ if len(res.ConflictPaths) == domain.MaxConflictPaths {
50+ res.Truncated = true
51+ break
52+ }
53+ res.ConflictPaths = append(res.ConflictPaths, p)
54+ }
55+ return res, nil
56+}
57+
58+// CountBehind runs `git rev-list --count --max-count=max+1 <target> ^<head>`.
59+//
60+//hexcheck:ignore suspicious-business-logic-in-adapter applies the cap of the port to the count git printed
61+func (g *Git) CountBehind(ctx context.Context, repo domain.RepoName, head, target domain.ObjectID, max int) (int, bool, error) {
62+ dir, err := g.repoDir(repo)
63+ if err != nil {
64+ return 0, false, err
65+ }
66+ if max < 0 {
67+ max = 0
68+ }
69+ out, err := g.run(ctx, readArgs(dir, "rev-list", "--count", "--max-count="+strconv.Itoa(max+1), target.String(), "^"+head.String(), "--")...)
70+ if err != nil {
71+ return 0, false, fmt.Errorf("count behind %s in %q: %w", target, repo, err)
72+ }
73+ n, err := strconv.Atoi(strings.TrimSpace(out))
74+ if err != nil {
75+ return 0, false, fmt.Errorf("git printed %q: %w", strings.TrimSpace(out), err)
76+ }
77+ if n > max {
78+ return max, true, nil
79+ }
80+ return n, false, nil
81+}
@@ -0,0 +1,81 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "strconv"
10+ "strings"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+// MergeTree runs `git merge-tree --write-tree -z --name-only`, which merges in
16+// the object database and touches no ref or index. It prints the tree of the
17+// result, then on a conflict (exit 1) the NUL-separated conflicted paths.
18+// Commits with no common history do not merge: not clean, without paths.
19+//
20+//hexcheck:ignore suspicious-business-logic-in-adapter maps git exit codes and output to the clean/conflict answer of the port
21+func (g *Git) MergeTree(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID) (domain.MergeTreeResult, error) {
22+ dir, err := g.repoDir(repo)
23+ if err != nil {
24+ return domain.MergeTreeResult{}, err
25+ }
26+ if _, err := g.MergeBase(ctx, repo, base, head); errors.Is(err, domain.ErrNotFound) {
27+ return domain.MergeTreeResult{}, nil
28+ } else if err != nil {
29+ return domain.MergeTreeResult{}, err
30+ }
31+ out, err := g.runCapture(ctx, nil, readArgs(dir, "merge-tree", "--write-tree", "-z", "--name-only", "--no-messages", "--end-of-options", base.String(), head.String())...)
32+ conflict := exitCode(err) == 1
33+ if err != nil && !conflict {
34+ return domain.MergeTreeResult{}, fmt.Errorf("merge %s into %s in %q: %w", head, base, repo, err)
35+ }
36+ fields := strings.Split(out, "\x00")
37+ tree, perr := parseID(fields[0])
38+ if perr != nil && !conflict {
39+ return domain.MergeTreeResult{}, perr
40+ }
41+ if !conflict {
42+ return domain.MergeTreeResult{Tree: tree, Clean: true}, nil
43+ }
44+ res := domain.MergeTreeResult{}
45+ for _, p := range fields[1:] {
46+ if p == "" {
47+ break
48+ }
49+ if len(res.ConflictPaths) == domain.MaxConflictPaths {
50+ res.Truncated = true
51+ break
52+ }
53+ res.ConflictPaths = append(res.ConflictPaths, p)
54+ }
55+ return res, nil
56+}
57+
58+// CountBehind runs `git rev-list --count --max-count=max+1 <target> ^<head>`.
59+//
60+//hexcheck:ignore suspicious-business-logic-in-adapter applies the cap of the port to the count git printed
61+func (g *Git) CountBehind(ctx context.Context, repo domain.RepoName, head, target domain.ObjectID, max int) (int, bool, error) {
62+ dir, err := g.repoDir(repo)
63+ if err != nil {
64+ return 0, false, err
65+ }
66+ if max < 0 {
67+ max = 0
68+ }
69+ out, err := g.run(ctx, readArgs(dir, "rev-list", "--count", "--max-count="+strconv.Itoa(max+1), target.String(), "^"+head.String(), "--")...)
70+ if err != nil {
71+ return 0, false, fmt.Errorf("count behind %s in %q: %w", target, repo, err)
72+ }
73+ n, err := strconv.Atoi(strings.TrimSpace(out))
74+ if err != nil {
75+ return 0, false, fmt.Errorf("git printed %q: %w", strings.TrimSpace(out), err)
76+ }
77+ if n > max {
78+ return max, true, nil
79+ }
80+ return n, false, nil
81+}
M internal/adapters/kvstore/mergerequests.go +147 -5
@@ -17,6 +17,7 @@ import (
1717 kv "bnema.dev/ermite-kv"
1818 "github.com/bnema/zerowrap"
1919 "google.golang.org/protobuf/proto"
20+ "google.golang.org/protobuf/types/known/timestamppb"
2021
2122 storagepb "bnema.dev/ermite/gen/ermite/storage"
2223 "bnema.dev/ermite/internal/domain"
@@ -27,10 +28,11 @@ import (
2728 // ending in "/" never matches another repository; numbers and comment ids are
2829 // zero-padded so that keys sort numerically.
2930 const (
30- mrPrefix = "mr/" // mr/<repo>/<number:010d>
31- commentPrefix = "mrc/" // mrc/<repo>/<number:010d>/<id:010d>
32- readPrefix = "mrread/" // mrread/<repo>/<number:010d>
33- seqPrefix = "mrseq/" // mrseq/<repo>
31+ mrPrefix = "mr/" // mr/<repo>/<number:010d>
32+ commentPrefix = "mrc/" // mrc/<repo>/<number:010d>/<id:010d>
33+ readPrefix = "mrread/" // mrread/<repo>/<number:010d>
34+ seqPrefix = "mrseq/" // mrseq/<repo>
35+ mergePrefix = "mrmerge/" // mrmerge/<repo>/<number:010d>
3436 // The indexes of the open merge requests by source branch and by target
3537 // branch, which a push looks up (the values are not read). They are written in
3638 // the same batch as the merge request, so they are never out of step with it,
@@ -69,6 +71,9 @@ func commentKey(repo domain.RepoName, n, id int64) []byte {
6971 func readKey(repo domain.RepoName, n int64) []byte {
7072 return []byte(readPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, n))
7173 }
74+func mergeKey(repo domain.RepoName, n int64) []byte {
75+ return []byte(mergePrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, n))
76+}
7277 func seqKey(repo domain.RepoName) []byte { return []byte(seqPrefix + repo.String()) }
7378
7479 // mrOpenRepoPrefix is where the open merge requests of a repository are indexed
@@ -175,6 +180,7 @@ func mergeRequestKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoNa
175180 for _, p := range []string{
176181 mrRepoPrefix(repo), commentPrefix + repo.String() + "/", readPrefix + repo.String() + "/",
177182 mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/",
183+ mergePrefix + repo.String() + "/",
178184 } {
179185 for k := range snap.Prefix(ctx, []byte(p)) {
180186 keys = append(keys, slices.Clone(k))
@@ -469,6 +475,9 @@ func (m *MergeRequests) Update(ctx context.Context, mr domain.MergeRequest) erro
469475 } else {
470476 indexOpen(&b, nil, mr)
471477 }
478+ if !mr.IsOpen() {
479+ b.Delete(mergeKey(mr.Repo, mr.Number))
480+ }
472481 if _, err := m.db.Apply(ctx, &b); err != nil {
473482 if errors.Is(err, kv.ErrConflict) {
474483 return domain.ErrConflict
@@ -579,6 +588,130 @@ func (m *MergeRequests) SetRead(ctx context.Context, repo domain.RepoName, numbe
579588 return fmt.Errorf("record read position of %d in %q: %w", number, repo, domain.ErrConflict)
580589 }
581590
591+// GetMergeability returns the stored mergeability, or domain.ErrNotFound.
592+func (m *MergeRequests) GetMergeability(ctx context.Context, repo domain.RepoName, number int64) (domain.Mergeability, error) {
593+ item, ok := m.db.Get(ctx, mergeKey(repo, number))
594+ if !ok {
595+ return domain.Mergeability{}, domain.ErrNotFound
596+ }
597+ return decodeMergeability(repo, number, item)
598+}
599+
600+// PutMergeability stores the mergeability in a batch that requires the merge
601+// request to be unchanged since it was read here and open, and the record to
602+// be absent (Version 0) or unchanged.
603+//
604+//hexcheck:ignore suspicious-business-logic-in-adapter version preconditions of a derived record: a storage concern, no policy
605+func (m *MergeRequests) PutMergeability(ctx context.Context, mg domain.Mergeability) error {
606+ val, err := encodeMergeability(mg)
607+ if err != nil {
608+ return err
609+ }
610+ for range createTries {
611+ mrItem, ok := m.db.Get(ctx, mrKey(mg.Repo, mg.Number))
612+ if !ok {
613+ return domain.ErrNotFound
614+ }
615+ mr, err := decodeMergeRequest(mg.Repo, mrItem)
616+ if err != nil {
617+ return err
618+ }
619+ if !mr.IsOpen() {
620+ return domain.ErrNotFound
621+ }
622+ key := mergeKey(mg.Repo, mg.Number)
623+ var b kv.Batch
624+ b.RequireVersion(mrKey(mg.Repo, mg.Number), mrItem.Version)
625+ if mg.Version == 0 {
626+ b.RequireAbsent(key)
627+ } else {
628+ b.RequireVersion(key, mg.Version)
629+ }
630+ b.Put(key, val)
631+ _, err = m.db.Apply(ctx, &b)
632+ if errors.Is(err, kv.ErrConflict) {
633+ // Either the record changed (the caller's conflict) or the merge
634+ // request did (retry against it).
635+ if cur, ok := m.db.Get(ctx, mrKey(mg.Repo, mg.Number)); ok && cur.Version != mrItem.Version {
636+ continue
637+ }
638+ return domain.ErrConflict
639+ }
640+ if err != nil {
641+ return fmt.Errorf("store mergeability of %d in %q: %w", mg.Number, mg.Repo, err)
642+ }
643+ return nil
644+ }
645+ return fmt.Errorf("store mergeability of %d in %q: %w", mg.Number, mg.Repo, domain.ErrConflict)
646+}
647+
648+// DeleteMergeability removes the stored mergeability; absent is fine.
649+func (m *MergeRequests) DeleteMergeability(ctx context.Context, repo domain.RepoName, number int64) error {
650+ var b kv.Batch
651+ b.Delete(mergeKey(repo, number))
652+ if _, err := m.db.Apply(ctx, &b); err != nil {
653+ return fmt.Errorf("delete mergeability of %d in %q: %w", number, repo, err)
654+ }
655+ return nil
656+}
657+
658+func encodeMergeability(m domain.Mergeability) ([]byte, error) {
659+ var state storagepb.MergeabilityState
660+ switch m.State {
661+ case domain.MergeabilityUpToDate:
662+ state = storagepb.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE
663+ case domain.MergeabilityBehind:
664+ state = storagepb.MergeabilityState_MERGEABILITY_STATE_BEHIND
665+ case domain.MergeabilityConflict:
666+ state = storagepb.MergeabilityState_MERGEABILITY_STATE_CONFLICT
667+ default:
668+ return nil, fmt.Errorf("%w: mergeability state %q is not stored", domain.ErrInvalid, m.State)
669+ }
670+ return proto.Marshal(&storagepb.Mergeability{
671+ State: state,
672+ TargetId: m.TargetID.String(),
673+ HeadId: m.HeadID.String(),
674+ Behind: int32(m.Behind), //nolint:gosec // capped at MaxBehindCount
675+ BehindCapped: m.BehindCapped,
676+ ConflictPaths: m.ConflictPaths,
677+ ConflictPathsTruncated: m.ConflictPathsTruncated,
678+ CheckedAt: timestamppb.New(m.CheckedAt),
679+ })
680+}
681+
682+func decodeMergeability(repo domain.RepoName, number int64, item kv.Item) (domain.Mergeability, error) {
683+ var pb storagepb.Mergeability
684+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
685+ return domain.Mergeability{}, fmt.Errorf("decode mergeability of %d in %q: %w", number, repo, err)
686+ }
687+ out := domain.Mergeability{
688+ Repo: repo, Number: number,
689+ Behind: int(pb.GetBehind()), BehindCapped: pb.GetBehindCapped(),
690+ ConflictPaths: pb.GetConflictPaths(),
691+ ConflictPathsTruncated: pb.GetConflictPathsTruncated(),
692+ CheckedAt: pb.GetCheckedAt().AsTime(),
693+ Version: item.Version,
694+ }
695+ switch pb.GetState() {
696+ case storagepb.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE:
697+ out.State = domain.MergeabilityUpToDate
698+ case storagepb.MergeabilityState_MERGEABILITY_STATE_BEHIND:
699+ out.State = domain.MergeabilityBehind
700+ case storagepb.MergeabilityState_MERGEABILITY_STATE_CONFLICT:
701+ out.State = domain.MergeabilityConflict
702+ default:
703+ return domain.Mergeability{}, fmt.Errorf("decode mergeability of %d in %q: unknown state %d: %w", number, repo, pb.GetState(), domain.ErrNotFound)
704+ }
705+ var err error
706+ if out.TargetID, err = domain.ParseObjectID(pb.GetTargetId()); err != nil {
707+ return domain.Mergeability{}, fmt.Errorf("decode mergeability of %d in %q: %w", number, repo, err)
708+ }
709+ if out.HeadID, err = domain.ParseObjectID(pb.GetHeadId()); err != nil {
710+ return domain.Mergeability{}, fmt.Errorf("decode mergeability of %d in %q: %w", number, repo, err)
711+ }
712+ return out, nil
713+}
714+
582715 func encodeMergeRequest(mr domain.MergeRequest) ([]byte, error) {
583716 if mr.Repo.IsZero() {
584717 return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
@@ -735,6 +868,8 @@ func encodeMode(m domain.MergeMode) (storagepb.MergeMode, error) {
735868 return storagepb.MergeMode_MERGE_MODE_FAST_FORWARD, nil
736869 case domain.MergeModeSquash:
737870 return storagepb.MergeMode_MERGE_MODE_SQUASH, nil
871+ case domain.MergeModeMerge:
872+ return storagepb.MergeMode_MERGE_MODE_MERGE, nil
738873 }
739874 return 0, fmt.Errorf("%w: merge mode %q", domain.ErrInvalid, m)
740875 }
@@ -746,6 +881,8 @@ func decodeMode(m storagepb.MergeMode) domain.MergeMode {
746881 return domain.MergeModeFastForward
747882 case storagepb.MergeMode_MERGE_MODE_SQUASH:
748883 return domain.MergeModeSquash
884+ case storagepb.MergeMode_MERGE_MODE_MERGE:
885+ return domain.MergeModeMerge
749886 }
750887 return ""
751888 }
@@ -773,7 +910,7 @@ func decodeSide(s storagepb.DiffSide) domain.DiffSide {
773910 }
774911
775912 func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) {
776- pb := &storagepb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock}
913+ pb := &storagepb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, MergeModesV2: true}
777914 for _, m := range s.AllowedMergeModes {
778915 mode, err := encodeMode(m)
779916 if err != nil || mode == storagepb.MergeMode_MERGE_MODE_UNSPECIFIED {
@@ -810,5 +947,10 @@ func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings {
810947 if err != nil {
811948 return domain.DefaultRepoSettings()
812949 }
950+ // Settings stored before merge commits: exactly {fast-forward, squash} was
951+ // the old default, "everything allowed".
952+ if !pb.GetMergeModesV2() && slices.Equal(normalized, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}) {
953+ normalized = domain.MergeModes()
954+ }
813955 return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock(), Issues: decodeIssuesMode(pb.GetIssues())}
814956 }
@@ -17,6 +17,7 @@ import (
17 kv "bnema.dev/ermite-kv"17 kv "bnema.dev/ermite-kv"
18 "github.com/bnema/zerowrap"18 "github.com/bnema/zerowrap"
19 "google.golang.org/protobuf/proto"19 "google.golang.org/protobuf/proto"
20+ "google.golang.org/protobuf/types/known/timestamppb"
20 21
21 storagepb "bnema.dev/ermite/gen/ermite/storage"22 storagepb "bnema.dev/ermite/gen/ermite/storage"
22 "bnema.dev/ermite/internal/domain"23 "bnema.dev/ermite/internal/domain"
@@ -27,10 +28,11 @@ import (
27 // ending in "/" never matches another repository; numbers and comment ids are28 // ending in "/" never matches another repository; numbers and comment ids are
28 // zero-padded so that keys sort numerically.29 // zero-padded so that keys sort numerically.
29 const (30 const (
30- mrPrefix = "mr/" // mr/<repo>/<number:010d>31+ mrPrefix = "mr/" // mr/<repo>/<number:010d>
31- commentPrefix = "mrc/" // mrc/<repo>/<number:010d>/<id:010d>32+ commentPrefix = "mrc/" // mrc/<repo>/<number:010d>/<id:010d>
32- readPrefix = "mrread/" // mrread/<repo>/<number:010d>33+ readPrefix = "mrread/" // mrread/<repo>/<number:010d>
33- seqPrefix = "mrseq/" // mrseq/<repo>34+ seqPrefix = "mrseq/" // mrseq/<repo>
35+ mergePrefix = "mrmerge/" // mrmerge/<repo>/<number:010d>
34 // The indexes of the open merge requests by source branch and by target36 // The indexes of the open merge requests by source branch and by target
35 // branch, which a push looks up (the values are not read). They are written in37 // branch, which a push looks up (the values are not read). They are written in
36 // the same batch as the merge request, so they are never out of step with it,38 // the same batch as the merge request, so they are never out of step with it,
@@ -69,6 +71,9 @@ func commentKey(repo domain.RepoName, n, id int64) []byte {
69 func readKey(repo domain.RepoName, n int64) []byte {71 func readKey(repo domain.RepoName, n int64) []byte {
70 return []byte(readPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, n))72 return []byte(readPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, n))
71 }73 }
74+func mergeKey(repo domain.RepoName, n int64) []byte {
75+ return []byte(mergePrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, n))
76+}
72 func seqKey(repo domain.RepoName) []byte { return []byte(seqPrefix + repo.String()) }77 func seqKey(repo domain.RepoName) []byte { return []byte(seqPrefix + repo.String()) }
73 78
74 // mrOpenRepoPrefix is where the open merge requests of a repository are indexed79 // mrOpenRepoPrefix is where the open merge requests of a repository are indexed
@@ -175,6 +180,7 @@ func mergeRequestKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoNa
175 for _, p := range []string{180 for _, p := range []string{
176 mrRepoPrefix(repo), commentPrefix + repo.String() + "/", readPrefix + repo.String() + "/",181 mrRepoPrefix(repo), commentPrefix + repo.String() + "/", readPrefix + repo.String() + "/",
177 mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/",182 mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/",
183+ mergePrefix + repo.String() + "/",
178 } {184 } {
179 for k := range snap.Prefix(ctx, []byte(p)) {185 for k := range snap.Prefix(ctx, []byte(p)) {
180 keys = append(keys, slices.Clone(k))186 keys = append(keys, slices.Clone(k))
@@ -469,6 +475,9 @@ func (m *MergeRequests) Update(ctx context.Context, mr domain.MergeRequest) erro
469 } else {475 } else {
470 indexOpen(&b, nil, mr)476 indexOpen(&b, nil, mr)
471 }477 }
478+ if !mr.IsOpen() {
479+ b.Delete(mergeKey(mr.Repo, mr.Number))
480+ }
472 if _, err := m.db.Apply(ctx, &b); err != nil {481 if _, err := m.db.Apply(ctx, &b); err != nil {
473 if errors.Is(err, kv.ErrConflict) {482 if errors.Is(err, kv.ErrConflict) {
474 return domain.ErrConflict483 return domain.ErrConflict
@@ -579,6 +588,130 @@ func (m *MergeRequests) SetRead(ctx context.Context, repo domain.RepoName, numbe
579 return fmt.Errorf("record read position of %d in %q: %w", number, repo, domain.ErrConflict)588 return fmt.Errorf("record read position of %d in %q: %w", number, repo, domain.ErrConflict)
580 }589 }
581 590
591+// GetMergeability returns the stored mergeability, or domain.ErrNotFound.
592+func (m *MergeRequests) GetMergeability(ctx context.Context, repo domain.RepoName, number int64) (domain.Mergeability, error) {
593+ item, ok := m.db.Get(ctx, mergeKey(repo, number))
594+ if !ok {
595+ return domain.Mergeability{}, domain.ErrNotFound
596+ }
597+ return decodeMergeability(repo, number, item)
598+}
599+
600+// PutMergeability stores the mergeability in a batch that requires the merge
601+// request to be unchanged since it was read here and open, and the record to
602+// be absent (Version 0) or unchanged.
603+//
604+//hexcheck:ignore suspicious-business-logic-in-adapter version preconditions of a derived record: a storage concern, no policy
605+func (m *MergeRequests) PutMergeability(ctx context.Context, mg domain.Mergeability) error {
606+ val, err := encodeMergeability(mg)
607+ if err != nil {
608+ return err
609+ }
610+ for range createTries {
611+ mrItem, ok := m.db.Get(ctx, mrKey(mg.Repo, mg.Number))
612+ if !ok {
613+ return domain.ErrNotFound
614+ }
615+ mr, err := decodeMergeRequest(mg.Repo, mrItem)
616+ if err != nil {
617+ return err
618+ }
619+ if !mr.IsOpen() {
620+ return domain.ErrNotFound
621+ }
622+ key := mergeKey(mg.Repo, mg.Number)
623+ var b kv.Batch
624+ b.RequireVersion(mrKey(mg.Repo, mg.Number), mrItem.Version)
625+ if mg.Version == 0 {
626+ b.RequireAbsent(key)
627+ } else {
628+ b.RequireVersion(key, mg.Version)
629+ }
630+ b.Put(key, val)
631+ _, err = m.db.Apply(ctx, &b)
632+ if errors.Is(err, kv.ErrConflict) {
633+ // Either the record changed (the caller's conflict) or the merge
634+ // request did (retry against it).
635+ if cur, ok := m.db.Get(ctx, mrKey(mg.Repo, mg.Number)); ok && cur.Version != mrItem.Version {
636+ continue
637+ }
638+ return domain.ErrConflict
639+ }
640+ if err != nil {
641+ return fmt.Errorf("store mergeability of %d in %q: %w", mg.Number, mg.Repo, err)
642+ }
643+ return nil
644+ }
645+ return fmt.Errorf("store mergeability of %d in %q: %w", mg.Number, mg.Repo, domain.ErrConflict)
646+}
647+
648+// DeleteMergeability removes the stored mergeability; absent is fine.
649+func (m *MergeRequests) DeleteMergeability(ctx context.Context, repo domain.RepoName, number int64) error {
650+ var b kv.Batch
651+ b.Delete(mergeKey(repo, number))
652+ if _, err := m.db.Apply(ctx, &b); err != nil {
653+ return fmt.Errorf("delete mergeability of %d in %q: %w", number, repo, err)
654+ }
655+ return nil
656+}
657+
658+func encodeMergeability(m domain.Mergeability) ([]byte, error) {
659+ var state storagepb.MergeabilityState
660+ switch m.State {
661+ case domain.MergeabilityUpToDate:
662+ state = storagepb.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE
663+ case domain.MergeabilityBehind:
664+ state = storagepb.MergeabilityState_MERGEABILITY_STATE_BEHIND
665+ case domain.MergeabilityConflict:
666+ state = storagepb.MergeabilityState_MERGEABILITY_STATE_CONFLICT
667+ default:
668+ return nil, fmt.Errorf("%w: mergeability state %q is not stored", domain.ErrInvalid, m.State)
669+ }
670+ return proto.Marshal(&storagepb.Mergeability{
671+ State: state,
672+ TargetId: m.TargetID.String(),
673+ HeadId: m.HeadID.String(),
674+ Behind: int32(m.Behind), //nolint:gosec // capped at MaxBehindCount
675+ BehindCapped: m.BehindCapped,
676+ ConflictPaths: m.ConflictPaths,
677+ ConflictPathsTruncated: m.ConflictPathsTruncated,
678+ CheckedAt: timestamppb.New(m.CheckedAt),
679+ })
680+}
681+
682+func decodeMergeability(repo domain.RepoName, number int64, item kv.Item) (domain.Mergeability, error) {
683+ var pb storagepb.Mergeability
684+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
685+ return domain.Mergeability{}, fmt.Errorf("decode mergeability of %d in %q: %w", number, repo, err)
686+ }
687+ out := domain.Mergeability{
688+ Repo: repo, Number: number,
689+ Behind: int(pb.GetBehind()), BehindCapped: pb.GetBehindCapped(),
690+ ConflictPaths: pb.GetConflictPaths(),
691+ ConflictPathsTruncated: pb.GetConflictPathsTruncated(),
692+ CheckedAt: pb.GetCheckedAt().AsTime(),
693+ Version: item.Version,
694+ }
695+ switch pb.GetState() {
696+ case storagepb.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE:
697+ out.State = domain.MergeabilityUpToDate
698+ case storagepb.MergeabilityState_MERGEABILITY_STATE_BEHIND:
699+ out.State = domain.MergeabilityBehind
700+ case storagepb.MergeabilityState_MERGEABILITY_STATE_CONFLICT:
701+ out.State = domain.MergeabilityConflict
702+ default:
703+ return domain.Mergeability{}, fmt.Errorf("decode mergeability of %d in %q: unknown state %d: %w", number, repo, pb.GetState(), domain.ErrNotFound)
704+ }
705+ var err error
706+ if out.TargetID, err = domain.ParseObjectID(pb.GetTargetId()); err != nil {
707+ return domain.Mergeability{}, fmt.Errorf("decode mergeability of %d in %q: %w", number, repo, err)
708+ }
709+ if out.HeadID, err = domain.ParseObjectID(pb.GetHeadId()); err != nil {
710+ return domain.Mergeability{}, fmt.Errorf("decode mergeability of %d in %q: %w", number, repo, err)
711+ }
712+ return out, nil
713+}
714+
582 func encodeMergeRequest(mr domain.MergeRequest) ([]byte, error) {715 func encodeMergeRequest(mr domain.MergeRequest) ([]byte, error) {
583 if mr.Repo.IsZero() {716 if mr.Repo.IsZero() {
584 return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)717 return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
@@ -735,6 +868,8 @@ func encodeMode(m domain.MergeMode) (storagepb.MergeMode, error) {
735 return storagepb.MergeMode_MERGE_MODE_FAST_FORWARD, nil868 return storagepb.MergeMode_MERGE_MODE_FAST_FORWARD, nil
736 case domain.MergeModeSquash:869 case domain.MergeModeSquash:
737 return storagepb.MergeMode_MERGE_MODE_SQUASH, nil870 return storagepb.MergeMode_MERGE_MODE_SQUASH, nil
871+ case domain.MergeModeMerge:
872+ return storagepb.MergeMode_MERGE_MODE_MERGE, nil
738 }873 }
739 return 0, fmt.Errorf("%w: merge mode %q", domain.ErrInvalid, m)874 return 0, fmt.Errorf("%w: merge mode %q", domain.ErrInvalid, m)
740 }875 }
@@ -746,6 +881,8 @@ func decodeMode(m storagepb.MergeMode) domain.MergeMode {
746 return domain.MergeModeFastForward881 return domain.MergeModeFastForward
747 case storagepb.MergeMode_MERGE_MODE_SQUASH:882 case storagepb.MergeMode_MERGE_MODE_SQUASH:
748 return domain.MergeModeSquash883 return domain.MergeModeSquash
884+ case storagepb.MergeMode_MERGE_MODE_MERGE:
885+ return domain.MergeModeMerge
749 }886 }
750 return ""887 return ""
751 }888 }
@@ -773,7 +910,7 @@ func decodeSide(s storagepb.DiffSide) domain.DiffSide {
773 }910 }
774 911
775 func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) {912 func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) {
776- pb := &storagepb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock}913+ pb := &storagepb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, MergeModesV2: true}
777 for _, m := range s.AllowedMergeModes {914 for _, m := range s.AllowedMergeModes {
778 mode, err := encodeMode(m)915 mode, err := encodeMode(m)
779 if err != nil || mode == storagepb.MergeMode_MERGE_MODE_UNSPECIFIED {916 if err != nil || mode == storagepb.MergeMode_MERGE_MODE_UNSPECIFIED {
@@ -810,5 +947,10 @@ func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings {
810 if err != nil {947 if err != nil {
811 return domain.DefaultRepoSettings()948 return domain.DefaultRepoSettings()
812 }949 }
950+ // Settings stored before merge commits: exactly {fast-forward, squash} was
951+ // the old default, "everything allowed".
952+ if !pb.GetMergeModesV2() && slices.Equal(normalized, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}) {
953+ normalized = domain.MergeModes()
954+ }
813 return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock(), Issues: decodeIssuesMode(pb.GetIssues())}955 return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock(), Issues: decodeIssuesMode(pb.GetIssues())}
814 }956 }
M internal/adapters/kvstore/mergerequests_test.go +117 -0
@@ -574,3 +574,120 @@ func TestNoteFieldsRoundTrip(t *testing.T) {
574574 assert.Equal(t, c, got)
575575 }
576576 }
577+
578+// TestStoredOldDefaultModesGainMerge: settings written before merge commits
579+// (no merge_modes_v2) with exactly {fast-forward, squash} read with merge too;
580+// the same set written by this build is an explicit choice and stays.
581+func TestStoredOldDefaultModesGainMerge(t *testing.T) {
582+ ctx := context.Background()
583+ dir := t.TempDir()
584+ s, err := kvstore.Open(ctx, dir)
585+ require.NoError(t, err)
586+ require.NoError(t, s.Close(ctx))
587+
588+ put := func(db *kv.DB, repo string, modes ...storagepb.MergeMode) {
589+ val, err := proto.Marshal(&storagepb.Repo{Name: repo, Visibility: storagepb.Visibility_VISIBILITY_PUBLIC,
590+ Settings: &storagepb.RepoSettings{AllowedMergeModes: modes, FailingChecksBlock: true}})
591+ require.NoError(t, err)
592+ var b kv.Batch
593+ b.Put([]byte("repo/"+repo), val)
594+ _, err = db.Apply(ctx, &b)
595+ require.NoError(t, err)
596+ }
597+ db, err := kv.Open(ctx, dir, kv.Options{})
598+ require.NoError(t, err)
599+ put(db, "old", storagepb.MergeMode_MERGE_MODE_FAST_FORWARD, storagepb.MergeMode_MERGE_MODE_SQUASH)
600+ put(db, "single", storagepb.MergeMode_MERGE_MODE_SQUASH)
601+ require.NoError(t, db.Close(ctx))
602+
603+ s = openStore(t, dir)
604+ old, err := s.Get(ctx, name(t, "old"))
605+ require.NoError(t, err)
606+ assert.Equal(t, domain.MergeModes(), old.Settings.AllowedMergeModes)
607+ single, err := s.Get(ctx, name(t, "single"))
608+ require.NoError(t, err)
609+ assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, single.Settings.AllowedMergeModes)
610+
611+ // An explicit {ff, squash} written now is kept.
612+ old.Settings.AllowedMergeModes = []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}
613+ require.NoError(t, s.Update(ctx, old))
614+ again, err := s.Get(ctx, name(t, "old"))
615+ require.NoError(t, err)
616+ assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, again.Settings.AllowedMergeModes)
617+}
618+
619+func mergeability(mr domain.MergeRequest) domain.Mergeability {
620+ return domain.Mergeability{
621+ Repo: mr.Repo, Number: mr.Number, State: domain.MergeabilityConflict,
622+ TargetID: oid('a'), HeadID: oid('b'), Behind: 3, BehindCapped: true,
623+ ConflictPaths: []string{"a.txt", "b/c.txt"}, ConflictPathsTruncated: true, CheckedAt: created,
624+ }
625+}
626+
627+func TestPutMergeabilityCAS(t *testing.T) {
628+ ctx := context.Background()
629+ _, mrs, n := repoWithMRs(t)
630+ mr, err := mrs.Create(ctx, newMR(t, n, "t"))
631+ require.NoError(t, err)
632+
633+ _, err = mrs.GetMergeability(ctx, n, mr.Number)
634+ assert.ErrorIs(t, err, domain.ErrNotFound)
635+
636+ m := mergeability(mr)
637+ require.NoError(t, mrs.PutMergeability(ctx, m))
638+ assert.ErrorIs(t, mrs.PutMergeability(ctx, m), domain.ErrConflict, "version 0 needs it absent")
639+
640+ got, err := mrs.GetMergeability(ctx, n, mr.Number)
641+ require.NoError(t, err)
642+ assert.NotZero(t, got.Version)
643+ m.Version = got.Version
644+ assert.Equal(t, m, got)
645+
646+ next := got
647+ next.State = domain.MergeabilityUpToDate
648+ require.NoError(t, mrs.PutMergeability(ctx, next))
649+ assert.ErrorIs(t, mrs.PutMergeability(ctx, next), domain.ErrConflict, "stale version")
650+
651+ require.NoError(t, mrs.DeleteMergeability(ctx, n, mr.Number))
652+ require.NoError(t, mrs.DeleteMergeability(ctx, n, mr.Number), "absent is fine")
653+ _, err = mrs.GetMergeability(ctx, n, mr.Number)
654+ assert.ErrorIs(t, err, domain.ErrNotFound)
655+
656+ missing := mergeability(mr)
657+ missing.Number = 99
658+ assert.ErrorIs(t, mrs.PutMergeability(ctx, missing), domain.ErrNotFound)
659+
660+ checking := mergeability(mr)
661+ checking.State = domain.MergeabilityChecking
662+ assert.ErrorIs(t, mrs.PutMergeability(ctx, checking), domain.ErrInvalid)
663+}
664+
665+func TestMergeabilityRefusedAndDroppedWhenNotOpen(t *testing.T) {
666+ for _, state := range []domain.MRState{domain.MRStateMerged, domain.MRStateClosed} {
667+ ctx := context.Background()
668+ _, mrs, n := repoWithMRs(t)
669+ mr, err := mrs.Create(ctx, newMR(t, n, "t"))
670+ require.NoError(t, err)
671+ require.NoError(t, mrs.PutMergeability(ctx, mergeability(mr)))
672+
673+ cur, err := mrs.Get(ctx, n, mr.Number)
674+ require.NoError(t, err)
675+ cur.State = state
676+ require.NoError(t, mrs.Update(ctx, cur))
677+ _, err = mrs.GetMergeability(ctx, n, mr.Number)
678+ assert.ErrorIs(t, err, domain.ErrNotFound, state)
679+
680+ assert.ErrorIs(t, mrs.PutMergeability(ctx, mergeability(mr)), domain.ErrNotFound, "a late worker never resurrects it: %s", state)
681+ }
682+}
683+
684+func TestDeleteWipesMergeability(t *testing.T) {
685+ ctx := context.Background()
686+ s, mrs, n := repoWithMRs(t)
687+ mr, err := mrs.Create(ctx, newMR(t, n, "t"))
688+ require.NoError(t, err)
689+ require.NoError(t, mrs.PutMergeability(ctx, mergeability(mr)))
690+ require.NoError(t, s.Delete(ctx, n))
691+ _, err = mrs.GetMergeability(ctx, n, mr.Number)
692+ assert.ErrorIs(t, err, domain.ErrNotFound)
693+}
@@ -574,3 +574,120 @@ func TestNoteFieldsRoundTrip(t *testing.T) {
574 assert.Equal(t, c, got)574 assert.Equal(t, c, got)
575 }575 }
576 }576 }
577+
578+// TestStoredOldDefaultModesGainMerge: settings written before merge commits
579+// (no merge_modes_v2) with exactly {fast-forward, squash} read with merge too;
580+// the same set written by this build is an explicit choice and stays.
581+func TestStoredOldDefaultModesGainMerge(t *testing.T) {
582+ ctx := context.Background()
583+ dir := t.TempDir()
584+ s, err := kvstore.Open(ctx, dir)
585+ require.NoError(t, err)
586+ require.NoError(t, s.Close(ctx))
587+
588+ put := func(db *kv.DB, repo string, modes ...storagepb.MergeMode) {
589+ val, err := proto.Marshal(&storagepb.Repo{Name: repo, Visibility: storagepb.Visibility_VISIBILITY_PUBLIC,
590+ Settings: &storagepb.RepoSettings{AllowedMergeModes: modes, FailingChecksBlock: true}})
591+ require.NoError(t, err)
592+ var b kv.Batch
593+ b.Put([]byte("repo/"+repo), val)
594+ _, err = db.Apply(ctx, &b)
595+ require.NoError(t, err)
596+ }
597+ db, err := kv.Open(ctx, dir, kv.Options{})
598+ require.NoError(t, err)
599+ put(db, "old", storagepb.MergeMode_MERGE_MODE_FAST_FORWARD, storagepb.MergeMode_MERGE_MODE_SQUASH)
600+ put(db, "single", storagepb.MergeMode_MERGE_MODE_SQUASH)
601+ require.NoError(t, db.Close(ctx))
602+
603+ s = openStore(t, dir)
604+ old, err := s.Get(ctx, name(t, "old"))
605+ require.NoError(t, err)
606+ assert.Equal(t, domain.MergeModes(), old.Settings.AllowedMergeModes)
607+ single, err := s.Get(ctx, name(t, "single"))
608+ require.NoError(t, err)
609+ assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, single.Settings.AllowedMergeModes)
610+
611+ // An explicit {ff, squash} written now is kept.
612+ old.Settings.AllowedMergeModes = []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}
613+ require.NoError(t, s.Update(ctx, old))
614+ again, err := s.Get(ctx, name(t, "old"))
615+ require.NoError(t, err)
616+ assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, again.Settings.AllowedMergeModes)
617+}
618+
619+func mergeability(mr domain.MergeRequest) domain.Mergeability {
620+ return domain.Mergeability{
621+ Repo: mr.Repo, Number: mr.Number, State: domain.MergeabilityConflict,
622+ TargetID: oid('a'), HeadID: oid('b'), Behind: 3, BehindCapped: true,
623+ ConflictPaths: []string{"a.txt", "b/c.txt"}, ConflictPathsTruncated: true, CheckedAt: created,
624+ }
625+}
626+
627+func TestPutMergeabilityCAS(t *testing.T) {
628+ ctx := context.Background()
629+ _, mrs, n := repoWithMRs(t)
630+ mr, err := mrs.Create(ctx, newMR(t, n, "t"))
631+ require.NoError(t, err)
632+
633+ _, err = mrs.GetMergeability(ctx, n, mr.Number)
634+ assert.ErrorIs(t, err, domain.ErrNotFound)
635+
636+ m := mergeability(mr)
637+ require.NoError(t, mrs.PutMergeability(ctx, m))
638+ assert.ErrorIs(t, mrs.PutMergeability(ctx, m), domain.ErrConflict, "version 0 needs it absent")
639+
640+ got, err := mrs.GetMergeability(ctx, n, mr.Number)
641+ require.NoError(t, err)
642+ assert.NotZero(t, got.Version)
643+ m.Version = got.Version
644+ assert.Equal(t, m, got)
645+
646+ next := got
647+ next.State = domain.MergeabilityUpToDate
648+ require.NoError(t, mrs.PutMergeability(ctx, next))
649+ assert.ErrorIs(t, mrs.PutMergeability(ctx, next), domain.ErrConflict, "stale version")
650+
651+ require.NoError(t, mrs.DeleteMergeability(ctx, n, mr.Number))
652+ require.NoError(t, mrs.DeleteMergeability(ctx, n, mr.Number), "absent is fine")
653+ _, err = mrs.GetMergeability(ctx, n, mr.Number)
654+ assert.ErrorIs(t, err, domain.ErrNotFound)
655+
656+ missing := mergeability(mr)
657+ missing.Number = 99
658+ assert.ErrorIs(t, mrs.PutMergeability(ctx, missing), domain.ErrNotFound)
659+
660+ checking := mergeability(mr)
661+ checking.State = domain.MergeabilityChecking
662+ assert.ErrorIs(t, mrs.PutMergeability(ctx, checking), domain.ErrInvalid)
663+}
664+
665+func TestMergeabilityRefusedAndDroppedWhenNotOpen(t *testing.T) {
666+ for _, state := range []domain.MRState{domain.MRStateMerged, domain.MRStateClosed} {
667+ ctx := context.Background()
668+ _, mrs, n := repoWithMRs(t)
669+ mr, err := mrs.Create(ctx, newMR(t, n, "t"))
670+ require.NoError(t, err)
671+ require.NoError(t, mrs.PutMergeability(ctx, mergeability(mr)))
672+
673+ cur, err := mrs.Get(ctx, n, mr.Number)
674+ require.NoError(t, err)
675+ cur.State = state
676+ require.NoError(t, mrs.Update(ctx, cur))
677+ _, err = mrs.GetMergeability(ctx, n, mr.Number)
678+ assert.ErrorIs(t, err, domain.ErrNotFound, state)
679+
680+ assert.ErrorIs(t, mrs.PutMergeability(ctx, mergeability(mr)), domain.ErrNotFound, "a late worker never resurrects it: %s", state)
681+ }
682+}
683+
684+func TestDeleteWipesMergeability(t *testing.T) {
685+ ctx := context.Background()
686+ s, mrs, n := repoWithMRs(t)
687+ mr, err := mrs.Create(ctx, newMR(t, n, "t"))
688+ require.NoError(t, err)
689+ require.NoError(t, mrs.PutMergeability(ctx, mergeability(mr)))
690+ require.NoError(t, s.Delete(ctx, n))
691+ _, err = mrs.GetMergeability(ctx, n, mr.Number)
692+ assert.ErrorIs(t, err, domain.ErrNotFound)
693+}
M internal/adapters/rpc/convert.go +4 -0
@@ -88,6 +88,8 @@ func mergeModeToPB(m domain.MergeMode) pb.MergeMode {
8888 return pb.MergeMode_MERGE_MODE_FAST_FORWARD
8989 case domain.MergeModeSquash:
9090 return pb.MergeMode_MERGE_MODE_SQUASH
91+ case domain.MergeModeMerge:
92+ return pb.MergeMode_MERGE_MODE_MERGE_COMMIT
9193 }
9294 return pb.MergeMode_MERGE_MODE_UNSPECIFIED
9395 }
@@ -102,6 +104,8 @@ func mergeModeFromPB(m pb.MergeMode) (domain.MergeMode, error) {
102104 return domain.MergeModeFastForward, nil
103105 case pb.MergeMode_MERGE_MODE_SQUASH:
104106 return domain.MergeModeSquash, nil
107+ case pb.MergeMode_MERGE_MODE_MERGE_COMMIT:
108+ return domain.MergeModeMerge, nil
105109 }
106110 return "", fmt.Errorf("%w: merge mode %v", domain.ErrInvalid, m)
107111 }
@@ -88,6 +88,8 @@ func mergeModeToPB(m domain.MergeMode) pb.MergeMode {
88 return pb.MergeMode_MERGE_MODE_FAST_FORWARD88 return pb.MergeMode_MERGE_MODE_FAST_FORWARD
89 case domain.MergeModeSquash:89 case domain.MergeModeSquash:
90 return pb.MergeMode_MERGE_MODE_SQUASH90 return pb.MergeMode_MERGE_MODE_SQUASH
91+ case domain.MergeModeMerge:
92+ return pb.MergeMode_MERGE_MODE_MERGE_COMMIT
91 }93 }
92 return pb.MergeMode_MERGE_MODE_UNSPECIFIED94 return pb.MergeMode_MERGE_MODE_UNSPECIFIED
93 }95 }
@@ -102,6 +104,8 @@ func mergeModeFromPB(m pb.MergeMode) (domain.MergeMode, error) {
102 return domain.MergeModeFastForward, nil104 return domain.MergeModeFastForward, nil
103 case pb.MergeMode_MERGE_MODE_SQUASH:105 case pb.MergeMode_MERGE_MODE_SQUASH:
104 return domain.MergeModeSquash, nil106 return domain.MergeModeSquash, nil
107+ case pb.MergeMode_MERGE_MODE_MERGE_COMMIT:
108+ return domain.MergeModeMerge, nil
105 }109 }
106 return "", fmt.Errorf("%w: merge mode %v", domain.ErrInvalid, m)110 return "", fmt.Errorf("%w: merge mode %v", domain.ErrInvalid, m)
107 }111 }
M internal/adapters/rpc/convert_test.go +1 -1
@@ -213,7 +213,7 @@ func TestRepoToPBAlwaysHasSettings(t *testing.T) {
213213 t.Parallel()
214214 // A repository value without settings (never stored with them) reads as the defaults.
215215 r := repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustName(t, "x")}})
216- assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, r.GetSettings().GetAllowedMergeModes())
216+ assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_MERGE_COMMIT}, r.GetSettings().GetAllowedMergeModes())
217217 assert.True(t, r.GetSettings().GetFailingChecksBlock())
218218
219219 custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}}
@@ -213,7 +213,7 @@ func TestRepoToPBAlwaysHasSettings(t *testing.T) {
213 t.Parallel()213 t.Parallel()
214 // A repository value without settings (never stored with them) reads as the defaults.214 // A repository value without settings (never stored with them) reads as the defaults.
215 r := repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustName(t, "x")}})215 r := repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustName(t, "x")}})
216- assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, r.GetSettings().GetAllowedMergeModes())216+ assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_MERGE_COMMIT}, r.GetSettings().GetAllowedMergeModes())
217 assert.True(t, r.GetSettings().GetFailingChecksBlock())217 assert.True(t, r.GetSettings().GetFailingChecksBlock())
218 218
219 custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}}219 custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}}
M internal/app/cli_mr.go +6 -1
@@ -31,6 +31,7 @@ const (
3131
3232 modeFastForward = "fast-forward"
3333 modeSquash = "squash"
34+ modeMerge = "merge"
3435 )
3536
3637 func stateName(s ermite.MergeRequestState) string {
@@ -65,6 +66,8 @@ func modeName(m ermite.MergeMode) string {
6566 return modeFastForward
6667 case ermite.MergeMode_MERGE_MODE_SQUASH:
6768 return modeSquash
69+ case ermite.MergeMode_MERGE_MODE_MERGE_COMMIT:
70+ return modeMerge
6871 }
6972 return ""
7073 }
@@ -75,8 +78,10 @@ func parseMode(s string) (ermite.MergeMode, error) {
7578 return ermite.MergeMode_MERGE_MODE_FAST_FORWARD, nil
7679 case modeSquash:
7780 return ermite.MergeMode_MERGE_MODE_SQUASH, nil
81+ case modeMerge:
82+ return ermite.MergeMode_MERGE_MODE_MERGE_COMMIT, nil
7883 }
79- return 0, fmt.Errorf("invalid merge mode %q: use %s or %s", s, modeFastForward, modeSquash)
84+ return 0, fmt.Errorf("invalid merge mode %q: use %s, %s or %s", s, modeFastForward, modeSquash, modeMerge)
8085 }
8186
8287 func sideName(s ermite.DiffSide) string {
@@ -31,6 +31,7 @@ const (
31 31
32 modeFastForward = "fast-forward"32 modeFastForward = "fast-forward"
33 modeSquash = "squash"33 modeSquash = "squash"
34+ modeMerge = "merge"
34 )35 )
35 36
36 func stateName(s ermite.MergeRequestState) string {37 func stateName(s ermite.MergeRequestState) string {
@@ -65,6 +66,8 @@ func modeName(m ermite.MergeMode) string {
65 return modeFastForward66 return modeFastForward
66 case ermite.MergeMode_MERGE_MODE_SQUASH:67 case ermite.MergeMode_MERGE_MODE_SQUASH:
67 return modeSquash68 return modeSquash
69+ case ermite.MergeMode_MERGE_MODE_MERGE_COMMIT:
70+ return modeMerge
68 }71 }
69 return ""72 return ""
70 }73 }
@@ -75,8 +78,10 @@ func parseMode(s string) (ermite.MergeMode, error) {
75 return ermite.MergeMode_MERGE_MODE_FAST_FORWARD, nil78 return ermite.MergeMode_MERGE_MODE_FAST_FORWARD, nil
76 case modeSquash:79 case modeSquash:
77 return ermite.MergeMode_MERGE_MODE_SQUASH, nil80 return ermite.MergeMode_MERGE_MODE_SQUASH, nil
81+ case modeMerge:
82+ return ermite.MergeMode_MERGE_MODE_MERGE_COMMIT, nil
78 }83 }
79- return 0, fmt.Errorf("invalid merge mode %q: use %s or %s", s, modeFastForward, modeSquash)84+ return 0, fmt.Errorf("invalid merge mode %q: use %s, %s or %s", s, modeFastForward, modeSquash, modeMerge)
80 }85 }
81 86
82 func sideName(s ermite.DiffSide) string {87 func sideName(s ermite.DiffSide) string {
M internal/app/cli_mr_e2e_test.go +1 -1
@@ -381,7 +381,7 @@ func TestRepoEditMergeSettingsCLI(t *testing.T) {
381381 MergeModes []string `json:"merge_modes"`
382382 FailingChecksBlock bool `json:"failing_checks_block"`
383383 }](t, res.stdout)
384- assert.Equal(t, []string{"fast-forward", "squash"}, repo.MergeModes)
384+ assert.Equal(t, []string{"fast-forward", "squash", "merge"}, repo.MergeModes)
385385 assert.True(t, repo.FailingChecksBlock)
386386
387387 res = c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "squash", "--json").ok(t)
@@ -381,7 +381,7 @@ func TestRepoEditMergeSettingsCLI(t *testing.T) {
381 MergeModes []string `json:"merge_modes"`381 MergeModes []string `json:"merge_modes"`
382 FailingChecksBlock bool `json:"failing_checks_block"`382 FailingChecksBlock bool `json:"failing_checks_block"`
383 }](t, res.stdout)383 }](t, res.stdout)
384- assert.Equal(t, []string{"fast-forward", "squash"}, repo.MergeModes)384+ assert.Equal(t, []string{"fast-forward", "squash", "merge"}, repo.MergeModes)
385 assert.True(t, repo.FailingChecksBlock)385 assert.True(t, repo.FailingChecksBlock)
386 386
387 res = c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "squash", "--json").ok(t)387 res = c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "squash", "--json").ok(t)
M internal/app/mr_api_test.go +1 -1
@@ -298,7 +298,7 @@ func TestUpdateRepoSettingsOverTheAPI(t *testing.T) {
298298
299299 got, err := h.api("").repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"}))
300300 require.NoError(t, err)
301- assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, got.Msg.GetRepo().GetSettings().GetAllowedMergeModes())
301+ assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_MERGE_COMMIT}, got.Msg.GetRepo().GetSettings().GetAllowedMergeModes())
302302 assert.True(t, got.Msg.GetRepo().GetSettings().GetFailingChecksBlock())
303303
304304 update := func(c *clients, paths []string, s *pb.RepoSettings) (*pb.Repo, error) {
@@ -298,7 +298,7 @@ func TestUpdateRepoSettingsOverTheAPI(t *testing.T) {
298 298
299 got, err := h.api("").repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"}))299 got, err := h.api("").repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"}))
300 require.NoError(t, err)300 require.NoError(t, err)
301- assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, got.Msg.GetRepo().GetSettings().GetAllowedMergeModes())301+ assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_MERGE_COMMIT}, got.Msg.GetRepo().GetSettings().GetAllowedMergeModes())
302 assert.True(t, got.Msg.GetRepo().GetSettings().GetFailingChecksBlock())302 assert.True(t, got.Msg.GetRepo().GetSettings().GetFailingChecksBlock())
303 303
304 update := func(c *clients, paths []string, s *pb.RepoSettings) (*pb.Repo, error) {304 update := func(c *clients, paths []string, s *pb.RepoSettings) (*pb.Repo, error) {
M internal/domain/event.go +14 -0
@@ -284,6 +284,20 @@ type CommentResolved struct {
284284 // EventKind implements Event.
285285 func (CommentResolved) EventKind() string { return "mr.comment_resolved" }
286286
287+// MergeRequestMergeability is published when the mergeability of an open merge
288+// request changed.
289+type MergeRequestMergeability struct {
290+ Repo RepoName
291+ Number int64
292+ Mergeability Mergeability
293+ At time.Time
294+ Visibility Visibility
295+ CreatedAt time.Time
296+}
297+
298+// EventKind implements Event.
299+func (MergeRequestMergeability) EventKind() string { return "mr.mergeability" }
300+
287301 // EventGap tells a subscriber that it missed events because it was too slow;
288302 // it must refresh its whole view.
289303 type EventGap struct{}
@@ -284,6 +284,20 @@ type CommentResolved struct {
284 // EventKind implements Event.284 // EventKind implements Event.
285 func (CommentResolved) EventKind() string { return "mr.comment_resolved" }285 func (CommentResolved) EventKind() string { return "mr.comment_resolved" }
286 286
287+// MergeRequestMergeability is published when the mergeability of an open merge
288+// request changed.
289+type MergeRequestMergeability struct {
290+ Repo RepoName
291+ Number int64
292+ Mergeability Mergeability
293+ At time.Time
294+ Visibility Visibility
295+ CreatedAt time.Time
296+}
297+
298+// EventKind implements Event.
299+func (MergeRequestMergeability) EventKind() string { return "mr.mergeability" }
300+
287 // EventGap tells a subscriber that it missed events because it was too slow;301 // EventGap tells a subscriber that it missed events because it was too slow;
288 // it must refresh its whole view.302 // it must refresh its whole view.
289 type EventGap struct{}303 type EventGap struct{}
M internal/domain/mergerequest.go +121 -16
@@ -18,18 +18,23 @@ const (
1818 MergeModeFastForward MergeMode = "fast-forward"
1919 // MergeModeSquash makes one new signed commit with the tree of the head.
2020 MergeModeSquash MergeMode = "squash"
21+ // MergeModeMerge makes one new signed commit with two parents: the target
22+ // tip and the head.
23+ MergeModeMerge MergeMode = "merge"
2124 )
2225
23-// MergeModes returns the two modes in their canonical order.
24-func MergeModes() []MergeMode { return []MergeMode{MergeModeFastForward, MergeModeSquash} }
26+// MergeModes returns the modes in their canonical order.
27+func MergeModes() []MergeMode {
28+ return []MergeMode{MergeModeFastForward, MergeModeSquash, MergeModeMerge}
29+}
2530
26-// ParseMergeMode parses "fast-forward" or "squash".
31+// ParseMergeMode parses "fast-forward", "squash" or "merge".
2732 func ParseMergeMode(s string) (MergeMode, error) {
2833 m := MergeMode(s)
2934 if slices.Contains(MergeModes(), m) {
3035 return m, nil
3136 }
32- return "", fmt.Errorf("%w: merge mode %q (want fast-forward or squash)", ErrInvalid, s)
37+ return "", fmt.Errorf("%w: merge mode %q (want fast-forward, squash or merge)", ErrInvalid, s)
3338 }
3439
3540 // String returns the mode as text.
@@ -47,7 +52,7 @@ type RepoSettings struct {
4752 Issues IssuesMode
4853 }
4954
50-// DefaultRepoSettings are the settings of a new repository: both modes, and
55+// DefaultRepoSettings are the settings of a new repository: every mode, and
5156 // failing checks block the merge.
5257 func DefaultRepoSettings() RepoSettings {
5358 return RepoSettings{AllowedMergeModes: MergeModes(), FailingChecksBlock: true, Issues: IssuesOff}
@@ -249,10 +254,10 @@ type Identity struct {
249254 Name, Email string
250255 }
251256
252-// SquashCommit is the commit a squash merge creates: the tree of the head on
253-// top of the target.
254-type SquashCommit struct {
255- Tree, Parent ObjectID
257+// ServerCommit is a commit the server creates: a squash or a merge commit.
258+type ServerCommit struct {
259+ Tree ObjectID
260+ Parents []ObjectID
256261 Author, Committer Signature
257262 Message string
258263 }
@@ -267,7 +272,7 @@ const MaxSquashCommits = 1000
267272 // trailers, in order of appearance. The message is the title, " (!<number>)",
268273 // a blank line, the summaries of the commits oldest first as a list, and the
269274 // trailers.
270-func NewSquashCommit(mr MergeRequest, tree, parent ObjectID, commits []Commit, committer Identity, at time.Time) SquashCommit {
275+func NewSquashCommit(mr MergeRequest, tree, parent ObjectID, commits []Commit, committer Identity, at time.Time) ServerCommit {
271276 oldest := slices.Clone(commits)
272277 slices.Reverse(oldest)
273278 var first Signature
@@ -293,26 +298,41 @@ func NewSquashCommit(mr MergeRequest, tree, parent ObjectID, commits []Commit, c
293298 if len(trailers) > 0 {
294299 msg.WriteString("\n" + strings.Join(trailers, "\n") + "\n")
295300 }
296- return SquashCommit{
297- Tree: tree, Parent: parent,
301+ return ServerCommit{
302+ Tree: tree, Parents: []ObjectID{parent},
298303 Author: first,
299304 Committer: Signature{Name: committer.Name, Email: committer.Email, Time: at.UTC()},
300305 Message: msg.String(),
301306 }
302307 }
303308
309+// NewMergeCommit builds the merge commit of a merge request: the tree of the
310+// merge, the target tip and the head as parents, and the server identity as
311+// author and committer.
312+func NewMergeCommit(mr MergeRequest, tree, target, head ObjectID, committer Identity, at time.Time) ServerCommit {
313+ sig := Signature{Name: committer.Name, Email: committer.Email, Time: at.UTC()}
314+ return ServerCommit{
315+ Tree: tree, Parents: []ObjectID{target, head},
316+ Author: sig, Committer: sig,
317+ Message: fmt.Sprintf("Merge branch '%s' into %s (!%d)\n\n%s\n",
318+ singleLine(mr.Source), singleLine(mr.Target), mr.Number, singleLine(mr.Title.String())),
319+ }
320+}
321+
304322 // Payload returns the text of the commit object without a signature, which is
305323 // what the signature covers.
306-func (c SquashCommit) Payload() []byte { return c.encode("") }
324+func (c ServerCommit) Payload() []byte { return c.encode("") }
307325
308326 // Signed returns the text of the commit object with the armored SSH signature
309327 // in its gpgsig header.
310-func (c SquashCommit) Signed(armored string) []byte { return c.encode(armored) }
328+func (c ServerCommit) Signed(armored string) []byte { return c.encode(armored) }
311329
312-func (c SquashCommit) encode(armored string) []byte {
330+func (c ServerCommit) encode(armored string) []byte {
313331 var b strings.Builder
314332 b.WriteString("tree " + c.Tree.String() + "\n")
315- b.WriteString("parent " + c.Parent.String() + "\n")
333+ for _, p := range c.Parents {
334+ b.WriteString("parent " + p.String() + "\n")
335+ }
316336 b.WriteString("author " + signatureLine(c.Author) + "\n")
317337 b.WriteString("committer " + signatureLine(c.Committer) + "\n")
318338 if armored != "" {
@@ -430,3 +450,88 @@ func CurrentVerdicts(comments []Comment, head ObjectID) []AuthorVerdict {
430450 })
431451 return out
432452 }
453+
454+// Limits of a stored Mergeability.
455+const (
456+ // MaxBehindCount caps the commits counted as behind.
457+ MaxBehindCount = 1000
458+ // MaxConflictPaths caps the conflicted paths listed.
459+ MaxConflictPaths = 20
460+)
461+
462+// MergeabilityState is the outcome of a mergeability check.
463+type MergeabilityState string
464+
465+// Mergeability states.
466+const (
467+ // MergeabilityChecking means there is no up-to-date result yet; it is never
468+ // stored.
469+ MergeabilityChecking MergeabilityState = "checking"
470+ // MergeabilityUpToDate means the target is an ancestor of the head.
471+ MergeabilityUpToDate MergeabilityState = "up_to_date"
472+ // MergeabilityBehind means the head lacks commits of the target but merges
473+ // cleanly.
474+ MergeabilityBehind MergeabilityState = "behind"
475+ // MergeabilityConflict means the head does not merge cleanly.
476+ MergeabilityConflict MergeabilityState = "conflict"
477+)
478+
479+// Mergeability is whether an open merge request merges with its target, for
480+// given tips.
481+type Mergeability struct {
482+ Repo RepoName
483+ Number int64
484+ State MergeabilityState
485+ // TargetID and HeadID are the tips the result is for; zero for a derived
486+ // "checking".
487+ TargetID, HeadID ObjectID
488+ // Behind counts the commits of the target not in the head (behind only),
489+ // capped at MaxBehindCount.
490+ Behind int
491+ BehindCapped bool
492+ // ConflictPaths holds at most MaxConflictPaths paths (conflict only).
493+ ConflictPaths []string
494+ ConflictPathsTruncated bool
495+ CheckedAt time.Time
496+ // Version is set by the store on read, like MergeRequest.Version.
497+ Version uint64
498+}
499+
500+// Allows reports whether the mode may be used given the state, and if not,
501+// why.
502+func (m Mergeability) Allows(mode MergeMode) (bool, string) {
503+ switch mode {
504+ case MergeModeFastForward:
505+ if m.State == MergeabilityUpToDate {
506+ return true, ""
507+ }
508+ if m.State == MergeabilityBehind {
509+ return false, "behind the target: rebase or use squash/merge"
510+ }
511+ case MergeModeSquash, MergeModeMerge:
512+ if m.State != MergeabilityConflict && m.State != MergeabilityChecking && m.State != "" {
513+ return true, ""
514+ }
515+ }
516+ switch m.State {
517+ case MergeabilityConflict:
518+ return false, "conflicts with the target"
519+ case MergeabilityChecking, "":
520+ return false, "mergeability is being checked"
521+ }
522+ return false, "mode not allowed"
523+}
524+
525+// IsFor reports whether the result is for these tips, that is not stale.
526+func (m Mergeability) IsFor(target, head ObjectID) bool {
527+ return m.State != "" && m.State != MergeabilityChecking && m.TargetID == target && m.HeadID == head
528+}
529+
530+// MergeTreeResult is the outcome of merging two commits without a commit.
531+type MergeTreeResult struct {
532+ // Tree is the merged tree; zero when not clean.
533+ Tree ObjectID
534+ Clean bool
535+ ConflictPaths []string
536+ Truncated bool
537+}
@@ -18,18 +18,23 @@ const (
18 MergeModeFastForward MergeMode = "fast-forward"18 MergeModeFastForward MergeMode = "fast-forward"
19 // MergeModeSquash makes one new signed commit with the tree of the head.19 // MergeModeSquash makes one new signed commit with the tree of the head.
20 MergeModeSquash MergeMode = "squash"20 MergeModeSquash MergeMode = "squash"
21+ // MergeModeMerge makes one new signed commit with two parents: the target
22+ // tip and the head.
23+ MergeModeMerge MergeMode = "merge"
21 )24 )
22 25
23-// MergeModes returns the two modes in their canonical order.26+// MergeModes returns the modes in their canonical order.
24-func MergeModes() []MergeMode { return []MergeMode{MergeModeFastForward, MergeModeSquash} }27+func MergeModes() []MergeMode {
28+ return []MergeMode{MergeModeFastForward, MergeModeSquash, MergeModeMerge}
29+}
25 30
26-// ParseMergeMode parses "fast-forward" or "squash".31+// ParseMergeMode parses "fast-forward", "squash" or "merge".
27 func ParseMergeMode(s string) (MergeMode, error) {32 func ParseMergeMode(s string) (MergeMode, error) {
28 m := MergeMode(s)33 m := MergeMode(s)
29 if slices.Contains(MergeModes(), m) {34 if slices.Contains(MergeModes(), m) {
30 return m, nil35 return m, nil
31 }36 }
32- return "", fmt.Errorf("%w: merge mode %q (want fast-forward or squash)", ErrInvalid, s)37+ return "", fmt.Errorf("%w: merge mode %q (want fast-forward, squash or merge)", ErrInvalid, s)
33 }38 }
34 39
35 // String returns the mode as text.40 // String returns the mode as text.
@@ -47,7 +52,7 @@ type RepoSettings struct {
47 Issues IssuesMode52 Issues IssuesMode
48 }53 }
49 54
50-// DefaultRepoSettings are the settings of a new repository: both modes, and55+// DefaultRepoSettings are the settings of a new repository: every mode, and
51 // failing checks block the merge.56 // failing checks block the merge.
52 func DefaultRepoSettings() RepoSettings {57 func DefaultRepoSettings() RepoSettings {
53 return RepoSettings{AllowedMergeModes: MergeModes(), FailingChecksBlock: true, Issues: IssuesOff}58 return RepoSettings{AllowedMergeModes: MergeModes(), FailingChecksBlock: true, Issues: IssuesOff}
@@ -249,10 +254,10 @@ type Identity struct {
249 Name, Email string254 Name, Email string
250 }255 }
251 256
252-// SquashCommit is the commit a squash merge creates: the tree of the head on257+// ServerCommit is a commit the server creates: a squash or a merge commit.
253-// top of the target.258+type ServerCommit struct {
254-type SquashCommit struct {259+ Tree ObjectID
255- Tree, Parent ObjectID260+ Parents []ObjectID
256 Author, Committer Signature261 Author, Committer Signature
257 Message string262 Message string
258 }263 }
@@ -267,7 +272,7 @@ const MaxSquashCommits = 1000
267 // trailers, in order of appearance. The message is the title, " (!<number>)",272 // trailers, in order of appearance. The message is the title, " (!<number>)",
268 // a blank line, the summaries of the commits oldest first as a list, and the273 // a blank line, the summaries of the commits oldest first as a list, and the
269 // trailers.274 // trailers.
270-func NewSquashCommit(mr MergeRequest, tree, parent ObjectID, commits []Commit, committer Identity, at time.Time) SquashCommit {275+func NewSquashCommit(mr MergeRequest, tree, parent ObjectID, commits []Commit, committer Identity, at time.Time) ServerCommit {
271 oldest := slices.Clone(commits)276 oldest := slices.Clone(commits)
272 slices.Reverse(oldest)277 slices.Reverse(oldest)
273 var first Signature278 var first Signature
@@ -293,26 +298,41 @@ func NewSquashCommit(mr MergeRequest, tree, parent ObjectID, commits []Commit, c
293 if len(trailers) > 0 {298 if len(trailers) > 0 {
294 msg.WriteString("\n" + strings.Join(trailers, "\n") + "\n")299 msg.WriteString("\n" + strings.Join(trailers, "\n") + "\n")
295 }300 }
296- return SquashCommit{301+ return ServerCommit{
297- Tree: tree, Parent: parent,302+ Tree: tree, Parents: []ObjectID{parent},
298 Author: first,303 Author: first,
299 Committer: Signature{Name: committer.Name, Email: committer.Email, Time: at.UTC()},304 Committer: Signature{Name: committer.Name, Email: committer.Email, Time: at.UTC()},
300 Message: msg.String(),305 Message: msg.String(),
301 }306 }
302 }307 }
303 308
309+// NewMergeCommit builds the merge commit of a merge request: the tree of the
310+// merge, the target tip and the head as parents, and the server identity as
311+// author and committer.
312+func NewMergeCommit(mr MergeRequest, tree, target, head ObjectID, committer Identity, at time.Time) ServerCommit {
313+ sig := Signature{Name: committer.Name, Email: committer.Email, Time: at.UTC()}
314+ return ServerCommit{
315+ Tree: tree, Parents: []ObjectID{target, head},
316+ Author: sig, Committer: sig,
317+ Message: fmt.Sprintf("Merge branch '%s' into %s (!%d)\n\n%s\n",
318+ singleLine(mr.Source), singleLine(mr.Target), mr.Number, singleLine(mr.Title.String())),
319+ }
320+}
321+
304 // Payload returns the text of the commit object without a signature, which is322 // Payload returns the text of the commit object without a signature, which is
305 // what the signature covers.323 // what the signature covers.
306-func (c SquashCommit) Payload() []byte { return c.encode("") }324+func (c ServerCommit) Payload() []byte { return c.encode("") }
307 325
308 // Signed returns the text of the commit object with the armored SSH signature326 // Signed returns the text of the commit object with the armored SSH signature
309 // in its gpgsig header.327 // in its gpgsig header.
310-func (c SquashCommit) Signed(armored string) []byte { return c.encode(armored) }328+func (c ServerCommit) Signed(armored string) []byte { return c.encode(armored) }
311 329
312-func (c SquashCommit) encode(armored string) []byte {330+func (c ServerCommit) encode(armored string) []byte {
313 var b strings.Builder331 var b strings.Builder
314 b.WriteString("tree " + c.Tree.String() + "\n")332 b.WriteString("tree " + c.Tree.String() + "\n")
315- b.WriteString("parent " + c.Parent.String() + "\n")333+ for _, p := range c.Parents {
334+ b.WriteString("parent " + p.String() + "\n")
335+ }
316 b.WriteString("author " + signatureLine(c.Author) + "\n")336 b.WriteString("author " + signatureLine(c.Author) + "\n")
317 b.WriteString("committer " + signatureLine(c.Committer) + "\n")337 b.WriteString("committer " + signatureLine(c.Committer) + "\n")
318 if armored != "" {338 if armored != "" {
@@ -430,3 +450,88 @@ func CurrentVerdicts(comments []Comment, head ObjectID) []AuthorVerdict {
430 })450 })
431 return out451 return out
432 }452 }
453+
454+// Limits of a stored Mergeability.
455+const (
456+ // MaxBehindCount caps the commits counted as behind.
457+ MaxBehindCount = 1000
458+ // MaxConflictPaths caps the conflicted paths listed.
459+ MaxConflictPaths = 20
460+)
461+
462+// MergeabilityState is the outcome of a mergeability check.
463+type MergeabilityState string
464+
465+// Mergeability states.
466+const (
467+ // MergeabilityChecking means there is no up-to-date result yet; it is never
468+ // stored.
469+ MergeabilityChecking MergeabilityState = "checking"
470+ // MergeabilityUpToDate means the target is an ancestor of the head.
471+ MergeabilityUpToDate MergeabilityState = "up_to_date"
472+ // MergeabilityBehind means the head lacks commits of the target but merges
473+ // cleanly.
474+ MergeabilityBehind MergeabilityState = "behind"
475+ // MergeabilityConflict means the head does not merge cleanly.
476+ MergeabilityConflict MergeabilityState = "conflict"
477+)
478+
479+// Mergeability is whether an open merge request merges with its target, for
480+// given tips.
481+type Mergeability struct {
482+ Repo RepoName
483+ Number int64
484+ State MergeabilityState
485+ // TargetID and HeadID are the tips the result is for; zero for a derived
486+ // "checking".
487+ TargetID, HeadID ObjectID
488+ // Behind counts the commits of the target not in the head (behind only),
489+ // capped at MaxBehindCount.
490+ Behind int
491+ BehindCapped bool
492+ // ConflictPaths holds at most MaxConflictPaths paths (conflict only).
493+ ConflictPaths []string
494+ ConflictPathsTruncated bool
495+ CheckedAt time.Time
496+ // Version is set by the store on read, like MergeRequest.Version.
497+ Version uint64
498+}
499+
500+// Allows reports whether the mode may be used given the state, and if not,
501+// why.
502+func (m Mergeability) Allows(mode MergeMode) (bool, string) {
503+ switch mode {
504+ case MergeModeFastForward:
505+ if m.State == MergeabilityUpToDate {
506+ return true, ""
507+ }
508+ if m.State == MergeabilityBehind {
509+ return false, "behind the target: rebase or use squash/merge"
510+ }
511+ case MergeModeSquash, MergeModeMerge:
512+ if m.State != MergeabilityConflict && m.State != MergeabilityChecking && m.State != "" {
513+ return true, ""
514+ }
515+ }
516+ switch m.State {
517+ case MergeabilityConflict:
518+ return false, "conflicts with the target"
519+ case MergeabilityChecking, "":
520+ return false, "mergeability is being checked"
521+ }
522+ return false, "mode not allowed"
523+}
524+
525+// IsFor reports whether the result is for these tips, that is not stale.
526+func (m Mergeability) IsFor(target, head ObjectID) bool {
527+ return m.State != "" && m.State != MergeabilityChecking && m.TargetID == target && m.HeadID == head
528+}
529+
530+// MergeTreeResult is the outcome of merging two commits without a commit.
531+type MergeTreeResult struct {
532+ // Tree is the merged tree; zero when not clean.
533+ Tree ObjectID
534+ Clean bool
535+ ConflictPaths []string
536+ Truncated bool
537+}
M internal/domain/mergerequest_test.go +50 -3
@@ -3,6 +3,7 @@
33 package domain_test
44
55 import (
6+ "strconv"
67 "strings"
78 "testing"
89 "time"
@@ -23,7 +24,7 @@ func oid(t *testing.T, c byte) domain.ObjectID {
2324 func TestDefaultRepoSettings(t *testing.T) {
2425 t.Parallel()
2526 s := domain.DefaultRepoSettings()
26- assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, s.AllowedMergeModes)
27+ assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge}, s.AllowedMergeModes)
2728 assert.True(t, s.FailingChecksBlock)
2829 assert.True(t, s.Allows(domain.MergeModeSquash))
2930 assert.False(t, domain.RepoSettings{}.Allows(domain.MergeModeSquash))
@@ -39,7 +40,7 @@ func TestNormalizeMergeModes(t *testing.T) {
3940 t.Parallel()
4041 got, err := domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash, domain.MergeModeFastForward, domain.MergeModeSquash})
4142 require.NoError(t, err)
42- assert.Equal(t, domain.MergeModes(), got)
43+ assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, got)
4344
4445 got, err = domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash})
4546 require.NoError(t, err)
@@ -56,7 +57,10 @@ func TestParseMergeMode(t *testing.T) {
5657 m, err := domain.ParseMergeMode("fast-forward")
5758 require.NoError(t, err)
5859 assert.Equal(t, domain.MergeModeFastForward, m)
59- _, err = domain.ParseMergeMode("merge")
60+ m, err = domain.ParseMergeMode("merge")
61+ require.NoError(t, err)
62+ assert.Equal(t, domain.MergeModeMerge, m)
63+ _, err = domain.ParseMergeMode("rebase")
6064 assert.ErrorIs(t, err, domain.ErrInvalid)
6165 }
6266
@@ -283,3 +287,46 @@ func TestReviewScopeOwnerRights(t *testing.T) {
283287 assert.Equal(t, tc.want, domain.AuthorName(tc.p))
284288 }
285289 }
290+
291+func TestNewMergeCommit(t *testing.T) {
292+ t.Parallel()
293+ title, err := domain.ParseMRTitle("Add things")
294+ require.NoError(t, err)
295+ mr := domain.MergeRequest{Number: 7, Title: title, Source: "feat/x", Target: "main"}
296+ at := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
297+ c := domain.NewMergeCommit(mr, oid(t, 'a'), oid(t, 'b'), oid(t, 'c'), domain.Identity{Name: "ermite", Email: "e@x"}, at)
298+ assert.Equal(t, []domain.ObjectID{oid(t, 'b'), oid(t, 'c')}, c.Parents)
299+ assert.Equal(t, c.Author, c.Committer)
300+ want := "tree " + strings.Repeat("a", 40) + "\nparent " + strings.Repeat("b", 40) + "\nparent " + strings.Repeat("c", 40) + "\n" +
301+ "author ermite <e@x> " + strconv.FormatInt(at.Unix(), 10) + " +0000\ncommitter ermite <e@x> " + strconv.FormatInt(at.Unix(), 10) + " +0000\n\n" +
302+ "Merge branch 'feat/x' into main (!7)\n\nAdd things\n"
303+ assert.Equal(t, want, string(c.Payload()))
304+ assert.Contains(t, string(c.Signed("-----BEGIN-----\nabc\n-----END-----\n")), "gpgsig -----BEGIN-----\n abc\n -----END-----\n")
305+}
306+
307+func TestMergeabilityAllowsAndIsFor(t *testing.T) {
308+ t.Parallel()
309+ for _, tc := range []struct {
310+ state domain.MergeabilityState
311+ ff, squash, mg bool
312+ }{
313+ {domain.MergeabilityUpToDate, true, true, true},
314+ {domain.MergeabilityBehind, false, true, true},
315+ {domain.MergeabilityConflict, false, false, false},
316+ {domain.MergeabilityChecking, false, false, false},
317+ } {
318+ m := domain.Mergeability{State: tc.state}
319+ ok, reason := m.Allows(domain.MergeModeFastForward)
320+ assert.Equal(t, tc.ff, ok, tc.state)
321+ assert.Equal(t, !ok, reason != "")
322+ ok, _ = m.Allows(domain.MergeModeSquash)
323+ assert.Equal(t, tc.squash, ok, tc.state)
324+ ok, _ = m.Allows(domain.MergeModeMerge)
325+ assert.Equal(t, tc.mg, ok, tc.state)
326+ }
327+ m := domain.Mergeability{State: domain.MergeabilityBehind, TargetID: oid(t, 'a'), HeadID: oid(t, 'b')}
328+ assert.True(t, m.IsFor(oid(t, 'a'), oid(t, 'b')))
329+ assert.False(t, m.IsFor(oid(t, 'c'), oid(t, 'b')))
330+ assert.False(t, m.IsFor(oid(t, 'a'), oid(t, 'c')))
331+ assert.False(t, domain.Mergeability{}.IsFor(domain.ObjectID{}, domain.ObjectID{}))
332+}
@@ -3,6 +3,7 @@
3 package domain_test3 package domain_test
4 4
5 import (5 import (
6+ "strconv"
6 "strings"7 "strings"
7 "testing"8 "testing"
8 "time"9 "time"
@@ -23,7 +24,7 @@ func oid(t *testing.T, c byte) domain.ObjectID {
23 func TestDefaultRepoSettings(t *testing.T) {24 func TestDefaultRepoSettings(t *testing.T) {
24 t.Parallel()25 t.Parallel()
25 s := domain.DefaultRepoSettings()26 s := domain.DefaultRepoSettings()
26- assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, s.AllowedMergeModes)27+ assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge}, s.AllowedMergeModes)
27 assert.True(t, s.FailingChecksBlock)28 assert.True(t, s.FailingChecksBlock)
28 assert.True(t, s.Allows(domain.MergeModeSquash))29 assert.True(t, s.Allows(domain.MergeModeSquash))
29 assert.False(t, domain.RepoSettings{}.Allows(domain.MergeModeSquash))30 assert.False(t, domain.RepoSettings{}.Allows(domain.MergeModeSquash))
@@ -39,7 +40,7 @@ func TestNormalizeMergeModes(t *testing.T) {
39 t.Parallel()40 t.Parallel()
40 got, err := domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash, domain.MergeModeFastForward, domain.MergeModeSquash})41 got, err := domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash, domain.MergeModeFastForward, domain.MergeModeSquash})
41 require.NoError(t, err)42 require.NoError(t, err)
42- assert.Equal(t, domain.MergeModes(), got)43+ assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, got)
43 44
44 got, err = domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash})45 got, err = domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash})
45 require.NoError(t, err)46 require.NoError(t, err)
@@ -56,7 +57,10 @@ func TestParseMergeMode(t *testing.T) {
56 m, err := domain.ParseMergeMode("fast-forward")57 m, err := domain.ParseMergeMode("fast-forward")
57 require.NoError(t, err)58 require.NoError(t, err)
58 assert.Equal(t, domain.MergeModeFastForward, m)59 assert.Equal(t, domain.MergeModeFastForward, m)
59- _, err = domain.ParseMergeMode("merge")60+ m, err = domain.ParseMergeMode("merge")
61+ require.NoError(t, err)
62+ assert.Equal(t, domain.MergeModeMerge, m)
63+ _, err = domain.ParseMergeMode("rebase")
60 assert.ErrorIs(t, err, domain.ErrInvalid)64 assert.ErrorIs(t, err, domain.ErrInvalid)
61 }65 }
62 66
@@ -283,3 +287,46 @@ func TestReviewScopeOwnerRights(t *testing.T) {
283 assert.Equal(t, tc.want, domain.AuthorName(tc.p))287 assert.Equal(t, tc.want, domain.AuthorName(tc.p))
284 }288 }
285 }289 }
290+
291+func TestNewMergeCommit(t *testing.T) {
292+ t.Parallel()
293+ title, err := domain.ParseMRTitle("Add things")
294+ require.NoError(t, err)
295+ mr := domain.MergeRequest{Number: 7, Title: title, Source: "feat/x", Target: "main"}
296+ at := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
297+ c := domain.NewMergeCommit(mr, oid(t, 'a'), oid(t, 'b'), oid(t, 'c'), domain.Identity{Name: "ermite", Email: "e@x"}, at)
298+ assert.Equal(t, []domain.ObjectID{oid(t, 'b'), oid(t, 'c')}, c.Parents)
299+ assert.Equal(t, c.Author, c.Committer)
300+ want := "tree " + strings.Repeat("a", 40) + "\nparent " + strings.Repeat("b", 40) + "\nparent " + strings.Repeat("c", 40) + "\n" +
301+ "author ermite <e@x> " + strconv.FormatInt(at.Unix(), 10) + " +0000\ncommitter ermite <e@x> " + strconv.FormatInt(at.Unix(), 10) + " +0000\n\n" +
302+ "Merge branch 'feat/x' into main (!7)\n\nAdd things\n"
303+ assert.Equal(t, want, string(c.Payload()))
304+ assert.Contains(t, string(c.Signed("-----BEGIN-----\nabc\n-----END-----\n")), "gpgsig -----BEGIN-----\n abc\n -----END-----\n")
305+}
306+
307+func TestMergeabilityAllowsAndIsFor(t *testing.T) {
308+ t.Parallel()
309+ for _, tc := range []struct {
310+ state domain.MergeabilityState
311+ ff, squash, mg bool
312+ }{
313+ {domain.MergeabilityUpToDate, true, true, true},
314+ {domain.MergeabilityBehind, false, true, true},
315+ {domain.MergeabilityConflict, false, false, false},
316+ {domain.MergeabilityChecking, false, false, false},
317+ } {
318+ m := domain.Mergeability{State: tc.state}
319+ ok, reason := m.Allows(domain.MergeModeFastForward)
320+ assert.Equal(t, tc.ff, ok, tc.state)
321+ assert.Equal(t, !ok, reason != "")
322+ ok, _ = m.Allows(domain.MergeModeSquash)
323+ assert.Equal(t, tc.squash, ok, tc.state)
324+ ok, _ = m.Allows(domain.MergeModeMerge)
325+ assert.Equal(t, tc.mg, ok, tc.state)
326+ }
327+ m := domain.Mergeability{State: domain.MergeabilityBehind, TargetID: oid(t, 'a'), HeadID: oid(t, 'b')}
328+ assert.True(t, m.IsFor(oid(t, 'a'), oid(t, 'b')))
329+ assert.False(t, m.IsFor(oid(t, 'c'), oid(t, 'b')))
330+ assert.False(t, m.IsFor(oid(t, 'a'), oid(t, 'c')))
331+ assert.False(t, domain.Mergeability{}.IsFor(domain.ObjectID{}, domain.ObjectID{}))
332+}
M internal/ports/dto.go +2 -0
@@ -262,6 +262,8 @@ type MergeRequestDetail struct {
262262 Comments []domain.Comment
263263 // Verdicts are the latest verdict per author in note ID order.
264264 Verdicts []domain.AuthorVerdict
265+ // Mergeability is zero unless the request is open.
266+ Mergeability domain.Mergeability
265267 // LastReadID is the head recorded by MarkRead; zero when none.
266268 LastReadID domain.ObjectID
267269 // LastReadLost tells that since-last-read was asked for, a head was
@@ -262,6 +262,8 @@ type MergeRequestDetail struct {
262 Comments []domain.Comment262 Comments []domain.Comment
263 // Verdicts are the latest verdict per author in note ID order.263 // Verdicts are the latest verdict per author in note ID order.
264 Verdicts []domain.AuthorVerdict264 Verdicts []domain.AuthorVerdict
265+ // Mergeability is zero unless the request is open.
266+ Mergeability domain.Mergeability
265 // LastReadID is the head recorded by MarkRead; zero when none.267 // LastReadID is the head recorded by MarkRead; zero when none.
266 LastReadID domain.ObjectID268 LastReadID domain.ObjectID
267 // LastReadLost tells that since-last-read was asked for, a head was269 // LastReadLost tells that since-last-read was asked for, a head was
M internal/ports/mocks/MergeGit.go +168 -0
@@ -191,6 +191,96 @@ func (_c *MergeGit_CommitTree_Call) RunAndReturn(run func(ctx context.Context, r
191191 return _c
192192 }
193193
194+// CountBehind provides a mock function for the type MergeGit
195+func (_mock *MergeGit) CountBehind(ctx context.Context, repo domain.RepoName, head domain.ObjectID, target domain.ObjectID, max int) (int, bool, error) {
196+ ret := _mock.Called(ctx, repo, head, target, max)
197+
198+ if len(ret) == 0 {
199+ panic("no return value specified for CountBehind")
200+ }
201+
202+ var r0 int
203+ var r1 bool
204+ var r2 error
205+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) (int, bool, error)); ok {
206+ return returnFunc(ctx, repo, head, target, max)
207+ }
208+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) int); ok {
209+ r0 = returnFunc(ctx, repo, head, target, max)
210+ } else {
211+ r0 = ret.Get(0).(int)
212+ }
213+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) bool); ok {
214+ r1 = returnFunc(ctx, repo, head, target, max)
215+ } else {
216+ r1 = ret.Get(1).(bool)
217+ }
218+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) error); ok {
219+ r2 = returnFunc(ctx, repo, head, target, max)
220+ } else {
221+ r2 = ret.Error(2)
222+ }
223+ return r0, r1, r2
224+}
225+
226+// MergeGit_CountBehind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CountBehind'
227+type MergeGit_CountBehind_Call struct {
228+ *mock.Call
229+}
230+
231+// CountBehind is a helper method to define mock.On call
232+// - ctx context.Context
233+// - repo domain.RepoName
234+// - head domain.ObjectID
235+// - target domain.ObjectID
236+// - max int
237+func (_e *MergeGit_Expecter) CountBehind(ctx any, repo any, head any, target any, max any) *MergeGit_CountBehind_Call {
238+ return &MergeGit_CountBehind_Call{Call: _e.mock.On("CountBehind", ctx, repo, head, target, max)}
239+}
240+
241+func (_c *MergeGit_CountBehind_Call) Run(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, target domain.ObjectID, max int)) *MergeGit_CountBehind_Call {
242+ _c.Call.Run(func(args mock.Arguments) {
243+ var arg0 context.Context
244+ if args[0] != nil {
245+ arg0 = args[0].(context.Context)
246+ }
247+ var arg1 domain.RepoName
248+ if args[1] != nil {
249+ arg1 = args[1].(domain.RepoName)
250+ }
251+ var arg2 domain.ObjectID
252+ if args[2] != nil {
253+ arg2 = args[2].(domain.ObjectID)
254+ }
255+ var arg3 domain.ObjectID
256+ if args[3] != nil {
257+ arg3 = args[3].(domain.ObjectID)
258+ }
259+ var arg4 int
260+ if args[4] != nil {
261+ arg4 = args[4].(int)
262+ }
263+ run(
264+ arg0,
265+ arg1,
266+ arg2,
267+ arg3,
268+ arg4,
269+ )
270+ })
271+ return _c
272+}
273+
274+func (_c *MergeGit_CountBehind_Call) Return(n int, capped bool, err error) *MergeGit_CountBehind_Call {
275+ _c.Call.Return(n, capped, err)
276+ return _c
277+}
278+
279+func (_c *MergeGit_CountBehind_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, target domain.ObjectID, max int) (int, bool, error)) *MergeGit_CountBehind_Call {
280+ _c.Call.Return(run)
281+ return _c
282+}
283+
194284 // DeleteBranch provides a mock function for the type MergeGit
195285 func (_mock *MergeGit) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error {
196286 ret := _mock.Called(ctx, repo, branch, old)
@@ -670,6 +760,84 @@ func (_c *MergeGit_MergeBase_Call) RunAndReturn(run func(ctx context.Context, re
670760 return _c
671761 }
672762
763+// MergeTree provides a mock function for the type MergeGit
764+func (_mock *MergeGit) MergeTree(ctx context.Context, repo domain.RepoName, base domain.ObjectID, head domain.ObjectID) (domain.MergeTreeResult, error) {
765+ ret := _mock.Called(ctx, repo, base, head)
766+
767+ if len(ret) == 0 {
768+ panic("no return value specified for MergeTree")
769+ }
770+
771+ var r0 domain.MergeTreeResult
772+ var r1 error
773+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID) (domain.MergeTreeResult, error)); ok {
774+ return returnFunc(ctx, repo, base, head)
775+ }
776+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID) domain.MergeTreeResult); ok {
777+ r0 = returnFunc(ctx, repo, base, head)
778+ } else {
779+ r0 = ret.Get(0).(domain.MergeTreeResult)
780+ }
781+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID) error); ok {
782+ r1 = returnFunc(ctx, repo, base, head)
783+ } else {
784+ r1 = ret.Error(1)
785+ }
786+ return r0, r1
787+}
788+
789+// MergeGit_MergeTree_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MergeTree'
790+type MergeGit_MergeTree_Call struct {
791+ *mock.Call
792+}
793+
794+// MergeTree is a helper method to define mock.On call
795+// - ctx context.Context
796+// - repo domain.RepoName
797+// - base domain.ObjectID
798+// - head domain.ObjectID
799+func (_e *MergeGit_Expecter) MergeTree(ctx any, repo any, base any, head any) *MergeGit_MergeTree_Call {
800+ return &MergeGit_MergeTree_Call{Call: _e.mock.On("MergeTree", ctx, repo, base, head)}
801+}
802+
803+func (_c *MergeGit_MergeTree_Call) Run(run func(ctx context.Context, repo domain.RepoName, base domain.ObjectID, head domain.ObjectID)) *MergeGit_MergeTree_Call {
804+ _c.Call.Run(func(args mock.Arguments) {
805+ var arg0 context.Context
806+ if args[0] != nil {
807+ arg0 = args[0].(context.Context)
808+ }
809+ var arg1 domain.RepoName
810+ if args[1] != nil {
811+ arg1 = args[1].(domain.RepoName)
812+ }
813+ var arg2 domain.ObjectID
814+ if args[2] != nil {
815+ arg2 = args[2].(domain.ObjectID)
816+ }
817+ var arg3 domain.ObjectID
818+ if args[3] != nil {
819+ arg3 = args[3].(domain.ObjectID)
820+ }
821+ run(
822+ arg0,
823+ arg1,
824+ arg2,
825+ arg3,
826+ )
827+ })
828+ return _c
829+}
830+
831+func (_c *MergeGit_MergeTree_Call) Return(mergeTreeResult domain.MergeTreeResult, err error) *MergeGit_MergeTree_Call {
832+ _c.Call.Return(mergeTreeResult, err)
833+ return _c
834+}
835+
836+func (_c *MergeGit_MergeTree_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, base domain.ObjectID, head domain.ObjectID) (domain.MergeTreeResult, error)) *MergeGit_MergeTree_Call {
837+ _c.Call.Return(run)
838+ return _c
839+}
840+
673841 // UpdateBranch provides a mock function for the type MergeGit
674842 func (_mock *MergeGit) UpdateBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID, new domain.ObjectID) error {
675843 ret := _mock.Called(ctx, repo, branch, old, new)
@@ -191,6 +191,96 @@ func (_c *MergeGit_CommitTree_Call) RunAndReturn(run func(ctx context.Context, r
191 return _c191 return _c
192 }192 }
193 193
194+// CountBehind provides a mock function for the type MergeGit
195+func (_mock *MergeGit) CountBehind(ctx context.Context, repo domain.RepoName, head domain.ObjectID, target domain.ObjectID, max int) (int, bool, error) {
196+ ret := _mock.Called(ctx, repo, head, target, max)
197+
198+ if len(ret) == 0 {
199+ panic("no return value specified for CountBehind")
200+ }
201+
202+ var r0 int
203+ var r1 bool
204+ var r2 error
205+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) (int, bool, error)); ok {
206+ return returnFunc(ctx, repo, head, target, max)
207+ }
208+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) int); ok {
209+ r0 = returnFunc(ctx, repo, head, target, max)
210+ } else {
211+ r0 = ret.Get(0).(int)
212+ }
213+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) bool); ok {
214+ r1 = returnFunc(ctx, repo, head, target, max)
215+ } else {
216+ r1 = ret.Get(1).(bool)
217+ }
218+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) error); ok {
219+ r2 = returnFunc(ctx, repo, head, target, max)
220+ } else {
221+ r2 = ret.Error(2)
222+ }
223+ return r0, r1, r2
224+}
225+
226+// MergeGit_CountBehind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CountBehind'
227+type MergeGit_CountBehind_Call struct {
228+ *mock.Call
229+}
230+
231+// CountBehind is a helper method to define mock.On call
232+// - ctx context.Context
233+// - repo domain.RepoName
234+// - head domain.ObjectID
235+// - target domain.ObjectID
236+// - max int
237+func (_e *MergeGit_Expecter) CountBehind(ctx any, repo any, head any, target any, max any) *MergeGit_CountBehind_Call {
238+ return &MergeGit_CountBehind_Call{Call: _e.mock.On("CountBehind", ctx, repo, head, target, max)}
239+}
240+
241+func (_c *MergeGit_CountBehind_Call) Run(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, target domain.ObjectID, max int)) *MergeGit_CountBehind_Call {
242+ _c.Call.Run(func(args mock.Arguments) {
243+ var arg0 context.Context
244+ if args[0] != nil {
245+ arg0 = args[0].(context.Context)
246+ }
247+ var arg1 domain.RepoName
248+ if args[1] != nil {
249+ arg1 = args[1].(domain.RepoName)
250+ }
251+ var arg2 domain.ObjectID
252+ if args[2] != nil {
253+ arg2 = args[2].(domain.ObjectID)
254+ }
255+ var arg3 domain.ObjectID
256+ if args[3] != nil {
257+ arg3 = args[3].(domain.ObjectID)
258+ }
259+ var arg4 int
260+ if args[4] != nil {
261+ arg4 = args[4].(int)
262+ }
263+ run(
264+ arg0,
265+ arg1,
266+ arg2,
267+ arg3,
268+ arg4,
269+ )
270+ })
271+ return _c
272+}
273+
274+func (_c *MergeGit_CountBehind_Call) Return(n int, capped bool, err error) *MergeGit_CountBehind_Call {
275+ _c.Call.Return(n, capped, err)
276+ return _c
277+}
278+
279+func (_c *MergeGit_CountBehind_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, target domain.ObjectID, max int) (int, bool, error)) *MergeGit_CountBehind_Call {
280+ _c.Call.Return(run)
281+ return _c
282+}
283+
194 // DeleteBranch provides a mock function for the type MergeGit284 // DeleteBranch provides a mock function for the type MergeGit
195 func (_mock *MergeGit) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error {285 func (_mock *MergeGit) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error {
196 ret := _mock.Called(ctx, repo, branch, old)286 ret := _mock.Called(ctx, repo, branch, old)
@@ -670,6 +760,84 @@ func (_c *MergeGit_MergeBase_Call) RunAndReturn(run func(ctx context.Context, re
670 return _c760 return _c
671 }761 }
672 762
763+// MergeTree provides a mock function for the type MergeGit
764+func (_mock *MergeGit) MergeTree(ctx context.Context, repo domain.RepoName, base domain.ObjectID, head domain.ObjectID) (domain.MergeTreeResult, error) {
765+ ret := _mock.Called(ctx, repo, base, head)
766+
767+ if len(ret) == 0 {
768+ panic("no return value specified for MergeTree")
769+ }
770+
771+ var r0 domain.MergeTreeResult
772+ var r1 error
773+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID) (domain.MergeTreeResult, error)); ok {
774+ return returnFunc(ctx, repo, base, head)
775+ }
776+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID) domain.MergeTreeResult); ok {
777+ r0 = returnFunc(ctx, repo, base, head)
778+ } else {
779+ r0 = ret.Get(0).(domain.MergeTreeResult)
780+ }
781+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID) error); ok {
782+ r1 = returnFunc(ctx, repo, base, head)
783+ } else {
784+ r1 = ret.Error(1)
785+ }
786+ return r0, r1
787+}
788+
789+// MergeGit_MergeTree_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MergeTree'
790+type MergeGit_MergeTree_Call struct {
791+ *mock.Call
792+}
793+
794+// MergeTree is a helper method to define mock.On call
795+// - ctx context.Context
796+// - repo domain.RepoName
797+// - base domain.ObjectID
798+// - head domain.ObjectID
799+func (_e *MergeGit_Expecter) MergeTree(ctx any, repo any, base any, head any) *MergeGit_MergeTree_Call {
800+ return &MergeGit_MergeTree_Call{Call: _e.mock.On("MergeTree", ctx, repo, base, head)}
801+}
802+
803+func (_c *MergeGit_MergeTree_Call) Run(run func(ctx context.Context, repo domain.RepoName, base domain.ObjectID, head domain.ObjectID)) *MergeGit_MergeTree_Call {
804+ _c.Call.Run(func(args mock.Arguments) {
805+ var arg0 context.Context
806+ if args[0] != nil {
807+ arg0 = args[0].(context.Context)
808+ }
809+ var arg1 domain.RepoName
810+ if args[1] != nil {
811+ arg1 = args[1].(domain.RepoName)
812+ }
813+ var arg2 domain.ObjectID
814+ if args[2] != nil {
815+ arg2 = args[2].(domain.ObjectID)
816+ }
817+ var arg3 domain.ObjectID
818+ if args[3] != nil {
819+ arg3 = args[3].(domain.ObjectID)
820+ }
821+ run(
822+ arg0,
823+ arg1,
824+ arg2,
825+ arg3,
826+ )
827+ })
828+ return _c
829+}
830+
831+func (_c *MergeGit_MergeTree_Call) Return(mergeTreeResult domain.MergeTreeResult, err error) *MergeGit_MergeTree_Call {
832+ _c.Call.Return(mergeTreeResult, err)
833+ return _c
834+}
835+
836+func (_c *MergeGit_MergeTree_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, base domain.ObjectID, head domain.ObjectID) (domain.MergeTreeResult, error)) *MergeGit_MergeTree_Call {
837+ _c.Call.Return(run)
838+ return _c
839+}
840+
673 // UpdateBranch provides a mock function for the type MergeGit841 // UpdateBranch provides a mock function for the type MergeGit
674 func (_mock *MergeGit) UpdateBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID, new domain.ObjectID) error {842 func (_mock *MergeGit) UpdateBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID, new domain.ObjectID) error {
675 ret := _mock.Called(ctx, repo, branch, old, new)843 ret := _mock.Called(ctx, repo, branch, old, new)
M internal/ports/mocks/MergeRequestStore.go +192 -0
@@ -179,6 +179,69 @@ func (_c *MergeRequestStore_Create_Call) RunAndReturn(run func(ctx context.Conte
179179 return _c
180180 }
181181
182+// DeleteMergeability provides a mock function for the type MergeRequestStore
183+func (_mock *MergeRequestStore) DeleteMergeability(ctx context.Context, repo domain.RepoName, number int64) error {
184+ ret := _mock.Called(ctx, repo, number)
185+
186+ if len(ret) == 0 {
187+ panic("no return value specified for DeleteMergeability")
188+ }
189+
190+ var r0 error
191+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) error); ok {
192+ r0 = returnFunc(ctx, repo, number)
193+ } else {
194+ r0 = ret.Error(0)
195+ }
196+ return r0
197+}
198+
199+// MergeRequestStore_DeleteMergeability_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteMergeability'
200+type MergeRequestStore_DeleteMergeability_Call struct {
201+ *mock.Call
202+}
203+
204+// DeleteMergeability is a helper method to define mock.On call
205+// - ctx context.Context
206+// - repo domain.RepoName
207+// - number int64
208+func (_e *MergeRequestStore_Expecter) DeleteMergeability(ctx any, repo any, number any) *MergeRequestStore_DeleteMergeability_Call {
209+ return &MergeRequestStore_DeleteMergeability_Call{Call: _e.mock.On("DeleteMergeability", ctx, repo, number)}
210+}
211+
212+func (_c *MergeRequestStore_DeleteMergeability_Call) Run(run func(ctx context.Context, repo domain.RepoName, number int64)) *MergeRequestStore_DeleteMergeability_Call {
213+ _c.Call.Run(func(args mock.Arguments) {
214+ var arg0 context.Context
215+ if args[0] != nil {
216+ arg0 = args[0].(context.Context)
217+ }
218+ var arg1 domain.RepoName
219+ if args[1] != nil {
220+ arg1 = args[1].(domain.RepoName)
221+ }
222+ var arg2 int64
223+ if args[2] != nil {
224+ arg2 = args[2].(int64)
225+ }
226+ run(
227+ arg0,
228+ arg1,
229+ arg2,
230+ )
231+ })
232+ return _c
233+}
234+
235+func (_c *MergeRequestStore_DeleteMergeability_Call) Return(err error) *MergeRequestStore_DeleteMergeability_Call {
236+ _c.Call.Return(err)
237+ return _c
238+}
239+
240+func (_c *MergeRequestStore_DeleteMergeability_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, number int64) error) *MergeRequestStore_DeleteMergeability_Call {
241+ _c.Call.Return(run)
242+ return _c
243+}
244+
182245 // Get provides a mock function for the type MergeRequestStore
183246 func (_mock *MergeRequestStore) Get(ctx context.Context, repo domain.RepoName, number int64) (domain.MergeRequest, error) {
184247 ret := _mock.Called(ctx, repo, number)
@@ -329,6 +392,78 @@ func (_c *MergeRequestStore_GetComment_Call) RunAndReturn(run func(ctx context.C
329392 return _c
330393 }
331394
395+// GetMergeability provides a mock function for the type MergeRequestStore
396+func (_mock *MergeRequestStore) GetMergeability(ctx context.Context, repo domain.RepoName, number int64) (domain.Mergeability, error) {
397+ ret := _mock.Called(ctx, repo, number)
398+
399+ if len(ret) == 0 {
400+ panic("no return value specified for GetMergeability")
401+ }
402+
403+ var r0 domain.Mergeability
404+ var r1 error
405+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) (domain.Mergeability, error)); ok {
406+ return returnFunc(ctx, repo, number)
407+ }
408+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) domain.Mergeability); ok {
409+ r0 = returnFunc(ctx, repo, number)
410+ } else {
411+ r0 = ret.Get(0).(domain.Mergeability)
412+ }
413+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok {
414+ r1 = returnFunc(ctx, repo, number)
415+ } else {
416+ r1 = ret.Error(1)
417+ }
418+ return r0, r1
419+}
420+
421+// MergeRequestStore_GetMergeability_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetMergeability'
422+type MergeRequestStore_GetMergeability_Call struct {
423+ *mock.Call
424+}
425+
426+// GetMergeability is a helper method to define mock.On call
427+// - ctx context.Context
428+// - repo domain.RepoName
429+// - number int64
430+func (_e *MergeRequestStore_Expecter) GetMergeability(ctx any, repo any, number any) *MergeRequestStore_GetMergeability_Call {
431+ return &MergeRequestStore_GetMergeability_Call{Call: _e.mock.On("GetMergeability", ctx, repo, number)}
432+}
433+
434+func (_c *MergeRequestStore_GetMergeability_Call) Run(run func(ctx context.Context, repo domain.RepoName, number int64)) *MergeRequestStore_GetMergeability_Call {
435+ _c.Call.Run(func(args mock.Arguments) {
436+ var arg0 context.Context
437+ if args[0] != nil {
438+ arg0 = args[0].(context.Context)
439+ }
440+ var arg1 domain.RepoName
441+ if args[1] != nil {
442+ arg1 = args[1].(domain.RepoName)
443+ }
444+ var arg2 int64
445+ if args[2] != nil {
446+ arg2 = args[2].(int64)
447+ }
448+ run(
449+ arg0,
450+ arg1,
451+ arg2,
452+ )
453+ })
454+ return _c
455+}
456+
457+func (_c *MergeRequestStore_GetMergeability_Call) Return(mergeability domain.Mergeability, err error) *MergeRequestStore_GetMergeability_Call {
458+ _c.Call.Return(mergeability, err)
459+ return _c
460+}
461+
462+func (_c *MergeRequestStore_GetMergeability_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, number int64) (domain.Mergeability, error)) *MergeRequestStore_GetMergeability_Call {
463+ _c.Call.Return(run)
464+ return _c
465+}
466+
332467 // GetRead provides a mock function for the type MergeRequestStore
333468 func (_mock *MergeRequestStore) GetRead(ctx context.Context, repo domain.RepoName, number int64) (domain.ObjectID, error) {
334469 ret := _mock.Called(ctx, repo, number)
@@ -845,6 +980,63 @@ func (_c *MergeRequestStore_OpenByTarget_Call) RunAndReturn(run func(ctx context
845980 return _c
846981 }
847982
983+// PutMergeability provides a mock function for the type MergeRequestStore
984+func (_mock *MergeRequestStore) PutMergeability(ctx context.Context, m domain.Mergeability) error {
985+ ret := _mock.Called(ctx, m)
986+
987+ if len(ret) == 0 {
988+ panic("no return value specified for PutMergeability")
989+ }
990+
991+ var r0 error
992+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Mergeability) error); ok {
993+ r0 = returnFunc(ctx, m)
994+ } else {
995+ r0 = ret.Error(0)
996+ }
997+ return r0
998+}
999+
1000+// MergeRequestStore_PutMergeability_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PutMergeability'
1001+type MergeRequestStore_PutMergeability_Call struct {
1002+ *mock.Call
1003+}
1004+
1005+// PutMergeability is a helper method to define mock.On call
1006+// - ctx context.Context
1007+// - m domain.Mergeability
1008+func (_e *MergeRequestStore_Expecter) PutMergeability(ctx any, m any) *MergeRequestStore_PutMergeability_Call {
1009+ return &MergeRequestStore_PutMergeability_Call{Call: _e.mock.On("PutMergeability", ctx, m)}
1010+}
1011+
1012+func (_c *MergeRequestStore_PutMergeability_Call) Run(run func(ctx context.Context, m domain.Mergeability)) *MergeRequestStore_PutMergeability_Call {
1013+ _c.Call.Run(func(args mock.Arguments) {
1014+ var arg0 context.Context
1015+ if args[0] != nil {
1016+ arg0 = args[0].(context.Context)
1017+ }
1018+ var arg1 domain.Mergeability
1019+ if args[1] != nil {
1020+ arg1 = args[1].(domain.Mergeability)
1021+ }
1022+ run(
1023+ arg0,
1024+ arg1,
1025+ )
1026+ })
1027+ return _c
1028+}
1029+
1030+func (_c *MergeRequestStore_PutMergeability_Call) Return(err error) *MergeRequestStore_PutMergeability_Call {
1031+ _c.Call.Return(err)
1032+ return _c
1033+}
1034+
1035+func (_c *MergeRequestStore_PutMergeability_Call) RunAndReturn(run func(ctx context.Context, m domain.Mergeability) error) *MergeRequestStore_PutMergeability_Call {
1036+ _c.Call.Return(run)
1037+ return _c
1038+}
1039+
8481040 // SaveComment provides a mock function for the type MergeRequestStore
8491041 func (_mock *MergeRequestStore) SaveComment(ctx context.Context, mr domain.MergeRequest, c domain.Comment) error {
8501042 ret := _mock.Called(ctx, mr, c)
@@ -179,6 +179,69 @@ func (_c *MergeRequestStore_Create_Call) RunAndReturn(run func(ctx context.Conte
179 return _c179 return _c
180 }180 }
181 181
182+// DeleteMergeability provides a mock function for the type MergeRequestStore
183+func (_mock *MergeRequestStore) DeleteMergeability(ctx context.Context, repo domain.RepoName, number int64) error {
184+ ret := _mock.Called(ctx, repo, number)
185+
186+ if len(ret) == 0 {
187+ panic("no return value specified for DeleteMergeability")
188+ }
189+
190+ var r0 error
191+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) error); ok {
192+ r0 = returnFunc(ctx, repo, number)
193+ } else {
194+ r0 = ret.Error(0)
195+ }
196+ return r0
197+}
198+
199+// MergeRequestStore_DeleteMergeability_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteMergeability'
200+type MergeRequestStore_DeleteMergeability_Call struct {
201+ *mock.Call
202+}
203+
204+// DeleteMergeability is a helper method to define mock.On call
205+// - ctx context.Context
206+// - repo domain.RepoName
207+// - number int64
208+func (_e *MergeRequestStore_Expecter) DeleteMergeability(ctx any, repo any, number any) *MergeRequestStore_DeleteMergeability_Call {
209+ return &MergeRequestStore_DeleteMergeability_Call{Call: _e.mock.On("DeleteMergeability", ctx, repo, number)}
210+}
211+
212+func (_c *MergeRequestStore_DeleteMergeability_Call) Run(run func(ctx context.Context, repo domain.RepoName, number int64)) *MergeRequestStore_DeleteMergeability_Call {
213+ _c.Call.Run(func(args mock.Arguments) {
214+ var arg0 context.Context
215+ if args[0] != nil {
216+ arg0 = args[0].(context.Context)
217+ }
218+ var arg1 domain.RepoName
219+ if args[1] != nil {
220+ arg1 = args[1].(domain.RepoName)
221+ }
222+ var arg2 int64
223+ if args[2] != nil {
224+ arg2 = args[2].(int64)
225+ }
226+ run(
227+ arg0,
228+ arg1,
229+ arg2,
230+ )
231+ })
232+ return _c
233+}
234+
235+func (_c *MergeRequestStore_DeleteMergeability_Call) Return(err error) *MergeRequestStore_DeleteMergeability_Call {
236+ _c.Call.Return(err)
237+ return _c
238+}
239+
240+func (_c *MergeRequestStore_DeleteMergeability_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, number int64) error) *MergeRequestStore_DeleteMergeability_Call {
241+ _c.Call.Return(run)
242+ return _c
243+}
244+
182 // Get provides a mock function for the type MergeRequestStore245 // Get provides a mock function for the type MergeRequestStore
183 func (_mock *MergeRequestStore) Get(ctx context.Context, repo domain.RepoName, number int64) (domain.MergeRequest, error) {246 func (_mock *MergeRequestStore) Get(ctx context.Context, repo domain.RepoName, number int64) (domain.MergeRequest, error) {
184 ret := _mock.Called(ctx, repo, number)247 ret := _mock.Called(ctx, repo, number)
@@ -329,6 +392,78 @@ func (_c *MergeRequestStore_GetComment_Call) RunAndReturn(run func(ctx context.C
329 return _c392 return _c
330 }393 }
331 394
395+// GetMergeability provides a mock function for the type MergeRequestStore
396+func (_mock *MergeRequestStore) GetMergeability(ctx context.Context, repo domain.RepoName, number int64) (domain.Mergeability, error) {
397+ ret := _mock.Called(ctx, repo, number)
398+
399+ if len(ret) == 0 {
400+ panic("no return value specified for GetMergeability")
401+ }
402+
403+ var r0 domain.Mergeability
404+ var r1 error
405+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) (domain.Mergeability, error)); ok {
406+ return returnFunc(ctx, repo, number)
407+ }
408+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) domain.Mergeability); ok {
409+ r0 = returnFunc(ctx, repo, number)
410+ } else {
411+ r0 = ret.Get(0).(domain.Mergeability)
412+ }
413+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok {
414+ r1 = returnFunc(ctx, repo, number)
415+ } else {
416+ r1 = ret.Error(1)
417+ }
418+ return r0, r1
419+}
420+
421+// MergeRequestStore_GetMergeability_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetMergeability'
422+type MergeRequestStore_GetMergeability_Call struct {
423+ *mock.Call
424+}
425+
426+// GetMergeability is a helper method to define mock.On call
427+// - ctx context.Context
428+// - repo domain.RepoName
429+// - number int64
430+func (_e *MergeRequestStore_Expecter) GetMergeability(ctx any, repo any, number any) *MergeRequestStore_GetMergeability_Call {
431+ return &MergeRequestStore_GetMergeability_Call{Call: _e.mock.On("GetMergeability", ctx, repo, number)}
432+}
433+
434+func (_c *MergeRequestStore_GetMergeability_Call) Run(run func(ctx context.Context, repo domain.RepoName, number int64)) *MergeRequestStore_GetMergeability_Call {
435+ _c.Call.Run(func(args mock.Arguments) {
436+ var arg0 context.Context
437+ if args[0] != nil {
438+ arg0 = args[0].(context.Context)
439+ }
440+ var arg1 domain.RepoName
441+ if args[1] != nil {
442+ arg1 = args[1].(domain.RepoName)
443+ }
444+ var arg2 int64
445+ if args[2] != nil {
446+ arg2 = args[2].(int64)
447+ }
448+ run(
449+ arg0,
450+ arg1,
451+ arg2,
452+ )
453+ })
454+ return _c
455+}
456+
457+func (_c *MergeRequestStore_GetMergeability_Call) Return(mergeability domain.Mergeability, err error) *MergeRequestStore_GetMergeability_Call {
458+ _c.Call.Return(mergeability, err)
459+ return _c
460+}
461+
462+func (_c *MergeRequestStore_GetMergeability_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, number int64) (domain.Mergeability, error)) *MergeRequestStore_GetMergeability_Call {
463+ _c.Call.Return(run)
464+ return _c
465+}
466+
332 // GetRead provides a mock function for the type MergeRequestStore467 // GetRead provides a mock function for the type MergeRequestStore
333 func (_mock *MergeRequestStore) GetRead(ctx context.Context, repo domain.RepoName, number int64) (domain.ObjectID, error) {468 func (_mock *MergeRequestStore) GetRead(ctx context.Context, repo domain.RepoName, number int64) (domain.ObjectID, error) {
334 ret := _mock.Called(ctx, repo, number)469 ret := _mock.Called(ctx, repo, number)
@@ -845,6 +980,63 @@ func (_c *MergeRequestStore_OpenByTarget_Call) RunAndReturn(run func(ctx context
845 return _c980 return _c
846 }981 }
847 982
983+// PutMergeability provides a mock function for the type MergeRequestStore
984+func (_mock *MergeRequestStore) PutMergeability(ctx context.Context, m domain.Mergeability) error {
985+ ret := _mock.Called(ctx, m)
986+
987+ if len(ret) == 0 {
988+ panic("no return value specified for PutMergeability")
989+ }
990+
991+ var r0 error
992+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Mergeability) error); ok {
993+ r0 = returnFunc(ctx, m)
994+ } else {
995+ r0 = ret.Error(0)
996+ }
997+ return r0
998+}
999+
1000+// MergeRequestStore_PutMergeability_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PutMergeability'
1001+type MergeRequestStore_PutMergeability_Call struct {
1002+ *mock.Call
1003+}
1004+
1005+// PutMergeability is a helper method to define mock.On call
1006+// - ctx context.Context
1007+// - m domain.Mergeability
1008+func (_e *MergeRequestStore_Expecter) PutMergeability(ctx any, m any) *MergeRequestStore_PutMergeability_Call {
1009+ return &MergeRequestStore_PutMergeability_Call{Call: _e.mock.On("PutMergeability", ctx, m)}
1010+}
1011+
1012+func (_c *MergeRequestStore_PutMergeability_Call) Run(run func(ctx context.Context, m domain.Mergeability)) *MergeRequestStore_PutMergeability_Call {
1013+ _c.Call.Run(func(args mock.Arguments) {
1014+ var arg0 context.Context
1015+ if args[0] != nil {
1016+ arg0 = args[0].(context.Context)
1017+ }
1018+ var arg1 domain.Mergeability
1019+ if args[1] != nil {
1020+ arg1 = args[1].(domain.Mergeability)
1021+ }
1022+ run(
1023+ arg0,
1024+ arg1,
1025+ )
1026+ })
1027+ return _c
1028+}
1029+
1030+func (_c *MergeRequestStore_PutMergeability_Call) Return(err error) *MergeRequestStore_PutMergeability_Call {
1031+ _c.Call.Return(err)
1032+ return _c
1033+}
1034+
1035+func (_c *MergeRequestStore_PutMergeability_Call) RunAndReturn(run func(ctx context.Context, m domain.Mergeability) error) *MergeRequestStore_PutMergeability_Call {
1036+ _c.Call.Return(run)
1037+ return _c
1038+}
1039+
848 // SaveComment provides a mock function for the type MergeRequestStore1040 // SaveComment provides a mock function for the type MergeRequestStore
849 func (_mock *MergeRequestStore) SaveComment(ctx context.Context, mr domain.MergeRequest, c domain.Comment) error {1041 func (_mock *MergeRequestStore) SaveComment(ctx context.Context, mr domain.MergeRequest, c domain.Comment) error {
850 ret := _mock.Called(ctx, mr, c)1042 ret := _mock.Called(ctx, mr, c)
A internal/ports/mocks/MergeabilityReader.go +111 -0
@@ -0,0 +1,111 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMergeabilityReader creates a new instance of MergeabilityReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMergeabilityReader(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MergeabilityReader {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &MergeabilityReader{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// MergeabilityReader is an autogenerated mock type for the MergeabilityReader type
38+type MergeabilityReader struct {
39+ mock.Mock
40+}
41+
42+type MergeabilityReader_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *MergeabilityReader) EXPECT() *MergeabilityReader_Expecter {
47+ return &MergeabilityReader_Expecter{mock: &_m.Mock}
48+}
49+
50+// Read provides a mock function for the type MergeabilityReader
51+func (_mock *MergeabilityReader) Read(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID) domain.Mergeability {
52+ ret := _mock.Called(ctx, mr, targetTip)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Read")
56+ }
57+
58+ var r0 domain.Mergeability
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.MergeRequest, domain.ObjectID) domain.Mergeability); ok {
60+ r0 = returnFunc(ctx, mr, targetTip)
61+ } else {
62+ r0 = ret.Get(0).(domain.Mergeability)
63+ }
64+ return r0
65+}
66+
67+// MergeabilityReader_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read'
68+type MergeabilityReader_Read_Call struct {
69+ *mock.Call
70+}
71+
72+// Read is a helper method to define mock.On call
73+// - ctx context.Context
74+// - mr domain.MergeRequest
75+// - targetTip domain.ObjectID
76+func (_e *MergeabilityReader_Expecter) Read(ctx any, mr any, targetTip any) *MergeabilityReader_Read_Call {
77+ return &MergeabilityReader_Read_Call{Call: _e.mock.On("Read", ctx, mr, targetTip)}
78+}
79+
80+func (_c *MergeabilityReader_Read_Call) Run(run func(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID)) *MergeabilityReader_Read_Call {
81+ _c.Call.Run(func(args mock.Arguments) {
82+ var arg0 context.Context
83+ if args[0] != nil {
84+ arg0 = args[0].(context.Context)
85+ }
86+ var arg1 domain.MergeRequest
87+ if args[1] != nil {
88+ arg1 = args[1].(domain.MergeRequest)
89+ }
90+ var arg2 domain.ObjectID
91+ if args[2] != nil {
92+ arg2 = args[2].(domain.ObjectID)
93+ }
94+ run(
95+ arg0,
96+ arg1,
97+ arg2,
98+ )
99+ })
100+ return _c
101+}
102+
103+func (_c *MergeabilityReader_Read_Call) Return(mergeability domain.Mergeability) *MergeabilityReader_Read_Call {
104+ _c.Call.Return(mergeability)
105+ return _c
106+}
107+
108+func (_c *MergeabilityReader_Read_Call) RunAndReturn(run func(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID) domain.Mergeability) *MergeabilityReader_Read_Call {
109+ _c.Call.Return(run)
110+ return _c
111+}
@@ -0,0 +1,111 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMergeabilityReader creates a new instance of MergeabilityReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMergeabilityReader(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MergeabilityReader {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &MergeabilityReader{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// MergeabilityReader is an autogenerated mock type for the MergeabilityReader type
38+type MergeabilityReader struct {
39+ mock.Mock
40+}
41+
42+type MergeabilityReader_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *MergeabilityReader) EXPECT() *MergeabilityReader_Expecter {
47+ return &MergeabilityReader_Expecter{mock: &_m.Mock}
48+}
49+
50+// Read provides a mock function for the type MergeabilityReader
51+func (_mock *MergeabilityReader) Read(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID) domain.Mergeability {
52+ ret := _mock.Called(ctx, mr, targetTip)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Read")
56+ }
57+
58+ var r0 domain.Mergeability
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.MergeRequest, domain.ObjectID) domain.Mergeability); ok {
60+ r0 = returnFunc(ctx, mr, targetTip)
61+ } else {
62+ r0 = ret.Get(0).(domain.Mergeability)
63+ }
64+ return r0
65+}
66+
67+// MergeabilityReader_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read'
68+type MergeabilityReader_Read_Call struct {
69+ *mock.Call
70+}
71+
72+// Read is a helper method to define mock.On call
73+// - ctx context.Context
74+// - mr domain.MergeRequest
75+// - targetTip domain.ObjectID
76+func (_e *MergeabilityReader_Expecter) Read(ctx any, mr any, targetTip any) *MergeabilityReader_Read_Call {
77+ return &MergeabilityReader_Read_Call{Call: _e.mock.On("Read", ctx, mr, targetTip)}
78+}
79+
80+func (_c *MergeabilityReader_Read_Call) Run(run func(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID)) *MergeabilityReader_Read_Call {
81+ _c.Call.Run(func(args mock.Arguments) {
82+ var arg0 context.Context
83+ if args[0] != nil {
84+ arg0 = args[0].(context.Context)
85+ }
86+ var arg1 domain.MergeRequest
87+ if args[1] != nil {
88+ arg1 = args[1].(domain.MergeRequest)
89+ }
90+ var arg2 domain.ObjectID
91+ if args[2] != nil {
92+ arg2 = args[2].(domain.ObjectID)
93+ }
94+ run(
95+ arg0,
96+ arg1,
97+ arg2,
98+ )
99+ })
100+ return _c
101+}
102+
103+func (_c *MergeabilityReader_Read_Call) Return(mergeability domain.Mergeability) *MergeabilityReader_Read_Call {
104+ _c.Call.Return(mergeability)
105+ return _c
106+}
107+
108+func (_c *MergeabilityReader_Read_Call) RunAndReturn(run func(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID) domain.Mergeability) *MergeabilityReader_Read_Call {
109+ _c.Call.Return(run)
110+ return _c
111+}
A internal/ports/mocks/MergeabilityTrigger.go +146 -0
@@ -0,0 +1,146 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMergeabilityTrigger creates a new instance of MergeabilityTrigger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMergeabilityTrigger(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MergeabilityTrigger {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &MergeabilityTrigger{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// MergeabilityTrigger is an autogenerated mock type for the MergeabilityTrigger type
38+type MergeabilityTrigger struct {
39+ mock.Mock
40+}
41+
42+type MergeabilityTrigger_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *MergeabilityTrigger) EXPECT() *MergeabilityTrigger_Expecter {
47+ return &MergeabilityTrigger_Expecter{mock: &_m.Mock}
48+}
49+
50+// OnBranchesMoved provides a mock function for the type MergeabilityTrigger
51+func (_mock *MergeabilityTrigger) OnBranchesMoved(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate) {
52+ _mock.Called(ctx, repo, updates)
53+ return
54+}
55+
56+// MergeabilityTrigger_OnBranchesMoved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnBranchesMoved'
57+type MergeabilityTrigger_OnBranchesMoved_Call struct {
58+ *mock.Call
59+}
60+
61+// OnBranchesMoved is a helper method to define mock.On call
62+// - ctx context.Context
63+// - repo domain.RepoName
64+// - updates []domain.RefUpdate
65+func (_e *MergeabilityTrigger_Expecter) OnBranchesMoved(ctx any, repo any, updates any) *MergeabilityTrigger_OnBranchesMoved_Call {
66+ return &MergeabilityTrigger_OnBranchesMoved_Call{Call: _e.mock.On("OnBranchesMoved", ctx, repo, updates)}
67+}
68+
69+func (_c *MergeabilityTrigger_OnBranchesMoved_Call) Run(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)) *MergeabilityTrigger_OnBranchesMoved_Call {
70+ _c.Call.Run(func(args mock.Arguments) {
71+ var arg0 context.Context
72+ if args[0] != nil {
73+ arg0 = args[0].(context.Context)
74+ }
75+ var arg1 domain.RepoName
76+ if args[1] != nil {
77+ arg1 = args[1].(domain.RepoName)
78+ }
79+ var arg2 []domain.RefUpdate
80+ if args[2] != nil {
81+ arg2 = args[2].([]domain.RefUpdate)
82+ }
83+ run(
84+ arg0,
85+ arg1,
86+ arg2,
87+ )
88+ })
89+ return _c
90+}
91+
92+func (_c *MergeabilityTrigger_OnBranchesMoved_Call) Return() *MergeabilityTrigger_OnBranchesMoved_Call {
93+ _c.Call.Return()
94+ return _c
95+}
96+
97+func (_c *MergeabilityTrigger_OnBranchesMoved_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)) *MergeabilityTrigger_OnBranchesMoved_Call {
98+ _c.Run(run)
99+ return _c
100+}
101+
102+// OnOpened provides a mock function for the type MergeabilityTrigger
103+func (_mock *MergeabilityTrigger) OnOpened(ctx context.Context, mr domain.MergeRequest) {
104+ _mock.Called(ctx, mr)
105+ return
106+}
107+
108+// MergeabilityTrigger_OnOpened_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnOpened'
109+type MergeabilityTrigger_OnOpened_Call struct {
110+ *mock.Call
111+}
112+
113+// OnOpened is a helper method to define mock.On call
114+// - ctx context.Context
115+// - mr domain.MergeRequest
116+func (_e *MergeabilityTrigger_Expecter) OnOpened(ctx any, mr any) *MergeabilityTrigger_OnOpened_Call {
117+ return &MergeabilityTrigger_OnOpened_Call{Call: _e.mock.On("OnOpened", ctx, mr)}
118+}
119+
120+func (_c *MergeabilityTrigger_OnOpened_Call) Run(run func(ctx context.Context, mr domain.MergeRequest)) *MergeabilityTrigger_OnOpened_Call {
121+ _c.Call.Run(func(args mock.Arguments) {
122+ var arg0 context.Context
123+ if args[0] != nil {
124+ arg0 = args[0].(context.Context)
125+ }
126+ var arg1 domain.MergeRequest
127+ if args[1] != nil {
128+ arg1 = args[1].(domain.MergeRequest)
129+ }
130+ run(
131+ arg0,
132+ arg1,
133+ )
134+ })
135+ return _c
136+}
137+
138+func (_c *MergeabilityTrigger_OnOpened_Call) Return() *MergeabilityTrigger_OnOpened_Call {
139+ _c.Call.Return()
140+ return _c
141+}
142+
143+func (_c *MergeabilityTrigger_OnOpened_Call) RunAndReturn(run func(ctx context.Context, mr domain.MergeRequest)) *MergeabilityTrigger_OnOpened_Call {
144+ _c.Run(run)
145+ return _c
146+}
@@ -0,0 +1,146 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMergeabilityTrigger creates a new instance of MergeabilityTrigger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMergeabilityTrigger(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MergeabilityTrigger {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &MergeabilityTrigger{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// MergeabilityTrigger is an autogenerated mock type for the MergeabilityTrigger type
38+type MergeabilityTrigger struct {
39+ mock.Mock
40+}
41+
42+type MergeabilityTrigger_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *MergeabilityTrigger) EXPECT() *MergeabilityTrigger_Expecter {
47+ return &MergeabilityTrigger_Expecter{mock: &_m.Mock}
48+}
49+
50+// OnBranchesMoved provides a mock function for the type MergeabilityTrigger
51+func (_mock *MergeabilityTrigger) OnBranchesMoved(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate) {
52+ _mock.Called(ctx, repo, updates)
53+ return
54+}
55+
56+// MergeabilityTrigger_OnBranchesMoved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnBranchesMoved'
57+type MergeabilityTrigger_OnBranchesMoved_Call struct {
58+ *mock.Call
59+}
60+
61+// OnBranchesMoved is a helper method to define mock.On call
62+// - ctx context.Context
63+// - repo domain.RepoName
64+// - updates []domain.RefUpdate
65+func (_e *MergeabilityTrigger_Expecter) OnBranchesMoved(ctx any, repo any, updates any) *MergeabilityTrigger_OnBranchesMoved_Call {
66+ return &MergeabilityTrigger_OnBranchesMoved_Call{Call: _e.mock.On("OnBranchesMoved", ctx, repo, updates)}
67+}
68+
69+func (_c *MergeabilityTrigger_OnBranchesMoved_Call) Run(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)) *MergeabilityTrigger_OnBranchesMoved_Call {
70+ _c.Call.Run(func(args mock.Arguments) {
71+ var arg0 context.Context
72+ if args[0] != nil {
73+ arg0 = args[0].(context.Context)
74+ }
75+ var arg1 domain.RepoName
76+ if args[1] != nil {
77+ arg1 = args[1].(domain.RepoName)
78+ }
79+ var arg2 []domain.RefUpdate
80+ if args[2] != nil {
81+ arg2 = args[2].([]domain.RefUpdate)
82+ }
83+ run(
84+ arg0,
85+ arg1,
86+ arg2,
87+ )
88+ })
89+ return _c
90+}
91+
92+func (_c *MergeabilityTrigger_OnBranchesMoved_Call) Return() *MergeabilityTrigger_OnBranchesMoved_Call {
93+ _c.Call.Return()
94+ return _c
95+}
96+
97+func (_c *MergeabilityTrigger_OnBranchesMoved_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)) *MergeabilityTrigger_OnBranchesMoved_Call {
98+ _c.Run(run)
99+ return _c
100+}
101+
102+// OnOpened provides a mock function for the type MergeabilityTrigger
103+func (_mock *MergeabilityTrigger) OnOpened(ctx context.Context, mr domain.MergeRequest) {
104+ _mock.Called(ctx, mr)
105+ return
106+}
107+
108+// MergeabilityTrigger_OnOpened_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnOpened'
109+type MergeabilityTrigger_OnOpened_Call struct {
110+ *mock.Call
111+}
112+
113+// OnOpened is a helper method to define mock.On call
114+// - ctx context.Context
115+// - mr domain.MergeRequest
116+func (_e *MergeabilityTrigger_Expecter) OnOpened(ctx any, mr any) *MergeabilityTrigger_OnOpened_Call {
117+ return &MergeabilityTrigger_OnOpened_Call{Call: _e.mock.On("OnOpened", ctx, mr)}
118+}
119+
120+func (_c *MergeabilityTrigger_OnOpened_Call) Run(run func(ctx context.Context, mr domain.MergeRequest)) *MergeabilityTrigger_OnOpened_Call {
121+ _c.Call.Run(func(args mock.Arguments) {
122+ var arg0 context.Context
123+ if args[0] != nil {
124+ arg0 = args[0].(context.Context)
125+ }
126+ var arg1 domain.MergeRequest
127+ if args[1] != nil {
128+ arg1 = args[1].(domain.MergeRequest)
129+ }
130+ run(
131+ arg0,
132+ arg1,
133+ )
134+ })
135+ return _c
136+}
137+
138+func (_c *MergeabilityTrigger_OnOpened_Call) Return() *MergeabilityTrigger_OnOpened_Call {
139+ _c.Call.Return()
140+ return _c
141+}
142+
143+func (_c *MergeabilityTrigger_OnOpened_Call) RunAndReturn(run func(ctx context.Context, mr domain.MergeRequest)) *MergeabilityTrigger_OnOpened_Call {
144+ _c.Run(run)
145+ return _c
146+}
M internal/ports/ports.go +35 -0
@@ -380,6 +380,17 @@ type MergeRequestStore interface {
380380 // domain.ErrConflict when concurrent writes to the repository keep it
381381 // from landing.
382382 SetRead(ctx context.Context, repo domain.RepoName, number int64, head domain.ObjectID) error
383+ // GetMergeability returns the stored mergeability of an open merge request,
384+ // or domain.ErrNotFound when there is none.
385+ GetMergeability(ctx context.Context, repo domain.RepoName, number int64) (domain.Mergeability, error)
386+ // PutMergeability stores the mergeability. A Version of 0 requires that
387+ // none is stored, otherwise the stored one must be unchanged since it was
388+ // read (domain.ErrConflict). It returns domain.ErrNotFound when the merge
389+ // request is not stored or not open, so that a late write never resurrects
390+ // a record.
391+ PutMergeability(ctx context.Context, m domain.Mergeability) error
392+ // DeleteMergeability removes the stored mergeability; no error when absent.
393+ DeleteMergeability(ctx context.Context, repo domain.RepoName, number int64) error
383394 }
384395
385396 // MergeGit is what merge requests need from the Git repositories beyond
@@ -415,6 +426,30 @@ type MergeGit interface {
415426 // DeleteBranch deletes refs/heads/<branch> if it still points to old. It
416427 // returns domain.ErrConflict when the branch moved or is gone.
417428 DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error
429+ // MergeTree merges head into base without creating a commit. When the
430+ // merge is not clean (or the commits share no history) Clean is false and
431+ // ConflictPaths lists at most domain.MaxConflictPaths paths.
432+ MergeTree(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID) (domain.MergeTreeResult, error)
433+ // CountBehind counts the commits of target that head lacks, at most max;
434+ // capped is true when there are more.
435+ CountBehind(ctx context.Context, repo domain.RepoName, head, target domain.ObjectID, max int) (n int, capped bool, err error)
436+}
437+
438+// MergeabilityTrigger queues mergeability checks. It only queues the work and
439+// returns.
440+type MergeabilityTrigger interface {
441+ // OnBranchesMoved queues the open merge requests whose source or target
442+ // branch was moved or deleted by updates.
443+ OnBranchesMoved(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)
444+ // OnOpened queues a newly opened merge request.
445+ OnOpened(ctx context.Context, mr domain.MergeRequest)
446+}
447+
448+// MergeabilityReader reads the mergeability of open merge requests.
449+type MergeabilityReader interface {
450+ // Read returns the stored mergeability for mr and targetTip, or a
451+ // "checking" one (and queues a check) when there is none or it is stale.
452+ Read(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID) domain.Mergeability
418453 }
419454
420455 // CommitSigner signs the commits ermite creates (ADR 0022). The private key
@@ -380,6 +380,17 @@ type MergeRequestStore interface {
380 // domain.ErrConflict when concurrent writes to the repository keep it380 // domain.ErrConflict when concurrent writes to the repository keep it
381 // from landing.381 // from landing.
382 SetRead(ctx context.Context, repo domain.RepoName, number int64, head domain.ObjectID) error382 SetRead(ctx context.Context, repo domain.RepoName, number int64, head domain.ObjectID) error
383+ // GetMergeability returns the stored mergeability of an open merge request,
384+ // or domain.ErrNotFound when there is none.
385+ GetMergeability(ctx context.Context, repo domain.RepoName, number int64) (domain.Mergeability, error)
386+ // PutMergeability stores the mergeability. A Version of 0 requires that
387+ // none is stored, otherwise the stored one must be unchanged since it was
388+ // read (domain.ErrConflict). It returns domain.ErrNotFound when the merge
389+ // request is not stored or not open, so that a late write never resurrects
390+ // a record.
391+ PutMergeability(ctx context.Context, m domain.Mergeability) error
392+ // DeleteMergeability removes the stored mergeability; no error when absent.
393+ DeleteMergeability(ctx context.Context, repo domain.RepoName, number int64) error
383 }394 }
384 395
385 // MergeGit is what merge requests need from the Git repositories beyond396 // MergeGit is what merge requests need from the Git repositories beyond
@@ -415,6 +426,30 @@ type MergeGit interface {
415 // DeleteBranch deletes refs/heads/<branch> if it still points to old. It426 // DeleteBranch deletes refs/heads/<branch> if it still points to old. It
416 // returns domain.ErrConflict when the branch moved or is gone.427 // returns domain.ErrConflict when the branch moved or is gone.
417 DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error428 DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error
429+ // MergeTree merges head into base without creating a commit. When the
430+ // merge is not clean (or the commits share no history) Clean is false and
431+ // ConflictPaths lists at most domain.MaxConflictPaths paths.
432+ MergeTree(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID) (domain.MergeTreeResult, error)
433+ // CountBehind counts the commits of target that head lacks, at most max;
434+ // capped is true when there are more.
435+ CountBehind(ctx context.Context, repo domain.RepoName, head, target domain.ObjectID, max int) (n int, capped bool, err error)
436+}
437+
438+// MergeabilityTrigger queues mergeability checks. It only queues the work and
439+// returns.
440+type MergeabilityTrigger interface {
441+ // OnBranchesMoved queues the open merge requests whose source or target
442+ // branch was moved or deleted by updates.
443+ OnBranchesMoved(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)
444+ // OnOpened queues a newly opened merge request.
445+ OnOpened(ctx context.Context, mr domain.MergeRequest)
446+}
447+
448+// MergeabilityReader reads the mergeability of open merge requests.
449+type MergeabilityReader interface {
450+ // Read returns the stored mergeability for mr and targetTip, or a
451+ // "checking" one (and queues a check) when there is none or it is stale.
452+ Read(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID) domain.Mergeability
418 }453 }
419 454
420 // CommitSigner signs the commits ermite creates (ADR 0022). The private key455 // CommitSigner signs the commits ermite creates (ADR 0022). The private key
M proto/ermite/storage/merge_request.proto +28 -0
@@ -15,6 +15,7 @@ enum MergeMode {
1515 MERGE_MODE_UNSPECIFIED = 0;
1616 MERGE_MODE_FAST_FORWARD = 1;
1717 MERGE_MODE_SQUASH = 2;
18+ MERGE_MODE_MERGE = 3;
1819 }
1920
2021 // RepoSettings are the merge settings of a repository, stored inside Repo.
@@ -24,6 +25,11 @@ message RepoSettings {
2425 bool failing_checks_block = 2;
2526 // Who may write issues; unspecified reads as off.
2627 IssuesMode issues = 3;
28+ // Set on every write since merge commits exist: the modes are the explicit
29+ // choice of the owner. When false (settings stored before), exactly
30+ // {fast-forward, squash} was the old default and gains MERGE_MODE_MERGE on
31+ // read.
32+ bool merge_modes_v2 = 4;
2733 }
2834
2935 // IssuesMode says who may write issues in a repository.
@@ -104,3 +110,25 @@ message Comment {
104110 message Sequence {
105111 int64 last = 1;
106112 }
113+
114+// MergeabilityState is the outcome of a mergeability check. "Checking" is
115+// never stored.
116+enum MergeabilityState {
117+ MERGEABILITY_STATE_UNSPECIFIED = 0;
118+ MERGEABILITY_STATE_UP_TO_DATE = 1;
119+ MERGEABILITY_STATE_BEHIND = 2;
120+ MERGEABILITY_STATE_CONFLICT = 3;
121+}
122+
123+// Mergeability is stored under the key "mrmerge/<repo>/<number:010d>": a
124+// derived, discardable record of an open merge request.
125+message Mergeability {
126+ MergeabilityState state = 1;
127+ string target_id = 2;
128+ string head_id = 3;
129+ int32 behind = 4;
130+ bool behind_capped = 5;
131+ repeated string conflict_paths = 6;
132+ bool conflict_paths_truncated = 7;
133+ google.protobuf.Timestamp checked_at = 8;
134+}
@@ -15,6 +15,7 @@ enum MergeMode {
15 MERGE_MODE_UNSPECIFIED = 0;15 MERGE_MODE_UNSPECIFIED = 0;
16 MERGE_MODE_FAST_FORWARD = 1;16 MERGE_MODE_FAST_FORWARD = 1;
17 MERGE_MODE_SQUASH = 2;17 MERGE_MODE_SQUASH = 2;
18+ MERGE_MODE_MERGE = 3;
18 }19 }
19 20
20 // RepoSettings are the merge settings of a repository, stored inside Repo.21 // RepoSettings are the merge settings of a repository, stored inside Repo.
@@ -24,6 +25,11 @@ message RepoSettings {
24 bool failing_checks_block = 2;25 bool failing_checks_block = 2;
25 // Who may write issues; unspecified reads as off.26 // Who may write issues; unspecified reads as off.
26 IssuesMode issues = 3;27 IssuesMode issues = 3;
28+ // Set on every write since merge commits exist: the modes are the explicit
29+ // choice of the owner. When false (settings stored before), exactly
30+ // {fast-forward, squash} was the old default and gains MERGE_MODE_MERGE on
31+ // read.
32+ bool merge_modes_v2 = 4;
27 }33 }
28 34
29 // IssuesMode says who may write issues in a repository.35 // IssuesMode says who may write issues in a repository.
@@ -104,3 +110,25 @@ message Comment {
104 message Sequence {110 message Sequence {
105 int64 last = 1;111 int64 last = 1;
106 }112 }
113+
114+// MergeabilityState is the outcome of a mergeability check. "Checking" is
115+// never stored.
116+enum MergeabilityState {
117+ MERGEABILITY_STATE_UNSPECIFIED = 0;
118+ MERGEABILITY_STATE_UP_TO_DATE = 1;
119+ MERGEABILITY_STATE_BEHIND = 2;
120+ MERGEABILITY_STATE_CONFLICT = 3;
121+}
122+
123+// Mergeability is stored under the key "mrmerge/<repo>/<number:010d>": a
124+// derived, discardable record of an open merge request.
125+message Mergeability {
126+ MergeabilityState state = 1;
127+ string target_id = 2;
128+ string head_id = 3;
129+ int32 behind = 4;
130+ bool behind_capped = 5;
131+ repeated string conflict_paths = 6;
132+ bool conflict_paths_truncated = 7;
133+ google.protobuf.Timestamp checked_at = 8;
134+}

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 42

↑↓ moveenter openesc close