v0.28.0 fca9155

git_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain_test
4
5import (
6	"strings"
7	"testing"
8
9	"github.com/stretchr/testify/assert"
10	"github.com/stretchr/testify/require"
11
12	"bnema.dev/ermite/internal/domain"
13)
14
15func TestParseObjectID(t *testing.T) {
16	t.Parallel()
17	good := strings.Repeat("ab12", 10)
18	id, err := domain.ParseObjectID(good)
19	require.NoError(t, err)
20	assert.Equal(t, good, id.String())
21	assert.False(t, id.IsZero())
22	assert.True(t, domain.ObjectID{}.IsZero())
23
24	for _, bad := range []string{"", "abc", strings.ToUpper(good), good + "0", good[:39], strings.Repeat("g", 40), "main"} {
25		_, err := domain.ParseObjectID(bad)
26		assert.ErrorIs(t, err, domain.ErrInvalidObjectID, bad)
27	}
28}
29
30func TestParseRevision(t *testing.T) {
31	t.Parallel()
32	valid := []string{
33		"", "main", "feature/x", "refs/heads/feature/x", "v1.0.0", "HEAD",
34		"abc1234", strings.Repeat("a", 40), "release-2026.10", "café",
35	}
36	for _, s := range valid {
37		r, err := domain.ParseRevision(s)
38		require.NoError(t, err, s)
39		assert.Equal(t, s, r.String())
40	}
41	invalid := []string{
42		"-x", "--upload-pack=x", "/x", "x/", "x.", "a..b", "a@{1}", "@", "a b", "a~1", "a^", "a:b", "a?", "a*", "a[",
43		"a\\b", "a\nb", "a\x00b", "a//b", ".hidden", "a/.b", "x.lock", "a/x.lock/b", "\xff", strings.Repeat("a", 513),
44	}
45	for _, s := range invalid {
46		_, err := domain.ParseRevision(s)
47		assert.ErrorIs(t, err, domain.ErrInvalidRevision, "%q", s)
48	}
49}
50
51func TestRevisionKinds(t *testing.T) {
52	t.Parallel()
53	head, _ := domain.ParseRevision("")
54	assert.True(t, head.IsHead())
55	full, _ := domain.ParseRevision(strings.Repeat("a", 40))
56	assert.True(t, full.IsObjectID())
57	assert.False(t, full.IsAbbrevID())
58	short, _ := domain.ParseRevision("abcdef1")
59	assert.True(t, short.IsAbbrevID())
60	tooShort, _ := domain.ParseRevision("abcdef")
61	assert.False(t, tooShort.IsAbbrevID())
62	branch, _ := domain.ParseRevision("deadbeefx")
63	assert.False(t, branch.IsAbbrevID())
64
65	id, _ := domain.ParseObjectID(strings.Repeat("c", 40))
66	assert.True(t, domain.RevisionOf(id).IsObjectID())
67}
68
69func TestParseTreePath(t *testing.T) {
70	t.Parallel()
71	root, err := domain.ParseTreePath("")
72	require.NoError(t, err)
73	assert.True(t, root.IsRoot())
74	assert.Equal(t, "x", root.Join("x"))
75
76	p, err := domain.ParseTreePath("a/b/")
77	require.NoError(t, err)
78	assert.Equal(t, "a/b", p.String())
79	assert.Equal(t, "a/b/c", p.Join("c"))
80}
81
82// ParseTreePath and IsAddressablePath share one rule. The only differences
83// are the root ("" is a valid path to parse, and not addressable) and the
84// trailing slash (ParseTreePath drops one, IsAddressablePath wants the path
85// as it is).
86func TestTreePathRule(t *testing.T) {
87	long := strings.Repeat("a", 4096)
88	for _, tt := range []struct {
89		path string
90		ok   bool // parses, and is addressable
91	}{
92		{"a", true}, {"a/b.txt", true}, {"dir/ünï.go", true}, {"a b/c", true}, {".github/ci.yml", true}, {long, true},
93		{"/a", false}, {"a//b", false}, {"./a", false}, {"a/..", false}, {"..", false}, {".", false},
94		{"a/../b", false}, {"tab\tname", false}, {"a\nb", false}, {"a\x00b", false}, {"a\x7fb", false},
95		{"\xff", false}, {"a/\xfe/b", false}, {long + "a", false}, {"//", false},
96	} {
97		_, err := domain.ParseTreePath(tt.path)
98		assert.Equal(t, tt.ok, err == nil, "ParseTreePath(%q)", tt.path)
99		if !tt.ok {
100			assert.ErrorIs(t, err, domain.ErrInvalidPath, "%q", tt.path)
101		}
102		assert.Equal(t, tt.ok, domain.IsAddressablePath(tt.path), "IsAddressablePath(%q)", tt.path)
103		assert.Equal(t, tt.ok, domain.IsAddressablePathBytes([]byte(tt.path)), "IsAddressablePathBytes(%q)", tt.path)
104	}
105
106	// The exceptions.
107	_, err := domain.ParseTreePath("")
108	assert.NoError(t, err, "the root parses")
109	assert.False(t, domain.IsAddressablePath(""), "the root is not addressable")
110	assert.False(t, domain.IsAddressablePathBytes(nil))
111	_, err = domain.ParseTreePath("a/")
112	assert.NoError(t, err, "one trailing slash is dropped")
113	assert.False(t, domain.IsAddressablePath("a/"), "an addressable path has none")
114	assert.False(t, domain.IsAddressablePathBytes([]byte("a/")))
115	_, err = domain.ParseTreePath("/")
116	assert.NoError(t, err, `"/" is the root with its trailing slash dropped`)
117	assert.False(t, domain.IsAddressablePath("/"))
118}
119
120func TestPathListAdd(t *testing.T) {
121	for _, tt := range []struct {
122		name               string
123		adds               []string
124		maxPaths, maxBytes int
125		want               []string
126		wantStopAt         int // index of the Add that returned false, -1 for none
127		truncated          bool
128	}{
129		{"all fit", []string{"a", "b/c"}, 10, 100, []string{"a", "b/c"}, -1, false},
130		{"not addressable are skipped", []string{"a", "", "b//c", "d/", "e\nf", "g"}, 10, 100, []string{"a", "g"}, -1, false},
131		{"skipped paths do not count", []string{"", "..", "a"}, 1, 1, []string{"a"}, -1, false},
132		{"exactly maxPaths", []string{"a", "b"}, 2, 100, []string{"a", "b"}, -1, false},
133		{"over maxPaths", []string{"a", "b", "c"}, 2, 100, []string{"a", "b"}, 2, true},
134		{"exactly maxBytes", []string{"ab", "cd"}, 10, 4, []string{"ab", "cd"}, -1, false},
135		{"over maxBytes", []string{"ab", "cde", "f"}, 10, 4, []string{"ab"}, 1, true},
136		{"nothing fits", []string{"abc"}, 10, 2, nil, 0, true},
137		{"no paths allowed", []string{"a"}, 0, 100, nil, 0, true},
138		{"unaddressable past the limit is no truncation", []string{"a", "b/"}, 1, 100, []string{"a"}, -1, false},
139	} {
140		t.Run(tt.name, func(t *testing.T) {
141			var l domain.PathList
142			stop := -1
143			for i, p := range tt.adds {
144				if !l.Add(p, tt.maxPaths, tt.maxBytes) {
145					stop = i
146					break
147				}
148			}
149			assert.Equal(t, tt.want, l.Paths)
150			assert.Equal(t, tt.wantStopAt, stop)
151			assert.Equal(t, tt.truncated, l.Truncated)
152		})
153	}
154}
155
156func TestEntryKindOf(t *testing.T) {
157	t.Parallel()
158	tests := map[uint32]domain.EntryKind{
159		0o100644: domain.EntryFile,
160		0o100664: domain.EntryFile,
161		0o100755: domain.EntryExecutable,
162		0o040000: domain.EntryDirectory,
163		0o120000: domain.EntrySymlink,
164		0o160000: domain.EntrySubmodule,
165		0o000000: "",
166		0o060000: "",
167	}
168	for mode, want := range tests {
169		assert.Equal(t, want, domain.EntryKindOf(mode), "%o", mode)
170	}
171}
172
173func TestDefaultDiffLimits(t *testing.T) {
174	t.Parallel()
175	assert.Equal(t, domain.DiffLimits{MaxFiles: 300, MaxFileLines: 2000, MaxTotalLines: 20000, MaxTotalBytes: 8 << 20}, domain.DefaultDiffLimits())
176}

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 3

Tags 43

↑↓ moveenter openesc close