detached fda3c2b

web_commits_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"fmt"
7	"os"
8	"path/filepath"
9	"regexp"
10	"strings"
11	"testing"
12	"time"
13
14	"github.com/stretchr/testify/assert"
15	"github.com/stretchr/testify/require"
16
17	"bnema.dev/ermite/internal/domain"
18	"bnema.dev/ermite/internal/ports"
19)
20
21// diffFixture is the repository of the commit pages: one commit for each
22// kind of change a diff shows.
23type diffFixture struct {
24	root, modify, rename, binary, merge, trojan, big string
25}
26
27// createRepo creates a public repository for the pages.
28func (h *harness) createRepo(name string, vis domain.Visibility) {
29	h.t.Helper()
30	_, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name(name), Description: "Diffs", Visibility: vis})
31	require.NoError(h.t, err)
32}
33
34// commitAt commits what is staged at a fixed date and returns the id.
35func (h *harness) commitAt(wt, date, msg string) string {
36	h.t.Helper()
37	env := []string{"GIT_AUTHOR_DATE=" + date, "GIT_COMMITTER_DATE=" + date}
38	out, err := h.gitEnv(env, wt, "commit", "-q", "--allow-empty", "-m", msg)
39	require.NoError(h.t, err, out)
40	return strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD"))
41}
42
43func (h *harness) put(wt, name string, data []byte, mode os.FileMode) {
44	h.t.Helper()
45	full := filepath.Join(wt, name)
46	require.NoError(h.t, os.MkdirAll(filepath.Dir(full), 0o755))
47	require.NoError(h.t, os.WriteFile(full, data, mode))
48	require.NoError(h.t, os.Chmod(full, mode))
49	h.mustGit(wt, "add", name)
50}
51
52// diffRepo builds the fixture and pushes it as a public repository.
53func (h *harness) diffRepo(name string) diffFixture {
54	h.t.Helper()
55	wt := h.t.TempDir()
56	h.mustGit(wt, "init", "-q", "--initial-branch=main")
57	var f diffFixture
58
59	h.put(wt, "a.txt", []byte("one\ntwo\nthree\nfour\nfive\n"), 0o644)
60	h.put(wt, "old.txt", []byte("keep me\nkeep me too\nand me\n"), 0o644)
61	h.put(wt, "run.sh", []byte("#!/bin/sh\necho hi\n"), 0o644)
62	h.put(wt, "gone.txt", []byte("bye\n"), 0o644)
63	h.put(wt, "logo.bin", []byte{0x89, 'P', 'N', 'G', 0, 1, 2, 3}, 0o644)
64	f.root = h.commitAt(wt, "2026-02-01T10:00:00Z", "root: the first files")
65
66	h.put(wt, "a.txt", []byte("one\n2\nthree\nfour\nfive\nsix\n"), 0o644)
67	h.put(wt, "run.sh", []byte("#!/bin/sh\necho hi\n"), 0o755)
68	f.modify = h.commitAt(wt, "2026-02-02T10:00:00Z", "modify a file and make a script executable\n\nThe body has a second paragraph.\n\nAnd <b>html</b> & \"quotes\".")
69
70	h.mustGit(wt, "mv", "old.txt", "docs-old.txt")
71	h.mustGit(wt, "rm", "-q", "gone.txt")
72	f.rename = h.commitAt(wt, "2026-02-03T10:00:00Z", "rename a file and delete another")
73
74	h.put(wt, "logo.bin", []byte{0x89, 'P', 'N', 'G', 0, 9, 9, 9, 9}, 0o644)
75	f.binary = h.commitAt(wt, "2026-02-04T10:00:00Z", "change a binary file")
76
77	// A side branch from the root, merged with a merge commit.
78	h.mustGit(wt, "checkout", "-q", "-b", "side", f.root)
79	h.put(wt, "side.txt", []byte("from the side\n"), 0o644)
80	h.commitAt(wt, "2026-02-05T10:00:00Z", "work on the side")
81	h.mustGit(wt, "checkout", "-q", "main")
82	env := []string{"GIT_AUTHOR_DATE=2026-02-06T10:00:00Z", "GIT_COMMITTER_DATE=2026-02-06T10:00:00Z"}
83	out, err := h.gitEnv(env, wt, "merge", "-q", "--no-ff", "-m", "merge the side branch", "side")
84	require.NoError(h.t, err, out)
85	f.merge = strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD"))
86
87	h.put(wt, "evil.go", []byte("package evil\n\nfunc admin(user string) bool {\n\tif user == \"user\u202e \u2066// check\u2069 \u2066\" {\n\t\treturn true // z\u200bw\n\t}\n\treturn false\n}\n"), 0o644)
88	f.trojan = h.commitAt(wt, "2026-02-07T10:00:00Z", "add evil.go\n\nlooks \u202efine\u202c")
89
90	var big strings.Builder
91	for i := range 2600 {
92		fmt.Fprintf(&big, "line %d\n", i)
93	}
94	h.put(wt, "big.txt", []byte(big.String()), 0o644)
95	f.big = h.commitAt(wt, "2026-02-08T10:00:00Z", "add a very large file")
96
97	h.mustGit(wt, "tag", "v1.0.0", f.modify)
98	h.createRepo(name, domain.VisibilityPublic)
99	h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main", "side", "--tags")
100	return f
101}
102
103// longRepo is a repository with more commits than a page lists: 52 on main,
104// one of which adds a.txt and one changes it.
105func (h *harness) longRepo(name string) {
106	h.t.Helper()
107	wt := h.t.TempDir()
108	h.mustGit(wt, "init", "-q", "--initial-branch=main")
109	base := time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC)
110	for i := range 52 {
111		switch i {
112		case 3:
113			h.put(wt, "a.txt", []byte("first\n"), 0o644)
114		case 30:
115			h.put(wt, "a.txt", []byte("second\n"), 0o644)
116		default:
117			h.put(wt, "counter.txt", []byte(fmt.Sprintf("%d\n", i)), 0o644)
118		}
119		h.commitAt(wt, base.Add(time.Duration(i)*12*time.Hour).Format(time.RFC3339), fmt.Sprintf("commit number %d", i))
120	}
121	h.createRepo(name, domain.VisibilityPublic)
122	h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main")
123}
124
125var reOlder = regexp.MustCompile(`<a class="btn" href="([^"]+)" rel="next">`)
126
127// unescape undoes the HTML escaping of an attribute value.
128func unescape(s string) string { return strings.ReplaceAll(s, "&amp;", "&") }
129
130// AC1: the history pages render for the fixtures with the structure of the
131// mockup (golden HTML, normalised).
132func TestWebCommitsGolden(t *testing.T) {
133	h := newHarness(t, harnessOpts{})
134	h.diffRepo("diffs")
135	h.longRepo("long")
136
137	// The second page is reached by the link of the first, as a visitor would.
138	_, _, first := h.get("/long/commits/main")
139	m := reOlder.FindStringSubmatch(first)
140	require.Len(t, m, 2, "the first page links to the older commits")
141	second := unescape(m[1])
142	assert.True(t, strings.HasPrefix(second, "/long/commits/main?page="), second)
143
144	for _, tt := range []struct{ name, path string }{
145		{"commits-root", "/long/commits/main"},
146		{"commits-page2", second},
147		{"commits-path", "/long/commits/main/a.txt"},
148		{"commits-small", "/diffs/commits"},
149	} {
150		t.Run(tt.name, func(t *testing.T) {
151			status, hdr, body := h.get(tt.path)
152			require.Equal(t, 200, status)
153			assert.Equal(t, "no-cache", hdr.Get("Cache-Control"))
154			assertNoInline(t, body)
155			golden(t, tt.name, normalise(body))
156		})
157	}
158}
159
160// The behaviours of the history pages, asserted on their own.
161func TestWebCommits(t *testing.T) {
162	h := newHarness(t, harnessOpts{})
163	f := h.diffRepo("diffs")
164	h.longRepo("long")
165	get := func(path string, status int) string {
166		t.Helper()
167		st, _, body := h.get(path)
168		require.Equal(t, status, st, path)
169		return body
170	}
171
172	t.Run("history", func(t *testing.T) {
173		first := get("/long/commits/main", 200)
174		assert.Equal(t, 50, strings.Count(first, `class="c-sha mute"`), "a page lists 50 commits")
175		assert.Contains(t, first, "commit number 51")
176		assert.NotContains(t, first, "commit number 1<", "the oldest are on the next page")
177		assert.Contains(t, first, `<span class="btn" aria-disabled="true">← Newest</span>`)
178		second := unescape(reOlder.FindStringSubmatch(first)[1])
179		page2 := get(second, 200)
180		assert.Equal(t, 2, strings.Count(page2, `class="c-sha mute"`))
181		assert.Contains(t, page2, `href="/long/commits/main">← Newest</a>`)
182		assert.Contains(t, page2, `<span class="btn" aria-disabled="true">Older →</span>`)
183		get("/long/commits/main?page=garbage", 404)
184	})
185
186	t.Run("path filter", func(t *testing.T) {
187		body := get("/long/commits/main/a.txt", 200)
188		assert.Equal(t, 2, strings.Count(body, `class="c-sha mute"`))
189		assert.Contains(t, body, "history of <code>a.txt</code> at main")
190		assert.Contains(t, body, `href="/long/blob/main/a.txt"`, "the way to the file")
191		get("/long/commits/main/never/existed.txt", 404)
192	})
193
194	t.Run("refs", func(t *testing.T) {
195		// A tag, a branch, a commit id, and a ref with a slash.
196		assert.Contains(t, get("/diffs/commits/v1.0.0", 200), "modify a file")
197		assert.Contains(t, get("/diffs/commits/side", 200), "work on the side")
198		assert.Contains(t, get("/diffs/commits/"+f.modify[:10], 200), "modify a file")
199		assert.Contains(t, get("/diffs/commits/main", 200), `<span class="pill tag"><span class="sr">tag </span>v1.0.0</span>`)
200		assert.Contains(t, get("/diffs/commits/main", 200), `<span class="pill">merge</span>`)
201		get("/diffs/commits/nope", 404)
202		get("/diffs/commits/mai", 404)
203		get("/nope/commits/main", 404)
204	})
205}
206
207// AC2: the history pages of a private repository are the 404 page of a
208// repository that does not exist.
209func TestWebCommitsPrivate(t *testing.T) {
210	h := newHarness(t, harnessOpts{})
211	wt := h.workTree("main", "secret.txt")
212	h.createRepo("hidden-one", domain.VisibilityPrivate)
213	h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/hidden-one.git"), "main")
214
215	_, _, missing := h.get("/never-existed")
216	for _, p := range []string{"/hidden-one/commits/main", "/hidden-one/commits", "/hidden-one/commits/main/secret.txt"} {
217		st, hdr, body := h.get(p)
218		assert.Equal(t, 404, st, p)
219		assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), p)
220		assert.NotContains(t, body, "hidden-one", p)
221		assert.NotContains(t, body, "secret.txt", p)
222		assert.Equal(t, normalise(missing), normalise(body), p)
223	}
224}

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 4

Tags 45

↑↓ moveenter openesc close