v0.8.0 eaf4295

pushes_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6	"context"
7	"errors"
8	"testing"
9	"time"
10
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/mock"
13	"github.com/stretchr/testify/require"
14
15	"bnema.dev/ermite/internal/domain"
16	"bnema.dev/ermite/internal/ports"
17	"bnema.dev/ermite/internal/ports/mocks"
18	"bnema.dev/ermite/internal/usecase"
19)
20
21type pushFixture struct {
22	meta   *mocks.RepoMetaStore
23	git    *mocks.GitRepos
24	bus    *mocks.EventBus
25	clock  *mocks.Clock
26	tokens *mocks.TokenStore
27	sched  *mocks.MaintenanceScheduler
28	pushes *usecase.Pushes
29}
30
31func newPushFixture(t *testing.T) *pushFixture {
32	t.Helper()
33	f := &pushFixture{
34		meta:   mocks.NewRepoMetaStore(t),
35		git:    mocks.NewGitRepos(t),
36		bus:    mocks.NewEventBus(t),
37		clock:  mocks.NewClock(t),
38		tokens: mocks.NewTokenStore(t),
39		sched:  mocks.NewMaintenanceScheduler(t),
40	}
41	f.clock.EXPECT().Now().Return(now).Maybe()
42	repos := usecase.NewRepos(f.meta, f.git, f.bus, f.clock)
43	auth := usecase.NewAuth(domain.HashTokenSecret(rootSecret), f.tokens, f.clock)
44	f.pushes = usecase.NewPushes(f.meta, repos, f.git, f.bus, f.clock, f.sched, auth)
45	return f
46}
47
48const (
49	oidA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
50	oidB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
51)
52
53func TestPushesOpen(t *testing.T) {
54	t.Parallel()
55	ctx := context.Background()
56	n := name(t, "tools")
57	priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}
58	pub := domain.Repo{Name: n, Visibility: domain.VisibilityPublic}
59
60	tests := []struct {
61		name   string
62		p      domain.Principal
63		repo   *domain.Repo // nil: missing
64		create bool
65		want   error
66	}{
67		{"anonymous", domain.Anonymous(), &pub, false, domain.ErrUnauthenticated},
68		{"root, existing", domain.Root(), &priv, false, nil},
69		{"writer, existing", tokenPrincipal(domain.ScopeRepoWrite), &priv, false, nil},
70		{"writer limited to the repo", tokenPrincipal(domain.ScopeRepoWrite, "tools"), &priv, false, nil},
71		{"writer limited to another repo, private", tokenPrincipal(domain.ScopeRepoWrite, "other"), &priv, false, domain.ErrNotFound},
72		{"writer limited to another repo, public", tokenPrincipal(domain.ScopeRepoWrite, "other"), &pub, false, domain.ErrForbidden},
73		{"reader of a private repo", tokenPrincipal(domain.ScopeRepoRead), &priv, false, domain.ErrForbidden},
74		{"reader of a public repo", tokenPrincipal(domain.ScopeRepoRead), &pub, false, domain.ErrForbidden},
75		{"token with no relevant scope, private", tokenPrincipal(domain.ScopeMRWrite), &priv, false, domain.ErrNotFound},
76		{"missing, no create", domain.Root(), nil, false, domain.ErrNotFound},
77		{"missing, reader may not create", tokenPrincipal(domain.ScopeRepoRead), nil, true, domain.ErrNotFound},
78		{"missing, writer limited elsewhere", tokenPrincipal(domain.ScopeRepoWrite, "other"), nil, true, domain.ErrNotFound},
79		{"existing, writer limited to branches", branchLimited(domain.ScopeRepoWrite), &priv, false, nil},
80		{"missing, writer limited to branches may not create", branchLimited(domain.ScopeRepoWrite), nil, true, domain.ErrNotFound},
81		{"missing, writer limited to branches and to the repo may not create", branchLimited(domain.ScopeRepoWrite, "tools"), nil, true, domain.ErrNotFound},
82	}
83	for _, tt := range tests {
84		t.Run(tt.name, func(t *testing.T) {
85			t.Parallel()
86			f := newPushFixture(t)
87			if !tt.p.IsAnonymous() {
88				if tt.repo != nil {
89					f.meta.EXPECT().Get(ctx, n).Return(*tt.repo, nil)
90				} else {
91					f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
92				}
93			}
94			// The bus mock expects no Publish: nothing is created here.
95			err := f.pushes.Open(ctx, tt.p, n, tt.create)
96			if tt.want != nil {
97				assert.ErrorIs(t, err, tt.want)
98				return
99			}
100			require.NoError(t, err)
101		})
102	}
103
104	t.Run("creates a private repository and publishes RepoCreated", func(t *testing.T) {
105		t.Parallel()
106		f := newPushFixture(t)
107		f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
108		f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
109		want := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: domain.DefaultRepoSettings()}
110		f.meta.EXPECT().Create(ctx, want).Return(nil)
111		f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)
112		f.bus.EXPECT().Publish(ctx, domain.RepoCreated{Repo: n, Visibility: domain.VisibilityPrivate, At: now}).Return()
113
114		require.NoError(t, f.pushes.Open(ctx, tokenPrincipal(domain.ScopeRepoWrite, "tools"), n, true))
115	})
116
117	t.Run("a concurrent first push wins: this one pushes to its repository", func(t *testing.T) {
118		t.Parallel()
119		f := newPushFixture(t)
120		f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound).Once()
121		f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
122		f.meta.EXPECT().Create(ctx, mock.Anything).Return(domain.ErrAlreadyExists)
123		f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).Return(nil)
124		f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
125
126		// The bus mock expects no Publish: the repository was not created here.
127		require.NoError(t, f.pushes.Open(ctx, domain.Root(), n, true))
128	})
129
130	t.Run("store failure", func(t *testing.T) {
131		t.Parallel()
132		f := newPushFixture(t)
133		boom := errors.New("boom")
134		f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, boom)
135		err := f.pushes.Open(ctx, domain.Root(), n, true)
136		assert.ErrorIs(t, err, boom)
137	})
138
139	t.Run("create failure is returned and nothing is published", func(t *testing.T) {
140		t.Parallel()
141		f := newPushFixture(t)
142		boom := errors.New("boom")
143		f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
144		f.git.EXPECT().Stage(ctx).Return(ports.StagedID(""), boom)
145		err := f.pushes.Open(ctx, domain.Root(), n, true)
146		assert.ErrorIs(t, err, boom)
147	})
148}
149
150func TestPushesCheck(t *testing.T) {
151	t.Parallel()
152	ctx := context.Background()
153	n := name(t, "tools")
154	ups := []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}}
155	writer := domain.TokenPrincipal(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite), Repos: names(t, "tools")})
156	reader := domain.TokenPrincipal(domain.Token{ID: "r1", Scopes: scopes(domain.ScopeRepoRead)})
157
158	t.Run("root is always valid and needs no lookup", func(t *testing.T) {
159		t.Parallel()
160		f := newPushFixture(t) // the token store mock expects no call
161		require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, ups))
162	})
163
164	t.Run("a writer whose token still exists", func(t *testing.T) {
165		t.Parallel()
166		f := newPushFixture(t)
167		f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)}, nil)
168		require.NoError(t, f.pushes.Check(ctx, writer, n, ups))
169	})
170
171	t.Run("a token revoked during the push is rejected", func(t *testing.T) {
172		t.Parallel()
173		f := newPushFixture(t)
174		f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{}, domain.ErrNotFound)
175		assert.ErrorIs(t, f.pushes.Check(ctx, writer, n, ups), domain.ErrRejected)
176	})
177
178	t.Run("a token that expired during the push is rejected", func(t *testing.T) {
179		t.Parallel()
180		f := newPushFixture(t)
181		f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite), ExpiresAt: now.Add(-time.Second)}, nil)
182		assert.ErrorIs(t, f.pushes.Check(ctx, writer, n, ups), domain.ErrRejected)
183	})
184
185	t.Run("a store failure fails closed", func(t *testing.T) {
186		t.Parallel()
187		f := newPushFixture(t)
188		boom := errors.New("boom")
189		f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{}, boom)
190		err := f.pushes.Check(ctx, writer, n, ups)
191		require.ErrorIs(t, err, boom)
192		assert.NotErrorIs(t, err, domain.ErrRejected, "an internal failure, not a refusal")
193	})
194
195	t.Run("a token without write access is rejected", func(t *testing.T) {
196		t.Parallel()
197		f := newPushFixture(t)
198		f.tokens.EXPECT().Get(ctx, "r1").Return(domain.Token{ID: "r1", Scopes: scopes(domain.ScopeRepoRead)}, nil)
199		assert.ErrorIs(t, f.pushes.Check(ctx, reader, n, ups), domain.ErrRejected)
200	})
201
202	t.Run("anonymous is rejected", func(t *testing.T) {
203		t.Parallel()
204		f := newPushFixture(t)
205		assert.ErrorIs(t, f.pushes.Check(ctx, domain.Anonymous(), n, ups), domain.ErrRejected)
206	})
207}
208
209// AC5: a token limited to branch prefixes pushes only under them.
210func TestPushesCheckBranchPrefixes(t *testing.T) {
211	t.Parallel()
212	ctx := context.Background()
213	n := name(t, "tools")
214	limited := domain.TokenPrincipal(domain.Token{
215		ID: "b1", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: []string{"agent/claude/", "bot/"},
216	})
217	up := func(old, new, ref string) domain.RefUpdate { return domain.RefUpdate{Old: old, New: new, Ref: ref} }
218
219	tests := []struct {
220		name    string
221		updates []domain.RefUpdate
222		refused string // the ref named by the error; "" = accepted
223	}{
224		{"create under the prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude/x")}, ""},
225		{"force-push under the prefix", []domain.RefUpdate{up(oidA, oidB, "refs/heads/agent/claude/x")}, ""},
226		{"delete under the prefix", []domain.RefUpdate{up(oidA, domain.ZeroOID, "refs/heads/agent/claude/x")}, ""},
227		{"second prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/bot/y")}, ""},
228		{"several under the prefixes", []domain.RefUpdate{
229			up(domain.ZeroOID, oidA, "refs/heads/agent/claude/a"), up(domain.ZeroOID, oidA, "refs/heads/bot/b"),
230		}, ""},
231		{"main", []domain.RefUpdate{up(oidA, oidB, "refs/heads/main")}, "refs/heads/main"},
232		{"delete main", []domain.RefUpdate{up(oidA, domain.ZeroOID, "refs/heads/main")}, "refs/heads/main"},
233		{"the prefix itself without a name", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude")}, "refs/heads/agent/claude"},
234		{"a sibling that shares the text", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claudex/y")}, "refs/heads/agent/claudex/y"},
235		{"a tag", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/v1")}, "refs/tags/v1"},
236		{"a tag named like the prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/agent/claude/v1")}, "refs/tags/agent/claude/v1"},
237		{"a notes ref", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/notes/commits")}, "refs/notes/commits"},
238		{"a ref outside refs/heads", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/agent/claude/x")}, "refs/agent/claude/x"},
239		{"a path trick", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude/../main")}, "refs/heads/agent/claude/../main"},
240		{"mixed push: one allowed, one forbidden", []domain.RefUpdate{
241			up(domain.ZeroOID, oidA, "refs/heads/agent/claude/ok"), up(oidA, oidB, "refs/heads/main"),
242		}, "refs/heads/main"},
243		{"mixed push, forbidden first", []domain.RefUpdate{
244			up(oidA, oidB, "refs/heads/main"), up(domain.ZeroOID, oidA, "refs/heads/agent/claude/ok"),
245		}, "refs/heads/main"},
246	}
247	for _, tt := range tests {
248		t.Run(tt.name, func(t *testing.T) {
249			t.Parallel()
250			f := newPushFixture(t)
251			f.tokens.EXPECT().Get(ctx, "b1").Return(domain.Token{ID: "b1", Scopes: scopes(domain.ScopeRepoWrite)}, nil)
252			err := f.pushes.Check(ctx, limited, n, tt.updates)
253			if tt.refused == "" {
254				require.NoError(t, err)
255				return
256			}
257			require.ErrorIs(t, err, domain.ErrRejected)
258			assert.Contains(t, err.Error(), tt.refused+" is outside the branches this token may push (agent/claude/, bot/)")
259		})
260	}
261
262	t.Run("a token without limit pushes anywhere", func(t *testing.T) {
263		t.Parallel()
264		f := newPushFixture(t)
265		f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)}, nil)
266		free := domain.TokenPrincipal(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)})
267		require.NoError(t, f.pushes.Check(ctx, free, n, []domain.RefUpdate{
268			up(domain.ZeroOID, oidA, "refs/tags/v1"), up(oidA, oidB, "refs/heads/main"),
269		}))
270	})
271
272	t.Run("root pushes anywhere", func(t *testing.T) {
273		t.Parallel()
274		f := newPushFixture(t)
275		require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/v1")}))
276	})
277
278	t.Run("a limited token without repo:write is rejected for the repository first", func(t *testing.T) {
279		t.Parallel()
280		f := newPushFixture(t)
281		f.tokens.EXPECT().Get(ctx, "b2").Return(domain.Token{ID: "b2", Scopes: scopes(domain.ScopeRepoRead)}, nil)
282		p := domain.TokenPrincipal(domain.Token{ID: "b2", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"a/"}})
283		err := f.pushes.Check(ctx, p, n, []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/a/x")})
284		require.ErrorIs(t, err, domain.ErrRejected)
285		assert.Contains(t, err.Error(), "no write access")
286	})
287}
288
289func TestPushesRecord(t *testing.T) {
290	t.Parallel()
291	ctx := context.Background()
292	n := name(t, "tools")
293	p := tokenPrincipal(domain.ScopeRepoWrite)
294	ups := []domain.RefUpdate{
295		{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/dev"},
296		{Old: domain.ZeroOID, New: oidB, Ref: "refs/heads/main"},
297		{Old: domain.ZeroOID, New: oidB, Ref: "refs/tags/v1"},
298	}
299
300	t.Run("sets pushed_at, publishes the exact updates, schedules maintenance", func(t *testing.T) {
301		t.Parallel()
302		f := newPushFixture(t)
303		f.sched.EXPECT().Schedule(ctx, n).Return().Once()
304		repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now.Add(-time.Hour)}
305		f.meta.EXPECT().Get(ctx, n).Return(repo, nil)
306		f.meta.EXPECT().Update(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: repo.CreatedAt, PushedAt: now}).Return(nil)
307		f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
308		f.bus.EXPECT().Publish(ctx, domain.PushEvent{Repo: n, Updates: ups, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}).Return()
309
310		require.NoError(t, f.pushes.Record(ctx, p, n, ups))
311	})
312
313	t.Run("tells the reader that the repository changed", func(t *testing.T) {
314		t.Parallel()
315		f := newPushFixture(t)
316		f.sched.EXPECT().Schedule(ctx, n).Return().Once()
317		fresh := mocks.NewRepoFreshener(t)
318		fresh.EXPECT().Refresh(ctx, n).Return().Once()
319		f.pushes.WithFreshener(fresh)
320		f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n}, nil)
321		f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil)
322		f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
323		f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
324		require.NoError(t, f.pushes.Record(ctx, p, n, ups))
325	})
326
327	t.Run("pushed_at retries after a conflict and never goes backwards", func(t *testing.T) {
328		t.Parallel()
329		f := newPushFixture(t)
330		f.sched.EXPECT().Schedule(ctx, n).Return().Once()
331		repo := domain.Repo{Name: n}
332		f.meta.EXPECT().Get(ctx, n).Return(repo, nil).Once()
333		f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once()
334		later := domain.Repo{Name: n, PushedAt: now.Add(time.Second)}
335		f.meta.EXPECT().Get(ctx, n).Return(later, nil).Once() // a later push recorded meanwhile
336		f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
337		f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
338		require.NoError(t, f.pushes.Record(ctx, p, n, ups))
339	})
340
341	t.Run("a deleted repository is not an error", func(t *testing.T) {
342		t.Parallel()
343		f := newPushFixture(t)
344		f.sched.EXPECT().Schedule(ctx, n).Return().Once()
345		f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
346		f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
347		f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
348		require.NoError(t, f.pushes.Record(ctx, p, n, ups))
349	})
350
351	t.Run("a repository deleted before the default branch step is not an error", func(t *testing.T) {
352		t.Parallel()
353		f := newPushFixture(t)
354		f.sched.EXPECT().Schedule(ctx, n).Return().Once()
355		f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
356		f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, domain.ErrNotFound)
357		f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
358		require.NoError(t, f.pushes.Record(ctx, p, n, ups))
359	})
360
361	t.Run("a repository deleted before HEAD is set is not an error", func(t *testing.T) {
362		t.Parallel()
363		f := newPushFixture(t)
364		f.sched.EXPECT().Schedule(ctx, n).Return().Once()
365		f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
366		f.git.EXPECT().DefaultBranch(ctx, n).Return("trunk", false, nil)
367		f.git.EXPECT().SetDefaultBranch(ctx, n, "main").Return(domain.ErrNotFound)
368		f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
369		require.NoError(t, f.pushes.Record(ctx, p, n, ups))
370	})
371
372	t.Run("every step runs when one fails", func(t *testing.T) {
373		t.Parallel()
374		f := newPushFixture(t)
375		f.sched.EXPECT().Schedule(ctx, n).Return().Once()
376		boom := errors.New("boom")
377		f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, boom)
378		f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, boom)
379		f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
380		err := f.pushes.Record(ctx, p, n, ups)
381		assert.ErrorIs(t, err, boom)
382	})
383}
384
385func TestPushesDefaultBranchRule(t *testing.T) {
386	t.Parallel()
387	ctx := context.Background()
388	n := name(t, "tools")
389	p := domain.Root()
390	up := func(ref string) domain.RefUpdate { return domain.RefUpdate{Old: domain.ZeroOID, New: oidA, Ref: ref} }
391	del := func(ref string) domain.RefUpdate { return domain.RefUpdate{Old: oidA, New: domain.ZeroOID, Ref: ref} }
392
393	tests := []struct {
394		name    string
395		head    string
396		exists  bool
397		updates []domain.RefUpdate
398		want    string // "" = HEAD untouched
399	}{
400		{"HEAD already valid", "main", true, []domain.RefUpdate{up("refs/heads/dev")}, ""},
401		{"main preferred", "trunk", false, []domain.RefUpdate{up("refs/heads/dev"), up("refs/heads/main")}, "main"},
402		{"first pushed branch", "main", false, []domain.RefUpdate{up("refs/tags/v1"), up("refs/heads/dev"), up("refs/heads/feature")}, "dev"},
403		{"HEAD names another missing branch", "trunk", false, []domain.RefUpdate{up("refs/heads/dev")}, "dev"},
404		{"only tags pushed", "main", false, []domain.RefUpdate{up("refs/tags/v1")}, ""},
405		{"deleted branches never become HEAD", "main", false, []domain.RefUpdate{del("refs/heads/old"), up("refs/heads/dev")}, "dev"},
406		{"only deletions", "main", false, []domain.RefUpdate{del("refs/heads/old")}, ""},
407		{"HEAD already points to the only candidate", "dev", false, []domain.RefUpdate{up("refs/heads/dev")}, ""},
408	}
409	for _, tt := range tests {
410		t.Run(tt.name, func(t *testing.T) {
411			t.Parallel()
412			f := newPushFixture(t)
413			f.sched.EXPECT().Schedule(ctx, n).Return().Once()
414			f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil)
415			f.git.EXPECT().DefaultBranch(ctx, n).Return(tt.head, tt.exists, nil)
416			if tt.want != "" {
417				f.git.EXPECT().SetDefaultBranch(ctx, n, tt.want).Return(nil)
418			}
419			f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
420			require.NoError(t, f.pushes.Record(ctx, p, n, tt.updates))
421		})
422	}
423
424	t.Run("set failure is reported", func(t *testing.T) {
425		t.Parallel()
426		f := newPushFixture(t)
427		f.sched.EXPECT().Schedule(ctx, n).Return().Once()
428		boom := errors.New("boom")
429		f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil)
430		f.git.EXPECT().DefaultBranch(ctx, n).Return("main", false, nil)
431		f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)
432		f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
433		assert.ErrorIs(t, f.pushes.Record(ctx, p, n, []domain.RefUpdate{up("refs/heads/dev")}), boom)
434	})
435}
436
437// mockAny matches any argument of a mocked call.
438var mockAny = mock.Anything

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 45

↑↓ moveenter openesc close