v0.22.0 4c072b1

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		assert.Equal(t, head, r.branchHead("agent/claude/base"))
71		_, err = r.git.BranchHead(r.ctx, r.repo, "agent/claude/feat")
72		assert.ErrorIs(t, err, domain.ErrNotFound)
73	})
74
75	t.Run("deleting a source outside the prefixes is forbidden, merging without deleting is not", func(t *testing.T) {
76		t.Parallel()
77		r := newMRRig(t)
78		r.seed()
79		r.sh("checkout", "-q", "-B", "agent/claude/base", "main")
80		r.push("agent/claude/base")
81		base := r.branchHead("agent/claude/base")
82		r.sh("checkout", "-q", "-B", "other", "agent/claude/base")
83		head := r.commit(author, "o.txt", "o\n", "add o")
84		r.push("other")
85		mr, err := r.mrs.Create(r.ctx, branchMerger, r.repo, ports.CreateMergeRequestParams{
86			Title: "other", Source: "other", Target: "agent/claude/base",
87		})
88		require.NoError(t, err)
89
90		_, err = r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
91		require.ErrorIs(t, err, domain.ErrForbidden)
92		assert.Contains(t, err.Error(), "this token may not write other")
93		assert.Equal(t, base, r.branchHead("agent/claude/base"), "not merged")
94		assert.Equal(t, head, r.branchHead("other"), "the source stays")
95
96		res, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head})
97		require.NoError(t, err)
98		assert.False(t, res.SourceDeleted)
99		assert.Equal(t, head, r.branchHead("agent/claude/base"))
100		assert.Equal(t, head, r.branchHead("other"), "the source stays")
101	})
102
103	t.Run("a squash into main is forbidden too", func(t *testing.T) {
104		t.Parallel()
105		r := newMRRig(t)
106		main := r.seed()
107		head := r.feature("agent/claude/feat")
108		mr := r.open("agent/claude/feat")
109		_, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash})
110		require.ErrorIs(t, err, domain.ErrForbidden)
111		assert.Equal(t, main, r.branchHead("main"))
112	})
113}

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