v0.36.0 1fb2421

cli_mirror_e2e_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"os"
7	"path/filepath"
8	"strings"
9	"testing"
10	"time"
11
12	"github.com/stretchr/testify/assert"
13	"github.com/stretchr/testify/require"
14	"golang.org/x/crypto/ssh"
15
16	"bnema.dev/ermite/internal/adapters/gitcli"
17	"bnema.dev/ermite/internal/adapters/gitpush"
18	"bnema.dev/ermite/internal/config"
19	"bnema.dev/ermite/internal/domain"
20	"bnema.dev/ermite/internal/usecase"
21)
22
23// Pushes mark mirrors dirty; a due cron tick performs the asynchronous sync.
24// Advancing the injected clock exercises that contract without waiting five minutes.
25func TestMirrorCLIEndToEnd(t *testing.T) {
26	clk := &testClock{}
27	clk.set("2025-12-31 23:59:00")
28	transport := t.TempDir()
29	var dataDir string
30	h := newHarnessWith(t, harnessOpts{env: map[string]string{config.EnvSecretsKey: secretsKeyA}}, func(o *options) {
31		o.mirrors = func(c *usecase.MirrorsConfig) {
32			c.ValidateURL = func(string) error { return nil } // file:// remotes
33			c.Clock = clk
34			c.ScheduleTick = time.Hour
35			c.Pusher = gitpush.New(gitpush.Config{
36				Bin: requireGit(t), Path: os.Getenv("PATH"), Home: transport,
37				KnownHosts: filepath.Join(transport, "known_hosts"), TempDir: filepath.Join(transport, "keys"),
38				AllowedProtocols: "file", RepoDir: func(repo domain.RepoName) string { return gitcli.RepoDir(dataDir, repo) },
39			})
40		}
41	})
42	// The transport needs the harness data directory, which is known after newForge.
43	// Replace the test-only closure's captured directory before any sync is queued.
44	dataDir = h.data
45	c := &cliHarness{harness: h, url: h.srv.URL}
46	run := func(args ...string) result { return c.cli(t, adminToken, append([]string{"mirror"}, args...)...) }
47	h.createRepo("proj", domain.VisibilityPrivate)
48	remote := filepath.Join(t.TempDir(), "remote.git")
49	h.mustGit(transport, "init", "--bare", remote)
50	run("add", "proj", "file://"+remote, "--cron", "*/5 * * * *").ok(t)
51	key := run("key", "show").ok(t)
52	assert.Empty(t, key.stderr)
53	pub, _, _, rest, err := ssh.ParseAuthorizedKey([]byte(key.stdout))
54	require.NoError(t, err)
55	assert.Empty(t, rest)
56	assert.Equal(t, "ssh-ed25519", pub.Type())
57	assert.Equal(t, 1, strings.Count(key.stdout, "\n"))
58	assert.Contains(t, c.cli(t, adminToken, "version").ok(t).stdout, "mirror")
59	h.forge.Mirrors.Start()
60	events := h.forge.Bus.Subscribe(h.ctx)
61	wt := h.workTree("main", "README.md")
62	h.mustGit(wt, "tag", "v1")
63	h.mustGit(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main", "--tags")
64	clk.set("2026-01-01 00:00:00")
65	h.forge.Mirrors.ScheduleNow(h.ctx)
66	require.Eventually(t, func() bool {
67		s, err := h.forge.Mirrors.GetStatus(h.ctx, domain.Root(), h.name("proj"))
68		return err == nil && !s.Status.LastSuccessAt.IsZero()
69	}, 10*time.Second, 10*time.Millisecond)
70	main := strings.TrimSpace(h.mustGit(wt, "rev-parse", "main"))
71	assert.Equal(t, main, strings.TrimSpace(h.mustGit(transport, "--git-dir="+remote, "rev-parse", "refs/heads/main")))
72	assert.Equal(t, main, strings.TrimSpace(h.mustGit(transport, "--git-dir="+remote, "rev-parse", "refs/tags/v1")))
73	clk.set("2026-01-01 00:02:00")
74	assert.Contains(t, run("sync", "proj").ok(t).stdout, "true", "unchanged refs are a no-op")
75
76	// Advance the remote independently, so the next atomic mirror push is rejected.
77	other := filepath.Join(t.TempDir(), "other")
78	h.mustGit(transport, "clone", "--branch", "main", remote, other)
79	h.mustGit(other, "commit", "--allow-empty", "-m", "remote-only")
80	h.mustGit(other, "push", "origin", "main")
81	h.mustGit(wt, "commit", "--allow-empty", "-m", "forge-only")
82	h.mustGit(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main")
83	clk.set("2026-01-01 00:05:00")
84	h.forge.Mirrors.ScheduleNow(h.ctx)
85	require.Eventually(t, func() bool {
86		s, err := h.forge.Mirrors.GetStatus(h.ctx, domain.Root(), h.name("proj"))
87		return err == nil && s.Status.State == domain.MirrorPaused
88	}, 10*time.Second, 10*time.Millisecond)
89	status := run("status", "proj").ok(t).stdout
90	assert.Contains(t, status, "paused")
91	assert.Contains(t, status, "remote has commits")
92	assert.Contains(t, run("status", "proj", "--json").ok(t).stdout, `"failure_class": "diverged"`)
93	h.mustGit(wt, "commit", "--allow-empty", "-m", "warning")
94	warning := h.mustGit(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main")
95	assert.Contains(t, warning, "remote: ermite: mirror")
96	assert.Contains(t, warning, "paused")
97	token := c.signIn(t)
98	page := (browser{h}).do("GET", "/proj", map[string]string{"Cookie": sessionCookie + "=" + token}, "")
99	assert.Equal(t, 200, page.status)
100	assert.Contains(t, page.body, "Mirror paused")
101	assert.Contains(t, page.body, "ermite mirror status proj")
102
103	// Repair the remote, then force a normal (never forced) Git push to recover.
104	latest := strings.TrimSpace(h.mustGit(wt, "rev-parse", "main"))
105	h.mustGit(wt, "push", "--force", remote, "main") // deliberate remote repair, not mirror behavior
106	run("sync", "proj", "--force").ok(t)
107	assert.Equal(t, latest, strings.TrimSpace(h.mustGit(transport, "--git-dir="+remote, "rev-parse", "refs/heads/main")))
108	assert.Contains(t, run("status", "proj").ok(t).stdout, "ok")
109	run("sync", "--all", "--force").ok(t)
110	var kinds []string
111	for {
112		select {
113		case event := <-events:
114			if event.EventKind() == "mirror.failed" || event.EventKind() == "mirror.recovered" {
115				kinds = append(kinds, event.EventKind())
116			}
117		default:
118			assert.Equal(t, []string{"mirror.failed", "mirror.recovered"}, kinds)
119			return
120		}
121	}
122}

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