detached 78f9cbc

fix(deploy): report removed services as removed (#7)

Closes #2

## Summary

`gordon apps deploy` and `gordon apps remove` report a retired service as `removed` instead of `deployed`.

- New `domain.AppServiceRemoved` result.
- The admin deploy response maps succeeded `service.<name>.remove` steps to `removed` and leaves their `effective_revision` empty, since the input revision no longer declares the service.
- `ComputeOutcome` counts `removed` as success; the `apps remove` lifecycle path uses the new constant.
- The CLI prints `runner: removed` without a trailing revision.
- Docs mention the `removed` result for deploy and remove.

## Notes

`result: "removed"` is a new value for JSON clients. `stop` still reports `deployed` (out of scope).

## Checks

- `go test ./...`
- `golangci-lint run ./...`

Reviewed-on: https://git.bnema.dev/bnema/gordon/pulls/7
commit
78f9cbcaa14b0b0b43ec7cbeea10ecb3bfe7cc61
author
bnema · · 2026-10-02 13:23 UTC
parent
598d46d
signature
unsigned
10 files · +32 -9
M docs/cli/apps.md +4 -1
@@ -272,6 +272,8 @@ new image behind the same tag (for example `latest`) has a new digest and is
272272 deployed. A service already running with all of these unchanged keeps its
273273 container and is reported `unchanged`. Services with host binds or devices
274274 are always replaced, so bind and device policy changes apply on deploy.
275+A service the revision no longer declares is retired during deploy and
276+reported `removed`; its volumes and secrets are retained.
275277
276278 Fail-fast across services:
277279 the first failure stops the deploy, successful services are preserved,
@@ -331,7 +333,8 @@ gordon apps remove APP [--json]
331333
332334 Withdraws workloads. Volumes and secrets are retained as owned orphans
333335 under the old internal UUID; removing frees the name but never implicitly
334-attaches retained resources to a new app reusing the name.
336+attaches retained resources to a new app reusing the name. Each withdrawn
337+service is reported `removed`.
335338
336339 There is deliberately no `purge`: destructive volume deletion requires a
337340 separately accepted destructive-action contract.
@@ -272,6 +272,8 @@ new image behind the same tag (for example `latest`) has a new digest and is
272 deployed. A service already running with all of these unchanged keeps its272 deployed. A service already running with all of these unchanged keeps its
273 container and is reported `unchanged`. Services with host binds or devices273 container and is reported `unchanged`. Services with host binds or devices
274 are always replaced, so bind and device policy changes apply on deploy.274 are always replaced, so bind and device policy changes apply on deploy.
275+A service the revision no longer declares is retired during deploy and
276+reported `removed`; its volumes and secrets are retained.
275 277
276 Fail-fast across services:278 Fail-fast across services:
277 the first failure stops the deploy, successful services are preserved,279 the first failure stops the deploy, successful services are preserved,
@@ -331,7 +333,8 @@ gordon apps remove APP [--json]
331 333
332 Withdraws workloads. Volumes and secrets are retained as owned orphans334 Withdraws workloads. Volumes and secrets are retained as owned orphans
333 under the old internal UUID; removing frees the name but never implicitly335 under the old internal UUID; removing frees the name but never implicitly
334-attaches retained resources to a new app reusing the name.336+attaches retained resources to a new app reusing the name. Each withdrawn
337+service is reported `removed`.
335 338
336 There is deliberately no `purge`: destructive volume deletion requires a339 There is deliberately no `purge`: destructive volume deletion requires a
337 separately accepted destructive-action contract.340 separately accepted destructive-action contract.
M internal/adapters/in/cli/apps.go +1 -1
@@ -1000,7 +1000,7 @@ func renderDeployServices(out io.Writer, resp *dto.AppDeployResponse) error {
10001000 sort.Strings(names)
10011001 for _, name := range names {
10021002 svc := resp.Services[name]
1003- detail := svc.Result + " " + svc.EffectiveRevision
1003+ detail := strings.TrimSpace(svc.Result + " " + svc.EffectiveRevision)
10041004 if svc.Result == domain.AppServiceUnchanged {
10051005 detail += " (already running this image, config, and secrets)"
10061006 }
@@ -1000,7 +1000,7 @@ func renderDeployServices(out io.Writer, resp *dto.AppDeployResponse) error {
1000 sort.Strings(names)1000 sort.Strings(names)
1001 for _, name := range names {1001 for _, name := range names {
1002 svc := resp.Services[name]1002 svc := resp.Services[name]
1003- detail := svc.Result + " " + svc.EffectiveRevision1003+ detail := strings.TrimSpace(svc.Result + " " + svc.EffectiveRevision)
1004 if svc.Result == domain.AppServiceUnchanged {1004 if svc.Result == domain.AppServiceUnchanged {
1005 detail += " (already running this image, config, and secrets)"1005 detail += " (already running this image, config, and secrets)"
1006 }1006 }
M internal/adapters/in/cli/apps_test.go +5 -2
@@ -24,6 +24,7 @@ import (
2424 "github.com/bnema/gordon/internal/adapters/dto"
2525 climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
2626 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
27+ "github.com/bnema/gordon/internal/domain"
2728 )
2829
2930 // appPlane returns the generated control plane mock for one test.
@@ -277,8 +278,9 @@ func TestRenderAppDeployResponse_SortedServices(t *testing.T) {
277278 resp := &dto.AppDeployResponse{
278279 Op: "op-1", App: "blog", Outcome: "partial",
279280 Services: map[string]dto.AppServiceResultDTO{
280- "web": {Result: "deployed", EffectiveRevision: "rev-b"},
281- "db": {Result: "failed", EffectiveRevision: "rev-a", Error: "nope", RestartUnsafe: true},
281+ "web": {Result: "deployed", EffectiveRevision: "rev-b"},
282+ "db": {Result: "failed", EffectiveRevision: "rev-a", Error: "nope", RestartUnsafe: true},
283+ "runner": {Result: domain.AppServiceRemoved},
282284 },
283285 CleanupWarnings: []dto.AppCleanupWarningDTO{{Service: "web", Leftover: "ctr-old", Detail: "retire failed"}},
284286 Effective: &dto.AppEffectiveDTO{Services: map[string]string{"web": "rev-b", "db": "rev-a"}},
@@ -289,6 +291,7 @@ func TestRenderAppDeployResponse_SortedServices(t *testing.T) {
289291 text := out.String()
290292 assert.Less(t, strings.Index(text, "db:"), strings.Index(text, "web:"))
291293 assert.Contains(t, text, "restart_unsafe")
294+ assert.Contains(t, text, "runner: removed\n")
292295 assert.Contains(t, text, "cleanup:")
293296 assert.Contains(t, text, "blog-db")
294297 }
@@ -24,6 +24,7 @@ import (
24 "github.com/bnema/gordon/internal/adapters/dto"24 "github.com/bnema/gordon/internal/adapters/dto"
25 climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"25 climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
26 "github.com/bnema/gordon/internal/adapters/in/cli/remote"26 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
27+ "github.com/bnema/gordon/internal/domain"
27 )28 )
28 29
29 // appPlane returns the generated control plane mock for one test.30 // appPlane returns the generated control plane mock for one test.
@@ -277,8 +278,9 @@ func TestRenderAppDeployResponse_SortedServices(t *testing.T) {
277 resp := &dto.AppDeployResponse{278 resp := &dto.AppDeployResponse{
278 Op: "op-1", App: "blog", Outcome: "partial",279 Op: "op-1", App: "blog", Outcome: "partial",
279 Services: map[string]dto.AppServiceResultDTO{280 Services: map[string]dto.AppServiceResultDTO{
280- "web": {Result: "deployed", EffectiveRevision: "rev-b"},281+ "web": {Result: "deployed", EffectiveRevision: "rev-b"},
281- "db": {Result: "failed", EffectiveRevision: "rev-a", Error: "nope", RestartUnsafe: true},282+ "db": {Result: "failed", EffectiveRevision: "rev-a", Error: "nope", RestartUnsafe: true},
283+ "runner": {Result: domain.AppServiceRemoved},
282 },284 },
283 CleanupWarnings: []dto.AppCleanupWarningDTO{{Service: "web", Leftover: "ctr-old", Detail: "retire failed"}},285 CleanupWarnings: []dto.AppCleanupWarningDTO{{Service: "web", Leftover: "ctr-old", Detail: "retire failed"}},
284 Effective: &dto.AppEffectiveDTO{Services: map[string]string{"web": "rev-b", "db": "rev-a"}},286 Effective: &dto.AppEffectiveDTO{Services: map[string]string{"web": "rev-b", "db": "rev-a"}},
@@ -289,6 +291,7 @@ func TestRenderAppDeployResponse_SortedServices(t *testing.T) {
289 text := out.String()291 text := out.String()
290 assert.Less(t, strings.Index(text, "db:"), strings.Index(text, "web:"))292 assert.Less(t, strings.Index(text, "db:"), strings.Index(text, "web:"))
291 assert.Contains(t, text, "restart_unsafe")293 assert.Contains(t, text, "restart_unsafe")
294+ assert.Contains(t, text, "runner: removed\n")
292 assert.Contains(t, text, "cleanup:")295 assert.Contains(t, text, "cleanup:")
293 assert.Contains(t, text, "blog-db")296 assert.Contains(t, text, "blog-db")
294 }297 }
M internal/adapters/in/http/admin/handler_apps.go +7 -2
@@ -602,11 +602,14 @@ func toAppDeployResponse(app string, op *domain.AppOperation, includeDiagnostics
602602 if !ok {
603603 continue
604604 }
605- service, _, _ = strings.Cut(service, ".")
605+ service, action, _ := strings.Cut(service, ".")
606606 entry := resp.Services[service]
607607 switch step.State {
608608 case domain.AppStepSucceeded:
609609 entry.Result = "deployed"
610+ if action == "remove" {
611+ entry.Result = domain.AppServiceRemoved
612+ }
610613 if strings.HasPrefix(step.Detail, domain.AppServiceUnchanged+":") {
611614 entry.Result = domain.AppServiceUnchanged
612615 }
@@ -618,7 +621,9 @@ func toAppDeployResponse(app string, op *domain.AppOperation, includeDiagnostics
618621 }
619622 entry.Before = step.Before
620623 entry.After = step.After
621- if entry.EffectiveRevision == "" {
624+ // A removed service has no effective revision: the input revision
625+ // no longer declares it.
626+ if entry.EffectiveRevision == "" && entry.Result != domain.AppServiceRemoved {
622627 entry.EffectiveRevision = op.InputRevision
623628 }
624629 resp.Services[service] = entry
@@ -602,11 +602,14 @@ func toAppDeployResponse(app string, op *domain.AppOperation, includeDiagnostics
602 if !ok {602 if !ok {
603 continue603 continue
604 }604 }
605- service, _, _ = strings.Cut(service, ".")605+ service, action, _ := strings.Cut(service, ".")
606 entry := resp.Services[service]606 entry := resp.Services[service]
607 switch step.State {607 switch step.State {
608 case domain.AppStepSucceeded:608 case domain.AppStepSucceeded:
609 entry.Result = "deployed"609 entry.Result = "deployed"
610+ if action == "remove" {
611+ entry.Result = domain.AppServiceRemoved
612+ }
610 if strings.HasPrefix(step.Detail, domain.AppServiceUnchanged+":") {613 if strings.HasPrefix(step.Detail, domain.AppServiceUnchanged+":") {
611 entry.Result = domain.AppServiceUnchanged614 entry.Result = domain.AppServiceUnchanged
612 }615 }
@@ -618,7 +621,9 @@ func toAppDeployResponse(app string, op *domain.AppOperation, includeDiagnostics
618 }621 }
619 entry.Before = step.Before622 entry.Before = step.Before
620 entry.After = step.After623 entry.After = step.After
621- if entry.EffectiveRevision == "" {624+ // A removed service has no effective revision: the input revision
625+ // no longer declares it.
626+ if entry.EffectiveRevision == "" && entry.Result != domain.AppServiceRemoved {
622 entry.EffectiveRevision = op.InputRevision627 entry.EffectiveRevision = op.InputRevision
623 }628 }
624 resp.Services[service] = entry629 resp.Services[service] = entry
M internal/adapters/in/http/admin/handler_apps_test.go +4 -0
@@ -128,6 +128,7 @@ func TestHandler_AppDeploy_MapsOp(t *testing.T) {
128128 Steps: []domain.AppOperationStep{
129129 {ID: "preflight", State: domain.AppStepSucceeded},
130130 {ID: "service.web.replace", State: domain.AppStepSucceeded, Before: "c-old", After: "c-new"},
131+ {ID: "service.runner.remove", State: domain.AppStepSucceeded, Before: "c-runner"},
131132 },
132133 }
133134 appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(op, nil).Once()
@@ -147,6 +148,9 @@ func TestHandler_AppDeploy_MapsOp(t *testing.T) {
147148 require.Contains(t, resp.Services, "web")
148149 assert.Equal(t, "deployed", resp.Services["web"].Result)
149150 assert.Equal(t, "c-new", resp.Services["web"].After)
151+ require.Contains(t, resp.Services, "runner")
152+ assert.Equal(t, domain.AppServiceRemoved, resp.Services["runner"].Result)
153+ assert.Empty(t, resp.Services["runner"].EffectiveRevision, "a removed service has no effective revision")
150154 // Effective and retained state comes from the app read model, never
151155 // from the journal outcome.
152156 require.NotNil(t, resp.Effective)
@@ -128,6 +128,7 @@ func TestHandler_AppDeploy_MapsOp(t *testing.T) {
128 Steps: []domain.AppOperationStep{128 Steps: []domain.AppOperationStep{
129 {ID: "preflight", State: domain.AppStepSucceeded},129 {ID: "preflight", State: domain.AppStepSucceeded},
130 {ID: "service.web.replace", State: domain.AppStepSucceeded, Before: "c-old", After: "c-new"},130 {ID: "service.web.replace", State: domain.AppStepSucceeded, Before: "c-old", After: "c-new"},
131+ {ID: "service.runner.remove", State: domain.AppStepSucceeded, Before: "c-runner"},
131 },132 },
132 }133 }
133 appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(op, nil).Once()134 appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(op, nil).Once()
@@ -147,6 +148,9 @@ func TestHandler_AppDeploy_MapsOp(t *testing.T) {
147 require.Contains(t, resp.Services, "web")148 require.Contains(t, resp.Services, "web")
148 assert.Equal(t, "deployed", resp.Services["web"].Result)149 assert.Equal(t, "deployed", resp.Services["web"].Result)
149 assert.Equal(t, "c-new", resp.Services["web"].After)150 assert.Equal(t, "c-new", resp.Services["web"].After)
151+ require.Contains(t, resp.Services, "runner")
152+ assert.Equal(t, domain.AppServiceRemoved, resp.Services["runner"].Result)
153+ assert.Empty(t, resp.Services["runner"].EffectiveRevision, "a removed service has no effective revision")
150 // Effective and retained state comes from the app read model, never154 // Effective and retained state comes from the app read model, never
151 // from the journal outcome.155 // from the journal outcome.
152 require.NotNil(t, resp.Effective)156 require.NotNil(t, resp.Effective)
M internal/domain/app_state.go +4 -0
@@ -35,6 +35,10 @@ const (
3535 // A succeeded step whose Detail starts with it reports this result.
3636 const AppServiceUnchanged = "unchanged"
3737
38+// AppServiceRemoved is the result of a step that retired a service and its
39+// container. A succeeded "service.<name>.remove" step reports this result.
40+const AppServiceRemoved = "removed"
41+
3842 // App operation outcomes over terminal per-service results.
3943 const (
4044 AppOutcomeSuccess = "success"
@@ -35,6 +35,10 @@ const (
35 // A succeeded step whose Detail starts with it reports this result.35 // A succeeded step whose Detail starts with it reports this result.
36 const AppServiceUnchanged = "unchanged"36 const AppServiceUnchanged = "unchanged"
37 37
38+// AppServiceRemoved is the result of a step that retired a service and its
39+// container. A succeeded "service.<name>.remove" step reports this result.
40+const AppServiceRemoved = "removed"
41+
38 // App operation outcomes over terminal per-service results.42 // App operation outcomes over terminal per-service results.
39 const (43 const (
40 AppOutcomeSuccess = "success"44 AppOutcomeSuccess = "success"
M internal/usecase/deployment/lifecycle.go +2 -2
@@ -505,9 +505,9 @@ func (s *Service) removeServiceContainers(ctx context.Context, app, opID string,
505505 if !retired.Gone {
506506 return nil, fmt.Errorf("deployment: remove %q/%q: container %s not confirmed gone: %s", app, name, container, cleanupDetail(retired))
507507 }
508- result.Services[name] = ServiceResult{Result: "deployed", Before: container, After: "", CleanupWarnings: retired.Warnings}
508+ result.Services[name] = ServiceResult{Result: domain.AppServiceRemoved, Before: container, After: "", CleanupWarnings: retired.Warnings}
509509 } else {
510- result.Services[name] = ServiceResult{Result: "deployed", Before: container, After: ""}
510+ result.Services[name] = ServiceResult{Result: domain.AppServiceRemoved, Before: container, After: ""}
511511 }
512512 step.State = domain.AppStepSucceeded
513513 steps = append(steps, step)
@@ -505,9 +505,9 @@ func (s *Service) removeServiceContainers(ctx context.Context, app, opID string,
505 if !retired.Gone {505 if !retired.Gone {
506 return nil, fmt.Errorf("deployment: remove %q/%q: container %s not confirmed gone: %s", app, name, container, cleanupDetail(retired))506 return nil, fmt.Errorf("deployment: remove %q/%q: container %s not confirmed gone: %s", app, name, container, cleanupDetail(retired))
507 }507 }
508- result.Services[name] = ServiceResult{Result: "deployed", Before: container, After: "", CleanupWarnings: retired.Warnings}508+ result.Services[name] = ServiceResult{Result: domain.AppServiceRemoved, Before: container, After: "", CleanupWarnings: retired.Warnings}
509 } else {509 } else {
510- result.Services[name] = ServiceResult{Result: "deployed", Before: container, After: ""}510+ result.Services[name] = ServiceResult{Result: domain.AppServiceRemoved, Before: container, After: ""}
511 }511 }
512 step.State = domain.AppStepSucceeded512 step.State = domain.AppStepSucceeded
513 steps = append(steps, step)513 steps = append(steps, step)
M internal/usecase/deployment/recovery_integration_test.go +1 -0
@@ -225,6 +225,7 @@ func TestRemove_InhibitsBeforeRuntimeWithdrawal(t *testing.T) {
225225 result, err := svc.Remove(ctx, "blog", "")
226226 require.NoError(t, err)
227227 require.NotNil(t, result)
228+ assert.Equal(t, domain.AppServiceRemoved, result.Services["web"].Result)
228229 assert.Equal(t, []string{"intent-stopped", "inhibited", "stopped", "removed", "retired"}, order)
229230 runtime.AssertNotCalled(t, "RemoveVolume", mock.Anything, mock.Anything, mock.Anything)
230231 }
@@ -225,6 +225,7 @@ func TestRemove_InhibitsBeforeRuntimeWithdrawal(t *testing.T) {
225 result, err := svc.Remove(ctx, "blog", "")225 result, err := svc.Remove(ctx, "blog", "")
226 require.NoError(t, err)226 require.NoError(t, err)
227 require.NotNil(t, result)227 require.NotNil(t, result)
228+ assert.Equal(t, domain.AppServiceRemoved, result.Services["web"].Result)
228 assert.Equal(t, []string{"intent-stopped", "inhibited", "stopped", "removed", "retired"}, order)229 assert.Equal(t, []string{"intent-stopped", "inhibited", "stopped", "removed", "retired"}, order)
229 runtime.AssertNotCalled(t, "RemoveVolume", mock.Anything, mock.Anything, mock.Anything)230 runtime.AssertNotCalled(t, "RemoveVolume", mock.Anything, mock.Anything, mock.Anything)
230 }231 }
M internal/usecase/deployment/service.go +1 -1
@@ -936,7 +936,7 @@ func ComputeOutcome(results map[string]ServiceResult) string {
936936 failed := 0
937937 for _, result := range results {
938938 switch result.Result {
939- case "deployed", ServiceResultUnchanged:
939+ case "deployed", ServiceResultUnchanged, domain.AppServiceRemoved:
940940 deployed++
941941 case "failed":
942942 failed++
@@ -936,7 +936,7 @@ func ComputeOutcome(results map[string]ServiceResult) string {
936 failed := 0936 failed := 0
937 for _, result := range results {937 for _, result := range results {
938 switch result.Result {938 switch result.Result {
939- case "deployed", ServiceResultUnchanged:939+ case "deployed", ServiceResultUnchanged, domain.AppServiceRemoved:
940 deployed++940 deployed++
941 case "failed":941 case "failed":
942 failed++942 failed++
M internal/usecase/deployment/service_test.go +3 -0
@@ -63,6 +63,9 @@ func preflightService(
6363 func TestComputeOutcome_TerminalResults(t *testing.T) {
6464 deployed := deployment.ServiceResult{Result: "deployed"}
6565 failed := deployment.ServiceResult{Result: "failed", Error: "boom"}
66+ removed := deployment.ServiceResult{Result: domain.AppServiceRemoved}
67+ assert.Equal(t, "success", deployment.ComputeOutcome(map[string]deployment.ServiceResult{"a": removed}))
68+ assert.Equal(t, "partial", deployment.ComputeOutcome(map[string]deployment.ServiceResult{"a": removed, "b": failed}))
6669 assert.Equal(t, "success", deployment.ComputeOutcome(map[string]deployment.ServiceResult{"a": deployed}))
6770 assert.Equal(t, "failed", deployment.ComputeOutcome(map[string]deployment.ServiceResult{"a": failed}))
6871 assert.Equal(t, "partial", deployment.ComputeOutcome(map[string]deployment.ServiceResult{"a": deployed, "b": failed}))
@@ -63,6 +63,9 @@ func preflightService(
63 func TestComputeOutcome_TerminalResults(t *testing.T) {63 func TestComputeOutcome_TerminalResults(t *testing.T) {
64 deployed := deployment.ServiceResult{Result: "deployed"}64 deployed := deployment.ServiceResult{Result: "deployed"}
65 failed := deployment.ServiceResult{Result: "failed", Error: "boom"}65 failed := deployment.ServiceResult{Result: "failed", Error: "boom"}
66+ removed := deployment.ServiceResult{Result: domain.AppServiceRemoved}
67+ assert.Equal(t, "success", deployment.ComputeOutcome(map[string]deployment.ServiceResult{"a": removed}))
68+ assert.Equal(t, "partial", deployment.ComputeOutcome(map[string]deployment.ServiceResult{"a": removed, "b": failed}))
66 assert.Equal(t, "success", deployment.ComputeOutcome(map[string]deployment.ServiceResult{"a": deployed}))69 assert.Equal(t, "success", deployment.ComputeOutcome(map[string]deployment.ServiceResult{"a": deployed}))
67 assert.Equal(t, "failed", deployment.ComputeOutcome(map[string]deployment.ServiceResult{"a": failed}))70 assert.Equal(t, "failed", deployment.ComputeOutcome(map[string]deployment.ServiceResult{"a": failed}))
68 assert.Equal(t, "partial", deployment.ComputeOutcome(map[string]deployment.ServiceResult{"a": deployed, "b": failed}))71 assert.Equal(t, "partial", deployment.ComputeOutcome(map[string]deployment.ServiceResult{"a": deployed, "b": failed}))

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