v0.12.0 dccd128

mergerequest_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain_test
4
5import (
6	"strings"
7	"testing"
8	"time"
9
10	"github.com/stretchr/testify/assert"
11	"github.com/stretchr/testify/require"
12
13	"bnema.dev/ermite/internal/domain"
14)
15
16func oid(t *testing.T, c byte) domain.ObjectID {
17	t.Helper()
18	id, err := domain.ParseObjectID(strings.Repeat(string(c), domain.ObjectIDLen))
19	require.NoError(t, err)
20	return id
21}
22
23func TestDefaultRepoSettings(t *testing.T) {
24	t.Parallel()
25	s := domain.DefaultRepoSettings()
26	assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, s.AllowedMergeModes)
27	assert.True(t, s.FailingChecksBlock)
28	assert.True(t, s.Allows(domain.MergeModeSquash))
29	assert.False(t, domain.RepoSettings{}.Allows(domain.MergeModeSquash))
30	assert.True(t, domain.RepoSettings{}.IsZero())
31	assert.False(t, s.IsZero())
32
33	// The default list is a copy: callers cannot alter the next default.
34	s.AllowedMergeModes[0] = domain.MergeModeSquash
35	assert.Equal(t, domain.MergeModeFastForward, domain.DefaultRepoSettings().AllowedMergeModes[0])
36}
37
38func TestNormalizeMergeModes(t *testing.T) {
39	t.Parallel()
40	got, err := domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash, domain.MergeModeFastForward, domain.MergeModeSquash})
41	require.NoError(t, err)
42	assert.Equal(t, domain.MergeModes(), got)
43
44	got, err = domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash})
45	require.NoError(t, err)
46	assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, got)
47
48	_, err = domain.NormalizeMergeModes(nil)
49	assert.ErrorIs(t, err, domain.ErrInvalid)
50	_, err = domain.NormalizeMergeModes([]domain.MergeMode{"rebase"})
51	assert.ErrorIs(t, err, domain.ErrInvalid)
52}
53
54func TestParseMergeMode(t *testing.T) {
55	t.Parallel()
56	m, err := domain.ParseMergeMode("fast-forward")
57	require.NoError(t, err)
58	assert.Equal(t, domain.MergeModeFastForward, m)
59	_, err = domain.ParseMergeMode("merge")
60	assert.ErrorIs(t, err, domain.ErrInvalid)
61}
62
63func hunkDiff() domain.Diff {
64	return domain.Diff{Files: []domain.FileDiff{
65		{OldPath: "a.txt", NewPath: "a.txt", Status: domain.FileModified, Hunks: []domain.Hunk{{
66			Lines: []domain.Line{
67				{Kind: domain.LineContext, OldNumber: 4, NewNumber: 4},
68				{Kind: domain.LineDeletion, OldNumber: 5},
69				{Kind: domain.LineAddition, NewNumber: 5},
70				{Kind: domain.LineAddition, NewNumber: 6},
71			},
72		}}},
73		{OldPath: "gone.txt", Status: domain.FileDeleted, Hunks: []domain.Hunk{{
74			Lines: []domain.Line{{Kind: domain.LineDeletion, OldNumber: 1}},
75		}}},
76		{NewPath: "big.txt", Status: domain.FileAdded, Truncated: true},
77		{OldPath: "old.txt", NewPath: "new.txt", Status: domain.FileRenamed, Hunks: []domain.Hunk{{
78			Lines: []domain.Line{{Kind: domain.LineAddition, NewNumber: 2}},
79		}}},
80	}}
81}
82
83func TestDiffHasLine(t *testing.T) {
84	t.Parallel()
85	d := hunkDiff()
86	tests := []struct {
87		name string
88		path string
89		side domain.DiffSide
90		line int
91		want bool
92	}{
93		{"context on the new side", "a.txt", domain.SideNew, 4, true},
94		{"context on the old side", "a.txt", domain.SideOld, 4, true},
95		{"deletion on the old side", "a.txt", domain.SideOld, 5, true},
96		{"addition on the new side", "a.txt", domain.SideNew, 6, true},
97		{"addition is not on the old side", "a.txt", domain.SideOld, 6, false},
98		{"new line 5 is the addition, not the deletion", "a.txt", domain.SideNew, 5, true},
99		{"line outside the hunk", "a.txt", domain.SideNew, 99, false},
100		{"line zero", "a.txt", domain.SideNew, 0, false},
101		{"negative line", "a.txt", domain.SideNew, -1, false},
102		{"no side", "a.txt", domain.SideNone, 4, false},
103		{"unknown path", "nope.txt", domain.SideNew, 4, false},
104		{"empty path", "", domain.SideNew, 4, false},
105		{"deleted file by its old path", "gone.txt", domain.SideOld, 1, true},
106		{"deleted file has no new side", "gone.txt", domain.SideNew, 1, false},
107		{"truncated file has no lines", "big.txt", domain.SideNew, 1, false},
108		{"renamed file by its new path", "new.txt", domain.SideNew, 2, true},
109		{"renamed file by its old path", "old.txt", domain.SideNew, 2, false},
110	}
111	for _, tc := range tests {
112		t.Run(tc.name, func(t *testing.T) {
113			t.Parallel()
114			assert.Equal(t, tc.want, d.HasLine(tc.path, tc.side, tc.line))
115		})
116	}
117}
118
119func sig(name, email string, sec int64) domain.Signature {
120	return domain.Signature{Name: name, Email: email, Time: time.Unix(sec, 0).UTC()}
121}
122
123func TestNewSquashCommit(t *testing.T) {
124	t.Parallel()
125	title, err := domain.ParseMRTitle("Add search")
126	require.NoError(t, err)
127	mr := domain.MergeRequest{Number: 7, Title: title}
128	// Newest first, as Git lists them.
129	commits := []domain.Commit{
130		{Summary: "fix typo", Author: sig("Bob", "bob@example.org", 300)},
131		{Summary: "add tests", Author: sig("Ada", "ADA@example.org", 200)},
132		{Summary: "add search", Author: sig("Ada", "ada@example.org", 100)},
133		{Summary: "wip", Author: sig("Cy", "cy@example.org", 150)},
134		{Summary: "polish", Author: sig("Bob", "bob@example.org", 160)},
135	}
136	committer := domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}
137	at := time.Unix(1_800_000_000, 0)
138
139	c := domain.NewSquashCommit(mr, oid(t, 'a'), oid(t, 'b'), commits, committer, at)
140
141	// The author is the author of the oldest commit (the last one listed).
142	assert.Equal(t, sig("Bob", "bob@example.org", 160), c.Author)
143	assert.Equal(t, domain.Signature{Name: "ermite", Email: "ermite@git.example.org", Time: at.UTC()}, c.Committer)
144	assert.Equal(t, "Add search (!7)\n"+
145		"\n"+
146		"- polish\n- wip\n- add search\n- add tests\n- fix typo\n"+
147		"\n"+
148		"Co-authored-by: Cy <cy@example.org>\n"+
149		"Co-authored-by: Ada <ada@example.org>\n", c.Message)
150
151	want := "tree " + strings.Repeat("a", 40) + "\n" +
152		"parent " + strings.Repeat("b", 40) + "\n" +
153		"author Bob <bob@example.org> 160 +0000\n" +
154		"committer ermite <ermite@git.example.org> 1800000000 +0000\n" +
155		"\n" + c.Message
156	assert.Equal(t, want, string(c.Payload()))
157}
158
159func TestSquashCommitSigned(t *testing.T) {
160	t.Parallel()
161	title, err := domain.ParseMRTitle("T")
162	require.NoError(t, err)
163	c := domain.NewSquashCommit(domain.MergeRequest{Number: 1, Title: title}, oid(t, 'a'), oid(t, 'b'),
164		[]domain.Commit{{Summary: "s", Author: sig("A", "a@x.org", 1)}}, domain.Identity{Name: "e", Email: "e@x.org"}, time.Unix(2, 0))
165
166	got := string(c.Signed("-----BEGIN SSH SIGNATURE-----\nAAAA\nBBBB\n-----END SSH SIGNATURE-----\n"))
167	assert.Equal(t, "tree "+strings.Repeat("a", 40)+"\n"+
168		"parent "+strings.Repeat("b", 40)+"\n"+
169		"author A <a@x.org> 1 +0000\n"+
170		"committer e <e@x.org> 2 +0000\n"+
171		"gpgsig -----BEGIN SSH SIGNATURE-----\n"+
172		" AAAA\n"+
173		" BBBB\n"+
174		" -----END SSH SIGNATURE-----\n"+
175		"\n"+c.Message, got)
176}
177
178func TestSquashCommitCleansIdentitiesAndSummaries(t *testing.T) {
179	t.Parallel()
180	title, err := domain.ParseMRTitle("T")
181	require.NoError(t, err)
182	commits := []domain.Commit{
183		{Summary: "second", Author: sig("Eve <evil@x.org>\nCo-authored-by: Z", "eve@x.org", 2)},
184		{Summary: "first\x1b[0m", Author: sig("Ada", "ada@x.org", 1)},
185	}
186	c := domain.NewSquashCommit(domain.MergeRequest{Number: 2, Title: title}, oid(t, 'a'), oid(t, 'b'), commits,
187		domain.Identity{Name: "e", Email: "e@x.org"}, time.Unix(3, 0))
188	assert.Contains(t, c.Message, "- first [0m\n")
189	assert.Contains(t, c.Message, "\nCo-authored-by: Eve evil@x.orgCo-authored-by: Z <eve@x.org>\n")
190	assert.NotContains(t, c.Message, "\nCo-authored-by: Z")
191}
192
193func TestSigningKeyInfoAllowedSigners(t *testing.T) {
194	t.Parallel()
195	i := domain.SigningKeyInfo{
196		Key:       domain.SigningPublicKey{AuthorizedKey: "ssh-ed25519 AAAA"},
197		Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"},
198	}
199	assert.Equal(t, "ermite@git.example.org ssh-ed25519 AAAA", i.AllowedSigners())
200}
201
202func TestParseBranchName(t *testing.T) {
203	t.Parallel()
204	for _, ok := range []string{"main", "feat/x", "v1.2", "a-b_c"} {
205		got, err := domain.ParseBranchName(ok)
206		require.NoError(t, err, ok)
207		assert.Equal(t, ok, got)
208	}
209	for _, bad := range []string{"", "refs/heads/main", "-x", "a..b", "a b", "x/", "a:b", "x.lock", "a\x00b"} {
210		_, err := domain.ParseBranchName(bad)
211		assert.ErrorIs(t, err, domain.ErrInvalidRevision, "%q", bad)
212	}
213}

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