v2.31.0-dev21 b59533b

push_remote_inference_test.go RawHistoryFolder

1package cli
2
3import (
4	"context"
5	"encoding/json"
6	"net/http"
7	"net/http/httptest"
8	"net/url"
9	"os"
10	"path/filepath"
11	"strings"
12	"testing"
13
14	"github.com/stretchr/testify/assert"
15	"github.com/stretchr/testify/require"
16
17	"github.com/bnema/gordon/internal/domain"
18)
19
20func TestInferPushRemote_UsesUniqueMatchingSavedRemote(t *testing.T) {
21	prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
22		assert.Equal(t, "myapp", image)
23		return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
24	})
25	staging := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
26		assert.Equal(t, "myapp", image)
27		return nil, http.StatusOK
28	})
29
30	configurePushRemoteInferenceTestEnv(t, `
31[remotes.prod]
32url = "`+prod.URL+`"
33
34[remotes.staging]
35url = "`+staging.URL+`"
36`)
37
38	resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
39	require.NoError(t, err)
40	require.NotNil(t, resolved)
41	assert.Equal(t, "prod", resolved.Name)
42	assert.Equal(t, prod.URL, resolved.URL)
43}
44
45func TestInferPushRemote_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
46	prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
47		return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
48	})
49	staging := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
50		return []domain.Route{{Domain: "staging.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
51	})
52
53	configurePushRemoteInferenceTestEnv(t, `
54[remotes.prod]
55url = "`+prod.URL+`"
56
57[remotes.staging]
58url = "`+staging.URL+`"
59`)
60
61	resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
62	require.Error(t, err)
63	assert.Nil(t, resolved)
64	assert.Contains(t, err.Error(), `multiple saved remotes match image "myapp"`)
65	assert.Contains(t, err.Error(), "prod")
66	assert.Contains(t, err.Error(), "staging")
67}
68
69func TestInferPushRemote_SkipsGuessWhenExplicitRemoteSelected(t *testing.T) {
70	originalRemoteFlag := remoteFlag
71	t.Cleanup(func() {
72		remoteFlag = originalRemoteFlag
73	})
74
75	called := false
76	prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
77		called = true
78		return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
79	})
80
81	configurePushRemoteInferenceTestEnv(t, `
82[remotes.prod]
83url = "`+prod.URL+`"
84`)
85	t.Setenv("GORDON_REMOTE", "")
86	remoteFlag = "prod"
87
88	resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
89	require.NoError(t, err)
90	assert.Nil(t, resolved)
91	assert.False(t, called)
92}
93
94func TestInferPushRemote_SkipsGuessWhenActiveRemoteConfigured(t *testing.T) {
95	called := false
96	prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
97		called = true
98		return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
99	})
100
101	configurePushRemoteInferenceTestEnv(t, `
102active = "prod"
103
104[remotes.prod]
105url = "`+prod.URL+`"
106`)
107
108	resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
109	require.NoError(t, err)
110	assert.Nil(t, resolved)
111	assert.False(t, called)
112}
113
114func TestInferPushRemote_IgnoresPreviewOnlyMatches(t *testing.T) {
115	prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
116		return []domain.Route{{Domain: "preview--pr-42.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
117	})
118
119	configurePushRemoteInferenceTestEnv(t, `
120[remotes.prod]
121url = "`+prod.URL+`"
122`)
123
124	resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
125	require.NoError(t, err)
126	assert.Nil(t, resolved)
127}
128
129func TestInferPushRemote_FailsSafeWhenSomeRemotesCannotBeProbed(t *testing.T) {
130	prod := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
131		return []domain.Route{{Domain: "app.example.com", Image: "registry.example.com/myapp:latest"}}, http.StatusOK
132	})
133	broken := newFindRoutesByImageTestServer(t, func(image string) ([]domain.Route, int) {
134		return nil, http.StatusInternalServerError
135	})
136
137	configurePushRemoteInferenceTestEnv(t, `
138[remotes.prod]
139url = "`+prod.URL+`"
140
141[remotes.broken]
142url = "`+broken.URL+`"
143`)
144
145	resolved, err := inferPushRemote(context.Background(), "myapp", "", "Dockerfile")
146	require.Error(t, err)
147	assert.Nil(t, resolved)
148	assert.Contains(t, err.Error(), `could not safely infer remote for image "myapp"`)
149	assert.Contains(t, err.Error(), "broken")
150}
151
152func TestInferRemoteForRouteDomain_UsesUniqueMatchingSavedRemote(t *testing.T) {
153	prod := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
154		return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
155	})
156	staging := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
157		return nil, http.StatusNotFound
158	})
159
160	configurePushRemoteInferenceTestEnv(t, `
161[remotes.prod]
162url = "`+prod.URL+`"
163
164[remotes.staging]
165url = "`+staging.URL+`"
166`)
167
168	resolved, err := inferRemoteForRouteDomain(context.Background(), "app.example.com")
169	require.NoError(t, err)
170	require.NotNil(t, resolved)
171	assert.Equal(t, "prod", resolved.Name)
172}
173
174func TestInferRemoteForRouteDomain_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
175	prod := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
176		return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
177	})
178	staging := newGetRouteTestServer(t, func(domainName string) (*domain.Route, int) {
179		return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
180	})
181
182	configurePushRemoteInferenceTestEnv(t, `
183[remotes.prod]
184url = "`+prod.URL+`"
185
186[remotes.staging]
187url = "`+staging.URL+`"
188`)
189
190	resolved, err := inferRemoteForRouteDomain(context.Background(), "app.example.com")
191	require.Error(t, err)
192	assert.Nil(t, resolved)
193	assert.Contains(t, err.Error(), `multiple saved remotes match route "app.example.com"`)
194}
195
196func TestInferRemoteForRouteCleanupDomain_UsesCleanupPreviewWhenRouteConfigMissing(t *testing.T) {
197	prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
198		getRoute: func(domainName string) (*domain.Route, int) {
199			return nil, http.StatusNotFound
200		},
201		getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
202			return &domain.CleanupReport{
203				Domain: domainName,
204				OrphanedEntities: []domain.CleanupOrphanedEntity{{
205					Kind:   "route_container",
206					ID:     "abc123",
207					Status: "running",
208				}},
209			}, http.StatusOK
210		},
211	})
212	staging := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
213		getRoute: func(domainName string) (*domain.Route, int) {
214			return nil, http.StatusNotFound
215		},
216		getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
217			return &domain.CleanupReport{Domain: domainName}, http.StatusOK
218		},
219	})
220
221	configurePushRemoteInferenceTestEnv(t, `
222[remotes.prod]
223url = "`+prod.URL+`"
224
225[remotes.staging]
226url = "`+staging.URL+`"
227`)
228
229	resolved, err := inferRemoteForRouteCleanupDomain(context.Background(), "app.example.com")
230	require.NoError(t, err)
231	require.NotNil(t, resolved)
232	assert.Equal(t, "prod", resolved.Name)
233}
234
235func TestInferRemoteForRouteCleanupDomain_UsesCleanupPreviewVolumesWhenRouteConfigMissing(t *testing.T) {
236	prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
237		getRoute: func(domainName string) (*domain.Route, int) {
238			return nil, http.StatusNotFound
239		},
240		getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
241			return &domain.CleanupReport{
242				Domain:           domainName,
243				PreservedVolumes: []domain.CleanupVolume{{Name: "gordon-app-example-com-data"}},
244			}, http.StatusOK
245		},
246	})
247	staging := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
248		getRoute: func(domainName string) (*domain.Route, int) {
249			return nil, http.StatusNotFound
250		},
251		getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
252			return &domain.CleanupReport{Domain: domainName}, http.StatusOK
253		},
254	})
255
256	configurePushRemoteInferenceTestEnv(t, `
257[remotes.prod]
258url = "`+prod.URL+`"
259
260[remotes.staging]
261url = "`+staging.URL+`"
262`)
263
264	resolved, err := inferRemoteForRouteCleanupDomain(context.Background(), "app.example.com")
265	require.NoError(t, err)
266	require.NotNil(t, resolved)
267	assert.Equal(t, "prod", resolved.Name)
268}
269
270func TestInferRemoteForRouteCleanupDomain_FailsSafeWhenCleanupPreviewProbeFails(t *testing.T) {
271	prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
272		getRoute: func(domainName string) (*domain.Route, int) {
273			return nil, http.StatusNotFound
274		},
275		getRouteCleanup: func(domainName string) (*domain.CleanupReport, int) {
276			return nil, http.StatusForbidden
277		},
278	})
279
280	configurePushRemoteInferenceTestEnv(t, `
281[remotes.prod]
282url = "`+prod.URL+`"
283`)
284
285	resolved, err := inferRemoteForRouteCleanupDomain(context.Background(), "app.example.com")
286	require.Error(t, err)
287	assert.Nil(t, resolved)
288	assert.Contains(t, err.Error(), `could not safely infer remote for route cleanup "app.example.com"`)
289}
290
291func TestInferRemoteForAttachmentImage_UsesUniqueMatchingSavedRemote(t *testing.T) {
292	prod := newAttachmentTargetsByImageTestServer(t, func(image string) ([]string, int) {
293		return []string{"app.example.com"}, http.StatusOK
294	})
295	staging := newAttachmentTargetsByImageTestServer(t, func(image string) ([]string, int) {
296		return nil, http.StatusOK
297	})
298
299	configurePushRemoteInferenceTestEnv(t, `
300[remotes.prod]
301url = "`+prod.URL+`"
302
303[remotes.staging]
304url = "`+staging.URL+`"
305`)
306
307	resolved, err := inferRemoteForAttachmentImage(context.Background(), "postgres:18")
308	require.NoError(t, err)
309	require.NotNil(t, resolved)
310	assert.Equal(t, "prod", resolved.Name)
311}
312
313func TestInferRemoteForAttachmentTarget_FallsBackToRouteLookup(t *testing.T) {
314	prod := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
315		getRoute: func(domainName string) (*domain.Route, int) {
316			return &domain.Route{Domain: domainName, Image: "registry.example.com/myapp:latest"}, http.StatusOK
317		},
318		getAttachmentsConfig: func(target string) ([]string, int) {
319			return nil, http.StatusNotFound
320		},
321	})
322	staging := newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{
323		getRoute: func(domainName string) (*domain.Route, int) {
324			return nil, http.StatusNotFound
325		},
326		getAttachmentsConfig: func(target string) ([]string, int) {
327			return nil, http.StatusNotFound
328		},
329	})
330
331	configurePushRemoteInferenceTestEnv(t, `
332[remotes.prod]
333url = "`+prod.URL+`"
334
335[remotes.staging]
336url = "`+staging.URL+`"
337`)
338
339	resolved, err := inferRemoteForAttachmentTarget(context.Background(), "app.example.com")
340	require.NoError(t, err)
341	require.NotNil(t, resolved)
342	assert.Equal(t, "prod", resolved.Name)
343}
344
345func TestInferRemoteForRepository_ReturnsAmbiguousErrorWhenMultipleRemotesMatch(t *testing.T) {
346	prod := newTagsTestServer(t, func(repository string) ([]string, int) {
347		return []string{"latest", "v1.0.0"}, http.StatusOK
348	})
349	staging := newTagsTestServer(t, func(repository string) ([]string, int) {
350		return []string{"latest"}, http.StatusOK
351	})
352
353	configurePushRemoteInferenceTestEnv(t, `
354[remotes.prod]
355url = "`+prod.URL+`"
356
357[remotes.staging]
358url = "`+staging.URL+`"
359`)
360
361	resolved, err := inferRemoteForRepository(context.Background(), "myapp")
362	require.Error(t, err)
363	assert.Nil(t, resolved)
364	assert.Contains(t, err.Error(), `multiple saved remotes match repository "myapp"`)
365}
366
367func configurePushRemoteInferenceTestEnv(t *testing.T, remotesTOML string) {
368	t.Helper()
369
370	originalRemoteFlag := remoteFlag
371	originalTokenFlag := tokenFlag
372	originalInsecureTLSFlag := insecureTLSFlag
373	t.Cleanup(func() {
374		remoteFlag = originalRemoteFlag
375		tokenFlag = originalTokenFlag
376		insecureTLSFlag = originalInsecureTLSFlag
377	})
378
379	remoteFlag = ""
380	tokenFlag = ""
381	insecureTLSFlag = false
382	t.Setenv("GORDON_REMOTE", "")
383	t.Setenv("GORDON_TOKEN", "")
384	t.Setenv("GORDON_INSECURE", "")
385	t.Setenv("HOME", t.TempDir())
386
387	configHome := t.TempDir()
388	t.Setenv("XDG_CONFIG_HOME", configHome)
389	configPath := filepath.Join(configHome, "gordon", "remotes.toml")
390	require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
391	require.NoError(t, os.WriteFile(configPath, []byte(strings.TrimSpace(remotesTOML)), 0o600))
392}
393
394func newFindRoutesByImageTestServer(t *testing.T, handler func(image string) ([]domain.Route, int)) *httptest.Server {
395	t.Helper()
396
397	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
398		if !strings.HasPrefix(r.URL.Path, "/admin/routes/by-image/") {
399			http.NotFound(w, r)
400			return
401		}
402
403		image, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/routes/by-image/"))
404		if !assert.NoError(t, err) {
405			return
406		}
407
408		routes, status := handler(image)
409		if status >= http.StatusBadRequest {
410			w.Header().Set("Content-Type", "application/json")
411			w.WriteHeader(status)
412			if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
413				return
414			}
415			return
416		}
417
418		w.Header().Set("Content-Type", "application/json")
419		if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{
420			"image":  image,
421			"routes": routes,
422		})) {
423			return
424		}
425	}))
426	t.Cleanup(server.Close)
427	return server
428}
429
430func newGetRouteTestServer(t *testing.T, handler func(domainName string) (*domain.Route, int)) *httptest.Server {
431	t.Helper()
432	return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{getRoute: handler})
433}
434
435type multiAdminProbeHandlers struct {
436	getRoute             func(domainName string) (*domain.Route, int)
437	getRouteCleanup      func(domainName string) (*domain.CleanupReport, int)
438	getAttachmentsConfig func(target string) ([]string, int)
439	listTags             func(repository string) ([]string, int)
440	findAttachmentTarget func(image string) ([]string, int)
441}
442
443func newMultiAdminProbeTestServer(t *testing.T, handlers multiAdminProbeHandlers) *httptest.Server {
444	t.Helper()
445
446	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
447		switch {
448		case strings.HasSuffix(r.URL.Path, "/cleanup") && strings.HasPrefix(r.URL.Path, "/admin/routes/"):
449			handleGetRouteCleanupProbe(t, w, r, handlers.getRouteCleanup)
450		case strings.HasPrefix(r.URL.Path, "/admin/routes/"):
451			handleGetRouteProbe(t, w, r, handlers.getRoute)
452		case strings.HasPrefix(r.URL.Path, "/admin/attachments/by-image/"):
453			handleAttachmentTargetsByImageProbe(t, w, r, handlers.findAttachmentTarget)
454		case strings.HasPrefix(r.URL.Path, "/admin/attachments/"):
455			handleGetAttachmentsConfigProbe(t, w, r, handlers.getAttachmentsConfig)
456		case strings.HasPrefix(r.URL.Path, "/admin/tags/"):
457			handleListTagsProbe(t, w, r, handlers.listTags)
458		default:
459			http.NotFound(w, r)
460		}
461	}))
462	t.Cleanup(server.Close)
463	return server
464}
465
466func newAttachmentTargetsByImageTestServer(t *testing.T, handler func(image string) ([]string, int)) *httptest.Server {
467	t.Helper()
468	return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{findAttachmentTarget: handler})
469}
470
471func newTagsTestServer(t *testing.T, handler func(repository string) ([]string, int)) *httptest.Server {
472	t.Helper()
473	return newMultiAdminProbeTestServer(t, multiAdminProbeHandlers{listTags: handler})
474}
475
476func handleGetRouteProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(domainName string) (*domain.Route, int)) {
477	t.Helper()
478	if handler == nil {
479		http.NotFound(w, r)
480		return
481	}
482	domainName, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/routes/"))
483	if !assert.NoError(t, err) {
484		return
485	}
486	route, status := handler(domainName)
487	if status >= http.StatusBadRequest {
488		w.Header().Set("Content-Type", "application/json")
489		w.WriteHeader(status)
490		if status == http.StatusNotFound {
491			if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": domain.ErrRouteNotFound.Error()})) {
492				return
493			}
494			return
495		}
496		if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
497			return
498		}
499		return
500	}
501	w.Header().Set("Content-Type", "application/json")
502	if !assert.NoError(t, json.NewEncoder(w).Encode(route)) {
503		return
504	}
505}
506
507func handleGetRouteCleanupProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(domainName string) (*domain.CleanupReport, int)) {
508	t.Helper()
509	if handler == nil {
510		http.NotFound(w, r)
511		return
512	}
513	domainName, err := url.PathUnescape(strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/admin/routes/"), "/cleanup"))
514	if !assert.NoError(t, err) {
515		return
516	}
517	report, status := handler(domainName)
518	if status >= http.StatusBadRequest {
519		w.Header().Set("Content-Type", "application/json")
520		w.WriteHeader(status)
521		if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
522			return
523		}
524		return
525	}
526	w.Header().Set("Content-Type", "application/json")
527	if !assert.NoError(t, json.NewEncoder(w).Encode(report)) {
528		return
529	}
530}
531
532func handleAttachmentTargetsByImageProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(image string) ([]string, int)) {
533	t.Helper()
534	if handler == nil {
535		http.NotFound(w, r)
536		return
537	}
538	image, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/attachments/by-image/"))
539	if !assert.NoError(t, err) {
540		return
541	}
542	targets, status := handler(image)
543	if status >= http.StatusBadRequest {
544		w.Header().Set("Content-Type", "application/json")
545		w.WriteHeader(status)
546		if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
547			return
548		}
549		return
550	}
551	w.Header().Set("Content-Type", "application/json")
552	if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{"image": image, "targets": targets})) {
553		return
554	}
555}
556
557func handleGetAttachmentsConfigProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(target string) ([]string, int)) {
558	t.Helper()
559	if handler == nil {
560		http.NotFound(w, r)
561		return
562	}
563	target, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/attachments/"))
564	if !assert.NoError(t, err) {
565		return
566	}
567	images, status := handler(target)
568	if status >= http.StatusBadRequest {
569		w.Header().Set("Content-Type", "application/json")
570		w.WriteHeader(status)
571		if status == http.StatusNotFound {
572			if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "no attachments found for target"})) {
573				return
574			}
575			return
576		}
577		if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
578			return
579		}
580		return
581	}
582	w.Header().Set("Content-Type", "application/json")
583	if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{"target": target, "images": images})) {
584		return
585	}
586}
587
588func handleListTagsProbe(t *testing.T, w http.ResponseWriter, r *http.Request, handler func(repository string) ([]string, int)) {
589	t.Helper()
590	if handler == nil {
591		http.NotFound(w, r)
592		return
593	}
594	repository, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/admin/tags/"))
595	if !assert.NoError(t, err) {
596		return
597	}
598	tags, status := handler(repository)
599	if status >= http.StatusBadRequest {
600		w.Header().Set("Content-Type", "application/json")
601		w.WriteHeader(status)
602		if status == http.StatusNotFound {
603			if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "repository not found"})) {
604				return
605			}
606			return
607		}
608		if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{"error": "boom"})) {
609			return
610		}
611		return
612	}
613	w.Header().Set("Content-Type", "application/json")
614	if !assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{"repository": repository, "tags": tags})) {
615		return
616	}
617}

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 7

Tags 106

↑↓ moveenter openesc close