v0.31.0 d5e0505

mergerequest_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain_test
4
5import (
6	"strconv"
7	"strings"
8	"testing"
9	"time"
10
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/require"
13
14	"bnema.dev/ermite/internal/domain"
15)
16
17func oid(t *testing.T, c byte) domain.ObjectID {
18	t.Helper()
19	id, err := domain.ParseObjectID(strings.Repeat(string(c), domain.ObjectIDLen))
20	require.NoError(t, err)
21	return id
22}
23
24func TestDefaultRepoSettings(t *testing.T) {
25	t.Parallel()
26	s := domain.DefaultRepoSettings()
27	assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge}, s.AllowedMergeModes)
28	assert.True(t, s.FailingChecksBlock)
29	assert.True(t, s.Allows(domain.MergeModeSquash))
30	assert.False(t, domain.RepoSettings{}.Allows(domain.MergeModeSquash))
31	assert.True(t, domain.RepoSettings{}.IsZero())
32	assert.False(t, s.IsZero())
33
34	// The default list is a copy: callers cannot alter the next default.
35	s.AllowedMergeModes[0] = domain.MergeModeSquash
36	assert.Equal(t, domain.MergeModeFastForward, domain.DefaultRepoSettings().AllowedMergeModes[0])
37}
38
39func TestNormalizeMergeModes(t *testing.T) {
40	t.Parallel()
41	got, err := domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash, domain.MergeModeFastForward, domain.MergeModeSquash})
42	require.NoError(t, err)
43	assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, got)
44
45	got, err = domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash})
46	require.NoError(t, err)
47	assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, got)
48
49	_, err = domain.NormalizeMergeModes(nil)
50	assert.ErrorIs(t, err, domain.ErrInvalid)
51	_, err = domain.NormalizeMergeModes([]domain.MergeMode{"rebase"})
52	assert.ErrorIs(t, err, domain.ErrInvalid)
53}
54
55func TestParseMergeMode(t *testing.T) {
56	t.Parallel()
57	m, err := domain.ParseMergeMode("fast-forward")
58	require.NoError(t, err)
59	assert.Equal(t, domain.MergeModeFastForward, m)
60	m, err = domain.ParseMergeMode("merge")
61	require.NoError(t, err)
62	assert.Equal(t, domain.MergeModeMerge, m)
63	_, err = domain.ParseMergeMode("rebase")
64	assert.ErrorIs(t, err, domain.ErrInvalid)
65}
66
67func hunkDiff() domain.Diff {
68	return domain.Diff{Files: []domain.FileDiff{
69		{OldPath: "a.txt", NewPath: "a.txt", Status: domain.FileModified, Hunks: []domain.Hunk{{
70			Lines: []domain.Line{
71				{Kind: domain.LineContext, OldNumber: 4, NewNumber: 4},
72				{Kind: domain.LineDeletion, OldNumber: 5},
73				{Kind: domain.LineAddition, NewNumber: 5},
74				{Kind: domain.LineAddition, NewNumber: 6},
75			},
76		}}},
77		{OldPath: "gone.txt", Status: domain.FileDeleted, Hunks: []domain.Hunk{{
78			Lines: []domain.Line{{Kind: domain.LineDeletion, OldNumber: 1}},
79		}}},
80		{NewPath: "big.txt", Status: domain.FileAdded, Omitted: domain.OmitLarge},
81		{OldPath: "old.txt", NewPath: "new.txt", Status: domain.FileRenamed, Hunks: []domain.Hunk{{
82			Lines: []domain.Line{{Kind: domain.LineAddition, NewNumber: 2}},
83		}}},
84	}}
85}
86
87func TestDiffHasLine(t *testing.T) {
88	t.Parallel()
89	d := hunkDiff()
90	tests := []struct {
91		name string
92		path string
93		side domain.DiffSide
94		line int
95		want bool
96	}{
97		{"context on the new side", "a.txt", domain.SideNew, 4, true},
98		{"context on the old side", "a.txt", domain.SideOld, 4, true},
99		{"deletion on the old side", "a.txt", domain.SideOld, 5, true},
100		{"addition on the new side", "a.txt", domain.SideNew, 6, true},
101		{"addition is not on the old side", "a.txt", domain.SideOld, 6, false},
102		{"new line 5 is the addition, not the deletion", "a.txt", domain.SideNew, 5, true},
103		{"line outside the hunk", "a.txt", domain.SideNew, 99, false},
104		{"line zero", "a.txt", domain.SideNew, 0, false},
105		{"negative line", "a.txt", domain.SideNew, -1, false},
106		{"no side", "a.txt", domain.SideNone, 4, false},
107		{"unknown path", "nope.txt", domain.SideNew, 4, false},
108		{"empty path", "", domain.SideNew, 4, false},
109		{"deleted file by its old path", "gone.txt", domain.SideOld, 1, true},
110		{"deleted file has no new side", "gone.txt", domain.SideNew, 1, false},
111		{"truncated file has no lines", "big.txt", domain.SideNew, 1, false},
112		{"renamed file by its new path", "new.txt", domain.SideNew, 2, true},
113		{"renamed file by its old path", "old.txt", domain.SideNew, 2, false},
114	}
115	for _, tc := range tests {
116		t.Run(tc.name, func(t *testing.T) {
117			t.Parallel()
118			assert.Equal(t, tc.want, d.HasLine(tc.path, tc.side, tc.line))
119		})
120	}
121}
122
123func sig(name, email string, sec int64) domain.Signature {
124	return domain.Signature{Name: name, Email: email, Time: time.Unix(sec, 0).UTC()}
125}
126
127func TestNewSquashCommit(t *testing.T) {
128	t.Parallel()
129	title, err := domain.ParseMRTitle("Add search")
130	require.NoError(t, err)
131	mr := domain.MergeRequest{Number: 7, Title: title}
132	// Newest first, as Git lists them.
133	commits := []domain.Commit{
134		{Summary: "fix typo", Author: sig("Bob", "bob@example.org", 300)},
135		{Summary: "add tests", Author: sig("Ada", "ADA@example.org", 200)},
136		{Summary: "add search", Author: sig("Ada", "ada@example.org", 100)},
137		{Summary: "wip", Author: sig("Cy", "cy@example.org", 150)},
138		{Summary: "polish", Author: sig("Bob", "bob@example.org", 160)},
139	}
140	committer := domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}
141	at := time.Unix(1_800_000_000, 0)
142
143	c := domain.NewSquashCommit(mr, oid(t, 'a'), oid(t, 'b'), commits, committer, at)
144
145	// The author is the author of the oldest commit (the last one listed).
146	assert.Equal(t, sig("Bob", "bob@example.org", 160), c.Author)
147	assert.Equal(t, domain.Signature{Name: "ermite", Email: "ermite@git.example.org", Time: at.UTC()}, c.Committer)
148	assert.Equal(t, "Add search (!7)\n"+
149		"\n"+
150		"- polish\n- wip\n- add search\n- add tests\n- fix typo\n"+
151		"\n"+
152		"Co-authored-by: Cy <cy@example.org>\n"+
153		"Co-authored-by: Ada <ada@example.org>\n", c.Message)
154
155	want := "tree " + strings.Repeat("a", 40) + "\n" +
156		"parent " + strings.Repeat("b", 40) + "\n" +
157		"author Bob <bob@example.org> 160 +0000\n" +
158		"committer ermite <ermite@git.example.org> 1800000000 +0000\n" +
159		"\n" + c.Message
160	assert.Equal(t, want, string(c.Payload()))
161}
162
163func TestSquashCommitSigned(t *testing.T) {
164	t.Parallel()
165	title, err := domain.ParseMRTitle("T")
166	require.NoError(t, err)
167	c := domain.NewSquashCommit(domain.MergeRequest{Number: 1, Title: title}, oid(t, 'a'), oid(t, 'b'),
168		[]domain.Commit{{Summary: "s", Author: sig("A", "a@x.org", 1)}}, domain.Identity{Name: "e", Email: "e@x.org"}, time.Unix(2, 0))
169
170	got := string(c.Signed("-----BEGIN SSH SIGNATURE-----\nAAAA\nBBBB\n-----END SSH SIGNATURE-----\n"))
171	assert.Equal(t, "tree "+strings.Repeat("a", 40)+"\n"+
172		"parent "+strings.Repeat("b", 40)+"\n"+
173		"author A <a@x.org> 1 +0000\n"+
174		"committer e <e@x.org> 2 +0000\n"+
175		"gpgsig -----BEGIN SSH SIGNATURE-----\n"+
176		" AAAA\n"+
177		" BBBB\n"+
178		" -----END SSH SIGNATURE-----\n"+
179		"\n"+c.Message, got)
180}
181
182func TestSquashCommitCleansIdentitiesAndSummaries(t *testing.T) {
183	t.Parallel()
184	title, err := domain.ParseMRTitle("T")
185	require.NoError(t, err)
186	commits := []domain.Commit{
187		{Summary: "second", Author: sig("Eve <evil@x.org>\nCo-authored-by: Z", "eve@x.org", 2)},
188		{Summary: "first\x1b[0m", Author: sig("Ada", "ada@x.org", 1)},
189	}
190	c := domain.NewSquashCommit(domain.MergeRequest{Number: 2, Title: title}, oid(t, 'a'), oid(t, 'b'), commits,
191		domain.Identity{Name: "e", Email: "e@x.org"}, time.Unix(3, 0))
192	assert.Contains(t, c.Message, "- first [0m\n")
193	assert.Contains(t, c.Message, "\nCo-authored-by: Eve evil@x.orgCo-authored-by: Z <eve@x.org>\n")
194	assert.NotContains(t, c.Message, "\nCo-authored-by: Z")
195}
196
197func TestSigningKeyInfoAllowedSigners(t *testing.T) {
198	t.Parallel()
199	i := domain.SigningKeyInfo{
200		Key:       domain.SigningPublicKey{AuthorizedKey: "ssh-ed25519 AAAA"},
201		Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"},
202	}
203	assert.Equal(t, "ermite@git.example.org ssh-ed25519 AAAA", i.AllowedSigners())
204}
205
206func TestParseBranchName(t *testing.T) {
207	t.Parallel()
208	for _, ok := range []string{"main", "feat/x", "v1.2", "a-b_c"} {
209		got, err := domain.ParseBranchName(ok)
210		require.NoError(t, err, ok)
211		assert.Equal(t, ok, got)
212	}
213	for _, bad := range []string{"", "refs/heads/main", "-x", "a..b", "a b", "x/", "a:b", "x.lock", "a\x00b"} {
214		_, err := domain.ParseBranchName(bad)
215		assert.ErrorIs(t, err, domain.ErrInvalidRevision, "%q", bad)
216	}
217}
218
219// A title that starts with "WIP:" or "[WIP]", in any case, is a draft; ready
220// drops the prefix and the spaces after it.
221func TestMergeRequestDraft(t *testing.T) {
222	for _, tc := range []struct {
223		title, ready string
224		draft        bool
225	}{
226		{"WIP: feat: drag and drop", "feat: drag and drop", true},
227		{"wip:fix", "fix", true},
228		{"  [WIP]  tidy", "tidy", true},
229		{"WIP: [wip] both", "both", true}, // every prefix goes
230		{"feat: wip: later", "", false},
231		{"WIPE the cache", "", false},
232		{"WIP:", "", true}, // a draft, but nothing would be left
233	} {
234		title, err := domain.ParseMRTitle(tc.title)
235		require.NoError(t, err)
236		assert.Equal(t, tc.draft, domain.MergeRequest{Title: title}.IsDraft(), tc.title)
237		ready, ok := domain.ReadyTitle(title)
238		assert.Equal(t, tc.ready != "", ok, tc.title)
239		assert.Equal(t, tc.ready, ready.String(), tc.title)
240	}
241}
242
243func TestNoteVerdicts(t *testing.T) {
244	head, old := oid(t, 'a'), oid(t, 'b')
245	for _, tc := range []struct {
246		text    string
247		verdict domain.Verdict
248	}{{"", domain.VerdictNone}, {"approve", domain.VerdictApprove}, {"changes_requested", domain.VerdictChangesRequested}} {
249		v, err := domain.ParseVerdict(tc.text)
250		require.NoError(t, err)
251		assert.Equal(t, tc.verdict, v)
252		assert.Equal(t, tc.text, v.String())
253	}
254	_, err := domain.ParseVerdict("unknown")
255	require.ErrorIs(t, err, domain.ErrInvalid)
256	comments := []domain.Comment{{ID: 3, Author: "a", Verdict: domain.VerdictApprove, CommitID: old, Deleted: true}, {ID: 1, Author: "a", Verdict: domain.VerdictChangesRequested}, {ID: 2, Author: "b", Verdict: domain.VerdictChangesRequested, CommitID: old}, {ID: 4, Author: "a"}}
257	for _, h := range []domain.ObjectID{head, {}} {
258		got := domain.CurrentVerdicts(comments, h)
259		require.Len(t, got, 2)
260		assert.Equal(t, int64(2), got[0].CommentID)
261		assert.False(t, got[0].Stale)
262		assert.Equal(t, int64(3), got[1].CommentID)
263		assert.True(t, got[1].Stale)
264	}
265	comments[0].CommitID = head
266	assert.False(t, domain.CurrentVerdicts(comments, head)[1].Stale)
267	assert.True(t, (domain.Comment{ReplyTo: 1}).IsReply())
268	assert.True(t, (domain.Comment{StartLine: 1}).IsRange())
269}
270
271func TestReviewScopeOwnerRights(t *testing.T) {
272	var old []domain.Scope
273	for _, s := range domain.AllScopes() {
274		if s != domain.ScopeMRReview {
275			old = append(old, s)
276		}
277	}
278	assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: old}).HasOwnerRights())
279	assert.True(t, domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes()}).HasOwnerRights())
280	s, err := domain.ParseScope("mr:review")
281	require.NoError(t, err)
282	assert.Equal(t, domain.ScopeMRReview, s)
283	for _, tc := range []struct {
284		p    domain.Principal
285		want string
286	}{{domain.Root(), "root"}, {domain.Owner(), "owner"}, {domain.Anonymous(), "owner"}, {domain.TokenPrincipal(domain.Token{Label: "bot"}), "bot"}} {
287		assert.Equal(t, tc.want, domain.AuthorName(tc.p))
288	}
289}
290
291func 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
307func 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}
333
334func TestRepoSettingsAllowAutoMergeDefaultOff(t *testing.T) {
335	t.Parallel()
336	assert.False(t, domain.DefaultRepoSettings().AllowAutoMerge)
337	assert.False(t, domain.RepoSettings{}.AllowAutoMerge)
338}

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