detached 22312a8

fix: skip mutable sections in post-save verification to prevent false corruption errors

The post-save verifier compared ALL viper keys before/after write, causing
500 errors when AddRoute or RemoveRoute intentionally changed routes.* keys.
Now only immutable config sections (server, auth, etc.) are verified.

Also refactor bootstrap: extract RunE into helpers following runXxx(ctx, ...,
out) convention, and use normalized resp.Image in next-step CLI message.

Close #101
commit
22312a8da6f1afe36c59c6242951335fdb15aa58
author
brice · · 2026-03-14 05:31 UTC
committer
brice · · 2026-03-14 05:35 UTC
parent
7d7aff9
signature
unsigned
3 files · +189 -59
M internal/adapters/in/cli/bootstrap.go +74 -59
@@ -1,7 +1,9 @@
11 package cli
22
33 import (
4+ "context"
45 "fmt"
6+ "io"
57 "sort"
68 "strings"
79
@@ -21,66 +23,11 @@ func newBootstrapCmd() *cobra.Command {
2123 Short: "Create a route, attachments, and secrets together",
2224 Args: cobra.ExactArgs(2),
2325 RunE: func(cmd *cobra.Command, args []string) error {
24- ctx := cmd.Context()
25- req := dto.BootstrapRequest{
26- Domain: args[0],
27- Image: args[1],
28- Attachments: append([]string(nil), attachments...),
29- Env: map[string]string{},
30- AttachmentEnv: map[string]map[string]string{},
31- }
32-
33- for _, pair := range envPairs {
34- parts := strings.SplitN(pair, "=", 2)
35- if len(parts) != 2 {
36- return fmt.Errorf("invalid --env value %q (expected KEY=VALUE)", pair)
37- }
38- req.Env[parts[0]] = parts[1]
39- }
40-
41- for _, pair := range attachmentEnvPairs {
42- serviceAndKV := strings.SplitN(pair, ":", 2)
43- if len(serviceAndKV) != 2 {
44- return fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair)
45- }
46- keyValue := strings.SplitN(serviceAndKV[1], "=", 2)
47- if len(keyValue) != 2 {
48- return fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair)
49- }
50- service := serviceAndKV[0]
51- if req.AttachmentEnv[service] == nil {
52- req.AttachmentEnv[service] = map[string]string{}
53- }
54- req.AttachmentEnv[service][keyValue[0]] = keyValue[1]
55- }
56-
57- if len(req.Env) == 0 {
58- req.Env = nil
59- }
60- if len(req.AttachmentEnv) == 0 {
61- req.AttachmentEnv = nil
62- }
63-
64- handle, err := resolveControlPlane(configPath)
26+ req, err := parseBootstrapRequest(args, attachments, envPairs, attachmentEnvPairs)
6527 if err != nil {
6628 return err
6729 }
68- defer handle.close()
69-
70- resp, err := handle.plane.Bootstrap(ctx, req)
71- if resp != nil {
72- if writeErr := printBootstrapSummary(cmd, resp); writeErr != nil {
73- return writeErr
74- }
75- }
76- if err != nil {
77- return fmt.Errorf("failed to bootstrap: %w", err)
78- }
79- out := cmd.OutOrStdout()
80- if err := cliWriteLine(out, cliRenderSuccess("Bootstrap complete")); err != nil {
81- return err
82- }
83- return cliWriteLine(out, fmt.Sprintf("Next: gordon push %s --build", req.Image))
30+ return runBootstrap(cmd.Context(), req, configPath, cmd.OutOrStdout())
8431 },
8532 }
8633
@@ -92,8 +39,76 @@ func newBootstrapCmd() *cobra.Command {
9239 return cmd
9340 }
9441
95-func printBootstrapSummary(cmd *cobra.Command, resp *dto.BootstrapResponse) error {
96- out := cmd.OutOrStdout()
42+func parseBootstrapRequest(args []string, attachments, envPairs, attachmentEnvPairs []string) (dto.BootstrapRequest, error) {
43+ req := dto.BootstrapRequest{
44+ Domain: args[0],
45+ Image: args[1],
46+ Attachments: append([]string(nil), attachments...),
47+ Env: map[string]string{},
48+ AttachmentEnv: map[string]map[string]string{},
49+ }
50+
51+ for _, pair := range envPairs {
52+ parts := strings.SplitN(pair, "=", 2)
53+ if len(parts) != 2 {
54+ return dto.BootstrapRequest{}, fmt.Errorf("invalid --env value %q (expected KEY=VALUE)", pair)
55+ }
56+ req.Env[parts[0]] = parts[1]
57+ }
58+
59+ for _, pair := range attachmentEnvPairs {
60+ serviceAndKV := strings.SplitN(pair, ":", 2)
61+ if len(serviceAndKV) != 2 {
62+ return dto.BootstrapRequest{}, fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair)
63+ }
64+ keyValue := strings.SplitN(serviceAndKV[1], "=", 2)
65+ if len(keyValue) != 2 {
66+ return dto.BootstrapRequest{}, fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair)
67+ }
68+ service := serviceAndKV[0]
69+ if req.AttachmentEnv[service] == nil {
70+ req.AttachmentEnv[service] = map[string]string{}
71+ }
72+ req.AttachmentEnv[service][keyValue[0]] = keyValue[1]
73+ }
74+
75+ if len(req.Env) == 0 {
76+ req.Env = nil
77+ }
78+ if len(req.AttachmentEnv) == 0 {
79+ req.AttachmentEnv = nil
80+ }
81+
82+ return req, nil
83+}
84+
85+func runBootstrap(ctx context.Context, req dto.BootstrapRequest, configPath string, out io.Writer) error {
86+ handle, err := resolveControlPlane(configPath)
87+ if err != nil {
88+ return err
89+ }
90+ defer handle.close()
91+
92+ resp, err := handle.plane.Bootstrap(ctx, req)
93+ if resp != nil {
94+ if writeErr := printBootstrapSummary(out, resp); writeErr != nil {
95+ return writeErr
96+ }
97+ }
98+ if err != nil {
99+ return fmt.Errorf("failed to bootstrap: %w", err)
100+ }
101+ if err := cliWriteLine(out, cliRenderSuccess("Bootstrap complete")); err != nil {
102+ return err
103+ }
104+ nextImage := req.Image
105+ if resp != nil && resp.Image != "" {
106+ nextImage = resp.Image
107+ }
108+ return cliWriteLine(out, fmt.Sprintf("Next: gordon push %s --build", nextImage))
109+}
110+
111+func printBootstrapSummary(out io.Writer, resp *dto.BootstrapResponse) error {
97112 if err := cliWriteLine(out, cliRenderTitle("Bootstrap")); err != nil {
98113 return err
99114 }
@@ -1,7 +1,9 @@
1 package cli1 package cli
2 2
3 import (3 import (
4+ "context"
4 "fmt"5 "fmt"
6+ "io"
5 "sort"7 "sort"
6 "strings"8 "strings"
7 9
@@ -21,66 +23,11 @@ func newBootstrapCmd() *cobra.Command {
21 Short: "Create a route, attachments, and secrets together",23 Short: "Create a route, attachments, and secrets together",
22 Args: cobra.ExactArgs(2),24 Args: cobra.ExactArgs(2),
23 RunE: func(cmd *cobra.Command, args []string) error {25 RunE: func(cmd *cobra.Command, args []string) error {
24- ctx := cmd.Context()26+ req, err := parseBootstrapRequest(args, attachments, envPairs, attachmentEnvPairs)
25- req := dto.BootstrapRequest{
26- Domain: args[0],
27- Image: args[1],
28- Attachments: append([]string(nil), attachments...),
29- Env: map[string]string{},
30- AttachmentEnv: map[string]map[string]string{},
31- }
32-
33- for _, pair := range envPairs {
34- parts := strings.SplitN(pair, "=", 2)
35- if len(parts) != 2 {
36- return fmt.Errorf("invalid --env value %q (expected KEY=VALUE)", pair)
37- }
38- req.Env[parts[0]] = parts[1]
39- }
40-
41- for _, pair := range attachmentEnvPairs {
42- serviceAndKV := strings.SplitN(pair, ":", 2)
43- if len(serviceAndKV) != 2 {
44- return fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair)
45- }
46- keyValue := strings.SplitN(serviceAndKV[1], "=", 2)
47- if len(keyValue) != 2 {
48- return fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair)
49- }
50- service := serviceAndKV[0]
51- if req.AttachmentEnv[service] == nil {
52- req.AttachmentEnv[service] = map[string]string{}
53- }
54- req.AttachmentEnv[service][keyValue[0]] = keyValue[1]
55- }
56-
57- if len(req.Env) == 0 {
58- req.Env = nil
59- }
60- if len(req.AttachmentEnv) == 0 {
61- req.AttachmentEnv = nil
62- }
63-
64- handle, err := resolveControlPlane(configPath)
65 if err != nil {27 if err != nil {
66 return err28 return err
67 }29 }
68- defer handle.close()30+ return runBootstrap(cmd.Context(), req, configPath, cmd.OutOrStdout())
69-
70- resp, err := handle.plane.Bootstrap(ctx, req)
71- if resp != nil {
72- if writeErr := printBootstrapSummary(cmd, resp); writeErr != nil {
73- return writeErr
74- }
75- }
76- if err != nil {
77- return fmt.Errorf("failed to bootstrap: %w", err)
78- }
79- out := cmd.OutOrStdout()
80- if err := cliWriteLine(out, cliRenderSuccess("Bootstrap complete")); err != nil {
81- return err
82- }
83- return cliWriteLine(out, fmt.Sprintf("Next: gordon push %s --build", req.Image))
84 },31 },
85 }32 }
86 33
@@ -92,8 +39,76 @@ func newBootstrapCmd() *cobra.Command {
92 return cmd39 return cmd
93 }40 }
94 41
95-func printBootstrapSummary(cmd *cobra.Command, resp *dto.BootstrapResponse) error {42+func parseBootstrapRequest(args []string, attachments, envPairs, attachmentEnvPairs []string) (dto.BootstrapRequest, error) {
96- out := cmd.OutOrStdout()43+ req := dto.BootstrapRequest{
44+ Domain: args[0],
45+ Image: args[1],
46+ Attachments: append([]string(nil), attachments...),
47+ Env: map[string]string{},
48+ AttachmentEnv: map[string]map[string]string{},
49+ }
50+
51+ for _, pair := range envPairs {
52+ parts := strings.SplitN(pair, "=", 2)
53+ if len(parts) != 2 {
54+ return dto.BootstrapRequest{}, fmt.Errorf("invalid --env value %q (expected KEY=VALUE)", pair)
55+ }
56+ req.Env[parts[0]] = parts[1]
57+ }
58+
59+ for _, pair := range attachmentEnvPairs {
60+ serviceAndKV := strings.SplitN(pair, ":", 2)
61+ if len(serviceAndKV) != 2 {
62+ return dto.BootstrapRequest{}, fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair)
63+ }
64+ keyValue := strings.SplitN(serviceAndKV[1], "=", 2)
65+ if len(keyValue) != 2 {
66+ return dto.BootstrapRequest{}, fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair)
67+ }
68+ service := serviceAndKV[0]
69+ if req.AttachmentEnv[service] == nil {
70+ req.AttachmentEnv[service] = map[string]string{}
71+ }
72+ req.AttachmentEnv[service][keyValue[0]] = keyValue[1]
73+ }
74+
75+ if len(req.Env) == 0 {
76+ req.Env = nil
77+ }
78+ if len(req.AttachmentEnv) == 0 {
79+ req.AttachmentEnv = nil
80+ }
81+
82+ return req, nil
83+}
84+
85+func runBootstrap(ctx context.Context, req dto.BootstrapRequest, configPath string, out io.Writer) error {
86+ handle, err := resolveControlPlane(configPath)
87+ if err != nil {
88+ return err
89+ }
90+ defer handle.close()
91+
92+ resp, err := handle.plane.Bootstrap(ctx, req)
93+ if resp != nil {
94+ if writeErr := printBootstrapSummary(out, resp); writeErr != nil {
95+ return writeErr
96+ }
97+ }
98+ if err != nil {
99+ return fmt.Errorf("failed to bootstrap: %w", err)
100+ }
101+ if err := cliWriteLine(out, cliRenderSuccess("Bootstrap complete")); err != nil {
102+ return err
103+ }
104+ nextImage := req.Image
105+ if resp != nil && resp.Image != "" {
106+ nextImage = resp.Image
107+ }
108+ return cliWriteLine(out, fmt.Sprintf("Next: gordon push %s --build", nextImage))
109+}
110+
111+func printBootstrapSummary(out io.Writer, resp *dto.BootstrapResponse) error {
97 if err := cliWriteLine(out, cliRenderTitle("Bootstrap")); err != nil {112 if err := cliWriteLine(out, cliRenderTitle("Bootstrap")); err != nil {
98 return err113 return err
99 }114 }
M internal/usecase/config/service.go +21 -0
@@ -496,9 +496,30 @@ type configSnapshot struct {
496496 values map[string]any
497497 }
498498
499+// mutableSections lists config sections that are intentionally modified by service operations.
500+var mutableSections = []string{"routes.", "external_routes.", "attachments.", "network_groups."}
501+
502+func isMutableKey(key string) bool {
503+ for _, prefix := range mutableSections {
504+ if strings.HasPrefix(key, prefix) {
505+ return true
506+ }
507+ }
508+
509+ switch key {
510+ case "routes", "external_routes", "attachments", "network_groups":
511+ return true
512+ }
513+
514+ return false
515+}
516+
499517 func (s *Service) snapshotCriticalFields() configSnapshot {
500518 snap := configSnapshot{values: make(map[string]any)}
501519 for _, key := range s.viper.AllKeys() {
520+ if isMutableKey(key) {
521+ continue
522+ }
502523 snap.values[key] = s.viper.Get(key)
503524 }
504525 return snap
@@ -496,9 +496,30 @@ type configSnapshot struct {
496 values map[string]any496 values map[string]any
497 }497 }
498 498
499+// mutableSections lists config sections that are intentionally modified by service operations.
500+var mutableSections = []string{"routes.", "external_routes.", "attachments.", "network_groups."}
501+
502+func isMutableKey(key string) bool {
503+ for _, prefix := range mutableSections {
504+ if strings.HasPrefix(key, prefix) {
505+ return true
506+ }
507+ }
508+
509+ switch key {
510+ case "routes", "external_routes", "attachments", "network_groups":
511+ return true
512+ }
513+
514+ return false
515+}
516+
499 func (s *Service) snapshotCriticalFields() configSnapshot {517 func (s *Service) snapshotCriticalFields() configSnapshot {
500 snap := configSnapshot{values: make(map[string]any)}518 snap := configSnapshot{values: make(map[string]any)}
501 for _, key := range s.viper.AllKeys() {519 for _, key := range s.viper.AllKeys() {
520+ if isMutableKey(key) {
521+ continue
522+ }
502 snap.values[key] = s.viper.Get(key)523 snap.values[key] = s.viper.Get(key)
503 }524 }
504 return snap525 return snap
M internal/usecase/config/service_test.go +94 -0
@@ -1285,6 +1285,100 @@ port = 9999
12851285 assert.NotContains(t, string(backupData), "new.example.com")
12861286 })
12871287
1288+ t.Run("AddRoute updates existing dotted domain route", func(t *testing.T) {
1289+ tmpDir := t.TempDir()
1290+ configFile := filepath.Join(tmpDir, "gordon.toml")
1291+ initialConfig := `
1292+[server]
1293+port = 8088
1294+
1295+[auth]
1296+username = "testadmin"
1297+token_secret = "testsecret123"
1298+
1299+[routes]
1300+"pitlane.example.com" = "pitlane:latest"
1301+`
1302+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1303+ require.NoError(t, err)
1304+
1305+ v := viper.New()
1306+ v.SetConfigFile(configFile)
1307+ err = v.ReadInConfig()
1308+ require.NoError(t, err)
1309+
1310+ eventBus := mocks.NewMockEventPublisher(t)
1311+ svc := NewService(v, eventBus)
1312+ ctx := testContext()
1313+ err = svc.Load(ctx)
1314+ require.NoError(t, err)
1315+
1316+ err = svc.AddRoute(ctx, domain.Route{
1317+ Domain: "pitlane.example.com",
1318+ Image: "reg.example.com/pitlane:latest",
1319+ })
1320+ require.NoError(t, err, "AddRoute should succeed when updating an existing dotted-domain route")
1321+
1322+ routes := svc.GetRoutes(ctx)
1323+ require.Len(t, routes, 1)
1324+ assert.Equal(t, "pitlane.example.com", routes[0].Domain)
1325+ assert.Equal(t, "reg.example.com/pitlane:latest", routes[0].Image)
1326+
1327+ v2 := viper.New()
1328+ v2.SetConfigFile(configFile)
1329+ err = v2.ReadInConfig()
1330+ require.NoError(t, err)
1331+ assert.Equal(t, 8088, v2.GetInt("server.port"))
1332+ assert.Equal(t, "testadmin", v2.GetString("auth.username"))
1333+ assert.Equal(t, "testsecret123", v2.GetString("auth.token_secret"))
1334+ })
1335+
1336+ t.Run("RemoveRoute removes dotted domain route", func(t *testing.T) {
1337+ tmpDir := t.TempDir()
1338+ configFile := filepath.Join(tmpDir, "gordon.toml")
1339+ initialConfig := `
1340+[server]
1341+port = 8088
1342+
1343+[auth]
1344+username = "testadmin"
1345+token_secret = "testsecret123"
1346+
1347+[routes]
1348+"pitlane.example.com" = "reg.example.com/pitlane:latest"
1349+"other.example.com" = "reg.example.com/other:latest"
1350+`
1351+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1352+ require.NoError(t, err)
1353+
1354+ v := viper.New()
1355+ v.SetConfigFile(configFile)
1356+ err = v.ReadInConfig()
1357+ require.NoError(t, err)
1358+
1359+ eventBus := mocks.NewMockEventPublisher(t)
1360+ svc := NewService(v, eventBus)
1361+ ctx := testContext()
1362+ err = svc.Load(ctx)
1363+ require.NoError(t, err)
1364+
1365+ err = svc.RemoveRoute(ctx, "pitlane.example.com")
1366+ require.NoError(t, err, "RemoveRoute should succeed for a dotted-domain route")
1367+
1368+ routes := svc.GetRoutes(ctx)
1369+ require.Len(t, routes, 1)
1370+ assert.Equal(t, "other.example.com", routes[0].Domain, "other routes should be preserved")
1371+ assert.Equal(t, "reg.example.com/other:latest", routes[0].Image, "other routes should be preserved")
1372+
1373+ v2 := viper.New()
1374+ v2.SetConfigFile(configFile)
1375+ err = v2.ReadInConfig()
1376+ require.NoError(t, err)
1377+ assert.Equal(t, 8088, v2.GetInt("server.port"))
1378+ assert.Equal(t, "testadmin", v2.GetString("auth.username"))
1379+ assert.Equal(t, "testsecret123", v2.GetString("auth.token_secret"))
1380+ })
1381+
12881382 t.Run("network_groups are persisted to disk", func(t *testing.T) {
12891383 // Setup: config file with auth, server, and network_groups
12901384 tmpDir := t.TempDir()
@@ -1285,6 +1285,100 @@ port = 9999
1285 assert.NotContains(t, string(backupData), "new.example.com")1285 assert.NotContains(t, string(backupData), "new.example.com")
1286 })1286 })
1287 1287
1288+ t.Run("AddRoute updates existing dotted domain route", func(t *testing.T) {
1289+ tmpDir := t.TempDir()
1290+ configFile := filepath.Join(tmpDir, "gordon.toml")
1291+ initialConfig := `
1292+[server]
1293+port = 8088
1294+
1295+[auth]
1296+username = "testadmin"
1297+token_secret = "testsecret123"
1298+
1299+[routes]
1300+"pitlane.example.com" = "pitlane:latest"
1301+`
1302+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1303+ require.NoError(t, err)
1304+
1305+ v := viper.New()
1306+ v.SetConfigFile(configFile)
1307+ err = v.ReadInConfig()
1308+ require.NoError(t, err)
1309+
1310+ eventBus := mocks.NewMockEventPublisher(t)
1311+ svc := NewService(v, eventBus)
1312+ ctx := testContext()
1313+ err = svc.Load(ctx)
1314+ require.NoError(t, err)
1315+
1316+ err = svc.AddRoute(ctx, domain.Route{
1317+ Domain: "pitlane.example.com",
1318+ Image: "reg.example.com/pitlane:latest",
1319+ })
1320+ require.NoError(t, err, "AddRoute should succeed when updating an existing dotted-domain route")
1321+
1322+ routes := svc.GetRoutes(ctx)
1323+ require.Len(t, routes, 1)
1324+ assert.Equal(t, "pitlane.example.com", routes[0].Domain)
1325+ assert.Equal(t, "reg.example.com/pitlane:latest", routes[0].Image)
1326+
1327+ v2 := viper.New()
1328+ v2.SetConfigFile(configFile)
1329+ err = v2.ReadInConfig()
1330+ require.NoError(t, err)
1331+ assert.Equal(t, 8088, v2.GetInt("server.port"))
1332+ assert.Equal(t, "testadmin", v2.GetString("auth.username"))
1333+ assert.Equal(t, "testsecret123", v2.GetString("auth.token_secret"))
1334+ })
1335+
1336+ t.Run("RemoveRoute removes dotted domain route", func(t *testing.T) {
1337+ tmpDir := t.TempDir()
1338+ configFile := filepath.Join(tmpDir, "gordon.toml")
1339+ initialConfig := `
1340+[server]
1341+port = 8088
1342+
1343+[auth]
1344+username = "testadmin"
1345+token_secret = "testsecret123"
1346+
1347+[routes]
1348+"pitlane.example.com" = "reg.example.com/pitlane:latest"
1349+"other.example.com" = "reg.example.com/other:latest"
1350+`
1351+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1352+ require.NoError(t, err)
1353+
1354+ v := viper.New()
1355+ v.SetConfigFile(configFile)
1356+ err = v.ReadInConfig()
1357+ require.NoError(t, err)
1358+
1359+ eventBus := mocks.NewMockEventPublisher(t)
1360+ svc := NewService(v, eventBus)
1361+ ctx := testContext()
1362+ err = svc.Load(ctx)
1363+ require.NoError(t, err)
1364+
1365+ err = svc.RemoveRoute(ctx, "pitlane.example.com")
1366+ require.NoError(t, err, "RemoveRoute should succeed for a dotted-domain route")
1367+
1368+ routes := svc.GetRoutes(ctx)
1369+ require.Len(t, routes, 1)
1370+ assert.Equal(t, "other.example.com", routes[0].Domain, "other routes should be preserved")
1371+ assert.Equal(t, "reg.example.com/other:latest", routes[0].Image, "other routes should be preserved")
1372+
1373+ v2 := viper.New()
1374+ v2.SetConfigFile(configFile)
1375+ err = v2.ReadInConfig()
1376+ require.NoError(t, err)
1377+ assert.Equal(t, 8088, v2.GetInt("server.port"))
1378+ assert.Equal(t, "testadmin", v2.GetString("auth.username"))
1379+ assert.Equal(t, "testsecret123", v2.GetString("auth.token_secret"))
1380+ })
1381+
1288 t.Run("network_groups are persisted to disk", func(t *testing.T) {1382 t.Run("network_groups are persisted to disk", func(t *testing.T) {
1289 // Setup: config file with auth, server, and network_groups1383 // Setup: config file with auth, server, and network_groups
1290 tmpDir := t.TempDir()1384 tmpDir := t.TempDir()

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