diff --git a/.changes/unreleased/Added-20260602-160809.yaml b/.changes/unreleased/Added-20260602-160809.yaml new file mode 100644 index 000000000..ef44b242d --- /dev/null +++ b/.changes/unreleased/Added-20260602-160809.yaml @@ -0,0 +1,3 @@ +kind: Added +body: 'integration: Add storage model for integration branch state (internal)' +time: 2026-06-02T16:08:09.643553-04:00 diff --git a/internal/spice/branch.go b/internal/spice/branch.go index ff66d4c85..51fef9326 100644 --- a/internal/spice/branch.go +++ b/internal/spice/branch.go @@ -381,6 +381,40 @@ func (s *Service) RenameBranch(ctx context.Context, oldName, newName string) err } s.log.Debug("Renamed tracked name of branch", "old", oldName, "new", newName) + if err := s.renameIntegrationTip(ctx, oldName, newName); err != nil { + // Branch rename already succeeded; surface as warning only. + s.log.Warnf("integration tip rename: %v", err) + } + + return nil +} + +// renameIntegrationTip rewrites any integration tip that references +// oldName to use newName instead. +func (s *Service) renameIntegrationTip(ctx context.Context, oldName, newName string) error { + info, err := s.store.Integration(ctx) + if errors.Is(err, state.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("get integration: %w", err) + } + + var updated bool + for i, tip := range info.Tips { + if tip.Name == oldName { + info.Tips[i].Name = newName + updated = true + } + } + if !updated { + return nil + } + + if err := s.store.SetIntegration(ctx, info); err != nil { + return fmt.Errorf("save integration: %w", err) + } + s.log.Debug("Renamed integration tip", "old", oldName, "new", newName) return nil } diff --git a/internal/spice/mock_service_test.go b/internal/spice/mock_service_test.go index ea07300aa..9cb451717 100644 --- a/internal/spice/mock_service_test.go +++ b/internal/spice/mock_service_test.go @@ -342,6 +342,21 @@ func (mr *MockStoreMockRecorder) CacheTemplates(arg0, arg1, arg2 any) *gomock.Ca return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CacheTemplates", reflect.TypeOf((*MockStore)(nil).CacheTemplates), arg0, arg1, arg2) } +// Integration mocks base method. +func (m *MockStore) Integration(ctx context.Context) (*state.IntegrationInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Integration", ctx) + ret0, _ := ret[0].(*state.IntegrationInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Integration indicates an expected call of Integration. +func (mr *MockStoreMockRecorder) Integration(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Integration", reflect.TypeOf((*MockStore)(nil).Integration), ctx) +} + // ListBranches mocks base method. func (m *MockStore) ListBranches(ctx context.Context) iter.Seq2[string, error] { m.ctrl.T.Helper() @@ -402,6 +417,20 @@ func (mr *MockStoreMockRecorder) Remote() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Remote", reflect.TypeOf((*MockStore)(nil).Remote)) } +// SetIntegration mocks base method. +func (m *MockStore) SetIntegration(ctx context.Context, info *state.IntegrationInfo) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetIntegration", ctx, info) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetIntegration indicates an expected call of SetIntegration. +func (mr *MockStoreMockRecorder) SetIntegration(ctx, info any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetIntegration", reflect.TypeOf((*MockStore)(nil).SetIntegration), ctx, info) +} + // TakeContinuations mocks base method. func (m *MockStore) TakeContinuations(arg0 context.Context, arg1 string) ([]state.Continuation, error) { m.ctrl.T.Helper() diff --git a/internal/spice/service.go b/internal/spice/service.go index 7297cd19f..dd2da4c36 100644 --- a/internal/spice/service.go +++ b/internal/spice/service.go @@ -87,6 +87,14 @@ type Store interface { LoadCachedTemplates(context.Context) (string, []*state.CachedTemplate, error) CacheTemplates(context.Context, string, []*state.CachedTemplate) error + + // Integration returns the configured integration branch, + // or [state.ErrNotExist] if none is configured. + Integration(ctx context.Context) (*state.IntegrationInfo, error) + + // SetIntegration writes the integration branch configuration. + // Pass nil to clear. + SetIntegration(ctx context.Context, info *state.IntegrationInfo) error } var _ Store = (*state.Store)(nil) diff --git a/internal/spice/state/integration_rebuild.go b/internal/spice/state/integration_rebuild.go new file mode 100644 index 000000000..a5638e9ca --- /dev/null +++ b/internal/spice/state/integration_rebuild.go @@ -0,0 +1,93 @@ +package state + +import ( + "context" + "errors" + "fmt" + + "go.abhg.dev/gs/internal/git" +) + +const _integrationRebuildJSON = "integration-rebuild" + +type integrationRebuildState struct { + Integration string `json:"integration"` + Tips []integrationRebuildTip `json:"tips"` + NextTipIndex int `json:"nextTipIndex"` +} + +type integrationRebuildTip struct { + Name string `json:"name"` + Hash string `json:"hash"` +} + +// IntegrationRebuild describes an in-progress integration rebuild that +// was paused (typically due to a merge conflict). Its presence signals +// that the next [gs integration rebuild] invocation should resume +// rather than start fresh. +type IntegrationRebuild struct { + // Integration is the local name of the integration branch. + Integration string + + // Tips holds the full set of tips for the rebuild, with the hashes + // captured at the start of the original attempt. Indexed positions + // align with [NextTipIndex]. + Tips []IntegrationTip + + // NextTipIndex points to the first tip that has not yet been + // started. Tips with index < NextTipIndex have been merged + // successfully (or the merge is in progress in the worktree, in + // the case of the most recently attempted one). + NextTipIndex int +} + +// PendingIntegrationRebuild returns the saved pending rebuild, or +// [ErrNotExist] if no rebuild is in progress. +func (s *Store) PendingIntegrationRebuild(ctx context.Context) (*IntegrationRebuild, error) { + var st integrationRebuildState + if err := s.db.Get(ctx, _integrationRebuildJSON, &st); err != nil { + if errors.Is(err, ErrNotExist) { + return nil, ErrNotExist + } + return nil, fmt.Errorf("get pending rebuild: %w", err) + } + + tips := make([]IntegrationTip, len(st.Tips)) + for i, t := range st.Tips { + tips[i] = IntegrationTip{Name: t.Name, Hash: git.Hash(t.Hash)} + } + return &IntegrationRebuild{ + Integration: st.Integration, + Tips: tips, + NextTipIndex: st.NextTipIndex, + }, nil +} + +// SetPendingIntegrationRebuild records a pending integration rebuild. +func (s *Store) SetPendingIntegrationRebuild(ctx context.Context, rb *IntegrationRebuild) error { + tips := make([]integrationRebuildTip, len(rb.Tips)) + for i, t := range rb.Tips { + tips[i] = integrationRebuildTip{Name: t.Name, Hash: t.Hash.String()} + } + st := integrationRebuildState{ + Integration: rb.Integration, + Tips: tips, + NextTipIndex: rb.NextTipIndex, + } + if err := s.db.Set(ctx, _integrationRebuildJSON, st, "save pending integration rebuild"); err != nil { + return fmt.Errorf("save pending rebuild: %w", err) + } + return nil +} + +// ClearPendingIntegrationRebuild removes any saved pending rebuild. +// No-op if none is saved. +func (s *Store) ClearPendingIntegrationRebuild(ctx context.Context) error { + if err := s.db.Delete(ctx, _integrationRebuildJSON, "clear pending integration rebuild"); err != nil { + if errors.Is(err, ErrNotExist) { + return nil + } + return fmt.Errorf("clear pending rebuild: %w", err) + } + return nil +} diff --git a/internal/spice/state/integration_rebuild_test.go b/internal/spice/state/integration_rebuild_test.go new file mode 100644 index 000000000..00606f457 --- /dev/null +++ b/internal/spice/state/integration_rebuild_test.go @@ -0,0 +1,77 @@ +package state_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.abhg.dev/gs/internal/silog/silogtest" + "go.abhg.dev/gs/internal/spice/state" + "go.abhg.dev/gs/internal/spice/state/storage" +) + +func TestStore_PendingIntegrationRebuild_notExist(t *testing.T) { + mem := make(storage.MapBackend) + _, err := state.InitStore(t.Context(), state.InitStoreRequest{ + DB: storage.NewDB(mem), + Trunk: "main", + }) + require.NoError(t, err) + + store, err := state.OpenStore(t.Context(), storage.NewDB(mem), silogtest.New(t)) + require.NoError(t, err) + + _, err = store.PendingIntegrationRebuild(t.Context()) + assert.ErrorIs(t, err, state.ErrNotExist) +} + +func TestStore_SetPendingIntegrationRebuild_roundTrip(t *testing.T) { + mem := make(storage.MapBackend) + _, err := state.InitStore(t.Context(), state.InitStoreRequest{ + DB: storage.NewDB(mem), + Trunk: "main", + }) + require.NoError(t, err) + + store, err := state.OpenStore(t.Context(), storage.NewDB(mem), silogtest.New(t)) + require.NoError(t, err) + + want := &state.IntegrationRebuild{ + Integration: "preview", + Tips: []state.IntegrationTip{ + {Name: "feat-a", Hash: "hash-a"}, + {Name: "feat-b", Hash: "hash-b"}, + {Name: "feat-c", Hash: "hash-c"}, + }, + NextTipIndex: 2, + } + require.NoError(t, store.SetPendingIntegrationRebuild(t.Context(), want)) + + got, err := store.PendingIntegrationRebuild(t.Context()) + require.NoError(t, err) + assert.Equal(t, want, got) +} + +func TestStore_ClearPendingIntegrationRebuild(t *testing.T) { + mem := make(storage.MapBackend) + _, err := state.InitStore(t.Context(), state.InitStoreRequest{ + DB: storage.NewDB(mem), + Trunk: "main", + }) + require.NoError(t, err) + + store, err := state.OpenStore(t.Context(), storage.NewDB(mem), silogtest.New(t)) + require.NoError(t, err) + + require.NoError(t, store.SetPendingIntegrationRebuild(t.Context(), &state.IntegrationRebuild{ + Integration: "preview", + })) + + require.NoError(t, store.ClearPendingIntegrationRebuild(t.Context())) + + _, err = store.PendingIntegrationRebuild(t.Context()) + assert.ErrorIs(t, err, state.ErrNotExist) + + // Idempotent + require.NoError(t, store.ClearPendingIntegrationRebuild(t.Context())) +} diff --git a/internal/spice/state/integration_test.go b/internal/spice/state/integration_test.go new file mode 100644 index 000000000..ec01fc236 --- /dev/null +++ b/internal/spice/state/integration_test.go @@ -0,0 +1,186 @@ +package state_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.abhg.dev/gs/internal/silog/silogtest" + "go.abhg.dev/gs/internal/spice/state" + "go.abhg.dev/gs/internal/spice/state/storage" +) + +func TestStore_Integration_notConfigured(t *testing.T) { + mem := make(storage.MapBackend) + _, err := state.InitStore(t.Context(), state.InitStoreRequest{ + DB: storage.NewDB(mem), + Trunk: "main", + }) + require.NoError(t, err) + + store, err := state.OpenStore(t.Context(), storage.NewDB(mem), silogtest.New(t)) + require.NoError(t, err) + + _, err = store.Integration(t.Context()) + assert.ErrorIs(t, err, state.ErrNotExist) +} + +func TestStore_SetIntegration(t *testing.T) { + mem := make(storage.MapBackend) + _, err := state.InitStore(t.Context(), state.InitStoreRequest{ + DB: storage.NewDB(mem), + Trunk: "main", + }) + require.NoError(t, err) + + store, err := state.OpenStore(t.Context(), storage.NewDB(mem), silogtest.New(t)) + require.NoError(t, err) + + want := &state.IntegrationInfo{ + Name: "preview", + UpstreamBranch: "preview", + Tips: []state.IntegrationTip{ + {Name: "feat-a", Hash: "abc123"}, + {Name: "feat-b", Hash: "def456"}, + }, + } + require.NoError(t, store.SetIntegration(t.Context(), want)) + + t.Run("VersionBumpedToThree", func(t *testing.T) { + assert.JSONEq(t, `3`, string(mem["version"])) + }) + + t.Run("ReopenAndGet", func(t *testing.T) { + reopened, err := state.OpenStore(t.Context(), storage.NewDB(mem), silogtest.New(t)) + require.NoError(t, err) + + got, err := reopened.Integration(t.Context()) + require.NoError(t, err) + assert.Equal(t, want, got) + }) +} + +func TestStore_SetIntegration_clear(t *testing.T) { + mem := make(storage.MapBackend) + _, err := state.InitStore(t.Context(), state.InitStoreRequest{ + DB: storage.NewDB(mem), + Trunk: "main", + }) + require.NoError(t, err) + + store, err := state.OpenStore(t.Context(), storage.NewDB(mem), silogtest.New(t)) + require.NoError(t, err) + + require.NoError(t, store.SetIntegration(t.Context(), &state.IntegrationInfo{ + Name: "preview", + Tips: []state.IntegrationTip{{Name: "feat-a"}}, + })) + + // Version was bumped to 3. + assert.JSONEq(t, `3`, string(mem["version"])) + + // Clear it. + require.NoError(t, store.SetIntegration(t.Context(), nil)) + + // Version should drop back to 1 (no remote, no integration). + assert.JSONEq(t, `1`, string(mem["version"])) + + _, err = store.Integration(t.Context()) + assert.ErrorIs(t, err, state.ErrNotExist) +} + +func TestStore_SetIntegration_validate(t *testing.T) { + mem := make(storage.MapBackend) + _, err := state.InitStore(t.Context(), state.InitStoreRequest{ + DB: storage.NewDB(mem), + Trunk: "main", + }) + require.NoError(t, err) + + store, err := state.OpenStore(t.Context(), storage.NewDB(mem), silogtest.New(t)) + require.NoError(t, err) + + tests := []struct { + name string + give *state.IntegrationInfo + wantErr string + }{ + { + name: "empty name", + give: &state.IntegrationInfo{Name: ""}, + wantErr: "integration branch name is empty", + }, + { + name: "name equals trunk", + give: &state.IntegrationInfo{Name: "main"}, + wantErr: "must not equal trunk", + }, + { + name: "tip equals trunk", + give: &state.IntegrationInfo{ + Name: "preview", + Tips: []state.IntegrationTip{{Name: "main"}}, + }, + wantErr: "tip must not equal trunk", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := store.SetIntegration(t.Context(), tt.give) + require.Error(t, err) + assert.ErrorContains(t, err, tt.wantErr) + }) + } +} + +func TestOpenStore_versionThree(t *testing.T) { + mem := storage.MapBackend{ + "version": []byte("3"), + "repo": []byte(`{ + "trunk": "main", + "integration": { + "name": "preview", + "tips": [{"name": "feat-a", "hash": "abc"}] + } + }`), + } + + store, err := state.OpenStore(t.Context(), storage.NewDB(mem), silogtest.New(t)) + require.NoError(t, err) + + got, err := store.Integration(t.Context()) + require.NoError(t, err) + assert.Equal(t, &state.IntegrationInfo{ + Name: "preview", + Tips: []state.IntegrationTip{{Name: "feat-a", Hash: "abc"}}, + }, got) +} + +func TestStore_SetIntegration_preservesRemote(t *testing.T) { + mem := make(storage.MapBackend) + _, err := state.InitStore(t.Context(), state.InitStoreRequest{ + DB: storage.NewDB(mem), + Trunk: "main", + Remote: state.Remote{ + Upstream: "upstream", + Push: "origin", + }, + }) + require.NoError(t, err) + + store, err := state.OpenStore(t.Context(), storage.NewDB(mem), silogtest.New(t)) + require.NoError(t, err) + + require.NoError(t, store.SetIntegration(t.Context(), &state.IntegrationInfo{ + Name: "preview", + })) + + // Remote should still be intact. + gotRemote, err := store.Remote() + require.NoError(t, err) + assert.Equal(t, state.Remote{Upstream: "upstream", Push: "origin"}, gotRemote) + + // And version is 3 (since integration overrides v2). + assert.JSONEq(t, `3`, string(mem["version"])) +} diff --git a/internal/spice/state/repo.go b/internal/spice/state/repo.go index 8fac92c33..68cad5b7c 100644 --- a/internal/spice/state/repo.go +++ b/internal/spice/state/repo.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" + "go.abhg.dev/gs/internal/git" "go.abhg.dev/gs/internal/spice/state/storage" ) @@ -37,9 +38,29 @@ func newRemoteInfo(remote Remote) remoteInfo { } type repoInfo struct { - Trunk string `json:"trunk"` - Remote string `json:"remote,omitempty"` - Remotes *remoteInfo `json:"remotes,omitempty"` + Trunk string `json:"trunk"` + Remote string `json:"remote,omitempty"` + Remotes *remoteInfo `json:"remotes,omitempty"` + Integration *integrationInfo `json:"integration,omitempty"` +} + +// integrationInfo persists the configured integration branch. +// +// An integration branch is a separate, repo-scoped singleton: +// it combines the tips of multiple tracked branches by sequentially +// merging them onto trunk. It is not a tracked stack branch. +type integrationInfo struct { + Name string `json:"name"` + UpstreamBranch string `json:"upstreamBranch,omitempty"` + LastPushedHash string `json:"lastPushedHash,omitempty"` + Tips []integrationTipInfo `json:"tips,omitempty"` +} + +// integrationTipInfo identifies a branch whose tip composes the integration +// branch, along with the hash recorded at the last successful rebuild. +type integrationTipInfo struct { + Name string `json:"name"` + Hash string `json:"hash,omitempty"` } func newRepoInfo(trunk string, remote Remote) repoInfo { @@ -81,9 +102,10 @@ func (i *repoInfo) stateRemote() Remote { func (i *repoInfo) UnmarshalJSON(data []byte) error { var raw struct { - Trunk string `json:"trunk"` - Remote json.RawMessage `json:"remote"` - Remotes *remoteInfo `json:"remotes"` + Trunk string `json:"trunk"` + Remote json.RawMessage `json:"remote"` + Remotes *remoteInfo `json:"remotes"` + Integration *integrationInfo `json:"integration"` } if err := json.Unmarshal(data, &raw); err != nil { return err @@ -91,6 +113,7 @@ func (i *repoInfo) UnmarshalJSON(data []byte) error { i.Trunk = raw.Trunk i.Remotes = raw.Remotes + i.Integration = raw.Integration if len(raw.Remote) == 0 || string(raw.Remote) == "null" { return nil } @@ -115,6 +138,32 @@ func (i *repoInfo) Validate() error { if i.Trunk == "" { return errors.New("trunk branch name is empty") } + if i.Integration != nil { + if err := i.Integration.validate(i.Trunk); err != nil { + return err + } + } + return nil +} + +func (n *integrationInfo) validate(trunk string) error { + if n.Name == "" { + return errors.New("integration branch name is empty") + } + if n.Name == trunk { + return errors.New("integration branch name must not equal trunk") + } + for _, tip := range n.Tips { + if tip.Name == "" { + return errors.New("integration tip name is empty") + } + if tip.Name == trunk { + return errors.New("integration tip must not equal trunk") + } + if tip.Name == n.Name { + return errors.New("integration tip must not equal integration branch name") + } + } return nil } @@ -139,7 +188,9 @@ func (s *Store) SetRemote(ctx context.Context, remote Remote) error { if err := s.db.Get(ctx, _repoJSON, &info); err != nil { return fmt.Errorf("get repo info: %w", err) } + integration := info.Integration info = newRepoInfo(info.Trunk, remote) + info.Integration = integration if err := info.Validate(); err != nil { // Technically impossible if state was already validated @@ -147,7 +198,6 @@ func (s *Store) SetRemote(ctx context.Context, remote Remote) error { return fmt.Errorf("would corrupt state: %w", err) } - version := storageVersionForRemote(remote) if err := s.db.Update(ctx, storage.UpdateRequest{ Sets: []storage.SetRequest{ { @@ -156,7 +206,7 @@ func (s *Store) SetRemote(ctx context.Context, remote Remote) error { }, { Key: _versionFile, - Value: version, + Value: storageVersion(info), }, }, Message: fmt.Sprintf("set remote: %v", remote), @@ -167,3 +217,121 @@ func (s *Store) SetRemote(ctx context.Context, remote Remote) error { return nil } + +// IntegrationInfo describes the configured integration branch. +// +// An integration branch is a repo-scoped singleton that is rebuilt by +// sequentially merging configured tip branches onto trunk. It is +// distinct from tracked stack branches. +type IntegrationInfo struct { + // Name is the local branch name of the integration branch. + Name string + + // UpstreamBranch is the remote branch name when pushed. + // Defaults to Name if empty. + UpstreamBranch string + + // LastPushedHash is the integration branch hash at the last successful + // push. Used for --force-with-lease on subsequent submits. An empty + // value indicates the branch has never been submitted. + LastPushedHash git.Hash + + // Tips lists the branches whose tips compose the integration branch. + Tips []IntegrationTip +} + +// IntegrationTip identifies a branch whose tip is merged into the +// integration branch, along with the hash recorded at the last +// successful rebuild. +type IntegrationTip struct { + // Name is the local branch name of the tip. + Name string + + // Hash is the tip's hash at the last successful integration rebuild. + // Compared against the current hash to detect drift. + Hash git.Hash +} + +// Integration returns the configured integration branch, or +// [ErrNotExist] if none is configured. +func (s *Store) Integration(ctx context.Context) (*IntegrationInfo, error) { + var info repoInfo + if err := s.db.Get(ctx, _repoJSON, &info); err != nil { + return nil, fmt.Errorf("get repo info: %w", err) + } + if info.Integration == nil { + return nil, ErrNotExist + } + return integrationInfoToPublic(info.Integration), nil +} + +// SetIntegration writes the integration branch configuration. +// Pass nil to clear the configuration. +func (s *Store) SetIntegration(ctx context.Context, integration *IntegrationInfo) error { + var info repoInfo + if err := s.db.Get(ctx, _repoJSON, &info); err != nil { + return fmt.Errorf("get repo info: %w", err) + } + + if integration == nil { + info.Integration = nil + } else { + info.Integration = integrationInfoFromPublic(integration) + } + + if err := info.Validate(); err != nil { + return fmt.Errorf("would corrupt state: %w", err) + } + + msg := "set integration" + if integration == nil { + msg = "clear integration" + } + + if err := s.db.Update(ctx, storage.UpdateRequest{ + Sets: []storage.SetRequest{ + { + Key: _repoJSON, + Value: info, + }, + { + Key: _versionFile, + Value: storageVersion(info), + }, + }, + Message: msg, + }); err != nil { + return fmt.Errorf("update: %w", err) + } + + return nil +} + +func integrationInfoToPublic(in *integrationInfo) *IntegrationInfo { + tips := make([]IntegrationTip, len(in.Tips)) + for i, t := range in.Tips { + tips[i] = IntegrationTip{Name: t.Name, Hash: git.Hash(t.Hash)} + } + return &IntegrationInfo{ + Name: in.Name, + UpstreamBranch: in.UpstreamBranch, + LastPushedHash: git.Hash(in.LastPushedHash), + Tips: tips, + } +} + +func integrationInfoFromPublic(in *IntegrationInfo) *integrationInfo { + var tips []integrationTipInfo + if len(in.Tips) > 0 { + tips = make([]integrationTipInfo, len(in.Tips)) + for i, t := range in.Tips { + tips[i] = integrationTipInfo{Name: t.Name, Hash: t.Hash.String()} + } + } + return &integrationInfo{ + Name: in.Name, + UpstreamBranch: in.UpstreamBranch, + LastPushedHash: in.LastPushedHash.String(), + Tips: tips, + } +} diff --git a/internal/spice/state/repo_test.go b/internal/spice/state/repo_test.go index 2d86c4327..6d8396b61 100644 --- a/internal/spice/state/repo_test.go +++ b/internal/spice/state/repo_test.go @@ -49,6 +49,48 @@ func TestRepoInfoUnmarshalJSON(t *testing.T) { give: `{"trunk":"main","remote":""}`, want: repoInfo{Trunk: "main"}, }, + { + name: "integration full", + give: `{ + "trunk": "main", + "integration": { + "name": "preview", + "upstreamBranch": "preview", + "lastPushedHash": "abc123", + "tips": [ + {"name": "feat-a", "hash": "def456"}, + {"name": "feat-b", "hash": "789abc"} + ] + } + }`, + want: repoInfo{ + Trunk: "main", + Integration: &integrationInfo{ + Name: "preview", + UpstreamBranch: "preview", + LastPushedHash: "abc123", + Tips: []integrationTipInfo{ + {Name: "feat-a", Hash: "def456"}, + {Name: "feat-b", Hash: "789abc"}, + }, + }, + }, + }, + { + name: "integration minimal", + give: `{ + "trunk": "main", + "integration": { + "name": "preview" + } + }`, + want: repoInfo{ + Trunk: "main", + Integration: &integrationInfo{ + Name: "preview", + }, + }, + }, } for _, tt := range tests { @@ -124,6 +166,77 @@ func TestRepoInfoValidate(t *testing.T) { }, }, }, + { + name: "valid with integration", + give: repoInfo{ + Trunk: "main", + Integration: &integrationInfo{ + Name: "preview", + Tips: []integrationTipInfo{ + {Name: "feat-a"}, + }, + }, + }, + }, + { + name: "integration empty name", + give: repoInfo{ + Trunk: "main", + Integration: &integrationInfo{ + Name: "", + }, + }, + wantErr: "integration branch name is empty", + }, + { + name: "integration name equals trunk", + give: repoInfo{ + Trunk: "main", + Integration: &integrationInfo{ + Name: "main", + }, + }, + wantErr: "integration branch name must not equal trunk", + }, + { + name: "integration tip empty name", + give: repoInfo{ + Trunk: "main", + Integration: &integrationInfo{ + Name: "preview", + Tips: []integrationTipInfo{ + {Name: ""}, + }, + }, + }, + wantErr: "integration tip name is empty", + }, + { + name: "integration tip equals trunk", + give: repoInfo{ + Trunk: "main", + Integration: &integrationInfo{ + Name: "preview", + Tips: []integrationTipInfo{ + {Name: "main"}, + }, + }, + }, + wantErr: "integration tip must not equal trunk", + }, + { + name: "integration tip equals integration name", + give: repoInfo{ + Trunk: "main", + Integration: &integrationInfo{ + Name: "preview", + Tips: []integrationTipInfo{ + {Name: "preview"}, + }, + }, + }, + wantErr: "integration tip must not equal integration branch name", + }, } for _, tt := range tests { diff --git a/internal/spice/state/store.go b/internal/spice/state/store.go index d646fcada..7e41545e3 100644 --- a/internal/spice/state/store.go +++ b/internal/spice/state/store.go @@ -105,15 +105,16 @@ func InitStore(ctx context.Context, req InitStoreRequest) (*Store, error) { } } + info := newRepoInfo(req.Trunk, store.remote) update := storage.UpdateRequest{ Sets: []storage.SetRequest{ { Key: _repoJSON, - Value: newRepoInfo(req.Trunk, store.remote), + Value: info, }, { Key: _versionFile, - Value: storageVersionForRemote(store.remote), + Value: storageVersion(info), }, }, Message: "initialize store", @@ -202,8 +203,11 @@ func OpenStore(ctx context.Context, db DB, logger *silog.Logger) (*Store, error) }, nil } -func storageVersionForRemote(remote Remote) Version { - if remote.ForkMode() { +func storageVersion(info repoInfo) Version { + if info.Integration != nil { + return VersionThree + } + if info.stateRemote().ForkMode() { return VersionTwo } return VersionOne diff --git a/internal/spice/state/version.go b/internal/spice/state/version.go index 8a21cd744..0041f697b 100644 --- a/internal/spice/state/version.go +++ b/internal/spice/state/version.go @@ -16,11 +16,12 @@ type Version int // Supported versions of the storage layout. const ( - VersionOne Version = 1 - VersionTwo Version = 2 + VersionOne Version = 1 + VersionTwo Version = 2 + VersionThree Version = 3 // LatestVersion refers to the latest supported version. - LatestVersion = VersionTwo + LatestVersion = VersionThree ) // checkVersion verifies that the given version is supported. @@ -28,7 +29,7 @@ func checkVersion(version Version) error { // If/when we make a breaking change to the storage format, // we'll add migration code here. switch version { - case VersionOne, VersionTwo: + case VersionOne, VersionTwo, VersionThree: // ok default: diff --git a/internal/spice/state/version_test.go b/internal/spice/state/version_test.go index d2fe1ed9f..be6beb077 100644 --- a/internal/spice/state/version_test.go +++ b/internal/spice/state/version_test.go @@ -59,6 +59,7 @@ func TestCheckVersion(t *testing.T) { }{ {name: "VersionOne", version: VersionOne}, {name: "VersionTwo", version: VersionTwo}, + {name: "VersionThree", version: VersionThree}, { name: "UnsupportedVersion", version: Version(500),