v0.27.0 0039612

forge.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"context"
7	"crypto/ed25519"
8	"crypto/rand"
9	"crypto/rsa"
10	"errors"
11	"fmt"
12	"net"
13	"os"
14	"path/filepath"
15	"strings"
16	"time"
17
18	"github.com/bnema/zerowrap"
19
20	"bnema.dev/ermite/internal/adapters/ciartifacts"
21	"bnema.dev/ermite/internal/adapters/cilogs"
22	"bnema.dev/ermite/internal/adapters/cisummaries"
23	"bnema.dev/ermite/internal/adapters/ermitegit"
24	"bnema.dev/ermite/internal/adapters/eventbus"
25	"bnema.dev/ermite/internal/adapters/gitcli"
26	"bnema.dev/ermite/internal/adapters/githttp"
27	"bnema.dev/ermite/internal/adapters/githubapi"
28	"bnema.dev/ermite/internal/adapters/githubraw"
29	"bnema.dev/ermite/internal/adapters/gitremote"
30	"bnema.dev/ermite/internal/adapters/httpserver"
31	"bnema.dev/ermite/internal/adapters/kvstore"
32	"bnema.dev/ermite/internal/adapters/oidchttp"
33	"bnema.dev/ermite/internal/adapters/rpc"
34	"bnema.dev/ermite/internal/adapters/secretbox"
35	"bnema.dev/ermite/internal/adapters/sigverify"
36	"bnema.dev/ermite/internal/adapters/sshsig"
37	"bnema.dev/ermite/internal/adapters/web"
38	"bnema.dev/ermite/internal/adapters/webauthn"
39	"bnema.dev/ermite/internal/clientlimit"
40	"bnema.dev/ermite/internal/config"
41	"bnema.dev/ermite/internal/domain"
42	"bnema.dev/ermite/internal/ports"
43	"bnema.dev/ermite/internal/usecase"
44)
45
46// maxReceiveBytes bounds the body of a push, both at the HTTP handler and in
47// Git (receive.maxInputSize).
48const maxReceiveBytes = githttp.DefaultMaxBodyBytes
49
50// options are the parts of the wiring that tests replace.
51type options struct {
52	// hookExe is the executable the hook scripts exec; empty means this
53	// process's own executable.
54	hookExe string
55	// maintenanceDelay overrides usecase.MaintenanceDelay when positive.
56	maintenanceDelay time.Duration
57	// maxBodyBytes overrides the limit of a push body when positive.
58	maxBodyBytes int64
59	// eventBuffer overrides usecase.DefaultEventBuffer when positive.
60	eventBuffer int
61	// revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero.
62	revalidateEvery time.Duration
63	// archivesPerClient overrides the archives one client downloads at once
64	// when positive.
65	archivesPerClient int
66	// remote replaces the lister of github.com refs (tests never reach the
67	// network).
68	remoteLister ports.GitRemote
69	// workflowFetcher replaces the downloader of github.com reusable workflows
70	// (tests never reach the network).
71	workflowFetcher ports.WorkflowFetcher
72	// ci overrides the settings of the CI engine (tests shorten its
73	// durations).
74	ci func(*usecase.CIConfig)
75	// oidcKey generates the signing keys of the ID tokens; nil generates RSA
76	// keys (tests give prepared ones: a 3072-bit key takes a while).
77	oidcKey func() (*rsa.PrivateKey, error)
78}
79
80// remote returns the lister of github.com refs: the test's, else def.
81func (o options) remote(def ports.GitRemote) ports.GitRemote {
82	if o.remoteLister != nil {
83		return o.remoteLister
84	}
85	return def
86}
87
88// workflows returns the downloader of github.com reusable workflows: the
89// test's, else def.
90func (o options) workflows(def ports.WorkflowFetcher) ports.WorkflowFetcher {
91	if o.workflowFetcher != nil {
92		return o.workflowFetcher
93	}
94	return def
95}
96
97// forge is the wired server: every adapter and use case, ready to serve.
98type forge struct {
99	cfg    config.Config
100	store  *kvstore.Store
101	server *httpserver.Server
102	hooks  *githttp.HookServer
103	maint  *usecase.Maintainer
104	git    *gitcli.Git
105	// reader is the ermite-git reader when ERMITE_GIT_READER selects it.
106	reader *ermitegit.Adapter
107
108	// The use cases and the API are exposed for tests and for the phases
109	// that add the web.
110	Repos  *usecase.Repos
111	Tokens *usecase.Tokens
112	Auth   *usecase.Auth
113	Pushes *usecase.Pushes
114	Browse *usecase.Browse
115	// MergeRequests and SigningKey serve MergeRequestService and
116	// ServerService.GetSigningKey.
117	MergeRequests *usecase.MergeRequests
118	SigningKey    *usecase.SigningKey
119	// Signatures are the trusted keys and the commit signature checks
120	// (ADR 0030).
121	Signatures *usecase.Signatures
122	Sessions   *usecase.Sessions
123	// Passkeys is nil when passkeys are not available (passkeysAvailable).
124	Passkeys *usecase.Passkeys
125	EventLog *usecase.Events
126	// CI is the CI engine: workflow trigger, planner, runner protocol and the
127	// owner side. It starts with the server and stops with it.
128	CI *usecase.CI
129	// Settings are the secrets, variables and environments of CI.
130	Settings *usecase.CISettings
131	// OIDC is the provider of the ID tokens of CI jobs; nil without a secrets
132	// key.
133	OIDC *usecase.OIDC
134	API  *rpc.API
135	Bus  ports.EventBus
136}
137
138// newSigningSeed makes the seed of a new ed25519 signing key.
139func newSigningSeed() ([]byte, error) {
140	seed := make([]byte, ed25519.SeedSize)
141	if _, err := rand.Read(seed); err != nil {
142		return nil, err
143	}
144	return seed, nil
145}
146
147// serverVersion is the version GetVersion reports: the module version of the
148// build, or "devel" for an untagged one.
149func serverVersion() string {
150	if v := buildVersion(); v != "(devel)" {
151		return v
152	}
153	return "devel"
154}
155
156// newForge opens the metadata store, resets run/, checks git, reconciles the repositories,
157// writes the hook scripts, opens the hook socket and builds the HTTP server.
158// The data directory must be prepared (prepareDataDir). Close it after the
159// server stopped.
160func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, err error) {
161	log := zerowrap.FromCtx(ctx)
162	store, err := kvstore.Open(ctx, filepath.Join(cfg.DataDir, "kv"))
163	if err != nil {
164		return nil, err
165	}
166	f := &forge{cfg: cfg, store: store}
167	defer func() {
168		if err != nil {
169			f.Close(context.WithoutCancel(ctx))
170		}
171	}()
172	// The kv store holds the directory lock: only now is run/ ours to reset.
173	// The hook scripts and the socket are written into it afterwards.
174	if err := resetRunDir(cfg.DataDir); err != nil {
175		return nil, err
176	}
177
178	path := os.Getenv("PATH")
179	if path == "" {
180		path = "/usr/local/bin:/usr/bin:/bin"
181	}
182	maxBody := maxReceiveBytes
183	if opts.maxBodyBytes > 0 {
184		maxBody = opts.maxBodyBytes
185	}
186	git := gitcli.New(gitcli.Config{Bin: cfg.Git, DataDir: cfg.DataDir, Path: path, MaxReceiveBytes: maxBody})
187	f.git = git
188	gitVersion, err := git.CheckVersion(ctx)
189	if err != nil {
190		return nil, fmt.Errorf("%s=%q: %w", config.EnvGit, cfg.Git, err)
191	}
192	log.Info().Str("git", gitVersion.String()).Msg("git checked")
193	if _, err := git.HTTPBackend(ctx, false); err != nil {
194		return nil, fmt.Errorf("%s=%q: %w", config.EnvGit, cfg.Git, err)
195	}
196
197	clock := systemClock{}
198	f.Bus = eventbus.New()
199	f.Repos = usecase.NewRepos(store, git, f.Bus, clock)
200	report, err := f.Repos.Reconcile(ctx)
201	if err != nil {
202		return nil, fmt.Errorf("reconcile repositories: %w", err)
203	}
204	log.Info().
205		Int("registered", len(report.Registered)).
206		Int("missing_dirs", len(report.MissingDirs)).
207		Int("invalid_dirs", len(report.InvalidDirs)).
208		Msg("repositories reconciled")
209
210	f.Tokens = usecase.NewTokens(store.Tokens(), clock, store.Sessions())
211	f.Auth = usecase.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock).WithRepoTokens(store.CI())
212	f.maint = usecase.NewMaintainer(ctx, git, opts.maintenanceDelay)
213	f.Pushes = usecase.NewPushes(store, f.Repos, git, f.Bus, clock, f.maint, f.Auth)
214
215	exe := opts.hookExe
216	if exe == "" {
217		if exe, err = os.Executable(); err != nil {
218			return nil, fmt.Errorf("find the ermite executable for the hooks: %w", err)
219		}
220	}
221	if err := githttp.WriteHookScripts(gitcli.HooksDir(cfg.DataDir), exe); err != nil {
222		return nil, err
223	}
224	f.hooks = githttp.NewHookServer(filepath.Join(cfg.DataDir, "run", "hook.sock"), f.Pushes)
225	if err := f.hooks.Listen(ctx); err != nil {
226		return nil, err
227	}
228
229	var reader ports.RepoReader = git
230	if cfg.GitReader == config.GitReaderErmiteGit {
231		f.reader = ermitegit.New(ermitegit.Config{
232			Fallback: git,
233			RepoDir:  func(name domain.RepoName) string { return gitcli.RepoDir(cfg.DataDir, name) },
234		})
235		git.OnTrash(f.reader.Forget)
236		f.maint.WithFreshener(f.reader)
237		f.Pushes.WithFreshener(f.reader)
238		reader = f.reader
239	}
240	log.Info().Str("reader", cfg.GitReader).Msg("git reader selected")
241	f.Browse = usecase.NewBrowse(store, reader)
242
243	// Secrets, variables and environments (ADR 0019, CI-3). With a key the
244	// secrets are sealed under it, and the server refuses to start when the
245	// key is not the one they were sealed with; without one they cannot be set
246	// and jobs receive none.
247	var sealer ports.SecretSealer // a nil *secretbox.Box must not become a non-nil interface
248	if cfg.SecretsKey != nil {
249		box, err := secretbox.New(cfg.SecretsKey)
250		if err != nil {
251			return nil, fmt.Errorf("%s: %w", config.EnvSecretsKey, err)
252		}
253		sealer = box
254	}
255	f.Settings, err = usecase.NewCISettings(ctx, store, store.Settings(), sealer, clock)
256	if err != nil {
257		return nil, fmt.Errorf("%s: %w", config.EnvSecretsKey, err)
258	}
259	// The OIDC provider of CI jobs (CI-4): its signing key is sealed with the
260	// secrets key, so there is none without one.
261	f.OIDC, err = usecase.NewOIDC(ctx, usecase.OIDCConfig{
262		Store: store, Sealer: sealer, Tokens: store.CI(), Clock: clock, PublicURL: cfg.PublicURL, NewKey: opts.oidcKey,
263	})
264	if err != nil {
265		return nil, fmt.Errorf("oidc: %w", err)
266	}
267
268	// CI (ADR 0025): the engine reads workflows through the reader, and plans
269	// them in the background; a push only queues its refs.
270	committer := domain.Identity{Name: cfg.CommitterName, Email: cfg.CommitterEmail}
271	ciCfg := usecase.CIConfig{
272		Store: store.CI(), Logs: cilogs.New(cilogs.Dir(cfg.DataDir)), Summaries: cisummaries.New(cisummaries.Dir(cfg.DataDir)), Meta: store, Repos: git, Reader: reader,
273		Git: git, Diff: git, Bus: f.Bus, Clock: clock, Auth: f.Auth,
274		Owner: cfg.Owner, PublicURL: cfg.PublicURL, Retention: cfg.CIRetention,
275		PollTimeout: cfg.CIPollTimeout, Archive: git,
276		Remote: opts.remote(gitremote.New(gitremote.Config{
277			Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"),
278			Proxy: gitremote.ProxyEnv(os.Getenv),
279		})),
280		Tokens:        store.Tokens(),
281		RefTips:       store.CI(),
282		WorkflowState: store.CI(),
283		Settings:      f.Settings,
284		Workflows:     opts.workflows(githubraw.New(githubraw.Config{})),
285		OIDC:          f.OIDC,
286	}
287	artifacts(&ciCfg, cfg, store)
288	f.pullRequests(&ciCfg, store, git, committer)
289	if opts.ci != nil {
290		opts.ci(&ciCfg)
291	}
292	f.CI = usecase.NewCI(ctx, ciCfg)
293	f.Pushes.WithWorkflowTrigger(f.CI).WithWorkflowGuard(git).WithRefTips(store.CI())
294	f.Repos.WithCIRunCleaner(f.CI)
295	f.CI.Start()
296	f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{
297		Size: opts.eventBuffer, Auth: f.Auth, RevalidateEvery: opts.revalidateEvery,
298	})
299
300	// The instance signing key (ADR 0022): generated on the first start.
301	seed, err := store.SigningSeed(ctx, newSigningSeed, clock.Now().UTC())
302	if err != nil {
303		return nil, err
304	}
305	signer, err := sshsig.New(seed)
306	if err != nil {
307		return nil, err
308	}
309	f.SigningKey = usecase.NewSigningKey(signer, committer)
310	if err := f.signatures(store, reader, signer.PublicKey(), committer, clock); err != nil {
311		return nil, err
312	}
313	f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
314		Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
315		Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
316		CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(),
317	})
318	f.MergeRequests.WithVerifier(f.Signatures)
319	f.CI.SetPullGate(f.MergeRequests)
320	log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
321
322	f.Sessions, err = usecase.NewSessions(ctx, store.Sessions(), clock, cfg.PublicURL)
323	if err != nil {
324		return nil, err
325	}
326
327	// Owner passkeys (ADR 0026): built only when the public URL allows them.
328	// When it does, a verifier that cannot be built is fatal.
329	if ok, reason := passkeysAvailable(cfg.PublicURL); ok {
330		verifier, err := webauthn.New(cfg.PublicURL, cfg.Owner)
331		if err != nil {
332			return nil, fmt.Errorf("passkeys: %w", err)
333		}
334		f.Passkeys = usecase.NewPasskeys(store.Passkeys(), verifier, f.Sessions, clock)
335		log.Info().Str("public_url", cfg.PublicURL).Msg("passkeys enabled")
336	} else {
337		log.Info().Str("reason", reason).Msg("passkeys disabled")
338	}
339
340	// One budget of failed authentications per client, for Git, the API and
341	// the web login.
342	limiter := clientlimit.NewLimiter(nil)
343	// A nil *usecase.Passkeys must not become a non-nil interface: the API
344	// tests for nil to answer failed_precondition and to leave the feature out.
345	var apiPasskeys ports.PasskeyService
346	if f.Passkeys != nil {
347		apiPasskeys = f.Passkeys
348	}
349	var apiOIDC ports.OIDCKeyService // same rule as apiPasskeys
350	if f.OIDC != nil {
351		apiOIDC = f.OIDC
352	}
353	var jobArtifacts ports.JobArtifactService
354	var artifacts ports.ArtifactService
355	if cfg.CIArtifacts {
356		jobArtifacts, artifacts = f.CI, f.CI
357	}
358	f.API = rpc.New(rpc.Config{
359		Auth:           f.Auth,
360		Repos:          f.Repos,
361		Tokens:         f.Tokens,
362		Browse:         f.Browse,
363		Events:         f.EventLog,
364		MergeRequests:  f.MergeRequests,
365		SigningKey:     f.SigningKey,
366		TrustedKeys:    f.Signatures,
367		Sessions:       f.Sessions,
368		Passkeys:       apiPasskeys,
369		Runner:         f.CI,
370		Runs:           f.CI,
371		Secrets:        f.Settings,
372		Variables:      f.Settings,
373		Environments:   f.Settings,
374		OIDCKeys:       apiOIDC,
375		JobArtifacts:   jobArtifacts,
376		Artifacts:      artifacts,
377		Version:        serverVersion(),
378		TrustedProxies: cfg.TrustedProxies,
379		Limiter:        limiter,
380	})
381
382	gh := githttp.New(githttp.Config{
383		Auth:           f.Auth,
384		Repos:          f.Repos,
385		Pushes:         f.Pushes,
386		Backend:        git,
387		Owner:          cfg.Owner,
388		Hooks:          f.hooks,
389		TrustedProxies: cfg.TrustedProxies,
390		Limiter:        limiter,
391		MaxBodyBytes:   maxBody,
392	})
393	routes := make([]httpserver.Route, 0, 2*len(githttp.Patterns)+len(githubapi.Patterns)+len(oidchttp.Patterns)+len(web.Patterns))
394	for _, p := range githttp.Patterns {
395		routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})
396	}
397	// The owner form of the clone URL (github.repository), which GitHub's
398	// actions use. config.Owner follows the rules of repository names, so it is
399	// a literal segment that never overlaps /-/.
400	for _, p := range githttp.OwnerPatterns(cfg.Owner) {
401		routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})
402	}
403	// The REST subset the actions call on github.api_url.
404	api := githubapi.New(githubapi.Config{Auth: f.Auth, API: f.CI, TrustedProxies: cfg.TrustedProxies, Limiter: limiter, MaxArchivesPerClient: opts.archivesPerClient})
405	for _, p := range githubapi.Patterns {
406		routes = append(routes, httpserver.Route{Pattern: p, Handler: api})
407	}
408	// The OIDC provider of CI jobs (issuer <public URL>/-/oidc).
409	idp := oidchttp.New(oidchttp.Config{OIDC: f.CI, TrustedProxies: cfg.TrustedProxies, Limiter: limiter})
410	for _, p := range oidchttp.Patterns {
411		routes = append(routes, httpserver.Route{Pattern: p, Handler: idp})
412	}
413	warnSessionsNeedHTTPS(ctx, cfg.PublicURL)
414	warnSharedClientKey(ctx, cfg.TrustedProxies, githttp.DefaultMaxPerClient)
415	site, err := newWeb(cfg, f, limiter)
416	if err != nil {
417		return nil, fmt.Errorf("web: %w", err)
418	}
419	routes = append(routes, webRoutes(site)...)
420	f.server = httpserver.New(httpserver.Config{
421		Addr:         cfg.Addr,
422		PublicURL:    cfg.PublicURL,
423		HealthChecks: []httpserver.Check{store.Check, httpserver.DirWritable(cfg.DataDir)},
424		Routes:       routes,
425		API:          f.API.Handler(),
426		// Event streams never end by themselves: close them when the
427		// shutdown begins, or it would wait for them until its timeout.
428		OnShutdown: []func(){func() { f.EventLog.Close(context.WithoutCancel(ctx)) }},
429	})
430	return f, nil
431}
432
433// Run listens on the configured address and serves until ctx ends.
434func (f *forge) Run(ctx context.Context) error {
435	ln, err := net.Listen("tcp", f.cfg.Addr)
436	if err != nil {
437		return fmt.Errorf("listen on %s: %w", f.cfg.Addr, err)
438	}
439	return f.Serve(ctx, ln)
440}
441
442// Serve serves HTTP on ln and the hook socket until ctx ends. The hook socket
443// outlives the HTTP server: requests still in flight while it drains are
444// pushes whose hooks need it. A failure of either stops both.
445func (f *forge) Serve(ctx context.Context, ln net.Listener) error {
446	ctx, cancel := context.WithCancel(ctx)
447	defer cancel()
448	hookCtx, stopHooks := context.WithCancel(context.WithoutCancel(ctx))
449	defer stopHooks()
450
451	hookErr := make(chan error, 1)
452	go func() {
453		err := f.hooks.Serve(hookCtx)
454		cancel() // a dead hook socket would reject every push: stop serving
455		hookErr <- err
456	}()
457
458	httpErr := f.server.Serve(ctx, ln)
459	stopHooks()
460	return errors.Join(httpErr, <-hookErr)
461}
462
463// Close releases what newForge opened. It is safe to call twice. Call it after
464// the HTTP server stopped: it stops the event log and the long-lived Git
465// processes of the read side, which requests in flight may still use.
466func (f *forge) Close(ctx context.Context) {
467	log := zerowrap.FromCtx(ctx)
468	if f.hooks != nil {
469		f.hooks.Close(ctx)
470	}
471	if f.CI != nil {
472		f.CI.Close(ctx)
473	}
474	if f.EventLog != nil {
475		f.EventLog.Close(ctx)
476	}
477	if f.reader != nil {
478		f.reader.Close()
479	}
480	if f.git != nil {
481		if err := f.git.Close(ctx); err != nil {
482			log.Error().Err(err).Msg("close git reader")
483		}
484	}
485	if f.maint != nil {
486		f.maint.Close(ctx)
487	}
488	if f.store != nil {
489		if err := f.store.Close(ctx); err != nil {
490			log.Error().Err(err).Msg("close metadata store")
491		}
492		f.store = nil
493	}
494}
495
496// artifacts gives the CI the stores and limits of the artifacts (CI-4 D3):
497// records in the kv store, contents under ci/artifacts. ERMITE_CI_ARTIFACTS=0
498// gives none, and the API then serves no artifact service.
499func artifacts(ci *usecase.CIConfig, cfg config.Config, store *kvstore.Store) {
500	if !cfg.CIArtifacts {
501		return
502	}
503	ci.Artifacts, ci.ArtifactFiles = store.Artifacts(), ciartifacts.New(ciartifacts.Dir(cfg.DataDir))
504	ci.ArtifactLimits = ports.ArtifactLimits{Retention: cfg.CIArtifactRetention, Quota: cfg.CIArtifactQuota, MaxSize: cfg.CIArtifactMax}
505}
506
507// pullRequests gives the CI the merge requests, the refs/pull refs and the
508// identity of the test merge commits (CI-3, D5).
509func (f *forge) pullRequests(cfg *usecase.CIConfig, store *kvstore.Store, git *gitcli.Git, committer domain.Identity) {
510	cfg.MRs, cfg.Pull, cfg.Committer = store.MergeRequests(), git, committer
511	if f.reader != nil {
512		cfg.Fresh = f.reader // a nil *ermitegit.Adapter must not become a non-nil interface
513	}
514}
515
516// signatures wires the commit signature checks (ADR 0030): the stored trusted
517// keys, the instance key as a builtin one for the committer email, the SSH and
518// OpenPGP verifiers, and the raw commit objects of the selected reader.
519func (f *forge) signatures(store *kvstore.Store, reader ports.RepoReader, key domain.SigningPublicKey, committer domain.Identity, clock ports.Clock) error {
520	objects, ok := reader.(ports.CommitObjectReader)
521	if !ok {
522		return errors.New("the git reader cannot read raw commit objects")
523	}
524	builtin := domain.TrustedKey{
525		ID: key.Fingerprint, Format: domain.FormatSSH, Title: "ermite instance key",
526		PublicKey: key.AuthorizedKey, Emails: []string{strings.ToLower(committer.Email)}, Builtin: true,
527	}
528	verifiers := map[domain.SignatureFormat]ports.SignatureVerifier{
529		domain.FormatSSH: sigverify.SSH{}, domain.FormatGPG: sigverify.GPG{},
530	}
531	sigs, err := usecase.NewSignatures(store.TrustedKeys(), sigverify.Inspector{}, verifiers, objects, builtin, clock)
532	if err != nil {
533		return err
534	}
535	f.Signatures = sigs
536	f.Browse.WithVerifier(sigs)
537	return nil
538}

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 44

↑↓ moveenter openesc close