v0.35.2 80d5067

automerge_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
21func autoRig(t *testing.T) (*mrRig, domain.MergeRequest, *mocks.CIChecksReader) {
22	t.Helper()
23	r := newMRRig(t)
24	r.seed()
25	r.feature("feat")
26	mr := r.open("feat")
27	checks := mocks.NewCIChecksReader(t)
28	r.mrs.SetChecks(checks)
29	_, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: ptr(true)})
30	require.NoError(t, err)
31	return r, mr, checks
32}
33
34func autoEvaluator(r *mrRig, checks ports.CIChecksReader, activity ports.CIActivity, tokens ports.TokenStore) *usecase.AutoMerge {
35	return usecase.NewAutoMerge(r.ctx, usecase.AutoMergeConfig{MRs: r.mrs, Store: r.store.MergeRequests(), Meta: r.store, Git: r.git, Checks: checks, Activity: activity, Tokens: tokens, Clock: fixedClock{}})
36}
37
38func enableAuto(t *testing.T, r *mrRig, mr domain.MergeRequest) domain.MergeRequest {
39	t.Helper()
40	enabled, err := r.mrs.EnableAutoMerge(r.ctx, root, r.repo, mr.Number, ports.EnableAutoMergeParams{HeadID: r.head()})
41	require.NoError(t, err)
42	require.NotNil(t, enabled.AutoMerge)
43	return enabled
44}
45
46func storedAuto(t *testing.T, r *mrRig, mr domain.MergeRequest) domain.MergeRequest {
47	t.Helper()
48	got, err := r.store.MergeRequests().Get(r.ctx, r.repo, mr.Number)
49	require.NoError(t, err)
50	return got
51}
52
53func TestEnableAutoMergeNeedsSettingScopeAndRef(t *testing.T) {
54	for _, tc := range []string{"setting", "scope", "ref", "draft", "no CI", "closed", "mode"} {
55		t.Run(tc, func(t *testing.T) {
56			r, mr, _ := autoRig(t)
57			p := root
58			want := domain.ErrPreconditionFailed
59			switch tc {
60			case "setting":
61				_, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: ptr(false)})
62				require.NoError(t, err)
63			case "scope":
64				p = tokenPrincipal(domain.ScopeMRWrite, r.repo.String())
65				want = domain.ErrNotFound
66			case "ref":
67				p = domain.TokenPrincipal(domain.Token{ID: "limited", Scopes: []domain.Scope{domain.ScopeMRMerge}, Repos: []domain.RepoName{r.repo}, BranchPrefixes: []string{"feat"}})
68				want = domain.ErrForbidden
69			case "draft":
70				mr.Title, _ = domain.ParseMRTitle("WIP: work")
71				require.NoError(t, r.store.MergeRequests().Update(r.ctx, mr))
72			case "no CI":
73				r.mrs.SetChecks(nil)
74			case "closed":
75				_, err := r.mrs.Close(r.ctx, root, r.repo, mr.Number)
76				require.NoError(t, err)
77			case "mode":
78				_, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: ptr([]domain.MergeMode{domain.MergeModeSquash})})
79				require.NoError(t, err)
80			}
81			_, err := r.mrs.EnableAutoMerge(r.ctx, p, r.repo, mr.Number, ports.EnableAutoMergeParams{HeadID: r.head()})
82			require.ErrorIs(t, err, want)
83		})
84	}
85}
86
87func TestEnableAutoMergeRecordsEnablerModeHeadAndPublishes(t *testing.T) {
88	r, mr, _ := autoRig(t)
89	trigger := mocks.NewAutoMergeTrigger(t)
90	trigger.EXPECT().OnAutoMergeEnabled(mock.Anything, r.repo).Return().Twice()
91	r.mrs.WithAutoMerge(trigger)
92	r.drain()
93	got, err := r.mrs.EnableAutoMerge(r.ctx, domain.Owner(), r.repo, mr.Number, ports.EnableAutoMergeParams{Mode: domain.MergeModeSquash, HeadID: r.head()})
94	require.NoError(t, err)
95	assert.Equal(t, &domain.AutoMerge{Mode: domain.MergeModeSquash, HeadID: r.head(), EnabledBy: domain.PusherOf(domain.Owner()), EnablerLabel: domain.AuthorName(domain.Owner()), EnabledAt: now}, got.AutoMerge)
96	events := r.drain()
97	require.Len(t, events, 1)
98	assert.IsType(t, domain.MergeRequestAutoMergeEnabled{}, events[0])
99	got = enableAuto(t, r, mr)
100	assert.Equal(t, domain.PusherOf(root), got.AutoMerge.EnabledBy)
101	assert.Equal(t, domain.MergeModeFastForward, got.AutoMerge.Mode)
102}
103
104func TestCancelAutoMergePublishesReason(t *testing.T) {
105	r, mr, _ := autoRig(t)
106	enableAuto(t, r, mr)
107	r.drain()
108	got, err := r.mrs.CancelAutoMerge(r.ctx, root, r.repo, mr.Number)
109	require.NoError(t, err)
110	assert.Nil(t, got.AutoMerge)
111	events := r.drain()
112	require.Len(t, events, 1)
113	ev, ok := events[0].(domain.MergeRequestAutoMergeCancelled)
114	require.True(t, ok)
115	assert.NotEmpty(t, ev.Reason)
116	_, err = r.mrs.CancelAutoMerge(r.ctx, root, r.repo, mr.Number)
117	require.ErrorIs(t, err, domain.ErrPreconditionFailed)
118}
119
120func TestAutoMergeWaitsWhileCINotSettledOrChecksPending(t *testing.T) {
121	r, mr, checks := autoRig(t)
122	enableAuto(t, r, mr)
123	activity := mocks.NewCIActivity(t)
124	activity.EXPECT().Settled(mock.Anything, r.repo, mr.Number).Return(false).Once()
125	activity.EXPECT().Settled(mock.Anything, r.repo, mr.Number).Return(true).Twice()
126	checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}, {Status: domain.CIQueued, Name: "pending"}}, nil)
127	a := autoEvaluator(r, checks, activity, nil)
128	defer a.Close(r.ctx)
129	require.True(t, a.Evaluate(r.repo))
130	require.True(t, a.Evaluate(r.repo))
131	assert.True(t, storedAuto(t, r, mr).IsOpen())
132	assert.NotNil(t, storedAuto(t, r, mr).AutoMerge)
133}
134
135func TestAutoMergeMergesAsEnablerWhenAChecksPassed(t *testing.T) {
136	r, mr, checks := autoRig(t)
137	trigger := mocks.NewWorkflowTrigger(t)
138	trigger.EXPECT().OnMerge(mock.Anything, r.repo, mock.Anything, root).Return().Once()
139	r.mrs.SetWorkflowTrigger(trigger)
140	enableAuto(t, r, mr)
141	// One pre-lock fast-path read and exactly one under-lock snapshot.
142	checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil).Twice()
143	a := autoEvaluator(r, checks, nil, nil)
144	defer a.Close(r.ctx)
145	r.drain()
146	require.True(t, a.Evaluate(r.repo))
147	got := storedAuto(t, r, mr)
148	assert.Equal(t, domain.MRStateMerged, got.State)
149	assert.True(t, got.AutoMerged)
150	assert.Nil(t, got.AutoMerge)
151	events := r.drain()
152	found := false
153	for _, ev := range events {
154		if merged, ok := ev.(domain.MergeRequestMerged); ok {
155			assert.True(t, merged.Auto)
156			found = true
157		}
158	}
159	require.True(t, found)
160}
161
162func TestAutoMergeRequiresPassingChecks(t *testing.T) {
163	for _, tc := range []struct {
164		name  string
165		check domain.CICheck
166	}{
167		{"failure", domain.CICheck{Status: domain.CICompleted, Conclusion: domain.CIFailure}},
168		{"pending", domain.CICheck{Status: domain.CIQueued}},
169	} {
170		t.Run(tc.name, func(t *testing.T) {
171			r, mr, checks := autoRig(t)
172			enableAuto(t, r, mr)
173			checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}, tc.check}, nil)
174			a := autoEvaluator(r, checks, nil, nil)
175			defer a.Close(r.ctx)
176			require.True(t, a.Evaluate(r.repo))
177			assert.True(t, storedAuto(t, r, mr).IsOpen())
178			assert.NotNil(t, storedAuto(t, r, mr).AutoMerge)
179		})
180	}
181}
182
183func TestAutoMergeCancelsOnNewHead(t *testing.T)               { testAutoCancellation(t, "head") }
184func TestAutoMergeCancelsWhenModeNoLongerAllowed(t *testing.T) { testAutoCancellation(t, "mode") }
185func TestAutoMergeCancelsWhenSettingTurnedOff(t *testing.T)    { testAutoCancellation(t, "setting") }
186func testAutoCancellation(t *testing.T, change string) {
187	r, mr, checks := autoRig(t)
188	enableAuto(t, r, mr)
189	switch change {
190	case "head":
191		r.commit(author, "new", "new", "new head")
192		r.push("feat")
193	case "mode":
194		_, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: ptr([]domain.MergeMode{domain.MergeModeSquash})})
195		require.NoError(t, err)
196	case "setting":
197		_, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: ptr(false)})
198		require.NoError(t, err)
199	}
200	a := autoEvaluator(r, checks, nil, nil)
201	defer a.Close(r.ctx)
202	r.drain()
203	require.True(t, a.Evaluate(r.repo))
204	assert.Nil(t, storedAuto(t, r, mr).AutoMerge)
205	events := r.drain()
206	require.Len(t, events, 1)
207	assert.IsType(t, domain.MergeRequestAutoMergeCancelled{}, events[0])
208}
209
210func TestAutoMergeCancelsWhenEnablerTokenRevokedOrExpired(t *testing.T) {
211	for _, expired := range []bool{false, true} {
212		t.Run(map[bool]string{false: "revoked", true: "expired"}[expired], func(t *testing.T) {
213			r, mr, checks := autoRig(t)
214			enableAuto(t, r, mr)
215			enabled := storedAuto(t, r, mr)
216			enabled.AutoMerge.EnabledBy = domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "gone"}
217			require.NoError(t, r.store.MergeRequests().Update(r.ctx, enabled))
218			tokens := mocks.NewTokenStore(t)
219			// The enabler is checked before CI: no check is read.
220			if expired {
221				tokens.EXPECT().Get(mock.Anything, "gone").Return(domain.Token{ExpiresAt: now.Add(-time.Hour)}, nil)
222			} else {
223				tokens.EXPECT().Get(mock.Anything, "gone").Return(domain.Token{}, domain.ErrNotFound)
224			}
225			a := autoEvaluator(r, checks, nil, tokens)
226			defer a.Close(r.ctx)
227			require.True(t, a.Evaluate(r.repo))
228			assert.Nil(t, storedAuto(t, r, mr).AutoMerge)
229			assert.True(t, storedAuto(t, r, mr).IsOpen())
230		})
231	}
232}
233
234func TestAutoMergeWaitsOnConflictingOrBehindHead(t *testing.T) {
235	for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} {
236		t.Run(mode.String(), func(t *testing.T) {
237			r, mr, checks := autoRig(t)
238			_, err := r.mrs.EnableAutoMerge(r.ctx, root, r.repo, mr.Number, ports.EnableAutoMergeParams{Mode: mode, HeadID: r.head()})
239			require.NoError(t, err)
240			head := r.head()
241			r.sh("checkout", "main")
242			r.commit(author, "a.txt", "conflicting target\n", "move target")
243			r.push("main")
244			checks.EXPECT().CommitChecks(mock.Anything, r.repo, head.String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil)
245			a := autoEvaluator(r, checks, nil, nil)
246			defer a.Close(r.ctx)
247			require.True(t, a.Evaluate(r.repo))
248			assert.NotNil(t, storedAuto(t, r, mr).AutoMerge)
249			assert.True(t, storedAuto(t, r, mr).IsOpen())
250		})
251	}
252}
253
254func TestManualMergeOrCloseClearsAutoMerge(t *testing.T) {
255	for _, closeMR := range []bool{false, true} {
256		t.Run(map[bool]string{false: "merge", true: "close"}[closeMR], func(t *testing.T) {
257			r, mr, checks := autoRig(t)
258			enabled := enableAuto(t, r, mr)
259			if closeMR {
260				_, err := r.mrs.Close(r.ctx, root, r.repo, mr.Number)
261				require.NoError(t, err)
262			} else {
263				checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return(nil, nil)
264				_, err := r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: enabled.AutoMerge.HeadID})
265				require.NoError(t, err)
266			}
267			got := storedAuto(t, r, mr)
268			assert.Nil(t, got.AutoMerge)
269			assert.False(t, got.AutoMerged)
270		})
271	}
272}
273
274func TestAutoMergeStartupRescan(t *testing.T) {
275	r, mr, checks := autoRig(t)
276	enableAuto(t, r, mr)
277	checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil)
278	a := autoEvaluator(r, checks, nil, nil)
279	a.Start()
280	defer a.Close(r.ctx)
281	require.Eventually(t, func() bool {
282		got, err := r.store.MergeRequests().Get(r.ctx, r.repo, mr.Number)
283		return err == nil && got.AutoMerged
284	}, 5*time.Second, time.Millisecond)
285	a.Close(r.ctx)
286	r.mrs.Wait(r.ctx)
287}
288
289func TestAutoMergeRetriesTransientErrors(t *testing.T) {
290	ctx := context.Background()
291	store := mocks.NewMergeRequestStore(t)
292	meta := mocks.NewRepoMetaStore(t)
293	clock := mocks.NewClock(t)
294	clock.EXPECT().Now().Return(now).Maybe()
295	repo := name(t, "proj")
296	store.EXPECT().ListOpenAll(mock.Anything).Return([]domain.MergeRequest{{Repo: repo, AutoMerge: &domain.AutoMerge{}}}, nil)
297	meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{}, errors.New("temporary read error")).Once()
298	meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo}, nil).Once()
299	done := make(chan struct{}, 1)
300	store.EXPECT().ListOpen(mock.Anything, repo).Run(func(context.Context, domain.RepoName) { done <- struct{}{} }).Return(nil, nil).Once()
301	timer := make(chan time.Time, 1)
302	armed := make(chan time.Duration, 1)
303	a := usecase.NewAutoMerge(ctx, usecase.AutoMergeConfig{Store: store, Meta: meta, Clock: clock, After: func(d time.Duration) <-chan time.Time { armed <- d; return timer }})
304	a.Start()
305	defer a.Close(ctx)
306	select {
307	case d := <-armed:
308		assert.Equal(t, time.Second, d)
309	case <-time.After(5 * time.Second):
310		t.Fatal("no retry scheduled")
311	}
312	timer <- now.Add(time.Second)
313	select {
314	case <-done:
315	case <-time.After(5 * time.Second):
316		t.Fatal("retry did not evaluate")
317	}
318}
319
320func TestAutoMergeKickFromUnderRunLockDoesNotDeadlock(t *testing.T) {
321	r, mr, checks := autoRig(t)
322	enableAuto(t, r, mr)
323	checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil)
324	a := autoEvaluator(r, checks, nil, nil)
325	ci := newCIUnit(t, nil).ci
326	ci.SetAutoMergeTrigger(a)
327	gate := make(chan struct{})
328	entered := make(chan struct{})
329	r.mrs.SetAfterMergeGate(func() { close(entered); <-gate })
330	defer func() { close(gate); a.Close(r.ctx); r.mrs.Wait(r.ctx) }()
331	require.NoError(t, r.mrs.UnderRepoLock(r.ctx, r.repo, func() {
332		a.Start()
333		done := make(chan struct{})
334		go func() {
335			ci.UnderRunLock(1, func() { a.OnRepoChanged(r.ctx, r.repo) })
336			close(done)
337		}()
338		select {
339		case <-done:
340		case <-time.After(5 * time.Second):
341			t.Fatal("kick blocked behind merge lock")
342		}
343	}))
344	select {
345	case <-entered:
346	case <-time.After(5 * time.Second):
347		t.Fatal("merge never reached post-merge gate")
348	}
349}
350
351func TestAutoMergeRequiresSuccessfulCheck(t *testing.T) {
352	for _, checksOnHead := range [][]domain.CICheck{nil, {{Status: domain.CICompleted, Conclusion: domain.CISkipped}}, {{Status: domain.CICompleted, Conclusion: domain.CINeutral}}} {
353		r, mr, checks := autoRig(t)
354		enableAuto(t, r, mr)
355		checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return(checksOnHead, nil)
356		a := autoEvaluator(r, checks, nil, nil)
357		require.True(t, a.Evaluate(r.repo))
358		a.Close(r.ctx)
359		assert.True(t, storedAuto(t, r, mr).IsOpen())
360		assert.NotNil(t, storedAuto(t, r, mr).AutoMerge)
361	}
362}
363
364func TestEnableAutoMergeNeedsTheHeadTheCallerSaw(t *testing.T) {
365	t.Run("stale head", func(t *testing.T) {
366		r, mr, _ := autoRig(t)
367		stale := r.head()
368		r.commit(author, "later.txt", "later\n", "push after review")
369		r.push("feat")
370		r.drain()
371		_, err := r.mrs.EnableAutoMerge(r.ctx, root, r.repo, mr.Number, ports.EnableAutoMergeParams{HeadID: stale})
372		require.ErrorIs(t, err, domain.ErrPreconditionFailed)
373		assert.Contains(t, err.Error(), "branch moved, read again")
374		assert.Nil(t, storedAuto(t, r, mr).AutoMerge)
375		assert.Empty(t, r.drain(), "nothing is published")
376	})
377	t.Run("missing head", func(t *testing.T) {
378		r, mr, _ := autoRig(t)
379		_, err := r.mrs.EnableAutoMerge(r.ctx, root, r.repo, mr.Number, ports.EnableAutoMergeParams{})
380		require.ErrorIs(t, err, domain.ErrInvalid)
381		assert.Nil(t, storedAuto(t, r, mr).AutoMerge)
382	})
383}
384
385func TestMergeGateSettingsUpdateWaits(t *testing.T) {
386	for _, params := range []ports.UpdateRepoParams{
387		{AllowAutoMerge: ptr(false)},
388		{AllowedMergeModes: ptr([]domain.MergeMode{domain.MergeModeSquash})},
389	} {
390		r, _, _ := autoRig(t)
391		r.repos.WithMergeGate(r.mrs)
392		entered, release, done := make(chan struct{}), make(chan struct{}), make(chan error, 1)
393		go func() {
394			done <- r.mrs.WithMergeLock(r.ctx, r.repo, func() error { close(entered); <-release; return nil })
395		}()
396		<-entered
397		ctx, cancel := context.WithTimeout(r.ctx, 20*time.Millisecond)
398		_, err := r.repos.Update(ctx, root, r.repo, params)
399		cancel()
400		require.ErrorIs(t, err, context.DeadlineExceeded)
401		close(release)
402		require.NoError(t, <-done)
403		_, err = r.repos.Update(r.ctx, root, r.repo, params)
404		require.NoError(t, err)
405	}
406}
407
408func TestAutoMergeResolvesEnablerAfterMergeGate(t *testing.T) {
409	r, mr, checks := autoRig(t)
410	enableAuto(t, r, mr)
411	enabled := storedAuto(t, r, mr)
412	enabled.AutoMerge.EnabledBy = domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "revoked"}
413	require.NoError(t, r.store.MergeRequests().Update(r.ctx, enabled))
414	tokens := mocks.NewTokenStore(t)
415	// Valid when the evaluator looks before CI, revoked by the time it holds the
416	// merge lock: the merge must use the fresh answer.
417	validTok := domain.Token{ID: "revoked", Scopes: []domain.Scope{domain.ScopeMRMerge}, Repos: []domain.RepoName{r.repo}}
418	tokens.EXPECT().Get(mock.Anything, "revoked").Return(validTok, nil).Once()
419	tokens.EXPECT().Get(mock.Anything, "revoked").Return(domain.Token{}, domain.ErrNotFound).Once()
420	reached := make(chan struct{})
421	checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Run(func(context.Context, domain.RepoName, string) { close(reached) }).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil).Once()
422	a := autoEvaluator(r, checks, nil, tokens)
423	defer a.Close(r.ctx)
424	done := make(chan bool, 1)
425	require.NoError(t, r.mrs.WithMergeLock(r.ctx, r.repo, func() error {
426		go func() { done <- a.Evaluate(r.repo) }()
427		<-reached
428		// Evaluation has passed its fast-path checks with the then-valid token
429		// and waits for this lock. The token is revoked meanwhile (second Get).
430		return nil
431	}))
432	require.True(t, <-done)
433	got := storedAuto(t, r, mr)
434	require.Nil(t, got.AutoMerge)
435	require.True(t, got.IsOpen())
436}
437
438func TestAutoMergeRechecksSettledUnderMergeGate(t *testing.T) {
439	r, mr, checks := autoRig(t)
440	enableAuto(t, r, mr)
441	activity := mocks.NewCIActivity(t)
442	activity.EXPECT().Settled(mock.Anything, r.repo, mr.Number).Return(true).Once()
443	activity.EXPECT().Settled(mock.Anything, r.repo, mr.Number).Return(false).Once()
444	checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil).Once()
445	a := autoEvaluator(r, checks, activity, nil)
446	defer a.Close(r.ctx)
447	require.True(t, a.Evaluate(r.repo))
448	require.True(t, storedAuto(t, r, mr).IsOpen())
449	require.NotNil(t, storedAuto(t, r, mr).AutoMerge)
450}

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