v0.33.0 cf26365

mergerequests_branches_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6	"testing"
7
8	"github.com/stretchr/testify/assert"
9	"github.com/stretchr/testify/require"
10
11	"bnema.dev/ermite/internal/domain"
12	"bnema.dev/ermite/internal/ports"
13)
14
15// branchMerger holds mr:merge but may only write the branches under
16// agent/claude/.
17var branchMerger = domain.TokenPrincipal(domain.Token{
18	ID: "bm", Label: "claude", BranchPrefixes: []string{"agent/claude/"},
19	Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeMRWrite, domain.ScopeMRMerge},
20})
21
22// AC5: a merge writes the target, and the source when it is deleted; a token
23// limited to branch prefixes may do neither outside them.
24func TestMergeWithBranchLimitedToken(t *testing.T) {
25	t.Parallel()
26
27	t.Run("into main is forbidden and nothing moves", func(t *testing.T) {
28		t.Parallel()
29		r := newMRRig(t)
30		main := r.seed()
31		head := r.feature("agent/claude/feat")
32		mr := r.open("agent/claude/feat") // targets main
33		r.drain()
34
35		for _, del := range []bool{false, true} {
36			_, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: del})
37			require.ErrorIs(t, err, domain.ErrForbidden)
38			assert.Contains(t, err.Error(), "this token may not write main")
39		}
40		assert.Equal(t, main, r.branchHead("main"), "main did not move")
41		assert.Equal(t, head, r.branchHead("agent/claude/feat"), "the source stays")
42		d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{})
43		require.NoError(t, err)
44		assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State)
45		assert.Empty(t, r.drain(), "no event")
46
47		// The same merge by an unlimited token works.
48		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
49		require.NoError(t, err)
50		assert.Equal(t, head, r.branchHead("main"))
51	})
52
53	t.Run("into a branch under the prefixes is allowed, deleting the source too", func(t *testing.T) {
54		t.Parallel()
55		r := newMRRig(t)
56		r.seed()
57		r.sh("checkout", "-q", "-B", "agent/claude/base", "main")
58		r.push("agent/claude/base")
59		r.sh("checkout", "-q", "-B", "agent/claude/feat", "agent/claude/base")
60		head := r.commit(author, "f.txt", "f\n", "add f")
61		r.push("agent/claude/feat")
62		mr, err := r.mrs.Create(r.ctx, branchMerger, r.repo, ports.CreateMergeRequestParams{
63			Title: "feat", Source: "agent/claude/feat", Target: "agent/claude/base",
64		})
65		require.NoError(t, err)
66
67		res, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
68		require.NoError(t, err)
69		assert.True(t, res.SourceDeleted)
70		r.mrs.Wait(r.ctx) // the deletion runs after Merge returned
71		assert.Equal(t, head, r.branchHead("agent/claude/base"))
72		_, err = r.git.BranchHead(r.ctx, r.repo, "agent/claude/feat")
73		assert.ErrorIs(t, err, domain.ErrNotFound)
74	})
75
76	t.Run("deleting a source outside the prefixes is forbidden, merging without deleting is not", func(t *testing.T) {
77		t.Parallel()
78		r := newMRRig(t)
79		r.seed()
80		r.sh("checkout", "-q", "-B", "agent/claude/base", "main")
81		r.push("agent/claude/base")
82		base := r.branchHead("agent/claude/base")
83		r.sh("checkout", "-q", "-B", "other", "agent/claude/base")
84		head := r.commit(author, "o.txt", "o\n", "add o")
85		r.push("other")
86		mr, err := r.mrs.Create(r.ctx, branchMerger, r.repo, ports.CreateMergeRequestParams{
87			Title: "other", Source: "other", Target: "agent/claude/base",
88		})
89		require.NoError(t, err)
90
91		_, err = r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
92		require.ErrorIs(t, err, domain.ErrForbidden)
93		assert.Contains(t, err.Error(), "this token may not write other")
94		assert.Equal(t, base, r.branchHead("agent/claude/base"), "not merged")
95		assert.Equal(t, head, r.branchHead("other"), "the source stays")
96
97		res, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head})
98		require.NoError(t, err)
99		assert.False(t, res.SourceDeleted)
100		assert.Equal(t, head, r.branchHead("agent/claude/base"))
101		assert.Equal(t, head, r.branchHead("other"), "the source stays")
102	})
103
104	t.Run("a squash into main is forbidden too", func(t *testing.T) {
105		t.Parallel()
106		r := newMRRig(t)
107		main := r.seed()
108		head := r.feature("agent/claude/feat")
109		mr := r.open("agent/claude/feat")
110		_, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash})
111		require.ErrorIs(t, err, domain.ErrForbidden)
112		assert.Equal(t, main, r.branchHead("main"))
113	})
114}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 44

↑↓ moveenter openesc close