detached 9ef2c23

refactor: address code review findings for env-aware redeploy

- Remove redundant containerSvc/eventBus params from runServers (use svc fields)
- Extract DefaultSecretsDebounce constant for configurable debounce duration
- Add image drift check to handleRunningAttachment alongside env drift
- Extract sortedKeys helper in secrets service for deterministic event payloads
- Replace fragile test sleeps with require.Eventually polling
commit
9ef2c2357e02632dbf58fa3a7b00092d91fa9661
author
brice · · 2026-03-14 20:29 UTC
parent
868d000
signature
unsigned
5 files · +54 -25
M internal/app/run.go +5 -5
@@ -273,7 +273,7 @@ func Run(ctx context.Context, configPath string) error {
273273 registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log)
274274
275275 // Start servers, wait for listeners to bind, then sync/auto-start containers.
276- return runServers(ctx, v, cfg, registryHandler, proxyHandler, svc.containerSvc, svc.eventBus, svc, cleanupHandlers, log)
276+ return runServers(ctx, v, cfg, registryHandler, proxyHandler, svc, cleanupHandlers, log)
277277 }
278278
279279 func ensureTLSConfig(cfg *Config, log zerowrap.Logger) error {
@@ -1329,7 +1329,7 @@ func registerEventHandlers(ctx context.Context, svc *services, cfg Config) (func
13291329 return nil, fmt.Errorf("failed to subscribe manual deploy handler: %w", err)
13301330 }
13311331
1332- secretsChangedHandler := container.NewSecretsChangedHandler(ctx, svc.containerSvc, svc.configSvc, 60*time.Second)
1332+ secretsChangedHandler := container.NewSecretsChangedHandler(ctx, svc.containerSvc, svc.configSvc, container.DefaultSecretsDebounce)
13331333 if err := svc.eventBus.Subscribe(secretsChangedHandler); err != nil {
13341334 return nil, fmt.Errorf("failed to subscribe secrets changed handler: %w", err)
13351335 }
@@ -1701,7 +1701,7 @@ func isLoopbackHost(host string) bool {
17011701 // - SIGUSR2: Triggers manual deploy for a specific route
17021702 // The deferred signal.Stop calls ensure signal handlers are properly
17031703 // cleaned up before program exit, preventing signal handler leaks.
1704-func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, svc *services, cleanupHandlers func(), log zerowrap.Logger) error {
1704+func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler, proxyHandler http.Handler, svc *services, cleanupHandlers func(), log zerowrap.Logger) error {
17051705 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
17061706 defer cancel()
17071707
@@ -1771,9 +1771,9 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler
17711771 // Auto-start after servers are listening (registry port is now bound).
17721772 syncAndAutoStart(ctx, svc, log)
17731773
1774- waitForShutdown(ctx, errChan, reloadChan, deployChan, eventBus, log)
1774+ waitForShutdown(ctx, errChan, reloadChan, deployChan, svc.eventBus, log)
17751775 cleanupHandlers() // Stop debounce timers before draining containers
1776- gracefulShutdown(registrySrv, proxySrv, tlsSrv, containerSvc, svc.proxySvc, log)
1776+ gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, log)
17771777 return nil
17781778 }
17791779
@@ -273,7 +273,7 @@ func Run(ctx context.Context, configPath string) error {
273 registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log)273 registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log)
274 274
275 // Start servers, wait for listeners to bind, then sync/auto-start containers.275 // Start servers, wait for listeners to bind, then sync/auto-start containers.
276- return runServers(ctx, v, cfg, registryHandler, proxyHandler, svc.containerSvc, svc.eventBus, svc, cleanupHandlers, log)276+ return runServers(ctx, v, cfg, registryHandler, proxyHandler, svc, cleanupHandlers, log)
277 }277 }
278 278
279 func ensureTLSConfig(cfg *Config, log zerowrap.Logger) error {279 func ensureTLSConfig(cfg *Config, log zerowrap.Logger) error {
@@ -1329,7 +1329,7 @@ func registerEventHandlers(ctx context.Context, svc *services, cfg Config) (func
1329 return nil, fmt.Errorf("failed to subscribe manual deploy handler: %w", err)1329 return nil, fmt.Errorf("failed to subscribe manual deploy handler: %w", err)
1330 }1330 }
1331 1331
1332- secretsChangedHandler := container.NewSecretsChangedHandler(ctx, svc.containerSvc, svc.configSvc, 60*time.Second)1332+ secretsChangedHandler := container.NewSecretsChangedHandler(ctx, svc.containerSvc, svc.configSvc, container.DefaultSecretsDebounce)
1333 if err := svc.eventBus.Subscribe(secretsChangedHandler); err != nil {1333 if err := svc.eventBus.Subscribe(secretsChangedHandler); err != nil {
1334 return nil, fmt.Errorf("failed to subscribe secrets changed handler: %w", err)1334 return nil, fmt.Errorf("failed to subscribe secrets changed handler: %w", err)
1335 }1335 }
@@ -1701,7 +1701,7 @@ func isLoopbackHost(host string) bool {
1701 // - SIGUSR2: Triggers manual deploy for a specific route1701 // - SIGUSR2: Triggers manual deploy for a specific route
1702 // The deferred signal.Stop calls ensure signal handlers are properly1702 // The deferred signal.Stop calls ensure signal handlers are properly
1703 // cleaned up before program exit, preventing signal handler leaks.1703 // cleaned up before program exit, preventing signal handler leaks.
1704-func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, svc *services, cleanupHandlers func(), log zerowrap.Logger) error {1704+func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler, proxyHandler http.Handler, svc *services, cleanupHandlers func(), log zerowrap.Logger) error {
1705 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)1705 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
1706 defer cancel()1706 defer cancel()
1707 1707
@@ -1771,9 +1771,9 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler
1771 // Auto-start after servers are listening (registry port is now bound).1771 // Auto-start after servers are listening (registry port is now bound).
1772 syncAndAutoStart(ctx, svc, log)1772 syncAndAutoStart(ctx, svc, log)
1773 1773
1774- waitForShutdown(ctx, errChan, reloadChan, deployChan, eventBus, log)1774+ waitForShutdown(ctx, errChan, reloadChan, deployChan, svc.eventBus, log)
1775 cleanupHandlers() // Stop debounce timers before draining containers1775 cleanupHandlers() // Stop debounce timers before draining containers
1776- gracefulShutdown(registrySrv, proxySrv, tlsSrv, containerSvc, svc.proxySvc, log)1776+ gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, log)
1777 return nil1777 return nil
1778 }1778 }
1779 1779
M internal/usecase/container/secrets_handler.go +2 -0
@@ -11,6 +11,8 @@ import (
1111 "github.com/bnema/gordon/internal/domain"
1212 )
1313
14+const DefaultSecretsDebounce = 60 * time.Second
15+
1416 // SecretsChangedHandler handles secrets.changed events with per-domain
1517 // debouncing. When secrets are modified rapidly (e.g. multiple
1618 // "gordon secrets set" calls), the handler resets a timer on each
@@ -11,6 +11,8 @@ import (
11 "github.com/bnema/gordon/internal/domain"11 "github.com/bnema/gordon/internal/domain"
12 )12 )
13 13
14+const DefaultSecretsDebounce = 60 * time.Second
15+
14 // SecretsChangedHandler handles secrets.changed events with per-domain16 // SecretsChangedHandler handles secrets.changed events with per-domain
15 // debouncing. When secrets are modified rapidly (e.g. multiple17 // debouncing. When secrets are modified rapidly (e.g. multiple
16 // "gordon secrets set" calls), the handler resets a timer on each18 // "gordon secrets set" calls), the handler resets a timer on each
M internal/usecase/container/secrets_handler_test.go +16 -5
@@ -8,6 +8,7 @@ import (
88
99 "github.com/stretchr/testify/assert"
1010 "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
1112
1213 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
1314 "github.com/bnema/gordon/internal/domain"
@@ -55,8 +56,9 @@ func TestSecretsChangedHandler_DebouncesBurstEvents(t *testing.T) {
5556 time.Sleep(10 * time.Millisecond)
5657 }
5758
58- time.Sleep(debounceDelay + 100*time.Millisecond)
59- assert.Equal(t, int32(1), deployCalls.Load())
59+ require.Eventually(t, func() bool {
60+ return deployCalls.Load() == 1
61+ }, debounceDelay+time.Second, 10*time.Millisecond, "expected exactly 1 deploy call after debounce")
6062 }
6163
6264 func TestSecretsChangedHandler_IndependentDomains(t *testing.T) {
@@ -73,8 +75,15 @@ func TestSecretsChangedHandler_IndependentDomains(t *testing.T) {
7375 configSvc.EXPECT().GetRoute(mock.Anything, routeOne.Domain).Return(routeOne, nil).Once()
7476 configSvc.EXPECT().GetRoute(mock.Anything, routeTwo.Domain).Return(routeTwo, nil).Once()
7577
76- containerSvc.EXPECT().Deploy(mock.Anything, *routeOne).Return(&domain.Container{ID: "container-1"}, nil).Once()
77- containerSvc.EXPECT().Deploy(mock.Anything, *routeTwo).Return(&domain.Container{ID: "container-2"}, nil).Once()
78+ var deployCalls atomic.Int32
79+ containerSvc.EXPECT().Deploy(mock.Anything, *routeOne).RunAndReturn(func(_ context.Context, _ domain.Route) (*domain.Container, error) {
80+ deployCalls.Add(1)
81+ return &domain.Container{ID: "container-1"}, nil
82+ }).Once()
83+ containerSvc.EXPECT().Deploy(mock.Anything, *routeTwo).RunAndReturn(func(_ context.Context, _ domain.Route) (*domain.Container, error) {
84+ deployCalls.Add(1)
85+ return &domain.Container{ID: "container-2"}, nil
86+ }).Once()
7887
7988 assert.NoError(t, h.Handle(context.Background(), domain.Event{
8089 Type: domain.EventSecretsChanged,
@@ -85,7 +94,9 @@ func TestSecretsChangedHandler_IndependentDomains(t *testing.T) {
8594 Data: domain.SecretsChangedPayload{Domain: routeTwo.Domain, Operation: "delete", Keys: []string{"B"}},
8695 }))
8796
88- time.Sleep(debounceDelay + 100*time.Millisecond)
97+ require.Eventually(t, func() bool {
98+ return deployCalls.Load() == 2
99+ }, debounceDelay+time.Second, 10*time.Millisecond, "expected 2 independent deploy calls")
89100 }
90101
91102 func TestSecretsChangedHandler_NoRouteFound_NoOp(t *testing.T) {
@@ -8,6 +8,7 @@ import (
8 8
9 "github.com/stretchr/testify/assert"9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/mock"10 "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
11 12
12 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"13 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
13 "github.com/bnema/gordon/internal/domain"14 "github.com/bnema/gordon/internal/domain"
@@ -55,8 +56,9 @@ func TestSecretsChangedHandler_DebouncesBurstEvents(t *testing.T) {
55 time.Sleep(10 * time.Millisecond)56 time.Sleep(10 * time.Millisecond)
56 }57 }
57 58
58- time.Sleep(debounceDelay + 100*time.Millisecond)59+ require.Eventually(t, func() bool {
59- assert.Equal(t, int32(1), deployCalls.Load())60+ return deployCalls.Load() == 1
61+ }, debounceDelay+time.Second, 10*time.Millisecond, "expected exactly 1 deploy call after debounce")
60 }62 }
61 63
62 func TestSecretsChangedHandler_IndependentDomains(t *testing.T) {64 func TestSecretsChangedHandler_IndependentDomains(t *testing.T) {
@@ -73,8 +75,15 @@ func TestSecretsChangedHandler_IndependentDomains(t *testing.T) {
73 configSvc.EXPECT().GetRoute(mock.Anything, routeOne.Domain).Return(routeOne, nil).Once()75 configSvc.EXPECT().GetRoute(mock.Anything, routeOne.Domain).Return(routeOne, nil).Once()
74 configSvc.EXPECT().GetRoute(mock.Anything, routeTwo.Domain).Return(routeTwo, nil).Once()76 configSvc.EXPECT().GetRoute(mock.Anything, routeTwo.Domain).Return(routeTwo, nil).Once()
75 77
76- containerSvc.EXPECT().Deploy(mock.Anything, *routeOne).Return(&domain.Container{ID: "container-1"}, nil).Once()78+ var deployCalls atomic.Int32
77- containerSvc.EXPECT().Deploy(mock.Anything, *routeTwo).Return(&domain.Container{ID: "container-2"}, nil).Once()79+ containerSvc.EXPECT().Deploy(mock.Anything, *routeOne).RunAndReturn(func(_ context.Context, _ domain.Route) (*domain.Container, error) {
80+ deployCalls.Add(1)
81+ return &domain.Container{ID: "container-1"}, nil
82+ }).Once()
83+ containerSvc.EXPECT().Deploy(mock.Anything, *routeTwo).RunAndReturn(func(_ context.Context, _ domain.Route) (*domain.Container, error) {
84+ deployCalls.Add(1)
85+ return &domain.Container{ID: "container-2"}, nil
86+ }).Once()
78 87
79 assert.NoError(t, h.Handle(context.Background(), domain.Event{88 assert.NoError(t, h.Handle(context.Background(), domain.Event{
80 Type: domain.EventSecretsChanged,89 Type: domain.EventSecretsChanged,
@@ -85,7 +94,9 @@ func TestSecretsChangedHandler_IndependentDomains(t *testing.T) {
85 Data: domain.SecretsChangedPayload{Domain: routeTwo.Domain, Operation: "delete", Keys: []string{"B"}},94 Data: domain.SecretsChangedPayload{Domain: routeTwo.Domain, Operation: "delete", Keys: []string{"B"}},
86 }))95 }))
87 96
88- time.Sleep(debounceDelay + 100*time.Millisecond)97+ require.Eventually(t, func() bool {
98+ return deployCalls.Load() == 2
99+ }, debounceDelay+time.Second, 10*time.Millisecond, "expected 2 independent deploy calls")
89 }100 }
90 101
91 func TestSecretsChangedHandler_NoRouteFound_NoOp(t *testing.T) {102 func TestSecretsChangedHandler_NoRouteFound_NoOp(t *testing.T) {
M internal/usecase/container/service.go +18 -5
@@ -2164,22 +2164,35 @@ func (s *Service) handleRunningAttachment(ctx context.Context, existing *domain.
21642164 }
21652165
21662166 log := zerowrap.FromCtx(ctx)
2167+
2168+ // Check image drift
2169+ existingImage := existing.Labels[domain.LabelImage]
2170+ imageDrifted := existingImage != "" && existingImage != serviceImage
2171+
2172+ // Check env drift
21672173 envDrifted, err := s.attachmentEnvDrifted(ctx, existing, containerName, serviceImage)
21682174 if err != nil {
21692175 log.WrapErr(err, "failed to check attachment env drift")
21702176 return true, nil
21712177 }
2172- if !envDrifted {
2173- log.Debug().Str("container_name", containerName).Msg("attachment already running with current env, skipping")
2178+
2179+ if !envDrifted && !imageDrifted {
2180+ log.Debug().Str("container_name", containerName).Msg("attachment already running with current env and image, skipping")
21742181 return true, nil
21752182 }
21762183
2177- log.Info().Str("container_name", containerName).Msg("attachment env changed, recreating")
2184+ if imageDrifted {
2185+ log.Info().Str("container_name", containerName).Str("existing_image", existingImage).Str("desired_image", serviceImage).Msg("attachment image changed, recreating")
2186+ }
2187+ if envDrifted {
2188+ log.Info().Str("container_name", containerName).Msg("attachment env changed, recreating")
2189+ }
2190+
21782191 if err := s.runtime.StopContainer(ctx, existing.ID); err != nil {
2179- return false, log.WrapErr(err, "failed to stop attachment for env update")
2192+ return false, log.WrapErr(err, "failed to stop attachment for drift update")
21802193 }
21812194 if err := s.runtime.RemoveContainer(ctx, existing.ID, true); err != nil {
2182- return false, log.WrapErr(err, "failed to remove attachment for env update")
2195+ return false, log.WrapErr(err, "failed to remove attachment for drift update")
21832196 }
21842197
21852198 return false, nil
@@ -2164,22 +2164,35 @@ func (s *Service) handleRunningAttachment(ctx context.Context, existing *domain.
2164 }2164 }
2165 2165
2166 log := zerowrap.FromCtx(ctx)2166 log := zerowrap.FromCtx(ctx)
2167+
2168+ // Check image drift
2169+ existingImage := existing.Labels[domain.LabelImage]
2170+ imageDrifted := existingImage != "" && existingImage != serviceImage
2171+
2172+ // Check env drift
2167 envDrifted, err := s.attachmentEnvDrifted(ctx, existing, containerName, serviceImage)2173 envDrifted, err := s.attachmentEnvDrifted(ctx, existing, containerName, serviceImage)
2168 if err != nil {2174 if err != nil {
2169 log.WrapErr(err, "failed to check attachment env drift")2175 log.WrapErr(err, "failed to check attachment env drift")
2170 return true, nil2176 return true, nil
2171 }2177 }
2172- if !envDrifted {2178+
2173- log.Debug().Str("container_name", containerName).Msg("attachment already running with current env, skipping")2179+ if !envDrifted && !imageDrifted {
2180+ log.Debug().Str("container_name", containerName).Msg("attachment already running with current env and image, skipping")
2174 return true, nil2181 return true, nil
2175 }2182 }
2176 2183
2177- log.Info().Str("container_name", containerName).Msg("attachment env changed, recreating")2184+ if imageDrifted {
2185+ log.Info().Str("container_name", containerName).Str("existing_image", existingImage).Str("desired_image", serviceImage).Msg("attachment image changed, recreating")
2186+ }
2187+ if envDrifted {
2188+ log.Info().Str("container_name", containerName).Msg("attachment env changed, recreating")
2189+ }
2190+
2178 if err := s.runtime.StopContainer(ctx, existing.ID); err != nil {2191 if err := s.runtime.StopContainer(ctx, existing.ID); err != nil {
2179- return false, log.WrapErr(err, "failed to stop attachment for env update")2192+ return false, log.WrapErr(err, "failed to stop attachment for drift update")
2180 }2193 }
2181 if err := s.runtime.RemoveContainer(ctx, existing.ID, true); err != nil {2194 if err := s.runtime.RemoveContainer(ctx, existing.ID, true); err != nil {
2182- return false, log.WrapErr(err, "failed to remove attachment for env update")2195+ return false, log.WrapErr(err, "failed to remove attachment for drift update")
2183 }2196 }
2184 2197
2185 return false, nil2198 return false, nil
M internal/usecase/secrets/service.go +13 -10
@@ -4,6 +4,7 @@ package secrets
44 import (
55 "context"
66 "errors"
7+ "sort"
78 "strings"
89
910 "github.com/bnema/zerowrap"
@@ -56,6 +57,16 @@ func (s *Service) publishSecretsChanged(ctx context.Context, domainName, operati
5657 }
5758 }
5859
60+// sortedKeys returns the keys of a map in sorted order.
61+func sortedKeys(m map[string]string) []string {
62+ keys := make([]string, 0, len(m))
63+ for k := range m {
64+ keys = append(keys, k)
65+ }
66+ sort.Strings(keys)
67+ return keys
68+}
69+
5970 // ListKeys returns the list of secret keys for a domain (not values).
6071 func (s *Service) ListKeys(ctx context.Context, domain string) ([]string, error) {
6172 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -160,11 +171,7 @@ func (s *Service) Set(ctx context.Context, domain string, secrets map[string]str
160171 return err
161172 }
162173
163- keys := make([]string, 0, len(secrets))
164- for k := range secrets {
165- keys = append(keys, k)
166- }
167- s.publishSecretsChanged(ctx, domain, "set", keys)
174+ s.publishSecretsChanged(ctx, domain, "set", sortedKeys(secrets))
168175
169176 log.Info().Int("count", len(secrets)).Msg("secrets set")
170177 return nil
@@ -223,11 +230,7 @@ func (s *Service) SetAttachment(ctx context.Context, domainName, service string,
223230 return err
224231 }
225232
226- keys := make([]string, 0, len(secrets))
227- for k := range secrets {
228- keys = append(keys, k)
229- }
230- s.publishSecretsChanged(ctx, domainName, "set", keys)
233+ s.publishSecretsChanged(ctx, domainName, "set", sortedKeys(secrets))
231234
232235 log.Info().Int("count", len(secrets)).Str("container", containerName).Msg("attachment secrets set")
233236 return nil
@@ -4,6 +4,7 @@ package secrets
4 import (4 import (
5 "context"5 "context"
6 "errors"6 "errors"
7+ "sort"
7 "strings"8 "strings"
8 9
9 "github.com/bnema/zerowrap"10 "github.com/bnema/zerowrap"
@@ -56,6 +57,16 @@ func (s *Service) publishSecretsChanged(ctx context.Context, domainName, operati
56 }57 }
57 }58 }
58 59
60+// sortedKeys returns the keys of a map in sorted order.
61+func sortedKeys(m map[string]string) []string {
62+ keys := make([]string, 0, len(m))
63+ for k := range m {
64+ keys = append(keys, k)
65+ }
66+ sort.Strings(keys)
67+ return keys
68+}
69+
59 // ListKeys returns the list of secret keys for a domain (not values).70 // ListKeys returns the list of secret keys for a domain (not values).
60 func (s *Service) ListKeys(ctx context.Context, domain string) ([]string, error) {71 func (s *Service) ListKeys(ctx context.Context, domain string) ([]string, error) {
61 ctx = zerowrap.CtxWithFields(ctx, map[string]any{72 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -160,11 +171,7 @@ func (s *Service) Set(ctx context.Context, domain string, secrets map[string]str
160 return err171 return err
161 }172 }
162 173
163- keys := make([]string, 0, len(secrets))174+ s.publishSecretsChanged(ctx, domain, "set", sortedKeys(secrets))
164- for k := range secrets {
165- keys = append(keys, k)
166- }
167- s.publishSecretsChanged(ctx, domain, "set", keys)
168 175
169 log.Info().Int("count", len(secrets)).Msg("secrets set")176 log.Info().Int("count", len(secrets)).Msg("secrets set")
170 return nil177 return nil
@@ -223,11 +230,7 @@ func (s *Service) SetAttachment(ctx context.Context, domainName, service string,
223 return err230 return err
224 }231 }
225 232
226- keys := make([]string, 0, len(secrets))233+ s.publishSecretsChanged(ctx, domainName, "set", sortedKeys(secrets))
227- for k := range secrets {
228- keys = append(keys, k)
229- }
230- s.publishSecretsChanged(ctx, domainName, "set", keys)
231 234
232 log.Info().Int("count", len(secrets)).Str("container", containerName).Msg("attachment secrets set")235 log.Info().Int("count", len(secrets)).Str("container", containerName).Msg("attachment secrets set")
233 return nil236 return nil

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 8

Tags 106

↑↓ moveenter openesc close