detached 760ee8d

merge: resolve conflict with main in service_test.go

commit
760ee8ded7a6504fd38f7dd799785da17f1fe200
author
brice · · 2026-03-14 09:22 UTC
merge of
8a929d8 + 22312a8 · diff against the first parent
signature
unsigned
3 files · +188 -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
@@ -515,9 +515,30 @@ type configSnapshot struct {
515515 values map[string]any
516516 }
517517
518+// mutableSections lists config sections that are intentionally modified by service operations.
519+var mutableSections = []string{"routes.", "external_routes.", "attachments.", "network_groups."}
520+
521+func isMutableKey(key string) bool {
522+ for _, prefix := range mutableSections {
523+ if strings.HasPrefix(key, prefix) {
524+ return true
525+ }
526+ }
527+
528+ switch key {
529+ case "routes", "external_routes", "attachments", "network_groups":
530+ return true
531+ }
532+
533+ return false
534+}
535+
518536 func (s *Service) snapshotCriticalFields() configSnapshot {
519537 snap := configSnapshot{values: make(map[string]any)}
520538 for _, key := range s.viper.AllKeys() {
539+ if isMutableKey(key) {
540+ continue
541+ }
521542 snap.values[key] = s.viper.Get(key)
522543 }
523544 return snap
@@ -515,9 +515,30 @@ type configSnapshot struct {
515 values map[string]any515 values map[string]any
516 }516 }
517 517
518+// mutableSections lists config sections that are intentionally modified by service operations.
519+var mutableSections = []string{"routes.", "external_routes.", "attachments.", "network_groups."}
520+
521+func isMutableKey(key string) bool {
522+ for _, prefix := range mutableSections {
523+ if strings.HasPrefix(key, prefix) {
524+ return true
525+ }
526+ }
527+
528+ switch key {
529+ case "routes", "external_routes", "attachments", "network_groups":
530+ return true
531+ }
532+
533+ return false
534+}
535+
518 func (s *Service) snapshotCriticalFields() configSnapshot {536 func (s *Service) snapshotCriticalFields() configSnapshot {
519 snap := configSnapshot{values: make(map[string]any)}537 snap := configSnapshot{values: make(map[string]any)}
520 for _, key := range s.viper.AllKeys() {538 for _, key := range s.viper.AllKeys() {
539+ if isMutableKey(key) {
540+ continue
541+ }
521 snap.values[key] = s.viper.Get(key)542 snap.values[key] = s.viper.Get(key)
522 }543 }
523 return snap544 return snap
M internal/usecase/config/service_test.go +93 -0
@@ -1409,6 +1409,99 @@ port = 9999
14091409 assert.Equal(t, 5, len(matches))
14101410 })
14111411
1412+ t.Run("AddRoute updates existing dotted domain route", func(t *testing.T) {
1413+ tmpDir := t.TempDir()
1414+ configFile := filepath.Join(tmpDir, "gordon.toml")
1415+ initialConfig := `
1416+[server]
1417+port = 8088
1418+
1419+[auth]
1420+username = "testadmin"
1421+token_secret = "testsecret123"
1422+
1423+[routes]
1424+"pitlane.example.com" = "pitlane:latest"
1425+`
1426+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1427+ require.NoError(t, err)
1428+
1429+ v := viper.New()
1430+ v.SetConfigFile(configFile)
1431+ err = v.ReadInConfig()
1432+ require.NoError(t, err)
1433+
1434+ eventBus := mocks.NewMockEventPublisher(t)
1435+ svc := NewService(v, eventBus)
1436+ ctx := testContext()
1437+ err = svc.Load(ctx)
1438+ require.NoError(t, err)
1439+
1440+ err = svc.AddRoute(ctx, domain.Route{
1441+ Domain: "pitlane.example.com",
1442+ Image: "reg.example.com/pitlane:latest",
1443+ })
1444+ require.NoError(t, err, "AddRoute should succeed when updating an existing dotted-domain route")
1445+
1446+ routes := svc.GetRoutes(ctx)
1447+ require.Len(t, routes, 1)
1448+ assert.Equal(t, "pitlane.example.com", routes[0].Domain)
1449+ assert.Equal(t, "reg.example.com/pitlane:latest", routes[0].Image)
1450+
1451+ v2 := viper.New()
1452+ v2.SetConfigFile(configFile)
1453+ err = v2.ReadInConfig()
1454+ require.NoError(t, err)
1455+ assert.Equal(t, 8088, v2.GetInt("server.port"))
1456+ assert.Equal(t, "testadmin", v2.GetString("auth.username"))
1457+ assert.Equal(t, "testsecret123", v2.GetString("auth.token_secret"))
1458+ })
1459+
1460+ t.Run("RemoveRoute removes dotted domain route", func(t *testing.T) {
1461+ tmpDir := t.TempDir()
1462+ configFile := filepath.Join(tmpDir, "gordon.toml")
1463+ initialConfig := `
1464+[server]
1465+port = 8088
1466+
1467+[auth]
1468+username = "testadmin"
1469+token_secret = "testsecret123"
1470+
1471+[routes]
1472+"pitlane.example.com" = "reg.example.com/pitlane:latest"
1473+"other.example.com" = "reg.example.com/other:latest"
1474+`
1475+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1476+ require.NoError(t, err)
1477+
1478+ v := viper.New()
1479+ v.SetConfigFile(configFile)
1480+ err = v.ReadInConfig()
1481+ require.NoError(t, err)
1482+
1483+ eventBus := mocks.NewMockEventPublisher(t)
1484+ svc := NewService(v, eventBus)
1485+ ctx := testContext()
1486+ err = svc.Load(ctx)
1487+ require.NoError(t, err)
1488+
1489+ err = svc.RemoveRoute(ctx, "pitlane.example.com")
1490+ require.NoError(t, err, "RemoveRoute should succeed for a dotted-domain route")
1491+
1492+ routes := svc.GetRoutes(ctx)
1493+ require.Len(t, routes, 1)
1494+ assert.Equal(t, "other.example.com", routes[0].Domain, "other routes should be preserved")
1495+ assert.Equal(t, "reg.example.com/other:latest", routes[0].Image, "other routes should be preserved")
1496+
1497+ v2 := viper.New()
1498+ v2.SetConfigFile(configFile)
1499+ err = v2.ReadInConfig()
1500+ require.NoError(t, err)
1501+ assert.Equal(t, 8088, v2.GetInt("server.port"))
1502+ assert.Equal(t, "testadmin", v2.GetString("auth.username"))
1503+ assert.Equal(t, "testsecret123", v2.GetString("auth.token_secret"))
1504+ })
14121505 t.Run("network_groups are persisted to disk", func(t *testing.T) {
14131506 // Setup: config file with auth, server, and network_groups
14141507 tmpDir := t.TempDir()
@@ -1409,6 +1409,99 @@ port = 9999
1409 assert.Equal(t, 5, len(matches))1409 assert.Equal(t, 5, len(matches))
1410 })1410 })
1411 1411
1412+ t.Run("AddRoute updates existing dotted domain route", func(t *testing.T) {
1413+ tmpDir := t.TempDir()
1414+ configFile := filepath.Join(tmpDir, "gordon.toml")
1415+ initialConfig := `
1416+[server]
1417+port = 8088
1418+
1419+[auth]
1420+username = "testadmin"
1421+token_secret = "testsecret123"
1422+
1423+[routes]
1424+"pitlane.example.com" = "pitlane:latest"
1425+`
1426+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1427+ require.NoError(t, err)
1428+
1429+ v := viper.New()
1430+ v.SetConfigFile(configFile)
1431+ err = v.ReadInConfig()
1432+ require.NoError(t, err)
1433+
1434+ eventBus := mocks.NewMockEventPublisher(t)
1435+ svc := NewService(v, eventBus)
1436+ ctx := testContext()
1437+ err = svc.Load(ctx)
1438+ require.NoError(t, err)
1439+
1440+ err = svc.AddRoute(ctx, domain.Route{
1441+ Domain: "pitlane.example.com",
1442+ Image: "reg.example.com/pitlane:latest",
1443+ })
1444+ require.NoError(t, err, "AddRoute should succeed when updating an existing dotted-domain route")
1445+
1446+ routes := svc.GetRoutes(ctx)
1447+ require.Len(t, routes, 1)
1448+ assert.Equal(t, "pitlane.example.com", routes[0].Domain)
1449+ assert.Equal(t, "reg.example.com/pitlane:latest", routes[0].Image)
1450+
1451+ v2 := viper.New()
1452+ v2.SetConfigFile(configFile)
1453+ err = v2.ReadInConfig()
1454+ require.NoError(t, err)
1455+ assert.Equal(t, 8088, v2.GetInt("server.port"))
1456+ assert.Equal(t, "testadmin", v2.GetString("auth.username"))
1457+ assert.Equal(t, "testsecret123", v2.GetString("auth.token_secret"))
1458+ })
1459+
1460+ t.Run("RemoveRoute removes dotted domain route", func(t *testing.T) {
1461+ tmpDir := t.TempDir()
1462+ configFile := filepath.Join(tmpDir, "gordon.toml")
1463+ initialConfig := `
1464+[server]
1465+port = 8088
1466+
1467+[auth]
1468+username = "testadmin"
1469+token_secret = "testsecret123"
1470+
1471+[routes]
1472+"pitlane.example.com" = "reg.example.com/pitlane:latest"
1473+"other.example.com" = "reg.example.com/other:latest"
1474+`
1475+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1476+ require.NoError(t, err)
1477+
1478+ v := viper.New()
1479+ v.SetConfigFile(configFile)
1480+ err = v.ReadInConfig()
1481+ require.NoError(t, err)
1482+
1483+ eventBus := mocks.NewMockEventPublisher(t)
1484+ svc := NewService(v, eventBus)
1485+ ctx := testContext()
1486+ err = svc.Load(ctx)
1487+ require.NoError(t, err)
1488+
1489+ err = svc.RemoveRoute(ctx, "pitlane.example.com")
1490+ require.NoError(t, err, "RemoveRoute should succeed for a dotted-domain route")
1491+
1492+ routes := svc.GetRoutes(ctx)
1493+ require.Len(t, routes, 1)
1494+ assert.Equal(t, "other.example.com", routes[0].Domain, "other routes should be preserved")
1495+ assert.Equal(t, "reg.example.com/other:latest", routes[0].Image, "other routes should be preserved")
1496+
1497+ v2 := viper.New()
1498+ v2.SetConfigFile(configFile)
1499+ err = v2.ReadInConfig()
1500+ require.NoError(t, err)
1501+ assert.Equal(t, 8088, v2.GetInt("server.port"))
1502+ assert.Equal(t, "testadmin", v2.GetString("auth.username"))
1503+ assert.Equal(t, "testsecret123", v2.GetString("auth.token_secret"))
1504+ })
1412 t.Run("network_groups are persisted to disk", func(t *testing.T) {1505 t.Run("network_groups are persisted to disk", func(t *testing.T) {
1413 // Setup: config file with auth, server, and network_groups1506 // Setup: config file with auth, server, and network_groups
1414 tmpDir := t.TempDir()1507 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