detached a8652f5

test(snapshot): cover repository durability faults

commit
a8652f510fd3f474b0ee4bff41f661ca0a0ab2c6
author
brice · · 2026-07-21 20:15 UTC
parent
2d19589
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +172 -14
M internal/adapters/snapshot/repository.go +27 -14
@@ -449,14 +449,17 @@ func verifyObjectFile(path string, digest ports.SnapshotDigest, ref codec.Object
449449 }
450450
451451 func (r *Repository) ensureSession(key string) error {
452- for _, dir := range []string{
453- r.dir,
454- filepath.Join(r.dir, repositorySessionsDir),
455- r.sessionPath(key),
456- filepath.Join(r.sessionPath(key), repositoryObjectsDir),
457- filepath.Join(r.sessionPath(key), repositoryGenerations),
452+ for _, directory := range []struct {
453+ path string
454+ phase string
455+ }{
456+ {r.dir, "repository"},
457+ {filepath.Join(r.dir, repositorySessionsDir), "sessions"},
458+ {r.sessionPath(key), "session"},
459+ {filepath.Join(r.sessionPath(key), repositoryObjectsDir), "objects"},
460+ {filepath.Join(r.sessionPath(key), repositoryGenerations), "generations"},
458461 } {
459- if err := r.ensurePrivateDirectory(dir); err != nil {
462+ if err := r.ensurePrivateDirectoryPhase(directory.path, directory.phase); err != nil {
460463 return fmt.Errorf("create snapshot repository directory: %w", err)
461464 }
462465 }
@@ -464,6 +467,10 @@ func (r *Repository) ensureSession(key string) error {
464467 }
465468
466469 func (r *Repository) ensurePrivateDirectory(dir string) error {
470+ return r.ensurePrivateDirectoryPhase(dir, "snapshot directory")
471+}
472+
473+func (r *Repository) ensurePrivateDirectoryPhase(dir, phase string) error {
467474 _, err := os.Lstat(dir)
468475 created := errors.Is(err, os.ErrNotExist)
469476 if err != nil && !created {
@@ -472,7 +479,7 @@ func (r *Repository) ensurePrivateDirectory(dir string) error {
472479 if created {
473480 parent := filepath.Dir(dir)
474481 if _, err := os.Lstat(parent); errors.Is(err, os.ErrNotExist) {
475- if err := r.ensurePrivateDirectory(parent); err != nil {
482+ if err := r.ensurePrivateDirectoryPhase(parent, "snapshot directory"); err != nil {
476483 return err
477484 }
478485 } else if err != nil {
@@ -486,7 +493,9 @@ func (r *Repository) ensurePrivateDirectory(dir string) error {
486493 return err
487494 }
488495 if created {
489- return r.syncDirectory(filepath.Dir(dir))
496+ if err := r.syncDirectory(filepath.Dir(dir)); err != nil {
497+ return fmt.Errorf("%s parent directory sync: %w", phase, err)
498+ }
490499 }
491500 return nil
492501 }
@@ -597,7 +606,11 @@ func (r *Repository) readHead(key string) (uint64, ports.SnapshotDigest, error)
597606 // after verifier reads it through the same secure descriptor path as Load.
598607 func (r *Repository) writeImmutable(path string, data []byte, verifier func([]byte) error) error {
599608 dir := filepath.Dir(path)
600- if err := r.ensurePrivateDirectory(dir); err != nil {
609+ phase := "object shard"
610+ if filepath.Base(dir) == repositoryGenerations {
611+ phase = "generation"
612+ }
613+ if err := r.ensurePrivateDirectoryPhase(dir, phase); err != nil {
601614 return err
602615 }
603616 temp, err := r.createTemp(dir)
@@ -608,8 +621,8 @@ func (r *Repository) writeImmutable(path string, data []byte, verifier func([]by
608621 closed := false
609622 cleanup := func(cause error) error {
610623 if !closed {
611- if err := r.closeFile(temp); err != nil && cause == nil {
612- cause = err
624+ if err := r.closeFile(temp); err != nil {
625+ cause = errors.Join(cause, err)
613626 }
614627 closed = true
615628 }
@@ -668,8 +681,8 @@ func (r *Repository) writeMutable(path string, data []byte) error {
668681 closed := false
669682 cleanup := func(cause error) error {
670683 if !closed {
671- if err := r.closeFile(temp); err != nil && cause == nil {
672- cause = err
684+ if err := r.closeFile(temp); err != nil {
685+ cause = errors.Join(cause, err)
673686 }
674687 closed = true
675688 }
@@ -449,14 +449,17 @@ func verifyObjectFile(path string, digest ports.SnapshotDigest, ref codec.Object
449 }449 }
450 450
451 func (r *Repository) ensureSession(key string) error {451 func (r *Repository) ensureSession(key string) error {
452- for _, dir := range []string{452+ for _, directory := range []struct {
453- r.dir,453+ path string
454- filepath.Join(r.dir, repositorySessionsDir),454+ phase string
455- r.sessionPath(key),455+ }{
456- filepath.Join(r.sessionPath(key), repositoryObjectsDir),456+ {r.dir, "repository"},
457- filepath.Join(r.sessionPath(key), repositoryGenerations),457+ {filepath.Join(r.dir, repositorySessionsDir), "sessions"},
458+ {r.sessionPath(key), "session"},
459+ {filepath.Join(r.sessionPath(key), repositoryObjectsDir), "objects"},
460+ {filepath.Join(r.sessionPath(key), repositoryGenerations), "generations"},
458 } {461 } {
459- if err := r.ensurePrivateDirectory(dir); err != nil {462+ if err := r.ensurePrivateDirectoryPhase(directory.path, directory.phase); err != nil {
460 return fmt.Errorf("create snapshot repository directory: %w", err)463 return fmt.Errorf("create snapshot repository directory: %w", err)
461 }464 }
462 }465 }
@@ -464,6 +467,10 @@ func (r *Repository) ensureSession(key string) error {
464 }467 }
465 468
466 func (r *Repository) ensurePrivateDirectory(dir string) error {469 func (r *Repository) ensurePrivateDirectory(dir string) error {
470+ return r.ensurePrivateDirectoryPhase(dir, "snapshot directory")
471+}
472+
473+func (r *Repository) ensurePrivateDirectoryPhase(dir, phase string) error {
467 _, err := os.Lstat(dir)474 _, err := os.Lstat(dir)
468 created := errors.Is(err, os.ErrNotExist)475 created := errors.Is(err, os.ErrNotExist)
469 if err != nil && !created {476 if err != nil && !created {
@@ -472,7 +479,7 @@ func (r *Repository) ensurePrivateDirectory(dir string) error {
472 if created {479 if created {
473 parent := filepath.Dir(dir)480 parent := filepath.Dir(dir)
474 if _, err := os.Lstat(parent); errors.Is(err, os.ErrNotExist) {481 if _, err := os.Lstat(parent); errors.Is(err, os.ErrNotExist) {
475- if err := r.ensurePrivateDirectory(parent); err != nil {482+ if err := r.ensurePrivateDirectoryPhase(parent, "snapshot directory"); err != nil {
476 return err483 return err
477 }484 }
478 } else if err != nil {485 } else if err != nil {
@@ -486,7 +493,9 @@ func (r *Repository) ensurePrivateDirectory(dir string) error {
486 return err493 return err
487 }494 }
488 if created {495 if created {
489- return r.syncDirectory(filepath.Dir(dir))496+ if err := r.syncDirectory(filepath.Dir(dir)); err != nil {
497+ return fmt.Errorf("%s parent directory sync: %w", phase, err)
498+ }
490 }499 }
491 return nil500 return nil
492 }501 }
@@ -597,7 +606,11 @@ func (r *Repository) readHead(key string) (uint64, ports.SnapshotDigest, error)
597 // after verifier reads it through the same secure descriptor path as Load.606 // after verifier reads it through the same secure descriptor path as Load.
598 func (r *Repository) writeImmutable(path string, data []byte, verifier func([]byte) error) error {607 func (r *Repository) writeImmutable(path string, data []byte, verifier func([]byte) error) error {
599 dir := filepath.Dir(path)608 dir := filepath.Dir(path)
600- if err := r.ensurePrivateDirectory(dir); err != nil {609+ phase := "object shard"
610+ if filepath.Base(dir) == repositoryGenerations {
611+ phase = "generation"
612+ }
613+ if err := r.ensurePrivateDirectoryPhase(dir, phase); err != nil {
601 return err614 return err
602 }615 }
603 temp, err := r.createTemp(dir)616 temp, err := r.createTemp(dir)
@@ -608,8 +621,8 @@ func (r *Repository) writeImmutable(path string, data []byte, verifier func([]by
608 closed := false621 closed := false
609 cleanup := func(cause error) error {622 cleanup := func(cause error) error {
610 if !closed {623 if !closed {
611- if err := r.closeFile(temp); err != nil && cause == nil {624+ if err := r.closeFile(temp); err != nil {
612- cause = err625+ cause = errors.Join(cause, err)
613 }626 }
614 closed = true627 closed = true
615 }628 }
@@ -668,8 +681,8 @@ func (r *Repository) writeMutable(path string, data []byte) error {
668 closed := false681 closed := false
669 cleanup := func(cause error) error {682 cleanup := func(cause error) error {
670 if !closed {683 if !closed {
671- if err := r.closeFile(temp); err != nil && cause == nil {684+ if err := r.closeFile(temp); err != nil {
672- cause = err685+ cause = errors.Join(cause, err)
673 }686 }
674 closed = true687 closed = true
675 }688 }
M internal/adapters/snapshot/repository_fault_test.go +145 -0
@@ -4,6 +4,8 @@ import (
44 "context"
55 "errors"
66 "os"
7+ "path/filepath"
8+ "strings"
79 "testing"
810
911 codec "github.com/bnema/vev/internal/usecase/snapshot"
@@ -31,6 +33,149 @@ func TestRepositoryHeadFailureKeepsOldCompleteGeneration(t *testing.T) {
3133 }
3234 }
3335
36+func TestRepositoryCleanupFaultsSurfaceForImmutableAndMutablePublication(t *testing.T) {
37+ primary := errors.New("primary write failure")
38+ cleanup := errors.New("cleanup failure")
39+ cases := []struct {
40+ name string
41+ location string
42+ cleanup string
43+ }{
44+ {name: "immutable/close temporary", location: "manifest", cleanup: "close"},
45+ {name: "immutable/remove temporary", location: "manifest", cleanup: "remove"},
46+ {name: "immutable/sync after temporary removal", location: "manifest", cleanup: "sync-after-remove"},
47+ {name: "mutable/close temporary", location: "HEAD", cleanup: "close"},
48+ {name: "mutable/remove temporary", location: "HEAD", cleanup: "remove"},
49+ {name: "mutable/sync after temporary removal", location: "HEAD", cleanup: "sync-after-remove"},
50+ }
51+ for _, tc := range cases {
52+ t.Run(tc.name, func(t *testing.T) {
53+ repo := NewRepository(privateDir(t))
54+ first := repositoryPublication(t, "named", 1, []byte("one"))
55+ if err := repo.Publish(context.Background(), first); err != nil {
56+ t.Fatal(err)
57+ }
58+ second := repositoryPublication(t, "named", 2, []byte("two"))
59+ key := sessionKey(second.Name)
60+ if tc.location == "manifest" {
61+ prepareObjects(t, repo, second)
62+ } else {
63+ prepareHeadStage(t, repo, second)
64+ }
65+ dir := filepath.Dir(repo.manifestPath(key, second.Generation))
66+ phase := "write manifest"
67+ if tc.location == "HEAD" {
68+ dir = filepath.Dir(repo.headPath(key))
69+ phase = "write HEAD"
70+ }
71+ repo.hooks.writeTemp = func(path string) error {
72+ if filepath.Dir(path) == dir {
73+ return primary
74+ }
75+ return nil
76+ }
77+ repo.hooks.closeFile = func(path string) error {
78+ if tc.cleanup == "close" && filepath.Dir(path) == dir {
79+ return cleanup
80+ }
81+ return nil
82+ }
83+ repo.hooks.remove = func(string) error {
84+ if tc.cleanup == "remove" {
85+ return cleanup
86+ }
87+ return nil
88+ }
89+ repo.hooks.syncDirectory = func(path string) error {
90+ if tc.cleanup == "sync-after-remove" && path == dir {
91+ return cleanup
92+ }
93+ return nil
94+ }
95+
96+ err := repo.Publish(context.Background(), second)
97+ if !errors.Is(err, primary) || !errors.Is(err, cleanup) || !strings.Contains(err.Error(), phase) {
98+ t.Fatalf("Publish error = %v, want %q and both primary and cleanup failures", err, phase)
99+ }
100+ if tc.cleanup != "remove" {
101+ matches, globErr := filepath.Glob(filepath.Join(dir, ".tmp-*"))
102+ if globErr != nil || len(matches) != 0 {
103+ t.Fatalf("temporary files after successful cleanup = %v, glob error = %v", matches, globErr)
104+ }
105+ }
106+ repo.hooks = repositoryHooks{}
107+ assertRepositoryCompleteGeneration(t, repo, "named", 1, 2)
108+ })
109+ }
110+}
111+
112+func TestRepositoryNewDirectorySyncFaultsAreIndependent(t *testing.T) {
113+ cases := []struct {
114+ name string
115+ phase string
116+ occurrence int
117+ directory func(*Repository, string) string
118+ }{
119+ {name: "repository", phase: "repository", occurrence: 1, directory: func(_ *Repository, root string) string { return filepath.Dir(root) }},
120+ {name: "sessions", phase: "sessions", occurrence: 1, directory: func(repo *Repository, _ string) string { return repo.dir }},
121+ {name: "session", phase: "session", occurrence: 1, directory: func(repo *Repository, _ string) string { return filepath.Join(repo.dir, repositorySessionsDir) }},
122+ {name: "objects", phase: "objects", occurrence: 1, directory: func(repo *Repository, _ string) string { return repo.sessionPath(sessionKey("named")) }},
123+ {name: "generations", phase: "generations", occurrence: 2, directory: func(repo *Repository, _ string) string { return repo.sessionPath(sessionKey("named")) }},
124+ {name: "object shard", phase: "object shard", occurrence: 1, directory: func(repo *Repository, _ string) string {
125+ return filepath.Join(repo.sessionPath(sessionKey("named")), repositoryObjectsDir)
126+ }},
127+ }
128+ for _, tc := range cases {
129+ t.Run(tc.name, func(t *testing.T) {
130+ parent := privateDir(t)
131+ root := filepath.Join(parent, "repository")
132+ repo := NewRepository(root)
133+ wantDirectory := tc.directory(repo, root)
134+ calls := 0
135+ injected := errors.New("new directory sync failure")
136+ repo.hooks.syncDirectory = func(directory string) error {
137+ if directory == wantDirectory {
138+ calls++
139+ if calls == tc.occurrence {
140+ return injected
141+ }
142+ }
143+ return nil
144+ }
145+
146+ publication := repositoryPublication(t, "named", 1, []byte("one"))
147+ err := repo.Publish(context.Background(), publication)
148+ if !errors.Is(err, injected) || !strings.Contains(err.Error(), tc.phase) {
149+ t.Fatalf("Publish error = %v, want %q-labelled directory sync failure", err, tc.phase)
150+ }
151+ repo.hooks = repositoryHooks{}
152+ // A first-generation failure cannot have an older complete generation;
153+ // it must not expose an incomplete one as if it were loadable.
154+ if _, loadErr := repo.Load(context.Background(), "named"); loadErr == nil || !strings.Contains(loadErr.Error(), "no complete snapshot generation") {
155+ t.Fatalf("Load error after incomplete first generation = %v, want no complete generation", loadErr)
156+ }
157+ if err := repo.Publish(context.Background(), publication); err != nil {
158+ t.Fatal(err)
159+ }
160+ assertRepositoryCompleteGeneration(t, repo, "named", 1)
161+ })
162+ }
163+}
164+
165+func assertRepositoryCompleteGeneration(t *testing.T, repo *Repository, name string, generations ...uint64) {
166+ t.Helper()
167+ got, err := repo.Load(context.Background(), name)
168+ if err != nil {
169+ t.Fatal(err)
170+ }
171+ for _, generation := range generations {
172+ if got.Generation == generation && len(got.Manifest) != 0 && len(got.Objects) != 0 {
173+ return
174+ }
175+ }
176+ t.Fatalf("Load = incomplete or mixed generation %+v, want one of %v", got, generations)
177+}
178+
34179 func TestRepositoryLoadFallsBackFromIncompleteNewestGeneration(t *testing.T) {
35180 dir := privateDir(t)
36181 repo := NewRepository(dir)
@@ -4,6 +4,8 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "os"6 "os"
7+ "path/filepath"
8+ "strings"
7 "testing"9 "testing"
8 10
9 codec "github.com/bnema/vev/internal/usecase/snapshot"11 codec "github.com/bnema/vev/internal/usecase/snapshot"
@@ -31,6 +33,149 @@ func TestRepositoryHeadFailureKeepsOldCompleteGeneration(t *testing.T) {
31 }33 }
32 }34 }
33 35
36+func TestRepositoryCleanupFaultsSurfaceForImmutableAndMutablePublication(t *testing.T) {
37+ primary := errors.New("primary write failure")
38+ cleanup := errors.New("cleanup failure")
39+ cases := []struct {
40+ name string
41+ location string
42+ cleanup string
43+ }{
44+ {name: "immutable/close temporary", location: "manifest", cleanup: "close"},
45+ {name: "immutable/remove temporary", location: "manifest", cleanup: "remove"},
46+ {name: "immutable/sync after temporary removal", location: "manifest", cleanup: "sync-after-remove"},
47+ {name: "mutable/close temporary", location: "HEAD", cleanup: "close"},
48+ {name: "mutable/remove temporary", location: "HEAD", cleanup: "remove"},
49+ {name: "mutable/sync after temporary removal", location: "HEAD", cleanup: "sync-after-remove"},
50+ }
51+ for _, tc := range cases {
52+ t.Run(tc.name, func(t *testing.T) {
53+ repo := NewRepository(privateDir(t))
54+ first := repositoryPublication(t, "named", 1, []byte("one"))
55+ if err := repo.Publish(context.Background(), first); err != nil {
56+ t.Fatal(err)
57+ }
58+ second := repositoryPublication(t, "named", 2, []byte("two"))
59+ key := sessionKey(second.Name)
60+ if tc.location == "manifest" {
61+ prepareObjects(t, repo, second)
62+ } else {
63+ prepareHeadStage(t, repo, second)
64+ }
65+ dir := filepath.Dir(repo.manifestPath(key, second.Generation))
66+ phase := "write manifest"
67+ if tc.location == "HEAD" {
68+ dir = filepath.Dir(repo.headPath(key))
69+ phase = "write HEAD"
70+ }
71+ repo.hooks.writeTemp = func(path string) error {
72+ if filepath.Dir(path) == dir {
73+ return primary
74+ }
75+ return nil
76+ }
77+ repo.hooks.closeFile = func(path string) error {
78+ if tc.cleanup == "close" && filepath.Dir(path) == dir {
79+ return cleanup
80+ }
81+ return nil
82+ }
83+ repo.hooks.remove = func(string) error {
84+ if tc.cleanup == "remove" {
85+ return cleanup
86+ }
87+ return nil
88+ }
89+ repo.hooks.syncDirectory = func(path string) error {
90+ if tc.cleanup == "sync-after-remove" && path == dir {
91+ return cleanup
92+ }
93+ return nil
94+ }
95+
96+ err := repo.Publish(context.Background(), second)
97+ if !errors.Is(err, primary) || !errors.Is(err, cleanup) || !strings.Contains(err.Error(), phase) {
98+ t.Fatalf("Publish error = %v, want %q and both primary and cleanup failures", err, phase)
99+ }
100+ if tc.cleanup != "remove" {
101+ matches, globErr := filepath.Glob(filepath.Join(dir, ".tmp-*"))
102+ if globErr != nil || len(matches) != 0 {
103+ t.Fatalf("temporary files after successful cleanup = %v, glob error = %v", matches, globErr)
104+ }
105+ }
106+ repo.hooks = repositoryHooks{}
107+ assertRepositoryCompleteGeneration(t, repo, "named", 1, 2)
108+ })
109+ }
110+}
111+
112+func TestRepositoryNewDirectorySyncFaultsAreIndependent(t *testing.T) {
113+ cases := []struct {
114+ name string
115+ phase string
116+ occurrence int
117+ directory func(*Repository, string) string
118+ }{
119+ {name: "repository", phase: "repository", occurrence: 1, directory: func(_ *Repository, root string) string { return filepath.Dir(root) }},
120+ {name: "sessions", phase: "sessions", occurrence: 1, directory: func(repo *Repository, _ string) string { return repo.dir }},
121+ {name: "session", phase: "session", occurrence: 1, directory: func(repo *Repository, _ string) string { return filepath.Join(repo.dir, repositorySessionsDir) }},
122+ {name: "objects", phase: "objects", occurrence: 1, directory: func(repo *Repository, _ string) string { return repo.sessionPath(sessionKey("named")) }},
123+ {name: "generations", phase: "generations", occurrence: 2, directory: func(repo *Repository, _ string) string { return repo.sessionPath(sessionKey("named")) }},
124+ {name: "object shard", phase: "object shard", occurrence: 1, directory: func(repo *Repository, _ string) string {
125+ return filepath.Join(repo.sessionPath(sessionKey("named")), repositoryObjectsDir)
126+ }},
127+ }
128+ for _, tc := range cases {
129+ t.Run(tc.name, func(t *testing.T) {
130+ parent := privateDir(t)
131+ root := filepath.Join(parent, "repository")
132+ repo := NewRepository(root)
133+ wantDirectory := tc.directory(repo, root)
134+ calls := 0
135+ injected := errors.New("new directory sync failure")
136+ repo.hooks.syncDirectory = func(directory string) error {
137+ if directory == wantDirectory {
138+ calls++
139+ if calls == tc.occurrence {
140+ return injected
141+ }
142+ }
143+ return nil
144+ }
145+
146+ publication := repositoryPublication(t, "named", 1, []byte("one"))
147+ err := repo.Publish(context.Background(), publication)
148+ if !errors.Is(err, injected) || !strings.Contains(err.Error(), tc.phase) {
149+ t.Fatalf("Publish error = %v, want %q-labelled directory sync failure", err, tc.phase)
150+ }
151+ repo.hooks = repositoryHooks{}
152+ // A first-generation failure cannot have an older complete generation;
153+ // it must not expose an incomplete one as if it were loadable.
154+ if _, loadErr := repo.Load(context.Background(), "named"); loadErr == nil || !strings.Contains(loadErr.Error(), "no complete snapshot generation") {
155+ t.Fatalf("Load error after incomplete first generation = %v, want no complete generation", loadErr)
156+ }
157+ if err := repo.Publish(context.Background(), publication); err != nil {
158+ t.Fatal(err)
159+ }
160+ assertRepositoryCompleteGeneration(t, repo, "named", 1)
161+ })
162+ }
163+}
164+
165+func assertRepositoryCompleteGeneration(t *testing.T, repo *Repository, name string, generations ...uint64) {
166+ t.Helper()
167+ got, err := repo.Load(context.Background(), name)
168+ if err != nil {
169+ t.Fatal(err)
170+ }
171+ for _, generation := range generations {
172+ if got.Generation == generation && len(got.Manifest) != 0 && len(got.Objects) != 0 {
173+ return
174+ }
175+ }
176+ t.Fatalf("Load = incomplete or mixed generation %+v, want one of %v", got, generations)
177+}
178+
34 func TestRepositoryLoadFallsBackFromIncompleteNewestGeneration(t *testing.T) {179 func TestRepositoryLoadFallsBackFromIncompleteNewestGeneration(t *testing.T) {
35 dir := privateDir(t)180 dir := privateDir(t)
36 repo := NewRepository(dir)181 repo := NewRepository(dir)

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 12

Tags 11

↑↓ moveenter openesc close