detached 33d8242

test(daemon): align recovery integration fixtures

commit
33d82420ce36c12b7bb9113befe68ba2e58da51f
author
brice · · 2026-07-26 00:11 UTC
parent
2bc840b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +103 -131
M internal/usecase/daemon/legacy_snapshot_test.go +1 -14
@@ -1,21 +1,8 @@
11 package daemon
22
3-import (
4- "bytes"
5- "context"
6- "fmt"
7-
8- "github.com/bnema/vev/internal/ports"
9-)
3+import "context"
104
115 // importLegacySnapshots is retained only for tests covering the retired
126 // pre-migration bridge. Normal runtime legacy decoding lives exclusively in
137 // the snapshot migration adapter.
148 func (d *Daemon) importLegacySnapshots(context.Context) {}
15-
16-func verifyLegacyImportGeneration(generation ports.SnapshotGeneration, publication ports.SnapshotPublication) error {
17- if generation.Name != publication.Name || generation.Generation != publication.Generation || !bytes.Equal(generation.Manifest, publication.Manifest) {
18- return fmt.Errorf("snapshot: legacy import verification mismatch")
19- }
20- return nil
21-}
@@ -1,21 +1,8 @@
1 package daemon1 package daemon
2 2
3-import (3+import "context"
4- "bytes"
5- "context"
6- "fmt"
7-
8- "github.com/bnema/vev/internal/ports"
9-)
10 4
11 // importLegacySnapshots is retained only for tests covering the retired5 // importLegacySnapshots is retained only for tests covering the retired
12 // pre-migration bridge. Normal runtime legacy decoding lives exclusively in6 // pre-migration bridge. Normal runtime legacy decoding lives exclusively in
13 // the snapshot migration adapter.7 // the snapshot migration adapter.
14 func (d *Daemon) importLegacySnapshots(context.Context) {}8 func (d *Daemon) importLegacySnapshots(context.Context) {}
15-
16-func verifyLegacyImportGeneration(generation ports.SnapshotGeneration, publication ports.SnapshotPublication) error {
17- if generation.Name != publication.Name || generation.Generation != publication.Generation || !bytes.Equal(generation.Manifest, publication.Manifest) {
18- return fmt.Errorf("snapshot: legacy import verification mismatch")
19- }
20- return nil
21-}
M internal/usecase/daemon/performance_benchmark_test.go +8 -8
@@ -627,6 +627,12 @@ func publishBenchmarkSnapshot(b testing.TB, fixture *performanceFixture, generat
627627 if err := fixture.snapshots.Publish(context.Background(), publication); err != nil {
628628 b.Fatal(err)
629629 }
630+ checkpoint := domain.CheckpointRef{Generation: generation, ManifestDigest: snapcodec.ManifestDigest(publication.Manifest)}
631+ capture.checkpoint = checkpoint
632+ fixture.sess.snapshotMu.Lock()
633+ fixture.sess.snapshotPublishedGeneration = generation
634+ fixture.sess.snapshotPublishedCheckpoint = &checkpoint
635+ fixture.sess.snapshotMu.Unlock()
630636 markSnapshotCaptureObjectsPublished(capture)
631637 }
632638
@@ -1036,6 +1042,7 @@ func newPerformanceFixtureWithCleanup(t testing.TB, config performanceConfig, re
10361042 ac.size = config.size
10371043 sess.name = "performance"
10381044 sess.ephemeral = false
1045+ sess.incarnation = domain.IncarnationID{1}
10391046 sess.snapEligible.Store(true)
10401047
10411048 fixture := &performanceFixture{
@@ -1376,6 +1383,7 @@ type countingSnapshotMetrics struct {
13761383 writes, objectBytes, historyBlobBytes, suppliedObjectBytes, suppliedHistoryBytes, manifestBytes, headBytes uint64
13771384 }
13781385 type countingSnapshotRepository struct {
1386+ noOpSnapshotRepository
13791387 mu sync.Mutex
13801388 countingSnapshotMetrics
13811389 objects map[string]map[ports.SnapshotDigest]struct{}
@@ -1418,14 +1426,6 @@ func (s *countingSnapshotRepository) Publish(_ context.Context, publication port
14181426 s.last = publication.Manifest
14191427 return nil
14201428 }
1421-func (*countingSnapshotRepository) List(context.Context) ([]string, error) { return nil, nil }
1422-func (*countingSnapshotRepository) Load(context.Context, string) (ports.SnapshotGeneration, error) {
1423- return ports.SnapshotGeneration{}, nil
1424-}
1425-func (*countingSnapshotRepository) Delete(context.Context, string) error { return nil }
1426-func (*countingSnapshotRepository) Tombstone(context.Context, string) error { return nil }
1427-func (*countingSnapshotRepository) DeleteTombstone(context.Context, string) error { return nil }
1428-func (*countingSnapshotRepository) Maintain(context.Context) error { return nil }
14291429 func (s *countingSnapshotRepository) reset() {
14301430 s.mu.Lock()
14311431 s.countingSnapshotMetrics = countingSnapshotMetrics{}
@@ -627,6 +627,12 @@ func publishBenchmarkSnapshot(b testing.TB, fixture *performanceFixture, generat
627 if err := fixture.snapshots.Publish(context.Background(), publication); err != nil {627 if err := fixture.snapshots.Publish(context.Background(), publication); err != nil {
628 b.Fatal(err)628 b.Fatal(err)
629 }629 }
630+ checkpoint := domain.CheckpointRef{Generation: generation, ManifestDigest: snapcodec.ManifestDigest(publication.Manifest)}
631+ capture.checkpoint = checkpoint
632+ fixture.sess.snapshotMu.Lock()
633+ fixture.sess.snapshotPublishedGeneration = generation
634+ fixture.sess.snapshotPublishedCheckpoint = &checkpoint
635+ fixture.sess.snapshotMu.Unlock()
630 markSnapshotCaptureObjectsPublished(capture)636 markSnapshotCaptureObjectsPublished(capture)
631 }637 }
632 638
@@ -1036,6 +1042,7 @@ func newPerformanceFixtureWithCleanup(t testing.TB, config performanceConfig, re
1036 ac.size = config.size1042 ac.size = config.size
1037 sess.name = "performance"1043 sess.name = "performance"
1038 sess.ephemeral = false1044 sess.ephemeral = false
1045+ sess.incarnation = domain.IncarnationID{1}
1039 sess.snapEligible.Store(true)1046 sess.snapEligible.Store(true)
1040 1047
1041 fixture := &performanceFixture{1048 fixture := &performanceFixture{
@@ -1376,6 +1383,7 @@ type countingSnapshotMetrics struct {
1376 writes, objectBytes, historyBlobBytes, suppliedObjectBytes, suppliedHistoryBytes, manifestBytes, headBytes uint641383 writes, objectBytes, historyBlobBytes, suppliedObjectBytes, suppliedHistoryBytes, manifestBytes, headBytes uint64
1377 }1384 }
1378 type countingSnapshotRepository struct {1385 type countingSnapshotRepository struct {
1386+ noOpSnapshotRepository
1379 mu sync.Mutex1387 mu sync.Mutex
1380 countingSnapshotMetrics1388 countingSnapshotMetrics
1381 objects map[string]map[ports.SnapshotDigest]struct{}1389 objects map[string]map[ports.SnapshotDigest]struct{}
@@ -1418,14 +1426,6 @@ func (s *countingSnapshotRepository) Publish(_ context.Context, publication port
1418 s.last = publication.Manifest1426 s.last = publication.Manifest
1419 return nil1427 return nil
1420 }1428 }
1421-func (*countingSnapshotRepository) List(context.Context) ([]string, error) { return nil, nil }
1422-func (*countingSnapshotRepository) Load(context.Context, string) (ports.SnapshotGeneration, error) {
1423- return ports.SnapshotGeneration{}, nil
1424-}
1425-func (*countingSnapshotRepository) Delete(context.Context, string) error { return nil }
1426-func (*countingSnapshotRepository) Tombstone(context.Context, string) error { return nil }
1427-func (*countingSnapshotRepository) DeleteTombstone(context.Context, string) error { return nil }
1428-func (*countingSnapshotRepository) Maintain(context.Context) error { return nil }
1429 func (s *countingSnapshotRepository) reset() {1429 func (s *countingSnapshotRepository) reset() {
1430 s.mu.Lock()1430 s.mu.Lock()
1431 s.countingSnapshotMetrics = countingSnapshotMetrics{}1431 s.countingSnapshotMetrics = countingSnapshotMetrics{}
M internal/usecase/daemon/picker_test.go +6 -10
@@ -45,21 +45,17 @@ func (failingDeleteStore) Range(func(k, v []byte) bool) {}
4545 func (failingDeleteStore) Sync() error { return nil }
4646 func (failingDeleteStore) Close() error { return nil }
4747
48-type refusingSnapshotDeleteRepository struct{ err error }
48+type refusingSnapshotDeleteRepository struct {
49+ noOpSnapshotRepository
50+ err error
51+}
4952
5053 func (refusingSnapshotDeleteRepository) Publish(context.Context, ports.SnapshotPublication) error {
5154 return nil
5255 }
53-func (refusingSnapshotDeleteRepository) List(context.Context) ([]string, error) { return nil, nil }
54-func (refusingSnapshotDeleteRepository) Load(context.Context, string) (ports.SnapshotGeneration, error) {
55- return ports.SnapshotGeneration{}, nil
56-}
57-func (s refusingSnapshotDeleteRepository) Delete(context.Context, string) error { return s.err }
58-func (refusingSnapshotDeleteRepository) Tombstone(context.Context, string) error { return nil }
59-func (refusingSnapshotDeleteRepository) DeleteTombstone(context.Context, string) error {
60- return nil
56+func (s refusingSnapshotDeleteRepository) QuarantineDeletionSources(context.Context, domain.DeletionTombstone, bool) error {
57+ return s.err
6158 }
62-func (refusingSnapshotDeleteRepository) Maintain(context.Context) error { return nil }
6359
6460 func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab {
6561 tb := newTab(p, domain.Size{Cols: 80, Rows: 23})
@@ -45,21 +45,17 @@ func (failingDeleteStore) Range(func(k, v []byte) bool) {}
45 func (failingDeleteStore) Sync() error { return nil }45 func (failingDeleteStore) Sync() error { return nil }
46 func (failingDeleteStore) Close() error { return nil }46 func (failingDeleteStore) Close() error { return nil }
47 47
48-type refusingSnapshotDeleteRepository struct{ err error }48+type refusingSnapshotDeleteRepository struct {
49+ noOpSnapshotRepository
50+ err error
51+}
49 52
50 func (refusingSnapshotDeleteRepository) Publish(context.Context, ports.SnapshotPublication) error {53 func (refusingSnapshotDeleteRepository) Publish(context.Context, ports.SnapshotPublication) error {
51 return nil54 return nil
52 }55 }
53-func (refusingSnapshotDeleteRepository) List(context.Context) ([]string, error) { return nil, nil }56+func (s refusingSnapshotDeleteRepository) QuarantineDeletionSources(context.Context, domain.DeletionTombstone, bool) error {
54-func (refusingSnapshotDeleteRepository) Load(context.Context, string) (ports.SnapshotGeneration, error) {57+ return s.err
55- return ports.SnapshotGeneration{}, nil
56-}
57-func (s refusingSnapshotDeleteRepository) Delete(context.Context, string) error { return s.err }
58-func (refusingSnapshotDeleteRepository) Tombstone(context.Context, string) error { return nil }
59-func (refusingSnapshotDeleteRepository) DeleteTombstone(context.Context, string) error {
60- return nil
61 }58 }
62-func (refusingSnapshotDeleteRepository) Maintain(context.Context) error { return nil }
63 59
64 func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab {60 func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab {
65 tb := newTab(p, domain.Size{Cols: 80, Rows: 23})61 tb := newTab(p, domain.Size{Cols: 80, Rows: 23})
M internal/usecase/daemon/session_purge_retry_test.go +21 -23
@@ -13,6 +13,7 @@ import (
1313 )
1414
1515 type retryablePurgeRepository struct {
16+ noOpSnapshotRepository
1617 incrementalErr error
1718 legacyErr error
1819 tombstoned map[string]bool
@@ -22,38 +23,35 @@ type retryablePurgeRepository struct {
2223 func (r *retryablePurgeRepository) Publish(context.Context, ports.SnapshotPublication) error {
2324 return nil
2425 }
25-func (r *retryablePurgeRepository) List(context.Context) ([]string, error) { return nil, nil }
26-func (r *retryablePurgeRepository) Load(context.Context, string) (ports.SnapshotGeneration, error) {
27- return ports.SnapshotGeneration{}, errors.New("unused")
28-}
29-func (r *retryablePurgeRepository) Maintain(context.Context) error { return nil }
30-func (r *retryablePurgeRepository) Delete(_ context.Context, name string) error {
31- r.calls = append(r.calls, "incremental")
32- return r.incrementalErr
33-}
34-func (r *retryablePurgeRepository) LoadLegacy(context.Context) ([]ports.LegacySnapshot, error) {
35- return nil, nil
36-}
37-func (r *retryablePurgeRepository) DeleteVerifiedLegacy(ctx context.Context, blob ports.LegacySnapshot) error {
38- return r.DeleteLegacy(ctx, blob.Name)
39-}
40-func (r *retryablePurgeRepository) DeleteLegacy(_ context.Context, name string) error {
41- r.calls = append(r.calls, "legacy")
42- return r.legacyErr
43-}
44-func (r *retryablePurgeRepository) Tombstone(_ context.Context, name string) error {
26+func (r *retryablePurgeRepository) WriteDeletionTombstone(_ context.Context, tombstone domain.DeletionTombstone) error {
4527 if r.tombstoned == nil {
4628 r.tombstoned = make(map[string]bool)
4729 }
4830 r.calls = append(r.calls, "tombstone")
49- r.tombstoned[name] = true
31+ r.tombstoned[tombstone.Name] = true
5032 return nil
5133 }
52-func (r *retryablePurgeRepository) DeleteTombstone(_ context.Context, name string) error {
34+func (r *retryablePurgeRepository) QuarantineDeletionSources(_ context.Context, tombstone domain.DeletionTombstone, _ bool) error {
35+ r.calls = append(r.calls, "incremental", "legacy")
36+ if r.incrementalErr != nil {
37+ return r.incrementalErr
38+ }
39+ return r.legacyErr
40+}
41+func (r *retryablePurgeRepository) DeleteDeletionTombstone(_ context.Context, _ domain.IncarnationID) error {
5342 r.calls = append(r.calls, "clear tombstone")
54- delete(r.tombstoned, name)
43+ for name := range r.tombstoned {
44+ delete(r.tombstoned, name)
45+ }
46+ return nil
47+}
48+func (*retryablePurgeRepository) LoadLegacy(context.Context) ([]ports.LegacySnapshot, error) {
49+ return nil, nil
50+}
51+func (*retryablePurgeRepository) DeleteVerifiedLegacy(context.Context, ports.LegacySnapshot) error {
5552 return nil
5653 }
54+func (*retryablePurgeRepository) DeleteLegacy(context.Context, string) error { return nil }
5755
5856 func TestLivePurgeRetainsTombstoneAcrossPartialSourceDeletion(t *testing.T) {
5957 for _, tt := range []struct {
@@ -13,6 +13,7 @@ import (
13 )13 )
14 14
15 type retryablePurgeRepository struct {15 type retryablePurgeRepository struct {
16+ noOpSnapshotRepository
16 incrementalErr error17 incrementalErr error
17 legacyErr error18 legacyErr error
18 tombstoned map[string]bool19 tombstoned map[string]bool
@@ -22,38 +23,35 @@ type retryablePurgeRepository struct {
22 func (r *retryablePurgeRepository) Publish(context.Context, ports.SnapshotPublication) error {23 func (r *retryablePurgeRepository) Publish(context.Context, ports.SnapshotPublication) error {
23 return nil24 return nil
24 }25 }
25-func (r *retryablePurgeRepository) List(context.Context) ([]string, error) { return nil, nil }26+func (r *retryablePurgeRepository) WriteDeletionTombstone(_ context.Context, tombstone domain.DeletionTombstone) error {
26-func (r *retryablePurgeRepository) Load(context.Context, string) (ports.SnapshotGeneration, error) {
27- return ports.SnapshotGeneration{}, errors.New("unused")
28-}
29-func (r *retryablePurgeRepository) Maintain(context.Context) error { return nil }
30-func (r *retryablePurgeRepository) Delete(_ context.Context, name string) error {
31- r.calls = append(r.calls, "incremental")
32- return r.incrementalErr
33-}
34-func (r *retryablePurgeRepository) LoadLegacy(context.Context) ([]ports.LegacySnapshot, error) {
35- return nil, nil
36-}
37-func (r *retryablePurgeRepository) DeleteVerifiedLegacy(ctx context.Context, blob ports.LegacySnapshot) error {
38- return r.DeleteLegacy(ctx, blob.Name)
39-}
40-func (r *retryablePurgeRepository) DeleteLegacy(_ context.Context, name string) error {
41- r.calls = append(r.calls, "legacy")
42- return r.legacyErr
43-}
44-func (r *retryablePurgeRepository) Tombstone(_ context.Context, name string) error {
45 if r.tombstoned == nil {27 if r.tombstoned == nil {
46 r.tombstoned = make(map[string]bool)28 r.tombstoned = make(map[string]bool)
47 }29 }
48 r.calls = append(r.calls, "tombstone")30 r.calls = append(r.calls, "tombstone")
49- r.tombstoned[name] = true31+ r.tombstoned[tombstone.Name] = true
50 return nil32 return nil
51 }33 }
52-func (r *retryablePurgeRepository) DeleteTombstone(_ context.Context, name string) error {34+func (r *retryablePurgeRepository) QuarantineDeletionSources(_ context.Context, tombstone domain.DeletionTombstone, _ bool) error {
35+ r.calls = append(r.calls, "incremental", "legacy")
36+ if r.incrementalErr != nil {
37+ return r.incrementalErr
38+ }
39+ return r.legacyErr
40+}
41+func (r *retryablePurgeRepository) DeleteDeletionTombstone(_ context.Context, _ domain.IncarnationID) error {
53 r.calls = append(r.calls, "clear tombstone")42 r.calls = append(r.calls, "clear tombstone")
54- delete(r.tombstoned, name)43+ for name := range r.tombstoned {
44+ delete(r.tombstoned, name)
45+ }
46+ return nil
47+}
48+func (*retryablePurgeRepository) LoadLegacy(context.Context) ([]ports.LegacySnapshot, error) {
49+ return nil, nil
50+}
51+func (*retryablePurgeRepository) DeleteVerifiedLegacy(context.Context, ports.LegacySnapshot) error {
55 return nil52 return nil
56 }53 }
54+func (*retryablePurgeRepository) DeleteLegacy(context.Context, string) error { return nil }
57 55
58 func TestLivePurgeRetainsTombstoneAcrossPartialSourceDeletion(t *testing.T) {56 func TestLivePurgeRetainsTombstoneAcrossPartialSourceDeletion(t *testing.T) {
59 for _, tt := range []struct {57 for _, tt := range []struct {
M internal/usecase/daemon/snapshot_import_test.go +6 -58
@@ -101,36 +101,20 @@ func TestLegacyPublicationMarshalObjectErrorsIdentifyPaneAndStage(t *testing.T)
101101 }
102102 }
103103
104-func TestImportLegacyPublishesCompleteGenerationBeforeDeletingSource(t *testing.T) {
104+func TestImportLegacySnapshotsIsRetiredRuntimeNoop(t *testing.T) {
105105 snapshot := legacyAcceptanceSnapshot(t)
106106 repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)}
107107 legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}}
108108 d := newTestDaemon(t, nil, stubClock{})
109109 WithSnapshotRepository(repository, legacy)(d)
110- expected, verifyErr := legacyPublication(snapshot)
111- require.NoError(t, verifyErr)
112- _, verifyErr = snapcodec.Unmarshal(legacy.blobs[0].Data)
113- require.NoError(t, verifyErr)
114- require.NoError(t, verifyLegacyImportGeneration(acceptanceGeneration(t, snapshot, 1), expected))
115110
116111 d.importLegacySnapshots(context.Background())
117112
118- require.Len(t, repository.publishes, 1)
119- publication := repository.publishes[0]
120- require.Equal(t, snapshot.Name, publication.Name)
121- require.EqualValues(t, 1, publication.Generation)
122- manifest, err := snapcodec.UnmarshalManifest(publication.Manifest)
123- require.NoError(t, err)
124- require.Equal(t, uint64(1), manifest.Generation)
125- require.Empty(t, manifest.Tabs)
126- for _, object := range publication.Objects {
127- kind, _, err := snapcodec.UnmarshalObject(object.Data)
128- require.NoError(t, err)
129- require.Contains(t, []snapcodec.ObjectKind{snapcodec.HistoryChunk, snapcodec.HistoryTail, snapcodec.Visible}, kind)
130- require.Equal(t, object.Digest, snapcodec.ManifestDigest(object.Data))
131- }
132- require.Equal(t, []string{snapshot.Name}, legacy.deleteCalls)
133- require.Empty(t, legacy.blobs)
113+ // Migration is adapter-owned. The daemon must never name-scan, publish, or
114+ // delete a legacy source during normal recovery.
115+ require.Empty(t, repository.publishes)
116+ require.Empty(t, legacy.deleteCalls)
117+ require.Len(t, legacy.blobs, 1)
134118 }
135119
136120 func TestImportLegacyExistingIncrementalSkipsLegacy(t *testing.T) {
@@ -177,39 +161,3 @@ func TestImportLegacyRetainsSourceUntilExactReloadVerification(t *testing.T) {
177161 })
178162 }
179163 }
180-
181-func TestImportLegacyDeleteFailureRetriesWithoutRepublishing(t *testing.T) {
182- snapshot := legacyAcceptanceSnapshot(t)
183- repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)}
184- legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}, deleteErr: errors.New("sync failed")}
185- d := newTestDaemon(t, nil, stubClock{})
186- WithSnapshotRepository(repository, legacy)(d)
187-
188- d.importLegacySnapshots(context.Background())
189- legacy.deleteErr = nil
190- d.importLegacySnapshots(context.Background())
191-
192- require.Len(t, repository.publishes, 1, "a retry must only retry source deletion")
193- require.Equal(t, []string{snapshot.Name, snapshot.Name}, legacy.deleteCalls)
194- require.Empty(t, legacy.blobs)
195-}
196-
197-func TestImportLegacyCancellationAndPerSessionContinuation(t *testing.T) {
198- bad := legacyAcceptanceSnapshot(t)
199- good := legacyAcceptanceSnapshot(t)
200- badBlob := legacyAcceptanceBlob(t, bad)
201- badBlob.Data = []byte("not-a-v3-snapshot")
202- repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)}
203- legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{badBlob, legacyAcceptanceBlob(t, good)}}
204- d := newTestDaemon(t, nil, stubClock{})
205- WithSnapshotRepository(repository, legacy)(d)
206-
207- d.importLegacySnapshots(context.Background())
208- require.Len(t, repository.publishes, 1, "a corrupt session must not block later sessions")
209- require.Equal(t, []string{good.Name}, legacy.deleteCalls)
210-
211- cancelled, cancel := context.WithCancel(context.Background())
212- cancel()
213- d.importLegacySnapshots(cancelled)
214- require.Equal(t, 1, legacy.loadCalls, "a canceled import must not touch the legacy source")
215-}
@@ -101,36 +101,20 @@ func TestLegacyPublicationMarshalObjectErrorsIdentifyPaneAndStage(t *testing.T)
101 }101 }
102 }102 }
103 103
104-func TestImportLegacyPublishesCompleteGenerationBeforeDeletingSource(t *testing.T) {104+func TestImportLegacySnapshotsIsRetiredRuntimeNoop(t *testing.T) {
105 snapshot := legacyAcceptanceSnapshot(t)105 snapshot := legacyAcceptanceSnapshot(t)
106 repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)}106 repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)}
107 legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}}107 legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}}
108 d := newTestDaemon(t, nil, stubClock{})108 d := newTestDaemon(t, nil, stubClock{})
109 WithSnapshotRepository(repository, legacy)(d)109 WithSnapshotRepository(repository, legacy)(d)
110- expected, verifyErr := legacyPublication(snapshot)
111- require.NoError(t, verifyErr)
112- _, verifyErr = snapcodec.Unmarshal(legacy.blobs[0].Data)
113- require.NoError(t, verifyErr)
114- require.NoError(t, verifyLegacyImportGeneration(acceptanceGeneration(t, snapshot, 1), expected))
115 110
116 d.importLegacySnapshots(context.Background())111 d.importLegacySnapshots(context.Background())
117 112
118- require.Len(t, repository.publishes, 1)113+ // Migration is adapter-owned. The daemon must never name-scan, publish, or
119- publication := repository.publishes[0]114+ // delete a legacy source during normal recovery.
120- require.Equal(t, snapshot.Name, publication.Name)115+ require.Empty(t, repository.publishes)
121- require.EqualValues(t, 1, publication.Generation)116+ require.Empty(t, legacy.deleteCalls)
122- manifest, err := snapcodec.UnmarshalManifest(publication.Manifest)117+ require.Len(t, legacy.blobs, 1)
123- require.NoError(t, err)
124- require.Equal(t, uint64(1), manifest.Generation)
125- require.Empty(t, manifest.Tabs)
126- for _, object := range publication.Objects {
127- kind, _, err := snapcodec.UnmarshalObject(object.Data)
128- require.NoError(t, err)
129- require.Contains(t, []snapcodec.ObjectKind{snapcodec.HistoryChunk, snapcodec.HistoryTail, snapcodec.Visible}, kind)
130- require.Equal(t, object.Digest, snapcodec.ManifestDigest(object.Data))
131- }
132- require.Equal(t, []string{snapshot.Name}, legacy.deleteCalls)
133- require.Empty(t, legacy.blobs)
134 }118 }
135 119
136 func TestImportLegacyExistingIncrementalSkipsLegacy(t *testing.T) {120 func TestImportLegacyExistingIncrementalSkipsLegacy(t *testing.T) {
@@ -177,39 +161,3 @@ func TestImportLegacyRetainsSourceUntilExactReloadVerification(t *testing.T) {
177 })161 })
178 }162 }
179 }163 }
180-
181-func TestImportLegacyDeleteFailureRetriesWithoutRepublishing(t *testing.T) {
182- snapshot := legacyAcceptanceSnapshot(t)
183- repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)}
184- legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}, deleteErr: errors.New("sync failed")}
185- d := newTestDaemon(t, nil, stubClock{})
186- WithSnapshotRepository(repository, legacy)(d)
187-
188- d.importLegacySnapshots(context.Background())
189- legacy.deleteErr = nil
190- d.importLegacySnapshots(context.Background())
191-
192- require.Len(t, repository.publishes, 1, "a retry must only retry source deletion")
193- require.Equal(t, []string{snapshot.Name, snapshot.Name}, legacy.deleteCalls)
194- require.Empty(t, legacy.blobs)
195-}
196-
197-func TestImportLegacyCancellationAndPerSessionContinuation(t *testing.T) {
198- bad := legacyAcceptanceSnapshot(t)
199- good := legacyAcceptanceSnapshot(t)
200- badBlob := legacyAcceptanceBlob(t, bad)
201- badBlob.Data = []byte("not-a-v3-snapshot")
202- repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)}
203- legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{badBlob, legacyAcceptanceBlob(t, good)}}
204- d := newTestDaemon(t, nil, stubClock{})
205- WithSnapshotRepository(repository, legacy)(d)
206-
207- d.importLegacySnapshots(context.Background())
208- require.Len(t, repository.publishes, 1, "a corrupt session must not block later sessions")
209- require.Equal(t, []string{good.Name}, legacy.deleteCalls)
210-
211- cancelled, cancel := context.WithCancel(context.Background())
212- cancel()
213- d.importLegacySnapshots(cancelled)
214- require.Equal(t, 1, legacy.loadCalls, "a canceled import must not touch the legacy source")
215-}
M internal/usecase/daemon/snapshot_legacy_import.go +6 -2
@@ -3,12 +3,16 @@ package daemon
33 import (
44 "fmt"
55
6+ "github.com/bnema/vev/internal/domain"
67 "github.com/bnema/vev/internal/ports"
78 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
89 )
910
1011 func legacyPublication(snapshot snapcodec.Session) (ports.SnapshotPublication, error) {
11- manifest := snapcodec.Manifest{Generation: 1, Name: snapshot.Name, CreatedAt: snapshot.CreatedAt, Active: snapshot.Active, Tabs: make([]snapcodec.ManifestTab, 0, len(snapshot.Tabs))}
12+ // Legacy payloads have no durable identity; this test-only conversion uses
13+ // the fixture's stable synthetic identity.
14+ incarnation := domain.IncarnationID{1}
15+ manifest := snapcodec.Manifest{Generation: 1, IncarnationID: incarnation, Name: snapshot.Name, CreatedAt: snapshot.CreatedAt, Active: snapshot.Active, Tabs: make([]snapcodec.ManifestTab, 0, len(snapshot.Tabs))}
1216 objects := make([]ports.SnapshotObject, 0)
1317 for _, tab := range snapshot.Tabs {
1418 outTab := snapcodec.ManifestTab{StableID: tab.StableID, Cols: tab.Cols, Rows: tab.Rows, NextPaneID: tab.NextPaneID, Focus: tab.Focus, Tree: tab.Tree, Panes: make([]snapcodec.ManifestPane, 0, len(tab.Panes))}
@@ -40,5 +44,5 @@ func legacyPublication(snapshot snapcodec.Session) (ports.SnapshotPublication, e
4044 if err != nil {
4145 return ports.SnapshotPublication{}, fmt.Errorf("snapshot: marshal legacy import manifest: %w", err)
4246 }
43- return ports.SnapshotPublication{Name: snapshot.Name, Generation: 1, Manifest: encoded, Objects: objects}, nil
47+ return ports.SnapshotPublication{IncarnationID: incarnation, Name: snapshot.Name, Generation: 1, Manifest: encoded, Objects: objects}, nil
4448 }
@@ -3,12 +3,16 @@ package daemon
3 import (3 import (
4 "fmt"4 "fmt"
5 5
6+ "github.com/bnema/vev/internal/domain"
6 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
7 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"8 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
8 )9 )
9 10
10 func legacyPublication(snapshot snapcodec.Session) (ports.SnapshotPublication, error) {11 func legacyPublication(snapshot snapcodec.Session) (ports.SnapshotPublication, error) {
11- manifest := snapcodec.Manifest{Generation: 1, Name: snapshot.Name, CreatedAt: snapshot.CreatedAt, Active: snapshot.Active, Tabs: make([]snapcodec.ManifestTab, 0, len(snapshot.Tabs))}12+ // Legacy payloads have no durable identity; this test-only conversion uses
13+ // the fixture's stable synthetic identity.
14+ incarnation := domain.IncarnationID{1}
15+ manifest := snapcodec.Manifest{Generation: 1, IncarnationID: incarnation, Name: snapshot.Name, CreatedAt: snapshot.CreatedAt, Active: snapshot.Active, Tabs: make([]snapcodec.ManifestTab, 0, len(snapshot.Tabs))}
12 objects := make([]ports.SnapshotObject, 0)16 objects := make([]ports.SnapshotObject, 0)
13 for _, tab := range snapshot.Tabs {17 for _, tab := range snapshot.Tabs {
14 outTab := snapcodec.ManifestTab{StableID: tab.StableID, Cols: tab.Cols, Rows: tab.Rows, NextPaneID: tab.NextPaneID, Focus: tab.Focus, Tree: tab.Tree, Panes: make([]snapcodec.ManifestPane, 0, len(tab.Panes))}18 outTab := snapcodec.ManifestTab{StableID: tab.StableID, Cols: tab.Cols, Rows: tab.Rows, NextPaneID: tab.NextPaneID, Focus: tab.Focus, Tree: tab.Tree, Panes: make([]snapcodec.ManifestPane, 0, len(tab.Panes))}
@@ -40,5 +44,5 @@ func legacyPublication(snapshot snapcodec.Session) (ports.SnapshotPublication, e
40 if err != nil {44 if err != nil {
41 return ports.SnapshotPublication{}, fmt.Errorf("snapshot: marshal legacy import manifest: %w", err)45 return ports.SnapshotPublication{}, fmt.Errorf("snapshot: marshal legacy import manifest: %w", err)
42 }46 }
43- return ports.SnapshotPublication{Name: snapshot.Name, Generation: 1, Manifest: encoded, Objects: objects}, nil47+ return ports.SnapshotPublication{IncarnationID: incarnation, Name: snapshot.Name, Generation: 1, Manifest: encoded, Objects: objects}, nil
44 }48 }
M internal/usecase/daemon/snapshot_restore_test.go +17 -16
@@ -18,6 +18,7 @@ import (
1818 )
1919
2020 type snapshotAcceptanceRepository struct {
21+ noOpSnapshotRepository
2122 mu sync.Mutex
2223 names []string
2324 generations map[string]ports.SnapshotGeneration
@@ -42,7 +43,7 @@ func (r *snapshotAcceptanceRepository) Publish(ctx context.Context, p ports.Snap
4243 for _, object := range p.Objects {
4344 objects[object.Digest] = append([]byte(nil), object.Data...)
4445 }
45- r.generations[p.Name] = ports.SnapshotGeneration{Name: p.Name, Generation: p.Generation, Manifest: append([]byte(nil), p.Manifest...), Objects: objects}
46+ r.generations[p.Name] = ports.SnapshotGeneration{IncarnationID: p.IncarnationID, Name: p.Name, Generation: p.Generation, ParentCheckpoint: p.ParentCheckpoint, Manifest: append([]byte(nil), p.Manifest...), Objects: objects}
4647 found := false
4748 for _, name := range r.names {
4849 found = found || name == p.Name
@@ -85,10 +86,16 @@ func (r *snapshotAcceptanceRepository) Load(ctx context.Context, name string) (p
8586 return cloneAcceptanceGeneration(generation), nil
8687 }
8788
88-func (*snapshotAcceptanceRepository) Delete(context.Context, string) error { return nil }
89-func (*snapshotAcceptanceRepository) Tombstone(context.Context, string) error { return nil }
90-func (*snapshotAcceptanceRepository) DeleteTombstone(context.Context, string) error { return nil }
91-func (*snapshotAcceptanceRepository) Maintain(context.Context) error { return nil }
89+func (r *snapshotAcceptanceRepository) LoadCheckpoint(ctx context.Context, id domain.IncarnationID, name string, ref ports.CheckpointRef) (ports.SnapshotGeneration, error) {
90+ generation, err := r.Load(ctx, name)
91+ if err != nil {
92+ return ports.SnapshotGeneration{}, err
93+ }
94+ if generation.IncarnationID != id || generation.Generation != ref.Generation || snapcodec.ManifestDigest(generation.Manifest) != ref.ManifestDigest {
95+ return ports.SnapshotGeneration{}, errors.New("checkpoint unavailable")
96+ }
97+ return generation, nil
98+}
9299
93100 func TestSnapshotAcceptanceRepositoryLoadMutationDoesNotChangeStoredGeneration(t *testing.T) {
94101 digest := ports.SnapshotDigest{1}
@@ -178,7 +185,7 @@ func acceptanceGeneration(t *testing.T, snapshot snapcodec.Session, generation u
178185 for _, object := range publication.Objects {
179186 objects[object.Digest] = append([]byte(nil), object.Data...)
180187 }
181- return ports.SnapshotGeneration{Name: snapshot.Name, Generation: generation, Manifest: encoded, Objects: objects}
188+ return ports.SnapshotGeneration{IncarnationID: publication.IncarnationID, Name: snapshot.Name, Generation: generation, ParentCheckpoint: publication.ParentCheckpoint, Manifest: encoded, Objects: objects}
182189 }
183190
184191 func TestValidateRestoreSessionSnapshot(t *testing.T) {
@@ -212,6 +219,10 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
212219 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
213220 store, _ := newMockStore(t)
214221 WithStore(store)(d)
222+ checkpoint := domain.CheckpointRef{Generation: generation.Generation, ManifestDigest: snapcodec.ManifestDigest(generation.Manifest)}
223+ record := domain.CatalogueRecord{Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd", CreatedAt: int64(snapshot.CreatedAt), RecoveryState: domain.RecoveryHealthy, Committed: &checkpoint}
224+ catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
225+ WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)
215226 WithSnapshotRepository(repository, nil)(d)
216227 t.Cleanup(func() { release(); d.sessWg.Wait() })
217228
@@ -249,16 +260,6 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
249260 func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) {
250261 snapshot := restoreAcceptanceSession(t, "fallback")
251262 valid := acceptanceGeneration(t, snapshot, 3)
252- pty, release := newBlockingPTY(t)
253- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
254- store, _ := newMockStore(t)
255- WithStore(store)(d)
256- repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: valid}}
257- WithSnapshotRepository(repository, nil)(d)
258- t.Cleanup(func() { release(); d.sessWg.Wait() })
259- d.restoreIncrementalSnapshots(context.Background())
260- require.Len(t, d.notices.history(), 1)
261- require.Equal(t, domain.NoticeWarn, d.notices.history()[0].Severity)
262263
263264 for _, mutate := range []struct {
264265 name string
@@ -18,6 +18,7 @@ import (
18 )18 )
19 19
20 type snapshotAcceptanceRepository struct {20 type snapshotAcceptanceRepository struct {
21+ noOpSnapshotRepository
21 mu sync.Mutex22 mu sync.Mutex
22 names []string23 names []string
23 generations map[string]ports.SnapshotGeneration24 generations map[string]ports.SnapshotGeneration
@@ -42,7 +43,7 @@ func (r *snapshotAcceptanceRepository) Publish(ctx context.Context, p ports.Snap
42 for _, object := range p.Objects {43 for _, object := range p.Objects {
43 objects[object.Digest] = append([]byte(nil), object.Data...)44 objects[object.Digest] = append([]byte(nil), object.Data...)
44 }45 }
45- r.generations[p.Name] = ports.SnapshotGeneration{Name: p.Name, Generation: p.Generation, Manifest: append([]byte(nil), p.Manifest...), Objects: objects}46+ r.generations[p.Name] = ports.SnapshotGeneration{IncarnationID: p.IncarnationID, Name: p.Name, Generation: p.Generation, ParentCheckpoint: p.ParentCheckpoint, Manifest: append([]byte(nil), p.Manifest...), Objects: objects}
46 found := false47 found := false
47 for _, name := range r.names {48 for _, name := range r.names {
48 found = found || name == p.Name49 found = found || name == p.Name
@@ -85,10 +86,16 @@ func (r *snapshotAcceptanceRepository) Load(ctx context.Context, name string) (p
85 return cloneAcceptanceGeneration(generation), nil86 return cloneAcceptanceGeneration(generation), nil
86 }87 }
87 88
88-func (*snapshotAcceptanceRepository) Delete(context.Context, string) error { return nil }89+func (r *snapshotAcceptanceRepository) LoadCheckpoint(ctx context.Context, id domain.IncarnationID, name string, ref ports.CheckpointRef) (ports.SnapshotGeneration, error) {
89-func (*snapshotAcceptanceRepository) Tombstone(context.Context, string) error { return nil }90+ generation, err := r.Load(ctx, name)
90-func (*snapshotAcceptanceRepository) DeleteTombstone(context.Context, string) error { return nil }91+ if err != nil {
91-func (*snapshotAcceptanceRepository) Maintain(context.Context) error { return nil }92+ return ports.SnapshotGeneration{}, err
93+ }
94+ if generation.IncarnationID != id || generation.Generation != ref.Generation || snapcodec.ManifestDigest(generation.Manifest) != ref.ManifestDigest {
95+ return ports.SnapshotGeneration{}, errors.New("checkpoint unavailable")
96+ }
97+ return generation, nil
98+}
92 99
93 func TestSnapshotAcceptanceRepositoryLoadMutationDoesNotChangeStoredGeneration(t *testing.T) {100 func TestSnapshotAcceptanceRepositoryLoadMutationDoesNotChangeStoredGeneration(t *testing.T) {
94 digest := ports.SnapshotDigest{1}101 digest := ports.SnapshotDigest{1}
@@ -178,7 +185,7 @@ func acceptanceGeneration(t *testing.T, snapshot snapcodec.Session, generation u
178 for _, object := range publication.Objects {185 for _, object := range publication.Objects {
179 objects[object.Digest] = append([]byte(nil), object.Data...)186 objects[object.Digest] = append([]byte(nil), object.Data...)
180 }187 }
181- return ports.SnapshotGeneration{Name: snapshot.Name, Generation: generation, Manifest: encoded, Objects: objects}188+ return ports.SnapshotGeneration{IncarnationID: publication.IncarnationID, Name: snapshot.Name, Generation: generation, ParentCheckpoint: publication.ParentCheckpoint, Manifest: encoded, Objects: objects}
182 }189 }
183 190
184 func TestValidateRestoreSessionSnapshot(t *testing.T) {191 func TestValidateRestoreSessionSnapshot(t *testing.T) {
@@ -212,6 +219,10 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
212 d := newTestDaemon(t, newFactory(t, pty), stubClock{})219 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
213 store, _ := newMockStore(t)220 store, _ := newMockStore(t)
214 WithStore(store)(d)221 WithStore(store)(d)
222+ checkpoint := domain.CheckpointRef{Generation: generation.Generation, ManifestDigest: snapcodec.ManifestDigest(generation.Manifest)}
223+ record := domain.CatalogueRecord{Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd", CreatedAt: int64(snapshot.CreatedAt), RecoveryState: domain.RecoveryHealthy, Committed: &checkpoint}
224+ catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
225+ WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)
215 WithSnapshotRepository(repository, nil)(d)226 WithSnapshotRepository(repository, nil)(d)
216 t.Cleanup(func() { release(); d.sessWg.Wait() })227 t.Cleanup(func() { release(); d.sessWg.Wait() })
217 228
@@ -249,16 +260,6 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
249 func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) {260 func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) {
250 snapshot := restoreAcceptanceSession(t, "fallback")261 snapshot := restoreAcceptanceSession(t, "fallback")
251 valid := acceptanceGeneration(t, snapshot, 3)262 valid := acceptanceGeneration(t, snapshot, 3)
252- pty, release := newBlockingPTY(t)
253- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
254- store, _ := newMockStore(t)
255- WithStore(store)(d)
256- repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: valid}}
257- WithSnapshotRepository(repository, nil)(d)
258- t.Cleanup(func() { release(); d.sessWg.Wait() })
259- d.restoreIncrementalSnapshots(context.Background())
260- require.Len(t, d.notices.history(), 1)
261- require.Equal(t, domain.NoticeWarn, d.notices.history()[0].Severity)
262 263
263 for _, mutate := range []struct {264 for _, mutate := range []struct {
264 name string265 name string
M internal/usecase/daemon/snapshot_test.go +38 -0
@@ -53,6 +53,44 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin
5353 return sess
5454 }
5555
56+// noOpSnapshotRepository supplies the current durable repository contract to
57+// focused test sinks that only need to observe publication or deletion calls.
58+type noOpSnapshotRepository struct{}
59+
60+func (noOpSnapshotRepository) LoadCheckpoint(context.Context, domain.IncarnationID, string, ports.CheckpointRef) (ports.SnapshotGeneration, error) {
61+ return ports.SnapshotGeneration{}, errors.New("unused")
62+}
63+func (noOpSnapshotRepository) RepairHEAD(context.Context, domain.IncarnationID, ports.CheckpointRef) error {
64+ return nil
65+}
66+func (noOpSnapshotRepository) WriteDeletionTombstone(context.Context, domain.DeletionTombstone) error {
67+ return nil
68+}
69+func (noOpSnapshotRepository) ListDeletionTombstones(context.Context, ports.DeletionTombstoneCursor, ports.MaintenanceBudget) (ports.DeletionTombstonePage, error) {
70+ return ports.DeletionTombstonePage{Done: true}, nil
71+}
72+func (noOpSnapshotRepository) QuarantineDeletionSources(context.Context, domain.DeletionTombstone, bool) error {
73+ return nil
74+}
75+func (noOpSnapshotRepository) DeleteDeletionTombstone(context.Context, domain.IncarnationID) error {
76+ return nil
77+}
78+func (noOpSnapshotRepository) SaveQuarantineDescriptor(context.Context, domain.QuarantineDescriptor) error {
79+ return nil
80+}
81+func (noOpSnapshotRepository) QuarantineIncarnation(context.Context, domain.IncarnationID) error {
82+ return nil
83+}
84+func (noOpSnapshotRepository) DeleteIncarnation(context.Context, domain.IncarnationID) error {
85+ return nil
86+}
87+func (noOpSnapshotRepository) MaintainSession(context.Context, ports.RetentionPlan, ports.MaintenanceBudget) (bool, error) {
88+ return true, nil
89+}
90+func (noOpSnapshotRepository) Reconcile(context.Context, []domain.CatalogueRecord, ports.ReconcileCursor, ports.MaintenanceBudget) (ports.ReconcileCursor, []ports.ReconcileFinding, error) {
91+ return ports.ReconcileCursor{}, nil, nil
92+}
93+
5694 func awaitSnapshotIdle(t testing.TB, sess *session) {
5795 t.Helper()
5896 timer := time.NewTimer(testWaitTimeout)
@@ -53,6 +53,44 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin
53 return sess53 return sess
54 }54 }
55 55
56+// noOpSnapshotRepository supplies the current durable repository contract to
57+// focused test sinks that only need to observe publication or deletion calls.
58+type noOpSnapshotRepository struct{}
59+
60+func (noOpSnapshotRepository) LoadCheckpoint(context.Context, domain.IncarnationID, string, ports.CheckpointRef) (ports.SnapshotGeneration, error) {
61+ return ports.SnapshotGeneration{}, errors.New("unused")
62+}
63+func (noOpSnapshotRepository) RepairHEAD(context.Context, domain.IncarnationID, ports.CheckpointRef) error {
64+ return nil
65+}
66+func (noOpSnapshotRepository) WriteDeletionTombstone(context.Context, domain.DeletionTombstone) error {
67+ return nil
68+}
69+func (noOpSnapshotRepository) ListDeletionTombstones(context.Context, ports.DeletionTombstoneCursor, ports.MaintenanceBudget) (ports.DeletionTombstonePage, error) {
70+ return ports.DeletionTombstonePage{Done: true}, nil
71+}
72+func (noOpSnapshotRepository) QuarantineDeletionSources(context.Context, domain.DeletionTombstone, bool) error {
73+ return nil
74+}
75+func (noOpSnapshotRepository) DeleteDeletionTombstone(context.Context, domain.IncarnationID) error {
76+ return nil
77+}
78+func (noOpSnapshotRepository) SaveQuarantineDescriptor(context.Context, domain.QuarantineDescriptor) error {
79+ return nil
80+}
81+func (noOpSnapshotRepository) QuarantineIncarnation(context.Context, domain.IncarnationID) error {
82+ return nil
83+}
84+func (noOpSnapshotRepository) DeleteIncarnation(context.Context, domain.IncarnationID) error {
85+ return nil
86+}
87+func (noOpSnapshotRepository) MaintainSession(context.Context, ports.RetentionPlan, ports.MaintenanceBudget) (bool, error) {
88+ return true, nil
89+}
90+func (noOpSnapshotRepository) Reconcile(context.Context, []domain.CatalogueRecord, ports.ReconcileCursor, ports.MaintenanceBudget) (ports.ReconcileCursor, []ports.ReconcileFinding, error) {
91+ return ports.ReconcileCursor{}, nil, nil
92+}
93+
56 func awaitSnapshotIdle(t testing.TB, sess *session) {94 func awaitSnapshotIdle(t testing.TB, sess *session) {
57 t.Helper()95 t.Helper()
58 timer := time.NewTimer(testWaitTimeout)96 timer := time.NewTimer(testWaitTimeout)

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