From dbeafc0cbc56df7c577a6f88a61ae1e3794c5228 Mon Sep 17 00:00:00 2001 From: Edmund Kohlwey Date: Thu, 14 May 2026 10:53:42 -0400 Subject: [PATCH] integration: Add state model for integration branch Introduce repo-scoped integrationInfo carrying a singleton integration branch name, the upstream branch, the last pushed hash for --force-with-lease leasing, and an ordered list of tips with the hashes recorded at the last rebuild. Adds Store.Integration / Store.SetIntegration as the public API and bumps the state storage layout to VersionThree. The version is upgraded only when an integration is configured, so existing repos that never opt in keep V1 or V2. SetRemote preserves integration through remote changes; clearing the integration drops the version back to its prior level. Extends the spice.Service Store interface with Integration and SetIntegration so the service layer can read and write the configuration. spice.Service.RenameBranch now rewrites any integration tip that references the renamed branch; rename failures of the integration update are surfaced as warnings rather than aborting the primary rename. Also adds Store.PendingIntegrationRebuild, Store.SetPendingIntegrationRebuild, and Store.ClearPendingIntegrationRebuild for persisting a rebuild that was paused mid-flight (typically by a merge conflict). The handler uses this to support resuming after the user resolves conflicts in the worktree. --- .../unreleased/Added-20260602-160809.yaml | 3 + internal/spice/branch.go | 34 ++++ internal/spice/mock_service_test.go | 29 +++ internal/spice/service.go | 8 + internal/spice/state/integration_rebuild.go | 93 +++++++++ .../spice/state/integration_rebuild_test.go | 77 ++++++++ internal/spice/state/integration_test.go | 186 ++++++++++++++++++ internal/spice/state/repo.go | 184 ++++++++++++++++- internal/spice/state/repo_test.go | 113 +++++++++++ internal/spice/state/store.go | 12 +- internal/spice/state/version.go | 9 +- internal/spice/state/version_test.go | 1 + 12 files changed, 733 insertions(+), 16 deletions(-) create mode 100644 .changes/unreleased/Added-20260602-160809.yaml create mode 100644 internal/spice/state/integration_rebuild.go create mode 100644 internal/spice/state/integration_rebuild_test.go create mode 100644 internal/spice/state/integration_test.go 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),