detached ed8ebfe

refactor(snapshot): unify atomic file preparation

commit
ed8ebfea0f02baad1af852190446ece476297ecb
author
brice · · 2026-07-22 04:15 UTC
parent
97e11ed
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +99 -73
M internal/adapters/snapshot/atomic.go +31 -65
@@ -110,56 +110,20 @@ func (r *Repository) writeImmutable(path string, data []byte, verifier func([]by
110110 if err := r.ensurePrivateDirectoryPhase(dir, phase); err != nil {
111111 return err
112112 }
113- temp, err := r.createTemp(dir)
114- if err != nil {
115- return err
116- }
117- tempPath := temp.Name()
118- closed := false
119- cleanup := func(cause error) error {
120- if !closed {
121- if err := r.closeFile(temp); err != nil {
122- cause = errors.Join(cause, err)
113+ return r.withAtomicTemp(dir, data, func(tempPath string) (bool, error) {
114+ if err := r.installImmutable(tempPath, path); err != nil {
115+ if !errors.Is(err, os.ErrExist) {
116+ return true, err
123117 }
124- closed = true
125- }
126- if err := r.remove(tempPath); err != nil && !errors.Is(err, os.ErrNotExist) {
127- cause = errors.Join(cause, err)
128- } else if err == nil {
129- cause = errors.Join(cause, r.syncDirectory(dir))
130- }
131- return cause
132- }
133- if err := temp.Chmod(0o600); err != nil {
134- return cleanup(err)
135- }
136- if err := r.writeFile(temp, data); err != nil {
137- return cleanup(err)
138- }
139- if err := r.syncFile(temp); err != nil {
140- return cleanup(err)
141- }
142- if err := r.closeFile(temp); err != nil {
143- closed = true
144- return cleanup(err)
145- }
146- closed = true
147- if err := r.installImmutable(tempPath, path); err != nil {
148- if errors.Is(err, os.ErrExist) {
149118 existing, readErr := r.readBounded(path)
150119 if readErr != nil {
151- return cleanup(readErr)
152- }
153- if verifyErr := verifier(existing); verifyErr != nil {
154- return cleanup(verifyErr)
120+ return true, readErr
155121 }
156- return cleanup(nil)
122+ return true, verifier(existing)
157123 }
158- return cleanup(err)
159- }
160- // Removing the linked temporary entry before the directory sync lets one
161- // sync persist both the immutable install and its consumed temporary file.
162- return cleanup(nil)
124+ // link retains the temporary name, so cleanup must remove and sync it.
125+ return true, nil
126+ })
163127 }
164128
165129 // writeMutable is used only for HEAD, the sole authoritative pointer allowed
@@ -169,6 +133,24 @@ func (r *Repository) writeMutable(path string, data []byte) error {
169133 if err := r.ensurePrivateDirectory(dir); err != nil {
170134 return err
171135 }
136+ return r.withAtomicTemp(dir, data, func(tempPath string) (bool, error) {
137+ if err := r.rename(tempPath, path); err != nil {
138+ return true, err
139+ }
140+ // rename consumed tempPath. Its directory sync persists both rename and
141+ // the absence of the temporary entry.
142+ if err := r.syncDirectory(dir); err != nil {
143+ return true, err
144+ }
145+ return false, nil
146+ })
147+}
148+
149+// withAtomicTemp prepares a private, durable temporary file, invokes publish,
150+// then closes and removes the temporary entry when publish reports that its
151+// operation did not consume it. Cleanup errors are joined after the operation
152+// error in the same order as the former immutable and mutable write paths.
153+func (r *Repository) withAtomicTemp(dir string, data []byte, publish func(string) (removeTemp bool, err error)) error {
172154 temp, err := r.createTemp(dir)
173155 if err != nil {
174156 return err
@@ -203,15 +185,11 @@ func (r *Repository) writeMutable(path string, data []byte) error {
203185 return cleanup(err)
204186 }
205187 closed = true
206- if err := r.rename(tempPath, path); err != nil {
207- return cleanup(err)
208- }
209- if err := r.syncDirectory(dir); err != nil {
210- return cleanup(err)
188+ removeTemp, err := publish(tempPath)
189+ if !removeTemp {
190+ return err
211191 }
212- // rename consumed tempPath. Its directory sync above persists both rename
213- // and the absence of the temporary entry.
214- return nil
192+ return cleanup(err)
215193 }
216194
217195 func (r *Repository) createTemp(dir string) (*os.File, error) {
@@ -412,15 +390,3 @@ func readBoundedFile(f *os.File) ([]byte, error) {
412390 }
413391 return data, nil
414392 }
415-
416-func equalBytes(a, b []byte) bool {
417- if len(a) != len(b) {
418- return false
419- }
420- for i := range a {
421- if a[i] != b[i] {
422- return false
423- }
424- }
425- return true
426-}
@@ -110,56 +110,20 @@ func (r *Repository) writeImmutable(path string, data []byte, verifier func([]by
110 if err := r.ensurePrivateDirectoryPhase(dir, phase); err != nil {110 if err := r.ensurePrivateDirectoryPhase(dir, phase); err != nil {
111 return err111 return err
112 }112 }
113- temp, err := r.createTemp(dir)113+ return r.withAtomicTemp(dir, data, func(tempPath string) (bool, error) {
114- if err != nil {114+ if err := r.installImmutable(tempPath, path); err != nil {
115- return err115+ if !errors.Is(err, os.ErrExist) {
116- }116+ return true, err
117- tempPath := temp.Name()
118- closed := false
119- cleanup := func(cause error) error {
120- if !closed {
121- if err := r.closeFile(temp); err != nil {
122- cause = errors.Join(cause, err)
123 }117 }
124- closed = true
125- }
126- if err := r.remove(tempPath); err != nil && !errors.Is(err, os.ErrNotExist) {
127- cause = errors.Join(cause, err)
128- } else if err == nil {
129- cause = errors.Join(cause, r.syncDirectory(dir))
130- }
131- return cause
132- }
133- if err := temp.Chmod(0o600); err != nil {
134- return cleanup(err)
135- }
136- if err := r.writeFile(temp, data); err != nil {
137- return cleanup(err)
138- }
139- if err := r.syncFile(temp); err != nil {
140- return cleanup(err)
141- }
142- if err := r.closeFile(temp); err != nil {
143- closed = true
144- return cleanup(err)
145- }
146- closed = true
147- if err := r.installImmutable(tempPath, path); err != nil {
148- if errors.Is(err, os.ErrExist) {
149 existing, readErr := r.readBounded(path)118 existing, readErr := r.readBounded(path)
150 if readErr != nil {119 if readErr != nil {
151- return cleanup(readErr)120+ return true, readErr
152- }
153- if verifyErr := verifier(existing); verifyErr != nil {
154- return cleanup(verifyErr)
155 }121 }
156- return cleanup(nil)122+ return true, verifier(existing)
157 }123 }
158- return cleanup(err)124+ // link retains the temporary name, so cleanup must remove and sync it.
159- }125+ return true, nil
160- // Removing the linked temporary entry before the directory sync lets one126+ })
161- // sync persist both the immutable install and its consumed temporary file.
162- return cleanup(nil)
163 }127 }
164 128
165 // writeMutable is used only for HEAD, the sole authoritative pointer allowed129 // writeMutable is used only for HEAD, the sole authoritative pointer allowed
@@ -169,6 +133,24 @@ func (r *Repository) writeMutable(path string, data []byte) error {
169 if err := r.ensurePrivateDirectory(dir); err != nil {133 if err := r.ensurePrivateDirectory(dir); err != nil {
170 return err134 return err
171 }135 }
136+ return r.withAtomicTemp(dir, data, func(tempPath string) (bool, error) {
137+ if err := r.rename(tempPath, path); err != nil {
138+ return true, err
139+ }
140+ // rename consumed tempPath. Its directory sync persists both rename and
141+ // the absence of the temporary entry.
142+ if err := r.syncDirectory(dir); err != nil {
143+ return true, err
144+ }
145+ return false, nil
146+ })
147+}
148+
149+// withAtomicTemp prepares a private, durable temporary file, invokes publish,
150+// then closes and removes the temporary entry when publish reports that its
151+// operation did not consume it. Cleanup errors are joined after the operation
152+// error in the same order as the former immutable and mutable write paths.
153+func (r *Repository) withAtomicTemp(dir string, data []byte, publish func(string) (removeTemp bool, err error)) error {
172 temp, err := r.createTemp(dir)154 temp, err := r.createTemp(dir)
173 if err != nil {155 if err != nil {
174 return err156 return err
@@ -203,15 +185,11 @@ func (r *Repository) writeMutable(path string, data []byte) error {
203 return cleanup(err)185 return cleanup(err)
204 }186 }
205 closed = true187 closed = true
206- if err := r.rename(tempPath, path); err != nil {188+ removeTemp, err := publish(tempPath)
207- return cleanup(err)189+ if !removeTemp {
208- }190+ return err
209- if err := r.syncDirectory(dir); err != nil {
210- return cleanup(err)
211 }191 }
212- // rename consumed tempPath. Its directory sync above persists both rename192+ return cleanup(err)
213- // and the absence of the temporary entry.
214- return nil
215 }193 }
216 194
217 func (r *Repository) createTemp(dir string) (*os.File, error) {195 func (r *Repository) createTemp(dir string) (*os.File, error) {
@@ -412,15 +390,3 @@ func readBoundedFile(f *os.File) ([]byte, error) {
412 }390 }
413 return data, nil391 return data, nil
414 }392 }
415-
416-func equalBytes(a, b []byte) bool {
417- if len(a) != len(b) {
418- return false
419- }
420- for i := range a {
421- if a[i] != b[i] {
422- return false
423- }
424- }
425- return true
426-}
M internal/adapters/snapshot/atomic_test.go +59 -1
@@ -1,7 +1,9 @@
11 package snapshot
22
33 import (
4+ "bytes"
45 "errors"
6+ "os"
57 "path/filepath"
68 "testing"
79 )
@@ -22,7 +24,7 @@ func TestWriteImmutableUsesOneFinalDirectorySync(t *testing.T) {
2224 }
2325 data := []byte("immutable")
2426 if err := repo.writeImmutable(filepath.Join(dir, "object"), data, func(existing []byte) error {
25- if !equalBytes(existing, data) {
27+ if !bytes.Equal(existing, data) {
2628 return errors.New("unexpected immutable data")
2729 }
2830 return nil
@@ -33,3 +35,59 @@ func TestWriteImmutableUsesOneFinalDirectorySync(t *testing.T) {
3335 t.Fatalf("successful immutable directory syncs = %d, want 1", syncs)
3436 }
3537 }
38+
39+func TestWithAtomicTempPreservesLifecycleOrder(t *testing.T) {
40+ repo := NewRepository(privateDir(t))
41+ dir := filepath.Join(repo.dir, "temporary")
42+ if err := repo.ensurePrivateDirectory(dir); err != nil {
43+ t.Fatal(err)
44+ }
45+
46+ var events []string
47+ repo.hooks.createTemp = func(string) error { events = append(events, "create"); return nil }
48+ repo.hooks.writeTemp = func(string) error { events = append(events, "write"); return nil }
49+ repo.hooks.syncFile = func(string) error { events = append(events, "sync file"); return nil }
50+ repo.hooks.closeFile = func(string) error { events = append(events, "close"); return nil }
51+ repo.hooks.remove = func(string) error { events = append(events, "remove"); return nil }
52+ repo.hooks.syncDirectory = func(string) error { events = append(events, "sync directory"); return nil }
53+
54+ if err := repo.withAtomicTemp(dir, []byte("data"), func(path string) (bool, error) {
55+ events = append(events, "publish")
56+ info, err := os.Stat(path)
57+ if err != nil {
58+ return true, err
59+ }
60+ if got := info.Mode().Perm(); got != 0o600 {
61+ t.Fatalf("temporary file mode = %o, want 600", got)
62+ }
63+ return true, nil
64+ }); err != nil {
65+ t.Fatal(err)
66+ }
67+ want := []string{"create", "write", "sync file", "close", "publish", "remove", "sync directory"}
68+ if len(events) != len(want) {
69+ t.Fatalf("lifecycle = %v, want %v", events, want)
70+ }
71+ for i := range want {
72+ if events[i] != want[i] {
73+ t.Fatalf("lifecycle = %v, want %v", events, want)
74+ }
75+ }
76+}
77+
78+func TestWriteMutableDoesNotRemoveConsumedTemporaryFile(t *testing.T) {
79+ repo := NewRepository(privateDir(t))
80+ path := filepath.Join(repo.dir, "HEAD")
81+ repo.hooks.remove = func(string) error { return errors.New("remove must not run") }
82+
83+ if err := repo.writeMutable(path, []byte("head")); err != nil {
84+ t.Fatalf("writeMutable error = %v", err)
85+ }
86+ got, err := os.ReadFile(path)
87+ if err != nil {
88+ t.Fatal(err)
89+ }
90+ if !bytes.Equal(got, []byte("head")) {
91+ t.Fatalf("HEAD = %q, want %q", got, "head")
92+ }
93+}
@@ -1,7 +1,9 @@
1 package snapshot1 package snapshot
2 2
3 import (3 import (
4+ "bytes"
4 "errors"5 "errors"
6+ "os"
5 "path/filepath"7 "path/filepath"
6 "testing"8 "testing"
7 )9 )
@@ -22,7 +24,7 @@ func TestWriteImmutableUsesOneFinalDirectorySync(t *testing.T) {
22 }24 }
23 data := []byte("immutable")25 data := []byte("immutable")
24 if err := repo.writeImmutable(filepath.Join(dir, "object"), data, func(existing []byte) error {26 if err := repo.writeImmutable(filepath.Join(dir, "object"), data, func(existing []byte) error {
25- if !equalBytes(existing, data) {27+ if !bytes.Equal(existing, data) {
26 return errors.New("unexpected immutable data")28 return errors.New("unexpected immutable data")
27 }29 }
28 return nil30 return nil
@@ -33,3 +35,59 @@ func TestWriteImmutableUsesOneFinalDirectorySync(t *testing.T) {
33 t.Fatalf("successful immutable directory syncs = %d, want 1", syncs)35 t.Fatalf("successful immutable directory syncs = %d, want 1", syncs)
34 }36 }
35 }37 }
38+
39+func TestWithAtomicTempPreservesLifecycleOrder(t *testing.T) {
40+ repo := NewRepository(privateDir(t))
41+ dir := filepath.Join(repo.dir, "temporary")
42+ if err := repo.ensurePrivateDirectory(dir); err != nil {
43+ t.Fatal(err)
44+ }
45+
46+ var events []string
47+ repo.hooks.createTemp = func(string) error { events = append(events, "create"); return nil }
48+ repo.hooks.writeTemp = func(string) error { events = append(events, "write"); return nil }
49+ repo.hooks.syncFile = func(string) error { events = append(events, "sync file"); return nil }
50+ repo.hooks.closeFile = func(string) error { events = append(events, "close"); return nil }
51+ repo.hooks.remove = func(string) error { events = append(events, "remove"); return nil }
52+ repo.hooks.syncDirectory = func(string) error { events = append(events, "sync directory"); return nil }
53+
54+ if err := repo.withAtomicTemp(dir, []byte("data"), func(path string) (bool, error) {
55+ events = append(events, "publish")
56+ info, err := os.Stat(path)
57+ if err != nil {
58+ return true, err
59+ }
60+ if got := info.Mode().Perm(); got != 0o600 {
61+ t.Fatalf("temporary file mode = %o, want 600", got)
62+ }
63+ return true, nil
64+ }); err != nil {
65+ t.Fatal(err)
66+ }
67+ want := []string{"create", "write", "sync file", "close", "publish", "remove", "sync directory"}
68+ if len(events) != len(want) {
69+ t.Fatalf("lifecycle = %v, want %v", events, want)
70+ }
71+ for i := range want {
72+ if events[i] != want[i] {
73+ t.Fatalf("lifecycle = %v, want %v", events, want)
74+ }
75+ }
76+}
77+
78+func TestWriteMutableDoesNotRemoveConsumedTemporaryFile(t *testing.T) {
79+ repo := NewRepository(privateDir(t))
80+ path := filepath.Join(repo.dir, "HEAD")
81+ repo.hooks.remove = func(string) error { return errors.New("remove must not run") }
82+
83+ if err := repo.writeMutable(path, []byte("head")); err != nil {
84+ t.Fatalf("writeMutable error = %v", err)
85+ }
86+ got, err := os.ReadFile(path)
87+ if err != nil {
88+ t.Fatal(err)
89+ }
90+ if !bytes.Equal(got, []byte("head")) {
91+ t.Fatalf("HEAD = %q, want %q", got, "head")
92+ }
93+}
M internal/adapters/snapshot/publish.go +6 -5
@@ -1,6 +1,7 @@
11 package snapshot
22
33 import (
4+ "bytes"
45 "context"
56 "crypto/sha256"
67 "fmt"
@@ -37,7 +38,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
3738 // map. Checking the immutable bytes directly also keeps its descriptor
3839 // reads beneath the pinned repository root.
3940 if generation, _, err := r.readHead(key); err == nil && generation == publication.Generation {
40- if current, err := r.readBounded(r.manifestPath(key, generation)); err == nil && equalBytes(current, publication.Manifest) {
41+ if current, err := r.readBounded(r.manifestPath(key, generation)); err == nil && bytes.Equal(current, publication.Manifest) {
4142 return nil
4243 }
4344 }
@@ -50,7 +51,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
5051 return fmt.Errorf("snapshot generation %d, current %d: immutable conflict", publication.Generation, current)
5152 }
5253 if publication.Generation == current {
53- if !equalBytes(currentManifest, publication.Manifest) {
54+ if !bytes.Equal(currentManifest, publication.Manifest) {
5455 return fmt.Errorf("snapshot generation %d: immutable conflict", publication.Generation)
5556 }
5657 return nil
@@ -111,7 +112,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
111112 return err
112113 }
113114 if exists {
114- if !equalBytes(existing, publication.Manifest) {
115+ if !bytes.Equal(existing, publication.Manifest) {
115116 return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation)
116117 }
117118 } else {
@@ -124,7 +125,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
124125 return err
125126 }
126127 if err := r.writeImmutable(manifestPath, publication.Manifest, func(existing []byte) error {
127- if !equalBytes(existing, publication.Manifest) {
128+ if !bytes.Equal(existing, publication.Manifest) {
128129 return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation)
129130 }
130131 return nil
@@ -251,7 +252,7 @@ func validateSuppliedObject(objects []ports.SnapshotObject, digest ports.Snapsho
251252 if actual != candidate.Digest || candidate.Digest != digest {
252253 return ports.SnapshotObject{}, fmt.Errorf("object digest mismatch")
253254 }
254- if !equalBytes(candidate.Data, object.Data) {
255+ if !bytes.Equal(candidate.Data, object.Data) {
255256 return ports.SnapshotObject{}, fmt.Errorf("conflicting object")
256257 }
257258 }
@@ -1,6 +1,7 @@
1 package snapshot1 package snapshot
2 2
3 import (3 import (
4+ "bytes"
4 "context"5 "context"
5 "crypto/sha256"6 "crypto/sha256"
6 "fmt"7 "fmt"
@@ -37,7 +38,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
37 // map. Checking the immutable bytes directly also keeps its descriptor38 // map. Checking the immutable bytes directly also keeps its descriptor
38 // reads beneath the pinned repository root.39 // reads beneath the pinned repository root.
39 if generation, _, err := r.readHead(key); err == nil && generation == publication.Generation {40 if generation, _, err := r.readHead(key); err == nil && generation == publication.Generation {
40- if current, err := r.readBounded(r.manifestPath(key, generation)); err == nil && equalBytes(current, publication.Manifest) {41+ if current, err := r.readBounded(r.manifestPath(key, generation)); err == nil && bytes.Equal(current, publication.Manifest) {
41 return nil42 return nil
42 }43 }
43 }44 }
@@ -50,7 +51,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
50 return fmt.Errorf("snapshot generation %d, current %d: immutable conflict", publication.Generation, current)51 return fmt.Errorf("snapshot generation %d, current %d: immutable conflict", publication.Generation, current)
51 }52 }
52 if publication.Generation == current {53 if publication.Generation == current {
53- if !equalBytes(currentManifest, publication.Manifest) {54+ if !bytes.Equal(currentManifest, publication.Manifest) {
54 return fmt.Errorf("snapshot generation %d: immutable conflict", publication.Generation)55 return fmt.Errorf("snapshot generation %d: immutable conflict", publication.Generation)
55 }56 }
56 return nil57 return nil
@@ -111,7 +112,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
111 return err112 return err
112 }113 }
113 if exists {114 if exists {
114- if !equalBytes(existing, publication.Manifest) {115+ if !bytes.Equal(existing, publication.Manifest) {
115 return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation)116 return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation)
116 }117 }
117 } else {118 } else {
@@ -124,7 +125,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
124 return err125 return err
125 }126 }
126 if err := r.writeImmutable(manifestPath, publication.Manifest, func(existing []byte) error {127 if err := r.writeImmutable(manifestPath, publication.Manifest, func(existing []byte) error {
127- if !equalBytes(existing, publication.Manifest) {128+ if !bytes.Equal(existing, publication.Manifest) {
128 return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation)129 return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation)
129 }130 }
130 return nil131 return nil
@@ -251,7 +252,7 @@ func validateSuppliedObject(objects []ports.SnapshotObject, digest ports.Snapsho
251 if actual != candidate.Digest || candidate.Digest != digest {252 if actual != candidate.Digest || candidate.Digest != digest {
252 return ports.SnapshotObject{}, fmt.Errorf("object digest mismatch")253 return ports.SnapshotObject{}, fmt.Errorf("object digest mismatch")
253 }254 }
254- if !equalBytes(candidate.Data, object.Data) {255+ if !bytes.Equal(candidate.Data, object.Data) {
255 return ports.SnapshotObject{}, fmt.Errorf("conflicting object")256 return ports.SnapshotObject{}, fmt.Errorf("conflicting object")
256 }257 }
257 }258 }
M internal/adapters/snapshot/repository_durability_test.go +3 -2
@@ -1,6 +1,7 @@
11 package snapshot
22
33 import (
4+ "bytes"
45 "context"
56 "errors"
67 "os"
@@ -39,7 +40,7 @@ func prepareHeadStage(t *testing.T, repo *Repository, pub ports.SnapshotPublicat
3940 key := sessionKey(pub.Name)
4041 prepareObjects(t, repo, pub)
4142 if err := repo.writeImmutable(repo.manifestPath(key, pub.Generation), pub.Manifest, func(data []byte) error {
42- if !equalBytes(data, pub.Manifest) {
43+ if !bytes.Equal(data, pub.Manifest) {
4344 return errors.New("invalid manifest")
4445 }
4546 return nil
@@ -171,7 +172,7 @@ func prepareObjects(t *testing.T, repo *Repository, pub ports.SnapshotPublicatio
171172 for _, object := range pub.Objects {
172173 ref := manifestReference(t, pub.Manifest, object.Digest)
173174 if err := repo.writeImmutable(repo.objectPath(key, object.Digest), object.Data, func(data []byte) error {
174- if !equalBytes(data, object.Data) || !validObject(data, ref) {
175+ if !bytes.Equal(data, object.Data) || !validObject(data, ref) {
175176 return errors.New("invalid object")
176177 }
177178 return nil
@@ -1,6 +1,7 @@
1 package snapshot1 package snapshot
2 2
3 import (3 import (
4+ "bytes"
4 "context"5 "context"
5 "errors"6 "errors"
6 "os"7 "os"
@@ -39,7 +40,7 @@ func prepareHeadStage(t *testing.T, repo *Repository, pub ports.SnapshotPublicat
39 key := sessionKey(pub.Name)40 key := sessionKey(pub.Name)
40 prepareObjects(t, repo, pub)41 prepareObjects(t, repo, pub)
41 if err := repo.writeImmutable(repo.manifestPath(key, pub.Generation), pub.Manifest, func(data []byte) error {42 if err := repo.writeImmutable(repo.manifestPath(key, pub.Generation), pub.Manifest, func(data []byte) error {
42- if !equalBytes(data, pub.Manifest) {43+ if !bytes.Equal(data, pub.Manifest) {
43 return errors.New("invalid manifest")44 return errors.New("invalid manifest")
44 }45 }
45 return nil46 return nil
@@ -171,7 +172,7 @@ func prepareObjects(t *testing.T, repo *Repository, pub ports.SnapshotPublicatio
171 for _, object := range pub.Objects {172 for _, object := range pub.Objects {
172 ref := manifestReference(t, pub.Manifest, object.Digest)173 ref := manifestReference(t, pub.Manifest, object.Digest)
173 if err := repo.writeImmutable(repo.objectPath(key, object.Digest), object.Data, func(data []byte) error {174 if err := repo.writeImmutable(repo.objectPath(key, object.Digest), object.Data, func(data []byte) error {
174- if !equalBytes(data, object.Data) || !validObject(data, ref) {175+ if !bytes.Equal(data, object.Data) || !validObject(data, ref) {
175 return errors.New("invalid object")176 return errors.New("invalid object")
176 }177 }
177 return nil178 return nil

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