detached e8ee147

fix(cli): tighten route diagnose resource scoping

commit
e8ee147b04062e8bab4b15fbe0f8d815be0f76f5
author
brice · · 2026-05-22 07:00 UTC
parent
7b9923f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +167 -6
M internal/adapters/in/cli/routes.go +86 -6
@@ -1125,8 +1125,8 @@ func buildRouteDiagnosis(ctx context.Context, cp ControlPlane, domainName string
11251125 loadRouteDiagnosisConfig(ctx, cp, domainName, diag)
11261126 loadRouteDiagnosisRuntime(ctx, cp, domainName, diag)
11271127 loadRouteDiagnosisHealth(ctx, cp, domainName, diag)
1128- loadRouteDiagnosisVolumes(ctx, cp, domainName, diag)
11291128 loadRouteDiagnosisOrphanedAttachments(ctx, cp, domainName, diag)
1129+ loadRouteDiagnosisVolumes(ctx, cp, domainName, diag)
11301130 finalizeRouteDiagnosis(diag)
11311131 return diag, nil
11321132 }
@@ -1166,7 +1166,8 @@ func loadRouteDiagnosisHealth(ctx context.Context, cp ControlPlane, domainName s
11661166
11671167 func loadRouteDiagnosisVolumes(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
11681168 if volumes, err := cp.ListVolumes(ctx); err == nil {
1169- diag.Volumes = volumesForRouteDiagnosis(volumes, domainName)
1169+ scope := newRouteResourceScope(domainName, diag.Runtime, diag.OrphanedAttachments)
1170+ diag.Volumes = volumesForRouteDiagnosis(volumes, scope)
11701171 }
11711172 }
11721173
@@ -1194,17 +1195,75 @@ func finalizeRouteDiagnosis(diag *routeDiagnosis) {
11941195 }
11951196 }
11961197
1197-func volumesForRouteDiagnosis(volumes []dto.Volume, domainName string) []dto.Volume {
1198- needle := strings.ReplaceAll(strings.ToLower(domainName), ".", "-")
1198+type routeResourceScope struct {
1199+ containerNames map[string]struct{}
1200+ volumePrefixes []string
1201+}
1202+
1203+func newRouteResourceScope(domainName string, runtime *remote.RouteInfo, orphaned []domain.CleanupAttachment) routeResourceScope {
1204+ scope := routeResourceScope{
1205+ containerNames: make(map[string]struct{}),
1206+ volumePrefixes: []string{"gordon-" + strings.ReplaceAll(domainName, ".", "-") + "-"},
1207+ }
1208+ for _, name := range []string{
1209+ fmt.Sprintf("gordon-%s", domainName),
1210+ fmt.Sprintf("gordon-%s-new", domainName),
1211+ fmt.Sprintf("gordon-%s-next", domainName),
1212+ } {
1213+ scope.containerNames[name] = struct{}{}
1214+ }
1215+ if runtime != nil {
1216+ for _, attachment := range runtime.Attachments {
1217+ for _, name := range routeAttachmentContainerNameCandidates(domainName, attachment.Name) {
1218+ scope.containerNames[name] = struct{}{}
1219+ scope.volumePrefixes = append(scope.volumePrefixes, "gordon-"+name+"-")
1220+ }
1221+ }
1222+ }
1223+ for _, attachment := range orphaned {
1224+ if attachment.Name == "" {
1225+ continue
1226+ }
1227+ scope.containerNames[attachment.Name] = struct{}{}
1228+ scope.volumePrefixes = append(scope.volumePrefixes, "gordon-"+attachment.Name+"-")
1229+ }
1230+ return scope
1231+}
1232+
1233+func routeAttachmentContainerNameCandidates(domainName, serviceName string) []string {
1234+ if serviceName == "" {
1235+ return nil
1236+ }
1237+ return []string{
1238+ fmt.Sprintf("gordon-%s-%s", domain.SanitizeDomainForContainer(domainName), serviceName),
1239+ fmt.Sprintf("gordon-%s-%s", domain.SanitizeDomainForContainerLegacy(domainName), serviceName),
1240+ }
1241+}
1242+
1243+func volumesForRouteDiagnosis(volumes []dto.Volume, scope routeResourceScope) []dto.Volume {
11991244 var out []dto.Volume
12001245 for _, volume := range volumes {
1201- if strings.Contains(strings.ToLower(volume.Name), needle) {
1246+ if scope.matchesVolume(volume) {
12021247 out = append(out, volume)
12031248 }
12041249 }
12051250 return out
12061251 }
12071252
1253+func (s routeResourceScope) matchesVolume(volume dto.Volume) bool {
1254+ for _, containerName := range volume.Containers {
1255+ if _, ok := s.containerNames[containerName]; ok {
1256+ return true
1257+ }
1258+ }
1259+ for _, prefix := range s.volumePrefixes {
1260+ if strings.HasPrefix(volume.Name, prefix) {
1261+ return true
1262+ }
1263+ }
1264+ return false
1265+}
1266+
12081267 func attachmentsForRouteDiagnosis(attachments []domain.CleanupAttachment, domainName string) []domain.CleanupAttachment {
12091268 var out []domain.CleanupAttachment
12101269 for _, attachment := range attachments {
@@ -1239,7 +1298,28 @@ func writeRouteDiagnosisSummary(out io.Writer, diag *routeDiagnosis) error {
12391298 if diag.Runtime == nil {
12401299 return nil
12411300 }
1242- return cliWriteLine(out, cliRenderMeta("Container:", diag.Runtime.ContainerStatus+" "+shortContainerID(diag.Runtime.ContainerID)))
1301+ if err := cliWriteLine(out, cliRenderMeta("Container:", diag.Runtime.ContainerStatus+" "+shortContainerID(diag.Runtime.ContainerID))); err != nil {
1302+ return err
1303+ }
1304+ if len(diag.Runtime.Attachments) == 0 {
1305+ return nil
1306+ }
1307+ if err := cliWriteLine(out, cliRenderMeta("Active attachments:", "")); err != nil {
1308+ return err
1309+ }
1310+ for _, attachment := range diag.Runtime.Attachments {
1311+ label := attachment.Name
1312+ if label == "" {
1313+ label = attachment.ContainerID
1314+ }
1315+ if attachment.Status != "" {
1316+ label = fmt.Sprintf("%s (%s)", label, attachment.Status)
1317+ }
1318+ if err := cliWriteLine(out, " "+label); err != nil {
1319+ return err
1320+ }
1321+ }
1322+ return nil
12431323 }
12441324
12451325 func writeRouteDiagnosisLeftovers(out io.Writer, diag *routeDiagnosis) error {
@@ -1125,8 +1125,8 @@ func buildRouteDiagnosis(ctx context.Context, cp ControlPlane, domainName string
1125 loadRouteDiagnosisConfig(ctx, cp, domainName, diag)1125 loadRouteDiagnosisConfig(ctx, cp, domainName, diag)
1126 loadRouteDiagnosisRuntime(ctx, cp, domainName, diag)1126 loadRouteDiagnosisRuntime(ctx, cp, domainName, diag)
1127 loadRouteDiagnosisHealth(ctx, cp, domainName, diag)1127 loadRouteDiagnosisHealth(ctx, cp, domainName, diag)
1128- loadRouteDiagnosisVolumes(ctx, cp, domainName, diag)
1129 loadRouteDiagnosisOrphanedAttachments(ctx, cp, domainName, diag)1128 loadRouteDiagnosisOrphanedAttachments(ctx, cp, domainName, diag)
1129+ loadRouteDiagnosisVolumes(ctx, cp, domainName, diag)
1130 finalizeRouteDiagnosis(diag)1130 finalizeRouteDiagnosis(diag)
1131 return diag, nil1131 return diag, nil
1132 }1132 }
@@ -1166,7 +1166,8 @@ func loadRouteDiagnosisHealth(ctx context.Context, cp ControlPlane, domainName s
1166 1166
1167 func loadRouteDiagnosisVolumes(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {1167 func loadRouteDiagnosisVolumes(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1168 if volumes, err := cp.ListVolumes(ctx); err == nil {1168 if volumes, err := cp.ListVolumes(ctx); err == nil {
1169- diag.Volumes = volumesForRouteDiagnosis(volumes, domainName)1169+ scope := newRouteResourceScope(domainName, diag.Runtime, diag.OrphanedAttachments)
1170+ diag.Volumes = volumesForRouteDiagnosis(volumes, scope)
1170 }1171 }
1171 }1172 }
1172 1173
@@ -1194,17 +1195,75 @@ func finalizeRouteDiagnosis(diag *routeDiagnosis) {
1194 }1195 }
1195 }1196 }
1196 1197
1197-func volumesForRouteDiagnosis(volumes []dto.Volume, domainName string) []dto.Volume {1198+type routeResourceScope struct {
1198- needle := strings.ReplaceAll(strings.ToLower(domainName), ".", "-")1199+ containerNames map[string]struct{}
1200+ volumePrefixes []string
1201+}
1202+
1203+func newRouteResourceScope(domainName string, runtime *remote.RouteInfo, orphaned []domain.CleanupAttachment) routeResourceScope {
1204+ scope := routeResourceScope{
1205+ containerNames: make(map[string]struct{}),
1206+ volumePrefixes: []string{"gordon-" + strings.ReplaceAll(domainName, ".", "-") + "-"},
1207+ }
1208+ for _, name := range []string{
1209+ fmt.Sprintf("gordon-%s", domainName),
1210+ fmt.Sprintf("gordon-%s-new", domainName),
1211+ fmt.Sprintf("gordon-%s-next", domainName),
1212+ } {
1213+ scope.containerNames[name] = struct{}{}
1214+ }
1215+ if runtime != nil {
1216+ for _, attachment := range runtime.Attachments {
1217+ for _, name := range routeAttachmentContainerNameCandidates(domainName, attachment.Name) {
1218+ scope.containerNames[name] = struct{}{}
1219+ scope.volumePrefixes = append(scope.volumePrefixes, "gordon-"+name+"-")
1220+ }
1221+ }
1222+ }
1223+ for _, attachment := range orphaned {
1224+ if attachment.Name == "" {
1225+ continue
1226+ }
1227+ scope.containerNames[attachment.Name] = struct{}{}
1228+ scope.volumePrefixes = append(scope.volumePrefixes, "gordon-"+attachment.Name+"-")
1229+ }
1230+ return scope
1231+}
1232+
1233+func routeAttachmentContainerNameCandidates(domainName, serviceName string) []string {
1234+ if serviceName == "" {
1235+ return nil
1236+ }
1237+ return []string{
1238+ fmt.Sprintf("gordon-%s-%s", domain.SanitizeDomainForContainer(domainName), serviceName),
1239+ fmt.Sprintf("gordon-%s-%s", domain.SanitizeDomainForContainerLegacy(domainName), serviceName),
1240+ }
1241+}
1242+
1243+func volumesForRouteDiagnosis(volumes []dto.Volume, scope routeResourceScope) []dto.Volume {
1199 var out []dto.Volume1244 var out []dto.Volume
1200 for _, volume := range volumes {1245 for _, volume := range volumes {
1201- if strings.Contains(strings.ToLower(volume.Name), needle) {1246+ if scope.matchesVolume(volume) {
1202 out = append(out, volume)1247 out = append(out, volume)
1203 }1248 }
1204 }1249 }
1205 return out1250 return out
1206 }1251 }
1207 1252
1253+func (s routeResourceScope) matchesVolume(volume dto.Volume) bool {
1254+ for _, containerName := range volume.Containers {
1255+ if _, ok := s.containerNames[containerName]; ok {
1256+ return true
1257+ }
1258+ }
1259+ for _, prefix := range s.volumePrefixes {
1260+ if strings.HasPrefix(volume.Name, prefix) {
1261+ return true
1262+ }
1263+ }
1264+ return false
1265+}
1266+
1208 func attachmentsForRouteDiagnosis(attachments []domain.CleanupAttachment, domainName string) []domain.CleanupAttachment {1267 func attachmentsForRouteDiagnosis(attachments []domain.CleanupAttachment, domainName string) []domain.CleanupAttachment {
1209 var out []domain.CleanupAttachment1268 var out []domain.CleanupAttachment
1210 for _, attachment := range attachments {1269 for _, attachment := range attachments {
@@ -1239,7 +1298,28 @@ func writeRouteDiagnosisSummary(out io.Writer, diag *routeDiagnosis) error {
1239 if diag.Runtime == nil {1298 if diag.Runtime == nil {
1240 return nil1299 return nil
1241 }1300 }
1242- return cliWriteLine(out, cliRenderMeta("Container:", diag.Runtime.ContainerStatus+" "+shortContainerID(diag.Runtime.ContainerID)))1301+ if err := cliWriteLine(out, cliRenderMeta("Container:", diag.Runtime.ContainerStatus+" "+shortContainerID(diag.Runtime.ContainerID))); err != nil {
1302+ return err
1303+ }
1304+ if len(diag.Runtime.Attachments) == 0 {
1305+ return nil
1306+ }
1307+ if err := cliWriteLine(out, cliRenderMeta("Active attachments:", "")); err != nil {
1308+ return err
1309+ }
1310+ for _, attachment := range diag.Runtime.Attachments {
1311+ label := attachment.Name
1312+ if label == "" {
1313+ label = attachment.ContainerID
1314+ }
1315+ if attachment.Status != "" {
1316+ label = fmt.Sprintf("%s (%s)", label, attachment.Status)
1317+ }
1318+ if err := cliWriteLine(out, " "+label); err != nil {
1319+ return err
1320+ }
1321+ }
1322+ return nil
1243 }1323 }
1244 1324
1245 func writeRouteDiagnosisLeftovers(out io.Writer, diag *routeDiagnosis) error {1325 func writeRouteDiagnosisLeftovers(out io.Writer, diag *routeDiagnosis) error {
M internal/adapters/in/cli/routes_test.go +81 -0
@@ -899,6 +899,87 @@ func TestAttachmentsForRouteDiagnosisUsesOwner(t *testing.T) {
899899 assert.Equal(t, "match", matched[0].ContainerID)
900900 }
901901
902+func TestBuildRouteDiagnosis_VolumesAreScopedToExactRouteContainers(t *testing.T) {
903+ cpMock := climocks.NewMockControlPlane(t)
904+ cpMock.EXPECT().GetRoute(context.Background(), "example.test").Return(&domain.Route{Domain: "example.test", Image: "site:latest"}, nil).Once()
905+ cpMock.EXPECT().ListRoutesWithDetails(context.Background()).Return([]remote.RouteInfo{{
906+ Domain: "example.test",
907+ ContainerID: "route-1",
908+ ContainerStatus: "running",
909+ }}, nil).Once()
910+ cpMock.EXPECT().GetHealth(context.Background()).Return(nil, nil).Once()
911+ cpMock.EXPECT().ListVolumes(context.Background()).Return([]dto.Volume{
912+ {Name: "gordon-example-test-data-docs", Containers: []string{"gordon-example.test"}},
913+ {Name: "gordon-api-example-test-cache", Containers: []string{"gordon-api.example.test"}},
914+ }, nil).Once()
915+ cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return(nil, nil).Once()
916+
917+ diag, err := buildRouteDiagnosis(context.Background(), cpMock, "example.test")
918+
919+ require.NoError(t, err)
920+ require.Len(t, diag.Volumes, 1)
921+ assert.Equal(t, "gordon-example-test-data-docs", diag.Volumes[0].Name)
922+}
923+
924+func TestBuildRouteDiagnosis_IncludesAttachmentVolumesWithDoubleUnderscoreOwnerEncoding(t *testing.T) {
925+ cpMock := climocks.NewMockControlPlane(t)
926+ cpMock.EXPECT().GetRoute(context.Background(), "app.example.test").Return(&domain.Route{Domain: "app.example.test", Image: "app:latest"}, nil).Once()
927+ cpMock.EXPECT().ListRoutesWithDetails(context.Background()).Return([]remote.RouteInfo{{
928+ Domain: "app.example.test",
929+ ContainerID: "route-1",
930+ ContainerStatus: "running",
931+ Attachments: []dto.Attachment{{
932+ Name: "postgres",
933+ Image: "postgres:16",
934+ ContainerID: "attachment-1",
935+ Status: "running",
936+ }},
937+ }}, nil).Once()
938+ cpMock.EXPECT().GetHealth(context.Background()).Return(nil, nil).Once()
939+ cpMock.EXPECT().ListVolumes(context.Background()).Return([]dto.Volume{
940+ {Name: "gordon-gordon-app__example__test-postgres-var-lib-postgresql", Containers: []string{"gordon-app__example__test-postgres"}},
941+ }, nil).Once()
942+ cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return(nil, nil).Once()
943+
944+ diag, err := buildRouteDiagnosis(context.Background(), cpMock, "app.example.test")
945+
946+ require.NoError(t, err)
947+ require.Len(t, diag.Volumes, 1)
948+ assert.Equal(t, "gordon-gordon-app__example__test-postgres-var-lib-postgresql", diag.Volumes[0].Name)
949+}
950+
951+func TestWriteRouteDiagnosisText_ShowsActiveAttachmentsSeparatelyFromOrphans(t *testing.T) {
952+ var out bytes.Buffer
953+ diag := &routeDiagnosis{
954+ Domain: "app.example.test",
955+ Configured: true,
956+ Route: &domain.Route{Domain: "app.example.test", Image: "app:latest"},
957+ Runtime: &remote.RouteInfo{
958+ Domain: "app.example.test",
959+ ContainerID: "route-1",
960+ ContainerStatus: "running",
961+ Attachments: []dto.Attachment{{
962+ Name: "postgres",
963+ Image: "postgres:16",
964+ ContainerID: "attachment-1",
965+ Status: "running",
966+ }},
967+ },
968+ OrphanedAttachments: []domain.CleanupAttachment{{
969+ Name: "old-postgres",
970+ ContainerID: "attachment-2",
971+ Owner: "app.example.test",
972+ }},
973+ }
974+
975+ err := writeRouteDiagnosisText(&out, diag)
976+ require.NoError(t, err)
977+ rendered := stripANSI(out.String())
978+ assert.Contains(t, rendered, "Active attachments:")
979+ assert.Contains(t, rendered, "postgres (running)")
980+ assert.Contains(t, rendered, "Orphaned attachment: old-postgres")
981+}
982+
902983 func TestRunRoutePurge_AttachmentsForceIsScopedToRoute(t *testing.T) {
903984 cpMock := climocks.NewMockControlPlane(t)
904985 orphanedAttachments := []domain.CleanupAttachment{
@@ -899,6 +899,87 @@ func TestAttachmentsForRouteDiagnosisUsesOwner(t *testing.T) {
899 assert.Equal(t, "match", matched[0].ContainerID)899 assert.Equal(t, "match", matched[0].ContainerID)
900 }900 }
901 901
902+func TestBuildRouteDiagnosis_VolumesAreScopedToExactRouteContainers(t *testing.T) {
903+ cpMock := climocks.NewMockControlPlane(t)
904+ cpMock.EXPECT().GetRoute(context.Background(), "example.test").Return(&domain.Route{Domain: "example.test", Image: "site:latest"}, nil).Once()
905+ cpMock.EXPECT().ListRoutesWithDetails(context.Background()).Return([]remote.RouteInfo{{
906+ Domain: "example.test",
907+ ContainerID: "route-1",
908+ ContainerStatus: "running",
909+ }}, nil).Once()
910+ cpMock.EXPECT().GetHealth(context.Background()).Return(nil, nil).Once()
911+ cpMock.EXPECT().ListVolumes(context.Background()).Return([]dto.Volume{
912+ {Name: "gordon-example-test-data-docs", Containers: []string{"gordon-example.test"}},
913+ {Name: "gordon-api-example-test-cache", Containers: []string{"gordon-api.example.test"}},
914+ }, nil).Once()
915+ cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return(nil, nil).Once()
916+
917+ diag, err := buildRouteDiagnosis(context.Background(), cpMock, "example.test")
918+
919+ require.NoError(t, err)
920+ require.Len(t, diag.Volumes, 1)
921+ assert.Equal(t, "gordon-example-test-data-docs", diag.Volumes[0].Name)
922+}
923+
924+func TestBuildRouteDiagnosis_IncludesAttachmentVolumesWithDoubleUnderscoreOwnerEncoding(t *testing.T) {
925+ cpMock := climocks.NewMockControlPlane(t)
926+ cpMock.EXPECT().GetRoute(context.Background(), "app.example.test").Return(&domain.Route{Domain: "app.example.test", Image: "app:latest"}, nil).Once()
927+ cpMock.EXPECT().ListRoutesWithDetails(context.Background()).Return([]remote.RouteInfo{{
928+ Domain: "app.example.test",
929+ ContainerID: "route-1",
930+ ContainerStatus: "running",
931+ Attachments: []dto.Attachment{{
932+ Name: "postgres",
933+ Image: "postgres:16",
934+ ContainerID: "attachment-1",
935+ Status: "running",
936+ }},
937+ }}, nil).Once()
938+ cpMock.EXPECT().GetHealth(context.Background()).Return(nil, nil).Once()
939+ cpMock.EXPECT().ListVolumes(context.Background()).Return([]dto.Volume{
940+ {Name: "gordon-gordon-app__example__test-postgres-var-lib-postgresql", Containers: []string{"gordon-app__example__test-postgres"}},
941+ }, nil).Once()
942+ cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return(nil, nil).Once()
943+
944+ diag, err := buildRouteDiagnosis(context.Background(), cpMock, "app.example.test")
945+
946+ require.NoError(t, err)
947+ require.Len(t, diag.Volumes, 1)
948+ assert.Equal(t, "gordon-gordon-app__example__test-postgres-var-lib-postgresql", diag.Volumes[0].Name)
949+}
950+
951+func TestWriteRouteDiagnosisText_ShowsActiveAttachmentsSeparatelyFromOrphans(t *testing.T) {
952+ var out bytes.Buffer
953+ diag := &routeDiagnosis{
954+ Domain: "app.example.test",
955+ Configured: true,
956+ Route: &domain.Route{Domain: "app.example.test", Image: "app:latest"},
957+ Runtime: &remote.RouteInfo{
958+ Domain: "app.example.test",
959+ ContainerID: "route-1",
960+ ContainerStatus: "running",
961+ Attachments: []dto.Attachment{{
962+ Name: "postgres",
963+ Image: "postgres:16",
964+ ContainerID: "attachment-1",
965+ Status: "running",
966+ }},
967+ },
968+ OrphanedAttachments: []domain.CleanupAttachment{{
969+ Name: "old-postgres",
970+ ContainerID: "attachment-2",
971+ Owner: "app.example.test",
972+ }},
973+ }
974+
975+ err := writeRouteDiagnosisText(&out, diag)
976+ require.NoError(t, err)
977+ rendered := stripANSI(out.String())
978+ assert.Contains(t, rendered, "Active attachments:")
979+ assert.Contains(t, rendered, "postgres (running)")
980+ assert.Contains(t, rendered, "Orphaned attachment: old-postgres")
981+}
982+
902 func TestRunRoutePurge_AttachmentsForceIsScopedToRoute(t *testing.T) {983 func TestRunRoutePurge_AttachmentsForceIsScopedToRoute(t *testing.T) {
903 cpMock := climocks.NewMockControlPlane(t)984 cpMock := climocks.NewMockControlPlane(t)
904 orphanedAttachments := []domain.CleanupAttachment{985 orphanedAttachments := []domain.CleanupAttachment{

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