v0.6.0 d5cc8fe

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

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